aprover / bmc_agent /assert_driven_specs.py
theyoucheng's picture
Deploy AProver demo
ab54eb4 verified
Raw
History Blame Contribute Delete
52 kB
"""Assertion-driven spec synthesis.
Mode: given a program annotated with ``//@ assert`` clauses, synthesize the
function POSTCONDITIONS that make every assertion provable β€” and refine them
when an assertion does not yet hold. The asserts are the GOAL; the function
contracts are the knobs.
Two CBMC checks bound the loop (CBMC is the oracle, the LLM proposes):
* SUFFICIENCY β€” do the asserts hold when each callee is replaced by an
``__CPROVER_assume(<postcondition>)`` stub? (compositional: the caller is
proved against the callee CONTRACTS, not their bodies). If not, the
implicated postcondition is too weak.
* SOUNDNESS β€” is a proposed postcondition actually implied by the callee's
BODY (nondet inputs)? This is what stops the LLM from "satisfying" a false
assert by inventing a postcondition the code doesn't honour. If no SOUND
postcondition makes an assert hold, the assert itself is false β€” reported,
not papered over.
The LLM (agentic) builds the stubbed sufficiency harness and proposes stronger
postconditions; CBMC decides. Loop until all asserts hold or a fixpoint /
iteration cap is reached.
"""
from __future__ import annotations
import re
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
from bmc_agent.cbmc import run_cbmc
from bmc_agent.dsl_to_cbmc import _looks_like_c_expr
from bmc_agent.llm import LLMClient, agentic_system_prompt
from bmc_agent.logger import get_logger
logger = get_logger("assert_specs")
_ACSL_ASSERT = re.compile(r"//@\s*assert\s+(.+?)\s*;", re.IGNORECASE)
_CALL_RE_TMPL = r"\b{name}\s*\("
# Verification goals are INPUTS (per the Specification Synthesis Problem): the
# executable goal forms the program already contains, plus the ACSL comment form.
# assert(E); static_assert(E[, "msg"]); __VERIFIER_assert(E); //@ assert E;
_GOAL_CALL = re.compile(
r"\b(?:__VERIFIER_assert|static_assert|_Static_assert|assert)\s*\(", re.IGNORECASE)
def _function_has_goal(body: str) -> bool:
"""True iff a function body holds a verification goal (//@ assert / assert(...) /
static_assert / __VERIFIER_assert)."""
return bool(_ACSL_ASSERT.search(body or "") or _GOAL_CALL.search(body or ""))
def _resolve_entry(parsed, entry: str) -> str:
"""Pick the function the asserts actually live in. The asserts are the proof
target, so the entry must be the function that CONTAINS them. If the given/default
entry already holds a goal (or doesn't exist while exactly one other function
does), keep/switch accordingly β€” so a bare run on a program whose asserts sit in
`foo` (not `main`) targets `foo` instead of silently verifying nothing. Only
switches when the current entry bears NO goal AND exactly one function does, so an
explicit, correct --entry is always respected and ambiguity never guesses."""
bodies = getattr(parsed, "function_bodies", None) or {}
if entry in bodies and _function_has_goal(bodies[entry]):
return entry
bearers = [fn for fn, b in bodies.items() if _function_has_goal(b)]
if len(bearers) == 1 and bearers[0] != entry:
logger.info("assert-synth: entry %r bears no goal; using goal-bearing "
"function %r", entry, bearers[0])
return bearers[0]
return entry
def _balanced_arg(source: str, open_paren: int) -> tuple[str, int]:
"""Return (arg_text, index_after_close) for the parenthesised argument list
starting at ``source[open_paren] == '('``. Paren-balanced so nested calls and
commas inside the expression are handled; respects char/string literals."""
depth, i, n = 0, open_paren, len(source)
quote = None
while i < n:
ch = source[i]
if quote:
if ch == "\\":
i += 2; continue
if ch == quote:
quote = None
elif ch in "\"'":
quote = ch
elif ch == "(":
depth += 1
elif ch == ")":
depth -= 1
if depth == 0:
return source[open_paren + 1:i], i + 1
i += 1
return source[open_paren + 1:], n
def _strip_assert_message(arg: str) -> str:
"""`static_assert(cond, "msg")` β†’ `cond`. Drop a trailing string-literal
message argument at top-level (depth 0), keep the condition expression."""
depth, i, n = 0, 0, len(arg)
quote = None
last_top_comma = -1
while i < n:
ch = arg[i]
if quote:
if ch == "\\":
i += 2; continue
if ch == quote:
quote = None
elif ch in "\"'":
quote = ch
elif ch in "([{":
depth += 1
elif ch in ")]}":
depth -= 1
elif ch == "," and depth == 0:
last_top_comma = i
i += 1
if last_top_comma >= 0 and '"' in arg[last_top_comma:]:
return arg[:last_top_comma].strip()
return arg.strip()
def extract_goals(source: str) -> list[str]:
"""All verification-goal expressions (INPUTS) in source order, de-duplicated:
executable ``assert``/``static_assert``/``__VERIFIER_assert`` plus the ACSL
``//@ assert`` comment form. The goals are what S must let the verifier prove;
they are NOT synthesis targets."""
goals: list[str] = []
for m in _GOAL_CALL.finditer(source):
arg, _ = _balanced_arg(source, m.end() - 1)
expr = _strip_assert_message(arg)
if expr:
goals.append(expr.strip())
goals.extend(extract_asserts(source)) # //@ assert E;
seen, out = set(), []
for g in goals:
if g not in seen:
seen.add(g); out.append(g)
return out
@dataclass
class SynthResult:
ok: bool
iterations: int
postconditions: dict = field(default_factory=dict) # callee -> postcondition
preconditions: dict = field(default_factory=dict) # callee -> precondition
failing_asserts: list = field(default_factory=list) # asserts still unprovable
asserts: list = field(default_factory=list)
entry: str = "" # the entry function actually used (after auto-resolution)
note: str = ""
# No verification goal in the program (no //@ assert / assert / __VERIFIER_assert).
# Distinct from ok: there is NOTHING to prove, so the run is N/A β€” reporting it as
# SATISFIED would be a vacuous pass. ok is forced False so it is never counted as a
# pass and the oracle-confirmation step is skipped.
no_goals: bool = False
def extract_asserts(source: str) -> list[str]:
"""Return the list of ``//@ assert`` expressions (in source order)."""
return [m.group(1).strip() for m in _ACSL_ASSERT.finditer(source)]
def called_functions(source: str, defined: list[str]) -> list[str]:
"""Which defined functions are actually called in the source (call sites)."""
return [fn for fn in defined if re.search(_CALL_RE_TMPL.format(name=re.escape(fn)), source)
and re.search(rf"\b\w[\w\s\*]*\b{re.escape(fn)}\s*\([^;]*\)\s*\{{", source)]
def callee_lhs_map(entry_src: str, callees: list[str]) -> dict:
"""Map each callee -> the LHS variables it is assigned to at its call sites.
Parses ``[type] lhs = callee(...)`` so a failing assert can be traced back to
the callee whose return value flows into it. Order-preserving, de-duplicated.
"""
m: dict = {}
for c in callees:
lhs = re.findall(rf"(\w+)\s*=\s*{re.escape(c)}\s*\(", entry_src or "")
if lhs:
m[c] = list(dict.fromkeys(lhs))
return m
def attribute_assert(expr: str, lhs_map: dict, callees: list[str]) -> list[str]:
"""Callees implicated by a failing assert, most-likely first.
A callee is implicated if one of its call-site LHS variables appears as an
identifier in the assert expression (its return value flows into the assert).
Implicated callees come first (source order); the remaining callees follow as
fallbacks, so refinement still progresses when attribution is empty/ambiguous.
"""
words = set(re.findall(r"\b\w+\b", expr or ""))
hit = [c for c in callees if any(v in words for v in lhs_map.get(c, []))]
rest = [c for c in callees if c not in hit]
return hit + rest
_BUILD_HARNESS_SYS = (
"You are a CBMC harness engineer doing COMPOSITIONAL verification. You output "
"ONLY a self-contained C harness in a single fenced ```c block."
)
_BUILD_HARNESS_PROMPT = """\
Build a CBMC harness that proves the `//@ assert` clauses of the entry function
`{entry}` COMPOSITIONALLY β€” i.e. each call to a contracted callee is replaced by
its CONTRACT, not its body.
For every call `lhs = {callee}(args);` to a contracted function, replace it with:
/* the caller must ESTABLISH the callee's precondition */
__CPROVER_assert(<the callee's PRECONDITION, parameters := actual args>, "pre: <P>");
lhs = <nondet of lhs's type>;
/* then the caller may ASSUME the callee's postcondition */
__CPROVER_assume(<the callee's postcondition, with `result` := lhs and the
callee's parameters := the actual argument expressions>);
(If the call has no lhs, just emit the assume with result unconstrained. If the
precondition is `true`/empty, the assert may be omitted.)
Translate each `//@ assert E;` in `{entry}` to `__CPROVER_assert(E, "assert: E");`.
Keep all of `{entry}`'s own concrete local setup (variable initialisers, etc.)
verbatim β€” only the contracted CALLS are replaced. Do NOT include the callee
bodies. Define `int main(void)` that runs `{entry}`'s logic (inline it if
`{entry}` is not already main).
ENTRY FUNCTION:
```c
{entry_src}
```
CONTRACTED CALLEES (name : signature : requires <precondition> : ensures <postcondition>):
{contracts}
Output ONLY the harness in one ```c block.
"""
_REFINE_SYS = (
"You are a formal-methods engineer strengthening a function postcondition so "
"a caller's assertion becomes provable. You output ONLY the new postcondition "
"as a single DSL/boolean expression on one line β€” no prose, no code fences."
)
_REFINE_PROMPT = """\
The caller assertion `{failing}` is NOT provable from `{callee}`'s current
postcondition:
{current_post}
Propose a STRONGER postcondition for `{callee}` that (a) makes `{failing}`
provable at the call site, and (b) is ACTUALLY IMPLIED BY THE BODY below (it must
be sound β€” only state what the code guarantees). Refer to the return value as
`result` and use the parameter names from the signature.
SIGNATURE: {signature}
BODY:
```c
{body}
```
Output ONLY the new postcondition expression on one line.
"""
def _split_conjuncts(expr: str) -> list[str]:
"""Split a boolean expression on TOP-LEVEL ``&&`` (paren/quote-aware). A
conjunction of facts each of which is individually provable is itself provable,
so this lets contract mining keep the sound conjuncts and drop the over-claimed
ones. Single-clause expressions return as a one-element list."""
parts, depth, i, n, start = [], 0, 0, len(expr or ""), 0
quote = None
while i < n:
ch = expr[i]
if quote:
if ch == "\\":
i += 2; continue
if ch == quote:
quote = None
elif ch in "\"'":
quote = ch
elif ch in "([{":
depth += 1
elif ch in ")]}":
depth -= 1
elif ch == "&" and depth == 0 and i + 1 < n and expr[i + 1] == "&":
parts.append(expr[start:i].strip()); i += 2; start = i; continue
i += 1
parts.append(expr[start:].strip())
return [p for p in parts if p]
def _nondet_decl() -> str:
return ("int __VERIFIER_nondet_int(void);\n"
"long __VERIFIER_nondet_long(void);\n")
def _extract_c(text: str) -> str:
m = re.search(r"```(?:c|cpp)?\s*\n(.*?)```", text or "", re.DOTALL)
return (m.group(1) if m else (text or "")).strip()
def _run(check_src: str, config, entry: str, unwind: int, timeout: int):
with tempfile.NamedTemporaryFile("w", suffix=".c", delete=False) as tf:
tf.write(check_src)
path = tf.name
return run_cbmc(
harness_path=path, function=entry, unwind=unwind, timeout=timeout,
cbmc_path=getattr(config, "cbmc_path", "cbmc"),
signed_overflow_check=False, bounds_check=True, pointer_check=True,
)
def _conjoin_precondition(existing: str, added: str) -> str:
"""Conjoin an added precondition without turning ``true`` into noise."""
cur = (existing or "true").strip()
add = (added or "").strip()
if not add or add in ("true", "1", "\\true"):
return cur or "true"
if cur in ("", "true", "1", "\\true"):
return add
if add in cur:
return cur
return f"({cur}) && ({add})"
_NUMERIC_BOUNDS = {
"char": ("(-128LL)", "127LL"),
"signed char": ("(-128LL)", "127LL"),
"short": ("(-32768LL)", "32767LL"),
"int": ("(-2147483647LL - 1LL)", "2147483647LL"),
"long": ("(-9223372036854775807LL - 1LL)", "9223372036854775807LL"),
"long long": ("(-9223372036854775807LL - 1LL)", "9223372036854775807LL"),
}
def _widen_int_expr(expr: str) -> str:
"""Promote scalar identifiers in a simple arithmetic expression to
``long long`` arithmetic so the generated no-overflow precondition can be
evaluated without overflowing before it filters states."""
skip = {"sizeof", "true", "false", "NULL"}
def repl(m: re.Match) -> str:
name = m.group(0)
if name in skip or name.endswith(("LL", "UL")):
return name
start = m.start()
if start > 0 and expr[start - 1] == ".":
return name
if start > 1 and expr[start - 2:start] == "->":
return name
return f"(1LL * {name})"
return re.sub(r"\b[A-Za-z_]\w*\b", repl, expr)
def _overflow_precondition_from_cbmc_failures(res, src: str, fn: str) -> str:
"""Return a C/ACSL boolean precondition that rules out the signed-overflow
sites reported by CBMC, but only when *all* failures are overflow failures.
Specs benchmarks run with mathematical-integer intent. A candidate
functional postcondition such as ``result == (a + b > c ? 1 : 0)`` can be
semantically right yet fail the machine-int soundness gate because the body
and/or the postcondition expression has unbounded signed addition. In that
case, adding caller-proved no-overflow ``requires`` clauses is the right
repair. Non-overflow failures are not repaired here; they remain genuine
unsoundness.
"""
ces = list(getattr(res, "counterexamples", []) or [])
if not ces:
return ""
exprs: list[str] = []
for ce in ces:
desc = (getattr(ce, "description", "") or "").strip()
prop = (getattr(ce, "failing_property", "") or "").lower()
if "overflow" not in prop and "overflow" not in desc.lower():
return ""
m = re.search(r"\bin\s+(.+)$", desc)
if not m:
continue
expr = m.group(1).strip()
if expr and expr not in exprs:
exprs.append(expr)
if not exprs:
return ""
ret = _return_type_of(src, fn)
lo, hi = _NUMERIC_BOUNDS.get(ret if ret in _NUMERIC_BOUNDS else "int",
_NUMERIC_BOUNDS["int"])
return " && ".join(
f"({lo} <= ({_widen_int_expr(e)})) && (({_widen_int_expr(e)}) <= {hi})"
for e in exprs)
# signed-integer <limits.h> bound macros by return type (for the no-overflow
# precondition). Unsigned types wrap by definition (no UB) β†’ no precondition.
_RET_BOUNDS = {
"int": ("INT_MIN", "INT_MAX"),
"short": ("SHRT_MIN", "SHRT_MAX"),
"long": ("LONG_MIN", "LONG_MAX"),
"long long": ("LLONG_MIN", "LLONG_MAX"),
"char": ("CHAR_MIN", "CHAR_MAX"),
"signed char": ("SCHAR_MIN", "SCHAR_MAX"),
}
_RESULT_EQ_RX = re.compile(r"^\\?result\s*==\s*(.+)$", re.S)
_TYPE_KW = r"(?:unsigned|signed|long|short|int|char)"
def _return_type_of(src: str, fn: str) -> str:
"""Best-effort return type of ``fn`` from its definition; '' if not found.
Matches a run of integer type keywords (``unsigned``, ``long long``, ``int`` …)
immediately before the (possibly pointer-qualified) function name."""
m = re.search(rf"\b({_TYPE_KW}(?:\s+{_TYPE_KW})*)\s+\**\s*{re.escape(fn)}\s*\(", src)
return re.sub(r"\s+", " ", m.group(1)).strip() if m else ""
def _has_signed_arith(e: str) -> bool:
"""True iff ``e`` performs a binary +/-/* (which can signed-overflow). A unary
``*p`` deref or a bare variable/constant cannot, so they are excluded β€” the
multiply/subtract pattern requires a left OPERAND (word / ``)`` / ``]``)."""
return bool(re.search(r"[\w\)\]]\s*\+", e) # binary +
or re.search(r"[\w\)\]]\s*[-*]\s*[\w(]", e)) # binary - or *
def overflow_preconditions(src: str, postconditions: dict) -> dict:
"""Map fn -> no-overflow precondition (DSL ``MIN <= E <= MAX``) for each
postcondition of the shape ``result == E`` where ``E`` does signed arithmetic
that the C body therefore computes and can overflow (signed overflow is UB).
This is a CANDIDATE only β€” the caller adopts it solely if Frama-C/WP then
discharges all goals with RTE on (so an insufficient bound, e.g. when the body
has un-bounded intermediates, harmlessly falls back to the math-int contract).
Unsigned return types are skipped (wrapping is defined, not UB)."""
out = {}
for fn, post in (postconditions or {}).items():
m = _RESULT_EQ_RX.match((post or "").strip())
if not m:
continue
expr = m.group(1).strip().rstrip(";").strip()
if not _has_signed_arith(expr):
continue
ret = _return_type_of(src, fn)
if ret.startswith("unsigned"):
continue
lo, hi = _RET_BOUNDS.get(ret, ("INT_MIN", "INT_MAX"))
out[fn] = f"{lo} <= {expr} <= {hi}"
return out
# Parse one Frama-C `-rte -warn-signed-overflow -print` overflow assertion, e.g.
# assert rte: signed_overflow: -2147483648 ≀ a + b;
# assert rte: signed_overflow: a + b ≀ 2147483647;
# recovering the (parameter-level) arithmetic sub-expression being bounded.
_RTE_OVF_RX = re.compile(r"assert rte: signed_overflow:\s*(.+?);")
_FUNC_SIG_RX = re.compile(
rf"^\s*(?:{_TYPE_KW}\s+)*{_TYPE_KW}\s+\**\s*(\w+)\s*\([^;{{]*\)\s*$")
def overflow_preconditions_from_body(
src: str, fns, frama_c_path: str = "frama-c") -> dict:
"""Map fn -> no-overflow precondition by enumerating EVERY signed-overflow
site in the body β€” not just the ``result == E`` postcondition shape that
``overflow_preconditions`` handles. Lets Frama-C's own RTE pass list the
overflowing sub-expressions (``-rte -warn-signed-overflow -print``), then
bounds each with ``INT_MIN <= expr <= INT_MAX``.
This catches arithmetic buried in guards/comparisons (e.g. ``a+b>c``) whose
result never appears in the postcondition. CANDIDATE only β€” the caller adopts
it iff Frama-C/WP then discharges all goals with RTE on; lifting a body
expression to a precondition is sound when its operands are the parameters'
initial values (no prior reassignment), and the verification gate rejects any
case where that does not hold. Best-effort: returns {} if Frama-C is absent
or emits nothing.
"""
import subprocess
want = set(fns or [])
if not want:
return {}
try:
with tempfile.NamedTemporaryFile("w", suffix=".c", delete=False) as tf:
tf.write(src)
path = tf.name
proc = subprocess.run(
[frama_c_path, "-rte", "-warn-signed-overflow", "-print", path],
capture_output=True, text=True, timeout=60)
printed = proc.stdout
except Exception:
return {}
cur = None
per: dict[str, set] = {}
for line in printed.splitlines():
sig = _FUNC_SIG_RX.match(line.rstrip())
if sig and "{" not in line:
cur = sig.group(1)
if cur in want:
per.setdefault(cur, set())
a = _RTE_OVF_RX.search(line)
if a and cur in want:
body = a.group(1)
# strip the numeric bound + relational operator from either side,
# leaving just the arithmetic sub-expression.
expr = re.sub(r"[-\d]+\s*[≀<>=]+\s*", "", body)
expr = re.sub(r"\s*[≀<>=]+\s*[-\d]+", "", expr).strip()
if expr:
per[cur].add(re.sub(r"\s+", " ", expr))
out = {}
for fn, exprs in per.items():
ret = _return_type_of(src, fn)
# operand width drives the bound; default to int (the promoted width).
lo, hi = _RET_BOUNDS.get(ret if ret in _RET_BOUNDS else "int",
("INT_MIN", "INT_MAX"))
if exprs:
out[fn] = " && ".join(f"{lo} <= {e} <= {hi}"
for e in sorted(exprs))
return out
def synthesize(
source_file: str | Path,
config,
llm: LLMClient,
entry: str = "main",
max_iters: int = 5,
unwind: int = 16,
timeout: int = 120,
) -> SynthResult:
"""Run the assertion-driven spec-synthesis loop. Returns a SynthResult."""
src = Path(source_file).read_text(encoding="utf-8", errors="replace")
# Verification goals are INPUTS: assert / static_assert / __VERIFIER_assert /
# //@ assert. They are what S must let the verifier prove, not synthesis targets.
asserts = extract_goals(src)
if not asserts:
# No explicit proof target. Rather than bail N/A, MINE a contract from each
# function's BODY and prove the body satisfies it (see _mine_contracts). That
# is a real obligation β€” the synthesized spec must be correct w.r.t. the code β€”
# so SATISFIED is non-vacuous. Still degrades to N/A when nothing non-trivial
# can be specified (e.g. a program that is only `main`).
return _mine_contracts(source_file, src, config, llm, entry, unwind, timeout)
from bmc_agent.source_parser import parse_source_file
from bmc_agent.harness_generator import _c_expressible_postcondition as _cexpr
parsed = parse_source_file(str(source_file), source_text=src)
# The entry must be the function the asserts live in (default 'main' may not
# exist, or the goals may sit in another function like `foo`).
entry = _resolve_entry(parsed, entry)
defined = list(parsed.functions.keys())
entry_src = parsed.function_bodies.get(entry, "")
callees = [c for c in called_functions(src, defined) if c != entry]
logger.info("assert-synth: %d assert(s), entry=%s, callees=%s",
len(asserts), entry, callees)
# Initial postconditions from Phase-1 spec-gen.
from bmc_agent.spec_generator_v2 import SpecGeneratorV2
gen = SpecGeneratorV2(config, llm, _NullStore(), corpus_paths=[Path(source_file)])
specs = gen.generate_specs(str(source_file), "assertsynth", only_functions=set(callees))
post = {c: (specs[c].postcondition if c in specs else "true") for c in callees}
pre = {c: (specs[c].precondition if c in specs else "true") for c in callees}
sigs = {c: _signature_of(parsed, c) for c in callees}
bodies = {c: parsed.function_bodies.get(c, "") for c in callees}
pnames = {c: [n for _, n in (parsed.get_function_info(c).signature.parameters
if parsed.get_function_info(c) else [])] for c in callees}
lhs_map = callee_lhs_map(entry_src, callees)
# Engine context for the FAST path: reuse the pipeline's compositional harness
# (callee stubs) with assume_callee_postcondition=True, so a C-expressible
# contract propagates to the caller. Best-effort; None β†’ agentic-only.
eng = _engine_context(source_file, src, config, entry, callees)
def _cexpr_ok(c, p=None):
return _cexpr(p if p is not None else post[c], pnames.get(c, [])) is not None
def _result(ok, it, failing, note, backend=""):
return SynthResult(ok=ok, iterations=it, postconditions=dict(post),
preconditions=dict(pre), failing_asserts=list(failing),
asserts=asserts, entry=entry,
note=(note + (f" [{backend}]" if backend else "")))
def _sound_ok(target, candidate):
"""Does the body imply `candidate`? (engine fast path, else LLM harness)."""
if eng is not None and _cexpr_ok(target, candidate):
sv = _sound_engine(eng, target, pre[target], candidate)
return bool(sv and sv.verified)
return _postcondition_sound(llm, config, target, sigs[target],
bodies[target], candidate, unwind, timeout)
def _sufficient(trial_post):
"""Is the goal provable with this (callee -> postcondition) map?"""
if eng is not None and all(_cexpr_ok(c, trial_post[c]) for c in callees):
v = _suff_engine(eng, callees, pre, trial_post)
return bool(v and v.verified) and not _failing_asserts(v)
h = _build_sufficiency_harness(llm, config, entry, entry_src, pre, trial_post, sigs)
if not h:
return False
r = _run(_nondet_decl() + h, config, "main", unwind, timeout)
return bool(r.verified) and not _failing_asserts(r)
def _strengthen():
"""#2 β€” push each sound+adequate postcondition toward the behavioral form.
Monotonic: a candidate replaces the current post ONLY if re-verified sound,
stronger-or-equal (cand ==> cur), and still adequate. Else the original is
kept, so the already-achieved SATISFIED can never be lost."""
for c in callees:
cur = post[c]
cand = _propose_behavioral_post(llm, config, c, sigs[c], bodies[c], cur)
logger.debug("assert-synth strengthen %s: candidate=%r", c, cand)
if not cand or cand == cur:
continue
if not _sound_ok(c, cand):
logger.debug("assert-synth strengthen %s: reject (unsound)", c)
continue
# Don't trade for a weaker/incomparable spec β€” require cand ==> cur.
if not _expr_implies(parsed, c, cand, cur, config, unwind, timeout):
logger.debug("assert-synth strengthen %s: reject (cand=>cur unproven)", c)
continue
trial = dict(post); trial[c] = cand
if not _sufficient(trial):
logger.debug("assert-synth strengthen %s: reject (no longer adequate)", c)
continue
post[c] = cand
logger.info("assert-synth: strengthened '%s' to behavioral postcondition "
"(%s)", c, cand)
for it in range(1, max_iters + 1):
# SUFFICIENCY: engine stub (fast, reuses pipeline) when every contract is
# C-expressible; otherwise the agentic harness (handles prose/unrolled).
use_engine = eng is not None and all(_cexpr_ok(c) for c in callees)
backend = "engine" if use_engine else "agentic"
if use_engine:
verdict = _suff_engine(eng, callees, pre, post)
failing, verified = _failing_asserts(verdict), bool(verdict and verdict.verified)
else:
harness = _build_sufficiency_harness(llm, config, entry, entry_src, pre, post, sigs)
if not harness:
return _result(False, it, asserts, "could not build sufficiency harness", backend)
res = _run(_nondet_decl() + harness, config, "main", unwind, timeout)
failing, verified = _failing_asserts(res), res.verified
logger.info("assert-synth iter %d [%s]: verified=%s failing=%s", it, backend, verified, failing)
if verified and not failing:
# The goal is provable. Optionally tighten loose-but-adequate
# postconditions toward the function's behavioral relation (quality
# only β€” gated so it can never weaken the result or flip the verdict).
if getattr(config, "enable_spec_strengthen", True):
_strengthen()
return _result(True, it, [], "all //@ asserts provable from synthesized specs", backend)
if not callees:
return _result(False, it, failing, "no callee to refine", backend)
# Attribute the failing assert to the callee whose return value flows into
# it; try implicated callees first, then the rest. A refinement counts only
# if it (a) changes the postcondition and (b) is SOUND (implied by the body).
focus = failing[0] if failing else asserts[0]
candidates = attribute_assert(focus, lhs_map, callees)
progressed = False
saw_changed_unsound = False # a strictly-stronger proposal failed soundness
for target in candidates:
new_post = _refine_postcondition(
llm, config, target, sigs[target], bodies[target], focus, post[target])
if not new_post or new_post == post[target]:
continue # no new information from this callee β€” try the next
# SOUNDNESS gate: body must imply the strengthened postcondition.
if eng is not None and _cexpr_ok(target, new_post):
sv = _sound_engine(eng, target, pre[target], new_post)
sound = bool(sv and sv.verified)
# Specs benchmarks assume mathematical integers, while the
# engine soundness gate checks machine-int C. If the only
# objection is signed overflow in the body/postcondition, repair
# the candidate by adding caller-proved no-overflow requires and
# retry. This keeps false goals false: the next sufficiency pass
# still requires the caller to establish the new precondition.
if (not sound and getattr(config, "math_ints", False)):
ovf_pre = _overflow_precondition_from_cbmc_failures(sv, src, target)
if ovf_pre:
trial_pre = _conjoin_precondition(pre[target], ovf_pre)
sv2 = _sound_engine(eng, target, trial_pre, new_post)
if sv2 and sv2.verified:
pre[target] = trial_pre
sound = True
logger.info(
"assert-synth: added overflow precondition for '%s' "
"during soundness repair (%s)", target, ovf_pre)
else:
sound = _postcondition_sound(llm, config, target, sigs[target],
bodies[target], new_post, unwind, timeout)
if not sound:
saw_changed_unsound = True
logger.info("assert-synth: '%s' proposal unsound, trying next callee", target)
continue # unsound for this callee β€” another callee may carry the assert
post[target] = new_post
logger.info("assert-synth: refined '%s' (implicated by %r)", target, focus)
progressed = True
break
if not progressed:
note = ("no SOUND postcondition strengthening across the implicated callees makes "
"the assert provable β†’ assert likely false / not implied by any callee body"
if saw_changed_unsound else
"refinement proposed no stronger postcondition (fixpoint) β€” assert unprovable")
return _result(False, it, failing, note, backend)
return _result(False, max_iters, asserts,
"max iterations reached without satisfying all asserts")
def _mine_contracts(
source_file, src, config, llm, entry: str, unwind: int, timeout: int,
) -> SynthResult:
"""Goal-free spec synthesis: the program carries no //@ assert / assert /
__VERIFIER_assert, so there is no caller goal to drive refinement. Instead of
reporting N/A, MINE a function contract from each function's BODY and prove the
body actually satisfies it.
This is NOT the vacuous pass the goal-required path guards against: the proof
obligation here is "the synthesized postcondition is SOUND β€” implied by the
implementation", discharged by the same soundness oracle (engine or CBMC harness)
the refinement loop uses. SATISFIED therefore means a non-trivial contract was
synthesized AND the code provably meets it. The CLI's Frama-C/WP step then
re-confirms the same contracts deductively.
Degrades to N/A only when there is genuinely nothing to specify: no function other
than the driver, or spec-gen yields only trivial (`true`) postconditions.
"""
from bmc_agent.source_parser import parse_source_file
from bmc_agent.frama_c import (
function_assigns_clause, function_frame_precondition,
insert_contract_acsl, run_wp)
parsed = parse_source_file(str(source_file), source_text=src)
entry = _resolve_entry(parsed, entry) # no goals β†’ unchanged (usually 'main')
defined = list(parsed.functions.keys())
# Targets: every defined function except the driver. `main` is an entry point that
# wires inputs, not a unit with an interesting contract, so it is never a target.
targets = [f for f in defined if f != entry and f != "main"]
if not targets:
return SynthResult(ok=False, iterations=0, no_goals=True, entry=entry,
note="no verification goal and no non-driver function to "
"specify β€” nothing to prove")
from bmc_agent.spec_generator_v2 import SpecGeneratorV2
gen = SpecGeneratorV2(config, llm, _NullStore(), corpus_paths=[Path(source_file)])
specs = gen.generate_specs(str(source_file), "assertsynth", only_functions=set(targets))
post = {c: (specs[c].postcondition if c in specs else "true") for c in targets}
pre = {c: (specs[c].precondition if c in specs else "true") for c in targets}
# Drop trivial postconditions β€” a `true`/empty ensures states nothing, so proving it
# would be vacuous. Keep only contracts with real content.
def _trivial(p: str) -> bool:
return (p or "").strip() in ("", "true", "1", "\\true")
post = {c: p for c, p in post.items() if not _trivial(p)}
if not post:
return SynthResult(ok=False, iterations=0, no_goals=True, entry=entry,
note="no verification goal; spec-gen produced only trivial "
"(true) postconditions β€” nothing to prove")
pre = {c: pre[c] for c in post}
logger.info("assert-synth: no goal β€” mining contracts for %s", list(post))
oracle = getattr(config, "oracle", "cbmc")
math_ints = bool(getattr(config, "math_ints", False))
fc_path = getattr(config, "frama_c_path", "frama-c")
fn_assigns = {c: function_assigns_clause(src, c) for c in post}
for c, assigns in fn_assigns.items():
frame_pre = function_frame_precondition(src, c, assigns)
if frame_pre:
pre[c] = _conjoin_precondition(pre.get(c, "true"), frame_pre)
def _conjunct_sound(c: str, clause: str) -> bool:
"""Is a single postcondition clause implied by the body of `c`? Checked with
the CONFIGURED oracle β€” they have complementary reach:
* frama-c/WP discharges math-int functional clauses (e.g. result == p*n*r/100)
deductively in milliseconds β€” CBMC would bit-blast the nonlinear mul/div and
time out. This is the correct oracle for spec-synthesis contracts.
* CBMC checks via a deterministic verbatim-body harness (nondet inputs, assert
the clause), falling back to an LLM-built harness for non-scalar params."""
if oracle == "frama-c":
annotated = insert_contract_acsl(
src, c, requires=pre.get(c, "true"), ensures=clause,
assigns=fn_assigns.get(c, ""))
wp = run_wp(annotated, frama_c_path=fc_path,
rte=not math_ints, exclude_terminates=True)
if wp.available:
return bool(wp.n_total and wp.n_proved == wp.n_total)
# frama-c requested but unavailable β†’ fall through to the CBMC harness
h = _mk_sound_harness(parsed, c, clause)
if h is not None:
res = _run(_nondet_decl() + h, config, "main", unwind, timeout)
return bool(res.verified and not res.error)
return _postcondition_sound(
llm, config, c, _signature_of(parsed, c),
parsed.function_bodies.get(c, ""), clause, unwind, timeout)
# SOUNDNESS: keep the STRONGEST sound contract. spec-gen can over-claim (e.g.
# `result >= 0` on a value that may be negative), so check each top-level
# conjunct against the body and keep only the ones the code actually guarantees.
sound_post: dict[str, str] = {}
dropped: dict[str, list[str]] = {}
for c, p in post.items():
kept = [cl for cl in _split_conjuncts(p) if _conjunct_sound(c, cl)]
drop = [cl for cl in _split_conjuncts(p) if cl not in kept]
if kept:
sound_post[c] = " && ".join(kept)
if drop:
dropped[c] = drop
logger.info("assert-synth: '%s' kept=%s dropped=%s", c, kept, drop)
if not sound_post:
# Every mined clause was either trivial or unsound β†’ no provable spec.
return SynthResult(
ok=False, iterations=1, no_goals=True, entry=entry,
note="no explicit goal; mined contracts but no clause was sound (body "
"implies none of the proposed postconditions) β€” nothing provable")
pre = {c: pre.get(c, "true") for c in sound_post}
note = ("no explicit goal β€” synthesized function contract(s) from the body and "
"proved the implementation satisfies them (spec mined + verified sound)")
if dropped:
note += "; dropped unsound clauses: " + "; ".join(
f"{c}: {', '.join(cls)}" for c, cls in dropped.items())
return SynthResult(
ok=True, iterations=1, postconditions=sound_post, preconditions=pre,
asserts=[], entry=entry, note=note)
# --- engine backend (fast path: reuse the pipeline's compositional harness) --
_ENTRY_ALIAS = "__assert_entry" # rename `main` so it doesn't clash with the engine harness's own main
def _engine_context(source_file, src, config, entry, callees):
"""Build a reusable engine context for the C-expressible fast path, or None.
Translates //@ asserts β†’ __CPROVER_assert, renames `main` (clashes with the
harness's own main), parses, and wires the pipeline engine with inlining OFF
+ assume_callee_postcondition ON so callee stubs propagate functional
contracts. Returns a dict, or None on any failure (caller falls back to agentic)."""
try:
from bmc_agent.standalone import translate_acsl_asserts
from bmc_agent.source_parser import parse_source_file
import re as _re, tempfile as _tf
translated, _ = translate_acsl_asserts(src)
# Make executable goal forms checkable by CBMC: __VERIFIER_assert has no
# body (CBMC would otherwise treat the goal as an uninterpreted call and
# skip it); map it to a CBMC assertion. `assert` is handled natively.
if "__VERIFIER_assert" in translated and "#define __VERIFIER_assert" not in translated:
translated = ('#define __VERIFIER_assert(c) __CPROVER_assert((c), "goal")\n'
+ translated)
entry_name = entry
if entry == "main":
translated = _re.sub(r"\b(int|void)(\s+)main(\s*\()", rf"\1\2{_ENTRY_ALIAS}\3", translated)
entry_name = _ENTRY_ALIAS
with _tf.NamedTemporaryFile("w", suffix=".c", delete=False) as tf:
tf.write(translated)
tu = tf.name
parsed = parse_source_file(tu, source_text=translated)
entry_func = parsed.get_function_info(entry_name)
if entry_func is None:
return None
all_funcs = {n: parsed.get_function_info(n) for n in parsed.functions}
config.inline_pure_callees = False
config.enable_inlining_advisor = False
config.assume_callee_postcondition = True
from bmc_agent.pipeline import AMCPipeline
engine = AMCPipeline(config).bmc_engine
return {"engine": engine, "parsed": parsed, "all_funcs": all_funcs,
"entry_name": entry_name, "entry_func": entry_func}
except Exception as exc:
logger.info("assert-synth: engine context unavailable (%r) β€” agentic-only", exc)
return None
def _suff_engine(eng, callees, pre, post):
"""SUFFICIENCY via the engine: verify the entry with callees stubbed by their
current contracts (functional postconditions propagated). Returns the verdict."""
from bmc_agent.spec import Spec
callee_specs = {c: Spec(function_name=c, precondition=pre[c], postcondition=post[c]) for c in callees}
entry_spec = Spec(function_name=eng["entry_name"], precondition="true",
postcondition="true", callee_specs=callee_specs)
return eng["engine"].check_function(eng["entry_func"], entry_spec, eng["parsed"],
"assertsynth", all_funcs=eng["all_funcs"])
def _sound_engine(eng, callee, pre_c, new_post):
"""SOUNDNESS via the engine: does the callee body imply `new_post`?"""
from bmc_agent.spec import Spec
cf = eng["all_funcs"].get(callee)
if cf is None:
return None
spec = Spec(function_name=callee, precondition=pre_c, postcondition=new_post)
return eng["engine"].check_function(cf, spec, eng["parsed"], "assertsynth_sound",
all_funcs=eng["all_funcs"])
# --- helpers -----------------------------------------------------------------
class _NullStore:
def init_driver(self, *a, **k): pass
def save_spec(self, *a, **k): pass
def _failing_asserts(res) -> list[str]:
"""The //@ assert expressions that CBMC could not prove (from cex). Our
__CPROVER_assert messages are 'assert: <expr>', so recover <expr>."""
out = []
for ce in getattr(res, "counterexamples", []) or []:
d = (ce.description or "").strip()
prop = (ce.failing_property or "").lower()
if d.startswith("assert:"):
out.append(d[len("assert:"):].strip())
elif "assertion" in prop:
out.append(d or ce.failing_property)
return out
def _signature_of(parsed, fn: str) -> str:
fi = parsed.get_function_info(fn)
if not fi:
return fn
sig = fi.signature
params = ", ".join(f"{t} {n}" for t, n in sig.parameters) or "void"
return f"{sig.return_type} {sig.name}({params})"
def _build_sufficiency_harness(llm, config, entry, entry_src, pre, post, sigs) -> str:
contracts = "\n".join(
f" - {c} : {sigs.get(c, c)} : requires {pre.get(c, 'true')} : ensures {post[c]}"
for c in post) or " (none)"
prompt = _BUILD_HARNESS_PROMPT.format(
entry=entry, entry_src=entry_src, contracts=contracts, callee=next(iter(post), "f"))
txt = llm.complete(
agentic_system_prompt(config, "spec_gen", _BUILD_HARNESS_SYS),
prompt, max_tokens=2048, role="spec_gen")
return _extract_c(txt)
def _clean_expr(txt: str) -> str:
"""Extract a single postcondition expression from an LLM reply, tolerating a
```code fence``` and a leading keyword / trailing ``;`` even though the system
prompt asks for a bare line (models add fences anyway β€” without this the first
'line' is the fence and the expression is silently lost).
The agentic backend frequently precedes the answer with a paragraph of
reasoning, so the FIRST non-comment line is often explanatory prose rather
than the postcondition. Capturing that prose verbatim corrupts the spec β€”
and, being non-assertable, it translates to a bare comment that passes the
soundness check vacuously (0 VCCs) and breaks downstream Frama-C. So skip
lines that read as prose and return the first that reads as a C/DSL
expression; if none does, return "" (treated as "no new information")
rather than leaking prose into the postcondition."""
s = (txt or "").strip()
m = re.search(r"```(?:c|cpp|text)?\s*\n?(.*?)```", s, re.DOTALL)
if m:
s = m.group(1).strip()
for line in s.splitlines():
line = line.strip().strip("`").strip()
line = re.sub(r"^(ensures|postcondition:?)\s+", "", line, flags=re.I)
line = line.rstrip(";").strip()
if not line or line.startswith("//"):
continue
if _looks_like_c_expr(line):
return line
return ""
def _refine_postcondition(llm, config, callee, signature, body, failing, current) -> str:
prompt = _REFINE_PROMPT.format(
failing=failing, callee=callee, current_post=current,
signature=signature, body=body)
txt = llm.complete(
agentic_system_prompt(config, "refinement", _REFINE_SYS),
prompt, max_tokens=256, role="refinement")
return _clean_expr(txt)
# --- behavioral strengthening (the dual of refinement) -----------------------
#
# Refinement makes a too-WEAK postcondition strong enough to prove the goal.
# Strengthening runs AFTER the goal is already provable and pushes a sound,
# adequate-but-LOOSE postcondition toward the function's exact input/output
# relation β€” `result` pinned as a function of the PARAMETERS in every branch β€”
# so the contract is reusable by any caller, not just the one whose goal
# happened to exercise one branch. It is purely a quality lever: a candidate is
# adopted only when re-verified SOUND, strictly-stronger-or-equal, AND still
# adequate, so it can never weaken a result or flip the verdict.
_STRENGTHEN_SYS = (
"You are a formal-methods engineer writing the STRONGEST SOUND, fully "
"BEHAVIORAL postcondition for a function β€” the exact input/output relation, "
"with the return value pinned as a function of the PARAMETERS in every "
"branch. You output ONLY the postcondition as a single DSL/boolean line β€” "
"no prose, no code fences."
)
_STRENGTHEN_PROMPT = """\
`{callee}` already has this SOUND postcondition (a caller goal relies on it):
{current}
Rewrite it as the STRONGEST postcondition the BODY guarantees β€” the exact
input/output relation β€” so it constrains `result` for ALL inputs, not just the
arguments some caller happens to pass. Requirements:
- Refer to the return value as `result`; use the parameter names from the signature.
- Express `result` purely as a function of the PARAMETERS. Do NOT use concrete
caller argument values (e.g. `a == 2`) and do NOT restate a specific call.
- State the relation unconditionally (keep `requires` true); cover EVERY branch
β€” e.g. both the true and false cases of a condition.
- It MUST be sound: only state what the code below actually computes.
- Prefer a conjunction of biconditionals written with `==` (C-expressible), e.g.
`((result == 1) == COND) && ((result == 0) == (!(COND)))`, or a ternary
`result == (COND ? X : Y)`.
SIGNATURE: {signature}
BODY:
```c
{body}
```
Output ONLY the postcondition expression on one line.
"""
def _propose_behavioral_post(llm, config, callee, signature, body, current) -> str:
prompt = _STRENGTHEN_PROMPT.format(
callee=callee, current=current, signature=signature, body=body)
txt = llm.complete(
agentic_system_prompt(config, "refinement", _STRENGTHEN_SYS),
prompt, max_tokens=256, role="refinement")
return _clean_expr(txt)
def _expr_implies(parsed, fn, strong, weak, config, unwind, timeout) -> bool:
"""Verify ``strong ==> weak`` over all nondet (result, params): assume
`strong`, assert `weak`. Body-independent β€” a pure validity check that
`strong` is at least as strong as `weak`. Scalar params only; returns False
when a safe harness can't be built or CBMC can't discharge it, so the caller
treats "not provably stronger" as "keep the original" (no regression)."""
fi = parsed.get_function_info(fn)
if not fi:
return False
params = list(fi.signature.parameters)
if any(("*" in t) or ("[" in t or "[" in n) for t, n in params):
return False # pointer/array params: not deterministically nondet-able here
ret = (fi.signature.return_type or "").strip()
if not ret or ret == "void":
return False
decls = "\n ".join(f"{t} {n};" for t, n in params)
s = re.sub(r"\bresult\b", "__r", strong)
w = re.sub(r"\bresult\b", "__r", weak)
h = (f"int main(void) {{\n"
f" {decls}\n"
f" {ret} __r;\n"
f" __CPROVER_assume({s});\n"
f" __CPROVER_assert({w}, \"implies\");\n"
f" return 0;\n}}\n")
res = _run(_nondet_decl() + h, config, "main", unwind, timeout)
return bool(getattr(res, "verified", False)) and not getattr(res, "error", None)
def _mk_sound_harness(parsed, fn: str, clause: str) -> str | None:
"""A deterministic CBMC soundness harness for `fn`: define the function body
verbatim, declare each parameter as an uninitialised (β‡’ nondet) local, call it,
and assert `clause` (with `result` bound to the return value). Returns None when
a safe deterministic harness can't be built β€” non-scalar params (pointers/arrays
need backing memory) or a missing body/signature β€” so the caller can fall back to
the LLM-built harness. Reliable for leaf functions where the engine's
compositional harness goes vacuous."""
fi = parsed.get_function_info(fn)
if not fi:
return None
sig = fi.signature
params = list(sig.parameters)
if any(("*" in t) or ("[" in t or "[" in n) for t, n in params):
return None # pointer/array param β†’ not deterministically nondet-able here
body = parsed.function_bodies.get(fn, "") # brace block only, e.g. "{ ... }"
ret = (sig.return_type or "").strip()
if not body or not ret or ret == "void":
return None # nothing to bind `result` to
param_decls = ", ".join(f"{t} {n}" for t, n in params) or "void"
decls = "\n ".join(f"{t} {n};" for t, n in params)
call_args = ", ".join(n for _, n in params)
expr = re.sub(r"\bresult\b", "__r", clause)
return (f"{ret} {fn}({param_decls}) {body}\n\n" # reconstruct the full definition
f"int main(void) {{\n"
f" {decls}\n"
f" {ret} __r = {fn}({call_args});\n"
f" __CPROVER_assert({expr}, \"sound\");\n"
f" return 0;\n}}\n")
def _postcondition_sound(llm, config, callee, signature, body, post, unwind, timeout) -> bool:
"""CBMC-check that the callee body implies `post` for nondet inputs. The LLM
writes a small harness (nondet args -> call body -> assert(post))."""
sys = ("You write a CBMC harness that checks a postcondition holds for a "
"function body on fully nondeterministic inputs. Output ONLY a ```c block.")
prompt = (f"Write a CBMC harness: define the function below verbatim, then in "
f"main() call it on FULLY NONDET inputs (nondet pointers backed by "
f"nondet values) and `__CPROVER_assert(<postcondition>, \"sound\")` "
f"with result bound to the call's return.\n\n"
f"SIGNATURE: {signature}\nPOSTCONDITION: {post}\nBODY:\n```c\n{body}\n```\n"
f"Output ONLY the harness in one ```c block.")
txt = llm.complete(agentic_system_prompt(config, "refinement", sys),
prompt, max_tokens=1024, role="refinement")
h = _extract_c(txt)
if not h:
return False
res = _run(_nondet_decl() + h, config, "main", unwind, timeout)
# sound iff the postcondition assertion is NOT violated
return res.verified and not res.error