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: 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 let mut idx = build_finding_index(&state);
285 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
299pub fn verify_replay(state: &Project) -> ReplayVerification {
306 if state.events.is_empty() {
314 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 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
347fn apply_finding_asserted(
350 state: &mut Project,
351 event: &StateEvent,
352 idx: &mut FindingIndex,
353) -> Result<(), String> {
354 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 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
543fn 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
600fn 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
620fn 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
737fn 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
805fn 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
899fn 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 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
960fn 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
980fn 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
1000fn 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
1044fn 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 #[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 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 #[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 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 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}