Skip to main content

waggle_core/
fold.rs

1//! The fold engine: folds are the **only** read model (design docs `04`,
2//! `13 §4`). Every counter, view, and report is `Fold` state after a replay
3//! — no side-channel state, ever. Tuple composition runs N folds in **one
4//! pass** over the log; adding an analytic is a new `Fold` impl, never a
5//! new scan.
6
7use std::collections::{BTreeMap, BTreeSet};
8
9use crate::log::LogRecord;
10use crate::manifest::AttributionManifest;
11use crate::slug::Stage;
12use crate::token::Token;
13
14/// A pure accumulator over log records. `apply` performs no I/O and asks no
15/// clock; unknown record kinds must be ignored (additive schema growth —
16/// doc `13 §4`).
17pub trait Fold {
18    /// Fold one record into the accumulator.
19    fn apply(&mut self, record: &LogRecord);
20}
21
22/// One-pass composition: `(A, B)` applies both. Nest tuples for more.
23impl<A: Fold, B: Fold> Fold for (A, B) {
24    fn apply(&mut self, record: &LogRecord) {
25        self.0.apply(record);
26        self.1.apply(record);
27    }
28}
29
30/// Run `records` through `fold` (one pass) and hand the fold back.
31pub fn replay<F: Fold>(records: impl IntoIterator<Item = LogRecord>, mut fold: F) -> F {
32    for rec in &records.into_iter().collect::<Vec<_>>() {
33        fold.apply(rec);
34    }
35    fold
36}
37
38/// Materializes manifests: `Minted` inserts; mutations apply in arrival
39/// order (reconstruct orders per-token by seq, so arrival order *is* commit
40/// order — doc `04 §2`). Lifecycle changes bump `version` (the CAS
41/// baseline C-9); cosmetic changes don't.
42#[derive(Debug, Default)]
43pub struct ManifestFold {
44    /// Token → latest manifest state at the folded prefix.
45    pub manifests: BTreeMap<Token, AttributionManifest>,
46}
47
48impl Fold for ManifestFold {
49    fn apply(&mut self, record: &LogRecord) {
50        match record {
51            LogRecord::Minted { manifest } => {
52                // First write wins: a duplicate Minted (same token) is a
53                // replayed record, not a new token (C-8's fold-side face).
54                self.manifests
55                    .entry(manifest.token)
56                    .or_insert_with(|| (**manifest).clone());
57            }
58            LogRecord::Mutation {
59                token, at, change, ..
60            } => {
61                let Some(m) = self.manifests.get_mut(token) else {
62                    return;
63                };
64                crate::manifest::apply_change(m, change, *at);
65            }
66            LogRecord::Event(_) => {}
67        }
68    }
69}
70
71/// Counts stages per token. Commutative across tokens by construction —
72/// counts don't care about cross-token order (doc `04 §2`).
73#[derive(Debug, Default)]
74pub struct FunnelFold {
75    /// Token → stage → count.
76    pub per_token: BTreeMap<Token, BTreeMap<Stage, u64>>,
77}
78
79impl Fold for FunnelFold {
80    fn apply(&mut self, record: &LogRecord) {
81        if let LogRecord::Event(e) = record {
82            *self
83                .per_token
84                .entry(e.token)
85                .or_default()
86                .entry(e.stage.clone())
87                .or_insert(0) += 1;
88        }
89    }
90}
91
92/// A token's judged outcome, derived from the `accepted`/`rejected`
93/// stage counts (doc `19 §4.1`): the verdict IS the stage, so the log
94/// stays payload-free (I-1) and this derivation is a pure function of
95/// counts — order-free, replay-stable (R-1).
96#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
97#[serde(rename_all = "kebab-case")]
98pub enum Outcome {
99    /// No judge has recorded a verdict.
100    Pending,
101    /// At least one `accepted`, no `rejected`.
102    Accepted,
103    /// At least one `rejected`, no `accepted`.
104    Rejected,
105    /// Both were recorded — surfaced honestly for the orchestrator to
106    /// resolve (re-judge, or supersede and re-delegate).
107    Contested,
108}
109
110/// Derive the [`Outcome`] from a token's stage counts (the shape
111/// [`FunnelFold`] and every store's `funnel` view produce).
112#[must_use]
113pub fn outcome_of(counts: &BTreeMap<Stage, u64>) -> Outcome {
114    let n = |s: &Stage| counts.get(s).copied().unwrap_or(0);
115    match (n(&Stage::accepted()) > 0, n(&Stage::rejected()) > 0) {
116        (false, false) => Outcome::Pending,
117        (true, false) => Outcome::Accepted,
118        (false, true) => Outcome::Rejected,
119        (true, true) => Outcome::Contested,
120    }
121}
122
123/// Accumulates contract region touches per token: the OR of every
124/// event's `regions` bitmask (doc `19 §4.2`). Commutative and
125/// duplicate-tolerant by construction — OR is both — so R-1/R-3 hold
126/// without ceremony. `coverage` evaluates this against the manifest's
127/// declared [`crate::Contract`].
128#[derive(Debug, Default)]
129pub struct RegionTouchFold {
130    /// Token → OR of region-touch bits observed so far.
131    pub per_token: BTreeMap<Token, u8>,
132}
133
134impl Fold for RegionTouchFold {
135    fn apply(&mut self, record: &LogRecord) {
136        if let LogRecord::Event(e) = record {
137            if let Some(bits) = e.regions {
138                *self.per_token.entry(e.token).or_insert(0) |= bits;
139            }
140        }
141    }
142}
143
144/// Accumulates the set of tree-node file ordinals a token's reads have
145/// touched: the UNION of every event's [`crate::Event::entry`]. Union is
146/// commutative and idempotent — like the OR in [`RegionTouchFold`] — so
147/// R-1/R-3 (order- and duplicate-tolerance) and C-8 hold for free. Tree
148/// coverage evaluates `|touched ∩ [0, node.files)|` against each node's
149/// signed directory index for a true per-file receipt.
150#[derive(Debug, Default)]
151pub struct EntryTouchFold {
152    /// Token → the set of `DirIndex` file ordinals its reads have reached.
153    pub per_token: BTreeMap<Token, BTreeSet<u32>>,
154}
155
156impl Fold for EntryTouchFold {
157    fn apply(&mut self, record: &LogRecord) {
158        if let LogRecord::Event(e) = record {
159            if let Some(ordinal) = e.entry {
160                self.per_token.entry(e.token).or_default().insert(ordinal);
161            }
162        }
163    }
164}
165
166/// The delegation forest: parent → children, from `Minted` records
167/// (doc `06 §4` — coordination trace, attribution roll-up, cascade path).
168#[derive(Debug, Default)]
169pub struct LineageFold {
170    /// Parent token → children in mint order.
171    pub children: BTreeMap<Token, Vec<Token>>,
172}
173
174impl Fold for LineageFold {
175    fn apply(&mut self, record: &LogRecord) {
176        if let LogRecord::Minted { manifest } = record {
177            if let Some(parent) = manifest.parent {
178                let siblings = self.children.entry(parent).or_default();
179                if !siblings.contains(&manifest.token) {
180                    siblings.push(manifest.token);
181                }
182            }
183        }
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use crate::event::{ActorClass, Event, Seq};
191    use crate::log::Change;
192    use crate::{CanonicalUrl, Channel, MintOptions, MintSpec, ResolverContext, Sharer, Timestamp};
193
194    fn minted(tag: u8, parent: Option<Token>) -> AttributionManifest {
195        let mut entropy = move |buf: &mut [u8]| {
196            buf.fill(tag);
197            Ok(())
198        };
199        let mut spec = MintSpec::new(
200            CanonicalUrl::new("ws://a/b").unwrap(),
201            Sharer::new("lead").unwrap(),
202            Channel::subagent_general(),
203        );
204        if let Some(p) = parent {
205            spec = spec.child_of(p);
206        }
207        crate::mint(
208            spec,
209            &MintOptions::default(),
210            &mut entropy,
211            Timestamp::from_unix_ms(1),
212        )
213        .unwrap()
214    }
215
216    fn event(token: Token, stage: Stage, seq: u32) -> LogRecord {
217        LogRecord::Event(Event {
218            token,
219            stage,
220            actor: ActorClass::from_context(&ResolverContext::anonymous_agent()),
221            at: Timestamp::from_unix_ms(2),
222            seq: Seq(seq),
223            variant: None,
224            regions: None,
225            entry: None,
226        })
227    }
228
229    #[test]
230    fn one_pass_multi_fold_agrees_with_separate_passes() {
231        // CP-3 gate: single scan, N folds (doc 13 §3).
232        let parent = minted(1, None);
233        let child = minted(2, Some(parent.token));
234        let records = vec![
235            LogRecord::Minted {
236                manifest: Box::new(parent.clone()),
237            },
238            LogRecord::Minted {
239                manifest: Box::new(child.clone()),
240            },
241            event(parent.token, Stage::resolve(), 1),
242            event(parent.token, Stage::run(), 2),
243            LogRecord::Mutation {
244                token: parent.token,
245                at: Timestamp::from_unix_ms(3),
246                seq: Seq(3),
247                change: Change::Revoked,
248            },
249        ];
250
251        let (manifests, (funnel, lineage)) = replay(
252            records.clone(),
253            (
254                ManifestFold::default(),
255                (FunnelFold::default(), LineageFold::default()),
256            ),
257        );
258        let solo_m = replay(records.clone(), ManifestFold::default());
259        let solo_f = replay(records.clone(), FunnelFold::default());
260        let solo_l = replay(records, LineageFold::default());
261
262        assert_eq!(manifests.manifests, solo_m.manifests);
263        assert_eq!(funnel.per_token, solo_f.per_token);
264        assert_eq!(lineage.children, solo_l.children);
265
266        // And the folds say what happened.
267        assert!(manifests.manifests[&parent.token].revoked_at.is_some());
268        assert_eq!(
269            manifests.manifests[&parent.token].version, 2,
270            "lifecycle bumps version"
271        );
272        assert_eq!(funnel.per_token[&parent.token][&Stage::run()], 1);
273        assert_eq!(lineage.children[&parent.token], vec![child.token]);
274    }
275
276    #[test]
277    fn cosmetic_changes_do_not_bump_version() {
278        let m = minted(5, None);
279        let records = vec![
280            LogRecord::Minted {
281                manifest: Box::new(m.clone()),
282            },
283            LogRecord::Mutation {
284                token: m.token,
285                at: Timestamp::from_unix_ms(2),
286                seq: Seq(1),
287                change: Change::LabelSet {
288                    key: "campaign".into(),
289                    value: "q3".into(),
290                },
291            },
292        ];
293        let folded = replay(records, ManifestFold::default());
294        let out = &folded.manifests[&m.token];
295        assert_eq!(out.version, 1);
296        assert_eq!(out.labels["campaign"], "q3");
297    }
298
299    #[test]
300    fn outcome_derivation_is_a_pure_function_of_counts() {
301        let m = minted(7, None);
302        let judged = |stages: &[Stage]| {
303            let records: Vec<LogRecord> = stages
304                .iter()
305                .enumerate()
306                .map(|(i, s)| event(m.token, s.clone(), u32::try_from(i).unwrap() + 1))
307                .collect();
308            let funnel = replay(records, FunnelFold::default());
309            outcome_of(funnel.per_token.get(&m.token).unwrap_or(&BTreeMap::new()))
310        };
311        assert_eq!(judged(&[]), Outcome::Pending);
312        assert_eq!(judged(&[Stage::resolve(), Stage::run()]), Outcome::Pending);
313        assert_eq!(judged(&[Stage::accepted()]), Outcome::Accepted);
314        assert_eq!(judged(&[Stage::rejected()]), Outcome::Rejected);
315        assert_eq!(
316            judged(&[Stage::accepted(), Stage::rejected()]),
317            Outcome::Contested,
318            "both verdicts surface honestly, never a silent overwrite"
319        );
320    }
321
322    #[test]
323    fn region_touches_or_together_shuffle_and_duplicate_immune() {
324        let m = minted(8, None);
325        let touch = |bits: u8, seq: u32| {
326            LogRecord::Event(Event {
327                token: m.token,
328                stage: Stage::read(),
329                actor: ActorClass::from_context(&ResolverContext::anonymous_agent()),
330                at: Timestamp::from_unix_ms(2),
331                seq: Seq(seq),
332                variant: None,
333                regions: Some(bits),
334                entry: None,
335            })
336        };
337        let records = vec![
338            touch(0b001, 1),
339            event(m.token, Stage::read(), 2), // no regions: contract-free access
340            touch(0b100, 3),
341            touch(0b001, 1), // duplicate record — OR absorbs it (R-3)
342        ];
343        let mut shuffled = records.clone();
344        shuffled.reverse();
345        let a = replay(records, RegionTouchFold::default());
346        let b = replay(shuffled, RegionTouchFold::default());
347        assert_eq!(a.per_token[&m.token], 0b101);
348        assert_eq!(a.per_token, b.per_token, "OR is commutative — R-1");
349    }
350
351    #[test]
352    fn entry_touches_union_together_shuffle_and_duplicate_immune() {
353        let m = minted(9, None);
354        let touch = |ordinal: u32, seq: u32| {
355            LogRecord::Event(Event {
356                token: m.token,
357                stage: Stage::read(),
358                actor: ActorClass::from_context(&ResolverContext::anonymous_agent()),
359                at: Timestamp::from_unix_ms(2),
360                seq: Seq(seq),
361                variant: None,
362                regions: None,
363                entry: Some(ordinal),
364            })
365        };
366        let records = vec![
367            touch(2, 1),
368            event(m.token, Stage::read(), 2), // no entry: whole-node access
369            touch(0, 3),
370            touch(2, 1), // duplicate record — the set absorbs it (R-3)
371        ];
372        let mut shuffled = records.clone();
373        shuffled.reverse();
374        let a = replay(records, EntryTouchFold::default());
375        let b = replay(shuffled, EntryTouchFold::default());
376        assert_eq!(a.per_token[&m.token], BTreeSet::from([0, 2]));
377        assert_eq!(a.per_token, b.per_token, "union is commutative — R-1");
378    }
379
380    #[test]
381    fn mutation_before_mint_is_ignored_not_a_panic() {
382        // Hostile/misordered input: folds are total (reconstruct orders
383        // per-token, but folds must survive anything).
384        let m = minted(6, None);
385        let records = vec![LogRecord::Mutation {
386            token: m.token,
387            at: Timestamp::from_unix_ms(1),
388            seq: Seq(1),
389            change: Change::Revoked,
390        }];
391        let folded = replay(records, ManifestFold::default());
392        assert!(folded.manifests.is_empty());
393    }
394}