Skip to content

Carry what a type states across a basic block boundary - #230

Draft
coord-e wants to merge 2 commits into
mainfrom
claude/issue-201-fix-q4bn5v
Draft

Carry what a type states across a basic block boundary#230
coord-e wants to merge 2 commits into
mainfrom
claude/issue-201-fix-q4bn5v

Conversation

@coord-e

@coord-e coord-e commented Aug 16, 2026

Copy link
Copy Markdown
Owner

Fixes #201.

Root cause

A block that inherits its precondition gets two things and no more: the target's parameters, typed from their MIR types alone, and a formula capturing what the predecessor's env says about each parameter's value.

PrecondCapture is where the second part is assembled, and it only ever reads a value:

fn push(&mut self, target: ..., var_ty: PlaceType) {
    self.body.push_conj(var_ty.formula...);            // the formula
    self.target_equations.push((target, var_ty.term...)); // the term
    // var_ty.ty is not read
}

and push_env_state folds in rty.refinement — the outer refinement of { T | phi } — leaving T alone. So everything a type states by itself is dropped at the boundary and rebuilt unrefined from MIR: the refinements nested inside it, and the specification a function type spells out.

A fn-pointer value is where this becomes visible, because a function type has nowhere else to keep its specification. type_call reads the pointer through operand_type(func).ty, gets an unrefined (..) -> .., and relate_fn_sub_type relates the call against true, leaving the result unconstrained.

Before, for let f: fn(i64) -> i64 = add1; let a = f(0); let b = f(a);:

register_basic_block_def bb=bb1 rty=(_0: (), _1: (int) → int, _2: int) → () has_precondition=false

fn_sub_type got=({ int | p0 ν }) → { int | p1 ν $0 }   expected=({ int | ν = 0 }) → { int | p6 ν }     ; bb0
fn_sub_type got=(int) → int                            expected=({ int | ν = _2 }) → { int | p7 ν _2 }  ; bb1

Trigger

The trigger is the block, not the number of calls: a call is a terminator, so the first call in a body sits in the reify cast's own block and is typed precisely, while every later one does not. A single call behind a branch reproduces it just as well.

It is not limited to ReifyFnPointer locals, nor to the root of a type. A fn-pointer parameter hits the same path once it is called from a later block, and so does a fn pointer nested in a tuple.

Change

install_inherited_bb_ty already materializes a goto target's type from the predecessor's env, so it now hands the env's types over there too, alongside the precondition it was already capturing.

A type in the env is closed: a refinement nested in one constrains the value at its own position and names nothing from the env. So the type transfers as it stands, with no substitution — assert_closed states that invariant where it is relied on. The sort assertion beside it holds the env's view and the MIR-built parameter to the same shape.

Blocks that need their own precondition are untouched. Those get template types and relate the two sides at the goto, and that relation — being structural — already carried all of this, nested positions included.

Why not relate the types at the goto here too

Because on this path there is nothing to relate against:

if !needs_own_precondition(&self.body, bb) {
    self.install_inherited_bb_ty(bb, outer_fn_param_vars);
    return;                       // <- no relation happens on this path
}

The target's type comes from TypeBuilder::build_basic_block, which registers no templates (its FakeRegistry panics if asked to). With no predicate variables in the expected type, a subtyping relation yields true ⟹ true, and the block body goes on reading its parameters from that same type.

Sending these blocks down the predicate-variable path instead was tried and is not a one-line change: it makes pass/option_unwrap_or_else and fail/option_unwrap_or_else abort in type_goto, where assert_closed meets a parameter type that is not closed, and it pushes pass/closure_receiver_mut_model past a 150s solver timeout. Unifying the two paths looks worthwhile but wants that invariant sorted out first.

Testing

  • tests/ui/{pass,fail}/fn_ptr_call_twice.rs, fn_ptr_call_in_branch.rs, fn_ptr_param_call_twice.rs, and fn_ptr_in_tuple_call_twice.rs added. Each fail one is the pass one with the property broken as narrowly as possible. The last pins the nested case, which a version of this change that looked only at the root of a type did not fix.
  • All three reproducers from the issue verify as safe, and every row of the issue's behavior matrix matches its Expected column.
  • Soundness spot-checks still report Unsat: b == 3 and b == 1 on the two-call program, x == 3 on the &mut one, the branch variant, the two-target variant, and a fn pointer chosen by a branch (if c { add1 } else { add2 }).
  • cargo test with the PCSat tests filtered out: 284 passed, 0 failed, plus 3 unit tests and 2 doc-tests.
  • The 32 PCSat tests were run directly, sequentially, on this branch and on main at cd33fbf, with identical verdicts: 31 as expected on both, and fail/option_map.rs undecided on both at a 150s solver timeout.
  • The closedness assert_closed relies on was measured before relying on it: instrumented to report any variable occurring in an env type at a goto, then run over all 316 test files. No occurrences.
  • cargo fmt --all -- --check and cargo clippy -- -D warnings are clean.

Two things found while testing, neither addressed here:

Since the fix is not specific to function pointers, #201's framing is narrower than the defect. Happy to retitle it, or to leave it as the fn-pointer symptom and open a separate issue for the general loss — whichever you prefer.

@coord-e
coord-e force-pushed the claude/issue-201-fix-q4bn5v branch from d974768 to 5961f61 Compare August 20, 2026 12:10
@coord-e coord-e changed the title Carry a fn-pointer's specification across a basic block boundary Give a body with a fn pointer its own basic block preconditions Aug 20, 2026
A function type states the callee's specification in the type itself, and
`Type::Function` lowers to a null sort, so a fn-pointer local carries no logical
content at all. A block that inherits its predecessor's env state as its
precondition therefore cannot receive that specification: the precondition is a
formula about the parameters' values, and there is no value to speak about. The
capture loop skips the parameter as singleton-sorted, `TypeBuilder::build` has
meanwhile rebuilt it from its MIR type alone, and `type_call` relates the call
against `true`, leaving the result unconstrained.

Because a call ends its block, the first call in a body sits in the reify cast's
own block and is typed precisely, while every later one is not; a single call
behind a branch is enough on its own. A fn-pointer parameter reaches the same
path once it is called from a later block.

`install_inherited_bb_ty` already materializes the target's type from the env, so
hand the function types over there too. The copy walks into the type rather than
matching only its root, so a function type nested inside a tuple or a struct is
carried across as well. A position where the two shapes disagree is left alone:
missing a specification costs precision, while taking one from an unrelated
position would be wrong.

Relating the two types at the goto instead, as the other path in `type_goto`
does, would achieve nothing here: the target's type is built without predicate
variables, so there would be nothing for the subtyping to constrain.
@coord-e
coord-e force-pushed the claude/issue-201-fix-q4bn5v branch from 5961f61 to 788fa99 Compare August 20, 2026 12:18
@coord-e coord-e changed the title Give a body with a fn pointer its own basic block preconditions Carry a fn pointer's specification across a basic block boundary Aug 20, 2026
A block that inherits its precondition takes over what its predecessor's env
says about each parameter's *value*, and is otherwise typed from MIR types
alone. Everything a type states by itself is dropped at that boundary: the
refinements nested in it, and the specification a function type spells out.

A fn-pointer value is where this shows: called from a block other than the one
that created it, it is related against an unrefined `(..) -> ..`, so the
callee's pre- and postcondition are both gone and the result is left
unconstrained. A call ends its block, so the first call in a body is typed
precisely and every later one is not.

Hand the env's types over alongside the precondition. A type in the env is
closed -- a refinement nested in one constrains the value at its own position
and names nothing from the env -- so it transfers as it stands, and
`assert_closed` pins that down.

Blocks that need their own precondition keep relating the two types at the
goto, which already transferred all of this.

Fixes #201.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014bFB7y5QM3ebxvtusQYZBo
@coord-e coord-e changed the title Carry a fn pointer's specification across a basic block boundary Carry what a type states across a basic block boundary Aug 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants