From 5db504e64f3610efaf3e997091df888458b1eeb7 Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Fri, 12 Jun 2026 20:14:47 +0200 Subject: [PATCH 01/75] CC offset equalities: weighted union-find foundation Add the data-structure foundation for offset (affine) equality classes in congruence closure, where a class member t satisfies t == rep + offset rather than t == rep. This is increment 1 of the offset-equality plan: it threads the offset through the union-find but creates no non-zero offsets yet, so behavior is preserved (every offset is ZERO and the structure degenerates to plain congruence closure). - CCTerm: add mOffsetToRep; thread the offset delta through mergeInternal and undoMerge, add the same-class offset-consistency check in merge (the x = x+1 conflict case, explanation stubbed until the proofs increment), a reasonDiff helper, and a rep-offset-is-zero invariant. - CCEquality: add mOffset (lhs == rhs + offset) with accessor/setter/toString. - CCAppTerm: add the nullable mArgOffsets array and getArgOffset. - doc/offset-equality-plan.md: design and increment plan. Full test suite passes unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- SMTInterpol/doc/offset-equality-plan.md | 319 ++++++++++++++++++ .../theory/cclosure/CCAppTerm.java | 17 + .../theory/cclosure/CCEquality.java | 22 +- .../smtinterpol/theory/cclosure/CCTerm.java | 66 +++- 4 files changed, 421 insertions(+), 3 deletions(-) create mode 100644 SMTInterpol/doc/offset-equality-plan.md diff --git a/SMTInterpol/doc/offset-equality-plan.md b/SMTInterpol/doc/offset-equality-plan.md new file mode 100644 index 000000000..6ccb88602 --- /dev/null +++ b/SMTInterpol/doc/offset-equality-plan.md @@ -0,0 +1,319 @@ +# Offset Equality Extension for Congruence Closure + +## Overview + +Standard congruence closure (CC) maintains equality classes: all members of a +class are equal. With offset equalities, a class becomes an *affine class*: +every member `t` has a known rational offset from the representative, meaning +`t = rep + t.mOffsetToRep`. Plain equality is the special case offset = 0. + +The main motivation is tighter integration with linear arithmetic (LA): instead +of LA posting only zero-offset equalities to CC via the Nelson-Oppen shared-term +mechanism, LA can post `a = b + k` for any rational `k`, allowing CC to merge +the two terms and potentially fire new congruences immediately. + +--- + +## CCTerm — weighted union-find + +Add one field: + +```java +Rational mOffsetToRep; // this == mRepStar + mOffsetToRep +``` + +`mRepStar` continues to point directly to the representative; no path +compression is used (merges already update all `mRepStar` pointers), so +`mOffsetToRep` is always exact. + +### Merge with offset k (`a = b + k`) + +``` +repA = a.mRepStar, offsetA = a.mOffsetToRep → a = repA + offsetA +repB = b.mRepStar, offsetB = b.mOffsetToRep → b = repB + offsetB + +a = b + k ⟹ repA + offsetA = repB + offsetB + k + ⟹ repA = repB + (offsetB + k − offsetA) +``` + +Let `delta = offsetB + k − offsetA`. When merging the smaller class (say +`srcRep = repA`) into the larger (`destRep = repB`), all members of `srcRep`'s +class get their `mOffsetToRep` shifted: + +```java +Rational delta = offsetB.add(k).sub(offsetA); +for (CCTerm t : src.mMembers) { + t.mRepStar = destRep; + t.mOffsetToRep = t.mOffsetToRep.add(delta); +} +``` + +`undoMerge` restores both `mRepStar` and `mOffsetToRep` for all moved members, +which it already iterates over — no structural change needed there. + +### Consistency check (already same class) + +If `repA == repB`, require `offsetA == offsetB + k`; otherwise it is an +arithmetic conflict and a conflict clause must be generated. + +### invertEqualEdges + +Must accumulate and negate offsets as it reverses the equality-edge chain. + +### Proof trail — no mEqualEdgeOffset needed + +`mEqualEdge` from term `t` already points to one of the two specific CCTerms in +`mReasonLiteral` (either `getLhs()` or `getRhs()`). Path reconstruction can +recover the edge offset without any extra field: + +- If `t == eq.getLhs()`: `t = mEqualEdge + eq.mOffset`, contributing `+mOffset` +- If `t == eq.getRhs()`: `mEqualEdge = t + eq.mOffset`, contributing `−mOffset` + +A pointer comparison against `eq.getLhs()` determines the direction at +reconstruction time. `CongruencePath` therefore only needs to accumulate these +signed offsets as it walks the chain. + +--- + +## CCEquality — offset equality atoms + +Add one field: + +```java +Rational mOffset; // lhs == rhs + mOffset (currently always 0) +``` + +All existing code that constructs `CCEquality` passes offset 0 and is +unaffected. LA creates `CCEquality` objects with non-zero offsets when it +derives `a = b + k`. + +--- + +## Offset arguments to function applications + +For an argument of the shape `ccterm + constant`, e.g. `f(x+5)`, there are two +ways to represent the `+5`. + +### Option A — reify a CCTerm for `x+5` (rejected) + +Create a CCTerm for `x+5` and place it in `x`'s affine class at offset 5. +`CCAppTerm` and `SignatureTrigger` stay structurally unchanged; the argument is +an ordinary CCTerm whose `mOffsetToRep` happens to be 5. + +The problem: to put `x+5` into `x`'s class, CC must assert the offset equality +`(+ x 5) = x + 5`. This is a definitional **tautology**, and the proof +generator has to justify it as a leaf ("by definition of +"). Every +offset-shaped argument then costs one extra CCTerm **and** one tautological +offset equality that the proof machinery must discharge. + +(Note: a *standalone* `(= y (+ x 5))` is not affected — it is simply the offset +equality `y = x + 5`, a genuine asserted fact, and creates no `x+5` CCTerm.) + +### Option B — offset inside CCAppTerm (chosen) + +Add a parallel offset array to `CCAppTerm`: + +```java +Rational[] mArgOffsets; // arg i represents mArgs[i] + mArgOffsets[i]; null if all zero +``` + +No CCTerm is created for `x+5`. The effective offset of argument `i` for +signature purposes is `mArgs[i].mOffsetToRep + mArgOffsets[i]`. Keeping the +field `null` when every offset is zero means the common (plain-CC) case costs +nothing. + +The `+5` is now **intrinsic** to the term: the proof checker sees `f(x+5)` and +the offset is part of what the term is — there is no tautology to discharge. + +`SignatureTrigger` carries the same structural offsets. They are constants +fixed at term-creation time (they never change), so they need no backref +tracking; only `mArgs[i].mOffsetToRep` varies, so `recomputeHashCode`'s delta +logic is unchanged. + +`CongruenceTrigger` handles both forms transparently. + +### Scope + +Option B only helps arguments of the shape `ccterm + constant`. A genuine +linear-combination argument such as `f(x+y)` or `f(2x+1)` still needs a shared +CCTerm and obtains its offset relationship through real LA propagation — an +honest fact, not a tautology. + +## SignatureTrigger — congruence detection + +For uninterpreted functions, `f(a) = f(b)` only holds when `a = b + 0`, i.e. +same representative **and** same offset. The signature hash and equality check +must therefore include the effective offset of each argument — that is +`mArgs[i].mOffsetToRep` plus the structural `mArgOffsets[i]` from the app term +(see Option B above). + +Only `SignatureTrigger.computeHashCode()` and `equals()` need to change. +`CongruenceTrigger` extends `SignatureTrigger` and inherits both methods without +overriding them, so it picks up the fix for free. `ReverseTrigger` is +independent (extends `SimpleListable`, not `SignatureTrigger`) and is +unaffected. + +### computeHashCode + +```java +public int computeHashCode() { + int h = mId.hashCode(); + for (int i = 0; i < mTerms.length; i++) { + CCTerm t = mTerms[i]; + h ^= HashUtils.hashJenkins(i, t.getRepresentative()); + h ^= HashUtils.hashJenkins(i, t.mOffsetToRep); // add offset + } + return h; +} +``` + +`recomputeHashCode` needs a corresponding update to XOR out the old offset +contribution and XOR in the new one when a representative (and therefore its +offset assignment) changes. + +### equals + +```java +for (int i = 0; i < mTerms.length; i++) { + CCTerm a = mTerms[i], b = other.mTerms[i]; + if (a.getRepresentative() != b.getRepresentative()) return false; + if (!a.mOffsetToRep.equals(b.mOffsetToRep)) return false; +} +``` + +--- + +## CCTermPairHash — keyed by (lhs, rhs, offset) + +### Design + +Instead of a `Map` inside `Info`, change the *key* of the +hash table to include the offset. Each `Info` entry now represents one specific +relationship `lhs = rhs + offset`. `mDiseq` and `mEqlits` remain a single +disequality and a flat list of equalities, all pertaining to that one offset. + +The symmetry condition + +``` +hash(lhs, rhs, offset) == hash(rhs, lhs, −offset) +``` + +reflects that `lhs = rhs + k` and `rhs = lhs + (−k)` are the same fact. + +### Canonicalization + +Normalize at `Info` construction time: if +`System.identityHashCode(lhs) > System.identityHashCode(rhs)`, swap lhs↔rhs and +negate the offset. The stored triple `(lhs, rhs, offset)` is then always in +canonical form, so `hashCode` is a straightforward function of the three fields +and `equals` is a direct comparison (no need to check both orientations at query +time). + +`Info` gains one field: + +```java +Rational mOffset; // lhs == rhs + mOffset (canonical form) +``` + +`getInfo(lhs, rhs)` becomes `getInfo(lhs, rhs, offset)`. + +### Merge rehashing + +When `srcRep` merges into `destRep` with offset `delta` (`srcRep = destRep + delta`), +each pair-info entry `(srcRep, other, k)` migrates to `(destRep, other, k − delta)`: + +``` +srcRep = other + k, srcRep = destRep + delta + → destRep = other + (k − delta) +``` + +If an entry `(destRep, other, k − delta)` already exists, the equality and +disequality lists are joined and any conflict is checked — exactly the same +logic as today, just with the adjusted offset in the key. Conflict detection +remains O(1): `getInfo(srcRep, destRep, k)` directly returns the relevant entry. + +The zero-offset case is unchanged (offset = 0 throughout). + +--- + +## CongruencePath / proof reconstruction + +Walking the `mEqualEdge` chain accumulates signed offsets (see CCTerm section +above). The final proof term asserts `a = b + Σoffsets` rather than `a = b`. +For the hyperresolution intermediate representation, offset-equality steps need +an offset parameter added to the existing equality step type (or a new step +type). + +--- + +## LA interface + +When LA derives an equality `a − b = k`, it currently posts to CC only if +`k = 0`. With offset CC, LA can post it as a `CCEquality` with `mOffset = k` +for any `k`, allowing CC to merge the two terms immediately and fire congruences +that Nelson-Oppen would only discover through an additional round-trip. + +The existing `share()` mechanism propagates to LA when two shared terms end up +in the same affine class with offset `k`: LA should be told `a − b = k` rather +than the bare equality `a = b`. + +--- + +## Implementation strategy: one engine, one flag + +Rather than maintaining a separate "no offset" code path, the engine carries +offsets **unconditionally**. A single flag (`mCreateOffsetEqualities` on +`CClosure`) gates only the two places where non-zero offsets are *created*: + +- `CCTermBuilder` / `addTermAxioms` — flag on: build the offset-free CCTerm + (`factor·affine`) and remember the constant offset; flag off: build the + whole-term CCTerm as today (offset 0). +- `createCCEquality` — flag on: `mOffset = o_b − o_a` from the `LASharedTerm` + offsets; flag off: `mOffset = 0`. + +Everywhere else (union-find, pair hash, signatures, proofs) is offset-uniform. +With every offset `ZERO` the arithmetic degenerates exactly to plain congruence +closure, so the existing test suite continuously exercises the offset plumbing +in its degenerate case. The flag also doubles as the proof guard: default it +**off when proof generation is enabled** until offset-aware proof production +lands, then flip. + +### Entry pathway via LASharedTerm + +The offset machinery already exists in `LASharedTerm` (it stores summands and a +`Rational` offset). For `2x+4y+1` the normalized form is `factor·affine + +offset` with factor 2, affine `x+2y`, offset 1. The CCTerm represents the +offset-free `factor·affine` (`2x+4y`, shared with an `LASharedTerm` of offset +0); the factor stays in CC's term (so `2x+4y` and `x+2y` are *distinct* +CCTerms — the factor is LA's concern, not CC's). A `CCEquality` between two +LA-shared terms gets its offset from the difference of their `LASharedTerm` +offsets. + +## Increment plan + +1. **Weighted union-find foundation (done).** Added `CCTerm.mOffsetToRep`, + `CCEquality.mOffset`, `CCAppTerm.mArgOffsets`; threaded the offset delta + through `merge`/`mergeInternal`/`undoMerge` and added the same-class offset + consistency check. Behavior-preserving (all offsets `ZERO`); no creation site + emits non-zero offsets yet. +2. **Signatures + pair hash.** Offset in `SignatureTrigger` hash/equals and + `CCAppTerm.mArgOffsets`; re-key `CCTermPairHash.Info` by canonical + `(lhs, rhs, offset)`; update the ~10 `CClosure` call sites. +3. **Creation sites + flag.** Implement the two flag-gated creation sites and + turn the flag on (off under proof generation). +4. **Proof production.** Offset-aware `CongruencePath` / `CCProofGenerator` + (and proof checker); then enable offsets under proof generation. + +## Summary of files to change + +| File | Change | +|---|---| +| `CCTerm.java` | Add `mOffsetToRep`; update `merge()`, `invertEqualEdges()`, `undoMerge()` | +| `CCAppTerm.java` | Add `mArgOffsets: Rational[]` (null when all zero) for offset-shaped arguments | +| `CCEquality.java` | Add `mOffset: Rational` | +| `CCTermPairHash.java` | Add `mOffset` to `Info`; canonicalize key; update `getInfo()` and merge rehashing | +| `SignatureTrigger.java` | Include `mOffsetToRep` in `computeHashCode()`, `recomputeHashCode()`, `equals()` | +| `CongruencePath.java` | Accumulate signed offsets along equality-edge chain | +| `CCProofGenerator.java` | Emit offset-equality proof steps | +| `CClosure.java` | Update `addEquality()` entry point; update `share()` to propagate offset to LA | +| `LinArSolve.java` | Post offset equalities (`k ≠ 0`) to CC, not only zero-offset equalities | diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAppTerm.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAppTerm.java index 511d4d3fb..bf9d8513c 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAppTerm.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAppTerm.java @@ -22,12 +22,20 @@ import java.util.HashMap; import de.uni_freiburg.informatik.ultimate.logic.FunctionSymbol; +import de.uni_freiburg.informatik.ultimate.logic.Rational; import de.uni_freiburg.informatik.ultimate.logic.Term; import de.uni_freiburg.informatik.ultimate.util.HashUtils; public class CCAppTerm extends CCTerm { final FunctionSymbol mFunc; final CCTerm[] mArgs; + /** + * The constant offset that is added to each argument: argument {@code i} of the represented application is + * {@code mArgs[i] + mArgOffsets[i]}. This lets a term like {@code f(x+5)} use the offset-free CCTerm {@code x} as + * its argument while remembering the {@code +5}. The array is {@code null} when every offset is zero (the common + * case), so plain congruence closure pays no extra cost. + */ + Rational[] mArgOffsets; Term mSmtTerm; CongruenceTrigger mCongTrigger; @@ -60,6 +68,15 @@ public CCTerm getArgument(int argPosition) { return mArgs[argPosition]; } + /** + * @return the constant offset added to the argument at the given position, i.e. the argument of the application is + * {@code getArgument(argPosition) + getArgOffset(argPosition)}. Returns {@link Rational#ZERO} when no + * offsets are stored. + */ + public Rational getArgOffset(int argPosition) { + return mArgOffsets == null ? Rational.ZERO : mArgOffsets[argPosition]; + } + @Override public String toString() { final StringBuilder sb = new StringBuilder(); diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCEquality.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCEquality.java index a962ecd6a..4e0ad59ae 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCEquality.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCEquality.java @@ -28,6 +28,12 @@ public class CCEquality extends DPLLAtom { private final CCTerm mLhs, mRhs; + /** + * The constant offset of this equality: it states {@code mLhs == mRhs + mOffset}. For a plain equality this is + * {@link Rational#ZERO}; a non-zero value turns this into an offset equality, used to relate terms that differ by a + * constant (e.g. {@code a == b + 3}). + */ + private Rational mOffset = Rational.ZERO; CCEquality mDiseqReason; private LAEquality mLasd; private Rational mLAFactor; @@ -54,6 +60,17 @@ public CCTerm getRhs() { return mRhs; } + /** + * @return the constant offset of this equality, such that {@code getLhs() == getRhs() + getOffset()}. + */ + public Rational getOffset() { + return mOffset; + } + + public void setOffset(final Rational offset) { + mOffset = offset; + } + public Entry getEntry() { return mEntry; } @@ -89,6 +106,9 @@ public Term getSMTFormula(final Theory smtTheory) { @Override public String toString() { - return mLhs + " == " + mRhs; + if (mOffset.equals(Rational.ZERO)) { + return mLhs + " == " + mRhs; + } + return mLhs + " == " + mRhs + " + " + mOffset; } } diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCTerm.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCTerm.java index bf0088892..fddfcba92 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCTerm.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCTerm.java @@ -18,6 +18,7 @@ */ package de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure; +import de.uni_freiburg.informatik.ultimate.logic.Rational; import de.uni_freiburg.informatik.ultimate.logic.Term; import de.uni_freiburg.informatik.ultimate.smtinterpol.Config; import de.uni_freiburg.informatik.ultimate.smtinterpol.dpll.Clause; @@ -63,6 +64,14 @@ public abstract class CCTerm extends SimpleListable { * is the one that contains the members, ccpar and eqlits information. */ CCTerm mRepStar; + /** + * The offset of this term relative to its representative: the value of this term equals the value of + * {@link #mRepStar} plus this offset. This realises the affine (offset) equality classes: a member {@code t} + * satisfies {@code t == t.mRepStar + t.mOffsetToRep}. A representative has offset {@link Rational#ZERO} relative to + * itself. As long as no offset equality is ever created this is always {@link Rational#ZERO} and the structure + * degenerates to plain congruence closure. + */ + Rational mOffsetToRep = Rational.ZERO; /** * Points to the next merged representative in the congruence class. * This representative can have another representative of its own, if @@ -187,6 +196,7 @@ final boolean invariant() { // assert parInfo.mCCParents.wellformed(); // assert parInfo.mNext == null || parInfo.mFuncSymbNr < parInfo.mNext.mFuncSymbNr; // } + assert mOffsetToRep.equals(Rational.ZERO) : "representative must have zero offset"; for (final CCTerm m : mMembers) { assert m.mRepStar == this; } @@ -203,6 +213,13 @@ public final CCTerm getRepresentative() { return mRepStar; } + /** + * @return the offset of this term relative to its representative, i.e. {@code value(this) == value(rep) + offset}. + */ + public final Rational getOffsetToRep() { + return mOffsetToRep; + } + public final boolean isRepresentative() { return mRep == this; } @@ -343,6 +360,25 @@ public void invertEqualEdges(final CClosure engine) { } } + /** + * Compute the value difference {@code value(from) - value(to)} implied by a merge reason. For an offset equality the + * reason states {@code reason.getLhs() == reason.getRhs() + reason.getOffset()}; {@code from} and {@code to} are the + * two terms being merged and must be exactly the two sides of the reason (in either order). A {@code null} reason is + * a congruence merge, where the two function applications have equal value, hence a difference of zero. + */ + private static Rational reasonDiff(final CCEquality reason, final CCTerm from, final CCTerm to) { + if (reason == null) { + return Rational.ZERO; + } + if (from == reason.getLhs() && to == reason.getRhs()) { + return reason.getOffset(); + } + if (from == reason.getRhs() && to == reason.getLhs()) { + return reason.getOffset().negate(); + } + throw new AssertionError("merge reason does not match the merged terms"); + } + public Clause merge(final CClosure engine, final CCTerm lhs, final CCEquality reason) { assert reason != null || (this instanceof CCAppTerm && lhs instanceof CCAppTerm); @@ -355,7 +391,17 @@ public Clause merge(final CClosure engine, final CCTerm lhs, final CCEquality re assert dest.invariant(); assert dest.pairHashValid(engine); if (src == dest) { - /* Terms are already merged. */ + /* + * Terms are already merged. With offset equalities this is only consistent if the offset implied by the + * reason matches the offset that the two terms already have within their common class. A mismatch (e.g. + * asserting x = x + 1) is a conflict. + */ + final Rational existingDiff = lhs.mOffsetToRep.sub(mOffsetToRep); + if (!existingDiff.equals(reasonDiff(reason, lhs, this))) { + // TODO offset-conflict explanation needs the offset-aware proof machinery; it can only be reached once + // non-zero offset equalities are created (later increment). + throw new UnsupportedOperationException("offset equality conflict explanation not yet implemented"); + } return null; } @@ -427,10 +473,19 @@ private Clause mergeInternal(final CClosure engine, final CCTerm lhs, final CCEq time = System.nanoTime(); } engine.rehashSignatures(src, dest, src.mSignatureBackRefs); - /* Update rep fields */ + /* + * Compute the offset that src (the old representative of the merged class) gets relative to dest. We have + * value(src) = value(dest) + delta, where delta is derived from the reason and the offsets the two merged terms + * already have relative to their respective representatives: + * delta = (value(lhs) - value(this)) - lhs.mOffsetToRep + this.mOffsetToRep. + * Since src is a representative its own offset was ZERO, so after the shift below src.mOffsetToRep == delta. + */ + final Rational delta = reasonDiff(reason, lhs, this).sub(lhs.mOffsetToRep).add(mOffsetToRep); + /* Update rep fields and shift offsets of the merged members into dest's frame */ src.mRep = dest; for (final CCTerm t : src.mMembers) { t.mRepStar = dest; + t.mOffsetToRep = t.mOffsetToRep.add(delta); } if (Config.PROFILE_TIME) { engine.addSetRepTime(System.nanoTime() - time); @@ -567,9 +622,16 @@ public void undoMerge(final CClosure engine, final CCTerm lhs) { if (Config.PROFILE_TIME) { time = System.nanoTime(); } + /* + * Undo the offset shift applied at merge. src was a representative before the merge (offset ZERO), so the delta + * that was added to every member equals src's current offset relative to dest. Subtracting it restores each + * member's offset relative to src and resets src.mOffsetToRep to ZERO. + */ + final Rational delta = src.mOffsetToRep; dest.mMembers.unjoinList(src.mMembers); for (final CCTerm t : src.mMembers) { t.mRepStar = src; + t.mOffsetToRep = t.mOffsetToRep.sub(delta); } src.mRep = src; From a6b63da869ec109688ec27435d6cb24f44870ed6 Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Fri, 12 Jun 2026 20:32:09 +0200 Subject: [PATCH 02/75] CC offset equalities: offset-aware congruence signatures Make SignatureTrigger key on each argument's effective offset (the term's offset to its representative plus the structural argument offset) in addition to the representative, so two function applications are congruent only when their arguments have the same representative and the same offset. Thread the offset delta through recomputeHashCode/rehash/rehashSignatures and the merge/undoMerge callers. CongruenceTrigger picks up the structural offsets from its app term; FindTriggerTrigger (no terms) and ReverseTriggerTrigger (no structural offset) are unaffected. This is increment 2a of the offset-equality plan. No creation site emits non-zero offsets yet, so every effective offset is ZERO and the change is a no-op; the full test suite passes unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../smtinterpol/theory/cclosure/CCTerm.java | 18 +++-- .../smtinterpol/theory/cclosure/CClosure.java | 6 +- .../theory/cclosure/CongruenceTrigger.java | 2 +- .../theory/cclosure/SignatureTrigger.java | 71 ++++++++++++++++--- 4 files changed, 78 insertions(+), 19 deletions(-) diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCTerm.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCTerm.java index fddfcba92..26646d9dc 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCTerm.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCTerm.java @@ -472,15 +472,17 @@ private Clause mergeInternal(final CClosure engine, final CCTerm lhs, final CCEq if (Config.PROFILE_TIME) { time = System.nanoTime(); } - engine.rehashSignatures(src, dest, src.mSignatureBackRefs); /* * Compute the offset that src (the old representative of the merged class) gets relative to dest. We have * value(src) = value(dest) + delta, where delta is derived from the reason and the offsets the two merged terms * already have relative to their respective representatives: * delta = (value(lhs) - value(this)) - lhs.mOffsetToRep + this.mOffsetToRep. * Since src is a representative its own offset was ZERO, so after the shift below src.mOffsetToRep == delta. + * The signatures must be rehashed with this delta as well, since the effective offset of every src-class + * argument increases by delta; rehashing happens before the offsets are actually updated below. */ final Rational delta = reasonDiff(reason, lhs, this).sub(lhs.mOffsetToRep).add(mOffsetToRep); + engine.rehashSignatures(src, dest, delta, src.mSignatureBackRefs); /* Update rep fields and shift offsets of the merged members into dest's frame */ src.mRep = dest; for (final CCTerm t : src.mMembers) { @@ -583,9 +585,15 @@ public void undoMerge(final CClosure engine, final CCTerm lhs) { dest = mRepStar; assert src.mRep == dest; + /* + * The delta that was added to every src-class member at merge time is src's current offset relative to dest + * (src was a representative with offset ZERO before the merge). Undoing reverses both the representative change + * (dest -> src) and the offset shift (by -delta). Rehash before the offsets below are restored. + */ + final Rational delta = src.mOffsetToRep; dest.mSignatureBackRefs.unjoinList(src.mSignatureBackRefs); engine.getLogger().debug("Unmerge Backrefs: %s", src.mSignatureBackRefs); - engine.rehashSignatures(dest, src, src.mSignatureBackRefs); + engine.rehashSignatures(dest, src, delta.negate(), src.mSignatureBackRefs); src.mReasonLiteral = null; for (final CCTermPairHash.Info.Entry pentry : src.mPairInfos.reverse()) { @@ -623,11 +631,9 @@ public void undoMerge(final CClosure engine, final CCTerm lhs) { time = System.nanoTime(); } /* - * Undo the offset shift applied at merge. src was a representative before the merge (offset ZERO), so the delta - * that was added to every member equals src's current offset relative to dest. Subtracting it restores each - * member's offset relative to src and resets src.mOffsetToRep to ZERO. + * Undo the offset shift applied at merge (delta computed above). Subtracting it restores each member's offset + * relative to src and resets src.mOffsetToRep to ZERO. */ - final Rational delta = src.mOffsetToRep; dest.mMembers.unjoinList(src.mMembers); for (final CCTerm t : src.mMembers) { t.mRepStar = src; diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java index 756daf992..7a5b6a161 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java @@ -29,6 +29,7 @@ import de.uni_freiburg.informatik.ultimate.logic.ApplicationTerm; import de.uni_freiburg.informatik.ultimate.logic.ConstantTerm; import de.uni_freiburg.informatik.ultimate.logic.FunctionSymbol; +import de.uni_freiburg.informatik.ultimate.logic.Rational; import de.uni_freiburg.informatik.ultimate.logic.Term; import de.uni_freiburg.informatik.ultimate.logic.Theory; import de.uni_freiburg.informatik.ultimate.smtinterpol.Config; @@ -794,10 +795,11 @@ public void moveToSignatureTodo(SignatureTrigger signatureTrigger) { * @param backRefs the list of (signature, listIndex, trigger) back-refs from * that representative. */ - void rehashSignatures(final CCTerm oldRep, final CCTerm newRep, final SimpleList backRefs) { + void rehashSignatures(final CCTerm oldRep, final CCTerm newRep, final Rational offsetDelta, + final SimpleList backRefs) { for (final SignatureBackRef backRef : backRefs) { final SignatureTrigger signatureTrigger = backRef.getSignatureTrigger(); - signatureTrigger.rehash(this, backRef.getArgPosition(), oldRep, newRep); + signatureTrigger.rehash(this, backRef.getArgPosition(), oldRep, newRep, offsetDelta); } } diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruenceTrigger.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruenceTrigger.java index 589a9e1a2..cbebde9c7 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruenceTrigger.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruenceTrigger.java @@ -39,7 +39,7 @@ public final class CongruenceTrigger extends SignatureTrigger { * the (kept) application term for this signature. */ public CongruenceTrigger(final CCAppTerm app, FunctionSymbol func, CCTerm[] args) { - super(func, args); + super(func, args, app.mArgOffsets); mApp = app; } diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/SignatureTrigger.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/SignatureTrigger.java index 9e0df9d78..d7cae67a2 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/SignatureTrigger.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/SignatureTrigger.java @@ -21,6 +21,7 @@ import java.util.Arrays; import java.util.List; +import de.uni_freiburg.informatik.ultimate.logic.Rational; import de.uni_freiburg.informatik.ultimate.smtinterpol.dpll.SimpleListable; import de.uni_freiburg.informatik.ultimate.util.HashUtils; @@ -35,13 +36,22 @@ public class SignatureTrigger extends SimpleListable { private final Object mId; private final CCTerm[] mTerms; + /** + * The structural constant offsets of the signature, parallel to {@link #mTerms}: argument {@code i} of the + * signature is {@code mTerms[i] + mArgOffsets[i]}. This is {@code null} when every offset is zero (the common case), + * which keeps plain congruence closure free of any offset cost. The effective offset that the signature is keyed on + * additionally includes the term's offset to its representative (see {@link #effectiveOffset(int)}), so two + * applications are congruent only if their arguments have the same representative and the same effective + * offset. + */ + private final Rational[] mArgOffsets; private int mLastHashCode; private SignatureTrigger mMergedTrigger; private SignatureBackRef[] mBackrefs; + /** - * Create a signature with the given identifier and non-empty term array. The array may contain any CCTerms, not - * necessarily representatives. The array is copied defensively. + * Create a signature with the given identifier and non-empty term array, with all structural offsets zero. * * @param id * opaque identifier (e.g. function symbol for congruence, or trigger id for reverse triggers). @@ -49,11 +59,35 @@ public class SignatureTrigger extends SimpleListable { * non-empty array of CCTerms. */ public SignatureTrigger(final Object id, final CCTerm[] terms) { + this(id, terms, null); + } + + /** + * Create a signature with the given identifier, term array and structural offsets. + * + * @param id + * opaque identifier (e.g. function symbol for congruence, or trigger id for reverse triggers). + * @param terms + * non-empty array of CCTerms. + * @param argOffsets + * the structural offset for each term, or {@code null} when all are zero. + */ + public SignatureTrigger(final Object id, final CCTerm[] terms, final Rational[] argOffsets) { mId = id; mTerms = terms; + mArgOffsets = argOffsets; mLastHashCode = computeHashCode(); } + /** + * The effective offset of argument {@code index}: the term's offset to its representative plus the structural + * offset. This is what determines, together with the representative, whether two signatures are congruent. + */ + private Rational effectiveOffset(final int index) { + final Rational structural = mArgOffsets == null ? Rational.ZERO : mArgOffsets[index]; + return mTerms[index].mOffsetToRep.add(structural); + } + public Object getId() { return mId; } @@ -73,7 +107,7 @@ public List getTerms() { return Arrays.asList(mTerms); } - public void rehash(CClosure engine, int argPosition, CCTerm oldRep, CCTerm newRep) { + public void rehash(CClosure engine, int argPosition, CCTerm oldRep, CCTerm newRep, Rational offsetDelta) { /* only if not merged */ if (mMergedTrigger == null) { assert mBackrefs != null; @@ -81,7 +115,7 @@ public void rehash(CClosure engine, int argPosition, CCTerm oldRep, CCTerm newRe assert mLastHashCode == computeHashCode(); engine.moveToSignatureTodo(this); } - recomputeHashCode(argPosition, oldRep, newRep); + recomputeHashCode(argPosition, oldRep, newRep, offsetDelta); } } @@ -120,16 +154,30 @@ public int hashCode() { public int computeHashCode() { int h = mId.hashCode(); for (int i = 0; i < mTerms.length; i++) { - h ^= HashUtils.hashJenkins(i, mTerms[i].getRepresentative()); + // Use disjoint salts (2*i for the representative, 2*i+1 for the offset) so the two contributions do not + // trivially cancel against each other. + h ^= HashUtils.hashJenkins(2 * i, mTerms[i].getRepresentative()); + h ^= HashUtils.hashJenkins(2 * i + 1, effectiveOffset(i).hashCode()); } return h; } - public void recomputeHashCode(int argPos, CCTerm oldRep, CCTerm newRep) { - final int hOld = HashUtils.hashJenkins(argPos, oldRep.hashCode()); - final int hNew = HashUtils.hashJenkins(argPos, newRep.hashCode()); - // assert computeHashCode() == (mLastHashCode ^ hOld ^ hNew); - mLastHashCode ^= hOld ^ hNew; + /** + * Incrementally update the cached hash when argument {@code argPos}'s representative changes from {@code oldRep} to + * {@code newRep} and its offset to the representative changes by {@code offsetDelta}. This must be called before the + * term's {@code mOffsetToRep} is actually updated, so {@link #effectiveOffset(int)} still reflects the old value. + */ + public void recomputeHashCode(int argPos, CCTerm oldRep, CCTerm newRep, Rational offsetDelta) { + final int hOldRep = HashUtils.hashJenkins(2 * argPos, oldRep.hashCode()); + final int hNewRep = HashUtils.hashJenkins(2 * argPos, newRep.hashCode()); + mLastHashCode ^= hOldRep ^ hNewRep; + if (!offsetDelta.equals(Rational.ZERO)) { + final Rational oldEff = effectiveOffset(argPos); + final Rational newEff = oldEff.add(offsetDelta); + final int hOldOff = HashUtils.hashJenkins(2 * argPos + 1, oldEff.hashCode()); + final int hNewOff = HashUtils.hashJenkins(2 * argPos + 1, newEff.hashCode()); + mLastHashCode ^= hOldOff ^ hNewOff; + } } @Override @@ -148,6 +196,9 @@ public boolean equals(final Object obj) { if (mTerms[i].getRepresentative() != other.mTerms[i].getRepresentative()) { return false; } + if (!effectiveOffset(i).equals(other.effectiveOffset(i))) { + return false; + } } return true; } From c86c95daacd52e30548aa89fda1d53161af2139a Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Fri, 12 Jun 2026 20:58:02 +0200 Subject: [PATCH 03/75] CC offset equalities: offset-keyed pair hash structure Make CCTermPairHash.Info carry an offset so that several infos with the same endpoints but different offsets can coexist (e.g. for a == b and a == b + 5). The offset enters the cuckoo-hash key via an offset hash that is invariant under negation, so (A, B, off) and its mirror (B, A, -off) still hash and compare equal; equals() and getInfo() take the offset into account. To keep the change minimal and behavior-preserving, zero-defaulting overloads Info(l, r) and getInfo(l, r) are provided, so every existing call site continues to query offset zero unchanged. Threading the context-derived offsets through the individual call sites (insertEqualityEntry, isDiseqSet, separate, the merge-time propagation, etc.) is deferred to the creation increment, where it is exercised end-to-end. At zero offset the offset hash contribution is zero, so the hash values are identical to before. This is increment 2b of the offset-equality plan. Full test suite passes unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../theory/cclosure/CCTermPairHash.java | 75 ++++++++++++++++--- 1 file changed, 64 insertions(+), 11 deletions(-) diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCTermPairHash.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCTermPairHash.java index eabcda27d..970ffb9c7 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCTermPairHash.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCTermPairHash.java @@ -18,6 +18,7 @@ */ package de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure; +import de.uni_freiburg.informatik.ultimate.logic.Rational; import de.uni_freiburg.informatik.ultimate.smtinterpol.dpll.SimpleList; import de.uni_freiburg.informatik.ultimate.smtinterpol.dpll.SimpleListable; import de.uni_freiburg.informatik.ultimate.smtinterpol.util.CuckooHashSet; @@ -26,6 +27,14 @@ public class CCTermPairHash extends CuckooHashSet { public final static class Info { CCEquality mDiseq; + /** + * The offset of this pair info: it relates the two endpoints {@code A = mRhsEntry.mOther} and + * {@code B = mLhsEntry.mOther} by {@code value(A) == value(B) + mOffset}. Several infos with the same endpoints + * but different offsets may coexist (e.g. for the equalities {@code a == b} and {@code a == b + 5}); they are + * distinguished by this offset both in the pair hash key and in the targeted lookups. For plain (offset-free) + * congruence closure this is always {@link Rational#ZERO}. + */ + Rational mOffset; final SimpleList mEqlits; final Entry mLhsEntry, mRhsEntry; final SimpleList mCompareTriggers; // E-Matching @@ -46,6 +55,15 @@ Entry getOtherEntry() { ? mRhsEntry : mLhsEntry; } + /** + * The offset of the info as seen from the owner of this entry towards {@link #mOther}, i.e. + * {@code value(owner) - value(mOther)}. This is {@code +mOffset} for the lhs entry (owner {@code A}) and + * {@code -mOffset} for the rhs entry (owner {@code B}). + */ + Rational getOffsetToOther() { + return mLhsEntry == this ? mOffset : mOffset.negate(); + } + @Override public String toString() { return Info.this.toString(); @@ -53,8 +71,13 @@ public String toString() { } public Info(CCTerm l, CCTerm r) { + this(l, r, Rational.ZERO); + } + + public Info(CCTerm l, CCTerm r, Rational offset) { mLhsEntry = new Entry(r); mRhsEntry = new Entry(l); + mOffset = offset; l.mPairInfos.append(mLhsEntry); r.mPairInfos.append(mRhsEntry); mEqlits = new SimpleList(); @@ -63,17 +86,26 @@ public Info(CCTerm l, CCTerm r) { @Override public int hashCode() { - return pairHash(mRhsEntry.mOther, mLhsEntry.mOther); + return pairHash(mRhsEntry.mOther, mLhsEntry.mOther) ^ offsetHash(mOffset); } - public final boolean equals(CCTerm lhs, CCTerm rhs) { - return (mRhsEntry.mOther == lhs && mLhsEntry.mOther == rhs) - || (mRhsEntry.mOther == rhs && mLhsEntry.mOther == lhs); + public final boolean equals(CCTerm lhs, CCTerm rhs, Rational offset) { + // A = mRhsEntry.mOther, B = mLhsEntry.mOther, mOffset = value(A) - value(B). + if (mRhsEntry.mOther == lhs && mLhsEntry.mOther == rhs) { + return mOffset.equals(offset); + } + if (mRhsEntry.mOther == rhs && mLhsEntry.mOther == lhs) { + return mOffset.equals(offset.negate()); + } + return false; } @Override public String toString() { - return "Info[" + mLhsEntry.mOther + "," + mRhsEntry.mOther + "]"; + if (mOffset.equals(Rational.ZERO)) { + return "Info[" + mLhsEntry.mOther + "," + mRhsEntry.mOther + "]"; + } + return "Info[" + mRhsEntry.mOther + "," + mLhsEntry.mOther + "+" + mOffset + "]"; } // public void addExtensionalityDiseq(ConvertFormula converter) { @@ -89,31 +121,52 @@ public boolean isEmpty() { } } - private Info getInfoStash(CCTerm lhs, CCTerm rhs) { - if (mStash != null && mStash.equals(lhs, rhs)) { + private Info getInfoStash(CCTerm lhs, CCTerm rhs, Rational offset) { + if (mStash != null && mStash.equals(lhs, rhs, offset)) { return mStash; } return null; } + /** + * Look up the offset-zero pair info between the two terms. Equivalent to {@link #getInfo(CCTerm, CCTerm, Rational)} + * with a zero offset; used by all call sites that only deal with plain (offset-free) equalities. + */ public Info getInfo(CCTerm lhs, CCTerm rhs) { - final int hash = hashJenkins(pairHash(lhs, rhs)); + return getInfo(lhs, rhs, Rational.ZERO); + } + + /** + * Look up the pair info for the relationship {@code value(lhs) == value(rhs) + offset}. Infos with the same + * endpoints but different offsets are distinct entries, so the offset is part of the lookup key. + */ + public Info getInfo(CCTerm lhs, CCTerm rhs, Rational offset) { + final int hash = hashJenkins(pairHash(lhs, rhs) ^ offsetHash(offset)); final int hash1 = hash1(hash); Info bucket = (Info) mBuckets[hash1]; - if (bucket != null && bucket.equals(lhs, rhs)) { + if (bucket != null && bucket.equals(lhs, rhs, offset)) { return bucket; } bucket = (Info) mBuckets[hash2(hash) ^ hash1]; - if (bucket != null && bucket.equals(lhs, rhs)) { + if (bucket != null && bucket.equals(lhs, rhs, offset)) { return bucket; } - return getInfoStash(lhs, rhs); + return getInfoStash(lhs, rhs, offset); } private static int pairHash(CCTerm lhs, CCTerm rhs) { return hashJenkins(lhs.hashCode()) + hashJenkins(rhs.hashCode()); } + /** + * A hash contribution for the offset that is invariant under negation, so that the relationship + * {@code (A, B, off)} and its mirror {@code (B, A, -off)} hash identically. It is zero for a zero offset, so + * offset-free congruence closure keeps the original hash values. + */ + static int offsetHash(Rational offset) { + return offset.hashCode() ^ offset.negate().hashCode(); + } + public void removePairInfo(Info info) { // First remove this pair from the pairInfos-lists in the components info.mLhsEntry.removeFromList(); From f2f9f5fa81938708e55a3bad4e4255c89e970ba5 Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Fri, 12 Jun 2026 20:59:00 +0200 Subject: [PATCH 04/75] Update offset-equality plan with refined increment sequencing Co-Authored-By: Claude Opus 4.8 (1M context) --- SMTInterpol/doc/offset-equality-plan.md | 28 +++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/SMTInterpol/doc/offset-equality-plan.md b/SMTInterpol/doc/offset-equality-plan.md index 6ccb88602..171815e69 100644 --- a/SMTInterpol/doc/offset-equality-plan.md +++ b/SMTInterpol/doc/offset-equality-plan.md @@ -296,13 +296,29 @@ offsets. through `merge`/`mergeInternal`/`undoMerge` and added the same-class offset consistency check. Behavior-preserving (all offsets `ZERO`); no creation site emits non-zero offsets yet. -2. **Signatures + pair hash.** Offset in `SignatureTrigger` hash/equals and - `CCAppTerm.mArgOffsets`; re-key `CCTermPairHash.Info` by canonical - `(lhs, rhs, offset)`; update the ~10 `CClosure` call sites. -3. **Creation sites + flag.** Implement the two flag-gated creation sites and - turn the flag on (off under proof generation). +2. **Signatures + pair hash structure.** + - **2a (done).** Offset in `SignatureTrigger` hash/equals/recompute (the + effective offset is the term's offset to its representative plus the + structural `CCAppTerm.mArgOffsets`); the offset delta is threaded through + `rehashSignatures` and the merge/undo callers. + - **2b (done).** `CCTermPairHash.Info` carries an offset; the cuckoo-hash key + and `equals`/`getInfo` are offset-aware (offset hash invariant under + negation, so `(A,B,off)` mirrors `(B,A,-off)`). Zero-defaulting overloads + keep every existing call site querying offset 0, so it is behavior- + preserving. The actual offset-threading through the call sites is deferred + to increment 3, where it is testable. +3. **Creation sites + flag + call-site offset threading.** Implement the two + flag-gated creation sites (offset-free CCTerms from `LASharedTerm`; + `CCEquality.mOffset` from the offset difference) and, in the same increment, + thread the context-derived offsets through `insertEqualityEntry`, + `isDiseqSet`, `separate`/`undoSep`, `createCCEquality`, `backtrackComplete`, + `removeCCEquality`, the merge-time propagation in `CCTerm.mergeInternal`/ + `undoMerge` (propagate the matching-offset eqlits as true, the others as + negated), and `CompareTrigger.getOffset`. Then turn the flag on (off under + proof generation). This is the first end-to-end testable increment. 4. **Proof production.** Offset-aware `CongruencePath` / `CCProofGenerator` - (and proof checker); then enable offsets under proof generation. + (and proof checker), and the offset-conflict explanation stubbed in + increment 1; then enable offsets under proof generation. ## Summary of files to change From 8cf4e2c638c187f2307929a036d20873358e4a45 Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Fri, 12 Jun 2026 21:06:10 +0200 Subject: [PATCH 05/75] CC offset equalities: canonicalize pair-hash offset to avoid collisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The offset hash was made invariant under negation, which also made (a, b, off) and (a, b, -off) — i.e. a = b + off and a = b - off — collide. That is a structured collision cuckoo hashing handles badly. Instead express the offset in a canonical endpoint orientation before hashing, so the mirror (b, a, -off) still matches but off and -off are distinguished. The orientation uses CCTerm.hashCode() (deterministic) rather than System.identityHashCode(), to keep the solver reproducible. On the rare event that the two endpoints have equal hash codes, the orientation is ambiguous, so we fall back to the negation-invariant offset.abs() hash. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../theory/cclosure/CCTermPairHash.java | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCTermPairHash.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCTermPairHash.java index 970ffb9c7..ea7e2c3a1 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCTermPairHash.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCTermPairHash.java @@ -86,7 +86,8 @@ public Info(CCTerm l, CCTerm r, Rational offset) { @Override public int hashCode() { - return pairHash(mRhsEntry.mOther, mLhsEntry.mOther) ^ offsetHash(mOffset); + return pairHash(mRhsEntry.mOther, mLhsEntry.mOther) + ^ offsetHash(mRhsEntry.mOther, mLhsEntry.mOther, mOffset); } public final boolean equals(CCTerm lhs, CCTerm rhs, Rational offset) { @@ -141,7 +142,7 @@ public Info getInfo(CCTerm lhs, CCTerm rhs) { * endpoints but different offsets are distinct entries, so the offset is part of the lookup key. */ public Info getInfo(CCTerm lhs, CCTerm rhs, Rational offset) { - final int hash = hashJenkins(pairHash(lhs, rhs) ^ offsetHash(offset)); + final int hash = hashJenkins(pairHash(lhs, rhs) ^ offsetHash(lhs, rhs, offset)); final int hash1 = hash1(hash); Info bucket = (Info) mBuckets[hash1]; if (bucket != null && bucket.equals(lhs, rhs, offset)) { @@ -159,12 +160,25 @@ private static int pairHash(CCTerm lhs, CCTerm rhs) { } /** - * A hash contribution for the offset that is invariant under negation, so that the relationship - * {@code (A, B, off)} and its mirror {@code (B, A, -off)} hash identically. It is zero for a zero offset, so - * offset-free congruence closure keeps the original hash values. + * A hash contribution for the offset of an info between {@code a} and {@code b} with {@code offset == value(a) - + * value(b)}. The offset is expressed in a canonical endpoint orientation (the term with the smaller + * {@link CCTerm#hashCode()} first) before hashing, so that the relationship {@code (a, b, off)} and its mirror + * {@code (b, a, -off)} hash identically, while {@code (a, b, off)} and {@code (a, b, -off)} (i.e. {@code a = b + off} + * versus {@code a = b - off}) get different hashes — important because cuckoo hashing degrades badly on structured + * collisions. We use {@code hashCode()} rather than identity hashes to keep the solver deterministic. + * + *

If the two endpoints have equal hash codes (about a one in four billion chance) the orientation is ambiguous, + * so we fall back to the negation-invariant {@code offset.abs()} hash. That keeps the mirror consistent (at the + * cost of {@code off}/{@code -off} colliding only in that rare case). */ - static int offsetHash(Rational offset) { - return offset.hashCode() ^ offset.negate().hashCode(); + static int offsetHash(CCTerm a, CCTerm b, Rational offset) { + final int ha = a.hashCode(); + final int hb = b.hashCode(); + if (ha == hb) { + return offset.abs().hashCode(); + } + final Rational canonical = ha < hb ? offset : offset.negate(); + return canonical.hashCode(); } public void removePairInfo(Info info) { From bda06ac16f627dd83f5be4f4def78c87814f9ffc Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Fri, 12 Jun 2026 22:37:29 +0200 Subject: [PATCH 06/75] CC offset equalities: enable creation and thread offsets end-to-end This is increment 3 of the offset-equality plan: the feature is now active and exercised end-to-end (the full test suite runs with it enabled, since it is on for non-proof runs). - Flag createOffsetEqualities() on CClosure (= !isProofGenerationEnabled()). - Clausifier/CCTermBuilder: build offset-free CCTerms for numeric terms, keyed on the offset-free affine, carrying the constant as an offset; LASharedTerm becomes offset-free (offset zero) and is shared with linear arithmetic only once (the share() guard prevents re-sharing a CCTerm several offset terms map to). createAppTerm records per-argument offsets (mArgOffsets). - EqualityProxy: derive the CCEquality offset from the difference of the two terms' constants. - CClosure/CCTerm: thread the context-derived offset through insertEqualityEntry (chain walk), createCCEquality, isDiseqSet, separate/undoSep, backtrackComplete, removeCCEquality, and the mergeInternal/undoMerge pair-hash re-keying and propagation (propagate equalities at the matching offset). setLiteral detects the same-class offset conflict (x == x + 1). - Shared-term equality propagation (mergeInternal, propagateSharedEquality) now carries the offset: createEquality encodes value(t1) == value(t2) + offset by building the rhs term t2+offset, so the CCEquality carries the offset and the linked LAEquality the matching constant. - ModelBuilder: a class member's model value adds its offset to the representative, the representative's value subtracts the shared term's offset, and function-application keys/values include the argument and term offsets. - Debugging aids: M/U merge logs and dumpModel print offsets. Proof generation stays on the offset-free path (flag off under proofs) until the next increment. Full test suite passes with offsets enabled. Co-Authored-By: Claude Opus 4.8 (1M context) --- SMTInterpol/doc/offset-equality-plan.md | 12 ++ .../smtinterpol/convert/CCTermBuilder.java | 66 +++++++- .../smtinterpol/convert/Clausifier.java | 48 +++++- .../smtinterpol/convert/EqualityProxy.java | 8 +- .../smtinterpol/theory/cclosure/CCTerm.java | 69 +++++--- .../smtinterpol/theory/cclosure/CClosure.java | 160 +++++++++++++++--- .../theory/cclosure/ModelBuilder.java | 27 ++- 7 files changed, 327 insertions(+), 63 deletions(-) diff --git a/SMTInterpol/doc/offset-equality-plan.md b/SMTInterpol/doc/offset-equality-plan.md index 171815e69..32f09626f 100644 --- a/SMTInterpol/doc/offset-equality-plan.md +++ b/SMTInterpol/doc/offset-equality-plan.md @@ -278,6 +278,18 @@ in its degenerate case. The flag also doubles as the proof guard: default it **off when proof generation is enabled** until offset-aware proof production lands, then flip. +### CC↔LA sharing of offset-free terms + +When offset equalities are enabled, a numeric term `t = affine + c` is +represented by the offset-free CCTerm for `affine`, and the **`LASharedTerm` for +`t` has offset zero** (it represents `affine`, not `t`). Only this offset-free +CCTerm is `share()`d with the offset-free `LASharedTerm`, so the CC↔LA +shared-term equality has equal values on both sides and stays sound. The full +term `t` gets no separate CC↔LA share — the constant `c` (recomputed from the +polynomial on demand) bridges CC (`affine + c`) and LA (`affine + c`), both +anchored on the shared `affine`. When the feature is off, `LASharedTerm` keeps +the constant as its offset and the whole term is shared, exactly as before. + ### Entry pathway via LASharedTerm The offset machinery already exists in `LASharedTerm` (it stores summands and a diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/CCTermBuilder.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/CCTermBuilder.java index 32037071b..9112822d5 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/CCTermBuilder.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/CCTermBuilder.java @@ -22,6 +22,7 @@ import de.uni_freiburg.informatik.ultimate.logic.ApplicationTerm; import de.uni_freiburg.informatik.ultimate.logic.FunctionSymbol; +import de.uni_freiburg.informatik.ultimate.logic.Rational; import de.uni_freiburg.informatik.ultimate.logic.Term; import de.uni_freiburg.informatik.ultimate.smtinterpol.proof.SourceAnnotation; import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CCTerm; @@ -36,19 +37,33 @@ public class CCTermBuilder { private final ArrayDeque mOps = new ArrayDeque<>(); private final ArrayDeque mConverted = new ArrayDeque<>(); + /** + * The constant offsets of the converted CCTerms, parallel to {@link #mConverted}: the term that produced + * {@code mConverted.peek()} equals that CCTerm plus {@code mOffsets.peek()}. This carries the {@code +5} of an + * offset-free term like {@code x+5} up to the enclosing application so it ends up in the app term's argument + * offsets. Offsets are {@link Rational#ZERO} unless offset equalities are enabled. + */ + private final ArrayDeque mOffsets = new ArrayDeque<>(); public CCTermBuilder(Clausifier clausifier, final SourceAnnotation source) { mClausifier = clausifier; mSource = source; } + private void pushResult(final CCTerm ccTerm, final Rational offset) { + mConverted.push(ccTerm); + mOffsets.push(offset); + } + public CCTerm convert(final Term t) { mOps.push(new BuildCCTerm(t)); while (!mOps.isEmpty()) { mOps.pop().perform(); } final CCTerm res = mConverted.pop(); + mOffsets.pop(); assert mConverted.isEmpty(); + assert mOffsets.isEmpty(); return res; } @@ -63,10 +78,15 @@ public BuildCCTerm(final Term term) { public void perform() { CCTerm ccTerm = mClausifier.getCCTerm(mTerm); if (ccTerm != null) { - mConverted.push(ccTerm); + pushResult(ccTerm, mClausifier.getTermConstant(mTerm)); } else { final CClosure cclosure = mClausifier.getCClosure(); - if (Clausifier.needCCTerm(mTerm) && ((ApplicationTerm) mTerm).getParameters().length > 0) { + final Term offsetFree = mClausifier.getOffsetFreeTerm(mTerm); + if (offsetFree != mTerm) { + // Numeric term with a non-zero constant: build the offset-free CCTerm and remember the constant. + mOps.push(new MapOffsetFreeTerm(mTerm, mClausifier.getTermConstant(mTerm))); + mOps.push(new BuildCCTerm(offsetFree)); + } else if (Clausifier.needCCTerm(mTerm) && ((ApplicationTerm) mTerm).getParameters().length > 0) { final FunctionSymbol fs = ((ApplicationTerm) mTerm).getFunction(); if (fs.isIntern() && fs.getName() == "select") { mClausifier.getArrayTheory().cleanCaches(); @@ -83,12 +103,37 @@ public void perform() { cclosure.addTerm(ccTerm, mTerm); mClausifier.shareCCTerm(mTerm, ccTerm); mClausifier.addTermAxioms(mTerm, mSource); - mConverted.push(ccTerm); + pushResult(ccTerm, Rational.ZERO); } } } } + /** + * Maps a numeric term with a non-zero constant to the (already built) CCTerm of its offset-free part, and pushes + * that CCTerm together with the constant offset. + */ + private class MapOffsetFreeTerm implements Operation { + private final Term mTerm; + private final Rational mOffset; + + public MapOffsetFreeTerm(final Term term, final Rational offset) { + mTerm = term; + mOffset = offset; + } + + @Override + public void perform() { + final CCTerm offsetFreeCCTerm = mConverted.pop(); + mOffsets.pop(); + if (mClausifier.getCCTerm(mTerm) == null) { + mClausifier.shareCCTerm(mTerm, offsetFreeCCTerm); + mClausifier.addTermAxioms(mTerm, mSource); + } + pushResult(offsetFreeCCTerm, mOffset); + } + } + /** * Helper class to build the intermediate CCAppTerms. Note that all these terms will be func terms. * @@ -104,15 +149,26 @@ public BuildCCAppTerm(ApplicationTerm appTerm) { @Override public void perform() { final CCTerm[] args = new CCTerm[mAppTerm.getParameters().length]; + Rational[] argOffsets = null; for (int i = args.length - 1; i >= 0; i--) { args[i] = mConverted.pop(); + final Rational offset = mOffsets.pop(); + if (!offset.equals(Rational.ZERO)) { + if (argOffsets == null) { + argOffsets = new Rational[args.length]; + java.util.Arrays.fill(argOffsets, Rational.ZERO); + } + argOffsets[i] = offset; + } } assert mClausifier.getCCTerm(mAppTerm) == null; - final CCTerm ccTerm = mClausifier.getCClosure().createAppTerm(mAppTerm.getFunction(), args, mSource); + final CCTerm ccTerm = + mClausifier.getCClosure().createAppTerm(mAppTerm.getFunction(), args, argOffsets, mSource); mClausifier.getCClosure().addTerm(ccTerm, mAppTerm); mClausifier.shareCCTerm(mAppTerm, ccTerm); mClausifier.addTermAxioms(mAppTerm, mSource); - mConverted.push(ccTerm); + // the application term itself is not numeric-offset; its value is the application + pushResult(ccTerm, Rational.ZERO); } } } \ No newline at end of file diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/Clausifier.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/Clausifier.java index b16c38307..eb5119a40 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/Clausifier.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/Clausifier.java @@ -261,6 +261,12 @@ public void setTermFlags(final Term term, final int newFlags) { } public void share(final CCTerm ccTerm, final LASharedTerm laTerm) { + // With offset equalities several terms (e.g. 2x+4y, 2x+4y+1, 2x+4y+5) map to the same offset-free CCTerm. That + // CCTerm must be shared with linear arithmetic only once; the others are bridged by their constant offset. The + // LASharedTerm is still registered in the term maps (by the callers) so getLATerm lookups keep working. + if (ccTerm.getSharedTerm() == ccTerm) { + return; + } getLASolver().addSharedTerm(laTerm); getCClosure().addSharedTerm(ccTerm); } @@ -364,7 +370,10 @@ public void addTermAxioms(final Term term, final SourceAnnotation source) { final MutableAffineTerm mat = createMutableAffinTerm(new Polynomial(term), source); assert mat.getConstant().mEps == 0; if (!mLATerms.containsKey(term)) { - shareLATerm(term, new LASharedTerm(term, mat.getSummands(), mat.getConstant().mReal)); + // With offset equalities the shared term is offset-free (the constant is carried as an offset), + // so the LASharedTerm has offset zero and is shared with the offset-free CCTerm. + final Rational offset = createOffsetEqualities() ? Rational.ZERO : mat.getConstant().mReal; + shareLATerm(term, new LASharedTerm(term, mat.getSummands(), offset)); } } } @@ -577,6 +586,43 @@ public LASharedTerm getLATerm(final Term term) { return mLATerms.get(term); } + /** + * Whether offset equalities are enabled in the congruence closure. When enabled, numeric terms are represented by + * offset-free CCTerms with the constant carried as an offset. + */ + public boolean createOffsetEqualities() { + return getCClosure() != null && getCClosure().createOffsetEqualities(); + } + + /** + * The constant offset of a numeric term, i.e. the value such that {@code term == offsetFreeTerm + constant}. Returns + * {@link Rational#ZERO} when offset equalities are disabled or the term is not numeric. + */ + public Rational getTermConstant(final Term term) { + if (!createOffsetEqualities() || !term.getSort().isNumericSort()) { + return Rational.ZERO; + } + return new Polynomial(term).getConstant(); + } + + /** + * The offset-free part of a numeric term, i.e. the term with its constant summand removed. Two terms that differ + * only by a constant (e.g. {@code 2x+4y+1} and {@code 2x+4y+5}) yield the same canonical offset-free term, so they + * share a single CCTerm. Returns the term itself when it has no constant or offset equalities are disabled. + */ + public Term getOffsetFreeTerm(final Term term) { + if (!createOffsetEqualities() || !term.getSort().isNumericSort()) { + return term; + } + final Polynomial poly = new Polynomial(term); + final Rational constant = poly.getConstant(); + if (constant.equals(Rational.ZERO)) { + return term; + } + poly.add(constant.negate()); + return mCompiler.unifyPolynomial(poly, term.getSort()); + } + public ILiteral getILiteral(final Term term) { return mLiterals.get(term); } diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/EqualityProxy.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/EqualityProxy.java index 921c2d4b8..26ea27da3 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/EqualityProxy.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/EqualityProxy.java @@ -130,7 +130,10 @@ public CCEquality createCCEquality(final Term lhs, final Term rhs) { return eq; } } - final CCEquality eq = mClausifier.getCClosure().createCCEquality(mClausifier.getStackLevel(), ccLhs, ccRhs); + // offset such that value(ccLhs) == value(ccRhs) + offset, i.e. the difference of the two terms' constants. + final Rational offset = mClausifier.getTermConstant(rhs).sub(mClausifier.getTermConstant(lhs)); + final CCEquality eq = + mClausifier.getCClosure().createCCEquality(mClausifier.getStackLevel(), ccLhs, ccRhs, offset); final Rational normFactor = computeNormFactor(lhs, rhs); laeq.addDependentAtom(eq); eq.setLASharedData(laeq, normFactor); @@ -176,7 +179,8 @@ private DPLLAtom createAtom(final Term eqTerm, final SourceAnnotation source) { } /* create CC equality */ - return mClausifier.getCClosure().createCCEquality(mClausifier.getStackLevel(), ccLhs, ccRhs); + final Rational offset = mClausifier.getTermConstant(mRhs).sub(mClausifier.getTermConstant(mLhs)); + return mClausifier.getCClosure().createCCEquality(mClausifier.getStackLevel(), ccLhs, ccRhs, offset); } } diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCTerm.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCTerm.java index 26646d9dc..a1296df9f 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCTerm.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCTerm.java @@ -313,7 +313,9 @@ private void propagateSharedEquality(final CClosure engine, final CCTerm otherSh * as sterm is a newly shared term, which must be linear independent * of all previously created terms. */ - final CCEquality cceq = engine.createEquality(this, otherSharedTerm, true); + // value(this) - value(otherSharedTerm); both are in the same class, so the difference of their offsets. + final Rational offset = mOffsetToRep.sub(otherSharedTerm.mOffsetToRep); + final CCEquality cceq = engine.createEquality(this, otherSharedTerm, offset, true); assert cceq != null; if (engine.getLogger().isDebugEnabled()) { engine.getLogger().debug("PL: %s", cceq); @@ -423,9 +425,17 @@ private Clause mergeInternal(final CClosure engine, final CCTerm lhs, final CCEq final CCTerm src = lhs.mRepStar; final CCTerm dest = mRepStar; - // Need to prevent MBTC when we get a conflict. Hence a two-way pass + /* + * The offset that src (the old representative of the merged class) gets relative to dest: value(src) == + * value(dest) + delta. It is derived from the reason and the offsets the two merged terms already have relative + * to their respective representatives. + */ + final Rational delta = reasonDiff(reason, lhs, this).sub(lhs.mOffsetToRep).add(mOffsetToRep); + + // Need to prevent MBTC when we get a conflict. Hence a two-way pass. A disequality conflicts with the merge only + // if it forbids exactly the offset delta at which the two classes are being merged. CCEquality diseq = null; - final CCTermPairHash.Info diseqInfo = engine.mPairHash.getInfo(src, dest); + final CCTermPairHash.Info diseqInfo = engine.mPairHash.getInfo(src, dest, delta); if (diseqInfo != null) { diseq = diseqInfo.mDiseq; } @@ -435,7 +445,12 @@ private Clause mergeInternal(final CClosure engine, final CCTerm lhs, final CCEq dest.mSharedTerm = src.mSharedTerm; } else { final boolean createInLA = dest.mSharedTerm.mFlatTerm.getSort().isNumericSort(); - final CCEquality cceq = engine.createEquality(src.mSharedTerm, dest.mSharedTerm, createInLA); + // value(src.mSharedTerm) - value(dest.mSharedTerm) after the merge: the merge offset delta plus the two + // shared terms' offsets to their (current) representatives. + final Rational sharedOffset = + delta.add(src.mSharedTerm.mOffsetToRep).sub(dest.mSharedTerm.mOffsetToRep); + final CCEquality cceq = + engine.createEquality(src.mSharedTerm, dest.mSharedTerm, sharedOffset, createInLA); /* If cceq cannot be created this is a conflict like merging x+1 and x */ sharedTermConflict = (cceq == null); /* @@ -467,21 +482,16 @@ private Clause mergeInternal(final CClosure engine, final CCTerm lhs, final CCEq src.mMergeTime = engine.getMergeDepth(); engine.recordMerge(lhs); - engine.getLogger().debug("M %s %s", this, lhs); + engine.getLogger().debug("M %s %s (offset %s)", this, lhs, delta); if (Config.PROFILE_TIME) { time = System.nanoTime(); } /* - * Compute the offset that src (the old representative of the merged class) gets relative to dest. We have - * value(src) = value(dest) + delta, where delta is derived from the reason and the offsets the two merged terms - * already have relative to their respective representatives: - * delta = (value(lhs) - value(this)) - lhs.mOffsetToRep + this.mOffsetToRep. - * Since src is a representative its own offset was ZERO, so after the shift below src.mOffsetToRep == delta. - * The signatures must be rehashed with this delta as well, since the effective offset of every src-class - * argument increases by delta; rehashing happens before the offsets are actually updated below. + * Rehash the signatures with delta (computed above): the effective offset of every src-class argument increases + * by delta. Since src is a representative its own offset was ZERO, so after the shift below src.mOffsetToRep == + * delta. Rehashing happens before the offsets are actually updated below. */ - final Rational delta = reasonDiff(reason, lhs, this).sub(lhs.mOffsetToRep).add(mOffsetToRep); engine.rehashSignatures(src, dest, delta, src.mSignatureBackRefs); /* Update rep fields and shift offsets of the merged members into dest's frame */ src.mRep = dest; @@ -503,19 +513,28 @@ private Clause mergeInternal(final CClosure engine, final CCTerm lhs, final CCEq assert pentry.getOtherEntry().mOther == src; final CCTerm other = pentry.mOther; assert other.mRepStar == other; + // offset of this info as seen from src: value(src) - value(other) + final Rational offFromSrc = pentry.getOffsetToOther(); if (other == dest) { - assert (info.mDiseq == null); - for (final CCEquality.Entry eq : info.mEqlits) { - engine.addPending(eq.getCCEquality()); - } - // E-Matching - for (final CompareTrigger trigger : info.mCompareTriggers) { - trigger.activate(); + if (offFromSrc.equals(delta)) { + // The equalities in this info hold at exactly the merge offset, so they are now implied true. + assert (info.mDiseq == null); + for (final CCEquality.Entry eq : info.mEqlits) { + engine.addPending(eq.getCCEquality()); + } + // E-Matching + for (final CompareTrigger trigger : info.mCompareTriggers) { + trigger.activate(); + } } + // else: these equalities are at a different offset and become false; the conflict (if any) is detected + // when such an equality is asserted (setLiteral), so we do not propagate them eagerly here. } else { - CCTermPairHash.Info destInfo = engine.mPairHash.getInfo(dest, other); + // Re-key the info from src to dest: value(dest) - value(other) = (value(src) - value(other)) - delta. + final Rational destOffset = offFromSrc.sub(delta); + CCTermPairHash.Info destInfo = engine.mPairHash.getInfo(dest, other, destOffset); if (destInfo == null) { - destInfo = new CCTermPairHash.Info(dest, other); + destInfo = new CCTermPairHash.Info(dest, other, destOffset); engine.mPairHash.add(destInfo); } //System.err.println("M "+src+" "+other+" "+dest); @@ -569,7 +588,7 @@ private Clause mergeInternal(final CClosure engine, final CCTerm lhs, final CCEq } public void undoMerge(final CClosure engine, final CCTerm lhs) { - engine.getLogger().debug("U %s %s", lhs, this); + engine.getLogger().debug("U %s %s (offset %s)", lhs, this, mOldRep == null ? null : mOldRep.mOffsetToRep); long time; CCTerm src, dest; @@ -606,7 +625,9 @@ public void undoMerge(final CClosure engine, final CCTerm lhs) { final CCTerm other = pentry.mOther; assert other.mRepStar == other; if (other != dest) { - final CCTermPairHash.Info destInfo = engine.mPairHash.getInfo(dest, other); + // Mirror the merge re-keying: the merged info lived at value(dest) - value(other) = offFromSrc - delta. + final Rational destOffset = pentry.getOffsetToOther().sub(delta); + final CCTermPairHash.Info destInfo = engine.mPairHash.getInfo(dest, other, destOffset); if (destInfo == null) { continue; } diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java index 7a5b6a161..1e7f18e59 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java @@ -207,6 +207,24 @@ public boolean isProofGenerationEnabled() { return getEngine().isProofGenerationEnabled(); } + /** + * Whether offset equalities are created. When enabled, arithmetic terms are turned into offset-free CCTerms (with + * the constant carried as an offset) and equalities between numeric terms become offset equalities. When disabled, + * the classic offset-free-less behaviour is used (every offset is zero). + * + * This is currently forced off while proof generation is enabled, until the proof machinery understands offset + * equalities (a later increment). + */ + private boolean mOffsetEqualities = true; + + public boolean createOffsetEqualities() { + return mOffsetEqualities && !isProofGenerationEnabled(); + } + + public void setOffsetEqualities(final boolean enabled) { + mOffsetEqualities = enabled; + } + public CCTerm createAnonTerm(final Term term) { final CCTerm ccTerm = new CCBaseTerm(term); mAllTerms.add(ccTerm); @@ -277,9 +295,20 @@ private int getMergeStackDepth(CCTerm t1, CCTerm t2) { } public CCTerm createAppTerm(FunctionSymbol func, final CCTerm[] args, final SourceAnnotation source) { + return createAppTerm(func, args, null, source); + } + + /** + * Create a function application CCTerm. The optional {@code argOffsets} array carries the constant offset of each + * argument, so that the actual argument is {@code args[i] + argOffsets[i]} (used for offset-free arguments like the + * {@code +5} in {@code f(x+5)}). It may be {@code null} when every offset is zero. + */ + public CCTerm createAppTerm(FunctionSymbol func, final CCTerm[] args, final Rational[] argOffsets, + final SourceAnnotation source) { assert args.length > 0; if (args.length > 0) { final CCAppTerm term = new CCAppTerm(func, args, this, source.isFromQuantTheory()); + term.mArgOffsets = argOffsets; if (term.getAge() > 0) { getLogger().debug("Create new AppTerm %s of age %d", term, term.getAge()); } @@ -604,7 +633,10 @@ public boolean isEqSet(final CCTerm first, final CCTerm second) { public boolean isDiseqSet(final CCTerm first, final CCTerm second) { final CCTerm firstRep = first.getRepresentative(); final CCTerm secondRep = second.getRepresentative(); - final CCTermPairHash.Info diseqInfo = mPairHash.getInfo(firstRep, secondRep); + // A diseq disproves value(first) == value(second), i.e. value(firstRep) - value(secondRep) == + // second.mOffsetToRep - first.mOffsetToRep. + final Rational offset = second.getOffsetToRep().sub(first.getOffsetToRep()); + final CCTermPairHash.Info diseqInfo = mPairHash.getInfo(firstRep, secondRep, offset); return diseqInfo != null && diseqInfo.mDiseq != null; } @@ -617,13 +649,15 @@ public boolean isDiseqSet(final CCTerm first, final CCTerm second) { * @param eqentry the equality entry that should be inserted into the pair * infos. */ - public void insertEqualityEntry(CCTerm t1, CCTerm t2, final CCEquality.Entry eqentry) { + public void insertEqualityEntry(CCTerm t1, CCTerm t2, Rational offset, final CCEquality.Entry eqentry) { + // offset == value(t1) - value(t2) while (true) { // make t1 the term that was merged before t2 was merged. if (t1.mMergeTime > t2.mMergeTime) { final CCTerm tmp = t1; t1 = t2; t2 = tmp; + offset = offset.negate(); } // if t1 is its own representative, then t2 should also be the representative @@ -631,9 +665,9 @@ public void insertEqualityEntry(CCTerm t1, CCTerm t2, final CCEquality.Entry eqe if (t1.mRep == t1) { assert t2.mRep == t2; // Insert this entry into the pair hash, create it if necessary. - CCTermPairHash.Info info = mPairHash.getInfo(t1, t2); + CCTermPairHash.Info info = mPairHash.getInfo(t1, t2, offset); if (info == null) { - info = new CCTermPairHash.Info(t1, t2); + info = new CCTermPairHash.Info(t1, t2, offset); mPairHash.add(info); } info.mEqlits.prependIntoJoined(eqentry, true); @@ -649,7 +683,7 @@ public void insertEqualityEntry(CCTerm t1, CCTerm t2, final CCEquality.Entry eqe final CCTermPairHash.Info info = pentry.getInfo(); // info might have blocked compare triggers but no eqlits // assert (!info.eqlits.isEmpty()); - if (pentry.mOther == t2) { + if (pentry.mOther == t2 && pentry.getOffsetToOther().equals(offset)) { info.mEqlits.prependIntoJoined(eqentry, isLast); found = true; break; @@ -657,18 +691,27 @@ public void insertEqualityEntry(CCTerm t1, CCTerm t2, final CCEquality.Entry eqe } if (!found) { // we need to create a new entry. - final CCTermPairHash.Info info = new CCTermPairHash.Info(t1, t2); + final CCTermPairHash.Info info = new CCTermPairHash.Info(t1, t2, offset); info.mRhsEntry.unlink(); info.mEqlits.prependIntoJoined(eqentry, isLast); } if (isLast) { break; } + // walk t1 up to its representative; value(t1) - value(t1.mRep) = t1.mOffsetToRep - t1.mRep.mOffsetToRep + offset = offset.sub(t1.getOffsetToRep()).add(t1.mRep.getOffsetToRep()); t1 = t1.mRep; } } - public CCEquality createCCEquality(final int stackLevel, CCTerm t1, CCTerm t2) { + public CCEquality createCCEquality(final int stackLevel, final CCTerm t1, final CCTerm t2) { + return createCCEquality(stackLevel, t1, t2, Rational.ZERO); + } + + /** + * Create a CCEquality stating {@code value(t1) == value(t2) + offset}. + */ + public CCEquality createCCEquality(final int stackLevel, CCTerm t1, CCTerm t2, Rational offset) { assert (t1 != t2); CCEquality eq = null; assert t1.invariant(); @@ -680,9 +723,11 @@ public CCEquality createCCEquality(final int stackLevel, CCTerm t1, CCTerm t2) { final CCTerm tmp = t2; t2 = t1; t1 = tmp; + offset = offset.negate(); } eq = new CCEquality(stackLevel, t1, t2); - insertEqualityEntry(t1, t2, eq.getEntry()); + eq.setOffset(offset); + insertEqualityEntry(t1, t2, offset, eq.getEntry()); getEngine().addAtom(eq); assert t1.invariant(); @@ -691,13 +736,22 @@ public CCEquality createCCEquality(final int stackLevel, CCTerm t1, CCTerm t2) { assert t2.pairHashValid(this); if (t1.mRepStar == t2.mRepStar) { - if (getLogger().isDebugEnabled()) { - getLogger().debug("CC-Prop: " + eq + " repStar: " + t1.mRepStar); + // The two terms are already in the same class. The equality is implied true only if the offset matches the + // offset they already have; otherwise it is implied false but we leave it for the SAT solver (setLiteral + // detects the conflict if it is ever set true). + final Rational existingDiff = t1.getOffsetToRep().sub(t2.getOffsetToRep()); + if (existingDiff.equals(offset)) { + if (getLogger().isDebugEnabled()) { + getLogger().debug("CC-Prop: " + eq + " repStar: " + t1.mRepStar); + } + mPendingLits.add(eq); + mRecheckOnBacktrackLits.add(eq); } - mPendingLits.add(eq); - mRecheckOnBacktrackLits.add(eq); } else { - final CCEquality diseq = mPairHash.getInfo(t1.mRepStar, t2.mRepStar).mDiseq; + // value(t1Rep) - value(t2Rep) under the equality + final Rational repOffset = offset.sub(t1.getOffsetToRep()).add(t2.getOffsetToRep()); + final CCTermPairHash.Info repInfo = mPairHash.getInfo(t1.mRepStar, t2.mRepStar, repOffset); + final CCEquality diseq = repInfo == null ? null : repInfo.mDiseq; if (diseq != null) { if (getLogger().isDebugEnabled()) { getLogger().debug("CC-Prop: " + eq.negate() + " diseq: " + diseq); @@ -838,6 +892,13 @@ public Clause setLiteral(final Literal literal) { if (conflict != null) { return conflict; } + } else { + // The terms are already in the same class. Asserting the equality is a conflict if their actual offset + // differs from the offset claimed by the equality (e.g. asserting x == x + 1). + final Rational existingDiff = eq.getLhs().getOffsetToRep().sub(eq.getRhs().getOffsetToRep()); + if (!existingDiff.equals(eq.getOffset())) { + return computeCycle(eq); + } } } else { final CCTerm left = eq.getLhs().mRepStar; @@ -845,12 +906,19 @@ public Clause setLiteral(final Literal literal) { /* Check for conflict */ if (left == right) { - final Clause conflict = computeCycle(eq); - if (conflict != null) { - return conflict; + // They are in the same class. The disequality is a conflict only if they are equal at exactly the + // offset the equality claims; if their actual offset differs, the disequality already holds and there + // is nothing to separate. + final Rational existingDiff = eq.getLhs().getOffsetToRep().sub(eq.getRhs().getOffsetToRep()); + if (existingDiff.equals(eq.getOffset())) { + final Clause conflict = computeCycle(eq); + if (conflict != null) { + return conflict; + } } + } else { + separate(left, right, eq); } - separate(left, right, eq); } final LAEquality laeq = eq.getLASharedData(); if (laeq != null) { @@ -887,7 +955,7 @@ private void separate(final CCTerm lhs, final CCTerm rhs, final CCEquality diseq return; } } - final CCTermPairHash.Info info = mPairHash.getInfo(lhs, rhs); + final CCTermPairHash.Info info = mPairHash.getInfo(lhs, rhs, repOffset(diseq)); assert info.mDiseq == null; mUndoStack.push(new SepUndoInfo(diseq)); @@ -903,8 +971,17 @@ private void separate(final CCTerm lhs, final CCTerm rhs, final CCEquality diseq } } + /** + * The offset of an equality as seen between the representatives of its two sides, i.e. + * {@code value(eq.getLhs().mRepStar) - value(eq.getRhs().mRepStar)} implied by the equality. + */ + private static Rational repOffset(final CCEquality eq) { + return eq.getOffset().sub(eq.getLhs().getOffsetToRep()).add(eq.getRhs().getOffsetToRep()); + } + private void undoSep(final CCEquality atom) { - final CCTermPairHash.Info destInfo = mPairHash.getInfo(atom.getLhs().mRepStar, atom.getRhs().mRepStar); + final CCTermPairHash.Info destInfo = + mPairHash.getInfo(atom.getLhs().mRepStar, atom.getRhs().mRepStar, repOffset(atom)); assert destInfo != null && destInfo.mDiseq == atom; destInfo.mDiseq = null; } @@ -993,11 +1070,30 @@ public Clause checkpoint() { } public CCEquality createEquality(final CCTerm t1, final CCTerm t2, final boolean createLAEquality) { + return createEquality(t1, t2, Rational.ZERO, createLAEquality); + } + + /** + * Create (and propagate) an equality {@code value(t1) == value(t2) + offset} between two shared terms. The offset is + * encoded into the right-hand term ({@code t2 + offset}) so that the resulting CCEquality carries the offset and the + * linked LAEquality carries the matching constant. With a zero offset this is the classic shared-term equality. + */ + public CCEquality createEquality(final CCTerm t1, final CCTerm t2, final Rational offset, + final boolean createLAEquality) { assert t1 != t2; - final EqualityProxy ep = mClausifier.createEqualityProxy(t1.getFlatTerm(), t2.getFlatTerm(), null); + final Term lhsTerm = t1.getFlatTerm(); + Term rhsTerm = t2.getFlatTerm(); + if (!offset.equals(Rational.ZERO)) { + rhsTerm = rhsTerm.getTheory().term("+", rhsTerm, offset.toTerm(rhsTerm.getSort())); + } + final EqualityProxy ep = mClausifier.createEqualityProxy(lhsTerm, rhsTerm, null); if (ep == EqualityProxy.getFalseProxy()) { return null; } + if (!offset.equals(Rational.ZERO) && mClausifier.getCCTerm(rhsTerm) == null) { + // the offset term t2+offset is offset-free-equivalent to t2; map it to the same CCTerm. + mClausifier.shareCCTerm(rhsTerm, t2); + } if (!createLAEquality) { final Literal res = ep.getLiteral(null); if (res instanceof CCEquality) { @@ -1007,7 +1103,7 @@ public CCEquality createEquality(final CCTerm t1, final CCTerm t2, final boolean } } } - return ep.createCCEquality(t1.getFlatTerm(), t2.getFlatTerm()); + return ep.createCCEquality(lhsTerm, rhsTerm); } @Override @@ -1020,6 +1116,9 @@ public void dumpModel(final LogProxy logger) { String comma = ""; for (final CCTerm t2 : t.mMembers) { sb.append(comma).append(t2); + if (!t2.getOffsetToRep().equals(Rational.ZERO)) { + sb.append("[+").append(t2.getOffsetToRep()).append(']'); + } comma = "="; } logger.info(sb.toString()); @@ -1131,9 +1230,12 @@ public Clause backtrackComplete() { /* check if literal is still implied by the graph */ boolean repropagate = false; if (l.getSign() > 0) { - repropagate = (lhs == rhs); + // implied true only if the terms are in the same class at the equality's offset + repropagate = (lhs == rhs + && eq.getLhs().getOffsetToRep().sub(eq.getRhs().getOffsetToRep()).equals(eq.getOffset())); } else { - final CCEquality diseq = mPairHash.getInfo(lhs, rhs).mDiseq; + final CCTermPairHash.Info info = mPairHash.getInfo(lhs, rhs, repOffset(eq)); + final CCEquality diseq = info == null ? null : info.mDiseq; if (diseq != null) { eq.mDiseqReason = diseq; repropagate = true; @@ -1237,21 +1339,23 @@ public int getStackDepth() { public void removeAtom(final DPLLAtom atom) { if (atom instanceof CCEquality) { final CCEquality cceq = (CCEquality) atom; - removeCCEquality(cceq.getLhs(), cceq.getRhs(), cceq); + removeCCEquality(cceq.getLhs(), cceq.getRhs(), cceq.getOffset(), cceq); } } - private void removeCCEquality(CCTerm t1, CCTerm t2, final CCEquality eq) { + private void removeCCEquality(CCTerm t1, CCTerm t2, Rational offset, final CCEquality eq) { // TODO Need test for this!!! + // offset == value(t1) - value(t2) if (t1.mMergeTime > t2.mMergeTime) { final CCTerm tmp = t1; t1 = t2; t2 = tmp; + offset = offset.negate(); } if (t1.mRep == t1) { assert t2.mRep == t2; - final CCTermPairHash.Info info = mPairHash.getInfo(t1, t2); + final CCTermPairHash.Info info = mPairHash.getInfo(t1, t2, offset); if (info != null) { // Remove pair hash info info.mEqlits.prepareRemove(eq.getEntry()); @@ -1265,7 +1369,7 @@ private void removeCCEquality(CCTerm t1, CCTerm t2, final CCEquality eq) { boolean found = false; for (final CCTermPairHash.Info.Entry pentry : t1.mPairInfos) { final CCTermPairHash.Info info = pentry.getInfo(); - if (pentry.mOther == t2) { + if (pentry.mOther == t2 && pentry.getOffsetToOther().equals(offset)) { info.mEqlits.prepareRemove(eq.getEntry()); found = true; break; @@ -1275,7 +1379,7 @@ private void removeCCEquality(CCTerm t1, CCTerm t2, final CCEquality eq) { if (isLast) { eq.getEntry().removeFromList(); } else { - removeCCEquality(t1.mRep, t2, eq); + removeCCEquality(t1.mRep, t2, offset.sub(t1.getOffsetToRep()).add(t1.mRep.getOffsetToRep()), eq); } } if (eq.getLASharedData() != null) { diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ModelBuilder.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ModelBuilder.java index 59ffb0b66..6a14850ef 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ModelBuilder.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ModelBuilder.java @@ -155,6 +155,23 @@ public Term getModelValue(final CCTerm term) { return mModelValues.get(term.getRepresentative()); } + /** + * The model value of a (numeric) term plus a constant. The representative carries the model value of the offset-free + * class; an individual member's value adds its offset to the representative, and {@code extraOffset} adds a further + * constant (e.g. the structural argument offset of {@code x+5}). For non-numeric terms the offsets are zero and the + * representative's value is returned unchanged. + */ + private Term getModelValueWithOffset(final CCTerm term, final Rational extraOffset) { + final Term repValue = mModelValues.get(term.getRepresentative()); + final Sort sort = term.getFlatTerm().getSort(); + if (!sort.isNumericSort()) { + assert term.getOffsetToRep().equals(Rational.ZERO) && extraOffset.equals(Rational.ZERO); + return repValue; + } + final Rational value = NumericSortInterpretation.toRational(repValue).add(term.getOffsetToRep()).add(extraOffset); + return value.toTerm(sort); + } + public void setModelValue(final CCTerm term, final Term value) { assert term == term.getRepresentative(); final Term old = mModelValues.put(term, value); @@ -172,7 +189,10 @@ public void fillInTermValues(final List terms, final CCTerm trueNode, fi if (sort.isNumericSort()) { Rational v; if (ccterm.getSharedTerm() != null) { - v = mEvaluator.evaluate(ccterm.getSharedTerm().getFlatTerm(), mTheory); + final CCTerm shared = ccterm.getSharedTerm(); + // evaluate gives the value of the (offset-free) shared term; the representative's value is that + // minus the shared term's offset to the representative. + v = mEvaluator.evaluate(shared.getFlatTerm(), mTheory).sub(shared.getOffsetToRep()); if (smtterm.getSort().getName().equals("Int") && !v.isIntegral()) { throw new AssertionError("Int term has non-integral value"); } @@ -223,7 +243,7 @@ public void fillInTermValues(final List terms, final CCTerm trueNode, fi public void fillInFunctions(final List terms, final Model model, final Theory t) { for (final CCTerm term : terms) { - add(model, term, mModelValues.get(term.getRepresentative()), t); + add(model, term, getModelValueWithOffset(term, Rational.ZERO), t); } } @@ -270,7 +290,8 @@ private void addApp(final Model model, final CCAppTerm app, final Term value, fi CCTerm[] ccArgs = app.getArguments(); Term[] args = new Term[ccArgs.length]; for (int i = 0; i < args.length; i++) { - args[i] = mModelValues.get(ccArgs[i].getRepresentative()); + // the actual argument is ccArgs[i] + structural offset, evaluated at its model value + args[i] = getModelValueWithOffset(ccArgs[i], app.getArgOffset(i)); } final FunctionSymbol fs = app.getFunctionSymbol(); if (!fs.isIntern() || isUndefinedFor(fs, args)) { From 72ced6cafd35f5f8e7c3208a9a1ecb17e186bc6f Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Fri, 12 Jun 2026 23:19:01 +0200 Subject: [PATCH 07/75] CC offset equalities: fix shared-term equality losing polynomial structure The shared-term equality propagation built the right-hand term as a nested (+ t2 offset). When t2 is itself a normalized sum (e.g. k - 256*(div k 256)), Polynomial re-parses that inner sum as a single opaque monomial, so the derived LAEquality dropped the summands (turning k mod 256 = 1 into a bogus k = 1) and produced spurious unsat (e.g. bv/test04.smt2). Build the right-hand term as a single flattened polynomial term via the new Clausifier.addConstantToTerm (unifyPolynomial(Polynomial(t2) + offset)) so the equality re-parses with all summands intact. Also print argument offsets in CCAppTerm.toString to make offset terms legible in debug output. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ultimate/smtinterpol/convert/Clausifier.java | 10 ++++++++++ .../smtinterpol/theory/cclosure/CCAppTerm.java | 3 +++ .../ultimate/smtinterpol/theory/cclosure/CClosure.java | 4 +++- 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/Clausifier.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/Clausifier.java index eb5119a40..f4966f291 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/Clausifier.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/Clausifier.java @@ -610,6 +610,16 @@ public Rational getTermConstant(final Term term) { * only by a constant (e.g. {@code 2x+4y+1} and {@code 2x+4y+5}) yield the same canonical offset-free term, so they * share a single CCTerm. Returns the term itself when it has no constant or offset equalities are disabled. */ + /** + * Build the normalized term for {@code term + constant}. The result is a single flattened polynomial term (not a + * nested {@code (+ term constant)}), so that re-parsing it as a Polynomial recovers all summands. + */ + public Term addConstantToTerm(final Term term, final Rational constant) { + final Polynomial poly = new Polynomial(term); + poly.add(constant); + return mCompiler.unifyPolynomial(poly, term.getSort()); + } + public Term getOffsetFreeTerm(final Term term) { if (!createOffsetEqualities() || !term.getSort().isNumericSort()) { return term; diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAppTerm.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAppTerm.java index bf9d8513c..4dd0c4e1c 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAppTerm.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAppTerm.java @@ -96,6 +96,9 @@ public String toString() { visited.put(app, visited.size()); todo.add(")"); for (int i = app.mArgs.length - 1; i >= 0; i--) { + if (!app.getArgOffset(i).equals(Rational.ZERO)) { + todo.add("+" + app.getArgOffset(i)); + } todo.add(app.mArgs[i]); todo.add(" "); } diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java index 1e7f18e59..a1563ab80 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java @@ -1084,7 +1084,9 @@ public CCEquality createEquality(final CCTerm t1, final CCTerm t2, final Rationa final Term lhsTerm = t1.getFlatTerm(); Term rhsTerm = t2.getFlatTerm(); if (!offset.equals(Rational.ZERO)) { - rhsTerm = rhsTerm.getTheory().term("+", rhsTerm, offset.toTerm(rhsTerm.getSort())); + // Build the flattened polynomial term t2 + offset. A nested (+ t2 offset) would not be re-parsed + // correctly by Polynomial when t2 is itself a normalized sum (the inner sum is treated as one monomial). + rhsTerm = mClausifier.addConstantToTerm(rhsTerm, offset); } final EqualityProxy ep = mClausifier.createEqualityProxy(lhsTerm, rhsTerm, null); if (ep == EqualityProxy.getFalseProxy()) { From 87015ba3837ec9a21af2312560ab1f2289065537 Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Fri, 12 Jun 2026 23:37:41 +0200 Subject: [PATCH 08/75] CC offset equalities: disable offsets in the presence of quantifiers E-matching binds quantifier variables to offset-free CCTerms (GetArgCode reads the offset-free argument), losing the constant offset: matching a(x) against the ground term a(l+1) instantiates x := l instead of x := l+1, so the conflict instance is never produced and an unsat problem is reported sat (quantified/quanttest001.smt2). Gate createOffsetEqualities() on mQuantTheory == null, mirroring the existing quantifier check in isBasicStablyInfinite. Quantifier-free problems keep offsets; quantified problems use the offset-free-less path. Offset-aware e-matching is future work for when the quantifier theory is reworked. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../informatik/ultimate/smtinterpol/convert/Clausifier.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/Clausifier.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/Clausifier.java index f4966f291..a614218c9 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/Clausifier.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/Clausifier.java @@ -591,7 +591,11 @@ public LASharedTerm getLATerm(final Term term) { * offset-free CCTerms with the constant carried as an offset. */ public boolean createOffsetEqualities() { - return getCClosure() != null && getCClosure().createOffsetEqualities(); + // Offsets are disabled in the presence of quantifiers: e-matching binds quantifier variables to offset-free + // CCTerms and would lose the offset (e.g. match a(x) against a(l+1) but instantiate x := l), which is unsound. + // Quantifier-free problems are unaffected. (The quantifier theory is slated for rework; full offset-aware + // e-matching is future work.) + return mQuantTheory == null && getCClosure() != null && getCClosure().createOffsetEqualities(); } /** From 381d96462d02bde461241f458293ae2f6c182656 Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Sat, 13 Jun 2026 08:21:48 +0200 Subject: [PATCH 09/75] Update offset-equality plan: status, remaining gaps, CCParameter design Co-Authored-By: Claude Opus 4.8 (1M context) --- SMTInterpol/doc/offset-equality-plan.md | 86 +++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/SMTInterpol/doc/offset-equality-plan.md b/SMTInterpol/doc/offset-equality-plan.md index 32f09626f..cc2ecc598 100644 --- a/SMTInterpol/doc/offset-equality-plan.md +++ b/SMTInterpol/doc/offset-equality-plan.md @@ -345,3 +345,89 @@ offsets. | `CCProofGenerator.java` | Emit offset-equality proof steps | | `CClosure.java` | Update `addEquality()` entry point; update `share()` to propagate offset to LA | | `LinArSolve.java` | Post offset equalities (`k ≠ 0`) to CC, not only zero-offset equalities | + +## Status after increment 3 + +Increments 1, 2a, 2b and 3 are implemented and committed; offsets are live for +quantifier-free, non-proof problems and the full unit suite passes. Two further +fixes landed: +- the shared-term equality must use a *flattened* polynomial term + (`Clausifier.addConstantToTerm`), otherwise a nested `(+ t offset)` is + re-parsed with the inner sum collapsed to one monomial (was unsound, e.g. + `bv/test04`); +- offsets are disabled in the presence of quantifiers (`mQuantTheory != null`), + because e-matching binds variables to offset-free CCTerms and would lose the + offset (was unsound, e.g. `quanttest001`). + +Running the system benchmarks with proofs/interpolants disabled (so offsets are +exercised) leaves these failures, all in offset-heavy machinery and **none of +them unsound** (crashes or incompleteness): `array/difftest004` (crash), +`nia/divaxiom2` (crash), `bv/test01`, `abv/indexInRange01`, `abv/ext02` +(unsat → unknown). They stem from the gaps below. + +## Remaining gaps + +1. **Every `CCAppTerm` consumer must treat argument `i` as `getArgument(i) + + getArgOffset(i)`** (the most important gap). Consumers that currently drop the + offset: `ArrayTheory` (select/store indices and array arguments), + `DataTypeTheory`, `CClosure.getCCTermRep`/`getAllFuncApps`/`checkCongruence`, + and the e-matching (`GetArgCode`/`EMReverseTrigger`, currently moot because + offsets are off under quantifiers). Already fixed: `ModelBuilder.addApp`. This + is behind the array crash and the BV/ABV incompleteness. +2. **LA must propagate offset equalities (LA→CC).** Today LA only propagates an + equality between two shared terms when they are equal (offset 0). When LA + determines `value(a) − value(b) = k` for a constant `k`, it should propagate + `a = b + k` so CC merges them at offset `k`. This closes the BV/ABV + incompleteness (e.g. LA knows `k mod 256 = 1` but never tells CC). +3. **Eager negated-equality propagation.** Increment 3 deliberately omitted (kept + sound-but-lazy) the propagation of `mEqlits` at a non-matching offset as + *false* when two classes merge at offset δ. Add it to match the eagerness of + the offset-0 code. + +## CCParameter: a value `CCTerm + Rational` + +To stop every consumer from re-deriving `getArgument(i) + getArgOffset(i)` / +`mOffsetToRep` arithmetic, introduce a small abstraction for "a value up to a +constant" (array index, congruence argument, shared-term comparison): + +```java +public interface CCParameter { // value == getCCTerm() + getOffset() + CCTerm getCCTerm(); + Rational getOffset(); // structural offset; ZERO for a bare CCTerm + default CCTerm getRepresentative() { return getCCTerm().getRepresentative(); } + default Rational getOffsetToRep() { return getCCTerm().getOffsetToRep().add(getOffset()); } +} +``` + +- It must be an **interface** (not a base class): `CCTerm` already + `extends SimpleListable`, so it `implements CCParameter` with `getCCTerm() = + this`, `getOffset() = ZERO` — no extra object for the common offset-0 case. +- `OffsettedCCTerm implements CCParameter` is an immutable `(mTerm, mOffset)` with + `mOffset != ZERO`. Factory `CCParameter.of(t, off)` returns the bare `t` when + `off` is zero, so an offset-0 value is *always* a bare `CCTerm` (canonical + representation; no ambiguous `OffsettedCCTerm(t, 0)`). +- `SignatureTrigger` can use `CCParameter[]` instead of the parallel + `CCTerm[] mTerms` + `Rational[] mArgOffsets`, with the rehash-on-rep-change it + already performs. + +**Caveat (array index keys).** A `CCParameter`'s value identity +`(getRepresentative(), getOffsetToRep())` *changes on merge* (rep and offset both +shift). So it cannot be a naive persistent `HashMap` key — the hash mutates. +`ArrayTheory` currently keys indices on the representative alone, which is +insufficient with offsets. Options (to be settled when adapting `ArrayTheory`): +either rehash index keys on merge (the pattern `SignatureTrigger`/`CCTermPairHash` +already use), or use a structure keyed on the representative with the offset as a +secondary dimension (no reliance on a stable value hash). A second +`OffsettedCCTerm`-style wrapper does **not** by itself solve this — any +value-identity key has the same mutating-hash problem; the merge-time rehashing +is the actual requirement. + +## Gap-fix order + +1. `CCParameter` + `OffsettedCCTerm`; wire the `CClosure` consumers + (`getCCTermRep`, `getAllFuncApps`, `checkCongruence`) and `DataTypeTheory`. +2. `ArrayTheory` offset-aware index handling (gap 1, the substantial one). +3. LA → CC offset-equality propagation (gap 2). +4. Eager negated-equality propagation (gap 3). +5. Proof production (increment 4) and offset-aware e-matching (re-enable offsets + under quantifiers) — both deferred to the quantifier-theory rework. From ba28012e80dc596a48c410453c82707ed6b4a50b Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Sat, 13 Jun 2026 08:31:10 +0200 Subject: [PATCH 10/75] Plan: array theory rebuilds on index merge, so index keys need no rehashing Co-Authored-By: Claude Opus 4.8 (1M context) --- SMTInterpol/doc/offset-equality-plan.md | 28 ++++++++++++++++--------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/SMTInterpol/doc/offset-equality-plan.md b/SMTInterpol/doc/offset-equality-plan.md index cc2ecc598..db44c09ca 100644 --- a/SMTInterpol/doc/offset-equality-plan.md +++ b/SMTInterpol/doc/offset-equality-plan.md @@ -410,17 +410,25 @@ public interface CCParameter { // value == getCCTerm() + getOffset() `CCTerm[] mTerms` + `Rational[] mArgOffsets`, with the rehash-on-rep-change it already performs. -**Caveat (array index keys).** A `CCParameter`'s value identity +**Array index keys.** A `CCParameter`'s value identity `(getRepresentative(), getOffsetToRep())` *changes on merge* (rep and offset both -shift). So it cannot be a naive persistent `HashMap` key — the hash mutates. -`ArrayTheory` currently keys indices on the representative alone, which is -insufficient with offsets. Options (to be settled when adapting `ArrayTheory`): -either rehash index keys on merge (the pattern `SignatureTrigger`/`CCTermPairHash` -already use), or use a structure keyed on the representative with the offset as a -secondary dimension (no reliance on a stable value hash). A second -`OffsettedCCTerm`-style wrapper does **not** by itself solve this — any -value-identity key has the same mutating-hash problem; the merge-time rehashing -is the actual requirement. +shift), so it cannot be a naive persistent `HashMap` key. However, `ArrayTheory` +does **not** keep persistent index-keyed maps across merges: it **rebuilds its +weak-equivalence structures from scratch on every index merge** (the class shape, +primary/secondary store edges can change completely — e.g. when a secondary-edge +index becomes equal to the primary-edge index a different secondary edge must be +chosen). So **no rehashing is needed**: within one rebuild the representatives and +offsets are a fixed snapshot, making a value-identity key stable for that rebuild. + +The adaptation is therefore: where the array theory keys an index on +`index.getRepresentative()` today, key it on the value identity +`(getRepresentative(), getOffsetToRep())` instead — a small value-identity key +wrapper built fresh during the rebuild. The index value itself is read as +`CCParameter.of(app.getArgument(idxPos), app.getArgOffset(idxPos))`, which +supplies those accessors uniformly (a bare `CCTerm` when the index has no +offset). Note this composite key cannot be the bare `CCParameter`/`CCTerm` +directly, because `CCTerm`'s `hashCode`/`equals` are object identity, not the +value identity `(rep, offsetToRep)` the array theory needs. ## Gap-fix order From 3aa32c67e7b5b36822225abbf4d76c6865e21148 Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Sat, 13 Jun 2026 08:46:07 +0200 Subject: [PATCH 11/75] CC offset equalities: add CCParameter (CCTerm + Rational) abstraction Introduce CCParameter, an interface for "a value up to a constant" used wherever a function-application argument, array index or congruence argument is handled once offsets exist. A bare CCTerm is a CCParameter with offset zero (no wrapper allocated in the common case); OffsettedCCTerm carries a non-zero offset. CCParameter.of canonicalizes (offset zero -> bare CCTerm). Value identity is (getRepresentative(), getOffsetToRep()); use sameValueAs for comparison. Purely additive; no behavior change yet. Consumers (CClosure lookups, DataTypeTheory, ArrayTheory) will be migrated to use it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../theory/cclosure/CCParameter.java | 70 +++++++++++++++++ .../smtinterpol/theory/cclosure/CCTerm.java | 14 +++- .../theory/cclosure/OffsettedCCTerm.java | 75 +++++++++++++++++++ 3 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCParameter.java create mode 100644 SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/OffsettedCCTerm.java diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCParameter.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCParameter.java new file mode 100644 index 000000000..7a08c94be --- /dev/null +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCParameter.java @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2026 University of Freiburg + * + * This file is part of SMTInterpol. + * + * SMTInterpol is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * SMTInterpol is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with SMTInterpol. If not, see . + */ +package de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure; + +import de.uni_freiburg.informatik.ultimate.logic.Rational; + +/** + * A value of the form {@code getCCTerm() + getOffset()}: a CCTerm together with a constant offset. This is what every + * consumer of a function application argument actually deals with once offset equalities exist (the argument of + * {@code f(x+5)} is the CCTerm {@code x} plus the offset {@code 5}), as well as array indices, congruence arguments and + * shared-term comparisons. + * + *

A bare {@link CCTerm} is a {@code CCParameter} with offset {@link Rational#ZERO}, so the common offset-free + * case needs no wrapper object; only non-zero offsets allocate an {@link OffsettedCCTerm}. Use {@link #of} to build one + * with this canonicalization. + * + *

The value identity of a parameter is the pair {@code (getRepresentative(), getOffsetToRep())}: two + * parameters denote the same value iff they agree on both. Note this identity changes when the underlying class is + * merged (the representative and its offset shift), so it must not be used as a key in a map that persists across + * merges; see {@link #sameValueAs}. + * + * @author Jochen Hoenicke + */ +public interface CCParameter { + + /** The underlying CCTerm. */ + CCTerm getCCTerm(); + + /** The constant offset added to the CCTerm; {@link Rational#ZERO} for a bare {@link CCTerm}. */ + Rational getOffset(); + + /** The representative of the underlying CCTerm's congruence class. */ + default CCTerm getRepresentative() { + return getCCTerm().getRepresentative(); + } + + /** The offset of this value relative to the representative: {@code value == value(representative) + this}. */ + default Rational getOffsetToRep() { + return getCCTerm().getOffsetToRep().add(getOffset()); + } + + /** Whether this and the other parameter currently denote the same value (same representative and offset). */ + default boolean sameValueAs(final CCParameter other) { + return getRepresentative() == other.getRepresentative() && getOffsetToRep().equals(other.getOffsetToRep()); + } + + /** + * Build a parameter for {@code term + offset}, returning the bare term when the offset is zero so that the common + * case allocates nothing. + */ + static CCParameter of(final CCTerm term, final Rational offset) { + return offset.equals(Rational.ZERO) ? term : new OffsettedCCTerm(term, offset); + } +} diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCTerm.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCTerm.java index a1296df9f..7a9f79756 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCTerm.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCTerm.java @@ -49,7 +49,7 @@ * * @author hoenicke */ -public abstract class CCTerm extends SimpleListable { +public abstract class CCTerm extends SimpleListable implements CCParameter { /** * The destination of the outgoing equality edge. Every term has at most one @@ -216,10 +216,22 @@ public final CCTerm getRepresentative() { /** * @return the offset of this term relative to its representative, i.e. {@code value(this) == value(rep) + offset}. */ + @Override public final Rational getOffsetToRep() { return mOffsetToRep; } + /** A bare CCTerm is a {@link CCParameter} for its own value, with structural offset zero. */ + @Override + public final CCTerm getCCTerm() { + return this; + } + + @Override + public final Rational getOffset() { + return Rational.ZERO; + } + public final boolean isRepresentative() { return mRep == this; } diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/OffsettedCCTerm.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/OffsettedCCTerm.java new file mode 100644 index 000000000..0605af3cc --- /dev/null +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/OffsettedCCTerm.java @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2026 University of Freiburg + * + * This file is part of SMTInterpol. + * + * SMTInterpol is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * SMTInterpol is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with SMTInterpol. If not, see . + */ +package de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure; + +import de.uni_freiburg.informatik.ultimate.logic.Rational; + +/** + * A {@link CCParameter} with a non-zero constant offset, i.e. the value {@code mTerm + mOffset}. Offset-free values are + * represented by a bare {@link CCTerm}, so this class is only ever created with a non-zero offset (use + * {@link CCParameter#of}). + * + *

{@code equals}/{@code hashCode} are structural over {@code (mTerm, mOffset)} (the term object and the offset), + * which is stable. This is not the value identity {@code (representative, offsetToRep)} (which changes on + * merge); for value comparison use {@link CCParameter#sameValueAs}. + * + * @author Jochen Hoenicke + */ +public final class OffsettedCCTerm implements CCParameter { + private final CCTerm mTerm; + private final Rational mOffset; + + OffsettedCCTerm(final CCTerm term, final Rational offset) { + assert !offset.equals(Rational.ZERO) : "use a bare CCTerm for a zero offset"; + mTerm = term; + mOffset = offset; + } + + @Override + public CCTerm getCCTerm() { + return mTerm; + } + + @Override + public Rational getOffset() { + return mOffset; + } + + @Override + public int hashCode() { + return mTerm.hashCode() * 31 + mOffset.hashCode(); + } + + @Override + public boolean equals(final Object other) { + if (this == other) { + return true; + } + if (!(other instanceof OffsettedCCTerm)) { + return false; + } + final OffsettedCCTerm o = (OffsettedCCTerm) other; + return mTerm == o.mTerm && mOffset.equals(o.mOffset); + } + + @Override + public String toString() { + return mTerm + "+" + mOffset; + } +} From 9bc1523fc7780656f61d909e6796ccb22419fb96 Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Sat, 13 Jun 2026 08:50:07 +0200 Subject: [PATCH 12/75] CC offset equalities: make checkCongruence offset-aware via CCParameter The congruence sanity check compared application arguments by representative only; with offsets two applications are congruent only when their arguments have the same value (representative AND offset). Compare via CCParameter.sameValueAs. (getCCTermRep and getAllFuncApps need no change: getCCTermRep is only called from the quantifier theory, where offsets are disabled, and getAllFuncApps keys solely on the function symbol so it is offset-agnostic.) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ultimate/smtinterpol/theory/cclosure/CClosure.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java index a1563ab80..beb05434c 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java @@ -1155,7 +1155,11 @@ private boolean checkCongruence() { final CCTerm[] args2 = a2.mArgs; int i; for (i = 0; i < args1.length; i++) { - if (args1[i].getRepresentative() != args2[i].getRepresentative()) { + // congruent requires the arguments to have the same value, i.e. same representative AND + // same offset (offset-free arguments are compared as plain representatives). + final CCParameter p1 = CCParameter.of(args1[i], a1.getArgOffset(i)); + final CCParameter p2 = CCParameter.of(args2[i], a2.getArgOffset(i)); + if (!p1.sameValueAs(p2)) { break; } } From 90a285b7b855823dde1a267a523dc483467f4c6b Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Sat, 13 Jun 2026 11:04:26 +0200 Subject: [PATCH 13/75] CC offset equalities: add CCParameter.getValueKey for offset-aware map keys getValueKey() builds a canonical value-identity key from the representative, so a plain HashMap keyed on it distinguishes values by (representative, offsetToRep): a bare representative CCTerm for offset zero, an OffsettedCCTerm(rep, offset) otherwise (whose structural equals/hashCode then coincide with value identity). Intended for the array theory's index maps, rebuilt per merge. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../smtinterpol/theory/cclosure/CCParameter.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCParameter.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCParameter.java index 7a08c94be..968b3f55d 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCParameter.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCParameter.java @@ -60,6 +60,17 @@ default boolean sameValueAs(final CCParameter other) { return getRepresentative() == other.getRepresentative() && getOffsetToRep().equals(other.getOffsetToRep()); } + /** + * A canonical key for this value, suitable as a map key within a single snapshot (e.g. while the array theory + * rebuilds its structures). It is built from the representative, so its {@code equals}/{@code hashCode} (the bare + * representative for offset zero, an {@link OffsettedCCTerm} over {@code (representative, offsetToRep)} otherwise) + * coincide with value identity. The key changes when the class is merged, so it must be rebuilt after merges, not + * stored across them. + */ + default CCParameter getValueKey() { + return CCParameter.of(getRepresentative(), getOffsetToRep()); + } + /** * Build a parameter for {@code term + offset}, returning the bare term when the offset is zero so that the common * case allocates nothing. From 7e3fd52c34f4a04fe0e9a3b2af928a14b6fffed4 Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Sat, 13 Jun 2026 11:06:52 +0200 Subject: [PATCH 14/75] Plan: add resume point for the offset-equality work Co-Authored-By: Claude Opus 4.8 (1M context) --- SMTInterpol/doc/offset-equality-plan.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/SMTInterpol/doc/offset-equality-plan.md b/SMTInterpol/doc/offset-equality-plan.md index db44c09ca..b19d9ce12 100644 --- a/SMTInterpol/doc/offset-equality-plan.md +++ b/SMTInterpol/doc/offset-equality-plan.md @@ -346,6 +346,31 @@ offsets. | `CClosure.java` | Update `addEquality()` entry point; update `share()` to propagate offset to LA | | `LinArSolve.java` | Post offset equalities (`k ≠ 0`) to CC, not only zero-offset equalities | +## Resume point + +Branch `offsetequality`. Done and committed: increments 1, 2a, 2b, 3; the +deterministic pair-hash offset; the shared-term polynomial-flattening fix +(`test04`); the quantifier gate (`quanttest001`); the `CCParameter` / +`OffsettedCCTerm` abstraction with `getValueKey()`; and the `checkCongruence` +migration. The full unit suite is green; offsets are active for quantifier-free, +non-proof problems. + +**Next:** the `ArrayTheory` migration (steps under "Array index keys" above and +"Gap-fix order" below). Then LA→CC offset propagation (gap 2), eager negated +equalities (gap 3), proof production (increment 4), and offset-aware e-matching +(to re-enable offsets under quantifiers). + +**Remaining system-benchmark failures** (with proofs/interpolants disabled so +offsets are exercised), all non-soundness: `array/difftest004` (crash), +`nia/divaxiom2` (crash), `bv/test01`, `abv/indexInRange01`, `abv/ext02` +(unsat → unknown). + +**Temporary working-tree change (uncommitted):** +`SMTInterpolTest/src/system/SystemTest.java` has `:proof-check-mode`, +`:proof-level` and `:interpolant-check-mode` commented out so the system +benchmarks exercise offsets (offsets are forced off under proof generation). +Revert this before merging. + ## Status after increment 3 Increments 1, 2a, 2b and 3 are implemented and committed; offsets are live for From cd42ced6150291fa115e1cffdeab6699fb523e1a Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Sun, 14 Jun 2026 17:34:45 +0200 Subject: [PATCH 15/75] CC offset equalities: make ArrayTheory offset-aware via CCParameter Array indices are now values up to a constant. Every index is read as a CCParameter (getIndexParamFromSelect/getIndexParamFromStore = argument 1 plus its structural offset), and all index-keyed maps/sets key on the value identity getValueKey() (representative + offset-to-rep) instead of the bare representative: ArrayNode.mSelects, the makeWeakRepresentative/mergeWith working maps, merge's seenIndices, the Cursor store-index sets, computeWeakeqExt's nodeMapping and the weakeq-ext inverse map, and mArrayModels. Index comparisons use sameValueAs / .equals(valueKey). This is sound because the array theory rebuilds its weak-equivalence structures from scratch on every index merge, so the value keys are a stable snapshot for the duration of a buildWeakEq/computeWeakeqExt pass. Index disequality literals in array lemmas are offset-aware: createIndexEquality (ArrayTheory) and computeIndexDiseq (WeakCongruencePath) build the literal via createEquality(t1, t2, offset, ...) with offset = storeIdx.offset - readIdx.offset, and drop the always-false disjunct when the two indices share a CCTerm but differ by a constant. Store indices for the lemma are collected as full parameters (raw term + structural offset), not value keys, so the generated literal coincides with the asserted atom rather than one on the index representative. WeakCongruencePath navigates by value key; its computePath calls only collect reason atoms along the union-find walk (matched indices always share a representative), so the clause is sound. The net path offset matters only for the proof object, which is disabled while offsets are on; WeakSubPath.mIdx stays a bare CCTerm for that (offset-disabled) annotation. ModelBuilder gains getModelValue(CCParameter) so array models store at the true index value (representative value + offset), not the representative's value. This fixes a wrong model on array/difftest004 (the @diff index offset was dropped) and the array/weakeq-ext crashes (nia/divaxiom2, abv/ext02). System-benchmark failures drop from 6 to 5 with no new failures; the remaining unsat->unknown cases stem from the still-open LA->CC offset propagation gap. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../theory/cclosure/ArrayTheory.java | 182 +++++++++++------- .../theory/cclosure/ModelBuilder.java | 9 + .../theory/cclosure/WeakCongruencePath.java | 60 +++--- 3 files changed, 152 insertions(+), 99 deletions(-) diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ArrayTheory.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ArrayTheory.java index 5492e0275..4ac59447a 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ArrayTheory.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ArrayTheory.java @@ -32,6 +32,7 @@ import java.util.Set; import de.uni_freiburg.informatik.ultimate.logic.FunctionSymbol; +import de.uni_freiburg.informatik.ultimate.logic.Rational; import de.uni_freiburg.informatik.ultimate.logic.SMTLIBConstants; import de.uni_freiburg.informatik.ultimate.logic.Sort; import de.uni_freiburg.informatik.ultimate.logic.Term; @@ -160,13 +161,15 @@ class ArrayNode { * classes and to quickly compute new equivalences. We only have to remember one of the select terms for each * index, since the other are supposed to be equal. * - * The key is always the representative of the index of the corresponding select. + * The key is always the value identity (representative and offset-to-representative) of the index of the + * corresponding select, i.e. {@link CCParameter#getValueKey()} of {@link #getIndexParamFromSelect}. Two selects + * on indices with the same value share a key. * * This is an arbitrary map for weak representative (no primary edge), should be a singleton for weak-i * representatives (primary edge exists, but no secondary edge) and empty for all other nodes (where both edges * exist). */ - Map mSelects; + Map mSelects; CCAppTerm mConstTerm; @@ -205,11 +208,11 @@ public ArrayNode getWeakRepresentative() { * the index i for which the weak-i equivalence class representative should be returned. * @return the representative array node. */ - public ArrayNode getWeakIRepresentative(CCTerm index) { - index = index.getRepresentative(); + public ArrayNode getWeakIRepresentative(CCParameter index) { + index = index.getValueKey(); ArrayNode node = this; while (node.mPrimaryEdge != null) { - if (getIndexFromStore(node.mPrimaryStore).getRepresentative() == index) { + if (getIndexParamFromStore(node.mPrimaryStore).getValueKey().equals(index)) { if (node.mSecondaryEdge == null) { break; } @@ -230,7 +233,7 @@ public ArrayNode getWeakIRepresentative(CCTerm index) { public void makeWeakIRepresentative() { assert mPrimaryEdge != null; assert mSecondaryEdge != null; - final CCTerm index = getIndexFromStore(mPrimaryStore).getRepresentative(); + final CCParameter index = getIndexParamFromStore(mPrimaryStore).getValueKey(); assert getWeakIRepresentative(index) != getWeakRepresentative(); ArrayNode prev = this; ArrayNode next = prev.mSecondaryEdge; @@ -265,12 +268,12 @@ public void makeWeakRepresentative() { } // hash map from store indices we have seen on the primary path to the previous destination/new source of // the primary edge (or in other words, to the new candidate for weak-i equivalence class). - final LinkedHashMap seenStores = new LinkedHashMap<>(); + final LinkedHashMap seenStores = new LinkedHashMap<>(); // for each index, the information about secondary edge that we later need to invert. - final LinkedHashMap todoSecondary = new LinkedHashMap<>(); - final LinkedHashMap todoSecondaryStores = new LinkedHashMap<>(); + final LinkedHashMap todoSecondary = new LinkedHashMap<>(); + final LinkedHashMap todoSecondaryStores = new LinkedHashMap<>(); // the select information for the new root node. - final LinkedHashMap newSelectMap = new LinkedHashMap<>(); + final LinkedHashMap newSelectMap = new LinkedHashMap<>(); ArrayNode node = this; ArrayNode prev = null; CCAppTerm prevStore = null; @@ -281,7 +284,7 @@ public void makeWeakRepresentative() { node.mPrimaryEdge = prev; node.mPrimaryStore = prevStore; - final CCTerm index = getIndexFromStore(nextStore).getRepresentative(); + final CCParameter index = getIndexParamFromStore(nextStore).getValueKey(); final ArrayNode old = seenStores.put(index, next); if (old == node) { // the node is in the middle of two consecutive stores on the same @@ -300,7 +303,7 @@ public void makeWeakRepresentative() { // insert the secondary edge of node into old node. The reasoning is that // the old node is connected using only primary edges on different indices, so it has // the same weak-i representative as node. - assert (getIndexFromStore(old.mPrimaryStore).getRepresentative() == index); + assert (getIndexParamFromStore(old.mPrimaryStore).getValueKey().equals(index)); assert (old.mSecondaryEdge == null); old.mSecondaryEdge = node.mSecondaryEdge; old.mSecondaryStore = node.mSecondaryStore; @@ -328,19 +331,19 @@ public void makeWeakRepresentative() { node.mPrimaryStore = prevStore; mConstTerm = node.mConstTerm; node.mConstTerm = null; - final Map rootSelects = node.mSelects; + final Map rootSelects = node.mSelects; node.mSelects = Collections.emptyMap(); - for (final Entry entry : seenStores.entrySet()) { + for (final Entry entry : seenStores.entrySet()) { // The seen stores get the new representatives of their weak-i equivalence groups - final CCTerm index = entry.getKey(); + final CCParameter index = entry.getKey(); final CCAppTerm select = rootSelects.remove(index); if (select != null) { entry.getValue().mSelects = Collections.singletonMap(index, select); } } // Now invert the secondary edges - for (final Entry entry : todoSecondary.entrySet()) { - final CCTerm index = entry.getKey(); + for (final Entry entry : todoSecondary.entrySet()) { + final CCParameter index = entry.getKey(); ArrayNode dest = entry.getValue(); dest = dest.findSecondaryNode(index); if (dest.mSecondaryEdge != null) { @@ -373,7 +376,7 @@ public void mergeWith(final ArrayNode storeNode, final CCAppTerm store, final Co // merge the consts; // this map collects all selects in the other class, that may need to be merge with the const. - final LinkedHashMap mergeConstSelects = new LinkedHashMap<>(); + final LinkedHashMap mergeConstSelects = new LinkedHashMap<>(); if (mConstTerm != null) { if (storeNode.mConstTerm == null) { storeNode.mConstTerm = mConstTerm; @@ -389,15 +392,15 @@ public void mergeWith(final ArrayNode storeNode, final CCAppTerm store, final Co } else if (storeNode.mConstTerm != null) { mergeConstSelects.putAll(mSelects); } - mergeConstSelects.remove(getIndexFromStore(store).getRepresentative()); + mergeConstSelects.remove(getIndexParamFromStore(store).getValueKey()); // merge the selects; - Map newSelects = Collections.emptyMap(); - for (final Entry entry : mSelects.entrySet()) { - final CCTerm index = entry.getKey(); + Map newSelects = Collections.emptyMap(); + for (final Entry entry : mSelects.entrySet()) { + final CCParameter index = entry.getKey(); final CCAppTerm select = entry.getValue(); assert select != null; - if (index == getIndexFromStore(store).getRepresentative()) { + if (index.equals(getIndexParamFromStore(store).getValueKey())) { newSelects = Collections.singletonMap(index, select); } else { final CCAppTerm otherSelect = storeNode.mSelects.get(index); @@ -417,8 +420,8 @@ public void mergeWith(final ArrayNode storeNode, final CCAppTerm store, final Co if (storeNode.mConstTerm != null) { final CCTerm const1 = getValueFromConst(storeNode.mConstTerm); final ArrayNode constNode = mCongRoots.get(storeNode.mConstTerm.getRepresentative()); - for (final Entry entry : mergeConstSelects.entrySet()) { - final CCTerm index = entry.getKey(); + for (final Entry entry : mergeConstSelects.entrySet()) { + final CCParameter index = entry.getKey(); final CCAppTerm select = entry.getValue(); // check if selected array is weakly equal to the const array if (constNode.getWeakIRepresentative(index) == storeNode) { @@ -447,13 +450,13 @@ public void mergeWith(final ArrayNode storeNode, final CCAppTerm store, final Co public void mergeSecondary(final ArrayNode storeNode, final CCAppTerm store, final Collection propEqualities) { assert storeNode.mPrimaryEdge == null; assert mPrimaryEdge != null; - assert getIndexFromStore(mPrimaryStore).getRepresentative() != getIndexFromStore(store).getRepresentative(); + assert !getIndexParamFromStore(mPrimaryStore).sameValueAs(getIndexParamFromStore(store)); if (mSecondaryEdge != null) { makeWeakIRepresentative(); } mSecondaryEdge = storeNode; mSecondaryStore = store; - final CCTerm storeIndex = getIndexFromStore(mPrimaryStore).getRepresentative(); + final CCParameter storeIndex = getIndexParamFromStore(mPrimaryStore).getValueKey(); if (!mSelects.isEmpty()) { final CCAppTerm select = mSelects.get(storeIndex); assert (select != null); @@ -489,12 +492,12 @@ public void mergeSecondary(final ArrayNode storeNode, final CCAppTerm store, fin * the index i. * @return the number of secondary edges from this to the weak-i root. */ - public int countSecondaryEdges(final CCTerm index) { - assert index.isRepresentative(); + public int countSecondaryEdges(final CCParameter index) { + assert index.getValueKey().equals(index); int count = 0; ArrayNode node = this; while (node.mPrimaryEdge != null) { - if (getIndexFromStore(node.mPrimaryStore).getRepresentative() == index) { + if (getIndexParamFromStore(node.mPrimaryStore).getValueKey().equals(index)) { if (node.mSecondaryEdge == null) { break; } @@ -510,10 +513,10 @@ public int countSecondaryEdges(final CCTerm index) { /** * Find the next node on the primary chain where the primary edge uses the given index. */ - public ArrayNode findSecondaryNode(final CCTerm index) { - assert index.isRepresentative(); + public ArrayNode findSecondaryNode(final CCParameter index) { + assert index.getValueKey().equals(index); ArrayNode node = this; - while (node.mPrimaryEdge != null && getIndexFromStore(node.mPrimaryStore).getRepresentative() != index) { + while (node.mPrimaryEdge != null && !getIndexParamFromStore(node.mPrimaryStore).getValueKey().equals(index)) { node = node.mPrimaryEdge; } return node; @@ -617,7 +620,7 @@ public String toString() { * The value is the select cc term representative. If there is no select term for that index, the value is the * weak-i representative node. At index null, the map stores the weak representative node. */ - Map> mArrayModels = null; + Map> mArrayModels = null; // =============== STATISTICS =============== private int mNumInstsSelect = 0; @@ -798,11 +801,11 @@ public void dumpModel(final LogProxy logger) { if (!logger.isInfoEnabled()) { return; } - for (final Entry> entry : mArrayModels.entrySet()) { + for (final Entry> entry : mArrayModels.entrySet()) { final StringBuilder sb = new StringBuilder(); sb.append(entry.getKey().mTerm).append(" = store["); sb.append(entry.getKey().getWeakRepresentative().mTerm); - for (final Entry storeEntry : entry.getValue().entrySet()) { + for (final Entry storeEntry : entry.getValue().entrySet()) { if (storeEntry.getKey() == null) { continue; } @@ -928,8 +931,8 @@ public void fillInModel(final ModelBuilder builder, final List ccArrayTe nodeValue = model.extendFresh(arraySort); } // change all indices to the right select value - for (final Entry indexValuePairs : node.mSelects.entrySet()) { - final CCTerm index = indexValuePairs.getKey(); + for (final Entry indexValuePairs : node.mSelects.entrySet()) { + final CCParameter index = indexValuePairs.getKey(); final CCTerm value = indexValuePairs.getValue().getRepresentative(); nodeValue = t.term("store", nodeValue, builder.getModelValue(index), builder.getModelValue(value)); @@ -943,7 +946,7 @@ public void fillInModel(final ModelBuilder builder, final List ccArrayTe continue; } assert other.mSelects.size() == 1; - final CCTerm index = other.mSelects.keySet().iterator().next(); + final CCParameter index = other.mSelects.keySet().iterator().next(); if (!node.mSelects.containsKey(index)) { // we have another array in the weak-equivalence class that may by accident // store the @@ -979,8 +982,8 @@ public void fillInModel(final ModelBuilder builder, final List ccArrayTe } else { secondParentTerm = null; } - final CCTerm ccIndex = getIndexFromStore(node.mPrimaryStore).getRepresentative(); - final CCTerm ccValue = node.mSelects.get(ccIndex); + final CCParameter ccIndex = getIndexParamFromStore(node.mPrimaryStore).getValueKey(); + final CCAppTerm ccValue = node.mSelects.get(ccIndex); final Term index = builder.getModelValue(ccIndex); final Term value = secondParentTerm != null ? arraySortInterpretation.getSelect(secondParentTerm, index) : ccValue == null ? model.extendFresh(valueSort) : builder.getModelValue(ccValue); @@ -1045,6 +1048,15 @@ public static CCTerm getIndexFromSelect(final CCAppTerm select) { return select.getArgument(1); } + /** + * The value of the index of a select term as a {@link CCParameter}, i.e. argument 1 together with its structural + * offset. With offset equalities the index of {@code (select a (+ i 5))} is the CCTerm {@code i} plus offset 5. + */ + public static CCParameter getIndexParamFromSelect(final CCAppTerm select) { + assert isSelectTerm(select); + return CCParameter.of(select.getArgument(1), select.getArgOffset(1)); + } + public static CCTerm getArrayFromStore(final CCAppTerm store) { assert isStoreTerm(store); return store.getArgument(0); @@ -1055,6 +1067,15 @@ public static CCTerm getIndexFromStore(final CCAppTerm store) { return store.getArgument(1); } + /** + * The value of the index of a store term as a {@link CCParameter}, i.e. argument 1 together with its structural + * offset (see {@link #getIndexParamFromSelect}). + */ + public static CCParameter getIndexParamFromStore(final CCAppTerm store) { + assert isStoreTerm(store); + return CCParameter.of(store.getArgument(1), store.getArgOffset(1)); + } + public static CCTerm getValueFromStore(final CCAppTerm store) { assert isStoreTerm(store); return store.getArgument(2); @@ -1124,7 +1145,7 @@ private void merge(final CCAppTerm store) { mNumMerges++; if (mLogger.isDebugEnabled()) { mLogger.debug( - "Merge: [" + getIndexFromStore(store).getRepresentative() + "] " + arrayNode + " and " + storeNode); + "Merge: [" + getIndexParamFromStore(store).getValueKey() + "] " + arrayNode + " and " + storeNode); } arrayNode.makeWeakRepresentative(); @@ -1141,12 +1162,12 @@ private void merge(final CCAppTerm store) { // Otherwise arrayNode would have stayed its representative. // We need to insert appropriate select edges. - final HashSet seenIndices = new HashSet<>(); - final CCTerm storeIndex = getIndexFromStore(store).getRepresentative(); + final HashSet seenIndices = new HashSet<>(); + final CCParameter storeIndex = getIndexParamFromStore(store).getValueKey(); seenIndices.add(storeIndex); ArrayNode node = arrayNode; while (node.mPrimaryEdge != null) { - final CCTerm index = getIndexFromStore(node.mPrimaryStore).getRepresentative(); + final CCParameter index = getIndexParamFromStore(node.mPrimaryStore).getValueKey(); /* * add the index to the seen indices and merge weak-i equivalence classes if index was not seen before * and they are not already the same. @@ -1183,25 +1204,25 @@ public Cursor(final CCTerm term, final ArrayNode node) { * @param storeIndices * a map where the store indices are collected to. */ - public void collectOverPrimaries(ArrayNode destNode, final Set storeIndices) { + public void collectOverPrimaries(ArrayNode destNode, final Set storeIndices) { // compute the steps to the common root int steps1 = mNode.countPrimaryEdges(); int steps2 = destNode.countPrimaryEdges(); // if one needs more step than the other, follow the primary edges until the steps equal while (steps1 > steps2) { - storeIndices.add(getIndexFromStore(mNode.mPrimaryStore)); + storeIndices.add(getIndexParamFromStore(mNode.mPrimaryStore)); mNode = mNode.mPrimaryEdge; steps1--; } while (steps2 > steps1) { - storeIndices.add(getIndexFromStore(destNode.mPrimaryStore)); + storeIndices.add(getIndexParamFromStore(destNode.mPrimaryStore)); destNode = destNode.mPrimaryEdge; steps2--; } // now follow the primary edge from both nodes until the common ancestor is found while (mNode != destNode) { - storeIndices.add(getIndexFromStore(mNode.mPrimaryStore)); - storeIndices.add(getIndexFromStore(destNode.mPrimaryStore)); + storeIndices.add(getIndexParamFromStore(mNode.mPrimaryStore)); + storeIndices.add(getIndexParamFromStore(destNode.mPrimaryStore)); mNode = mNode.mPrimaryEdge; destNode = destNode.mPrimaryEdge; } @@ -1217,7 +1238,7 @@ public void collectOverPrimaries(ArrayNode destNode, final Set storeIndi * @param storeIndices * All store indices are added to this map as side-effect. */ - private void collectOneSecondary(final CCTerm index, final Set storeIndices) { + private void collectOneSecondary(final CCParameter index, final Set storeIndices) { final ArrayNode selectNode = mNode.findSecondaryNode(index); final CCAppTerm store = selectNode.mSecondaryStore; final CCTerm array = getArrayFromStore(store); @@ -1231,7 +1252,7 @@ private void collectOneSecondary(final CCTerm index, final Set storeIndi collectOverPrimaries(storeNode, storeIndices); mNode = arrayNode; } - storeIndices.add(getIndexFromStore(store)); + storeIndices.add(getIndexParamFromStore(store)); } /** @@ -1244,7 +1265,7 @@ private void collectOneSecondary(final CCTerm index, final Set storeIndi * @param storeIndices * initially an empty map. All store indices are added to this map as side-effect. */ - public void collect(final CCTerm index, final Cursor dest, final Set storeIndices) { + public void collect(final CCParameter index, final Cursor dest, final Set storeIndices) { int steps1 = mNode.countSecondaryEdges(index); int steps2 = dest.mNode.countSecondaryEdges(index); while (steps1 > steps2) { @@ -1275,15 +1296,31 @@ public void collect(final CCTerm index, final Cursor dest, final Set sto * @param storeIndices * initially an empty map. All store indices are added to this map as side-effect. */ - private void computeStoreIndices(final CCTerm index, final CCTerm array1, final CCTerm array2, final Set storeIndices) { + private void computeStoreIndices(final CCParameter index, final CCTerm array1, final CCTerm array2, final Set storeIndices) { final ArrayNode node1 = mCongRoots.get(array1.getRepresentative()); final ArrayNode node2 = mCongRoots.get(array2.getRepresentative()); final Cursor cursor1 = new Cursor(array1, node1); final Cursor cursor2 = new Cursor(array2, node2); - assert index == index.getRepresentative(); + assert index.getValueKey().equals(index); cursor1.collect(index, cursor2, storeIndices); } + /** + * Create the equality literal expressing that the two index values are equal, i.e. + * {@code value(index) == value(storeIndex)}. With offsets this is the offset equality + * {@code index.ccTerm == storeIndex.ccTerm + (storeIndex.offset - index.offset)}. If both share the same underlying + * CCTerm but differ by a constant the equality is unsatisfiable, so no literal is created (returns {@code null}); the + * disjunct it would contribute is always false and can be dropped from the array lemma. + */ + private CCEquality createIndexEquality(final CCParameter index, final CCParameter storeIndex) { + if (index.getCCTerm() == storeIndex.getCCTerm()) { + // same term, different constant offset -> the index values can never be equal. + return null; + } + final Rational offset = storeIndex.getOffset().sub(index.getOffset()); + return getCClosure().createEquality(index.getCCTerm(), storeIndex.getCCTerm(), offset, false); + } + private void createPropagatedClauses() { for (final ArrayLemma lemma : mPropClauses) { final CCTerm lhs = lemma.getEquality().getFirst(); @@ -1293,15 +1330,15 @@ private void createPropagatedClauses() { case READ_OVER_WEAKEQ: { final CCAppTerm select1 = (CCAppTerm) lhs; final CCAppTerm select2 = (CCAppTerm) rhs; - final CCTerm index1 = getIndexFromSelect(select1); + final CCParameter index1 = getIndexParamFromSelect(select1); final CCTerm array1 = getArrayFromSelect(select1); final CCTerm array2 = getArrayFromSelect(select2); - final Set storeIndices = new LinkedHashSet<>(); - computeStoreIndices(index1.getRepresentative(), array1, array2, storeIndices); + final Set storeIndices = new LinkedHashSet<>(); + computeStoreIndices(index1.getValueKey(), array1, array2, storeIndices); final Set propClause = new LinkedHashSet<>(); - for (final CCTerm idx : storeIndices) { - assert index1.getRepresentative() != idx.getRepresentative(); - final CCEquality lit = getCClosure().createEquality(index1, idx, false); + for (final CCParameter idx : storeIndices) { + assert !index1.sameValueAs(idx); + final CCEquality lit = createIndexEquality(index1, idx); if (lit != null) { assert lit.getDecideStatus() != lit; if (lit.getDecideStatus() == null) { @@ -1331,15 +1368,15 @@ private void createPropagatedClauses() { } case READ_CONST_WEAKEQ: { final CCAppTerm select1 = (CCAppTerm) lhs; - final CCTerm index1 = getIndexFromSelect(select1); + final CCParameter index1 = getIndexParamFromSelect(select1); final CCTerm array1 = getArrayFromSelect(select1); final CCTerm array2 = findConst(rhs); - final Set storeIndices = new LinkedHashSet<>(); - computeStoreIndices(index1.getRepresentative(), array1, array2, storeIndices); + final Set storeIndices = new LinkedHashSet<>(); + computeStoreIndices(index1.getValueKey(), array1, array2, storeIndices); final Set propClause = new LinkedHashSet<>(); - for (final CCTerm idx : storeIndices) { - assert index1.getRepresentative() != idx.getRepresentative(); - final CCEquality lit = getCClosure().createEquality(index1, idx, false); + for (final CCParameter idx : storeIndices) { + assert !index1.sameValueAs(idx); + final CCEquality lit = createIndexEquality(index1, idx); if (lit != null) { assert lit.getDecideStatus() != lit; if (lit.getDecideStatus() == null) { @@ -1373,9 +1410,8 @@ private void collectSelects(Sort arraySort) { for (final CCTerm select : mCClosure.getAllFuncApps(selectFsym)) { final CCAppTerm selectApp = (CCAppTerm) select; final CCTerm array = getArrayFromSelect(selectApp); - final CCTerm index = getIndexFromSelect(selectApp); final ArrayNode node = mCongRoots.get(array.getRepresentative()); - node.mSelects.put(index.getRepresentative(), selectApp); + node.mSelects.put(getIndexParamFromSelect(selectApp).getValueKey(), selectApp); } } @@ -1439,7 +1475,7 @@ private boolean computeWeakeqExt() { */ mArrayModels = new LinkedHashMap<>(); final HashMap defaultValue = new HashMap<>(); - final HashMap, ArrayNode>> inverse = new HashMap<>(); + final HashMap, ArrayNode>> inverse = new HashMap<>(); final HashSet> propEqualities = new LinkedHashSet<>(); final ArrayDeque todoQueue = new ArrayDeque<>(mCongRoots.values()); while (!todoQueue.isEmpty()) { @@ -1454,7 +1490,7 @@ private boolean computeWeakeqExt() { } final Sort arraySort = node.mTerm.getFlatTerm().getSort(); todoQueue.removeFirst(); - final HashMap nodeMapping = new LinkedHashMap<>(); + final HashMap nodeMapping = new LinkedHashMap<>(); final ArrayNode weakRep = node.getWeakRepresentative(); if (node == weakRep) { CCTerm constRep = null; @@ -1498,14 +1534,14 @@ private boolean computeWeakeqExt() { } else { nodeMapping.put(null, node); } - for (final Entry e : node.mSelects.entrySet()) { + for (final Entry e : node.mSelects.entrySet()) { final CCTerm value = e.getValue().getRepresentative(); if (value != constRep) { nodeMapping.put(e.getKey(), value); } } } else { - final CCTerm storeIndex = getIndexFromStore(node.mPrimaryStore).getRepresentative(); + final CCParameter storeIndex = getIndexParamFromStore(node.mPrimaryStore).getValueKey(); nodeMapping.putAll(mArrayModels.get(node.mPrimaryEdge)); final Object constRep = nodeMapping.get(null); nodeMapping.remove(storeIndex); diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ModelBuilder.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ModelBuilder.java index 6a14850ef..6f1fd6cc1 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ModelBuilder.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ModelBuilder.java @@ -155,6 +155,15 @@ public Term getModelValue(final CCTerm term) { return mModelValues.get(term.getRepresentative()); } + /** + * The model value of a {@link CCParameter} value {@code term + offset}, i.e. the representative's value shifted by + * the parameter's offset to the representative. For an offset-free parameter (a bare CCTerm) this coincides with + * {@link #getModelValue(CCTerm)}. + */ + public Term getModelValue(final CCParameter param) { + return getModelValueWithOffset(param.getCCTerm(), param.getOffset()); + } + /** * The model value of a (numeric) term plus a constant. The representative carries the model value of the offset-free * class; an individual member's value adds its offset to the representative, and {@code extraOffset} adds a further diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/WeakCongruencePath.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/WeakCongruencePath.java index 350214bd0..8056f7fe9 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/WeakCongruencePath.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/WeakCongruencePath.java @@ -21,6 +21,7 @@ import java.util.HashSet; import java.util.LinkedHashSet; +import de.uni_freiburg.informatik.ultimate.logic.Rational; import de.uni_freiburg.informatik.ultimate.smtinterpol.dpll.Clause; import de.uni_freiburg.informatik.ultimate.smtinterpol.dpll.Literal; import de.uni_freiburg.informatik.ultimate.smtinterpol.proof.LeafNode; @@ -57,12 +58,12 @@ public void update(final CCTerm term, final ArrayNode arrayNode) { static class WeakSubPath extends SubPath { private final CCTerm mIdx; - private final CCTerm mIdxRep; + private final CCParameter mIdxRep; - public WeakSubPath(final CCTerm idx, final CCTerm start, final boolean produceProofs) { + public WeakSubPath(final CCParameter idx, final CCTerm start, final boolean produceProofs) { super(start, produceProofs); - mIdx = idx; - mIdxRep = idx.getRepresentative(); + mIdx = idx.getCCTerm(); + mIdxRep = idx.getValueKey(); } @Override @@ -85,11 +86,11 @@ public WeakCongruencePath(final ArrayTheory arrayTheory) { public Clause computeSelectOverWeakEQ(final CCAppTerm select1, final CCAppTerm select2, final boolean produceProofs) { - final CCTerm i1 = ArrayTheory.getIndexFromSelect(select1); - final CCTerm i2 = ArrayTheory.getIndexFromSelect(select2); + final CCParameter i1 = ArrayTheory.getIndexParamFromSelect(select1); + final CCParameter i2 = ArrayTheory.getIndexParamFromSelect(select2); final CCTerm a = ArrayTheory.getArrayFromSelect(select1); final CCTerm b = ArrayTheory.getArrayFromSelect(select2); - computePath(i1, i2); + computePath(i1.getCCTerm(), i2.getCCTerm()); final WeakSubPath weakPath = computeWeakPath(a, b, i1, produceProofs); mAllPaths.addFirst(weakPath); @@ -100,7 +101,7 @@ public Clause computeSelectOverWeakEQ(final CCAppTerm select1, final CCAppTerm s public Clause computeSelectConstOverWeakEQ(final CCAppTerm select1, final CCAppTerm const2, final boolean produceProofs) { final CCTerm value2 = ArrayTheory.getValueFromConst(const2); - final CCTerm i1 = ArrayTheory.getIndexFromSelect(select1); + final CCParameter i1 = ArrayTheory.getIndexParamFromSelect(select1); final CCTerm a = ArrayTheory.getArrayFromSelect(select1); final WeakSubPath weakPath = computeWeakPath(a, const2, i1, produceProofs); mAllPaths.addFirst(weakPath); @@ -111,7 +112,7 @@ public Clause computeConstOverWeakEQ(final CCAppTerm const1, final CCAppTerm con final CCTerm value1 = ArrayTheory.getValueFromConst(const1); final CCTerm value2 = ArrayTheory.getValueFromConst(const2); - final HashSet storeIndices = new HashSet<>(); + final HashSet storeIndices = new HashSet<>(); final Cursor start = new Cursor(const1, mArrayTheory.mCongRoots.get(const1.getRepresentative())); final Cursor dest = new Cursor(const2, mArrayTheory.mCongRoots.get(const2.getRepresentative())); final SubPath path = collectPathPrimary(start, dest, storeIndices, produceProofs); @@ -134,11 +135,11 @@ public Clause computeConstOverWeakEQ(final CCAppTerm const1, final CCAppTerm con */ public Clause computeWeakeqExt(final CCTerm a, final CCTerm b, final boolean produceProofs) { assert a != b; - final LinkedHashSet storeIndices = new LinkedHashSet<>(); + final LinkedHashSet storeIndices = new LinkedHashSet<>(); final Cursor start = new Cursor(a, mArrayTheory.mCongRoots.get(a.getRepresentative())); final Cursor dest = new Cursor(b, mArrayTheory.mCongRoots.get(b.getRepresentative())); final SubPath path = collectPathPrimary(start, dest, storeIndices, produceProofs); - for (final CCTerm idx : storeIndices) { + for (final CCParameter idx : storeIndices) { final WeakSubPath weakpath = computeWeakCongruencePath(a, b, idx, produceProofs); mAllPaths.addFirst(weakpath); } @@ -160,10 +161,10 @@ public Clause computeWeakeqExt(final CCTerm a, final CCTerm b, final boolean pro * Proof production enabled? * @return Path information for this weak path. */ - private WeakSubPath computeWeakPath(final CCTerm array1, final CCTerm array2, final CCTerm index, final boolean produceProofs) { + private WeakSubPath computeWeakPath(final CCTerm array1, final CCTerm array2, final CCParameter index, final boolean produceProofs) { - final LinkedHashSet storeIndices = new LinkedHashSet<>(); - final CCTerm indexRep = index.getRepresentative(); + final LinkedHashSet storeIndices = new LinkedHashSet<>(); + final CCParameter indexRep = index.getValueKey(); final Cursor cursor1 = new Cursor(array1, mArrayTheory.mCongRoots.get(array1.getRepresentative())); final Cursor cursor2 = new Cursor(array2, mArrayTheory.mCongRoots.get(array2.getRepresentative())); final WeakSubPath sub1 = new WeakSubPath(index, array1, produceProofs); @@ -186,7 +187,7 @@ private WeakSubPath computeWeakPath(final CCTerm array1, final CCTerm array2, fi } sub1.addSubPath(collectPathPrimary(cursor1, cursor2, storeIndices, produceProofs)); sub1.addSubPath(sub2); - for (final CCTerm storeIdx : storeIndices) { + for (final CCParameter storeIdx : storeIndices) { computeIndexDiseq(index, storeIdx); } return sub1; @@ -206,9 +207,9 @@ private WeakSubPath computeWeakPath(final CCTerm array1, final CCTerm array2, fi * Proof production enabled? * @return Path information for this weak path. Other paths are added as side-effect to mAllPaths. */ - private WeakSubPath computeWeakCongruencePath(final CCTerm array1, final CCTerm array2, final CCTerm index, final boolean produceProofs) { + private WeakSubPath computeWeakCongruencePath(final CCTerm array1, final CCTerm array2, final CCParameter index, final boolean produceProofs) { - final CCTerm indexRep = index.getRepresentative(); + final CCParameter indexRep = index.getValueKey(); final ArrayNode node1 = mArrayTheory.mCongRoots.get(array1.getRepresentative()); final ArrayNode node2 = mArrayTheory.mCongRoots.get(array2.getRepresentative()); final ArrayNode rep1 = node1.getWeakIRepresentative(indexRep); @@ -227,7 +228,7 @@ private WeakSubPath computeWeakCongruencePath(final CCTerm array1, final CCTerm // get select for left-hand-side final CCAppTerm select = rep1.mSelects.get(indexRep); final CCTerm selectArray = ArrayTheory.getArrayFromSelect(select); - computePath(index, ArrayTheory.getIndexFromSelect(select)); + computePath(index.getCCTerm(), ArrayTheory.getIndexFromSelect(select)); path = computeWeakPath(array1, selectArray, index, produceProofs); select1 = select; } @@ -241,7 +242,7 @@ private WeakSubPath computeWeakCongruencePath(final CCTerm array1, final CCTerm // get select for right-hand-side final CCAppTerm select = rep2.mSelects.get(indexRep); final CCTerm selectArray = ArrayTheory.getArrayFromSelect(select); - computePath(index, ArrayTheory.getIndexFromSelect(select)); + computePath(index.getCCTerm(), ArrayTheory.getIndexFromSelect(select)); path.addEntry(selectArray, null); path.addSubPath(computeWeakPath(selectArray, array2, index, produceProofs)); select2 = select; @@ -255,7 +256,7 @@ private WeakSubPath computeWeakCongruencePath(final CCTerm array1, final CCTerm return path; } - public void collectPathOnePrimary(final Cursor cursor, final SubPath path, final HashSet storeIndices) { + public void collectPathOnePrimary(final Cursor cursor, final SubPath path, final HashSet storeIndices) { final ArrayNode node = cursor.mArrayNode; final CCAppTerm store = node.mPrimaryStore; CCTerm t1 = store; @@ -274,7 +275,7 @@ public void collectPathOnePrimary(final Cursor cursor, final SubPath path, final } path.addEntry(t2, null); cursor.update(t2, node.mPrimaryEdge); - storeIndices.add(ArrayTheory.getIndexFromStore(store)); + storeIndices.add(ArrayTheory.getIndexParamFromStore(store)); } /** @@ -286,7 +287,7 @@ public void collectPathOnePrimary(final Cursor cursor, final SubPath path, final * @param produceProofs true, if path must be recorded. * @returns the path connecting the arrays. */ - private SubPath collectPathPrimary(final Cursor start, final Cursor dest, final HashSet storeIndices, + private SubPath collectPathPrimary(final Cursor start, final Cursor dest, final HashSet storeIndices, final boolean produceProofs) { final SubPath path1 = new SubPath(start.mTerm, produceProofs); final SubPath path2 = new SubPath(dest.mTerm, produceProofs); @@ -326,7 +327,7 @@ private SubPath collectPathPrimary(final Cursor start, final Cursor dest, final * @param produceProofs * true, if path must be recorded. */ - private void collectPathOneSecondary(final Cursor cursor, final WeakSubPath path, final HashSet storeIndices, + private void collectPathOneSecondary(final Cursor cursor, final WeakSubPath path, final HashSet storeIndices, final boolean produceProofs) { final ArrayNode selector = cursor.mArrayNode.findSecondaryNode(path.mIdxRep); final CCAppTerm store = selector.mSecondaryStore; @@ -348,7 +349,7 @@ private void collectPathOneSecondary(final Cursor cursor, final WeakSubPath path storeIndices, produceProofs)); path.addEntry(t2, null); cursor.update(t2, n2); - storeIndices.add(ArrayTheory.getIndexFromStore(store)); + storeIndices.add(ArrayTheory.getIndexParamFromStore(store)); } /** @@ -356,8 +357,15 @@ private void collectPathOneSecondary(final Cursor cursor, final WeakSubPath path * @param idx The index for a weak equality path. * @param idxFromStore The index of an edge in the weakeq graph. */ - private void computeIndexDiseq(final CCTerm idx, final CCTerm idxFromStore) { - final CCEquality eqlit = mArrayTheory.getCClosure().createEquality(idx, idxFromStore, false); + private void computeIndexDiseq(final CCParameter idx, final CCParameter idxFromStore) { + if (idx.getCCTerm() == idxFromStore.getCCTerm()) { + // the two index values share the same CCTerm but differ by a constant offset, so they can never be equal; + // the disequality holds unconditionally and contributes no literal to the lemma. + return; + } + final Rational offset = idxFromStore.getOffset().sub(idx.getOffset()); + final CCEquality eqlit = mArrayTheory.getCClosure().createEquality(idx.getCCTerm(), idxFromStore.getCCTerm(), + offset, false); if (eqlit != null) { mAllLiterals.add(eqlit.negate()); } From 5265789c815010d8b9aaadfebb8299f4ace736ee Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Sun, 14 Jun 2026 17:34:58 +0200 Subject: [PATCH 16/75] Plan: ArrayTheory offset migration done; LA->CC propagation is next Record completion of gap 1 (the array index migration, incl. WeakCongruencePath and ModelBuilder.getModelValue) and update the resume point and remaining-gap list. System failures are now bv/test01 and abv/indexInRange01 (both unsat->sat, pre-existing, from the LA->CC offset propagation gap), which is the next target. Co-Authored-By: Claude Opus 4.8 (1M context) --- SMTInterpol/doc/offset-equality-plan.md | 48 ++++++++++++++++++------- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/SMTInterpol/doc/offset-equality-plan.md b/SMTInterpol/doc/offset-equality-plan.md index b19d9ce12..a8b497a88 100644 --- a/SMTInterpol/doc/offset-equality-plan.md +++ b/SMTInterpol/doc/offset-equality-plan.md @@ -352,18 +352,40 @@ Branch `offsetequality`. Done and committed: increments 1, 2a, 2b, 3; the deterministic pair-hash offset; the shared-term polynomial-flattening fix (`test04`); the quantifier gate (`quanttest001`); the `CCParameter` / `OffsettedCCTerm` abstraction with `getValueKey()`; and the `checkCongruence` -migration. The full unit suite is green; offsets are active for quantifier-free, -non-proof problems. - -**Next:** the `ArrayTheory` migration (steps under "Array index keys" above and -"Gap-fix order" below). Then LA→CC offset propagation (gap 2), eager negated -equalities (gap 3), proof production (increment 4), and offset-aware e-matching -(to re-enable offsets under quantifiers). +migration. **Also done (this session, gap 1, the array migration):** +`ArrayTheory` and `WeakCongruencePath` are now offset-aware — every array index +is read as a `CCParameter` (`getIndexParamFromSelect`/`getIndexParamFromStore`) +and all index-keyed maps/sets (`mSelects`, `seenStores`, `nodeMapping`, +`storeIndices`, `seenIndices`, `mArrayModels`, the weakeq-ext `inverse` map) key +on the value identity `getValueKey()` instead of the bare representative; index +comparisons use `sameValueAs`/`.equals(valueKey)`; index disequality literals in +array lemmas are offset-aware (`createIndexEquality` / `computeIndexDiseq` via +`createEquality(t1, t2, offset, …)`, dropping the always-false disjunct when the +two indices share a CCTerm but differ by a constant); and `ModelBuilder` gained a +`getModelValue(CCParameter)` overload so array models store at the true index +value (rep value + offset), not the representative's value. This relies on the +array theory rebuilding from scratch each `buildWeakEq`/`computeWeakeqExt` pass, +so value keys are a stable snapshot. `WeakCongruencePath` navigates by value key; +its `computePath` calls only collect reason atoms (sound clause) — the net offset +matters only for the proof object, which is disabled while offsets are on. +`WeakSubPath.mIdx` stays a bare CCTerm for the (offset-disabled) proof annotation. + +Results: `array/difftest004` now SAT with a **correct** model (previously a wrong +model — the index offset was dropped); `nia/divaxiom2` and `abv/ext02` no longer +crash (`ext02` correctly UNSAT). All `array/` benchmarks pass; no crashes in +`abv/`,`bv/`. + +**Next:** LA→CC offset propagation (gap 2), eager negated equalities (gap 3), +proof production (increment 4), offset-aware e-matching (re-enable offsets under +quantifiers), and the still-deferred `DataTypeTheory` offset-aware argument +handling (only matters for datatypes with numeric fields; no failing benchmark +yet). **Remaining system-benchmark failures** (with proofs/interpolants disabled so -offsets are exercised), all non-soundness: `array/difftest004` (crash), -`nia/divaxiom2` (crash), `bv/test01`, `abv/indexInRange01`, `abv/ext02` -(unsat → unknown). +offsets are exercised): `bv/test01`, `abv/indexInRange01` (both unsat → **sat**, +*unsound*, but pre-existing before the array migration — they stem from gap 2, +LA knowing e.g. `k mod 256 = 1` but never telling CC). These are the motivation +for gap 2 and should be the next target. **Temporary working-tree change (uncommitted):** `SMTInterpolTest/src/system/SystemTest.java` has `:proof-check-mode`, @@ -458,9 +480,11 @@ value identity `(rep, offsetToRep)` the array theory needs. ## Gap-fix order 1. `CCParameter` + `OffsettedCCTerm`; wire the `CClosure` consumers - (`getCCTermRep`, `getAllFuncApps`, `checkCongruence`) and `DataTypeTheory`. + (`getCCTermRep`, `getAllFuncApps`, `checkCongruence`). **(done)** — + `DataTypeTheory` still deferred (only matters for numeric datatype fields). 2. `ArrayTheory` offset-aware index handling (gap 1, the substantial one). -3. LA → CC offset-equality propagation (gap 2). + **(done — incl. `WeakCongruencePath` and `ModelBuilder.getModelValue`.)** +3. LA → CC offset-equality propagation (gap 2). **(next)** 4. Eager negated-equality propagation (gap 3). 5. Proof production (increment 4) and offset-aware e-matching (re-enable offsets under quantifiers) — both deferred to the quantifier-theory rework. From 6a00b7884efa64bcf6a3bfef228c7c307e63ec8c Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Sun, 14 Jun 2026 17:43:56 +0200 Subject: [PATCH 17/75] CC offset equalities: add CCAppTerm.getArgParam for the CCParameter arg view Consolidate the hand-rolled CCParameter.of(getArgument(i), getArgOffset(i)) into a single CCAppTerm.getArgParam(i) accessor, giving the uniform "an argument is a value up to a constant" view at the API boundary. ArrayTheory's index helpers and CClosure.checkCongruence now route through it. This keeps the internal storage as parallel CCTerm[]/Rational[] arrays (null in the offset-free case): the argument array is shared between CCAppTerm and its CongruenceTrigger, getArguments() returns a CCTerm[] consumed by the proof, path and model code, and Java array covariance would force a fresh allocation in the offset case for a CCParameter[] field. The parallel-array form is the right internal representation for the flat congruence engine; CCParameter is the right abstraction only at the consumer boundary. Behavior-preserving. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../smtinterpol/theory/cclosure/ArrayTheory.java | 4 ++-- .../smtinterpol/theory/cclosure/CCAppTerm.java | 9 +++++++++ .../smtinterpol/theory/cclosure/CClosure.java | 11 ++++------- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ArrayTheory.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ArrayTheory.java index 4ac59447a..b1ab99a03 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ArrayTheory.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ArrayTheory.java @@ -1054,7 +1054,7 @@ public static CCTerm getIndexFromSelect(final CCAppTerm select) { */ public static CCParameter getIndexParamFromSelect(final CCAppTerm select) { assert isSelectTerm(select); - return CCParameter.of(select.getArgument(1), select.getArgOffset(1)); + return select.getArgParam(1); } public static CCTerm getArrayFromStore(final CCAppTerm store) { @@ -1073,7 +1073,7 @@ public static CCTerm getIndexFromStore(final CCAppTerm store) { */ public static CCParameter getIndexParamFromStore(final CCAppTerm store) { assert isStoreTerm(store); - return CCParameter.of(store.getArgument(1), store.getArgOffset(1)); + return store.getArgParam(1); } public static CCTerm getValueFromStore(final CCAppTerm store) { diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAppTerm.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAppTerm.java index 4dd0c4e1c..4bb38212e 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAppTerm.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAppTerm.java @@ -77,6 +77,15 @@ public Rational getArgOffset(int argPosition) { return mArgOffsets == null ? Rational.ZERO : mArgOffsets[argPosition]; } + /** + * The argument at the given position as a {@link CCParameter}, i.e. {@link #getArgument(int)} together with its + * structural {@link #getArgOffset(int)}. This is the uniform "an argument is a value up to a constant" view; a bare + * {@link CCTerm} is returned when the argument has no offset. + */ + public CCParameter getArgParam(int argPosition) { + return CCParameter.of(mArgs[argPosition], getArgOffset(argPosition)); + } + @Override public String toString() { final StringBuilder sb = new StringBuilder(); diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java index beb05434c..f9f3a3a5d 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java @@ -1151,19 +1151,16 @@ private boolean checkCongruence() { if (t1.getRepresentative() != t2.getRepresentative() && t2 instanceof CCAppTerm) { final CCAppTerm a2 = (CCAppTerm) t2; if (a1.getFunctionSymbol() == a2.getFunctionSymbol()) { - final CCTerm[] args1 = a1.mArgs; - final CCTerm[] args2 = a2.mArgs; + final int arity = a1.mArgs.length; int i; - for (i = 0; i < args1.length; i++) { + for (i = 0; i < arity; i++) { // congruent requires the arguments to have the same value, i.e. same representative AND // same offset (offset-free arguments are compared as plain representatives). - final CCParameter p1 = CCParameter.of(args1[i], a1.getArgOffset(i)); - final CCParameter p2 = CCParameter.of(args2[i], a2.getArgOffset(i)); - if (!p1.sameValueAs(p2)) { + if (!a1.getArgParam(i).sameValueAs(a2.getArgParam(i))) { break; } } - if (i == args1.length) { + if (i == arity) { getLogger().fatal("Should be congruent: " + t1 + " and " + t2); return false; } From 98b9d6045147c90352580ec4cb324bd3a39a2786 Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Sun, 14 Jun 2026 18:39:44 +0200 Subject: [PATCH 18/75] =?UTF-8?q?Plan:=20gap=202=20(LA=E2=86=92CC=20offset?= =?UTF-8?q?=20propagation)=20is=20blocked=20on=20increment=204?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record the gap-2 investigation: the LA side (non-constant fingerprint bucketing + offset equality propagation via EqualityProxy) is small and fixes bv/test01 and abv/ext02, but it exposes that CC's offset-conflict explanation is incomplete - computeAntiCycle's same-class assertion fires, and CongruencePath is offset-blind so the reconstructed conflict path is inconsistent (plus gap 3, eager same-class propagation). Gap 2 must therefore be done together with increment 4 (offset-aware CongruencePath / conflict explanation) and gap 3, not as a standalone change. Co-Authored-By: Claude Opus 4.8 (1M context) --- SMTInterpol/doc/offset-equality-plan.md | 46 +++++++++++++++++++++---- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/SMTInterpol/doc/offset-equality-plan.md b/SMTInterpol/doc/offset-equality-plan.md index a8b497a88..dcc8a6694 100644 --- a/SMTInterpol/doc/offset-equality-plan.md +++ b/SMTInterpol/doc/offset-equality-plan.md @@ -375,11 +375,15 @@ model — the index offset was dropped); `nia/divaxiom2` and `abv/ext02` no long crash (`ext02` correctly UNSAT). All `array/` benchmarks pass; no crashes in `abv/`,`bv/`. -**Next:** LA→CC offset propagation (gap 2), eager negated equalities (gap 3), -proof production (increment 4), offset-aware e-matching (re-enable offsets under -quantifiers), and the still-deferred `DataTypeTheory` offset-aware argument -handling (only matters for datatypes with numeric fields; no failing benchmark -yet). +**Next:** the LA side of gap 2 is prototyped and understood (see "Remaining gaps" +below) but is **blocked on increment 4**: it makes `bv/test01`/`abv/ext02` pass +but exposes that CC's offset-conflict explanation (`computeAntiCycle` and the +offset-blind `CongruencePath`) plus eager same-class propagation (gap 3) are +incomplete. So the next real milestone is **increment 4 (offset-aware +`CongruencePath` + conflict explanation) done together with gaps 2 and 3.** After +that: offset-aware e-matching (re-enable offsets under quantifiers), and the +still-deferred `DataTypeTheory` offset-aware argument handling (only matters for +datatypes with numeric fields; no failing benchmark yet). **Remaining system-benchmark failures** (with proofs/interpolants disabled so offsets are exercised): `bv/test01`, `abv/indexInRange01` (both unsat → **sat**, @@ -426,6 +430,34 @@ them unsound** (crashes or incompleteness): `array/difftest004` (crash), determines `value(a) − value(b) = k` for a constant `k`, it should propagate `a = b + k` so CC merges them at offset `k`. This closes the BV/ABV incompleteness (e.g. LA knows `k mod 256 = 1` but never tells CC). + + **Prototyped and reverted — blocked on increment 4.** The LA side is small and + was verified to work: in `LinArSolve.propagateSharedEqualities`, key the + fingerprint on its *non-constant* part (the `null` entry holds the constant; it + is added by `addToFingerprint`/`fpr.add(shared.getOffset())`), and on a + collision propagate `a = b + k` with `k` the constant difference, via a + generalized `propagateSharedEquality(lhs, rhs, offset, …)` that builds the + right-hand term as `addConstantToTerm(rhs, k)` and reuses + `EqualityProxy.createCCEquality` (which already derives the CCEquality offset + from the term constants and links the `LAEquality`). Guard the true-proxy case + (`lhs == rhs + k` as terms ⇒ a tautology between two distinct *constant* terms, + nothing to merge; non-constant offset-equivalent terms already share a CCTerm + and hit the `lhsCC == rhsCC` early return). Gate the non-constant bucketing on + `createOffsetEqualities()` so behavior is unchanged when offsets are off. This + **fixed `bv/test01` and `abv/ext02`** with default options. + + It cannot land yet because it surfaces two downstream CC gaps (below): the + offset-conflict explanation crashes. Concretely, with the LA propagation on, + `bv/test01` becomes correctly unsat but `abv/ext01` hits + `CClosure.computeAntiCycle`'s `assert left.mRepStar != right.mRepStar` — an + offset equality is now false while its two sides are in the *same* class at a + different offset. Adding a same-class branch to `computeAntiCycle` (explain via + the path between the two sides, clause `{¬eq, ¬path}`) then hits a "Not a unit + clause" assertion: `CongruencePath` is **offset-blind** (gap / increment 4), so + the reconstructed path and the propagation ordering of the same-class + disequality (gap 3) are not yet consistent. So **gap 2 must be implemented + together with increment 4 (offset-aware `CongruencePath`/conflict explanation) + and gap 3 (eager same-class offset (dis)equality propagation)**, not before. 3. **Eager negated-equality propagation.** Increment 3 deliberately omitted (kept sound-but-lazy) the propagation of `mEqlits` at a non-matching offset as *false* when two classes merge at offset δ. Add it to match the eagerness of @@ -484,7 +516,9 @@ value identity `(rep, offsetToRep)` the array theory needs. `DataTypeTheory` still deferred (only matters for numeric datatype fields). 2. `ArrayTheory` offset-aware index handling (gap 1, the substantial one). **(done — incl. `WeakCongruencePath` and `ModelBuilder.getModelValue`.)** -3. LA → CC offset-equality propagation (gap 2). **(next)** +3. LA → CC offset-equality propagation (gap 2). **(LA side prototyped; blocked on + increment 4 — must be done together with offset-aware `CongruencePath` and + gap 3. See "Remaining gaps".)** 4. Eager negated-equality propagation (gap 3). 5. Proof production (increment 4) and offset-aware e-matching (re-enable offsets under quantifiers) — both deferred to the quantifier-theory rework. From 858013d5a21e4367bb2a4d60744d521819e2fd4e Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Sun, 14 Jun 2026 22:03:45 +0200 Subject: [PATCH 19/75] CC offset equalities: LA->CC offset propagation + offset disequality handling Implements gap 2 (LA->CC offset-equality propagation) together with the offset- aware congruence-closure disequality handling it requires (gaps 3 + the conflict explanation). LinArSolve.propagateSharedEqualities now keys the shared-term fingerprint on its non-constant part when offset equalities are enabled, and on a collision propagates value(a) == value(b) + k as an offset CCEquality via a generalized propagateSharedEquality (building rhs + k through EqualityProxy, which already derives the CCEquality offset and links the LAEquality). A true-proxy guard skips the tautology between two distinct constant terms. When offsets are off the constant stays in the key, so the classic equal-value bucketing is unchanged. On the CC side, a merge now propagates the disequality for eqlits whose offset differs from the merge offset (CCTerm.mergeInternal), mirroring the existing matching-offset true propagation. The conflict/explanation machinery is made offset-aware: - CClosure.computeAntiCycle and CongruencePath.computeAntiCycle explain an offset disequality whose two sides are in the same class at a different offset via the path between them ({not eq, not path}), instead of the offset-0 temporary-merge trick which cannot work once the classes are already merged; - setLiteral's same-class "assert eq while offset differs" conflict uses the same {not eq, not path} form (computeCycle would emit a satisfied clause); - getPropagatedLiteral computes a propagated disequality's explanation eagerly (only when offsets are enabled), while the congruence graph still matches the trail, and stores it on the atom so DPLLEngine does not recompute it later from a state where a subsequent offset merge has joined the two sides (the merge is only undone at decision-level backtrack); - checkPending and separate allow the new same-class / decided-false offset cases. Fixes bv/test01 and abv/ext02; abv/ext01 no longer crashes. SystemTest drops from 5 to 4 failures with no new failures and all other unit tests green. The two remaining array cases (abv/indexInRange01, ufbv/ufbv01) need the LA-propagated index equality to re-trigger array congruence, which is a separate gap. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../smtinterpol/theory/cclosure/CCTerm.java | 18 +++++-- .../smtinterpol/theory/cclosure/CClosure.java | 32 ++++++++++-- .../theory/cclosure/CongruencePath.java | 26 ++++++++++ .../smtinterpol/theory/linar/LinArSolve.java | 52 +++++++++++++++++-- 4 files changed, 117 insertions(+), 11 deletions(-) diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCTerm.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCTerm.java index 7a9f79756..52c7b4981 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCTerm.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCTerm.java @@ -209,6 +209,7 @@ final boolean invariant() { return true; } + @Override public final CCTerm getRepresentative() { return mRepStar; } @@ -494,7 +495,7 @@ private Clause mergeInternal(final CClosure engine, final CCTerm lhs, final CCEq src.mMergeTime = engine.getMergeDepth(); engine.recordMerge(lhs); - engine.getLogger().debug("M %s %s (offset %s)", this, lhs, delta); + engine.getLogger().debug("M %s %s (offset %s)", this, lhs, reasonDiff(reason, this, lhs)); if (Config.PROFILE_TIME) { time = System.nanoTime(); @@ -538,9 +539,20 @@ private Clause mergeInternal(final CClosure engine, final CCTerm lhs, final CCEq for (final CompareTrigger trigger : info.mCompareTriggers) { trigger.activate(); } + } else { + // These equalities claim an offset different from the merge offset, so they are now implied + // false. We propagate the disequality eagerly here with the merge as its reason; the explanation + // (CClosure.computeAntiCycle, same-class branch) reconstructs it from the path between the two + // terms, so no separating disequality atom is involved and mDiseqReason stays null. An equality + // here cannot already be true, as that would mean src and dest are already in the same class. + for (final CCEquality.Entry eq : info.mEqlits) { + final CCEquality cceq = eq.getCCEquality(); + assert cceq.getDecideStatus() != cceq; + if (cceq.getDecideStatus() == null) { + engine.addPending(cceq.negate()); + } + } } - // else: these equalities are at a different offset and become false; the conflict (if any) is detected - // when such an equality is asserted (setLiteral), so we do not propagate them eagerly here. } else { // Re-key the info from src to dest: value(dest) - value(other) = (value(src) - value(other)) - delta. final Rational destOffset = offFromSrc.sub(delta); diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java index f9f3a3a5d..6112a8c39 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java @@ -786,6 +786,15 @@ public Clause finalCheck() { public Literal getPropagatedLiteral() { final Literal lit = mPendingLits.poll(); assert (lit == null || checkPending(lit)); + if (lit != null && createOffsetEqualities() && lit.getAtom().mExplanation == null + && lit.getAtom() instanceof CCEquality && !(lit instanceof CCEquality)) { + // This is a propagated disequality. With offset equalities its two sides may later be merged at a different + // offset (a merge that is only undone at decision-level backtrack), which would break the lazy anti-cycle + // explanation: getUnitClause would temporarily insert an equal-edge into an already-merged class, or read a + // path through an edge whose literal has since been backtracked. So we compute the explanation now, while + // the congruence graph still matches the trail, and store it so DPLLEngine uses it directly. + lit.getAtom().mExplanation = getUnitClause(lit); + } return lit; } @@ -894,10 +903,12 @@ public Clause setLiteral(final Literal literal) { } } else { // The terms are already in the same class. Asserting the equality is a conflict if their actual offset - // differs from the offset claimed by the equality (e.g. asserting x == x + 1). + // differs from the offset claimed by the equality (e.g. asserting x == x + 1). The conflict clause is + // {¬eq, ¬path}: eq is true, so ¬eq is false, and the path literals are all true, so the clause is + // falsified. (computeCycle would put eq positively and yield a satisfied clause.) final Rational existingDiff = eq.getLhs().getOffsetToRep().sub(eq.getRhs().getOffsetToRep()); if (!existingDiff.equals(eq.getOffset())) { - return computeCycle(eq); + return new CongruencePath(this).computeAntiCycle(eq, isProofGenerationEnabled()); } } } else { @@ -963,7 +974,10 @@ private void separate(final CCTerm lhs, final CCTerm rhs, final CCEquality diseq /* Propagate inequalities */ for (final CCEquality.Entry eqentry : info.mEqlits) { final CCEquality eq = eqentry.getCCEquality(); - assert eq.getDecideStatus() == null || eq == diseq; + // eq cannot be decided true here: that would merge lhs and rhs, contradicting that they are distinct + // representatives. With offset equalities it may already be decided false (propagated by an earlier + // offset merge); such an eq keeps its existing reason and is simply skipped. + assert eq.getDecideStatus() != eq || eq == diseq; if (eq.getDecideStatus() == null) { eq.mDiseqReason = diseq; addPending(eq.negate()); @@ -1001,6 +1015,12 @@ public Clause computeCycle(final CCTerm lconstant, final CCTerm rconstant) { public Clause computeAntiCycle(final CCEquality eq) { final CCTerm left = eq.getLhs(); final CCTerm right = eq.getRhs(); + if (left.mRepStar == right.mRepStar) { + // Offset equalities make this reachable: the two sides are in the same class at an offset different from the + // one eq claims, so eq is false without any separating disequality. The path between them explains it. + assert !left.getOffsetToRep().sub(right.getOffsetToRep()).equals(eq.getOffset()); + return new CongruencePath(this).computeAntiCycle(eq, isProofGenerationEnabled()); + } final CCEquality diseq = eq.mDiseqReason; assert left.mRepStar != right.mRepStar; assert diseq.getLhs().mRepStar == left.mRepStar || diseq.getLhs().mRepStar == right.mRepStar; @@ -1043,6 +1063,12 @@ private boolean checkPending(final Literal literal) { final CCEquality eq = (CCEquality) literal.negate(); final CCTerm left = eq.getLhs(); final CCTerm right = eq.getRhs(); + if (left.mRepStar == right.mRepStar) { + // Offset disequality: the two sides are in the same class at an offset different from eq's, so eq is + // false with no separating disequality atom (mDiseqReason stays null; the path is the reason). + assert !left.getOffsetToRep().sub(right.getOffsetToRep()).equals(eq.getOffset()); + return true; + } final CCEquality diseq = eq.mDiseqReason; assert left.mRepStar != right.mRepStar; assert diseq.getLhs().mRepStar == left.mRepStar || diseq.getLhs().mRepStar == right.mRepStar; diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java index 55498ef30..458b1db05 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java @@ -332,6 +332,32 @@ public Clause computeCycle(final CCEquality eq, final boolean produceProofs) { return c; } + /** + * Build the clause {@code {¬eq, ¬path}} for an offset equality {@code eq} whose two sides are in the same congruence + * class at an offset different from the one {@code eq} claims. The path between the two sides establishes their + * actual (constant) offset, so {@code eq} is implied false. Used both as the conflict clause when such an + * {@code eq} is asserted true and as the reason when {@code ¬eq} is propagated at merge time. The path-edge literals + * are collected exactly as in {@link #computeCycle(CCEquality, boolean)}; only the polarity of {@code eq} differs + * (negated here instead of positive). + */ + public Clause computeAntiCycle(final CCEquality eq, final boolean produceProofs) { + final CCTerm lhs = eq.getLhs(); + final CCTerm rhs = eq.getRhs(); + assert lhs.getRepresentative() == rhs.getRepresentative(); + computePath(lhs, rhs); + final Literal[] clause = new Literal[mAllLiterals.size() + 1]; + int i = 0; + clause[i++] = eq.negate(); + for (final Literal l : mAllLiterals) { + clause[i++] = l.negate(); + } + final Clause c = new Clause(clause); + if (produceProofs) { + c.setProof(new LeafNode(LeafNode.THEORY_CC, createAnnotation(new SymmetricPair<>(lhs, rhs)))); + } + return c; + } + public Clause computeCycle(final CCTerm lconstant, final CCTerm rconstant, final boolean produceProofs) { mClosure.getLogger().debug("computeCycle for Constants"); computePath(lconstant, rconstant); diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/linar/LinArSolve.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/linar/LinArSolve.java index 01686fbd7..37d4e51d4 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/linar/LinArSolve.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/linar/LinArSolve.java @@ -1197,17 +1197,39 @@ public Map fingerprintSharedVar(LASharedTerm shared) { public Clause propagateSharedEquality(LASharedTerm lhs, LASharedTerm rhs, HashSet> propagated) { + return propagateSharedEquality(lhs, rhs, Rational.ZERO, propagated); + } + + /** + * Propagate that two shared terms have values differing by a constant, i.e. {@code value(lhs) == value(rhs) + + * offset}. With a zero offset this is the classic shared-term equality; with a non-zero offset (only created when + * offset equalities are enabled) it states {@code lhs == rhs + offset} as an offset CCEquality, so congruence + * closure can merge the two terms at that offset. + */ + public Clause propagateSharedEquality(LASharedTerm lhs, LASharedTerm rhs, Rational offset, + HashSet> propagated) { final CCTerm lhsCC = mClausifier.getCCTerm(lhs.getTerm()).getRepresentative(); final CCTerm rhsCC = mClausifier.getCCTerm(rhs.getTerm()).getRepresentative(); if (lhsCC == rhsCC || !propagated.add(new SymmetricPair(lhsCC, rhsCC))) { return null; } - final EqualityProxy eq = mClausifier.createEqualityProxy(lhs.getTerm(), rhs.getTerm(), null); - assert eq != EqualityProxy.getTrueProxy(); + // Encode the offset into the right-hand term so the EqualityProxy and resulting CCEquality carry it. + Term rhsTerm = rhs.getTerm(); + if (!offset.equals(Rational.ZERO)) { + rhsTerm = mClausifier.addConstantToTerm(rhsTerm, offset); + } + final EqualityProxy eq = mClausifier.createEqualityProxy(lhs.getTerm(), rhsTerm, null); + if (eq == EqualityProxy.getTrueProxy()) { + // lhs and rhs + offset are the same term: the offset equality is a tautology between two distinct constant + // terms (offset-equivalent non-constant terms already share a CCTerm and hit the lhsCC == rhsCC return + // above). There is nothing for congruence closure to merge. + return null; + } if (eq == EqualityProxy.getFalseProxy()) { // We found a conflict while trying to propagate a shared equality. // This can happen if the difference between the shared terms cannot be an integer. // We insert the difference as new basic in the tableau and let bound propagation do the rest. + // The constant offset does not affect integrality, so the difference variable is lhs - rhs. final MutableAffineTerm at = new MutableAffineTerm(); at.addMap(Rational.ONE, lhs.getSummands()); at.addMap(Rational.MONE, rhs.getSummands()); @@ -1216,7 +1238,11 @@ public Clause propagateSharedEquality(LASharedTerm lhs, LASharedTerm rhs, assert mDirty.get(var.mMatrixpos); return null; } - final CCEquality cceq = eq.createCCEquality(lhs.getTerm(), rhs.getTerm()); + if (!offset.equals(Rational.ZERO) && mClausifier.getCCTerm(rhsTerm) == null) { + // the synthesized term rhs + offset is offset-free-equivalent to rhs; map it to the same CCTerm. + mClausifier.shareCCTerm(rhsTerm, mClausifier.getCCTerm(rhs.getTerm())); + } + final CCEquality cceq = eq.createCCEquality(lhs.getTerm(), rhsTerm); final LAEquality laeq = cceq.getLASharedData(); mClausifier.getLogger().debug("Propagate: %s (laeq: %s) %s %s", cceq, laeq, cceq.getDecideStatus(), laeq.getDecideStatus()); @@ -1254,17 +1280,33 @@ public Clause propagateSharedEqualities() { for (final LASharedTerm shared : mSharedVars) { sharedVarSorts.add(shared.getTerm().getSort()); } + // When offset equalities are enabled, two shared terms whose values differ by a constant should be reported to + // CC as an offset equality. We therefore key the fingerprint on its non-constant part only (the null entry holds + // the constant) and recover the constant difference at a collision. When offsets are off, the constant stays in + // the key, so the behavior is exactly the classic equal-value bucketing. + final boolean offsets = mClausifier.getCClosure().createOffsetEqualities(); for (final Sort sort : sharedVarSorts) { final Map, LASharedTerm> fingerprints = new HashMap<>(); + final Map, Rational> constants = new HashMap<>(); final HashSet> propagated = new HashSet<>(); for (final LASharedTerm shared : mSharedVars) { if (shared.getTerm().getSort() == sort) { - final Map fingerprint = fingerprintSharedVar(shared); + final Map fingerprint = fingerprintSharedVar(shared); + Rational constant = Rational.ZERO; + if (offsets) { + final Rational c = fingerprint.remove(null); + if (c != null) { + constant = c; + } + } final LASharedTerm other = fingerprints.get(fingerprint); if (other == null) { fingerprints.put(fingerprint, shared); + constants.put(fingerprint, constant); } else { - final Clause conflict = propagateSharedEquality(other, shared, propagated); + // value(other) == value(shared) + (constants[fp] - constant) + final Rational offset = constants.get(fingerprint).sub(constant); + final Clause conflict = propagateSharedEquality(other, shared, offset, propagated); if (conflict != null || !mDirty.isEmpty()) { return conflict; } From 083f73a0818691e235294a17dbb4b72e76a9ca6c Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Sun, 14 Jun 2026 22:07:10 +0200 Subject: [PATCH 20/75] Plan: gaps 2 and 3 + offset conflict explanation done Record that LA->CC offset-equality propagation, eager offset (dis)equality propagation at merge time, and the offset-aware conflict explanation are implemented (commit 858013d5): bv/test01 fixed, abv/ext01 no longer crashes, SystemTest 5 -> 4 failures with all other unit tests green. Capture Jochen's insight that the old mDiseqReason anti-cycle (not the offset-differs propagation) was the obstacle, resolved by computing the disequality explanation eagerly at propagation time. Next: the two remaining array cases need an index-merge to re-trigger array congruence. Co-Authored-By: Claude Opus 4.8 (1M context) --- SMTInterpol/doc/offset-equality-plan.md | 105 +++++++++++++----------- 1 file changed, 56 insertions(+), 49 deletions(-) diff --git a/SMTInterpol/doc/offset-equality-plan.md b/SMTInterpol/doc/offset-equality-plan.md index dcc8a6694..b010a10fc 100644 --- a/SMTInterpol/doc/offset-equality-plan.md +++ b/SMTInterpol/doc/offset-equality-plan.md @@ -375,15 +375,24 @@ model — the index offset was dropped); `nia/divaxiom2` and `abv/ext02` no long crash (`ext02` correctly UNSAT). All `array/` benchmarks pass; no crashes in `abv/`,`bv/`. -**Next:** the LA side of gap 2 is prototyped and understood (see "Remaining gaps" -below) but is **blocked on increment 4**: it makes `bv/test01`/`abv/ext02` pass -but exposes that CC's offset-conflict explanation (`computeAntiCycle` and the -offset-blind `CongruencePath`) plus eager same-class propagation (gap 3) are -incomplete. So the next real milestone is **increment 4 (offset-aware -`CongruencePath` + conflict explanation) done together with gaps 2 and 3.** After -that: offset-aware e-matching (re-enable offsets under quantifiers), and the -still-deferred `DataTypeTheory` offset-aware argument handling (only matters for -datatypes with numeric fields; no failing benchmark yet). +**Done (commit `858013d5`):** gaps 2 and 3 — LA→CC offset-equality propagation, +eager offset (dis)equality propagation at merge time, and the offset-aware +conflict explanation (`computeAntiCycle`/`CongruencePath.computeAntiCycle`, +`setLiteral` polarity, eager disequality explanation in `getPropagatedLiteral`, +`checkPending`/`separate` allowances). `bv/test01` fixed; `abv/ext01` no longer +crashes. SystemTest 5 → 4 failures, no new failures, all other unit tests green. + +**Next:** the two remaining SystemTest failures `abv/indexInRange01` and +`ufbv/ufbv01` (both unsat → **sat**, masked to `unknown` by model-check): the +LA-propagated index offset-equality merges the index CCTerms, but that merge does +not re-trigger **array** congruence (read-over-weakeq), so the array lemma never +fires. This is an array-theory-rerun-on-index-merge gap (the array theory should +recheck when an index class changes). After that: the full offset-aware **proof +object** (hyperresolution offset steps in `CongruencePath`/`CCProofGenerator` + +proof checker) to enable offsets under proofs; offset-aware e-matching (re-enable +offsets under quantifiers); and the deferred `DataTypeTheory` numeric-field offset +handling (no failing benchmark yet). Pre-existing, unrelated: `model/buggy001`, +`nia/divaxiom2`. **Remaining system-benchmark failures** (with proofs/interpolants disabled so offsets are exercised): `bv/test01`, `abv/indexInRange01` (both unsat → **sat**, @@ -425,43 +434,41 @@ them unsound** (crashes or incompleteness): `array/difftest004` (crash), and the e-matching (`GetArgCode`/`EMReverseTrigger`, currently moot because offsets are off under quantifiers). Already fixed: `ModelBuilder.addApp`. This is behind the array crash and the BV/ABV incompleteness. -2. **LA must propagate offset equalities (LA→CC).** Today LA only propagates an - equality between two shared terms when they are equal (offset 0). When LA - determines `value(a) − value(b) = k` for a constant `k`, it should propagate - `a = b + k` so CC merges them at offset `k`. This closes the BV/ABV - incompleteness (e.g. LA knows `k mod 256 = 1` but never tells CC). - - **Prototyped and reverted — blocked on increment 4.** The LA side is small and - was verified to work: in `LinArSolve.propagateSharedEqualities`, key the - fingerprint on its *non-constant* part (the `null` entry holds the constant; it - is added by `addToFingerprint`/`fpr.add(shared.getOffset())`), and on a - collision propagate `a = b + k` with `k` the constant difference, via a - generalized `propagateSharedEquality(lhs, rhs, offset, …)` that builds the - right-hand term as `addConstantToTerm(rhs, k)` and reuses - `EqualityProxy.createCCEquality` (which already derives the CCEquality offset - from the term constants and links the `LAEquality`). Guard the true-proxy case - (`lhs == rhs + k` as terms ⇒ a tautology between two distinct *constant* terms, - nothing to merge; non-constant offset-equivalent terms already share a CCTerm - and hit the `lhsCC == rhsCC` early return). Gate the non-constant bucketing on - `createOffsetEqualities()` so behavior is unchanged when offsets are off. This - **fixed `bv/test01` and `abv/ext02`** with default options. - - It cannot land yet because it surfaces two downstream CC gaps (below): the - offset-conflict explanation crashes. Concretely, with the LA propagation on, - `bv/test01` becomes correctly unsat but `abv/ext01` hits - `CClosure.computeAntiCycle`'s `assert left.mRepStar != right.mRepStar` — an - offset equality is now false while its two sides are in the *same* class at a - different offset. Adding a same-class branch to `computeAntiCycle` (explain via - the path between the two sides, clause `{¬eq, ¬path}`) then hits a "Not a unit - clause" assertion: `CongruencePath` is **offset-blind** (gap / increment 4), so - the reconstructed path and the propagation ordering of the same-class - disequality (gap 3) are not yet consistent. So **gap 2 must be implemented - together with increment 4 (offset-aware `CongruencePath`/conflict explanation) - and gap 3 (eager same-class offset (dis)equality propagation)**, not before. -3. **Eager negated-equality propagation.** Increment 3 deliberately omitted (kept - sound-but-lazy) the propagation of `mEqlits` at a non-matching offset as - *false* when two classes merge at offset δ. Add it to match the eagerness of - the offset-0 code. +2. **LA → CC offset-equality propagation (DONE, commit `858013d5`).** In + `LinArSolve.propagateSharedEqualities`, when `createOffsetEqualities()` the + shared-term fingerprint is keyed on its *non-constant* part (the `null` entry + holds the constant from `addToFingerprint`/`fpr.add(shared.getOffset())`), and a + collision propagates `value(a) == value(b) + k` (`k` = constant difference) via a + generalized `propagateSharedEquality(lhs, rhs, offset, …)` that builds `rhs + k` + with `addConstantToTerm` and reuses `EqualityProxy.createCCEquality` (which + derives the CCEquality offset from the term constants and links the + `LAEquality`). A true-proxy guard skips the tautology between two distinct + constant terms. Off when offsets are off (constant stays in the key). Fixed + `bv/test01`. +3. **Eager offset (dis)equality propagation + offset-aware conflict explanation + (DONE, commit `858013d5`).** `CCTerm.mergeInternal` now propagates the + *disequality* for eqlits whose offset differs from the merge offset (mirroring + the existing matching-offset *true* propagation). The conflict/explanation side + is offset-aware: `CClosure.computeAntiCycle` + `CongruencePath.computeAntiCycle` + explain an offset disequality whose two sides are in the *same* class at a + different offset via the path between them (`{¬eq, ¬path}`), since the offset-0 + temporary-merge trick cannot work once the classes are merged; `setLiteral`'s + same-class conflict uses the same form; and — the key fix — `getPropagatedLiteral` + computes a propagated disequality's explanation **eagerly** (offsets only), + while the congruence graph still matches the trail, storing it on the atom so + `DPLLEngine` does not recompute it later from a state where a subsequent offset + merge (undone only at decision-level backtrack) has joined the two sides. + `checkPending`/`separate` allow the new same-class / decided-false cases. Fixed + `abv/ext01` (was crashing) and `abv/ext02`. **SystemTest 5 → 4 failures, no new + failures, all other unit tests green.** + + *Insight (Jochen):* the offset-differs disequality propagation is not the + problem — the old `mDiseqReason` anti-cycle is, because you cannot temporarily + insert the equal-edge when the classes are already equal at a different offset; + computing the explanation eagerly at propagation time sidesteps it. `CongruencePath` + itself needs **no** offset tracking for the clause: it only collects the path + edges; δ vs k lives in `CClosure`. (Net offsets are only needed for the eventual + *proof object*, still deferred and moot while offsets are off under proofs.) ## CCParameter: a value `CCTerm + Rational` @@ -516,9 +523,9 @@ value identity `(rep, offsetToRep)` the array theory needs. `DataTypeTheory` still deferred (only matters for numeric datatype fields). 2. `ArrayTheory` offset-aware index handling (gap 1, the substantial one). **(done — incl. `WeakCongruencePath` and `ModelBuilder.getModelValue`.)** -3. LA → CC offset-equality propagation (gap 2). **(LA side prototyped; blocked on - increment 4 — must be done together with offset-aware `CongruencePath` and - gap 3. See "Remaining gaps".)** +3. LA → CC offset-equality propagation (gap 2) + eager offset (dis)equality + propagation (gap 3) + offset-aware conflict explanation. **(done, commit + `858013d5`; SystemTest 5 → 4 failures.)** 4. Eager negated-equality propagation (gap 3). 5. Proof production (increment 4) and offset-aware e-matching (re-enable offsets under quantifiers) — both deferred to the quantifier-theory rework. From ee590bf10cf2da4b9ccc585ef93532d75b76dfb6 Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Sun, 14 Jun 2026 22:11:48 +0200 Subject: [PATCH 21/75] nia/divaxiom2: enable produce-proofs so get-proof works The benchmark calls (get-proof) but never enables proof production, so it errors whenever the runner does not turn proofs on for it. Add (set-option :produce-proofs true) to the benchmark itself. Co-Authored-By: Claude Opus 4.8 (1M context) --- SMTInterpolTest/test/nia/divaxiom2.smt2 | 1 + 1 file changed, 1 insertion(+) diff --git a/SMTInterpolTest/test/nia/divaxiom2.smt2 b/SMTInterpolTest/test/nia/divaxiom2.smt2 index 875265c65..9348a2b98 100644 --- a/SMTInterpolTest/test/nia/divaxiom2.smt2 +++ b/SMTInterpolTest/test/nia/divaxiom2.smt2 @@ -1,3 +1,4 @@ +(set-option :produce-proofs true) (set-logic QF_UFNIA) (declare-const x Int) From 0c94d3054b4827f372415d668497a3c42eb975f8 Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Mon, 15 Jun 2026 10:45:24 +0200 Subject: [PATCH 22/75] CC offset equalities: give LASharedTerm its full value (offset), share per value Redesign the CC<->LA sharing for offset equalities, replacing the fragile non-constant fingerprint bucketing. The root problem: with offsets the LASharedTerm was made offset-free (value of the offset-free affine), so a term like (a+255) mod 256 was handed to LA with value -255 instead of 0, and mbtc - which groups shared terms by value - never matched it against 0. The fingerprint bucketing recovered this but over-collided on every fixed value and starved the useful propagation (ufbv01, indexInRange01 stayed sat-masked-as-unknown). Now the LASharedTerm carries the term's full value (its constant included). This is sound because the offset-free CCTerm and the full LASharedTerm share the same LinVars - the constant only lands in the LAEquality bound - and the CC-level offset equality is still derived from the term constants in EqualityProxy. mbtc then groups by true value and propagates the offset equality the ordinary way, so the non-constant bucketing is removed. share() is decoupled: several full terms (2x+4y, 2x+4y+1) collapse to one offset-free CCTerm, so each distinct full-value LASharedTerm is registered with LA (mbtc must see every value) while the offset-free CCTerm is shared with CC once. The offset-aware CC disequality machinery from the previous commit (merge-time disequality propagation, computeAntiCycle, eager explanation) is unchanged and still needed - offset CCEqualities are still created, just via the value-based path now. Fixes abv/indexInRange01 and ufbv/ufbv01. SystemTest now has a single remaining failure, model/buggy001, a pre-existing array-model assertion unrelated to offsets. All other unit tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../smtinterpol/convert/Clausifier.java | 22 +++++++++---------- .../smtinterpol/theory/linar/LinArSolve.java | 22 +++++-------------- 2 files changed, 16 insertions(+), 28 deletions(-) diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/Clausifier.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/Clausifier.java index a614218c9..3d7f2e7ee 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/Clausifier.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/Clausifier.java @@ -261,14 +261,13 @@ public void setTermFlags(final Term term, final int newFlags) { } public void share(final CCTerm ccTerm, final LASharedTerm laTerm) { - // With offset equalities several terms (e.g. 2x+4y, 2x+4y+1, 2x+4y+5) map to the same offset-free CCTerm. That - // CCTerm must be shared with linear arithmetic only once; the others are bridged by their constant offset. The - // LASharedTerm is still registered in the term maps (by the callers) so getLATerm lookups keep working. - if (ccTerm.getSharedTerm() == ccTerm) { - return; - } + // With offset equalities several terms (e.g. 2x+4y, 2x+4y+1, 2x+4y+5) map to the same offset-free CCTerm. Each + // of them is a distinct value, so each full-value LASharedTerm must be registered with linear arithmetic (so + // mbtc sees every value); but the offset-free CCTerm is shared with congruence closure only once. getLASolver().addSharedTerm(laTerm); - getCClosure().addSharedTerm(ccTerm); + if (ccTerm.getSharedTerm() != ccTerm) { + getCClosure().addSharedTerm(ccTerm); + } } public void shareLATerm(final Term term, final LASharedTerm laTerm) { @@ -370,10 +369,11 @@ public void addTermAxioms(final Term term, final SourceAnnotation source) { final MutableAffineTerm mat = createMutableAffinTerm(new Polynomial(term), source); assert mat.getConstant().mEps == 0; if (!mLATerms.containsKey(term)) { - // With offset equalities the shared term is offset-free (the constant is carried as an offset), - // so the LASharedTerm has offset zero and is shared with the offset-free CCTerm. - final Rational offset = createOffsetEqualities() ? Rational.ZERO : mat.getConstant().mReal; - shareLATerm(term, new LASharedTerm(term, mat.getSummands(), offset)); + // The LASharedTerm carries the term's full value (including its constant). With offset equalities + // it is shared with the offset-free CCTerm, but it keeps the constant so that model-based theory + // combination (mbtc) groups shared terms by their true value; the offset-free CCTerm and the full + // LASharedTerm share the same LinVars, so the constant only affects the LAEquality bound. + shareLATerm(term, new LASharedTerm(term, mat.getSummands(), mat.getConstant().mReal)); } } } diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/linar/LinArSolve.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/linar/LinArSolve.java index 37d4e51d4..392272398 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/linar/LinArSolve.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/linar/LinArSolve.java @@ -1280,33 +1280,21 @@ public Clause propagateSharedEqualities() { for (final LASharedTerm shared : mSharedVars) { sharedVarSorts.add(shared.getTerm().getSort()); } - // When offset equalities are enabled, two shared terms whose values differ by a constant should be reported to - // CC as an offset equality. We therefore key the fingerprint on its non-constant part only (the null entry holds - // the constant) and recover the constant difference at a collision. When offsets are off, the constant stays in - // the key, so the behavior is exactly the classic equal-value bucketing. - final boolean offsets = mClausifier.getCClosure().createOffsetEqualities(); + // The fingerprint includes the term's full value (its constant offset is part of the LASharedTerm), so two + // shared terms collide exactly when they are provably equal. The resulting equality may be an offset equality + // at the CC level (the two terms share an offset-free CCTerm but differ by a constant); that offset is derived + // from the term constants in EqualityProxy, so nothing offset-specific is needed here. for (final Sort sort : sharedVarSorts) { final Map, LASharedTerm> fingerprints = new HashMap<>(); - final Map, Rational> constants = new HashMap<>(); final HashSet> propagated = new HashSet<>(); for (final LASharedTerm shared : mSharedVars) { if (shared.getTerm().getSort() == sort) { final Map fingerprint = fingerprintSharedVar(shared); - Rational constant = Rational.ZERO; - if (offsets) { - final Rational c = fingerprint.remove(null); - if (c != null) { - constant = c; - } - } final LASharedTerm other = fingerprints.get(fingerprint); if (other == null) { fingerprints.put(fingerprint, shared); - constants.put(fingerprint, constant); } else { - // value(other) == value(shared) + (constants[fp] - constant) - final Rational offset = constants.get(fingerprint).sub(constant); - final Clause conflict = propagateSharedEquality(other, shared, offset, propagated); + final Clause conflict = propagateSharedEquality(other, shared, propagated); if (conflict != null || !mDirty.isEmpty()) { return conflict; } From 74b73b0d73e6c08c591c47b91416dfe916ca08c8 Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Mon, 15 Jun 2026 10:46:55 +0200 Subject: [PATCH 23/75] Plan: CC<->LA sharing redesign done; SystemTest 6 -> 1 Record the full-value LASharedTerm redesign (commit 0c94d305): mbtc now groups by true value, the non-constant fingerprint bucketing is gone, and indexInRange01 + ufbv01 are fixed. Only model/buggy001 remains (pre-existing array-model assertion, unrelated to offsets). Capture the next items: share all numeric CCAppTerm arguments (completeness), simplify the vestigial offset propagateSharedEquality, the proof object, and the optional CCEquality<->LAEquality unlinking. Co-Authored-By: Claude Opus 4.8 (1M context) --- SMTInterpol/doc/offset-equality-plan.md | 44 +++++++++++++++++-------- 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/SMTInterpol/doc/offset-equality-plan.md b/SMTInterpol/doc/offset-equality-plan.md index b010a10fc..e6d0b33f5 100644 --- a/SMTInterpol/doc/offset-equality-plan.md +++ b/SMTInterpol/doc/offset-equality-plan.md @@ -380,19 +380,37 @@ eager offset (dis)equality propagation at merge time, and the offset-aware conflict explanation (`computeAntiCycle`/`CongruencePath.computeAntiCycle`, `setLiteral` polarity, eager disequality explanation in `getPropagatedLiteral`, `checkPending`/`separate` allowances). `bv/test01` fixed; `abv/ext01` no longer -crashes. SystemTest 5 → 4 failures, no new failures, all other unit tests green. - -**Next:** the two remaining SystemTest failures `abv/indexInRange01` and -`ufbv/ufbv01` (both unsat → **sat**, masked to `unknown` by model-check): the -LA-propagated index offset-equality merges the index CCTerms, but that merge does -not re-trigger **array** congruence (read-over-weakeq), so the array lemma never -fires. This is an array-theory-rerun-on-index-merge gap (the array theory should -recheck when an index class changes). After that: the full offset-aware **proof -object** (hyperresolution offset steps in `CongruencePath`/`CCProofGenerator` + -proof checker) to enable offsets under proofs; offset-aware e-matching (re-enable -offsets under quantifiers); and the deferred `DataTypeTheory` numeric-field offset -handling (no failing benchmark yet). Pre-existing, unrelated: `model/buggy001`, -`nia/divaxiom2`. +crashes. + +**Done (commit `0c94d305`) — the CC↔LA sharing redesign:** the `LASharedTerm` now +carries the term's **full value** (its constant), instead of being offset-free. +The offset-free design handed LA the wrong value (`(a+255) mod 256` shared as +`−255` not `0`), so `mbtc` (which groups shared terms by value) never matched it — +the fingerprint bucketing was papering over this and over-collided. Sound because +the offset-free CCTerm and full `LASharedTerm` share the same LinVars (constant +only affects the `LAEquality` bound), and the CC-level offset equality still comes +from `EqualityProxy`'s term constants. `share()` decoupled so each distinct +full-value `LASharedTerm` reaches LA while the offset-free CCTerm is shared with +CC once. The non-constant fingerprint bucketing is **removed**; `mbtc`/ +`propagateSharedEqualities` are value-based again. **Fixes `abv/indexInRange01` +and `ufbv/ufbv01`.** + +**SystemTest: 6 → 1.** The lone remaining failure `model/buggy001` is a +pre-existing array-model assertion (`assert mArrayModels != null` in +`ArrayTheory.fillInModel`), unrelated to offsets. All other unit tests green. +`array/difftest004`, `abv/ext01/02`, `bv/test01`, `nia/divaxiom2`, +`abv/indexInRange01`, `ufbv/ufbv01` all fixed this round. + +**Next:** (1) the still-open *completeness* item from the design discussion — +share **every** numeric `CCAppTerm` argument with LA (a plain variable used only +as a function argument never enters LA today; create a LinVar for it), so an +argument that becomes CC-equal to a shared term can't be missed. No current +benchmark exercises it, but it's the textbook-complete shape. (2) `propagateShared- +Equality`'s 4-arg offset variant is now vestigial (always called with offset 0, +the offset comes from `EqualityProxy`) — simplify. (3) the offset-aware **proof +object** to enable offsets under proofs; offset-aware e-matching; deferred +`DataTypeTheory` numeric-field handling. (4) consider removing the +`CCEquality`↔`LAEquality` linkage (bigger; has proof-generation consequences). **Remaining system-benchmark failures** (with proofs/interpolants disabled so offsets are exercised): `bv/test01`, `abv/indexInRange01` (both unsat → **sat**, From 329f227a5d2c9247d0d858485229c78b1211a905 Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Mon, 15 Jun 2026 17:07:52 +0200 Subject: [PATCH 24/75] CC offset equalities: propagate implied offset equalities at checkpoint Make the shared-term fingerprint ignore the constant part when offset equalities are enabled, so two shared terms collide when their non-constant parts agree (provably equal up to a constant). propagateSharedEqualities recovers the exact offset from the model-independent value difference and propagates an offset CCEquality to CC -- the LA->CC dual of CCTerm.mSharedTerm -- instead of only zero-offset equalities. The early-out guard in propagateSharedEquality is offset-aware: same affine class and matching offset is already known and skipped; otherwise the propagated hash dedups, so the same-class/different-offset conflict (self-pair (rep, rep)) lets CC raise the offset conflict exactly once. The shared-var loops iterate a snapshot because propagating an offset equality synthesizes and shares a (rhs + offset) term (appends to mSharedVars); it is offset-free-equivalent to an existing term and the guard recognizes it as known on the next call. No-op when offset equalities are disabled (proofs/quantifiers): the full-value fingerprint is kept and every offset is zero. SystemTest unchanged at 1 failure (model/buggy001, pre-existing). Co-Authored-By: Claude Opus 4.8 (1M context) --- SMTInterpol/doc/offset-equality-plan.md | 19 ++++- .../smtinterpol/theory/linar/LinArSolve.java | 69 ++++++++++++++----- 2 files changed, 68 insertions(+), 20 deletions(-) diff --git a/SMTInterpol/doc/offset-equality-plan.md b/SMTInterpol/doc/offset-equality-plan.md index e6d0b33f5..aa2d9c2dc 100644 --- a/SMTInterpol/doc/offset-equality-plan.md +++ b/SMTInterpol/doc/offset-equality-plan.md @@ -405,9 +405,22 @@ pre-existing array-model assertion (`assert mArrayModels != null` in share **every** numeric `CCAppTerm` argument with LA (a plain variable used only as a function argument never enters LA today; create a LinVar for it), so an argument that becomes CC-equal to a shared term can't be missed. No current -benchmark exercises it, but it's the textbook-complete shape. (2) `propagateShared- -Equality`'s 4-arg offset variant is now vestigial (always called with offset 0, -the offset comes from `EqualityProxy`) — simplify. (3) the offset-aware **proof +benchmark exercises it, but it's the textbook-complete shape. (2) **DONE — offset-less +checkpoint propagation.** `fingerprintSharedVar` now drops the constant (the `null` +key, holding the offset and any fixed-variable contributions) when +`createOffsetEqualities()`, so two shared terms collide when their *non-constant* +parts agree, i.e. they are provably equal up to a constant. `propagateSharedEqualities` +recovers the exact offset from the (model-independent) value difference and calls the +4-arg `propagateSharedEquality`, which now propagates implied **offset** equalities to +CC at checkpoint (the LA→CC dual of `CCTerm.mSharedTerm`), not just zero-offset ones. +The early-out guard is offset-aware: same affine class *and* matching offset → already +known (skip); otherwise the `propagated` hash dedups (the same-class/different-offset +self-pair `(rep, rep)` lets CC raise the offset conflict exactly once). The shared-var +loops iterate a snapshot, because propagating an offset equality synthesizes and shares +a `rhs + offset` term (appends to `mSharedVars`); it is offset-free-equivalent to an +existing term and the guard recognizes it as known on the next call. No-op when offsets +are disabled (full-value fingerprint, every offset 0). SystemTest unchanged at 1 +failure (`model/buggy001`, pre-existing). (3) the offset-aware **proof object** to enable offsets under proofs; offset-aware e-matching; deferred `DataTypeTheory` numeric-field handling. (4) consider removing the `CCEquality`↔`LAEquality` linkage (bigger; has proof-generation consequences). diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/linar/LinArSolve.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/linar/LinArSolve.java index 392272398..1b388258f 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/linar/LinArSolve.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/linar/LinArSolve.java @@ -1192,7 +1192,16 @@ public Map fingerprintSharedVar(LASharedTerm shared) { } } fpr.add(shared.getOffset()); - return fpr.getSummands(); + final Map fingerprint = fpr.getSummands(); + if (mClausifier.getCClosure().createOffsetEqualities()) { + // Ignore the constant part (accumulated under the null key from the offset and from any fixed + // variables): two shared terms then collide when their non-constant parts agree, i.e. they are + // provably equal up to a constant. That constant is an offset equality congruence closure can use; the + // exact offset is recovered from the value difference at the collision site in + // propagateSharedEqualities. + fingerprint.remove(null); + } + return fingerprint; } public Clause propagateSharedEquality(LASharedTerm lhs, LASharedTerm rhs, @@ -1208,9 +1217,18 @@ public Clause propagateSharedEquality(LASharedTerm lhs, LASharedTerm rhs, */ public Clause propagateSharedEquality(LASharedTerm lhs, LASharedTerm rhs, Rational offset, HashSet> propagated) { - final CCTerm lhsCC = mClausifier.getCCTerm(lhs.getTerm()).getRepresentative(); - final CCTerm rhsCC = mClausifier.getCCTerm(rhs.getTerm()).getRepresentative(); - if (lhsCC == rhsCC || !propagated.add(new SymmetricPair(lhsCC, rhsCC))) { + final CCTerm lhsCCTerm = mClausifier.getCCTerm(lhs.getTerm()); + final CCTerm rhsCCTerm = mClausifier.getCCTerm(rhs.getTerm()); + final CCTerm lhsCC = lhsCCTerm.getRepresentative(); + final CCTerm rhsCC = rhsCCTerm.getRepresentative(); + if (lhsCC == rhsCC && lhsCCTerm.getOffsetToRep().sub(rhsCCTerm.getOffsetToRep()).equals(offset)) { + // lhs and rhs are already in the same affine class at exactly this offset: nothing to propagate. + return null; + } + if (!propagated.add(new SymmetricPair(lhsCC, rhsCC))) { + // Already handled this (representative) pair in this round. This also deduplicates the inconsistent + // same-class/different-offset case (a self-pair (rep, rep)), so the offset conflict that the CCEquality + // propagation below triggers in congruence closure is created only once. return null; } // Encode the offset into the right-hand term so the EqualityProxy and resulting CCEquality carry it. @@ -1275,26 +1293,37 @@ public Clause propagateSharedEqualities() { mLastNumFixed = numFix; mClausifier.getLogger().debug("Shared Terms: %d, Matrix Size: %d + %d, Num Fixed: %d", mSharedVars.size(), mLinvars.size() - mBasics.size(), mBasics.size(), mLastNumFixed); + // Snapshot the shared terms: propagating an offset equality synthesizes a term (rhs + offset) and shares + // it, which appends to mSharedVars. The snapshot avoids a ConcurrentModificationException; the newly shared + // term is offset-free-equivalent to an existing one and is picked up on the next call (gated by mLastNumFixed), + // where the offset-aware guard in propagateSharedEquality recognizes it as already known. + final List sharedSnapshot = new ArrayList<>(mSharedVars); // TODO: store sharedVars already separated by sorts. final Set sharedVarSorts = new LinkedHashSet(); - for (final LASharedTerm shared : mSharedVars) { + for (final LASharedTerm shared : sharedSnapshot) { sharedVarSorts.add(shared.getTerm().getSort()); } - // The fingerprint includes the term's full value (its constant offset is part of the LASharedTerm), so two - // shared terms collide exactly when they are provably equal. The resulting equality may be an offset equality - // at the CC level (the two terms share an offset-free CCTerm but differ by a constant); that offset is derived - // from the term constants in EqualityProxy, so nothing offset-specific is needed here. + // When offset equalities are enabled the fingerprint ignores the constant part (see fingerprintSharedVar), + // so two shared terms collide when their non-constant parts agree, i.e. they are provably equal up to a + // constant. That constant is the offset of the propagated (offset) CCEquality. When offset equalities are + // disabled the fingerprint keeps the constant, terms collide only when provably equal, and the offset is zero. for (final Sort sort : sharedVarSorts) { final Map, LASharedTerm> fingerprints = new HashMap<>(); final HashSet> propagated = new HashSet<>(); - for (final LASharedTerm shared : mSharedVars) { + for (final LASharedTerm shared : sharedSnapshot) { if (shared.getTerm().getSort() == sort) { final Map fingerprint = fingerprintSharedVar(shared); final LASharedTerm other = fingerprints.get(fingerprint); if (other == null) { fingerprints.put(fingerprint, shared); } else { - final Clause conflict = propagateSharedEquality(other, shared, propagated); + // other and shared have equal non-constant parts (the fingerprints collided), so they + // differ by a fixed constant: value(other) == value(shared) + offset. The non-constant + // parts cancel, so the value difference is exact and model-independent. + final ExactInfinitesimalNumber diff = sharedTermValue(other).sub(sharedTermValue(shared)); + assert diff.getEpsilon().signum() == 0; + final Clause conflict = + propagateSharedEquality(other, shared, diff.getRealValue(), propagated); if (conflict != null || !mDirty.isEmpty()) { return conflict; } @@ -1912,16 +1941,22 @@ private void mutate() { * @return A map from the value to the list of shared variables that have this * value. */ + /** + * Compute the current value of a shared term: its constant offset plus the weighted values of its summands. + */ + private ExactInfinitesimalNumber sharedTermValue(final LASharedTerm shared) { + ExactInfinitesimalNumber value = new ExactInfinitesimalNumber(shared.getOffset()); + for (final Entry entry : shared.getSummands().entrySet()) { + value = value.add(entry.getKey().getValue().mul(entry.getValue())); + } + return value; + } + Map> getSharedCongruences() { mClausifier.getLogger().debug("Shared Vars:"); final Map> result = new HashMap<>(); for (final LASharedTerm shared : mSharedVars) { - ExactInfinitesimalNumber value = new ExactInfinitesimalNumber(shared.getOffset()); - for (final Entry entry : shared.getSummands().entrySet()) { - final LinVar lv = entry.getKey(); - final Rational factor = entry.getValue(); - value = value.add(lv.getValue().mul(factor)); - } + final ExactInfinitesimalNumber value = sharedTermValue(shared); mClausifier.getLogger().debug("%s = %s", shared, value); List slot = result.get(value); if (slot == null) { From 104d04e40de6f80d1d99b68dac3f5bc7433d5276 Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Tue, 16 Jun 2026 20:47:18 +0200 Subject: [PATCH 25/75] CC offset equalities: unify parallel offset arrays into one CCParameter[] Replace the parallel (CCTerm[], Rational[] offsets) representation with a single CCParameter[] in CCAppTerm, SignatureTrigger (and CongruenceTrigger / FindTriggerTrigger / ReverseTriggerTrigger), and CCTermBuilder. Offset-free arguments stay bare CCTerms via CCParameter.of, so there is no allocation regression and the two parallel-array invariants are gone. - createAppTerm takes CCParameter[] (the argOffsets overload is dropped); SignatureTrigger drops its 3-arg constructor; effectiveOffset is just getOffsetToRep() and equals() uses sameValueAs(). - ReverseTrigger.getArgument() now returns CCParameter, and ReverseTriggerTrigger keys its signature on the argument *value* (getArgParam) rather than the offset-free term. - Full break: CCAppTerm.getArgument(i)/getArguments() are removed. Callers use getArgParam(i) for the value or getArgParam(i).getCCTerm() for the structural term (ArrayTheory, DataTypeTheory, DTReverseTrigger, EMReverseTrigger, GetArgCode, QuantClause, ModelBuilder). Offset-aware proof generation is left for later: CongruencePath.computeCCPath and CCProofGenerator carry TODOs noting the annotation should pair on CCParameter; clause extraction is already correct (<=1 offset edge per arg). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../smtinterpol/convert/CCTermBuilder.java | 41 +++----- .../theory/cclosure/ArrayTheory.java | 16 +-- .../theory/cclosure/CCAppTerm.java | 44 ++++----- .../theory/cclosure/CCProofGenerator.java | 15 +-- .../smtinterpol/theory/cclosure/CClosure.java | 14 +-- .../theory/cclosure/CongruencePath.java | 10 +- .../theory/cclosure/CongruenceTrigger.java | 8 +- .../theory/cclosure/DTReverseTrigger.java | 8 +- .../theory/cclosure/DataTypeTheory.java | 28 +++--- .../theory/cclosure/MasterReverseTrigger.java | 2 +- .../theory/cclosure/ModelBuilder.java | 8 +- .../theory/cclosure/ReverseTrigger.java | 7 +- .../cclosure/ReverseTriggerTrigger.java | 5 +- .../theory/cclosure/SignatureTrigger.java | 99 +++++++------------ .../smtinterpol/theory/quant/QuantClause.java | 2 +- .../quant/ematching/EMReverseTrigger.java | 6 +- .../theory/quant/ematching/GetArgCode.java | 3 +- 17 files changed, 145 insertions(+), 171 deletions(-) diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/CCTermBuilder.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/CCTermBuilder.java index 9112822d5..22e2245b8 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/CCTermBuilder.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/CCTermBuilder.java @@ -25,6 +25,7 @@ import de.uni_freiburg.informatik.ultimate.logic.Rational; import de.uni_freiburg.informatik.ultimate.logic.Term; import de.uni_freiburg.informatik.ultimate.smtinterpol.proof.SourceAnnotation; +import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CCParameter; import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CCTerm; import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CClosure; @@ -36,14 +37,13 @@ public class CCTermBuilder { private final SourceAnnotation mSource; private final ArrayDeque mOps = new ArrayDeque<>(); - private final ArrayDeque mConverted = new ArrayDeque<>(); /** - * The constant offsets of the converted CCTerms, parallel to {@link #mConverted}: the term that produced - * {@code mConverted.peek()} equals that CCTerm plus {@code mOffsets.peek()}. This carries the {@code +5} of an - * offset-free term like {@code x+5} up to the enclosing application so it ends up in the app term's argument - * offsets. Offsets are {@link Rational#ZERO} unless offset equalities are enabled. + * The converted results as {@link CCParameter}s: the term that produced {@code mConverted.peek()} has value + * {@code peek().getCCTerm() + peek().getOffset()}. The offset carries the {@code +5} of an offset-free term like + * {@code x+5} up to the enclosing application, where it ends up in that argument's {@link CCParameter}. Offsets are + * {@link Rational#ZERO} (i.e. a bare {@link CCTerm}) unless offset equalities are enabled. */ - private final ArrayDeque mOffsets = new ArrayDeque<>(); + private final ArrayDeque mConverted = new ArrayDeque<>(); public CCTermBuilder(Clausifier clausifier, final SourceAnnotation source) { mClausifier = clausifier; @@ -51,8 +51,7 @@ public CCTermBuilder(Clausifier clausifier, final SourceAnnotation source) { } private void pushResult(final CCTerm ccTerm, final Rational offset) { - mConverted.push(ccTerm); - mOffsets.push(offset); + mConverted.push(CCParameter.of(ccTerm, offset)); } public CCTerm convert(final Term t) { @@ -60,11 +59,11 @@ public CCTerm convert(final Term t) { while (!mOps.isEmpty()) { mOps.pop().perform(); } - final CCTerm res = mConverted.pop(); - mOffsets.pop(); + // The caller asked for the CCTerm of the term; a top-level numeric constant offset (e.g. the +5 of x+5) is not + // part of the CCTerm and is recovered by the caller from the term itself, so it is dropped here (as before). + final CCParameter res = mConverted.pop(); assert mConverted.isEmpty(); - assert mOffsets.isEmpty(); - return res; + return res.getCCTerm(); } private class BuildCCTerm implements Operation { @@ -124,8 +123,9 @@ public MapOffsetFreeTerm(final Term term, final Rational offset) { @Override public void perform() { - final CCTerm offsetFreeCCTerm = mConverted.pop(); - mOffsets.pop(); + final CCParameter offsetFreeParam = mConverted.pop(); + assert offsetFreeParam.getOffset().equals(Rational.ZERO) : "offset-free part must have zero offset"; + final CCTerm offsetFreeCCTerm = offsetFreeParam.getCCTerm(); if (mClausifier.getCCTerm(mTerm) == null) { mClausifier.shareCCTerm(mTerm, offsetFreeCCTerm); mClausifier.addTermAxioms(mTerm, mSource); @@ -148,22 +148,13 @@ public BuildCCAppTerm(ApplicationTerm appTerm) { @Override public void perform() { - final CCTerm[] args = new CCTerm[mAppTerm.getParameters().length]; - Rational[] argOffsets = null; + final CCParameter[] args = new CCParameter[mAppTerm.getParameters().length]; for (int i = args.length - 1; i >= 0; i--) { args[i] = mConverted.pop(); - final Rational offset = mOffsets.pop(); - if (!offset.equals(Rational.ZERO)) { - if (argOffsets == null) { - argOffsets = new Rational[args.length]; - java.util.Arrays.fill(argOffsets, Rational.ZERO); - } - argOffsets[i] = offset; - } } assert mClausifier.getCCTerm(mAppTerm) == null; final CCTerm ccTerm = - mClausifier.getCClosure().createAppTerm(mAppTerm.getFunction(), args, argOffsets, mSource); + mClausifier.getCClosure().createAppTerm(mAppTerm.getFunction(), args, mSource); mClausifier.getCClosure().addTerm(ccTerm, mAppTerm); mClausifier.shareCCTerm(mAppTerm, ccTerm); mClausifier.addTermAxioms(mAppTerm, mSource); diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ArrayTheory.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ArrayTheory.java index b1ab99a03..e24876f25 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ArrayTheory.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ArrayTheory.java @@ -1040,12 +1040,12 @@ public static boolean isDiffTerm(final CCTerm term) { public static CCTerm getArrayFromSelect(final CCAppTerm select) { assert isSelectTerm(select); - return select.getArgument(0); + return select.getArgParam(0).getCCTerm(); } public static CCTerm getIndexFromSelect(final CCAppTerm select) { assert isSelectTerm(select); - return select.getArgument(1); + return select.getArgParam(1).getCCTerm(); } /** @@ -1059,12 +1059,12 @@ public static CCParameter getIndexParamFromSelect(final CCAppTerm select) { public static CCTerm getArrayFromStore(final CCAppTerm store) { assert isStoreTerm(store); - return store.getArgument(0); + return store.getArgParam(0).getCCTerm(); } public static CCTerm getIndexFromStore(final CCAppTerm store) { assert isStoreTerm(store); - return store.getArgument(1); + return store.getArgParam(1).getCCTerm(); } /** @@ -1078,22 +1078,22 @@ public static CCParameter getIndexParamFromStore(final CCAppTerm store) { public static CCTerm getValueFromStore(final CCAppTerm store) { assert isStoreTerm(store); - return store.getArgument(2); + return store.getArgParam(2).getCCTerm(); } public static CCTerm getValueFromConst(final CCAppTerm constArr) { assert isConstTerm(constArr); - return constArr.getArgument(0); + return constArr.getArgParam(0).getCCTerm(); } public static CCTerm getLeftFromDiff(final CCAppTerm diff) { assert isDiffTerm(diff); - return diff.getArgument(0); + return diff.getArgParam(0).getCCTerm(); } public static CCTerm getRightFromDiff(final CCAppTerm diff) { assert isDiffTerm(diff); - return diff.getArgument(1); + return diff.getArgParam(1).getCCTerm(); } public static Sort getArraySortFromSelect(final CCAppTerm select) { diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAppTerm.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAppTerm.java index 4bb38212e..b109bcfc1 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAppTerm.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAppTerm.java @@ -28,30 +28,30 @@ public class CCAppTerm extends CCTerm { final FunctionSymbol mFunc; - final CCTerm[] mArgs; /** - * The constant offset that is added to each argument: argument {@code i} of the represented application is - * {@code mArgs[i] + mArgOffsets[i]}. This lets a term like {@code f(x+5)} use the offset-free CCTerm {@code x} as - * its argument while remembering the {@code +5}. The array is {@code null} when every offset is zero (the common - * case), so plain congruence closure pays no extra cost. + * The arguments of the application as {@link CCParameter}s: argument {@code i} of the represented application has + * value {@code mArgs[i]}, i.e. {@code mArgs[i].getCCTerm() + mArgs[i].getOffset()}. An offset-free argument is a + * bare {@link CCTerm} (no wrapper object), so a term like {@code f(x+5)} stores the offset-free CCTerm {@code x} + * wrapped in an {@link OffsettedCCTerm} remembering the {@code +5} only for that one argument; plain congruence + * closure allocates nothing extra. */ - Rational[] mArgOffsets; + final CCParameter[] mArgs; Term mSmtTerm; CongruenceTrigger mCongTrigger; FindTriggerTrigger mFindTrigger; - public CCAppTerm(FunctionSymbol fsym, final CCTerm[] args, + public CCAppTerm(FunctionSymbol fsym, final CCParameter[] args, final CClosure engine, final boolean isFromQuant) { super(HashUtils.hashJenkins(fsym.hashCode(), (Object[]) args), isFromQuant ? CCAppTerm.computeAge(args) : 0); mFunc = fsym; mArgs = args; } - private final static int computeAge(CCTerm[] args) { + private final static int computeAge(CCParameter[] args) { int age = 1; for (int i = 0; i < args.length; i++) { - age = Math.max(age, args[i].mAge + 1); + age = Math.max(age, args[i].getCCTerm().mAge + 1); } return age; } @@ -60,30 +60,26 @@ public FunctionSymbol getFunctionSymbol() { return mFunc; } - public CCTerm[] getArguments() { - return mArgs; - } - - public CCTerm getArgument(int argPosition) { - return mArgs[argPosition]; + /** The number of arguments of this application. */ + public int getArgCount() { + return mArgs.length; } /** - * @return the constant offset added to the argument at the given position, i.e. the argument of the application is - * {@code getArgument(argPosition) + getArgOffset(argPosition)}. Returns {@link Rational#ZERO} when no - * offsets are stored. + * @return the constant offset added to the argument at the given position, i.e. the argument value is + * {@code getArgParam(argPosition).getCCTerm() + getArgOffset(argPosition)}. */ public Rational getArgOffset(int argPosition) { - return mArgOffsets == null ? Rational.ZERO : mArgOffsets[argPosition]; + return mArgs[argPosition].getOffset(); } /** - * The argument at the given position as a {@link CCParameter}, i.e. {@link #getArgument(int)} together with its - * structural {@link #getArgOffset(int)}. This is the uniform "an argument is a value up to a constant" view; a bare - * {@link CCTerm} is returned when the argument has no offset. + * The value of the argument at the given position as a {@link CCParameter} ("a CCTerm up to a constant offset"). + * This is the only argument accessor: callers that genuinely want the offset-free structural CCTerm must say so + * explicitly via {@code getArgParam(i).getCCTerm()}. */ public CCParameter getArgParam(int argPosition) { - return CCParameter.of(mArgs[argPosition], getArgOffset(argPosition)); + return mArgs[argPosition]; } @Override @@ -108,7 +104,7 @@ public String toString() { if (!app.getArgOffset(i).equals(Rational.ZERO)) { todo.add("+" + app.getArgOffset(i)); } - todo.add(app.mArgs[i]); + todo.add(app.mArgs[i].getCCTerm()); todo.add(" "); } todo.add(app.mFunc.getApplicationString()); diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCProofGenerator.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCProofGenerator.java index af3037e6f..9abe2061e 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCProofGenerator.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCProofGenerator.java @@ -721,12 +721,15 @@ private ProofInfo findCongruencePaths(CCTerm first, CCTerm second) { return null; } - final CCTerm[] firstArgs = firstApp.getArguments(); - final CCTerm[] secondArgs = secondApp.getArguments(); - assert firstArgs.length == secondArgs.length; - for (int i = 0; i < firstArgs.length; i++) { - if (firstArgs[i] != secondArgs[i]) { - final SymmetricPair argPair = new SymmetricPair<>(firstArgs[i], secondArgs[i]); + // TODO offset equalities: compares the offset-free argument terms; offset-aware proof generation (pairing on + // the CCParameter, i.e. getArgParam(i), so equal-by-value-but-different-offset args are handled) is not done + // yet. + assert firstApp.getArgCount() == secondApp.getArgCount(); + for (int i = 0; i < firstApp.getArgCount(); i++) { + final CCTerm firstArg = firstApp.getArgParam(i).getCCTerm(); + final CCTerm secondArg = secondApp.getArgParam(i).getCCTerm(); + if (firstArg != secondArg) { + final SymmetricPair argPair = new SymmetricPair<>(firstArg, secondArg); if (isEqualityLiteral(argPair)) { proofInfo.addLiteral(mEqualityLiterals.get(argPair)); } else if (mPathProofMap.containsKey(argPair)) { diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java index 6112a8c39..b799a5c8f 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java @@ -294,21 +294,15 @@ private int getMergeStackDepth(CCTerm t1, CCTerm t2) { } } - public CCTerm createAppTerm(FunctionSymbol func, final CCTerm[] args, final SourceAnnotation source) { - return createAppTerm(func, args, null, source); - } - /** - * Create a function application CCTerm. The optional {@code argOffsets} array carries the constant offset of each - * argument, so that the actual argument is {@code args[i] + argOffsets[i]} (used for offset-free arguments like the - * {@code +5} in {@code f(x+5)}). It may be {@code null} when every offset is zero. + * Create a function application CCTerm. Each argument is a {@link CCParameter}, i.e. a CCTerm together with a + * constant offset, so the actual argument is {@code args[i].getCCTerm() + args[i].getOffset()} (the offset carries + * e.g. the {@code +5} in {@code f(x+5)}). Offset-free arguments are bare {@link CCTerm}s. */ - public CCTerm createAppTerm(FunctionSymbol func, final CCTerm[] args, final Rational[] argOffsets, - final SourceAnnotation source) { + public CCTerm createAppTerm(FunctionSymbol func, final CCParameter[] args, final SourceAnnotation source) { assert args.length > 0; if (args.length > 0) { final CCAppTerm term = new CCAppTerm(func, args, this, source.isFromQuantTheory()); - term.mArgOffsets = argOffsets; if (term.getAge() > 0) { getLogger().debug("Create new AppTerm %s of age %d", term, term.getAge()); } diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java index 458b1db05..f5f849edb 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java @@ -149,10 +149,12 @@ private int computeDepth(CCTerm t) { * @param end the other function application term. */ private void computeCCPath(CCAppTerm start, CCAppTerm end) { - CCTerm[] startArgs = start.getArguments(); - CCTerm[] endArgs = end.getArguments(); - for (int i = 0; i < startArgs.length; i++) { - mTodo.addFirst(new SymmetricPair<>(startArgs[i], endArgs[i])); + // TODO offset equalities: this pairs the offset-free argument terms, which is correct for clause extraction + // (there is at most one offset-equality edge between corresponding args, so computePathTo collects it), but + // loses the offset for the recorded path/annotation. To make offset proofs readable the annotation should + // carry the CCParameter (getArgParam(i)) rather than the bare CCTerm. + for (int i = 0; i < start.getArgCount(); i++) { + mTodo.addFirst(new SymmetricPair<>(start.getArgParam(i).getCCTerm(), end.getArgParam(i).getCCTerm())); } } diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruenceTrigger.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruenceTrigger.java index cbebde9c7..b15d79c9b 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruenceTrigger.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruenceTrigger.java @@ -37,9 +37,13 @@ public final class CongruenceTrigger extends SignatureTrigger { * * @param app * the (kept) application term for this signature. + * @param func + * the function symbol (signature id). + * @param args + * the application's arguments as {@link CCParameter}s (shared with the app term). */ - public CongruenceTrigger(final CCAppTerm app, FunctionSymbol func, CCTerm[] args) { - super(func, args, app.mArgOffsets); + public CongruenceTrigger(final CCAppTerm app, FunctionSymbol func, CCParameter[] args) { + super(func, args); mApp = app; } diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/DTReverseTrigger.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/DTReverseTrigger.java index af3297f70..a7b4d03dd 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/DTReverseTrigger.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/DTReverseTrigger.java @@ -57,7 +57,7 @@ public DTReverseTrigger(final DataTypeTheory dtTheory, final Clausifier clausifi } @Override - public CCTerm getArgument() { + public CCParameter getArgument() { return mArg; } @@ -77,9 +77,11 @@ public void activate(final CCAppTerm appTerm, final boolean isFresh) { mClausifier.getLogger().debug("DTReverseTrigger: %s on %s", appTerm, mArg); final ApplicationTerm argAT = (ApplicationTerm) mArg.mFlatTerm; final SymmetricPair[] reason; - if (appTerm.getArgument(0) != mArg) { + // data type constructor arguments are offset-free, so the structural CCTerm is the value + final CCTerm appArg = appTerm.getArgParam(0).getCCTerm(); + if (appArg != mArg) { reason = new SymmetricPair[] { - new SymmetricPair<>(appTerm.getArgument(0), mArg) + new SymmetricPair<>(appArg, mArg) }; } else { reason = new SymmetricPair[0]; diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/DataTypeTheory.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/DataTypeTheory.java index fc1e1f785..43ea6c7f3 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/DataTypeTheory.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/DataTypeTheory.java @@ -213,7 +213,7 @@ public Clause checkpoint() { final ApplicationTerm at = (ApplicationTerm) t.mFlatTerm; final CCAppTerm trueIsApp = (CCAppTerm) t; if (at.getFunction().getName() == "is") { - final CCTerm argRep = trueIsApp.getArgument(0).getRepresentative(); + final CCTerm argRep = trueIsApp.getArgParam(0).getCCTerm().getRepresentative(); if (!visited.containsKey(argRep)) { visited.put(argRep, trueIsApp); addConstructorLemma(trueIsApp); @@ -228,8 +228,8 @@ public Clause checkpoint() { final ArrayList> reason = new ArrayList<>(); reason.add(new SymmetricPair<>(prevIsApp, trueCC)); reason.add(new SymmetricPair<>(trueIsApp, trueCC)); - if (prevIsApp.getArgument(0) != trueIsApp.getArgument(0)) { - reason.add(new SymmetricPair<>(prevIsApp.getArgument(0), trueIsApp.getArgument(0))); + if (prevIsApp.getArgParam(0).getCCTerm() != trueIsApp.getArgParam(0).getCCTerm()) { + reason.add(new SymmetricPair<>(prevIsApp.getArgParam(0).getCCTerm(), trueIsApp.getArgParam(0).getCCTerm())); } final Term[] testers = new Term[2]; testers[0] = prevIsApp.mFlatTerm; @@ -252,7 +252,7 @@ public Clause checkpoint() { if (cct instanceof CCAppTerm) { final CCAppTerm appTerm = (CCAppTerm) cct; if (appTerm.getFunctionSymbol().getName().equals(SMTLIBConstants.IS)) { - final CCTerm arg = appTerm.getArgument(0); + final CCTerm arg = appTerm.getArgParam(0).getCCTerm(); falseIsFuns.putIfAbsent(arg.getRepresentative(), new LinkedHashSet<>()); falseIsFuns.get(arg.getRepresentative()).add(appTerm); } @@ -279,7 +279,7 @@ public Clause checkpoint() { for (final Constructor consName : dt.getConstructors()) { final CCAppTerm isFun = isIndices.get(consName.getName()); testers[i++] = isFun.mFlatTerm; - final CCTerm arg = isFun.getArgument(0); + final CCTerm arg = isFun.getArgParam(0).getCCTerm(); reason.add(new SymmetricPair<>(isFun, falseCC)); if (firstArg == null) { firstArg = arg; @@ -423,7 +423,7 @@ public Clause finalCheck() { final CCAppTerm appTerm = (CCAppTerm) ccTerm; final FunctionSymbol fs = appTerm.getFunctionSymbol(); if (fs.isSelector() || fs.getName().equals(SMTLIBConstants.IS)) { - final CCTerm argTerm = appTerm.getArgument(0); + final CCTerm argTerm = appTerm.getArgParam(0).getCCTerm(); final CCTerm consTerm = argTerm.getRepresentative().getSharedTerm(); if (consTerm != null) { final ApplicationTerm consApp = (ApplicationTerm) consTerm.getFlatTerm(); @@ -450,7 +450,7 @@ public Clause finalCheck() { final String[] allSelectorNames = constructor.getSelectors(); for (int i = 0; i < allSelectorNames.length; i++) { if (allSelectorNames[i].equals(fs.getName())) { - final CCTerm consArg = ((CCAppTerm) consTerm).getArgument(i); + final CCTerm consArg = ((CCAppTerm) consTerm).getArgParam(i).getCCTerm(); if (ccTerm.getRepresentative() != consArg.getRepresentative()) { mCClosure.getLogger().error("Unpropagated selector of constructor"); @SuppressWarnings("unchecked") @@ -534,7 +534,8 @@ private List getAllDataTypeChildren(final CCTerm ccTerm, final Map // Get the corresponding tester or create it if it does not exists. // If it exists, the corresponding tester is true. final CCAppTerm selectTerm = (CCAppTerm) currentAsChild; - prevAsParent = selectTerm.getArgument(0); + prevAsParent = selectTerm.getArgParam(0).getCCTerm(); final FunctionSymbol selectorFunc = selectTerm.getFunctionSymbol(); final Constructor cons = getConstructor(selectorFunc); final Term isTerm = mTheory.term(mTheory.getFunctionWithResult(SMTLIBConstants.IS, new String[] { cons.getName() }, @@ -729,7 +730,7 @@ public void recheckTrigger() { ApplicationTerm constructor = null; final CCAppTerm checkTerm = iter.next(); final FunctionSymbol selectorOrTester = checkTerm.getFunctionSymbol(); - final CCTerm selectOrIsArg = checkTerm.getArgument(0); + final CCTerm selectOrIsArg = checkTerm.getArgParam(0).getCCTerm(); assert selectorOrTester.isSelector() || selectorOrTester.getName().equals(SMTLIBConstants.IS); for (final CCTerm ct : selectOrIsArg.getRepresentative().mMembers) { if (ct.mFlatTerm instanceof ApplicationTerm && ((ApplicationTerm) ct.mFlatTerm).getFunction().isConstructor()) { @@ -831,7 +832,7 @@ public Object[] getStatistics() { */ private void addConstructorLemma(final CCAppTerm isTerm) { // check if there is already a constructor application equal to the argument - final CCTerm arg = isTerm.getArgument(0); + final CCTerm arg = isTerm.getArgParam(0).getCCTerm(); if (arg.getRepresentative().getSharedTerm() != null) { // We don't care which constructor it is. If it's the wrong constructor // there should already be a trigger that set the isTerm to false. @@ -978,7 +979,10 @@ public void fillInModel(ModelBuilder modelBuilder, List sorts, LinkedHashM if (sharedTerm instanceof CCAppTerm) { final CCAppTerm constrAppTerm = (CCAppTerm) sharedTerm; final FunctionSymbol constr = constrAppTerm.getFunctionSymbol(); - final CCTerm[] args = constrAppTerm.getArguments(); + final CCTerm[] args = new CCTerm[constrAppTerm.getArgCount()]; + for (int i = 0; i < args.length; i++) { + args[i] = constrAppTerm.getArgParam(i).getCCTerm(); + } valueMap.put(ct, new ConstrTerm(constr, args)); } else { final ApplicationTerm appTerm = (ApplicationTerm) sharedTerm.getFlatTerm(); diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/MasterReverseTrigger.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/MasterReverseTrigger.java index c28363459..40ac4b056 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/MasterReverseTrigger.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/MasterReverseTrigger.java @@ -63,7 +63,7 @@ public FunctionSymbol getFunctionSymbol() { } @Override - public CCTerm getArgument() { + public CCParameter getArgument() { return null; } diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ModelBuilder.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ModelBuilder.java index 6f1fd6cc1..d6f2d29f4 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ModelBuilder.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ModelBuilder.java @@ -296,11 +296,11 @@ private boolean isUndefinedFor(FunctionSymbol fs, Term[] args) { } private void addApp(final Model model, final CCAppTerm app, final Term value, final Theory t) { - CCTerm[] ccArgs = app.getArguments(); - Term[] args = new Term[ccArgs.length]; + Term[] args = new Term[app.getArgCount()]; for (int i = 0; i < args.length; i++) { - // the actual argument is ccArgs[i] + structural offset, evaluated at its model value - args[i] = getModelValueWithOffset(ccArgs[i], app.getArgOffset(i)); + // the actual argument is the CCParameter's term + offset, evaluated at its model value + final CCParameter argParam = app.getArgParam(i); + args[i] = getModelValueWithOffset(argParam.getCCTerm(), argParam.getOffset()); } final FunctionSymbol fs = app.getFunctionSymbol(); if (!fs.isIntern() || isUndefinedFor(fs, args)) { diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ReverseTrigger.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ReverseTrigger.java index 3f387785d..d180ac57e 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ReverseTrigger.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ReverseTrigger.java @@ -40,11 +40,12 @@ public abstract class ReverseTrigger extends SimpleListable { SignatureTrigger mSignatureTrigger; /** - * Get the argument on which the reverse trigger is installed. + * Get the argument value on which the reverse trigger is installed, as a {@link CCParameter} so the offset (if any) + * is part of the watched value. * - * @return the argument term. + * @return the argument value. */ - public abstract CCTerm getArgument(); + public abstract CCParameter getArgument(); /** * Get the position in the function application where the argument should be. diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ReverseTriggerTrigger.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ReverseTriggerTrigger.java index 5f0bc9604..95d795e18 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ReverseTriggerTrigger.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ReverseTriggerTrigger.java @@ -35,12 +35,13 @@ public final class ReverseTriggerTrigger extends SignatureTrigger { public ReverseTriggerTrigger(MasterReverseTrigger masterTrigger, ReverseTrigger trigger) { - super(masterTrigger, new CCTerm[] { trigger.getArgument() }); + super(masterTrigger, new CCParameter[] { trigger.getArgument() }); mTriggers.append(trigger); } public ReverseTriggerTrigger(MasterReverseTrigger masterTrigger, CCAppTerm app, int argPosition) { - super(masterTrigger, new CCTerm[] { app.getArgument(argPosition) }); + // key the signature on the argument value (including its offset), so it matches the trigger's watched value + super(masterTrigger, new CCParameter[] { app.getArgParam(argPosition) }); mApplications.append(new AppTermEntry(app)); } diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/SignatureTrigger.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/SignatureTrigger.java index d7cae67a2..7f9d2bf2b 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/SignatureTrigger.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/SignatureTrigger.java @@ -18,93 +18,69 @@ */ package de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure; -import java.util.Arrays; -import java.util.List; - import de.uni_freiburg.informatik.ultimate.logic.Rational; import de.uni_freiburg.informatik.ultimate.smtinterpol.dpll.SimpleListable; import de.uni_freiburg.informatik.ultimate.util.HashUtils; /** - * A signature is a tuple of an opaque identifier and a non-empty array of CCTerms. It is used as the key for the - * global signature-to-trigger map in CClosure. Hash and equality are based on the representatives of the - * terms, so when representatives change (e.g. after a merge), the same term array yields a different signature key. + * A signature is a tuple of an opaque identifier and a non-empty array of {@link CCParameter}s (a CCTerm together with + * a constant offset). It is used as the key for the global signature-to-trigger map in CClosure. Hash and equality are + * based on each parameter's representative and its offset to that representative, so when + * representatives or offsets change (e.g. after a merge), the same parameter array yields a different signature key. * * @author Jochen Hoenicke */ public class SignatureTrigger extends SimpleListable { private final Object mId; - private final CCTerm[] mTerms; /** - * The structural constant offsets of the signature, parallel to {@link #mTerms}: argument {@code i} of the - * signature is {@code mTerms[i] + mArgOffsets[i]}. This is {@code null} when every offset is zero (the common case), - * which keeps plain congruence closure free of any offset cost. The effective offset that the signature is keyed on - * additionally includes the term's offset to its representative (see {@link #effectiveOffset(int)}), so two - * applications are congruent only if their arguments have the same representative and the same effective - * offset. + * The arguments of the signature as {@link CCParameter}s: argument {@code i} has value + * {@code mParams[i].getCCTerm() + mParams[i].getOffset()}. Offset-free arguments are bare {@link CCTerm}s, which + * keeps plain congruence closure free of any offset cost. The effective offset that the signature is keyed on is + * {@code mParams[i].getOffsetToRep()} (the parameter's offset relative to its representative; see + * {@link #effectiveOffset(int)}), so two applications are congruent only if their arguments have the same + * representative and the same effective offset. */ - private final Rational[] mArgOffsets; + private final CCParameter[] mParams; private int mLastHashCode; private SignatureTrigger mMergedTrigger; private SignatureBackRef[] mBackrefs; /** - * Create a signature with the given identifier and non-empty term array, with all structural offsets zero. - * - * @param id - * opaque identifier (e.g. function symbol for congruence, or trigger id for reverse triggers). - * @param terms - * non-empty array of CCTerms. - */ - public SignatureTrigger(final Object id, final CCTerm[] terms) { - this(id, terms, null); - } - - /** - * Create a signature with the given identifier, term array and structural offsets. + * Create a signature with the given identifier and non-empty parameter array. * * @param id * opaque identifier (e.g. function symbol for congruence, or trigger id for reverse triggers). - * @param terms - * non-empty array of CCTerms. - * @param argOffsets - * the structural offset for each term, or {@code null} when all are zero. + * @param params + * non-empty array of {@link CCParameter}s (bare {@link CCTerm}s for offset-free arguments). */ - public SignatureTrigger(final Object id, final CCTerm[] terms, final Rational[] argOffsets) { + public SignatureTrigger(final Object id, final CCParameter[] params) { mId = id; - mTerms = terms; - mArgOffsets = argOffsets; + mParams = params; mLastHashCode = computeHashCode(); } /** - * The effective offset of argument {@code index}: the term's offset to its representative plus the structural - * offset. This is what determines, together with the representative, whether two signatures are congruent. + * The effective offset of argument {@code index}: the parameter's offset relative to its representative. This is + * what determines, together with the representative, whether two signatures are congruent. */ private Rational effectiveOffset(final int index) { - final Rational structural = mArgOffsets == null ? Rational.ZERO : mArgOffsets[index]; - return mTerms[index].mOffsetToRep.add(structural); + return mParams[index].getOffsetToRep(); } public Object getId() { return mId; } - /** Returns the number of terms in this signature. */ + /** Returns the number of arguments in this signature. */ public int getTermCount() { - return mTerms.length; - } - - /** Returns the term at the given index. */ - public CCTerm getTerm(final int index) { - return mTerms[index]; + return mParams.length; } - /** Returns an unmodifiable list view of the terms. */ - public List getTerms() { - return Arrays.asList(mTerms); + /** Returns the argument at the given index. */ + public CCParameter getParam(final int index) { + return mParams[index]; } public void rehash(CClosure engine, int argPosition, CCTerm oldRep, CCTerm newRep, Rational offsetDelta) { @@ -153,10 +129,10 @@ public int hashCode() { public int computeHashCode() { int h = mId.hashCode(); - for (int i = 0; i < mTerms.length; i++) { + for (int i = 0; i < mParams.length; i++) { // Use disjoint salts (2*i for the representative, 2*i+1 for the offset) so the two contributions do not // trivially cancel against each other. - h ^= HashUtils.hashJenkins(2 * i, mTerms[i].getRepresentative()); + h ^= HashUtils.hashJenkins(2 * i, mParams[i].getRepresentative()); h ^= HashUtils.hashJenkins(2 * i + 1, effectiveOffset(i).hashCode()); } return h; @@ -189,14 +165,11 @@ public boolean equals(final Object obj) { return false; } final SignatureTrigger other = (SignatureTrigger) obj; - if (!mId.equals(other.mId) || mTerms.length != other.mTerms.length) { + if (!mId.equals(other.mId) || mParams.length != other.mParams.length) { return false; } - for (int i = 0; i < mTerms.length; i++) { - if (mTerms[i].getRepresentative() != other.mTerms[i].getRepresentative()) { - return false; - } - if (!effectiveOffset(i).equals(other.effectiveOffset(i))) { + for (int i = 0; i < mParams.length; i++) { + if (!mParams[i].sameValueAs(other.mParams[i])) { return false; } } @@ -206,8 +179,8 @@ public boolean equals(final Object obj) { @Override public String toString() { final StringBuilder sb = new StringBuilder("Sig[").append(mId); - for (final CCTerm t : mTerms) { - sb.append(',').append(t); + for (final CCParameter p : mParams) { + sb.append(',').append(p); } return sb.append(']').toString(); } @@ -221,16 +194,16 @@ public boolean unmerge(CClosure cclosure) { } public void addBackrefs(CClosure cclosure) { - mBackrefs = new SignatureBackRef[mTerms.length]; - for (int i = 0; i < mTerms.length; i++) { + mBackrefs = new SignatureBackRef[mParams.length]; + for (int i = 0; i < mParams.length; i++) { mBackrefs[i] = new SignatureBackRef(this, i); - cclosure.addSignatureBackRef(mTerms[i], mBackrefs[i]); + cclosure.addSignatureBackRef(mParams[i].getCCTerm(), mBackrefs[i]); } } public void removeBackrefs(CClosure cclosure) { - for (int i = 0; i < mTerms.length; i++) { - cclosure.removeSignatureBackRef(mTerms[i], mBackrefs[i]); + for (int i = 0; i < mParams.length; i++) { + cclosure.removeSignatureBackRef(mParams[i].getCCTerm(), mBackrefs[i]); } mBackrefs = null; } diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/QuantClause.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/QuantClause.java index 351f4278e..4acbe2fe3 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/QuantClause.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/QuantClause.java @@ -451,7 +451,7 @@ private void updateInterestingTermsForFuncArgs(final TermVariable var, final int final BitSet pos = entry.getValue(); for (int i = pos.nextSetBit(0); i >= 0; i = pos.nextSetBit(i + 1)) { for (CCAppTerm appTerm : mQuantTheory.getCClosure().getAllFuncApps(func)) { - interestingTerms.add(appTerm.getArgument(i).getFlatTerm()); + interestingTerms.add(appTerm.getArgParam(i).getCCTerm().getFlatTerm()); } } } diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/EMReverseTrigger.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/EMReverseTrigger.java index f5318347c..6d70b1995 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/EMReverseTrigger.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/EMReverseTrigger.java @@ -22,6 +22,7 @@ import de.uni_freiburg.informatik.ultimate.logic.FunctionSymbol; import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CCAppTerm; +import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CCParameter; import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CCTerm; /** @@ -55,7 +56,7 @@ public EMReverseTrigger(final EMatching eMatching, final ICode remainingCode, fi } @Override - public CCTerm getArgument() { + public CCParameter getArgument() { return mArg; } @@ -75,7 +76,8 @@ public void activate(final CCAppTerm appTerm, final boolean isFresh) { updatedRegister[mOutRegIndex] = appTerm; if (mArg != null) { // Reverse - final CCTerm candArg = appTerm.getArgument(mArgPos); + // e-matching arguments are offset-free; use the structural CCTerm for the path comparison + final CCTerm candArg = appTerm.getArgParam(mArgPos).getCCTerm(); final int termDecisionLevel = mEMatching.getQuantTheory().getCClosure() .getDecideLevelForPath(mArg, candArg); diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/GetArgCode.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/GetArgCode.java index 5ca37f6d7..bee5757de 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/GetArgCode.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/GetArgCode.java @@ -52,7 +52,8 @@ public void execute(final CCTerm[] register, final int decisionLevel) { final CCTerm appTerm = register[mAppTermRegIndex]; assert appTerm instanceof CCAppTerm; CCAppTerm partialApp = (CCAppTerm) appTerm; - CCTerm arg = partialApp.getArgument(mArgPos); + // e-matching arguments are offset-free; use the structural CCTerm for the register + CCTerm arg = partialApp.getArgParam(mArgPos).getCCTerm(); final CCTerm[] updatedRegister = Arrays.copyOf(register, register.length); updatedRegister[mOutRegIndex] = arg; mEMatching.addCode(mRemainingCode, updatedRegister, decisionLevel); From a65d17871f61eb99d847cdfe1b99f981c77324b7 Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Tue, 16 Jun 2026 21:01:55 +0200 Subject: [PATCH 26/75] CC offset equalities: cast offset-free CCParameters to CCTerm (runtime check) At argument sites that are structurally offset-free -- datatype selector/tester arguments, array arguments of select/store, and the two array arguments of diff -- replace `getArgParam(i).getCCTerm()` with `(CCTerm) getArgParam(i)`. The cast asserts the no-offset assumption at runtime (ClassCastException on an OffsettedCCTerm) instead of silently dropping an unexpected offset. Array index/value accessors keep getCCTerm() (they intentionally take the offset-free part of a possibly-offset argument), as do the constructor-argument sites, which still need offset-aware handling (injectivity, selector projection, model building). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../theory/cclosure/ArrayTheory.java | 12 ++++++++---- .../theory/cclosure/DTReverseTrigger.java | 5 +++-- .../theory/cclosure/DataTypeTheory.java | 19 ++++++++++--------- 3 files changed, 21 insertions(+), 15 deletions(-) diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ArrayTheory.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ArrayTheory.java index e24876f25..61fa80cb4 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ArrayTheory.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ArrayTheory.java @@ -1040,7 +1040,8 @@ public static boolean isDiffTerm(final CCTerm term) { public static CCTerm getArrayFromSelect(final CCAppTerm select) { assert isSelectTerm(select); - return select.getArgParam(0).getCCTerm(); + // the array argument is never numeric; the cast asserts it carries no offset + return (CCTerm) select.getArgParam(0); } public static CCTerm getIndexFromSelect(final CCAppTerm select) { @@ -1059,7 +1060,8 @@ public static CCParameter getIndexParamFromSelect(final CCAppTerm select) { public static CCTerm getArrayFromStore(final CCAppTerm store) { assert isStoreTerm(store); - return store.getArgParam(0).getCCTerm(); + // the array argument is never numeric; the cast asserts it carries no offset + return (CCTerm) store.getArgParam(0); } public static CCTerm getIndexFromStore(final CCAppTerm store) { @@ -1088,12 +1090,14 @@ public static CCTerm getValueFromConst(final CCAppTerm constArr) { public static CCTerm getLeftFromDiff(final CCAppTerm diff) { assert isDiffTerm(diff); - return diff.getArgParam(0).getCCTerm(); + // diff arguments are arrays, never numeric; the cast asserts they carry no offset + return (CCTerm) diff.getArgParam(0); } public static CCTerm getRightFromDiff(final CCAppTerm diff) { assert isDiffTerm(diff); - return diff.getArgParam(1).getCCTerm(); + // diff arguments are arrays, never numeric; the cast asserts they carry no offset + return (CCTerm) diff.getArgParam(1); } public static Sort getArraySortFromSelect(final CCAppTerm select) { diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/DTReverseTrigger.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/DTReverseTrigger.java index a7b4d03dd..181505db6 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/DTReverseTrigger.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/DTReverseTrigger.java @@ -77,8 +77,9 @@ public void activate(final CCAppTerm appTerm, final boolean isFresh) { mClausifier.getLogger().debug("DTReverseTrigger: %s on %s", appTerm, mArg); final ApplicationTerm argAT = (ApplicationTerm) mArg.mFlatTerm; final SymmetricPair[] reason; - // data type constructor arguments are offset-free, so the structural CCTerm is the value - final CCTerm appArg = appTerm.getArgParam(0).getCCTerm(); + // this trigger fires on a selector/tester, whose argument is the datatype value (never numeric, so offset-free); + // the cast asserts that no-offset assumption at runtime + final CCTerm appArg = (CCTerm) appTerm.getArgParam(0); if (appArg != mArg) { reason = new SymmetricPair[] { new SymmetricPair<>(appArg, mArg) diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/DataTypeTheory.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/DataTypeTheory.java index 43ea6c7f3..5b66a64eb 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/DataTypeTheory.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/DataTypeTheory.java @@ -213,7 +213,7 @@ public Clause checkpoint() { final ApplicationTerm at = (ApplicationTerm) t.mFlatTerm; final CCAppTerm trueIsApp = (CCAppTerm) t; if (at.getFunction().getName() == "is") { - final CCTerm argRep = trueIsApp.getArgParam(0).getCCTerm().getRepresentative(); + final CCTerm argRep = ((CCTerm) trueIsApp.getArgParam(0)).getRepresentative(); if (!visited.containsKey(argRep)) { visited.put(argRep, trueIsApp); addConstructorLemma(trueIsApp); @@ -228,8 +228,9 @@ public Clause checkpoint() { final ArrayList> reason = new ArrayList<>(); reason.add(new SymmetricPair<>(prevIsApp, trueCC)); reason.add(new SymmetricPair<>(trueIsApp, trueCC)); - if (prevIsApp.getArgParam(0).getCCTerm() != trueIsApp.getArgParam(0).getCCTerm()) { - reason.add(new SymmetricPair<>(prevIsApp.getArgParam(0).getCCTerm(), trueIsApp.getArgParam(0).getCCTerm())); + if ((CCTerm) prevIsApp.getArgParam(0) != (CCTerm) trueIsApp.getArgParam(0)) { + reason.add(new SymmetricPair<>((CCTerm) prevIsApp.getArgParam(0), + (CCTerm) trueIsApp.getArgParam(0))); } final Term[] testers = new Term[2]; testers[0] = prevIsApp.mFlatTerm; @@ -252,7 +253,7 @@ public Clause checkpoint() { if (cct instanceof CCAppTerm) { final CCAppTerm appTerm = (CCAppTerm) cct; if (appTerm.getFunctionSymbol().getName().equals(SMTLIBConstants.IS)) { - final CCTerm arg = appTerm.getArgParam(0).getCCTerm(); + final CCTerm arg = (CCTerm) appTerm.getArgParam(0); falseIsFuns.putIfAbsent(arg.getRepresentative(), new LinkedHashSet<>()); falseIsFuns.get(arg.getRepresentative()).add(appTerm); } @@ -279,7 +280,7 @@ public Clause checkpoint() { for (final Constructor consName : dt.getConstructors()) { final CCAppTerm isFun = isIndices.get(consName.getName()); testers[i++] = isFun.mFlatTerm; - final CCTerm arg = isFun.getArgParam(0).getCCTerm(); + final CCTerm arg = (CCTerm) isFun.getArgParam(0); reason.add(new SymmetricPair<>(isFun, falseCC)); if (firstArg == null) { firstArg = arg; @@ -423,7 +424,7 @@ public Clause finalCheck() { final CCAppTerm appTerm = (CCAppTerm) ccTerm; final FunctionSymbol fs = appTerm.getFunctionSymbol(); if (fs.isSelector() || fs.getName().equals(SMTLIBConstants.IS)) { - final CCTerm argTerm = appTerm.getArgParam(0).getCCTerm(); + final CCTerm argTerm = (CCTerm) appTerm.getArgParam(0); final CCTerm consTerm = argTerm.getRepresentative().getSharedTerm(); if (consTerm != null) { final ApplicationTerm consApp = (ApplicationTerm) consTerm.getFlatTerm(); @@ -627,7 +628,7 @@ private Clause buildCycleConflict(final CCTerm currentTerm, final Deque // Get the corresponding tester or create it if it does not exists. // If it exists, the corresponding tester is true. final CCAppTerm selectTerm = (CCAppTerm) currentAsChild; - prevAsParent = selectTerm.getArgParam(0).getCCTerm(); + prevAsParent = (CCTerm) selectTerm.getArgParam(0); final FunctionSymbol selectorFunc = selectTerm.getFunctionSymbol(); final Constructor cons = getConstructor(selectorFunc); final Term isTerm = mTheory.term(mTheory.getFunctionWithResult(SMTLIBConstants.IS, new String[] { cons.getName() }, @@ -730,7 +731,7 @@ public void recheckTrigger() { ApplicationTerm constructor = null; final CCAppTerm checkTerm = iter.next(); final FunctionSymbol selectorOrTester = checkTerm.getFunctionSymbol(); - final CCTerm selectOrIsArg = checkTerm.getArgParam(0).getCCTerm(); + final CCTerm selectOrIsArg = (CCTerm) checkTerm.getArgParam(0); assert selectorOrTester.isSelector() || selectorOrTester.getName().equals(SMTLIBConstants.IS); for (final CCTerm ct : selectOrIsArg.getRepresentative().mMembers) { if (ct.mFlatTerm instanceof ApplicationTerm && ((ApplicationTerm) ct.mFlatTerm).getFunction().isConstructor()) { @@ -832,7 +833,7 @@ public Object[] getStatistics() { */ private void addConstructorLemma(final CCAppTerm isTerm) { // check if there is already a constructor application equal to the argument - final CCTerm arg = isTerm.getArgParam(0).getCCTerm(); + final CCTerm arg = (CCTerm) isTerm.getArgParam(0); if (arg.getRepresentative().getSharedTerm() != null) { // We don't care which constructor it is. If it's the wrong constructor // there should already be a trigger that set the isTerm to false. From 497b19f548a53205715d3543b9afdeacabf46ece Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Tue, 16 Jun 2026 21:09:43 +0200 Subject: [PATCH 27/75] CC offset equalities: offset-aware datatype model building DataTypeTheory.ConstrTerm now stores its arguments as CCParameter[] instead of CCTerm[], so a numeric constructor field keeps its offset (e.g. cons(x+5)) and the field's model value is computed offset-aware via ModelBuilder.getModelValue(CCParameter). The model-assembly loop reads the value through the parameter and only recurses on the offset-free representative for still-unresolved datatype-typed fields (numeric fields are resolved in an earlier SCC). Previously the field's offset was dropped, giving e.g. (fst p) = y instead of y + 5. Selector-sourced arguments are offset-free already, so they are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../theory/cclosure/DataTypeTheory.java | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/DataTypeTheory.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/DataTypeTheory.java index 5b66a64eb..7a9ddd2b5 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/DataTypeTheory.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/DataTypeTheory.java @@ -942,9 +942,13 @@ private static boolean isConstructorApp(Term term) { private class ConstrTerm { FunctionSymbol mConstr; - CCTerm[] mArguments; + /** + * The constructor's arguments as {@link CCParameter}s, so a numeric field's offset (e.g. the {@code +5} in + * {@code cons(x+5)}) is kept and shows up in the model value. {@code null} entries are holes (no known value). + */ + CCParameter[] mArguments; - public ConstrTerm(FunctionSymbol constr, CCTerm[] args) { + public ConstrTerm(FunctionSymbol constr, CCParameter[] args) { mConstr = constr; mArguments = args; } @@ -980,14 +984,15 @@ public void fillInModel(ModelBuilder modelBuilder, List sorts, LinkedHashM if (sharedTerm instanceof CCAppTerm) { final CCAppTerm constrAppTerm = (CCAppTerm) sharedTerm; final FunctionSymbol constr = constrAppTerm.getFunctionSymbol(); - final CCTerm[] args = new CCTerm[constrAppTerm.getArgCount()]; + // keep each argument as a CCParameter so a numeric field's offset is preserved + final CCParameter[] args = new CCParameter[constrAppTerm.getArgCount()]; for (int i = 0; i < args.length; i++) { - args[i] = constrAppTerm.getArgParam(i).getCCTerm(); + args[i] = constrAppTerm.getArgParam(i); } valueMap.put(ct, new ConstrTerm(constr, args)); } else { final ApplicationTerm appTerm = (ApplicationTerm) sharedTerm.getFlatTerm(); - valueMap.put(ct, new ConstrTerm(appTerm.getFunction(), new CCTerm[0])); + valueMap.put(ct, new ConstrTerm(appTerm.getFunction(), new CCParameter[0])); } } else { final Map selectorsAndTester = getSelectorsAndTesters(ct); @@ -995,7 +1000,8 @@ public void fillInModel(ModelBuilder modelBuilder, List sorts, LinkedHashM // we can use any constructor for which no tester exists. We use the first one. final String[] selectors = constr.getSelectors(); final Sort[] argSorts = new Sort[selectors.length]; - final CCTerm[] args = new CCTerm[selectors.length]; + // a selector application's value already is the field value (no structural offset) + final CCParameter[] args = new CCParameter[selectors.length]; for (int i = 0; i < selectors.length; i++) { final FunctionSymbol selector = mTheory.getFunction(selectors[i], sort); argSorts[i] = selector.getReturnSort(); @@ -1033,14 +1039,17 @@ public void fillInModel(ModelBuilder modelBuilder, List sorts, LinkedHashM boolean undefined = false; boolean hasHole = false; for (int i = 0; i < argModels.length; i++) { - CCTerm arg = constrTerm.mArguments[i]; + final CCParameter arg = constrTerm.mArguments[i]; if (arg != null) { - arg = arg.getRepresentative(); + // offset-aware: a numeric field cons(.., y+5, ..) contributes value(y) + 5 argModels[i] = modelBuilder.getModelValue(arg); if (argModels[i] == null) { - assert valueMap.containsKey(arg); + // only datatype-typed fields can be unresolved here (numeric deps come first and are + // offset-free w.r.t. their own representative); recurse on the field's representative + final CCTerm argRep = arg.getCCTerm().getRepresentative(); + assert valueMap.containsKey(argRep); path.add(ct); - todoStack.addLast(arg); + todoStack.addLast(argRep); undefined = true; break; } From b23521c0e82eca36bb966ac78fcd0fb58de53b85 Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Tue, 16 Jun 2026 21:13:52 +0200 Subject: [PATCH 28/75] CC offset equalities: include offset in CCEquality.getSMTFormula getSMTFormula returned (= lhs rhs) and dropped mOffset, so an offset equality lhs == rhs + k was translated as lhs == rhs. With model-check-mode this made the checker evaluate e.g. 0 == y instead of 0 == y + (-3) and report a spurious "Literal not satisfied" FATAL (the printed model values were already correct). Emit (= lhs (+ rhs offset)) when the offset is non-zero. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ultimate/smtinterpol/theory/cclosure/CCEquality.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCEquality.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCEquality.java index 4e0ad59ae..5eb95bbcf 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCEquality.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCEquality.java @@ -101,7 +101,13 @@ public void removeLASharedData() { @Override public Term getSMTFormula(final Theory smtTheory) { - return smtTheory.term("=", mLhs.getFlatTerm(), mRhs.getFlatTerm()); + final Term lhs = mLhs.getFlatTerm(); + final Term rhs = mRhs.getFlatTerm(); + if (mOffset.equals(Rational.ZERO)) { + return smtTheory.term("=", lhs, rhs); + } + // this equality states lhs == rhs + mOffset + return smtTheory.term("=", lhs, smtTheory.term("+", rhs, mOffset.toTerm(rhs.getSort()))); } @Override From 33f6582a30da8b2b01ad6a76bb0aa156cc4dadae Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Tue, 16 Jun 2026 21:19:44 +0200 Subject: [PATCH 29/75] Add system tests for offset-equality model construction Two QF model benchmarks (model-check-mode, :status sat) exercising the offset paths: offset_lia (congruence offset equality f(y+5)) and offset_datatype (an offset equality inside a datatype constructor, (mk (+ y 5) 0)). Co-Authored-By: Claude Opus 4.8 (1M context) --- SMTInterpolTest/test/model/offset_datatype.smt2 | 14 ++++++++++++++ SMTInterpolTest/test/model/offset_lia.smt2 | 12 ++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 SMTInterpolTest/test/model/offset_datatype.smt2 create mode 100644 SMTInterpolTest/test/model/offset_lia.smt2 diff --git a/SMTInterpolTest/test/model/offset_datatype.smt2 b/SMTInterpolTest/test/model/offset_datatype.smt2 new file mode 100644 index 000000000..0213d5096 --- /dev/null +++ b/SMTInterpolTest/test/model/offset_datatype.smt2 @@ -0,0 +1,14 @@ +; Model construction with an offset equality inside a datatype constructor: +; the numeric field of (mk (+ y 5) 0) must keep its +5 offset in the model, so +; (fst p) evaluates to y + 5 = 8 rather than y = 3. +(set-option :produce-models true) +(set-option :model-check-mode true) +(set-info :status sat) +(set-logic QF_UFDTLIA) +(declare-datatype Pair ((mk (fst Int) (snd Int)))) +(declare-fun p () Pair) +(declare-fun y () Int) +(assert (= p (mk (+ y 5) 0))) +(assert (= y 3)) +(check-sat) +(get-model) diff --git a/SMTInterpolTest/test/model/offset_lia.smt2 b/SMTInterpolTest/test/model/offset_lia.smt2 new file mode 100644 index 000000000..716b519d2 --- /dev/null +++ b/SMTInterpolTest/test/model/offset_lia.smt2 @@ -0,0 +1,12 @@ +; Model construction with a congruence offset equality: f(y+5) shares the +; offset-free CCTerm y plus a +5 offset, and y is fixed to 3 in arithmetic. +(set-option :produce-models true) +(set-option :model-check-mode true) +(set-info :status sat) +(set-logic QF_UFLIA) +(declare-fun y () Int) +(declare-fun f (Int) Int) +(assert (= (f (+ y 5)) 0)) +(assert (= y 3)) +(check-sat) +(get-model) From b32334c4ba74ec5c4bad49c685e911b7b1249aec Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Tue, 16 Jun 2026 21:37:42 +0200 Subject: [PATCH 30/75] CC offset equalities: offset-aware datatype dt-project / dt-injective lemmas Per the proof rules (doc/proof/datatypes.tex), the only datatype operands that can be numeric are constructor arguments, so offsets only enter the propagated equality of dt-project (s_ij(u) ~ v_j) and dt-injective (a_k ~ b_k); every reason and the mainEq of the other rules stay offset-free. DataTypeLemma.mMainEquality becomes a SymmetricPair, and the lemma producers read the constructor field via CCAppTerm.getArgParam(i) (keeping its offset) and compare values with sameValueAs instead of comparing offset-free representatives. processPendingLemmas encodes the offset into the propagated equality via createEquality(t1, t2, offset, ...) and treats a same-term / different-offset main equality as a trivial conflict. Reasons remain SymmetricPair (offset-free) and computePath already collects offset edges for clause extraction, so the conflict/propagation clauses are correct without proof generation. Proof annotation still pairs on the offset-free terms (offset-aware datatype proofs are deferred, see the CongruencePath TODO). Adds test/datatype/offset_lemmas.smt2 exercising both rules over a numeric field. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../theory/cclosure/CongruencePath.java | 6 +- .../theory/cclosure/DTReverseTrigger.java | 8 ++- .../theory/cclosure/DataTypeLemma.java | 16 ++++-- .../theory/cclosure/DataTypeTheory.java | 56 ++++++++++++------- .../test/datatype/offset_lemmas.smt2 | 15 +++++ 5 files changed, 72 insertions(+), 29 deletions(-) create mode 100644 SMTInterpolTest/test/datatype/offset_lemmas.smt2 diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java index f5f849edb..761dd844c 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java @@ -392,7 +392,11 @@ public Clause computeDTLemma(final CCEquality propagatedEq, final DataTypeLemma } final Clause c = new Clause(negLits); if (produceProofs) { - final SymmetricPair diseq = lemma.getMainEquality(); + // TODO offset equalities: the proof annotation still uses the offset-free terms of the propagated + // equality; offset-aware datatype proofs (pairing on the CCParameter) are not done yet. + final SymmetricPair mainEq = lemma.getMainEquality(); + final SymmetricPair diseq = mainEq == null ? null + : new SymmetricPair<>(mainEq.getFirst().getCCTerm(), mainEq.getSecond().getCCTerm()); c.setProof(new LeafNode(LeafNode.THEORY_DT, new CCAnnotation(diseq, mAllPaths, lemma))); } return c; diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/DTReverseTrigger.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/DTReverseTrigger.java index 181505db6..741070ede 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/DTReverseTrigger.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/DTReverseTrigger.java @@ -97,7 +97,7 @@ public void activate(final CCAppTerm appTerm, final boolean isFresh) { } else { truthValue = mClausifier.getTheory().mFalse; } - final SymmetricPair mainEq = new SymmetricPair<>(appTerm, mClausifier.getCCTerm(truthValue)); + final SymmetricPair mainEq = new SymmetricPair<>(appTerm, mClausifier.getCCTerm(truthValue)); final DataTypeLemma lemma = new DataTypeLemma(RuleKind.DT_TESTER, mainEq, reason, mArg); mDTTheory.addPendingLemma(lemma); if (isFresh) { @@ -110,8 +110,10 @@ public void activate(final CCAppTerm appTerm, final boolean isFresh) { final Constructor c = argDT.getConstructor(argAT.getFunction().getName()); for (int i = 0; i < c.getSelectors().length; i++) { if (mFunctionSymbol.getName() == c.getSelectors()[i]) { - final SymmetricPair mainEq = new SymmetricPair<>(appTerm, - mClausifier.getCCTerm(argAT.getParameters()[i])); + // mArg is the constructor application; read field i as a CCParameter so a numeric field keeps + // its offset, making the propagated equality value(sel(u)) == value(field). + final CCParameter mainArg = ((CCAppTerm) mArg).getArgParam(i); + final SymmetricPair mainEq = new SymmetricPair<>(appTerm, mainArg); final DataTypeLemma lemma = new DataTypeLemma(RuleKind.DT_PROJECT, mainEq, reason, mArg); mDTTheory.addPendingLemma(lemma); if (isFresh) { diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/DataTypeLemma.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/DataTypeLemma.java index d2e1c644c..031cb8938 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/DataTypeLemma.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/DataTypeLemma.java @@ -9,7 +9,13 @@ public class DataTypeLemma { private final RuleKind mRule; - private final SymmetricPair mMainEquality; + /** + * The propagated equality as a pair of {@link CCParameter}s, i.e. the equality of the two values + * {@code first.getCCTerm() + first.getOffset() == second.getCCTerm() + second.getOffset()}. For all rules except + * dt-project and dt-injective both sides are offset-free (a bare {@link CCTerm}); those two carry the offset of a + * numeric constructor argument. {@code null} for conflict lemmas that prove no equality. + */ + private final SymmetricPair mMainEquality; private final SymmetricPair[] mReason; private final Object[] mAnnotation; @@ -20,7 +26,7 @@ public class DataTypeLemma { * @param mainEquality the propagated equality {@code u = C(s1(u),...,sn(u))}. * @param reason the equality isC(u) = true. */ - public DataTypeLemma(final RuleKind rule, final SymmetricPair mainEquality, + public DataTypeLemma(final RuleKind rule, final SymmetricPair mainEquality, final SymmetricPair[] reason) { mRule = rule; mReason = reason; @@ -37,7 +43,7 @@ public DataTypeLemma(final RuleKind rule, final SymmetricPair mainEquali * @param reason the equalities to prove u = consTerm * @param consTerm1 the constructor {@code C(a1,...,an)} to which u is equal. */ - public DataTypeLemma(final RuleKind rule, final SymmetricPair mainEquality, + public DataTypeLemma(final RuleKind rule, final SymmetricPair mainEquality, final SymmetricPair[] reason, final CCTerm consTerm) { assert rule == RuleKind.DT_PROJECT || rule == RuleKind.DT_TESTER; mRule = rule; @@ -55,7 +61,7 @@ public DataTypeLemma(final RuleKind rule, final SymmetricPair mainEquali * @param consTerm1 the first constructor {@code C(a1,...,an)} * @param consTerm2 the second constructor {@code C(b1,...,bn)} */ - public DataTypeLemma(final RuleKind rule, final SymmetricPair mainEquality, + public DataTypeLemma(final RuleKind rule, final SymmetricPair mainEquality, final SymmetricPair[] reason, final CCTerm consTerm1, final CCTerm consTerm2) { assert rule == RuleKind.DT_INJECTIVE; mRule = rule; @@ -102,7 +108,7 @@ public RuleKind getRule() { return mRule; } - public SymmetricPair getMainEquality() { + public SymmetricPair getMainEquality() { return mMainEquality; } diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/DataTypeTheory.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/DataTypeTheory.java index 7a9ddd2b5..354299296 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/DataTypeTheory.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/DataTypeTheory.java @@ -35,6 +35,7 @@ import de.uni_freiburg.informatik.ultimate.logic.ApplicationTerm; import de.uni_freiburg.informatik.ultimate.logic.DataType; import de.uni_freiburg.informatik.ultimate.logic.DataType.Constructor; +import de.uni_freiburg.informatik.ultimate.logic.Rational; import de.uni_freiburg.informatik.ultimate.logic.FunctionSymbol; import de.uni_freiburg.informatik.ultimate.logic.SMTLIBConstants; import de.uni_freiburg.informatik.ultimate.logic.Sort; @@ -113,13 +114,22 @@ public void addPendingLemma(final DataTypeLemma lemma) { */ private Clause processPendingLemmas() { for (final DataTypeLemma lemma : mPendingLemmas) { - final SymmetricPair eq = lemma.getMainEquality(); + final SymmetricPair eq = lemma.getMainEquality(); if (eq == null) { // this is a conflict lemma, not a lemma that proves an equality. return computeClause(null, lemma); } - if (eq.getFirst().mRepStar != eq.getSecond().mRepStar) { - final CCEquality eqAtom = mCClosure.createEquality(eq.getFirst(), eq.getSecond(), false); + final CCParameter first = eq.getFirst(); + final CCParameter second = eq.getSecond(); + // the lemma propagates value(first) == value(second); only act if that is not already the case + if (!first.sameValueAs(second)) { + if (first.getCCTerm() == second.getCCTerm()) { + // same CCTerm but different offset: value-equality is trivially unsatisfiable -> conflict + return computeClause(null, lemma); + } + // encode the offset into the equality: value(first.cc) == value(second.cc) + offset + final Rational offset = second.getOffset().sub(first.getOffset()); + final CCEquality eqAtom = mCClosure.createEquality(first.getCCTerm(), second.getCCTerm(), offset, false); if (eqAtom == null) { // this is a trivial disequality, so we need to create a conflict. return computeClause(null, lemma); @@ -176,11 +186,15 @@ private void computeInjectiveDisjointLemmas(CCTerm lhs, CCTerm rhs) { @SuppressWarnings("unchecked") final SymmetricPair[] reason = new SymmetricPair[] { new SymmetricPair<>(lhs, rhs) }; if (lhsApp.getFunction() == rhsApp.getFunction()) { + // lhs/rhs are constructor applications, hence the CCAppTerms; read each argument as a CCParameter so + // a numeric field keeps its offset (cons(y+5) = cons(z) propagates the offset equality y+5 = z). + final CCAppTerm lhsCons = (CCAppTerm) lhs; + final CCAppTerm rhsCons = (CCAppTerm) rhs; for (int i = 0; i < lhsApp.getParameters().length; i++) { - final CCTerm lhsArg = mClausifier.getCCTerm(lhsApp.getParameters()[i]); - final CCTerm rhsArg = mClausifier.getCCTerm(rhsApp.getParameters()[i]); - if (rhsArg.mRepStar != lhsArg.mRepStar) { - final SymmetricPair eqPair = new SymmetricPair<>(lhsArg, rhsArg); + final CCParameter lhsArg = lhsCons.getArgParam(i); + final CCParameter rhsArg = rhsCons.getArgParam(i); + if (!lhsArg.sameValueAs(rhsArg)) { + final SymmetricPair eqPair = new SymmetricPair<>(lhsArg, rhsArg); // dt_injective: cons(args1) != cons(args2) or args1[i] == args2[i] addPendingLemma(new DataTypeLemma(RuleKind.DT_INJECTIVE, eqPair, reason, lhs, rhs)); } @@ -403,11 +417,11 @@ public Clause finalCheck() { mCClosure.getLogger().error("Unpropagated equality on different conses"); computeInjectiveDisjointLemmas(consTerm, member); } else { - // check we propagated all equalities between constructor arguments. + // check we propagated all equalities between constructor arguments (offset-aware). for (int i = 0; i < memberAt.getParameters().length; i++) { - final CCTerm consArg = mClausifier.getCCTerm(consAt.getParameters()[i]); - final CCTerm memArg = mClausifier.getCCTerm(memberAt.getParameters()[i]); - if (memArg.mRepStar != consArg.mRepStar) { + final CCParameter consArg = ((CCAppTerm) consTerm).getArgParam(i); + final CCParameter memArg = ((CCAppTerm) member).getArgParam(i); + if (!memArg.sameValueAs(consArg)) { mCClosure.getLogger().error("Unpropagated constructor argument equality"); computeInjectiveDisjointLemmas(consTerm, member); } @@ -441,7 +455,7 @@ public Clause finalCheck() { @SuppressWarnings("unchecked") final SymmetricPair[] reason = consTerm == argTerm ? new SymmetricPair[0] : new SymmetricPair[] { new SymmetricPair<>(consTerm, argTerm) }; - final SymmetricPair mainEq = new SymmetricPair<>(ccTerm, truthCC); + final SymmetricPair mainEq = new SymmetricPair<>(ccTerm, truthCC); final DataTypeLemma lemma = new DataTypeLemma(RuleKind.DT_TESTER, mainEq, reason, consTerm); addPendingLemma(lemma); } @@ -451,13 +465,15 @@ public Clause finalCheck() { final String[] allSelectorNames = constructor.getSelectors(); for (int i = 0; i < allSelectorNames.length; i++) { if (allSelectorNames[i].equals(fs.getName())) { - final CCTerm consArg = ((CCAppTerm) consTerm).getArgParam(i).getCCTerm(); - if (ccTerm.getRepresentative() != consArg.getRepresentative()) { + // the field may be numeric, so keep its offset; ccTerm (the selector application) is + // offset-free, and the propagated equality is value(ccTerm) == value(field). + final CCParameter consArg = ((CCAppTerm) consTerm).getArgParam(i); + if (!ccTerm.sameValueAs(consArg)) { mCClosure.getLogger().error("Unpropagated selector of constructor"); @SuppressWarnings("unchecked") final SymmetricPair[] reason = consTerm == argTerm ? new SymmetricPair[0] : new SymmetricPair[] { new SymmetricPair<>(consTerm, argTerm) }; - final SymmetricPair mainEq = new SymmetricPair<>(ccTerm, consArg); + final SymmetricPair mainEq = new SymmetricPair<>(ccTerm, consArg); final DataTypeLemma lemma = new DataTypeLemma(RuleKind.DT_PROJECT, mainEq, reason, consTerm); addPendingLemma(lemma); @@ -756,9 +772,9 @@ public void recheckTrigger() { assert c.getName().equals(constructor.getFunction().getName()); for (int i = 0; i < c.getSelectors().length; i++) { if (selName.equals(c.getSelectors()[i])) { - final CCTerm arg = mClausifier.getCCTerm(constructor.getParameters()[i]); - if (arg.mRepStar != checkTerm.mRepStar) { - final SymmetricPair provedEq = new SymmetricPair<>(checkTerm, arg); + final CCParameter arg = ((CCAppTerm) constructorCCTerm).getArgParam(i); + if (!checkTerm.sameValueAs(arg)) { + final SymmetricPair provedEq = new SymmetricPair<>(checkTerm, arg); final DataTypeLemma lemma = new DataTypeLemma(RuleKind.DT_PROJECT, provedEq, reason, constructorCCTerm); addPendingLemma(lemma); @@ -776,7 +792,7 @@ public void recheckTrigger() { } final CCTerm ccTruthValue = mClausifier.getCCTerm(truthValue); if (ccTruthValue.mRepStar != checkTerm.mRepStar) { - final SymmetricPair provedEq = new SymmetricPair<>(checkTerm, ccTruthValue); + final SymmetricPair provedEq = new SymmetricPair<>(checkTerm, ccTruthValue); final DataTypeLemma lemma = new DataTypeLemma(RuleKind.DT_TESTER, provedEq, reason, constructorCCTerm); addPendingLemma(lemma); @@ -860,7 +876,7 @@ private void addConstructorLemma(final CCAppTerm isTerm) { final Sort consType = c.needsReturnOverload() ? dtTerm.getSort() : null; final Term consTerm = mTheory.term(consName, null, consType, selectorTerms); final CCTerm consCCTerm = mClausifier.createCCTerm(consTerm, SourceAnnotation.EMPTY_SOURCE_ANNOT); - final SymmetricPair eq = new SymmetricPair<>(arg, consCCTerm); + final SymmetricPair eq = new SymmetricPair<>(arg, consCCTerm); @SuppressWarnings("unchecked") final DataTypeLemma lemma = new DataTypeLemma(RuleKind.DT_CONSTRUCTOR, eq, new SymmetricPair[] { new SymmetricPair<>(isTerm, mClausifier.getCCTerm(mTheory.mTrue)) }); diff --git a/SMTInterpolTest/test/datatype/offset_lemmas.smt2 b/SMTInterpolTest/test/datatype/offset_lemmas.smt2 new file mode 100644 index 000000000..976afb5ee --- /dev/null +++ b/SMTInterpolTest/test/datatype/offset_lemmas.smt2 @@ -0,0 +1,15 @@ +; Datatype lemmas over a numeric constructor field must respect offset equalities. +; dt-project: from u = mk(y+5) the selector f(u) must equal y+5 (not y). +; dt-injective: from mk(y+5) = mk(z) the argument equality y+5 = z must be propagated. +(set-info :smt-lib-version 2.6) +(set-logic QF_UFDTLIA) +(set-info :status unsat) +(declare-datatype P ((mk (f Int)))) +(declare-fun u () P) +(declare-fun y () Int) +(declare-fun z () Int) +(assert (= u (mk (+ y 5)))) +(assert (= (mk (+ y 5)) (mk z))) +; f(u) = y+5 (dt-project) and z = y+5 (dt-injective); both contradict the disjunction below +(assert (or (not (= (f u) (+ y 5))) (not (= z (+ y 5))))) +(check-sat) From 63e3a9088ce473a885f3d8765b77d507d43b69f0 Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Tue, 16 Jun 2026 22:03:55 +0200 Subject: [PATCH 31/75] CC offset equalities: carry CCParameters with offsets in CCAnnotation paths Proof production step 1 (cclosure only): the conflict/congruence paths now record each node as a CCParameter with its offset, so a path reads e.g. x+2 = y+6 = z+8 when x = y+4 and y = z+2. Offsets are derived from mOffsetToRep relative to the path's anchor (the first node), which is intrinsic and therefore stable under subpath concatenation; computeCCPath anchors each argument subpath at the argument's offset (getArgParam). CCAnnotation now stores the CCParameter paths (getParamPaths) and the CCParameter diseq (getDiseqParam), and additionally keeps the offset-free CCTerm views (getPaths/getDiseq) as stable objects so the current CCProofGenerator is unchanged. CongruencePath threads CCParameters through computePath / computePathNonRecursive / computePathTo / mTodo / mVisited; WeakCongruencePath passes its (offset-free) array diseqs as CCParameters. This only prepares the data: offset equalities are still disabled while proof generation is on (CClosure.createOffsetEqualities), so the offsets are 0 in practice for now. Regression-free (SystemTest 420->1, only pre-existing buggy001; proof-check full level passes for UF/datatype/array). Next: make CCProofGenerator consume the offsets, then enable offsets under proofs. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../theory/cclosure/CCAnnotation.java | 73 ++++++++++------ .../theory/cclosure/CongruencePath.java | 84 ++++++++++++------- .../theory/cclosure/WeakCongruencePath.java | 11 ++- 3 files changed, 109 insertions(+), 59 deletions(-) diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAnnotation.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAnnotation.java index fa35d0896..04deea06f 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAnnotation.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAnnotation.java @@ -225,48 +225,63 @@ public String getKind() { final RuleKind mRule; /** - * The disequality of the theory lemma. This is the only positive atom in the - * generated theory clause. If this is null, then the first and last element in - * the main paths are distinct terms. + * The disequality of the theory lemma as a pair of {@link CCParameter}s (so a numeric side keeps its offset). This + * is the only positive atom in the generated theory clause. If this is null, then the first and last element in the + * main paths are distinct terms. + */ + final SymmetricPair mDiseqParam; + + /** + * The offset-free view of {@link #mDiseqParam}, kept as a stable object for the current (offset-free) proof + * generator, which compares it by identity. */ final SymmetricPair mDiseq; /** - * A sequence of paths. The main path with index 0 must always exist and explain - * the diseq. The other paths must be in such an order that later paths explain + * A sequence of paths, as {@link CCParameter}s with offsets (e.g. {@code x+2, y+6, z+8}). The main path with index 0 + * must always exist and explain the diseq. The other paths must be in such an order that later paths explain * congruences on earlier. */ + final CCParameter[][] mParamPaths; + + /** + * The offset-free view of {@link #mParamPaths}, used by the current proof generator. + */ final CCTerm[][] mPaths; final CCTerm[] mWeakIndices; final DataTypeLemma mDTLemma; - public CCAnnotation(final SymmetricPair diseq, final Collection paths, final RuleKind rule) { - mDiseq = diseq; - mPaths = new CCTerm[paths.size()][]; - mWeakIndices = new CCTerm[mPaths.length]; - int i = 0; - for (final SubPath p : paths) { - mPaths[i] = p.getTerms(); - mWeakIndices[i] = p instanceof WeakSubPath ? ((WeakSubPath) p).getIndex() : null; - i++; - } - mRule = rule; - mDTLemma = null; + private static SymmetricPair offsetFreeDiseq(final SymmetricPair diseq) { + return diseq == null ? null + : new SymmetricPair<>(diseq.getFirst().getCCTerm(), diseq.getSecond().getCCTerm()); + } + + public CCAnnotation(final SymmetricPair diseq, final Collection paths, final RuleKind rule) { + this(diseq, paths, rule, null); + } + + public CCAnnotation(final SymmetricPair diseq, final Collection paths, + final DataTypeLemma lemma) { + this(diseq, paths, lemma.getRule(), lemma); } - public CCAnnotation(final SymmetricPair diseq, final Collection paths, final DataTypeLemma lemma) { - mDiseq = diseq; + private CCAnnotation(final SymmetricPair diseq, final Collection paths, final RuleKind rule, + final DataTypeLemma lemma) { + mDiseqParam = diseq; + mDiseq = offsetFreeDiseq(diseq); + mParamPaths = new CCParameter[paths.size()][]; mPaths = new CCTerm[paths.size()][]; mWeakIndices = new CCTerm[mPaths.length]; int i = 0; for (final SubPath p : paths) { + mParamPaths[i] = p.getParams(); mPaths[i] = p.getTerms(); mWeakIndices[i] = p instanceof WeakSubPath ? ((WeakSubPath) p).getIndex() : null; i++; } - mRule = lemma.getRule(); + mRule = rule; mDTLemma = lemma; } @@ -274,10 +289,20 @@ public SymmetricPair getDiseq() { return mDiseq; } + /** The disequality with offsets. */ + public SymmetricPair getDiseqParam() { + return mDiseqParam; + } + public CCTerm[][] getPaths() { return mPaths; } + /** The paths with offsets. */ + public CCParameter[][] getParamPaths() { + return mParamPaths; + } + public CCTerm[] getWeakIndices() { return mWeakIndices; } @@ -305,8 +330,8 @@ public Term toTerm(final Clause clause, final ProofRules proofRules) { public String toString() { final StringBuilder sb = new StringBuilder(); sb.append('('); - sb.append(mDiseq); - for (int i = 0; i < mPaths.length; i++) { + sb.append(mDiseqParam); + for (int i = 0; i < mParamPaths.length; i++) { if (mWeakIndices[i] != null) { sb.append(" :weak ").append(mWeakIndices[i]).append(' '); } else { @@ -314,8 +339,8 @@ public String toString() { } sb.append("("); String comma = ""; - for (final CCTerm term : mPaths[i]) { - sb.append(comma).append(term); + for (final CCParameter param : mParamPaths[i]) { + sb.append(comma).append(param); comma = " "; } sb.append(")"); diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java index 761dd844c..09390be5e 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java @@ -25,6 +25,7 @@ import java.util.LinkedHashSet; import java.util.Set; +import de.uni_freiburg.informatik.ultimate.logic.Rational; import de.uni_freiburg.informatik.ultimate.smtinterpol.dpll.Clause; import de.uni_freiburg.informatik.ultimate.smtinterpol.dpll.Literal; import de.uni_freiburg.informatik.ultimate.smtinterpol.proof.LeafNode; @@ -56,22 +57,45 @@ public class CongruencePath { */ public static class SubPath { ArrayList mTermsOnPath; + /** The anchor (first node) of the path and its offset; the offset of every other node is derived from it. */ + final CCTerm mStart; + final Rational mStartOffset; - public SubPath(final CCTerm start) { + public SubPath(final CCParameter start) { this(start, true); } - public SubPath(final CCTerm start, final boolean produceProofs) { + public SubPath(final CCParameter start, final boolean produceProofs) { + mStart = start.getCCTerm(); + mStartOffset = start.getOffset(); if (produceProofs) { mTermsOnPath = new ArrayList<>(); - mTermsOnPath.add(start); + mTermsOnPath.add(mStart); } } + /** The offset-free terms on the path. */ public CCTerm[] getTerms() { return mTermsOnPath.toArray(new CCTerm[mTermsOnPath.size()]); } + /** + * The path nodes as {@link CCParameter}s. All nodes are in one congruence class, and the offsets are chosen so + * that every node has the same value as the anchor (the first node): {@code value(node) + offset == value(start) + * + startOffset}. So if {@code x = y+4} and {@code y = z+2}, a path anchored at {@code x+2} reads + * {@code x+2, y+6, z+8}. The relative offsets are intrinsic ({@code mOffsetToRep} differences), so this is stable + * under {@link #addSubPath} concatenation regardless of the appended pieces' own anchors. + */ + public CCParameter[] getParams() { + final CCParameter[] params = new CCParameter[mTermsOnPath.size()]; + for (int i = 0; i < params.length; i++) { + final CCTerm t = mTermsOnPath.get(i); + final Rational off = mStartOffset.add(mStart.mOffsetToRep).sub(t.mOffsetToRep); + params[i] = CCParameter.of(t, off); + } + return params; + } + public void addEntry(final CCTerm term, final CCEquality reason) { if (mTermsOnPath != null) { mTermsOnPath.add(term); @@ -102,9 +126,9 @@ public String toString() { } } - final HashMap,SubPath> mVisited; + final HashMap,SubPath> mVisited; final ArrayDeque mAllPaths; - final ArrayDeque> mTodo; + final ArrayDeque> mTodo; final Set mAllLiterals; public CongruencePath(final CClosure closure) { @@ -115,7 +139,7 @@ public CongruencePath(final CClosure closure) { mAllPaths = new ArrayDeque<>(); } - private CCAnnotation createAnnotation(final SymmetricPair diseq) { + private CCAnnotation createAnnotation(final SymmetricPair diseq) { return new CCAnnotation(diseq, mAllPaths, CCAnnotation.RuleKind.CONG); } @@ -149,12 +173,10 @@ private int computeDepth(CCTerm t) { * @param end the other function application term. */ private void computeCCPath(CCAppTerm start, CCAppTerm end) { - // TODO offset equalities: this pairs the offset-free argument terms, which is correct for clause extraction - // (there is at most one offset-equality edge between corresponding args, so computePathTo collects it), but - // loses the offset for the recorded path/annotation. To make offset proofs readable the annotation should - // carry the CCParameter (getArgParam(i)) rather than the bare CCTerm. + // Pair the argument values (CCParameters), so the recorded subpath for each argument is anchored at the + // argument's offset, e.g. f(x+2) congruent f(z+8) yields a subpath from x+2 to z+8. for (int i = 0; i < start.getArgCount(); i++) { - mTodo.addFirst(new SymmetricPair<>(start.getArgParam(i).getCCTerm(), end.getArgParam(i).getCCTerm())); + mTodo.addFirst(new SymmetricPair<>(start.getArgParam(i), end.getArgParam(i))); } } @@ -178,9 +200,10 @@ private void computeCCPath(CCAppTerm start, CCAppTerm end) { * @return the sub path from t to end, if proof production is enabled. * Without proof production, this returns null. */ - private SubPath computePathTo(CCTerm t, final CCTerm end) { + private SubPath computePathTo(final CCParameter startParam, final CCTerm end) { final SubPath path = - new SubPath(t, mClosure.isProofGenerationEnabled()); + new SubPath(startParam, mClosure.isProofGenerationEnabled()); + CCTerm t = startParam.getCCTerm(); CCTerm startCongruence = t; while (t != end) { if (t.mOldRep.mReasonLiteral != null) { @@ -220,21 +243,23 @@ private SubPath computePathTo(CCTerm t, final CCTerm end) { * @param left the left end of the congruence chain that should be evaluated. * @param right the right end of the congruence chain that should be evaluated. */ - SubPath computePathNonRecursive(final CCTerm left, final CCTerm right) { - /* check for and ignore trivial paths */ - if (left == right) { + SubPath computePathNonRecursive(final CCParameter left, final CCParameter right) { + final CCTerm leftTerm = left.getCCTerm(); + final CCTerm rightTerm = right.getCCTerm(); + /* check for and ignore trivial paths (the offsets coincide for a genuine congruence) */ + if (leftTerm == rightTerm) { return null; } - final SymmetricPair key = new SymmetricPair<>(left, right); + final SymmetricPair key = new SymmetricPair<>(left, right); if (mVisited.containsKey(key)) { return mVisited.get(key); } - int leftDepth = computeDepth(left); - int rightDepth = computeDepth(right); - CCTerm ll = left; - CCTerm rr = right; + int leftDepth = computeDepth(leftTerm); + int rightDepth = computeDepth(rightTerm); + CCTerm ll = leftTerm; + CCTerm rr = rightTerm; CCTerm llWithReason = ll, rrWithReason = rr; while (leftDepth > rightDepth) { if (ll.mOldRep.mReasonLiteral != null) { @@ -291,14 +316,14 @@ SubPath computePathNonRecursive(final CCTerm left, final CCTerm right) { * @param right * the right end of the congruence chain that should be evaluated. */ - public void computePath(final CCTerm left, final CCTerm right) { - final HashSet> added = new HashSet<>(); + public void computePath(final CCParameter left, final CCParameter right) { + final HashSet> added = new HashSet<>(); mTodo.add(new SymmetricPair<>(left, right)); while (!mTodo.isEmpty()) { - final SymmetricPair pathEnds = mTodo.removeFirst(); + final SymmetricPair pathEnds = mTodo.removeFirst(); // don't do anything for trivial paths - if (pathEnds.getFirst() == pathEnds.getSecond()) { + if (pathEnds.getFirst().getCCTerm() == pathEnds.getSecond().getCCTerm()) { continue; } @@ -392,12 +417,9 @@ public Clause computeDTLemma(final CCEquality propagatedEq, final DataTypeLemma } final Clause c = new Clause(negLits); if (produceProofs) { - // TODO offset equalities: the proof annotation still uses the offset-free terms of the propagated - // equality; offset-aware datatype proofs (pairing on the CCParameter) are not done yet. - final SymmetricPair mainEq = lemma.getMainEquality(); - final SymmetricPair diseq = mainEq == null ? null - : new SymmetricPair<>(mainEq.getFirst().getCCTerm(), mainEq.getSecond().getCCTerm()); - c.setProof(new LeafNode(LeafNode.THEORY_DT, new CCAnnotation(diseq, mAllPaths, lemma))); + // the main equality carries the offset of a numeric constructor field; CCAnnotation keeps both the + // CCParameter view (with offset) and the offset-free CCTerm view used by the current proof generator. + c.setProof(new LeafNode(LeafNode.THEORY_DT, new CCAnnotation(lemma.getMainEquality(), mAllPaths, lemma))); } return c; } diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/WeakCongruencePath.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/WeakCongruencePath.java index 8056f7fe9..162a31d4b 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/WeakCongruencePath.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/WeakCongruencePath.java @@ -95,7 +95,8 @@ public Clause computeSelectOverWeakEQ(final CCAppTerm select1, final CCAppTerm s computeWeakPath(a, b, i1, produceProofs); mAllPaths.addFirst(weakPath); - return generateClause(new SymmetricPair(select1, select2), produceProofs, RuleKind.READ_OVER_WEAKEQ); + return generateClause(new SymmetricPair(select1, select2), produceProofs, + RuleKind.READ_OVER_WEAKEQ); } public Clause computeSelectConstOverWeakEQ(final CCAppTerm select1, final CCAppTerm const2, final boolean produceProofs) { @@ -371,9 +372,11 @@ private void computeIndexDiseq(final CCParameter idx, final CCParameter idxFromS } } - private Clause generateClause(final SymmetricPair diseq, final boolean produceProofs, final RuleKind rule) { + private Clause generateClause(final SymmetricPair diseq, final boolean produceProofs, + final RuleKind rule) { if (diseq != null) { - final CCEquality eq = mArrayTheory.getCClosure().createEquality(diseq.getFirst(), diseq.getSecond(), false); + final CCEquality eq = mArrayTheory.getCClosure().createEquality(diseq.getFirst().getCCTerm(), + diseq.getSecond().getCCTerm(), false); if (eq != null) { // Note that it can actually happen that diseq is already in // the list of all literals (because it is an index assumption). @@ -394,7 +397,7 @@ private Clause generateClause(final SymmetricPair diseq, final boolean p return c; } - private CCAnnotation createAnnotation(final SymmetricPair diseq, final RuleKind rule) { + private CCAnnotation createAnnotation(final SymmetricPair diseq, final RuleKind rule) { return new CCAnnotation(diseq, mAllPaths, rule); } } From f6ecacfe1beb33e3099a5a6f742203139ab3ddf0 Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Tue, 16 Jun 2026 22:17:20 +0200 Subject: [PATCH 32/75] CC offset equalities: add CCParameter.getFlatTerm() (option-2 SMT encoding) A CCParameter now renders itself as an SMT-LIB term: a bare CCTerm returns its own flat term (offset zero, byte-identical to before), an OffsettedCCTerm builds (+ term offset). This is the agreed representation choice (a plain term when the offset is zero, the sum otherwise) and lets the proof generator emit a parameter uniformly via getFlatTerm() without a sort-dependent special case. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ultimate/smtinterpol/theory/cclosure/CCParameter.java | 8 ++++++++ .../smtinterpol/theory/cclosure/OffsettedCCTerm.java | 7 +++++++ 2 files changed, 15 insertions(+) diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCParameter.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCParameter.java index 968b3f55d..396f29646 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCParameter.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCParameter.java @@ -19,6 +19,7 @@ package de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure; import de.uni_freiburg.informatik.ultimate.logic.Rational; +import de.uni_freiburg.informatik.ultimate.logic.Term; /** * A value of the form {@code getCCTerm() + getOffset()}: a CCTerm together with a constant offset. This is what every @@ -45,6 +46,13 @@ public interface CCParameter { /** The constant offset added to the CCTerm; {@link Rational#ZERO} for a bare {@link CCTerm}. */ Rational getOffset(); + /** + * The SMT-LIB term denoting this value: the underlying term when the offset is zero, otherwise {@code (+ term + * offset)}. A bare {@link CCTerm} returns its own flat term unchanged, so offset-free uses are byte-identical; only + * a non-zero (necessarily numeric) offset builds the sum. + */ + Term getFlatTerm(); + /** The representative of the underlying CCTerm's congruence class. */ default CCTerm getRepresentative() { return getCCTerm().getRepresentative(); diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/OffsettedCCTerm.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/OffsettedCCTerm.java index 0605af3cc..746d0f459 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/OffsettedCCTerm.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/OffsettedCCTerm.java @@ -19,6 +19,7 @@ package de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure; import de.uni_freiburg.informatik.ultimate.logic.Rational; +import de.uni_freiburg.informatik.ultimate.logic.Term; /** * A {@link CCParameter} with a non-zero constant offset, i.e. the value {@code mTerm + mOffset}. Offset-free values are @@ -51,6 +52,12 @@ public Rational getOffset() { return mOffset; } + @Override + public Term getFlatTerm() { + final Term term = mTerm.getFlatTerm(); + return term.getTheory().term("+", term, mOffset.toTerm(term.getSort())); + } + @Override public int hashCode() { return mTerm.hashCode() * 31 + mOffset.hashCode(); From eac6718f99a6693bd4b3a9e094e134519cd4357d Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Tue, 16 Jun 2026 22:55:03 +0200 Subject: [PATCH 33/75] CC offset equalities: make CCProofGenerator offset-aware Thread CCParameter through the proof generator: IndexedPath nodes, ProofInfo.mLemmaDiseq and SelectEdge now carry CCParameters and are emitted via CCParameter.getFlatTerm() (so a path node x+2 prints as (+ x 2)), while the lookup maps (mEqualityLiterals/mPathProofMap/mAuxLiterals/...) stay keyed on the offset-free CCTerm pair (a small key() helper). This split is what makes the "weird" offset transitivity work: a path step x+2 -> y+4 matches the offset-free clause literal x = y+2, and the TRANS/CONG rule verifies the offset; congruence arguments that share an offset edge (f(x,x+2)=f(y+2,y+4)) resolve on that one shared literal, so no separate one-step bridge lemma is needed. The annotation is sourced from getParamPaths()/getDiseqParam(); array weak-path handling stays offset-free via getCCTerm() (offset-free array indices unchanged). Offsets are still gated off under proofs, so this is byte-identical offset-free (32 proof/model unit tests, SystemTest 420->1 only pre-existing buggy001). Validated with offsets by temporarily flipping the gate: UF/datatype offset transitivity and two-argument offset congruence (including a shared multi-step transitivity path) proof-check at :proof-level full. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../theory/cclosure/CCProofGenerator.java | 181 ++++++++++-------- 1 file changed, 106 insertions(+), 75 deletions(-) diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCProofGenerator.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCProofGenerator.java index 9abe2061e..ae0b05c99 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCProofGenerator.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCProofGenerator.java @@ -53,9 +53,10 @@ public class CCProofGenerator { */ private static class IndexedPath { private final CCTerm mIndex; - private final CCTerm[] mPath; + /** The path nodes as CCParameters (with offsets); a step's offset is justified by an offset equality. */ + private final CCParameter[] mPath; - public IndexedPath(final CCTerm index, final CCTerm[] path) { + public IndexedPath(final CCTerm index, final CCParameter[] path) { mIndex = index; mPath = path; } @@ -64,14 +65,20 @@ public CCTerm getIndex() { return mIndex; } - public CCTerm[] getPath() { + public CCParameter[] getPath() { return mPath; } - public SymmetricPair getPathEnds() { + /** The two path ends as a CCParameter pair (with offsets), i.e. the equality this path proves. */ + public SymmetricPair getPathEndParams() { return new SymmetricPair<>(mPath[0], mPath[mPath.length - 1]); } + /** The offset-free key of the path ends, used to look the path up among shared subpaths. */ + public SymmetricPair getPathEnds() { + return key(getPathEndParams()); + } + @Override public String toString() { return mIndex + ": " + Arrays.toString(mPath); @@ -90,7 +97,7 @@ public SelectEdge(final CCTerm left, final CCTerm right) { mRight = right; } - public SymmetricPair toSymmetricPair() { + public SymmetricPair toSymmetricPair() { return new SymmetricPair<>(mLeft, mRight); } @@ -131,9 +138,10 @@ private class ProofInfo { // Information needed to build the proof graph /** - * The equality this lemma proves. + * The equality this lemma proves, as a CCParameter pair (with offsets); the offset-free key (see + * {@link CCProofGenerator#key}) is used for the lookup maps. */ - private SymmetricPair mLemmaDiseq; + private SymmetricPair mLemmaDiseq; /** * The literals this lemma requires for the proof. */ @@ -159,7 +167,7 @@ public ProofInfo() { mNumVisitedParents = 0; } - public SymmetricPair getDiseq() { + public SymmetricPair getDiseq() { return mLemmaDiseq; } @@ -196,7 +204,7 @@ private void addLiteral(Literal literal) { mProofLiterals.add(new ProofLiteral(literal.getAtom().getSMTFormula(theory), literal.getSign() > 0)); } - private void addAuxLiteral(SymmetricPair equality, boolean positive) { + private void addAuxLiteral(SymmetricPair equality, boolean positive) { final Term eqTerm = addAuxEquality(equality); mProofLiterals.add(new ProofLiteral(eqTerm, positive)); } @@ -209,14 +217,15 @@ private void addSubProof(ProofInfo congruence) { /** * Collect the proof info for one path. */ - private boolean collectEquality(final SymmetricPair termPair) { + private boolean collectEquality(final SymmetricPair termPair) { + final SymmetricPair termKey = key(termPair); if (isEqualityLiteral(termPair)) { // equality literals are just added - addLiteral(mEqualityLiterals.get(termPair)); + addLiteral(mEqualityLiterals.get(termKey)); return true; - } else if (mPathProofMap.containsKey(termPair)) { + } else if (mPathProofMap.containsKey(termKey)) { // already created sub proof; add it - addSubProof(mPathProofMap.get(termPair)); + addSubProof(mPathProofMap.get(termKey)); return true; } else { // create congruence sub proof @@ -224,7 +233,7 @@ private boolean collectEquality(final SymmetricPair termPair) { if (congruence == null) { return false; } - mPathProofMap.put(termPair, congruence); + mPathProofMap.put(termKey, congruence); addSubProof(congruence); return true; } @@ -235,12 +244,12 @@ private boolean collectEquality(final SymmetricPair termPair) { */ private void collectStrongPath(final IndexedPath indexedPath) { assert (indexedPath.getIndex() == null); - final CCTerm[] path = indexedPath.getPath(); + final CCParameter[] path = indexedPath.getPath(); // Check cases (i) - (iv) for all term pairs. for (int i = 0; i < path.length - 1; i++) { - final CCTerm firstTerm = path[i]; - final CCTerm secondTerm = path[i + 1]; - final SymmetricPair termPair = new SymmetricPair<>(firstTerm, secondTerm); + final CCParameter firstTerm = path[i]; + final CCParameter secondTerm = path[i + 1]; + final SymmetricPair termPair = new SymmetricPair<>(firstTerm, secondTerm); if (!collectEquality(termPair)) { throw new IllegalArgumentException("Cannot explain term pair " + termPair.toString()); } @@ -251,7 +260,7 @@ private void collectSelectIndexEquality(final CCTerm select, final CCTerm pathIn if (ArrayTheory.isSelectTerm(select)) { final CCTerm index = ArrayTheory.getIndexFromSelect((CCAppTerm) select); if (index != pathIndex) { - if (!collectEquality(new SymmetricPair<>(pathIndex, index))) { + if (!collectEquality(new SymmetricPair(pathIndex, index))) { throw new AssertionError("Cannot find select index equality " + pathIndex + " = " + index); } } @@ -264,12 +273,15 @@ private void collectSelectIndexEquality(final CCTerm select, final CCTerm pathIn private void collectWeakPath(final IndexedPath indexedPath) { assert (indexedPath.getIndex() != null || mRule == RuleKind.WEAKEQ_EXT || mRule == RuleKind.CONST_WEAKEQ); final CCTerm pathIndex = indexedPath.getIndex(); - final CCTerm[] path = indexedPath.getPath(); - // Check cases (i) - (iv) for all term pairs. + final CCParameter[] path = indexedPath.getPath(); + // Check cases (i) - (iv) for all term pairs. Array path nodes are offset-free, so we use the structural + // CCTerms for the array cases and the CCParameters only for the equality (case i). for (int i = 0; i < path.length - 1; i++) { - final CCTerm firstTerm = path[i]; - final CCTerm secondTerm = path[i + 1]; - final SymmetricPair termPair = new SymmetricPair<>(firstTerm, secondTerm); + final CCParameter firstParam = path[i]; + final CCParameter secondParam = path[i + 1]; + final CCTerm firstTerm = firstParam.getCCTerm(); + final CCTerm secondTerm = secondParam.getCCTerm(); + final SymmetricPair termPair = new SymmetricPair<>(firstParam, secondParam); // Case (i) if (collectEquality(termPair)) { continue; @@ -289,20 +301,20 @@ private void collectWeakPath(final IndexedPath indexedPath) { continue; } final CCTerm storeIndex = ArrayTheory.getIndexFromStore((CCAppTerm) storeTerm); - final SymmetricPair indexPair = new SymmetricPair<>(pathIndex, storeIndex); + final SymmetricPair indexPair = new SymmetricPair<>(pathIndex, storeIndex); if (isDisequalityLiteral(indexPair)) { - addLiteral(mEqualityLiterals.get(indexPair)); + addLiteral(mEqualityLiterals.get(key(indexPair))); continue; } if (isTrivialDisequality(indexPair)) { - mTrivialDisequalities.add(indexPair); + mTrivialDisequalities.add(key(indexPair)); addAuxLiteral(indexPair, true); continue; } } // Case (iv) select if (mRule == RuleKind.WEAKEQ_EXT && pathIndex != null) { - final SelectEdge selectEdge = findSelectPath(termPair, pathIndex); + final SelectEdge selectEdge = findSelectPath(new SymmetricPair<>(firstTerm, secondTerm), pathIndex); if (selectEdge != null) { if (selectEdge.getLeft() != selectEdge.getRight()) { if (!collectEquality(selectEdge.toSymmetricPair())) { @@ -342,12 +354,21 @@ public String toString() { private LinkedHashSet> mTrivialDisequalities; private ProofRules mProofRules; + /** + * The offset-free key of a CCParameter equality. The lookup maps are keyed on the offset-free CCTerm pair, so a + * shared offset edge (e.g. x = y+2) is proven once and reused for every argument equality that follows from it + * (x = y+2, x+2 = y+4, ...). + */ + static SymmetricPair key(final SymmetricPair pair) { + return new SymmetricPair<>(pair.getFirst().getCCTerm(), pair.getSecond().getCCTerm()); + } + public CCProofGenerator(final CCAnnotation arrayAnnot) { mAnnot = arrayAnnot; mRule = arrayAnnot.mRule; - mIndexedPaths = new IndexedPath[arrayAnnot.getPaths().length]; + mIndexedPaths = new IndexedPath[arrayAnnot.getParamPaths().length]; for (int i = 0; i < mIndexedPaths.length; i++) { - mIndexedPaths[i] = new IndexedPath(arrayAnnot.getWeakIndices()[i], arrayAnnot.getPaths()[i]); + mIndexedPaths[i] = new IndexedPath(arrayAnnot.getWeakIndices()[i], arrayAnnot.getParamPaths()[i]); } } @@ -375,15 +396,15 @@ public Term toTerm(final Clause clause, final ProofRules proofRules) { // Collect the paths needed to prove the main disequality final ProofInfo mainInfo = findMainPaths(); - if (mAnnot.mDiseq != null) { - final SymmetricPair mainDiseq = mAnnot.mDiseq; + if (mAnnot.mDiseqParam != null) { + final SymmetricPair mainDiseq = mAnnot.mDiseqParam; if (!isDisequalityLiteral(mainDiseq)) { assert isTrivialDisequality(mainDiseq); - mTrivialDisequalities.add(mainDiseq); + mTrivialDisequalities.add(key(mainDiseq)); addAuxEquality(mainDiseq); } mainInfo.mLemmaDiseq = mainDiseq; - mPathProofMap.put(mainDiseq, mainInfo); + mPathProofMap.put(key(mainDiseq), mainInfo); } else { assert mAnnot.mRule == RuleKind.DT_CASES || mAnnot.mRule == RuleKind.DT_UNIQUE || mAnnot.mRule == RuleKind.DT_DISJOINT @@ -429,8 +450,9 @@ private void collectStrongEqualities() { // never a strong equality, even though its weak index is null. if (indexedPath.getIndex() == null && ((mRule != RuleKind.WEAKEQ_EXT && mRule != RuleKind.CONST_WEAKEQ) || i > 0)) { - final CCTerm[] path = indexedPath.getPath(); - final SymmetricPair pathEnds = new SymmetricPair<>(path[0], path[path.length - 1]); + final CCParameter[] path = indexedPath.getPath(); + final SymmetricPair pathEndParams = indexedPath.getPathEndParams(); + final SymmetricPair pathEnds = key(pathEndParams); if (mAllEqualities.add(pathEnds) && !mPathProofMap.containsKey(pathEnds)) { if (path.length == 2) { // A path of length 2 must be a congruence, otherwise we would not be able to explain it @@ -439,7 +461,7 @@ private void collectStrongEqualities() { mPathProofMap.put(pathEnds, congruence); } else { final ProofInfo pathInfo = new ProofInfo(); - pathInfo.mLemmaDiseq = pathEnds; + pathInfo.mLemmaDiseq = pathEndParams; pathInfo.mProofPaths = new IndexedPath[] { indexedPath }; pathInfo.collectStrongPath(indexedPath); mPathProofMap.put(pathEnds, pathInfo); @@ -514,7 +536,8 @@ private ProofInfo findMainPaths() { case DT_TESTER: case DT_UNIQUE: for (final SymmetricPair dependentEq : mAnnot.mDTLemma.getReason()) { - mainProof.collectEquality(dependentEq); + // datatype lemma reasons are offset-free + mainProof.collectEquality(new SymmetricPair(dependentEq.getFirst(), dependentEq.getSecond())); } mainProof.mProofPaths = new IndexedPath[0]; break; @@ -558,8 +581,9 @@ private ArrayList determineProofOrder(final ProofInfo mainInfo) { return proofOrder; } - private Term addAuxEquality(SymmetricPair equality) { - if (!mAuxLiterals.containsKey(equality)) { + private Term addAuxEquality(SymmetricPair equality) { + final SymmetricPair eqKey = key(equality); + if (!mAuxLiterals.containsKey(eqKey)) { final Theory theory = mProofRules.getTheory(); Term lhs = equality.getFirst().getFlatTerm(); Term rhs = equality.getSecond().getFlatTerm(); @@ -571,9 +595,9 @@ private Term addAuxEquality(SymmetricPair equality) { rhs = lhs; lhs = constantTerm; } - mAuxLiterals.put(equality, theory.term("=", lhs, rhs)); + mAuxLiterals.put(eqKey, theory.term("=", lhs, rhs)); } - return mAuxLiterals.get(equality); + return mAuxLiterals.get(eqKey); } private Term buildLemma(final ProofRules proofRules, RuleKind rule, final ProofInfo info, final Term mainEq) { @@ -595,7 +619,7 @@ private Term buildLemma(final ProofRules proofRules, RuleKind rule, final ProofI if (rule == RuleKind.CONG) { // this is a transitivity or congruence lemma assert info.getPaths().length == 1; - final CCTerm[] path = info.getPaths()[0].getPath(); + final CCParameter[] path = info.getPaths()[0].getPath(); final Term[] subs = new Term[path.length]; for (int j = 0; j < path.length; ++j) { subs[j] = path[j].getFlatTerm(); @@ -604,7 +628,7 @@ private Term buildLemma(final ProofRules proofRules, RuleKind rule, final ProofI subannots = subs; } else { final IndexedPath[] paths = info.getPaths(); - final SymmetricPair infoDiseq = info.getDiseq(); + final SymmetricPair infoDiseq = info.getDiseq(); Object[] lemmaAnnot = new Object[0]; if (mAnnot.mDTLemma != null && mAnnot.mDTLemma.getAnnotation() != null) { lemmaAnnot = mAnnot.mDTLemma.getAnnotation(); @@ -621,7 +645,7 @@ private Term buildLemma(final ProofRules proofRules, RuleKind rule, final ProofI } for (final IndexedPath p : paths) { final CCTerm index = p.getIndex(); - final CCTerm[] path = p.getPath(); + final CCParameter[] path = p.getPath(); final Term[] subs = new Term[path.length]; for (int j = 0; j < path.length; ++j) { subs[j] = path[j].getFlatTerm(); @@ -650,20 +674,20 @@ private Term buildProofTerm(final Clause clause, final ProofRules proofRules, // Build main lemma final ProofInfo mainInfo = proofOrder.get(0); - assert mainInfo.getDiseq() == mAnnot.getDiseq(); + assert mainInfo.getDiseq() == mAnnot.getDiseqParam(); // The equality proved by the lemma. It is null for rules without main equality final Term mainEq = mainInfo.getDiseq() == null ? null : isDisequalityLiteral(mainInfo.getDiseq()) - ? mEqualityLiterals.get(mainInfo.getDiseq()).getSMTFormula(theory) - : mAuxLiterals.get(mainInfo.getDiseq()); + ? mEqualityLiterals.get(key(mainInfo.getDiseq())).getSMTFormula(theory) + : mAuxLiterals.get(key(mainInfo.getDiseq())); Term proof = buildLemma(proofRules, mRule, mainInfo, mainEq); // Resolve with sub-lemmas. for (int lemmaNo = 1; lemmaNo < proofOrder.size(); lemmaNo++) { // Build the lemma clause. final ProofInfo info = proofOrder.get(lemmaNo); // auxLiteral should already have been created by the lemma that needs it. - assert mAuxLiterals.containsKey(info.getDiseq()); - final Term provedEq = mAuxLiterals.get(info.getDiseq()); + assert mAuxLiterals.containsKey(key(info.getDiseq())); + final Term provedEq = mAuxLiterals.get(key(info.getDiseq())); // Build lemma annotations. final Term lemma = buildLemma(proofRules, RuleKind.CONG, info, provedEq); @@ -678,19 +702,20 @@ private Term buildProofTerm(final Clause clause, final ProofRules proofRules, return proof; } - private boolean isEqualityLiteral(final SymmetricPair termPair) { - return mEqualityLiterals.containsKey(termPair) && mEqualityLiterals.get(termPair).getSign() < 0; + private boolean isEqualityLiteral(final SymmetricPair termPair) { + final SymmetricPair k = key(termPair); + return mEqualityLiterals.containsKey(k) && mEqualityLiterals.get(k).getSign() < 0; } - private boolean isDisequalityLiteral(final SymmetricPair termPair) { - return mEqualityLiterals.containsKey(termPair) && mEqualityLiterals.get(termPair).getSign() > 0; + private boolean isDisequalityLiteral(final SymmetricPair termPair) { + final SymmetricPair k = key(termPair); + return mEqualityLiterals.containsKey(k) && mEqualityLiterals.get(k).getSign() > 0; } - private boolean isTrivialDisequality(final SymmetricPair termPair) { - final CCTerm first = termPair.getFirst(); - final CCTerm second = termPair.getSecond(); - final Polynomial smtAffine = new Polynomial(first.getFlatTerm()); - smtAffine.add(Rational.MONE, second.getFlatTerm()); + private boolean isTrivialDisequality(final SymmetricPair termPair) { + // use the CCParameter flat terms so an offset clash (e.g. x+2 vs x+5) is detected + final Polynomial smtAffine = new Polynomial(termPair.getFirst().getFlatTerm()); + smtAffine.add(Rational.MONE, termPair.getSecond().getFlatTerm()); if (smtAffine.isConstant()) { return smtAffine.getConstant() != Rational.ZERO; } @@ -706,36 +731,42 @@ private boolean isTrivialDisequality(final SymmetricPair termPair) { * @return The argument paths, if they exist for all arguments, or null to indicate that the termpair is not a * congruence. */ - private ProofInfo findCongruencePaths(CCTerm first, CCTerm second) { + private ProofInfo findCongruencePaths(final CCParameter first, final CCParameter second) { final ProofInfo proofInfo = new ProofInfo(); proofInfo.mLemmaDiseq = new SymmetricPair<>(first, second); - proofInfo.mProofPaths = new IndexedPath[] { new IndexedPath(null, new CCTerm[] { first, second }) }; - if (!(first instanceof CCAppTerm) || !(second instanceof CCAppTerm)) { + proofInfo.mProofPaths = new IndexedPath[] { new IndexedPath(null, new CCParameter[] { first, second }) }; + final CCTerm firstTerm = first.getCCTerm(); + final CCTerm secondTerm = second.getCCTerm(); + if (!(firstTerm instanceof CCAppTerm) || !(secondTerm instanceof CCAppTerm)) { // This is not a congruence return null; } - final CCAppTerm firstApp = (CCAppTerm) first; - final CCAppTerm secondApp = (CCAppTerm) second; + final CCAppTerm firstApp = (CCAppTerm) firstTerm; + final CCAppTerm secondApp = (CCAppTerm) secondTerm; if (firstApp.getFunctionSymbol() != secondApp.getFunctionSymbol()) { // This is not a congruence return null; } - // TODO offset equalities: compares the offset-free argument terms; offset-aware proof generation (pairing on - // the CCParameter, i.e. getArgParam(i), so equal-by-value-but-different-offset args are handled) is not done - // yet. assert firstApp.getArgCount() == secondApp.getArgCount(); for (int i = 0; i < firstApp.getArgCount(); i++) { - final CCTerm firstArg = firstApp.getArgParam(i).getCCTerm(); - final CCTerm secondArg = secondApp.getArgParam(i).getCCTerm(); - if (firstArg != secondArg) { - final SymmetricPair argPair = new SymmetricPair<>(firstArg, secondArg); + final CCParameter firstArg = firstApp.getArgParam(i); + final CCParameter secondArg = secondApp.getArgParam(i); + // Resolve on the exact CCParameter equality of the arguments. Offset-free this is a direct literal or + // subpath lookup; the lookup maps are keyed offset-free so a shared offset edge is reused. + // TODO offset equalities: when the available proof establishes a shifted version of this argument equality + // (same offset-free edge, different offset, e.g. f(x,x+2)=f(y+2,y+4) where the (x,y) path proves x=y+2 but + // arg 1 needs x+2=y+4), bridge it with a one-step (offset) transitivity lemma instead of looking up the + // exact param pair. This path is only reachable once offsets are enabled under proofs. + if (firstArg.getCCTerm() != secondArg.getCCTerm()) { + final SymmetricPair argPair = new SymmetricPair<>(firstArg, secondArg); + final SymmetricPair argKey = key(argPair); if (isEqualityLiteral(argPair)) { - proofInfo.addLiteral(mEqualityLiterals.get(argPair)); - } else if (mPathProofMap.containsKey(argPair)) { - proofInfo.addSubProof(mPathProofMap.get(argPair)); + proofInfo.addLiteral(mEqualityLiterals.get(argKey)); + } else if (mPathProofMap.containsKey(argKey)) { + proofInfo.addSubProof(mPathProofMap.get(argKey)); } else { - // If no path was found for the arguments, termPair is not a congruence! + // If no path was found for the arguments, termpair is not a congruence! return null; } } From 74265a81edd1759948265cad9538fe66122b5372 Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Tue, 16 Jun 2026 23:36:14 +0200 Subject: [PATCH 34/75] CC offset equalities: key CongruencePath.mVisited offset-free (one subpath per edge) Two requested paths that differ only by a constant offset (e.g. x+5=y+7 and x+7=y+9, both for the edge x=y+2, or g(a)+5=g(b)+5 and g(a)+7=g(b)+7 via the same transitivity) now share one mVisited key, so only a single subpath is built and recorded in the annotation instead of one per offset variant. Consumers (CCProofGenerator) already absorb the per-use constant difference, so the proof is unchanged; this just avoids the redundant subpath in the annotation. mTodo/anchoring still use CCParameters (the first request fixes the subpath's anchor offset). Offset-free this is identical (bare CCTerm == its own offset-free key): 32 proof/model unit tests, SystemTest 420->1 only buggy001. With the gate temporarily flipped, the two-offset-variant congruence examples still proof-check at :proof-level full with identical (single :trans / :cong) oracles. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../theory/cclosure/CongruencePath.java | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java index 09390be5e..bfed3ae31 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java @@ -126,11 +126,21 @@ public String toString() { } } - final HashMap,SubPath> mVisited; + /** + * Visited subpaths, keyed by the offset-free end terms. Two requested paths that differ only by a constant + * offset (e.g. {@code x+5 = y+7} and {@code x+7 = y+9}, both for the edge {@code x = y+2}) share one key, so only a + * single subpath is built; consumers (e.g. {@link CCProofGenerator}) absorb the per-use constant difference. + */ + final HashMap,SubPath> mVisited; final ArrayDeque mAllPaths; final ArrayDeque> mTodo; final Set mAllLiterals; + /** The offset-free end terms of a parameter pair, used as the {@link #mVisited} key. */ + private static SymmetricPair offsetFreeKey(final SymmetricPair ends) { + return new SymmetricPair<>(ends.getFirst().getCCTerm(), ends.getSecond().getCCTerm()); + } + public CongruencePath(final CClosure closure) { mClosure = closure; mVisited = new HashMap<>(); @@ -251,7 +261,7 @@ SubPath computePathNonRecursive(final CCParameter left, final CCParameter right) return null; } - final SymmetricPair key = new SymmetricPair<>(left, right); + final SymmetricPair key = new SymmetricPair<>(leftTerm, rightTerm); if (mVisited.containsKey(key)) { return mVisited.get(key); } @@ -317,7 +327,7 @@ SubPath computePathNonRecursive(final CCParameter left, final CCParameter right) * the right end of the congruence chain that should be evaluated. */ public void computePath(final CCParameter left, final CCParameter right) { - final HashSet> added = new HashSet<>(); + final HashSet> added = new HashSet<>(); mTodo.add(new SymmetricPair<>(left, right)); while (!mTodo.isEmpty()) { final SymmetricPair pathEnds = mTodo.removeFirst(); @@ -327,15 +337,15 @@ public void computePath(final CCParameter left, final CCParameter right) { continue; } - // check if we already visited this path - final SubPath path = mVisited.get(pathEnds); + // check if we already visited this path (keyed offset-free, so offset variants share one subpath) + final SubPath path = mVisited.get(offsetFreeKey(pathEnds)); if (path == null) { // if we did not visit it yet, enqueue again for later and visit the path mTodo.addFirst(pathEnds); computePathNonRecursive(pathEnds.getFirst(), pathEnds.getSecond()); } else { // already visited it, so we just add the path now unless we did this earlier - if (added.add(pathEnds)) { + if (added.add(offsetFreeKey(pathEnds))) { mAllPaths.addFirst(path); } } From 4a2fac44a3721a68049b0352e42704a22e8cb99f Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Wed, 17 Jun 2026 23:56:11 +0200 Subject: [PATCH 35/75] CC offset equalities: lowlevel proof for offset cong/trans/dt lemmas Generalize ProofSimplifier.resolveNeededEqualities so a needed (dis)equality that carries a different offset rendering than the clause literal expressing the same affine fact is bridged with a Farkas step (proveEqWithMultiplier, multiplier +/-1). Matching uses an offset-aware OffsetEqKey (the two non-constant parts kept separate plus the constant offset, checked in both polarities) so unrelated edges whose difference polynomials coincide up to sign do not collide. This discharges cong/trans argument equalities and dt-project/dt-injective goal/reason (dis)equalities uniformly, since every CC-family converter funnels through resolveNeededEqualities. Render offset terms flattened (new CCParameter.addConstant): (+ z w k) rather than a nested (+ (+ z w) k), reconstructing the original flat parameter term that CCTermBuilder split into base+offset. The proof checker parses + with the non-recursive Polynomial, so a nested sum would be an opaque monomial and could not be related arithmetically to the flat term. OffsettedCCTerm.getFlatTerm and CCEquality.getSMTFormula both use it; this also repairs convertEQLemma's multiplier. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../smtinterpol/proof/ProofSimplifier.java | 126 +++++++++++++++++- .../theory/cclosure/CCEquality.java | 6 +- .../theory/cclosure/CCParameter.java | 22 +++ .../theory/cclosure/OffsettedCCTerm.java | 2 +- 4 files changed, 149 insertions(+), 7 deletions(-) diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/proof/ProofSimplifier.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/proof/ProofSimplifier.java index 2f717f64d..d3b107179 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/proof/ProofSimplifier.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/proof/ProofSimplifier.java @@ -22,6 +22,7 @@ import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; @@ -4626,6 +4627,13 @@ private AnnotatedTerm substituteInQuantInst(final Term[] subst, final Quantified private Term resolveNeededEqualities(Term proof, final Map, Term> allEqualities, final Map, Term> allDisequalities, final Set neededEqualities, final Set neededDisequalities) { + // Offset-aware indices, built lazily: with offset equalities a needed + // (dis)equality may carry a different but equivalent offset rendering than the + // clause literal that proves it (e.g. needed (= (+ x 5) (+ y 7)) vs. clause + // (= x (+ y 2))). These index the clause literals by the affine fact they + // express so the matching literal can be found and bridged with a Farkas step. + Map eqIndex = null; + Map diseqIndex = null; for (final Term eq : neededEqualities) { assert isApplication("=", eq); final Term[] eqParam = ((ApplicationTerm) eq).getParameters(); @@ -4636,22 +4644,132 @@ private Term resolveNeededEqualities(Term proof, final Map, proof = res(eq, mProofRules.symm(eqParam[0], eqParam[1]), proof); } } else { - final Term proofEq = mProofUtils.proveTrivialEquality(eqParam[0], eqParam[1]); - proof = res(eq, proofEq, proof); + if (eqIndex == null) { + eqIndex = buildOffsetIndex(allEqualities.values()); + } + final Object[] match = findOffsetMatch(eqParam[0], eqParam[1], eqIndex); + if (match != null) { + // bridge: prove (= eqParam0 eqParam1) from the clause literal `atom`. + final Term atom = (Term) match[0]; + final Term[] atomParam = ((ApplicationTerm) atom).getParameters(); + final Term bridge = mProofUtils.proveEqWithMultiplier(atomParam, eqParam, (Rational) match[1]); + proof = res(eq, bridge, proof); + } else { + final Term proofEq = mProofUtils.proveTrivialEquality(eqParam[0], eqParam[1]); + proof = res(eq, proofEq, proof); + } } } for (final Term eq : neededDisequalities) { assert isApplication("=", eq); final Term[] eqParam = ((ApplicationTerm) eq).getParameters(); final Term clauseEq = allDisequalities.get(new SymmetricPair<>(eqParam[0], eqParam[1])); - if (clauseEq != eq) { - // need symmetry + if (clauseEq != null) { + if (clauseEq != eq) { + // need symmetry + proof = res(eq, proof, mProofRules.symm(eqParam[1], eqParam[0])); + } + continue; + } + if (diseqIndex == null) { + diseqIndex = buildOffsetIndex(allDisequalities.values()); + } + final Object[] match = findOffsetMatch(eqParam[0], eqParam[1], diseqIndex); + if (match != null) { + // bridge: prove the clause literal `atom` from the proved (= eqParam0 eqParam1). + final Term atom = (Term) match[0]; + final Term[] atomParam = ((ApplicationTerm) atom).getParameters(); + final Term bridge = mProofUtils.proveEqWithMultiplier(eqParam, atomParam, (Rational) match[1]); + proof = res(eq, proof, bridge); + } else { + // not found offset-aware either: assume present but swapped (legacy behavior). proof = res(eq, proof, mProofRules.symm(eqParam[1], eqParam[0])); } } return proof; } + /** + * Build an index of clause (dis)equality atoms keyed by the affine fact they + * express, see {@link OffsetEqKey}. The first atom for a given fact wins; this is + * only used as a fallback when no syntactically identical clause literal exists. + */ + private static Map buildOffsetIndex(final Collection atoms) { + final Map index = new HashMap<>(); + for (final Term atom : atoms) { + final Term[] sides = ((ApplicationTerm) atom).getParameters(); + index.putIfAbsent(new OffsetEqKey(sides[0], sides[1]), atom); + } + return index; + } + + /** + * Look up the clause literal that expresses the same affine fact as + * {@code (= a b)}, allowing for a different offset rendering. Returns + * {@code {atom, multiplier}} where {@code multiplier} is the rational + * {@code (a - b) / (atomLhs - atomRhs) = ±1} relating the two, or {@code null} if + * no matching literal exists. + */ + private static Object[] findOffsetMatch(final Term a, final Term b, final Map index) { + Term atom = index.get(new OffsetEqKey(a, b)); + if (atom != null) { + return new Object[] { atom, Rational.ONE }; + } + atom = index.get(new OffsetEqKey(b, a)); + if (atom != null) { + return new Object[] { atom, Rational.MONE }; + } + return null; + } + + /** + * A lookup key identifying an (offset) equality by the affine fact it expresses: + * the non-constant parts of its two sides together with the constant offset + * {@code constant(lhs) - constant(rhs)} between them. Two equalities with the same + * key denote the same fact {@code lhs = rhs}, which is what lets a needed equality + * like {@code (= (+ x 5) (+ y 7))} be matched against the clause literal + * {@code (= x (+ y 2))}. The two non-constant parts are kept separate + * (rather than subtracted into one difference polynomial) so that unrelated edges + * whose difference polynomials coincide up to sign — e.g. {@code x+y = z+w+2} and + * {@code z-y = x-w-2} — do not collide. + */ + private static final class OffsetEqKey { + private final Map, Rational> mLhs; + private final Map, Rational> mRhs; + private final Rational mOffset; + + OffsetEqKey(final Term lhs, final Term rhs) { + final Polynomial pLhs = new Polynomial(lhs); + final Polynomial pRhs = new Polynomial(rhs); + mOffset = pLhs.getConstant().sub(pRhs.getConstant()); + mLhs = nonConstantPart(pLhs); + mRhs = nonConstantPart(pRhs); + } + + private static Map, Rational> nonConstantPart(final Polynomial poly) { + final Map, Rational> summands = new HashMap<>(poly.getSummands()); + summands.remove(Collections.emptyMap()); + return summands; + } + + @Override + public int hashCode() { + return mLhs.hashCode() * 31 * 31 + mRhs.hashCode() * 31 + mOffset.hashCode(); + } + + @Override + public boolean equals(final Object other) { + if (this == other) { + return true; + } + if (!(other instanceof OffsetEqKey)) { + return false; + } + final OffsetEqKey o = (OffsetEqKey) other; + return mOffset.equals(o.mOffset) && mLhs.equals(o.mLhs) && mRhs.equals(o.mRhs); + } + } + /** * Convert a clause term into an Array of terms, one entry for each disjunct. * This also handles singleton and empty clause correctly. diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCEquality.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCEquality.java index 5eb95bbcf..fb7f636dd 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCEquality.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCEquality.java @@ -106,8 +106,10 @@ public Term getSMTFormula(final Theory smtTheory) { if (mOffset.equals(Rational.ZERO)) { return smtTheory.term("=", lhs, rhs); } - // this equality states lhs == rhs + mOffset - return smtTheory.term("=", lhs, smtTheory.term("+", rhs, mOffset.toTerm(rhs.getSort()))); + // this equality states lhs == rhs + mOffset; build the right-hand side as a + // flattened sum (not a nested (+ rhs offset)) so the proof checker's + // non-recursive Polynomial parsing relates it to the original flat term. + return smtTheory.term("=", lhs, CCParameter.addConstant(rhs, mOffset)); } @Override diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCParameter.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCParameter.java index 396f29646..e4c3989a7 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCParameter.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCParameter.java @@ -18,6 +18,7 @@ */ package de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure; +import de.uni_freiburg.informatik.ultimate.logic.ApplicationTerm; import de.uni_freiburg.informatik.ultimate.logic.Rational; import de.uni_freiburg.informatik.ultimate.logic.Term; @@ -86,4 +87,25 @@ default CCParameter getValueKey() { static CCParameter of(final CCTerm term, final Rational offset) { return offset.equals(Rational.ZERO) ? term : new OffsettedCCTerm(term, offset); } + + /** + * Build the SMT term {@code term + offset} as a flattened sum: when {@code term} is itself a {@code +} + * application, its summands are spliced in rather than nested, so the result is {@code (+ z w offset)} rather than + * {@code (+ (+ z w) offset)}. This matters because the proof checker parses a {@code +} term with the non-recursive + * {@link de.uni_freiburg.informatik.ultimate.smtinterpol.util.Polynomial} (only the top level is flattened); a nested + * sum would be treated as an opaque monomial and could not be related arithmetically to the original flat parameter + * term that the {@link de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CCTermBuilder} split into this + * {@code (base, offset)} pair. Flattening reconstructs that original parameter term. + */ + static Term addConstant(final Term term, final Rational offset) { + final Term offsetTerm = offset.toTerm(term.getSort()); + if (term instanceof ApplicationTerm && ((ApplicationTerm) term).getFunction().getName().equals("+")) { + final Term[] inner = ((ApplicationTerm) term).getParameters(); + final Term[] args = new Term[inner.length + 1]; + System.arraycopy(inner, 0, args, 0, inner.length); + args[inner.length] = offsetTerm; + return term.getTheory().term("+", args); + } + return term.getTheory().term("+", term, offsetTerm); + } } diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/OffsettedCCTerm.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/OffsettedCCTerm.java index 746d0f459..5dcd15aaa 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/OffsettedCCTerm.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/OffsettedCCTerm.java @@ -55,7 +55,7 @@ public Rational getOffset() { @Override public Term getFlatTerm() { final Term term = mTerm.getFlatTerm(); - return term.getTheory().term("+", term, mOffset.toTerm(term.getSort())); + return CCParameter.addConstant(term, mOffset); } @Override From c227c9b498799334a17247cd3d72c0fea29aa821 Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Wed, 17 Jun 2026 23:56:22 +0200 Subject: [PATCH 36/75] CC offset equalities: offset-aware CCProofGenerator keys + anti-cycle lemma Key CCProofGenerator's lookup maps (mEqualityLiterals, mAllEqualities, mPathProofMap, mAuxLiterals, mTrivialDisequalities) and key() by an OffsetPair triple (CCTerm, CCTerm, Rational) with CCTermPairHash-style symmetry, instead of the offset-free SymmetricPair. The old offset-free key conflated two different facts sharing endpoints, e.g. car(x)=y (offset 0, a dt-project consequence) and car(x)=y-1 (offset -1, asserted) in an offset anti-cycle; the triple keeps them distinct while different renderings of one fact (x+5=y+7 and x+7=y+9, both x=y+2) still collapse to one key. computeAntiCycle now emits a valid lemma instead of a degenerate congruence over a single asserted edge. It proves the trivially-false (rhs+offEq) = (rhs+offPath) via a :trans through the common term -- the first step justified by the conflicting equality, the rest by the path -- with a trivial offset disequality discharged by an EQ lemma, mirroring the offset-free trivial-disequality cycle. The conflicting equality's offset deviates from the union-find and cannot be carried by a SubPath node (whose offset is derived intrinsically from mOffsetToRep), so it rides on an explicit CCParameter prepended to the main path via a new CCAnnotation constructor; SubPath and the rest of CCProofGenerator are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../theory/cclosure/CCAnnotation.java | 45 +++++++- .../theory/cclosure/CCProofGenerator.java | 109 ++++++++++++++---- .../theory/cclosure/CongruencePath.java | 15 ++- 3 files changed, 140 insertions(+), 29 deletions(-) diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAnnotation.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAnnotation.java index 04deea06f..d43c4a7ec 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAnnotation.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAnnotation.java @@ -259,16 +259,30 @@ private static SymmetricPair offsetFreeDiseq(final SymmetricPair diseq, final Collection paths, final RuleKind rule) { - this(diseq, paths, rule, null); + this(diseq, paths, rule, null, null); } public CCAnnotation(final SymmetricPair diseq, final Collection paths, final DataTypeLemma lemma) { - this(diseq, paths, lemma.getRule(), lemma); + this(diseq, paths, lemma.getRule(), lemma, null); + } + + /** + * Annotation with an extra leading edge prepended to the main path (path 0). This is used by the offset anti-cycle + * (see {@link CongruencePath#computeAntiCycle}): the deviating offset of the conflicting equality cannot be carried + * by a {@link SubPath} node (whose offset is derived intrinsically from {@code mOffsetToRep}), so it rides on an + * explicit {@code CCParameter} prepended here, exactly as a datatype lemma carries its offset on a standalone + * {@code CCParameter} main equality. + * + * @param pathPrefix the explicit first node of the main path (e.g. {@code rhs + offset(eq)}). + */ + public CCAnnotation(final SymmetricPair diseq, final Collection paths, final RuleKind rule, + final CCParameter pathPrefix) { + this(diseq, paths, rule, null, pathPrefix); } private CCAnnotation(final SymmetricPair diseq, final Collection paths, final RuleKind rule, - final DataTypeLemma lemma) { + final DataTypeLemma lemma, final CCParameter pathPrefix) { mDiseqParam = diseq; mDiseq = offsetFreeDiseq(diseq); mParamPaths = new CCParameter[paths.size()][]; @@ -276,8 +290,15 @@ private CCAnnotation(final SymmetricPair diseq, final Collection diseq, final Collection getDiseq() { return mDiseq; } diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCProofGenerator.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCProofGenerator.java index ae0b05c99..7d7d30128 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCProofGenerator.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCProofGenerator.java @@ -74,8 +74,8 @@ public SymmetricPair getPathEndParams() { return new SymmetricPair<>(mPath[0], mPath[mPath.length - 1]); } - /** The offset-free key of the path ends, used to look the path up among shared subpaths. */ - public SymmetricPair getPathEnds() { + /** The offset-aware key of the path ends, used to look the path up among shared subpaths. */ + public OffsetPair getPathEnds() { return key(getPathEndParams()); } @@ -218,7 +218,7 @@ private void addSubProof(ProofInfo congruence) { * Collect the proof info for one path. */ private boolean collectEquality(final SymmetricPair termPair) { - final SymmetricPair termKey = key(termPair); + final OffsetPair termKey = key(termPair); if (isEqualityLiteral(termPair)) { // equality literals are just added addLiteral(mEqualityLiterals.get(termKey)); @@ -347,20 +347,80 @@ public String toString() { // Store the self-built auxiliary equality literals, such that the // arguments of the equality are always in the same order. - private HashMap, Term> mAuxLiterals; - private HashMap, Literal> mEqualityLiterals; - private HashMap, ProofInfo> mPathProofMap; - private LinkedHashSet> mAllEqualities; - private LinkedHashSet> mTrivialDisequalities; + private HashMap mAuxLiterals; + private HashMap mEqualityLiterals; + private HashMap mPathProofMap; + private LinkedHashSet mAllEqualities; + private LinkedHashSet mTrivialDisequalities; private ProofRules mProofRules; /** - * The offset-free key of a CCParameter equality. The lookup maps are keyed on the offset-free CCTerm pair, so a - * shared offset edge (e.g. x = y+2) is proven once and reused for every argument equality that follows from it - * (x = y+2, x+2 = y+4, ...). + * A lookup key identifying an (offset) equality fact between two CCTerms: {@code value(first) == value(second) + + * offset}. The maps in this class are keyed on this triple so that two facts sharing offset-free endpoints but + * differing in offset — e.g. {@code car(x) = y} (offset 0, a dt-project consequence) and {@code car(x) = y-1} + * (offset -1, asserted) in an offset anti-cycle — are kept distinct, while different renderings of the same + * fact (e.g. {@code x+5 = y+7} and {@code x+7 = y+9}, both {@code x = y+2}) still collapse to one key. The key is + * canonical under swapping the two terms (which negates the offset), mirroring {@link CCTermPairHash}. */ - static SymmetricPair key(final SymmetricPair pair) { - return new SymmetricPair<>(pair.getFirst().getCCTerm(), pair.getSecond().getCCTerm()); + static final class OffsetPair { + final CCTerm mFirst; + final CCTerm mSecond; + final Rational mOffset; + + OffsetPair(final CCTerm first, final CCTerm second, final Rational offset) { + mFirst = first; + mSecond = second; + mOffset = offset; + } + + CCTerm getFirst() { + return mFirst; + } + + CCTerm getSecond() { + return mSecond; + } + + @Override + public int hashCode() { + // symmetric in (first, second); offsetHash is invariant under (first, second, off) -> (second, first, -off) + return (mFirst.hashCode() ^ mSecond.hashCode()) + CCTermPairHash.offsetHash(mFirst, mSecond, mOffset); + } + + @Override + public boolean equals(final Object other) { + if (this == other) { + return true; + } + if (!(other instanceof OffsetPair)) { + return false; + } + final OffsetPair o = (OffsetPair) other; + if (mFirst == o.mFirst && mSecond == o.mSecond) { + return mOffset.equals(o.mOffset); + } + if (mFirst == o.mSecond && mSecond == o.mFirst) { + return mOffset.equals(o.mOffset.negate()); + } + return false; + } + + @Override + public String toString() { + return mOffset.equals(Rational.ZERO) ? "(" + mFirst + "," + mSecond + ")" + : "(" + mFirst + "," + mSecond + "+" + mOffset + ")"; + } + } + + /** + * The offset-aware key of a CCParameter equality: {@code value(first) == value(second) + offset}, where + * {@code offset = second.getOffset() - first.getOffset()} is the constant offset between the two underlying CCTerms. + * See {@link OffsetPair}. + */ + static OffsetPair key(final SymmetricPair pair) { + final CCParameter first = pair.getFirst(); + final CCParameter second = pair.getSecond(); + return new OffsetPair(first.getCCTerm(), second.getCCTerm(), second.getOffset().sub(first.getOffset())); } public CCProofGenerator(final CCAnnotation arrayAnnot) { @@ -428,7 +488,9 @@ private void collectClauseLiterals(final Clause clause) { for (int i = 0; i < clause.getSize(); i++) { final Literal literal = clause.getLiteral(i); final CCEquality atom = (CCEquality) literal.getAtom(); - final SymmetricPair pair = new SymmetricPair<>(atom.getLhs(), atom.getRhs()); + // key on the actual offset so two literals between the same terms but with different offsets + // (e.g. car(x)=y and car(x)=y-1 in an offset anti-cycle) stay distinct. + final OffsetPair pair = new OffsetPair(atom.getLhs(), atom.getRhs(), atom.getOffset()); mEqualityLiterals.put(pair, literal); if (literal.getSign() < 0) { /* equality in conflict (negated in clause) */ @@ -452,7 +514,7 @@ private void collectStrongEqualities() { && ((mRule != RuleKind.WEAKEQ_EXT && mRule != RuleKind.CONST_WEAKEQ) || i > 0)) { final CCParameter[] path = indexedPath.getPath(); final SymmetricPair pathEndParams = indexedPath.getPathEndParams(); - final SymmetricPair pathEnds = key(pathEndParams); + final OffsetPair pathEnds = key(pathEndParams); if (mAllEqualities.add(pathEnds) && !mPathProofMap.containsKey(pathEnds)) { if (path.length == 2) { // A path of length 2 must be a congruence, otherwise we would not be able to explain it @@ -582,7 +644,7 @@ private ArrayList determineProofOrder(final ProofInfo mainInfo) { } private Term addAuxEquality(SymmetricPair equality) { - final SymmetricPair eqKey = key(equality); + final OffsetPair eqKey = key(equality); if (!mAuxLiterals.containsKey(eqKey)) { final Theory theory = mProofRules.getTheory(); Term lhs = equality.getFirst().getFlatTerm(); @@ -693,7 +755,7 @@ private Term buildProofTerm(final Clause clause, final ProofRules proofRules, final Term lemma = buildLemma(proofRules, RuleKind.CONG, info, provedEq); proof = proofRules.resolutionRule(provedEq, lemma, proof); } - for (final SymmetricPair trivialDiseq : mTrivialDisequalities) { + for (final OffsetPair trivialDiseq : mTrivialDisequalities) { final Term provedEq = mAuxLiterals.get(trivialDiseq); final ProofLiteral[] proofLits = new ProofLiteral[] { new ProofLiteral(provedEq, false) }; final Term diseqLemma = proofRules.oracle(proofLits, EQAnnotation.getAnnotation()); @@ -703,12 +765,12 @@ private Term buildProofTerm(final Clause clause, final ProofRules proofRules, } private boolean isEqualityLiteral(final SymmetricPair termPair) { - final SymmetricPair k = key(termPair); + final OffsetPair k = key(termPair); return mEqualityLiterals.containsKey(k) && mEqualityLiterals.get(k).getSign() < 0; } private boolean isDisequalityLiteral(final SymmetricPair termPair) { - final SymmetricPair k = key(termPair); + final OffsetPair k = key(termPair); return mEqualityLiterals.containsKey(k) && mEqualityLiterals.get(k).getSign() > 0; } @@ -760,7 +822,7 @@ private ProofInfo findCongruencePaths(final CCParameter first, final CCParameter // exact param pair. This path is only reachable once offsets are enabled under proofs. if (firstArg.getCCTerm() != secondArg.getCCTerm()) { final SymmetricPair argPair = new SymmetricPair<>(firstArg, secondArg); - final SymmetricPair argKey = key(argPair); + final OffsetPair argKey = key(argPair); if (isEqualityLiteral(argPair)) { proofInfo.addLiteral(mEqualityLiterals.get(argKey)); } else if (mPathProofMap.containsKey(argKey)) { @@ -795,8 +857,8 @@ private SelectEdge findSelectPath(final SymmetricPair termPair, final CC } } - for (final SymmetricPair equality : mAllEqualities) { - // Find some select path. + for (final OffsetPair equality : mAllEqualities) { + // Find some select path. Array select/store terms are offset-free, so the structural CCTerms are used. final CCTerm start = equality.getFirst(); final CCTerm end = equality.getSecond(); if (isGoodSelectStep(start, end, termPair, weakpathindex)) { @@ -826,7 +888,8 @@ private boolean isSelect(final CCTerm select, final CCTerm array, final CCTerm w return false; } final CCTerm index = ArrayTheory.getIndexFromSelect((CCAppTerm) select); - return (index == weakpathindex || mAllEqualities.contains(new SymmetricPair<>(weakpathindex, index))); + return (index == weakpathindex + || mAllEqualities.contains(new OffsetPair(weakpathindex, index, Rational.ZERO))); } /** diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java index bfed3ae31..365f370e9 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java @@ -390,7 +390,20 @@ public Clause computeAntiCycle(final CCEquality eq, final boolean produceProofs) } final Clause c = new Clause(clause); if (produceProofs) { - c.setProof(new LeafNode(LeafNode.THEORY_CC, createAnnotation(new SymmetricPair<>(lhs, rhs)))); + // The path proves lhs = rhs + deltaPath (the actual union-find offset), but eq claims lhs = rhs + deltaEq + // with deltaEq != deltaPath. We cannot express this as a single congruence: both conflicting values share the + // CCTerm rhs (the offset was factored out), and a SubPath cannot carry the same CCTerm at two offsets. So we + // build the trivially-false equality (rhs + deltaEq) = (rhs + deltaPath) and prove it via the trans path + // [rhs+deltaEq, lhs, ..., rhs+deltaPath]: the first step rhs+deltaEq = lhs is justified by eq, the rest by the + // path. The explicit leading node rhs+deltaEq carries deltaEq, which the path nodes cannot. The diseq is a + // trivial offset disequality (constant difference deltaEq - deltaPath), discharged by an EQ lemma. + final Rational deltaPath = lhs.mOffsetToRep.sub(rhs.mOffsetToRep); + final Rational deltaEq = eq.getOffset(); + final CCParameter rhsAtEq = CCParameter.of(rhs, deltaEq); + final CCParameter rhsAtPath = CCParameter.of(rhs, deltaPath); + final SymmetricPair diseq = new SymmetricPair<>(rhsAtEq, rhsAtPath); + c.setProof(new LeafNode(LeafNode.THEORY_CC, + new CCAnnotation(diseq, mAllPaths, CCAnnotation.RuleKind.CONG, rhsAtEq))); } return c; } From 1087ea1c215ab4080668589d012264e5bdbf13e7 Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Thu, 18 Jun 2026 12:24:54 +0200 Subject: [PATCH 37/75] CC offset equalities: cross-class anti-cycle proof object When a disequality is propagated because eq's two sides are in different congruence classes separated by a disequality, the explanation must build a path that crosses the eq edge between the two classes. The old code temporarily inserted eq as an equal-edge into the graph and ran computeCycle, but the resulting SubPath spans two representatives and SubPath.getParams derives each node's offset from mOffsetToRep (relative to that node's own representative), so the offsets across the two classes mix reference frames and are garbage. Solving was unaffected (the clause literals are correct), but proof-object generation produced an unexplainable path (e.g. match = 0 in the match_rewrite benchmark). Replace the graph-mutating branch with CongruencePath.computeAntiCycleDiffClass: compute the two halves (diseq.lhs..left and right..diseq.rhs) as ordinary single-class paths -- each offset-correct on its own -- and stitch them by hand, shifting the second half by eq.getOffset() so the proof carries consistent offsets across the bridge. The eq edge becomes a normal trans step discharged by the eq literal; the diseq is the lemma's (real) main disequality. No graph mutation. A new CCAnnotation constructor takes the pre-built stitched main path while the congruence sub-lemmas (each single-class) keep deriving offsets as before. With this, the whole proof suite passes with offset equalities enabled under proofs (match_rewrite was the last failure); offset-free behavior is unchanged (98/98 proof benchmarks, ProofSimplifierTest, ProofUtilsTest green). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../theory/cclosure/CCAnnotation.java | 34 +++++++++ .../smtinterpol/theory/cclosure/CClosure.java | 16 ++--- .../theory/cclosure/CongruencePath.java | 72 +++++++++++++++++++ 3 files changed, 110 insertions(+), 12 deletions(-) diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAnnotation.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAnnotation.java index d43c4a7ec..806bcd68c 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAnnotation.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAnnotation.java @@ -281,6 +281,40 @@ public CCAnnotation(final SymmetricPair diseq, final Collection diseq, final CCParameter[] mainPath, + final Collection otherPaths, final RuleKind rule) { + mDiseqParam = diseq; + mDiseq = offsetFreeDiseq(diseq); + final int n = 1 + otherPaths.size(); + mParamPaths = new CCParameter[n][]; + mPaths = new CCTerm[n][]; + mWeakIndices = new CCTerm[n]; + mParamPaths[0] = mainPath; + final CCTerm[] mainTerms = new CCTerm[mainPath.length]; + for (int j = 0; j < mainPath.length; j++) { + mainTerms[j] = mainPath[j].getCCTerm(); + } + mPaths[0] = mainTerms; + mWeakIndices[0] = null; + int i = 1; + for (final SubPath p : otherPaths) { + mParamPaths[i] = p.getParams(); + mPaths[i] = p.getTerms(); + mWeakIndices[i] = p instanceof WeakSubPath ? ((WeakSubPath) p).getIndex() : null; + i++; + } + mRule = rule; + mDTLemma = null; + } + private CCAnnotation(final SymmetricPair diseq, final Collection paths, final RuleKind rule, final DataTypeLemma lemma, final CCParameter pathPrefix) { mDiseqParam = diseq; diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java index b799a5c8f..5d8cbaaac 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java @@ -1019,18 +1019,10 @@ public Clause computeAntiCycle(final CCEquality eq) { assert left.mRepStar != right.mRepStar; assert diseq.getLhs().mRepStar == left.mRepStar || diseq.getLhs().mRepStar == right.mRepStar; assert diseq.getRhs().mRepStar == left.mRepStar || diseq.getRhs().mRepStar == right.mRepStar; - - left.invertEqualEdges(this); - left.mEqualEdge = right; - left.mOldRep = left.mRepStar; - assert left.mOldRep.mReasonLiteral == null; - left.mOldRep.mReasonLiteral = eq; - final Clause c = computeCycle(diseq); - assert left.mEqualEdge == right && left.mOldRep == left.mRepStar; - left.mOldRep.mReasonLiteral = null; - left.mOldRep = null; - left.mEqualEdge = null; - return c; + // left and right are in different classes, separated by diseq. Build the explanation without mutating the graph: + // the path crosses the eq edge between the two classes, and its offsets are stitched by hand (see the method), + // because mOffsetToRep cannot express an offset across two representatives. + return new CongruencePath(this).computeAntiCycleDiffClass(eq, diseq, isProofGenerationEnabled()); } /** diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java index 365f370e9..18e8ea149 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java @@ -408,6 +408,78 @@ public Clause computeAntiCycle(final CCEquality eq, final boolean produceProofs) return c; } + /** + * Build the explanation clause {@code {diseq, ¬eq, ¬path}} for a disequality {@code ¬eq} that is propagated (or + * conflicts) because {@code eq}'s two sides are in different congruence classes, separated by the + * disequality {@code diseq}. The path runs {@code diseq.lhs … left -[eq]- right … diseq.rhs}, so it spans the two + * classes joined by the {@code eq} edge. + * + *

Unlike the old approach (temporarily insert {@code eq} as an equal-edge into the graph and run + * {@link #computeCycle(CCEquality, boolean)}), we do not mutate the graph: a path crossing the {@code eq} + * edge would have its offsets read from {@code mOffsetToRep}, which is relative to each node's own representative and + * meaningless across the two classes. Instead we compute the two halves as ordinary single-class paths (offset-correct + * on their own) and stitch them by hand, shifting the second half by {@code eq.getOffset()} so the proof object + * carries consistent offsets across the bridge. + */ + public Clause computeAntiCycleDiffClass(final CCEquality eq, final CCEquality diseq, final boolean produceProofs) { + final CCTerm left = eq.getLhs(); + final CCTerm right = eq.getRhs(); + assert left.mRepStar != right.mRepStar; + // Orient the separating disequality: dLeft is in left's class, dRight in right's class. dOff is the offset of the + // forbidden equality in that orientation, i.e. diseq forbids dLeft == dRight + dOff. + final CCTerm dLeft, dRight; + final Rational dOff; + if (diseq.getLhs().mRepStar == left.mRepStar) { + dLeft = diseq.getLhs(); + dRight = diseq.getRhs(); + dOff = diseq.getOffset(); + } else { + dLeft = diseq.getRhs(); + dRight = diseq.getLhs(); + dOff = diseq.getOffset().negate(); + } + assert dLeft.mRepStar == left.mRepStar && dRight.mRepStar == right.mRepStar; + // Two single-class paths, accumulating their reason literals into mAllLiterals and their subpaths into mAllPaths. + computePath(dLeft, left); + computePath(right, dRight); + final Literal[] clause = new Literal[mAllLiterals.size() + 2]; + int i = 0; + clause[i++] = diseq; + clause[i++] = eq.negate(); + for (final Literal l : mAllLiterals) { + clause[i++] = l.negate(); + } + final Clause c = new Clause(clause); + if (produceProofs) { + final SubPath segA = dLeft == left ? null : mVisited.get(new SymmetricPair<>(dLeft, left)); + final SubPath segB = right == dRight ? null : mVisited.get(new SymmetricPair<>(right, dRight)); + // paramsA = [dLeft@0, ..., left@offLeft]; paramsB = [right@0, ..., dRight@offRight] (single-class, correct). + final CCParameter[] paramsA = segA != null ? segA.getParams() : new CCParameter[] { dLeft }; + final CCParameter[] paramsB = segB != null ? segB.getParams() : new CCParameter[] { right }; + // Shift the right half into the left half's frame: after the eq edge (left == right + eq.getOffset()), right + // sits at left's offset plus eq.getOffset(). + final Rational shift = paramsA[paramsA.length - 1].getOffset().add(eq.getOffset()); + final CCParameter[] mainPath = new CCParameter[paramsA.length + paramsB.length]; + System.arraycopy(paramsA, 0, mainPath, 0, paramsA.length); + for (int j = 0; j < paramsB.length; j++) { + final CCParameter p = paramsB[j]; + mainPath[paramsA.length + j] = CCParameter.of(p.getCCTerm(), p.getOffset().add(shift)); + } + assert mainPath[mainPath.length - 1].getOffset().equals(dOff) : "net path offset must match the diseq offset"; + // The remaining subpaths (congruences within either half) keep deriving their offsets the usual way. + final ArrayList otherPaths = new ArrayList<>(); + for (final SubPath p : mAllPaths) { + if (p != segA && p != segB) { + otherPaths.add(p); + } + } + final SymmetricPair diseqParam = new SymmetricPair<>(dLeft, CCParameter.of(dRight, dOff)); + c.setProof(new LeafNode(LeafNode.THEORY_CC, + new CCAnnotation(diseqParam, mainPath, otherPaths, CCAnnotation.RuleKind.CONG))); + } + return c; + } + public Clause computeCycle(final CCTerm lconstant, final CCTerm rconstant, final boolean produceProofs) { mClosure.getLogger().debug("computeCycle for Constants"); computePath(lconstant, rconstant); From 8ec627d30d80ea91fb07a7ffc17a27c53bd1b175 Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Fri, 19 Jun 2026 17:49:12 +0200 Subject: [PATCH 38/75] CC offset equalities: lazy disequality explanation, drop eager precompute The eager explanation in getPropagatedLiteral (which precomputed a propagated disequality's reason at propagation time) was a workaround for two problems with the old lazy path: the graph-mutating computeAntiCycle could not run on an already-merged class, and the orientation of the separating disequality could no longer be recovered once the two sides merged. Both are now solved, so the reason is computed lazily again in getUnitClause/computeAntiCycle: - computeAntiCycleDiffClass (added earlier) never mutates the graph. - CCEquality records the orientation of mDiseqReason relative to the equality when the reason is set (mDiseqOrientation, via setDiseqReason), while the two sides are still in distinct classes; computeAntiCycleDiffClass uses it instead of the live representatives, so it stays correct after a later merge. - computeAntiCycle now dispatches on mDiseqReason == null (offset conflict with no separating disequality, e.g. x != x+5 -> path between the same-class sides) vs non-null (concrete disequality -> computeAntiCycleDiffClass), rather than on the current representatives. The actual bug this exposed: mDiseqReason is not cleared on backtracking, so a CCEquality re-propagated false by a same-rep offset conflict could still carry a stale mDiseqReason from an earlier different-class propagation, wrongly routing it to the concrete-disequality explanation and corrupting conflict analysis (a decided literal ended up with a null explanation). Fixed by clearing mDiseqReason at that propagation site (CCTerm.mergeInternal, offset-differs branch). Validated: abv/ext01 and abv/ext02 solve correctly (offset solving); the test/proof suite is 98/98 at the default gate; ProofSimplifierTest and ProofUtilsTest pass; no new solving regressions. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../theory/cclosure/CCEquality.java | 17 ++++++++ .../smtinterpol/theory/cclosure/CCTerm.java | 7 +++- .../smtinterpol/theory/cclosure/CClosure.java | 39 +++++++------------ .../theory/cclosure/CongruencePath.java | 10 ++--- 4 files changed, 41 insertions(+), 32 deletions(-) diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCEquality.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCEquality.java index fb7f636dd..977d145f5 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCEquality.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCEquality.java @@ -35,6 +35,12 @@ public class CCEquality extends DPLLAtom { */ private Rational mOffset = Rational.ZERO; CCEquality mDiseqReason; + /** + * Orientation of {@link #mDiseqReason} relative to this equality, captured when the reason is set (while the two + * sides are still in distinct classes): {@code true} iff {@code mDiseqReason.getLhs()} is in {@code getLhs()}'s class. + * Needed because once the two sides later merge, {@code getRepresentative()} can no longer tell the orientation. + */ + boolean mDiseqOrientation; private LAEquality mLasd; private Rational mLAFactor; private final Entry mEntry; @@ -71,6 +77,17 @@ public void setOffset(final Rational offset) { mOffset = offset; } + /** + * Set the separating disequality that makes this equality false, recording its orientation relative to this equality. + * Must be called while this equality's two sides are still in distinct classes (which holds at every propagation + * site), so the orientation is unambiguous; it is then used even after the two sides are later merged. + */ + void setDiseqReason(final CCEquality reason) { + assert reason.getLhs().getRepresentative() != reason.getRhs().getRepresentative(); + mDiseqReason = reason; + mDiseqOrientation = reason.getLhs().getRepresentative() == mLhs.getRepresentative(); + } + public Entry getEntry() { return mEntry; } diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCTerm.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCTerm.java index 52c7b4981..2575f2023 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCTerm.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCTerm.java @@ -549,6 +549,8 @@ private Clause mergeInternal(final CClosure engine, final CCTerm lhs, final CCEq final CCEquality cceq = eq.getCCEquality(); assert cceq.getDecideStatus() != cceq; if (cceq.getDecideStatus() == null) { + // this is caused by an offset conflict, not by a set disequality + cceq.mDiseqReason = null; engine.addPending(cceq.negate()); } } @@ -568,7 +570,7 @@ private Clause mergeInternal(final CClosure engine, final CCTerm lhs, final CCEq for (final CCEquality.Entry eq : destInfo.mEqlits) { assert eq.getCCEquality().getDecideStatus() != eq.getCCEquality(); if (eq.getCCEquality().getDecideStatus() == null) { - eq.getCCEquality().mDiseqReason = info.mDiseq; + eq.getCCEquality().setDiseqReason(info.mDiseq); engine.addPending(eq.getCCEquality().negate()); } } @@ -576,7 +578,7 @@ private Clause mergeInternal(final CClosure engine, final CCTerm lhs, final CCEq for (final CCEquality.Entry eq : info.mEqlits) { assert eq.getCCEquality().getDecideStatus() != eq.getCCEquality(); if (eq.getCCEquality().getDecideStatus() == null) { - eq.getCCEquality().mDiseqReason = destInfo.mDiseq; + eq.getCCEquality().setDiseqReason(destInfo.mDiseq); engine.addPending(eq.getCCEquality().negate()); } } @@ -713,6 +715,7 @@ public int hashCode() { return mHashCode; } + @Override public Term getFlatTerm() { return mFlatTerm; } diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java index 5d8cbaaac..f95eed3aa 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java @@ -750,7 +750,7 @@ public CCEquality createCCEquality(final int stackLevel, CCTerm t1, CCTerm t2, R if (getLogger().isDebugEnabled()) { getLogger().debug("CC-Prop: " + eq.negate() + " diseq: " + diseq); } - eq.mDiseqReason = diseq; + eq.setDiseqReason(diseq); mPendingLits.add(eq.negate()); mRecheckOnBacktrackLits.add(eq.negate()); } @@ -780,15 +780,8 @@ public Clause finalCheck() { public Literal getPropagatedLiteral() { final Literal lit = mPendingLits.poll(); assert (lit == null || checkPending(lit)); - if (lit != null && createOffsetEqualities() && lit.getAtom().mExplanation == null - && lit.getAtom() instanceof CCEquality && !(lit instanceof CCEquality)) { - // This is a propagated disequality. With offset equalities its two sides may later be merged at a different - // offset (a merge that is only undone at decision-level backtrack), which would break the lazy anti-cycle - // explanation: getUnitClause would temporarily insert an equal-edge into an already-merged class, or read a - // path through an edge whose literal has since been backtracked. So we compute the explanation now, while - // the congruence graph still matches the trail, and store it so DPLLEngine uses it directly. - lit.getAtom().mExplanation = getUnitClause(lit); - } + // Lazy explanation: the reason for a propagated disequality is computed on demand in getUnitClause / + // computeAntiCycle (which no longer mutates the graph and orients the diseq from eq.mDiseqOrientation). return lit; } @@ -973,7 +966,7 @@ private void separate(final CCTerm lhs, final CCTerm rhs, final CCEquality diseq // offset merge); such an eq keeps its existing reason and is simply skipped. assert eq.getDecideStatus() != eq || eq == diseq; if (eq.getDecideStatus() == null) { - eq.mDiseqReason = diseq; + eq.setDiseqReason(diseq); addPending(eq.negate()); } } @@ -1007,21 +1000,17 @@ public Clause computeCycle(final CCTerm lconstant, final CCTerm rconstant) { } public Clause computeAntiCycle(final CCEquality eq) { - final CCTerm left = eq.getLhs(); - final CCTerm right = eq.getRhs(); - if (left.mRepStar == right.mRepStar) { - // Offset equalities make this reachable: the two sides are in the same class at an offset different from the - // one eq claims, so eq is false without any separating disequality. The path between them explains it. - assert !left.getOffsetToRep().sub(right.getOffsetToRep()).equals(eq.getOffset()); + final CCEquality diseq = eq.mDiseqReason; + if (diseq == null) { + // No separating disequality (e.g. x != x+5): the two sides are in the same class at an offset different from + // the one eq claims, so the path between them is the entire reason; no CCEquality is involved. + assert eq.getLhs().mRepStar == eq.getRhs().mRepStar; + assert !eq.getLhs().getOffsetToRep().sub(eq.getRhs().getOffsetToRep()).equals(eq.getOffset()); return new CongruencePath(this).computeAntiCycle(eq, isProofGenerationEnabled()); } - final CCEquality diseq = eq.mDiseqReason; - assert left.mRepStar != right.mRepStar; - assert diseq.getLhs().mRepStar == left.mRepStar || diseq.getLhs().mRepStar == right.mRepStar; - assert diseq.getRhs().mRepStar == left.mRepStar || diseq.getRhs().mRepStar == right.mRepStar; - // left and right are in different classes, separated by diseq. Build the explanation without mutating the graph: - // the path crosses the eq edge between the two classes, and its offsets are stitched by hand (see the method), - // because mOffsetToRep cannot express an offset across two representatives. + // A concrete disequality separates eq's two sides. This works whether they are still in different classes or were + // merged later: computeAntiCycleDiffClass orients diseq from eq's stored orientation and walks the two original + // single-class paths plus the explicit eq edge, never mutating the graph. return new CongruencePath(this).computeAntiCycleDiffClass(eq, diseq, isProofGenerationEnabled()); } @@ -1252,7 +1241,7 @@ public Clause backtrackComplete() { final CCTermPairHash.Info info = mPairHash.getInfo(lhs, rhs, repOffset(eq)); final CCEquality diseq = info == null ? null : info.mDiseq; if (diseq != null) { - eq.mDiseqReason = diseq; + eq.setDiseqReason(diseq); repropagate = true; } } diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java index 18e8ea149..155fab4d6 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java @@ -424,12 +424,13 @@ public Clause computeAntiCycle(final CCEquality eq, final boolean produceProofs) public Clause computeAntiCycleDiffClass(final CCEquality eq, final CCEquality diseq, final boolean produceProofs) { final CCTerm left = eq.getLhs(); final CCTerm right = eq.getRhs(); - assert left.mRepStar != right.mRepStar; - // Orient the separating disequality: dLeft is in left's class, dRight in right's class. dOff is the offset of the - // forbidden equality in that orientation, i.e. diseq forbids dLeft == dRight + dOff. + // Orient the separating disequality so dLeft is on left's side and dRight on right's side; dOff is the offset of + // the forbidden equality in that orientation (diseq forbids dLeft == dRight + dOff). Use the orientation captured + // when the reason was set (eq.mDiseqOrientation), not the current representatives: by the time this runs left and + // right may have been merged, after which getRepresentative() can no longer tell the two sides apart. final CCTerm dLeft, dRight; final Rational dOff; - if (diseq.getLhs().mRepStar == left.mRepStar) { + if (eq.mDiseqOrientation) { dLeft = diseq.getLhs(); dRight = diseq.getRhs(); dOff = diseq.getOffset(); @@ -438,7 +439,6 @@ public Clause computeAntiCycleDiffClass(final CCEquality eq, final CCEquality di dRight = diseq.getLhs(); dOff = diseq.getOffset().negate(); } - assert dLeft.mRepStar == left.mRepStar && dRight.mRepStar == right.mRepStar; // Two single-class paths, accumulating their reason literals into mAllLiterals and their subpaths into mAllPaths. computePath(dLeft, left); computePath(right, dRight); From 027794857f7bee8efdd77670b5eb8b7922fa9789 Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Fri, 19 Jun 2026 22:53:36 +0200 Subject: [PATCH 39/75] CC offset equalities: offset-aware array proof paths + structural-offset keys Make the array weak-path proof code offset-aware and fix the structural-vs-dynamic offset confusion that regressed offsetted-store-index array proofs: - ArrayTheory.getIndexFromStore now returns a CCParameter (offset-carrying), mirroring getIndexFromSelect; getIndexParamFromStore removed, all callers updated. Same for getValueFromStore/getValueFromConst/getIndexFromSelect. isSelectTerm/isStoreTerm/ isConstTerm/isDiffTerm take CCParameter. ArrayLemma equalities are CCParameter pairs; createIndexEquality goes through createEquality(CCParameter,CCParameter,..); array weak-eq value comparisons use sameValueAs/getOffsetToRep. - CClosure.createEquality(CCParameter,CCParameter,boolean) uses the STRUCTURAL offset (getOffset()), so created literals key like the original clause literals (CCProofGenerator.collectClauseLiterals); getOffsetToRep() must not be used here as the two parameters need not be in one class. createEquality allows same CCTerm when offset != 0 (the false-proxy path returns null for the trivially-false x = x+k). - CCProofGenerator: OffsetPair(CCParameter,CCParameter) and goodSelectStep use the structural getOffset(); SelectEdge/IndexedPath/findSelectPath/isSelect/isConst and the weak-path/select-path handling thread CCParameter; collectWeakPath case (ii) keys the store-index disequality on the offset-carrying CCParameter. - WeakCongruencePath threads CCParameter select/store indices and values. Fixes regression/20051113-...TraceCheck (offsetted store indices), which produced a "Cannot explain term pair" / proof-check failure with offsets+proofs. CCTerm's LA shared-term propagation keeps the explicit dynamic-offset createEquality (unchanged). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../theory/cclosure/ArrayTheory.java | 123 ++++++++---------- .../theory/cclosure/CCAnnotation.java | 34 +---- .../theory/cclosure/CCProofGenerator.java | 112 ++++++++++------ .../smtinterpol/theory/cclosure/CClosure.java | 9 +- .../theory/cclosure/WeakCongruencePath.java | 24 ++-- 5 files changed, 149 insertions(+), 153 deletions(-) diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ArrayTheory.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ArrayTheory.java index 61fa80cb4..5dd07333b 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ArrayTheory.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ArrayTheory.java @@ -32,7 +32,6 @@ import java.util.Set; import de.uni_freiburg.informatik.ultimate.logic.FunctionSymbol; -import de.uni_freiburg.informatik.ultimate.logic.Rational; import de.uni_freiburg.informatik.ultimate.logic.SMTLIBConstants; import de.uni_freiburg.informatik.ultimate.logic.Sort; import de.uni_freiburg.informatik.ultimate.logic.Term; @@ -212,7 +211,7 @@ public ArrayNode getWeakIRepresentative(CCParameter index) { index = index.getValueKey(); ArrayNode node = this; while (node.mPrimaryEdge != null) { - if (getIndexParamFromStore(node.mPrimaryStore).getValueKey().equals(index)) { + if (getIndexFromStore(node.mPrimaryStore).getValueKey().equals(index)) { if (node.mSecondaryEdge == null) { break; } @@ -233,7 +232,7 @@ public ArrayNode getWeakIRepresentative(CCParameter index) { public void makeWeakIRepresentative() { assert mPrimaryEdge != null; assert mSecondaryEdge != null; - final CCParameter index = getIndexParamFromStore(mPrimaryStore).getValueKey(); + final CCParameter index = getIndexFromStore(mPrimaryStore).getValueKey(); assert getWeakIRepresentative(index) != getWeakRepresentative(); ArrayNode prev = this; ArrayNode next = prev.mSecondaryEdge; @@ -284,7 +283,7 @@ public void makeWeakRepresentative() { node.mPrimaryEdge = prev; node.mPrimaryStore = prevStore; - final CCParameter index = getIndexParamFromStore(nextStore).getValueKey(); + final CCParameter index = getIndexFromStore(nextStore).getValueKey(); final ArrayNode old = seenStores.put(index, next); if (old == node) { // the node is in the middle of two consecutive stores on the same @@ -303,7 +302,7 @@ public void makeWeakRepresentative() { // insert the secondary edge of node into old node. The reasoning is that // the old node is connected using only primary edges on different indices, so it has // the same weak-i representative as node. - assert (getIndexParamFromStore(old.mPrimaryStore).getValueKey().equals(index)); + assert (getIndexFromStore(old.mPrimaryStore).getValueKey().equals(index)); assert (old.mSecondaryEdge == null); old.mSecondaryEdge = node.mSecondaryEdge; old.mSecondaryStore = node.mSecondaryStore; @@ -383,16 +382,17 @@ public void mergeWith(final ArrayNode storeNode, final CCAppTerm store, final Co mConstTerm = null; mergeConstSelects.putAll(storeNode.mSelects); } else { - final CCTerm const1 = getValueFromConst(mConstTerm); - final CCTerm const2 = getValueFromConst(storeNode.mConstTerm); - if (const1.getRepresentative() != const2.getRepresentative()) { + final CCParameter const1 = getValueFromConst(mConstTerm); + final CCParameter const2 = getValueFromConst(storeNode.mConstTerm); + if (const1.getRepresentative() != const2.getRepresentative() + || !const1.getOffsetToRep().equals(const2.getOffsetToRep())) { propLemmas.add(new ArrayLemma(RuleKind.CONST_WEAKEQ, const1, const2)); } } } else if (storeNode.mConstTerm != null) { mergeConstSelects.putAll(mSelects); } - mergeConstSelects.remove(getIndexParamFromStore(store).getValueKey()); + mergeConstSelects.remove(getIndexFromStore(store).getValueKey()); // merge the selects; Map newSelects = Collections.emptyMap(); @@ -400,7 +400,7 @@ public void mergeWith(final ArrayNode storeNode, final CCAppTerm store, final Co final CCParameter index = entry.getKey(); final CCAppTerm select = entry.getValue(); assert select != null; - if (index.equals(getIndexParamFromStore(store).getValueKey())) { + if (index.equals(getIndexFromStore(store).getValueKey())) { newSelects = Collections.singletonMap(index, select); } else { final CCAppTerm otherSelect = storeNode.mSelects.get(index); @@ -418,7 +418,7 @@ public void mergeWith(final ArrayNode storeNode, final CCAppTerm store, final Co } mSelects = newSelects; if (storeNode.mConstTerm != null) { - final CCTerm const1 = getValueFromConst(storeNode.mConstTerm); + final CCParameter const1 = getValueFromConst(storeNode.mConstTerm); final ArrayNode constNode = mCongRoots.get(storeNode.mConstTerm.getRepresentative()); for (final Entry entry : mergeConstSelects.entrySet()) { final CCParameter index = entry.getKey(); @@ -450,13 +450,13 @@ public void mergeWith(final ArrayNode storeNode, final CCAppTerm store, final Co public void mergeSecondary(final ArrayNode storeNode, final CCAppTerm store, final Collection propEqualities) { assert storeNode.mPrimaryEdge == null; assert mPrimaryEdge != null; - assert !getIndexParamFromStore(mPrimaryStore).sameValueAs(getIndexParamFromStore(store)); + assert !getIndexFromStore(mPrimaryStore).sameValueAs(getIndexFromStore(store)); if (mSecondaryEdge != null) { makeWeakIRepresentative(); } mSecondaryEdge = storeNode; mSecondaryStore = store; - final CCParameter storeIndex = getIndexParamFromStore(mPrimaryStore).getValueKey(); + final CCParameter storeIndex = getIndexFromStore(mPrimaryStore).getValueKey(); if (!mSelects.isEmpty()) { final CCAppTerm select = mSelects.get(storeIndex); assert (select != null); @@ -477,7 +477,7 @@ public void mergeSecondary(final ArrayNode storeNode, final CCAppTerm store, fin if (constNode.getWeakIRepresentative(storeIndex) == storeNode) { // do not keep select, we will merge it with the constant value final CCAppTerm select = storeNode.mSelects.remove(storeIndex); - final CCTerm const1 = getValueFromConst(storeNode.mConstTerm); + final CCParameter const1 = getValueFromConst(storeNode.mConstTerm); if (select != null && select.getRepresentative() != const1.getRepresentative()) { propEqualities.add(new ArrayLemma(RuleKind.READ_CONST_WEAKEQ, select, const1)); } @@ -497,7 +497,7 @@ public int countSecondaryEdges(final CCParameter index) { int count = 0; ArrayNode node = this; while (node.mPrimaryEdge != null) { - if (getIndexParamFromStore(node.mPrimaryStore).getValueKey().equals(index)) { + if (getIndexFromStore(node.mPrimaryStore).getValueKey().equals(index)) { if (node.mSecondaryEdge == null) { break; } @@ -516,7 +516,7 @@ public int countSecondaryEdges(final CCParameter index) { public ArrayNode findSecondaryNode(final CCParameter index) { assert index.getValueKey().equals(index); ArrayNode node = this; - while (node.mPrimaryEdge != null && !getIndexParamFromStore(node.mPrimaryStore).getValueKey().equals(index)) { + while (node.mPrimaryEdge != null && !getIndexFromStore(node.mPrimaryStore).getValueKey().equals(index)) { node = node.mPrimaryEdge; } return node; @@ -554,10 +554,10 @@ public String toString() { private static class ArrayLemma { RuleKind mRule; - SymmetricPair mPropagatedEq; + SymmetricPair mPropagatedEq; Set mUndecidedLits; - public ArrayLemma(final RuleKind rule, final CCTerm lhs, final CCTerm rhs) { + public ArrayLemma(final RuleKind rule, final CCParameter lhs, final CCParameter rhs) { mRule = rule; mPropagatedEq = new SymmetricPair<>(lhs, rhs); } @@ -566,7 +566,7 @@ public RuleKind getRule() { return mRule; } - public SymmetricPair getEquality() { + public SymmetricPair getEquality() { return mPropagatedEq; } @@ -741,7 +741,7 @@ public int checkCompleteness() { } private Clause explainPropagation(final ArrayLemma lemma) { - final SymmetricPair equality = lemma.getEquality(); + final SymmetricPair equality = lemma.getEquality(); final long start = System.nanoTime(); mNumInstsSelect++; final WeakCongruencePath path = new WeakCongruencePath(this); @@ -982,7 +982,7 @@ public void fillInModel(final ModelBuilder builder, final List ccArrayTe } else { secondParentTerm = null; } - final CCParameter ccIndex = getIndexParamFromStore(node.mPrimaryStore).getValueKey(); + final CCParameter ccIndex = getIndexFromStore(node.mPrimaryStore).getValueKey(); final CCAppTerm ccValue = node.mSelects.get(ccIndex); final Term index = builder.getModelValue(ccIndex); final Term value = secondParentTerm != null ? arraySortInterpretation.getSelect(secondParentTerm, index) @@ -1010,28 +1010,28 @@ public void notifyDiff(final CCAppTerm diff) { mDiffs.add(diff); } - public static boolean isSelectTerm(final CCTerm term) { + public static boolean isSelectTerm(final CCParameter term) { if (term instanceof CCAppTerm) { return ((CCAppTerm) term).getFunctionSymbol().getName().equals(SMTLIBConstants.SELECT); } return false; } - public static boolean isStoreTerm(final CCTerm term) { + public static boolean isStoreTerm(final CCParameter term) { if (term instanceof CCAppTerm) { return ((CCAppTerm) term).getFunctionSymbol().getName().equals(SMTLIBConstants.STORE); } return false; } - public static boolean isConstTerm(final CCTerm term) { + public static boolean isConstTerm(final CCParameter term) { if (term instanceof CCAppTerm) { return ((CCAppTerm) term).getFunctionSymbol().getName().equals(SMTLIBConstants.CONST); } return false; } - public static boolean isDiffTerm(final CCTerm term) { + public static boolean isDiffTerm(final CCParameter term) { if (term instanceof CCAppTerm) { return ((CCAppTerm) term).getFunctionSymbol().getName().equals(SMTInterpolConstants.DIFF); } @@ -1044,16 +1044,11 @@ public static CCTerm getArrayFromSelect(final CCAppTerm select) { return (CCTerm) select.getArgParam(0); } - public static CCTerm getIndexFromSelect(final CCAppTerm select) { - assert isSelectTerm(select); - return select.getArgParam(1).getCCTerm(); - } - /** * The value of the index of a select term as a {@link CCParameter}, i.e. argument 1 together with its structural * offset. With offset equalities the index of {@code (select a (+ i 5))} is the CCTerm {@code i} plus offset 5. */ - public static CCParameter getIndexParamFromSelect(final CCAppTerm select) { + public static CCParameter getIndexFromSelect(final CCAppTerm select) { assert isSelectTerm(select); return select.getArgParam(1); } @@ -1064,28 +1059,23 @@ public static CCTerm getArrayFromStore(final CCAppTerm store) { return (CCTerm) store.getArgParam(0); } - public static CCTerm getIndexFromStore(final CCAppTerm store) { - assert isStoreTerm(store); - return store.getArgParam(1).getCCTerm(); - } - /** * The value of the index of a store term as a {@link CCParameter}, i.e. argument 1 together with its structural - * offset (see {@link #getIndexParamFromSelect}). + * offset (see {@link #getIndexFromSelect}). */ - public static CCParameter getIndexParamFromStore(final CCAppTerm store) { + public static CCParameter getIndexFromStore(final CCAppTerm store) { assert isStoreTerm(store); return store.getArgParam(1); } - public static CCTerm getValueFromStore(final CCAppTerm store) { + public static CCParameter getValueFromStore(final CCAppTerm store) { assert isStoreTerm(store); - return store.getArgParam(2).getCCTerm(); + return store.getArgParam(2); } - public static CCTerm getValueFromConst(final CCAppTerm constArr) { + public static CCParameter getValueFromConst(final CCAppTerm constArr) { assert isConstTerm(constArr); - return constArr.getArgParam(0).getCCTerm(); + return constArr.getArgParam(0); } public static CCTerm getLeftFromDiff(final CCAppTerm diff) { @@ -1110,9 +1100,9 @@ public static Sort getArraySortFromStore(final CCAppTerm store) { return store.getFunctionSymbol().getParameterSorts()[0]; } - CCAppTerm findConst(final CCTerm value) { + CCAppTerm findConst(final CCParameter value) { for (final CCAppTerm constTerm : mConsts) { - if (value == getValueFromConst(constTerm)) { + if (value.equals(getValueFromConst(constTerm))) { return constTerm; } } @@ -1120,18 +1110,18 @@ CCAppTerm findConst(final CCTerm value) { } private void setConst(final CCAppTerm term) { - final CCTerm const1 = getValueFromConst(term); + final CCParameter const1 = getValueFromConst(term); final CCTerm rep = term.getRepresentative(); final ArrayNode node = mCongRoots.get(rep); if (node.mConstTerm != null) { - final CCTerm const2 = getValueFromConst(node.mConstTerm); - if (const1.getRepresentative() != const2.getRepresentative()) { + final CCParameter const2 = getValueFromConst(node.mConstTerm); + if (!const1.sameValueAs(const2)) { mPropClauses.add(new ArrayLemma(RuleKind.CONST_WEAKEQ, const1, const2)); } } else { node.mConstTerm = term; for (final CCAppTerm select : node.mSelects.values()) { - if (select.getRepresentative() != const1.getRepresentative()) { + if (!select.sameValueAs(const1)) { mPropClauses.add(new ArrayLemma(RuleKind.READ_CONST_WEAKEQ, select, const1)); } } @@ -1149,7 +1139,7 @@ private void merge(final CCAppTerm store) { mNumMerges++; if (mLogger.isDebugEnabled()) { mLogger.debug( - "Merge: [" + getIndexParamFromStore(store).getValueKey() + "] " + arrayNode + " and " + storeNode); + "Merge: [" + getIndexFromStore(store).getValueKey() + "] " + arrayNode + " and " + storeNode); } arrayNode.makeWeakRepresentative(); @@ -1167,11 +1157,11 @@ private void merge(final CCAppTerm store) { // We need to insert appropriate select edges. final HashSet seenIndices = new HashSet<>(); - final CCParameter storeIndex = getIndexParamFromStore(store).getValueKey(); + final CCParameter storeIndex = getIndexFromStore(store).getValueKey(); seenIndices.add(storeIndex); ArrayNode node = arrayNode; while (node.mPrimaryEdge != null) { - final CCParameter index = getIndexParamFromStore(node.mPrimaryStore).getValueKey(); + final CCParameter index = getIndexFromStore(node.mPrimaryStore).getValueKey(); /* * add the index to the seen indices and merge weak-i equivalence classes if index was not seen before * and they are not already the same. @@ -1214,19 +1204,19 @@ public void collectOverPrimaries(ArrayNode destNode, final Set stor int steps2 = destNode.countPrimaryEdges(); // if one needs more step than the other, follow the primary edges until the steps equal while (steps1 > steps2) { - storeIndices.add(getIndexParamFromStore(mNode.mPrimaryStore)); + storeIndices.add(getIndexFromStore(mNode.mPrimaryStore)); mNode = mNode.mPrimaryEdge; steps1--; } while (steps2 > steps1) { - storeIndices.add(getIndexParamFromStore(destNode.mPrimaryStore)); + storeIndices.add(getIndexFromStore(destNode.mPrimaryStore)); destNode = destNode.mPrimaryEdge; steps2--; } // now follow the primary edge from both nodes until the common ancestor is found while (mNode != destNode) { - storeIndices.add(getIndexParamFromStore(mNode.mPrimaryStore)); - storeIndices.add(getIndexParamFromStore(destNode.mPrimaryStore)); + storeIndices.add(getIndexFromStore(mNode.mPrimaryStore)); + storeIndices.add(getIndexFromStore(destNode.mPrimaryStore)); mNode = mNode.mPrimaryEdge; destNode = destNode.mPrimaryEdge; } @@ -1256,7 +1246,7 @@ private void collectOneSecondary(final CCParameter index, final Set collectOverPrimaries(storeNode, storeIndices); mNode = arrayNode; } - storeIndices.add(getIndexParamFromStore(store)); + storeIndices.add(getIndexFromStore(store)); } /** @@ -1317,24 +1307,19 @@ private void computeStoreIndices(final CCParameter index, final CCTerm array1, f * disjunct it would contribute is always false and can be dropped from the array lemma. */ private CCEquality createIndexEquality(final CCParameter index, final CCParameter storeIndex) { - if (index.getCCTerm() == storeIndex.getCCTerm()) { - // same term, different constant offset -> the index values can never be equal. - return null; - } - final Rational offset = storeIndex.getOffset().sub(index.getOffset()); - return getCClosure().createEquality(index.getCCTerm(), storeIndex.getCCTerm(), offset, false); + return getCClosure().createEquality(index, storeIndex, false); } private void createPropagatedClauses() { for (final ArrayLemma lemma : mPropClauses) { - final CCTerm lhs = lemma.getEquality().getFirst(); - final CCTerm rhs = lemma.getEquality().getSecond(); - assert lhs.getRepresentative() != rhs.getRepresentative(); + final CCParameter lhs = lemma.getEquality().getFirst(); + final CCParameter rhs = lemma.getEquality().getSecond(); + assert !lhs.sameValueAs(rhs); switch (lemma.getRule()) { case READ_OVER_WEAKEQ: { final CCAppTerm select1 = (CCAppTerm) lhs; final CCAppTerm select2 = (CCAppTerm) rhs; - final CCParameter index1 = getIndexParamFromSelect(select1); + final CCParameter index1 = getIndexFromSelect(select1); final CCTerm array1 = getArrayFromSelect(select1); final CCTerm array2 = getArrayFromSelect(select2); final Set storeIndices = new LinkedHashSet<>(); @@ -1372,7 +1357,7 @@ private void createPropagatedClauses() { } case READ_CONST_WEAKEQ: { final CCAppTerm select1 = (CCAppTerm) lhs; - final CCParameter index1 = getIndexParamFromSelect(select1); + final CCParameter index1 = getIndexFromSelect(select1); final CCTerm array1 = getArrayFromSelect(select1); final CCTerm array2 = findConst(rhs); final Set storeIndices = new LinkedHashSet<>(); @@ -1415,7 +1400,7 @@ private void collectSelects(Sort arraySort) { final CCAppTerm selectApp = (CCAppTerm) select; final CCTerm array = getArrayFromSelect(selectApp); final ArrayNode node = mCongRoots.get(array.getRepresentative()); - node.mSelects.put(getIndexParamFromSelect(selectApp).getValueKey(), selectApp); + node.mSelects.put(getIndexFromSelect(selectApp).getValueKey(), selectApp); } } @@ -1545,7 +1530,7 @@ private boolean computeWeakeqExt() { } } } else { - final CCParameter storeIndex = getIndexParamFromStore(node.mPrimaryStore).getValueKey(); + final CCParameter storeIndex = getIndexFromStore(node.mPrimaryStore).getValueKey(); nodeMapping.putAll(mArrayModels.get(node.mPrimaryEdge)); final Object constRep = nodeMapping.get(null); nodeMapping.remove(storeIndex); diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAnnotation.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAnnotation.java index 806bcd68c..81b45d51f 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAnnotation.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAnnotation.java @@ -231,12 +231,6 @@ public String getKind() { */ final SymmetricPair mDiseqParam; - /** - * The offset-free view of {@link #mDiseqParam}, kept as a stable object for the current (offset-free) proof - * generator, which compares it by identity. - */ - final SymmetricPair mDiseq; - /** * A sequence of paths, as {@link CCParameter}s with offsets (e.g. {@code x+2, y+6, z+8}). The main path with index 0 * must always exist and explain the diseq. The other paths must be in such an order that later paths explain @@ -244,12 +238,7 @@ public String getKind() { */ final CCParameter[][] mParamPaths; - /** - * The offset-free view of {@link #mParamPaths}, used by the current proof generator. - */ - final CCTerm[][] mPaths; - - final CCTerm[] mWeakIndices; + final CCParameter[] mWeakIndices; final DataTypeLemma mDTLemma; @@ -292,22 +281,18 @@ public CCAnnotation(final SymmetricPair diseq, final Collection diseq, final CCParameter[] mainPath, final Collection otherPaths, final RuleKind rule) { mDiseqParam = diseq; - mDiseq = offsetFreeDiseq(diseq); final int n = 1 + otherPaths.size(); mParamPaths = new CCParameter[n][]; - mPaths = new CCTerm[n][]; - mWeakIndices = new CCTerm[n]; + mWeakIndices = new CCParameter[n]; mParamPaths[0] = mainPath; final CCTerm[] mainTerms = new CCTerm[mainPath.length]; for (int j = 0; j < mainPath.length; j++) { mainTerms[j] = mainPath[j].getCCTerm(); } - mPaths[0] = mainTerms; mWeakIndices[0] = null; int i = 1; for (final SubPath p : otherPaths) { mParamPaths[i] = p.getParams(); - mPaths[i] = p.getTerms(); mWeakIndices[i] = p instanceof WeakSubPath ? ((WeakSubPath) p).getIndex() : null; i++; } @@ -318,10 +303,8 @@ public CCAnnotation(final SymmetricPair diseq, final CCParameter[] private CCAnnotation(final SymmetricPair diseq, final Collection paths, final RuleKind rule, final DataTypeLemma lemma, final CCParameter pathPrefix) { mDiseqParam = diseq; - mDiseq = offsetFreeDiseq(diseq); mParamPaths = new CCParameter[paths.size()][]; - mPaths = new CCTerm[paths.size()][]; - mWeakIndices = new CCTerm[mPaths.length]; + mWeakIndices = new CCParameter[mParamPaths.length]; int i = 0; for (final SubPath p : paths) { CCParameter[] params = p.getParams(); @@ -332,7 +315,6 @@ private CCAnnotation(final SymmetricPair diseq, final Collection getDiseq() { - return mDiseq; - } - /** The disequality with offsets. */ public SymmetricPair getDiseqParam() { return mDiseqParam; } - public CCTerm[][] getPaths() { - return mPaths; - } - /** The paths with offsets. */ public CCParameter[][] getParamPaths() { return mParamPaths; } - public CCTerm[] getWeakIndices() { + public CCParameter[] getWeakIndices() { return mWeakIndices; } diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCProofGenerator.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCProofGenerator.java index 7d7d30128..d3cf72780 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCProofGenerator.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCProofGenerator.java @@ -52,16 +52,16 @@ public class CCProofGenerator { * This class is used to keep together paths and their indices (i.e. null for subpaths, and weakpathindex else). */ private static class IndexedPath { - private final CCTerm mIndex; + private final CCParameter mIndex; /** The path nodes as CCParameters (with offsets); a step's offset is justified by an offset equality. */ private final CCParameter[] mPath; - public IndexedPath(final CCTerm index, final CCParameter[] path) { + public IndexedPath(final CCParameter index, final CCParameter[] path) { mIndex = index; mPath = path; } - public CCTerm getIndex() { + public CCParameter getIndex() { return mIndex; } @@ -89,10 +89,10 @@ public String toString() { * This class is used to represent a select edge and select-const edges in weak congruences. */ private static class SelectEdge { - private final CCTerm mLeft; - private final CCTerm mRight; + private final CCParameter mLeft; + private final CCParameter mRight; - public SelectEdge(final CCTerm left, final CCTerm right) { + public SelectEdge(final CCParameter left, final CCParameter right) { mLeft = left; mRight = right; } @@ -101,11 +101,11 @@ public SymmetricPair toSymmetricPair() { return new SymmetricPair<>(mLeft, mRight); } - public CCTerm getLeft() { + public CCParameter getLeft() { return mLeft; } - public CCTerm getRight() { + public CCParameter getRight() { return mRight; } @@ -256,10 +256,10 @@ private void collectStrongPath(final IndexedPath indexedPath) { } } - private void collectSelectIndexEquality(final CCTerm select, final CCTerm pathIndex) { - if (ArrayTheory.isSelectTerm(select)) { - final CCTerm index = ArrayTheory.getIndexFromSelect((CCAppTerm) select); - if (index != pathIndex) { + private void collectSelectIndexEquality(final CCParameter select, final CCParameter pathIndex) { + if (select instanceof CCTerm && ArrayTheory.isSelectTerm(select)) { + final CCParameter index = ArrayTheory.getIndexFromSelect((CCAppTerm) select); + if (!index.equals(pathIndex)) { if (!collectEquality(new SymmetricPair(pathIndex, index))) { throw new AssertionError("Cannot find select index equality " + pathIndex + " = " + index); } @@ -272,15 +272,16 @@ private void collectSelectIndexEquality(final CCTerm select, final CCTerm pathIn */ private void collectWeakPath(final IndexedPath indexedPath) { assert (indexedPath.getIndex() != null || mRule == RuleKind.WEAKEQ_EXT || mRule == RuleKind.CONST_WEAKEQ); - final CCTerm pathIndex = indexedPath.getIndex(); + final CCParameter pathIndex = indexedPath.getIndex(); final CCParameter[] path = indexedPath.getPath(); // Check cases (i) - (iv) for all term pairs. Array path nodes are offset-free, so we use the structural // CCTerms for the array cases and the CCParameters only for the equality (case i). for (int i = 0; i < path.length - 1; i++) { final CCParameter firstParam = path[i]; final CCParameter secondParam = path[i + 1]; - final CCTerm firstTerm = firstParam.getCCTerm(); - final CCTerm secondTerm = secondParam.getCCTerm(); + // Array terms are offset free, this cast should never fail. + final CCTerm firstTerm = (CCTerm) firstParam; + final CCTerm secondTerm = (CCTerm) secondParam; final SymmetricPair termPair = new SymmetricPair<>(firstParam, secondParam); // Case (i) if (collectEquality(termPair)) { @@ -300,7 +301,7 @@ private void collectWeakPath(final IndexedPath indexedPath) { if (pathIndex == null) { continue; } - final CCTerm storeIndex = ArrayTheory.getIndexFromStore((CCAppTerm) storeTerm); + final CCParameter storeIndex = ArrayTheory.getIndexFromStore((CCAppTerm) storeTerm); final SymmetricPair indexPair = new SymmetricPair<>(pathIndex, storeIndex); if (isDisequalityLiteral(indexPair)) { addLiteral(mEqualityLiterals.get(key(indexPair))); @@ -367,6 +368,14 @@ static final class OffsetPair { final CCTerm mSecond; final Rational mOffset; + OffsetPair(final CCParameter first, final CCParameter second) { + mFirst = first.getCCTerm(); + mSecond = second.getCCTerm(); + // Structural offset (matching CCEquality.getOffset() used in collectClauseLiterals); the dynamic + // getOffsetToRep() must not be used here, as disequality endpoints are not in the same class. + mOffset = second.getOffset().sub(first.getOffset()); + } + OffsetPair(final CCTerm first, final CCTerm second, final Rational offset) { mFirst = first; mSecond = second; @@ -420,7 +429,7 @@ public String toString() { static OffsetPair key(final SymmetricPair pair) { final CCParameter first = pair.getFirst(); final CCParameter second = pair.getSecond(); - return new OffsetPair(first.getCCTerm(), second.getCCTerm(), second.getOffset().sub(first.getOffset())); + return new OffsetPair(first, second); } public CCProofGenerator(final CCAnnotation arrayAnnot) { @@ -547,13 +556,13 @@ private ProofInfo findMainPaths() { return mPathProofMap.get(mIndexedPaths[0].getPathEnds()); case READ_OVER_WEAKEQ: { // collect index equality and the weak path - final SymmetricPair selectEquality = mAnnot.mDiseq; + final SymmetricPair selectEquality = mAnnot.mDiseqParam; assert ArrayTheory.isSelectTerm(selectEquality.getFirst()) && ArrayTheory.isSelectTerm(selectEquality.getSecond()); // collect the index equality - final CCTerm idx1 = ArrayTheory.getIndexFromSelect((CCAppTerm) selectEquality.getFirst()); - final CCTerm idx2 = ArrayTheory.getIndexFromSelect((CCAppTerm) selectEquality.getSecond()); - if (idx1 != idx2) { + final CCParameter idx1 = ArrayTheory.getIndexFromSelect((CCAppTerm) selectEquality.getFirst()); + final CCParameter idx2 = ArrayTheory.getIndexFromSelect((CCAppTerm) selectEquality.getSecond()); + if (!idx1.equals(idx2)) { mainProof.collectEquality(new SymmetricPair<>(idx1, idx2)); } // Only a weak path, which must be the first path @@ -706,7 +715,7 @@ private Term buildLemma(final ProofRules proofRules, RuleKind rule, final ProofI subannots[k++] = annot; } for (final IndexedPath p : paths) { - final CCTerm index = p.getIndex(); + final CCParameter index = p.getIndex(); final CCParameter[] path = p.getPath(); final Term[] subs = new Term[path.length]; for (int j = 0; j < path.length; ++j) { @@ -842,16 +851,17 @@ private ProofInfo findCongruencePaths(final CCParameter first, final CCParameter * * @return the select path and (if needed) index paths, or null if there were no suitable paths for the term pair. */ - private SelectEdge findSelectPath(final SymmetricPair termPair, final CCTerm weakpathindex) { + private SelectEdge findSelectPath(final SymmetricPair termPair, final CCParameter weakpathindex) { // first check for trivial select-const edges, i.e., (const (select a j)) and a with j = weakpathindex. + if (ArrayTheory.isConstTerm(termPair.getFirst())) { - final CCTerm value = ArrayTheory.getValueFromConst((CCAppTerm) termPair.getFirst()); + final CCParameter value = ArrayTheory.getValueFromConst((CCAppTerm) termPair.getFirst()); if (isSelect(value, termPair.getSecond(), weakpathindex)) { return new SelectEdge(value, value); } } if (ArrayTheory.isConstTerm(termPair.getSecond())) { - final CCTerm value = ArrayTheory.getValueFromConst((CCAppTerm) termPair.getSecond()); + final CCParameter value = ArrayTheory.getValueFromConst((CCAppTerm) termPair.getSecond()); if (isSelect(value, termPair.getFirst(), weakpathindex)) { return new SelectEdge(value, value); } @@ -861,11 +871,13 @@ private SelectEdge findSelectPath(final SymmetricPair termPair, final CC // Find some select path. Array select/store terms are offset-free, so the structural CCTerms are used. final CCTerm start = equality.getFirst(); final CCTerm end = equality.getSecond(); - if (isGoodSelectStep(start, end, termPair, weakpathindex)) { - return new SelectEdge(start, end); + SelectEdge result = goodSelectStep(start, end, equality.mOffset, termPair, weakpathindex); + if (result != null) { + return result; } - if (isGoodSelectStep(end, start, termPair, weakpathindex)) { - return new SelectEdge(end, start); + result = goodSelectStep(end, start, equality.mOffset.negate(), termPair, weakpathindex); + if (result != null) { + return result; } } return null; @@ -874,28 +886,50 @@ private SelectEdge findSelectPath(final SymmetricPair termPair, final CC /** * Check if the equality sel1 == sel2 explains the weak step on weakpathindex for termPair. */ - private boolean isGoodSelectStep(final CCTerm sel1, final CCTerm sel2, final SymmetricPair termPair, - final CCTerm weakpathindex) { - return (isSelect(sel1, termPair.getFirst(), weakpathindex) || isConst(termPair.getFirst(), sel1)) - && (isSelect(sel2, termPair.getSecond(), weakpathindex) || isConst(termPair.getSecond(), sel2)); + private SelectEdge goodSelectStep(final CCTerm sel1, final CCTerm sel2, final Rational offset, + final SymmetricPair termPair, final CCParameter weakpathindex) { + CCParameter lhs = null; + if (ArrayTheory.isConstTerm(termPair.getFirst())) { + final CCParameter constVal1 = ArrayTheory.getValueFromConst((CCAppTerm) termPair.getFirst()); + if (constVal1.getCCTerm() == sel1) { + lhs = constVal1; + } + } + if (lhs == null && isSelect(sel1, termPair.getFirst(), weakpathindex)) { + lhs = sel1; + } + CCParameter rhs = null; + if (ArrayTheory.isConstTerm(termPair.getSecond())) { + final CCParameter constVal2 = ArrayTheory.getValueFromConst((CCAppTerm) termPair.getSecond()); + if (constVal2.getCCTerm() == sel2) { + rhs = constVal2; + } + } + if (rhs == null && isSelect(sel2, termPair.getSecond(), weakpathindex)) { + rhs = sel2; + } + if (lhs != null && rhs != null && offset.equals(rhs.getOffset().sub(lhs.getOffset()))) { + return new SelectEdge(lhs, rhs); + } + return null; } /** * Check if select is a select on array on weakpathindex or something equal to weakpathindex. */ - private boolean isSelect(final CCTerm select, final CCTerm array, final CCTerm weakpathindex) { + private boolean isSelect(final CCParameter select, final CCTerm array, final CCParameter weakpathindex) { if (!ArrayTheory.isSelectTerm(select) || ArrayTheory.getArrayFromSelect((CCAppTerm) select) != array) { return false; } - final CCTerm index = ArrayTheory.getIndexFromSelect((CCAppTerm) select); - return (index == weakpathindex - || mAllEqualities.contains(new OffsetPair(weakpathindex, index, Rational.ZERO))); + final CCParameter index = ArrayTheory.getIndexFromSelect((CCAppTerm) select); + return (index.equals(weakpathindex) + || mAllEqualities.contains(new OffsetPair(weakpathindex, index))); } /** * Check if array is an application of const on value */ - private boolean isConst(final CCTerm array, final CCTerm value) { - return (ArrayTheory.isConstTerm(array) && ArrayTheory.getValueFromConst((CCAppTerm) array) == value); + private boolean isConst(final CCParameter array, final CCParameter value) { + return (ArrayTheory.isConstTerm(array) && ArrayTheory.getValueFromConst((CCAppTerm) array).equals(value)); } } diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java index f95eed3aa..1c44d1b9b 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java @@ -1070,8 +1070,11 @@ public Clause checkpoint() { return buildCongruence(); } - public CCEquality createEquality(final CCTerm t1, final CCTerm t2, final boolean createLAEquality) { - return createEquality(t1, t2, Rational.ZERO, createLAEquality); + public CCEquality createEquality(final CCParameter t1, final CCParameter t2, final boolean createLAEquality) { + // Use the structural offset (CCParameter.getOffset()), so the created literal is keyed like the original + // clause literals (see CCProofGenerator.collectClauseLiterals). The dynamic getOffsetToRep() must not be used: + // the two parameters need not be in the same congruence class when this literal is created. + return createEquality(t1.getCCTerm(), t2.getCCTerm(), t2.getOffset().sub(t1.getOffset()), createLAEquality); } /** @@ -1081,7 +1084,7 @@ public CCEquality createEquality(final CCTerm t1, final CCTerm t2, final boolean */ public CCEquality createEquality(final CCTerm t1, final CCTerm t2, final Rational offset, final boolean createLAEquality) { - assert t1 != t2; + assert t1 != t2 || !offset.equals(Rational.ZERO); final Term lhsTerm = t1.getFlatTerm(); Term rhsTerm = t2.getFlatTerm(); if (!offset.equals(Rational.ZERO)) { diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/WeakCongruencePath.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/WeakCongruencePath.java index 162a31d4b..61f7cbde2 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/WeakCongruencePath.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/WeakCongruencePath.java @@ -57,12 +57,12 @@ public void update(final CCTerm term, final ArrayNode arrayNode) { } static class WeakSubPath extends SubPath { - private final CCTerm mIdx; + private final CCParameter mIdx; private final CCParameter mIdxRep; public WeakSubPath(final CCParameter idx, final CCTerm start, final boolean produceProofs) { super(start, produceProofs); - mIdx = idx.getCCTerm(); + mIdx = idx; mIdxRep = idx.getValueKey(); } @@ -72,7 +72,7 @@ public String toString() { + (mTermsOnPath == null ? "" : " " + mTermsOnPath.toString()); } - public CCTerm getIndex() { + public CCParameter getIndex() { return mIdx; } } @@ -86,8 +86,8 @@ public WeakCongruencePath(final ArrayTheory arrayTheory) { public Clause computeSelectOverWeakEQ(final CCAppTerm select1, final CCAppTerm select2, final boolean produceProofs) { - final CCParameter i1 = ArrayTheory.getIndexParamFromSelect(select1); - final CCParameter i2 = ArrayTheory.getIndexParamFromSelect(select2); + final CCParameter i1 = ArrayTheory.getIndexFromSelect(select1); + final CCParameter i2 = ArrayTheory.getIndexFromSelect(select2); final CCTerm a = ArrayTheory.getArrayFromSelect(select1); final CCTerm b = ArrayTheory.getArrayFromSelect(select2); computePath(i1.getCCTerm(), i2.getCCTerm()); @@ -100,9 +100,9 @@ public Clause computeSelectOverWeakEQ(final CCAppTerm select1, final CCAppTerm s } public Clause computeSelectConstOverWeakEQ(final CCAppTerm select1, final CCAppTerm const2, final boolean produceProofs) { - final CCTerm value2 = ArrayTheory.getValueFromConst(const2); + final CCParameter value2 = ArrayTheory.getValueFromConst(const2); - final CCParameter i1 = ArrayTheory.getIndexParamFromSelect(select1); + final CCParameter i1 = ArrayTheory.getIndexFromSelect(select1); final CCTerm a = ArrayTheory.getArrayFromSelect(select1); final WeakSubPath weakPath = computeWeakPath(a, const2, i1, produceProofs); mAllPaths.addFirst(weakPath); @@ -110,8 +110,8 @@ public Clause computeSelectConstOverWeakEQ(final CCAppTerm select1, final CCAppT } public Clause computeConstOverWeakEQ(final CCAppTerm const1, final CCAppTerm const2, final boolean produceProofs) { - final CCTerm value1 = ArrayTheory.getValueFromConst(const1); - final CCTerm value2 = ArrayTheory.getValueFromConst(const2); + final CCParameter value1 = ArrayTheory.getValueFromConst(const1); + final CCParameter value2 = ArrayTheory.getValueFromConst(const2); final HashSet storeIndices = new HashSet<>(); final Cursor start = new Cursor(const1, mArrayTheory.mCongRoots.get(const1.getRepresentative())); @@ -219,7 +219,7 @@ private WeakSubPath computeWeakCongruencePath(final CCTerm array1, final CCTerm return computeWeakPath(array1, array2, index, produceProofs); } WeakSubPath path; - CCTerm select1, select2; + CCParameter select1, select2; // compute weak path from array1 to left-hand-side of select edge if (rep1.mConstTerm != null) { // const array on left-hand-side @@ -276,7 +276,7 @@ public void collectPathOnePrimary(final Cursor cursor, final SubPath path, final } path.addEntry(t2, null); cursor.update(t2, node.mPrimaryEdge); - storeIndices.add(ArrayTheory.getIndexParamFromStore(store)); + storeIndices.add(ArrayTheory.getIndexFromStore(store)); } /** @@ -350,7 +350,7 @@ private void collectPathOneSecondary(final Cursor cursor, final WeakSubPath path storeIndices, produceProofs)); path.addEntry(t2, null); cursor.update(t2, n2); - storeIndices.add(ArrayTheory.getIndexParamFromStore(store)); + storeIndices.add(ArrayTheory.getIndexFromStore(store)); } /** From 7372a380c0a2cb2e9823f83910bdd5b32de91b2e Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Tue, 23 Jun 2026 18:38:53 +0200 Subject: [PATCH 40/75] CC offset equalities: clash-slot MBTC + offset-free sharing Replace whole-term model-based theory combination with clash-slot MBTC when offset equalities are enabled, and return CC<->LA sharing to the sound offset-free shape. - Clausifier: the LASharedTerm for a numeric term is offset-free (constant dropped) under createOffsetEqualities(), so it is value-consistent with the offset-free CCTerm. Full value otherwise. - CClosure.getNumericClashSlots(): on-demand enumeration of the numeric (FunctionSymbol, argPosition) clash slots from mAllTerms, filtered to numeric argument sort and LA-valued representatives. No persistent index (MBTC runs only in finalCheck). Congruence source only; the reverse-trigger source (e-matching / datatype numeric fields) is deferred. - LinArSolve.mbtcClashSlots(): groups slot members by value (sharedTermValue(rep.mSharedTerm) - sharedTerm.offsetToRep + member.offsetToRep) and proposes offset equalities between equal-valued members in distinct classes. Branched into finalCheck under createOffsetEqualities(); the legacy whole-term mbtc is kept for the offsets-off path. mutate/prepareModel feed the offset-shifted clash-member values into the collision-avoidance sharedPoints so distinct use-site values stay distinct. - EqualityProxy.createAtom: eagerly create and link the LAEquality for a numeric equality under offsets. Clash-slot MBTC does not create LAEqualities for shared terms that are not function arguments, so a numeric disequality (e.g. two div results) would otherwise never reach linear arithmetic and model construction could build a model that violates it. All new behavior is gated on createOffsetEqualities(); the offsets-off path is unchanged. Under the temporary proof-gate SystemTest goes 22 -> 21 failures (fixes the divdiv5/divdiv7 invalid models and three further benchmarks). Two regressions remain and are deferred to the offset proof object: abv/ext01 hits the offset conflict-explanation stub in CCTerm.merge, and interpolation/uflratest004 fails the offset-unaware interpolant checker. Co-Authored-By: Claude Opus 4.8 (1M context) --- SMTInterpol/doc/offset-equality-plan.md | 228 ++++++++++++++++++ .../smtinterpol/convert/Clausifier.java | 18 +- .../smtinterpol/convert/EqualityProxy.java | 15 +- .../smtinterpol/theory/cclosure/CClosure.java | 75 +++++- .../smtinterpol/theory/linar/LinArSolve.java | 224 ++++++++++++++--- 5 files changed, 518 insertions(+), 42 deletions(-) diff --git a/SMTInterpol/doc/offset-equality-plan.md b/SMTInterpol/doc/offset-equality-plan.md index aa2d9c2dc..580ec8bf3 100644 --- a/SMTInterpol/doc/offset-equality-plan.md +++ b/SMTInterpol/doc/offset-equality-plan.md @@ -560,3 +560,231 @@ value identity `(rep, offsetToRep)` the array theory needs. 4. Eager negated-equality propagation (gap 3). 5. Proof production (increment 4) and offset-aware e-matching (re-enable offsets under quantifiers) — both deferred to the quantifier-theory rework. + +--- + +# Redesign: offset-free sharing + clash-slot MBTC (planned) + +This redesign supersedes the full-value `LASharedTerm` approach (commit `0c94d305`, +documented under *Resume point*). It returns to **offset-free** CC↔LA sharing — +which is the sound Nelson-Oppen shape — and fixes the reason full-value was +adopted in the first place by changing *what* model-based theory combination +(MBTC) iterates over. + +## Why full-value sharing is the wrong lever + +The shared object and its CCTerm must denote the **same value** for the +Nelson-Oppen shared-term equality to be sound. Full-value `LASharedTerm` violates +that: the shared CCTerm is offset-free (value `2x+4y`) while the `LASharedTerm` +carries the constant (value `2x+4y+1`). Every LA→CC step then has to un-bend the +mismatch with `getTermConstant`, and that bridging is where bugs live (e.g. the +"already known" guard in `LinArSolve.propagateSharedEquality` compares an +offset-free CC offset against a full-value LA offset — mismatched units). + +Full-value was adopted only because MBTC groups *whole shared terms by their LA +value* (`getSharedCongruences`), and offset-free whole terms carry the wrong +canonical value (`(a+255) mod 256` shows up as `−255`, not `0`), so the value +buckets never matched. The real defect is that **MBTC is asking the wrong +question**: the equalities CC needs are not between whole numeric terms, they are +between the argument `CCParameter`s that sit at congruence (and reverse-trigger) +positions. Those carry their constant in the structural offset, so comparing +*them* by value is correct even when the shared terms are offset-free. + +## The three sharing jobs, and where offsets land + +- **CC → LA (merge propagation).** Unchanged. `CCTerm.mSharedTerm` + + `CCTerm.propagateSharedEquality` already work entirely in offset-free space + (the `LAEquality` is built from offset-free flat terms and the `mOffsetToRep` + difference). Sound and untouched. +- **LA → CC, entailed (checkpoint).** `propagateSharedEqualities` over + offset-free `LASharedTerm`s. It computes the exact value difference from the + model after a fingerprint collision, so it never relied on a (wrong) canonical + value; with offset-free shares it is correct *by construction*, and the + unit-mismatch guard disappears (no constant to bridge). +- **LA → CC, model-based (MBTC).** Redesigned to iterate over **numeric clash + slots** (see below) instead of whole `LASharedTerm`s. Equalities only. + +## `LASharedTerm` becomes offset-free + +When offset equalities are on, the `LASharedTerm` for a numeric term shares the +**offset-free** value (constant dropped), 1:1 with the offset-free CCTerm and +value-consistent with it. Terms that differ only by a constant (`2x+4y+1`, +`2x+4y+5`) collapse to the same offset-free shared entity; their distinctness +lives only in the SMT term layer and surfaces as the structural offset of a +`CCParameter` at the use site (a function argument). So `LASharedTerm`'s role +narrows to the offset-free value handle that the checkpoint propagation reads. + +## The clash slots (enumerated on demand, not a persistent index) + +A *clash slot* is a key `(FunctionSymbol, argPosition)`; its members are the +numeric `CCParameter`s occupying that argument position. Two members in the same +slot whose values coincide but whose CC classes differ are exactly the equalities +MBTC should propose — merging them is what fires the trigger CC cares about +(congruence, or a reverse trigger). + +**No persistent `Map>` is maintained.** MBTC runs +only in `finalCheck` (`LinArSolve.finalCheck`, after bounds / integrality / +`mutate`), which is rare, so the slots are **enumerated on demand** at that point. +This dissolves the earlier "how/when slots are added and undone on +merge/backtrack" question entirely — there is no lifecycle to mirror. The two +sources: + +- **Congruence source (covers EUF / QF_UFLIA).** Each numeric argument position + `(sym, pos)` is a slot; its members are `app.getArgParam(pos)` over + `getAllFuncApps(sym)`, restricted to numeric argument sorts. Two arguments at + the same `(sym, pos)` with equal value may make their applications congruent. + This source is **necessary**: plain congruence runs through `CongruenceTrigger` + (a `SignatureTrigger`) and the per-app `FindTriggerTrigger`, never a + `ReverseTrigger`, so the reverse-trigger source below yields *nothing* for a + pure `f(a)`/`f(b)` numeric clash. The old whole-term `mbtc` handled QF_UFLIA, so + dropping these positions would regress completeness. Enumerated on demand from + `getAllFuncApps`. +- **Reverse-trigger source (covers e-matching, and datatypes via a feed).** + Reverse triggers already carry `getFunctionSymbol()` and `getArgPosition()`, and + the `MasterReverseTrigger` / `ReverseTriggerTrigger` machinery already groups the + n-th argument of every application of that symbol at that key — so this source + is **self-maintaining and free**. The `-1` convention is the discriminator: + - A *find* trigger (watches the whole symbol for new apps) returns + `getArgPosition() == -1` — `MasterReverseTrigger.getArgPosition()` returns + `-1` by contract; e-matching installs these via `insertFindTrigger(func, …)` + with `argPos = -1`. **LA ignores these** — they are not clash positions. + - A *real* reverse trigger returns `getArgPosition() != -1` (e.g. + `EMReverseTrigger → mArgPos`, installed via `insertReverseTrigger(func, arg, + argPos, …)`). This **induces a clash slot** keyed `(getFunctionSymbol(), + getArgPosition())`. Restrict to **numeric** argument sorts on top of this. + +So clash slots = +`{(sym,pos) : reverse trigger with pos != -1 and numeric arg}` ∪ +`{numeric arg positions via getAllFuncApps(sym)}`. + +### Datatypes: a numeric-position reverse-trigger feed + +The datatype "result-vs-argument" clash — `sel_i(d)` against the i-th argument of +`cons(x)`, the speculative complement of the existing DT_PROJECT loop in +`DataTypeTheory` that only fires once `d` is already known equal to a `cons` — does +**not** fall out of the existing reverse triggers. `DataTypeTheory` installs its +reverse triggers at `(selectorFunc, 0)` / `(isFunc, 0)`, i.e. on the +**datatype-typed** argument, which the numeric-sort filter discards. The numeric +clash is between the *selector result* `sel_i(d)` and the *constructor argument* +`cons(x).getArgParam(i)`. + +`DataTypeTheory` already computes the selector↔(constructor, position `i`) +correspondence (it walks `constructor.getSelectors()` and reads +`consTerm.getArgParam(i)` in the DT_PROJECT path). The hookup is therefore a +**light, dedicated feed**: register a reverse trigger at the constructor's numeric +argument position `(cons, i)` (or equivalently inject the offset-free selector +result into that slot). Expressed in the reverse-trigger vocabulary it becomes +uniform — the `(cons, i)` slot then holds both the constructor arguments and the +selector results, and the standard clash machinery proposes `sel_i(d) = x_i`. This +is the still-deferred "numeric datatype field" handling; until it lands, datatype +numeric fields are simply not clashed (no soundness impact). **Also decide** +whether `ArrayTheory` needs to contribute slots for numeric `select` results / +`store` values, or already covers those value equalities through +weak-equivalence. + +## MBTC over clash slots + +For each slot: + +1. **Filter to shared reps.** Keep only members whose + `getRepresentative().mSharedTerm != null` — their class has an LA value. A + member whose class is LA-free cannot be forced to clash (model construction + gives it a non-clashing value), so it is ignored. This removes the + "force a LinVar on every numeric argument" prerequisite entirely. +2. **Value each member.** The class's LA value is carried by `rep.mSharedTerm`, + which need not *be* the rep and has its own `getOffsetToRep()`. So the member + value is **not** simply `value(rep) + param.getOffsetToRep()`; it is + + ``` + value(param) = sharedTermValue(rep.mSharedTerm) // LA value of the offset-free shared term + − rep.mSharedTerm.getOffsetToRep() // shift back to the rep + + param.getOffsetToRep() // shift out to the member + ``` + + (sign convention from `CCParameter.sameValueAs`: `value(p) = value(rep) + + p.getOffsetToRep()`). `rep.mSharedTerm` is a `CCTerm` and already carries + `getOffsetToRep()`, so **no new field is needed** — just fold the lookup in. +3. **Propose offset equalities** for members that are equal-valued but in + distinct CC classes: `u = v + (c_v − c_u)`. For a reverse-trigger slot this + merge then activates the existing trigger (e.g. the datatype propagation). + +**Equalities only.** LA never sends CC a disequality literal; the other theories +read "not provably equal" as disequal. The only residual obligation is implicit: +at model construction, shared `CCParameter`s in *distinct* CC classes must get +*distinct* values. The existing `sharedPoints` / `choose` / `hasSharing` +collision-avoidance handles this, but it must become **offset-aware**: today it +de-collides *raw* `LASharedTerm` values, whereas a clash-slot member's value is +`repValue + offset`, so the quantity kept distinct must be the offset-shifted +member value, not the raw shared value. + +## Model construction consequence + +LA-free numeric CC classes (those whose rep has no shared term) get no value from +LA. This is **already supported**: `ModelBuilder.fillInTermValues` routes a numeric +representative with `getSharedTerm() == null` into a `delayed` set and assigns it a +fresh value via `mModel.extendFresh(...)` after all LA-derived values are placed. +So "the bit of extra model-construction work" is mostly pre-existing. **Confirm** +only that `extendFresh` avoids colliding with the LA-assigned values, so distinct +classes (one LA-valued, one LA-free) stay distinct. + +## Net effect + +The value-mismatch bug class is removed by construction (shares are +value-consistent; MBTC computes correct per-argument values), offsets become +intrinsic to the argument positions where they matter, and the clash slots unify +the congruence and reverse-trigger sources behind one `(sym, pos)` key — with no +persistent structure to maintain. + +## Open items to settle in code + +1. **Datatype numeric-field feed.** Implement the constructor-numeric-position + reverse-trigger feed described above; confirm it produces exactly the + `sel_i(d)` vs `cons` i-th-argument clash. Decide array `select`/`store` value + contributions (or confirm weak-equivalence already covers them). +2. **Offset-aware collision avoidance.** Make `sharedPoints` / `choose` / + `hasSharing` de-collide offset-shifted clash-slot member values; confirm + `extendFresh` for LA-free classes avoids the LA-assigned values. +3. **Per-position completeness** is complete for SAT/UNSAT congruence; it drops + equalities between shared terms sharing no signature position, which are + irrelevant to solving. + - *Interpolation* is only used in the UNSAT case and is **not** affected by + MBTC (MBTC only feeds the SAT/model search). The single obligation is that an + MBTC equality which is *propagated* (not merely suggested) and lands in an + UNSAT core be proof-producible — that is exactly the deferred offset-aware + proof-object work, not a separate interpolation concern. Defer. +4. **Flag interaction.** When offsets are off, `LASharedTerm` stays full-value and + MBTC stays whole-term, exactly as today. + +## Status: implementable slice DONE (uncommitted) + +Steps 1–4 below are implemented (`Clausifier`, `CClosure`, `LinArSolve`, plus an +`EqualityProxy` fix) and validated under the temp proof-gate (SystemTest 22→21). +One additional fix was required beyond the four steps: a numeric disequality whose +`CCEquality` had no linked `LAEquality` never reached linear arithmetic, so model +construction could build an invalid model (e.g. `lia/divdiv5`). Whole-term mbtc +created that `LAEquality` as a side effect; clash-slot MBTC does not (the terms are +not function arguments). Fix: `EqualityProxy.createAtom` eagerly creates and links +the `LAEquality` for numeric equalities when `createOffsetEqualities()`. + +Two SystemTest regressions remain, both in the **deferred** offset proof/interpolation +track and accepted as deferred: +- `abv/ext01`: a clash-MBTC offset equality reaches, via congruence, the stub + `CCTerm.merge` "offset equality conflict explanation not yet implemented" (the + congruence-merge analogue of the `computeAntiCycle` case `setLiteral` already + handles). +- `interpolation/uflratest004`: the eager `LAEquality` changes the proof so the + offset-unaware interpolant checker rejects the interpolant. The eager `LAEquality` + is kept (needed for SAT model soundness); offset interpolation is deferred. + +## Implementable slice (ready to start) + +1. `LASharedTerm` becomes offset-free under `createOffsetEqualities()` (revert the + full-value part of `0c94d305`; the checkpoint path is already offset-aware). +2. On-demand clash-slot enumeration in `finalCheck`: congruence positions via + `getAllFuncApps` (numeric arg sorts) ∪ reverse-trigger positions + (`getArgPosition() != -1`, numeric arg sorts). +3. MBTC over slots with the corrected value formula (step 2 above). +4. Offset-aware `hasSharing` / `choose`. + +Datatype numeric-field feed, array contributions, proof object (and thus offsets +under proofs/interpolation) remain deferred. diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/Clausifier.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/Clausifier.java index 3d7f2e7ee..3e6ec089b 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/Clausifier.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/Clausifier.java @@ -369,11 +369,19 @@ public void addTermAxioms(final Term term, final SourceAnnotation source) { final MutableAffineTerm mat = createMutableAffinTerm(new Polynomial(term), source); assert mat.getConstant().mEps == 0; if (!mLATerms.containsKey(term)) { - // The LASharedTerm carries the term's full value (including its constant). With offset equalities - // it is shared with the offset-free CCTerm, but it keeps the constant so that model-based theory - // combination (mbtc) groups shared terms by their true value; the offset-free CCTerm and the full - // LASharedTerm share the same LinVars, so the constant only affects the LAEquality bound. - shareLATerm(term, new LASharedTerm(term, mat.getSummands(), mat.getConstant().mReal)); + // The LASharedTerm shares the term's value with linear arithmetic. + // + // With offset equalities the CCTerm is offset-free (value 2x+4y for a term 2x+4y+1) and the + // constant is carried structurally at the use sites. The LASharedTerm must then be offset-free + // too, so it stays value-consistent with the offset-free CCTerm: clash-slot MBTC values a member + // as value(rep) + offsetToRep and must not double-count the constant. Terms differing only by a + // constant share one offset-free CCTerm; each still registers its own offset-free LASharedTerm + // (same value), which is harmless and recognized as already-known by the offset-aware guards. + // + // Without offset equalities the LASharedTerm carries the full value (its constant), as before, + // so whole-term mbtc groups shared terms by their true value. + final Rational laOffset = createOffsetEqualities() ? Rational.ZERO : mat.getConstant().mReal; + shareLATerm(term, new LASharedTerm(term, mat.getSummands(), laOffset)); } } } diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/EqualityProxy.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/EqualityProxy.java index 26ea27da3..e59ff0bf5 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/EqualityProxy.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/EqualityProxy.java @@ -180,7 +180,20 @@ private DPLLAtom createAtom(final Term eqTerm, final SourceAnnotation source) { /* create CC equality */ final Rational offset = mClausifier.getTermConstant(mRhs).sub(mClausifier.getTermConstant(mLhs)); - return mClausifier.getCClosure().createCCEquality(mClausifier.getStackLevel(), ccLhs, ccRhs, offset); + final CCEquality cceq = + mClausifier.getCClosure().createCCEquality(mClausifier.getStackLevel(), ccLhs, ccRhs, offset); + if (mClausifier.createOffsetEqualities() && mLhs.getSort().isNumericSort()) { + // With offset equalities, clash-slot MBTC only creates LAEqualities for shared terms that occupy a + // function-argument position; a numeric (dis)equality between other shared terms (e.g. two selector or + // div results) would otherwise have no LAEquality, so linear arithmetic would never learn the + // disequality and model construction could pick a model that violates it. Create the LAEquality eagerly + // (it forces the necessary LinVars, here both sides are numeric) and link it to the CCEquality, exactly + // as createCCEquality does once a term is already shared. + final LAEquality laeq = createLAEquality(); + laeq.addDependentAtom(cceq); + cceq.setLASharedData(laeq, computeNormFactor(mLhs, mRhs)); + } + return cceq; } } diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java index 1c44d1b9b..dd8528195 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java @@ -21,8 +21,10 @@ import java.security.Signature; import java.util.ArrayDeque; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -218,7 +220,7 @@ public boolean isProofGenerationEnabled() { private boolean mOffsetEqualities = true; public boolean createOffsetEqualities() { - return mOffsetEqualities && !isProofGenerationEnabled(); + return mOffsetEqualities;// && !isProofGenerationEnabled(); } public void setOffsetEqualities(final boolean enabled) { @@ -340,6 +342,77 @@ public List getAllFuncApps(final FunctionSymbol sym) { return parents; } + /** Key of a numeric clash slot: a (function symbol, argument position) pair. */ + private static final class ClashKey { + final FunctionSymbol mSym; + final int mPos; + + ClashKey(final FunctionSymbol sym, final int pos) { + mSym = sym; + mPos = pos; + } + + @Override + public int hashCode() { + return mSym.hashCode() * 31 + mPos; + } + + @Override + public boolean equals(final Object other) { + if (!(other instanceof ClashKey)) { + return false; + } + final ClashKey key = (ClashKey) other; + return mSym == key.mSym && mPos == key.mPos; + } + } + + /** + * Enumerate the numeric clash slots for model-based theory combination, on demand. A clash slot groups the + * {@link CCParameter}s occupying one (function symbol, argument position) whose argument sort is numeric. Two + * members of the same slot with equal value but in distinct congruence classes are an equality that MBTC may + * propose: merging them is what makes the two applications congruent at that position. Only members whose class + * has a linear-arithmetic value ({@code getRepresentative().getSharedTerm() != null}) are kept — a class + * without a shared term is free to receive a non-clashing value at model construction, so it cannot be forced to + * clash. + *

+ * MBTC runs only in {@code finalCheck}, so the slots are computed on demand here rather than maintained in a + * persistent, backtracked index. + *

+ * Currently only the congruence source is enumerated (every function-application argument position). The + * reverse-trigger source (e-matching reverse triggers and the deferred datatype numeric-field feed) is + * future work; it is inactive while offset equalities are enabled, because offsets are disabled in the presence of + * quantifiers. + * + * @return the slots as lists of members; each list holds the numeric, LA-valued members at one (symbol, position). + */ + public Collection> getNumericClashSlots() { + final Map> slots = new LinkedHashMap<>(); + for (final CCTerm term : mAllTerms) { + if (!(term instanceof CCAppTerm)) { + continue; + } + final CCAppTerm app = (CCAppTerm) term; + final FunctionSymbol sym = app.getFunctionSymbol(); + for (int pos = 0; pos < app.getArgCount(); pos++) { + final CCParameter arg = app.getArgParam(pos); + if (!arg.getCCTerm().getFlatTerm().getSort().isNumericSort()) { + continue; + } + if (arg.getRepresentative().getSharedTerm() == null) { + continue; + } + List members = slots.get(new ClashKey(sym, pos)); + if (members == null) { + members = new ArrayList<>(); + slots.put(new ClashKey(sym, pos), members); + } + members.add(arg); + } + } + return slots.values(); + } + /** * Insert a Compare trigger that will be activated as soon as the two given * CCTerms are equal. It is inserted into the pair hash tables and all diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/linar/LinArSolve.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/linar/LinArSolve.java index 1b388258f..530ab08fd 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/linar/LinArSolve.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/linar/LinArSolve.java @@ -22,6 +22,7 @@ import java.util.ArrayDeque; import java.util.ArrayList; import java.util.BitSet; +import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; @@ -56,6 +57,7 @@ import de.uni_freiburg.informatik.ultimate.smtinterpol.model.SharedTermEvaluator; import de.uni_freiburg.informatik.ultimate.smtinterpol.proof.LeafNode; import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CCEquality; +import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CCParameter; import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CCTerm; import de.uni_freiburg.informatik.ultimate.smtinterpol.util.ScopedArrayList; import de.uni_freiburg.informatik.ultimate.smtinterpol.util.SymmetricPair; @@ -700,12 +702,99 @@ public Clause finalCheck() { } } if (mSuggestions.isEmpty() && mProplist.isEmpty()) { + if (mClausifier.getCClosure().createOffsetEqualities()) { + // Offset equalities: shared terms are offset-free, so whole-term mbtc (which groups by value) + // would wrongly merge terms that share an offset-free value but differ by a constant. MBTC instead + // ranges over numeric clash slots and proposes per-argument offset equalities. + return mbtcClashSlots(); + } return mbtc(cong); } assert compositesSatisfied(); return null; } + /** + * The value of a numeric clash-slot member as an exact infinitesimal number. The member's congruence class carries + * its linear-arithmetic value on the representative's shared term ({@code rep.getSharedTerm()}), which need not be + * the representative itself, so its offset-to-rep is folded back in: + * + *

+	 *   value(param) = sharedTermValue(rep.mSharedTerm)   // LA value of the offset-free shared term
+	 *                - rep.mSharedTerm.getOffsetToRep()    // shift back to the representative
+	 *                + param.getOffsetToRep()              // shift out to the member
+	 * 
+ * + * The caller guarantees {@code param.getRepresentative().getSharedTerm() != null} (clash slots drop LA-free + * members). + */ + private ExactInfinitesimalNumber clashMemberValue(final CCParameter param) { + final CCTerm rep = param.getRepresentative(); + final CCTerm sharedCC = rep.getSharedTerm(); + final LASharedTerm laShared = mClausifier.getLATerm(sharedCC.getFlatTerm()); + final ExactInfinitesimalNumber repValue = + sharedTermValue(laShared).sub(new ExactInfinitesimalNumber(sharedCC.getOffsetToRep())); + return repValue.add(new ExactInfinitesimalNumber(param.getOffsetToRep())); + } + + /** + * Model-based theory combination over numeric clash slots (offset-equality mode). For each slot + * (see {@link CClosure#getNumericClashSlots()}) the members are grouped by value; two members with equal value but + * in distinct affine classes get an offset equality proposed, encoded as an offset CCEquality between their base + * CCTerms ({@code value(param) == value(other)}). The equality is propagated when already implied or suggested as a + * decision otherwise, mirroring {@link #mbtc}. + */ + private Clause mbtcClashSlots() { + final Collection> slots = mClausifier.getCClosure().getNumericClashSlots(); + for (final List slot : slots) { + if (slot.size() <= 1) { + continue; + } + final Map byValue = new HashMap<>(); + for (final CCParameter param : slot) { + final ExactInfinitesimalNumber value = clashMemberValue(param); + final CCParameter other = byValue.get(value); + if (other == null) { + byValue.put(value, param); + } else if (!param.sameValueAs(other)) { + // Equal value but distinct affine classes: propose value(param) == value(other) as an offset + // CCEquality between the two base CCTerms (offset = other.getOffset() - param.getOffset()). + final CCEquality cceq = mClausifier.getCClosure().createEquality(param, other, true); + if (cceq == null) { + // false proxy: the two terms can never be equal; cannot happen for equal-valued members. + continue; + } + final Clause conflict = suggestOrPropagate(cceq); + if (conflict != null) { + return conflict; + } + } + } + } + return null; + } + + /** + * Hand a model-based equality to the engine: propagate it if its truth value is already implied (or report the + * conflict if it was decided false), otherwise suggest the linked LA equality as a decision. Shared by clash-slot + * MBTC and the legacy whole-term {@link #mbtc}. + */ + private Clause suggestOrPropagate(final CCEquality cceq) { + if (cceq.getLASharedData().getDecideStatus() != null) { + if (cceq.getDecideStatus() == cceq.negate()) { + return generateEqualityClause(cceq); + } else if (cceq.getDecideStatus() == null) { + mProplist.add(cceq); + } else { + mClausifier.getLogger().debug("already set: %s", cceq.getAtom().getDecideStatus()); + } + } else { + mClausifier.getLogger().debug("MBTC: Suggesting literal %s", cceq); + mSuggestions.add(cceq.getLASharedData()); + } + return null; + } + @Override public int checkCompleteness() { if (mHasNonLinearVar >= 0) { @@ -1429,6 +1518,21 @@ private void prepareModel() { } confl.add(new ExactInfinitesimalNumber(value.getRealValue())); } + // Also keep the clash-slot members (offset-shifted use-site values) from accidentally coinciding: the + // offset-free shared terms above no longer carry the per-argument constant once offset equalities are on. + for (final ModelSharedPoint pt : clashModelPoints()) { + ExactInfinitesimalNumber value = new ExactInfinitesimalNumber(pt.mOffset); + for (final Entry entry : pt.mSummands.entrySet()) { + value = value.add(entry.getKey().getValue().mul(entry.getValue())); + } + final Rational eps = value.getEpsilon(); + Set confl = sharedPoints.get(eps); + if (confl == null) { + confl = new TreeSet<>(); + sharedPoints.put(eps, confl); + } + confl.add(new ExactInfinitesimalNumber(value.getRealValue())); + } // If we cannot choose the current value since we would violate a // disequality, choose a different number. while (prohibitions.contains(mEps) || hasSharing(sharedPoints, new ExactInfinitesimalNumber(mEps))) { @@ -1845,6 +1949,74 @@ private ExactInfinitesimalNumber[] freedom(final LinVar var) { return new ExactInfinitesimalNumber[] { min, max }; } + /** + * A model "shared point": a value {@code offset + sum(summands)} whose accidental coincidence with another such + * point must be avoided during model construction. Besides the registered shared terms these include the numeric + * clash-slot members (function arguments): with offset equalities a function argument's use-site value carries a + * structural offset that the offset-free shared term no longer holds, so the offset is reattached here. + */ + private static final class ModelSharedPoint { + final Map mSummands; + final Rational mOffset; + + ModelSharedPoint(final Map summands, final Rational offset) { + mSummands = summands; + mOffset = offset; + } + } + + /** + * Collect the numeric clash-slot members as model shared points carrying their offset-shifted use-site value + * ({@code value(member) == value(rep) + (member.offsetToRep - sharedTerm.offsetToRep) + sum(sharedTerm summands)}). + * Empty unless offset equalities are enabled: without offsets the shared terms already carry their full use-site + * value, so whole-term collision avoidance suffices. + */ + private List clashModelPoints() { + final List points = new ArrayList<>(); + if (!mClausifier.getCClosure().createOffsetEqualities()) { + return points; + } + for (final List slot : mClausifier.getCClosure().getNumericClashSlots()) { + for (final CCParameter param : slot) { + final CCTerm rep = param.getRepresentative(); + final CCTerm sharedCC = rep.getSharedTerm(); + final LASharedTerm laShared = mClausifier.getLATerm(sharedCC.getFlatTerm()); + if (laShared == null) { + continue; + } + // laShared is offset-free, so its own value is sum(summands); the structural part of value(member) + // relative to it is (member.offsetToRep - sharedTerm.offsetToRep). laShared.getOffset() is zero here. + final Rational offset = param.getOffsetToRep().sub(sharedCC.getOffsetToRep()).add(laShared.getOffset()); + points.add(new ModelSharedPoint(laShared.getSummands(), offset)); + } + } + return points; + } + + /** + * Add one model shared point (its current value and its sensitivity to the mutating variable) to the slope-keyed + * {@code sharedPoints} map used by {@link #choose}. + */ + private void addModelSharedPoint(final Map summands, final Rational offset, + final Map basicFactors, final Map> sharedPoints) { + Rational sharedCoeff = Rational.ZERO; + ExactInfinitesimalNumber sharedCurVal = new ExactInfinitesimalNumber(offset, Rational.ZERO); + for (final Entry entry : summands.entrySet()) { + final LinVar lv = entry.getKey(); + final Rational factor = entry.getValue(); + if (basicFactors.containsKey(lv)) { + sharedCoeff = sharedCoeff.addmul(basicFactors.get(lv), factor); + } + sharedCurVal = sharedCurVal.add(lv.getValue().mul(factor)); + } + Set set = sharedPoints.get(sharedCoeff); + if (set == null) { + set = new TreeSet<>(); + sharedPoints.put(sharedCoeff, set); + } + set.add(sharedCurVal); + } + /** * Mutate a model such that less variables have the same value. * @@ -1854,6 +2026,7 @@ private ExactInfinitesimalNumber[] freedom(final LinVar var) { private void mutate() { final Map> sharedPoints = new TreeMap<>(); final Set prohib = new TreeSet<>(); + final List clashPoints = clashModelPoints(); for (final LinVar mutatingLV : mLinvars) { if (mutatingLV.mBasic || mutatingLV.getTightUpperBound().equals(mutatingLV.getTightLowerBound())) { // variable is basic or is fixed by its own constraints @@ -1903,25 +2076,14 @@ private void mutate() { } } - // Do not merge two shared variables + // Do not merge two shared variables (offset-free) or two clash-slot members (offset-shifted use-site + // values). The clash points restore the per-argument distinctness that whole-term sharing provided + // before offset equalities; without them a function would be modelled inconsistently. for (final LASharedTerm sharedVar : mSharedVars) { - Rational sharedCoeff = Rational.ZERO; - ExactInfinitesimalNumber sharedCurVal = new ExactInfinitesimalNumber(sharedVar.getOffset(), - Rational.ZERO); - for (final Entry entry : sharedVar.getSummands().entrySet()) { - final LinVar lv = entry.getKey(); - final Rational factor = entry.getValue(); - if (basicFactors.containsKey(lv)) { - sharedCoeff = sharedCoeff.addmul(basicFactors.get(lv), factor); - } - sharedCurVal = sharedCurVal.add(lv.getValue().mul(factor)); - } - Set set = sharedPoints.get(sharedCoeff); - if (set == null) { - set = new TreeSet<>(); - sharedPoints.put(sharedCoeff, set); - } - set.add(sharedCurVal); + addModelSharedPoint(sharedVar.getSummands(), sharedVar.getOffset(), basicFactors, sharedPoints); + } + for (final ModelSharedPoint pt : clashPoints) { + addModelSharedPoint(pt.mSummands, pt.mOffset, basicFactors, sharedPoints); } // If there is no integer constraint for the non-basic manipulate // it by eps, otherwise incrementing by a multiple of gcd.inverse() @@ -1935,12 +2097,6 @@ private void mutate() { } } - /** - * Compute the value of each shared variable as exact infinite number. - * - * @return A map from the value to the list of shared variables that have this - * value. - */ /** * Compute the current value of a shared term: its constant offset plus the weighted values of its summands. */ @@ -1952,6 +2108,12 @@ private ExactInfinitesimalNumber sharedTermValue(final LASharedTerm shared) { return value; } + /** + * Compute the value of each shared variable as exact infinite number. + * + * @return A map from the value to the list of shared variables that have this + * value. + */ Map> getSharedCongruences() { mClausifier.getLogger().debug("Shared Vars:"); final Map> result = new HashMap<>(); @@ -2159,17 +2321,9 @@ private Clause mbtc(final Map> cong assert eq != EqualityProxy.getTrueProxy(); assert eq != EqualityProxy.getFalseProxy(); cceq = eq.createCCEquality(lhs, rhs); - if (cceq.getLASharedData().getDecideStatus() != null) { // NOPMD - if (cceq.getDecideStatus() == cceq.negate()) { - return generateEqualityClause(cceq); - } else if (cceq.getDecideStatus() == null) { - mProplist.add(cceq); - } else { - mClausifier.getLogger().debug("already set: %s", cceq.getAtom().getDecideStatus()); - } - } else { - mClausifier.getLogger().debug("MBTC: Suggesting literal %s", cceq); - mSuggestions.add(cceq.getLASharedData()); + final Clause conflict = suggestOrPropagate(cceq); + if (conflict != null) { + return conflict; } } } From c7506e8b2bdf5736321ff4441e3bc498e5c9570d Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Tue, 23 Jun 2026 19:21:02 +0200 Subject: [PATCH 41/75] CC offset equalities: use the effective offset flag in LinArSolve Fix a wrong offset-equality propagation (bitVecLearnConflict2 returned unsat instead of sat): the bv constant 11 was effectively equated with ubv_to_int(p4) at offset 0 although ubv_to_int(p4) = 8. Root cause: two diverging "offsets enabled" predicates. CClosure.createOffsetEqualities() is the raw flag; Clausifier.createOffsetEqualities() is the effective one, which also requires the absence of a quantifier theory (offsets are disabled under quantifiers). With setLogic(ALL) a quantifier theory exists, so the effective flag is false and CCTerms are built full-value, but LinArSolve still gated offset behavior on the raw flag. fingerprintSharedVar then dropped the constant (offset mode) and collided the constant 11 with the fixed ubv_to_int(p4), propagating an offset equality whose offset was immediately dropped again by getTermConstant (effective flag false) -> an offset-0 CCEquality 11 == ubv_to_int(p4), conflated with the literal of the same shape. - LinArSolve now gates fingerprintSharedVar, the finalCheck MBTC branch and clashModelPoints on mClausifier.createOffsetEqualities() (the effective flag), so offset reasoning matches whether the CCTerms are actually offset-free. - propagateSharedEqualities computes the propagated offset from the terms' full SMT values (sharedTermValue is offset-free under offsets, so the term constants are added back via getTermConstant; zero when offsets are off). - EqualityProxy.createCCEquality now matches a cached dependent CCEquality on its offset as well, so two equalities between the same pair at different offsets (x == y and x == y + k) are no longer conflated. BitvectorTest 89/89; SystemTest under the temp proof-gate 22 -> 20 (offsets-off path unchanged). The two remaining regressions (abv/ext01, interpolation/uflratest004) stay deferred to the offset proof object. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../smtinterpol/convert/EqualityProxy.java | 11 +++++++---- .../smtinterpol/theory/linar/LinArSolve.java | 15 +++++++++++---- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/EqualityProxy.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/EqualityProxy.java index e59ff0bf5..be59bd335 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/EqualityProxy.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/EqualityProxy.java @@ -121,17 +121,20 @@ public CCEquality createCCEquality(final Term lhs, final Term rhs) { } else { laeq = (LAEquality) eqAtom; } + // offset such that value(ccLhs) == value(ccRhs) + offset, i.e. the difference of the two terms' constants. + final Rational offset = mClausifier.getTermConstant(rhs).sub(mClausifier.getTermConstant(lhs)); for (final CCEquality eq : laeq.getDependentEqualities()) { assert (eq.getLASharedData() == laeq); - if (eq.getLhs() == ccLhs && eq.getRhs() == ccRhs) { + // The offset must match too: two CCEqualities between the same pair of CCTerms but with different offsets + // (e.g. x == y and x == y + 3) are distinct facts and must not be conflated, otherwise propagating one + // would wrongly set the other. eq states getLhs() == getRhs() + eq.getOffset(). + if (eq.getLhs() == ccLhs && eq.getRhs() == ccRhs && eq.getOffset().equals(offset)) { return eq; } - if (eq.getRhs() == ccLhs && eq.getLhs() == ccRhs) { + if (eq.getLhs() == ccRhs && eq.getRhs() == ccLhs && eq.getOffset().equals(offset.negate())) { return eq; } } - // offset such that value(ccLhs) == value(ccRhs) + offset, i.e. the difference of the two terms' constants. - final Rational offset = mClausifier.getTermConstant(rhs).sub(mClausifier.getTermConstant(lhs)); final CCEquality eq = mClausifier.getCClosure().createCCEquality(mClausifier.getStackLevel(), ccLhs, ccRhs, offset); final Rational normFactor = computeNormFactor(lhs, rhs); diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/linar/LinArSolve.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/linar/LinArSolve.java index 530ab08fd..8d0e583f5 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/linar/LinArSolve.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/linar/LinArSolve.java @@ -702,7 +702,7 @@ public Clause finalCheck() { } } if (mSuggestions.isEmpty() && mProplist.isEmpty()) { - if (mClausifier.getCClosure().createOffsetEqualities()) { + if (mClausifier.createOffsetEqualities()) { // Offset equalities: shared terms are offset-free, so whole-term mbtc (which groups by value) // would wrongly merge terms that share an offset-free value but differ by a constant. MBTC instead // ranges over numeric clash slots and proposes per-argument offset equalities. @@ -1282,7 +1282,7 @@ public Map fingerprintSharedVar(LASharedTerm shared) { } fpr.add(shared.getOffset()); final Map fingerprint = fpr.getSummands(); - if (mClausifier.getCClosure().createOffsetEqualities()) { + if (mClausifier.createOffsetEqualities()) { // Ignore the constant part (accumulated under the null key from the offset and from any fixed // variables): two shared terms then collide when their non-constant parts agree, i.e. they are // provably equal up to a constant. That constant is an offset equality congruence closure can use; the @@ -1409,7 +1409,14 @@ public Clause propagateSharedEqualities() { // other and shared have equal non-constant parts (the fingerprints collided), so they // differ by a fixed constant: value(other) == value(shared) + offset. The non-constant // parts cancel, so the value difference is exact and model-independent. - final ExactInfinitesimalNumber diff = sharedTermValue(other).sub(sharedTermValue(shared)); + // The propagated offset is the difference of the terms' full SMT values. With offset + // equalities the LASharedTerm value is offset-free (the constant is dropped from both the value + // and the fingerprint), so the term constants are added back here; getTermConstant is zero when + // offset equalities are disabled, where sharedTermValue already carries the constant. + final Rational constDiff = mClausifier.getTermConstant(other.getTerm()) + .sub(mClausifier.getTermConstant(shared.getTerm())); + final ExactInfinitesimalNumber diff = sharedTermValue(other).sub(sharedTermValue(shared)) + .add(new ExactInfinitesimalNumber(constDiff)); assert diff.getEpsilon().signum() == 0; final Clause conflict = propagateSharedEquality(other, shared, diff.getRealValue(), propagated); @@ -1973,7 +1980,7 @@ private static final class ModelSharedPoint { */ private List clashModelPoints() { final List points = new ArrayList<>(); - if (!mClausifier.getCClosure().createOffsetEqualities()) { + if (!mClausifier.createOffsetEqualities()) { return points; } for (final List slot : mClausifier.getCClosure().getNumericClashSlots()) { From b43f9eec9b847cbe5f4a4142d5844b6d99c7323c Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Tue, 23 Jun 2026 19:48:36 +0200 Subject: [PATCH 42/75] CC offset equalities: carry the offset on computeCycle's main disequality When a CCEquality eq with a non-zero offset is the disequality violated by a congruence cycle, computeCycle built the lemma's main disequality as a bare SymmetricPair<>(lhs, rhs), dropping eq.getOffset(). The proof generator then saw a main equality lhs == rhs (offset 0) instead of lhs == rhs + offset, matched no literal and was not a trivial disequality, and failed (CCProofGenerator.toTerm assertion). Carry eq.getOffset() on the main diseq (lhs, CCParameter.of(rhs, eq.getOffset())), mirroring computeAntiCycle, so the proof matches the offset-aware literal eq. For offset-free equalities eq.getOffset() is zero and CCParameter.of returns the bare term, so the offsets-off path is unchanged. Fixes the bitvector trivialConflict-style proof failure (e.g. main equality 0 == ubv_to_int(q4) that should be 0 == ubv_to_int(q4) + -1). The remaining bitvector proof failures under offsets are the separate, still-deferred "offset equality conflict explanation" stub in CCTerm.merge. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ultimate/smtinterpol/theory/cclosure/CongruencePath.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java index 155fab4d6..fd9c51faa 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java @@ -364,7 +364,10 @@ public Clause computeCycle(final CCEquality eq, final boolean produceProofs) { } final Clause c = new Clause(cycle); if (produceProofs) { - c.setProof(new LeafNode(LeafNode.THEORY_CC, createAnnotation(new SymmetricPair<>(lhs, rhs)))); + // The disequality being violated is eq, which states value(lhs) == value(rhs) + eq.getOffset(); carry that + // offset on the main diseq so the proof matches the (offset-aware) literal eq instead of a bare lhs == rhs. + final SymmetricPair diseq = new SymmetricPair<>(lhs, CCParameter.of(rhs, eq.getOffset())); + c.setProof(new LeafNode(LeafNode.THEORY_CC, createAnnotation(diseq))); } return c; } From 345ecee893811fce12cb1e41ab8cd517420e5b6e Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Tue, 23 Jun 2026 20:51:26 +0200 Subject: [PATCH 43/75] CC offset equalities: congruence-merge offset conflict explanation Replace the CCTerm.merge stub (UnsupportedOperationException "offset equality conflict explanation not yet implemented") in the src==dest branch. It is reachable only from buildCongruence (reason==null): two congruent CCAppTerms already in one class at a non-zero offset, e.g. the class records f(x)=f(y)+k while congruence implies f(x)=f(y). setLiteral guards src!=dest, so reason!=null never reaches this branch (asserted). New CClosure.computeCongruenceAntiCycle -> CongruencePath.compute- CongruenceAntiCycle. Conflict clause negates the argument-equality path literals (justify the congruence) plus the existing class-path literals (establish the conflicting offset); no positive literal, the contradiction 0!=existingDiff is intrinsic. computeAntiCycle could not be reused: it is keyed on a real CCEquality (negated literal + getOffset); here the deviating edge is a congruence justified by argument equalities. Proof object mirrors computeAntiCycle's leading-node trick: explicit mainPath [first@0, second@0, ..., first@existingDiff] via the CCAnnotation(diseq, mainPath, otherPaths, CONG) ctor; the leading first@0->second@0 step has no literal so CCProofGenerator.findCongruence- Paths resolves it from the argument subpaths in otherPaths. Trivial offset diseq (first@0, first@existingDiff) -> EQ lemma. abv/ext01, abv/ext02 now solve unsat (were unsupported); BitvectorTest 89/89 with FULL proofs + proof-check-mode; test/proof 97/98 (only the pre-existing trivialdiseqarray blocker). Co-Authored-By: Claude Opus 4.8 (1M context) --- SMTInterpol/doc/offset-equality-plan.md | 63 +++++++++++++++++-- .../smtinterpol/theory/cclosure/CCTerm.java | 9 ++- .../smtinterpol/theory/cclosure/CClosure.java | 10 +++ .../theory/cclosure/CongruencePath.java | 59 +++++++++++++++++ 4 files changed, 134 insertions(+), 7 deletions(-) diff --git a/SMTInterpol/doc/offset-equality-plan.md b/SMTInterpol/doc/offset-equality-plan.md index 580ec8bf3..7718fca74 100644 --- a/SMTInterpol/doc/offset-equality-plan.md +++ b/SMTInterpol/doc/offset-equality-plan.md @@ -766,16 +766,71 @@ created that `LAEquality` as a side effect; clash-slot MBTC does not (the terms not function arguments). Fix: `EqualityProxy.createAtom` eagerly creates and links the `LAEquality` for numeric equalities when `createOffsetEqualities()`. -Two SystemTest regressions remain, both in the **deferred** offset proof/interpolation -track and accepted as deferred: -- `abv/ext01`: a clash-MBTC offset equality reaches, via congruence, the stub +Two SystemTest regressions remained, both in the offset proof/interpolation track: +- `abv/ext01`: a clash-MBTC offset equality reached, via congruence, the stub `CCTerm.merge` "offset equality conflict explanation not yet implemented" (the congruence-merge analogue of the `computeAntiCycle` case `setLiteral` already - handles). + handles). **FIXED** — see *Congruence-merge offset conflict* below; `ext01`/`ext02` + now solve `unsat` and proof-check. - `interpolation/uflratest004`: the eager `LAEquality` changes the proof so the offset-unaware interpolant checker rejects the interpolant. The eager `LAEquality` is kept (needed for SAT model soundness); offset interpolation is deferred. +## Congruence-merge offset conflict (the `CCTerm.merge` stub) — DONE (uncommitted) + +The stub `CCTerm.merge` threw `UnsupportedOperationException("offset equality +conflict explanation not yet implemented")` in the `src == dest` branch (the two +merged terms are already in one class) when the merge offset disagreed with the +offset they already have. It is reachable **only** from `buildCongruence` +(`lhs.merge(this, rhs, null)`, `reason == null`): two congruent `CCAppTerm`s +`f(x)`, `f(y)` — congruence implies value difference `0` — are already in the same +class at a non-zero offset `existingDiff` (e.g. the class already records +`f(x) = f(y) + k`). `setLiteral` is the only other `merge` caller and it guards +`src != dest`, so the `reason != null` case never reaches this branch (asserted). + +Why `computeAntiCycle` could not be reused directly: it is keyed on a real +`CCEquality eq` — it negates `eq` in the clause and reads `eq.getOffset()` as the +deviating offset, and in the proof its prepended leading edge is discharged by +resolving against the `eq` literal. The congruence merge has `reason == null`: +there is no literal to negate, the deviating "edge" is a congruence justified by +the *argument* equalities (so the clause carries `{¬argEq…, ¬path}` with no positive +literal, like `computeCycle(const, const)`), and the leading proof edge must be +discharged by a congruence sub-proof, not a literal. + +Implementation: `CClosure.computeCongruenceAntiCycle(CCAppTerm, CCAppTerm)` → +`CongruencePath.computeCongruenceAntiCycle`. +- **Clause:** collect `mAllLiterals` from `computePath(second@0, first@0)` (the + existing class path, establishing `existingDiff`) plus `computePath` over each + argument pair `getArgParam(i)` (justifying the congruence); negate all. The + contradiction is intrinsic to arithmetic (`0 != existingDiff`), so no positive + literal is needed. +- **Proof object** (mirrors `computeAntiCycle`'s leading-node trick — a `SubPath` + cannot carry one CCTerm at two offsets): explicit + `mainPath = [first@0, second@0, …, first@existingDiff]` via the + `CCAnnotation(diseq, mainPath, otherPaths, CONG)` constructor (the one + `computeAntiCycleDiffClass` uses). The leading step `first@0 → second@0` has no + clause literal and is not a registered subpath, so + `CCProofGenerator.collectEquality → findCongruencePaths` auto-resolves it from the + argument subpaths in `otherPaths` (= `mAllPaths` minus the inlined existing path, + retrieved via `mVisited.get(SymmetricPair(second, first))`). The two endpoints are + the same term `first` at offsets `0` and `existingDiff` — a trivially-false offset + disequality discharged by an EQ lemma. + +Validated (clean build, assertions on): `abv/ext01`, `abv/ext02` now `unsat` (were +`unsupported`); `BitvectorTest` 89/89 with FULL proofs + `proof-check-mode` (the +proof object verifies); `test/proof` 97/98 (only the pre-existing +`trivialdiseqarray` array+offset blocker, identical at baseline); +`CongruentAddTest`/`PairHashTest`/`ProofSimplifierTest`/`ProofUtilsTest`/`RPITest` +green. The change is gated only by the structural shape of the merge, not by +`createOffsetEqualities()` directly, but it is dead code when offsets are off (every +offset is then `0`, so `existingDiff` is always `0`). Files: `CCTerm.java`, +`CClosure.java`, `CongruencePath.java`. + +Still-deferred gate-flip blockers (confirmed pre-existing, identical at baseline): +`ProofSimplifier.checkProof` on offset lowlevel proofs (e.g. `bv/bvand0*`) and +`QuantClause.collectVarInfos` on quantified datatype matching (e.g. +`datatype/quantified/match_test`). + ## Implementable slice (ready to start) 1. `LASharedTerm` becomes offset-free under `createOffsetEqualities()` (revert the diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCTerm.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCTerm.java index 2575f2023..652fab528 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCTerm.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCTerm.java @@ -413,9 +413,12 @@ public Clause merge(final CClosure engine, final CCTerm lhs, final CCEquality re */ final Rational existingDiff = lhs.mOffsetToRep.sub(mOffsetToRep); if (!existingDiff.equals(reasonDiff(reason, lhs, this))) { - // TODO offset-conflict explanation needs the offset-aware proof machinery; it can only be reached once - // non-zero offset equalities are created (later increment). - throw new UnsupportedOperationException("offset equality conflict explanation not yet implemented"); + // The two terms are congruent (reason == null ⇒ implied value difference 0) but already in the same + // class at a non-zero offset existingDiff (e.g. f(x) and f(y) forced equal while the class already has + // f(x) = f(y) + k). This is an offset conflict: build the anti-cycle from the argument equalities (which + // justify the congruence) plus the existing path (which establishes the conflicting offset). + assert reason == null : "non-congruence merge of terms already in the same class"; + return engine.computeCongruenceAntiCycle((CCAppTerm) this, (CCAppTerm) lhs); } return null; } diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java index dd8528195..9e5c88bac 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java @@ -1087,6 +1087,16 @@ public Clause computeAntiCycle(final CCEquality eq) { return new CongruencePath(this).computeAntiCycleDiffClass(eq, diseq, isProofGenerationEnabled()); } + /** + * Compute the conflict clause when a congruence merge finds its two function applications already in the same + * congruence class at an offset different from the zero offset that congruence implies (e.g. f(x) and f(y) are + * congruent but the class already records f(x) = f(y) + k for k != 0). See + * {@link CongruencePath#computeCongruenceAntiCycle}. + */ + public Clause computeCongruenceAntiCycle(final CCAppTerm first, final CCAppTerm second) { + return new CongruencePath(this).computeCongruenceAntiCycle(first, second, isProofGenerationEnabled()); + } + /** * Compute the earliest decide level at which the path between lhs and rhs * exists. There must be a path, i.e. diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java index fd9c51faa..052d912c7 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java @@ -483,6 +483,65 @@ public Clause computeAntiCycleDiffClass(final CCEquality eq, final CCEquality di return c; } + /** + * Build the conflict clause for a congruence merge whose two function applications {@code first} and {@code second} + * are already in the same congruence class at an offset different from the zero offset that congruence implies. The + * two terms have equal value by congruence (their arguments are pairwise equal), but the existing path between them + * in their common class establishes a non-zero constant offset {@code existingDiff}; the two facts are + * contradictory. The conflict clause negates the argument equalities (which justify the congruence) together with + * the path literals (which establish the conflicting offset) — no positive literal is needed, the contradiction is + * intrinsic to arithmetic ({@code 0 != existingDiff}). + * + *

For the proof object we mirror {@link #computeAntiCycle}: the leading congruence edge cannot be carried by a + * {@link SubPath} node (a SubPath cannot hold the same CCTerm at two offsets), so we build an explicit main path + * {@code [first@0, second@0, ..., first@existingDiff]} whose leading step {@code first@0 -> second@0} is recognised + * as a congruence (resolved from the argument subpaths in {@code otherPaths}) and whose remaining nodes are the + * existing class path. The two endpoints are the same term {@code first} at offsets {@code 0} and + * {@code existingDiff}, a trivially-false offset disequality discharged by an EQ lemma. + */ + public Clause computeCongruenceAntiCycle(final CCAppTerm first, final CCAppTerm second, final boolean produceProofs) { + assert first.getRepresentative() == second.getRepresentative(); + final Rational existingDiff = second.mOffsetToRep.sub(first.mOffsetToRep); + assert !existingDiff.equals(Rational.ZERO); + assert first.getFunctionSymbol() == second.getFunctionSymbol(); + // The existing path between the two app terms (anchored at second@0 so getParams yields [second@0, ..., + // first@existingDiff]); it establishes the actual (non-zero) offset. + computePath(CCParameter.of(second, Rational.ZERO), CCParameter.of(first, Rational.ZERO)); + // The argument equalities that justify the congruence first == second (offset 0). + for (int i = 0; i < first.getArgCount(); i++) { + computePath(first.getArgParam(i), second.getArgParam(i)); + } + final Literal[] clause = new Literal[mAllLiterals.size()]; + int i = 0; + for (final Literal l : mAllLiterals) { + clause[i++] = l.negate(); + } + final Clause c = new Clause(clause); + if (produceProofs) { + final SubPath existing = mVisited.get(new SymmetricPair<>((CCTerm) second, (CCTerm) first)); + final CCParameter[] existingParams = existing.getParams(); // [second@0, ..., first@existingDiff] + // main path: prepend first@0 (the congruence's other end) so the leading step first@0 -> second@0 is a + // congruence edge and the two ends first@0 / first@existingDiff form the trivial diseq. + final CCParameter[] mainPath = new CCParameter[existingParams.length + 1]; + mainPath[0] = CCParameter.of(first, Rational.ZERO); + System.arraycopy(existingParams, 0, mainPath, 1, existingParams.length); + final CCParameter firstAtDiff = mainPath[mainPath.length - 1]; + assert firstAtDiff.getCCTerm() == first && firstAtDiff.getOffset().equals(existingDiff); + // The remaining subpaths (the congruence's argument paths plus any congruences along the existing path) keep + // deriving their offsets the usual way; only the existing main path is inlined into mainPath. + final ArrayList otherPaths = new ArrayList<>(); + for (final SubPath p : mAllPaths) { + if (p != existing) { + otherPaths.add(p); + } + } + final SymmetricPair diseqParam = new SymmetricPair<>(mainPath[0], firstAtDiff); + c.setProof(new LeafNode(LeafNode.THEORY_CC, + new CCAnnotation(diseqParam, mainPath, otherPaths, CCAnnotation.RuleKind.CONG))); + } + return c; + } + public Clause computeCycle(final CCTerm lconstant, final CCTerm rconstant, final boolean produceProofs) { mClosure.getLogger().debug("computeCycle for Constants"); computePath(lconstant, rconstant); From c71138ec2703b24ac273ec17e70665c486ed8c91 Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Tue, 23 Jun 2026 21:40:26 +0200 Subject: [PATCH 44/75] CC offset equalities: congruence proof lemma is offset-free MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit findCongruencePaths rendered the :cong oracle with the path's anchor offset baked into both app-term endpoints, e.g. (oracle (+ (= (+ (ubv_to_int (int_to_bv 1)) -1) (+ (ubv_to_int q4) -1)) - (= (int_to_bv 1) q4)) :cong (...)) That is not a valid single congruence (the offsetted step is over +, not over the function), so it survived only because FULL-proof oracles are trusted; the lowlevel conversion (ProofSimplifier) then rejected it (side-condition violated) — e.g. BitvectorTest.trivialConflict, bv/bvand0*. A congruence is intrinsically offset-free: f(a) and f(b) have equal value once their arguments are equal, so the two app terms always sit at the same offset on a path. Build the lemma diseq and the length-2 proof path from the offset-free app terms; the surrounding :trans/:cong checker absorbs the common constant shift. The diseq key is offset-invariant (the offset cancels in the difference), so all lookups are unchanged, and when offsets are off the rendering is byte-identical (offset already 0). bv/ 0 crashes (were bvand01/02/03, bvxor03 in ProofSimplifier.checkProof); test/proof 97/98; BitvectorTest 89/89; ProofSimplifierTest 14/14. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../theory/cclosure/CCProofGenerator.java | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCProofGenerator.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCProofGenerator.java index d3cf72780..0fd26add5 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCProofGenerator.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCProofGenerator.java @@ -803,12 +803,10 @@ private boolean isTrivialDisequality(final SymmetricPair termPair) * congruence. */ private ProofInfo findCongruencePaths(final CCParameter first, final CCParameter second) { - final ProofInfo proofInfo = new ProofInfo(); - proofInfo.mLemmaDiseq = new SymmetricPair<>(first, second); - proofInfo.mProofPaths = new IndexedPath[] { new IndexedPath(null, new CCParameter[] { first, second }) }; final CCTerm firstTerm = first.getCCTerm(); final CCTerm secondTerm = second.getCCTerm(); - if (!(firstTerm instanceof CCAppTerm) || !(secondTerm instanceof CCAppTerm)) { + if (!(firstTerm instanceof CCAppTerm) || !(secondTerm instanceof CCAppTerm) + || !first.getOffset().equals(second.getOffset())) { // This is not a congruence return null; } @@ -819,6 +817,17 @@ private ProofInfo findCongruencePaths(final CCParameter first, final CCParameter return null; } + final ProofInfo proofInfo = new ProofInfo(); + // A congruence is offset-free: f(a) and f(b) have equal value once their arguments are equal, so the two app + // terms always sit at the same offset on a path and the congruence proves the offset-free f(a) = f(b). Render + // the lemma and its (length-2) proof path with the offset-free app terms; the surrounding :trans/:cong checker + // absorbs the common constant shift. Carrying the offset (e.g. (= (+ f(a) -1) (+ f(b) -1)) :cong) is not a valid + // single congruence — that step is over +, not over f — and breaks the lowlevel proof. The diseq key is offset + // invariant (the offset cancels in the difference), so all lookups are unaffected. + proofInfo.mLemmaDiseq = new SymmetricPair<>(firstTerm, secondTerm); + proofInfo.mProofPaths = + new IndexedPath[] { new IndexedPath(null, new CCParameter[] { firstTerm, secondTerm }) }; + assert firstApp.getArgCount() == secondApp.getArgCount(); for (int i = 0; i < firstApp.getArgCount(); i++) { final CCParameter firstArg = firstApp.getArgParam(i); From 4aebeec620ac40516e9c0051c782fdf23de22e84 Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Wed, 24 Jun 2026 17:50:52 +0200 Subject: [PATCH 45/75] CC offset equalities: shared-term clash conflict + CongruencePath cleanup Fix the proof object for a shared-term clash detected during a merge (the computeCycle constant case). When merging two classes whose shared terms have provably distinct merged values (e.g. an integer shared term forced to a non-integer value, to_real a == 1/2 in uflira_001), the clash is found before the union-find is updated: the bridge equal edge is set while mOffsetToRep is still relative to each node's own representative, so SubPath.getParams() reads the bridge step across two reference frames and the proof generator cannot explain it ("Cannot explain term pair"). New CClosure.computeSharedConflictCycle -> CongruencePath.computeSharedConflictCycle, a hybrid of computeAntiCycleDiffClass (two single-class halves stitched by hand) and computeCongruenceAntiCycle (the bridge may be a congruence, reason == null): the two halves are computed within their own classes (offset-correct) and the destination half is anchored already shifted into the source frame, so mainPath is a plain concatenation. The diseq is the trivially distinct shared values, discharged by an EQ/LA lemma. Also fixes a latent bug where the diseq offset was delta instead of the true value difference (they differ unless both shared terms are class reps). CongruencePath refactor: - rename computeCCPath -> computeCongruence; both cross-class builders call it. - computePath only enqueues; each top-level method calls the extracted drainTodo() once. WeakCongruencePath consumes paths synchronously, so it drains per call (drainTodo() is protected). - computeAntiCycleDiffClass uses the same pre-shifted anchor as computeSharedConflictCycle (no post-hoc per-node shift loop). test/proof 97/98 (only pre-existing trivialdiseqarray); uflira_001 proof-checks unsat; BitvectorTest 89/89 (FULL proof + proof-check), ProofSimplifierTest 14/14, ProofUtilsTest 4/4, CongruentAddTest 5/5, PairHashTest 1/1. Co-Authored-By: Claude Opus 4.8 (1M context) --- SMTInterpol/doc/offset-equality-plan.md | 81 +++++++++++ .../smtinterpol/theory/cclosure/CCTerm.java | 3 +- .../smtinterpol/theory/cclosure/CClosure.java | 13 +- .../theory/cclosure/CongruencePath.java | 130 +++++++++++++++--- .../theory/cclosure/WeakCongruencePath.java | 6 + 5 files changed, 210 insertions(+), 23 deletions(-) diff --git a/SMTInterpol/doc/offset-equality-plan.md b/SMTInterpol/doc/offset-equality-plan.md index 7718fca74..1f79de9e3 100644 --- a/SMTInterpol/doc/offset-equality-plan.md +++ b/SMTInterpol/doc/offset-equality-plan.md @@ -831,6 +831,87 @@ Still-deferred gate-flip blockers (confirmed pre-existing, identical at baseline `QuantClause.collectVarInfos` on quantified datatype matching (e.g. `datatype/quantified/match_test`). +## Shared-term clash during a merge (the `computeCycle` constant case) — DONE (uncommitted) + +`uflira/uflira_001.smt2` (`to_real a = f a`, `f b = 1/2`, `a = b`) was `unsat` +correctly but the **proof object** failed: `Cannot explain term pair +((f b)+-1/2,(f a))`. Same root cause as `computeAntiCycleDiffClass`: an equal edge +was added to the CC graph but the nodes were not yet really merged. + +Mechanism: asserting `a = b` makes `f a ≡ f b` by congruence. `CCTerm.mergeInternal` +tries to merge `f a`'s class (shared term `to_real a`) with `f b`'s class (shared +term `0.0`, with `f b` at offset `-1/2`). The merged shared-term equality +`to_real a == 0.0 + (-1/2)` is integer-impossible (`a` is `Int`), so +`createEquality` returns the false proxy → `sharedTermConflict`, and the conflict was +built by `computeCycle(0.0, (to_real a)+(-1/2))`. But the conflict is detected +**before** the union-find update: the equal edge `f a — f b` is set while +`mOffsetToRep` is still relative to each node's own representative. `computePath` +walks across the bridge fine for the *clause*, but `SubPath.getParams()` reads the +congruence bridge step as `(f b)+(-1/2) → (f a)+0` (two reference frames) instead of +offset-free → the proof generator cannot explain it. + +Fix: new `CClosure.computeSharedConflictCycle` → +`CongruencePath.computeSharedConflictCycle(lshared, rshared, lhs, rhsTerm, reason, +bridgeOff, …)`, a hybrid of `computeAntiCycleDiffClass` (two single-class halves +stitched by hand) and `computeCongruenceAntiCycle` (the bridge may be a congruence, +`reason == null`): +- **Clause:** `computePath(lshared@0, lhs@0)` (source half) + `computePath(rhsTerm@0, + rshared@0)` (dest half), each single-class and offset-correct. The bridge edge + `lhs — rhsTerm` is justified by the merge `reason` (added as a literal) when + `reason != null`, or — for a congruence merge — by `computePath` over each argument + pair `getArgParam(i)` (which also builds the subpaths the proof needs). Negate all; + no positive literal (the contradiction is the trivially distinct shared values). +- **Proof object:** build an explicit `mainPath = paramsSrc ++ paramsDest` via the + `CCAnnotation(diseq, mainPath, otherPaths, CONG)` constructor. The destination half + is computed **already shifted** into the source frame: instead of anchoring at + `rhsTerm@0` and shifting every node afterwards, the dest `computePath` is anchored at + `rhsTerm@shift` with `shift = (lshared.mOffsetToRep − lhs.mOffsetToRep) + bridgeOff` + (`bridgeOff = reasonDiff(reason, lhs, rhsTerm)`, `0` for a congruence). Since + `SubPath.getParams()` offsets every node by the anchor's `mStartOffset`, the dest + params come out in the source frame and `mainPath` is a plain two-`arraycopy` + concatenation — no post-hoc per-node shift. For a congruence bridge the step + `lhs → rhsTerm` (offset 0) is auto-resolved from the argument subpaths in + `otherPaths`, exactly like `computeCongruenceAntiCycle`'s leading edge; for a + real-equality bridge the step matches the `reason` literal. The diseq is taken from + the stitched path endpoints (`mainPath[0]`, `mainPath[last]`), so its offset is the + true value difference `value(lshared) − value(rshared)` (= `sharedOffset`), fixing a + latent bug where the old call passed `delta` instead — these coincide only when both + shared terms are their class reps. Discharged by an EQ/LA lemma. + +`reason == null` is the key complication the user flagged: there is no separating +disequality literal to resolve the bridge on. It is handled exactly as in +`computeCongruenceAntiCycle` (argument subpaths discharge the congruence step), so the +hybrid covers both bridge kinds uniformly. + +**CongruencePath refactor (same change).** Three cleanups landed alongside the fix: +1. `computeCCPath` renamed to **`computeCongruence`** (it pairs the arguments of two + congruent app terms); both cross-class builders (`computeCongruenceAntiCycle`, + `computeSharedConflictCycle`) now call it instead of hand-rolling the argument loop. +2. **`computePath` no longer drains** the work list — it only enqueues. Each top-level + `compute*Cycle`/`computeDTLemma`/`computeDecideLevel` calls the extracted + `drainTodo()` **once** after all `computePath`/`computeCongruence` calls are queued, + so a single shared drain builds them (and dedups subpaths shared between several + queued ends). `WeakCongruencePath` consumes single paths synchronously + (`computePath(...); mAllPaths.removeFirst()`), so it drains per call — `drainTodo()` + is `protected` and called explicitly at each of its six `computePath` sites + (reproducing the old per-call drain; without this the array proofs in + `test/proof/auxaxioms/*` throw `NoSuchElementException`). +3. **`computeAntiCycleDiffClass` gets the same pre-shift simplification** as + `computeSharedConflictCycle`: its right half is anchored at `right@shift` + (`shift = dLeft.mOffsetToRep − left.mOffsetToRep + eq.getOffset()`), so `getParams()` + returns it already in the left frame and `mainPath` is a plain concatenation. The + `assert mainPath[last].getOffset() == dOff` still holds. + +Validated (clean build, `-ea`): `uflira_001` proof now `unsat` with +`proof-check-mode` (was `Cannot explain term pair`); `test/proof` 97/98 (only the +pre-existing `trivialdiseqarray`, confirmed identical at baseline; `match_rewrite` +re-checks its full cross-class anti-cycle proof); +`test/uflira` 5/5, `test/lia` 32/32, `test/abv` 4/4 clean; `BitvectorTest` 89/89 +(FULL proof + proof-check), `ProofSimplifierTest` 14/14, `ProofUtilsTest` 4/4, +`CongruentAddTest` 5/5, `PairHashTest` 1/1. Pre-existing deferred crashes unchanged +(`datatype/quantified/*match*` = `QuantClause.collectVarInfos`). Files: +`CCTerm.java`, `CClosure.java`, `CongruencePath.java`, `WeakCongruencePath.java`. + ## Implementable slice (ready to start) 1. `LASharedTerm` becomes offset-free under `createOffsetEqualities()` (revert the diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCTerm.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCTerm.java index 652fab528..47525f98a 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCTerm.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCTerm.java @@ -486,7 +486,8 @@ private Clause mergeInternal(final CClosure engine, final CCTerm lhs, final CCEq /* Check for conflict */ if (sharedTermConflict || diseq != null) { final Clause conflict = sharedTermConflict - ? engine.computeCycle(src.mSharedTerm, dest.mSharedTerm) + ? engine.computeSharedConflictCycle(src.mSharedTerm, dest.mSharedTerm, lhs, this, reason, + reasonDiff(reason, lhs, this)) : engine.computeCycle(diseq); lhs.mEqualEdge = null; lhs.mOldRep = null; diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java index 9e5c88bac..91114707a 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java @@ -1067,11 +1067,22 @@ public Clause computeCycle(final CCEquality eq) { return res; } - public Clause computeCycle(final CCTerm lconstant, final CCTerm rconstant) { + public Clause computeCycle(final CCParameter lconstant, final CCParameter rconstant) { final CongruencePath congPath = new CongruencePath(this); return congPath.computeCycle(lconstant, rconstant, isProofGenerationEnabled()); } + /** + * Compute the conflict clause for a shared-term clash detected during a merge (the merged values of the two classes' + * shared terms are provably distinct, e.g. an integer shared term forced to a non-integer value). See + * {@link CongruencePath#computeSharedConflictCycle}. + */ + public Clause computeSharedConflictCycle(final CCTerm lshared, final CCTerm rshared, final CCTerm lhs, + final CCTerm rhsTerm, final CCEquality reason, final Rational bridgeOff) { + return new CongruencePath(this).computeSharedConflictCycle(lshared, rshared, lhs, rhsTerm, reason, bridgeOff, + isProofGenerationEnabled()); + } + public Clause computeAntiCycle(final CCEquality eq) { final CCEquality diseq = eq.mDiseqReason; if (diseq == null) { diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java index 052d912c7..a91eb1005 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java @@ -182,9 +182,10 @@ private int computeDepth(CCTerm t) { * @param start one of the function application terms. * @param end the other function application term. */ - private void computeCCPath(CCAppTerm start, CCAppTerm end) { + private void computeCongruence(CCAppTerm start, CCAppTerm end) { // Pair the argument values (CCParameters), so the recorded subpath for each argument is anchored at the - // argument's offset, e.g. f(x+2) congruent f(z+8) yields a subpath from x+2 to z+8. + // argument's offset, e.g. f(x+2) congruent f(z+8) yields a subpath from x+2 to z+8. This only enqueues the + // argument pairs; the surrounding drain loop ({@link #drainTodo}, run by {@link #computePath}) builds them. for (int i = 0; i < start.getArgCount(); i++) { mTodo.addFirst(new SymmetricPair<>(start.getArgParam(i), end.getArgParam(i))); } @@ -222,7 +223,7 @@ private SubPath computePathTo(final CCParameter startParam, final CCTerm end) { * Compute the paths for the func and arg parts and merge into the * interpolation info. */ - computeCCPath((CCAppTerm) startCongruence, (CCAppTerm) t); + computeCongruence((CCAppTerm) startCongruence, (CCAppTerm) t); path.addEntry(t, null); } /* Add the equality literal to conflict set */ @@ -298,7 +299,7 @@ SubPath computePathNonRecursive(final CCParameter left, final CCParameter right) assert (ll != null); final SubPath path = computePathTo(left, llWithReason); if (llWithReason != rrWithReason) { - computeCCPath((CCAppTerm)llWithReason, (CCAppTerm)rrWithReason); + computeCongruence((CCAppTerm)llWithReason, (CCAppTerm)rrWithReason); path.addEntry(rrWithReason, null); } final SubPath pathBack = computePathTo(right, rrWithReason); @@ -327,8 +328,21 @@ SubPath computePathNonRecursive(final CCParameter left, final CCParameter right) * the right end of the congruence chain that should be evaluated. */ public void computePath(final CCParameter left, final CCParameter right) { - final HashSet> added = new HashSet<>(); + // Only enqueue the path. The caller (a top-level compute*Cycle/Lemma method) calls drainTodo() once after all + // computePath/computeCongruence calls are queued, so a single shared drain builds them all (and dedups subpaths + // shared between several queued ends). mTodo.add(new SymmetricPair<>(left, right)); + } + + /** + * Process the work list {@link #mTodo} of (sub)paths to compute, building each one and collecting it into + * {@link #mAllPaths} (and its literals into {@link #mAllLiterals}). The top-level compute*Cycle/Lemma methods seed + * the work list via {@link #computePath} (a single pair) and {@link #computeCongruence} (argument pairs), then call + * this once. {@link WeakCongruencePath} consumes single paths synchronously and drains after each + * {@link #computePath}, hence protected. + */ + protected void drainTodo() { + final HashSet> added = new HashSet<>(); while (!mTodo.isEmpty()) { final SymmetricPair pathEnds = mTodo.removeFirst(); @@ -356,6 +370,7 @@ public Clause computeCycle(final CCEquality eq, final boolean produceProofs) { final CCTerm lhs = eq.getLhs(); final CCTerm rhs = eq.getRhs(); computePath(eq.getLhs(), eq.getRhs()); + drainTodo(); final Literal[] cycle = new Literal[mAllLiterals.size() + 1]; int i = 0; cycle[i++] = eq; @@ -385,6 +400,7 @@ public Clause computeAntiCycle(final CCEquality eq, final boolean produceProofs) final CCTerm rhs = eq.getRhs(); assert lhs.getRepresentative() == rhs.getRepresentative(); computePath(lhs, rhs); + drainTodo(); final Literal[] clause = new Literal[mAllLiterals.size() + 1]; int i = 0; clause[i++] = eq.negate(); @@ -443,8 +459,13 @@ public Clause computeAntiCycleDiffClass(final CCEquality eq, final CCEquality di dOff = diseq.getOffset().negate(); } // Two single-class paths, accumulating their reason literals into mAllLiterals and their subpaths into mAllPaths. + // The left half is anchored at dLeft@0, so its last node left sits at offLeft = dLeft.mOffsetToRep - + // left.mOffsetToRep. The right half is anchored directly in the left half's frame (right@shift), so after the eq + // edge (left == right + eq.getOffset()) its params come out already shifted across the bridge; no post-hoc shift. + final Rational shift = dLeft.mOffsetToRep.sub(left.mOffsetToRep).add(eq.getOffset()); computePath(dLeft, left); - computePath(right, dRight); + computePath(CCParameter.of(right, shift), dRight); + drainTodo(); final Literal[] clause = new Literal[mAllLiterals.size() + 2]; int i = 0; clause[i++] = diseq; @@ -456,18 +477,13 @@ public Clause computeAntiCycleDiffClass(final CCEquality eq, final CCEquality di if (produceProofs) { final SubPath segA = dLeft == left ? null : mVisited.get(new SymmetricPair<>(dLeft, left)); final SubPath segB = right == dRight ? null : mVisited.get(new SymmetricPair<>(right, dRight)); - // paramsA = [dLeft@0, ..., left@offLeft]; paramsB = [right@0, ..., dRight@offRight] (single-class, correct). + // paramsA = [dLeft@0, ..., left@offLeft]; paramsB = [right@shift, ..., dRight@...] (already shifted into the + // left half's frame via the anchor offset above), so the main path is a plain concatenation. final CCParameter[] paramsA = segA != null ? segA.getParams() : new CCParameter[] { dLeft }; - final CCParameter[] paramsB = segB != null ? segB.getParams() : new CCParameter[] { right }; - // Shift the right half into the left half's frame: after the eq edge (left == right + eq.getOffset()), right - // sits at left's offset plus eq.getOffset(). - final Rational shift = paramsA[paramsA.length - 1].getOffset().add(eq.getOffset()); + final CCParameter[] paramsB = segB != null ? segB.getParams() : new CCParameter[] { CCParameter.of(right, shift) }; final CCParameter[] mainPath = new CCParameter[paramsA.length + paramsB.length]; System.arraycopy(paramsA, 0, mainPath, 0, paramsA.length); - for (int j = 0; j < paramsB.length; j++) { - final CCParameter p = paramsB[j]; - mainPath[paramsA.length + j] = CCParameter.of(p.getCCTerm(), p.getOffset().add(shift)); - } + System.arraycopy(paramsB, 0, mainPath, paramsA.length, paramsB.length); assert mainPath[mainPath.length - 1].getOffset().equals(dOff) : "net path offset must match the diseq offset"; // The remaining subpaths (congruences within either half) keep deriving their offsets the usual way. final ArrayList otherPaths = new ArrayList<>(); @@ -508,9 +524,8 @@ public Clause computeCongruenceAntiCycle(final CCAppTerm first, final CCAppTerm // first@existingDiff]); it establishes the actual (non-zero) offset. computePath(CCParameter.of(second, Rational.ZERO), CCParameter.of(first, Rational.ZERO)); // The argument equalities that justify the congruence first == second (offset 0). - for (int i = 0; i < first.getArgCount(); i++) { - computePath(first.getArgParam(i), second.getArgParam(i)); - } + computeCongruence(first, second); + drainTodo(); final Literal[] clause = new Literal[mAllLiterals.size()]; int i = 0; for (final Literal l : mAllLiterals) { @@ -518,7 +533,7 @@ public Clause computeCongruenceAntiCycle(final CCAppTerm first, final CCAppTerm } final Clause c = new Clause(clause); if (produceProofs) { - final SubPath existing = mVisited.get(new SymmetricPair<>((CCTerm) second, (CCTerm) first)); + final SubPath existing = mVisited.get(new SymmetricPair<>(second, first)); final CCParameter[] existingParams = existing.getParams(); // [second@0, ..., first@existingDiff] // main path: prepend first@0 (the congruence's other end) so the leading step first@0 -> second@0 is a // congruence edge and the two ends first@0 / first@existingDiff form the trivial diseq. @@ -542,9 +557,80 @@ public Clause computeCongruenceAntiCycle(final CCAppTerm first, final CCAppTerm return c; } - public Clause computeCycle(final CCTerm lconstant, final CCTerm rconstant, final boolean produceProofs) { - mClosure.getLogger().debug("computeCycle for Constants"); + /** + * Build the conflict clause for a shared-term clash discovered during a merge: the two classes being merged + * carry shared terms {@code lshared} (in the source class, reachable from the bridge term {@code lhs}) and + * {@code rshared} (in the destination class, reachable from the bridge term {@code rhsTerm}) whose merged values are + * provably distinct (e.g. an integer shared term forced to a non-integer value, {@code to_real a == 1/2}). The clash + * is detected before the union-find is updated, so the two classes are joined only by the freshly added equal edge + * {@code lhs — rhsTerm} while {@code mOffsetToRep} is still relative to each node's own representative. + * + *

As in {@link #computeAntiCycleDiffClass}, a single {@code computePath} across the bridge would read offsets from + * two different reference frames and produce a garbage proof object. Instead we compute the two halves as ordinary + * single-class paths ({@code lshared … lhs} and {@code rhsTerm … rshared}, each offset-correct) and stitch them by + * hand, shifting the destination half by {@code bridgeOff} (= {@code value(lhs) − value(rhsTerm)} implied by the + * merge reason; {@code 0} for a congruence). The bridge edge itself is justified either by the merge {@code reason} + * (a real equality literal) or, for a congruence merge ({@code reason == null}), by the argument equalities — exactly + * as in {@link #computeCongruenceAntiCycle}, where the bridge step {@code lhs → rhsTerm} is auto-resolved from the + * argument subpaths. No positive literal is needed: the contradiction is the trivially distinct shared values, + * discharged by an EQ/LA lemma (like {@link #computeCycle(CCParameter, CCParameter, boolean)}). + */ + public Clause computeSharedConflictCycle(final CCTerm lshared, final CCTerm rshared, final CCTerm lhs, + final CCTerm rhsTerm, final CCEquality reason, final Rational bridgeOff, final boolean produceProofs) { + // Two single-class paths. The source half is anchored at lshared@0, so its last node lhs sits at + // offLhs = lshared.mOffsetToRep - lhs.mOffsetToRep. The destination half is anchored directly in the source + // half's frame (rhsTerm@shift), so its params come out already shifted across the bridge: after the bridge edge + // (value(lhs) == value(rhsTerm) + bridgeOff), rhsTerm sits at offLhs + bridgeOff. No post-hoc shifting needed. + final Rational shift = lshared.mOffsetToRep.sub(lhs.mOffsetToRep).add(bridgeOff); + computePath(CCParameter.of(lshared, Rational.ZERO), CCParameter.of(lhs, Rational.ZERO)); + computePath(CCParameter.of(rhsTerm, shift), CCParameter.of(rshared, Rational.ZERO)); + // Justify the bridge edge lhs — rhsTerm. + if (reason != null) { + mAllLiterals.add(reason); + } else { + // Congruence bridge: the argument equalities justify lhs == rhsTerm (and build the subpaths that the proof + // generator uses to resolve the bridge step). + computeCongruence((CCAppTerm) lhs, (CCAppTerm) rhsTerm); + } + drainTodo(); + final Literal[] clause = new Literal[mAllLiterals.size()]; + int i = 0; + for (final Literal l : mAllLiterals) { + clause[i++] = l.negate(); + } + final Clause c = new Clause(clause); + if (produceProofs) { + final SubPath segSrc = lshared == lhs ? null : mVisited.get(new SymmetricPair<>(lshared, lhs)); + final SubPath segDest = rhsTerm == rshared ? null : mVisited.get(new SymmetricPair<>(rhsTerm, rshared)); + // paramsSrc = [lshared@0, ..., lhs@offLhs]; paramsDest = [rhsTerm@shift, ..., rshared@...] (already shifted + // into the source frame via the anchor offset above), so the main path is a plain concatenation. + final CCParameter[] paramsSrc = + segSrc != null ? segSrc.getParams() : new CCParameter[] { CCParameter.of(lshared, Rational.ZERO) }; + final CCParameter[] paramsDest = + segDest != null ? segDest.getParams() : new CCParameter[] { CCParameter.of(rhsTerm, shift) }; + final CCParameter[] mainPath = new CCParameter[paramsSrc.length + paramsDest.length]; + System.arraycopy(paramsSrc, 0, mainPath, 0, paramsSrc.length); + System.arraycopy(paramsDest, 0, mainPath, paramsSrc.length, paramsDest.length); + // The remaining subpaths (congruences within either half, plus the bridge's argument subpaths) keep deriving + // their offsets the usual way. + final ArrayList otherPaths = new ArrayList<>(); + for (final SubPath p : mAllPaths) { + if (p != segSrc && p != segDest) { + otherPaths.add(p); + } + } + // The trivially distinct shared values, discharged by an EQ/LA lemma. + final SymmetricPair diseq = new SymmetricPair<>(mainPath[0], mainPath[mainPath.length - 1]); + c.setProof(new LeafNode(LeafNode.THEORY_CC, + new CCAnnotation(diseq, mainPath, otherPaths, CCAnnotation.RuleKind.CONG))); + } + return c; + } + + public Clause computeCycle(final CCParameter lconstant, final CCParameter rconstant, final boolean produceProofs) { + mClosure.getLogger().debug("computeCycle for Constants: %s != %s", lconstant, rconstant); computePath(lconstant, rconstant); + drainTodo(); final Literal[] cycle = new Literal[mAllLiterals.size()]; int i = 0; for (final Literal l: mAllLiterals) { @@ -563,6 +649,7 @@ public Clause computeDTLemma(final CCEquality propagatedEq, final DataTypeLemma for (final SymmetricPair reason : lemma.getReason()) { computePath(reason.getFirst(), reason.getSecond()); } + drainTodo(); final Literal[] negLits = new Literal[mAllLiterals.size() + (propagatedEq != null ? 1 : 0)]; int i = 0; @@ -593,6 +680,7 @@ public Clause computeDTLemma(final CCEquality propagatedEq, final DataTypeLemma */ public int computeDecideLevel(final CCTerm lhs, final CCTerm rhs) { computePath(lhs, rhs); + drainTodo(); int depth = 0; for (final Literal l : mAllLiterals) { depth = Math.max(depth, l.getAtom().getDecideLevel()); diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/WeakCongruencePath.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/WeakCongruencePath.java index 61f7cbde2..12234fb02 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/WeakCongruencePath.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/WeakCongruencePath.java @@ -91,6 +91,7 @@ public Clause computeSelectOverWeakEQ(final CCAppTerm select1, final CCAppTerm s final CCTerm a = ArrayTheory.getArrayFromSelect(select1); final CCTerm b = ArrayTheory.getArrayFromSelect(select2); computePath(i1.getCCTerm(), i2.getCCTerm()); + drainTodo(); final WeakSubPath weakPath = computeWeakPath(a, b, i1, produceProofs); mAllPaths.addFirst(weakPath); @@ -230,6 +231,7 @@ private WeakSubPath computeWeakCongruencePath(final CCTerm array1, final CCTerm final CCAppTerm select = rep1.mSelects.get(indexRep); final CCTerm selectArray = ArrayTheory.getArrayFromSelect(select); computePath(index.getCCTerm(), ArrayTheory.getIndexFromSelect(select)); + drainTodo(); path = computeWeakPath(array1, selectArray, index, produceProofs); select1 = select; } @@ -244,6 +246,7 @@ private WeakSubPath computeWeakCongruencePath(final CCTerm array1, final CCTerm final CCAppTerm select = rep2.mSelects.get(indexRep); final CCTerm selectArray = ArrayTheory.getArrayFromSelect(select); computePath(index.getCCTerm(), ArrayTheory.getIndexFromSelect(select)); + drainTodo(); path.addEntry(selectArray, null); path.addSubPath(computeWeakPath(selectArray, array2, index, produceProofs)); select2 = select; @@ -253,6 +256,7 @@ private WeakSubPath computeWeakCongruencePath(final CCTerm array1, final CCTerm // check for trivial select edge (select-const). if (select1 != select2) { computePath(select1, select2); + drainTodo(); } return path; } @@ -271,6 +275,7 @@ public void collectPathOnePrimary(final Cursor cursor, final SubPath path, final assert t2.mRepStar == node.mPrimaryEdge.mTerm; if (cursor.mTerm != t1) { computePath(cursor.mTerm, t1); + drainTodo(); final SubPath subpath = mAllPaths.removeFirst(); path.addSubPath(subpath); } @@ -308,6 +313,7 @@ private SubPath collectPathPrimary(final Cursor start, final Cursor dest, final } if (start.mTerm != dest.mTerm) { computePath(start.mTerm, dest.mTerm); + drainTodo(); final SubPath subpath = mAllPaths.removeFirst(); path1.addSubPath(subpath); } From 47debf06b5d3f3fb2dc0637181008a26fea1f925 Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Wed, 24 Jun 2026 19:15:14 +0200 Subject: [PATCH 46/75] CC offset equalities: getParams(anchor) + merge-conflict-diseq stitch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework SubPath rendering and fix a lingering cross-class offset bug in the congruence-merge conflict path. SubPath.getParams now takes the anchor (a CCParameter in the path's class) at render time instead of storing mStart/mStartOffset; the relative offsets are intrinsic (getOffsetToRep differences). It asserts the anchor shares the representative of every numeric node — a path spanning two classes mixes reference frames and yields garbage offsets, the root of the earlier offset conflicts. Non-numeric terms never carry an offset, so weak-array paths (which cross strong classes) are exempt. The no-arg getParams() self-anchors at the first node. This guard exposed that computeCycle(diseq) from CCTerm.mergeInternal (a disequality forbids a merge at the matching offset) walks a single path across the freshly added, not-yet-united merge bridge — the eager-conflict twin of the lazy computeAntiCycle/computeAntiCycleDiffClass path, never converted, silently wrong in the proof object when the bridge offset is non-zero. Fix: generalize computeSharedConflictCycle into computeMergeConflictCycle taking an optional concrete diseq literal (shared-term clash -> null; merge conflict -> the disequality as the cycle's positive literal); both build the two single-class halves separately and stitch them. CClosure.computeMergeDiseqCycle wraps it; mergeInternal orients the diseq sides into source/destination class and calls it. abv/ext01 (bridgeOff=-1) exercises it and proof-checks unsat. The destination-half pre-shift now rides on getParams(rhsTerm@shift), so the main path is a plain concatenation (computeAntiCycleDiffClass and computeMergeConflictCycle). Removed dead code orphaned by the shared-term-clash change: SubPath.getTerms(), the CCTerm[] prepend overload and unused terms/mainTerms locals in CCAnnotation, and the two-arg computeCycle(CCParameter,CCParameter). test/proof 97/98, abv 4/4, bv 35/35, regression 53/55, uflira 5/5, lia 32/32 (only the two documented pre-existing failures); BitvectorTest 89/89, ProofSimplifierTest 14/14, ProofUtilsTest 4/4, CongruentAddTest 5/5, PairHashTest 1/1. Co-Authored-By: Claude Opus 4.8 (1M context) --- SMTInterpol/doc/offset-equality-plan.md | 48 ++++++ .../theory/cclosure/CCAnnotation.java | 13 -- .../smtinterpol/theory/cclosure/CCTerm.java | 17 ++- .../smtinterpol/theory/cclosure/CClosure.java | 24 ++- .../theory/cclosure/CongruencePath.java | 142 +++++++++--------- 5 files changed, 148 insertions(+), 96 deletions(-) diff --git a/SMTInterpol/doc/offset-equality-plan.md b/SMTInterpol/doc/offset-equality-plan.md index 1f79de9e3..2448082a6 100644 --- a/SMTInterpol/doc/offset-equality-plan.md +++ b/SMTInterpol/doc/offset-equality-plan.md @@ -912,6 +912,54 @@ re-checks its full cross-class anti-cycle proof); (`datatype/quantified/*match*` = `QuantClause.collectVarInfos`). Files: `CCTerm.java`, `CClosure.java`, `CongruencePath.java`, `WeakCongruencePath.java`. +## `SubPath.getParams(anchor)` + the cross-class guard, and the merge-conflict-diseq stitch — DONE (uncommitted) + +A `SubPath` is a list of offset-free CCTerms; `getParams` renders them as offsetted +`CCParameter`s. It used to store the anchor (`mStart`/`mStartOffset`) on the SubPath. +Now `getParams(CCParameter anchor)` takes the anchor at render time (the relative +offsets are intrinsic — `getOffsetToRep` differences — so the anchor only fixes the +absolute base). `mStart`/`mStartOffset` are gone; the no-arg `getParams()` self-anchors +at the path's first node. This also subsumes the per-element "pre-shift" in +`computeAntiCycleDiffClass`/`computeMergeConflictCycle`: the destination half is rendered +with `getParams(rhsTerm@shift)`, so the main path is a plain concatenation. + +**The cross-class assertion (Jochen's idea).** `getParams` asserts the anchor shares the +representative of every *numeric* node. `getOffsetToRep` is class-relative, so a path that +spans two classes mixes reference frames and yields garbage offsets — the bug behind every +cross-class offset conflict. Non-numeric terms can never carry an offset, so the legitimate +cross-class paths (weak-array paths over distinct strong classes) are exempt. + +**The lingering bug the assertion found.** With the strict guard, `computeCycle(diseq)` +called from `CCTerm.mergeInternal` (the `diseq != null` branch: a disequality forbids a +merge at exactly the merge offset) fired the assertion — it walks a single path across the +freshly added, not-yet-united merge bridge, exactly like the shared-term clash. It is the +eager-conflict twin of the lazy `computeAntiCycle → computeAntiCycleDiffClass` path, and was +never converted; it was silently wrong in the proof object whenever the bridge offset is +non-zero (the clause is always sound, so only proof-checking catches it). Fix: generalize +`computeSharedConflictCycle` into `computeMergeConflictCycle`, which takes an optional +concrete `diseqLit` (the shared-term clash passes `null`; the merge-conflict passes the +disequality, which becomes the cycle's positive literal). `CClosure.computeMergeDiseqCycle` +wraps it; `mergeInternal` orients the diseq's two sides into the source/destination class +(via pre-merge `mRepStar`) and calls it. Now both clash kinds build the two halves +separately — no cross-class numeric `getParams` remains. + +**Real trigger:** `abv/ext01.smt2` hits `computeMergeDiseqCycle` with `bridgeOff = -1` on +`(ubv_to_int (select d (@diff d e))) == (ubv_to_int (@diff d d))` — the abv/ext case where +this was first suspected. It now proof-checks `unsat`. + +Also removed dead code orphaned by the shared-term-clash change: `SubPath.getTerms()`, the +`CCTerm[]` `prepend` overload and the unused `terms`/`mainTerms` locals in `CCAnnotation`, +and the two-arg `computeCycle(CCParameter,CCParameter)` (CongruencePath impl + CClosure +wrapper). + +Validated (clean build, `-ea`; note `ant clean` is required after a stash/`cp` restore — a +stale `.class` newer than the restored `.java` is not rebuilt): `test/proof` 97/98, +`test/abv` 4/4, `test/bv` 35/35, `test/regression` 53/55, `test/uflira` 5/5, `test/lia` +32/32 — only the two documented pre-existing failures (`trivialdiseqarray` array+offset; +`Script_simple` `CCInterpolator occur==null` offset interpolation). `BitvectorTest` 89/89, +`ProofSimplifierTest` 14/14, `ProofUtilsTest` 4/4, `CongruentAddTest` 5/5, `PairHashTest` +1/1. Files: `CongruencePath.java`, `CCAnnotation.java`, `CClosure.java`, `CCTerm.java`. + ## Implementable slice (ready to start) 1. `LASharedTerm` becomes offset-free under `createOffsetEqualities()` (revert the diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAnnotation.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAnnotation.java index 81b45d51f..fd7bcfb96 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAnnotation.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAnnotation.java @@ -285,10 +285,6 @@ public CCAnnotation(final SymmetricPair diseq, final CCParameter[] mParamPaths = new CCParameter[n][]; mWeakIndices = new CCParameter[n]; mParamPaths[0] = mainPath; - final CCTerm[] mainTerms = new CCTerm[mainPath.length]; - for (int j = 0; j < mainPath.length; j++) { - mainTerms[j] = mainPath[j].getCCTerm(); - } mWeakIndices[0] = null; int i = 1; for (final SubPath p : otherPaths) { @@ -308,11 +304,9 @@ private CCAnnotation(final SymmetricPair diseq, final Collection getDiseqParam() { return mDiseqParam; diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCTerm.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCTerm.java index 47525f98a..e1590896a 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCTerm.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCTerm.java @@ -485,10 +485,19 @@ private Clause mergeInternal(final CClosure engine, final CCTerm lhs, final CCEq /* Check for conflict */ if (sharedTermConflict || diseq != null) { - final Clause conflict = sharedTermConflict - ? engine.computeSharedConflictCycle(src.mSharedTerm, dest.mSharedTerm, lhs, this, reason, - reasonDiff(reason, lhs, this)) - : engine.computeCycle(diseq); + final Clause conflict; + if (sharedTermConflict) { + conflict = engine.computeSharedConflictCycle(src.mSharedTerm, dest.mSharedTerm, lhs, this, reason, + reasonDiff(reason, lhs, this)); + } else { + // A disequality forbids this merge. Its two sides straddle the freshly added (not-yet-united) bridge, so + // orient them into the source/destination class and build the two halves separately (computeCycle would + // walk a single path across the bridge and mix offset frames). mRepStar is still the pre-merge rep here. + final CCTerm srcEnd = diseq.getLhs().mRepStar == src ? diseq.getLhs() : diseq.getRhs(); + final CCTerm destEnd = diseq.getLhs().mRepStar == src ? diseq.getRhs() : diseq.getLhs(); + conflict = engine.computeMergeDiseqCycle(srcEnd, destEnd, lhs, this, reason, + reasonDiff(reason, lhs, this), diseq); + } lhs.mEqualEdge = null; lhs.mOldRep = null; src.mReasonLiteral = null; diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java index 91114707a..d36316ebd 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java @@ -1067,20 +1067,28 @@ public Clause computeCycle(final CCEquality eq) { return res; } - public Clause computeCycle(final CCParameter lconstant, final CCParameter rconstant) { - final CongruencePath congPath = new CongruencePath(this); - return congPath.computeCycle(lconstant, rconstant, isProofGenerationEnabled()); - } - /** * Compute the conflict clause for a shared-term clash detected during a merge (the merged values of the two classes' * shared terms are provably distinct, e.g. an integer shared term forced to a non-integer value). See - * {@link CongruencePath#computeSharedConflictCycle}. + * {@link CongruencePath#computeMergeConflictCycle}. */ public Clause computeSharedConflictCycle(final CCTerm lshared, final CCTerm rshared, final CCTerm lhs, final CCTerm rhsTerm, final CCEquality reason, final Rational bridgeOff) { - return new CongruencePath(this).computeSharedConflictCycle(lshared, rshared, lhs, rhsTerm, reason, bridgeOff, - isProofGenerationEnabled()); + return new CongruencePath(this).computeMergeConflictCycle(lshared, rshared, lhs, rhsTerm, reason, bridgeOff, + null, isProofGenerationEnabled()); + } + + /** + * Compute the conflict clause when a merge of two classes is forbidden by a disequality {@code diseq} registered + * between them at exactly the merge offset. {@code srcEnd}/{@code destEnd} are the two sides of {@code diseq}, + * oriented into the source class (reachable from {@code lhs}) and destination class (reachable from {@code rhsTerm}); + * the path crosses the freshly added (not-yet-united) merge bridge, so it is built as two single-class halves. See + * {@link CongruencePath#computeMergeConflictCycle}. + */ + public Clause computeMergeDiseqCycle(final CCTerm srcEnd, final CCTerm destEnd, final CCTerm lhs, + final CCTerm rhsTerm, final CCEquality reason, final Rational bridgeOff, final CCEquality diseq) { + return new CongruencePath(this).computeMergeConflictCycle(srcEnd, destEnd, lhs, rhsTerm, reason, bridgeOff, + diseq, isProofGenerationEnabled()); } public Clause computeAntiCycle(final CCEquality eq) { diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java index a91eb1005..a7595ccd3 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java @@ -57,45 +57,51 @@ public class CongruencePath { */ public static class SubPath { ArrayList mTermsOnPath; - /** The anchor (first node) of the path and its offset; the offset of every other node is derived from it. */ - final CCTerm mStart; - final Rational mStartOffset; public SubPath(final CCParameter start) { this(start, true); } public SubPath(final CCParameter start, final boolean produceProofs) { - mStart = start.getCCTerm(); - mStartOffset = start.getOffset(); if (produceProofs) { mTermsOnPath = new ArrayList<>(); - mTermsOnPath.add(mStart); + mTermsOnPath.add(start.getCCTerm()); } } - /** The offset-free terms on the path. */ - public CCTerm[] getTerms() { - return mTermsOnPath.toArray(new CCTerm[mTermsOnPath.size()]); - } - /** - * The path nodes as {@link CCParameter}s. All nodes are in one congruence class, and the offsets are chosen so - * that every node has the same value as the anchor (the first node): {@code value(node) + offset == value(start) - * + startOffset}. So if {@code x = y+4} and {@code y = z+2}, a path anchored at {@code x+2} reads - * {@code x+2, y+6, z+8}. The relative offsets are intrinsic ({@code mOffsetToRep} differences), so this is stable - * under {@link #addSubPath} concatenation regardless of the appended pieces' own anchors. + * The path nodes as {@link CCParameter}s, offset so that every node has the same value as {@code anchor}: + * {@code value(node) + offset == value(anchor) + anchor.getOffset()}. So if {@code x = y+4} and {@code y = z+2}, + * a path rendered with anchor {@code x+2} reads {@code x+2, y+6, z+8}. The relative offsets are intrinsic + * ({@link CCTerm#getOffsetToRep} differences), so the anchor only fixes the absolute base and the result is + * stable under {@link #addSubPath} concatenation. The anchor is typically one of the path's own nodes. + * + *

{@link CCTerm#getOffsetToRep} is relative to each node's own representative, so for two nodes in different + * classes the difference mixes reference frames and yields garbage offsets — the bug behind several earlier + * offset conflicts (a path built over a freshly added, not-yet-united merge bridge). For numeric terms + * we therefore assert the anchor shares the representative of every node; the conflict builders that legitimately + * span two classes ({@link #computeMergeConflictCycle}, {@link #computeAntiCycleDiffClass}) avoid this by rendering + * each single-class half separately. The only paths that genuinely cross classes are non-numeric (weak-array + * paths over distinct strong classes), and a non-numeric term can never carry an offset, so the rendering is + * trivially correct (offset zero) regardless of frame. */ - public CCParameter[] getParams() { + public CCParameter[] getParams(final CCParameter anchor) { final CCParameter[] params = new CCParameter[mTermsOnPath.size()]; for (int i = 0; i < params.length; i++) { final CCTerm t = mTermsOnPath.get(i); - final Rational off = mStartOffset.add(mStart.mOffsetToRep).sub(t.mOffsetToRep); - params[i] = CCParameter.of(t, off); + assert !t.getFlatTerm().getSort().isNumericSort() + || anchor.getRepresentative() == t.getRepresentative() + : "getParams anchor must share the congruence class of every numeric node"; + params[i] = CCParameter.of(t, anchor.getOffsetToRep().sub(t.getOffsetToRep())); } return params; } + /** {@link #getParams(CCParameter)} self-anchored at the path's first node (offset 0). */ + public CCParameter[] getParams() { + return getParams(mTermsOnPath.get(0)); + } + public void addEntry(final CCTerm term, final CCEquality reason) { if (mTermsOnPath != null) { mTermsOnPath.add(term); @@ -459,12 +465,8 @@ public Clause computeAntiCycleDiffClass(final CCEquality eq, final CCEquality di dOff = diseq.getOffset().negate(); } // Two single-class paths, accumulating their reason literals into mAllLiterals and their subpaths into mAllPaths. - // The left half is anchored at dLeft@0, so its last node left sits at offLeft = dLeft.mOffsetToRep - - // left.mOffsetToRep. The right half is anchored directly in the left half's frame (right@shift), so after the eq - // edge (left == right + eq.getOffset()) its params come out already shifted across the bridge; no post-hoc shift. - final Rational shift = dLeft.mOffsetToRep.sub(left.mOffsetToRep).add(eq.getOffset()); computePath(dLeft, left); - computePath(CCParameter.of(right, shift), dRight); + computePath(right, dRight); drainTodo(); final Literal[] clause = new Literal[mAllLiterals.size() + 2]; int i = 0; @@ -477,10 +479,14 @@ public Clause computeAntiCycleDiffClass(final CCEquality eq, final CCEquality di if (produceProofs) { final SubPath segA = dLeft == left ? null : mVisited.get(new SymmetricPair<>(dLeft, left)); final SubPath segB = right == dRight ? null : mVisited.get(new SymmetricPair<>(right, dRight)); - // paramsA = [dLeft@0, ..., left@offLeft]; paramsB = [right@shift, ..., dRight@...] (already shifted into the - // left half's frame via the anchor offset above), so the main path is a plain concatenation. + // paramsA = [dLeft@0, ..., left@offLeft] (self-anchored). Shift the right half into the left half's frame: + // after the eq edge (left == right + eq.getOffset()), right sits at left's offset plus eq.getOffset(); + // rendering the right half with that anchor yields it already shifted, so the main path is a plain concat. final CCParameter[] paramsA = segA != null ? segA.getParams() : new CCParameter[] { dLeft }; - final CCParameter[] paramsB = segB != null ? segB.getParams() : new CCParameter[] { CCParameter.of(right, shift) }; + final CCParameter rightAnchor = + CCParameter.of(right, paramsA[paramsA.length - 1].getOffset().add(eq.getOffset())); + final CCParameter[] paramsB = + segB != null ? segB.getParams(rightAnchor) : new CCParameter[] { rightAnchor }; final CCParameter[] mainPath = new CCParameter[paramsA.length + paramsB.length]; System.arraycopy(paramsA, 0, mainPath, 0, paramsA.length); System.arraycopy(paramsB, 0, mainPath, paramsA.length, paramsB.length); @@ -558,32 +564,35 @@ public Clause computeCongruenceAntiCycle(final CCAppTerm first, final CCAppTerm } /** - * Build the conflict clause for a shared-term clash discovered during a merge: the two classes being merged - * carry shared terms {@code lshared} (in the source class, reachable from the bridge term {@code lhs}) and - * {@code rshared} (in the destination class, reachable from the bridge term {@code rhsTerm}) whose merged values are - * provably distinct (e.g. an integer shared term forced to a non-integer value, {@code to_real a == 1/2}). The clash - * is detected before the union-find is updated, so the two classes are joined only by the freshly added equal edge - * {@code lhs — rhsTerm} while {@code mOffsetToRep} is still relative to each node's own representative. + * Build the conflict clause for a clash discovered during a merge of two classes that are joined only by the + * freshly added equal edge {@code lhs — rhsTerm} (the union-find is not yet updated, so {@code mOffsetToRep} is still + * relative to each node's own representative). The conflicting fact lives between an endpoint {@code srcEnd} + * in the source class (reachable from {@code lhs}) and an endpoint {@code destEnd} in the destination class (reachable + * from {@code rhsTerm}). Two cases: + *

    + *
  • Shared-term clash ({@code diseqLit == null}): {@code srcEnd}/{@code destEnd} are the two classes' shared + * terms, whose merged values are provably distinct (e.g. an integer shared term forced to a non-integer value, + * {@code to_real a == 1/2}). The contradiction is intrinsic, discharged by an EQ/LA lemma; no positive literal.
  • + *
  • Disequality clash ({@code diseqLit != null}): a registered disequality {@code diseqLit} between the two + * classes forbids exactly the offset at which the merge would unite {@code srcEnd} and {@code destEnd}. The clause + * carries {@code diseqLit} as its positive literal.
  • + *
* *

As in {@link #computeAntiCycleDiffClass}, a single {@code computePath} across the bridge would read offsets from * two different reference frames and produce a garbage proof object. Instead we compute the two halves as ordinary - * single-class paths ({@code lshared … lhs} and {@code rhsTerm … rshared}, each offset-correct) and stitch them by + * single-class paths ({@code srcEnd … lhs} and {@code rhsTerm … destEnd}, each offset-correct) and stitch them by * hand, shifting the destination half by {@code bridgeOff} (= {@code value(lhs) − value(rhsTerm)} implied by the * merge reason; {@code 0} for a congruence). The bridge edge itself is justified either by the merge {@code reason} * (a real equality literal) or, for a congruence merge ({@code reason == null}), by the argument equalities — exactly * as in {@link #computeCongruenceAntiCycle}, where the bridge step {@code lhs → rhsTerm} is auto-resolved from the - * argument subpaths. No positive literal is needed: the contradiction is the trivially distinct shared values, - * discharged by an EQ/LA lemma (like {@link #computeCycle(CCParameter, CCParameter, boolean)}). + * argument subpaths. */ - public Clause computeSharedConflictCycle(final CCTerm lshared, final CCTerm rshared, final CCTerm lhs, - final CCTerm rhsTerm, final CCEquality reason, final Rational bridgeOff, final boolean produceProofs) { - // Two single-class paths. The source half is anchored at lshared@0, so its last node lhs sits at - // offLhs = lshared.mOffsetToRep - lhs.mOffsetToRep. The destination half is anchored directly in the source - // half's frame (rhsTerm@shift), so its params come out already shifted across the bridge: after the bridge edge - // (value(lhs) == value(rhsTerm) + bridgeOff), rhsTerm sits at offLhs + bridgeOff. No post-hoc shifting needed. - final Rational shift = lshared.mOffsetToRep.sub(lhs.mOffsetToRep).add(bridgeOff); - computePath(CCParameter.of(lshared, Rational.ZERO), CCParameter.of(lhs, Rational.ZERO)); - computePath(CCParameter.of(rhsTerm, shift), CCParameter.of(rshared, Rational.ZERO)); + public Clause computeMergeConflictCycle(final CCTerm srcEnd, final CCTerm destEnd, final CCTerm lhs, + final CCTerm rhsTerm, final CCEquality reason, final Rational bridgeOff, final CCEquality diseqLit, + final boolean produceProofs) { + // Two single-class paths, each offset-correct on its own. + computePath(srcEnd, lhs); + computePath(rhsTerm, destEnd); // Justify the bridge edge lhs — rhsTerm. if (reason != null) { mAllLiterals.add(reason); @@ -593,21 +602,28 @@ public Clause computeSharedConflictCycle(final CCTerm lshared, final CCTerm rsha computeCongruence((CCAppTerm) lhs, (CCAppTerm) rhsTerm); } drainTodo(); - final Literal[] clause = new Literal[mAllLiterals.size()]; + final Literal[] clause = new Literal[mAllLiterals.size() + (diseqLit != null ? 1 : 0)]; int i = 0; + // The separating disequality (if any) is the cycle's only positive literal; the merge falsifies it. + if (diseqLit != null) { + clause[i++] = diseqLit; + } for (final Literal l : mAllLiterals) { clause[i++] = l.negate(); } final Clause c = new Clause(clause); if (produceProofs) { - final SubPath segSrc = lshared == lhs ? null : mVisited.get(new SymmetricPair<>(lshared, lhs)); - final SubPath segDest = rhsTerm == rshared ? null : mVisited.get(new SymmetricPair<>(rhsTerm, rshared)); - // paramsSrc = [lshared@0, ..., lhs@offLhs]; paramsDest = [rhsTerm@shift, ..., rshared@...] (already shifted - // into the source frame via the anchor offset above), so the main path is a plain concatenation. - final CCParameter[] paramsSrc = - segSrc != null ? segSrc.getParams() : new CCParameter[] { CCParameter.of(lshared, Rational.ZERO) }; + final SubPath segSrc = srcEnd == lhs ? null : mVisited.get(new SymmetricPair<>(srcEnd, lhs)); + final SubPath segDest = rhsTerm == destEnd ? null : mVisited.get(new SymmetricPair<>(rhsTerm, destEnd)); + // paramsSrc = [srcEnd@0, ..., lhs@offLhs] (self-anchored). Shift the destination half into the source + // half's frame: after the bridge edge (value(lhs) == value(rhsTerm) + bridgeOff), rhsTerm sits at lhs's + // offset plus bridgeOff; rendering the dest half with that anchor yields it already shifted, so the main + // path is a plain concatenation. + final CCParameter[] paramsSrc = segSrc != null ? segSrc.getParams() : new CCParameter[] { srcEnd }; + final CCParameter destAnchor = + CCParameter.of(rhsTerm, paramsSrc[paramsSrc.length - 1].getOffset().add(bridgeOff)); final CCParameter[] paramsDest = - segDest != null ? segDest.getParams() : new CCParameter[] { CCParameter.of(rhsTerm, shift) }; + segDest != null ? segDest.getParams(destAnchor) : new CCParameter[] { destAnchor }; final CCParameter[] mainPath = new CCParameter[paramsSrc.length + paramsDest.length]; System.arraycopy(paramsSrc, 0, mainPath, 0, paramsSrc.length); System.arraycopy(paramsDest, 0, mainPath, paramsSrc.length, paramsDest.length); @@ -619,7 +635,8 @@ public Clause computeSharedConflictCycle(final CCTerm lshared, final CCTerm rsha otherPaths.add(p); } } - // The trivially distinct shared values, discharged by an EQ/LA lemma. + // The clashing equality: a concrete disequality discharged against diseqLit, or (shared case) the trivially + // distinct shared values discharged by an EQ/LA lemma. final SymmetricPair diseq = new SymmetricPair<>(mainPath[0], mainPath[mainPath.length - 1]); c.setProof(new LeafNode(LeafNode.THEORY_CC, new CCAnnotation(diseq, mainPath, otherPaths, CCAnnotation.RuleKind.CONG))); @@ -627,23 +644,6 @@ public Clause computeSharedConflictCycle(final CCTerm lshared, final CCTerm rsha return c; } - public Clause computeCycle(final CCParameter lconstant, final CCParameter rconstant, final boolean produceProofs) { - mClosure.getLogger().debug("computeCycle for Constants: %s != %s", lconstant, rconstant); - computePath(lconstant, rconstant); - drainTodo(); - final Literal[] cycle = new Literal[mAllLiterals.size()]; - int i = 0; - for (final Literal l: mAllLiterals) { - cycle[i++] = l.negate(); - } - final Clause c = new Clause(cycle); - if (produceProofs) { - c.setProof(new LeafNode( - LeafNode.THEORY_CC, createAnnotation(new SymmetricPair<>(lconstant, rconstant)))); - } - return c; - } - public Clause computeDTLemma(final CCEquality propagatedEq, final DataTypeLemma lemma, final boolean produceProofs) { for (final SymmetricPair reason : lemma.getReason()) { From 98de08bc2f3b22730637eaffc1d4c29de2ffacf3 Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Wed, 24 Jun 2026 19:38:19 +0200 Subject: [PATCH 47/75] CC offset equalities: orient stitched subpaths by anchor in getParams mVisited keys congruence subpaths by an undirected SymmetricPair, so a retrieved SubPath's stored term list may run either way. The cross-class stitch builders (computeMergeConflictCycle, computeAntiCycleDiffClass, computeCongruenceAntiCycle) assumed [start..end] and produced a mis-stitched main path when the subpath was stored backwards. getParams(anchor) now treats the anchor as the intended start: the anchor must be a path endpoint, and the term list is rendered in reverse when the anchor is the stored last node, so callers always get [anchor..other]. The stitch sites anchor explicitly at their start endpoint (segSrc.getParams(srcEnd), segB.getParams(right@shift), existing.getParams(second@0)) instead of self-anchoring, and assert the main path runs srcEnd..destEnd by comparing getCCTerm() (not the offsetted parameter, which carries a non-zero offset for a real offset diseq). Validated by temporarily reversing the build order of both halves: abv/ext01 still proof-checks unsat. test/proof 97/98, abv 4/4, bv 35/35, regression 53/55, uflira 5/5, lia 32/32 (only the two pre-existing failures); BitvectorTest 89/89, ProofSimplifier/ ProofUtils/CongruentAdd/PairHash green. Co-Authored-By: Claude Opus 4.8 (1M context) --- SMTInterpol/doc/offset-equality-plan.md | 12 ++++++ .../theory/cclosure/CongruencePath.java | 38 +++++++++++++------ 2 files changed, 38 insertions(+), 12 deletions(-) diff --git a/SMTInterpol/doc/offset-equality-plan.md b/SMTInterpol/doc/offset-equality-plan.md index 2448082a6..2a555a3cf 100644 --- a/SMTInterpol/doc/offset-equality-plan.md +++ b/SMTInterpol/doc/offset-equality-plan.md @@ -923,6 +923,18 @@ at the path's first node. This also subsumes the per-element "pre-shift" in `computeAntiCycleDiffClass`/`computeMergeConflictCycle`: the destination half is rendered with `getParams(rhsTerm@shift)`, so the main path is a plain concatenation. +**Direction fix (Jochen caught it).** `mVisited` keys paths by an undirected +`SymmetricPair`, so a retrieved `SubPath`'s stored term list may run either way; the +stitch builders assumed `[start … end]`. `getParams(anchor)` now treats the anchor as the +intended start (it must be one of the two endpoints) and renders the term list in +reverse when the anchor is the stored last node, so callers always get `[anchor … other]`. +The three stitch sites anchor explicitly at their start endpoint +(`segSrc.getParams(srcEnd)`, `segB.getParams(right@shift)`, +`existing.getParams(second@0)`) rather than self-anchoring, and assert the main path runs +`srcEnd … destEnd` (comparing `getCCTerm()`, not the offsetted parameter). Validated by +temporarily reversing the build order of both halves: `abv/ext01` still proof-checks +`unsat`. + **The cross-class assertion (Jochen's idea).** `getParams` asserts the anchor shares the representative of every *numeric* node. `getOffsetToRep` is class-relative, so a path that spans two classes mixes reference frames and yields garbage offsets — the bug behind every diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java index a7595ccd3..3d968b580 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java @@ -76,6 +76,11 @@ public SubPath(final CCParameter start, final boolean produceProofs) { * ({@link CCTerm#getOffsetToRep} differences), so the anchor only fixes the absolute base and the result is * stable under {@link #addSubPath} concatenation. The anchor is typically one of the path's own nodes. * + *

The {@code anchor} is treated as the start of the path: its term must be one of the path's two + * endpoints, and the result is oriented so that the anchor's term is first (the stored term list may run either + * way, since {@link #mVisited} keys paths by an undirected {@link SymmetricPair}). The anchor's term renders at + * {@code anchor.getOffset()} and the other endpoint follows from the intrinsic relative offsets. + * *

{@link CCTerm#getOffsetToRep} is relative to each node's own representative, so for two nodes in different * classes the difference mixes reference frames and yields garbage offsets — the bug behind several earlier * offset conflicts (a path built over a freshly added, not-yet-united merge bridge). For numeric terms @@ -86,9 +91,15 @@ public SubPath(final CCParameter start, final boolean produceProofs) { * trivially correct (offset zero) regardless of frame. */ public CCParameter[] getParams(final CCParameter anchor) { - final CCParameter[] params = new CCParameter[mTermsOnPath.size()]; - for (int i = 0; i < params.length; i++) { - final CCTerm t = mTermsOnPath.get(i); + final int n = mTermsOnPath.size(); + assert mTermsOnPath.get(0) == anchor.getCCTerm() || mTermsOnPath.get(n - 1) == anchor.getCCTerm() + : "getParams anchor must be a path endpoint"; + // Orient the path so the anchor's term is first. The stored term list may run either way (mVisited keys + // paths undirected), so if the anchor is the stored last node, render in reverse. + final boolean reversed = mTermsOnPath.get(0) != anchor.getCCTerm(); + final CCParameter[] params = new CCParameter[n]; + for (int i = 0; i < n; i++) { + final CCTerm t = mTermsOnPath.get(reversed ? n - 1 - i : i); assert !t.getFlatTerm().getSort().isNumericSort() || anchor.getRepresentative() == t.getRepresentative() : "getParams anchor must share the congruence class of every numeric node"; @@ -479,10 +490,10 @@ public Clause computeAntiCycleDiffClass(final CCEquality eq, final CCEquality di if (produceProofs) { final SubPath segA = dLeft == left ? null : mVisited.get(new SymmetricPair<>(dLeft, left)); final SubPath segB = right == dRight ? null : mVisited.get(new SymmetricPair<>(right, dRight)); - // paramsA = [dLeft@0, ..., left@offLeft] (self-anchored). Shift the right half into the left half's frame: - // after the eq edge (left == right + eq.getOffset()), right sits at left's offset plus eq.getOffset(); - // rendering the right half with that anchor yields it already shifted, so the main path is a plain concat. - final CCParameter[] paramsA = segA != null ? segA.getParams() : new CCParameter[] { dLeft }; + // paramsA = [dLeft@0, ..., left@offLeft] (anchored and oriented at dLeft). Shift the right half into the left + // half's frame: after the eq edge (left == right + eq.getOffset()), right sits at left's offset plus + // eq.getOffset(); rendering the right half anchored there yields it already shifted, so a plain concat. + final CCParameter[] paramsA = segA != null ? segA.getParams(dLeft) : new CCParameter[] { dLeft }; final CCParameter rightAnchor = CCParameter.of(right, paramsA[paramsA.length - 1].getOffset().add(eq.getOffset())); final CCParameter[] paramsB = @@ -540,7 +551,8 @@ public Clause computeCongruenceAntiCycle(final CCAppTerm first, final CCAppTerm final Clause c = new Clause(clause); if (produceProofs) { final SubPath existing = mVisited.get(new SymmetricPair<>(second, first)); - final CCParameter[] existingParams = existing.getParams(); // [second@0, ..., first@existingDiff] + // anchored and oriented at second@0: [second@0, ..., first@existingDiff] + final CCParameter[] existingParams = existing.getParams(CCParameter.of(second, Rational.ZERO)); // main path: prepend first@0 (the congruence's other end) so the leading step first@0 -> second@0 is a // congruence edge and the two ends first@0 / first@existingDiff form the trivial diseq. final CCParameter[] mainPath = new CCParameter[existingParams.length + 1]; @@ -615,11 +627,11 @@ public Clause computeMergeConflictCycle(final CCTerm srcEnd, final CCTerm destEn if (produceProofs) { final SubPath segSrc = srcEnd == lhs ? null : mVisited.get(new SymmetricPair<>(srcEnd, lhs)); final SubPath segDest = rhsTerm == destEnd ? null : mVisited.get(new SymmetricPair<>(rhsTerm, destEnd)); - // paramsSrc = [srcEnd@0, ..., lhs@offLhs] (self-anchored). Shift the destination half into the source - // half's frame: after the bridge edge (value(lhs) == value(rhsTerm) + bridgeOff), rhsTerm sits at lhs's - // offset plus bridgeOff; rendering the dest half with that anchor yields it already shifted, so the main + // paramsSrc = [srcEnd@0, ..., lhs@offLhs] (anchored and oriented at srcEnd). Shift the destination half into + // the source half's frame: after the bridge edge (value(lhs) == value(rhsTerm) + bridgeOff), rhsTerm sits at + // lhs's offset plus bridgeOff; rendering the dest half anchored there yields it already shifted, so the main // path is a plain concatenation. - final CCParameter[] paramsSrc = segSrc != null ? segSrc.getParams() : new CCParameter[] { srcEnd }; + final CCParameter[] paramsSrc = segSrc != null ? segSrc.getParams(srcEnd) : new CCParameter[] { srcEnd }; final CCParameter destAnchor = CCParameter.of(rhsTerm, paramsSrc[paramsSrc.length - 1].getOffset().add(bridgeOff)); final CCParameter[] paramsDest = @@ -629,6 +641,8 @@ public Clause computeMergeConflictCycle(final CCTerm srcEnd, final CCTerm destEn System.arraycopy(paramsDest, 0, mainPath, paramsSrc.length, paramsDest.length); // The remaining subpaths (congruences within either half, plus the bridge's argument subpaths) keep deriving // their offsets the usual way. + assert mainPath[0].getCCTerm() == srcEnd : "main path must start at srcEnd"; + assert mainPath[mainPath.length - 1].getCCTerm() == destEnd : "main path must end at destEnd"; final ArrayList otherPaths = new ArrayList<>(); for (final SubPath p : mAllPaths) { if (p != segSrc && p != segDest) { From 19a70b20c9deba046f50e81e367a5cb701fb2abc Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Wed, 24 Jun 2026 21:50:04 +0200 Subject: [PATCH 48/75] CC offset equalities: consolidate all offset-cycle explainers into one The three anti-cycle builders are special cases of computeMergeConflictCycle. The same-class ones are the degenerate case where the two endpoints coincide (srcEnd == destEnd == lhs): the source half is empty and the destination half is the existing class path from rhsTerm back to lhs, so the trivial diseq (lhs@0, lhs@(bridgeOff - pathOffset)) falls out of the same two-half stitch. Three knobs span every case: srcEnd==destEnd (same vs cross class), reason==null (congruence vs equality bridge), diseqLit==null (trivial/shared vs concrete disequality). Delete CongruencePath.computeAntiCycle, computeAntiCycleDiffClass and computeCongruenceAntiCycle; route CClosure.computeAntiCycle (both branches, orienting the separating diseq via eq.mDiseqOrientation at the call site) and computeCongruenceAntiCycle through computeMergeConflictCycle. setLiteral's same-class assert-true conflict uses a new computeSameClassAntiCycle helper (must force the same-class shape since eq may carry a stale mDiseqReason). Five conflict explainers collapse to one (plus computeCycle, the bridgeless positive-eq cycle). The unified same-class anti-cycle anchors its trivial diseq on lhs instead of the old rhs; both are valid EQ-discharged trivial diseqs, so proof-check is unaffected. test/proof 97/98, abv 4/4, bv 35/35, regression 54/55, uflira 5/5, lia 32/32, datatype non-quantified clean (only the documented pre-existing failures); BitvectorTest 89/89, ProofSimplifierTest 14/14, ProofUtilsTest 4/4, CongruentAddTest 5/5, PairHashTest 1/1. Co-Authored-By: Claude Opus 4.8 (1M context) --- SMTInterpol/doc/offset-equality-plan.md | 38 +++ .../theory/cclosure/CCAnnotation.java | 12 +- .../smtinterpol/theory/cclosure/CClosure.java | 45 +++- .../theory/cclosure/CongruencePath.java | 224 +++--------------- 4 files changed, 109 insertions(+), 210 deletions(-) diff --git a/SMTInterpol/doc/offset-equality-plan.md b/SMTInterpol/doc/offset-equality-plan.md index 2a555a3cf..176e5fbc1 100644 --- a/SMTInterpol/doc/offset-equality-plan.md +++ b/SMTInterpol/doc/offset-equality-plan.md @@ -972,6 +972,44 @@ stale `.class` newer than the restored `.java` is not rebuilt): `test/proof` 97/ `ProofSimplifierTest` 14/14, `ProofUtilsTest` 4/4, `CongruentAddTest` 5/5, `PairHashTest` 1/1. Files: `CongruencePath.java`, `CCAnnotation.java`, `CClosure.java`, `CCTerm.java`. +## All offset-cycle explainers consolidated into `computeMergeConflictCycle` — DONE (uncommitted) + +Jochen's observation: the three anti-cycle builders are all special cases of +`computeMergeConflictCycle`. The same-class ones are the degenerate case where the two +endpoints coincide (`srcEnd == destEnd == lhs`): the source half is empty and the +destination half is the existing class path from `rhsTerm` back to `lhs`, so the trivial +diseq `(lhs@0, lhs@(bridgeOff − pathOffset))` falls out of the same two-half stitch. The +three knobs span every case: + +- `srcEnd == destEnd` ⇒ same-class (anti-cycle), else cross-class (merge / diff-class). +- `reason == null` ⇒ congruence bridge (justified by argument equalities), else an equality + literal bridge. +- `diseqLit == null` ⇒ trivial/shared diseq (EQ/LA discharged), else a concrete disequality + literal carried positively in the clause. + +Mapping (all now `CClosure` → `CongruencePath.computeMergeConflictCycle`): +- `computeAntiCycle` same-class: `(eq.lhs, eq.lhs, eq.lhs, eq.rhs, eq, eq.getOffset(), null)`. +- `computeAntiCycle` diff-class: `(dLeft, dRight, eq.lhs, eq.rhs, eq, eq.getOffset(), diseq)` + with `dLeft`/`dRight` oriented via `eq.mDiseqOrientation` at the call site. +- `computeCongruenceAntiCycle`: `(first, first, first, second, null, ZERO, null)`. +- `computeMergeDiseqCycle` / `computeSharedConflictCycle` unchanged (eager merge conflict). + +Deleted `CongruencePath.computeAntiCycle`, `computeAntiCycleDiffClass`, +`computeCongruenceAntiCycle` (5 explainers → 1 + `computeCycle`, the bridgeless +positive-eq cycle which stays separate). `CClosure.setLiteral`'s same-class assert-true +case routes through a new `computeSameClassAntiCycle` helper (must force the same-class +shape — `eq` can carry a stale `mDiseqReason`). Cosmetic: the unified same-class anti-cycle +anchors its trivial diseq on `lhs` (`(lhs@0, lhs@dev)`) instead of the old `rhs` anchoring — +both are valid EQ-discharged trivial diseqs, proof-check unaffected. `CongruencePath` +−170 lines net. + +Validated (clean build, `-ea`): `test/proof` 97/98, `test/abv` 4/4, `test/bv` 35/35, +`test/regression` 54/55, `test/uflira` 5/5, `test/lia` 32/32, `test/datatype` non-quantified +clean — only the documented pre-existing failures (`trivialdiseqarray`, `Script_simple`, +`datatype/quantified/*match*` = `QuantClause.collectVarInfos`). `BitvectorTest` 89/89, +`ProofSimplifierTest` 14/14, `ProofUtilsTest` 4/4, `CongruentAddTest` 5/5, `PairHashTest` +1/1. Files: `CongruencePath.java`, `CClosure.java`, `CCAnnotation.java`. + ## Implementable slice (ready to start) 1. `LASharedTerm` becomes offset-free under `createOffsetEqualities()` (revert the diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAnnotation.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAnnotation.java index fd7bcfb96..5ab830143 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAnnotation.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAnnotation.java @@ -271,12 +271,12 @@ public CCAnnotation(final SymmetricPair diseq, final Collection diseq, final CCParameter[] mainPath, final Collection otherPaths, final RuleKind rule) { diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java index d36316ebd..899cf59e4 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java @@ -968,7 +968,7 @@ public Clause setLiteral(final Literal literal) { // falsified. (computeCycle would put eq positively and yield a satisfied clause.) final Rational existingDiff = eq.getLhs().getOffsetToRep().sub(eq.getRhs().getOffsetToRep()); if (!existingDiff.equals(eq.getOffset())) { - return new CongruencePath(this).computeAntiCycle(eq, isProofGenerationEnabled()); + return computeSameClassAntiCycle(eq); } } } else { @@ -1091,29 +1091,50 @@ public Clause computeMergeDiseqCycle(final CCTerm srcEnd, final CCTerm destEnd, diseq, isProofGenerationEnabled()); } + /** + * Same-class offset conflict: {@code eq}'s two sides are in one class at an offset different from the one {@code eq} + * claims (e.g. asserting {@code x == x + 1}, or an offset disequality re-propagated false). This is a degenerate + * {@link CongruencePath#computeMergeConflictCycle}: the "merge" bridge is {@code eq} itself and the two endpoints + * coincide ({@code srcEnd == destEnd == eq.getLhs()}), so the source half is empty, the destination half is the + * existing class path from {@code eq.getRhs()} back to {@code eq.getLhs()}, and the trivial diseq + * {@code (lhs@0, lhs@deviation)} is discharged by an EQ lemma. + */ + private Clause computeSameClassAntiCycle(final CCEquality eq) { + assert eq.getLhs().mRepStar == eq.getRhs().mRepStar; + assert !eq.getLhs().getOffsetToRep().sub(eq.getRhs().getOffsetToRep()).equals(eq.getOffset()); + return new CongruencePath(this).computeMergeConflictCycle(eq.getLhs(), eq.getLhs(), eq.getLhs(), eq.getRhs(), eq, + eq.getOffset(), null, isProofGenerationEnabled()); + } + public Clause computeAntiCycle(final CCEquality eq) { final CCEquality diseq = eq.mDiseqReason; if (diseq == null) { - // No separating disequality (e.g. x != x+5): the two sides are in the same class at an offset different from - // the one eq claims, so the path between them is the entire reason; no CCEquality is involved. - assert eq.getLhs().mRepStar == eq.getRhs().mRepStar; - assert !eq.getLhs().getOffsetToRep().sub(eq.getRhs().getOffsetToRep()).equals(eq.getOffset()); - return new CongruencePath(this).computeAntiCycle(eq, isProofGenerationEnabled()); + // No separating disequality (e.g. x != x+5): the two sides are in the same class at a deviating offset. + return computeSameClassAntiCycle(eq); } // A concrete disequality separates eq's two sides. This works whether they are still in different classes or were - // merged later: computeAntiCycleDiffClass orients diseq from eq's stored orientation and walks the two original - // single-class paths plus the explicit eq edge, never mutating the graph. - return new CongruencePath(this).computeAntiCycleDiffClass(eq, diseq, isProofGenerationEnabled()); + // merged later: orient diseq from eq's stored orientation (live reps can no longer tell the two sides apart after + // a merge), then build the two single-class halves and stitch them across the eq bridge. + final CCTerm srcEnd = eq.mDiseqOrientation ? diseq.getLhs() : diseq.getRhs(); + final CCTerm destEnd = eq.mDiseqOrientation ? diseq.getRhs() : diseq.getLhs(); + return new CongruencePath(this).computeMergeConflictCycle(srcEnd, destEnd, eq.getLhs(), eq.getRhs(), eq, + eq.getOffset(), diseq, isProofGenerationEnabled()); } /** * Compute the conflict clause when a congruence merge finds its two function applications already in the same * congruence class at an offset different from the zero offset that congruence implies (e.g. f(x) and f(y) are - * congruent but the class already records f(x) = f(y) + k for k != 0). See - * {@link CongruencePath#computeCongruenceAntiCycle}. + * congruent but the class already records f(x) = f(y) + k for k != 0). A degenerate + * {@link CongruencePath#computeMergeConflictCycle}: the bridge is the congruence ({@code reason == null}, justified by + * the argument equalities) and the endpoints coincide ({@code srcEnd == destEnd == first}), so the destination half + * is the existing class path from {@code second} back to {@code first} and the trivial diseq + * {@code (first@0, first@existingDiff)} is EQ-discharged. */ public Clause computeCongruenceAntiCycle(final CCAppTerm first, final CCAppTerm second) { - return new CongruencePath(this).computeCongruenceAntiCycle(first, second, isProofGenerationEnabled()); + assert first.getRepresentative() == second.getRepresentative(); + assert first.getFunctionSymbol() == second.getFunctionSymbol(); + return new CongruencePath(this).computeMergeConflictCycle(first, first, first, second, null, Rational.ZERO, null, + isProofGenerationEnabled()); } /** diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java index 3d968b580..d611081aa 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java @@ -84,9 +84,9 @@ public SubPath(final CCParameter start, final boolean produceProofs) { *

{@link CCTerm#getOffsetToRep} is relative to each node's own representative, so for two nodes in different * classes the difference mixes reference frames and yields garbage offsets — the bug behind several earlier * offset conflicts (a path built over a freshly added, not-yet-united merge bridge). For numeric terms - * we therefore assert the anchor shares the representative of every node; the conflict builders that legitimately - * span two classes ({@link #computeMergeConflictCycle}, {@link #computeAntiCycleDiffClass}) avoid this by rendering - * each single-class half separately. The only paths that genuinely cross classes are non-numeric (weak-array + * we therefore assert the anchor shares the representative of every node; the cross-class conflict builder + * ({@link #computeMergeConflictCycle}) avoids this by rendering each single-class half separately. The only paths + * that genuinely cross classes are non-numeric (weak-array * paths over distinct strong classes), and a non-numeric term can never carry an offset, so the rendering is * trivially correct (offset zero) regardless of frame. */ @@ -405,199 +405,39 @@ public Clause computeCycle(final CCEquality eq, final boolean produceProofs) { } /** - * Build the clause {@code {¬eq, ¬path}} for an offset equality {@code eq} whose two sides are in the same congruence - * class at an offset different from the one {@code eq} claims. The path between the two sides establishes their - * actual (constant) offset, so {@code eq} is implied false. Used both as the conflict clause when such an - * {@code eq} is asserted true and as the reason when {@code ¬eq} is propagated at merge time. The path-edge literals - * are collected exactly as in {@link #computeCycle(CCEquality, boolean)}; only the polarity of {@code eq} differs - * (negated here instead of positive). - */ - public Clause computeAntiCycle(final CCEquality eq, final boolean produceProofs) { - final CCTerm lhs = eq.getLhs(); - final CCTerm rhs = eq.getRhs(); - assert lhs.getRepresentative() == rhs.getRepresentative(); - computePath(lhs, rhs); - drainTodo(); - final Literal[] clause = new Literal[mAllLiterals.size() + 1]; - int i = 0; - clause[i++] = eq.negate(); - for (final Literal l : mAllLiterals) { - clause[i++] = l.negate(); - } - final Clause c = new Clause(clause); - if (produceProofs) { - // The path proves lhs = rhs + deltaPath (the actual union-find offset), but eq claims lhs = rhs + deltaEq - // with deltaEq != deltaPath. We cannot express this as a single congruence: both conflicting values share the - // CCTerm rhs (the offset was factored out), and a SubPath cannot carry the same CCTerm at two offsets. So we - // build the trivially-false equality (rhs + deltaEq) = (rhs + deltaPath) and prove it via the trans path - // [rhs+deltaEq, lhs, ..., rhs+deltaPath]: the first step rhs+deltaEq = lhs is justified by eq, the rest by the - // path. The explicit leading node rhs+deltaEq carries deltaEq, which the path nodes cannot. The diseq is a - // trivial offset disequality (constant difference deltaEq - deltaPath), discharged by an EQ lemma. - final Rational deltaPath = lhs.mOffsetToRep.sub(rhs.mOffsetToRep); - final Rational deltaEq = eq.getOffset(); - final CCParameter rhsAtEq = CCParameter.of(rhs, deltaEq); - final CCParameter rhsAtPath = CCParameter.of(rhs, deltaPath); - final SymmetricPair diseq = new SymmetricPair<>(rhsAtEq, rhsAtPath); - c.setProof(new LeafNode(LeafNode.THEORY_CC, - new CCAnnotation(diseq, mAllPaths, CCAnnotation.RuleKind.CONG, rhsAtEq))); - } - return c; - } - - /** - * Build the explanation clause {@code {diseq, ¬eq, ¬path}} for a disequality {@code ¬eq} that is propagated (or - * conflicts) because {@code eq}'s two sides are in different congruence classes, separated by the - * disequality {@code diseq}. The path runs {@code diseq.lhs … left -[eq]- right … diseq.rhs}, so it spans the two - * classes joined by the {@code eq} edge. - * - *

Unlike the old approach (temporarily insert {@code eq} as an equal-edge into the graph and run - * {@link #computeCycle(CCEquality, boolean)}), we do not mutate the graph: a path crossing the {@code eq} - * edge would have its offsets read from {@code mOffsetToRep}, which is relative to each node's own representative and - * meaningless across the two classes. Instead we compute the two halves as ordinary single-class paths (offset-correct - * on their own) and stitch them by hand, shifting the second half by {@code eq.getOffset()} so the proof object - * carries consistent offsets across the bridge. - */ - public Clause computeAntiCycleDiffClass(final CCEquality eq, final CCEquality diseq, final boolean produceProofs) { - final CCTerm left = eq.getLhs(); - final CCTerm right = eq.getRhs(); - // Orient the separating disequality so dLeft is on left's side and dRight on right's side; dOff is the offset of - // the forbidden equality in that orientation (diseq forbids dLeft == dRight + dOff). Use the orientation captured - // when the reason was set (eq.mDiseqOrientation), not the current representatives: by the time this runs left and - // right may have been merged, after which getRepresentative() can no longer tell the two sides apart. - final CCTerm dLeft, dRight; - final Rational dOff; - if (eq.mDiseqOrientation) { - dLeft = diseq.getLhs(); - dRight = diseq.getRhs(); - dOff = diseq.getOffset(); - } else { - dLeft = diseq.getRhs(); - dRight = diseq.getLhs(); - dOff = diseq.getOffset().negate(); - } - // Two single-class paths, accumulating their reason literals into mAllLiterals and their subpaths into mAllPaths. - computePath(dLeft, left); - computePath(right, dRight); - drainTodo(); - final Literal[] clause = new Literal[mAllLiterals.size() + 2]; - int i = 0; - clause[i++] = diseq; - clause[i++] = eq.negate(); - for (final Literal l : mAllLiterals) { - clause[i++] = l.negate(); - } - final Clause c = new Clause(clause); - if (produceProofs) { - final SubPath segA = dLeft == left ? null : mVisited.get(new SymmetricPair<>(dLeft, left)); - final SubPath segB = right == dRight ? null : mVisited.get(new SymmetricPair<>(right, dRight)); - // paramsA = [dLeft@0, ..., left@offLeft] (anchored and oriented at dLeft). Shift the right half into the left - // half's frame: after the eq edge (left == right + eq.getOffset()), right sits at left's offset plus - // eq.getOffset(); rendering the right half anchored there yields it already shifted, so a plain concat. - final CCParameter[] paramsA = segA != null ? segA.getParams(dLeft) : new CCParameter[] { dLeft }; - final CCParameter rightAnchor = - CCParameter.of(right, paramsA[paramsA.length - 1].getOffset().add(eq.getOffset())); - final CCParameter[] paramsB = - segB != null ? segB.getParams(rightAnchor) : new CCParameter[] { rightAnchor }; - final CCParameter[] mainPath = new CCParameter[paramsA.length + paramsB.length]; - System.arraycopy(paramsA, 0, mainPath, 0, paramsA.length); - System.arraycopy(paramsB, 0, mainPath, paramsA.length, paramsB.length); - assert mainPath[mainPath.length - 1].getOffset().equals(dOff) : "net path offset must match the diseq offset"; - // The remaining subpaths (congruences within either half) keep deriving their offsets the usual way. - final ArrayList otherPaths = new ArrayList<>(); - for (final SubPath p : mAllPaths) { - if (p != segA && p != segB) { - otherPaths.add(p); - } - } - final SymmetricPair diseqParam = new SymmetricPair<>(dLeft, CCParameter.of(dRight, dOff)); - c.setProof(new LeafNode(LeafNode.THEORY_CC, - new CCAnnotation(diseqParam, mainPath, otherPaths, CCAnnotation.RuleKind.CONG))); - } - return c; - } - - /** - * Build the conflict clause for a congruence merge whose two function applications {@code first} and {@code second} - * are already in the same congruence class at an offset different from the zero offset that congruence implies. The - * two terms have equal value by congruence (their arguments are pairwise equal), but the existing path between them - * in their common class establishes a non-zero constant offset {@code existingDiff}; the two facts are - * contradictory. The conflict clause negates the argument equalities (which justify the congruence) together with - * the path literals (which establish the conflicting offset) — no positive literal is needed, the contradiction is - * intrinsic to arithmetic ({@code 0 != existingDiff}). + * The single conflict explainer for an offset cycle: an equality "bridge" {@code lhs — rhsTerm} together with the + * existing congruence-class path(s) implies {@code value(srcEnd) == value(destEnd) + k}, contradicting a disequality. + * The bridge is justified by the merge {@code reason} (a real equality literal) or, for a congruence + * ({@code reason == null}), by the argument equalities; {@code bridgeOff} is the bridge's offset + * ({@code value(lhs) − value(rhsTerm)}, {@code 0} for a congruence). The two endpoints carry the disequality: + *

    + *
  • Disequality clash ({@code diseqLit != null}): a registered disequality {@code diseqLit} forbids exactly + * the offset at which the bridge would unite {@code srcEnd} and {@code destEnd}. The clause carries {@code diseqLit} + * as its positive literal.
  • + *
  • Trivial clash ({@code diseqLit == null}): {@code srcEnd}/{@code destEnd} are either two class shared + * terms whose merged values are provably distinct (an integer shared term forced to a non-integer value, + * {@code to_real a == 1/2}), or — in the same-class case below — the same term at two offsets. The contradiction is + * intrinsic, discharged by an EQ/LA lemma; no positive literal.
  • + *
* - *

For the proof object we mirror {@link #computeAntiCycle}: the leading congruence edge cannot be carried by a - * {@link SubPath} node (a SubPath cannot hold the same CCTerm at two offsets), so we build an explicit main path - * {@code [first@0, second@0, ..., first@existingDiff]} whose leading step {@code first@0 -> second@0} is recognised - * as a congruence (resolved from the argument subpaths in {@code otherPaths}) and whose remaining nodes are the - * existing class path. The two endpoints are the same term {@code first} at offsets {@code 0} and - * {@code existingDiff}, a trivially-false offset disequality discharged by an EQ lemma. - */ - public Clause computeCongruenceAntiCycle(final CCAppTerm first, final CCAppTerm second, final boolean produceProofs) { - assert first.getRepresentative() == second.getRepresentative(); - final Rational existingDiff = second.mOffsetToRep.sub(first.mOffsetToRep); - assert !existingDiff.equals(Rational.ZERO); - assert first.getFunctionSymbol() == second.getFunctionSymbol(); - // The existing path between the two app terms (anchored at second@0 so getParams yields [second@0, ..., - // first@existingDiff]); it establishes the actual (non-zero) offset. - computePath(CCParameter.of(second, Rational.ZERO), CCParameter.of(first, Rational.ZERO)); - // The argument equalities that justify the congruence first == second (offset 0). - computeCongruence(first, second); - drainTodo(); - final Literal[] clause = new Literal[mAllLiterals.size()]; - int i = 0; - for (final Literal l : mAllLiterals) { - clause[i++] = l.negate(); - } - final Clause c = new Clause(clause); - if (produceProofs) { - final SubPath existing = mVisited.get(new SymmetricPair<>(second, first)); - // anchored and oriented at second@0: [second@0, ..., first@existingDiff] - final CCParameter[] existingParams = existing.getParams(CCParameter.of(second, Rational.ZERO)); - // main path: prepend first@0 (the congruence's other end) so the leading step first@0 -> second@0 is a - // congruence edge and the two ends first@0 / first@existingDiff form the trivial diseq. - final CCParameter[] mainPath = new CCParameter[existingParams.length + 1]; - mainPath[0] = CCParameter.of(first, Rational.ZERO); - System.arraycopy(existingParams, 0, mainPath, 1, existingParams.length); - final CCParameter firstAtDiff = mainPath[mainPath.length - 1]; - assert firstAtDiff.getCCTerm() == first && firstAtDiff.getOffset().equals(existingDiff); - // The remaining subpaths (the congruence's argument paths plus any congruences along the existing path) keep - // deriving their offsets the usual way; only the existing main path is inlined into mainPath. - final ArrayList otherPaths = new ArrayList<>(); - for (final SubPath p : mAllPaths) { - if (p != existing) { - otherPaths.add(p); - } - } - final SymmetricPair diseqParam = new SymmetricPair<>(mainPath[0], firstAtDiff); - c.setProof(new LeafNode(LeafNode.THEORY_CC, - new CCAnnotation(diseqParam, mainPath, otherPaths, CCAnnotation.RuleKind.CONG))); - } - return c; - } - - /** - * Build the conflict clause for a clash discovered during a merge of two classes that are joined only by the - * freshly added equal edge {@code lhs — rhsTerm} (the union-find is not yet updated, so {@code mOffsetToRep} is still - * relative to each node's own representative). The conflicting fact lives between an endpoint {@code srcEnd} - * in the source class (reachable from {@code lhs}) and an endpoint {@code destEnd} in the destination class (reachable - * from {@code rhsTerm}). Two cases: + *

This covers both the cross-class and same-class shapes uniformly: *

    - *
  • Shared-term clash ({@code diseqLit == null}): {@code srcEnd}/{@code destEnd} are the two classes' shared - * terms, whose merged values are provably distinct (e.g. an integer shared term forced to a non-integer value, - * {@code to_real a == 1/2}). The contradiction is intrinsic, discharged by an EQ/LA lemma; no positive literal.
  • - *
  • Disequality clash ({@code diseqLit != null}): a registered disequality {@code diseqLit} between the two - * classes forbids exactly the offset at which the merge would unite {@code srcEnd} and {@code destEnd}. The clause - * carries {@code diseqLit} as its positive literal.
  • + *
  • Cross-class ({@code srcEnd != destEnd}): the conflict is found during a merge of two classes joined only + * by the freshly added bridge {@code lhs — rhsTerm} (the union-find is not yet updated). {@code srcEnd} is in the + * source class (reachable from {@code lhs}), {@code destEnd} in the destination class (reachable from + * {@code rhsTerm}).
  • + *
  • Same-class ({@code srcEnd == destEnd}, both equal to {@code lhs}): the bridge {@code lhs — rhsTerm} + * closes a cycle within one class against the existing path from {@code rhsTerm} back to {@code lhs}. The source half + * is empty and the trivial diseq is {@code (lhs@0, lhs@(bridgeOff − pathOffset))}. This is the offset anti-cycle + * ({@code x != x + 5}) and the congruence-merge offset conflict ({@code f(x) = f(y) + k}).
  • *
* - *

As in {@link #computeAntiCycleDiffClass}, a single {@code computePath} across the bridge would read offsets from - * two different reference frames and produce a garbage proof object. Instead we compute the two halves as ordinary - * single-class paths ({@code srcEnd … lhs} and {@code rhsTerm … destEnd}, each offset-correct) and stitch them by - * hand, shifting the destination half by {@code bridgeOff} (= {@code value(lhs) − value(rhsTerm)} implied by the - * merge reason; {@code 0} for a congruence). The bridge edge itself is justified either by the merge {@code reason} - * (a real equality literal) or, for a congruence merge ({@code reason == null}), by the argument equalities — exactly - * as in {@link #computeCongruenceAntiCycle}, where the bridge step {@code lhs → rhsTerm} is auto-resolved from the - * argument subpaths. + *

A single {@code computePath} across the bridge would read offsets from two different reference frames (across the + * not-yet-united classes) and produce a garbage proof object. Instead we compute the two halves as ordinary + * single-class paths ({@code srcEnd … lhs} and {@code rhsTerm … destEnd}, each offset-correct) and stitch them, the + * destination half rendered with anchor {@code rhsTerm@(offLhs + bridgeOff)} so it is already in the source frame and + * the main path is a plain concatenation. The bridge step {@code lhs → rhsTerm} is discharged by {@code reason} (or, + * for a congruence, auto-resolved from the argument subpaths the proof generator finds in the other paths). */ public Clause computeMergeConflictCycle(final CCTerm srcEnd, final CCTerm destEnd, final CCTerm lhs, final CCTerm rhsTerm, final CCEquality reason, final Rational bridgeOff, final CCEquality diseqLit, From 14b49bb711a6b4aa7f5e48b98e27b45e30e0b4a4 Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Wed, 24 Jun 2026 22:08:36 +0200 Subject: [PATCH 49/75] CC offset equalities: report merge conflicts before mutating the graph The conflict explainers build each half within its own class and never walk the merge bridge, so a merge conflict no longer needs the equal edge. CCTerm.mergeInternal now detects the conflict (a disequality forbidding the merge, or a shared-term clash) and returns it before invertEqualEdges / mEqualEdge / mOldRep / mReasonLiteral are set, dropping the old add-the-edge-then-undo-it dance. The conflict path now mutates nothing and the diseq orientation reads a pristine union-find. Guard fix: a CCTermPairHash.Info also holds equality literals and compare triggers, so diseqInfo.mDiseq can be null when diseqInfo != null. Only a non-null mDiseq is a merge conflict (else diseq.getLhs() NPEs); fall through to the shared-term check / normal merge otherwise. test/proof 97/98, abv 4/4, bv 35/35, uflira 5/5, lia 32/32, datatype non-quantified clean (only the documented pre-existing failures); BitvectorTest 89/89, ProofSimplifierTest 14/14, ProofUtilsTest 4/4, CongruentAddTest 5/5, PairHashTest 1/1. Co-Authored-By: Claude Opus 4.8 (1M context) --- SMTInterpol/doc/offset-equality-plan.md | 15 ++++++ .../smtinterpol/theory/cclosure/CCTerm.java | 50 ++++++++----------- 2 files changed, 35 insertions(+), 30 deletions(-) diff --git a/SMTInterpol/doc/offset-equality-plan.md b/SMTInterpol/doc/offset-equality-plan.md index 176e5fbc1..826080830 100644 --- a/SMTInterpol/doc/offset-equality-plan.md +++ b/SMTInterpol/doc/offset-equality-plan.md @@ -1010,6 +1010,21 @@ clean — only the documented pre-existing failures (`trivialdiseqarray`, `Scrip `ProofSimplifierTest` 14/14, `ProofUtilsTest` 4/4, `CongruentAddTest` 5/5, `PairHashTest` 1/1. Files: `CongruencePath.java`, `CClosure.java`, `CCAnnotation.java`. +## Merge conflicts reported before mutating the graph — DONE (uncommitted) + +Since the conflict explainers build each half within its own class and never walk the +merge bridge, `CCTerm.mergeInternal` no longer needs the equal edge for a conflict. It now +detects the conflict (disequality forbidding the merge, or shared-term clash) and returns it +before `invertEqualEdges`/`mEqualEdge`/`mOldRep`/`mReasonLiteral` are set — dropping +the old add-the-edge-then-undo-it dance, so the conflict path mutates nothing. The diseq +orientation (`diseq.getLhs().mRepStar == src`) now reads a pristine union-find. **Bug fixed +while reviewing:** the first cut treated any non-null `diseqInfo` as a disequality conflict, +but a `CCTermPairHash.Info` also holds equality literals / compare triggers, so +`diseqInfo.mDiseq` can be null — the guard must be `diseqInfo != null && diseqInfo.mDiseq != +null` (otherwise `diseq.getLhs()` NPEs on essentially every benchmark). Validated: test/proof +97/98, abv 4/4, bv 35/35, uflira/lia/datatype-nonquant clean (only the pre-existing failures); +unit suites green. File: `CCTerm.java`. + ## Implementable slice (ready to start) 1. `LASharedTerm` becomes offset-free under `createOffsetEqualities()` (revert the diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCTerm.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCTerm.java index e1590896a..2d8fad765 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCTerm.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCTerm.java @@ -448,15 +448,23 @@ private Clause mergeInternal(final CClosure engine, final CCTerm lhs, final CCEq */ final Rational delta = reasonDiff(reason, lhs, this).sub(lhs.mOffsetToRep).add(mOffsetToRep); - // Need to prevent MBTC when we get a conflict. Hence a two-way pass. A disequality conflicts with the merge only - // if it forbids exactly the offset delta at which the two classes are being merged. - CCEquality diseq = null; + // Detect a conflict (a disequality forbidding the merge, or a shared-term clash) before mutating anything and + // return it immediately. The conflict explainers build each half within its own class and never walk the merge + // bridge, so they do not need the equal edge — returning here (rather than merging, then undoing the edge on a + // conflict) keeps the union-find and MBTC from ever seeing the half-merged conflicting state. A disequality + // conflicts only if it forbids exactly the offset delta at which the two classes are being merged. final CCTermPairHash.Info diseqInfo = engine.mPairHash.getInfo(src, dest, delta); - if (diseqInfo != null) { - diseq = diseqInfo.mDiseq; - } - boolean sharedTermConflict = false; - if (diseq == null && src.mSharedTerm != null) { + if (diseqInfo != null && diseqInfo.mDiseq != null) { + final CCEquality diseq = diseqInfo.mDiseq; + // A disequality forbids this merge. Its two sides straddle the freshly added (not-yet-united) bridge, so + // orient them into the source/destination class and build the two halves separately (computeCycle would + // walk a single path across the bridge and mix offset frames). mRepStar is still the pre-merge rep here. + final CCTerm srcEnd = diseq.getLhs().mRepStar == src ? diseq.getLhs() : diseq.getRhs(); + final CCTerm destEnd = diseq.getLhs().mRepStar == src ? diseq.getRhs() : diseq.getLhs(); + return engine.computeMergeDiseqCycle(srcEnd, destEnd, lhs, this, reason, + reasonDiff(reason, lhs, this), diseq); + } + if (src.mSharedTerm != null) { if (dest.mSharedTerm == null) { dest.mSharedTerm = src.mSharedTerm; } else { @@ -468,7 +476,10 @@ private Clause mergeInternal(final CClosure engine, final CCTerm lhs, final CCEq final CCEquality cceq = engine.createEquality(src.mSharedTerm, dest.mSharedTerm, sharedOffset, createInLA); /* If cceq cannot be created this is a conflict like merging x+1 and x */ - sharedTermConflict = (cceq == null); + if (cceq == null) { + return engine.computeSharedConflictCycle(src.mSharedTerm, dest.mSharedTerm, lhs, this, reason, + reasonDiff(reason, lhs, this)); + } /* * No need to remember the created equality. It was inserted and will be found later and propagated * automatically. @@ -483,27 +494,6 @@ private Clause mergeInternal(final CClosure engine, final CCTerm lhs, final CCEq lhs.mOldRep = src; src.mReasonLiteral = reason; - /* Check for conflict */ - if (sharedTermConflict || diseq != null) { - final Clause conflict; - if (sharedTermConflict) { - conflict = engine.computeSharedConflictCycle(src.mSharedTerm, dest.mSharedTerm, lhs, this, reason, - reasonDiff(reason, lhs, this)); - } else { - // A disequality forbids this merge. Its two sides straddle the freshly added (not-yet-united) bridge, so - // orient them into the source/destination class and build the two halves separately (computeCycle would - // walk a single path across the bridge and mix offset frames). mRepStar is still the pre-merge rep here. - final CCTerm srcEnd = diseq.getLhs().mRepStar == src ? diseq.getLhs() : diseq.getRhs(); - final CCTerm destEnd = diseq.getLhs().mRepStar == src ? diseq.getRhs() : diseq.getLhs(); - conflict = engine.computeMergeDiseqCycle(srcEnd, destEnd, lhs, this, reason, - reasonDiff(reason, lhs, this), diseq); - } - lhs.mEqualEdge = null; - lhs.mOldRep = null; - src.mReasonLiteral = null; - return conflict; - } - long time; src.mMergeTime = engine.getMergeDepth(); From af957637bff5aa2e5ac07dbe193452418affc730 Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Thu, 25 Jun 2026 11:56:26 +0200 Subject: [PATCH 50/75] CC offset equalities: persistent drainTodo dedup + inline grafts via computePathNonRecursive mAllPaths was overloaded as both the topologically-ordered annotation output and a return channel (WeakCongruencePath read mAllPaths.removeFirst() to grab the strong path it had just built so it could inline it into a weak path). That coupling caused two problems: drainTodo's dedup set was per-call, so a subpath shared across drains was re-appended (duplicate); and the removeFirst graft relied on mAllPaths's front being the just-built path, which only held because the broken per-call dedup kept re-adding already-built paths. - Promote drainTodo's local `added` set to the field mCollected, so each subpath enters mAllPaths at most once across all drains of a lemma. - Inline grafts (collectPathOnePrimary, collectPathPrimary) call computePathNonRecursive directly: it returns the SubPath without adding it to mAllPaths and enqueues its congruence dependencies for the enclosing drain. Removes the removeFirst call and its ordering assumption. - computeMergeConflictCycle builds its two halves the same way, dropping the mVisited lookup and the filter-out-of-mAllPaths loop; mAllPaths is now exactly the other-paths list and is passed straight to CCAnnotation. - computeConstOverWeakEQ gains the explicit drainTodo() the removed internal graft-drains used to provide. Validated (-ea): test/proof 97/98 (only pre-existing trivialdiseqarray); array + interpolation proof-check pass/fail set identical to baseline; uflira/abv/lia clean; BitvectorTest/ProofSimplifierTest/ProofUtilsTest/RPITest/CongruentAddTest green (117 tests). Co-Authored-By: Claude Opus 4.8 (1M context) --- SMTInterpol/doc/offset-equality-plan.md | 64 +++++++++++++++++-- .../theory/cclosure/CongruencePath.java | 61 +++++++++--------- .../theory/cclosure/WeakCongruencePath.java | 30 +++++---- 3 files changed, 106 insertions(+), 49 deletions(-) diff --git a/SMTInterpol/doc/offset-equality-plan.md b/SMTInterpol/doc/offset-equality-plan.md index 826080830..ad4e5b173 100644 --- a/SMTInterpol/doc/offset-equality-plan.md +++ b/SMTInterpol/doc/offset-equality-plan.md @@ -891,11 +891,10 @@ hybrid covers both bridge kinds uniformly. `compute*Cycle`/`computeDTLemma`/`computeDecideLevel` calls the extracted `drainTodo()` **once** after all `computePath`/`computeCongruence` calls are queued, so a single shared drain builds them (and dedups subpaths shared between several - queued ends). `WeakCongruencePath` consumes single paths synchronously - (`computePath(...); mAllPaths.removeFirst()`), so it drains per call — `drainTodo()` - is `protected` and called explicitly at each of its six `computePath` sites - (reproducing the old per-call drain; without this the array proofs in - `test/proof/auxaxioms/*` throw `NoSuchElementException`). + queued ends). `WeakCongruencePath` drains once per weak/main path; the strong paths + it inlines into a weak path are built via `computePathNonRecursive` (see the + *drainTodo / mAllPaths cleanup* section below — the original `computePath(...); + mAllPaths.removeFirst()` per-call-drain idiom is superseded). 3. **`computeAntiCycleDiffClass` gets the same pre-shift simplification** as `computeSharedConflictCycle`: its right half is anchored at `right@shift` (`shift = dLeft.mOffsetToRep − left.mOffsetToRep + eq.getOffset()`), so `getParams()` @@ -1025,6 +1024,61 @@ null` (otherwise `diseq.getLhs()` NPEs on essentially every benchmark). Validate 97/98, abv 4/4, bv 35/35, uflira/lia/datatype-nonquant clean (only the pre-existing failures); unit suites green. File: `CCTerm.java`. +## `drainTodo` / `mAllPaths` cleanup — DONE (uncommitted) + +`mAllPaths` was overloaded: it is the final, topologically-ordered annotation output +(the producer must emit paths so that *later paths explain congruences on earlier* — see +`CCAnnotation.mParamPaths` and `CCProofGenerator.collectStrongEqualities`, which walks the +array backwards), **and** it was abused as a return channel — `WeakCongruencePath` read +`mAllPaths.removeFirst()` to retrieve the strong path it had just asked `computePath` to build +so it could inline it (`addSubPath`) into a weak path. That coupling caused two problems: + +- **Per-call dedup set.** `drainTodo`'s `added` set was local, while `mVisited` is a field. A + subpath built in one drain and re-enqueued in a later drain (`computeWeakeqExt` loops over + store indices; `computeCongruence` re-enqueues argument pairs unconditionally) was found in + `mVisited` but missing from the fresh `added` set → **re-appended to `mAllPaths` (duplicate)**. +- **`removeFirst` ordering reliance.** The graft assumed `mAllPaths`'s front was exactly the + path for the last `computePath` — fragile, since the shared drain also drains other pending + pairs (the index/select equalities), and it only stayed correct *because* the broken per-call + dedup kept re-adding already-built paths. + +**Fix (three changes):** +1. **Persistent dedup.** `drainTodo`'s local `added` set is promoted to the field `mCollected`, + so a subpath enters `mAllPaths` at most once across all drains of a lemma. (Per-conflict + instances are fresh from the constructor, so no clearing is needed.) +2. **Inline via `computePathNonRecursive`.** The two graft sites in `WeakCongruencePath` + (`collectPathOnePrimary`, `collectPathPrimary`) call `computePathNonRecursive(left, right)` + directly and `addSubPath` the returned `SubPath`. `computePathNonRecursive` returns the path + (cached or freshly built), enqueues its congruence dependencies on `mTodo`, and — crucially — + **never adds it to `mAllPaths`** (correct: an inlined path is not a standalone annotation + path). The dependencies are collected by the enclosing lemma's drain. This removes both the + `removeFirst` call and its ordering assumption; you get the right `SubPath` by return value + regardless of what else is pending in `mTodo`. Strong paths that *are* standalone annotation + subpaths (the index/select equalities in `computeWeakCongruencePath`) keep using `computePath`. +3. **Explicit drain in `computeConstOverWeakEQ`.** It was the one lemma method with no + `drainTodo()` of its own — it worked only because `collectPathPrimary` drained internally. + With the internal graft-drains gone, it now calls `drainTodo()` before `mAllPaths.addFirst(path)` + (the drain must precede the prepend so the main path lands ahead of its dependencies). The + other lemma methods already drain at the right spot before each manual `addFirst`. +4. **`computeMergeConflictCycle` builds its two halves the same way.** It used to + `computePath(srcEnd, lhs)` / `computePath(rhsTerm, destEnd)`, drain, then re-fetch the halves via + `mVisited.get(SymmetricPair(...))` and filter them back out of `mAllPaths` + (`for (p : mAllPaths) if (p != segSrc && p != segDest)`). Now it calls + `computePathNonRecursive` for each half directly: the `SubPath`s come back by return value (no + `mVisited` lookup), they never enter `mAllPaths` (no filter), and only their congruence + dependencies plus the bridge's argument subpaths are drained — so `mAllPaths` *is* the + other-paths list and is passed straight to the `CCAnnotation` constructor (which only iterates + it, so no copy). The two halves must be built before the drain regardless of `produceProofs`, + since that is where their literals reach `mAllLiterals` for the clause. + +Validated (clean build, `-ea`): `test/proof` **97/98** (only the pre-existing +`trivialdiseqarray`, identical to baseline); `array/` + interpolation `.smt2` under +`proof-check-mode` have an **identical pass/fail set** to a reverted baseline (the +`constarr00{4,5,11,12,14}` interpolation failures are pre-existing in the deferred +offset-interpolation track); `BitvectorTest`, `ProofSimplifierTest`, `ProofUtilsTest`, +`RPITest`, `CongruentAddTest` green (117 tests); `abv`/`uflira`/`lia` sweeps clean. +Files: `CongruencePath.java`, `WeakCongruencePath.java`. + ## Implementable slice (ready to start) 1. `LASharedTerm` becomes offset-free under `createOffsetEqualities()` (revert the diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java index d611081aa..6a96b73cd 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java @@ -150,20 +150,23 @@ public String toString() { */ final HashMap,SubPath> mVisited; final ArrayDeque mAllPaths; - final ArrayDeque> mTodo; + final ArrayDeque> mTodo; + /** + * The subpaths already appended to {@link #mAllPaths}. Persistent across {@link #drainTodo} calls (unlike a + * per-drain set) so a subpath shared between several drains is collected exactly once. {@link WeakCongruencePath} + * drains once per weak/main path, and {@link #computeCongruence} re-enqueues argument pairs unconditionally, so the + * same dependency can resurface in a later drain; this set keeps it from being added twice. + */ + final Set> mCollected; final Set mAllLiterals; - /** The offset-free end terms of a parameter pair, used as the {@link #mVisited} key. */ - private static SymmetricPair offsetFreeKey(final SymmetricPair ends) { - return new SymmetricPair<>(ends.getFirst().getCCTerm(), ends.getSecond().getCCTerm()); - } - public CongruencePath(final CClosure closure) { mClosure = closure; mVisited = new HashMap<>(); mAllLiterals = new LinkedHashSet<>(); mTodo = new ArrayDeque<>(); mAllPaths = new ArrayDeque<>(); + mCollected = new HashSet<>(); } private CCAnnotation createAnnotation(final SymmetricPair diseq) { @@ -204,7 +207,7 @@ private void computeCongruence(CCAppTerm start, CCAppTerm end) { // argument's offset, e.g. f(x+2) congruent f(z+8) yields a subpath from x+2 to z+8. This only enqueues the // argument pairs; the surrounding drain loop ({@link #drainTodo}, run by {@link #computePath}) builds them. for (int i = 0; i < start.getArgCount(); i++) { - mTodo.addFirst(new SymmetricPair<>(start.getArgParam(i), end.getArgParam(i))); + mTodo.addFirst(new SymmetricPair<>(start.getArgParam(i).getCCTerm(), end.getArgParam(i).getCCTerm())); } } @@ -348,20 +351,21 @@ public void computePath(final CCParameter left, final CCParameter right) { // Only enqueue the path. The caller (a top-level compute*Cycle/Lemma method) calls drainTodo() once after all // computePath/computeCongruence calls are queued, so a single shared drain builds them all (and dedups subpaths // shared between several queued ends). - mTodo.add(new SymmetricPair<>(left, right)); + mTodo.add(new SymmetricPair<>(left.getCCTerm(), right.getCCTerm())); } /** * Process the work list {@link #mTodo} of (sub)paths to compute, building each one and collecting it into * {@link #mAllPaths} (and its literals into {@link #mAllLiterals}). The top-level compute*Cycle/Lemma methods seed * the work list via {@link #computePath} (a single pair) and {@link #computeCongruence} (argument pairs), then call - * this once. {@link WeakCongruencePath} consumes single paths synchronously and drains after each - * {@link #computePath}, hence protected. + * this once. {@link WeakCongruencePath} drains once per weak/main path (a strong path to be inlined into a weak path + * is instead built directly via {@link #computePathNonRecursive}, which returns it without adding it to + * {@link #mAllPaths}), hence protected. Dedup against {@link #mCollected} is persistent, so a subpath shared between + * drains is appended only once. */ protected void drainTodo() { - final HashSet> added = new HashSet<>(); while (!mTodo.isEmpty()) { - final SymmetricPair pathEnds = mTodo.removeFirst(); + final SymmetricPair pathEnds = mTodo.removeFirst(); // don't do anything for trivial paths if (pathEnds.getFirst().getCCTerm() == pathEnds.getSecond().getCCTerm()) { @@ -369,14 +373,14 @@ protected void drainTodo() { } // check if we already visited this path (keyed offset-free, so offset variants share one subpath) - final SubPath path = mVisited.get(offsetFreeKey(pathEnds)); + final SubPath path = mVisited.get(pathEnds); if (path == null) { // if we did not visit it yet, enqueue again for later and visit the path mTodo.addFirst(pathEnds); computePathNonRecursive(pathEnds.getFirst(), pathEnds.getSecond()); } else { // already visited it, so we just add the path now unless we did this earlier - if (added.add(offsetFreeKey(pathEnds))) { + if (mCollected.add(pathEnds)) { mAllPaths.addFirst(path); } } @@ -442,9 +446,13 @@ public Clause computeCycle(final CCEquality eq, final boolean produceProofs) { public Clause computeMergeConflictCycle(final CCTerm srcEnd, final CCTerm destEnd, final CCTerm lhs, final CCTerm rhsTerm, final CCEquality reason, final Rational bridgeOff, final CCEquality diseqLit, final boolean produceProofs) { - // Two single-class paths, each offset-correct on its own. - computePath(srcEnd, lhs); - computePath(rhsTerm, destEnd); + // Two single-class paths, each offset-correct on its own. Build them directly (not via the work list) so they + // are NOT collected into mAllPaths: they are stitched by hand into the explicit main path below, while only their + // congruence dependencies (enqueued here, drained next) belong in mAllPaths as the other paths. Each is null for + // a trivial half (srcEnd == lhs / rhsTerm == destEnd). The literal collection into mAllLiterals happens here + // regardless of produceProofs, so this must run for both the clause and the proof. + final SubPath segSrc = computePathNonRecursive(srcEnd, lhs); + final SubPath segDest = computePathNonRecursive(rhsTerm, destEnd); // Justify the bridge edge lhs — rhsTerm. if (reason != null) { mAllLiterals.add(reason); @@ -465,13 +473,12 @@ public Clause computeMergeConflictCycle(final CCTerm srcEnd, final CCTerm destEn } final Clause c = new Clause(clause); if (produceProofs) { - final SubPath segSrc = srcEnd == lhs ? null : mVisited.get(new SymmetricPair<>(srcEnd, lhs)); - final SubPath segDest = rhsTerm == destEnd ? null : mVisited.get(new SymmetricPair<>(rhsTerm, destEnd)); // paramsSrc = [srcEnd@0, ..., lhs@offLhs] (anchored and oriented at srcEnd). Shift the destination half into // the source half's frame: after the bridge edge (value(lhs) == value(rhsTerm) + bridgeOff), rhsTerm sits at // lhs's offset plus bridgeOff; rendering the dest half anchored there yields it already shifted, so the main // path is a plain concatenation. final CCParameter[] paramsSrc = segSrc != null ? segSrc.getParams(srcEnd) : new CCParameter[] { srcEnd }; + assert paramsSrc[paramsSrc.length - 1].getCCTerm() == lhs : "src path must end at lhs + offset"; final CCParameter destAnchor = CCParameter.of(rhsTerm, paramsSrc[paramsSrc.length - 1].getOffset().add(bridgeOff)); final CCParameter[] paramsDest = @@ -479,21 +486,16 @@ public Clause computeMergeConflictCycle(final CCTerm srcEnd, final CCTerm destEn final CCParameter[] mainPath = new CCParameter[paramsSrc.length + paramsDest.length]; System.arraycopy(paramsSrc, 0, mainPath, 0, paramsSrc.length); System.arraycopy(paramsDest, 0, mainPath, paramsSrc.length, paramsDest.length); - // The remaining subpaths (congruences within either half, plus the bridge's argument subpaths) keep deriving - // their offsets the usual way. assert mainPath[0].getCCTerm() == srcEnd : "main path must start at srcEnd"; assert mainPath[mainPath.length - 1].getCCTerm() == destEnd : "main path must end at destEnd"; - final ArrayList otherPaths = new ArrayList<>(); - for (final SubPath p : mAllPaths) { - if (p != segSrc && p != segDest) { - otherPaths.add(p); - } - } + // The remaining subpaths (congruences within either half, plus the bridge's argument subpaths) keep deriving + // their offsets the usual way. segSrc/segDest were built off the work list and never entered mAllPaths, so + // mAllPaths is exactly the other paths (the constructor only iterates it, so no copy is needed). // The clashing equality: a concrete disequality discharged against diseqLit, or (shared case) the trivially // distinct shared values discharged by an EQ/LA lemma. final SymmetricPair diseq = new SymmetricPair<>(mainPath[0], mainPath[mainPath.length - 1]); c.setProof(new LeafNode(LeafNode.THEORY_CC, - new CCAnnotation(diseq, mainPath, otherPaths, CCAnnotation.RuleKind.CONG))); + new CCAnnotation(diseq, mainPath, mAllPaths, CCAnnotation.RuleKind.CONG))); } return c; } @@ -515,8 +517,7 @@ public Clause computeDTLemma(final CCEquality propagatedEq, final DataTypeLemma } final Clause c = new Clause(negLits); if (produceProofs) { - // the main equality carries the offset of a numeric constructor field; CCAnnotation keeps both the - // CCParameter view (with offset) and the offset-free CCTerm view used by the current proof generator. + // the main equality carries the offset of a numeric constructor field. c.setProof(new LeafNode(LeafNode.THEORY_DT, new CCAnnotation(lemma.getMainEquality(), mAllPaths, lemma))); } return c; diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/WeakCongruencePath.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/WeakCongruencePath.java index 12234fb02..ade0366b7 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/WeakCongruencePath.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/WeakCongruencePath.java @@ -91,9 +91,9 @@ public Clause computeSelectOverWeakEQ(final CCAppTerm select1, final CCAppTerm s final CCTerm a = ArrayTheory.getArrayFromSelect(select1); final CCTerm b = ArrayTheory.getArrayFromSelect(select2); computePath(i1.getCCTerm(), i2.getCCTerm()); - drainTodo(); final WeakSubPath weakPath = computeWeakPath(a, b, i1, produceProofs); + drainTodo(); mAllPaths.addFirst(weakPath); return generateClause(new SymmetricPair(select1, select2), produceProofs, @@ -106,6 +106,7 @@ public Clause computeSelectConstOverWeakEQ(final CCAppTerm select1, final CCAppT final CCParameter i1 = ArrayTheory.getIndexFromSelect(select1); final CCTerm a = ArrayTheory.getArrayFromSelect(select1); final WeakSubPath weakPath = computeWeakPath(a, const2, i1, produceProofs); + drainTodo(); mAllPaths.addFirst(weakPath); return generateClause(new SymmetricPair<>(select1, value2), produceProofs, RuleKind.READ_CONST_WEAKEQ); } @@ -118,6 +119,9 @@ public Clause computeConstOverWeakEQ(final CCAppTerm const1, final CCAppTerm con final Cursor start = new Cursor(const1, mArrayTheory.mCongRoots.get(const1.getRepresentative())); final Cursor dest = new Cursor(const2, mArrayTheory.mCongRoots.get(const2.getRepresentative())); final SubPath path = collectPathPrimary(start, dest, storeIndices, produceProofs); + // Drain the strong-path dependencies collectPathPrimary enqueued before prepending the main path, so the main + // path lands ahead of them in mAllPaths (the topological order the proof generator expects). + drainTodo(); mAllPaths.addFirst(path); return generateClause(new SymmetricPair<>(value1, value2), produceProofs, RuleKind.CONST_WEAKEQ); } @@ -143,8 +147,10 @@ public Clause computeWeakeqExt(final CCTerm a, final CCTerm b, final boolean pro final SubPath path = collectPathPrimary(start, dest, storeIndices, produceProofs); for (final CCParameter idx : storeIndices) { final WeakSubPath weakpath = computeWeakCongruencePath(a, b, idx, produceProofs); + drainTodo(); mAllPaths.addFirst(weakpath); } + drainTodo(); mAllPaths.addFirst(path); return generateClause(new SymmetricPair<>(a, b), produceProofs, RuleKind.WEAKEQ_EXT); } @@ -230,8 +236,7 @@ private WeakSubPath computeWeakCongruencePath(final CCTerm array1, final CCTerm // get select for left-hand-side final CCAppTerm select = rep1.mSelects.get(indexRep); final CCTerm selectArray = ArrayTheory.getArrayFromSelect(select); - computePath(index.getCCTerm(), ArrayTheory.getIndexFromSelect(select)); - drainTodo(); + computePath(index, ArrayTheory.getIndexFromSelect(select)); path = computeWeakPath(array1, selectArray, index, produceProofs); select1 = select; } @@ -245,8 +250,7 @@ private WeakSubPath computeWeakCongruencePath(final CCTerm array1, final CCTerm // get select for right-hand-side final CCAppTerm select = rep2.mSelects.get(indexRep); final CCTerm selectArray = ArrayTheory.getArrayFromSelect(select); - computePath(index.getCCTerm(), ArrayTheory.getIndexFromSelect(select)); - drainTodo(); + computePath(index, ArrayTheory.getIndexFromSelect(select)); path.addEntry(selectArray, null); path.addSubPath(computeWeakPath(selectArray, array2, index, produceProofs)); select2 = select; @@ -256,7 +260,6 @@ private WeakSubPath computeWeakCongruencePath(final CCTerm array1, final CCTerm // check for trivial select edge (select-const). if (select1 != select2) { computePath(select1, select2); - drainTodo(); } return path; } @@ -274,10 +277,10 @@ public void collectPathOnePrimary(final Cursor cursor, final SubPath path, final assert t1.mRepStar == node.mTerm; assert t2.mRepStar == node.mPrimaryEdge.mTerm; if (cursor.mTerm != t1) { - computePath(cursor.mTerm, t1); - drainTodo(); - final SubPath subpath = mAllPaths.removeFirst(); - path.addSubPath(subpath); + // Build the strong path directly and inline it into the weak path. computePathNonRecursive returns the + // SubPath (without adding it to mAllPaths, since it is inlined here, not a standalone subpath) and enqueues + // its congruence dependencies for the enclosing lemma's drainTodo to collect. + path.addSubPath(computePathNonRecursive(cursor.mTerm, t1)); } path.addEntry(t2, null); cursor.update(t2, node.mPrimaryEdge); @@ -312,10 +315,9 @@ private SubPath collectPathPrimary(final Cursor start, final Cursor dest, final collectPathOnePrimary(dest, path2, storeIndices); } if (start.mTerm != dest.mTerm) { - computePath(start.mTerm, dest.mTerm); - drainTodo(); - final SubPath subpath = mAllPaths.removeFirst(); - path1.addSubPath(subpath); + // Build the strong path directly and inline it (see collectPathOnePrimary): computePathNonRecursive returns + // the SubPath without adding it to mAllPaths and enqueues its dependencies for the enclosing drainTodo. + path1.addSubPath(computePathNonRecursive(start.mTerm, dest.mTerm)); } path1.addSubPath(path2); return path1; From e19c935bce8850ff2c98c9a8d9d40d8f94e04465 Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Thu, 25 Jun 2026 12:48:07 +0200 Subject: [PATCH 51/75] CC offset equalities: offset-aware array weak-eq propagation (fix trivialdiseqarray) trivialdiseqarray (a = (store (const ((select a j)+1)) i 1), i != j) returned sat for an unsat problem. With i != j, a and the const array agree at j, so select(a,j) = (select a j)+1, i.e. x = x+1, a same-class/different-offset conflict. Two array-side spots dropped the offset and suppressed it: - ArrayTheory: four weak-eq propagation guards (READ_OVER_WEAKEQ / READ_CONST_WEAKEQ, primary-merge and secondary-edge paths) skipped propagation on getRepresentative() equality. Two terms in the same class at different offsets are not already equal -- that is the conflict -- so the lemma was thrown away. Use the offset-aware !select.sameValueAs(other) (matching the existing CONST_WEAKEQ guard). With offsets off sameValueAs reduces to a representative comparison, so classic behaviour is unchanged. - WeakCongruencePath.generateClause built the lemma equality from bare CCTerms (getCCTerm()), dropping the offset into the degenerate select(a,j)=select(a,j) (which trips the createEquality assertion once the guard fires). Pass the CCParameters to the offset-aware overload; it yields the false proxy (eq == null, already handled) so the lemma becomes the conflict clause. trivialdiseqarray is now unsat with a proof-checking low-level proof. Validated (-ea): test/proof 98/98 (was 97/98, no regressions); array sweep (93 benchmarks, proof-check) 0 status mismatches, only pre-existing errors; 117 JUnit tests green (Bitvector/ProofSimplifier/ProofUtils/RPI/CongruentAdd). Co-Authored-By: Claude Opus 4.8 (1M context) --- SMTInterpol/doc/offset-equality-plan.md | 40 +++++++++++++++++++ .../theory/cclosure/ArrayTheory.java | 12 ++++-- .../theory/cclosure/WeakCongruencePath.java | 7 +++- 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/SMTInterpol/doc/offset-equality-plan.md b/SMTInterpol/doc/offset-equality-plan.md index ad4e5b173..2489ed720 100644 --- a/SMTInterpol/doc/offset-equality-plan.md +++ b/SMTInterpol/doc/offset-equality-plan.md @@ -1079,6 +1079,46 @@ offset-interpolation track); `BitvectorTest`, `ProofSimplifierTest`, `ProofUtils `RPITest`, `CongruentAddTest` green (117 tests); `abv`/`uflira`/`lia` sweeps clean. Files: `CongruencePath.java`, `WeakCongruencePath.java`. +## Offset-aware array weak-eq propagation (the `trivialdiseqarray` blocker) — DONE (uncommitted) + +`trivialdiseqarray` (`a = (store (const ((select a j) + 1)) i 1)`, `i != j`) returned **sat** +for an unsat problem — the lone remaining `test/proof` failure. The unsat reasoning is: with +`i != j`, `a` and the const array agree at `j`, so `select(a, j) = const value = (select a j) + +1`, i.e. `x = x + 1`, a (same-class, different-offset) offset conflict. Two array-side spots +dropped the offset and suppressed it (an instance of gap 1, the `ArrayTheory` part): + +1. **Offset-blind propagation guards** (`ArrayTheory`, four sites: `READ_OVER_WEAKEQ` / + `READ_CONST_WEAKEQ` in both the primary-merge `mergeWith` path and the secondary-edge path). + They skipped propagation with `select.getRepresentative() != other.getRepresentative()`. Two + selects (or a select and a const value) in the *same* class but at *different* offsets are + **not** already equal — that is exactly the conflict — so the guard threw the conflicting + lemma away. Fixed to the offset-aware `!select.sameValueAs(other)` + (`(getRepresentative(), getOffsetToRep())` identity). The sibling `CONST_WEAKEQ` guard already + compared offsets; these four now match it. With offsets off, `sameValueAs` reduces to a + representative comparison, so behaviour is unchanged in the classic mode. +2. **Offset dropped building the lemma equality** (`WeakCongruencePath.generateClause`): it called + `createEquality(diseq.getFirst().getCCTerm(), diseq.getSecond().getCCTerm(), …)`, stripping to + bare `CCTerm`s → the degenerate `select(a,j) = select(a,j)` (which trips the `createEquality` + `t1 != t2 || offset != 0` assertion once the guard above fires). Fixed to pass the + `CCParameter`s to the offset-aware overload, which builds `select(a,j) = select(a,j)+1`, + recognizes the false proxy (`eq == null`, already handled), and lets the lemma become the + conflict clause over its premises. + +`trivialdiseqarray` is now **unsat**, with a low-level proof that proof-checks (`read-const-weakeq` +derives `select(a,j) = select(a,j)+1`; an `EQ`/farkas lemma refutes it). + +*Debug-output note:* the LA `Shared Vars` / `Assignments` dump (`LinArSolve.getSharedCongruences`) +prints `sharedTermValue`, the **offset-free** LA value with no structural constant, so e.g. +`(+ (select a j) 1)` printed `= 2` alongside `(select a j) = 2` (the `+1` is a CC offset, not in +the LA value) — misleading when reading a model, but not the cause of the bug. The +equivalence-class dump (`CClosure.dumpModel`) does print offsets (`[+k]`). + +Validated (clean build, `-ea`): `test/proof` **98/98** (`trivialdiseqarray` fixed, no +regressions); array-relevant sweep (93 benchmarks under `proof-check`) has **0 status mismatches** +and only pre-existing errors (`constarr00{4,5,11,12,14}` offset-interpolation, `Script_simple`); +`BitvectorTest`/`ProofSimplifierTest`/`ProofUtilsTest`/`RPITest`/`CongruentAddTest` green (117 +tests). Files: `ArrayTheory.java`, `WeakCongruencePath.java`. + ## Implementable slice (ready to start) 1. `LASharedTerm` becomes offset-free under `createOffsetEqualities()` (revert the diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ArrayTheory.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ArrayTheory.java index 5dd07333b..84c331ac6 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ArrayTheory.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ArrayTheory.java @@ -409,7 +409,9 @@ public void mergeWith(final ArrayNode storeNode, final CCAppTerm store, final Co storeNode.mSelects.put(index, select); } else { mergeConstSelects.remove(index); - if (select.getRepresentative() != otherSelect.getRepresentative()) { + // Offset-aware: two selects in the same class but at different offsets are NOT already equal — + // the propagated equality is a (false) offset equality that raises the conflict. + if (!select.sameValueAs(otherSelect)) { // add propagated equality propLemmas.add(new ArrayLemma(RuleKind.READ_OVER_WEAKEQ, select, otherSelect)); } @@ -427,7 +429,9 @@ public void mergeWith(final ArrayNode storeNode, final CCAppTerm store, final Co if (constNode.getWeakIRepresentative(index) == storeNode) { // do not keep select, we will merge it with the constant value storeNode.mSelects.remove(index); - if (select.getRepresentative() != const1.getRepresentative()) { + // Offset-aware: a select in the same class as the const value but at a different offset is NOT + // already equal — propagating the (false) offset equality raises the conflict. + if (!select.sameValueAs(const1)) { propLemmas.add(new ArrayLemma(RuleKind.READ_CONST_WEAKEQ, select, const1)); } } @@ -464,7 +468,7 @@ public void mergeSecondary(final ArrayNode storeNode, final CCAppTerm store, fin if (otherSelect == null) { storeNode.mSelects.put(storeIndex, select); } else { - if (select.getRepresentative() != otherSelect.getRepresentative()) { + if (!select.sameValueAs(otherSelect)) { propEqualities.add(new ArrayLemma(RuleKind.READ_OVER_WEAKEQ, select, otherSelect)); } } @@ -478,7 +482,7 @@ public void mergeSecondary(final ArrayNode storeNode, final CCAppTerm store, fin // do not keep select, we will merge it with the constant value final CCAppTerm select = storeNode.mSelects.remove(storeIndex); final CCParameter const1 = getValueFromConst(storeNode.mConstTerm); - if (select != null && select.getRepresentative() != const1.getRepresentative()) { + if (select != null && !select.sameValueAs(const1)) { propEqualities.add(new ArrayLemma(RuleKind.READ_CONST_WEAKEQ, select, const1)); } } diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/WeakCongruencePath.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/WeakCongruencePath.java index ade0366b7..ba6590d7c 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/WeakCongruencePath.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/WeakCongruencePath.java @@ -383,8 +383,11 @@ private void computeIndexDiseq(final CCParameter idx, final CCParameter idxFromS private Clause generateClause(final SymmetricPair diseq, final boolean produceProofs, final RuleKind rule) { if (diseq != null) { - final CCEquality eq = mArrayTheory.getCClosure().createEquality(diseq.getFirst().getCCTerm(), - diseq.getSecond().getCCTerm(), false); + // Pass the CCParameters (with offsets), not the bare CCTerms: a propagated array equality may be an offset + // equality (e.g. select(a,j) == select(a,j)+1 in trivialdiseqarray). Dropping the offset would build the + // degenerate select(a,j) == select(a,j) and trip the createEquality assertion; keeping it yields the false + // proxy (eq == null), so the lemma becomes the pure conflict clause over its premises. + final CCEquality eq = mArrayTheory.getCClosure().createEquality(diseq.getFirst(), diseq.getSecond(), false); if (eq != null) { // Note that it can actually happen that diseq is already in // the list of all literals (because it is an index assumption). From fb562ece26ce22d32fe786de8a80b853f5c9b26c Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Thu, 25 Jun 2026 14:07:29 +0200 Subject: [PATCH 52/75] CC offset equalities: offset-aware array extensionality fingerprint + model values An audit of ArrayTheory for offset correctness turned up two value spots that still dropped the offset (indices were already offset-aware throughout): - computeWeakeqExt fingerprint (SOUNDNESS): the per-array nodeMapping that drives weakeq-ext stored element values as bare representatives, so two arrays whose elements agree only up to a constant offset got the same fingerprint and a spurious a = b was propagated. Witness (genuinely sat): (not (= (store c i y) (store c i (+ y 1)))) was reported unsat. Store value identities (getValueKey) and compare with equals() instead of == (offset value-keys are fresh OffsettedCCTerm objects); constRep/defaultValue become CCParameter. Precision only improves: arrays still match iff they agree at every index including offsets, so no valid extensionality is lost. - fillInModel element values (wrong models): the const value, per-index select values, finite-elem default, and secondary-edge select all fed a bare representative to getModelValue, dropping offsetToRep. getModelValue(CCTerm) returns only the representative value while getModelValue(CCParameter) shifts by the offset, and Java picks the CCTerm overload for a CCAppTerm argument, so pass CCParameters (getValueFromConst directly, or getValueKey for selects). Validated (-ea): test/proof 98/98; the witness is now sat (was unsat pre-fix, confirming the soundness bug); model-check-mode on a const/store offset instance yields a consistent model; array sweep (93 benchmarks, proof-check) 0 status mismatches, only pre-existing errors; 117 JUnit tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- SMTInterpol/doc/offset-equality-plan.md | 33 ++++++++++++++++ .../theory/cclosure/ArrayTheory.java | 39 ++++++++++++------- 2 files changed, 57 insertions(+), 15 deletions(-) diff --git a/SMTInterpol/doc/offset-equality-plan.md b/SMTInterpol/doc/offset-equality-plan.md index 2489ed720..b1cefb079 100644 --- a/SMTInterpol/doc/offset-equality-plan.md +++ b/SMTInterpol/doc/offset-equality-plan.md @@ -1119,6 +1119,39 @@ and only pre-existing errors (`constarr00{4,5,11,12,14}` offset-interpolation, ` `BitvectorTest`/`ProofSimplifierTest`/`ProofUtilsTest`/`RPITest`/`CongruentAddTest` green (117 tests). Files: `ArrayTheory.java`, `WeakCongruencePath.java`. +## Offset-aware array extensionality fingerprint + model values — DONE (uncommitted) + +An audit of `ArrayTheory` for offset correctness found index handling solid (everything keys on +`CCParameter.getValueKey()` = `(representative, offsetToRep)`) and the value-propagation guards +fixed by the previous increment, but **two value spots still dropped the offset** — the more +serious one a soundness bug in extensionality: + +1. **`computeWeakeqExt` model fingerprint (soundness).** The per-array `nodeMapping` that drives + `weakeq-ext` stored element values as bare representatives (`getRepresentative()`), so two arrays + whose elements agree only **up to a constant offset** got the same fingerprint, collided in the + `inverse` map, and `weakeq-ext` propagated a spurious `a = b`. Minimal witness (genuinely sat): + `(not (= (store c i y) (store c i (+ y 1))))` was reported **unsat** (the spurious `a = b` + conflicts with the asserted disequality). Fixed to store value identities + (`getValueKey()`) and compare with `equals()` (offset value-keys are fresh `OffsettedCCTerm` + objects, so the old `!=` reference test would not even match equal offset values); `constRep` and + the `defaultValue` cache become `CCParameter`. Precision only improves — two arrays still match + iff they agree at every index *including* offsets, so no valid extensionality is lost. The + witness is now **sat**. +2. **`fillInModel` element values (wrong models).** The const value (`getValueFromConst(..) + .getRepresentative()`), the per-index select values (`.getRepresentative()`), the finite-elem + default (read back from `mArrayModels`), and the secondary-edge select (`getModelValue(ccValue)`) + all fed a bare representative to `getModelValue`, dropping `offsetToRep`. Note + `getModelValue(CCTerm)` returns only the representative's value while `getModelValue(CCParameter)` + shifts by the offset, and Java picks the `CCTerm` overload for a `CCAppTerm` argument — so the fix + passes `CCParameter`s (`getValueFromConst(..)` directly, or `getValueKey()` for selects). Verified + with `model-check-mode`: `a = (store (const (+ y 3)) i (+ y 1))`, `i != 0` yields a consistent + model (`y=-2`, `select(a,i)=-1`, `select(a,0)=1`). + +Validated (clean build, `-ea`): `test/proof` **98/98**; the extensionality witness is `sat` (was +`unsat` on the pre-fix build, confirming the soundness bug); array-relevant sweep (93 benchmarks, +`proof-check`) **0 status mismatches**, only the pre-existing errors; 117 JUnit tests green. Files: +`ArrayTheory.java`. + ## Implementable slice (ready to start) 1. `LASharedTerm` becomes offset-free under `createOffsetEqualities()` (revert the diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ArrayTheory.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ArrayTheory.java index 84c331ac6..3ec27ca4b 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ArrayTheory.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ArrayTheory.java @@ -915,8 +915,10 @@ public void fillInModel(final ModelBuilder builder, final List ccArrayTe // constant array or // it's not weakly equivalent to one. if (node.mConstTerm != null) { - final CCTerm value = getValueFromConst(node.mConstTerm).getRepresentative(); - final Term nodeValue = t.term(SMTLIBConstants.CONST, null, arraySort, builder.getModelValue(value)); + // getModelValue(CCParameter) shifts the representative's value by the const value's offset; using + // getRepresentative() + getModelValue(CCTerm) would drop the offset and build a wrong model value. + final Term nodeValue = t.term(SMTLIBConstants.CONST, null, arraySort, + builder.getModelValue(getValueFromConst(node.mConstTerm))); builder.setModelValue(node.mTerm, nodeValue); } else { Term nodeValue; @@ -924,7 +926,7 @@ public void fillInModel(final ModelBuilder builder, final List ccArrayTe // the array is based on a certain const value, see computeWeakEqExt() final Term valueTerm; if (hasFiniteElemSort(node.mTerm.getFlatTerm().getSort())) { - final CCTerm value = (CCTerm) mArrayModels.get(node).get(null); + final CCParameter value = (CCParameter) mArrayModels.get(node).get(null); valueTerm = builder.getModelValue(value); } else { valueTerm = model.extendFresh(valueSort); @@ -937,7 +939,9 @@ public void fillInModel(final ModelBuilder builder, final List ccArrayTe // change all indices to the right select value for (final Entry indexValuePairs : node.mSelects.entrySet()) { final CCParameter index = indexValuePairs.getKey(); - final CCTerm value = indexValuePairs.getValue().getRepresentative(); + // value identity (getValueKey), so a select whose value sits at a non-zero offset from its + // representative renders the shifted value, not the representative's. + final CCParameter value = indexValuePairs.getValue().getValueKey(); nodeValue = t.term("store", nodeValue, builder.getModelValue(index), builder.getModelValue(value)); } @@ -990,7 +994,7 @@ public void fillInModel(final ModelBuilder builder, final List ccArrayTe final CCAppTerm ccValue = node.mSelects.get(ccIndex); final Term index = builder.getModelValue(ccIndex); final Term value = secondParentTerm != null ? arraySortInterpretation.getSelect(secondParentTerm, index) - : ccValue == null ? model.extendFresh(valueSort) : builder.getModelValue(ccValue); + : ccValue == null ? model.extendFresh(valueSort) : builder.getModelValue(ccValue.getValueKey()); Term nodeValue = t.term("store", parentTerm, index, value); nodeValue = arraySortInterpretation.normalizeStoreTerm(nodeValue); builder.setModelValue(node.mTerm, nodeValue); @@ -1467,7 +1471,7 @@ private boolean computeWeakeqExt() { * fact mSelects is empty since we removed them). */ mArrayModels = new LinkedHashMap<>(); - final HashMap defaultValue = new HashMap<>(); + final HashMap defaultValue = new HashMap<>(); final HashMap, ArrayNode>> inverse = new HashMap<>(); final HashSet> propEqualities = new LinkedHashSet<>(); final ArrayDeque todoQueue = new ArrayDeque<>(mCongRoots.values()); @@ -1486,9 +1490,13 @@ private boolean computeWeakeqExt() { final HashMap nodeMapping = new LinkedHashMap<>(); final ArrayNode weakRep = node.getWeakRepresentative(); if (node == weakRep) { - CCTerm constRep = null; + // The element values stored in the fingerprint are value identities (getValueKey = representative + + // offsetToRep), not bare representatives: two arrays whose elements agree up to a constant offset must + // NOT get the same fingerprint, or weakeq-ext would propagate a spurious array equality (e.g. + // store(c,i,y) vs store(c,i,y+1)). Comparisons against constRep therefore use equals(), not ==. + CCParameter constRep = null; if (weakRep.mConstTerm != null) { - constRep = getValueFromConst(weakRep.mConstTerm).getRepresentative(); + constRep = getValueFromConst(weakRep.mConstTerm).getValueKey(); nodeMapping.put(null, constRep); } else if (hasFiniteIndexSort(arraySort)) { // For finite index sorts we cannot just assume that non weak-equivalent arrays @@ -1511,7 +1519,7 @@ private boolean computeWeakeqExt() { cleanCaches(); return true; } - constRep = weakRep.mSelects.values().iterator().next().getRepresentative(); + constRep = weakRep.mSelects.values().iterator().next().getValueKey(); defaultValue.put(arraySort, constRep); } nodeMapping.put(null, constRep); @@ -1528,8 +1536,8 @@ private boolean computeWeakeqExt() { nodeMapping.put(null, node); } for (final Entry e : node.mSelects.entrySet()) { - final CCTerm value = e.getValue().getRepresentative(); - if (value != constRep) { + final CCParameter value = e.getValue().getValueKey(); + if (!value.equals(constRep)) { nodeMapping.put(e.getKey(), value); } } @@ -1539,10 +1547,11 @@ private boolean computeWeakeqExt() { final Object constRep = nodeMapping.get(null); nodeMapping.remove(storeIndex); final ArrayNode weakiRep = node.getWeakIRepresentative(storeIndex); - final CCTerm value = weakiRep.mSelects.get(storeIndex); - if (value != null) { // NOPMD - if (value.getRepresentative() != constRep) { - nodeMapping.put(storeIndex, value.getRepresentative()); + final CCAppTerm valueSelect = weakiRep.mSelects.get(storeIndex); + if (valueSelect != null) { // NOPMD + final CCParameter value = valueSelect.getValueKey(); + if (!value.equals(constRep)) { + nodeMapping.put(storeIndex, value); } } else if (weakiRep != weakRep) { nodeMapping.put(storeIndex, weakiRep); From f6ebca240b7363d2ede7e1ad2f45d4565c0e6d8f Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Thu, 25 Jun 2026 14:32:37 +0200 Subject: [PATCH 53/75] CC offset equalities: remove error-prone getModelValue(CCTerm) from ModelBuilder getModelValue(CCTerm) returned mModelValues.get(term.getRepresentative()) -- the representative's value with the member's offsetToRep silently dropped, which is surprising and a source of wrong models for numeric members (the bug just fixed in fillInModel). Remove it in favour of the single offset-aware getModelValue(CCParameter): a bare CCTerm is an offset-free parameter, so all former callers (array/datatype/boolean terms -- all non-numeric or representatives) rebind to it with identical behaviour, while any numeric member now necessarily goes through the offset-shifting path. Removing an overload is binary-incompatible: callers in other files (e.g. DataTypeTheory) must be recompiled, so a clean build is required (an incremental rebuild leaves a stale .class referencing the deleted method and throws NoSuchMethodError). Validated (clean build, -ea): test/proof 98/98; datatype+model dirs 30/30 (incl. model/offset_datatype); array sweep 0 status mismatches; trivialdiseqarray unsat, extensionality witness sat, model-check on a const/store offset instance consistent; 117 JUnit tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- SMTInterpol/doc/offset-equality-plan.md | 19 +++++++++++++------ .../theory/cclosure/ArrayTheory.java | 4 ++-- .../theory/cclosure/ModelBuilder.java | 16 +++++++++------- 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/SMTInterpol/doc/offset-equality-plan.md b/SMTInterpol/doc/offset-equality-plan.md index b1cefb079..7b445c2be 100644 --- a/SMTInterpol/doc/offset-equality-plan.md +++ b/SMTInterpol/doc/offset-equality-plan.md @@ -1140,17 +1140,24 @@ serious one a soundness bug in extensionality: 2. **`fillInModel` element values (wrong models).** The const value (`getValueFromConst(..) .getRepresentative()`), the per-index select values (`.getRepresentative()`), the finite-elem default (read back from `mArrayModels`), and the secondary-edge select (`getModelValue(ccValue)`) - all fed a bare representative to `getModelValue`, dropping `offsetToRep`. Note - `getModelValue(CCTerm)` returns only the representative's value while `getModelValue(CCParameter)` - shifts by the offset, and Java picks the `CCTerm` overload for a `CCAppTerm` argument — so the fix - passes `CCParameter`s (`getValueFromConst(..)` directly, or `getValueKey()` for selects). Verified + all fed a bare representative to `getModelValue`, dropping `offsetToRep`. The fix passes + `CCParameter`s (`getValueFromConst(..)` directly, or `getValueKey()` for selects). Verified with `model-check-mode`: `a = (store (const (+ y 3)) i (+ y 1))`, `i != 0` yields a consistent model (`y=-2`, `select(a,i)=-1`, `select(a,0)=1`). +Follow-up: the error-prone `getModelValue(CCTerm)` accessor (returns the representative's value, +silently dropping a member's offset) was **removed** in favour of the single offset-aware +`getModelValue(CCParameter)` — a bare `CCTerm` is an offset-free parameter, so all former callers +(array/datatype/boolean terms, all non-numeric or representatives) bind to it with identical +behaviour, while any numeric member now necessarily goes through the offset-shifting path. Removing +the overload is binary-incompatible, so callers in other files (e.g. `DataTypeTheory`) must be +recompiled — a clean build is required (an incremental rebuild leaves a stale `.class` referencing +the deleted method and throws `NoSuchMethodError`). + Validated (clean build, `-ea`): `test/proof` **98/98**; the extensionality witness is `sat` (was `unsat` on the pre-fix build, confirming the soundness bug); array-relevant sweep (93 benchmarks, -`proof-check`) **0 status mismatches**, only the pre-existing errors; 117 JUnit tests green. Files: -`ArrayTheory.java`. +`proof-check`) **0 status mismatches**, only the pre-existing errors; `datatype`/`model` dirs 30/30; +117 JUnit tests green. Files: `ArrayTheory.java`, `ModelBuilder.java`. ## Implementable slice (ready to start) diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ArrayTheory.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ArrayTheory.java index 3ec27ca4b..cbf38d8f1 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ArrayTheory.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ArrayTheory.java @@ -915,8 +915,8 @@ public void fillInModel(final ModelBuilder builder, final List ccArrayTe // constant array or // it's not weakly equivalent to one. if (node.mConstTerm != null) { - // getModelValue(CCParameter) shifts the representative's value by the const value's offset; using - // getRepresentative() + getModelValue(CCTerm) would drop the offset and build a wrong model value. + // getModelValue shifts the representative's value by the const value's offset; using + // getRepresentative() would drop the offset and build a wrong model value. final Term nodeValue = t.term(SMTLIBConstants.CONST, null, arraySort, builder.getModelValue(getValueFromConst(node.mConstTerm))); builder.setModelValue(node.mTerm, nodeValue); diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ModelBuilder.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ModelBuilder.java index d6f2d29f4..5076d14dd 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ModelBuilder.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ModelBuilder.java @@ -151,14 +151,16 @@ public SharedTermEvaluator getEvaluator() { return mEvaluator; } - public Term getModelValue(final CCTerm term) { - return mModelValues.get(term.getRepresentative()); - } - /** - * The model value of a {@link CCParameter} value {@code term + offset}, i.e. the representative's value shifted by - * the parameter's offset to the representative. For an offset-free parameter (a bare CCTerm) this coincides with - * {@link #getModelValue(CCTerm)}. + * The model value of a {@link CCParameter} value {@code term + offset}: the representative's model value shifted by + * the parameter's offset to the representative. A bare {@link CCTerm} is an offset-free parameter, so this returns + * its representative's value; a numeric member additionally adds its offset to the representative. Returns + * {@code null} if the class has no model value yet (used as a "value computed?" check) — this only happens for + * non-numeric sorts, where no offset arithmetic is attempted on the missing value. + * + *

This is the only model-value accessor: there is deliberately no {@code getModelValue(CCTerm)}, since looking up + * a bare representative would silently drop a member's offset (returning the representative's value instead of the + * member's) — a subtle source of wrong models for numeric terms. */ public Term getModelValue(final CCParameter param) { return getModelValueWithOffset(param.getCCTerm(), param.getOffset()); From 19ebbea3bbcafb2989bd641888c94c23b1b86a77 Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Thu, 25 Jun 2026 15:06:07 +0200 Subject: [PATCH 54/75] CC offset equalities: inline getModelValueWithOffset into getModelValue(CCParameter) getModelValueWithOffset(CCTerm, Rational) was a private helper called only from getModelValue(CCParameter) and two sites that reconstructed a CCParameter's (term, offset) by hand. Inline its body into getModelValue(CCParameter) (using param.getRepresentative()/getOffsetToRep() directly) and route the two callers through getModelValue: - fillInFunctions: getModelValueWithOffset(term, ZERO) -> getModelValue(term) - addApp: getModelValueWithOffset(argParam.getCCTerm(), argParam.getOffset()) -> getModelValue(argParam) Pure refactor, behaviour unchanged. Validated (-ea): test/proof 98/98, datatype+model 30/30, array sweep 0 status mismatches, 117 JUnit tests green; trivialdiseqarray unsat, extensionality witness sat, model-check consistent. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../theory/cclosure/ModelBuilder.java | 25 ++++++------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ModelBuilder.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ModelBuilder.java index 5076d14dd..b4348bc0d 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ModelBuilder.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ModelBuilder.java @@ -163,24 +163,15 @@ public SharedTermEvaluator getEvaluator() { * member's) — a subtle source of wrong models for numeric terms. */ public Term getModelValue(final CCParameter param) { - return getModelValueWithOffset(param.getCCTerm(), param.getOffset()); - } - - /** - * The model value of a (numeric) term plus a constant. The representative carries the model value of the offset-free - * class; an individual member's value adds its offset to the representative, and {@code extraOffset} adds a further - * constant (e.g. the structural argument offset of {@code x+5}). For non-numeric terms the offsets are zero and the - * representative's value is returned unchanged. - */ - private Term getModelValueWithOffset(final CCTerm term, final Rational extraOffset) { - final Term repValue = mModelValues.get(term.getRepresentative()); - final Sort sort = term.getFlatTerm().getSort(); + final Term repValue = mModelValues.get(param.getRepresentative()); + final Sort sort = param.getCCTerm().getFlatTerm().getSort(); if (!sort.isNumericSort()) { - assert term.getOffsetToRep().equals(Rational.ZERO) && extraOffset.equals(Rational.ZERO); + assert param.getOffsetToRep().equals(Rational.ZERO); return repValue; } - final Rational value = NumericSortInterpretation.toRational(repValue).add(term.getOffsetToRep()).add(extraOffset); - return value.toTerm(sort); + // the member's value is the representative's value shifted by the parameter's offset to the representative + // (which folds in both the class offset and the structural argument offset, e.g. the +5 in x+5). + return NumericSortInterpretation.toRational(repValue).add(param.getOffsetToRep()).toTerm(sort); } public void setModelValue(final CCTerm term, final Term value) { @@ -254,7 +245,7 @@ public void fillInTermValues(final List terms, final CCTerm trueNode, fi public void fillInFunctions(final List terms, final Model model, final Theory t) { for (final CCTerm term : terms) { - add(model, term, getModelValueWithOffset(term, Rational.ZERO), t); + add(model, term, getModelValue(term), t); } } @@ -302,7 +293,7 @@ private void addApp(final Model model, final CCAppTerm app, final Term value, fi for (int i = 0; i < args.length; i++) { // the actual argument is the CCParameter's term + offset, evaluated at its model value final CCParameter argParam = app.getArgParam(i); - args[i] = getModelValueWithOffset(argParam.getCCTerm(), argParam.getOffset()); + args[i] = getModelValue(argParam); } final FunctionSymbol fs = app.getFunctionSymbol(); if (!fs.isIntern() || isUndefinedFor(fs, args)) { From 854ab191b3b8fc715af572daea4bfdb5832fbc0a Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Thu, 25 Jun 2026 16:15:04 +0200 Subject: [PATCH 55/75] Trivial refactoring: set offset of equality in constructor. --- .../ultimate/smtinterpol/theory/cclosure/CCEquality.java | 7 ++----- .../ultimate/smtinterpol/theory/cclosure/CClosure.java | 3 +-- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCEquality.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCEquality.java index 977d145f5..1994eaa74 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCEquality.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCEquality.java @@ -51,11 +51,12 @@ public CCEquality getCCEquality() { } } - CCEquality(final int assertionstacklevel, final CCTerm c1, final CCTerm c2) { + CCEquality(final int assertionstacklevel, final CCTerm c1, final CCTerm c2, Rational offset) { super(HashUtils.hashJenkins(c1.hashCode(), c2), assertionstacklevel); mLhs = c1; mRhs = c2; mEntry = new Entry(); + mOffset = offset; } public CCTerm getLhs() { @@ -73,10 +74,6 @@ public Rational getOffset() { return mOffset; } - public void setOffset(final Rational offset) { - mOffset = offset; - } - /** * Set the separating disequality that makes this equality false, recording its orientation relative to this equality. * Must be called while this equality's two sides are still in distinct classes (which holds at every propagation diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java index 899cf59e4..e5d9dbbc8 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java @@ -792,8 +792,7 @@ public CCEquality createCCEquality(final int stackLevel, CCTerm t1, CCTerm t2, R t1 = tmp; offset = offset.negate(); } - eq = new CCEquality(stackLevel, t1, t2); - eq.setOffset(offset); + eq = new CCEquality(stackLevel, t1, t2, offset); insertEqualityEntry(t1, t2, offset, eq.getEntry()); getEngine().addAtom(eq); From d8342e52eb18c2ea6633bf68a2051c38f6882448 Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Thu, 25 Jun 2026 18:18:49 +0200 Subject: [PATCH 56/75] Refactoring of anti cycle code --- .../smtinterpol/theory/cclosure/CClosure.java | 58 ++++----- .../theory/cclosure/CongruencePath.java | 116 ++++++++++-------- 2 files changed, 91 insertions(+), 83 deletions(-) diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java index e5d9dbbc8..99a4de2e1 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java @@ -875,7 +875,7 @@ public Clause getUnitClause(final Literal lit) { } else { /* ComputeAntiCycle */ final CCEquality eq = (CCEquality) lit.negate(); - return computeAntiCycle(eq); + return computeAntiCycle(eq.mDiseqReason, eq.mDiseqOrientation, eq); } } @@ -967,7 +967,7 @@ public Clause setLiteral(final Literal literal) { // falsified. (computeCycle would put eq positively and yield a satisfied clause.) final Rational existingDiff = eq.getLhs().getOffsetToRep().sub(eq.getRhs().getOffsetToRep()); if (!existingDiff.equals(eq.getOffset())) { - return computeSameClassAntiCycle(eq); + return computeAntiCycle(null, false, eq); } } } else { @@ -981,10 +981,7 @@ public Clause setLiteral(final Literal literal) { // is nothing to separate. final Rational existingDiff = eq.getLhs().getOffsetToRep().sub(eq.getRhs().getOffsetToRep()); if (existingDiff.equals(eq.getOffset())) { - final Clause conflict = computeCycle(eq); - if (conflict != null) { - return conflict; - } + return computeCycle(eq); } } else { separate(left, right, eq); @@ -1073,7 +1070,7 @@ public Clause computeCycle(final CCEquality eq) { */ public Clause computeSharedConflictCycle(final CCTerm lshared, final CCTerm rshared, final CCTerm lhs, final CCTerm rhsTerm, final CCEquality reason, final Rational bridgeOff) { - return new CongruencePath(this).computeMergeConflictCycle(lshared, rshared, lhs, rhsTerm, reason, bridgeOff, + return new CongruencePath(this).computeMergeConflictCycle(lhs, rhsTerm, bridgeOff, reason, lshared, rshared, null, isProofGenerationEnabled()); } @@ -1086,38 +1083,35 @@ public Clause computeSharedConflictCycle(final CCTerm lshared, final CCTerm rsha */ public Clause computeMergeDiseqCycle(final CCTerm srcEnd, final CCTerm destEnd, final CCTerm lhs, final CCTerm rhsTerm, final CCEquality reason, final Rational bridgeOff, final CCEquality diseq) { - return new CongruencePath(this).computeMergeConflictCycle(srcEnd, destEnd, lhs, rhsTerm, reason, bridgeOff, + return new CongruencePath(this).computeMergeConflictCycle(lhs, rhsTerm, bridgeOff, reason, srcEnd, destEnd, diseq, isProofGenerationEnabled()); } /** - * Same-class offset conflict: {@code eq}'s two sides are in one class at an offset different from the one {@code eq} - * claims (e.g. asserting {@code x == x + 1}, or an offset disequality re-propagated false). This is a degenerate - * {@link CongruencePath#computeMergeConflictCycle}: the "merge" bridge is {@code eq} itself and the two endpoints - * coincide ({@code srcEnd == destEnd == eq.getLhs()}), so the source half is empty, the destination half is the - * existing class path from {@code eq.getRhs()} back to {@code eq.getLhs()}, and the trivial diseq - * {@code (lhs@0, lhs@deviation)} is discharged by an EQ lemma. + * Compute a conflict explaining incompatibility of eq and diseq. Called when + * asserting eq would create a conflict on merge with the given diseq. */ - private Clause computeSameClassAntiCycle(final CCEquality eq) { - assert eq.getLhs().mRepStar == eq.getRhs().mRepStar; - assert !eq.getLhs().getOffsetToRep().sub(eq.getRhs().getOffsetToRep()).equals(eq.getOffset()); - return new CongruencePath(this).computeMergeConflictCycle(eq.getLhs(), eq.getLhs(), eq.getLhs(), eq.getRhs(), eq, - eq.getOffset(), null, isProofGenerationEnabled()); - } - - public Clause computeAntiCycle(final CCEquality eq) { - final CCEquality diseq = eq.mDiseqReason; + public Clause computeAntiCycle(final CCEquality diseq, final boolean diseqOrientation, final CCEquality eq) { + final CCTerm lhsDiseq; + final CCTerm rhsDiseq; if (diseq == null) { // No separating disequality (e.g. x != x+5): the two sides are in the same class at a deviating offset. - return computeSameClassAntiCycle(eq); + assert eq.getLhs().mRepStar == eq.getRhs().mRepStar; + assert !eq.getLhs().getOffsetToRep().sub(eq.getRhs().getOffsetToRep()).equals(eq.getOffset()); + lhsDiseq = eq.getLhs(); + rhsDiseq = eq.getLhs(); + } else { + // A concrete disequality separates eq's two sides. This works whether they are + // still in different classes or were + // merged later: orient diseq from eq's stored orientation (live reps can no + // longer tell the two sides apart after + // a merge), then build the two single-class halves and stitch them across the + // eq bridge. + lhsDiseq = diseqOrientation ? diseq.getLhs() : diseq.getRhs(); + rhsDiseq = diseqOrientation ? diseq.getRhs() : diseq.getLhs(); } - // A concrete disequality separates eq's two sides. This works whether they are still in different classes or were - // merged later: orient diseq from eq's stored orientation (live reps can no longer tell the two sides apart after - // a merge), then build the two single-class halves and stitch them across the eq bridge. - final CCTerm srcEnd = eq.mDiseqOrientation ? diseq.getLhs() : diseq.getRhs(); - final CCTerm destEnd = eq.mDiseqOrientation ? diseq.getRhs() : diseq.getLhs(); - return new CongruencePath(this).computeMergeConflictCycle(srcEnd, destEnd, eq.getLhs(), eq.getRhs(), eq, - eq.getOffset(), diseq, isProofGenerationEnabled()); + return new CongruencePath(this).computeMergeConflictCycle(eq.getLhs(), eq.getRhs(), eq.getOffset(), eq, lhsDiseq, + rhsDiseq, diseq, isProofGenerationEnabled()); } /** @@ -1132,7 +1126,7 @@ public Clause computeAntiCycle(final CCEquality eq) { public Clause computeCongruenceAntiCycle(final CCAppTerm first, final CCAppTerm second) { assert first.getRepresentative() == second.getRepresentative(); assert first.getFunctionSymbol() == second.getFunctionSymbol(); - return new CongruencePath(this).computeMergeConflictCycle(first, first, first, second, null, Rational.ZERO, null, + return new CongruencePath(this).computeMergeConflictCycle(first, second, Rational.ZERO, null, first, first, null, isProofGenerationEnabled()); } diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java index 6a96b73cd..686c0d919 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java @@ -409,64 +409,77 @@ public Clause computeCycle(final CCEquality eq, final boolean produceProofs) { } /** - * The single conflict explainer for an offset cycle: an equality "bridge" {@code lhs — rhsTerm} together with the - * existing congruence-class path(s) implies {@code value(srcEnd) == value(destEnd) + k}, contradicting a disequality. - * The bridge is justified by the merge {@code reason} (a real equality literal) or, for a congruence - * ({@code reason == null}), by the argument equalities; {@code bridgeOff} is the bridge's offset - * ({@code value(lhs) − value(rhsTerm)}, {@code 0} for a congruence). The two endpoints carry the disequality: + * The single conflict explainer for an offset cycle: a bridge equality + * {@code lhs = rhs + offset} together with the existing congruence-class + * path(s) implies a contradiction to a known disequality + * {@code lhsDiseq != rhsDiseq + k}. The bridge is justified by a real equality + * literal {@code equality} or, congruence ({@code equality == null}), by the + * argument equalities; {@code offset} is the bridge's offset, {@code 0} for a + * congruence). The disequality can stem from two sources: *

    - *
  • Disequality clash ({@code diseqLit != null}): a registered disequality {@code diseqLit} forbids exactly - * the offset at which the bridge would unite {@code srcEnd} and {@code destEnd}. The clause carries {@code diseqLit} - * as its positive literal.
  • - *
  • Trivial clash ({@code diseqLit == null}): {@code srcEnd}/{@code destEnd} are either two class shared - * terms whose merged values are provably distinct (an integer shared term forced to a non-integer value, - * {@code to_real a == 1/2}), or — in the same-class case below — the same term at two offsets. The contradiction is - * intrinsic, discharged by an EQ/LA lemma; no positive literal.
  • + *
  • Disequality clash ({@code diseq != null}): a registered + * disequality {@code diseq} forbids exactly the offset at which the bridge + * would unite. The disequality is between {@code lhsDiseq} and {@code rhsDiseq} + * with exactly the right offset. The clause carries {@code diseq} as its + * positive literal.
  • + *
  • Trivial clash ({@code diseqLit == null}): + * {@code lhsDiseq}/{@code rhsDiseq} are provable distinct by integer reasoning + * (an integer shared term forced to a non-integer value, + * {@code to_real a == 1/2}), or — in the same-class case — the same term at two + * offsets. The contradiction is a trivial axiom; the clause contains no + * positive literal.
  • *
* - *

This covers both the cross-class and same-class shapes uniformly: + *

+ * This covers both the cross-class and same-class shapes uniformly. *

    - *
  • Cross-class ({@code srcEnd != destEnd}): the conflict is found during a merge of two classes joined only - * by the freshly added bridge {@code lhs — rhsTerm} (the union-find is not yet updated). {@code srcEnd} is in the - * source class (reachable from {@code lhs}), {@code destEnd} in the destination class (reachable from - * {@code rhsTerm}).
  • - *
  • Same-class ({@code srcEnd == destEnd}, both equal to {@code lhs}): the bridge {@code lhs — rhsTerm} - * closes a cycle within one class against the existing path from {@code rhsTerm} back to {@code lhs}. The source half - * is empty and the trivial diseq is {@code (lhs@0, lhs@(bridgeOff − pathOffset))}. This is the offset anti-cycle - * ({@code x != x + 5}) and the congruence-merge offset conflict ({@code f(x) = f(y) + k}).
  • + *
  • Cross-class + * ({@code lhs.getRepresentative() != rhs.getRepresentative()}): this is most + * likely caused by a disequality (diseq != null), but it could also be an + * integer conflict.
  • + *
  • Same-class in that case most likely lhsDiseq=rhsDiseq = lhs (or + * rhs). and the equality closes a cycle within one class against the existing + * path from {@code rhs} back to {@code lhs} with a mismatched offset. The + * source half is empty and the trivial diseq is + * {@code lhs != lhs + (bridgeOff − pathOffset)}. The diseq is null.
  • *
* - *

A single {@code computePath} across the bridge would read offsets from two different reference frames (across the - * not-yet-united classes) and produce a garbage proof object. Instead we compute the two halves as ordinary - * single-class paths ({@code srcEnd … lhs} and {@code rhsTerm … destEnd}, each offset-correct) and stitch them, the - * destination half rendered with anchor {@code rhsTerm@(offLhs + bridgeOff)} so it is already in the source frame and - * the main path is a plain concatenation. The bridge step {@code lhs → rhsTerm} is discharged by {@code reason} (or, - * for a congruence, auto-resolved from the argument subpaths the proof generator finds in the other paths). + * @param lhs one side of equality. + * @param rhs one side of equality. + * @param offset offset for rhs in the equality. + * @param equality the equality that caused the conflicting merge (null for + * congruence). + * @param lhsDiseq the side of the diseq that matches lhs (not necessarily + * the diseq's lhs). + * @param rhsDiseq the side of the diseq that matches rhs. + * @param diseq the disequality (null if trivial arithmetic + * disequality). + * @param produceProofs true if proof production is enabled. */ - public Clause computeMergeConflictCycle(final CCTerm srcEnd, final CCTerm destEnd, final CCTerm lhs, - final CCTerm rhsTerm, final CCEquality reason, final Rational bridgeOff, final CCEquality diseqLit, + public Clause computeMergeConflictCycle(final CCTerm lhs, final CCTerm rhs, final Rational offset, + final CCEquality equality, final CCTerm lhsDiseq, final CCTerm rhsDiseq, final CCEquality diseq, final boolean produceProofs) { - // Two single-class paths, each offset-correct on its own. Build them directly (not via the work list) so they - // are NOT collected into mAllPaths: they are stitched by hand into the explicit main path below, while only their - // congruence dependencies (enqueued here, drained next) belong in mAllPaths as the other paths. Each is null for - // a trivial half (srcEnd == lhs / rhsTerm == destEnd). The literal collection into mAllLiterals happens here - // regardless of produceProofs, so this must run for both the clause and the proof. - final SubPath segSrc = computePathNonRecursive(srcEnd, lhs); - final SubPath segDest = computePathNonRecursive(rhsTerm, destEnd); - // Justify the bridge edge lhs — rhsTerm. - if (reason != null) { - mAllLiterals.add(reason); + assert lhs.getRepresentative() == lhsDiseq.getRepresentative(); + assert rhs.getRepresentative() == rhsDiseq.getRepresentative(); + // Justify the bridge edge lhs = rhs + offset. + if (equality != null) { + mAllLiterals.add(equality); } else { - // Congruence bridge: the argument equalities justify lhs == rhsTerm (and build the subpaths that the proof - // generator uses to resolve the bridge step). - computeCongruence((CCAppTerm) lhs, (CCAppTerm) rhsTerm); + // Congruence bridge: the argument equalities justify lhs == rhs (and build the + // subpaths that the proof generator uses to resolve the bridge step). + assert offset.equals(Rational.ZERO); + computeCongruence((CCAppTerm) lhs, (CCAppTerm) rhs); } + // Two single-class paths for lhs and rhs, each offset-correct on its own. These + // are merged later to a single main path. + final SubPath segSrc = computePathNonRecursive(lhsDiseq, lhs); + final SubPath segDest = computePathNonRecursive(rhs, rhsDiseq); drainTodo(); - final Literal[] clause = new Literal[mAllLiterals.size() + (diseqLit != null ? 1 : 0)]; + final Literal[] clause = new Literal[mAllLiterals.size() + (diseq != null ? 1 : 0)]; int i = 0; // The separating disequality (if any) is the cycle's only positive literal; the merge falsifies it. - if (diseqLit != null) { - clause[i++] = diseqLit; + if (diseq != null) { + clause[i++] = diseq; } for (final Literal l : mAllLiterals) { clause[i++] = l.negate(); @@ -477,25 +490,26 @@ public Clause computeMergeConflictCycle(final CCTerm srcEnd, final CCTerm destEn // the source half's frame: after the bridge edge (value(lhs) == value(rhsTerm) + bridgeOff), rhsTerm sits at // lhs's offset plus bridgeOff; rendering the dest half anchored there yields it already shifted, so the main // path is a plain concatenation. - final CCParameter[] paramsSrc = segSrc != null ? segSrc.getParams(srcEnd) : new CCParameter[] { srcEnd }; + final CCParameter[] paramsSrc = segSrc != null ? segSrc.getParams(lhsDiseq) : new CCParameter[] { lhsDiseq }; assert paramsSrc[paramsSrc.length - 1].getCCTerm() == lhs : "src path must end at lhs + offset"; final CCParameter destAnchor = - CCParameter.of(rhsTerm, paramsSrc[paramsSrc.length - 1].getOffset().add(bridgeOff)); + CCParameter.of(rhs, paramsSrc[paramsSrc.length - 1].getOffset().add(offset)); final CCParameter[] paramsDest = segDest != null ? segDest.getParams(destAnchor) : new CCParameter[] { destAnchor }; final CCParameter[] mainPath = new CCParameter[paramsSrc.length + paramsDest.length]; System.arraycopy(paramsSrc, 0, mainPath, 0, paramsSrc.length); System.arraycopy(paramsDest, 0, mainPath, paramsSrc.length, paramsDest.length); - assert mainPath[0].getCCTerm() == srcEnd : "main path must start at srcEnd"; - assert mainPath[mainPath.length - 1].getCCTerm() == destEnd : "main path must end at destEnd"; + assert mainPath[0].getCCTerm() == lhsDiseq : "main path must start at lhs of diseq"; + assert mainPath[mainPath.length - 1].getCCTerm() == rhsDiseq : "main path must end at rhs of diseq"; // The remaining subpaths (congruences within either half, plus the bridge's argument subpaths) keep deriving // their offsets the usual way. segSrc/segDest were built off the work list and never entered mAllPaths, so // mAllPaths is exactly the other paths (the constructor only iterates it, so no copy is needed). // The clashing equality: a concrete disequality discharged against diseqLit, or (shared case) the trivially // distinct shared values discharged by an EQ/LA lemma. - final SymmetricPair diseq = new SymmetricPair<>(mainPath[0], mainPath[mainPath.length - 1]); + final SymmetricPair diseqPair = new SymmetricPair<>(mainPath[0], + mainPath[mainPath.length - 1]); c.setProof(new LeafNode(LeafNode.THEORY_CC, - new CCAnnotation(diseq, mainPath, mAllPaths, CCAnnotation.RuleKind.CONG))); + new CCAnnotation(diseqPair, mainPath, mAllPaths, CCAnnotation.RuleKind.CONG))); } return c; } From 5a7c41b597d76358a3ce554f98706d45b2c9d5f2 Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Thu, 25 Jun 2026 18:35:29 +0200 Subject: [PATCH 57/75] Remove some dead code. --- .../theory/cclosure/CCAnnotation.java | 39 ++----------------- 1 file changed, 4 insertions(+), 35 deletions(-) diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAnnotation.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAnnotation.java index 5ab830143..02b1a1a60 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAnnotation.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAnnotation.java @@ -242,32 +242,13 @@ public String getKind() { final DataTypeLemma mDTLemma; - private static SymmetricPair offsetFreeDiseq(final SymmetricPair diseq) { - return diseq == null ? null - : new SymmetricPair<>(diseq.getFirst().getCCTerm(), diseq.getSecond().getCCTerm()); - } - public CCAnnotation(final SymmetricPair diseq, final Collection paths, final RuleKind rule) { - this(diseq, paths, rule, null, null); + this(diseq, paths, rule, null); } public CCAnnotation(final SymmetricPair diseq, final Collection paths, final DataTypeLemma lemma) { - this(diseq, paths, lemma.getRule(), lemma, null); - } - - /** - * Annotation with an extra leading edge prepended to the main path (path 0). This is used by the offset anti-cycle - * (see {@link CongruencePath#computeAntiCycle}): the deviating offset of the conflicting equality cannot be carried - * by a {@link SubPath} node (whose offset is derived intrinsically from {@code mOffsetToRep}), so it rides on an - * explicit {@code CCParameter} prepended here, exactly as a datatype lemma carries its offset on a standalone - * {@code CCParameter} main equality. - * - * @param pathPrefix the explicit first node of the main path (e.g. {@code rhs + offset(eq)}). - */ - public CCAnnotation(final SymmetricPair diseq, final Collection paths, final RuleKind rule, - final CCParameter pathPrefix) { - this(diseq, paths, rule, null, pathPrefix); + this(diseq, paths, lemma.getRule(), lemma); } /** @@ -297,18 +278,13 @@ public CCAnnotation(final SymmetricPair diseq, final CCParameter[] } private CCAnnotation(final SymmetricPair diseq, final Collection paths, final RuleKind rule, - final DataTypeLemma lemma, final CCParameter pathPrefix) { + final DataTypeLemma lemma) { mDiseqParam = diseq; mParamPaths = new CCParameter[paths.size()][]; mWeakIndices = new CCParameter[mParamPaths.length]; int i = 0; for (final SubPath p : paths) { - CCParameter[] params = p.getParams(); - // Prepend the explicit leading edge to the main path (path 0); see the constructor above. - if (i == 0 && pathPrefix != null) { - params = prepend(pathPrefix, params); - } - mParamPaths[i] = params; + mParamPaths[i] = p.getParams(); mWeakIndices[i] = p instanceof WeakSubPath ? ((WeakSubPath) p).getIndex() : null; i++; } @@ -316,13 +292,6 @@ private CCAnnotation(final SymmetricPair diseq, final Collection getDiseqParam() { return mDiseqParam; From 5d473e686d47553029ed1322ab6b71f96f3d1fed Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Thu, 25 Jun 2026 19:42:27 +0200 Subject: [PATCH 58/75] CC offset equalities: map offseted terms to CCParameter. Terms with a constant offset (e.g. x+5) map to a CCParameter (base CCTerm + offset) at build time; offset-free terms map to a plain CCTerm. createCCTerm()/convert() returns the CCParameter, while getCCTerm() returns the offset-free CCTerm from the offset-free-keyed map. EqualityProxy stores the offset-free sides plus the offset. A term that is just an offseted CCTerm is no longer shared with LA. The addTermAxioms must now only be called for offset-free terms. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../smtinterpol/convert/CCTermBuilder.java | 50 ++++++-------- .../smtinterpol/convert/Clausifier.java | 66 ++++++++++++------- .../smtinterpol/convert/EqualityProxy.java | 59 ++++++++++++----- .../smtinterpol/model/ModelProver.java | 2 +- .../smtinterpol/theory/cclosure/CClosure.java | 8 +-- .../theory/cclosure/DataTypeTheory.java | 6 +- .../smtinterpol/theory/linar/LinArSolve.java | 9 ++- .../smtinterpol/theory/quant/QuantClause.java | 10 +-- .../quant/ematching/PatternCompiler.java | 3 +- .../theory/cclosure/CongruentAddTest.java | 10 +-- 10 files changed, 128 insertions(+), 95 deletions(-) diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/CCTermBuilder.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/CCTermBuilder.java index 22e2245b8..ea8bca656 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/CCTermBuilder.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/CCTermBuilder.java @@ -50,20 +50,18 @@ public CCTermBuilder(Clausifier clausifier, final SourceAnnotation source) { mSource = source; } - private void pushResult(final CCTerm ccTerm, final Rational offset) { - mConverted.push(CCParameter.of(ccTerm, offset)); + private void pushResult(final CCParameter ccParam) { + mConverted.push(ccParam); } - public CCTerm convert(final Term t) { + public CCParameter convert(final Term t) { mOps.push(new BuildCCTerm(t)); while (!mOps.isEmpty()) { mOps.pop().perform(); } - // The caller asked for the CCTerm of the term; a top-level numeric constant offset (e.g. the +5 of x+5) is not - // part of the CCTerm and is recovered by the caller from the term itself, so it is dropped here (as before). final CCParameter res = mConverted.pop(); assert mConverted.isEmpty(); - return res.getCCTerm(); + return res; } private class BuildCCTerm implements Operation { @@ -75,15 +73,17 @@ public BuildCCTerm(final Term term) { @Override public void perform() { - CCTerm ccTerm = mClausifier.getCCTerm(mTerm); + // mCCTerms is keyed by offset-free terms; probe (and below build) the offset-free part, then re-apply the + // constant as the CCParameter's offset (zero when the term is already offset-free). + final Term offsetFree = mClausifier.getOffsetFreeTerm(mTerm); + final CCTerm ccTerm = mClausifier.getCCTerm(offsetFree); if (ccTerm != null) { - pushResult(ccTerm, mClausifier.getTermConstant(mTerm)); + pushResult(CCParameter.of(ccTerm, mClausifier.getTermConstant(mTerm))); } else { final CClosure cclosure = mClausifier.getCClosure(); - final Term offsetFree = mClausifier.getOffsetFreeTerm(mTerm); if (offsetFree != mTerm) { // Numeric term with a non-zero constant: build the offset-free CCTerm and remember the constant. - mOps.push(new MapOffsetFreeTerm(mTerm, mClausifier.getTermConstant(mTerm))); + mOps.push(new AddOffsetToTerm(mClausifier.getTermConstant(mTerm))); mOps.push(new BuildCCTerm(offsetFree)); } else if (Clausifier.needCCTerm(mTerm) && ((ApplicationTerm) mTerm).getParameters().length > 0) { final FunctionSymbol fs = ((ApplicationTerm) mTerm).getFunction(); @@ -98,39 +98,31 @@ public void perform() { } } else { // We have an intern function symbol - ccTerm = cclosure.createAnonTerm(mTerm); - cclosure.addTerm(ccTerm, mTerm); - mClausifier.shareCCTerm(mTerm, ccTerm); + final CCTerm anonTerm = cclosure.createAnonTerm(mTerm); + cclosure.addTerm(anonTerm, mTerm); + mClausifier.shareCCTerm(mTerm, anonTerm); mClausifier.addTermAxioms(mTerm, mSource); - pushResult(ccTerm, Rational.ZERO); + pushResult(anonTerm); } } } } /** - * Maps a numeric term with a non-zero constant to the (already built) CCTerm of its offset-free part, and pushes - * that CCTerm together with the constant offset. + * Adds an offset to the (already built) numeric CCTerm of its offset-free part, + * and pushes the CCParameter with the offset. */ - private class MapOffsetFreeTerm implements Operation { - private final Term mTerm; + private class AddOffsetToTerm implements Operation { private final Rational mOffset; - public MapOffsetFreeTerm(final Term term, final Rational offset) { - mTerm = term; + public AddOffsetToTerm(final Rational offset) { mOffset = offset; } @Override public void perform() { - final CCParameter offsetFreeParam = mConverted.pop(); - assert offsetFreeParam.getOffset().equals(Rational.ZERO) : "offset-free part must have zero offset"; - final CCTerm offsetFreeCCTerm = offsetFreeParam.getCCTerm(); - if (mClausifier.getCCTerm(mTerm) == null) { - mClausifier.shareCCTerm(mTerm, offsetFreeCCTerm); - mClausifier.addTermAxioms(mTerm, mSource); - } - pushResult(offsetFreeCCTerm, mOffset); + final CCTerm offsetFreeParam = (CCTerm) mConverted.pop(); + pushResult(CCParameter.of(offsetFreeParam, mOffset)); } } @@ -159,7 +151,7 @@ public void perform() { mClausifier.shareCCTerm(mAppTerm, ccTerm); mClausifier.addTermAxioms(mAppTerm, mSource); // the application term itself is not numeric-offset; its value is the application - pushResult(ccTerm, Rational.ZERO); + pushResult(ccTerm); } } } \ No newline at end of file diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/Clausifier.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/Clausifier.java index 3e6ec089b..db84ef1c9 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/Clausifier.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/Clausifier.java @@ -71,6 +71,7 @@ import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.bitvector.BvToIntUtils; import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.ArrayTheory; import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CCAppTerm; +import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CCParameter; import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CCTerm; import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CClosure; import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.DTReverseTrigger; @@ -221,10 +222,10 @@ public Clausifier(final Theory theory, final DPLLEngine engine, final ProofMode * The source annotation that is used for auxiliary axioms. * @return the ccterm. */ - public CCTerm createCCTerm(final Term term, final SourceAnnotation source) { + public CCParameter createCCTerm(final Term term, final SourceAnnotation source) { final boolean wasRunning = mIsRunning; mIsRunning = true; - final CCTerm ccterm = new CCTermBuilder(this, source).convert(term); + final CCParameter ccterm = new CCTermBuilder(this, source).convert(term); mIsRunning = wasRunning; if (!wasRunning) { run(); @@ -289,6 +290,10 @@ public void shareCCTerm(final Term term, final CCTerm ccTerm) { } public void addTermAxioms(final Term term, final SourceAnnotation source) { + // Axioms are added for offset-free terms only; an offseted term (x+5) carries its constant structurally and its + // offset-free part (x) gets the axioms. The only callers with user terms (EqualityProxy, createEqualityProxy) + // normalize via getOffsetFreeTerm before calling. + assert getTermConstant(term).equals(Rational.ZERO) : "addTermAxioms on offseted term " + term; final int termFlags = getTermFlags(term); if ((termFlags & Clausifier.AUX_AXIOM_ADDED) == 0) { final boolean wasRunning = mIsRunning; @@ -298,7 +303,7 @@ public void addTermAxioms(final Term term, final SourceAnnotation source) { CCTerm ccTerm = getCCTerm(term); if (ccTerm == null && (needCCTerm(term) || term.getSort().isArraySort())) { final CCTermBuilder cc = new CCTermBuilder(this, source); - ccTerm = cc.convert(term); + ccTerm = (CCTerm) cc.convert(term); } final ApplicationTerm at = (ApplicationTerm) term; @@ -334,7 +339,7 @@ public void addTermAxioms(final Term term, final SourceAnnotation source) { final String funcName = at.getFunction().getName(); final boolean isStore = funcName.equals("store"); final boolean isConst = funcName.equals(SMTLIBConstants.CONST); - mArrayTheory.notifyArray(getCCTerm(term), isStore, isConst); + mArrayTheory.notifyArray(ccTerm, isStore, isConst); } if (fs.isConstructor()) { @@ -358,6 +363,15 @@ public void addTermAxioms(final Term term, final SourceAnnotation source) { } if (term.getSort().isNumericSort()) { + // With offset equalities the CCTerm is offset-free (value 2x+4y for a term 2x+4y+1) and the + // constant is carried structurally at the use sites. The LASharedTerm must then be offset-free + // too, so it stays value-consistent with the offset-free CCTerm: clash-slot MBTC values a member + // as value(rep) + offsetToRep and must not double-count the constant. Terms differing only by a + // constant share one offset-free CCTerm; each still registers its own offset-free LASharedTerm + // (same value), which is harmless and recognized as already-known by the offset-aware guards. + // + // Without offset equalities the LASharedTerm carries the full value (its constant), as before, + // so whole-term mbtc groups shared terms by their true value. boolean needsLA = term instanceof ConstantTerm; if (term instanceof ApplicationTerm) { final String func = ((ApplicationTerm) term).getFunction().getName(); @@ -369,19 +383,10 @@ public void addTermAxioms(final Term term, final SourceAnnotation source) { final MutableAffineTerm mat = createMutableAffinTerm(new Polynomial(term), source); assert mat.getConstant().mEps == 0; if (!mLATerms.containsKey(term)) { - // The LASharedTerm shares the term's value with linear arithmetic. - // - // With offset equalities the CCTerm is offset-free (value 2x+4y for a term 2x+4y+1) and the - // constant is carried structurally at the use sites. The LASharedTerm must then be offset-free - // too, so it stays value-consistent with the offset-free CCTerm: clash-slot MBTC values a member - // as value(rep) + offsetToRep and must not double-count the constant. Terms differing only by a - // constant share one offset-free CCTerm; each still registers its own offset-free LASharedTerm - // (same value), which is harmless and recognized as already-known by the offset-aware guards. - // - // Without offset equalities the LASharedTerm carries the full value (its constant), as before, - // so whole-term mbtc groups shared terms by their true value. - final Rational laOffset = createOffsetEqualities() ? Rational.ZERO : mat.getConstant().mReal; - shareLATerm(term, new LASharedTerm(term, mat.getSummands(), laOffset)); + // The LASharedTerm shares the term's value with linear arithmetic. The term is offset-free (see + // the precondition), so this stays consistent with getCCTerm and the endScope unshare loop, which + // look up CC nodes by the (offset-free) key. + shareLATerm(term, new LASharedTerm(term, mat.getSummands(), mat.getConstant().mReal)); } } } @@ -587,6 +592,10 @@ public MutableAffineTerm toMutableAffineTerm(final Polynomial poly) { } public CCTerm getCCTerm(final Term term) { + // mCCTerms is keyed by offset-free terms only; an offseted term has no entry of its own (its CCParameter, with + // the constant as offset, is produced at build time by createCCTerm). Callers must normalize via + // getOffsetFreeTerm first. + assert getTermConstant(term).equals(Rational.ZERO) : "getCCTerm on offseted term " + term; return mCCTerms.get(term); } @@ -617,11 +626,6 @@ public Rational getTermConstant(final Term term) { return new Polynomial(term).getConstant(); } - /** - * The offset-free part of a numeric term, i.e. the term with its constant summand removed. Two terms that differ - * only by a constant (e.g. {@code 2x+4y+1} and {@code 2x+4y+5}) yield the same canonical offset-free term, so they - * share a single CCTerm. Returns the term itself when it has no constant or offset equalities are disabled. - */ /** * Build the normalized term for {@code term + constant}. The result is a single flattened polynomial term (not a * nested {@code (+ term constant)}), so that re-parsing it as a Polynomial recovers all summands. @@ -632,6 +636,13 @@ public Term addConstantToTerm(final Term term, final Rational constant) { return mCompiler.unifyPolynomial(poly, term.getSort()); } + /** + * The offset-free part of a numeric term, i.e. the term with its constant + * summand removed. Two terms that differ only by a constant (e.g. + * {@code 2x+4y+1} and {@code 2x+4y+5}) yield the same canonical offset-free + * term, so they share a single CCTerm. Returns the term itself when it has no + * constant or offset equalities are disabled. + */ public Term getOffsetFreeTerm(final Term term) { if (!createOffsetEqualities() || !term.getSort().isNumericSort()) { return term; @@ -1364,8 +1375,13 @@ public EqualityProxy createEqualityProxy(final Term lhs, final Term rhs, final S if (sort.getName().equals("Int") && !diff.getConstant().isIntegral()) { return EqualityProxy.getFalseProxy(); } - addTermAxioms(lhs, source); - addTermAxioms(rhs, source); + // The proxy works on the offset-free sides plus the offset (value(offLhs) == value(offRhs) + offset). The + // offset is the difference of the two sides' constants. + final Term offLhs = getOffsetFreeTerm(lhs); + final Term offRhs = getOffsetFreeTerm(rhs); + final Rational offset = getTermConstant(rhs).sub(getTermConstant(lhs)); + addTermAxioms(offLhs, source); + addTermAxioms(offRhs, source); // we cannot really normalize the sign of the term. Try both signs. EqualityProxy eqForm = mEqualities.get(diff); if (eqForm != null) { @@ -1376,7 +1392,7 @@ public EqualityProxy createEqualityProxy(final Term lhs, final Term rhs, final S if (eqForm != null) { return eqForm; } - eqForm = new EqualityProxy(this, lhs, rhs); + eqForm = new EqualityProxy(this, offLhs, offRhs, offset); mEqualities.put(diff, eqForm); return eqForm; } diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/EqualityProxy.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/EqualityProxy.java index be59bd335..5e9b19f28 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/EqualityProxy.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/EqualityProxy.java @@ -36,7 +36,7 @@ public class EqualityProxy { */ public static final class TrueEqualityProxy extends EqualityProxy { private TrueEqualityProxy() { - super(null, null, null); + super(null, null, null, null); } @Override public DPLLAtom getLiteral(final SourceAnnotation source) { @@ -49,7 +49,7 @@ public DPLLAtom getLiteral(final SourceAnnotation source) { */ public static final class FalseEqualityProxy extends EqualityProxy { private FalseEqualityProxy() { - super(null, null, null); + super(null, null, null, null); } @Override public DPLLAtom getLiteral(final SourceAnnotation source) { @@ -69,21 +69,31 @@ public static FalseEqualityProxy getFalseProxy() { } final Clausifier mClausifier; + /** The offset-free sides and the constant offset: this proxy states {@code value(mLhs) == value(mRhs) + mOffset}. */ final Term mLhs, mRhs; + final Rational mOffset; - public EqualityProxy(final Clausifier clausifier, final Term lhs, final Term rhs) { + public EqualityProxy(final Clausifier clausifier, final Term lhs, final Term rhs, final Rational offset) { mClausifier = clausifier; mLhs = lhs; mRhs = rhs; + mOffset = offset; } public LAEquality createLAEquality() { - /* create la part */ + // The proxy's canonical equality as a linear constraint: mLhs - mRhs - mOffset == 0. final Polynomial affine = new Polynomial(mLhs); affine.add(Rational.MONE, mRhs); + affine.add(mOffset.negate()); return mClausifier.getLASolver().createEquality(mClausifier.createMutableAffinTerm(affine, null)); } + /** + * The norm factor for a CCEquality between {@code lhs} and {@code rhs}: it relates the CCEquality to the proxy's + * shared LAEquality via {@code factor * (lhs - rhs) == laeq.getVar()}. It is per CCEquality, not per proxy, since + * equivalent equalities can be scaled (e.g. {@code 2x = 2y+4} vs {@code x = y+2} share a proxy but need factors 1/2 + * and 1). The constant offset does not affect the gcd, so it is irrelevant here. + */ public Rational computeNormFactor(final Term lhs, final Term rhs) { final Polynomial affine = new Polynomial(lhs); affine.add(Rational.MONE, rhs); @@ -104,8 +114,19 @@ public Rational computeNormFactor(final Term lhs, final Term rhs) { */ public CCEquality createCCEquality(final Term lhs, final Term rhs) { assert lhs.getSort().isNumericSort(); - final CCTerm ccLhs = mClausifier.getCCTerm(lhs); - final CCTerm ccRhs = mClausifier.getCCTerm(rhs); + // Resolve the offset-free CC nodes and the offset (the difference of the sides' constants); both are per call, + // not the proxy's, since this lhs/rhs may be a scaled equivalent of the proxy's canonical equality. + return createCCEquality(mClausifier.getCCTerm(mClausifier.getOffsetFreeTerm(lhs)), + mClausifier.getCCTerm(mClausifier.getOffsetFreeTerm(rhs)), + mClausifier.getTermConstant(rhs).sub(mClausifier.getTermConstant(lhs))); + } + + /** + * Create a CCEquality {@code value(ccLhs) == value(ccRhs) + offset} linked to this proxy's LAEquality. The offset + * and norm factor are derived from {@code ccLhs}/{@code ccRhs}/{@code offset} (not from the proxy's canonical + * {@link #mOffset}), because equivalent equalities can be scaled and share this proxy at different offsets. + */ + public CCEquality createCCEquality(final CCTerm ccLhs, final CCTerm ccRhs, final Rational offset) { assert ccLhs != null && ccRhs != null; final DPLLAtom eqAtom = getLiteral(null); LAEquality laeq; @@ -113,16 +134,13 @@ public CCEquality createCCEquality(final Term lhs, final Term rhs) { final CCEquality eq = (CCEquality) eqAtom; laeq = eq.getLASharedData(); if (laeq == null) { - final Rational normFactor = computeNormFactor(mLhs, mRhs); laeq = createLAEquality(); laeq.addDependentAtom(eq); - eq.setLASharedData(laeq, normFactor); + eq.setLASharedData(laeq, computeNormFactor(eq.getLhs().getFlatTerm(), eq.getRhs().getFlatTerm())); } } else { laeq = (LAEquality) eqAtom; } - // offset such that value(ccLhs) == value(ccRhs) + offset, i.e. the difference of the two terms' constants. - final Rational offset = mClausifier.getTermConstant(rhs).sub(mClausifier.getTermConstant(lhs)); for (final CCEquality eq : laeq.getDependentEqualities()) { assert (eq.getLASharedData() == laeq); // The offset must match too: two CCEqualities between the same pair of CCTerms but with different offsets @@ -137,13 +155,14 @@ public CCEquality createCCEquality(final Term lhs, final Term rhs) { } final CCEquality eq = mClausifier.getCClosure().createCCEquality(mClausifier.getStackLevel(), ccLhs, ccRhs, offset); - final Rational normFactor = computeNormFactor(lhs, rhs); laeq.addDependentAtom(eq); - eq.setLASharedData(laeq, normFactor); + eq.setLASharedData(laeq, computeNormFactor(ccLhs.getFlatTerm(), ccRhs.getFlatTerm())); return eq; } private DPLLAtom createAtom(final Term eqTerm, final SourceAnnotation source) { + // mLhs/mRhs are offset-free, so axioms, existence probes and LinVar creation operate directly on them; the + // offset (mOffset) is reintroduced when the CCEquality is built below. mClausifier.addTermAxioms(mLhs, source); mClausifier.addTermAxioms(mRhs, source); @@ -172,8 +191,8 @@ private DPLLAtom createAtom(final Term eqTerm, final SourceAnnotation source) { return createLAEquality(); } else { /* let them share congruence closure */ - final CCTerm ccLhs = mClausifier.createCCTerm(mLhs, source); - final CCTerm ccRhs = mClausifier.createCCTerm(mRhs, source); + final CCTerm ccLhs = mClausifier.createCCTerm(mLhs, source).getCCTerm(); + final CCTerm ccRhs = mClausifier.createCCTerm(mRhs, source).getCCTerm(); /* Creating the CC terms could have created the equality */ final DPLLAtom atom = (DPLLAtom) mClausifier.getILiteral(eqTerm); @@ -182,9 +201,8 @@ private DPLLAtom createAtom(final Term eqTerm, final SourceAnnotation source) { } /* create CC equality */ - final Rational offset = mClausifier.getTermConstant(mRhs).sub(mClausifier.getTermConstant(mLhs)); final CCEquality cceq = - mClausifier.getCClosure().createCCEquality(mClausifier.getStackLevel(), ccLhs, ccRhs, offset); + mClausifier.getCClosure().createCCEquality(mClausifier.getStackLevel(), ccLhs, ccRhs, mOffset); if (mClausifier.createOffsetEqualities() && mLhs.getSort().isNumericSort()) { // With offset equalities, clash-slot MBTC only creates LAEqualities for shared terms that occupy a // function-argument position; a numeric (dis)equality between other shared terms (e.g. two selector or @@ -201,7 +219,11 @@ private DPLLAtom createAtom(final Term eqTerm, final SourceAnnotation source) { } public DPLLAtom getLiteral(final SourceAnnotation source) { - final Term eqTerm = mLhs.getTheory().term("=", mLhs, mRhs); + // Reconstruct the offset-aware equality term (mLhs == mRhs + mOffset) as the literal/flag key, so proxies that + // differ only by their offset get distinct atoms (mirrors CCEquality.getSMTFormula). + final Term rhsWithOffset = + mOffset.equals(Rational.ZERO) ? mRhs : mClausifier.addConstantToTerm(mRhs, mOffset); + final Term eqTerm = mLhs.getTheory().term("=", mLhs, rhsWithOffset); DPLLAtom lit = (DPLLAtom) mClausifier.getILiteral(eqTerm); if (lit == null) { lit = createAtom(eqTerm, source); @@ -221,6 +243,9 @@ public String toString() { pt.append(sb, mLhs); sb.append(" == "); pt.append(sb, mRhs); + if (!mOffset.equals(Rational.ZERO)) { + sb.append(" + ").append(mOffset); + } return sb.toString(); } diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/model/ModelProver.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/model/ModelProver.java index f18f2a1bc..9ef90a6c6 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/model/ModelProver.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/model/ModelProver.java @@ -948,7 +948,7 @@ private Term interpret(Term origTerm, final FunctionSymbol fs, final Term[] args } case SMTLIBConstants.CONST: - return theory.term(fs, args[0]); + return annotateProof(mProofRules.refl(funcTerm), funcTerm); case SMTLIBConstants.SELECT: { // we assume that the array parameter is a term of the form diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java index 99a4de2e1..996ff9e21 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java @@ -1212,10 +1212,6 @@ public CCEquality createEquality(final CCTerm t1, final CCTerm t2, final Rationa if (ep == EqualityProxy.getFalseProxy()) { return null; } - if (!offset.equals(Rational.ZERO) && mClausifier.getCCTerm(rhsTerm) == null) { - // the offset term t2+offset is offset-free-equivalent to t2; map it to the same CCTerm. - mClausifier.shareCCTerm(rhsTerm, t2); - } if (!createLAEquality) { final Literal res = ep.getLiteral(null); if (res instanceof CCEquality) { @@ -1225,7 +1221,9 @@ public CCEquality createEquality(final CCTerm t1, final CCTerm t2, final Rationa } } } - return ep.createCCEquality(lhsTerm, rhsTerm); + // t1 and t2 are the offset-free CC nodes; pass them with this call's offset (which may differ from the proxy's + // canonical offset for a scaled equivalent) rather than round-tripping through the synthesized rhsTerm. + return ep.createCCEquality(t1, t2, offset); } @Override diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/DataTypeTheory.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/DataTypeTheory.java index 354299296..10ef75d55 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/DataTypeTheory.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/DataTypeTheory.java @@ -35,8 +35,8 @@ import de.uni_freiburg.informatik.ultimate.logic.ApplicationTerm; import de.uni_freiburg.informatik.ultimate.logic.DataType; import de.uni_freiburg.informatik.ultimate.logic.DataType.Constructor; -import de.uni_freiburg.informatik.ultimate.logic.Rational; import de.uni_freiburg.informatik.ultimate.logic.FunctionSymbol; +import de.uni_freiburg.informatik.ultimate.logic.Rational; import de.uni_freiburg.informatik.ultimate.logic.SMTLIBConstants; import de.uni_freiburg.informatik.ultimate.logic.Sort; import de.uni_freiburg.informatik.ultimate.logic.SortSymbol; @@ -562,7 +562,7 @@ private List getAllDataTypeChildren(final CCTerm ccTerm, final Map selectors = new LinkedHashSet<>(); FunctionSymbol trueTester = null; final Set falseTesters = new LinkedHashSet<>(); @@ -875,7 +875,7 @@ private void addConstructorLemma(final CCAppTerm isTerm) { // create a new constructor application like C(s1(x), s2(x), ..., sm(x)) final Sort consType = c.needsReturnOverload() ? dtTerm.getSort() : null; final Term consTerm = mTheory.term(consName, null, consType, selectorTerms); - final CCTerm consCCTerm = mClausifier.createCCTerm(consTerm, SourceAnnotation.EMPTY_SOURCE_ANNOT); + final CCTerm consCCTerm = (CCTerm) mClausifier.createCCTerm(consTerm, SourceAnnotation.EMPTY_SOURCE_ANNOT); final SymmetricPair eq = new SymmetricPair<>(arg, consCCTerm); @SuppressWarnings("unchecked") final DataTypeLemma lemma = new DataTypeLemma(RuleKind.DT_CONSTRUCTOR, eq, diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/linar/LinArSolve.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/linar/LinArSolve.java index 8d0e583f5..766612ed8 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/linar/LinArSolve.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/linar/LinArSolve.java @@ -59,6 +59,7 @@ import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CCEquality; import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CCParameter; import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CCTerm; +import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CClosure; import de.uni_freiburg.informatik.ultimate.smtinterpol.util.ScopedArrayList; import de.uni_freiburg.informatik.ultimate.smtinterpol.util.SymmetricPair; import de.uni_freiburg.informatik.ultimate.util.datastructures.ScopedHashMap; @@ -1345,11 +1346,9 @@ public Clause propagateSharedEquality(LASharedTerm lhs, LASharedTerm rhs, Ration assert mDirty.get(var.mMatrixpos); return null; } - if (!offset.equals(Rational.ZERO) && mClausifier.getCCTerm(rhsTerm) == null) { - // the synthesized term rhs + offset is offset-free-equivalent to rhs; map it to the same CCTerm. - mClausifier.shareCCTerm(rhsTerm, mClausifier.getCCTerm(rhs.getTerm())); - } - final CCEquality cceq = eq.createCCEquality(lhs.getTerm(), rhsTerm); + // The CCEquality is between the offset-free CCTerms of lhs and rhs at the known offset; the synthesized + // rhsTerm above only carries the offset for the EqualityProxy/atom identity, not the CC node lookup. + final CCEquality cceq = eq.createCCEquality(lhsCCTerm, rhsCCTerm, offset); final LAEquality laeq = cceq.getLASharedData(); mClausifier.getLogger().debug("Propagate: %s (laeq: %s) %s %s", cceq, laeq, cceq.getDecideStatus(), laeq.getDecideStatus()); diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/QuantClause.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/QuantClause.java index 4acbe2fe3..fc1d8a052 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/QuantClause.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/QuantClause.java @@ -41,6 +41,7 @@ import de.uni_freiburg.informatik.ultimate.smtinterpol.proof.SourceAnnotation; import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.ArrayTheory; import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CCAppTerm; +import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CCParameter; import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CCTerm; import de.uni_freiburg.informatik.ultimate.smtinterpol.util.Polynomial; @@ -450,7 +451,7 @@ private void updateInterestingTermsForFuncArgs(final TermVariable var, final int final FunctionSymbol func = entry.getKey(); final BitSet pos = entry.getValue(); for (int i = pos.nextSetBit(0); i >= 0; i = pos.nextSetBit(i + 1)) { - for (CCAppTerm appTerm : mQuantTheory.getCClosure().getAllFuncApps(func)) { + for (final CCAppTerm appTerm : mQuantTheory.getCClosure().getAllFuncApps(func)) { interestingTerms.add(appTerm.getArgParam(i).getCCTerm().getFlatTerm()); } } @@ -482,7 +483,7 @@ private void updateInterestingTermsForFuncArgs(final TermVariable var, final int final CCTerm stArr = ArrayTheory.getArrayFromStore(st); if (weakRep == null ? stArr.getFlatTerm().getSort() == array.getSort() : weakRep == mQuantTheory.getClausifier().getArrayTheory().getWeakRep(stArr)) { - final Term indexTerm = ArrayTheory.getIndexFromStore((CCAppTerm) st).getFlatTerm(); + final Term indexTerm = ArrayTheory.getIndexFromStore(st).getFlatTerm(); interestingTerms.add(indexTerm); if (funcName == "select" && var.getSort().getName() == "Int" && !QuantUtil.isLambda(indexTerm)) { @@ -506,8 +507,9 @@ private void updateInterestingTermsForFuncArgs(final TermVariable var, final int final CCTerm selArr = ArrayTheory.getArrayFromSelect(sel); if (weakRep == null ? selArr.getFlatTerm().getSort() == array.getSort() : weakRep == mQuantTheory.getClausifier().getArrayTheory().getWeakRep(selArr)) { - final CCTerm index = ArrayTheory.getIndexFromSelect((CCAppTerm) sel); - interestingTerms.add(index.getFlatTerm()); + final CCParameter index = ArrayTheory.getIndexFromSelect(sel); + // TODO: add offset + interestingTerms.add(index.getCCTerm().getFlatTerm()); } } } diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/PatternCompiler.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/PatternCompiler.java index 12ccf0e2c..781ef4391 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/PatternCompiler.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/PatternCompiler.java @@ -100,7 +100,8 @@ private void collectTermInfos(final Term term) { } if (term.getFreeVars().length == 0) { final Clausifier clausifier = mEMatching.getQuantTheory().getClausifier(); - info.mGroundTerm = clausifier.createCCTerm(term, mQuantAtom.getClause().getSource()); + // Offsets are disabled under quantifiers, so the built CCParameter is always offset-free (a CCTerm). + info.mGroundTerm = (CCTerm) clausifier.createCCTerm(term, mQuantAtom.getClause().getSource()); } else if (!(term instanceof TermVariable)) { assert term instanceof ApplicationTerm; final Term[] args = ((ApplicationTerm) term).getParameters(); diff --git a/SMTInterpolTest/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruentAddTest.java b/SMTInterpolTest/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruentAddTest.java index cda6a256f..f114e6437 100644 --- a/SMTInterpolTest/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruentAddTest.java +++ b/SMTInterpolTest/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruentAddTest.java @@ -89,7 +89,7 @@ private void createterms() { for (int i = 0; i < 6; ++i) {// NOCHECKSTYLE final String xi = "x" + i; mTheory.declareFunction(xi, Script.EMPTY_SORT_ARRAY, sort); - mTerms[i] = mClausifier.createCCTerm(mTheory.term(xi), null); + mTerms[i] = (CCTerm) mClausifier.createCCTerm(mTheory.term(xi), null); } mTheory.declareFunction("d", Script.EMPTY_SORT_ARRAY, sort); mTheory.declareFunction("c", Script.EMPTY_SORT_ARRAY, sort); @@ -99,10 +99,10 @@ private void createterms() { final Term termc = mTheory.term("c"); final Term termb = mTheory.term("b"); final Term terma = mTheory.term("a"); - mD = mClausifier.createCCTerm(termd, null); - mC = mClausifier.createCCTerm(termc, null); - mB = mClausifier.createCCTerm(termb, null); - mA = mClausifier.createCCTerm(terma, null); + mD = (CCTerm) mClausifier.createCCTerm(termd, null); + mC = (CCTerm) mClausifier.createCCTerm(termc, null); + mB = (CCTerm) mClausifier.createCCTerm(termb, null); + mA = (CCTerm) mClausifier.createCCTerm(terma, null); final EqualityProxy eqab = mClausifier.createEqualityProxy(terma, termb, mSource); Assert.assertNotSame(EqualityProxy.getTrueProxy(), eqab); Assert.assertNotSame(EqualityProxy.getFalseProxy(), eqab); From 811e201385bce721b3e0e62ab448dbd18081fd4a Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Sat, 27 Jun 2026 00:01:52 +0200 Subject: [PATCH 59/75] CC offset equalities: order subpaths after their congruence dependencies drainTodo keeps mAllPaths in the order the proof generator needs (a path precedes the paths explaining its congruences) via a re-enqueue discipline: a freshly seen pair takes the path==null branch, which re-enqueues it behind the dependencies that computePathNonRecursive pushes to the front, so the dependencies are collected first. The only way to bypass that branch is to find the path already cached in mVisited on its first pop. That is exactly what happened for inline grafts. computePathNonRecursive cached every path it built in mVisited, including the strong paths built directly (outside the drain) for splicing into a weak/store path (WeakCongruencePath.collectPathOnePrimary/collectPathPrimary, computeMergeConflictCycle). When such an edge was later requested as a standalone subpath, drainTodo found it already cached and collected it immediately -- ahead of its still-pending dependencies, which addFirst then placed before it. The symptom was an array read-over-weakeq lemma whose (as const) congruence path appeared before the select-congruence path that explains it. Fix: make computePathNonRecursive a pure builder and move the mVisited caching into drainTodo, which already does the lookup. Inline grafts are then not cached, so a standalone request for the same edge rebuilds it through the drain and is collected after its dependencies. The graft's dependencies are enqueued either way, so the inlined congruences are still explained. (A full switch to CCParameter path nodes, to handle cross-class offsets uniformly, is left as a follow-up.) Validated: test/proof 98/98, array/abv proof-check clean, ProofSimplifier/ProofUtils/RPI/CongruentAdd/PairHash green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../theory/cclosure/CongruencePath.java | 41 +++++++++++++------ .../theory/cclosure/WeakCongruencePath.java | 13 +++--- 2 files changed, 36 insertions(+), 18 deletions(-) diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java index 686c0d919..300a8e23d 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CongruencePath.java @@ -273,6 +273,15 @@ private SubPath computePathTo(final CCParameter startParam, final CCTerm end) { * @param tailNr this gives the (last) formula number of the equality after right. * @param left the left end of the congruence chain that should be evaluated. * @param right the right end of the congruence chain that should be evaluated. + * @return the built path, or {@code null} for a trivial path ({@code left} and {@code right} share the same term). + * + *

This is a pure builder: it does not consult or update the {@link #mVisited} cache. {@link #drainTodo} owns the + * cache — it looks up {@link #mVisited} before calling and stores the result afterwards, so a path requested as a + * standalone subpath is built once and collected through the re-enqueue discipline. Inline grafts (built directly, + * outside the drain, to be spliced into a weak/store path) call this without caching: a later standalone request for + * the same edge therefore rebuilds it through the drain rather than short-circuiting to the already-visited branch + * of {@link #drainTodo} (which would collect it ahead of its still-pending congruence dependencies). The graft's + * dependencies are enqueued either way, so the inlined congruences are still explained. */ SubPath computePathNonRecursive(final CCParameter left, final CCParameter right) { final CCTerm leftTerm = left.getCCTerm(); @@ -282,11 +291,6 @@ SubPath computePathNonRecursive(final CCParameter left, final CCParameter right) return null; } - final SymmetricPair key = new SymmetricPair<>(leftTerm, rightTerm); - if (mVisited.containsKey(key)) { - return mVisited.get(key); - } - int leftDepth = computeDepth(leftTerm); int rightDepth = computeDepth(rightTerm); CCTerm ll = leftTerm; @@ -324,7 +328,6 @@ SubPath computePathNonRecursive(final CCParameter left, final CCParameter right) } final SubPath pathBack = computePathTo(right, rrWithReason); path.addSubPath(pathBack); - mVisited.put(key, path); return path; } @@ -359,9 +362,17 @@ public void computePath(final CCParameter left, final CCParameter right) { * {@link #mAllPaths} (and its literals into {@link #mAllLiterals}). The top-level compute*Cycle/Lemma methods seed * the work list via {@link #computePath} (a single pair) and {@link #computeCongruence} (argument pairs), then call * this once. {@link WeakCongruencePath} drains once per weak/main path (a strong path to be inlined into a weak path - * is instead built directly via {@link #computePathNonRecursive}, which returns it without adding it to - * {@link #mAllPaths}), hence protected. Dedup against {@link #mCollected} is persistent, so a subpath shared between - * drains is appended only once. + * is instead built directly via {@link #computePathNonRecursive} with {@code store == false}, which returns it + * without adding it to {@link #mVisited} or {@link #mAllPaths}), hence protected. Dedup against {@link #mCollected} + * is persistent, so a subpath shared between drains is appended only once. + * + *

Ordering invariant: a path is collected (appended to {@link #mAllPaths}) only after its congruence + * dependencies, so it precedes the paths explaining its congruences (as the proof generator requires). This holds + * because a freshly seen path takes the {@code path == null} branch: it re-enqueues itself behind the + * dependencies that {@link #computePathNonRecursive} pushes to the front, so those are collected first. The only way + * to bypass this would be to find the path already in {@link #mVisited} on its first pop — which is exactly why + * inline grafts are built with {@code store == false}: a standalone request for the same edge then rebuilds it + * through this branch instead of short-circuiting to the already-collected branch ahead of its dependencies. */ protected void drainTodo() { while (!mTodo.isEmpty()) { @@ -375,9 +386,11 @@ protected void drainTodo() { // check if we already visited this path (keyed offset-free, so offset variants share one subpath) final SubPath path = mVisited.get(pathEnds); if (path == null) { - // if we did not visit it yet, enqueue again for later and visit the path + // if we did not visit it yet, enqueue again for later, build the path and cache it. drainTodo owns the + // mVisited cache; computePathNonRecursive is a pure builder. The pair is non-trivial (checked above), so + // the build never returns null here. mTodo.addFirst(pathEnds); - computePathNonRecursive(pathEnds.getFirst(), pathEnds.getSecond()); + mVisited.put(pathEnds, computePathNonRecursive(pathEnds.getFirst(), pathEnds.getSecond())); } else { // already visited it, so we just add the path now unless we did this earlier if (mCollected.add(pathEnds)) { @@ -470,8 +483,10 @@ public Clause computeMergeConflictCycle(final CCTerm lhs, final CCTerm rhs, fina assert offset.equals(Rational.ZERO); computeCongruence((CCAppTerm) lhs, (CCAppTerm) rhs); } - // Two single-class paths for lhs and rhs, each offset-correct on its own. These - // are merged later to a single main path. + // Two single-class paths for lhs and rhs, each offset-correct on its own. These are merged later by hand into a + // single main path, so they are not standalone subpaths: computePathNonRecursive builds them without caching in + // mVisited, so they neither enter mAllPaths nor short-circuit a later standalone request for the same edge + // (their congruence dependencies are still enqueued and collected through the drain below). final SubPath segSrc = computePathNonRecursive(lhsDiseq, lhs); final SubPath segDest = computePathNonRecursive(rhs, rhsDiseq); drainTodo(); diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/WeakCongruencePath.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/WeakCongruencePath.java index ba6590d7c..712ad1259 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/WeakCongruencePath.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/WeakCongruencePath.java @@ -277,9 +277,11 @@ public void collectPathOnePrimary(final Cursor cursor, final SubPath path, final assert t1.mRepStar == node.mTerm; assert t2.mRepStar == node.mPrimaryEdge.mTerm; if (cursor.mTerm != t1) { - // Build the strong path directly and inline it into the weak path. computePathNonRecursive returns the - // SubPath (without adding it to mAllPaths, since it is inlined here, not a standalone subpath) and enqueues - // its congruence dependencies for the enclosing lemma's drainTodo to collect. + // Build the strong path directly and inline it into the weak path. computePathNonRecursive is a pure + // builder: the result is not cached in mVisited (only drainTodo caches), so it is inlined here rather than + // becoming a standalone subpath, while its congruence dependencies are enqueued for the enclosing lemma's + // drainTodo to collect. Not caching it lets a later standalone request for the same edge rebuild it through + // the drain, so it is collected after its dependencies. path.addSubPath(computePathNonRecursive(cursor.mTerm, t1)); } path.addEntry(t2, null); @@ -315,8 +317,9 @@ private SubPath collectPathPrimary(final Cursor start, final Cursor dest, final collectPathOnePrimary(dest, path2, storeIndices); } if (start.mTerm != dest.mTerm) { - // Build the strong path directly and inline it (see collectPathOnePrimary): computePathNonRecursive returns - // the SubPath without adding it to mAllPaths and enqueues its dependencies for the enclosing drainTodo. + // Build the strong path directly and inline it (see collectPathOnePrimary): computePathNonRecursive is a + // pure builder whose result is not cached in mVisited, so it is inlined here rather than becoming a + // standalone subpath, while its dependencies are enqueued for the enclosing drainTodo. path1.addSubPath(computePathNonRecursive(start.mTerm, dest.mTerm)); } path1.addSubPath(path2); From 14e58e042d6c09d99f2ac6cfb8cf2572bdd9eb54 Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Sat, 4 Jul 2026 11:33:50 +0200 Subject: [PATCH 60/75] Handle Offset Equalities in all CC/Array/DT-Lemmas Hash them by OffsetEqKey instead of symmetric pair. The resolveEqualities still checks for the simple identity or symm case and only uses the farkas lemma when needed. --- .../smtinterpol/proof/ProofSimplifier.java | 248 ++++++++---------- 1 file changed, 106 insertions(+), 142 deletions(-) diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/proof/ProofSimplifier.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/proof/ProofSimplifier.java index d3b107179..3b089bc30 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/proof/ProofSimplifier.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/proof/ProofSimplifier.java @@ -22,7 +22,6 @@ import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; @@ -58,7 +57,6 @@ import de.uni_freiburg.informatik.ultimate.smtinterpol.proof.resolute.ProofRules; import de.uni_freiburg.informatik.ultimate.smtinterpol.smtlib2.SMTInterpol; import de.uni_freiburg.informatik.ultimate.smtinterpol.util.Polynomial; -import de.uni_freiburg.informatik.ultimate.smtinterpol.util.SymmetricPair; /** * This class explains an SMTInterpol proof with oracles using the low-level @@ -3117,20 +3115,17 @@ private Term convertEQLemma(final ProofLiteral[] clause) { * @param equalities HashMap to store equalities (negated in the clause). * @param disequalities HashMap to store disequalities (positive in the clause). */ - private void collectEqualities(final ProofLiteral[] clause, final HashMap, Term> equalities, - final HashMap, Term> disequalities) { + private void collectEqualities(final ProofLiteral[] clause, final HashMap equalities, + final HashMap disequalities) { for (final ProofLiteral literal : clause) { final Term atom = literal.getAtom(); assert isApplication("=", atom); final Term[] sides = ((ApplicationTerm) atom).getParameters(); - assert sides.length == 2; - if (literal.getPolarity()) { - // positive in clause -> disequality in conflict - disequalities.put(new SymmetricPair<>(sides[0], sides[1]), atom); - } else { - // negated atom in clause -> equality in conflict - equalities.put(new SymmetricPair<>(sides[0], sides[1]), atom); - } + final OffsetEqKey key = new OffsetEqKey(sides[0], sides[1]); + + // positive in clause -> disequality in conflict -> collect into disequalities + // negative in clause -> equality in conflict -> collect into equalities + (literal.getPolarity() ? disequalities : equalities).put(key, atom); } } @@ -3147,8 +3142,8 @@ private Term convertCCLemma(final ProofLiteral[] clause, String lemmaType, final final Theory theory = mainPath[0].getTheory(); /* collect literals and search for the disequality */ - final HashMap, Term> allEqualities = new HashMap<>(); - final HashMap, Term> allDisequalities = new HashMap<>(); + final HashMap allEqualities = new HashMap<>(); + final HashMap allDisequalities = new HashMap<>(); collectEqualities(clause, allEqualities, allDisequalities); assert allDisequalities.size() <= 1; @@ -3201,7 +3196,7 @@ private Term convertCCLemma(final ProofLiteral[] clause, String lemmaType, final * neededEqualities. */ private Term proveSelectConst(final Term value, final Term array, final Term weakIdx, - final Set> allEqualities, final Set neededEqualities) { + final Map allEqualities, final Set neededEqualities) { final Theory theory = value.getTheory(); // Check if value is (select array idx2) with (weakIdx = idx2) in equalities or // syntactically equal. @@ -3211,7 +3206,7 @@ private Term proveSelectConst(final Term value, final Term array, final Term wea if (args[1] == weakIdx) { return mProofRules.refl(value); } - if (allEqualities.contains(new SymmetricPair<>(weakIdx, args[1]))) { + if (allEqualities.containsKey(new OffsetEqKey(weakIdx, args[1]))) { neededEqualities.add(theory.term(SMTLIBConstants.EQUALS, array, array)); neededEqualities.add(theory.term(SMTLIBConstants.EQUALS, weakIdx, args[1])); return mProofRules.cong(theory.term(SMTLIBConstants.SELECT, array, weakIdx), value); @@ -3294,15 +3289,16 @@ private Term proveSelectPathTrans(final Term arrayLeft, final Term value1, final * store step. */ private Term proveSelectPath(final Term arrayLeft, final Term arrayRight, final Term weakIdx, - final Set> allEqualities, final Set neededEqualities) { - for (final SymmetricPair candidateEquality : allEqualities) { + final Map allEqualities, final Set neededEqualities) { + for (final Term candidateEquality : allEqualities.values()) { // Check for each candidate equality if it explains a select edge for a // weakeq-ext lemma. // We check if termPair.first[weakIdx]] equals one side of the equality and // termPair.second[weakIdx] // equals the other side. - final Term first = candidateEquality.getFirst(); - final Term second = candidateEquality.getSecond(); + final Term[] sides = ((ApplicationTerm) candidateEquality).getParameters(); + final Term first = sides[0]; + final Term second = sides[1]; Term eq1 = proveSelectConst(first, arrayLeft, weakIdx, allEqualities, neededEqualities); Term eq2 = proveSelectConst(second, arrayRight, weakIdx, allEqualities, neededEqualities); if (eq1 != null && eq2 != null) { @@ -3353,13 +3349,13 @@ private Term proveSelectPath(final Term arrayLeft, final Term arrayRight, final * to neededDisequalities. It returns null if this is not a store step. */ private Term proveStoreStep(final Term arrayLeft, final Term arrayRight, final Term weakIdx, - final Set> disequalities, final Set neededDisequalities) { + final Map disequalities, final Set neededDisequalities) { if (isApplication("store", arrayLeft)) { final Term[] storeArgs = ((ApplicationTerm) arrayLeft).getParameters(); if (storeArgs[0] == arrayRight) { // this is a step from a to (store a storeIndex v). Check if storeIndex is okay. final Term storeIdx = ((ApplicationTerm) arrayLeft).getParameters()[1]; - if (disequalities.contains(new SymmetricPair<>(weakIdx, storeIdx))) { + if (disequalities.containsKey(new OffsetEqKey(weakIdx, storeIdx))) { final Term storeVal = ((ApplicationTerm) arrayLeft).getParameters()[2]; final Theory theory = arrayLeft.getTheory(); neededDisequalities.add(theory.term(SMTLIBConstants.EQUALS, storeIdx, weakIdx)); @@ -3392,12 +3388,12 @@ private Term proveStoreStep(final Term arrayLeft, final Term arrayRight, final T * which case they are added to the needed(Dis)Equalities set. */ private Term proveSelectOverPathStep(final Term arrayLeft, final Term arrayRight, final Term weakIdx, - final Term selectLeft, final Term selectRight, final Set> equalities, - final Set> disequalities, final Set neededEqualities, + final Term selectLeft, final Term selectRight, final Map equalities, + final Map disequalities, final Set neededEqualities, final Set neededDisequalities) { final Theory theory = arrayLeft.getTheory(); /* check for strong path first */ - if (equalities.contains(new SymmetricPair<>(arrayLeft, arrayRight))) { + if (equalities.containsKey(new OffsetEqKey(arrayLeft, arrayRight))) { neededEqualities.add(theory.term(SMTLIBConstants.EQUALS, arrayLeft, arrayRight)); neededEqualities.add(theory.term(SMTLIBConstants.EQUALS, weakIdx, weakIdx)); return mProofRules.cong(selectLeft, selectRight); @@ -3437,8 +3433,8 @@ private Term proveSelectOverPathStep(final Term arrayLeft, final Term arrayRight * some trivial (dis)equalities or some from (dis)equalities set, in * which case they are added to the needed(Dis)Equalities set. */ - private Term proveSelectOverPath(final Term weakIdx, final Term[] path, final Set> equalities, - final Set> disequalities, final Set neededEqualities, + private Term proveSelectOverPath(final Term weakIdx, final Term[] path, final Map equalities, + final Map disequalities, final Set neededEqualities, final Set neededDisequalities) { // note that a read-const-weakeq path can have length 1 assert path.length >= 1; @@ -3474,9 +3470,9 @@ private Term convertArraySelectConstWeakEqLemma(final ProofLiteral[] clause, fin * weak path was proven for this pair. strongPaths contains the sets of all * proven strong paths. */ - final HashMap, Term> allEqualities = new HashMap<>(); + final HashMap allEqualities = new HashMap<>(); /* indexDiseqs contains all index equalities in the clause */ - final HashMap, Term> allDisequalities = new HashMap<>(); + final HashMap allDisequalities = new HashMap<>(); collectEqualities(clause, allEqualities, allDisequalities); final HashSet neededEqualities = new HashSet<>(); @@ -3498,8 +3494,8 @@ private Term convertArraySelectConstWeakEqLemma(final ProofLiteral[] clause, fin final Term mainIdx = (Term) weakItems[0]; final Term[] mainPath = (Term[]) weakItems[1]; - Term proof = proveSelectOverPath(mainIdx, mainPath, allEqualities.keySet(), allDisequalities.keySet(), - neededEqualities, neededDisequalities); + Term proof = proveSelectOverPath(mainIdx, mainPath, allEqualities, allDisequalities, neededEqualities, + neededDisequalities); final Term firstTerm = theory.term("select", mainPath[0], mainIdx); final Term lastTerm = theory.term("select", mainPath[mainPath.length - 1], mainIdx); assert isApplication("const", mainPath[mainPath.length - 1]); @@ -3528,9 +3524,9 @@ private Term convertArraySelectWeakEqLemma(final ProofLiteral[] clause, final Ob * weak path was proven for this pair. strongPaths contains the sets of all * proven strong paths. */ - final HashMap, Term> allEqualities = new HashMap<>(); + final HashMap allEqualities = new HashMap<>(); /* indexDiseqs contains all index equalities in the clause */ - final HashMap, Term> allDisequalities = new HashMap<>(); + final HashMap allDisequalities = new HashMap<>(); collectEqualities(clause, allEqualities, allDisequalities); final HashSet neededEqualities = new HashSet<>(); @@ -3552,7 +3548,7 @@ private Term convertArraySelectWeakEqLemma(final ProofLiteral[] clause, final Ob final Term mainIdx = (Term) weakItems[0]; final Term[] mainPath = (Term[]) weakItems[1]; - Term proof = proveSelectOverPath(mainIdx, mainPath, allEqualities.keySet(), allDisequalities.keySet(), + Term proof = proveSelectOverPath(mainIdx, mainPath, allEqualities, allDisequalities, neededEqualities, neededDisequalities); assert isApplication("select", goalTerms[0]) && isApplication("select", goalTerms[1]); final int goalOrder = ((ApplicationTerm) goalTerms[0]).getParameters()[0] == mainPath[0] ? 0 : 1; @@ -3594,9 +3590,9 @@ private Term convertArrayWeakEqExtLemma(final ProofLiteral[] clause, final Objec * weak path was proven for this pair. strongPaths contains the sets of all * proven strong paths. */ - final HashMap, Term> allEqualities = new HashMap<>(); + final HashMap allEqualities = new HashMap<>(); /* indexDiseqs contains all index equalities in the clause */ - final HashMap, Term> allDisequalities = new HashMap<>(); + final HashMap allDisequalities = new HashMap<>(); collectEqualities(clause, allEqualities, allDisequalities); final HashSet neededEqualities = new HashSet<>(); @@ -3625,14 +3621,14 @@ private Term convertArrayWeakEqExtLemma(final ProofLiteral[] clause, final Objec final Term selectLeftDiff = mainSelectChain[0]; final Term selectRightDiff = mainSelectChain[mainPath.length - 1]; - final HashSet> weakDisequalities = new HashSet<>(); + final HashMap weakDisequalities = new HashMap<>(); final HashSet neededWeakDisequalities = new HashSet<>(); /* Collect weak paths */ for (int i = 3; i < ccAnnotation.length; i += 2) { assert ccAnnotation[i] == ":weakpath"; final Object[] weakItems = (Object[]) ccAnnotation[i + 1]; final Term idx = (Term) weakItems[0]; - weakDisequalities.add(new SymmetricPair<>(idx, diffTerm)); + weakDisequalities.put(new OffsetEqKey(idx, diffTerm), theory.term(SMTLIBConstants.EQUALS, idx, diffTerm)); } /* @@ -3641,7 +3637,7 @@ private Term convertArrayWeakEqExtLemma(final ProofLiteral[] clause, final Objec Term mainChainProof = mainPath.length > 2 ? mProofRules.trans(mainSelectChain) : null; for (int i = 0; i < mainPath.length - 1; i++) { Term proofSelectEq; - final SymmetricPair pair = new SymmetricPair<>(mainPath[i], mainPath[i + 1]); + final OffsetEqKey pair = new OffsetEqKey(mainPath[i], mainPath[i + 1]); /* check for strong path first */ if (allEqualities.containsKey(pair)) { neededEqualities.add(theory.term(SMTLIBConstants.EQUALS, mainPath[i], mainPath[i + 1])); @@ -3676,7 +3672,7 @@ private Term convertArrayWeakEqExtLemma(final ProofLiteral[] clause, final Objec final Term selectLeftIdx = theory.term(SMTLIBConstants.SELECT, arrayLeft, idx); final Term selectRightIdx = theory.term(SMTLIBConstants.SELECT, arrayRight, idx); - Term subproof = proveSelectOverPath(idx, weakPath, allEqualities.keySet(), allDisequalities.keySet(), + Term subproof = proveSelectOverPath(idx, weakPath, allEqualities, allDisequalities, neededEqualities, neededDisequalities); subproof = res(theory.term(SMTLIBConstants.EQUALS, selectLeftIdx, selectRightIdx), subproof, mProofRules.trans(selectLeftDiff, selectLeftIdx, selectRightIdx, selectRightDiff)); @@ -3716,8 +3712,8 @@ private Term convertDTProject(final ProofLiteral[] clause, final Object[] ccAnno assert ccAnnotation.length == 3; final Theory theory = clause[0].getAtom().getTheory(); - final HashMap, Term> allEqualities = new HashMap<>(); - final HashMap, Term> allDisequalities = new HashMap<>(); + final HashMap allEqualities = new HashMap<>(); + final HashMap allDisequalities = new HashMap<>(); collectEqualities(clause, allEqualities, allDisequalities); final HashSet neededEqualities = new HashSet<>(); @@ -3768,8 +3764,8 @@ private Term convertDTProject(final ProofLiteral[] clause, final Object[] ccAnno private Term convertDTTester(final ProofLiteral[] clause, final Object[] ccAnnotation) { assert ccAnnotation.length == 3; final Theory theory = clause[0].getAtom().getTheory(); - final HashMap, Term> allEqualities = new HashMap<>(); - final HashMap, Term> allDisequalities = new HashMap<>(); + final HashMap allEqualities = new HashMap<>(); + final HashMap allDisequalities = new HashMap<>(); collectEqualities(clause, allEqualities, allDisequalities); final HashSet neededEqualities = new HashSet<>(); @@ -3829,8 +3825,8 @@ private Term convertDTConstructor(final ProofLiteral[] clause, final Object[] cc assert ccAnnotation.length == 1; final Theory theory = clause[0].getAtom().getTheory(); - final HashMap, Term> allEqualities = new HashMap<>(); - final HashMap, Term> allDisequalities = new HashMap<>(); + final HashMap allEqualities = new HashMap<>(); + final HashMap allDisequalities = new HashMap<>(); collectEqualities(clause, allEqualities, allDisequalities); final HashSet neededEqualities = new HashSet<>(); @@ -3869,8 +3865,8 @@ private Term convertDTConstructor(final ProofLiteral[] clause, final Object[] cc */ private Term convertDTCases(final ProofLiteral[] clause, final Object[] ccAnnotation) { final Theory theory = clause[0].getAtom().getTheory(); - final HashMap, Term> allEqualities = new HashMap<>(); - final HashMap, Term> allDisequalities = new HashMap<>(); + final HashMap allEqualities = new HashMap<>(); + final HashMap allDisequalities = new HashMap<>(); collectEqualities(clause, allEqualities, allDisequalities); final HashSet neededEqualities = new HashSet<>(); @@ -3914,8 +3910,8 @@ private Term convertDTCases(final ProofLiteral[] clause, final Object[] ccAnnota */ private Term convertDTUnique(final ProofLiteral[] clause, final Object[] ccAnnotation) { final Theory theory = clause[0].getAtom().getTheory(); - final HashMap, Term> allEqualities = new HashMap<>(); - final HashMap, Term> allDisequalities = new HashMap<>(); + final HashMap allEqualities = new HashMap<>(); + final HashMap allDisequalities = new HashMap<>(); collectEqualities(clause, allEqualities, allDisequalities); final HashSet neededEqualities = new HashSet<>(); @@ -3970,8 +3966,8 @@ private Term convertDTUnique(final ProofLiteral[] clause, final Object[] ccAnnot */ private Term convertDTInjective(final ProofLiteral[] clause, final Object[] ccAnnotation) { final Theory theory = clause[0].getAtom().getTheory(); - final HashMap, Term> allEqualities = new HashMap<>(); - final HashMap, Term> allDisequalities = new HashMap<>(); + final HashMap allEqualities = new HashMap<>(); + final HashMap allDisequalities = new HashMap<>(); collectEqualities(clause, allEqualities, allDisequalities); final HashSet neededEqualities = new HashSet<>(); @@ -4029,8 +4025,8 @@ private Term convertDTInjective(final ProofLiteral[] clause, final Object[] ccAn */ private Term convertDTDisjoint(final ProofLiteral[] clause, final Object[] ccAnnotation) { final Theory theory = clause[0].getAtom().getTheory(); - final HashMap, Term> allEqualities = new HashMap<>(); - final HashMap, Term> allDisequalities = new HashMap<>(); + final HashMap allEqualities = new HashMap<>(); + final HashMap allDisequalities = new HashMap<>(); collectEqualities(clause, allEqualities, allDisequalities); final HashSet neededEqualities = new HashSet<>(); @@ -4092,8 +4088,8 @@ private int checkAndFindConsArg(final Term consTerm, final Term argTerm) { */ private Term convertDTCycle(final ProofLiteral[] clause, final Object[] ccAnnotation) { final Theory theory = clause[0].getAtom().getTheory(); - final HashMap, Term> allEqualities = new HashMap<>(); - final HashMap, Term> allDisequalities = new HashMap<>(); + final HashMap allEqualities = new HashMap<>(); + final HashMap allDisequalities = new HashMap<>(); collectEqualities(clause, allEqualities, allDisequalities); final HashSet neededEqualities = new HashSet<>(); @@ -4624,104 +4620,55 @@ private AnnotatedTerm substituteInQuantInst(final Term[] subst, final Quantified * in the proved clause). * @return the modified proof. */ - private Term resolveNeededEqualities(Term proof, final Map, Term> allEqualities, - final Map, Term> allDisequalities, final Set neededEqualities, + private Term resolveNeededEqualities(Term proof, final Map allEqualities, + final Map allDisequalities, final Set neededEqualities, final Set neededDisequalities) { - // Offset-aware indices, built lazily: with offset equalities a needed - // (dis)equality may carry a different but equivalent offset rendering than the - // clause literal that proves it (e.g. needed (= (+ x 5) (+ y 7)) vs. clause - // (= x (+ y 2))). These index the clause literals by the affine fact they - // express so the matching literal can be found and bridged with a Farkas step. - Map eqIndex = null; - Map diseqIndex = null; for (final Term eq : neededEqualities) { assert isApplication("=", eq); final Term[] eqParam = ((ApplicationTerm) eq).getParameters(); - final Term clauseEq = allEqualities.get(new SymmetricPair<>(eqParam[0], eqParam[1])); - if (clauseEq != null) { - if (clauseEq != eq) { + final OffsetEqKey eqKey = new OffsetEqKey(eqParam[0], eqParam[1]); + final ApplicationTerm clauseEq = (ApplicationTerm) allEqualities.get(eqKey); + if (clauseEq == null) { + final Term proofEq = mProofUtils.proveTrivialEquality(eqParam[0], eqParam[1]); + proof = res(eq, proofEq, proof); + } else { + final Term[] clauseEqParam = clauseEq.getParameters(); + if (clauseEq == eq) { + // nothing to do + } else if (clauseEqParam[1] == eqParam[0] && clauseEqParam[0] == eqParam[1]) { // need symmetry proof = res(eq, mProofRules.symm(eqParam[0], eqParam[1]), proof); - } - } else { - if (eqIndex == null) { - eqIndex = buildOffsetIndex(allEqualities.values()); - } - final Object[] match = findOffsetMatch(eqParam[0], eqParam[1], eqIndex); - if (match != null) { - // bridge: prove (= eqParam0 eqParam1) from the clause literal `atom`. - final Term atom = (Term) match[0]; - final Term[] atomParam = ((ApplicationTerm) atom).getParameters(); - final Term bridge = mProofUtils.proveEqWithMultiplier(atomParam, eqParam, (Rational) match[1]); - proof = res(eq, bridge, proof); } else { - final Term proofEq = mProofUtils.proveTrivialEquality(eqParam[0], eqParam[1]); - proof = res(eq, proofEq, proof); + // need shifted offset + final OffsetEqKey clauseEqKey = new OffsetEqKey(clauseEqParam[0], clauseEqParam[1]); + final Rational factor = (clauseEqKey.mLhs == eqKey.mLhs ? Rational.ONE : Rational.MONE); + final Term bridge = mProofUtils.proveEqWithMultiplier(clauseEqParam, eqParam, factor); + proof = res(eq, bridge, proof); } } } for (final Term eq : neededDisequalities) { assert isApplication("=", eq); final Term[] eqParam = ((ApplicationTerm) eq).getParameters(); - final Term clauseEq = allDisequalities.get(new SymmetricPair<>(eqParam[0], eqParam[1])); - if (clauseEq != null) { - if (clauseEq != eq) { - // need symmetry - proof = res(eq, proof, mProofRules.symm(eqParam[1], eqParam[0])); - } - continue; - } - if (diseqIndex == null) { - diseqIndex = buildOffsetIndex(allDisequalities.values()); - } - final Object[] match = findOffsetMatch(eqParam[0], eqParam[1], diseqIndex); - if (match != null) { - // bridge: prove the clause literal `atom` from the proved (= eqParam0 eqParam1). - final Term atom = (Term) match[0]; - final Term[] atomParam = ((ApplicationTerm) atom).getParameters(); - final Term bridge = mProofUtils.proveEqWithMultiplier(eqParam, atomParam, (Rational) match[1]); - proof = res(eq, proof, bridge); - } else { - // not found offset-aware either: assume present but swapped (legacy behavior). + final OffsetEqKey eqKey = new OffsetEqKey(eqParam[0], eqParam[1]); + final ApplicationTerm clauseEq = (ApplicationTerm) allDisequalities.get(eqKey); + final Term[] clauseEqParam = clauseEq.getParameters(); + if (clauseEq == eq) { + // nothing to do + } else if (clauseEqParam[1] == eqParam[0] && clauseEqParam[0] == eqParam[1]) { + // need symmetry proof = res(eq, proof, mProofRules.symm(eqParam[1], eqParam[0])); + } else { + // need shifted offset + final OffsetEqKey clauseEqKey = new OffsetEqKey(clauseEqParam[0], clauseEqParam[1]); + final Rational factor = (clauseEqKey.mLhs == eqKey.mLhs ? Rational.ONE : Rational.MONE); + final Term bridge = mProofUtils.proveEqWithMultiplier(eqParam, clauseEqParam, factor); + proof = res(eq, proof, bridge); } } return proof; } - /** - * Build an index of clause (dis)equality atoms keyed by the affine fact they - * express, see {@link OffsetEqKey}. The first atom for a given fact wins; this is - * only used as a fallback when no syntactically identical clause literal exists. - */ - private static Map buildOffsetIndex(final Collection atoms) { - final Map index = new HashMap<>(); - for (final Term atom : atoms) { - final Term[] sides = ((ApplicationTerm) atom).getParameters(); - index.putIfAbsent(new OffsetEqKey(sides[0], sides[1]), atom); - } - return index; - } - - /** - * Look up the clause literal that expresses the same affine fact as - * {@code (= a b)}, allowing for a different offset rendering. Returns - * {@code {atom, multiplier}} where {@code multiplier} is the rational - * {@code (a - b) / (atomLhs - atomRhs) = ±1} relating the two, or {@code null} if - * no matching literal exists. - */ - private static Object[] findOffsetMatch(final Term a, final Term b, final Map index) { - Term atom = index.get(new OffsetEqKey(a, b)); - if (atom != null) { - return new Object[] { atom, Rational.ONE }; - } - atom = index.get(new OffsetEqKey(b, a)); - if (atom != null) { - return new Object[] { atom, Rational.MONE }; - } - return null; - } - /** * A lookup key identifying an (offset) equality by the affine fact it expresses: * the non-constant parts of its two sides together with the constant offset @@ -4737,13 +4684,29 @@ private static final class OffsetEqKey { private final Map, Rational> mLhs; private final Map, Rational> mRhs; private final Rational mOffset; + private final int mHash; OffsetEqKey(final Term lhs, final Term rhs) { - final Polynomial pLhs = new Polynomial(lhs); - final Polynomial pRhs = new Polynomial(rhs); - mOffset = pLhs.getConstant().sub(pRhs.getConstant()); - mLhs = nonConstantPart(pLhs); - mRhs = nonConstantPart(pRhs); + if (lhs.getSort().isNumericSort()) { + final Polynomial pLhs = new Polynomial(lhs); + final Polynomial pRhs = new Polynomial(rhs); + mOffset = pLhs.getConstant().sub(pRhs.getConstant()); + mLhs = nonConstantPart(pLhs); + mRhs = nonConstantPart(pRhs); + } else { + mOffset = Rational.ZERO; + mLhs = Collections.singletonMap(Collections.singletonMap(lhs, 1), Rational.ONE); + mRhs = Collections.singletonMap(Collections.singletonMap(rhs, 1), Rational.ONE); + } + final int lhsHash = lhs.hashCode(); + final int rhsHash = rhs.hashCode(); + if (lhsHash == rhsHash) { + mHash = lhsHash * 31 + mOffset.abs().hashCode(); + } else if (lhsHash < rhsHash) { + mHash = lhsHash * 37 + rhsHash * 31 + mOffset.hashCode(); + } else { + mHash = rhsHash * 37 + lhsHash * 31 + mOffset.negate().hashCode(); + } } private static Map, Rational> nonConstantPart(final Polynomial poly) { @@ -4754,7 +4717,7 @@ private static Map, Rational> nonConstantPart(final Polynomia @Override public int hashCode() { - return mLhs.hashCode() * 31 * 31 + mRhs.hashCode() * 31 + mOffset.hashCode(); + return mHash; } @Override @@ -4766,7 +4729,8 @@ public boolean equals(final Object other) { return false; } final OffsetEqKey o = (OffsetEqKey) other; - return mOffset.equals(o.mOffset) && mLhs.equals(o.mLhs) && mRhs.equals(o.mRhs); + return (mOffset.equals(o.mOffset) && mLhs.equals(o.mLhs) && mRhs.equals(o.mRhs)) + || (mOffset.equals(o.mOffset.negate()) && mLhs.equals(o.mRhs) && mRhs.equals(o.mLhs)); } } From 10a25cdef839cd30355374de7acdd92c68468d8d Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Sat, 4 Jul 2026 12:13:11 +0200 Subject: [PATCH 61/75] CC offset equalities: annotate select/const edge in weakeq-ext lemmas Record the select/const edge of each weak-i path explicitly in the lemma annotation instead of re-deriving it by searching the clause equalities. With offset equalities the justifying select equality is offset-rendered (the constant may sit on the select side), so the old search is ambiguous; delivering the edge from the producer removes that ambiguity for all three consumers (CCProofGenerator, ProofSimplifier, ArrayInterpolator). - WeakSubPath stores the edge (select1/select2, ordered path[0]->path[last]), set in computeWeakCongruencePath; null for plain weak paths. - CCAnnotation carries mSelectEdges parallel to the paths. - The :weakpath term becomes {index, subs} or {index, subs, {left, right}}; ArrayInterpolator ignores the extra element (backward tolerant). - CCProofGenerator.collectWeakPath reads the annotated edge (orientSelectEdge), findSelectPath only as fallback. - ProofSimplifier threads the edge through proveSelectOverPath and feeds its flat terms (bare select / full const value) to proveSelectConst, bridging the offset-rendered clause equality via resolveNeededEqualities; match is decided by proveSelectConst, not by proveSelectPathTrans (which may return null). Validated with offsets off: array + interpolation proof-check sweep unchanged vs baseline; on constarr014 the annotated edge covers every select step and the search fallback is never hit. Preparation for offsets under proofs (the proveSelectConst offset-value handling is still open). See doc/offset-equality-plan.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- SMTInterpol/doc/offset-equality-plan.md | 57 +++++++++++++++++ .../smtinterpol/proof/ProofSimplifier.java | 43 ++++++++++--- .../theory/cclosure/CCAnnotation.java | 25 ++++++++ .../theory/cclosure/CCProofGenerator.java | 63 +++++++++++++++++-- .../theory/cclosure/WeakCongruencePath.java | 27 +++++++- 5 files changed, 201 insertions(+), 14 deletions(-) diff --git a/SMTInterpol/doc/offset-equality-plan.md b/SMTInterpol/doc/offset-equality-plan.md index 7b445c2be..f25d5aa28 100644 --- a/SMTInterpol/doc/offset-equality-plan.md +++ b/SMTInterpol/doc/offset-equality-plan.md @@ -1159,6 +1159,63 @@ Validated (clean build, `-ea`): `test/proof` **98/98**; the extensionality witne `proof-check`) **0 status mismatches**, only the pre-existing errors; `datatype`/`model` dirs 30/30; 117 JUnit tests green. Files: `ArrayTheory.java`, `ModelBuilder.java`. +## Explicit select/const edge in weakeq-ext annotations — DONE (uncommitted) + +Preparation for offsets under proofs. In a `weakeq-ext` lemma, each weak-i path may +contain one *select/const edge*: the step where two arrays are weakly-i-equivalent +not by a store or a strong equality but because a select equality +`select(a1,j1) = select(a2,j2)` (or `select(a1,j1) = v` for `a2 = (const v)`) holds +with `j1, j2` equal to the path index. Until now the edge was *not* recorded — three +consumers (`CCProofGenerator.findSelectPath`, `ProofSimplifier.proveSelectPath`, and +`ArrayInterpolator`) each re-derived it by iterating the clause equalities. With +offset equalities this search becomes ambiguous: the justifying equality is +offset-rendered (`EqualityProxy.getLiteral` builds `(= mLhs (+ mRhs off))`, and the +`CCTermPairHash` identity-hash canonicalization can move the constant onto the +*select* side, e.g. `(= core (+ (select a1 j1) off))`), so neither atom side is the +bare select the searcher matches on, and among several candidates only one is right. + +Fix: record the edge in the annotation and let the consumers read it. + +- **`WeakCongruencePath.WeakSubPath`** gains `mSelectLeft`/`mSelectRight` + (`CCParameter`, with offset), set by `computeWeakCongruencePath` — the one place + the edge is known (`select1`/`select2`), ordered `path[0]`-side then + `path[last]`-side. Plain weak paths leave it null; it is *always* null for + read-over-weakeq / read-const-weakeq (their weak paths have no select step). +- **`CCAnnotation`** carries a parallel `mSelectEdges[i]` (`{left, right}` or null) + populated from the `WeakSubPath`, with a getter. +- **Term format:** the `:weakpath` value stays `{index, subs}` when there is no edge + and becomes `{index, subs, {leftTerm, rightTerm}}` when there is one + (`CCProofGenerator.buildLemma`). Backward-tolerant: `ArrayInterpolator.ProofPath` + reads only `[0]`/`[1]` and ignores the third element. +- **`CCProofGenerator.collectWeakPath`** uses the annotated edge (oriented against the + step's two arrays via `isSelect`/`isConst` in the new `orientSelectEdge`); it only + falls back to `findSelectPath` if no edge is present. +- **`ProofSimplifier`** parses the edge from `:weakpath[2]` and threads it through + `proveSelectOverPath` → `proveSelectOverPathStep`, feeding the edge's + `getFlatTerm()` (a bare select, or the const's full stored value) directly to + `proveSelectConst`. This sidesteps the affine-extraction problem: those terms match + `proveSelectConst` as-is, and the offset-rendered clause equality is bridged by + `resolveNeededEqualities` (`OffsetEqKey` + `proveEqWithMultiplier`). Match is decided + by `proveSelectConst` succeeding on both sides, *not* by the returned proof — + `proveSelectPathTrans` legitimately returns `null` for a trivial step (the edge + selects coincide with the path-end selects), which must not be read as "no match". + `proveSelectPath` is kept only as a fallback for an absent edge. + +The change is validated with offsets *off* (the format machinery is exercised; +select edges are offset-0): the array + interpolation `proof-check-mode` + +`proof-level lowlevel` sweep is unchanged versus baseline (only the pre-existing +deferred offset-interpolation and datatype failures remain — `CCInterpolator` throws +on offset lemmas). Instrumentation on `constarr014` (68 select edges) confirms the +annotated edge is used for every select step and the search fallback is never hit in +any passing test. Files: `WeakCongruencePath.java`, `CCAnnotation.java`, +`CCProofGenerator.java`, `ProofSimplifier.java`. + +**Still open (the reason this is preparation):** `proveSelectConst` does not yet +handle a select/const edge whose select sits at an LA/offset-equal index, or a const +value carrying a non-zero offset — i.e. offsets *under proofs*. The edge is now +delivered explicitly (bare select + full const value), so this handling no longer has +to contend with the search ambiguity; it is the natural next step. + ## Implementable slice (ready to start) 1. `LASharedTerm` becomes offset-free under `createOffsetEqualities()` (revert the diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/proof/ProofSimplifier.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/proof/ProofSimplifier.java index 3b089bc30..1673c4448 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/proof/ProofSimplifier.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/proof/ProofSimplifier.java @@ -3390,7 +3390,7 @@ private Term proveStoreStep(final Term arrayLeft, final Term arrayRight, final T private Term proveSelectOverPathStep(final Term arrayLeft, final Term arrayRight, final Term weakIdx, final Term selectLeft, final Term selectRight, final Map equalities, final Map disequalities, final Set neededEqualities, - final Set neededDisequalities) { + final Set neededDisequalities, final Term[] selectEdge) { final Theory theory = arrayLeft.getTheory(); /* check for strong path first */ if (equalities.containsKey(new OffsetEqKey(arrayLeft, arrayRight))) { @@ -3409,9 +3409,27 @@ private Term proveSelectOverPathStep(final Term arrayLeft, final Term arrayRight mProofRules.symm(selectLeft, selectRight)); } /* - * check for select path with select indices equal to weakIdx, both trivially - * equal and proven equal by a strong path + * This is a select/const edge. Prefer the select/const values recorded in the annotation (see + * WeakCongruencePath.WeakSubPath.setSelectEdge): they are the bare select or the full const value, so + * proveSelectConst matches them directly and the offset-rendered clause equality is bridged by + * resolveNeededEqualities. Only fall back to searching the clause equalities if no edge is annotated or it does + * not match this step. proveSelectPathTrans may legitimately return null (a trivial step), so the match is + * decided by proveSelectConst succeeding on both sides, not by the returned proof. */ + if (selectEdge != null) { + Term eq1 = proveSelectConst(selectEdge[0], arrayLeft, weakIdx, equalities, neededEqualities); + Term eq2 = proveSelectConst(selectEdge[1], arrayRight, weakIdx, equalities, neededEqualities); + if (eq1 != null && eq2 != null) { + return proveSelectPathTrans(arrayLeft, selectEdge[0], selectEdge[1], arrayRight, weakIdx, eq1, eq2, + neededEqualities); + } + eq1 = proveSelectConst(selectEdge[1], arrayLeft, weakIdx, equalities, neededEqualities); + eq2 = proveSelectConst(selectEdge[0], arrayRight, weakIdx, equalities, neededEqualities); + if (eq1 != null && eq2 != null) { + return proveSelectPathTrans(arrayLeft, selectEdge[1], selectEdge[0], arrayRight, weakIdx, eq1, eq2, + neededEqualities); + } + } return proveSelectPath(arrayLeft, arrayRight, weakIdx, equalities, neededEqualities); } @@ -3435,7 +3453,7 @@ private Term proveSelectOverPathStep(final Term arrayLeft, final Term arrayRight */ private Term proveSelectOverPath(final Term weakIdx, final Term[] path, final Map equalities, final Map disequalities, final Set neededEqualities, - final Set neededDisequalities) { + final Set neededDisequalities, final Term[] selectEdge) { // note that a read-const-weakeq path can have length 1 assert path.length >= 1; final Theory theory = path[0].getTheory(); @@ -3449,7 +3467,7 @@ private Term proveSelectOverPath(final Term weakIdx, final Term[] path, final Ma Term proof = selectChain.length > 2 ? mProofRules.trans(selectChain) : null; for (int i = 0; i < path.length - 1; i++) { final Term subproof = proveSelectOverPathStep(path[i], path[i + 1], weakIdx, selectChain[i], - selectChain[i + 1], equalities, disequalities, neededEqualities, neededDisequalities); + selectChain[i + 1], equalities, disequalities, neededEqualities, neededDisequalities, selectEdge); proof = res(theory.term(SMTLIBConstants.EQUALS, selectChain[i], selectChain[i + 1]), subproof, proof); } return proof; @@ -3495,7 +3513,7 @@ private Term convertArraySelectConstWeakEqLemma(final ProofLiteral[] clause, fin final Term[] mainPath = (Term[]) weakItems[1]; Term proof = proveSelectOverPath(mainIdx, mainPath, allEqualities, allDisequalities, neededEqualities, - neededDisequalities); + neededDisequalities, null); final Term firstTerm = theory.term("select", mainPath[0], mainIdx); final Term lastTerm = theory.term("select", mainPath[mainPath.length - 1], mainIdx); assert isApplication("const", mainPath[mainPath.length - 1]); @@ -3549,7 +3567,7 @@ private Term convertArraySelectWeakEqLemma(final ProofLiteral[] clause, final Ob final Term[] mainPath = (Term[]) weakItems[1]; Term proof = proveSelectOverPath(mainIdx, mainPath, allEqualities, allDisequalities, - neededEqualities, neededDisequalities); + neededEqualities, neededDisequalities, null); assert isApplication("select", goalTerms[0]) && isApplication("select", goalTerms[1]); final int goalOrder = ((ApplicationTerm) goalTerms[0]).getParameters()[0] == mainPath[0] ? 0 : 1; final Term goal1 = goalTerms[goalOrder]; @@ -3663,6 +3681,15 @@ private Term convertArrayWeakEqExtLemma(final ProofLiteral[] clause, final Objec final Object[] weakItems = (Object[]) ccAnnotation[i + 1]; final Term idx = (Term) weakItems[0]; final Term[] weakPath = (Term[]) weakItems[1]; + // The optional third element is the select/const edge {left, right} justifying this weak path's + // weak-congruence step; left is on the weakPath[0] side (see CCProofGenerator.buildLemma). + final Term[] selectEdge; + if (weakItems.length > 2) { + final Object[] edge = (Object[]) weakItems[2]; + selectEdge = new Term[] { (Term) edge[0], (Term) edge[1] }; + } else { + selectEdge = null; + } /* check end points */ assert arrayLeft == weakPath[0] && arrayRight == weakPath[weakPath.length - 1]; @@ -3673,7 +3700,7 @@ private Term convertArrayWeakEqExtLemma(final ProofLiteral[] clause, final Objec final Term selectLeftIdx = theory.term(SMTLIBConstants.SELECT, arrayLeft, idx); final Term selectRightIdx = theory.term(SMTLIBConstants.SELECT, arrayRight, idx); Term subproof = proveSelectOverPath(idx, weakPath, allEqualities, allDisequalities, - neededEqualities, neededDisequalities); + neededEqualities, neededDisequalities, selectEdge); subproof = res(theory.term(SMTLIBConstants.EQUALS, selectLeftIdx, selectRightIdx), subproof, mProofRules.trans(selectLeftDiff, selectLeftIdx, selectRightIdx, selectRightDiff)); subproof = res(theory.term(SMTLIBConstants.EQUALS, selectLeftDiff, selectLeftIdx), diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAnnotation.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAnnotation.java index 02b1a1a60..8bd2c02bd 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAnnotation.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAnnotation.java @@ -240,8 +240,23 @@ public String getKind() { final CCParameter[] mWeakIndices; + /** + * For each path, the select/const edge justifying its single weak-congruence step, or {@code null} if it has none + * (all store/equality steps, or not a weak path). When present, {@code mSelectEdges[i]} is a two-element array + * {@code {selectLeft, selectRight}} with {@code selectLeft} on the {@code mParamPaths[i][0]} side. This lets the + * proof and interpolation consumers use the edge directly instead of searching the clause equalities for it. + */ + final CCParameter[][] mSelectEdges; + final DataTypeLemma mDTLemma; + private static CCParameter[] selectEdgeOf(final SubPath p) { + if (p instanceof WeakSubPath && ((WeakSubPath) p).getSelectLeft() != null) { + return new CCParameter[] { ((WeakSubPath) p).getSelectLeft(), ((WeakSubPath) p).getSelectRight() }; + } + return null; + } + public CCAnnotation(final SymmetricPair diseq, final Collection paths, final RuleKind rule) { this(diseq, paths, rule, null); } @@ -265,12 +280,15 @@ public CCAnnotation(final SymmetricPair diseq, final CCParameter[] final int n = 1 + otherPaths.size(); mParamPaths = new CCParameter[n][]; mWeakIndices = new CCParameter[n]; + mSelectEdges = new CCParameter[n][]; mParamPaths[0] = mainPath; mWeakIndices[0] = null; + mSelectEdges[0] = null; int i = 1; for (final SubPath p : otherPaths) { mParamPaths[i] = p.getParams(); mWeakIndices[i] = p instanceof WeakSubPath ? ((WeakSubPath) p).getIndex() : null; + mSelectEdges[i] = selectEdgeOf(p); i++; } mRule = rule; @@ -282,10 +300,12 @@ private CCAnnotation(final SymmetricPair diseq, final Collection getPathEndParams() { return new SymmetricPair<>(mPath[0], mPath[mPath.length - 1]); @@ -315,7 +325,13 @@ private void collectWeakPath(final IndexedPath indexedPath) { } // Case (iv) select if (mRule == RuleKind.WEAKEQ_EXT && pathIndex != null) { - final SelectEdge selectEdge = findSelectPath(new SymmetricPair<>(firstTerm, secondTerm), pathIndex); + final SymmetricPair arrayPair = new SymmetricPair<>(firstTerm, secondTerm); + // Prefer the select/const edge recorded in the annotation; only fall back to searching the clause + // equalities if it is absent (should not happen for a genuine select step). + SelectEdge selectEdge = orientSelectEdge(indexedPath.getSelectEdge(), arrayPair, pathIndex); + if (selectEdge == null) { + selectEdge = findSelectPath(arrayPair, pathIndex); + } if (selectEdge != null) { if (selectEdge.getLeft() != selectEdge.getRight()) { if (!collectEquality(selectEdge.toSymmetricPair())) { @@ -437,7 +453,8 @@ public CCProofGenerator(final CCAnnotation arrayAnnot) { mRule = arrayAnnot.mRule; mIndexedPaths = new IndexedPath[arrayAnnot.getParamPaths().length]; for (int i = 0; i < mIndexedPaths.length; i++) { - mIndexedPaths[i] = new IndexedPath(arrayAnnot.getWeakIndices()[i], arrayAnnot.getParamPaths()[i]); + mIndexedPaths[i] = new IndexedPath(arrayAnnot.getWeakIndices()[i], arrayAnnot.getParamPaths()[i], + arrayAnnot.getSelectEdges()[i]); } } @@ -726,7 +743,16 @@ private Term buildLemma(final ProofRules proofRules, RuleKind rule, final ProofI subannots[k++] = subs; } else { subannots[k++] = ":weakpath"; - subannots[k++] = new Object[] { index.getFlatTerm(), subs }; + final CCParameter[] edge = p.getSelectEdge(); + if (edge == null) { + subannots[k++] = new Object[] { index.getFlatTerm(), subs }; + } else { + // Append the select/const edge {left, right} that justifies this weak path's weak-congruence + // step; left is on the subs[0] side. Consumers that predate this element ignore it (they read + // only [0] and [1]). + subannots[k++] = new Object[] { index.getFlatTerm(), subs, + new Object[] { edge[0].getFlatTerm(), edge[1].getFlatTerm() } }; + } } } } @@ -826,7 +852,7 @@ private ProofInfo findCongruencePaths(final CCParameter first, final CCParameter // invariant (the offset cancels in the difference), so all lookups are unaffected. proofInfo.mLemmaDiseq = new SymmetricPair<>(firstTerm, secondTerm); proofInfo.mProofPaths = - new IndexedPath[] { new IndexedPath(null, new CCParameter[] { firstTerm, secondTerm }) }; + new IndexedPath[] { new IndexedPath(null, new CCParameter[] { firstTerm, secondTerm }, null) }; assert firstApp.getArgCount() == secondApp.getArgCount(); for (int i = 0; i < firstApp.getArgCount(); i++) { @@ -895,6 +921,33 @@ private SelectEdge findSelectPath(final SymmetricPair termPair, final CC /** * Check if the equality sel1 == sel2 explains the weak step on weakpathindex for termPair. */ + /** + * Orient the select/const edge {@code edge = {a, b}} recorded in the annotation against the array step + * {@code termPair}: return a {@link SelectEdge} whose left member belongs to {@code termPair.getFirst()} and right to + * {@code termPair.getSecond()}. Returns {@code null} if {@code edge} is absent or does not match this step (the + * caller then falls back to searching). + */ + private SelectEdge orientSelectEdge(final CCParameter[] edge, final SymmetricPair termPair, + final CCParameter weakpathindex) { + if (edge == null) { + return null; + } + if (belongsTo(edge[0], termPair.getFirst(), weakpathindex) + && belongsTo(edge[1], termPair.getSecond(), weakpathindex)) { + return new SelectEdge(edge[0], edge[1]); + } + if (belongsTo(edge[1], termPair.getFirst(), weakpathindex) + && belongsTo(edge[0], termPair.getSecond(), weakpathindex)) { + return new SelectEdge(edge[1], edge[0]); + } + return null; + } + + /** Whether {@code sel} is the select-at-weakpathindex on {@code array}, or {@code array} is {@code (const sel)}. */ + private boolean belongsTo(final CCParameter sel, final CCTerm array, final CCParameter weakpathindex) { + return isConst(array, sel) || isSelect(sel, array, weakpathindex); + } + private SelectEdge goodSelectStep(final CCTerm sel1, final CCTerm sel2, final Rational offset, final SymmetricPair termPair, final CCParameter weakpathindex) { CCParameter lhs = null; diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/WeakCongruencePath.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/WeakCongruencePath.java index 712ad1259..27dd9257a 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/WeakCongruencePath.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/WeakCongruencePath.java @@ -59,6 +59,14 @@ public void update(final CCTerm term, final ArrayNode arrayNode) { static class WeakSubPath extends SubPath { private final CCParameter mIdx; private final CCParameter mIdxRep; + /** + * The select/const edge justifying the single weak-congruence step on this path, if any: {@code mSelectLeft} is + * the select (or const value) on the {@code path[0]} side, {@code mSelectRight} the one on the {@code path[last]} + * side. Both {@code null} for plain weak paths (store/equality steps only), which is always the case for + * read-over-weakeq and read-const-weakeq. + */ + private CCParameter mSelectLeft; + private CCParameter mSelectRight; public WeakSubPath(final CCParameter idx, final CCTerm start, final boolean produceProofs) { super(start, produceProofs); @@ -66,15 +74,29 @@ public WeakSubPath(final CCParameter idx, final CCTerm start, final boolean prod mIdxRep = idx.getValueKey(); } + public void setSelectEdge(final CCParameter selectLeft, final CCParameter selectRight) { + mSelectLeft = selectLeft; + mSelectRight = selectRight; + } + @Override public String toString() { return "Weakpath " + mIdx - + (mTermsOnPath == null ? "" : " " + mTermsOnPath.toString()); + + (mTermsOnPath == null ? "" : " " + mTermsOnPath.toString()) + + (mSelectLeft == null ? "" : " selectedge " + mSelectLeft + " <-> " + mSelectRight); } public CCParameter getIndex() { return mIdx; } + + public CCParameter getSelectLeft() { + return mSelectLeft; + } + + public CCParameter getSelectRight() { + return mSelectRight; + } } final ArrayTheory mArrayTheory; @@ -261,6 +283,9 @@ private WeakSubPath computeWeakCongruencePath(final CCTerm array1, final CCTerm if (select1 != select2) { computePath(select1, select2); } + // Record the select/const edge so the proof/interpolation consumers need not search for it. select1 is on the + // array1 (path[0]) side, select2 on the array2 (path[last]) side, matching the emitted path order. + path.setSelectEdge(select1, select2); return path; } From 23e63e15e7bafad4385503c6870df8f8faff68f9 Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Sat, 4 Jul 2026 13:33:42 +0200 Subject: [PATCH 62/75] Fix-up for offset equality change for CC-Lemmas --- .../ultimate/smtinterpol/proof/ProofSimplifier.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/proof/ProofSimplifier.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/proof/ProofSimplifier.java index 1673c4448..20c025b36 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/proof/ProofSimplifier.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/proof/ProofSimplifier.java @@ -4668,7 +4668,7 @@ private Term resolveNeededEqualities(Term proof, final Map al } else { // need shifted offset final OffsetEqKey clauseEqKey = new OffsetEqKey(clauseEqParam[0], clauseEqParam[1]); - final Rational factor = (clauseEqKey.mLhs == eqKey.mLhs ? Rational.ONE : Rational.MONE); + final Rational factor = (clauseEqKey.mLhs.equals(eqKey.mLhs) ? Rational.ONE : Rational.MONE); final Term bridge = mProofUtils.proveEqWithMultiplier(clauseEqParam, eqParam, factor); proof = res(eq, bridge, proof); } @@ -4688,7 +4688,7 @@ private Term resolveNeededEqualities(Term proof, final Map al } else { // need shifted offset final OffsetEqKey clauseEqKey = new OffsetEqKey(clauseEqParam[0], clauseEqParam[1]); - final Rational factor = (clauseEqKey.mLhs == eqKey.mLhs ? Rational.ONE : Rational.MONE); + final Rational factor = (clauseEqKey.mLhs.equals(eqKey.mLhs) ? Rational.ONE : Rational.MONE); final Term bridge = mProofUtils.proveEqWithMultiplier(eqParam, clauseEqParam, factor); proof = res(eq, proof, bridge); } @@ -4725,8 +4725,8 @@ private static final class OffsetEqKey { mLhs = Collections.singletonMap(Collections.singletonMap(lhs, 1), Rational.ONE); mRhs = Collections.singletonMap(Collections.singletonMap(rhs, 1), Rational.ONE); } - final int lhsHash = lhs.hashCode(); - final int rhsHash = rhs.hashCode(); + final int lhsHash = mLhs.hashCode(); + final int rhsHash = mRhs.hashCode(); if (lhsHash == rhsHash) { mHash = lhsHash * 31 + mOffset.abs().hashCode(); } else if (lhsHash < rhsHash) { From e45b0b49ed441dba620e98338a9c3b98b0aecb54 Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Sat, 4 Jul 2026 14:04:11 +0200 Subject: [PATCH 63/75] Always use the select edge and fail if not present Remove old code to determine the select/const edges --- .../smtinterpol/proof/ProofSimplifier.java | 83 +-------- .../theory/cclosure/CCAnnotation.java | 11 +- .../theory/cclosure/CCProofGenerator.java | 176 ++---------------- .../theory/cclosure/WeakCongruencePath.java | 13 +- 4 files changed, 32 insertions(+), 251 deletions(-) diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/proof/ProofSimplifier.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/proof/ProofSimplifier.java index 20c025b36..92d9fbab1 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/proof/ProofSimplifier.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/proof/ProofSimplifier.java @@ -3268,70 +3268,6 @@ private Term proveSelectPathTrans(final Term arrayLeft, final Term value1, final return proof; } - /** - * Prove for a step in a weak array path that - * {@code (select arrayLeft weakIdx) = (select arrayRight weakIdx)}, for the - * case that there is an explicit select equality (or the edge-case where this - * explicit select equality would be trivial. A select equality is an equality - * of the form {@code (select arrayLeft idx1) = (select arrayRight idx2)}, where - * an equality between weakIdx and idx1 resp. idx2 is either trivial or in the - * equalities set. In case arrayLeft is the term {@pre (const v)} the left-hand - * side of the equality can be simply {@pre v}, similarly for arrayRight. - * - * @param arrayLeft the left array of the step. - * @param arrayRight the right array of the step. - * @param weakIdx the weak path index. - * @param equalities the equality literals from the clause. - * @param neededEqualities a set into which needed equalities are added. - * @return the proof for the equality between the two selects. The proof uses - * the equality between the select index in the equality and weakIndex, - * which it adds to neededEqualities. It returns null if this is not a - * store step. - */ - private Term proveSelectPath(final Term arrayLeft, final Term arrayRight, final Term weakIdx, - final Map allEqualities, final Set neededEqualities) { - for (final Term candidateEquality : allEqualities.values()) { - // Check for each candidate equality if it explains a select edge for a - // weakeq-ext lemma. - // We check if termPair.first[weakIdx]] equals one side of the equality and - // termPair.second[weakIdx] - // equals the other side. - final Term[] sides = ((ApplicationTerm) candidateEquality).getParameters(); - final Term first = sides[0]; - final Term second = sides[1]; - Term eq1 = proveSelectConst(first, arrayLeft, weakIdx, allEqualities, neededEqualities); - Term eq2 = proveSelectConst(second, arrayRight, weakIdx, allEqualities, neededEqualities); - if (eq1 != null && eq2 != null) { - return proveSelectPathTrans(arrayLeft, first, second, arrayRight, weakIdx, eq1, eq2, neededEqualities); - } - eq1 = proveSelectConst(second, arrayLeft, weakIdx, allEqualities, neededEqualities); - eq2 = proveSelectConst(first, arrayRight, weakIdx, allEqualities, neededEqualities); - if (eq1 != null && eq2 != null) { - return proveSelectPathTrans(arrayLeft, second, first, arrayRight, weakIdx, eq1, eq2, neededEqualities); - } - } - // No candidate equality was found but it could also be a select-const edge - // where a[i] and v are - // syntactically equal, in which case there is no equality. - if (isApplication(SMTLIBConstants.CONST, arrayLeft)) { - final Term value = ((ApplicationTerm) arrayLeft).getParameters()[0]; - final Term eq2 = proveSelectConst(value, arrayRight, weakIdx, allEqualities, neededEqualities); - if (eq2 != null) { - return proveSelectPathTrans(arrayLeft, value, value, arrayRight, weakIdx, - mProofRules.constArray(value, weakIdx), eq2, neededEqualities); - } - } - if (isApplication(SMTLIBConstants.CONST, arrayRight)) { - final Term value = ((ApplicationTerm) arrayRight).getParameters()[0]; - final Term eq1 = proveSelectConst(value, arrayLeft, weakIdx, allEqualities, neededEqualities); - if (eq1 != null) { - return proveSelectPathTrans(arrayLeft, value, value, arrayRight, weakIdx, eq1, - mProofRules.constArray(value, weakIdx), neededEqualities); - } - } - return null; - } - /** * Try to prove for a step in a weak array path that * {@code (select arrayLeft weakIdx) = (select arrayRight weakIdx)}, for the @@ -3416,21 +3352,10 @@ private Term proveSelectOverPathStep(final Term arrayLeft, final Term arrayRight * not match this step. proveSelectPathTrans may legitimately return null (a trivial step), so the match is * decided by proveSelectConst succeeding on both sides, not by the returned proof. */ - if (selectEdge != null) { - Term eq1 = proveSelectConst(selectEdge[0], arrayLeft, weakIdx, equalities, neededEqualities); - Term eq2 = proveSelectConst(selectEdge[1], arrayRight, weakIdx, equalities, neededEqualities); - if (eq1 != null && eq2 != null) { - return proveSelectPathTrans(arrayLeft, selectEdge[0], selectEdge[1], arrayRight, weakIdx, eq1, eq2, - neededEqualities); - } - eq1 = proveSelectConst(selectEdge[1], arrayLeft, weakIdx, equalities, neededEqualities); - eq2 = proveSelectConst(selectEdge[0], arrayRight, weakIdx, equalities, neededEqualities); - if (eq1 != null && eq2 != null) { - return proveSelectPathTrans(arrayLeft, selectEdge[1], selectEdge[0], arrayRight, weakIdx, eq1, eq2, - neededEqualities); - } - } - return proveSelectPath(arrayLeft, arrayRight, weakIdx, equalities, neededEqualities); + final Term eq1 = proveSelectConst(selectEdge[0], arrayLeft, weakIdx, equalities, neededEqualities); + final Term eq2 = proveSelectConst(selectEdge[1], arrayRight, weakIdx, equalities, neededEqualities); + return proveSelectPathTrans(arrayLeft, selectEdge[0], selectEdge[1], arrayRight, weakIdx, eq1, eq2, + neededEqualities); } /** diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAnnotation.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAnnotation.java index 8bd2c02bd..798b8d274 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAnnotation.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAnnotation.java @@ -250,13 +250,6 @@ public String getKind() { final DataTypeLemma mDTLemma; - private static CCParameter[] selectEdgeOf(final SubPath p) { - if (p instanceof WeakSubPath && ((WeakSubPath) p).getSelectLeft() != null) { - return new CCParameter[] { ((WeakSubPath) p).getSelectLeft(), ((WeakSubPath) p).getSelectRight() }; - } - return null; - } - public CCAnnotation(final SymmetricPair diseq, final Collection paths, final RuleKind rule) { this(diseq, paths, rule, null); } @@ -288,7 +281,7 @@ public CCAnnotation(final SymmetricPair diseq, final CCParameter[] for (final SubPath p : otherPaths) { mParamPaths[i] = p.getParams(); mWeakIndices[i] = p instanceof WeakSubPath ? ((WeakSubPath) p).getIndex() : null; - mSelectEdges[i] = selectEdgeOf(p); + mSelectEdges[i] = p instanceof WeakSubPath ? ((WeakSubPath) p).getSelectEdge() : null; i++; } mRule = rule; @@ -305,7 +298,7 @@ private CCAnnotation(final SymmetricPair diseq, final Collection toSymmetricPair() { - return new SymmetricPair<>(mLeft, mRight); - } - - public CCParameter getLeft() { - return mLeft; - } - - public CCParameter getRight() { - return mRight; - } - - @Override - public String toString() { - return mLeft + " <-> " + mRight; - } - } - /** * This class collects the information for an auxiliary lemma that proves an equality literal needed in the main * lemma. @@ -266,13 +236,14 @@ private void collectStrongPath(final IndexedPath indexedPath) { } } - private void collectSelectIndexEquality(final CCParameter select, final CCParameter pathIndex) { - if (select instanceof CCTerm && ArrayTheory.isSelectTerm(select)) { - final CCParameter index = ArrayTheory.getIndexFromSelect((CCAppTerm) select); - if (!index.equals(pathIndex)) { - if (!collectEquality(new SymmetricPair(pathIndex, index))) { - throw new AssertionError("Cannot find select index equality " + pathIndex + " = " + index); - } + private void collectSelectIndexEquality(final CCParameter select, final CCTerm array, final CCParameter pathIndex) { + if (!ArrayTheory.isSelectTerm(select) || ArrayTheory.getArrayFromSelect((CCAppTerm) select) != array) { + throw new AssertionError(); + } + final CCParameter index = ArrayTheory.getIndexFromSelect((CCAppTerm) select); + if (!index.equals(pathIndex)) { + if (!collectEquality(new SymmetricPair(pathIndex, index))) { + throw new AssertionError("Cannot find select index equality " + pathIndex + " = " + index); } } } @@ -325,24 +296,19 @@ private void collectWeakPath(final IndexedPath indexedPath) { } // Case (iv) select if (mRule == RuleKind.WEAKEQ_EXT && pathIndex != null) { - final SymmetricPair arrayPair = new SymmetricPair<>(firstTerm, secondTerm); - // Prefer the select/const edge recorded in the annotation; only fall back to searching the clause - // equalities if it is absent (should not happen for a genuine select step). - SelectEdge selectEdge = orientSelectEdge(indexedPath.getSelectEdge(), arrayPair, pathIndex); - if (selectEdge == null) { - selectEdge = findSelectPath(arrayPair, pathIndex); - } + // Use the select/const edge recorded in the annotation. + final CCParameter[] selectEdge = indexedPath.getSelectEdge(); if (selectEdge != null) { - if (selectEdge.getLeft() != selectEdge.getRight()) { - if (!collectEquality(selectEdge.toSymmetricPair())) { + if (selectEdge[0] != selectEdge[1]) { + if (!collectEquality(new SymmetricPair<>(selectEdge[0], selectEdge[1]))) { throw new AssertionError("Cannot find select edge " + selectEdge); } } - if (!isConst(firstTerm, selectEdge.getLeft())) { - collectSelectIndexEquality(selectEdge.getLeft(), pathIndex); + if (!isConst(firstTerm, selectEdge[0])) { + collectSelectIndexEquality(selectEdge[0], firstTerm, pathIndex); } - if (!isConst(secondTerm, selectEdge.getRight())) { - collectSelectIndexEquality(selectEdge.getRight(), pathIndex); + if (!isConst(secondTerm, selectEdge[1])) { + collectSelectIndexEquality(selectEdge[1], secondTerm, pathIndex); } continue; } @@ -819,8 +785,6 @@ private boolean isTrivialDisequality(final SymmetricPair termPair) return smtAffine.isAllIntSummands() && !smtAffine.getConstant().div(smtAffine.getGcd()).isIntegral(); } - - /** * Find argument paths for a congruence. These may also be literals from the original clause. Note that a function * can have several arguments, and a path is needed for each of them! @@ -880,114 +844,6 @@ private ProofInfo findCongruencePaths(final CCParameter first, final CCParameter return proofInfo; } - /** - * Search for a select path between two given arrays and paths from the select indices to the weakpath index for - * which one wants to prove equality of the array elements. - * - * @return the select path and (if needed) index paths, or null if there were no suitable paths for the term pair. - */ - private SelectEdge findSelectPath(final SymmetricPair termPair, final CCParameter weakpathindex) { - // first check for trivial select-const edges, i.e., (const (select a j)) and a with j = weakpathindex. - - if (ArrayTheory.isConstTerm(termPair.getFirst())) { - final CCParameter value = ArrayTheory.getValueFromConst((CCAppTerm) termPair.getFirst()); - if (isSelect(value, termPair.getSecond(), weakpathindex)) { - return new SelectEdge(value, value); - } - } - if (ArrayTheory.isConstTerm(termPair.getSecond())) { - final CCParameter value = ArrayTheory.getValueFromConst((CCAppTerm) termPair.getSecond()); - if (isSelect(value, termPair.getFirst(), weakpathindex)) { - return new SelectEdge(value, value); - } - } - - for (final OffsetPair equality : mAllEqualities) { - // Find some select path. Array select/store terms are offset-free, so the structural CCTerms are used. - final CCTerm start = equality.getFirst(); - final CCTerm end = equality.getSecond(); - SelectEdge result = goodSelectStep(start, end, equality.mOffset, termPair, weakpathindex); - if (result != null) { - return result; - } - result = goodSelectStep(end, start, equality.mOffset.negate(), termPair, weakpathindex); - if (result != null) { - return result; - } - } - return null; - } - - /** - * Check if the equality sel1 == sel2 explains the weak step on weakpathindex for termPair. - */ - /** - * Orient the select/const edge {@code edge = {a, b}} recorded in the annotation against the array step - * {@code termPair}: return a {@link SelectEdge} whose left member belongs to {@code termPair.getFirst()} and right to - * {@code termPair.getSecond()}. Returns {@code null} if {@code edge} is absent or does not match this step (the - * caller then falls back to searching). - */ - private SelectEdge orientSelectEdge(final CCParameter[] edge, final SymmetricPair termPair, - final CCParameter weakpathindex) { - if (edge == null) { - return null; - } - if (belongsTo(edge[0], termPair.getFirst(), weakpathindex) - && belongsTo(edge[1], termPair.getSecond(), weakpathindex)) { - return new SelectEdge(edge[0], edge[1]); - } - if (belongsTo(edge[1], termPair.getFirst(), weakpathindex) - && belongsTo(edge[0], termPair.getSecond(), weakpathindex)) { - return new SelectEdge(edge[1], edge[0]); - } - return null; - } - - /** Whether {@code sel} is the select-at-weakpathindex on {@code array}, or {@code array} is {@code (const sel)}. */ - private boolean belongsTo(final CCParameter sel, final CCTerm array, final CCParameter weakpathindex) { - return isConst(array, sel) || isSelect(sel, array, weakpathindex); - } - - private SelectEdge goodSelectStep(final CCTerm sel1, final CCTerm sel2, final Rational offset, - final SymmetricPair termPair, final CCParameter weakpathindex) { - CCParameter lhs = null; - if (ArrayTheory.isConstTerm(termPair.getFirst())) { - final CCParameter constVal1 = ArrayTheory.getValueFromConst((CCAppTerm) termPair.getFirst()); - if (constVal1.getCCTerm() == sel1) { - lhs = constVal1; - } - } - if (lhs == null && isSelect(sel1, termPair.getFirst(), weakpathindex)) { - lhs = sel1; - } - CCParameter rhs = null; - if (ArrayTheory.isConstTerm(termPair.getSecond())) { - final CCParameter constVal2 = ArrayTheory.getValueFromConst((CCAppTerm) termPair.getSecond()); - if (constVal2.getCCTerm() == sel2) { - rhs = constVal2; - } - } - if (rhs == null && isSelect(sel2, termPair.getSecond(), weakpathindex)) { - rhs = sel2; - } - if (lhs != null && rhs != null && offset.equals(rhs.getOffset().sub(lhs.getOffset()))) { - return new SelectEdge(lhs, rhs); - } - return null; - } - - /** - * Check if select is a select on array on weakpathindex or something equal to weakpathindex. - */ - private boolean isSelect(final CCParameter select, final CCTerm array, final CCParameter weakpathindex) { - if (!ArrayTheory.isSelectTerm(select) || ArrayTheory.getArrayFromSelect((CCAppTerm) select) != array) { - return false; - } - final CCParameter index = ArrayTheory.getIndexFromSelect((CCAppTerm) select); - return (index.equals(weakpathindex) - || mAllEqualities.contains(new OffsetPair(weakpathindex, index))); - } - /** * Check if array is an application of const on value */ diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/WeakCongruencePath.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/WeakCongruencePath.java index 27dd9257a..6934ef5fe 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/WeakCongruencePath.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/WeakCongruencePath.java @@ -90,6 +90,13 @@ public CCParameter getIndex() { return mIdx; } + public CCParameter[] getSelectEdge() { + if (mSelectLeft == null) { + return null; + } + return new CCParameter[] { mSelectLeft, mSelectRight }; + } + public CCParameter getSelectLeft() { return mSelectLeft; } @@ -277,15 +284,15 @@ private WeakSubPath computeWeakCongruencePath(final CCTerm array1, final CCTerm path.addSubPath(computeWeakPath(selectArray, array2, index, produceProofs)); select2 = select; } + // Record the select/const edge so the proof/interpolation consumers need not search for it. select1 is on the + // array1 (path[0]) side, select2 on the array2 (path[last]) side, matching the emitted path order. + path.setSelectEdge(select1, select2); // compute the path between the selects or between select and constant value. assert select1.getRepresentative() == select2.getRepresentative(); // check for trivial select edge (select-const). if (select1 != select2) { computePath(select1, select2); } - // Record the select/const edge so the proof/interpolation consumers need not search for it. select1 is on the - // array1 (path[0]) side, select2 on the array2 (path[last]) side, matching the emitted path order. - path.setSelectEdge(select1, select2); return path; } From 694da7ab5baca4781e302977b0e0d91803fb1bb9 Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Sat, 4 Jul 2026 16:57:39 +0200 Subject: [PATCH 64/75] Canonicalize sums as offset-free part plus trailing constant TermCompiler.unifyPolynomial now unifies only the constant-free part of a polynomial and re-attaches the constant with CCParameter.addConstant, so every canonic sum is the canonic offset-free term with the constant as last summand, byte-identical to the flat terms that annotations build via addConstant. A constant base folds into a plain constant (5, not (+ 0 5)), a zero offset returns the term unchanged, and Clausifier.addConstantToTerm is just CCParameter.addConstant now. This lets ProofSimplifier.OffsetEqKey drop the Polynomial summand hashmaps: the new OffsetTerm splits a side purely syntactically into offset-free part (a Term, compared by identity; the 0 term for pure constants) and constant offset. BvToIntUtils was the one producer bypassing the unifier with direct Polynomial.toTerm calls (e.g. normalizeMod emitted the constant mid-sum, breaking the syntactic key on abv/indexInRange01 and ufbv/ufbv01); its polynomial-term exits now go through the unifier. Co-Authored-By: Claude Fable 5 --- SMTInterpol/doc/offset-equality-plan.md | 26 ++++++ .../smtinterpol/convert/Clausifier.java | 4 +- .../smtinterpol/convert/TermCompiler.java | 38 +++++--- .../smtinterpol/proof/ProofSimplifier.java | 89 +++++++++++++------ .../theory/bitvector/BvToIntUtils.java | 24 ++--- .../theory/cclosure/CCParameter.java | 17 +++- 6 files changed, 143 insertions(+), 55 deletions(-) diff --git a/SMTInterpol/doc/offset-equality-plan.md b/SMTInterpol/doc/offset-equality-plan.md index f25d5aa28..6b1260c5d 100644 --- a/SMTInterpol/doc/offset-equality-plan.md +++ b/SMTInterpol/doc/offset-equality-plan.md @@ -1216,6 +1216,32 @@ value carrying a non-zero offset — i.e. offsets *under proofs*. The edge is no delivered explicitly (bare select + full const value), so this handling no longer has to contend with the search ambiguity; it is the natural next step. +## Syntactic `OffsetEqKey` (constant-last canonic sums) — DONE (uncommitted) + +`ProofSimplifier.OffsetEqKey` no longer parses its sides with `Polynomial` into +summand hashmaps; a new `OffsetTerm` splits a side purely syntactically — a trailing +constant summand of a `+` application is the offset, dropping it yields the +offset-free part (a plain `Term`, compared by identity). A side that is entirely +constant splits into the base `0` term and its value as offset, mirroring the `0` +base CCTerm of a plain-numeral parameter; correspondingly `CCParameter.addConstant` +folds a constant base into a plain constant (`5`, not `(+ 0 5)`), agreeing with the +canonic term of that value (and with `Clausifier.addConstantToTerm`). Constants in +any *other* position stay inside the offset-free part (harmless when offsets are +off, since keys then only compare identical flat terms). + +This is sound because the split is now the exact inverse of term construction: +`TermCompiler.unifyPolynomial` canonicalizes a constant-carrying polynomial as +`CCParameter.addConstant(unifyPolynomial(constantFreePart), constant)`, so canonic +compiler terms, `Clausifier.addConstantToTerm` results and annotation flat terms +(`CCParameter.getFlatTerm`/`addConstant`) are all byte-identical per value, with the +constant last. `BvToIntUtils` was the one producer bypassing the unifier (direct +`Polynomial.toTerm`, e.g. `normalizeMod` emitted `(+ x 255 (* -256 (div …)))` with +the constant mid-sum, which made `resolveNeededEqualities` miss the clause literal +and emit an invalid trichotomy farkas on `abv/indexInRange01`, `ufbv/ufbv01`); all +its polynomial-term exits now go through `mPolyUnifier`. Full unit suite matches the +baseline (only the known pre-existing SystemTest failures). Files: +`ProofSimplifier.java`, `TermCompiler.java`, `BvToIntUtils.java`, `CCParameter.java`. + ## Implementable slice (ready to start) 1. `LASharedTerm` becomes offset-free under `createOffsetEqualities()` (revert the diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/Clausifier.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/Clausifier.java index db84ef1c9..b663b3521 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/Clausifier.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/Clausifier.java @@ -631,9 +631,7 @@ public Rational getTermConstant(final Term term) { * nested {@code (+ term constant)}), so that re-parsing it as a Polynomial recovers all summands. */ public Term addConstantToTerm(final Term term, final Rational constant) { - final Polynomial poly = new Polynomial(term); - poly.add(constant); - return mCompiler.unifyPolynomial(poly, term.getSort()); + return CCParameter.addConstant(term, constant); } /** diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/TermCompiler.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/TermCompiler.java index 0d207586e..9d0ea39da 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/TermCompiler.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/TermCompiler.java @@ -47,6 +47,7 @@ import de.uni_freiburg.informatik.ultimate.smtinterpol.proof.ProofConstants; import de.uni_freiburg.informatik.ultimate.smtinterpol.proof.resolute.BitvectorRules; import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.bitvector.BvToIntUtils; +import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CCParameter; import de.uni_freiburg.informatik.ultimate.smtinterpol.util.IPolynomialUnifier; import de.uni_freiburg.informatik.ultimate.smtinterpol.util.Polynomial; import de.uni_freiburg.informatik.ultimate.smtinterpol.util.TermUtils; @@ -783,24 +784,39 @@ public void postConvertAnnotation(final AnnotatedTerm old, final Annotation[] ne } /** - * Canonicalize a polynomial, i.e. check if we already created this term with the summands in a different order. + * Canonicalize a polynomial, i.e. check if we already created this term with + * the summands in a different order. * - * @param poly - * the polynomial to canonicalize and convert to a term. - * @param sort - * the Sort of the resulting term. + *

+ * A polynomial with a non-zero constant (and at least one other summand) is + * canonicalized as the canonic term of its constant-free part with the constant + * appended as last summand ({@link CCParameter#addConstant}). Thus a + * canonic term and its offset-free term (the flat term of the base CCTerm when + * offset equalities are enabled) agree summand-for-summand, and dropping the + * trailing constant from a canonic term syntactically recovers its canonic + * offset-free term. + * + * @param poly the polynomial to canonicalize and convert to a term. + * @param sort the Sort of the resulting term. * @return the canonic summation term. */ @Override public Term unifyPolynomial(final Polynomial poly, final Sort sort) { - final int hash = poly.hashCode() ^ sort.hashCode(); + Polynomial basePoly = poly; + final Rational offset = poly.getConstant(); + if (offset != Rational.ZERO) { + basePoly = new Polynomial(); + basePoly.add(Rational.ONE, poly); + basePoly.add(offset.negate()); + } + final int hash = basePoly.hashCode() ^ sort.hashCode(); for (final Term canonic : mCanonicalPolys.iterateHashCode(hash)) { - if (canonic.getSort() == sort && poly.equals(new Polynomial(canonic))) { - return canonic; + if (canonic.getSort() == sort && basePoly.equals(new Polynomial(canonic))) { + return CCParameter.addConstant(canonic, offset); } } - final Term canonic = poly.toTerm(sort); + final Term canonic = basePoly.toTerm(sort); mCanonicalPolys.put(hash, canonic); - return canonic; + return CCParameter.addConstant(canonic, offset); } -} \ No newline at end of file +} diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/proof/ProofSimplifier.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/proof/ProofSimplifier.java index 92d9fbab1..e9631a577 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/proof/ProofSimplifier.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/proof/ProofSimplifier.java @@ -4593,7 +4593,7 @@ private Term resolveNeededEqualities(Term proof, final Map al } else { // need shifted offset final OffsetEqKey clauseEqKey = new OffsetEqKey(clauseEqParam[0], clauseEqParam[1]); - final Rational factor = (clauseEqKey.mLhs.equals(eqKey.mLhs) ? Rational.ONE : Rational.MONE); + final Rational factor = (clauseEqKey.mLhs == eqKey.mLhs ? Rational.ONE : Rational.MONE); final Term bridge = mProofUtils.proveEqWithMultiplier(clauseEqParam, eqParam, factor); proof = res(eq, bridge, proof); } @@ -4613,7 +4613,7 @@ private Term resolveNeededEqualities(Term proof, final Map al } else { // need shifted offset final OffsetEqKey clauseEqKey = new OffsetEqKey(clauseEqParam[0], clauseEqParam[1]); - final Rational factor = (clauseEqKey.mLhs.equals(eqKey.mLhs) ? Rational.ONE : Rational.MONE); + final Rational factor = (clauseEqKey.mLhs == eqKey.mLhs ? Rational.ONE : Rational.MONE); final Term bridge = mProofUtils.proveEqWithMultiplier(eqParam, clauseEqParam, factor); proof = res(eq, proof, bridge); } @@ -4621,34 +4621,75 @@ private Term resolveNeededEqualities(Term proof, final Map al return proof; } + /** + * A numeric term split syntactically into its offset-free part and a constant + * offset: a trailing constant summand of a {@code +} application is the offset, + * and dropping it yields the offset-free part; a term that is entirely constant + * splits into the base {@code 0} and its value (matching the {@code 0} CCTerm + * that a plain-numeral parameter is based on). This is the exact inverse of + * {@code CCParameter.addConstant} and {@code TermCompiler.unifyPolynomial}, which + * both place the constant last behind the canonic offset-free term, so equal + * values always split into byte-identical offset-free parts. A constant summand + * in any other position (possible only when offset equalities are disabled and + * constants never move between equality sides) is deliberately left inside the + * offset-free part. + */ + private static class OffsetTerm { + final Term mTerm; + final Rational mOffset; + + public OffsetTerm(final Term term) { + Term base = term; + Rational offset = Rational.ZERO; + if (base instanceof ApplicationTerm + && ((ApplicationTerm) base).getFunction().getName().equals(SMTLIBConstants.PLUS)) { + final Term[] params = ((ApplicationTerm) base).getParameters(); + final Rational last = Polynomial.parseConstant(params[params.length - 1]); + if (last != null) { + offset = last; + base = params.length == 2 ? params[0] + : term.getTheory().term(SMTLIBConstants.PLUS, Arrays.copyOf(params, params.length - 1)); + } + } + // a plain constant is all offset; its offset-free part is the 0 base term + final Rational baseConstant = Polynomial.parseConstant(base); + if (baseConstant != null) { + offset = offset.add(baseConstant); + base = Rational.ZERO.toTerm(term.getSort()); + } + mTerm = base; + mOffset = offset; + } + } + /** * A lookup key identifying an (offset) equality by the affine fact it expresses: - * the non-constant parts of its two sides together with the constant offset - * {@code constant(lhs) - constant(rhs)} between them. Two equalities with the same - * key denote the same fact {@code lhs = rhs}, which is what lets a needed equality - * like {@code (= (+ x 5) (+ y 7))} be matched against the clause literal - * {@code (= x (+ y 2))}. The two non-constant parts are kept separate - * (rather than subtracted into one difference polynomial) so that unrelated edges - * whose difference polynomials coincide up to sign — e.g. {@code x+y = z+w+2} and - * {@code z-y = x-w-2} — do not collide. + * the offset-free parts of its two sides (see {@link OffsetTerm}) together with + * the constant offset {@code constant(lhs) - constant(rhs)} between them. Two + * equalities with the same key denote the same fact {@code lhs = rhs}, which is + * what lets a needed equality like {@code (= (+ x 5) (+ y 7))} be matched against + * the clause literal {@code (= x (+ y 2))}. The two offset-free parts are kept + * separate (rather than subtracted into one difference polynomial) so + * that unrelated edges whose difference polynomials coincide up to sign — e.g. + * {@code x+y = z+w+2} and {@code z-y = x-w-2} — do not collide. */ private static final class OffsetEqKey { - private final Map, Rational> mLhs; - private final Map, Rational> mRhs; + private final Term mLhs; + private final Term mRhs; private final Rational mOffset; private final int mHash; OffsetEqKey(final Term lhs, final Term rhs) { if (lhs.getSort().isNumericSort()) { - final Polynomial pLhs = new Polynomial(lhs); - final Polynomial pRhs = new Polynomial(rhs); - mOffset = pLhs.getConstant().sub(pRhs.getConstant()); - mLhs = nonConstantPart(pLhs); - mRhs = nonConstantPart(pRhs); + final OffsetTerm lhsSplit = new OffsetTerm(lhs); + final OffsetTerm rhsSplit = new OffsetTerm(rhs); + mLhs = lhsSplit.mTerm; + mRhs = rhsSplit.mTerm; + mOffset = lhsSplit.mOffset.sub(rhsSplit.mOffset); } else { + mLhs = lhs; + mRhs = rhs; mOffset = Rational.ZERO; - mLhs = Collections.singletonMap(Collections.singletonMap(lhs, 1), Rational.ONE); - mRhs = Collections.singletonMap(Collections.singletonMap(rhs, 1), Rational.ONE); } final int lhsHash = mLhs.hashCode(); final int rhsHash = mRhs.hashCode(); @@ -4661,12 +4702,6 @@ private static final class OffsetEqKey { } } - private static Map, Rational> nonConstantPart(final Polynomial poly) { - final Map, Rational> summands = new HashMap<>(poly.getSummands()); - summands.remove(Collections.emptyMap()); - return summands; - } - @Override public int hashCode() { return mHash; @@ -4681,8 +4716,8 @@ public boolean equals(final Object other) { return false; } final OffsetEqKey o = (OffsetEqKey) other; - return (mOffset.equals(o.mOffset) && mLhs.equals(o.mLhs) && mRhs.equals(o.mRhs)) - || (mOffset.equals(o.mOffset.negate()) && mLhs.equals(o.mRhs) && mRhs.equals(o.mLhs)); + return (mOffset.equals(o.mOffset) && mLhs == o.mLhs && mRhs == o.mRhs) + || (mOffset.equals(o.mOffset.negate()) && mLhs == o.mRhs && mRhs == o.mLhs); } } diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/bitvector/BvToIntUtils.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/bitvector/BvToIntUtils.java index de2aa095b..371500707 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/bitvector/BvToIntUtils.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/bitvector/BvToIntUtils.java @@ -89,9 +89,9 @@ public Term normalizeMod(final Term lhs, final Rational maxNumber) { final Polynomial arg0 = new Polynomial(lhs); arg0.mod(maxNumber); final Term div = arg0.isConstant() ? arg0.getConstant().div(maxNumber).floor().toTerm(sort) - : theory.term("div", arg0.toTerm(sort), maxNumber.toTerm(sort)); + : theory.term("div", mPolyUnifier.unifyPolynomial(arg0, sort), maxNumber.toTerm(sort)); arg0.add(maxNumber.negate(), div); - return arg0.toTerm(sort); + return mPolyUnifier.unifyPolynomial(arg0, sort); } private Rational pow2(int exponent) { @@ -164,7 +164,7 @@ public Term translateBvshl(final IProofTracker tracker, final FunctionSymbol fsy final int shiftAsInt = shiftValue.numerator().intValue(); final Polynomial multiply = new Polynomial(); multiply.add(pow2(shiftAsInt), translatedLHS); - transformedAsInt = multiply.toTerm(mInteger); + transformedAsInt = mPolyUnifier.unifyPolynomial(multiply, mInteger); } } else { final int logWidth = log2(width); @@ -175,11 +175,11 @@ public Term translateBvshl(final IProofTracker tracker, final FunctionSymbol fsy final Polynomial compare = new Polynomial(); compare.add(shiftStep); compare.add(Rational.MONE, shift); - final Term cond = theory.term("<=", compare.toTerm(mInteger), zero); + final Term cond = theory.term("<=", mPolyUnifier.unifyPolynomial(compare, mInteger), zero); shift.add(Rational.ONE, theory.term("ite", cond, shiftStep.negate().toTerm(mInteger), zero)); final Polynomial multiply = new Polynomial(); multiply.add(pow2(1 << i), result); - result = theory.term("ite", cond, multiply.toTerm(mInteger), result); + result = theory.term("ite", cond, mPolyUnifier.unifyPolynomial(multiply, mInteger), result); } transformedAsInt = result; } @@ -223,7 +223,7 @@ public Term translateBvshr(final IProofTracker tracker, final FunctionSymbol fsy final Polynomial compare = new Polynomial(); compare.add(shiftStep); compare.add(Rational.MONE, shift); - final Term cond = theory.term("<=", compare.toTerm(mInteger), zero); + final Term cond = theory.term("<=", mPolyUnifier.unifyPolynomial(compare, mInteger), zero); shift.add(Rational.ONE, theory.term("ite", cond, shiftStep.negate().toTerm(mInteger), zero)); final Term divide = theory.term(SMTLIBConstants.DIV, result, pow2(1 << i).toTerm(mInteger)); result = theory.term("ite", cond, divide, result); @@ -318,14 +318,14 @@ private final Term applyMod(final int width, Term integerTerm, boolean isSigned) poly.add(signBit); } poly.mod(maxNumber); - final Term shiftedX = poly.toTerm(mInteger); + final Term shiftedX = mPolyUnifier.unifyPolynomial(poly, mInteger); if (!poly.isConstant()) { poly.add(maxNumber.negate(), theory.term(SMTLIBConstants.DIV, shiftedX, maxNumber.toTerm(mInteger))); } if (isSigned) { poly.add(signBit.negate()); } - return poly.toTerm(mInteger); + return mPolyUnifier.unifyPolynomial(poly, mInteger); } public Term bitBlastAndConstant(final Term lhs, final Rational rhs, int width) { @@ -362,7 +362,7 @@ public Term bitBlastAndConstant(final Term lhs, final Rational rhs, int width) { final Rational powHighRat = Rational.valueOf(powHigh, BigInteger.ONE); result.add(powHighRat.negate(), theory.term(SMTLIBConstants.DIV, lhs, powHighRat.toTerm(mInteger))); } - return result.toTerm(mInteger); + return mPolyUnifier.unifyPolynomial(result, mInteger); } public Term bitBlastAnd(final Term lhs, final Term rhs, int width) { @@ -381,7 +381,7 @@ public Term bitBlastAnd(final Term lhs, final Term rhs, int width) { final Term ite = theory.term("ite", theory.term("=", isel(i, lhs), one), isel(i, rhs), zero); poly.add(pow2(i), ite); } - return poly.toTerm(mInteger); + return mPolyUnifier.unifyPolynomial(poly, mInteger); } public Term translateBvandSum(final IProofTracker tracker, final FunctionSymbol fsym, final Term convertedApp) { @@ -415,7 +415,7 @@ public Term translateBvor(final IProofTracker tracker, final FunctionSymbol fsym final Polynomial poly = new Polynomial(lhs); poly.add(Rational.ONE, rhs); poly.add(Rational.MONE, bitBlastAnd(lhs, rhs, width)); - final Term transformed = int2bv(poly.toTerm(mInteger), bvSort.getIndices()); + final Term transformed = int2bv(mPolyUnifier.unifyPolynomial(poly, mInteger), bvSort.getIndices()); return trackBvRewrite(convertedApp, transformed, ProofConstants.RW_BVBLAST); } @@ -428,7 +428,7 @@ public Term translateBvxor(final IProofTracker tracker, final FunctionSymbol fsy final Polynomial poly = new Polynomial(lhs); poly.add(Rational.ONE, rhs); poly.add(Rational.TWO.negate(), bitBlastAnd(lhs, rhs, width)); - final Term transformed = int2bv(poly.toTerm(mInteger), bvSort.getIndices()); + final Term transformed = int2bv(mPolyUnifier.unifyPolynomial(poly, mInteger), bvSort.getIndices()); return trackBvRewrite(convertedApp, transformed, ProofConstants.RW_BVBLAST); } diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCParameter.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCParameter.java index e4c3989a7..51b336af5 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCParameter.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCParameter.java @@ -21,6 +21,7 @@ import de.uni_freiburg.informatik.ultimate.logic.ApplicationTerm; import de.uni_freiburg.informatik.ultimate.logic.Rational; import de.uni_freiburg.informatik.ultimate.logic.Term; +import de.uni_freiburg.informatik.ultimate.smtinterpol.util.Polynomial; /** * A value of the form {@code getCCTerm() + getOffset()}: a CCTerm together with a constant offset. This is what every @@ -49,8 +50,9 @@ public interface CCParameter { /** * The SMT-LIB term denoting this value: the underlying term when the offset is zero, otherwise {@code (+ term - * offset)}. A bare {@link CCTerm} returns its own flat term unchanged, so offset-free uses are byte-identical; only - * a non-zero (necessarily numeric) offset builds the sum. + * offset)} (or a plain constant when the underlying term is the constant {@code 0}). A bare {@link CCTerm} returns + * its own flat term unchanged, so offset-free uses are byte-identical; only a non-zero (necessarily numeric) offset + * builds the sum. */ Term getFlatTerm(); @@ -96,8 +98,19 @@ static CCParameter of(final CCTerm term, final Rational offset) { * sum would be treated as an opaque monomial and could not be related arithmetically to the original flat parameter * term that the {@link de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CCTermBuilder} split into this * {@code (base, offset)} pair. Flattening reconstructs that original parameter term. + * + *

A constant {@code term} (the base {@code 0} of a plain-numeral parameter) folds into a plain constant, e.g. + * {@code 5} rather than {@code (+ 0 5)}, matching the canonic term of that value. A zero offset returns + * {@code term} unchanged. */ static Term addConstant(final Term term, final Rational offset) { + if (offset.equals(Rational.ZERO)) { + return term; + } + final Rational termValue = Polynomial.parseConstant(term); + if (termValue != null) { + return termValue.add(offset).toTerm(term.getSort()); + } final Term offsetTerm = offset.toTerm(term.getSort()); if (term instanceof ApplicationTerm && ((ApplicationTerm) term).getFunction().getName().equals("+")) { final Term[] inner = ((ApplicationTerm) term).getParameters(); From 135564b227c5b4802da6395b216a8d5415851ebb Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Sat, 4 Jul 2026 22:49:38 +0200 Subject: [PATCH 65/75] Move OffsetEqKey and OffsetTerm to util package. --- .../smtinterpol/proof/ProofSimplifier.java | 105 +----------------- .../smtinterpol/util/OffsetEqKey.java | 93 ++++++++++++++++ .../ultimate/smtinterpol/util/OffsetTerm.java | 79 +++++++++++++ 3 files changed, 175 insertions(+), 102 deletions(-) create mode 100644 SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/util/OffsetEqKey.java create mode 100644 SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/util/OffsetTerm.java diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/proof/ProofSimplifier.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/proof/ProofSimplifier.java index e9631a577..dd34fa0dd 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/proof/ProofSimplifier.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/proof/ProofSimplifier.java @@ -56,6 +56,7 @@ import de.uni_freiburg.informatik.ultimate.smtinterpol.proof.resolute.ProofLiteral; import de.uni_freiburg.informatik.ultimate.smtinterpol.proof.resolute.ProofRules; import de.uni_freiburg.informatik.ultimate.smtinterpol.smtlib2.SMTInterpol; +import de.uni_freiburg.informatik.ultimate.smtinterpol.util.OffsetEqKey; import de.uni_freiburg.informatik.ultimate.smtinterpol.util.Polynomial; /** @@ -4593,7 +4594,7 @@ private Term resolveNeededEqualities(Term proof, final Map al } else { // need shifted offset final OffsetEqKey clauseEqKey = new OffsetEqKey(clauseEqParam[0], clauseEqParam[1]); - final Rational factor = (clauseEqKey.mLhs == eqKey.mLhs ? Rational.ONE : Rational.MONE); + final Rational factor = (clauseEqKey.getLhs() == eqKey.getLhs() ? Rational.ONE : Rational.MONE); final Term bridge = mProofUtils.proveEqWithMultiplier(clauseEqParam, eqParam, factor); proof = res(eq, bridge, proof); } @@ -4613,7 +4614,7 @@ private Term resolveNeededEqualities(Term proof, final Map al } else { // need shifted offset final OffsetEqKey clauseEqKey = new OffsetEqKey(clauseEqParam[0], clauseEqParam[1]); - final Rational factor = (clauseEqKey.mLhs == eqKey.mLhs ? Rational.ONE : Rational.MONE); + final Rational factor = (clauseEqKey.getLhs() == eqKey.getLhs() ? Rational.ONE : Rational.MONE); final Term bridge = mProofUtils.proveEqWithMultiplier(eqParam, clauseEqParam, factor); proof = res(eq, proof, bridge); } @@ -4621,106 +4622,6 @@ private Term resolveNeededEqualities(Term proof, final Map al return proof; } - /** - * A numeric term split syntactically into its offset-free part and a constant - * offset: a trailing constant summand of a {@code +} application is the offset, - * and dropping it yields the offset-free part; a term that is entirely constant - * splits into the base {@code 0} and its value (matching the {@code 0} CCTerm - * that a plain-numeral parameter is based on). This is the exact inverse of - * {@code CCParameter.addConstant} and {@code TermCompiler.unifyPolynomial}, which - * both place the constant last behind the canonic offset-free term, so equal - * values always split into byte-identical offset-free parts. A constant summand - * in any other position (possible only when offset equalities are disabled and - * constants never move between equality sides) is deliberately left inside the - * offset-free part. - */ - private static class OffsetTerm { - final Term mTerm; - final Rational mOffset; - - public OffsetTerm(final Term term) { - Term base = term; - Rational offset = Rational.ZERO; - if (base instanceof ApplicationTerm - && ((ApplicationTerm) base).getFunction().getName().equals(SMTLIBConstants.PLUS)) { - final Term[] params = ((ApplicationTerm) base).getParameters(); - final Rational last = Polynomial.parseConstant(params[params.length - 1]); - if (last != null) { - offset = last; - base = params.length == 2 ? params[0] - : term.getTheory().term(SMTLIBConstants.PLUS, Arrays.copyOf(params, params.length - 1)); - } - } - // a plain constant is all offset; its offset-free part is the 0 base term - final Rational baseConstant = Polynomial.parseConstant(base); - if (baseConstant != null) { - offset = offset.add(baseConstant); - base = Rational.ZERO.toTerm(term.getSort()); - } - mTerm = base; - mOffset = offset; - } - } - - /** - * A lookup key identifying an (offset) equality by the affine fact it expresses: - * the offset-free parts of its two sides (see {@link OffsetTerm}) together with - * the constant offset {@code constant(lhs) - constant(rhs)} between them. Two - * equalities with the same key denote the same fact {@code lhs = rhs}, which is - * what lets a needed equality like {@code (= (+ x 5) (+ y 7))} be matched against - * the clause literal {@code (= x (+ y 2))}. The two offset-free parts are kept - * separate (rather than subtracted into one difference polynomial) so - * that unrelated edges whose difference polynomials coincide up to sign — e.g. - * {@code x+y = z+w+2} and {@code z-y = x-w-2} — do not collide. - */ - private static final class OffsetEqKey { - private final Term mLhs; - private final Term mRhs; - private final Rational mOffset; - private final int mHash; - - OffsetEqKey(final Term lhs, final Term rhs) { - if (lhs.getSort().isNumericSort()) { - final OffsetTerm lhsSplit = new OffsetTerm(lhs); - final OffsetTerm rhsSplit = new OffsetTerm(rhs); - mLhs = lhsSplit.mTerm; - mRhs = rhsSplit.mTerm; - mOffset = lhsSplit.mOffset.sub(rhsSplit.mOffset); - } else { - mLhs = lhs; - mRhs = rhs; - mOffset = Rational.ZERO; - } - final int lhsHash = mLhs.hashCode(); - final int rhsHash = mRhs.hashCode(); - if (lhsHash == rhsHash) { - mHash = lhsHash * 31 + mOffset.abs().hashCode(); - } else if (lhsHash < rhsHash) { - mHash = lhsHash * 37 + rhsHash * 31 + mOffset.hashCode(); - } else { - mHash = rhsHash * 37 + lhsHash * 31 + mOffset.negate().hashCode(); - } - } - - @Override - public int hashCode() { - return mHash; - } - - @Override - public boolean equals(final Object other) { - if (this == other) { - return true; - } - if (!(other instanceof OffsetEqKey)) { - return false; - } - final OffsetEqKey o = (OffsetEqKey) other; - return (mOffset.equals(o.mOffset) && mLhs == o.mLhs && mRhs == o.mRhs) - || (mOffset.equals(o.mOffset.negate()) && mLhs == o.mRhs && mRhs == o.mLhs); - } - } - /** * Convert a clause term into an Array of terms, one entry for each disjunct. * This also handles singleton and empty clause correctly. diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/util/OffsetEqKey.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/util/OffsetEqKey.java new file mode 100644 index 000000000..032297f39 --- /dev/null +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/util/OffsetEqKey.java @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2009-2026 University of Freiburg + * + * This file is part of SMTInterpol. + * + * SMTInterpol is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * SMTInterpol is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with SMTInterpol. If not, see . + */ +package de.uni_freiburg.informatik.ultimate.smtinterpol.util; + +import de.uni_freiburg.informatik.ultimate.logic.Rational; +import de.uni_freiburg.informatik.ultimate.logic.Term; + +/** + * A lookup key identifying an (offset) equality by the affine fact it expresses: + * the offset-free parts of its two sides (see {@link OffsetTerm}) together with + * the constant offset {@code constant(lhs) - constant(rhs)} between them. Two + * equalities with the same key denote the same fact {@code lhs = rhs}, which is + * what lets a needed equality like {@code (= (+ x 5) (+ y 7))} be matched against + * the clause literal {@code (= x (+ y 2))}. The two offset-free parts are kept + * separate (rather than subtracted into one difference polynomial) so + * that unrelated edges whose difference polynomials coincide up to sign — e.g. + * {@code x+y = z+w+2} and {@code z-y = x-w-2} — do not collide. + */ +public final class OffsetEqKey { + private final Term mLhs; + private final Term mRhs; + private final Rational mOffset; + private final int mHash; + + public OffsetEqKey(final Term lhs, final Term rhs) { + if (lhs.getSort().isNumericSort()) { + final OffsetTerm lhsSplit = new OffsetTerm(lhs); + final OffsetTerm rhsSplit = new OffsetTerm(rhs); + mLhs = lhsSplit.getTerm(); + mRhs = rhsSplit.getTerm(); + mOffset = lhsSplit.getOffset().sub(rhsSplit.getOffset()); + } else { + mLhs = lhs; + mRhs = rhs; + mOffset = Rational.ZERO; + } + final int lhsHash = mLhs.hashCode(); + final int rhsHash = mRhs.hashCode(); + if (lhsHash == rhsHash) { + mHash = lhsHash * 31 + mOffset.abs().hashCode(); + } else if (lhsHash < rhsHash) { + mHash = lhsHash * 37 + rhsHash * 31 + mOffset.hashCode(); + } else { + mHash = rhsHash * 37 + lhsHash * 31 + mOffset.negate().hashCode(); + } + } + + public Term getLhs() { + return mLhs; + } + + public Term getRhs() { + return mRhs; + } + + public Rational getOffset() { + return mOffset; + } + + @Override + public int hashCode() { + return mHash; + } + + @Override + public boolean equals(final Object other) { + if (this == other) { + return true; + } + if (!(other instanceof OffsetEqKey)) { + return false; + } + final OffsetEqKey o = (OffsetEqKey) other; + return (mOffset.equals(o.mOffset) && mLhs == o.mLhs && mRhs == o.mRhs) + || (mOffset.equals(o.mOffset.negate()) && mLhs == o.mRhs && mRhs == o.mLhs); + } +} diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/util/OffsetTerm.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/util/OffsetTerm.java new file mode 100644 index 000000000..37a78bcdf --- /dev/null +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/util/OffsetTerm.java @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2009-2026 University of Freiburg + * + * This file is part of SMTInterpol. + * + * SMTInterpol is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * SMTInterpol is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with SMTInterpol. If not, see . + */ +package de.uni_freiburg.informatik.ultimate.smtinterpol.util; + +import java.util.Arrays; + +import de.uni_freiburg.informatik.ultimate.logic.ApplicationTerm; +import de.uni_freiburg.informatik.ultimate.logic.ConstantTerm; +import de.uni_freiburg.informatik.ultimate.logic.Rational; +import de.uni_freiburg.informatik.ultimate.logic.SMTLIBConstants; +import de.uni_freiburg.informatik.ultimate.logic.Term; + +/** + * A numeric term split syntactically into its offset-free part and a constant + * offset: a trailing constant summand of a {@code +} application is the offset, + * and dropping it yields the offset-free part; a term that is entirely constant + * splits into the base {@code 0} and its value (matching the {@code 0} CCTerm + * that a plain-numeral parameter is based on). This is the exact inverse of + * {@code CCParameter.addConstant} and {@code TermCompiler.unifyPolynomial}, which + * both place the constant last behind the canonic offset-free term, so equal + * values always split into byte-identical offset-free parts. A constant summand + * in any other position (possible only when offset equalities are disabled and + * constants never move between equality sides) is deliberately left inside the + * offset-free part. + */ +public class OffsetTerm { + private final Term mTerm; + private final Rational mOffset; + + public OffsetTerm(final Term term) { + Term base = term; + Rational offset = Rational.ZERO; + if (term.getSort().isNumericSort()) { + if (base instanceof ApplicationTerm + && ((ApplicationTerm) base).getFunction().getName().equals(SMTLIBConstants.PLUS)) { + final Term[] params = ((ApplicationTerm) base).getParameters(); + final Rational last = Polynomial.parseConstant(params[params.length - 1]); + if (last != null) { + offset = last; + base = params.length == 2 ? params[0] + : term.getTheory().term(SMTLIBConstants.PLUS, Arrays.copyOf(params, params.length - 1)); + } + } else if (base instanceof ConstantTerm) { + // a plain constant is all offset; its offset-free part is the 0 base term + final Rational baseConstant = Polynomial.parseConstant(base); + if (baseConstant != null) { + offset = offset.add(baseConstant); + base = Rational.ZERO.toTerm(term.getSort()); + } + } + } + mTerm = base; + mOffset = offset; + } + + public Term getTerm() { + return mTerm; + } + + public Rational getOffset() { + return mOffset; + } +} From 5cd35cb688da3b60b35cf5bc8cf617b6a0957d33 Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Sun, 5 Jul 2026 13:21:21 +0200 Subject: [PATCH 66/75] Support offset equalities in CCInterpolator. An offset equality x = y + k can justify a path step x+k1 = y+(k+k1). The mixed variable of the literal names the shared value of its own two sides, so at path level it stands for mixedVar + k1, and an EQ placeholder equating it with a path-level shared term s must use EQ(mixedVar, s - k1). The new OffsetLitInfo wraps a LitInfo with the shift and orientation of a literal instance relative to the literal (one shift suffices: a key match forces both sides to shift equally). It provides getMixedBoundary() (mixedVar + shift), buildEQ() (normalized via Polynomial), and isLeftALocal/isLeftBLocal, which also fix the orientation hazard that OffsetEqKey matches flipped pairs. OffsetEqKey now retains the absolute side offsets and computes shift/orientation between two equal keys. CCInterpolator routes all lookups through getEqualityInfo(), reorients the clause disequality against the path endpoints (it can itself be shifted), and closeAPathMixed takes the OffsetLitInfo to emit the shifted EQ. interpolateInstantiation needs no change since it uses the atom's own parameters. The new test dt_project_offset001 exercises a mixed dt-project equality used at a non-zero shift in a congruence. Co-Authored-By: Claude Fable 5 --- .../interpolate/CCInterpolator.java | 100 ++++++----- .../interpolate/OffsetLitInfo.java | 161 ++++++++++++++++++ .../smtinterpol/util/OffsetEqKey.java | 42 +++++ .../datatype/dt_project_offset001.smt2 | 30 ++++ 4 files changed, 291 insertions(+), 42 deletions(-) create mode 100644 SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/interpolate/OffsetLitInfo.java create mode 100644 SMTInterpolTest/test/interpolation/datatype/dt_project_offset001.smt2 diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/interpolate/CCInterpolator.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/interpolate/CCInterpolator.java index 6e925164d..b1bc3106d 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/interpolate/CCInterpolator.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/interpolate/CCInterpolator.java @@ -34,7 +34,7 @@ import de.uni_freiburg.informatik.ultimate.smtinterpol.interpolate.Interpolator.LitInfo; import de.uni_freiburg.informatik.ultimate.smtinterpol.interpolate.Interpolator.Occurrence; import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.linar.InfinitesimalNumber; -import de.uni_freiburg.informatik.ultimate.smtinterpol.util.SymmetricPair; +import de.uni_freiburg.informatik.ultimate.smtinterpol.util.OffsetEqKey; /** * The interpolator for congruence-closure theory lemmata. @@ -44,8 +44,8 @@ public class CCInterpolator { Interpolator mInterpolator; - LitInfo mDiseqOccurrences; - HashMap, LitInfo> mEqualityOccurrences; + OffsetLitInfo mDiseqInfo; + HashMap mEqualityOccurrences; Theory mTheory; int mNumInterpolants; @@ -172,13 +172,13 @@ public void openAPath(final PathEnd other, final Term boundaryTerm, final Occurr } } - public void closeAPathMixed(final TermVariable mixedVar, final Occurrence occur) { + public void closeAPathMixed(final OffsetLitInfo diseqInfo, final Occurrence occur) { // go to the mixed parent and insert the EQ to the boundary term for all partitions on the path. while (occur.isBLocal(mColor)) { final int color = mColor; mColor = getParent(color); assert color < mMaxColor; - mInterpolants[color].add(mTheory.term(Interpolator.EQ, mixedVar, mTerm[color])); + mInterpolants[color].add(diseqInfo.buildEQ(mTerm[color])); mTerm[color] = null; } } @@ -244,19 +244,20 @@ private Term[] interpolateCongruence(final ApplicationTerm left, final Applicati final Term[] interpolants = new Term[mNumInterpolants]; final Term[] leftParams = left.getParameters(); final Term[] rightParams = right.getParameters(); - final LitInfo[] paramInfos = new LitInfo[leftParams.length]; + final OffsetLitInfo[] paramInfos = new OffsetLitInfo[leftParams.length]; assert left.getFunction() == right.getFunction() && leftParams.length == rightParams.length; + final OffsetLitInfo diseqInfo = mDiseqInfo.reorient(new OffsetEqKey(left, right)); for (int i = 0; i < leftParams.length; i++) { if (leftParams[i] == rightParams[i]) { paramInfos[i] = null; } else { - paramInfos[i] = mEqualityOccurrences.get(new SymmetricPair<>(leftParams[i], rightParams[i])); + paramInfos[i] = getEqualityInfo(leftParams[i], rightParams[i]); } } for (int part = 0; part < mNumInterpolants; part++) { - if (mDiseqOccurrences.isBorShared(part)) { + if (diseqInfo.isBorShared(part)) { final ArrayDeque terms = new ArrayDeque<>(leftParams.length); for (int paramNr = 0; paramNr < leftParams.length; paramNr++) { // Collect A-local literals. @@ -264,14 +265,14 @@ private Term[] interpolateCongruence(final ApplicationTerm left, final Applicati terms.add(mTheory.term("=", leftParams[paramNr], rightParams[paramNr])); } else if (paramInfos[paramNr] != null && paramInfos[paramNr].isMixed(part)) { // Collect A-local parts in mixed parameter equalities. - final TermVariable mixedVar = paramInfos[paramNr].getMixedVar(); - final Term sideA = paramInfos[paramNr].getLhsOccur().isALocal(part) ? leftParams[paramNr] + final Term mixedBoundary = paramInfos[paramNr].getMixedBoundary(); + final Term sideA = paramInfos[paramNr].isLeftALocal(part) ? leftParams[paramNr] : rightParams[paramNr]; - terms.add(mTheory.term("=", mixedVar, sideA)); + terms.add(mTheory.term("=", mixedBoundary, sideA)); } } interpolants[part] = mTheory.and(terms.toArray(new Term[terms.size()])); - } else if (mDiseqOccurrences.isALocal(part)) { + } else if (diseqInfo.isALocal(part)) { final ArrayDeque terms = new ArrayDeque<>(leftParams.length); for (int paramNr = 0; paramNr < leftParams.length; paramNr++) { // Collect negated B-local literals. @@ -279,10 +280,10 @@ private Term[] interpolateCongruence(final ApplicationTerm left, final Applicati terms.add(mTheory.not(mTheory.term("=", leftParams[paramNr], rightParams[paramNr]))); } else if (paramInfos[paramNr] != null && paramInfos[paramNr].isMixed(part)) { // Collect B-local parts in mixed parameter equalities. - final TermVariable mixedVar = paramInfos[paramNr].getMixedVar(); - final Term sideB = paramInfos[paramNr].getLhsOccur().isBLocal(part) ? leftParams[paramNr] + final Term mixedBoundary = paramInfos[paramNr].getMixedBoundary(); + final Term sideB = paramInfos[paramNr].isLeftBLocal(part) ? leftParams[paramNr] : rightParams[paramNr]; - terms.add(mTheory.not(mTheory.term("=", mixedVar, sideB))); + terms.add(mTheory.not(mTheory.term("=", mixedBoundary, sideB))); } } interpolants[part] = mTheory.or(terms.toArray(new Term[terms.size()])); @@ -296,8 +297,8 @@ private Term[] interpolateCongruence(final ApplicationTerm left, final Applicati // term occurs left and right, so this is obviously shared boundaryTerms[paramNr] = leftParams[paramNr]; } else if (paramInfos[paramNr].isMixed(part)) { - // mixed case: take mixed var - boundaryTerms[paramNr] = paramInfos[paramNr].getMixedVar(); + // mixed case: take mixed var (shifted to the level of the congruence arguments) + boundaryTerms[paramNr] = paramInfos[paramNr].getMixedBoundary(); } else if (paramInfos[paramNr].isAorShared(part)) { // the argument of the B-local side of the congruence is shared boundaryTerms[paramNr] = isLeftAlocal ? rightParams[paramNr] : leftParams[paramNr]; @@ -310,7 +311,7 @@ private Term[] interpolateCongruence(final ApplicationTerm left, final Applicati } } final Term sharedTerm = mTheory.term(left.getFunction(), boundaryTerms); - interpolants[part] = mTheory.term(Interpolator.EQ, mDiseqOccurrences.getMixedVar(), sharedTerm); + interpolants[part] = diseqInfo.buildEQ(sharedTerm); } } return interpolants; @@ -344,25 +345,26 @@ public Term[] interpolateTransitivity() { for (int i = 0; i < mPath.length - 1; i++) { final Term left = mPath[i]; final Term right = mPath[i + 1]; - final LitInfo info = mEqualityOccurrences.get(new SymmetricPair<>(left, right)); - mTail.closeAPath(mHead, left, info); - mTail.openAPath(mHead, left, info); + final OffsetLitInfo info = getEqualityInfo(left, right); + mTail.closeAPath(mHead, left, info.getLitInfo()); + mTail.openAPath(mHead, left, info.getLitInfo()); if (info.getMixedVar() != null) { final Occurrence occ = mInterpolator.getOccurrence(right); - mTail.closeAPath(mHead, info.getMixedVar(), occ); - mTail.openAPath(mHead, info.getMixedVar(), occ); + mTail.closeAPath(mHead, info.getMixedBoundary(), occ); + mTail.openAPath(mHead, info.getMixedBoundary(), occ); } } // add the disequality if present - if (mDiseqOccurrences != null) { - mTail.closeAPath(mHead, tailTerm, mDiseqOccurrences); - mTail.openAPath(mHead, tailTerm, mDiseqOccurrences); - mHead.closeAPath(mTail, headTerm, mDiseqOccurrences); - mHead.openAPath(mTail, headTerm, mDiseqOccurrences); - - if (mDiseqOccurrences.getMixedVar() != null) { - mTail.closeAPathMixed(mDiseqOccurrences.getMixedVar(), headOccur); - mHead.closeAPathMixed(mDiseqOccurrences.getMixedVar(), tailOccur); + if (mDiseqInfo != null) { + final OffsetLitInfo diseqInfo = mDiseqInfo.reorient(new OffsetEqKey(headTerm, tailTerm)); + mTail.closeAPath(mHead, tailTerm, diseqInfo.getLitInfo()); + mTail.openAPath(mHead, tailTerm, diseqInfo.getLitInfo()); + mHead.closeAPath(mTail, headTerm, diseqInfo.getLitInfo()); + mHead.openAPath(mTail, headTerm, diseqInfo.getLitInfo()); + + if (diseqInfo.getMixedVar() != null) { + mTail.closeAPathMixed(diseqInfo, headOccur); + mHead.closeAPathMixed(diseqInfo, tailOccur); } } // close the still open ends where head and tail have different color. The headTerm/tailTerm must be @@ -424,13 +426,13 @@ public Term[] interpolateInstantiation(final InterpolatorClauseInfo proofTermInf terms.add(lits[i]); } else if (litInfo.isMixed(part)) { final TermVariable mixedVar = litInfo.getMixedVar(); - InterpolatorAtomInfo atomInfo = mInterpolator.getAtomTermInfo(atom); + final InterpolatorAtomInfo atomInfo = mInterpolator.getAtomTermInfo(atom); if (atomInfo.isCCEquality()) { // Collect B-part from splitting mixed literal. final Term sideB = litInfo.getLhsOccur().isBLocal(part) ? ((ApplicationTerm) atom).getParameters()[0] : ((ApplicationTerm) atom).getParameters()[1]; - + if (mInterpolator.isNegatedTerm(lits[i])) { terms.add(mTheory.not(mTheory.term(SMTLIBConstants.EQUALS, mixedVar, sideB))); } else { @@ -480,7 +482,7 @@ public Term[] interpolateInstantiation(final InterpolatorClauseInfo proofTermInf } else if (litInfo.isMixed(part)) { // Collect A-part from splitting mixed literal. final TermVariable mixedVar = litInfo.getMixedVar(); - InterpolatorAtomInfo atomInfo = mInterpolator.getAtomTermInfo(atom); + final InterpolatorAtomInfo atomInfo = mInterpolator.getAtomTermInfo(atom); if (atomInfo.isCCEquality()) { final Term sideA = litInfo.getLhsOccur().isALocal(part) ? ((ApplicationTerm) atom).getParameters()[0] @@ -523,21 +525,35 @@ public Term[] interpolateInstantiation(final InterpolatorClauseInfo proofTermInf return interpolants; } + /** + * Find the literal info for a clause equality justifying the step {@code left = right}. The literal may differ + * from the step by a constant shift and may have swapped sides; the returned info accounts for both. + * + * @return the reoriented literal info, or null if the clause contains no matching equality. + */ + private OffsetLitInfo getEqualityInfo(final Term left, final Term right) { + final OffsetEqKey key = new OffsetEqKey(left, right); + final OffsetLitInfo info = mEqualityOccurrences.get(key); + return info == null ? null : info.reorient(key); + } + public Term[] computeInterpolants(final InterpolatorClauseInfo proofTermInfo) { // Collect the literal infos for all equalities in the clause. mEqualityOccurrences = new HashMap<>(); for (final Term literal : proofTermInfo.getLiterals()) { final Term atom = mInterpolator.getAtom(literal); + final InterpolatorAtomInfo atomTermInfo = mInterpolator.getAtomTermInfo(atom); + final LitInfo occInfo = mInterpolator.getAtomOccurenceInfo(atom); + assert atomTermInfo.isCCEquality(); + final ApplicationTerm eq = atomTermInfo.getEquality(); + final OffsetEqKey key = new OffsetEqKey(eq.getParameters()[0], eq.getParameters()[1]); + final OffsetLitInfo info = new OffsetLitInfo(mTheory, occInfo, key); if (atom != literal) { // negated equality in clause, i.e., positive in conflict. - final InterpolatorAtomInfo atomTermInfo = mInterpolator.getAtomTermInfo(atom); - final LitInfo occInfo = mInterpolator.getAtomOccurenceInfo(atom); - assert atomTermInfo.isCCEquality(); - final ApplicationTerm eq = atomTermInfo.getEquality(); - mEqualityOccurrences.put(new SymmetricPair<>(eq.getParameters()[0], eq.getParameters()[1]), occInfo); + mEqualityOccurrences.put(key, info); } else { - assert mDiseqOccurrences == null; - mDiseqOccurrences = mInterpolator.getAtomOccurenceInfo(atom); + assert mDiseqInfo == null; + mDiseqInfo = info; } } diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/interpolate/OffsetLitInfo.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/interpolate/OffsetLitInfo.java new file mode 100644 index 000000000..908a555b1 --- /dev/null +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/interpolate/OffsetLitInfo.java @@ -0,0 +1,161 @@ +/* + * Copyright (C) 2009-2026 University of Freiburg + * + * This file is part of SMTInterpol. + * + * SMTInterpol is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * SMTInterpol is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with SMTInterpol. If not, see . + */ +package de.uni_freiburg.informatik.ultimate.smtinterpol.interpolate; + +import de.uni_freiburg.informatik.ultimate.logic.Rational; +import de.uni_freiburg.informatik.ultimate.logic.Term; +import de.uni_freiburg.informatik.ultimate.logic.TermVariable; +import de.uni_freiburg.informatik.ultimate.logic.Theory; +import de.uni_freiburg.informatik.ultimate.smtinterpol.interpolate.Interpolator.LitInfo; +import de.uni_freiburg.informatik.ultimate.smtinterpol.interpolate.Interpolator.Occurrence; +import de.uni_freiburg.informatik.ultimate.smtinterpol.util.OffsetEqKey; +import de.uni_freiburg.informatik.ultimate.smtinterpol.util.Polynomial; + +/** + * The {@link LitInfo} of an (offset) equality literal together with the offset shift and orientation between the + * literal and the concrete instance in which it is used, e.g. a path step in a CC lemma. If the literal is + * {@code l = r}, the instance is {@code l + shift = r + shift} (with sides possibly swapped). The mixed variable of + * the literal names the shared value of {@code l} and {@code r}; at instance level it therefore stands for + * {@code mixedVar + shift}, see {@link #getMixedBoundary()}. Conversely, an {@code EQ} placeholder equating the + * mixed variable with an instance-level shared term must subtract the shift from that term, see + * {@link #buildEQ(Term)}. + */ +public class OffsetLitInfo { + private final Theory mTheory; + private final OffsetEqKey mLitKey; + private final LitInfo mLitInfo; + private final Rational mShift; + private final boolean mSwapped; + + /** + * Create the info for an equality literal itself, i.e. with zero shift and unswapped sides. + * + * @param theory the SMT theory. + * @param litInfo the occurrence info of the literal. + * @param litKey the key built from the literal's own lhs and rhs. + */ + public OffsetLitInfo(final Theory theory, final LitInfo litInfo, final OffsetEqKey litKey) { + this(theory, litInfo, litKey, Rational.ZERO, false); + } + + private OffsetLitInfo(final Theory theory, final LitInfo litInfo, final OffsetEqKey litKey, final Rational shift, + final boolean swapped) { + mTheory = theory; + mLitInfo = litInfo; + mLitKey = litKey; + mShift = shift; + mSwapped = swapped; + } + + /** + * Derive the info for a concrete instance of the literal, computing the shift and orientation of the instance + * relative to the literal. + * + * @param instanceKey the key built from the instance's left and right side; must be {@code equals} to the + * literal's key. + */ + public OffsetLitInfo reorient(final OffsetEqKey instanceKey) { + final Rational shift = instanceKey.getShift(mLitKey); + final boolean swapped = instanceKey.isSwapped(mLitKey); + if (shift.equals(mShift) && swapped == mSwapped) { + return this; + } + return new OffsetLitInfo(mTheory, mLitInfo, mLitKey, shift, swapped); + } + + public LitInfo getLitInfo() { + return mLitInfo; + } + + public TermVariable getMixedVar() { + return mLitInfo.getMixedVar(); + } + + public Rational getShift() { + return mShift; + } + + public boolean isALocal(final int partition) { + return mLitInfo.isALocal(partition); + } + + public boolean isBLocal(final int partition) { + return mLitInfo.isBLocal(partition); + } + + public boolean isAorShared(final int partition) { + return mLitInfo.isAorShared(partition); + } + + public boolean isBorShared(final int partition) { + return mLitInfo.isBorShared(partition); + } + + public boolean isMixed(final int partition) { + return mLitInfo.isMixed(partition); + } + + /** + * Check whether the left side of the instance is A-local. This is the literal's lhs occurrence corrected for + * the instance's orientation. Only meaningful for partitions where the literal is mixed. + */ + public boolean isLeftALocal(final int partition) { + final Occurrence lhsOccur = mLitInfo.getLhsOccur(); + return mSwapped ? lhsOccur.isBLocal(partition) : lhsOccur.isALocal(partition); + } + + /** + * Check whether the left side of the instance is B-local. This is the literal's lhs occurrence corrected for + * the instance's orientation. Only meaningful for partitions where the literal is mixed. + */ + public boolean isLeftBLocal(final int partition) { + final Occurrence lhsOccur = mLitInfo.getLhsOccur(); + return mSwapped ? lhsOccur.isALocal(partition) : lhsOccur.isBLocal(partition); + } + + /** + * The mixed variable lifted to instance level, i.e. {@code mixedVar + shift}. This is the shared boundary term + * for the instance of the mixed literal. The result is in normal form, since the mixed variable is a plain + * term variable and the constant comes last. + */ + public Term getMixedBoundary() { + final TermVariable mixedVar = mLitInfo.getMixedVar(); + if (mShift.equals(Rational.ZERO)) { + return mixedVar; + } + return mTheory.term("+", mixedVar, mShift.toTerm(mixedVar.getSort())); + } + + /** + * Build the placeholder {@code EQ(mixedVar, sharedTerm - shift)}. The shared term is given at instance level; + * subtracting the shift moves it to the literal level at which the mixed variable lives. The subtraction is + * normalized, as the shared term may itself be a sum or a constant. + * + * @param sharedTerm the instance-level shared term the mixed variable should be equated with. + */ + public Term buildEQ(final Term sharedTerm) { + Term shifted = sharedTerm; + if (!mShift.equals(Rational.ZERO)) { + final Polynomial poly = new Polynomial(sharedTerm); + poly.add(mShift.negate()); + shifted = poly.toTerm(sharedTerm.getSort()); + } + return mTheory.term(Interpolator.EQ, mLitInfo.getMixedVar(), shifted); + } +} diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/util/OffsetEqKey.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/util/OffsetEqKey.java index 032297f39..3993ec6c0 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/util/OffsetEqKey.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/util/OffsetEqKey.java @@ -35,6 +35,7 @@ public final class OffsetEqKey { private final Term mLhs; private final Term mRhs; + private final Rational mLhsOffset; private final Rational mOffset; private final int mHash; @@ -44,10 +45,12 @@ public OffsetEqKey(final Term lhs, final Term rhs) { final OffsetTerm rhsSplit = new OffsetTerm(rhs); mLhs = lhsSplit.getTerm(); mRhs = rhsSplit.getTerm(); + mLhsOffset = lhsSplit.getOffset(); mOffset = lhsSplit.getOffset().sub(rhsSplit.getOffset()); } else { mLhs = lhs; mRhs = rhs; + mLhsOffset = Rational.ZERO; mOffset = Rational.ZERO; } final int lhsHash = mLhs.hashCode(); @@ -73,6 +76,45 @@ public Rational getOffset() { return mOffset; } + /** + * The absolute offset of the lhs term this key was built from, i.e. {@code lhs = getLhs() + getLhsOffset()}. + */ + public Rational getLhsOffset() { + return mLhsOffset; + } + + /** + * The absolute offset of the rhs term this key was built from, i.e. {@code rhs = getRhs() + getRhsOffset()}. + */ + public Rational getRhsOffset() { + return mLhsOffset.sub(mOffset); + } + + /** + * Check whether this key matches the given equal key with swapped sides, i.e. whether the lhs this key was built + * from corresponds to the rhs of {@code other}. + * + * @param other a key that is {@code equals} to this key. + */ + public boolean isSwapped(final OffsetEqKey other) { + assert equals(other); + return !(mLhs == other.mLhs && mOffset.equals(other.mOffset)); + } + + /** + * Compute the constant shift between the equality this key was built from and the equality the given equal key + * was built from. If {@code other} was built from {@code l = r}, then this key was built from an equality + * {@code l + shift = r + shift} (modulo swapping of sides). + * + * @param other a key that is {@code equals} to this key. + * @return the shift such that the sides of this key's equality are the sides of {@code other}'s equality plus + * the shift. + */ + public Rational getShift(final OffsetEqKey other) { + assert equals(other); + return mLhsOffset.sub(isSwapped(other) ? other.getRhsOffset() : other.getLhsOffset()); + } + @Override public int hashCode() { return mHash; diff --git a/SMTInterpolTest/test/interpolation/datatype/dt_project_offset001.smt2 b/SMTInterpolTest/test/interpolation/datatype/dt_project_offset001.smt2 new file mode 100644 index 000000000..d3cf69c19 --- /dev/null +++ b/SMTInterpolTest/test/interpolation/datatype/dt_project_offset001.smt2 @@ -0,0 +1,30 @@ +(set-option :produce-interpolants true) +(set-option :interpolant-check-mode true) + +(set-info :smt-lib-version 2.6) +(set-logic QF_UFDTLIA) + +(set-info :category "crafted") +(set-info :status unsat) + + +(declare-datatypes ( (D 0) ) ( + ( (mk (val Int)) ) +)) + +(declare-const a Int) +(declare-const s D) +(declare-const bs D) +(declare-fun p (Int) Bool) + +;; projection on a shifted constructor argument: bs = s = mk(a+5) gives via +;; dt-project the offset equality val(bs) = a+5, which then justifies the +;; congruence p(a+10) = p(val(bs)+5) with shift 5. + +(assert (! (and (= (mk (+ a 5)) s) (p (+ a 10))) :named A )) +(assert (! (and (= s bs) (not (p (+ (val bs) 5)))) :named B )) + +(check-sat) +(get-interpolants A B) +(get-interpolants B A) +(exit) From db10b2c328a9e2645c1583a4b10805258663f4dc Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Sun, 5 Jul 2026 13:21:37 +0200 Subject: [PATCH 67/75] Support offset equalities in DatatypeInterpolator. In a dt-injective lemma both constructor arguments can carry offsets, so the canonicalized clause literal (e.g. b = a-2) differs from the annotation's main equality (b+7 = a+5) by a constant shift and possibly swapped sides. The new getAnnotationLitInfo() reorients the literal info to the annotation equality via OffsetLitInfo. interpolateDTInjective works at annotation level in the mixed branches: shared terms come from the annotation's diseq parameters selected by isLeftALocal (removing the assumption that the clause atom order matches the annotation), and the mixed disequality emits buildEQ, which subtracts the shift from the shared selector term. interpolateDTProject gets the same treatment for its disequality against the goal equality; the shift is currently always zero there since the propagated atom is created at exactly the annotation offset, but this is safer against future lemma changes. Also fix the missing-disequality branch, which read the equality info from the (empty) disequality map. Co-Authored-By: Claude Fable 5 --- .../interpolate/DatatypeInterpolator.java | 55 ++++++++++++++----- .../datatype/dt_injective_offset001.smt2 | 30 ++++++++++ 2 files changed, 71 insertions(+), 14 deletions(-) create mode 100644 SMTInterpolTest/test/interpolation/datatype/dt_injective_offset001.smt2 diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/interpolate/DatatypeInterpolator.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/interpolate/DatatypeInterpolator.java index 98b6da5ad..04069e94c 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/interpolate/DatatypeInterpolator.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/interpolate/DatatypeInterpolator.java @@ -21,6 +21,7 @@ import java.util.ArrayList; import java.util.HashMap; +import java.util.Map.Entry; import de.uni_freiburg.informatik.ultimate.logic.ApplicationTerm; import de.uni_freiburg.informatik.ultimate.logic.DataType; @@ -31,6 +32,7 @@ import de.uni_freiburg.informatik.ultimate.logic.Theory; import de.uni_freiburg.informatik.ultimate.smtinterpol.interpolate.Interpolator.LitInfo; import de.uni_freiburg.informatik.ultimate.smtinterpol.interpolate.Interpolator.Occurrence; +import de.uni_freiburg.informatik.ultimate.smtinterpol.util.OffsetEqKey; import de.uni_freiburg.informatik.ultimate.smtinterpol.util.SymmetricPair; /** @@ -99,6 +101,24 @@ public Term[] computeDatatypeInterpolants(final InterpolatorClauseInfo clauseInf } } + /** + * Compute the literal info oriented and shifted to match the annotation-level equality + * {@code left = right}. For numeric sorts the clause literal is canonicalized and may + * therefore differ from the equality in the lemma annotation by a constant offset (and + * by swapped sides), e.g. clause literal {@code (= b (+ a (- 2)))} for the annotation + * equality {@code (= (+ b 7) (+ a 5))} in a dt-injective lemma. + * + * @param atomPair the clause atom's parameters in atom order. + * @param info the literal info of the clause atom. + * @param left the left-hand side of the annotation equality. + * @param right the right-hand side of the annotation equality. + */ + private OffsetLitInfo getAnnotationLitInfo(final SymmetricPair atomPair, final LitInfo info, + final Term left, final Term right) { + final OffsetEqKey litKey = new OffsetEqKey(atomPair.getFirst(), atomPair.getSecond()); + return new OffsetLitInfo(mTheory, info, litKey).reorient(new OffsetEqKey(left, right)); + } + /** * Interpolate a datatype uniqueness conflict. The conflict has the form * {@code ((_ is cons1) u1) == true, ((_ is cons2) u2) == true, u1 == u2}. The @@ -207,11 +227,15 @@ private Term[] interpolateDTInjective(Object[] annot) { final ApplicationTerm diseqAtom = (ApplicationTerm) annot[0]; final Term[] diseqParams = diseqAtom.getParameters(); assert mDisequalityInfos.size() == 1; - final SymmetricPair diseqPair = mDisequalityInfos.keySet().iterator().next(); - final LitInfo diseqInfo = mDisequalityInfos.get(diseqPair); + final Entry, LitInfo> diseqEntry = mDisequalityInfos.entrySet().iterator().next(); + final OffsetLitInfo diseqInfo = getAnnotationLitInfo(diseqEntry.getKey(), diseqEntry.getValue(), + diseqParams[0], diseqParams[1]); final ApplicationTerm firstConsTerm = (ApplicationTerm) annot[2]; final ApplicationTerm secondConsTerm = (ApplicationTerm) annot[3]; - final LitInfo eqInfo = mEqualityInfos.get(new SymmetricPair<>(firstConsTerm, secondConsTerm)); + assert mEqualityInfos.size() == 1; + final Entry, LitInfo> eqEntry = mEqualityInfos.entrySet().iterator().next(); + final OffsetLitInfo eqInfo = getAnnotationLitInfo(eqEntry.getKey(), eqEntry.getValue(), + firstConsTerm, secondConsTerm); String selFunc = null; for (int partition = 0; partition < mNumInterpolants; partition++) { @@ -228,18 +252,18 @@ private Term[] interpolateDTInjective(Object[] annot) { if (selFunc == null) { selFunc = getSelectorForPair(firstConsTerm, secondConsTerm, diseqParams); } + // sharedSelTerm equals the constructor arguments at annotation level, so the + // shared term must also be the annotation-level argument on the other side. final Term sharedSelTerm = mTheory.term(selFunc, eqInfo.getMixedVar()); if (diseqInfo.isAorShared(partition)) { - final Term sharedTerm = eqInfo.getLhsOccur().isALocal(partition) ? diseqPair.getSecond() - : diseqPair.getFirst(); + final Term sharedTerm = eqInfo.isLeftALocal(partition) ? diseqParams[1] : diseqParams[0]; interpolants[partition] = mTheory.term(SMTLIBConstants.NOT, mTheory.term(SMTLIBConstants.EQUALS, sharedTerm, sharedSelTerm)); } else if (diseqInfo.isBLocal(partition)) { - final Term sharedTerm = eqInfo.getLhsOccur().isALocal(partition) ? diseqPair.getFirst() - : diseqPair.getSecond(); + final Term sharedTerm = eqInfo.isLeftALocal(partition) ? diseqParams[0] : diseqParams[1]; interpolants[partition] = mTheory.term(SMTLIBConstants.EQUALS, sharedTerm, sharedSelTerm); } else { - interpolants[partition] = mTheory.term(Interpolator.EQ, diseqInfo.getMixedVar(), sharedSelTerm); + interpolants[partition] = diseqInfo.buildEQ(sharedSelTerm); } } } @@ -361,7 +385,9 @@ private Term[] interpolateDTProject(Object[] annot) { // equality is missing because it is trivial if (mEqualityInfos.size() == 0) { - final LitInfo diseqInfo = mDisequalityInfos.values().iterator().next(); + final Entry, LitInfo> diseqEntry = mDisequalityInfos.entrySet().iterator().next(); + final OffsetLitInfo diseqInfo = getAnnotationLitInfo(diseqEntry.getKey(), diseqEntry.getValue(), + goalEq.getParameters()[0], goalEq.getParameters()[1]); for (int partition = 0; partition < mNumInterpolants; partition++) { if (diseqInfo.isAorShared(partition)) { interpolants[partition] = mTheory.mFalse; @@ -371,14 +397,14 @@ private Term[] interpolateDTProject(Object[] annot) { // mixed case is possible only with quantifiers assert diseqInfo.isMixed(partition); final Term sharedTerm = goalEq.getParameters()[1]; - interpolants[partition] = mTheory.term(Interpolator.EQ, diseqInfo.getMixedVar(), sharedTerm); + interpolants[partition] = diseqInfo.buildEQ(sharedTerm); } } return interpolants; } assert (mEqualityInfos.size() == 1); if (mDisequalityInfos.size() == 0) { - final LitInfo eqInfo = mDisequalityInfos.values().iterator().next(); + final LitInfo eqInfo = mEqualityInfos.values().iterator().next(); for (int partition = 0; partition < mNumInterpolants; partition++) { if (eqInfo.isAorShared(partition)) { interpolants[partition] = mTheory.mFalse; @@ -392,8 +418,9 @@ private Term[] interpolateDTProject(Object[] annot) { } assert (mDisequalityInfos.size() == 1); - final SymmetricPair diseqLit = mDisequalityInfos.keySet().iterator().next(); - final LitInfo diseqInfo = mDisequalityInfos.get(diseqLit); + final Entry, LitInfo> diseqEntry = mDisequalityInfos.entrySet().iterator().next(); + final OffsetLitInfo diseqInfo = getAnnotationLitInfo(diseqEntry.getKey(), diseqEntry.getValue(), + goalEq.getParameters()[0], goalEq.getParameters()[1]); final SymmetricPair eqLit = mEqualityInfos.keySet().iterator().next(); final LitInfo eqInfo = mEqualityInfos.get(eqLit); @@ -415,7 +442,7 @@ private Term[] interpolateDTProject(Object[] annot) { shared1Term = goalEq.getParameters()[1]; } if (diseqInfo.isMixed(partition)) { - interpolants[partition] = mTheory.term(Interpolator.EQ, diseqInfo.getMixedVar(), shared1Term); + interpolants[partition] = diseqInfo.buildEQ(shared1Term); } else if (diseqInfo.isALocal(partition)) { interpolants[partition] = mTheory.term(SMTLIBConstants.NOT, mTheory.term(SMTLIBConstants.EQUALS, shared0Term, goalEq.getParameters()[1])); diff --git a/SMTInterpolTest/test/interpolation/datatype/dt_injective_offset001.smt2 b/SMTInterpolTest/test/interpolation/datatype/dt_injective_offset001.smt2 new file mode 100644 index 000000000..399f0bb78 --- /dev/null +++ b/SMTInterpolTest/test/interpolation/datatype/dt_injective_offset001.smt2 @@ -0,0 +1,30 @@ +(set-option :produce-interpolants true) +(set-option :interpolant-check-mode true) + +(set-info :smt-lib-version 2.6) +(set-logic QF_UFDTLIA) + +(set-info :category "crafted") +(set-info :status unsat) + + +(declare-datatypes ( (D 0) ) ( + ( (mk (val Int)) ) +)) + +(declare-const a Int) +(declare-const b Int) +(declare-const s D) +(declare-fun p (Int) Bool) + +;; injectivity on shifted constructor arguments: mk(a+5) = s = mk(b+7) +;; gives the offset equality a+5 = b+7 (canonically b = a-2), which then +;; justifies the congruence p(a+3) = p(b+5) with shift -2. + +(assert (! (and (= (mk (+ a 5)) s) (p (+ a 3))) :named A )) +(assert (! (and (= s (mk (+ b 7))) (not (p (+ b 5)))) :named B )) + +(check-sat) +(get-interpolants A B) +(get-interpolants B A) +(exit) From d09f5cde4f6f2b47933841eee186bbed5b56b313 Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Sun, 5 Jul 2026 14:48:32 +0200 Subject: [PATCH 68/75] Fix two lifetime bugs in the master reverse trigger machinery. The unifier for master reverse triggers was a static JVM-global cache keyed only on (function symbol, argument position). A second solver sharing the theory -- the interpolant checking solver is a clone of the main solver -- got the first solver's master trigger back and never registered its own find trigger, so it had no selector/tester reverse triggers at all: no dt-project/dt-tester propagation (the "Unpropagated selector/is of constructor" recovery fired at every final check) and missed dt-cycle conflicts, making the checking solver claim SAT on unsat datatype queries. The unifier is now a per-engine field of CClosure. MasterReverseTrigger.activate wraps each new application in a ReverseTriggerTrigger, but removeTerm only removed mCongTrigger and mFindTrigger, so the application entry survived a pop and later trigger merges activated reverse triggers on the removed CCAppTerm. The application term now remembers these signatures (mReverseTriggers) and removeTerm removes them. This is sound because pop asserts an empty undo stack: all signature merges, including the insert-time merges that addSignatureHash also records, are already undone at that point, so each application entry is back in its own signature object. This fixes the interpolation tests dt_constructor001/002 and dt_cycle001/2/3/5/7/8, whose interpolants were correct but rejected because the checking solver missed the datatype cycle conflict. Co-Authored-By: Claude Fable 5 --- .../theory/cclosure/CCAppTerm.java | 8 ++++++++ .../smtinterpol/theory/cclosure/CClosure.java | 19 +++++++++++++++++++ .../theory/cclosure/MasterReverseTrigger.java | 17 +++++++++++++---- 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAppTerm.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAppTerm.java index b109bcfc1..eab48481e 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAppTerm.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CCAppTerm.java @@ -19,6 +19,7 @@ package de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure; import java.util.ArrayDeque; +import java.util.ArrayList; import java.util.HashMap; import de.uni_freiburg.informatik.ultimate.logic.FunctionSymbol; @@ -40,6 +41,13 @@ public class CCAppTerm extends CCTerm { CongruenceTrigger mCongTrigger; FindTriggerTrigger mFindTrigger; + /** + * The reverse trigger signatures created for this application by {@link MasterReverseTrigger#activate}, one per + * master trigger watching this application's function symbol. They must be removed together with this term (see + * {@code CClosure.removeTerm}), otherwise a stale application entry survives a pop and later trigger merges + * activate reverse triggers on the removed term. + */ + ArrayList mReverseTriggers; public CCAppTerm(FunctionSymbol fsym, final CCParameter[] args, final CClosure engine, final boolean isFromQuant) { diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java index 996ff9e21..91d3684d0 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java @@ -56,6 +56,7 @@ import de.uni_freiburg.informatik.ultimate.smtinterpol.util.ScopedArrayList; import de.uni_freiburg.informatik.ultimate.smtinterpol.util.SymmetricPair; import de.uni_freiburg.informatik.ultimate.util.datastructures.ScopedHashMap; +import de.uni_freiburg.informatik.ultimate.util.datastructures.UnifyHash; /** * This class implements the theory of equality, a.k.a. congruence closure. @@ -189,6 +190,12 @@ public class CClosure implements ITheory { * Todo list of deferred signatures. */ final SimpleList mSignatureTodo = new SimpleList(); + /** + * The master reverse triggers of this engine, one per (function symbol, argument position) pair, see + * {@link MasterReverseTrigger#of}. This must be per engine (not a global unifier): solver instances may share the + * theory and thus the function symbols, but each engine needs its own find trigger registration. + */ + private final UnifyHash mMasterReverseTriggers = new UnifyHash<>(); private long mInvertEdgeTime, mEqTime, mCcTime, mSetRepTime, mSigHashTime; private long mCcCount, mMergeCount; @@ -197,6 +204,10 @@ public CClosure(final Clausifier clausifier) { mClausifier = clausifier; } + UnifyHash getMasterReverseTriggers() { + return mMasterReverseTriggers; + } + public DPLLEngine getEngine() { return mClausifier.getEngine(); } @@ -1519,6 +1530,14 @@ private void removeTerm(final CCTerm t) { final CCAppTerm appTerm = (CCAppTerm) t; removeSignature(appTerm.mCongTrigger); removeSignature(appTerm.mFindTrigger); + if (appTerm.mReverseTriggers != null) { + // remove the reverse trigger signatures created for this application (all merges are already + // undone at pop time, so each application entry is back in its own signature). + for (final ReverseTriggerTrigger trigger : appTerm.mReverseTriggers) { + removeSignature(trigger); + } + appTerm.mReverseTriggers = null; + } } } diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/MasterReverseTrigger.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/MasterReverseTrigger.java index 40ac4b056..2bc089e74 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/MasterReverseTrigger.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/MasterReverseTrigger.java @@ -18,6 +18,8 @@ */ package de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure; +import java.util.ArrayList; + import de.uni_freiburg.informatik.ultimate.logic.FunctionSymbol; import de.uni_freiburg.informatik.ultimate.util.HashUtils; import de.uni_freiburg.informatik.ultimate.util.datastructures.UnifyHash; @@ -33,19 +35,21 @@ public final class MasterReverseTrigger extends ReverseTrigger { private final FunctionSymbol mFunctionSymbol; private final int mArgPosition; - static final UnifyHash sUnifier = new UnifyHash<>(); - public static MasterReverseTrigger of(final CClosure engine, final FunctionSymbol functionSymbol, final int argPosition) { + // The unifier must be per engine: solver instances may share the theory and thus the function + // symbols (e.g. the interpolant checking solver is a clone of the main solver), and each engine + // needs its own master trigger registered as find trigger. + final UnifyHash unifier = engine.getMasterReverseTriggers(); final int hash = HashUtils.hashJenkins(argPosition, functionSymbol); - for (final MasterReverseTrigger masterReverseTrigger : sUnifier.iterateHashCode(hash)) { + for (final MasterReverseTrigger masterReverseTrigger : unifier.iterateHashCode(hash)) { if (masterReverseTrigger.mFunctionSymbol == functionSymbol && masterReverseTrigger.mArgPosition == argPosition) { return masterReverseTrigger; } } final MasterReverseTrigger masterReverseTrigger = new MasterReverseTrigger(engine, functionSymbol, argPosition); - sUnifier.put(hash, masterReverseTrigger); + unifier.put(hash, masterReverseTrigger); engine.insertFindTrigger(functionSymbol, masterReverseTrigger); return masterReverseTrigger; } @@ -78,5 +82,10 @@ public void activate(final CCAppTerm appTerm, final boolean isFresh) { assert appTerm.getFunctionSymbol() == mFunctionSymbol; final ReverseTriggerTrigger reverseTriggerTrigger = new ReverseTriggerTrigger(this, appTerm, mArgPosition); mEngine.addSignature(reverseTriggerTrigger); + // remember the signature on the term, so it is removed with the term on pop. + if (appTerm.mReverseTriggers == null) { + appTerm.mReverseTriggers = new ArrayList<>(); + } + appTerm.mReverseTriggers.add(reverseTriggerTrigger); } } From 008223913254e897871cae49a6b5a816e7cc33cf Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Sun, 5 Jul 2026 14:49:09 +0200 Subject: [PATCH 69/75] Pass empty source annotation for theory-created equality atoms. CClosure.createEquality and EqualityProxy.createCCEquality passed a null SourceAnnotation, which addTermAxioms dereferences when the equality's term is new to the solver. This was unreachable in practice because the terms of theory-propagated equalities always existed already; in the interpolant checking solver with working datatype triggers, a propagated tester equality can reference a term the clone never clausified. Use SourceAnnotation.EMPTY_SOURCE_ANNOT like other theory-internal term creation does. Also assert that the createCCEquality fall-through in createEquality is only taken for numeric equalities: it creates an LAEquality, and a non-numeric equality always matches the cached literal above unless that literal is a stale atom referencing removed CCTerms. Co-Authored-By: Claude Fable 5 --- .../ultimate/smtinterpol/convert/EqualityProxy.java | 2 +- .../ultimate/smtinterpol/theory/cclosure/CClosure.java | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/EqualityProxy.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/EqualityProxy.java index 5e9b19f28..ad248b5da 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/EqualityProxy.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/EqualityProxy.java @@ -128,7 +128,7 @@ public CCEquality createCCEquality(final Term lhs, final Term rhs) { */ public CCEquality createCCEquality(final CCTerm ccLhs, final CCTerm ccRhs, final Rational offset) { assert ccLhs != null && ccRhs != null; - final DPLLAtom eqAtom = getLiteral(null); + final DPLLAtom eqAtom = getLiteral(SourceAnnotation.EMPTY_SOURCE_ANNOT); LAEquality laeq; if (eqAtom instanceof CCEquality) { final CCEquality eq = (CCEquality) eqAtom; diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java index 91d3684d0..f11a64b93 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java @@ -1219,12 +1219,13 @@ public CCEquality createEquality(final CCTerm t1, final CCTerm t2, final Rationa // correctly by Polynomial when t2 is itself a normalized sum (the inner sum is treated as one monomial). rhsTerm = mClausifier.addConstantToTerm(rhsTerm, offset); } - final EqualityProxy ep = mClausifier.createEqualityProxy(lhsTerm, rhsTerm, null); + final EqualityProxy ep = mClausifier.createEqualityProxy(lhsTerm, rhsTerm, + SourceAnnotation.EMPTY_SOURCE_ANNOT); if (ep == EqualityProxy.getFalseProxy()) { return null; } if (!createLAEquality) { - final Literal res = ep.getLiteral(null); + final Literal res = ep.getLiteral(SourceAnnotation.EMPTY_SOURCE_ANNOT); if (res instanceof CCEquality) { final CCEquality eq = (CCEquality) res; if ((eq.getLhs() == t1 && eq.getRhs() == t2) || (eq.getLhs() == t2 && eq.getRhs() == t1)) { @@ -1234,6 +1235,9 @@ public CCEquality createEquality(final CCTerm t1, final CCTerm t2, final Rationa } // t1 and t2 are the offset-free CC nodes; pass them with this call's offset (which may differ from the proxy's // canonical offset for a scaled equivalent) rather than round-tripping through the synthesized rhsTerm. + // This path creates an LAEquality, so it must only be taken for numeric equalities; a non-numeric equality + // always matches the literal above (a mismatch would indicate a stale atom referencing removed CCTerms). + assert lhsTerm.getSort().isNumericSort(); return ep.createCCEquality(t1, t2, offset); } From 25b8fb8f9698fffd6c16286cc61925612b401096 Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Sun, 5 Jul 2026 16:32:57 +0200 Subject: [PATCH 70/75] Support printing int arrays (for dt_cycle proof terms) --- .../informatik/ultimate/logic/PrintTerm.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Library-SMTLIB/src/de/uni_freiburg/informatik/ultimate/logic/PrintTerm.java b/Library-SMTLIB/src/de/uni_freiburg/informatik/ultimate/logic/PrintTerm.java index b54b59c77..87713de3b 100644 --- a/Library-SMTLIB/src/de/uni_freiburg/informatik/ultimate/logic/PrintTerm.java +++ b/Library-SMTLIB/src/de/uni_freiburg/informatik/ultimate/logic/PrintTerm.java @@ -144,6 +144,16 @@ private void run(final Appendable appender) throws IOException { } } appender.append('('); + } else if (next instanceof int[]) { + final int[] arr = (int[]) next; + mTodo.addLast(")"); + for (int i = arr.length - 1; i >= 0; i--) { + mTodo.addLast(arr[i]); + if (i > 0) { + mTodo.addLast(" "); + } + } + appender.append('('); } else { appender.append(next.toString()); } From 2f97d925063c1ac8166b33e279b7f94ef0d33045 Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Sun, 5 Jul 2026 16:34:12 +0200 Subject: [PATCH 71/75] Avoid pivoting on literals not occuring in clause --- .../smtinterpol/proof/ProofSimplifier.java | 92 +++++++++++-------- 1 file changed, 53 insertions(+), 39 deletions(-) diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/proof/ProofSimplifier.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/proof/ProofSimplifier.java index dd34fa0dd..d63f554f4 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/proof/ProofSimplifier.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/proof/ProofSimplifier.java @@ -2106,29 +2106,35 @@ private Term convertRewriteDistinct(final String rewriteRule, final Term rewrite switch (rewriteRule) { case ":distinctBool": assert args.length > 2 && args[0].getSort().getName() == "Bool" && isApplication("false", rhs); - final Term eq01 = theory.term("=", args[0], args[1]); - final Term eq02 = theory.term("=", args[0], args[2]); - final Term eq12 = theory.term("=", args[1], args[2]); - final Term proof01 = mProofRules.distinctElim(0, 1, lhs); - final Term proof02 = mProofRules.distinctElim(0, 2, lhs); - final Term proof12 = mProofRules.distinctElim(1, 2, lhs); - // Prove contradiction using the three equalities eq01, eq02, eq12. - // Do case distinction over three boolean values and show that in each case one - // equality needs to hold. - Term proof = mProofRules.resolutionRule(args[0], - mProofRules.resolutionRule(args[1], mProofRules.iffIntro1(eq01), - mProofRules.resolutionRule(args[2], mProofRules.iffIntro1(eq02), - mProofRules.iffIntro2(eq12))), - mProofRules.resolutionRule(args[1], mProofRules.resolutionRule(args[2], mProofRules.iffIntro1(eq12), - mProofRules.iffIntro2(eq02)), mProofRules.iffIntro2(eq01))); - // Now use the fact that one of the equalities is false, to prove that distinct - // is false. - proof = mProofRules.resolutionRule(eq01, - mProofRules.resolutionRule(eq02, mProofRules.resolutionRule(eq12, proof, proof12), proof02), - proof01); - proof = proveIffFalse(rewrite, proof); - return proof; + // check that the first three args are different. Otherwise use distinctSame + // proof. + if (args[0] != args[1] && args[0] != args[2] && args[1] != args[2]) { + final Term eq01 = theory.term("=", args[0], args[1]); + final Term eq02 = theory.term("=", args[0], args[2]); + final Term eq12 = theory.term("=", args[1], args[2]); + final Term proof01 = mProofRules.distinctElim(0, 1, lhs); + final Term proof02 = mProofRules.distinctElim(0, 2, lhs); + final Term proof12 = mProofRules.distinctElim(1, 2, lhs); + // Prove contradiction using the three equalities eq01, eq02, eq12. + // Do case distinction over three boolean values and show that in each case one + // equality needs to hold. + Term proof = mProofRules.resolutionRule(args[0], + mProofRules.resolutionRule(args[1], mProofRules.iffIntro1(eq01), + mProofRules.resolutionRule(args[2], mProofRules.iffIntro1(eq02), + mProofRules.iffIntro2(eq12))), + mProofRules.resolutionRule(args[1], mProofRules.resolutionRule(args[2], + mProofRules.iffIntro1(eq12), mProofRules.iffIntro2(eq02)), + mProofRules.iffIntro2(eq01))); + // Now use the fact that one of the equalities is false, to prove that distinct + // is false. + proof = mProofRules.resolutionRule(eq01, + mProofRules.resolutionRule(eq02, mProofRules.resolutionRule(eq12, proof, proof12), proof02), + proof01); + proof = proveIffFalse(rewrite, proof); + return proof; + } + /* fall through into distinctSame case */ case ":distinctSame": { // (distinct ... x ... x ...) = false assert isApplication("false", rhs); @@ -4095,34 +4101,42 @@ private Term convertDTCycle(final ProofLiteral[] clause, final Object[] ccAnnota final String[] selectors = c.getSelectors(); for (pos = 0; pos < selectors.length; pos++) { if (selectors[pos].equals(appTerm.getFunction().getName())) { + final Term isConsTerm = theory.term(SMTLIBConstants.IS, new String[] { c.getName() }, + null, consTerm); final Term[] consArgs = new Term[selectors.length]; - final Term[] runningArgs = new Term[selectors.length]; for (int argnr = 0; argnr < consArgs.length; argnr++) { consArgs[argnr] = theory.term(selectors[argnr], consTerm); - if (argnr != pos) { - runningArgs[argnr] = consArgs[argnr]; - neededEqualities.add( - theory.term(SMTLIBConstants.EQUALS, consArgs[argnr], consArgs[argnr])); - } else { - runningArgs[argnr] = runningTerm; - } } final Term newConsTerm = theory.term(c.getName(), null, (c.needsReturnOverload() ? consTerm.getSort() : null), consArgs); - final Term newRunningTerm = theory.term(c.getName(), null, - (c.needsReturnOverload() ? consTerm.getSort() : null), runningArgs); - final Term isConsTerm = theory.term(SMTLIBConstants.IS, new String[] { c.getName() }, - null, consTerm); - proof = res(theory.term(SMTLIBConstants.EQUALS, runningTerm, selectTerm), proof, - mProofRules.cong(newRunningTerm, newConsTerm)); - proof = mProofUtils.proveTransitivity(newRunningTerm, newConsTerm, consTerm, proof, - mProofRules.dtCons(isConsTerm)); + if (runningTerm == selectTerm) { + proof = mProofRules.dtCons(isConsTerm); + runningTerm = newConsTerm; + } else { + final Term[] runningArgs = new Term[selectors.length]; + for (int argnr = 0; argnr < consArgs.length; argnr++) { + if (argnr != pos) { + runningArgs[argnr] = consArgs[argnr]; + neededEqualities.add(theory.term(SMTLIBConstants.EQUALS, consArgs[argnr], + consArgs[argnr])); + } else { + runningArgs[argnr] = runningTerm; + } + } + final Term newRunningTerm = theory.term(c.getName(), null, + (c.needsReturnOverload() ? consTerm.getSort() : null), runningArgs); + proof = res(theory.term(SMTLIBConstants.EQUALS, runningTerm, selectTerm), proof, + mProofRules.cong(newRunningTerm, newConsTerm)); + proof = mProofUtils.proveTransitivity(newRunningTerm, newConsTerm, consTerm, proof, + mProofRules.dtCons(isConsTerm)); + runningTerm = newRunningTerm; + } + final Term isConsEq = theory.term(SMTLIBConstants.EQUALS, isConsTerm, theory.mTrue); proof = res(isConsTerm, res(theory.mTrue, mProofRules.trueIntro(), mProofRules.iffElim1(isConsEq)), proof); neededEqualities.add(isConsEq); - runningTerm = newRunningTerm; argSequence[i / 2] = pos; break findSelector; } From fea5a802d14b0df6cd168a550b8bdef77c59521f Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Sun, 5 Jul 2026 17:23:38 +0200 Subject: [PATCH 72/75] Support offset equalities in ArrayInterpolator. Key the lemma's equality/disequality maps by OffsetEqKey, so a path-level fact like (+ i 1) != (+ j 2) finds the canonicalized clause literal (= i (+ j 1)). The lookups return the atom together with an OffsetLitInfo reoriented to the query. A found atom denotes exactly the queried fact, so it can be used as a formula unchanged; the constant shift only matters in three places: - Projections: index (dis)equalities collected on a weak path are stored as ProjectedIndexLit holding the path-level term of the non-weakpath side. buildFPiTerm uses it instead of picking the atom parameter that is not the weakpath index (silently wrong under a shift), buildEQ for mixed disequalities and getMixedBoundary for mixed equalities. - findSharedTerms matches the offset-free parts and returns the other side (or the mixed variable) plus the constant difference. - The select structure of the main disequality and the read-const select are taken from the lemma annotation, as the clause atom may be offset-canonicalized. The select/const edge of a weakeq-ext weak path is now read from the lemma annotation (:weakpath {index, subs, {left, right}}) instead of searching the clause equalities; findSelectEquality and isGoodSelect are removed. Note that an edge term that is a select can still be the value of a const array node, so the const-value attachment is checked first, regardless of the edge term's shape. A mixed select edge whose literal is value-shifted against the annotation is not supported yet and asserts. The addStoreEdgeStep branch for a missing index disequality now asserts that the indices are trivially distinct (same offset-free part, different constant), so a missed lookup can no longer be silently misread. The new tests arrayoffset001-009 cover shifted store/select indices in read-over-weakeq (001/002/007), offset const values (003/009), the annotated select edge (008), offset stores resolved by congruence (004/005), and an LA-derived offset index through a mixed CC equality (006); 001 and 007-009 fail without this commit. Co-Authored-By: Claude Fable 5 --- .../interpolate/ArrayInterpolator.java | 426 ++++++++++-------- .../test/interpolation/arrayoffset001.smt2 | 16 + .../test/interpolation/arrayoffset002.smt2 | 15 + .../test/interpolation/arrayoffset003.smt2 | 15 + .../test/interpolation/arrayoffset004.smt2 | 18 + .../test/interpolation/arrayoffset005.smt2 | 16 + .../test/interpolation/arrayoffset006.smt2 | 17 + .../test/interpolation/arrayoffset007.smt2 | 17 + .../test/interpolation/arrayoffset008.smt2 | 16 + .../test/interpolation/arrayoffset009.smt2 | 16 + 10 files changed, 383 insertions(+), 189 deletions(-) create mode 100644 SMTInterpolTest/test/interpolation/arrayoffset001.smt2 create mode 100644 SMTInterpolTest/test/interpolation/arrayoffset002.smt2 create mode 100644 SMTInterpolTest/test/interpolation/arrayoffset003.smt2 create mode 100644 SMTInterpolTest/test/interpolation/arrayoffset004.smt2 create mode 100644 SMTInterpolTest/test/interpolation/arrayoffset005.smt2 create mode 100644 SMTInterpolTest/test/interpolation/arrayoffset006.smt2 create mode 100644 SMTInterpolTest/test/interpolation/arrayoffset007.smt2 create mode 100644 SMTInterpolTest/test/interpolation/arrayoffset008.smt2 create mode 100644 SMTInterpolTest/test/interpolation/arrayoffset009.smt2 diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/interpolate/ArrayInterpolator.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/interpolate/ArrayInterpolator.java index c4faa13c5..b4b3c3855 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/interpolate/ArrayInterpolator.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/interpolate/ArrayInterpolator.java @@ -30,6 +30,7 @@ import de.uni_freiburg.informatik.ultimate.logic.ApplicationTerm; import de.uni_freiburg.informatik.ultimate.logic.FormulaUnLet; import de.uni_freiburg.informatik.ultimate.logic.FunctionSymbol; +import de.uni_freiburg.informatik.ultimate.logic.Rational; import de.uni_freiburg.informatik.ultimate.logic.SMTLIBConstants; import de.uni_freiburg.informatik.ultimate.logic.Sort; import de.uni_freiburg.informatik.ultimate.logic.Term; @@ -38,7 +39,9 @@ import de.uni_freiburg.informatik.ultimate.smtinterpol.interpolate.Interpolator.LitInfo; import de.uni_freiburg.informatik.ultimate.smtinterpol.interpolate.Interpolator.Occurrence; import de.uni_freiburg.informatik.ultimate.smtinterpol.option.SMTInterpolConstants; -import de.uni_freiburg.informatik.ultimate.smtinterpol.util.SymmetricPair; +import de.uni_freiburg.informatik.ultimate.smtinterpol.util.OffsetEqKey; +import de.uni_freiburg.informatik.ultimate.smtinterpol.util.OffsetTerm; +import de.uni_freiburg.informatik.ultimate.smtinterpol.util.Polynomial; /** * The interpolator for the theory of arrays. @@ -69,13 +72,16 @@ public class ArrayInterpolator { */ private LitInfo mDiseqInfo; /** - * The atoms of the equality literals in this lemma. Note that they appear negated in the clause. + * The atoms of the equality literals in this lemma, keyed by the fact they denote (offset-aware, so a lemma's + * shifted instance like {@code (+ i 1) = (+ j 2)} finds the canonicalized literal {@code i = (+ j 1)}). Note that + * they appear negated in the clause. */ - private Map, Term> mEqualities; + private Map mEqualities; /** - * The atoms of the disequality literals in this lemma. Note that they appear positively in the clause. + * The atoms of the disequality literals in this lemma, keyed like {@link #mEqualities}. Note that they appear + * positively in the clause. */ - private Map, Term> mDisequalities; + private Map mDisequalities; /** * The store path between the arrays of the main disequality for weak equivalence in a weakeq-ext lemma, and for * weak equivalence modulo i, where i is the path index, in a read-over-weakeq lemma. @@ -90,9 +96,10 @@ public class ArrayInterpolator { */ private Occurrence mABSwitchOccur; /** - * The strong path between the select indices of the main disequality for read-over-weakeq. + * The strong path between the select indices of the main disequality for read-over-weakeq. The projection index + * is the select index that is not the weakpath index. */ - private Term mIndexEquality; + private ProjectedIndexLit mIndexEquality; /** * This map contains the paths for weak congruence on index i in weakeq-ext. */ @@ -162,6 +169,79 @@ private ProofPath[] getPaths(final InterpolatorClauseInfo clauseInfo) { return paths; } + /** + * A clause literal found for a queried (dis)equality {@code left = right}, together with its occurrence info + * reoriented to the query: the literal may be a canonicalized form of the queried fact, differing by a constant + * shift and swapped sides. + */ + class ArrayLit { + final Term mAtom; + final OffsetLitInfo mInfo; + + ArrayLit(final Term atom, final OffsetEqKey queryKey) { + mAtom = atom; + final ApplicationTerm eq = mInterpolator.getAtomTermInfo(atom).getEquality(); + final OffsetEqKey litKey = new OffsetEqKey(eq.getParameters()[0], eq.getParameters()[1]); + mInfo = new OffsetLitInfo(mTheory, mInterpolator.getAtomOccurenceInfo(atom), litKey).reorient(queryKey); + } + } + + /** + * Find the clause equality denoting the fact {@code left = right} (modulo constant shift), or null. + */ + private ArrayLit lookupEquality(final Term left, final Term right) { + final OffsetEqKey key = new OffsetEqKey(left, right); + final Term atom = mEqualities.get(key); + return atom == null ? null : new ArrayLit(atom, key); + } + + /** + * Find the clause disequality denoting the fact {@code left != right} (modulo constant shift), or null. + */ + private ArrayLit lookupDisequality(final Term left, final Term right) { + final OffsetEqKey key = new OffsetEqKey(left, right); + final Term atom = mDisequalities.get(key); + return atom == null ? null : new ArrayLit(atom, key); + } + + /** + * Check that two numeric terms are trivially distinct, i.e. they have the same offset-free part but different + * constants (like {@code x} and {@code x + 1}, or two distinct numerals). + */ + private static boolean isTriviallyDistinct(final Term left, final Term right) { + final OffsetEqKey key = new OffsetEqKey(left, right); + return key.getLhs() == key.getRhs() && !key.getOffset().equals(Rational.ZERO); + } + + /** + * An index (dis)equality collected on a weak path: the clause atom with its info reoriented to the path-level + * fact (some index vs. the weakpath index), and the path-level term of the non-weakpath side, which is used to + * build the projections in the interpolant (the atom's own parameters may be shifted by a constant). + */ + class ProjectedIndexLit { + final Term mAtom; + final OffsetLitInfo mInfo; + final Term mProjIndex; + + ProjectedIndexLit(final ArrayLit lit, final Term projIndex) { + mAtom = lit.mAtom; + mInfo = lit.mInfo; + mProjIndex = projIndex; + } + } + + /** + * Build the term {@code term + offset} in canonical form (flattened sum, constant last). + */ + private Term addOffset(final Term term, final Rational offset) { + if (offset.equals(Rational.ZERO)) { + return term; + } + final Polynomial poly = new Polynomial(term); + poly.add(offset); + return poly.toTerm(term.getSort()); + } + /** * Compute interpolants for array lemmas of type read-over-weakeq and weakeq-ext. * @@ -179,13 +259,13 @@ public Term[] computeInterpolants(final InterpolatorClauseInfo lemmaInfo) { final InterpolatorAtomInfo atomTermInfo = mInterpolator.getAtomTermInfo(atom); final ApplicationTerm equality = atomTermInfo.getEquality(); // negated in clause means positive in conflict - final Map, Term> map = (atom != literal ? mEqualities : mDisequalities); - map.put(new SymmetricPair<>(equality.getParameters()[0], equality.getParameters()[1]), + final Map map = (atom != literal ? mEqualities : mDisequalities); + map.put(new OffsetEqKey(equality.getParameters()[0], equality.getParameters()[1]), atom); } final ApplicationTerm equality = getDiseq(mLemmaInfo); final Term[] eqParams = equality.getParameters(); - mDiseq = mDisequalities.get(new SymmetricPair<>(eqParams[0], eqParams[1])); + mDiseq = mDisequalities.get(new OffsetEqKey(eqParams[0], eqParams[1])); mDiseqInfo = mInterpolator.getAtomOccurenceInfo(mDiseq); Term[] interpolants = new Term[mNumInterpolants]; @@ -227,17 +307,21 @@ private Term[] computeReadOverWeakeqInterpolants() { mStorePath = paths[0]; assert mDiseq != null; - final ApplicationTerm selectEq = (ApplicationTerm) mDiseq; - assert selectEq.getFunction().getName().equals("=") - && selectEq.getParameters()[0] instanceof ApplicationTerm + // Read the select structure from the annotation's disequality: the clause atom may be an offset-canonicalized + // form of it, but the annotation is guaranteed to be an equality of two path-level select terms. + final ApplicationTerm selectEq = getDiseq(mLemmaInfo); + assert selectEq.getParameters()[0] instanceof ApplicationTerm && selectEq.getParameters()[1] instanceof ApplicationTerm && ((ApplicationTerm) selectEq.getParameters()[0]).getFunction().getName().equals("select") && ((ApplicationTerm) selectEq.getParameters()[1]).getFunction().getName().equals("select"); final Term leftIndex = ((ApplicationTerm) selectEq.getParameters()[0]).getParameters()[1]; final Term rightIndex = ((ApplicationTerm) selectEq.getParameters()[1]).getParameters()[1]; if (leftIndex != rightIndex) { - mIndexEquality = mEqualities.get(new SymmetricPair<>(leftIndex, rightIndex)); - assert mIndexEquality != null; + assert mStorePath.getIndex() == leftIndex || mStorePath.getIndex() == rightIndex; + final Term otherIndex = mStorePath.getIndex() == leftIndex ? rightIndex : leftIndex; + final ArrayLit indexEquality = lookupEquality(otherIndex, mStorePath.getIndex()); + assert indexEquality != null; + mIndexEquality = new ProjectedIndexLit(indexEquality, otherIndex); } final WeakPathInfo arrayPath = new WeakPathInfo(mStorePath); @@ -422,8 +506,8 @@ private void determineInterpolationColor() { } } else if (mLemmaInfo.getLemmaType().equals(":read-const-weakeq")) { // Compute the first partition where the select term (i.e. not the value "v" of "const(v)") is A-local. - final InterpolatorAtomInfo diseqInfo = mInterpolator.getAtomTermInfo(mDiseq); - final ApplicationTerm mainDiseqApp = diseqInfo.getEquality(); + // Use the annotation's disequality: the clause atom may be offset-canonicalized. + final ApplicationTerm mainDiseqApp = getDiseq(mLemmaInfo); final Term left = mainDiseqApp.getParameters()[0]; final Term right = mainDiseqApp.getParameters()[1]; final Term select; @@ -488,23 +572,38 @@ private Term[] findSharedTerms(final Term term) { if (sharedTermCounter == mNumInterpolants) { return sharedTerms; } - // If the term is not shared itself in all partitions, we go through the equalities - for (final SymmetricPair eq : mEqualities.keySet()) { + // If the term is not shared itself in all partitions, we go through the equalities. An equality relates the + // term if one side has the same offset-free part; the shared candidate is then the other side plus the + // difference of the constants (for the mixed case, the mixed variable plus that difference). + final OffsetTerm termSplit = new OffsetTerm(term); + for (final Map.Entry entry : mEqualities.entrySet()) { // Always check first if we still need to search. if (sharedTermCounter == mNumInterpolants) { return sharedTerms; } - if (eq.getFirst().equals(term) || eq.getSecond().equals(term)) { - final LitInfo eqInfo = mInterpolator.getAtomOccurenceInfo(mEqualities.get(eq)); - for (int color = 0; color < mNumInterpolants; color++) { - if (eqInfo.isMixed(color)) { // in those partitions, term is local - sharedTerms[color] = eqInfo.getMixedVar(); - } else if (sharedTerms[color] == null) { // term itself wasn't shared - final Term otherTerm = eq.getFirst().equals(term) ? eq.getSecond() : eq.getFirst(); - final Occurrence otherOccur = mInterpolator.getOccurrence(otherTerm); - if (otherOccur.isAB(color)) { - sharedTerms[color] = otherTerm; - } + final InterpolatorAtomInfo atomTermInfo = mInterpolator.getAtomTermInfo(entry.getValue()); + final Term[] eqParams = atomTermInfo.getEquality().getParameters(); + final OffsetTerm leftSplit = new OffsetTerm(eqParams[0]); + final OffsetTerm rightSplit = new OffsetTerm(eqParams[1]); + final Term otherTerm; + final Rational shift; + if (leftSplit.getTerm() == termSplit.getTerm()) { + shift = termSplit.getOffset().sub(leftSplit.getOffset()); + otherTerm = eqParams[1]; + } else if (rightSplit.getTerm() == termSplit.getTerm()) { + shift = termSplit.getOffset().sub(rightSplit.getOffset()); + otherTerm = eqParams[0]; + } else { + continue; + } + final LitInfo eqInfo = mInterpolator.getAtomOccurenceInfo(entry.getValue()); + for (int color = 0; color < mNumInterpolants; color++) { + if (eqInfo.isMixed(color)) { // in those partitions, term is local + sharedTerms[color] = addOffset(eqInfo.getMixedVar(), shift); + } else if (sharedTerms[color] == null) { // term itself wasn't shared + final Occurrence otherOccur = mInterpolator.getOccurrence(otherTerm); + if (otherOccur.isAB(color)) { + sharedTerms[color] = addOffset(otherTerm, shift); } } } @@ -518,20 +617,16 @@ private Term[] findSharedTerms(final Term term) { * is B-local and the main diseq is A -> it is a premise for the path summaries */ private void addIndexEqualityReadOverWeakeq(final WeakPathInfo mainPath) { - final LitInfo indexEqInfo = mInterpolator.getAtomOccurenceInfo(mIndexEquality); - final InterpolatorAtomInfo diseqInfo = mInterpolator.getAtomTermInfo(mDiseq); - final ApplicationTerm mainDiseqApp = diseqInfo.getEquality(); - final Term otherIndex = getIndexFromSelect(mainDiseqApp.getParameters()[0]).equals(mStorePath.getIndex()) - ? getIndexFromSelect(mainDiseqApp.getParameters()[1]) - : getIndexFromSelect(mainDiseqApp.getParameters()[0]); + final OffsetLitInfo indexEqInfo = mIndexEquality.mInfo; + final Term otherIndex = mIndexEquality.mProjIndex; final Occurrence otherIndexOccur = mInterpolator.getOccurrence(otherIndex); for (int color = 0; color < mNumInterpolants; color++) { if (mainPath.mSharedIndex[color] != null && mainPath.mSharedIndex[color] == mStorePath.getIndex()) { if (mDiseqInfo.isALocal(color) && indexEqInfo.isBLocal(color)) { - mInterpolants[color].add(mTheory.not(mIndexEquality)); + mInterpolants[color].add(mTheory.not(mIndexEquality.mAtom)); } else if (!mDiseqInfo.isALocal(color) && indexEqInfo.isALocal(color)) { if (otherIndexOccur.isAB(color)) { - mInterpolants[color].add(mIndexEquality); + mInterpolants[color].add(mIndexEquality.mAtom); } } } @@ -805,16 +900,29 @@ private Term buildConstPathInterpolant(final boolean isAPath, final Term sharedA class ProofPath { private final Term mPathIndex; private final Term[] mPath; + /** + * The select/const edge {@code {left, right}} justifying the single weak-congruence step of this weak path, or + * null if the path has none. Each element is either a select term or the value of a const array; {@code left} + * is on the {@code mPath[0]} side. + */ + private final Term[] mSelectEdge; private ProofPath(final String type, final Object path) { if (type.equals(":subpath")) { mPathIndex = null; mPath = (Term[]) path; + mSelectEdge = null; } else { assert type.equals(":weakpath"); final Object[] indexAndPath = (Object[]) path; mPathIndex = (Term) indexAndPath[0]; mPath = (Term[]) indexAndPath[1]; + if (indexAndPath.length > 2) { + final Object[] edge = (Object[]) indexAndPath[2]; + mSelectEdge = new Term[] { (Term) edge[0], (Term) edge[1] }; + } else { + mSelectEdge = null; + } } } @@ -825,6 +933,10 @@ public Term getIndex() { public Term[] getPath() { return mPath; } + + public Term[] getSelectEdge() { + return mSelectEdge; + } } /** @@ -843,6 +955,11 @@ class WeakPathInfo { * The array containing the array terms on this path. */ Term[] mPath; + /** + * The select/const edge {@code {left, right}} of this weak path from the lemma annotation, or null. It + * justifies the path's single weak-congruence step; {@code left} is on the {@code mPath[0]} side. + */ + Term[] mPathSelectEdge; /** * The set of partitions for which there is an AB-shared path from start to end. */ @@ -881,6 +998,7 @@ public WeakPathInfo(final ProofPath path) { mPathIndex = path.getIndex(); mSharedIndex = new Term[mNumInterpolants]; mPath = path.getPath(); + mPathSelectEdge = path.getSelectEdge(); mHasABPath = new BitSet(mNumInterpolants); mHasABPath.set(0, mNumInterpolants); mMaxColor = mNumInterpolants; @@ -907,8 +1025,8 @@ public Set[] interpolateWeakPathInfo(final boolean close) { final String lemmaType = mLemmaInfo.getLemmaType(); if (lemmaType.equals(":read-over-weakeq") || lemmaType.equals(":read-const-weakeq")) { // The select or value term of the main diseq corresponding to the left path end determines start color. - final InterpolatorAtomInfo diseqInfo = mInterpolator.getAtomTermInfo(mDiseq); - final Term[] diseqTerms = diseqInfo.getEquality().getParameters(); + // Use the annotation's disequality; the clause atom may be offset-canonicalized. + final Term[] diseqTerms = getDiseq(mLemmaInfo).getParameters(); if (isSelectTerm(diseqTerms[0]) && getArrayFromSelect(diseqTerms[0]).equals(mPath[0]) || isConstArray(mPath[0]) && getValueFromConst(mPath[0]).equals(diseqTerms[0])) { headTerm = diseqTerms[0]; @@ -932,14 +1050,14 @@ public Set[] interpolateWeakPathInfo(final boolean close) { for (int i = 0; i < mPath.length - 1; i++) { final Term left = mPath[i]; final Term right = mPath[i + 1]; - final Term lit = mEqualities.get(new SymmetricPair<>(left, right)); + final Term lit = mEqualities.get(new OffsetEqKey(left, right)); Term boundaryTerm = left; // Each step in a weak path can be either an equality literal or a store step of form "a (store a k v)", - // or, in weakeq-ext lemmas only, a select equality. + // or, in weakeq-ext lemmas only, the select/const edge annotated at the weak path. // In the second and third case, there is no equality literal for the arrays in the lemma. if (lit == null) { - if (!mLemmaInfo.getLemmaType().equals(":weakeq-ext") || !checkAndAddSelectEdgeStep(left, right)) { + if (!checkAndAddSelectEdgeStep(left, right)) { addStoreEdgeStep(left, right); } } else { // For equality steps, we just close or open A paths. @@ -985,54 +1103,75 @@ private void addStoreEdgeStep(Term left, Term right) { final Term boundaryTerm = left; final Occurrence stepOcc = mInterpolator.getOccurrence(storeTerm); final Term storeIndex = getIndexFromStore(storeTerm); - final Term indexDiseq = mDisequalities.get(new SymmetricPair<>(storeIndex, mPathIndex)); + final ArrayLit indexDiseq = lookupDisequality(storeIndex, mPathIndex); if (indexDiseq != null) { - final Occurrence indexDiseqOcc = mInterpolator.getAtomOccurenceInfo(indexDiseq); + final Occurrence indexDiseqOcc = indexDiseq.mInfo.getLitInfo(); final Occurrence intersectOcc = stepOcc.intersect(indexDiseqOcc); mTail.closeAPath(mHead, boundaryTerm, stepOcc); mTail.closeAPath(mHead, boundaryTerm, intersectOcc); mTail.openAPath(mHead, boundaryTerm, intersectOcc); mTail.openAPath(mHead, boundaryTerm, stepOcc); - mTail.addIndexDisequality(mHead, indexDiseq); + mTail.addIndexDisequality(mHead, new ProjectedIndexLit(indexDiseq, storeIndex)); } else { // Otherwise indexDiseq is a trivial disequality like x = x + 1. // Treat it as a shared disequality. + assert isTriviallyDistinct(storeIndex, mPathIndex); mTail.closeAPath(mHead, boundaryTerm, stepOcc); mTail.openAPath(mHead, boundaryTerm, stepOcc); } } + /** + * Check whether the step between left and right is this weak path's select/const edge (annotated at the weak + * path by the lemma producer) and if so, add it. The corresponding select equality literal is found via the + * offset-aware lookup; the edge terms from the annotation provide the path-level select/value structure, as + * the clause literal may be offset-canonicalized. + */ private boolean checkAndAddSelectEdgeStep(Term left, Term right) { - final MatchingSelectEquality match = findSelectEquality(left, right); - if (match == null) { + if (mPathSelectEdge == null) { + return false; + } + // The edge's left term is on the mPath[0] side; check that the edge belongs to this step. Each edge term + // either is a select on the step's array or the value of the step's const array — and a select term can + // itself be the value of a const array, so both cases must be considered regardless of the term's shape. + final Term edgeLeft = mPathSelectEdge[0]; + final Term edgeRight = mPathSelectEdge[1]; + final boolean isConstLeft = isConstArray(left) && getValueFromConst(left).equals(edgeLeft); + final boolean isConstRight = isConstArray(right) && getValueFromConst(right).equals(edgeRight); + final boolean leftMatches = isConstLeft + || isSelectTerm(edgeLeft) && getArrayFromSelect(edgeLeft).equals(left); + final boolean rightMatches = isConstRight + || isSelectTerm(edgeRight) && getArrayFromSelect(edgeRight).equals(right); + if (!leftMatches || !rightMatches) { return false; } - final Term selectEq = match.mEqualityLit; - final InterpolatorAtomInfo termInfo = mInterpolator.getAtomTermInfo(selectEq); - final LitInfo stepInfo = mInterpolator.getAtomOccurenceInfo(selectEq); - final ApplicationTerm selectEqApp = termInfo.getEquality(); - final Term leftSelectOrValue = selectEqApp.getParameters()[match.mIsSwapped ? 1 : 0]; - final Term rightSelectOrValue = selectEqApp.getParameters()[match.mIsSwapped ? 0 : 1]; + final ArrayLit selectLit = lookupEquality(edgeLeft, edgeRight); + assert selectLit != null : "select edge without matching literal"; + final Term selectEq = selectLit.mAtom; + final LitInfo stepInfo = selectLit.mInfo.getLitInfo(); Term boundaryTerm = left; mTail.closeAPath(mHead, boundaryTerm, stepInfo); mTail.openAPath(mHead, boundaryTerm, stepInfo); final TermVariable mixedVar = stepInfo.getMixedVar(); if (mixedVar != null) { - final Occurrence leftOcc = mInterpolator.getOccurrence(leftSelectOrValue); + // The mixed variable denotes the literal-level select value; a constant shift between the literal + // and the path-level edge is not yet supported here (the marker mechanism cannot carry it). + assert selectLit.mInfo.getShift().equals(Rational.ZERO) : "mixed select edge with offset"; + final Occurrence leftOcc = mInterpolator.getOccurrence(edgeLeft); // The left select can be A-local although we were on a B-path (and vice versa) mTail.closeAPath(mHead, boundaryTerm, leftOcc); mTail.openAPath(mHead, boundaryTerm, leftOcc); } // Add the index equality for the first select term (if it is a select) - if (!match.mIsConstLeft) { - mTail.addSelectIndexEquality(mHead, leftSelectOrValue); + if (!isConstLeft) { + mTail.addSelectIndexEquality(mHead, edgeLeft); } if (mixedVar != null) { - final Occurrence rightOcc = mInterpolator.getOccurrence(rightSelectOrValue); - if (match.mIsConstLeft || match.mIsConstRight) { + final Occurrence rightOcc = mInterpolator.getOccurrence(edgeRight); + if (isConstLeft || isConstRight) { // It is a const select equality - we close the path with const(mixedVar) boundaryTerm = buildConst(mixedVar, left.getSort()); } else { @@ -1043,8 +1182,8 @@ private boolean checkAndAddSelectEdgeStep(Term left, Term right) { } // The other index equality is added after opening/closing (if the rightTerm is // a select) - if (!match.mIsConstRight) { - mTail.addSelectIndexEquality(mHead, rightSelectOrValue); + if (!isConstRight) { + mTail.addSelectIndexEquality(mHead, edgeRight); } return true; } @@ -1073,7 +1212,7 @@ public void collectStorePaths() { for (int i = 0; i < mPath.length - 1; i++) { final Term left = mPath[i]; final Term right = mPath[i + 1]; - final Term lit = mEqualities.get(new SymmetricPair<>(left, right)); + final Term lit = mEqualities.get(new OffsetEqKey(left, right)); Term boundaryTerm; boundaryTerm = left; @@ -1241,89 +1380,6 @@ public Set[] interpolateStorePathInfoConst() { return mPathInterpolants; } - class MatchingSelectEquality { - Term mEqualityLit; - boolean mIsSwapped; - boolean mIsConstLeft; - boolean mIsConstRight; - - public MatchingSelectEquality(Term equalityLit, - boolean isSwapped, boolean isConstLeft, boolean isConstRight) { - mEqualityLit = equalityLit; - mIsSwapped = isSwapped; - mIsConstLeft = isConstLeft; - mIsConstRight = isConstRight; - } - } - - /** - * For a step in an index path of a weakeq-ext lemma that is not an array equality, check if we can find a - * select equality between the arrays and corresponding index equalities. - * - * In the presence of constant arrays, one side of the select equality can be the value "v" of a "const(v)". - * - * @return the select equality if it exists, else null. - */ - private MatchingSelectEquality findSelectEquality(final Term leftArray, final Term rightArray) { - for (final SymmetricPair testEq : mEqualities.keySet()) { - // Find some select equality. - final Term eqLeft = testEq.getFirst(); - final Term eqRight = testEq.getSecond(); - final boolean isLeftSelect = isSelectTerm(eqLeft); - final boolean isRightSelect = isSelectTerm(eqRight); - if (isLeftSelect && isRightSelect) { - // Check equalities of the form "arr1[j1] = arr2[j2]". - // Check if the arrays of the select terms match the term pair. - if (isGoodSelect(eqLeft, leftArray) && isGoodSelect(eqRight, rightArray)) { - return new MatchingSelectEquality(mEqualities.get(testEq), false, false, false); - } - if (isGoodSelect(eqRight, leftArray) && isGoodSelect(eqLeft, rightArray)) { - return new MatchingSelectEquality(mEqualities.get(testEq), true, false, false); - } - } - if (isConstArray(leftArray)) { - // Check equalities of the form "arr[j] = v". - final Term value = getValueFromConst(leftArray); - if (eqLeft == value && isRightSelect && isGoodSelect(eqRight, rightArray)) { - return new MatchingSelectEquality(mEqualities.get(testEq), false, true, false); - } - if (eqRight == value && isLeftSelect && isGoodSelect(eqLeft, rightArray)) { - return new MatchingSelectEquality(mEqualities.get(testEq), true, true, false); - } - } - if (isConstArray(rightArray)) { - // Check equalities of the form "arr[j] = v". - final Term value = getValueFromConst(rightArray); - if (eqLeft == value && isRightSelect && isGoodSelect(eqRight, leftArray)) { - return new MatchingSelectEquality(mEqualities.get(testEq), true, false, true); - } - if (eqRight == value && isLeftSelect && isGoodSelect(eqLeft, leftArray)) { - return new MatchingSelectEquality(mEqualities.get(testEq), false, false, true); - } - } - } - // No select equality could be found. - return null; - } - - /** - * Check if the selectTerm is a select on the array term with an index matching - * mPathIndex. - * - * @param selectTerm The select term. Caller must ensure it is a select. - * @param arrayTerm The array term on which the select term must match. - * @return true if the array term match and the index either equals mPathIndex - * or a corresponding equality exists. - */ - private boolean isGoodSelect(Term selectTerm, Term arrayTerm) { - if (getArrayFromSelect(selectTerm) == arrayTerm) { - final Term selectIndex = getIndexFromSelect(selectTerm); - return selectIndex == mPathIndex - || mEqualities.containsKey(new SymmetricPair<>(selectIndex, mPathIndex)); - } - return false; - } - /** * Close the path using the main disequality. * @@ -1354,8 +1410,7 @@ public void addDiseq(final Occurrence headOcc, final Occurrence tailOcc) { if (mLemmaInfo.getLemmaType().equals(":read-over-weakeq")) { if (mIndexEquality != null) { - final LitInfo indexEqInfo = mInterpolator.getAtomOccurenceInfo(mIndexEquality); - mTail.addSelectIndexEqAllColors(mHead, indexEqInfo, mIndexEquality); + mTail.addSelectIndexEqAllColors(mHead, mIndexEquality.mInfo.getLitInfo(), mIndexEquality); } mTail.closeAPath(mHead, mDiseq, headOcc); @@ -1502,33 +1557,29 @@ private void closeWeakeqExt(final Occurrence headOcc, final Occurrence tailOcc) * */ private Term buildFPiTerm(final boolean isAPath, final int color, final Term sharedIndex, - final ArrayList indexDiseqs, final ArrayList indexEqs) { + final ArrayList indexDiseqs, final ArrayList indexEqs) { if (indexDiseqs == null && indexEqs == null) { return isAPath ? mTheory.mFalse : mTheory.mTrue; } final Set indexTerms = new HashSet<>(); if (indexDiseqs != null) { - for (final Term diseq : indexDiseqs) { - final InterpolatorAtomInfo termInfo = mInterpolator.getAtomTermInfo(diseq); - final LitInfo info = mInterpolator.getAtomOccurenceInfo(diseq); - final ApplicationTerm diseqApp = termInfo.getEquality(); + for (final ProjectedIndexLit diseq : indexDiseqs) { // Collected index diseqs are either mixed or B-local on A-paths (resp. A-local on B-paths). // In the first case, there is a mixed term, in the second, the store index is shared. - if (info.isMixed(color)) { - final Term var = info.getMixedVar(); - final Term projection; - projection = mTheory.term(Interpolator.EQ, var, sharedIndex); - indexTerms.add(projection); + if (diseq.mInfo.isMixed(color)) { + // The mixed variable denotes the literal-level index values; shift the path-level shared + // index down accordingly. + indexTerms.add(diseq.mInfo.buildEQ(sharedIndex)); } else { - final Term index = diseqApp.getParameters()[0].equals(mPathIndex) ? diseqApp.getParameters()[1] - : diseqApp.getParameters()[0]; + // The path-level store index (the literal's own parameter may be shifted by a constant). + final Term index = diseq.mProjIndex; // On A-paths, the negated B-projection of the index diseq is added. // It is always an equality (representing an EQ term for mixed index diseqs). Term projection = mTheory.equals(index, sharedIndex); // On B-paths, the A-projection of the index diseq is added. // It is an equality (EQ-term) for mixed index diseq, and a disequality for A-local index diseq. - if (!isAPath && info.isALocal(color)) { + if (!isAPath && diseq.mInfo.isALocal(color)) { projection = mTheory.not(projection); } indexTerms.add(projection); @@ -1536,15 +1587,11 @@ private Term buildFPiTerm(final boolean isAPath, final int color, final Term sha } } if (indexEqs != null) { - for (final Term eq : indexEqs) { - final InterpolatorAtomInfo termInfo = mInterpolator.getAtomTermInfo(eq); - final LitInfo info = mInterpolator.getAtomOccurenceInfo(eq); - final ApplicationTerm eqApp = termInfo.getEquality(); + for (final ProjectedIndexLit eq : indexEqs) { // Index eqs are either mixed or B-local on A-paths (resp. A-local on B-paths). - // In the first case, there is a mixed term, in the second, the select index is shared. - final Term index = info.isMixed(color) ? info.getMixedVar() - : eqApp.getParameters()[0].equals(mPathIndex) ? eqApp.getParameters()[1] - : eqApp.getParameters()[0]; + // In the first case, there is a mixed term (lifted to path level), in the second, the select + // index is shared (we use the path-level term; the literal's parameter may be shifted). + final Term index = eq.mInfo.isMixed(color) ? eq.mInfo.getMixedBoundary() : eq.mProjIndex; // On B-paths, the A-projection is added. It is always an equality. Term projection = mTheory.equals(index, sharedIndex); // On A-paths, the negated B-projection is added. It is always a disequality. @@ -1589,12 +1636,12 @@ class WeakPathEnd { * For each partition this contains the set of B(resp. A)-local and mixed store index disequalities found on * the A (resp. B) path so far. */ - ArrayList[] mIndexDiseqs; + ArrayList[] mIndexDiseqs; /** * For each partition this contains the set of B(resp. A)-local and mixed select index equalities found on * the A (resp. B) path so far. */ - ArrayList[] mIndexEqs; + ArrayList[] mIndexEqs; /** * For each partition, this stores the store indices on the A (B) path so far. This is only used for the * main path of a weakeq-ext lemma. @@ -1742,8 +1789,8 @@ private void openSingleAPath(final WeakPathEnd other, final Term boundary, final * @param storeTerm * The store term from which we extract the store index. */ - private void addIndexDisequality(final WeakPathEnd other, final Term diseq) { - final LitInfo diseqInfo = mInterpolator.getAtomOccurenceInfo(diseq); + private void addIndexDisequality(final WeakPathEnd other, final ProjectedIndexLit diseq) { + final LitInfo diseqInfo = diseq.mInfo.getLitInfo(); // The diseq has to be added to all partitions where it is mixed and all partitions that lie on the // tree path between the partition of the diseq and the partition of the store term. @@ -1763,7 +1810,7 @@ private void addIndexDisequality(final WeakPathEnd other, final Term diseq) { * the path is. */ private void addIndexDiseqAllColors(final WeakPathEnd other, final Occurrence occur, - final Term diseq) { + final ProjectedIndexLit diseq) { int currentColor = mColor; // Up mHasABPath.and(occur.mInA); @@ -1797,7 +1844,8 @@ private void addIndexDiseqAllColors(final WeakPathEnd other, final Occurrence oc /** * Add the index disequality to one partition. */ - private void addIndexDiseqOneColor(final WeakPathEnd other, final Term diseq, final int color) { + private void addIndexDiseqOneColor(final WeakPathEnd other, final ProjectedIndexLit diseq, + final int color) { // If the path is still open at the other path end, i.e. if other.mLastChange[color] is still null, we // have to store the diseq in the other pathend if (other.mLastChange[color] == null) { @@ -1827,8 +1875,10 @@ private void addSelectIndexEquality(final WeakPathEnd other, final Term selectTe assert isSelectTerm(selectTerm); if (getIndexFromSelect(selectTerm) != mPathIndex) { final Term selectIndex = getIndexFromSelect(selectTerm); - final Term indexEq = mEqualities.get(new SymmetricPair<>(selectIndex, mPathIndex)); - final LitInfo eqInfo = mInterpolator.getAtomOccurenceInfo(indexEq); + final ArrayLit indexEqLit = lookupEquality(selectIndex, mPathIndex); + assert indexEqLit != null; + final ProjectedIndexLit indexEq = new ProjectedIndexLit(indexEqLit, selectIndex); + final LitInfo eqInfo = indexEqLit.mInfo.getLitInfo(); addSelectIndexEqAllColors(other, eqInfo, indexEq); if (eqInfo.getMixedVar() != null) { final Occurrence occur = mInterpolator.getOccurrence(mPathIndex); @@ -1842,7 +1892,8 @@ private void addSelectIndexEquality(final WeakPathEnd other, final Term selectTe * those partitions. This adds the index equality to all partitions where it is not in A (resp. B) while the * path is. */ - private void addSelectIndexEqAllColors(final WeakPathEnd other, final Occurrence occur, final Term eq) { + private void addSelectIndexEqAllColors(final WeakPathEnd other, final Occurrence occur, + final ProjectedIndexLit eq) { int currentColor = mColor; // Up mHasABPath.and(occur.mInA); @@ -1876,7 +1927,8 @@ private void addSelectIndexEqAllColors(final WeakPathEnd other, final Occurrence /** * Add the index equality to one partition. */ - private void addSelectIndexEqOneColor(final WeakPathEnd other, final Term eq, final int color) { + private void addSelectIndexEqOneColor(final WeakPathEnd other, final ProjectedIndexLit eq, + final int color) { // If the path is still open at the other path end, i.e. if other.mLastChange[color] is still null, we // have to store the diseq in the other pathend if (other.mLastChange[color] == null) { @@ -1975,16 +2027,12 @@ private void addInterpolantClausePathSeg(final boolean isAPath, final int color, // Use shared store indices to rewrite "left" to "right" in order to shorten the weq- or nweq-term. final Set sharedIndices = new HashSet<>(); if (mIndexDiseqs[color] != null) { - final Iterator it = mIndexDiseqs[color].iterator(); + final Iterator it = mIndexDiseqs[color].iterator(); while (it.hasNext()) { - final Term diseq = it.next(); - final InterpolatorAtomInfo termInfo = mInterpolator.getAtomTermInfo(diseq); - final LitInfo info = mInterpolator.getAtomOccurenceInfo(diseq); - if (!info.isMixed(color)) { - final ApplicationTerm diseqApp = termInfo.getEquality(); - final Term storeIndex = - diseqApp.getParameters()[0].equals(mPathIndex) ? diseqApp.getParameters()[1] - : diseqApp.getParameters()[0]; + final ProjectedIndexLit diseq = it.next(); + if (!diseq.mInfo.isMixed(color)) { + // The path-level store index (the literal's parameter may be constant-shifted). + final Term storeIndex = diseq.mProjIndex; final Occurrence storeOcc = mInterpolator.getOccurrence(storeIndex); if (storeOcc.isAB(color)) { sharedIndices.add(storeIndex); @@ -2266,12 +2314,12 @@ private void buildRecursiveInterpolant(final int color, final WeakPathEnd other, // Needed for case without shared index (then there are no indexEqs) final int fPiOrderForRecursion; if (equals(mHead)) { // recursionPath is the left outer path - final ArrayList indexDiseqs = indexPath.mTail.mIndexDiseqs[color]; + final ArrayList indexDiseqs = indexPath.mTail.mIndexDiseqs[color]; fPiOrderForRecursion = indexDiseqs == null ? 0 : indexDiseqs.size(); fPi = indexPath.buildFPiTerm(!isAPath, color, rewriteAtIndex, indexDiseqs, indexPath.mTail.mIndexEqs[color]); } else { // recursionPath is the right outer path - final ArrayList indexDiseqs = indexPath.mHead.mIndexDiseqs[color]; + final ArrayList indexDiseqs = indexPath.mHead.mIndexDiseqs[color]; fPiOrderForRecursion = indexDiseqs == null ? 0 : indexDiseqs.size(); fPi = indexPath.buildFPiTerm(!isAPath, color, rewriteAtIndex, indexDiseqs, indexPath.mHead.mIndexEqs[color]); diff --git a/SMTInterpolTest/test/interpolation/arrayoffset001.smt2 b/SMTInterpolTest/test/interpolation/arrayoffset001.smt2 new file mode 100644 index 000000000..b25aeb339 --- /dev/null +++ b/SMTInterpolTest/test/interpolation/arrayoffset001.smt2 @@ -0,0 +1,16 @@ +(set-option :produce-interpolants true) +(set-option :interpolant-check-mode true) +(set-logic QF_ALIA) +(set-info :status unsat) +(declare-const a (Array Int Int)) +(declare-const b (Array Int Int)) +(declare-const i Int) +(declare-const j Int) +(declare-const v Int) +;; read-over-weakeq with shifted indices: store at i+1, read at j+2, i+1 != j+2 +(assert (! (= b (store a (+ i 1) v)) :named A)) +(assert (! (and (not (= (select b (+ j 2)) (select a (+ j 2)))) (not (= i (+ j 1)))) :named B)) +(check-sat) +(get-interpolants A B) +(get-interpolants B A) +(exit) diff --git a/SMTInterpolTest/test/interpolation/arrayoffset002.smt2 b/SMTInterpolTest/test/interpolation/arrayoffset002.smt2 new file mode 100644 index 000000000..295dd991c --- /dev/null +++ b/SMTInterpolTest/test/interpolation/arrayoffset002.smt2 @@ -0,0 +1,15 @@ +(set-option :produce-interpolants true) +(set-option :interpolant-check-mode true) +(set-logic QF_ALIA) +(set-info :status unsat) +(declare-const a (Array Int Int)) +(declare-const b (Array Int Int)) +(declare-const i Int) +(declare-const j Int) +(declare-const v Int) +(assert (! (and (= b (store a (+ i 1) v)) (not (= i (+ j 1)))) :named A)) +(assert (! (not (= (select b (+ j 2)) (select a (+ j 2)))) :named B)) +(check-sat) +(get-interpolants A B) +(get-interpolants B A) +(exit) diff --git a/SMTInterpolTest/test/interpolation/arrayoffset003.smt2 b/SMTInterpolTest/test/interpolation/arrayoffset003.smt2 new file mode 100644 index 000000000..6d5628bdc --- /dev/null +++ b/SMTInterpolTest/test/interpolation/arrayoffset003.smt2 @@ -0,0 +1,15 @@ +(set-option :produce-interpolants true) +(set-option :interpolant-check-mode true) +(set-logic QF_ALIA) +(set-info :status unsat) +(declare-const a (Array Int Int)) +(declare-const x Int) +(declare-const y Int) +(declare-const k Int) +;; read-const-weakeq: const array with offset value, read shifted through y = x+2 +(assert (! (= a ((as const (Array Int Int)) (+ x 1))) :named A)) +(assert (! (and (= y (+ x 2)) (not (= (select a k) (+ y (- 1))))) :named B)) +(check-sat) +(get-interpolants A B) +(get-interpolants B A) +(exit) diff --git a/SMTInterpolTest/test/interpolation/arrayoffset004.smt2 b/SMTInterpolTest/test/interpolation/arrayoffset004.smt2 new file mode 100644 index 000000000..82cb631f8 --- /dev/null +++ b/SMTInterpolTest/test/interpolation/arrayoffset004.smt2 @@ -0,0 +1,18 @@ +(set-option :produce-interpolants true) +(set-option :interpolant-check-mode true) +(set-logic QF_ALIA) +(set-info :status unsat) +(declare-const a (Array Int Int)) +(declare-const b (Array Int Int)) +(declare-const c (Array Int Int)) +(declare-const i Int) +(declare-const j Int) +(declare-const v Int) +(declare-const w Int) +;; weakeq-ext: both stores hit the same cell (i+1 = j+2) with the same value +(assert (! (and (= a (store c (+ i 1) v)) (= i (+ j 1))) :named A)) +(assert (! (and (= b (store c (+ j 2) w)) (= v w) (not (= a b))) :named B)) +(check-sat) +(get-interpolants A B) +(get-interpolants B A) +(exit) diff --git a/SMTInterpolTest/test/interpolation/arrayoffset005.smt2 b/SMTInterpolTest/test/interpolation/arrayoffset005.smt2 new file mode 100644 index 000000000..b8ef643b2 --- /dev/null +++ b/SMTInterpolTest/test/interpolation/arrayoffset005.smt2 @@ -0,0 +1,16 @@ +(set-option :produce-interpolants true) +(set-option :interpolant-check-mode true) +(set-logic QF_ALIA) +(set-info :status unsat) +(declare-const a (Array Int Int)) +(declare-const b (Array Int Int)) +(declare-const i Int) +(declare-const k Int) +(declare-const v Int) +;; read-over-store through equality k = i+1, stored value carries an offset v+1 +(assert (! (= b (store a (+ i 1) (+ v 1))) :named A)) +(assert (! (and (= k (+ i 1)) (not (= (select b k) (+ v 1)))) :named B)) +(check-sat) +(get-interpolants A B) +(get-interpolants B A) +(exit) diff --git a/SMTInterpolTest/test/interpolation/arrayoffset006.smt2 b/SMTInterpolTest/test/interpolation/arrayoffset006.smt2 new file mode 100644 index 000000000..b7aff1cb5 --- /dev/null +++ b/SMTInterpolTest/test/interpolation/arrayoffset006.smt2 @@ -0,0 +1,17 @@ +(set-option :produce-interpolants true) +(set-option :interpolant-check-mode true) +(set-logic QF_ALIA) +(set-info :status unsat) +(declare-const a (Array Int Int)) +(declare-const b (Array Int Int)) +(declare-const i Int) +(declare-const j Int) +(declare-const k Int) +(declare-const v Int) +;; index equality derived via LA: i = k+1 (A), j = k+2 (B), so j = i+1 = store index +(assert (! (and (= b (store a (+ i 1) v)) (<= i (+ k 1)) (>= i (+ k 1))) :named A)) +(assert (! (and (<= j (+ k 2)) (>= j (+ k 2)) (not (= (select b j) v))) :named B)) +(check-sat) +(get-interpolants A B) +(get-interpolants B A) +(exit) diff --git a/SMTInterpolTest/test/interpolation/arrayoffset007.smt2 b/SMTInterpolTest/test/interpolation/arrayoffset007.smt2 new file mode 100644 index 000000000..81ef4ef0c --- /dev/null +++ b/SMTInterpolTest/test/interpolation/arrayoffset007.smt2 @@ -0,0 +1,17 @@ +(set-option :produce-interpolants true) +(set-option :interpolant-check-mode true) +(set-logic QF_ALIA) +(set-info :status unsat) +(declare-const a (Array Int Int)) +(declare-const b (Array Int Int)) +(declare-const i Int) +(declare-const j Int) +(declare-const k Int) +(declare-const v Int) +;; read-over-weakeq between two selects with shifted indices i+1 = j+2 +(assert (! (and (= b (store a (+ k 1) v)) (not (= k i))) :named A)) +(assert (! (and (= i (+ j 1)) (not (= (select b (+ i 1)) (select a (+ j 2))))) :named B)) +(check-sat) +(get-interpolants A B) +(get-interpolants B A) +(exit) diff --git a/SMTInterpolTest/test/interpolation/arrayoffset008.smt2 b/SMTInterpolTest/test/interpolation/arrayoffset008.smt2 new file mode 100644 index 000000000..74bf9dc9c --- /dev/null +++ b/SMTInterpolTest/test/interpolation/arrayoffset008.smt2 @@ -0,0 +1,16 @@ +(set-option :produce-interpolants true) +(set-option :interpolant-check-mode true) +(set-logic QF_ALIA) +(set-info :status unsat) +(declare-const a (Array Int Int)) +(declare-const b (Array Int Int)) +(declare-const i Int) +(declare-const j Int) +(declare-const v Int) +;; weakeq-ext: store position patched by a select equality at shifted index j+2 = i+1 +(assert (! (= a (store b (+ i 1) v)) :named A)) +(assert (! (and (= i (+ j 1)) (= (select a (+ j 2)) (select b (+ j 2))) (not (= a b))) :named B)) +(check-sat) +(get-interpolants A B) +(get-interpolants B A) +(exit) diff --git a/SMTInterpolTest/test/interpolation/arrayoffset009.smt2 b/SMTInterpolTest/test/interpolation/arrayoffset009.smt2 new file mode 100644 index 000000000..4a8f01276 --- /dev/null +++ b/SMTInterpolTest/test/interpolation/arrayoffset009.smt2 @@ -0,0 +1,16 @@ +(set-option :produce-interpolants true) +(set-option :interpolant-check-mode true) +(set-logic QF_ALIA) +(set-info :status unsat) +(declare-const a (Array Int Int)) +(declare-const b (Array Int Int)) +(declare-const x Int) +(declare-const y Int) +(declare-const k Int) +;; const-weakeq: store writes the const value through the shifted equality y = x-1 +(assert (! (= a ((as const (Array Int Int)) (+ x 1))) :named A)) +(assert (! (and (= b (store a k (+ y 2))) (= y (- x 1)) (not (= b ((as const (Array Int Int)) (+ x 1))))) :named B)) +(check-sat) +(get-interpolants A B) +(get-interpolants B A) +(exit) From 2efaa539ce099c740f20a4e86dc51c64b380f493 Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Mon, 6 Jul 2026 00:07:21 +0200 Subject: [PATCH 73/75] Support offset equalities in e-matching and instantiation. The e-matching register becomes CCParameter[] end to end, so candidate values keep their constant offsets: GetArgCode passes the full argument value (binding x := a+3 when matching f(x) against f(a+3)), YieldCode records CCParameter substitutions, and the dawg keys render the value terms. Compare triggers are offset-aware: insert/removeCompareTrigger mirror the insertEqualityEntry offset walk (including parking a trigger at the merge boundary when the two values are already in one class at a different offset); merge-time activation was already offset-selective. isEqSet/isDiseqSet take CCParameters with value semantics; isDiseqSet also detects same-class values at different offsets as provably distinct. getCCTermRep becomes getCCParamRep (constant split, value-keyed signature lookup, returns rep plus offset), and the new Clausifier.getCCParameter looks up the value of a possibly offsetted term. getRepresentativeTerm canonicalizes to the representative value term so a and a+1 in one class stay distinct substitutions; QuantClause keeps argument offsets in the interesting instantiation terms. MBTC clash slots gain the reverse-trigger source: the watched value of an installed reverse trigger joins its (symbol, position) slot. Offset-free behavior is unchanged; offsets stay disabled under quantifiers until the following commit. Co-Authored-By: Claude Fable 5 --- .../smtinterpol/convert/Clausifier.java | 15 +- .../smtinterpol/theory/cclosure/CClosure.java | 180 +++++++++++------- .../theory/cclosure/CompareTrigger.java | 7 +- .../cclosure/ReverseTriggerTrigger.java | 4 + .../theory/quant/InstantiationManager.java | 45 ++--- .../smtinterpol/theory/quant/QuantClause.java | 8 +- .../theory/quant/QuantifierTheory.java | 19 +- .../theory/quant/ematching/CompareCode.java | 21 +- .../quant/ematching/EMCompareTrigger.java | 23 +-- .../quant/ematching/EMReverseTrigger.java | 15 +- .../theory/quant/ematching/EMatching.java | 62 +++--- .../theory/quant/ematching/FindCode.java | 6 +- .../theory/quant/ematching/GetArgCode.java | 15 +- .../theory/quant/ematching/ICode.java | 10 +- .../quant/ematching/PatternCompiler.java | 11 +- .../theory/quant/ematching/ReverseCode.java | 6 +- .../theory/quant/ematching/YieldCode.java | 8 +- 17 files changed, 266 insertions(+), 189 deletions(-) diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/Clausifier.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/Clausifier.java index b663b3521..4a0a2cc4f 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/Clausifier.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/Clausifier.java @@ -594,11 +594,24 @@ public MutableAffineTerm toMutableAffineTerm(final Polynomial poly) { public CCTerm getCCTerm(final Term term) { // mCCTerms is keyed by offset-free terms only; an offseted term has no entry of its own (its CCParameter, with // the constant as offset, is produced at build time by createCCTerm). Callers must normalize via - // getOffsetFreeTerm first. + // getOffsetFreeTerm first, or use getCCParameter for a possibly offseted term. assert getTermConstant(term).equals(Rational.ZERO) : "getCCTerm on offseted term " + term; return mCCTerms.get(term); } + /** + * Get the {@link CCParameter} denoting the value of the given term: the CCTerm of the term's offset-free part plus + * the term's constant. Unlike {@link #getCCTerm} this accepts terms with a constant summand. This function does not + * create new terms. + * + * @return the value of the term, or null if no CCTerm exists for the term's offset-free part. + */ + public CCParameter getCCParameter(final Term term) { + final Rational constant = getTermConstant(term); + final CCTerm ccTerm = getCCTerm(constant.equals(Rational.ZERO) ? term : getOffsetFreeTerm(term)); + return ccTerm == null ? null : CCParameter.of(ccTerm, constant); + } + public LASharedTerm getLATerm(final Term term) { return mLATerms.get(term); } diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java index f11a64b93..f32f21293 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CClosure.java @@ -388,12 +388,14 @@ public boolean equals(final Object other) { * clash. *

* MBTC runs only in {@code finalCheck}, so the slots are computed on demand here rather than maintained in a - * persistent, backtracked index. + * persistent, backtracked index; the signature todo queue is drained at every checkpoint, so all installed + * reverse triggers are in the signature map by then. *

- * Currently only the congruence source is enumerated (every function-application argument position). The - * reverse-trigger source (e-matching reverse triggers and the deferred datatype numeric-field feed) is - * future work; it is inactive while offset equalities are enabled, because offsets are disabled in the presence of - * quantifiers. + * Two sources are enumerated: the congruence source (every function-application argument position) and the + * reverse-trigger source — an installed reverse trigger (e.g. from e-matching) watches a (symbol, + * position) for applications whose argument has the watched value, so that value is a slot member, too: an MBTC + * merge that moves an argument onto it activates the trigger. The deferred datatype numeric-field feed is still + * future work. * * @return the slots as lists of members; each list holds the numeric, LA-valued members at one (symbol, position). */ @@ -406,42 +408,69 @@ public Collection> getNumericClashSlots() { final CCAppTerm app = (CCAppTerm) term; final FunctionSymbol sym = app.getFunctionSymbol(); for (int pos = 0; pos < app.getArgCount(); pos++) { - final CCParameter arg = app.getArgParam(pos); - if (!arg.getCCTerm().getFlatTerm().getSort().isNumericSort()) { - continue; - } - if (arg.getRepresentative().getSharedTerm() == null) { - continue; - } - List members = slots.get(new ClashKey(sym, pos)); - if (members == null) { - members = new ArrayList<>(); - slots.put(new ClashKey(sym, pos), members); + addClashMember(slots, sym, pos, app.getArgParam(pos)); + } + } + for (final SignatureTrigger sigTrigger : mSignatureTriggers.values()) { + if (!(sigTrigger instanceof ReverseTriggerTrigger)) { + continue; + } + for (final ReverseTrigger trigger : ((ReverseTriggerTrigger) sigTrigger).getTriggers()) { + final CCParameter arg = trigger.getArgument(); + if (arg != null && trigger.getArgPosition() >= 0) { + addClashMember(slots, trigger.getFunctionSymbol(), trigger.getArgPosition(), arg); } - members.add(arg); } } return slots.values(); } + /** + * Add one member to its clash slot, filtering to numeric, LA-valued members (see {@link #getNumericClashSlots}). + */ + private static void addClashMember(final Map> slots, final FunctionSymbol sym, + final int pos, final CCParameter arg) { + if (!arg.getCCTerm().getFlatTerm().getSort().isNumericSort()) { + return; + } + if (arg.getRepresentative().getSharedTerm() == null) { + return; + } + List members = slots.get(new ClashKey(sym, pos)); + if (members == null) { + members = new ArrayList<>(); + slots.put(new ClashKey(sym, pos), members); + } + members.add(arg); + } + /** * Insert a Compare trigger that will be activated as soon as the two given - * CCTerms are equal. It is inserted into the pair hash tables and all - * intermediate pair infos. + * values are equal. It is inserted into the pair hash tables and all + * intermediate pair infos, keyed on the offset between the two base terms + * (mirroring {@link #insertEqualityEntry}). The two values may already be in + * the same congruence class at a different offset; the trigger is then parked + * in the merge-boundary pair info, which becomes live again when the merge is + * undone. * - * @param t1 the first CCTerm. - * @param t2 the second CCTerm. + * @param lhs the first value. + * @param rhs the second value. * @param trigger the Compare trigger. */ - public void insertCompareTrigger(CCTerm t1, CCTerm t2, final CompareTrigger trigger) { - assert t1.getRepresentative() != t2.getRepresentative(); + public void insertCompareTrigger(final CCParameter lhs, final CCParameter rhs, final CompareTrigger trigger) { + assert !lhs.sameValueAs(rhs); assert !trigger.inList(); + CCTerm t1 = lhs.getCCTerm(); + CCTerm t2 = rhs.getCCTerm(); + // The trigger fires when value(lhs) == value(rhs), i.e. value(t1) - value(t2) == offset. + Rational offset = rhs.getOffset().sub(lhs.getOffset()); while (true) { // make t1 the term that was merged before t2 was merged. if (t1.mMergeTime > t2.mMergeTime) { final CCTerm tmp = t1; t1 = t2; t2 = tmp; + offset = offset.negate(); } // if t1 is its own representative, then t2 should also be the representative @@ -449,9 +478,9 @@ public void insertCompareTrigger(CCTerm t1, CCTerm t2, final CompareTrigger trig if (t1.mRep == t1) { assert t2.mRep == t2; // Insert this entry into the pair hash, create it if necessary. - CCTermPairHash.Info info = mPairHash.getInfo(t1, t2); + CCTermPairHash.Info info = mPairHash.getInfo(t1, t2, offset); if (info == null) { - info = new CCTermPairHash.Info(t1, t2); + info = new CCTermPairHash.Info(t1, t2, offset); mPairHash.add(info); } info.mCompareTriggers.prependIntoJoined(trigger, true); @@ -459,24 +488,31 @@ public void insertCompareTrigger(CCTerm t1, CCTerm t2, final CompareTrigger trig } // find the pair info entry in the pair info list of t1 or create a new one. - assert t1.mRep != t2; + // isLast is set if t1 was merged with t2; in this case the compare trigger + // lists were not joined. + final boolean isLast = t1.mRep == t2; boolean found = false; for (final CCTermPairHash.Info.Entry pentry : t1.mPairInfos) { final CCTermPairHash.Info info = pentry.getInfo(); // info might have blocked compare triggers but no eqlits // assert (!info.eqlits.isEmpty()); - if (pentry.mOther == t2) { - info.mCompareTriggers.prependIntoJoined(trigger, false); + if (pentry.mOther == t2 && pentry.getOffsetToOther().equals(offset)) { + info.mCompareTriggers.prependIntoJoined(trigger, isLast); found = true; break; } } if (!found) { // we need to create a new entry. - final CCTermPairHash.Info info = new CCTermPairHash.Info(t1, t2); + final CCTermPairHash.Info info = new CCTermPairHash.Info(t1, t2, offset); info.mRhsEntry.unlink(); - info.mCompareTriggers.prependIntoJoined(trigger, false); + info.mCompareTriggers.prependIntoJoined(trigger, isLast); } + if (isLast) { + break; + } + // walk t1 up to its representative; value(t1) - value(t1.mRep) = t1.mOffsetToRep - t1.mRep.mOffsetToRep + offset = offset.sub(t1.getOffsetToRep()).add(t1.mRep.getOffsetToRep()); t1 = t1.mRep; } } @@ -485,8 +521,8 @@ public void insertCompareTrigger(CCTerm t1, CCTerm t2, final CompareTrigger trig * Remove a given Compare trigger. */ public void removeCompareTrigger(final CompareTrigger trigger) { - CCTerm t1 = trigger.getLhs(); - CCTerm t2 = trigger.getRhs(); + CCTerm t1 = trigger.getLhs().getCCTerm(); + CCTerm t2 = trigger.getRhs().getCCTerm(); if (!mAllTerms.contains(t1) || !mAllTerms.contains(t2)) { return; // FIXME This is a workaround for the problem that pop() first removes terms, // then triggers, as it @@ -494,41 +530,46 @@ public void removeCompareTrigger(final CompareTrigger trigger) { // where the // corresponding terms have already been removed. } + Rational offset = trigger.getRhs().getOffset().sub(trigger.getLhs().getOffset()); while (true) { // make t1 the term that was merged before t2 was merged. if (t1.mMergeTime > t2.mMergeTime) { final CCTerm tmp = t1; t1 = t2; t2 = tmp; + offset = offset.negate(); } // if t1 is its own representative, then t2 should also be the representative // because of merge time if (t1.mRep == t1) { assert t2.mRep == t2; - // Insert this entry into the pair hash, create it if necessary. - final CCTermPairHash.Info info = mPairHash.getInfo(t1, t2); + final CCTermPairHash.Info info = mPairHash.getInfo(t1, t2, offset); assert info != null; info.mCompareTriggers.undoPrependIntoJoined(trigger, true); break; } - // find the pair info entry in the pair info list of t1 or create a new one. - // isLast is set if t1 was merged with t2; in this case the equality entry lists - // were not joined. - assert t1.mRep != t2; + // find the pair info entry in the pair info list of t1. + // isLast is set if t1 was merged with t2; in this case the compare trigger + // lists were not joined. + final boolean isLast = t1.mRep == t2; boolean found = false; for (final CCTermPairHash.Info.Entry pentry : t1.mPairInfos) { final CCTermPairHash.Info info = pentry.getInfo(); // info might have blocked compare triggers but no eqlits // assert (!info.eqlits.isEmpty()); - if (pentry.mOther == t2) { - info.mCompareTriggers.undoPrependIntoJoined(trigger, false); + if (pentry.mOther == t2 && pentry.getOffsetToOther().equals(offset)) { + info.mCompareTriggers.undoPrependIntoJoined(trigger, isLast); found = true; break; } } assert found; + if (isLast) { + break; + } + offset = offset.sub(t1.getOffsetToRep()).add(t1.mRep.getOffsetToRep()); t1 = t1.mRep; } } @@ -565,15 +606,15 @@ public void removeSignatureBackRef(CCTerm arg, final SignatureBackRef backref) { /** * Insert a Reverse trigger that will be activated as soon as a new function - * application of the given function symbol with a given argument at a given - * position exists. + * application of the given function symbol with a given argument value at a + * given position exists. * * @param fSym the function symbol. - * @param arg the argument the new term should contain. + * @param arg the argument value the new term should contain; must equal {@code trigger.getArgument()}. * @param argPos the position of this argument. * @param trigger the Reverse trigger. */ - public void insertReverseTrigger(final FunctionSymbol sym, CCTerm arg, final int argPos, + public void insertReverseTrigger(final FunctionSymbol sym, final CCParameter arg, final int argPos, final ReverseTrigger trigger) { assert trigger.mSignatureTrigger == null; final MasterReverseTrigger masterTrigger = MasterReverseTrigger.of(this, sym, argPos); @@ -661,26 +702,30 @@ public void removeSignatureHash(SignatureTrigger signatureTrigger) { } /** - * Find the representative CCTerm for the given term. This function does not + * Find the representative value for the given term. This function does not * create new terms. If there is no equivalent CCTerm, it returns null. If a * term that is congruent to the given term already exists, it will return the - * representative of this congruent term. + * value of this congruent term, canonicalized as the class representative plus + * the value's offset to it. * * @param term The term which a representative is searched for. - * @return The representative, or null if no congruent term exists in the + * @return The representative value, or null if no congruent term exists in the * CClosure. */ - public CCTerm getCCTermRep(final Term term) { - if (mAnonTerms.containsKey(term)) { - return mAnonTerms.get(term).getRepresentative(); - } - if (term instanceof ApplicationTerm) { - final ApplicationTerm at = (ApplicationTerm) term; + public CCParameter getCCParamRep(final Term term) { + final Rational constant = mClausifier.getTermConstant(term); + final Term offsetFree = constant.equals(Rational.ZERO) ? term : mClausifier.getOffsetFreeTerm(term); + if (mAnonTerms.containsKey(offsetFree)) { + final CCTerm ccTerm = mAnonTerms.get(offsetFree); + return CCParameter.of(ccTerm.getRepresentative(), ccTerm.getOffsetToRep().add(constant)); + } + if (offsetFree instanceof ApplicationTerm) { + final ApplicationTerm at = (ApplicationTerm) offsetFree; final FunctionSymbol funcSym = at.getFunction(); final Term[] params = at.getParameters(); - final CCTerm[] argReps = new CCTerm[params.length]; + final CCParameter[] argReps = new CCParameter[params.length]; for (int i = 0; i < params.length; i++) { - argReps[i] = getCCTermRep(params[i]); + argReps[i] = getCCParamRep(params[i]); if (argReps[i] == null) { return null; } @@ -688,32 +733,39 @@ public CCTerm getCCTermRep(final Term term) { final SignatureTrigger sig = new SignatureTrigger(funcSym, argReps); final CongruenceTrigger congTrigger = (CongruenceTrigger) mSignatureTriggers.get(sig); if (congTrigger != null) { - return congTrigger.getApp().getRepresentative(); + final CCAppTerm app = congTrigger.getApp(); + return CCParameter.of(app.getRepresentative(), app.getOffsetToRep().add(constant)); } } return null; } /** - * For two given CCTerms, check if the equality is set. + * For two given values, check if the equality is set. * - * @return true if the terms are in the same congruence class, false otherwise. + * @return true if the values are currently equal (same congruence class at the same offset), false otherwise. */ - public boolean isEqSet(final CCTerm first, final CCTerm second) { - return first.getRepresentative() == second.getRepresentative(); + public boolean isEqSet(final CCParameter first, final CCParameter second) { + return first.sameValueAs(second); } /** - * For two given CCTerms, check if the disequality is set. + * For two given values, check if the disequality is known to hold, either because a disequality literal is set or + * because the values are in the same congruence class at different offsets (so they provably differ by a non-zero + * constant). * - * @return true if the disequality is set, false otherwise. + * @return true if the disequality holds, false otherwise. */ - public boolean isDiseqSet(final CCTerm first, final CCTerm second) { + public boolean isDiseqSet(final CCParameter first, final CCParameter second) { final CCTerm firstRep = first.getRepresentative(); final CCTerm secondRep = second.getRepresentative(); + final Rational offset = second.getOffsetToRep().sub(first.getOffsetToRep()); + if (firstRep == secondRep) { + // Same class: the value difference is the known constant offset. + return !offset.equals(Rational.ZERO); + } // A diseq disproves value(first) == value(second), i.e. value(firstRep) - value(secondRep) == // second.mOffsetToRep - first.mOffsetToRep. - final Rational offset = second.getOffsetToRep().sub(first.getOffsetToRep()); final CCTermPairHash.Info diseqInfo = mPairHash.getInfo(firstRep, secondRep, offset); return diseqInfo != null && diseqInfo.mDiseq != null; } diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CompareTrigger.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CompareTrigger.java index 4cdd0fcbf..4e15c843a 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CompareTrigger.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/CompareTrigger.java @@ -21,12 +21,15 @@ import de.uni_freiburg.informatik.ultimate.smtinterpol.dpll.SimpleListable; /** + * A trigger that is activated when two values become equal. The two values are {@link CCParameter}s; the trigger fires + * when their congruence classes are merged such that the values coincide (same representative and same offset to it). + * * @author Tanja Schindler */ public abstract class CompareTrigger extends SimpleListable { - public abstract CCTerm getLhs(); + public abstract CCParameter getLhs(); - public abstract CCTerm getRhs(); + public abstract CCParameter getRhs(); public abstract void activate(); } diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ReverseTriggerTrigger.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ReverseTriggerTrigger.java index 95d795e18..f99eea015 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ReverseTriggerTrigger.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ReverseTriggerTrigger.java @@ -45,6 +45,10 @@ public ReverseTriggerTrigger(MasterReverseTrigger masterTrigger, CCAppTerm app, mApplications.append(new AppTermEntry(app)); } + public SimpleList getTriggers() { + return mTriggers; + } + public SimpleList getApplications() { return mApplications; } diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/InstantiationManager.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/InstantiationManager.java index 5f8bc60f5..c945c5bee 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/InstantiationManager.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/InstantiationManager.java @@ -42,6 +42,7 @@ import de.uni_freiburg.informatik.ultimate.smtinterpol.Config; import de.uni_freiburg.informatik.ultimate.smtinterpol.convert.Clausifier; import de.uni_freiburg.informatik.ultimate.smtinterpol.dpll.Literal; +import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CCParameter; import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CCTerm; import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.epr.util.Pair; import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.linar.InfinitesimalNumber; @@ -550,8 +551,8 @@ private Set> computeSubstitutionsForAge(final List[] sortedSubs * @return the age of the CCTerm if the term has a CCTerm, 0 else. */ private int getTermAge(final Term t) { - final CCTerm cc = mClausifier.getCCTerm(t); - return cc != null ? cc.getAge() : 0; + final CCParameter cc = mClausifier.getCCParameter(t); + return cc != null ? cc.getCCTerm().getAge() : 0; } /** @@ -756,9 +757,8 @@ private Dawg computeEMatchingLitDawg(final QuantLiteral private SubstitutionInfo mapToFirstChecked(final SubstitutionInfo first, final SubstitutionInfo second) { if (Config.EXPENSIVE_ASSERTS) { assert first.getEquivalentCCTerms().keySet().equals(second.getEquivalentCCTerms().keySet()); - for (final Entry equi : first.getEquivalentCCTerms().entrySet()) { - assert first.getEquivalentCCTerms().get(equi.getKey()).getRepresentative() == second - .getEquivalentCCTerms().get(equi.getKey()).getRepresentative(); + for (final Entry equi : first.getEquivalentCCTerms().entrySet()) { + assert equi.getValue().sameValueAs(second.getEquivalentCCTerms().get(equi.getKey())); } } return first; @@ -768,10 +768,10 @@ private List getTermSubsFromSubsInfo(final QuantLiteral qLit, final Substi final int length = qLit.getClause().getVars().length; final List termSubs = new ArrayList<>(); if (!info.equals(mEMatching.getEmptySubs())) { - final List ccSubs = info.getVarSubs(); + final List ccSubs = info.getVarSubs(); assert ccSubs.size() == length; for (int i = 0; i < length; i++) { - final CCTerm ccTerm = ccSubs.get(i); + final CCParameter ccTerm = ccSubs.get(i); termSubs.add(ccTerm == null ? null : ccTerm.getFlatTerm()); } } @@ -842,15 +842,15 @@ private InstantiationInfo evaluateLitForPartialEMatchingSubsInfo(final QuantLite final List clauseVarSubs = clauseInstInfo.getSubs(); if (clauseInstInfo.getInstValue() != InstanceValue.IRRELEVANT && !clauseVarSubs.isEmpty()) { // Complete the equivalent term map from info by adding the variable substitution from the key - final Map equivalentTerms = new HashMap<>(); + final Map equivalentTerms = new HashMap<>(); equivalentTerms.putAll(litSubsInfo.getEquivalentCCTerms()); for (int i = 0; i < clauseVars.length; i++) { - final CCTerm clauseSubs = mClausifier.getCCTerm(clauseVarSubs.get(i)); + final CCParameter clauseSubs = mClausifier.getCCParameter(clauseVarSubs.get(i)); if (clauseSubs != null) { - final CCTerm litSubs = + final CCParameter litSubs = litSubsInfo.equals(mEMatching.getEmptySubs()) ? null : litSubsInfo.getVarSubs().get(i); if (litSubs != null) { // If the substitutionInfo has a substitution for the variable, keep it. - assert litSubs.getRepresentative().equals(clauseSubs.getRepresentative()); + assert litSubs.sameValueAs(clauseSubs); } else { // Use the subs from the clause. equivalentTerms.put(clauseVars[i], clauseSubs); } @@ -1066,10 +1066,10 @@ private Collection getRelevantSubsFromDawg(final QuantClause /** * Helper method to build a map from Term to Term, given a map from Term to CCTerm. */ - private Map buildSharedMapFromCCMap(final Map ccMap) { + private Map buildSharedMapFromCCMap(final Map ccMap) { final Map sharedMap = new HashMap<>(); - for (final Entry entry : ccMap.entrySet()) { - final CCTerm ccTerm = entry.getValue(); + for (final Entry entry : ccMap.entrySet()) { + final CCParameter ccTerm = entry.getValue(); final Term term = ccTerm.getFlatTerm(); sharedMap.put(entry.getKey(), term); } @@ -1356,15 +1356,16 @@ private InstClause computeClauseInstance(final QuantClause clause, final List equivalentCCTerms) { - final CCTerm leftCC, rightCC; + private InstanceValue evaluateCCEqualityKnownShared(final QuantEquality qEq, + final Map equivalentCCTerms) { + final CCParameter leftCC, rightCC; if (qEq.getLhs().getFreeVars().length == 0) { - leftCC = mClausifier.getCCTerm(qEq.getLhs()); + leftCC = mClausifier.getCCParameter(qEq.getLhs()); } else { leftCC = equivalentCCTerms.get(qEq.getLhs()); } if (qEq.getRhs().getFreeVars().length == 0) { - rightCC = mClausifier.getCCTerm(qEq.getRhs()); + rightCC = mClausifier.getCCParameter(qEq.getRhs()); } else { rightCC = equivalentCCTerms.get(qEq.getRhs()); } @@ -1395,8 +1396,8 @@ private InstanceValue evaluateCCEquality(final QuantEquality qEq, final List= 0; i = pos.nextSetBit(i + 1)) { for (final CCAppTerm appTerm : mQuantTheory.getCClosure().getAllFuncApps(func)) { - interestingTerms.add(appTerm.getArgParam(i).getCCTerm().getFlatTerm()); + interestingTerms.add(appTerm.getArgParam(i).getFlatTerm()); } } } @@ -461,7 +461,8 @@ private void updateInterestingTermsForFuncArgs(final TermVariable var, final int final String funcName = func.getName(); final Term[] args = arrayFuncTerm.getParameters(); final Term array = args[0]; - final CCTerm arrayCC = mQuantTheory.getCClosure().getCCTermRep(array); + // arrays are not numeric, so the value is a bare CCTerm (the cast checks this) + final CCTerm arrayCC = (CCTerm) mQuantTheory.getCClosure().getCCParamRep(array); final CCTerm weakRep = arrayCC == null ? null : mQuantTheory.getClausifier().getArrayTheory().getWeakRep(arrayCC); if (args[1] == var) { // The variable is an array index @@ -508,8 +509,7 @@ private void updateInterestingTermsForFuncArgs(final TermVariable var, final int if (weakRep == null ? selArr.getFlatTerm().getSort() == array.getSort() : weakRep == mQuantTheory.getClausifier().getArrayTheory().getWeakRep(selArr)) { final CCParameter index = ArrayTheory.getIndexFromSelect(sel); - // TODO: add offset - interestingTerms.add(index.getCCTerm().getFlatTerm()); + interestingTerms.add(index.getFlatTerm()); } } } diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/QuantifierTheory.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/QuantifierTheory.java index 158f59263..2d28568ca 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/QuantifierTheory.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/QuantifierTheory.java @@ -47,6 +47,7 @@ import de.uni_freiburg.informatik.ultimate.smtinterpol.dpll.ITheory; import de.uni_freiburg.informatik.ultimate.smtinterpol.dpll.Literal; import de.uni_freiburg.informatik.ultimate.smtinterpol.proof.SourceAnnotation; +import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CCParameter; import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CCTerm; import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CClosure; import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.linar.LinArSolve; @@ -927,16 +928,18 @@ private Clause addInstClausesToPending(final Collection instances) { } /** - * Get the term that is the current CC representative of the given term, if such term exists. + * Get the term that canonically denotes the current value of the given term in CC, if the term has a CC value. With + * offset equalities this is the flat term of the class representative plus the value's offset to it, so two terms + * yield the same representative term exactly if CC knows them to be equal (same class at the same offset). * * @param term * a term. - * @return the the term corresponding to the current CC representative of the given term, if it exists, the input + * @return the term corresponding to the current CC representative value of the given term, if it exists, the input * term else. */ Term getRepresentativeTerm(final Term term) { - final CCTerm ccTerm = getClausifier().getCCTerm(term); - return ccTerm == null ? term : ccTerm.getRepresentative().getFlatTerm(); + final CCParameter ccParam = getClausifier().getCCParameter(term); + return ccParam == null ? term : ccParam.getValueKey().getFlatTerm(); } private void addGroundCCTerms(final Term term, final SourceAnnotation source) { @@ -947,9 +950,11 @@ private void addGroundCCTerms(final Term term, final SourceAnnotation source) { final Term subTerm = todo.pop(); if (subTerm instanceof ApplicationTerm && seen.add(subTerm)) { if (subTerm.getFreeVars().length == 0) { - final CCTerm ccTerm = mClausifier.getCCTerm(subTerm); - if (ccTerm == null && (Clausifier.needCCTerm(subTerm) || subTerm.getSort().isArraySort())) { - mClausifier.createCCTerm(subTerm, source); + // a ground term with a constant summand is represented by the CCTerm of its offset-free part + final Term offsetFree = mClausifier.getOffsetFreeTerm(subTerm); + final CCTerm ccTerm = mClausifier.getCCTerm(offsetFree); + if (ccTerm == null && (Clausifier.needCCTerm(offsetFree) || offsetFree.getSort().isArraySort())) { + mClausifier.createCCTerm(offsetFree, source); } } else { for (final Term arg : ((ApplicationTerm) subTerm).getParameters()) { diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/CompareCode.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/CompareCode.java index d136614f9..3dc9c8945 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/CompareCode.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/CompareCode.java @@ -18,12 +18,12 @@ */ package de.uni_freiburg.informatik.ultimate.smtinterpol.theory.quant.ematching; -import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CCTerm; +import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CCParameter; /** - * Code to compare two CCTerms. If the terms are equal, the remaining code can be executed. If they are not yet equal, + * Code to compare two values. If the values are equal, the remaining code can be executed. If they are not yet equal, * executing the Compare code will install a trigger in the CClosure. - * + * * @author Tanja Schindler */ public class CompareCode implements ICode { @@ -41,17 +41,18 @@ public CompareCode(final EMatching eMatching, final int firstRegIndex, final int } @Override - public void execute(final CCTerm[] register, final int decisionLevel) { - final CCTerm firstTerm = register[mFirstRegIndex]; - final CCTerm secondTerm = register[mSecondRegIndex]; - if (mEMatching.getQuantTheory().getCClosure().isEqSet(firstTerm, secondTerm)) { - final int eqDecisionLevel = - mEMatching.getQuantTheory().getCClosure().getDecideLevelForPath(firstTerm, secondTerm); + public void execute(final CCParameter[] register, final int decisionLevel) { + final CCParameter firstTerm = register[mFirstRegIndex]; + final CCParameter secondTerm = register[mSecondRegIndex]; + if (firstTerm.sameValueAs(secondTerm)) { + final int eqDecisionLevel = mEMatching.getQuantTheory().getCClosure() + .getDecideLevelForPath(firstTerm.getCCTerm(), secondTerm.getCCTerm()); mEMatching.addCode(mRemainingCode, register, eqDecisionLevel > decisionLevel ? eqDecisionLevel : decisionLevel); - } else { + } else if (firstTerm.getCCTerm() != secondTerm.getCCTerm()) { mEMatching.installCompareTrigger(firstTerm, secondTerm, mRemainingCode, register, decisionLevel); } + // else: the same base term at two different offsets; the values can never become equal. } @Override diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/EMCompareTrigger.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/EMCompareTrigger.java index e34393895..4f7238dbd 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/EMCompareTrigger.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/EMCompareTrigger.java @@ -18,26 +18,25 @@ */ package de.uni_freiburg.informatik.ultimate.smtinterpol.theory.quant.ematching; -import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CCTerm; +import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CCParameter; /** - * A trigger for comparing two CCTerms. It has to be installed into the CClosure. Upon activation, the remaining + * A trigger for comparing two values. It has to be installed into the CClosure. Upon activation, the remaining * E-Matching code can be executed with the given register. - * + * * @author Tanja Schindler */ public class EMCompareTrigger extends de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CompareTrigger { private final EMatching mEMatching; private final ICode mRemainingCode; - private final CCTerm mLhs; - private final CCTerm mRhs; - private final CCTerm[] mRegister; + private final CCParameter mLhs; + private final CCParameter mRhs; + private final CCParameter[] mRegister; private final int mDecisionLevel; - public EMCompareTrigger(final EMatching eMatching, final CCTerm lhs, final CCTerm rhs, final ICode remainingCode, - final CCTerm[] register, - final int decisionLevel) { + public EMCompareTrigger(final EMatching eMatching, final CCParameter lhs, final CCParameter rhs, + final ICode remainingCode, final CCParameter[] register, final int decisionLevel) { mEMatching = eMatching; mLhs = lhs; mRhs = rhs; @@ -46,11 +45,13 @@ public EMCompareTrigger(final EMatching eMatching, final CCTerm lhs, final CCTer mDecisionLevel = decisionLevel; } - public CCTerm getLhs() { + @Override + public CCParameter getLhs() { return mLhs; } - public CCTerm getRhs() { + @Override + public CCParameter getRhs() { return mRhs; } diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/EMReverseTrigger.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/EMReverseTrigger.java index 6d70b1995..a02bdd502 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/EMReverseTrigger.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/EMReverseTrigger.java @@ -23,7 +23,6 @@ import de.uni_freiburg.informatik.ultimate.logic.FunctionSymbol; import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CCAppTerm; import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CCParameter; -import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CCTerm; /** * A trigger to find new function applications. It has to be installed into the CClosure. Upon activation, the remaining @@ -37,13 +36,13 @@ public class EMReverseTrigger extends de.uni_freiburg.informatik.ultimate.smtint private final ICode mRemainingCode; private final FunctionSymbol mFunc; private final int mArgPos; - private final CCTerm mArg; - private final CCTerm[] mRegister; + private final CCParameter mArg; + private final CCParameter[] mRegister; private final int mOutRegIndex; private final int mDecisionLevel; public EMReverseTrigger(final EMatching eMatching, final ICode remainingCode, final FunctionSymbol func, - final int argPos, final CCTerm arg, final CCTerm[] register, final int outRegIndex, + final int argPos, final CCParameter arg, final CCParameter[] register, final int outRegIndex, final int decisionLevel) { mEMatching = eMatching; mRemainingCode = remainingCode; @@ -72,14 +71,14 @@ public FunctionSymbol getFunctionSymbol() { @Override public void activate(final CCAppTerm appTerm, final boolean isFresh) { - final CCTerm[] updatedRegister = Arrays.copyOf(mRegister, mRegister.length); + final CCParameter[] updatedRegister = Arrays.copyOf(mRegister, mRegister.length); updatedRegister[mOutRegIndex] = appTerm; if (mArg != null) { // Reverse - // e-matching arguments are offset-free; use the structural CCTerm for the path comparison - final CCTerm candArg = appTerm.getArgParam(mArgPos).getCCTerm(); + // the values match by signature; the decide level comes from the path between the base terms + final CCParameter candArg = appTerm.getArgParam(mArgPos); final int termDecisionLevel = mEMatching.getQuantTheory().getCClosure() - .getDecideLevelForPath(mArg, candArg); + .getDecideLevelForPath(mArg.getCCTerm(), candArg.getCCTerm()); mEMatching.addCode(mRemainingCode, updatedRegister, termDecisionLevel > mDecisionLevel ? termDecisionLevel : mDecisionLevel); diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/EMatching.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/EMatching.java index 925ad5794..d21f8b0c5 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/EMatching.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/EMatching.java @@ -36,7 +36,7 @@ import de.uni_freiburg.informatik.ultimate.logic.Term; import de.uni_freiburg.informatik.ultimate.logic.TermVariable; import de.uni_freiburg.informatik.ultimate.smtinterpol.Config; -import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CCTerm; +import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CCParameter; import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.epr.util.Pair; import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.epr.util.Triple; import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.quant.QuantBoundConstraint; @@ -58,7 +58,7 @@ public class EMatching { private final QuantifierTheory mQuantTheory; - private Deque> mTodoStack; + private Deque> mTodoStack; private final Map mUndoInformation; /** @@ -66,7 +66,7 @@ public class EMatching { * corresponding SubstitutionInfo */ private final Map> mAtomSubsDawgs; - private final Map>> mClauseCodes; + private final Map>> mClauseCodes; private final Set mEmatchingAtoms, mPartialEmatchingAtoms; final SubstitutionInfo mEmptySubs; @@ -76,7 +76,7 @@ public EMatching(final QuantifierTheory quantifierTheory) { mAtomSubsDawgs = new HashMap<>(); mClauseCodes = new HashMap<>(); mUndoInformation = new LinkedHashMap<>(); - mEmptySubs = new SubstitutionInfo(new ArrayList(), new LinkedHashMap<>()); + mEmptySubs = new SubstitutionInfo(new ArrayList(), new LinkedHashMap<>()); mEmatchingAtoms = new HashSet<>(); mPartialEmatchingAtoms = new HashSet<>(); } @@ -90,7 +90,7 @@ public EMatching(final QuantifierTheory quantifierTheory) { */ public void addClause(final QuantClause qClause) { assert !mClauseCodes.containsKey(qClause); - final ArrayList> clauseCodes = new ArrayList<>(); + final ArrayList> clauseCodes = new ArrayList<>(); for (final QuantLiteral qLit : qClause.getQuantLits()) { final QuantLiteral qAtom = qLit.getAtom(); if (!qLit.isArithmetical() && QuantUtil.containsArithmeticOnQuantOnlyAtTopLevel(qAtom)) { @@ -125,7 +125,7 @@ public void addClause(final QuantClause qClause) { } if (!patterns.isEmpty()) { - final Pair newCode = + final Pair newCode = new PatternCompiler(mQuantTheory, qAtom, patterns.toArray(new Term[patterns.size()])) .compile(); addCode(newCode.getFirst(), newCode.getSecond(), 0); @@ -169,7 +169,7 @@ public void reAddClauses(final Iterable clauses) { assert mTodoStack.isEmpty() && mUndoInformation.isEmpty(); for (final QuantClause qClause : clauses) { assert mClauseCodes.containsKey(qClause); - for (final Triple code : mClauseCodes.get(qClause)) { + for (final Triple code : mClauseCodes.get(qClause)) { mTodoStack.add(code); } } @@ -197,7 +197,7 @@ public void run() { time = System.nanoTime(); } while (!mTodoStack.isEmpty() && !mQuantTheory.getEngine().isTerminationRequested()) { - final Triple code = mTodoStack.pop(); + final Triple code = mTodoStack.pop(); mQuantTheory.getLogger().debug("EM-Code: %s", code); assert code.getThird() <= mQuantTheory.getEngine().getDecideLevel(); code.getFirst().execute(code.getSecond(), code.getThird()); @@ -232,8 +232,8 @@ public void undo(final int decisionLevel) { } } mQuantTheory.getLogger().debug("Remaining levels: %s", mUndoInformation.keySet()); - final Deque> undoneTodoStack = new ArrayDeque<>(); - for (final Triple todo : mTodoStack) { + final Deque> undoneTodoStack = new ArrayDeque<>(); + for (final Triple todo : mTodoStack) { if (todo.getThird() <= decisionLevel) { undoneTodoStack.add(todo); } @@ -270,31 +270,31 @@ public QuantifierTheory getQuantTheory() { * @param code * the remaining code. * @param register - * the candidate CCTerms for this execution. + * the candidate values for this execution. * @param decisionLevel * the decision level that is relevant for this execution. */ - void addCode(final ICode code, final CCTerm[] register, final int decisionLevel) { - final Triple todo = + void addCode(final ICode code, final CCParameter[] register, final int decisionLevel) { + final Triple todo = new Triple<>(code, register, decisionLevel); assert decisionLevel <= mQuantTheory.getEngine().getDecideLevel(); mTodoStack.add(todo); } /** - * Add a new interesting substitution for a quantified literal, together with the corresponding CCTerms. + * Add a new interesting substitution for a quantified literal, together with the corresponding values. * * @param qLit * the quantified Literal * @param varSubs * the variable substitution ordered as the variables in the clause. * @param equivalentCCTerms - * the corresponding CCTerms for the EUTerms in the literal. + * the corresponding values for the EUTerms in the literal. * @param decisionLevel * the decision level relevant for this substitution. */ - void addInterestingSubstitution(final QuantLiteral qLit, final List varSubs, - final Map equivalentCCTerms, final int decisionLevel) { + void addInterestingSubstitution(final QuantLiteral qLit, final List varSubs, + final Map equivalentCCTerms, final int decisionLevel) { final long time = System.nanoTime(); assert mAtomSubsDawgs.containsKey(qLit); Dawg subsDawg = mAtomSubsDawgs.get(qLit); @@ -312,12 +312,12 @@ void addInterestingSubstitution(final QuantLiteral qLit, final List varS } /** - * Install a trigger into the CClosure that compares two CCTerms. + * Install a trigger into the CClosure that compares two values. * * @param lhs - * the first CCTerm. + * the first value. * @param rhs - * the other CCTerm it should be compared with. + * the other value it should be compared with. * @param remainingCode * the remaining E-Matching code. * @param register @@ -325,8 +325,8 @@ void addInterestingSubstitution(final QuantLiteral qLit, final List varS * @param decisionLevel * the decision level relevant for the compare trigger. */ - void installCompareTrigger(final CCTerm lhs, final CCTerm rhs, final ICode remainingCode, - final CCTerm[] register, final int decisionLevel) { + void installCompareTrigger(final CCParameter lhs, final CCParameter rhs, final ICode remainingCode, + final CCParameter[] register, final int decisionLevel) { assert decisionLevel <= mQuantTheory.getClausifier().getEngine().getDecideLevel(); final EMCompareTrigger trigger = new EMCompareTrigger(this, lhs, rhs, remainingCode, register, decisionLevel); mQuantTheory.getCClosure().insertCompareTrigger(lhs, rhs, trigger); @@ -348,7 +348,7 @@ void installCompareTrigger(final CCTerm lhs, final CCTerm rhs, final ICode remai * the decision level relevant for the find trigger. */ void installFindTrigger(final FunctionSymbol func, final int regIndex, final ICode remainingCode, - final CCTerm[] register, final int decisionLevel) { + final CCParameter[] register, final int decisionLevel) { mQuantTheory.getLogger().debug("Install Find Trigger: FIND %s (decide@%d)", func, decisionLevel); final EMReverseTrigger trigger = new EMReverseTrigger(this, remainingCode, func, -1, null, register, regIndex, decisionLevel); @@ -374,8 +374,8 @@ void installFindTrigger(final FunctionSymbol func, final int regIndex, final ICo * @param decisionLevel * the decision level relevant for the reverse trigger. */ - void installReverseTrigger(final FunctionSymbol func, final CCTerm arg, final int argPos, - final int regIndex, final ICode remainingCode, final CCTerm[] register, final int decisionLevel) { + void installReverseTrigger(final FunctionSymbol func, final CCParameter arg, final int argPos, + final int regIndex, final ICode remainingCode, final CCParameter[] register, final int decisionLevel) { mQuantTheory.getLogger().debug("Install Reverse Trigger: REV %s,%d on %s (decide@%d)", func, argPos, arg, decisionLevel); assert decisionLevel <= mQuantTheory.getClausifier().getEngine().getDecideLevel(); @@ -467,26 +467,26 @@ public boolean isPartiallyUsingEmatching(final QuantLiteral qLit) { /** * This class stores information about a substitution found by the E-Matching. That is, the variable substitutions, - * as well as for each pattern the CCTerm that is equivalent to the ground term that would result from applying the + * as well as for each pattern the value that is equivalent to the ground term that would result from applying the * substitution to the pattern. * * @author Tanja Schindler * */ public class SubstitutionInfo { - final List mVarSubs; - final Map mEquivalentCCTerms; + final List mVarSubs; + final Map mEquivalentCCTerms; - SubstitutionInfo(final List varSubs, final Map equivalentCCTerms) { + SubstitutionInfo(final List varSubs, final Map equivalentCCTerms) { mVarSubs = varSubs; mEquivalentCCTerms = equivalentCCTerms; } - public List getVarSubs() { + public List getVarSubs() { return mVarSubs; } - public Map getEquivalentCCTerms() { + public Map getEquivalentCCTerms() { return mEquivalentCCTerms; } diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/FindCode.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/FindCode.java index bdf97a8bc..80cc65d3f 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/FindCode.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/FindCode.java @@ -23,7 +23,7 @@ import de.uni_freiburg.informatik.ultimate.logic.FunctionSymbol; import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CCAppTerm; -import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CCTerm; +import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CCParameter; import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CClosure; /** @@ -51,13 +51,13 @@ public FindCode(final EMatching eMatching, final CClosure cclosure, final Functi } @Override - public void execute(final CCTerm[] register, final int decisionLevel) { + public void execute(final CCParameter[] register, final int decisionLevel) { if (mFunc.getParameterSorts().length > 0) { mEMatching.installFindTrigger(mFunc, mOutRegIndex, mRemainingCode, register, decisionLevel); } final List funcApps = mCClosure.getAllFuncApps(mFunc); for (final CCAppTerm cand : funcApps) { - final CCTerm[] updatedReg = Arrays.copyOf(register, register.length); + final CCParameter[] updatedReg = Arrays.copyOf(register, register.length); updatedReg[mOutRegIndex] = cand; mEMatching.addCode(mRemainingCode, updatedReg, decisionLevel > 0 ? decisionLevel : 0); } diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/GetArgCode.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/GetArgCode.java index bee5757de..b33870af6 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/GetArgCode.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/GetArgCode.java @@ -22,7 +22,7 @@ import de.uni_freiburg.informatik.ultimate.logic.FunctionSymbol; import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CCAppTerm; -import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CCTerm; +import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CCParameter; /** * Code to get the specified argument of the candidate for a specified term. @@ -48,13 +48,12 @@ public GetArgCode(final EMatching eMatching, final int appTermRegIndex, final Fu } @Override - public void execute(final CCTerm[] register, final int decisionLevel) { - final CCTerm appTerm = register[mAppTermRegIndex]; - assert appTerm instanceof CCAppTerm; - CCAppTerm partialApp = (CCAppTerm) appTerm; - // e-matching arguments are offset-free; use the structural CCTerm for the register - CCTerm arg = partialApp.getArgParam(mArgPos).getCCTerm(); - final CCTerm[] updatedRegister = Arrays.copyOf(register, register.length); + public void execute(final CCParameter[] register, final int decisionLevel) { + // a candidate for a function application is always a bare CCAppTerm (offset 0) + assert register[mAppTermRegIndex] instanceof CCAppTerm; + final CCAppTerm partialApp = (CCAppTerm) register[mAppTermRegIndex]; + final CCParameter arg = partialApp.getArgParam(mArgPos); + final CCParameter[] updatedRegister = Arrays.copyOf(register, register.length); updatedRegister[mOutRegIndex] = arg; mEMatching.addCode(mRemainingCode, updatedRegister, decisionLevel); } diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/ICode.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/ICode.java index 0ce7f2f5d..2c8a79ade 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/ICode.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/ICode.java @@ -18,23 +18,23 @@ */ package de.uni_freiburg.informatik.ultimate.smtinterpol.theory.quant.ematching; -import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CCTerm; +import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CCParameter; /** * This represents a piece of code for E-matching which can be executed for a register containing candidate terms and a * decision level depending on those candidate terms. - * + * * @author Tanja Schindler */ public interface ICode { /** * Execute this piece of code with the given register. - * + * * @param register - * the relevant CCTerms for this execution. + * the relevant candidate values for this execution. * @param decisionLevel * the relevant decisionLevel for this execution. */ - public void execute(final CCTerm[] register, final int decisionLevel); + public void execute(final CCParameter[] register, final int decisionLevel); } diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/PatternCompiler.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/PatternCompiler.java index 781ef4391..9b1c372ce 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/PatternCompiler.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/PatternCompiler.java @@ -29,7 +29,7 @@ import de.uni_freiburg.informatik.ultimate.logic.Term; import de.uni_freiburg.informatik.ultimate.logic.TermVariable; import de.uni_freiburg.informatik.ultimate.smtinterpol.convert.Clausifier; -import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CCTerm; +import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CCParameter; import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.epr.util.Pair; import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.quant.QuantLiteral; import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.quant.QuantifierTheory; @@ -64,10 +64,10 @@ public PatternCompiler(final QuantifierTheory quantTheory, final QuantLiteral qA * * @return the resulting code and the corresponding register. */ - public Pair compile() { + public Pair compile() { collectTermInfos(mPatterns); final ICode code = generateCode(); - final CCTerm[] register = new CCTerm[getNextFreeRegIndex()]; + final CCParameter[] register = new CCParameter[getNextFreeRegIndex()]; for (final TermInfo termInfo : mTermInfos.values()) { if (termInfo.mGroundTerm != null) { register[termInfo.mRegIndex] = termInfo.mGroundTerm; @@ -100,8 +100,7 @@ private void collectTermInfos(final Term term) { } if (term.getFreeVars().length == 0) { final Clausifier clausifier = mEMatching.getQuantTheory().getClausifier(); - // Offsets are disabled under quantifiers, so the built CCParameter is always offset-free (a CCTerm). - info.mGroundTerm = (CCTerm) clausifier.createCCTerm(term, mQuantAtom.getClause().getSource()); + info.mGroundTerm = clausifier.createCCTerm(term, mQuantAtom.getClause().getSource()); } else if (!(term instanceof TermVariable)) { assert term instanceof ApplicationTerm; final Term[] args = ((ApplicationTerm) term).getParameters(); @@ -272,7 +271,7 @@ private boolean hasProcessedOrGroundOrNonVarSubterms(final Term term) { */ class TermInfo { - CCTerm mGroundTerm; + CCParameter mGroundTerm; /** * The number of occurrences of the subterm. */ diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/ReverseCode.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/ReverseCode.java index 6bf42d6c2..83ed4b6fe 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/ReverseCode.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/ReverseCode.java @@ -19,7 +19,7 @@ package de.uni_freiburg.informatik.ultimate.smtinterpol.theory.quant.ematching; import de.uni_freiburg.informatik.ultimate.logic.FunctionSymbol; -import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CCTerm; +import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CCParameter; /** * Code to find a function application for a given function symbol that has the given term as argument at the given @@ -49,8 +49,8 @@ public ReverseCode(final EMatching eMatching, final int argRegIndex, final Funct } @Override - public void execute(final CCTerm[] register, final int decisionLevel) { - final CCTerm arg = register[mArgRegIndex]; + public void execute(final CCParameter[] register, final int decisionLevel) { + final CCParameter arg = register[mArgRegIndex]; mEMatching.installReverseTrigger(mFunc, arg, mArgPos, mOutRegIndex, mRemainingCode, register, decisionLevel); } diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/YieldCode.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/YieldCode.java index 8f6057557..6981a0f45 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/YieldCode.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/quant/ematching/YieldCode.java @@ -26,7 +26,7 @@ import de.uni_freiburg.informatik.ultimate.logic.Term; import de.uni_freiburg.informatik.ultimate.logic.TermVariable; -import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CCTerm; +import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.cclosure.CCParameter; import de.uni_freiburg.informatik.ultimate.smtinterpol.theory.quant.QuantLiteral; /** @@ -66,8 +66,8 @@ public YieldCode(final EMatching eMatching, final QuantLiteral qLit, final TermV } @Override - public void execute(CCTerm[] register, final int decisionLevel) { - final List varSubs = new ArrayList(mVarOrder.length); + public void execute(final CCParameter[] register, final int decisionLevel) { + final List varSubs = new ArrayList<>(mVarOrder.length); for (int i = 0; i < mVarOrder.length; i++) { if (mVarPos.containsKey(mVarOrder[i])) { varSubs.add(register[mVarPos.get(mVarOrder[i])]); @@ -75,7 +75,7 @@ public void execute(CCTerm[] register, final int decisionLevel) { varSubs.add(null); } } - final Map equivalentCCTerms = new HashMap<>(); + final Map equivalentCCTerms = new HashMap<>(); for (final Entry pos : mEquivCCTermPos.entrySet()) { equivalentCCTerms.put(pos.getKey(), register[pos.getValue()]); } From 09582e9052f92d31e95150f6d2eebcd3a5d0fc84 Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Mon, 6 Jul 2026 00:07:52 +0200 Subject: [PATCH 74/75] Enable offset equalities in the presence of quantifiers. Drops the quantifier condition from Clausifier.createOffsetEqualities; with the proof gate already open this enables offsets for all problems, including quantifiers with proofs and interpolation. It also makes the Clausifier and CClosure offset predicates coincide, eliminating the raw-vs-effective flag divergence. New regression tests pin the e-matching value semantics: the offset binding x := a+3, the dedup distinctness of a and a+1 within one congruence class, and a guard against the spurious unsat that dropping the offset used to cause. Validated by the full test suite (726/726) with lowlevel proof checking, model checking and interpolant checking enabled in the system tests. The remaining quantified/*match* failures are a pre-existing limitation (quantified datatype match terms), unrelated to offsets. Co-Authored-By: Claude Fable 5 --- SMTInterpol/doc/offset-equality-plan.md | 211 ++++++++++++++++++ .../smtinterpol/convert/Clausifier.java | 6 +- .../test/quantified/offsetmatch001.smt2 | 11 + .../test/quantified/offsetmatch002.smt2 | 14 ++ .../test/quantified/offsetmatch003.smt2 | 13 ++ 5 files changed, 250 insertions(+), 5 deletions(-) create mode 100644 SMTInterpolTest/test/quantified/offsetmatch001.smt2 create mode 100644 SMTInterpolTest/test/quantified/offsetmatch002.smt2 create mode 100644 SMTInterpolTest/test/quantified/offsetmatch003.smt2 diff --git a/SMTInterpol/doc/offset-equality-plan.md b/SMTInterpol/doc/offset-equality-plan.md index 6b1260c5d..6c280f5cc 100644 --- a/SMTInterpol/doc/offset-equality-plan.md +++ b/SMTInterpol/doc/offset-equality-plan.md @@ -1254,3 +1254,214 @@ baseline (only the known pre-existing SystemTest failures). Files: Datatype numeric-field feed, array contributions, proof object (and thus offsets under proofs/interpolation) remain deferred. + +--- + +# Quantifier support (offset-aware e-matching) — planned + +Offsets are currently disabled whenever a `QuantifierTheory` exists +(`Clausifier.createOffsetEqualities()` requires `mQuantTheory == null`), because +e-matching binds variables to offset-free CCTerms and loses the constant (the +recorded unsoundness in `quanttest001`: match `a(x)` against `a(l+1)` but +instantiate `x := l`). This section maps every CC touch point in `theory/quant` +and plans the migration. The EPR theory is ignored (slated for deletion). + +## Inventory: where the quantifier theory touches CC values + +**A. The e-matching register is `CCTerm[]`** — the core representation gap. +`ICode.execute(CCTerm[], int)`, `EMatching.mTodoStack`/`mClauseCodes`/`addCode`, +`PatternCompiler.compile()` (builds the initial register; note +`TermInfo.mGroundTerm` is `(CCTerm) clausifier.createCCTerm(...)` — that cast +throws for a ground pattern subterm like `(+ a 2)` once offsets are on), +`EMReverseTrigger.mRegister`, `EMCompareTrigger.mRegister`. A register slot +holds a *value* (candidate for a pattern subterm or a variable binding), and +values are `CCParameter`s now. Whole-pattern candidates are always `CCAppTerm`s +(offset 0); only argument values / variable bindings carry offsets. + +**B. `GetArgCode` strips the offset** (`getArgParam(pos).getCCTerm()`, with an +explicit "offsets are off under quantifiers" comment). This is *the* unsound +line: binding a variable from `f(a+2)`'s argument must yield the value `a+2`. + +**C. Compare triggers — SETTLED, almost trivial (Jochen).** The trigger is keyed +on the base-term pair plus the *structural* offset δ = o2 − o1 (from +value(t1)+o1 == value(t2)+o2, i.e. value(base1) = value(base2) + δ; stable, so +`EMCompareTrigger` can store the two `CCParameter`s and recompute δ at remove +time). Verified against the code, nearly everything is already in place: +- **Merge-time activation is already offset-selective.** + `CCTerm.mergeInternal` fires `info.mCompareTriggers` only in the + `offFromSrc.equals(delta)` branch; the conflicting-offset branch leaves them + untouched — they ride along dormant in the removed Info and come back on + unmerge. Since there is no "definitely distinct" event in the machinery, + nothing needs to happen on a conflicting-offset merge, and nothing does. +- **`insertCompareTrigger`/`removeCompareTrigger` copy the + `insertEqualityEntry` walk** (CClosure.java:730): negate δ on the merge-time + swap, re-base when stepping `t1 = t1.mRep` (`offset − t1.offsetToRep + + t1.mRep.offsetToRep`), and match `pentry.getOffsetToOther()` when scanning + `mPairInfos`. Just a few CCTerm→CCParameter/offset changes. +- **Same rep, wrong offset at install time:** the trigger can never fire in the + current subtree, so per Jochen it need not be installed (drop the + continuation). Note the `insertEqualityEntry` walk would park it for free — + it has no different-reps assert; the walk terminates via `isLast = t1.mRep == + t2` and leaves the entry in the merge-boundary Info, which goes live again on + unmerge — which matters only when the conflicting merge is *younger* than the + register data (data at level 0, merge at level 3: backtrack to 1 keeps the + trigger, a later matching merge fires it; dropping misses that instance). + Either way it is the same walk; pick during implementation. +`CompareCode` itself becomes: `sameValueAs` → proceed; otherwise install (or +drop in the same-rep case, per the previous point). + +**D. Reverse triggers.** `ReverseCode` reads the arg from the register (now a +`CCParameter`) and `CClosure.insertReverseTrigger(sym, CCTerm arg, pos, trig)` +takes a bare CCTerm. The underlying machinery is already value-keyed +(`ReverseTrigger.getArgument()` returns `CCParameter`; +`ReverseTriggerTrigger`/`MasterReverseTrigger`/`SignatureTrigger` key on +`getOffsetToRep`), so this is mostly signature plumbing: make +`EMReverseTrigger.mArg` a `CCParameter` and let `insertReverseTrigger` accept +it. `EMReverseTrigger.activate` compares `mArg` with +`appTerm.getArgParam(pos)`: the *decide level* path +(`getDecideLevelForPath`) stays on the offset-free `getCCTerm()`s (the merge +path is the same for all offsets); the value match is guaranteed by the +signature. + +**E. Yield / SubstitutionInfo.** `SubstitutionInfo.mVarSubs` is +`List`, `mEquivalentCCTerms` is `Map`; both become +`CCParameter`. Dawg keys come from `varSubs.get(i).getFlatTerm()` — +`CCParameter.getFlatTerm()` already renders the flattened `(+ a 2)`, so the +Term-keyed dawg layer is unaffected once the values are right. + +**F. Canonicalization drops offsets.** +`QuantifierTheory.getRepresentativeTerm(term)` does +`getCCTerm(term).getRepresentative().getFlatTerm()`. Two problems: (i) +`Clausifier.getCCTerm` now *asserts* its argument is offset-free, and +interesting terms / substitutions are value terms like `(+ a 2)`; (ii) the rep +flat term drops the offset — the values `a` and `a+1` in one class would +canonicalize to the same key, conflating *different* substitutions in +`addAllInteresting` and `getRepresentativeSubsDawg` (the dedup dual of the +`quanttest001` unsoundness). Fix: split via +`getOffsetFreeTerm`/`getTermConstant`, then return +`CCParameter.addConstant(rep.getFlatTerm(), k + offsetToRep)` — the canonical +*value* term. + +**G. Ground-side CC lookups on possibly-offsetted terms** (all hit the +`getCCTerm` assert or silently miss): `InstantiationManager.getTermAge`, +`evaluateCCEquality`/`evaluateCCEqualityKnownShared` (ground lhs/rhs of a +`QuantEquality` can carry a constant, e.g. `x = t+3`), +`evaluateLitForPartialEMatchingSubsInfo` (`getCCTerm(clauseVarSubs.get(i))`), +`QuantifierTheory.addGroundCCTerms` (recurses into ground subterms like +`(+ a 1)`). Each needs the offset-free-part + constant split. The equality +evaluation itself needs value semantics: `isEqSet` → `sameValueAs` on +`CCParameter`s; `isDiseqSet` is already offset-aware internally but needs a +`CCParameter` entry point. Same-rep-different-offset evaluates to **FALSE** +(new case — today same rep means TRUE). + +**H. `CClosure.getCCTermRep(Term)`** (used by +`InstantiationManager.TermFinder` to find an existing congruent term for an +instance term) recurses with offset-free `CCTerm[]` argReps and an offset-free +`SignatureTrigger`. It must become `CCParameter`-based: parse each argument's +constant, build the signature on value keys, and return a `CCParameter` +(rep + offset) so `FindSharedAppTerm`/`FindSharedAffine` record the true value +term, not the rep's offset-free flat term. + +**I. `QuantClause`.** `updateInterestingTermsForFuncArgs` drops offsets: +`appTerm.getArgParam(i).getCCTerm().getFlatTerm()` → `getArgParam(i) +.getFlatTerm()`; same for the select-index branch (existing `TODO: add offset` +at the `ArrayTheory.getIndexFromSelect` site). Dedup via +`getRepresentativeTerm` is fixed by F. The `collectVarInfos:338` crash +(`datatype/quantified/*match*`, 4 tests) turned out to be **pre-existing with +offsets off and unrelated to this work**: the quantified equality has a +`MatchTerm` side (`(match l1 ...)` with free variables) and the assert +`rhs instanceof ApplicationTerm` rejects it — a missing quantified-`match` +feature in the quantifier theory, out of scope here (earlier notes +miscategorized it as an offset blocker because it was observed during +gate-flip validation). + +**J. Unaffected (checked):** `SubstitutionHelper`, `DestructiveEqualityReasoning`, +`QuantAuxEquality` (Term-level only); `Dawg` (Term-keyed); +`evaluateLAEquality*`/`evaluateBoundConstraint*` (Polynomial-level); +`checkCompleteness` (lambdas are fresh offset-free constants); +`getDecideLevelForPath` (paths are offset-free). + +**K. Follow-ups enabled/required by the flip:** +- The **reverse-trigger clash-slot source** in + `CClosure.getNumericClashSlots()` (documented deferred: e-matching reverse + triggers with `getArgPosition() != -1` and numeric arg sort) becomes *live* + once quantifiers run with offsets — MBTC must see e-matching positions. +- Proofs: instantiation lemmas substitute real SMT terms, and `(+ a 2)` is one; + the `:inst` rule should be term-level clean, but validate with + `proof-check-mode` once both gates (quantifier and proof) are open. + +## What offsets *buy* e-matching (the extension, separate step) + +Today the fragment forbids arithmetic below uninterpreted functions +(`QuantUtil.containsArithmeticOnQuantOnlyAtTopLevel`, +`isEssentiallyUninterpreted` rejects `f(x+3)`). With offset CC, a pattern +argument `x + k` (variable-plus-constant, and more generally `t + k`) is +matchable for free: `GetArgCode` yields the value `v`, the binding is +`x := v − k` (constant shift on a `CCParameter`); `CompareCode` compares +shifted values. That extends the almost-uninterpreted fragment with constant +offsets under function symbols (`f(x+3) = g(y)` etc.) — the actual quantifier +payoff of the offset project. Requires: `PatternCompiler` support for affine +arguments (constant part only), a shift field on GetArg/Compare codes, fragment +checks widened (`isEssentiallyUninterpreted`, +`containsArithmeticOnQuantOnlyAtTopLevel`, `containsAppTermsForEachVar`), and +`QuantClause.addVarArgInfo`/`VarInfo` positions that record the shift so +interesting-term collection proposes `groundArg − k`. Non-constant coefficients +and multi-variable arguments stay out. + +## Increments + +- **Q1 — `CCParameter` register. (DONE, uncommitted.)** Mechanical + thread-through of A/B/E: register `CCTerm[]` → `CCParameter[]` everywhere in + `ematching/`, `GetArgCode` keeps the offset, `SubstitutionInfo` holds + `CCParameter`s, `PatternCompiler` ground slots stop casting. Type change + propagates into `InstantiationManager` (equivalent-CCTerm maps, var subs; + asserts use `sameValueAs`). `CompareCode`: `sameValueAs` → proceed; same base + term at different structural offsets → drop (can never become equal); + otherwise install. Offset-free behavior verified byte-identical + (`test/quantified` + `test/datatype/quantified` outputs diff-identical to + baseline under `-ea`; full `ant runtests` 723/723). +- **Q2 — CClosure API. (DONE except `getCCParamRep`, uncommitted.)** + `insertCompareTrigger(CCParameter, CCParameter, trigger)` / + `removeCompareTrigger` mirror the `insertEqualityEntry` offset walk + (including the same-class park-at-boundary case — chosen over dropping since + it is the same walk anyway); merge-time activation was already + offset-selective; `insertReverseTrigger(CCParameter)`; `isEqSet`/`isDiseqSet` + on `CCParameter` (`isEqSet` = `sameValueAs`; all callers were quant-side and + want value semantics). `isDiseqSet` also returns true for same-rep pairs at + different offsets (provably distinct by a non-zero constant; Jochen) — the + same-rep case is now fully decided between `isEqSet`/`isDiseqSet`, and the + degenerate `getInfo(rep, rep, ·)` lookup is avoided. Still open: + `getCCTermRep` → `getCCParamRep` (needed in Q3 for `TermFinder`). +- **Q3 — evaluation & canonicalization. (DONE, uncommitted.)** New + `Clausifier.getCCParameter(Term)` (offset-free split + `CCParameter.of`); + `getRepresentativeTerm` returns the canonical *value* term + (`getValueKey().getFlatTerm()`); `getTermAge`/`evaluateCCEquality + (KnownShared)`/partial-EM var-subs lookup use `getCCParameter`; + `addGroundCCTerms` normalizes via `getOffsetFreeTerm`; `getCCTermRep` → + `getCCParamRep` (constant split, value-keyed signature lookup, returns + rep+offset; `TermFinder` and the QuantClause array lookup adapted); + `QuantClause` interesting terms keep offsets (`getArgParam(i).getFlatTerm()`, + select-index TODO resolved). Same-rep-wrong-offset evaluates FALSE via the + `isDiseqSet` same-class case. `collectVarInfos:338` needs no fix here (see I). +- **Q4 — flip the quantifier gate. (DONE, uncommitted.)** Dropped + `mQuantTheory == null` from `Clausifier.createOffsetEqualities()` — this also + dissolves the raw-vs-effective flag divergence (the `bitVecLearnConflict2` + class of bugs), since both predicates now coincide. Reverse-trigger + clash-slot source added to `getNumericClashSlots()` (watched values of + installed reverse triggers join their (symbol, position) slot; + `ReverseTriggerTrigger.getTriggers()` accessor). NOTE the proof gate was + already open at HEAD (`CClosure.createOffsetEqualities` no longer checks + `isProofGenerationEnabled`), so this flip enables offsets under + quantifiers+proofs+interpolation together. Validation: new + `test/quantified/offsetmatch001..003` (offset binding `x := a+3`; a/a+1 + dedup-distinctness; spurious-unsat guard — all pass with `-ea`); + `quanttest001` unsat; all `test/quantified` verdicts unchanged with + offset-shaped proofs/checked interpolants (`ArrayInitialization02` has + `interpolant-check-mode true`); full `ant runtests` **726/726** incl. + SystemTest (lowlevel proof-check + model-check + interpolant-check on the + whole corpus) and BitvectorTest. +- **Q5 — offset patterns** (the fragment extension above). Independent, + largest, do after Q4 is stable. + +Q1+Q2 are the substantial work; Q3 is broad but mechanical; the compare-trigger +offset semantics (C) is the one genuinely new CC mechanism. diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/Clausifier.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/Clausifier.java index 4a0a2cc4f..cc0bda787 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/Clausifier.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/convert/Clausifier.java @@ -621,11 +621,7 @@ public LASharedTerm getLATerm(final Term term) { * offset-free CCTerms with the constant carried as an offset. */ public boolean createOffsetEqualities() { - // Offsets are disabled in the presence of quantifiers: e-matching binds quantifier variables to offset-free - // CCTerms and would lose the offset (e.g. match a(x) against a(l+1) but instantiate x := l), which is unsound. - // Quantifier-free problems are unaffected. (The quantifier theory is slated for rework; full offset-aware - // e-matching is future work.) - return mQuantTheory == null && getCClosure() != null && getCClosure().createOffsetEqualities(); + return getCClosure() != null && getCClosure().createOffsetEqualities(); } /** diff --git a/SMTInterpolTest/test/quantified/offsetmatch001.smt2 b/SMTInterpolTest/test/quantified/offsetmatch001.smt2 new file mode 100644 index 000000000..027649a23 --- /dev/null +++ b/SMTInterpolTest/test/quantified/offsetmatch001.smt2 @@ -0,0 +1,11 @@ +(set-option :print-success false) +(set-logic UFLIA) +(set-info :source |E-matching with offset equalities: matching f(x) against the ground +term f(a+3) must bind x := a+3 (not the offset-free x := a).|) +(set-info :status unsat) +(declare-fun f (Int) Int) +(declare-fun a () Int) +(assert (forall ((x Int)) (= (f x) x))) +(assert (not (= (f (+ a 3)) (+ a 3)))) +(check-sat) +(exit) diff --git a/SMTInterpolTest/test/quantified/offsetmatch002.smt2 b/SMTInterpolTest/test/quantified/offsetmatch002.smt2 new file mode 100644 index 000000000..2a3006a5c --- /dev/null +++ b/SMTInterpolTest/test/quantified/offsetmatch002.smt2 @@ -0,0 +1,14 @@ +(set-option :print-success false) +(set-logic UFLIA) +(set-info :source |E-matching with offset equalities: a and b share a congruence class +at offset 1 (b = a+1). The substitutions x := a and x := b are different values and +must not be conflated by the representative-based dedup; both instances are needed.|) +(set-info :status unsat) +(declare-fun f (Int) Int) +(declare-fun a () Int) +(declare-fun b () Int) +(assert (= b (+ a 1))) +(assert (forall ((x Int)) (>= (f x) 0))) +(assert (< (+ (f a) (f b)) 0)) +(check-sat) +(exit) diff --git a/SMTInterpolTest/test/quantified/offsetmatch003.smt2 b/SMTInterpolTest/test/quantified/offsetmatch003.smt2 new file mode 100644 index 000000000..133d39d06 --- /dev/null +++ b/SMTInterpolTest/test/quantified/offsetmatch003.smt2 @@ -0,0 +1,13 @@ +(set-option :print-success false) +(set-logic UFLIA) +(set-info :source |E-matching with offset equalities: binding x := a (offset dropped) +from the candidate f(a+1) would produce the instance f(a)=7 => a=a+1 with f(a) +wrongly identified with f(a+1), i.e. a spurious unsat. The correct binding +x := a+1 gives a trivially true instance.|) +(set-info :status sat) +(declare-fun f (Int) Int) +(declare-fun a () Int) +(assert (= (f (+ a 1)) 7)) +(assert (forall ((x Int)) (=> (= (f x) 7) (= x (+ a 1))))) +(check-sat) +(exit) From a220871e05c3b303c8f8314ac6cc4ac1c859d0fe Mon Sep 17 00:00:00 2001 From: Jochen Hoenicke Date: Mon, 6 Jul 2026 12:17:58 +0200 Subject: [PATCH 75/75] Fix fresh model values colliding with offset use-sites The model builder chooses fresh values for numeric classes that never entered the linear arithmetic solver, avoiding all values registered so far. But only representative values were registered: a use site of a class (a member or a function application argument, a CCParameter) has value repValue + offsetToRep, which was never marked as used. A fresh value could thus coincide with such a use-site value, evaluating a function at the same point twice with different results. Fix: compute per numeric representative the minimal and maximal offset at which any use site references the class (all members and all numeric CCAppTerm argument parameters, scanned globally since use sites of a class can sit in applications of other sorts). setModelValue additionally registers value + maxOffset as used, and a fresh class value is chosen as extendFresh() - minOffset so that even the smallest use-site value is fresh. Add regression tests test/model/offset_fresh001..003.smt2 covering the offset on the shared side, a Bool predicate variant, and a negative offset on the CC-local side. Co-Authored-By: Claude Fable 5 --- .../theory/cclosure/ModelBuilder.java | 57 ++++++++++++++++++- .../test/model/offset_fresh001.smt2 | 18 ++++++ .../test/model/offset_fresh002.smt2 | 17 ++++++ .../test/model/offset_fresh003.smt2 | 16 ++++++ 4 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 SMTInterpolTest/test/model/offset_fresh001.smt2 create mode 100644 SMTInterpolTest/test/model/offset_fresh002.smt2 create mode 100644 SMTInterpolTest/test/model/offset_fresh003.smt2 diff --git a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ModelBuilder.java b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ModelBuilder.java index b4348bc0d..3d3d2bd26 100644 --- a/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ModelBuilder.java +++ b/SMTInterpol/src/de/uni_freiburg/informatik/ultimate/smtinterpol/theory/cclosure/ModelBuilder.java @@ -55,6 +55,14 @@ public class ModelBuilder { SharedTermEvaluator mEvaluator; Map mModelValues = new HashMap<>(); Theory mTheory; + /** + * For each representative of a numeric congruence class, the minimal/maximal offset at which any use site (a class + * member or a function application argument) references the class. The value at such a use site is the + * representative's value plus the offset, so a freshly chosen class value must keep the whole range + * {@code [value+min, value+max]} clear of other model values, not just the value itself. + */ + Map mMinOffset = new HashMap<>(); + Map mMaxOffset = new HashMap<>(); public ModelBuilder(final CClosure closure, final List terms, final Model model, final Theory t, final SharedTermEvaluator ste, @@ -64,6 +72,25 @@ public ModelBuilder(final CClosure closure, final List terms, final Mode mEvaluator = ste; mTheory = t; + // collect the offset range at which each numeric class is used: every member and every function application + // argument is a use site whose model value is the representative's value plus its offset to the representative. + for (final CCTerm term : terms) { + final Term flatTerm = term.getFlatTerm(); + if (flatTerm != null && flatTerm.getSort().isNumericSort()) { + updateOffsetRange(term.getRepresentative(), term.getOffsetToRep()); + } + if (term instanceof CCAppTerm) { + final CCAppTerm app = (CCAppTerm) term; + for (int i = 0; i < app.getArgCount(); i++) { + final CCParameter arg = app.getArgParam(i); + final Term argTerm = arg.getCCTerm().getFlatTerm(); + if (argTerm != null && argTerm.getSort().isNumericSort()) { + updateOffsetRange(arg.getRepresentative(), arg.getOffsetToRep()); + } + } + } + } + // create a map from sorts to representatives of that sort. final LinkedHashMap> repsBySort = new LinkedHashMap<>(); for (final CCTerm term : terms) { @@ -174,10 +201,29 @@ public Term getModelValue(final CCParameter param) { return NumericSortInterpretation.toRational(repValue).add(param.getOffsetToRep()).toTerm(sort); } + private void updateOffsetRange(final CCTerm rep, final Rational offset) { + final Rational min = mMinOffset.get(rep); + if (min == null || offset.compareTo(min) < 0) { + mMinOffset.put(rep, offset); + } + final Rational max = mMaxOffset.get(rep); + if (max == null || offset.compareTo(max) > 0) { + mMaxOffset.put(rep, offset); + } + } + public void setModelValue(final CCTerm term, final Term value) { assert term == term.getRepresentative(); final Term old = mModelValues.put(term, value); mModel.provideSortInterpretation(value.getSort()).register(value); + if (value.getSort().isNumericSort()) { + // mark the largest use-site value of the class as used, so fresh values avoid the whole class range. + final Rational maxOffset = mMaxOffset.get(term); + if (maxOffset != null && maxOffset.signum() > 0) { + mModel.provideSortInterpretation(value.getSort()) + .register(NumericSortInterpretation.toRational(value).add(maxOffset).toTerm(value.getSort())); + } + } assert old == null || old == value; } @@ -238,8 +284,15 @@ public void fillInTermValues(final List terms, final CCTerm trueNode, fi // Handle all delayed elements // note that extendFresh must be called after all values in the model have been extended to the numeric sort. for (final CCTerm ccterm : delayed) { - final Term value = mModel.extendFresh(ccterm.getFlatTerm().getSort()); - setModelValue(ccterm, value); + final Sort sort = ccterm.getFlatTerm().getSort(); + Rational value = NumericSortInterpretation.toRational(mModel.extendFresh(sort)); + // shift by the class's smallest use-site offset, so even the smallest use-site value (value + minOffset) + // is the fresh one; setModelValue then marks the largest one (value + maxOffset) as used. + final Rational minOffset = mMinOffset.get(ccterm); + if (minOffset != null && minOffset.signum() < 0) { + value = value.sub(minOffset); + } + setModelValue(ccterm, value.toTerm(sort)); } } diff --git a/SMTInterpolTest/test/model/offset_fresh001.smt2 b/SMTInterpolTest/test/model/offset_fresh001.smt2 new file mode 100644 index 000000000..c12a72a96 --- /dev/null +++ b/SMTInterpolTest/test/model/offset_fresh001.smt2 @@ -0,0 +1,18 @@ +; Fresh-value collision with an offset use-site: x is CC-local (never enters +; LA), so the model builder assigns it a "fresh" value larger than all used +; values. But "used" only tracks values registered for CCTerm representatives; +; the use-site value of the argument a+1 (= value(a) + offset 1) is never +; registered. With a = 2 the fresh value for x is 3 = a+1, so f is evaluated +; at the same point twice with different results. +(set-option :produce-models true) +(set-option :model-check-mode true) +(set-info :status sat) +(set-logic QF_UFLIA) +(declare-fun a () Int) +(declare-fun x () Int) +(declare-fun f (Int) Int) +(assert (>= a 2)) +(assert (not (= (f (+ a 1)) (f x)))) +(check-sat) +(get-model) +(get-value (a x (f (+ a 1)) (f x))) diff --git a/SMTInterpolTest/test/model/offset_fresh002.smt2 b/SMTInterpolTest/test/model/offset_fresh002.smt2 new file mode 100644 index 000000000..b22e4b99a --- /dev/null +++ b/SMTInterpolTest/test/model/offset_fresh002.smt2 @@ -0,0 +1,17 @@ +; Fresh-value collision, predicate variant: no numeric function values enter +; LA, so the only registered numeric value is a = 2. The CC-local x gets the +; fresh value 3, which collides with the unregistered use-site value a+1 = 3, +; making p(3) both true and false. +(set-option :produce-models true) +(set-option :model-check-mode true) +(set-info :status sat) +(set-logic QF_UFLIA) +(declare-fun a () Int) +(declare-fun x () Int) +(declare-fun p (Int) Bool) +(assert (>= a 2)) +(assert (p (+ a 1))) +(assert (not (p x))) +(check-sat) +(get-model) +(get-value (a x (p (+ a 1)) (p x))) diff --git a/SMTInterpolTest/test/model/offset_fresh003.smt2 b/SMTInterpolTest/test/model/offset_fresh003.smt2 new file mode 100644 index 000000000..5c2897860 --- /dev/null +++ b/SMTInterpolTest/test/model/offset_fresh003.smt2 @@ -0,0 +1,16 @@ +; Fresh-value collision with the offset on the CC-local side: the fresh value +; chosen for x must keep the use-site value x-1 distinct from all other +; f-argument values, i.e. the fresh value must account for the parameter's +; negative offset. Here LA mutates a to 1 and the fresh x = 2 gives x-1 = 1 = a. +(set-option :produce-models true) +(set-option :model-check-mode true) +(set-info :status sat) +(set-logic QF_UFLIA) +(declare-fun a () Int) +(declare-fun x () Int) +(declare-fun f (Int) Int) +(assert (>= a 0)) +(assert (not (= (f a) (f (- x 1))))) +(check-sat) +(get-model) +(get-value (a x (f a) (f (- x 1))))