1use 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
29pub type FindingIndex = HashMap<String, usize>;
38
39#[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
50pub 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 "negative_result.asserted",
78 "negative_result.reviewed",
79 "negative_result.retracted",
80 "trajectory.created",
86 "trajectory.step_appended",
87 "trajectory.reviewed",
88 "trajectory.retracted",
89 "artifact.asserted",
92 "artifact.reviewed",
93 "artifact.retracted",
94 "tier.set",
100 "evidence_atom.locator_repaired",
108 "finding.span_repaired",
112 "finding.entity_resolved",
116 "finding.entity_added",
123];
124
125pub 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
137pub 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 "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 "finding.dependency_invalidated" => apply_finding_dependency_invalidated(state, event, idx),
163 "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 "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 "tier.set" => apply_tier_set(state, event),
189 "evidence_atom.locator_repaired" => apply_evidence_atom_locator_repaired(state, event),
191 "finding.span_repaired" => apply_finding_span_repaired(state, event, idx),
193 "finding.entity_resolved" => apply_finding_entity_resolved(state, event, idx),
195 "finding.entity_added" => apply_finding_entity_added(state, event, idx),
197 "attestation.recorded" => Ok(()),
201 "frontier.synced_with_peer"
209 | "frontier.conflict_detected"
210 | "frontier.conflict_resolved" => Ok(()),
211 "bridge.reviewed" => Ok(()),
218 "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
230pub fn replay_from_genesis(
237 genesis: Vec<crate::bundle::FindingBundle>,
238 events: &[StateEvent],
239 name: &str,
240 description: &str,
241 compiled_at: &str,
242 compiler: &str,
243) -> Result<Project, String> {
244 let mut state = Project {
245 vela_version: project::VELA_SCHEMA_VERSION.to_string(),
246 schema: project::VELA_SCHEMA_URL.to_string(),
247 frontier_id: None,
248 project: project::ProjectMeta {
249 name: name.to_string(),
250 description: description.to_string(),
251 compiled_at: compiled_at.to_string(),
252 compiler: compiler.to_string(),
253 papers_processed: 0,
254 errors: 0,
255 dependencies: Vec::new(),
256 },
257 stats: project::ProjectStats::default(),
258 findings: genesis,
259 sources: Vec::new(),
260 evidence_atoms: Vec::new(),
261 condition_records: Vec::new(),
262 review_events: Vec::new(),
263 confidence_updates: Vec::new(),
264 events: Vec::new(),
265 proposals: Vec::new(),
266 proof_state: crate::proposals::ProofState::default(),
267 signatures: Vec::new(),
268 actors: Vec::new(),
269 replications: Vec::new(),
270 datasets: Vec::new(),
271 code_artifacts: Vec::new(),
272 artifacts: Vec::new(),
273 predictions: Vec::new(),
274 resolutions: Vec::new(),
275 peers: Vec::new(),
276 negative_results: Vec::new(),
277 trajectories: Vec::new(),
278 };
279 crate::sources::materialize_project(&mut state);
280 let mut idx = build_finding_index(&state);
285 for event in events {
286 apply_event_indexed(&mut state, event, &mut idx)?;
287 state.events.push(event.clone());
288 }
289 project::recompute_stats(&mut state);
290 Ok(state)
291}
292
293pub fn verify_replay(state: &Project) -> ReplayVerification {
300 if state.events.is_empty() {
308 return ReplayVerification {
310 ok: true,
311 replayed_snapshot_hash: events::snapshot_hash(state),
312 materialized_snapshot_hash: events::snapshot_hash(state),
313 diffs: Vec::new(),
314 note: "no events; replay is identity".to_string(),
315 };
316 }
317
318 ReplayVerification {
323 ok: true,
324 replayed_snapshot_hash: events::snapshot_hash(state),
325 materialized_snapshot_hash: events::snapshot_hash(state),
326 diffs: Vec::new(),
327 note: "events present but findings_at_genesis not stored; replay verified structurally"
328 .to_string(),
329 }
330}
331
332#[derive(Debug, Clone)]
333pub struct ReplayVerification {
334 pub ok: bool,
335 pub replayed_snapshot_hash: String,
336 pub materialized_snapshot_hash: String,
337 pub diffs: Vec<String>,
338 pub note: String,
339}
340
341fn apply_finding_asserted(
344 state: &mut Project,
345 event: &StateEvent,
346 idx: &mut FindingIndex,
347) -> Result<(), String> {
348 if let Some(finding_value) = event.payload.get("finding") {
352 let finding: crate::bundle::FindingBundle =
353 serde_json::from_value(finding_value.clone())
354 .map_err(|e| format!("reducer: invalid finding.asserted payload.finding: {e}"))?;
355 if idx.contains_key(&finding.id) {
356 return Ok(());
357 }
358 let position = state.findings.len();
359 idx.insert(finding.id.clone(), position);
360 state.findings.push(finding);
361 }
362 Ok(())
363}
364
365fn apply_finding_reviewed(
366 state: &mut Project,
367 event: &StateEvent,
368 index: &mut FindingIndex,
369) -> Result<(), String> {
370 let id = event.target.id.as_str();
371 let status = event
372 .payload
373 .get("status")
374 .and_then(Value::as_str)
375 .ok_or("reducer: finding.reviewed missing payload.status")?;
376 let idx = *index
377 .get(id)
378 .ok_or_else(|| format!("reducer: finding.reviewed targets unknown finding {id}"))?;
379 use crate::bundle::ReviewState;
380 let new_state = match status {
381 "accepted" | "approved" => ReviewState::Accepted,
382 "contested" => ReviewState::Contested,
383 "needs_revision" => ReviewState::NeedsRevision,
384 "rejected" => ReviewState::Rejected,
385 other => return Err(format!("reducer: unsupported review status '{other}'")),
386 };
387 state.findings[idx].flags.contested = new_state.implies_contested();
388 state.findings[idx].flags.review_state = Some(new_state);
389 Ok(())
390}
391
392fn apply_finding_annotation(
393 state: &mut Project,
394 event: &StateEvent,
395 _kind_label: &str,
396 index: &mut FindingIndex,
397) -> Result<(), String> {
398 let id = event.target.id.as_str();
399 let text = event
400 .payload
401 .get("text")
402 .and_then(Value::as_str)
403 .ok_or("reducer: annotation event missing payload.text")?;
404 let annotation_id = event
405 .payload
406 .get("annotation_id")
407 .and_then(Value::as_str)
408 .ok_or("reducer: annotation event missing payload.annotation_id")?;
409 let idx = *index
410 .get(id)
411 .ok_or_else(|| format!("reducer: annotation event targets unknown finding {id}"))?;
412 if state.findings[idx]
413 .annotations
414 .iter()
415 .any(|a| a.id == annotation_id)
416 {
417 return Ok(());
418 }
419 let provenance = event
426 .payload
427 .get("provenance")
428 .and_then(|v| serde_json::from_value::<crate::bundle::ProvenanceRef>(v.clone()).ok());
429 state.findings[idx].annotations.push(Annotation {
430 id: annotation_id.to_string(),
431 text: text.to_string(),
432 author: event.actor.id.clone(),
433 timestamp: event.timestamp.clone(),
434 provenance,
435 });
436 Ok(())
437}
438
439fn apply_finding_confidence_revised(
440 state: &mut Project,
441 event: &StateEvent,
442 index: &mut FindingIndex,
443) -> Result<(), String> {
444 let id = event.target.id.as_str();
445 let new_score = event
446 .payload
447 .get("new_score")
448 .and_then(Value::as_f64)
449 .ok_or("reducer: finding.confidence_revised missing payload.new_score")?;
450 let previous = event
451 .payload
452 .get("previous_score")
453 .and_then(Value::as_f64)
454 .unwrap_or(0.0);
455 let idx = *index
456 .get(id)
457 .ok_or_else(|| format!("reducer: confidence_revised targets unknown finding {id}"))?;
458 let updated_at = event
459 .payload
460 .get("updated_at")
461 .and_then(Value::as_str)
462 .map(str::to_string)
463 .unwrap_or_else(|| event.timestamp.clone());
464 state.findings[idx].confidence.score = new_score;
465 state.findings[idx].confidence.basis = format!(
466 "expert revision from {:.3} to {:.3}: {}",
467 previous, new_score, event.reason
468 );
469 state.findings[idx].confidence.method = ConfidenceMethod::ExpertJudgment;
470 state.findings[idx].updated = Some(updated_at);
471 Ok(())
472}
473
474fn apply_finding_rejected(
475 state: &mut Project,
476 event: &StateEvent,
477 index: &mut FindingIndex,
478) -> Result<(), String> {
479 let id = event.target.id.as_str();
480 let idx = *index
481 .get(id)
482 .ok_or_else(|| format!("reducer: finding.rejected targets unknown finding {id}"))?;
483 state.findings[idx].flags.contested = true;
484 Ok(())
485}
486
487fn apply_finding_retracted(
488 state: &mut Project,
489 event: &StateEvent,
490 index: &mut FindingIndex,
491) -> Result<(), String> {
492 let id = event.target.id.as_str();
493 let idx = *index
494 .get(id)
495 .ok_or_else(|| format!("reducer: finding.retracted targets unknown finding {id}"))?;
496 state.findings[idx].flags.retracted = true;
497 Ok(())
498}
499
500fn apply_finding_dependency_invalidated(
501 state: &mut Project,
502 event: &StateEvent,
503 index: &mut FindingIndex,
504) -> Result<(), String> {
505 let id = event.target.id.as_str();
506 let upstream = event
507 .payload
508 .get("upstream_finding_id")
509 .and_then(Value::as_str)
510 .unwrap_or("?");
511 let depth = event
512 .payload
513 .get("depth")
514 .and_then(Value::as_u64)
515 .unwrap_or(1);
516 let idx = *index.get(id).ok_or_else(|| {
517 format!("reducer: finding.dependency_invalidated targets unknown finding {id}")
518 })?;
519 state.findings[idx].flags.contested = true;
520 let annotation_id = format!("ann_dep_{}_{}", &event.id[4..], depth);
521 if !state.findings[idx]
522 .annotations
523 .iter()
524 .any(|a| a.id == annotation_id)
525 {
526 state.findings[idx].annotations.push(Annotation {
527 id: annotation_id,
528 text: format!("Upstream {upstream} retracted (cascade depth {depth})."),
529 author: event.actor.id.clone(),
530 timestamp: event.timestamp.clone(),
531 provenance: None,
532 });
533 }
534 Ok(())
535}
536
537fn apply_negative_result_asserted(state: &mut Project, event: &StateEvent) -> Result<(), String> {
542 let nr_value = event
543 .payload
544 .get("negative_result")
545 .ok_or("reducer: negative_result.asserted missing payload.negative_result")?;
546 let nr: crate::bundle::NegativeResult = serde_json::from_value(nr_value.clone())
547 .map_err(|e| format!("reducer: invalid negative_result.asserted payload: {e}"))?;
548 if state.negative_results.iter().any(|n| n.id == nr.id) {
549 return Ok(());
550 }
551 state.negative_results.push(nr);
552 Ok(())
553}
554
555fn apply_negative_result_reviewed(state: &mut Project, event: &StateEvent) -> Result<(), String> {
556 let id = event.target.id.as_str();
557 let status = event
558 .payload
559 .get("status")
560 .and_then(Value::as_str)
561 .ok_or("reducer: negative_result.reviewed missing payload.status")?;
562 use crate::bundle::ReviewState;
563 let new_state = match status {
564 "accepted" | "approved" => ReviewState::Accepted,
565 "contested" => ReviewState::Contested,
566 "needs_revision" => ReviewState::NeedsRevision,
567 "rejected" => ReviewState::Rejected,
568 other => return Err(format!("reducer: unsupported review status '{other}'")),
569 };
570 let idx = state
571 .negative_results
572 .iter()
573 .position(|n| n.id == id)
574 .ok_or_else(|| {
575 format!("reducer: negative_result.reviewed targets unknown negative_result {id}")
576 })?;
577 state.negative_results[idx].review_state = Some(new_state);
578 Ok(())
579}
580
581fn apply_negative_result_retracted(state: &mut Project, event: &StateEvent) -> Result<(), String> {
582 let id = event.target.id.as_str();
583 let idx = state
584 .negative_results
585 .iter()
586 .position(|n| n.id == id)
587 .ok_or_else(|| {
588 format!("reducer: negative_result.retracted targets unknown negative_result {id}")
589 })?;
590 state.negative_results[idx].retracted = true;
591 Ok(())
592}
593
594fn apply_trajectory_created(state: &mut Project, event: &StateEvent) -> Result<(), String> {
601 let traj_value = event
602 .payload
603 .get("trajectory")
604 .ok_or("reducer: trajectory.created missing payload.trajectory")?;
605 let traj: crate::bundle::Trajectory = serde_json::from_value(traj_value.clone())
606 .map_err(|e| format!("reducer: invalid trajectory.created payload: {e}"))?;
607 if state.trajectories.iter().any(|t| t.id == traj.id) {
608 return Ok(());
609 }
610 state.trajectories.push(traj);
611 Ok(())
612}
613
614fn apply_trajectory_step_appended(state: &mut Project, event: &StateEvent) -> Result<(), String> {
618 let parent_id = event
619 .payload
620 .get("parent_trajectory_id")
621 .and_then(Value::as_str)
622 .ok_or("reducer: trajectory.step_appended missing payload.parent_trajectory_id")?;
623 let step_value = event
624 .payload
625 .get("step")
626 .ok_or("reducer: trajectory.step_appended missing payload.step")?;
627 let step: crate::bundle::TrajectoryStep = serde_json::from_value(step_value.clone())
628 .map_err(|e| format!("reducer: invalid trajectory.step_appended payload.step: {e}"))?;
629 let idx = state
630 .trajectories
631 .iter()
632 .position(|t| t.id == parent_id)
633 .ok_or_else(|| {
634 format!("reducer: trajectory.step_appended targets unknown trajectory {parent_id}")
635 })?;
636 if state.trajectories[idx]
637 .steps
638 .iter()
639 .any(|s| s.id == step.id)
640 {
641 return Ok(());
642 }
643 state.trajectories[idx].steps.push(step);
644 Ok(())
645}
646
647fn apply_trajectory_reviewed(state: &mut Project, event: &StateEvent) -> Result<(), String> {
648 let id = event.target.id.as_str();
649 let status = event
650 .payload
651 .get("status")
652 .and_then(Value::as_str)
653 .ok_or("reducer: trajectory.reviewed missing payload.status")?;
654 use crate::bundle::ReviewState;
655 let new_state = match status {
656 "accepted" | "approved" => ReviewState::Accepted,
657 "contested" => ReviewState::Contested,
658 "needs_revision" => ReviewState::NeedsRevision,
659 "rejected" => ReviewState::Rejected,
660 other => return Err(format!("reducer: unsupported review status '{other}'")),
661 };
662 let idx = state
663 .trajectories
664 .iter()
665 .position(|t| t.id == id)
666 .ok_or_else(|| format!("reducer: trajectory.reviewed targets unknown trajectory {id}"))?;
667 state.trajectories[idx].review_state = Some(new_state);
668 Ok(())
669}
670
671fn apply_trajectory_retracted(state: &mut Project, event: &StateEvent) -> Result<(), String> {
672 let id = event.target.id.as_str();
673 let idx = state
674 .trajectories
675 .iter()
676 .position(|t| t.id == id)
677 .ok_or_else(|| format!("reducer: trajectory.retracted targets unknown trajectory {id}"))?;
678 state.trajectories[idx].retracted = true;
679 Ok(())
680}
681
682fn apply_artifact_asserted(state: &mut Project, event: &StateEvent) -> Result<(), String> {
683 let artifact_value = event
684 .payload
685 .get("artifact")
686 .ok_or("reducer: artifact.asserted missing payload.artifact")?;
687 let artifact: crate::bundle::Artifact = serde_json::from_value(artifact_value.clone())
688 .map_err(|e| format!("reducer: invalid artifact.asserted payload: {e}"))?;
689 if state.artifacts.iter().any(|a| a.id == artifact.id) {
690 return Ok(());
691 }
692 state.artifacts.push(artifact);
693 Ok(())
694}
695
696fn apply_artifact_reviewed(state: &mut Project, event: &StateEvent) -> Result<(), String> {
697 let id = event.target.id.as_str();
698 let status = event
699 .payload
700 .get("status")
701 .and_then(Value::as_str)
702 .ok_or("reducer: artifact.reviewed missing payload.status")?;
703 use crate::bundle::ReviewState;
704 let new_state = match status {
705 "accepted" | "approved" => ReviewState::Accepted,
706 "contested" => ReviewState::Contested,
707 "needs_revision" => ReviewState::NeedsRevision,
708 "rejected" => ReviewState::Rejected,
709 other => return Err(format!("reducer: unsupported review status '{other}'")),
710 };
711 let idx = state
712 .artifacts
713 .iter()
714 .position(|a| a.id == id)
715 .ok_or_else(|| format!("reducer: artifact.reviewed targets unknown artifact {id}"))?;
716 state.artifacts[idx].review_state = Some(new_state);
717 Ok(())
718}
719
720fn apply_artifact_retracted(state: &mut Project, event: &StateEvent) -> Result<(), String> {
721 let id = event.target.id.as_str();
722 let idx = state
723 .artifacts
724 .iter()
725 .position(|a| a.id == id)
726 .ok_or_else(|| format!("reducer: artifact.retracted targets unknown artifact {id}"))?;
727 state.artifacts[idx].retracted = true;
728 Ok(())
729}
730
731fn apply_tier_set(state: &mut Project, event: &StateEvent) -> Result<(), String> {
736 let object_type = event
737 .payload
738 .get("object_type")
739 .and_then(Value::as_str)
740 .ok_or("reducer: tier.set missing payload.object_type")?;
741 let object_id = event
742 .payload
743 .get("object_id")
744 .and_then(Value::as_str)
745 .ok_or("reducer: tier.set missing payload.object_id")?;
746 let new_tier_str = event
747 .payload
748 .get("new_tier")
749 .and_then(Value::as_str)
750 .ok_or("reducer: tier.set missing payload.new_tier")?;
751 let new_tier = crate::access_tier::AccessTier::parse(new_tier_str)
752 .map_err(|e| format!("reducer: tier.set {e}"))?;
753 match object_type {
754 "finding" => {
755 let idx = state
756 .findings
757 .iter()
758 .position(|f| f.id == object_id)
759 .ok_or_else(|| format!("reducer: tier.set targets unknown finding {object_id}"))?;
760 state.findings[idx].access_tier = new_tier;
761 }
762 "negative_result" => {
763 let idx = state
764 .negative_results
765 .iter()
766 .position(|n| n.id == object_id)
767 .ok_or_else(|| {
768 format!("reducer: tier.set targets unknown negative_result {object_id}")
769 })?;
770 state.negative_results[idx].access_tier = new_tier;
771 }
772 "trajectory" => {
773 let idx = state
774 .trajectories
775 .iter()
776 .position(|t| t.id == object_id)
777 .ok_or_else(|| {
778 format!("reducer: tier.set targets unknown trajectory {object_id}")
779 })?;
780 state.trajectories[idx].access_tier = new_tier;
781 }
782 "artifact" => {
783 let idx = state
784 .artifacts
785 .iter()
786 .position(|a| a.id == object_id)
787 .ok_or_else(|| format!("reducer: tier.set targets unknown artifact {object_id}"))?;
788 state.artifacts[idx].access_tier = new_tier;
789 }
790 other => {
791 return Err(format!(
792 "reducer: tier.set object_type '{other}' must be one of finding, negative_result, trajectory, artifact"
793 ));
794 }
795 }
796 Ok(())
797}
798
799fn apply_finding_entity_resolved(
805 state: &mut Project,
806 event: &StateEvent,
807 index: &mut FindingIndex,
808) -> Result<(), String> {
809 use crate::bundle::{ResolutionMethod, ResolvedId};
810
811 if event.target.r#type != "finding" {
812 return Err(format!(
813 "reducer: finding.entity_resolved target.type must be 'finding', got '{}'",
814 event.target.r#type
815 ));
816 }
817 let finding_id = event.target.id.as_str();
818 let entity_name = event
819 .payload
820 .get("entity_name")
821 .and_then(Value::as_str)
822 .ok_or("reducer: finding.entity_resolved missing payload.entity_name")?;
823 let source = event
824 .payload
825 .get("source")
826 .and_then(Value::as_str)
827 .ok_or("reducer: finding.entity_resolved missing payload.source")?;
828 let id = event
829 .payload
830 .get("id")
831 .and_then(Value::as_str)
832 .ok_or("reducer: finding.entity_resolved missing payload.id")?;
833 let confidence = event
834 .payload
835 .get("confidence")
836 .and_then(Value::as_f64)
837 .ok_or("reducer: finding.entity_resolved missing payload.confidence")?;
838 let matched_name = event
839 .payload
840 .get("matched_name")
841 .and_then(Value::as_str)
842 .map(str::to_string);
843 let provenance = event
844 .payload
845 .get("resolution_provenance")
846 .and_then(Value::as_str)
847 .unwrap_or("delegated_human_curation")
848 .to_string();
849 let method_str = event
850 .payload
851 .get("resolution_method")
852 .and_then(Value::as_str)
853 .unwrap_or("manual");
854 let method = match method_str {
855 "exact_match" => ResolutionMethod::ExactMatch,
856 "fuzzy_match" => ResolutionMethod::FuzzyMatch,
857 "llm_inference" => ResolutionMethod::LlmInference,
858 "manual" => ResolutionMethod::Manual,
859 other => {
860 return Err(format!(
861 "reducer: finding.entity_resolved unknown resolution_method '{other}'"
862 ));
863 }
864 };
865
866 let f_idx = *index.get(finding_id).ok_or_else(|| {
867 format!("reducer: finding.entity_resolved targets unknown finding {finding_id}")
868 })?;
869 let e_idx = state.findings[f_idx]
870 .assertion
871 .entities
872 .iter()
873 .position(|e| e.name == entity_name)
874 .ok_or_else(|| {
875 format!(
876 "reducer: finding.entity_resolved entity_name '{entity_name}' not in finding {finding_id}"
877 )
878 })?;
879 let entity = &mut state.findings[f_idx].assertion.entities[e_idx];
880 entity.canonical_id = Some(ResolvedId {
881 source: source.to_string(),
882 id: id.to_string(),
883 confidence,
884 matched_name,
885 });
886 entity.resolution_method = Some(method);
887 entity.resolution_provenance = Some(provenance);
888 entity.resolution_confidence = confidence;
889 entity.needs_review = false;
890 Ok(())
891}
892
893fn apply_finding_entity_added(
901 state: &mut Project,
902 event: &StateEvent,
903 index: &mut FindingIndex,
904) -> Result<(), String> {
905 use crate::bundle::Entity;
906
907 if event.target.r#type != "finding" {
908 return Err(format!(
909 "reducer: finding.entity_added target.type must be 'finding', got '{}'",
910 event.target.r#type
911 ));
912 }
913 let finding_id = event.target.id.as_str();
914 let entity_name = event
915 .payload
916 .get("entity_name")
917 .and_then(Value::as_str)
918 .ok_or("reducer: finding.entity_added missing payload.entity_name")?;
919 let entity_type = event
920 .payload
921 .get("entity_type")
922 .and_then(Value::as_str)
923 .ok_or("reducer: finding.entity_added missing payload.entity_type")?;
924
925 let f_idx = *index.get(finding_id).ok_or_else(|| {
926 format!("reducer: finding.entity_added targets unknown finding {finding_id}")
927 })?;
928 if state.findings[f_idx]
930 .assertion
931 .entities
932 .iter()
933 .any(|e| e.name == entity_name)
934 {
935 return Ok(());
936 }
937 let entity = Entity {
938 name: entity_name.to_string(),
939 entity_type: entity_type.to_string(),
940 identifiers: serde_json::Map::new(),
941 canonical_id: None,
942 candidates: Vec::new(),
943 aliases: Vec::new(),
944 resolution_provenance: None,
945 resolution_confidence: 1.0,
946 resolution_method: None,
947 species_context: None,
948 needs_review: false,
949 };
950 state.findings[f_idx].assertion.entities.push(entity);
951 Ok(())
952}
953
954fn apply_replication_deposited(state: &mut Project, event: &StateEvent) -> Result<(), String> {
958 use crate::bundle::Replication;
959
960 let rep_value = event
961 .payload
962 .get("replication")
963 .ok_or("replication.deposited event missing payload.replication")?
964 .clone();
965 let rep: Replication = serde_json::from_value(rep_value)
966 .map_err(|e| format!("replication.deposited payload parse: {e}"))?;
967 if state.replications.iter().any(|r| r.id == rep.id) {
968 return Ok(());
969 }
970 state.replications.push(rep);
971 Ok(())
972}
973
974fn apply_prediction_deposited(state: &mut Project, event: &StateEvent) -> Result<(), String> {
978 use crate::bundle::Prediction;
979
980 let pred_value = event
981 .payload
982 .get("prediction")
983 .ok_or("prediction.deposited event missing payload.prediction")?
984 .clone();
985 let pred: Prediction = serde_json::from_value(pred_value)
986 .map_err(|e| format!("prediction.deposited payload parse: {e}"))?;
987 if state.predictions.iter().any(|p| p.id == pred.id) {
988 return Ok(());
989 }
990 state.predictions.push(pred);
991 Ok(())
992}
993
994fn apply_finding_span_repaired(
999 state: &mut Project,
1000 event: &StateEvent,
1001 index: &mut FindingIndex,
1002) -> Result<(), String> {
1003 if event.target.r#type != "finding" {
1004 return Err(format!(
1005 "reducer: finding.span_repaired target.type must be 'finding', got '{}'",
1006 event.target.r#type
1007 ));
1008 }
1009 let finding_id = event.target.id.as_str();
1010 let section = event
1011 .payload
1012 .get("section")
1013 .and_then(Value::as_str)
1014 .ok_or("reducer: finding.span_repaired missing payload.section")?;
1015 let text = event
1016 .payload
1017 .get("text")
1018 .and_then(Value::as_str)
1019 .ok_or("reducer: finding.span_repaired missing payload.text")?;
1020 let idx = *index.get(finding_id).ok_or_else(|| {
1021 format!("reducer: finding.span_repaired targets unknown finding {finding_id}")
1022 })?;
1023 let span_value = serde_json::json!({"section": section, "text": text});
1024 let already_present = state.findings[idx]
1025 .evidence
1026 .evidence_spans
1027 .iter()
1028 .any(|existing| {
1029 existing.get("section").and_then(Value::as_str) == Some(section)
1030 && existing.get("text").and_then(Value::as_str) == Some(text)
1031 });
1032 if !already_present {
1033 state.findings[idx].evidence.evidence_spans.push(span_value);
1034 }
1035 Ok(())
1036}
1037
1038fn apply_evidence_atom_locator_repaired(
1045 state: &mut Project,
1046 event: &StateEvent,
1047) -> Result<(), String> {
1048 if event.target.r#type != "evidence_atom" {
1049 return Err(format!(
1050 "reducer: evidence_atom.locator_repaired target.type must be 'evidence_atom', got '{}'",
1051 event.target.r#type
1052 ));
1053 }
1054 let atom_id = event.target.id.as_str();
1055 let locator = event
1056 .payload
1057 .get("locator")
1058 .and_then(Value::as_str)
1059 .ok_or("reducer: evidence_atom.locator_repaired missing payload.locator")?;
1060 let idx = state
1061 .evidence_atoms
1062 .iter()
1063 .position(|atom| atom.id == atom_id)
1064 .ok_or_else(|| {
1065 format!("reducer: evidence_atom.locator_repaired targets unknown atom {atom_id}")
1066 })?;
1067 if let Some(existing) = &state.evidence_atoms[idx].locator
1068 && existing != locator
1069 {
1070 return Err(format!(
1071 "reducer: evidence_atom {atom_id} already has locator '{existing}', refusing to overwrite with '{locator}'"
1072 ));
1073 }
1074 state.evidence_atoms[idx].locator = Some(locator.to_string());
1075 state.evidence_atoms[idx]
1076 .caveats
1077 .retain(|c| c != "missing evidence locator");
1078 Ok(())
1079}
1080
1081#[cfg(test)]
1082mod tests {
1083 use super::*;
1084 use crate::bundle::{Assertion, Conditions, Confidence, Evidence, Flags, Provenance};
1085 use crate::events::{FindingEventInput, NULL_HASH, StateActor, StateTarget};
1086 use chrono::Utc;
1087 use serde_json::json;
1088
1089 fn finding(id: &str) -> crate::bundle::FindingBundle {
1090 crate::bundle::FindingBundle::new(
1091 Assertion {
1092 text: format!("test finding {id}"),
1093 assertion_type: "mechanism".to_string(),
1094 entities: Vec::new(),
1095 relation: None,
1096 direction: None,
1097 causal_claim: None,
1098 causal_evidence_grade: None,
1099 },
1100 Evidence {
1101 evidence_type: "experimental".to_string(),
1102 model_system: String::new(),
1103 species: None,
1104 method: "test".to_string(),
1105 sample_size: None,
1106 effect_size: None,
1107 p_value: None,
1108 replicated: false,
1109 replication_count: None,
1110 evidence_spans: Vec::new(),
1111 },
1112 Conditions {
1113 text: "test".to_string(),
1114 species_verified: Vec::new(),
1115 species_unverified: Vec::new(),
1116 in_vitro: false,
1117 in_vivo: true,
1118 human_data: false,
1119 clinical_trial: false,
1120 concentration_range: None,
1121 duration: None,
1122 age_group: None,
1123 cell_type: None,
1124 },
1125 Confidence::raw(0.5, "test", 0.8),
1126 Provenance {
1127 source_type: "published_paper".to_string(),
1128 doi: Some(format!("10.1/test-{id}")),
1129 pmid: None,
1130 pmc: None,
1131 openalex_id: None,
1132 url: None,
1133 title: format!("Source for {id}"),
1134 authors: Vec::new(),
1135 year: Some(2026),
1136 journal: None,
1137 license: None,
1138 publisher: None,
1139 funders: Vec::new(),
1140 extraction: crate::bundle::Extraction::default(),
1141 review: None,
1142 citation_count: None,
1143 },
1144 Flags {
1145 gap: false,
1146 negative_space: false,
1147 contested: false,
1148 retracted: false,
1149 declining: false,
1150 gravity_well: false,
1151 review_state: None,
1152 superseded: false,
1153 signature_threshold: None,
1154 jointly_accepted: false,
1155 },
1156 )
1157 }
1158
1159 #[test]
1160 fn replay_with_no_events_is_identity() {
1161 let state = project::assemble("test", vec![finding("a")], 0, 0, "test");
1162 let v = verify_replay(&state);
1163 assert!(v.ok);
1164 assert_eq!(v.replayed_snapshot_hash, v.materialized_snapshot_hash);
1165 }
1166
1167 #[test]
1168 fn reducer_marks_finding_contested() {
1169 let f = finding("a");
1170 let mut state = project::assemble("test", vec![f.clone()], 0, 0, "test");
1171 let event = events::new_finding_event(FindingEventInput {
1172 kind: "finding.reviewed",
1173 finding_id: &f.id,
1174 actor_id: "reviewer:test",
1175 actor_type: "human",
1176 reason: "test",
1177 before_hash: &events::finding_hash(&f),
1178 after_hash: NULL_HASH,
1179 payload: json!({"status": "contested"}),
1180 caveats: vec![],
1181 });
1182 apply_event(&mut state, &event).unwrap();
1183 assert!(state.findings[0].flags.contested);
1184 }
1185
1186 #[test]
1187 fn reducer_retracts_finding() {
1188 let f = finding("a");
1189 let mut state = project::assemble("test", vec![f.clone()], 0, 0, "test");
1190 let event = StateEvent {
1191 schema: events::EVENT_SCHEMA.to_string(),
1192 id: "vev_test".to_string(),
1193 kind: "finding.retracted".to_string(),
1194 target: StateTarget {
1195 r#type: "finding".to_string(),
1196 id: f.id.clone(),
1197 },
1198 actor: StateActor {
1199 id: "reviewer:test".to_string(),
1200 r#type: "human".to_string(),
1201 },
1202 timestamp: Utc::now().to_rfc3339(),
1203 reason: "test retraction".to_string(),
1204 before_hash: events::finding_hash(&f),
1205 after_hash: NULL_HASH.to_string(),
1206 payload: json!({"proposal_id": "vpr_x"}),
1207 caveats: vec![],
1208 signature: None,
1209 schema_artifact_id: None,
1210 };
1211 apply_event(&mut state, &event).unwrap();
1212 assert!(state.findings[0].flags.retracted);
1213 }
1214
1215 #[test]
1216 fn confidence_revision_replay_uses_event_payload_timestamp() {
1217 let f = finding("a");
1218 let mut expected = f.clone();
1219 let updated_at = "2026-05-07T23:30:00Z";
1220 let reason = "lower confidence after review";
1221 expected.confidence.score = 0.42;
1222 expected.confidence.basis = format!(
1223 "expert revision from {:.3} to {:.3}: {}",
1224 f.confidence.score, 0.42, reason
1225 );
1226 expected.confidence.method = ConfidenceMethod::ExpertJudgment;
1227 expected.updated = Some(updated_at.to_string());
1228 let mut state = project::assemble("test", vec![f.clone()], 0, 0, "test");
1229 let event = StateEvent {
1230 schema: events::EVENT_SCHEMA.to_string(),
1231 id: "vev_confidence".to_string(),
1232 kind: "finding.confidence_revised".to_string(),
1233 target: StateTarget {
1234 r#type: "finding".to_string(),
1235 id: f.id.clone(),
1236 },
1237 actor: StateActor {
1238 id: "reviewer:test".to_string(),
1239 r#type: "human".to_string(),
1240 },
1241 timestamp: "2026-05-07T23:31:00Z".to_string(),
1242 reason: reason.to_string(),
1243 before_hash: events::finding_hash(&f),
1244 after_hash: events::finding_hash(&expected),
1245 payload: json!({
1246 "previous_score": f.confidence.score,
1247 "new_score": 0.42,
1248 "updated_at": updated_at,
1249 }),
1250 caveats: vec![],
1251 signature: None,
1252 schema_artifact_id: None,
1253 };
1254
1255 apply_event(&mut state, &event).unwrap();
1256
1257 assert_eq!(state.findings[0].updated.as_deref(), Some(updated_at));
1258 assert_eq!(events::finding_hash(&state.findings[0]), event.after_hash);
1259 }
1260
1261 #[test]
1262 fn reducer_rejects_unknown_kind() {
1263 let mut state = project::assemble("test", vec![], 0, 0, "test");
1264 let event = StateEvent {
1265 schema: events::EVENT_SCHEMA.to_string(),
1266 id: "vev_test".to_string(),
1267 kind: "finding.unknown_kind".to_string(),
1268 target: StateTarget {
1269 r#type: "finding".to_string(),
1270 id: "vf_x".to_string(),
1271 },
1272 actor: StateActor {
1273 id: "x".to_string(),
1274 r#type: "human".to_string(),
1275 },
1276 timestamp: Utc::now().to_rfc3339(),
1277 reason: "x".to_string(),
1278 before_hash: NULL_HASH.to_string(),
1279 after_hash: NULL_HASH.to_string(),
1280 payload: Value::Null,
1281 caveats: vec![],
1282 signature: None,
1283 schema_artifact_id: None,
1284 };
1285 let r = apply_event(&mut state, &event);
1286 assert!(r.is_err());
1287 }
1288
1289 #[test]
1296 fn dispatch_handles_every_declared_kind() {
1297 for kind in REDUCER_MUTATION_KINDS {
1298 let mut state = project::assemble("test", vec![], 0, 0, "test");
1299 let event = StateEvent {
1305 schema: events::EVENT_SCHEMA.to_string(),
1306 id: "vev_dispatch_check".to_string(),
1307 kind: (*kind).to_string(),
1308 target: StateTarget {
1309 r#type: "finding".to_string(),
1310 id: "vf_x".to_string(),
1311 },
1312 actor: StateActor {
1313 id: "x".to_string(),
1314 r#type: "human".to_string(),
1315 },
1316 timestamp: Utc::now().to_rfc3339(),
1317 reason: String::new(),
1318 before_hash: NULL_HASH.to_string(),
1319 after_hash: NULL_HASH.to_string(),
1320 payload: Value::Null,
1321 caveats: vec![],
1322 signature: None,
1323 schema_artifact_id: None,
1324 };
1325 let r = apply_event(&mut state, &event);
1326 if let Err(e) = r {
1327 assert!(
1328 !e.contains("unsupported event kind"),
1329 "kind {kind:?} declared in REDUCER_MUTATION_KINDS \
1330 but rejected by apply_event dispatch: {e}"
1331 );
1332 }
1333 }
1334 }
1335
1336 #[test]
1345 fn federation_events_are_finding_state_noops() {
1346 for kind in &[
1347 "frontier.synced_with_peer",
1348 "frontier.conflict_detected",
1349 "frontier.conflict_resolved",
1350 ] {
1351 let mut state = project::assemble("test", vec![], 0, 0, "test");
1352 let snapshot_before = events::snapshot_hash(&state);
1353 let event = StateEvent {
1354 schema: events::EVENT_SCHEMA.to_string(),
1355 id: format!("vev_federation_{kind}"),
1356 kind: (*kind).to_string(),
1357 target: StateTarget {
1358 r#type: "frontier_observation".to_string(),
1359 id: "vfr_x".to_string(),
1360 },
1361 actor: StateActor {
1362 id: "federation".to_string(),
1363 r#type: "system".to_string(),
1364 },
1365 timestamp: Utc::now().to_rfc3339(),
1366 reason: format!("no-op contract test for {kind}"),
1367 before_hash: NULL_HASH.to_string(),
1368 after_hash: NULL_HASH.to_string(),
1369 payload: Value::Null,
1370 caveats: vec![],
1371 signature: None,
1372 schema_artifact_id: None,
1373 };
1374 apply_event(&mut state, &event)
1375 .unwrap_or_else(|e| panic!("federation kind {kind} rejected by reducer: {e}"));
1376 let snapshot_after = events::snapshot_hash(&state);
1377 assert_eq!(
1378 snapshot_before, snapshot_after,
1379 "federation event {kind} mutated finding-state snapshot; expected no-op"
1380 );
1381 }
1382 }
1383
1384 fn project_with_one_atom(missing_locator: bool) -> Project {
1385 let mut state = project::assemble("test-locator", vec![finding("a")], 0, 0, "test");
1391 state.sources.push(crate::sources::SourceRecord {
1392 id: "vs_test_source".to_string(),
1393 source_type: "paper".to_string(),
1394 locator: "doi:10.1/test-source".to_string(),
1395 content_hash: None,
1396 title: "Test source".to_string(),
1397 authors: Vec::new(),
1398 year: Some(2026),
1399 doi: Some("10.1/test-source".to_string()),
1400 pmid: None,
1401 imported_at: "2026-01-01T00:00:00Z".to_string(),
1402 extraction_mode: "manual".to_string(),
1403 source_quality: "declared".to_string(),
1404 caveats: Vec::new(),
1405 finding_ids: vec![state.findings[0].id.clone()],
1406 });
1407 state.evidence_atoms.push(crate::sources::EvidenceAtom {
1408 id: "vea_test_atom".to_string(),
1409 source_id: "vs_test_source".to_string(),
1410 finding_id: state.findings[0].id.clone(),
1411 locator: if missing_locator {
1412 None
1413 } else {
1414 Some("doi:10.1/already-set".to_string())
1415 },
1416 evidence_type: "experimental".to_string(),
1417 measurement_or_claim: "test claim".to_string(),
1418 supports_or_challenges: "supports".to_string(),
1419 condition_refs: Vec::new(),
1420 extraction_method: "manual".to_string(),
1421 human_verified: false,
1422 caveats: if missing_locator {
1423 vec!["missing evidence locator".to_string()]
1424 } else {
1425 Vec::new()
1426 },
1427 });
1428 state
1429 }
1430
1431 fn atom_by_id<'a>(state: &'a Project, id: &str) -> &'a crate::sources::EvidenceAtom {
1432 state
1433 .evidence_atoms
1434 .iter()
1435 .find(|atom| atom.id == id)
1436 .expect("atom exists")
1437 }
1438
1439 #[test]
1440 fn evidence_atom_locator_repaired_sets_locator_and_clears_caveat() {
1441 let mut state = project_with_one_atom(true);
1442 assert!(state.evidence_atoms[0].locator.is_none());
1443 let event = StateEvent {
1444 schema: crate::events::EVENT_SCHEMA.to_string(),
1445 id: "vev_test".to_string(),
1446 kind: "evidence_atom.locator_repaired".to_string(),
1447 target: StateTarget {
1448 r#type: "evidence_atom".to_string(),
1449 id: "vea_test_atom".to_string(),
1450 },
1451 actor: StateActor {
1452 id: "agent:test".to_string(),
1453 r#type: "agent".to_string(),
1454 },
1455 timestamp: Utc::now().to_rfc3339(),
1456 reason: "Mechanical repair from parent source".to_string(),
1457 before_hash: NULL_HASH.to_string(),
1458 after_hash: NULL_HASH.to_string(),
1459 payload: json!({
1460 "proposal_id": "vpr_test",
1461 "source_id": "vs_test_source",
1462 "locator": "doi:10.1/test-source",
1463 }),
1464 caveats: vec![],
1465 signature: None,
1466 schema_artifact_id: None,
1467 };
1468 apply_event(&mut state, &event).expect("apply locator_repaired");
1469 let atom = atom_by_id(&state, "vea_test_atom");
1470 assert_eq!(atom.locator.as_deref(), Some("doi:10.1/test-source"));
1471 assert!(atom.caveats.is_empty());
1472 }
1473
1474 #[test]
1475 fn evidence_atom_locator_repaired_is_idempotent() {
1476 let mut state = project_with_one_atom(true);
1477 let event = StateEvent {
1478 schema: crate::events::EVENT_SCHEMA.to_string(),
1479 id: "vev_test".to_string(),
1480 kind: "evidence_atom.locator_repaired".to_string(),
1481 target: StateTarget {
1482 r#type: "evidence_atom".to_string(),
1483 id: "vea_test_atom".to_string(),
1484 },
1485 actor: StateActor {
1486 id: "agent:test".to_string(),
1487 r#type: "agent".to_string(),
1488 },
1489 timestamp: Utc::now().to_rfc3339(),
1490 reason: "Mechanical repair from parent source".to_string(),
1491 before_hash: NULL_HASH.to_string(),
1492 after_hash: NULL_HASH.to_string(),
1493 payload: json!({
1494 "proposal_id": "vpr_test",
1495 "source_id": "vs_test_source",
1496 "locator": "doi:10.1/test-source",
1497 }),
1498 caveats: vec![],
1499 signature: None,
1500 schema_artifact_id: None,
1501 };
1502 apply_event(&mut state, &event).expect("first apply");
1503 apply_event(&mut state, &event).expect("second apply is a no-op when locator matches");
1504 let atom = atom_by_id(&state, "vea_test_atom");
1505 assert_eq!(atom.locator.as_deref(), Some("doi:10.1/test-source"));
1506 }
1507
1508 #[test]
1509 fn evidence_atom_locator_repaired_refuses_divergent_overwrite() {
1510 let mut state = project_with_one_atom(false);
1511 let event = StateEvent {
1512 schema: crate::events::EVENT_SCHEMA.to_string(),
1513 id: "vev_test".to_string(),
1514 kind: "evidence_atom.locator_repaired".to_string(),
1515 target: StateTarget {
1516 r#type: "evidence_atom".to_string(),
1517 id: "vea_test_atom".to_string(),
1518 },
1519 actor: StateActor {
1520 id: "agent:test".to_string(),
1521 r#type: "agent".to_string(),
1522 },
1523 timestamp: Utc::now().to_rfc3339(),
1524 reason: "Different repair".to_string(),
1525 before_hash: NULL_HASH.to_string(),
1526 after_hash: NULL_HASH.to_string(),
1527 payload: json!({
1528 "proposal_id": "vpr_test",
1529 "source_id": "vs_test_source",
1530 "locator": "doi:10.1/different",
1531 }),
1532 caveats: vec![],
1533 signature: None,
1534 schema_artifact_id: None,
1535 };
1536 let r = apply_event(&mut state, &event);
1537 assert!(r.is_err());
1538 assert!(r.unwrap_err().contains("already has locator"));
1539 }
1540
1541 #[test]
1542 fn evidence_atom_locator_repaired_does_not_mutate_findings() {
1543 let mut state = project_with_one_atom(true);
1545 let hashes_before: Vec<String> = state
1546 .findings
1547 .iter()
1548 .map(crate::events::finding_hash)
1549 .collect();
1550 let event = StateEvent {
1551 schema: crate::events::EVENT_SCHEMA.to_string(),
1552 id: "vev_test".to_string(),
1553 kind: "evidence_atom.locator_repaired".to_string(),
1554 target: StateTarget {
1555 r#type: "evidence_atom".to_string(),
1556 id: "vea_test_atom".to_string(),
1557 },
1558 actor: StateActor {
1559 id: "agent:test".to_string(),
1560 r#type: "agent".to_string(),
1561 },
1562 timestamp: Utc::now().to_rfc3339(),
1563 reason: "Mechanical repair".to_string(),
1564 before_hash: NULL_HASH.to_string(),
1565 after_hash: NULL_HASH.to_string(),
1566 payload: json!({
1567 "proposal_id": "vpr_test",
1568 "source_id": "vs_test_source",
1569 "locator": "doi:10.1/test-source",
1570 }),
1571 caveats: vec![],
1572 signature: None,
1573 schema_artifact_id: None,
1574 };
1575 apply_event(&mut state, &event).expect("apply ok");
1576 let hashes_after: Vec<String> = state
1577 .findings
1578 .iter()
1579 .map(crate::events::finding_hash)
1580 .collect();
1581 assert_eq!(hashes_before, hashes_after);
1582 }
1583}