Skip to content

Encode unknown generic type parameters with (declare-forall-sort) - #93

Draft
coeff-aij wants to merge 161 commits into
coord-e:mainfrom
coeff-aij:forall-sort
Draft

coeff-aij wants to merge 161 commits into
coord-e:mainfrom
coeff-aij:forall-sort

Conversation

@coeff-aij

Copy link
Copy Markdown
Collaborator

This PR introduces an extension to SMT-LIB2 to encode unknown type parameters (like T in f<T: PartialOrd>()) as a universally quantified sort.

For example, a universally quantified sort a0 is first declared using (declare-forall-sort a0) and then used everywhere like a normal sort.
This feature lays the groundwork for the future verification of generic functions involving unknown types.

Example

  • Input:
#[thrust_macros::context]
trait A {
    #[thrust_macros::requires(Self::p(x))]
    #[thrust_macros::ensures(Self::p(result))]
    fn f(&self, x: i64) -> i64;

    #[thrust_macros::predicate]
    fn p(x: i64) -> bool;
}

#[thrust_macros::requires(T::p(x))]
#[thrust_macros::ensures(T::p(result))]
fn target<T: A>(a: &T, x: i64) -> i64 {
    let mut v = x;
    let mut i = 0;
    while i < 3 {
        v = a.f(v);
        i += 1;
    }

    v
}

fn main() {}
  • output:
(set-logic HORN)

(declare-forall-sort a0)

; span=refine_fn_def 
(declare-fun p0 (Int a0) Bool)

; span=refine_fn_def 
(declare-fun p1 (Int a0 Int) Bool)

... (snip)

Comment thread src/rty/params.rs Outdated
Comment thread src/chc.rs Outdated
Comment on lines +1836 to +1837
pub forall_sorts: Vec<ForallSortIdx>,
pub num_forall_sort_idx: ForallSortIdx,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

forall_sorts is actually redundant because we know it only contains the range 0..num_forall_sort_idx.

@coeff-aij
coeff-aij force-pushed the forall-sort branch 4 times, most recently from 2f6c77f to 9824e3a Compare May 29, 2026 06:19
@coeff-aij
coeff-aij force-pushed the forall-sort branch 3 times, most recently from d459b92 to 4f84b7a Compare June 3, 2026 15:27
coeff-aij and others added 30 commits September 7, 2026 06:03
The example was Unsat on its own account, independently of the predicate
routing bug fixed in the previous commit: `Y(-1)` violated `repeat`'s
precondition `T::p(*x)` at `repeat(&mut y, 5)`. Call `y.g()` first, whose
`ensures(Self::p(!self))` establishes the precondition.

With both fixed, PCSat still times out inferring the invariant of the
loop in the generic `repeat`, so spell it out the way
simple_loop_self_mut.rs does: `T::p(*b)` plus the prophecy link
`!b == !x.at_entry()`, with `x` rebound to `b` so the invariant can name
both the current `&mut` and the entry value.

The fail twin tests/ui/fail/traits/simple_loop_call_multi.rs drops the
`y.g()` call again.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
simple_loop_self_mut.rs put `requires(Self::p(*self, !self, x))` on `f`, a
precondition on the callee's own prophecy. Each `a.f(v)` in the loop reborrows
`a` with a fresh, universally quantified final value, so no invariant can
establish that for an abstract `p` (the commit adding the file, 52ad0ae, already
marked it as not supported). Use the `&mut` analogue of simple_loop_self.rs
instead: `p(self, x)` with `requires(Self::p(*self, x))` and
`ensures(Self::p(!self, result))`. PCSat still needs the loop invariant spelled
out, so the loop carries `invariant!(|b: &mut T, ...| T::p(*b, v) && !b == !a.at_entry())`
with `a` rebound to `b`, because an `invariant!` cannot name both views of the
same `&mut` argument.

Its fail twin uses `v = b.f(v) + 1`. The simple_loop_self twin weakens the
precondition to `true`; the rebuilt PCSat refutes it in 0.3s where the previous
build timed out.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The tests keep their header comment describing which adapter pattern each
one isolates; the prefix added nothing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A generic function's spec calling `<Bar<T> as Foo>::valid(..)`, the
predicate of a generic impl on a type that still contains the owner's type
parameter, resolves through `Instance::try_resolve` to the impl item and
uses its `define-fun` body; only a call on the type parameter itself
(`T::valid`) falls back to a forall predicate. This is the intended
behaviour of the routing changed in 4b4f0ac; the pair records it (both the
qualified-path and the `Bar::<T>::valid` spelling resolve the same way).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The impl predicate bodies of these tests are SMT strings that refer to the
inner type's trait predicates by their hash-suffixed names (`q_p_<hash><a0>`).
The hash comes from the def path, which includes the crate name and hence the
file name, so 323a3b8 left every reference stale and PCSat failed with
`q_p_<old hash><a0> is not bound`. Regenerated with
.experimental/extract-predicate-hashes.py for the eight affected pairs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The file, type and function names say what each test isolates; drop the
"Probe:" headers and the inline explanations, keep the one-line Rust
renderings of the SMT predicate bodies, and shorten the one remaining FIXME.
Remove option_field_reborrow_refmut: it only worked around a solver timeout
that the current PCSat build no longer has and matches no adapter pattern.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
They were written to report macro and annotation typing problems, not to
guard behaviour, and the problems they describe are settled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each pass file verifies against the `Iterator` specification it declares, and
its fail twin breaks only the property under test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Ghost<T>` was known to the analyzer only through `impl<T: Model> Model for Ghost<T>`,
so `resolve_model_ty` had to normalize `<Ghost<T> as Model>::Ty` to learn anything about
it. In a function generic over `T` that normalization fails -- without a `T: Model` bound
rustc cannot select the impl -- and the fallback hands back the unmodeled Rust type, a
`PhantomData` newtype, which lowers to the singleton sort `(own (),)`. A parameter of
singleton sort is not bound in `relate_sub_param_types`, while the refinement lifted from
`#[requires(g == v)]` still names it, so building the entry obligation panicked with
`unbound var $0`.

Mark the struct and lower it structurally, the way `Closure<T>` already is: in the logic a
`Ghost<T>` is its content, so `model_adt` returns the content type directly and no trait
selection is involved. The `Model` impl stays, since a specification parameter still
lowers to `<Ghost<T> as Model>::Ty` and has to name it; a TODO on both sides records that
the two have to agree.

The `fail` twin still reports nothing: a generic function's parameter predicates never
occur in a clause head, so its body is discharged vacuously and its callers constrain a
separate pair -- the same gap the `fn_poly*` tests sit in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
two_loops.rs and multi_params.rs time out while the loop invariants are being
inferred; these variants supply them by hand, as annot_simple_loop_self.rs does
for simple_loop_self.rs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The inference versions time out; these supply the inner loop's invariant by
hand, as annot_simple_loop_self.rs does for simple_loop_self.rs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… args

Generic function calls at concrete type arguments built a fresh function
type whose predicate variables were never constrained: the body was
analyzed once, with placeholder args, constraining a different set of
predicate variables. Call sites therefore learned nothing about the
callee, so assertions on returned values were left unchecked (unsound).

Re-analyze the monomorphized body at each concrete instantiation
(DefTy::Generic now uses DeferredDefMode::Analyze) so the fresh contract
predicate variables are constrained by the body. Basic-block types are
registered per analysis instance (AnalysisKey), so nested analyses of the
same def (e.g. recursive generics) no longer clobber each other, and
calls still carrying type parameters keep using the placeholder contract.

This fixes the known-bug in adt_generic_enum_helper_return and makes 18
previously silently-accepted fail tests report Unsat.
`InstantiationKey` carries `caller_def_id`, so a generic body is re-analyzed once per
(type arguments, calling function) rather than once per monomorphization. Record why it
cannot simply be dropped -- the caller's `owner_fn_id` is what interprets a `ParamTy`'s
index, so removing it would silently conflate type parameters declared in different
items -- and what would have to change for this to become a monomorphization cache.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two shapes that no test covered, both of which a change to how a def is routed
between the concrete and the generic analysis would silently alter.

A generic function whose signature does not mention its type parameter is analyzed
with a concrete contract, so a caller that is itself generic still learns its result.
Routing such a def to the generic analysis instead would leave the contract to a
re-analysis that a call at type arguments which are still type parameters never
triggers, and the caller would accept anything from that call onwards.

An annotated generic function contributes its contract at such a call site, where an
inferred one does not. The existing fn_poly_annot tests all call from `main` at
concrete arguments, which is the case that does not distinguish the two.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`collect_forall_defaults` decided which `default_<sort>` constants to declare by
scanning the stored clause AST for `Term::ForallDefault`, and treated
`Term::ArrayEmpty` as a leaf. But the SMT-LIB2 writer synthesises
`Term::default_for(elem)` for an empty array at print time, so an array over an
abstract element sort emitted `default_a0` with no declaration and CoAR rejected
the file with `default_a0 is not bound`. Ask `default_for` itself which defaults
the writer will reference, so the two cannot drift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… impl

Lifting a formula out of an impl method replaced the type `Self` with the impl's
self type, turning the projection `Self::Item` into `<Wrap<I>>::Item`. `Self` in a
trait impl says which trait to look in; a bare ADT does not, so rustc rejected the
lifted `#[thrust::formula_fn]` with `E0223 ambiguous associated type` — and the
type annotations of nearby ghost terms failed to infer as a consequence. Carry the
implemented trait along and emit `<Wrap<I> as Iterator>::Item`. The trait-method
branch is unaffected: it substitutes a type parameter, whose bounds resolve the
projection on their own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Registering the contract of an `Fn`-bounded type parameter read the parameter's
index in the generics of the function being analysed. A bound declared on an impl
block was taken straight from `predicates_of(impl)`, which no instantiation has
touched, so an external call into that impl looked the impl's own parameter up in
the caller's table and hit `unknown type param idx`.

Resolve such a parameter through the arguments the analysis runs with before using
it, for both the sort it builds and the key it registers under. A parameter that
resolves to another generic caller's parameter is now recorded against that one;
a parameter that resolves to a concrete callable needs no parameter-keyed contract
at all, since the callable carries its own.

`build_closure_type_for_param` tried to do this by instantiating the `ParamTy`
itself, which is an identity by construction -- instantiating a `ParamTy` can only
yield a `ParamTy`, never the concrete argument. Bind the type instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An `FnMut` closure receives its upvars behind a `&mut`, and its precondition took that whole
`Mut` pair. The pair's prophecy is still unconstrained where the precondition has to be
discharged -- the borrow resolves it only after the call -- so `pre!(f(..))`, which names the
upvars as they are, could never reach the obligation the call raises, and every `FnMut`
precondition needed a `forall` over the prophecy before it said anything at all.

Take the current value of the upvars in the precondition instead. A precondition is a property
of the state the call starts from, and the two states stay related by the postcondition, which
is unchanged. `closure_trait_call` now reports the `Fn` trait it resolved, so the projection
keys off that rather than off the shape of the receiver type.

Specs that spelled the receiver out as `Mut::new(f, g)` still mean what they meant, since the
projection drops `g`. The two-call tests gain the precondition they were missing: their upvars
have to satisfy it in every state the calls start from, which needs one binder now instead of
two. Their `fail` twins keep their old specification -- with the quantified precondition the
solver does not answer the negative direction within 180s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Twelve pass/fail pairs whose specifications verify in both directions on this
branch: a ghost `Seq` grown by a loop (`ghost_seq_loop`, and its `forall`
variant), the same history carried through a trait loop (`traits/ghost_produced`,
`traits/ghost_step_chain`, `traits/ghost_count`), fold specified with an `Fn`
and that history (`traits/fold_fn_ghost`, `_noiter`, `_call`, and `_call_law`,
the last discharging a call site by assuming an induction principle), and Map
with the closure's precondition stated three ways (`traits/map_fn_uncond_pre`
unconditionally, `traits/map_fn_concrete_item` at a concrete item type, and
`traits/map_ext_total_pre` over the produced history with a preservation law).

Each was run here after copying, all 24 files green. The remaining exploration
on iterator-adapters is not adopted: `traits/map` and `traits/map_fn` are the
superseded non-inductive Map invariant and still Unsat, and `traits/fold`,
`traits/fold_fn`, `traits/fuse`, `traits/map_no_closure` and `traits/skip` have
no fail twin to pin the other direction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The twin used to drop the empty-history precondition, which is the base
case of the loop invariant: it goes Unsat even when nothing carries
`item_ok` across an iteration, so it never pinned the property the pass
file is named for. Removing `next`'s `item_ok` postcondition instead
takes away the fact's only source.

Weakening the loop invariant or shifting the postcondition's index both
leave the solver at Unknown -- refuting those needs it to reason about
every interpretation of an abstract predicate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`trait_assoc_type_spec` asked Thrust to prove `nonempty`'s postcondition from an
empty default body. A default method is checked once against the abstract
predicate, so the claim has to hold for every implementor, and nothing in the
trait says `produces` is non-empty -- the `pass` file was claiming a capability
Thrust does not have (re-checking default bodies per impl) rather than pinning
one it does. Declaring `nonempty` without a body moves the obligation to the
impl, where `produces` is concrete, and the fail twin's `false` predicate still
refutes it.

`trait_default_method_spec` keeps the rejected shape as its own pair, so the
distinction between the two is pinned rather than lost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An empty array at a type-parameter element sort needs some value of that sort
to sit in the cells past the length. The value is arbitrary and nothing may
observe it, but the query still has to name it, and the name was spelled
`(declare-const default_aN aN)`. Under `(set-logic HORN)` a top-level
`declare-const` is a symbol the solver is being asked to *build*, and there is
no way to build a value of an uninterpreted sort, so the query stopped at the
parser instead of reaching the solver at all.

The universal reading is the one that matches the intent: nothing may observe
the padding, so the clauses have to hold whatever it is. Spell it as a nullary
`declare-forall-fun`, which is the form the solver already provides for a
constant of an abstract sort.

This is what kept a generic `FromIterator<T> for Vec<T>` from being verified:
`Seq::singleton(i)` at an abstract `Item` reaches the empty array, so the whole
collect query died at the parser.

Also let the sort collector recurse into the default term the writer
synthesises for an empty array, matching the collector that decides which
defaults to declare. The two disagreed; the emitted declarations are identical
across every tracked test either way, so this only keeps them from drifting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Repeated runs of thrust-rustc on the same input could emit .smt2 files
that differed only in the numbering/ordering of universally quantified
clause variables and in the order of forall-fun (predicate variable)
and datatype declarations. The declared and clause set was always the
same; only how it got labeled and ordered varied between runs.

Three call sites iterated a hash-based container to decide the order
in which fresh output identifiers get assigned, and Rust's default
hasher is randomly seeded per process:

- `bind_locals` returned function-parameter bindings in a `HashMap`;
  `type_return`/`type_goto`/`install_inherited_bb_ty` iterated it to
  assign clause variables, so the same parameter could get a different
  `TermVarIdx` on every run. Switched to a `BTreeMap` keyed on the
  (`#[orderable]`) `FunctionParamIdx`, so variables are always assigned
  in parameter-index order.
- `register_enum_defs`'s `EnumCollector` gathered referenced enum
  `DefId`s in a `HashSet` before registering their datatype
  declarations; registration order is emission order. Switched to
  `FxIndexSet`, which keeps first-visit order instead of hash order.
- `refine_local_defs` collected trait-method spec keys (refined first,
  so their predicate variables get the lowest numbers) in a `HashSet`.
  Switched to a `Vec`; each key is only ever added once, so this only
  drops the incidental hash order, not any deduplication the analysis
  relied on.

Each change swaps only the container type, not what gets inserted into
it or when — the fix is at the point where output identifiers are
assigned, not in the smt2 printer, so no reordering of meaning happens
downstream of it.

Verified with `THRUST_SOLVER=/bin/false` (no docker, no solver): all
168 tests/ui/pass and 164 tests/ui/fail files dump byte-identical
`thrust_output.smt2` across 5 runs each, and a 10-run check across four
differently-shaped inputs (a two-parameter leaf function, a generic
trait impl, a many-predicate function, and a generic trait method)
comes back identical too. Before the fix, the same 5-run sweep on main
(2bf022d) showed 45 of the 168 pass files varying. For all 45, the
datatype/predicate-declaration/clause/define-fun counts and total line
counts against this branch's output match exactly, and the raw diffs
that motivated the fix are consistent variable-identity swaps (e.g. a
clause's `v21`/`v22` trading places along with the equations that name
them), never an added, dropped, or altered declaration or clause.

No measured performance cost: a 168-file no-solver sweep takes the
same ~19.7s on both this branch and main.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`3a3fbb1` stabilised the assignment of output identifiers, which is
enough for byte-identical dumps on `main`. On this lineage it is not:
108 of the 477 `tests/ui` files still emitted a different `.smt2` on
every run. Two containers decide an emission order and neither was
touched by that commit, both holding `ForallPred`s in a `HashSet`:

- `System::forall_pred_vars` is iterated to emit the
  `declare-forall-fun` blocks, so the blocks permuted.
- `System::compute_dependency`'s value sets are handed to
  `DepExistsPredVarDef`, whose `Display` iterates them to list a
  `declare-dep-exists-fun`'s dependencies, so those lists permuted.

Both become `BTreeSet`, which orders a `ForallPred` by its symbol and
then by its type-parameter and parameter sorts. The symbol is built by
`refine::stable_def_id_symbol` as `q_<name>_<def-path hash>`, so the key
is a function of the predicate's own definition path: it does not move
between runs, and — unlike a `DefId` index or an insertion counter — it
does not move when an unrelated item elsewhere in the crate is added or
reordered either.

The dependency sets are ordered where they are built rather than sorted
in `fmt`, so the printer still just walks what it is handed. With this,
every container the printer iterates is a `Vec`, an `IndexVec` or a
`BTreeSet`; the `HashMap`s left in `System` (`type_params_reverse`, and
`compute_dependency`'s outer map) are only ever looked up by key.

Verified with `THRUST_OUTPUT_DIR` and `THRUST_SOLVER=/bin/false` (no
solver, no docker), honouring each file's `//@compile-flags`, with the
comparator counting an absent dump as its own outcome rather than as
agreement: all 477 `tests/ui` files dump byte-identical `.smt2` across
11 runs in three sweeps spanning two rebuilds, and 0 produced no
output. Before, the same sweep showed 108 varying.

Content is unchanged. Comparing every one of the 477 dumps before and
after, once the dependency lists are sorted for comparison, the whole
multiset of lines is identical, and so are the declaration set, the
clause count, the `define-fun`/`define-fun-rec` counts and the total
line count. 383 files are byte-identical either way; the other 94 are
the same lines in a different order, with the `assert` sequence itself
unmoved. The pre-existing variance was ordering only: across 5 runs of
the old binary, no file's set of `q_*`, `p<N>`, `a<N>`, `v<N>` or
datatype identifiers differed.

No measured performance cost: a single-run 477-file sweep takes 73.6s
with the ordering imposed against 75.5s without it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
upstream 9569c71 brings native SMT sequences: `Seq<T>` lowers to `(Seq T)`
with `seq.++` / `seq.unit` / `seq.nth` / `seq.len` / `seq.extract` instead of
the `(array, length)` tuple, and the hand-written `seq_concat` definition the
writer used to emit is gone.

Fourteen files conflicted. How each was resolved:

src/chc/smtlib2.rs (1 hunk) -- upstream deletes the `define-fun-rec
seq_concat` loop, and the forall-predicate definitions this branch prints sit
inside the same removed block. Kept the forall-predicate loop, dropped the
seq_concat definitions.

src/chc/format_context.rs (2 hunks) -- same shape. Kept this branch's
`ArrayEmpty` arm, which recurses into `Term::default_for(elem)` so an empty
array's abstract padding is declared, and took upstream's new `SeqEmpty`
arm beside it; dropped the `SeqConcat` arm and the `seq_concat` formatter,
and kept `forall_pred`.

src/chc.rs (3 hunks) -- combined: `Sort::Forall` stays in the scalar arm of
`walk` while `Sort::Seq` joins `Box`/`Mut`; `instantiate_params` keeps this
branch's `forall_sort_resolver` parameter and gains a `Sort::Seq` arm;
`SeqConcatTerm` is deleted with upstream.

src/rty.rs (2 hunks) -- took HEAD, which drops upstream's `free_ty_params`
and `unify_ty_params` on `Type`. The conflict presents an empty HEAD side, so
it reads as an upstream addition, but these were deliberately removed on this
branch when type-parameter unification was replaced (6e3bf1b). Restoring them
does not compile: the per-constructor `free_ty_params` / `unify_ty_params` on
PointerType, TupleType, ArrayType, EnumType and FunctionType no longer exist,
and taking upstream's side yields thirteen E0599s. Nothing in the merged tree
calls either function; the only references are their own recursive calls.

tests/ui/{pass,fail}/{slice_first_mut,slice_last_mut,slice_methods,
slice_methods_mut,vec_2}.rs (1 hunk each) -- one line every time, this
branch's `COAR_IMAGE=coar:latest` against upstream rewriting the same
`//@rustc-env:` line without a pin. Took upstream's: every one of these ten is
now a native-sequence test, and the pinned image predates the sequence
support, so keeping the pin would guarantee a parse abort rather than pin
anything. Running them locally now needs a sequence-capable COAR_IMAGE in
the environment.

This commit does not build on its own; the two follow-ups complete it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`collect_forall_defaults` is this branch's, and upstream never touched it, so
the merge took it unchanged and it kept an arm for a term that no longer
exists. Dropping `SeqConcat` is forced; adding `SeqEmpty` is the choice.

`ArrayEmpty` recurses into `Term::default_for(elem)` because an empty array
has to name a padding value, and at a type-parameter element sort that value
is a forall default which must then be declared. `SeqEmpty` deliberately does
not: the native empty sequence prints as `(as seq.empty (Seq X))`, which is
well-sorted with no padding value, so there is nothing to collect.

`Sort::instantiate_params`, resolved in the merge, is the other half of this
and is worth naming because it would not have failed to compile. Its match
ends in `_ => {}`, so had the `Sort::Seq` arm been left out, a type parameter
occurring under a sequence sort would simply not have been instantiated. The
conflict happened to cover that arm; nothing else would have caught it.

The rest of the branch survives the two new variants. Every other match on
`chc::Sort` or `chc::Term` that ends in a catch-all is either upstream's, and
already handles `Sort::Seq` explicitly ahead of it (`fmt_sort_impl`,
`Function::sort`), or is a question a sequence answers correctly by falling
through: `deref` and `tuple_elem` panic, `as_tuple` / `as_datatype` /
`into_datatype` return `None`, and `is_singleton` is false for a sequence
whose length is not fixed. `Sort::walk` lists every constructor and has no
catch-all at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Sort::instantiate_params` ended in `_ => {}`. Every constructor that carries
a sort inside it was already named, so the arm only ever caught the scalars,
but nothing made that true: adding a constructor with a sort inside it would
have compiled and silently left type parameters under it uninstantiated.

Native sequences were exactly that case. `Sort::Seq` needed its arm, and it
got one only because the surrounding hunk happened to conflict during the
merge. Nothing else would have reported it.

Naming `Null | Int | Bool | String` costs nothing and makes the compiler
report the next one. `Sort::walk_impl` next door already reads this way.

Emission is unchanged: every ui test dumped with the driver built before and
after this commit, 468 queries, byte-identical, exit codes identical.

The other eleven catch-all arms over `chc::Sort` or `chc::Term` in the tree
are left alone. All eleven are upstream's own code and carry no local
divergence, so enumerating them would only widen the diff and cost a conflict
at the next merge; upstream already handles `Sort::Seq` explicitly ahead of
the catch-all where it matters (`fmt_sort_impl`, `Function::sort`), and the
rest are partial accessors and printers that a sequence answers correctly by
falling through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant