Skip to main content

brink_analyzer/infer/
ty.rs

1//! The type universe (typed-mode-spec §2/§4) and its unification rule.
2//!
3//! `Ty` is deliberately small: `int`, `float`, `bool`, `string`, `divert`,
4//! nominal `List<L>`, `Array<T>`, `Map<K, V>`, nominal structs (TM-4),
5//! structural `fn(T…): R` function-value types (T1c), plus `Unknown` — no
6//! unions. v1 is monomorphic and
7//! unification-free of overloading/typeclasses, so the constraint lattice is
8//! finite and every join terminates in O(depth) — see spec §2's "constraint
9//! solving stays near-linear" ruling.
10//!
11//! ## Why `join`, not a union-find unifier
12//!
13//! A textbook HM implementation threads fresh unification variables through
14//! a union-find structure so a variable can be equated with another variable
15//! before either resolves to a concrete type. That machinery earns its keep
16//! when the type language has polymorphism (a variable can specialize
17//! differently at different call sites). Spec §2 rules that out for v1: "user
18//! code is monomorphic ... every unification variable must resolve to a
19//! concrete type per definition. No overloading, no typeclasses." With
20//! exactly one openness axis (`Unknown`, which behaves as a bottom/identity
21//! element) and no variable-to-variable equating ever required, a
22//! monotonically-growing accumulator — start every local at `Unknown`, `join`
23//! in the type implied by each use — reaches the same fixpoint a union-find
24//! unifier would, without the extra bookkeeping. [`unify`] is that join.
25
26use std::cmp::Ordering;
27use std::collections::BTreeSet;
28
29use brink_format::DefinitionId;
30
31/// A type in the checker's universe (typed-mode-spec §2).
32#[derive(Debug, Clone, PartialEq, Eq, Hash)]
33pub enum Ty {
34    Int,
35    Float,
36    Bool,
37    String,
38    /// A first-class content value (issue #1846, `docs/prose-dialect-spec.md`
39    /// §3.5b's capture contract): the type of a `content`-typed parameter,
40    /// e.g. `fn radio(chan: string, text: content)`. Backed by the existing
41    /// fragment-capture path (`BeginFragment`…`EndFragment` →
42    /// `Value::FragmentRef`, `brink_format::Opcode`) — the same machinery an
43    /// ordinary call embedded in display position already composes through
44    /// (`brink-codegen-inkb::content::emit_slot_expr`). Deliberately a
45    /// distinct nominal leaf, **not** unified or coerced with [`Ty::String`]:
46    /// the whole reason `content` exists is that a captured prose run stays
47    /// translation-resident and measurable through the normal line path
48    /// (`docs/decision-log.md` 2026-07-31 ruling) — silently flattening it to
49    /// a plain string would defeat that. This variant is the type-resolution
50    /// prerequisite only; the dispatch mechanism that actually binds a
51    /// captured run to a `content` param (`@[element(args = "…", block)]`'s
52    /// block capture) is issue #1839's scope, not this one's.
53    Content,
54    /// A divert target value (`-> knot` used as a value, not a jump).
55    Divert,
56    /// A LIST value, nominal per the declaring `LIST` name (spec §2: "nominal
57    /// per LIST declaration").
58    List(String),
59    /// `#[...]` array sigil literal element type.
60    Array(Box<Ty>),
61    /// `#{...}` map sigil literal key/value types.
62    Map(Box<Ty>, Box<Ty>),
63    /// A struct value, nominal per the declaring `STRUCT` name (TM-4b,
64    /// docs/typed-mode-spec.md §6 — mirrors `List`'s "nominal per LIST
65    /// declaration" precedent). Carries only the shape name in this slice —
66    /// no field-type table is threaded through inference yet (TM-4c/codegen
67    /// concern); a struct-typed slot is still *concrete* for E065/E066
68    /// escape-checking purposes (`brink-analyzer::strict::classify`).
69    Struct(String),
70    /// A function value's type, `fn(T…): R` (T1c, docs/t1c-spec.md §4;
71    /// typed-mode-spec §3 reserved the written form). The param row is
72    /// **val-only by construction**: every `ref` param of the target is
73    /// bound away at `#fn` creation (all refs must bind in the prefix,
74    /// E080), so the type form carries no modes — a `Ty::Fn` row describes
75    /// exactly the *remaining* (unbound) params a call through the value
76    /// must supply.
77    ///
78    /// The third component is the **effect row riding the type**
79    /// (`docs/effects-spec.md` §5, issue #1680 step 3) — see [`FnRow`].
80    /// It is joined by [`unify`] alongside the params and the return, and
81    /// is deliberately **not** part of assignability: see [`assignable`].
82    Fn(Vec<Ty>, Box<Ty>, FnRow),
83    /// A host-resource handle value, nominal per its manifest-declared kind
84    /// name (T1d-2, docs/t1d-spec.md §3: `Handle<K>` — mirrors `List`'s
85    /// "nominal per LIST declaration" precedent, but the vocabulary lives in
86    /// the external manifest, not ink source). Two handle types unify only
87    /// when the kind names match exactly; a cross-kind pair is a genuine
88    /// structural mismatch and joins to `Conflicted` (#627 lattice) — a
89    /// binding declared `Handle<AudioInstance>` and one declared
90    /// `Handle<Timer>` are as incompatible as `int` and `string`.
91    Handle(String),
92    /// `Option<T>` — the third compiler-known parameterized builtin
93    /// (NS-A1, `docs/stdlib-spec.md` §1.4, ruled 2026-07-18), joining
94    /// `Array`/`Map` in the static type language. A compiler-owned enum
95    /// shape (`none` / `some(T)`), NOT user generics (#1090's door stays
96    /// shut). Unifies pointwise on the element like `Array`; against any
97    /// non-Option concrete type it is a genuine structural mismatch —
98    /// the ruled `Option<T> ≠ T` strictness IS the `Conflicted` join
99    /// (display-boundary forgiveness is Track B4 and deliberately absent
100    /// from this lattice).
101    Option(Box<Ty>),
102    /// An integer range value (NS-A5, `docs/stdlib-spec.md` §7 — F7 ruled
103    /// 2026-07-19: ranges are a real Value kind). The `non_empty` flag is
104    /// the language's **first value refinement**: `true` is checker-minted
105    /// EVIDENCE that the range denotes at least one element — a refined
106    /// *view* over the same runtime value, never a second value kind. The
107    /// checker mints it in exactly two places (the closed-refinement
108    /// doctrine): a range literal with provably-inhabited bounds, and the
109    /// `some` payload of `non_empty(r)`. Under gradual typing the flag is
110    /// inert (F8 — the runtime fault is the residual); under `types =
111    /// strict` `rand::int` demands it (E117).
112    Range {
113        /// Checker-minted inhabitedness evidence (`NonEmptyRange`, the S2
114        /// spelling). Joins with `&&` in `unify`: evidence survives only
115        /// if EVERY observed source carries it.
116        non_empty: bool,
117    },
118    /// Not (yet) resolved to a concrete type — legal in this slice (spec
119    /// `Weighted<T>` — the weighted table builtin (NS-A7,
120    /// `docs/stdlib-spec.md` §8): compiler-known and parameterized like
121    /// `Option`/`Array`, NOT user generics. Unifies pointwise on the value
122    /// element; against any other concrete type it is a genuine structural
123    /// mismatch (`Conflicted`). v1 is construct-and-roll — the only
124    /// producer is the `weighted(…)` intrinsic, the only consumer `roll`.
125    Weighted(Box<Ty>),
126    /// A numeric-tower value kind (NS-A8, `docs/tower-mini-spec.md`):
127    /// `vec2`/`vec3`/`vec4`/`quat`/`mat2`/`mat3`/`mat4` — seven closed
128    /// compiler-known kinds carried by one variant (they behave identically
129    /// in the lattice: nominal scalar-like leaves; no coercion into or out
130    /// of them, so a tower-vs-anything-else join is `Conflicted`).
131    Tower(TowerTy),
132    /// §2: "unresolved -> Unknown, which is LEGAL"). Acts as the join
133    /// identity: `unify(Unknown, x) == x`.
134    Unknown,
135    /// A genuine, irreconcilable type conflict was observed for this slot
136    /// (e.g. used as both `int` and `string`) — #627 ruling. A distinct
137    /// absorbing lattice point, *not* a synonym for `Unknown`:
138    /// `unify(Conflicted, x) == Conflicted` for every `x` (including
139    /// `Unknown`), so a conflict can never silently "heal" back to a
140    /// concrete type depending on the order observations arrive in. Gradual
141    /// mode (every consumer today) treats it exactly like `Unknown` —
142    /// strict mode's TM-3 (#619) is the slice that reports it as a
143    /// diagnostic; this lattice point only exists so that reporting can be
144    /// order-independent when it lands.
145    Conflicted,
146}
147
148/// The effect row riding a [`Ty::Fn`] — `docs/effects-spec.md` §5 ("rows ride
149/// the unifier — the heap answer"), issue #1680 step 3.
150///
151/// **What a row is, concretely.** §7 rules that *"the runtime never computes a
152/// row … a live fn value is a token; its row is a **table lookup**"* in the
153/// shipped `DefinitionId → row` table. So the thing a *type* has to carry is
154/// not a computed [`EffectRow`](super::EffectRow) but the **set of in-project
155/// creation targets** whose fn values may inhabit the slot — the keys that
156/// table is looked up by. §6.1a is what makes that the right (and the only
157/// acyclic) choice: *"§6.1 fixes every fn value's row at its creation site and
158/// creation sites are **syntactic**: `#fn(g)` names `g` literally, and `bind`
159/// copies from an already-known value rather than naming a new target."* A
160/// target set is therefore structural evidence, never an inferred row, so
161/// growing it onto `Ty` cannot put an inferred row inside the
162/// `call_graph → scc_membership → solve_scc` fixpoint the way Fork A's
163/// rejected shape would have.
164///
165/// **The lattice.** `unknown` is the top element (the conservative-total floor
166/// of §3): *"some fn value from a source this slot's type cannot name may
167/// reach here"*. [`FnRow::join`] is set union with `unknown` absorbing, which
168/// is exactly §5's *"a cell or collection's element type accumulates the join
169/// of every fn value assigned into it, through copies, parameters, returns,
170/// and nesting"*. That absorption is on the `unknown` **row**, not on every
171/// untraceable-looking write: a write typed plain `Ty::Unknown` (an
172/// unresolved reference, or an unregistered `EXTERNAL`'s return) unifies
173/// through `Ty::Unknown`'s own identity arm, not through this lattice at
174/// all, and so leaves the other operand's row untouched rather than
175/// poisoning it to `unknown`.
176///
177/// **Not yet read by the effect walk.** `def_effect_atoms` deliberately runs
178/// the body walk with empty globals and empty signatures (§6.1a's acyclicity
179/// is load-bearing on that), so a `#fn` literal types as `Unknown` there and
180/// this row is invisible to effect inference as currently constructed.
181/// Wiring §6 mechanism 3 — the heap — means deciding *which stratum* reads
182/// the type-carried row, which the 2026-07-28 sitting did not settle; see
183/// `docs/effects-spec.md` §6.1c.
184///
185/// Represented as an `Option<Box<…>>` so the common `Ty::Fn` stays one
186/// pointer wider rather than three words wider — `Ty` is copied constantly
187/// through the join.
188#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
189#[expect(
190    clippy::box_collection,
191    reason = "the box is the point: it keeps `Ty::Fn` one pointer wider \
192              instead of three words wider, and the unknown top element — \
193              which is what almost every `Ty::Fn` carries — costs nothing \
194              at all through the `Option` niche"
195)]
196pub struct FnRow(Option<Box<BTreeSet<DefinitionId>>>);
197
198impl FnRow {
199    /// The top element: nothing is known about where the values inhabiting
200    /// this slot were created. Also [`Default`], so every `Ty::Fn` built
201    /// without explicit creation evidence is conservative by construction.
202    #[must_use]
203    pub fn unknown() -> Self {
204        FnRow(None)
205    }
206
207    /// The row of a fn value created *here*, at a `#fn(target, …)` literal
208    /// naming `target` — the one syntactic creation form (§6.1a).
209    #[must_use]
210    pub fn of_target(target: DefinitionId) -> Self {
211        FnRow(Some(Box::new(BTreeSet::from([target]))))
212    }
213
214    /// The empty row: this slot provably holds no in-project fn value. Only
215    /// reachable through a join that started here; never minted directly.
216    #[must_use]
217    pub fn empty() -> Self {
218        FnRow(Some(Box::new(BTreeSet::new())))
219    }
220
221    /// `true` when this row is the top element ([`FnRow::unknown`]).
222    #[must_use]
223    pub fn is_unknown(&self) -> bool {
224        self.0.is_none()
225    }
226
227    /// The creation targets, or `None` when the row is the top element.
228    /// A caller that wants the conservative reading must treat `None` as
229    /// *"every target"*, never as *"no targets"*.
230    #[must_use]
231    pub fn targets(&self) -> Option<&BTreeSet<DefinitionId>> {
232        self.0.as_deref()
233    }
234
235    /// Least upper bound: union of the target sets, with the top element
236    /// absorbing. Monotone over a finite per-project lattice, so folding it
237    /// through [`unify`] terminates for the same reason the rest of the join
238    /// does.
239    #[must_use]
240    pub fn join(&self, other: &FnRow) -> FnRow {
241        match (&self.0, &other.0) {
242            (Some(a), Some(b)) => FnRow(Some(Box::new(a.union(b).copied().collect()))),
243            _ => FnRow::unknown(),
244        }
245    }
246}
247
248/// The seven numeric-tower kinds (NS-A8) carried by [`Ty::Tower`].
249#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
250pub enum TowerTy {
251    Vec2,
252    Vec3,
253    Vec4,
254    Quat,
255    Mat2,
256    Mat3,
257    Mat4,
258}
259
260impl TowerTy {
261    /// The global type name (`vec2` … `mat4`) — also the constructor verb.
262    #[must_use]
263    pub fn name(self) -> &'static str {
264        match self {
265            TowerTy::Vec2 => "vec2",
266            TowerTy::Vec3 => "vec3",
267            TowerTy::Vec4 => "vec4",
268            TowerTy::Quat => "quat",
269            TowerTy::Mat2 => "mat2",
270            TowerTy::Mat3 => "mat3",
271            TowerTy::Mat4 => "mat4",
272        }
273    }
274
275    /// Resolve a tower type name (`vec2` … `mat4`) to its kind.
276    #[must_use]
277    pub fn from_name(name: &str) -> Option<Self> {
278        match name {
279            "vec2" => Some(TowerTy::Vec2),
280            "vec3" => Some(TowerTy::Vec3),
281            "vec4" => Some(TowerTy::Vec4),
282            "quat" => Some(TowerTy::Quat),
283            "mat2" => Some(TowerTy::Mat2),
284            "mat3" => Some(TowerTy::Mat3),
285            "mat4" => Some(TowerTy::Mat4),
286            _ => None,
287        }
288    }
289}
290
291/// `Unknown` is the natural default: every local/return slot starts here
292/// before any use narrows it (see the module doc on why `join`-from-`Unknown`
293/// stands in for a union-find unifier in this monomorphic, overload-free
294/// universe).
295impl Default for Ty {
296    fn default() -> Self {
297        Ty::Unknown
298    }
299}
300
301impl Ty {
302    /// Human-readable name, for future hover/diagnostic surfacing (TM-5).
303    #[must_use]
304    pub fn display(&self) -> String {
305        match self {
306            Ty::Int => "int".to_string(),
307            Ty::Float => "float".to_string(),
308            Ty::Bool => "bool".to_string(),
309            Ty::String => "string".to_string(),
310            Ty::Content => "content".to_string(),
311            Ty::Divert => "divert".to_string(),
312            Ty::List(name) => format!("List<{name}>"),
313            Ty::Array(elem) => format!("Array<{}>", elem.display()),
314            Ty::Map(k, v) => format!("Map<{}, {}>", k.display(), v.display()),
315            Ty::Struct(name) => name.clone(),
316            // The effect row is deliberately absent: `display` renders the
317            // *written* type language (what an annotation can spell, what a
318            // diagnostic quotes back), and there is no surface syntax for a
319            // row. Rendering it here would churn every `E063`/`E066` message
320            // for a component the author never wrote.
321            Ty::Fn(params, ret, _) => {
322                let row = params
323                    .iter()
324                    .map(Ty::display)
325                    .collect::<Vec<_>>()
326                    .join(", ");
327                format!("fn({row}): {}", ret.display())
328            }
329            Ty::Handle(kind) => format!("Handle<{kind}>"),
330            Ty::Option(elem) => format!("Option<{}>", elem.display()),
331            Ty::Range { non_empty: false } => "range".to_string(),
332            Ty::Range { non_empty: true } => "NonEmptyRange".to_string(),
333            Ty::Tower(kind) => kind.name().to_string(),
334            Ty::Weighted(elem) => format!("Weighted<{}>", elem.display()),
335            Ty::Unknown => "Unknown".to_string(),
336            Ty::Conflicted => "Conflicted".to_string(),
337        }
338    }
339
340    #[must_use]
341    pub fn is_unknown(&self) -> bool {
342        matches!(self, Ty::Unknown)
343    }
344
345    #[must_use]
346    pub fn is_conflicted(&self) -> bool {
347        matches!(self, Ty::Conflicted)
348    }
349
350    /// Gradual/advisory consumers' view (#627 ruling: "Conflicted like
351    /// Unknown, zero behavior change today") — a slot that is either
352    /// unconstrained or genuinely conflicted carries no usable concrete
353    /// type for a consumer that isn't strict-mode's TM-3 conflict reporter.
354    /// Strict mode (#619) is the one place these two must stay
355    /// distinguished; every other consumer should read this instead of
356    /// `is_unknown()`.
357    #[must_use]
358    pub fn is_unresolved(&self) -> bool {
359        matches!(self, Ty::Unknown | Ty::Conflicted)
360    }
361
362    #[must_use]
363    pub fn is_numeric(&self) -> bool {
364        matches!(self, Ty::Int | Ty::Float)
365    }
366}
367
368/// A stable ordering over `Ty` values, used only to keep generated
369/// diagnostics/tests deterministic (never for typing decisions). Not derived
370/// `Ord` because `Ty` intentionally has no natural total order over its
371/// structural variants beyond "same shape, compare recursively".
372impl PartialOrd for Ty {
373    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
374        Some(self.cmp(other))
375    }
376}
377
378impl Ord for Ty {
379    fn cmp(&self, other: &Self) -> Ordering {
380        fn rank(t: &Ty) -> u8 {
381            match t {
382                Ty::Int => 0,
383                Ty::Float => 1,
384                Ty::Bool => 2,
385                Ty::String => 3,
386                Ty::Divert => 4,
387                Ty::List(_) => 5,
388                Ty::Array(_) => 6,
389                Ty::Map(_, _) => 7,
390                Ty::Struct(_) => 8,
391                Ty::Fn(..) => 9,
392                Ty::Handle(_) => 10,
393                Ty::Option(_) => 11,
394                Ty::Range { .. } => 12,
395                Ty::Tower(_) => 13,
396                Ty::Weighted(_) => 14,
397                Ty::Content => 15,
398                Ty::Unknown => 16,
399                Ty::Conflicted => 17,
400            }
401        }
402        match (self, other) {
403            (Ty::List(a), Ty::List(b))
404            | (Ty::Struct(a), Ty::Struct(b))
405            | (Ty::Handle(a), Ty::Handle(b)) => a.cmp(b),
406            (Ty::Array(a), Ty::Array(b))
407            | (Ty::Option(a), Ty::Option(b))
408            | (Ty::Weighted(a), Ty::Weighted(b)) => a.cmp(b),
409            (Ty::Range { non_empty: a }, Ty::Range { non_empty: b }) => a.cmp(b),
410            (Ty::Tower(a), Ty::Tower(b)) => a.cmp(b),
411            (Ty::Map(k1, v1), Ty::Map(k2, v2)) => k1.cmp(k2).then_with(|| v1.cmp(v2)),
412            // The effect row participates so that `cmp` stays consistent
413            // with the derived `PartialEq` — two `Ty::Fn`s that differ only
414            // in their row are *not* equal, and an `Ordering::Equal` for an
415            // unequal pair would break any `BTreeMap`/`BTreeSet` keyed by
416            // `Ty`.
417            (Ty::Fn(p1, r1, e1), Ty::Fn(p2, r2, e2)) => {
418                p1.cmp(p2).then_with(|| r1.cmp(r2)).then_with(|| e1.cmp(e2))
419            }
420            _ => rank(self).cmp(&rank(other)),
421        }
422    }
423}
424
425/// Unify (join) two observed types for the same slot.
426///
427/// This is the coercion lattice of spec §4 applied bidirectionally during
428/// inference (strict-mode narrowing is TM-3's job, not this one):
429///
430/// - `Unknown` is the identity — `unify(Unknown, x) == x` in either
431///   position, which is what lets a still-unconstrained local absorb its
432///   first real use without special-casing "first observation".
433/// - `int -> float` is the one directional numeric coercion ink allows
434///   (spec §4): joining `Int` and `Float` in either order produces `Float`.
435/// - Two structurally equal types join to themselves (including recursively
436///   for `Array<T>`/`Map<K, V>`, so `Array<int>` joined with `Array<float>`
437///   is `Array<float>`, not a hard mismatch).
438/// - `Conflicted` is absorbing: joining it with anything (including
439///   `Unknown`) stays `Conflicted` (#627 ruling). This is what makes
440///   conflict detection order-independent — once a slot has seen a genuine
441///   conflict, no later observation (concrete or `Unknown`) can heal it
442///   back to a concrete type.
443/// - Anything else is a genuine structural mismatch (e.g. `int` vs
444///   `string`) and joins to `Ty::Conflicted` (#627 ruling — previously this
445///   degraded to `Unknown`, which let a later observation silently absorb
446///   and hide the conflict depending on source order). This slice is still
447///   advisory-only and raises no new diagnostic for it (spec step 1:
448///   "essentially no new user-facing diagnostics") — gradual/advisory
449///   consumers read `Conflicted` exactly like `Unknown`
450///   ([`Ty::is_unresolved`]); TM-3 (#619) is the slice that turns a
451///   `Conflicted` slot into a real strict-mode error.
452///
453/// **The trailing `_ => Ty::Conflicted` cannot be removed** (issue #1772,
454/// the pair-match sibling of #1758's `erase_fn_rows` fix above it in this
455/// file). Unlike `erase_fn_rows`'s single-argument match, this one matches
456/// `(Ty, Ty)`: the wildcard is not just a catch-all for "haven't handled
457/// this variant yet" — it is also the *correct*, load-bearing arm for every
458/// genuine cross-nominal mismatch (`int` vs `string`, `Handle("A")` vs
459/// `Handle("B")`, two different-length `Fn` rows, …), so the match can never
460/// be made exhaustive the way `erase_fn_rows` was. That is exactly what
461/// makes this wildcard a silent-erasure hole for a *future* `Ty` variant
462/// that nests a `Ty`: such a variant would compile cleanly against this
463/// wildcard and silently join to `Conflicted` instead of unifying
464/// pointwise, with no compiler error to catch it — and, worse than
465/// `erase_fn_rows`'s failure mode, gradual mode (every consumer today)
466/// treats `Conflicted` exactly like `Unknown`, so nothing would even be
467/// visibly wrong. `classify_unify_nesting` and
468/// `unify_has_a_pointwise_arm_for_every_nesting_ty_variant` in `mod tests`
469/// below are the guard: an exhaustive match with no wildcard forces an
470/// explicit decision the moment a new variant lands on `Ty`, and the test
471/// proves today's five nesting variants (`Array`, `Map`, `Fn`, `Option`,
472/// `Weighted`) all really do have a pointwise arm above rather than falling
473/// through to here.
474#[must_use]
475pub fn unify(a: &Ty, b: &Ty) -> Ty {
476    match (a, b) {
477        (Ty::Unknown, x) | (x, Ty::Unknown) => x.clone(),
478        (Ty::Conflicted, _) | (_, Ty::Conflicted) => Ty::Conflicted,
479        (x, y) if x == y => x.clone(),
480        (Ty::Int, Ty::Float) | (Ty::Float, Ty::Int) => Ty::Float,
481        (Ty::Array(x), Ty::Array(y)) => Ty::Array(Box::new(unify(x, y))),
482        // Option unifies pointwise on the element, exactly like Array
483        // (NS-A1, docs/stdlib-spec.md §1.4). Option vs any non-Option
484        // concrete type falls through to `Conflicted` below — the ruled
485        // `Option<T> ≠ T` strictness lives in the lattice itself.
486        (Ty::Option(x), Ty::Option(y)) => Ty::Option(Box::new(unify(x, y))),
487        // Weighted unifies pointwise on the value element, exactly like
488        // Array/Option (NS-A7, docs/stdlib-spec.md §8); against any other
489        // concrete type it falls through to `Conflicted` below.
490        (Ty::Weighted(x), Ty::Weighted(y)) => Ty::Weighted(Box::new(unify(x, y))),
491        // Ranges unify with ranges; the refinement evidence joins with
492        // `&&` (NS-A5): a slot is `NonEmptyRange` only if EVERY observed
493        // source carries the evidence — one possibly-empty assignment
494        // demotes the join to plain `range`, exactly the sound direction
495        // (evidence can be lost at a join, never fabricated). The `x == y`
496        // arm above already handles the equal-flag cases.
497        (Ty::Range { non_empty: a }, Ty::Range { non_empty: b }) => Ty::Range {
498            non_empty: *a && *b,
499        },
500        (Ty::Map(k1, v1), Ty::Map(k2, v2)) => {
501            Ty::Map(Box::new(unify(k1, k2)), Box::new(unify(v1, v2)))
502        }
503        // Fn vs Fn unifies pointwise when the (val-only) param rows agree on
504        // arity (T1c ruling, docs/t1c-spec.md §4 / the #627 lattice) —
505        // params and return join component-wise, so `fn(int): int` and
506        // `fn(float): int` join to `fn(float): int` exactly like the
507        // Array/Map elements above. Param rows of different length are a
508        // genuine structural mismatch and fall through to `Conflicted`.
509        //
510        // The **effect row** joins alongside them (`docs/effects-spec.md`
511        // §5, issue #1680): "a cell or collection's element type accumulates
512        // the join of every fn value assigned into it, through copies,
513        // parameters, returns, and nesting — because typing already follows
514        // values". `FnRow::join` is set union with the unknown top element
515        // absorbing, so a slot that has seen one untraceable source stays
516        // conservative no matter what else flows in (§3).
517        (Ty::Fn(p1, r1, e1), Ty::Fn(p2, r2, e2)) if p1.len() == p2.len() => Ty::Fn(
518            p1.iter().zip(p2).map(|(x, y)| unify(x, y)).collect(),
519            Box::new(unify(r1, r2)),
520            e1.join(e2),
521        ),
522        // Falls through for e.g. `(Ty::Handle(a), Ty::Handle(b))` with
523        // `a != b` (T1d-2, #627 lattice): the `x == y` arm above already
524        // handles same-kind handles, so two different kinds — as
525        // structurally incompatible as `int` vs `string` — land here, same
526        // as every other cross-nominal mismatch.
527        _ => Ty::Conflicted,
528    }
529}
530
531/// Fold `unify` over an iterator of observed types, starting from
532/// `Ty::Unknown` (the identity). Used for collection-literal element joins
533/// (spec §5: `#[1, 2.0]` is `Array<float>`) and for folding multiple
534/// observations of the same local/return slot across a body.
535#[must_use]
536pub fn unify_all(tys: impl IntoIterator<Item = Ty>) -> Ty {
537    tys.into_iter().fold(Ty::Unknown, |acc, t| unify(&acc, &t))
538}
539
540/// Rewrite every [`Ty::Fn`] anywhere inside `ty` to carry the top
541/// [`FnRow`] — the canonical form used to compare two types *modulo* their
542/// effect rows. Recurses through every structural variant so a row nested
543/// inside `array<fn(): int>` or `fn(fn(): int): int` is erased too.
544///
545/// **No wildcard arm** (issue #1758). `Option`, `Weighted`, and `Tower` were
546/// each added to [`Ty`] over time, and any of them (or a future variant)
547/// could nest a `Ty` the way `Array`/`Map`/`Fn` do — a `_ => ty.clone()`
548/// catch-all would let such an addition silently fall through as a no-op
549/// erasure instead of failing to compile, reintroducing the exact
550/// spurious-`E063` class #1754 fixed (a nested [`FnRow`] surviving erasure
551/// and then getting compared structurally by [`assignable`]'s callers).
552/// Every nominal leaf is therefore listed explicitly: adding a new variant
553/// that nests a `Ty` is a compile error here until someone decides whether
554/// it needs an erasing arm.
555#[must_use]
556pub fn erase_fn_rows(ty: &Ty) -> Ty {
557    match ty {
558        Ty::Fn(params, ret, _) => Ty::Fn(
559            params.iter().map(erase_fn_rows).collect(),
560            Box::new(erase_fn_rows(ret)),
561            FnRow::unknown(),
562        ),
563        Ty::Array(elem) => Ty::Array(Box::new(erase_fn_rows(elem))),
564        Ty::Option(elem) => Ty::Option(Box::new(erase_fn_rows(elem))),
565        Ty::Weighted(elem) => Ty::Weighted(Box::new(erase_fn_rows(elem))),
566        Ty::Map(k, v) => Ty::Map(Box::new(erase_fn_rows(k)), Box::new(erase_fn_rows(v))),
567        Ty::Int
568        | Ty::Float
569        | Ty::Bool
570        | Ty::String
571        | Ty::Content
572        | Ty::Divert
573        | Ty::List(_)
574        | Ty::Struct(_)
575        | Ty::Handle(_)
576        | Ty::Range { .. }
577        | Ty::Tower(_)
578        | Ty::Unknown
579        | Ty::Conflicted => ty.clone(),
580    }
581}
582
583/// Is a value of type `source` legal in a slot declared `target`?
584///
585/// The one assignability predicate every "does this argument fit this
586/// parameter" check shares — `annotations::report_if_mismatched` (`E063`),
587/// the two `ValueCallKind::ArgMismatch` sites in [`super::body`], and the
588/// `E071` struct-field check. It is [`unify`] plus the directional reading
589/// *"the join must not widen the target"*, which is what makes the one legal
590/// numeric coercion (`int` into a `float` slot) pass while `float` into an
591/// `int` slot fails.
592///
593/// **Row-insensitive** (issue #1680 step 2). Two `fn(): int` values created
594/// at different targets have different [`FnRow`]s, so their join is a third
595/// row that equals neither operand — a *structural* `unify(target, source)
596/// == target` test would therefore report a mismatch on perfectly correct
597/// code, and `strict.rs` promotes that mismatch to an `E063` **error** under
598/// `types = strict`. Effect rows are inferred provenance, never part of the
599/// written type language (see [`Ty::display`]), so they must not decide
600/// assignability: both sides are erased before the comparison.
601#[must_use]
602pub fn assignable(target: &Ty, source: &Ty) -> bool {
603    erase_fn_rows(&unify(target, source)) == erase_fn_rows(target)
604}
605
606/// Is a value of type `source` legal as the argument bound to a `ref`
607/// parameter declared `target`?
608///
609/// **Invariant, not covariant** (issue #1995/#1920, ruled 2026-08-01;
610/// `docs/t1e-spec.md` §5b owns this ruling — the ref/projection binding
611/// spec, not just the decision log). A `ref` slot both *reads* and *writes*
612/// through the caller's own storage cell — [`assignable`]'s one-directional
613/// widening (`int` argument fits a `float` slot) is unsound here, because
614/// the callee can write a `float` back through a cell that is statically
615/// declared `int`. Every other argument-type check in this crate is a
616/// genuine "does this value fit" question and stays on [`assignable`]; only
617/// a call's `ref`-parameter
618/// slots need this stricter twin.
619///
620/// Still row-insensitive for the same reason [`assignable`] is: two
621/// `fn(): int` values created at different targets must not conflict merely
622/// because their [`FnRow`]s differ (issue #1680 step 2). Erasing both sides
623/// before comparing keeps that guarantee while requiring the erased types to
624/// match exactly, in either direction, rather than merely joining without
625/// widening the target.
626///
627/// **Nested `Unknown` is a wildcard, not a mismatch** (issue #1995 review
628/// finding, BLOCKING). Raw structural `==` treats `Array<Unknown>` (an empty
629/// `#[]` literal, or any other element-type-unconstrained spelling) as
630/// unequal to `Array<float>`, which reported a false `E063` on ordinary code
631/// like `VAR xs = #[]` bound to a `ref Array<float>` parameter — call sites
632/// only guard `is_unresolved()` at the *top* level (`Unknown`/`Conflicted`),
633/// never on a nested position, so an inner `Unknown` used to leak through as
634/// a genuine structural mismatch. [`invariant_eq`] recurses through the same
635/// parameterized shapes [`unify`] does, treating `Ty::Unknown` at any nesting
636/// depth as compatible with anything — "element type unknown" is not
637/// "element type wrong". A concrete mismatch one level down (`Array<int>`
638/// into `ref Array<float>`) still fails: only `Unknown` gets the wildcard
639/// reading, every other leaf still compares exactly.
640#[must_use]
641pub fn ref_assignable(target: &Ty, source: &Ty) -> bool {
642    invariant_eq(&erase_fn_rows(target), &erase_fn_rows(source))
643}
644
645/// [`ref_assignable`]'s comparator: structurally equal except that
646/// `Ty::Unknown` in either operand, at any nesting depth, matches anything.
647/// Assumes both `fn` row effects have already been erased by the caller
648/// (mirrors [`assignable`]'s own row-insensitivity).
649fn invariant_eq(a: &Ty, b: &Ty) -> bool {
650    match (a, b) {
651        (Ty::Unknown, _) | (_, Ty::Unknown) => true,
652        (Ty::Array(x), Ty::Array(y))
653        | (Ty::Option(x), Ty::Option(y))
654        | (Ty::Weighted(x), Ty::Weighted(y)) => invariant_eq(x, y),
655        (Ty::Map(k1, v1), Ty::Map(k2, v2)) => invariant_eq(k1, k2) && invariant_eq(v1, v2),
656        (Ty::Fn(p1, r1, e1), Ty::Fn(p2, r2, e2)) => {
657            e1 == e2
658                && p1.len() == p2.len()
659                && p1.iter().zip(p2.iter()).all(|(x, y)| invariant_eq(x, y))
660                && invariant_eq(r1, r2)
661        }
662        _ => a == b,
663    }
664}
665
666/// Why an [`coalesce`] application is ill-typed (NS-A1, F19).
667#[derive(Debug, Clone, PartialEq, Eq)]
668pub enum CoalesceError {
669    /// The left operand is a concrete non-Option type — coalescing is only
670    /// defined over an optional left-hand side (`Option<T> or …`).
671    LeftNotOption(Ty),
672    /// The element/right types are structurally irreconcilable
673    /// (`Option<int> or "text"` — the join of `int` and `string` is
674    /// `Conflicted`).
675    Mismatch { element: Ty, fallback: Ty },
676}
677
678/// The `or`-coalescing TYPING rule (NS-A1; `docs/stdlib-phase-c-findings.md`
679/// F19's recommendation, implemented as ruled by wave A1's scope):
680///
681/// - `(Option<T>, T') → join(T, T')` — the Option-then-value form collapses
682///   to the value type (the `x or default` 90% case; the `int -> float`
683///   directional coercion applies inside the join, so
684///   `Option<int> or 1.5` is `float`).
685/// - `(Option<T>, Option<U>) → Option<join(T, U)>` — the two-Option form
686///   keeps optionality, which is what makes chaining work:
687///   `a.get(k) or a.get(k2) or default` associates left, staying
688///   `Option<V>` until the final non-Option fallback collapses it.
689/// - Left-associative by construction: a chain is just repeated
690///   application, so no explicit associativity machinery is needed.
691/// - Gradual escape hatches: an `Unknown` left operand yields `Unknown`
692///   (nothing is known to check — same posture as every other gradual
693///   position); `Conflicted` anywhere stays `Conflicted` via the join.
694///
695/// **Surface spelling landed in B1** (issue #1460): `InfixOp::Coalesce`,
696/// produced only by native lowering (`hir::lower_native::expr::infix_op`,
697/// the `KW_OR` token). The brink/ink dialect still has no coalescing
698/// operator to hang this on — `InfixOp::Or` there stays ink's boolean
699/// `||`, oracle-frozen and untouched — so this rule is consumed
700/// exclusively by [`super::body::InferPass::infer_infix`]'s
701/// `InfixOp::Coalesce` arm.
702pub fn coalesce(lhs: &Ty, rhs: &Ty) -> Result<Ty, CoalesceError> {
703    match (lhs, rhs) {
704        // Two-Option form: keep optionality, join elements.
705        (Ty::Option(elem), Ty::Option(relem)) => Ok(Ty::Option(Box::new(unify(elem, relem)))),
706        // Option-then-value form: collapse to the value type when the
707        // element and fallback reconcile (join is not Conflicted).
708        (Ty::Option(elem), _) => {
709            let joined = unify(elem, rhs);
710            if joined.is_conflicted() && !elem.is_conflicted() && !rhs.is_conflicted() {
711                Err(CoalesceError::Mismatch {
712                    element: (**elem).clone(),
713                    fallback: rhs.clone(),
714                })
715            } else {
716                Ok(joined)
717            }
718        }
719        // Gradual: an unresolved left side types as itself (Unknown stays
720        // Unknown, Conflicted stays Conflicted) — strict-mode escape
721        // reporting owns surfacing those, same as every consumer.
722        (Ty::Unknown, _) => Ok(Ty::Unknown),
723        (Ty::Conflicted, _) => Ok(Ty::Conflicted),
724        (other, _) => Err(CoalesceError::LeftNotOption(other.clone())),
725    }
726}
727
728#[cfg(test)]
729mod tests {
730    use super::*;
731
732    #[test]
733    fn unknown_is_identity() {
734        assert_eq!(unify(&Ty::Unknown, &Ty::Int), Ty::Int);
735        assert_eq!(unify(&Ty::Int, &Ty::Unknown), Ty::Int);
736        assert_eq!(unify(&Ty::Unknown, &Ty::Unknown), Ty::Unknown);
737    }
738
739    #[test]
740    fn int_float_join_is_directional_to_float() {
741        assert_eq!(unify(&Ty::Int, &Ty::Float), Ty::Float);
742        assert_eq!(unify(&Ty::Float, &Ty::Int), Ty::Float);
743    }
744
745    #[test]
746    fn equal_types_join_to_themselves() {
747        assert_eq!(unify(&Ty::String, &Ty::String), Ty::String);
748        assert_eq!(
749            unify(&Ty::List("Weathers".into()), &Ty::List("Weathers".into())),
750            Ty::List("Weathers".into())
751        );
752    }
753
754    // ─── #1846 `Ty::Content` ───────────────────────────────────────────
755
756    #[test]
757    fn content_joins_with_itself() {
758        assert_eq!(unify(&Ty::Content, &Ty::Content), Ty::Content);
759    }
760
761    #[test]
762    fn content_never_coerces_to_or_from_string() {
763        // The whole reason `content` is a distinct leaf (docs/decision-
764        // log.md 2026-07-31 ruling, `docs/prose-dialect-spec.md` §3.5b's
765        // capture contract): a content value must never silently flatten to
766        // a plain string, which would defeat its translation-residency
767        // guarantee. Unlike `int -> float`, there is no directional
768        // coercion here — the pair is a genuine structural mismatch, same
769        // as `int` vs `string`.
770        assert_eq!(unify(&Ty::Content, &Ty::String), Ty::Conflicted);
771        assert_eq!(unify(&Ty::String, &Ty::Content), Ty::Conflicted);
772    }
773
774    #[test]
775    fn content_is_assignable_only_to_content() {
776        assert!(assignable(&Ty::Content, &Ty::Content));
777        assert!(!assignable(&Ty::Content, &Ty::String));
778        assert!(!assignable(&Ty::String, &Ty::Content));
779    }
780
781    #[test]
782    fn structural_mismatch_yields_conflicted_not_unknown() {
783        // #627 ruling: a genuinely disjoint concrete pair is a distinct
784        // absorbing lattice point, `Ty::Conflicted` — no longer degrades to
785        // `Unknown` (which would let a later observation silently "heal"
786        // the slot back to a concrete type, hiding the conflict).
787        assert_eq!(unify(&Ty::Int, &Ty::String), Ty::Conflicted);
788        assert_eq!(unify(&Ty::Bool, &Ty::Divert), Ty::Conflicted);
789    }
790
791    #[test]
792    fn conflicted_absorbs_unknown_and_everything_else() {
793        // `Conflicted JOIN anything = Conflicted` (#627 ruling) — it is a
794        // stronger absorbing point than `Unknown`: `Unknown` is the join
795        // *identity*, `Conflicted` is the join *absorber*.
796        assert_eq!(unify(&Ty::Conflicted, &Ty::Unknown), Ty::Conflicted);
797        assert_eq!(unify(&Ty::Unknown, &Ty::Conflicted), Ty::Conflicted);
798        assert_eq!(unify(&Ty::Conflicted, &Ty::Int), Ty::Conflicted);
799        assert_eq!(unify(&Ty::Int, &Ty::Conflicted), Ty::Conflicted);
800        assert_eq!(unify(&Ty::Conflicted, &Ty::Conflicted), Ty::Conflicted);
801    }
802
803    #[test]
804    fn conflict_detection_is_order_independent() {
805        // #627 ruling: permuting the order observations are folded in must
806        // not change whether a conflict is detected — a genuine int/string
807        // conflict must not "self-heal" depending on which observation
808        // arrives first (the bug this issue exists to close).
809        let orderings: [&[Ty]; 6] = [
810            &[Ty::Int, Ty::String, Ty::Int],
811            &[Ty::String, Ty::Int, Ty::Int],
812            &[Ty::Int, Ty::Int, Ty::String],
813            &[Ty::String, Ty::Int, Ty::Int],
814            &[Ty::Int, Ty::String, Ty::Int],
815            &[Ty::String, Ty::String, Ty::Int],
816        ];
817        for ordering in orderings {
818            assert_eq!(
819                unify_all(ordering.iter().cloned()),
820                Ty::Conflicted,
821                "order {ordering:?} must detect the conflict"
822            );
823        }
824    }
825
826    #[test]
827    fn conflict_detection_survives_unknown_interleaving_in_any_order() {
828        // Interleaving `Unknown` observations (an unconstrained/unused
829        // intermediate use) anywhere in the sequence must not mask the
830        // conflict — `Unknown` is a true identity, never a reset.
831        let orderings: [&[Ty]; 4] = [
832            &[Ty::Unknown, Ty::Int, Ty::Unknown, Ty::String],
833            &[Ty::Int, Ty::Unknown, Ty::String, Ty::Unknown],
834            &[Ty::String, Ty::Unknown, Ty::Unknown, Ty::Int],
835            &[Ty::Unknown, Ty::Unknown, Ty::String, Ty::Int],
836        ];
837        for ordering in orderings {
838            assert_eq!(unify_all(ordering.iter().cloned()), Ty::Conflicted);
839        }
840    }
841
842    #[test]
843    fn array_and_map_join_recursively() {
844        assert_eq!(
845            unify(
846                &Ty::Array(Box::new(Ty::Int)),
847                &Ty::Array(Box::new(Ty::Float))
848            ),
849            Ty::Array(Box::new(Ty::Float))
850        );
851        assert_eq!(
852            unify(
853                &Ty::Map(Box::new(Ty::Int), Box::new(Ty::String)),
854                &Ty::Map(Box::new(Ty::Unknown), Box::new(Ty::String))
855            ),
856            Ty::Map(Box::new(Ty::Int), Box::new(Ty::String))
857        );
858    }
859
860    #[test]
861    fn unify_all_folds_left_to_right_from_unknown() {
862        assert_eq!(unify_all([Ty::Int, Ty::Float]), Ty::Float);
863        assert_eq!(unify_all(Vec::<Ty>::new()), Ty::Unknown);
864        assert_eq!(unify_all([Ty::Bool]), Ty::Bool);
865    }
866
867    // ─── T1c `Ty::Fn` (docs/t1c-spec.md §4) ────────────────────────────
868
869    fn fn_ty(params: &[Ty], ret: Ty) -> Ty {
870        Ty::Fn(params.to_vec(), Box::new(ret), FnRow::unknown())
871    }
872
873    /// A stand-in creation-target id.
874    fn def(n: u64) -> DefinitionId {
875        DefinitionId::new(brink_format::DefinitionTag::Address, n)
876    }
877
878    /// A `Ty::Fn` carrying a concrete creation-target row (issue #1680).
879    fn fn_ty_from(params: &[Ty], ret: Ty, targets: &[u64]) -> Ty {
880        let row = targets.iter().fold(FnRow::empty(), |acc, &t| {
881            acc.join(&FnRow::of_target(def(t)))
882        });
883        Ty::Fn(params.to_vec(), Box::new(ret), row)
884    }
885
886    #[test]
887    fn fn_unifies_pointwise_including_the_directional_numeric_join() {
888        // Params and return join component-wise, exactly like Array/Map
889        // elements — the int -> float directional coercion applies inside
890        // the row too.
891        assert_eq!(
892            unify(&fn_ty(&[Ty::Int], Ty::Int), &fn_ty(&[Ty::Float], Ty::Int)),
893            fn_ty(&[Ty::Float], Ty::Int)
894        );
895        assert_eq!(
896            unify(
897                &fn_ty(&[Ty::String], Ty::Int),
898                &fn_ty(&[Ty::String], Ty::Float)
899            ),
900            fn_ty(&[Ty::String], Ty::Float)
901        );
902    }
903
904    #[test]
905    fn fn_unknown_row_slots_absorb_concrete_ones() {
906        assert_eq!(
907            unify(
908                &fn_ty(&[Ty::Unknown], Ty::Unknown),
909                &fn_ty(&[Ty::Int], Ty::Bool)
910            ),
911            fn_ty(&[Ty::Int], Ty::Bool)
912        );
913    }
914
915    #[test]
916    fn fn_arity_mismatch_is_conflicted() {
917        assert_eq!(
918            unify(&fn_ty(&[Ty::Int], Ty::Int), &fn_ty(&[], Ty::Int)),
919            Ty::Conflicted
920        );
921    }
922
923    #[test]
924    fn fn_vs_other_concrete_is_conflicted_per_the_627_lattice() {
925        assert_eq!(unify(&fn_ty(&[], Ty::Int), &Ty::Int), Ty::Conflicted);
926        assert_eq!(unify(&Ty::String, &fn_ty(&[], Ty::Int)), Ty::Conflicted);
927        assert_eq!(
928            unify(&fn_ty(&[], Ty::Int), &Ty::Array(Box::new(Ty::Int))),
929            Ty::Conflicted
930        );
931    }
932
933    #[test]
934    fn fn_conflict_inside_the_row_stays_inside_the_row() {
935        // A disagreeing param slot conflicts *pointwise* — the row shape
936        // survives, mirroring `Array(Conflicted)` for `#[1, "a"]`, and the
937        // recursive strict-mode classify walk is what surfaces it.
938        assert_eq!(
939            unify(&fn_ty(&[Ty::Int], Ty::Int), &fn_ty(&[Ty::String], Ty::Int)),
940            fn_ty(&[Ty::Conflicted], Ty::Int)
941        );
942    }
943
944    // ─── §5 effect rows on `Ty::Fn` (issue #1680 steps 2/3) ────────────
945
946    #[test]
947    fn fn_row_join_is_union_with_unknown_absorbing() {
948        let a = FnRow::of_target(def(1));
949        let b = FnRow::of_target(def(2));
950        let ab = a.join(&b);
951        assert_eq!(
952            ab.targets().map(BTreeSet::len),
953            Some(2),
954            "two creation sites union"
955        );
956        assert!(ab.targets().is_some_and(|t| t.contains(&def(1))));
957        assert!(ab.targets().is_some_and(|t| t.contains(&def(2))));
958        // The top element absorbs in either position — one untraceable
959        // source poisons the slot for good (§3, conservative-total).
960        assert!(a.join(&FnRow::unknown()).is_unknown());
961        assert!(FnRow::unknown().join(&a).is_unknown());
962        assert!(FnRow::unknown().join(&FnRow::unknown()).is_unknown());
963        // `empty` is the join identity, so a fold can start there.
964        assert_eq!(FnRow::empty().join(&a), a);
965    }
966
967    #[test]
968    fn fn_row_join_is_commutative_and_idempotent() {
969        let a = FnRow::of_target(def(1));
970        let b = FnRow::of_target(def(2));
971        assert_eq!(a.join(&b), b.join(&a));
972        assert_eq!(a.join(&a), a);
973        assert_eq!(a.join(&b).join(&b), a.join(&b));
974    }
975
976    #[test]
977    fn unify_joins_the_effect_row_alongside_params_and_return() {
978        // §5: "a cell accumulates the join of every fn value assigned into
979        // it" — two `#fn` literals reaching one slot leave both targets on
980        // the slot's type.
981        let joined = unify(
982            &fn_ty_from(&[Ty::Int], Ty::Int, &[1]),
983            &fn_ty_from(&[Ty::Int], Ty::Int, &[2]),
984        );
985        assert_eq!(joined, fn_ty_from(&[Ty::Int], Ty::Int, &[1, 2]));
986        // A traced row joined with an unknown one stays unknown.
987        assert_eq!(
988            unify(
989                &fn_ty_from(&[Ty::Int], Ty::Int, &[1]),
990                &fn_ty(&[Ty::Int], Ty::Int)
991            ),
992            fn_ty(&[Ty::Int], Ty::Int)
993        );
994        // `Ty::Unknown` is still the identity — it carries no row at all,
995        // so an unobserved slot must not poison a traced one.
996        let traced = fn_ty_from(&[Ty::Int], Ty::Int, &[1]);
997        assert_eq!(unify(&Ty::Unknown, &traced), traced);
998        assert_eq!(unify(&traced, &Ty::Unknown), traced);
999    }
1000
1001    #[test]
1002    fn effect_rows_do_not_change_the_displayed_type() {
1003        // Rows are inferred provenance, not written syntax — every `E063`/
1004        // `E066` message that quotes a type back must be unaffected.
1005        assert_eq!(
1006            fn_ty_from(&[Ty::Int], Ty::Bool, &[1, 2]).display(),
1007            fn_ty(&[Ty::Int], Ty::Bool).display()
1008        );
1009    }
1010
1011    #[test]
1012    fn erase_fn_rows_reaches_nested_positions() {
1013        let nested = Ty::Array(Box::new(Ty::Map(
1014            Box::new(Ty::String),
1015            Box::new(fn_ty_from(
1016                &[fn_ty_from(&[], Ty::Int, &[3])],
1017                Ty::Int,
1018                &[1, 2],
1019            )),
1020        )));
1021        let erased = Ty::Array(Box::new(Ty::Map(
1022            Box::new(Ty::String),
1023            Box::new(fn_ty(&[fn_ty(&[], Ty::Int)], Ty::Int)),
1024        )));
1025        assert_eq!(erase_fn_rows(&nested), erased);
1026        assert_eq!(erase_fn_rows(&erased), erased, "erasure is idempotent");
1027    }
1028
1029    /// Whether a `Ty` variant nests another `Ty` — and so needs an erasing
1030    /// arm in [`erase_fn_rows`] — or is a nominal leaf that erasure must
1031    /// leave untouched.
1032    enum ErasureShape {
1033        NestsTy,
1034        Leaf,
1035    }
1036
1037    /// Structural exhaustiveness guard (issue #1758), the same idiom as
1038    /// `brink-format`'s `assert_value_variants_exhaustive` (issue #883) and
1039    /// `brink-syntax-native`'s `coverage.rs` `classify`: an *exhaustive*
1040    /// match over every current [`Ty`] variant with **no wildcard arm**, so
1041    /// this — and therefore `cargo test` for this crate — fails to compile
1042    /// the moment a new variant lands on `Ty`, until it is explicitly
1043    /// classified here (and in `erase_fn_rows` itself, which mirrors this
1044    /// classification one-for-one). `Option`, `Weighted`, and `Tower` were
1045    /// each added to `Ty` over time carrying exactly this risk; a `_ =>`
1046    /// wildcard here would let the next one slip past unnoticed the way
1047    /// `erase_fn_rows`'s old wildcard would have.
1048    fn classify_erasure_shape(ty: &Ty) -> ErasureShape {
1049        match ty {
1050            Ty::Fn(..) | Ty::Array(_) | Ty::Map(_, _) | Ty::Option(_) | Ty::Weighted(_) => {
1051                ErasureShape::NestsTy
1052            }
1053            Ty::Int
1054            | Ty::Float
1055            | Ty::Bool
1056            | Ty::String
1057            | Ty::Content
1058            | Ty::Divert
1059            | Ty::List(_)
1060            | Ty::Struct(_)
1061            | Ty::Handle(_)
1062            | Ty::Range { .. }
1063            | Ty::Tower(_)
1064            | Ty::Unknown
1065            | Ty::Conflicted => ErasureShape::Leaf,
1066        }
1067    }
1068
1069    #[test]
1070    fn erase_fn_rows_leaves_every_nominal_leaf_untouched() {
1071        // Every variant `classify_erasure_shape` calls `Leaf`, exercised
1072        // through `erase_fn_rows` directly: proves the leaf arms really are
1073        // no-ops, not just that the guard match above compiles. The
1074        // exhaustive-match guard (both here and in `erase_fn_rows` itself)
1075        // only forces an explicit DECISION when a new `Ty` variant lands —
1076        // it cannot detect a WRONG decision: a future variant that nests a
1077        // `Ty` but gets placed in the `Leaf` arm of *both* matches compiles
1078        // cleanly and this loop would not catch it (the new variant would
1079        // need to be added to this list by hand). This loop only pins
1080        // today's leaves so an existing leaf accidentally gaining mutation
1081        // logic later is caught.
1082        let leaves = [
1083            Ty::Int,
1084            Ty::Float,
1085            Ty::Bool,
1086            Ty::String,
1087            Ty::Divert,
1088            Ty::List("Weathers".into()),
1089            Ty::Struct("Vec2".into()),
1090            Ty::Handle("AudioInstance".into()),
1091            Ty::Range { non_empty: true },
1092            Ty::Range { non_empty: false },
1093            Ty::Tower(TowerTy::Vec2),
1094            Ty::Unknown,
1095            Ty::Conflicted,
1096        ];
1097        for leaf in leaves {
1098            assert!(
1099                matches!(classify_erasure_shape(&leaf), ErasureShape::Leaf),
1100                "expected {leaf:?} to classify as a nominal leaf"
1101            );
1102            assert_eq!(erase_fn_rows(&leaf), leaf, "leaf erasure must be a no-op");
1103        }
1104    }
1105
1106    #[test]
1107    fn erase_fn_rows_reaches_option_and_weighted_nesting() {
1108        // `erase_fn_rows_reaches_nested_positions` above only exercises
1109        // `Array`/`Map`/`Fn` nesting at runtime — `classify_erasure_shape`'s
1110        // `NestsTy` arm also names `Option` and `Weighted`, but neither had
1111        // a test actually erasing a row through it. Mirror the same check
1112        // for both, so the classification and the real erasure agree on
1113        // every `NestsTy` variant, not just three of the five.
1114        let rowed = fn_ty_from(&[], Ty::Int, &[1]);
1115        let erased = fn_ty(&[], Ty::Int);
1116
1117        let via_option = Ty::Option(Box::new(rowed.clone()));
1118        assert!(matches!(
1119            classify_erasure_shape(&via_option),
1120            ErasureShape::NestsTy
1121        ));
1122        assert_eq!(
1123            erase_fn_rows(&via_option),
1124            Ty::Option(Box::new(erased.clone()))
1125        );
1126
1127        let via_weighted = Ty::Weighted(Box::new(rowed));
1128        assert!(matches!(
1129            classify_erasure_shape(&via_weighted),
1130            ErasureShape::NestsTy
1131        ));
1132        assert_eq!(erase_fn_rows(&via_weighted), Ty::Weighted(Box::new(erased)));
1133    }
1134
1135    // ─── #1772 `unify`'s pair-match wildcard ───────────────────────────
1136    //
1137    // `unify` cannot be made an exhaustive match the way `erase_fn_rows`
1138    // was for #1758 — it matches `(Ty, Ty)`, and the `_ => Ty::Conflicted`
1139    // wildcard is also the *correct* arm for every genuine cross-nominal
1140    // mismatch (`int` vs `string`, two different-kind `Handle`s, …), so it
1141    // can never be removed. What CAN be pinned down at compile time is
1142    // which variants nest a `Ty` — those two evaluated approaches:
1143    //
1144    // 1. A same-discriminant runtime guard in `unify`'s wildcard arm
1145    //    (`debug_assert!(discriminant(a) != discriminant(b), …)`) — rejected.
1146    //    It cannot tell a same-variant NESTING mismatch (a bug: the
1147    //    hypothetical case this issue is about) apart from a same-variant
1148    //    NOMINAL mismatch that is supposed to reach the wildcard today
1149    //    (`Ty::List("A")` vs `Ty::List("B")`, `Ty::Handle("A")` vs
1150    //    `Ty::Handle("B")` — see the comment on that arm above). Telling
1151    //    those apart needs the same nesting-vs-leaf classification as
1152    //    option 2 below anyway, so it buys nothing extra — and what it
1153    //    gives up is real: `debug_assert!` is compiled out of release
1154    //    builds and only fires if some runtime call site actually exercises
1155    //    the new variant pair, so an untested new variant sails through
1156    //    silently. A compile-time forcing function catches it before any
1157    //    test even has to think to exercise it.
1158    // 2. An exhaustive classification match with no wildcard arm (this),
1159    //    the same idiom `classify_erasure_shape` above uses for #1758 —
1160    //    chosen because it forces the decision at compile time, the moment
1161    //    the variant is added, independent of whether any test happens to
1162    //    construct it.
1163
1164    /// Whether a `Ty` variant nests another `Ty` — and so needs an explicit
1165    /// pointwise `(Variant, Variant)` arm in [`unify`] rather than falling
1166    /// through to the trailing `_ => Ty::Conflicted` — or is a nominal leaf
1167    /// for which reaching that wildcard on a same-variant mismatch (e.g.
1168    /// two different `Handle` kinds) is correct, not a bug.
1169    enum UnifyNestingShape {
1170        NestsTy,
1171        Leaf,
1172    }
1173
1174    /// Structural exhaustiveness guard (issue #1772), the pair-match
1175    /// sibling of `classify_erasure_shape` above (issue #1758): an
1176    /// *exhaustive* match over every current [`Ty`] variant with **no
1177    /// wildcard arm**, so this — and therefore `cargo test` for this crate
1178    /// — fails to compile the moment a new variant lands on `Ty`, until it
1179    /// is explicitly classified here. Today's five `NestsTy` variants
1180    /// (`Array`, `Map`, `Fn`, `Option`, `Weighted`) are exactly the ones
1181    /// that already carry an explicit pointwise arm in `unify` — see the
1182    /// companion test below, which proves the classification and `unify`'s
1183    /// real behavior agree rather than just asserting this match compiles.
1184    fn classify_unify_nesting(ty: &Ty) -> UnifyNestingShape {
1185        match ty {
1186            Ty::Fn(..) | Ty::Array(_) | Ty::Map(_, _) | Ty::Option(_) | Ty::Weighted(_) => {
1187                UnifyNestingShape::NestsTy
1188            }
1189            Ty::Int
1190            | Ty::Float
1191            | Ty::Bool
1192            | Ty::String
1193            | Ty::Content
1194            | Ty::Divert
1195            | Ty::List(_)
1196            | Ty::Struct(_)
1197            | Ty::Handle(_)
1198            | Ty::Range { .. }
1199            | Ty::Tower(_)
1200            | Ty::Unknown
1201            | Ty::Conflicted => UnifyNestingShape::Leaf,
1202        }
1203    }
1204
1205    #[test]
1206    fn unify_has_a_pointwise_arm_for_every_nesting_ty_variant() {
1207        // For every variant `classify_unify_nesting` calls `NestsTy`, build
1208        // two same-variant values whose nested element genuinely differs
1209        // and confirm `unify` returns the pointwise join, not
1210        // `Ty::Conflicted`. A `NestsTy` variant whose `unify` arm is
1211        // missing (present or future) falls through the trailing wildcard,
1212        // and for a same-variant pair that wildcard result is never
1213        // correct — so `Ty::Conflicted` here is exactly the failure this
1214        // guard exists to catch (#1772).
1215        //
1216        // Same caveat as `erase_fn_rows_leaves_every_nominal_leaf_untouched`
1217        // above: the exhaustive match only forces a DECISION when a new
1218        // variant lands on `Ty`, it cannot force a CORRECT one — a future
1219        // nesting variant classified `NestsTy` here still needs a row added
1220        // to this table by hand to actually prove `unify` handles it.
1221        let nesting_pairs: [(Ty, Ty, Ty); 5] = [
1222            (
1223                Ty::Array(Box::new(Ty::Int)),
1224                Ty::Array(Box::new(Ty::Float)),
1225                Ty::Array(Box::new(Ty::Float)),
1226            ),
1227            (
1228                Ty::Map(Box::new(Ty::Int), Box::new(Ty::String)),
1229                Ty::Map(Box::new(Ty::Unknown), Box::new(Ty::String)),
1230                Ty::Map(Box::new(Ty::Int), Box::new(Ty::String)),
1231            ),
1232            (
1233                Ty::Option(Box::new(Ty::Int)),
1234                Ty::Option(Box::new(Ty::Float)),
1235                Ty::Option(Box::new(Ty::Float)),
1236            ),
1237            (
1238                Ty::Weighted(Box::new(Ty::Int)),
1239                Ty::Weighted(Box::new(Ty::Float)),
1240                Ty::Weighted(Box::new(Ty::Float)),
1241            ),
1242            (
1243                fn_ty(&[Ty::Int], Ty::Int),
1244                fn_ty(&[Ty::Float], Ty::Int),
1245                fn_ty(&[Ty::Float], Ty::Int),
1246            ),
1247        ];
1248
1249        for (x, y, expected) in &nesting_pairs {
1250            assert!(
1251                matches!(classify_unify_nesting(x), UnifyNestingShape::NestsTy),
1252                "{x:?} must classify as NestsTy to belong in this table"
1253            );
1254            let joined = unify(x, y);
1255            assert_ne!(
1256                joined,
1257                Ty::Conflicted,
1258                "unify({x:?}, {y:?}) fell through the pair-match wildcard \
1259                 to Conflicted — a same-variant nesting pair must unify \
1260                 pointwise instead (#1772)"
1261            );
1262            assert_eq!(
1263                &joined, expected,
1264                "unify({x:?}, {y:?}) did not join pointwise on the nested \
1265                 element"
1266            );
1267        }
1268    }
1269
1270    #[test]
1271    fn assignable_ignores_effect_rows_but_not_the_rest_of_the_type() {
1272        // The step-2 guarantee: a fn value created at *any* target fits a
1273        // slot declared `fn(int): int`, whatever row the annotation carries
1274        // (an annotation's row is always the top element). A structural
1275        // `unify(param, arg) == param` test fails all three of these.
1276        let declared = fn_ty(&[Ty::Int], Ty::Int);
1277        assert!(assignable(
1278            &declared,
1279            &fn_ty_from(&[Ty::Int], Ty::Int, &[1])
1280        ));
1281        assert!(assignable(
1282            &fn_ty_from(&[Ty::Int], Ty::Int, &[1]),
1283            &fn_ty_from(&[Ty::Int], Ty::Int, &[2])
1284        ));
1285        assert!(assignable(
1286            &Ty::Array(Box::new(declared.clone())),
1287            &Ty::Array(Box::new(fn_ty_from(&[Ty::Int], Ty::Int, &[7])))
1288        ));
1289        // Everything the structural test used to reject is still rejected.
1290        assert!(!assignable(
1291            &declared,
1292            &fn_ty_from(&[Ty::String], Ty::Int, &[1])
1293        ));
1294        assert!(!assignable(&declared, &fn_ty_from(&[], Ty::Int, &[1])));
1295        assert!(!assignable(&declared, &Ty::Int));
1296        assert!(!assignable(&Ty::Int, &Ty::Float));
1297        // …and the one legal directional numeric coercion still passes.
1298        assert!(assignable(&Ty::Float, &Ty::Int));
1299    }
1300
1301    #[test]
1302    fn ref_assignable_rejects_the_widening_assignable_permits() {
1303        // Issue #1995/#1920: `assignable(Float, Int)` is `true` (by-value
1304        // widening), but a `ref` slot must reject it — the exact soundness
1305        // hole the ruling closed.
1306        assert!(assignable(&Ty::Float, &Ty::Int));
1307        assert!(!ref_assignable(&Ty::Float, &Ty::Int));
1308        // The reverse direction was already rejected by `assignable` and
1309        // stays rejected here.
1310        assert!(!ref_assignable(&Ty::Int, &Ty::Float));
1311        // Identical types still match invariantly.
1312        assert!(ref_assignable(&Ty::Int, &Ty::Int));
1313        assert!(ref_assignable(&Ty::Float, &Ty::Float));
1314        // Row-insensitivity is preserved: two `fn(): int` values from
1315        // different creation targets still match a `ref` slot declared
1316        // `fn(int): int`.
1317        assert!(ref_assignable(
1318            &fn_ty(&[Ty::Int], Ty::Int),
1319            &fn_ty_from(&[Ty::Int], Ty::Int, &[1])
1320        ));
1321        // But a structurally different fn type still does not.
1322        assert!(!ref_assignable(
1323            &fn_ty(&[Ty::Int], Ty::Int),
1324            &fn_ty_from(&[Ty::String], Ty::Int, &[1])
1325        ));
1326    }
1327
1328    #[test]
1329    fn ref_assignable_treats_nested_unknown_as_a_wildcard() {
1330        // Issue #1995 review finding (BLOCKING): a nested `Unknown` — the
1331        // element type of an empty `#[]` array literal, or `none`'s
1332        // `Option<Unknown>` — is "element type unknown", not "element type
1333        // wrong". Raw structural `==` used to reject both against a
1334        // concrete `ref` slot; the wildcard reading accepts them.
1335        assert!(ref_assignable(
1336            &Ty::Array(Box::new(Ty::Float)),
1337            &Ty::Array(Box::new(Ty::Unknown))
1338        ));
1339        assert!(ref_assignable(
1340            &Ty::Option(Box::new(Ty::Float)),
1341            &Ty::Option(Box::new(Ty::Unknown))
1342        ));
1343        // Wildcard applies in either direction.
1344        assert!(ref_assignable(
1345            &Ty::Array(Box::new(Ty::Unknown)),
1346            &Ty::Array(Box::new(Ty::Float))
1347        ));
1348        // Nested inside a Map, on either the key or the value side.
1349        assert!(ref_assignable(
1350            &Ty::Map(Box::new(Ty::String), Box::new(Ty::Int)),
1351            &Ty::Map(Box::new(Ty::Unknown), Box::new(Ty::Unknown))
1352        ));
1353        // A genuine concrete mismatch one level down is still rejected —
1354        // the wildcard reading applies only to `Unknown`, never as a
1355        // general "give up and accept" escape hatch.
1356        assert!(!ref_assignable(
1357            &Ty::Array(Box::new(Ty::Float)),
1358            &Ty::Array(Box::new(Ty::Int))
1359        ));
1360        assert!(!ref_assignable(
1361            &Ty::Option(Box::new(Ty::Float)),
1362            &Ty::Option(Box::new(Ty::String))
1363        ));
1364    }
1365
1366    #[test]
1367    fn fn_unify_is_order_independent() {
1368        // Extends the #627 order-independence property to `Fn` rows: every
1369        // permutation of observations must reach the same join.
1370        let a = fn_ty(&[Ty::Int, Ty::String], Ty::Int);
1371        let b = fn_ty(&[Ty::Float, Ty::String], Ty::Unknown);
1372        let c = fn_ty(&[Ty::Unknown, Ty::String], Ty::Float);
1373        let expected = fn_ty(&[Ty::Float, Ty::String], Ty::Float);
1374        let orderings: [[&Ty; 3]; 6] = [
1375            [&a, &b, &c],
1376            [&a, &c, &b],
1377            [&b, &a, &c],
1378            [&b, &c, &a],
1379            [&c, &a, &b],
1380            [&c, &b, &a],
1381        ];
1382        for ordering in orderings {
1383            assert_eq!(
1384                unify_all(ordering.iter().map(|t| (*t).clone())),
1385                expected,
1386                "order {ordering:?} must reach the same join"
1387            );
1388        }
1389    }
1390
1391    #[test]
1392    fn fn_conflict_detection_is_order_independent() {
1393        // A genuine row conflict (int vs string in the same slot) must be
1394        // detected regardless of observation order, and must never heal.
1395        let a = fn_ty(&[Ty::Int], Ty::Int);
1396        let b = fn_ty(&[Ty::String], Ty::Int);
1397        let u = fn_ty(&[Ty::Unknown], Ty::Unknown);
1398        let expected = fn_ty(&[Ty::Conflicted], Ty::Int);
1399        let orderings: [[&Ty; 3]; 6] = [
1400            [&a, &b, &u],
1401            [&a, &u, &b],
1402            [&b, &a, &u],
1403            [&b, &u, &a],
1404            [&u, &a, &b],
1405            [&u, &b, &a],
1406        ];
1407        for ordering in orderings {
1408            assert_eq!(
1409                unify_all(ordering.iter().map(|t| (*t).clone())),
1410                expected,
1411                "order {ordering:?} must detect the row conflict"
1412            );
1413        }
1414    }
1415
1416    #[test]
1417    fn fn_display_is_the_reserved_written_form() {
1418        assert_eq!(fn_ty(&[Ty::Int], Ty::Int).display(), "fn(int): int");
1419        assert_eq!(
1420            fn_ty(&[Ty::Int, Ty::String], Ty::Bool).display(),
1421            "fn(int, string): bool"
1422        );
1423        assert_eq!(fn_ty(&[], Ty::Float).display(), "fn(): float");
1424    }
1425
1426    // ─── T1d-2 `Ty::Handle` (docs/t1d-spec.md §3) ──────────────────────
1427
1428    #[test]
1429    fn handle_same_kind_unifies_to_itself() {
1430        let h = Ty::Handle("AudioInstance".to_string());
1431        assert_eq!(unify(&h, &h), h);
1432        assert_eq!(unify(&Ty::Unknown, &h), h);
1433        assert_eq!(unify(&h, &Ty::Unknown), h);
1434    }
1435
1436    #[test]
1437    fn handle_cross_kind_is_conflicted_not_unknown() {
1438        // #627 lattice: a genuinely different handle kind is a structural
1439        // mismatch, exactly like `int` vs `string` — never a silent
1440        // `Unknown` degradation.
1441        let a = Ty::Handle("AudioInstance".to_string());
1442        let b = Ty::Handle("Timer".to_string());
1443        assert_eq!(unify(&a, &b), Ty::Conflicted);
1444        assert_eq!(unify(&b, &a), Ty::Conflicted);
1445    }
1446
1447    #[test]
1448    fn handle_vs_other_concrete_type_is_conflicted() {
1449        let h = Ty::Handle("AudioInstance".to_string());
1450        assert_eq!(unify(&h, &Ty::Int), Ty::Conflicted);
1451        assert_eq!(unify(&Ty::String, &h), Ty::Conflicted);
1452        assert_eq!(unify(&h, &Ty::Array(Box::new(Ty::Int))), Ty::Conflicted);
1453    }
1454
1455    #[test]
1456    fn handle_conflicted_absorbs_everything() {
1457        let h = Ty::Handle("AudioInstance".to_string());
1458        assert_eq!(unify(&Ty::Conflicted, &h), Ty::Conflicted);
1459        assert_eq!(unify(&h, &Ty::Conflicted), Ty::Conflicted);
1460    }
1461
1462    #[test]
1463    fn handle_display_carries_the_kind_name() {
1464        assert_eq!(
1465            Ty::Handle("AudioInstance".to_string()).display(),
1466            "Handle<AudioInstance>"
1467        );
1468    }
1469
1470    // ─── NS-A1 `Ty::Option` (docs/stdlib-spec.md §1.4) ─────────────────
1471
1472    fn opt(t: Ty) -> Ty {
1473        Ty::Option(Box::new(t))
1474    }
1475
1476    #[test]
1477    fn option_unifies_pointwise_like_array() {
1478        assert_eq!(opt(Ty::Int).display(), "Option<int>");
1479        assert_eq!(unify(&opt(Ty::Int), &opt(Ty::Int)), opt(Ty::Int));
1480        // The int -> float directional join applies inside the element.
1481        assert_eq!(unify(&opt(Ty::Int), &opt(Ty::Float)), opt(Ty::Float));
1482        // Unknown element absorbs a concrete one.
1483        assert_eq!(unify(&opt(Ty::Unknown), &opt(Ty::String)), opt(Ty::String));
1484        assert_eq!(unify(&Ty::Unknown, &opt(Ty::Int)), opt(Ty::Int));
1485    }
1486
1487    #[test]
1488    fn option_vs_bare_type_is_conflicted_the_ruled_strictness() {
1489        // `Option<T> ≠ T` — everywhere, no display-boundary forgiveness in
1490        // the lattice (that's Track B4, cut by position at a later layer).
1491        assert_eq!(unify(&opt(Ty::Int), &Ty::Int), Ty::Conflicted);
1492        assert_eq!(unify(&Ty::Int, &opt(Ty::Int)), Ty::Conflicted);
1493        assert_eq!(unify(&opt(Ty::String), &Ty::String), Ty::Conflicted);
1494        assert_eq!(
1495            unify(&opt(Ty::Int), &Ty::Array(Box::new(Ty::Int))),
1496            Ty::Conflicted
1497        );
1498    }
1499
1500    #[test]
1501    fn option_nests_like_any_parameterized_builtin() {
1502        // Option<Option<int>> is a real type; joining it with Option<int>
1503        // conflicts pointwise in the element slot.
1504        let nested = opt(opt(Ty::Int));
1505        assert_eq!(nested.display(), "Option<Option<int>>");
1506        assert_eq!(unify(&nested, &nested), nested);
1507        assert_eq!(unify(&nested, &opt(Ty::Int)), opt(Ty::Conflicted));
1508    }
1509
1510    #[test]
1511    fn option_element_conflict_stays_inside_the_element() {
1512        // Mirrors Array(Conflicted): the Option shape survives, the element
1513        // slot carries the conflict for the strict classify walk to find.
1514        assert_eq!(unify(&opt(Ty::Int), &opt(Ty::String)), opt(Ty::Conflicted));
1515    }
1516
1517    // ─── NS-A5 `Ty::Range` + the NonEmptyRange refinement (F7/F8) ──────
1518
1519    fn range(non_empty: bool) -> Ty {
1520        Ty::Range { non_empty }
1521    }
1522
1523    #[test]
1524    fn range_display_names_the_refinement() {
1525        assert_eq!(range(false).display(), "range");
1526        assert_eq!(range(true).display(), "NonEmptyRange");
1527        assert_eq!(opt(range(true)).display(), "Option<NonEmptyRange>");
1528    }
1529
1530    #[test]
1531    fn range_evidence_joins_with_and() {
1532        // Evidence survives only if EVERY observed source carries it — the
1533        // sound direction (a join can lose evidence, never fabricate it).
1534        assert_eq!(unify(&range(true), &range(true)), range(true));
1535        assert_eq!(unify(&range(true), &range(false)), range(false));
1536        assert_eq!(unify(&range(false), &range(true)), range(false));
1537        assert_eq!(unify(&range(false), &range(false)), range(false));
1538        // Unknown is the identity, refinement bit included.
1539        assert_eq!(unify(&Ty::Unknown, &range(true)), range(true));
1540    }
1541
1542    #[test]
1543    fn range_vs_other_concrete_is_conflicted() {
1544        // A range never coerces — not to int, not to Array<int>, and the
1545        // refinement is a view over Range, never a separate kind.
1546        assert_eq!(unify(&range(false), &Ty::Int), Ty::Conflicted);
1547        assert_eq!(
1548            unify(&range(true), &Ty::Array(Box::new(Ty::Int))),
1549            Ty::Conflicted
1550        );
1551        assert_eq!(unify(&opt(range(true)), &range(true)), Ty::Conflicted);
1552    }
1553
1554    #[test]
1555    fn range_evidence_join_is_order_independent() {
1556        let orderings: [&[Ty]; 3] = [
1557            &[
1558                Ty::Range { non_empty: true },
1559                Ty::Range { non_empty: false },
1560            ],
1561            &[
1562                Ty::Range { non_empty: false },
1563                Ty::Range { non_empty: true },
1564            ],
1565            &[
1566                Ty::Unknown,
1567                Ty::Range { non_empty: true },
1568                Ty::Range { non_empty: false },
1569            ],
1570        ];
1571        for ordering in orderings {
1572            assert_eq!(
1573                unify_all(ordering.iter().cloned()),
1574                Ty::Range { non_empty: false },
1575                "order {ordering:?} must lose the evidence at the join"
1576            );
1577        }
1578    }
1579
1580    // ─── NS-A1 `or`-coalescing typing (F19 — typing only, no spelling) ──
1581
1582    #[test]
1583    fn coalesce_option_then_value_collapses_to_the_value_type() {
1584        assert_eq!(coalesce(&opt(Ty::Int), &Ty::Int), Ok(Ty::Int));
1585        // The directional numeric join applies.
1586        assert_eq!(coalesce(&opt(Ty::Int), &Ty::Float), Ok(Ty::Float));
1587        assert_eq!(coalesce(&opt(Ty::String), &Ty::String), Ok(Ty::String));
1588    }
1589
1590    #[test]
1591    fn coalesce_two_options_keeps_optionality_for_chaining() {
1592        assert_eq!(coalesce(&opt(Ty::Int), &opt(Ty::Int)), Ok(opt(Ty::Int)));
1593        assert_eq!(coalesce(&opt(Ty::Int), &opt(Ty::Float)), Ok(opt(Ty::Float)));
1594    }
1595
1596    #[test]
1597    fn coalesce_chains_left_associatively() {
1598        // a.get(k) or a.get(k2) or 0  ⟶  ((Option<int> or Option<int>) or int)
1599        let step1 = coalesce(&opt(Ty::Int), &opt(Ty::Int)).expect("chain step");
1600        assert_eq!(step1, opt(Ty::Int));
1601        assert_eq!(coalesce(&step1, &Ty::Int), Ok(Ty::Int));
1602    }
1603
1604    #[test]
1605    fn coalesce_mismatched_fallback_is_an_error() {
1606        assert_eq!(
1607            coalesce(&opt(Ty::Int), &Ty::String),
1608            Err(CoalesceError::Mismatch {
1609                element: Ty::Int,
1610                fallback: Ty::String,
1611            })
1612        );
1613    }
1614
1615    #[test]
1616    fn coalesce_non_option_left_is_an_error() {
1617        assert_eq!(
1618            coalesce(&Ty::Int, &Ty::Int),
1619            Err(CoalesceError::LeftNotOption(Ty::Int))
1620        );
1621        assert_eq!(
1622            coalesce(&Ty::Array(Box::new(Ty::Int)), &Ty::Int),
1623            Err(CoalesceError::LeftNotOption(Ty::Array(Box::new(Ty::Int))))
1624        );
1625    }
1626
1627    #[test]
1628    fn coalesce_gradual_escapes() {
1629        // Unknown left: nothing to check — gradual posture.
1630        assert_eq!(coalesce(&Ty::Unknown, &Ty::Int), Ok(Ty::Unknown));
1631        // Unknown element/fallback join without erroring.
1632        assert_eq!(coalesce(&opt(Ty::Unknown), &Ty::Int), Ok(Ty::Int));
1633        assert_eq!(coalesce(&opt(Ty::Int), &Ty::Unknown), Ok(Ty::Int));
1634        // Conflicted stays absorbing, never "heals" into an error report
1635        // here (strict-mode escape reporting owns it).
1636        assert_eq!(coalesce(&Ty::Conflicted, &Ty::Int), Ok(Ty::Conflicted));
1637        assert_eq!(coalesce(&opt(Ty::Conflicted), &Ty::Int), Ok(Ty::Conflicted));
1638    }
1639
1640    #[test]
1641    fn fn_composes_with_handle_typed_params() {
1642        // Ty::Fn composition with handle-typed params (T1d-2): the
1643        // pre-existing pointwise Fn unify needs no special-casing — each
1644        // row slot unifies via the generic `unify` recursion, so a
1645        // handle-typed param slot behaves exactly like any other Ty.
1646        let a = fn_ty(&[Ty::Handle("AudioInstance".to_string())], Ty::Bool);
1647        let b = fn_ty(&[Ty::Handle("AudioInstance".to_string())], Ty::Bool);
1648        assert_eq!(unify(&a, &b), a);
1649
1650        let mismatched = fn_ty(&[Ty::Handle("Timer".to_string())], Ty::Bool);
1651        assert_eq!(unify(&a, &mismatched), fn_ty(&[Ty::Conflicted], Ty::Bool));
1652    }
1653}