Design doc: tines opportunistic shared memory
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
# tines — opportunistic shared memory for mostly-offline robots
|
||||
|
||||
Design document. 2026-10-16.
|
||||
|
||||
## Intent
|
||||
|
||||
Small robots doing their work collect facts about the world. They are
|
||||
mostly offline. When two of them happen to be in range, they should swap
|
||||
what they know so that each robot's picture of the world improves over
|
||||
time. No robot *depends* on receiving anything — it can do its job on its
|
||||
own observations — but there is no reason not to share.
|
||||
|
||||
`tines` is the library that does this: a store of timestamped
|
||||
observations, a belief layer derived from them, and a delay-tolerant
|
||||
exchange protocol that runs over whatever radio or link the robot has.
|
||||
|
||||
The name is from Vinge's *A Fire Upon the Deep*: the Tines only form a
|
||||
mind when close enough to hear each other.
|
||||
|
||||
## Goals
|
||||
|
||||
- A robot's knowledge is a monotonically growing set of observations;
|
||||
meeting another robot can only add to it.
|
||||
- Any two robots holding the same set of observations have identical
|
||||
storage, regardless of the order or path by which they got them.
|
||||
- Exchange is opportunistic, session-free, and tolerant of being cut off
|
||||
at any byte. A half-finished exchange leaves both sides valid.
|
||||
- The same core code runs on a Jetson-class Linux robot, an MCU-class
|
||||
robot, and inside a simulator, with only the transport and storage
|
||||
adapters differing.
|
||||
- The semantics of an observation's payload are opaque to storage and
|
||||
transport. Only the memory layer interprets payloads, and the
|
||||
interpretation can change without touching stored data.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Guaranteed or timely delivery to any particular node. There is no
|
||||
"destination"; every node is a sink.
|
||||
- Routing, custody transfer, or any multi-hop forwarding logic beyond
|
||||
"I carry what I have and give it to whoever I meet". Classic Bundle
|
||||
Protocol (RFC 9171) routing and custody are explicitly not in scope.
|
||||
- Consensus. Robots may hold different beliefs about the same topic;
|
||||
that is acceptable and expected.
|
||||
- Security, authentication, or encryption in v1. Observer IDs are
|
||||
asserted, not proven. (Noted as future work; the ID scheme should not
|
||||
preclude signing later.)
|
||||
- Defining the observation payload schema. That is the application's
|
||||
problem; `tines` ships a byte-string payload and a topic key.
|
||||
|
||||
## Anti-goals
|
||||
|
||||
Things that would make this a failure even if it technically works:
|
||||
|
||||
- Sync behaviour that depends on contact order (robot A ends up with a
|
||||
different store depending on whether it met B or C first).
|
||||
- A core crate that silently requires an allocator, `std`, threads, or a
|
||||
filesystem, making the MCU target an afterthought.
|
||||
- A memory layer that must be synced, or whose state cannot be rebuilt
|
||||
from storage alone.
|
||||
- Exchange that requires a reliable, ordered, long-lived connection.
|
||||
|
||||
## Constraints (decided)
|
||||
|
||||
- **Language:** Rust.
|
||||
- **Targets:** Jetson-class Linux (primary), MCU-class (`no_std`, likely
|
||||
no allocator), and an in-process simulator for development and test.
|
||||
- **Core crate is `no_std`.** `alloc` is an optional feature. Storage and
|
||||
transport are traits; the core contains no I/O.
|
||||
- **PHY-agnostic.** The core never sees a radio. It sees a transport trait
|
||||
(below). Adapters exist outside the core.
|
||||
- **Observations are opaque, timestamped, observer-attributed, and keyed
|
||||
by topic.** The payload shape is deliberately undecided (one candidate
|
||||
is a timestamped RSO observation; it does not matter to this design).
|
||||
|
||||
## Architecture
|
||||
|
||||
Three layers, each a separate concern, top to bottom:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ memory — "my priors": beliefs per │ derived, local, never synced
|
||||
│ topic, computed from │
|
||||
│ storage under a policy │
|
||||
├──────────────────────────────────────────┤
|
||||
│ storage — append-only log of raw │ synced by set-union
|
||||
│ observations; local │
|
||||
│ trimming policy │
|
||||
├──────────────────────────────────────────┤
|
||||
│ comms — contact-driven exchange │ PHY-agnostic; transport
|
||||
│ of observations │ trait + adapters
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Only observations cross the wire. Memory is a pure function of storage
|
||||
plus a policy.
|
||||
|
||||
### Observation (the unit of everything)
|
||||
|
||||
```
|
||||
Observation {
|
||||
id: ObservationId // content-derived hash of the fields below
|
||||
observer: NodeId // who saw it
|
||||
time: Timestamp // when they saw it (observer's clock)
|
||||
topic: Topic // key for grouping/trimming, e.g. "door/3"
|
||||
payload: bytes // opaque to tines
|
||||
}
|
||||
```
|
||||
|
||||
Decisions:
|
||||
|
||||
- `id` is a hash over `(observer, time, topic, payload)`. Two robots that
|
||||
observe the same thing produce two observations; de-duplication is
|
||||
by identity, not by meaning. Union is therefore idempotent and
|
||||
order-independent.
|
||||
- `time` is the observer's local clock. `tines` does not attempt clock
|
||||
sync. Cross-observer recency comparison is a memory-layer policy
|
||||
question and must tolerate skew.
|
||||
- `topic` is a short byte string. Its structure (hierarchy, separators)
|
||||
is application-defined but `tines` supports prefix matching for
|
||||
trimming and queries.
|
||||
- Observations are immutable. There is no update or delete at the
|
||||
protocol level — only local trimming.
|
||||
- Fixed upper bounds on `topic` and `payload` length are a core
|
||||
configuration constant so the MCU target can use fixed-size buffers.
|
||||
*(My call: 64 bytes topic, 1 KiB payload default; configurable at
|
||||
compile time.)*
|
||||
|
||||
### Storage layer
|
||||
|
||||
Responsibilities: hold observations, answer "do you have X", enumerate
|
||||
what's held, enforce a local trimming policy.
|
||||
|
||||
```
|
||||
trait Store {
|
||||
fn insert(&mut self, obs: &Observation) -> Result<Inserted, StoreError>;
|
||||
fn contains(&self, id: &ObservationId) -> bool;
|
||||
fn get(&self, id: &ObservationId) -> Option<Observation>;
|
||||
fn ids(&self) -> impl Iterator<Item = ObservationId>;
|
||||
fn by_topic(&self, prefix: &[u8]) -> impl Iterator<Item = Observation>;
|
||||
fn trim(&mut self, policy: &TrimPolicy) -> usize;
|
||||
}
|
||||
```
|
||||
|
||||
Decisions:
|
||||
|
||||
- Storage is append-only from the protocol's point of view. `trim` is
|
||||
the only removal and is purely local.
|
||||
- Trimming is policy, not semantics: keep the last N per topic, drop
|
||||
older than T, stay under B bytes. Trimming never merges or rewrites
|
||||
observations.
|
||||
- A trimmed observation may be re-received from a peer later. *(My call:
|
||||
accept it; the trim policy will drop it again. Keeping a tombstone set
|
||||
to refuse re-receipt is a possible later optimisation, bounded in size,
|
||||
and must not be required for correctness.)*
|
||||
- Implementations: SQLite-backed (Jetson), flash ring buffer or
|
||||
fixed-size table (MCU), in-memory (simulator and tests). The core
|
||||
ships the in-memory one.
|
||||
|
||||
### Memory layer
|
||||
|
||||
Responsibilities: answer "what do I believe about topic X" by folding the
|
||||
relevant observations under a policy.
|
||||
|
||||
```
|
||||
trait BeliefPolicy<B> {
|
||||
fn fold(&self, me: NodeId, obs: impl Iterator<Item = Observation>) -> B;
|
||||
}
|
||||
```
|
||||
|
||||
Decisions:
|
||||
|
||||
- Memory is derived. It can be a cache, but it must be rebuildable from
|
||||
storage at any time and is never transmitted.
|
||||
- Policies are pluggable. Examples the application might implement:
|
||||
most recent wins; own observations outrank others'; per-observer trust
|
||||
weights; observation-count thresholds; a confidence that decays with
|
||||
age. `tines` ships a `LatestWins` and a `SelfFirst` policy as
|
||||
references; anything that needs to parse the payload belongs to the
|
||||
application.
|
||||
- Memory is where disagreement between observations is handled. Storage
|
||||
never resolves conflicts.
|
||||
|
||||
### Communications layer
|
||||
|
||||
Responsibilities: notice a peer, find out what the peer has that we lack
|
||||
(and vice versa), transfer it, survive being interrupted.
|
||||
|
||||
#### Transport trait (the PHY boundary)
|
||||
|
||||
```
|
||||
trait Transport {
|
||||
type Addr;
|
||||
fn poll(&mut self) -> Option<Event<Self::Addr>>; // Contact(addr) | Lost(addr) | Frame(addr, bytes)
|
||||
fn send(&mut self, to: &Self::Addr, frame: &[u8]) -> Result<(), SendError>;
|
||||
fn mtu(&self) -> usize;
|
||||
}
|
||||
```
|
||||
|
||||
Decisions:
|
||||
|
||||
- Datagram semantics, unreliable, unordered, bounded size. Everything
|
||||
above assumes frames can be lost or duplicated.
|
||||
- The core is driven by polling (`poll` / `send`) so it fits both a
|
||||
bare-metal superloop and an async runtime via a thin shim. *(My call:
|
||||
no async in the core.)*
|
||||
- Contact detection is the transport's job (beacon heard, link up, sim
|
||||
says "in range"). The core reacts to `Contact` and `Lost`.
|
||||
- Adapters planned: UDP broadcast/unicast over ad-hoc Wi-Fi (Jetson),
|
||||
serial-attached LoRa/packet radio, in-process simulator.
|
||||
|
||||
#### Exchange protocol
|
||||
|
||||
On `Contact(peer)`, both sides run the same symmetric procedure; neither
|
||||
is the initiator in any semantic sense.
|
||||
|
||||
1. **Summarise.** Send a compact summary of held observation IDs.
|
||||
2. **Diff.** On receiving the peer's summary, compute which held IDs the
|
||||
peer appears to lack and send those observations.
|
||||
3. **Repeat** while the contact lasts, re-summarising periodically so
|
||||
observations received from a third party mid-contact also propagate.
|
||||
|
||||
Decisions:
|
||||
|
||||
- Every message is self-contained and idempotent. Receiving the same
|
||||
observation twice is a no-op; receiving a summary out of order is
|
||||
harmless. There is no session state that must survive to keep the
|
||||
store consistent.
|
||||
- Summaries must fit in a small number of frames even when the store is
|
||||
large. *(My call: an inverted Bloom filter / IBLT-style structure or a
|
||||
per-topic-prefix hash tree; the builder picks, with the requirement
|
||||
that a false "peer has it" costs only a delayed propagation, never an
|
||||
inconsistency.)*
|
||||
- Observations are sent smallest-first or oldest-first? *(My call:
|
||||
prioritise the peer's probable interest if the application supplies a
|
||||
priority function; otherwise newest-first, on the theory that short
|
||||
contacts should carry the freshest data.)*
|
||||
- Fragmentation: observations may exceed MTU on narrow radios. The core
|
||||
fragments and reassembles; a partially received observation is
|
||||
discarded, never stored.
|
||||
- Rate: the core emits at most one frame per `poll` so the application
|
||||
controls pacing and duty cycle.
|
||||
|
||||
#### Simulator
|
||||
|
||||
A `std` crate wrapping the core with:
|
||||
|
||||
- N nodes, each with its own store and clock skew.
|
||||
- A contact model: positions and ranges over time, or an explicit
|
||||
contact-trace file.
|
||||
- A loss/duplication model per link.
|
||||
- Assertions: after the trace, every pair of nodes that were ever
|
||||
transitively connected hold the same store (modulo trimming); no node's
|
||||
store depends on contact order.
|
||||
|
||||
The simulator is also the test harness for the protocol.
|
||||
|
||||
## Crate layout (my call)
|
||||
|
||||
```
|
||||
tines/
|
||||
tines-core/ no_std; Observation, Store trait, Transport trait,
|
||||
BeliefPolicy trait, exchange state machine,
|
||||
in-memory Store, reference policies
|
||||
tines-sim/ std; simulator, contact traces, property tests
|
||||
tines-udp/ std; UDP adapter (Jetson)
|
||||
tines-serial/ no_std-compatible; framed serial adapter
|
||||
tines-sqlite/ std; SQLite Store
|
||||
tines-cli/ std; inspect a store, replay a trace, run a node
|
||||
```
|
||||
|
||||
## Left to the builder
|
||||
|
||||
- Hash function for `ObservationId` (should be cheap on MCU; BLAKE2s or
|
||||
SipHash-128 are reasonable).
|
||||
- Summary data structure and its parameters.
|
||||
- Wire encoding (postcard or a hand-rolled fixed layout; must be
|
||||
`no_std`).
|
||||
- Fragment header layout and reassembly timeout.
|
||||
- SQLite schema.
|
||||
- MCU flash store layout (can be a stub in v1 as long as the trait
|
||||
boundary is honoured).
|
||||
|
||||
## Open questions (not blocking)
|
||||
|
||||
- Payload schema. Deliberately out of scope; an RSO observation is one
|
||||
candidate.
|
||||
- Whether topics should carry a schema/version tag so the memory layer
|
||||
can skip payloads it cannot parse. *(Leaning yes, as a convention in
|
||||
the topic string rather than a protocol field.)*
|
||||
- Observer authentication (signing). Out of scope for v1; the ID hash
|
||||
should be chosen so a signature can be appended without changing the
|
||||
ID.
|
||||
Reference in New Issue
Block a user