Design doc: tines opportunistic shared memory

This commit is contained in:
2026-10-16 14:43:28 -07:00
commit f73f9247ed
@@ -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.