/** * Cross-run eval comparison (docs/testing/evals-test-plan.md S17) — the OFFLINE, deterministic diff * of two recorded {@link EvalRunDetail}s over the same corpus: which per-sample verdicts flipped, * whether each flip is the kind a tightened store is allowed to produce (`permitted`) or the kind a * release has to explain (`flagged`), whether the two stored summaries reconcile to the per-sample * accounting, and which rules gained or lost exercise (core #394 `rule_coverage`). * * Pure over the two records: no store, no engine, no clock, no I/O — two persisted drilldowns in, * one report out, byte-stable for the same inputs (every list is codepoint-sorted by id). * * # What "comparable" means here * * Plan §3 keys a comparison by five identities. The records carry four of them today (`corpus`, * `rule_store`, `type_filter`, `degraded`; the rule snapshot and the engine build are §5 follow-ups), * so `comparable` is derived from CONTENT, never from a name alone: the same corpus name AND the same * type filter AND the same sample ids AND, per id, the same `kind` AND the same sample PAYLOAD * identity — the `sample.payload_hash` a producer stamped on each result row (`eval-sample.js` * `samplePayloadHash`: sha256 over the canonical JSON of id, description, kind, steering_type, * signals). The engine echoes only id/description/kind/steering_type per row, never the input * `signals`, so a row WITHOUT a payload hash cannot be proven to be the same action as its * namesake in the other run: such a pair is `comparable: false` with `comparable_reason` starting * `unverified: no sample identity` — unverified, which is not the same as "different". A sample * present on one side only, one whose kind changed, or one whose payload hash changed means the * corpus changed under the same name — reported, and the pair is not comparable release over * release. Two runs under DIFFERENT type filters judged different slices of the corpus over * different coverage denominators — not comparable either (`differing-type-filter`, see below). * `comparable_reason` is `null` exactly when `comparable` is true. * * # Flip classification (the S17 rule table) * * bad gap → caught permitted a rule tightened: a bad behavior is now caught * bad caught → gap flagged regression: a bad behavior is no longer caught * good false_positive → caught permitted a good sample is no longer denied * good caught → false_positive flagged a good sample is newly denied * anything else flagged not a verdict pair that kind can take (evals.rs * `evaluate_sample`: a bad sample is caught|gap, a * good sample is caught|false_positive) * * `good` is a sample KIND, not a verdict — the draft plan's `good → false_positive` spelling was * corrected in revision 3; this module is the executable form of that correction. * * # Coverage transitions vs. rule-set changes — what a record CANNOT tell (codex rounds 6 and 8) * * The wire carries `rule_coverage.exercised` as a COUNT and `unexercised` as a LIST of ids * (api-types `GovernanceEvalRuleCoverage`; wicked-core `crates/wicked-governance/src/evals.rs` on * branch `feat/evals-effect-and-coverage` (core #394/#395, PR #398) at `a87e461` — `RuleCoverage` * lines 323-328: `exercised: usize`, `unexercised: Vec`, `recall_only: usize`, * `per_type: BTreeMap`). The partition is over the ELIGIBLE rules — the * active, effect-bearing rules of the slice (`decide_lane_rules` lines 816-843: `effect.is_some() * && !retired && in_slice`); `recall_only` counts the effect-less active rules OUTSIDE it (docs * lines 317-319, computed at 875-878). A rule is EXERCISED when it appeared in ANY evaluated claim's * `policy_ids`, whatever its effect (`RuleCoverage` docs lines 312-314; `rule_coverage()` lines * 849-880 partitions the eligible rules by `triggered`, the union `run_evals` collects of every * claim's `policy_ids`). A result row's `fired`, however, is the BLOCKING subset only: * `evaluate_sample` lines 906-916 keep the ids whose effect is `Deny`. Hence, for an UNFILTERED run, * * fired(run) ⊆ exercised(run), and `exercised − |fired|` rules were exercised by a non-blocking * (`warn`) effect ALONE — counted, but NEVER named anywhere in the record. * * NO field of the wire lists a run's rule set: `exercised` and `recall_only` are counts, * `unexercised` names only the rules nothing exercised, `fired` only the blocking firings. The rule * identities a record enumerates are therefore exactly `unexercised ∪ fired`, and an id one record * enumerates and the other does not is NOT thereby absent from the other's store: it may be one of * that side's unnamed warn-exercised rules, an effect-less (recall-only) or retired rule outside the * eligible partition, under a type filter a rule of another type outside the denominator — or a rule * the store really gained or lost. Round 6 replaced a reconstruction of "the inventory" as * `unexercised ∪ fired` with a completeness inference: `exercised === |fired|` ⇒ every exercised rule * is named ⇒ the silent side's absence was asserted as `added_rules`/`removed_rules`. Round 8 deleted * that inference too — under a type filter a fired id is typed into the slice only by the OTHER run's * `unexercised` row, and a rule's type in run A does not establish its type in run B (codex's * reproduction: A lists R and Q unexercised under `development`; B moves R to `security`, fires R * blocking and exercises Q by `warn`; the cross-run intersection named R, declared B complete and * reported Q — still present and exercised — as `removed_rules`). The delta asserts ONLY what the * records state: * * gained unexercised in A (listed) AND blocking-fired in B (listed): a sample now exercises * the rule — a statement about the RULE, both ends named, certain * lost blocking-fired in A AND unexercised in B (listed): certain * unidentified PER RECORD, how many exercised rules of its denominator it does not name: * unfiltered `exercised − |fired|`; under a type filter the whole `exercised` (a * fired id carries no steering_type, so a record types none of its own firings into * the slice) — never reduced by what the OTHER run lists * added_rules / asserted only from an explicit rule inventory on BOTH records — the wire carries * removed_rules none, so both are EMPTY for every daemon-recorded run and `inventory` is `partial` * transitions_withheld * every id enumerated by one record and not the other, one reason per id saying what * it may be besides a rule-set change — WITHHELD, never guessed * * `exercised_delta` is always the difference of the two counts. Reconciliation errors on coverage are * the record contradicting ITSELF (or being malformed — below); two valid reports always `reconcile`. * * # Type filters (codex round 7) — the denominator is the SLICE; the firings are not * * A run recorded under `type_filter: T` was produced by `rules eval --type T`. In the engine that * filter slices the SAMPLES (`run_evals` lines 974-977: only samples of type T are judged) and the * COVERAGE DENOMINATOR (`decide_lane_rules` lines 816-844, `in_slice` at 820: only type-T rules are * eligible; `rule_coverage` 849-880 partitions exactly those; `recall_only` 875-878 is sliced too) * but NOT the gate: `evaluate_sample` runs `select_any` over every active rule whatever its type * (line 901) and a row's `fired` keeps every blocking id it produced (906-916). So under a filter a * row may fire a rule OUTSIDE the denominator — codex's reproduction: a development-filtered run * with `fired: ["SECURITY-DENY"]` and `exercised: 0` is a VALID record (the security rule is not a * development rule), and the unfiltered invariant `exercised ≥ |fired|` does not hold. Hence, per * record ({@link EvalCoverageReconciliation}, reported per side in `coverage_reconciliation`): * * type_filter null `'rows'` — every active rule is in the denominator, so the rows' * blocking-fired ids are exercised rules: `exercised ≥ |fired|`, and * the exercised set is complete iff `exercised === |fired|`. * type_filter T, per_type `'per_type'` — reconciled against the engine's OWN row for the slice * present (`rule_coverage.per_type[T]`, `TypeCoverage` lines 300-303): * `per_type[T].exercised === exercised`; the rows' fired ids carry no * steering type and are NOT a denominator check. * type_filter T, no `'n/a (engine reports no per-type coverage)'` — the record carries * per_type nothing to check its slice against; nothing fired-based is asserted. * * Whatever the filter: `per_type`, when present, must sum to `exercised` and agree per type with the * listed `unexercised` rows; under a filter every listed row is of the filter's type; no id is * listed twice; and no listed-unexercised id fired — that row TYPES the rule into the slice, and a * blocking firing is a firing, which makes an eligible rule exercised (862-873) — so this last check * is sound under a filter too (the fix brief's floor was "unfiltered only"; the engine's definition * makes it general). * * Two runs under DIFFERENT filters get NO `rule_coverage_delta` (an inventory diff across * denominators would report the other slice's rules as changes) and are not comparable. Under the * SAME filter T the delta is computed exactly as unfiltered — `gained`/`lost` from ids listed on both * ends (the unexercised row carries `steering_type: T`, the firing is a firing), every one-sided id * withheld — except that `unidentified` is the whole `exercised` count (see above) and each withheld * reason adds that the id may be a rule of another type outside the denominator. * * # Malformed persisted coverage (codex round 8) * * The daemon persists a run's `rule_coverage` verbatim and validates none of it (the script's * `verifyEngineReport` gates only its own reports). A recorded `rule_coverage` that is not the wire * shape — `null`; `unexercised` not an array; `per_type: null`, a row that is not `{ exercised, * unexercised }` of non-negative integers, a key that is no steering type … — cannot be reconciled: * that side is `coverage_reconciliation: 'unverified (malformed rule_coverage)'` * ({@link MALFORMED_RULE_COVERAGE}), a reconciliation error names the run and the defect, no * `rule_coverage_delta` is computed, and the comparison never throws over persisted data. * * # Malformed persisted result rows (Copilot on #475) * * The same store validates a detail's `results` only as "an array" (`EvalRunStore.get()`), so a row * that is not the wire shape (`GovernanceEvalResult` — no `sample`, `sample.id` not a non-empty * string, a `kind` or `verdict` outside its union, `fired` not an array of strings, a row that is no * object at all) can reach the comparison from a corrupted or hand-edited file. Every row is checked * ({@link resultRowProblem}) BEFORE it is indexed, tallied or iterated: a malformed one is ONE * reconciliation error naming the run, the index and the defect, and is EXCLUDED from everything — * the id index, the identity counts, the tally, the coverage `fired` set. The pair is then not * comparable ({@link MALFORMED_RESULT_ROWS}), and that side's stored summary is NOT checked against a * results list the comparison could not fully read (the shortfall is the excluded rows, not a summary * defect — reporting it as one would misattribute it). Never a throw. */ import type { EvalRunDetail, GovernanceEvalResult, GovernanceEvalSummary, SteeringType } from '../core/types.js'; export type EvalVerdict = GovernanceEvalResult['verdict']; export type EvalSampleKind = GovernanceEvalResult['sample']['kind']; export type FlipClass = 'permitted' | 'flagged'; /** The exact prefix of `comparable_reason` when a side carries result rows without a payload hash. */ export declare const UNVERIFIED_NO_SAMPLE_IDENTITY = "unverified: no sample identity"; /** The exact prefix of `comparable_reason` when the two runs were produced under different type filters. */ export declare const DIFFERING_TYPE_FILTER = "differing-type-filter"; /** One identity dimension, side by side. */ export interface IdentityPair { a: T; b: T; same: boolean; } /** One sample whose verdict differs between the two runs. */ export interface EvalVerdictFlip { sample_id: string; /** The sample's kind in run B (equal to run A's unless the id is also in `kind_changed`). */ kind: EvalSampleKind; from: EvalVerdict; to: EvalVerdict; classification: FlipClass; reason: string; } /** Whether the two records LIST their rule sets. `complete` requires an explicit rule inventory on * BOTH records; no field of `GovernanceEvalRuleCoverage` (api-types 0.27.0) carries one — `exercised` * and `recall_only` are counts, `unexercised` names only the unexercised rules, a row's `fired` only * the blocking firings — so every comparison of daemon-recorded runs is `partial` and the rule-set * transitions are withheld by name. See the module doc. */ export type EvalRuleInventory = 'complete' | 'partial'; /** The exact `coverage_reconciliation` value of a side whose persisted `rule_coverage` is not the wire * shape — unverified, not reconciled, no delta computed over it (module doc, "Malformed persisted * coverage"). */ export declare const MALFORMED_RULE_COVERAGE = "unverified (malformed rule_coverage)"; /** The `comparable_reason` prefix of a pair one side of which persisted result rows that are not the * wire shape (api-types `GovernanceEvalResult`, module doc "Malformed persisted result rows"): each * is named in `reconciliation_errors` and EXCLUDED, never thrown over — and a comparison over a * record it could not fully read asserts nothing (Copilot on #475). */ export declare const MALFORMED_RESULT_ROWS = "unverified: malformed result row(s)"; /** * How one record's `rule_coverage` was reconciled — which denominator its numbers were checked * against (module doc, "Type filters"): * - `'rows'` — `type_filter` null: every active rule is in the denominator, so the rows' distinct * blocking-fired ids are exercised rules and `exercised ≥ |fired|` is checked; * - `'per_type'` — `type_filter` set and `per_type` present: checked against the engine's own row * for that type, `per_type[]`; the rows' fired ids may belong to other types; * - `'n/a (engine reports no per-type coverage)'` — `type_filter` set and no `per_type`: the * denominator is the filter's slice and the record carries no number to check it against; * - {@link MALFORMED_RULE_COVERAGE} — the persisted value is not the wire shape: nothing was * checked, a reconciliation error names the defect, no delta is computed over this side. */ export type EvalCoverageReconciliation = 'rows' | 'per_type' | 'n/a (engine reports no per-type coverage)' | typeof MALFORMED_RULE_COVERAGE; /** Which rules gained or lost exercise between the runs (present only when BOTH carry WELL-FORMED * coverage AND both were produced under the same `type_filter`). */ export interface EvalRuleCoverageDelta { /** `b.rule_coverage.exercised − a.rule_coverage.exercised` — the counts, always. */ exercised_delta: number; /** `partial` for every comparison of daemon-recorded runs: the wire carries no rule inventory, so * `added_rules`/`removed_rules` are never asserted and every one-sided id is in * `transitions_withheld`. `complete` is reserved for two records that LIST their rule sets. */ inventory: EvalRuleInventory; /** Per side, how many exercised rules of its denominator the record counts but does not name — * a fact about THAT record alone, never reduced by what the other run lists. Unfiltered: * `rule_coverage.exercised − |distinct blocking-fired ids|` (the rules exercised by a `warn` * effect alone). Under a type filter: `rule_coverage.exercised` — a fired id carries no * steering_type, so the record types none of its own firings into the slice. */ unidentified: { a: number; b: number; }; /** Unexercised in A (listed) AND blocking-fired in B (listed) — a sample now exercises the rule. * A statement about the RULE, certain on both ends whatever the filter (under one, the id is typed * into the slice by A's row alone — B's `unidentified` does not subtract it). */ gained: string[]; /** Blocking-fired in A AND unexercised in B (listed) — a rule nothing exercises any more. Certain. */ lost: string[]; /** Enumerated by B and not by A AND absent from A's explicit rule inventory — the wire carries no * inventory, so this is EMPTY for every daemon-recorded run (the candidates are in * `transitions_withheld`). */ added_rules: string[]; /** Enumerated by A and not by B AND absent from B's explicit rule inventory — EMPTY for the same * reason. */ removed_rules: string[]; /** Every id enumerated (unexercised or blocking-fired) by one record and not the other, one reason * per id, codepoint-sorted — what the id may be besides a rule-set change: an exercised rule the * silent side never names (a `warn`-only firing; under a type filter any of its firings), an * effect-less (recall-only) or retired rule outside the eligible partition, under a type filter a * rule of another type outside the denominator. WITHHELD, never guessed. */ transitions_withheld: string[]; } export interface EvalRunComparison { /** The two run ids, in the order given (A = the baseline, B = the candidate). */ a: string; b: string; identity: { corpus: IdentityPair; rule_store: IdentityPair; type_filter: IdentityPair; degraded: IdentityPair<'facet-only' | null>; }; /** Same corpus name, same type filter, same sample ids, same kinds, same payload identities on * every row — the same actions were judged over the same slice, provably. */ comparable: boolean; /** Why `comparable` is false (`;`-joined when several apply; starts with * {@link UNVERIFIED_NO_SAMPLE_IDENTITY} when a side carries rows without a payload hash, contains * {@link DIFFERING_TYPE_FILTER} when the filters differ), or `null` when it is true. */ comparable_reason: string | null; /** Every per-sample verdict change, codepoint-sorted by sample id, each classified. */ flips: EvalVerdictFlip[]; /** Shared samples with the same kind AND the same verdict on both sides, minus those whose payload * identity is VERIFIED to differ (`payload_changed`). A row unverified on either side (no * well-formed `payload_hash`) still counts when kind and verdict agree — `unchanged` is a verdict * statement, not proof of payload identity; `comparable` / `unverified_rows` carry that claim. */ unchanged: number; only_in_a: string[]; only_in_b: string[]; /** Shared ids whose `kind` differs — the corpus changed under this name (always flagged). */ kind_changed: string[]; /** Shared ids whose `payload_hash` differs on the two sides (both present) — the description, * steering type or signals changed under the same id and kind: the corpus changed under this * name (a flip on such an id is always flagged). */ payload_changed: string[]; /** Result rows WITHOUT a `sample.payload_hash`, per side — the rows whose identity is unverified. */ unverified_rows: { a: number; b: number; }; /** `b.summary − a.summary`, field by field. */ summary_delta: GovernanceEvalSummary; /** Each run's summary equals its own results' tally AND the delta equals the flip/add/remove * accounting AND each run's coverage is well-formed and does not contradict itself (see * `coverage_reconciliation` for what it was checked against) — a stored record that disagrees * with ITSELF, or carries a `rule_coverage` that is not the wire shape, is a defect, not a flip. * Two valid records always reconcile; an exercised count ABOVE the fired ids is valid (warn-only * exercise), and so is a fired id outside a filtered run's denominator. */ reconciles: boolean; reconciliation_errors: string[]; /** Per side, how the record's `rule_coverage` was reconciled ({@link EvalCoverageReconciliation}; * {@link MALFORMED_RULE_COVERAGE} when the persisted value is not the wire shape), or `null` when * that side carries no coverage (a pre-#394 engine). */ coverage_reconciliation: { a: EvalCoverageReconciliation | null; b: EvalCoverageReconciliation | null; }; /** Present only when BOTH sides carry WELL-FORMED coverage AND `identity.type_filter.same`. */ rule_coverage_delta?: EvalRuleCoverageDelta; } /** The S17 rule table, as a function. Exported so a caller can classify a single flip. */ export declare function classifyFlip(kind: EvalSampleKind, from: EvalVerdict, to: EvalVerdict): { classification: FlipClass; reason: string; }; /** Compare two recorded runs — A is the baseline, B the candidate. See the module doc. */ export declare function compareEvalRuns(a: EvalRunDetail, b: EvalRunDetail): EvalRunComparison; //# sourceMappingURL=eval-compare.d.ts.map