panproto_mig/align/mod.rs
1//! Protocol-agnostic alignment strategies for auto-lens generation.
2//!
3//! Each strategy proposes **candidate anchors** (source-vertex ↔
4//! target-vertex pairs). [`evidence::aggregate`] reduces an anchor pool to one
5//! score per pair. Callers can pass those scores to an evidence-aware span
6//! search, where they enter as unary costs. The automatic lens generator uses
7//! a different route: it selects a one-to-one seed map, tries those pairs as
8//! provisional hard pins, and compares that result with a search in which the
9//! strategy pins have been released.
10//!
11//! # Priority is real
12//!
13//! Strategies are ranked by [`StrategyTag::priority`], and the ranking is
14//! honoured rather than consulted only on bit-exact ties: each tag owns a band
15//! of the `[0, 1]` interval ([`StrategyTag::band`]) and its raw confidence
16//! positions it inside that band. So an [`StrategyTag::Exact`] anchor at
17//! confidence 0.4 outranks a [`StrategyTag::TokenSimilarity`] anchor at 0.8,
18//! which is what the ordering has always claimed. Adjacent bands meet at a
19//! shared endpoint rather than being separated by a gap, so the guarantee is
20//! `≥` rather than `>`; [`StrategyTag::band`] says where that bites.
21//!
22//! # Stringency tiers
23//!
24//! The `Stringency` level (in `panproto_lens`) selects which strategies
25//! run and at what thresholds. Every tier runs [`exact`], [`suffix`], and
26//! [`edge_label`]. Higher tiers add the strategies documented on
27//! `panproto_lens::Stringency`; `Exploratory` adds structural and registered
28//! coercion-witness proposals. No production strategy emits
29//! [`StrategyTag::Llm`].
30//!
31//! Aggregation is monotone when one literal anchor pool contains another.
32//! Stringency tiers do not always satisfy that premise: another
33//! Weisfeiler-Leman round can split a singleton class, and neighborhood
34//! propagation can change when the selected parent seeds change.
35
36use panproto_gat::Name;
37use panproto_schema::Schema;
38
39use evidence::{Family, Provenance, STRATEGY_COUNT};
40
41pub mod alias;
42pub mod coerce;
43pub mod defaults;
44pub mod description_similarity;
45pub mod edge_label;
46pub mod evidence;
47pub mod exact;
48pub mod neighborhood;
49pub mod structural;
50pub mod suffix;
51pub mod token_similarity;
52pub mod type_signature;
53pub mod wl;
54pub mod wrap_unwrap;
55
56pub use alias::{AliasDict, alias_anchors, default_alias_dict};
57pub use coerce::{CoerceAnchor, coerce_anchors};
58pub use description_similarity::{description_anchors, description_similarity};
59pub use edge_label::edge_label_anchors;
60pub use exact::exact_anchors;
61pub use neighborhood::neighborhood_anchors;
62pub use structural::structural_anchors;
63pub use suffix::suffix_anchors;
64pub use token_similarity::{token_anchors, token_similarity};
65pub use type_signature::type_signature_anchors;
66pub use wl::wl_anchors;
67pub use wrap_unwrap::wrap_unwrap_anchors;
68
69/// Tag identifying which strategy produced an anchor.
70#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
71#[serde(rename_all = "snake_case")]
72pub enum StrategyTag {
73 /// User-supplied correspondence.
74 UserHint,
75 /// Kind-compatible name equality.
76 Exact,
77 /// Kind-compatible terminal dot-segment equality. Recovers anchors
78 /// for namespaced identifiers that share the same local prop or
79 /// field name under disjoint prefixes.
80 ExactSuffix,
81 /// Same-label same-kind edge on each side with compatible child
82 /// vertex kinds. Catches pairs of children reached via labeled
83 /// edges whose parents have disjoint identifiers.
84 EdgeLabel,
85 /// Name match modulo alias dictionary + casing variants.
86 Alias,
87 /// Token-bag Jaccard + character-n-gram cosine above threshold.
88 TokenSimilarity,
89 /// Token similarity of vertex descriptions (constraint sort
90 /// `description`) above threshold. Only fires on schemas whose
91 /// vertices carry description annotations.
92 DescriptionSimilarity,
93 /// Matching sort carrier shapes (edge-kind signatures + cardinality).
94 TypeSignature,
95 /// Wrap/unwrap detection between record shapes.
96 WrapUnwrap,
97 /// Sort-coercion via a registered witness lens (Iso, Retraction, or
98 /// Projection). Distinct from [`StrategyTag::TypeSignature`] so that
99 /// conflict resolution ranks same-kind signatures above cross-kind
100 /// bridges.
101 Coerce,
102 /// Neighborhood propagation: child-pair scoring seeded from an
103 /// already-aligned parent pair via edge-label similarity, edge-kind
104 /// equality, kind-and-constraints compatibility, and degree
105 /// overlap.
106 Neighborhood,
107 /// Weisfeiler-Leman color refinement: structural signatures from
108 /// iterated neighborhood hashing. Emits anchors for singleton
109 /// color classes on both sides.
110 WlRefinement,
111 /// Pure degree-and-kind-signature matching (last resort).
112 Structural,
113 /// LM-proposed alignment supplied by an external caller.
114 Llm,
115}
116
117/// A proposed alignment between one source vertex and one target vertex,
118/// annotated with confidence and provenance.
119#[derive(Clone, Debug)]
120pub struct Anchor {
121 /// Source vertex ID.
122 pub src: Name,
123 /// Target vertex ID.
124 pub tgt: Name,
125 /// Score in \[0.0, 1.0\]; higher = stronger proposal.
126 ///
127 /// Values outside the interval are not a contract violation the type can
128 /// prevent, and [`evidence::aggregate`] clamps rather than trusts: a
129 /// non-number drops the anchor entirely, and anything else is brought into
130 /// range before the provenance ceiling is applied.
131 pub confidence: f64,
132 /// Strategy that produced this anchor.
133 pub strategy: StrategyTag,
134 /// What kind of input the evidence was read from, which caps how much
135 /// confidence it can claim.
136 ///
137 /// It is stamped by the emitting branch rather than derived from
138 /// [`Anchor::strategy`], because one strategy can emit from two branches
139 /// reading two different inputs: [`alias_anchors`] compares vertex
140 /// identifiers on its leaf branch and child edge labels on its composite
141 /// branch, and the tag cannot tell those apart. [`Family::of`] reads the
142 /// provenance for exactly that reason.
143 pub provenance: Provenance,
144 /// Human-readable explanation, suitable for UI.
145 pub explanation: String,
146}
147
148impl StrategyTag {
149 /// Priority ordering over strategies. Higher is better.
150 ///
151 /// The numbers are spaced rather than consecutive so a new strategy can be
152 /// slotted between two existing ones without renumbering the table, and
153 /// only their *order* is read: [`StrategyTag::rank`] is the position in
154 /// that order, and the band arithmetic goes through the rank.
155 ///
156 /// **The table is uncalibrated.** It encodes a judgement about which kind
157 /// of evidence is stronger, not a measurement, and it has never been
158 /// validated against labelled data.
159 #[must_use]
160 pub const fn priority(self) -> u8 {
161 match self {
162 Self::UserHint => 100,
163 Self::Exact => 90,
164 Self::EdgeLabel => 85,
165 Self::ExactSuffix => 80,
166 Self::Alias => 70,
167 Self::TypeSignature => 60,
168 Self::WrapUnwrap => 55,
169 Self::TokenSimilarity => 50,
170 Self::DescriptionSimilarity => 45,
171 // Cross-kind bridges sit below same-kind heuristics: a token-match
172 // within the same kind is stronger evidence than a coercion across
173 // kinds.
174 Self::Coerce => 40,
175 Self::Neighborhood => 35,
176 Self::WlRefinement => 32,
177 Self::Structural => 30,
178 Self::Llm => 20,
179 }
180 }
181
182 /// Position in descending priority order, from `0` for the strongest tag
183 /// to `13` for the weakest.
184 ///
185 /// This is the index into
186 /// [`PRIORITY_ORDER`](evidence::PRIORITY_ORDER), and it is what the band
187 /// arithmetic reads: the bands must partition `[0, 1]` exactly, which a
188 /// spaced priority table cannot do and a dense rank can.
189 #[must_use]
190 pub const fn rank(self) -> u32 {
191 match self {
192 Self::UserHint => 0,
193 Self::Exact => 1,
194 Self::EdgeLabel => 2,
195 Self::ExactSuffix => 3,
196 Self::Alias => 4,
197 Self::TypeSignature => 5,
198 Self::WrapUnwrap => 6,
199 Self::TokenSimilarity => 7,
200 Self::DescriptionSimilarity => 8,
201 Self::Coerce => 9,
202 Self::Neighborhood => 10,
203 Self::WlRefinement => 11,
204 Self::Structural => 12,
205 Self::Llm => 13,
206 }
207 }
208
209 /// The **closed** interval of aggregated evidence this tag can occupy,
210 /// as `(lo, hi)`.
211 ///
212 /// The 14 tags cut `[0, 1]` into 14 bands of width `1/14` in descending
213 /// priority order, and their union is the whole interval. An anchor's
214 /// confidence, capped by its provenance, positions it *within* its band and
215 /// never outside it, which is what makes the priority ordering dominate
216 /// confidence rather than merely break ties under it.
217 ///
218 /// # The bands meet, they do not overlap
219 ///
220 /// Both endpoints are attainable. A tag reaches its `hi` at capped
221 /// confidence 1, and reaches its `lo` at capped confidence 0, so each `hi`
222 /// is bit-identical to the `lo` of the band above it: all 13 adjacent
223 /// boundaries coincide exactly. The bands are therefore closed intervals
224 /// that meet at their endpoints rather than half-open intervals that are
225 /// disjoint.
226 ///
227 /// Nothing about the ordering is lost by that, because no band's *interior*
228 /// is reachable from another tag: priority dominance holds as `≥`, and a
229 /// higher-priority anchor is never scored below a lower-priority one. What
230 /// is lost is strictness at the seam. A strongest possible claim by one tag
231 /// and a weakest possible claim by the tag above it aggregate to the same
232 /// number, so the `max` within a family selects a tie rather than the
233 /// higher-priority member. That case is reachable and not exotic:
234 /// [`adjust_anchors_by_required_sets`] clamps to exactly 0 and 1, which is
235 /// precisely where the seam is.
236 #[must_use]
237 pub fn band(self) -> (f64, f64) {
238 let rank = self.rank();
239 let count = f64::from(STRATEGY_COUNT);
240 (
241 f64::from(STRATEGY_COUNT - 1 - rank) / count,
242 f64::from(STRATEGY_COUNT - rank) / count,
243 )
244 }
245
246 /// The most aggregated evidence an anchor with this tag can ever carry,
247 /// which is the top of its [`StrategyTag::band`].
248 ///
249 /// This is the quantity priority dominance is stated over: for tags `a`
250 /// and `b` with `a.priority() > b.priority()`, every `a` anchor scores at
251 /// least `b.ceiling()`, whatever the two confidences are.
252 #[must_use]
253 pub fn ceiling(self) -> f64 {
254 self.band().1
255 }
256
257 /// Which input this tag's anchors are read from.
258 ///
259 /// [`Family::of`] is the function the aggregation actually uses: it agrees
260 /// with this one except on [`StrategyTag::Alias`], whose two emission
261 /// branches read two different inputs and are told apart by the anchor's
262 /// [`Anchor::provenance`]. This method reports the family of the leaf
263 /// branch, which is the one the tag is named for.
264 ///
265 /// # One family per tag, and why two candidates for a split did not get one
266 ///
267 /// The partition is total and each tag lands in exactly one family, which
268 /// is what makes the fixed-arity mean well defined. Two tags read inputs
269 /// that arguably straddle a family boundary, and both are filed whole:
270 ///
271 /// * [`StrategyTag::Neighborhood`] is seeded from an already-aligned
272 /// parent, so the identifier and edge-label evidence that produced the
273 /// seed is already counted in those families. Filing it under
274 /// [`Family::Structure`] is what stops it being counted twice.
275 /// * [`StrategyTag::WrapUnwrap`] scores a correlated group of fields by how
276 /// well their labels cover one another, so its confidence is entirely a
277 /// label-coverage measure and [`Family::EdgeLabel`] is where it belongs.
278 /// It selects a *parent* pair, which is why an identifier reading is
279 /// tempting, but nothing about the parents' own identifiers enters the
280 /// number.
281 ///
282 /// Splitting either would need a provenance that tells the branches apart,
283 /// as `Alias` has. Both stamp a single provenance unconditionally, so
284 /// [`Family::of`] could not act on a split even if one were wanted.
285 #[must_use]
286 pub const fn family(self) -> Family {
287 match self {
288 Self::UserHint => Family::UserHint,
289 Self::Exact | Self::ExactSuffix | Self::Alias | Self::TokenSimilarity => {
290 Family::Identifier
291 }
292 Self::EdgeLabel | Self::WrapUnwrap => Family::EdgeLabel,
293 Self::DescriptionSimilarity => Family::Documentation,
294 Self::TypeSignature
295 | Self::Neighborhood
296 | Self::WlRefinement
297 | Self::Structural
298 | Self::Llm => Family::Structure,
299 Self::Coerce => Family::Coercion,
300 }
301 }
302}
303
304/// Kind-compatibility test: two vertices may be aligned only if they share
305/// the same schema-level kind (sort carrier). This enforces what a theory
306/// morphism requires at the sort level.
307#[must_use]
308pub fn kinds_compatible(src: &Schema, src_id: &Name, tgt: &Schema, tgt_id: &Name) -> bool {
309 src.vertex(src_id)
310 .zip(tgt.vertex(tgt_id))
311 .is_some_and(|(sv, tv)| sv.kind == tv.kind)
312}
313
314/// Kind-and-constraint compatibility test.
315///
316/// Stricter than [`kinds_compatible`]: in addition to the kind check,
317/// every constraint sort declared on the source vertex must be carried
318/// by the target vertex with an equal value. A source vertex with no
319/// constraints matches any target of the same kind; a source vertex
320/// with constraints requires the target to carry a matching constraint
321/// of the same sort and value.
322///
323/// Equality of constraint values is string-literal: the function
324/// compares the serialized form stored on
325/// [`panproto_schema::Constraint`]. Protocols whose constraint sorts
326/// are enumerated discretely (`format`, `knownValues`) therefore get
327/// exact match semantics; numeric-range sorts
328/// (`maxLength = 200` vs `maxLength = 300`) are treated as distinct.
329/// This is the intended behaviour: loosening numeric constraints
330/// should not produce a silent match.
331///
332/// The function is protocol-generic: it consults the schemas' own
333/// constraint tables rather than any external vocabulary.
334#[must_use]
335pub fn kinds_and_constraints_compatible(
336 src: &Schema,
337 src_id: &Name,
338 tgt: &Schema,
339 tgt_id: &Name,
340) -> bool {
341 if !kinds_compatible(src, src_id, tgt, tgt_id) {
342 return false;
343 }
344 let empty: Vec<panproto_schema::Constraint> = Vec::new();
345 let src_cs = src.constraints.get(src_id).unwrap_or(&empty);
346 if src_cs.is_empty() {
347 return true;
348 }
349 let tgt_cs = tgt.constraints.get(tgt_id).unwrap_or(&empty);
350 for sc in src_cs {
351 let ok = tgt_cs
352 .iter()
353 .any(|tc| tc.sort == sc.sort && tc.value == sc.value);
354 if !ok {
355 return false;
356 }
357 }
358 true
359}
360
361/// Return `true` if `vertex_id` is pointed to by any edge in the
362/// schema's `required` table. Protocol-generic: uses the schema's own
363/// required-edge annotations rather than any specific vocabulary.
364#[must_use]
365pub fn vertex_is_required(schema: &Schema, vertex_id: &Name) -> bool {
366 schema
367 .required
368 .values()
369 .any(|edges| edges.iter().any(|e| &e.tgt == vertex_id))
370}
371
372/// Apply a required-set correspondence tiebreak to `anchors` in place.
373///
374/// For every anchor proposal `(s, t)`:
375/// * `+0.05` when both `s` and `t` are required (positively correlated).
376/// * `-0.05` when exactly one side is required (asymmetric: reassigning
377/// required data to optional data, or vice versa, usually indicates a
378/// schema-shape change the anchor should not silently confirm).
379/// * Unchanged when both sides are optional.
380///
381/// Confidences are clamped to `[0.0, 1.0]` so the adjustment cannot
382/// push a heuristic anchor above `Exact = 1.0` or below zero. The
383/// magnitude is small by design: it moves an anchor within its
384/// [`StrategyTag::band`] and can never move it out of one, so it breaks
385/// ties without disturbing the priority ordering.
386///
387/// It can, however, land an anchor *on* a band boundary, since the clamp
388/// targets exactly the two endpoints where adjacent bands meet. That is a tie
389/// across bands rather than a reordering of them, and [`StrategyTag::band`]
390/// says what such a tie costs.
391///
392/// # User hints are exempt
393///
394/// [`StrategyTag::UserHint`] anchors are left untouched. A hint is a caller
395/// stating a correspondence, not a heuristic proposing one, so there is no tie
396/// for a tiebreak to settle; and because
397/// [`aggregate`](evidence::aggregate) folds the hint in with a `max` against
398/// its own capped confidence, a `-0.05` here would silently hand back 0.95 for
399/// a pair the caller asserted at 1.0. Requiredness disagreeing across the two
400/// schemas is a normal consequence of the schema change the caller is hinting
401/// through, so it is the most likely case rather than a rare one.
402///
403/// The adjustment is otherwise the same function of the anchor applied
404/// uniformly to the whole pool, so it commutes with the pool growing:
405/// aggregating an adjusted superset still dominates aggregating an adjusted
406/// subset.
407pub fn adjust_anchors_by_required_sets(anchors: &mut [Anchor], src: &Schema, tgt: &Schema) {
408 for anchor in anchors.iter_mut() {
409 if anchor.strategy == StrategyTag::UserHint {
410 continue;
411 }
412 let sr = vertex_is_required(src, &anchor.src);
413 let tr = vertex_is_required(tgt, &anchor.tgt);
414 let delta = match (sr, tr) {
415 (true, true) => 0.05,
416 (true, false) | (false, true) => -0.05,
417 (false, false) => 0.0,
418 };
419 if delta == 0.0 {
420 continue;
421 }
422 anchor.confidence = (anchor.confidence + delta).clamp(0.0, 1.0);
423 }
424}
425
426#[cfg(test)]
427#[allow(clippy::unwrap_used, clippy::float_cmp)]
428mod required_tiebreak_tests {
429 use super::*;
430 use panproto_schema::{Edge, EdgeRule, Protocol, SchemaBuilder};
431
432 fn proto() -> Protocol {
433 Protocol {
434 name: "t".into(),
435 schema_theory: "ThTest".into(),
436 instance_theory: "ThWType".into(),
437 edge_rules: vec![EdgeRule {
438 edge_kind: "prop".into(),
439 src_kinds: vec!["object".into()],
440 tgt_kinds: vec!["string".into()],
441 }],
442 obj_kinds: vec!["object".into(), "string".into()],
443 constraint_sorts: vec![],
444 ..Protocol::default()
445 }
446 }
447
448 fn schema_with_required(parent: &str, child: &str, required: bool) -> panproto_schema::Schema {
449 let p = proto();
450 let mut b = SchemaBuilder::new(&p)
451 .vertex(parent, "object", None::<&str>)
452 .unwrap()
453 .vertex(child, "string", None::<&str>)
454 .unwrap()
455 .edge(parent, child, "prop", Some("f"))
456 .unwrap();
457 if required {
458 let edge = Edge {
459 src: Name::from(parent),
460 tgt: Name::from(child),
461 kind: Name::from("prop"),
462 name: Some(Name::from("f")),
463 };
464 b = b.required(parent, vec![edge]);
465 }
466 b.build().unwrap()
467 }
468
469 #[test]
470 fn required_matching_required_boosts() {
471 let src = schema_with_required("p", "c", true);
472 let tgt = schema_with_required("q", "d", true);
473 let mut anchors = vec![Anchor {
474 src: Name::from("c"),
475 tgt: Name::from("d"),
476 confidence: 0.5,
477 strategy: StrategyTag::Alias,
478 provenance: Provenance::Synonym,
479 explanation: String::new(),
480 }];
481 adjust_anchors_by_required_sets(&mut anchors, &src, &tgt);
482 assert!((anchors[0].confidence - 0.55).abs() < 1e-9);
483 }
484
485 #[test]
486 fn required_to_optional_penalizes() {
487 let src = schema_with_required("p", "c", true);
488 let tgt = schema_with_required("q", "d", false);
489 let mut anchors = vec![Anchor {
490 src: Name::from("c"),
491 tgt: Name::from("d"),
492 confidence: 0.5,
493 strategy: StrategyTag::Alias,
494 provenance: Provenance::Synonym,
495 explanation: String::new(),
496 }];
497 adjust_anchors_by_required_sets(&mut anchors, &src, &tgt);
498 assert!((anchors[0].confidence - 0.45).abs() < 1e-9);
499 }
500
501 #[test]
502 fn both_optional_unchanged() {
503 let src = schema_with_required("p", "c", false);
504 let tgt = schema_with_required("q", "d", false);
505 let mut anchors = vec![Anchor {
506 src: Name::from("c"),
507 tgt: Name::from("d"),
508 confidence: 0.5,
509 strategy: StrategyTag::Alias,
510 provenance: Provenance::Synonym,
511 explanation: String::new(),
512 }];
513 adjust_anchors_by_required_sets(&mut anchors, &src, &tgt);
514 assert_eq!(anchors[0].confidence, 0.5);
515 }
516
517 /// A user hint is a caller's statement, not a proposal to be tiebroken.
518 ///
519 /// The one-sided case is the one that matters: a hint across a schema
520 /// change that made a field optional would otherwise be knocked to 0.95,
521 /// and since the hint reaches the score through a `max` against its own
522 /// capped confidence, the pair would read 0.95 rather than the 1.0 the
523 /// aggregation documents.
524 #[test]
525 fn a_user_hint_survives_the_required_set_tiebreak() {
526 let src = schema_with_required("p", "c", true);
527 let tgt = schema_with_required("q", "d", false);
528 let mut anchors = vec![Anchor {
529 src: Name::from("c"),
530 tgt: Name::from("d"),
531 confidence: 1.0,
532 strategy: StrategyTag::UserHint,
533 provenance: Provenance::UserSupplied,
534 explanation: "the caller said so".into(),
535 }];
536 adjust_anchors_by_required_sets(&mut anchors, &src, &tgt);
537 assert_eq!(
538 anchors[0].confidence, 1.0,
539 "the tiebreak must not move a hint"
540 );
541
542 let table = evidence::aggregate(&anchors, evidence::AggregationPolicy::StrictPriority);
543 let scored = table.get(&Name::from("c"), &Name::from("d")).unwrap();
544 assert_eq!(scored.score, 1.0, "a hinted pair reads 1.0");
545 }
546
547 /// And the both-required case, which moves in the other direction, is
548 /// equally exempt: a hint is not boosted either.
549 #[test]
550 fn a_user_hint_is_not_boosted_by_the_required_set_tiebreak() {
551 let src = schema_with_required("p", "c", true);
552 let tgt = schema_with_required("q", "d", true);
553 let mut anchors = vec![Anchor {
554 src: Name::from("c"),
555 tgt: Name::from("d"),
556 confidence: 0.6,
557 strategy: StrategyTag::UserHint,
558 provenance: Provenance::UserSupplied,
559 explanation: String::new(),
560 }];
561 adjust_anchors_by_required_sets(&mut anchors, &src, &tgt);
562 assert_eq!(anchors[0].confidence, 0.6);
563 }
564
565 #[test]
566 fn clamps_to_unit_interval() {
567 let src = schema_with_required("p", "c", true);
568 let tgt = schema_with_required("q", "d", true);
569 let mut anchors = vec![Anchor {
570 src: Name::from("c"),
571 tgt: Name::from("d"),
572 confidence: 0.99,
573 strategy: StrategyTag::Exact,
574 provenance: Provenance::ExactIdentifier,
575 explanation: String::new(),
576 }];
577 adjust_anchors_by_required_sets(&mut anchors, &src, &tgt);
578 assert!(anchors[0].confidence <= 1.0);
579 assert!(anchors[0].confidence >= 0.99); // boost applied but clamped
580 }
581
582 #[test]
583 fn matched_required_beats_mismatched_at_tie() {
584 // Two anchors pointing the same source at different targets; after the
585 // tiebreak, selection must pick the required-matching one.
586 let src = schema_with_required("p", "c", true);
587 let p2 = proto();
588 // Target with two children: `d` required, `e` optional.
589 let tgt = SchemaBuilder::new(&p2)
590 .vertex("q", "object", None::<&str>)
591 .unwrap()
592 .vertex("d", "string", None::<&str>)
593 .unwrap()
594 .vertex("e", "string", None::<&str>)
595 .unwrap()
596 .edge("q", "d", "prop", Some("fd"))
597 .unwrap()
598 .edge("q", "e", "prop", Some("fe"))
599 .unwrap()
600 .required(
601 "q",
602 vec![Edge {
603 src: Name::from("q"),
604 tgt: Name::from("d"),
605 kind: Name::from("prop"),
606 name: Some(Name::from("fd")),
607 }],
608 )
609 .build()
610 .unwrap();
611 let mut anchors = vec![
612 Anchor {
613 src: Name::from("c"),
614 tgt: Name::from("d"),
615 confidence: 0.7,
616 strategy: StrategyTag::Alias,
617 provenance: Provenance::Synonym,
618 explanation: String::new(),
619 },
620 Anchor {
621 src: Name::from("c"),
622 tgt: Name::from("e"),
623 confidence: 0.7,
624 strategy: StrategyTag::Alias,
625 provenance: Provenance::Synonym,
626 explanation: String::new(),
627 },
628 ];
629 adjust_anchors_by_required_sets(&mut anchors, &src, &tgt);
630 let picked = evidence::aggregate(&anchors, evidence::AggregationPolicy::StrictPriority)
631 .select(
632 evidence::Cardinality::Strict,
633 evidence::RowFilter::relative_only(),
634 )
635 .to_map();
636 assert_eq!(
637 picked.get(&Name::from("c")).map(Name::as_str),
638 Some("d"),
639 "required-matching anchor must win the source slot"
640 );
641 }
642}
643
644#[cfg(test)]
645#[allow(clippy::unwrap_used)]
646mod constraint_compat_tests {
647 use super::*;
648 use panproto_schema::{Protocol, SchemaBuilder};
649
650 fn proto_with_format() -> Protocol {
651 Protocol {
652 name: "t".into(),
653 schema_theory: "ThTest".into(),
654 instance_theory: "ThWType".into(),
655 edge_rules: vec![],
656 obj_kinds: vec!["string".into()],
657 constraint_sorts: vec!["format".into(), "knownValues".into()],
658 ..Protocol::default()
659 }
660 }
661
662 fn schema_with(
663 name: &str,
664 kind: &str,
665 constraints: &[(&str, &str)],
666 ) -> panproto_schema::Schema {
667 let proto = proto_with_format();
668 let mut b = SchemaBuilder::new(&proto)
669 .vertex(name, kind, None::<&str>)
670 .unwrap();
671 for (sort, value) in constraints {
672 b = b.constraint(name, sort, value);
673 }
674 b.build().unwrap()
675 }
676
677 #[test]
678 fn source_with_no_constraints_matches_any_target_of_same_kind() {
679 let src = schema_with("a", "string", &[]);
680 let tgt = schema_with("a", "string", &[("format", "datetime")]);
681 assert!(kinds_and_constraints_compatible(
682 &src,
683 &Name::from("a"),
684 &tgt,
685 &Name::from("a"),
686 ));
687 }
688
689 #[test]
690 fn matching_format_constraint_compatible() {
691 let src = schema_with("a", "string", &[("format", "datetime")]);
692 let tgt = schema_with("a", "string", &[("format", "datetime")]);
693 assert!(kinds_and_constraints_compatible(
694 &src,
695 &Name::from("a"),
696 &tgt,
697 &Name::from("a"),
698 ));
699 }
700
701 #[test]
702 fn missing_constraint_on_target_fails() {
703 let src = schema_with("a", "string", &[("format", "datetime")]);
704 let tgt = schema_with("a", "string", &[]);
705 assert!(!kinds_and_constraints_compatible(
706 &src,
707 &Name::from("a"),
708 &tgt,
709 &Name::from("a"),
710 ));
711 }
712
713 #[test]
714 fn differing_format_value_fails() {
715 let src = schema_with("a", "string", &[("format", "datetime")]);
716 let tgt = schema_with("a", "string", &[("format", "uri")]);
717 assert!(!kinds_and_constraints_compatible(
718 &src,
719 &Name::from("a"),
720 &tgt,
721 &Name::from("a"),
722 ));
723 }
724
725 #[test]
726 fn mismatched_kind_fails_even_with_identical_constraints() {
727 let proto = proto_with_format();
728 let other_proto = Protocol {
729 obj_kinds: vec!["string".into(), "object".into()],
730 ..proto.clone()
731 };
732 let src = SchemaBuilder::new(&proto)
733 .vertex("a", "string", None::<&str>)
734 .unwrap()
735 .constraint("a", "format", "datetime")
736 .build()
737 .unwrap();
738 let tgt = SchemaBuilder::new(&other_proto)
739 .vertex("a", "object", None::<&str>)
740 .unwrap()
741 .build()
742 .unwrap();
743 assert!(!kinds_and_constraints_compatible(
744 &src,
745 &Name::from("a"),
746 &tgt,
747 &Name::from("a"),
748 ));
749 }
750}
751
752#[cfg(test)]
753mod tests {
754 use super::*;
755 use evidence::{AggregationPolicy, Cardinality, RowFilter, aggregate};
756
757 fn anchor(
758 src: &str,
759 tgt: &str,
760 confidence: f64,
761 tag: StrategyTag,
762 provenance: Provenance,
763 ) -> Anchor {
764 Anchor {
765 src: Name::from(src),
766 tgt: Name::from(tgt),
767 confidence,
768 strategy: tag,
769 provenance,
770 explanation: format!("{tag:?}: {src} ↔ {tgt}"),
771 }
772 }
773
774 /// One target per source, the way every caller that wants a map gets one.
775 fn picked(anchors: &[Anchor]) -> std::collections::HashMap<Name, Name> {
776 aggregate(anchors, AggregationPolicy::StrictPriority)
777 .select(Cardinality::Strict, RowFilter::relative_only())
778 .to_map()
779 }
780
781 #[test]
782 fn select_prefers_exact_over_alias_at_equal_confidence() {
783 let anchors = vec![
784 anchor("a", "B", 0.9, StrategyTag::Alias, Provenance::Synonym),
785 anchor(
786 "a",
787 "A",
788 0.9,
789 StrategyTag::Exact,
790 Provenance::ExactIdentifier,
791 ),
792 ];
793 assert_eq!(
794 picked(&anchors).get(&Name::from("a")).map(Name::as_str),
795 Some("A"),
796 "exact should beat alias at tied confidence"
797 );
798 }
799
800 /// The inversion the bands remove.
801 ///
802 /// Confidence used to be the primary key, so a `TokenSimilarity` anchor at
803 /// 0.8 took the slot from an `Exact` anchor at 0.4 and the documented
804 /// priority ordering was consulted only on bit-exact ties. Under the bands
805 /// the ordering is literally true: `Exact` owns `[0.8571, 0.9286]` and
806 /// `TokenSimilarity` owns `[0.4286, 0.5000]`, so no confidence can cross
807 /// between them.
808 #[test]
809 fn select_prefers_exact_over_token_similarity_despite_lower_confidence() {
810 let anchors = vec![
811 anchor(
812 "a",
813 "X",
814 0.4,
815 StrategyTag::Exact,
816 Provenance::ExactIdentifier,
817 ),
818 anchor(
819 "a",
820 "Y",
821 0.8,
822 StrategyTag::TokenSimilarity,
823 Provenance::Derived,
824 ),
825 ];
826 assert_eq!(
827 picked(&anchors).get(&Name::from("a")).map(Name::as_str),
828 Some("X")
829 );
830 }
831
832 #[test]
833 fn select_strict_drops_duplicate_targets() {
834 let anchors = vec![
835 anchor(
836 "a",
837 "T",
838 0.9,
839 StrategyTag::Exact,
840 Provenance::ExactIdentifier,
841 ),
842 anchor("b", "T", 0.8, StrategyTag::Alias, Provenance::Synonym),
843 ];
844 let resolved = picked(&anchors);
845 assert_eq!(resolved.len(), 1);
846 assert_eq!(
847 resolved.get(&Name::from("a")).map(Name::as_str),
848 Some("T"),
849 "the stronger anchor keeps the target"
850 );
851 }
852
853 /// Many-to-one is no longer a selector mode.
854 ///
855 /// The old resolver took a `monic` flag and, when it was false, let several
856 /// sources share a target. Sharing a target is a property of the *morphism*
857 /// the search returns, decided by `SearchOptions::monic` against the whole
858 /// objective, so no cardinality here reproduces it: the strictly weaker
859 /// claim on a contested target loses under every mode.
860 #[test]
861 fn select_never_reproduces_the_old_many_to_one_fan_out() {
862 let anchors = vec![
863 anchor(
864 "a",
865 "T",
866 0.9,
867 StrategyTag::Exact,
868 Provenance::ExactIdentifier,
869 ),
870 anchor("b", "T", 0.8, StrategyTag::Alias, Provenance::Synonym),
871 ];
872 let table = aggregate(&anchors, AggregationPolicy::StrictPriority);
873 for cardinality in [
874 Cardinality::Strict,
875 Cardinality::Permissive,
876 Cardinality::default(),
877 ] {
878 assert_eq!(
879 table.select(cardinality, RowFilter::new(0.0, 1.0)).len(),
880 1,
881 "{cardinality:?} let the weaker claim share the target"
882 );
883 }
884 }
885
886 #[test]
887 fn select_prefers_type_signature_over_coerce_at_equal_confidence() {
888 // Same-kind signature match ranks above cross-kind Coerce bridge.
889 let anchors = vec![
890 anchor("a", "C", 0.7, StrategyTag::Coerce, Provenance::Inferred),
891 anchor(
892 "a",
893 "T",
894 0.7,
895 StrategyTag::TypeSignature,
896 Provenance::Inferred,
897 ),
898 ];
899 assert_eq!(
900 picked(&anchors).get(&Name::from("a")).map(Name::as_str),
901 Some("T"),
902 "TypeSignature must beat Coerce at tied confidence"
903 );
904 }
905
906 #[test]
907 fn select_prefers_exact_over_coerce_at_equal_confidence() {
908 let anchors = vec![
909 anchor("a", "C", 0.7, StrategyTag::Coerce, Provenance::Inferred),
910 anchor(
911 "a",
912 "E",
913 0.7,
914 StrategyTag::Exact,
915 Provenance::ExactIdentifier,
916 ),
917 ];
918 assert_eq!(
919 picked(&anchors).get(&Name::from("a")).map(Name::as_str),
920 Some("E"),
921 "Exact must beat Coerce at tied confidence"
922 );
923 }
924
925 #[test]
926 fn select_strict_three_sources_one_target_keeps_highest_confidence() {
927 // Three sources all want the same target at different confidences.
928 // Under the strictest cardinality only the strongest source wins the
929 // target; the others are dropped entirely, having no fallback anchor.
930 let anchors = vec![
931 anchor(
932 "a",
933 "T",
934 0.6,
935 StrategyTag::Exact,
936 Provenance::ExactIdentifier,
937 ),
938 anchor(
939 "b",
940 "T",
941 0.9,
942 StrategyTag::Exact,
943 Provenance::ExactIdentifier,
944 ),
945 anchor(
946 "c",
947 "T",
948 0.75,
949 StrategyTag::Exact,
950 Provenance::ExactIdentifier,
951 ),
952 ];
953 let resolved = picked(&anchors);
954 assert_eq!(resolved.len(), 1);
955 assert_eq!(
956 resolved.get(&Name::from("b")).map(Name::as_str),
957 Some("T"),
958 "highest confidence wins the target within one band"
959 );
960 assert!(!resolved.contains_key(&Name::from("a")));
961 assert!(!resolved.contains_key(&Name::from("c")));
962 }
963
964 #[test]
965 fn select_drops_nan_confidence_anchor() {
966 // A malformed anchor whose confidence is a non-number must never win
967 // its source slot over a rival with a real score, however strong its
968 // tag. `aggregate` drops it before the ceiling, the band, or the user
969 // hint override can read it.
970 let anchors = vec![
971 anchor("a", "GOOD", 0.8, StrategyTag::Alias, Provenance::Synonym),
972 anchor(
973 "a",
974 "BAD",
975 f64::NAN,
976 StrategyTag::UserHint,
977 Provenance::UserSupplied,
978 ),
979 ];
980 assert_eq!(
981 picked(&anchors).get(&Name::from("a")).map(Name::as_str),
982 Some("GOOD"),
983 "a non-number confidence must be dropped even when its tag outranks"
984 );
985 }
986
987 #[test]
988 fn select_all_nan_anchors_yields_empty_map() {
989 let anchors = vec![
990 anchor(
991 "a",
992 "X",
993 f64::NAN,
994 StrategyTag::Exact,
995 Provenance::ExactIdentifier,
996 ),
997 anchor(
998 "b",
999 "Y",
1000 f64::NAN,
1001 StrategyTag::Exact,
1002 Provenance::ExactIdentifier,
1003 ),
1004 ];
1005 assert!(picked(&anchors).is_empty());
1006 }
1007
1008 /// An infinity is a confidence the old resolver ranked above every finite
1009 /// rival, so a single malformed anchor could take any slot. It is now
1010 /// clamped into `[0, 1]` and then capped by its provenance, so it competes
1011 /// on its band like everything else and loses to a stronger tag.
1012 #[test]
1013 fn select_clamps_infinite_confidence() {
1014 let anchors = vec![
1015 anchor(
1016 "a",
1017 "X",
1018 0.9,
1019 StrategyTag::Exact,
1020 Provenance::ExactIdentifier,
1021 ),
1022 anchor(
1023 "a",
1024 "INF",
1025 f64::INFINITY,
1026 StrategyTag::Alias,
1027 Provenance::Synonym,
1028 ),
1029 ];
1030 assert_eq!(
1031 picked(&anchors).get(&Name::from("a")).map(Name::as_str),
1032 Some("X")
1033 );
1034 }
1035
1036 #[test]
1037 fn select_empty_anchors_returns_empty_map() {
1038 assert!(picked(&[]).is_empty());
1039 }
1040
1041 #[test]
1042 fn strategy_priority_is_strictly_decreasing_across_all_variants() {
1043 // Audit-of-audits: ensure the documented ordering
1044 // UserHint > Exact > EdgeLabel > ExactSuffix > Alias >
1045 // TypeSignature > WrapUnwrap > TokenSimilarity > Coerce >
1046 // Structural > Llm holds strictly (no ties) across every
1047 // variant. A future addition of a new variant must explicitly
1048 // slot into the ordering here.
1049 let ordered = [
1050 StrategyTag::UserHint,
1051 StrategyTag::Exact,
1052 StrategyTag::EdgeLabel,
1053 StrategyTag::ExactSuffix,
1054 StrategyTag::Alias,
1055 StrategyTag::TypeSignature,
1056 StrategyTag::WrapUnwrap,
1057 StrategyTag::TokenSimilarity,
1058 StrategyTag::DescriptionSimilarity,
1059 StrategyTag::Coerce,
1060 StrategyTag::Neighborhood,
1061 StrategyTag::WlRefinement,
1062 StrategyTag::Structural,
1063 StrategyTag::Llm,
1064 ];
1065 for pair in ordered.windows(2) {
1066 let hi = pair[0].priority();
1067 let lo = pair[1].priority();
1068 assert!(
1069 hi > lo,
1070 "priority must strictly decrease: {:?}({hi}) !> {:?}({lo})",
1071 pair[0],
1072 pair[1]
1073 );
1074 }
1075 }
1076
1077 #[test]
1078 fn strategy_priority_table_is_total_and_ordered() {
1079 // Explicit snapshot of the priority table so future edits don't
1080 // silently reshuffle ties.
1081 let tags = [
1082 (StrategyTag::UserHint, 100),
1083 (StrategyTag::Exact, 90),
1084 (StrategyTag::EdgeLabel, 85),
1085 (StrategyTag::ExactSuffix, 80),
1086 (StrategyTag::Alias, 70),
1087 (StrategyTag::TypeSignature, 60),
1088 (StrategyTag::WrapUnwrap, 55),
1089 (StrategyTag::TokenSimilarity, 50),
1090 (StrategyTag::DescriptionSimilarity, 45),
1091 (StrategyTag::Coerce, 40),
1092 (StrategyTag::Neighborhood, 35),
1093 (StrategyTag::WlRefinement, 32),
1094 (StrategyTag::Structural, 30),
1095 (StrategyTag::Llm, 20),
1096 ];
1097 for (tag, expected) in tags {
1098 assert_eq!(tag.priority(), expected, "{tag:?}");
1099 }
1100 }
1101
1102 /// The bands are cut on the rank, so a rank that disagreed with the
1103 /// priority table would silently invert the ordering the bands exist to
1104 /// enforce.
1105 #[test]
1106 fn rank_is_the_position_in_priority_order() {
1107 let mut position = 0u32;
1108 for tag in evidence::PRIORITY_ORDER {
1109 assert_eq!(tag.rank(), position, "{tag:?}");
1110 position += 1;
1111 }
1112 assert_eq!(position, STRATEGY_COUNT);
1113
1114 for pair in evidence::PRIORITY_ORDER.windows(2) {
1115 assert!(pair[0].rank() < pair[1].rank());
1116 assert!(pair[0].priority() > pair[1].priority());
1117 }
1118 }
1119
1120 /// Every tag has a family, and the one tag whose branches read different
1121 /// inputs is the only one whose family moves with the provenance.
1122 #[test]
1123 fn every_tag_has_a_family() {
1124 for tag in evidence::PRIORITY_ORDER {
1125 let default_family = tag.family();
1126 for provenance in evidence::PROVENANCES {
1127 let family = Family::of(tag, provenance);
1128 if tag == StrategyTag::Alias && provenance == Provenance::DeclaredEdgeLabel {
1129 assert_eq!(family, Family::EdgeLabel);
1130 } else {
1131 assert_eq!(family, default_family, "{tag:?}/{provenance:?}");
1132 }
1133 }
1134 }
1135 }
1136}