taicpp parity: differential harness, ledger-generated PARITY.md with verified status, test_tai.py coverage map, seed cases

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 01:21:03 +02:00
co-authored by Claude Sonnet 5.5
parent 01bd396a12
commit 55b879718b
10 changed files with 2793 additions and 77 deletions
+305
View File
@@ -0,0 +1,305 @@
# Parity detz vs tai.py (generated, do not edit)
Generated by `tools/parity_ledger.py` from the symbol ledger and the last differential run.
Only a passing differential case (`tools/parity_diff.py`) makes a symbol `verified`; a facet
after the symbol says what that case proved (for example `parse` means argument parsing only).
`name-only` means an identifier matches in the C++ tree and NOTHING about behavior is known.
- tai.py sha256: `7e817bba8d48f4ab`
- detz sha256: `a7a705c9b8bd5ed0`
- differential run: 2026-10-25T01:20:14 (tai.py `7e817bba8d48f4ab`, detz `a7a705c9b8bd5ed0`)
## Summary
| kind | total | verified | name-only | missing |
|---|---|---|---|---|
| class | 264 | 0 | 28 | 236 |
| method | 2140 | 0 | 109 | 2031 |
| function | 413 | 0 | 25 | 388 |
| tool | 61 | 0 | 61 | 0 |
| flag | 45 | 3 | 42 | 0 |
| setting | 28 | 0 | 13 | 15 |
| command | 31 | 0 | 24 | 7 |
| **all** | 2982 | 3 | 302 | 2677 |
## Differential cases
Counts: fail=54, pass=9, xfail=1
| case | status | reason |
|---|---|---|
| boot.vault | fail | vault differs (44 diff lines) |
| cli.cap_manifest | fail | stdout differs (61 diff lines) |
| cli.err.ambiguous.cap | fail | stderr differs (18 diff lines) |
| cli.err.ambiguous.debug | fail | stderr differs (18 diff lines) |
| cli.err.ambiguous.install | fail | stderr differs (18 diff lines) |
| cli.err.ambiguous.tui | fail | stderr differs (18 diff lines) |
| cli.err.auto.explicit | fail | stderr differs (18 diff lines) |
| cli.err.cap-cwd.missing | fail | stdout differs (12 diff lines) |
| cli.err.cap-manifest.explicit | fail | stderr differs (18 diff lines) |
| cli.err.cap-max-turns.int | fail | stderr differs (19 diff lines) |
| cli.err.cap-max-turns.missing | fail | stdout differs (12 diff lines) |
| cli.err.cap-model.missing | fail | stdout differs (12 diff lines) |
| cli.err.cap-permission-mode.choice | fail | stderr differs (18 diff lines) |
| cli.err.cap-permission-mode.missing | fail | stdout differs (12 diff lines) |
| cli.err.cap-speak.explicit | fail | stderr differs (18 diff lines) |
| cli.err.continue.explicit | fail | stderr differs (18 diff lines) |
| cli.err.debug-format.choice | fail | stderr differs (18 diff lines) |
| cli.err.debug-format.missing | fail | stderr differs (17 diff lines) |
| cli.err.debug.choice | fail | stderr differs (18 diff lines) |
| cli.err.demo-shot.explicit | fail | stderr differs (18 diff lines) |
| cli.err.demo.explicit | fail | stderr differs (18 diff lines) |
| cli.err.env.missing | fail | stdout differs (12 diff lines) |
| cli.err.error_before_help | fail | stderr differs (18 diff lines) |
| cli.err.eval-retrieval.explicit | fail | stderr differs (18 diff lines) |
| cli.err.install-telegram.explicit | fail | stderr differs (18 diff lines) |
| cli.err.install.explicit | fail | stderr differs (18 diff lines) |
| cli.err.mcp-bind.missing | fail | stdout differs (12 diff lines) |
| cli.err.mcp-port.int | fail | stderr differs (19 diff lines) |
| cli.err.mcp-port.missing | fail | stdout differs (12 diff lines) |
| cli.err.no-opencode.explicit | fail | stderr differs (18 diff lines) |
| cli.err.plexer-wrap.explicit | fail | stderr differs (18 diff lines) |
| cli.err.profile.missing | fail | stdout differs (12 diff lines) |
| cli.err.scheduler.explicit | fail | stderr differs (18 diff lines) |
| cli.err.serve-mcp.missing | fail | stdout differs (12 diff lines) |
| cli.err.stats-rag.explicit | fail | stderr differs (18 diff lines) |
| cli.err.telegram.explicit | fail | stderr differs (18 diff lines) |
| cli.err.theme.missing | fail | stdout differs (12 diff lines) |
| cli.err.tui-local.explicit | fail | stderr differs (18 diff lines) |
| cli.err.tui.explicit | fail | stderr differs (18 diff lines) |
| cli.err.uninstall-telegram.explicit | fail | stderr differs (18 diff lines) |
| cli.err.unknown.long | fail | stderr differs (17 diff lines) |
| cli.err.unknown.short | fail | stderr differs (17 diff lines) |
| cli.err.update-models.explicit | fail | stderr differs (18 diff lines) |
| cli.err.version.explicit | fail | stderr differs (18 diff lines) |
| cli.err.yes.explicit | fail | stderr differs (18 diff lines) |
| cli.err.yolo.explicit | fail | stderr differs (18 diff lines) |
| cli.help.wins_over_error | fail | stdout differs (87 diff lines) |
| cli.prefix.version | fail | stdout differs (5 diff lines) |
| persist.restart_history | xfail | needs the foundation agent: vault events persistence and history resume across process restarts; detz has no REPL/agent |
| repl.help | fail | stdout differs (123 diff lines) |
| repl.profiles | fail | stdout differs (94 diff lines) |
| repl.skills_secret_schedules | fail | stdout differs (103 diff lines) |
| repl.tools | fail | stdout differs (139 diff lines) |
| repl.tools_named | fail | stdout differs (92 diff lines) |
| repl.unknown_command | fail | stdout differs (89 diff lines) |
## Python test-suite coverage
150 test classes, 1233 tests; tests reproduced by a passing diff case: 0 (in 0 classes); classes with no covered test: 150.
Full table: `build/parity/tests-map.md`.
## flags (45)
| name | status | facets | cases |
|---|---|---|---|
| `--allow-tool` | name-only | | |
| `--argv` | name-only | | |
| `--auto` | name-only | | |
| `--auto-answer` | name-only | | |
| `--binary` | name-only | | |
| `--cap` | name-only | | |
| `--cap-cwd` | name-only | | |
| `--cap-manifest` | name-only | | |
| `--cap-max-turns` | name-only | | |
| `--cap-model` | name-only | | |
| `--cap-permission-mode` | verified | parse | cli.version.with_choice,cli.ok.cap-permission-mode.none,cli.ok.cap-permission-mode.confirm |
| `--cap-speak` | name-only | | |
| `--continue` | name-only | | |
| `--cwd` | name-only | | |
| `--debug` | name-only | | |
| `--debug-format` | verified | parse | cli.ok.debug-format.sentence,cli.ok.debug-format.json |
| `--demo` | name-only | | |
| `--demo-shot` | name-only | | |
| `--deny-tool` | name-only | | |
| `--driver` | name-only | | |
| `--env` | name-only | | |
| `--eval-retrieval` | name-only | | |
| `--install` | name-only | | |
| `--install-telegram` | name-only | | |
| `--max-risk` | name-only | | |
| `--mcp-bind` | name-only | | |
| `--mcp-port` | name-only | | |
| `--model` | name-only | | |
| `--mplex` | name-only | | |
| `--no-opencode` | name-only | | |
| `--plexer-wrap` | name-only | | |
| `--profile` | name-only | | |
| `--scheduler` | name-only | | |
| `--serve-mcp` | name-only | | |
| `--stats-rag` | name-only | | |
| `--task` | name-only | | |
| `--telegram` | name-only | | |
| `--theme` | name-only | | |
| `--tui` | name-only | | |
| `--tui-local` | name-only | | |
| `--uninstall-telegram` | name-only | | |
| `--update-models` | name-only | | |
| `--version` | verified | output | cli.version.long |
| `--yes` | name-only | | |
| `--yolo` | name-only | | |
## tools (61)
| name | status | facets | cases |
|---|---|---|---|
| `ask_user` | name-only | | |
| `audit` | name-only | | |
| `create_bot` | name-only | | |
| `create_skill` | name-only | | |
| `delete_file` | name-only | | |
| `delete_secret` | name-only | | |
| `describe_image` | name-only | | |
| `edit_file` | name-only | | |
| `export_terminal_log` | name-only | | |
| `fork` | name-only | | |
| `get_current_terminal_content` | name-only | | |
| `graph_link` | name-only | | |
| `graph_query` | name-only | | |
| `install` | name-only | | |
| `install_skill` | name-only | | |
| `list_secrets` | name-only | | |
| `list_terminals` | name-only | | |
| `listen` | name-only | | |
| `load_skill` | name-only | | |
| `mcp_add` | name-only | | |
| `mcp_call` | name-only | | |
| `mcp_disable` | name-only | | |
| `mcp_enable` | name-only | | |
| `mcp_list` | name-only | | |
| `mcp_login` | name-only | | |
| `mcp_remove` | name-only | | |
| `mcp_tools` | name-only | | |
| `plexer_read` | name-only | | |
| `plexer_send` | name-only | | |
| `plexer_show` | name-only | | |
| `plexer_status` | name-only | | |
| `plexer_type` | name-only | | |
| `poll` | name-only | | |
| `read_file` | name-only | | |
| `recall` | name-only | | |
| `record_delete` | name-only | | |
| `record_read` | name-only | | |
| `record_save` | name-only | | |
| `record_search` | name-only | | |
| `release` | name-only | | |
| `remember` | name-only | | |
| `restore` | name-only | | |
| `schedule` | name-only | | |
| `schedules` | name-only | | |
| `search` | name-only | | |
| `shell` | name-only | | |
| `speak` | name-only | | |
| `store_secret` | name-only | | |
| `sysinfo` | name-only | | |
| `tags` | name-only | | |
| `tail_terminals` | name-only | | |
| `tui_click` | name-only | | |
| `tui_goto` | name-only | | |
| `tui_keys` | name-only | | |
| `tui_result` | name-only | | |
| `tui_screen` | name-only | | |
| `tui_type` | name-only | | |
| `unschedule` | name-only | | |
| `web_fetch` | name-only | | |
| `web_search` | name-only | | |
| `write_file` | name-only | | |
## settings (28)
| name | status | facets | cases |
|---|---|---|---|
| `backends` | name-only | | |
| `box_caps` | missing | | |
| `claude_bin` | missing | | |
| `codex_bin` | missing | | |
| `codex_sandbox` | missing | | |
| `devplace_base` | name-only | | |
| `devplace_key` | name-only | | |
| `devplace_models` | name-only | | |
| `gemini_bin` | missing | | |
| `gemini_plan` | missing | | |
| `grok_bin` | missing | | |
| `model` | name-only | | |
| `muse_bin` | missing | | |
| `opencode_bin` | missing | | |
| `opencode_client_name` | missing | | |
| `opencode_client_ua` | missing | | |
| `opencode_enabled` | missing | | |
| `opencode_models` | name-only | | |
| `openrouter_enabled` | missing | | |
| `openrouter_key` | name-only | | |
| `openrouter_models` | name-only | | |
| `pollinations_enabled` | missing | | |
| `pollinations_models` | name-only | | |
| `retry_seconds` | name-only | | |
| `theme` | name-only | | |
| `voice` | name-only | | |
| `zen_enabled` | missing | | |
| `zen_models` | name-only | | |
## commands (31)
| name | status | facets | cases |
|---|---|---|---|
| `/agent` | name-only | | |
| `/agents` | missing | | |
| `/audit` | name-only | | |
| `/bot` | missing | | |
| `/bots` | name-only | | |
| `/clear` | name-only | | |
| `/compact` | missing | | |
| `/ctx` | missing | | |
| `/demo` | name-only | | |
| `/env` | missing | | |
| `/fork` | name-only | | |
| `/graph` | name-only | | |
| `/help` | name-only | | |
| `/install` | name-only | | |
| `/models` | name-only | | |
| `/profile` | name-only | | |
| `/profiles` | missing | | |
| `/quit` | missing | | |
| `/record` | name-only | | |
| `/records` | name-only | | |
| `/release` | name-only | | |
| `/restore` | name-only | | |
| `/schedule` | name-only | | |
| `/schedules` | name-only | | |
| `/search` | name-only | | |
| `/secret` | name-only | | |
| `/skills` | name-only | | |
| `/sysinfo` | name-only | | |
| `/tags` | name-only | | |
| `/tools` | name-only | | |
| `/unschedule` | name-only | | |
## Classes with the most missing methods (top 25 of 241)
| class | missing methods |
|---|---|
| MplexCopyMode | 128 |
| MplexCommands | 92 |
| Store | 90 |
| MplexServer | 87 |
| Tools | 85 |
| TuiApp | 62 |
| GrrChatView | 55 |
| TuiPanel | 50 |
| GrrShell | 50 |
| CapAcpDriver | 44 |
| TuiVtScreen | 35 |
| ChatClient | 28 |
| Agent | 26 |
| CapA2aDriver | 24 |
| McpOAuth | 21 |
| MplexPane | 20 |
| TuiCompositor | 20 |
| TuiTextBox | 19 |
| CapPtyDriver | 19 |
| CapFleet | 19 |
| GrrPage | 18 |
| CapDriver | 17 |
| Glow | 16 |
| DebugLog | 16 |
| TuiTerminal | 16 |
+126
View File
@@ -0,0 +1,126 @@
# parity: how detz is proven equal to tai.py
Goal: detz behaves exactly like `../tai.py`. This document is the spec of the proof
machinery. It states what exists today; gaps are listed at the end.
## Pipeline
`tools/parity.sh run|check` runs, in order:
1. `tools/parity_gen_cases.py` - regenerates `tests/diff/cases/cli_generated.json` from
`build_parser()` in tai.py (every argparse error and choice message). `--check` fails if stale.
2. `tools/parity_diff.py` - the differential harness (below).
3. `tools/parity_tests.py` - mines `../test_tai.py` into `build/parity/tests-map.{json,md}`.
4. `tools/parity_ledger.py --publish` - builds the symbol ledger and writes `PARITY.md`
(generated, never hand-edited). `--check` makes the pipeline exit 1 while anything is
missing or unverified.
All output goes to `build/parity/`; only `PARITY.md` is published to the tree.
## Symbol statuses
| status | meaning | who sets it |
|---|---|---|
| missing | no identifier in `src/` or `main.cpp` matches the normalized name | ledger scan |
| name-only | an identifier matches; behavior unknown | ledger scan |
| verified | a passing differential case names the symbol | harness result only |
`verified` is never inferred. A case names a symbol as `kind:name` or `kind:name#facet`
(`#parse`, `#output`, `#listed`, ...). The facet is printed in PARITY.md, so "verified" always
says what was proven (`flag:--debug-format#parse` is argument parsing, not the flag's effect).
Evidence is rejected as stale when the sha256 of tai.py or of the detz binary differs from
the one recorded in `diff-results.json`, and a filtered run (`--filter`) writes
`diff-results-partial.json`, which the ledger ignores. A case naming a symbol the ledger does not
know is an error. Kinds: class, method, function, tool, flag, setting, command (a `/command` of
`HELP_TEXT`).
## Case format (`tests/diff/cases/*.json`)
```json
{"cases": [{
"id": "repl.help",
"doc": "what this proves",
"symbols": ["command:/help"],
"symbols_from_stdout": [{"prefix": "tool:", "pattern": "^ ([a-z_]+)$", "facet": "listed", "step": 0}],
"tests": ["CapCliTests.test_cap_manifest_flag_prints_valid_toml"],
"steps": [{"argv": [], "stdin": "", "env": {}, "files": {"rel/in/cwd": "text"},
"home_files": {"rel/in/home": "text"}, "mode": "pipe|pty", "timeout": 60}],
"compare": {"stdout": true, "stderr": true, "exit": true, "vault": false,
"files": [{"root": "home|cwd", "glob": "**/*"}],
"vault_ignore_tables": [], "vault_ignore_why": ""},
"normalize": [{"on": ["stdout"], "pattern": "regex", "replace": "<X>", "dotall": false, "why": "required"}],
"llm": false, "attempts": 1, "min_pass": 1, "checks": [], "expected_fail": "reason"
}]}
```
Semantics:
- Each side (`python3 ../tai.py`, `build/detz`) runs every step in order, sharing one throwaway
`TAI_HOME` and cwd per side (`build/parity/work/<case>/<side>-<n>/`). `HOME` is a third
throwaway dir so provider CLIs never touch the real one. Inherited `TAI_*`, `CAP_*`, `TMUX*`
variables are dropped (they leak the caller's session); `COLUMNS=80`, `LINES=24`,
`TERM=dumb`, `TAI_PASSPHRASE=` are set. A step's `env` overrides.
- Compared channels: stdout, stderr, exit code per step, vault, declared files. `mode: pty` uses
`tai_test.ManagedProcess` (stderr is merged into stdout there).
- Vault = a deterministic dump of every `*.db` under the home: table DDL, rows sorted as JSON,
index names. sqlite internals and `fts_docs_*` shadow tables are skipped globally. Anything
else skipped must be listed in `vault_ignore_tables` with `vault_ignore_why`.
- Normalization is per case, per channel, regex only, and every rule needs a `why`. There is
no global scrubbing. Rules that matched nothing on either side are listed in the report
as suspect.
- `symbols_from_stdout` adds symbols read out of the reference's own stdout (for example every
tool `/tools` prints); they count only if the case passes, so detz had to print the same names.
- `tests` lists test_tai.py tests (`Class` or `Class.test_name`) whose assertions the case
reproduces through the real binaries. This is a claim for a reviewer to read, not inferred.
- `expected_fail` marks a known gap with its reason. It still counts as not verified and as
unverified in `parity-check`; it only keeps the harness exit code at 0. If such a case
passes it is reported `xpass` and fails the run, forcing the marker to be removed.
### LLM cases
`"llm": true` follows tests/README.md: a real model call on both sides, no mocks, no database
writes standing in for the agent. Outputs are not equal-compared. Instead `checks` must hold
on each side independently: `{"kind":"exit","step":0,"value":0}`,
`{"kind":"stdout_regex","step":1,"pattern":"..."}` (also `stderr_regex`),
`{"kind":"vault_sql","sql":"select count(*) ...","op":">=","value":2}`; add `"side":"ref"|"cpp"`
to scope one. The free rotating backend is flaky by design: `attempts` (cap 5) and `min_pass`
report a ratio per side (`2/3`) in the result instead of demanding 100 percent. `--no-llm`
skips them; skipped cases are never verified.
## Caps
jobs 6, steps per case 8, step timeout 300 s, captured output 2 MB per stream, diff lines shown
40 per channel, LLM attempts 5. A timed-out step kills the whole process group.
## Test-suite map
`build/parity/tests-map.md` lists every test class of `../test_tai.py` (150 classes, 1233
tests when written), the tai.py symbols it touches (ast, from `tai.X`, `tai.X.y` and
`"tai.X.y"` patch strings; names that are not code symbols show as `data:`), the number of its
tests reproduced by a passing case, and the case ids or `uncovered`.
## Seed cases (what exists today)
- 56 generated cli cases: every missing-value, invalid-choice, invalid-int, explicit-value
error, unknown flags, ambiguous prefixes, valid prefix, `--version`, `-h`, `--help`,
`--cap-manifest`, each choice accepted.
- REPL cases: `/help`, `/tools` (names), `/tools <name>`, unknown command, profiles and bots,
empty listings, plus `boot.vault` (seeded settings catalog, schema, input_history).
- `persist.restart_history`: LLM-driven restart persistence with the same `TAI_PLEXER_PANE`
in both runs. Marked `expected_fail` until the foundation agent lands the store and history
resume. On tai.py it passed 2 of 3 attempts on the first run (free model, flaky).
## Gaps (each with what closes it)
- Full tool schema JSON and `SETTINGS_CATALOG` entries are not exposed by any CLI flag, so only
tool names (`/tools`) and the seeded settings rows (`boot.vault`) are diffable today. Closing
it needs a read-only dump command in BOTH implementations, or a Python-side oracle (a script
importing tai.py) plus a detz dump flag.
- Startup banner lines (self-backup name, ctx id, live backend probes) are stripped by a
declared per-case rule; they cannot be compared byte for byte.
- tai.py snapshots itself into the vault on boot (audit, records, chunks, tags, fts_docs). detz
has no such file; `boot.vault` excludes those tables on purpose and says so. Needs an owner
decision, not a harness change.
- Module constants, TUI rendering, mplex, grr need their own cases (pty replay); none exist.
- Ledger "name-only" is a normalized-identifier match, weak by nature; it only separates
"nothing written" from "something written".
+24
View File
@@ -0,0 +1,24 @@
# wiring: parity verification targets (for the integrator)
Add these to `taicpp/Makefile`. Nothing else in the tree needs to change; the tools live in
`tools/` and write only under `build/parity/` (plus the generated `PARITY.md`).
```make
parity: $(BIN)
DETZ_BIN=$(abspath $(BIN)) tools/parity.sh run --detz $(abspath $(BIN))
parity-check: $(BIN)
DETZ_BIN=$(abspath $(BIN)) tools/parity.sh check --detz $(abspath $(BIN))
```
- `make parity` regenerates cases, runs the differential harness against `python3 ../tai.py`
and `build/detz`, mines `../test_tai.py`, rebuilds the ledger and `PARITY.md`.
Exit 1 if a non-expected case failed.
- `make parity-check` does the same and exits non-zero while any symbol is missing or
unverified, a case names an unknown symbol, or evidence is stale. Expect it to fail for a long time.
- Extra arguments are passed to `tools/parity_diff.py`: `--no-llm`, `--filter TEXT`, `--jobs N`.
Example: `tools/parity.sh run --no-llm --filter cli.err`. A filtered run
never feeds the ledger (it writes `diff-results-partial.json`); a `--no-llm` run does feed it but parity-check reports it as a problem.
- Requires python3 (stdlib only), network for LLM cases and for the REPL startup probes.
Cases take about 4 s each because tai.py probes its backends on startup; `--jobs 6` is the cap.
- Do not run `make parity-check` in a gate that must stay green until the symbol count says so.
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
{"cases": [
{"id": "boot.vault", "doc": "Fresh home, REPL started then EOF. Compares the seeded settings catalog rows, schema of every table, input_history. audit/records/chunks/tags/edges/shell_sessions are excluded because tai.py snapshots ITSELF into the vault on boot (path of tai.py, 50 KB source as a record); that is a deliberate per-binary difference, to be decided by the foundation agent, not hidden.",
"symbols": ["function:ensure_schema#schema", "function:boot_settings#seed"],
"steps": [{"argv": [], "stdin": "", "timeout": 60}],
"compare": {"stdout": false, "stderr": true, "exit": true, "vault": true,
"vault_ignore_tables": ["audit", "records", "chunks", "tags", "edges", "shell_sessions", "fts_docs"],
"vault_ignore_why": "tai.py self-snapshot (audit/records/chunks/tags/edges/fts_docs) and shell session tree (shell_sessions) are volatile per binary and host pid"},
"normalize": [
{"on": ["vault"], "pattern": "\"\\d{4}-\\d\\d-\\d\\dT\\d\\d:\\d\\d:\\d\\d(\\.\\d+)?\\+00:00\"", "replace": "\"<TS>\"", "why": "created/updated/ts columns hold wall-clock ISO timestamps"}
]},
{"id": "repl.help", "doc": "/help text in the REPL", "symbols": ["command:/help"],
"steps": [{"argv": [], "stdin": "/help\n", "timeout": 60}],
"normalize": [{"on": ["stdout"], "pattern": "\\A.*?type /help for commands\\n", "replace": "", "dotall": true, "why": "startup banner holds self-backup file name with timestamp, ctx id with pid, and live backend probe results (model counts, latency ms)"}]},
{"id": "repl.tools", "doc": "/tools lists core and lazy tool names", "symbols": ["command:/tools"],
"symbols_from_stdout": [{"prefix": "tool:", "pattern": "^ ([a-z_]+)$", "facet": "listed", "step": 0}],
"steps": [{"argv": [], "stdin": "/tools\n", "timeout": 60}],
"normalize": [{"on": ["stdout"], "pattern": "\\A.*?type /help for commands\\n", "replace": "", "dotall": true, "why": "startup banner is volatile (see repl.help)"}]},
{"id": "repl.tools_named", "doc": "/tools shell shows one tool", "symbols": ["command:/tools"],
"steps": [{"argv": [], "stdin": "/tools shell\n/tools nosuchtool\n", "timeout": 60}],
"normalize": [{"on": ["stdout"], "pattern": "\\A.*?type /help for commands\\n", "replace": "", "dotall": true, "why": "startup banner is volatile (see repl.help)"}]},
{"id": "repl.unknown_command", "doc": "unknown slash command message", "symbols": [],
"steps": [{"argv": [], "stdin": "/nonsense\n", "timeout": 60}],
"normalize": [{"on": ["stdout"], "pattern": "\\A.*?type /help for commands\\n", "replace": "", "dotall": true, "why": "startup banner is volatile (see repl.help)"}]},
{"id": "repl.profiles", "doc": "/profiles, /profile, /bots on a fresh home", "symbols": ["command:/profiles", "command:/profile", "command:/bots"],
"steps": [{"argv": [], "stdin": "/profiles\n/profile\n/bots\n/profile work\n/profiles\n", "timeout": 60}],
"compare": {"vault": true, "vault_ignore_tables": ["audit", "records", "chunks", "tags", "edges", "shell_sessions", "fts_docs"], "vault_ignore_why": "tai.py self-snapshot and shell session tree, see boot.vault"},
"normalize": [
{"on": ["stdout"], "pattern": "\\A.*?type /help for commands\\n", "replace": "", "dotall": true, "why": "startup banner is volatile (see repl.help)"},
{"on": ["vault"], "pattern": "\"\\d{4}-\\d\\d-\\d\\dT\\d\\d:\\d\\d:\\d\\d(\\.\\d+)?\\+00:00\"", "replace": "\"<TS>\"", "why": "wall-clock timestamps"}
]},
{"id": "repl.skills_secret_schedules", "doc": "listing commands on an empty vault", "symbols": ["command:/skills", "command:/secret", "command:/schedules", "command:/tags", "command:/records"],
"steps": [{"argv": [], "stdin": "/skills\n/secret list\n/schedules\n/tags\n/records zebra\n", "timeout": 60}],
"normalize": [{"on": ["stdout"], "pattern": "\\A.*?type /help for commands\\n", "replace": "", "dotall": true, "why": "startup banner is volatile (see repl.help)"}]},
{"id": "persist.restart_history", "doc": "RESTART PERSISTENCE. Run 1 tells the agent a codeword (real LLM call), run 2 starts a new process with the SAME TAI_PLEXER_PANE and the same TAI_HOME and must resume the history: the events rows of run 1 survive and the model can answer from them. Expected to fail on detz until the foundation agent (store, history resume, plexer pane keyed session) lands. Probabilistic: real free model, 3 attempts, 1 must pass.",
"llm": true, "attempts": 3, "min_pass": 1,
"expected_fail": "needs the foundation agent: vault events persistence and history resume across process restarts; detz has no REPL/agent loop yet (2026-10-25)",
"symbols": ["function:main#restart", "class:Store#events"],
"steps": [
{"argv": [], "stdin": "My secret codeword is heliotrope-7314. Just acknowledge it in one short sentence.\n/exit\n", "env": {"TAI_PLEXER_PANE": "7"}, "timeout": 180},
{"argv": [], "stdin": "What is my secret codeword? Answer with the codeword only.\n/exit\n", "env": {"TAI_PLEXER_PANE": "7"}, "timeout": 180}
],
"checks": [
{"kind": "vault_sql", "sql": "select count(*) from events where text like '%heliotrope-7314%'", "op": ">=", "value": 2},
{"kind": "stdout_regex", "step": 1, "pattern": "heliotrope-7314"}
]}
]}
+21
View File
@@ -0,0 +1,21 @@
#!/bin/bash
# Parity pipeline. Usage: tools/parity.sh run|check [extra parity_diff.py args]
# run regenerate cases, run the differential harness, mine test_tai.py, rebuild the ledger
# and the committed PARITY.md. Exit 0 unless a non-expected case failed.
# check same as run, then exit 1 while any symbol is missing or unverified, a case names an
# unknown symbol, or the evidence is stale. This is `make parity-check`.
# Outputs go to build/parity/ (PARITY.md is published to taicpp/PARITY.md by --publish).
set -u
cd "$(dirname "$0")/.." || exit 2
mode="${1:-run}"
shift || true
rc=0
python3 tools/parity_gen_cases.py --check || { echo "regenerating cli_generated.json"; python3 tools/parity_gen_cases.py || exit 2; }
python3 tools/parity_diff.py "$@" || rc=1
python3 tools/parity_tests.py || rc=1
if [ "$mode" = "check" ]; then
python3 tools/parity_ledger.py --publish --check || rc=1
else
python3 tools/parity_ledger.py --publish || rc=1
fi
exit $rc
+602
View File
@@ -0,0 +1,602 @@
#!/usr/bin/env python3
"""Differential harness: run each case against tai.py and detz, compare.
Usage:
tools/parity_diff.py [--detz PATH] [--py PATH] [--filter TEXT] [--no-llm]
[--cases DIR] [--jobs N] [--list]
Every case runs twice, once per side, in its own throwaway TAI_HOME and cwd
(never ~/.tai), with the real binaries and a real vault. Compared channels:
stdout, stderr, exit code, vault state (sqlite dump of every *.db in the home),
and declared files. Volatile data is normalized ONLY by rules declared in the
case itself (each with a `why`); there is no blanket scrubbing. A rule that
matched nothing on either side is reported as unused.
LLM cases (`"llm": true`) make a real model call on both sides (tests/README.md
rule, no mocks). Their outputs are not equal-compared; both sides must each
satisfy the case's `checks`. They are probabilistic: `attempts` / `min_pass`
report a ratio, and the result carries it.
Writes build/parity/diff-results.json and diff-report.md. Exit 1 if any case
that is not marked expected_fail failed, or an expected_fail case passed.
See docs/parity.md for the case format.
"""
import argparse
import concurrent.futures
import difflib
import glob as globmod
import hashlib
import json
import os
import re
import shutil
import signal
import sqlite3
import subprocess
import sys
import tempfile
import time
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
REPO = os.path.dirname(ROOT)
OUT = os.path.join(ROOT, "build", "parity")
CASES_DIR = os.path.join(ROOT, "tests", "diff", "cases")
# Hard caps (nothing unbounded).
MAX_JOBS = 6
MAX_STEPS = 8
MAX_STEP_TIMEOUT = 300
MAX_DIFF_LINES = 40
MAX_CAPTURE = 2_000_000
MAX_ATTEMPTS = 5
BASE_ENV = {"COLUMNS": "80", "LINES": "24", "TAI_PASSPHRASE": "", "LC_ALL": "C.UTF-8", "TERM": "dumb"}
VAULT_SKIP_PREFIXES = ("sqlite_", "fts_docs_") # sqlite internals and fts shadow tables
CHANNELS = ("stdout", "stderr", "exit", "vault", "files")
class CaseError(Exception):
pass
def sha256_file(path):
digest = hashlib.sha256()
with open(path, "rb") as fh:
for block in iter(lambda: fh.read(1 << 20), b""):
digest.update(block)
return digest.hexdigest()
class Rule:
"""One declared normalization: regex -> replacement on named channels."""
def __init__(self, spec, case_id):
for key in ("on", "pattern", "replace", "why"):
if key not in spec:
raise CaseError("%s: normalize rule missing %r" % (case_id, key))
if not str(spec["why"]).strip():
raise CaseError("%s: normalize rule needs a non-empty why" % case_id)
self.on = list(spec["on"])
for ch in self.on:
if ch not in CHANNELS:
raise CaseError("%s: normalize rule on unknown channel %r" % (case_id, ch))
self.regex = re.compile(spec["pattern"], re.M | (re.S if spec.get("dotall") else 0))
self.replace = spec["replace"]
self.why = spec["why"]
self.hits = {"ref": 0, "cpp": 0}
def apply(self, channel, text, side):
if channel not in self.on:
return text
text, count = self.regex.subn(self.replace, text)
self.hits[side] += count
return text
class Step:
def __init__(self, spec, case_id):
self.argv = [str(a) for a in spec.get("argv", [])]
self.stdin = spec.get("stdin", "")
self.env = {k: str(v) for k, v in spec.get("env", {}).items()}
self.files = dict(spec.get("files", {}))
self.home_files = dict(spec.get("home_files", {}))
self.mode = spec.get("mode", "pipe")
self.timeout = min(float(spec.get("timeout", 60)), MAX_STEP_TIMEOUT)
if self.mode not in ("pipe", "pty"):
raise CaseError("%s: unknown step mode %r" % (case_id, self.mode))
class Case:
def __init__(self, spec, source):
self.id = spec["id"]
self.source = source
self.doc = spec.get("doc", "")
self.symbols = list(spec.get("symbols", []))
self.tests = list(spec.get("tests", []))
self.llm = bool(spec.get("llm", False))
self.expected_fail = spec.get("expected_fail")
self.steps = [Step(s, self.id) for s in spec["steps"]]
if not self.steps or len(self.steps) > MAX_STEPS:
raise CaseError("%s: 1..%d steps required" % (self.id, MAX_STEPS))
compare = spec.get("compare", {})
self.compare = {
"stdout": compare.get("stdout", True),
"stderr": compare.get("stderr", True),
"exit": compare.get("exit", True),
"vault": compare.get("vault", False),
"files": compare.get("files", []),
}
self.vault_ignore = list(compare.get("vault_ignore_tables", []))
if self.vault_ignore and not str(compare.get("vault_ignore_why", "")).strip():
raise CaseError("%s: vault_ignore_tables needs vault_ignore_why" % self.id)
self.rule_specs = list(spec.get("normalize", []))
for rs in self.rule_specs:
Rule(rs, self.id) # validate at load
self.checks = list(spec.get("checks", []))
# Symbols named by the reference's own output (e.g. every tool /tools lists), claimed only
# when the case passes, i.e. when detz printed the same thing.
self.symbols_from_stdout = list(spec.get("symbols_from_stdout", []))
self.attempts = min(int(spec.get("attempts", 1)), MAX_ATTEMPTS)
self.min_pass = int(spec.get("min_pass", 1))
if self.llm and not self.checks:
raise CaseError("%s: llm case needs checks (outputs are not equal-compared)" % self.id)
def rules(self):
return [Rule(rs, self.id) for rs in self.rule_specs]
def load_cases(directory):
cases, seen = [], {}
for path in sorted(globmod.glob(os.path.join(directory, "*.json"))):
with open(path, encoding="utf-8") as fh:
doc = json.load(fh)
for spec in doc["cases"]:
case = Case(spec, os.path.basename(path))
if case.id in seen:
raise CaseError("duplicate case id %s (%s, %s)" % (case.id, seen[case.id], path))
seen[case.id] = path
cases.append(case)
return cases
class Side:
"""One implementation under test."""
def __init__(self, name, command):
self.name = name
self.command = list(command)
class Capture:
"""Everything observed from running one case once on one side."""
def __init__(self):
self.steps = [] # list of dict(argv, rc, stdout, stderr, timed_out)
self.vault = ""
self.files = ""
self.sql = None # open read-only vault path for checks
self.home = None
self.cwd = None
class Runner:
def __init__(self, work_root):
self.work_root = work_root
def run_step(self, side, step, home, cwd):
for rel, content in step.files.items():
self._write(os.path.join(cwd, rel), content)
for rel, content in step.home_files.items():
self._write(os.path.join(home, rel), content)
# Hermetic environment: inherited TAI_*, CAP_*, TMUX* leak the caller's session into the
# run (parent shell session, pane resolution), so they are dropped. Declared per step env wins.
env = {k: v for k, v in os.environ.items() if not k.startswith(("TAI_", "CAP_", "TMUX"))}
env.update(BASE_ENV)
user_home = os.path.join(os.path.dirname(home), "user")
os.makedirs(user_home, exist_ok=True)
env.update({"TAI_HOME": home, "HOME": user_home})
env.update(step.env)
cmd = side.command + step.argv
start = time.monotonic()
if step.mode == "pty":
return self._run_pty(cmd, step, env, cwd, start)
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
cwd=cwd, env=env, start_new_session=True)
timed_out = False
try:
out, err = proc.communicate(step.stdin.encode("utf-8"), timeout=step.timeout)
except subprocess.TimeoutExpired:
timed_out = True
self._kill(proc)
out, err = proc.communicate()
return {"argv": step.argv, "rc": proc.returncode, "timed_out": timed_out,
"stdout": out[:MAX_CAPTURE].decode("utf-8", "replace"),
"stderr": err[:MAX_CAPTURE].decode("utf-8", "replace"),
"secs": round(time.monotonic() - start, 2)}
def _run_pty(self, cmd, step, env, cwd, start):
import asyncio
sys.path.insert(0, REPO)
import tai_test # the one base harness (tests/README.md); pty merges stderr into stdout
async def go():
proc = tai_test.ManagedProcess(cmd, mode="pty", cwd=cwd, env=env)
await proc.start()
timed_out = False
text = ""
try:
for line in step.stdin.splitlines():
await proc.send_line(line)
text = await proc.drain_until_eof(timeout=step.timeout)
except tai_test.TestTimeout:
timed_out = True
text = "".join(l.text for l in proc.transcript if l.stream == "stdout")
rc = await proc.terminate() if timed_out else await proc.wait(5)
await proc.terminate()
return text, rc, timed_out
text, rc, timed_out = asyncio.run(go())
return {"argv": step.argv, "rc": rc, "timed_out": timed_out, "stdout": tai_test.strip_ansi(text)[:MAX_CAPTURE],
"stderr": "", "secs": round(time.monotonic() - start, 2)}
@staticmethod
def _write(path, content):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as fh:
fh.write(content)
@staticmethod
def _kill(proc):
for sig in (signal.SIGTERM, signal.SIGKILL):
try:
os.killpg(proc.pid, sig)
except (ProcessLookupError, PermissionError):
return
try:
proc.wait(3)
return
except subprocess.TimeoutExpired:
continue
def capture(self, case, side, tag):
base = os.path.join(self.work_root, case.id.replace("/", "_"), "%s-%s" % (side.name, tag))
shutil.rmtree(base, ignore_errors=True)
home, cwd = os.path.join(base, "home"), os.path.join(base, "cwd")
os.makedirs(home)
os.makedirs(cwd)
cap = Capture()
cap.home, cap.cwd = home, cwd
for step in case.steps:
cap.steps.append(self.run_step(side, step, home, cwd))
if case.compare["vault"]:
cap.vault = VaultDump(home, case.vault_ignore).text()
if case.compare["files"]:
cap.files = FilesDump(home, cwd, case.compare["files"]).text()
return cap
class VaultDump:
"""Deterministic text dump of every sqlite db under the home (read-only copy)."""
def __init__(self, home, ignore):
self.home = home
self.ignore = set(ignore)
def text(self):
lines = []
for base, _dirs, names in sorted(os.walk(self.home)):
for name in sorted(names):
if name.endswith(".db"):
path = os.path.join(base, name)
lines.append("# db %s" % os.path.relpath(path, self.home))
lines.extend(self._dump(path))
return "\n".join(lines) + ("\n" if lines else "")
def _dump(self, path):
out = []
tmp = tempfile.mkdtemp()
try:
copy = os.path.join(tmp, "v.db")
shutil.copy(path, copy)
for suffix in ("-wal", "-shm"):
if os.path.exists(path + suffix):
shutil.copy(path + suffix, copy + suffix)
db = sqlite3.connect(copy)
db.text_factory = lambda b: b.decode("utf-8", "replace")
tables = [r for r in db.execute("SELECT name, sql FROM sqlite_master WHERE type='table' ORDER BY name")]
for name, sql in tables:
if name.startswith(VAULT_SKIP_PREFIXES) or name in self.ignore:
continue
out.append("## table %s :: %s" % (name, " ".join((sql or "").split())))
rows = [json.dumps(list(r), ensure_ascii=False, default=repr) for r in db.execute('SELECT * FROM "%s"' % name)]
out.extend(" " + r for r in sorted(rows))
idx = [r[0] for r in db.execute("SELECT name FROM sqlite_master WHERE type='index' AND name NOT LIKE 'sqlite_%' ORDER BY name")]
out.append("## indexes %s" % ",".join(idx))
db.close()
except sqlite3.Error as exc:
out.append("## unreadable: %s" % exc)
finally:
shutil.rmtree(tmp, ignore_errors=True)
return out
class FilesDump:
"""Path + content hash listing for declared globs, rooted at home or cwd."""
def __init__(self, home, cwd, specs):
self.roots = {"home": home, "cwd": cwd}
self.specs = specs
def text(self):
lines = []
for spec in self.specs:
root = self.roots[spec["root"]]
for path in sorted(globmod.glob(os.path.join(root, spec["glob"]), recursive=True)):
if os.path.isfile(path):
rel = "%s:%s" % (spec["root"], os.path.relpath(path, root))
with open(path, "rb") as fh:
data = fh.read(MAX_CAPTURE)
try:
body = data.decode("utf-8")
lines.append("%s\t%s" % (rel, json.dumps(body, ensure_ascii=False)))
except UnicodeDecodeError:
lines.append("%s\tsha256:%s" % (rel, hashlib.sha256(data).hexdigest()))
return "\n".join(sorted(lines)) + ("\n" if lines else "")
class Normalizer:
def __init__(self, case):
self.rules = case.rules()
def apply(self, channel, text, side):
for rule in self.rules:
text = rule.apply(channel, text, side)
return text
def unused(self):
return [r.why for r in self.rules if r.hits["ref"] == 0 and r.hits["cpp"] == 0]
class Checker:
"""Evaluates `checks` of an llm case against one side's capture."""
def __init__(self, case):
self.case = case
def evaluate(self, cap, side):
fails = []
for chk in self.case.checks:
if chk.get("side", "both") not in ("both", side):
continue
kind = chk["kind"]
if kind == "exit":
got = cap.steps[chk["step"]]["rc"]
if got != chk["value"]:
fails.append("exit of step %d is %r, want %r" % (chk["step"], got, chk["value"]))
elif kind in ("stdout_regex", "stderr_regex"):
text = cap.steps[chk["step"]]["stdout" if kind == "stdout_regex" else "stderr"]
if not re.search(chk["pattern"], text, re.S | re.I):
fails.append("%s step %d: /%s/ not found" % (kind, chk["step"], chk["pattern"]))
elif kind == "vault_sql":
got = self._sql(cap, chk["sql"])
if not self._cmp(got, chk["op"], chk["value"]):
fails.append("vault_sql %r gave %r, want %s %r" % (chk["sql"], got, chk["op"], chk["value"]))
else:
fails.append("unknown check kind %r" % kind)
return fails
@staticmethod
def _cmp(got, op, want):
try:
return {"==": got == want, ">=": got >= want, "<=": got <= want, ">": got > want, "<": got < want}[op]
except TypeError:
return False
@staticmethod
def _sql(cap, sql):
path = os.path.join(cap.home, "memory.db")
if not os.path.exists(path):
return None
tmp = tempfile.mkdtemp()
try:
copy = os.path.join(tmp, "v.db")
shutil.copy(path, copy)
for suffix in ("-wal", "-shm"):
if os.path.exists(path + suffix):
shutil.copy(path + suffix, copy + suffix)
db = sqlite3.connect(copy)
row = db.execute(sql).fetchone()
db.close()
return row[0] if row else None
except sqlite3.Error as exc:
return "sqlerror: %s" % exc
finally:
shutil.rmtree(tmp, ignore_errors=True)
class Verdict:
def __init__(self, case):
self.case = case
self.status = "fail" # pass | fail | xfail | xpass | skipped
self.problems = [] # human lines, capped
self.ratio = None # llm: "ok/attempts" per side
self.unused_rules = []
self.extra_symbols = []
self.secs = 0.0
def as_dict(self):
return {"id": self.case.id, "status": self.status, "llm": self.case.llm, "symbols": self.case.symbols + self.extra_symbols,
"tests": self.case.tests, "expected_fail": self.case.expected_fail, "problems": self.problems,
"ratio": self.ratio, "unused_rules": self.unused_rules, "secs": round(self.secs, 1),
"source": self.case.source}
class CaseExecutor:
def __init__(self, runner, ref, cpp):
self.runner, self.ref, self.cpp = runner, ref, cpp
def execute(self, case):
start = time.monotonic()
verdict = Verdict(case)
try:
if case.llm:
self._llm(case, verdict)
else:
self._equal(case, verdict)
except Exception as exc: # harness fault is a failure, loudly
verdict.problems.append("harness error: %r" % exc)
passed = not verdict.problems
if case.expected_fail:
verdict.status = "xpass" if passed else "xfail"
else:
verdict.status = "pass" if passed else "fail"
verdict.secs = time.monotonic() - start
return verdict
def _equal(self, case, verdict):
norm = Normalizer(case)
caps = {"ref": self.runner.capture(case, self.ref, "a"), "cpp": self.runner.capture(case, self.cpp, "a")}
for i, step in enumerate(case.steps):
for side, cap in caps.items():
if cap.steps[i]["timed_out"]:
verdict.problems.append("step %d timed out on %s (%.0fs)" % (i, side, step.timeout))
views = {}
for side, cap in caps.items():
view = {"exit": "\n".join(str(s["rc"]) for s in cap.steps) + "\n"}
view["stdout"] = "".join("--- step %d\n%s" % (i, s["stdout"]) for i, s in enumerate(cap.steps))
view["stderr"] = "".join("--- step %d\n%s" % (i, s["stderr"]) for i, s in enumerate(cap.steps))
view["vault"] = cap.vault
view["files"] = cap.files
views[side] = {ch: norm.apply(ch, txt, side) for ch, txt in view.items()}
for ch in CHANNELS:
if not case.compare[ch]:
continue
if views["ref"][ch] != views["cpp"][ch]:
verdict.problems.extend(self._diff(ch, views["ref"][ch], views["cpp"][ch]))
verdict.unused_rules = norm.unused()
for spec in case.symbols_from_stdout:
text = views["ref"]["stdout"].split("--- step %d\n" % spec["step"], 1)[-1]
names = re.findall(spec["pattern"], text, re.M)
if not names:
verdict.problems.append("symbols_from_stdout matched nothing for %s" % spec["prefix"])
verdict.extra_symbols += ["%s%s#%s" % (spec["prefix"], n, spec["facet"]) for n in names]
@staticmethod
def _diff(channel, ref, cpp):
lines = list(difflib.unified_diff(ref.splitlines(), cpp.splitlines(), "tai.py:" + channel, "detz:" + channel, lineterm="", n=1))
out = ["%s differs (%d diff lines)" % (channel, len(lines))]
out.extend(lines[:MAX_DIFF_LINES])
if len(lines) > MAX_DIFF_LINES:
out.append("... %d more diff lines" % (len(lines) - MAX_DIFF_LINES))
return out
def _llm(self, case, verdict):
checker = Checker(case)
ratios = {}
for name, side in (("ref", self.ref), ("cpp", self.cpp)):
ok, last = 0, []
for attempt in range(case.attempts):
cap = self.runner.capture(case, side, "t%d" % attempt)
fails = checker.evaluate(cap, name)
for i, s in enumerate(cap.steps):
if s["timed_out"]:
fails.append("step %d timed out" % i)
if fails:
last = fails
else:
ok += 1
ratios[name] = "%d/%d" % (ok, case.attempts)
if ok < case.min_pass:
verdict.problems.append("%s: %d/%d attempts passed, need %d" % (name, ok, case.attempts, case.min_pass))
verdict.problems.extend(" " + f for f in last[:MAX_DIFF_LINES])
verdict.ratio = ratios
class Report:
def __init__(self, verdicts, meta):
self.verdicts, self.meta = verdicts, meta
def write(self):
os.makedirs(OUT, exist_ok=True)
suffix = "-partial" if self.meta["filtered"] else "" # a filtered run never feeds the ledger
with open(os.path.join(OUT, "diff-results%s.json" % suffix), "w") as fh:
json.dump({"meta": self.meta, "results": [v.as_dict() for v in self.verdicts]}, fh, indent=1)
counts = {}
for v in self.verdicts:
counts[v.status] = counts.get(v.status, 0) + 1
lines = ["# Differential report (generated)", "",
"tai.py sha256 `%s`, detz sha256 `%s`" % (self.meta["tai_sha"][:16], self.meta["detz_sha"][:16]), "",
"Counts: " + ", ".join("%s=%d" % kv for kv in sorted(counts.items())), "",
"| case | status | llm | secs | note |", "|---|---|---|---|---|"]
for v in sorted(self.verdicts, key=lambda x: x.case.id):
note = (v.case.expected_fail or "") if v.status in ("xfail", "xpass") else ""
if v.ratio:
note += " ratio %s" % json.dumps(v.ratio)
lines.append("| %s | %s | %s | %.1f | %s |" % (v.case.id, v.status, "yes" if v.case.llm else "", v.secs, note.strip()))
lines += ["", "## Details of non-passing cases", ""]
for v in sorted(self.verdicts, key=lambda x: x.case.id):
if v.status in ("pass", "skipped"):
continue
lines += ["### %s (%s)" % (v.case.id, v.status), ""]
if v.case.expected_fail:
lines += ["expected_fail: " + v.case.expected_fail, ""]
lines += ["```"] + v.problems[:MAX_DIFF_LINES * 3] + ["```", ""]
unused = [(v.case.id, w) for v in self.verdicts for w in v.unused_rules]
if unused:
lines += ["## Unused normalization rules (suspect)", ""] + ["- %s: %s" % u for u in unused] + [""]
with open(os.path.join(OUT, "diff-report%s.md" % suffix), "w") as fh:
fh.write("\n".join(lines))
return counts
def main():
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
ap.add_argument("--detz", default=os.environ.get("DETZ_BIN", os.path.join(ROOT, "build", "detz")))
ap.add_argument("--py", default=os.environ.get("TAI_PY", os.path.join(REPO, "tai.py")))
ap.add_argument("--cases", default=CASES_DIR)
ap.add_argument("--filter", default=None)
ap.add_argument("--no-llm", action="store_true")
ap.add_argument("--jobs", type=int, default=4)
ap.add_argument("--list", action="store_true")
args = ap.parse_args()
cases = load_cases(args.cases)
if args.filter:
cases = [c for c in cases if args.filter in c.id]
if args.list:
for c in cases:
print(c.id, "llm" if c.llm else "", c.expected_fail or "")
return 0
if not os.path.isfile(args.py):
print("tai.py not found: %s" % args.py, file=sys.stderr)
return 2
detz_ok = os.path.isfile(args.detz) and os.access(args.detz, os.X_OK)
if not detz_ok:
print("detz binary missing: %s (every case will fail; build it with `make`)" % args.detz, file=sys.stderr)
work = os.path.join(OUT, "work")
shutil.rmtree(work, ignore_errors=True)
runner = Runner(work)
ref = Side("ref", [sys.executable, args.py])
cpp = Side("cpp", [args.detz] if detz_ok else ["/nonexistent/detz"])
executor = CaseExecutor(runner, ref, cpp)
todo, verdicts = [], []
for c in cases:
if c.llm and args.no_llm:
v = Verdict(c)
v.status = "skipped"
verdicts.append(v)
else:
todo.append(c)
with concurrent.futures.ThreadPoolExecutor(max_workers=max(1, min(args.jobs, MAX_JOBS))) as pool:
for v in pool.map(executor.execute, todo):
print("%-7s %s" % (v.status.upper(), v.case.id), flush=True)
verdicts.append(v)
meta = {"tai_sha": sha256_file(args.py), "detz_sha": sha256_file(args.detz) if detz_ok else "missing",
"detz_path": args.detz, "ts": time.strftime("%Y-%m-%dT%H:%M:%S"), "filtered": bool(args.filter),
"no_llm": args.no_llm}
counts = Report(verdicts, meta).write()
print("counts:", counts, "(report dir: %s)" % OUT)
bad = counts.get("fail", 0) + counts.get("xpass", 0)
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""Generate tests/diff/cases/cli_generated.json from tai.py's build_parser().
Every argparse error and choice message is exercised from the parser's own
declaration (read with ast, tai.py untouched), so the case list cannot drift
from the flags. Usage: tools/parity_gen_cases.py [--check]
--check exits 1 if the committed file differs from what the parser implies.
Facet note: these cases verify argument PARSING only (messages, exit code),
so they name symbols as `flag:--x#parse`, never the flag's behavior.
"""
import ast
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
PY = os.path.join(os.path.dirname(ROOT), "tai.py")
TARGET = os.path.join(ROOT, "tests", "diff", "cases", "cli_generated.json")
class Opt:
def __init__(self, call):
self.flag = next(a.value for a in call.args if isinstance(a, ast.Constant) and isinstance(a.value, str))
kw = {k.arg: k.value for k in call.keywords}
self.action = ast.literal_eval(kw["action"]) if "action" in kw and isinstance(kw["action"], ast.Constant) else None
self.type = kw["type"].id if "type" in kw and isinstance(kw["type"], ast.Name) else None
self.choices = list(ast.literal_eval(kw["choices"])) if "choices" in kw else None
nargs = kw.get("nargs")
self.nargs = None
if isinstance(nargs, ast.Constant):
self.nargs = nargs.value
elif isinstance(nargs, ast.Attribute):
self.nargs = nargs.attr # REMAINDER
@property
def takes_value(self):
return self.action != "store_true" and self.nargs != "REMAINDER"
@property
def optional_value(self):
return self.nargs == "?"
class CaseBuilder:
def __init__(self):
self.cases = []
def add(self, cid, doc, argv, flag, facet="parse"):
self.cases.append({
"id": cid, "doc": doc, "symbols": ["flag:%s#%s" % (flag, facet)] if flag else [],
"steps": [{"argv": argv, "timeout": 30}],
"compare": {"stdout": True, "stderr": True, "exit": True},
})
def options():
with open(PY, encoding="utf-8") as fh:
tree = ast.parse(fh.read())
fn = next(n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == "build_parser")
out = []
for node in ast.walk(fn):
if isinstance(node, ast.Call) and getattr(node.func, "attr", "") == "add_argument":
if any(isinstance(a, ast.Constant) and isinstance(a.value, str) and a.value.startswith("--") for a in node.args):
out.append(Opt(node))
return out
def build():
b = CaseBuilder()
opts = options()
for o in opts:
slug = o.flag[2:]
if o.takes_value and not o.optional_value:
b.add("cli.err.%s.missing" % slug, "%s with no value" % o.flag, [o.flag], o.flag)
if o.choices:
b.add("cli.err.%s.choice" % slug, "%s with a value outside its choices" % o.flag, [o.flag, "zzz-not-a-choice"], o.flag)
if o.type == "int":
b.add("cli.err.%s.int" % slug, "%s with a non-integer" % o.flag, [o.flag, "abc"], o.flag)
if o.action == "store_true":
b.add("cli.err.%s.explicit" % slug, "%s given an explicit value" % o.flag, ["%s=1" % o.flag], o.flag)
# Prefix abbreviation: python argparse allows unique prefixes and rejects ambiguous ones.
b.add("cli.err.unknown.long", "unknown long flag", ["--bogus"], None)
b.add("cli.err.unknown.short", "unknown short flag", ["-z"], None)
b.add("cli.err.ambiguous.debug", "--deb matches --debug and --debug-format", ["--deb"], "--debug")
b.add("cli.err.ambiguous.cap", "--cap-m matches --cap-model and --cap-max-turns", ["--cap-m"], "--cap-model")
b.add("cli.err.ambiguous.install", "--ins matches --install, --install-telegram", ["--ins"], "--install")
b.add("cli.err.ambiguous.tui", "--tu matches --tui and --tui-local", ["--tu"], "--tui")
b.add("cli.prefix.version", "unique prefix --vers resolves to --version", ["--vers"], "--version")
b.add("cli.version.long", "--version", ["--version"], "--version", "output")
b.add("cli.version.with_choice", "--version alongside a valid choice flag", ["--version", "--cap-permission-mode", "none"], "--cap-permission-mode")
b.add("cli.help.long", "--help text, exact", ["--help"], None)
b.add("cli.help.short", "-h text, exact", ["-h"], None)
b.add("cli.help.wins_over_error", "-h before a bad flag prints help, exit 0", ["-h", "--bogus"], None)
b.add("cli.err.error_before_help", "bad choice before -h is an error, exit 2", ["--cap-permission-mode", "zzz", "-h"], "--cap-permission-mode")
b.add("cli.cap_manifest", "--cap-manifest text, exact", ["--cap-manifest"], "--cap-manifest", "output")
b.cases[-1]["tests"] = ["CapCliTests.test_cap_manifest_flag_prints_valid_toml"]
# Choice success path for each choices option, parse-only via --version short-circuit is not
# reliable for all flags, so only the flags whose success path is side-effect free are listed.
for o in opts:
if o.choices:
for ch in o.choices:
if o.flag == "--debug":
continue # --debug starts logging; success behavior is a behavior case
b.add("cli.ok.%s.%s" % (o.flag[2:], ch), "%s %s accepted, then --version" % (o.flag, ch), [o.flag, ch, "--version"], o.flag)
return {"cases": b.cases}
def main():
text = json.dumps(build(), indent=1) + "\n"
if "--check" in sys.argv:
have = open(TARGET).read() if os.path.exists(TARGET) else ""
if have != text:
print("cli_generated.json is stale: run tools/parity_gen_cases.py")
return 1
print("cli_generated.json current")
return 0
os.makedirs(os.path.dirname(TARGET), exist_ok=True)
with open(TARGET, "w") as fh:
fh.write(text)
print("wrote %s (%d cases)" % (TARGET, len(json.loads(text)["cases"])))
return 0
if __name__ == "__main__":
sys.exit(main())
Regular → Executable
+290 -77
View File
@@ -1,15 +1,27 @@
#!/usr/bin/env python3
"""Parity ledger: every tai.py symbol against the C++ tree.
"""Parity ledger: every tai.py symbol against the C++ tree and the differential results.
Usage: tools/parity_ledger.py [--check] (writes build/parity/ledger.json and ledger.md)
Usage: tools/parity_ledger.py [--check] [--publish]
Status per symbol:
missing no identifier in src/ or main.cpp matches the normalized name
name-only an identifier matches; behavior is NOT verified by this tool
Verification (diff of real outputs against tai.py) is a separate, later status
and is never inferred here. --check exits 1 while any symbol is missing.
Writes build/parity/ledger.json, ledger.md and PARITY.md (all under build/).
--publish also copies the generated PARITY.md over taicpp/PARITY.md (the committed copy).
--check exits 1 while ANY symbol is missing or unverified, a case names a symbol the
ledger does not know, or the differential results are absent or stale.
Status per symbol (strongest first):
verified a passing differential case (tools/parity_diff.py, unfiltered run, tai.py and
detz hashes equal to the current files) names the symbol. Facets list WHAT the
case verified (`parse`, `output`, ...); verified never means "all behavior".
name-only an identifier in src/ or main.cpp matches the normalized name. Behavior unproven.
missing no identifier matches.
Only a differential case sets `verified`; nothing here infers it.
Symbol ids are `kind:name`, optionally `#facet` in case files:
class:Store method:Store.load function:main tool:shell flag:--version
setting:model command:/help
"""
import ast
import hashlib
import json
import os
import re
@@ -17,93 +29,294 @@ import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
PY = os.path.join(os.path.dirname(ROOT), "tai.py")
REPO = os.path.dirname(ROOT)
PY = os.path.join(REPO, "tai.py")
OUT = os.path.join(ROOT, "build", "parity")
DETZ = os.environ.get("DETZ_BIN", os.path.join(ROOT, "build", "detz"))
CASES_DIR = os.path.join(ROOT, "tests", "diff", "cases")
PARITY_MD = os.path.join(ROOT, "PARITY.md")
KINDS = ("class", "method", "function", "tool", "flag", "setting", "command")
def norm(name):
return re.sub(r"[^a-z0-9]", "", name.lower())
def cpp_idents():
idents = set()
files = [os.path.join(ROOT, "main.cpp")]
for base, _dirs, names in os.walk(os.path.join(ROOT, "src")):
files += [os.path.join(base, n) for n in names if n.endswith((".cpp", ".h", ".hpp"))]
for path in files:
with open(path, encoding="utf-8", errors="replace") as fh:
idents.update(norm(t) for t in re.findall(r"[A-Za-z_][A-Za-z0-9_]*", fh.read()))
return idents
def sha256_file(path):
if not os.path.isfile(path):
return "missing"
digest = hashlib.sha256()
with open(path, "rb") as fh:
for block in iter(lambda: fh.read(1 << 20), b""):
digest.update(block)
return digest.hexdigest()
def python_symbols(tree):
out = []
for node in tree.body:
if isinstance(node, ast.ClassDef):
out.append(("class", node.name, node.lineno))
for sub in node.body:
if isinstance(sub, (ast.FunctionDef, ast.AsyncFunctionDef)):
out.append(("method", "%s.%s" % (node.name, sub.name), sub.lineno))
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
out.append(("function", node.name, node.lineno))
return out
class PySymbols:
"""Symbols extracted from tai.py with ast."""
def __init__(self, path):
with open(path, encoding="utf-8") as fh:
self.tree = ast.parse(fh.read())
def code(self):
out = []
for node in self.tree.body:
if isinstance(node, ast.ClassDef):
out.append(("class", node.name, node.lineno))
for sub in node.body:
if isinstance(sub, (ast.FunctionDef, ast.AsyncFunctionDef)):
out.append(("method", "%s.%s" % (node.name, sub.name), sub.lineno))
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
out.append(("function", node.name, node.lineno))
return out
def tools(self):
out = []
for node in ast.walk(self.tree):
if isinstance(node, ast.Call) and getattr(node.func, "id", "") == "tool_schema" and node.args:
arg = node.args[0]
if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
out.append(("tool", arg.value, node.lineno))
return out
def flags(self):
out = []
for node in ast.walk(self.tree):
if isinstance(node, ast.Call) and getattr(node.func, "attr", "") == "add_argument":
for arg in node.args:
if isinstance(arg, ast.Constant) and isinstance(arg.value, str) and arg.value.startswith("--"):
out.append(("flag", arg.value, node.lineno))
return out
def settings(self):
out = []
for node in self.tree.body:
if isinstance(node, ast.Assign) and any(getattr(t, "id", "") == "SETTINGS_CATALOG" for t in node.targets):
for sub in ast.walk(node):
if isinstance(sub, ast.Call) and getattr(sub.func, "id", "") == "setting_spec" and sub.args:
arg = sub.args[0]
if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
out.append(("setting", arg.value, sub.lineno))
return out
def commands(self):
out = []
for node in self.tree.body:
if isinstance(node, ast.Assign) and any(getattr(t, "id", "") == "HELP_TEXT" for t in node.targets):
text = "".join(c.value for c in ast.walk(node.value) if isinstance(c, ast.Constant) and isinstance(c.value, str))
seen = set()
for line in text.splitlines():
m = re.match(r"\s+(/[a-z]+)", line)
if m and m.group(1) not in seen:
seen.add(m.group(1))
out.append(("command", m.group(1), node.lineno))
return out
def all(self):
return self.code() + self.tools() + self.flags() + self.settings() + self.commands()
def tool_names(tree):
names = []
for node in ast.walk(tree):
if isinstance(node, ast.Call) and getattr(node.func, "id", "") == "tool_schema" and node.args:
arg = node.args[0]
if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
names.append((arg.value, node.lineno))
return names
class CppIndex:
"""Normalized identifiers found in the C++ tree."""
def __init__(self):
self.idents = set()
files = [os.path.join(ROOT, "main.cpp")]
for base, _dirs, names in os.walk(os.path.join(ROOT, "src")):
files += [os.path.join(base, n) for n in names if n.endswith((".cpp", ".h", ".hpp"))]
for path in files:
if os.path.isfile(path):
with open(path, encoding="utf-8", errors="replace") as fh:
self.idents.update(norm(t) for t in re.findall(r"[A-Za-z_][A-Za-z0-9_]*", fh.read()))
def flag_names(tree):
flags = []
for node in ast.walk(tree):
if isinstance(node, ast.Call) and getattr(node.func, "attr", "") == "add_argument":
for arg in node.args:
if isinstance(arg, ast.Constant) and isinstance(arg.value, str) and arg.value.startswith("--"):
flags.append((arg.value, node.lineno))
return flags
def build():
with open(PY, encoding="utf-8") as fh:
tree = ast.parse(fh.read())
idents = cpp_idents()
rows = []
for kind, name, line in python_symbols(tree):
def has(self, kind, name):
if kind in ("tool", "setting"):
return norm(name) in self.idents
if kind == "flag":
return norm(name) in self.idents
if kind == "command":
return norm(name.lstrip("/")) in self.idents and len(norm(name)) > 3
leaf = name.split(".")[-1]
hit = norm(name.replace(".", "")) in idents or (kind != "class" and len(norm(leaf)) > 5 and norm(leaf) in idents)
rows.append({"kind": kind, "name": name, "line": line, "status": "name-only" if hit else "missing"})
for name, line in tool_names(tree):
rows.append({"kind": "tool", "name": name, "line": line, "status": "name-only" if norm(name) in idents else "missing"})
for name, line in flag_names(tree):
rows.append({"kind": "flag", "name": name, "line": line, "status": "name-only" if norm(name) in idents else "missing"})
return rows
if norm(name.replace(".", "")) in self.idents:
return True
return kind != "class" and len(norm(leaf)) > 5 and norm(leaf) in self.idents
class Evidence:
"""Passing differential cases, and whether they may be trusted for the current files."""
def __init__(self):
self.path = os.path.join(OUT, "diff-results.json")
self.problems = []
self.by_symbol = {} # "kind:name" -> {"cases": [ids], "facets": set}
self.cases = {} # id -> result dict
self.meta = None
if not os.path.isfile(self.path):
self.problems.append("no differential results (run tools/parity_diff.py)")
return
with open(self.path) as fh:
doc = json.load(fh)
self.meta = doc["meta"]
now_py, now_detz = sha256_file(PY), sha256_file(self.meta.get("detz_path", DETZ))
if self.meta["tai_sha"] != now_py:
self.problems.append("differential results are stale: tai.py changed since the run")
if self.meta["detz_sha"] != now_detz:
self.problems.append("differential results are stale: detz binary changed or missing since the run")
if self.meta.get("no_llm"):
self.problems.append("last differential run skipped LLM cases (--no-llm)")
trusted = not any("stale" in p for p in self.problems)
for res in doc["results"]:
self.cases[res["id"]] = res
if res["status"] != "pass" or not trusted:
continue
for sym in res["symbols"]:
base, _, facet = sym.partition("#")
slot = self.by_symbol.setdefault(base, {"cases": [], "facets": set()})
slot["cases"].append(res["id"])
slot["facets"].add(facet or "all")
class Ledger:
def __init__(self):
self.py = PySymbols(PY)
self.cpp = CppIndex()
self.evidence = Evidence()
self.rows = []
self.case_symbol_errors = []
self._build()
def _build(self):
known = set()
for kind, name, line in self.py.all():
sid = "%s:%s" % (kind, name)
if sid in known:
continue
known.add(sid)
ev = self.evidence.by_symbol.get(sid)
if ev:
status, facets, cases = "verified", sorted(ev["facets"]), ev["cases"]
else:
status, facets, cases = ("name-only" if self.cpp.has(kind, name) else "missing"), [], []
self.rows.append({"id": sid, "kind": kind, "name": name, "line": line, "status": status, "facets": facets, "cases": cases})
for cid, res in self.evidence.cases.items():
for sym in res["symbols"]:
if sym.partition("#")[0] not in known:
self.case_symbol_errors.append("case %s names unknown symbol %s" % (cid, sym))
def count(self, kind=None):
rows = [r for r in self.rows if kind is None or r["kind"] == kind]
tally = {"total": len(rows), "verified": 0, "name-only": 0, "missing": 0}
for r in rows:
tally[r["status"]] += 1
return tally
def problems(self):
out = list(self.evidence.problems) + self.case_symbol_errors
t = self.count()
if t["missing"]:
out.append("%d symbols missing" % t["missing"])
if t["name-only"]:
out.append("%d symbols name-only (unverified)" % t["name-only"])
return out
class Writer:
def __init__(self, ledger):
self.ledger = ledger
def tally_table(self):
lines = ["| kind | total | verified | name-only | missing |", "|---|---|---|---|---|"]
for kind in KINDS + (None,):
t = self.ledger.count(kind)
lines.append("| %s | %d | %d | %d | %d |" % (kind or "**all**", t["total"], t["verified"], t["name-only"], t["missing"]))
return lines
def parity_md(self):
led, ev = self.ledger, self.ledger.evidence
meta = ev.meta or {}
lines = ["# Parity detz vs tai.py (generated, do not edit)", "",
"Generated by `tools/parity_ledger.py` from the symbol ledger and the last differential run.",
"Only a passing differential case (`tools/parity_diff.py`) makes a symbol `verified`; a facet",
"after the symbol says what that case proved (for example `parse` means argument parsing only).",
"`name-only` means an identifier matches in the C++ tree and NOTHING about behavior is known.", "",
"- tai.py sha256: `%s`" % sha256_file(PY)[:16],
"- detz sha256: `%s`" % sha256_file(meta.get("detz_path", DETZ))[:16],
"- differential run: %s (tai.py `%s`, detz `%s`)" % (meta.get("ts", "none"), meta.get("tai_sha", "-")[:16], meta.get("detz_sha", "-")[:16]), ""]
if ev.problems:
lines += ["## Evidence problems", ""] + ["- " + p for p in ev.problems] + [""]
lines += ["## Summary", ""] + self.tally_table() + [""]
results = list(ev.cases.values())
counts = {}
for r in results:
counts[r["status"]] = counts.get(r["status"], 0) + 1
lines += ["## Differential cases", "", "Counts: " + (", ".join("%s=%d" % kv for kv in sorted(counts.items())) or "none"), ""]
failing = [r for r in results if r["status"] in ("fail", "xfail", "xpass")]
if failing:
lines += ["| case | status | reason |", "|---|---|---|"]
for r in sorted(failing, key=lambda x: x["id"]):
reason = r["expected_fail"] or (r["problems"][0] if r["problems"] else "")
lines.append("| %s | %s | %s |" % (r["id"], r["status"], reason.replace("|", "/")[:120]))
lines.append("")
tm = os.path.join(OUT, "tests-map.json")
if os.path.isfile(tm):
with open(tm) as fh:
cov = json.load(fh)["summary"]
lines += ["## Python test-suite coverage", "",
"%d test classes, %d tests; tests reproduced by a passing diff case: %d (in %d classes); classes with no covered test: %d."
% (cov["classes"], cov["tests"], cov["tests_covered"], cov["classes_covered"], cov["classes_uncovered"]),
"Full table: `build/parity/tests-map.md`.", ""]
for kind in ("flag", "tool", "setting", "command"):
rows = [r for r in led.rows if r["kind"] == kind]
lines += ["## %ss (%d)" % (kind, len(rows)), "", "| name | status | facets | cases |", "|---|---|---|---|"]
for r in sorted(rows, key=lambda x: x["name"]):
lines.append("| `%s` | %s | %s | %s |" % (r["name"], r["status"], ",".join(r["facets"]), ",".join(r["cases"][:3])))
lines.append("")
miss = {}
for r in led.rows:
if r["kind"] == "method" and r["status"] == "missing":
miss.setdefault(r["name"].split(".")[0], 0)
miss[r["name"].split(".")[0]] += 1
top = sorted(miss.items(), key=lambda kv: -kv[1])[:25]
lines += ["## Classes with the most missing methods (top 25 of %d)" % len(miss), "", "| class | missing methods |", "|---|---|"]
lines += ["| %s | %d |" % kv for kv in top]
lines.append("")
return "\n".join(lines)
def ledger_md(self):
lines = ["# Parity ledger (generated, do not edit)", ""] + self.tally_table() + [""]
lines += ["| id | status | facets | cases |", "|---|---|---|---|"]
for r in self.ledger.rows:
lines.append("| `%s` | %s | %s | %s |" % (r["id"], r["status"], ",".join(r["facets"]), ",".join(r["cases"][:3])))
return "\n".join(lines) + "\n"
def write(self, publish):
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "ledger.json"), "w") as fh:
json.dump(self.ledger.rows, fh, indent=1)
with open(os.path.join(OUT, "ledger.md"), "w") as fh:
fh.write(self.ledger_md())
text = self.parity_md()
with open(os.path.join(OUT, "PARITY.md"), "w") as fh:
fh.write(text)
if publish:
with open(PARITY_MD, "w") as fh:
fh.write(text)
def main():
rows = build()
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "ledger.json"), "w") as fh:
json.dump(rows, fh, indent=1)
kinds = sorted({r["kind"] for r in rows})
lines = ["# Parity ledger (generated, do not edit)", "",
"Source: tai.py. `name-only` means an identifier matches, NOT that behavior is verified.", "",
"| kind | total | name-only | missing |", "|---|---|---|---|"]
for kind in kinds:
sub = [r for r in rows if r["kind"] == kind]
have = sum(1 for r in sub if r["status"] == "name-only")
lines.append("| %s | %d | %d | %d |" % (kind, len(sub), have, len(sub) - have))
total_missing = sum(1 for r in rows if r["status"] == "missing")
lines += ["", "Total symbols: %d, missing: %d, verified: 0" % (len(rows), total_missing)]
with open(os.path.join(OUT, "ledger.md"), "w") as fh:
fh.write("\n".join(lines) + "\n")
print("\n".join(lines))
return 1 if "--check" in sys.argv and total_missing else 0
ledger = Ledger()
Writer(ledger).write("--publish" in sys.argv)
print("\n".join(Writer(ledger).tally_table()))
problems = ledger.problems()
for p in problems:
print("PROBLEM: %s" % p)
if "--check" in sys.argv:
if problems:
print("parity-check: FAIL (%d problems)" % len(problems))
return 1
print("parity-check: OK")
return 0
if __name__ == "__main__":
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""Mine ../test_tai.py into a coverage table: test class -> tai.py symbols -> diff case or `uncovered`.
Usage: tools/parity_tests.py (writes build/parity/tests-map.json and tests-map.md)
For every unittest class in test_tai.py (ast, never imported): the number of tests, and the
tai.py symbols it touches (`tai.X`, `tai.X.y`, and `"tai.X.y"` mock.patch strings), resolved
against the ledger ids (class:X, function:X, method:X.y; module data shows as data:X).
A test (or whole class) maps to a differential case ONLY when the case lists `Class` or `Class.test_name` in its `tests` field
AND that case passed in the last unfiltered run. Everything else is `uncovered`. Listing a
class in a case means the case reproduces what those tests assert through the real binaries;
it is a claim the reviewer can read in the case file, not something inferred here.
"""
import ast
import json
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import parity_ledger as pl # noqa: E402
TEST_FILE = os.path.join(pl.REPO, "test_tai.py")
class TestMiner:
def __init__(self, path, symbol_ids):
with open(path, encoding="utf-8") as fh:
self.tree = ast.parse(fh.read())
self.ids = symbol_ids
def classes(self):
out = []
for node in self.tree.body:
if isinstance(node, ast.ClassDef):
tests = [n for n in node.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) and n.name.startswith("test")]
if tests:
out.append({"class": node.name, "line": node.lineno, "tests": len(tests), "names": [t.name for t in tests], "symbols": self.symbols(node)})
return out
def symbols(self, cls):
found = set()
for node in ast.walk(cls):
if isinstance(node, ast.Attribute):
inner = node.value
if isinstance(inner, ast.Name) and inner.id == "tai":
found.add(node.attr)
elif isinstance(inner, ast.Attribute) and isinstance(inner.value, ast.Name) and inner.value.id == "tai":
found.add("%s.%s" % (inner.attr, node.attr))
elif isinstance(node, ast.Constant) and isinstance(node.value, str):
for m in re.finditer(r"\btai\.([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)?)\b", node.value):
found.add(m.group(1))
return sorted({self.resolve(n) for n in found})
def resolve(self, name):
for kind in ("method", "class", "function"):
sid = "%s:%s" % (kind, name)
if sid in self.ids:
return sid
head = name.split(".")[0]
if "." in name and "class:%s" % head in self.ids:
return "class:%s" % head # attribute or nested name on a known class
return "data:%s" % name
def main():
ledger = pl.Ledger()
ids = {r["id"] for r in ledger.rows}
classes = TestMiner(TEST_FILE, ids).classes()
ev = ledger.evidence
by_test = {} # "Class" or "Class.test" -> passing case ids
for cid, res in ev.cases.items():
if res["status"] == "pass":
for tc in res["tests"]:
by_test.setdefault(tc, []).append(cid)
errors = []
names = {c["class"]: set(c["names"]) for c in classes}
for cid, res in ev.cases.items():
for tc in res["tests"]:
cls, _, test = tc.partition(".")
if cls not in names or (test and test not in names[cls]):
errors.append("case %s names unknown test %s" % (cid, tc))
for c in classes:
whole = by_test.get(c["class"], [])
per = {n: sorted(set(whole + by_test.get("%s.%s" % (c["class"], n), []))) for n in c["names"]}
c["covered_tests"] = sum(1 for v in per.values() if v)
c["cases"] = sorted({cid for v in per.values() for cid in v})
c["status"] = "uncovered" if not c["covered_tests"] else ("covered" if c["covered_tests"] == c["tests"] else "partial")
total_tests = sum(c["tests"] for c in classes)
cov = [c for c in classes if c["covered_tests"]]
summary = {"classes": len(classes), "tests": total_tests, "classes_covered": len(cov),
"tests_covered": sum(c["covered_tests"] for c in classes), "classes_uncovered": len(classes) - len(cov)}
os.makedirs(pl.OUT, exist_ok=True)
with open(os.path.join(pl.OUT, "tests-map.json"), "w") as fh:
json.dump({"summary": summary, "classes": classes, "errors": sorted(set(errors))}, fh, indent=1)
lines = ["# test_tai.py coverage map (generated)", "",
"%(classes)d classes, %(tests)d tests. Tests reproduced by a passing diff case: %(tests_covered)d, in %(classes_covered)d classes. Fully uncovered classes: %(classes_uncovered)d." % summary, "",
"| test class | tests | covered | status | diff cases | tai.py symbols |", "|---|---|---|---|---|---|"]
for c in sorted(classes, key=lambda x: (x["status"], -x["tests"], x["class"])):
syms = ", ".join(c["symbols"][:8]) + (" ... +%d" % (len(c["symbols"]) - 8) if len(c["symbols"]) > 8 else "")
lines.append("| %s | %d | %d | %s | %s | %s |" % (c["class"], c["tests"], c["covered_tests"], c["status"], ",".join(c["cases"]) or "uncovered", syms))
if errors:
lines += ["", "## Errors", ""] + ["- " + e for e in sorted(set(errors))]
with open(os.path.join(pl.OUT, "tests-map.md"), "w") as fh:
fh.write("\n".join(lines) + "\n")
print("test classes %(classes)d, tests %(tests)d, covered classes %(classes_covered)d (tests %(tests_covered)d), uncovered classes %(classes_uncovered)d" % summary)
for e in sorted(set(errors)):
print("ERROR:", e)
return 1 if errors else 0
if __name__ == "__main__":
sys.exit(main())