Skip to content

Found: rewrite facts through ghost equalities g(x) == g(x@k) so they are stated about x #501

Open

Description

@CatarinaGamboa

Problem

A state error's Found keeps every call's frame equality, g(x) == g(x@k), next to the facts about the older instance x@k. The reader has to chain them in their head to see what x is now:

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

says "d is closed" in two steps. Written directly about d, the clash with Expected is plain:

found closed(d) && locked(d) but expected closed(d) && unlocked(d)

VCFunctionSubstitution already propagates f(...) == e, but only into later rows and only for an exact syntactic match. A state is written closed(d@26), not doorState(d@26) == 0, so it never matches and the equality stays.

Proposal: a general display step, rewrite through ghost equalities

Source equality. g(x) == g(y) where g is a ghost function (a state set, named or stateN, or any @Ghost such as size or r) with the same argument tuple shape, and either:

  • the equality is a top-level conjunct of Found, in which case it rewrites across all of Found; or
  • it sits inside a branch of c ? … : …, in which case it rewrites only within that branch, and the guard stays. The equality is dropped only if g(y) occurs nowhere outside that branch. Otherwise it stays: rewriting under an equality that holds is still exact, but eliminating g(y) is not.

An equality under ||, !, or on the left of --> is never used.

Conditions on the eliminated term g(y):

  1. Expected does not mention g(y).

  2. Let the fresh variables be the argument variables of g(y) that do not occur anywhere in the arguments of g(x). Occurrence is by variable identity across the whole tuple, so a variable moved to another position is not fresh. There must be at least one fresh variable (so g(a,b) == g(b,a) is never eliminated). Every fresh variable has a class type, which Z3 encodes as an uninterpreted sort, and is never constrained as a value in Found or in Expected. No other application of the same SMT-resolved ghost in Found or Expected uses a fresh variable: g(y,b) next to g(x,a) == g(y,a) blocks it, because a == b would make the two the same application. The conditions are checked again before each step of a chain. That means no y == z, no y as an argument of an alias or of a non-ghost function, and no y in a ghost application whose arguments are not plain variables. This is the same guard as Add a Relevant to Expected step to state errors #495. Ghosts over boolean, enums, int or other interpreted or finite sorts are never eliminated. Facts about other ghosts of y (h(y) == 1) may stay.

  3. Under 1 and 2, replacing every g(y) (that exact application) by g(x) and dropping the equality is exact: Found' ⇒ Expected is valid iff Found ⇒ Expected is valid.

    • From a model of Found' ∧ ¬Expected, build a model of Found ∧ ¬Expected. Map each fresh variable to a fresh element of its sort. Give every ghost application involving those elements the value its original version had, and give g(y) the value of g(x). This is consistent because the tuple of g(y) now contains a fresh element, so it differs from every other tuple of g, g(x)'s included. That holds because no other application of g uses a fresh variable. Every fact keeps its truth value, because by condition 2 the fresh variables occur only as ghost arguments. The equality now holds, and Expected is unchanged.
    • The converse direction is substitution under the equality.
    • A fresh element always exists for an uninterpreted sort. For a finite sort it may not: with x == false, h(x) == 0 && h(y) == 1 forces y == true without ever comparing y. That is why condition 2 restricts the type.

    When either condition fails, the equality and the facts stay as they are.

Rewrite. Replace every occurrence of the exact application g(y) (same qualified ghost, same full argument tuple) by g(x), including inside other equalities. So g(x) == g(y) && g(z) == g(y) && g(z) == 0 becomes g(z) == g(x) && g(z) == 0, and then g(x) == 0.

A state counts as an occurrence only if its refinement is exactly g(v) == k for this g, which is the form @StateSet gives (TypeChecker builds it). So closed(d@26) becomes closed(d). A state or alias with any other refinement is an argument of a non-ghost function, which condition 2 already excludes.

Direction. The kept term is the endpoint Expected mentions. Normally that is the newest instance, which keeps the target variable visible (#374). If Expected mentions both endpoints, the equality joins two terms it sees, so it stays. If Expected mentions neither, keep the newer instance (x over x@k; a variable over a generated #…_result).

Rows. The display chain scopes variables by row. Each rewritten fact goes in the latest of its original row and the rows binding each of its variables, so x and any other variable it mentions are in scope. A fact whose row already binds all of them stays where it is.

Interaction with VCFunctionSubstitution. That pass consumes g(x) == g(y) in the opposite direction: it rewrites later rows from g(x) to g(y) and drops the equality. Running this pass first is not enough, because an equality this pass declines (for example g(x) == g(y) && y != z) would still be consumed there. Main can then already print y != z && g(y) == 1 for an Expected g(x) == 1, which no longer shows the link. So for state errors, VCFunctionSubstitution skips every equality between two ghost applications, and this pass is the only one that rewrites through them. Guards are checked against the ghost application that actually resolves in SMT (resolveFunctionDecl falls back to the simple name).

Termination. Each step removes the term g(y) from Found, so the steps stop.

Like the other steps, this only affects what is displayed: the solver's VC is unchanged and the original Found stays as the previous step. State errors only, until it is checked against the counterexamples of refinement errors.

A side effect: a frame equality whose g(y) has no other fact, and whose y meets condition 2, disappears outright. In ErrorTrafficLightRGB, r(tl) == r(tl@48) && g(tl) == g(tl@48) && b(tl) == b(tl@48) only says "the colour values were kept" and goes away.

Where it shows

  • ErrorStateHintMultipleSets: as above.
  • Study A1_56 (undo manager): undo(undoManager@70) == undo(#getUndoManager_result), limit(...) == limit(...), phase(...) == phase(...) chains through every call.
  • E_A3_07 scanner, ErrorArgumentPicksTheStateEarlier: state1(param) == state1(param@25) next to tilingOff(param@25).

Relation to other work

Tests (lean), each failing without its rule:

  • a message test on ErrorStateHintMultipleSets;
  • unit tests for a two-step chain; a declined equality that VCFunctionSubstitution must keep; an equality inside one branch that must not rewrite outside it; y == z blocking the elimination; and a ghost over a boolean argument that is not eliminated; a permuted tuple g(a,b) == g(b,a) that is kept; and g(x,a) == g(y,a) && a == b && g(y,b) == 1 that is kept.

Activity

  1. CatarinaGamboa commented on Nov 6, 2026

    @CatarinaGamboa
    CollaboratorAuthor

    Codex review of the design, round 1 (soundness)

    Codex found that the rule as first written was unsound. All five findings were real, and the issue body is now amended:

    Finding Counterexample Amendment
    Conditional equality used outside its branch (c ? g(x) == g(y) : true) && g(y) == 1, Expected g(x) == 1 Only top-level conjuncts rewrite across Found. Inside a branch, rewrite only within it, and drop the equality only if g(y) occurs nowhere else. Never use equalities under ||, ! or -->.
    Dropping the equality when y has value facts g(x) == g(y) && y == z, Expected g(x) == g(z) Condition 2: y is never constrained as a value (the same guard as #495). Under it, dropping is existential elimination of g(y) and is exact.
    Several equalities / both endpoints in Expected g(x) == g(y) && g(z) == g(y) && g(z) == 0 Rewrite every occurrence, including inside other equalities. If Expected mentions both endpoints, the equality stays. Each step removes one term, so the rewriting terminates.
    States with other refinements, multi-argument ghosts S(v) = g(v) == 0 && h(v) == 1 Match only the exact application (qualified ghost, full argument tuple), and only states whose refinement is exactly g(v) == k.
    Row scope and pass order Rewriting g(y) into a row before x's binder; VCFunctionSubstitution consuming the equality in the opposite direction Rewritten facts move into the equality's row. The pass runs before VCFunctionSubstitution, or that pass skips ghost-to-ghost equalities.

    Codex: "The design is sound once amended". Next round checks the amended text.

  2. CatarinaGamboa commented on Nov 6, 2026

    @CatarinaGamboa
    CollaboratorAuthor

    Codex review of the design, round 2 (soundness): one real gap, amended

    Codex found that the round 1 argument was wrong. It treated g(y) as an independent value, but Z3 sees a function applied to y. Over a finite sort, other facts can fix y without ever comparing it as a value:

    • x == false && z == true && h(x) == 0 && h(y) == 1 && g(x) == g(y), Expected g(x) == g(z). With boolean arguments, h(y) != h(x) forces y == z, so the original proves Expected. Dropping the equality loses that.
    • x == false && z == true && g(false,false) == 0 && g(true,true) == 0 && g(x,z) == g(y,y), Expected g(x,z) == 0. A repeated boolean argument does the same.

    Amended (condition 2 and the proof in condition 3). Every argument of g(y) not shared with g(x) must have a class type, which Z3 encodes as an uninterpreted sort. Ghosts over boolean, enums, int and other interpreted or finite sorts are never eliminated.

    The proof: from a model of Found' ∧ ¬Expected, move y to a fresh element, copy its ghost values, and set g(y) := g(x). That gives a model of Found ∧ ¬Expected. This construction is exactly what fails for a finite sort, where a fresh element need not exist. State sets and object ghosts, the use case of this issue, all take a class-typed object. A boolean test was added to the test list.

  3. CatarinaGamboa commented on Nov 6, 2026

    @CatarinaGamboa
    CollaboratorAuthor

    Codex review of the design, round 3 (soundness): proof holds, two fixes to the display chain

    Codex found no counterexample to the fresh-element proof for class-typed arguments. It checked the uninterpreted sort per class, the type views of one reference (it found the axioms that keep them in sync, and they can be given fresh elements together), static constants and enum sorts. It found no cardinality or range axiom on state sets.

    Two gaps in how the rewrite fits the display chain, both amended:

    1. A declined equality can still be consumed. g(x) == g(y) && y != z, then g(x) == 1 in a later row, with Expected g(x) == 1. This pass declines (y is compared as a value), but VCFunctionSubstitution drops the equality anyway and prints y != z && g(y) == 1. That is an existing display problem on main. Amended: in state errors, VCFunctionSubstitution skips equalities between two ghost applications, and this pass is the only one that rewrites through them.
    2. Row scope of other variables. Rewriting g(y) == h(z) to g(x) == h(z) and moving it into the equality's row can put it before z's binder. Amended: each rewritten fact goes in the latest of its own row and the binder rows of all its variables.

    Codex: "The design is sound once both amendments above are made". Next round checks the amended text.

  4. CatarinaGamboa commented on Nov 6, 2026

    @CatarinaGamboa
    CollaboratorAuthor

    Codex review of the design, round 4 (soundness): permuted tuples, amended

    Codex confirmed that the round 3 amendments (VCFunctionSubstitution skipping ghost-to-ghost equalities, and the row placement) fix their problems. It found one more case:

    • g(a,b) == g(b,a) && a == c && b == d && g(d,c) == 1, Expected g(a,b) == 1, all class typed. Every argument of g(b,a) also occurs in g(a,b), so no variable could be moved to a fresh element and condition 2 guarded nothing. Dropping the equality loses the step from g(d,c) to g(a,b).

    Amended (condition 2 and the proof in condition 3). The fresh variables are the argument variables of g(y) that occur nowhere in g(x)'s arguments, compared by identity across the whole tuple. There must be at least one, and each must be class typed and never constrained as a value. The proof moves them to fresh elements, so the tuple of g(y) differs from every other tuple. A permuted-tuple test was added to the list.

    For the single-argument state sets and object ghosts this issue targets, y itself is the fresh variable, so nothing changes for them.

  5. CatarinaGamboa commented on Nov 6, 2026

    @CatarinaGamboa
    CollaboratorAuthor

    Codex review of the design, round 5 (soundness): another multi-argument case, amended

    • g(x,a) == g(y,a) && a == b && g(y,b) == 1, Expected g(x,a) == 1, all class typed. y is fresh, but a == b makes g(y,a) and g(y,b) the same application. Moving y to a fresh element does not separate them, so dropping the equality loses Expected.

    Amended (condition 2). No other application of the same SMT-resolved ghost may use a fresh variable, and the conditions are checked again before each step of a chain. This guard is conservative. For the single-argument state sets and object ghosts this issue targets it never blocks anything, since the only application of g over y is g(y) itself.

    Codex: "With this additional guard, I found no further counterexample to successive elimination, branch scoping, or row placement." One confirming round follows.

  6. CatarinaGamboa commented on Nov 6, 2026

    @CatarinaGamboa
    CollaboratorAuthor

    Codex review of the design, round 6 (confirming): no new findings

    Codex: "The amended display rewrite is sound and sufficiently specified to implement against the current code."

    It wrote out the model construction for the general multi-argument case and confirmed:

    • Each fresh variable gets a new element, with its type views moved together.
    • The other applications keep their values or copy them.
    • The same-ghost guard leaves g(y) as the only tuple that needs a new value, namely that of g(x).
    • Successive steps hold because the guards are rechecked each time. Branch-local rewriting holds because the equality is kept when g(y) occurs outside the branch. Row placement keeps every variable in scope.

    It also confirmed that skipping ghost-to-ghost equalities in VCFunctionSubstitution for state errors is necessary.

    Six rounds in total; the design is settled. Implementation notes: guards are checked against the SMT-resolved ghost, and the tests are the lean list in the issue body.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions