Skip to main content

panproto_gat/
sort.rs

1use std::sync::Arc;
2
3use rustc_hash::FxHashMap;
4
5use crate::eq::Term;
6
7/// A sort expression: a plain sort name or a dependent sort applied to
8/// argument terms.
9///
10/// Appears in [`Operation::output`](crate::op::Operation::output),
11/// [`Operation::inputs`](crate::op::Operation::inputs) entries, and
12/// [`SortParam::sort`], wherever a sort occurs.
13///
14/// Normalization invariant: every [`SortExpr`] produced by constructors
15/// and public operations is normalized. A normalized value uses the
16/// `Name` spelling whenever the argument list is empty; `App { name, args:
17/// [] }` never appears in normalized values. This invariant is enforced
18/// by the smart constructor [`SortExpr::app`], by [`Self::subst`], by
19/// [`Self::rename_head`], by [`Self::apply_maps`], and by the custom
20/// `serde::Deserialize` impl. The derived [`PartialEq`] and [`Hash`] are
21/// replaced with manual impls that treat `Name(n)` and `App { name: n,
22/// args: [] }` as equal (in case a caller constructs a non-normalized
23/// value directly through the `App` variant).
24///
25/// The serde representation uses `#[serde(untagged)]`, so `Name(n)`
26/// serializes as the bare string `"n"` and `App` serializes as a struct
27/// with `name` and `args` fields.
28#[derive(Debug, Clone, Eq, serde::Serialize)]
29#[serde(untagged)]
30pub enum SortExpr {
31    /// A plain sort name with no parameters applied.
32    Name(Arc<str>),
33    /// A dependent sort applied to argument terms, e.g. `Tm(Γ, A)`.
34    App {
35        /// The sort's declared name.
36        name: Arc<str>,
37        /// Argument terms, one per declared sort parameter.
38        args: Vec<Term>,
39    },
40}
41
42impl SortExpr {
43    /// Smart constructor: produces [`Self::Name`] when `args` is empty,
44    /// otherwise [`Self::App`]. This is the only way to construct a
45    /// normalized `SortExpr::App`; direct use of the `App` variant is
46    /// allowed for backwards compatibility, but normalization should be
47    /// restored via [`Self::normalize`] before the value escapes its
48    /// local context.
49    #[must_use]
50    pub fn app(name: impl Into<Arc<str>>, args: Vec<Term>) -> Self {
51        if args.is_empty() {
52            Self::Name(name.into())
53        } else {
54            Self::App {
55                name: name.into(),
56                args,
57            }
58        }
59    }
60
61    /// Normalize this expression: collapse `App { name, args: [] }` into
62    /// `Name(name)`. Idempotent; leaves already-normalized values
63    /// unchanged. The argument list is not recursed into because the
64    /// term AST inside arguments does not share this two-spelling
65    /// ambiguity.
66    #[must_use]
67    pub fn normalize(self) -> Self {
68        if let Self::App { name, args } = &self {
69            if args.is_empty() {
70                return Self::Name(Arc::clone(name));
71            }
72        }
73        self
74    }
75
76    /// Extract the bare sort name, ignoring any applied arguments.
77    #[must_use]
78    pub const fn head(&self) -> &Arc<str> {
79        match self {
80            Self::Name(n) | Self::App { name: n, .. } => n,
81        }
82    }
83
84    /// Argument terms, if any; empty slice for `Name`.
85    #[must_use]
86    pub fn args(&self) -> &[Term] {
87        match self {
88            Self::Name(_) => &[],
89            Self::App { args, .. } => args,
90        }
91    }
92
93    /// Substitute `mapping` (parameter name to term) throughout this sort
94    /// expression's argument terms.
95    #[must_use]
96    pub fn subst(&self, mapping: &FxHashMap<Arc<str>, Term>) -> Self {
97        match self {
98            Self::Name(n) => Self::Name(Arc::clone(n)),
99            Self::App { name, args } => Self::app(
100                Arc::clone(name),
101                args.iter().map(|t| t.substitute(mapping)).collect(),
102            ),
103        }
104    }
105
106    /// Structural equality modulo `Name(n) == App { name: n, args: [] }`.
107    ///
108    /// Equivalent to [`PartialEq::eq`] after the normalization invariant;
109    /// retained as a named method for documentation and for code that
110    /// wants to make the two-spelling quotient explicit at call sites.
111    #[must_use]
112    pub fn alpha_eq(&self, other: &Self) -> bool {
113        self.head() == other.head() && self.args() == other.args()
114    }
115
116    /// Definitional equality modulo a directed-rewrite system.
117    ///
118    /// Normalizes both sides by rewriting every argument term with
119    /// [`crate::eq::normalize`] under `rules` and a bounded step budget,
120    /// then compares the normalized sort expressions with
121    /// [`Self::alpha_eq`]. Heads must agree; if they do not, no amount
122    /// of rewriting closes the gap and the function returns `false`
123    /// immediately. Callers that want the strict structural equality
124    /// should continue to use [`Self::alpha_eq`].
125    #[must_use]
126    pub fn alpha_eq_modulo_rewrites(
127        &self,
128        other: &Self,
129        rules: &[crate::eq::DirectedEquation],
130        step_limit: usize,
131    ) -> bool {
132        if self.head() != other.head() {
133            return false;
134        }
135        if self.args().len() != other.args().len() {
136            return false;
137        }
138        let normalize_all = |args: &[Term]| -> Vec<Term> {
139            args.iter()
140                .map(|t| crate::eq::normalize(t, rules, step_limit))
141                .collect()
142        };
143        let left = normalize_all(self.args());
144        let right = normalize_all(other.args());
145        left == right
146    }
147
148    /// Rename the head (sort name) via `sort_map`, leaving arguments
149    /// unchanged.
150    #[must_use]
151    pub fn rename_head(&self, sort_map: &std::collections::HashMap<Arc<str>, Arc<str>>) -> Self {
152        match self {
153            Self::Name(n) => Self::Name(sort_map.get(n).cloned().unwrap_or_else(|| Arc::clone(n))),
154            Self::App { name, args } => Self::app(
155                sort_map
156                    .get(name)
157                    .cloned()
158                    .unwrap_or_else(|| Arc::clone(name)),
159                args.clone(),
160            ),
161        }
162    }
163
164    /// Apply a sort-name rename to the head and an operation rename to
165    /// every term in the argument list.
166    #[must_use]
167    pub fn apply_maps(
168        &self,
169        sort_map: &std::collections::HashMap<Arc<str>, Arc<str>>,
170        op_map: &std::collections::HashMap<Arc<str>, Arc<str>>,
171    ) -> Self {
172        match self {
173            Self::Name(n) => Self::Name(sort_map.get(n).cloned().unwrap_or_else(|| Arc::clone(n))),
174            Self::App { name, args } => Self::app(
175                sort_map
176                    .get(name)
177                    .cloned()
178                    .unwrap_or_else(|| Arc::clone(name)),
179                args.iter().map(|t| t.rename_ops(op_map)).collect(),
180            ),
181        }
182    }
183}
184
185/// Build a variable-rename substitution that sends each domain parameter
186/// name to the codomain parameter name at the same position.
187///
188/// Returned substitution is suitable for feeding to
189/// [`SortExpr::subst`](crate::sort::SortExpr::subst) or
190/// [`Term::substitute`](crate::eq::Term::substitute). Positions past the
191/// shorter of the two sequences are not bound; callers that need an
192/// arity equality check should verify it before calling this helper.
193///
194/// Identity pairs (where the two names agree) are omitted, so the
195/// returned substitution is the identity when both sides already use the
196/// same names and `subst` becomes a no-op on short-circuit paths.
197#[must_use]
198pub fn positional_param_rename<I, J>(
199    domain_params: I,
200    target_params: J,
201) -> FxHashMap<Arc<str>, Term>
202where
203    I: IntoIterator<Item = Arc<str>>,
204    J: IntoIterator<Item = Arc<str>>,
205{
206    let mut rename = FxHashMap::default();
207    for (d, t) in domain_params.into_iter().zip(target_params) {
208        if d != t {
209            rename.insert(d, Term::Var(t));
210        }
211    }
212    rename
213}
214
215/// Compare two operation or sort signatures modulo positional alpha-rename
216/// of the declared parameter names.
217///
218/// Each signature is a list of `(param_name, param_sort)` pairs plus an
219/// output sort expression. Parameter names are local binders and are in
220/// scope in every later parameter sort and in the output. Two signatures
221/// are considered equivalent when the substitution that sends the left
222/// side's parameter names to the right side's (positionally) makes the
223/// sort expressions pairwise equal under [`SortExpr::alpha_eq`].
224///
225/// Arity mismatches fail. If either side has fewer parameters than the
226/// other this function returns `false`.
227#[must_use]
228pub fn signatures_equivalent_modulo_param_rename(
229    lhs_inputs: &[(Arc<str>, SortExpr, crate::op::Implicit)],
230    lhs_output: &SortExpr,
231    rhs_inputs: &[(Arc<str>, SortExpr, crate::op::Implicit)],
232    rhs_output: &SortExpr,
233) -> bool {
234    if lhs_inputs.len() != rhs_inputs.len() {
235        return false;
236    }
237    let rename = positional_param_rename(
238        lhs_inputs.iter().map(|(n, _, _)| Arc::clone(n)),
239        rhs_inputs.iter().map(|(n, _, _)| Arc::clone(n)),
240    );
241    for ((_, lhs_sort, l_imp), (_, rhs_sort, r_imp)) in lhs_inputs.iter().zip(rhs_inputs.iter()) {
242        if l_imp != r_imp {
243            return false;
244        }
245        if !lhs_sort.subst(&rename).alpha_eq(rhs_sort) {
246            return false;
247        }
248    }
249    lhs_output.subst(&rename).alpha_eq(rhs_output)
250}
251
252/// Compare two sort declarations' parameter blocks modulo positional
253/// alpha-rename of the declared parameter names.
254///
255/// Sort parameter sorts may themselves be dependent (e.g.
256/// `(Γ : Ctx, A : Ty(Γ))`), so the rename is accumulated positionally
257/// and applied to every later parameter sort.
258#[must_use]
259pub fn sort_params_equivalent_modulo_rename(lhs: &[SortParam], rhs: &[SortParam]) -> bool {
260    if lhs.len() != rhs.len() {
261        return false;
262    }
263    let rename = positional_param_rename(
264        lhs.iter().map(|p| Arc::clone(&p.name)),
265        rhs.iter().map(|p| Arc::clone(&p.name)),
266    );
267    lhs.iter()
268        .zip(rhs.iter())
269        .all(|(l, r)| l.sort.subst(&rename).alpha_eq(&r.sort))
270}
271
272impl PartialEq for SortExpr {
273    /// Two sort expressions are equal when their heads and argument lists
274    /// agree. This reduces `Name(n)` to `App { name: n, args: [] }` under
275    /// equality, matching [`SortExpr::alpha_eq`] and ensuring that the
276    /// `Eq`/`Hash` contract holds across both spellings.
277    fn eq(&self, other: &Self) -> bool {
278        self.head() == other.head() && self.args() == other.args()
279    }
280}
281
282impl std::hash::Hash for SortExpr {
283    /// Hash by head and arguments. This agrees with [`PartialEq::eq`]
284    /// across the `Name` / empty-args `App` quotient so that both
285    /// spellings occupy the same hash bucket when used as a map key.
286    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
287        self.head().hash(state);
288        self.args().hash(state);
289    }
290}
291
292impl<'de> serde::Deserialize<'de> for SortExpr {
293    /// Normalize on load: any `App { name, args: [] }` incoming from JSON
294    /// collapses to `Name(name)` so that every downstream consumer sees
295    /// the canonical spelling.
296    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
297    where
298        D: serde::Deserializer<'de>,
299    {
300        #[derive(serde::Deserialize)]
301        #[serde(untagged)]
302        enum Raw {
303            Name(Arc<str>),
304            App { name: Arc<str>, args: Vec<Term> },
305        }
306        match Raw::deserialize(deserializer)? {
307            Raw::Name(n) => Ok(Self::Name(n)),
308            Raw::App { name, args } => Ok(Self::app(name, args)),
309        }
310    }
311}
312
313impl std::fmt::Display for SortExpr {
314    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
315        match self {
316            Self::Name(n) => f.write_str(n),
317            Self::App { name, args } => {
318                f.write_str(name)?;
319                f.write_str("(")?;
320                for (i, a) in args.iter().enumerate() {
321                    if i > 0 {
322                        f.write_str(", ")?;
323                    }
324                    write!(f, "{a}")?;
325                }
326                f.write_str(")")
327            }
328        }
329    }
330}
331
332impl std::fmt::Display for Term {
333    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
334        match self {
335            Self::Var(n) => f.write_str(n),
336            Self::App { op, args } if args.is_empty() => write!(f, "{op}()"),
337            Self::App { op, args } => {
338                write!(f, "{op}(")?;
339                for (i, a) in args.iter().enumerate() {
340                    if i > 0 {
341                        f.write_str(", ")?;
342                    }
343                    write!(f, "{a}")?;
344                }
345                f.write_str(")")
346            }
347            Self::Hole { name } => match name {
348                Some(n) => write!(f, "?{n}"),
349                None => f.write_str("?"),
350            },
351            Self::Let { name, bound, body } => {
352                write!(f, "let {name} = {bound} in {body}")
353            }
354            Self::Case {
355                scrutinee,
356                branches,
357            } => {
358                write!(f, "case {scrutinee} of ")?;
359                for (i, b) in branches.iter().enumerate() {
360                    if i > 0 {
361                        f.write_str(" | ")?;
362                    }
363                    write!(f, "{}(", b.constructor)?;
364                    for (j, binder) in b.binders.iter().enumerate() {
365                        if j > 0 {
366                            f.write_str(", ")?;
367                        }
368                        f.write_str(binder)?;
369                    }
370                    write!(f, ") => {}", b.body)?;
371                }
372                f.write_str(" end")
373            }
374        }
375    }
376}
377
378impl From<&str> for SortExpr {
379    fn from(s: &str) -> Self {
380        Self::Name(Arc::from(s))
381    }
382}
383
384impl From<String> for SortExpr {
385    fn from(s: String) -> Self {
386        Self::Name(Arc::from(s))
387    }
388}
389
390impl From<Arc<str>> for SortExpr {
391    fn from(s: Arc<str>) -> Self {
392        Self::Name(s)
393    }
394}
395
396impl From<&Arc<str>> for SortExpr {
397    fn from(s: &Arc<str>) -> Self {
398        Self::Name(Arc::clone(s))
399    }
400}
401
402/// Classification of a coercion's round-trip properties.
403///
404/// Forms a four-element lattice under information loss, shaped as a
405/// diamond:
406///
407/// ```text
408///          Iso
409///         /   \
410///  Retraction   Projection
411///         \   /
412///         Opaque
413/// ```
414///
415/// Categorically, this classifies the adjunction witness of a fiber
416/// morphism in the Grothendieck fibration over the schema category:
417///
418/// - `Iso`: both unit and counit are identities (isomorphism in the
419///   fiber). Complement stores nothing.
420/// - `Retraction`: the forward map has a left inverse
421///   (`inverse(forward(v)) = v`). The forward map is injective
422///   (information-preserving). Complement stores the residual between
423///   the original and the round-tripped value.
424/// - `Projection`: the forward map is a dependent projection from the
425///   total fiber. The result is deterministically re-derivable from
426///   the source data, but no inverse exists that recovers the source
427///   from the result alone. Complement stores nothing for the derived
428///   field because `get` re-derives it; this is the dual of
429///   `Retraction` in the information-loss lattice.
430/// - `Opaque`: no structural relationship between forward and backward
431///   maps. Complement stores the entire original value.
432///
433/// Composition follows the lattice meet (in the quality ordering):
434/// `Iso` is the identity, `Opaque` is the absorber, same-kind composes
435/// idempotently, and cross-kind (`Retraction` ∘ `Projection`) collapses
436/// to `Opaque`.
437#[derive(
438    Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize,
439)]
440#[non_exhaustive]
441pub enum CoercionClass {
442    /// Isomorphism: both round-trip laws hold. Complement stores nothing
443    /// for this coercion.
444    #[default]
445    Iso,
446    /// Retraction: `inverse(forward(v)) = v` but
447    /// `forward(inverse(w)) ≠ w` in general. The forward map is
448    /// injective (information-preserving). Complement stores the
449    /// residual between the original and the round-tripped value.
450    Retraction,
451    /// Projection: the result is a deterministic function of the source
452    /// fiber, but no inverse recovers the source from the result alone.
453    /// This is the classification for `ComputeField` transforms that
454    /// derive data from child scalar values (the dependent-sum
455    /// projection). Complement stores nothing for the derived field
456    /// because `get` re-derives it deterministically; modifications to
457    /// the derived field in the view are not independently
458    /// round-trippable (analogous to SQL computed columns).
459    ///
460    /// Dual to `Retraction` in the information-loss lattice:
461    /// `Retraction` preserves information forward (left inverse exists),
462    /// `Projection` is re-derivable (no inverse, but deterministic).
463    Projection,
464    /// No inverse exists and no structural re-derivation property holds.
465    /// Complement stores the entire original value.
466    Opaque,
467}
468
469impl CoercionClass {
470    /// Compose two coercion classes (lattice meet in the quality
471    /// ordering, equivalently lattice join in the information-loss
472    /// ordering).
473    ///
474    /// Laws:
475    /// - `Iso` is identity: `Iso.compose(x) = x`
476    /// - `Opaque` absorbs: `Opaque.compose(x) = Opaque`
477    /// - Same-kind is idempotent: `Retraction.compose(Retraction) = Retraction`,
478    ///   `Projection.compose(Projection) = Projection`
479    /// - Cross-kind collapses: `Retraction.compose(Projection) = Opaque`
480    ///   (a retraction followed by a projection, or vice versa, has
481    ///   neither a left inverse nor a re-derivation property)
482    #[must_use]
483    pub const fn compose(self, other: Self) -> Self {
484        match (self, other) {
485            (Self::Iso, x) | (x, Self::Iso) => x,
486            (Self::Opaque, _) | (_, Self::Opaque) => Self::Opaque,
487            (Self::Retraction, Self::Retraction) => Self::Retraction,
488            (Self::Projection, Self::Projection) => Self::Projection,
489            // Cross-kind: retraction composed with projection (or vice
490            // versa) has neither a left inverse nor re-derivability,
491            // so it collapses to Opaque.
492            (Self::Retraction, Self::Projection) | (Self::Projection, Self::Retraction) => {
493                Self::Opaque
494            }
495        }
496    }
497
498    /// Returns `true` if this coercion is lossless (isomorphism).
499    ///
500    /// Only `Iso` is lossless: both round-trip laws hold. `Projection`
501    /// is NOT lossless (the derivation discards source information),
502    /// even though its complement happens to be empty (the derived value
503    /// is re-computable, not stored).
504    #[must_use]
505    pub const fn is_lossless(self) -> bool {
506        matches!(self, Self::Iso)
507    }
508
509    /// Returns `true` if the complement must store data for this
510    /// coercion.
511    ///
512    /// `Iso` and `Projection` both have empty complements:
513    /// - `Iso`: no information lost, nothing to store.
514    /// - `Projection`: the derived value is re-computed by `get`
515    ///   deterministically from the source fiber, so the complement
516    ///   need not store it. (The source data itself survives through
517    ///   the tree structure, not through this coercion's complement.)
518    ///
519    /// `Retraction` and `Opaque` require complement storage:
520    /// - `Retraction`: stores the residual (the counit's failure).
521    /// - `Opaque`: stores the entire original value.
522    #[must_use]
523    pub const fn needs_complement_storage(self) -> bool {
524        matches!(self, Self::Retraction | Self::Opaque)
525    }
526
527    /// Every currently-known variant of [`CoercionClass`].
528    ///
529    /// The returned slice is the authoritative iteration order for
530    /// exhaustiveness-sensitive consumers (sample-based law checkers,
531    /// regression tests that must run once per class, breaking-change
532    /// migration tables). A dummy `match` below forces a compile
533    /// error when a new variant is added upstream: extend both the
534    /// match arms and the returned slice together.
535    ///
536    /// `CoercionClass` is `#[non_exhaustive]`; callers outside this
537    /// crate cannot write a `match` that panics on unknown variants
538    /// without a wildcard arm. This method lets them stay in lockstep
539    /// with the enum anyway.
540    #[must_use]
541    pub const fn all() -> &'static [Self] {
542        // Exhaustiveness guard: adding a new variant to
543        // `CoercionClass` breaks this match and forces an update to
544        // `ALL` below.
545        const fn _exhaustiveness_witness(c: CoercionClass) {
546            match c {
547                CoercionClass::Iso
548                | CoercionClass::Retraction
549                | CoercionClass::Projection
550                | CoercionClass::Opaque => {}
551            }
552        }
553        const ALL: &[CoercionClass] = &[
554            CoercionClass::Iso,
555            CoercionClass::Retraction,
556            CoercionClass::Projection,
557            CoercionClass::Opaque,
558        ];
559        ALL
560    }
561}
562
563impl PartialOrd for CoercionClass {
564    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
565        Some(self.cmp(other))
566    }
567}
568
569impl Ord for CoercionClass {
570    /// Linear extension of the diamond partial order, reflecting
571    /// increasing lossiness: `Iso < Retraction < Projection < Opaque`.
572    ///
573    /// In the diamond lattice, `Retraction` and `Projection` are
574    /// incomparable (neither implies the other). This total order
575    /// linearizes them by ranking `Retraction` below `Projection`
576    /// because a retraction has a left inverse (the forward map
577    /// preserves all information), while a projection has no inverse
578    /// (though the result is re-derivable). The linearization is
579    /// consistent with `compose`: `compose(a, b) >= max(a, b)` holds
580    /// for all pairs.
581    ///
582    /// This ordering is used by the breaking-change detector: changing
583    /// a coercion from `Retraction` to `Projection` is a downgrade
584    /// (more structural information lost).
585    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
586        const fn rank(c: CoercionClass) -> u8 {
587            match c {
588                CoercionClass::Iso => 0,
589                CoercionClass::Retraction => 1,
590                CoercionClass::Projection => 2,
591                CoercionClass::Opaque => 3,
592            }
593        }
594        rank(*self).cmp(&rank(*other))
595    }
596}
597
598/// Classify a builtin coercion operation by its source/target value kinds
599/// and round-trip class.
600///
601/// Returns `None` for non-coercion builtins.
602#[must_use]
603pub const fn classify_builtin_coercion(
604    op: panproto_expr::BuiltinOp,
605) -> Option<(ValueKind, ValueKind, CoercionClass)> {
606    use panproto_expr::BuiltinOp;
607    match op {
608        // Int → Float: every i64 maps to a distinct f64 (within 2^53),
609        // but not every f64 maps back. Retraction: float_to_int(int_to_float(n)) = n.
610        BuiltinOp::IntToFloat => {
611            Some((ValueKind::Int, ValueKind::Float, CoercionClass::Retraction))
612        }
613        // Float → Int: truncation loses fractional part. No guaranteed inverse.
614        BuiltinOp::FloatToInt => Some((ValueKind::Float, ValueKind::Int, CoercionClass::Opaque)),
615        // Int → Str: every int has a canonical string form and str_to_int(int_to_str(n)) = n,
616        // but not every string is a valid int, so int_to_str(str_to_int(s)) ≠ s in general.
617        // This is a retraction (section of str_to_int), not an iso.
618        BuiltinOp::IntToStr => Some((ValueKind::Int, ValueKind::Str, CoercionClass::Retraction)),
619        // Float → Str: formatting may lose precision (e.g., "1.0" round-trips to "1").
620        // Neither direction is guaranteed to round-trip. Opaque.
621        BuiltinOp::FloatToStr => Some((ValueKind::Float, ValueKind::Str, CoercionClass::Opaque)),
622        // Str → Int: fails on non-numeric strings. Opaque.
623        BuiltinOp::StrToInt => Some((ValueKind::Str, ValueKind::Int, CoercionClass::Opaque)),
624        // Str → Float: fails on non-numeric strings. Opaque.
625        BuiltinOp::StrToFloat => Some((ValueKind::Str, ValueKind::Float, CoercionClass::Opaque)),
626        _ => None,
627    }
628}
629
630/// The primitive value kind that a value sort ranges over.
631#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
632pub enum ValueKind {
633    /// Boolean values.
634    Bool,
635    /// Integer values.
636    Int,
637    /// Floating-point values.
638    Float,
639    /// String values.
640    Str,
641    /// Byte-sequence values.
642    Bytes,
643    /// Opaque token values.
644    Token,
645    /// Null / unit values.
646    Null,
647    /// Any value kind (polymorphic).
648    Any,
649}
650
651impl ValueKind {
652    /// Returns the canonical string representation of this value kind.
653    #[must_use]
654    pub const fn as_str(&self) -> &'static str {
655        match self {
656            Self::Bool => "boolean",
657            Self::Int => "integer",
658            Self::Float => "number",
659            Self::Str => "string",
660            Self::Bytes => "bytes",
661            Self::Token => "token",
662            Self::Null => "null",
663            Self::Any => "any",
664        }
665    }
666
667    /// Every variant of [`ValueKind`], in a fixed canonical order.
668    ///
669    /// Consumers that need to iterate every value kind (sample
670    /// registries, coverage regression tests, kind-indexed tables)
671    /// should use this rather than hand-written slice literals; the
672    /// dummy `match` below turns a silent omission into a compile
673    /// error when a new variant is added.
674    #[must_use]
675    pub const fn all() -> &'static [Self] {
676        // Exhaustiveness guard: adding a new variant to `ValueKind`
677        // breaks this match and forces an update to `ALL` below.
678        const fn _exhaustiveness_witness(k: ValueKind) {
679            match k {
680                ValueKind::Bool
681                | ValueKind::Int
682                | ValueKind::Float
683                | ValueKind::Str
684                | ValueKind::Bytes
685                | ValueKind::Token
686                | ValueKind::Null
687                | ValueKind::Any => {}
688            }
689        }
690        const ALL: &[ValueKind] = &[
691            ValueKind::Bool,
692            ValueKind::Int,
693            ValueKind::Float,
694            ValueKind::Str,
695            ValueKind::Bytes,
696            ValueKind::Token,
697            ValueKind::Null,
698            ValueKind::Any,
699        ];
700        ALL
701    }
702}
703
704/// The kind of a sort, distinguishing structural sorts from value/coercion sorts.
705#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
706pub enum SortKind {
707    /// Standard structural sort (vertices, edges, constraints).
708    #[default]
709    Structural,
710    /// Value sort: carries data of a specific kind.
711    Val(ValueKind),
712    /// Coercion sort: a directed morphism between value kinds.
713    /// Categorically, this is a morphism in the fiber category over the schema.
714    Coercion {
715        /// The source value kind.
716        from: ValueKind,
717        /// The target value kind.
718        to: ValueKind,
719        /// Round-trip classification of this coercion.
720        class: CoercionClass,
721    },
722    /// Merger sort: combines values of a specific kind.
723    Merger(ValueKind),
724}
725
726/// A parameter of a dependent sort.
727///
728/// Sort parameters allow sorts to depend on terms of other sorts,
729/// which is the key feature distinguishing GATs from ordinary algebraic theories.
730///
731/// # Example
732///
733/// In the theory of categories, `Hom(a: Ob, b: Ob)` has two parameters
734/// of sort `Ob`.
735#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
736pub struct SortParam {
737    /// The parameter name (e.g., "a", "b").
738    pub name: Arc<str>,
739    /// The sort expression this parameter ranges over (e.g., `Ob` or
740    /// a dependent sort like `Ty(Γ)`).
741    pub sort: SortExpr,
742}
743
744/// Closure attribute on a [`Sort`].
745///
746/// An open sort may be inhabited by any operation whose output head
747/// names this sort. A closed sort enumerates the exhaustive set of
748/// constructor operations, enabling pattern matching via `Term::Case`
749/// and exhaustiveness checking at theory-declaration time.
750#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
751pub enum SortClosure {
752    /// Open sort: any operation with this output head may produce an
753    /// inhabitant. No exhaustiveness check applies.
754    #[default]
755    Open,
756    /// Closed sort: the listed op names form the complete set of
757    /// introduction forms. Pattern matches over this sort must cover
758    /// every listed constructor exactly once.
759    Closed(Vec<Arc<str>>),
760}
761
762/// A sort declaration in a GAT.
763///
764/// Sorts are the types of a GAT. They may be simple (no parameters)
765/// or dependent (parameterized by terms of other sorts). A sort may
766/// additionally be declared closed against an enumerated set of
767/// constructors, enabling exhaustive pattern matching.
768///
769/// # Examples
770///
771/// - Simple sort: `Vertex` (no params)
772/// - Dependent sort: `Hom(a: Ob, b: Ob)` (two params of sort `Ob`)
773/// - Dependent sort: `Constraint(v: Vertex)` (one param of sort `Vertex`)
774/// - Closed inductive sort: `Nat` closed against `[zero, succ]`
775///
776/// Based on the formal definition of GAT sorts from Cartmell (1986).
777#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
778pub struct Sort {
779    /// The sort name (e.g., "Vertex", "Edge", "Hom").
780    pub name: Arc<str>,
781    /// Parameters this sort depends on. Empty for simple sorts.
782    pub params: Vec<SortParam>,
783    /// The kind of this sort (structural, value, coercion, or merger).
784    #[serde(default)]
785    pub kind: SortKind,
786    /// Closure attribute. Defaults to [`SortClosure::Open`].
787    #[serde(default)]
788    pub closure: SortClosure,
789}
790
791impl Sort {
792    /// Create a simple (non-dependent) sort with structural kind and
793    /// open closure.
794    #[must_use]
795    pub fn simple(name: impl Into<Arc<str>>) -> Self {
796        Self {
797            name: name.into(),
798            params: Vec::new(),
799            kind: SortKind::default(),
800            closure: SortClosure::Open,
801        }
802    }
803
804    /// Create a dependent sort with the given parameters, structural
805    /// kind, and open closure.
806    #[must_use]
807    pub fn dependent(name: impl Into<Arc<str>>, params: Vec<SortParam>) -> Self {
808        Self {
809            name: name.into(),
810            params,
811            kind: SortKind::default(),
812            closure: SortClosure::Open,
813        }
814    }
815
816    /// Create a simple sort with a specific kind and open closure.
817    #[must_use]
818    pub fn with_kind(name: impl Into<Arc<str>>, kind: SortKind) -> Self {
819        Self {
820            name: name.into(),
821            params: Vec::new(),
822            kind,
823            closure: SortClosure::Open,
824        }
825    }
826
827    /// Create a closed simple sort with the given constructor op names.
828    ///
829    /// The closure declares these ops as the exhaustive set of
830    /// constructors for this sort. A `Term::Case` over the sort must
831    /// cover every listed constructor exactly once; adding a new
832    /// constructor elsewhere in the theory without updating the
833    /// closure fails `typecheck_theory`.
834    #[must_use]
835    pub fn closed<I, S>(name: impl Into<Arc<str>>, params: Vec<SortParam>, constructors: I) -> Self
836    where
837        I: IntoIterator<Item = S>,
838        S: Into<Arc<str>>,
839    {
840        Self {
841            name: name.into(),
842            params,
843            kind: SortKind::default(),
844            closure: SortClosure::Closed(constructors.into_iter().map(Into::into).collect()),
845        }
846    }
847
848    /// Returns the canonical schema-level vertex kind for this sort.
849    ///
850    /// The vertex kind is derived from the `SortKind` via the Grothendieck
851    /// fibration: `Val(vk)` maps to the canonical value kind name,
852    /// `Structural` maps to the sort name itself.
853    #[must_use]
854    pub fn default_vertex_kind(&self) -> Arc<str> {
855        match &self.kind {
856            SortKind::Val(vk) => Arc::from(vk.as_str()),
857            SortKind::Structural | SortKind::Coercion { .. } | SortKind::Merger(_) => {
858                Arc::clone(&self.name)
859            }
860        }
861    }
862
863    /// Returns `true` if this sort has no parameters.
864    #[must_use]
865    pub fn is_simple(&self) -> bool {
866        self.params.is_empty()
867    }
868
869    /// Returns the arity (number of parameters) of this sort.
870    #[must_use]
871    pub fn arity(&self) -> usize {
872        self.params.len()
873    }
874}
875
876impl SortParam {
877    /// Create a new sort parameter.
878    ///
879    /// The `sort` argument may be any value that converts to a
880    /// [`SortExpr`], including `&str`, `String`, `Arc<str>`, or a
881    /// fully-specified `SortExpr::App`.
882    #[must_use]
883    pub fn new(name: impl Into<Arc<str>>, sort: impl Into<SortExpr>) -> Self {
884        Self {
885            name: name.into(),
886            sort: sort.into(),
887        }
888    }
889}
890
891#[cfg(test)]
892mod tests {
893    use super::*;
894
895    #[test]
896    fn simple_sort() {
897        let s = Sort::simple("Vertex");
898        assert!(s.is_simple());
899        assert_eq!(s.arity(), 0);
900        assert_eq!(&*s.name, "Vertex");
901    }
902
903    #[test]
904    fn dependent_sort() {
905        let s = Sort::dependent(
906            "Hom",
907            vec![SortParam::new("a", "Ob"), SortParam::new("b", "Ob")],
908        );
909        assert!(!s.is_simple());
910        assert_eq!(s.arity(), 2);
911    }
912
913    // --- SortExpr tests ---
914
915    #[test]
916    fn sort_expr_from_str() {
917        let e: SortExpr = "Ob".into();
918        assert_eq!(e, SortExpr::Name(Arc::from("Ob")));
919        assert_eq!(&**e.head(), "Ob");
920    }
921
922    #[test]
923    fn sort_expr_app_head() {
924        let e = SortExpr::App {
925            name: Arc::from("Hom"),
926            args: vec![Term::var("x"), Term::var("y")],
927        };
928        assert_eq!(&**e.head(), "Hom");
929        assert_eq!(e.args().len(), 2);
930    }
931
932    #[test]
933    fn sort_expr_alpha_eq_name_vs_empty_app() {
934        let a = SortExpr::Name(Arc::from("Ob"));
935        let b = SortExpr::App {
936            name: Arc::from("Ob"),
937            args: Vec::new(),
938        };
939        assert!(a.alpha_eq(&b));
940        assert!(b.alpha_eq(&a));
941    }
942
943    #[test]
944    fn sort_expr_alpha_eq_structural() {
945        let a = SortExpr::App {
946            name: Arc::from("Hom"),
947            args: vec![Term::var("x"), Term::var("y")],
948        };
949        let b = SortExpr::App {
950            name: Arc::from("Hom"),
951            args: vec![Term::var("x"), Term::var("y")],
952        };
953        let c = SortExpr::App {
954            name: Arc::from("Hom"),
955            args: vec![Term::var("y"), Term::var("x")],
956        };
957        assert!(a.alpha_eq(&b));
958        assert!(!a.alpha_eq(&c));
959    }
960
961    #[test]
962    fn sort_expr_subst() {
963        let e = SortExpr::App {
964            name: Arc::from("Hom"),
965            args: vec![Term::var("x"), Term::var("y")],
966        };
967        let mut mapping: FxHashMap<Arc<str>, Term> = FxHashMap::default();
968        mapping.insert(Arc::from("x"), Term::constant("a"));
969        mapping.insert(Arc::from("y"), Term::constant("b"));
970        let result = e.subst(&mapping);
971        assert_eq!(
972            result,
973            SortExpr::App {
974                name: Arc::from("Hom"),
975                args: vec![Term::constant("a"), Term::constant("b")],
976            }
977        );
978    }
979
980    #[test]
981    fn sort_expr_subst_name_unchanged() {
982        let e = SortExpr::Name(Arc::from("Ob"));
983        let mut mapping: FxHashMap<Arc<str>, Term> = FxHashMap::default();
984        mapping.insert(Arc::from("x"), Term::constant("a"));
985        assert_eq!(e.subst(&mapping), e);
986    }
987
988    #[test]
989    fn sort_expr_serde_name_is_bare_string() -> Result<(), Box<dyn std::error::Error>> {
990        let e = SortExpr::Name(Arc::from("Ob"));
991        let s = serde_json::to_string(&e)?;
992        assert_eq!(s, "\"Ob\"");
993        let back: SortExpr = serde_json::from_str(&s)?;
994        assert_eq!(back, e);
995        Ok(())
996    }
997
998    #[test]
999    fn sort_expr_serde_app_is_struct() -> Result<(), Box<dyn std::error::Error>> {
1000        let e = SortExpr::App {
1001            name: Arc::from("Hom"),
1002            args: vec![Term::var("x"), Term::var("y")],
1003        };
1004        let s = serde_json::to_string(&e)?;
1005        let back: SortExpr = serde_json::from_str(&s)?;
1006        assert_eq!(back, e);
1007        Ok(())
1008    }
1009
1010    #[test]
1011    fn sort_expr_display() {
1012        let e = SortExpr::App {
1013            name: Arc::from("Hom"),
1014            args: vec![Term::var("x"), Term::var("y")],
1015        };
1016        assert_eq!(format!("{e}"), "Hom(x, y)");
1017        let n = SortExpr::Name(Arc::from("Ob"));
1018        assert_eq!(format!("{n}"), "Ob");
1019    }
1020
1021    // --- CoercionClass algebraic law tests ---
1022
1023    const ALL_CLASSES: [CoercionClass; 4] = [
1024        CoercionClass::Iso,
1025        CoercionClass::Retraction,
1026        CoercionClass::Projection,
1027        CoercionClass::Opaque,
1028    ];
1029
1030    #[test]
1031    fn coercion_class_identity() {
1032        for &x in &ALL_CLASSES {
1033            assert_eq!(CoercionClass::Iso.compose(x), x, "Iso is left identity");
1034            assert_eq!(x.compose(CoercionClass::Iso), x, "Iso is right identity");
1035        }
1036    }
1037
1038    #[test]
1039    fn coercion_class_absorption() {
1040        for &x in &ALL_CLASSES {
1041            assert_eq!(
1042                CoercionClass::Opaque.compose(x),
1043                CoercionClass::Opaque,
1044                "Opaque absorbs on left"
1045            );
1046            assert_eq!(
1047                x.compose(CoercionClass::Opaque),
1048                CoercionClass::Opaque,
1049                "Opaque absorbs on right"
1050            );
1051        }
1052    }
1053
1054    #[test]
1055    fn coercion_class_associativity() {
1056        for &a in &ALL_CLASSES {
1057            for &b in &ALL_CLASSES {
1058                for &c in &ALL_CLASSES {
1059                    assert_eq!(
1060                        a.compose(b).compose(c),
1061                        a.compose(b.compose(c)),
1062                        "associativity: ({a:?} . {b:?}) . {c:?} == {a:?} . ({b:?} . {c:?})"
1063                    );
1064                }
1065            }
1066        }
1067    }
1068
1069    #[test]
1070    fn coercion_class_commutativity() {
1071        for &a in &ALL_CLASSES {
1072            for &b in &ALL_CLASSES {
1073                assert_eq!(
1074                    a.compose(b),
1075                    b.compose(a),
1076                    "commutativity: {a:?} . {b:?} == {b:?} . {a:?}"
1077                );
1078            }
1079        }
1080    }
1081
1082    #[test]
1083    fn coercion_class_ordering_consistent_with_compose() {
1084        for &a in &ALL_CLASSES {
1085            for &b in &ALL_CLASSES {
1086                let composed = a.compose(b);
1087                assert!(
1088                    composed >= a,
1089                    "compose({a:?}, {b:?}) = {composed:?} should be >= {a:?}"
1090                );
1091                assert!(
1092                    composed >= b,
1093                    "compose({a:?}, {b:?}) = {composed:?} should be >= {b:?}"
1094                );
1095            }
1096        }
1097    }
1098
1099    #[test]
1100    fn classify_builtin_coercion_coverage() {
1101        use panproto_expr::BuiltinOp;
1102
1103        // Every coercion builtin is classified.
1104        let coercion_ops = [
1105            BuiltinOp::IntToFloat,
1106            BuiltinOp::FloatToInt,
1107            BuiltinOp::IntToStr,
1108            BuiltinOp::FloatToStr,
1109            BuiltinOp::StrToInt,
1110            BuiltinOp::StrToFloat,
1111        ];
1112        for op in coercion_ops {
1113            assert!(
1114                classify_builtin_coercion(op).is_some(),
1115                "{op:?} should be classified"
1116            );
1117        }
1118
1119        // Non-coercion builtins are not classified.
1120        assert!(classify_builtin_coercion(BuiltinOp::Add).is_none());
1121        assert!(classify_builtin_coercion(BuiltinOp::Concat).is_none());
1122    }
1123
1124    #[test]
1125    fn no_builtin_classified_as_iso() {
1126        use panproto_expr::BuiltinOp;
1127
1128        // No builtin coercion should be Iso (all have failure modes or precision loss).
1129        let coercion_ops = [
1130            BuiltinOp::IntToFloat,
1131            BuiltinOp::FloatToInt,
1132            BuiltinOp::IntToStr,
1133            BuiltinOp::FloatToStr,
1134            BuiltinOp::StrToInt,
1135            BuiltinOp::StrToFloat,
1136        ];
1137        for op in coercion_ops {
1138            if let Some((_, _, class)) = classify_builtin_coercion(op) {
1139                assert_ne!(
1140                    class,
1141                    CoercionClass::Iso,
1142                    "{op:?} should not be classified as Iso"
1143                );
1144            }
1145        }
1146    }
1147
1148    #[test]
1149    fn needs_complement_storage_consistent_with_lattice() {
1150        // Iso and Projection have empty complement; Retraction and Opaque store data.
1151        // This matches the diamond lattice: the "upper" pair (Retraction, Opaque)
1152        // requires storage; the "lower" pair (Iso) and "re-derivable" (Projection)
1153        // do not.
1154        assert!(
1155            !CoercionClass::Iso.needs_complement_storage(),
1156            "Iso: lossless, no storage"
1157        );
1158        assert!(
1159            CoercionClass::Retraction.needs_complement_storage(),
1160            "Retraction: stores residual"
1161        );
1162        assert!(
1163            !CoercionClass::Projection.needs_complement_storage(),
1164            "Projection: derived value re-computed, no storage"
1165        );
1166        assert!(
1167            CoercionClass::Opaque.needs_complement_storage(),
1168            "Opaque: stores entire original"
1169        );
1170    }
1171
1172    #[test]
1173    fn projection_compose_laws() {
1174        // Projection-specific composition laws that verify the diamond
1175        // lattice structure.
1176
1177        // Projection is idempotent.
1178        assert_eq!(
1179            CoercionClass::Projection.compose(CoercionClass::Projection),
1180            CoercionClass::Projection,
1181            "Projection . Projection = Projection (projections compose)"
1182        );
1183
1184        // Cross-kind composition collapses to Opaque: a retraction
1185        // (left inverse) composed with a projection (no inverse) has
1186        // neither property.
1187        assert_eq!(
1188            CoercionClass::Retraction.compose(CoercionClass::Projection),
1189            CoercionClass::Opaque,
1190            "Retraction . Projection = Opaque (diamond lattice meet)"
1191        );
1192        assert_eq!(
1193            CoercionClass::Projection.compose(CoercionClass::Retraction),
1194            CoercionClass::Opaque,
1195            "Projection . Retraction = Opaque (commutativity of meet)"
1196        );
1197
1198        // Iso is identity for Projection.
1199        assert_eq!(
1200            CoercionClass::Iso.compose(CoercionClass::Projection),
1201            CoercionClass::Projection,
1202        );
1203
1204        // Opaque absorbs Projection.
1205        assert_eq!(
1206            CoercionClass::Opaque.compose(CoercionClass::Projection),
1207            CoercionClass::Opaque,
1208        );
1209    }
1210
1211    // --- A1 normalization / Eq-Hash consistency tests ---
1212
1213    #[test]
1214    fn empty_args_app_normalizes_to_name() {
1215        let raw = SortExpr::App {
1216            name: Arc::from("Ob"),
1217            args: Vec::new(),
1218        };
1219        let n = raw.normalize();
1220        assert!(matches!(n, SortExpr::Name(ref s) if &**s == "Ob"));
1221        // Normalization is idempotent.
1222        assert_eq!(n.clone().normalize(), n);
1223    }
1224
1225    #[test]
1226    fn smart_constructor_collapses_empty_args() {
1227        let v = SortExpr::app("Ob", Vec::new());
1228        assert!(matches!(v, SortExpr::Name(_)));
1229    }
1230
1231    #[test]
1232    fn smart_constructor_preserves_nonempty() {
1233        let v = SortExpr::app("Hom", vec![Term::var("x"), Term::var("y")]);
1234        assert!(matches!(v, SortExpr::App { .. }));
1235    }
1236
1237    #[test]
1238    fn eq_treats_name_and_empty_app_equal() {
1239        let a = SortExpr::Name(Arc::from("Ob"));
1240        let b = SortExpr::App {
1241            name: Arc::from("Ob"),
1242            args: Vec::new(),
1243        };
1244        assert_eq!(a, b);
1245    }
1246
1247    #[test]
1248    fn hash_agrees_with_eq_across_spellings() {
1249        use std::collections::hash_map::DefaultHasher;
1250        use std::hash::{Hash, Hasher};
1251
1252        let a = SortExpr::Name(Arc::from("Ob"));
1253        let b = SortExpr::App {
1254            name: Arc::from("Ob"),
1255            args: Vec::new(),
1256        };
1257        let hash = |v: &SortExpr| {
1258            let mut h = DefaultHasher::new();
1259            v.hash(&mut h);
1260            h.finish()
1261        };
1262        assert_eq!(hash(&a), hash(&b));
1263    }
1264
1265    #[test]
1266    fn hashmap_lookup_crosses_spellings() {
1267        let mut m: FxHashMap<SortExpr, usize> = FxHashMap::default();
1268        m.insert(SortExpr::Name(Arc::from("Ob")), 1);
1269        let key = SortExpr::App {
1270            name: Arc::from("Ob"),
1271            args: Vec::new(),
1272        };
1273        assert_eq!(m.get(&key).copied(), Some(1));
1274    }
1275
1276    #[test]
1277    fn subst_produces_normalized_output() {
1278        let e = SortExpr::App {
1279            name: Arc::from("S"),
1280            args: vec![Term::var("x")],
1281        };
1282        let mut mapping: FxHashMap<Arc<str>, Term> = FxHashMap::default();
1283        // Substitute x with a term that reduces args count? No, subst
1284        // replaces var-by-term; args count stays. Normalization matters
1285        // most at *construction* time and for the empty-args App case.
1286        mapping.insert(Arc::from("x"), Term::constant("c"));
1287        let r = e.subst(&mapping);
1288        assert!(matches!(r, SortExpr::App { .. }));
1289    }
1290
1291    #[test]
1292    fn rename_head_normalizes_empty_app() {
1293        let e = SortExpr::App {
1294            name: Arc::from("Ob"),
1295            args: Vec::new(),
1296        };
1297        let mut sm: std::collections::HashMap<Arc<str>, Arc<str>> =
1298            std::collections::HashMap::new();
1299        sm.insert(Arc::from("Ob"), Arc::from("Obj"));
1300        let r = e.rename_head(&sm);
1301        assert!(matches!(r, SortExpr::Name(ref n) if &**n == "Obj"));
1302    }
1303
1304    #[test]
1305    fn deserialize_empty_args_app_normalizes() -> Result<(), Box<dyn std::error::Error>> {
1306        let json = r#"{"name":"Ob","args":[]}"#;
1307        let v: SortExpr = serde_json::from_str(json)?;
1308        assert!(matches!(v, SortExpr::Name(ref n) if &**n == "Ob"));
1309        Ok(())
1310    }
1311
1312    // --- positional_param_rename / signatures_equivalent_modulo_param_rename ---
1313
1314    #[test]
1315    fn positional_rename_identity_is_empty() {
1316        let r = positional_param_rename(
1317            [Arc::from("a"), Arc::from("b")],
1318            [Arc::from("a"), Arc::from("b")],
1319        );
1320        assert!(r.is_empty(), "identity rename should be empty");
1321    }
1322
1323    #[test]
1324    fn positional_rename_maps_differing_names_only() {
1325        let r = positional_param_rename(
1326            [Arc::from("a"), Arc::from("y"), Arc::from("c")],
1327            [Arc::from("x"), Arc::from("y"), Arc::from("z")],
1328        );
1329        assert_eq!(r.len(), 2);
1330        assert_eq!(r.get(&Arc::from("a")), Some(&Term::var("x")));
1331        assert_eq!(r.get(&Arc::from("c")), Some(&Term::var("z")));
1332        assert!(!r.contains_key(&Arc::from("y")));
1333    }
1334
1335    #[test]
1336    fn signature_equivalence_accepts_alpha_variant() {
1337        use crate::op::Implicit;
1338        // (a : Ob) -> Hom(a, a) vs (x : Ob) -> Hom(x, x)
1339        let lhs_inputs = vec![(Arc::from("a"), SortExpr::from("Ob"), Implicit::No)];
1340        let lhs_output = SortExpr::App {
1341            name: Arc::from("Hom"),
1342            args: vec![Term::var("a"), Term::var("a")],
1343        };
1344        let rhs_inputs = vec![(Arc::from("x"), SortExpr::from("Ob"), Implicit::No)];
1345        let rhs_output = SortExpr::App {
1346            name: Arc::from("Hom"),
1347            args: vec![Term::var("x"), Term::var("x")],
1348        };
1349        assert!(signatures_equivalent_modulo_param_rename(
1350            &lhs_inputs,
1351            &lhs_output,
1352            &rhs_inputs,
1353            &rhs_output,
1354        ));
1355    }
1356
1357    #[test]
1358    fn signature_equivalence_rejects_swap() {
1359        use crate::op::Implicit;
1360        // (x, y : Ob) -> Hom(x, y) vs (x, y : Ob) -> Hom(y, x)
1361        let hom = |a: &str, b: &str| SortExpr::App {
1362            name: Arc::from("Hom"),
1363            args: vec![Term::var(a), Term::var(b)],
1364        };
1365        let lhs_inputs = vec![
1366            (Arc::from("x"), SortExpr::from("Ob"), Implicit::No),
1367            (Arc::from("y"), SortExpr::from("Ob"), Implicit::No),
1368        ];
1369        let rhs_inputs = lhs_inputs.clone();
1370        assert!(!signatures_equivalent_modulo_param_rename(
1371            &lhs_inputs,
1372            &hom("x", "y"),
1373            &rhs_inputs,
1374            &hom("y", "x"),
1375        ));
1376    }
1377
1378    #[test]
1379    fn signature_equivalence_rejects_arity_mismatch() {
1380        use crate::op::Implicit;
1381        let lhs_inputs = vec![(Arc::from("x"), SortExpr::from("Ob"), Implicit::No)];
1382        let rhs_inputs: Vec<(Arc<str>, SortExpr, Implicit)> = Vec::new();
1383        assert!(!signatures_equivalent_modulo_param_rename(
1384            &lhs_inputs,
1385            &SortExpr::from("Ob"),
1386            &rhs_inputs,
1387            &SortExpr::from("Ob"),
1388        ));
1389    }
1390
1391    #[test]
1392    fn sort_params_rename_alpha_equivalent() {
1393        // (a : Ob, b : Ob) vs (p : Ob, q : Ob)
1394        let lhs = vec![SortParam::new("a", "Ob"), SortParam::new("b", "Ob")];
1395        let rhs = vec![SortParam::new("p", "Ob"), SortParam::new("q", "Ob")];
1396        assert!(sort_params_equivalent_modulo_rename(&lhs, &rhs));
1397    }
1398
1399    #[test]
1400    fn sort_params_rename_detects_dependent_difference() {
1401        // (Γ : Ctx, A : Ty(Γ)) vs (G : Ctx, A : Ty(G)) -- rename should succeed.
1402        let lhs = vec![
1403            SortParam::new("Gamma", "Ctx"),
1404            SortParam::new(
1405                "A",
1406                SortExpr::App {
1407                    name: Arc::from("Ty"),
1408                    args: vec![Term::var("Gamma")],
1409                },
1410            ),
1411        ];
1412        let rhs = vec![
1413            SortParam::new("G", "Ctx"),
1414            SortParam::new(
1415                "A",
1416                SortExpr::App {
1417                    name: Arc::from("Ty"),
1418                    args: vec![Term::var("G")],
1419                },
1420            ),
1421        ];
1422        assert!(sort_params_equivalent_modulo_rename(&lhs, &rhs));
1423    }
1424
1425    #[test]
1426    fn sort_params_rename_rejects_genuine_difference() {
1427        // (Γ : Ctx, A : Ty(Γ)) vs (G : Ctx, A : Ty(A)) -- second param's
1428        // inner var refers to itself, not to the first param. Not an alpha
1429        // variant.
1430        let lhs = vec![
1431            SortParam::new("Gamma", "Ctx"),
1432            SortParam::new(
1433                "A",
1434                SortExpr::App {
1435                    name: Arc::from("Ty"),
1436                    args: vec![Term::var("Gamma")],
1437                },
1438            ),
1439        ];
1440        let rhs = vec![
1441            SortParam::new("G", "Ctx"),
1442            SortParam::new(
1443                "A",
1444                SortExpr::App {
1445                    name: Arc::from("Ty"),
1446                    args: vec![Term::var("A")],
1447                },
1448            ),
1449        ];
1450        assert!(!sort_params_equivalent_modulo_rename(&lhs, &rhs));
1451    }
1452
1453    mod property {
1454        use super::*;
1455        use proptest::prelude::*;
1456
1457        fn arb_name() -> impl Strategy<Value = Arc<str>> {
1458            prop::sample::select(&["S", "T", "Hom", "Tm", "Ob"][..]).prop_map(Arc::from)
1459        }
1460
1461        fn arb_term(depth: usize) -> BoxedStrategy<Term> {
1462            if depth == 0 {
1463                prop::sample::select(&["x", "y", "z"][..])
1464                    .prop_map(|s| Term::var(Arc::from(s)))
1465                    .boxed()
1466            } else {
1467                let leaf = prop::sample::select(&["x", "y", "z"][..])
1468                    .prop_map(|s| Term::var(Arc::from(s)));
1469                let app = (
1470                    prop::sample::select(&["f", "g"][..]).prop_map(Arc::from),
1471                    prop::collection::vec(arb_term(depth - 1), 0..=2),
1472                )
1473                    .prop_map(|(op, args)| Term::App { op, args });
1474                prop_oneof![leaf, app].boxed()
1475            }
1476        }
1477
1478        fn arb_sort_expr() -> BoxedStrategy<SortExpr> {
1479            prop_oneof![
1480                arb_name().prop_map(SortExpr::Name),
1481                (arb_name(), prop::collection::vec(arb_term(1), 0..=3))
1482                    .prop_map(|(name, args)| SortExpr::app(name, args))
1483            ]
1484            .boxed()
1485        }
1486
1487        fn arb_subst() -> BoxedStrategy<FxHashMap<Arc<str>, Term>> {
1488            prop::collection::vec(
1489                (
1490                    prop::sample::select(&["x", "y", "z"][..]).prop_map(Arc::from),
1491                    arb_term(1),
1492                ),
1493                0..=3,
1494            )
1495            .prop_map(|pairs| {
1496                let mut m = FxHashMap::default();
1497                for (k, v) in pairs {
1498                    m.insert(k, v);
1499                }
1500                m
1501            })
1502            .boxed()
1503        }
1504
1505        proptest! {
1506            #![proptest_config(ProptestConfig::with_cases(256))]
1507
1508            #[test]
1509            fn subst_empty_is_identity(e in arb_sort_expr()) {
1510                let empty = FxHashMap::default();
1511                prop_assert_eq!(e.subst(&empty), e);
1512            }
1513
1514            #[test]
1515            fn subst_preserves_head(e in arb_sort_expr(), sigma in arb_subst()) {
1516                let after = e.subst(&sigma);
1517                prop_assert_eq!(e.head(), after.head());
1518            }
1519
1520            #[test]
1521            fn normalization_is_idempotent(e in arb_sort_expr()) {
1522                let n1 = e.normalize();
1523                let n2 = n1.clone().normalize();
1524                prop_assert_eq!(n1, n2);
1525            }
1526
1527            #[test]
1528            fn sig_equivalence_is_reflexive(
1529                raw_inputs in prop::collection::vec(
1530                    (prop::sample::select(&["x", "y", "z"][..]).prop_map(Arc::from), arb_sort_expr()),
1531                    0..=3,
1532                ),
1533                output in arb_sort_expr(),
1534            ) {
1535                let inputs: Vec<(Arc<str>, SortExpr, crate::op::Implicit)> = raw_inputs
1536                    .into_iter()
1537                    .map(|(n, s)| (n, s, crate::op::Implicit::No))
1538                    .collect();
1539                prop_assert!(signatures_equivalent_modulo_param_rename(
1540                    &inputs, &output, &inputs, &output,
1541                ));
1542            }
1543
1544            #[test]
1545            fn sig_equivalence_under_alpha_rename(
1546                sort_name in arb_name(),
1547                first in prop::sample::select(&["x", "y", "z"][..]).prop_map(Arc::from),
1548                replacement in prop::sample::select(&["p", "q", "r"][..]).prop_map(Arc::from),
1549            ) {
1550                // Build signature `(first : sort_name) -> App { sort_name, [first] }`
1551                // and its alpha variant `(replacement : sort_name) -> App { sort_name, [replacement] }`.
1552                // Both should be signature-equivalent under the positional rename.
1553                let lhs_inputs: Vec<(Arc<str>, SortExpr, crate::op::Implicit)> = vec![(
1554                    Arc::clone(&first),
1555                    SortExpr::Name(Arc::clone(&sort_name)),
1556                    crate::op::Implicit::No,
1557                )];
1558                let lhs_output = SortExpr::App {
1559                    name: Arc::clone(&sort_name),
1560                    args: vec![Term::Var(Arc::clone(&first))],
1561                };
1562                let rhs_inputs: Vec<(Arc<str>, SortExpr, crate::op::Implicit)> = vec![(
1563                    Arc::clone(&replacement),
1564                    SortExpr::Name(Arc::clone(&sort_name)),
1565                    crate::op::Implicit::No,
1566                )];
1567                let rhs_output = SortExpr::App {
1568                    name: sort_name,
1569                    args: vec![Term::Var(replacement)],
1570                };
1571                prop_assert!(signatures_equivalent_modulo_param_rename(
1572                    &lhs_inputs, &lhs_output, &rhs_inputs, &rhs_output,
1573                ));
1574            }
1575
1576            #[test]
1577            fn name_and_empty_app_hash_equal(name in arb_name()) {
1578                use std::collections::hash_map::DefaultHasher;
1579                use std::hash::{Hash, Hasher};
1580                let a = SortExpr::Name(Arc::clone(&name));
1581                let b = SortExpr::App { name, args: Vec::new() };
1582                let mut ha = DefaultHasher::new();
1583                a.hash(&mut ha);
1584                let mut hb = DefaultHasher::new();
1585                b.hash(&mut hb);
1586                prop_assert_eq!(ha.finish(), hb.finish());
1587                prop_assert_eq!(a, b);
1588            }
1589        }
1590    }
1591
1592    #[test]
1593    fn coercion_class_serde_wire_format_is_pascal_case() {
1594        // `CoercionClass` uses the default serde representation (no
1595        // `rename_all` attribute). The TypeScript `CoercionClass` and
1596        // Python string decoders MUST match this exactly; if anyone ever
1597        // adds `#[serde(rename_all = ...)]` to the enum, every SDK
1598        // consumer would silently start seeing a different string and
1599        // drop the field. This test locks the wire format.
1600        let cases = [
1601            (CoercionClass::Iso, "\"Iso\""),
1602            (CoercionClass::Retraction, "\"Retraction\""),
1603            (CoercionClass::Projection, "\"Projection\""),
1604            (CoercionClass::Opaque, "\"Opaque\""),
1605        ];
1606        for (value, expected) in cases {
1607            match serde_json::to_string(&value) {
1608                Ok(s) => assert_eq!(s, expected, "unexpected wire format"),
1609                Err(e) => panic!("serde failed to serialize a plain enum: {e}"),
1610            }
1611        }
1612    }
1613}