use std::collections::HashMap;
use panproto_gat::Name;
use panproto_schema::Schema;
pub mod alias;
pub mod coerce;
pub mod description_similarity;
pub mod edge_label;
#[cfg(feature = "lm_embeddings")]
pub mod embedding;
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;
#[cfg(feature = "lm_embeddings")]
pub use embedding::{Embedder, HashEmbedder, cosine_similarity, embedding_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 explanation: String,
}
#[must_use]
pub fn resolve_anchors(anchors: &[Anchor], monic: bool) -> HashMap<Name, Name> {
let mut ranked: Vec<&Anchor> = anchors.iter().filter(|a| !a.confidence.is_nan()).collect();
ranked.sort_by(|a, b| {
b.confidence
.total_cmp(&a.confidence)
.then_with(|| strategy_priority(b.strategy).cmp(&strategy_priority(a.strategy)))
.then_with(|| a.src.as_str().cmp(b.src.as_str()))
});
let mut out: HashMap<Name, Name> = HashMap::new();
let mut used_targets: std::collections::HashSet<Name> = std::collections::HashSet::new();
for anchor in ranked {
if out.contains_key(&anchor.src) {
continue;
}
if monic && used_targets.contains(&anchor.tgt) {
continue;
}
out.insert(anchor.src.clone(), anchor.tgt.clone());
used_targets.insert(anchor.tgt.clone());
}
out
}
const fn strategy_priority(tag: StrategyTag) -> u8 {
match tag {
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,
}
}
#[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() {
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,
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,
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,
explanation: String::new(),
}];
adjust_anchors_by_required_sets(&mut anchors, &src, &tgt);
assert_eq!(anchors[0].confidence, 0.5);
}
#[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,
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,
explanation: String::new(),
},
Anchor {
src: Name::from("c"),
tgt: Name::from("e"),
confidence: 0.7,
strategy: StrategyTag::Alias,
explanation: String::new(),
},
];
adjust_anchors_by_required_sets(&mut anchors, &src, &tgt);
let resolved = resolve_anchors(&anchors, false);
assert_eq!(
resolved.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::*;
fn anchor(src: &str, tgt: &str, confidence: f64, tag: StrategyTag) -> Anchor {
Anchor {
src: Name::from(src),
tgt: Name::from(tgt),
confidence,
strategy: tag,
explanation: format!("{tag:?}: {src} ↔ {tgt}"),
}
}
#[test]
fn resolve_prefers_exact_over_alias_at_equal_confidence() {
let anchors = vec![
anchor("a", "B", 0.9, StrategyTag::Alias),
anchor("a", "A", 0.9, StrategyTag::Exact),
];
let resolved = resolve_anchors(&anchors, false);
assert_eq!(
resolved.get(&Name::from("a")).map(Name::as_str),
Some("A"),
"exact should beat alias at tied confidence"
);
}
#[test]
fn resolve_prefers_higher_confidence() {
let anchors = vec![
anchor("a", "X", 0.4, StrategyTag::Exact),
anchor("a", "Y", 0.8, StrategyTag::TokenSimilarity),
];
let resolved = resolve_anchors(&anchors, false);
assert_eq!(resolved.get(&Name::from("a")).map(Name::as_str), Some("Y"));
}
#[test]
fn resolve_monic_drops_duplicate_targets() {
let anchors = vec![
anchor("a", "T", 0.9, StrategyTag::Exact),
anchor("b", "T", 0.8, StrategyTag::Alias),
];
let resolved = resolve_anchors(&anchors, true);
assert_eq!(resolved.len(), 1);
assert_eq!(
resolved.get(&Name::from("a")).map(Name::as_str),
Some("T"),
"higher-confidence anchor keeps the target"
);
}
#[test]
fn resolve_non_monic_allows_shared_targets() {
let anchors = vec![
anchor("a", "T", 0.9, StrategyTag::Exact),
anchor("b", "T", 0.8, StrategyTag::Alias),
];
let resolved = resolve_anchors(&anchors, false);
assert_eq!(resolved.len(), 2);
}
#[test]
fn resolve_prefers_type_signature_over_coerce_at_equal_confidence() {
let anchors = vec![
anchor("a", "C", 0.7, StrategyTag::Coerce),
anchor("a", "T", 0.7, StrategyTag::TypeSignature),
];
let resolved = resolve_anchors(&anchors, false);
assert_eq!(
resolved.get(&Name::from("a")).map(Name::as_str),
Some("T"),
"TypeSignature must beat Coerce at tied confidence"
);
}
#[test]
fn resolve_prefers_exact_over_coerce_at_equal_confidence() {
let anchors = vec![
anchor("a", "C", 0.7, StrategyTag::Coerce),
anchor("a", "E", 0.7, StrategyTag::Exact),
];
let resolved = resolve_anchors(&anchors, false);
assert_eq!(
resolved.get(&Name::from("a")).map(Name::as_str),
Some("E"),
"Exact must beat Coerce at tied confidence"
);
}
#[test]
fn resolve_monic_three_sources_one_target_keeps_highest_confidence() {
let anchors = vec![
anchor("a", "T", 0.6, StrategyTag::Exact),
anchor("b", "T", 0.9, StrategyTag::Exact),
anchor("c", "T", 0.75, StrategyTag::Exact),
];
let resolved = resolve_anchors(&anchors, true);
assert_eq!(resolved.len(), 1);
assert_eq!(
resolved.get(&Name::from("b")).map(Name::as_str),
Some("T"),
"highest confidence wins the target under monic"
);
assert!(!resolved.contains_key(&Name::from("a")));
assert!(!resolved.contains_key(&Name::from("c")));
}
#[test]
fn resolve_drops_nan_confidence_anchor() {
let anchors = vec![
anchor("a", "GOOD", 0.8, StrategyTag::Alias),
Anchor {
src: Name::from("a"),
tgt: Name::from("BAD"),
confidence: f64::NAN,
strategy: StrategyTag::UserHint,
explanation: "NaN confidence".into(),
},
];
let resolved = resolve_anchors(&anchors, false);
assert_eq!(
resolved.get(&Name::from("a")).map(Name::as_str),
Some("GOOD"),
"NaN-confidence anchor must be dropped even when its strategy tag outranks"
);
}
#[test]
fn resolve_all_nan_anchors_yields_empty_map() {
let anchors = vec![
Anchor {
src: Name::from("a"),
tgt: Name::from("X"),
confidence: f64::NAN,
strategy: StrategyTag::Exact,
explanation: String::new(),
},
Anchor {
src: Name::from("b"),
tgt: Name::from("Y"),
confidence: f64::NAN,
strategy: StrategyTag::Exact,
explanation: String::new(),
},
];
assert!(resolve_anchors(&anchors, false).is_empty());
assert!(resolve_anchors(&anchors, true).is_empty());
}
#[test]
fn resolve_handles_infinite_confidence_deterministically() {
let anchors = vec![
anchor("a", "X", 0.9, StrategyTag::Exact),
Anchor {
src: Name::from("a"),
tgt: Name::from("INF"),
confidence: f64::INFINITY,
strategy: StrategyTag::Alias,
explanation: String::new(),
},
];
let resolved = resolve_anchors(&anchors, false);
assert_eq!(
resolved.get(&Name::from("a")).map(Name::as_str),
Some("INF"),
"+∞ confidence beats finite confidence under total_cmp"
);
}
#[test]
fn resolve_empty_anchors_returns_empty_map() {
let resolved = resolve_anchors(&[], false);
assert!(resolved.is_empty());
let resolved = resolve_anchors(&[], true);
assert!(resolved.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 = strategy_priority(pair[0]);
let lo = strategy_priority(pair[1]);
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!(strategy_priority(tag), expected, "{tag:?}");
}
}
}