Add (speculate ...): test an instantiation hypothesis in one scope - #14
Merged
Merged
Conversation
A caller can now try, in the current user context only, one hypothesis
about quantifier instantiation and read what it did after check-sat:
(speculate :instantiate <qid> ((<var> "<term>")+)) directed instance
(speculate :trigger <qid> ((<var> <sort>)+) ("<term>"+)) extra trigger
(speculate :block <qid> "<fingerprint>") refuse matching instances
(speculate :observe) change nothing, only watch for loops
(get-info :speculation)
Everything a hypothesis installs (the hypothesis, directed-instance
bookkeeping, speculative triggers, blocks) lives in the user context and
is released on pop. Instances it makes carry the new inference id
QUANTIFIERS_INST_LLM_DIRECTED. Terms are strings parsed one at a time, so
an unreadable term fails the command with an error instead of ending the
solver. While a hypothesis is active, each check tracks per quantifier the
rounds in which its deepest instantiating term got deeper; formulas with
at least :loop-threshold such rises are reported as loops.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The entailment skip and the block exemption keyed off the inference id, which a speculative trigger's matches share with the directed instance. The justification (offered once per user context) only holds for the directed instance; a trigger's matches come back every round. Key both off Speculation::isDirecting, set while the directed instance is added. A regress case blocks a speculative trigger's match. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A round whose lemmas came from a speculative hypothesis returned from QuantifiersEngine::checkInternal before d_numInstRoundsLemma and notifyEndRound, so --inst-max-rounds never counted it: a speculative trigger that feeds itself ran until a resource limit. Count the round. A block refuses instances as a duplicate would be refused, and nothing marked the check incomplete, so under --finite-model-find a block turned an unsat query into sat. Instantiate::checkComplete now reports QUANTIFIERS_SPECULATIVE_BLOCK when a block refused anything in the check. A directed term was only rewritten, so a variable preprocessing had eliminated, or a define-fun application, named a term unrelated to the assertions: the instance applied and changed nothing. Apply the top-level substitutions first. Also: a leaf constant such as (- 1) or (/ 1 2) matches the fingerprint text the printer gives it; :quantifiers counts distinct formulas and :bodies and :materialized list each once after a nested pop; the report before any check shows the default loop threshold, not 0. Five regressions and an API unit test cover these. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts: # src/smt/solver_engine.cpp # src/theory/quantifiers/instantiate.cpp
…erms A round whose lemmas came from a hypothesis returned before any strategy ran, so a speculative trigger that feeds itself kept every other formula from being instantiated. A query the :materialized formula proves unsat ran until a resource limit, or answered unknown under --inst-max-rounds. Hypotheses now apply at the start of the standard effort, the one e-matching runs at. Their lemmas stay pending while the strategies of that round run, are sent with theirs, and the ordinary path counts the round. Replay-only mode, where no strategy runs, keeps its own call. A check can now end before any round reaches e-matching, so no-quantifier is reported only for a hypothesis that was applied, and pending otherwise. Solver::speculateInstantiation rejects terms with free variables, which reached TheoryEngine::lemma, and speculateTrigger requires bound variables and a pattern whose free variables are among them. Drop speculation.cpp's copy of quoteString. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts: # src/smt/solver_engine.cpp # src/theory/quantifiers/instantiate.cpp
The quantifiers rewriter dropped a formula's whole pattern list, :qid included, once a rewrite removed one of its variables, so a hypothesis about the formula reported no-quantifier. The patterns still go; a :qid attribute stays. No other attribute does. speculate in a logic without quantifiers is now a recoverable error. A variable named twice is an error. A directed instance that simplifies to true says so. A fingerprint application may be headed by an indexed operator. SpeculateCommand prints its terms as the input spelled them, so the dump tester runs on every speculate regression. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…oping A directed term "5" for a real variable was a mismatch; it now means 5.0, as it would in an SMT-LIB term. A real term for an integer variable is still a mismatch. New regression speculate-instantiate-real. Miniscoping still drops the :qid of a formula without a pattern that it splits, so a hypothesis about it gives no-quantifier. That is documented on speculateInstantiation and the no-quantifier status rather than changed: the proof rules rebuild the parts without attributes, and parts that shared one name would merge in the reports keyed by :qid. Also drop Speculation::d_envRef, which duplicated EnvObj::d_env. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements the solver side of
plans/speculative_probe.md: a caller tries one hypothesis about quantifier instantiation in the current user context, checks, and reads back what the hypothesis did. The push/pop around it is the fork; nothing of it survives the pop.Commands
Terms are string literals, each parsed on its own (the
import-instantiationslambdas became sharedtryParseString/unquoteStringhelpers). A term naming an undeclared symbol fails the command with(error "speculate: cannot parse ...")and the session goes on; nothing is left active. The outer syntax is a command like any other: malformed, it is a parse error, as(assert)would be, so a driver must spell it exactly and pass only the terms through as strings.The reply lists each hypothesis with a status (
applied,rejected,mismatch,unusable,no-quantifier,pending: no round of the scope has reached e-matching yet), a reason, the instances it made (in original form), and for:triggerthe formula with the pattern as its only one (:materialized). It also lists loops, see below. A hypothesis's counts and lists cover every check of its scope so far (:quantifierscounts distinct formulas);:roundsand:loopscover the last check.Design, and where it departs from the plan
theory/quantifiers/speculation.{h,cpp}, owned byInstantiate. Hypotheses, the per-formula "applied" set and the speculative triggers are all user-context-dependent (CDList,CDHashSet). The triggers are owned by thatCDList, not aTriggerDatabase, so a pop frees them along with the formulas they hold. AContextNotifyObjclears the qid and depth caches on pop. Keeping popped nodes alive shifts later node ids, and with them the search; that resource drift was seen in the nl-frontier work.InferenceId::QUANTIFIERS_INST_LLM_DIRECTED. Directed instances and speculative-trigger matches carry it (Trigger::setInferenceId), so a leaked one is easy to find.Speculation::isDirecting, set only while the directed instance is added, not by the inference id.(= a b)), or adefine-funapplication, means what it did in the input.:instancesshows the term after substitution.QuantifiersEngine::applySpeculationruns at the start ofQEFFORT_STANDARD, the effort e-matching runs at, ahead of that round's strategies. Its lemmas stay pending while the strategies run and are sent with theirs, so a speculative trigger is matched in the same round as the formulas' own triggers, as the:materializedformula's would be. The ordinary path counts the round, so--inst-max-roundsbounds a speculative trigger that feeds itself. Directed instances run once per formula; triggers match every round. Conflict-based instantiation still runs first, so a check it closes never applies the hypotheses (statuspending). In replay-only mode no strategy runs, and the hypotheses apply before the replay-only return.speculateInstantiationrejects a term with free variables;speculateTriggerrequires bound variables and a pattern whose free variables are among them. The SMT-LIB parser cannot produce either._,_nand#nmatch any term (repeated names must match equal terms), written in the vocabulary of:matching-loops':step; a leaf constant matches the text the printer gives it,(- 1)and(/ 1 2)included. The directed instance of an:instantiatehypothesis is never refused. A check in which a block refused an instance answersunknown, notsat(newIncompleteId::QUANTIFIERS_SPECULATIVE_BLOCK): its model may violate the formulas the block kept from being instantiated.:observeincluded), each check records, per quantified formula, the rounds in which its deepest instantiating term got deeper than in any earlier round. A formula with at least:loop-thresholdsuch rises (default 5) is reported as a loop. Comparing an:observecheck with a hypothesis check tells whether the hypothesis introduced one.d_recordedInstis not used. As Add (get-info :matching-loops): self-feeding quantifiers of the last check #3 and Record the instantiation graph: --inst-graph and (get-instantiation-graph) #4 found, it only holds CEGQI's recorded instances.Testing
regress0/quantifiers/speculate-*.smt2. They cover scoping (the scope after a probe is unknown again and nothing is active), mismatches, unusable patterns, unknown qids, parse errors, a loop observed and then cut by a block, a block that stops a proof, a block refusing a speculative trigger's match,--inst-max-roundsstopping a self-feeding speculative trigger (speculate-rounds), a block under--finite-model-findansweringunknown(speculate-block-model), directed terms through an eliminated variable and adefine-fun(speculate-instantiate-subst), constant fingerprints (speculate-block-const), the report before any check and after a nested pop (speculate-report), a self-feeding speculative trigger that no longer keeps an ordinary trigger from closing the goal in the same round (speculate-trigger-round,--no-cbqi, next to the:materializedformula), and a check closed by conflict-based instantiation leaving the hypothesispending(speculate-pending). From the fourth round: a formula found by its:qidafter variable elimination (speculate-varelim), therejectedreasons (speculate-rejected), an indexed-operator fingerprint (speculate-block-indexed) and a logic without quantifiers (speculate-qf). From the fifth round: integer terms for real variables (speculate-instantiate-real). The dump tester runs on all sixteen.TestApiBlackSolver.speculate: the fourSolver::speculate*functions, a directed instance closing a goal, the report before and after the pop, and the argument errors, including a directed term and a pattern with a free variable.unrestricted --assertions), after the third review change and the merge of main (Add (get-info :branch-profile) for counterfactual twins #11, Clear pending quantifier lemmas when a check is interrupted #15): allregress0/regress1quantifier and nl regressions pluspending_lemmas_after_interrupt, 718/718;api_solver_black131/131;git clang-formatclean.da4b2b0073), same build type: all ofregress0, 2730/2730, and theregress1quantifier, nl and proof regressions, 363/363, every tester included;api_solver_black131/131. Neutrality of the rewriter change, over the 710regress0–regress2quantifier and nl files against the build just before it: every verdict is identical;resource::resourceUnitsUseddiffers on 5. Two vary from run to run on the new build (quant-wf-int-ind 877/878, ufdt-solve-model-built 1162/1163). Three move by a fixed amount over five runs, each with a:qidin its input (issue5373-2 1213 to 1216, issue6638-sygus-inst 2197 to 2206, cee-event-wrong-sat 11711 to 11729), consistent with a formula that keeps its name being a different node.regress0/regress1quantifier files against a local build of the same base (c893833): every verdict is identical;resource::resourceUnitsUsedis equal on 426 files. The 3 others (ho-seu-sygus-inst, ufdt-solve-model-built, infer-arith-trigger-eq) vary from run to run on the base build itself (e.g. 55815/61099, 1162/1163, 134702/134722/135177), and the branch build lands on the same values. The second review change touches only code that runs while a hypothesis is active (the round count sits in the speculation branch,hasBlockedis false without a block, the substitution applies to directed terms only). So does the third:applySpeculationis called only while a hypothesis is active, and the replay-only branch is unchanged without one.Review changes
instantiate.cpp,speculation.{h,cpp}: the entailment skip and the block exemption applied to everyQUANTIFIERS_INST_LLM_DIRECTEDinstantiation, so a speculative trigger's matches were neither filtered as entailed nor refused by a block, unlike the real trigger the probe stands for. Both now key offSpeculation::isDirecting();isBlockedlost itsInferenceIdparameter.speculate-trigger.smt2: one more scope with a:triggerand a:blockon the same formula; the match is refused each round it is offered.Second round, from probing the branch:
quantifiers_engine.cpp: the speculation branch returned befored_numInstRoundsLemma++andnotifyEndRound(), so--inst-max-roundsnever counted its rounds and a self-feeding speculative trigger ran until a resource limit (seen: over 90 s with--inst-max-rounds=12). The round now counts.instantiate.cpp,incomplete_id.{h,cpp}: a block refuses as a duplicate would be refused and nothing marked the check incomplete, so--finite-model-findansweredsaton an unsat query.checkCompletenow reportsQUANTIFIERS_SPECULATIVE_BLOCK.speculation.cpp: directed terms were only rewritten; an eliminated variable or adefine-funterm gave an unrelated instance that still reportedapplied. They now go through the top-level substitutions.speculation.{h,cpp},solver_engine.cpp: constant fingerprints match;:quantifiers,:bodiesand:materializedno longer repeat after a nested pop; the report before any check shows the default threshold, not 0.Third round:
quantifiers_engine.{h,cpp}: a round whose lemmas came from a hypothesis returned before any strategy ran, so a speculative trigger that feeds itself kept every other formula from being instantiated. A query the:materializedformula provesunsatat once ran until killed (30 s), or answeredunknownunder--inst-max-rounds=20. Hypotheses now apply inside the strategy loop, see Design.speculation.{h,cpp}:no-quantifierwas inferred fromrounds > 0; a check can now end before a round reaches e-matching, so it needs the hypothesis to have been applied (d_considered), and ispendingotherwise.cvc5.cpp:speculateInstantiationaccepted a term with a free variable, which reachedTheoryEngine::lemma(Check failure !expr::hasFreeVar);speculateTriggernow also checks its variables and pattern.speculation.cpp: dropped a local copy ofquoteString(util/smt2_quote_string.hhas it); the unit test usesTermManager::mkConst.Fourth round:
quantifiers_rewriter.cpp: when a rewrite changes a formula's variables (variable elimination, prenexing), the rewriter dropped the whole pattern list, and the:qidwith it, so every hypothesis about such a formula reportedno-quantifier. The patterns still go, since they may mention a variable that is gone, but a:qidattribute stays. No other attribute does: some (fun-def, sygus, the prenex id) mark the formula for a procedure whose shape the rewrite need not keep. A pattern list holding only a:qidis what(! body :qid q)already gives, so no rewrite or strategy meets a new shape; proof reconstruction of variable elimination already declines formulas with a pattern list. New regressionspeculate-varelim. Its two formulas have different bodies: an alpha-equivalent formula is registered once whatever its:qid(--quant-alpha-equiv), so the second one's:qidfinds nothing. That is base behaviour, now documented onspeculateInstantiation.solver_engine.cpp:speculatein a logic without quantifiers was a fatal error that ended the session; it is now recoverable, like a term that does not parse (speculate-qf).speculation.cpp: a variable named twice, in:instantiateor:trigger, is an error (the first term used to win silently); a directed instance that simplifies totrueisrejectedwith that reason, not "a lemma already sent" (speculate-rejected, which also covers "made already"); a fingerprint application may be headed by an indexed operator such as(_ extract 3 0)(speculate-block-indexed).commands.{h,cpp},smt2_cmd_parser.cpp:SpeculateCommandkeeps each term as the input spelled it and prints that, so a command whose term failed to parse dumps as it was read. The dump tester now runs on everyspeculate-*regression.Fifth round:
speculation.cpp: an integer term for a real variable was amismatch("the term for y has sort Int, and y has sort Real"), so"5"could not stand for5.0. It now means its real, as in an SMT-LIB term; a real term for an integer variable is still amismatch(speculate-instantiate-real).cvc5.h,speculation.h: miniscoping still drops the:qid. A formula without a:patternthat it splits, such as(forall ((x Int) (y Int)) (or (P x) (R y))), givesno-quantifier;--miniscope-quant=offkeeps it whole. This is documented, not changed. The proof rules rebuild a miniscoped formula's parts without attributes (QUANT_MINISCOPE_ANDand the others), so parts that kept the name would no longer match their elaboration. The parts would also all carry one name, which the reports that key by:qidwould merge. A formula with a:patternis not split, and Verus gives every quantifier one.speculation.{h,cpp}: droppedd_envRef, which duplicatedEnvObj::d_env.The fourth round's
:qidchange is visible in output, beyond the verdicts and resource units above. A formula the rewriter changed now keeps its name in every report that names formulas. Before,--dump-instantiationsprinted the rewritten formula,(get-info :inst-pressure)reported it asquant_0 :named false, and the instantiation graph as_. Now all three print the:qid. The same holds for the formula'snum-instantiations,:matching-loopsand:branch-profilerows.--check-proofsstill passes on such a formula, with no trust step.After the fifth round (
cb8ffefd41), same build type: theregress0/regress1quantifier regressions, 445/445, every tester included;api_solver_black131/131;git clang-formatclean. The fifth-round code runs only while a hypothesis is active (the integer-to-real conversion sits in the directed-instance path), so the neutrality sweep above still applies.Verus against this build, on two trees: Verus main (
da8fa532e), and verus#36 merged with main at88e6566(the Final preparations for arithmetic for building with libc++. cvc5/cvc5#33 merge that main has since taken conflicts with verus#36 inresident.rs, so that tree stops short of it). The resident harness cannot turn the in-session(get-info :version)check off, so the build ran behind a test-only wrapper that rewrites its version answer to the pinned1.3.5.dev+main@79e8830; the solver is otherwise unchanged. Main:air212/212 (with this cvc5 asVERUS_CVC5_PATH),rust_verify --lib42/42,rust_verify_test --test resident --include-ignored50/50,--test difficulty3/3; the same counts as with the pinned release. verus#36:air214/214,rust_verify --lib46/46,resident52/52,difficulty3/3; with the pinned release,residentis 50/52, the two failures being thespeculatetests (resident_speculate_probes_and_leaves_the_session_unchanged,resident_speculate_reads_terms_at_the_goal), which need this PR.Overlap
Merged with main after #11, #12, #13 and #15. The #11 merge had two conflicts:
isValidGetInfoFlagaccepts bothspeculationandbranch-profile, andInstantiate::presolvetakes #11's pressure carry beforeSpeculation::presolve(). This branch also adds anIncompleteId.The Verus resident
speculaterequest (BasisResearch/verus#36) and the MCPspeculative_probetool (BasisResearch/verus-tools-mcp#36) use this. verus#36 reads:instances, which for a directed term now shows it after substitution. Its AIR doc forpending("no instantiation round ran") should now read "no round reached e-matching". After the fourth round, a formula whose variable the rewriter eliminated is found by its:qid, sono-quantifierthere now means the:qidis absent, belongs to an alpha-equivalent duplicate, or names a formula without a:patternthat miniscoping split.The named reports change what AIR reads for formulas the rewriter changed; none of its parsers needs a change.
parse_provenance_linestakes(instantiations <atom> ...)only, and sent a formula printed in full tounparsed, so provenance used to drop these formulas' instances; it now counts them under their:qid. The:inst-pressureand:branch-profilerows that werenamed: false,quant_<n>, now carry their:qidand map to source;twin.rskeys rows by(qid, named), so a twin comparing runs of cvc5 from before and after this change would see these rows move. The instantiation graph's_unnamed_<i>nodes become named ones.🤖 Generated with Claude Code