[Evals] Security LLM performance matrix generator in @kbn/evals-extensions - #285833
Draft
patrykkopycinski wants to merge 271 commits into
Draft
[Evals] Security LLM performance matrix generator in @kbn/evals-extensions#285833patrykkopycinski wants to merge 271 commits into
patrykkopycinski wants to merge 271 commits into
Conversation
Contributor
There was a problem hiding this comment.
One security finding: the HTML report renderer rewrites markdown links from untrusted evaluated-model answer text into <a href> without scheme validation, allowing (redacted) URI XSS in the generated matrix.html`. Details inline. The rest of the matrix pipeline (config schema, build/aggregation, query paths, CSV/markdown/JSON rendering) is well-tested and reads cleanly.
Generated by Claude Reviewer for #285833 · 162.3 AIC · ⌖ 16.6 AIC · ⊞ 4.6K
patrykkopycinski
added a commit
to patrykkopycinski/kibana
that referenced
this pull request
Aug 18, 2026
patrykkopycinski
marked this pull request as draft
August 18, 2026 23:39
|
🤖 Jobs for this PR can be triggered through checkboxes. 🚧
ℹ️ To trigger the CI, please tick the checkbox below 👇
|
Contributor
Author
|
buildkite test this |
6 similar comments
Contributor
Author
|
buildkite test this |
Contributor
Author
|
buildkite test this |
Contributor
Author
|
buildkite test this |
Contributor
Author
|
buildkite test this |
Contributor
Author
|
buildkite test this |
Contributor
Author
|
buildkite test this |
Contributor
Author
|
buildkite test this |
…rix config set The judge's /internal/inference/prompt requests carry the full agent trajectory and exceed the Scout default 1.6MB payload cap on long multi-step persona-matrix examples (observed 413 on gemini-3.1-flash-lite). Filter the inherited --server.maxPayload arg and re-set it deterministically in this config set so arg ordering cannot reintroduce the cap.
…rable suite timeout Per-variant trace cards: category columns aggregate the a/b/c variants of a category, but model cards rendered only the first-wins representative trace, so 14 of the suite's 21 prompts were invisible in the report. Columns with examplePrefixes now render one expandable card per variant that has a trace, falling back to the representative for overall columns. Playwright timeout: the suite's fixed 30min ceiling covers a single-pass 21-example run, but determinism runs (EVAL_REPETITIONS=3) triple the workload (~90min observed) and died at example 7/21. The config now reads PERSONA_MATRIX_TIMEOUT_MINUTES, defaulting to 30 so ordinary runs are unchanged. Verified: eslint clean, persona-matrix jest 16/16, project type_check 0 errors.
This was referenced Aug 21, 2026
Contributor
Author
|
buildkite test this |
…arding, timeout gate scripts/orca-vm/persona_matrix_sweep.py — provisions/depreploys per-model Azure VMs, deploys six overlays (evaluator full-set ETC, scout config, eval_stack scout-timeout, playwright suite timeout, seeds, maxpayload), forwards EVAL_REPETITIONS + PERSONA_MATRIX_TIMEOUT_MINUTES over SSH, and gates PASS on 21 examples x 12 evaluator docs x reps (756 at 3 reps) in golden. run_model.sh — per-VM model launcher exporting both env vars with defaults. retry_sweep.sh — warm retry for still-provisioned VMs, forwarding both vars. Verified: py_compile, bash -n, shellcheck clean; 4.5-opus 3-rep run exported exactly 756 docs through this controller.
…n can't kill an experiment A single failing example (converse 500, judge inference error) previously rejected Promise.all(runJobs) and aborted the whole experiment, discarding every other example's measurements — three persona-matrix determinism runs died this way, deep into the suite, on transient inference errors. - kbn-evals executor: wrap each task run; record the failure on the run (TaskRun.error), skip its evaluators, keep going. Wrap each evaluator call; record score:null/'error' result docs so gaps stay visible in exported scores instead of silently dropped. After all jobs complete, reject with an aggregate failure so errored experiments never go green; an evaluator failing on EVERY run is a broken instrument, not a measurement, and fails the experiment too. - persona-matrix suite: judge precompute (correctness/groundedness analysis) degrades per analysis — a failed judge means N/A qualitative scores for that example while deterministic evaluators still score the real trajectory. Tests: client.test.ts +3 (task failure isolation, evaluator failure isolation, broken-instrument detection); evaluate_dataset.test.ts +1 (judge rejection keeps trajectory, degrades qualitative to unavailable).
…ep VMs One example's converse/judge 500 previously aborted the whole experiment mid-suite; deploy() now overlays the patched executor client + TaskRun type, and the overlay verification greps for erroredRuns.
Contributor
Author
|
buildkite test this |
`agentBuilder:tracing:includeToolDetails` defaults to false for privacy. Agent Builder's span processor then strips `gen_ai.tool.call.arguments` at span creation, so trace-based evaluators that match on those arguments score 0 for every model -- which reads as models that stopped invoking skills rather than a missing attribute. The existing test covers evals_tracing alone. Every other evals_* set inherits the setting by spreading that config, and nothing catches a new set that forgets to, or an inherited value that gets overridden back. Enumerate the config_sets directory instead of listing suites, so a set added later is covered without anyone remembering this file exists. Mutation-tested: overriding one set to false fails 1 test, a new set that omits the flag fails 1, and breaking the shared base fails 25.
Three related integrity fixes.
1. Skill steps rendered '[object Object]' in every trace card (1,708
occurrences on the published board). Agent Builder emits skills as
{ id, name, path, description } objects, but TraceStep typed them as
string[] so .join() stringified objects and TypeScript could not catch
it. Widen the type to the shape actually on the wire and normalise at
the render boundary.
2. Sweep VM names embedded the model under test (orca-sweep-anthropic-
claude-4-8-opus). The VM enrols into the eval cluster as a host entity,
so entity-analytics answers echoed the name back into judged text --
14 agent outputs identified their own model to the LLM judge. Name VMs
by hash and keep the readable model on an Azure tag instead.
3. Self-test asserts the blinding holds for every vendor token, and that
the existing collision guarantees (distinct models, distinct shards,
eis- prefix equivalence, 64-char limit) survive the change.
…scovery from golden
ConnectorInvoked evaluator
--------------------------
`ExpectedToolCalled` only proves the agent called
`platform.core.generate_workflow`. It never inspects the result, so a model
that calls the tool and emits a workflow with no http step -- or one pointed at
the wrong connector -- still scores 1.0. The workflow-authoring prompts ask for
a Slack post against a named connector id, so "did it actually target that
connector" is checkable in code rather than left to a prose judge.
The evaluator scans the produced text for the required connector id and a
`type: http` YAML key. Requiring the key means narration ("I would use an http
step against connector d7306385...") scores 0 while an authored workflow scores
1. Returns null/N-A when an example declares no connector expectation, so it
never dilutes datasets it does not apply to.
Variant B names no connector id in its prompt, so it asserts only the step type
-- asserting an id the prompt never supplied would false-fail a correct answer.
Attack-discovery board
----------------------
`extract_attack_discovery.py` reads the golden cluster and
`render_attack_discovery_board.py` renders it. Fields under `task.output.*` are
mapped non-queryable (`exists` returns 0 against them), so the extractor scans
_source rather than aggregating, with the doc count reported both ways to prove
the scan saw everything.
Latency is recovered from the `Latency` evaluator rather than a task field.
`totalRisk` has no source anywhere in our schema; it renders as a visible blank
and `absentFields` is computed from the rows, so it cannot drift from reality.
The board is NOT a 1:1 reproduction of the reference artifact and says so: the
reference reports one large run (8 discoveries over 95 alerts for every model)
while our golden data is a scenario-fixture suite (0/1/4 discoveries over 0-16
alerts). Copying that shape would describe a benchmark we did not run. Every
mean carries its own n so a 7-doc mean cannot pose as a 1040-doc one.
Guards assert what lands in the HTML, not that the operator remembered a flag:
the renderer refuses an empty aggregate or one where no model carries a
discovery count. Both refusals were mutation-tested (removed -> tests fail ->
restored -> pass), as was the connector check (forced pass -> 3 tests fail).
`expectedStepType` comes from dataset metadata and was interpolated into a RegExp unescaped, so a step type carrying regex metacharacters would match things it should not -- `a.b` silently matching `type: axb`. Escape it, and cover the case with a test that fails against the unescaped version. Also trims the file header, which restated in prose what the code and the per-branch comments already say.
…f vs reference The attack-discovery board previously showed 39 model rows with no statement of where each column came from, and silently omitted models that appear in the reference artifact. Both make the board easy to over-read. * render: a column-provenance table naming the exact source field for every column, including that Latency comes from the `Latency` EVALUATOR and not a `task.output` field, and that Total risk has no source at all. * render: reference-only models render as explicitly-missing rows with blank cells and a "never run in this suite" label, so absence is visible rather than inferred from a shorter table. * diff_attack_discovery_vs_reference.py: per-field comparison against the reference artifact. Result: 15/95 fields identical (15.8%), isOneToOne=false. A "1:1" claim now has to survive this file. * blinding_ab_test.py: judges identical transcripts with and without a visible host hint through the omni-opus-5 combo, recording paired scores and the combo's token usage. Guards extended 5 -> 8 groups. Mutation-tested: filling missing-model rows with the reference constants (8 discoveries / 95 alerts) fails 9 assertions; restored -> 8/8 pass.
A mean delta over 14 pairs is not a result without a distinguishability check. Adds exact two-sided sign test and USD cost from the combo's reported usage.
`ExpectedToolCalled` maps tool calls to `tool_id` and asserts the id is present. It never inspects arguments or output, so a model scores 1 for calling `generate_workflow` even when the workflow it produced targets no connector at all. Measured against golden: 36 cells score 1.0, only 26 author a real `type: http` step at the connector -- 30 false greens. Adds the measurement script, renders the result into the board with its sampling caveat, and guards both (a hollow false-green file is refused; a dropped caveat fails the suite).
…sured data Generated from connector_false_green.json so no number in the report can drift from the measurement it describes.
…ire base v7 The dense-profile spec (PR elastic#290163, dense_profile_live_retrieval.spec.ts) adds a 10th example to the AD suite: 10 x 13 evaluators = 130 score docs. Measured on a live dense canary (orca-base-builder-v7, ad-dense @ 5303a12, 2026-09-10, '10 passed (21.2m)'): 130 docs, dense example analysed ~100 alerts (4 chains, 16 rules) -- magnitude-comparable to the reference artifact's 95. Requires orca-eval-base-v7 (baked from ad-dense merged with this branch); a v5 VM has no dense spec and would emit 117 docs, failing this gate. Gate stays 'exact' so a regression in either direction is caught.
The AD gate read the golden doc count once at eval exit and raced the OTel flush: 2026-09-10 dense canary held all 130 docs in golden, but the gate read 117 because the dense example (experiment 10/10, 13 docs) flushed ~1 min after the eval process exited. Poll the count until it stops growing across two consecutive reads (8-min window covering the documented 3-7 min flush lag), then read the stable value.
…lity The first poll stopped when the count 'stopped growing' -- but the last experiment's docs land as a single burst after several stable reads, so it returned 117 while golden already held 130 (dense example, 2026-09-10 canary). Compute the exact-gate target before counting and poll until count reaches it (or the 8-min flush window elapses). Golden now returns 130 for the same execution_id.
…delivery lag reaches 10-20 min
The gate resolved task.model.id without the 'eis-' prefix, so it matched only the stale reference artifact (dotted, unprefixed, 117 docs) and gated the fresh 130-doc dense run against it (three FAIL 117/130 canaries). Emit every prefix x version spelling in _score_id_candidates and stop stripping the prefix before resolution.
The lookup gated on the single stored_id string, which comes from the VM's LOCAL scout index and holds the display name (dotted, unprefixed). That term matched only the stale reference artifact (117 docs) and false-FAILed the fresh 130-doc dense run as docs=117/130. Match any (prefix x version) spelling via _score_id_candidates(model) and sort to the true latest exec.
- SUITE_PROFILES entry: 1 example, exact gate (measured 8 docs = 8 evaluators x 1 x 1 on smoke exec 5bd8fa4a589f4cf4), honors_shard False, vm_prefix orca-pmad - deploy(): ship GCS dataset credentials + restore.ts dual-write fix overlay for the AD suite (no-ops for persona runs) - run_model.sh: load GCS_CREDENTIALS from shipped file, AD suite only, fail fast when missing - durable GCS cred copy in ~/.elastic (survives /tmp wipes) Self-test: 0 failures. py_compile OK, bash -n OK.
extract_attack_discovery.py: - --since date scope (34 historic executions excluded from board renders) - persona-matrix schema: raw.status/alerts_context_count/latency_ms, insights, errors promoted once per execution - traceCards payload (executionId, traceId, full insights) for board cards - generateErrors surfaced so a failed generation can't hide behind doc counts render_attack_discovery_board.py: - rewritten for persona-matrix shape; discoveries = insights count - per-model trace cards with final answers (title/risk/tactics/summary) - missing-models rows (flash-lite timeout, glm-5.2 blocked, judge excluded) - column-source table; blank-not-imputed policy for totalRisk
…d path The persona-matrix-maxpayload worktree was pruned; deploys failed with scp stat errors. The evals-ext-matrix tree carries the identical config (maxPayload raise + includeToolDetails via evals_tracing spread), so the overlay now sources from there.
- score docs give steps/answers per execution (dedup by repetition_index) - execute_tool spans give gen_ai.tool.call.arguments (includeToolDetails) - join by trace_id + tool-name order; unmatched -> explicit null - final answers from task.output.messages[].message
- render_agent_eval_full.py: scoreboard + trace cards + answers + disclosures (provenance, includeToolDetails evidence, missing models, oss-20b partial-coverage note). Zero hand-edited numbers. - build_traces_json.py: pick most-doc'd execution per (model, prompt) so retry runs never blend; deterministic.
EIS runs emit dash ids (anthropic-claude-4-5-haiku), reference board uses dotted (anthropic-claude-4.5-haiku). Without the map, models with data rendered as missing rows. + honest disclosure line for oss-20b per-prompt best-execution selection.
rainbow-sprinkles appears in the Aug-26 agent_eval_full.html but not the -2 reference; drop it from REFERENCE_MODELS. Board accounting now exact: 24 data rows + 9 disclosed-missing = 33.
run_model.sh: synthesize connector+proxy+endpoint for selfhost-* JUDGE (e.g. selfhost-omni-opus-5 on omniroute) when candidate is eis-*. Runs after the final CONNS export so nothing clobbers it; proxy strips the reasoning deltas the cursor failover route emits. Fails fast (exit 3) when /tmp/judge.env is missing instead of 404ing mid-suite. persona_matrix_sweep.py: deploy() ships .selfhost-judge.env -> /tmp/judge.env when present (gitignored, same pattern as .selfhost.env). render_attack_discovery_eis_trial.py: renders attack_discovery_results-3 (EIS Model Trial) from a golden extract; model list parsed from the reference artifact; classification have-data/failed/broken/no-connector/ not-run from extract + connectors cache; every cell traces to an extract doc. 4 models disclosed broken at render time (2026-09-11 directive: top-up skipped, not chased): eis-gp-llm-v2, eis-google-gemini-2-5-flash, eis-google-gemini-2-5-flash-lite, eis-zai-glm-5-2.
openrouter_proxy.py read no env at all — PROXY_UPSTREAM was decorative, so a selfhost judge's proxy still targeted openrouter.ai and 401'd every call (proven live on the Azure canary: DIRECT_PUBLIC=200 vs VIA_PROXY=401). TARGET now reads PROXY_UPSTREAM at import (default unchanged); the judge branch rides port 8089 so a selfhost candidate + selfhost judge pair can never kill each other's proxy via the shared 8088 lsof kill.
…s-5) Canary-verified end-to-end 2026-09-11: ES endpoint 200, judge calls 200 through the port-8089 proxy, gate PASS docs=8/8 with 8 golden score docs. Cross-family vs every eis-* candidate; ALT_JUDGE unchanged as the selfhost-candidate escape hatch. Self-test green; no coupling to the old default (judge-forwarded check pins an explicit env override).
The rewrite moved false-green reporting to render_false_green_bug_report.py and dropped --field-diff/--false-green from the board CLI; the old test kept asserting the removed flags, failing on clean HEAD. Rewritten outcome-based: mean/n cells, latency, BLANK markers for absent fields, missing-row labels, empty/vacuous refusals, trace-card content. Mutation-tested (strip BLANK -> red; restore -> green).
build_traces_json.py: --suite flag (was hardcoded security-persona-matrix — correct for this board, wrong for reuse; agent-builder and AD suites can now feed their own boards). render_agent_eval_full.py: flash-lite carries a defensive broken-at-render-time reason (only fires if a future extract loses its 21 cells — this window has data). Board: 33 reference rows (29 with data, 504 cells — up from 24 models in -2; fresh window since Sep 1), 8 no-connector + glm-5-2 (elastic#288469) disclosed, traces mandatory, reproducible modulo timestamp.
- build_traces_json.py: --until upper bound (era separation for trial-replica builds); meta records both bounds - render_agent_eval_full.py: REFERENCE_MODELS in the reference board's exact order (parsed from agent_eval_full-3.html); single-pass row assembly — one row per model in reference order, data row or honest missing-row, no more covered-first/missing-appended split - delivered agent_eval_full-trial.html (Aug 15–Sep 1 era) alongside the EIS board; both boards now order-identical to the reference (33/33) Verified: ref step counts absent from all golden executions of the era for 11/12 sampled prompts — numeric parity with the original trial is structur- ally impossible from this store; shape/coverage/order parity is what golden can deliver.
build_traces_json.py: second span pass sums gen_ai.usage.*_tokens + duration per trace_id, joined to cells via score-doc traceIds; reads both attribute shapes (dotted keys in traces-generic, nested in agent_builder) and queries both trace indices — persona-matrix suite spans land in traces-generic. render_agent_eval_full.py: cells render "Xs · Y/Z tok" under the step count (reference format, integer seconds, no separators); disclosure names the join and the honest coverage (cells without usage spans show steps only). Verified live: EIS board 462/504 data cells carry usage (34 models full, 1 partial, 6 none — their executions' traces lack LLM spans); Aug-era trial board 0 (era predates LLM-span emission — structural, disclosed).
Five upgrades from the 2026-09-12 methodology retrospective: 1. test_agent_eval_board.py: outcome-based guards for the agent_eval_full renderer — reference row order, usage format, missing-row reasons, disclosure block, reproducibility. Mutation-tested (red when the usage branch is disabled). 2. build_traces_json.py: silent-zero join guards — stderr warnings + meta counters when spans are fetched but no cells join (the failure class that cost an hour on 2026-09-12: wrong index, 764 spans, 0 cells, looked green). 3. golden_coverage.py: preflight suite x era map — score docs, traceId coverage, arg/usage span counts, linkage probe, verdict line. Run before builds; both live verdicts verified (Sep: JOIN LIKELY LIVE, Aug: NO TRACE LINKAGE — the exact eras we mapped by hand). 4. build_traces_json.py: usage fetch via composite aggregation on trace_id with server-side sums — replaces the serial search_after scan. Equivalent-or-better: comp1==comp2 deterministically; the ONE cell where scan differed is the scan's tie-timestamp skip bug, comp is correct. 5. render_agent_eval_full.py: extract sha256 stamped in the footer — any board verifiable against its input extract without re-rendering. Also: env-file defaults moved from /tmp to ~/.elastic (durable path).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Consolidated PR: the security LLM performance matrix generator plus every persona-matrix suite fix, so that merging this single PR leaves the matrix and the suite fully working together. Supersedes #283586; folds in (now-closed) #286165, #286201, #286421.
1. Matrix generator (
@kbn/evals-extensions)build_matrix,query_matrix_scores,query_matrix_traces; HTML rendering;matrix.tsCLI commandexamplePrefixes(slices the 21-example persona-matrix dataset into its 7 categories), grouped under a shared headertask.output/example.input, which made every trace render "Trace unavailable"); graceful degradation when one example's scores exceed the transport limitcreate_doc→createfor serverless;EvalsClientrestored to upstream API + additivelistExperiments/getExampleScores2. Persona-matrix suite fixes
expectedTools[0]only): 16 of 21 examples had later declared tools unenforced. ReportsmissingToolIds. Offline re-scoring confirms no model-ordering flip (4.8-opus < 4.5-opus under first/any/all/fraction).generate_esqlis load-bearing (required beforeexecute_esql; findings only from executed queries) — frontier models were hand-writing ES|QL and skipping the schema-validating generator.load_skillIDs (folded [Security Solution][kbn-evals] Match load_skill IDs in persona matrix #286165): recognizes both{"skill":"<id>"}arguments and SKILL.md paths.PERSONA_MATRIX_TIMEOUT_MINUTES, default 30 unchanged): determinism runs at 3 repetitions (~90min) died at the fixed ceiling.Verification
kbn-evals-extensions,kbn-evals-suite-security-persona-matrix)3. Suite-aware rejudge + reference-grounding correctness (this update)
14d7690bc1b7): rejudge juries now load ground truth through a per-suite adapter instead of assuming a single reference shape.6d96ffa11ba7): the AD jury recomputesCriteria/Rubric; replay selects the newest execution rather than first-found.e85d529fea08): prose references are keyed per-scenario so the jury grades against the right alert profile.db8bc1277b12):buildStructuredReferencesand the prose map derive the sameexampleKey, so both lookups agree. Mutation-proven: the drift test fails when the bug is reintroduced.d04a2f760d01): newderiveJudgeProvenancecounts the judge on every admitted run and refuses to name a single judge unless unanimous; unrecorded judges read "unknown" instead of inheriting one. 6 tests, mutation-verified (restoring the hardcoded id turns 3 red; counting unrecorded judges as a judge turns 2 red).d04a2f760d01): the published board previously depended on an untracked scratch file for its extraction step, so its provenance could not be checked against the data it summarised.extract_golden_aggregate.tsis committed, source-filters to the fields it aggregates (full_sourcepulls every transcript into memory: ~2GB RSS, no forward progress), and logs per-suite progress. Verified live: 30,534 docs across 3 suites, 41 models, exit 0, byte-identical across two runs.Known limitations (honest disclosures)
render_from_golden.tshardcodedjudgeModelId: 'google-gemini-3.1-pro'into the published provenance, and separately hardcoded a judge census into the disclosure notes; nothing checked either against the scores they described. Both are now derived at render time byderiveJudgeProvenance(per-suite mix in the table below), so the note cannot drift from the data it summarises. The persona columns are not graded by one shared instrument, which is the assumption the published spread/CI figures rest on. Since re-scoring identical trajectories with a different judge reorders this board (Spearman rho=0.55), cross-row comparisons mix instruments and must not be read as model-quality differences. Re-deriving the spread/CI statistics per-judge is v2 scope — this corrects the label, not the underlying mixed-instrument comparison.experiment_ids run-to-run, sometimes fetching 0 docs, sometimes 4-but-ungradable ("missing gradable attack-discovery output"); and (2) jury non-production — even with 4 gradable cells planned, the jury intermittently emits nothing gradable intoCriteria/Rubric(0 cells, 4 failed). Mode (2) means judge-side behaviour cannot yet be ruled out. The v2 fix must first capture the jury's raw output on a 0-cell verdict to distinguish a judge flake from a grading-contract mismatch, then constrain/pin replay to executions with gradable AD output and surface "no gradable output" as a first-class data gap.PUT /_inference/_ccmenables it, but it lives in ES's in-memory cache and reverts toenabled:falseon restart. Mitigated on the m1max judge host: the boot script now runs a background helper that PUTs the CCM key and gates on the gemini endpoint materializing after every ES restart (verified live on a fresh boot). Any other judge host needs the same step or it silently regresses to "connector not found".experiment_id, notexecution_id— merging re-judged scores back into golden requires a dedicated write path that does not yet exist.Judge attribution is confounded — the board does not support cross-model ranking
Counted from the golden extract the board is built from (42 graded models), derived at render time rather than asserted:
security-persona-matrixattack-discovery-agent-builderautomatic-migrationsBoard-wide: 24 of 42 models were graded by more than one judge, 18 by exactly one (10 under sonnet-4.6, 5 under gemini-3.1-pro, 3 under haiku). An earlier revision of this PR stated the opposite — "no persona-matrix model is graded by more than one judge (zero overlap)" — from a narrower extract. That claim is withdrawn: it is false on the current data, and the renderer no longer hardcodes any judge census. Every figure above is now derived by
deriveJudgeProvenanceat render time and mutation-tested (fabricating the judge mix in the extract changes every number in the published note).The confound is real but is not clean block separation: for the 18 single-judge models, judge severity is collinear with model identity and cross-block comparison is invalid; for the 24 multi-judge models, the cell mean itself averages over graders. Since re-scoring identical trajectories with a different judge reorders this board (Spearman rho 0.55), neither group supports reading row order as model quality.
Within the largest single-judge block, the LLM-judged composite correlates only Spearman 0.483 / Pearson 0.505 with the judge-independent deterministic evaluators (
ExpectedToolCalled,MinExpectedSteps,FinalAnswerPresent,SkillInvoked).gpt-5.5ranks 1st by judge but 11th of 25 deterministically. The judged axis is a separate instrument from what the model actually did.Sep 7 published board mislabelled its judge.
target/llm_matrix_golden/matrix.jsondeclaresjudgeModelId: google-gemini-3.1-pro, but golden shows its persona scores that day were graded 3842 docs by claude-4.5-haiku, 2122 by claude-4.6-sonnet and only 420 by gemini-3.1-pro. It is a mixed-judge aggregate labelled single-judge. Re-rendering it under the declared judge populates only 6.3% of cells, so the published numbers cannot be reproduced single-judge; the fidelity guard now refuses that render instead of emitting a near-empty board.The judge notes are now unconditional. They previously rendered only when optional caveat-stats env vars were set, so a plain render published a board with no judge disclosure — which is how the false claim survived.
Judge-overlap experiment — the confound is now measured, not assumed
That data-collection run has since been executed. Six models sharing an identical
7-column persona block were re-judged blind by all four judges: 507 graded
cells, 124 of them graded by every judge, 1 transient
toolValidationErrorand1 unreplayable cell, both recorded in the artifacts rather than dropped.
Analysis is committed (
src/matrix/judge_overlap.ts, 8 tests, 3 mutations verifiedred) so the numbers below come from the same code path CI runs, not a scratch script.
Judge severity splits into two parts. A uniform offset shifts every model
equally and cancels out of any within-judge ranking. The residual that survives
offset removal is what actually reorders a board.
anthropic-claude-4.5-haikugoogle-gemini-3.1-proanthropic-claude-4.6-sonnetopenai-gpt-5.4The decisive comparison: residual disagreement is 0.170 mean / 0.303 p90,
against a model spread of only 0.155. Judge noise exceeds the signal it is
meant to measure, so
analyzeJudgeOverlapreturnsrankable: false— thatverdict is about reading any single judge's column as a ranking, which is what
the shipped board does.
Consequences for reading this board:
eis-openai-gpt-5-5is 1st under all four judges — the only rank that isjudge-independent under any single judge. (Averaging the judges recovers more
than this; see the ensemble column below.)
glm-5.3-flashmoves 4th→last,gemini-3.1-pro2nd→5th. Mean pairwise Spearman on model order is +0.533(min +0.371).
board. That leniency is baked into the largest column.
−0.027 to +0.006, all within noise. The earlier family-bias hypothesis for the
attack-discovery column is unsupported.
Caveat stated plainly: 6 models × ~20 cells is a small block, so individual
pairwise rho values carry wide confidence intervals. The robust finding is the
residual magnitude relative to model spread, not any specific correlation.
Ensemble column — what the board can now rank
Averaging the four judges over the 124 cells they all graded is the remedy the
overlap data implies: judge-specific scatter is independent, model quality is
not, so the mean of N judges suppresses the noise without touching the signal.
Committed as
src/matrix/ensemble_column.ts(7 tests, 3 mutations verified red)and rendered into the board from the real artifacts.
eis-openai-gpt-5-5openai-gpt-5.4google-gemini-3.1-proanthropic-claude-4.8-opusz-ai/glm-5.3-flashgoogle-gemini-2.5-flashMeasured: 8 of 15 model pairs separate by paired bootstrap over shared
examples (95% CI excluding zero), 2 of them borderline. That figure does not
survive contact with run-to-run variance, and this PR no longer claims any
persona separation is real.
A doc-count gate cannot catch a wrong-suite run
While closing the migrations coverage gap, a sweep launched with
--suite security-automatic-migrationsreported 8/8 units PASS atdocs=86/80and filled zero board cells.The board's two migrations columns key on dataset ids
05fd1e03-…/07a6c75d-…, written by theagent-buildersuite(
kbn-evals-suite-agent-builder, experimentsagent builder: automatic-migration-start-end-to-end/-update-grounded). The similarly namedsecurity-automatic-migrationssuite is a different package with a differentplaywright.config.ts, and it wrote 688 docs under three unrelated datasets(
standard-dashboards,qradar,splunk-spl). Migrations coverage is thereforestill 8/41 — the run was green and useless.
The floor gate was structurally blind to this:
86 >= 80holds regardless ofwhich datasets produced the 86, so a gate that only detects undercounts cannot
detect running the wrong thing.
check_expected_datasetsnow asserts datasetidentity for suites that declare
expected_dataset_ids, and is verified byreplay against the real mig2 execution ids — every unit that passed at 86/80
fails with
wrong suite: unexpected datasets …; missing datasets …. Suites thatdeclare no identity (persona, AD) keep their existing behaviour.
The 7 pairs that remain tied are not ordered, and the board says so:
opus-4.8vsgemini-2.5-flash:+0.0053 CI[−0.0368, +0.0429]— a coin flip.gpt-5.4vsgemini-3.1-pro:+0.0115 CI[−0.0172, +0.0402]— not distinguishable.Honest reading: #1 is real, #2–3 are a tier, #4–6 are a tie. Ranks 4–6 span
0.005 points and must not be quoted as an ordering.
Each ensemble score publishes its
judgeSpreadalongside, because a 0.5 producedby judges at 1 and 0 is not the same claim as a 0.5 they agreed on. Only cells
graded by every judge are averaged, so a judge that failed on a model's hard
examples cannot make that model look strong through absence. The
sqrt(N)noisereduction is reported as an upper bound — judges are correlated, not
independent.
Evaluator health gate — the rubric defect was not isolated
The attack-discovery rubric was found by hand after 240 observations had already
been spent on it. Auditing the rest of the golden extract the same way turns up
more of the same class, so this adds
checkEvaluatorHealthto make the checkmechanical rather than retrospective.
Across the 32 scored evaluators in the golden extract (12,053 observations), only
38% actually discriminate between models:
1.000for every modelSequence Accuracy,RequiredAlertIdsInResponse,DocVersionReleaseDate— 159 observations each, min = max = 1Skill Invoked (data-exploration)scores 0 across all 237 observationsSequence Accuracy, 94.6% at ceilingThis distorts the published board. Recomputing composites from discriminating
evaluators only:
gemini-3.1-flash-litemoves 14 places on attack-discovery depending purely onwhether near-constant evaluators are averaged in.
Gates are deliberately held to a different standard: "did it avoid the forbidden
tool?" is supposed to sit at the ceiling, and failing it for saturation would
train people to ignore the warning. A gate is only flagged when nobody passes it.
Two caveats on this analysis. The composite used here is an unweighted mean over
evaluators, which is not exactly how the board ranks, so treat the rank moves as
evidence that evaluator selection matters, not as a corrected ranking. And role
(gate vs grader) is currently inferred from evaluator names in the audit script
— that belongs in the evaluator definition, since a name list can be widened
until the audit passes, which is the failure this gate exists to prevent.
Filed for the suites outside this PR's scope: #289935.
Attack Discovery golden-path slices could not be rejudged — now fixed forward
The AD column has 10 slices in golden. The four
scenario-registry (clean profile)slices were rejudged under the per-item rubric (99 cells, all four judges 99/99).
The five
golden-pathslices could not be, and the cause was a missing join keyrather than a scheduling gap:
A replay joins recorded scores to dataset references on
example.metadata.scenarioKey. The golden-path documents carry no metadata, andexample.idis'0'for 11,512 of 11,993 documents, so nothing distinguishedone scenario from another. Replaying them would have graded all five against the
first scenario's ground truth — the run would have succeeded and the numbers
would have been meaningless.
This PR gives each golden-path scenario a
scenarioKeyand makes the fieldrequired on the metadata type, so the compiler now rejects a scenario that
cannot be replayed. Four tests pin the contract (present, unique, agreeing with
the
fixturethe spec slices on, non-empty); all four fail without the fix andthree targeted mutations are caught.
This does not retroactively fix existing golden data. Documents already
recorded have no scenarioKey and stay unreplayable; the board's five golden-path
AD cells remain on old-rubric scores, labelled not comparable. The next suite run
records the key and those slices become rejudgeable from then on.
Evaluator roles are declared, not inferred
The health gate above shipped with roles inferred from evaluator names. That is
the wrong mechanism for a correctness check — a pattern list can be widened until
the audit passes, which is the failure the audit exists to catch.
It was already happening:
RequiredAlertIdsInResponseandDocVersionReleaseDatematched the gate patterns, so the audit stopped asking whether they could ever
fail, while both return a constant 1.000 across all 159 golden observations.
Replacing the regexes with a declared registry surfaces both as dead graders
rather than excusing them.
To be precise about what changed: the constant count in the table above stays at
3, because those two were already being counted from raw golden data in the
retrospective analysis. What changed is the tool's verdict — it now reports
them instead of classifying them as healthy gates. The one count that moved is
saturated graders, 2 to 1, because
ToolUsageOnlygenuinely is a gate and theregex had it right by accident.
Unregistered evaluators resolve to
unknownrather than defaulting: guessinggraderfails a healthy gate, guessinggateexcuses a dead one. Eachdeclaration carries a rationale, and a test pins the two known-constant
evaluators as graders so they cannot be relabelled as gates to silence the audit
— the cheapest way to make this tool useless.
Attack Discovery column — corrected twice, root cause found
Two earlier revisions of this description were wrong about AD, and the second
wrong answer was produced while fixing the first. Both are recorded here because
the board's credibility rests on how these numbers were obtained.
suite re-run." Wrong — that was an artifact of reading one slice.
Also wrong. The config does not drive extraction at all; the extractor takes
no config and discovers suites itself.
Actual root cause, in
extract_golden_aggregate.ts: the extractor selectedthe newest
experiment_idper suite. Persona runs all of its columns insideone experiment, so a suite-wide filter is harmless there and the bug stayed
invisible. Attack discovery runs each slice as its own experiment, so the
filter kept one slice and silently discarded the other eight.
count: 1was thesymptom of a selection bug, not a property of the data.
Selecting the newest experiment per column instead:
Golden holds 2,881 AD documents graded on
Criteria/Rubricacross 38 models;30 models carry all nine slices. AD never needed new examples or a suite re-run.
A suite can now carry several judges, so
judgeModelIdsis reported andjudgeModelIdgoes undefined rather than naming one of several. Judgeprovenance counts the full set — counting only the single-judge field made mixed
runs vanish from the breakdown built to expose them. AD is in fact mixed:
16 runs
gemini-3.1-pro, 16claude-4.6-sonnet, 3 others.Two harness bugs blocked reaching this column at all, both fixed here:
goldenPathExamples, the one the agent-builder suite actually uses, so--dataseton that suite failed with "does not export an examples array".JUDGE_KBN_AUTHis rawuser:passand is base64-encoded by the CLI. Apre-encoded value double-encoded into a 401 that only surfaced after all 99
planned cells had been judged and discarded.
buildJudgeAuthHeaderrejects itup front. The zero-graded guard added earlier in this PR is what made the
failure visible instead of writing an empty "refreshed" column.
First AD rejudge: not rankable under the OLD rubric (superseded below)
Four judges were run blind over the 99-cell AD block. Three completed
(
gemini99/99,gpt99/99,sonnet97/99);haikufailed all 99 onstructured-output validation. That failure was later traced to the rubric
itself, not to haiku — see the measured section below, where it grades 99/99
under the per-item form. 170 cells were graded by all three surviving judges, over 23 models.
The judges agree well here — far better than on persona:
But only 47 of 231 model pairs (20.3%) separate under a paired bootstrap.
The blocker is not the judge — it is rubric saturation:
Judges disagree by 0.052 while the models they are scoring differ by less than
that. Rejudging cannot fix this and neither can more judges — the AD rubric
does not discriminate among current frontier models, and only a harder or
finer-grained rubric would. That verdict is computed by
saturation.ts, whichreports
limitingFactor: 'saturation' | 'judge-noise' | 'neither'rather thanletting a reader infer it, so a future re-run cannot quietly turn a saturated
rubric into an apparent ranking.
The AD column therefore stays inventory on this board — now an accurate
38-model inventory rather than a 22-model artifact of a selection bug.
The rubric fix (folded in)
Diagnosing saturation was worth little without fixing it, so this PR now also
fixes the rubric that causes it.
createAttackDiscoveryRubricEvaluatorpassed all seven requirements to thecriteria judge as one string ending "answer Y or N", with a 5-of-7 threshold
buried in the prose. The judge returned a single binary score, so a discovery
missing two requirements scored identically to a flawless one. The saturation is
structural, not a property of the models.
The evidence separates the two evaluators cleanly over the same 3-judge rejudge:
The shared criteria evaluator already implements weighted per-item scoring
(
totalScore / maxScore); the AD rubric was discarding it. The fix passes theseven requirements as seven criteria, so missing two now scores 5/7 instead of 0.
The 5-of-7 threshold is dropped rather than reimplemented — a threshold on
top of per-item scores would re-collapse exactly the signal this recovers.
This changes AD scores for every model. New AD runs are not comparable with
previously recorded AD golden data, and the board's current AD column is still
the old-rubric, saturated one — it is labelled as such rather than silently
re-baselined. Re-running the AD suite to produce a comparable column is follow-up
work, not part of this PR.
A regression test asserts seven distinct criteria reach the judge and is
mutation-proven: restoring the collapsed single-string rubric turns it red.
Measured: the rubric fix works, and haiku was never the problem
The AD block was rejudged on the per-item rubric, four judges, blind, same 99
cells. Every judge completed:
haiku's failure was the rubric, not the model. It previously failed all 99
cells on structured-output validation across 4 attempts each, and was reported
here as a model limitation. Asked the same seven requirements as seven separate
criteria, it graded all 99 without a single retry. The collapsed prompt — seven
items plus a threshold rule, answered in one shot — was what it could not
satisfy. The earlier "haiku cannot grade the AD rubric" claim was wrong and is
retracted.
The distribution changed exactly as intended:
analyzeSaturationon the new artifacts returnslimitingFactor: "none"—against
"saturation"before. The AD column can now rank, where previouslyno amount of rejudging could have made it.
One honest caveat: per-cell judge spread rose from 0.052 to 0.223. That is
expected — judges agreeing on a binary question is not agreement about quality —
and it is still below the 0.369 model spread, which is why the column ranks. Judge
noise is now the next constraint, where saturation used to be the binding one.
These numbers come from the 87 cells graded by all four judges; the artifacts are
/tmp/ad_rj3_local/{gemini,sonnet,haiku,gpt}.json. The board's AD column isstill the old-rubric data — re-running the suite to publish a comparable ranked
column is follow-up work, not part of this PR.