Files
callstack__agent-device/scripts/__tests__/help-conformance-expectation-falsification.test.ts
Michał Pierzchała 250e30a578 test(bench): falsification fixtures for oracles + typed runner outcomes (#1893)
* test(bench): falsification fixtures for oracles + typed runner outcomes

Two deterministic PR-time quality gates for the help-conformance bench
(the repo's single non-gating small-model planning oracle):

- Every EXPECTATION_SCORERS entry in help-conformance-case-checks.mjs
  now has a falsification fixture (a minimal passing witness plus at
  least one known-bad counterexample, and a metamorphic variant where
  useful) in the new help-conformance-expectation-fixtures.ts, run
  through the real validatePlanCommands/scoreExpectations pipeline.
  help-conformance-expectation-falsification.test.ts is the "what
  enumerates N" completeness gate: a new named expectation with no
  fixture fails it. Counterexamples cover swallowed lifecycle command
  prefixes, unsupported flags/selectors, pseudo refs, shell operators,
  and invalid positional ordering.

- help-conformance-runner-output.mjs now returns a discriminated
  RunnerOutcome ({kind:'success',commands}|{kind:'runner-error',
  message,reason}) instead of a raw-string success inference. Only a
  'success' outcome ever reaches validatePlanCommands/scoreExpectations
  in runCase, so a runner-error result can no longer also carry
  model-validation checks, and an all-runner-error aggregate now
  reports passRate: null (rendered as "N/A") instead of "0/0 (0%)".

Fixes #1481

* refactor(bench): dedupe RunnerOutcome construction, drop leftover narrowing

Thermo-nuclear pass over 4b2df0a38's diff:

- help-conformance-bench.mjs's runOutcome() catch block was hand-building
  the exact {kind:'runner-error', raw, message, reason} shape that
  runner-output.mjs's private runnerError() helper already constructs for
  its own two error paths. Export it as runnerErrorOutcome so the
  discriminated union has exactly one constructor for its error variant,
  reused by both error sources instead of duplicated.
- runCase's two return branches repeated the same
  {runner, caseId, trial, outputPath} fields; pulled into a shared `base`
  object.
- Reverted bench.test.ts's rateLimitedOutcome block: it had an explicit
  `: RunnerOutcome` annotation and an if/throw narrowing guard, added only
  to give fallow's dead-code checker a "real consumer" of the type before
  the actual fix (adding the .d.mts to .fallowrc.json's ignorePatterns,
  matching the existing sample-outputs.d.mts precedent) was found. That
  workaround is now unnecessary scaffolding — replaced with the same
  flat assert.deepEqual style the surrounding assertions already use.
2026-08-20 12:52:42 +02:00

108 lines
4.0 KiB
TypeScript

import assert from 'node:assert/strict';
import { test } from 'vitest';
import { KNOWN_EXPECTATIONS, scoreExpectations } from '../help-conformance-case-checks.mjs';
import { validatePlanCommands } from '../help-conformance-plan-validator.mjs';
import {
EXPECTATION_FIXTURES,
type ExpectationWitness,
} from './help-conformance-expectation-fixtures.ts';
// "What enumerates N" for the expectation surface (see the analogous gates
// for help topics and error-recovery quizzes): KNOWN_EXPECTATIONS is the
// scorer registry itself (help-conformance-case-checks.mjs
// EXPECTATION_SCORERS), so a new named expectation with no falsification
// fixture fails this gate instead of silently shipping unfalsifiable. An
// oracle nobody has proven can fail is not an oracle — it is decoration that
// reports every plan as passing whether or not its regex or predicate still
// matches anything.
test('every named expectation has a falsification fixture', () => {
const missing = KNOWN_EXPECTATIONS.filter((id) => !(id in EXPECTATION_FIXTURES));
assert.deepEqual(
missing,
[],
`add a scripts/__tests__/help-conformance-expectation-fixtures.ts entry for: ${missing.join(', ')}`,
);
});
test('the fixture registry names only real, current expectations', () => {
const known = new Set<string>(KNOWN_EXPECTATIONS);
const stale = Object.keys(EXPECTATION_FIXTURES).filter((id) => !known.has(id));
assert.deepEqual(
stale,
[],
`fixture(s) for a retired/renamed expectation: ${stale.join(', ')} — remove them`,
);
});
test('every fixture set declares a passing witness and at least one counterexample', () => {
for (const [id, fixture] of Object.entries(EXPECTATION_FIXTURES)) {
assert.ok(fixture.passing, `expectation "${id}" needs a passing witness`);
assert.ok(
fixture.counterexamples.length > 0,
`expectation "${id}" needs at least one known-bad counterexample`,
);
for (const counter of fixture.counterexamples) {
assert.ok(
counter.id.trim().length > 0,
`expectation "${id}" has a counterexample with no id`,
);
}
}
});
/** Runs a witness through the real production scorer pipeline, not a reimplementation. */
async function score(id: string, witness: ExpectationWitness): Promise<boolean> {
const commandValidation = await validatePlanCommands(witness.commands, {
allowedExternalCommands: witness.allowedExternalCommands,
});
const raw = witness.commands.join('\n');
const checks = scoreExpectations(
{ expectations: [id] },
witness.commands,
raw,
commandValidation,
);
return checks[id] === true;
}
// One vitest test per witness/counterexample (rather than one loop inside a
// single test) so a failure names the exact fixture that broke, and so the
// many real validatePlanCommands subprocess calls this drives don't compound
// into one test's timeout budget.
for (const [id, fixture] of Object.entries(EXPECTATION_FIXTURES)) {
test(`${id}: passing witness scores true`, async () => {
assert.equal(
await score(id, fixture.passing),
true,
`expectation "${id}" passing witness must score true: ${JSON.stringify(fixture.passing.commands)}`,
);
});
for (const counter of fixture.counterexamples) {
test(`${id}: counterexample "${counter.id}" scores false`, async () => {
assert.equal(
await score(id, counter),
false,
`expectation "${id}" counterexample "${counter.id}" must score false: ${JSON.stringify(counter.commands)}`,
);
});
}
if (fixture.metamorphic) {
const metamorphic = fixture.metamorphic;
test(`${id}: metamorphic variant differs from the witness and still scores true`, async () => {
assert.notDeepEqual(
metamorphic.commands,
fixture.passing.commands,
`expectation "${id}" metamorphic variant must actually differ from the passing witness`,
);
assert.equal(
await score(id, metamorphic),
true,
`expectation "${id}" metamorphic variant must still score true: ${JSON.stringify(metamorphic.commands)}`,
);
});
}
}