Skip to content

feat(e2e): run deterministic metamorphic pairs - #100

Merged
ahrav merged 6 commits into
mainfrom
stack/metamorphic-02-deterministic-runner
Aug 30, 2026
Merged

feat(e2e): run deterministic metamorphic pairs#100
ahrav merged 6 commits into
mainfrom
stack/metamorphic-02-deterministic-runner

Conversation

@ahrav

@ahrav ahrav commented Aug 30, 2026

Copy link
Copy Markdown
Owner

Summary

Metamorphic pairs can now run without model credentials and produce a deterministic, machine-readable report. This is the second layer of the four-PR evaluation stack.

Design

  • Every scenario, transform, and seed receives an explicit applied, inapplicable, rejected, or error outcome.
  • Baseline and derivative outputs use the same historian scorer and invariant comparator.
  • Reports retain coverage violations and sentinel hits, and map outcomes to stable exit codes.

Stack

  1. feat(e2e): add metamorphic invariant transforms #99: transforms, admission primitives, invariants, and sentinel.
  2. This PR: deterministic runner and report schema.
  3. feat(e2e): add live metamorphic runner #101: live control/product runner.
  4. feat(e2e): wire metamorphic evaluation CI #102: CLI and CI workflow integration.

Validation

  • 59 metamorphic tests pass at this layer.

Stack created with GitHub Stacks CLIGive Feedback 💬

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for security reviews. Please try again later.

@coderabbitai

coderabbitai Bot commented Aug 30, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 32 minutes.

View limit details

Limit details: You’ve used the included review currently available. Your 108 included PR review attempts over the past 7 days set your current allowance at 1 review per hour.

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 6df5c791-3f37-433e-9951-635a4d43d86b

📥 Commits

Reviewing files that changed from the base of the PR and between db7d584 and 4512726.

📒 Files selected for processing (8)
  • packages/e2e-tests/src/historian-eval/mutations.ts
  • packages/e2e-tests/src/historian-eval/scorer.ts
  • packages/e2e-tests/src/metamorphic-eval/invariants.ts
  • packages/e2e-tests/src/metamorphic-eval/metamorphic.test.ts
  • packages/e2e-tests/src/metamorphic-eval/pairs.ts
  • packages/e2e-tests/src/metamorphic-eval/report.ts
  • packages/e2e-tests/src/metamorphic-eval/runner.ts
  • packages/e2e-tests/src/metamorphic-eval/transforms.ts

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 871f0b2213

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread packages/e2e-tests/src/historian-eval/scorer.ts Outdated
Comment thread packages/e2e-tests/src/metamorphic-eval/runner.ts Outdated
Comment thread packages/e2e-tests/src/metamorphic-eval/runner.ts Outdated
Comment thread packages/e2e-tests/src/metamorphic-eval/runner.ts Outdated
Comment thread packages/e2e-tests/src/metamorphic-eval/pairs.ts
Comment thread packages/e2e-tests/src/metamorphic-eval/runner.ts Outdated
Comment thread packages/e2e-tests/src/metamorphic-eval/report.ts
Comment thread packages/e2e-tests/src/metamorphic-eval/runner.ts Outdated
@kilo-code-bot

kilo-code-bot Bot commented Aug 30, 2026

Copy link
Copy Markdown

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Files Reviewed (9 files)
  • packages/e2e-tests/scripts/run-test-selection.ts
  • packages/e2e-tests/src/historian-eval/mutations.ts
  • packages/e2e-tests/src/historian-eval/scorer.ts
  • packages/e2e-tests/src/metamorphic-eval/invariants.ts
  • packages/e2e-tests/src/metamorphic-eval/metamorphic.test.ts
  • packages/e2e-tests/src/metamorphic-eval/pairs.ts
  • packages/e2e-tests/src/metamorphic-eval/report.ts
  • packages/e2e-tests/src/metamorphic-eval/runner.ts
  • packages/e2e-tests/src/metamorphic-eval/transforms.ts
Previous Review Summaries (4 snapshots, latest commit eb4549b)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit eb4549b)

Status: No Issues Found | Recommendation: Merge

Files Reviewed (8 files)
  • packages/e2e-tests/scripts/run-test-selection.ts
  • packages/e2e-tests/src/historian-eval/mutations.ts
  • packages/e2e-tests/src/historian-eval/scorer.ts
  • packages/e2e-tests/src/metamorphic-eval/invariants.ts
  • packages/e2e-tests/src/metamorphic-eval/metamorphic.test.ts
  • packages/e2e-tests/src/metamorphic-eval/pairs.ts
  • packages/e2e-tests/src/metamorphic-eval/report.ts
  • packages/e2e-tests/src/metamorphic-eval/runner.ts

Previous review (commit 1accf60)

Status: No Issues Found | Recommendation: Merge

Files Reviewed (8 files)
  • packages/e2e-tests/scripts/run-test-selection.ts
  • packages/e2e-tests/src/historian-eval/mutations.ts
  • packages/e2e-tests/src/historian-eval/scorer.ts
  • packages/e2e-tests/src/metamorphic-eval/invariants.ts
  • packages/e2e-tests/src/metamorphic-eval/metamorphic.test.ts
  • packages/e2e-tests/src/metamorphic-eval/pairs.ts
  • packages/e2e-tests/src/metamorphic-eval/report.ts
  • packages/e2e-tests/src/metamorphic-eval/runner.ts

Previous review (commit 9e73636)

Status: No Issues Found | Recommendation: Merge

Files Reviewed (8 files)
  • packages/e2e-tests/scripts/run-test-selection.ts
  • packages/e2e-tests/src/historian-eval/mutations.ts
  • packages/e2e-tests/src/historian-eval/scorer.ts
  • packages/e2e-tests/src/metamorphic-eval/invariants.ts
  • packages/e2e-tests/src/metamorphic-eval/metamorphic.test.ts
  • packages/e2e-tests/src/metamorphic-eval/pairs.ts
  • packages/e2e-tests/src/metamorphic-eval/report.ts
  • packages/e2e-tests/src/metamorphic-eval/runner.ts

Previous review (commit 1bdc20e)

Status: 2 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 2
SUGGESTION 0
Issue Details (click to expand)

WARNING

File Line Issue
packages/e2e-tests/src/metamorphic-eval/report.ts 112 metamorphicExitCode returns success on an empty report
packages/e2e-tests/src/metamorphic-eval/runner.ts 147 Coverage applied count increments on rejected pair admissions
Files Reviewed (6 files)
  • packages/e2e-tests/src/historian-eval/mutations.ts
  • packages/e2e-tests/src/historian-eval/scorer.ts
  • packages/e2e-tests/src/metamorphic-eval/metamorphic.test.ts
  • packages/e2e-tests/src/metamorphic-eval/pairs.ts
  • packages/e2e-tests/src/metamorphic-eval/report.ts - 1 issue
  • packages/e2e-tests/src/metamorphic-eval/runner.ts - 1 issue

Fix these issues in Kilo Cloud


Reviewed by gemini-3.7-flash · Input: 135.7K · Output: 11.8K · Cached: 800.8K

ahrav added a commit that referenced this pull request Aug 30, 2026
- preserve canary evidence from partial valid output
- validate seeded baselines, turn maps, and score invariants
- prevent vacuous coverage and empty-report success

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9e7363685d

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

},
},
});
const importances = shuffledImportances(compartments.length, seed);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Allow more than 100 scripted compartments

Fresh evidence after the earlier compartment-count review is that this fix now passes the generated compartment count into shuffledImportances, which rejects counts above 100. A lint-green scenario may have 51–100 turns and gold.compartments.minCount between 101 and turns.length * 2, so baseline construction still throws supports at most 100 transcript turns rather than scoring the pair. Permit repeated importance values after exhausting the 1–100 range, or otherwise decouple this limit from the legal compartment count.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Confirmed and fixed in eb4549b.

Two bounds were conflated. baselineCompartments yields max(1, min(minCount, turns * 2)), and buildScriptedOutput raises minCount to at least turns, so the compartment count is bounded by transcript capacity — turns * 2. That is also the only bound lintScenario enforces (contract.ts:1313, exceeds-message-capacity). 100 is unrelated: it is the size of the importance value range. So your case is reachable exactly as described — 60 turns with minCount: 110 is contract-legal and threw instead of scoring.

Repeating values is safe, which I checked before choosing it over raising the cap: importance appears nowhere in invariants.ts or scorer.ts, so no invariant or oracle reads it. The shuffle only needs baseline text to vary across seeds, not to be unique. shuffledImportances now indexes the same shuffled permutation with index % MAX_DISTINCT_IMPORTANCE_VALUES, so values stay within 1-100 and counts past 100 wrap.

Two tests cover it: distinct values up to 100 then repeats (100 distinct across 150 compartments, all within 1-100), and a 60-turn scenario with minCount: 110 that lint does not fault on the compartment count and that now builds 110 compartments. The old test asserting the throw is gone with the throw.

For the record the shipped corpus is nowhere near this — max effective count is 4 across all 13 dev scenarios — so this was a latent trap for future scenarios rather than a live failure.

role: "baseline",
transformId: null,
transformVersion: null,
seed: null,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Retain the seed on baseline canary hits

When options.seeds contains multiple values and only one seed-dependent baseline promotes the canary, the .some(...) check collapses all baseline executions into this single hit with seed: null. The report therefore cannot identify or reproduce the offending seed, even though baselines are now independently built per seed; record a hit for each matching seeded baseline and retain its seed.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Confirmed and fixed in eb4549b.

The .some(...) reduced every seeded baseline to one boolean and then pushed a single hit with seed: null, so the report could name neither the offending seed nor how many seeds promoted the canary — while baselines is keyed by seed and holds exactly that information. Replaced with a loop over baselines entries that records one hit per matching seed and carries the seed. Iteration follows seeds insertion order, and buildMetamorphicReport sorts hits, so the report stays deterministic.

That leaves canaryHit reachable only from the derivative path, so its role === "baseline" branches were dead code returning the same seed: null this fix removes. I narrowed the helper to derivatives rather than leaving that shape sitting next to the fix.

Contract change worth flagging: a baseline hit now carries a real seed instead of null, so records a baseline canary without derivative coordinates was updated to expect DETERMINISTIC_SEEDS[0]. Added names the offending seed when one seeded baseline promotes the canary — three seeds, canary promoted only under seed 22 — which fails on the old code with seed: null.

ahrav added 3 commits August 30, 2026 08:30
- preserve canary evidence from partial valid output
- validate seeded baselines, turn maps, and score invariants
- prevent vacuous coverage and empty-report success
@ahrav
ahrav force-pushed the stack/metamorphic-02-deterministic-runner branch from 9e73636 to 1accf60 Compare August 30, 2026 08:31
The scripted metamorphic baseline drew one importance per compartment from
a shuffled 1-100 permutation and threw once the compartment count passed
100. Two bounds were conflated: the compartment count is bounded by
transcript capacity (turns * 2, the only bound the scenario contract
lints), while 100 is the size of the importance value range. A scenario
with 60 turns and a compartment minimum of 110 satisfies the contract, so
baseline construction threw instead of scoring the pair. Counts past the
range now wrap onto the same shuffled order. Importance is read by no
invariant and no scorer, so repeated values cost no discrimination; the
shuffle only needs to keep baseline text varying across seeds.

Baseline injection-canary detection also collapsed every seeded baseline
into one hit with a null seed, so a report could not name or reproduce the
seed that promoted the canary. Baselines are built per seed, so the hit is
now recorded per matching seed and carries it. That leaves canaryHit
reachable only for derivatives, so its dead baseline branches are gone.

Verified: 61 metamorphic tests and the full 293-test historian-eval-unit
lane pass, and the package typechecks.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: eb4549b448

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +51 to +55
const turnMapValid = derivative.turnMap.length === base.transcript.turns.length &&
new Set(derivative.turnMap).size === derivative.turnMap.length &&
derivative.turnMap.every(
(index) => Number.isInteger(index) && index >= 0 && index < derivative.scenario.transcript.turns.length,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Bind the turn map to derivative transcript provenance

Fresh evidence after the earlier turn-map fix is that this validation checks only that the map is a permutation and that the derivative gold is self-consistent with it; it never verifies that derivative.scenario.transcript[turnMap[i]] actually derives from baseline turn i. A transform that reorders turns but returns a different valid permutation can therefore be admitted whenever the wrongly mapped ranges still contain the relevant predicates, and the report then scores a pair under incorrect provenance. Validate the map against the transcript transformation, or extend the transform result with enough edit metadata to distinguish intentional text rewrites from misplaced turns.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 92ae4e8.

Worse than described, in fact. The gold check compares remapGold(base.gold, derivative.turnMap) against derivative.scenario.gold, and buildDerivative (transforms.ts:88) constructs that gold as remapGold(base.gold, turnMap) from the same two inputs — so for every transform built through it the comparison is a tautology, not merely a self-consistency check. It can only fail for a transform that hand-rolls its scenario, which is exactly what the existing broken-remap fixture does.

Fixed the way your second option suggests, since the first is not decidable: paraphrase-irrelevant and rename-unrelated-symbols legitimately rewrite message text, so derivative.turns[turnMap[i]] === base.turns[i] cannot be asserted for them. Transform now carries preservesTurnText, and admission verifies each mapped turn against its baseline turn for the transforms that declare it — reorder-independent-turns, move-accepted-decision, duplicate-rejected-proposal, all three of which copy turns by index without touching text. That is precisely the class where a "different valid permutation" can hide.

Stating the remaining limitation rather than implying it is closed: the two text-rewriting transforms declare false and their maps stay unbound to the transcript. Distinguishing an intentional rewrite from a misplaced turn there needs per-message edit metadata in TurnTransform, which is a larger contract change than this PR should carry. Adding the field to Transform made the compiler enumerate every implementation, including the three test fixtures, so a new transform cannot silently skip the decision.

New test rejects a text-preserving transform whose turn map misstates provenance swaps two turns while reporting the identity map. Verified discriminating: it fails with only the new branch disabled and passes with it restored.

Comment on lines +66 to +67
if (contentFingerprint === scenarioFingerprint(base)) {
diagnostics.push("derivative semantic fingerprint equals baseline");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject transforms that alter non-transform scenario semantics

This check treats any semantic fingerprint difference as sufficient, so a transform that leaves the intended transcript relation intact but accidentally changes probes, trigger, or other authored scenario fields is admitted as long as the resulting scenario still lints. For example, changing a probe question makes the fingerprint differ and passes admission, while raw-output scoring always emits an empty probeVerdicts array, allowing the pair to remain green even though it no longer evaluates the same scenario. Require all authored fields outside the transcript and the explicitly remapped gold ranges to remain equal to the baseline.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 92ae4e8, including the probe consequence.

The guard only fires when the fingerprint is equal, so it proves a transform did something, never that it did the right thing — and scenarioFingerprint (contract.ts:769) covers families, transcript, expectedHistorianRuns, gold, and probes, so a change to any one of them satisfies it. Your probe example checks out end to end: probes is inside the fingerprint, and scoreRawOutputWithInjectedClaims — the scorer the runner uses — hardcodes probeVerdicts: [] (scorer.ts:804, and errorScore at 846). So a mutated probe both satisfies admission and is invisible to scoring, leaving the pair green while evaluating a different scenario.

Implemented as you specified: authoredSemanticsOutsideTranscript canonicalizes the scenario with id, title, transcript, and gold removed, and admission requires equality with the baseline. id and title are excluded because buildDerivative deliberately derives them and the existing rejects a transform that changes only derived labels test depends on a title-only change still being caught by the fingerprint guard; transcript and gold are excluded because they are what a transform is licensed to change, with gold already pinned by the turn-map check above. Net effect is that any surviving fingerprint difference now has to come from the transcript or gold, which is what the guard was meant to mean.

All five shipped transforms pass unchanged, as expected — buildDerivative overrides only id, transcript, and gold, so probes, trigger, and families already passed through untouched. New test rejects a transform that mutates authored fields outside the transcript drifts one probe question and is verified discriminating against the check.

"src/cache-analysis.test.ts",
"src/metamorphic-eval/injection-canary.test.ts",
"src/metamorphic-eval/invariants.test.ts",
"src/metamorphic-eval/metamorphic.test.ts",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Include the runner suite in the unconditional eval gate

Checked the historian contract job in .github/workflows/ci.yml: it invokes test:historian-eval-unit, whose selector globs only src/historian-eval/**/*.test.ts, while this addition places the new deterministic runner suite only in standaloneUnitFiles. Consequently it runs through the mode-based host suites instead of the unconditional historian-eval-contracts job; those host jobs have unrelated prerequisites (for example, e2e-host-opencode depends on e2e-opencode), so a prerequisite failure can skip all of these new deterministic checks. Add this suite to the dedicated historian-eval unit selection or invoke a metamorphic selection directly from that no-needs contract job.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 92ae4e8. Best catch of the round — the gate this PR exists to add was not running in the job that cannot be skipped.

Reproduced before changing anything. historianEvalUnitFiles() returned exactly six files, all under src/historian-eval/, and zero metamorphic ones. That also explains a false signal in my own verification earlier in this PR: bun run test:historian-eval-unit reported 293 passing tests and I read that as covering the runner, while the 63 metamorphic tests were only ever running because I invoked bun test src/metamorphic-eval/ separately.

Took your first option. The selection now globs src/metamorphic-eval/**/*.test.ts alongside the historian-eval glob, so historian-eval-contracts — the job whose own comment explains it declares no needs so that a gate downstream of check-plugin cannot be silently skipped — picks the suite up with no workflow edit. That job went from 6 files and 293 tests to 10 files and 356.

Kept the four entries in standaloneUnitFiles rather than moving them. assertSrcTestsClassified unions its sources into a Set, so being claimed twice is not an error, and no single selection pulls both lists, so nothing runs twice in one invocation. Removing them would have dropped the suite from the --mode ts, rust, and pi lanes, which is a coverage loss the finding does not ask for. Verified assertSrcTestsClassified, validate-mode-manifest, and the test:validate-manifest suite all still pass.

…uite

Three findings from the codex round on eb4549b, all verified against the
code before changing it.

Pair admission never checked that the declared turn map described the
transcript it shipped with. It verified the map was a permutation, then
compared remapGold(base.gold, turnMap) against the derivative's gold —
which buildDerivative computes from those same two inputs, so the
comparison is a tautology for every transform built through it. A
transform that reordered turns one way and reported a different valid
permutation was admitted whenever the mismapped ranges still held the
relevant predicates, and the pair then scored under wrong provenance.
Transforms now declare preservesTurnText, and admission verifies
derivative.turns[turnMap[i]] against baseline turn i for those that do.
The declaration is required because a rewritten turn is indistinguishable
from a misplaced one; paraphrase-irrelevant and rename-unrelated-symbols
declare false and stay unbound.

The fingerprint guard only rejected a derivative identical to its
baseline, so it proved a transform did something, not that it did the
right thing. Every field in scenarioFingerprint satisfied it, including
probes — which raw-output scoring never evaluates, since
scoreRawOutputWithInjectedClaims always emits an empty probeVerdicts
array. A transform that only drifted a probe question was admitted and
stayed green. Authored fields outside the transcript, gold, and derived
labels must now equal the baseline.

The metamorphic suite also sat only in standaloneUnitFiles, so it was
selected by mode-based host jobs that declare needs (e2e-host-opencode
needs e2e-opencode) and never by historian-eval-contracts, the no-needs
job that exists precisely so a deterministic gate cannot be skipped by an
unrelated prerequisite failure. The historian-eval unit selection now
globs src/metamorphic-eval too, taking that job from 6 files and 293
tests to 10 files and 356.

Verified: each new test fails with only its own check disabled and passes
with it restored; the 356-test unconditional lane, the manifest
validation suite, assertSrcTestsClassified, and typecheck all pass.
Base automatically changed from stack/metamorphic-01-foundation to main August 30, 2026 21:30
GitHub retargeted this PR to main when #99 merged, so it conflicted against
main rather than the stacked base. Resolved by keeping #99's reviewed
foundation and layering #100's additions where independent.

Took main's test selection: it already globs src/metamorphic-eval into the
historian-eval unit selection, the same fix made here, and additionally
drops the metamorphic entries from standaloneUnitFiles. This file now has
no diff against main.

Combined `alwaysApplicable: false` with `preservesTurnText` in
transforms.ts; they constrain different things. Re-verified the three
`preservesTurnText: true` declarations against main's rewritten
transforms: `reorderedTurns` and `duplicatedTurns` still copy turns
verbatim, and duplicate's turn map shifts mapped positions past the
insertion.

Took main's invariants.ts, including `normalizeContent` over
`normalizeMemoryContent`, and re-added `compareScoreInvariants` for
runner.ts. Kept both `verdict-monotonicity` and
`scenario-verdict-equality`: equality also catches FAIL to PASS, while
monotonicity carries `introducedFailReasons`. The score-level invariant
assertion now expects all six.

Verified: typecheck, 131 metamorphic tests, the 431-test historian-eval
unit lane, the manifest suite, `assertSrcTestsClassified`, and
`validate-mode-manifest`.
@ahrav
ahrav merged commit 705df9d into main Aug 30, 2026
21 of 25 checks passed
@ahrav
ahrav deleted the stack/metamorphic-02-deterministic-runner branch August 30, 2026 21:49

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4512726ce1

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

})),
facts: expectedClaims.map((claim) => ({
category: claim.category,
content: normalizeMemoryContent(claim.predicate.value),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Encode entity-like predicates before building facts

When a lint-clean expected predicate literally contains &lt;, &gt;, &quot;, or &apos;, normalizeMemoryContent leaves that spelling unchanged while buildMockHistorianOutput rejects it as non-round-trippable. The scenario contract accepts these predicate strings when matching authored transcript text, so baseline construction throws and records baseline scoring failed instead of evaluating the pair. Fresh evidence beyond the earlier whitespace-normalization fix is that XML-entity spellings still require a round-trip-safe equivalent.

Useful? React with 👍 / 👎.

Comment on lines +56 to +60
const turnMapValid = derivative.turnMap.length === base.transcript.turns.length &&
new Set(derivative.turnMap).size === derivative.turnMap.length &&
derivative.turnMap.every(
(index) => Number.isInteger(index) && index >= 0 && index < derivative.scenario.transcript.turns.length,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Validate provenance for inserted derivative turns

Fresh evidence after the earlier turn-map fix is that this map is only baseline-to-derivative: when a text-preserving transform adds a turn, as duplicate-rejected-proposal legitimately does, every mapped baseline turn can match while the unmapped derivative turn is never examined. A faulty transform can therefore append arbitrary lint-clean evidence with an identity map; the remapped-gold and fingerprint checks pass, and the scripted scorer can report a green pair because it derives facts from gold rather than the appended transcript. Require insertion provenance or verify that every unmapped derivative turn is the declared duplicate.

Useful? React with 👍 / 👎.

ahrav added a commit that referenced this pull request Aug 30, 2026
The base moved to main after PR #100 merged. Only metamorphic.test.ts
conflicted, in the import block and the helper block, and both were resolved as
unions.

Main split this branch's "preserves distinct importances and rejects transcripts
beyond their range" into two tests and added a turn-map provenance check gated on
a new required Transform field. Main's split supersedes the single test, so this
merge keeps main's pair rather than reintroducing the original, and the three
local Transform fixtures now declare preservesTurnText: false for the
inapplicable fixtures and true for the identity fixture, which does preserve
turn text and therefore still lints red on its fingerprint alone.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant