1use chrono::Datelike;
13use colored::Colorize;
14
15use crate::bundle::{FindingBundle, Replication};
16use crate::cli_style as style;
17
18pub struct ObserverPolicy {
20 pub name: String,
21 pub description: String,
22 pub weights: ObserverWeights,
23}
24
25pub struct ObserverWeights {
26 pub clinical_trial: f64,
28 pub replication: f64,
30 pub human_data: f64,
32 pub recency: f64,
34 pub citations: f64,
36 pub evidence_spans: f64,
38 pub min_confidence: f64,
40 pub priority_entity_types: Vec<String>,
42 pub priority_assertion_types: Vec<String>,
44 pub dark_territory: f64,
46}
47
48pub struct ScoredFinding {
50 pub finding_id: String,
51 pub original_confidence: f64,
52 pub observer_score: f64,
53 pub rank: usize,
54 pub label: String,
56}
57
58pub struct ObserverView {
60 pub policy: String,
61 pub findings: Vec<ScoredFinding>,
62 pub hidden: usize,
63 pub total: usize,
64}
65
66pub fn builtin_policies() -> Vec<ObserverPolicy> {
69 vec![
70 pharma(),
71 academic(),
72 regulatory(),
73 clinical(),
74 exploration(),
75 ]
76}
77
78pub fn policy_by_name(name: &str) -> Option<ObserverPolicy> {
79 builtin_policies().into_iter().find(|p| p.name == name)
80}
81
82pub fn pharma() -> ObserverPolicy {
83 ObserverPolicy {
84 name: "pharma".into(),
85 description: "Weights clinical trial data, human evidence, and citation count.".into(),
86 weights: ObserverWeights {
87 clinical_trial: 1.5,
88 replication: 1.0,
89 human_data: 1.5,
90 recency: 1.0,
91 citations: 1.3,
92 evidence_spans: 1.0,
93 min_confidence: 0.7,
94 priority_entity_types: vec![],
95 priority_assertion_types: vec!["therapeutic".into(), "diagnostic".into()],
96 dark_territory: 1.0,
97 },
98 }
99}
100
101pub fn academic() -> ObserverPolicy {
102 ObserverPolicy {
103 name: "academic".into(),
104 description: "Weights replication, evidence spans, and recency.".into(),
105 weights: ObserverWeights {
106 clinical_trial: 1.0,
107 replication: 1.5,
108 human_data: 1.0,
109 recency: 1.2,
110 citations: 1.0,
111 evidence_spans: 1.3,
112 min_confidence: 0.5,
113 priority_entity_types: vec![],
114 priority_assertion_types: vec![],
115 dark_territory: 1.0,
116 },
117 }
118}
119
120pub fn regulatory() -> ObserverPolicy {
121 ObserverPolicy {
122 name: "regulatory".into(),
123 description: "Demands human data, clinical trials, and auditability. High threshold."
124 .into(),
125 weights: ObserverWeights {
126 clinical_trial: 2.0,
127 replication: 1.0,
128 human_data: 2.0,
129 recency: 1.0,
130 citations: 1.0,
131 evidence_spans: 1.5,
132 min_confidence: 0.8,
133 priority_entity_types: vec![],
134 priority_assertion_types: vec!["diagnostic".into(), "epidemiological".into()],
135 dark_territory: 1.0,
136 },
137 }
138}
139
140pub fn clinical() -> ObserverPolicy {
141 ObserverPolicy {
142 name: "clinical".into(),
143 description: "Weights human data, replication, and recency for clinical relevance.".into(),
144 weights: ObserverWeights {
145 clinical_trial: 1.0,
146 replication: 1.5,
147 human_data: 1.5,
148 recency: 1.3,
149 citations: 1.0,
150 evidence_spans: 1.0,
151 min_confidence: 0.6,
152 priority_entity_types: vec![],
153 priority_assertion_types: vec!["therapeutic".into(), "diagnostic".into()],
154 dark_territory: 1.0,
155 },
156 }
157}
158
159pub fn exploration() -> ObserverPolicy {
160 ObserverPolicy {
161 name: "exploration".into(),
162 description: "No minimum confidence. Surfaces gaps and dark territory.".into(),
163 weights: ObserverWeights {
164 clinical_trial: 1.0,
165 replication: 1.0,
166 human_data: 1.0,
167 recency: 1.0,
168 citations: 1.0,
169 evidence_spans: 1.0,
170 min_confidence: 0.0,
171 priority_entity_types: vec![],
172 priority_assertion_types: vec![],
173 dark_territory: 2.0,
174 },
175 }
176}
177
178fn score_finding(
183 finding: &FindingBundle,
184 replications: &[Replication],
185 w: &ObserverWeights,
186) -> f64 {
187 let base = finding.confidence.score;
188 let mut multiplier = 1.0;
189
190 if finding.conditions.clinical_trial {
192 multiplier *= w.clinical_trial;
193 }
194
195 let has_record = replications.iter().any(|r| r.target_finding == finding.id);
201 let has_success = replications
202 .iter()
203 .any(|r| r.target_finding == finding.id && r.outcome == "replicated");
204 let counts_as_replicated = if has_record {
205 has_success
206 } else {
207 finding.evidence.replicated
208 };
209 if counts_as_replicated {
210 multiplier *= w.replication;
211 }
212
213 if finding.conditions.human_data {
215 multiplier *= w.human_data;
216 }
217
218 let current_year = chrono::Utc::now().naive_utc().year();
220 if let Some(year) = finding.provenance.year
221 && current_year - year <= 3
222 {
223 multiplier *= w.recency;
224 }
225
226 if let Some(cites) = finding.provenance.citation_count
228 && cites >= 100
229 {
230 multiplier *= w.citations;
231 }
232
233 if !finding.evidence.evidence_spans.is_empty() {
235 multiplier *= w.evidence_spans;
236 }
237
238 if finding.flags.gap || finding.flags.negative_space {
240 multiplier *= w.dark_territory;
241 }
242
243 if !w.priority_assertion_types.is_empty()
245 && w.priority_assertion_types
246 .contains(&finding.assertion.assertion_type)
247 {
248 multiplier *= 1.2;
249 }
250
251 if !w.priority_entity_types.is_empty() {
253 let matches = finding
254 .assertion
255 .entities
256 .iter()
257 .filter(|e| w.priority_entity_types.contains(&e.entity_type))
258 .count();
259 if matches > 0 {
260 multiplier *= (1.0 + 0.1 * matches as f64).min(1.3);
261 }
262 }
263
264 (base * multiplier).clamp(0.0, 1.0)
266}
267
268pub fn observe(
277 findings: &[FindingBundle],
278 replications: &[Replication],
279 policy: &ObserverPolicy,
280) -> ObserverView {
281 let total = findings.len();
282
283 let mut scored: Vec<ScoredFinding> = findings
284 .iter()
285 .map(|f| {
286 let s = score_finding(f, replications, &policy.weights);
287 let label = if f.assertion.text.len() > 72 {
288 let mut end = 72;
289 while end > 0 && !f.assertion.text.is_char_boundary(end) {
290 end -= 1;
291 }
292 format!("{}...", &f.assertion.text[..end])
293 } else {
294 f.assertion.text.clone()
295 };
296 ScoredFinding {
297 finding_id: f.id.clone(),
298 original_confidence: f.confidence.score,
299 observer_score: s,
300 rank: 0,
301 label,
302 }
303 })
304 .collect();
305
306 let hidden = scored
308 .iter()
309 .filter(|s| s.observer_score < policy.weights.min_confidence)
310 .count();
311
312 scored.retain(|s| s.observer_score >= policy.weights.min_confidence);
313
314 scored.sort_by(|a, b| {
316 b.observer_score
317 .partial_cmp(&a.observer_score)
318 .unwrap_or(std::cmp::Ordering::Equal)
319 });
320
321 for (i, s) in scored.iter_mut().enumerate() {
323 s.rank = i + 1;
324 }
325
326 ObserverView {
327 policy: policy.name.clone(),
328 findings: scored,
329 hidden,
330 total,
331 }
332}
333
334pub struct Disagreement {
338 pub finding_id: String,
339 pub label: String,
340 pub score_a: f64,
341 pub score_b: f64,
342 pub rank_a: Option<usize>,
343 pub rank_b: Option<usize>,
344 pub delta: f64,
345}
346
347pub fn diff_views(view_a: &ObserverView, view_b: &ObserverView) -> Vec<Disagreement> {
348 use std::collections::HashMap;
349
350 let map_a: HashMap<&str, &ScoredFinding> = view_a
352 .findings
353 .iter()
354 .map(|f| (f.finding_id.as_str(), f))
355 .collect();
356 let map_b: HashMap<&str, &ScoredFinding> = view_b
357 .findings
358 .iter()
359 .map(|f| (f.finding_id.as_str(), f))
360 .collect();
361
362 let mut all_ids: Vec<&str> = map_a.keys().copied().collect();
364 for id in map_b.keys() {
365 if !map_a.contains_key(id) {
366 all_ids.push(id);
367 }
368 }
369
370 let mut disagreements: Vec<Disagreement> = all_ids
371 .iter()
372 .map(|id| {
373 let a = map_a.get(id);
374 let b = map_b.get(id);
375
376 let score_a = a.map(|f| f.observer_score).unwrap_or(0.0);
377 let score_b = b.map(|f| f.observer_score).unwrap_or(0.0);
378 let label = a.or(b).map(|f| f.label.clone()).unwrap_or_default();
379
380 Disagreement {
381 finding_id: id.to_string(),
382 label,
383 score_a,
384 score_b,
385 rank_a: a.map(|f| f.rank),
386 rank_b: b.map(|f| f.rank),
387 delta: (score_a - score_b).abs(),
388 }
389 })
390 .collect();
391
392 disagreements.sort_by(|a, b| {
393 b.delta
394 .partial_cmp(&a.delta)
395 .unwrap_or(std::cmp::Ordering::Equal)
396 });
397 disagreements
398}
399
400pub fn print_view(view: &ObserverView) {
402 println!();
403 println!(
404 " {}",
405 format!("VELA · OBSERVER · {}", view.policy.to_uppercase()).dimmed()
406 );
407 println!(" {}", style::tick_row(80));
408 println!(
409 " {} findings shown · {} hidden (below threshold) · {} total",
410 view.findings.len(),
411 view.hidden,
412 view.total
413 );
414 println!();
415 println!(
416 " {}",
417 format!(
418 "{:<5} {:<16} {:>8} {:>8} assertion",
419 "rank", "id", "orig", "score"
420 )
421 .dimmed()
422 );
423
424 for sf in &view.findings {
425 println!(
426 " {:<5} {:<16} {:>8.3} {:>8.3} {}",
427 sf.rank, sf.finding_id, sf.original_confidence, sf.observer_score, sf.label
428 );
429 }
430 println!();
431}
432
433pub fn print_diff(policy_a: &str, policy_b: &str, disagreements: &[Disagreement], limit: usize) {
435 println!();
436 println!(
437 " {}",
438 format!(
439 "VELA · OBSERVER · DIFF · {} VS {}",
440 policy_a.to_uppercase(),
441 policy_b.to_uppercase()
442 )
443 .dimmed()
444 );
445 println!(" {}", style::tick_row(90));
446 println!(" top {} disagreements by score delta", limit);
447 println!();
448 println!(
449 " {}",
450 format!(
451 "{:<16} {:>10} {:>10} {:>8} assertion",
452 "id", policy_a, policy_b, "delta"
453 )
454 .dimmed()
455 );
456
457 for d in disagreements.iter().take(limit) {
458 let rank_a = d
459 .rank_a
460 .map(|r| format!("#{} ({:.3})", r, d.score_a))
461 .unwrap_or_else(|| "hidden".into());
462 let rank_b = d
463 .rank_b
464 .map(|r| format!("#{} ({:.3})", r, d.score_b))
465 .unwrap_or_else(|| "hidden".into());
466
467 let label = if d.label.len() > 40 {
468 let mut end = 40;
469 while end > 0 && !d.label.is_char_boundary(end) {
470 end -= 1;
471 }
472 format!("{}...", &d.label[..end])
473 } else {
474 d.label.clone()
475 };
476
477 println!(
478 " {:<16} {:>10} {:>10} {:>8.3} {}",
479 d.finding_id, rank_a, rank_b, d.delta, label
480 );
481 }
482 println!();
483}
484
485#[cfg(test)]
486mod tests {
487 use super::*;
488 use crate::bundle::*;
489
490 fn make_finding(
491 id: &str,
492 score: f64,
493 clinical_trial: bool,
494 human: bool,
495 replicated: bool,
496 ) -> FindingBundle {
497 FindingBundle {
498 id: id.into(),
499 version: 1,
500 previous_version: None,
501 assertion: Assertion {
502 text: format!("Finding {id}"),
503 assertion_type: "mechanism".into(),
504 entities: vec![],
505 relation: None,
506 direction: None,
507 causal_claim: None,
508 causal_evidence_grade: None,
509 },
510 evidence: Evidence {
511 evidence_type: "experimental".into(),
512 model_system: String::new(),
513 species: None,
514 method: String::new(),
515 sample_size: None,
516 effect_size: None,
517 p_value: None,
518 replicated,
519 replication_count: if replicated { Some(3) } else { None },
520 evidence_spans: vec![],
521 },
522 conditions: Conditions {
523 text: String::new(),
524 species_verified: vec![],
525 species_unverified: vec![],
526 in_vitro: false,
527 in_vivo: false,
528 human_data: human,
529 clinical_trial,
530 concentration_range: None,
531 duration: None,
532 age_group: None,
533 cell_type: None,
534 },
535 confidence: Confidence::raw(score, "test", 0.85),
536 provenance: Provenance {
537 source_type: "published_paper".into(),
538 doi: None,
539 pmid: None,
540 pmc: None,
541 openalex_id: None,
542 url: None,
543 title: "Test".into(),
544 authors: vec![],
545 year: Some(2020),
546 journal: None,
547 license: None,
548 publisher: None,
549 funders: vec![],
550 extraction: Extraction::default(),
551 review: None,
552 citation_count: Some(10),
553 },
554 flags: Flags {
555 gap: false,
556 negative_space: false,
557 contested: false,
558 retracted: false,
559 declining: false,
560 gravity_well: false,
561 review_state: None,
562 superseded: false,
563 signature_threshold: None,
564 jointly_accepted: false,
565 },
566 links: vec![],
567 annotations: vec![],
568 attachments: vec![],
569 created: String::new(),
570 updated: None,
571
572 access_tier: crate::access_tier::AccessTier::Public,
573 }
574 }
575
576 #[test]
577 fn pharma_ranks_clinical_higher() {
578 let findings = vec![
579 make_finding("a", 0.8, true, true, false),
580 make_finding("b", 0.8, false, false, false),
581 ];
582 let view = observe(&findings, &[], &pharma());
583 assert!(view.findings[0].finding_id == "a");
584 assert!(view.findings[0].observer_score > view.findings[1].observer_score);
585 }
586
587 #[test]
588 fn exploration_shows_all() {
589 let findings = vec![make_finding("low", 0.3, false, false, false)];
590 let view = observe(&findings, &[], &exploration());
591 assert_eq!(view.hidden, 0);
592 assert_eq!(view.findings.len(), 1);
593 }
594
595 #[test]
596 fn regulatory_hides_low_confidence() {
597 let findings = vec![make_finding("low", 0.5, false, false, false)];
598 let view = observe(&findings, &[], ®ulatory());
599 assert_eq!(view.hidden, 1);
600 assert_eq!(view.findings.len(), 0);
601 }
602
603 #[test]
606 fn policy_by_name_returns_pharma() {
607 let p = policy_by_name("pharma").unwrap();
608 assert_eq!(p.name, "pharma");
609 assert!(p.weights.clinical_trial > 1.0);
610 }
611
612 #[test]
613 fn policy_by_name_returns_academic() {
614 let p = policy_by_name("academic").unwrap();
615 assert_eq!(p.name, "academic");
616 assert!(p.weights.replication > 1.0);
617 }
618
619 #[test]
620 fn policy_by_name_returns_regulatory() {
621 let p = policy_by_name("regulatory").unwrap();
622 assert_eq!(p.name, "regulatory");
623 assert_eq!(p.weights.min_confidence, 0.8);
624 }
625
626 #[test]
627 fn policy_by_name_returns_clinical() {
628 let p = policy_by_name("clinical").unwrap();
629 assert_eq!(p.name, "clinical");
630 assert!(p.weights.human_data > 1.0);
631 }
632
633 #[test]
634 fn policy_by_name_returns_exploration() {
635 let p = policy_by_name("exploration").unwrap();
636 assert_eq!(p.name, "exploration");
637 assert_eq!(p.weights.min_confidence, 0.0);
638 assert_eq!(p.weights.dark_territory, 2.0);
639 }
640
641 #[test]
642 fn policy_by_name_unknown_returns_none() {
643 assert!(policy_by_name("nonexistent").is_none());
644 assert!(policy_by_name("").is_none());
645 }
646
647 #[test]
648 fn builtin_policies_has_five() {
649 let all = builtin_policies();
650 assert_eq!(all.len(), 5);
651 let names: Vec<&str> = all.iter().map(|p| p.name.as_str()).collect();
652 assert!(names.contains(&"pharma"));
653 assert!(names.contains(&"academic"));
654 assert!(names.contains(&"regulatory"));
655 assert!(names.contains(&"clinical"));
656 assert!(names.contains(&"exploration"));
657 }
658
659 #[test]
662 fn academic_ranks_replicated_higher() {
663 let findings = vec![
664 make_finding("rep", 0.7, false, false, true),
665 make_finding("norep", 0.7, false, false, false),
666 ];
667 let view = observe(&findings, &[], &academic());
668 assert_eq!(view.findings[0].finding_id, "rep");
669 assert!(view.findings[0].observer_score > view.findings[1].observer_score);
670 }
671
672 #[test]
673 fn clinical_ranks_human_higher() {
674 let findings = vec![
675 make_finding("human", 0.7, false, true, false),
676 make_finding("nohuman", 0.7, false, false, false),
677 ];
678 let view = observe(&findings, &[], &clinical());
679 assert_eq!(view.findings[0].finding_id, "human");
680 assert!(view.findings[0].observer_score > view.findings[1].observer_score);
681 }
682
683 #[test]
684 fn regulatory_boosts_clinical_trial_and_human() {
685 let findings = vec![
686 make_finding("both", 0.9, true, true, false),
687 make_finding("neither", 0.9, false, false, false),
688 ];
689 let view = observe(&findings, &[], ®ulatory());
690 assert_eq!(view.findings[0].finding_id, "both");
692 assert!(view.findings[0].observer_score > view.findings[1].observer_score);
694 }
695
696 #[test]
697 fn exploration_boosts_gap_findings() {
698 let mut gap_finding = make_finding("gap", 0.5, false, false, false);
699 gap_finding.flags.gap = true;
700 let normal_finding = make_finding("normal", 0.5, false, false, false);
701 let findings = vec![gap_finding, normal_finding];
702 let view = observe(&findings, &[], &exploration());
703 let gap_scored = view
704 .findings
705 .iter()
706 .find(|f| f.finding_id == "gap")
707 .unwrap();
708 let normal_scored = view
709 .findings
710 .iter()
711 .find(|f| f.finding_id == "normal")
712 .unwrap();
713 assert!(gap_scored.observer_score > normal_scored.observer_score);
714 }
715
716 #[test]
717 fn exploration_boosts_negative_space() {
718 let mut ns_finding = make_finding("ns", 0.5, false, false, false);
719 ns_finding.flags.negative_space = true;
720 let normal_finding = make_finding("normal", 0.5, false, false, false);
721 let findings = vec![ns_finding, normal_finding];
722 let view = observe(&findings, &[], &exploration());
723 let ns_scored = view.findings.iter().find(|f| f.finding_id == "ns").unwrap();
724 let normal_scored = view
725 .findings
726 .iter()
727 .find(|f| f.finding_id == "normal")
728 .unwrap();
729 assert!(ns_scored.observer_score > normal_scored.observer_score);
730 }
731
732 #[test]
733 fn pharma_hides_below_threshold() {
734 let findings = vec![
736 make_finding("low", 0.4, false, false, false),
737 make_finding("high", 0.9, true, true, false),
738 ];
739 let view = observe(&findings, &[], &pharma());
740 assert_eq!(view.hidden, 1);
741 assert_eq!(view.findings.len(), 1);
742 assert_eq!(view.findings[0].finding_id, "high");
743 }
744
745 #[test]
746 fn observer_view_total_is_correct() {
747 let findings = vec![
748 make_finding("a", 0.3, false, false, false),
749 make_finding("b", 0.5, false, false, false),
750 make_finding("c", 0.9, true, true, false),
751 ];
752 let view = observe(&findings, &[], &pharma());
753 assert_eq!(view.total, 3);
754 assert_eq!(view.findings.len() + view.hidden, view.total);
755 }
756
757 #[test]
758 fn observer_rankings_are_sequential() {
759 let findings = vec![
760 make_finding("a", 0.9, true, true, true),
761 make_finding("b", 0.85, true, false, false),
762 make_finding("c", 0.8, false, false, false),
763 ];
764 let view = observe(&findings, &[], &pharma());
765 for (i, sf) in view.findings.iter().enumerate() {
766 assert_eq!(sf.rank, i + 1);
767 }
768 }
769
770 #[test]
771 fn observe_empty_findings() {
772 let view = observe(&[], &[], &pharma());
773 assert_eq!(view.total, 0);
774 assert_eq!(view.findings.len(), 0);
775 assert_eq!(view.hidden, 0);
776 }
777
778 #[test]
779 fn score_clamped_to_one() {
780 let mut f = make_finding("max", 0.95, true, true, true);
782 f.evidence.evidence_spans = vec![serde_json::json!("span")];
783 f.provenance.year = Some(2025);
784 f.provenance.citation_count = Some(500);
785 f.flags.gap = true;
786 f.assertion.assertion_type = "therapeutic".into();
787 let findings = vec![f];
788 let view = observe(&findings, &[], &pharma());
789 assert!(view.findings[0].observer_score <= 1.0);
790 }
791
792 #[test]
795 fn diff_views_finds_disagreement() {
796 let findings = vec![
797 make_finding("a", 0.9, true, true, false), make_finding("b", 0.7, false, false, true), ];
800 let view_pharma = observe(&findings, &[], &pharma());
801 let view_academic = observe(&findings, &[], &academic());
802 let diffs = diff_views(&view_pharma, &view_academic);
803 assert!(!diffs.is_empty());
804 }
805
806 #[test]
807 fn diff_views_sorted_by_delta() {
808 let findings = vec![
809 make_finding("a", 0.9, true, true, false),
810 make_finding("b", 0.8, false, false, true),
811 make_finding("c", 0.7, false, false, false),
812 ];
813 let view_pharma = observe(&findings, &[], &pharma());
814 let view_academic = observe(&findings, &[], &academic());
815 let diffs = diff_views(&view_pharma, &view_academic);
816 for w in diffs.windows(2) {
817 assert!(w[0].delta >= w[1].delta);
818 }
819 }
820
821 #[test]
822 fn diff_views_includes_hidden_findings() {
823 let findings = vec![make_finding("low", 0.3, false, false, false)];
825 let view_reg = observe(&findings, &[], ®ulatory());
826 let view_exp = observe(&findings, &[], &exploration());
827 let diffs = diff_views(&view_reg, &view_exp);
828 assert!(!diffs.is_empty());
829 let low_diff = diffs.iter().find(|d| d.finding_id == "low").unwrap();
830 assert!(low_diff.rank_a.is_none()); assert!(low_diff.rank_b.is_some()); }
833
834 #[test]
835 fn label_truncated_for_long_assertions() {
836 let mut f = make_finding("long", 0.8, false, false, false);
837 f.assertion.text = "A".repeat(200);
838 let findings = vec![f];
839 let view = observe(&findings, &[], &exploration());
840 assert!(view.findings[0].label.len() < 200);
841 assert!(view.findings[0].label.ends_with("..."));
842 }
843
844 #[test]
847 fn pharma_boosts_therapeutic_assertion_type() {
848 let mut therapeutic = make_finding("ther", 0.8, false, false, false);
849 therapeutic.assertion.assertion_type = "therapeutic".into();
850 let mechanism = make_finding("mech", 0.8, false, false, false);
851 let findings = vec![therapeutic, mechanism];
852 let view = observe(&findings, &[], &pharma());
853 let ther_scored = view
854 .findings
855 .iter()
856 .find(|f| f.finding_id == "ther")
857 .unwrap();
858 let mech_scored = view
859 .findings
860 .iter()
861 .find(|f| f.finding_id == "mech")
862 .unwrap();
863 assert!(ther_scored.observer_score > mech_scored.observer_score);
864 }
865}