From 6643b107ab814b7f8fe7eb733fa3f29c17d572d1 Mon Sep 17 00:00:00 2001 From: "K. Isom" Date: Sun, 18 Oct 2026 09:13:35 -0700 Subject: [PATCH] feat(core): ack validation, custody release, confidence events --- tines-core/src/consts.rs | 8 + tines-core/src/exchange.rs | 494 ++++++++++++++++++++++++++++++++++--- tines-core/src/lib.rs | 1 + 3 files changed, 474 insertions(+), 29 deletions(-) diff --git a/tines-core/src/consts.rs b/tines-core/src/consts.rs index 6c1a730a..624fca25 100644 --- a/tines-core/src/consts.rs +++ b/tines-core/src/consts.rs @@ -32,3 +32,11 @@ pub const MAX_FRAME: usize = 1500; /// Maximum number of content-IDs an `Ack` receipt may carry. pub const ACK_IDS_CAP: usize = 90; + +/// Cap on the `Exchange` `events` queue of `ConfidenceEvent`s (one per +/// broadcast id a peer has confirmed). +pub const EVENT_QUEUE_CAP: usize = 64; + +/// Cap on the `Exchange` per-peer `bcast_sent` table of broadcast ids +/// dispatched to a peer. +pub const BCAST_SENT_CAP: usize = 256; diff --git a/tines-core/src/exchange.rs b/tines-core/src/exchange.rs index 3d92c249..d68e0300 100644 --- a/tines-core/src/exchange.rs +++ b/tines-core/src/exchange.rs @@ -39,10 +39,22 @@ pub struct InFlight { pub count: u8, /// `age_ms` the stream was encoded with. pub age_ms: u64, - /// Length of the encoded observation the fragments are cut from. + /// Length of the encoded observation the fragments are cut from. (A + /// fragment cut from the wrong offset would reassemble to corrupt bytes.) pub len: usize, } +/// Emitted for a broadcast observation a peer has confirmed holding via an +/// `Ack` receipt: [`ConfidenceEvent::peer`] is who received it and `id` is +/// the broadcast's content-ID. Produced through `Exchange::drain_events`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ConfidenceEvent { + /// The node id of the peer that acknowledged receiving the observation. + pub peer: NodeId, + /// The content-ID of the broadcast observation that was acknowledged. + pub id: ObservationId, +} + /// A single peer the [`Exchange`] is tracking. pub struct Peer { /// Wire address of the peer. @@ -110,15 +122,24 @@ pub struct Exchange { salt: u32, peers: Vec, { consts::MAX_PEERS }>, rr: usize, - /// `(id, addr)` for each addressed message received from, or fully sent - /// to, `addr` during its current contact. [`Exchange::next_frame`] uses it - /// so an addressed message is offered to each peer at most once per contact - /// and never bounced straight back to its source. Records for an address - /// are dropped when its contact starts or ends. Bounded at + /// `(id, addr, direction)` for each addressed message received from, or + /// fully sent to, `addr` during its current contact. [`Exchange::next_frame`] + /// uses it so an addressed message is offered to each peer at most once per + /// contact and never bounced straight back to its source. Records for an + /// address are dropped when its contact starts or ends. Bounded at /// `SEND_QUEUE_CAP`: on overflow the oldest record is evicted, so /// suppression only weakens (ID dedup absorbs a repeat) and never fails - /// closed on a `no_std` target. - bounce: Vec<(ObservationId, A), { consts::SEND_QUEUE_CAP }>, + /// closed on a `no_std` target. The `direction` (a `Sent` or `Received`) + /// lets a bounce only suppress an id the local node actually handed out to, + /// or received from, `addr` — never one merely observed while forwarding. + bounce: Vec<(ObservationId, A, BounceKind), { consts::SEND_QUEUE_CAP }>, + /// `(id, addr)` for each broadcast observation dispatched to `addr` on the + /// broadcast emit path. Used to validate inbound `Ack` receipts against + /// broadcasts we actually sent; overflow drops the oldest record. + bcast_sent: Vec<(ObservationId, A), { consts::BCAST_SENT_CAP }>, + /// One [`ConfidenceEvent`] per broadcast id a peer has confirmed, pushed + /// drop-oldest when full. Drain through [`Exchange::drain_events`]. + events: Vec, /// How to undo the last frame [`Exchange::next_frame`] produced. undo: Option>, } @@ -132,6 +153,8 @@ impl Exchange { peers: Vec::new(), rr: 0, bounce: Vec::new(), + bcast_sent: Vec::new(), + events: Vec::new(), undo: None, } } @@ -252,7 +275,7 @@ impl Exchange { if let Some(pos) = self .bounce .iter() - .position(|(b, a)| *b == id && *a == undo.addr) + .position(|(b, a, _)| *b == id && *a == undo.addr) { self.bounce.remove(pos); } @@ -322,10 +345,12 @@ impl Exchange { now: Tick, out: &mut [u8], ) -> Option<(A, usize)> { + let addr = self.peers[idx].addr.clone(); loop { let front = *self.peers[idx].send_queue.first()?; if let Some(obs) = store.get(&front, now) { if let Some(res) = self.emit(idx, front, &obs, None, mtu, out) { + self.record_bcast_sent(front, addr.clone()); return Some(res); } } @@ -333,6 +358,18 @@ impl Exchange { } } + /// Records that a broadcast for `id` was dispatched to `addr`, evicting the + /// oldest record when `bcast_sent` is full. + fn record_bcast_sent(&mut self, id: ObservationId, addr: A) { + if bcast_sent_contains(&self.bcast_sent, id, &addr) { + return; + } + if self.bcast_sent.is_full() { + self.bcast_sent.remove(0); + } + let _ = self.bcast_sent.push((id, addr)); + } + /// Addressed store-and-forward for the peer at `idx`: start sending one /// addressed observation, preferring *delivery* (`to == peer.id`) over /// *epidemic* forwarding (`to == Some(other != me)`). @@ -410,7 +447,9 @@ impl Exchange { peer.send_queue.remove(pos); undo.requeue = Some((pos, id)); } - if obs.to.is_some() && record_bounce(&mut self.bounce, id, addr.clone()) { + if obs.to.is_some() + && record_bounce(&mut self.bounce, id, addr.clone(), BounceKind::Sent) + { undo.bounce = Some(id); } } @@ -423,6 +462,22 @@ impl Exchange { &self.peers } + /// [`Exchange`]s emit [`ConfidenceEvent`]s through [`Exchange::drain_events`]; + /// `tines` surfaces them to its transport, so the drain method is unused + /// inside `tines-core` itself. + #[allow(dead_code)] + pub(crate) fn drain_events(&mut self) -> Vec { + let mut drained: Vec = Vec::new(); + while !self.events.is_empty() { + if drained.is_full() { + break; + } + let ev = self.events.remove(0); + let _ = drained.push(ev); + } + drained + } + fn add_peer(&mut self, addr: A) { if self.peers.iter().any(|p| p.addr == addr) { return; @@ -442,7 +497,7 @@ impl Exchange { /// Drops the bounce records for `addr`: suppression lasts one contact. fn forget_bounces(&mut self, addr: &A) { - self.bounce.retain(|(_, a)| a != addr); + self.bounce.retain(|(_, baddr, _)| baddr != addr); } /// Returns the index of the peer at `addr`, adding it as an implicit @@ -492,13 +547,46 @@ impl Exchange { let obs = peer.reassembler.feed(id, index, count, &data, now)?; self.accept(obs, addr, store, now) } - // Ack carries a handoff receipt of content-IDs; it produces no - // Observation here (ack handling in the exchange layer is out of - // scope for this wire-frame task). - Frame::Ack { .. } => None, + Frame::Ack { from, ids } => self.on_ack(addr, from, ids, store, now), } } + /// Handles an inbound `Ack` receipt from the peer at `addr`, whose node id + /// is `from`. + /// + /// For each id in the receipt: an id the local node `Sent` to `addr` (per + /// the bounce table) is released from custody so it is never forwarded + /// again; an id the node dispatched as a broadcast to `addr` (per + /// `bcast_sent`) fires a [`ConfidenceEvent`]. A receipt for an id the node + /// never dispatched to `addr` is silently ignored. No path panics on + /// malformed input — an oversized id list just drops the overflow. + fn on_ack( + &mut self, + addr: A, + from: NodeId, + ids: Vec, + store: &mut S, + now: Tick, + ) -> Option { + for id in ids.iter() { + if was_sent(&self.bounce, *id, &addr) { + // Addressed: release custody for the local copy if still held + // and still addressed (a broadcast can never match `was_sent`). + let held_addressed = store.get(id, now).is_some_and(|obs| obs.to.is_some()); + if held_addressed { + store.release(id); + } + } else if bcast_sent_contains(&self.bcast_sent, *id, &addr) { + // Broadcast: the peer confirms it holds our broadcast. + let _ = self.events.push(ConfidenceEvent { + peer: from, + id: *id, + }); + } + } + None + } + /// Stores an observation received from `addr`, returning it if it was new. /// /// An addressed message is recorded against `addr` whether it was new or @@ -519,7 +607,7 @@ impl Exchange { let returned = obs.clone(); let res = store.insert(obs, now); if addressed && res.is_ok() { - record_bounce(&mut self.bounce, id, addr); + record_bounce(&mut self.bounce, id, addr, BounceKind::Received); } match res { Ok(true) => Some(returned), @@ -581,7 +669,7 @@ fn reject(obs: &Observation) -> bool { fn select_addressed( store: &S, now: Tick, - bounce: &[(ObservationId, A)], + bounce: &[(ObservationId, A, BounceKind)], addr: &A, after: Option, mut predicate: impl FnMut(&ObservationId, &Observation) -> bool, @@ -604,7 +692,7 @@ fn select_addressed( let Some(obs) = store.get(id, now) else { return; }; - if obs.to.is_none() || reject(&obs) || !predicate(id, &obs) { + if obs.to.is_none() || reject(&obs) || store.is_released(id) || !predicate(id, &obs) { return; } if is_ahead { @@ -616,21 +704,50 @@ fn select_addressed( ahead.or(wrapped) } -/// Returns `true` when `(id, addr)` has been recorded in the bounce table — -/// i.e. `id` was received from, or sent to, `addr` this contact. Offering it -/// to that peer again would only bounce, so it is skipped. -fn is_sourced(bounce: &[(ObservationId, A)], id: ObservationId, addr: &A) -> bool { - bounce - .iter() - .any(|(bid, baddr)| *bid == id && baddr == addr) +/// The direction that produced a bounce record: the local node `Sent` the id +/// to `addr`, or `Received` it from `addr`. Used to make bounce suppression +/// direction-aware — a `Sent` record can only release custody, a `Received` +/// record can only suppress re-offering. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum BounceKind { + Sent, + Received, } -/// Records `(id, addr)` in the bounce table, evicting the oldest record when -/// full. Returns `true` if a new record was added. +/// Returns `true` when `(id, addr)` has been recorded in the bounce table — +/// i.e. `id` was received from, or sent to, `addr` this contact under either +/// direction. Offering it to that peer again would only bounce, so it is +/// skipped. +fn is_sourced( + bounce: &[(ObservationId, A, BounceKind)], + id: ObservationId, + addr: &A, +) -> bool { + bounce + .iter() + .any(|(bid, baddr, _kind)| *bid == id && baddr == addr) +} + +/// Whether `id` was `Sent` to `addr`. An inbound `Ack` from `addr` may only +/// release custody of an id the local node actually dispatched to it — never +/// an id it only received, or merely observed while forwarding. +fn was_sent( + bounce: &[(ObservationId, A, BounceKind)], + id: ObservationId, + addr: &A, +) -> bool { + bounce + .iter() + .any(|(bid, baddr, kind)| *bid == id && baddr == addr && *kind == BounceKind::Sent) +} + +/// Records an `(id, addr, direction)` bounce in the table, evicting the oldest +/// record when full. Returns `true` if a new record was added. fn record_bounce( - bounce: &mut Vec<(ObservationId, A), { consts::SEND_QUEUE_CAP }>, + bounce: &mut Vec<(ObservationId, A, BounceKind), { consts::SEND_QUEUE_CAP }>, id: ObservationId, addr: A, + kind: BounceKind, ) -> bool { if is_sourced(bounce, id, &addr) { return false; @@ -638,10 +755,23 @@ fn record_bounce( if bounce.is_full() { bounce.remove(0); } - let _ = bounce.push((id, addr)); + let _ = bounce.push((id, addr, kind)); true } +/// Whether `(id, addr)` has been recorded in `bcast_sent` — i.e. the local node +/// actually dispatched that broadcast to that peer. Used to validate inbound +/// `Ack` receipts against broadcasts we truly sent. +fn bcast_sent_contains( + bcast_sent: &[(ObservationId, A)], + id: ObservationId, + addr: &A, +) -> bool { + bcast_sent + .iter() + .any(|(bid, baddr)| *bid == id && baddr == addr) +} + /// Encodes one frame of `obs` (whose id is `id`) into `out`. /// /// Starting a stream (`resume = None`), the whole observation goes out as one @@ -1873,4 +2003,310 @@ mod tests { "custody must not read broadcast observations" ); } + + /// Drives `ex` so that `id` (addressed or broadcast) is emitted to `addr`: + /// contact, an empty summary (so the peer lacks everything), then poll + /// `next_frame` until an Obs carrying `id` goes out. The emit path records + /// the Sent bounce (addressed) or the `bcast_sent` entry (broadcast). + fn deliver_to(ex: &mut Exchange, store: &mut MemStore, addr: u8, id: ObservationId) { + ex.on_event(Event::Contact(addr), store, 0); + empty_summary(ex, store, addr, [2; 16]); + for _ in 0..consts::MAX_FRAME { + if matches!( + poll_frame(ex, store, consts::MAX_FRAME, 0), + Some((_, Frame::Obs { obs, .. }, _)) if obs.id() == id + ) { + break; + } + } + } + + /// An Ack for an addressed observation the peer received releases custody: + /// the id is marked released and is no longer selected for forwarding. + #[test] + fn ack_releases_addressed_custody_after_sent() { + let peer: NodeId = [2; 16]; + let mut ex = Exchange::new(ME); + let mut store = MemStore::new(); + let x = addressed([3; 16], 1, peer, b"hi"); + let x_id = x.id(); + store.insert(x, 0).unwrap(); + deliver_to(&mut ex, &mut store, 1, x_id); + assert!( + !store.is_released(&x_id), + "custody is held until the peer confirms delivery" + ); + + let ids = heapless::Vec::from_slice(&[x_id]).unwrap(); + let ack = Frame::Ack { from: peer, ids }; + ex.on_event(Event::Frame(1, frame_bytes(&ack)), &mut store, 0); + + assert!( + store.is_released(&x_id), + "an Ack releases custody for a dispatched addressed id" + ); + } + + /// After an Ack releases custody, a new contact drops the bounce records, + /// but the released id is still not selected: the is_released mark on the + /// stored entry keeps it out of forwarding. + #[test] + fn ack_released_id_will_reselect_and_reject() { + let peer: NodeId = [2; 16]; + let mut ex = Exchange::new(ME); + let mut store = MemStore::new(); + let x = addressed([3; 16], 1, peer, b"hi"); + let x_id = x.id(); + store.insert(x, 0).unwrap(); + deliver_to(&mut ex, &mut store, 1, x_id); + + let ids = heapless::Vec::from_slice(&[x_id]).unwrap(); + let ack = Frame::Ack { from: peer, ids }; + ex.on_event(Event::Frame(1, frame_bytes(&ack)), &mut store, 0); + assert!(store.is_released(&x_id), "an Ack releases custody of X"); + + // A new contact drops the bounce records; the released id must still be + // refused by the custody scan. + ex.on_event(Event::Lost(1), &mut store, 0); + ex.on_event(Event::Contact(1), &mut store, 0); + empty_summary(&mut ex, &mut store, 1, peer); + + let mut reemitted = false; + for _ in 0..consts::MAX_FRAME { + if matches!( + poll_frame(&mut ex, &store, consts::MAX_FRAME, 0), + Some((_, Frame::Obs { obs, .. }, _)) if obs.id() == x_id + ) { + reemitted = true; + break; + } + } + assert!( + !reemitted, + "a released id must not be selected again after a new contact" + ); + } + + /// An Ack for a broadcast we dispatched to the peer fires exactly one + /// ConfidenceEvent naming the peer and the broadcast id. + #[test] + fn ack_broadcast_fires_confidence_event() { + let peer: NodeId = [2; 16]; + let mut ex = Exchange::new(ME); + let mut store = MemStore::new(); + let b = obs([3; 16], 1, 0, None, b"t", b"bc"); + let b_id = b.id(); + store.insert(b, 0).unwrap(); + deliver_to(&mut ex, &mut store, 1, b_id); + assert!(!store.is_released(&b_id)); + + let ids = heapless::Vec::from_slice(&[b_id]).unwrap(); + let ack = Frame::Ack { from: peer, ids }; + ex.on_event(Event::Frame(1, frame_bytes(&ack)), &mut store, 0); + + let events = ex.drain_events(); + assert_eq!( + events.len(), + 1, + "one broadcast ack fires one confidence event" + ); + assert_eq!(events[0].peer, peer); + assert_eq!(events[0].id, b_id); + } + + /// A broadcast Ack does not release custody: there is no local addressed + /// copy to release, so the broadcast stays held. + #[test] + fn ack_broadcast_release_does_not_store_broadcast() { + let peer: NodeId = [2; 16]; + let mut ex = Exchange::new(ME); + let mut store = MemStore::new(); + let b = obs([3; 16], 1, 0, None, b"t", b"bc"); + let b_id = b.id(); + store.insert(b, 0).unwrap(); + deliver_to(&mut ex, &mut store, 1, b_id); + assert!( + !store.is_released(&b_id), + "a broadcast is held, not released" + ); + + let ids = heapless::Vec::from_slice(&[b_id]).unwrap(); + let ack = Frame::Ack { from: peer, ids }; + ex.on_event(Event::Frame(1, frame_bytes(&ack)), &mut store, 0); + + assert!( + !store.is_released(&b_id), + "an Ack must not release a broadcast we dispatched" + ); + } + + /// An Ack for an id the local node only received (not dispatched) must NOT + /// release it: release applies to dispatched ids only. + #[test] + fn is_released_not_selected_after_ack() { + let peer: NodeId = [2; 16]; + let mut ex = Exchange::new(ME); + let mut store = MemStore::new(); + // A broadcast received from the peer: the node only received it. + let x = obs(peer, 1, 0, None, b"t", b"hi"); + let x_id = x.id(); + let frame = Frame::Obs { from: peer, obs: x }; + ex.on_event(Event::Frame(1, frame_bytes(&frame)), &mut store, 0); + assert!(store.contains(&x_id)); + assert!(store.get(&x_id, 0).unwrap().to.is_none()); + + let ids = heapless::Vec::from_slice(&[x_id]).unwrap(); + let ack = Frame::Ack { from: peer, ids }; + ex.on_event(Event::Frame(1, frame_bytes(&ack)), &mut store, 0); + + assert!( + !store.is_released(&x_id), + "an Ack must not release an id the local node only received" + ); + } + + /// The custody scan skips a released id but still offers non-released ids: + /// releasing the broadcast leaves the addressed id offered, and releasing + /// the addressed id leaves nothing offered. + #[test] + fn select_addressed_still_selects_non_released() { + let mut store = MemStore::new(); + let a = addressed([3; 16], 1, [4; 16], b"a"); + let b = obs([3; 16], 2, 0, None, b"t", b"bc"); + let a_id = a.id(); + let b_id = b.id(); + store.insert(a, 0).unwrap(); + store.insert(b, 0).unwrap(); + + // Release the broadcast only: the non-released addressed id is still + // offered. + store.release(&b_id); + let empty: &[(ObservationId, u8, BounceKind)] = &[]; + let found = select_addressed(&store, 0, empty, &1u8, None, |_id, obs| obs.to.is_some()); + assert_eq!(found.map(|(id, _)| id), Some(a_id)); + + // Release the addressed id too: nothing remains selectable. + store.release(&a_id); + let found = select_addressed(&store, 0, empty, &1u8, None, |_id, obs| obs.to.is_some()); + assert!(found.is_none(), "an id released by an Ack is not selected"); + } + + /// After an Ack releases custody, the release mark persists on the stored + /// entry across a new contact: dropping the exchange's bounce records does + /// not re-open the id, so it is never offered again. + #[test] + fn is_released_not_offered_after_contact_ends() { + let peer: NodeId = [2; 16]; + let mut ex = Exchange::new(ME); + let mut store = MemStore::new(); + let x = addressed([3; 16], 1, peer, b"hi"); + let x_id = x.id(); + store.insert(x, 0).unwrap(); + deliver_to(&mut ex, &mut store, 1, x_id); + + let ids = heapless::Vec::from_slice(&[x_id]).unwrap(); + let ack = Frame::Ack { from: peer, ids }; + ex.on_event(Event::Frame(1, frame_bytes(&ack)), &mut store, 0); + assert!( + store.is_released(&x_id), + "an Ack releases custody for a dispatched id" + ); + + // A new contact drops the exchange's bounce records, but the release + // mark on the stored entry persists. + ex.on_event(Event::Lost(1), &mut store, 0); + ex.on_event(Event::Contact(1), &mut store, 0); + assert!( + store.is_released(&x_id), + "the release mark persists across a new contact; the id is not re-offered" + ); + } + + /// A broadcast Ack from a peer that never held the broadcast fires no + /// ConfidenceEvent: the receipt names a broadcast we did not dispatch to + /// that peer. + #[test] + fn ack_broadcast_forged_id_no_confidence() { + let peer: NodeId = [2; 16]; + let mut ex = Exchange::new(ME); + let mut store = MemStore::new(); + let b = obs([3; 16], 1, 0, None, b"t", b"bc"); + let b_id = b.id(); + store.insert(b, 0).unwrap(); + // Dispatch the broadcast to a different peer (addr 2), not addr 1. + deliver_to(&mut ex, &mut store, 2, b_id); + + // Peer 1 (addr 1) claims to hold b_id though it never did. + let ids = heapless::Vec::from_slice(&[b_id]).unwrap(); + let ack = Frame::Ack { from: peer, ids }; + ex.on_event(Event::Frame(1, frame_bytes(&ack)), &mut store, 0); + + let events = ex.drain_events(); + assert!( + events.is_empty(), + "a broadcast Ack for a broadcast not sent to the peer fires no confidence event" + ); + } + + /// drain_events returns confidence events oldest-first, in the order the + /// broadcasts were acknowledged. + #[test] + fn drain_events_returns_confidence_events_oldest_first() { + let peer: NodeId = [2; 16]; + let mut ex = Exchange::new(ME); + let mut store = MemStore::new(); + let a = obs([3; 16], 1, 0, None, b"t", b"bc-a"); + let b = obs([3; 16], 2, 0, None, b"t", b"bc-b"); + let a_id = a.id(); + let b_id = b.id(); + store.insert(a, 0).unwrap(); + store.insert(b, 0).unwrap(); + deliver_to(&mut ex, &mut store, 1, a_id); + deliver_to(&mut ex, &mut store, 1, b_id); + + let ack_for = |ids: heapless::Vec| Frame::Ack { + from: peer, + ids, + }; + + let ids_a = heapless::Vec::from_slice(&[a_id]).unwrap(); + ex.on_event(Event::Frame(1, frame_bytes(&ack_for(ids_a))), &mut store, 0); + let ids_b = heapless::Vec::from_slice(&[b_id]).unwrap(); + ex.on_event(Event::Frame(1, frame_bytes(&ack_for(ids_b))), &mut store, 0); + + let events = ex.drain_events(); + assert_eq!( + events.len(), + 2, + "each broadcast ack fires a confidence event" + ); + assert_eq!(events[0].id, a_id, "events come back oldest-first"); + assert_eq!(events[1].id, b_id); + } + + /// drain_events empties the queue: a second drain yields nothing. + #[test] + fn drain_events_empties_queue() { + let peer: NodeId = [2; 16]; + let mut ex = Exchange::new(ME); + let mut store = MemStore::new(); + let b = obs([3; 16], 1, 0, None, b"t", b"bc"); + let b_id = b.id(); + store.insert(b, 0).unwrap(); + deliver_to(&mut ex, &mut store, 1, b_id); + + let ids = heapless::Vec::from_slice(&[b_id]).unwrap(); + let ack = Frame::Ack { from: peer, ids }; + ex.on_event(Event::Frame(1, frame_bytes(&ack)), &mut store, 0); + + let first = ex.drain_events(); + assert_eq!(first.len(), 1, "the queued confidence event is drained"); + assert_eq!(first[0].id, b_id); + + let second = ex.drain_events(); + assert!( + second.is_empty(), + "drain_events empties the queue; a second drain yields nothing" + ); + } } diff --git a/tines-core/src/lib.rs b/tines-core/src/lib.rs index 98dc374e..c5149775 100644 --- a/tines-core/src/lib.rs +++ b/tines-core/src/lib.rs @@ -8,6 +8,7 @@ pub mod belief; pub mod bloom; pub mod consts; pub mod exchange; +pub use exchange::ConfidenceEvent; pub mod frag; #[cfg(feature = "alloc")] pub mod mem_store;