Skip to content

MCSAT: add support for tuples (incl. nested with function types); blasts tuples in input constraints + reconstitutes them in models and interpolants#606

Merged
disteph merged 22 commits intomasterfrom
mcsat-tuples-support
May 8, 2026
Merged

MCSAT: add support for tuples (incl. nested with function types); blasts tuples in input constraints + reconstitutes them in models and interpolants#606
disteph merged 22 commits intomasterfrom
mcsat-tuples-support

Conversation

@disteph
Copy link
Copy Markdown
Collaborator

@disteph disteph commented Mar 2, 2026

This branch adds end-to-end tuple support in MCSAT by eliminating tuples before solving and reconstructing results in terms of the original problem.

It includes:

  1. Tuple/function preprocessing (tuple blasting) before MCSAT solving.
  2. Model reconstruction from blasted leaves back to original tuple-typed terms/functions.
  3. Interpolant post-processing to replace tuple leaves with accessor-based terms over original tuple variables.
  4. Scope/GC integration so preprocessing artifacts are backtrack-safe.
  5. Expanded MCSAT-only API regression tests.

Main changes

Preprocessor: tuple blasting

  • Extended MCSAT preprocessor state and traversal to blast tuple structure recursively.
  • Handles:
    • tuple constructors/selectors
    • tuple equality/distinct
    • tuple-typed ITE
    • function applications/updates involving tuple domain/codomain
    • function type flattening for tuple arguments/results

Model reconstruction

  • Rebuilds tuple atom values from leaf values.
  • Rebuilds function values of original tuple types by merging blasted leaf functions:
    • reconstruct tuple-domain arguments from flattened arguments
    • reconstruct tuple codomain outputs from leaf outputs
    • preserve defaults/mappings coherently

Interpolants

  • Added unblasting of interpolants returned by MCSAT:
    • tuple leaves are rewritten to selector chains on original tuple terms
    • function leaves are rewritten with lambda wrappers so interpolants are expressed over original function terms and tuple arguments

Backtracking/GC behavior

  • Tuple-blast mappings and generated artifacts are integrated with preprocessor push/pop and GC marking.
  • Original assertion structure remains separated from processed assertions; preprocessing data is scoped and cleaned with solver backtracking.

Tests

  • Added/expanded tests/api/mcsat_tuples.c with MCSAT-only guards (#ifdef HAVE_MCSAT + runtime yices_has_mcsat()).
  • Coverage includes:
    • tuple-valued function model reconstruction
    • function update/application on tuple args/results
    • nested tuple + ite/distinct cases
    • push/pop scope behavior
    • UNSAT + interpolant retrieval on tuple/function constraints

@coveralls
Copy link
Copy Markdown

coveralls commented Mar 2, 2026

Coverage Status

coverage: 67.079% (+0.4%) from 66.687% — mcsat-tuples-support into master

@disteph disteph added this to the Yices 2.8 milestone Mar 2, 2026
@disteph disteph changed the title MCSAT: add tuple/function preprocessing + interpolant regression coverage MCSAT: add support for tuples (incl. nested with function types); blasts tuples in input constraints + reconstitutes them in models and interpolants Mar 11, 2026
Comment thread src/mcsat/solver.c Outdated

term_t mcsat_get_unsat_model_interpolant(mcsat_solver_t* mcsat) {
return mcsat->interpolant;
return preprocessor_unblast_term(&mcsat->preprocessor, mcsat->interpolant);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do you need this?
isn't mcat->interplant already unblasted?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. The code wasn't being very clean there. Fixed in commit c7e51e8: Now mcsat->interpolant is in the blasted world. Asking for it via the getter does the unblasting,

Copy link
Copy Markdown
Collaborator Author

@disteph disteph May 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me roll that back. It is cleaner to keep mcsat->interpolant in the pre-blast world where the user-written assertions and assumptions also belong. New commits enforce that.

@disteph disteph force-pushed the mcsat-tuples-support branch from 40404e9 to c7e51e8 Compare May 5, 2026 00:23
disteph added 15 commits May 5, 2026 01:18
Blast tuple-typed VARIABLE terms so Yices frontend tuple declarations go through the same MCSAT tuple encoding as API-created uninterpreted terms.

Keep stored MCSAT interpolants in the internal preprocessed world and unblast only through the public getter.

Add API and Yices frontend regressions for tuple selectors, updates, disequality, tuple-typed variables, tuple function domains/ranges, and interpolant reconstruction.
…riant

Centralize interpolant handling so mcsat->interpolant is always already in the public/postprocessed world (tuple-blasted leaves substituted back to accessors over original atoms). Writers go through mcsat_set_interpolant_from_internal for conflict-analysis values, or assign directly when the value is already public (false_term, label-substituted result). The getter mcsat_get_unsat_model_interpolant becomes a plain field read.

Replace mcsat_set_unsat_result with mcsat_set_unsat_result_from_labeled_interpolant: callers now pass the temporary Boolean labels and the original assumptions; the routine performs label->assumption substitution before storing. Update context_solver.c and no_mcsat.c accordingly.

Document the invariant on the interpolant field with the contract callers must respect.
Both helpers are pure functions of the type, recurse along function/tuple type structure, and were previously called repeatedly during tuple-blasting (once per visit to any term whose type involves tuples or functions). Cache the result on the preprocessor; type IDs are stable for the life of the type table so the cache never needs invalidation.

Take a preprocessor_t* instead of a type_table_t*, threading caches through. All call sites updated.
… path

Two related changes that together cut preprocessing cost on large or deep formulas with mostly tuple-free sub-DAGs:

(M3) tuple_blast_term short-circuits sub-DAGs that contain no tuple type anywhere. The new memoized helper term_has_tuples_in_subdag walks the DAG iteratively (using tuple_blast_children to enumerate sub-terms) and caches a 0/1 answer per term index in pre->term_has_tuples_cache. When the answer is false the term blasts to itself in one step, with no descent.

(M4) tuple_blast_term is now an iterative bottom-up driver instead of recursing through C stack. The recursive body is renamed tuple_blast_term_body and is invoked once per term after all its tuple_blast_children have been blasted. C stack depth is O(1) regardless of input DAG depth, eliminating a stack-overflow risk on deeply nested terms.

tuple_blast_children mirrors exactly the descent in tuple_blast_term_body for every kind it handles; that contract is documented above the helper. Defensive iteration caps in both while loops trip an assertion long before any pathological input could hang a debug build.
When the blasted interpolant references the per-leaf functions of a function-valued tuple atom, preprocessor_unblast_term substitutes each leaf function with a lambda of the form  (lambda v. (select j (f (tuple v_0 ... v_{k-1})))). Whatever wraps this substitution must then beta-reduce applications  ((lambda v. body) args) so that no YICES_LAMBDA_TERM escapes into the public, unblasted term world that is returned by yices_get_model_interpolant.

Before this commit the three interpolant tests only checked that the original tuple/function atom name reappeared and that the interpolant was boolean. A residual lambda wrapper would satisfy both checks while still being a bug.

Add assert_interpolant_has_no_lambda_residue, a bounded recursive walk over the interpolant that aborts on any YICES_LAMBDA_TERM constructor. Plug it into the three existing function-tuple interpolant tests. Addresses review item H2.
The function had two cleanup labels, done_orig_maps and done. orig_maps
was initialized late (right before the loop that populated it), so the
earlier `goto done` branches in the leaf-processing loop had to skip
the delete_ivector(&orig_maps) in the cleanup. This works today but
silently leaks if:

  - the init of orig_maps ever moves above the first `goto done`, or
  - a new error branch that should delete orig_maps is added and gets
    routed through `done` instead of `done_orig_maps`.

Move the init of orig_maps up with the other unconditional inits
(flat_dom, unique_offsets, unique_args), drop the done_orig_maps
label, and always delete orig_maps in the single done cleanup.
leaf_maps still uses its maps_init counter because its init is
interleaved with error branches in the first loop. The resulting
invariant -- "every goto done sees all scratch ivectors already
initialized" -- is now local and self-evident instead of having to be
reasoned about across two labels.

Behaviour is unchanged; all three build modes (debug, sanitize,
release) still pass the 31 api tests. Addresses review item M5.
The existing tests/regress/mcsat/tuples/ directory covers four
scenarios: (i) distinct tuples with equal components, (ii) tuple of
(function, int), (iii) tuple selectors inside arithmetic and bitvector
terms, (iv) tuple-update under push/pop. Several important blasting
paths that are only exercised by tests/api/mcsat_tuples.c have no
coverage in the language-level regression suite, so a regression that
only surfaces through the yices text frontend would go unnoticed.

Add four focused .ys tests that each pin down one such path:

  - tuple_nested_ite.ys: tuple-of-tuple with mk-tuple inside both
    branches of an ite, followed by two-level selection.
  - tuple_update_rewrite.ys: (tuple-update x 2 true) must behave as
    the tuple (mk-tuple (select x 1) true) once blasted and
    reconstructed in the model.
  - tuple_function_range_unsat.ys: function int -> Pair (scalar
    domain, tuple range) sitting behind a tuple selector; an unsat
    instance that forces the postprocessor to reconstruct a function
    whose codomain is a tuple.
  - tuple_function_domain_unsat.ys: function Pair -> int (tuple
    domain, scalar range) behind a tuple selector; the dual case.

Each test is under 20 lines and produces a single-word sat/unsat gold
output, matching the style of the existing tests in the directory.
All four pass against the current mcsat build.

Addresses review item M6.
Many call sites inside tuple_blast_term_body only read the blasted
leaves of a sub-term. They currently pay a malloc + memcpy per sub-term
by construction:

  init_ivector(&v, 0);
  tuple_blast_get(pre, x, &v);
  use v.data, v.size;
  delete_ivector(&v);

Add tuple_blast_peek, which returns (data_ptr, size) pointing directly
into tuple_blast_data. The pointer is live only until the next
operation that can grow tuple_blast_data -- most notably a subsequent
tuple_blast_term call; this invariant is spelled out in the helper's
doc-comment and repeated at every use site.

Convert the three hot read-only sites called out in the review:
  * tuple_blast_collect_arg  (called from APP / UPDATE / composites)
  * ITE                      (three leaf vectors per ITE sub-term)
  * DISTINCT                 (two leaf vectors per (i,j) pair, O(n^2))
  * EQ                       (two leaf vectors per equality)

Also tighten tuple_blast_eq_vector's signature to take (a, b, n)
directly so it can consume peek output without re-wrapping in an
ivector.

Call sites that call tuple_blast_term AGAIN after getting the leaves
(APP/UPDATE scan loops, composite/pprod/poly child loops) still use
the copying tuple_blast_get; peek would be unsafe there and rewriting
those to defer the tuple_blast_term calls would balloon the diff.

All three build modes (debug, sanitize, release) still pass the 31
api tests, and all eight tuple .ys regressions (the four pre-existing
plus the four added in M6) still match their gold output. Addresses
review item L3.
preprocessor_build_tuple_model has three paths along which a
tuple-blasted atom can quietly fail to appear in the reconstructed
model:

  1. one of the blasted leaf variables has no value in the trail,
  2. the function-type merge in merge_blasted_function_value returns
     null_value for one of several structural reasons (leaf value is
     neither a function nor an update object, a map's arity does not
     match the flattened domain, or a sub-call to build_value_from_flat
     could not rebuild a codomain / domain / default value),
  3. the non-function tuple merge in build_value_from_flat itself
     returns null_value.

Under the previous code each of these branches just `continue`'d or
returned null_value and the caller conditionally invoked
model_map_term only on success. A user inspecting (show-model) would
see the atom missing with no signal anywhere as to why.

Emit a short line under the existing "mcsat::preprocess" trace tag at
each drop site:

  - the leaf-missing case in preprocessor_build_tuple_model names the
    atom and the leaf index that is unassigned;
  - merge_blasted_function_value records a short reason string at each
    of its five `goto done` exits (via a local fail_reason variable
    set immediately before the jump), and emits it from the single
    cleanup label; the caller then adds the concrete atom term;
  - the tuple (non-function) branch in preprocessor_build_tuple_model
    names the atom and notes that the leaves did not decompose.

trace_enabled is a no-op in NDEBUG, so release builds pay nothing at
runtime. mcsat_trace_printf and trace_term_ln are already used from
this file under the same tag, so no new headers or dependencies.

No semantic change to the rebuilt model; the 31 api tests and the 8
tuple .ys regressions still pass in debug, sanitize, and release.
Adds tests/regress/mcsat/tuples/tuple_show_model.ys, the first .ys gold
test that exercises the (show-model) printer on tuple-typed free variables
under MCSAT. The constraints fully determine both components of two free
Pair tuples via select-only assertions, so the printed model is
deterministic across debug, release, and sanitize builds (verified). This
covers the post-unblast model reconstruction path for plain (int x bool)
tuples and pins the on-disk syntax of (mk-tuple ...) values that callers
of (show-model) see. Addresses review item \xC2\xA74.3.
Convert the remaining tuple_blast_get call sites whose only use of the
returned snapshot is a (size==1, data[0]) read to tuple_blast_peek:
NOT, SELECT, OR/XOR, POWER_PRODUCT, ARITH_POLY, ARITH_FF_POLY, BV64_POLY,
BV_POLY, and the generic-composite combine.

Each site already followed a uniform pattern -- tuple_blast_term, then
get into a one-shot ivector, then check size==1 and read data[0] -- so
nine independent malloc+memcpy+free triples per blasted DAG node go away.
The peek pointer is consumed within the same loop iteration before any
subsequent tuple_blast_term, preserving the lifetime invariant documented
on tuple_blast_peek.

The remaining tuple_blast_get callers (APP, UPDATE, top-level substitution
in tuple_blast_apply, model reconstruction in preprocessor_build_tuple_model
and the substitution-walk loop) all need a snapshot that survives further
tuple_blast_term calls and stay on the copying path. The peek doc-comment
is updated to spell out the split.

Verification: all 31 api tests pass in debug, release, and sanitize (the
unrelated type_macros UAF preexisting on master is unchanged); all 691
mcsat regression tests pass in all three modes.
@disteph disteph force-pushed the mcsat-tuples-support branch from 51a8ba2 to 7a3358d Compare May 5, 2026 08:46
@disteph
Copy link
Copy Markdown
Collaborator Author

disteph commented May 5, 2026

Review by Windsurf/Opus4.7:

Review: 7a3358d6 (mcsat-tuples-support) vs master

Scope. 15 commits, ~2,500 LOC added across src/mcsat/{preprocessor,solver}.{c,h}, src/context/context_solver.c, src/mcsat/no_mcsat.c, plus 1 large API test (tests/api/mcsat_tuples.c, ~670 LOC) and 9 .ys gold regressions under tests/regress/mcsat/tuples/.


High-level summary

The branch teaches the MCSAT preprocessor to handle tuple-typed terms (including tuples whose components are themselves functions), and threads the corresponding model-reconstruction and unsat-interpolant unblast through mcsat_solver_t. The new code is concentrated in one module (preprocessor.c), is well factored into a fixed pipeline (peek/get/set + iterative driver + memoized type-walks + unblast/build-model), and the public solver-side surface change is narrow: one renamed setter (mcsat_set_unsat_result_from_labeled_interpolant) and one new internal helper. Test coverage is substantial, with both API-level and gold .ys-level coverage including a deterministic (show-model) test.


Strengths

Architectural clarity

  • Single, documented invariant for the sticky interpolant. mcsat_solver_t::interpolant is always in the public/post-unblast world. Two doc-comments — one on the field declaration in solver.c, one on the API on solver.h — describe the contract and force every writer through the right entry point. The mcsat_set_interpolant_from_internal helper centralizes the unblast call, and false_term literal writes are explicitly tagged /* false_term is already in the public world */, so a future reviewer can follow every interpolant store on one screen.

  • Tuple blasting is a well-bounded subsystem. All new state is a contiguous block in preprocessor_t (the tuple_blast_* ivectors/maps and the three memoization caches), with matching init_* and delete_* calls and scope_holder_push/pop integration for backtracking. The packed tuple_blast_data layout ([size, terms...]) keeps lookup at O(1) and is what enables the zero-copy peek.

  • The iterative driver eliminates the stack-blowup risk. Replacing a recursive tuple_blast_term with the explicit preprocessing_stack-based loop means an arbitrarily deep DAG cannot crash the solver via C stack. The term_has_tuples_in_subdag memo and the tuple-free fast path mean tuple-free sub-DAGs are blasted in O(1) per node, which in practice means the iterative driver is no slower than the previous recursive version on the common case.

Correctness hygiene

  • Memoization with a stable-key argument. The caches type_is_tuple_free_cache, type_leaf_count_cache, and term_has_tuples_cache are documented as never needing invalidation because type/term IDs are immutable for the lifetime of the table. That argument is correct in this codebase and saves the complexity of tying these caches to GC.

  • Error paths converge. merge_blasted_function_value was refactored to a single cleanup label (done:) with a fail_reason string set before each early exit and emitted at cleanup. This is a real readability win over the prior multi-return null_value form, and pairs with the trace diagnostics so a silent drop is no longer silent.

  • Trace diagnostics for silent drops. preprocessor_build_tuple_model now logs the dropped atom and the reason (no value in trail / no function value reconstructable) under the mcsat::preprocess trace tag. This addresses the residual-concern that an atom missing from (show-model) had no observable cause.

  • Strict UNSAT assertion preserved end-to-end. The efa22f5234 commit (after rebase) now contains both the legitimate test stabilization (yices_default_config_for_logic(... "QF_UFLIA")) and the strict assert(... == YICES_STATUS_UNSAT) form. The history no longer shows the weaken/restore oscillation.

Performance

  • Zero-copy peek across all read-only single-leaf sites. Every tuple-blasting case whose only use of the blasted snapshot is a (size==1, data[0]) read uses tuple_blast_peek and bypasses the per-sub-term malloc + memcpy + free of the _get path: NOT, SELECT, EQ, ITE, DISTINCT, OR/XOR, POWER_PRODUCT, all four *_POLY, and the generic-composite combine. The peek doc-comment is explicit about the lifetime invariant and lists which call sites stay on the copying path (APP, UPDATE, top-level substitution, model reconstruction) and why.

Tests

  • API test (tests/api/mcsat_tuples.c). Covers tuple-of-int/bool, function-valued tuple components, tuple-update interactions, push/pop scope semantics, model reconstruction, and the no-residual-lambda invariant on interpolants. The no-lambda check directly addresses H2 — i.e., the worry that a function-tuple atom's interpolant could leak an internal lambda body into the user's term world.

  • .ys gold regressions. Nine tests covering distinct/unsat, function-domain/range, nested ITEs, mixed selectors over arith/BV, update-rewrite, push/pop, and the new tuple_show_model.ys deterministic gold for (show-model) over plain (int × bool) tuples. The show-model test is verified byte-identical across debug, release, and sanitize.

Build & test discipline

  • All 31 API tests pass in debug, release, and sanitize (with the unrelated, pre-existing type_macros heap-UAF on master being the only sanitize failure, also reproducible on master HEAD).
  • All 691 mcsat regression tests pass in all three modes.

Concerns / things I would still want to discuss

1. merge_blasted_function_value: fail-on-decompose still silently degrades the model

The function returns null_value if any blasted leaf doesn't decompose cleanly to a function-or-update, the caller drops the atom, and the user sees the atom missing from (show-model). The trace diagnostic is the right minimum, but a user running without a tracer enabled still gets a quietly under-specified model. Suggestion: consider promoting the dropped-atom set to a queryable property (e.g. a counter on the preprocessor), or at least fprintf(stderr, ...) when the drop happens with --mcsat in interactive mode. Severity: medium. Not a merge blocker.

2. The tuple_blast_peek lifetime invariant is fragile

It is stated clearly in the doc-comment, and every current caller respects it, but the rule ("MUST NOT hold the returned pointer across any operation that can grow tuple_blast_data -- most notably tuple_blast_term") is exactly the kind of invariant a future contributor will violate. Suggestion: add a debug-only generation counter on tuple_blast_data (incremented on every ivector_add/push that grows it), capture it in peek, and assert in a debug-only wrapper that the generation matches when the caller dereferences. Keeps the zero-copy benefit in release but makes a regression unmissable. Severity: low. Pure defensive engineering.

3. mcsat_set_unsat_result_from_labeled_interpolant precondition isn't enforced

The doc-comment states "interpolant is already in the public / post-unblast world" but no assertion checks it. If a future caller forgets and feeds an internal-world term, blasted leaves leak into the sticky interpolant. The unblast operation is idempotent, so a defensive apply_term_subst against the empty substitution wouldn't catch it, but a debug-only walk that asserts no internal-leaf variable appears would. Severity: low.

4. Test file is large (~670 LOC) and would benefit from a helper

tests/api/mcsat_tuples.c is a flat list of test functions, several of which share boilerplate (context setup, make_mcsat_context(true/false), the no-residual-lambda check). The helpers exist (assert_no_lambda_residue and friends) but the per-test setup/teardown is still copy-pasted. Severity: low, cosmetic.

5. Sanitize fail (type_macros) is pre-existing on master, not from this branch

Verified by switching to master and reproducing. Not a branch-blocker, but worth filing a separate issue against tests/api/type_macros.c /src/utils/symbol_tables.c.


Smaller observations

  • The // BD comment removed from mcsat_clear is the right cleanup — it carried no information.
  • The term_has_tuples_in_subdag cache is keyed on index_of(t) (polarity-insensitive), which is correct because tuple-ness doesn't depend on negation. The doc-comment says so. Good.
  • preprocessor_unblast_term is exposed in preprocessor.h. Since it's only consumed by solver.c, you could keep it static if you preferred a tighter surface, but the current factoring also makes it easier to test in isolation.
  • init_ivector(&eqs, n) (and similar) in tuple_blast_eq_vector pre-sizes the ivector, which is the right idiom; consistent throughout the new code.
  • The .ys.options files all carry just --mcsat. One could imagine a global default for tests/regress/mcsat/tuples/, but the current explicit per-file form matches the convention elsewhere.

Verdict

Approve, with the medium-severity suggestion on the silent-drop telemetry left as a follow-up. The architecture is clean, the invariant for the public-world interpolant is documented and enforced through the type-of-call (writers must pick the right entry point), the iterative driver removes a real correctness risk, the memoization is well argued, and test coverage at both the C-API and .ys levels is thorough. The branch is in a state where I would be comfortable merging once you've decided how aggressively you want to expose the silent-drop signal to users.

disteph added 3 commits May 7, 2026 15:13
The preprocessor maintains three opportunistic memoization caches keyed
on raw term/type IDs:

  - type_is_tuple_free_cache    (key: type_t)
  - type_leaf_count_cache       (key: type_t)
  - term_has_tuples_cache       (key: index_of(t))

Previously their lifetime was "lifetime of the term/type table" on the
assumption that IDs are stable forever. That assumption is wrong: Yices
recycles type IDs in indexed_table_free (src/terms/types.c) and term
indices in src/terms/terms.c after a GC sweep. preprocessor_gc_mark
explicitly marks tuple_blast_map / preprocess_map / equalities /
purification entries, but a term visited only as an *interior* node
during classification (e.g. a select traversed by
term_has_tuples_in_subdag while walking a top-level atom) is not in
those root sets. Once such a term's index is freed and reused, the
cached classification entry inherited by the new term could send the
iterative tuple-blast dispatch down the wrong arm: either treating a
tuple-containing term as tuple-free and forwarding an un-blasted SELECT
to the old preprocessor, or returning a stale leaf count that doesn't
match the new tuple's shape.

Fix: reset all three caches at the top of preprocessor_gc_mark, before
the marking loop and well before the sweep that follows. The caches
repopulate on first use after GC; cost is negligible relative to GC
itself.

Test: tests/api/mcsat_tuples.c gains test_tuple_preprocessing_then_gc,
which solves a Pair=(int,bool) problem (populating the caches), runs
yices_garbage_collect with no roots so the relevant types/terms are
swept and their IDs eligible for reuse, then solves a differently-shaped
Triple=(bool,int,int) problem and validates the model.

Also updates the doc-comments on the three cache fields in
src/mcsat/preprocessor.h to describe the new GC-reset contract.
The iterative tuple-blast driver dispatches on term_kind. The dispatch
in tuple_blast_term_body and the parallel descent in
tuple_blast_children were missing several kinds that can carry a
SELECT-of-tuple subterm:

  - BIT_TERM             (a bv-typed operand under bit-extract)
  - ARITH_IS_INT_ATOM    (unary, real -> bool)
  - ARITH_FLOOR          (unary, real -> int)
  - ARITH_CEIL           (unary, real -> int)
  - ARITH_ABS            (unary, arith -> arith)
  - ARITH_FF_EQ_ATOM     (unary, ff -> bool, "t == 0")
  - ARITH_FF_BINEQ_ATOM  (binary, ff x ff -> bool, "t1 == t2")

Worse, BIT_TERM was bucketed with the *constant* leaf cluster:

    case CONSTANT_TERM:
    case ARITH_CONSTANT:
    ...
    case BIT_TERM:
      ivector_push(&result, t);

so its bv operand was never recursed into. The companion comment in
tuple_blast_children's default branch even called BIT_TERM "atomic",
which is wrong -- it carries one bv argument.

The runtime effect was that `(<op> (select x i))` would be classified
tuple-free by term_has_tuples_in_subdag (which uses the same descent),
the iterative driver would short-circuit through the tuple-free fast
path, and the original term -- still containing an un-blasted SELECT --
would be forwarded to the rest of preprocessing, where the BV/NRA/NIA
plugins reject SELECT_TERM via MCSAT_EXCEPTION_UNSUPPORTED_THEORY. So
the failure was clean (no crash) but it artificially narrowed the set
of tuple-component formulas mcsat could solve.

Fix:

  1. tuple_blast_children gets per-kind cases that push the right
     children, using bit_term_arg, arith_*_arg, and
     arith_ff_bineq_atom_desc. The default-branch comment is corrected
     so it no longer calls BIT_TERM atomic.

  2. tuple_blast_term_body gets bespoke dispatch arms for each kind:
     blast the operand(s), peek the result, require size==1 (any
     tuple-typed operand here is meaningless and reports
     UNSUPPORTED_THEORY exactly like the existing composite cluster),
     and rebuild via the appropriate term_manager builder
     (mk_bitextract / mk_arith_is_int / mk_arith_floor / mk_arith_ceil
     / mk_arith_abs / mk_arith_ff_term_eq0 / mk_arith_ff_eq).

     BIT_TERM is removed from the constant-leaf cluster -- it was the
     only non-leaf in there.

Tests:

  - tests/api/mcsat_tuples.c: new
    test_tuple_blast_bit_over_tuple_component, exercising
    `(bit i (select x j))` over a (tuple int (bitvector 4)).

  - tests/regress/mcsat/tuples/tuple_bit_select.ys: same coverage at
    the .ys frontend level.

  - tests/regress/mcsat/tuples/tuple_unary_arith.ys: covers
    is-int / floor / abs over a (tuple int real).

The ARITH_FF_EQ_ATOM / ARITH_FF_BINEQ_ATOM cases share the dispatch
shape with the unary-arith cases above; they cannot be exercised at
.ys level (no FF arithmetic in the native frontend) and SMT2 has no
tuple types, so they have no targeted regression. The dispatch entries
themselves are reviewed against the existing FF kinds in get_composite
and term_manager.c.
Pin the property that interpolants over function-valued tuple
components remain lambda-free, so future changes that rewrite
interpolants in terms of blasted leaves -- and therefore start
producing naked (lambda (v0) (...)) residues that fail to
eta-reduce -- get caught here rather than at user-facing call sites.

The "naked" path is currently doubly guarded:
 - Single-leaf function ranges: mk_lambda's eta-reduction fast path
   in term_manager.c collapses (lambda (v0) (f v0)) to f at
   construction time.
 - Multi-leaf function ranges (range is itself a tuple): the
   per-slice lambda body (select ((select x 1) v0) i) is not
   eta-reducible, but MCSAT interpolation in this configuration
   returns the original asserted formula rather than a leaf-level
   rewrite, so unblast never substitutes the leaf inside the
   interpolant.

The new test asserts (= fx fy) against (distinct fx fy) over a
multi-leaf function range and uses assert_interpolant_has_no_lambda_residue
on the result. No source change is needed -- this is a guard rail.
disteph added 2 commits May 7, 2026 15:13
Two related fixes to preprocessor model reconstruction so that
yices_get_model(ctx, 0) -- i.e. keep_subst=false -- no longer drops
tuple atoms whose blasted leaves are unconstrained.

1. preprocessor_build_model:
   The existing code unconditionally called
   model_add_substitution(model, eq_var, ...) for every solved
   equality. That asserts has_alias in debug builds and silently
   leaves eq_var unmapped in release builds when the caller passed
   keep_subst=0. Split into two arms:
     - has_alias=true: keep the existing alias-based lazy binding.
     - has_alias=false: concretely evaluate eq_subst with a lazily
       initialized evaluator and model_map_term(eq_var, v). Fall
       back to vtbl_make_object only if eval_in_model fails (e.g.
       eq_subst transitively depends on another not-yet-mapped
       eq_var).
   This matches the spirit of context_build_model_for_real's
   handling of eliminated variables without introducing new aliases.

2. preprocessor_build_tuple_model:
   When a blasted tuple leaf has no value in the trail model --
   typical for unconstrained tuple components once the keep_subst=0
   path disables the alias-based default-completion in
   eval_uninterpreted -- fall back to vtbl_make_object on the leaf's
   declared type rather than dropping the parent tuple atom. Only
   truly uninhabitable types (should not occur in well-formed input)
   still take the drop path, now with a more accurate trace message.

Regression test test_partial_tuple_model_no_keep_subst: asserts
(= (select x 1) 5) over x : (tuple int int), calls
yices_get_model(ctx, 0), and checks that component 1 evaluates to 5
and the whole tuple reconstructs. Pre-fix debug builds hit the
has_alias assertion; release builds silently lost the x mapping and
reported (select x 1) = 0.
The previous header claimed the .ys file covered ARITH_CEIL alongside
ARITH_FLOOR / ARITH_IS_INT_ATOM / ARITH_ABS, but no ceil assertion was
present. The original assumption was that mcsat's nra plugin could not
solve any (= (ceil x) k) constraint and so ceil could only be reviewed
for dispatch by inspection.

That assumption is wrong: mcsat *does* route and answer ceil queries.
Separately, mcsat's nra plugin has a real semantic bug where (ceil x)
is treated like (floor x) for a range of rationals (e.g. it answers
sat for `(ceil 7/2) = 3` and unsat for `(ceil 7/2) = 4`). That bug is
unrelated to the tuple-blast dispatch fix in 9fefa45 and out of scope
for this PR.

So we add a deliberately *lax* ceil constraint over a tuple component
-- (<= (ceil (select x 2)) 100) -- which holds under both the correct
and the buggy interpretation. This exercises the ARITH_CEIL dispatch
arm end-to-end without depending on ceil's exact value, restoring the
header's accuracy. Update the matching note in mcsat_tuples.c to point
at the .ys file's header for the ceil-semantics caveat.
@disteph disteph force-pushed the mcsat-tuples-support branch from 857eb72 to 995ec36 Compare May 7, 2026 22:13
@disteph
Copy link
Copy Markdown
Collaborator Author

disteph commented May 7, 2026

Code review by OpenCode/GPT5.5 about commit 995ec36:

Findings No current findings.

The prior issues are addressed:

H1 dispatch gap: fixed for missing term kinds.
H2 stale memo caches: reset on GC.
M1 lambda-residue risk: now covered by naked function-component interpolant regression.
M2 partial tuple model with keep_subst=0: source fix plus regression.
Ceil note: regression now actually exercises ARITH_CEIL dispatch with a lax constraint.
Verified:

mcsat_tuples passes in debug.
tests/regress/mcsat/tuples passes: 11/11.
git diff --check HEAD~3..HEAD clean.
Residual risk: the known MCSAT ceil semantic bug is real but out of scope for this PR.

@disteph
Copy link
Copy Markdown
Collaborator Author

disteph commented May 7, 2026

Summary of changes since Code review of 7a3358d:

Branch summary: mcsat-tuples-support, H2 onward

Five commits, currently 995ec36d..42bd5656^. All target MCSAT's tuple-blasting infrastructure and its model-reconstruction edges.

42bd5656 — H2: reset tuple-blast memo caches on GC

The preprocessor maintains three opportunistic memoization caches keyed on raw term/type IDs (type_is_tuple_free_cache, term_has_tuples_in_subdag_cache, term_kind_tuple_classification_cache). The original code assumed those IDs were stable for the lifetime of the table. They are not — Yices recycles term and type IDs after a GC sweep (indexed_table_free under src/terms), so a freshly recycled ID can pick up a cached classification belonging to the freed term, which then drives the iterative tuple-blast dispatch down the wrong arm.

Fix: clear the three opportunistic memos at the start of preprocessor_gc_mark. The tuple_blast_map / preprocess_map / purification_map are kept — their entries are explicitly marked alive, so they survive GC by construction.

Includes a regression test that forces a GC between two preprocessing rounds with overlapping ID space.

99293ae0 — H1: tuple-blast dispatch for missing term kinds

term_has_tuples_in_subdag did not descend through several unary/binary arithmetic kinds (ARITH_IS_INT_ATOM, ARITH_FLOOR, ARITH_CEIL, ARITH_ABS, ARITH_FF_EQ_ATOM, ARITH_FF_BINEQ_ATOM) and the bit-extraction kind BIT_TERM. Result: (<op> (select x i)) was misclassified as tuple-free, and an un-blasted SELECT reached the legacy preprocessor, producing a formula not supported error.

Fix: add dispatch entries in both the descent helper and the rebuild loop, so the operator is rebuilt over the blasted operand.

Coverage:

  • BIT_TERM over a tuple-component bv: exercised at the C-API level in tests/api/mcsat_tuples.c (test_tuple_blast_bit_over_tuple_component).
  • ARITH_FLOOR / ARITH_IS_INT_ATOM / ARITH_CEIL / ARITH_ABS: exercised at the .ys level in tests/regress/mcsat/tuples/tuple_unary_arith.ys.
  • ARITH_FF_EQ_ATOM / ARITH_FF_BINEQ_ATOM: cannot be exercised end-to-end (FF arithmetic isn't reachable from the .ys or SMT2 frontends combined with tuples). Reviewed by inspection against the binary-arith dispatch shape, which is structurally identical.

51121a26 — M1: regression test for naked function-valued tuple component

Pure guard-rail test, no source change. Pins the property that interpolants over function-valued tuple components remain lambda-free, so future changes that start producing naked (lambda (v0) ...) residues on the (b) path (multi-leaf function ranges where the per-slice lambda body is non-eta-reducible) will fail this test rather than user-facing call sites.

The "naked" path is currently doubly guarded:

  • Single-leaf function ranges: mk_lambda eta-reduces (lambda (v0) (f v0))f at construction.
  • Multi-leaf function ranges: MCSAT interpolation in this configuration returns the original asserted formula rather than a leaf-level rewrite, so unblast never substitutes the leaf inside the interpolant.

Test asserts (= fx fy) against (distinct fx fy) over a function whose range is itself a tuple, then runs assert_interpolant_has_no_lambda_residue on the result.

6c13fcf2 — M2: preserve tuple atoms under keep_subst=0 model builds

Two related fixes in preprocessor_build_model and preprocessor_build_tuple_model, both triggered when yices_get_model(ctx, 0) builds a public model with has_alias=false.

  1. preprocessor_build_model previously called model_add_substitution unconditionally for every solved equality. With keep_subst=0 that asserts has_alias in debug builds and silently drops the binding in release. Fix: split on model->has_alias. When false, lazily initialize an evaluator, evaluate eq_subst to a concrete value, and model_map_term(eq_var, v). Falls back to vtbl_make_object if eval_in_model returns an error (e.g., eq_subst transitively depends on another not-yet-mapped eq_var).

  2. preprocessor_build_tuple_model previously dropped a tuple atom whenever any blasted leaf had no trail value — typical for unconstrained tuple components, made fatal by keep_subst=0 disabling the alias-based default-completion in eval_uninterpreted. Fix: fall back to vtbl_make_object on the leaf's declared type so the parent tuple atom is reconstructed instead of dropped. Truly uninhabitable types (shouldn't occur in well-formed input) still take the drop path, with a more accurate trace message.

Includes test_partial_tuple_model_no_keep_subst, which asserts (= (select x 1) 5) over x : (tuple int int), builds the model with keep_subst=0, and checks both that component 1 = 5 and that the whole tuple reconstructs.

995ec36d — actually exercise ARITH_CEIL dispatch in tuple_unary_arith.ys

Doc-and-test cleanup, no source change. The original tuple_unary_arith.ys header claimed it covered ARITH_CEIL but no ceil assertion was present, on the (incorrect) assumption that "mcsat's nra plugin cannot solve any (ceil x) = k."

In fact mcsat does route and answer ceil queries — but answers are wrong: for non-integer rationals it computes floor instead of ceil. That's a separate, pre-existing semantic bug in src/mcsat/preprocessor.c (the ARITH_CEIL arm builds arith_floor after purification — a 2016 copy-paste from the FLOOR arm). It is out of scope for this PR; will be filed as a separate issue.

To still exercise ARITH_CEIL dispatch routing here, the test asserts a deliberately lax (<= (ceil (select x 2)) 100) constraint that holds under both the correct and the buggy interpretation. The matching note in tests/api/mcsat_tuples.c was updated to point at the .ys header for the ceil-semantics caveat.

Pipeline status

  • Local: make MODE=debug check-api 31/31 pass; make MODE=release check-api 31/31 pass. All 11 tests/regress/mcsat/tuples/*.ys regressions pass. Full make MODE=debug check has 2 unrelated pre-existing failures (set-timeout.smt2 flaky timing, wd/issue401b.smt2 60s timeout) that reproduce on the previous 7a3358d6 tip without these commits.
  • CI: -Werror=comment build-break in the original H2 was fixed in place via git rebase --autosquash and force-pushed; H2 now builds clean with strict comment checking.

@disteph disteph force-pushed the mcsat-tuples-support branch from f3417f5 to b450750 Compare May 8, 2026 05:25
@disteph
Copy link
Copy Markdown
Collaborator Author

disteph commented May 8, 2026

Review of b4507500 by Windsurf/Opus4.7 — mcsat: avoid evaluating unmapped tuple-blast leaves

What it does. Seven added lines in preprocessor_build_tuple_model. Per blasted leaf, try a direct model_find_term_value first, and only fall through to model_get_term_value (which can invoke eval_in_model) when the evaluator has a chance of producing a value: either an alias table is present, or the leaf isn't an unmapped UNINTERPRETED_TERM. Otherwise we keep v == null_value and let the existing vtbl_make_object fallback mint a well-typed default.

Root cause it fixes. test_partial_tuple_model_no_keep_subst builds a tuple-typed uninterpreted x, constrains only the first component, and calls yices_get_model(ctx, /*keep_subst=*/0). The unconstrained second leaf is never assigned during search; the resulting model has has_alias == false. The old code therefore entered eval_uninterpreted and took the MDL_EVAL_UNKNOWN_TERM exit via longjmp to the setjmp in eval_in_model. That control flow is correct and longstanding (the evaluator has used setjmp/longjmp for unevaluable terms since 2010/2013; nothing new in this PR), but on windows-latest | release | --enable-thread-safety --enable-mcsat the unwind itself crashes — empirically requiring all three of -O3, -fomit-frame-pointer, and -DTHREAD_SAFE. That's a known-fragile mingw-w64 SEH-based setjmp/longjmp interaction, not a Yices bug. The patch sidesteps it on the only path the API tests reach it.

Why this shape. Semantics-preserving (the v < 0 branch already minted defaults for this case), minimal (+7/-2 in one file), localized to the new tuple-blast model build, and a net perf wash on the common path (the extra lookup replaces the one model_get_term_value would have done internally before falling through to the evaluator).

Suggested follow-up (separate issue). Other Yices entry points that reach eval_in_model on unevaluable terms (free vars, lambdas, quantifiers, algebraic atoms) remain vulnerable on the same MinGW toolchain. The robust fixes are (a) build Windows release with -fno-omit-frame-pointer, or (b) replace the setjmp/longjmp channel in model_eval.c with explicit error returns. Out of scope here.

LGTM.

The comment claimed the substitution runs 'before popping the temporary
context frame', but the caller in context_solver.c pops first, then
calls this helper. Rewrite the comment to match the actual ordering and
spell out why the temporary labels are still valid post-pop (mcsat_pop
does not invoke term_table_gc).

No behaviour change.
@disteph
Copy link
Copy Markdown
Collaborator Author

disteph commented May 8, 2026

Code review for entire PR by Codex/GPT5.5:

Findings
No correctness, API compatibility, or merge-blocking issues found in HEAD (9f29aab3) compared with master (7878664).

Summary
The PR adds MCSAT tuple support by tuple-blasting public tuple/function terms into scalar leaves inside src/mcsat/preprocessor.c, then reconstructing public-facing models and interpolants before exposing them through the API. The solver-side interpolant contract is explicit: internal conflict-analysis interpolants are unblasted before storage, while assumption-label substitution is handled by mcsat_set_unsat_result_from_labeled_interpolant.

The test coverage is substantial for the changed behavior: API tests cover tuple/function models, tuple selectors under arithmetic/BV contexts, push/pop scoping, GC, keep_subst=0 model reconstruction, and interpolation cases including function-valued tuple components. The regression tests add frontend coverage for tuple domains/ranges, distinct, update, nested ITE, selectors, bit selection, model display, and unary arithmetic dispatch.

Tests Run

  • make -s check-api

Result: 31 passed, 0 failed.

@disteph disteph removed the request for review from karthiknukala May 8, 2026 06:47
@disteph disteph merged commit 9f79474 into master May 8, 2026
32 checks passed
@ahmed-irfan ahmed-irfan deleted the mcsat-tuples-support branch May 9, 2026 04:40
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.

3 participants