use oxibrain_core::resolution::{Decision, ResolutionConfig, normalize, resolve, score};
use oxibrain_core::{EntityKey, KeyOrigin};
use proptest::prelude::*;
fn make_key(entity: &str, normalized: &str, ty: &str) -> EntityKey {
EntityKey {
id: format!("k_{entity}_{normalized}"),
space: "s1".into(),
entity: entity.into(),
ty: ty.into(),
normalized: normalized.into(),
surface: normalized.into(),
origin: KeyOrigin::UserDeclared,
}
}
fn arb_name() -> impl Strategy<Value = String> {
"[a-z]{3,8}"
}
fn arb_type() -> impl Strategy<Value = String> {
prop_oneof![
Just("Person".to_string()),
Just("Organization".to_string()),
Just("Place".to_string()),
]
}
fn arb_context() -> impl Strategy<Value = f64> {
0.0f64..=1.0
}
proptest! {
#[test]
fn score_in_bounds(
name in arb_name(),
ty in arb_type(),
cand_name in arb_name(),
cand_ty in arb_type(),
ctx in arb_context(),
) {
let candidate = make_key("e1", &cand_name, &cand_ty);
let s = score(&candidate, &name, &ty, ctx, 0.0, &ResolutionConfig::default());
prop_assert!((0.0..=1.0).contains(&s), "score {s} must be in [0, 1]");
}
#[test]
fn type_mismatch_zero_score(
name in arb_name(),
cand_name in arb_name(),
ctx in arb_context(),
) {
let candidate = make_key("e1", &cand_name, "Organization");
let s = score(&candidate, &name, &"Person".to_string(), ctx, 0.0, &ResolutionConfig::default());
prop_assert_eq!(s, 0.0, "type mismatch must gate to 0");
}
#[test]
fn exact_match_always_links(
name in arb_name(),
ty in arb_type(),
ctx in arb_context(),
) {
let candidate = make_key("e1", &name, &ty);
let dec = resolve(
&name,
&ty,
&[candidate],
move |_| ctx,
|_| 0.0,
&ResolutionConfig::default(),
);
match dec {
Decision::Link { score, .. } => {
prop_assert!(score >= 0.75, "exact match score {score} must reach tau_high");
}
_ => prop_assert!(false, "exact match must Link, got {dec:?}"),
}
}
#[test]
fn resolve_picks_highest_score(
names in prop::collection::vec(arb_name(), 1..5),
ty in arb_type(),
ctx in arb_context(),
) {
let candidates: Vec<EntityKey> = names
.iter()
.enumerate()
.map(|(i, n)| make_key(&format!("e{i}"), n, &ty))
.collect();
let mention = &names[0];
let dec = resolve(
mention,
&ty,
&candidates,
|_| ctx,
|_| 0.0,
&ResolutionConfig::default(),
);
let best = candidates
.iter()
.map(|c| score(c, mention, &ty, ctx, 0.0, &ResolutionConfig::default()))
.fold(0.0_f64, f64::max);
match &dec {
Decision::Link { score, entity, .. } => {
prop_assert!(*score >= best - 1e-9, "Link score {score} must equal best {best}");
prop_assert!(candidates.iter().any(|c| &c.entity == entity));
}
Decision::New { score, .. } => {
prop_assert!(*score <= 0.25, "New score {score} must be ≤ tau_low");
}
Decision::Candidate { score, .. } => {
prop_assert!((0.25..0.75).contains(score), "Candidate score in ambiguity band");
}
}
}
}
proptest! {
#[test]
fn normalize_idempotent(
s in "[A-Za-z ]{1,20}"
) {
let once = normalize(&s, &"Person".to_string());
let twice = normalize(&once, &"Person".to_string());
prop_assert_eq!(once, twice, "normalize must be idempotent");
}
#[test]
fn normalize_lowercase_collapsed(
s in "[A-Za-z \t]{1,20}"
) {
let n = normalize(&s, &"Person".to_string());
prop_assert!(
!n.chars().any(|c| c.is_uppercase()),
"normalized form must be lowercase: '{n}'"
);
prop_assert!(
!n.contains(" "),
"normalized form must have collapsed whitespace: '{n}'"
);
}
}
#[test]
fn no_candidates_is_new() {
let dec = resolve(
"alice",
&"Person".to_string(),
&[],
|_| 0.0,
|_| 0.0,
&ResolutionConfig::default(),
);
assert!(matches!(dec, Decision::New { .. }));
}
#[test]
fn resolve_deterministic_same_input() {
let candidates = vec![
make_key("e1", "alice", "Person"),
make_key("e2", "alicia", "Person"),
make_key("e3", "bob", "Person"),
];
let d1 = resolve(
"alice",
&"Person".to_string(),
&candidates,
|_| 0.3,
|_| 0.0,
&ResolutionConfig::default(),
);
let d2 = resolve(
"alice",
&"Person".to_string(),
&candidates,
|_| 0.3,
|_| 0.0,
&ResolutionConfig::default(),
);
match (&d1, &d2) {
(
Decision::Link {
entity: e1,
score: s1,
..
},
Decision::Link {
entity: e2,
score: s2,
..
},
) => {
assert_eq!(e1, e2);
assert!((s1 - s2).abs() < 1e-9);
}
(Decision::New { score: s1, .. }, Decision::New { score: s2, .. }) => {
assert!((s1 - s2).abs() < 1e-9);
}
(
Decision::Candidate {
score: s1,
existing: ex1,
..
},
Decision::Candidate {
score: s2,
existing: ex2,
..
},
) => {
assert_eq!(ex1, ex2);
assert!((s1 - s2).abs() < 1e-9);
}
_ => panic!("decisions must match: {d1:?} vs {d2:?}"),
}
}