use crate::discord::{ContextId, DiscordAssignment, DiscordKind, DiscordSet};
use crate::project::Project;
use crate::provenance_compute::status_provenance_for_finding;
use crate::status_provenance::BelnapStatus;
pub fn detect_evidence_gap(project: &Project, finding_id: &str) -> bool {
let Some(finding) = project.findings.iter().find(|f| f.id == finding_id) else {
return false;
};
let has_spans = !finding.evidence.evidence_spans.is_empty();
if has_spans {
return false;
}
let has_evidence_atoms = project
.evidence_atoms
.iter()
.any(|ea| ea.finding_id == finding_id);
!has_evidence_atoms
}
pub fn detect_provenance_fragile(project: &Project, finding_id: &str) -> bool {
let sp = status_provenance_for_finding(project, finding_id);
if sp.support.is_zero() {
return false;
}
sp.support.term_count() < 2
}
pub fn detect_status_divergent(project: &Project, finding_id: &str) -> bool {
use crate::bundle::ReviewState;
let Some(finding) = project.findings.iter().find(|f| f.id == finding_id) else {
return false;
};
let belnap = status_provenance_for_finding(project, finding_id).derive_status();
let on_disk_contested = finding.flags.contested;
let review_state = finding.flags.review_state.as_ref();
if matches!(belnap, BelnapStatus::Both) && !on_disk_contested {
return true;
}
if matches!(review_state, Some(ReviewState::Rejected)) && matches!(belnap, BelnapStatus::True) {
return true;
}
false
}
pub fn compute_discord_for_finding(project: &Project, finding_id: &str) -> DiscordSet {
let mut set = DiscordSet::empty();
if detect_evidence_gap(project, finding_id) {
set.insert(DiscordKind::EvidenceGap);
}
if detect_provenance_fragile(project, finding_id) {
set.insert(DiscordKind::ProvenanceFragile);
}
if detect_status_divergent(project, finding_id) {
set.insert(DiscordKind::StatusDivergent);
}
set
}
pub fn compute_discord_assignment(project: &Project) -> DiscordAssignment {
let mut a = DiscordAssignment::empty();
for finding in &project.findings {
let context: ContextId = finding.id.clone();
let set = compute_discord_for_finding(project, &finding.id);
if !set.is_empty() {
a.set(context, set);
}
}
a
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bundle::{
Assertion, Author, Conditions, Confidence, ConfidenceKind, ConfidenceMethod, Evidence,
FindingBundle, Flags, Provenance,
};
use crate::events::{StateActor, StateEvent, StateTarget};
use serde_json::json;
fn make_assertion(text: &str) -> Assertion {
Assertion {
text: text.to_string(),
assertion_type: "mechanism".into(),
entities: vec![],
relation: None,
direction: None,
causal_claim: None,
causal_evidence_grade: None,
}
}
fn make_evidence() -> Evidence {
Evidence {
evidence_type: "experimental".into(),
model_system: "test".into(),
species: None,
method: "test".into(),
sample_size: None,
effect_size: None,
p_value: None,
replicated: false,
replication_count: None,
evidence_spans: vec![],
}
}
fn make_conditions() -> Conditions {
Conditions {
text: String::new(),
species_verified: vec![],
species_unverified: vec![],
in_vitro: false,
in_vivo: false,
human_data: false,
clinical_trial: false,
concentration_range: None,
duration: None,
age_group: None,
cell_type: None,
}
}
fn make_confidence() -> Confidence {
Confidence {
kind: ConfidenceKind::FrontierEpistemic,
score: 0.5,
basis: "test".into(),
method: ConfidenceMethod::LlmInitial,
components: None,
extraction_confidence: 0.5,
}
}
fn make_provenance(id_seed: &str) -> Provenance {
Provenance {
source_type: "expert_assertion".into(),
doi: None,
pmid: None,
pmc: None,
openalex_id: None,
url: None,
title: format!("title-{id_seed}"),
authors: vec![Author {
name: "test".into(),
orcid: None,
}],
year: None,
journal: None,
license: None,
publisher: None,
funders: vec![],
extraction: Default::default(),
review: None,
citation_count: None,
}
}
fn empty_finding(id: &str) -> FindingBundle {
let mut f = FindingBundle::new(
make_assertion(&format!("test claim {id}")),
make_evidence(),
make_conditions(),
make_confidence(),
make_provenance(id),
Flags::default(),
);
f.id = id.to_string();
f
}
fn synthetic_event(id: &str, kind: &str, finding_id: &str, status: Option<&str>) -> StateEvent {
let payload = match status {
Some(s) => json!({"status": s}),
None => json!(null),
};
StateEvent {
schema: "vela.event.v0.1".into(),
id: id.to_string(),
kind: kind.to_string(),
target: StateTarget {
r#type: "finding".into(),
id: finding_id.to_string(),
},
actor: StateActor {
id: "reviewer:test".into(),
r#type: "human".into(),
},
timestamp: "2026-05-09T00:00:00Z".into(),
reason: "test".into(),
before_hash: String::new(),
after_hash: String::new(),
payload,
caveats: vec![],
signature: None,
schema_artifact_id: None,
}
}
fn build_project(findings: Vec<FindingBundle>, events: Vec<StateEvent>) -> Project {
let mut p = crate::project::assemble("test-frontier", vec![], 0, 0, "test");
p.events.clear();
p.findings = findings;
p.events = events;
p
}
#[test]
fn evidence_gap_fires_when_no_spans_and_no_atoms() {
let f = empty_finding("vf_x");
let p = build_project(vec![f], vec![]);
assert!(detect_evidence_gap(&p, "vf_x"));
}
#[test]
fn evidence_gap_does_not_fire_when_finding_has_spans() {
let mut f = empty_finding("vf_x");
f.evidence
.evidence_spans
.push(json!({"text": "verbatim quote"}));
let p = build_project(vec![f], vec![]);
assert!(!detect_evidence_gap(&p, "vf_x"));
}
#[test]
fn provenance_fragile_fires_when_only_one_supporting_event() {
let f = empty_finding("vf_x");
let events = vec![synthetic_event("vev_001", "finding.asserted", "vf_x", None)];
let p = build_project(vec![f], events);
assert!(detect_provenance_fragile(&p, "vf_x"));
}
#[test]
fn provenance_fragile_does_not_fire_when_multiple_supporting_events() {
let f = empty_finding("vf_x");
let events = vec![
synthetic_event("vev_001", "finding.asserted", "vf_x", None),
synthetic_event("vev_002", "finding.reviewed", "vf_x", Some("accepted")),
];
let p = build_project(vec![f], events);
assert!(!detect_provenance_fragile(&p, "vf_x"));
}
#[test]
fn provenance_fragile_does_not_fire_when_no_support_at_all() {
let f = empty_finding("vf_x");
let p = build_project(vec![f], vec![]);
assert!(!detect_provenance_fragile(&p, "vf_x"));
}
#[test]
fn status_divergent_fires_when_belnap_b_but_flags_say_uncontested() {
let mut f = empty_finding("vf_x");
f.flags.contested = false;
let events = vec![
synthetic_event("vev_001", "finding.asserted", "vf_x", None),
synthetic_event("vev_002", "finding.reviewed", "vf_x", Some("contested")),
];
let p = build_project(vec![f], events);
assert!(detect_status_divergent(&p, "vf_x"));
}
#[test]
fn status_divergent_does_not_fire_when_flags_match_substrate() {
let mut f = empty_finding("vf_x");
f.flags.contested = true;
let events = vec![
synthetic_event("vev_001", "finding.asserted", "vf_x", None),
synthetic_event("vev_002", "finding.reviewed", "vf_x", Some("contested")),
];
let p = build_project(vec![f], events);
assert!(!detect_status_divergent(&p, "vf_x"));
}
#[test]
fn compute_discord_for_finding_with_only_asserted_event() {
let f = empty_finding("vf_x");
let events = vec![synthetic_event("vev_001", "finding.asserted", "vf_x", None)];
let p = build_project(vec![f], events);
let set = compute_discord_for_finding(&p, "vf_x");
assert!(set.contains(DiscordKind::EvidenceGap));
assert!(set.contains(DiscordKind::ProvenanceFragile));
assert!(!set.contains(DiscordKind::StatusDivergent));
}
#[test]
fn compute_discord_for_finding_with_multiple_events_and_spans() {
let mut f = empty_finding("vf_x");
f.evidence.evidence_spans.push(json!({"text": "span"}));
let events = vec![
synthetic_event("vev_001", "finding.asserted", "vf_x", None),
synthetic_event("vev_002", "finding.reviewed", "vf_x", Some("accepted")),
];
let p = build_project(vec![f], events);
let set = compute_discord_for_finding(&p, "vf_x");
assert!(set.is_empty());
}
#[test]
fn compute_discord_assignment_collects_per_finding_results() {
let f1 = empty_finding("vf_a");
let f2 = {
let mut f = empty_finding("vf_b");
f.evidence.evidence_spans.push(json!({"text": "span"}));
f
};
let events = vec![
synthetic_event("vev_001", "finding.asserted", "vf_a", None),
synthetic_event("vev_002", "finding.asserted", "vf_b", None),
synthetic_event("vev_003", "finding.reviewed", "vf_b", Some("accepted")),
];
let p = build_project(vec![f1, f2], events);
let assignment = compute_discord_assignment(&p);
let support = assignment.frontier_support();
assert!(support.contains("vf_a"));
assert!(!support.contains("vf_b"));
}
}