Skip to content

Show the states of named sets as values of their sets in state errors - #511

Merged
CatarinaGamboa merged 4 commits into
mainfrom
feat/502-state-messages
Nov 9, 2026
Merged

CatarinaGamboa merged 4 commits into
mainfrom
feat/502-state-messages

Conversation

@CatarinaGamboa

Copy link
Copy Markdown
Collaborator

Part of #502 (PR 2, messages). Depends on #510, which depends on #507 and #508. Only the last commit belongs here.

What

State errors show the states of named sets as values of their sets, whichever form the spec used. ErrorStateHintMultipleSets (sets doorState and lockState):

- found closed(d@26) && locked(d) && doorState(d) == doorState(d@26) but expected closed(d) && unlocked(d)
+ found doorState(d@26) == closed && lockState(d) == locked && doorState(d) == doorState(d@26) but expected doorState(d) == closed && lockState(d) == unlocked

It is now plain which set fails (only lockState), and the frame equality chains on its own: doorState(d) == doorState(d@26) and doorState(d@26) == closed.

!open(x) is shown as door(x) != open. A state keeps the shorthand when its set has a generated name (state2(x) == closed reads worse than closed(x)), or when sets with different names have a state of that name. That way the set printed is never a guess.

How

StateValues.toSetForm rewrites expressions for display only. StateRefinementError receives it as display:

  • The facts to show are still chosen on the expressions as given. linkedToExpected and Add a Relevant to Expected step to state errors #495's relevance step run first. A shared state name such as closed, which is a variable in the rewritten form, would otherwise link facts about unrelated objects.
  • The message, getExpected() and every derivation step (VCSimplificationResult.map) hold the rewritten form, so the IDE shows it without a server change: the server formats those expressions as they are.

Nothing the verifier decides changes: the VC, the verdict, the hints and the category are the same. Refinement errors (RefinementError) keep the shorthand for now. They also build alias expansions and counterexamples from the same expressions, so that would be its own change.

Formats.STATE_SET names the generated stateN, which the display needs to recognise.

Tests

  • TestStateHints#foundKeepsANamedStateSetByItsName: the message above. It fails without the change.

The full suite passes locally.

🤖 Generated with Claude Code

@CatarinaGamboa

Copy link
Copy Markdown
Collaborator Author

Codex review, round 1 (codex exec review, this PR's commit only)

[P2] Distinguish generated set names from explicit names (StateValues.java). When a user explicitly names a state set state1 or state2, that name is accepted by stateSetName, but this pattern treats it as generated and leaves open(x) in state error messages.

Fixed, by keeping those names for sets without a name. An explicit name = "state2" is now an error: State set name 'state2' is kept for state sets without a name. Such a name could already clash with a generated one, for example an unnamed second set. With it reserved, the spelling tells exactly which sets were named, and there is no flag to carry. The pattern lives once, as Formats.STATE_SET_NAMES, next to STATE_SET, and both the check and the display use it.

Test: one line in ErrorStateSetNames.

Rebased on #510 after its round 4 fix.

Clean-code review (clean-code-principles)

  • solid-srp / core-separation-of-concerns: deciding what to show stays in ErrorMessage, on the expressions as given. Rewriting for display is one function, StateValues.toSetForm, applied last. The error stores the displayed form, so the CLI and the IDE cannot disagree.
  • solid-ocp: ErrorMessage.of takes the rewrite as a parameter. RefinementError passes identity(), and moving it to the new form later is a one-line change there.
  • core-dry: VCSimplificationResult.map rewrites every derivation step through one method. Formats.STATE_SET and STATE_SET_NAMES replace the inline "state%d".
  • name-meaningful: toSetForm, setValue, display.
  • Tests: the existing message test now asserts the new form, plus one fixture line for the reserved names.

@CatarinaGamboa
CatarinaGamboa force-pushed the feat/502-state-messages branch from f240944 to 51a2e6b Compare November 9, 2026 18:33
@CatarinaGamboa

Copy link
Copy Markdown
Collaborator Author

Codex review, round 2

[P2] Resolve the state's owning set before rewriting it. When two loaded classes have a state with the same name in differently named sets, both match by simple name, so even A.open(x) is left in the shorthand.

Declined: this is the intended fallback. An invocation's qualifier is the class where the spec was written, not the class that owns the set. A spec in A about a Door parameter writes A.open(d), and the solver resolves it through d's type. Picking a set by qualifier could therefore print the wrong set name. The display only rewrites when the set name is certain, and otherwise keeps the shorthand, which is still correct. That was in the PR description from the start ("so that the set printed is never a guess"). No code change.

@CatarinaGamboa

Copy link
Copy Markdown
Collaborator Author

Codex review, round 3

[P2] A ghost can be displayed as a state of an unrelated set. A boolean @Ghost("boolean ready") on Sensor was shown as phase(#new_Sensor) == ready == false, because an unrelated class has a set phase with a state ready.

Fixed. The display matched by name only. It now keeps the shorthand when a ghost that is not a state (a @Ghost, or a @RefinementPredicate) has that name, since the name alone does not tell which one it is. toSetForm takes the context's ghosts for that.

Regression: ErrorStateValueGhostName and TestStateHints#ghostNamedLikeAStateOfAnotherClassKeepsItsForm, which expects found ready(sensor) == false but expected ready(sensor). The full suite passes locally.

@CatarinaGamboa

Copy link
Copy Markdown
Collaborator Author

Codex review, round 4

[P2] Reserve only names the verifier can generate. state\d+ also matches state0 and state01, which are never generated, so name = "state0" was rejected.

Fixed: the reserved names are now state[1-9][0-9]*, exactly what Formats.STATE_SET produces. The full suite passes locally.

With this, the review loop for this PR is done: four rounds, three real fixes (reserved names, ghosts named like states, this one) and one intended fallback declined.

@CatarinaGamboa
CatarinaGamboa force-pushed the feat/502-state-messages branch 2 times, most recently from 47bbf25 to 9f0664b Compare November 9, 2026 21:00
CatarinaGamboa and others added 4 commits November 9, 2026 21:42
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rrors

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@CatarinaGamboa
CatarinaGamboa force-pushed the feat/502-state-messages branch from 9f0664b to f764dc0 Compare November 9, 2026 21:44
@CatarinaGamboa
CatarinaGamboa merged commit 9d8ccad into main Nov 9, 2026
1 check passed
CatarinaGamboa added a commit that referenced this pull request Nov 9, 2026
…512)

Part of #502 (PR 3, docs). **Depends on #511** (and so on #510, #508 and
#507). Only the last commit belongs here.

The README's typestate example now names its set and writes states as
values of it:

```java
@stateset(name="connection", value={"unconnected", "bound", "connected", "closed"})
...
@StateRefinement(from="connection(this) == unconnected", to="connection(this) == bound")
public void bind(SocketAddress add);
...
@StateRefinement(from="connection(this) != closed", to="connection(this) == closed")
public void close();
```

A short paragraph says that the shorthand `bound(this)` is still
accepted, that a `to` keeps the sets it does not mention, and what the
error looks like. That error was checked by running the README example
on #511:

```
State Refinement Error: found connection(socket) == bound but expected connection(socket) == connected
```

The study exercises live in the study repository and are not changed
here.

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
CatarinaGamboa added a commit that referenced this pull request Nov 10, 2026
…re (#517)

Closes #515

## What and why
Since #511 the headline of a state error shows a state of a **named**
set as a value of its set (`lockState(d) == locked`), but the hints and
"Refinement declared here" still used the shorthand (`locked`,
`closed(this)`). One message used several notations for the same states.
Now:

1. **Hints** name each state of a named set exactly as the headline
does, with the receiver: `lockState(d) == locked` (PM decision). The
sentences that said "d is X" or "needs it to be X" now state the
condition: `when it returns true, undoState(um) == undoable`, `this call
needs undoState(um) == undoable`, `When doorState(d) == closed and
lockState(d) == locked, d can call: ...`. In that form, a state of an
unnamed set in the same class is shown as the headline shows it too,
`closed(d)`. Classes with no named set keep the current wording (`In
state created, f can call: ...`).
2. **Alternatives in hint state lists are grouped**: `(a and b) or (c
and d)`. This applies to unnamed sets too, since the problem is the
same.
3. **"Declared here" stays verbatim** (it is source). When the
highlighted declaration writes states of named sets in the shorthand,
one `where` line under it says what they mean, for those states only:
`where closed(this) means doorState(this) == closed, ...`. It is the
same `where` style the alias notes of refinement errors use.

Display only: the VC, the verdict, which hints are given and the
category do not change.

**Same rule as the headline.** Hints and the gloss both use the rule of
`StateValues.toSetForm`. Its set lookup is now exposed as
`StateValues.setNameOf` (the hints use it), and the gloss runs the
error's own `display` rewrite on each `state(arg)` call in the
declaration. So:
- states of unnamed (`stateN`) sets keep the shorthand;
- a state name shared by sets with different names keeps the shorthand,
so no set is guessed. So does a set name shared by sets of different
classes, e.g. a class and its superclass;
- a negation is never turned into a value: hints describe the machine's
states as before, and the gloss explains `locked(this)`, never
`!locked(this)`, so `!locked(this)` reads as `!(lockState(this) ==
locked)`. A declaration in value form (`lockState(this) != locked`) gets
no gloss, since it already uses the headline's notation.

## Before / after (CLI, ANSI colours stripped)

### `ErrorStateHintMultipleSets` (sets `doorState`, `lockState`)
Before:
```
State Refinement Error: found doorState(d@26) == closed && lockState(d) == locked && doorState(d) == doorState(d@26) but expected doorState(d) == closed && lockState(d) == unlocked
26 |         ErrorStateHintMultipleSets d = new ErrorStateHintMultipleSets();
27 |         d.lock();
28 |         d.open(); // Expect: State Refinement Error
   |         ^^^^^^^^^
29 |     }
30 | }

Hints:
  - Call d.unlock() before this call to move d from locked to unlocked
  - In state closed and locked, d can call: unlock()

door/ErrorStateHintMultipleSets.java:28

--> Refinement declared here:
12 | 
13 |     @StateRefinement(from = "closed(this) && unlocked(this)", to = "opened(this)")
   |                              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
14 |     public void open() {}

door/ErrorStateHintMultipleSets.java:13
```
After:
```
State Refinement Error: found doorState(d@26) == closed && lockState(d) == locked && doorState(d) == doorState(d@26) but expected doorState(d) == closed && lockState(d) == unlocked
26 |         ErrorStateHintMultipleSets d = new ErrorStateHintMultipleSets();
27 |         d.lock();
28 |         d.open(); // Expect: State Refinement Error
   |         ^^^^^^^^^
29 |     }
30 | }

Hints:
  - Call d.unlock() before this call to move d from lockState(d) == locked to lockState(d) == unlocked
  - When doorState(d) == closed and lockState(d) == locked, d can call: unlock()

door/ErrorStateHintMultipleSets.java:28

--> Refinement declared here:
12 | 
13 |     @StateRefinement(from = "closed(this) && unlocked(this)", to = "opened(this)")
   |                              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
14 |     public void open() {}
  where closed(this) means doorState(this) == closed, unlocked(this) means lockState(this) == unlocked

door/ErrorStateHintMultipleSets.java:13
```

### The same spec in value form with `!=` (`from = "doorState(this) ==
closed && lockState(this) != locked"`)
Before:
```
State Refinement Error: found doorState(d@26) == closed && lockState(d) == locked && doorState(d) == doorState(d@26) but expected doorState(d) == closed && lockState(d) != locked
26 |         ErrorStateHintMultipleSets d = new ErrorStateHintMultipleSets();
27 |         d.lock();
28 |         d.open(); // Expect: State Refinement Error
   |         ^^^^^^^^^
29 |     }
30 | }

Hints:
  - Call d.unlock() before this call to move d from locked to unlocked
  - In state closed and locked, d can call: unlock()

doorne/ErrorStateHintMultipleSets.java:28

--> Refinement declared here:
12 | 
13 |     @StateRefinement(from = "doorState(this) == closed && lockState(this) != locked", to = "opened(this)")
   |                              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
14 |     public void open() {}

doorne/ErrorStateHintMultipleSets.java:13
```
After (no gloss: the declaration is already in value form; the hints
name the machine's states as before, only in the new notation):
```
State Refinement Error: found doorState(d@26) == closed && lockState(d) == locked && doorState(d) == doorState(d@26) but expected doorState(d) == closed && lockState(d) != locked
26 |         ErrorStateHintMultipleSets d = new ErrorStateHintMultipleSets();
27 |         d.lock();
28 |         d.open(); // Expect: State Refinement Error
   |         ^^^^^^^^^
29 |     }
30 | }

Hints:
  - Call d.unlock() before this call to move d from lockState(d) == locked to lockState(d) == unlocked
  - When doorState(d) == closed and lockState(d) == locked, d can call: unlock()

doorne/ErrorStateHintMultipleSets.java:28

--> Refinement declared here:
12 | 
13 |     @StateRefinement(from = "doorState(this) == closed && lockState(this) != locked", to = "opened(this)")
   |                              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
14 |     public void open() {}

doorne/ErrorStateHintMultipleSets.java:13
```

### `state_hint_study_undo_manager_error`, sets named `phase` /
`undoState` / `redoState`, with `if (um.canRedo()) um.redo();
um.undo();`
Before:
```
State Refinement Error: found (#canRedo_result ? redoState(um@27) == redoable : redoState(um@27) == notRedoable) && undoState(um@27) == undoState(#getUndoManager_result) && (#canRedo_result ? undoState(um) == undoable && redoState(um) == notRedoable && phase(um) == phase(#getUndoManager_result) : (#canRedo_result ? redoState(um) == redoable : redoState(um) == notRedoable) && phase(um) == phase(#getUndoManager_result) && undoState(um) == undoState(#getUndoManager_result)) but expected undoState(um) == undoable
26 |         CountingUndoManager um = frame.getUndoManager();
27 |         if (um.canRedo()) um.redo();
28 |         um.undo(); // Expect: State Refinement Error
   |         ^^^^^^^^^^
29 |     }
30 | 

Hints:
  - Guard this call with if (um.canUndo()): when it returns true, um is undoable
  - In state undoable and notRedoable or notUndoable and notRedoable, um can call: canUndo(), canRedo() (and possibly others)

undo/EditCommand.java:28

--> Refinement declared here:
62 | 
63 |     @StateRefinement(from="undoable(this)", to="notUndoable(this) && redoable(this)")
   |                            ^^^^^^^^^^^^^^
64 |     void undo();

undo/UndoManagerRefinementsExpert.java:63
```
After:
```
State Refinement Error: found (#canRedo_result ? redoState(um@27) == redoable : redoState(um@27) == notRedoable) && undoState(um@27) == undoState(#getUndoManager_result) && (#canRedo_result ? undoState(um) == undoable && redoState(um) == notRedoable && phase(um) == phase(#getUndoManager_result) : (#canRedo_result ? redoState(um) == redoable : redoState(um) == notRedoable) && phase(um) == phase(#getUndoManager_result) && undoState(um) == undoState(#getUndoManager_result)) but expected undoState(um) == undoable
26 |         CountingUndoManager um = frame.getUndoManager();
27 |         if (um.canRedo()) um.redo();
28 |         um.undo(); // Expect: State Refinement Error
   |         ^^^^^^^^^^
29 |     }
30 | 

Hints:
  - Guard this call with if (um.canUndo()): when it returns true, undoState(um) == undoable
  - When (undoState(um) == undoable and redoState(um) == notRedoable) or (undoState(um) == notUndoable and redoState(um) == notRedoable), um can call: canUndo(), canRedo() (and possibly others)

undo/EditCommand.java:28

--> Refinement declared here:
62 | 
63 |     @StateRefinement(from="undoable(this)", to="notUndoable(this) && redoable(this)")
   |                            ^^^^^^^^^^^^^^
64 |     void undo();
  where undoable(this) means undoState(this) == undoable

undo/UndoManagerRefinementsExpert.java:63
```
The fixture's two other errors change the same way: `this call needs
redoState(undoManager) == redoable`, `when it returns true,
redoState(undoManager) == redoable`, and `where redoable(this) means
redoState(this) == redoable`.

## Decided by the PM
- Hints use the headline's exact form with the receiver (`lockState(d)
== locked`), including grouped alternatives and the guard and "needs"
sentences.
- The gloss keeps the spec's own form with `(this)`: `where closed(this)
means doorState(this) == closed, ...`.

## Still open for the PM
- **Grouping.** Parentheses appear only when there are several
alternatives and an alternative has several states. The issue also
suggests naming only the set that matters (e.g. only `undoState`). That
would change which states a hint names, not only how they are written,
so it is not in this PR.

## IDE
Hints reach vscode-liquidjava as plain message text, so the new notation
shows there with no change. The extension does not show the declaration
excerpt, only a location link, so the gloss needs a server and client
change: liquid-java/vscode-liquidjava#181 (label `study`).

## Tests
The existing exact-message tests in `TestStateHints` are updated, and
one test is added. Each one fails on main:
- `callSequenceNamesOnlyTheStateSetsItChanges`: the hints for
`doorState`/`lockState` (named sets), in the headline form.
- `queryWrittenWithItsStateSetIsOfferedAsAGuard`: needed state and guard
for set `lock` (named set, value-form spec).
- `joinsAreNotTakenForAnUnknownState`: grouping, with unnamed sets,
which keep the shorthand.
- `foundKeepsANamedStateSetByItsName`: also checks the `where` line
under the declaration.
- New: `setNameOfTheClassAndOfItsSuperclassKeepsTheShorthand` (fixture
`ErrorStateSetNameInSuperclass`), from Codex round 1. When a class and
its superclass each have a set named `phase`, the headline (#511's rule)
and the hints printed `phase(c) == ready && phase(c) == busy`, which
reads as two values of one set. `StateValues.setNameOf`, shared by both,
now keeps the shorthand in that case.

The other hint tests use unnamed sets and pass unchanged, so the
shorthand is kept there. The full suite passes locally (728 tests).

🤖 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.

1 participant