1use std::collections::BTreeMap;
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)]
147pub struct LineageFold {
148 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 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 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), touch(0b100, 3),
317 touch(0b001, 1), ];
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 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}