Skip to main content

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}