Python library and CLI for Certora TAC program dumps (.tac and
.sbf.json). SeaTac vibes: taxiway maps for TAC CFGs. 🛫🛬
A quick map of what ctac gives you beyond grep:
| Purpose | Commands |
|---|---|
| Inspect a TAC file | stats, parse, pp, cfg, search (alias: grep), slice |
| Compare two builds | op-diff, cfg-match, bb-diff, sbf-tac |
| Analyze data-flow | df, types, absint |
| Transform TAC | rw (rewrites + DCE), ua (uniquify asserts), strip (remove client metadata), pin (specialize), cfg-simplify |
| Verify | run (concrete interpreter), smt (SMT-LIB VC + z3), verify-cover |
| Validate the rewriter | rw-valid |
Every command has its own --help (full flags + examples in the
epilog) and --agent (terse agent-oriented guidance with
"why beat manual" framing). The rest of this README is a map, not a
manual — drill into --help for flag-level detail.
Python 3.11+ (3.14 is fine). From the repo root:
With uv:
uv sync
uv run ctac --help
uv run pytestuv sync installs the project and its development dependencies into
.venv; use uv run to run commands in that environment.
With pip:
python3.14 -m venv .venv
.venv/bin/pip install -U pip
.venv/bin/pip install -e ".[dev]"Run tools with .venv/bin/ctac or source .venv/bin/activate.
Shell completion (one-time setup; tab-completes commands,
subcommands, flag values — and ctac search's pattern against the
TAC operator name set):
ctac --install-completion # auto-detects zsh / bash / fish / powershell
# restart shellSolve an assertion end-to-end:
ctac stats f.tac --plain # first look: memory capability + block/cmd counts
ctac rw f.tac -o opt.tac --plain # simplify (div/bitfield/Ite + DCE)
ctac ua opt.tac -o sa.tac --plain # fold asserts into one __UA_ERROR block
ctac smt sa.tac --plain --run --model m.txt # VC + z3, write SAT model
ctac run sa.tac --plain --model m.txt --trace # replay concretelyCompare two builds (did the encoder drift?):
ctac op-diff a.tac b.tac --plain # per-stat frequency delta, by section
ctac cfg-match a.tac b.tac --plain # block correspondence (for bb-diff)
ctac bb-diff a.tac b.tac --plain --drop-empty # per-block semantic deltaValidate the rewriter itself:
ctac rw-valid -o /tmp/rwv --plain # emit per-rule SMT soundness scripts
for f in /tmp/rwv/*.smt2; do echo -n "$(basename $f): "; z3 "$f" -T:5; done
# expected `unsat` on every scriptctac stats f.tac --plain # blocks/cmds/metas/ops + memory capability
ctac stats path/to/output-dir --plain # auto-resolves outputs/*.tac
ctac pp f.tac --plain # humanized TAC (slices, ceil-div, etc.)
ctac pp f.tac --plain --from A --to B # slice on a CFG path
ctac pp f.tac -o out.htac # write pretty-printed text
ctac cfg f.tac --plain # goto-style CFG text (default)
ctac cfg f.tac --plain --style edges # `src -> dst` per line, grep-friendly
ctac cfg f.tac --plain --style dot | dot -Tsvg -o cfg.svg
ctac cfg f.tac --plain --style blocks # one block id per line, shell loops
ctac search f.tac 'BWAnd' --plain --count # count op usage (tab-completes)
ctac search f.tac 'BWAnd' --plain -C 2 # grep-style context (-B / -A / -C)
ctac search f.tac '0x[0-9a-f]+' --plain --count-by-match # frequency table
ctac search f.tac 'BWAnd' --plain -q --count # pipeable; awk on `matches:`
ctac slice f.tac -c B1054 --plain # backward static slice from a symbol/block
ctac smtlib stats f.smt2 --plain # for .smt2 inputs: kinds, sorts,
# bytemap chain depth, UF-arg vars
ctac smtlib pp f.smt2 --width 100 # pretty-print via Doc algebra
ctac smtlib slice f.smt2 --kinds Assert --range 0-200 # filter view
ctac smtlib roundtrip f.smt2 # parse + emit; check byte-identicalcfg, pp, search, and df all share the same filter grammar —
--from, --to, --only, --id-contains, --id-regex,
--cmd-contains, --exclude — combining with AND.
ctac smtlib operates on SMT-LIB v2 files (the output of
ctac smt or any external .smt2). It's distinct from
ctac smt, which is the TAC→SMT encoder.
ctac op-diff a.tac b.tac --plain # per-stat delta, headline in one shot
ctac op-diff a.tac b.tac --plain --show expression_ops
ctac op-diff a.tac b.tac --json # machine-readable
ctac cfg-match a.tac b.tac --plain --const-weight 0.2
ctac bb-diff a.tac b.tac --plain --drop-empty --max-diff-lines 120
ctac sbf-tac f.sbf.json f.tac --plain # join SBF instrs with same-address TACop-diff is the fastest way to spot encoder-level drift between two
Prover versions (it's built on top of ctac stats). Reach for
cfg-match + bb-diff for block-level structural / semantic diffs.
ctac df f.tac --plain # all analyses, summary
ctac df f.tac --plain --show dsa # validate DSA (rejecter for sea_vc)
ctac df f.tac --plain --show dce --details # per-item dead-code listing
ctac df f.tac --plain --json # machine-readable
ctac types f.tac --plain --show ptr # kind inference: provable pointers
ctac absint f.tac --plain # abstract-interpreter analyses (polynomial degree, ...)Available analyses: def-use, liveness, dce, use-before-def,
dsa, control-dependence, uce (useless-assume elimination).
ctac rw f.tac --plain --report # simplify + print per-rule hit counts
ctac rw f.tac -o small.tac --plain # write a round-trippable .tac
ctac rw f.tac -o small.htac --plain # write pretty-printed .htac
ctac rw f.tac --purify-div --plain # enable R4a div purification (off by default)
ctac ua f.tac -o f_ua.tac --plain # fold asserts into one __UA_ERROR block
ctac ua f.tac -o f_ua.tac --plain --report # + counts
ctac strip f.tac -o f_open.tac --plain --report # remove client metadata
ctac pin f.tac --drop B7 --bind R3=0 -o sp.tac --plain # specialize the CFG
ctac cfg-simplify f.tac -o out.tac --plain # drop annotation-only fall-through blocksrw runs the iterated simplification pipeline (N1–N4 bit-op
canonicalization, R1–R4 div rules, R6 ceil-div collapse, boolean/Ite
cleanup, CSE, copy-prop). --purify-div (off by default) adds R4a
div purification; --purify-ite / --purify-assert /
--purify-assume control post-DCE naming phases.
Soundness of every rule is documented by ctac rw-valid.
ua is the prerequisite for ctac smt on multi-assert TAC: it
emits if (P) goto GOOD else goto __UA_ERROR for each assert, with
assume P opening the GOOD continuation — Floyd-Hoare style, no
inversion.
strip removes client-identifying metadata (spec paths, embedded
source, function names, call-trace snippets) so a dump can be shared
as an open benchmark, keeping the generic structural metadata solvers
care about. Unknown keys are dropped by default and flagged in
--report.
ctac run f.tac --plain # concrete interpreter, zero-havoc
ctac run f.tac --plain --model m.txt --trace # replay z3 model (TAC or SMT)
ctac run f.tac --plain --model m.txt --validate # compare computed vs model
ctac smt f.tac --plain # emit SMT-LIB VC to stdout
ctac smt f.tac --plain -o out.smt2 # write .smt2
ctac smt f.tac --plain --run # invoke z3
ctac smt f.tac --plain --run --model out.model.txt # TAC-format SAT model
ctac smt f.tac --plain --run --unsat-core # name asserts, print core on unsat
ctac z3 f.smt2 # single-run live progress + bottleneck signature
ctac z3 f.smt2 --seeds 0-7 -j 4 # seed sweep race; first verdict wins
ctac z3 f.smt2 --configs default,alt-then,bp-off --seeds 0-3 -j auto
ctac z3 f.smt2 --show-output --save-rerun winner.sh # capture winner artifacts
ctac z3 --list-configs # see available configs (defaults + discovered file)
ctac verify-cover cover.json --plain # re-verify a cover certificate via z3ctac z3 runs z3 on an existing .smt2 with classification +
parallel orchestration. It spawns z3 with -v:2 -st, parses
stderr into a progress timeline, and infers a bottleneck
signature (fast-close / lp-bp-blowup / nlsat-stuck /
nlsat-dominant / etc.). Configs come from a default set
(default, alt-then, qfnra-nlsat, bp-off, no-propagate-eqs,
legacy-arith); drop a .ctac-z3-configs.json next to the input
to add custom named configs. Binary resolution: --z3 PATH /
CTAC_Z3 env / $PATH.
ctac smt requires loop-free, single-assert TAC (run ua first);
any bytemap capability is supported (bytemap-rw is encoded via
Store/Select). The default encoder is sea_vc — QF_UFNIA, DSA +
block-reachability, sound bv256 domain constraints, bytemap-as-UF
with a per-application range axiom.
ctac run interprets the program concretely — assume failures stop
the run silently (infeasible path), assert failures continue and
count toward assert_fail. Bytemap symbols load from model entries
(both TAC and raw z3 formats) and feed Select lookups.
ctac rw-valid -o /tmp/rwv --plain # emit all per-rule SMT specs
ctac rw-valid -o /tmp/rwv --plain --rule R4a # one rule onlyEmits a self-contained .smt2 per rule (plus manifest.json) whose
expected outcome is unsat. Currently covers R4 (5 operator variants),
R4a (base + signed), R6 (base + signed). Other rules are listed in
manifest.json:missing.
ctac prj init f.tac -o mytac --plain # create a project, HEAD = base
ctac prj list mytac --plain # show all objects + labels
ctac prj info mytac base --recursive --plain # walk parents back to base
ctac prj label mytac <sha> tip --plain # name an object
ctac prj rewind mytac --plain # HEAD back to base; keep artifacts
ctac prj reset mytac --plain # back to init state; delete derived
ctac prj export-path mytac # abs path to HEAD (shell-pipe)
ctac prj archive mytac -o snap.tar.gz --plain # pack .ctac/ for sharing
ctac prj clone snap.tar.gz -o restored # extract + rebuild symlinksA project is a working directory with a .ctac/ sidecar that
pins HEAD ("the current TAC"), records every produced artifact's
provenance, and exposes friendly-name symlinks
(base.tac, base.rw.tac, ...) on top of the content-addressed
.ctac/objects/ store. Link names derive from the project label
(default base, or --label NAME), not the original long
filename — that path is kept as source provenance, shown by
prj list / prj info. Pass the project directory to rw, ua,
pp, smt, or pin in place of a .tac path — HEAD is read for
input, and outputs are ingested into the project automatically
when -o is omitted:
ctac rw mytac --plain # HEAD: base.tac -> base.rw.tac
ctac ua mytac --plain # HEAD: base.rw.tac -> base.rw.ua.tac
ctac smt mytac --plain # writes base.rw.ua.smt2 as a HEAD siblingFilesets (directories of TAC files) are first-class. pin --split
and ua --strategy split produce a tac-set object; HEAD advances
to the fileset (e.g. base.ua.split/). Pick a member with
prj set-head to continue:
ctac ua mytac --strategy split --plain # HEAD -> base.ua.split/
ctac prj list mytac base.ua.split --plain # show members + hint
ctac prj set-head mytac base.ua.split:assert_01.tac --plain
ctac smt mytac --plain # solve the focused caseThe library surface (ctac.project.Project) is what the REPL and
other tooling should use; the CLI is a thin façade.
- TAC inputs: a directory argument is scanned as
<dir>/outputs/*.tac, ignoring-rule_not_vacuousfiles. If multiple candidates remain, ctac warns and picks deterministically. - Model inputs (
run --model <dir>,--fallback <dir>): resolved from<dir>/Reports/ctpp_<rule>-Assertions.txtfor the selected TAC's rule. Non-Assertionssuffixes emit a warning and are skipped. - Text commands (
parse,stats,search,cfg,pp,cfg-match,bb-diff) accept either.tacor.sbf.jsondirectly.
The Python API mirrors the CLI surface. Major modules:
from ctac.parse import parse_path # .tac / .sbf.json -> TacFile
from ctac.graph import Cfg, CfgFilter # CFG with filtering + rendering
from ctac.analysis import ( # data-flow passes + classifiers
analyze_dsa, analyze_liveness, eliminate_dead_assignments,
classify_bytemap_usage, BytemapCapability,
)
from ctac.rewrite import rewrite_program # rewrite driver
from ctac.rewrite.rules import simplify_pipeline, purify_pipeline
from ctac.ua import uniquify_asserts, merge_asserts
from ctac.eval import RunConfig, run_program, parse_model_path, MemoryModel
from ctac.smt import build_vc, render_smt_scriptOne worked example — slice a CFG between two blocks:
from ctac.parse import parse_path
from ctac.graph import Cfg, CfgFilter
tac = parse_path("f.tac")
cfg = Cfg(tac.program)
sliced, warnings = cfg.filtered(CfgFilter(from_id="entry", to_id="exit"))
print(f"{len(sliced.blocks)} blocks on some path from entry to exit")End-to-end — rewrite, uniquify, build VC:
import dataclasses
from ctac.parse import parse_path
from ctac.rewrite import rewrite_program
from ctac.rewrite.rules import default_pipeline
from ctac.ua import uniquify_asserts
from ctac.smt import build_vc, render_smt_script
tac = parse_path("f.tac")
rw = rewrite_program(tac.program, default_pipeline)
ua = uniquify_asserts(rw.program)
vc = build_vc(dataclasses.replace(tac, program=ua.program))
print(render_smt_script(vc))A VSCode extension for the humanized TAC output of ctac pp
(.htac) and rendered SBF files (.sbf) lives in
tools/vscode-tac/ — syntax highlighting,
go-to-definition for block targets and variables, and
find-references for variables. It is not published to the
marketplace yet; build and install locally:
cd tools/vscode-tac
just package-install # package the .vsix and install into `code`Without just:
cd tools/vscode-tac
npx @vscode/vsce package
code --install-extension ctac-vscode-*.vsixSee tools/vscode-tac/README.md for
file-association tips (e.g. treating tac1.txt / tac2.txt as
htac without renaming).
ctac-fly is a Claude Code
skill that recognizes TAC-investigation intent in a Claude session
and routes to the right ctac command — instead of letting the
agent fall back to ad-hoc grep on raw .tac files. It ships as
a marketplace-of-one; install inside any Claude Code session:
/plugin marketplace add https://github.com/1arie1/ctac-fly.git
/plugin install ctac-fly@ctac-flyThe skill assumes ctac is already on PATH (set up per
Environment). When a command needs z3, the skill
instructs the agent to use ctac smt --run --z3-path <path>
rather than mutating the user's PATH.
- Arie Gurfinkel arie@certora.com
Generative-AI coding tools contributed implementation, tests, and documentation under direction. Design decisions, scope control, and final review rest with the human authors above; all AI contributions were reviewed and committed by a human.
- Cursor (OpenAI-backed models, e.g. Codex) —
Made-with: Cursorcommit trailers. - Claude Code (Anthropic Claude Opus 4.x) —
Co-Authored-By: Claude ...commit trailers. - Occasional use of editor-integrated assistants that do not emit trailers.
Per-change attribution is in git history (git log --grep='Made-with',
git log --grep='Co-Authored-By: Claude').
MIT. See LICENSE.