1use std::collections::{BTreeMap, BTreeSet};
8
9use crate::log::LogRecord;
10use crate::manifest::AttributionManifest;
11use crate::slug::Stage;
12use crate::token::Token;
13
14pub trait Fold {
18 fn apply(&mut self, record: &LogRecord);
20}
21
22impl<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
30pub 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#[derive(Debug, Default)]
43pub struct ManifestFold {
44 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 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#[derive(Debug, Default)]
74pub struct FunnelFold {
75 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#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
97#[serde(rename_all = "kebab-case")]
98pub enum Outcome {
99 Pending,
101 Accepted,
103 Rejected,
105 Contested,
108}
109
110#[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#[derive(Debug, Default)]
129pub struct RegionTouchFold {
130 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#[derive(Debug, Default)]
151pub struct EntryTouchFold {
152 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#[derive(Debug, Default)]
169pub struct LineageFold {
170 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 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 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), touch(0b100, 3),
341 touch(0b001, 1), ];
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), touch(0, 3),
370 touch(2, 1), ];
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 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}