Never substitute bound type variables; defer rigid fundep improvements#1037
Open
nanavati wants to merge 20 commits into
Open
Never substitute bound type variables; defer rigid fundep improvements#1037nanavati wants to merge 20 commits into
nanavati wants to merge 20 commits into
Conversation
Collaborator
Author
|
Full-testsuite result at this PR's head (6d19cdb), as promised in the description:
The count reconciles with the base exactly: #1036's 18,493 passes + 17 new checks from this PR's pins (skolemization, bound-variable audit goldens, ModalCapture, and the noinline expectation now counting a third T0043) = 18,510. |
This was referenced Jul 10, 2026
nanavati
added a commit
to nanavati/bsc
that referenced
this pull request
Jul 12, 2026
The typeclass-coherence PRs (B-Lang-org#1035/B-Lang-org#1037) claim T0159-T0161, and whichever series merges second would collide. Take the next free numbers here proactively; the tags are otherwise arbitrary. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cqQdWKp7jr73GHQYnvhiq
krame505
reviewed
Jul 14, 2026
krame505
left a comment
Collaborator
There was a problem hiding this comment.
Relative diff nanavati/bsc@nanavati:bsc:claude/sat-batching-settlement...nanavati:claude/bound-variable-discipline seems okay to me.
nanavati
force-pushed
the
claude/bound-variable-discipline
branch
from
July 14, 2026 12:26
6d19cdb to
5ad78cb
Compare
Classic has had "class incoherent C a b" / "class coherent C a b" all
along -- and the Prelude uses both -- but BSV had no way to declare
either: every BSV typeclass got Cclass Nothing and followed the
global -incoherent-instance-matches flag. The annotation matters
where resolution is not forced: an incoherent class permits the
compiler to select an instance even when the choice could still
change under refinement (which catch-all dispatch idioms require),
while a coherent class forbids exactly that, regardless of the flag.
A BSV user writing such a class had only the global flag, which flips
every unannotated class in the compilation.
The syntax mirrors Classic:
typeclass incoherent TwoLevel#(type a, type b);
typeclass coherent Bits#(type a, numeric type n);
The words are soft keywords, matched by string in the position between
"typeclass" and the class name -- exactly like "dependencies" and
"determines" -- so they remain valid identifiers everywhere else and
no existing program can change meaning: typeclass names are uppercase,
so a lowercase word in that position could never have parsed before.
The annotation is threaded through ISTypeclass into the Cclass
coherence field, from which it already propagates cross-package.
Tests pin both directions: an incoherent-annotated BSV class keeps
relational semantics (direct calls select the specific instances, a
call through a (Bits#(a,na), Bits#(b,nb)) helper selects the
catch-all, simulated on both backends), and a coherent-annotated
class rejects the under-determined match even under
-incoherent-instance-matches.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YMPXjaiFCjeVJv1TbhYfd5
…cies A functional dependency promises that a class's input positions determine its dependent positions, but instance admission never checked that an instance keeps the promise: a variable in a fundep-determined position of an instance head that is not a function of the input positions -- e.g. instance C#(Bool, v) in a class where the first parameter determines the second -- means the same inputs can be satisfied with many different results. Such an instance is worse than unenforced: its determined position unifies with anything, so any checking premised on the dependency is structurally blind to it, and resolution through it silently depends on what the context demands rather than on the inputs. mkSymTab now checks the liberal coverage condition -- every determined-position variable must be in the closure of the input-position variables under the instance's proviso fundeps, numeric classes included -- and warns (T0160; a warning, not an error, until the ecosystem is surveyed), naming the `incoherent' annotation as the sanctioned escape for classes whose resolution is intentionally relational. Classes declared incoherent are exempt. The warning is emitted once, from the first symbol table of the user-written package (mkSymTabWithWarnings); the pipeline's re-derived symbol tables stay quiet. The standard library census is exactly one violator, StmtTifiable in StmtFSM (instance StmtTifiable Action t: an Action statement fits an FSM of any result type), which is genuinely relational and now declared incoherent, with an XXX for investigating a covering formulation. Bug782_TLM2BRAM's expected output gains the warning: its ToBRAMSeverBETC instance genuinely leaves `n' undetermined, and the test exists to document exactly this pathology. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YMPXjaiFCjeVJv1TbhYfd5
The T0160 coverage-condition trio (uncovered instance warns, the incoherent annotation silences by declaring relational intent, proviso-covered stays quiet) and the soft-keyword pin (coherent and incoherent remain valid identifiers). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YMPXjaiFCjeVJv1TbhYfd5
BSV reference guide: the typeclass grammar gains the optional coherence annotation (coherent | incoherent) with a description of what it declares, and the instance-declaration section documents the new dependency-coverage expectation and its warning, with incoherent as the sanctioned escape for intentionally relational classes. BH reference guide: the class-declaration grammar now shows the coherent/incoherent annotation Classic has accepted all along (a longstanding documentation gap), and the stale note claiming functional dependencies are not checked by the compiler is replaced with a description of the coverage check and the annotation's meaning. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YMPXjaiFCjeVJv1TbhYfd5
Pure mechanical refactor, no behavior change. The solver's plumbing
passed unlabeled tuples whose fields only the call sites could name:
sat, sMany, satMany', satisfy', and reducePredsAggressive all
returned ([VPred], SolvedBinds, Subst); reducePred returned a
five-tuple Maybe ([VPred], SolvedBind, Subst, Maybe Pred, Maybe Id);
byInst a four-tuple with a nested substitution pair. As the
resolution behavior grows more distinctions, positional tuples stop
carrying the design.
- SolveResult { solveNeeded, solveBinds, solveSubst }: the outcome of
a reduction pass -- what could not be discharged, with the
accumulated bindings and substitution.
- Reduction { redGoals, redBind, redSubst, redIncoherent,
redPackage }: a successful predicate reduction, naming in
particular the previously anonymous Maybe Pred: the matched
instance head when the match was information-dependent.
- InstMatch { imGoals, imBind, imInstSubst, imFdSubst, imPackage }:
a successful instance match, flattening the nested substitution
pair.
Consumers in TCheck, CtxRed, and ContextErrors updated to the record
patterns.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YMPXjaiFCjeVJv1TbhYfd5
A functional dependency declares that a class's determined positions
are a function of its input positions. Resolution previously
violated this: when an instance's input positions matched but its
determined positions conflicted with the predicate, byInst treated
the instance as a non-match and fell through to a later, less
specific instance -- letting the demanded output choose the instance.
This made instance selection unstable under refinement (a matched
instance could be lost when its output position was refined from
elsewhere) and produced order-dependent acceptance: with a specific
instance TC (Maybe a) 5 and a catch-all TC x 6, the same two-pred
program compiled or failed depending on argument order. It is also
the behavior a long-standing comment in matchTopIsReducible worked
around ("we'd like to return Fails, but to support overlapping
instances which disagree on the fundeps...") and that pipeline.exp
asked about ("the typeclass instances break the stated dependency
requirement; why doesn't BSC catch this?").
Under the new semantics, instances form ordered clauses of a
type-level function keyed on input positions: the first instance
whose inputs match is the selected clause; the fundep improvement
always runs; and if the determined positions cannot unify, the
predicate is unsatisfiable as stated -- the walk stops instead of
falling through. The three-way result has one type, Match a
(NoMatch / Conflict / Match with evidence), and the per-row head
comparison lives in one place (matchFDRow); matchTop consumes it and
now distinguishes input mismatch (NoMatch) from determined-position
conflict (Conflict), and reducePred stops on a conflict. sat's
result records the certificate as a two-valued Commitment
(Committable / Provisional). Given-discharge (lookfor) is
unchanged: a conflict against a given just means that given does not
discharge the predicate.
An `incoherent' class annotation is the user's declaration that the
class's resolution is not a function of its inputs -- the Has_tpl_n
tuple machinery is provably not one (a nested tuple can satisfy the
same input shape with different outputs depending on what is known)
-- so the ordered-clause discipline applies only where coherence is
promised: a determined-position conflict on an instance of an
incoherent-allowed class skips the instance (the previous behavior)
instead of stopping the walk. The exemption has a single statement
(outputFallThroughAllowed). Such a fall-through match is marked
information-dependent: an output-conflict skip is the definition of
output-driven selection -- the demanded output just chose against
that clause -- so the eventual match past it must not be treated as
coherent evidence. The WIncoherentMatch warning stays gated on
unannotated classes, so annotated classes are silent.
sat also now reports whether a partial reduction came from a
committable coherent match (no other instance can ever satisfy the
predicate, and no in-scope given unifies with it); nothing consumes
the certificate yet -- committing changes which predicates
unresolved-context errors name, so it waits for residual-to-root
provenance in the next change.
The hidden flag -legacy-defer-instances restores the previous policy
(output-conflict fall-through, unmarked) for bisection and migration.
The stdlib is unaffected: its catch-all-input instances (ToGet (a) a,
WrapMethod a (Bit n)) are guarded by the incoherent-match deferral --
the walk's accumulator flags a match while any earlier clause still
unifies with the predicate, and sat defers flagged matches for
unannotated classes -- so the catch-all is only ever selected when it
is the unique remaining choice, independent of the removed veto. The one in-tree casualty is the pipeline typeclass
example, whose SeriesPipeline instances deliberately dispatch on the
fundep output (a coerce-wrapper catch-all); Tb1.bsv is now rejected
under the default policy, answering the XXX comment there, and is
kept compiling under -legacy-defer-instances.
New tests: FdOrder pins that both argument orders reject; FdStability
flips from pinning the fall-through acceptance to pinning consistent
rejection, with the legacy behavior pinned under the flag.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YMPXjaiFCjeVJv1TbhYfd5
Coherent partial reductions are now committed immediately: when byInst finds a coherent match whose context is not yet satisfiable, sat keeps the instance bindings, substitution, and residual subgoals instead of putting the predicate aside for retry, provided no given (explicit or stacked) pred could still unify with the goal. Together with the ordered-clause fundep semantics already on this branch, instance resolution is now first-match-wins for coherent classes. To keep error messages anchored at user-meaningful predicates, residual subgoals now carry their ancestry: PredWithPositions gains a list of PredAncestor entries (the ancestor Pred plus its positions), minted when byInst reduces a goal to its instance context and when a fundep improvement spawns equality subgoals, unioned when duplicate contexts join in joinCtxs. Ancestors are inert for the solver -- substitution, tv, Eq, and Ord all ignore them -- so resolution behavior is unchanged. Context-reduction and weak-context errors report the root-most non-pre-class ancestor (substituted to current knowledge) instead of the leaf residual, restoring curated diagnostics (e.g. Bits/PrimSelect messages with source-level types) that commitment would otherwise degrade, while purely numeric chains keep the normalized residual. If root-mapping makes every pred implied by the declared context, the reporter falls back to the residuals rather than reporting nothing. The -commit-coherent-matches flag is gone; -legacy-defer-instances now disables both the ordered-clause semantics and commitment as a single escape hatch. Expected outputs for eight tests are regenerated where messages improved (more precise positions, restored curated tags). Reporting shares one statement of the root-then-filter protocol (rootedForReport): map residuals to their user-written roots, drop implied predicates, and if rooting left nothing to report, report the residuals themselves. handleAmbiguousContext deliberately stays out (it reports the residual whose variable is ambiguous, not its root). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YMPXjaiFCjeVJv1TbhYfd5
Under ordered-clause fundep semantics, a predicate whose input
positions select an instance with conflicting fundep-determined
positions is unsatisfiable as stated, but it was reported with the
generic "there are no instances of the form" (T0031) -- actively
misleading, since an instance matching the inputs exists.
The conflict cannot be reported eagerly at the solver's conflict
site: a predicate with variable inputs may select an earlier (more
specific) clause after refinement, so a conflict-stop only defers the
predicate. Instead, handleContextReduction's generic fallback now
probes each unresolved residual at reporting time, when the predicate
is maximally refined: findFunDepConflict re-walks the instances with
matchTop -- the reporting probe needs only the match verdict, not
byInst's dictionaries and bindings -- and a conflict recovers the
instantiated head of the selected instance. The new EFunDepConflict
message (T0159; T0158 is left reserved for the transitive-incoherence
diagnostics) shows the predicate and the selected instance side by
side and explains that instances are chosen by the fundep input
positions while the instance's determined types differ from what the
context requires. Curated class-specific messages (Bits, BitExtend,
PrimSelectable, ...) keep precedence; only the generic fallback is
upgraded.
Fail-fast context reduction also becomes conclusive:
matchTopIsReducible's conflict case -- inputs match an instance but
its fundep-determined positions do not -- answered "no conclusion",
with a comment explaining that a conclusive failure was desired but
fall-through made it unsound. The fall-through it protected is gone,
so the case now returns Conflict (through the shared row core,
matchFDRow), and
isReduciblePred treats it as conclusive with the same finality guard
as reducePred's walk: only for coherent classes, and only when no
earlier instance could still capture the predicate. That last
judgment is modal (earlierInstanceMayCapture, shared with reducePred's
incoherence accumulator): whether an earlier instance could capture
the predicate is a question about all refinements -- including
call-site instantiation of the definition's rigid variables -- so
judging it against the current bound set would turn "not yet" into
"never" and license a wrong conclusive verdict naming the less
specific clause. byInstIsReducible answers (Match (), Bool)
directly rather than leaking the instantiated head for the caller to
judge. Incoherent classes and -legacy-defer-instances keep the old
inconclusive answer.
The effect is that fail-fast context reduction (updateContexts at
apply nodes) rejects provably-final fundep conflicts at the use site
with the dedicated T0159 message, instead of letting them decay into
later, vaguer reports: Tb1's conflict was previously reported as a
T0033 ambiguity ("not enough explicit type information"); FdOrder's
second error was a T0030 weak-context at the definition line. Both
now name the selected instance and the exact conflict position.
FdStability, FdOrder, ContextReductionRemoveImplied, and GetPut's
Err1 expected outputs upgrade accordingly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YMPXjaiFCjeVJv1TbhYfd5
Rooted reporting moved several diagnostics off their old code paths; four reporting-quality holes surfaced in the full-suite run and are closed here: - reportedPwp carries the residual's accumulated source positions (deduplicated) onto the reported root: the root may have been created at a positionless site (a generated wrapper signature) while the residual collected real positions on its way through the reduction. - The synthesis-failure report (EBadIfcType) falls back to the predicate's carried positions when the report site has none, so the message points at the offending method instead of "Unknown position". - The weak-context "could also be deduced from" hint reduces self-contained (no givens): a hint with members silently discharged against internally deferred predicates would leave its remaining members referencing variables the message never defines (b1225's Bits#(a_T, a__) was dropped this way). - Fail-fast context reduction defers to the definition-level report when neither the site nor the predicate has a source position: commitment moved some failures from the definition-level check (which knows the definition's name and position) into fail-fast probes in generated wrapper code (which know neither). An irreducible predicate cannot escape unreported; the definition-level check is the terminal backstop. The gh221/gh894/BuildList/BuildVector expected outputs are regenerated for root-based reporting: errors anchor at the offending expression instead of the enclosing binder, dropping the one-entry "implied by" indirection lists. The noinline and bsc.codegen/signature synthesis-failure tests now expect both T0043 reports (the definition-level one naming the module, and the rooted one at the method position, upgraded from a positionless T0031). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YMPXjaiFCjeVJv1TbhYfd5
Pins for the semantics and reporting of the preceding changes (testsuite/bsc.typechecker/instances/commit): - LegacyHatch: a clause's determined output conflicting with the demanded type is final (T0159) under ordered clauses and falls through under -legacy-defer-instances. (commit.exp erases the .bo before the fail variant: a stale .bo from the legacy-variant pass would let `bsc -u' skip the recompile, making the suite non-idempotent across runs.) - CommitAncestry: a committed match's unsatisfiable numeric proviso is reported as the instance's concrete obligation at the use site (message pinned), rather than backing the match out into an unreduced context. - SynonymInput: a type synonym at an input position selects the clause its expansion selects. - GivenBlocksCommit: a matched catch-all's unresolvable subgoal must not be stranded; the pred defers whole and the enclosing given discharges it. - IncoherentFallThrough: the annotated class falls through on output conflict with no flag (and still under the legacy hatch). - OutputDetermined / PartialOutputCommit: sole-instance output determination through both the full-solve and the coherent-partial commit paths. - CrossBranch: the catch-all is reachable through the instance index's fallback branch and specific clauses are walked before it. - WeakChainRoot: an open residual is rooted as the user-written predicate, its reduction shown as the dependency. - ChainAncestry: the ground-leaf message shape (root, use site, concrete obligation). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YMPXjaiFCjeVJv1TbhYfd5
BSV reference guide: the typeclass grammar gains the optional coherence annotation (coherent | incoherent), and a new "Instance selection and coherence" subsection specifies the resolution rules: overlapping instances are ordered most-specific-first (order between incomparable instances unspecified); for classes with dependencies, selection is driven by the determining parameters alone; the selected instance fixes the dependent parameters, and a conflict with the demanded types is an error rather than a fall-through; commitment waits until no more specific instance could match and no proviso in scope could supply the constraint; incoherent classes opt out (output-driven selection, per-context resolution, coverage warning suppressed); instances are expected to cover the class's dependencies (warning otherwise). BH reference guide: same annotation in the class-declaration grammar, and the stale "functional dependencies are not currently checked" NOTE is replaced with the enforcement rules (use-site selection and conflicts, instance coverage) and the annotation's meaning. User guide: the two hidden migration flags are documented in prose (outside the flag blocks that check-flags.pl compares against -help): -legacy-defer-instances (restore output-driven resolution; code relying on it should declare its classes incoherent instead) and -legacy-inst-index (restore the older instance ordering/indexing). For the PR stack: the grammar and coverage/annotation text belongs to the annotation-and-coverage PR, the selection-semantics subsection and flag prose to the commitment PR; the hunks split along those lines at assembly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YMPXjaiFCjeVJv1TbhYfd5
A committed instance reduction's residual context may become
satisfiable only after later unification or fundep improvement
grounds its variables; the previous flow could report it as a
context-reduction failure while it was merely not-yet-reducible.
The invariant, now enforced at both report sites: no predicate is
reported unsatisfiable without a satisfy attempt under the current
substitution.
- tiExpl: ground leftovers ("uds") get one final "satisfy" under the
completed substitution before reporting; the satisfiable ones stay
deferred to the enclosing binding like any other deferred context.
(A residual can arrive here ground-but-unsolved when a sibling
premise's improvement bound its variables inside the last satisfy
pass.)
- matchTopIsReducible: normalize type functions under the trial
substitution before judging the determined positions, and lift the
judgment into TI to do so. A predicate from a derived instance's
context carries ATF applications of its inputs (e.g. "TilePred
tag") that only reduce once the trial substitution grounds the
inputs; comparing them unreduced misjudged
satisfiable-after-refinement predicates as never reducible, letting
fail-fast context reduction report them prematurely.
The regression tests distill the failing shape: a tag that reaches a
derived DefaultValue instance only through a higher-order type
synonym and slot/pred type functions, with the field assignment
forcing the class output. The two-instance variant pins that the
resolution is structural unification, not reverse instance lookup.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KNk7jKzLHwCdvEv1uzGQYJ
Numeric provisos that instance reduction cannot discharge were sent to the proviso SAT solver from inside reducePred, one predicate at a time, with a fresh solver spun up (and torn down) per query. A definition with N numeric residuals paid N solver initializations per satisfy pass, and the same predicate could be re-posed by every pass that revisited it. The per-predicate fallback is gone (reducePred's givens parameter, which existed to feed it, is renamed _eps and remains as the seam where a given-aware walk would plug in). Numeric residuals now stream out of every satisfy pass and settle at their owner, where batchSolveNumericPreds proves the whole residual set in one solver session: one initialization, the definition's numeric givens asserted once, each residual posed as a query against them, with queries already decided in this definition answered from a cache (tsNumProven / tsNumRefuted) instead of re-posed. The owners: tiExpl''' proves this definition's retained and deferred residuals in one batch, after the ds/rs split and before defaulting (a deferred pred provable only at an enclosing binding survives its batch untouched and defers as before; ground preds must settle here or be misreported as unsatisfiable). The exported satisfy/satisfyFV keep their verdict-complete contract -- normT's expand-or-not decision, the foreign-import proviso check, ISyntaxCheck's eqTypeNum, and the error reporters are unchanged -- while new satisfyStream/satisfyFVStream variants serve the callers that own a later settlement point (tiExpl''', and tiImpls whose residuals settle in reducePredsAggressive). The two verdict sites that must not adopt on unproven numeric debt settle their own results locally: an incoherent-class match is deliberately revocable until its context is fully discharged, so its full-vs-partial verdict gets a local batch, and tryDefault's candidate validation settles under each trial substitution (the speculative pass is already quarantined -- only an accepted candidate's substitution is merged). Deferred debt belongs to its owner: a non-ground deferred pred rides upward unqueried and settles once, at the binding that owns its variables -- except through a binding with numeric-class givens (the only assumptions the solver can assert), whose local proof attempt may be load-bearing. Ground deferred preds settle where they stand. Measured on FloatingPoint.bsv (same host, same flags), before vs after this change: solver sessions 501 -> 24, queries 1418 -> 115, wall-clock 12.9s -> 9.6s (-26%). The dominant collapse is the desugared local bindings, which previously re-queried inherited debt at every nesting level. Soundness fix (b675 ICE): byInst's dictionary binding references only the instance's declared proviso dictionaries; the numeric equalities a fundep match defers (num_eqs) become goals but not arguments, so the binding's free variables did not reveal the obligation, and a consumer walking a binding's dependencies would treat the closure as complete while the equality never held. SolvedBinds.addBindDeps now records the equality dictionary ids as semantic dependencies of the bind: an unresolved equality shows up in the walk, a solved one is walked through. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YMPXjaiFCjeVJv1TbhYfd5
Pins for where numeric-proviso debt settles (bsc.typechecker/ numeric-settle). Both obligations are chosen to be provable ONLY by the SAT solver -- the cross-class entailment 2a = b |- a + a = b, which no direct instance can synthesize (the output position is pinned) and no commuted given can supply -- certified by a dual variant: each test must FAIL with the solver disabled (-no-use-proviso-sat) and pass with it enabled. A test that still passed without the solver would not be exercising settlement. - DebtRidesUp: a given-free local's obligation rides up (unqueried) and is proven by the enclosing definition's Mul proviso, in that binding's batch. - IncoherentNumBatch: an incoherent-class match takes a local numeric verdict -- its instance's Add proviso is proven in a local batch, so the specific instance wins; without the verdict the match looks partial and selection falls to the catch-all, which cannot satisfy the signature. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YMPXjaiFCjeVJv1TbhYfd5
Everything after the last substitution-mutating satisfy must stay substitution-neutral: settlement discharges without binding, and the final ground-predicate probe cannot bind (ground heads yield ground premises). Record the invariant, and what breaks it, where a future model-producing solver would land. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KNk7jKzLHwCdvEv1uzGQYJ
mgu now refuses to bind a bound (rigid, signature- or instance- quantified) type variable to structure at value kinds. Numeric and ATF positions already produced deferred equality constraints instead of binding; value kinds now follow the same design: an improvement that meets a rigid variable must become an equality constraint, never a substitution. This closes the audit hole where fundep improvement could rewrite a signature out from under its binder (misreported as T0029 "signature too general"). A rigid-induced unification failure is not a conflict -- the predicate may be satisfiable by a given, or at a call site that instantiates the variable -- so matchTop and matchTopIsReducible distinguish it from a structural clash by re-running the fd-mgu unguarded: guard-induced failures defer the match (the stage-1 approximation of an explicit type-equality constraint), structural clashes remain final (T0159). This keeps the recursive AppendTuple''/Curry' instance methods in the Prelude compiling (they re-encounter their class with the instance's own rigid variables; the discharge is the instance's given) while genuine output clashes still fail fast. The commitment machinery's modal checks -- the walk's incoherence accumulator, sat's given-escape gate, matchTopIsReducible's could-eventually-unify probe -- ask "could this ever match?", where refinement includes call-site instantiation of rigid variables and givens from enclosing frames quantifying over distinct rigid variables. These now unify unguarded: guarded, they turned "not yet" into "never", and "never" is what licenses a commitment. (Guarded, the Generic-default catch-all was committed for PrimDeepSeqCond predicates inside a definition whose call sites would select specific instances, stranding unsolvable Generic subgoals; the instance-overlap check also went blind to orthogonal overlaps.) orderInstHead and the ContextErrors message-selection probes get a dedicated modal unifier, mguModal, which keeps the bound set as a variable-direction preference only, so orderInstHead's substitution-domain attribution of specificity still works. The discipline is enforced mechanically: substitutions are built locally all the time (scheme instantiation, instance freshening, the modal probes above), but the global substitution has exactly one add-path, extSubst, and under the development flag -check-subst-bound it rejects any extension whose domain contains a bound variable -- "bound variables have exactly one binder", checked at the point where unification results become globally visible. Reporting: findReducedPreds tolerates preds that re-reduce fully at report time (deferred preds are re-reduced outside the bound set they failed under); T0020 suppresses generated "_tc..." temporaries as subjects (a bare "Type mismatch" plus the position, instead of an internal name with unstable numbering -- EUnboundVar1's golden had one baked in); two module-arity tests now report the precise T0084 (naming the function and the missing argument) instead of the T0107/T0108 guesses; the too-general-signature tests report at the offending expression instead of T0029 at the definition (SignatureTooGeneral and ETooGeneral1: T0020; ETooGeneral: T0080 naming the literal; StringKindSpecificToPoly: T0020); GenWrap and GenFuncWrap position the wrap-field method applications at the field/function they wrap, which routes an undischarged WrapField proviso to the synthesis reporter (T0043 naming the offending type) instead of surfacing as a weak-context error full of internal machinery at "Unknown position" (NoInline_ResNotInBits). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YMPXjaiFCjeVJv1TbhYfd5
Construction-side escapes (field value forcing the field's quantified variable to a concrete type, to another quantified variable, or to a rigid variable of the enclosing signature) must be rejected with a positioned type error; a proviso'd polymorphic field must construct (the field's given discharging the body's wanted) and instantiate freshly at each selection; struct update must re-check the new value against the field's scheme. These pin the soundness that the bound-variable guards provide to the existing higher-rank field support. A sixth case is known and not yet pinned: an outward escape through an enclosing *metavariable* (an unannotated binder) is accepted and fails later as an elaboration internal error; the planned scope check (no surviving substitution range may mention a scope's popped skolems, by tyvar-number level against the trim point) will turn it into a type error, and its test joins this directory then. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YMPXjaiFCjeVJv1TbhYfd5
A type variable quantified at a definition (a signature's variable, an instance method's, a polymorphic struct field's) must not leak into the type of anything bound outside it. The unifier's bound-variable guards keep the skolem itself from being substituted, but an outer metavariable may legally be bound TO a skolem -- the allowed direction -- and if such a binding survives the definition's check, an enclosing binding generalizes or instantiates the locally quantified variable. Previously this was accepted and exploded downstream as an elaboration-time internal error (constructing a polymorphic struct field whose body returns an unannotated enclosing binder was the reproduction). The check is one set intersection at tiExpl''': this definition's bound variables against the free variables of the substituted outer assumptions, which the generalization machinery already computes. A hit reports T0161, naming the escaping variable and the enclosing bindings that would capture it. HREscape2 joins the higher-rank skolemization pins: previously an ICE, now a positioned type error. Validated in one testsuite batch with the settlement change (the preceding commit); the two changes are independent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YMPXjaiFCjeVJv1TbhYfd5
Every unsound-substitution shape from the bound-variable audit, pinned: rigid fundep-improvement deferral reported honestly (BVB, T0031 with the selected instance shape), a genuine determined-position clash final under ordered clauses (BVC, T0159 message shape pinned), a rigid numeric result reported as a provable equality hint (BVD, T0030), expression-unifier refusals for rigid-vs-concrete and rigid-vs-rigid (T0020), and the var-var control that must compile. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YMPXjaiFCjeVJv1TbhYfd5
ModalCapture: inside a definition the predicate's variable is rigid, so a guarded judgment would call the output conflict at the less specific clause conclusive and emit a factually wrong fundep-conflict error naming that clause as selected; the modal (unguarded) judgment recognizes that the more specific clause can still capture the predicate at call sites and defers to the definition-level too-general report naming the true obligation. The uncovered catch-all's T0160 coverage warning is pinned alongside. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YMPXjaiFCjeVJv1TbhYfd5
nanavati
force-pushed
the
claude/bound-variable-discipline
branch
from
July 15, 2026 12:13
5ad78cb to
6a1639a
Compare
nanavati
added a commit
to nanavati/bsc
that referenced
this pull request
Jul 15, 2026
The typeclass-coherence PRs (B-Lang-org#1035/B-Lang-org#1037) claim T0159-T0161, and whichever series merges second would collide. Take the next free numbers here proactively; the tags are otherwise arbitrary. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cqQdWKp7jr73GHQYnvhiq
Collaborator
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A functional-dependency improvement that meets a rigid (signature- or instance-quantified) type variable must become an equality constraint, never a substitution. Numeric and ATF positions already worked this way; this PR extends the same design to value kinds:
mgunow refuses to bind a bound type variable to structure, closing the hole, found by the bound-variable audit, where fundep improvement could rewrite a signature out from under its binder (misreported as T0029 "signature too general"). It also adds the complementary check at the other end of a quantifier's life: a locally quantified variable must not escape into enclosing bindings (T0161).Stacked on #1036 (which carries #1032, #1033, and #1035): the 5 commits from "Never substitute bound type variables; defer rigid fundep improvements" onward are this PR's content.
Rigid-induced failure defers; structural clash stays final
A unification failure induced by the rigid-variable guard is not a conflict — the predicate may be satisfiable by a given, or at a call site that instantiates the variable.
matchTopandmatchTopIsReducibledistinguish the two by re-running the fundepmguunguarded: guard-induced failures defer the match (an approximation of an explicit type-equality constraint), structural clashes remain final (T0159, from #1035). This keeps the recursiveAppendTuple''/Curry'instance methods in the Prelude compiling — they re-encounter their class with the instance's own rigid variables, and the discharge is the instance's given — while genuine output clashes still fail fast. In theMatchresult type this is a newDeferarm, discriminated inmatchFDRowand ranked byfirstMatch(Match > Conflict > Defer > NoMatch).Modal checks ask "could this ever match?" — so they unify unguarded
The commitment machinery's modal checks — the walk's incoherence accumulator,
sat's given-escape gate,matchTopIsReducible's could-eventually-unify probe — quantify over futures in which call sites instantiate rigid variables and enclosing frames supply givens over distinct rigid variables. Guarded, they turned "not yet" into "never", and "never" is what licenses a commitment: theGeneric-default catch-all was committed forPrimDeepSeqCondpredicates inside definitions whose call sites would have selected specific instances, stranding unsolvableGenericsubgoals. The instance-overlap check likewise went blind to orthogonal overlaps. These probes now unify unguarded.orderInstHeadand theContextErrorsmessage-selection probes get a dedicated modal unifier,mguModal, which keeps the bound set as a variable-direction preference only, preserving howorderInstHeadattributes specificity by which side of a unification lands in the substitution's domain.The discipline is enforced mechanically: local substitutions are built everywhere (scheme instantiation, instance freshening, the modal probes), but the global substitution has exactly one add-path,
extSubst, and under the development flag-check-subst-boundit rejects any extension whose domain contains a bound variable — "bound variables have exactly one binder", checked at the point where unification results become globally visible.Skolem escapes are rejected at generalization (T0161)
The unifier's guards keep a skolem from being substituted, but an outer metavariable may legally be bound to a skolem — the allowed direction — and if such a binding survives the type check of the definition that binds the skolem, an enclosing binding generalizes or instantiates the locally quantified variable. Previously this was accepted and exploded downstream as an elaboration-time internal error. The check is one set intersection at
tiExpl''': this definition's bound variables against the free variables of the substituted outer assumptions — a set the generalization machinery already computes. A hit reports T0161, naming the escaping variable and the enclosing bindings that would capture it.HREscape2, previously an ICE, is now a positioned type error.Reporting
Deferred predicates are re-reduced outside the bound set they failed under, so
findReducedPredstolerates preds that re-reduce fully at report time. T0020 suppresses generated_tc...temporaries as subjects (a bare "Type mismatch" plus the position, instead of an internal name with unstable numbering). Two module-arity tests now report the precise T0084 (naming the function and the expected arguments) instead of T0107/T0108 guesses; the too-general-signature tests now report at the offending expression instead of reporting T0029 at the definition.GenWrapandGenFuncWrapposition wrap-field method applications at the field/function they wrap, which routes an undischargedWrapFieldproviso to the synthesis reporter: inNoInline_ResNotInBits, a third T0043 naming the offending type replaces the signature-mismatch T0029.Commit-by-commit
ModalCapture: the commitment gate's modal question answered across rigid instantiation.Validation
bsc.typecheckersweep including the new pins passes clean.The stack: #1032 (instance-trie fixes) and #1033 (coherence keywords + coverage warning), independent → #1035 (ordered-clause commitment) → #1036 (SAT settlement) → #1037 (bound-variable discipline, this PR) → #1038 (solved-dictionary pool). Each PR contains the ones below it.
🤖 Generated with Claude Code
https://claude.ai/code/session_018wageSF1tZAS25nrsrbPnZ