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/// 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/// The delegation forest: parent → children, from `Minted` records
145/// (doc `06 §4` — coordination trace, attribution roll-up, cascade path).
146#[derive(Debug, Default)]
147pub struct LineageFold {
148    /// Parent token → children in mint order.
149    pub children: BTreeMap<Token, Vec<Token>>,
150}
151
152impl Fold for LineageFold {
153    fn apply(&mut self, record: &LogRecord) {
154        if let LogRecord::Minted { manifest } = record {
155            if let Some(parent) = manifest.parent {
156                let siblings = self.children.entry(parent).or_default();
157                if !siblings.contains(&manifest.token) {
158                    siblings.push(manifest.token);
159                }
160            }
161        }
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use crate::event::{ActorClass, Event, Seq};
169    use crate::log::Change;
170    use crate::{CanonicalUrl, Channel, MintOptions, MintSpec, ResolverContext, Sharer, Timestamp};
171
172    fn minted(tag: u8, parent: Option<Token>) -> AttributionManifest {
173        let mut entropy = move |buf: &mut [u8]| {
174            buf.fill(tag);
175            Ok(())
176        };
177        let mut spec = MintSpec::new(
178            CanonicalUrl::new("ws://a/b").unwrap(),
179            Sharer::new("lead").unwrap(),
180            Channel::subagent_general(),
181        );
182        if let Some(p) = parent {
183            spec = spec.child_of(p);
184        }
185        crate::mint(
186            spec,
187            &MintOptions::default(),
188            &mut entropy,
189            Timestamp::from_unix_ms(1),
190        )
191        .unwrap()
192    }
193
194    fn event(token: Token, stage: Stage, seq: u32) -> LogRecord {
195        LogRecord::Event(Event {
196            token,
197            stage,
198            actor: ActorClass::from_context(&ResolverContext::anonymous_agent()),
199            at: Timestamp::from_unix_ms(2),
200            seq: Seq(seq),
201            variant: None,
202            regions: None,
203        })
204    }
205
206    #[test]
207    fn one_pass_multi_fold_agrees_with_separate_passes() {
208        // CP-3 gate: single scan, N folds (doc 13 §3).
209        let parent = minted(1, None);
210        let child = minted(2, Some(parent.token));
211        let records = vec![
212            LogRecord::Minted {
213                manifest: Box::new(parent.clone()),
214            },
215            LogRecord::Minted {
216                manifest: Box::new(child.clone()),
217            },
218            event(parent.token, Stage::resolve(), 1),
219            event(parent.token, Stage::run(), 2),
220            LogRecord::Mutation {
221                token: parent.token,
222                at: Timestamp::from_unix_ms(3),
223                seq: Seq(3),
224                change: Change::Revoked,
225            },
226        ];
227
228        let (manifests, (funnel, lineage)) = replay(
229            records.clone(),
230            (
231                ManifestFold::default(),
232                (FunnelFold::default(), LineageFold::default()),
233            ),
234        );
235        let solo_m = replay(records.clone(), ManifestFold::default());
236        let solo_f = replay(records.clone(), FunnelFold::default());
237        let solo_l = replay(records, LineageFold::default());
238
239        assert_eq!(manifests.manifests, solo_m.manifests);
240        assert_eq!(funnel.per_token, solo_f.per_token);
241        assert_eq!(lineage.children, solo_l.children);
242
243        // And the folds say what happened.
244        assert!(manifests.manifests[&parent.token].revoked_at.is_some());
245        assert_eq!(
246            manifests.manifests[&parent.token].version, 2,
247            "lifecycle bumps version"
248        );
249        assert_eq!(funnel.per_token[&parent.token][&Stage::run()], 1);
250        assert_eq!(lineage.children[&parent.token], vec![child.token]);
251    }
252
253    #[test]
254    fn cosmetic_changes_do_not_bump_version() {
255        let m = minted(5, None);
256        let records = vec![
257            LogRecord::Minted {
258                manifest: Box::new(m.clone()),
259            },
260            LogRecord::Mutation {
261                token: m.token,
262                at: Timestamp::from_unix_ms(2),
263                seq: Seq(1),
264                change: Change::LabelSet {
265                    key: "campaign".into(),
266                    value: "q3".into(),
267                },
268            },
269        ];
270        let folded = replay(records, ManifestFold::default());
271        let out = &folded.manifests[&m.token];
272        assert_eq!(out.version, 1);
273        assert_eq!(out.labels["campaign"], "q3");
274    }
275
276    #[test]
277    fn outcome_derivation_is_a_pure_function_of_counts() {
278        let m = minted(7, None);
279        let judged = |stages: &[Stage]| {
280            let records: Vec<LogRecord> = stages
281                .iter()
282                .enumerate()
283                .map(|(i, s)| event(m.token, s.clone(), u32::try_from(i).unwrap() + 1))
284                .collect();
285            let funnel = replay(records, FunnelFold::default());
286            outcome_of(funnel.per_token.get(&m.token).unwrap_or(&BTreeMap::new()))
287        };
288        assert_eq!(judged(&[]), Outcome::Pending);
289        assert_eq!(judged(&[Stage::resolve(), Stage::run()]), Outcome::Pending);
290        assert_eq!(judged(&[Stage::accepted()]), Outcome::Accepted);
291        assert_eq!(judged(&[Stage::rejected()]), Outcome::Rejected);
292        assert_eq!(
293            judged(&[Stage::accepted(), Stage::rejected()]),
294            Outcome::Contested,
295            "both verdicts surface honestly, never a silent overwrite"
296        );
297    }
298
299    #[test]
300    fn region_touches_or_together_shuffle_and_duplicate_immune() {
301        let m = minted(8, None);
302        let touch = |bits: u8, seq: u32| {
303            LogRecord::Event(Event {
304                token: m.token,
305                stage: Stage::read(),
306                actor: ActorClass::from_context(&ResolverContext::anonymous_agent()),
307                at: Timestamp::from_unix_ms(2),
308                seq: Seq(seq),
309                variant: None,
310                regions: Some(bits),
311            })
312        };
313        let records = vec![
314            touch(0b001, 1),
315            event(m.token, Stage::read(), 2), // no regions: contract-free access
316            touch(0b100, 3),
317            touch(0b001, 1), // duplicate record — OR absorbs it (R-3)
318        ];
319        let mut shuffled = records.clone();
320        shuffled.reverse();
321        let a = replay(records, RegionTouchFold::default());
322        let b = replay(shuffled, RegionTouchFold::default());
323        assert_eq!(a.per_token[&m.token], 0b101);
324        assert_eq!(a.per_token, b.per_token, "OR is commutative — R-1");
325    }
326
327    #[test]
328    fn mutation_before_mint_is_ignored_not_a_panic() {
329        // Hostile/misordered input: folds are total (reconstruct orders
330        // per-token, but folds must survive anything).
331        let m = minted(6, None);
332        let records = vec![LogRecord::Mutation {
333            token: m.token,
334            at: Timestamp::from_unix_ms(1),
335            seq: Seq(1),
336            change: Change::Revoked,
337        }];
338        let folded = replay(records, ManifestFold::default());
339        assert!(folded.manifests.is_empty());
340    }
341}