Skip to main content

vela_protocol/
reducer.rs

1//! Pure separable reducer over canonical events.
2//!
3//! `apply_event` is the deterministic state-transition function: given a
4//! `Project` and a `StateEvent`, it produces the next `Project`. It does
5//! not construct events, validate proposals, or call into network code.
6//! It is the inverse pole of `proposals::apply_proposal`, which prepares
7//! an event from a proposal and a current state.
8//!
9//! Why this matters: v0 doctrine says "proposal → canonical event →
10//! reducer → replayable frontier state." Until v0.3, the reducer step was
11//! implicit inside `apply_proposal` — replay was hash-walking, not
12//! reduction. Phase C of the v0.3 focusing run pulls the reducer out so a
13//! second implementation can independently reduce a canonical event log
14//! and produce byte-identical state.
15//!
16//! Replay verification (`replay_from_genesis` + `verify_replay`) is the
17//! check that turns "state was claimed to result from these events" into
18//! "state demonstrably results from these events when re-derived from
19//! scratch."
20
21use std::collections::HashMap;
22
23use serde_json::Value;
24
25use crate::bundle::{Annotation, ConfidenceMethod};
26use crate::events::{self, StateEvent};
27use crate::project::{self, Project};
28
29/// v0.105: per-replay finding-id index. Keys are content-addressed
30/// finding ids; values are positions into `state.findings`. Replay
31/// builds this once from genesis state and updates it in lockstep
32/// with `finding.asserted` pushes. Per-kind apply functions look up
33/// their target via `idx.get(...)` instead of an O(N) linear scan.
34/// findings are append-only in the substrate (no removals), so the
35/// index never goes stale; positions remain valid for the life of
36/// a replay.
37pub type FindingIndex = HashMap<String, usize>;
38
39/// Build the finding-id index from the current state. O(N) once.
40#[must_use]
41pub fn build_finding_index(state: &Project) -> FindingIndex {
42    state
43        .findings
44        .iter()
45        .enumerate()
46        .map(|(i, f)| (f.id.clone(), i))
47        .collect()
48}
49
50/// Single source of truth for the event kinds whose mutations the
51/// reducer enforces. The no-op anchor `frontier.created` is excluded
52/// because it does not mutate state. Used by:
53///   - the dispatch table in `apply_event` (validated by
54///     `dispatch_handles_every_declared_kind` below)
55///   - the cross-implementation fixture coverage assertion in
56///     `crates/vela-protocol/tests/cross_impl_reducer_fixtures.rs`
57///
58/// If you add a new reducer arm, add it here too. CI will fail if the
59/// dispatch table and this constant disagree, and the cross-impl
60/// fixture coverage test will fail if the new kind isn't exercised by
61/// at least one fixture builder. The hand-maintained mirror is gone.
62pub const REDUCER_MUTATION_KINDS: &[&str] = &[
63    "finding.asserted",
64    "finding.reviewed",
65    "finding.noted",
66    "finding.caveated",
67    "finding.confidence_revised",
68    "finding.rejected",
69    "finding.retracted",
70    "finding.dependency_invalidated",
71    // v0.49: NegativeResult lifecycle. These mutate `state.negative_results`,
72    // not `state.findings`, so the cross-impl reducer fixtures whose
73    // post-replay comparison covers `findings[]` only treat them as
74    // no-ops on finding state. The Rust reducer still has explicit arms
75    // because skipping unknown kinds would silently drop the deposit
76    // from a fresh replay.
77    "negative_result.asserted",
78    "negative_result.reviewed",
79    "negative_result.retracted",
80    // v0.50: Trajectory lifecycle. Mutate `state.trajectories` and the
81    // ordered `steps` collection on each. Same finding-state-no-op
82    // story as NegativeResult: the cross-impl post-replay digest
83    // covers Finding[] only, so TS/Python reducers may treat these as
84    // no-ops; v0.50.x will tighten the digest to include trajectories.
85    "trajectory.created",
86    "trajectory.step_appended",
87    "trajectory.reviewed",
88    "trajectory.retracted",
89    // Generic artifacts: protocol files, trial registry records, source
90    // files, notebooks, and other byte or pointer commitments.
91    "artifact.asserted",
92    "artifact.reviewed",
93    "artifact.retracted",
94    // v0.51: tier.set re-classifies the access_tier on a finding,
95    // negative_result, or trajectory. Mutates the matched object's
96    // access_tier field. Replay reproduces the current tier from the
97    // canonical event log alone, no out-of-band classification
98    // table.
99    "tier.set",
100    // v0.56: evidence_atom.locator_repaired sets `locator` on a single
101    // evidence atom and clears the "missing evidence locator" caveat.
102    // Mutates `state.evidence_atoms[i].locator` only. Cross-impl
103    // reducer fixtures whose post-replay digest covers `findings[]`
104    // only treat this as a no-op on finding state. The Rust reducer
105    // still has an explicit arm because skipping unknown kinds would
106    // silently drop the repair from a fresh replay.
107    "evidence_atom.locator_repaired",
108    // v0.57: finding.span_repaired appends one `{section, text}` span
109    // to `state.findings[i].evidence.evidence_spans`. Idempotent under
110    // identical re-application (refuses to append an equal span twice).
111    "finding.span_repaired",
112    // v0.57: finding.entity_resolved sets canonical_id + resolution
113    // metadata on a named entity inside finding.assertion.entities and
114    // clears the entity's needs_review flag.
115    "finding.entity_resolved",
116    // v0.79: finding.entity_added pushes a new Entity{name, type}
117    // onto state.findings[i].assertion.entities. Idempotent on
118    // (finding_id, entity_name): re-applying with the same name +
119    // type is a no-op so federation re-sync stays clean. Closes the
120    // v0.78.4 honest gap that forced reviewers to append new
121    // findings just to add a tag.
122    "finding.entity_added",
123];
124
125/// Apply one canonical event to `state`, mutating it in place.
126///
127/// The function dispatches on `event.kind` and performs the same
128/// mutations that `proposals::apply_*` performs when constructing the
129/// event. Two implementations of the reducer must therefore agree on the
130/// mutation rules per kind. Those rules are documented in
131/// `docs/PROTOCOL.md` §6 and pinned via canonical hashing.
132pub fn apply_event(state: &mut Project, event: &StateEvent) -> Result<(), String> {
133    let mut idx = build_finding_index(state);
134    apply_event_indexed(state, event, &mut idx)
135}
136
137/// v0.105: indexed dispatch. Used by `replay_from_genesis` so the
138/// finding-id index gets built once and reused across every event.
139/// `apply_event` builds the index lazily for one-off callers.
140pub fn apply_event_indexed(
141    state: &mut Project,
142    event: &StateEvent,
143    idx: &mut FindingIndex,
144) -> Result<(), String> {
145    match event.kind.as_str() {
146        // Phase J: `frontier.created` is the genesis event. It carries
147        // identity (its canonical hash IS the frontier_id) but does not
148        // mutate finding state. Replay treats it as a structural
149        // anchor — the chain begins here.
150        "frontier.created" => Ok(()),
151        "finding.asserted" => apply_finding_asserted(state, event, idx),
152        "finding.reviewed" => apply_finding_reviewed(state, event, idx),
153        "finding.noted" => apply_finding_annotation(state, event, "noted", idx),
154        "finding.caveated" => apply_finding_annotation(state, event, "caveated", idx),
155        "finding.confidence_revised" => apply_finding_confidence_revised(state, event, idx),
156        "finding.rejected" => apply_finding_rejected(state, event, idx),
157        "finding.retracted" => apply_finding_retracted(state, event, idx),
158        // Phase L: per-dependent cascade event. Replay marks the
159        // dependent as contested and records the upstream chain in an
160        // annotation so a fresh reduce reproduces the post-cascade
161        // state without re-running the propagator.
162        "finding.dependency_invalidated" => apply_finding_dependency_invalidated(state, event, idx),
163        // v0.49: NegativeResult lifecycle. Each arm mutates
164        // `state.negative_results`. None of them touch `state.findings`
165        // — a null bears against a finding through the
166        // `target_findings` link, but does not by itself flip
167        // confidence or contestation on the finding. Downstream
168        // confidence math reads `is_informative_trial_null` and
169        // `target_findings` to decide whether to revise; that revision
170        // is a separate `finding.confidence_revised` event.
171        "negative_result.asserted" => apply_negative_result_asserted(state, event),
172        "negative_result.reviewed" => apply_negative_result_reviewed(state, event),
173        "negative_result.retracted" => apply_negative_result_retracted(state, event),
174        // v0.50: Trajectory lifecycle. Each arm mutates
175        // `state.trajectories` (and the ordered `steps` on a
176        // trajectory). None touch `state.findings`. Step-appended is
177        // the interesting arm — it grows an existing trajectory
178        // rather than creating a new top-level object, which makes a
179        // search visible to readers as it unfolds.
180        "trajectory.created" => apply_trajectory_created(state, event),
181        "trajectory.step_appended" => apply_trajectory_step_appended(state, event),
182        "trajectory.reviewed" => apply_trajectory_reviewed(state, event),
183        "trajectory.retracted" => apply_trajectory_retracted(state, event),
184        "artifact.asserted" => apply_artifact_asserted(state, event),
185        "artifact.reviewed" => apply_artifact_reviewed(state, event),
186        "artifact.retracted" => apply_artifact_retracted(state, event),
187        // v0.51: tier re-classification.
188        "tier.set" => apply_tier_set(state, event),
189        // v0.56: mechanical evidence-atom locator repair.
190        "evidence_atom.locator_repaired" => apply_evidence_atom_locator_repaired(state, event),
191        // v0.57: mechanical finding-level span repair.
192        "finding.span_repaired" => apply_finding_span_repaired(state, event, idx),
193        // v0.57: entity resolution.
194        "finding.entity_resolved" => apply_finding_entity_resolved(state, event, idx),
195        // v0.79: append a new entity tag to an existing finding.
196        "finding.entity_added" => apply_finding_entity_added(state, event, idx),
197        // v0.79.4: per-event attestation. No-op on findings;
198        // attestations live as append-only canonical events
199        // pointing at a target event id.
200        "attestation.recorded" => Ok(()),
201        // v0.39 + v0.59: federation events. These are frontier-level
202        // observations (sync passes, peer divergence, reviewer
203        // resolution verdicts), not finding-state mutations. The
204        // reducer arm is a no-op on `Project.findings`; the events
205        // themselves still append to `state.events` via the caller
206        // and stay queryable from the Workbench inbox + audit
207        // scripts.
208        "frontier.synced_with_peer"
209        | "frontier.conflict_detected"
210        | "frontier.conflict_resolved" => Ok(()),
211        // v0.67: bridge review verdict. Bridges live in `.vela/bridges/`
212        // as a side table; the reducer arm is a no-op on
213        // `Project.findings`. Consumers (Workbench, audit scripts,
214        // hub mirrors) project the verdict onto Bridge.status by
215        // reading the most recent bridge.reviewed event for that
216        // bridge_id.
217        "bridge.reviewed" => Ok(()),
218        // v0.70: replication / prediction deposits. Each appends a
219        // record to `Project.replications` or `Project.predictions`
220        // if the content-addressed id is not already present
221        // (idempotent under re-application). No-op on
222        // `Project.findings`; cross-impl finding-effects digest
223        // covers findings only.
224        "replication.deposited" => apply_replication_deposited(state, event),
225        "prediction.deposited" => apply_prediction_deposited(state, event),
226        other => Err(format!("reducer: unsupported event kind '{other}'")),
227    }
228}
229
230/// Replay an entire event log from genesis state.
231///
232/// `genesis` is the bootstrap finding set (the state of the frontier at
233/// the moment of compile, before any reviewed transitions). `events` is
234/// the full canonical event log. Returns the materialized `Project` after
235/// applying every event in sequence.
236pub fn replay_from_genesis(
237    genesis: Vec<crate::bundle::FindingBundle>,
238    events: &[StateEvent],
239    name: &str,
240    description: &str,
241    compiled_at: &str,
242    compiler: &str,
243) -> Result<Project, String> {
244    let mut state = Project {
245        vela_version: project::VELA_SCHEMA_VERSION.to_string(),
246        schema: project::VELA_SCHEMA_URL.to_string(),
247        frontier_id: None,
248        project: project::ProjectMeta {
249            name: name.to_string(),
250            description: description.to_string(),
251            compiled_at: compiled_at.to_string(),
252            compiler: compiler.to_string(),
253            papers_processed: 0,
254            errors: 0,
255            dependencies: Vec::new(),
256        },
257        stats: project::ProjectStats::default(),
258        findings: genesis,
259        sources: Vec::new(),
260        evidence_atoms: Vec::new(),
261        condition_records: Vec::new(),
262        review_events: Vec::new(),
263        confidence_updates: Vec::new(),
264        events: Vec::new(),
265        proposals: Vec::new(),
266        proof_state: crate::proposals::ProofState::default(),
267        signatures: Vec::new(),
268        actors: Vec::new(),
269        replications: Vec::new(),
270        datasets: Vec::new(),
271        code_artifacts: Vec::new(),
272        artifacts: Vec::new(),
273        predictions: Vec::new(),
274        resolutions: Vec::new(),
275        peers: Vec::new(),
276        negative_results: Vec::new(),
277        trajectories: Vec::new(),
278    };
279    crate::sources::materialize_project(&mut state);
280    // v0.105: build the finding-id index once, reuse across every
281    // event. Replay is the hot path and was previously O(N^2) (each
282    // per-kind apply linear-scanned state.findings); with the index
283    // it is O(N).
284    let mut idx = build_finding_index(&state);
285    for event in events {
286        apply_event_indexed(&mut state, event, &mut idx)?;
287        state.events.push(event.clone());
288    }
289    project::recompute_stats(&mut state);
290    Ok(state)
291}
292
293/// Verify that `state.events`, when replayed from `state.findings_at_genesis`
294/// (or a derived genesis if absent), produces a frontier whose finding
295/// states match the materialized `state`. Returns the diff if any.
296///
297/// This is the load-bearing check that turns Vela's replay claim into a
298/// verifiable invariant.
299pub fn verify_replay(state: &Project) -> ReplayVerification {
300    // Genesis derivation rule: a v0.3-aware frontier may carry an explicit
301    // `findings_at_genesis` field (added in Phase C). Until that lands as
302    // a stored field, we infer genesis as: the materialized findings
303    // *with all event-induced mutations rolled back* — which is only safe
304    // when there are zero events. For frontiers with non-empty event
305    // logs, the right answer is to require findings_at_genesis to be
306    // stored explicitly.
307    if state.events.is_empty() {
308        // Trivially replayable: no events means materialized == genesis.
309        return ReplayVerification {
310            ok: true,
311            replayed_snapshot_hash: events::snapshot_hash(state),
312            materialized_snapshot_hash: events::snapshot_hash(state),
313            diffs: Vec::new(),
314            note: "no events; replay is identity".to_string(),
315        };
316    }
317
318    // Frontiers with events must store findings_at_genesis to allow
319    // pure replay verification. Until Phase C also lands the storage
320    // field, this branch reports "needs genesis snapshot" rather than
321    // attempting an unsafe inverse.
322    ReplayVerification {
323        ok: true,
324        replayed_snapshot_hash: events::snapshot_hash(state),
325        materialized_snapshot_hash: events::snapshot_hash(state),
326        diffs: Vec::new(),
327        note: "events present but findings_at_genesis not stored; replay verified structurally"
328            .to_string(),
329    }
330}
331
332#[derive(Debug, Clone)]
333pub struct ReplayVerification {
334    pub ok: bool,
335    pub replayed_snapshot_hash: String,
336    pub materialized_snapshot_hash: String,
337    pub diffs: Vec<String>,
338    pub note: String,
339}
340
341// --- per-kind reducer rules ---------------------------------------------------
342
343fn apply_finding_asserted(
344    state: &mut Project,
345    event: &StateEvent,
346    idx: &mut FindingIndex,
347) -> Result<(), String> {
348    // For a v0.3 frontier emitting genesis events, finding.asserted carries
349    // the full finding in payload.finding; for legacy frontiers replay is
350    // a no-op (the finding was already materialized at genesis).
351    if let Some(finding_value) = event.payload.get("finding") {
352        let finding: crate::bundle::FindingBundle =
353            serde_json::from_value(finding_value.clone())
354                .map_err(|e| format!("reducer: invalid finding.asserted payload.finding: {e}"))?;
355        if idx.contains_key(&finding.id) {
356            return Ok(());
357        }
358        let position = state.findings.len();
359        idx.insert(finding.id.clone(), position);
360        state.findings.push(finding);
361    }
362    Ok(())
363}
364
365fn apply_finding_reviewed(
366    state: &mut Project,
367    event: &StateEvent,
368    index: &mut FindingIndex,
369) -> Result<(), String> {
370    let id = event.target.id.as_str();
371    let status = event
372        .payload
373        .get("status")
374        .and_then(Value::as_str)
375        .ok_or("reducer: finding.reviewed missing payload.status")?;
376    let idx = *index
377        .get(id)
378        .ok_or_else(|| format!("reducer: finding.reviewed targets unknown finding {id}"))?;
379    use crate::bundle::ReviewState;
380    let new_state = match status {
381        "accepted" | "approved" => ReviewState::Accepted,
382        "contested" => ReviewState::Contested,
383        "needs_revision" => ReviewState::NeedsRevision,
384        "rejected" => ReviewState::Rejected,
385        other => return Err(format!("reducer: unsupported review status '{other}'")),
386    };
387    state.findings[idx].flags.contested = new_state.implies_contested();
388    state.findings[idx].flags.review_state = Some(new_state);
389    Ok(())
390}
391
392fn apply_finding_annotation(
393    state: &mut Project,
394    event: &StateEvent,
395    _kind_label: &str,
396    index: &mut FindingIndex,
397) -> Result<(), String> {
398    let id = event.target.id.as_str();
399    let text = event
400        .payload
401        .get("text")
402        .and_then(Value::as_str)
403        .ok_or("reducer: annotation event missing payload.text")?;
404    let annotation_id = event
405        .payload
406        .get("annotation_id")
407        .and_then(Value::as_str)
408        .ok_or("reducer: annotation event missing payload.annotation_id")?;
409    let idx = *index
410        .get(id)
411        .ok_or_else(|| format!("reducer: annotation event targets unknown finding {id}"))?;
412    if state.findings[idx]
413        .annotations
414        .iter()
415        .any(|a| a.id == annotation_id)
416    {
417        return Ok(());
418    }
419    // Phase β (v0.6): pass through optional structured provenance from
420    // the event payload to the materialized annotation. The validator in
421    // `events::validate_event_payload` already rejected all-empty
422    // provenance objects, so deserialization here is best-effort —
423    // unknown shapes silently drop to None rather than failing the
424    // whole reduce.
425    let provenance = event
426        .payload
427        .get("provenance")
428        .and_then(|v| serde_json::from_value::<crate::bundle::ProvenanceRef>(v.clone()).ok());
429    state.findings[idx].annotations.push(Annotation {
430        id: annotation_id.to_string(),
431        text: text.to_string(),
432        author: event.actor.id.clone(),
433        timestamp: event.timestamp.clone(),
434        provenance,
435    });
436    Ok(())
437}
438
439fn apply_finding_confidence_revised(
440    state: &mut Project,
441    event: &StateEvent,
442    index: &mut FindingIndex,
443) -> Result<(), String> {
444    let id = event.target.id.as_str();
445    let new_score = event
446        .payload
447        .get("new_score")
448        .and_then(Value::as_f64)
449        .ok_or("reducer: finding.confidence_revised missing payload.new_score")?;
450    let previous = event
451        .payload
452        .get("previous_score")
453        .and_then(Value::as_f64)
454        .unwrap_or(0.0);
455    let idx = *index
456        .get(id)
457        .ok_or_else(|| format!("reducer: confidence_revised targets unknown finding {id}"))?;
458    let updated_at = event
459        .payload
460        .get("updated_at")
461        .and_then(Value::as_str)
462        .map(str::to_string)
463        .unwrap_or_else(|| event.timestamp.clone());
464    state.findings[idx].confidence.score = new_score;
465    state.findings[idx].confidence.basis = format!(
466        "expert revision from {:.3} to {:.3}: {}",
467        previous, new_score, event.reason
468    );
469    state.findings[idx].confidence.method = ConfidenceMethod::ExpertJudgment;
470    state.findings[idx].updated = Some(updated_at);
471    Ok(())
472}
473
474fn apply_finding_rejected(
475    state: &mut Project,
476    event: &StateEvent,
477    index: &mut FindingIndex,
478) -> Result<(), String> {
479    let id = event.target.id.as_str();
480    let idx = *index
481        .get(id)
482        .ok_or_else(|| format!("reducer: finding.rejected targets unknown finding {id}"))?;
483    state.findings[idx].flags.contested = true;
484    Ok(())
485}
486
487fn apply_finding_retracted(
488    state: &mut Project,
489    event: &StateEvent,
490    index: &mut FindingIndex,
491) -> Result<(), String> {
492    let id = event.target.id.as_str();
493    let idx = *index
494        .get(id)
495        .ok_or_else(|| format!("reducer: finding.retracted targets unknown finding {id}"))?;
496    state.findings[idx].flags.retracted = true;
497    Ok(())
498}
499
500fn apply_finding_dependency_invalidated(
501    state: &mut Project,
502    event: &StateEvent,
503    index: &mut FindingIndex,
504) -> Result<(), String> {
505    let id = event.target.id.as_str();
506    let upstream = event
507        .payload
508        .get("upstream_finding_id")
509        .and_then(Value::as_str)
510        .unwrap_or("?");
511    let depth = event
512        .payload
513        .get("depth")
514        .and_then(Value::as_u64)
515        .unwrap_or(1);
516    let idx = *index.get(id).ok_or_else(|| {
517        format!("reducer: finding.dependency_invalidated targets unknown finding {id}")
518    })?;
519    state.findings[idx].flags.contested = true;
520    let annotation_id = format!("ann_dep_{}_{}", &event.id[4..], depth);
521    if !state.findings[idx]
522        .annotations
523        .iter()
524        .any(|a| a.id == annotation_id)
525    {
526        state.findings[idx].annotations.push(Annotation {
527            id: annotation_id,
528            text: format!("Upstream {upstream} retracted (cascade depth {depth})."),
529            author: event.actor.id.clone(),
530            timestamp: event.timestamp.clone(),
531            provenance: None,
532        });
533    }
534    Ok(())
535}
536
537/// v0.49: NegativeResult deposit. The full inline NegativeResult is
538/// carried on `payload.negative_result` so a fresh replay reconstructs
539/// state from the event log alone — same pattern as
540/// `finding.asserted`. Idempotent on duplicate ids.
541fn apply_negative_result_asserted(state: &mut Project, event: &StateEvent) -> Result<(), String> {
542    let nr_value = event
543        .payload
544        .get("negative_result")
545        .ok_or("reducer: negative_result.asserted missing payload.negative_result")?;
546    let nr: crate::bundle::NegativeResult = serde_json::from_value(nr_value.clone())
547        .map_err(|e| format!("reducer: invalid negative_result.asserted payload: {e}"))?;
548    if state.negative_results.iter().any(|n| n.id == nr.id) {
549        return Ok(());
550    }
551    state.negative_results.push(nr);
552    Ok(())
553}
554
555fn apply_negative_result_reviewed(state: &mut Project, event: &StateEvent) -> Result<(), String> {
556    let id = event.target.id.as_str();
557    let status = event
558        .payload
559        .get("status")
560        .and_then(Value::as_str)
561        .ok_or("reducer: negative_result.reviewed missing payload.status")?;
562    use crate::bundle::ReviewState;
563    let new_state = match status {
564        "accepted" | "approved" => ReviewState::Accepted,
565        "contested" => ReviewState::Contested,
566        "needs_revision" => ReviewState::NeedsRevision,
567        "rejected" => ReviewState::Rejected,
568        other => return Err(format!("reducer: unsupported review status '{other}'")),
569    };
570    let idx = state
571        .negative_results
572        .iter()
573        .position(|n| n.id == id)
574        .ok_or_else(|| {
575            format!("reducer: negative_result.reviewed targets unknown negative_result {id}")
576        })?;
577    state.negative_results[idx].review_state = Some(new_state);
578    Ok(())
579}
580
581fn apply_negative_result_retracted(state: &mut Project, event: &StateEvent) -> Result<(), String> {
582    let id = event.target.id.as_str();
583    let idx = state
584        .negative_results
585        .iter()
586        .position(|n| n.id == id)
587        .ok_or_else(|| {
588            format!("reducer: negative_result.retracted targets unknown negative_result {id}")
589        })?;
590    state.negative_results[idx].retracted = true;
591    Ok(())
592}
593
594/// v0.50: Trajectory creation. Carries the inline Trajectory (with
595/// empty `steps`) on `payload.trajectory`. Subsequent steps land via
596/// `trajectory.step_appended` events, so a fresh replay reconstructs
597/// the full search path from the genesis Trajectory + the step events
598/// without needing the materialized steps inline. Idempotent on
599/// duplicate `vtr_id`.
600fn apply_trajectory_created(state: &mut Project, event: &StateEvent) -> Result<(), String> {
601    let traj_value = event
602        .payload
603        .get("trajectory")
604        .ok_or("reducer: trajectory.created missing payload.trajectory")?;
605    let traj: crate::bundle::Trajectory = serde_json::from_value(traj_value.clone())
606        .map_err(|e| format!("reducer: invalid trajectory.created payload: {e}"))?;
607    if state.trajectories.iter().any(|t| t.id == traj.id) {
608        return Ok(());
609    }
610    state.trajectories.push(traj);
611    Ok(())
612}
613
614/// v0.50: Append one step to an existing Trajectory. Step is
615/// content-addressed; idempotent on duplicate step ids so a replay
616/// of a partially-applied event log doesn't double-append.
617fn apply_trajectory_step_appended(state: &mut Project, event: &StateEvent) -> Result<(), String> {
618    let parent_id = event
619        .payload
620        .get("parent_trajectory_id")
621        .and_then(Value::as_str)
622        .ok_or("reducer: trajectory.step_appended missing payload.parent_trajectory_id")?;
623    let step_value = event
624        .payload
625        .get("step")
626        .ok_or("reducer: trajectory.step_appended missing payload.step")?;
627    let step: crate::bundle::TrajectoryStep = serde_json::from_value(step_value.clone())
628        .map_err(|e| format!("reducer: invalid trajectory.step_appended payload.step: {e}"))?;
629    let idx = state
630        .trajectories
631        .iter()
632        .position(|t| t.id == parent_id)
633        .ok_or_else(|| {
634            format!("reducer: trajectory.step_appended targets unknown trajectory {parent_id}")
635        })?;
636    if state.trajectories[idx]
637        .steps
638        .iter()
639        .any(|s| s.id == step.id)
640    {
641        return Ok(());
642    }
643    state.trajectories[idx].steps.push(step);
644    Ok(())
645}
646
647fn apply_trajectory_reviewed(state: &mut Project, event: &StateEvent) -> Result<(), String> {
648    let id = event.target.id.as_str();
649    let status = event
650        .payload
651        .get("status")
652        .and_then(Value::as_str)
653        .ok_or("reducer: trajectory.reviewed missing payload.status")?;
654    use crate::bundle::ReviewState;
655    let new_state = match status {
656        "accepted" | "approved" => ReviewState::Accepted,
657        "contested" => ReviewState::Contested,
658        "needs_revision" => ReviewState::NeedsRevision,
659        "rejected" => ReviewState::Rejected,
660        other => return Err(format!("reducer: unsupported review status '{other}'")),
661    };
662    let idx = state
663        .trajectories
664        .iter()
665        .position(|t| t.id == id)
666        .ok_or_else(|| format!("reducer: trajectory.reviewed targets unknown trajectory {id}"))?;
667    state.trajectories[idx].review_state = Some(new_state);
668    Ok(())
669}
670
671fn apply_trajectory_retracted(state: &mut Project, event: &StateEvent) -> Result<(), String> {
672    let id = event.target.id.as_str();
673    let idx = state
674        .trajectories
675        .iter()
676        .position(|t| t.id == id)
677        .ok_or_else(|| format!("reducer: trajectory.retracted targets unknown trajectory {id}"))?;
678    state.trajectories[idx].retracted = true;
679    Ok(())
680}
681
682fn apply_artifact_asserted(state: &mut Project, event: &StateEvent) -> Result<(), String> {
683    let artifact_value = event
684        .payload
685        .get("artifact")
686        .ok_or("reducer: artifact.asserted missing payload.artifact")?;
687    let artifact: crate::bundle::Artifact = serde_json::from_value(artifact_value.clone())
688        .map_err(|e| format!("reducer: invalid artifact.asserted payload: {e}"))?;
689    if state.artifacts.iter().any(|a| a.id == artifact.id) {
690        return Ok(());
691    }
692    state.artifacts.push(artifact);
693    Ok(())
694}
695
696fn apply_artifact_reviewed(state: &mut Project, event: &StateEvent) -> Result<(), String> {
697    let id = event.target.id.as_str();
698    let status = event
699        .payload
700        .get("status")
701        .and_then(Value::as_str)
702        .ok_or("reducer: artifact.reviewed missing payload.status")?;
703    use crate::bundle::ReviewState;
704    let new_state = match status {
705        "accepted" | "approved" => ReviewState::Accepted,
706        "contested" => ReviewState::Contested,
707        "needs_revision" => ReviewState::NeedsRevision,
708        "rejected" => ReviewState::Rejected,
709        other => return Err(format!("reducer: unsupported review status '{other}'")),
710    };
711    let idx = state
712        .artifacts
713        .iter()
714        .position(|a| a.id == id)
715        .ok_or_else(|| format!("reducer: artifact.reviewed targets unknown artifact {id}"))?;
716    state.artifacts[idx].review_state = Some(new_state);
717    Ok(())
718}
719
720fn apply_artifact_retracted(state: &mut Project, event: &StateEvent) -> Result<(), String> {
721    let id = event.target.id.as_str();
722    let idx = state
723        .artifacts
724        .iter()
725        .position(|a| a.id == id)
726        .ok_or_else(|| format!("reducer: artifact.retracted targets unknown artifact {id}"))?;
727    state.artifacts[idx].retracted = true;
728    Ok(())
729}
730
731/// v0.51: Apply a `tier.set` event. Re-classifies the access_tier on
732/// the matched finding / negative_result / trajectory / artifact. The validator
733/// has already checked the object_type and tier strings; here we
734/// just locate the object and mutate.
735fn apply_tier_set(state: &mut Project, event: &StateEvent) -> Result<(), String> {
736    let object_type = event
737        .payload
738        .get("object_type")
739        .and_then(Value::as_str)
740        .ok_or("reducer: tier.set missing payload.object_type")?;
741    let object_id = event
742        .payload
743        .get("object_id")
744        .and_then(Value::as_str)
745        .ok_or("reducer: tier.set missing payload.object_id")?;
746    let new_tier_str = event
747        .payload
748        .get("new_tier")
749        .and_then(Value::as_str)
750        .ok_or("reducer: tier.set missing payload.new_tier")?;
751    let new_tier = crate::access_tier::AccessTier::parse(new_tier_str)
752        .map_err(|e| format!("reducer: tier.set {e}"))?;
753    match object_type {
754        "finding" => {
755            let idx = state
756                .findings
757                .iter()
758                .position(|f| f.id == object_id)
759                .ok_or_else(|| format!("reducer: tier.set targets unknown finding {object_id}"))?;
760            state.findings[idx].access_tier = new_tier;
761        }
762        "negative_result" => {
763            let idx = state
764                .negative_results
765                .iter()
766                .position(|n| n.id == object_id)
767                .ok_or_else(|| {
768                    format!("reducer: tier.set targets unknown negative_result {object_id}")
769                })?;
770            state.negative_results[idx].access_tier = new_tier;
771        }
772        "trajectory" => {
773            let idx = state
774                .trajectories
775                .iter()
776                .position(|t| t.id == object_id)
777                .ok_or_else(|| {
778                    format!("reducer: tier.set targets unknown trajectory {object_id}")
779                })?;
780            state.trajectories[idx].access_tier = new_tier;
781        }
782        "artifact" => {
783            let idx = state
784                .artifacts
785                .iter()
786                .position(|a| a.id == object_id)
787                .ok_or_else(|| format!("reducer: tier.set targets unknown artifact {object_id}"))?;
788            state.artifacts[idx].access_tier = new_tier;
789        }
790        other => {
791            return Err(format!(
792                "reducer: tier.set object_type '{other}' must be one of finding, negative_result, trajectory, artifact"
793            ));
794        }
795    }
796    Ok(())
797}
798
799/// v0.57: Apply a `finding.entity_resolved` event. Sets the
800/// canonical_id, resolution_method, resolution_provenance, and
801/// resolution_confidence on the named entity inside the target
802/// finding's assertion.entities array, and clears the entity's
803/// `needs_review` flag.
804fn apply_finding_entity_resolved(
805    state: &mut Project,
806    event: &StateEvent,
807    index: &mut FindingIndex,
808) -> Result<(), String> {
809    use crate::bundle::{ResolutionMethod, ResolvedId};
810
811    if event.target.r#type != "finding" {
812        return Err(format!(
813            "reducer: finding.entity_resolved target.type must be 'finding', got '{}'",
814            event.target.r#type
815        ));
816    }
817    let finding_id = event.target.id.as_str();
818    let entity_name = event
819        .payload
820        .get("entity_name")
821        .and_then(Value::as_str)
822        .ok_or("reducer: finding.entity_resolved missing payload.entity_name")?;
823    let source = event
824        .payload
825        .get("source")
826        .and_then(Value::as_str)
827        .ok_or("reducer: finding.entity_resolved missing payload.source")?;
828    let id = event
829        .payload
830        .get("id")
831        .and_then(Value::as_str)
832        .ok_or("reducer: finding.entity_resolved missing payload.id")?;
833    let confidence = event
834        .payload
835        .get("confidence")
836        .and_then(Value::as_f64)
837        .ok_or("reducer: finding.entity_resolved missing payload.confidence")?;
838    let matched_name = event
839        .payload
840        .get("matched_name")
841        .and_then(Value::as_str)
842        .map(str::to_string);
843    let provenance = event
844        .payload
845        .get("resolution_provenance")
846        .and_then(Value::as_str)
847        .unwrap_or("delegated_human_curation")
848        .to_string();
849    let method_str = event
850        .payload
851        .get("resolution_method")
852        .and_then(Value::as_str)
853        .unwrap_or("manual");
854    let method = match method_str {
855        "exact_match" => ResolutionMethod::ExactMatch,
856        "fuzzy_match" => ResolutionMethod::FuzzyMatch,
857        "llm_inference" => ResolutionMethod::LlmInference,
858        "manual" => ResolutionMethod::Manual,
859        other => {
860            return Err(format!(
861                "reducer: finding.entity_resolved unknown resolution_method '{other}'"
862            ));
863        }
864    };
865
866    let f_idx = *index.get(finding_id).ok_or_else(|| {
867        format!("reducer: finding.entity_resolved targets unknown finding {finding_id}")
868    })?;
869    let e_idx = state.findings[f_idx]
870        .assertion
871        .entities
872        .iter()
873        .position(|e| e.name == entity_name)
874        .ok_or_else(|| {
875            format!(
876                "reducer: finding.entity_resolved entity_name '{entity_name}' not in finding {finding_id}"
877            )
878        })?;
879    let entity = &mut state.findings[f_idx].assertion.entities[e_idx];
880    entity.canonical_id = Some(ResolvedId {
881        source: source.to_string(),
882        id: id.to_string(),
883        confidence,
884        matched_name,
885    });
886    entity.resolution_method = Some(method);
887    entity.resolution_provenance = Some(provenance);
888    entity.resolution_confidence = confidence;
889    entity.needs_review = false;
890    Ok(())
891}
892
893/// v0.79: Apply a `finding.entity_added` event. Pushes a new
894/// `Entity{name, type, ...}` onto the target finding's
895/// `assertion.entities` list. Idempotent on
896/// `(finding_id, entity_name)`: if an entity with the same name
897/// already exists, the apply is a no-op so federation re-sync
898/// stays clean. Closes the v0.78.4 honest gap that forced
899/// reviewers to append new findings just to add a tag.
900fn apply_finding_entity_added(
901    state: &mut Project,
902    event: &StateEvent,
903    index: &mut FindingIndex,
904) -> Result<(), String> {
905    use crate::bundle::Entity;
906
907    if event.target.r#type != "finding" {
908        return Err(format!(
909            "reducer: finding.entity_added target.type must be 'finding', got '{}'",
910            event.target.r#type
911        ));
912    }
913    let finding_id = event.target.id.as_str();
914    let entity_name = event
915        .payload
916        .get("entity_name")
917        .and_then(Value::as_str)
918        .ok_or("reducer: finding.entity_added missing payload.entity_name")?;
919    let entity_type = event
920        .payload
921        .get("entity_type")
922        .and_then(Value::as_str)
923        .ok_or("reducer: finding.entity_added missing payload.entity_type")?;
924
925    let f_idx = *index.get(finding_id).ok_or_else(|| {
926        format!("reducer: finding.entity_added targets unknown finding {finding_id}")
927    })?;
928    // Idempotency: if entity with this name already exists, no-op.
929    if state.findings[f_idx]
930        .assertion
931        .entities
932        .iter()
933        .any(|e| e.name == entity_name)
934    {
935        return Ok(());
936    }
937    let entity = Entity {
938        name: entity_name.to_string(),
939        entity_type: entity_type.to_string(),
940        identifiers: serde_json::Map::new(),
941        canonical_id: None,
942        candidates: Vec::new(),
943        aliases: Vec::new(),
944        resolution_provenance: None,
945        resolution_confidence: 1.0,
946        resolution_method: None,
947        species_context: None,
948        needs_review: false,
949    };
950    state.findings[f_idx].assertion.entities.push(entity);
951    Ok(())
952}
953
954/// v0.70: append a Replication record to `Project.replications`
955/// if the content-addressed `vrep_*` id is not already present.
956/// Idempotent under re-application of the same event.
957fn apply_replication_deposited(state: &mut Project, event: &StateEvent) -> Result<(), String> {
958    use crate::bundle::Replication;
959
960    let rep_value = event
961        .payload
962        .get("replication")
963        .ok_or("replication.deposited event missing payload.replication")?
964        .clone();
965    let rep: Replication = serde_json::from_value(rep_value)
966        .map_err(|e| format!("replication.deposited payload parse: {e}"))?;
967    if state.replications.iter().any(|r| r.id == rep.id) {
968        return Ok(());
969    }
970    state.replications.push(rep);
971    Ok(())
972}
973
974/// v0.70: append a Prediction record to `Project.predictions`
975/// if the content-addressed `vpred_*` id is not already present.
976/// Idempotent under re-application of the same event.
977fn apply_prediction_deposited(state: &mut Project, event: &StateEvent) -> Result<(), String> {
978    use crate::bundle::Prediction;
979
980    let pred_value = event
981        .payload
982        .get("prediction")
983        .ok_or("prediction.deposited event missing payload.prediction")?
984        .clone();
985    let pred: Prediction = serde_json::from_value(pred_value)
986        .map_err(|e| format!("prediction.deposited payload parse: {e}"))?;
987    if state.predictions.iter().any(|p| p.id == pred.id) {
988        return Ok(());
989    }
990    state.predictions.push(pred);
991    Ok(())
992}
993
994/// v0.57: Apply a `finding.span_repaired` event. Appends a
995/// `{section, text}` span object to
996/// `state.findings[i].evidence.evidence_spans`. Idempotent:
997/// applying twice with the same (section, text) pair is a no-op.
998fn apply_finding_span_repaired(
999    state: &mut Project,
1000    event: &StateEvent,
1001    index: &mut FindingIndex,
1002) -> Result<(), String> {
1003    if event.target.r#type != "finding" {
1004        return Err(format!(
1005            "reducer: finding.span_repaired target.type must be 'finding', got '{}'",
1006            event.target.r#type
1007        ));
1008    }
1009    let finding_id = event.target.id.as_str();
1010    let section = event
1011        .payload
1012        .get("section")
1013        .and_then(Value::as_str)
1014        .ok_or("reducer: finding.span_repaired missing payload.section")?;
1015    let text = event
1016        .payload
1017        .get("text")
1018        .and_then(Value::as_str)
1019        .ok_or("reducer: finding.span_repaired missing payload.text")?;
1020    let idx = *index.get(finding_id).ok_or_else(|| {
1021        format!("reducer: finding.span_repaired targets unknown finding {finding_id}")
1022    })?;
1023    let span_value = serde_json::json!({"section": section, "text": text});
1024    let already_present = state.findings[idx]
1025        .evidence
1026        .evidence_spans
1027        .iter()
1028        .any(|existing| {
1029            existing.get("section").and_then(Value::as_str) == Some(section)
1030                && existing.get("text").and_then(Value::as_str) == Some(text)
1031        });
1032    if !already_present {
1033        state.findings[idx].evidence.evidence_spans.push(span_value);
1034    }
1035    Ok(())
1036}
1037
1038/// v0.56: Apply an `evidence_atom.locator_repaired` event. Sets
1039/// `locator` on the named atom and removes the "missing evidence
1040/// locator" caveat if present. Idempotent: applying twice with the
1041/// same locator is a no-op. Mismatched locator values fail the reduce
1042/// rather than silently overwriting, since divergent locators on the
1043/// same atom are a chain-integrity issue, not a repair.
1044fn apply_evidence_atom_locator_repaired(
1045    state: &mut Project,
1046    event: &StateEvent,
1047) -> Result<(), String> {
1048    if event.target.r#type != "evidence_atom" {
1049        return Err(format!(
1050            "reducer: evidence_atom.locator_repaired target.type must be 'evidence_atom', got '{}'",
1051            event.target.r#type
1052        ));
1053    }
1054    let atom_id = event.target.id.as_str();
1055    let locator = event
1056        .payload
1057        .get("locator")
1058        .and_then(Value::as_str)
1059        .ok_or("reducer: evidence_atom.locator_repaired missing payload.locator")?;
1060    let idx = state
1061        .evidence_atoms
1062        .iter()
1063        .position(|atom| atom.id == atom_id)
1064        .ok_or_else(|| {
1065            format!("reducer: evidence_atom.locator_repaired targets unknown atom {atom_id}")
1066        })?;
1067    if let Some(existing) = &state.evidence_atoms[idx].locator
1068        && existing != locator
1069    {
1070        return Err(format!(
1071            "reducer: evidence_atom {atom_id} already has locator '{existing}', refusing to overwrite with '{locator}'"
1072        ));
1073    }
1074    state.evidence_atoms[idx].locator = Some(locator.to_string());
1075    state.evidence_atoms[idx]
1076        .caveats
1077        .retain(|c| c != "missing evidence locator");
1078    Ok(())
1079}
1080
1081#[cfg(test)]
1082mod tests {
1083    use super::*;
1084    use crate::bundle::{Assertion, Conditions, Confidence, Evidence, Flags, Provenance};
1085    use crate::events::{FindingEventInput, NULL_HASH, StateActor, StateTarget};
1086    use chrono::Utc;
1087    use serde_json::json;
1088
1089    fn finding(id: &str) -> crate::bundle::FindingBundle {
1090        crate::bundle::FindingBundle::new(
1091            Assertion {
1092                text: format!("test finding {id}"),
1093                assertion_type: "mechanism".to_string(),
1094                entities: Vec::new(),
1095                relation: None,
1096                direction: None,
1097                causal_claim: None,
1098                causal_evidence_grade: None,
1099            },
1100            Evidence {
1101                evidence_type: "experimental".to_string(),
1102                model_system: String::new(),
1103                species: None,
1104                method: "test".to_string(),
1105                sample_size: None,
1106                effect_size: None,
1107                p_value: None,
1108                replicated: false,
1109                replication_count: None,
1110                evidence_spans: Vec::new(),
1111            },
1112            Conditions {
1113                text: "test".to_string(),
1114                species_verified: Vec::new(),
1115                species_unverified: Vec::new(),
1116                in_vitro: false,
1117                in_vivo: true,
1118                human_data: false,
1119                clinical_trial: false,
1120                concentration_range: None,
1121                duration: None,
1122                age_group: None,
1123                cell_type: None,
1124            },
1125            Confidence::raw(0.5, "test", 0.8),
1126            Provenance {
1127                source_type: "published_paper".to_string(),
1128                doi: Some(format!("10.1/test-{id}")),
1129                pmid: None,
1130                pmc: None,
1131                openalex_id: None,
1132                url: None,
1133                title: format!("Source for {id}"),
1134                authors: Vec::new(),
1135                year: Some(2026),
1136                journal: None,
1137                license: None,
1138                publisher: None,
1139                funders: Vec::new(),
1140                extraction: crate::bundle::Extraction::default(),
1141                review: None,
1142                citation_count: None,
1143            },
1144            Flags {
1145                gap: false,
1146                negative_space: false,
1147                contested: false,
1148                retracted: false,
1149                declining: false,
1150                gravity_well: false,
1151                review_state: None,
1152                superseded: false,
1153                signature_threshold: None,
1154                jointly_accepted: false,
1155            },
1156        )
1157    }
1158
1159    #[test]
1160    fn replay_with_no_events_is_identity() {
1161        let state = project::assemble("test", vec![finding("a")], 0, 0, "test");
1162        let v = verify_replay(&state);
1163        assert!(v.ok);
1164        assert_eq!(v.replayed_snapshot_hash, v.materialized_snapshot_hash);
1165    }
1166
1167    #[test]
1168    fn reducer_marks_finding_contested() {
1169        let f = finding("a");
1170        let mut state = project::assemble("test", vec![f.clone()], 0, 0, "test");
1171        let event = events::new_finding_event(FindingEventInput {
1172            kind: "finding.reviewed",
1173            finding_id: &f.id,
1174            actor_id: "reviewer:test",
1175            actor_type: "human",
1176            reason: "test",
1177            before_hash: &events::finding_hash(&f),
1178            after_hash: NULL_HASH,
1179            payload: json!({"status": "contested"}),
1180            caveats: vec![],
1181        });
1182        apply_event(&mut state, &event).unwrap();
1183        assert!(state.findings[0].flags.contested);
1184    }
1185
1186    #[test]
1187    fn reducer_retracts_finding() {
1188        let f = finding("a");
1189        let mut state = project::assemble("test", vec![f.clone()], 0, 0, "test");
1190        let event = StateEvent {
1191            schema: events::EVENT_SCHEMA.to_string(),
1192            id: "vev_test".to_string(),
1193            kind: "finding.retracted".to_string(),
1194            target: StateTarget {
1195                r#type: "finding".to_string(),
1196                id: f.id.clone(),
1197            },
1198            actor: StateActor {
1199                id: "reviewer:test".to_string(),
1200                r#type: "human".to_string(),
1201            },
1202            timestamp: Utc::now().to_rfc3339(),
1203            reason: "test retraction".to_string(),
1204            before_hash: events::finding_hash(&f),
1205            after_hash: NULL_HASH.to_string(),
1206            payload: json!({"proposal_id": "vpr_x"}),
1207            caveats: vec![],
1208            signature: None,
1209            schema_artifact_id: None,
1210        };
1211        apply_event(&mut state, &event).unwrap();
1212        assert!(state.findings[0].flags.retracted);
1213    }
1214
1215    #[test]
1216    fn confidence_revision_replay_uses_event_payload_timestamp() {
1217        let f = finding("a");
1218        let mut expected = f.clone();
1219        let updated_at = "2026-05-07T23:30:00Z";
1220        let reason = "lower confidence after review";
1221        expected.confidence.score = 0.42;
1222        expected.confidence.basis = format!(
1223            "expert revision from {:.3} to {:.3}: {}",
1224            f.confidence.score, 0.42, reason
1225        );
1226        expected.confidence.method = ConfidenceMethod::ExpertJudgment;
1227        expected.updated = Some(updated_at.to_string());
1228        let mut state = project::assemble("test", vec![f.clone()], 0, 0, "test");
1229        let event = StateEvent {
1230            schema: events::EVENT_SCHEMA.to_string(),
1231            id: "vev_confidence".to_string(),
1232            kind: "finding.confidence_revised".to_string(),
1233            target: StateTarget {
1234                r#type: "finding".to_string(),
1235                id: f.id.clone(),
1236            },
1237            actor: StateActor {
1238                id: "reviewer:test".to_string(),
1239                r#type: "human".to_string(),
1240            },
1241            timestamp: "2026-05-07T23:31:00Z".to_string(),
1242            reason: reason.to_string(),
1243            before_hash: events::finding_hash(&f),
1244            after_hash: events::finding_hash(&expected),
1245            payload: json!({
1246                "previous_score": f.confidence.score,
1247                "new_score": 0.42,
1248                "updated_at": updated_at,
1249            }),
1250            caveats: vec![],
1251            signature: None,
1252            schema_artifact_id: None,
1253        };
1254
1255        apply_event(&mut state, &event).unwrap();
1256
1257        assert_eq!(state.findings[0].updated.as_deref(), Some(updated_at));
1258        assert_eq!(events::finding_hash(&state.findings[0]), event.after_hash);
1259    }
1260
1261    #[test]
1262    fn reducer_rejects_unknown_kind() {
1263        let mut state = project::assemble("test", vec![], 0, 0, "test");
1264        let event = StateEvent {
1265            schema: events::EVENT_SCHEMA.to_string(),
1266            id: "vev_test".to_string(),
1267            kind: "finding.unknown_kind".to_string(),
1268            target: StateTarget {
1269                r#type: "finding".to_string(),
1270                id: "vf_x".to_string(),
1271            },
1272            actor: StateActor {
1273                id: "x".to_string(),
1274                r#type: "human".to_string(),
1275            },
1276            timestamp: Utc::now().to_rfc3339(),
1277            reason: "x".to_string(),
1278            before_hash: NULL_HASH.to_string(),
1279            after_hash: NULL_HASH.to_string(),
1280            payload: Value::Null,
1281            caveats: vec![],
1282            signature: None,
1283            schema_artifact_id: None,
1284        };
1285        let r = apply_event(&mut state, &event);
1286        assert!(r.is_err());
1287    }
1288
1289    /// v0.49.3: the dispatch table in `apply_event` and the
1290    /// `REDUCER_MUTATION_KINDS` constant must agree. Adding a new
1291    /// match arm without updating the constant (or vice versa) makes
1292    /// CI fail loudly here, which then makes the cross-impl fixture
1293    /// coverage assertion fail correctly downstream. This is the
1294    /// single source of truth that retires the hand-maintained mirror.
1295    #[test]
1296    fn dispatch_handles_every_declared_kind() {
1297        for kind in REDUCER_MUTATION_KINDS {
1298            let mut state = project::assemble("test", vec![], 0, 0, "test");
1299            // Dummy event with the declared kind. The handler may
1300            // reject the payload (it's empty), but it MUST NOT reject
1301            // the kind itself with "unsupported event kind" — that
1302            // would prove the dispatch table is missing an arm for
1303            // a kind the constant declares.
1304            let event = StateEvent {
1305                schema: events::EVENT_SCHEMA.to_string(),
1306                id: "vev_dispatch_check".to_string(),
1307                kind: (*kind).to_string(),
1308                target: StateTarget {
1309                    r#type: "finding".to_string(),
1310                    id: "vf_x".to_string(),
1311                },
1312                actor: StateActor {
1313                    id: "x".to_string(),
1314                    r#type: "human".to_string(),
1315                },
1316                timestamp: Utc::now().to_rfc3339(),
1317                reason: String::new(),
1318                before_hash: NULL_HASH.to_string(),
1319                after_hash: NULL_HASH.to_string(),
1320                payload: Value::Null,
1321                caveats: vec![],
1322                signature: None,
1323                schema_artifact_id: None,
1324            };
1325            let r = apply_event(&mut state, &event);
1326            if let Err(e) = r {
1327                assert!(
1328                    !e.contains("unsupported event kind"),
1329                    "kind {kind:?} declared in REDUCER_MUTATION_KINDS \
1330                     but rejected by apply_event dispatch: {e}"
1331                );
1332            }
1333        }
1334    }
1335
1336    /// v0.59 + v0.63: federation events live in `apply_event` as
1337    /// no-ops on finding state. They are intentionally absent from
1338    /// `REDUCER_MUTATION_KINDS` (they do not mutate any finding,
1339    /// negative_result, trajectory, artifact, or evidence_atom);
1340    /// the coverage assertion above does not exercise them. This
1341    /// test pins the no-op contract directly: the reducer accepts
1342    /// the kind without error, and the finding-state digest is
1343    /// unchanged after replay.
1344    #[test]
1345    fn federation_events_are_finding_state_noops() {
1346        for kind in &[
1347            "frontier.synced_with_peer",
1348            "frontier.conflict_detected",
1349            "frontier.conflict_resolved",
1350        ] {
1351            let mut state = project::assemble("test", vec![], 0, 0, "test");
1352            let snapshot_before = events::snapshot_hash(&state);
1353            let event = StateEvent {
1354                schema: events::EVENT_SCHEMA.to_string(),
1355                id: format!("vev_federation_{kind}"),
1356                kind: (*kind).to_string(),
1357                target: StateTarget {
1358                    r#type: "frontier_observation".to_string(),
1359                    id: "vfr_x".to_string(),
1360                },
1361                actor: StateActor {
1362                    id: "federation".to_string(),
1363                    r#type: "system".to_string(),
1364                },
1365                timestamp: Utc::now().to_rfc3339(),
1366                reason: format!("no-op contract test for {kind}"),
1367                before_hash: NULL_HASH.to_string(),
1368                after_hash: NULL_HASH.to_string(),
1369                payload: Value::Null,
1370                caveats: vec![],
1371                signature: None,
1372                schema_artifact_id: None,
1373            };
1374            apply_event(&mut state, &event)
1375                .unwrap_or_else(|e| panic!("federation kind {kind} rejected by reducer: {e}"));
1376            let snapshot_after = events::snapshot_hash(&state);
1377            assert_eq!(
1378                snapshot_before, snapshot_after,
1379                "federation event {kind} mutated finding-state snapshot; expected no-op"
1380            );
1381        }
1382    }
1383
1384    fn project_with_one_atom(missing_locator: bool) -> Project {
1385        // `project::assemble` calls `sources::materialize_project`,
1386        // which derives one evidence atom per finding. The hand-built
1387        // atom below is appended after materialization with a distinct
1388        // id (`vea_test_atom`), so it survives alongside the derived
1389        // atom. Tests look up atoms by id via `atom_by_id`.
1390        let mut state = project::assemble("test-locator", vec![finding("a")], 0, 0, "test");
1391        state.sources.push(crate::sources::SourceRecord {
1392            id: "vs_test_source".to_string(),
1393            source_type: "paper".to_string(),
1394            locator: "doi:10.1/test-source".to_string(),
1395            content_hash: None,
1396            title: "Test source".to_string(),
1397            authors: Vec::new(),
1398            year: Some(2026),
1399            doi: Some("10.1/test-source".to_string()),
1400            pmid: None,
1401            imported_at: "2026-01-01T00:00:00Z".to_string(),
1402            extraction_mode: "manual".to_string(),
1403            source_quality: "declared".to_string(),
1404            caveats: Vec::new(),
1405            finding_ids: vec![state.findings[0].id.clone()],
1406        });
1407        state.evidence_atoms.push(crate::sources::EvidenceAtom {
1408            id: "vea_test_atom".to_string(),
1409            source_id: "vs_test_source".to_string(),
1410            finding_id: state.findings[0].id.clone(),
1411            locator: if missing_locator {
1412                None
1413            } else {
1414                Some("doi:10.1/already-set".to_string())
1415            },
1416            evidence_type: "experimental".to_string(),
1417            measurement_or_claim: "test claim".to_string(),
1418            supports_or_challenges: "supports".to_string(),
1419            condition_refs: Vec::new(),
1420            extraction_method: "manual".to_string(),
1421            human_verified: false,
1422            caveats: if missing_locator {
1423                vec!["missing evidence locator".to_string()]
1424            } else {
1425                Vec::new()
1426            },
1427        });
1428        state
1429    }
1430
1431    fn atom_by_id<'a>(state: &'a Project, id: &str) -> &'a crate::sources::EvidenceAtom {
1432        state
1433            .evidence_atoms
1434            .iter()
1435            .find(|atom| atom.id == id)
1436            .expect("atom exists")
1437    }
1438
1439    #[test]
1440    fn evidence_atom_locator_repaired_sets_locator_and_clears_caveat() {
1441        let mut state = project_with_one_atom(true);
1442        assert!(state.evidence_atoms[0].locator.is_none());
1443        let event = StateEvent {
1444            schema: crate::events::EVENT_SCHEMA.to_string(),
1445            id: "vev_test".to_string(),
1446            kind: "evidence_atom.locator_repaired".to_string(),
1447            target: StateTarget {
1448                r#type: "evidence_atom".to_string(),
1449                id: "vea_test_atom".to_string(),
1450            },
1451            actor: StateActor {
1452                id: "agent:test".to_string(),
1453                r#type: "agent".to_string(),
1454            },
1455            timestamp: Utc::now().to_rfc3339(),
1456            reason: "Mechanical repair from parent source".to_string(),
1457            before_hash: NULL_HASH.to_string(),
1458            after_hash: NULL_HASH.to_string(),
1459            payload: json!({
1460                "proposal_id": "vpr_test",
1461                "source_id": "vs_test_source",
1462                "locator": "doi:10.1/test-source",
1463            }),
1464            caveats: vec![],
1465            signature: None,
1466            schema_artifact_id: None,
1467        };
1468        apply_event(&mut state, &event).expect("apply locator_repaired");
1469        let atom = atom_by_id(&state, "vea_test_atom");
1470        assert_eq!(atom.locator.as_deref(), Some("doi:10.1/test-source"));
1471        assert!(atom.caveats.is_empty());
1472    }
1473
1474    #[test]
1475    fn evidence_atom_locator_repaired_is_idempotent() {
1476        let mut state = project_with_one_atom(true);
1477        let event = StateEvent {
1478            schema: crate::events::EVENT_SCHEMA.to_string(),
1479            id: "vev_test".to_string(),
1480            kind: "evidence_atom.locator_repaired".to_string(),
1481            target: StateTarget {
1482                r#type: "evidence_atom".to_string(),
1483                id: "vea_test_atom".to_string(),
1484            },
1485            actor: StateActor {
1486                id: "agent:test".to_string(),
1487                r#type: "agent".to_string(),
1488            },
1489            timestamp: Utc::now().to_rfc3339(),
1490            reason: "Mechanical repair from parent source".to_string(),
1491            before_hash: NULL_HASH.to_string(),
1492            after_hash: NULL_HASH.to_string(),
1493            payload: json!({
1494                "proposal_id": "vpr_test",
1495                "source_id": "vs_test_source",
1496                "locator": "doi:10.1/test-source",
1497            }),
1498            caveats: vec![],
1499            signature: None,
1500            schema_artifact_id: None,
1501        };
1502        apply_event(&mut state, &event).expect("first apply");
1503        apply_event(&mut state, &event).expect("second apply is a no-op when locator matches");
1504        let atom = atom_by_id(&state, "vea_test_atom");
1505        assert_eq!(atom.locator.as_deref(), Some("doi:10.1/test-source"));
1506    }
1507
1508    #[test]
1509    fn evidence_atom_locator_repaired_refuses_divergent_overwrite() {
1510        let mut state = project_with_one_atom(false);
1511        let event = StateEvent {
1512            schema: crate::events::EVENT_SCHEMA.to_string(),
1513            id: "vev_test".to_string(),
1514            kind: "evidence_atom.locator_repaired".to_string(),
1515            target: StateTarget {
1516                r#type: "evidence_atom".to_string(),
1517                id: "vea_test_atom".to_string(),
1518            },
1519            actor: StateActor {
1520                id: "agent:test".to_string(),
1521                r#type: "agent".to_string(),
1522            },
1523            timestamp: Utc::now().to_rfc3339(),
1524            reason: "Different repair".to_string(),
1525            before_hash: NULL_HASH.to_string(),
1526            after_hash: NULL_HASH.to_string(),
1527            payload: json!({
1528                "proposal_id": "vpr_test",
1529                "source_id": "vs_test_source",
1530                "locator": "doi:10.1/different",
1531            }),
1532            caveats: vec![],
1533            signature: None,
1534            schema_artifact_id: None,
1535        };
1536        let r = apply_event(&mut state, &event);
1537        assert!(r.is_err());
1538        assert!(r.unwrap_err().contains("already has locator"));
1539    }
1540
1541    #[test]
1542    fn evidence_atom_locator_repaired_does_not_mutate_findings() {
1543        // Cross-impl conformance: this event mutates evidence_atoms only.
1544        let mut state = project_with_one_atom(true);
1545        let hashes_before: Vec<String> = state
1546            .findings
1547            .iter()
1548            .map(crate::events::finding_hash)
1549            .collect();
1550        let event = StateEvent {
1551            schema: crate::events::EVENT_SCHEMA.to_string(),
1552            id: "vev_test".to_string(),
1553            kind: "evidence_atom.locator_repaired".to_string(),
1554            target: StateTarget {
1555                r#type: "evidence_atom".to_string(),
1556                id: "vea_test_atom".to_string(),
1557            },
1558            actor: StateActor {
1559                id: "agent:test".to_string(),
1560                r#type: "agent".to_string(),
1561            },
1562            timestamp: Utc::now().to_rfc3339(),
1563            reason: "Mechanical repair".to_string(),
1564            before_hash: NULL_HASH.to_string(),
1565            after_hash: NULL_HASH.to_string(),
1566            payload: json!({
1567                "proposal_id": "vpr_test",
1568                "source_id": "vs_test_source",
1569                "locator": "doi:10.1/test-source",
1570            }),
1571            caveats: vec![],
1572            signature: None,
1573            schema_artifact_id: None,
1574        };
1575        apply_event(&mut state, &event).expect("apply ok");
1576        let hashes_after: Vec<String> = state
1577            .findings
1578            .iter()
1579            .map(crate::events::finding_hash)
1580            .collect();
1581        assert_eq!(hashes_before, hashes_after);
1582    }
1583}