Skip to content

test(gc-ratchet): fail open per cell and re-pin the baseline (#7554) - #7609

Merged
proggeramlug merged 4 commits into
mainfrom
fix/7554-gc-ratchet-repair
Aug 7, 2026
Merged

test(gc-ratchet): fail open per cell and re-pin the baseline (#7554)#7609
proggeramlug merged 4 commits into
mainfrom
fix/7554-gc-ratchet-repair

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Closes the #7554 repair: makes an artifact defect cost one cell instead of the
whole gate, re-pins the baseline on the pinned host at current main, and shows
the restored gate going red and green on demand.

The premise moved — what is actually broken

#7554 says the ratchet dies in preflight. That was true, and #7557 already
fixed it
. Preflight passes at main today (63 unit tests + validate, exit 0,
verified locally). The failure is now one step later and one week longer:

last green gc-ratchet on main 2026-08-01T05:39Z (run 30686263856)
since then 179 consecutive red main runs, 15 cancelled, 0 green
2026-08-05 → #7557 red in preflight — the probes never ran at all
#7557 → now red in check, against a 0.5.1280 artifact that no longer describes the collector

So the gate has produced no actionable verdict for a week, and because
gc-ratchet is not a required context (see below) none of it blocked
anything. #7594 and #7596 both substituted hand-run both-arms A/Bs.

1. Fail open per cell, fail closed on the verdict

Artifact validation aborted on the first defect, and it runs before the
measurement step. One cell — 12_large_live_set.heap_used_bytes, spread 6,768
bytes — therefore meant none of the twelve probes executed on any branch for
three days
. The claim that caused it was about one cell; nothing about it
voided the other 143 or made the probes unrunnable. That blast radius was never
chosen, it was inherited from raising an exception.

Defects now carry a scope:

scope examples voids
artifact wrong schema, missing metric, summary disagreeing with its own samples everything — still fatal, still preflight
probe pinned without an oracle diff, pinned with no collection that probe
cell spread ≠ 0 where the band's premise is bit-identity that cell

probe/cell defects demote their subject out of the gating family and are
reported as failures. check still measures all twelve probes, still evaluates
the rest of the matrix, and still names a regression elsewhere — while the defect
keeps the job red.

CI preflight now runs validate --scope structural, which fails only on the
fatal kind. assemble is deliberately unchanged: pinning still refuses any
defect, so this cannot be used to freeze a new unfit artifact.

Why this is not suppression. check re-derives the same defect list and
fails on every entry, and a test asserts that coupling per planted defect shape.
Sabotage evidence below.

2. Baseline re-pinned at main 26b9c9d (0.5.1346)

Measured on perry-macos (Mac mini M1, 8 GB) — the same host and the same
rustc/cargo/clang as the 2026-08-05 pin, so like-for-like. Load 2.38/1.9/1.7 at
capture (the previous pin was taken at 2.01/3.2/7.57). All 12 probes
oracle-pass; heap_used_bytes spread 0 on eleven probes and 864 B on
12_large_live_set. Full attribution is written into the artifact's own notes.

Cells that moved

(a) Explained — collection pacing (#7594, #7596).

probe metric baseline → current
03_cross_gen_writes copied_objects 13,893 → 8,212 (−40.9%)
03_cross_gen_writes copied_bytes 990,736 → 590,688 (−40.4%)
03_cross_gen_writes promoted_objects 4,752 → 0
03_cross_gen_writes promoted_bytes 210,736 → 0
04_dead_after_deep_stack copied_objects 11,268 → 565 (−95.0%)
04_dead_after_deep_stack copied_bytes 663,512 → 44,688 (−93.3%)
04_dead_after_deep_stack promoted_objects 4,752 → 10
04_dead_after_deep_stack promoted_bytes 210,744 → 440

Recorded rather than glossed: 03's promoted_* now pin at 0, where the
allowance floor (16 objects / 64 KiB) covers the whole range and the liveness
assertion fires only when the baseline median is > 0. That cell no longer carries
signal in either direction. It is the price of pinning a counter at zero.

(b) Explained — measurement, not retention (#7558, #7571).

probe metric baseline → current classify precise
02_survivor_promotion heap_used_bytes 9,418,232 → 9,678,792 (+2.77%) 9,416,632below the old baseline
05_closure_capture heap_used_bytes 6,378,392 → 7,426,960 (+16.44%) 5,329,880 — byte-identical to #7571

05's precise retention is the exact figure #7571 measured at both ends of
its 74-commit window; the conservative residue went 1 block → 2 (2,097,080 B)
while real retention did not move at all. 02's precise reading is lower than
the number this artifact previously recorded as that probe's retention, so real
retention cannot have grown. This is #7559's answer, reproduced independently on
the pinned host rather than assumed.

(c) FLAGGED — not explained by any merged, documented decision.

12_large_live_set.wall_ms 3,056 → 3,471 ms (+13.58%). Two non-overlapping
7-sample clusters (3,047–3,061 vs 3,466–3,476), same host, same toolchain, same
protocol — while 06_string_retention and 11_collect_at_depth got 9.6% and
28.4% faster over the same window. #7596 reported −7.4% on this very cell
in its own both-arms A/B, so by that PR's own evidence this is not #7596. Gated
under pinned_host only, so it does not block CI. Pinned here so the rest of the
matrix can gate again; it wants a bisect over 0.5.1280..0.5.1346.

Note on the diff size

baseline/gc-ratchet-v1.json is +684 / −3,057, but 2,374 of those deleted
lines are the suite block
(a benchmarks/compare.sh run recorded alongside
the previous pin), now null. The probe set is unchanged and every top-level key
is still present. compare.sh needs a full checkout and a Node/Bun toolchain;
this pin measured shipped binaries on the mini, so the suite was not run. The
driver supports exactly this via --no-suite, and the omission is recorded in the
artifact's notes rather than left for a reader to notice. Re-running it later is
a re-pin, not a patch.

(d) Did not reproduce. #7596's merge audit accepted
12_large_live_set.heap_total_bytes +36% (95.4 → 130.0 MB) and deferred the
re-pin to this repair. Under the harness protocol on this host that cell is
110,100,480 → 110,100,480, +0.00% — and neither endpoint of the +36% figure
matches this artifact's reading. Nothing was re-pinned on account of it.

3. Sabotage evidence (real exit codes)

Harness mechanism — two ways to break it, both caught:

sabotage result
revert evaluate to abort on any defect (the #7554 collapse) 6 tests ERROR
demote the cell but drop the defect from failures (fail-open → suppression) 5 tests FAIL
restored 71 tests OK

Preflight scoping, on an artifact carrying a planted 6,768-byte spread:

arm exit
validate --scope all 1
validate --scope structural 0 (deferred, printed loudly)
check — 144 rows still evaluated, unfit cell demoted, and an unrelated planted freed_bytes −50% regression still named 1
tampered summary at --scope structural (integrity, not fitness) 2 — still fatal

That third row is the whole point: under the old behaviour that freed_bytes
regression was invisible, because nothing ran.

The re-pinned gate itself, end to end on the pinned host — sabotaging the
collector, not the JSON:

arm cells moved check exit
clean, pinned_host 0
clean, shared_ci 0
PERRY_GEN_GC=0 (non-generational) 79 1, 90 findings, liveness assertions fire by name

⚠️ A vacuous sabotage, reported rather than hidden. My first attempt was
PERRY_GEN_GC_EVACUATE=0, which moved 0 cells across all 12 probes and left
the gate green — measured, not inferred: a cell-by-cell diff of the two
measurements reports cells that moved: 0. So the knob changes nothing this
ratchet observes, and "passes with evacuation policy disabled" has never been
evidence about this suite. (Plausibly because it gates the C4b policy
evacuation of tenured objects rather than the copying minor that copied_objects
counts — but that attribution is a reading of the code, not something these runs
establish.) That is the
#6942/#7024 pattern (a knob that disables something other than what it is
believed to disable) and it is worth its own look; per CLAUDE.md's kill-policy an
off-state nothing exercises should not survive. It also means "passes with
evacuation policy disabled" has never been evidence about this suite.

4. CI workflow audit — the four hazards

hazard status
1. continue-on-error clean — none on any gating step; if: always() only on artifact upload
2. required context BROKEN — required contexts are lint, cargo-test, parity, compile-smoke, api-docs-drift, security-audit, conformance-smoke-complete. gc-ratchet is absent. 179 red main runs blocked nothing.
3. concurrency cancellation clean — per-commit group for push (#7205); main runs queue and complete
4. subject liveness cleancheck asserts minor_cycles > 0 and copied_objects > 0 rather than inferring from bands, and the PERRY_GEN_GC=0 arm above proves it fires. Caveat: 03.promoted_* pinned at 0 is a cell that can no longer fail (called out in §2a).

Hazard 2 needs admin action and I have not attempted it. Promoting
gc-ratchet to a required context is an admin-only branch-protection change, and
CLAUDE.md's corollary applies: promote after a green main run, not before, or
every open PR is blocked. This PR is the prerequisite — it is what makes a green
main run possible for the first time since 2026-08-01.

Validation

CI has a deep runner backlog (main push runs from 22:07Z onward are still
queued), so the evidence here is local and I am saying so plainly:

  • python3 -m unittest discover -s tests -p 'test_gc_ratchet.py'71 passed
    (63 pre-existing + 8 new), plus both sabotages above
  • gc_ratchet.py validate --scope all on the new artifact — exit 0
  • gc_ratchet.py check on the pinned host, both profiles — exit 0
  • raw_handle_debt.py 998 = baseline · addr_class_inventory.py pass ·
    class_id_collisions.py pass · check_file_size.sh pass ·
    cargo fmt --all -- --check clean
  • No Rust changed, so cargo test -p perry-runtime is not implicated; the
    measured collector is exactly origin/main 26b9c9d59

Follow-ups (not in this PR)

  1. Admin: promote gc-ratchet to a required context after its first green
    main run.
  2. 12_large_live_set.wall_ms +13.58% — filed as gc: 12_large_live_set is 13.6% slower at 0.5.1346 than 0.5.1280 with every collector counter flat #7610, bisect 0.5.1280..0.5.1346.
  3. PERRY_GEN_GC_EVACUATE=0 is inert on this suite — filed as gc: PERRY_GEN_GC_EVACUATE=0 moves zero cells on all 12 gc-ratchet probes — the knob's off-state is unexercised #7611;
    delete the off-state or give it an arm that exercises it (CLAUDE.md kill-policy).
  4. gc(): the forced conservative stack scan makes every retained-heap reading nondeterministic, and 16% too large on a 50 MB live set #7558's real fix: heap_used_bytes has a 1 MiB noise quantum and a
    127 KiB band on some probes, so re-pinning only resets the clock. test(gc-ratchet): classify a retention breach instead of guessing at it (#7559) #7571's own
    conclusion is that the fix is to measure a different quantity — gate the
    classify precise reading instead of the conservative one. That is a
    metric-family redesign and a maintainer call, deliberately not smuggled in
    here.

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@proggeramlug, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 9 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: aaa09217-c3a5-4ea7-94e1-dcfb9232bd42

📥 Commits

Reviewing files that changed from the base of the PR and between 2c79e8a and 8a04f53.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (3)
  • CLAUDE.md
  • Cargo.toml
  • changelog.d/7609-gc-ratchet-fail-open-and-repin.md
📝 Walkthrough

Walkthrough

The GC ratchet now collects artifact, probe, and cell defects. Structural validation can defer non-fatal defects until measurement and final checking. Evaluation demotes affected rows while continuing unaffected measurements. Strict validation remains the default.

Changes

GC ratchet validation flow

Layer / File(s) Summary
Artifact defect inspection and strict validation
benchmarks/gc_ratchet/gc_ratchet.py, tests/test_gc_ratchet.py
Introduces immutable ArtifactDefect records and inspect_artifact. Strict validation still rejects defective artifacts during pinning.
Deferred defect evaluation
benchmarks/gc_ratchet/gc_ratchet.py, tests/test_gc_ratchet.py
Evaluation retains non-fatal defects, demotes affected probes and cells, reports failures, and continues measuring unaffected rows.
Scoped validation workflow and documentation
.github/workflows/gc-ratchet.yml, benchmarks/gc_ratchet/README.md, benchmarks/gc_ratchet/gc_ratchet.py
Adds `validate --scope all

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CI
  participant main
  participant inspect_artifact
  participant evaluate
  participant Report
  CI->>main: validate --scope structural
  main->>inspect_artifact: inspect artifact
  inspect_artifact-->>main: return scoped defects
  main->>evaluate: run measurements
  evaluate->>Report: record failures and unfit rows
  CI->>main: run check
  main-->>CI: final ratchet verdict
Loading

Possibly related issues

Suggested labels: tooling

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the primary change: per-cell fail-open handling and baseline re-pinning for the GC ratchet.
Description check ✅ Passed The description thoroughly covers the repair, implementation changes, baseline re-pin, validation results, evidence, and follow-up issues.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/7554-gc-ratchet-repair

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@coderabbitai coderabbitai 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.

Actionable comments posted: 4

🧹 Nitpick comments (6)
tests/test_gc_ratchet.py (1)

908-920: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the negative half of "only that probe".

The test asserts that 01_probe rows are non-gating and that some 02_other failure exists. It does not assert that 02_other rows stayed gating, and it does not assert that the 02_other failure is the planted regression rather than any other message. Both additions keep the test honest if the demotion set is ever computed too widely.

💚 Proposed strengthening
         rows, failures = evaluate(baseline, current, profile="shared_ci")
         for row in rows:
             if row.probe == "01_probe":
                 self.assertFalse(row.gating, f"{row.metric} on an unfit probe must be demoted")
+        self.assertTrue(
+            any(row.gating for row in rows if row.probe == "02_other"),
+            "the fit probe must keep its gating cells",
+        )
         self.assertTrue(
-            any("02_other" in failure for failure in _hard(failures)),
+            any("02_other: copied_objects" in failure for failure in _hard(failures)),
             "the fit probe must still be able to fail the job",
         )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_gc_ratchet.py` around lines 908 - 920, Strengthen
test_an_unfit_probe_demotes_only_that_probe by asserting that rows for the fit
probe 02_other remain gating, and verify the hard failure specifically
identifies the planted copied_objects regression rather than merely containing
the probe name. Keep the existing assertions that 01_probe is demoted and that
the fit probe can fail the job.
benchmarks/gc_ratchet/gc_ratchet.py (5)

1490-1502: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The heading names cells, but the list also contains probe-scoped defects.

unfit holds every non-fatal defect, which includes probe scope. A probe pinned without an oracle diff appears under "Pinned cells that could not be gated". Name both scopes in the heading.

Separately, render re-derives the defect list that evaluate already computed for the same run. Passing the list from evaluate into render would remove the implicit coupling, at the cost of a signature change that tests/test_gc_ratchet.py Line 548 also uses.

♻️ Proposed heading fix
-            "### Pinned cells that could not be gated (baseline defects)",
+            "### Pinned probes and cells that could not be gated (baseline defects)",
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@benchmarks/gc_ratchet/gc_ratchet.py` around lines 1490 - 1502, Update the
baseline-defect heading in render to mention both pinned cells and probes, since
unfit includes non-fatal defects from either scope. Also pass the defect list
already computed by evaluate into render, updating the render signature and its
call site in tests/test_gc_ratchet.py, instead of re-deriving defects for the
same run.

1269-1271: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use describe() so pin-time errors name the defective cell.

message for a cell defect does not contain the probe or metric name; the location lives only in defect.where. assemble reports this exception to a maintainer, so the current text reads spread 6768 when pinned, ... with no indication of which of the 144 cells failed. cmd_validate already prints describe() for the same defects, so this is also an inconsistency between the two strict paths.

♻️ Proposed change
     defects = inspect_artifact(artifact)
     if defects:
-        raise RatchetError("; ".join(defect.message for defect in defects))
+        raise RatchetError("; ".join(defect.describe() for defect in defects))

tests/test_gc_ratchet.py::test_pinning_an_unfit_artifact_is_still_refused asserts on "bit-identity", which describe() still contains.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@benchmarks/gc_ratchet/gc_ratchet.py` around lines 1269 - 1271, Update the
defect aggregation in the artifact pinning path to use each defect’s describe()
output instead of defect.message when raising RatchetError. Preserve the
existing semicolon-separated formatting so errors identify the defective cell’s
probe or metric, matching cmd_validate behavior.

1678-1691: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Fix the "unfit cell(s)" wording and the trailing success line.

Two accuracy problems in the operator-facing output:

  1. unfit contains both cell- and probe-scoped defects, so "unfit cell(s)" mislabels a probe pinned without an oracle diff. Lines 1680 and 1687 both use it.
  2. Under --scope structural with deferred defects, Line 1691 prints is structurally valid directly after the deferral warning. The two lines read as contradictory in a CI log.
♻️ Proposed wording fix
     if unfit and args.scope == "all":
         print(
-            f"gc-ratchet: {args.artifact} has {len(unfit)} unfit cell(s); "
+            f"gc-ratchet: {args.artifact} has {len(unfit)} unfit probe(s)/cell(s); "
             "re-pin them or record a probe_overrides entry",
             file=sys.stderr,
         )
         return 1
     if unfit:
         print(
-            f"gc-ratchet: {len(unfit)} unfit cell(s) deferred to `check` "
+            f"gc-ratchet: {len(unfit)} unfit probe(s)/cell(s) deferred to `check` "
             "(--scope structural); they will fail the job there, after the probes run",
             file=sys.stderr,
         )
-    print(f"gc-ratchet: {args.artifact} is structurally valid")
+        print(f"gc-ratchet: {args.artifact} is structurally valid; `check` will fail on the deferred defects")
+    else:
+        print(f"gc-ratchet: {args.artifact} is structurally valid")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@benchmarks/gc_ratchet/gc_ratchet.py` around lines 1678 - 1691, Update the
operator-facing messages in the unfit handling flow to refer to generic unfit
item(s) rather than unfit cell(s), covering both the all-scope error and
structural-scope deferral warning. Suppress or adjust the final success message
when unfit defects are deferred under structural scope so it does not claim the
artifact is structurally valid; retain the success line for genuinely valid
results.

1429-1434: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Distinguish the two quarantined statuses by words, not by case.

UNFIT (pinned cell unusable) and unfit (pinned cell unusable) differ only in letter case. In a 144-row table, that is the only signal that a demoted cell also breached its band. A distinct wording makes the breach visible.

♻️ Optional wording change
             if breach and quarantined:
-                status = "UNFIT (pinned cell unusable)"
+                status = "UNFIT + breached (pinned cell unusable)"
             elif breach:
                 status = "REGRESSION" if tolerance.gating else "drift (informational)"
             elif quarantined:
-                status = "unfit (pinned cell unusable)"
+                status = "unfit (pinned cell unusable)"

If you change the first label, keep the substring unfit out of it or update tests/test_gc_ratchet.py Line 888, which asserts assertIn("unfit", row.status).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@benchmarks/gc_ratchet/gc_ratchet.py` around lines 1429 - 1434, Update the
status values in the breach/quarantined branching near status assignment so the
case where both breach and quarantined are true uses wording distinct from the
quarantined-only status by more than capitalization. Preserve the existing
status semantics and ensure the revised breach-and-quarantined label does not
contain the substring “unfit” unless the related test expectation is updated.

196-202: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider binding scope to SCOPES.

scope is typed as str, and SCOPES is declared but never used for validation or typing. A typo such as "cells" would produce a silently non-fatal defect with where == "artifact". All current constructors are internal, so this is only a robustness gap.

♻️ Optional: type the field against the declared scopes
-    scope: str
+    scope: Literal["cell", "probe", "artifact"]
     message: str
     probe: str | None = None
     metric: str | None = None

This requires from typing import Literal in the import block.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@benchmarks/gc_ratchet/gc_ratchet.py` around lines 196 - 202, Bind the scope
field in the relevant configuration or result class to the declared SCOPES
values, preferably by typing it with a Literal derived from "cell", "probe", and
"artifact" and importing Literal as needed. Ensure invalid scope values cannot
silently fall through to the artifact behavior, while preserving the existing
SCOPES ordering.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/gc-ratchet.yml:
- Around line 91-101: Add the gc-ratchet workflow’s check context to the
repository’s default-branch required status checks alongside the workflow
change, ensuring the deferred defect verdict can block merges. Update the
branch-protection configuration or automation that defines required contexts,
using the existing gc-ratchet status-check name and preserving the gate’s
non-canceling, non-continue-on-error behavior.

In `@benchmarks/gc_ratchet/gc_ratchet.py`:
- Around line 1678-1691: Update operator-facing labels in
benchmarks/gc_ratchet/gc_ratchet.py: in the messages near lines 1678-1691,
replace “unfit cell(s)” with “unfit probe(s)/cell(s)” in both the --scope all
and deferral paths; also update the report heading near lines 1490-1502 from
“Pinned cells that could not be gated” to name both probes and cells.
- Around line 1186-1212: Update inspect_artifact’s probe and metric validation
to require each probe entry and each metric entry to be a Mapping before calling
get or accessing its contents. Report invalid entries through artifact_defect,
mark the artifact unreadable, and continue classification so malformed artifacts
produce the existing fatal artifact result and RatchetError CLI handling instead
of raising AttributeError.

In `@benchmarks/gc_ratchet/README.md`:
- Around line 291-307: The README’s new artifact-validation section conflicts
with existing incident details and implementation behavior. Update the outage
duration to match the two-day account, revise the bit-identity rule reference
from validate_artifact to inspect_artifact, and clarify the cell scope row to
state that defects apply only when gated_anywhere(...) is true, excluding cells
disabled by probe_overrides.

---

Nitpick comments:
In `@benchmarks/gc_ratchet/gc_ratchet.py`:
- Around line 1490-1502: Update the baseline-defect heading in render to mention
both pinned cells and probes, since unfit includes non-fatal defects from either
scope. Also pass the defect list already computed by evaluate into render,
updating the render signature and its call site in tests/test_gc_ratchet.py,
instead of re-deriving defects for the same run.
- Around line 1269-1271: Update the defect aggregation in the artifact pinning
path to use each defect’s describe() output instead of defect.message when
raising RatchetError. Preserve the existing semicolon-separated formatting so
errors identify the defective cell’s probe or metric, matching cmd_validate
behavior.
- Around line 1678-1691: Update the operator-facing messages in the unfit
handling flow to refer to generic unfit item(s) rather than unfit cell(s),
covering both the all-scope error and structural-scope deferral warning.
Suppress or adjust the final success message when unfit defects are deferred
under structural scope so it does not claim the artifact is structurally valid;
retain the success line for genuinely valid results.
- Around line 1429-1434: Update the status values in the breach/quarantined
branching near status assignment so the case where both breach and quarantined
are true uses wording distinct from the quarantined-only status by more than
capitalization. Preserve the existing status semantics and ensure the revised
breach-and-quarantined label does not contain the substring “unfit” unless the
related test expectation is updated.
- Around line 196-202: Bind the scope field in the relevant configuration or
result class to the declared SCOPES values, preferably by typing it with a
Literal derived from "cell", "probe", and "artifact" and importing Literal as
needed. Ensure invalid scope values cannot silently fall through to the artifact
behavior, while preserving the existing SCOPES ordering.

In `@tests/test_gc_ratchet.py`:
- Around line 908-920: Strengthen test_an_unfit_probe_demotes_only_that_probe by
asserting that rows for the fit probe 02_other remain gating, and verify the
hard failure specifically identifies the planted copied_objects regression
rather than merely containing the probe name. Keep the existing assertions that
01_probe is demoted and that the fit probe can fail the job.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: cca1b93e-fdc6-4bc7-a0af-816e11720ee2

📥 Commits

Reviewing files that changed from the base of the PR and between a211da7 and 2c79e8a.

📒 Files selected for processing (5)
  • .github/workflows/gc-ratchet.yml
  • benchmarks/gc_ratchet/README.md
  • benchmarks/gc_ratchet/baseline/gc-ratchet-v1.json
  • benchmarks/gc_ratchet/gc_ratchet.py
  • tests/test_gc_ratchet.py

Comment on lines +91 to +101
# Under `structural` a per-cell defect is printed loudly and deferred; the
# `check` step below re-derives the same defect list and fails on it, after
# the probes have run and with the full table attached. So this cannot
# wave a defect through to a green job — `check` is where the verdict is,
# and tests/test_gc_ratchet.py's
# `test_structural_preflight_defers_every_defect_it_waves_through` asserts
# that coupling one planted defect shape at a time.
- name: Harness unit tests and artifact validation
run: |
python3 -m unittest discover -s tests -p 'test_gc_ratchet.py' -v
python3 benchmarks/gc_ratchet/gc_ratchet.py validate
python3 benchmarks/gc_ratchet/gc_ratchet.py validate --scope structural

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Make gc-ratchet a required context before this deferral lands.

This change moves the verdict for per-cell defects from the preflight step to the check step, and the comment on Lines 91-97 states that check is where the verdict is. The PR objectives list "making gc-ratchet a required context" as a follow-up, so the job is not required today. A deferred defect therefore produces a red check step in a job that cannot block a merge. That converts the deferral into effective suppression for merge purposes, which is the outcome the comment says the design prevents.

Add gc-ratchet to the branch-protection required contexts in the same change, not as a follow-up.

Run the following script to confirm the current protection settings:

#!/bin/bash
# Description: List required status checks on the default branch.
branch="$(gh repo view --json defaultBranchRef --jq '.defaultBranchRef.name')"
repo="$(gh repo view --json nameWithOwner --jq '.nameWithOwner')"
gh api "repos/$repo/branches/$branch/protection/required_status_checks" --jq '.contexts, .checks' 2>&1 || \
  echo "no required_status_checks configured or insufficient token scope"

As per coding guidelines: "A CI gate must not use continue-on-error, must be included in required branch-protection contexts, must avoid unconditional cancellation of main runs, and must assert that the behavior it measures actually executed."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/gc-ratchet.yml around lines 91 - 101, Add the gc-ratchet
workflow’s check context to the repository’s default-branch required status
checks alongside the workflow change, ensuring the deferred defect verdict can
block merges. Update the branch-protection configuration or automation that
defines required contexts, using the existing gc-ratchet status-check name and
preserving the gate’s non-canceling, non-continue-on-error behavior.

Source: Coding guidelines

Comment on lines 1186 to +1212
for name, entry in probes.items():
metrics = entry.get("metrics")
if not isinstance(metrics, Mapping):
raise RatchetError(f"{name}: no metrics recorded")
artifact_defect(f"{name}: no metrics recorded")
continue

# Integrity of the recorded numbers. A missing metric or a summary that
# disagrees with its own samples is tampering or corruption, not
# unfitness: it stays fatal, because a partially-trusted artifact is not
# a thing this gate should ever compare against.
unreadable = False
for metric in ALL_METRICS:
if metric not in metrics:
raise RatchetError(f"{name}: baseline is missing {metric}")
artifact_defect(f"{name}: baseline is missing {metric}")
unreadable = True
continue
recorded = metrics[metric]
samples = recorded.get("samples")
if not isinstance(samples, list) or len(samples) < 2:
raise RatchetError(f"{name}: {metric} has too few samples")
artifact_defect(f"{name}: {metric} has too few samples")
unreadable = True
continue
if recorded != distribution(samples):
raise RatchetError(f"{name}: {metric} summary is inconsistent with its samples")
artifact_defect(f"{name}: {metric} summary is inconsistent with its samples")
unreadable = True
if unreadable:
continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Guard against non-Mapping probe and metric entries.

inspect_artifact validates that probes and metrics are Mappings, but it does not validate their values. If a metric entry is a scalar, recorded.get("samples") at Line 1203 raises AttributeError. If a probe entry is a scalar, entry.get("metrics") at Line 1187 raises AttributeError. The function is the classifier for malformed artifacts, so it should report these as fatal artifact defects instead of crashing. A crash also bypasses the RatchetError handler in main, so the CLI prints a traceback instead of the gc-ratchet error: message and its exit code 2.

🛡️ Proposed fix
     for name, entry in probes.items():
+        if not isinstance(entry, Mapping):
+            artifact_defect(f"{name}: probe entry is not an object")
+            continue
         metrics = entry.get("metrics")
         if not isinstance(metrics, Mapping):
             artifact_defect(f"{name}: no metrics recorded")
             continue
@@
             recorded = metrics[metric]
+            if not isinstance(recorded, Mapping):
+                artifact_defect(f"{name}: {metric} is not a recorded distribution")
+                unreadable = True
+                continue
             samples = recorded.get("samples")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for name, entry in probes.items():
metrics = entry.get("metrics")
if not isinstance(metrics, Mapping):
raise RatchetError(f"{name}: no metrics recorded")
artifact_defect(f"{name}: no metrics recorded")
continue
# Integrity of the recorded numbers. A missing metric or a summary that
# disagrees with its own samples is tampering or corruption, not
# unfitness: it stays fatal, because a partially-trusted artifact is not
# a thing this gate should ever compare against.
unreadable = False
for metric in ALL_METRICS:
if metric not in metrics:
raise RatchetError(f"{name}: baseline is missing {metric}")
artifact_defect(f"{name}: baseline is missing {metric}")
unreadable = True
continue
recorded = metrics[metric]
samples = recorded.get("samples")
if not isinstance(samples, list) or len(samples) < 2:
raise RatchetError(f"{name}: {metric} has too few samples")
artifact_defect(f"{name}: {metric} has too few samples")
unreadable = True
continue
if recorded != distribution(samples):
raise RatchetError(f"{name}: {metric} summary is inconsistent with its samples")
artifact_defect(f"{name}: {metric} summary is inconsistent with its samples")
unreadable = True
if unreadable:
continue
for name, entry in probes.items():
if not isinstance(entry, Mapping):
artifact_defect(f"{name}: probe entry is not an object")
continue
metrics = entry.get("metrics")
if not isinstance(metrics, Mapping):
artifact_defect(f"{name}: no metrics recorded")
continue
# Integrity of the recorded numbers. A missing metric or a summary that
# disagrees with its own samples is tampering or corruption, not
# unfitness: it stays fatal, because a partially-trusted artifact is not
# a thing this gate should ever compare against.
unreadable = False
for metric in ALL_METRICS:
if metric not in metrics:
artifact_defect(f"{name}: baseline is missing {metric}")
unreadable = True
continue
recorded = metrics[metric]
if not isinstance(recorded, Mapping):
artifact_defect(f"{name}: {metric} is not a recorded distribution")
unreadable = True
continue
samples = recorded.get("samples")
if not isinstance(samples, list) or len(samples) < 2:
artifact_defect(f"{name}: {metric} has too few samples")
unreadable = True
continue
if recorded != distribution(samples):
artifact_defect(f"{name}: {metric} summary is inconsistent with its samples")
unreadable = True
if unreadable:
continue
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@benchmarks/gc_ratchet/gc_ratchet.py` around lines 1186 - 1212, Update
inspect_artifact’s probe and metric validation to require each probe entry and
each metric entry to be a Mapping before calling get or accessing its contents.
Report invalid entries through artifact_defect, mark the artifact unreadable,
and continue classification so malformed artifacts produce the existing fatal
artifact result and RatchetError CLI handling instead of raising AttributeError.

Comment on lines +1678 to +1691
if unfit and args.scope == "all":
print(
f"gc-ratchet: {args.artifact} has {len(unfit)} unfit cell(s); "
"re-pin them or record a probe_overrides entry",
file=sys.stderr,
)
return 1
if unfit:
print(
f"gc-ratchet: {len(unfit)} unfit cell(s) deferred to `check` "
"(--scope structural); they will fail the job there, after the probes run",
file=sys.stderr,
)
print(f"gc-ratchet: {args.artifact} is structurally valid")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Operator-facing text calls every non-fatal defect a "cell". The non-fatal set contains both cell- and probe-scoped defects, but two output paths name only cells. A probe pinned without an oracle diff is then reported as an unfit cell.

  • benchmarks/gc_ratchet/gc_ratchet.py#L1678-L1691: change "unfit cell(s)" to "unfit probe(s)/cell(s)" in both the --scope all message and the deferral message.
  • benchmarks/gc_ratchet/gc_ratchet.py#L1490-L1502: change the report heading "Pinned cells that could not be gated" to name probes and cells.
📍 Affects 1 file
  • benchmarks/gc_ratchet/gc_ratchet.py#L1678-L1691 (this comment)
  • benchmarks/gc_ratchet/gc_ratchet.py#L1490-L1502
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@benchmarks/gc_ratchet/gc_ratchet.py` around lines 1678 - 1691, Update
operator-facing labels in benchmarks/gc_ratchet/gc_ratchet.py: in the messages
near lines 1678-1691, replace “unfit cell(s)” with “unfit probe(s)/cell(s)” in
both the --scope all and deferral paths; also update the report heading near
lines 1490-1502 from “Pinned cells that could not be gated” to name both probes
and cells.

Comment on lines +291 to +307
## A defect in the artifact costs one cell, not the whole gate (#7554)

Artifact validation used to abort on the first problem it found, and it runs
*before* the measurement step. So one cell — `12_large_live_set.heap_used_bytes`,
spread 6,768 bytes — meant none of the twelve probes executed on any branch for
three days. Two GC pacing changes (#7594, #7596) merged inside that window and
each had to hand-run a both-arms A/B in place of the gate. The claim that caused
it was about **one cell**; nothing about it voided the other 143 or made the
probes unrunnable.

Defects now carry a scope, and the scope is the blast radius:

| scope | examples | what it voids |
|---|---|---|
| `artifact` | wrong schema, missing metric, a summary that disagrees with its own samples | everything — still fatal, still in preflight |
| `probe` | pinned without an oracle diff, pinned with no collection | that probe's rows |
| `cell` | spread ≠ 0 on a metric whose band's premise is bit-identity | that one cell |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Reconcile the new section with the existing #7554 text.

Three accuracy points:

  1. Line 295 states the outage lasted three days. Line 204 of the same file states two days for the same incident. Make the two agree.
  2. Line 249 states that the bit-identity rule "now lives in validate_artifact". This change moved the rule into inspect_artifact; validate_artifact only raises on the defects that inspect_artifact collects. Update that sentence so the reader looks in the right function.
  3. The cell row on Line 307 omits the gating condition. inspect_artifact raises a cell defect only when gated_anywhere(...) is true, so a cell already excluded by probe_overrides produces no defect. Adding that clause prevents a reader from concluding that a recorded override still fails the artifact.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@benchmarks/gc_ratchet/README.md` around lines 291 - 307, The README’s new
artifact-validation section conflicts with existing incident details and
implementation behavior. Update the outage duration to match the two-day
account, revise the bit-identity rule reference from validate_artifact to
inspect_artifact, and clarify the cell scope row to state that defects apply
only when gated_anywhere(...) is true, excluding cells disabled by
probe_overrides.

Ralph Küpper added 4 commits August 8, 2026 01:13
…gate (#7554)

An artifact defect used to abort validation on the first problem, and the
artifact-validation step runs BEFORE the measurement step. So one cell —
12_large_live_set.heap_used_bytes, spread 6,768 bytes — meant none of the
twelve probes executed on any branch for three days, while two GC pacing
changes (#7594, #7596) merged with hand-run both-arms A/Bs standing in for
the gate.

The defect was a claim about ONE cell. Nothing about it voided the other 143,
and nothing about it made the probes unrunnable.

Defects now carry a scope. `artifact` (unreadable, tampered, missing metric)
stays fatal and stays in preflight. `probe` (pinned without an oracle diff, or
with no collection) and `cell` (contradicts the bit-identity premise of its own
band) demote their subject out of the gating family and are reported as
failures — so `check` still measures everything, still evaluates the other
cells, and still names a regression elsewhere in the matrix, while the defect
itself keeps the job red.

`validate --scope structural` (what CI preflight now runs) fails only on the
fatal kind. It cannot suppress: `check` re-derives the same list and fails on
it, and a test asserts that coupling per planted defect shape.

`assemble` is unchanged — pin time still refuses any defect outright, so this
cannot be used to freeze a new unfit artifact.
…ost (#7554)

gc-ratchet had not been green on main since 2026-08-01T05:39Z — 179 consecutive
red main runs. The 2026-08-05 window where it could not reach its probes at all
(#7554, fixed by #7557) was an episode inside that, not the whole of it: after
#7557 restored measurement the job stayed red against a 0.5.1280 artifact that
no longer described the collector.

Re-pinned at origin/main 26b9c9d (0.5.1346) on perry-macos — the same Mac mini
and the same rustc/cargo/clang the 2026-08-05 pin used, so this is like-for-like.
All 12 probes oracle-pass; heap_used_bytes spread 0 on eleven and 864 B on
12_large_live_set. Full per-cell attribution is in the artifact's own `notes`.

Three of the four moved groups are explained:
 - 03/04's copy and promote counters collapsing 40–99.8% is #7594 + #7596 doing
   what they said (less futile promotion). Recorded caveat: 03's promoted_* now
   pin at 0, where the allowance floor and the liveness assert both go quiet.
 - 02 +2.77% and 05 +16.44% retention are conservative-scan false roots, not
   retention. `classify` on this host gives 05 precise 5,329,880 — byte-identical
   to what #7571 measured at both ends of its window — and 02 precise 9,416,632,
   BELOW the number this baseline previously recorded. That is #7559's answer,
   reproduced rather than assumed.

The fourth is flagged, not explained: 12_large_live_set.wall_ms 3,056 -> 3,471 ms
(+13.58%), two non-overlapping 7-sample clusters on one host, while 06 and 11 got
9.6% and 28.4% faster. #7596 reported -7.4% on that cell, so by its own evidence
this is not #7596. It is gated on pinned_host only.

#7596's accepted 12_large_live_set.heap_total_bytes +36% did NOT reproduce here
(110,100,480 -> 110,100,480, +0.00%), so nothing was re-pinned for it.
@proggeramlug
proggeramlug force-pushed the fix/7554-gc-ratchet-repair branch from 2657ba1 to 8a04f53 Compare August 7, 2026 23:14
@proggeramlug
proggeramlug merged commit d4342ff into main Aug 7, 2026
1 check passed
@proggeramlug
proggeramlug deleted the fix/7554-gc-ratchet-repair branch August 7, 2026 23:14
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Audit before merge — verified, merged as v0.5.1348

The premise correction is the finding. I briefed this as "preflight fails,
three days dark"; the truth is 179 consecutive red main runs since
2026-08-01
, with the preflight abort merely an episode inside it (#7557 fixed
that; the rest was check against a v0.5.1280 artifact describing a collector
that no longer exists). And because gc-ratchet is not a required context,
none of the 179 blocked anything. That is gate hazard 2 with a week of paper
trail — the required-context promotion is admin action, correctly not
attempted, and per the corollary it follows the first green main run, which
this PR enables.

Sabotage re-verified: silencing the demoted-defect reporting (turning
fail-open back into suppression) fails the harness suite — 11 FAIL/ERROR lines,
exit 1; restored, exit 0. The design line worth quoting: fail open per cell,
fail closed on the verdict
— a demoted cell still turns the run red, it just
does so after the other 143 cells were measured, so a regression elsewhere is
named in the same run rather than hidden behind the abort.

Two attribution results deserve the record:

  1. perf(gc): live-proportional collection budgets at both generations (#7592) #7596's accepted heap_total_bytes +36% did not reproduce on the
    re-pinned baseline
    (+0.00%, byte-identical). My merge decision on perf(gc): live-proportional collection budgets at both generations (#7592) #7596
    accepted that trade on the strength of a dev-Mac both-arms A/B; the pinned
    mini at current main does not show it. The decision cost nothing, but the
    discrepancy is a live reminder that the dev-Mac A/B and the pinned-host
    baseline are different instruments — the re-pinned artifact is now the
    authority.
  2. The flagged +13.58% on 12_large_live_set.wall_ms (gc: 12_large_live_set is 13.6% slower at 0.5.1346 than 0.5.1280 with every collector counter flat #7610) is exactly the
    right thing to refuse to explain away.
    perf(gc): live-proportional collection budgets at both generations (#7592) #7596 claimed −7.4% on that very
    cell; the re-pin measures +13.58% with non-overlapping sample clusters while
    other probes got faster. Unexplained, filed, and now gated — the next
    change that touches it goes red.

The honest failure is worth more than the sabotage table: the agent's first
sabotage (PERRY_GEN_GC_EVACUATE=0) moved zero cells across all 12 probes
and left the gate green — reported rather than hidden, filed as #7611, the
third instance of the #6942/#7024 inert-knob pattern and now subject to the
kill-policy. Meanwhile the PERRY_GEN_GC=0 arm moved 79 cells and exited 1,
which is the liveness proof the gate needed.

Gates re-run here: harness suite green, validate structurally valid on the
re-pinned baseline, all four lint scripts + fmt clean, no Rust changed.

Owner action queued: promote gc-ratchet to required contexts after the
first green main run of this workflow.

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