use std::collections::HashMap;
use panproto_schema::Schema;
use super::{Anchor, StrategyTag, kinds_and_constraints_compatible};
#[derive(Clone, Debug, Default)]
pub struct AliasDict {
term_to_cluster: HashMap<String, usize>,
clusters: Vec<Vec<String>>,
}
impl AliasDict {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn add_cluster<I, S>(&mut self, cluster: I)
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let canonical: Vec<String> = cluster
.into_iter()
.map(|s| canonical_form(s.as_ref()))
.filter(|s| !s.is_empty())
.collect();
if canonical.len() < 2 {
return;
}
let mut touched: Vec<usize> = canonical
.iter()
.filter_map(|t| self.term_to_cluster.get(t).copied())
.collect();
touched.sort_unstable();
touched.dedup();
match touched.as_slice() {
[] => {
let cluster_id = self.clusters.len();
for term in &canonical {
self.term_to_cluster.insert(term.clone(), cluster_id);
}
self.clusters.push(canonical);
}
[only] => {
let survivor = *only;
if canonical
.iter()
.all(|t| self.term_to_cluster.get(t) == Some(&survivor))
{
return;
}
for term in &canonical {
if !self.term_to_cluster.contains_key(term) {
self.term_to_cluster.insert(term.clone(), survivor);
self.clusters[survivor].push(term.clone());
}
}
}
_ => {
let survivor = touched[0];
for &victim in &touched[1..] {
let moved = std::mem::take(&mut self.clusters[victim]);
for term in moved {
self.term_to_cluster.insert(term.clone(), survivor);
if !self.clusters[survivor].contains(&term) {
self.clusters[survivor].push(term);
}
}
}
for term in &canonical {
if !self.term_to_cluster.contains_key(term) {
self.term_to_cluster.insert(term.clone(), survivor);
self.clusters[survivor].push(term.clone());
}
}
}
}
}
#[must_use]
pub fn cluster_count(&self) -> usize {
self.clusters.iter().filter(|c| !c.is_empty()).count()
}
pub fn extend(&mut self, other: &Self) {
for cluster in &other.clusters {
self.add_cluster(cluster.iter().map(String::as_str));
}
}
#[must_use]
pub fn are_aliases(&self, a: &str, b: &str) -> bool {
let ca = canonical_form(a);
let cb = canonical_form(b);
if ca.is_empty() || cb.is_empty() {
return false;
}
if ca == cb {
return true;
}
match (self.term_to_cluster.get(&ca), self.term_to_cluster.get(&cb)) {
(Some(x), Some(y)) => x == y,
_ => false,
}
}
#[must_use]
pub fn cluster_for(&self, term: &str) -> Option<&[String]> {
let canonical = canonical_form(term);
self.term_to_cluster
.get(&canonical)
.and_then(|id| self.clusters.get(*id).map(Vec::as_slice))
}
}
fn canonical_form(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
if ch.is_ascii_alphanumeric() {
out.extend(ch.to_lowercase());
}
}
out
}
#[must_use]
#[allow(clippy::too_many_lines)]
pub fn default_alias_dict() -> AliasDict {
let mut dict = AliasDict::new();
dict.add_cluster([
"createdAt",
"created",
"creationTime",
"updatedAt",
"updated",
"modifiedAt",
"modified",
"lastModified",
"indexedAt",
"indexed",
"ingestedAt",
"processedAt",
"sentAt",
"sent",
"sendTime",
"postedAt",
"publishedAt",
"receivedAt",
"timestamp",
"ts",
"date",
"datetime",
"when",
"time",
"mtime",
"ctime",
"atime",
]);
dict.add_cluster([
"id",
"identifier",
"key",
"uuid",
"guid",
"primary_key",
"pk",
]);
dict.add_cluster([
"ref",
"reference",
"target",
"subject",
"object",
"referent",
]);
dict.add_cluster([
"text", "body", "content", "message", "data", "payload", "value",
]);
dict.add_cluster([
"name",
"displayName",
"display_name",
"label",
"title",
"caption",
"heading",
]);
dict.add_cluster(["description", "summary", "about", "notes", "bio", "details"]);
dict.add_cluster([
"uri", "url", "link", "href", "location", "address", "endpoint",
]);
dict.add_cluster(["hash", "digest", "checksum", "fingerprint", "cid"]);
dict.add_cluster([
"count", "total", "num", "number", "n", "size", "length", "len",
]);
dict.add_cluster(["status", "state", "phase", "stage", "condition"]);
dict.add_cluster(["author", "creator", "owner", "user", "actor", "by"]);
dict.add_cluster(["version", "rev", "revision", "ver", "v"]);
dict.add_cluster(["tags", "labels", "categories", "keywords", "topics"]);
dict.add_cluster(["active", "enabled", "on"]);
dict.add_cluster(["deleted", "removed", "archived", "tombstoned"]);
dict.add_cluster(["order", "rank", "position", "index", "seq", "sequence"]);
dict.add_cluster(["parent", "parentId", "parent_id", "ancestor", "up"]);
dict.add_cluster(["child", "children", "descendants", "items", "entries"]);
dict
}
#[must_use]
pub fn alias_anchors(src: &Schema, tgt: &Schema, dict: &AliasDict) -> Vec<Anchor> {
let mut out = Vec::new();
let mut src_ids: Vec<&panproto_gat::Name> = src.vertices.keys().collect();
src_ids.sort_by(|a, b| a.as_str().cmp(b.as_str()));
let mut tgt_ids: Vec<&panproto_gat::Name> = tgt.vertices.keys().collect();
tgt_ids.sort_by(|a, b| a.as_str().cmp(b.as_str()));
for src_id in src_ids.iter().copied() {
let src_edge_names: Vec<&str> = src
.outgoing_edges(src_id)
.iter()
.filter_map(|e| e.name.as_deref())
.collect();
if src_edge_names.is_empty() {
for tgt_id in tgt_ids.iter().copied() {
if !kinds_and_constraints_compatible(src, src_id, tgt, tgt_id) {
continue;
}
if dict.are_aliases(src_id.as_str(), tgt_id.as_str())
&& src_id.as_str() != tgt_id.as_str()
{
out.push(Anchor {
src: src_id.clone(),
tgt: tgt_id.clone(),
confidence: 0.85,
strategy: StrategyTag::Alias,
explanation: format!(
"alias match: {} ↔ {}",
src_id.as_str(),
tgt_id.as_str()
),
});
}
}
continue;
}
for tgt_id in tgt_ids.iter().copied() {
if !kinds_and_constraints_compatible(src, src_id, tgt, tgt_id) {
continue;
}
let tgt_edge_names: Vec<&str> = tgt
.outgoing_edges(tgt_id)
.iter()
.filter_map(|e| e.name.as_deref())
.collect();
if tgt_edge_names.is_empty() {
continue;
}
let (score, matched) = alias_edge_overlap(&src_edge_names, &tgt_edge_names, dict);
if matched == 0 {
continue;
}
let name_bonus = if dict.are_aliases(src_id.as_str(), tgt_id.as_str()) {
0.1
} else {
0.0
};
let confidence = (score + name_bonus).clamp(0.0, 1.0);
if confidence < 0.4 {
continue;
}
out.push(Anchor {
src: src_id.clone(),
tgt: tgt_id.clone(),
confidence,
strategy: StrategyTag::Alias,
explanation: format!(
"alias-match on {matched} shared child field name(s): {} ↔ {}",
src_id.as_str(),
tgt_id.as_str()
),
});
}
}
out
}
fn alias_edge_overlap(src_names: &[&str], tgt_names: &[&str], dict: &AliasDict) -> (f64, usize) {
if src_names.is_empty() && tgt_names.is_empty() {
return (1.0, 0);
}
let mut matched = 0usize;
for s in src_names {
if tgt_names.iter().any(|t| dict.are_aliases(s, t)) {
matched += 1;
}
}
let denom_count = src_names.len().max(tgt_names.len());
let denom = f64::from(u32::try_from(denom_count).unwrap_or(u32::MAX));
let score = if denom > 0.0 {
f64::from(u32::try_from(matched).unwrap_or(u32::MAX)) / denom
} else {
0.0
};
(score, matched)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::option_if_let_else)]
mod tests {
use super::*;
use panproto_schema::{Protocol, SchemaBuilder};
fn test_protocol() -> Protocol {
Protocol {
name: "test".into(),
schema_theory: "ThTest".into(),
instance_theory: "ThWType".into(),
edge_rules: vec![],
obj_kinds: vec!["object".into(), "string".into(), "integer".into()],
constraint_sorts: vec![],
..Protocol::default()
}
}
fn build_schema(vertices: &[(&str, &str)], edges: &[(&str, &str, &str, &str)]) -> Schema {
let proto = test_protocol();
let mut b = SchemaBuilder::new(&proto);
for (id, kind) in vertices {
b = b.vertex(id, kind, None::<&str>).unwrap();
}
for (src, tgt, kind, name) in edges {
b = b.edge(src, tgt, kind, Some(*name)).unwrap();
}
b.build().unwrap()
}
#[test]
fn canonical_form_normalizes_casings() {
assert_eq!(canonical_form("createdAt"), "createdat");
assert_eq!(canonical_form("created_at"), "createdat");
assert_eq!(canonical_form("CREATED-AT"), "createdat");
assert_eq!(canonical_form("Created At"), "createdat");
}
#[test]
fn are_aliases_handles_casing_without_cluster() {
let dict = AliasDict::new();
assert!(dict.are_aliases("createdAt", "created_at"));
assert!(dict.are_aliases("displayName", "DisplayName"));
assert!(!dict.are_aliases("createdAt", "sentAt"));
}
#[test]
fn default_dict_matches_temporal_aliases() {
let dict = default_alias_dict();
assert!(dict.are_aliases("createdAt", "sentAt"));
assert!(dict.are_aliases("createdAt", "timestamp"));
assert!(dict.are_aliases("indexedAt", "processedAt"));
assert!(!dict.are_aliases("createdAt", "text"));
}
#[test]
fn default_dict_matches_identity_aliases() {
let dict = default_alias_dict();
assert!(dict.are_aliases("id", "uuid"));
assert!(dict.are_aliases("ref", "target"));
assert!(dict.are_aliases("subject", "referent"));
}
#[test]
fn are_aliases_rejects_empty_canonical() {
let dict = AliasDict::new();
assert!(!dict.are_aliases("", ""));
assert!(!dict.are_aliases("___", "---"));
assert!(!dict.are_aliases("foo", ""));
}
#[test]
fn add_cluster_ignores_singletons() {
let mut dict = AliasDict::new();
dict.add_cluster(["solo"]);
assert_eq!(dict.cluster_count(), 0);
dict.add_cluster(["bar", "___"]);
assert_eq!(dict.cluster_count(), 0);
dict.add_cluster(["bar", "baz"]);
assert_eq!(dict.cluster_count(), 1);
assert!(dict.are_aliases("bar", "baz"));
}
#[test]
fn alias_edge_overlap_asymmetric_sizes_uses_max_denominator() {
let dict = AliasDict::new();
let (score_small_large, matched) = alias_edge_overlap(
&["id"],
&["id", "a", "b", "c", "d", "e", "f", "g", "i", "j"],
&dict,
);
assert_eq!(matched, 1);
assert!((score_small_large - 0.1).abs() < 1e-9);
let (score_large_small, matched_swapped) = alias_edge_overlap(
&["id", "a", "b", "c", "d", "e", "f", "g", "i", "j"],
&["id"],
&dict,
);
assert_eq!(matched_swapped, 1);
assert!((score_large_small - score_small_large).abs() < 1e-9);
}
#[test]
fn alias_edge_overlap_duplicate_src_names_double_count() {
let dict = default_alias_dict();
let (score, matched) = alias_edge_overlap(&["text", "text", "body"], &["body"], &dict);
assert_eq!(
matched, 3,
"duplicate src names count independently; matched = 3 not 2"
);
assert!(
(score - 1.0).abs() < 1e-9,
"duplicate-src double-counting can saturate the score: {score}"
);
}
#[test]
fn alias_edge_overlap_empty_sides() {
let dict = AliasDict::new();
let (score, matched) = alias_edge_overlap(&[], &[], &dict);
assert_eq!(matched, 0);
assert!((score - 1.0).abs() < 1e-9);
let (score, matched) = alias_edge_overlap(&[], &["x"], &dict);
assert_eq!(matched, 0);
assert!(score.abs() < f64::EPSILON);
}
#[test]
fn alias_anchors_minimal_disjoint_schema_returns_empty() {
let src = build_schema(&[("unused_x", "string")], &[]);
let tgt = build_schema(&[("unused_y", "integer")], &[]);
let dict = default_alias_dict();
assert!(alias_anchors(&src, &tgt, &dict).is_empty());
}
#[test]
fn alias_anchors_deterministic_emission() {
let dict = default_alias_dict();
let perms: [&[(&str, &str)]; 2] = [
&[("aaa", "object"), ("bbb", "object"), ("ccc", "object")],
&[("ccc", "object"), ("aaa", "object"), ("bbb", "object")],
];
let targets = build_schema(
&[
("aaa", "object"),
("bbb", "object"),
("ccc", "object"),
("aaa.x", "string"),
("bbb.x", "string"),
("ccc.x", "string"),
],
&[
("aaa", "aaa.x", "prop", "id"),
("bbb", "bbb.x", "prop", "id"),
("ccc", "ccc.x", "prop", "id"),
],
);
let mut results = Vec::new();
for verts in perms {
let leaves: Vec<(&str, &str)> = verts
.iter()
.map(|(id, _)| {
(
Box::leak(format!("{id}.x").into_boxed_str()) as &str,
"string",
)
})
.collect();
let all_verts: Vec<(&str, &str)> = verts.iter().copied().chain(leaves).collect();
let edges: Vec<(&str, &str, &str, &str)> = verts
.iter()
.map(|(id, _)| {
(
*id,
Box::leak(format!("{id}.x").into_boxed_str()) as &str,
"prop",
"uuid",
)
})
.collect();
let src = build_schema(&all_verts, &edges);
let anchors = alias_anchors(&src, &targets, &dict);
let mut pairs: Vec<(String, String)> = anchors
.iter()
.map(|a| (a.src.as_str().into(), a.tgt.as_str().into()))
.collect();
pairs.sort();
results.push(pairs);
}
assert_eq!(
results[0], results[1],
"anchor set must be permutation-invariant"
);
}
#[test]
fn alias_anchors_emit_only_kind_compatible() {
let dict = default_alias_dict();
let src = build_schema(
&[("root", "object"), ("root.id", "string")],
&[("root", "root.id", "prop", "id")],
);
let tgt = build_schema(
&[("root", "integer"), ("root.id", "string")],
&[("root", "root.id", "prop", "uuid")],
);
for anchor in alias_anchors(&src, &tgt, &dict) {
assert!(super::super::kinds_compatible(
&src,
&anchor.src,
&tgt,
&anchor.tgt
));
}
}
#[test]
fn alias_anchors_single_isolated_vertex() {
let src = build_schema(&[("lone", "string")], &[]);
let tgt = build_schema(&[("other", "integer")], &[]);
let dict = default_alias_dict();
assert!(alias_anchors(&src, &tgt, &dict).is_empty());
}
#[test]
fn alias_anchors_bit_identical_across_100_runs() {
let dict = default_alias_dict();
let src = build_schema(
&[
("root", "object"),
("root.text", "string"),
("root.createdAt", "string"),
],
&[
("root", "root.text", "prop", "text"),
("root", "root.createdAt", "prop", "createdAt"),
],
);
let tgt = build_schema(
&[
("root", "object"),
("root.body", "string"),
("root.sentAt", "string"),
],
&[
("root", "root.body", "prop", "body"),
("root", "root.sentAt", "prop", "sentAt"),
],
);
let baseline: Vec<(String, String, u64)> = alias_anchors(&src, &tgt, &dict)
.iter()
.map(|a| {
(
a.src.as_str().into(),
a.tgt.as_str().into(),
a.confidence.to_bits(),
)
})
.collect();
for _ in 0..100 {
let again: Vec<(String, String, u64)> = alias_anchors(&src, &tgt, &dict)
.iter()
.map(|a| {
(
a.src.as_str().into(),
a.tgt.as_str().into(),
a.confidence.to_bits(),
)
})
.collect();
assert_eq!(again, baseline);
}
}
#[test]
fn add_cluster_is_idempotent_on_exact_duplicate() {
let mut dict = AliasDict::new();
dict.add_cluster(["foo", "bar"]);
assert_eq!(dict.cluster_count(), 1);
dict.add_cluster(["foo", "bar"]);
assert_eq!(
dict.cluster_count(),
1,
"duplicate add_cluster must not create a new cluster"
);
dict.add_cluster(["FOO", "Bar"]);
assert_eq!(dict.cluster_count(), 1);
dict.add_cluster(["baz", "qux"]);
assert_eq!(dict.cluster_count(), 2);
dict.add_cluster(["foo", "new"]);
assert_eq!(
dict.cluster_count(),
2,
"partial overlap must extend an existing cluster, not allocate"
);
assert!(dict.are_aliases("foo", "bar"));
assert!(
dict.are_aliases("foo", "new"),
"partial overlap must make fresh terms aliases of the existing cluster"
);
assert!(
dict.are_aliases("bar", "new"),
"transitivity must hold after in-place extension"
);
}
#[test]
fn add_cluster_partial_overlap_extends_in_place() {
let mut dict = AliasDict::new();
dict.add_cluster(["a", "b"]);
dict.add_cluster(["a", "b", "c"]);
assert_eq!(dict.cluster_count(), 1);
assert!(dict.are_aliases("a", "c"));
assert!(dict.are_aliases("b", "c"));
let mut via_a: Vec<String> = dict.cluster_for("a").unwrap().to_vec();
via_a.sort();
let mut via_c: Vec<String> = dict.cluster_for("c").unwrap().to_vec();
via_c.sort();
assert_eq!(
via_a, via_c,
"cluster_for must be consistent for cluster members"
);
assert_eq!(via_a, vec!["a", "b", "c"]);
}
#[test]
fn add_cluster_merges_multiple_existing_clusters() {
let mut dict = AliasDict::new();
dict.add_cluster(["a", "b"]);
dict.add_cluster(["c", "d"]);
assert_eq!(dict.cluster_count(), 2);
dict.add_cluster(["b", "c"]);
assert_eq!(
dict.cluster_count(),
1,
"bridging cluster must merge both pre-existing clusters"
);
for x in ["a", "b", "c", "d"] {
for y in ["a", "b", "c", "d"] {
if x != y {
assert!(dict.are_aliases(x, y), "transitivity failed on {x} ↔ {y}");
}
}
}
let mut via_a: Vec<String> = dict.cluster_for("a").unwrap().to_vec();
via_a.sort();
for x in ["b", "c", "d"] {
let mut via_x: Vec<String> = dict.cluster_for(x).unwrap().to_vec();
via_x.sort();
assert_eq!(via_a, via_x, "cluster_for must match for {x}");
}
}
#[test]
fn canonical_form_handles_multi_kilobyte_input() {
let big: String = "abcDEF_123-".repeat(5_000);
let canon = canonical_form(&big);
assert_eq!(canon.len(), 45_000);
assert!(canon.chars().all(|c| !c.is_uppercase()));
}
#[test]
fn alias_anchors_leaf_vs_leaf_emits_on_default_temporal_cluster() {
let src = build_schema(&[("created", "string")], &[]);
let tgt = build_schema(&[("createdAt", "string")], &[]);
let dict = default_alias_dict();
let anchors = alias_anchors(&src, &tgt, &dict);
assert!(
anchors
.iter()
.any(|a| a.src.as_str() == "created" && a.tgt.as_str() == "createdAt"),
"leaf-vs-leaf temporal aliases must emit an anchor; got {anchors:?}"
);
}
#[test]
fn extend_with_overlapping_dict_does_not_create_duplicates() {
let base = default_alias_dict();
let base_count = base.cluster_count();
let mut merged = base.clone();
merged.extend(&base);
assert_eq!(
merged.cluster_count(),
base_count,
"extend with overlapping clusters must be idempotent"
);
assert!(merged.are_aliases("createdAt", "sentAt"));
assert!(merged.are_aliases("id", "uuid"));
}
#[test]
fn alias_anchors_composite_source_leaf_target_emits_nothing() {
let src = build_schema(
&[("id", "object"), ("id.x", "string")],
&[("id", "id.x", "prop", "x")],
);
let tgt = build_schema(&[("uuid", "object")], &[]);
let dict = default_alias_dict();
let anchors = alias_anchors(&src, &tgt, &dict);
assert!(
anchors
.iter()
.all(|a| !(a.src.as_str() == "id" && a.tgt.as_str() == "uuid")),
"composite-source vs leaf-target must not emit alias anchor: {anchors:?}"
);
}
#[test]
fn alias_anchors_link_temporally_named_leaves() {
let src = build_schema(
&[
("root", "object"),
("root.text", "string"),
("root.createdAt", "string"),
],
&[
("root", "root.text", "prop", "text"),
("root", "root.createdAt", "prop", "createdAt"),
],
);
let tgt = build_schema(
&[
("root", "object"),
("root.body", "string"),
("root.sentAt", "string"),
],
&[
("root", "root.body", "prop", "body"),
("root", "root.sentAt", "prop", "sentAt"),
],
);
let dict = default_alias_dict();
let anchors = alias_anchors(&src, &tgt, &dict);
let root_anchor = anchors
.iter()
.find(|a| a.src.as_str() == "root" && a.tgt.as_str() == "root");
assert!(
root_anchor.is_some(),
"root↔root should anchor by shared alias-child-names"
);
}
#[test]
fn canonical_form_strips_extended_ascii_and_diacritics() {
assert_eq!(canonical_form("Ä"), "");
assert_eq!(canonical_form("ÄÅÆ"), "");
assert_eq!(canonical_form("café"), "caf");
assert_eq!(canonical_form("cafe"), "cafe");
assert_ne!(
canonical_form("caf\u{00E9}"),
canonical_form("cafe\u{0301}")
);
assert_ne!(canonical_form("naïve"), canonical_form("naive"));
}
#[test]
fn alias_anchors_ignore_edge_kind_when_matching_names() {
let src = build_schema(
&[("r", "object"), ("r.id", "string")],
&[("r", "r.id", "prop", "id")],
);
let tgt = build_schema(
&[("r", "object"), ("r.id", "string")],
&[("r", "r.id", "item", "id")],
);
let dict = default_alias_dict();
let anchors = alias_anchors(&src, &tgt, &dict);
assert!(
anchors
.iter()
.any(|a| a.src.as_str() == "r" && a.tgt.as_str() == "r"),
"alias_anchors scores on edge *names* regardless of edge kind: {anchors:?}"
);
}
}