Reject ambiguous eval identities

Fail closed on missing or duplicate comparison slots, reject duplicate stimulus names before execution, and clarify how the practical floor changes power at its boundary.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 1c6716a2-172b-461a-adb5-127b1ba7e96b
This commit is contained in:
Abhitej John
2026-08-18 18:45:16 -07:00
parent 4dc894a88f
commit 780c999546
10 changed files with 225 additions and 38 deletions
+8 -4
View File
@@ -127,6 +127,8 @@ own value rather than defaulting it.
cued prompts inflate the overfit score and bias the baseline. cued prompts inflate the overfit score and bias the baseline.
- Each stimulus should discriminate a **different** property of the skill. Five stimuli covering one - Each stimulus should discriminate a **different** property of the skill. Five stimuli covering one
property give arithmetic, not evidence. property give arithmetic, not evidence.
- Give every stimulus a stable, unique `name`. Vally pairs comparison trajectories by
`(stimulus name, trial index)`; duplicate names make slot identity ambiguous.
- Include a boundary / no-op stimulus for any skill that migrates or rewrites code, proving it - Include a boundary / no-op stimulus for any skill that migrates or rewrites code, proving it
leaves already-correct input alone. leaves already-correct input alone.
@@ -299,10 +301,11 @@ EXPERIMENT_FILE=my-agent.experiment.yaml ./eng/run-skill-evals.sh <plugin>
Read the trajectories rather than the verdict — there is no sign-test result for an agent eval. Read the trajectories rather than the verdict — there is no sign-test result for an agent eval.
`check_eval_quality.py` blocks ten structural defect classes that each already cost a real result: `check_eval_quality.py` blocks eleven structural defect classes that can corrupt a result:
missing or untracked fixtures, self-contradicting coverage fixtures, empty grader configs, dormancy missing or untracked fixtures, self-contradicting coverage fixtures, empty grader configs, dormancy
guards with `reject_skills`, sub-floor stimulus counts, duplicate YAML keys, and `config:`/`defaults:` guards with `reject_skills`, sub-floor stimulus counts, duplicate YAML keys or stimulus names, and
collisions. Do not add a new eval to `eng/eval-quality/underpowered-allowlist.txt` — the gate rejects `config:`/`defaults:` collisions. Do not add a new eval to
`eng/eval-quality/underpowered-allowlist.txt` — the gate rejects
allowlist entries that are new relative to the base branch. allowlist entries that are new relative to the base branch.
For the official run, submit a PR review containing `/evaluate` so it binds to the reviewed commit. For the official run, submit a PR review containing `/evaluate` so it binds to the reviewed commit.
@@ -312,7 +315,7 @@ For the official run, submit a PR review containing `/evaluate` so it binds to t
- [ ] Directory is `tests/<plugin>/<skill-name>/` or `tests/<plugin>/agent.<agent-name>/` - [ ] Directory is `tests/<plugin>/<skill-name>/` or `tests/<plugin>/agent.<agent-name>/`
- [ ] Spec uses `stimuli:` / `graders:`, and exactly one of `defaults:` or `config:` - [ ] Spec uses `stimuli:` / `graders:`, and exactly one of `defaults:` or `config:`
- [ ] For a skill eval, at least 5 distinct stimuli exist, with more for the effect and tie rate that must be detected (agent evals are exempt) - [ ] For a skill eval, at least 5 distinct stimuli exist, with more for the effect and tie rate that must be detected (agent evals are exempt)
- [ ] Each stimulus discriminates a different property - [ ] Each stimulus discriminates a different property and has a stable, unique name
- [ ] Prompts never name the skill, the agent, or its vocabulary - [ ] Prompts never name the skill, the agent, or its vocabulary
- [ ] Every referenced fixture exists and is tracked by `git ls-files` - [ ] Every referenced fixture exists and is tracked by `git ls-files`
- [ ] Every fixture behaves as its stimulus assumes — healthy ones build, deliberately broken ones fail only for the stated reason - [ ] Every fixture behaves as its stimulus assumes — healthy ones build, deliberately broken ones fail only for the stated reason
@@ -338,6 +341,7 @@ For the official run, submit a PR review containing `/evaluate` so it binds to t
| `expect_tools: [bash]` on an advisory question | Drop it; it causes timeouts, not quality | | `expect_tools: [bash]` on an advisory question | Drop it; it causes timeouts, not quality |
| Timeout too short for code generation | Use ~360s; empty output fails every grader | | Timeout too short for code generation | Use ~360s; empty output fails every grader |
| Duplicate YAML key left behind by an edit | It overwrites the next stimulus field by field — delete the stray block | | Duplicate YAML key left behind by an edit | It overwrites the next stimulus field by field — delete the stray block |
| Duplicate stimulus names | Vally uses names as comparison identity — give every stimulus a stable, unique name |
| Direct activation-graded eval for a `disable-model-invocation: true` skill | Cover it through a consumer skill, or grade the answer content as `filter-syntax` does | | Direct activation-graded eval for a `disable-model-invocation: true` skill | Cover it through a consumer skill, or grade the answer content as `filter-syntax` does |
| Agent eval sized for the stimulus floor | `agent.*` evals get no verdict; size them for scenario coverage instead | | Agent eval sized for the stimulus floor | `agent.*` evals get no verdict; size them for scenario coverage instead |
| Agent eval "run" with `./eng/run-skill-evals.sh` | The glob drops it — use a widened `EXPERIMENT_FILE` | | Agent eval "run" with `./eng/run-skill-evals.sh` | The glob drops it — use a widened `EXPERIMENT_FILE` |
@@ -90,7 +90,7 @@ See [references/eval-triage.md](references/eval-triage.md) for the full catalogu
### Step 4: Verify the fixtures before touching the skill ### Step 4: Verify the fixtures before touching the skill
Run `python eng/eval-quality/check_eval_quality.py` — it blocks ten defect classes that each already Run `python eng/eval-quality/check_eval_quality.py` — it blocks eleven defect classes that can
cost a real result here. Then confirm by hand: cost a real result here. Then confirm by hand:
- every fixture behaves as its stimulus assumes — a fixture meant to be healthy builds, and one - every fixture behaves as its stimulus assumes — a fixture meant to be healthy builds, and one
@@ -204,5 +204,5 @@ result, confirm the skill payload actually changed — reruns on byte-identical
- [references/writing-for-baseline-delta.md](references/writing-for-baseline-delta.md) — content patterns that beat the unskilled model - [references/writing-for-baseline-delta.md](references/writing-for-baseline-delta.md) — content patterns that beat the unskilled model
- [references/eval-triage.md](references/eval-triage.md) — symptom, cause and fix catalogue with PR citations - [references/eval-triage.md](references/eval-triage.md) — symptom, cause and fix catalogue with PR citations
- [eng/eval-quality/README.md](../../../eng/eval-quality/README.md) — the ten structural gate checks and why each exists - [eng/eval-quality/README.md](../../../eng/eval-quality/README.md) — the eleven structural gate checks and why each exists
- [eng/vally-adapter/InvestigatingResults.md](../../../eng/vally-adapter/InvestigatingResults.md) — downloading artifacts and reading `results.json`. This is the current guide; the similarly-named `eng/skill-validator/src/docs/InvestigatingResults.md` documents the retired `skill-validator evaluate` schema and does not describe today's results. - [eng/vally-adapter/InvestigatingResults.md](../../../eng/vally-adapter/InvestigatingResults.md) — downloading artifacts and reading `results.json`. This is the current guide; the similarly-named `eng/skill-validator/src/docs/InvestigatingResults.md` documents the retired `skill-validator evaluate` schema and does not describe today's results.
+3 -3
View File
@@ -13,9 +13,9 @@ Use the repository's own authoring skills under `.agents/skills/` instead of imp
Classify the failure before editing skill content; broken fixtures, underpowered trial counts and Classify the failure before editing skill content; broken fixtures, underpowered trial counts and
harness errors routinely masquerade as skill regressions. harness errors routinely masquerade as skill regressions.
Before pushing eval changes, run `python eng/eval-quality/check_eval_quality.py`. It blocks ten Before pushing eval changes, run `python eng/eval-quality/check_eval_quality.py`. It blocks eleven
structural defect classes documented in `eng/eval-quality/README.md`, each of which has already cost structural defect classes documented in `eng/eval-quality/README.md` that can corrupt a real
a real evaluation result here. evaluation result.
The distilled quality rules — what makes a skill beat its own baseline — live in the "Quality bar" The distilled quality rules — what makes a skill beat its own baseline — live in the "Quality bar"
section of `CONTRIBUTING.md`. section of `CONTRIBUTING.md`.
+18 -6
View File
@@ -1,10 +1,8 @@
# Eval quality gate # Eval quality gate
`check_eval_quality.py` blocks defect classes that have each already cost a real `check_eval_quality.py` blocks structural defects that can corrupt an eval
evaluation result on this repo. Every one of them was invisible to the existing result. Most were first found only after an eval mysteriously lost to its own
checks: the eval specs parsed, `skill-validator` passed, and the damage only baseline or won every trial and still failed.
showed up as a skill mysteriously losing to its own baseline — or as a skill
winning every trial and failing anyway.
Run it from the repository root: Run it from the repository root:
@@ -16,7 +14,7 @@ python eng/eval-quality/selftest_eval_quality.py # prove the gate still fi
## Failing checks ## Failing checks
All ten are **structural** — they inspect file existence, git state, declared All eleven are **structural** — they inspect file existence, git state, declared
numbers, or YAML shape/keys. None of them interprets prose, so they cannot fire numbers, or YAML shape/keys. None of them interprets prose, so they cannot fire
spuriously on a well-written eval. spuriously on a well-written eval.
@@ -198,6 +196,12 @@ stimuli because they do not enter the test. Eight stimuli are enough for 80%
power only for a near-deterministic 90% conditional win rate. A non-pass is not power only for a near-deterministic 90% conditional win rate. A non-pass is not
proof of no effect. proof of no effect.
The table gives **sign-test power**, before the 20% practical floor is applied.
At a true 60% conditional win rate, the floor is exactly at the expected effect:
with 158 votes the sign test has 80.6% power, but the combined gate passes about
52.2% of records and approaches 50% as the sample grows. The gate is designed to
certify effects above its practical threshold, not effects that only equal it.
Repeated runs still matter. Keep Vally's recommended run counts where the cost Repeated runs still matter. Keep Vally's recommended run counts where the cost
allows, and read `comparisonTrialEvidence` plus per-stimulus run W/T/L for allows, and read `comparisonTrialEvidence` plus per-stimulus run W/T/L for
reliability. Do not use those runs to clear the distinct-stimulus floor. reliability. Do not use those runs to clear the distinct-stimulus floor.
@@ -272,6 +276,14 @@ So the one actionable signal points away from the cause, and the suggested fix
re-runs a spec that can never load. Replace `config:` with one `defaults:` block re-runs a spec that can never load. Replace `config:` with one `defaults:` block
that carries all settings. that carries all settings.
### 11. Duplicate stimulus names
Vally pairs baseline and treatment trajectories by `(stimulus name, trial
index)`. Two stimuli with the same name therefore create ambiguous comparison
slots even when their prompts differ. The authoring gate requires every
stimulus name in one eval to be unique; the runtime adapter also rejects missing
or duplicate comparison slot identities.
## Why the gate scores direction, not magnitude ## Why the gate scores direction, not magnitude
Worth recording, because the check above is only half of what went wrong. Worth recording, because the check above is only half of what went wrong.
+22 -2
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Eval quality gate. """Eval quality gate.
Codifies defect classes that have each cost a real evaluation result, so they Codifies structural defect classes that can corrupt an evaluation result, so
cannot silently recur in any plugin. they cannot silently recur in any plugin.
FAILS on unambiguous bugs: FAILS on unambiguous bugs:
1. Referenced fixture missing on disk. The scenario fails at setup, which 1. Referenced fixture missing on disk. The scenario fails at setup, which
@@ -41,6 +41,8 @@ FAILS on unambiguous bugs:
alias for `defaults`; vally's loader throws on a spec carrying both, the alias for `defaults`; vally's loader throws on a spec carrying both, the
evaluate job then produces no verdicts, and CI misreports that as a evaluate job then produces no verdicts, and CI misreports that as a
transient infrastructure failure. transient infrastructure failure.
11. Duplicate stimulus names. Vally pairs comparison trajectories by stimulus
name and trial index, so names are slot identity, not display text.
Every failing check above is structural — it inspects file existence, git Every failing check above is structural — it inspects file existence, git
state, declared numbers, or YAML shape/keys — so it cannot fire spuriously on state, declared numbers, or YAML shape/keys — so it cannot fire spuriously on
@@ -249,6 +251,23 @@ def check_spec_shape(spec: str, doc: dict, raw: str) -> None:
f"failure. Merge them into one 'defaults:' block") f"failure. Merge them into one 'defaults:' block")
def check_stimulus_names(spec: str, doc: dict) -> None:
"""Require unique names because Vally uses them as comparison slot identity."""
seen: set[str] = set()
for index, stimulus in enumerate(doc.get("stimuli") or []):
if not isinstance(stimulus, dict):
continue
name = stimulus.get("name")
if not isinstance(name, str) or not name:
continue # Vally schema validation owns missing or malformed names.
if name in seen:
errors.append(
f"{spec}: duplicate stimulus name {name!r} at stimuli[{index}]. "
f"Vally pairs trajectories by (stimulus name, trial index), so every "
f"stimulus name must be unique.")
seen.add(name)
def check_dormancy_guards(spec: str, doc: dict) -> None: def check_dormancy_guards(spec: str, doc: dict) -> None:
for stim in doc.get("stimuli") or []: for stim in doc.get("stimuli") or []:
if stim.get("expect_activation") is not False: if stim.get("expect_activation") is not False:
@@ -687,6 +706,7 @@ def main() -> int:
check_fixtures(spec, doc, tracked) check_fixtures(spec, doc, tracked)
check_graders(spec, doc) check_graders(spec, doc)
check_spec_shape(spec, doc, raw) check_spec_shape(spec, doc, raw)
check_stimulus_names(spec, doc)
check_dormancy_guards(spec, doc) check_dormancy_guards(spec, doc)
check_cobertura() check_cobertura()
+10
View File
@@ -248,6 +248,14 @@ def config_and_defaults_together(d):
f.write("config:\n timeout: 5m\n") f.write("config:\n timeout: 5m\n")
def duplicate_stimulus_names(d):
path = EV(d)
with open(path) as f:
raw = f.read()
with open(path, "w") as f:
f.write(raw.replace("name: Does the edge thing", "name: Does the thing", 1))
def grandfathered_reports_its_arithmetic(d): def grandfathered_reports_its_arithmetic(d):
# The gate's job for a grandfathered eval is to tell the contributor what to # The gate's job for a grandfathered eval is to tell the contributor what to
# change, so the report must separate distinct stimuli from repeated runs. # change, so the report must separate distinct stimuli from repeated runs.
@@ -427,6 +435,8 @@ results = [
case("grader with an empty config enforces nothing", empty_grader_config, expect_fail=True), case("grader with an empty config enforces nothing", empty_grader_config, expect_fail=True),
case("duplicate key silently overwrites a scenario", duplicate_stimulus_keys, expect_fail=True), case("duplicate key silently overwrites a scenario", duplicate_stimulus_keys, expect_fail=True),
case("spec declares both config: and defaults:", config_and_defaults_together, expect_fail=True), case("spec declares both config: and defaults:", config_and_defaults_together, expect_fail=True),
case("duplicate stimulus names make slot identity ambiguous",
duplicate_stimulus_names, expect_fail=True),
case("dormancy guard also sets reject_skills", guard_with_reject_skills, expect_fail=True), case("dormancy guard also sets reject_skills", guard_with_reject_skills, expect_fail=True),
case("well-formed dormancy guard", guard_ok, expect_fail=False), case("well-formed dormancy guard", guard_ok, expect_fail=False),
output_case("reference skill carrying a direct-activation eval", output_case("reference skill carrying a direct-activation eval",
+3 -1
View File
@@ -466,4 +466,6 @@ Results include bootstrap confidence intervals computed across individual runs.
- **not significant**: the CI crosses zero — could be noise - **not significant**: the CI crosses zero — could be noise
- **g=**: normalized gain, controlling for ceiling effects (a skill improving a strong baseline is harder than improving a weak one) - **g=**: normalized gain, controlling for ceiling effects (a skill improving a strong baseline is harder than improving a weak one)
The default of 5 runs provides sufficient precision for significance testing (validated by [SkillsBench](https://arxiv.org/abs/2602.12670)). These intervals describe the legacy run-pooled report. The repository's current
Vally gate does not treat repeated runs as independent task samples: runs measure
within-stimulus reliability, while distinct stimuli supply the statistical votes.
+6
View File
@@ -126,6 +126,12 @@ These are planning values, not Vally requirements. Ties require more total
stimuli because they do not enter the sign test. A non-pass is therefore not stimuli because they do not enter the sign test. A non-pass is therefore not
proof that the skill has no effect. proof that the skill has no effect.
The table is sign-test power before the 20% practical floor. At a true 60%
conditional win rate, 158 votes give the sign test 80.6% power, but the combined
gate passes about 52.2% of records because the expected effect sits exactly on
the floor. The gate is intended to certify effects above that practical
threshold.
## Objective completion contract ## Objective completion contract
`VALID_REGRESSION` stays reserved until the harness can compute this tri-state `VALID_REGRESSION` stays reserved until the harness can compute this tri-state
+95 -19
View File
@@ -569,8 +569,53 @@ function classifyComparisonError(evidence) {
function comparisonTrialKey(stimulusName, trial) { function comparisonTrialKey(stimulusName, trial) {
const trialIndex = trial?.trialIndex; const trialIndex = trial?.trialIndex;
if (trialIndex == null) return null; if (!stimulusName || !Number.isInteger(trialIndex) || trialIndex < 0) return null;
return JSON.stringify([stimulusName ?? "", trialIndex]); return JSON.stringify([stimulusName, trialIndex]);
}
function comparisonTrialIdentityErrors(report) {
const seen = new Set();
const duplicates = new Set();
const errors = [];
for (const stimulus of report.stimuli ?? []) {
for (const trial of stimulus.trials ?? []) {
const key = comparisonTrialKey(stimulus.stimulusName, trial);
if (key === null) {
errors.push({
phase: "comparison_pairing",
kind: "permanent",
code: "comparison_trial_identity_missing",
message: "Comparison trial is missing trialIndex",
stimulusName: stimulus.stimulusName,
trialIndex: null,
});
} else if (seen.has(key) && !duplicates.has(key)) {
duplicates.add(key);
errors.push({
phase: "comparison_pairing",
kind: "permanent",
code: "comparison_trial_identity_duplicate",
message: "Comparison report contains a duplicate (stimulusName, trialIndex) slot",
stimulusName: stimulus.stimulusName,
trialIndex: trial.trialIndex,
});
} else {
seen.add(key);
}
}
}
return errors;
}
function comparisonTrialKeyCounts(report) {
const counts = new Map();
for (const stimulus of report.stimuli ?? []) {
for (const trial of stimulus.trials ?? []) {
const key = comparisonTrialKey(stimulus.stimulusName, trial);
if (key !== null) counts.set(key, (counts.get(key) ?? 0) + 1);
}
}
return counts;
} }
function summarizeComparisonTrials(report, invalidateInterval = false) { function summarizeComparisonTrials(report, invalidateInterval = false) {
@@ -611,11 +656,13 @@ function summarizeComparisonTrials(report, invalidateInterval = false) {
*/ */
function mergeComparisonReports(primaryReport, retryReport) { function mergeComparisonReports(primaryReport, retryReport) {
const report = structuredClone(primaryReport); const report = structuredClone(primaryReport);
const primaryKeyCounts = comparisonTrialKeyCounts(primaryReport);
const retryKeyCounts = comparisonTrialKeyCounts(retryReport);
const retryTrials = new Map(); const retryTrials = new Map();
for (const stimulus of retryReport?.stimuli ?? []) { for (const stimulus of retryReport?.stimuli ?? []) {
for (const trial of stimulus.trials ?? []) { for (const trial of stimulus.trials ?? []) {
const key = comparisonTrialKey(stimulus.stimulusName, trial); const key = comparisonTrialKey(stimulus.stimulusName, trial);
if (key !== null) retryTrials.set(key, trial); if (key !== null && retryKeyCounts.get(key) === 1) retryTrials.set(key, trial);
} }
} }
@@ -633,7 +680,9 @@ function mergeComparisonReports(primaryReport, retryReport) {
retriedSlots++; retriedSlots++;
const error = classifyComparisonError(trial.evidence); const error = classifyComparisonError(trial.evidence);
const retryKey = comparisonTrialKey(stimulus.stimulusName, trial); const retryKey = comparisonTrialKey(stimulus.stimulusName, trial);
const retry = retryKey === null ? undefined : retryTrials.get(retryKey); const primaryIdentityIsUnique =
retryKey !== null && primaryKeyCounts.get(retryKey) === 1;
const retry = primaryIdentityIsUnique ? retryTrials.get(retryKey) : undefined;
if (retry && !retry.errored) { if (retry && !retry.errored) {
recoveredErrors.push({ recoveredErrors.push({
stimulusName: stimulus.stimulusName, stimulusName: stimulus.stimulusName,
@@ -648,14 +697,34 @@ function mergeComparisonReports(primaryReport, retryReport) {
}; };
} }
const retryError = retry?.errored const retryError = !primaryIdentityIsUnique
? classifyComparisonError(retry.evidence) ? {
: { phase: "comparison_pairing",
phase: "comparison_judge", kind: "permanent",
kind: "unknown", code:
code: "retry_result_missing", retryKey === null
message: "Comparison retry did not return the planned trial slot", ? "comparison_trial_identity_missing"
}; : "comparison_trial_identity_duplicate",
message:
retryKey === null
? "Original comparison trial is missing trialIndex"
: "Original comparison report contains a duplicate trial slot",
}
: retryKey !== null && (retryKeyCounts.get(retryKey) ?? 0) > 1
? {
phase: "comparison_pairing",
kind: "permanent",
code: "retry_result_ambiguous",
message: "Comparison retry returned a duplicate trial slot",
}
: retry?.errored
? classifyComparisonError(retry.evidence)
: {
phase: "comparison_judge",
kind: "unknown",
code: "retry_result_missing",
message: "Comparison retry did not return the planned trial slot",
};
persistentErrors.push({ persistentErrors.push({
stimulusName: stimulus.stimulusName, stimulusName: stimulus.stimulusName,
trialIndex: trial.trialIndex ?? index, trialIndex: trial.trialIndex ?? index,
@@ -809,6 +878,7 @@ function comparisonToVerdict(report, identity, roles, nonActivationStims) {
const unmatchedBaseline = report.unmatchedBaseline ?? []; const unmatchedBaseline = report.unmatchedBaseline ?? [];
const unmatchedTreatment = report.unmatchedTreatment ?? []; const unmatchedTreatment = report.unmatchedTreatment ?? [];
const unmatchedTrialCount = unmatchedBaseline.length + unmatchedTreatment.length; const unmatchedTrialCount = unmatchedBaseline.length + unmatchedTreatment.length;
const identityErrors = comparisonTrialIdentityErrors(report);
// Raw paired trials remain authoritative for report-integrity checks, retry // Raw paired trials remain authoritative for report-integrity checks, retry
// accounting, and within-stimulus reliability. They are not independent task // accounting, and within-stimulus reliability. They are not independent task
@@ -836,7 +906,11 @@ function comparisonToVerdict(report, identity, roles, nonActivationStims) {
); );
} }
const conclusive = s.erroredCount === 0 && unmatchedTrialCount === 0 && summaryAgrees; const conclusive =
s.erroredCount === 0 &&
unmatchedTrialCount === 0 &&
summaryAgrees &&
identityErrors.length === 0;
// Collapse repeated runs to one vote per stimulus. A stimulus votes in the // Collapse repeated runs to one vote per stimulus. A stimulus votes in the
// direction supported by more of its successful runs; an even split or all // direction supported by more of its successful runs; an even split or all
@@ -1074,11 +1148,13 @@ function comparisonToVerdict(report, identity, roles, nonActivationStims) {
if (!conclusive) { if (!conclusive) {
state = VERDICT_STATES.INVALID_INCONCLUSIVE; state = VERDICT_STATES.INVALID_INCONCLUSIVE;
stateReason = stateReason =
unresolvedErrors.length > 0 identityErrors.length > 0
? { code: "comparison_judge_error", phase: "comparison_judge" } ? { code: identityErrors[0].code, phase: "comparison_pairing" }
: unmatchedTrialCount > 0 : unresolvedErrors.length > 0
? { code: "unmatched_trajectories", phase: "comparison_pairing" } ? { code: "comparison_judge_error", phase: "comparison_judge" }
: { code: "comparison_summary_mismatch", phase: "adapter" }; : unmatchedTrialCount > 0
? { code: "unmatched_trajectories", phase: "comparison_pairing" }
: { code: "comparison_summary_mismatch", phase: "adapter" };
} else if (underpowered) { } else if (underpowered) {
state = VERDICT_STATES.INVALID_INCONCLUSIVE; state = VERDICT_STATES.INVALID_INCONCLUSIVE;
stateReason = { code: "underpowered", phase: "eval_design" }; stateReason = { code: "underpowered", phase: "eval_design" };
@@ -1168,7 +1244,7 @@ function comparisonToVerdict(report, identity, roles, nonActivationStims) {
recoveredErrors: [], recoveredErrors: [],
persistentErrors: [], persistentErrors: [],
}, },
errors: unresolvedErrors, errors: [...identityErrors, ...unresolvedErrors],
recoveredErrors, recoveredErrors,
scenarios, scenarios,
reason, reason,
+58 -1
View File
@@ -533,7 +533,64 @@ test("does not pair retry slots by array position when trialIndex is absent", ()
assert.equal(merged.retrySummary.recoveredSlots, 0); assert.equal(merged.retrySummary.recoveredSlots, 0);
assert.equal( assert.equal(
merged.retrySummary.persistentErrors[0].attemptHistory[1].code, merged.retrySummary.persistentErrors[0].attemptHistory[1].code,
"retry_result_missing", "comparison_trial_identity_missing",
);
});
test("does not recover an ambiguous duplicate retry slot", () => {
const primary = reportFromRepeatedScores([null]);
primary.stimuli[0].trials[0] = {
trialIndex: 0,
score: 0,
winner: "tie",
errored: true,
evidence: "Comparison judge failed: Timeout after 120000ms waiting for session.idle",
};
primary.summary.trialCount = 0;
primary.summary.erroredCount = 1;
primary.summary.wins = 0;
const retry = reportFromRepeatedScores([0.4, 0.4]);
retry.stimuli[0].trials[1].trialIndex = 0;
const merged = mergeComparisonReports(primary, retry);
assert.equal(merged.summary.erroredCount, 1);
assert.equal(merged.retrySummary.recoveredSlots, 0);
assert.equal(
merged.retrySummary.persistentErrors[0].attemptHistory[1].code,
"retry_result_ambiguous",
);
});
test("missing or duplicate comparison slot identities invalidate a verdict", () => {
const missing = reportFromScores([0.4, 0.4, 0.4, 0.4, 0.4]);
delete missing.stimuli[0].trials[0].trialIndex;
const missingVerdict = comparisonToVerdict(
missing,
IDENTITY,
EMPTY_ROLES,
new Set(),
);
assert.equal(missingVerdict.conclusive, false);
assert.equal(missingVerdict.state, VERDICT_STATES.INVALID_INCONCLUSIVE);
assert.equal(
missingVerdict.stateReason.code,
"comparison_trial_identity_missing",
);
const duplicate = reportFromStimulusRuns([[0.4, 0.4], [0.4], [0.4], [0.4]]);
duplicate.stimuli[0].trials[1].trialIndex = 0;
const duplicateVerdict = comparisonToVerdict(
duplicate,
IDENTITY,
EMPTY_ROLES,
new Set(),
);
assert.equal(duplicateVerdict.conclusive, false);
assert.equal(duplicateVerdict.state, VERDICT_STATES.INVALID_INCONCLUSIVE);
assert.equal(
duplicateVerdict.stateReason.code,
"comparison_trial_identity_duplicate",
); );
}); });