1use std::path::Path;
7
8use chrono::Utc;
9use serde::Serialize;
10use serde_json::{Value, json};
11use sha2::{Digest, Sha256};
12
13use crate::bundle::{
14 Artifact, Assertion, Author, Conditions, Confidence, ConfidenceKind, ConfidenceMethod, Entity,
15 Evidence, Extraction, FindingBundle, Flags, NegativeResult, NegativeResultKind, Provenance,
16 ResolutionMethod, Review, Trajectory, TrajectoryStep, TrajectoryStepKind,
17};
18use crate::events::{self, NULL_HASH, StateActor, StateEvent, StateTarget};
19use crate::project::{self, Project};
20use crate::proposals::{self, StateProposal};
21use crate::reducer;
22use crate::repo;
23
24#[derive(Debug, Clone, Serialize)]
25pub struct StateCommandReport {
26 pub ok: bool,
27 pub command: String,
28 pub frontier: String,
29 pub finding_id: String,
30 pub proposal_id: String,
31 pub proposal_status: String,
32 #[serde(skip_serializing_if = "Option::is_none")]
33 pub applied_event_id: Option<String>,
34 pub wrote_to: String,
35 pub message: String,
36}
37
38#[derive(Debug, Clone)]
39pub struct FindingDraftOptions {
40 pub text: String,
41 pub assertion_type: String,
42 pub source: String,
43 pub source_type: String,
44 pub author: String,
45 pub confidence: f64,
46 pub evidence_type: String,
47 pub entities: Vec<(String, String)>,
48 #[allow(dead_code)] pub doi: Option<String>,
54 #[allow(dead_code)]
55 pub pmid: Option<String>,
56 #[allow(dead_code)]
57 pub year: Option<i32>,
58 #[allow(dead_code)]
59 pub journal: Option<String>,
60 #[allow(dead_code)]
61 pub url: Option<String>,
62 #[allow(dead_code)]
65 pub source_authors: Vec<String>,
66 #[allow(dead_code)]
70 pub conditions_text: Option<String>,
71 #[allow(dead_code)]
72 pub species: Vec<String>,
73 #[allow(dead_code)]
74 pub in_vivo: bool,
75 #[allow(dead_code)]
76 pub in_vitro: bool,
77 #[allow(dead_code)]
78 pub human_data: bool,
79 #[allow(dead_code)]
80 pub clinical_trial: bool,
81 #[allow(dead_code)]
82 pub entities_reviewed: bool,
83 #[allow(dead_code)]
84 pub evidence_spans: Vec<Value>,
85 #[allow(dead_code)]
86 pub gap: bool,
87 #[allow(dead_code)]
88 pub negative_space: bool,
89}
90
91#[derive(Debug, Clone)]
92pub struct ReviewOptions {
93 pub status: String,
94 pub reason: String,
95 pub reviewer: String,
96}
97
98#[derive(Debug, Clone)]
99pub struct ReviseOptions {
100 pub confidence: f64,
101 pub reason: String,
102 pub reviewer: String,
103}
104
105pub fn add_finding(
106 path: &Path,
107 options: FindingDraftOptions,
108 apply: bool,
109) -> Result<StateCommandReport, String> {
110 validate_score(options.confidence)?;
111 let proposal = build_add_finding_proposal(options)?;
112 let result = proposals::create_or_apply(path, proposal, apply)?;
113 let frontier = repo::load_from_path(path)?;
114 Ok(StateCommandReport {
115 ok: true,
116 command: "finding.add".to_string(),
117 frontier: frontier.project.name,
118 finding_id: result.finding_id,
119 proposal_id: result.proposal_id,
120 proposal_status: result.status.clone(),
121 applied_event_id: result.applied_event_id,
122 wrote_to: path.display().to_string(),
123 message: if result.status == "applied" {
124 "Finding proposal applied".to_string()
125 } else {
126 "Finding proposal recorded".to_string()
127 },
128 })
129}
130
131pub fn review_finding(
132 path: &Path,
133 finding_id: &str,
134 options: ReviewOptions,
135 apply: bool,
136) -> Result<StateCommandReport, String> {
137 let proposal = proposals::new_proposal(
138 "finding.review",
139 events::StateTarget {
140 r#type: "finding".to_string(),
141 id: finding_id.to_string(),
142 },
143 options.reviewer.clone(),
144 "human",
145 options.reason.clone(),
146 json!({"status": options.status}),
147 Vec::new(),
148 Vec::new(),
149 );
150 let result = proposals::create_or_apply(path, proposal, apply)?;
151 let frontier = repo::load_from_path(path)?;
152 Ok(StateCommandReport {
153 ok: true,
154 command: "review".to_string(),
155 frontier: frontier.project.name,
156 finding_id: result.finding_id,
157 proposal_id: result.proposal_id,
158 proposal_status: result.status,
159 applied_event_id: result.applied_event_id,
160 wrote_to: path.display().to_string(),
161 message: if apply {
162 "Review proposal applied".to_string()
163 } else {
164 "Review proposal recorded".to_string()
165 },
166 })
167}
168
169pub fn add_note(
170 path: &Path,
171 finding_id: &str,
172 text: &str,
173 author: &str,
174 apply: bool,
175) -> Result<StateCommandReport, String> {
176 let proposal = proposals::new_proposal(
177 "finding.note",
178 events::StateTarget {
179 r#type: "finding".to_string(),
180 id: finding_id.to_string(),
181 },
182 author.to_string(),
183 "human",
184 text.to_string(),
185 json!({"text": text}),
186 Vec::new(),
187 Vec::new(),
188 );
189 let result = proposals::create_or_apply(path, proposal, apply)?;
190 let frontier = repo::load_from_path(path)?;
191 Ok(StateCommandReport {
192 ok: true,
193 command: "note".to_string(),
194 frontier: frontier.project.name,
195 finding_id: result.finding_id,
196 proposal_id: result.proposal_id,
197 proposal_status: result.status,
198 applied_event_id: result.applied_event_id,
199 wrote_to: path.display().to_string(),
200 message: if apply {
201 "Note proposal applied".to_string()
202 } else {
203 "Note proposal recorded".to_string()
204 },
205 })
206}
207
208pub fn caveat_finding(
209 path: &Path,
210 finding_id: &str,
211 text: &str,
212 author: &str,
213 apply: bool,
214) -> Result<StateCommandReport, String> {
215 let proposal = proposals::new_proposal(
216 "finding.caveat",
217 events::StateTarget {
218 r#type: "finding".to_string(),
219 id: finding_id.to_string(),
220 },
221 author.to_string(),
222 "human",
223 text.to_string(),
224 json!({"text": text}),
225 Vec::new(),
226 Vec::new(),
227 );
228 let result = proposals::create_or_apply(path, proposal, apply)?;
229 let frontier = repo::load_from_path(path)?;
230 Ok(StateCommandReport {
231 ok: true,
232 command: "caveat".to_string(),
233 frontier: frontier.project.name,
234 finding_id: result.finding_id,
235 proposal_id: result.proposal_id,
236 proposal_status: result.status,
237 applied_event_id: result.applied_event_id,
238 wrote_to: path.display().to_string(),
239 message: if apply {
240 "Caveat proposal applied".to_string()
241 } else {
242 "Caveat proposal recorded".to_string()
243 },
244 })
245}
246
247pub fn revise_confidence(
248 path: &Path,
249 finding_id: &str,
250 options: ReviseOptions,
251 apply: bool,
252) -> Result<StateCommandReport, String> {
253 validate_score(options.confidence)?;
254 let proposal = proposals::new_proposal(
255 "finding.confidence_revise",
256 events::StateTarget {
257 r#type: "finding".to_string(),
258 id: finding_id.to_string(),
259 },
260 options.reviewer.clone(),
261 "human",
262 options.reason.clone(),
263 json!({"confidence": options.confidence}),
264 Vec::new(),
265 Vec::new(),
266 );
267 let result = proposals::create_or_apply(path, proposal, apply)?;
268 let frontier = repo::load_from_path(path)?;
269 Ok(StateCommandReport {
270 ok: true,
271 command: "revise".to_string(),
272 frontier: frontier.project.name,
273 finding_id: result.finding_id,
274 proposal_id: result.proposal_id,
275 proposal_status: result.status,
276 applied_event_id: result.applied_event_id,
277 wrote_to: path.display().to_string(),
278 message: if apply {
279 "Confidence revision applied".to_string()
280 } else {
281 "Confidence revision proposal recorded".to_string()
282 },
283 })
284}
285
286pub fn reject_finding(
287 path: &Path,
288 finding_id: &str,
289 reviewer: &str,
290 reason: &str,
291 apply: bool,
292) -> Result<StateCommandReport, String> {
293 let proposal = proposals::new_proposal(
294 "finding.reject",
295 events::StateTarget {
296 r#type: "finding".to_string(),
297 id: finding_id.to_string(),
298 },
299 reviewer.to_string(),
300 "human",
301 reason.to_string(),
302 json!({"status": "rejected"}),
303 Vec::new(),
304 Vec::new(),
305 );
306 let result = proposals::create_or_apply(path, proposal, apply)?;
307 let frontier = repo::load_from_path(path)?;
308 Ok(StateCommandReport {
309 ok: true,
310 command: "reject".to_string(),
311 frontier: frontier.project.name,
312 finding_id: result.finding_id,
313 proposal_id: result.proposal_id,
314 proposal_status: result.status,
315 applied_event_id: result.applied_event_id,
316 wrote_to: path.display().to_string(),
317 message: if apply {
318 "Rejection proposal applied".to_string()
319 } else {
320 "Rejection proposal recorded".to_string()
321 },
322 })
323}
324
325#[allow(clippy::too_many_arguments)]
330pub fn resolve_finding_entity(
331 path: &Path,
332 finding_id: &str,
333 entity_name: &str,
334 source: &str,
335 id: &str,
336 confidence: f64,
337 matched_name: Option<&str>,
338 resolution_method: &str,
339 reviewer: &str,
340 reason: &str,
341 apply: bool,
342) -> Result<StateCommandReport, String> {
343 let frontier_view = repo::load_from_path(path)?;
344 let f = frontier_view
345 .findings
346 .iter()
347 .find(|f| f.id == finding_id)
348 .ok_or_else(|| format!("Finding not found: {finding_id}"))?;
349 if !f.assertion.entities.iter().any(|e| e.name == entity_name) {
350 return Err(format!(
351 "Finding {finding_id} has no entity named {entity_name:?}"
352 ));
353 }
354 if !(0.0..=1.0).contains(&confidence) {
355 return Err(format!(
356 "--confidence must be in [0.0, 1.0], got {confidence}"
357 ));
358 }
359 if !matches!(
360 resolution_method,
361 "exact_match" | "fuzzy_match" | "llm_inference" | "manual"
362 ) {
363 return Err(format!(
364 "--resolution-method must be one of exact_match|fuzzy_match|llm_inference|manual, got {resolution_method:?}"
365 ));
366 }
367 let mut payload = json!({
368 "entity_name": entity_name,
369 "source": source,
370 "id": id,
371 "confidence": confidence,
372 "resolution_method": resolution_method,
373 });
374 if let Some(m) = matched_name {
375 payload["matched_name"] = json!(m);
376 }
377 let proposal = proposals::new_proposal(
378 "finding.entity_resolve",
379 events::StateTarget {
380 r#type: "finding".to_string(),
381 id: finding_id.to_string(),
382 },
383 reviewer,
384 "human",
385 reason,
386 payload,
387 Vec::new(),
388 Vec::new(),
389 );
390 let result = proposals::create_or_apply(path, proposal, apply)?;
391 Ok(StateCommandReport {
392 ok: true,
393 command: "entity-resolve".to_string(),
394 frontier: frontier_view.project.name,
395 finding_id: finding_id.to_string(),
396 proposal_id: result.proposal_id,
397 proposal_status: result.status,
398 applied_event_id: result.applied_event_id,
399 wrote_to: path.display().to_string(),
400 message: if apply {
401 "Entity resolution applied".to_string()
402 } else {
403 "Entity resolution proposal recorded".to_string()
404 },
405 })
406}
407
408pub fn record_attestation(
416 path: &Path,
417 target_event_id: &str,
418 attester_id: &str,
419 scope_note: &str,
420 proof_id: Option<&str>,
421 signature: Option<&str>,
422) -> Result<String, String> {
423 if !target_event_id.starts_with("vev_") {
424 return Err(format!(
425 "target_event_id must start with 'vev_', got '{target_event_id}'"
426 ));
427 }
428 if attester_id.trim().is_empty() {
429 return Err("attester_id must be non-empty".to_string());
430 }
431 if scope_note.trim().is_empty() {
432 return Err("scope_note must be non-empty".to_string());
433 }
434 if let Some(p) = proof_id
435 && !p.starts_with("vpf_")
436 {
437 return Err(format!(
438 "proof_id must start with 'vpf_' when present, got '{p}'"
439 ));
440 }
441 let mut frontier = repo::load_from_path(path)?;
442 if !frontier.events.iter().any(|e| e.id == target_event_id) {
445 return Err(format!(
446 "target event '{target_event_id}' not found in frontier"
447 ));
448 }
449 let mut payload = json!({
450 "target_event_id": target_event_id,
451 "attester_id": attester_id,
452 "scope_note": scope_note,
453 "signed_at": chrono::Utc::now().to_rfc3339(),
454 });
455 if let Some(p) = proof_id {
456 payload["proof_id"] = json!(p);
457 }
458 if let Some(s) = signature {
459 payload["signature"] = json!(s);
460 }
461 let actor_type = if attester_id.starts_with("agent:") {
462 "agent"
463 } else {
464 "human"
465 };
466 let mut event = events::StateEvent {
467 schema: events::EVENT_SCHEMA.to_string(),
468 id: String::new(),
469 kind: "attestation.recorded".to_string(),
470 target: events::StateTarget {
471 r#type: "event".to_string(),
472 id: target_event_id.to_string(),
473 },
474 actor: events::StateActor {
475 id: attester_id.to_string(),
476 r#type: actor_type.to_string(),
477 },
478 timestamp: chrono::Utc::now().to_rfc3339(),
479 reason: scope_note.to_string(),
480 before_hash: events::NULL_HASH.to_string(),
481 after_hash: events::NULL_HASH.to_string(),
482 payload,
483 caveats: Vec::new(),
484 signature: None,
485 schema_artifact_id: None,
486 };
487 event.id = events::compute_event_id(&event);
488 let event_id = event.id.clone();
489 frontier.events.push(event);
490 repo::save_to_path(path, &frontier)?;
491 Ok(event_id)
492}
493
494#[allow(clippy::too_many_arguments)]
501pub fn add_finding_entity(
502 path: &Path,
503 finding_id: &str,
504 entity_name: &str,
505 entity_type: &str,
506 reviewer: &str,
507 reason: &str,
508 apply: bool,
509) -> Result<StateCommandReport, String> {
510 const VALID_ENTITY_TYPES: &[&str] = &[
511 "gene",
512 "protein",
513 "compound",
514 "disease",
515 "cell_type",
516 "organism",
517 "pathway",
518 "assay",
519 "anatomical_structure",
520 "particle",
521 "instrument",
522 "dataset",
523 "quantity",
524 "other",
525 ];
526 if !VALID_ENTITY_TYPES.contains(&entity_type) {
527 return Err(format!(
528 "--entity-type must be one of {VALID_ENTITY_TYPES:?}, got {entity_type:?}"
529 ));
530 }
531 let frontier_view = repo::load_from_path(path)?;
532 let _ = frontier_view
533 .findings
534 .iter()
535 .find(|f| f.id == finding_id)
536 .ok_or_else(|| format!("Finding not found: {finding_id}"))?;
537 let payload = json!({
538 "entity_name": entity_name,
539 "entity_type": entity_type,
540 "reason": reason,
541 });
542 let proposal = proposals::new_proposal(
543 "finding.entity_add",
544 events::StateTarget {
545 r#type: "finding".to_string(),
546 id: finding_id.to_string(),
547 },
548 reviewer,
549 "human",
550 reason,
551 payload,
552 Vec::new(),
553 Vec::new(),
554 );
555 let result = proposals::create_or_apply(path, proposal, apply)?;
556 Ok(StateCommandReport {
557 ok: true,
558 command: "entity-add".to_string(),
559 frontier: frontier_view.project.name,
560 finding_id: finding_id.to_string(),
561 proposal_id: result.proposal_id,
562 proposal_status: result.status,
563 applied_event_id: result.applied_event_id,
564 wrote_to: path.display().to_string(),
565 message: if apply {
566 "Entity-add proposal applied".to_string()
567 } else {
568 "Entity-add proposal recorded".to_string()
569 },
570 })
571}
572
573pub fn repair_finding_span(
578 path: &Path,
579 finding_id: &str,
580 section: &str,
581 text: &str,
582 reviewer: &str,
583 reason: &str,
584 apply: bool,
585) -> Result<StateCommandReport, String> {
586 let frontier_view = repo::load_from_path(path)?;
587 let _ = frontier_view
588 .findings
589 .iter()
590 .find(|f| f.id == finding_id)
591 .ok_or_else(|| format!("Finding not found: {finding_id}"))?;
592 let trimmed_section = section.trim();
593 let trimmed_text = text.trim();
594 if trimmed_section.is_empty() {
595 return Err("--section must be non-empty".to_string());
596 }
597 if trimmed_text.is_empty() {
598 return Err("--text must be non-empty".to_string());
599 }
600 let proposal = proposals::new_proposal(
601 "finding.span_repair",
602 events::StateTarget {
603 r#type: "finding".to_string(),
604 id: finding_id.to_string(),
605 },
606 reviewer,
607 "human",
608 reason,
609 json!({
610 "section": trimmed_section,
611 "text": trimmed_text,
612 }),
613 Vec::new(),
614 Vec::new(),
615 );
616 let result = proposals::create_or_apply(path, proposal, apply)?;
617 Ok(StateCommandReport {
618 ok: true,
619 command: "span-repair".to_string(),
620 frontier: frontier_view.project.name,
621 finding_id: finding_id.to_string(),
622 proposal_id: result.proposal_id,
623 proposal_status: result.status,
624 applied_event_id: result.applied_event_id,
625 wrote_to: path.display().to_string(),
626 message: if apply {
627 "Span repair applied".to_string()
628 } else {
629 "Span repair proposal recorded".to_string()
630 },
631 })
632}
633
634pub fn repair_evidence_atom_locator(
641 path: &Path,
642 atom_id: &str,
643 locator_override: Option<&str>,
644 reviewer: &str,
645 reason: &str,
646 apply: bool,
647) -> Result<StateCommandReport, String> {
648 let frontier_view = repo::load_from_path(path)?;
649 let atom = frontier_view
650 .evidence_atoms
651 .iter()
652 .find(|atom| atom.id == atom_id)
653 .ok_or_else(|| format!("Evidence atom not found: {atom_id}"))?;
654 if let Some(existing) = &atom.locator {
655 return Err(format!(
656 "Evidence atom {atom_id} already carries locator '{existing}'"
657 ));
658 }
659 let source_id = atom.source_id.clone();
660 let locator = match locator_override {
661 Some(value) => {
662 let trimmed = value.trim();
663 if trimmed.is_empty() {
664 return Err("--locator value must be non-empty".to_string());
665 }
666 trimmed.to_string()
667 }
668 None => {
669 let source = frontier_view
670 .sources
671 .iter()
672 .find(|source| source.id == source_id)
673 .ok_or_else(|| {
674 format!(
675 "Cannot resolve locator for atom {atom_id}: parent source {source_id} not in frontier"
676 )
677 })?;
678 let trimmed = source.locator.trim();
679 if trimmed.is_empty() {
680 return Err(format!(
681 "Cannot resolve locator for atom {atom_id}: parent source {source_id} has an empty locator"
682 ));
683 }
684 trimmed.to_string()
685 }
686 };
687 let proposal = proposals::new_proposal(
688 "evidence_atom.locator_repair",
689 events::StateTarget {
690 r#type: "evidence_atom".to_string(),
691 id: atom_id.to_string(),
692 },
693 reviewer,
694 "human",
695 reason,
696 json!({
697 "locator": locator,
698 "source_id": source_id,
699 }),
700 Vec::new(),
701 Vec::new(),
702 );
703 let result = proposals::create_or_apply(path, proposal, apply)?;
704 Ok(StateCommandReport {
705 ok: true,
706 command: "locator-repair".to_string(),
707 frontier: frontier_view.project.name,
708 finding_id: atom_id.to_string(),
709 proposal_id: result.proposal_id,
710 proposal_status: result.status,
711 applied_event_id: result.applied_event_id,
712 wrote_to: path.display().to_string(),
713 message: if apply {
714 "Locator repair applied".to_string()
715 } else {
716 "Locator repair proposal recorded".to_string()
717 },
718 })
719}
720
721pub fn resolve_frontier_conflict(
727 path: &Path,
728 conflict_event_id: &str,
729 resolution_note: &str,
730 reviewer: &str,
731 winning_proposal_id: Option<&str>,
732 apply: bool,
733) -> Result<StateCommandReport, String> {
734 let frontier_view = repo::load_from_path(path)?;
735 let frontier_id = frontier_view.frontier_id();
736 let mut payload = json!({
737 "conflict_event_id": conflict_event_id,
738 "resolution_note": resolution_note,
739 });
740 if let Some(wpid) = winning_proposal_id {
741 payload["winning_proposal_id"] = json!(wpid);
742 }
743 let proposal = proposals::new_proposal(
744 "frontier.conflict_resolve",
745 events::StateTarget {
746 r#type: "frontier_observation".to_string(),
747 id: frontier_id,
748 },
749 reviewer,
750 "human",
751 format!("Conflict resolution: {resolution_note}"),
752 payload,
753 Vec::new(),
754 Vec::new(),
755 );
756 let result = proposals::create_or_apply(path, proposal, apply)?;
757 Ok(StateCommandReport {
758 ok: true,
759 command: "conflict-resolve".to_string(),
760 frontier: frontier_view.project.name,
761 finding_id: conflict_event_id.to_string(),
762 proposal_id: result.proposal_id,
763 proposal_status: result.status,
764 applied_event_id: result.applied_event_id,
765 wrote_to: path.display().to_string(),
766 message: if apply {
767 "Conflict resolution applied".to_string()
768 } else {
769 "Conflict resolution proposal recorded".to_string()
770 },
771 })
772}
773
774pub fn deposit_replication(
782 path: &Path,
783 rep: crate::bundle::Replication,
784 actor_id: &str,
785 reason: &str,
786) -> Result<events::StateEvent, String> {
787 let mut project = repo::load_from_path(path)?;
788 if project.replications.iter().any(|r| r.id == rep.id) {
789 return Err(format!(
790 "Replication {} already exists on this frontier; refusing duplicate deposit",
791 rep.id
792 ));
793 }
794 let rep_value =
795 serde_json::to_value(&rep).map_err(|e| format!("serialize replication: {e}"))?;
796 let payload = json!({ "replication": rep_value });
797 let timestamp = Utc::now().to_rfc3339();
798 let mut event = events::StateEvent {
799 schema: events::EVENT_SCHEMA.to_string(),
800 id: String::new(),
801 kind: "replication.deposited".to_string(),
802 target: events::StateTarget {
803 r#type: "finding".to_string(),
804 id: rep.target_finding.clone(),
805 },
806 actor: events::StateActor {
807 id: actor_id.to_string(),
808 r#type: "human".to_string(),
809 },
810 timestamp,
811 reason: reason.to_string(),
812 before_hash: NULL_HASH.to_string(),
813 after_hash: NULL_HASH.to_string(),
814 payload,
815 caveats: Vec::new(),
816 signature: None,
817 schema_artifact_id: None,
818 };
819 event.id = events::compute_event_id(&event);
820 project.replications.push(rep);
821 project.events.push(event.clone());
822 repo::save_to_path(path, &project)?;
823 Ok(event)
824}
825
826pub fn deposit_prediction(
830 path: &Path,
831 pred: crate::bundle::Prediction,
832 actor_id: &str,
833 reason: &str,
834) -> Result<events::StateEvent, String> {
835 let mut project = repo::load_from_path(path)?;
836 if project.predictions.iter().any(|p| p.id == pred.id) {
837 return Err(format!(
838 "Prediction {} already exists on this frontier; refusing duplicate deposit",
839 pred.id
840 ));
841 }
842 let pred_value =
843 serde_json::to_value(&pred).map_err(|e| format!("serialize prediction: {e}"))?;
844 let payload = json!({ "prediction": pred_value });
845 let timestamp = Utc::now().to_rfc3339();
846 let mut event = events::StateEvent {
847 schema: events::EVENT_SCHEMA.to_string(),
848 id: String::new(),
849 kind: "prediction.deposited".to_string(),
850 target: events::StateTarget {
851 r#type: "finding".to_string(),
852 id: pred.target_findings.first().cloned().unwrap_or_default(),
853 },
854 actor: events::StateActor {
855 id: actor_id.to_string(),
856 r#type: "human".to_string(),
857 },
858 timestamp,
859 reason: reason.to_string(),
860 before_hash: NULL_HASH.to_string(),
861 after_hash: NULL_HASH.to_string(),
862 payload,
863 caveats: Vec::new(),
864 signature: None,
865 schema_artifact_id: None,
866 };
867 event.id = events::compute_event_id(&event);
868 project.predictions.push(pred);
869 project.events.push(event.clone());
870 repo::save_to_path(path, &project)?;
871 Ok(event)
872}
873
874pub fn retract_finding(
875 path: &Path,
876 finding_id: &str,
877 reviewer: &str,
878 reason: &str,
879 apply: bool,
880) -> Result<StateCommandReport, String> {
881 let frontier = repo::load_from_path(path)?;
882 find_finding_index(&frontier, finding_id)?;
883 let proposal = proposals::new_proposal(
884 "finding.retract",
885 events::StateTarget {
886 r#type: "finding".to_string(),
887 id: finding_id.to_string(),
888 },
889 reviewer,
890 "human",
891 reason,
892 json!({}),
893 Vec::new(),
894 vec!["Retraction impact is simulated over declared dependency links.".to_string()],
895 );
896 let result = proposals::create_or_apply(path, proposal, apply)?;
897 Ok(StateCommandReport {
898 ok: true,
899 command: "retract".to_string(),
900 frontier: frontier.project.name,
901 finding_id: result.finding_id,
902 proposal_id: result.proposal_id,
903 proposal_status: result.status,
904 applied_event_id: result.applied_event_id,
905 wrote_to: path.display().to_string(),
906 message: if apply {
907 "Retraction proposal applied".to_string()
908 } else {
909 "Retraction proposal recorded".to_string()
910 },
911 })
912}
913
914pub fn set_causal(
922 path: &Path,
923 finding_id: &str,
924 new_claim: &str,
925 new_grade: Option<&str>,
926 actor: &str,
927 reason: &str,
928) -> Result<StateCommandReport, String> {
929 use crate::bundle::{CausalClaim, CausalEvidenceGrade};
930
931 let mut frontier: Project = repo::load_from_path(path)?;
932 let idx = frontier
933 .findings
934 .iter()
935 .position(|f| f.id == finding_id)
936 .ok_or_else(|| format!("Finding not found: {finding_id}"))?;
937
938 let before = json!({
940 "claim": frontier.findings[idx].assertion.causal_claim,
941 "grade": frontier.findings[idx].assertion.causal_evidence_grade,
942 });
943
944 let parsed_claim = match new_claim {
945 "correlation" => CausalClaim::Correlation,
946 "mediation" => CausalClaim::Mediation,
947 "intervention" => CausalClaim::Intervention,
948 other => return Err(format!("invalid causal claim '{other}'")),
949 };
950 let parsed_grade = match new_grade {
951 None => None,
952 Some("rct") => Some(CausalEvidenceGrade::Rct),
953 Some("quasi_experimental") => Some(CausalEvidenceGrade::QuasiExperimental),
954 Some("observational") => Some(CausalEvidenceGrade::Observational),
955 Some("theoretical") => Some(CausalEvidenceGrade::Theoretical),
956 Some(other) => return Err(format!("invalid causal evidence grade '{other}'")),
957 };
958
959 let before_hash = events::finding_hash(&frontier.findings[idx]);
960 frontier.findings[idx].assertion.causal_claim = Some(parsed_claim);
961 if let Some(g) = parsed_grade {
962 frontier.findings[idx].assertion.causal_evidence_grade = Some(g);
963 }
964 let after_hash = events::finding_hash(&frontier.findings[idx]);
965
966 let after = json!({
967 "claim": new_claim,
968 "grade": new_grade,
969 });
970
971 let proposal_id = format!(
973 "vpr_{}",
974 &hex::encode(Sha256::digest(
975 format!(
976 "{finding_id}|{actor}|{before_hash}|{after_hash}|{}",
977 Utc::now().to_rfc3339()
978 )
979 .as_bytes()
980 ))[..16]
981 );
982
983 let event = events::new_finding_event(events::FindingEventInput {
984 kind: "assertion.reinterpreted_causal",
985 finding_id,
986 actor_id: actor,
987 actor_type: "human",
988 reason,
989 before_hash: &before_hash,
990 after_hash: &after_hash,
991 payload: json!({
992 "proposal_id": proposal_id,
993 "before": before,
994 "after": after,
995 }),
996 caveats: Vec::new(),
997 });
998 let event_id = event.id.clone();
999 frontier.events.push(event);
1000
1001 repo::save_to_path(path, &frontier)?;
1002
1003 Ok(StateCommandReport {
1004 ok: true,
1005 command: "causal_set".to_string(),
1006 frontier: frontier.project.name,
1007 finding_id: finding_id.to_string(),
1008 proposal_id,
1009 proposal_status: "applied".to_string(),
1010 applied_event_id: Some(event_id),
1011 wrote_to: path.display().to_string(),
1012 message: format!("Causal claim set to {new_claim}"),
1013 })
1014}
1015
1016pub fn add_negative_result(
1036 path: &Path,
1037 kind: NegativeResultKind,
1038 target_findings: Vec<String>,
1039 deposited_by: &str,
1040 conditions: Conditions,
1041 provenance: Provenance,
1042 notes: &str,
1043 reason: &str,
1044) -> Result<StateCommandReport, String> {
1045 if deposited_by.trim().is_empty() {
1046 return Err("deposited_by must be a non-empty actor id".to_string());
1047 }
1048 if reason.trim().is_empty() {
1049 return Err("reason must be non-empty".to_string());
1050 }
1051
1052 let mut frontier: Project = repo::load_from_path(path)?;
1053
1054 let nr = NegativeResult::new(
1055 kind,
1056 target_findings,
1057 deposited_by,
1058 conditions,
1059 provenance,
1060 notes,
1061 );
1062 let nr_id = nr.id.clone();
1063
1064 if frontier.negative_results.iter().any(|n| n.id == nr_id) {
1065 return Err(format!(
1066 "Refusing to add duplicate negative_result with existing id {nr_id}"
1067 ));
1068 }
1069
1070 let proposal_id = format!(
1071 "vpr_{}",
1072 &hex::encode(Sha256::digest(
1073 format!("{nr_id}|{deposited_by}|{}", Utc::now().to_rfc3339()).as_bytes()
1074 ))[..16]
1075 );
1076
1077 let nr_value = serde_json::to_value(&nr)
1078 .map_err(|e| format!("failed to serialize negative_result: {e}"))?;
1079
1080 let mut event = StateEvent {
1081 schema: events::EVENT_SCHEMA.to_string(),
1082 id: String::new(),
1083 kind: events::EVENT_KIND_NEGATIVE_RESULT_ASSERTED.to_string(),
1084 target: StateTarget {
1085 r#type: "negative_result".to_string(),
1086 id: nr_id.clone(),
1087 },
1088 actor: StateActor {
1089 id: deposited_by.to_string(),
1090 r#type: "human".to_string(),
1091 },
1092 timestamp: Utc::now().to_rfc3339(),
1093 reason: reason.to_string(),
1094 before_hash: NULL_HASH.to_string(),
1095 after_hash: NULL_HASH.to_string(),
1096 payload: json!({
1097 "proposal_id": proposal_id,
1098 "negative_result": nr_value,
1099 }),
1100 caveats: Vec::new(),
1101 signature: None,
1102 schema_artifact_id: None,
1103 };
1104 event.id = events::compute_event_id(&event);
1105 let event_id = event.id.clone();
1106
1107 events::validate_event_payload(&event.kind, &event.payload)?;
1110 reducer::apply_event(&mut frontier, &event)?;
1111 frontier.events.push(event);
1112
1113 repo::save_to_path(path, &frontier)?;
1114
1115 Ok(StateCommandReport {
1116 ok: true,
1117 command: "negative_result.add".to_string(),
1118 frontier: frontier.project.name,
1119 finding_id: nr_id,
1120 proposal_id,
1121 proposal_status: "applied".to_string(),
1122 applied_event_id: Some(event_id),
1123 wrote_to: path.display().to_string(),
1124 message: "NegativeResult deposited".to_string(),
1125 })
1126}
1127
1128pub fn add_artifact(
1133 path: &Path,
1134 artifact: Artifact,
1135 deposited_by: &str,
1136 reason: &str,
1137) -> Result<StateCommandReport, String> {
1138 if deposited_by.trim().is_empty() {
1139 return Err("deposited_by must be a non-empty actor id".to_string());
1140 }
1141 if reason.trim().is_empty() {
1142 return Err("reason must be non-empty".to_string());
1143 }
1144
1145 let mut frontier: Project = repo::load_from_path(path)?;
1146 let artifact_id = artifact.id.clone();
1147
1148 if frontier.artifacts.iter().any(|a| a.id == artifact_id) {
1149 return Err(format!(
1150 "Refusing to add duplicate artifact with existing id {artifact_id}"
1151 ));
1152 }
1153
1154 let proposal_id = format!(
1155 "vpr_{}",
1156 &hex::encode(Sha256::digest(
1157 format!("{artifact_id}|{deposited_by}|{}", Utc::now().to_rfc3339()).as_bytes()
1158 ))[..16]
1159 );
1160
1161 let artifact_value = serde_json::to_value(&artifact)
1162 .map_err(|e| format!("failed to serialize artifact: {e}"))?;
1163
1164 let mut event = StateEvent {
1165 schema: events::EVENT_SCHEMA.to_string(),
1166 id: String::new(),
1167 kind: events::EVENT_KIND_ARTIFACT_ASSERTED.to_string(),
1168 target: StateTarget {
1169 r#type: "artifact".to_string(),
1170 id: artifact_id.clone(),
1171 },
1172 actor: StateActor {
1173 id: deposited_by.to_string(),
1174 r#type: "human".to_string(),
1175 },
1176 timestamp: Utc::now().to_rfc3339(),
1177 reason: reason.to_string(),
1178 before_hash: NULL_HASH.to_string(),
1179 after_hash: NULL_HASH.to_string(),
1180 payload: json!({
1181 "proposal_id": proposal_id,
1182 "artifact": artifact_value,
1183 }),
1184 caveats: Vec::new(),
1185 signature: None,
1186 schema_artifact_id: None,
1187 };
1188 event.id = events::compute_event_id(&event);
1189 let event_id = event.id.clone();
1190
1191 events::validate_event_payload(&event.kind, &event.payload)?;
1192 reducer::apply_event(&mut frontier, &event)?;
1193 frontier.events.push(event);
1194
1195 repo::save_to_path(path, &frontier)?;
1196
1197 Ok(StateCommandReport {
1198 ok: true,
1199 command: "artifact.add".to_string(),
1200 frontier: frontier.project.name,
1201 finding_id: artifact_id,
1202 proposal_id,
1203 proposal_status: "applied".to_string(),
1204 applied_event_id: Some(event_id),
1205 wrote_to: path.display().to_string(),
1206 message: "Artifact deposited".to_string(),
1207 })
1208}
1209
1210pub fn create_trajectory(
1219 path: &Path,
1220 target_findings: Vec<String>,
1221 deposited_by: &str,
1222 notes: &str,
1223 reason: &str,
1224) -> Result<StateCommandReport, String> {
1225 if deposited_by.trim().is_empty() {
1226 return Err("deposited_by must be a non-empty actor id".to_string());
1227 }
1228 if reason.trim().is_empty() {
1229 return Err("reason must be non-empty".to_string());
1230 }
1231
1232 let mut frontier: Project = repo::load_from_path(path)?;
1233
1234 let traj = Trajectory::new(target_findings, deposited_by, notes);
1235 let traj_id = traj.id.clone();
1236
1237 if frontier.trajectories.iter().any(|t| t.id == traj_id) {
1238 return Err(format!(
1239 "Refusing to create duplicate trajectory with existing id {traj_id}"
1240 ));
1241 }
1242
1243 let proposal_id = format!(
1244 "vpr_{}",
1245 &hex::encode(Sha256::digest(
1246 format!("{traj_id}|{deposited_by}|{}", Utc::now().to_rfc3339()).as_bytes()
1247 ))[..16]
1248 );
1249
1250 let traj_value =
1251 serde_json::to_value(&traj).map_err(|e| format!("failed to serialize trajectory: {e}"))?;
1252
1253 let mut event = StateEvent {
1254 schema: events::EVENT_SCHEMA.to_string(),
1255 id: String::new(),
1256 kind: events::EVENT_KIND_TRAJECTORY_CREATED.to_string(),
1257 target: StateTarget {
1258 r#type: "trajectory".to_string(),
1259 id: traj_id.clone(),
1260 },
1261 actor: StateActor {
1262 id: deposited_by.to_string(),
1263 r#type: "human".to_string(),
1264 },
1265 timestamp: Utc::now().to_rfc3339(),
1266 reason: reason.to_string(),
1267 before_hash: NULL_HASH.to_string(),
1268 after_hash: NULL_HASH.to_string(),
1269 payload: json!({
1270 "proposal_id": proposal_id,
1271 "trajectory": traj_value,
1272 }),
1273 caveats: Vec::new(),
1274 signature: None,
1275 schema_artifact_id: None,
1276 };
1277 event.id = events::compute_event_id(&event);
1278 let event_id = event.id.clone();
1279
1280 events::validate_event_payload(&event.kind, &event.payload)?;
1281 reducer::apply_event(&mut frontier, &event)?;
1282 frontier.events.push(event);
1283
1284 repo::save_to_path(path, &frontier)?;
1285
1286 Ok(StateCommandReport {
1287 ok: true,
1288 command: "trajectory.create".to_string(),
1289 frontier: frontier.project.name,
1290 finding_id: traj_id,
1291 proposal_id,
1292 proposal_status: "applied".to_string(),
1293 applied_event_id: Some(event_id),
1294 wrote_to: path.display().to_string(),
1295 message: "Trajectory opened".to_string(),
1296 })
1297}
1298
1299pub fn append_trajectory_step(
1304 path: &Path,
1305 trajectory_id: &str,
1306 kind: TrajectoryStepKind,
1307 description: &str,
1308 actor: &str,
1309 references: Vec<String>,
1310 reason: &str,
1311) -> Result<StateCommandReport, String> {
1312 if actor.trim().is_empty() {
1313 return Err("actor must be a non-empty id".to_string());
1314 }
1315 if description.trim().is_empty() {
1316 return Err("description must be non-empty".to_string());
1317 }
1318 if reason.trim().is_empty() {
1319 return Err("reason must be non-empty".to_string());
1320 }
1321
1322 let mut frontier: Project = repo::load_from_path(path)?;
1323 if !frontier.trajectories.iter().any(|t| t.id == trajectory_id) {
1324 return Err(format!("Trajectory not found: {trajectory_id}"));
1325 }
1326
1327 let step = TrajectoryStep::new(trajectory_id, kind, description, actor, None, references);
1328 let step_id = step.id.clone();
1329
1330 let proposal_id = format!(
1331 "vpr_{}",
1332 &hex::encode(Sha256::digest(
1333 format!("{trajectory_id}|{step_id}|{actor}").as_bytes()
1334 ))[..16]
1335 );
1336
1337 let step_value = serde_json::to_value(&step)
1338 .map_err(|e| format!("failed to serialize trajectory step: {e}"))?;
1339
1340 let mut event = StateEvent {
1341 schema: events::EVENT_SCHEMA.to_string(),
1342 id: String::new(),
1343 kind: events::EVENT_KIND_TRAJECTORY_STEP_APPENDED.to_string(),
1344 target: StateTarget {
1345 r#type: "trajectory".to_string(),
1346 id: trajectory_id.to_string(),
1347 },
1348 actor: StateActor {
1349 id: actor.to_string(),
1350 r#type: "human".to_string(),
1351 },
1352 timestamp: Utc::now().to_rfc3339(),
1353 reason: reason.to_string(),
1354 before_hash: NULL_HASH.to_string(),
1355 after_hash: NULL_HASH.to_string(),
1356 payload: json!({
1357 "proposal_id": proposal_id,
1358 "parent_trajectory_id": trajectory_id,
1359 "step": step_value,
1360 }),
1361 caveats: Vec::new(),
1362 signature: None,
1363 schema_artifact_id: None,
1364 };
1365 event.id = events::compute_event_id(&event);
1366 let event_id = event.id.clone();
1367
1368 events::validate_event_payload(&event.kind, &event.payload)?;
1369 reducer::apply_event(&mut frontier, &event)?;
1370 frontier.events.push(event);
1371
1372 repo::save_to_path(path, &frontier)?;
1373
1374 Ok(StateCommandReport {
1375 ok: true,
1376 command: "trajectory.step_append".to_string(),
1377 frontier: frontier.project.name,
1378 finding_id: step_id,
1379 proposal_id,
1380 proposal_status: "applied".to_string(),
1381 applied_event_id: Some(event_id),
1382 wrote_to: path.display().to_string(),
1383 message: "Trajectory step appended".to_string(),
1384 })
1385}
1386
1387pub fn set_tier(
1397 path: &Path,
1398 object_type: &str,
1399 object_id: &str,
1400 new_tier: crate::access_tier::AccessTier,
1401 actor: &str,
1402 reason: &str,
1403) -> Result<StateCommandReport, String> {
1404 if actor.trim().is_empty() {
1405 return Err("actor must be a non-empty id".to_string());
1406 }
1407 if reason.trim().is_empty() {
1408 return Err("reason must be non-empty".to_string());
1409 }
1410 if !matches!(
1411 object_type,
1412 "finding" | "negative_result" | "trajectory" | "artifact"
1413 ) {
1414 return Err(format!(
1415 "object_type '{object_type}' must be one of finding, negative_result, trajectory, artifact"
1416 ));
1417 }
1418
1419 let mut frontier: Project = repo::load_from_path(path)?;
1420
1421 let previous_tier = match object_type {
1422 "finding" => {
1423 frontier
1424 .findings
1425 .iter()
1426 .find(|f| f.id == object_id)
1427 .ok_or_else(|| format!("Finding not found: {object_id}"))?
1428 .access_tier
1429 }
1430 "negative_result" => {
1431 frontier
1432 .negative_results
1433 .iter()
1434 .find(|n| n.id == object_id)
1435 .ok_or_else(|| format!("NegativeResult not found: {object_id}"))?
1436 .access_tier
1437 }
1438 "trajectory" => {
1439 frontier
1440 .trajectories
1441 .iter()
1442 .find(|t| t.id == object_id)
1443 .ok_or_else(|| format!("Trajectory not found: {object_id}"))?
1444 .access_tier
1445 }
1446 "artifact" => {
1447 frontier
1448 .artifacts
1449 .iter()
1450 .find(|a| a.id == object_id)
1451 .ok_or_else(|| format!("Artifact not found: {object_id}"))?
1452 .access_tier
1453 }
1454 _ => unreachable!("validated above"),
1455 };
1456
1457 let proposal_id = format!(
1458 "vpr_{}",
1459 &hex::encode(Sha256::digest(
1460 format!(
1461 "{object_type}|{object_id}|{actor}|{}|{}",
1462 new_tier.canonical(),
1463 Utc::now().to_rfc3339()
1464 )
1465 .as_bytes()
1466 ))[..16]
1467 );
1468
1469 let mut event = StateEvent {
1470 schema: events::EVENT_SCHEMA.to_string(),
1471 id: String::new(),
1472 kind: events::EVENT_KIND_TIER_SET.to_string(),
1473 target: StateTarget {
1474 r#type: object_type.to_string(),
1475 id: object_id.to_string(),
1476 },
1477 actor: StateActor {
1478 id: actor.to_string(),
1479 r#type: "human".to_string(),
1480 },
1481 timestamp: Utc::now().to_rfc3339(),
1482 reason: reason.to_string(),
1483 before_hash: NULL_HASH.to_string(),
1484 after_hash: NULL_HASH.to_string(),
1485 payload: json!({
1486 "proposal_id": proposal_id,
1487 "object_type": object_type,
1488 "object_id": object_id,
1489 "previous_tier": previous_tier.canonical(),
1490 "new_tier": new_tier.canonical(),
1491 }),
1492 caveats: Vec::new(),
1493 signature: None,
1494 schema_artifact_id: None,
1495 };
1496 event.id = events::compute_event_id(&event);
1497 let event_id = event.id.clone();
1498
1499 events::validate_event_payload(&event.kind, &event.payload)?;
1500 reducer::apply_event(&mut frontier, &event)?;
1501 frontier.events.push(event);
1502
1503 repo::save_to_path(path, &frontier)?;
1504
1505 Ok(StateCommandReport {
1506 ok: true,
1507 command: "tier.set".to_string(),
1508 frontier: frontier.project.name,
1509 finding_id: object_id.to_string(),
1510 proposal_id,
1511 proposal_status: "applied".to_string(),
1512 applied_event_id: Some(event_id),
1513 wrote_to: path.display().to_string(),
1514 message: format!("Tier set to {} on {object_type}", new_tier.canonical()),
1515 })
1516}
1517
1518pub fn history(path: &Path, finding_id: &str) -> Result<Value, String> {
1519 history_as_of(path, finding_id, None)
1520}
1521
1522pub fn history_as_of(path: &Path, finding_id: &str, as_of: Option<&str>) -> Result<Value, String> {
1530 let frontier = repo::load_from_path(path)?;
1531 let context = finding_context(&frontier, finding_id)?;
1532 let finding = context
1533 .get("finding")
1534 .ok_or_else(|| format!("Finding not found: {finding_id}"))?;
1535
1536 let cutoff = as_of.map(|s| s.to_string());
1537 let filter_by_ts = |arr: Option<&Value>, ts_field: &str| -> Value {
1538 let Some(v) = arr else {
1539 return Value::Array(Vec::new());
1540 };
1541 let Some(items) = v.as_array() else {
1542 return Value::Array(Vec::new());
1543 };
1544 match &cutoff {
1545 None => Value::Array(items.clone()),
1546 Some(c) => Value::Array(
1547 items
1548 .iter()
1549 .filter(|item| {
1550 item.get(ts_field)
1551 .and_then(Value::as_str)
1552 .map(|t| t <= c.as_str())
1553 .unwrap_or(true)
1554 })
1555 .cloned()
1556 .collect(),
1557 ),
1558 }
1559 };
1560
1561 let events_filtered = filter_by_ts(context.get("events"), "timestamp");
1562 let review_events_filtered = filter_by_ts(context.get("review_events"), "reviewed_at");
1563 let confidence_updates_filtered = filter_by_ts(context.get("confidence_updates"), "updated_at");
1564
1565 let score_at = if let Some(arr) = confidence_updates_filtered.as_array() {
1569 let mut sorted: Vec<&Value> = arr.iter().collect();
1570 sorted.sort_by(|a, b| {
1571 let ta = a.get("updated_at").and_then(Value::as_str).unwrap_or("");
1572 let tb = b.get("updated_at").and_then(Value::as_str).unwrap_or("");
1573 ta.cmp(tb)
1574 });
1575 sorted
1576 .last()
1577 .and_then(|u| u.get("new_score"))
1578 .cloned()
1579 .unwrap_or_else(|| {
1580 finding
1581 .pointer("/confidence/score")
1582 .cloned()
1583 .unwrap_or(Value::Null)
1584 })
1585 } else {
1586 finding
1587 .pointer("/confidence/score")
1588 .cloned()
1589 .unwrap_or(Value::Null)
1590 };
1591
1592 Ok(json!({
1593 "ok": true,
1594 "command": "history",
1595 "frontier": frontier.project.name,
1596 "as_of": cutoff,
1597 "finding": {
1598 "id": finding.get("id"),
1599 "assertion": finding.pointer("/assertion/text"),
1600 "confidence": finding.pointer("/confidence/score"),
1601 "flags": finding.get("flags"),
1602 "annotations": finding.get("annotations"),
1603 },
1604 "replayed_at_score": score_at,
1605 "review_events": review_events_filtered,
1606 "confidence_updates": confidence_updates_filtered,
1607 "sources": context.get("sources"),
1608 "evidence_atoms": context.get("evidence_atoms"),
1609 "condition_records": context.get("condition_records"),
1610 "proposals": context.get("proposals"),
1611 "events": events_filtered,
1612 "proof_state": frontier.proof_state,
1613 }))
1614}
1615
1616pub fn finding_context(frontier: &Project, finding_id: &str) -> Result<Value, String> {
1617 let finding = frontier
1618 .findings
1619 .iter()
1620 .find(|finding| finding.id == finding_id)
1621 .ok_or_else(|| format!("Finding not found: {finding_id}"))?;
1622 let reviews = frontier
1623 .review_events
1624 .iter()
1625 .filter(|event| event.finding_id == finding_id)
1626 .collect::<Vec<_>>();
1627 let confidence_updates = frontier
1628 .confidence_updates
1629 .iter()
1630 .filter(|update| update.finding_id == finding_id)
1631 .collect::<Vec<_>>();
1632 let source_records = frontier
1633 .sources
1634 .iter()
1635 .filter(|source| source.finding_ids.iter().any(|id| id == finding_id))
1636 .collect::<Vec<_>>();
1637 let evidence_atoms = frontier
1638 .evidence_atoms
1639 .iter()
1640 .filter(|atom| atom.finding_id == finding_id)
1641 .collect::<Vec<_>>();
1642 let condition_records = frontier
1643 .condition_records
1644 .iter()
1645 .filter(|record| record.finding_id == finding_id)
1646 .collect::<Vec<_>>();
1647 Ok(json!({
1648 "finding": finding,
1649 "review_events": reviews,
1650 "confidence_updates": confidence_updates,
1651 "sources": source_records,
1652 "evidence_atoms": evidence_atoms,
1653 "condition_records": condition_records,
1654 "proposals": proposals::proposals_for_finding(frontier, finding_id),
1655 "events": events::events_for_finding(frontier, finding_id),
1656 "proof_state": frontier.proof_state,
1657 }))
1658}
1659
1660pub fn state_transitions(frontier: &Project) -> Value {
1661 let mut transitions = Vec::new();
1662 if !frontier.events.is_empty() {
1663 for event in &frontier.events {
1664 transitions.push(json!({
1665 "kind": event.kind,
1666 "id": event.id,
1667 "target": event.target,
1668 "actor": event.actor,
1669 "timestamp": event.timestamp,
1670 "reason": event.reason,
1671 "before_hash": event.before_hash,
1672 "after_hash": event.after_hash,
1673 "payload": event.payload,
1674 "caveats": event.caveats,
1675 }));
1676 }
1677 transitions.sort_by(|a, b| {
1678 a.get("timestamp")
1679 .and_then(Value::as_str)
1680 .cmp(&b.get("timestamp").and_then(Value::as_str))
1681 });
1682 return json!({
1683 "schema": "vela.state-transitions.v1",
1684 "frontier": frontier.project.name,
1685 "source": "canonical_events",
1686 "transitions": transitions,
1687 });
1688 }
1689 for event in &frontier.review_events {
1690 transitions.push(json!({
1691 "kind": "review_event",
1692 "id": event.id,
1693 "target": {"type": "finding", "id": event.finding_id},
1694 "actor": event.reviewer,
1695 "timestamp": event.reviewed_at,
1696 "action": event.action,
1697 "reason": event.reason,
1698 "state_change": event.state_change,
1699 }));
1700 }
1701 for update in &frontier.confidence_updates {
1702 transitions.push(json!({
1703 "kind": "confidence_update",
1704 "id": confidence_update_id(update),
1705 "target": {"type": "finding", "id": update.finding_id},
1706 "actor": update.updated_by,
1707 "timestamp": update.updated_at,
1708 "action": "confidence_revised",
1709 "reason": update.basis,
1710 "state_change": {
1711 "previous_score": update.previous_score,
1712 "new_score": update.new_score,
1713 },
1714 }));
1715 }
1716 transitions.sort_by(|a, b| {
1717 a.get("timestamp")
1718 .and_then(Value::as_str)
1719 .cmp(&b.get("timestamp").and_then(Value::as_str))
1720 });
1721 json!({
1722 "schema": "vela.state-transitions.v0",
1723 "frontier": frontier.project.name,
1724 "transitions": transitions,
1725 })
1726}
1727
1728fn build_finding_bundle(options: &FindingDraftOptions) -> FindingBundle {
1731 let now = Utc::now().to_rfc3339();
1732 let assertion = Assertion {
1733 text: options.text.clone(),
1734 assertion_type: options.assertion_type.clone(),
1735 entities: options
1736 .entities
1737 .iter()
1738 .map(|(name, entity_type)| Entity {
1739 name: name.clone(),
1740 entity_type: entity_type.clone(),
1741 identifiers: serde_json::Map::new(),
1742 canonical_id: None,
1743 candidates: Vec::new(),
1744 aliases: Vec::new(),
1745 resolution_provenance: Some("manual_state_transition".to_string()),
1746 resolution_confidence: if options.entities_reviewed { 0.95 } else { 0.6 },
1747 resolution_method: if options.entities_reviewed {
1748 Some(ResolutionMethod::Manual)
1749 } else {
1750 None
1751 },
1752 species_context: None,
1753 needs_review: !options.entities_reviewed,
1754 })
1755 .collect(),
1756 relation: None,
1757 direction: None,
1758 causal_claim: None,
1759 causal_evidence_grade: None,
1760 };
1761 let evidence = Evidence {
1762 evidence_type: options.evidence_type.clone(),
1763 model_system: String::new(),
1764 species: options
1765 .species
1766 .first()
1767 .cloned()
1768 .or_else(|| options.human_data.then(|| "Homo sapiens".to_string())),
1769 method: if options.clinical_trial {
1770 "manual state transition; placebo-controlled clinical trial where source reports control arm"
1771 .to_string()
1772 } else if options.evidence_type == "experimental" {
1773 "manual state transition; control details require source inspection".to_string()
1774 } else {
1775 "manual state transition".to_string()
1776 },
1777 sample_size: None,
1778 effect_size: None,
1779 p_value: None,
1780 replicated: false,
1781 replication_count: None,
1782 evidence_spans: options.evidence_spans.clone(),
1783 };
1784 let conditions = Conditions {
1785 text: options.conditions_text.clone().unwrap_or_else(|| {
1786 "Manually added finding; requires evidence review before scientific use.".to_string()
1787 }),
1788 species_verified: options.species.clone(),
1789 species_unverified: Vec::new(),
1790 in_vitro: options.in_vitro,
1791 in_vivo: options.in_vivo,
1792 human_data: options.human_data,
1793 clinical_trial: options.clinical_trial,
1794 concentration_range: None,
1795 duration: None,
1796 age_group: None,
1797 cell_type: None,
1798 };
1799 let confidence = Confidence {
1800 kind: ConfidenceKind::FrontierEpistemic,
1801 score: options.confidence,
1802 basis: "operator-supplied frontier prior; review required".to_string(),
1803 method: ConfidenceMethod::ExpertJudgment,
1804 components: None,
1805 extraction_confidence: 1.0,
1806 };
1807 let source_authors = if options.source_authors.is_empty() {
1808 vec![Author {
1809 name: options.author.clone(),
1810 orcid: None,
1811 }]
1812 } else {
1813 options
1814 .source_authors
1815 .iter()
1816 .map(|name| Author {
1817 name: name.clone(),
1818 orcid: None,
1819 })
1820 .collect()
1821 };
1822 let provenance = Provenance {
1823 source_type: options.source_type.clone(),
1824 doi: options.doi.clone(),
1825 pmid: options.pmid.clone(),
1826 pmc: None,
1827 openalex_id: None,
1828 url: options.url.clone(),
1829 title: options.source.clone(),
1830 authors: source_authors,
1831 year: options.year,
1832 journal: options.journal.clone(),
1833 license: None,
1834 publisher: None,
1835 funders: Vec::new(),
1836 extraction: Extraction {
1837 method: "manual_curation".to_string(),
1838 model: None,
1839 model_version: None,
1840 extracted_at: now,
1841 extractor_version: project::VELA_COMPILER_VERSION.to_string(),
1842 },
1843 review: Some(Review {
1844 reviewed: false,
1845 reviewer: None,
1846 reviewed_at: None,
1847 corrections: Vec::new(),
1848 }),
1849 citation_count: None,
1850 };
1851 let flags = Flags {
1852 gap: options.gap,
1853 negative_space: options.negative_space,
1854 ..Default::default()
1855 };
1856 FindingBundle::new(
1857 assertion, evidence, conditions, confidence, provenance, flags,
1858 )
1859}
1860
1861pub fn supersede_finding(
1863 path: &Path,
1864 old_id: &str,
1865 reason: &str,
1866 options: FindingDraftOptions,
1867 apply: bool,
1868) -> Result<StateCommandReport, String> {
1869 validate_score(options.confidence)?;
1870 if reason.trim().is_empty() {
1871 return Err("--reason is required for finding supersede".to_string());
1872 }
1873 let new_finding = build_finding_bundle(&options);
1874 if new_finding.id == old_id {
1875 return Err(
1876 "supersede new assertion must produce a different content address than the old finding (change assertion text, type, or provenance to derive a distinct vf_…)"
1877 .to_string(),
1878 );
1879 }
1880 let proposal = proposals::new_proposal(
1881 "finding.supersede",
1882 events::StateTarget {
1883 r#type: "finding".to_string(),
1884 id: old_id.to_string(),
1885 },
1886 options.author.clone(),
1887 "human",
1888 reason.to_string(),
1889 json!({"new_finding": new_finding}),
1890 Vec::new(),
1891 Vec::new(),
1892 );
1893 let result = proposals::create_or_apply(path, proposal, apply)?;
1894 let frontier = repo::load_from_path(path)?;
1895 Ok(StateCommandReport {
1896 ok: true,
1897 command: "finding.supersede".to_string(),
1898 frontier: frontier.project.name,
1899 finding_id: result.finding_id,
1900 proposal_id: result.proposal_id,
1901 proposal_status: result.status.clone(),
1902 applied_event_id: result.applied_event_id,
1903 wrote_to: path.display().to_string(),
1904 message: if result.status == "applied" {
1905 "Supersede proposal applied".to_string()
1906 } else {
1907 "Supersede proposal recorded".to_string()
1908 },
1909 })
1910}
1911
1912fn build_add_finding_proposal(options: FindingDraftOptions) -> Result<StateProposal, String> {
1913 let now = Utc::now().to_rfc3339();
1914 let assertion = Assertion {
1915 text: options.text.clone(),
1916 assertion_type: options.assertion_type.clone(),
1917 entities: options
1918 .entities
1919 .iter()
1920 .map(|(name, entity_type)| Entity {
1921 name: name.clone(),
1922 entity_type: entity_type.clone(),
1923 identifiers: serde_json::Map::new(),
1924 canonical_id: None,
1925 candidates: Vec::new(),
1926 aliases: Vec::new(),
1927 resolution_provenance: Some("manual_state_transition".to_string()),
1928 resolution_confidence: if options.entities_reviewed { 0.95 } else { 0.6 },
1929 resolution_method: if options.entities_reviewed {
1930 Some(ResolutionMethod::Manual)
1931 } else {
1932 None
1933 },
1934 species_context: None,
1935 needs_review: !options.entities_reviewed,
1936 })
1937 .collect(),
1938 relation: None,
1939 direction: None,
1940 causal_claim: None,
1941 causal_evidence_grade: None,
1942 };
1943 let evidence = Evidence {
1944 evidence_type: options.evidence_type.clone(),
1945 model_system: String::new(),
1946 species: options
1947 .species
1948 .first()
1949 .cloned()
1950 .or_else(|| options.human_data.then(|| "Homo sapiens".to_string())),
1951 method: if options.clinical_trial {
1952 "manual state transition; placebo-controlled clinical trial where source reports control arm"
1953 .to_string()
1954 } else if options.evidence_type == "experimental" {
1955 "manual state transition; control details require source inspection".to_string()
1956 } else {
1957 "manual state transition".to_string()
1958 },
1959 sample_size: None,
1960 effect_size: None,
1961 p_value: None,
1962 replicated: false,
1963 replication_count: None,
1964 evidence_spans: options.evidence_spans.clone(),
1965 };
1966 let conditions = Conditions {
1971 text: options.conditions_text.clone().unwrap_or_else(|| {
1972 "Manually added finding; requires evidence review before scientific use.".to_string()
1973 }),
1974 species_verified: options.species.clone(),
1975 species_unverified: Vec::new(),
1976 in_vitro: options.in_vitro,
1977 in_vivo: options.in_vivo,
1978 human_data: options.human_data,
1979 clinical_trial: options.clinical_trial,
1980 concentration_range: None,
1981 duration: None,
1982 age_group: None,
1983 cell_type: None,
1984 };
1985 let confidence = Confidence {
1986 kind: ConfidenceKind::FrontierEpistemic,
1987 score: options.confidence,
1988 basis: "operator-supplied frontier prior; review required".to_string(),
1989 method: ConfidenceMethod::ExpertJudgment,
1990 components: None,
1991 extraction_confidence: 1.0,
1992 };
1993 let source_authors = if options.source_authors.is_empty() {
1998 vec![Author {
1999 name: options.author.clone(),
2000 orcid: None,
2001 }]
2002 } else {
2003 options
2004 .source_authors
2005 .iter()
2006 .map(|name| Author {
2007 name: name.clone(),
2008 orcid: None,
2009 })
2010 .collect()
2011 };
2012 let provenance = Provenance {
2013 source_type: options.source_type.clone(),
2014 doi: options.doi.clone(),
2015 pmid: options.pmid.clone(),
2016 pmc: None,
2017 openalex_id: None,
2018 url: options.url.clone(),
2019 title: options.source.clone(),
2020 authors: source_authors,
2021 year: options.year,
2022 journal: options.journal.clone(),
2023 license: None,
2024 publisher: None,
2025 funders: Vec::new(),
2026 extraction: Extraction {
2027 method: "manual_curation".to_string(),
2028 model: None,
2029 model_version: None,
2030 extracted_at: now.clone(),
2031 extractor_version: project::VELA_COMPILER_VERSION.to_string(),
2032 },
2033 review: Some(Review {
2034 reviewed: false,
2035 reviewer: None,
2036 reviewed_at: None,
2037 corrections: Vec::new(),
2038 }),
2039 citation_count: None,
2040 };
2041 let flags = Flags {
2042 gap: options.gap,
2043 negative_space: options.negative_space,
2044 ..Default::default()
2045 };
2046 let finding = FindingBundle::new(
2047 assertion, evidence, conditions, confidence, provenance, flags,
2048 );
2049 let finding_id = finding.id.clone();
2050 Ok(proposals::new_proposal(
2051 "finding.add",
2052 events::StateTarget {
2053 r#type: "finding".to_string(),
2054 id: finding_id,
2055 },
2056 options.author,
2057 "human",
2058 "Manual finding added to frontier state",
2059 json!({"finding": finding}),
2060 Vec::new(),
2061 vec!["Manual findings require evidence review before scientific use.".to_string()],
2062 ))
2063}
2064
2065fn find_finding_index(frontier: &Project, finding_id: &str) -> Result<usize, String> {
2066 frontier
2067 .findings
2068 .iter()
2069 .position(|finding| finding.id == finding_id)
2070 .ok_or_else(|| format!("Finding not found: {finding_id}"))
2071}
2072
2073fn confidence_update_id(update: &crate::bundle::ConfidenceUpdate) -> String {
2074 let hash = Sha256::digest(
2075 format!(
2076 "{}|{}|{}|{}|{}",
2077 update.finding_id,
2078 update.previous_score,
2079 update.new_score,
2080 update.updated_by,
2081 update.updated_at
2082 )
2083 .as_bytes(),
2084 );
2085 format!("cu_{}", &hex::encode(hash)[..16])
2086}
2087
2088fn validate_score(score: f64) -> Result<(), String> {
2089 if (0.0..=1.0).contains(&score) {
2090 Ok(())
2091 } else {
2092 Err("--confidence must be between 0.0 and 1.0".to_string())
2093 }
2094}
2095
2096#[cfg(test)]
2097mod v0_11_finding_tests {
2098 use super::*;
2099 use crate::bundle;
2100
2101 fn base_options() -> FindingDraftOptions {
2102 FindingDraftOptions {
2103 text: "Test claim".to_string(),
2104 assertion_type: "mechanism".to_string(),
2105 source: "Test 2024".to_string(),
2106 source_type: "published_paper".to_string(),
2107 author: "reviewer:test".to_string(),
2108 confidence: 0.5,
2109 evidence_type: "experimental".to_string(),
2110 entities: Vec::new(),
2111 doi: None,
2112 pmid: None,
2113 year: None,
2114 journal: None,
2115 url: None,
2116 source_authors: Vec::new(),
2117 conditions_text: None,
2118 species: Vec::new(),
2119 in_vivo: false,
2120 in_vitro: false,
2121 human_data: false,
2122 clinical_trial: false,
2123 entities_reviewed: false,
2124 evidence_spans: Vec::new(),
2125 gap: false,
2126 negative_space: false,
2127 }
2128 }
2129
2130 #[test]
2131 fn provenance_flags_populate_structured_fields() {
2132 let mut opts = base_options();
2133 opts.doi = Some("10.1056/NEJMoa2212948".to_string());
2134 opts.pmid = Some("36449413".to_string());
2135 opts.year = Some(2023);
2136 opts.journal = Some("NEJM".to_string());
2137 opts.url = Some("https://nejm.org/...".to_string());
2138 opts.source_authors = vec!["van Dyck CH".to_string(), "Swanson CJ".to_string()];
2139 let proposal = build_add_finding_proposal(opts).unwrap();
2140 let finding: bundle::FindingBundle =
2141 serde_json::from_value(proposal.payload["finding"].clone()).unwrap();
2142 assert_eq!(
2143 finding.provenance.doi.as_deref(),
2144 Some("10.1056/NEJMoa2212948")
2145 );
2146 assert_eq!(finding.provenance.pmid.as_deref(), Some("36449413"));
2147 assert_eq!(finding.provenance.year, Some(2023));
2148 assert_eq!(finding.provenance.journal.as_deref(), Some("NEJM"));
2149 assert_eq!(
2150 finding.provenance.url.as_deref(),
2151 Some("https://nejm.org/...")
2152 );
2153 assert_eq!(
2154 finding
2155 .provenance
2156 .authors
2157 .iter()
2158 .map(|a| a.name.as_str())
2159 .collect::<Vec<_>>(),
2160 vec!["van Dyck CH", "Swanson CJ"],
2161 );
2162 }
2163
2164 #[test]
2165 fn conditions_flags_populate_structured_fields() {
2166 let mut opts = base_options();
2167 opts.conditions_text = Some("Phase 3 RCT, 18 mo".to_string());
2168 opts.species = vec!["Homo sapiens".to_string()];
2169 opts.in_vivo = true;
2170 opts.human_data = true;
2171 opts.clinical_trial = true;
2172 let proposal = build_add_finding_proposal(opts).unwrap();
2173 let finding: bundle::FindingBundle =
2174 serde_json::from_value(proposal.payload["finding"].clone()).unwrap();
2175 assert_eq!(finding.conditions.text, "Phase 3 RCT, 18 mo");
2176 assert_eq!(
2177 finding.conditions.species_verified,
2178 vec!["Homo sapiens".to_string()]
2179 );
2180 assert!(finding.conditions.in_vivo);
2181 assert!(finding.conditions.human_data);
2182 assert!(finding.conditions.clinical_trial);
2183 }
2184
2185 #[test]
2186 fn reviewed_entities_spans_and_gap_flags_populate_structured_fields() {
2187 let mut opts = base_options();
2188 opts.entities = vec![("lecanemab".to_string(), "drug".to_string())];
2189 opts.entities_reviewed = true;
2190 opts.evidence_spans = vec![json!({
2191 "section": "abstract",
2192 "text": "Lecanemab slowed decline under early symptomatic AD trial conditions."
2193 })];
2194 opts.gap = true;
2195 opts.negative_space = true;
2196
2197 let proposal = build_add_finding_proposal(opts).unwrap();
2198 let finding: bundle::FindingBundle =
2199 serde_json::from_value(proposal.payload["finding"].clone()).unwrap();
2200
2201 assert_eq!(finding.assertion.entities.len(), 1);
2202 assert!(!finding.assertion.entities[0].needs_review);
2203 assert_eq!(
2204 finding.assertion.entities[0].resolution_method,
2205 Some(bundle::ResolutionMethod::Manual)
2206 );
2207 assert_eq!(finding.evidence.evidence_spans.len(), 1);
2208 assert_eq!(
2209 finding.evidence.evidence_spans[0]["section"].as_str(),
2210 Some("abstract")
2211 );
2212 assert!(finding.flags.gap);
2213 assert!(finding.flags.negative_space);
2214 }
2215
2216 #[test]
2217 fn omitted_flags_fall_back_to_pre_v011_shape() {
2218 let proposal = build_add_finding_proposal(base_options()).unwrap();
2219 let finding: bundle::FindingBundle =
2220 serde_json::from_value(proposal.payload["finding"].clone()).unwrap();
2221 assert!(
2223 finding
2224 .conditions
2225 .text
2226 .starts_with("Manually added finding")
2227 );
2228 assert_eq!(finding.provenance.authors.len(), 1);
2230 assert_eq!(finding.provenance.authors[0].name, "reviewer:test");
2231 assert!(finding.provenance.doi.is_none());
2233 assert!(finding.provenance.year.is_none());
2234 assert!(finding.provenance.url.is_none());
2235 }
2236}
2237
2238#[cfg(test)]
2239mod v0_38_causal_tests {
2240 use super::*;
2241 use crate::bundle::{CausalClaim, CausalEvidenceGrade};
2242 use tempfile::tempdir;
2243
2244 fn seed_frontier(dir: &Path) -> std::path::PathBuf {
2245 let path = dir.join("frontier.json");
2246 let opts = FindingDraftOptions {
2247 text: "X causes Y".to_string(),
2248 assertion_type: "mechanism".to_string(),
2249 source: "test".to_string(),
2250 source_type: "published_paper".to_string(),
2251 author: "reviewer:test".to_string(),
2252 confidence: 0.5,
2253 evidence_type: "experimental".to_string(),
2254 entities: Vec::new(),
2255 doi: None,
2256 pmid: None,
2257 year: Some(2025),
2258 journal: None,
2259 url: None,
2260 source_authors: Vec::new(),
2261 conditions_text: None,
2262 species: Vec::new(),
2263 in_vivo: false,
2264 in_vitro: false,
2265 human_data: false,
2266 clinical_trial: false,
2267 entities_reviewed: false,
2268 evidence_spans: Vec::new(),
2269 gap: false,
2270 negative_space: false,
2271 };
2272 let proposal = build_add_finding_proposal(opts).unwrap();
2273 let finding: FindingBundle =
2274 serde_json::from_value(proposal.payload["finding"].clone()).unwrap();
2275 let project = project::assemble("Test", vec![finding], 1, 0, "test causal frontier");
2276 repo::save_to_path(&path, &project).unwrap();
2277 path
2278 }
2279
2280 #[test]
2281 fn set_causal_writes_fields_and_appends_event() {
2282 let dir = tempdir().unwrap();
2283 let path = seed_frontier(dir.path());
2284 let project = repo::load_from_path(&path).unwrap();
2285 let finding_id = project.findings[0].id.clone();
2286
2287 let report = set_causal(
2288 &path,
2289 &finding_id,
2290 "intervention",
2291 Some("rct"),
2292 "reviewer:test",
2293 "phase 3 RCT supports do(X=x) reading",
2294 )
2295 .unwrap();
2296 assert!(report.applied_event_id.is_some());
2297
2298 let after = repo::load_from_path(&path).unwrap();
2299 let f = &after.findings[0];
2300 assert_eq!(f.assertion.causal_claim, Some(CausalClaim::Intervention));
2301 assert_eq!(
2302 f.assertion.causal_evidence_grade,
2303 Some(CausalEvidenceGrade::Rct)
2304 );
2305
2306 let last_event = after.events.last().expect("an event was appended");
2307 assert_eq!(last_event.kind, "assertion.reinterpreted_causal");
2308 assert_eq!(last_event.target.id, finding_id);
2309 assert_eq!(last_event.payload["after"]["claim"], "intervention");
2310 assert_eq!(last_event.payload["after"]["grade"], "rct");
2311 }
2312
2313 #[test]
2314 fn set_causal_rejects_invalid_claim() {
2315 let dir = tempdir().unwrap();
2316 let path = seed_frontier(dir.path());
2317 let project = repo::load_from_path(&path).unwrap();
2318 let finding_id = project.findings[0].id.clone();
2319 let err =
2320 set_causal(&path, &finding_id, "magic", None, "reviewer:test", "test").unwrap_err();
2321 assert!(err.contains("invalid causal claim"));
2322 }
2323
2324 #[test]
2325 fn set_causal_preserves_grade_when_only_claim_changes() {
2326 let dir = tempdir().unwrap();
2327 let path = seed_frontier(dir.path());
2328 let project = repo::load_from_path(&path).unwrap();
2329 let finding_id = project.findings[0].id.clone();
2330
2331 set_causal(
2333 &path,
2334 &finding_id,
2335 "correlation",
2336 Some("observational"),
2337 "reviewer:test",
2338 "initial reading",
2339 )
2340 .unwrap();
2341 set_causal(
2343 &path,
2344 &finding_id,
2345 "mediation",
2346 None,
2347 "reviewer:test",
2348 "refined reading",
2349 )
2350 .unwrap();
2351 let after = repo::load_from_path(&path).unwrap();
2352 let f = &after.findings[0];
2353 assert_eq!(f.assertion.causal_claim, Some(CausalClaim::Mediation));
2354 assert_eq!(
2355 f.assertion.causal_evidence_grade,
2356 Some(CausalEvidenceGrade::Observational)
2357 );
2358 let causal_events: usize = after
2360 .events
2361 .iter()
2362 .filter(|e| e.kind == "assertion.reinterpreted_causal")
2363 .count();
2364 assert_eq!(causal_events, 2);
2365 }
2366}