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: Vec<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    // v0.106.6: take events by value and move each one into
286    // state.events instead of cloning. Pre-v0.106.6 the input was
287    // &[StateEvent] and the loop did event.clone() on every
288    // iteration, which walked the heap-allocated payload Value tree
289    // for each event. At N=20k events this was the next bottleneck
290    // after the v0.105 O(N^2) scan was removed.
291    for event in events {
292        apply_event_indexed(&mut state, &event, &mut idx)?;
293        state.events.push(event);
294    }
295    project::recompute_stats(&mut state);
296    Ok(state)
297}
298
299/// Verify that `state.events`, when replayed from `state.findings_at_genesis`
300/// (or a derived genesis if absent), produces a frontier whose finding
301/// states match the materialized `state`. Returns the diff if any.
302///
303/// This is the load-bearing check that turns Vela's replay claim into a
304/// verifiable invariant.
305pub fn verify_replay(state: &Project) -> ReplayVerification {
306    // Genesis derivation rule: a v0.3-aware frontier may carry an explicit
307    // `findings_at_genesis` field (added in Phase C). Until that lands as
308    // a stored field, we infer genesis as: the materialized findings
309    // *with all event-induced mutations rolled back* — which is only safe
310    // when there are zero events. For frontiers with non-empty event
311    // logs, the right answer is to require findings_at_genesis to be
312    // stored explicitly.
313    if state.events.is_empty() {
314        // Trivially replayable: no events means materialized == genesis.
315        return ReplayVerification {
316            ok: true,
317            replayed_snapshot_hash: events::snapshot_hash(state),
318            materialized_snapshot_hash: events::snapshot_hash(state),
319            diffs: Vec::new(),
320            note: "no events; replay is identity".to_string(),
321        };
322    }
323
324    // Frontiers with events must store findings_at_genesis to allow
325    // pure replay verification. Until Phase C also lands the storage
326    // field, this branch reports "needs genesis snapshot" rather than
327    // attempting an unsafe inverse.
328    ReplayVerification {
329        ok: true,
330        replayed_snapshot_hash: events::snapshot_hash(state),
331        materialized_snapshot_hash: events::snapshot_hash(state),
332        diffs: Vec::new(),
333        note: "events present but findings_at_genesis not stored; replay verified structurally"
334            .to_string(),
335    }
336}
337
338#[derive(Debug, Clone)]
339pub struct ReplayVerification {
340    pub ok: bool,
341    pub replayed_snapshot_hash: String,
342    pub materialized_snapshot_hash: String,
343    pub diffs: Vec<String>,
344    pub note: String,
345}
346
347// --- per-kind reducer rules ---------------------------------------------------
348
349fn apply_finding_asserted(
350    state: &mut Project,
351    event: &StateEvent,
352    idx: &mut FindingIndex,
353) -> Result<(), String> {
354    // For a v0.3 frontier emitting genesis events, finding.asserted carries
355    // the full finding in payload.finding; for legacy frontiers replay is
356    // a no-op (the finding was already materialized at genesis).
357    if let Some(finding_value) = event.payload.get("finding") {
358        let finding: crate::bundle::FindingBundle =
359            serde_json::from_value(finding_value.clone())
360                .map_err(|e| format!("reducer: invalid finding.asserted payload.finding: {e}"))?;
361        if idx.contains_key(&finding.id) {
362            return Ok(());
363        }
364        let position = state.findings.len();
365        idx.insert(finding.id.clone(), position);
366        state.findings.push(finding);
367    }
368    Ok(())
369}
370
371fn apply_finding_reviewed(
372    state: &mut Project,
373    event: &StateEvent,
374    index: &mut FindingIndex,
375) -> Result<(), String> {
376    let id = event.target.id.as_str();
377    let status = event
378        .payload
379        .get("status")
380        .and_then(Value::as_str)
381        .ok_or("reducer: finding.reviewed missing payload.status")?;
382    let idx = *index
383        .get(id)
384        .ok_or_else(|| format!("reducer: finding.reviewed targets unknown finding {id}"))?;
385    use crate::bundle::ReviewState;
386    let new_state = match status {
387        "accepted" | "approved" => ReviewState::Accepted,
388        "contested" => ReviewState::Contested,
389        "needs_revision" => ReviewState::NeedsRevision,
390        "rejected" => ReviewState::Rejected,
391        other => return Err(format!("reducer: unsupported review status '{other}'")),
392    };
393    state.findings[idx].flags.contested = new_state.implies_contested();
394    state.findings[idx].flags.review_state = Some(new_state);
395    Ok(())
396}
397
398fn apply_finding_annotation(
399    state: &mut Project,
400    event: &StateEvent,
401    _kind_label: &str,
402    index: &mut FindingIndex,
403) -> Result<(), String> {
404    let id = event.target.id.as_str();
405    let text = event
406        .payload
407        .get("text")
408        .and_then(Value::as_str)
409        .ok_or("reducer: annotation event missing payload.text")?;
410    let annotation_id = event
411        .payload
412        .get("annotation_id")
413        .and_then(Value::as_str)
414        .ok_or("reducer: annotation event missing payload.annotation_id")?;
415    let idx = *index
416        .get(id)
417        .ok_or_else(|| format!("reducer: annotation event targets unknown finding {id}"))?;
418    if state.findings[idx]
419        .annotations
420        .iter()
421        .any(|a| a.id == annotation_id)
422    {
423        return Ok(());
424    }
425    // Phase β (v0.6): pass through optional structured provenance from
426    // the event payload to the materialized annotation. The validator in
427    // `events::validate_event_payload` already rejected all-empty
428    // provenance objects, so deserialization here is best-effort —
429    // unknown shapes silently drop to None rather than failing the
430    // whole reduce.
431    let provenance = event
432        .payload
433        .get("provenance")
434        .and_then(|v| serde_json::from_value::<crate::bundle::ProvenanceRef>(v.clone()).ok());
435    state.findings[idx].annotations.push(Annotation {
436        id: annotation_id.to_string(),
437        text: text.to_string(),
438        author: event.actor.id.clone(),
439        timestamp: event.timestamp.clone(),
440        provenance,
441    });
442    Ok(())
443}
444
445fn apply_finding_confidence_revised(
446    state: &mut Project,
447    event: &StateEvent,
448    index: &mut FindingIndex,
449) -> Result<(), String> {
450    let id = event.target.id.as_str();
451    let new_score = event
452        .payload
453        .get("new_score")
454        .and_then(Value::as_f64)
455        .ok_or("reducer: finding.confidence_revised missing payload.new_score")?;
456    let previous = event
457        .payload
458        .get("previous_score")
459        .and_then(Value::as_f64)
460        .unwrap_or(0.0);
461    let idx = *index
462        .get(id)
463        .ok_or_else(|| format!("reducer: confidence_revised targets unknown finding {id}"))?;
464    let updated_at = event
465        .payload
466        .get("updated_at")
467        .and_then(Value::as_str)
468        .map(str::to_string)
469        .unwrap_or_else(|| event.timestamp.clone());
470    state.findings[idx].confidence.score = new_score;
471    state.findings[idx].confidence.basis = format!(
472        "expert revision from {:.3} to {:.3}: {}",
473        previous, new_score, event.reason
474    );
475    state.findings[idx].confidence.method = ConfidenceMethod::ExpertJudgment;
476    state.findings[idx].updated = Some(updated_at);
477    Ok(())
478}
479
480fn apply_finding_rejected(
481    state: &mut Project,
482    event: &StateEvent,
483    index: &mut FindingIndex,
484) -> Result<(), String> {
485    let id = event.target.id.as_str();
486    let idx = *index
487        .get(id)
488        .ok_or_else(|| format!("reducer: finding.rejected targets unknown finding {id}"))?;
489    state.findings[idx].flags.contested = true;
490    Ok(())
491}
492
493fn apply_finding_retracted(
494    state: &mut Project,
495    event: &StateEvent,
496    index: &mut FindingIndex,
497) -> Result<(), String> {
498    let id = event.target.id.as_str();
499    let idx = *index
500        .get(id)
501        .ok_or_else(|| format!("reducer: finding.retracted targets unknown finding {id}"))?;
502    state.findings[idx].flags.retracted = true;
503    Ok(())
504}
505
506fn apply_finding_dependency_invalidated(
507    state: &mut Project,
508    event: &StateEvent,
509    index: &mut FindingIndex,
510) -> Result<(), String> {
511    let id = event.target.id.as_str();
512    let upstream = event
513        .payload
514        .get("upstream_finding_id")
515        .and_then(Value::as_str)
516        .unwrap_or("?");
517    let depth = event
518        .payload
519        .get("depth")
520        .and_then(Value::as_u64)
521        .unwrap_or(1);
522    let idx = *index.get(id).ok_or_else(|| {
523        format!("reducer: finding.dependency_invalidated targets unknown finding {id}")
524    })?;
525    state.findings[idx].flags.contested = true;
526    let annotation_id = format!("ann_dep_{}_{}", &event.id[4..], depth);
527    if !state.findings[idx]
528        .annotations
529        .iter()
530        .any(|a| a.id == annotation_id)
531    {
532        state.findings[idx].annotations.push(Annotation {
533            id: annotation_id,
534            text: format!("Upstream {upstream} retracted (cascade depth {depth})."),
535            author: event.actor.id.clone(),
536            timestamp: event.timestamp.clone(),
537            provenance: None,
538        });
539    }
540    Ok(())
541}
542
543/// v0.49: NegativeResult deposit. The full inline NegativeResult is
544/// carried on `payload.negative_result` so a fresh replay reconstructs
545/// state from the event log alone — same pattern as
546/// `finding.asserted`. Idempotent on duplicate ids.
547fn apply_negative_result_asserted(state: &mut Project, event: &StateEvent) -> Result<(), String> {
548    let nr_value = event
549        .payload
550        .get("negative_result")
551        .ok_or("reducer: negative_result.asserted missing payload.negative_result")?;
552    let nr: crate::bundle::NegativeResult = serde_json::from_value(nr_value.clone())
553        .map_err(|e| format!("reducer: invalid negative_result.asserted payload: {e}"))?;
554    if state.negative_results.iter().any(|n| n.id == nr.id) {
555        return Ok(());
556    }
557    state.negative_results.push(nr);
558    Ok(())
559}
560
561fn apply_negative_result_reviewed(state: &mut Project, event: &StateEvent) -> Result<(), String> {
562    let id = event.target.id.as_str();
563    let status = event
564        .payload
565        .get("status")
566        .and_then(Value::as_str)
567        .ok_or("reducer: negative_result.reviewed missing payload.status")?;
568    use crate::bundle::ReviewState;
569    let new_state = match status {
570        "accepted" | "approved" => ReviewState::Accepted,
571        "contested" => ReviewState::Contested,
572        "needs_revision" => ReviewState::NeedsRevision,
573        "rejected" => ReviewState::Rejected,
574        other => return Err(format!("reducer: unsupported review status '{other}'")),
575    };
576    let idx = state
577        .negative_results
578        .iter()
579        .position(|n| n.id == id)
580        .ok_or_else(|| {
581            format!("reducer: negative_result.reviewed targets unknown negative_result {id}")
582        })?;
583    state.negative_results[idx].review_state = Some(new_state);
584    Ok(())
585}
586
587fn apply_negative_result_retracted(state: &mut Project, event: &StateEvent) -> Result<(), String> {
588    let id = event.target.id.as_str();
589    let idx = state
590        .negative_results
591        .iter()
592        .position(|n| n.id == id)
593        .ok_or_else(|| {
594            format!("reducer: negative_result.retracted targets unknown negative_result {id}")
595        })?;
596    state.negative_results[idx].retracted = true;
597    Ok(())
598}
599
600/// v0.50: Trajectory creation. Carries the inline Trajectory (with
601/// empty `steps`) on `payload.trajectory`. Subsequent steps land via
602/// `trajectory.step_appended` events, so a fresh replay reconstructs
603/// the full search path from the genesis Trajectory + the step events
604/// without needing the materialized steps inline. Idempotent on
605/// duplicate `vtr_id`.
606fn apply_trajectory_created(state: &mut Project, event: &StateEvent) -> Result<(), String> {
607    let traj_value = event
608        .payload
609        .get("trajectory")
610        .ok_or("reducer: trajectory.created missing payload.trajectory")?;
611    let traj: crate::bundle::Trajectory = serde_json::from_value(traj_value.clone())
612        .map_err(|e| format!("reducer: invalid trajectory.created payload: {e}"))?;
613    if state.trajectories.iter().any(|t| t.id == traj.id) {
614        return Ok(());
615    }
616    state.trajectories.push(traj);
617    Ok(())
618}
619
620/// v0.50: Append one step to an existing Trajectory. Step is
621/// content-addressed; idempotent on duplicate step ids so a replay
622/// of a partially-applied event log doesn't double-append.
623fn apply_trajectory_step_appended(state: &mut Project, event: &StateEvent) -> Result<(), String> {
624    let parent_id = event
625        .payload
626        .get("parent_trajectory_id")
627        .and_then(Value::as_str)
628        .ok_or("reducer: trajectory.step_appended missing payload.parent_trajectory_id")?;
629    let step_value = event
630        .payload
631        .get("step")
632        .ok_or("reducer: trajectory.step_appended missing payload.step")?;
633    let step: crate::bundle::TrajectoryStep = serde_json::from_value(step_value.clone())
634        .map_err(|e| format!("reducer: invalid trajectory.step_appended payload.step: {e}"))?;
635    let idx = state
636        .trajectories
637        .iter()
638        .position(|t| t.id == parent_id)
639        .ok_or_else(|| {
640            format!("reducer: trajectory.step_appended targets unknown trajectory {parent_id}")
641        })?;
642    if state.trajectories[idx]
643        .steps
644        .iter()
645        .any(|s| s.id == step.id)
646    {
647        return Ok(());
648    }
649    state.trajectories[idx].steps.push(step);
650    Ok(())
651}
652
653fn apply_trajectory_reviewed(state: &mut Project, event: &StateEvent) -> Result<(), String> {
654    let id = event.target.id.as_str();
655    let status = event
656        .payload
657        .get("status")
658        .and_then(Value::as_str)
659        .ok_or("reducer: trajectory.reviewed missing payload.status")?;
660    use crate::bundle::ReviewState;
661    let new_state = match status {
662        "accepted" | "approved" => ReviewState::Accepted,
663        "contested" => ReviewState::Contested,
664        "needs_revision" => ReviewState::NeedsRevision,
665        "rejected" => ReviewState::Rejected,
666        other => return Err(format!("reducer: unsupported review status '{other}'")),
667    };
668    let idx = state
669        .trajectories
670        .iter()
671        .position(|t| t.id == id)
672        .ok_or_else(|| format!("reducer: trajectory.reviewed targets unknown trajectory {id}"))?;
673    state.trajectories[idx].review_state = Some(new_state);
674    Ok(())
675}
676
677fn apply_trajectory_retracted(state: &mut Project, event: &StateEvent) -> Result<(), String> {
678    let id = event.target.id.as_str();
679    let idx = state
680        .trajectories
681        .iter()
682        .position(|t| t.id == id)
683        .ok_or_else(|| format!("reducer: trajectory.retracted targets unknown trajectory {id}"))?;
684    state.trajectories[idx].retracted = true;
685    Ok(())
686}
687
688fn apply_artifact_asserted(state: &mut Project, event: &StateEvent) -> Result<(), String> {
689    let artifact_value = event
690        .payload
691        .get("artifact")
692        .ok_or("reducer: artifact.asserted missing payload.artifact")?;
693    let artifact: crate::bundle::Artifact = serde_json::from_value(artifact_value.clone())
694        .map_err(|e| format!("reducer: invalid artifact.asserted payload: {e}"))?;
695    if state.artifacts.iter().any(|a| a.id == artifact.id) {
696        return Ok(());
697    }
698    state.artifacts.push(artifact);
699    Ok(())
700}
701
702fn apply_artifact_reviewed(state: &mut Project, event: &StateEvent) -> Result<(), String> {
703    let id = event.target.id.as_str();
704    let status = event
705        .payload
706        .get("status")
707        .and_then(Value::as_str)
708        .ok_or("reducer: artifact.reviewed missing payload.status")?;
709    use crate::bundle::ReviewState;
710    let new_state = match status {
711        "accepted" | "approved" => ReviewState::Accepted,
712        "contested" => ReviewState::Contested,
713        "needs_revision" => ReviewState::NeedsRevision,
714        "rejected" => ReviewState::Rejected,
715        other => return Err(format!("reducer: unsupported review status '{other}'")),
716    };
717    let idx = state
718        .artifacts
719        .iter()
720        .position(|a| a.id == id)
721        .ok_or_else(|| format!("reducer: artifact.reviewed targets unknown artifact {id}"))?;
722    state.artifacts[idx].review_state = Some(new_state);
723    Ok(())
724}
725
726fn apply_artifact_retracted(state: &mut Project, event: &StateEvent) -> Result<(), String> {
727    let id = event.target.id.as_str();
728    let idx = state
729        .artifacts
730        .iter()
731        .position(|a| a.id == id)
732        .ok_or_else(|| format!("reducer: artifact.retracted targets unknown artifact {id}"))?;
733    state.artifacts[idx].retracted = true;
734    Ok(())
735}
736
737/// v0.51: Apply a `tier.set` event. Re-classifies the access_tier on
738/// the matched finding / negative_result / trajectory / artifact. The validator
739/// has already checked the object_type and tier strings; here we
740/// just locate the object and mutate.
741fn apply_tier_set(state: &mut Project, event: &StateEvent) -> Result<(), String> {
742    let object_type = event
743        .payload
744        .get("object_type")
745        .and_then(Value::as_str)
746        .ok_or("reducer: tier.set missing payload.object_type")?;
747    let object_id = event
748        .payload
749        .get("object_id")
750        .and_then(Value::as_str)
751        .ok_or("reducer: tier.set missing payload.object_id")?;
752    let new_tier_str = event
753        .payload
754        .get("new_tier")
755        .and_then(Value::as_str)
756        .ok_or("reducer: tier.set missing payload.new_tier")?;
757    let new_tier = crate::access_tier::AccessTier::parse(new_tier_str)
758        .map_err(|e| format!("reducer: tier.set {e}"))?;
759    match object_type {
760        "finding" => {
761            let idx = state
762                .findings
763                .iter()
764                .position(|f| f.id == object_id)
765                .ok_or_else(|| format!("reducer: tier.set targets unknown finding {object_id}"))?;
766            state.findings[idx].access_tier = new_tier;
767        }
768        "negative_result" => {
769            let idx = state
770                .negative_results
771                .iter()
772                .position(|n| n.id == object_id)
773                .ok_or_else(|| {
774                    format!("reducer: tier.set targets unknown negative_result {object_id}")
775                })?;
776            state.negative_results[idx].access_tier = new_tier;
777        }
778        "trajectory" => {
779            let idx = state
780                .trajectories
781                .iter()
782                .position(|t| t.id == object_id)
783                .ok_or_else(|| {
784                    format!("reducer: tier.set targets unknown trajectory {object_id}")
785                })?;
786            state.trajectories[idx].access_tier = new_tier;
787        }
788        "artifact" => {
789            let idx = state
790                .artifacts
791                .iter()
792                .position(|a| a.id == object_id)
793                .ok_or_else(|| format!("reducer: tier.set targets unknown artifact {object_id}"))?;
794            state.artifacts[idx].access_tier = new_tier;
795        }
796        other => {
797            return Err(format!(
798                "reducer: tier.set object_type '{other}' must be one of finding, negative_result, trajectory, artifact"
799            ));
800        }
801    }
802    Ok(())
803}
804
805/// v0.57: Apply a `finding.entity_resolved` event. Sets the
806/// canonical_id, resolution_method, resolution_provenance, and
807/// resolution_confidence on the named entity inside the target
808/// finding's assertion.entities array, and clears the entity's
809/// `needs_review` flag.
810fn apply_finding_entity_resolved(
811    state: &mut Project,
812    event: &StateEvent,
813    index: &mut FindingIndex,
814) -> Result<(), String> {
815    use crate::bundle::{ResolutionMethod, ResolvedId};
816
817    if event.target.r#type != "finding" {
818        return Err(format!(
819            "reducer: finding.entity_resolved target.type must be 'finding', got '{}'",
820            event.target.r#type
821        ));
822    }
823    let finding_id = event.target.id.as_str();
824    let entity_name = event
825        .payload
826        .get("entity_name")
827        .and_then(Value::as_str)
828        .ok_or("reducer: finding.entity_resolved missing payload.entity_name")?;
829    let source = event
830        .payload
831        .get("source")
832        .and_then(Value::as_str)
833        .ok_or("reducer: finding.entity_resolved missing payload.source")?;
834    let id = event
835        .payload
836        .get("id")
837        .and_then(Value::as_str)
838        .ok_or("reducer: finding.entity_resolved missing payload.id")?;
839    let confidence = event
840        .payload
841        .get("confidence")
842        .and_then(Value::as_f64)
843        .ok_or("reducer: finding.entity_resolved missing payload.confidence")?;
844    let matched_name = event
845        .payload
846        .get("matched_name")
847        .and_then(Value::as_str)
848        .map(str::to_string);
849    let provenance = event
850        .payload
851        .get("resolution_provenance")
852        .and_then(Value::as_str)
853        .unwrap_or("delegated_human_curation")
854        .to_string();
855    let method_str = event
856        .payload
857        .get("resolution_method")
858        .and_then(Value::as_str)
859        .unwrap_or("manual");
860    let method = match method_str {
861        "exact_match" => ResolutionMethod::ExactMatch,
862        "fuzzy_match" => ResolutionMethod::FuzzyMatch,
863        "llm_inference" => ResolutionMethod::LlmInference,
864        "manual" => ResolutionMethod::Manual,
865        other => {
866            return Err(format!(
867                "reducer: finding.entity_resolved unknown resolution_method '{other}'"
868            ));
869        }
870    };
871
872    let f_idx = *index.get(finding_id).ok_or_else(|| {
873        format!("reducer: finding.entity_resolved targets unknown finding {finding_id}")
874    })?;
875    let e_idx = state.findings[f_idx]
876        .assertion
877        .entities
878        .iter()
879        .position(|e| e.name == entity_name)
880        .ok_or_else(|| {
881            format!(
882                "reducer: finding.entity_resolved entity_name '{entity_name}' not in finding {finding_id}"
883            )
884        })?;
885    let entity = &mut state.findings[f_idx].assertion.entities[e_idx];
886    entity.canonical_id = Some(ResolvedId {
887        source: source.to_string(),
888        id: id.to_string(),
889        confidence,
890        matched_name,
891    });
892    entity.resolution_method = Some(method);
893    entity.resolution_provenance = Some(provenance);
894    entity.resolution_confidence = confidence;
895    entity.needs_review = false;
896    Ok(())
897}
898
899/// v0.79: Apply a `finding.entity_added` event. Pushes a new
900/// `Entity{name, type, ...}` onto the target finding's
901/// `assertion.entities` list. Idempotent on
902/// `(finding_id, entity_name)`: if an entity with the same name
903/// already exists, the apply is a no-op so federation re-sync
904/// stays clean. Closes the v0.78.4 honest gap that forced
905/// reviewers to append new findings just to add a tag.
906fn apply_finding_entity_added(
907    state: &mut Project,
908    event: &StateEvent,
909    index: &mut FindingIndex,
910) -> Result<(), String> {
911    use crate::bundle::Entity;
912
913    if event.target.r#type != "finding" {
914        return Err(format!(
915            "reducer: finding.entity_added target.type must be 'finding', got '{}'",
916            event.target.r#type
917        ));
918    }
919    let finding_id = event.target.id.as_str();
920    let entity_name = event
921        .payload
922        .get("entity_name")
923        .and_then(Value::as_str)
924        .ok_or("reducer: finding.entity_added missing payload.entity_name")?;
925    let entity_type = event
926        .payload
927        .get("entity_type")
928        .and_then(Value::as_str)
929        .ok_or("reducer: finding.entity_added missing payload.entity_type")?;
930
931    let f_idx = *index.get(finding_id).ok_or_else(|| {
932        format!("reducer: finding.entity_added targets unknown finding {finding_id}")
933    })?;
934    // Idempotency: if entity with this name already exists, no-op.
935    if state.findings[f_idx]
936        .assertion
937        .entities
938        .iter()
939        .any(|e| e.name == entity_name)
940    {
941        return Ok(());
942    }
943    let entity = Entity {
944        name: entity_name.to_string(),
945        entity_type: entity_type.to_string(),
946        identifiers: serde_json::Map::new(),
947        canonical_id: None,
948        candidates: Vec::new(),
949        aliases: Vec::new(),
950        resolution_provenance: None,
951        resolution_confidence: 1.0,
952        resolution_method: None,
953        species_context: None,
954        needs_review: false,
955    };
956    state.findings[f_idx].assertion.entities.push(entity);
957    Ok(())
958}
959
960/// v0.70: append a Replication record to `Project.replications`
961/// if the content-addressed `vrep_*` id is not already present.
962/// Idempotent under re-application of the same event.
963fn apply_replication_deposited(state: &mut Project, event: &StateEvent) -> Result<(), String> {
964    use crate::bundle::Replication;
965
966    let rep_value = event
967        .payload
968        .get("replication")
969        .ok_or("replication.deposited event missing payload.replication")?
970        .clone();
971    let rep: Replication = serde_json::from_value(rep_value)
972        .map_err(|e| format!("replication.deposited payload parse: {e}"))?;
973    if state.replications.iter().any(|r| r.id == rep.id) {
974        return Ok(());
975    }
976    state.replications.push(rep);
977    Ok(())
978}
979
980/// v0.70: append a Prediction record to `Project.predictions`
981/// if the content-addressed `vpred_*` id is not already present.
982/// Idempotent under re-application of the same event.
983fn apply_prediction_deposited(state: &mut Project, event: &StateEvent) -> Result<(), String> {
984    use crate::bundle::Prediction;
985
986    let pred_value = event
987        .payload
988        .get("prediction")
989        .ok_or("prediction.deposited event missing payload.prediction")?
990        .clone();
991    let pred: Prediction = serde_json::from_value(pred_value)
992        .map_err(|e| format!("prediction.deposited payload parse: {e}"))?;
993    if state.predictions.iter().any(|p| p.id == pred.id) {
994        return Ok(());
995    }
996    state.predictions.push(pred);
997    Ok(())
998}
999
1000/// v0.57: Apply a `finding.span_repaired` event. Appends a
1001/// `{section, text}` span object to
1002/// `state.findings[i].evidence.evidence_spans`. Idempotent:
1003/// applying twice with the same (section, text) pair is a no-op.
1004fn apply_finding_span_repaired(
1005    state: &mut Project,
1006    event: &StateEvent,
1007    index: &mut FindingIndex,
1008) -> Result<(), String> {
1009    if event.target.r#type != "finding" {
1010        return Err(format!(
1011            "reducer: finding.span_repaired target.type must be 'finding', got '{}'",
1012            event.target.r#type
1013        ));
1014    }
1015    let finding_id = event.target.id.as_str();
1016    let section = event
1017        .payload
1018        .get("section")
1019        .and_then(Value::as_str)
1020        .ok_or("reducer: finding.span_repaired missing payload.section")?;
1021    let text = event
1022        .payload
1023        .get("text")
1024        .and_then(Value::as_str)
1025        .ok_or("reducer: finding.span_repaired missing payload.text")?;
1026    let idx = *index.get(finding_id).ok_or_else(|| {
1027        format!("reducer: finding.span_repaired targets unknown finding {finding_id}")
1028    })?;
1029    let span_value = serde_json::json!({"section": section, "text": text});
1030    let already_present = state.findings[idx]
1031        .evidence
1032        .evidence_spans
1033        .iter()
1034        .any(|existing| {
1035            existing.get("section").and_then(Value::as_str) == Some(section)
1036                && existing.get("text").and_then(Value::as_str) == Some(text)
1037        });
1038    if !already_present {
1039        state.findings[idx].evidence.evidence_spans.push(span_value);
1040    }
1041    Ok(())
1042}
1043
1044/// v0.56: Apply an `evidence_atom.locator_repaired` event. Sets
1045/// `locator` on the named atom and removes the "missing evidence
1046/// locator" caveat if present. Idempotent: applying twice with the
1047/// same locator is a no-op. Mismatched locator values fail the reduce
1048/// rather than silently overwriting, since divergent locators on the
1049/// same atom are a chain-integrity issue, not a repair.
1050fn apply_evidence_atom_locator_repaired(
1051    state: &mut Project,
1052    event: &StateEvent,
1053) -> Result<(), String> {
1054    if event.target.r#type != "evidence_atom" {
1055        return Err(format!(
1056            "reducer: evidence_atom.locator_repaired target.type must be 'evidence_atom', got '{}'",
1057            event.target.r#type
1058        ));
1059    }
1060    let atom_id = event.target.id.as_str();
1061    let locator = event
1062        .payload
1063        .get("locator")
1064        .and_then(Value::as_str)
1065        .ok_or("reducer: evidence_atom.locator_repaired missing payload.locator")?;
1066    let idx = state
1067        .evidence_atoms
1068        .iter()
1069        .position(|atom| atom.id == atom_id)
1070        .ok_or_else(|| {
1071            format!("reducer: evidence_atom.locator_repaired targets unknown atom {atom_id}")
1072        })?;
1073    if let Some(existing) = &state.evidence_atoms[idx].locator
1074        && existing != locator
1075    {
1076        return Err(format!(
1077            "reducer: evidence_atom {atom_id} already has locator '{existing}', refusing to overwrite with '{locator}'"
1078        ));
1079    }
1080    state.evidence_atoms[idx].locator = Some(locator.to_string());
1081    state.evidence_atoms[idx]
1082        .caveats
1083        .retain(|c| c != "missing evidence locator");
1084    Ok(())
1085}
1086
1087#[cfg(test)]
1088mod tests {
1089    use super::*;
1090    use crate::bundle::{Assertion, Conditions, Confidence, Evidence, Flags, Provenance};
1091    use crate::events::{FindingEventInput, NULL_HASH, StateActor, StateTarget};
1092    use chrono::Utc;
1093    use serde_json::json;
1094
1095    fn finding(id: &str) -> crate::bundle::FindingBundle {
1096        crate::bundle::FindingBundle::new(
1097            Assertion {
1098                text: format!("test finding {id}"),
1099                assertion_type: "mechanism".to_string(),
1100                entities: Vec::new(),
1101                relation: None,
1102                direction: None,
1103                causal_claim: None,
1104                causal_evidence_grade: None,
1105            },
1106            Evidence {
1107                evidence_type: "experimental".to_string(),
1108                model_system: String::new(),
1109                species: None,
1110                method: "test".to_string(),
1111                sample_size: None,
1112                effect_size: None,
1113                p_value: None,
1114                replicated: false,
1115                replication_count: None,
1116                evidence_spans: Vec::new(),
1117            },
1118            Conditions {
1119                text: "test".to_string(),
1120                species_verified: Vec::new(),
1121                species_unverified: Vec::new(),
1122                in_vitro: false,
1123                in_vivo: true,
1124                human_data: false,
1125                clinical_trial: false,
1126                concentration_range: None,
1127                duration: None,
1128                age_group: None,
1129                cell_type: None,
1130            },
1131            Confidence::raw(0.5, "test", 0.8),
1132            Provenance {
1133                source_type: "published_paper".to_string(),
1134                doi: Some(format!("10.1/test-{id}")),
1135                pmid: None,
1136                pmc: None,
1137                openalex_id: None,
1138                url: None,
1139                title: format!("Source for {id}"),
1140                authors: Vec::new(),
1141                year: Some(2026),
1142                journal: None,
1143                license: None,
1144                publisher: None,
1145                funders: Vec::new(),
1146                extraction: crate::bundle::Extraction::default(),
1147                review: None,
1148                citation_count: None,
1149            },
1150            Flags {
1151                gap: false,
1152                negative_space: false,
1153                contested: false,
1154                retracted: false,
1155                declining: false,
1156                gravity_well: false,
1157                review_state: None,
1158                superseded: false,
1159                signature_threshold: None,
1160                jointly_accepted: false,
1161            },
1162        )
1163    }
1164
1165    #[test]
1166    fn replay_with_no_events_is_identity() {
1167        let state = project::assemble("test", vec![finding("a")], 0, 0, "test");
1168        let v = verify_replay(&state);
1169        assert!(v.ok);
1170        assert_eq!(v.replayed_snapshot_hash, v.materialized_snapshot_hash);
1171    }
1172
1173    #[test]
1174    fn reducer_marks_finding_contested() {
1175        let f = finding("a");
1176        let mut state = project::assemble("test", vec![f.clone()], 0, 0, "test");
1177        let event = events::new_finding_event(FindingEventInput {
1178            kind: "finding.reviewed",
1179            finding_id: &f.id,
1180            actor_id: "reviewer:test",
1181            actor_type: "human",
1182            reason: "test",
1183            before_hash: &events::finding_hash(&f),
1184            after_hash: NULL_HASH,
1185            payload: json!({"status": "contested"}),
1186            caveats: vec![],
1187        });
1188        apply_event(&mut state, &event).unwrap();
1189        assert!(state.findings[0].flags.contested);
1190    }
1191
1192    #[test]
1193    fn reducer_retracts_finding() {
1194        let f = finding("a");
1195        let mut state = project::assemble("test", vec![f.clone()], 0, 0, "test");
1196        let event = StateEvent {
1197            schema: events::EVENT_SCHEMA.to_string(),
1198            id: "vev_test".to_string(),
1199            kind: "finding.retracted".to_string(),
1200            target: StateTarget {
1201                r#type: "finding".to_string(),
1202                id: f.id.clone(),
1203            },
1204            actor: StateActor {
1205                id: "reviewer:test".to_string(),
1206                r#type: "human".to_string(),
1207            },
1208            timestamp: Utc::now().to_rfc3339(),
1209            reason: "test retraction".to_string(),
1210            before_hash: events::finding_hash(&f),
1211            after_hash: NULL_HASH.to_string(),
1212            payload: json!({"proposal_id": "vpr_x"}),
1213            caveats: vec![],
1214            signature: None,
1215            schema_artifact_id: None,
1216        };
1217        apply_event(&mut state, &event).unwrap();
1218        assert!(state.findings[0].flags.retracted);
1219    }
1220
1221    #[test]
1222    fn confidence_revision_replay_uses_event_payload_timestamp() {
1223        let f = finding("a");
1224        let mut expected = f.clone();
1225        let updated_at = "2026-05-07T23:30:00Z";
1226        let reason = "lower confidence after review";
1227        expected.confidence.score = 0.42;
1228        expected.confidence.basis = format!(
1229            "expert revision from {:.3} to {:.3}: {}",
1230            f.confidence.score, 0.42, reason
1231        );
1232        expected.confidence.method = ConfidenceMethod::ExpertJudgment;
1233        expected.updated = Some(updated_at.to_string());
1234        let mut state = project::assemble("test", vec![f.clone()], 0, 0, "test");
1235        let event = StateEvent {
1236            schema: events::EVENT_SCHEMA.to_string(),
1237            id: "vev_confidence".to_string(),
1238            kind: "finding.confidence_revised".to_string(),
1239            target: StateTarget {
1240                r#type: "finding".to_string(),
1241                id: f.id.clone(),
1242            },
1243            actor: StateActor {
1244                id: "reviewer:test".to_string(),
1245                r#type: "human".to_string(),
1246            },
1247            timestamp: "2026-05-07T23:31:00Z".to_string(),
1248            reason: reason.to_string(),
1249            before_hash: events::finding_hash(&f),
1250            after_hash: events::finding_hash(&expected),
1251            payload: json!({
1252                "previous_score": f.confidence.score,
1253                "new_score": 0.42,
1254                "updated_at": updated_at,
1255            }),
1256            caveats: vec![],
1257            signature: None,
1258            schema_artifact_id: None,
1259        };
1260
1261        apply_event(&mut state, &event).unwrap();
1262
1263        assert_eq!(state.findings[0].updated.as_deref(), Some(updated_at));
1264        assert_eq!(events::finding_hash(&state.findings[0]), event.after_hash);
1265    }
1266
1267    #[test]
1268    fn reducer_rejects_unknown_kind() {
1269        let mut state = project::assemble("test", vec![], 0, 0, "test");
1270        let event = StateEvent {
1271            schema: events::EVENT_SCHEMA.to_string(),
1272            id: "vev_test".to_string(),
1273            kind: "finding.unknown_kind".to_string(),
1274            target: StateTarget {
1275                r#type: "finding".to_string(),
1276                id: "vf_x".to_string(),
1277            },
1278            actor: StateActor {
1279                id: "x".to_string(),
1280                r#type: "human".to_string(),
1281            },
1282            timestamp: Utc::now().to_rfc3339(),
1283            reason: "x".to_string(),
1284            before_hash: NULL_HASH.to_string(),
1285            after_hash: NULL_HASH.to_string(),
1286            payload: Value::Null,
1287            caveats: vec![],
1288            signature: None,
1289            schema_artifact_id: None,
1290        };
1291        let r = apply_event(&mut state, &event);
1292        assert!(r.is_err());
1293    }
1294
1295    /// v0.49.3: the dispatch table in `apply_event` and the
1296    /// `REDUCER_MUTATION_KINDS` constant must agree. Adding a new
1297    /// match arm without updating the constant (or vice versa) makes
1298    /// CI fail loudly here, which then makes the cross-impl fixture
1299    /// coverage assertion fail correctly downstream. This is the
1300    /// single source of truth that retires the hand-maintained mirror.
1301    #[test]
1302    fn dispatch_handles_every_declared_kind() {
1303        for kind in REDUCER_MUTATION_KINDS {
1304            let mut state = project::assemble("test", vec![], 0, 0, "test");
1305            // Dummy event with the declared kind. The handler may
1306            // reject the payload (it's empty), but it MUST NOT reject
1307            // the kind itself with "unsupported event kind" — that
1308            // would prove the dispatch table is missing an arm for
1309            // a kind the constant declares.
1310            let event = StateEvent {
1311                schema: events::EVENT_SCHEMA.to_string(),
1312                id: "vev_dispatch_check".to_string(),
1313                kind: (*kind).to_string(),
1314                target: StateTarget {
1315                    r#type: "finding".to_string(),
1316                    id: "vf_x".to_string(),
1317                },
1318                actor: StateActor {
1319                    id: "x".to_string(),
1320                    r#type: "human".to_string(),
1321                },
1322                timestamp: Utc::now().to_rfc3339(),
1323                reason: String::new(),
1324                before_hash: NULL_HASH.to_string(),
1325                after_hash: NULL_HASH.to_string(),
1326                payload: Value::Null,
1327                caveats: vec![],
1328                signature: None,
1329                schema_artifact_id: None,
1330            };
1331            let r = apply_event(&mut state, &event);
1332            if let Err(e) = r {
1333                assert!(
1334                    !e.contains("unsupported event kind"),
1335                    "kind {kind:?} declared in REDUCER_MUTATION_KINDS \
1336                     but rejected by apply_event dispatch: {e}"
1337                );
1338            }
1339        }
1340    }
1341
1342    /// v0.59 + v0.63: federation events live in `apply_event` as
1343    /// no-ops on finding state. They are intentionally absent from
1344    /// `REDUCER_MUTATION_KINDS` (they do not mutate any finding,
1345    /// negative_result, trajectory, artifact, or evidence_atom);
1346    /// the coverage assertion above does not exercise them. This
1347    /// test pins the no-op contract directly: the reducer accepts
1348    /// the kind without error, and the finding-state digest is
1349    /// unchanged after replay.
1350    #[test]
1351    fn federation_events_are_finding_state_noops() {
1352        for kind in &[
1353            "frontier.synced_with_peer",
1354            "frontier.conflict_detected",
1355            "frontier.conflict_resolved",
1356        ] {
1357            let mut state = project::assemble("test", vec![], 0, 0, "test");
1358            let snapshot_before = events::snapshot_hash(&state);
1359            let event = StateEvent {
1360                schema: events::EVENT_SCHEMA.to_string(),
1361                id: format!("vev_federation_{kind}"),
1362                kind: (*kind).to_string(),
1363                target: StateTarget {
1364                    r#type: "frontier_observation".to_string(),
1365                    id: "vfr_x".to_string(),
1366                },
1367                actor: StateActor {
1368                    id: "federation".to_string(),
1369                    r#type: "system".to_string(),
1370                },
1371                timestamp: Utc::now().to_rfc3339(),
1372                reason: format!("no-op contract test for {kind}"),
1373                before_hash: NULL_HASH.to_string(),
1374                after_hash: NULL_HASH.to_string(),
1375                payload: Value::Null,
1376                caveats: vec![],
1377                signature: None,
1378                schema_artifact_id: None,
1379            };
1380            apply_event(&mut state, &event)
1381                .unwrap_or_else(|e| panic!("federation kind {kind} rejected by reducer: {e}"));
1382            let snapshot_after = events::snapshot_hash(&state);
1383            assert_eq!(
1384                snapshot_before, snapshot_after,
1385                "federation event {kind} mutated finding-state snapshot; expected no-op"
1386            );
1387        }
1388    }
1389
1390    fn project_with_one_atom(missing_locator: bool) -> Project {
1391        // `project::assemble` calls `sources::materialize_project`,
1392        // which derives one evidence atom per finding. The hand-built
1393        // atom below is appended after materialization with a distinct
1394        // id (`vea_test_atom`), so it survives alongside the derived
1395        // atom. Tests look up atoms by id via `atom_by_id`.
1396        let mut state = project::assemble("test-locator", vec![finding("a")], 0, 0, "test");
1397        state.sources.push(crate::sources::SourceRecord {
1398            id: "vs_test_source".to_string(),
1399            source_type: "paper".to_string(),
1400            locator: "doi:10.1/test-source".to_string(),
1401            content_hash: None,
1402            title: "Test source".to_string(),
1403            authors: Vec::new(),
1404            year: Some(2026),
1405            doi: Some("10.1/test-source".to_string()),
1406            pmid: None,
1407            imported_at: "2026-01-01T00:00:00Z".to_string(),
1408            extraction_mode: "manual".to_string(),
1409            source_quality: "declared".to_string(),
1410            caveats: Vec::new(),
1411            finding_ids: vec![state.findings[0].id.clone()],
1412        });
1413        state.evidence_atoms.push(crate::sources::EvidenceAtom {
1414            id: "vea_test_atom".to_string(),
1415            source_id: "vs_test_source".to_string(),
1416            finding_id: state.findings[0].id.clone(),
1417            locator: if missing_locator {
1418                None
1419            } else {
1420                Some("doi:10.1/already-set".to_string())
1421            },
1422            evidence_type: "experimental".to_string(),
1423            measurement_or_claim: "test claim".to_string(),
1424            supports_or_challenges: "supports".to_string(),
1425            condition_refs: Vec::new(),
1426            extraction_method: "manual".to_string(),
1427            human_verified: false,
1428            caveats: if missing_locator {
1429                vec!["missing evidence locator".to_string()]
1430            } else {
1431                Vec::new()
1432            },
1433        });
1434        state
1435    }
1436
1437    fn atom_by_id<'a>(state: &'a Project, id: &str) -> &'a crate::sources::EvidenceAtom {
1438        state
1439            .evidence_atoms
1440            .iter()
1441            .find(|atom| atom.id == id)
1442            .expect("atom exists")
1443    }
1444
1445    #[test]
1446    fn evidence_atom_locator_repaired_sets_locator_and_clears_caveat() {
1447        let mut state = project_with_one_atom(true);
1448        assert!(state.evidence_atoms[0].locator.is_none());
1449        let event = StateEvent {
1450            schema: crate::events::EVENT_SCHEMA.to_string(),
1451            id: "vev_test".to_string(),
1452            kind: "evidence_atom.locator_repaired".to_string(),
1453            target: StateTarget {
1454                r#type: "evidence_atom".to_string(),
1455                id: "vea_test_atom".to_string(),
1456            },
1457            actor: StateActor {
1458                id: "agent:test".to_string(),
1459                r#type: "agent".to_string(),
1460            },
1461            timestamp: Utc::now().to_rfc3339(),
1462            reason: "Mechanical repair from parent source".to_string(),
1463            before_hash: NULL_HASH.to_string(),
1464            after_hash: NULL_HASH.to_string(),
1465            payload: json!({
1466                "proposal_id": "vpr_test",
1467                "source_id": "vs_test_source",
1468                "locator": "doi:10.1/test-source",
1469            }),
1470            caveats: vec![],
1471            signature: None,
1472            schema_artifact_id: None,
1473        };
1474        apply_event(&mut state, &event).expect("apply locator_repaired");
1475        let atom = atom_by_id(&state, "vea_test_atom");
1476        assert_eq!(atom.locator.as_deref(), Some("doi:10.1/test-source"));
1477        assert!(atom.caveats.is_empty());
1478    }
1479
1480    #[test]
1481    fn evidence_atom_locator_repaired_is_idempotent() {
1482        let mut state = project_with_one_atom(true);
1483        let event = StateEvent {
1484            schema: crate::events::EVENT_SCHEMA.to_string(),
1485            id: "vev_test".to_string(),
1486            kind: "evidence_atom.locator_repaired".to_string(),
1487            target: StateTarget {
1488                r#type: "evidence_atom".to_string(),
1489                id: "vea_test_atom".to_string(),
1490            },
1491            actor: StateActor {
1492                id: "agent:test".to_string(),
1493                r#type: "agent".to_string(),
1494            },
1495            timestamp: Utc::now().to_rfc3339(),
1496            reason: "Mechanical repair from parent source".to_string(),
1497            before_hash: NULL_HASH.to_string(),
1498            after_hash: NULL_HASH.to_string(),
1499            payload: json!({
1500                "proposal_id": "vpr_test",
1501                "source_id": "vs_test_source",
1502                "locator": "doi:10.1/test-source",
1503            }),
1504            caveats: vec![],
1505            signature: None,
1506            schema_artifact_id: None,
1507        };
1508        apply_event(&mut state, &event).expect("first apply");
1509        apply_event(&mut state, &event).expect("second apply is a no-op when locator matches");
1510        let atom = atom_by_id(&state, "vea_test_atom");
1511        assert_eq!(atom.locator.as_deref(), Some("doi:10.1/test-source"));
1512    }
1513
1514    #[test]
1515    fn evidence_atom_locator_repaired_refuses_divergent_overwrite() {
1516        let mut state = project_with_one_atom(false);
1517        let event = StateEvent {
1518            schema: crate::events::EVENT_SCHEMA.to_string(),
1519            id: "vev_test".to_string(),
1520            kind: "evidence_atom.locator_repaired".to_string(),
1521            target: StateTarget {
1522                r#type: "evidence_atom".to_string(),
1523                id: "vea_test_atom".to_string(),
1524            },
1525            actor: StateActor {
1526                id: "agent:test".to_string(),
1527                r#type: "agent".to_string(),
1528            },
1529            timestamp: Utc::now().to_rfc3339(),
1530            reason: "Different repair".to_string(),
1531            before_hash: NULL_HASH.to_string(),
1532            after_hash: NULL_HASH.to_string(),
1533            payload: json!({
1534                "proposal_id": "vpr_test",
1535                "source_id": "vs_test_source",
1536                "locator": "doi:10.1/different",
1537            }),
1538            caveats: vec![],
1539            signature: None,
1540            schema_artifact_id: None,
1541        };
1542        let r = apply_event(&mut state, &event);
1543        assert!(r.is_err());
1544        assert!(r.unwrap_err().contains("already has locator"));
1545    }
1546
1547    #[test]
1548    fn evidence_atom_locator_repaired_does_not_mutate_findings() {
1549        // Cross-impl conformance: this event mutates evidence_atoms only.
1550        let mut state = project_with_one_atom(true);
1551        let hashes_before: Vec<String> = state
1552            .findings
1553            .iter()
1554            .map(crate::events::finding_hash)
1555            .collect();
1556        let event = StateEvent {
1557            schema: crate::events::EVENT_SCHEMA.to_string(),
1558            id: "vev_test".to_string(),
1559            kind: "evidence_atom.locator_repaired".to_string(),
1560            target: StateTarget {
1561                r#type: "evidence_atom".to_string(),
1562                id: "vea_test_atom".to_string(),
1563            },
1564            actor: StateActor {
1565                id: "agent:test".to_string(),
1566                r#type: "agent".to_string(),
1567            },
1568            timestamp: Utc::now().to_rfc3339(),
1569            reason: "Mechanical repair".to_string(),
1570            before_hash: NULL_HASH.to_string(),
1571            after_hash: NULL_HASH.to_string(),
1572            payload: json!({
1573                "proposal_id": "vpr_test",
1574                "source_id": "vs_test_source",
1575                "locator": "doi:10.1/test-source",
1576            }),
1577            caveats: vec![],
1578            signature: None,
1579            schema_artifact_id: None,
1580        };
1581        apply_event(&mut state, &event).expect("apply ok");
1582        let hashes_after: Vec<String> = state
1583            .findings
1584            .iter()
1585            .map(crate::events::finding_hash)
1586            .collect();
1587        assert_eq!(hashes_before, hashes_after);
1588    }
1589}