diagnosis is a research tool for diagnosing falsified requirements in HLS/ThEodorE-based workflows using search-based mutation and decision-tree learning.
Given:
- a falsified requirement (a ThEodorE-generated Python property file),
- a set of execution traces (encoded as Z3 constraints),
- and a configuration describing how formulas may mutate,
diagnosis:
- Parses the requirement into a typed AST.
- Uses a genetic algorithm (GA) over structured mutations of that AST.
- Evaluates each mutated requirement against the traces via Z3.
- Logs the resulting verdicts and features into an ARFF dataset.
- Runs Weka J48 to learn a decision tree that explains why the original requirement failed.
The result is a diagnostic decision tree that relates parts of the requirement to satisfiability/violation patterns over execution traces.
The paper's data, archived runs, experiment configurations, and reproduction
scripts are maintained in the public
Diagnosis-replication-package
repository. This repository contains only the Diagnosis tool, examples, and
tests. The artifact records the original run commit as unavailable and
separately pins its declared rerun source base; do not substitute the latest
Diagnosis release when reproducing archived results.
diagnosis/
├── cli.py # CLI: run, explain-positions, inspect-internal, ...
├── config.py # JSON config -> dataclasses
├── pipeline.py # Unified pipeline: property → AST → GA → ARFF → J48
│
├── lang/
│ ├── ast.py # Typed AST definitions
│ ├── theodore_parser.py # Parse ThEodorE Python property → AST
│ ├── internal_encoder.py # AST → GA internal format + ARFF layout
│ ├── python_printer.py # AST → executable Python script for Z3
│ └── analysis.py # AST utilities (size, vars, depth, etc.)
│
├── mutation/
│ └── api.py # MutationConfig + mutate_formula (typed, safe)
│
├── diagnostics/
│ ├── arff.py # ARFF writer
│ └── j48.py # Weka J48 runner
│
├── ga.py # GA loop
├── individual.py # GA individual (AST + internal treenode)
├── harness.py # Safe Z3 evaluation
└── examples/ # Example ThEodorE properties
- Read a ThEodorE Python property (z3solver.add(Not(ForAll(...)))).
- Parse to a typed AST (lang.ast).
- Encode AST → internal GA format + ARFF layout.
- Mutate formulas through typed AST operators (numeric jitter, flips, bounds).
- Evaluate mutated properties via the safe Z3 harness (SAT / UNSAT / ERROR).
- Generate ARFF datasets from features extracted during encoding.
- Learn decision trees via Weka J48.
All of this orchestrated through:
diagnosis run --config <file.json>
You can use the docker installation or local cli installation:
For Docker (recommended):
- Docker
Build docker. From /docker run:
docker compose build
Check CLI availability:
docker compose run --rm diagnosis --help
docker compose run --rm diagnosis --version
If you do not want to use the docker installation, you'll need:
- Python ≥ 3.8
- Z3 (pip install z3-solver)
- Java (for using Weka J48)
Install the python envrionment:
python -m pip install -e .
Simply use the tool via:
diagnosis --help
Basic command help:
docker compose run --rm diagnosis --help
Full pipeline (AT1 example):
docker compose run --rm diagnosis run --config configs/AT1_config_exp1.json
This performs:
ThEodorE Python property
↓
AST (lang.ast)
↓ encode
Internal GA format + ARFF layout
↓
GA loop
(AST-based mutation)
↓ evaluate
Z3 harness
↓
ARFF dataset(s)
↓
Weka J48
↓
Diagnostic tree
All output is written in the output directory.
A config JSON includes:
- input.requirement_file – Python property file from ThEodorE
- input.traces_file – trace Z3 encoding
- ga.* – population, generations, seed, rates (ga.stopping.* – optional adaptive stopping, see "Performance options")
- mutation.* – allowed positions, numeric bounds, operator toggles
- diagnostics.* – ARFF output, Weka path, J48 options
- evaluation.* – optional evaluation tuning (cache, worker engine, parallelism, timeout), see "Performance options"
Example:
{
"input": {
"requirement_file": "diagnosis/examples/AT1_AT001.py", // ThEodorE-generated Python requirement
"traces_file": "diagnosis/tracesAT.csv", // Execution traces encoded for Z3
"output_dir": "outputs" // Directory for ARFF + decision trees + logs
},
"ga": {
"population_size": 50, // Number of candidate formulas per generation
"generations": 10, // Number of evolution iterations
"crossover_rate": 0.95, // Probability of combining two parents
"mutation_rate": 0.10, // Probability of mutating an individual
"seed": 42, // Random seed for reproducibility
"target_sats": 10, // Target number of formaulas to be satisfied and unsatisfied (stop criterion)
// Optional adaptive stopping (see "Performance options"). Omitting this
// block, or leaving mode="count", reproduces the legacy stop criterion.
"stopping": {
"mode": "count" // "count" (default) | "cv_pr" | "tree_stable"
}
},
// Optional evaluation tuning (see "Performance options"). Every key is
// opt-in; omitting the whole block preserves legacy behaviour.
"evaluation": {
"trace_check_timeout_sec": 3600, // Per-candidate Z3 trace-check timeout (legacy hard-coded value)
"cache_enabled": false, // Memoize verdicts by property-expression hash
"engine": "subprocess", // "subprocess" (default) | "worker" | "rtamt"
"parallel_workers": 1 // Evaluate a generation's candidates over N workers (1 = serial)
},
"mutation": {
"max_mutations": 1, // Max number of AST-level edits per mutation step
"enable_numeric_perturbation": true, // Allow jittering numeric constants
"enable_relop_flip": true, // Allow flipping relational operators (< → >, ≤ → ≥, etc.)
"enable_logical_flip": true, // Allow switching AND ↔ OR ↔ Implies
"enable_quantifier_flip": false, // Allow switching ∀ ↔ ∃
// Specific AST positions that are allowed to mutate (see explain-positions)
"allowed_positions": [2,6,11],
// Fine-grained control over *what* each position may change into
"allowed_changes": {
// Position 2: logical connective restricted to And, Or only, excludes Implies
"2": {
"logical": ["And","Or"]
},
// Position 6: relational operator restricted to < or >
"6": {
"relational": ["<", ">"]
},
// Position 11: numeric literal bounded to [100.0, 140.0]
"11": {
"numeric": [100.0, 140.0]
}
}
}
These keys tune how candidates are evaluated and when evolution stops.
They are performance optimizations, not behavior changes: every option
defaults to a value that reproduces legacy behavior bit-for-bit. A config
that omits the evaluation block and leaves ga.stopping.mode at "count"
produces the same verdicts, GA trajectory, ARFF datasets, J48 trees, and
report.json (modulo wall-clock/resource fields) as before these options
existed. Speedups are workload-dependent; enable an option and compare the
wall-clock fields in report.json for your requirement.
| Key | Default | Effect |
|---|---|---|
trace_check_timeout_sec |
3600 |
Per-candidate Z3 trace-check timeout, in seconds. The default matches the previously hard-coded one-hour limit, so it changes nothing; lower it to fail slow/undecidable candidates faster. |
cache_enabled |
false |
When true, verdicts are memoized by sha256 of the property-expression string in an in-memory dict backed by a SQLite file in the run directory, and consulted before any solver call. Verdict-preserving by construction (a cache hit returns the verdict the solver already produced). Hit/miss/distinct counters are written to report.json only when enabled. Helps workloads where formulas recur across generations. |
engine |
"subprocess" |
"subprocess" spawns a fresh Python+Z3 process per candidate. "worker" keeps a long-lived Z3 process. "rtamt" selects the optional RTAMT oracle for the supported AT1 and CCX templates and requires serial execution with search heuristics disabled. On Python 3.8–3.12, install it with pip install -e '.[rtamt]'; RTAMT 0.3.5's ANTLR dependency is not compatible with Python 3.13+. |
parallel_workers |
1 |
Number of candidates from one generation to evaluate concurrently, each with its own temp file (the shared temp file is used only on the serial path). Results are applied in population-index order after the batch completes, so datasets and report.json are independent of worker count and scheduling. 1 keeps the serial path untouched. The cache (if enabled) is shared race-safely via SQLite WAL. |
| Key | Default | Effect |
|---|---|---|
mode |
"count" |
"count" keeps the legacy stop criterion (at least target_sats sat and target_sats unsat samples). "cv_pr" stops once J48 weighted cross-validated precision and recall are both ≥ pr_threshold for patience consecutive checks. "tree_stable" stops once the normalized root+depth-2 tree hash is unchanged for patience consecutive checks. |
pr_threshold |
0.95 |
Precision/recall bar for cv_pr. |
check_every_generations |
1 |
Minimum number of generations between checks. |
patience |
2 |
Number of consecutive passing checks required before stopping. |
min_samples |
0 |
No check runs until this many cumulative samples exist. |
max_samples |
null |
Hard cap; evolution stops as soon as the cumulative sample count reaches it. |
Any adaptive check additionally requires at least one sat and one unsat
sample before it can fire (one-class guard), and each check is recorded in the
run summary under ga_stopping_checks when a non-count mode is active.
Four opt-in heuristics cut solver work without losing verdicts. All default
OFF; a config with no heuristics block is bit-identical to the baseline
(verified by tests/test_golden_parity.py). They are gated by a static
monotonicity proof and protected by a runtime consistency guard.
interval_inference — for a candidate that differs from the seed only at
numeric positions whose polarity is provably monotone (see explain-polarity),
the trace-check verdict is monotone in those constants. The tool reasons over the
joint space of all monotone knobs (parametric-STL monotonicity is
per-coordinate, so SATISFIED is an up-set and VIOLATED a down-set in the product
/ Pareto order) and infers a candidate's verdict with no solver call when it
dominates a known-SATISFIED point (⇒ SATISFIED) or is dominated by a
known-VIOLATED point (⇒ VIOLATED). With a single monotone knob this reduces to
the classic SATISFIED / VIOLATED half-lines plus UNDECIDED band; with several
knobs it infers candidates that moved all of them at once. A candidate that
also changes a non-monotone (UNKNOWN-polarity) numeric knob, or changes the
formula structurally, is always solved.
Soundness is doubly enforced: (1) inference is attempted only on
statically-proven monotone knobs, and (2) after every real solve the verdict
is checked against the region — a SATISFIED point dominated by a VIOLATED point
(or vice versa) is a monotonicity_violation, which permanently disables
inference, discards the region, and records a witness in report.json. In
addition, empirical_validation_k consistent real solves are required before any
verdict is inferred.
mode: "guide"— inferred verdicts only skip solver calls for the GA's bookkeeping; inferred individuals are excluded from the ARFF dataset (the decision tree trains on real verdicts only). Zero effect on diagnosis validity; smaller training set. Default.mode: "label"— inferred verdicts are written into the ARFF as normal rows (larger training set). To keep the J48 tree comparable to guide/baseline, the inferred flag is not added as an ARFF attribute (J48 would split on it); instead it is recorded in aninferred_labels.csvsidecar in the run directory. Requires the soundness argument in the paper.two_tier_timeout— solve withlow_secfirst; on UNDECIDED, re-solve once withhigh_sec(report.jsoncounters:tier1_decided,tier2_decided,tier2_undecided). A formula whose high-tier solve is UNDECIDED is cached as UNDECIDED, so exact repeats never re-solve. Region memory: when interval inference is also on and a single-mutation candidate lands inside a confirmed UNDECIDED band, the high tier is skipped (region_memory_skips) — slow requirements whose unknowns burn any budget stop paying repeatedly.high_secmust be<= evaluation.trace_check_timeout_sec(validated at load).
adaptive_range — changes numeric candidate generation for a monotone
position. With endpoint_init: true, the two configured range endpoints are
evaluated before generation 0. If the endpoints are the same decisive class,
the run records a structured one_class_space finding in report.json and
summary.json; on_one_class controls whether the run stops, widens the range
in the polarity-easier direction, or continues for comparison. During mutation,
draws come from the current SAT/UNSAT unresolved bracket with probability
1 - exploration_fraction, and from the full configured range otherwise. If no
bracket exists, a confirmed UNDECIDED band is reached, or the monotonicity guard
disables inference for that position, sampling reverts to the full range.
This is a search-behavior change: results obtained with
adaptive_range.enabled: true require re-validation against expert ground
truth because candidate distribution and GA trajectory change.
time_quantization — traces are sampled at a fixed period (detected from the
ToInt(… / PERIOD) index and cross-checked against the trace timestamp spacing),
and a signal is read as v_speed[ToInt((t - offset) / PERIOD)]. A mutated
time-window bound B therefore changes the verdict only through
floor(B / PERIOD): every B inside one inter-sample interval yields an
equivalent formula. When ON, the verdict cache is consulted with a canonical
key in which every quantizable time token is replaced by its class index
floor(value/period), so distinct raw bounds in one class collapse onto a single
solve. Only the key changes — the formula sent to the solver on a miss is the
original, un-canonicalized one, and the feature composes with both engines and the
two-tier timeout unchanged. Which positions are quantizable (and their period) is
shown by explain-polarity.
Two safety nets back the static gate: every validate_every_n_hits same-class
cache hits, the new value is actually re-solved and compared; a mismatch disables
quantization for that position, purges the class's cache entries, and logs a
quantization_violation witness (must be 0 in a valid run). A vacuity guard
flags a candidate whose two mutable bounds define an empty window (a >= b, a
vacuously SATISFIED ForAll) so it never feeds the tree as an ordinary SAT.
report.json gains quantized_positions, quant_hits_exact /
quant_hits_canonical, quant_validations, quantization_violations, and
vacuous_candidates.
In combination with adaptive_range, a quantizable monotone bound brackets
on class indices rather than raw floats (highest-known-UNSAT class to
lowest-known-SAT class), with a minimum gap of one sample period; the bracket
bisects to the boundary in ceil(log2(#classes)) real solves. The recovered
breakpoint is recorded in report.json as boundary_at — the sample
timestamp between the two adjacent classes. Reported time boundaries are
therefore sample-aligned: this is more faithful to the trace, not less
precise, since sub-sample bounds are indistinguishable to the encoding.
This is a search-behavior change: results obtained with
time_quantization.enabled: true require re-validation against expert ground
truth, and any reported boundary must be read as sample-aligned.
Per-run region state is persisted to inference_state.json; heuristic counters
are folded into report.json.
To see the proven direction of every numeric threshold position (this gates interval inference):
diagnosis explain-polarity --config configs/AT1_config_exp1.json
Pos Direction Quantizable Reason
----------------------------------------------------------------------------------------------------
11 INCREASING no threshold right of '<' against a signal term; polarity flag preserved (+) -> INCREASING
UNKNOWN means inference is disabled for that position (equality operators,
constants under arithmetic, non-quantizable temporal-window bounds, or any path
the analysis cannot soundly classify). The Quantizable column reports whether
a position is a sample-aligned time bound and its detected period; such bounds
are promoted from UNKNOWN to a definite direction (the floor of a monotone
index is monotone) and drive the time_quantization heuristic.
To see where every mutation position sits in the requirement:
docker compose run --rm diagnosis explain-positions --config configs/AT1_config_exp1.json
Example:
Pos NodeType Node Features
--------------------------------------------------------------------------------
0 ForAll ∀ t. (((0 <= t) ∧ (t <= 20000000)) → ... QUANTIFIERS0
1 Implies (((0 <= t) ∧ (t <= 20000000)) → (v_sp... LOGICALS0
2 And ((0 <= t) ∧ (t <= 20000000)) LOGICALS1
3 RelOp (0 <= t) RELATIONALS0
4 IntConst 0 NUM0
5 Var t TERM0
6 RelOp (t <= 20000000) RELATIONALS1
7 Var t TERM1
8 IntConst 20000000 NUM1
9 RelOp (v_speed[ToInt((RealVal(0) + ((t - 0... RELATIONALS2
10 Subscript v_speed[ToInt((RealVal(0) + ((t - 0 ... TERM2
11 RealConst 120.0 NUM2
To inspect a single mutation slot:
docker compose run --rm diagnosis explain-position --config configs/AT1_config_exp1.json --position 12
Happy diagnosing!