use crate::hyp::Hyp;
use crate::ontology::OntologySystem;
use crate::operator::Evidence;
use std::collections::HashSet;
pub type CandidateSet = HashSet<Hyp>;
#[derive(Clone, Debug)]
pub struct ConstraintError {
ontology_bounded_failed: bool,
operator_reachable_failed: bool,
details: String,
}
impl ConstraintError {
pub fn new(
ontology_bounded_failed: bool,
operator_reachable_failed: bool,
details: impl Into<String>,
) -> Self {
Self {
ontology_bounded_failed,
operator_reachable_failed,
details: details.into(),
}
}
pub fn ontology_bounded_failed(&self) -> bool {
self.ontology_bounded_failed
}
pub fn operator_reachable_failed(&self) -> bool {
self.operator_reachable_failed
}
pub fn details(&self) -> &str {
&self.details
}
}
impl std::fmt::Display for ConstraintError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "ProposerConstraint violated: {}", self.details)
}
}
impl std::error::Error for ConstraintError {}
pub struct ProposerConstraint {
}
impl ProposerConstraint {
pub fn new() -> Self {
Self {}
}
pub fn validate(
&self,
candidate: &Hyp,
input: &Hyp,
_evidence: &Evidence,
) -> Result<(), ConstraintError> {
let mut ontology_bounded_failed = false;
let mut operator_reachable_failed = false;
let mut details = Vec::new();
for atom in candidate.atoms() {
match atom.system {
OntologySystem::SNOMED
| OntologySystem::RxNorm
| OntologySystem::LOINC
| OntologySystem::ICD11 => {
if atom.code.is_empty() {
ontology_bounded_failed = true;
details.push(format!("Atom code is empty for system {:?}", atom.system));
}
}
OntologySystem::Unstructured => {
ontology_bounded_failed = true;
details.push("Unstructured atoms not permitted: OBL-PS-01 prohibits free-text from entering Hyp".to_string());
}
}
}
let candidate_atoms = candidate.atoms();
let input_atoms = input.atoms();
let all_input_atoms_in_candidate = input_atoms
.iter()
.all(|input_atom| candidate_atoms.iter().any(|c_atom| c_atom == input_atom));
if !all_input_atoms_in_candidate {
operator_reachable_failed = true;
details
.push("candidate does not refine input: candidate atoms ⊉ input atoms".to_string());
}
if ontology_bounded_failed || operator_reachable_failed {
Err(ConstraintError::new(
ontology_bounded_failed,
operator_reachable_failed,
details.join("; "),
))
} else {
Ok(())
}
}
pub fn validate_input(&self, h: &Hyp, _e: &Evidence) -> Result<(), ConstraintError> {
let mut ontology_bounded_failed = false;
let mut details = Vec::new();
for atom in h.atoms() {
match atom.system {
OntologySystem::SNOMED
| OntologySystem::RxNorm
| OntologySystem::LOINC
| OntologySystem::ICD11 => {
if atom.code.is_empty() {
ontology_bounded_failed = true;
details.push(format!(
"Input atom code is empty for system {:?}",
atom.system
));
}
}
OntologySystem::Unstructured => {
ontology_bounded_failed = true;
details.push("Input hypothesis contains Unstructured atoms: OBL-PS-01 prohibits free-text".to_string());
}
}
}
if ontology_bounded_failed {
Err(ConstraintError::new(
ontology_bounded_failed,
false,
details.join("; "),
))
} else {
Ok(())
}
}
}
impl Default for ProposerConstraint {
fn default() -> Self {
Self::new()
}
}
pub trait RefinementProposer: Send + Sync {
fn propose(&self, h: &Hyp, e: &Evidence) -> CandidateSet;
}
#[derive(Clone, Debug)]
pub struct FilterResult {
pub valid_candidates: CandidateSet,
pub filtered_out_count: usize,
pub filter_errors: Vec<ConstraintError>,
}
impl FilterResult {
pub fn new(
valid_candidates: CandidateSet,
filtered_out_count: usize,
filter_errors: Vec<ConstraintError>,
) -> Self {
debug_assert!(
filtered_out_count == filter_errors.len()
|| (filtered_out_count == 0 && filter_errors.len() == 1),
"FilterResult invariant violated: filtered_out_count={} but filter_errors.len()={}; \
these must be equal except when filtered_out_count==0 (input-gate rejection)",
filtered_out_count,
filter_errors.len()
);
Self {
valid_candidates,
filtered_out_count,
filter_errors,
}
}
pub fn all_passed(&self) -> bool {
self.filter_errors.is_empty()
}
pub fn has_valid_candidates(&self) -> bool {
!self.valid_candidates.is_empty()
}
}
pub fn propose_and_filter(
proposer: &dyn RefinementProposer,
h: &Hyp,
e: &Evidence,
) -> FilterResult {
let constraint = ProposerConstraint::new();
if let Err(input_error) = constraint.validate_input(h, e) {
return FilterResult::new(CandidateSet::new(), 0, vec![input_error]);
}
let candidates = proposer.propose(h, e);
let total_count = candidates.len();
let mut valid_candidates = CandidateSet::new();
let mut filter_errors = Vec::new();
for candidate in candidates.iter() {
match constraint.validate(candidate, h, e) {
Ok(()) => {
valid_candidates.insert(candidate.clone());
}
Err(err) => {
filter_errors.push(err);
}
}
}
let filtered_out_count = total_count - valid_candidates.len();
FilterResult::new(valid_candidates, filtered_out_count, filter_errors)
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct VerifyResult {
pub licensed_candidates: CandidateSet,
pub unlicensed_candidates: CandidateSet,
pub licensing_verdicts: Vec<(String, bool, String)>,
pub constraint_filtering_verdicts: Vec<(String, String)>,
}
impl VerifyResult {
pub fn new(
licensed_candidates: CandidateSet,
unlicensed_candidates: CandidateSet,
licensing_verdicts: Vec<(String, bool, String)>,
constraint_filtering_verdicts: Vec<(String, String)>,
) -> Self {
debug_assert!(
licensed_candidates.is_disjoint(&unlicensed_candidates),
"VerifyResult invariant violated: candidate appears in both licensed and unlicensed"
);
Self {
licensed_candidates,
unlicensed_candidates,
licensing_verdicts,
constraint_filtering_verdicts,
}
}
pub fn has_licensed_candidates(&self) -> bool {
!self.licensed_candidates.is_empty()
}
pub fn all_unlicensed(&self) -> bool {
!self.unlicensed_candidates.is_empty() && self.licensed_candidates.is_empty()
}
}
pub fn propose_verify(
proposer: &dyn RefinementProposer,
operators: &crate::operator_set::OperatorSet,
h: &Hyp,
e: &Evidence,
) -> Result<VerifyResult, crate::abstain::AbstainReason> {
let filter_result = propose_and_filter(proposer, h, e);
if filter_result.valid_candidates.is_empty()
&& filter_result.filtered_out_count == 0
&& !filter_result.filter_errors.is_empty()
{
return Err(crate::abstain::AbstainReason::OntologyOutOfScope(
"input hypothesis is ontology-unbounded (OBL-PS-01 constraint violation)",
));
}
let constraint_filtering_verdicts: Vec<(String, String)> = filter_result
.filter_errors
.iter()
.map(|err| {
let msg = if err.ontology_bounded_failed() {
"ontology-bounded constraint violated"
} else {
"operator-reachable constraint violated"
}
.to_string();
("<filtered by constraint>".to_string(), msg)
})
.collect();
let set_outcome = operators.apply_set(h, e);
let result_atoms = set_outcome.result.atoms();
let result_atoms_str = format!(
"{{{}}}",
result_atoms
.iter()
.map(|a| format!("{}:{}@{}", a.system, a.code, a.version))
.collect::<Vec<_>>()
.join(", ")
);
let mut licensed_candidates = CandidateSet::new();
let mut unlicensed_candidates = CandidateSet::new();
let mut licensing_verdicts = Vec::new();
for candidate in filter_result.valid_candidates.iter() {
let candidate_atoms = candidate.atoms();
let candidate_atoms_str = format!(
"{{{}}}",
candidate_atoms
.iter()
.map(|a| format!("{}:{}@{}", a.system, a.code, a.version))
.collect::<Vec<_>>()
.join(", ")
);
let is_licensed = if candidate_atoms.is_empty() && result_atoms.is_empty() {
h.atoms().is_empty()
} else {
candidate_atoms
.iter()
.all(|c_atom| result_atoms.iter().any(|r_atom| r_atom == c_atom))
};
if is_licensed {
licensed_candidates.insert(candidate.clone());
licensing_verdicts.push((candidate_atoms_str, true, result_atoms_str.clone()));
} else {
unlicensed_candidates.insert(candidate.clone());
licensing_verdicts.push((candidate_atoms_str, false, result_atoms_str.clone()));
}
}
if licensed_candidates.is_empty() {
Err(crate::abstain::AbstainReason::NoOperatorLicenses(
"no proposer candidates licensed by operator set",
))
} else {
Ok(VerifyResult::new(
licensed_candidates,
unlicensed_candidates,
licensing_verdicts,
constraint_filtering_verdicts,
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ontology::OntologySystem;
use crate::{Atom, ProvenanceOrigin, Ver};
use chrono::Utc;
use std::collections::BTreeMap;
struct TestProposer {
candidates: Vec<Hyp>,
}
impl TestProposer {
fn new(candidates: Vec<Hyp>) -> Self {
Self { candidates }
}
fn empty() -> Self {
Self { candidates: vec![] }
}
}
impl RefinementProposer for TestProposer {
fn propose(&self, _h: &Hyp, _e: &Evidence) -> CandidateSet {
self.candidates.iter().cloned().collect()
}
}
fn test_provenance() -> crate::Provenance {
let origin = ProvenanceOrigin::new("test_input", "SNOMED", "67822003");
let metadata = BTreeMap::new();
crate::Provenance::new(
origin,
Utc::now(),
Ver::new("clinlat", "test", "0.1.0"),
metadata,
)
}
#[test]
fn test_refinement_proposer_trait_compiles() {
let _proposer: Box<dyn RefinementProposer> = Box::new(TestProposer::empty());
}
#[test]
fn test_proposer_returns_candidate_set() {
let proposer = TestProposer::empty();
let h = Hyp::unknown();
let e = Evidence::new(vec![], test_provenance());
let candidates = proposer.propose(&h, &e);
assert!(candidates.is_empty(), "Test proposer returns empty set");
}
#[test]
fn test_proposer_with_atoms() {
let atom = Atom {
system: OntologySystem::SNOMED,
code: "67822003".to_string(),
preferred_term: "Hypoxemia".to_string(),
version: "2026-01-31".to_string(),
};
let h = Hyp::new(vec![atom]);
let e = Evidence::new(vec![], test_provenance());
let proposer = TestProposer::empty();
let candidates = proposer.propose(&h, &e);
assert_eq!(candidates.len(), 0, "Test proposer returns empty set");
}
#[test]
fn test_proposer_returns_set_not_option() {
let proposer = TestProposer::empty();
let h = Hyp::unknown();
let e = Evidence::new(vec![], test_provenance());
let candidates: CandidateSet = proposer.propose(&h, &e);
let _ = candidates.iter(); }
#[test]
fn test_proposer_constraint_accepts_valid_candidate() {
let atom_a = Atom {
system: OntologySystem::SNOMED,
code: "67822003".to_string(),
preferred_term: "Hypoxemia".to_string(),
version: "2026-01-31".to_string(),
};
let atom_b = si_atom_ards();
let input = Hyp::new(vec![atom_a.clone()]);
let candidate = Hyp::new(vec![atom_a, atom_b]);
let evidence = Evidence::new(vec![], test_provenance());
let constraint = ProposerConstraint::new();
let result = constraint.validate(&candidate, &input, &evidence);
assert!(
result.is_ok(),
"Valid candidate (refines input, ontology-bounded) should pass both clauses"
);
}
#[test]
fn test_proposer_constraint_rejects_non_refining_candidate() {
let candidate_atom = Atom {
system: OntologySystem::SNOMED,
code: "67822003".to_string(),
preferred_term: "Hypoxemia".to_string(),
version: "2026-01-31".to_string(),
};
let input = Hyp::new(vec![candidate_atom.clone()]);
let candidate = Hyp::unknown(); let evidence = Evidence::new(vec![], test_provenance());
let constraint = ProposerConstraint::new();
let result = constraint.validate(&candidate, &input, &evidence);
assert!(
result.is_err(),
"Non-refining candidate (doesn't satisfy operator-reachability) should fail"
);
}
#[test]
fn test_proposer_constraint_accepts_known_ontology_atom() {
let input = Hyp::unknown();
let valid_candidate = Hyp::new(vec![Atom {
system: OntologySystem::SNOMED,
code: "67822003".to_string(),
preferred_term: "Hypoxemia".to_string(),
version: "2026-01-31".to_string(),
}]);
let evidence = Evidence::new(vec![], test_provenance());
let constraint = ProposerConstraint::new();
let result = constraint.validate(&valid_candidate, &input, &evidence);
assert!(
result.is_ok(),
"Candidate with valid ontology atoms should pass ontology-bounded check"
);
}
#[test]
fn test_proposer_constraint_input_gate_rejects_invalid_evidence() {
let bad_input = Hyp::new(vec![Atom {
system: OntologySystem::Unstructured,
code: "clinician-note".to_string(),
preferred_term: "Some clinical observation".to_string(),
version: "2026-01-31".to_string(),
}]);
let evidence = Evidence::new(vec![], test_provenance());
let constraint = ProposerConstraint::new();
let result = constraint.validate_input(&bad_input, &evidence);
assert!(
result.is_err(),
"Input gate should reject Unstructured atoms"
);
let err = result.unwrap_err();
assert!(
err.ontology_bounded_failed(),
"Error should report ontology-bounded failure for Unstructured input"
);
}
#[test]
fn test_propose_and_filter_accepts_valid_candidates() {
let input = Hyp::unknown();
let atom_a = Atom {
system: OntologySystem::SNOMED,
code: "67822003".to_string(),
preferred_term: "Hypoxemia".to_string(),
version: "2026-01-31".to_string(),
};
let valid_candidate = Hyp::new(vec![atom_a]);
let proposer = TestProposer::new(vec![valid_candidate.clone()]);
let evidence = Evidence::new(vec![], test_provenance());
let result = propose_and_filter(&proposer, &input, &evidence);
assert_eq!(
result.valid_candidates.len(),
1,
"Valid candidate should pass filtering"
);
assert_eq!(result.filtered_out_count, 0);
assert!(result.all_passed(), "No errors should be recorded");
}
#[test]
fn test_propose_and_filter_rejects_invalid_candidates() {
let input = Hyp::unknown();
let invalid_candidate = Hyp::new(vec![Atom {
system: OntologySystem::Unstructured,
code: "bad".to_string(),
preferred_term: "Invalid".to_string(),
version: "2026-01-31".to_string(),
}]);
let proposer = TestProposer::new(vec![invalid_candidate]);
let evidence = Evidence::new(vec![], test_provenance());
let result = propose_and_filter(&proposer, &input, &evidence);
assert_eq!(
result.valid_candidates.len(),
0,
"Invalid candidate should be filtered"
);
assert_eq!(result.filtered_out_count, 1);
assert!(!result.all_passed(), "Should record filtering error");
assert_eq!(result.filter_errors.len(), 1);
assert!(result.filter_errors[0].ontology_bounded_failed());
}
#[test]
fn test_propose_and_filter_mixed_candidates() {
let input = Hyp::unknown();
let valid_atom = Atom {
system: OntologySystem::SNOMED,
code: "67822003".to_string(),
preferred_term: "Hypoxemia".to_string(),
version: "2026-01-31".to_string(),
};
let invalid_atom = Atom {
system: OntologySystem::Unstructured,
code: "bad".to_string(),
preferred_term: "Invalid".to_string(),
version: "2026-01-31".to_string(),
};
let proposer = TestProposer::new(vec![
Hyp::new(vec![valid_atom]),
Hyp::new(vec![invalid_atom]),
]);
let evidence = Evidence::new(vec![], test_provenance());
let result = propose_and_filter(&proposer, &input, &evidence);
assert_eq!(
result.valid_candidates.len(),
1,
"One valid candidate should pass"
);
assert_eq!(
result.filtered_out_count, 1,
"One invalid candidate should be filtered"
);
assert_eq!(result.filter_errors.len(), 1);
}
#[test]
fn test_propose_and_filter_rejects_invalid_input() {
let invalid_input = Hyp::new(vec![Atom {
system: OntologySystem::Unstructured,
code: "bad".to_string(),
preferred_term: "Invalid".to_string(),
version: "2026-01-31".to_string(),
}]);
let proposer = TestProposer::empty(); let evidence = Evidence::new(vec![], test_provenance());
let result = propose_and_filter(&proposer, &invalid_input, &evidence);
assert_eq!(result.valid_candidates.len(), 0);
assert_eq!(
result.filter_errors.len(),
1,
"Input gate should reject invalid hypothesis"
);
assert!(result.filter_errors[0].ontology_bounded_failed());
}
#[test]
fn test_filter_result_all_passed() {
let result = FilterResult::new(vec![Hyp::unknown()].into_iter().collect(), 0, vec![]);
assert!(result.all_passed());
assert!(result.has_valid_candidates());
}
#[test]
fn test_verify_result_disjoint_check() {
let atom_a = Atom {
system: OntologySystem::SNOMED,
code: "67822003".to_string(),
preferred_term: "Hypoxemia".to_string(),
version: "2026-01-31".to_string(),
};
let candidate_a = Hyp::new(vec![atom_a]);
let mut licensed = CandidateSet::new();
let unlicensed = CandidateSet::new();
licensed.insert(candidate_a.clone());
let result = VerifyResult::new(licensed.clone(), unlicensed, vec![], vec![]);
assert_eq!(result.licensed_candidates.len(), 1);
assert!(result.unlicensed_candidates.is_empty());
}
#[test]
fn test_proposer_constraint_returns_structured_error() {
let atom_a = Atom {
system: OntologySystem::SNOMED,
code: "67822003".to_string(),
preferred_term: "Hypoxemia".to_string(),
version: "2026-01-31".to_string(),
};
let atom_b = si_atom_ards();
let input = Hyp::new(vec![atom_a.clone(), atom_b.clone()]);
let non_refinement_candidate = Hyp::new(vec![atom_a]);
let evidence = Evidence::new(vec![], test_provenance());
let constraint = ProposerConstraint::new();
let result = constraint.validate(&non_refinement_candidate, &input, &evidence);
match result {
Err(e) => {
assert!(
e.operator_reachable_failed(),
"Error should report operator-reachable clause failed"
);
}
Ok(()) => panic!("Expected validation to fail: candidate must refine input"),
}
}
use crate::operator::Operator;
use crate::operator_set::{OperatorMetadata, OperatorSet};
use crate::outcome::Outcome;
struct RefiningOperatorFixture {
atom_to_add: Atom,
}
impl Operator for RefiningOperatorFixture {
fn apply(&self, h: &Hyp, _e: &Evidence) -> Outcome<Hyp, crate::abstain::AbstainReason> {
let mut atoms = h.atoms().to_vec();
atoms.push(self.atom_to_add.clone());
Outcome::Refined(Hyp::new(atoms))
}
}
#[test]
fn test_propose_verify_licenses_candidates_in_operator_result() {
let atom_a = Atom {
system: OntologySystem::SNOMED,
code: "67822003".to_string(),
preferred_term: "Hypoxemia".to_string(),
version: "2026-01-31".to_string(),
};
let atom_b = si_atom_ards();
let candidate = Hyp::new(vec![atom_a.clone()]);
let proposer = TestProposer::new(vec![candidate.clone()]);
let input = Hyp::new(vec![atom_a.clone()]);
let mut operators = OperatorSet::new();
operators = operators.register(
Box::new(RefiningOperatorFixture {
atom_to_add: atom_b.clone(),
}),
OperatorMetadata {
name: "AddB".to_string(),
version: "test".to_string(),
},
);
let evidence = Evidence::new(vec![], test_provenance());
let result = propose_verify(&proposer, &operators, &input, &evidence);
assert!(
result.is_ok(),
"propose_verify should succeed when candidates are licensed"
);
let verify_result = result.unwrap();
assert!(verify_result.has_licensed_candidates());
assert_eq!(verify_result.licensed_candidates.len(), 1);
}
#[test]
fn test_propose_verify_rejects_unlicensed_candidates() {
let atom_a = Atom {
system: OntologySystem::SNOMED,
code: "67822003".to_string(),
preferred_term: "Hypoxemia".to_string(),
version: "2026-01-31".to_string(),
};
let atom_b = si_atom_ards();
let candidate = Hyp::new(vec![atom_a.clone()]);
let proposer = TestProposer::new(vec![candidate]);
let input = Hyp::unknown();
let mut operators = OperatorSet::new();
operators = operators.register(
Box::new(RefiningOperatorFixture {
atom_to_add: atom_b.clone(),
}),
OperatorMetadata {
name: "AddB".to_string(),
version: "test".to_string(),
},
);
let evidence = Evidence::new(vec![], test_provenance());
let result = propose_verify(&proposer, &operators, &input, &evidence);
assert!(
result.is_err(),
"propose_verify should return Err when all candidates are unlicensed"
);
match result.unwrap_err() {
crate::abstain::AbstainReason::NoOperatorLicenses(_) => {
}
other => panic!("Expected NoOperatorLicenses, got {:?}", other),
}
}
#[test]
fn test_propose_verify_all_candidates_licensed() {
let atom_a = Atom {
system: OntologySystem::SNOMED,
code: "67822003".to_string(),
preferred_term: "Hypoxemia".to_string(),
version: "2026-01-31".to_string(),
};
let atom_b = si_atom_ards();
let candidate_a = Hyp::new(vec![atom_a.clone()]);
let candidate_b = Hyp::new(vec![atom_b.clone()]);
let proposer = TestProposer::new(vec![candidate_a, candidate_b]);
let input = Hyp::unknown();
let mut operators = OperatorSet::new();
operators = operators.register(
Box::new(RefiningOperatorFixture {
atom_to_add: atom_a.clone(),
}),
OperatorMetadata {
name: "AddA".to_string(),
version: "test".to_string(),
},
);
operators = operators.register(
Box::new(RefiningOperatorFixture {
atom_to_add: atom_b.clone(),
}),
OperatorMetadata {
name: "AddB".to_string(),
version: "test".to_string(),
},
);
let evidence = Evidence::new(vec![], test_provenance());
let result = propose_verify(&proposer, &operators, &input, &evidence);
assert!(result.is_ok());
let verify_result = result.unwrap();
assert_eq!(
verify_result.licensed_candidates.len(),
2,
"Both candidates should be licensed"
);
assert!(
verify_result.unlicensed_candidates.is_empty(),
"No candidates should be unlicensed"
);
}
#[test]
fn test_propose_verify_truly_mixed_licensed_unlicensed() {
let atom_a = Atom {
system: OntologySystem::SNOMED,
code: "67822003".to_string(),
preferred_term: "Hypoxemia".to_string(),
version: "2026-01-31".to_string(),
};
let atom_b = si_atom_ards();
let atom_c = Atom {
system: OntologySystem::SNOMED,
code: "2081003".to_string(),
preferred_term: "Sepsis".to_string(),
version: "2026-01-31".to_string(),
};
let candidate_a = Hyp::new(vec![atom_a.clone()]);
let candidate_c = Hyp::new(vec![atom_c.clone()]);
let proposer = TestProposer::new(vec![candidate_a, candidate_c]);
let input = Hyp::unknown();
let mut operators = OperatorSet::new();
operators = operators.register(
Box::new(RefiningOperatorFixture {
atom_to_add: atom_a.clone(),
}),
OperatorMetadata {
name: "AddA".to_string(),
version: "test".to_string(),
},
);
operators = operators.register(
Box::new(RefiningOperatorFixture {
atom_to_add: atom_b.clone(),
}),
OperatorMetadata {
name: "AddB".to_string(),
version: "test".to_string(),
},
);
let evidence = Evidence::new(vec![], test_provenance());
let result = propose_verify(&proposer, &operators, &input, &evidence);
assert!(result.is_ok());
let verify_result = result.unwrap();
assert_eq!(
verify_result.licensed_candidates.len(),
1,
"atom A should be licensed"
);
assert_eq!(
verify_result.unlicensed_candidates.len(),
1,
"atom C should be unlicensed"
);
}
#[test]
fn test_propose_verify_empty_proposer_output() {
let proposer = TestProposer::empty();
let input = Hyp::unknown();
let operators = OperatorSet::new();
let evidence = Evidence::new(vec![], test_provenance());
let result = propose_verify(&proposer, &operators, &input, &evidence);
assert!(
result.is_err(),
"Empty proposer output should result in NoOperatorLicenses"
);
match result.unwrap_err() {
crate::abstain::AbstainReason::NoOperatorLicenses(_) => {
}
other => panic!("Expected NoOperatorLicenses, got {:?}", other),
}
}
#[test]
fn test_propose_verify_with_constraint_filtering() {
let valid_atom = Atom {
system: OntologySystem::SNOMED,
code: "67822003".to_string(),
preferred_term: "Hypoxemia".to_string(),
version: "2026-01-31".to_string(),
};
let invalid_atom = Atom {
system: OntologySystem::Unstructured,
code: "bad".to_string(),
preferred_term: "Invalid".to_string(),
version: "2026-01-31".to_string(),
};
let valid_candidate = Hyp::new(vec![valid_atom.clone()]);
let invalid_candidate = Hyp::new(vec![invalid_atom]);
let proposer = TestProposer::new(vec![valid_candidate.clone(), invalid_candidate]);
let input = Hyp::unknown();
let mut operators = OperatorSet::new();
operators = operators.register(
Box::new(RefiningOperatorFixture {
atom_to_add: valid_atom.clone(),
}),
OperatorMetadata {
name: "AddValid".to_string(),
version: "test".to_string(),
},
);
let evidence = Evidence::new(vec![], test_provenance());
let result = propose_verify(&proposer, &operators, &input, &evidence);
assert!(result.is_ok());
let verify_result = result.unwrap();
assert_eq!(verify_result.licensed_candidates.len(), 1);
}
#[test]
fn test_propose_verify_audit_trail() {
let atom_a = Atom {
system: OntologySystem::SNOMED,
code: "67822003".to_string(),
preferred_term: "Hypoxemia".to_string(),
version: "2026-01-31".to_string(),
};
let atom_b = si_atom_ards();
let candidate_a = Hyp::new(vec![atom_a.clone()]);
let candidate_b = Hyp::new(vec![atom_b.clone()]);
let proposer = TestProposer::new(vec![candidate_a, candidate_b]);
let input = Hyp::unknown();
let mut operators = OperatorSet::new();
operators = operators.register(
Box::new(RefiningOperatorFixture {
atom_to_add: atom_a.clone(),
}),
OperatorMetadata {
name: "AddA".to_string(),
version: "test".to_string(),
},
);
operators = operators.register(
Box::new(RefiningOperatorFixture {
atom_to_add: atom_b.clone(),
}),
OperatorMetadata {
name: "AddB".to_string(),
version: "test".to_string(),
},
);
let evidence = Evidence::new(vec![], test_provenance());
let result = propose_verify(&proposer, &operators, &input, &evidence);
assert!(result.is_ok());
let verify_result = result.unwrap();
assert_eq!(verify_result.licensing_verdicts.len(), 2);
assert!(
verify_result
.licensing_verdicts
.iter()
.all(|(_, is_licensed, _)| *is_licensed),
"Audit trail should show both candidates as licensed"
);
}
struct UnstructuredProposer;
impl RefinementProposer for UnstructuredProposer {
fn propose(&self, _h: &Hyp, _e: &Evidence) -> CandidateSet {
let mut candidates = CandidateSet::new();
candidates.insert(Hyp::new(vec![Atom {
system: OntologySystem::Unstructured,
code: "bad".to_string(),
preferred_term: "Invalid".to_string(),
version: "2026-01-31".to_string(),
}]));
candidates
}
}
struct EmptyCodeProposer;
impl RefinementProposer for EmptyCodeProposer {
fn propose(&self, _h: &Hyp, _e: &Evidence) -> CandidateSet {
let mut candidates = CandidateSet::new();
candidates.insert(Hyp::new(vec![Atom {
system: OntologySystem::SNOMED,
code: "".to_string(),
preferred_term: "Empty Code".to_string(),
version: "2026-01-31".to_string(),
}]));
candidates
}
}
struct MixedProposer;
impl RefinementProposer for MixedProposer {
fn propose(&self, _h: &Hyp, _e: &Evidence) -> CandidateSet {
let mut candidates = CandidateSet::new();
candidates.insert(Hyp::new(vec![
Atom {
system: OntologySystem::SNOMED,
code: "67822003".to_string(),
preferred_term: "Hypoxemia".to_string(),
version: "2026-01-31".to_string(),
},
Atom {
system: OntologySystem::Unstructured,
code: "bad".to_string(),
preferred_term: "Invalid".to_string(),
version: "2026-01-31".to_string(),
},
]));
candidates
}
}
struct PurelyUnstructuredProposer;
impl RefinementProposer for PurelyUnstructuredProposer {
fn propose(&self, _h: &Hyp, _e: &Evidence) -> CandidateSet {
let mut candidates = CandidateSet::new();
for i in 0..3 {
candidates.insert(Hyp::new(vec![Atom {
system: OntologySystem::Unstructured,
code: format!("bad{}", i),
preferred_term: format!("Invalid {}", i),
version: "2026-01-31".to_string(),
}]));
}
candidates
}
}
struct NonRefiningProposer;
impl RefinementProposer for NonRefiningProposer {
fn propose(&self, h: &Hyp, _e: &Evidence) -> CandidateSet {
let mut candidates = CandidateSet::new();
if !h.atoms().is_empty() {
candidates.insert(Hyp::unknown());
}
candidates
}
}
#[test]
fn test_inv_ps_06_unstructured_atoms_filtered() {
let proposer = UnstructuredProposer;
let input = Hyp::unknown();
let evidence = Evidence::new(vec![], test_provenance());
let filter_result = propose_and_filter(&proposer, &input, &evidence);
assert_eq!(
filter_result.valid_candidates.len(),
0,
"Unstructured atoms must be rejected at constraint stage"
);
assert!(!filter_result.all_passed(), "Filter should record errors");
}
#[test]
fn test_inv_ps_06_empty_codes_filtered() {
let proposer = EmptyCodeProposer;
let input = Hyp::unknown();
let _operators = OperatorSet::new();
let evidence = Evidence::new(vec![], test_provenance());
let filter_result = propose_and_filter(&proposer, &input, &evidence);
assert_eq!(
filter_result.valid_candidates.len(),
0,
"Empty codes must be rejected at constraint stage"
);
}
#[test]
fn test_inv_ps_06_mixed_valid_invalid_filtered() {
let proposer = MixedProposer;
let input = Hyp::unknown();
let _operators = OperatorSet::new();
let evidence = Evidence::new(vec![], test_provenance());
let filter_result = propose_and_filter(&proposer, &input, &evidence);
assert_eq!(
filter_result.valid_candidates.len(),
0,
"Candidates with any Unstructured atoms must be rejected"
);
}
#[test]
fn test_inv_ps_06_purely_unstructured_proposer_blocked() {
let proposer = PurelyUnstructuredProposer;
let input = Hyp::unknown();
let _operators = OperatorSet::new();
let evidence = Evidence::new(vec![], test_provenance());
let filter_result = propose_and_filter(&proposer, &input, &evidence);
assert!(
filter_result.valid_candidates.is_empty(),
"All unstructured candidates must be filtered"
);
assert_eq!(
filter_result.filtered_out_count, 3,
"All 3 unstructured candidates rejected"
);
}
#[test]
fn test_inv_ps_06_non_refining_candidates_filtered() {
let atom_a = Atom {
system: OntologySystem::SNOMED,
code: "67822003".to_string(),
preferred_term: "Hypoxemia".to_string(),
version: "2026-01-31".to_string(),
};
let input = Hyp::new(vec![atom_a]);
let proposer = NonRefiningProposer;
let _operators = OperatorSet::new();
let evidence = Evidence::new(vec![], test_provenance());
let filter_result = propose_and_filter(&proposer, &input, &evidence);
assert_eq!(
filter_result.valid_candidates.len(),
0,
"Non-refining candidates (Hyp::unknown() when input has atoms) must be filtered"
);
}
#[test]
fn test_inv_ps_06_empty_proposer_output_safe() {
let proposer = TestProposer::empty();
let input = Hyp::unknown();
let _operators = OperatorSet::new();
let evidence = Evidence::new(vec![], test_provenance());
let filter_result = propose_and_filter(&proposer, &input, &evidence);
assert!(
filter_result.valid_candidates.is_empty(),
"Empty proposer output means no candidates to filter — structural safety holds"
);
}
#[test]
fn test_inv_ps_06_propose_verify_rejects_all_unlicensed() {
struct ValidButUnlicensedProposer;
impl RefinementProposer for ValidButUnlicensedProposer {
fn propose(&self, _h: &Hyp, _e: &Evidence) -> CandidateSet {
let mut candidates = CandidateSet::new();
candidates.insert(Hyp::new(vec![Atom {
system: OntologySystem::SNOMED,
code: "999999".to_string(),
preferred_term: "WillNotBeProduced".to_string(),
version: "2026-01-31".to_string(),
}]));
candidates
}
}
let proposer = ValidButUnlicensedProposer;
let input = Hyp::unknown();
let operators = OperatorSet::new();
let evidence = Evidence::new(vec![], test_provenance());
let result = propose_verify(&proposer, &operators, &input, &evidence);
assert!(
result.is_err(),
"Valid but unlicensed candidates should be rejected by licensing gate"
);
match result.unwrap_err() {
crate::abstain::AbstainReason::NoOperatorLicenses(_) => {
}
other => panic!("Expected NoOperatorLicenses, got {:?}", other),
}
}
#[test]
fn test_inv_ps_06_full_pipeline_structural_property() {
struct AdversarialProposer;
impl RefinementProposer for AdversarialProposer {
fn propose(&self, _h: &Hyp, _e: &Evidence) -> CandidateSet {
let mut candidates = CandidateSet::new();
candidates.insert(Hyp::new(vec![Atom {
system: OntologySystem::Unstructured,
code: "free_text".to_string(),
preferred_term: "Should fail".to_string(),
version: "2026-01-31".to_string(),
}]));
candidates.insert(Hyp::new(vec![Atom {
system: OntologySystem::SNOMED,
code: "".to_string(),
preferred_term: "Empty code".to_string(),
version: "2026-01-31".to_string(),
}]));
candidates
}
}
let proposer = AdversarialProposer;
let input = Hyp::unknown();
let operators = OperatorSet::new();
let evidence = Evidence::new(vec![], test_provenance());
let filter_result = propose_and_filter(&proposer, &input, &evidence);
assert!(
filter_result.valid_candidates.is_empty(),
"All adversarial candidates should be filtered by constraint validation"
);
let verify_result = propose_verify(&proposer, &operators, &input, &evidence);
assert!(
verify_result.is_err(),
"propose_verify should abstain when all candidates are filtered"
);
match verify_result.unwrap_err() {
crate::abstain::AbstainReason::NoOperatorLicenses(_) => {
}
other => panic!("Expected NoOperatorLicenses, got {:?}", other),
}
}
#[test]
fn test_inv_ps_06_input_gate_blocks_invalid_input() {
let invalid_input = Hyp::new(vec![Atom {
system: OntologySystem::Unstructured,
code: "bad_input".to_string(),
preferred_term: "Invalid input".to_string(),
version: "2026-01-31".to_string(),
}]);
let proposer = TestProposer::empty();
let evidence = Evidence::new(vec![], test_provenance());
let filter_result = propose_and_filter(&proposer, &invalid_input, &evidence);
assert!(
filter_result.valid_candidates.is_empty(),
"Invalid input hypothesis should be rejected at input-side gate"
);
assert_eq!(
filter_result.filter_errors.len(),
1,
"Input-side validation failure recorded"
);
}
#[test]
fn test_inv_ps_06_ontology_bounded_subset_safety() {
struct SubsetProposer {
atom: Atom,
}
impl RefinementProposer for SubsetProposer {
fn propose(&self, _h: &Hyp, _e: &Evidence) -> CandidateSet {
let mut candidates = CandidateSet::new();
candidates.insert(Hyp::new(vec![self.atom.clone()]));
candidates
}
}
let atom = Atom {
system: OntologySystem::RxNorm,
code: "99999".to_string(),
preferred_term: "NeverProduced".to_string(),
version: "2026-01-31".to_string(),
};
let proposer = SubsetProposer { atom: atom.clone() };
let input = Hyp::unknown();
let mut operators = OperatorSet::new();
operators = operators.register(
Box::new(RefiningOperatorFixture {
atom_to_add: Atom {
system: OntologySystem::SNOMED,
code: "67822003".to_string(),
preferred_term: "Hypoxemia".to_string(),
version: "2026-01-31".to_string(),
},
}),
OperatorMetadata {
name: "AddHypoxemia".to_string(),
version: "test".to_string(),
},
);
let evidence = Evidence::new(vec![], test_provenance());
let filter_result = propose_and_filter(&proposer, &input, &evidence);
assert_eq!(
filter_result.valid_candidates.len(),
1,
"Valid RxNorm candidate passes constraint filtering"
);
let verify_result = propose_verify(&proposer, &operators, &input, &evidence);
assert!(
verify_result.is_err(),
"Valid but unlicensed RxNorm candidate must be rejected by licensing gate"
);
match verify_result.unwrap_err() {
crate::abstain::AbstainReason::NoOperatorLicenses(_) => {
}
other => panic!("Expected NoOperatorLicenses, got {:?}", other),
}
}
use crate::lattice_search::LatticeSearchProposer;
use crate::llm_proposer::LlmProposer;
use crate::llm_proposer_config::LlmProposerConfig;
fn si_atom_hypoxemia() -> Atom {
Atom {
system: OntologySystem::SNOMED,
code: "67822003".to_string(),
preferred_term: "Hypoxemia".to_string(),
version: "2026-01-31".to_string(),
}
}
fn si_atom_ards() -> Atom {
Atom {
system: OntologySystem::SNOMED,
code: "67782005".to_string(),
preferred_term: "ARDS".to_string(),
version: "2026-01-31".to_string(),
}
}
#[test]
fn test_substrate_invariance_paired_proposers_simple_case() {
let atom_a = si_atom_hypoxemia();
let atom_b = si_atom_ards();
let input = Hyp::new(vec![atom_a.clone()]);
let evidence = Evidence::new(vec![], test_provenance());
let operators_for_proposer = OperatorSet::new().register(
Box::new(RefiningOperatorFixture {
atom_to_add: atom_b.clone(),
}),
OperatorMetadata {
name: "AddARDS".to_string(),
version: "test".to_string(),
},
);
let lattice_proposer = LatticeSearchProposer::new(operators_for_proposer);
let operators_verify1 = OperatorSet::new().register(
Box::new(RefiningOperatorFixture {
atom_to_add: atom_b.clone(),
}),
OperatorMetadata {
name: "AddARDS".to_string(),
version: "test".to_string(),
},
);
let expected_candidate_str = format!(
"SNOMED:{}@2026-01-31, SNOMED:{}@2026-01-31",
atom_a.code, atom_b.code
);
let config = LlmProposerConfig::mock("test-model", vec![expected_candidate_str], "0.1.0");
let llm_proposer = LlmProposer::new(config);
let operators_verify2 = OperatorSet::new().register(
Box::new(RefiningOperatorFixture {
atom_to_add: atom_b.clone(),
}),
OperatorMetadata {
name: "AddARDS".to_string(),
version: "test".to_string(),
},
);
let result_lattice =
propose_verify(&lattice_proposer, &operators_verify1, &input, &evidence);
let result_llm = propose_verify(&llm_proposer, &operators_verify2, &input, &evidence);
assert!(
result_lattice.is_ok(),
"LatticeSearchProposer should succeed"
);
assert!(result_llm.is_ok(), "LlmProposer should succeed");
let verify_lattice = result_lattice.unwrap();
let verify_llm = result_llm.unwrap();
assert_eq!(
verify_lattice.licensed_candidates, verify_llm.licensed_candidates,
"Substrate-invariance: identical input → identical licensed candidates"
);
}
#[test]
fn test_substrate_invariance_both_hallucinate_differently() {
let atom_a = si_atom_hypoxemia();
let atom_b = si_atom_ards();
let input = Hyp::new(vec![atom_a.clone()]);
let evidence = Evidence::new(vec![], test_provenance());
let operators_for_proposer = OperatorSet::new().register(
Box::new(RefiningOperatorFixture {
atom_to_add: atom_b.clone(),
}),
OperatorMetadata {
name: "AddARDS".to_string(),
version: "test".to_string(),
},
);
let lattice_proposer = LatticeSearchProposer::new(operators_for_proposer);
let response_with_hallucination = format!(
"SNOMED:{}@2026-01-31, SNOMED:{}@2026-01-31\nMALFORMED_GARBAGE",
atom_a.code, atom_b.code
);
let config =
LlmProposerConfig::mock("test-model", vec![response_with_hallucination], "0.1.0");
let llm_proposer = LlmProposer::new(config);
let operators_verify1 = OperatorSet::new().register(
Box::new(RefiningOperatorFixture {
atom_to_add: atom_b.clone(),
}),
OperatorMetadata {
name: "AddARDS".to_string(),
version: "test".to_string(),
},
);
let operators_verify2 = OperatorSet::new().register(
Box::new(RefiningOperatorFixture {
atom_to_add: atom_b.clone(),
}),
OperatorMetadata {
name: "AddARDS".to_string(),
version: "test".to_string(),
},
);
let result_lattice =
propose_verify(&lattice_proposer, &operators_verify1, &input, &evidence);
let result_llm = propose_verify(&llm_proposer, &operators_verify2, &input, &evidence);
assert!(result_lattice.is_ok());
assert!(result_llm.is_ok());
let verify_lattice = result_lattice.unwrap();
let verify_llm = result_llm.unwrap();
assert_eq!(
verify_lattice.licensed_candidates, verify_llm.licensed_candidates,
"Even with LLM hallucinations, licensed candidates are identical (hallucinations filtered)"
);
}
#[test]
fn test_substrate_invariance_multiple_paired_cases() {
let atom_base = si_atom_hypoxemia();
let atoms_to_test: Vec<Atom> = (0..10)
.map(|i| Atom {
system: OntologySystem::SNOMED,
code: format!("{:06}", 100000 + i),
preferred_term: format!("TestAtom{}", i),
version: "2026-01-31".to_string(),
})
.collect();
let input = Hyp::new(vec![atom_base.clone()]);
let evidence = Evidence::new(vec![], test_provenance());
for (i, atom) in atoms_to_test.iter().enumerate() {
let op_name = format!("OpAdd{}", i);
let operators_for_proposer = OperatorSet::new().register(
Box::new(RefiningOperatorFixture {
atom_to_add: atom.clone(),
}),
OperatorMetadata {
name: op_name.clone(),
version: "test".to_string(),
},
);
let lattice_proposer = LatticeSearchProposer::new(operators_for_proposer);
let expected_response = format!(
"SNOMED:{}@2026-01-31, SNOMED:{}@2026-01-31",
atom_base.code, atom.code
);
let config = LlmProposerConfig::mock("test-model", vec![expected_response], "0.1.0");
let llm_proposer = LlmProposer::new(config);
let operators_verify1 = OperatorSet::new().register(
Box::new(RefiningOperatorFixture {
atom_to_add: atom.clone(),
}),
OperatorMetadata {
name: op_name.clone(),
version: "test".to_string(),
},
);
let operators_verify2 = OperatorSet::new().register(
Box::new(RefiningOperatorFixture {
atom_to_add: atom.clone(),
}),
OperatorMetadata {
name: op_name.clone(),
version: "test".to_string(),
},
);
let result_lattice =
propose_verify(&lattice_proposer, &operators_verify1, &input, &evidence);
let result_llm = propose_verify(&llm_proposer, &operators_verify2, &input, &evidence);
assert!(
result_lattice.is_ok(),
"Case {}: LatticeSearch should succeed",
i
);
assert!(result_llm.is_ok(), "Case {}: LlmProposer should succeed", i);
let verify_lattice = result_lattice.unwrap();
let verify_llm = result_llm.unwrap();
assert_eq!(
verify_lattice.licensed_candidates, verify_llm.licensed_candidates,
"Case {}: licensed candidates must match",
i
);
}
}
#[test]
fn test_substrate_invariance_both_abstain_no_candidates() {
let atom_a = si_atom_hypoxemia();
let input = Hyp::new(vec![atom_a]);
let evidence = Evidence::new(vec![], test_provenance());
let operators_for_proposer = OperatorSet::new();
let lattice_proposer = LatticeSearchProposer::new(operators_for_proposer);
let config = LlmProposerConfig::mock(
"test-model",
vec!["".to_string()], "0.1.0",
);
let llm_proposer = LlmProposer::new(config);
let operators_verify = OperatorSet::new();
let result_lattice =
propose_verify(&lattice_proposer, &operators_verify, &input, &evidence);
let result_llm = propose_verify(&llm_proposer, &operators_verify, &input, &evidence);
assert!(
result_lattice.is_err(),
"LatticeSearch on empty operators should abstain"
);
assert!(
result_llm.is_err(),
"LlmProposer on empty operators should abstain"
);
match (result_lattice.unwrap_err(), result_llm.unwrap_err()) {
(
crate::abstain::AbstainReason::NoOperatorLicenses(_),
crate::abstain::AbstainReason::NoOperatorLicenses(_),
) => {
}
other => panic!(
"Expected both to abstain with NoOperatorLicenses, got {:?}",
other
),
}
}
#[test]
fn test_substrate_invariance_llm_mixed_valid_invalid_responses() {
let atom_a = si_atom_hypoxemia();
let atom_b = si_atom_ards();
let input = Hyp::new(vec![atom_a.clone()]);
let evidence = Evidence::new(vec![], test_provenance());
let operators_for_proposer = OperatorSet::new().register(
Box::new(RefiningOperatorFixture {
atom_to_add: atom_b.clone(),
}),
OperatorMetadata {
name: "AddARDS".to_string(),
version: "test".to_string(),
},
);
let lattice_proposer = LatticeSearchProposer::new(operators_for_proposer);
let response_mixed = format!(
"SNOMED:{}@2026-01-31, SNOMED:{}@2026-01-31, Unstructured free text",
atom_a.code, atom_b.code
);
let config = LlmProposerConfig::mock("test-model", vec![response_mixed], "0.1.0");
let llm_proposer = LlmProposer::new(config);
let operators_verify1 = OperatorSet::new().register(
Box::new(RefiningOperatorFixture {
atom_to_add: atom_b.clone(),
}),
OperatorMetadata {
name: "AddARDS".to_string(),
version: "test".to_string(),
},
);
let operators_verify2 = OperatorSet::new().register(
Box::new(RefiningOperatorFixture {
atom_to_add: atom_b.clone(),
}),
OperatorMetadata {
name: "AddARDS".to_string(),
version: "test".to_string(),
},
);
let result_lattice =
propose_verify(&lattice_proposer, &operators_verify1, &input, &evidence);
let result_llm = propose_verify(&llm_proposer, &operators_verify2, &input, &evidence);
assert!(result_lattice.is_ok());
assert!(result_llm.is_ok());
let verify_lattice = result_lattice.unwrap();
let verify_llm = result_llm.unwrap();
assert_eq!(
verify_lattice.licensed_candidates, verify_llm.licensed_candidates,
"Invalid LLM responses are filtered; licensed candidates identical"
);
}
#[test]
fn test_substrate_invariance_operator_licensing_decides_outcome() {
let atom_a = si_atom_hypoxemia();
let atom_b = si_atom_ards();
let atom_unlicensed = Atom {
system: OntologySystem::SNOMED,
code: "999999".to_string(),
preferred_term: "WontBeLicensed".to_string(),
version: "2026-01-31".to_string(),
};
let input = Hyp::new(vec![atom_a.clone()]);
let evidence = Evidence::new(vec![], test_provenance());
let operators_verify1 = OperatorSet::new().register(
Box::new(RefiningOperatorFixture {
atom_to_add: atom_b.clone(),
}),
OperatorMetadata {
name: "AddARDS".to_string(),
version: "test".to_string(),
},
);
struct UnlicensedProposer {
atom: Atom,
}
impl RefinementProposer for UnlicensedProposer {
fn propose(&self, _h: &Hyp, _e: &Evidence) -> CandidateSet {
let mut set = CandidateSet::new();
set.insert(Hyp::new(vec![self.atom.clone()]));
set
}
}
let unlicensed_proposer = UnlicensedProposer {
atom: atom_unlicensed.clone(),
};
let unlicensed_response = format!("SNOMED:{}@2026-01-31", atom_unlicensed.code);
let config = LlmProposerConfig::mock("test-model", vec![unlicensed_response], "0.1.0");
let llm_proposer = LlmProposer::new(config);
let operators_verify2 = OperatorSet::new().register(
Box::new(RefiningOperatorFixture {
atom_to_add: atom_b.clone(),
}),
OperatorMetadata {
name: "AddARDS".to_string(),
version: "test".to_string(),
},
);
let result_unlicensed =
propose_verify(&unlicensed_proposer, &operators_verify1, &input, &evidence);
let result_llm = propose_verify(&llm_proposer, &operators_verify2, &input, &evidence);
assert!(result_unlicensed.is_err(), "Unlicensed candidate → abstain");
assert!(result_llm.is_err(), "LLM unlicensed candidate → abstain");
match (result_unlicensed.unwrap_err(), result_llm.unwrap_err()) {
(
crate::abstain::AbstainReason::NoOperatorLicenses(_),
crate::abstain::AbstainReason::NoOperatorLicenses(_),
) => {
}
other => panic!(
"Expected both to abstain with NoOperatorLicenses, got {:?}",
other
),
}
}
#[test]
fn test_substrate_invariance_audit_trails_both_populated() {
let atom_a = si_atom_hypoxemia();
let atom_b = si_atom_ards();
let input = Hyp::new(vec![atom_a.clone()]);
let evidence = Evidence::new(vec![], test_provenance());
let operators_for_proposer = OperatorSet::new().register(
Box::new(RefiningOperatorFixture {
atom_to_add: atom_b.clone(),
}),
OperatorMetadata {
name: "AddARDS".to_string(),
version: "test".to_string(),
},
);
let lattice_proposer = LatticeSearchProposer::new(operators_for_proposer);
let response = format!(
"SNOMED:{}@2026-01-31, SNOMED:{}@2026-01-31",
atom_a.code, atom_b.code
);
let config = LlmProposerConfig::mock("test-model", vec![response], "0.1.0");
let llm_proposer = LlmProposer::new(config);
let operators_verify1 = OperatorSet::new().register(
Box::new(RefiningOperatorFixture {
atom_to_add: atom_b.clone(),
}),
OperatorMetadata {
name: "AddARDS".to_string(),
version: "test".to_string(),
},
);
let operators_verify2 = OperatorSet::new().register(
Box::new(RefiningOperatorFixture {
atom_to_add: atom_b.clone(),
}),
OperatorMetadata {
name: "AddARDS".to_string(),
version: "test".to_string(),
},
);
let result_lattice =
propose_verify(&lattice_proposer, &operators_verify1, &input, &evidence);
let result_llm = propose_verify(&llm_proposer, &operators_verify2, &input, &evidence);
assert!(result_lattice.is_ok());
assert!(result_llm.is_ok());
let verify_lattice = result_lattice.unwrap();
let verify_llm = result_llm.unwrap();
assert!(
!verify_lattice.licensing_verdicts.is_empty(),
"LatticeSearch should populate licensing verdicts"
);
assert!(
!verify_llm.licensing_verdicts.is_empty(),
"LlmProposer should populate licensing verdicts"
);
assert_eq!(
verify_lattice.licensing_verdicts.len(),
verify_llm.licensing_verdicts.len(),
"Audit trail verdict counts must match"
);
assert_eq!(
verify_lattice.licensed_candidates, verify_llm.licensed_candidates,
"Audit trails must describe identical refined hypotheses (same licensed candidates)"
);
}
}