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;
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/// The delegation forest: parent → children, from `Minted` records
93/// (doc `06 §4` — coordination trace, attribution roll-up, cascade path).
94#[derive(Debug, Default)]
95pub struct LineageFold {
96    /// Parent token → children in mint order.
97    pub children: BTreeMap<Token, Vec<Token>>,
98}
99
100impl Fold for LineageFold {
101    fn apply(&mut self, record: &LogRecord) {
102        if let LogRecord::Minted { manifest } = record {
103            if let Some(parent) = manifest.parent {
104                let siblings = self.children.entry(parent).or_default();
105                if !siblings.contains(&manifest.token) {
106                    siblings.push(manifest.token);
107                }
108            }
109        }
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use crate::event::{ActorClass, Event, Seq};
117    use crate::log::Change;
118    use crate::{CanonicalUrl, Channel, MintOptions, MintSpec, ResolverContext, Sharer, Timestamp};
119
120    fn minted(tag: u8, parent: Option<Token>) -> AttributionManifest {
121        let mut entropy = move |buf: &mut [u8]| {
122            buf.fill(tag);
123            Ok(())
124        };
125        let mut spec = MintSpec::new(
126            CanonicalUrl::new("ws://a/b").unwrap(),
127            Sharer::new("lead").unwrap(),
128            Channel::subagent_general(),
129        );
130        if let Some(p) = parent {
131            spec = spec.child_of(p);
132        }
133        crate::mint(
134            spec,
135            &MintOptions::default(),
136            &mut entropy,
137            Timestamp::from_unix_ms(1),
138        )
139        .unwrap()
140    }
141
142    fn event(token: Token, stage: Stage, seq: u32) -> LogRecord {
143        LogRecord::Event(Event {
144            token,
145            stage,
146            actor: ActorClass::from_context(&ResolverContext::anonymous_agent()),
147            at: Timestamp::from_unix_ms(2),
148            seq: Seq(seq),
149            variant: None,
150        })
151    }
152
153    #[test]
154    fn one_pass_multi_fold_agrees_with_separate_passes() {
155        // CP-3 gate: single scan, N folds (doc 13 §3).
156        let parent = minted(1, None);
157        let child = minted(2, Some(parent.token));
158        let records = vec![
159            LogRecord::Minted {
160                manifest: Box::new(parent.clone()),
161            },
162            LogRecord::Minted {
163                manifest: Box::new(child.clone()),
164            },
165            event(parent.token, Stage::resolve(), 1),
166            event(parent.token, Stage::run(), 2),
167            LogRecord::Mutation {
168                token: parent.token,
169                at: Timestamp::from_unix_ms(3),
170                seq: Seq(3),
171                change: Change::Revoked,
172            },
173        ];
174
175        let (manifests, (funnel, lineage)) = replay(
176            records.clone(),
177            (
178                ManifestFold::default(),
179                (FunnelFold::default(), LineageFold::default()),
180            ),
181        );
182        let solo_m = replay(records.clone(), ManifestFold::default());
183        let solo_f = replay(records.clone(), FunnelFold::default());
184        let solo_l = replay(records, LineageFold::default());
185
186        assert_eq!(manifests.manifests, solo_m.manifests);
187        assert_eq!(funnel.per_token, solo_f.per_token);
188        assert_eq!(lineage.children, solo_l.children);
189
190        // And the folds say what happened.
191        assert!(manifests.manifests[&parent.token].revoked_at.is_some());
192        assert_eq!(
193            manifests.manifests[&parent.token].version, 2,
194            "lifecycle bumps version"
195        );
196        assert_eq!(funnel.per_token[&parent.token][&Stage::run()], 1);
197        assert_eq!(lineage.children[&parent.token], vec![child.token]);
198    }
199
200    #[test]
201    fn cosmetic_changes_do_not_bump_version() {
202        let m = minted(5, None);
203        let records = vec![
204            LogRecord::Minted {
205                manifest: Box::new(m.clone()),
206            },
207            LogRecord::Mutation {
208                token: m.token,
209                at: Timestamp::from_unix_ms(2),
210                seq: Seq(1),
211                change: Change::LabelSet {
212                    key: "campaign".into(),
213                    value: "q3".into(),
214                },
215            },
216        ];
217        let folded = replay(records, ManifestFold::default());
218        let out = &folded.manifests[&m.token];
219        assert_eq!(out.version, 1);
220        assert_eq!(out.labels["campaign"], "q3");
221    }
222
223    #[test]
224    fn mutation_before_mint_is_ignored_not_a_panic() {
225        // Hostile/misordered input: folds are total (reconstruct orders
226        // per-token, but folds must survive anything).
227        let m = minted(6, None);
228        let records = vec![LogRecord::Mutation {
229            token: m.token,
230            at: Timestamp::from_unix_ms(1),
231            seq: Seq(1),
232            change: Change::Revoked,
233        }];
234        let folded = replay(records, ManifestFold::default());
235        assert!(folded.manifests.is_empty());
236    }
237}