Skip to content

feat(*): add trajectory regression scaffold and CI gate - #471

Open
forrestjgq wants to merge 12 commits into
mainfrom
feat/trajectory_regression_scaffold_ci
Open

forrestjgq wants to merge 12 commits into
mainfrom
feat/trajectory_regression_scaffold_ci

Conversation

@forrestjgq

Copy link
Copy Markdown
Contributor

Summary

Turn "bug fix to regression case" from a manual chore into a gated pipeline, and give trajectory regressions their own CI gate.

  • raven trajectory replay --json: stable machine-readable report (schema_version 1) with defensive JSON coercion; on a halted replay the full JSON is still emitted before exit code 2.
  • Case metadata contract: case.yaml requires non-blank issue/owner/why/re_record; residual-scan findings pass only with an explicit per-token human sign-off (reviewed_residuals: full-token sha256 plus a verbatim reason, nothing exempted automatically -- a machine-produced redaction report is not approval).
  • raven trajectory regression validate [CASE_DIR|--all]: static commit gate -- whole-directory discovery so a broken case cannot hide, both schemas, cassette completeness down to the replay contract (valid JSON is not a usable payload: missing payloads via the extracted replayability check, corrupt inner fields via validate_recording), rejection of files the residual scanner would silently skip, and a 256 KiB / 1 MiB size budget; every parse boundary collects problems instead of raising, so one malformed case cannot abort the --all sweep.
  • raven trajectory regression init <source> --name <case>: scaffold from a bundle directory, attempt id, trajectory report tarball, or bug report package (two-layer extraction; both passes accept only regular files/directories with root-contained names). Minimize lands in staging; residual findings are reviewed interactively (the full token is shown locally, files receive digest and reason); publication copies onto the destination filesystem under a .init- staging name, re-checks the case name, then renames on one device -- failures clean up only what the run created.
  • Vacuous-pass guard (MIN_COMMITTED_CASES, raised by hand) and a trajectory CI job: replay the committed cases, run validate --all even after a pytest failure so both gates report, upload per-case failure reports (replay --json schema plus check failures, mode from each case's expect.yaml) as a 14-day artifact.
  • Docs: the Trajectory Regression Case entry in CONTEXT.md extended in place; tests/trajectories/README.md documents the per-case layout and workflow.

Also: config_secrets_loaded (a field name of redaction.json itself, a guaranteed self-referential false positive in every cassette) joins the residual scanner's benign literals, and both sample cases gain real case.yaml metadata.

Type

  • Fix
  • Feature
  • Docs
  • CI / tooling
  • Refactor
  • Other

Verification

  • uv run pytest tests/ -q (after rebasing onto latest main): 23149 passed; the 37 failures and 20 errors are environment-bound and reproduced identically on a pristine origin/main worktree without these changes.

  • uv run pytest tests/test_trajectory_regressions.py tests/test_cli_trajectory_regression_commands.py tests/test_cli_trajectory_commands.py tests/test_trajectory_replay.py tests/test_trajectory_cassette.py tests/test_trajectory_redact.py -q: 282 passed (about 90 new tests across the five steps).

  • uv run ruff check ., uv run ruff format --check ., make lint-imports, make lint-deps, make check-large-files, make check-source-language: pass. make lint-types reports one diagnostic in raven/agent/tools/web.py, a file this branch does not touch, pre-existing on main.

  • CI workflow YAML parses (yaml.safe_load); the first real run of the new trajectory job happens on this PR (see this PR's checks).

  • CLI assertion width-independence re-verified with COLUMNS=60.

  • Relevant tests pass locally

  • Relevant lint / type checks pass locally

  • User-facing docs or screenshots are updated when needed

Risk

  • All additive: the default replay output path is unchanged; regression init/validate are new subcommands; the one scanner change adds a fixed benign literal (any variation still flags).

  • The new trajectory CI job becomes a required-passing gate for PRs; to lift it in an emergency, revert the CI commit (ci: add trajectory regression gate job and case-count guard) -- the remaining commits are independent.

  • Tar extraction accepts only regular files and directories with root-contained relative names on both layers, with filter="data" kept as defense in depth.

  • Security impact considered

  • Backward compatibility considered

  • Rollback path is clear for risky changes

Related Issues

N/A

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Blocking: the static validator lets malformed tool inputs disable the replay comparison.

I reviewed the full diff and covered the repository rules and canonical trajectory terms, the replay/cassette/CLI callers and branch history, backward compatibility, test-gate integrity, and the relevant architecture boundaries. The default replay path remains compatible and I found no evidence of weakened tests, but the inline false-green must be fixed before this gate can provide the contract it claims.

Verification: uv run pytest tests/test_trajectory_regressions.py tests/test_cli_trajectory_regression_commands.py tests/test_cli_trajectory_commands.py tests/test_trajectory_replay.py -q passed (244 tests); raven trajectory regression validate --all passed both committed cases; the source-language and large-file checks passed when run directly. I also reproduced the inline defect against a temporary copy of the committed tool-calling case.

Comment thread raven/trajectory/regression.py
@forrestjgq
forrestjgq force-pushed the feat/trajectory_regression_scaffold_ci branch from 2ad0738 to 4d33c9d Compare September 17, 2026 08:05

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Blocking: require recorded LLM input fields before accepting a case.

The original tool-input blocker is fixed and its thread is resolved. I reviewed the current full diff; range-diff confirms the eight previously reviewed commits are patch-identical, then I inspected the new contract check, tests, and formatting commit. I covered AGENTS/CLAUDE and canonical trajectory terms, the replay/cassette/CLI callers and history, backward compatibility, test-gate integrity, and the relevant architecture boundaries. No tests were weakened, but the parallel LLM-input false-green inline still prevents the static gate from enforcing its stated contract.

Verification: uv run pytest tests/test_trajectory_regressions.py tests/test_cli_trajectory_regression_commands.py tests/test_cli_trajectory_commands.py tests/test_trajectory_replay.py tests/test_trajectory_cassette.py tests/test_trajectory_redact.py -q passed (286 tests); raven trajectory regression validate --all passed both committed cases; source-language, large-file, and diff checks passed. I reproduced both missing-field variants against temporary copies of the committed tool-calling case.

Comment thread raven/trajectory/regression.py
forrestjgq1982 and others added 8 commits September 17, 2026 08:17
Add ReplayReport.to_dict() (schema_version 1) with a defensive JSON
coercion so dumps never raises on recorded values, and a --json/--out
pair on the replay command. JSON output suppresses the human summary,
is emitted in full even when the replay halts (exit code 2), and
embeds a best-effort scalar manifest summary. --out without --json is
validated by hand with exit code 1 because click usage errors exit 2,
which this command reserves for a halted replay.

Co-authored-by: Claude (claude-fable-5) <noreply@anthropic.com>
Add case.yaml (issue/owner/why/re_record required non-blank) parsed in
the load_expectation style, discover_case_dirs that lists every
subdirectory so a broken case cannot hide, and validate_case: schema
checks, cassette completeness down to the replay contract via
load_recording plus the extracted replayability_problems (valid JSON is
not a usable payload), rejection of files the residual scan would
silently skip, residual findings gated against the redaction.json
baseline reviewed at case creation (new tokens have no baseline sample
and fail), and a 256 KiB / 1 MiB size budget. Expose it as raven
trajectory regression validate (exit 1 on any problem, bad usage, or a
missing/empty cases root) and give both sample cases their case.yaml in
the same commit so the new gate does not break against them.

Co-authored-by: Claude (claude-fable-5) <noreply@anthropic.com>
…med cases

Replace the redaction.json auto-baseline with an explicit review list:
case.yaml gains reviewed_residuals entries (full-token sha256 plus a
required note) that go through git review, so a masked-sample collision
cannot ride along and a machine-written report never counts as human
approval; a listed digest literal itself is exempt since the 64-hex
entries are high-entropy tokens to the scanner. Distinguish a manifest
that parses to null from a parse failure so the object checks still
run, convert YAML syntax errors to file-naming ValueErrors at the parse
boundary, report non-mapping span attributes as a structural problem,
and run the semantic replay check only on structurally sound spans so
one malformed case cannot abort the --all sweep.

Co-authored-by: Claude (claude-fable-5) <noreply@anthropic.com>
…undaries

Collect instead of raise on three structural shapes that aborted the
--all sweep: an artifact payload parsing to a non-object (the loaders
index into it, so it is a structural problem that also blocks the
semantic pass), a non-string spanId (unhashable as the dedup key), and
non-string YAML mapping keys at every parse boundary (a mixed key set
crashed the unknown-key sort). CLI tests normalize whitespace before
substring assertions so Rich wrapping long tmp paths at narrow console
widths cannot split an asserted phrase.

Co-authored-by: Claude (claude-fable-5) <noreply@anthropic.com>
Scaffold a case directory from a bundle directory, attempt id,
trajectory report tarball, or bug report package (two-layer, both
passes accepting only regular files and directories with root-contained
names; filter=data stays as defense in depth). Minimize lands in a
staging area, residual findings gate on an explicit interactive review
showing the full token locally (files receive the sha256 digest and the
reviewer's verbatim reason; declining, an empty reason, or EOF cancels
the init), and --yes passes only a residual-free cassette. Publication
copies onto the destination filesystem under a .init- staging name,
re-checks the case name, then renames on one device; failures clean up
only what this run created. discover_case_dirs skips only the .init-
staging pattern. The redaction report's own config_secrets_loaded field
name joins the benign literals (it cleared the entropy bar in every
cassette), and the expect template avoids a hyphenated phrase that
flagged itself.

Co-authored-by: Claude (claude-fable-5) <noreply@anthropic.com>
Guard the committed-case set against vacuous passes: a hand-raised
minimum case count fails the suite when the cases root is missing,
empty, or shrinking, counted by the same expect.yaml discovery the
parametrization uses. A failed case serializes its replay report (the
replay --json schema, mode from the case's own expect.yaml) plus the
check failures into RAVEN_REGRESSION_REPORT_DIR, best-effort so a
serialization problem never masks the test failure. The new trajectory
CI job replays the committed cases, runs regression validate --all even
after a pytest failure so both gates report, and uploads the failure
reports as a 14-day artifact.

Co-authored-by: Claude (claude-fable-5) <noreply@anthropic.com>
Extend the Trajectory Regression Case entry in place with the case.yaml
contract (required metadata, per-token reviewed residual sign-offs), the
init/validate lifecycle, and the hand-raised committed-case floor the CI
trajectory job enforces, and add a README under tests/trajectories/
describing the per-case layout, the scaffold-to-validate workflow, and
the naming rules.

Co-authored-by: Claude (claude-fable-5) <noreply@anthropic.com>
…tract

replayability_problems only covers missing payloads, so a JSON object
with a corrupt inner field (tool_calls: [1]) passed the static gate,
halted the replay mid-run, and that crash-halt could even masquerade as
the expected divergence with both gates green. validate_case now also
collects validate_recording problems after loading the recording, and a
test asserts the case fails even when the expectation is written to
match the crash-halt divergence.

Co-authored-by: Claude (claude-fable-5) <noreply@anthropic.com>
@forrestjgq
forrestjgq force-pushed the feat/trajectory_regression_scaffold_ci branch from 4d33c9d to 291e0c3 Compare September 17, 2026 08:17

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

No blockers; this can merge as far as I am concerned.

The LLM-input contract fix rejects missing model, messages, and tools fields and a blank model, closing both zero-divergence reproductions. Range-diff confirms the previously reviewed series is patch-identical apart from the newer main base and this fix. I covered the repository rules and canonical trajectory terms, the full PR diff and new commit, replay/cassette/CLI callers and history, backward compatibility, test-gate integrity, and architecture boundaries; I found no weakened tests or remaining plain error. Both threads I opened are resolved.

Verification: the six relevant trajectory test files pass (290 tests); all committed cases pass raven trajectory regression validate --all; direct mutation checks reject each missing LLM field; source-language, large-file, and diff checks pass.

@forrestjgq
forrestjgq force-pushed the feat/trajectory_regression_scaffold_ci branch from 291e0c3 to 7401501 Compare September 17, 2026 08:25

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

No blockers; this can merge as far as I am concerned.

This head is tree-identical to the prior clean revision (7f4891308a80feb42e99cd04fab16a2bd81993d3). Range-diff confirms every code and test patch is unchanged; only the formatting commit type moved from style to the repository-approved chore. The prior review coverage therefore still applies: repository rules and canonical terms, full diff, callers and history, backward compatibility, test integrity, and architecture boundaries. Both threads I opened remain resolved.

Verification: exact tree equality against 291e0c339763; patch series identical apart from commit metadata; git diff --check github/main...HEAD passes. The unchanged tree previously passed the six relevant trajectory test files (290 tests), committed-case validation, source-language, and large-file gates.

forrestjgq and others added 3 commits September 17, 2026 08:40
…mpare

The tool feed skips the name comparison when the recorded name is None
and the whole argument comparison when params is None, and a non-string
name can only produce a synthetic tool-name divergence an expectation
could declare as expected -- either way a malformed cassette silently
disables the comparison the gate protects while both gates stay green.
validate_case now requires a non-empty string name and a mapping params
on every recorded tool call, with tests covering params: null under a
perfectly matching expectation and list/empty/int names.

Co-authored-by: Claude (claude-fable-5) <noreply@anthropic.com>
Co-authored-by: Claude (claude-fable-5) <noreply@anthropic.com>
…gate

compare_llm_request skips the model check when the recorded model is
absent, and a recording without a tools list meets the replay
registry's equally empty live tool surface, so a tampered cassette can
complete green while comparing nothing. The recorder always emits
model/messages/tools and minimize preserves exactly those keys, so
validate_case now requires model as a non-empty string and messages and
tools as lists on every recorded llm input, with tests deleting each
field and blanking the model.

Co-authored-by: Claude (claude-fable-5) <noreply@anthropic.com>
@forrestjgq
forrestjgq force-pushed the feat/trajectory_regression_scaffold_ci branch from 7401501 to 5a6d3d5 Compare September 17, 2026 08:40

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

No blockers; this can merge as far as I am concerned.

This revision is tree-identical to the prior clean head (7f4891308a80feb42e99cd04fab16a2bd81993d3). Range-diff shows only a commit-body punctuation correction from a non-ASCII em dash to ASCII --; every code and test patch is unchanged. That correction satisfies the repository's ASCII-only commit-message rule. The prior coverage of repository rules, canonical terms, full diff, callers/history, backward compatibility, test integrity, and architecture therefore remains applicable, and both threads I opened remain resolved.

Verification: exact tree equality against 7401501d36f9; patch series identical apart from commit metadata; git diff --check github/main...HEAD passes. The unchanged tree previously passed 290 relevant tests and all case/repository gates.

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

No blockers; this can merge as far as I am concerned.

This head merges the current target branch. Range comparison shows all 11 feature patches are unchanged from the previously reviewed revision, and the current PR diff remains limited to the same 15 trajectory/CI files, so the merge introduces no new feature behavior to reassess.

Coverage this round included the repository rules and canonical terms, the full PR diff and revision history, callers/contracts, backward compatibility, test integrity, and the prior architecture conclusions through the unchanged-patch comparison. Both previously opened findings remain resolved.

Verification: the four affected trajectory test modules pass (252 passed); raven trajectory regression validate --all reports 2 cases and 0 problems; the source-language gate, large-file gate, and git diff --check pass. An initial invocation used stale singular test filenames and collected no tests; it was replaced by the successful run against the actual files above.

@0xKT

0xKT commented Sep 17, 2026

Copy link
Copy Markdown
Member

**Not a blocker -- and the thing I most wanted to know came back right: this gate can fail, and it fails for the right reason. Three things stand, all of the same family: one rule with more than one reader.

The gate is not a gate that can only pass

I asked for this specifically, because a regression gate that never goes red is worse than no gate -- it buys confidence without paying for it. Two real regressions were built in production code and both were caught, with the replay naming the divergence:

replay halted: tool call #1: tool params -
  expected '{"note": "hi"}', got '{"injected_by_mutant": 1, "note": "hi"}'
replay halted: llm call #1: messages[1] -
  expected '...create the marker file", "role": "user"}', got '...create the marker file [M2-suffix]", "role": "user"}'

and a deliberately equivalent mutant (content.rstrip("."), on samples whose text ends in no period) correctly survived at "86 passed" -- the control that says the two kills were not a no-op edit passing for a catch. On the PR's own decision surface, 23 withdrawal mutants, 20 killed, and the three survivors each accounted for (one proven equivalent by a control that removes both escape checks and does go red; two byte-identical at base).

1. The .init-* rule is taught to one of three readers of the same directory

regression.py:418-421 introduces it, with a comment:

# ".init-*" is this feature's own publish-staging pattern (skipped so
# an interrupted init cannot read as a broken case); any other dot
# directory is still discovered - the gate stays whole-directory.
if path.is_dir() and not path.name.startswith(".init-")

The other two readers of that same root, both on CI paths this PR adds, do not know it:

# tests/test_trajectory_regressions.py:330  (ci.yml:261 parametrizes the replay over this)
return sorted(p for p in CASES_ROOT.iterdir() if (p / "expect.yaml").is_file())
# tests/test_trajectory_regressions.py:341  (the MIN_COMMITTED_CASES vacuity floor)
count = sum(1 for p in root.iterdir() if (p / "expect.yaml").is_file())

So a .init-* directory is replayed by ci.yml:261 and never validated by ci.yml:265 -- exempt from the residual scan, the schema checks, the cassette-completeness checks and the size budget, while the green pytest run makes it look covered. And it satisfies the floor that CONTEXT.md:1792-1794, added by this PR, says "keeps the suite from passing vacuously".

Measured, with the control the claim needs -- two byte-identical case directories carrying the same planted credential, differing only in name:

name validate --all
control_plain_name flags the planted token, 1 case, 1 problem, exit 1
.init-staged-abc123 (same bytes) not among the cases at all

and on the real tree, adding one .init-leftover-* copy: validate --all -> "2 case(s), 0 problem(s)" exit 0, while the pytest gate goes 86 -> 87 passed -- the extra parametrized case is the directory validate refuses to look at.

Not a blocker because the directory does not appear on its own: trajectory_regression_commands.py:234-237 removes it on every ordinary error path, so reaching this needs a SIGKILLed init plus a git add of the cases root. The single-owner fix is for the test file to call discover_case_dirs.

2. The PR writes "nothing is exempted automatically" and adds an automatic exemption in the same change

CONTEXT.md (added here): residual-scan findings carry "a full-token sha256 plus a reason; nothing is exempted automatically".

redact.py:143 (added here):

-_BENIGN_LITERALS = frozenset({"completion_tokens_details", "prompt_tokens_details"})
+_BENIGN_LITERALS = frozenset({"completion_tokens_details", "prompt_tokens_details", "config_secrets_loaded"})

A literal in that set never becomes a finding, so it never reaches the human review the sentence describes.

To be precise about what is new: the mechanism is not -- _BENIGN_LITERALS existed at base with two entries. What is new is the sentence claiming it does not exist, in the document a reviewer consults to decide how much the residual scan can be trusted. The exemption itself looks right (config_secrets_loaded is a redaction-report field name, and every cassette carries one); it is the description that needs the one-line correction.

3. Nit: the manifest summary is written twice, and the test asserts its own copy

_manifest_summary (trajectory_commands.py:573-586) builds the manifest block of replay --json; tests/test_trajectory_regressions.py:357-362 re-implements the same comprehension for the CI failure artifact, hardcoding the same key tuple and the same isinstance(..., (str, int)) filter. test_dump_failure_report_writes_the_artifact_schema then asserts against the test file's own version, so the production one could drift without anything noticing.

Two pre-existing gaps this PR makes load-bearing, recorded rather than filed

Neither is introduced here -- both lines are byte-identical at de3870a5 -- but this PR is what promotes check_report onto a CI gate for the first time, so they matter more now than they did:

  • regression.py:290: withdrawing the index half of the first-divergence match ((first.kind, first.index) -> (first.kind,)) leaves the suite fully green. Nothing asserts that an expectation naming llm call #1 is rejected when the replay first diverged at #3.
  • regression.py:256: withdrawing the tool name_equals failure arm leaves the suite green -- no test makes a name_equals check fail. The sibling params_equal is covered (its mutant dies), which is the control showing the suite does reach that function, so this is a selection gap rather than unreached code.

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.

4 participants