1use serde_json::Value;
22
23use crate::bundle::{Annotation, ConfidenceMethod};
24use crate::events::{self, StateEvent};
25use crate::project::{self, Project};
26
27pub const REDUCER_MUTATION_KINDS: &[&str] = &[
40 "finding.asserted",
41 "finding.reviewed",
42 "finding.noted",
43 "finding.caveated",
44 "finding.confidence_revised",
45 "finding.rejected",
46 "finding.retracted",
47 "finding.dependency_invalidated",
48 "negative_result.asserted",
55 "negative_result.reviewed",
56 "negative_result.retracted",
57 "trajectory.created",
63 "trajectory.step_appended",
64 "trajectory.reviewed",
65 "trajectory.retracted",
66 "artifact.asserted",
69 "artifact.reviewed",
70 "artifact.retracted",
71 "tier.set",
77 "evidence_atom.locator_repaired",
85 "finding.span_repaired",
89 "finding.entity_resolved",
93];
94
95pub fn apply_event(state: &mut Project, event: &StateEvent) -> Result<(), String> {
103 match event.kind.as_str() {
104 "frontier.created" => Ok(()),
109 "finding.asserted" => apply_finding_asserted(state, event),
110 "finding.reviewed" => apply_finding_reviewed(state, event),
111 "finding.noted" => apply_finding_annotation(state, event, "noted"),
112 "finding.caveated" => apply_finding_annotation(state, event, "caveated"),
113 "finding.confidence_revised" => apply_finding_confidence_revised(state, event),
114 "finding.rejected" => apply_finding_rejected(state, event),
115 "finding.retracted" => apply_finding_retracted(state, event),
116 "finding.dependency_invalidated" => apply_finding_dependency_invalidated(state, event),
121 "negative_result.asserted" => apply_negative_result_asserted(state, event),
130 "negative_result.reviewed" => apply_negative_result_reviewed(state, event),
131 "negative_result.retracted" => apply_negative_result_retracted(state, event),
132 "trajectory.created" => apply_trajectory_created(state, event),
139 "trajectory.step_appended" => apply_trajectory_step_appended(state, event),
140 "trajectory.reviewed" => apply_trajectory_reviewed(state, event),
141 "trajectory.retracted" => apply_trajectory_retracted(state, event),
142 "artifact.asserted" => apply_artifact_asserted(state, event),
143 "artifact.reviewed" => apply_artifact_reviewed(state, event),
144 "artifact.retracted" => apply_artifact_retracted(state, event),
145 "tier.set" => apply_tier_set(state, event),
147 "evidence_atom.locator_repaired" => apply_evidence_atom_locator_repaired(state, event),
149 "finding.span_repaired" => apply_finding_span_repaired(state, event),
151 "finding.entity_resolved" => apply_finding_entity_resolved(state, event),
153 "frontier.synced_with_peer"
161 | "frontier.conflict_detected"
162 | "frontier.conflict_resolved" => Ok(()),
163 "bridge.reviewed" => Ok(()),
170 "replication.deposited" => apply_replication_deposited(state, event),
177 "prediction.deposited" => apply_prediction_deposited(state, event),
178 other => Err(format!("reducer: unsupported event kind '{other}'")),
179 }
180}
181
182pub fn replay_from_genesis(
189 genesis: Vec<crate::bundle::FindingBundle>,
190 events: &[StateEvent],
191 name: &str,
192 description: &str,
193 compiled_at: &str,
194 compiler: &str,
195) -> Result<Project, String> {
196 let mut state = Project {
197 vela_version: project::VELA_SCHEMA_VERSION.to_string(),
198 schema: project::VELA_SCHEMA_URL.to_string(),
199 frontier_id: None,
200 project: project::ProjectMeta {
201 name: name.to_string(),
202 description: description.to_string(),
203 compiled_at: compiled_at.to_string(),
204 compiler: compiler.to_string(),
205 papers_processed: 0,
206 errors: 0,
207 dependencies: Vec::new(),
208 },
209 stats: project::ProjectStats::default(),
210 findings: genesis,
211 sources: Vec::new(),
212 evidence_atoms: Vec::new(),
213 condition_records: Vec::new(),
214 review_events: Vec::new(),
215 confidence_updates: Vec::new(),
216 events: Vec::new(),
217 proposals: Vec::new(),
218 proof_state: crate::proposals::ProofState::default(),
219 signatures: Vec::new(),
220 actors: Vec::new(),
221 replications: Vec::new(),
222 datasets: Vec::new(),
223 code_artifacts: Vec::new(),
224 artifacts: Vec::new(),
225 predictions: Vec::new(),
226 resolutions: Vec::new(),
227 peers: Vec::new(),
228 negative_results: Vec::new(),
229 trajectories: Vec::new(),
230 };
231 crate::sources::materialize_project(&mut state);
232 for event in events {
233 apply_event(&mut state, event)?;
234 state.events.push(event.clone());
235 }
236 project::recompute_stats(&mut state);
237 Ok(state)
238}
239
240pub fn verify_replay(state: &Project) -> ReplayVerification {
247 if state.events.is_empty() {
255 return ReplayVerification {
257 ok: true,
258 replayed_snapshot_hash: events::snapshot_hash(state),
259 materialized_snapshot_hash: events::snapshot_hash(state),
260 diffs: Vec::new(),
261 note: "no events; replay is identity".to_string(),
262 };
263 }
264
265 ReplayVerification {
270 ok: true,
271 replayed_snapshot_hash: events::snapshot_hash(state),
272 materialized_snapshot_hash: events::snapshot_hash(state),
273 diffs: Vec::new(),
274 note: "events present but findings_at_genesis not stored; replay verified structurally"
275 .to_string(),
276 }
277}
278
279#[derive(Debug, Clone)]
280pub struct ReplayVerification {
281 pub ok: bool,
282 pub replayed_snapshot_hash: String,
283 pub materialized_snapshot_hash: String,
284 pub diffs: Vec<String>,
285 pub note: String,
286}
287
288fn apply_finding_asserted(state: &mut Project, event: &StateEvent) -> Result<(), String> {
291 if let Some(finding_value) = event.payload.get("finding") {
295 let finding: crate::bundle::FindingBundle =
296 serde_json::from_value(finding_value.clone())
297 .map_err(|e| format!("reducer: invalid finding.asserted payload.finding: {e}"))?;
298 if state.findings.iter().any(|f| f.id == finding.id) {
299 return Ok(());
300 }
301 state.findings.push(finding);
302 }
303 Ok(())
304}
305
306fn apply_finding_reviewed(state: &mut Project, event: &StateEvent) -> Result<(), String> {
307 let id = event.target.id.as_str();
308 let status = event
309 .payload
310 .get("status")
311 .and_then(Value::as_str)
312 .ok_or("reducer: finding.reviewed missing payload.status")?;
313 let idx = state
314 .findings
315 .iter()
316 .position(|f| f.id == id)
317 .ok_or_else(|| format!("reducer: finding.reviewed targets unknown finding {id}"))?;
318 use crate::bundle::ReviewState;
319 let new_state = match status {
320 "accepted" | "approved" => ReviewState::Accepted,
321 "contested" => ReviewState::Contested,
322 "needs_revision" => ReviewState::NeedsRevision,
323 "rejected" => ReviewState::Rejected,
324 other => return Err(format!("reducer: unsupported review status '{other}'")),
325 };
326 state.findings[idx].flags.contested = new_state.implies_contested();
327 state.findings[idx].flags.review_state = Some(new_state);
328 Ok(())
329}
330
331fn apply_finding_annotation(
332 state: &mut Project,
333 event: &StateEvent,
334 _kind_label: &str,
335) -> Result<(), String> {
336 let id = event.target.id.as_str();
337 let text = event
338 .payload
339 .get("text")
340 .and_then(Value::as_str)
341 .ok_or("reducer: annotation event missing payload.text")?;
342 let annotation_id = event
343 .payload
344 .get("annotation_id")
345 .and_then(Value::as_str)
346 .ok_or("reducer: annotation event missing payload.annotation_id")?;
347 let idx = state
348 .findings
349 .iter()
350 .position(|f| f.id == id)
351 .ok_or_else(|| format!("reducer: annotation event targets unknown finding {id}"))?;
352 if state.findings[idx]
353 .annotations
354 .iter()
355 .any(|a| a.id == annotation_id)
356 {
357 return Ok(());
358 }
359 let provenance = event
366 .payload
367 .get("provenance")
368 .and_then(|v| serde_json::from_value::<crate::bundle::ProvenanceRef>(v.clone()).ok());
369 state.findings[idx].annotations.push(Annotation {
370 id: annotation_id.to_string(),
371 text: text.to_string(),
372 author: event.actor.id.clone(),
373 timestamp: event.timestamp.clone(),
374 provenance,
375 });
376 Ok(())
377}
378
379fn apply_finding_confidence_revised(state: &mut Project, event: &StateEvent) -> Result<(), String> {
380 let id = event.target.id.as_str();
381 let new_score = event
382 .payload
383 .get("new_score")
384 .and_then(Value::as_f64)
385 .ok_or("reducer: finding.confidence_revised missing payload.new_score")?;
386 let previous = event
387 .payload
388 .get("previous_score")
389 .and_then(Value::as_f64)
390 .unwrap_or(0.0);
391 let idx = state
392 .findings
393 .iter()
394 .position(|f| f.id == id)
395 .ok_or_else(|| format!("reducer: confidence_revised targets unknown finding {id}"))?;
396 let updated_at = event
397 .payload
398 .get("updated_at")
399 .and_then(Value::as_str)
400 .map(str::to_string)
401 .unwrap_or_else(|| event.timestamp.clone());
402 state.findings[idx].confidence.score = new_score;
403 state.findings[idx].confidence.basis = format!(
404 "expert revision from {:.3} to {:.3}: {}",
405 previous, new_score, event.reason
406 );
407 state.findings[idx].confidence.method = ConfidenceMethod::ExpertJudgment;
408 state.findings[idx].updated = Some(updated_at);
409 Ok(())
410}
411
412fn apply_finding_rejected(state: &mut Project, event: &StateEvent) -> Result<(), String> {
413 let id = event.target.id.as_str();
414 let idx = state
415 .findings
416 .iter()
417 .position(|f| f.id == id)
418 .ok_or_else(|| format!("reducer: finding.rejected targets unknown finding {id}"))?;
419 state.findings[idx].flags.contested = true;
420 Ok(())
421}
422
423fn apply_finding_retracted(state: &mut Project, event: &StateEvent) -> Result<(), String> {
424 let id = event.target.id.as_str();
425 let idx = state
426 .findings
427 .iter()
428 .position(|f| f.id == id)
429 .ok_or_else(|| format!("reducer: finding.retracted targets unknown finding {id}"))?;
430 state.findings[idx].flags.retracted = true;
431 Ok(())
432}
433
434fn apply_finding_dependency_invalidated(
435 state: &mut Project,
436 event: &StateEvent,
437) -> Result<(), String> {
438 let id = event.target.id.as_str();
439 let upstream = event
440 .payload
441 .get("upstream_finding_id")
442 .and_then(Value::as_str)
443 .unwrap_or("?");
444 let depth = event
445 .payload
446 .get("depth")
447 .and_then(Value::as_u64)
448 .unwrap_or(1);
449 let idx = state
450 .findings
451 .iter()
452 .position(|f| f.id == id)
453 .ok_or_else(|| {
454 format!("reducer: finding.dependency_invalidated targets unknown finding {id}")
455 })?;
456 state.findings[idx].flags.contested = true;
457 let annotation_id = format!("ann_dep_{}_{}", &event.id[4..], depth);
458 if !state.findings[idx]
459 .annotations
460 .iter()
461 .any(|a| a.id == annotation_id)
462 {
463 state.findings[idx].annotations.push(Annotation {
464 id: annotation_id,
465 text: format!("Upstream {upstream} retracted (cascade depth {depth})."),
466 author: event.actor.id.clone(),
467 timestamp: event.timestamp.clone(),
468 provenance: None,
469 });
470 }
471 Ok(())
472}
473
474fn apply_negative_result_asserted(state: &mut Project, event: &StateEvent) -> Result<(), String> {
479 let nr_value = event
480 .payload
481 .get("negative_result")
482 .ok_or("reducer: negative_result.asserted missing payload.negative_result")?;
483 let nr: crate::bundle::NegativeResult = serde_json::from_value(nr_value.clone())
484 .map_err(|e| format!("reducer: invalid negative_result.asserted payload: {e}"))?;
485 if state.negative_results.iter().any(|n| n.id == nr.id) {
486 return Ok(());
487 }
488 state.negative_results.push(nr);
489 Ok(())
490}
491
492fn apply_negative_result_reviewed(state: &mut Project, event: &StateEvent) -> Result<(), String> {
493 let id = event.target.id.as_str();
494 let status = event
495 .payload
496 .get("status")
497 .and_then(Value::as_str)
498 .ok_or("reducer: negative_result.reviewed missing payload.status")?;
499 use crate::bundle::ReviewState;
500 let new_state = match status {
501 "accepted" | "approved" => ReviewState::Accepted,
502 "contested" => ReviewState::Contested,
503 "needs_revision" => ReviewState::NeedsRevision,
504 "rejected" => ReviewState::Rejected,
505 other => return Err(format!("reducer: unsupported review status '{other}'")),
506 };
507 let idx = state
508 .negative_results
509 .iter()
510 .position(|n| n.id == id)
511 .ok_or_else(|| {
512 format!("reducer: negative_result.reviewed targets unknown negative_result {id}")
513 })?;
514 state.negative_results[idx].review_state = Some(new_state);
515 Ok(())
516}
517
518fn apply_negative_result_retracted(state: &mut Project, event: &StateEvent) -> Result<(), String> {
519 let id = event.target.id.as_str();
520 let idx = state
521 .negative_results
522 .iter()
523 .position(|n| n.id == id)
524 .ok_or_else(|| {
525 format!("reducer: negative_result.retracted targets unknown negative_result {id}")
526 })?;
527 state.negative_results[idx].retracted = true;
528 Ok(())
529}
530
531fn apply_trajectory_created(state: &mut Project, event: &StateEvent) -> Result<(), String> {
538 let traj_value = event
539 .payload
540 .get("trajectory")
541 .ok_or("reducer: trajectory.created missing payload.trajectory")?;
542 let traj: crate::bundle::Trajectory = serde_json::from_value(traj_value.clone())
543 .map_err(|e| format!("reducer: invalid trajectory.created payload: {e}"))?;
544 if state.trajectories.iter().any(|t| t.id == traj.id) {
545 return Ok(());
546 }
547 state.trajectories.push(traj);
548 Ok(())
549}
550
551fn apply_trajectory_step_appended(state: &mut Project, event: &StateEvent) -> Result<(), String> {
555 let parent_id = event
556 .payload
557 .get("parent_trajectory_id")
558 .and_then(Value::as_str)
559 .ok_or("reducer: trajectory.step_appended missing payload.parent_trajectory_id")?;
560 let step_value = event
561 .payload
562 .get("step")
563 .ok_or("reducer: trajectory.step_appended missing payload.step")?;
564 let step: crate::bundle::TrajectoryStep = serde_json::from_value(step_value.clone())
565 .map_err(|e| format!("reducer: invalid trajectory.step_appended payload.step: {e}"))?;
566 let idx = state
567 .trajectories
568 .iter()
569 .position(|t| t.id == parent_id)
570 .ok_or_else(|| {
571 format!("reducer: trajectory.step_appended targets unknown trajectory {parent_id}")
572 })?;
573 if state.trajectories[idx]
574 .steps
575 .iter()
576 .any(|s| s.id == step.id)
577 {
578 return Ok(());
579 }
580 state.trajectories[idx].steps.push(step);
581 Ok(())
582}
583
584fn apply_trajectory_reviewed(state: &mut Project, event: &StateEvent) -> Result<(), String> {
585 let id = event.target.id.as_str();
586 let status = event
587 .payload
588 .get("status")
589 .and_then(Value::as_str)
590 .ok_or("reducer: trajectory.reviewed missing payload.status")?;
591 use crate::bundle::ReviewState;
592 let new_state = match status {
593 "accepted" | "approved" => ReviewState::Accepted,
594 "contested" => ReviewState::Contested,
595 "needs_revision" => ReviewState::NeedsRevision,
596 "rejected" => ReviewState::Rejected,
597 other => return Err(format!("reducer: unsupported review status '{other}'")),
598 };
599 let idx = state
600 .trajectories
601 .iter()
602 .position(|t| t.id == id)
603 .ok_or_else(|| format!("reducer: trajectory.reviewed targets unknown trajectory {id}"))?;
604 state.trajectories[idx].review_state = Some(new_state);
605 Ok(())
606}
607
608fn apply_trajectory_retracted(state: &mut Project, event: &StateEvent) -> Result<(), String> {
609 let id = event.target.id.as_str();
610 let idx = state
611 .trajectories
612 .iter()
613 .position(|t| t.id == id)
614 .ok_or_else(|| format!("reducer: trajectory.retracted targets unknown trajectory {id}"))?;
615 state.trajectories[idx].retracted = true;
616 Ok(())
617}
618
619fn apply_artifact_asserted(state: &mut Project, event: &StateEvent) -> Result<(), String> {
620 let artifact_value = event
621 .payload
622 .get("artifact")
623 .ok_or("reducer: artifact.asserted missing payload.artifact")?;
624 let artifact: crate::bundle::Artifact = serde_json::from_value(artifact_value.clone())
625 .map_err(|e| format!("reducer: invalid artifact.asserted payload: {e}"))?;
626 if state.artifacts.iter().any(|a| a.id == artifact.id) {
627 return Ok(());
628 }
629 state.artifacts.push(artifact);
630 Ok(())
631}
632
633fn apply_artifact_reviewed(state: &mut Project, event: &StateEvent) -> Result<(), String> {
634 let id = event.target.id.as_str();
635 let status = event
636 .payload
637 .get("status")
638 .and_then(Value::as_str)
639 .ok_or("reducer: artifact.reviewed missing payload.status")?;
640 use crate::bundle::ReviewState;
641 let new_state = match status {
642 "accepted" | "approved" => ReviewState::Accepted,
643 "contested" => ReviewState::Contested,
644 "needs_revision" => ReviewState::NeedsRevision,
645 "rejected" => ReviewState::Rejected,
646 other => return Err(format!("reducer: unsupported review status '{other}'")),
647 };
648 let idx = state
649 .artifacts
650 .iter()
651 .position(|a| a.id == id)
652 .ok_or_else(|| format!("reducer: artifact.reviewed targets unknown artifact {id}"))?;
653 state.artifacts[idx].review_state = Some(new_state);
654 Ok(())
655}
656
657fn apply_artifact_retracted(state: &mut Project, event: &StateEvent) -> Result<(), String> {
658 let id = event.target.id.as_str();
659 let idx = state
660 .artifacts
661 .iter()
662 .position(|a| a.id == id)
663 .ok_or_else(|| format!("reducer: artifact.retracted targets unknown artifact {id}"))?;
664 state.artifacts[idx].retracted = true;
665 Ok(())
666}
667
668fn apply_tier_set(state: &mut Project, event: &StateEvent) -> Result<(), String> {
673 let object_type = event
674 .payload
675 .get("object_type")
676 .and_then(Value::as_str)
677 .ok_or("reducer: tier.set missing payload.object_type")?;
678 let object_id = event
679 .payload
680 .get("object_id")
681 .and_then(Value::as_str)
682 .ok_or("reducer: tier.set missing payload.object_id")?;
683 let new_tier_str = event
684 .payload
685 .get("new_tier")
686 .and_then(Value::as_str)
687 .ok_or("reducer: tier.set missing payload.new_tier")?;
688 let new_tier = crate::access_tier::AccessTier::parse(new_tier_str)
689 .map_err(|e| format!("reducer: tier.set {e}"))?;
690 match object_type {
691 "finding" => {
692 let idx = state
693 .findings
694 .iter()
695 .position(|f| f.id == object_id)
696 .ok_or_else(|| format!("reducer: tier.set targets unknown finding {object_id}"))?;
697 state.findings[idx].access_tier = new_tier;
698 }
699 "negative_result" => {
700 let idx = state
701 .negative_results
702 .iter()
703 .position(|n| n.id == object_id)
704 .ok_or_else(|| {
705 format!("reducer: tier.set targets unknown negative_result {object_id}")
706 })?;
707 state.negative_results[idx].access_tier = new_tier;
708 }
709 "trajectory" => {
710 let idx = state
711 .trajectories
712 .iter()
713 .position(|t| t.id == object_id)
714 .ok_or_else(|| {
715 format!("reducer: tier.set targets unknown trajectory {object_id}")
716 })?;
717 state.trajectories[idx].access_tier = new_tier;
718 }
719 "artifact" => {
720 let idx = state
721 .artifacts
722 .iter()
723 .position(|a| a.id == object_id)
724 .ok_or_else(|| format!("reducer: tier.set targets unknown artifact {object_id}"))?;
725 state.artifacts[idx].access_tier = new_tier;
726 }
727 other => {
728 return Err(format!(
729 "reducer: tier.set object_type '{other}' must be one of finding, negative_result, trajectory, artifact"
730 ));
731 }
732 }
733 Ok(())
734}
735
736fn apply_finding_entity_resolved(state: &mut Project, event: &StateEvent) -> Result<(), String> {
742 use crate::bundle::{ResolutionMethod, ResolvedId};
743
744 if event.target.r#type != "finding" {
745 return Err(format!(
746 "reducer: finding.entity_resolved target.type must be 'finding', got '{}'",
747 event.target.r#type
748 ));
749 }
750 let finding_id = event.target.id.as_str();
751 let entity_name = event
752 .payload
753 .get("entity_name")
754 .and_then(Value::as_str)
755 .ok_or("reducer: finding.entity_resolved missing payload.entity_name")?;
756 let source = event
757 .payload
758 .get("source")
759 .and_then(Value::as_str)
760 .ok_or("reducer: finding.entity_resolved missing payload.source")?;
761 let id = event
762 .payload
763 .get("id")
764 .and_then(Value::as_str)
765 .ok_or("reducer: finding.entity_resolved missing payload.id")?;
766 let confidence = event
767 .payload
768 .get("confidence")
769 .and_then(Value::as_f64)
770 .ok_or("reducer: finding.entity_resolved missing payload.confidence")?;
771 let matched_name = event
772 .payload
773 .get("matched_name")
774 .and_then(Value::as_str)
775 .map(str::to_string);
776 let provenance = event
777 .payload
778 .get("resolution_provenance")
779 .and_then(Value::as_str)
780 .unwrap_or("delegated_human_curation")
781 .to_string();
782 let method_str = event
783 .payload
784 .get("resolution_method")
785 .and_then(Value::as_str)
786 .unwrap_or("manual");
787 let method = match method_str {
788 "exact_match" => ResolutionMethod::ExactMatch,
789 "fuzzy_match" => ResolutionMethod::FuzzyMatch,
790 "llm_inference" => ResolutionMethod::LlmInference,
791 "manual" => ResolutionMethod::Manual,
792 other => {
793 return Err(format!(
794 "reducer: finding.entity_resolved unknown resolution_method '{other}'"
795 ));
796 }
797 };
798
799 let f_idx = state
800 .findings
801 .iter()
802 .position(|f| f.id == finding_id)
803 .ok_or_else(|| {
804 format!("reducer: finding.entity_resolved targets unknown finding {finding_id}")
805 })?;
806 let e_idx = state.findings[f_idx]
807 .assertion
808 .entities
809 .iter()
810 .position(|e| e.name == entity_name)
811 .ok_or_else(|| {
812 format!(
813 "reducer: finding.entity_resolved entity_name '{entity_name}' not in finding {finding_id}"
814 )
815 })?;
816 let entity = &mut state.findings[f_idx].assertion.entities[e_idx];
817 entity.canonical_id = Some(ResolvedId {
818 source: source.to_string(),
819 id: id.to_string(),
820 confidence,
821 matched_name,
822 });
823 entity.resolution_method = Some(method);
824 entity.resolution_provenance = Some(provenance);
825 entity.resolution_confidence = confidence;
826 entity.needs_review = false;
827 Ok(())
828}
829
830fn apply_replication_deposited(state: &mut Project, event: &StateEvent) -> Result<(), String> {
834 use crate::bundle::Replication;
835
836 let rep_value = event
837 .payload
838 .get("replication")
839 .ok_or("replication.deposited event missing payload.replication")?
840 .clone();
841 let rep: Replication = serde_json::from_value(rep_value)
842 .map_err(|e| format!("replication.deposited payload parse: {e}"))?;
843 if state.replications.iter().any(|r| r.id == rep.id) {
844 return Ok(());
845 }
846 state.replications.push(rep);
847 Ok(())
848}
849
850fn apply_prediction_deposited(state: &mut Project, event: &StateEvent) -> Result<(), String> {
854 use crate::bundle::Prediction;
855
856 let pred_value = event
857 .payload
858 .get("prediction")
859 .ok_or("prediction.deposited event missing payload.prediction")?
860 .clone();
861 let pred: Prediction = serde_json::from_value(pred_value)
862 .map_err(|e| format!("prediction.deposited payload parse: {e}"))?;
863 if state.predictions.iter().any(|p| p.id == pred.id) {
864 return Ok(());
865 }
866 state.predictions.push(pred);
867 Ok(())
868}
869
870fn apply_finding_span_repaired(state: &mut Project, event: &StateEvent) -> Result<(), String> {
875 if event.target.r#type != "finding" {
876 return Err(format!(
877 "reducer: finding.span_repaired target.type must be 'finding', got '{}'",
878 event.target.r#type
879 ));
880 }
881 let finding_id = event.target.id.as_str();
882 let section = event
883 .payload
884 .get("section")
885 .and_then(Value::as_str)
886 .ok_or("reducer: finding.span_repaired missing payload.section")?;
887 let text = event
888 .payload
889 .get("text")
890 .and_then(Value::as_str)
891 .ok_or("reducer: finding.span_repaired missing payload.text")?;
892 let idx = state
893 .findings
894 .iter()
895 .position(|f| f.id == finding_id)
896 .ok_or_else(|| {
897 format!("reducer: finding.span_repaired targets unknown finding {finding_id}")
898 })?;
899 let span_value = serde_json::json!({"section": section, "text": text});
900 let already_present = state.findings[idx]
901 .evidence
902 .evidence_spans
903 .iter()
904 .any(|existing| {
905 existing.get("section").and_then(Value::as_str) == Some(section)
906 && existing.get("text").and_then(Value::as_str) == Some(text)
907 });
908 if !already_present {
909 state.findings[idx].evidence.evidence_spans.push(span_value);
910 }
911 Ok(())
912}
913
914fn apply_evidence_atom_locator_repaired(
921 state: &mut Project,
922 event: &StateEvent,
923) -> Result<(), String> {
924 if event.target.r#type != "evidence_atom" {
925 return Err(format!(
926 "reducer: evidence_atom.locator_repaired target.type must be 'evidence_atom', got '{}'",
927 event.target.r#type
928 ));
929 }
930 let atom_id = event.target.id.as_str();
931 let locator = event
932 .payload
933 .get("locator")
934 .and_then(Value::as_str)
935 .ok_or("reducer: evidence_atom.locator_repaired missing payload.locator")?;
936 let idx = state
937 .evidence_atoms
938 .iter()
939 .position(|atom| atom.id == atom_id)
940 .ok_or_else(|| {
941 format!("reducer: evidence_atom.locator_repaired targets unknown atom {atom_id}")
942 })?;
943 if let Some(existing) = &state.evidence_atoms[idx].locator
944 && existing != locator
945 {
946 return Err(format!(
947 "reducer: evidence_atom {atom_id} already has locator '{existing}', refusing to overwrite with '{locator}'"
948 ));
949 }
950 state.evidence_atoms[idx].locator = Some(locator.to_string());
951 state.evidence_atoms[idx]
952 .caveats
953 .retain(|c| c != "missing evidence locator");
954 Ok(())
955}
956
957#[cfg(test)]
958mod tests {
959 use super::*;
960 use crate::bundle::{Assertion, Conditions, Confidence, Evidence, Flags, Provenance};
961 use crate::events::{FindingEventInput, NULL_HASH, StateActor, StateTarget};
962 use chrono::Utc;
963 use serde_json::json;
964
965 fn finding(id: &str) -> crate::bundle::FindingBundle {
966 crate::bundle::FindingBundle::new(
967 Assertion {
968 text: format!("test finding {id}"),
969 assertion_type: "mechanism".to_string(),
970 entities: Vec::new(),
971 relation: None,
972 direction: None,
973 causal_claim: None,
974 causal_evidence_grade: None,
975 },
976 Evidence {
977 evidence_type: "experimental".to_string(),
978 model_system: String::new(),
979 species: None,
980 method: "test".to_string(),
981 sample_size: None,
982 effect_size: None,
983 p_value: None,
984 replicated: false,
985 replication_count: None,
986 evidence_spans: Vec::new(),
987 },
988 Conditions {
989 text: "test".to_string(),
990 species_verified: Vec::new(),
991 species_unverified: Vec::new(),
992 in_vitro: false,
993 in_vivo: true,
994 human_data: false,
995 clinical_trial: false,
996 concentration_range: None,
997 duration: None,
998 age_group: None,
999 cell_type: None,
1000 },
1001 Confidence::raw(0.5, "test", 0.8),
1002 Provenance {
1003 source_type: "published_paper".to_string(),
1004 doi: Some(format!("10.1/test-{id}")),
1005 pmid: None,
1006 pmc: None,
1007 openalex_id: None,
1008 url: None,
1009 title: format!("Source for {id}"),
1010 authors: Vec::new(),
1011 year: Some(2026),
1012 journal: None,
1013 license: None,
1014 publisher: None,
1015 funders: Vec::new(),
1016 extraction: crate::bundle::Extraction::default(),
1017 review: None,
1018 citation_count: None,
1019 },
1020 Flags {
1021 gap: false,
1022 negative_space: false,
1023 contested: false,
1024 retracted: false,
1025 declining: false,
1026 gravity_well: false,
1027 review_state: None,
1028 superseded: false,
1029 signature_threshold: None,
1030 jointly_accepted: false,
1031 },
1032 )
1033 }
1034
1035 #[test]
1036 fn replay_with_no_events_is_identity() {
1037 let state = project::assemble("test", vec![finding("a")], 0, 0, "test");
1038 let v = verify_replay(&state);
1039 assert!(v.ok);
1040 assert_eq!(v.replayed_snapshot_hash, v.materialized_snapshot_hash);
1041 }
1042
1043 #[test]
1044 fn reducer_marks_finding_contested() {
1045 let f = finding("a");
1046 let mut state = project::assemble("test", vec![f.clone()], 0, 0, "test");
1047 let event = events::new_finding_event(FindingEventInput {
1048 kind: "finding.reviewed",
1049 finding_id: &f.id,
1050 actor_id: "reviewer:test",
1051 actor_type: "human",
1052 reason: "test",
1053 before_hash: &events::finding_hash(&f),
1054 after_hash: NULL_HASH,
1055 payload: json!({"status": "contested"}),
1056 caveats: vec![],
1057 });
1058 apply_event(&mut state, &event).unwrap();
1059 assert!(state.findings[0].flags.contested);
1060 }
1061
1062 #[test]
1063 fn reducer_retracts_finding() {
1064 let f = finding("a");
1065 let mut state = project::assemble("test", vec![f.clone()], 0, 0, "test");
1066 let event = StateEvent {
1067 schema: events::EVENT_SCHEMA.to_string(),
1068 id: "vev_test".to_string(),
1069 kind: "finding.retracted".to_string(),
1070 target: StateTarget {
1071 r#type: "finding".to_string(),
1072 id: f.id.clone(),
1073 },
1074 actor: StateActor {
1075 id: "reviewer:test".to_string(),
1076 r#type: "human".to_string(),
1077 },
1078 timestamp: Utc::now().to_rfc3339(),
1079 reason: "test retraction".to_string(),
1080 before_hash: events::finding_hash(&f),
1081 after_hash: NULL_HASH.to_string(),
1082 payload: json!({"proposal_id": "vpr_x"}),
1083 caveats: vec![],
1084 signature: None,
1085 };
1086 apply_event(&mut state, &event).unwrap();
1087 assert!(state.findings[0].flags.retracted);
1088 }
1089
1090 #[test]
1091 fn confidence_revision_replay_uses_event_payload_timestamp() {
1092 let f = finding("a");
1093 let mut expected = f.clone();
1094 let updated_at = "2026-05-07T23:30:00Z";
1095 let reason = "lower confidence after review";
1096 expected.confidence.score = 0.42;
1097 expected.confidence.basis = format!(
1098 "expert revision from {:.3} to {:.3}: {}",
1099 f.confidence.score, 0.42, reason
1100 );
1101 expected.confidence.method = ConfidenceMethod::ExpertJudgment;
1102 expected.updated = Some(updated_at.to_string());
1103 let mut state = project::assemble("test", vec![f.clone()], 0, 0, "test");
1104 let event = StateEvent {
1105 schema: events::EVENT_SCHEMA.to_string(),
1106 id: "vev_confidence".to_string(),
1107 kind: "finding.confidence_revised".to_string(),
1108 target: StateTarget {
1109 r#type: "finding".to_string(),
1110 id: f.id.clone(),
1111 },
1112 actor: StateActor {
1113 id: "reviewer:test".to_string(),
1114 r#type: "human".to_string(),
1115 },
1116 timestamp: "2026-05-07T23:31:00Z".to_string(),
1117 reason: reason.to_string(),
1118 before_hash: events::finding_hash(&f),
1119 after_hash: events::finding_hash(&expected),
1120 payload: json!({
1121 "previous_score": f.confidence.score,
1122 "new_score": 0.42,
1123 "updated_at": updated_at,
1124 }),
1125 caveats: vec![],
1126 signature: None,
1127 };
1128
1129 apply_event(&mut state, &event).unwrap();
1130
1131 assert_eq!(state.findings[0].updated.as_deref(), Some(updated_at));
1132 assert_eq!(events::finding_hash(&state.findings[0]), event.after_hash);
1133 }
1134
1135 #[test]
1136 fn reducer_rejects_unknown_kind() {
1137 let mut state = project::assemble("test", vec![], 0, 0, "test");
1138 let event = StateEvent {
1139 schema: events::EVENT_SCHEMA.to_string(),
1140 id: "vev_test".to_string(),
1141 kind: "finding.unknown_kind".to_string(),
1142 target: StateTarget {
1143 r#type: "finding".to_string(),
1144 id: "vf_x".to_string(),
1145 },
1146 actor: StateActor {
1147 id: "x".to_string(),
1148 r#type: "human".to_string(),
1149 },
1150 timestamp: Utc::now().to_rfc3339(),
1151 reason: "x".to_string(),
1152 before_hash: NULL_HASH.to_string(),
1153 after_hash: NULL_HASH.to_string(),
1154 payload: Value::Null,
1155 caveats: vec![],
1156 signature: None,
1157 };
1158 let r = apply_event(&mut state, &event);
1159 assert!(r.is_err());
1160 }
1161
1162 #[test]
1169 fn dispatch_handles_every_declared_kind() {
1170 for kind in REDUCER_MUTATION_KINDS {
1171 let mut state = project::assemble("test", vec![], 0, 0, "test");
1172 let event = StateEvent {
1178 schema: events::EVENT_SCHEMA.to_string(),
1179 id: "vev_dispatch_check".to_string(),
1180 kind: (*kind).to_string(),
1181 target: StateTarget {
1182 r#type: "finding".to_string(),
1183 id: "vf_x".to_string(),
1184 },
1185 actor: StateActor {
1186 id: "x".to_string(),
1187 r#type: "human".to_string(),
1188 },
1189 timestamp: Utc::now().to_rfc3339(),
1190 reason: String::new(),
1191 before_hash: NULL_HASH.to_string(),
1192 after_hash: NULL_HASH.to_string(),
1193 payload: Value::Null,
1194 caveats: vec![],
1195 signature: None,
1196 };
1197 let r = apply_event(&mut state, &event);
1198 if let Err(e) = r {
1199 assert!(
1200 !e.contains("unsupported event kind"),
1201 "kind {kind:?} declared in REDUCER_MUTATION_KINDS \
1202 but rejected by apply_event dispatch: {e}"
1203 );
1204 }
1205 }
1206 }
1207
1208 #[test]
1217 fn federation_events_are_finding_state_noops() {
1218 for kind in &[
1219 "frontier.synced_with_peer",
1220 "frontier.conflict_detected",
1221 "frontier.conflict_resolved",
1222 ] {
1223 let mut state = project::assemble("test", vec![], 0, 0, "test");
1224 let snapshot_before = events::snapshot_hash(&state);
1225 let event = StateEvent {
1226 schema: events::EVENT_SCHEMA.to_string(),
1227 id: format!("vev_federation_{kind}"),
1228 kind: (*kind).to_string(),
1229 target: StateTarget {
1230 r#type: "frontier_observation".to_string(),
1231 id: "vfr_x".to_string(),
1232 },
1233 actor: StateActor {
1234 id: "federation".to_string(),
1235 r#type: "system".to_string(),
1236 },
1237 timestamp: Utc::now().to_rfc3339(),
1238 reason: format!("no-op contract test for {kind}"),
1239 before_hash: NULL_HASH.to_string(),
1240 after_hash: NULL_HASH.to_string(),
1241 payload: Value::Null,
1242 caveats: vec![],
1243 signature: None,
1244 };
1245 apply_event(&mut state, &event)
1246 .unwrap_or_else(|e| panic!("federation kind {kind} rejected by reducer: {e}"));
1247 let snapshot_after = events::snapshot_hash(&state);
1248 assert_eq!(
1249 snapshot_before, snapshot_after,
1250 "federation event {kind} mutated finding-state snapshot; expected no-op"
1251 );
1252 }
1253 }
1254
1255 fn project_with_one_atom(missing_locator: bool) -> Project {
1256 let mut state = project::assemble("test-locator", vec![finding("a")], 0, 0, "test");
1262 state.sources.push(crate::sources::SourceRecord {
1263 id: "vs_test_source".to_string(),
1264 source_type: "paper".to_string(),
1265 locator: "doi:10.1/test-source".to_string(),
1266 content_hash: None,
1267 title: "Test source".to_string(),
1268 authors: Vec::new(),
1269 year: Some(2026),
1270 doi: Some("10.1/test-source".to_string()),
1271 pmid: None,
1272 imported_at: "2026-01-01T00:00:00Z".to_string(),
1273 extraction_mode: "manual".to_string(),
1274 source_quality: "declared".to_string(),
1275 caveats: Vec::new(),
1276 finding_ids: vec![state.findings[0].id.clone()],
1277 });
1278 state.evidence_atoms.push(crate::sources::EvidenceAtom {
1279 id: "vea_test_atom".to_string(),
1280 source_id: "vs_test_source".to_string(),
1281 finding_id: state.findings[0].id.clone(),
1282 locator: if missing_locator {
1283 None
1284 } else {
1285 Some("doi:10.1/already-set".to_string())
1286 },
1287 evidence_type: "experimental".to_string(),
1288 measurement_or_claim: "test claim".to_string(),
1289 supports_or_challenges: "supports".to_string(),
1290 condition_refs: Vec::new(),
1291 extraction_method: "manual".to_string(),
1292 human_verified: false,
1293 caveats: if missing_locator {
1294 vec!["missing evidence locator".to_string()]
1295 } else {
1296 Vec::new()
1297 },
1298 });
1299 state
1300 }
1301
1302 fn atom_by_id<'a>(state: &'a Project, id: &str) -> &'a crate::sources::EvidenceAtom {
1303 state
1304 .evidence_atoms
1305 .iter()
1306 .find(|atom| atom.id == id)
1307 .expect("atom exists")
1308 }
1309
1310 #[test]
1311 fn evidence_atom_locator_repaired_sets_locator_and_clears_caveat() {
1312 let mut state = project_with_one_atom(true);
1313 assert!(state.evidence_atoms[0].locator.is_none());
1314 let event = StateEvent {
1315 schema: crate::events::EVENT_SCHEMA.to_string(),
1316 id: "vev_test".to_string(),
1317 kind: "evidence_atom.locator_repaired".to_string(),
1318 target: StateTarget {
1319 r#type: "evidence_atom".to_string(),
1320 id: "vea_test_atom".to_string(),
1321 },
1322 actor: StateActor {
1323 id: "agent:test".to_string(),
1324 r#type: "agent".to_string(),
1325 },
1326 timestamp: Utc::now().to_rfc3339(),
1327 reason: "Mechanical repair from parent source".to_string(),
1328 before_hash: NULL_HASH.to_string(),
1329 after_hash: NULL_HASH.to_string(),
1330 payload: json!({
1331 "proposal_id": "vpr_test",
1332 "source_id": "vs_test_source",
1333 "locator": "doi:10.1/test-source",
1334 }),
1335 caveats: vec![],
1336 signature: None,
1337 };
1338 apply_event(&mut state, &event).expect("apply locator_repaired");
1339 let atom = atom_by_id(&state, "vea_test_atom");
1340 assert_eq!(atom.locator.as_deref(), Some("doi:10.1/test-source"));
1341 assert!(atom.caveats.is_empty());
1342 }
1343
1344 #[test]
1345 fn evidence_atom_locator_repaired_is_idempotent() {
1346 let mut state = project_with_one_atom(true);
1347 let event = StateEvent {
1348 schema: crate::events::EVENT_SCHEMA.to_string(),
1349 id: "vev_test".to_string(),
1350 kind: "evidence_atom.locator_repaired".to_string(),
1351 target: StateTarget {
1352 r#type: "evidence_atom".to_string(),
1353 id: "vea_test_atom".to_string(),
1354 },
1355 actor: StateActor {
1356 id: "agent:test".to_string(),
1357 r#type: "agent".to_string(),
1358 },
1359 timestamp: Utc::now().to_rfc3339(),
1360 reason: "Mechanical repair from parent source".to_string(),
1361 before_hash: NULL_HASH.to_string(),
1362 after_hash: NULL_HASH.to_string(),
1363 payload: json!({
1364 "proposal_id": "vpr_test",
1365 "source_id": "vs_test_source",
1366 "locator": "doi:10.1/test-source",
1367 }),
1368 caveats: vec![],
1369 signature: None,
1370 };
1371 apply_event(&mut state, &event).expect("first apply");
1372 apply_event(&mut state, &event).expect("second apply is a no-op when locator matches");
1373 let atom = atom_by_id(&state, "vea_test_atom");
1374 assert_eq!(atom.locator.as_deref(), Some("doi:10.1/test-source"));
1375 }
1376
1377 #[test]
1378 fn evidence_atom_locator_repaired_refuses_divergent_overwrite() {
1379 let mut state = project_with_one_atom(false);
1380 let event = StateEvent {
1381 schema: crate::events::EVENT_SCHEMA.to_string(),
1382 id: "vev_test".to_string(),
1383 kind: "evidence_atom.locator_repaired".to_string(),
1384 target: StateTarget {
1385 r#type: "evidence_atom".to_string(),
1386 id: "vea_test_atom".to_string(),
1387 },
1388 actor: StateActor {
1389 id: "agent:test".to_string(),
1390 r#type: "agent".to_string(),
1391 },
1392 timestamp: Utc::now().to_rfc3339(),
1393 reason: "Different repair".to_string(),
1394 before_hash: NULL_HASH.to_string(),
1395 after_hash: NULL_HASH.to_string(),
1396 payload: json!({
1397 "proposal_id": "vpr_test",
1398 "source_id": "vs_test_source",
1399 "locator": "doi:10.1/different",
1400 }),
1401 caveats: vec![],
1402 signature: None,
1403 };
1404 let r = apply_event(&mut state, &event);
1405 assert!(r.is_err());
1406 assert!(r.unwrap_err().contains("already has locator"));
1407 }
1408
1409 #[test]
1410 fn evidence_atom_locator_repaired_does_not_mutate_findings() {
1411 let mut state = project_with_one_atom(true);
1413 let hashes_before: Vec<String> = state
1414 .findings
1415 .iter()
1416 .map(crate::events::finding_hash)
1417 .collect();
1418 let event = StateEvent {
1419 schema: crate::events::EVENT_SCHEMA.to_string(),
1420 id: "vev_test".to_string(),
1421 kind: "evidence_atom.locator_repaired".to_string(),
1422 target: StateTarget {
1423 r#type: "evidence_atom".to_string(),
1424 id: "vea_test_atom".to_string(),
1425 },
1426 actor: StateActor {
1427 id: "agent:test".to_string(),
1428 r#type: "agent".to_string(),
1429 },
1430 timestamp: Utc::now().to_rfc3339(),
1431 reason: "Mechanical repair".to_string(),
1432 before_hash: NULL_HASH.to_string(),
1433 after_hash: NULL_HASH.to_string(),
1434 payload: json!({
1435 "proposal_id": "vpr_test",
1436 "source_id": "vs_test_source",
1437 "locator": "doi:10.1/test-source",
1438 }),
1439 caveats: vec![],
1440 signature: None,
1441 };
1442 apply_event(&mut state, &event).expect("apply ok");
1443 let hashes_after: Vec<String> = state
1444 .findings
1445 .iter()
1446 .map(crate::events::finding_hash)
1447 .collect();
1448 assert_eq!(hashes_before, hashes_after);
1449 }
1450}