use panproto_gat::Name;
use panproto_schema::Schema;
use evidence::{Family, Provenance, STRATEGY_COUNT};
pub mod alias;
pub mod coerce;
pub mod defaults;
pub mod description_similarity;
pub mod edge_label;
pub mod evidence;
pub mod exact;
pub mod neighborhood;
pub mod structural;
pub mod suffix;
pub mod token_similarity;
pub mod type_signature;
pub mod wl;
pub mod wrap_unwrap;
pub use alias::{AliasDict, alias_anchors, default_alias_dict};
pub use coerce::{CoerceAnchor, coerce_anchors};
pub use description_similarity::{description_anchors, description_similarity};
pub use edge_label::edge_label_anchors;
pub use exact::exact_anchors;
pub use neighborhood::neighborhood_anchors;
pub use structural::structural_anchors;
pub use suffix::suffix_anchors;
pub use token_similarity::{token_anchors, token_similarity};
pub use type_signature::type_signature_anchors;
pub use wl::wl_anchors;
pub use wrap_unwrap::wrap_unwrap_anchors;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StrategyTag {
UserHint,
Exact,
ExactSuffix,
EdgeLabel,
Alias,
TokenSimilarity,
DescriptionSimilarity,
TypeSignature,
WrapUnwrap,
Coerce,
Neighborhood,
WlRefinement,
Structural,
Llm,
}
#[derive(Clone, Debug)]
pub struct Anchor {
pub src: Name,
pub tgt: Name,
pub confidence: f64,
pub strategy: StrategyTag,
pub provenance: Provenance,
pub explanation: String,
}
impl StrategyTag {
#[must_use]
pub const fn priority(self) -> u8 {
match self {
Self::UserHint => 100,
Self::Exact => 90,
Self::EdgeLabel => 85,
Self::ExactSuffix => 80,
Self::Alias => 70,
Self::TypeSignature => 60,
Self::WrapUnwrap => 55,
Self::TokenSimilarity => 50,
Self::DescriptionSimilarity => 45,
Self::Coerce => 40,
Self::Neighborhood => 35,
Self::WlRefinement => 32,
Self::Structural => 30,
Self::Llm => 20,
}
}
#[must_use]
pub const fn rank(self) -> u32 {
match self {
Self::UserHint => 0,
Self::Exact => 1,
Self::EdgeLabel => 2,
Self::ExactSuffix => 3,
Self::Alias => 4,
Self::TypeSignature => 5,
Self::WrapUnwrap => 6,
Self::TokenSimilarity => 7,
Self::DescriptionSimilarity => 8,
Self::Coerce => 9,
Self::Neighborhood => 10,
Self::WlRefinement => 11,
Self::Structural => 12,
Self::Llm => 13,
}
}
#[must_use]
pub fn band(self) -> (f64, f64) {
let rank = self.rank();
let count = f64::from(STRATEGY_COUNT);
(
f64::from(STRATEGY_COUNT - 1 - rank) / count,
f64::from(STRATEGY_COUNT - rank) / count,
)
}
#[must_use]
pub fn ceiling(self) -> f64 {
self.band().1
}
#[must_use]
pub const fn family(self) -> Family {
match self {
Self::UserHint => Family::UserHint,
Self::Exact | Self::ExactSuffix | Self::Alias | Self::TokenSimilarity => {
Family::Identifier
}
Self::EdgeLabel | Self::WrapUnwrap => Family::EdgeLabel,
Self::DescriptionSimilarity => Family::Documentation,
Self::TypeSignature
| Self::Neighborhood
| Self::WlRefinement
| Self::Structural
| Self::Llm => Family::Structure,
Self::Coerce => Family::Coercion,
}
}
}
#[must_use]
pub fn kinds_compatible(src: &Schema, src_id: &Name, tgt: &Schema, tgt_id: &Name) -> bool {
src.vertex(src_id)
.zip(tgt.vertex(tgt_id))
.is_some_and(|(sv, tv)| sv.kind == tv.kind)
}
#[must_use]
pub fn kinds_and_constraints_compatible(
src: &Schema,
src_id: &Name,
tgt: &Schema,
tgt_id: &Name,
) -> bool {
if !kinds_compatible(src, src_id, tgt, tgt_id) {
return false;
}
let empty: Vec<panproto_schema::Constraint> = Vec::new();
let src_cs = src.constraints.get(src_id).unwrap_or(&empty);
if src_cs.is_empty() {
return true;
}
let tgt_cs = tgt.constraints.get(tgt_id).unwrap_or(&empty);
for sc in src_cs {
let ok = tgt_cs
.iter()
.any(|tc| tc.sort == sc.sort && tc.value == sc.value);
if !ok {
return false;
}
}
true
}
#[must_use]
pub fn vertex_is_required(schema: &Schema, vertex_id: &Name) -> bool {
schema
.required
.values()
.any(|edges| edges.iter().any(|e| &e.tgt == vertex_id))
}
pub fn adjust_anchors_by_required_sets(anchors: &mut [Anchor], src: &Schema, tgt: &Schema) {
for anchor in anchors.iter_mut() {
if anchor.strategy == StrategyTag::UserHint {
continue;
}
let sr = vertex_is_required(src, &anchor.src);
let tr = vertex_is_required(tgt, &anchor.tgt);
let delta = match (sr, tr) {
(true, true) => 0.05,
(true, false) | (false, true) => -0.05,
(false, false) => 0.0,
};
if delta == 0.0 {
continue;
}
anchor.confidence = (anchor.confidence + delta).clamp(0.0, 1.0);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::float_cmp)]
mod required_tiebreak_tests {
use super::*;
use panproto_schema::{Edge, EdgeRule, Protocol, SchemaBuilder};
fn proto() -> Protocol {
Protocol {
name: "t".into(),
schema_theory: "ThTest".into(),
instance_theory: "ThWType".into(),
edge_rules: vec![EdgeRule {
edge_kind: "prop".into(),
src_kinds: vec!["object".into()],
tgt_kinds: vec!["string".into()],
}],
obj_kinds: vec!["object".into(), "string".into()],
constraint_sorts: vec![],
..Protocol::default()
}
}
fn schema_with_required(parent: &str, child: &str, required: bool) -> panproto_schema::Schema {
let p = proto();
let mut b = SchemaBuilder::new(&p)
.vertex(parent, "object", None::<&str>)
.unwrap()
.vertex(child, "string", None::<&str>)
.unwrap()
.edge(parent, child, "prop", Some("f"))
.unwrap();
if required {
let edge = Edge {
src: Name::from(parent),
tgt: Name::from(child),
kind: Name::from("prop"),
name: Some(Name::from("f")),
};
b = b.required(parent, vec![edge]);
}
b.build().unwrap()
}
#[test]
fn required_matching_required_boosts() {
let src = schema_with_required("p", "c", true);
let tgt = schema_with_required("q", "d", true);
let mut anchors = vec![Anchor {
src: Name::from("c"),
tgt: Name::from("d"),
confidence: 0.5,
strategy: StrategyTag::Alias,
provenance: Provenance::Synonym,
explanation: String::new(),
}];
adjust_anchors_by_required_sets(&mut anchors, &src, &tgt);
assert!((anchors[0].confidence - 0.55).abs() < 1e-9);
}
#[test]
fn required_to_optional_penalizes() {
let src = schema_with_required("p", "c", true);
let tgt = schema_with_required("q", "d", false);
let mut anchors = vec![Anchor {
src: Name::from("c"),
tgt: Name::from("d"),
confidence: 0.5,
strategy: StrategyTag::Alias,
provenance: Provenance::Synonym,
explanation: String::new(),
}];
adjust_anchors_by_required_sets(&mut anchors, &src, &tgt);
assert!((anchors[0].confidence - 0.45).abs() < 1e-9);
}
#[test]
fn both_optional_unchanged() {
let src = schema_with_required("p", "c", false);
let tgt = schema_with_required("q", "d", false);
let mut anchors = vec![Anchor {
src: Name::from("c"),
tgt: Name::from("d"),
confidence: 0.5,
strategy: StrategyTag::Alias,
provenance: Provenance::Synonym,
explanation: String::new(),
}];
adjust_anchors_by_required_sets(&mut anchors, &src, &tgt);
assert_eq!(anchors[0].confidence, 0.5);
}
#[test]
fn a_user_hint_survives_the_required_set_tiebreak() {
let src = schema_with_required("p", "c", true);
let tgt = schema_with_required("q", "d", false);
let mut anchors = vec![Anchor {
src: Name::from("c"),
tgt: Name::from("d"),
confidence: 1.0,
strategy: StrategyTag::UserHint,
provenance: Provenance::UserSupplied,
explanation: "the caller said so".into(),
}];
adjust_anchors_by_required_sets(&mut anchors, &src, &tgt);
assert_eq!(
anchors[0].confidence, 1.0,
"the tiebreak must not move a hint"
);
let table = evidence::aggregate(&anchors, evidence::AggregationPolicy::StrictPriority);
let scored = table.get(&Name::from("c"), &Name::from("d")).unwrap();
assert_eq!(scored.score, 1.0, "a hinted pair reads 1.0");
}
#[test]
fn a_user_hint_is_not_boosted_by_the_required_set_tiebreak() {
let src = schema_with_required("p", "c", true);
let tgt = schema_with_required("q", "d", true);
let mut anchors = vec![Anchor {
src: Name::from("c"),
tgt: Name::from("d"),
confidence: 0.6,
strategy: StrategyTag::UserHint,
provenance: Provenance::UserSupplied,
explanation: String::new(),
}];
adjust_anchors_by_required_sets(&mut anchors, &src, &tgt);
assert_eq!(anchors[0].confidence, 0.6);
}
#[test]
fn clamps_to_unit_interval() {
let src = schema_with_required("p", "c", true);
let tgt = schema_with_required("q", "d", true);
let mut anchors = vec![Anchor {
src: Name::from("c"),
tgt: Name::from("d"),
confidence: 0.99,
strategy: StrategyTag::Exact,
provenance: Provenance::ExactIdentifier,
explanation: String::new(),
}];
adjust_anchors_by_required_sets(&mut anchors, &src, &tgt);
assert!(anchors[0].confidence <= 1.0);
assert!(anchors[0].confidence >= 0.99); }
#[test]
fn matched_required_beats_mismatched_at_tie() {
let src = schema_with_required("p", "c", true);
let p2 = proto();
let tgt = SchemaBuilder::new(&p2)
.vertex("q", "object", None::<&str>)
.unwrap()
.vertex("d", "string", None::<&str>)
.unwrap()
.vertex("e", "string", None::<&str>)
.unwrap()
.edge("q", "d", "prop", Some("fd"))
.unwrap()
.edge("q", "e", "prop", Some("fe"))
.unwrap()
.required(
"q",
vec![Edge {
src: Name::from("q"),
tgt: Name::from("d"),
kind: Name::from("prop"),
name: Some(Name::from("fd")),
}],
)
.build()
.unwrap();
let mut anchors = vec![
Anchor {
src: Name::from("c"),
tgt: Name::from("d"),
confidence: 0.7,
strategy: StrategyTag::Alias,
provenance: Provenance::Synonym,
explanation: String::new(),
},
Anchor {
src: Name::from("c"),
tgt: Name::from("e"),
confidence: 0.7,
strategy: StrategyTag::Alias,
provenance: Provenance::Synonym,
explanation: String::new(),
},
];
adjust_anchors_by_required_sets(&mut anchors, &src, &tgt);
let picked = evidence::aggregate(&anchors, evidence::AggregationPolicy::StrictPriority)
.select(
evidence::Cardinality::Strict,
evidence::RowFilter::relative_only(),
)
.to_map();
assert_eq!(
picked.get(&Name::from("c")).map(Name::as_str),
Some("d"),
"required-matching anchor must win the source slot"
);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod constraint_compat_tests {
use super::*;
use panproto_schema::{Protocol, SchemaBuilder};
fn proto_with_format() -> Protocol {
Protocol {
name: "t".into(),
schema_theory: "ThTest".into(),
instance_theory: "ThWType".into(),
edge_rules: vec![],
obj_kinds: vec!["string".into()],
constraint_sorts: vec!["format".into(), "knownValues".into()],
..Protocol::default()
}
}
fn schema_with(
name: &str,
kind: &str,
constraints: &[(&str, &str)],
) -> panproto_schema::Schema {
let proto = proto_with_format();
let mut b = SchemaBuilder::new(&proto)
.vertex(name, kind, None::<&str>)
.unwrap();
for (sort, value) in constraints {
b = b.constraint(name, sort, value);
}
b.build().unwrap()
}
#[test]
fn source_with_no_constraints_matches_any_target_of_same_kind() {
let src = schema_with("a", "string", &[]);
let tgt = schema_with("a", "string", &[("format", "datetime")]);
assert!(kinds_and_constraints_compatible(
&src,
&Name::from("a"),
&tgt,
&Name::from("a"),
));
}
#[test]
fn matching_format_constraint_compatible() {
let src = schema_with("a", "string", &[("format", "datetime")]);
let tgt = schema_with("a", "string", &[("format", "datetime")]);
assert!(kinds_and_constraints_compatible(
&src,
&Name::from("a"),
&tgt,
&Name::from("a"),
));
}
#[test]
fn missing_constraint_on_target_fails() {
let src = schema_with("a", "string", &[("format", "datetime")]);
let tgt = schema_with("a", "string", &[]);
assert!(!kinds_and_constraints_compatible(
&src,
&Name::from("a"),
&tgt,
&Name::from("a"),
));
}
#[test]
fn differing_format_value_fails() {
let src = schema_with("a", "string", &[("format", "datetime")]);
let tgt = schema_with("a", "string", &[("format", "uri")]);
assert!(!kinds_and_constraints_compatible(
&src,
&Name::from("a"),
&tgt,
&Name::from("a"),
));
}
#[test]
fn mismatched_kind_fails_even_with_identical_constraints() {
let proto = proto_with_format();
let other_proto = Protocol {
obj_kinds: vec!["string".into(), "object".into()],
..proto.clone()
};
let src = SchemaBuilder::new(&proto)
.vertex("a", "string", None::<&str>)
.unwrap()
.constraint("a", "format", "datetime")
.build()
.unwrap();
let tgt = SchemaBuilder::new(&other_proto)
.vertex("a", "object", None::<&str>)
.unwrap()
.build()
.unwrap();
assert!(!kinds_and_constraints_compatible(
&src,
&Name::from("a"),
&tgt,
&Name::from("a"),
));
}
}
#[cfg(test)]
mod tests {
use super::*;
use evidence::{AggregationPolicy, Cardinality, RowFilter, aggregate};
fn anchor(
src: &str,
tgt: &str,
confidence: f64,
tag: StrategyTag,
provenance: Provenance,
) -> Anchor {
Anchor {
src: Name::from(src),
tgt: Name::from(tgt),
confidence,
strategy: tag,
provenance,
explanation: format!("{tag:?}: {src} ↔ {tgt}"),
}
}
fn picked(anchors: &[Anchor]) -> std::collections::HashMap<Name, Name> {
aggregate(anchors, AggregationPolicy::StrictPriority)
.select(Cardinality::Strict, RowFilter::relative_only())
.to_map()
}
#[test]
fn select_prefers_exact_over_alias_at_equal_confidence() {
let anchors = vec![
anchor("a", "B", 0.9, StrategyTag::Alias, Provenance::Synonym),
anchor(
"a",
"A",
0.9,
StrategyTag::Exact,
Provenance::ExactIdentifier,
),
];
assert_eq!(
picked(&anchors).get(&Name::from("a")).map(Name::as_str),
Some("A"),
"exact should beat alias at tied confidence"
);
}
#[test]
fn select_prefers_exact_over_token_similarity_despite_lower_confidence() {
let anchors = vec![
anchor(
"a",
"X",
0.4,
StrategyTag::Exact,
Provenance::ExactIdentifier,
),
anchor(
"a",
"Y",
0.8,
StrategyTag::TokenSimilarity,
Provenance::Derived,
),
];
assert_eq!(
picked(&anchors).get(&Name::from("a")).map(Name::as_str),
Some("X")
);
}
#[test]
fn select_strict_drops_duplicate_targets() {
let anchors = vec![
anchor(
"a",
"T",
0.9,
StrategyTag::Exact,
Provenance::ExactIdentifier,
),
anchor("b", "T", 0.8, StrategyTag::Alias, Provenance::Synonym),
];
let resolved = picked(&anchors);
assert_eq!(resolved.len(), 1);
assert_eq!(
resolved.get(&Name::from("a")).map(Name::as_str),
Some("T"),
"the stronger anchor keeps the target"
);
}
#[test]
fn select_never_reproduces_the_old_many_to_one_fan_out() {
let anchors = vec![
anchor(
"a",
"T",
0.9,
StrategyTag::Exact,
Provenance::ExactIdentifier,
),
anchor("b", "T", 0.8, StrategyTag::Alias, Provenance::Synonym),
];
let table = aggregate(&anchors, AggregationPolicy::StrictPriority);
for cardinality in [
Cardinality::Strict,
Cardinality::Permissive,
Cardinality::default(),
] {
assert_eq!(
table.select(cardinality, RowFilter::new(0.0, 1.0)).len(),
1,
"{cardinality:?} let the weaker claim share the target"
);
}
}
#[test]
fn select_prefers_type_signature_over_coerce_at_equal_confidence() {
let anchors = vec![
anchor("a", "C", 0.7, StrategyTag::Coerce, Provenance::Inferred),
anchor(
"a",
"T",
0.7,
StrategyTag::TypeSignature,
Provenance::Inferred,
),
];
assert_eq!(
picked(&anchors).get(&Name::from("a")).map(Name::as_str),
Some("T"),
"TypeSignature must beat Coerce at tied confidence"
);
}
#[test]
fn select_prefers_exact_over_coerce_at_equal_confidence() {
let anchors = vec![
anchor("a", "C", 0.7, StrategyTag::Coerce, Provenance::Inferred),
anchor(
"a",
"E",
0.7,
StrategyTag::Exact,
Provenance::ExactIdentifier,
),
];
assert_eq!(
picked(&anchors).get(&Name::from("a")).map(Name::as_str),
Some("E"),
"Exact must beat Coerce at tied confidence"
);
}
#[test]
fn select_strict_three_sources_one_target_keeps_highest_confidence() {
let anchors = vec![
anchor(
"a",
"T",
0.6,
StrategyTag::Exact,
Provenance::ExactIdentifier,
),
anchor(
"b",
"T",
0.9,
StrategyTag::Exact,
Provenance::ExactIdentifier,
),
anchor(
"c",
"T",
0.75,
StrategyTag::Exact,
Provenance::ExactIdentifier,
),
];
let resolved = picked(&anchors);
assert_eq!(resolved.len(), 1);
assert_eq!(
resolved.get(&Name::from("b")).map(Name::as_str),
Some("T"),
"highest confidence wins the target within one band"
);
assert!(!resolved.contains_key(&Name::from("a")));
assert!(!resolved.contains_key(&Name::from("c")));
}
#[test]
fn select_drops_nan_confidence_anchor() {
let anchors = vec![
anchor("a", "GOOD", 0.8, StrategyTag::Alias, Provenance::Synonym),
anchor(
"a",
"BAD",
f64::NAN,
StrategyTag::UserHint,
Provenance::UserSupplied,
),
];
assert_eq!(
picked(&anchors).get(&Name::from("a")).map(Name::as_str),
Some("GOOD"),
"a non-number confidence must be dropped even when its tag outranks"
);
}
#[test]
fn select_all_nan_anchors_yields_empty_map() {
let anchors = vec![
anchor(
"a",
"X",
f64::NAN,
StrategyTag::Exact,
Provenance::ExactIdentifier,
),
anchor(
"b",
"Y",
f64::NAN,
StrategyTag::Exact,
Provenance::ExactIdentifier,
),
];
assert!(picked(&anchors).is_empty());
}
#[test]
fn select_clamps_infinite_confidence() {
let anchors = vec![
anchor(
"a",
"X",
0.9,
StrategyTag::Exact,
Provenance::ExactIdentifier,
),
anchor(
"a",
"INF",
f64::INFINITY,
StrategyTag::Alias,
Provenance::Synonym,
),
];
assert_eq!(
picked(&anchors).get(&Name::from("a")).map(Name::as_str),
Some("X")
);
}
#[test]
fn select_empty_anchors_returns_empty_map() {
assert!(picked(&[]).is_empty());
}
#[test]
fn strategy_priority_is_strictly_decreasing_across_all_variants() {
let ordered = [
StrategyTag::UserHint,
StrategyTag::Exact,
StrategyTag::EdgeLabel,
StrategyTag::ExactSuffix,
StrategyTag::Alias,
StrategyTag::TypeSignature,
StrategyTag::WrapUnwrap,
StrategyTag::TokenSimilarity,
StrategyTag::DescriptionSimilarity,
StrategyTag::Coerce,
StrategyTag::Neighborhood,
StrategyTag::WlRefinement,
StrategyTag::Structural,
StrategyTag::Llm,
];
for pair in ordered.windows(2) {
let hi = pair[0].priority();
let lo = pair[1].priority();
assert!(
hi > lo,
"priority must strictly decrease: {:?}({hi}) !> {:?}({lo})",
pair[0],
pair[1]
);
}
}
#[test]
fn strategy_priority_table_is_total_and_ordered() {
let tags = [
(StrategyTag::UserHint, 100),
(StrategyTag::Exact, 90),
(StrategyTag::EdgeLabel, 85),
(StrategyTag::ExactSuffix, 80),
(StrategyTag::Alias, 70),
(StrategyTag::TypeSignature, 60),
(StrategyTag::WrapUnwrap, 55),
(StrategyTag::TokenSimilarity, 50),
(StrategyTag::DescriptionSimilarity, 45),
(StrategyTag::Coerce, 40),
(StrategyTag::Neighborhood, 35),
(StrategyTag::WlRefinement, 32),
(StrategyTag::Structural, 30),
(StrategyTag::Llm, 20),
];
for (tag, expected) in tags {
assert_eq!(tag.priority(), expected, "{tag:?}");
}
}
#[test]
fn rank_is_the_position_in_priority_order() {
let mut position = 0u32;
for tag in evidence::PRIORITY_ORDER {
assert_eq!(tag.rank(), position, "{tag:?}");
position += 1;
}
assert_eq!(position, STRATEGY_COUNT);
for pair in evidence::PRIORITY_ORDER.windows(2) {
assert!(pair[0].rank() < pair[1].rank());
assert!(pair[0].priority() > pair[1].priority());
}
}
#[test]
fn every_tag_has_a_family() {
for tag in evidence::PRIORITY_ORDER {
let default_family = tag.family();
for provenance in evidence::PROVENANCES {
let family = Family::of(tag, provenance);
if tag == StrategyTag::Alias && provenance == Provenance::DeclaredEdgeLabel {
assert_eq!(family, Family::EdgeLabel);
} else {
assert_eq!(family, default_family, "{tag:?}/{provenance:?}");
}
}
}
}
}