Skip to content

[Evals] Security LLM performance matrix generator in @kbn/evals-extensions - #285833

Draft
patrykkopycinski wants to merge 271 commits into
elastic:mainfrom
patrykkopycinski:feat/evals-extensions-matrix-v3
Draft

[Evals] Security LLM performance matrix generator in @kbn/evals-extensions#285833
patrykkopycinski wants to merge 271 commits into
elastic:mainfrom
patrykkopycinski:feat/evals-extensions-matrix-v3

Conversation

@patrykkopycinski

@patrykkopycinski patrykkopycinski commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

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)

  • Matrix generation: build_matrix, query_matrix_scores, query_matrix_traces; HTML rendering; matrix.ts CLI command
  • Per-category columns via examplePrefixes (slices the 21-example persona-matrix dataset into its 7 categories), grouped under a shared header
  • Trace detail lookup via the per-example scores route (the per-experiment route strips task.output/example.input, which made every trace render "Trace unavailable"); graceful degradation when one example's scores exceed the transport limit
  • Per-variant trace cards: category columns aggregate a/b/c variants — previously only the first-wins representative rendered, hiding 14 of 21 prompts
  • Golden cluster privileges: create_doccreate for serverless; EvalsClient restored to upstream API + additive listExperiments/getExampleScores

2. Persona-matrix suite fixes

Verification

  • type_check: 0 errors on both touched projects (kbn-evals-extensions, kbn-evals-suite-security-persona-matrix)
  • Jest: matrix suites 64/64; persona-matrix evaluator suite 16/16
  • ESLint clean on all changed files; pre-push hook PASS
  • E2E evidence from live runs against the golden cluster: haiku smoke 21/21 with 252/252 scores after env-seeding; 5/5 models render question/tool trail/step trace/answer in matrix.html; sweep VM runs exercise the overlaid evaluator + timeout config end-to-end

3. Suite-aware rejudge + reference-grounding correctness (this update)

  • Per-suite reference adapters (14d7690bc1b7): rejudge juries now load ground truth through a per-suite adapter instead of assuming a single reference shape.
  • Suite-aware rejudge juries + newest-execution selection (6d96ffa11ba7): the AD jury recomputes Criteria/Rubric; replay selects the newest execution rather than first-found.
  • Attack-discovery references joined by scenario key (e85d529fea08): prose references are keyed per-scenario so the jury grades against the right alert profile.
  • Structured ground truth keyed like the prose references (db8bc1277b12): buildStructuredReferences and the prose map derive the same exampleKey, so both lookups agree. Mutation-proven: the drift test fails when the bug is reintroduced.
  • Judge provenance derived, not asserted (d04a2f760d01): new deriveJudgeProvenance counts 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).
  • Golden extraction is now reproducible (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.ts is committed, source-filters to the fields it aggregates (full _source pulls 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)

  • The board is mixed-judge across every column, and the artifact previously mis-declared this. render_from_golden.ts hardcoded judgeModelId: '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 by deriveJudgeProvenance (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.
  • AD-column rejudge is incomplete, with two distinct unresolved failure modes. A full-column rejudge (9 models × 27 criteria × 3 shards) is not done. Observed across identical invocations of the same single-execution rejudge (judge stack confirmed healthy throughout: CCM enabled, full gemini catalog, Kibana 200): (1) selection variability — the newest-execution selection lands on different golden 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 into Criteria/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.
  • CCM (Elastic Inference Service model sync) is in-memory only on the judge stack. PUT /_inference/_ccm enables it, but it lives in ES's in-memory cache and reverts to enabled:false on 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".
  • Golden docs are keyed on experiment_id, not execution_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:

Suite models judge mix graded by >1 judge
security-persona-matrix 39 haiku 22, sonnet-4.6 15, gemini-3.1-pro 3, gpt-5.4 1 2
attack-discovery-agent-builder 32 sonnet-4.6 18, gemini-3.1-pro 15 1
automatic-migrations 9 gemini-3.1-pro 8, kimi-k3 1 0

Board-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 deriveJudgeProvenance at 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.5 ranks 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.json declares judgeModelId: 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 toolValidationError and
1 unreplayable cell, both recorded in the artifacts rather than dropped.

Analysis is committed (src/matrix/judge_overlap.ts, 8 tests, 3 mutations verified
red) 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.

Judge mean offset
anthropic-claude-4.5-haiku 0.794 +0.064
google-gemini-3.1-pro 0.726 −0.004
anthropic-claude-4.6-sonnet 0.713 −0.018
openai-gpt-5.4 0.688 −0.042

The 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 analyzeJudgeOverlap returns rankable: false — that
verdict 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-5 is 1st under all four judges — the only rank that is
    judge-independent under any single judge. (Averaging the judges recovers more
    than this; see the ensemble column below.)
  • Every position below the top reorders by judge. glm-5.3-flash moves 4th→last,
    gemini-3.1-pro 2nd→5th. Mean pairwise Spearman on model order is +0.533
    (min +0.371).
  • Haiku is the most lenient judge (+0.064) and graded 67.6% of the shipped
    board.
    That leniency is baked into the largest column.
  • No self-preference detected: judge-family vs model-family deltas span
    −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.

# Model Ensemble Judge spread
1 eis-openai-gpt-5-5 0.779 0.153
2 openai-gpt-5.4 0.746 0.165
3 google-gemini-3.1-pro 0.735 0.083
4 anthropic-claude-4.8-opus 0.709 0.113
5 z-ai/glm-5.3-flash 0.706 0.139
6 google-gemini-2.5-flash 0.704 0.056

Measured: 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.

Retraction (supersedes the corrections below). The bootstrap resamples
cells within a single run, so it measures judge and example variance but is
structurally blind to run-to-run variance — a one-run sample has none to see.

The golden data contains 7 models that were run twice under two naming
conventions (eis-* on Sep 7, bare ids on Sep 6). Those pairs are the same
model on the same columns, and they are the only re-run evidence available:

mean |Δ| across 7 re-run pairs (0–1 quality evaluators) 0.099
cells reproducing identically ~70%
ensemble pairwise gaps median 0.033, max 0.075
gaps exceeding the re-run noise floor 0 of 15

Every pairwise gap on the persona ensemble — including all 8 "separable" and
all 6 "robust" — is smaller than the disagreement between two runs of the same
model. The correct reading is that the persona column orders nothing yet.

Measured directly (2026-09-09). The upper bound above has since been
confirmed by a dedicated seed-only re-run, so the retraction no longer rests on
inference from differently-named pairs. Three models were re-run on the
identical suite, examples and judge, with the seed as the only difference
(VM_NAME_SUFFIX=seedrep, 9/9 shards PASS, 98/98 docs each, 882 score docs):

model cells mean |Δ| identical
eis-anthropic-claude-5-sonnet 194 0.1072 61%
eis-google-gemini-3-1-pro 194 0.0873 62%
eis-openai-gpt-5-5 194 0.0945 68%
combined 582 0.0963 ~64%

The seed-only floor is 0.0963, within 0.003 of the 0.099 upper bound — the
naming-convention confounds contributed almost nothing, and this is genuine
sampling variance. 0 of 15 ensemble gaps clear it; the largest gap in the
column is 0.0750. About a third of cells change value on a pure re-run, which
is the discrimination limit of a single-run persona sweep. Averaging more
judges cannot recover it, because the variance originates in the model's own
sampling rather than in judge disagreement.

A doc-count gate cannot catch a wrong-suite run

While closing the migrations coverage gap, a sweep launched with
--suite security-automatic-migrations reported 8/8 units PASS at
docs=86/80
and filled zero board cells.

The board's two migrations columns key on dataset ids
05fd1e03-… / 07a6c75d-…, written by the agent-builder suite
(kbn-evals-suite-agent-builder, experiments agent builder: automatic-migration-start-end-to-end / -update-grounded). The similarly named
security-automatic-migrations suite is a different package with a different
playwright.config.ts, and it wrote 688 docs under three unrelated datasets
(standard-dashboards, qradar, splunk-spl). Migrations coverage is therefore
still 8/41 — the run was green and useless.

The floor gate was structurally blind to this: 86 >= 80 holds regardless of
which datasets produced the 86, so a gate that only detects undercounts cannot
detect running the wrong thing. check_expected_datasets now asserts dataset
identity for suites that declare expected_dataset_ids, and is verified by
replay against the real mig2 execution ids — every unit that passed at 86/80
fails with wrong suite: unexpected datasets …; missing datasets …. Suites that
declare no identity (persona, AD) keep their existing behaviour.

One mutation initially survived here: deleting the unexpected-dataset check
entirely still passed, because the wrong-suite fixture also triggered the
missing branch and passed for the wrong reason. A superset case now isolates
the unexpected-only path; 5/5 mutations caught.

Correction. An earlier revision of this PR claimed 8 of 15. That figure came
from an ad-hoc script with two defects, both now fixed in committed code:

  1. Index pairing. countSeparablePairs paired the two models by array
    position. Each model's cell list is built independently, so position i is a
    different example per model — the "paired" bootstrap compared unrelated cells
    and leaked example difficulty, the dominant variance component, into every CI.
    It now pairs on exampleId.
  2. Evaluators counted as independent. The ad-hoc version treated each
    evaluator as its own observation, inflating n by 4x. Within-example evaluator
    spread is 0.636 against a between-example sd of 0.094, so evaluators on one
    example measure different constructs and are not independent draws.

The board and the module now derive both counts from the artifact, so a re-run
that separates differently cannot leave a stale number on the page.

Third correction, found by a lint failure. no-bitwise rejected the
Mulberry32 PRNG, so the bootstrap moved to a plain-arithmetic LCG — and the
count moved again, 7 → 8. Same data, same estimator, different generator.
The cause is real and worth stating: one pair (opus-4.8 vs
claude-5-sonnet) sits 0.0019 from its CI edge and separates in only 4%
of seeds. A bare "N pairs separate" was reporting a coin flip as a verdict.
buildEnsembleColumn now returns borderlinePairs (CI edge within 0.005 of
zero) and the board publishes "only N separate robustly" beside the headline.
8 separate, 2 borderline, 6 robust.

The 7 pairs that remain tied are not ordered, and the board says so:

  • opus-4.8 vs gemini-2.5-flash: +0.0053 CI[−0.0368, +0.0429] — a coin flip.
  • gpt-5.4 vs gemini-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 judgeSpread alongside, because a 0.5 produced
by 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) noise
reduction 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 checkEvaluatorHealth to make the check
mechanical rather than retrospective.

Across the 32 scored evaluators in the golden extract (12,053 observations), only
38% actually discriminate between models:

finding count evidence
constant 1.000 for every model 3 Sequence Accuracy, RequiredAlertIdsInResponse, DocVersionReleaseDate — 159 observations each, min = max = 1
gates that never pass for anyone 3 Skill Invoked (data-exploration) scores 0 across all 237 observations
saturated graders 1 persona Sequence Accuracy, 94.6% at ceiling

This distorts the published board. Recomputing composites from discriminating
evaluators only:

suite models changing rank largest move
attack-discovery-agent-builder 26/38 14 places
security-persona-matrix 47/58 7 places

gemini-3.1-flash-lite moves 14 places on attack-discovery depending purely on
whether 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-path slices could not be, and the cause was a missing join key
rather than a scheduling gap:

golden-path (live-retrieval)      n=2969  scenarioKey: 100% missing
golden-path (provided-alerts)     n=2378  scenarioKey: 100% missing
golden-path (missing-alert-r...)  n=2226  scenarioKey: 100% missing
golden-path (multiple-alert-...)  n=2210  scenarioKey: 100% missing
golden-path (status-only)         n=2210  scenarioKey: 100% missing
scenario-registry (clean profile) n=1749  scenarioKey: encoded-powershell, ...

A replay joins recorded scores to dataset references on
example.metadata.scenarioKey. The golden-path documents carry no metadata, and
example.id is '0' for 11,512 of 11,993 documents, so nothing distinguished
one 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 scenarioKey and makes the field
required 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 fixture the spec slices on, non-empty); all four fail without the fix and
three 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: RequiredAlertIdsInResponse and DocVersionReleaseDate
matched 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 ToolUsageOnly genuinely is a gate and the
regex had it right by accident.

Unregistered evaluators resolve to unknown rather than defaulting: guessing
grader fails a healthy gate, guessing gate excuses a dead one. Each
declaration 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.

  1. First claim: "AD is a 22-model, single-example inventory column needing a
    suite re-run."
    Wrong — that was an artifact of reading one slice.
  2. Second claim: "the matrix config named three of the nine AD datasets."
    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 selected
the newest experiment_id per suite. Persona runs all of its columns inside
one 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: 1 was the
symptom of a selection bug, not a property of the data.

Selecting the newest experiment per column instead:

Suite Before After
attack-discovery 31 models × 1 dataset 38 models × up to 10 slices
persona-matrix 7 columns 21 columns
automatic-migrations 2 columns 21 columns

Golden holds 2,881 AD documents graded on Criteria/Rubric across 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 judgeModelIds is reported and
judgeModelId goes undefined rather than naming one of several. Judge
provenance 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, 16 claude-4.6-sonnet, 3 others.

Two harness bugs blocked reaching this column at all, both fixed here:

  • The attack-discovery reference adapter listed every dataset export name except
    goldenPathExamples, the one the agent-builder suite actually uses, so
    --dataset on that suite failed with "does not export an examples array".
  • JUDGE_KBN_AUTH is raw user:pass and is base64-encoded by the CLI. A
    pre-encoded value double-encoded into a 401 that only surfaced after all 99
    planned cells had been judged and discarded. buildJudgeAuthHeader rejects it
    up 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
(gemini 99/99, gpt 99/99, sonnet 97/99); haiku failed all 99 on
structured-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:

persona block AD block
per-cell judge spread 0.170 0.052
pairwise exact agreement 0.93 – 0.98

But only 47 of 231 model pairs (20.3%) separate under a paired bootstrap.
The blocker is not the judge — it is rubric saturation:

  • 87.8% of all scores are exactly 1.0; 2.4% are 0.
  • 16 of 23 models sit within 0.02 of the ceiling.
  • Excluding the two 2.5-era models, the entire top-21 spans 0.0595.

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, which
reports limitingFactor: 'saturation' | 'judge-noise' | 'neither' rather than
letting 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.

createAttackDiscoveryRubricEvaluator passed all seven requirements to the
criteria 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:

evaluator n mean at 1.0 sd
Rubric (collapsed Y/N) 295 0.956 95.6% 0.205
Criteria (already per-item) 295 0.963 81.4% 0.088

The shared criteria evaluator already implements weighted per-item scoring
(totalScore / maxScore); the AD rubric was discarding it. The fix passes the
seven 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:

judge graded rubric loaded from suite
gemini-3.1-pro 99/99 yes
claude-4.6-sonnet 99/99 yes
claude-4.5-haiku 99/99 yes
gpt-5.4 99/99 yes

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:

old (collapsed Y/N) new (per-item)
distinct score values 2 6
scores at 1.0 95.6% 57.8%
sd 0.205 (binary) 0.139
models within 0.02 of ceiling 16 / 23 0 / 23
separable pairs (paired bootstrap) 47 / 231 (20.3%) 89 / 251 (35.5%)
borderline separations 0

analyzeSaturation on the new artifacts returns limitingFactor: "none"
against "saturation" before. The AD column can now rank, where previously
no 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 is
still the old-rubric data
— re-running the suite to publish a comparable ranked
column is follow-up work, not part of this PR.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
patrykkopycinski marked this pull request as draft August 18, 2026 23:39
@infra-vault-gh-plugin-prod

Copy link
Copy Markdown
🤖 Jobs for this PR can be triggered through checkboxes. 🚧

ℹ️ To trigger the CI, please tick the checkbox below 👇

  • Click to trigger kibana-pull-request for this PR!
  • Click to trigger kibana-deploy-project-from-pr for this PR!
  • Click to trigger kibana-deploy-cloud-from-pr for this PR!
  • Click to trigger kibana-entity-store-performance-from-pr for this PR!
  • Click to trigger kibana-storybooks-from-pr for this PR!

@patrykkopycinski patrykkopycinski added backport:skip This PR does not require backporting release_note:skip Skip the PR/issue when compiling release notes labels Aug 19, 2026
@patrykkopycinski

Copy link
Copy Markdown
Contributor Author

buildkite test this

6 similar comments
@patrykkopycinski

Copy link
Copy Markdown
Contributor Author

buildkite test this

@patrykkopycinski

Copy link
Copy Markdown
Contributor Author

buildkite test this

@patrykkopycinski

Copy link
Copy Markdown
Contributor Author

buildkite test this

@patrykkopycinski

Copy link
Copy Markdown
Contributor Author

buildkite test this

@patrykkopycinski

Copy link
Copy Markdown
Contributor Author

buildkite test this

@patrykkopycinski

Copy link
Copy Markdown
Contributor Author

buildkite test this

@patrykkopycinski

Copy link
Copy Markdown
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.
@patrykkopycinski

Copy link
Copy Markdown
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.
@patrykkopycinski

Copy link
Copy Markdown
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.
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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport:skip This PR does not require backporting buildkite-ci evals:smoke-tests release_note:skip Skip the PR/issue when compiling release notes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants