Skip to content

test(gc-ratchet): classify a retention breach instead of guessing at it (#7559) - #7571

Merged
proggeramlug merged 3 commits into
mainfrom
diag/7559-closure-capture-retention
Aug 7, 2026
Merged

test(gc-ratchet): classify a retention breach instead of guessing at it (#7559)#7571
proggeramlug merged 3 commits into
mainfrom
diag/7559-closure-capture-retention

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Closes the investigation half of #7559.

The answer

05_closure_capture did not retain anything extra. Its retention measured
with the conservative native-stack scan disabled is 5,329,880 bytes at both
endpoints, to the byte
. The +16.44% is false-root residue moving from one
1 MiB nursery block to two.

The issue's lead was right that "identical work, more retained" rules out a
change in collector behaviour — but the thing being kept alive is not the
program's data. It is one extra stale word on the native Rust stack at the
instant the probe calls gc(), and heap_used_bytes amplifies it by ~26,000x.

Why the measurement does that

  1. The measurement point forces the conservative scan. Every probe reads
    process.memoryUsage() immediately after an explicit gc(), and an explicit
    gc() is the one site in Perry that forces the conservative native-stack
    scan (ManualGcScanGuard, GC: explicit gc() (full collect, default auto stack-scan) reclaims live top-level locals — string fields read back as garbage #4977 — production resolves to SkipDisabled).
    PERRY_GC_DIAG confirms the suite's whole conservative-scan census is the
    probes' own gc(): site=manual_collect automatic=false on all twelve, plus
    a single automatic old_reclaim_alloc_point on 12_large_live_set.
  2. js_arena_stats sums block offsets, not live bytes. A bump pointer
    never moves backwards and a block holding one marked object cannot be reset,
    so a single stale stack word costs a whole 1 MiB block. (This is the
    nursery's version of the old-generation accounting problem GC: old-gen fragmentation — scattered survivors pin 105 MB of blocks for a ~1 MB live set #7437/gc: old-generation hole free list — swept holes become reusable capacity (#7437) #7443 fixed
    for OLD_ARENA by subtracting swept holes.)

PERRY_GC_DIAG counts it directly at the measurement collection on
05_closure_capture:

arm general blocks marked live heap_used_bytes
pinned commit 5e236e6e2, default scan 6 6,378,392
pinned commit 5e236e6e2, scan off 5 5,329,880
b5a2954ec (v0.5.1321), default scan 7 7,426,960
b5a2954ec (v0.5.1321), scan off 5 5,329,880

Both minors are bit-identical between the arms (copied_objects 1422 + 1614 =
3036, exactly the pinned value), and with the scan off both arms free exactly
8,124,192 bytes at the final mark-sweep and retain exactly 425 forwarded stubs.

The window, all twelve probes

Both arms built --release -p perry -p perry-runtime-static -p perry-stdlib-static
with a cold object cache. The baseline arm reproduces the pinned artifact
byte-for-byte on all twelve probes, which is what makes the head arm's
numbers comparable.

probe heap_used_bytes base → head scan-off (precise) base → head
01_nursery_churn 7,325,584 → 6,277,048 (−14.3%) 5,228,512 → 5,228,512 (0)
02_survivor_promotion 9,418,232 → 9,678,792 (+2.77%) 9,418,232 → 9,416,632 (−1,600)
03_cross_gen_writes 2,793,472 → 1,427,664 (−48.9%) 2,465,776 → 1,394,880 (−1,070,896)
04_dead_after_deep_stack 6,257,040 → 4,897,320 (−21.7%) 5,208,472 → 4,891,968 (−316,504)
05_closure_capture 6,378,392 → 7,426,960 (+16.44%) 5,329,880 → 5,329,880 (0)
06_string_retention 7,058,896 → 7,058,896 4,961,800 → 4,961,800 (0)
07_array_grow_evacuate 15,649,104 → 15,649,104 unchanged (0)
08_map_set_sidetables 1,548,960 → 1,548,960 unchanged (0)
09_try_catch_roots 7,068,864 → 6,020,320 (−14.8%) 4,982,528 → 4,982,528 (0)
10_store_receiver_across_alloc 4,666,248 → 4,666,248 unchanged (0)
11_collect_at_depth 7,390,400 → 7,390,400 unchanged (0)
12_large_live_set 59,943,824 → 59,943,896 51,668,568 → 51,668,568 (0)

heap_used_bytes moved on five probes, always by whole blocks, three down
and two up. Precise retention moved on two, and both went down.

02_survivor_promotion's +2.77% is the same shape one survivor block down
(262,160 B ≈ 256 KiB), and its precise retention fell by 1,600 bytes.

The metric is not reporting the live set

05_closure_capture drops everything before it measures, so its live set is
~zero for every workload size. Sweeping BATCHES 690 → 710 with one compiler:

BATCHES=690   default=6,812,880    scanoff=4,715,800    excess=2,097,080
BATCHES=700   default=7,426,960    scanoff=5,329,880    excess=2,097,080
BATCHES=702   default=6,501,264    scanoff=4,404,184    excess=2,097,080   <- sawtooth
BATCHES=710   default=6,992,528    scanoff=4,895,448    excess=2,097,080

A 1 MiB sawtooth over a constant (zero) live set: what is left over is the
un-reset tail blocks' bump pointers, and arena_reset_empty_blocks never resets
the current block or the four before it. Two batches of workload — 123 KB — move
the gated number by 925,696 bytes.

What this PR changes

gc_ratchet.py classify runs every probe under both scan modes and prints the
split plus the scan-site census:

| Probe                   | conservative | spread | precise   | excess    | excess % | scan sites                       |
| `05_closure_capture`    |    7,426,960 |      0 | 5,329,880 | 2,097,080 |   28.24% | manual_collect×1 (explicit gc()) |
| `12_large_live_set`     |   59,943,896 |  2,304 | 51,668,568| 8,275,328 |   13.81% | manual_collect×1 (explicit gc()), old_reclaim_alloc_point×1 |

A row whose excess moved and whose precise did not is a false-root artifact;
a row whose precise moved is a real retention change.

It refuses to tabulate a probe whose stdout changes when the scan is
disabled — then the scan was load-bearing for that probe's correctness and its
precise number is not evidence about the collector — and refuses to report a
precise reading that is not bit-identical across repeats. The conservative
reading is allowed to vary and its spread is reported instead: that spread on
12_large_live_set is exactly why #7554 had to stop gating the cell, and
raising on it would delete the evidence. (All twelve probes are verified to
produce byte-identical stdout under both modes.)

Also here: the finding written into gc_ratchet.py's module docstring, the
README (a new section on what heap_used_bytes actually contains, plus a
mandatory classify step in "When the gate goes red"), and tolerances.json's
_readme; six new tests, including an end-to-end classify run against a stub
compiler that plays the #7559 shape, and one that fails if the runtime renames
PERRY_CONSERVATIVE_STACK_SCAN (a rename would otherwise make every precise
column silently equal its conservative one).

run_once now hands Popen the exit status os.wait4 collected behind its
back, so a harness run stops emitting a ResourceWarning per probe.

What this PR deliberately does NOT change

No band is widened and nothing is re-pinned. 05_closure_capture and
02_survivor_promotion stay red. The artifact's embedded tolerances copy is
synced to the file (prose only — every pct/abs/direction/gating tuple is
asserted identical before the rewrite) because evaluate reads the artifact's
copy, and #7557 set the precedent for a deliberate sync without a re-pin.

The remaining decision is a maintainer one and needs the pinned host; it is
written up on #7559. In short, the honest options are (a) re-pin those two cells
with the rationale above recorded in --notes, or (b) change what the retention
family measures. Widening the band is not one of them: heap_used_bytes's
churn quantum is one whole block, which is also the unit
test_one_falsely_retained_nursery_block_fails asserts must go red — you cannot
gate a quantity whose noise quantum equals its signal quantum, so the fix is to
measure a different quantity, not to loosen the band on this one.

Validation

  • python3 -m unittest discover -s tests -p 'test_gc_ratchet.py' — 63 tests, OK
  • python3 benchmarks/gc_ratchet/gc_ratchet.py validate — OK
  • python3 benchmarks/gc_ratchet/gc_ratchet.py measure + check --profile shared_ci
    at b5a2954ec reproduces the issue's numbers exactly (05 +16.44%, 02
    +2.77%, every 05 counter +0.00%)
  • bash scripts/check_file_size.sh — OK
  • python3 scripts/raw_handle_debt.py — 998 (baseline 998)
  • python3 scripts/gc_store_site_inventory.py — passed
  • cargo fmt --all -- --check — clean

No Rust changed, so the runtime gates are unaffected by construction.

Refs #7559, #7554, #7558

https://claude.ai/code/session_019EHcmXKArA7m42SihYCcgH

Summary by CodeRabbit

  • New Features

    • Added a gc_ratchet.py classify workflow to compare conservative and scan-disabled retention results.
    • Reports precise retention, scan-related residue, deterministic readings, output differences, and active scan sites.
    • Supports configurable repeat and warmup runs, with optional classification report output.
  • Documentation

    • Added guidance for interpreting heap measurements and classifying retention threshold breaches.
    • Documented scan behavior, arena accounting effects, and available command capabilities.
  • Bug Fixes

    • Improved subprocess cleanup to prevent resource warnings.
    • Added validation for diagnostic output and scan-mode configuration.

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5a29579f-bd0e-4651-bd48-53116c79bf03

📥 Commits

Reviewing files that changed from the base of the PR and between 56d1e8e and a21ebae.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (8)
  • CLAUDE.md
  • Cargo.toml
  • benchmarks/gc_ratchet/README.md
  • benchmarks/gc_ratchet/baseline/gc-ratchet-v1.json
  • benchmarks/gc_ratchet/gc_ratchet.py
  • benchmarks/gc_ratchet/tolerances.json
  • changelog.d/7571-gc-ratchet-classify-retention.md
  • tests/test_gc_ratchet.py

📝 Walkthrough

Walkthrough

The GC ratchet adds a classify workflow that compares conservative and scan-disabled retention, validates probe determinism, parses scan diagnostics, and reports false-root residue. Tests, documentation, changelog entries, and version metadata cover the change.

Changes

GC ratchet classification

Layer / File(s) Summary
Scan diagnostics and process handling
benchmarks/gc_ratchet/gc_ratchet.py, tests/test_gc_ratchet.py
The benchmark configures scan-disabled execution, parses scan-fallback diagnostics, records the reaped subprocess status, and tests diagnostic aggregation.
Retention classification and validation
benchmarks/gc_ratchet/gc_ratchet.py, tests/test_gc_ratchet.py
classify compares conservative and precise heap readings, checks exits, metrics, output equivalence, and precise-retention determinism, then reports residue, spread, and scan sites.
Classify command and guidance
benchmarks/gc_ratchet/README.md, benchmarks/gc_ratchet/baseline/gc-ratchet-v1.json, benchmarks/gc_ratchet/tolerances.json, changelog.d/7571-gc-ratchet-classify-retention.md, CLAUDE.md, Cargo.toml
The CLI exposes classification controls and JSON output. Documentation and metadata describe conservative-scan effects, the required classification workflow, and the version update.

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

Sequence Diagram(s)

sequenceDiagram
  participant cmd_classify
  participant classify
  participant PerryProbe
  participant parse_scan_fallbacks
  cmd_classify->>classify: request probe classification
  classify->>PerryProbe: run with conservative scanning
  PerryProbe-->>classify: return metrics, output, and stderr
  classify->>PerryProbe: run with scanning disabled
  PerryProbe-->>classify: return precise metrics and output
  classify->>parse_scan_fallbacks: parse scan diagnostics
  parse_scan_fallbacks-->>classify: return scan-site activity
  classify-->>cmd_classify: render classification report
Loading

Possibly related issues

  • PerryTS/perry issue 7559: Addresses the 05_closure_capture retention increase with scan-disabled diagnostics and classification.
  • PerryTS/perry issue 7558: Addresses conservative-stack-scan residue and nondeterministic heap_used_bytes measurements.

Possibly related PRs

  • PerryTS/perry#7147: Concerns conservative native-stack scan configuration and scan-disabled GC behavior.
  • PerryTS/perry#7166: Introduced conservative-scan fallback diagnostics consumed by this workflow.
  • PerryTS/perry#7359: Concerns GC ratchet probes and conservative native-stack root scanning.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely identifies the main change: classifying GC retention breaches instead of guessing their cause.
Description check ✅ Passed The description is detailed and covers the change, rationale, related issues, validation results, and deliberately unchanged behavior.
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.
✨ 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 diag/7559-closure-capture-retention

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: 5

🤖 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 `@benchmarks/gc_ratchet/gc_ratchet.py`:
- Around line 719-720: Remove the unnecessary f-string prefix from the string
literal on the line describing the conservative native-stack scan, leaving its
text and adjacent formatted string unchanged.
- Around line 645-648: Update the benchmark loop around run_once so the
conservative arm explicitly removes SCAN_MODE_ENV from the inherited environment
instead of passing None, while retaining the "off" override for the precise arm.
Apply the same cleanup to diagnostic runs, and add coverage that sets the parent
SCAN_MODE_ENV to "off" and verifies conservative execution still uses the
default scan mode.
- Around line 677-679: Update the diagnostic probe in the surrounding ratchet
flow to retain the result from run_once, check its returncode before calling
parse_scan_fallbacks, and raise RatchetError when the PERRY_GC_DIAG run fails;
add a fixture that exits nonzero only when PERRY_GC_DIAG is set and cover this
failure path.

In `@benchmarks/gc_ratchet/README.md`:
- Around line 365-372: Update the execution-count description in the README to
state that each probe runs in both conservative-scan modes using the configured
repeat count, and mention the additional diagnostic run. Remove the claim that
every probe runs exactly twice while preserving the existing output and
reporting behavior.

In `@changelog.d/7571-gc-ratchet-classify-retention.md`:
- Around line 67-71: Update the changelog note in the _readme section to correct
the test-count claim: the change adds nine related test methods, not six, so
replace the inaccurate count with the correct total or remove the count
entirely. Keep the surrounding description about the end-to-end classify run and
the PERRY_CONSERVATIVE_STACK_SCAN contract test unchanged.
🪄 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: ccdc6806-f35e-41e4-bb85-18f9cd6c09fe

📥 Commits

Reviewing files that changed from the base of the PR and between b5a2954 and 3228e80.

📒 Files selected for processing (6)
  • benchmarks/gc_ratchet/README.md
  • benchmarks/gc_ratchet/baseline/gc-ratchet-v1.json
  • benchmarks/gc_ratchet/gc_ratchet.py
  • benchmarks/gc_ratchet/tolerances.json
  • changelog.d/7571-gc-ratchet-classify-retention.md
  • tests/test_gc_ratchet.py

Comment on lines +645 to +648
for label, env in (("conservative", None), ("precise", {SCAN_MODE_ENV: "off"})):
seen: list[dict[str, int]] = []
for _ in range(repeats):
run = run_once([str(binary)], extra_env=env)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Clear the inherited scan-mode override for the conservative arm.

Line 645 passes no environment override for conservative. run_once copies os.environ, so a caller with PERRY_CONSERVATIVE_STACK_SCAN=off makes both arms precise. The report then shows zero residue and does not perform the required comparison.

Explicitly remove SCAN_MODE_ENV for conservative and diagnostic runs. Add a test that sets the parent environment variable to off.

🤖 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 645 - 648, Update the
benchmark loop around run_once so the conservative arm explicitly removes
SCAN_MODE_ENV from the inherited environment instead of passing None, while
retaining the "off" override for the precise arm. Apply the same cleanup to
diagnostic runs, and add coverage that sets the parent SCAN_MODE_ENV to "off"
and verifies conservative execution still uses the default scan mode.

Comment on lines +677 to +679
sites = parse_scan_fallbacks(
run_once([str(binary)], extra_env={"PERRY_GC_DIAG": "1"})["stderr"]
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Reject a failed diagnostic run.

The PERRY_GC_DIAG=1 run is used as the source of scan_fallback_sites, but its exit status is ignored. If diagnostics cause the probe to fail, parse_scan_fallbacks can produce an empty census and the classification still succeeds.

Check returncode before parsing stderr and raise RatchetError on failure. Add a fixture that exits nonzero only when PERRY_GC_DIAG is set.

🤖 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 677 - 679, Update the
diagnostic probe in the surrounding ratchet flow to retain the result from
run_once, check its returncode before calling parse_scan_fallbacks, and raise
RatchetError when the PERRY_GC_DIAG run fails; add a fixture that exits nonzero
only when PERRY_GC_DIAG is set and cover this failure path.

Comment on lines +719 to +720
f"own `gc()`, which forces the conservative native-stack scan. `precise` is the",
f"same reading with `{SCAN_MODE_ENV}=off`, i.e. the retention the",

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

Remove the unused f-string prefix.

Line 719 has no replacement field. Ruff reports F541 for this changed line.

🧰 Tools
🪛 Ruff (0.16.1)

[error] 719-719: f-string without any placeholders

Remove extraneous f prefix

(F541)

🤖 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 719 - 720, Remove the
unnecessary f-string prefix from the string literal on the line describing the
conservative native-stack scan, leaving its text and adjacent formatted string
unchanged.

Source: Linters/SAST tools

Comment on lines +365 to +372
It runs every probe twice — once as the gate does, once with
`PERRY_CONSERVATIVE_STACK_SCAN=off` — and prints the split, plus the census of
which conservative-scan sites actually fired. It refuses to tabulate a probe
whose *output* changes when the scan is disabled (the scan was load-bearing for
that probe's correctness, so its precise number is not evidence), and it refuses
to report a precise reading that is not bit-identical across repeats. The
conservative reading is allowed to vary and its spread is reported instead —
that spread on `12_large_live_set` is why #7554 had to stop gating the 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

Correct the execution-count description.

classify defaults to three repeats in each scan mode and also performs a diagnostic run. “Runs every probe twice” is incorrect. State that it runs probes in both scan modes, with the configured repeat count.

🤖 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 365 - 372, Update the
execution-count description in the README to state that each probe runs in both
conservative-scan modes using the configured repeat count, and mention the
additional diagnostic run. Remove the claim that every probe runs exactly twice
while preserving the existing output and reporting behavior.

Comment on lines +67 to +71
and in `tolerances.json`'s `_readme`. Six new tests cover it, including an
end-to-end `classify` run against a stub compiler that plays the #7559 shape and
one that fails if the runtime renames `PERRY_CONSERVATIVE_STACK_SCAN` — a rename
would otherwise make every `precise` column silently equal its `conservative`
one, i.e. a classifier that classifies nothing.

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

Correct the test-count claim.

The changed test file adds nine related test methods: two parser tests and seven classification or runtime-contract tests. “Six new tests” is inaccurate, including because the next sentence counts the runtime-knob test as part of that set.

State the correct count or remove the count.

🤖 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 `@changelog.d/7571-gc-ratchet-classify-retention.md` around lines 67 - 71,
Update the changelog note in the _readme section to correct the test-count
claim: the change adds nine related test methods, not six, so replace the
inaccurate count with the correct total or remove the count entirely. Keep the
surrounding description about the end-to-end classify run and the
PERRY_CONSERVATIVE_STACK_SCAN contract test unchanged.

Ralph Küpper added 3 commits August 7, 2026 06:35
…it (#7559)

`05_closure_capture` reported `heap_used_bytes` +16.44% with every collector
counter at +0.00%. Nothing was kept alive that had not been kept alive before:
its retention measured with the conservative native-stack scan disabled is
**5,329,880 bytes at both endpoints, to the byte**. What moved was false-root
residue, one 1 MiB nursery block.

Two properties of the measurement point produce that:

1. Every probe reads `process.memoryUsage()` right after an explicit `gc()`,
   and an explicit `gc()` is the ONE site in Perry that forces the conservative
   native-stack scan (`ManualGcScanGuard`, #4977; production resolves to
   `SkipDisabled`). The reading is taken under a root set nothing else in the
   language uses.
2. `js_arena_stats` sums each arena block's bump-pointer OFFSET, and a block
   holding one marked object cannot be reset — so one stale stack word costs a
   whole 1 MiB block. `PERRY_GC_DIAG` counts it directly at the measurement
   collection: general blocks marked live are 5 with the scan off at BOTH
   endpoints, 6 with it on at the pinned commit, 7 at v0.5.1321.

Across the 74 commits from the 2026-08-05 pin (5e236e6) to v0.5.1321, both
arms built with the same package set and both reproducing the pinned artifact
byte-for-byte on all twelve probes: `heap_used_bytes` moved on five probes,
always by whole blocks, while scan-off retention was byte-identical on ten of
twelve and FELL on the other two. The metric is deterministic without being
semantic, and the probe's live set is not what it reports: sweeping
`05_closure_capture`'s BATCHES 690..710 — live set ~0 throughout — walks it
between 6,501,264 and 7,426,960 in a 1 MiB sawtooth.

So: `gc_ratchet.py classify` runs every probe under both scan modes and prints
the split plus the census of which conservative-scan sites actually fired. A row
whose `excess` moved and whose `precise` did not is a false-root artifact; a row
whose `precise` moved is a real retention change. It refuses to tabulate a probe
whose OUTPUT changes when the scan is disabled (the scan was load-bearing for
that probe's correctness, so its precise number is not evidence about the
collector), and refuses to report a precise reading that is not bit-identical
across repeats. The conservative reading is allowed to vary and its spread is
reported instead — that spread on `12_large_live_set` is why #7554 had to stop
gating the cell, and raising on it would delete the evidence.

No band is changed and nothing is re-pinned: `05_closure_capture` and
`02_survivor_promotion` stay red until someone decides, which is what the
ratchet is for. The artifact's embedded tolerances copy is synced to the file
(prose only — every pct/abs/direction/gating tuple is identical, asserted before
the rewrite) because `evaluate` reads the artifact's copy and a drifted file
would be a gate whose configuration disagrees with itself.

Also: `run_once` now tells `Popen` the exit status `os.wait4` collected behind
its back, so a harness run stops emitting a ResourceWarning per probe.

Claude-Session: https://claude.ai/code/session_019EHcmXKArA7m42SihYCcgH
@proggeramlug
proggeramlug force-pushed the diag/7559-closure-capture-retention branch from 3228e80 to a21ebae Compare August 7, 2026 04:35
@proggeramlug
proggeramlug merged commit 2b54d58 into main Aug 7, 2026
10 of 12 checks passed
@proggeramlug
proggeramlug deleted the diag/7559-closure-capture-retention branch August 7, 2026 04:35
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

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