Skip to main content

vela_protocol/
observer.rs

1//! Observer policies — named lenses over the frontier.
2//!
3//! The observer layer separates the record from the judgment. The kernel stores
4//! structural facts. Observers are functions over that shared record — they
5//! score, filter, and rank under declared policies without altering shared state.
6//!
7//! Different communities interpret the same evidence differently: a pharma team
8//! weights clinical trial data and citation counts; an academic team weights
9//! replication and evidence spans; a regulatory body demands human data at high
10//! confidence thresholds. The observer makes those lenses explicit.
11
12use chrono::Datelike;
13use colored::Colorize;
14
15use crate::bundle::{FindingBundle, Replication};
16use crate::cli_style as style;
17
18/// An observer policy — a named lens over the frontier.
19pub struct ObserverPolicy {
20    pub name: String,
21    pub description: String,
22    pub weights: ObserverWeights,
23}
24
25pub struct ObserverWeights {
26    /// Weight for clinical trial evidence (0.0-2.0, 1.0 = neutral).
27    pub clinical_trial: f64,
28    /// Weight for replicated findings.
29    pub replication: f64,
30    /// Weight for human data vs animal models.
31    pub human_data: f64,
32    /// Weight for recency (newer = higher).
33    pub recency: f64,
34    /// Weight for citation count.
35    pub citations: f64,
36    /// Weight for evidence spans present (auditability).
37    pub evidence_spans: f64,
38    /// Minimum confidence threshold — findings below this are hidden.
39    pub min_confidence: f64,
40    /// Entity types to prioritize (empty = all).
41    pub priority_entity_types: Vec<String>,
42    /// Assertion types to prioritize (empty = all).
43    pub priority_assertion_types: Vec<String>,
44    /// Weight for gap and negative-space findings (exploration lens).
45    pub dark_territory: f64,
46}
47
48/// A scored finding within an observer view.
49pub struct ScoredFinding {
50    pub finding_id: String,
51    pub original_confidence: f64,
52    pub observer_score: f64,
53    pub rank: usize,
54    /// Short label for display (truncated assertion text).
55    pub label: String,
56}
57
58/// The output of applying an observer policy to a frontier's findings.
59pub struct ObserverView {
60    pub policy: String,
61    pub findings: Vec<ScoredFinding>,
62    pub hidden: usize,
63    pub total: usize,
64}
65
66// ── Built-in policies ───────────────────────────────────────────────────
67
68pub 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
178// ── Scoring ─────────────────────────────────────────────────────────────
179
180/// Score a single finding under the given policy weights.
181/// Returns the raw (unnormalized) score.
182fn 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    // Clinical trial signal.
191    if finding.conditions.clinical_trial {
192        multiplier *= w.clinical_trial;
193    }
194
195    // v0.36.2: Replication signal sources from `Project.replications`,
196    // with the legacy `evidence.replicated` scalar as fall-through for
197    // findings that have no `Replication` records yet. A finding gets
198    // the multiplier only when at least one `replicated` outcome is
199    // recorded; a `failed` outcome with no successes loses it.
200    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    // Human data signal.
214    if finding.conditions.human_data {
215        multiplier *= w.human_data;
216    }
217
218    // Recency signal: papers from the last 3 years get the weight boost.
219    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    // Citation count signal: papers with 100+ citations get the boost.
227    if let Some(cites) = finding.provenance.citation_count
228        && cites >= 100
229    {
230        multiplier *= w.citations;
231    }
232
233    // Evidence spans (auditability).
234    if !finding.evidence.evidence_spans.is_empty() {
235        multiplier *= w.evidence_spans;
236    }
237
238    // Dark territory: gap and negative-space findings.
239    if finding.flags.gap || finding.flags.negative_space {
240        multiplier *= w.dark_territory;
241    }
242
243    // Priority assertion type boost (1.2x if matching).
244    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    // Priority entity type boost (1.1x per matching entity, capped at 1.3x).
252    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    // Apply and clamp to 0.0-1.0.
265    (base * multiplier).clamp(0.0, 1.0)
266}
267
268/// Apply an observer policy to a set of findings, producing a filtered,
269/// reranked view. The findings vector is not mutated.
270///
271/// v0.36.2: takes the live `replications` slice so the replication
272/// multiplier reads from `Project.replications` (the source of truth)
273/// rather than the legacy `evidence.replicated` scalar. Pass `&[]` for
274/// frontiers without v0.32 replication records — the function falls
275/// through to the scalar.
276pub 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    // Filter below min_confidence.
307    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    // Sort descending by observer_score.
315    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    // Assign ranks.
322    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
334/// Compute the disagreement between two observer views. Returns findings sorted
335/// by the absolute difference in rank between the two policies — the most
336/// contested findings first.
337pub 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    // Build lookup maps by finding_id.
351    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    // Collect all finding IDs from both views.
363    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
400/// Print an observer view to stdout.
401pub 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
433/// Print a diff between two observer views.
434pub 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, &[], &regulatory());
599        assert_eq!(view.hidden, 1);
600        assert_eq!(view.findings.len(), 0);
601    }
602
603    // ── policy_by_name tests ────────────────────────────────────────
604
605    #[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    // ── Scoring edge cases ──────────────────────────────────────────
660
661    #[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, &[], &regulatory());
690        // "both" should be ranked first with a much higher score
691        assert_eq!(view.findings[0].finding_id, "both");
692        // The boost should be substantial (2.0 * 2.0 = 4x multiplier)
693        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        // pharma min_confidence = 0.7
735        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        // A finding with every possible boost should still be <= 1.0
781        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    // ── diff_views tests ────────────────────────────────────────────
793
794    #[test]
795    fn diff_views_finds_disagreement() {
796        let findings = vec![
797            make_finding("a", 0.9, true, true, false), // pharma loves this
798            make_finding("b", 0.7, false, false, true), // academic prefers replicated
799        ];
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        // regulatory hides low-confidence, exploration does not
824        let findings = vec![make_finding("low", 0.3, false, false, false)];
825        let view_reg = observe(&findings, &[], &regulatory());
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()); // hidden in regulatory
831        assert!(low_diff.rank_b.is_some()); // visible in exploration
832    }
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    // ── Priority assertion type boost ───────────────────────────────
845
846    #[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}