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 repair_finding_span(
413 path: &Path,
414 finding_id: &str,
415 section: &str,
416 text: &str,
417 reviewer: &str,
418 reason: &str,
419 apply: bool,
420) -> Result<StateCommandReport, String> {
421 let frontier_view = repo::load_from_path(path)?;
422 let _ = frontier_view
423 .findings
424 .iter()
425 .find(|f| f.id == finding_id)
426 .ok_or_else(|| format!("Finding not found: {finding_id}"))?;
427 let trimmed_section = section.trim();
428 let trimmed_text = text.trim();
429 if trimmed_section.is_empty() {
430 return Err("--section must be non-empty".to_string());
431 }
432 if trimmed_text.is_empty() {
433 return Err("--text must be non-empty".to_string());
434 }
435 let proposal = proposals::new_proposal(
436 "finding.span_repair",
437 events::StateTarget {
438 r#type: "finding".to_string(),
439 id: finding_id.to_string(),
440 },
441 reviewer,
442 "human",
443 reason,
444 json!({
445 "section": trimmed_section,
446 "text": trimmed_text,
447 }),
448 Vec::new(),
449 Vec::new(),
450 );
451 let result = proposals::create_or_apply(path, proposal, apply)?;
452 Ok(StateCommandReport {
453 ok: true,
454 command: "span-repair".to_string(),
455 frontier: frontier_view.project.name,
456 finding_id: finding_id.to_string(),
457 proposal_id: result.proposal_id,
458 proposal_status: result.status,
459 applied_event_id: result.applied_event_id,
460 wrote_to: path.display().to_string(),
461 message: if apply {
462 "Span repair applied".to_string()
463 } else {
464 "Span repair proposal recorded".to_string()
465 },
466 })
467}
468
469pub fn repair_evidence_atom_locator(
476 path: &Path,
477 atom_id: &str,
478 locator_override: Option<&str>,
479 reviewer: &str,
480 reason: &str,
481 apply: bool,
482) -> Result<StateCommandReport, String> {
483 let frontier_view = repo::load_from_path(path)?;
484 let atom = frontier_view
485 .evidence_atoms
486 .iter()
487 .find(|atom| atom.id == atom_id)
488 .ok_or_else(|| format!("Evidence atom not found: {atom_id}"))?;
489 if let Some(existing) = &atom.locator {
490 return Err(format!(
491 "Evidence atom {atom_id} already carries locator '{existing}'"
492 ));
493 }
494 let source_id = atom.source_id.clone();
495 let locator = match locator_override {
496 Some(value) => {
497 let trimmed = value.trim();
498 if trimmed.is_empty() {
499 return Err("--locator value must be non-empty".to_string());
500 }
501 trimmed.to_string()
502 }
503 None => {
504 let source = frontier_view
505 .sources
506 .iter()
507 .find(|source| source.id == source_id)
508 .ok_or_else(|| {
509 format!(
510 "Cannot resolve locator for atom {atom_id}: parent source {source_id} not in frontier"
511 )
512 })?;
513 let trimmed = source.locator.trim();
514 if trimmed.is_empty() {
515 return Err(format!(
516 "Cannot resolve locator for atom {atom_id}: parent source {source_id} has an empty locator"
517 ));
518 }
519 trimmed.to_string()
520 }
521 };
522 let proposal = proposals::new_proposal(
523 "evidence_atom.locator_repair",
524 events::StateTarget {
525 r#type: "evidence_atom".to_string(),
526 id: atom_id.to_string(),
527 },
528 reviewer,
529 "human",
530 reason,
531 json!({
532 "locator": locator,
533 "source_id": source_id,
534 }),
535 Vec::new(),
536 Vec::new(),
537 );
538 let result = proposals::create_or_apply(path, proposal, apply)?;
539 Ok(StateCommandReport {
540 ok: true,
541 command: "locator-repair".to_string(),
542 frontier: frontier_view.project.name,
543 finding_id: atom_id.to_string(),
544 proposal_id: result.proposal_id,
545 proposal_status: result.status,
546 applied_event_id: result.applied_event_id,
547 wrote_to: path.display().to_string(),
548 message: if apply {
549 "Locator repair applied".to_string()
550 } else {
551 "Locator repair proposal recorded".to_string()
552 },
553 })
554}
555
556pub fn resolve_frontier_conflict(
562 path: &Path,
563 conflict_event_id: &str,
564 resolution_note: &str,
565 reviewer: &str,
566 winning_proposal_id: Option<&str>,
567 apply: bool,
568) -> Result<StateCommandReport, String> {
569 let frontier_view = repo::load_from_path(path)?;
570 let frontier_id = frontier_view.frontier_id();
571 let mut payload = json!({
572 "conflict_event_id": conflict_event_id,
573 "resolution_note": resolution_note,
574 });
575 if let Some(wpid) = winning_proposal_id {
576 payload["winning_proposal_id"] = json!(wpid);
577 }
578 let proposal = proposals::new_proposal(
579 "frontier.conflict_resolve",
580 events::StateTarget {
581 r#type: "frontier_observation".to_string(),
582 id: frontier_id,
583 },
584 reviewer,
585 "human",
586 format!("Conflict resolution: {resolution_note}"),
587 payload,
588 Vec::new(),
589 Vec::new(),
590 );
591 let result = proposals::create_or_apply(path, proposal, apply)?;
592 Ok(StateCommandReport {
593 ok: true,
594 command: "conflict-resolve".to_string(),
595 frontier: frontier_view.project.name,
596 finding_id: conflict_event_id.to_string(),
597 proposal_id: result.proposal_id,
598 proposal_status: result.status,
599 applied_event_id: result.applied_event_id,
600 wrote_to: path.display().to_string(),
601 message: if apply {
602 "Conflict resolution applied".to_string()
603 } else {
604 "Conflict resolution proposal recorded".to_string()
605 },
606 })
607}
608
609pub fn deposit_replication(
617 path: &Path,
618 rep: crate::bundle::Replication,
619 actor_id: &str,
620 reason: &str,
621) -> Result<events::StateEvent, String> {
622 let mut project = repo::load_from_path(path)?;
623 if project.replications.iter().any(|r| r.id == rep.id) {
624 return Err(format!(
625 "Replication {} already exists on this frontier; refusing duplicate deposit",
626 rep.id
627 ));
628 }
629 let rep_value =
630 serde_json::to_value(&rep).map_err(|e| format!("serialize replication: {e}"))?;
631 let payload = json!({ "replication": rep_value });
632 let timestamp = Utc::now().to_rfc3339();
633 let mut event = events::StateEvent {
634 schema: events::EVENT_SCHEMA.to_string(),
635 id: String::new(),
636 kind: "replication.deposited".to_string(),
637 target: events::StateTarget {
638 r#type: "finding".to_string(),
639 id: rep.target_finding.clone(),
640 },
641 actor: events::StateActor {
642 id: actor_id.to_string(),
643 r#type: "human".to_string(),
644 },
645 timestamp,
646 reason: reason.to_string(),
647 before_hash: NULL_HASH.to_string(),
648 after_hash: NULL_HASH.to_string(),
649 payload,
650 caveats: Vec::new(),
651 signature: None,
652 };
653 event.id = events::compute_event_id(&event);
654 project.replications.push(rep);
655 project.events.push(event.clone());
656 repo::save_to_path(path, &project)?;
657 Ok(event)
658}
659
660pub fn deposit_prediction(
664 path: &Path,
665 pred: crate::bundle::Prediction,
666 actor_id: &str,
667 reason: &str,
668) -> Result<events::StateEvent, String> {
669 let mut project = repo::load_from_path(path)?;
670 if project.predictions.iter().any(|p| p.id == pred.id) {
671 return Err(format!(
672 "Prediction {} already exists on this frontier; refusing duplicate deposit",
673 pred.id
674 ));
675 }
676 let pred_value =
677 serde_json::to_value(&pred).map_err(|e| format!("serialize prediction: {e}"))?;
678 let payload = json!({ "prediction": pred_value });
679 let timestamp = Utc::now().to_rfc3339();
680 let mut event = events::StateEvent {
681 schema: events::EVENT_SCHEMA.to_string(),
682 id: String::new(),
683 kind: "prediction.deposited".to_string(),
684 target: events::StateTarget {
685 r#type: "finding".to_string(),
686 id: pred.target_findings.first().cloned().unwrap_or_default(),
687 },
688 actor: events::StateActor {
689 id: actor_id.to_string(),
690 r#type: "human".to_string(),
691 },
692 timestamp,
693 reason: reason.to_string(),
694 before_hash: NULL_HASH.to_string(),
695 after_hash: NULL_HASH.to_string(),
696 payload,
697 caveats: Vec::new(),
698 signature: None,
699 };
700 event.id = events::compute_event_id(&event);
701 project.predictions.push(pred);
702 project.events.push(event.clone());
703 repo::save_to_path(path, &project)?;
704 Ok(event)
705}
706
707pub fn retract_finding(
708 path: &Path,
709 finding_id: &str,
710 reviewer: &str,
711 reason: &str,
712 apply: bool,
713) -> Result<StateCommandReport, String> {
714 let frontier = repo::load_from_path(path)?;
715 find_finding_index(&frontier, finding_id)?;
716 let proposal = proposals::new_proposal(
717 "finding.retract",
718 events::StateTarget {
719 r#type: "finding".to_string(),
720 id: finding_id.to_string(),
721 },
722 reviewer,
723 "human",
724 reason,
725 json!({}),
726 Vec::new(),
727 vec!["Retraction impact is simulated over declared dependency links.".to_string()],
728 );
729 let result = proposals::create_or_apply(path, proposal, apply)?;
730 Ok(StateCommandReport {
731 ok: true,
732 command: "retract".to_string(),
733 frontier: frontier.project.name,
734 finding_id: result.finding_id,
735 proposal_id: result.proposal_id,
736 proposal_status: result.status,
737 applied_event_id: result.applied_event_id,
738 wrote_to: path.display().to_string(),
739 message: if apply {
740 "Retraction proposal applied".to_string()
741 } else {
742 "Retraction proposal recorded".to_string()
743 },
744 })
745}
746
747pub fn set_causal(
755 path: &Path,
756 finding_id: &str,
757 new_claim: &str,
758 new_grade: Option<&str>,
759 actor: &str,
760 reason: &str,
761) -> Result<StateCommandReport, String> {
762 use crate::bundle::{CausalClaim, CausalEvidenceGrade};
763
764 let mut frontier: Project = repo::load_from_path(path)?;
765 let idx = frontier
766 .findings
767 .iter()
768 .position(|f| f.id == finding_id)
769 .ok_or_else(|| format!("Finding not found: {finding_id}"))?;
770
771 let before = json!({
773 "claim": frontier.findings[idx].assertion.causal_claim,
774 "grade": frontier.findings[idx].assertion.causal_evidence_grade,
775 });
776
777 let parsed_claim = match new_claim {
778 "correlation" => CausalClaim::Correlation,
779 "mediation" => CausalClaim::Mediation,
780 "intervention" => CausalClaim::Intervention,
781 other => return Err(format!("invalid causal claim '{other}'")),
782 };
783 let parsed_grade = match new_grade {
784 None => None,
785 Some("rct") => Some(CausalEvidenceGrade::Rct),
786 Some("quasi_experimental") => Some(CausalEvidenceGrade::QuasiExperimental),
787 Some("observational") => Some(CausalEvidenceGrade::Observational),
788 Some("theoretical") => Some(CausalEvidenceGrade::Theoretical),
789 Some(other) => return Err(format!("invalid causal evidence grade '{other}'")),
790 };
791
792 let before_hash = events::finding_hash(&frontier.findings[idx]);
793 frontier.findings[idx].assertion.causal_claim = Some(parsed_claim);
794 if let Some(g) = parsed_grade {
795 frontier.findings[idx].assertion.causal_evidence_grade = Some(g);
796 }
797 let after_hash = events::finding_hash(&frontier.findings[idx]);
798
799 let after = json!({
800 "claim": new_claim,
801 "grade": new_grade,
802 });
803
804 let proposal_id = format!(
806 "vpr_{}",
807 &hex::encode(Sha256::digest(
808 format!(
809 "{finding_id}|{actor}|{before_hash}|{after_hash}|{}",
810 Utc::now().to_rfc3339()
811 )
812 .as_bytes()
813 ))[..16]
814 );
815
816 let event = events::new_finding_event(events::FindingEventInput {
817 kind: "assertion.reinterpreted_causal",
818 finding_id,
819 actor_id: actor,
820 actor_type: "human",
821 reason,
822 before_hash: &before_hash,
823 after_hash: &after_hash,
824 payload: json!({
825 "proposal_id": proposal_id,
826 "before": before,
827 "after": after,
828 }),
829 caveats: Vec::new(),
830 });
831 let event_id = event.id.clone();
832 frontier.events.push(event);
833
834 repo::save_to_path(path, &frontier)?;
835
836 Ok(StateCommandReport {
837 ok: true,
838 command: "causal_set".to_string(),
839 frontier: frontier.project.name,
840 finding_id: finding_id.to_string(),
841 proposal_id,
842 proposal_status: "applied".to_string(),
843 applied_event_id: Some(event_id),
844 wrote_to: path.display().to_string(),
845 message: format!("Causal claim set to {new_claim}"),
846 })
847}
848
849pub fn add_negative_result(
869 path: &Path,
870 kind: NegativeResultKind,
871 target_findings: Vec<String>,
872 deposited_by: &str,
873 conditions: Conditions,
874 provenance: Provenance,
875 notes: &str,
876 reason: &str,
877) -> Result<StateCommandReport, String> {
878 if deposited_by.trim().is_empty() {
879 return Err("deposited_by must be a non-empty actor id".to_string());
880 }
881 if reason.trim().is_empty() {
882 return Err("reason must be non-empty".to_string());
883 }
884
885 let mut frontier: Project = repo::load_from_path(path)?;
886
887 let nr = NegativeResult::new(
888 kind,
889 target_findings,
890 deposited_by,
891 conditions,
892 provenance,
893 notes,
894 );
895 let nr_id = nr.id.clone();
896
897 if frontier.negative_results.iter().any(|n| n.id == nr_id) {
898 return Err(format!(
899 "Refusing to add duplicate negative_result with existing id {nr_id}"
900 ));
901 }
902
903 let proposal_id = format!(
904 "vpr_{}",
905 &hex::encode(Sha256::digest(
906 format!("{nr_id}|{deposited_by}|{}", Utc::now().to_rfc3339()).as_bytes()
907 ))[..16]
908 );
909
910 let nr_value = serde_json::to_value(&nr)
911 .map_err(|e| format!("failed to serialize negative_result: {e}"))?;
912
913 let mut event = StateEvent {
914 schema: events::EVENT_SCHEMA.to_string(),
915 id: String::new(),
916 kind: events::EVENT_KIND_NEGATIVE_RESULT_ASSERTED.to_string(),
917 target: StateTarget {
918 r#type: "negative_result".to_string(),
919 id: nr_id.clone(),
920 },
921 actor: StateActor {
922 id: deposited_by.to_string(),
923 r#type: "human".to_string(),
924 },
925 timestamp: Utc::now().to_rfc3339(),
926 reason: reason.to_string(),
927 before_hash: NULL_HASH.to_string(),
928 after_hash: NULL_HASH.to_string(),
929 payload: json!({
930 "proposal_id": proposal_id,
931 "negative_result": nr_value,
932 }),
933 caveats: Vec::new(),
934 signature: None,
935 };
936 event.id = events::compute_event_id(&event);
937 let event_id = event.id.clone();
938
939 events::validate_event_payload(&event.kind, &event.payload)?;
942 reducer::apply_event(&mut frontier, &event)?;
943 frontier.events.push(event);
944
945 repo::save_to_path(path, &frontier)?;
946
947 Ok(StateCommandReport {
948 ok: true,
949 command: "negative_result.add".to_string(),
950 frontier: frontier.project.name,
951 finding_id: nr_id,
952 proposal_id,
953 proposal_status: "applied".to_string(),
954 applied_event_id: Some(event_id),
955 wrote_to: path.display().to_string(),
956 message: "NegativeResult deposited".to_string(),
957 })
958}
959
960pub fn add_artifact(
965 path: &Path,
966 artifact: Artifact,
967 deposited_by: &str,
968 reason: &str,
969) -> Result<StateCommandReport, String> {
970 if deposited_by.trim().is_empty() {
971 return Err("deposited_by must be a non-empty actor id".to_string());
972 }
973 if reason.trim().is_empty() {
974 return Err("reason must be non-empty".to_string());
975 }
976
977 let mut frontier: Project = repo::load_from_path(path)?;
978 let artifact_id = artifact.id.clone();
979
980 if frontier.artifacts.iter().any(|a| a.id == artifact_id) {
981 return Err(format!(
982 "Refusing to add duplicate artifact with existing id {artifact_id}"
983 ));
984 }
985
986 let proposal_id = format!(
987 "vpr_{}",
988 &hex::encode(Sha256::digest(
989 format!("{artifact_id}|{deposited_by}|{}", Utc::now().to_rfc3339()).as_bytes()
990 ))[..16]
991 );
992
993 let artifact_value = serde_json::to_value(&artifact)
994 .map_err(|e| format!("failed to serialize artifact: {e}"))?;
995
996 let mut event = StateEvent {
997 schema: events::EVENT_SCHEMA.to_string(),
998 id: String::new(),
999 kind: events::EVENT_KIND_ARTIFACT_ASSERTED.to_string(),
1000 target: StateTarget {
1001 r#type: "artifact".to_string(),
1002 id: artifact_id.clone(),
1003 },
1004 actor: StateActor {
1005 id: deposited_by.to_string(),
1006 r#type: "human".to_string(),
1007 },
1008 timestamp: Utc::now().to_rfc3339(),
1009 reason: reason.to_string(),
1010 before_hash: NULL_HASH.to_string(),
1011 after_hash: NULL_HASH.to_string(),
1012 payload: json!({
1013 "proposal_id": proposal_id,
1014 "artifact": artifact_value,
1015 }),
1016 caveats: Vec::new(),
1017 signature: None,
1018 };
1019 event.id = events::compute_event_id(&event);
1020 let event_id = event.id.clone();
1021
1022 events::validate_event_payload(&event.kind, &event.payload)?;
1023 reducer::apply_event(&mut frontier, &event)?;
1024 frontier.events.push(event);
1025
1026 repo::save_to_path(path, &frontier)?;
1027
1028 Ok(StateCommandReport {
1029 ok: true,
1030 command: "artifact.add".to_string(),
1031 frontier: frontier.project.name,
1032 finding_id: artifact_id,
1033 proposal_id,
1034 proposal_status: "applied".to_string(),
1035 applied_event_id: Some(event_id),
1036 wrote_to: path.display().to_string(),
1037 message: "Artifact deposited".to_string(),
1038 })
1039}
1040
1041pub fn create_trajectory(
1050 path: &Path,
1051 target_findings: Vec<String>,
1052 deposited_by: &str,
1053 notes: &str,
1054 reason: &str,
1055) -> Result<StateCommandReport, String> {
1056 if deposited_by.trim().is_empty() {
1057 return Err("deposited_by must be a non-empty actor id".to_string());
1058 }
1059 if reason.trim().is_empty() {
1060 return Err("reason must be non-empty".to_string());
1061 }
1062
1063 let mut frontier: Project = repo::load_from_path(path)?;
1064
1065 let traj = Trajectory::new(target_findings, deposited_by, notes);
1066 let traj_id = traj.id.clone();
1067
1068 if frontier.trajectories.iter().any(|t| t.id == traj_id) {
1069 return Err(format!(
1070 "Refusing to create duplicate trajectory with existing id {traj_id}"
1071 ));
1072 }
1073
1074 let proposal_id = format!(
1075 "vpr_{}",
1076 &hex::encode(Sha256::digest(
1077 format!("{traj_id}|{deposited_by}|{}", Utc::now().to_rfc3339()).as_bytes()
1078 ))[..16]
1079 );
1080
1081 let traj_value =
1082 serde_json::to_value(&traj).map_err(|e| format!("failed to serialize trajectory: {e}"))?;
1083
1084 let mut event = StateEvent {
1085 schema: events::EVENT_SCHEMA.to_string(),
1086 id: String::new(),
1087 kind: events::EVENT_KIND_TRAJECTORY_CREATED.to_string(),
1088 target: StateTarget {
1089 r#type: "trajectory".to_string(),
1090 id: traj_id.clone(),
1091 },
1092 actor: StateActor {
1093 id: deposited_by.to_string(),
1094 r#type: "human".to_string(),
1095 },
1096 timestamp: Utc::now().to_rfc3339(),
1097 reason: reason.to_string(),
1098 before_hash: NULL_HASH.to_string(),
1099 after_hash: NULL_HASH.to_string(),
1100 payload: json!({
1101 "proposal_id": proposal_id,
1102 "trajectory": traj_value,
1103 }),
1104 caveats: Vec::new(),
1105 signature: None,
1106 };
1107 event.id = events::compute_event_id(&event);
1108 let event_id = event.id.clone();
1109
1110 events::validate_event_payload(&event.kind, &event.payload)?;
1111 reducer::apply_event(&mut frontier, &event)?;
1112 frontier.events.push(event);
1113
1114 repo::save_to_path(path, &frontier)?;
1115
1116 Ok(StateCommandReport {
1117 ok: true,
1118 command: "trajectory.create".to_string(),
1119 frontier: frontier.project.name,
1120 finding_id: traj_id,
1121 proposal_id,
1122 proposal_status: "applied".to_string(),
1123 applied_event_id: Some(event_id),
1124 wrote_to: path.display().to_string(),
1125 message: "Trajectory opened".to_string(),
1126 })
1127}
1128
1129pub fn append_trajectory_step(
1134 path: &Path,
1135 trajectory_id: &str,
1136 kind: TrajectoryStepKind,
1137 description: &str,
1138 actor: &str,
1139 references: Vec<String>,
1140 reason: &str,
1141) -> Result<StateCommandReport, String> {
1142 if actor.trim().is_empty() {
1143 return Err("actor must be a non-empty id".to_string());
1144 }
1145 if description.trim().is_empty() {
1146 return Err("description must be non-empty".to_string());
1147 }
1148 if reason.trim().is_empty() {
1149 return Err("reason must be non-empty".to_string());
1150 }
1151
1152 let mut frontier: Project = repo::load_from_path(path)?;
1153 if !frontier.trajectories.iter().any(|t| t.id == trajectory_id) {
1154 return Err(format!("Trajectory not found: {trajectory_id}"));
1155 }
1156
1157 let step = TrajectoryStep::new(trajectory_id, kind, description, actor, None, references);
1158 let step_id = step.id.clone();
1159
1160 let proposal_id = format!(
1161 "vpr_{}",
1162 &hex::encode(Sha256::digest(
1163 format!("{trajectory_id}|{step_id}|{actor}").as_bytes()
1164 ))[..16]
1165 );
1166
1167 let step_value = serde_json::to_value(&step)
1168 .map_err(|e| format!("failed to serialize trajectory step: {e}"))?;
1169
1170 let mut event = StateEvent {
1171 schema: events::EVENT_SCHEMA.to_string(),
1172 id: String::new(),
1173 kind: events::EVENT_KIND_TRAJECTORY_STEP_APPENDED.to_string(),
1174 target: StateTarget {
1175 r#type: "trajectory".to_string(),
1176 id: trajectory_id.to_string(),
1177 },
1178 actor: StateActor {
1179 id: actor.to_string(),
1180 r#type: "human".to_string(),
1181 },
1182 timestamp: Utc::now().to_rfc3339(),
1183 reason: reason.to_string(),
1184 before_hash: NULL_HASH.to_string(),
1185 after_hash: NULL_HASH.to_string(),
1186 payload: json!({
1187 "proposal_id": proposal_id,
1188 "parent_trajectory_id": trajectory_id,
1189 "step": step_value,
1190 }),
1191 caveats: Vec::new(),
1192 signature: None,
1193 };
1194 event.id = events::compute_event_id(&event);
1195 let event_id = event.id.clone();
1196
1197 events::validate_event_payload(&event.kind, &event.payload)?;
1198 reducer::apply_event(&mut frontier, &event)?;
1199 frontier.events.push(event);
1200
1201 repo::save_to_path(path, &frontier)?;
1202
1203 Ok(StateCommandReport {
1204 ok: true,
1205 command: "trajectory.step_append".to_string(),
1206 frontier: frontier.project.name,
1207 finding_id: step_id,
1208 proposal_id,
1209 proposal_status: "applied".to_string(),
1210 applied_event_id: Some(event_id),
1211 wrote_to: path.display().to_string(),
1212 message: "Trajectory step appended".to_string(),
1213 })
1214}
1215
1216pub fn set_tier(
1226 path: &Path,
1227 object_type: &str,
1228 object_id: &str,
1229 new_tier: crate::access_tier::AccessTier,
1230 actor: &str,
1231 reason: &str,
1232) -> Result<StateCommandReport, String> {
1233 if actor.trim().is_empty() {
1234 return Err("actor must be a non-empty id".to_string());
1235 }
1236 if reason.trim().is_empty() {
1237 return Err("reason must be non-empty".to_string());
1238 }
1239 if !matches!(
1240 object_type,
1241 "finding" | "negative_result" | "trajectory" | "artifact"
1242 ) {
1243 return Err(format!(
1244 "object_type '{object_type}' must be one of finding, negative_result, trajectory, artifact"
1245 ));
1246 }
1247
1248 let mut frontier: Project = repo::load_from_path(path)?;
1249
1250 let previous_tier = match object_type {
1251 "finding" => {
1252 frontier
1253 .findings
1254 .iter()
1255 .find(|f| f.id == object_id)
1256 .ok_or_else(|| format!("Finding not found: {object_id}"))?
1257 .access_tier
1258 }
1259 "negative_result" => {
1260 frontier
1261 .negative_results
1262 .iter()
1263 .find(|n| n.id == object_id)
1264 .ok_or_else(|| format!("NegativeResult not found: {object_id}"))?
1265 .access_tier
1266 }
1267 "trajectory" => {
1268 frontier
1269 .trajectories
1270 .iter()
1271 .find(|t| t.id == object_id)
1272 .ok_or_else(|| format!("Trajectory not found: {object_id}"))?
1273 .access_tier
1274 }
1275 "artifact" => {
1276 frontier
1277 .artifacts
1278 .iter()
1279 .find(|a| a.id == object_id)
1280 .ok_or_else(|| format!("Artifact not found: {object_id}"))?
1281 .access_tier
1282 }
1283 _ => unreachable!("validated above"),
1284 };
1285
1286 let proposal_id = format!(
1287 "vpr_{}",
1288 &hex::encode(Sha256::digest(
1289 format!(
1290 "{object_type}|{object_id}|{actor}|{}|{}",
1291 new_tier.canonical(),
1292 Utc::now().to_rfc3339()
1293 )
1294 .as_bytes()
1295 ))[..16]
1296 );
1297
1298 let mut event = StateEvent {
1299 schema: events::EVENT_SCHEMA.to_string(),
1300 id: String::new(),
1301 kind: events::EVENT_KIND_TIER_SET.to_string(),
1302 target: StateTarget {
1303 r#type: object_type.to_string(),
1304 id: object_id.to_string(),
1305 },
1306 actor: StateActor {
1307 id: actor.to_string(),
1308 r#type: "human".to_string(),
1309 },
1310 timestamp: Utc::now().to_rfc3339(),
1311 reason: reason.to_string(),
1312 before_hash: NULL_HASH.to_string(),
1313 after_hash: NULL_HASH.to_string(),
1314 payload: json!({
1315 "proposal_id": proposal_id,
1316 "object_type": object_type,
1317 "object_id": object_id,
1318 "previous_tier": previous_tier.canonical(),
1319 "new_tier": new_tier.canonical(),
1320 }),
1321 caveats: Vec::new(),
1322 signature: None,
1323 };
1324 event.id = events::compute_event_id(&event);
1325 let event_id = event.id.clone();
1326
1327 events::validate_event_payload(&event.kind, &event.payload)?;
1328 reducer::apply_event(&mut frontier, &event)?;
1329 frontier.events.push(event);
1330
1331 repo::save_to_path(path, &frontier)?;
1332
1333 Ok(StateCommandReport {
1334 ok: true,
1335 command: "tier.set".to_string(),
1336 frontier: frontier.project.name,
1337 finding_id: object_id.to_string(),
1338 proposal_id,
1339 proposal_status: "applied".to_string(),
1340 applied_event_id: Some(event_id),
1341 wrote_to: path.display().to_string(),
1342 message: format!("Tier set to {} on {object_type}", new_tier.canonical()),
1343 })
1344}
1345
1346pub fn history(path: &Path, finding_id: &str) -> Result<Value, String> {
1347 history_as_of(path, finding_id, None)
1348}
1349
1350pub fn history_as_of(path: &Path, finding_id: &str, as_of: Option<&str>) -> Result<Value, String> {
1358 let frontier = repo::load_from_path(path)?;
1359 let context = finding_context(&frontier, finding_id)?;
1360 let finding = context
1361 .get("finding")
1362 .ok_or_else(|| format!("Finding not found: {finding_id}"))?;
1363
1364 let cutoff = as_of.map(|s| s.to_string());
1365 let filter_by_ts = |arr: Option<&Value>, ts_field: &str| -> Value {
1366 let Some(v) = arr else {
1367 return Value::Array(Vec::new());
1368 };
1369 let Some(items) = v.as_array() else {
1370 return Value::Array(Vec::new());
1371 };
1372 match &cutoff {
1373 None => Value::Array(items.clone()),
1374 Some(c) => Value::Array(
1375 items
1376 .iter()
1377 .filter(|item| {
1378 item.get(ts_field)
1379 .and_then(Value::as_str)
1380 .map(|t| t <= c.as_str())
1381 .unwrap_or(true)
1382 })
1383 .cloned()
1384 .collect(),
1385 ),
1386 }
1387 };
1388
1389 let events_filtered = filter_by_ts(context.get("events"), "timestamp");
1390 let review_events_filtered = filter_by_ts(context.get("review_events"), "reviewed_at");
1391 let confidence_updates_filtered = filter_by_ts(context.get("confidence_updates"), "updated_at");
1392
1393 let score_at = if let Some(arr) = confidence_updates_filtered.as_array() {
1397 let mut sorted: Vec<&Value> = arr.iter().collect();
1398 sorted.sort_by(|a, b| {
1399 let ta = a.get("updated_at").and_then(Value::as_str).unwrap_or("");
1400 let tb = b.get("updated_at").and_then(Value::as_str).unwrap_or("");
1401 ta.cmp(tb)
1402 });
1403 sorted
1404 .last()
1405 .and_then(|u| u.get("new_score"))
1406 .cloned()
1407 .unwrap_or_else(|| {
1408 finding
1409 .pointer("/confidence/score")
1410 .cloned()
1411 .unwrap_or(Value::Null)
1412 })
1413 } else {
1414 finding
1415 .pointer("/confidence/score")
1416 .cloned()
1417 .unwrap_or(Value::Null)
1418 };
1419
1420 Ok(json!({
1421 "ok": true,
1422 "command": "history",
1423 "frontier": frontier.project.name,
1424 "as_of": cutoff,
1425 "finding": {
1426 "id": finding.get("id"),
1427 "assertion": finding.pointer("/assertion/text"),
1428 "confidence": finding.pointer("/confidence/score"),
1429 "flags": finding.get("flags"),
1430 "annotations": finding.get("annotations"),
1431 },
1432 "replayed_at_score": score_at,
1433 "review_events": review_events_filtered,
1434 "confidence_updates": confidence_updates_filtered,
1435 "sources": context.get("sources"),
1436 "evidence_atoms": context.get("evidence_atoms"),
1437 "condition_records": context.get("condition_records"),
1438 "proposals": context.get("proposals"),
1439 "events": events_filtered,
1440 "proof_state": frontier.proof_state,
1441 }))
1442}
1443
1444pub fn finding_context(frontier: &Project, finding_id: &str) -> Result<Value, String> {
1445 let finding = frontier
1446 .findings
1447 .iter()
1448 .find(|finding| finding.id == finding_id)
1449 .ok_or_else(|| format!("Finding not found: {finding_id}"))?;
1450 let reviews = frontier
1451 .review_events
1452 .iter()
1453 .filter(|event| event.finding_id == finding_id)
1454 .collect::<Vec<_>>();
1455 let confidence_updates = frontier
1456 .confidence_updates
1457 .iter()
1458 .filter(|update| update.finding_id == finding_id)
1459 .collect::<Vec<_>>();
1460 let source_records = frontier
1461 .sources
1462 .iter()
1463 .filter(|source| source.finding_ids.iter().any(|id| id == finding_id))
1464 .collect::<Vec<_>>();
1465 let evidence_atoms = frontier
1466 .evidence_atoms
1467 .iter()
1468 .filter(|atom| atom.finding_id == finding_id)
1469 .collect::<Vec<_>>();
1470 let condition_records = frontier
1471 .condition_records
1472 .iter()
1473 .filter(|record| record.finding_id == finding_id)
1474 .collect::<Vec<_>>();
1475 Ok(json!({
1476 "finding": finding,
1477 "review_events": reviews,
1478 "confidence_updates": confidence_updates,
1479 "sources": source_records,
1480 "evidence_atoms": evidence_atoms,
1481 "condition_records": condition_records,
1482 "proposals": proposals::proposals_for_finding(frontier, finding_id),
1483 "events": events::events_for_finding(frontier, finding_id),
1484 "proof_state": frontier.proof_state,
1485 }))
1486}
1487
1488pub fn state_transitions(frontier: &Project) -> Value {
1489 let mut transitions = Vec::new();
1490 if !frontier.events.is_empty() {
1491 for event in &frontier.events {
1492 transitions.push(json!({
1493 "kind": event.kind,
1494 "id": event.id,
1495 "target": event.target,
1496 "actor": event.actor,
1497 "timestamp": event.timestamp,
1498 "reason": event.reason,
1499 "before_hash": event.before_hash,
1500 "after_hash": event.after_hash,
1501 "payload": event.payload,
1502 "caveats": event.caveats,
1503 }));
1504 }
1505 transitions.sort_by(|a, b| {
1506 a.get("timestamp")
1507 .and_then(Value::as_str)
1508 .cmp(&b.get("timestamp").and_then(Value::as_str))
1509 });
1510 return json!({
1511 "schema": "vela.state-transitions.v1",
1512 "frontier": frontier.project.name,
1513 "source": "canonical_events",
1514 "transitions": transitions,
1515 });
1516 }
1517 for event in &frontier.review_events {
1518 transitions.push(json!({
1519 "kind": "review_event",
1520 "id": event.id,
1521 "target": {"type": "finding", "id": event.finding_id},
1522 "actor": event.reviewer,
1523 "timestamp": event.reviewed_at,
1524 "action": event.action,
1525 "reason": event.reason,
1526 "state_change": event.state_change,
1527 }));
1528 }
1529 for update in &frontier.confidence_updates {
1530 transitions.push(json!({
1531 "kind": "confidence_update",
1532 "id": confidence_update_id(update),
1533 "target": {"type": "finding", "id": update.finding_id},
1534 "actor": update.updated_by,
1535 "timestamp": update.updated_at,
1536 "action": "confidence_revised",
1537 "reason": update.basis,
1538 "state_change": {
1539 "previous_score": update.previous_score,
1540 "new_score": update.new_score,
1541 },
1542 }));
1543 }
1544 transitions.sort_by(|a, b| {
1545 a.get("timestamp")
1546 .and_then(Value::as_str)
1547 .cmp(&b.get("timestamp").and_then(Value::as_str))
1548 });
1549 json!({
1550 "schema": "vela.state-transitions.v0",
1551 "frontier": frontier.project.name,
1552 "transitions": transitions,
1553 })
1554}
1555
1556fn build_finding_bundle(options: &FindingDraftOptions) -> FindingBundle {
1559 let now = Utc::now().to_rfc3339();
1560 let assertion = Assertion {
1561 text: options.text.clone(),
1562 assertion_type: options.assertion_type.clone(),
1563 entities: options
1564 .entities
1565 .iter()
1566 .map(|(name, entity_type)| Entity {
1567 name: name.clone(),
1568 entity_type: entity_type.clone(),
1569 identifiers: serde_json::Map::new(),
1570 canonical_id: None,
1571 candidates: Vec::new(),
1572 aliases: Vec::new(),
1573 resolution_provenance: Some("manual_state_transition".to_string()),
1574 resolution_confidence: if options.entities_reviewed { 0.95 } else { 0.6 },
1575 resolution_method: if options.entities_reviewed {
1576 Some(ResolutionMethod::Manual)
1577 } else {
1578 None
1579 },
1580 species_context: None,
1581 needs_review: !options.entities_reviewed,
1582 })
1583 .collect(),
1584 relation: None,
1585 direction: None,
1586 causal_claim: None,
1587 causal_evidence_grade: None,
1588 };
1589 let evidence = Evidence {
1590 evidence_type: options.evidence_type.clone(),
1591 model_system: String::new(),
1592 species: options
1593 .species
1594 .first()
1595 .cloned()
1596 .or_else(|| options.human_data.then(|| "Homo sapiens".to_string())),
1597 method: if options.clinical_trial {
1598 "manual state transition; placebo-controlled clinical trial where source reports control arm"
1599 .to_string()
1600 } else if options.evidence_type == "experimental" {
1601 "manual state transition; control details require source inspection".to_string()
1602 } else {
1603 "manual state transition".to_string()
1604 },
1605 sample_size: None,
1606 effect_size: None,
1607 p_value: None,
1608 replicated: false,
1609 replication_count: None,
1610 evidence_spans: options.evidence_spans.clone(),
1611 };
1612 let conditions = Conditions {
1613 text: options.conditions_text.clone().unwrap_or_else(|| {
1614 "Manually added finding; requires evidence review before scientific use.".to_string()
1615 }),
1616 species_verified: options.species.clone(),
1617 species_unverified: Vec::new(),
1618 in_vitro: options.in_vitro,
1619 in_vivo: options.in_vivo,
1620 human_data: options.human_data,
1621 clinical_trial: options.clinical_trial,
1622 concentration_range: None,
1623 duration: None,
1624 age_group: None,
1625 cell_type: None,
1626 };
1627 let confidence = Confidence {
1628 kind: ConfidenceKind::FrontierEpistemic,
1629 score: options.confidence,
1630 basis: "operator-supplied frontier prior; review required".to_string(),
1631 method: ConfidenceMethod::ExpertJudgment,
1632 components: None,
1633 extraction_confidence: 1.0,
1634 };
1635 let source_authors = if options.source_authors.is_empty() {
1636 vec![Author {
1637 name: options.author.clone(),
1638 orcid: None,
1639 }]
1640 } else {
1641 options
1642 .source_authors
1643 .iter()
1644 .map(|name| Author {
1645 name: name.clone(),
1646 orcid: None,
1647 })
1648 .collect()
1649 };
1650 let provenance = Provenance {
1651 source_type: options.source_type.clone(),
1652 doi: options.doi.clone(),
1653 pmid: options.pmid.clone(),
1654 pmc: None,
1655 openalex_id: None,
1656 url: options.url.clone(),
1657 title: options.source.clone(),
1658 authors: source_authors,
1659 year: options.year,
1660 journal: options.journal.clone(),
1661 license: None,
1662 publisher: None,
1663 funders: Vec::new(),
1664 extraction: Extraction {
1665 method: "manual_curation".to_string(),
1666 model: None,
1667 model_version: None,
1668 extracted_at: now,
1669 extractor_version: project::VELA_COMPILER_VERSION.to_string(),
1670 },
1671 review: Some(Review {
1672 reviewed: false,
1673 reviewer: None,
1674 reviewed_at: None,
1675 corrections: Vec::new(),
1676 }),
1677 citation_count: None,
1678 };
1679 let flags = Flags {
1680 gap: options.gap,
1681 negative_space: options.negative_space,
1682 ..Default::default()
1683 };
1684 FindingBundle::new(
1685 assertion, evidence, conditions, confidence, provenance, flags,
1686 )
1687}
1688
1689pub fn supersede_finding(
1691 path: &Path,
1692 old_id: &str,
1693 reason: &str,
1694 options: FindingDraftOptions,
1695 apply: bool,
1696) -> Result<StateCommandReport, String> {
1697 validate_score(options.confidence)?;
1698 if reason.trim().is_empty() {
1699 return Err("--reason is required for finding supersede".to_string());
1700 }
1701 let new_finding = build_finding_bundle(&options);
1702 if new_finding.id == old_id {
1703 return Err(
1704 "supersede new assertion must produce a different content address than the old finding (change assertion text, type, or provenance to derive a distinct vf_…)"
1705 .to_string(),
1706 );
1707 }
1708 let proposal = proposals::new_proposal(
1709 "finding.supersede",
1710 events::StateTarget {
1711 r#type: "finding".to_string(),
1712 id: old_id.to_string(),
1713 },
1714 options.author.clone(),
1715 "human",
1716 reason.to_string(),
1717 json!({"new_finding": new_finding}),
1718 Vec::new(),
1719 Vec::new(),
1720 );
1721 let result = proposals::create_or_apply(path, proposal, apply)?;
1722 let frontier = repo::load_from_path(path)?;
1723 Ok(StateCommandReport {
1724 ok: true,
1725 command: "finding.supersede".to_string(),
1726 frontier: frontier.project.name,
1727 finding_id: result.finding_id,
1728 proposal_id: result.proposal_id,
1729 proposal_status: result.status.clone(),
1730 applied_event_id: result.applied_event_id,
1731 wrote_to: path.display().to_string(),
1732 message: if result.status == "applied" {
1733 "Supersede proposal applied".to_string()
1734 } else {
1735 "Supersede proposal recorded".to_string()
1736 },
1737 })
1738}
1739
1740fn build_add_finding_proposal(options: FindingDraftOptions) -> Result<StateProposal, String> {
1741 let now = Utc::now().to_rfc3339();
1742 let assertion = Assertion {
1743 text: options.text.clone(),
1744 assertion_type: options.assertion_type.clone(),
1745 entities: options
1746 .entities
1747 .iter()
1748 .map(|(name, entity_type)| Entity {
1749 name: name.clone(),
1750 entity_type: entity_type.clone(),
1751 identifiers: serde_json::Map::new(),
1752 canonical_id: None,
1753 candidates: Vec::new(),
1754 aliases: Vec::new(),
1755 resolution_provenance: Some("manual_state_transition".to_string()),
1756 resolution_confidence: if options.entities_reviewed { 0.95 } else { 0.6 },
1757 resolution_method: if options.entities_reviewed {
1758 Some(ResolutionMethod::Manual)
1759 } else {
1760 None
1761 },
1762 species_context: None,
1763 needs_review: !options.entities_reviewed,
1764 })
1765 .collect(),
1766 relation: None,
1767 direction: None,
1768 causal_claim: None,
1769 causal_evidence_grade: None,
1770 };
1771 let evidence = Evidence {
1772 evidence_type: options.evidence_type.clone(),
1773 model_system: String::new(),
1774 species: options
1775 .species
1776 .first()
1777 .cloned()
1778 .or_else(|| options.human_data.then(|| "Homo sapiens".to_string())),
1779 method: if options.clinical_trial {
1780 "manual state transition; placebo-controlled clinical trial where source reports control arm"
1781 .to_string()
1782 } else if options.evidence_type == "experimental" {
1783 "manual state transition; control details require source inspection".to_string()
1784 } else {
1785 "manual state transition".to_string()
1786 },
1787 sample_size: None,
1788 effect_size: None,
1789 p_value: None,
1790 replicated: false,
1791 replication_count: None,
1792 evidence_spans: options.evidence_spans.clone(),
1793 };
1794 let conditions = Conditions {
1799 text: options.conditions_text.clone().unwrap_or_else(|| {
1800 "Manually added finding; requires evidence review before scientific use.".to_string()
1801 }),
1802 species_verified: options.species.clone(),
1803 species_unverified: Vec::new(),
1804 in_vitro: options.in_vitro,
1805 in_vivo: options.in_vivo,
1806 human_data: options.human_data,
1807 clinical_trial: options.clinical_trial,
1808 concentration_range: None,
1809 duration: None,
1810 age_group: None,
1811 cell_type: None,
1812 };
1813 let confidence = Confidence {
1814 kind: ConfidenceKind::FrontierEpistemic,
1815 score: options.confidence,
1816 basis: "operator-supplied frontier prior; review required".to_string(),
1817 method: ConfidenceMethod::ExpertJudgment,
1818 components: None,
1819 extraction_confidence: 1.0,
1820 };
1821 let source_authors = if options.source_authors.is_empty() {
1826 vec![Author {
1827 name: options.author.clone(),
1828 orcid: None,
1829 }]
1830 } else {
1831 options
1832 .source_authors
1833 .iter()
1834 .map(|name| Author {
1835 name: name.clone(),
1836 orcid: None,
1837 })
1838 .collect()
1839 };
1840 let provenance = Provenance {
1841 source_type: options.source_type.clone(),
1842 doi: options.doi.clone(),
1843 pmid: options.pmid.clone(),
1844 pmc: None,
1845 openalex_id: None,
1846 url: options.url.clone(),
1847 title: options.source.clone(),
1848 authors: source_authors,
1849 year: options.year,
1850 journal: options.journal.clone(),
1851 license: None,
1852 publisher: None,
1853 funders: Vec::new(),
1854 extraction: Extraction {
1855 method: "manual_curation".to_string(),
1856 model: None,
1857 model_version: None,
1858 extracted_at: now.clone(),
1859 extractor_version: project::VELA_COMPILER_VERSION.to_string(),
1860 },
1861 review: Some(Review {
1862 reviewed: false,
1863 reviewer: None,
1864 reviewed_at: None,
1865 corrections: Vec::new(),
1866 }),
1867 citation_count: None,
1868 };
1869 let flags = Flags {
1870 gap: options.gap,
1871 negative_space: options.negative_space,
1872 ..Default::default()
1873 };
1874 let finding = FindingBundle::new(
1875 assertion, evidence, conditions, confidence, provenance, flags,
1876 );
1877 let finding_id = finding.id.clone();
1878 Ok(proposals::new_proposal(
1879 "finding.add",
1880 events::StateTarget {
1881 r#type: "finding".to_string(),
1882 id: finding_id,
1883 },
1884 options.author,
1885 "human",
1886 "Manual finding added to frontier state",
1887 json!({"finding": finding}),
1888 Vec::new(),
1889 vec!["Manual findings require evidence review before scientific use.".to_string()],
1890 ))
1891}
1892
1893fn find_finding_index(frontier: &Project, finding_id: &str) -> Result<usize, String> {
1894 frontier
1895 .findings
1896 .iter()
1897 .position(|finding| finding.id == finding_id)
1898 .ok_or_else(|| format!("Finding not found: {finding_id}"))
1899}
1900
1901fn confidence_update_id(update: &crate::bundle::ConfidenceUpdate) -> String {
1902 let hash = Sha256::digest(
1903 format!(
1904 "{}|{}|{}|{}|{}",
1905 update.finding_id,
1906 update.previous_score,
1907 update.new_score,
1908 update.updated_by,
1909 update.updated_at
1910 )
1911 .as_bytes(),
1912 );
1913 format!("cu_{}", &hex::encode(hash)[..16])
1914}
1915
1916fn validate_score(score: f64) -> Result<(), String> {
1917 if (0.0..=1.0).contains(&score) {
1918 Ok(())
1919 } else {
1920 Err("--confidence must be between 0.0 and 1.0".to_string())
1921 }
1922}
1923
1924#[cfg(test)]
1925mod v0_11_finding_tests {
1926 use super::*;
1927 use crate::bundle;
1928
1929 fn base_options() -> FindingDraftOptions {
1930 FindingDraftOptions {
1931 text: "Test claim".to_string(),
1932 assertion_type: "mechanism".to_string(),
1933 source: "Test 2024".to_string(),
1934 source_type: "published_paper".to_string(),
1935 author: "reviewer:test".to_string(),
1936 confidence: 0.5,
1937 evidence_type: "experimental".to_string(),
1938 entities: Vec::new(),
1939 doi: None,
1940 pmid: None,
1941 year: None,
1942 journal: None,
1943 url: None,
1944 source_authors: Vec::new(),
1945 conditions_text: None,
1946 species: Vec::new(),
1947 in_vivo: false,
1948 in_vitro: false,
1949 human_data: false,
1950 clinical_trial: false,
1951 entities_reviewed: false,
1952 evidence_spans: Vec::new(),
1953 gap: false,
1954 negative_space: false,
1955 }
1956 }
1957
1958 #[test]
1959 fn provenance_flags_populate_structured_fields() {
1960 let mut opts = base_options();
1961 opts.doi = Some("10.1056/NEJMoa2212948".to_string());
1962 opts.pmid = Some("36449413".to_string());
1963 opts.year = Some(2023);
1964 opts.journal = Some("NEJM".to_string());
1965 opts.url = Some("https://nejm.org/...".to_string());
1966 opts.source_authors = vec!["van Dyck CH".to_string(), "Swanson CJ".to_string()];
1967 let proposal = build_add_finding_proposal(opts).unwrap();
1968 let finding: bundle::FindingBundle =
1969 serde_json::from_value(proposal.payload["finding"].clone()).unwrap();
1970 assert_eq!(
1971 finding.provenance.doi.as_deref(),
1972 Some("10.1056/NEJMoa2212948")
1973 );
1974 assert_eq!(finding.provenance.pmid.as_deref(), Some("36449413"));
1975 assert_eq!(finding.provenance.year, Some(2023));
1976 assert_eq!(finding.provenance.journal.as_deref(), Some("NEJM"));
1977 assert_eq!(
1978 finding.provenance.url.as_deref(),
1979 Some("https://nejm.org/...")
1980 );
1981 assert_eq!(
1982 finding
1983 .provenance
1984 .authors
1985 .iter()
1986 .map(|a| a.name.as_str())
1987 .collect::<Vec<_>>(),
1988 vec!["van Dyck CH", "Swanson CJ"],
1989 );
1990 }
1991
1992 #[test]
1993 fn conditions_flags_populate_structured_fields() {
1994 let mut opts = base_options();
1995 opts.conditions_text = Some("Phase 3 RCT, 18 mo".to_string());
1996 opts.species = vec!["Homo sapiens".to_string()];
1997 opts.in_vivo = true;
1998 opts.human_data = true;
1999 opts.clinical_trial = true;
2000 let proposal = build_add_finding_proposal(opts).unwrap();
2001 let finding: bundle::FindingBundle =
2002 serde_json::from_value(proposal.payload["finding"].clone()).unwrap();
2003 assert_eq!(finding.conditions.text, "Phase 3 RCT, 18 mo");
2004 assert_eq!(
2005 finding.conditions.species_verified,
2006 vec!["Homo sapiens".to_string()]
2007 );
2008 assert!(finding.conditions.in_vivo);
2009 assert!(finding.conditions.human_data);
2010 assert!(finding.conditions.clinical_trial);
2011 }
2012
2013 #[test]
2014 fn reviewed_entities_spans_and_gap_flags_populate_structured_fields() {
2015 let mut opts = base_options();
2016 opts.entities = vec![("lecanemab".to_string(), "drug".to_string())];
2017 opts.entities_reviewed = true;
2018 opts.evidence_spans = vec![json!({
2019 "section": "abstract",
2020 "text": "Lecanemab slowed decline under early symptomatic AD trial conditions."
2021 })];
2022 opts.gap = true;
2023 opts.negative_space = true;
2024
2025 let proposal = build_add_finding_proposal(opts).unwrap();
2026 let finding: bundle::FindingBundle =
2027 serde_json::from_value(proposal.payload["finding"].clone()).unwrap();
2028
2029 assert_eq!(finding.assertion.entities.len(), 1);
2030 assert!(!finding.assertion.entities[0].needs_review);
2031 assert_eq!(
2032 finding.assertion.entities[0].resolution_method,
2033 Some(bundle::ResolutionMethod::Manual)
2034 );
2035 assert_eq!(finding.evidence.evidence_spans.len(), 1);
2036 assert_eq!(
2037 finding.evidence.evidence_spans[0]["section"].as_str(),
2038 Some("abstract")
2039 );
2040 assert!(finding.flags.gap);
2041 assert!(finding.flags.negative_space);
2042 }
2043
2044 #[test]
2045 fn omitted_flags_fall_back_to_pre_v011_shape() {
2046 let proposal = build_add_finding_proposal(base_options()).unwrap();
2047 let finding: bundle::FindingBundle =
2048 serde_json::from_value(proposal.payload["finding"].clone()).unwrap();
2049 assert!(
2051 finding
2052 .conditions
2053 .text
2054 .starts_with("Manually added finding")
2055 );
2056 assert_eq!(finding.provenance.authors.len(), 1);
2058 assert_eq!(finding.provenance.authors[0].name, "reviewer:test");
2059 assert!(finding.provenance.doi.is_none());
2061 assert!(finding.provenance.year.is_none());
2062 assert!(finding.provenance.url.is_none());
2063 }
2064}
2065
2066#[cfg(test)]
2067mod v0_38_causal_tests {
2068 use super::*;
2069 use crate::bundle::{CausalClaim, CausalEvidenceGrade};
2070 use tempfile::tempdir;
2071
2072 fn seed_frontier(dir: &Path) -> std::path::PathBuf {
2073 let path = dir.join("frontier.json");
2074 let opts = FindingDraftOptions {
2075 text: "X causes Y".to_string(),
2076 assertion_type: "mechanism".to_string(),
2077 source: "test".to_string(),
2078 source_type: "published_paper".to_string(),
2079 author: "reviewer:test".to_string(),
2080 confidence: 0.5,
2081 evidence_type: "experimental".to_string(),
2082 entities: Vec::new(),
2083 doi: None,
2084 pmid: None,
2085 year: Some(2025),
2086 journal: None,
2087 url: None,
2088 source_authors: Vec::new(),
2089 conditions_text: None,
2090 species: Vec::new(),
2091 in_vivo: false,
2092 in_vitro: false,
2093 human_data: false,
2094 clinical_trial: false,
2095 entities_reviewed: false,
2096 evidence_spans: Vec::new(),
2097 gap: false,
2098 negative_space: false,
2099 };
2100 let proposal = build_add_finding_proposal(opts).unwrap();
2101 let finding: FindingBundle =
2102 serde_json::from_value(proposal.payload["finding"].clone()).unwrap();
2103 let project = project::assemble("Test", vec![finding], 1, 0, "test causal frontier");
2104 repo::save_to_path(&path, &project).unwrap();
2105 path
2106 }
2107
2108 #[test]
2109 fn set_causal_writes_fields_and_appends_event() {
2110 let dir = tempdir().unwrap();
2111 let path = seed_frontier(dir.path());
2112 let project = repo::load_from_path(&path).unwrap();
2113 let finding_id = project.findings[0].id.clone();
2114
2115 let report = set_causal(
2116 &path,
2117 &finding_id,
2118 "intervention",
2119 Some("rct"),
2120 "reviewer:test",
2121 "phase 3 RCT supports do(X=x) reading",
2122 )
2123 .unwrap();
2124 assert!(report.applied_event_id.is_some());
2125
2126 let after = repo::load_from_path(&path).unwrap();
2127 let f = &after.findings[0];
2128 assert_eq!(f.assertion.causal_claim, Some(CausalClaim::Intervention));
2129 assert_eq!(
2130 f.assertion.causal_evidence_grade,
2131 Some(CausalEvidenceGrade::Rct)
2132 );
2133
2134 let last_event = after.events.last().expect("an event was appended");
2135 assert_eq!(last_event.kind, "assertion.reinterpreted_causal");
2136 assert_eq!(last_event.target.id, finding_id);
2137 assert_eq!(last_event.payload["after"]["claim"], "intervention");
2138 assert_eq!(last_event.payload["after"]["grade"], "rct");
2139 }
2140
2141 #[test]
2142 fn set_causal_rejects_invalid_claim() {
2143 let dir = tempdir().unwrap();
2144 let path = seed_frontier(dir.path());
2145 let project = repo::load_from_path(&path).unwrap();
2146 let finding_id = project.findings[0].id.clone();
2147 let err =
2148 set_causal(&path, &finding_id, "magic", None, "reviewer:test", "test").unwrap_err();
2149 assert!(err.contains("invalid causal claim"));
2150 }
2151
2152 #[test]
2153 fn set_causal_preserves_grade_when_only_claim_changes() {
2154 let dir = tempdir().unwrap();
2155 let path = seed_frontier(dir.path());
2156 let project = repo::load_from_path(&path).unwrap();
2157 let finding_id = project.findings[0].id.clone();
2158
2159 set_causal(
2161 &path,
2162 &finding_id,
2163 "correlation",
2164 Some("observational"),
2165 "reviewer:test",
2166 "initial reading",
2167 )
2168 .unwrap();
2169 set_causal(
2171 &path,
2172 &finding_id,
2173 "mediation",
2174 None,
2175 "reviewer:test",
2176 "refined reading",
2177 )
2178 .unwrap();
2179 let after = repo::load_from_path(&path).unwrap();
2180 let f = &after.findings[0];
2181 assert_eq!(f.assertion.causal_claim, Some(CausalClaim::Mediation));
2182 assert_eq!(
2183 f.assertion.causal_evidence_grade,
2184 Some(CausalEvidenceGrade::Observational)
2185 );
2186 let causal_events: usize = after
2188 .events
2189 .iter()
2190 .filter(|e| e.kind == "assertion.reinterpreted_causal")
2191 .count();
2192 assert_eq!(causal_events, 2);
2193 }
2194}