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}