chore: ci script and README

This commit is contained in:
2026-10-17 21:48:37 -07:00
parent 4706004f47
commit ab9b892d94
19 changed files with 468 additions and 137 deletions
+104
View File
@@ -0,0 +1,104 @@
# tines — opportunistic shared memory for mostly-offline robots
Small robots doing their work collect facts about the world. They are mostly
offline, so when two of them happen to meet in range they 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-by-age
observations, a belief layer derived from them, and a delay-tolerant exchange
protocol that runs over whatever datagram 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.
## 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 and is never transmitted; storage is synced by set-union (order- and
path-independent); comms is a contact-driven exchange of observations over a
`Transport` trait.
## Usage
`tines-core` exposes a single application-facing facade, `Node<S, T>`, generic
over any `Store` and `Transport`. Persist `Node::next_seq()` across reboots
(`boot_count << 32 | counter`) and pass it back as the initial `first_seq`; a
repeated `seq` after reboot collides in the content-id hash and dedupes silently.
```rust
use tines_core::{LatestWins, MemStore, Node, NodeId};
let me: NodeId = [7; 16];
let boot_count = 0u64;
let counter = 3u64;
let store = MemStore::new();
let transport = /* any Transport implementation */;
// first_seq must survive reboot: boot_count << 32 | counter
let mut node: Node<MemStore, _> = Node::new(
me,
boot_count << 32 | counter,
store,
transport,
);
// Record something you saw. observed_at is an optional absolute clock
// (ms since epoch); `now` is a local monotonic Tick on every call.
let id = node
.observe(b"door/3", b"open", Some(1_700_000_000_000), 0)
.expect("observation fits the size limits");
// Drive the exchange in your loop. Returns how many new observations were
// stored this round.
let stored = node.poll(500);
// Ask what you believe about a topic prefix, under a belief policy
// (e.g. the youngest observation wins).
let belief = node.belief(b"door", &LatestWins, 500);
```
## Crate layout
```
tines/
tines-core/ no_std core: Observation, Store / Transport / BeliefPolicy
traits, the exchange state machine, and the `Node` facade.
`std` is optional; `tines-core` builds for `thumbv7em-none-eabihf`
with `--no-default-features`.
tines-sim/ std: the simulator, contact traces, and the convergence /
order-independence property tests.
```
## CI
`scripts/ci.sh` runs the full gate — formatting, clippy, the test suite, and the
`no_std` build — and is safe to drop into a CI config:
```sh
sh scripts/ci.sh
```
## Where to read more
The spec (authoritative) and the implementation plan that argues from it:
- **Spec:** `docs/superpowers/specs/2026-10-16-tines-design.md`
- **Plan:** `docs/superpowers/plans/2026-10-16-tines-core-and-sim.md`
Executable
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
set -e
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
sh scripts/check-no-std.sh
+20 -4
View File
@@ -14,7 +14,12 @@ pub trait BeliefPolicy {
/// Streaming fold: called once per matching observation (already /// Streaming fold: called once per matching observation (already
/// age-adjusted to `now`), in no particular order. `acc` is `None` before /// age-adjusted to `now`), in no particular order. `acc` is `None` before
/// the first call. Return the new accumulator. /// the first call. Return the new accumulator.
fn step(&self, me: NodeId, acc: Option<Self::Belief>, obs: &Observation) -> Option<Self::Belief>; fn step(
&self,
me: NodeId,
acc: Option<Self::Belief>,
obs: &Observation,
) -> Option<Self::Belief>;
} }
/// Latest observation wins: the observation with the smallest `age_ms`; ties /// Latest observation wins: the observation with the smallest `age_ms`; ties
@@ -29,12 +34,18 @@ pub struct SelfFirst;
impl BeliefPolicy for LatestWins { impl BeliefPolicy for LatestWins {
type Belief = Observation; type Belief = Observation;
fn step(&self, _me: NodeId, acc: Option<Self::Belief>, obs: &Observation) -> Option<Self::Belief> { fn step(
&self,
_me: NodeId,
acc: Option<Self::Belief>,
obs: &Observation,
) -> Option<Self::Belief> {
match acc { match acc {
None => Some(obs.clone()), None => Some(obs.clone()),
Some(cur) => { Some(cur) => {
if obs.age_ms < cur.age_ms if obs.age_ms < cur.age_ms
|| (obs.age_ms == cur.age_ms && (obs.observer, obs.seq) < (cur.observer, cur.seq)) || (obs.age_ms == cur.age_ms
&& (obs.observer, obs.seq) < (cur.observer, cur.seq))
{ {
Some(obs.clone()) Some(obs.clone())
} else { } else {
@@ -48,7 +59,12 @@ impl BeliefPolicy for LatestWins {
impl BeliefPolicy for SelfFirst { impl BeliefPolicy for SelfFirst {
type Belief = Observation; type Belief = Observation;
fn step(&self, me: NodeId, acc: Option<Self::Belief>, obs: &Observation) -> Option<Self::Belief> { fn step(
&self,
me: NodeId,
acc: Option<Self::Belief>,
obs: &Observation,
) -> Option<Self::Belief> {
match acc { match acc {
None => Some(obs.clone()), None => Some(obs.clone()),
Some(cur) => { Some(cur) => {
+8 -2
View File
@@ -142,7 +142,10 @@ mod tests {
bloom.insert(&id); bloom.insert(&id);
} }
for id in ids { for id in ids {
assert!(bloom.might_contain(&id), "inserted id must be reported present"); assert!(
bloom.might_contain(&id),
"inserted id must be reported present"
);
} }
} }
@@ -152,7 +155,10 @@ mod tests {
let mut rng = Rng::new(2); let mut rng = Rng::new(2);
let ids: [ObservationId; 100] = std::array::from_fn(|_| rng.id()); let ids: [ObservationId; 100] = std::array::from_fn(|_| rng.id());
for id in ids { for id in ids {
assert!(!bloom.might_contain(&id), "empty filter must report nothing"); assert!(
!bloom.might_contain(&id),
"empty filter must report nothing"
);
} }
} }
+99 -21
View File
@@ -80,7 +80,12 @@ impl<A: Clone + PartialEq + core::fmt::Debug> Exchange<A> {
/// ///
/// Returns `Some(obs)` when an inbound observation is newly stored (the /// Returns `Some(obs)` when an inbound observation is newly stored (the
/// caller may react); otherwise `None`. /// caller may react); otherwise `None`.
pub fn on_event<S: Store>(&mut self, ev: Event<A>, store: &mut S, now: Tick) -> Option<Observation> { pub fn on_event<S: Store>(
&mut self,
ev: Event<A>,
store: &mut S,
now: Tick,
) -> Option<Observation> {
match ev { match ev {
Event::Contact(addr) => { Event::Contact(addr) => {
self.add_peer(addr); self.add_peer(addr);
@@ -99,7 +104,13 @@ impl<A: Clone + PartialEq + core::fmt::Debug> Exchange<A> {
/// ///
/// Priority: (1) send a Summary to any peer due for one, then (2) emit one /// Priority: (1) send a Summary to any peer due for one, then (2) emit one
/// Obs/Frag frame from a peer via round-robin. /// Obs/Frag frame from a peer via round-robin.
pub fn next_frame<S: Store>(&mut self, store: &S, mtu: usize, now: Tick, out: &mut [u8]) -> Option<(A, usize)> { pub fn next_frame<S: Store>(
&mut self,
store: &S,
mtu: usize,
now: Tick,
out: &mut [u8],
) -> Option<(A, usize)> {
let me = self.me; let me = self.me;
// Priority 1: a summary is due for some peer. // Priority 1: a summary is due for some peer.
for peer in self.peers.iter_mut() { for peer in self.peers.iter_mut() {
@@ -173,7 +184,13 @@ impl<A: Clone + PartialEq + core::fmt::Debug> Exchange<A> {
None None
} }
fn on_frame<S: Store>(&mut self, addr: A, bytes: &[u8], store: &mut S, now: Tick) -> Option<Observation> { fn on_frame<S: Store>(
&mut self,
addr: A,
bytes: &[u8],
store: &mut S,
now: Tick,
) -> Option<Observation> {
let frame = match Frame::decode(bytes) { let frame = match Frame::decode(bytes) {
Ok(frame) => frame, Ok(frame) => frame,
Err(_) => return None, Err(_) => return None,
@@ -194,7 +211,13 @@ impl<A: Clone + PartialEq + core::fmt::Debug> Exchange<A> {
Ok(false) | Err(_) => None, Ok(false) | Err(_) => None,
}; };
} }
Frame::Frag { from: _, id, index, count, data } => { Frame::Frag {
from: _,
id,
index,
count,
data,
} => {
if let Some(obs) = peer.reassembler.feed(id, index, count, &data, now) { if let Some(obs) = peer.reassembler.feed(id, index, count, &data, now) {
let returned = obs.clone(); let returned = obs.clone();
return match store.insert(obs, now) { return match store.insert(obs, now) {
@@ -220,7 +243,14 @@ fn summary_due<A>(peer: &Peer<A>, now: Tick) -> bool {
/// Emits at most one frame for `peer`: continuing an in-flight fragment if one /// Emits at most one frame for `peer`: continuing an in-flight fragment if one
/// is pending, otherwise draining the send queue (skipping ids no longer /// is pending, otherwise draining the send queue (skipping ids no longer
/// stored). /// stored).
fn emit_one<A: Clone, S: Store>(me: NodeId, peer: &mut Peer<A>, store: &S, mtu: usize, now: Tick, out: &mut [u8]) -> Option<(A, usize)> { fn emit_one<A: Clone, S: Store>(
me: NodeId,
peer: &mut Peer<A>,
store: &S,
mtu: usize,
now: Tick,
out: &mut [u8],
) -> Option<(A, usize)> {
// in_flight takes precedence: continue the current fragmentation. // in_flight takes precedence: continue the current fragmentation.
if let Some((id, index)) = peer.in_flight { if let Some((id, index)) = peer.in_flight {
match store.get(&id, now) { match store.get(&id, now) {
@@ -333,13 +363,19 @@ fn rebuild_send_queue<A, S: Store>(peer: &mut Peer<A>, store: &S, now: Tick) {
peer.send_queue.clear(); peer.send_queue.clear();
for (id, _) in heap { for (id, _) in heap {
peer.send_queue.push(id).expect("heap is capped at SEND_QUEUE_CAP"); peer.send_queue
.push(id)
.expect("heap is capped at SEND_QUEUE_CAP");
} }
} }
/// Inserts `(id, age)` into the age-ascending top-K buffer, keeping it sorted /// Inserts `(id, age)` into the age-ascending top-K buffer, keeping it sorted
/// and capped at `SEND_QUEUE_CAP`. /// and capped at `SEND_QUEUE_CAP`.
fn insert_youngest(heap: &mut Vec<(ObservationId, u64), { consts::SEND_QUEUE_CAP }>, id: ObservationId, age: u64) { fn insert_youngest(
heap: &mut Vec<(ObservationId, u64), { consts::SEND_QUEUE_CAP }>,
id: ObservationId,
age: u64,
) {
if heap.len() < consts::SEND_QUEUE_CAP { if heap.len() < consts::SEND_QUEUE_CAP {
let pos = insert_pos(heap, age); let pos = insert_pos(heap, age);
let _ = heap.insert(pos, (id, age)); let _ = heap.insert(pos, (id, age));
@@ -372,7 +408,14 @@ mod tests {
const ME: NodeId = [42; 16]; const ME: NodeId = [42; 16];
fn obs(observer: [u8; 16], seq: u64, age: u64, at: Option<u64>, topic: &[u8], payload: &[u8]) -> Observation { fn obs(
observer: [u8; 16],
seq: u64,
age: u64,
at: Option<u64>,
topic: &[u8],
payload: &[u8],
) -> Observation {
Observation { Observation {
observer, observer,
seq, seq,
@@ -397,7 +440,9 @@ mod tests {
ex.on_event(Event::Contact(1), &mut store, 0); ex.on_event(Event::Contact(1), &mut store, 0);
let mut buf = [0u8; consts::MAX_FRAME]; let mut buf = [0u8; consts::MAX_FRAME];
let (addr, n) = ex.next_frame(&store, consts::MAX_FRAME, 0, &mut buf).expect("summary on contact"); let (addr, n) = ex
.next_frame(&store, consts::MAX_FRAME, 0, &mut buf)
.expect("summary on contact");
assert_eq!(addr, 1); assert_eq!(addr, 1);
match Frame::decode(&buf[..n]).unwrap() { match Frame::decode(&buf[..n]).unwrap() {
Frame::Summary { from, .. } => assert_eq!(from, ME), Frame::Summary { from, .. } => assert_eq!(from, ME),
@@ -450,10 +495,14 @@ mod tests {
assert_obs_id(&emitted[1], id_c); assert_obs_id(&emitted[1], id_c);
assert_obs_id(&emitted[2], id_a); assert_obs_id(&emitted[2], id_a);
assert!( assert!(
!emitted.iter().any(|f| matches!(f, Frame::Obs { obs, .. } if obs.id() == id_b)), !emitted
.iter()
.any(|f| matches!(f, Frame::Obs { obs, .. } if obs.id() == id_b)),
"B must be omitted" "B must be omitted"
); );
assert!(ex.next_frame(&store, consts::MAX_FRAME, 0, &mut [0u8; consts::MAX_FRAME]).is_none()); assert!(ex
.next_frame(&store, consts::MAX_FRAME, 0, &mut [0u8; consts::MAX_FRAME])
.is_none());
} }
fn assert_obs_id(frame: &Frame, id: ObservationId) { fn assert_obs_id(frame: &Frame, id: ObservationId) {
@@ -473,7 +522,9 @@ mod tests {
from: [2; 16], from: [2; 16],
obs: obs.clone(), obs: obs.clone(),
}; };
let returned = ex.on_event(Event::Frame(1, frame_bytes(&frame)), &mut store, 0).expect("obs returned"); let returned = ex
.on_event(Event::Frame(1, frame_bytes(&frame)), &mut store, 0)
.expect("obs returned");
assert_eq!(returned, obs); assert_eq!(returned, obs);
assert!(store.contains(&obs.id())); assert!(store.contains(&obs.id()));
} }
@@ -488,12 +539,16 @@ mod tests {
from: [2; 16], from: [2; 16],
obs: obs.clone(), obs: obs.clone(),
}; };
assert!(ex.on_event(Event::Frame(1, frame_bytes(&frame)), &mut store, 0).is_some()); assert!(ex
.on_event(Event::Frame(1, frame_bytes(&frame)), &mut store, 0)
.is_some());
let dup = Frame::Obs { let dup = Frame::Obs {
from: [2; 16], from: [2; 16],
obs: obs.clone(), obs: obs.clone(),
}; };
assert!(ex.on_event(Event::Frame(1, frame_bytes(&dup)), &mut store, 0).is_none()); assert!(ex
.on_event(Event::Frame(1, frame_bytes(&dup)), &mut store, 0)
.is_none());
} }
#[test] #[test]
@@ -504,7 +559,11 @@ mod tests {
let before = store.len(); let before = store.len();
let bogus = Event::Frame(1, heapless::Vec::from_slice(&[0xFFu8; 50]).unwrap()); let bogus = Event::Frame(1, heapless::Vec::from_slice(&[0xFFu8; 50]).unwrap());
assert_eq!(ex.on_event(bogus, &mut store, 0), None); assert_eq!(ex.on_event(bogus, &mut store, 0), None);
assert_eq!(store.len(), before, "store must be unchanged by a malformed frame"); assert_eq!(
store.len(),
before,
"store must be unchanged by a malformed frame"
);
} }
#[test] #[test]
@@ -554,7 +613,9 @@ mod tests {
ex.on_event(Event::Contact(1), &mut store, 0); ex.on_event(Event::Contact(1), &mut store, 0);
let mut buf = [0u8; consts::MAX_FRAME]; let mut buf = [0u8; consts::MAX_FRAME];
let (_, n) = ex.next_frame(&store, consts::MAX_FRAME, 0, &mut buf).expect("first summary"); let (_, n) = ex
.next_frame(&store, consts::MAX_FRAME, 0, &mut buf)
.expect("first summary");
match Frame::decode(&buf[..n]).unwrap() { match Frame::decode(&buf[..n]).unwrap() {
Frame::Summary { salt, .. } => assert_eq!(salt, 0), Frame::Summary { salt, .. } => assert_eq!(salt, 0),
other => panic!("expected a Summary, got {other:?}"), other => panic!("expected a Summary, got {other:?}"),
@@ -568,7 +629,12 @@ mod tests {
// After the interval: another Summary, now rotated to salt 1. // After the interval: another Summary, now rotated to salt 1.
let mut buf = [0u8; consts::MAX_FRAME]; let mut buf = [0u8; consts::MAX_FRAME];
let (_, n) = ex let (_, n) = ex
.next_frame(&store, consts::MAX_FRAME, consts::SUMMARY_INTERVAL_MS, &mut buf) .next_frame(
&store,
consts::MAX_FRAME,
consts::SUMMARY_INTERVAL_MS,
&mut buf,
)
.expect("second summary"); .expect("second summary");
match Frame::decode(&buf[..n]).unwrap() { match Frame::decode(&buf[..n]).unwrap() {
Frame::Summary { salt, .. } => assert_eq!(salt, 1), Frame::Summary { salt, .. } => assert_eq!(salt, 1),
@@ -589,7 +655,9 @@ mod tests {
fn summary_salt(ex: &mut Exchange<u8>, store: &MemStore, now: Tick) -> u32 { fn summary_salt(ex: &mut Exchange<u8>, store: &MemStore, now: Tick) -> u32 {
let mut buf = [0u8; consts::MAX_FRAME]; let mut buf = [0u8; consts::MAX_FRAME];
let (_, n) = ex.next_frame(store, consts::MAX_FRAME, now, &mut buf).expect("summary"); let (_, n) = ex
.next_frame(store, consts::MAX_FRAME, now, &mut buf)
.expect("summary");
match Frame::decode(&buf[..n]).unwrap() { match Frame::decode(&buf[..n]).unwrap() {
Frame::Summary { salt, .. } => salt, Frame::Summary { salt, .. } => salt,
other => panic!("expected a Summary, got {other:?}"), other => panic!("expected a Summary, got {other:?}"),
@@ -629,15 +697,25 @@ mod tests {
} }
} }
assert!(!indices.is_empty(), "the large observation must be fragmented"); assert!(
!indices.is_empty(),
"the large observation must be fragmented"
);
let count = indices[0].1; let count = indices[0].1;
assert!(count >= 2, "a 1024-byte payload must span multiple fragments"); assert!(
count >= 2,
"a 1024-byte payload must span multiple fragments"
);
for (i, &(index, c)) in indices.iter().enumerate() { for (i, &(index, c)) in indices.iter().enumerate() {
assert_eq!(index, i as u8, "fragments must carry increasing indices"); assert_eq!(index, i as u8, "fragments must carry increasing indices");
assert_eq!(c, count, "all fragments share the same count"); assert_eq!(c, count, "all fragments share the same count");
} }
let last = indices.last().copied().unwrap(); let last = indices.last().copied().unwrap();
assert_eq!(last.0 + 1, last.1, "fragment indices are contiguous to count"); assert_eq!(
last.0 + 1,
last.1,
"fragment indices are contiguous to count"
);
assert_eq!(indices.len(), count as usize); assert_eq!(indices.len(), count as usize);
// After draining, in_flight clears and the queue drops the id. // After draining, in_flight clears and the queue drops the id.
assert!(ex.peers()[0].in_flight.is_none()); assert!(ex.peers()[0].in_flight.is_none());
+77 -13
View File
@@ -1,7 +1,7 @@
//! Fragmentation and reassembly of `Observation`s over a byte-oriented link. //! Fragmentation and reassembly of `Observation`s over a byte-oriented link.
use crate::consts; use crate::consts;
use crate::{Frame, Observation, NodeId, ObservationId, Tick, WireError}; use crate::{Frame, NodeId, Observation, ObservationId, Tick, WireError};
use heapless::Vec; use heapless::Vec;
/// Header allowance per fragment (bytes), pinned. /// Header allowance per fragment (bytes), pinned.
@@ -85,7 +85,14 @@ impl Reassembler {
} }
} }
pub fn feed(&mut self, id: ObservationId, index: u8, count: u8, data: &[u8], now: Tick) -> Option<Observation> { pub fn feed(
&mut self,
id: ObservationId,
index: u8,
count: u8,
data: &[u8],
now: Tick,
) -> Option<Observation> {
// 1. Drop malformed indices: count must be in 1..=64 and index < count. // 1. Drop malformed indices: count must be in 1..=64 and index < count.
// `index >= 64` is guarded too so the `1 << index` bitmap never // `index >= 64` is guarded too so the `1 << index` bitmap never
// shifts out of bounds on adversarial wire input. // shifts out of bounds on adversarial wire input.
@@ -115,13 +122,19 @@ impl Reassembler {
if self.buf.len().saturating_add(data.len()) > self.buf.capacity() { if self.buf.len().saturating_add(data.len()) > self.buf.capacity() {
return None; return None;
} }
self.buf.extend_from_slice(data).expect("buf capacity check above"); self.buf
.extend_from_slice(data)
.expect("buf capacity check above");
self.offs[index as usize] = start as u16; self.offs[index as usize] = start as u16;
self.lens[index as usize] = data.len() as u16; self.lens[index as usize] = data.len() as u16;
self.received |= 1u64 << index; self.received |= 1u64 << index;
// 4. Not all fragments have arrived yet. // 4. Not all fragments have arrived yet.
let complete: u64 = if count >= 64 { u64::MAX } else { (1u64 << count) - 1 }; let complete: u64 = if count >= 64 {
u64::MAX
} else {
(1u64 << count) - 1
};
if self.received != complete { if self.received != complete {
return None; return None;
} }
@@ -219,12 +232,22 @@ mod tests {
let mut r = Reassembler::new(); let mut r = Reassembler::new();
let mut last: Option<Observation> = None; let mut last: Option<Observation> = None;
for f in frames.iter() { for f in frames.iter() {
if let Frame::Frag { id, index, count, data, .. } = Frame::decode(f).unwrap() { if let Frame::Frag {
id,
index,
count,
data,
..
} = Frame::decode(f).unwrap()
{
last = r.feed(id, index, count, &data, 0); last = r.feed(id, index, count, &data, 0);
} }
} }
let reassembled = last.expect("reassembled obs"); let reassembled = last.expect("reassembled obs");
assert_eq!(reassembled, obs, "reassembled observation must equal original"); assert_eq!(
reassembled, obs,
"reassembled observation must equal original"
);
} }
#[test] #[test]
@@ -236,7 +259,14 @@ mod tests {
let mut r = Reassembler::new(); let mut r = Reassembler::new();
let mut last: Option<Observation> = None; let mut last: Option<Observation> = None;
for f in frames.iter().rev() { for f in frames.iter().rev() {
if let Frame::Frag { id, index, count, data, .. } = Frame::decode(f).unwrap() { if let Frame::Frag {
id,
index,
count,
data,
..
} = Frame::decode(f).unwrap()
{
last = r.feed(id, index, count, &data, 0); last = r.feed(id, index, count, &data, 0);
} }
} }
@@ -253,7 +283,14 @@ mod tests {
let frags: Vec<(ObservationId, u8, u8, Vec<u8>)> = frames let frags: Vec<(ObservationId, u8, u8, Vec<u8>)> = frames
.iter() .iter()
.map(|f| { .map(|f| {
if let Frame::Frag { id, index, count, data, .. } = Frame::decode(f).unwrap() { if let Frame::Frag {
id,
index,
count,
data,
..
} = Frame::decode(f).unwrap()
{
(id, index, count, data.to_vec()) (id, index, count, data.to_vec())
} else { } else {
panic!("expected Frag frames"); panic!("expected Frag frames");
@@ -283,7 +320,14 @@ mod tests {
let frags: Vec<(ObservationId, u8, u8, Vec<u8>)> = frames let frags: Vec<(ObservationId, u8, u8, Vec<u8>)> = frames
.iter() .iter()
.map(|f| { .map(|f| {
if let Frame::Frag { id, index, count, data, .. } = Frame::decode(f).unwrap() { if let Frame::Frag {
id,
index,
count,
data,
..
} = Frame::decode(f).unwrap()
{
(id, index, count, data.to_vec()) (id, index, count, data.to_vec())
} else { } else {
panic!("expected Frag frames"); panic!("expected Frag frames");
@@ -323,7 +367,14 @@ mod tests {
let frags: Vec<(ObservationId, u8, u8, Vec<u8>)> = frames let frags: Vec<(ObservationId, u8, u8, Vec<u8>)> = frames
.iter() .iter()
.map(|f| { .map(|f| {
if let Frame::Frag { id, index, count, data, .. } = Frame::decode(f).unwrap() { if let Frame::Frag {
id,
index,
count,
data,
..
} = Frame::decode(f).unwrap()
{
(id, index, count, data.to_vec()) (id, index, count, data.to_vec())
} else { } else {
panic!("expected Frag frames"); panic!("expected Frag frames");
@@ -337,12 +388,18 @@ mod tests {
r.feed(*id0, *idx0, *cnt0, data0, 0); r.feed(*id0, *idx0, *cnt0, data0, 0);
// feed index 1 at now=timeout+1 -> slot restarts with only index 1, returns None // feed index 1 at now=timeout+1 -> slot restarts with only index 1, returns None
let (id1, idx1, cnt1, data1) = &frags[1]; let (id1, idx1, cnt1, data1) = &frags[1];
assert!(r.feed(*id1, *idx1, *cnt1, data1, consts::REASSEMBLY_TIMEOUT_MS + 1).is_none()); assert!(r
.feed(*id1, *idx1, *cnt1, data1, consts::REASSEMBLY_TIMEOUT_MS + 1)
.is_none());
// re-feed index 0 late: proves index 0 from now=0 was discarded, but the // re-feed index 0 late: proves index 0 from now=0 was discarded, but the
// restarted slot now holds index 1, so the stream completes to the original // restarted slot now holds index 1, so the stream completes to the original
let (idc, idxc, cntc, datac) = &frags[0]; let (idc, idxc, cntc, datac) = &frags[0];
let late = r.feed(*idc, *idxc, *cntc, datac, consts::REASSEMBLY_TIMEOUT_MS + 2); let late = r.feed(*idc, *idxc, *cntc, datac, consts::REASSEMBLY_TIMEOUT_MS + 2);
assert_eq!(late, Some(obs.clone()), "late re-feed must complete the restarted stream"); assert_eq!(
late,
Some(obs.clone()),
"late re-feed must complete the restarted stream"
);
// sanity: a fresh feed of the whole set from now=0 also completes // sanity: a fresh feed of the whole set from now=0 also completes
let mut r2 = Reassembler::new(); let mut r2 = Reassembler::new();
let mut complete: Option<Observation> = None; let mut complete: Option<Observation> = None;
@@ -361,7 +418,14 @@ mod tests {
let mut frags: Vec<(ObservationId, u8, u8, Vec<u8>)> = frames let mut frags: Vec<(ObservationId, u8, u8, Vec<u8>)> = frames
.iter() .iter()
.map(|f| { .map(|f| {
if let Frame::Frag { id, index, count, data, .. } = Frame::decode(f).unwrap() { if let Frame::Frag {
id,
index,
count,
data,
..
} = Frame::decode(f).unwrap()
{
(id, index, count, data.to_vec()) (id, index, count, data.to_vec())
} else { } else {
panic!("expected Frag frames"); panic!("expected Frag frames");
+4 -4
View File
@@ -9,11 +9,11 @@ pub mod bloom;
pub mod consts; pub mod consts;
pub mod exchange; pub mod exchange;
pub mod frag; pub mod frag;
pub mod observation;
pub mod store;
#[cfg(feature = "alloc")] #[cfg(feature = "alloc")]
pub mod mem_store; pub mod mem_store;
pub mod node; pub mod node;
pub mod observation;
pub mod store;
pub mod transport; pub mod transport;
pub mod wire; pub mod wire;
@@ -22,8 +22,8 @@ pub use bloom::Bloom;
pub use exchange::{Exchange, Peer}; pub use exchange::{Exchange, Peer};
pub use frag::{fragment, Reassembler}; pub use frag::{fragment, Reassembler};
pub use node::{Node, ObserveError}; pub use node::{Node, ObserveError};
pub use observation::{Observation, NodeId, ObservationId, Payload, Tick, Topic}; pub use observation::{NodeId, Observation, ObservationId, Payload, Tick, Topic};
pub use store::{Store, TrimPolicy, StoreError}; pub use store::{Store, StoreError, TrimPolicy};
pub use transport::{Event, FrameBuf, SendError, Transport}; pub use transport::{Event, FrameBuf, SendError, Transport};
pub use wire::{Frame, WireError}; pub use wire::{Frame, WireError};
+35 -22
View File
@@ -8,14 +8,14 @@
#[cfg(feature = "alloc")] #[cfg(feature = "alloc")]
extern crate alloc; extern crate alloc;
#[cfg(feature = "alloc")]
use alloc::collections::{BTreeMap, BTreeSet};
#[cfg(feature = "alloc")]
use alloc::vec::Vec;
#[cfg(feature = "alloc")] #[cfg(feature = "alloc")]
use crate::observation::{Observation, ObservationId, Tick}; use crate::observation::{Observation, ObservationId, Tick};
#[cfg(feature = "alloc")] #[cfg(feature = "alloc")]
use crate::store::{current_age, Store, StoreError, TrimPolicy}; use crate::store::{current_age, Store, StoreError, TrimPolicy};
#[cfg(feature = "alloc")]
use alloc::collections::{BTreeMap, BTreeSet};
#[cfg(feature = "alloc")]
use alloc::vec::Vec;
#[cfg(feature = "alloc")] #[cfg(feature = "alloc")]
struct Held { struct Held {
@@ -60,7 +60,13 @@ impl Store for MemStore {
// Dedupe: keep the first-stored entry and its age lineage. // Dedupe: keep the first-stored entry and its age lineage.
return Ok(false); return Ok(false);
} }
self.map.insert(id, Held { obs, stored_at: now }); self.map.insert(
id,
Held {
obs,
stored_at: now,
},
);
Ok(true) Ok(true)
} }
@@ -84,12 +90,7 @@ impl Store for MemStore {
} }
} }
fn for_each_topic( fn for_each_topic(&self, prefix: &[u8], now: Tick, f: &mut dyn FnMut(&Observation)) {
&self,
prefix: &[u8],
now: Tick,
f: &mut dyn FnMut(&Observation),
) {
for held in self.map.values() { for held in self.map.values() {
if held.obs.topic.starts_with(prefix) { if held.obs.topic.starts_with(prefix) {
let elapsed = now.saturating_sub(held.stored_at); let elapsed = now.saturating_sub(held.stored_at);
@@ -107,7 +108,10 @@ impl Store for MemStore {
let mut groups: BTreeMap<&[u8], Vec<ObservationId>> = BTreeMap::new(); let mut groups: BTreeMap<&[u8], Vec<ObservationId>> = BTreeMap::new();
for (id, held) in self.map.iter() { for (id, held) in self.map.iter() {
if !removed.contains(id) { if !removed.contains(id) {
groups.entry(held.obs.topic.as_slice()).or_default().push(*id); groups
.entry(held.obs.topic.as_slice())
.or_default()
.push(*id);
} }
} }
for member_ids in groups.values() { for member_ids in groups.values() {
@@ -116,8 +120,11 @@ impl Store for MemStore {
} }
let mut ids = member_ids.clone(); let mut ids = member_ids.clone();
ids.sort_by(|&a, &b| { ids.sort_by(|&a, &b| {
current_age(&self.map[&a].obs, self.map[&a].stored_at, now) current_age(&self.map[&a].obs, self.map[&a].stored_at, now).cmp(&current_age(
.cmp(&current_age(&self.map[&b].obs, self.map[&b].stored_at, now)) &self.map[&b].obs,
self.map[&b].stored_at,
now,
))
}); });
for id in ids.into_iter().skip(keep) { for id in ids.into_iter().skip(keep) {
removed.insert(id); removed.insert(id);
@@ -128,9 +135,7 @@ impl Store for MemStore {
// (b) max_age_ms: drop anything whose current age exceeds the cutoff. // (b) max_age_ms: drop anything whose current age exceeds the cutoff.
if let Some(max_age) = policy.max_age_ms { if let Some(max_age) = policy.max_age_ms {
for (id, held) in self.map.iter() { for (id, held) in self.map.iter() {
if !removed.contains(id) if !removed.contains(id) && current_age(&held.obs, held.stored_at, now) > max_age {
&& current_age(&held.obs, held.stored_at, now) > max_age
{
removed.insert(*id); removed.insert(*id);
} }
} }
@@ -147,8 +152,11 @@ impl Store for MemStore {
.collect(); .collect();
if remaining.len() > max_count { if remaining.len() > max_count {
remaining.sort_by(|&a, &b| { remaining.sort_by(|&a, &b| {
current_age(&self.map[&a].obs, self.map[&a].stored_at, now) current_age(&self.map[&a].obs, self.map[&a].stored_at, now).cmp(&current_age(
.cmp(&current_age(&self.map[&b].obs, self.map[&b].stored_at, now)) &self.map[&b].obs,
self.map[&b].stored_at,
now,
))
}); });
let over = remaining.len() - max_count; let over = remaining.len() - max_count;
for id in remaining.into_iter().rev().take(over) { for id in remaining.into_iter().rev().take(over) {
@@ -255,7 +263,8 @@ mod tests {
fn trim_keep_per_topic_keeps_youngest() { fn trim_keep_per_topic_keeps_youngest() {
let mut s = MemStore::new(); let mut s = MemStore::new();
for (i, age) in [10u64, 20, 30, 40, 50].iter().enumerate() { for (i, age) in [10u64, 20, 30, 40, 50].iter().enumerate() {
s.insert(obs([1; 16], i as u64, *age, None, b"t"), 0).unwrap(); s.insert(obs([1; 16], i as u64, *age, None, b"t"), 0)
.unwrap();
} }
let policy = TrimPolicy { let policy = TrimPolicy {
keep_per_topic: Some(2), keep_per_topic: Some(2),
@@ -293,7 +302,8 @@ mod tests {
fn trim_max_count_drops_oldest() { fn trim_max_count_drops_oldest() {
let mut s = MemStore::new(); let mut s = MemStore::new();
for i in 0..10u64 { for i in 0..10u64 {
s.insert(obs([1; 16], i, (i + 1) * 10, None, b"t"), 0).unwrap(); s.insert(obs([1; 16], i, (i + 1) * 10, None, b"t"), 0)
.unwrap();
} }
let policy = TrimPolicy { let policy = TrimPolicy {
keep_per_topic: None, keep_per_topic: None,
@@ -306,7 +316,10 @@ mod tests {
s.for_each_topic(b"t", 0, &mut |o| { s.for_each_topic(b"t", 0, &mut |o| {
ages.insert(o.age_ms); ages.insert(o.age_ms);
}); });
assert_eq!(ages, std::collections::BTreeSet::from_iter([10u64, 20, 30, 40])); assert_eq!(
ages,
std::collections::BTreeSet::from_iter([10u64, 20, 30, 40])
);
} }
#[test] #[test]
+10 -8
View File
@@ -11,8 +11,8 @@
//! transport frames are dropped by the [`Exchange`]. //! transport frames are dropped by the [`Exchange`].
use crate::{ use crate::{
BeliefPolicy, consts, Exchange, NodeId, Observation, ObservationId, Payload, Store, StoreError, consts, BeliefPolicy, Exchange, NodeId, Observation, ObservationId, Payload, Store, StoreError,
TrimPolicy, Tick, Topic, Transport, Tick, Topic, Transport, TrimPolicy,
}; };
/// Errors returned by [`Node::observe`]. /// Errors returned by [`Node::observe`].
@@ -115,7 +115,10 @@ impl<S: Store, T: Transport> Node<S, T> {
// Hoist the MTU, then borrow `exchange`/`store`, then borrow // Hoist the MTU, then borrow `exchange`/`store`, then borrow
// `transport` for the send — disjoint field borrows, never overlapping. // `transport` for the send — disjoint field borrows, never overlapping.
let mtu = self.transport.mtu(); let mtu = self.transport.mtu();
if let Some((addr, n)) = self.exchange.next_frame(&self.store, mtu, now, &mut self.out) { if let Some((addr, n)) = self
.exchange
.next_frame(&self.store, mtu, now, &mut self.out)
{
let _ = self.transport.send(&addr, &self.out[..n]); let _ = self.transport.send(&addr, &self.out[..n]);
} }
stored stored
@@ -134,11 +137,10 @@ impl<S: Store, T: Transport> Node<S, T> {
now: Tick, now: Tick,
) -> Option<P::Belief> { ) -> Option<P::Belief> {
let mut acc: Option<P::Belief> = None; let mut acc: Option<P::Belief> = None;
self.store self.store.for_each_topic(topic_prefix, now, &mut |obs| {
.for_each_topic(topic_prefix, now, &mut |obs| { let prev = acc.take();
let prev = acc.take(); acc = policy.step(self.me, prev, obs);
acc = policy.step(self.me, prev, obs); });
});
acc acc
} }
+2 -1
View File
@@ -118,7 +118,8 @@ mod tests {
assert_eq!( assert_eq!(
o.id(), o.id(),
[ [
0x92, 0x37, 0x37, 0xa1, 0x06, 0x9b, 0xd9, 0x5e, 0x08, 0xc2, 0xe6, 0x39, 0x7d, 0x4a, 0x8f, 0xd0 0x92, 0x37, 0x37, 0xa1, 0x06, 0x9b, 0xd9, 0x5e, 0x08, 0xc2, 0xe6, 0x39, 0x7d, 0x4a,
0x8f, 0xd0
] ]
); );
} }
+1 -6
View File
@@ -62,12 +62,7 @@ pub trait Store {
/// Visit every stored observation whose topic starts with `prefix` /// Visit every stored observation whose topic starts with `prefix`
/// (an empty prefix matches all), each age-adjusted to `now`. /// (an empty prefix matches all), each age-adjusted to `now`.
fn for_each_topic( fn for_each_topic(&self, prefix: &[u8], now: Tick, f: &mut dyn FnMut(&Observation));
&self,
prefix: &[u8],
now: Tick,
f: &mut dyn FnMut(&Observation),
);
/// Apply [`TrimPolicy`] rules in order (per-topic, age, count) and return /// Apply [`TrimPolicy`] rules in order (per-topic, age, count) and return
/// the number of observations removed. Trimmed ids are not tombstoned: /// the number of observations removed. Trimmed ids are not tombstoned:
+1 -1
View File
@@ -4,7 +4,7 @@
//! — `tines-core` is `#![no_std]` and never panics on untrusted bytes. //! — `tines-core` is `#![no_std]` and never panics on untrusted bytes.
use crate::consts; use crate::consts;
use crate::{Observation, NodeId, ObservationId}; use crate::{NodeId, Observation, ObservationId};
use heapless::Vec; use heapless::Vec;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
+16 -5
View File
@@ -1,10 +1,10 @@
mod common; mod common;
use common::PairTransport; use common::PairTransport;
use std::collections::BTreeSet;
use tines_core::observation::Payload; use tines_core::observation::Payload;
use tines_core::transport::{Event, FrameBuf, Transport}; use tines_core::transport::{Event, FrameBuf, Transport};
use tines_core::{consts, LatestWins, MemStore, Node, ObserveError, Store}; use tines_core::{consts, LatestWins, MemStore, Node, ObserveError, Store};
use std::collections::BTreeSet;
#[test] #[test]
fn pair_transport_connect_then_frame() { fn pair_transport_connect_then_frame() {
@@ -111,15 +111,23 @@ fn belief_latest_wins_across_nodes() {
// observe stores at `now` (b's stored_at=500, a's stored_at=0), so b's // observe stores at `now` (b's stored_at=500, a's stored_at=0), so b's
// is younger everywhere it is held; a re-received copy has a slightly // is younger everywhere it is held; a re-received copy has a slightly
// different age, so compare by identity, not byte-equality. // different age, so compare by identity, not byte-equality.
let belief = nb.belief(b"door", &LatestWins, now).expect("door belief exists"); let belief = nb
assert_eq!(belief.observer, [2; 16], "nb belief should be b's observation"); .belief(b"door", &LatestWins, now)
.expect("door belief exists");
assert_eq!(
belief.observer, [2; 16],
"nb belief should be b's observation"
);
assert_eq!(belief.seq, 0); assert_eq!(belief.seq, 0);
assert_eq!(belief.payload, Payload::from_slice(b"late").unwrap()); assert_eq!(belief.payload, Payload::from_slice(b"late").unwrap());
assert_eq!(belief.observed_at, Some(500)); assert_eq!(belief.observed_at, Some(500));
// a's node, looking at the same topic, agrees on the same observation. // a's node, looking at the same topic, agrees on the same observation.
let belief_a = na.belief(b"door", &LatestWins, now).unwrap(); let belief_a = na.belief(b"door", &LatestWins, now).unwrap();
assert_eq!(belief_a.observer, [2; 16], "na belief should be b's observation"); assert_eq!(
belief_a.observer, [2; 16],
"na belief should be b's observation"
);
assert_eq!(belief_a.payload, Payload::from_slice(b"late").unwrap()); assert_eq!(belief_a.payload, Payload::from_slice(b"late").unwrap());
assert_eq!(belief_a.observed_at, Some(500)); assert_eq!(belief_a.observed_at, Some(500));
} }
@@ -192,6 +200,9 @@ fn third_party_observation_propagates_within_contact() {
nb.poll(t); nb.poll(t);
t += 10; t += 10;
} }
assert!(nb.store().contains(&id), "fresh observation must propagate to b"); assert!(
nb.store().contains(&id),
"fresh observation must propagate to b"
);
assert_eq!(nb.store().len(), 1); assert_eq!(nb.store().len(), 1);
} }
+3 -3
View File
@@ -9,10 +9,10 @@
#![deny(warnings)] #![deny(warnings)]
pub mod trace;
pub mod world;
pub mod transport;
pub mod sim; pub mod sim;
pub mod trace;
pub mod transport;
pub mod world;
pub use sim::{Sim, SimNode}; pub use sim::{Sim, SimNode};
pub use trace::{Contact, ContactTrace}; pub use trace::{Contact, ContactTrace};
+6 -1
View File
@@ -53,7 +53,12 @@ mod tests {
#[test] #[test]
fn in_contact_matches_both_orders() { fn in_contact_matches_both_orders() {
let mut trace = ContactTrace::default(); let mut trace = ContactTrace::default();
trace.contacts.push(Contact { start_ms: 100, end_ms: 200, a: 3, b: 7 }); trace.contacts.push(Contact {
start_ms: 100,
end_ms: 200,
a: 3,
b: 7,
});
assert!(!trace.in_contact(3, 7, 50)); // before the window assert!(!trace.in_contact(3, 7, 50)); // before the window
assert!(trace.in_contact(3, 7, 100)); // at the start edge assert!(trace.in_contact(3, 7, 100)); // at the start edge
assert!(trace.in_contact(7, 3, 150)); // mid-window, either order assert!(trace.in_contact(7, 3, 150)); // mid-window, either order
+1 -2
View File
@@ -49,8 +49,7 @@ impl Transport for SimTransport {
if !w.trace.in_contact(self.me, *to, w.prev_t) { if !w.trace.in_contact(self.me, *to, w.prev_t) {
return Err(SendError::Unreachable); return Err(SendError::Unreachable);
} }
if frame.len() > w.cfg.mtu if frame.len() > w.cfg.mtu && !matches!(Frame::decode(frame), Ok(Frame::Summary { .. })) {
&& !matches!(Frame::decode(frame), Ok(Frame::Summary { .. })) {
return Err(SendError::TooLarge); return Err(SendError::TooLarge);
} }
if w.rng.gen::<f64>() < w.cfg.loss { if w.rng.gen::<f64>() < w.cfg.loss {
+13 -9
View File
@@ -150,9 +150,12 @@ mod tests {
seed, seed,
}; };
let mut trace = ContactTrace::default(); let mut trace = ContactTrace::default();
trace trace.contacts.push(Contact {
.contacts start_ms: start,
.push(Contact { start_ms: start, end_ms: end, a: 0, b: 1 }); end_ms: end,
a: 0,
b: 1,
});
SimWorld::new(cfg, trace) SimWorld::new(cfg, trace)
} }
@@ -163,9 +166,7 @@ mod tests {
/// The number of `Frame` events queued in `node`'s inbox. /// The number of `Frame` events queued in `node`'s inbox.
fn frame_count(world: &Rc<RefCell<SimWorld>>, node: usize) -> usize { fn frame_count(world: &Rc<RefCell<SimWorld>>, node: usize) -> usize {
world world.borrow().inboxes[node]
.borrow()
.inboxes[node]
.iter() .iter()
.filter(|e| matches!(e, Event::Frame(_, _))) .filter(|e| matches!(e, Event::Frame(_, _)))
.count() .count()
@@ -244,9 +245,12 @@ mod tests {
}; };
let make_trace = || { let make_trace = || {
let mut trace = ContactTrace::default(); let mut trace = ContactTrace::default();
trace trace.contacts.push(Contact {
.contacts start_ms: 0,
.push(Contact { start_ms: 0, end_ms: 1000, a: 0, b: 1 }); end_ms: 1000,
a: 0,
b: 1,
});
trace trace
}; };
let w1 = SimWorld::new(cfg.clone(), make_trace()); let w1 = SimWorld::new(cfg.clone(), make_trace());
+37 -8
View File
@@ -27,14 +27,25 @@ fn two_nodes_one_contact_converge() {
let mut sim = Sim::new(cfg, trace); let mut sim = Sim::new(cfg, trace);
for i in 0..10 { for i in 0..10 {
sim.observe(0, format!("n0-{}", i).as_bytes(), format!("p{}", i).as_bytes()); sim.observe(
0,
format!("n0-{}", i).as_bytes(),
format!("p{}", i).as_bytes(),
);
} }
for i in 0..10 { for i in 0..10 {
sim.observe(1, format!("n1-{}", i).as_bytes(), format!("p{}", i).as_bytes()); sim.observe(
1,
format!("n1-{}", i).as_bytes(),
format!("p{}", i).as_bytes(),
);
} }
sim.run_until(5000); sim.run_until(5000);
assert!(sim.converged(&[0, 1]), "both nodes must hold every observation"); assert!(
sim.converged(&[0, 1]),
"both nodes must hold every observation"
);
assert_eq!(sim.id_set(0).len(), 20, "each node stores 10 + 10 obs"); assert_eq!(sim.id_set(0).len(), 20, "each node stores 10 + 10 obs");
assert_eq!(sim.id_set(1).len(), 20); assert_eq!(sim.id_set(1).len(), 20);
} }
@@ -66,7 +77,11 @@ fn relay_through_middle_node() {
let mut sim = Sim::new(cfg, trace); let mut sim = Sim::new(cfg, trace);
for i in 0..5 { for i in 0..5 {
sim.observe(0, format!("a-{}", i).as_bytes(), format!("a-p{}", i).as_bytes()); sim.observe(
0,
format!("a-{}", i).as_bytes(),
format!("a-p{}", i).as_bytes(),
);
} }
sim.run_until(7000); sim.run_until(7000);
@@ -74,7 +89,10 @@ fn relay_through_middle_node() {
let node_2 = sim.id_set(2); let node_2 = sim.id_set(2);
// 0 and 2 never shared a contact, yet 2 received everything through 1. // 0 and 2 never shared a contact, yet 2 received everything through 1.
for id in &from_0 { for id in &from_0 {
assert!(node_2.contains(id), "node 2 is missing node 0's observation {id:?}"); assert!(
node_2.contains(id),
"node 2 is missing node 0's observation {id:?}"
);
} }
assert_eq!(from_0.len(), 5, "node 0 observed 5 things"); assert_eq!(from_0.len(), 5, "node 0 observed 5 things");
} }
@@ -114,7 +132,10 @@ fn disconnected_component_does_not_converge() {
vec![vec![0, 1], vec![2, 3]], vec![vec![0, 1], vec![2, 3]],
"the contact graph splits into two pairs" "the contact graph splits into two pairs"
); );
assert!(!sim.converged(&[0, 1, 2, 3]), "cross-component never converges"); assert!(
!sim.converged(&[0, 1, 2, 3]),
"cross-component never converges"
);
assert!(sim.converged(&[0, 1]), "component A converges"); assert!(sim.converged(&[0, 1]), "component A converges");
assert!(sim.converged(&[2, 3]), "component B converges"); assert!(sim.converged(&[2, 3]), "component B converges");
} }
@@ -139,8 +160,16 @@ fn lossy_link_still_converges_given_time() {
let mut sim = Sim::new(cfg, trace); let mut sim = Sim::new(cfg, trace);
for i in 0..50 { for i in 0..50 {
sim.observe(0, format!("l0-{}", i).as_bytes(), format!("l0-p{}", i).as_bytes()); sim.observe(
sim.observe(1, format!("l1-{}", i).as_bytes(), format!("l1-p{}", i).as_bytes()); 0,
format!("l0-{}", i).as_bytes(),
format!("l0-p{}", i).as_bytes(),
);
sim.observe(
1,
format!("l1-{}", i).as_bytes(),
format!("l1-p{}", i).as_bytes(),
);
} }
sim.run_until(60_000); sim.run_until(60_000);
+25 -27
View File
@@ -19,14 +19,7 @@ use tines_sim::{Contact, ContactTrace, Sim, WorldConfig};
/// A fixed set of short observation topics the tests reuse so the generated /// A fixed set of short observation topics the tests reuse so the generated
/// observations round-trip cleanly through the core (`MAX_TOPIC` is 64 bytes). /// observations round-trip cleanly through the core (`MAX_TOPIC` is 64 bytes).
const TOPICS: [&[u8]; 8] = [ const TOPICS: [&[u8]; 8] = [
b"temp", b"temp", b"humid", b"door", b"motion", b"light", b"batt", b"press", b"power",
b"humid",
b"door",
b"motion",
b"light",
b"batt",
b"press",
b"power",
]; ];
/// A single contact window over `nodes` nodes. `start` is bounded well below the /// A single contact window over `nodes` nodes. `start` is bounded well below the
@@ -44,28 +37,28 @@ fn contact(nodes: usize) -> impl Strategy<Value = Contact> {
0usize..nodes, 0usize..nodes,
0usize..nodes.saturating_sub(1), 0usize..nodes.saturating_sub(1),
) )
.prop_map(|(start, duration, a, b_prime)| { .prop_map(|(start, duration, a, b_prime)| {
let mut b = b_prime; let mut b = b_prime;
if b >= a { if b >= a {
b += 1; b += 1;
} }
Contact { Contact {
start_ms: start, start_ms: start,
end_ms: start + duration, end_ms: start + duration,
a, a,
b, b,
} }
}) })
} }
/// A single observation: a topic drawn from [`TOPICS`] and a random 1..=64 byte /// A single observation: a topic drawn from [`TOPICS`] and a random 1..=64 byte
/// payload. /// payload.
fn observation() -> impl Strategy<Value = (Vec<u8>, Vec<u8>)> { fn observation() -> impl Strategy<Value = (Vec<u8>, Vec<u8>)> {
( (
0u32..8u32, 0u32..8u32,
proptest::collection::vec(any::<u8>(), 1..=64usize), proptest::collection::vec(any::<u8>(), 1..=64usize),
) )
.prop_map(|(topic_idx, payload)| (TOPICS[topic_idx as usize].to_vec(), payload)) .prop_map(|(topic_idx, payload)| (TOPICS[topic_idx as usize].to_vec(), payload))
} }
/// A generated scenario: a node count, a contact trace over those nodes, and a /// A generated scenario: a node count, a contact trace over those nodes, and a
@@ -93,8 +86,13 @@ fn scenario() -> impl Strategy<Value = Scenario> {
/// A 3-node observation list, reused by the order-independence test. /// A 3-node observation list, reused by the order-independence test.
fn observations_3nodes() -> impl Strategy<Value = Vec<(usize, Vec<u8>, Vec<u8>)>> { fn observations_3nodes() -> impl Strategy<Value = Vec<(usize, Vec<u8>, Vec<u8>)>> {
proptest::collection::vec(observation(), 3..=3) proptest::collection::vec(observation(), 3..=3).prop_map(|per_node| {
.prop_map(|per_node| per_node.into_iter().enumerate().map(|(n, o)| (n, o.0, o.1)).collect()) per_node
.into_iter()
.enumerate()
.map(|(n, o)| (n, o.0, o.1))
.collect()
})
} }
/// Maps a `u64` seed to one of the six permutations of three slots. /// Maps a `u64` seed to one of the six permutations of three slots.