Skip to content

Keep the values expected depends on in the counterexample - #460

Merged
CatarinaGamboa merged 3 commits into
mainfrom
fix/counterexample-expected-vars
Nov 3, 2026
Merged

CatarinaGamboa merged 3 commits into
mainfrom
fix/counterexample-expected-vars

Conversation

@CatarinaGamboa

@CatarinaGamboa CatarinaGamboa commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

When Found says nothing about the variable that Expected constrains, the counterexample leaves that variable out. It shows values for unrelated path conditions instead:

Refinement Error: !(port < 0 || port > 65535) is not a subtype of timeout >= 0
 --> Counterexample: port == 0

The value that breaks the call (timeout == -1) is in the SMT model, but it gets filtered out. In the OpenNMS study exercise the panel shows #fresh³¹⁶ == false && port³¹³ == 0, which points the reader at the port guard.

Cause

RefinementError.filterCounterexample kept only assignments to the binders of the simplified Found. That rule is wrong in three ways:

  1. It drops what Expected constrains. A variable Found only knows as true loses its binder during simplification, so its value goes too (timeout, and also size(bag) for ghosts).
  2. It drops variables Found does mention. A variable whose binder was simplified away can still appear in a path condition.
  3. It keeps noise. It keeps any surviving binder, even one nothing displayed mentions (a generated path binder). It keeps path conditions unrelated to Expected. It keeps raw Z3 array values (l == ((as const (Array Int Int)) 4)).

Fix

The counterexample exists to show values that make Expected false under Found.

What is kept

  • The terms of Expected. These are its variables and its ghost applications over variables (size(bag), length(a)), named as the SMT model names them.
  • The terms of every Found fact linked to them, directly or through other facts. A fact is a conjunct of Found. Negated disjunctions are split (!(a || b) gives !a and !b), and double negations are dropped. So one combined guard !(port < 0 || … || timeout > 60000) links only timeout > 60000 to timeout, not every variable in the guard. A fact that shares no term with Expected only constrains values that cannot affect it, so it is left out.
  • Not values Found already states. An x == v fact is compared structurally. Enum constants match the model's value (Format.JPG in Found, JPG in the model), and so do ghost applications with several arguments.
  • Nothing when Found fixes every value Expected depends on. There is no fallback to other terms: int value = 0; if (port > 0) take(value); against _ > 1 shows no counterexample rather than port == 1. This is the same rule that already gives literal arguments no counterexample (ErrorLiteralZero). A path condition is shown only when it is linked to Expected, e.g. return port + 1; under port < 0 shows #ret == 0 && port == -1.
  • Never array values. Only Z3 can read them. TranslatorToZ3 now skips array-sorted model values, as it already skipped uninterpreted sorts. Their ghosts (length(a)) remain.

Order, most relevant first:

  1. Terms of Expected that Found says nothing about.
  2. The other terms of Expected.
  3. The linked terms, in the order the variables were introduced (the binder order of the VC chain before simplification).
  4. Generated #fresh names last.

Display. VariableFormatter.format formats ghost application arguments (size(#s_3) becomes size(s³)). It uses a scanner that only splits top-level commas, so f(g(#a_1, #b_2), #c_3) becomes f(g(a¹, b²), c³). The CLI uses this, and so does the VS Code server's CounterexampleDTO. The fresh-name prefix now lives in Formats.FRESH_PREFIX, shared with VCBinderSimplification.

Behaviour changes in existing outputs

Sweep of main against this PR over 149 Error* testSuite entries, 25 ErrorCounterexample* fixtures and 97 study exercises (257 entries, main at 19991d36): 70 outputs change.

  • 21 change order only. The same values come back, with Expected's values (often #ret) now first and #fresh last:
    • fixtures: ErrorAfterIf, ErrorAssignmentBeforeReturn, ErrorBitwiseUnknownOperand, ErrorBoolean, ErrorCharLiteralComparisons, ErrorDeferredCallbackState, ErrorDependentUpperBound, ErrorEmptyLoopBody, ErrorFieldOfOtherObject, ErrorIdentity, ErrorIntegerDivision, ErrorOperatorAssignments, ErrorRecursion, ErrorRecursiveDecrement, ErrorRecursiveSiblingParameter, ErrorUnknownMethodInOperation
    • exercises: E_A2_03, E_A2_07, E_A2_08, E_A2_10, E_OSS_A2_05
    • For example, x == 1 && #ret² == 0 becomes #ret² == 0 && x == 1. The 7 affected TestCounterexamples assertions are updated.
    • Of these, five fixtures reorder because #fresh now goes last: ErrorBitwiseUnknownOperand, ErrorFieldOfOtherObject, ErrorNullLiterals, ErrorUnknownMethodInOperation and ErrorCounterexampleCallArgument. The first version of this PR body did not say so.
  • 49 change content. These are the new fixtures, plus:
    • values Expected needs that were missing: ErrorUnconstrainedRefinement, ErrorUnknownArgument, ErrorUnresolvedOperationValue, ErrorEnumConstantInCondition, E_OSS_A1_05, E_OSS_A2_04
    • unlinked path values dropped: ErrorLoopCondition, ErrorInstanceof, ErrorUnknownMethodInComparison, E_A2_02, E_OSS_A2_01, plus counterexamples made only of an opaque #fresh value that Expected does not depend on: ErrorNullLiterals (two of its errors; the others only reorder), ErrorUnresolvedBinaryOperator, ErrorUnresolvedFieldDeclaringType, ErrorUnresolvedUnaryOperator
    • known enum values no longer repeated: ErrorEnumFunctionRefinement, ErrorEnumNegation, ErrorLocalShadowsConstant, and the first error of ErrorEnumConstantInCondition
    • array values replaced by length(…), or dropped when the length is known: ErrorSearchIntArray, ErrorImplementationSearchValueIntArray, ErrorSearchValueIntArray, ErrorStringArrayLength, ErrorLenZeroIntArray
    • ErrorEnumNull, which is a known limitation (see below)

Relation to #299 and #459

Tests

25 testSuite fixtures (ErrorCounterexample*.java, each with // Expect: Refinement Error) plus matching TestCounterexamples cases that assert the exact assignments:

Fixture main this PR
UnconstrainedArgument (issue reproducer) port == 0 timeout == -1
CombinedGuard (if (port<0 || … || window<1 || timeout>60000) return;) port == 0 && retries == 0 && backoff == 0 && window == 1 timeout == -1
MixedGuard (port<0 || (retries>3 && backoff>10) || timeout>60000) port == 0 && retries == 0 timeout == -1
GuardRelatesArguments (retries > timeout + 5) port == 0 && retries == 0 timeout == -1 && retries == 0
NothingKnown (Found is true) (none) millis == -1
GuardedArgument port == 0 timeout == -1
AliasedArgument configured, port, timeout¹¹ timeout¹¹ == -1 && configured == -1
FieldArgument this#…timeout, port, timeout⁶ timeout⁶ == -1 && this#…timeout == -1
CallArgument (connect(read(key) - 1)) #fresh⁶, port, timeout⁷ timeout⁷ == -1 && #fresh⁶ == 0
BranchArgument (nested if/else) secure == false && #fresh⁰ == false && port == 1025 timeout == -1
SeveralArguments port == 0 && lo == 1 hi == 0 && lo == 1
KnownArgument port == 0 capacity == 0
LinkedPathCondition (connect(port - 1)) port, retries, timeout¹⁰ timeout¹⁰ == -1 && port == 0
ReturnPath configured, port, #ret⁵ #ret⁵ == -1 && configured == -1
ReturnConstant (return 0 under port < 0) port == -1 (none): Found fixes #ret == 0
ReturnFromPath (return port + 1 under port < 0) port == -1 && #ret² == 0 #ret² == 0 && port == -1
KnownGuardedArgument (int value = 0; if (port > 0) take(value);) port == 1 (none)
KnownTwiceGuardedArgument (same, under two guards) port == 1 && retries == 0 (none)
KnownAliasedArgument (int base = 0; int value = base;) port == 1 (none)
RefinementAlias port == 0 timeout == -1
GhostArgument port == 0 size(bag) == 0
KnownGhost (Found states size(s⁹) == 1) port == 0 (none)
KnownEnum (format == Format.JPG) (none) (none); format == JPG stays hidden
ArrayArgument (get(a, 3)) port == 0 length(a) == 0
ArrayIndex (int[] a = new int[4]; get(a, k)) port == 0 && a⁵ == ((as const (Array Int Int)) 4) k == -1

15 unit tests in RefinementErrorCounterexampleTest. Each builds a RefinementError from a VC chain and a hand-written model. They cover:

  • an Expected variable that simplification dropped
  • unlinked facts
  • equality chains
  • unmentioned path binders
  • generated names ordered last
  • known values, known enum values, and known ghosts with several arguments (and the unknown case)
  • splitting negated disjunctions, nested conjunctions and double negations
  • a guard that relates Expected to another variable
  • no counterexample when Found fixes every linked value, even with a path condition, and a path condition Expected's value is computed from

VariableFormatterTest also covers nested applications.

The review fixtures fail on the commit before them: CombinedGuard, MixedGuard, GuardRelatesArguments, KnownEnum, ArrayArgument and ArrayIndex on 491d297e; KnownGuardedArgument, KnownTwiceGuardedArgument and KnownAliasedArgument on 9e037b6b, which showed port == 1. So do their unit tests. Four tests guard behaviour that should not change and pass on both: ReturnFromPath, keepsAPathConditionTheExpectedValueIsComputedFrom, hasNoCounterexampleWhenFoundStatesEveryValue and keepsGhostValuesWithSeveralArgumentsFoundSaysNothingAbout.

mvn test (verifier): 599 tests, 0 failures, 0 errors (534 on main).

CLI before / after

Issue reproducer:

Refinement Error: !(port < 0 || port > 65535) is not a subtype of timeout >= 0
- --> Counterexample: port == 0
+ --> Counterexample: timeout == -1

OpenNMS study exercise (E_OSS_A2_04_VmwareCimProbe):

- --> Counterexample: #fresh³¹⁶ == false && port³¹³ == 0
+ --> Counterexample: timeout == -1

Combined guard:

Refinement Error: !(port < 0 || port > 65535 || retries < 0 || backoff < 0 || window < 1 || timeout > 60000) is not a subtype of timeout >= 0
- --> Counterexample: port == 0 && retries == 0 && backoff == 0 && window == 1
+ --> Counterexample: timeout == -1

Array index:

Refinement Error: !(port < 0) && length(a⁵) == 4 is not a subtype of k >= 0 && k < length(a⁵)
- --> Counterexample: port == 0 && a⁵ == ((as const (Array Int Int)) 4)
+ --> Counterexample: k == -1

Ghost state:

Refinement Error: !(port < 0) is not a subtype of size(bag) > 0
- --> Counterexample: port == 0
+ --> Counterexample: size(bag) == 0

Known enum value (ErrorEnumFunctionRefinement):

Refinement Error: newColor¹¹ == Blue is not a subtype of newColor¹¹ == Color.Red || newColor¹¹ == Color.Green
- --> Counterexample: newColor¹¹ == Blue
+ (no counterexample: Found already states it)

Left out, and why

  • ErrorEnumNull (Color c = null) now shows c³ == Blue. Found is true because the verifier does not model null for enums; the assignment is lost before the error is built. Blue is a real counterexample to what Found states, but not the value the program has. Hiding values whenever Found is true would also hide millis == -1 in the issue's own case. The fix belongs in how null enum assignments are translated, which is a separate issue.
  • Typestate parameters (@Refinement("open(f)")): RefinementError receives Expected before states are expanded, while the model names the state ghost state1(#f_6). Here Found already states that ghost (closed(f⁶) is state1 == 0), so nothing new would be shown even after expansion, and state1(f⁶) == 0 would be harder to read than the state name. With nothing new to show, there is no counterexample (main showed the unrelated port == 0). Showing values in state names needs the state mapping in the error, which this PR does not add.
  • No counterexample, rather than the known value, when Found fixes Expected. Repeating value == 0 would only restate Found, and it is the rule ErrorLiteralZero and ErrorStaticFinalConstant already pin. The cost is ten(port) with return 0; under port < 0 (ReturnConstant): main showed port == -1, which says which return failed. The VC cannot tell that case apart from take(value) under port > 0, where port == 1 misleads. In both, a guard is unlinked to an Expected term that Found fixes. When the return value is computed from the path (return port + 1;), the path value is linked and shown.
  • Ghost applications whose arguments are not plain variables (size(old(this))) are not matched. The model names them by Z3's rendering of the argument.
  • !(x != v) is not rewritten to x == v. Hiding that value would hide the breaking value itself (t == -3 against t >= 0).
  • Declaring a ghost class as a nested static class makes Z3 fail with Sort mismatch … supplied sort is …$Bag. This is an unrelated, existing bug, so the ghost fixtures use top-level classes.

Fixes #453

🤖 Generated with Claude Code

@CatarinaGamboa

Copy link
Copy Markdown
Collaborator Author

[Codex review] The fallback still produces a counterexample that points at the wrong value when Found already states the value that violates Expected. Reproducer: take(@Refinement("_ > 1") int value), then int value = 0; if (port > 0) take(value);. On this head the error says port > 0 && value == 0 is not a subtype of value > 1, but the counterexample is only port == 1. In filterCounterexample, isKnown removes value == 0; the linked assignment list becomes empty, and the fallback selects an unrelated Found term. This recreates the distracting-path-value problem the PR aims to fix. When Found already explains the failing Expected term, please omit the counterexample or display that known value explicitly; reserve a path-context fallback for a case where the path value actually explains the violation. The focused counterexample tests passed, but none covers this shape.

@CatarinaGamboa

Copy link
Copy Markdown
Collaborator Author

[Claude] Fixed in 712759f: the fallback is gone. The counterexample now keeps only the values linked to Expected that Found does not already state. So when Found fixes every value Expected depends on, there is no counterexample. Your reproducer now shows none instead of port == 1. That is the same rule ErrorLiteralZero already pins: we don't repeat a known value. A path condition is still shown when Expected's value is computed from it: return port + 1; under port < 0 gives #ret == 0 && port == -1.

Covered by:

  • TestCounterexamples, negative: argumentFoundAlreadyStatesHasNoCounterexampleUnderAGuard (your reproducer), …UnderTwoGuards, argumentKnownThroughAnAliasHasNoCounterexampleUnderAGuard, returnedConstantFoundAlreadyStatesHasNoCounterexample, ghostValueFoundAlreadyStatesIsOmittedWithTheUnrelatedPathCondition
  • TestCounterexamples, positive: returnComputedFromThePathConditionShowsBoth
  • RefinementErrorCounterexampleTest: omitsThePathConditionWhenFoundStatesEveryLinkedValue and keepsAPathConditionTheExpectedValueIsComputedFrom

The trade-off for return 0; under a guard is noted in the PR body.

@CatarinaGamboa

Copy link
Copy Markdown
Collaborator Author

[Codex re-review] Resolved. The fallback is removed, and the new tests cover the guarded argument case, aliases, multiple guards, and return values computed from path conditions. The PR’s CI is green. My port == 1 reproducer now correctly yields no counterexample when Found already fixes the expected value.

CatarinaGamboa and others added 3 commits November 3, 2026 14:20
The counterexample kept only the binders of the simplified found, so a
variable found knows nothing about (simplified to true) lost its value,
while unrelated path conditions and unmentioned path binders stayed.

It now keeps the variables and ghost applications of the expected
refinement plus those of the found conjuncts linked to them, still hiding
values found already states. Terms only expected mentions come first,
generated names last, the rest in the order of the VC binders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… read

Review follow-ups for the counterexample filter:
- Negated disjunctions and double negations are split into separate facts
  before linking, so one combined guard no longer links every variable in
  it to expected.
- The terms of expected come before the terms linked to them.
- Known values are compared structurally, so enum constants (Format.JPG
  against the model's JPG) and ghost applications with several arguments
  are recognised.
- Array values are left out of the model; their ghosts (length(a)) remain.
- When every linked value is known, the counterexample falls back to the
  other terms found shows, such as the path condition of a constant return.
- The fresh name prefix lives in Formats, and ghost application names are
  formatted with a nesting-aware scanner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The fallback to the other terms found shows could point at an unrelated
path condition (port == 1 for take(value) with value == 0 already in
found). Only terms linked to expected are now kept, so when found states
all of them the counterexample is empty, as for a literal argument.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@CatarinaGamboa
CatarinaGamboa force-pushed the fix/counterexample-expected-vars branch from 712759f to b9d7e9b Compare November 3, 2026 14:26
@CatarinaGamboa
CatarinaGamboa merged commit 00cdb11 into main Nov 3, 2026
1 check passed
CatarinaGamboa added a commit that referenced this pull request Nov 3, 2026
…#459)

## Problem

When a method reads a field, Found shows a row for it under its internal
name, and the row says only `true`. Issue reproducer (`ArrayList.size()`
refined `_ == size(this) && _ >= 0`, `Random.nextInt(bound)` refined
`bound > 0`):

```
∀this#shop.Basket.items   true →
∀bound⁶                   bound⁶ == size(items) && bound⁶ >= 0
```

In the IDE the first row wraps over several lines, above the row that
matters. The message and the later row also write the field as `items`,
so it reads like the local it was copied into.

![A2_10 in VS Code,
before](https://raw.githubusercontent.com/liquid-java/liquidjava/pr-screenshots/issues/457/verification.png)

## Cause

1. `VCBinderSimplification` dropped a `true` binder only when no later
row mentioned its variable. Here `size(this#shop.Basket.items)` mentions
it. Every field, parameter, call result or local that is only passed
along keeps its `true` row this way. The `∀getUndoManager⁸⁵⁶ true` and
`∀#fresh… true` rows of #375 come from the same rule.
2. `VariableFormatter.format` left `this#<package>.<Class>.<field>`
unchanged. The binder, the counterexample and the extension's context
and translation-table keys therefore showed the internal name, while
`ExpressionNameResolver` cut it down to the bare `items` inside
expressions.

## Fix

### Which `true` rows stay

A binder refined only by `true` states no fact. It stays in Found only
when the error needs it:

- **Linked to Expected.** Some fact of the chain constrains the binder's
*value* and is linked to Expected, meaning it shares a term with
Expected directly or through other linked facts.
- Terms are variables and ghost or state applications such as `size(x)`.
- Facts are the rows' conjuncts. A negated disjunction is split first:
`!(p || q)` becomes `!p` and `!q`, so one combined guard does not link
everything.
- A variable of an **object** type that a fact names only as a ghost
argument (`x` in `b == size(x)`) is not constrained by that fact. Its
row would say nothing, so it goes. A primitive argument (`k` in `y ==
f(k)`) has a value of its own and is kept.
- **Expected constrains its value, in a refinement error only.** The
counterexample then shows that value; `filterCounterexample` lists only
binders. A state error has no counterexample, so there this alone keeps
nothing.

The rule depends only on the chain and Expected, not on Z3's model, and
it works the same way for both error kinds.

A kept `true` binder that comes after a row using its variable is moved
to just before that row. This is a separate `Binder` step.

Examples:

| Case | Rows (after) |
|---|---|
| issue reproducer | `∀bound⁶ bound⁶ == size(this.items) && bound⁶ >= 0`
(field only inside `size(...)`) |
| `if (port < 0) return; positive(t);` | `∀#fresh !(port < 0)`, `∀t
true`: `port` dropped (unlinked guard), `t` kept (Expected,
counterexample `t == 0`) |
| `if (p \|\| q) return; positive(t);` | `∀#fresh !(p \|\| q)`, `∀t
true`: `p` and `q` dropped |
| `int y = x + 1` refined `_ > 0` | `∀x true`, `∀y y == x + 1`: `x`
kept, linked through `y` |
| `ErrorStateHintPathDependent` (state) | `∀ready true`, `∀f ready ?
open(f) : created(f)`: `ready` kept |
| state errors whose Found is otherwise `true` | unchanged from `main`:
no `∀it true` / `∀this true` row is added |
| `for (int i = start; i < served; i++) positive(i);` | `∀Basket.served
true`, `∀i true`, `∀#fresh i < Basket.served`: `i` now before its first
use |

The linking is `VCSimplificationUtils.linkedConjuncts(conjuncts,
termsOf, seed)`. It does what `linkedTerms` in `RefinementError` does on
#460 (branch `fix/counterexample-expected-vars`, issue #453): the same
closure over shared terms, with the term function passed in. Once both
are merged, `linkedTerms` can call this helper with its `termsOf`, and
`splitConjuncts` can replace `getConjuncts` there, so the counterexample
and the rows are linked in the same way. I didn't touch
`filterCounterexample`.

### Simplification history (the VS Code arrows)

Dropping a `true` row is an ordinary simplification step of the `Binder`
pass, one binder per step. It goes into `VCSimplificationResult`'s
origin chain, which the extension's server sends whole
(`VCSimplificationResultDTO.origin`). The panel steps back through that
chain, and its last step is the untouched `Original` with every binder.

What the panel shows for the issue reproducer:

| Step | `main` (2 steps) | this branch (3 steps) |
|---|---|---|
| 1 Original | `∀this#shop.Basket.items true`<br>`∀items⁵ items⁵ ==
this#shop.Basket.items`<br>`∀bound⁶ bound⁶ == size(items⁵) && bound⁶ >=
0` | `∀this.items true`<br>`∀items⁵ items⁵ == this.items`<br>`∀bound⁶
bound⁶ == size(items⁵) && bound⁶ >= 0` |
| 2 after Substitution | (Simplified) `∀this#shop.Basket.items
true`<br>`∀bound⁶ bound⁶ == size(items) && bound⁶ >= 0` | `∀this.items
true`<br>`∀bound⁶ bound⁶ == size(this.items) && bound⁶ >= 0` |
| 3 after Binder | | (Simplified) `∀bound⁶ bound⁶ == size(this.items) &&
bound⁶ >= 0` |

The field display name is applied at every step, including Original,
because the server formats every step with `VariableFormatter.format`.
The internal names are untouched in the history itself.
`TestFoundBinders.steppingBackReachesTheOriginalWithTheDroppedRow`
checks that the original's binders are
`this#testSuite.classes.found_true_binders_error.Basket.items`,
`#items_…`, `#bound_…`. Showing the Original raw would be a one-line
change in `VCImplicationDTO` (skip `format` for the oldest step), but I
left that for the PM to decide.

### Source-like field names

`this#pkg.Class.field` is written `this.field` in the message, the Found
rows, the counterexample and the extension's context and translation
table. This holds for both `this.x` and bare `x`. A static field is
written `Class.field`. Instance counters stay (`this.interrupts¹¹³`),
nested classes and long package names reduce the same way, and the
**internal SMT name is unchanged**.

Static-ness is not in the name, so `Utils.qualifyFieldName` records
static fields as it builds their names, and `CommandLineLauncher.launch`
clears the record at the start of each run. Every error comes from code
verified in that run, which also built the names of the fields it shows,
so the record is complete for that run's errors. Verifying a static
`total`, then an instance `total` of the same class, then the static one
again in one JVM shows `C.total`, `this.total`, `C.total`. There is no
incremental verification that skips classes. If one is added, the record
would need to be filled from the field declarations instead.

I removed the earlier class-based disambiguation (`Basket.this.count`).
Real code could not reach it, it was not valid Java for inherited
fields, and it was not applied to binder rows or translation-table keys.
Two different fields with the same simple name in one expression (a
shadowed field) now both read `this.x`.

### Writes to another object's field (display only)

This PR does not change what the verifier accepts or rejects for any
write. A write to another object's field (`other.level = …`,
`other.level++`, `other.level--`, `other.level += …`) is still checked
and tracked exactly as on `main`, against the current object's field
variable.

Only its label changes. When `TypeChecker.checkVariableRefinements`
creates the new instance for a write that `Utils.accessesTrackedField`
says is not to the current object, it records the receiver's variable
name for that instance in `VariableFormatter` (a bare field name when
the receiver is not a variable). The instance is then written
`other.level¹` instead of `this.level¹`:

```
main:   level¹ == -1 is not a subtype of level¹ >= 0
now:    other.level¹ == -1 is not a subtype of other.level¹ >= 0
```

Because tracking is unchanged, a decrement shows what the verifier
actually uses as the old value: `other.level⁹ == this.level⁸ - 1`.
Writes through another object leak into `this.level`, and aliasing with
`this` is not handled. Both are left for their own issue.

Parity with `main`, checked on the reviewers' reproducers and
`ErrorOtherObjectFieldWrite.java`:
- `other.level++`, `other.level += 1` and `other.level = other.level +
1` verify.
- `other.level--` and `other.level = -1` are reported.
- The write through `W other = this` and `maybeSelf(this)` are reported.
- The `other.level = 5; int k = this.level` (`_ == 5`) case verifies as
on `main`.

### #375

This covers part of #375, not all of it. The `true` rows go, and in the
JabRef `EditAction` exercise all four are removed. The path conditions
over now-undeclared fresh variables (`∀#fresh¹²² !#fresh¹²¹`) are not
`true` rows and stay; they also tie in with #373. #375 stays open.

## Before / after (main vs this branch, every error)

I ran the harness that dumps what the IDE gets (message, rows,
counterexample, translation-table keys) over 321 test-suite errors and
117 errors from the 98 study exercises, with `main` and this branch on
identical inputs:

- The same errors are reported at the same lines on both sides (the
harness requires it), so nothing that `main` accepts or rejects changes.
- 58 test-suite errors and 57 exercise errors differ in what they show.
- The differences are renames (`this#…` to `this.x` or `Class.x`),
dropped `true` rows, the `Binder` reordering, and in refinement errors a
kept `true` row for an Expected variable. In those refinement errors
`main` showed only `true`; now the variable's counterexample value also
shows, e.g. `port == 65536`.
- `true` rows: 110 → 87 (test suite), 78 → 46 (exercises). Raw `this#`
names: 30 → 0 and 72 → 0.
- No state error gains a `true` row.
- **Instance numbers do not move**: 0 of the 438 messages change their
superscripts. I could not reproduce the `transactionCommitted²⁷→²⁸`
shift (A1_50 shows `²⁸` on both). I think it came from different inputs
between the two runs.

**Several fields** (`pickAny`)
```
before: ∀this#testSuite.classes.found_true_binders_error.Basket.items³⁷   true
        ∀first³⁸   first³⁸ == size(items³⁷) && first³⁸ >= 0
        ∀this#testSuite.classes.found_true_binders_error.Basket.extras⁴¹   true
        ∀second⁴²  second⁴² == size(extras⁴¹) && second⁴² >= 0
        ∀bound⁴⁵   bound⁴⁵ == first³⁸ + second⁴²
after:  ∀first³⁸   first³⁸ == size(this.items³⁷) && first³⁸ >= 0
        ∀second⁴²  second⁴² == size(this.extras⁴¹) && second⁴² >= 0
        ∀bound⁴⁵   bound⁴⁵ == first³⁸ + second⁴²
```

**Static field, linked to Expected** (`serve`)
```
before: count⁵⁴ == served + 1 is not a subtype of count⁵⁴ > 0
        ∀this#testSuite.classes.found_true_binders_error.Basket.served   true
        ∀count⁵⁴   count⁵⁴ == served + 1
after:  count⁵⁴ == Basket.served + 1 is not a subtype of count⁵⁴ > 0
        ∀Basket.served   true
        ∀count⁵⁴   count⁵⁴ == Basket.served + 1
        Counterexample: Basket.served == -1 && count⁵⁴ == 0
```

**Field holding a state kept, unrelated `true` parameter dropped**
(`classes/found_field_state_error`, after Jpcsp's `IntrManager`)
```
before: ∀intrNumber   true
        ∀#fresh¹⁶     !(intrNumber < 0 || intrNumber >= 67)
        ∀this#testSuite.classes.found_field_state_error.InterruptManager.interrupts¹³   empty(interrupts¹³)
after:  ∀#fresh¹⁶     !(intrNumber < 0 || intrNumber >= 67)
        ∀this.interrupts¹³   empty(this.interrupts¹³)
```

## Tests

- `TestFoundBinders` (19 tests) on the fixtures
`classes/found_true_binders_error`, `classes/found_field_state_error`,
`ErrorStateHintPathDependent.java` and the new
`ErrorOtherObjectFieldWrite.java`:
- dropped: the reproducer's field row; a `true` parameter, call result
and local; several fields; `port` behind an unrelated guard; `p`/`q`
behind one combined guard.
- kept: `x` linked through `y == x + 1`; an Expected variable in a
refinement error; `ready` in the path-dependent state error; the field
row holding `empty(...)`.
  - row order for a loop variable.
- names: static `Basket.served`; `this.amount` and bare `amount` written
alike; an inner-class field; `other.level` for writes and decrements on
another object, and `this.level` for an own write.
- parity with `main` for writes: `other.level++` and `other.level += 1`
verify; `other.level--` and the write through an alias of `this` are
reported.
- the original the panel steps back to keeps the dropped row and the raw
binder names, and the history includes a `Binder` step.
- `VCBinderSimplificationTest` (13 new, including a primitive ghost
argument that is kept):
  - linked through one or several rows;
  - an unlinked guard;
  - a split negated disjunction;
  - an Expected binder in refinement vs state errors;
  - linking through a ghost while keeping only value variables;
  - an object named only inside a ghost;
  - moving a kept binder before its first use;
  - the `NeededVariables` factories.
- `VCSimplificationUtilsTest` (4): splitting conjuncts and negated
disjunctions; transitive linking.
- `VariableFormatterTest` (6 new) and `ExpressionNameResolverTest` (2
new): instance, static, nested, long-package and other-object field
names; clearing the static record between runs; a field next to a local
of the same name; a field inside a ghost argument.

Without the fix (`main` sources, the new tests kept), `TestFoundBinders`
fails 15 of 19 and `ExpressionNameResolverTest` fails 2 of 5. The unit
tests on the new API do not compile on `main`.

`mvn test` (verifier): **583 tests, 0 failures** (`main`: 534).

**Changed existing expectations**
-
`VCBinderSimplificationTest.keepsTrueBinderWhenVariableIsUsedDownstream`
became `removesTrueBinderUsedDownstreamWhenNothingNeedsIt`: with nothing
to link to, the binder goes. That is the rule change itself.
-
`VCBinderSimplificationTest.skipsInapplicableTrueBinderAndSimplifiesLaterBinder`
now makes `x` an Expected variable, so its binder is still the
inapplicable one the test is about. The expected steps are unchanged.
- `VCTestUtils.parseImplication` splits a binder at the first `.` after
the `:`, so binder names can contain dots.

No `// Expect:` fixture, `TestCounterexamples` or `TestErrorCategories`
expectation changed.

## Not in this PR

- Raw names in the Original step (above).
- `random.nextInt(items.size() + extras.size())` fails on `main` with
`Sort mismatch … supplied sort is …Basket`, a separate translation bug.
The fixture uses two locals instead.
- The client's hover (`getOriginalVariableName`) strips only `this#`, so
it does not match fields, before or after this change. Stripping a
leading `this.` there would be a small follow-up in vscode-liquidjava.

Fixes #457

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <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.

Counterexample leaves out the variable the expected refinement constrains, when Found knows nothing about it

1 participant