Skip to content

Support for Offset Equalities - #156

Open
jhoenicke wants to merge 75 commits into
masterfrom
offsetequality
Open

Support for Offset Equalities#156
jhoenicke wants to merge 75 commits into
masterfrom
offsetequality

Conversation

@jhoenicke

Copy link
Copy Markdown
Member

No description provided.

jhoenicke and others added 30 commits June 12, 2026 20:14
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…cture

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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ashing

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…p 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) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…rg 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…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) <noreply@anthropic.com>
Record that LA->CC offset-equality propagation, eager offset (dis)equality
propagation at merge time, and the offset-aware conflict explanation are
implemented (commit 858013d): 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…e 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) <noreply@anthropic.com>
Record the full-value LASharedTerm redesign (commit 0c94d30): 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…er[]

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) <noreply@anthropic.com>
…e 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
… 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<CCParameter>, 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<CCTerm> (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) <noreply@anthropic.com>
jhoenicke and others added 30 commits June 24, 2026 19:15
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…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) <noreply@anthropic.com>
…vialdiseqarray)

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) <noreply@anthropic.com>
… 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) <noreply@anthropic.com>
…odelBuilder

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) <noreply@anthropic.com>
…ue(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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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.
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) <noreply@anthropic.com>
Remove old code to determine the select/const edges
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant