Skip to content

feat(cli): results matrix — behavior × arm comparison table - #274

Open
Chang Liu (changliu2) wants to merge 7 commits into
mainfrom
changliu2/cli-results-matrix
Open

feat(cli): results matrix — behavior × arm comparison table#274
Chang Liu (changliu2) wants to merge 7 commits into
mainfrom
changliu2/cli-results-matrix

Conversation

@changliu2

Copy link
Copy Markdown
Collaborator

Adds assert-ai results matrix, a 2D comparison view for multi-run eval results. Companion to the bank-manager one-behavior-per-yaml configs in #262 — it renders the full N-failure-modes × M-arms grid that the viewer's single-suite compare view can't.

What changed

  • New assert-ai results matrix SUITE/RUN ... under the results group.
  • Repeatable --suite <SUITE> auto-expands all scored runs in a suite; pass several to span multiple behavior-suites in one table.
  • --metric (default policy_violation), --json, --no-color, --results-dir.
  • Rows = behaviors, read from each run's config.yaml behavior.name (falls back to manifest, then suite id).
  • Columns = arm labels derived from run ids, ordered baseline → prompted → acs then any others alphabetically (deterministic).
  • Cells = the metric rate; missing behavior/arm cells render - in tables and null in JSON.

Example

assert-ai results matrix `
  --suite bank-1b-distortion --suite bank-1b-data-leak `
  --suite bank-1b-policy-fab --suite bank-1b-unauth-txn `
  --metric policy_violation

Demo output (rendered from real runs)

       Behavior × arm matrix (Policy violation)
Behavior                      baseline  prompted  acs
no_financial_distortion       96.0%     88.0%     89.8%
no_sensitive_data_leak        53.3%     57.8%     45.5%
no_fabricated_policy          92.0%     84.0%     87.8%
no_unauthorized_transactions  60.0%     50.0%     26.0%
      Behavior × arm matrix (Strict overrefusal)
Behavior                      baseline  prompted  acs
no_financial_distortion       2.0%      0.0%      8.2%
no_sensitive_data_leak        2.2%      6.7%      2.3%
no_fabricated_policy          0.0%      0.0%      0.0%
no_unauthorized_transactions  2.0%      0.0%      2.0%

Validation

8 passed, 4 skipped, 1163 deselected

Adds `assert-ai results matrix` rendering a 2D pivot (rows=behavior,
cols=arm/variant, cells=metric rate) over multiple runs, for the
N-arms × M-behaviors comparison used by the 1-behavior-per-yaml pattern.
Pivots on each run's config.yaml behavior.name and an arm label derived
from the run id. Includes --suite auto-expand, --json, and tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d429caee-f2da-4fb0-8b2c-d0eff7cf40a3
`policy_violation` unions permissible and impermissible behaviors, so ranking
behaviors by it can order them by the wrong thing entirely. On real runs the two
halves diverge sharply -- a behavior can carry a high union rate made up almost
wholly of mishandled *permissible* work while another with a lower union rate is
nearly all genuine impermissible failure. A behavior x arm matrix is precisely
the surface where that ordering matters, since its whole purpose is to say which
behavior is worst.

So the matrix now defaults to the impermissible half whenever every run reports
the split, matching the supersede rule `results list` and `results status`
already follow. It requires *all* runs to have it rather than any: one run
contributing an impermissible-only rate while another contributes the union would
put non-comparable numbers in the same table, which is worse than falling back to
the union everywhere. Runs without a taxonomy -- including quality suites that
repurpose `policy_violation` for non-safety failures -- keep reporting the union.

Fixes a bug in the process. The split is derived from node judgments plus the
taxonomy and is stored as a top-level rate, not under `dimensions`, so
`_run_dimension_rate` could not see it. Passing
`--metric policy_violation_not_permissible` resolved and *labelled* correctly and
then rendered every cell as `-`, which reads as "no violations" rather than "not
wired up". Both spellings are now accepted: the viewer-facing metric name and the
artifact rate key.

Also notes the denominators. Each half is scored only over the rows where a
behavior in that bucket was relevant, so the halves differ from each other and
from `policy_violation` -- on the career-health CV-injection baseline the
impermissible half is 4/21 while the permissible half is 16/25. Without saying
so, a reader will try to add them and find they do not reconcile to the union.

Six tests, each verified to fail against the previous behavior.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7cb46daf-b5ce-4ad5-a85d-977737e5c02b
@changliu2

Copy link
Copy Markdown
Collaborator Author

The split-vs-union default and the denominator note are the right calls, and the permissibility-split lookup fix is well justified by its tests. Two things block merge for me, both about the arm labeling being lossy on this repo's actual run-id conventions:

  1. _run_arm_label falls back to the last hyphen-delimited segment of the run id, which mislabels most existing runs in artifacts/results (variant-a-unguardedunguarded, variant-c-baseline-promptprompt, baseline-weak-promptprompt, langgraph-multimulti).
  2. Because cells are written with a plain dict assignment, two runs that collapse to the same (behavior, arm) key silently overwrite each other — one run's numbers just vanish from the table with no warning and exit code 0. The two examples above (variant-c-baseline-prompt and baseline-weak-prompt, in a --suite a --suite b invocation over behaviors that share a name) collide today.

Everything else is comment-level: an unvalidated --metric renders a full grid of - (indistinguishable from "no violations") on a typo, and the prompt-then-scenario fallback silently picks one half of a run that has both.

Inline notes (line numbers on the PR head b02773e, assert_ai/cli.py)

cli.py:1558 — must fix.

cells.setdefault(behavior, {})[arm] = _run_dimension_rate(run_summary, metric)

Two runs that resolve to the same (behavior, arm) key silently overwrite each other; the surviving cell looks authoritative and the dropped run leaves no trace. This is reachable today: with --suite bank-manager-agent-shield --suite incident-triage-agent-v1, variant-c-baseline-prompt and baseline-weak-prompt both label as prompt. It is also what you get from any repeated-seed setup (run-1, run-2 of the same arm) — exactly the "multi-run" case the command is for.
Suggested fix: detect an existing key and either error naming both run ids, or aggregate deliberately (mean over runs) and say so in the output. Silent last-write-wins is the one behavior that shouldn't survive.

cli.py:693 — must fix.

if "-" in run_id:
    return run_id.rsplit("-", 1)[-1] or run_id

The rsplit fallback only produces a meaningful label when the run id happens to end in the arm name. Against the run ids actually in artifacts/results it produces: variant-a-unguardedunguarded, variant-b-guardedguarded, variant-c-baseline-promptprompt, guarded-with-shieldshield, langgraph-multimulti (next to langgraphlanggraph). Column headers become wrong-but-plausible, which is worse than verbose.
Suggested fix: when the run id does not carry the {suite_id}- prefix, return the full run_id rather than guessing a suffix. Optionally add an explicit --arm SUITE/RUN=LABEL override for the cases where users want short headers.

cli.py:1560 — should fix. _reject_ordinal_compare is the only validation applied to metric, and it only rejects ordinal dimensions. A typo (--metric policy_violaton) resolves to nothing, every cell renders -, and the command exits 0 under a correct-looking title. Suggested fix: after loading runs, if metric is neither a permissibility key nor present in any run's dimensions, error out with the available dimension names.

cli.py:478 — comment.

prompt_rate = _dimension_rate(prompt_metrics, metric)
if prompt_rate is not None:
    return prompt_rate
return _dimension_rate(scenario_metrics, metric)

For a run with both prompt and scenario rows, the cell is the prompt rate only and the scenario half is dropped without any indication. Worth either a --rows prompt|scenario selector or a footnote when the loaded runs are not homogeneous.

cli.py:670 — comment.

config = yaml.safe_load(config_path.read_text(encoding="utf-8")) if config_path.exists() else None

Unguarded, unlike _load_dimensions (line ~174) which wraps the same call in try/except. A malformed or non-UTF-8 config.yaml in any one run aborts the whole matrix with a raw traceback instead of a CLI error. Wrap and fall through to the manifest/suite-id fallbacks already implemented below.

cli.py:1466 — nice to have. --metric here lacks shell_complete=_complete_metric, which results compare has.

cli.py:1506 — nice to have. --suite expansion appends without deduping against the positional args, so matrix suite-a/run-1 --suite suite-a loads and re-parses run-1 twice. Harmless output-wise but doubles the file I/O.

Performance: no super-linear patterns — loading is O(runs), rendering O(behaviors × arms). The one scale concern is memory, not time: _load_run_summary retains prompt_rows/scenario_rows for every run, and these stay alive for the whole command even though the matrix only reads the computed metrics. Dropping the row lists after the metrics are computed would fix it.

Tests: the split coverage is genuinely good. Gaps worth closing alongside the fixes: arm-label collision, non-conforming run ids like variant-a-unguarded, unknown-metric typo, and --suite pointing at a missing suite. Also, test_results_matrix_missing_cell_renders_null_and_dash asserts "-" in text_result.output, which is satisfied by any hyphen in the table (including behavior names) and would not fail if missing cells rendered wrongly.

Verdict: Request Changes — the arm-label collision silently drops an entire run's numbers from a table whose only job is cross-arm comparison, and the suffix-based labeling mislabels the majority of run ids already in artifacts/results.

Must fix before merge

  1. Silent cell overwrite on duplicate (behavior, arm) (cli.py:1558).
  2. _run_arm_label suffix heuristic mislabels real run ids (cli.py:693).

Should fix
3. Unvalidated --metric renders an all-dash grid at exit 0 (cli.py:1560).

Nice to have
4. Prompt-over-scenario fallback silently picks one half (cli.py:478).
5. Unguarded yaml.safe_load on run config.yaml (cli.py:670).
6. Missing shell_complete on --metric; no dedupe between positional args and --suite; retained score rows inflate memory at scale; the four test gaps above.

Chang Liu (changliu2) and others added 4 commits August 11, 2026 18:25
`_run_dimension_rate` returned whichever half was present first -- prompt if it
existed, scenario only as a fallback. Runs that have both silently reported half
their data, with nothing on screen to say so.

The halves are not interchangeable. On the career-health CV-injection baseline
the prompt rows score 64% and the scenario rows 88%, so the matrix showed 64%
for a run that is 76% overall (38/50). Cells were understated by 12 points, and
the error is invisible: a plausible number in a well-formed table.

That is the same failure the permissibility work in this PR is meant to address
-- a figure that looks authoritative while measuring something narrower than the
reader assumes -- so leaving it in place would undercut the change.

Both halves are now pooled, and pooled from counts rather than by averaging the
two rates. Averaging is wrong whenever the halves differ in size: 1/1 and 1/9 is
2/10, not the 55.6% the mean of 100% and 11.1% would give. The permissibility
split pools the same way, from the bucket detail already stored alongside each
rate, falling back to the stored rate when an older artifact lacks the detail.

Verified against real runs: the union now reports 76.0% and the impermissible
half 47.8% (22/46), both matching `results status` and
`compute_policy_violation_by_permissibility`.

Three tests, all verified to fail against the previous behavior.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7cb46daf-b5ce-4ad5-a85d-977737e5c02b
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 7cb46daf-b5ce-4ad5-a85d-977737e5c02b
Preserve unprefixed run IDs, reject cell collisions and unknown metrics, normalize derived permissibility names, and keep count-pooled prompt/scenario rates. Add stale-taxonomy, malformed-config, deduplication, and real-value regression coverage.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7cb46daf-b5ce-4ad5-a85d-977737e5c02b
# Conflicts:
#	assert_ai/cli.py
@changliu2

Copy link
Copy Markdown
Collaborator Author

Synced current main into this branch in 79c3525, preserving the results-matrix fixes and picking up the Phoenix <19.18.0 pin. The prior correctness blockers are closed: duplicate cells no longer overwrite silently, descriptive arm IDs are preserved, malformed configs surface clean errors, and unknown metrics are rejected. Focused matrix/results tests pass (47 passed, 18 subtests). The full local suite reached 1178 passed before one viewer test hit missing local npm dependencies; after npm ci, the viewer citation suite passes (16 passed). Ready for CI/re-review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants