From 8b7efaebbdcd48f8a22f8acdf23a0489fb188657 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 7 Aug 2026 03:06:30 +0200 Subject: [PATCH 1/6] test(gc-ratchet): allow a per-probe metric to leave the gating family benchmarks/gc_ratchet/tolerances.json is keyed per metric per profile, so the only lever for a metric that has become sample-dependent on ONE probe was to stop gating it on all twelve. Add a probe_overrides section that takes a single (probe, metric) cell out of the gating family, carrying the evidence that justifies the exclusion, and move the bit-identity rule from the unit tests into validate_artifact so such an artifact cannot be pinned in the first place. Refs #7554 --- .../gc_ratchet/baseline/gc-ratchet-v1.json | 26 +- benchmarks/gc_ratchet/gc_ratchet.py | 248 ++++++++++++++++- benchmarks/gc_ratchet/tolerances.json | 27 +- tests/test_gc_ratchet.py | 260 +++++++++++++++++- 4 files changed, 549 insertions(+), 12 deletions(-) diff --git a/benchmarks/gc_ratchet/baseline/gc-ratchet-v1.json b/benchmarks/gc_ratchet/baseline/gc-ratchet-v1.json index 1e0baabb64..0d62a3bb24 100644 --- a/benchmarks/gc_ratchet/baseline/gc-ratchet-v1.json +++ b/benchmarks/gc_ratchet/baseline/gc-ratchet-v1.json @@ -90,7 +90,17 @@ "class from the host the baseline was captured on: retention and GC counters", "transfer because they are semantic, memory and time do not. 'pinned_host' is", "for a maintainer re-running the suite on the same quiet box, where memory and", - "time are comparable and are gated." + "time are comparable and are gated.", + "", + "'probe_overrides' takes ONE (probe, metric) cell out of the gating family.", + "A profile band answers 'what is this machine class's noise floor'; whether a", + "metric is deterministic enough to gate at all is a property of the WORKLOAD,", + "and those two questions were conflated until #7554. It may only ever set", + "gating to false, never true, and never touches the band: an excluded cell is", + "still measured, still compared, and still printed, it just cannot turn the", + "job red. Every entry carries evidence that is checked, not merely stored --", + "at least 21 runs (the same number every band above is justified by) and a", + "spread that is actually non-zero, so a cell cannot be excluded on a hunch." ], "shared_ci": { "heap_used_bytes": { @@ -263,6 +273,20 @@ "gating": true, "rationale": "GATED HERE ONLY. Worst cross-session spread of medians-of-7 was 0.751% on an idle box (load 1.7-2.0); worst raw within-session spread was 5.3%, which the median-of-7 damps out. 10% is ~13x the cross-session figure and ~2x the worst raw spread, so it will not fire on scheduler jitter but will catch the tens-of-percent slowdown a whole-stack conservative scan would introduce. The 15 ms floor covers the fastest probe (126 ms)." } + }, + "probe_overrides": { + "12_large_live_set": { + "heap_used_bytes": { + "gating": false, + "rationale": "NOT GATED ON THIS PROBE. Retention here is genuinely sample-dependent, so it cannot carry a band whose premise is bit-identity. The cause is in the collector, not the harness: the evacuation policy (crates/perry-runtime/src/gc/oldgen.rs) thresholds on two measured, machine-dependent inputs -- process RSS read from the OS at decision time, and the previous cycle's pause in microseconds against MAX_PREVIOUS_PAUSE_US. Both are step functions, so a run that lands on the other side of a threshold compacts a different amount and retains a different amount. The other eleven probes hold small live sets and never approach either threshold, which is why they remain bit-identical and stay gated; 12_large_live_set is the only probe with a ~100 MB live set and multi-millisecond pauses. Retention on this probe is still measured, still compared against the baseline, and still printed as an informational drift row -- and heap_total_bytes, minor_cycles, copied_objects, promoted_bytes and freed_bytes on this same probe remain gating, so a real over-retention regression here still goes red.", + "evidence": { + "observed_runs": 21, + "observed_spread": 4536, + "measured_on": "2026-08-06, pinned quiet host perry-macos (Mac mini M1, 8 GB, Spotlight disabled), main @ 52f7dae1f, release build: 5 distinct values across 21 runs (42612752 42613184 42613328 42614048 42617288). The 2026-08-05 pin saw the same effect as one outlier in seven (+6768 B).", + "issue": "https://github.com/PerryTS/perry/issues/7554" + } + } + } } }, "notes": "Old-generation hole free list (#7443): ratchets 12_large_live_set heap_used down 105.6 MB -> 59.9 MB so a regression back to unreclaimable scattered-survivor retention goes red; other probes counters unchanged. Captured on the dedicated bench host (Mac mini M1, 8 GB, Spotlight disabled via root), which replaces the shared MacBook as the pinned quiet host.", diff --git a/benchmarks/gc_ratchet/gc_ratchet.py b/benchmarks/gc_ratchet/gc_ratchet.py index 4a0d017df2..c484332d9c 100644 --- a/benchmarks/gc_ratchet/gc_ratchet.py +++ b/benchmarks/gc_ratchet/gc_ratchet.py @@ -101,8 +101,26 @@ #: Metrics collected once per repeat from a normal (untraced) run. SAMPLED_METRICS = RETENTION_METRICS + MEMORY_METRICS + TIMING_METRICS +#: Metrics whose gating premise is *bit-identity*, not a noise allowance. Their +#: bands in ``tolerances.json`` are justified by an observed spread of 0.000% +#: over 21 runs, so a pinned probe whose samples disagree is not merely noisy: +#: it contradicts the reason its band is that tight. Such a cell may not be +#: gating (see ``validate_artifact`` and ``probe_overrides``). +DETERMINISTIC_METRICS = RETENTION_METRICS + GC_METRICS + PROFILES = ("shared_ci", "pinned_host") +#: Top-level keys ``tolerances.json`` may contain. Anything else is refused +#: rather than ignored: a mistyped ``probe_override`` section would silently not +#: apply, which is a gate quietly measuring something other than what its file +#: says it measures. +TOLERANCE_SECTIONS = frozenset(("_readme", "probe_overrides", *PROFILES)) + +#: Minimum repeats behind a probe-override exclusion. ``tolerances.json`` +#: justifies every *inclusion* on 21 runs (3 sessions x 7); an *exclusion* is +#: the same claim with the opposite sign and is held to the same evidence. +MIN_EXCLUSION_RUNS = 21 + class RatchetError(RuntimeError): """Raised when measurement, artifact validation, or comparison fails.""" @@ -580,7 +598,158 @@ def _tolerance_from_json(metric: str, raw: Mapping[str, Any]) -> Tolerance: ) +@dataclass(frozen=True) +class ProbeOverride: + """One (probe, metric) cell removed from the gating family, with its reason. + + ``tolerances.json`` is keyed per metric per profile, which is the right + granularity for a *band*: the band expresses a machine class's noise floor. + It is the wrong granularity for the question "can this metric carry a gate + at all on this workload", because that is a property of the workload. When + those two got conflated the only available lever was to stop gating a metric + on all twelve probes because one of them had become sample-dependent (#7554). + + So an override may only ever *remove* a cell from the gating family, never + add one — re-gating is the profile's job — and it may not touch the band. + A non-gating row is still measured, still compared, and still printed; it + just cannot turn the job red. + + ``evidence`` is mandatory and is checked, not merely stored. An exclusion + claims a metric is not deterministic on this probe; that claim needs at + least as many runs behind it as the inclusion it overrules, and a spread + that is actually non-zero. A silent exclusion is how gates rot. + """ + + probe: str + metric: str + rationale: str + observed_runs: int + observed_spread: float + measured_on: str + issue: str + + def applied_to(self, base: Tolerance) -> Tolerance: + """The profile's band, with gating removed and the reason substituted.""" + return Tolerance( + pct=base.pct, + abs=base.abs, + direction=base.direction, + gating=False, + rationale=self.rationale, + ) + + +def _probe_override_from_json(probe: str, metric: str, raw: Mapping[str, Any]) -> ProbeOverride: + where = f"probe_overrides.{probe}.{metric}" + if metric not in ALL_METRICS: + raise RatchetError(f"{where}: unknown metric") + unknown = sorted(set(raw) - {"gating", "rationale", "evidence"}) + if unknown: + raise RatchetError(f"{where}: unexpected field(s) {unknown}") + if raw.get("gating") is not False: + raise RatchetError( + f"{where}: an override may only set gating to false. It exists to take a cell " + "out of a gating family; putting one back is the profile's job, and a band that " + "gates on one probe but not another belongs in the profile where it can be read." + ) + rationale = str(raw.get("rationale", "")).strip() + if not rationale: + raise RatchetError( + f"{where}: has no rationale. Recording that a cell is non-gating without " + "recording why is a silent exclusion, which is how gates rot." + ) + evidence = raw.get("evidence") + if not isinstance(evidence, Mapping): + raise RatchetError(f"{where}: has no evidence block") + unknown_evidence = sorted( + set(evidence) - {"observed_runs", "observed_spread", "measured_on", "issue"} + ) + if unknown_evidence: + raise RatchetError(f"{where}: evidence has unexpected field(s) {unknown_evidence}") + for field in ("observed_runs", "observed_spread", "measured_on", "issue"): + if field not in evidence: + raise RatchetError(f"{where}: evidence is missing {field}") + runs = int(evidence["observed_runs"]) + if runs < MIN_EXCLUSION_RUNS: + raise RatchetError( + f"{where}: evidence rests on {runs} runs; at least {MIN_EXCLUSION_RUNS} are " + "required, the same number every band in this file is justified by. Fewer runs " + "cannot distinguish a non-deterministic metric from one bad sample." + ) + spread = float(evidence["observed_spread"]) + if spread <= 0: + raise RatchetError( + f"{where}: evidence records a spread of {spread:g}. A metric that was observed " + "to be deterministic has not been shown to be ungateable; it must stay gated." + ) + for field in ("measured_on", "issue"): + if not str(evidence[field]).strip(): + raise RatchetError(f"{where}: evidence.{field} is blank") + return ProbeOverride( + probe=probe, + metric=metric, + rationale=rationale, + observed_runs=runs, + observed_spread=spread, + measured_on=str(evidence["measured_on"]).strip(), + issue=str(evidence["issue"]).strip(), + ) + + +def probe_overrides_from_json(payload: Mapping[str, Any]) -> dict[str, dict[str, ProbeOverride]]: + """Parse the optional ``probe_overrides`` section. + + Deliberately *not* nested per profile. An override answers "is this metric + deterministic enough to gate on this workload", which is a property of the + probe and the collector, not of the machine the numbers were taken on. Bands + stay per profile; gateability does not. + """ + raw = payload.get("probe_overrides", {}) + if not isinstance(raw, Mapping): + raise RatchetError("probe_overrides must be an object keyed by probe name") + overrides: dict[str, dict[str, ProbeOverride]] = {} + for probe, metrics in raw.items(): + if not isinstance(metrics, Mapping) or not metrics: + raise RatchetError(f"probe_overrides.{probe} must be a non-empty object") + overrides[probe] = { + metric: _probe_override_from_json(probe, metric, entry) + for metric, entry in metrics.items() + } + return overrides + + +def resolve_tolerance( + profile_tolerances: Mapping[str, Tolerance], + overrides: Mapping[str, Mapping[str, ProbeOverride]], + probe: str, + metric: str, +) -> Tolerance: + override = overrides.get(probe, {}).get(metric) + base = profile_tolerances[metric] + return override.applied_to(base) if override else base + + +def gated_anywhere( + profiles: Mapping[str, Mapping[str, Tolerance]], + overrides: Mapping[str, Mapping[str, ProbeOverride]], + probe: str, + metric: str, +) -> bool: + """True when this cell can turn the job red under at least one profile.""" + return any( + resolve_tolerance(profiles[profile], overrides, probe, metric).gating + for profile in profiles + ) + + def tolerances_from_json(payload: Mapping[str, Any]) -> dict[str, dict[str, Tolerance]]: + unknown_sections = sorted(set(payload) - TOLERANCE_SECTIONS) + if unknown_sections: + raise RatchetError( + f"tolerances have unknown top-level section(s) {unknown_sections}; a mistyped " + "section would be silently ignored and the gate would not do what the file says" + ) + probe_overrides_from_json(payload) profiles: dict[str, dict[str, Tolerance]] = {} for profile in PROFILES: if profile not in payload: @@ -645,6 +814,46 @@ def assemble( return artifact +def _validate_probe_overrides( + profiles: Mapping[str, Mapping[str, Tolerance]], + overrides: Mapping[str, Mapping[str, ProbeOverride]], + probes: Mapping[str, Any], +) -> None: + """Cross-check the override set against the probes it claims to describe. + + Two rules, both aimed at the same rot. An override that matches nothing is a + failure, not a no-op — the same rule ``scripts/gc_root_dominance_allowlist.json`` + carries, so deleting a probe (or fixing the non-determinism and renaming it) + forces the exclusion to be revisited instead of outliving its reason. And an + override set that covers every probe for a metric has achieved, one cell at a + time, exactly what ``"gating": false`` at profile level would have done, only + without saying so anywhere a reader would look. + """ + for probe, metrics in sorted(overrides.items()): + if probe not in probes: + raise RatchetError( + f"probe_overrides names {probe!r}, which is not in this artifact " + f"(probes: {', '.join(sorted(probes))}). An exclusion that matches nothing " + "must be deleted, not left behind to outlive its reason." + ) + for metric in sorted(metrics): + if metric not in probes[probe].get("metrics", {}): + raise RatchetError(f"probe_overrides.{probe}.{metric} is not a recorded metric") + + for profile, entries in sorted(profiles.items()): + for metric, tolerance in sorted(entries.items()): + if not tolerance.gating: + continue + if not any( + resolve_tolerance(entries, overrides, probe, metric).gating for probe in probes + ): + raise RatchetError( + f"{profile}: {metric} is marked gating but every probe overrides it to " + "non-gating, so it can never fail. Say that once at profile level, with " + "the reason, instead of assembling it out of per-probe exclusions." + ) + + def validate_artifact(artifact: Mapping[str, Any]) -> None: if artifact.get("schema_version") != SCHEMA_VERSION: raise RatchetError(f"unsupported schema_version {artifact.get('schema_version')!r}") @@ -659,7 +868,10 @@ def validate_artifact(artifact: Mapping[str, Any]) -> None: expected = artifact.get("run_config", {}).get("probes") if not isinstance(expected, list) or sorted(expected) != sorted(probes): raise RatchetError("artifact probe set does not match its run_config") - tolerances_from_json(artifact.get("tolerances", {})) + tolerance_payload = artifact.get("tolerances", {}) + profiles = tolerances_from_json(tolerance_payload) + overrides = probe_overrides_from_json(tolerance_payload) + _validate_probe_overrides(profiles, overrides, probes) for name, entry in probes.items(): metrics = entry.get("metrics") if not isinstance(metrics, Mapping): @@ -684,6 +896,23 @@ def validate_artifact(artifact: Mapping[str, Any]) -> None: ) if metrics["minor_cycles"]["median"] < 1: raise RatchetError(f"{name}: baseline pinned a probe that ran no minor collection") + # The bit-identity rule, enforced at PINNING time rather than only in + # the unit tests. It used to live only in tests/test_gc_ratchet.py, so + # #7446 was able to write an artifact whose 12_large_live_set retention + # spread was 6,768 bytes; the test then failed in the CI step that runs + # *before* the measurement step, and the ratchet measured nothing at all + # for two days (#7554). Refusing to assemble such an artifact turns that + # into a loud failure on the maintainer's machine, at the moment the + # judgement is being made, instead of a silent one in CI afterwards. + for metric in DETERMINISTIC_METRICS: + spread = metrics[metric]["spread"] + if spread and gated_anywhere(profiles, overrides, name, metric): + raise RatchetError( + f"{name}: {metric} spread {spread:g} when pinned, but its band is " + "justified by bit-identity, not by a noise allowance. Either re-pin on a " + "quiet host, or take this one cell out of the gating family with a " + "probe_overrides entry that records the evidence." + ) # --------------------------------------------------------------------------- @@ -729,6 +958,7 @@ def evaluate( failures: list[str] = [] rows: list[Row] = [] tolerances = tolerances_from_json(baseline["tolerances"])[profile] + overrides = probe_overrides_from_json(baseline["tolerances"]) if baseline["platform"] != current.get("platform"): message = ( @@ -777,7 +1007,7 @@ def evaluate( failures.append(f"{name}: correctness was not verified against the Node oracle ({reason})") for metric in ALL_METRICS: - tolerance = tolerances[metric] + tolerance = resolve_tolerance(tolerances, overrides, name, metric) base_median = float(base_entry["metrics"][metric]["median"]) cur_median = float(cur_entry["metrics"][metric]["median"]) delta = cur_median - base_median @@ -845,6 +1075,20 @@ def render(rows: Iterable[Row], baseline: Mapping[str, Any], profile: str) -> st f"| `{row.probe}` | {row.metric} | {row.baseline:,.0f} | {row.current:,.0f} | " f"{delta} | {row.allowance:,.0f} | {'yes' if row.gating else 'no'} | {row.status} |" ) + # Print the exclusions with their reasons on every run. A reader who sees a + # "no" in the Gating column must be able to find out why it is a no without + # opening another file, or the exclusion is effectively invisible. + overrides = probe_overrides_from_json(baseline.get("tolerances", {})) + if overrides: + lines += ["", "### Cells excluded from the gating family by probe override", ""] + for probe in sorted(overrides): + for metric in sorted(overrides[probe]): + override = overrides[probe][metric] + lines.append( + f"- `{probe}`.{metric} — {override.rationale} " + f"(observed spread {override.observed_spread:,.0f} over " + f"{override.observed_runs} runs, {override.measured_on}; {override.issue})" + ) return "\n".join(lines) + "\n" diff --git a/benchmarks/gc_ratchet/tolerances.json b/benchmarks/gc_ratchet/tolerances.json index 7a71a6ef1c..65be3a4486 100644 --- a/benchmarks/gc_ratchet/tolerances.json +++ b/benchmarks/gc_ratchet/tolerances.json @@ -16,7 +16,17 @@ "class from the host the baseline was captured on: retention and GC counters", "transfer because they are semantic, memory and time do not. 'pinned_host' is", "for a maintainer re-running the suite on the same quiet box, where memory and", - "time are comparable and are gated." + "time are comparable and are gated.", + "", + "'probe_overrides' takes ONE (probe, metric) cell out of the gating family.", + "A profile band answers 'what is this machine class's noise floor'; whether a", + "metric is deterministic enough to gate at all is a property of the WORKLOAD,", + "and those two questions were conflated until #7554. It may only ever set", + "gating to false, never true, and never touches the band: an excluded cell is", + "still measured, still compared, and still printed, it just cannot turn the", + "job red. Every entry carries evidence that is checked, not merely stored --", + "at least 21 runs (the same number every band above is justified by) and a", + "spread that is actually non-zero, so a cell cannot be excluded on a hunch." ], "shared_ci": { @@ -191,5 +201,20 @@ "gating": true, "rationale": "GATED HERE ONLY. Worst cross-session spread of medians-of-7 was 0.751% on an idle box (load 1.7-2.0); worst raw within-session spread was 5.3%, which the median-of-7 damps out. 10% is ~13x the cross-session figure and ~2x the worst raw spread, so it will not fire on scheduler jitter but will catch the tens-of-percent slowdown a whole-stack conservative scan would introduce. The 15 ms floor covers the fastest probe (126 ms)." } + }, + + "probe_overrides": { + "12_large_live_set": { + "heap_used_bytes": { + "gating": false, + "rationale": "NOT GATED ON THIS PROBE. Retention here is genuinely sample-dependent, so it cannot carry a band whose premise is bit-identity. The cause is in the collector, not the harness: the evacuation policy (crates/perry-runtime/src/gc/oldgen.rs) thresholds on two measured, machine-dependent inputs -- process RSS read from the OS at decision time, and the previous cycle's pause in microseconds against MAX_PREVIOUS_PAUSE_US. Both are step functions, so a run that lands on the other side of a threshold compacts a different amount and retains a different amount. The other eleven probes hold small live sets and never approach either threshold, which is why they remain bit-identical and stay gated; 12_large_live_set is the only probe with a ~100 MB live set and multi-millisecond pauses. Retention on this probe is still measured, still compared against the baseline, and still printed as an informational drift row -- and heap_total_bytes, minor_cycles, copied_objects, promoted_bytes and freed_bytes on this same probe remain gating, so a real over-retention regression here still goes red.", + "evidence": { + "observed_runs": 21, + "observed_spread": 4536, + "measured_on": "2026-08-06, pinned quiet host perry-macos (Mac mini M1, 8 GB, Spotlight disabled), main @ 52f7dae1f, release build: 5 distinct values across 21 runs (42612752 42613184 42613328 42614048 42617288). The 2026-08-05 pin saw the same effect as one outlier in seven (+6768 B).", + "issue": "https://github.com/PerryTS/perry/issues/7554" + } + } + } } } diff --git a/tests/test_gc_ratchet.py b/tests/test_gc_ratchet.py index 6a1e757689..5213cde198 100644 --- a/tests/test_gc_ratchet.py +++ b/tests/test_gc_ratchet.py @@ -19,13 +19,18 @@ from benchmarks.gc_ratchet.gc_ratchet import ( ALL_METRICS, DEFAULT_ARTIFACT, + DETERMINISTIC_METRICS, GC_METRICS, + MIN_EXCLUSION_RUNS, PROFILES, RatchetError, distribution, evaluate, + gated_anywhere, parse_gc_diag, parse_gcmetrics, + probe_overrides_from_json, + render, tolerances_from_json, validate_artifact, ) @@ -33,6 +38,47 @@ REPO_ROOT = Path(__file__).resolve().parent.parent TOLERANCES_PATH = REPO_ROOT / "benchmarks" / "gc_ratchet" / "tolerances.json" + +def _shipped_tolerances(): + return json.loads(TOLERANCES_PATH.read_text(encoding="utf-8")) + + +def _tolerances(): + """The shipped bands without the shipped probe overrides. + + An override that names a probe the artifact does not contain is a hard + failure by design, so the synthetic single-probe fixtures below cannot carry + the real ``12_large_live_set`` entry. The override machinery is exercised + explicitly in ``ProbeOverrideTests`` instead, against fixtures whose probe + names match. + """ + payload = _shipped_tolerances() + payload.pop("probe_overrides", None) + return payload + + +def _override_entry(): + return { + "gating": False, + "rationale": "measured non-deterministic on this workload; see the issue", + "evidence": { + "observed_runs": 21, + "observed_spread": 4536, + "measured_on": "2026-08-06, pinned quiet host", + "issue": "https://github.com/PerryTS/perry/issues/7554", + }, + } + + +def _with_override(probe="01_probe", metric="heap_used_bytes", entry=None): + """Shipped bands plus one probe override, ready to hand to a fixture.""" + payload = _tolerances() + payload["probe_overrides"] = { + probe: {metric: entry if entry is not None else _override_entry()} + } + return payload + + BASE_VALUES = { "heap_used_bytes": 1_000_000.0, "heap_total_bytes": 20_971_520.0, @@ -65,7 +111,7 @@ def _probe(name="01_probe", overrides=None, correctness="pass"): } -def _baseline(probes=None): +def _baseline(probes=None, tolerances=None): probes = probes if probes is not None else _probe() return { "schema_version": 1, @@ -76,11 +122,21 @@ def _baseline(probes=None): "platform": "darwin-arm64", "host": {"cpu_count": 8, "load_average": {"1m": 1.0}}, "run_config": {"repeats": 7, "warmup": 1, "traced_runs": 2, "probes": sorted(probes)}, - "tolerances": json.loads(TOLERANCES_PATH.read_text(encoding="utf-8")), + "tolerances": tolerances if tolerances is not None else _tolerances(), "probes": probes, } +def _pair(overrides=None, other_overrides=None): + """Two probes. + + An override that covers every probe of a metric is refused (it would be a + profile-level ``"gating": false`` assembled out of parts), so any fixture + exercising an override needs at least one probe the override does not touch. + """ + return _probe("01_probe", overrides) | _probe("02_other", other_overrides) + + def _measurement(probes=None, platform="darwin-arm64", repeats=7): probes = probes if probes is not None else _probe() return { @@ -137,12 +193,12 @@ def test_eligible_lines_do_not_count_as_cycles(self): class ToleranceTests(unittest.TestCase): def test_shipped_tolerances_parse_and_cover_every_metric(self): - profiles = tolerances_from_json(json.loads(TOLERANCES_PATH.read_text(encoding="utf-8"))) + profiles = tolerances_from_json(_shipped_tolerances()) for profile in PROFILES: self.assertEqual(set(profiles[profile]), set(ALL_METRICS)) def test_every_tolerance_states_a_rationale(self): - profiles = tolerances_from_json(json.loads(TOLERANCES_PATH.read_text(encoding="utf-8"))) + profiles = tolerances_from_json(_shipped_tolerances()) for profile, entries in profiles.items(): for metric, tolerance in entries.items(): self.assertTrue( @@ -151,7 +207,7 @@ def test_every_tolerance_states_a_rationale(self): ) def test_profile_with_nothing_gating_is_rejected(self): - payload = json.loads(TOLERANCES_PATH.read_text(encoding="utf-8")) + payload = _shipped_tolerances() for entry in payload["shared_ci"].values(): entry["gating"] = False with self.assertRaises(RatchetError) as caught: @@ -159,13 +215,13 @@ def test_profile_with_nothing_gating_is_rejected(self): self.assertIn("could never fail", str(caught.exception)) def test_rationale_may_not_be_blank(self): - payload = json.loads(TOLERANCES_PATH.read_text(encoding="utf-8")) + payload = _shipped_tolerances() payload["shared_ci"]["heap_used_bytes"]["rationale"] = " " with self.assertRaises(RatchetError): tolerances_from_json(payload) def test_gc_counters_are_two_sided(self): - profiles = tolerances_from_json(json.loads(TOLERANCES_PATH.read_text(encoding="utf-8"))) + profiles = tolerances_from_json(_shipped_tolerances()) for profile in PROFILES: for metric in GC_METRICS: self.assertEqual( @@ -310,7 +366,7 @@ def test_every_gating_metric_can_independently_fail(self): Without this, a band could quietly be set wide enough that the metric is gating in name only, and nothing would ever notice. """ - payload = json.loads(TOLERANCES_PATH.read_text(encoding="utf-8")) + payload = _shipped_tolerances() profiles = tolerances_from_json(payload) for profile in PROFILES: for metric, tolerance in profiles[profile].items(): @@ -363,15 +419,59 @@ def test_pinned_artifact_probes_match_the_node_oracle(self): ) def test_pinned_artifact_retention_is_deterministic(self): + """Every *gating* retention cell in the pinned artifact must be bit-identical. + + The band on these metrics is justified in tolerances.json as pure + anti-brittleness margin over an observed spread of 0.000%, not as a noise + allowance, so a gating cell whose own samples disagree contradicts the + reason its band is that tight. + + The exemption is narrow on purpose: a cell is skipped only when a + ``probe_overrides`` entry has already taken it out of the gating family + under *every* profile, which the override schema forces to carry checked + evidence with it. Anything else still fails, and + ``ProbeOverrideTests.test_a_nondeterministic_gating_cell_cannot_be_pinned`` + proves this rule can still refuse an artifact. + """ artifact = json.loads(DEFAULT_ARTIFACT.read_text(encoding="utf-8")) + profiles = tolerances_from_json(artifact["tolerances"]) + overrides = probe_overrides_from_json(artifact["tolerances"]) + checked = 0 for name, entry in artifact["probes"].items(): for metric in ("heap_used_bytes", "heap_total_bytes"): + if not gated_anywhere(profiles, overrides, name, metric): + continue + checked += 1 self.assertEqual( entry["metrics"][metric]["spread"], 0, f"{name}.{metric} was not deterministic when pinned; " "it must not be in a gating family", ) + self.assertGreater(checked, 0, "no gating retention cell was checked at all") + + def test_shipped_overrides_name_probes_that_exist(self): + # An exclusion that matches nothing must be deleted, not left behind to + # outlive its reason. + artifact = json.loads(DEFAULT_ARTIFACT.read_text(encoding="utf-8")) + for probe in probe_overrides_from_json(_shipped_tolerances()): + self.assertIn(probe, artifact["probes"]) + + def test_artifact_embeds_the_shipped_tolerances(self): + """The gate reads the artifact's copy, so a drifted tolerances.json is a lie. + + ``evaluate`` takes its bands from ``baseline["tolerances"]``, not from + the file. Editing the file without re-pinning would leave the gate + running the old bands while the file claims otherwise — a gate measuring + something other than what its configuration says. + """ + artifact = json.loads(DEFAULT_ARTIFACT.read_text(encoding="utf-8")) + self.assertEqual( + artifact["tolerances"], + _shipped_tolerances(), + "benchmarks/gc_ratchet/tolerances.json and the copy embedded in the pinned " + "artifact disagree; re-pin, or sync the artifact deliberately", + ) def test_tampered_summary_is_rejected(self): artifact = json.loads(DEFAULT_ARTIFACT.read_text(encoding="utf-8")) @@ -390,5 +490,149 @@ def test_probe_without_a_collection_cannot_be_pinned(self): validate_artifact(tampered) +class ProbeOverrideTests(unittest.TestCase): + """Per-probe exclusions: they must work, and they must not become a back door. + + The mechanism exists because ``tolerances.json`` was keyed per metric per + profile, so the only way to stop gating one non-deterministic cell was to + stop gating that metric on all twelve probes (#7554). The risk it introduces + is obvious — an exclusion is a hole in a gate — so most of these tests are + about the ways an exclusion is refused. + """ + + def test_an_overridden_cell_cannot_fail_the_job(self): + baseline = _baseline(_pair(), _with_override()) + current = _measurement(_pair({"heap_used_bytes": 9_000_000.0})) + rows, failures = evaluate(baseline, current, profile="shared_ci") + self.assertEqual(_hard(failures), []) + row = next(r for r in rows if r.probe == "01_probe" and r.metric == "heap_used_bytes") + self.assertFalse(row.gating) + # Excluded, not dropped: the breach is still measured and still shown. + self.assertEqual(row.status, "drift (informational)") + + def test_an_overridden_cell_is_still_reported_with_its_reason(self): + baseline = _baseline(_pair(), _with_override()) + rows, _ = evaluate(baseline, _measurement(_pair()), profile="shared_ci") + report = render(rows, baseline, "shared_ci") + self.assertIn("excluded from the gating family", report) + self.assertIn("01_probe`.heap_used_bytes", report) + self.assertIn("measured non-deterministic on this workload", report) + self.assertIn("21 runs", report) + + def test_an_override_does_not_leak_to_other_probes(self): + # The whole point: the other probes keep gating the same metric. + baseline = _baseline(_pair(), _with_override()) + current = _measurement(_pair(other_overrides={"heap_used_bytes": 9_000_000.0})) + _, failures = evaluate(baseline, current, profile="shared_ci") + joined = " ".join(_hard(failures)) + self.assertIn("02_other", joined) + self.assertIn("heap_used_bytes", joined) + + def test_an_override_does_not_leak_to_other_metrics(self): + baseline = _baseline(_pair(), _with_override()) + current = _measurement(_pair({"heap_total_bytes": 90_000_000.0})) + _, failures = evaluate(baseline, current, profile="shared_ci") + self.assertTrue(any("heap_total_bytes" in failure for failure in _hard(failures))) + + def test_an_override_may_not_re_gate(self): + entry = _override_entry() + entry["gating"] = True + with self.assertRaises(RatchetError) as caught: + tolerances_from_json(_with_override(entry=entry)) + self.assertIn("only set gating to false", str(caught.exception)) + + def test_an_override_needs_a_rationale(self): + entry = _override_entry() + entry["rationale"] = " " + with self.assertRaises(RatchetError) as caught: + tolerances_from_json(_with_override(entry=entry)) + self.assertIn("silent exclusion", str(caught.exception)) + + def test_an_override_needs_evidence(self): + entry = _override_entry() + del entry["evidence"] + with self.assertRaises(RatchetError): + tolerances_from_json(_with_override(entry=entry)) + + def test_an_override_needs_enough_runs_behind_it(self): + entry = _override_entry() + entry["evidence"]["observed_runs"] = MIN_EXCLUSION_RUNS - 1 + with self.assertRaises(RatchetError) as caught: + tolerances_from_json(_with_override(entry=entry)) + self.assertIn("cannot distinguish", str(caught.exception)) + + def test_an_override_needs_a_non_zero_observed_spread(self): + # Excluding a metric that was measured as deterministic is unjustified: + # nothing has been shown to be ungateable. + entry = _override_entry() + entry["evidence"]["observed_spread"] = 0 + with self.assertRaises(RatchetError) as caught: + tolerances_from_json(_with_override(entry=entry)) + self.assertIn("must stay gated", str(caught.exception)) + + def test_an_override_for_an_unknown_metric_is_rejected(self): + with self.assertRaises(RatchetError): + tolerances_from_json(_with_override(metric="heap_used_byte")) + + def test_an_override_that_matches_no_probe_is_rejected(self): + artifact = _baseline(tolerances=_with_override(probe="99_does_not_exist")) + with self.assertRaises(RatchetError) as caught: + validate_artifact(artifact) + self.assertIn("must be deleted", str(caught.exception)) + + def test_overriding_every_probe_is_rejected(self): + # One cell at a time, this would achieve exactly what "gating": false at + # profile level does, without saying so where a reader would look. + payload = _tolerances() + payload["probe_overrides"] = { + probe: {"heap_used_bytes": _override_entry()} for probe in ("01_probe", "02_other") + } + with self.assertRaises(RatchetError) as caught: + validate_artifact(_baseline(_pair(), payload)) + self.assertIn("can never fail", str(caught.exception)) + + def test_a_mistyped_section_is_rejected_rather_than_ignored(self): + payload = _tolerances() + payload["probe_override"] = {"01_probe": {"heap_used_bytes": _override_entry()}} + with self.assertRaises(RatchetError) as caught: + tolerances_from_json(payload) + self.assertIn("unknown top-level section", str(caught.exception)) + + def test_a_nondeterministic_gating_cell_cannot_be_pinned(self): + """The assertion that caught #7554, now enforced at pinning time. + + It used to live only in the unit tests, so an artifact carrying a + non-deterministic gating cell could be written and committed; the test + then failed in the CI step that runs *before* the measurement step, and + the ratchet measured nothing for two days. + """ + for metric in DETERMINISTIC_METRICS: + with self.subTest(metric=metric): + probes = _probe() + probes["01_probe"]["metrics"][metric] = distribution( + [BASE_VALUES[metric]] * 6 + [BASE_VALUES[metric] + 6768] + ) + with self.assertRaises(RatchetError) as caught: + validate_artifact(_baseline(probes)) + self.assertIn("bit-identity", str(caught.exception)) + + def test_the_same_cell_may_be_pinned_once_it_is_excluded(self): + probes = _pair() + probes["01_probe"]["metrics"]["heap_used_bytes"] = distribution( + [BASE_VALUES["heap_used_bytes"]] * 6 + [BASE_VALUES["heap_used_bytes"] + 6768] + ) + validate_artifact(_baseline(probes, _with_override())) + + def test_memory_and_timing_spread_is_still_allowed(self): + # Only the bit-identical families are held to spread 0; RSS and wall + # time have declared noise floors and must not be caught by this rule. + probes = _probe() + for metric in ("rss_bytes", "peak_rss_bytes", "wall_ms"): + probes["01_probe"]["metrics"][metric] = distribution( + [BASE_VALUES[metric]] * 6 + [BASE_VALUES[metric] * 1.01] + ) + validate_artifact(_baseline(probes)) + + if __name__ == "__main__": unittest.main() From be1fc80f85723e4f9f9ebc3654c01598a0ee6a7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 7 Aug 2026 03:11:20 +0200 Subject: [PATCH 2/6] test(gc-ratchet): fail a probe whose current run collected nothing Six of the twelve probes pin minor_cycles at 1 and the allowance floor is also 1, so a collapse from 1 to 0 landed on delta == -allowance and scored ok. A collector that stops running copying minors -- the largest regression this ratchet exists to catch -- was reported as passing. Assert liveness instead of inferring it from the bands, and document probe_overrides. Refs #7554 --- benchmarks/gc_ratchet/README.md | 75 +++++++++++++++++++++++++++++ benchmarks/gc_ratchet/gc_ratchet.py | 22 +++++++++ tests/test_gc_ratchet.py | 23 +++++++++ 3 files changed, 120 insertions(+) diff --git a/benchmarks/gc_ratchet/README.md b/benchmarks/gc_ratchet/README.md index f581b39dd9..2c48def527 100644 --- a/benchmarks/gc_ratchet/README.md +++ b/benchmarks/gc_ratchet/README.md @@ -190,6 +190,81 @@ collector, not a score. A collector that suddenly copies fewer objects has changed — plausibly because objects are now pinned — and must be re-pinned deliberately rather than silently congratulated. +## Taking one cell out of the gating family (`probe_overrides`) + +`tolerances.json` is keyed per metric per profile. That is the right +granularity for a *band*, which expresses a machine class's noise floor. It is +the wrong granularity for "can this metric carry a gate at all on this +workload", which is a property of the workload — and the two were conflated +until #7554. + +The symptom: `12_large_live_set` retention stopped being bit-identical, the +pinned artifact recorded a non-zero spread, and the assertion that refuses to +gate a metric on a spread it cannot support fired — **in the CI step that runs +before the measurement step**, so all twelve probes stopped running on every +branch for two days. The prescribed fix, "take this metric out of the gating +family for this probe", could not be expressed: the only lever turned +`heap_used_bytes` gating off for all twelve. + +So `tolerances.json` has a `probe_overrides` section: + +```json +"probe_overrides": { + "12_large_live_set": { + "heap_used_bytes": { + "gating": false, + "rationale": "NOT GATED ON THIS PROBE. …why…", + "evidence": { + "observed_runs": 21, + "observed_spread": 4536, + "measured_on": "…host, commit, build…", + "issue": "https://github.com/PerryTS/perry/issues/7554" + } + } + } +} +``` + +Deliberate properties, each of them a refusal: + +- **It may only set `gating` to `false`.** An override exists to remove a cell + from a gating family. Putting one back is the profile's job, where a reader + looking for what is gated will find it. +- **It never touches the band.** An excluded cell is still measured, still + compared, and still printed — a breach shows as `drift (informational)` + rather than vanishing. `check` also prints every override with its reason + under the table, so a `no` in the Gating column can be explained without + opening another file. +- **The evidence is checked, not merely stored.** At least 21 runs — the same + number every band in the file is justified by — and a spread that is + actually non-zero. You cannot exclude a metric you have not shown is + ungateable. +- **An override that matches no probe fails**, the same rule + `scripts/gc_root_dominance_allowlist.json` carries. Fixing the + non-determinism means deleting the entry, not leaving it to outlive its + reason. +- **Overriding every probe for a metric fails.** Assembled one cell at a time, + that is a profile-level `"gating": false` with nowhere to read the reason. + +The bit-identity rule itself now lives in `validate_artifact`, so an artifact +carrying a non-deterministic gating cell cannot be *pinned*. Before #7554 the +rule existed only in `tests/test_gc_ratchet.py`, which is why a bad pin could +be committed and only wedge CI afterwards. + +`heap_total_bytes`, `minor_cycles`, `copied_objects`, `promoted_bytes` and +`freed_bytes` on `12_large_live_set` all remain gating, so a real over-retention +regression on that probe still goes red. + +### The measurement must show the collector ran + +`check` fails a probe whose current run reports `minor_cycles == 0` or +`copied_objects == 0` where the baseline reports more, rather than leaving that +to the tolerance arithmetic. The arithmetic could not catch it: six probes pin +`minor_cycles` at 1 and the allowance floor is also 1, so a collapse from 1 to 0 +is `delta == -allowance` and scored `ok`. The largest regression this ratchet +exists to catch — a collector that stops running copying minors — was being +reported as passing. + ## Running it Checking on the pinned quiet host, with memory and time gated: diff --git a/benchmarks/gc_ratchet/gc_ratchet.py b/benchmarks/gc_ratchet/gc_ratchet.py index c484332d9c..9d24228a4b 100644 --- a/benchmarks/gc_ratchet/gc_ratchet.py +++ b/benchmarks/gc_ratchet/gc_ratchet.py @@ -1006,6 +1006,28 @@ def evaluate( reason = correctness.get("reason") or "no correctness report" failures.append(f"{name}: correctness was not verified against the Node oracle ({reason})") + # Liveness, asserted rather than inferred from the bands. A ratchet over + # the evacuating minor is meaningless if the evacuating minor did not + # run, and the tolerance arithmetic cannot be relied on to notice: six + # of the twelve probes pin `minor_cycles` at 1, where the allowance + # floor is also 1, so a collapse from 1 to 0 is `delta == -allowance` + # and scores "ok". A probe that stopped collecting would have been + # reported as passing — CLAUDE.md's fourth failure mode (the gate runs + # but its subject never did) sitting inside the gate meant to close it. + for metric, what in ( + ("minor_cycles", "ran no minor collection"), + ("copied_objects", "evacuated nothing"), + ): + if base_entry["metrics"][metric]["median"] > 0 and ( + cur_entry["metrics"][metric]["median"] <= 0 + ): + failures.append( + f"{name}: {what} in this run ({metric} " + f"{base_entry['metrics'][metric]['median']:,.0f} -> 0). The baseline it is " + "being compared against measures a collector that did; there is nothing " + "here to compare." + ) + for metric in ALL_METRICS: tolerance = resolve_tolerance(tolerances, overrides, name, metric) base_median = float(base_entry["metrics"][metric]["median"]) diff --git a/tests/test_gc_ratchet.py b/tests/test_gc_ratchet.py index 5213cde198..9db69aa8b9 100644 --- a/tests/test_gc_ratchet.py +++ b/tests/test_gc_ratchet.py @@ -288,6 +288,29 @@ def test_reclaiming_less_fails(self): _, failures = evaluate(baseline, current, profile="shared_ci") self.assertTrue(any("freed_bytes" in failure for failure in _hard(failures))) + def test_a_probe_that_stopped_collecting_fails(self): + """The bands alone could not catch this, which is why it is asserted. + + Six of the twelve shipped probes pin ``minor_cycles`` at 1 and the + allowance floor is also 1, so a collapse from 1 to 0 lands exactly on + ``delta == -allowance`` and scored "ok". A collector that stopped + running copying minors — the largest regression this ratchet exists to + catch — was reported as passing. + """ + baseline = _baseline(_probe(overrides={"minor_cycles": 1.0})) + current = _measurement(_probe(overrides={"minor_cycles": 0.0})) + _, failures = evaluate(baseline, current, profile="shared_ci") + self.assertTrue( + any("ran no minor collection" in failure for failure in _hard(failures)), + "a probe that ran no collection at all was not reported", + ) + + def test_a_probe_that_evacuated_nothing_fails(self): + baseline = _baseline() + current = _measurement(_probe(overrides={"copied_objects": 0.0})) + _, failures = evaluate(baseline, current, profile="shared_ci") + self.assertTrue(any("evacuated nothing" in failure for failure in _hard(failures))) + def test_missing_probe_fails_instead_of_being_skipped(self): baseline = _baseline() current = _measurement({}) From 6fc7ed15f44bd95efe284acf00147a176095f70e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 7 Aug 2026 03:19:15 +0200 Subject: [PATCH 3/6] docs(gc-ratchet): record the measured cause of 12_large_live_set's spread The rationale first written for the override named the evacuation policy's RSS and pause thresholds. That was a hypothesis and it is wrong -- no [gc-evac-policy] line is ever emitted on this probe. The measured cause is the conservative stack scan the probe's own explicit gc() forces: diffing two disagreeing traces shows every minor, tenuring decision, step cycle and copy/promote counter matching exactly, with the sole difference in the last mark-sweep's freed_bytes, and PERRY_CONSERVATIVE_STACK_SCAN=off makes the probe bit-identical at 51,668,688 bytes over 8 runs. Refs #7554 --- benchmarks/gc_ratchet/README.md | 23 +++++++++++++++++++ .../gc_ratchet/baseline/gc-ratchet-v1.json | 8 +++---- benchmarks/gc_ratchet/tolerances.json | 8 +++---- 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/benchmarks/gc_ratchet/README.md b/benchmarks/gc_ratchet/README.md index 2c48def527..1f9d698f34 100644 --- a/benchmarks/gc_ratchet/README.md +++ b/benchmarks/gc_ratchet/README.md @@ -255,6 +255,29 @@ be committed and only wedge CI afterwards. `freed_bytes` on `12_large_live_set` all remain gating, so a real over-retention regression on that probe still goes red. +### What that probe's non-determinism actually is + +Worth knowing, because it is a property of the *metric* rather than of the +collector's steady state. Every probe reads `process.memoryUsage()` after an +explicit `gc()`, and an explicit `gc()` runs a full mark-sweep with a **forced +conservative stack scan** — `PERRY_GC_DIAG` prints `[gc-scan-fallback] +site=manual_collect automatic=false` on every run. A conservative scan retains +whatever the native stack happens to look like a pointer to, and the stack +residue at that moment differs between runs. + +Diffing two full traces that disagree shows this directly: the minors, the +tenuring decisions, the step cycles and every copy/promote counter match +exactly, and the only difference is in the *last* collection's `freed_bytes`. +And with `PERRY_CONSERVATIVE_STACK_SCAN=off` the probe reports **51,668,688 +bytes on 8 consecutive runs, bit-identical**. + +Two things follow. The variance is entirely false roots, so it is bounded by +how much a handful of stale stack words can pin — a few kilobytes here. And +the conservative scan is retaining **8.28 MB, 16% of this probe's reported +retention**, systematically. The eleven small probes stay bit-identical because +their live sets are one to two orders of magnitude smaller, so a stale stack +word is far less likely to alias a plausible heap address at all. + ### The measurement must show the collector ran `check` fails a probe whose current run reports `minor_cycles == 0` or diff --git a/benchmarks/gc_ratchet/baseline/gc-ratchet-v1.json b/benchmarks/gc_ratchet/baseline/gc-ratchet-v1.json index 0d62a3bb24..06a7efed76 100644 --- a/benchmarks/gc_ratchet/baseline/gc-ratchet-v1.json +++ b/benchmarks/gc_ratchet/baseline/gc-ratchet-v1.json @@ -278,11 +278,11 @@ "12_large_live_set": { "heap_used_bytes": { "gating": false, - "rationale": "NOT GATED ON THIS PROBE. Retention here is genuinely sample-dependent, so it cannot carry a band whose premise is bit-identity. The cause is in the collector, not the harness: the evacuation policy (crates/perry-runtime/src/gc/oldgen.rs) thresholds on two measured, machine-dependent inputs -- process RSS read from the OS at decision time, and the previous cycle's pause in microseconds against MAX_PREVIOUS_PAUSE_US. Both are step functions, so a run that lands on the other side of a threshold compacts a different amount and retains a different amount. The other eleven probes hold small live sets and never approach either threshold, which is why they remain bit-identical and stay gated; 12_large_live_set is the only probe with a ~100 MB live set and multi-millisecond pauses. Retention on this probe is still measured, still compared against the baseline, and still printed as an informational drift row -- and heap_total_bytes, minor_cycles, copied_objects, promoted_bytes and freed_bytes on this same probe remain gating, so a real over-retention regression here still goes red.", + "rationale": "NOT GATED ON THIS PROBE. Retention here is genuinely sample-dependent, so it cannot carry a band whose premise is bit-identity. The cause is the CONSERVATIVE STACK SCAN that the probe's own explicit gc() forces (PERRY_GC_DIAG prints '[gc-scan-fallback] site=manual_collect automatic=false' on every run). A conservative scan retains whatever the native stack happens to look like a pointer to, and stack residue at the moment of that final collection differs run to run. Every earlier phase is bit-identical -- diffing two full traces that disagree shows the minors, the tenuring decisions, the step cycles and every copy/promote counter matching exactly, with the ONLY difference in the last full mark-sweep's freed_bytes. Proof: with PERRY_CONSERVATIVE_STACK_SCAN=off this probe reports 51,668,688 bytes on 8 consecutive runs, bit-identical, so the residual variance is entirely false roots. The other eleven probes hold live sets one to two orders of magnitude smaller, where a stale stack word is far less likely to alias a plausible heap address, which is why they stay bit-identical and stay gated. Retention here is still measured, still compared against the baseline, and still printed as an informational drift row -- and heap_total_bytes, minor_cycles, copied_objects, promoted_bytes and freed_bytes on this same probe remain gating, so a real over-retention regression still goes red.", "evidence": { - "observed_runs": 21, - "observed_spread": 4536, - "measured_on": "2026-08-06, pinned quiet host perry-macos (Mac mini M1, 8 GB, Spotlight disabled), main @ 52f7dae1f, release build: 5 distinct values across 21 runs (42612752 42613184 42613328 42614048 42617288). The 2026-08-05 pin saw the same effect as one outlier in seven (+6768 B).", + "observed_runs": 36, + "observed_spread": 9072, + "measured_on": "2026-08-06/07, three independent reproductions at main @ be1fc80f8 (0.5.1315): pinned quiet host perry-macos (Mac mini M1, 8 GB, load 2.2) spread 2,304 over 7 harness repeats while all eleven other probes were spread 0; a MacBook Pro under load 21 spread 9,072 over 7 (59943752 59943752 59943896 59951744 59949656 59952824 59950520); 22 ad-hoc runs of the same binary spanning 59,943,080 to 59,952,824. The 2026-08-05 pin saw the same effect as one outlier in seven (+6,768 B).", "issue": "https://github.com/PerryTS/perry/issues/7554" } } diff --git a/benchmarks/gc_ratchet/tolerances.json b/benchmarks/gc_ratchet/tolerances.json index 65be3a4486..9050e10a34 100644 --- a/benchmarks/gc_ratchet/tolerances.json +++ b/benchmarks/gc_ratchet/tolerances.json @@ -207,11 +207,11 @@ "12_large_live_set": { "heap_used_bytes": { "gating": false, - "rationale": "NOT GATED ON THIS PROBE. Retention here is genuinely sample-dependent, so it cannot carry a band whose premise is bit-identity. The cause is in the collector, not the harness: the evacuation policy (crates/perry-runtime/src/gc/oldgen.rs) thresholds on two measured, machine-dependent inputs -- process RSS read from the OS at decision time, and the previous cycle's pause in microseconds against MAX_PREVIOUS_PAUSE_US. Both are step functions, so a run that lands on the other side of a threshold compacts a different amount and retains a different amount. The other eleven probes hold small live sets and never approach either threshold, which is why they remain bit-identical and stay gated; 12_large_live_set is the only probe with a ~100 MB live set and multi-millisecond pauses. Retention on this probe is still measured, still compared against the baseline, and still printed as an informational drift row -- and heap_total_bytes, minor_cycles, copied_objects, promoted_bytes and freed_bytes on this same probe remain gating, so a real over-retention regression here still goes red.", + "rationale": "NOT GATED ON THIS PROBE. Retention here is genuinely sample-dependent, so it cannot carry a band whose premise is bit-identity. The cause is the CONSERVATIVE STACK SCAN that the probe's own explicit gc() forces (PERRY_GC_DIAG prints '[gc-scan-fallback] site=manual_collect automatic=false' on every run). A conservative scan retains whatever the native stack happens to look like a pointer to, and stack residue at the moment of that final collection differs run to run. Every earlier phase is bit-identical -- diffing two full traces that disagree shows the minors, the tenuring decisions, the step cycles and every copy/promote counter matching exactly, with the ONLY difference in the last full mark-sweep's freed_bytes. Proof: with PERRY_CONSERVATIVE_STACK_SCAN=off this probe reports 51,668,688 bytes on 8 consecutive runs, bit-identical, so the residual variance is entirely false roots. The other eleven probes hold live sets one to two orders of magnitude smaller, where a stale stack word is far less likely to alias a plausible heap address, which is why they stay bit-identical and stay gated. Retention here is still measured, still compared against the baseline, and still printed as an informational drift row -- and heap_total_bytes, minor_cycles, copied_objects, promoted_bytes and freed_bytes on this same probe remain gating, so a real over-retention regression still goes red.", "evidence": { - "observed_runs": 21, - "observed_spread": 4536, - "measured_on": "2026-08-06, pinned quiet host perry-macos (Mac mini M1, 8 GB, Spotlight disabled), main @ 52f7dae1f, release build: 5 distinct values across 21 runs (42612752 42613184 42613328 42614048 42617288). The 2026-08-05 pin saw the same effect as one outlier in seven (+6768 B).", + "observed_runs": 36, + "observed_spread": 9072, + "measured_on": "2026-08-06/07, three independent reproductions at main @ be1fc80f8 (0.5.1315): pinned quiet host perry-macos (Mac mini M1, 8 GB, load 2.2) spread 2,304 over 7 harness repeats while all eleven other probes were spread 0; a MacBook Pro under load 21 spread 9,072 over 7 (59943752 59943752 59943896 59951744 59949656 59952824 59950520); 22 ad-hoc runs of the same binary spanning 59,943,080 to 59,952,824. The 2026-08-05 pin saw the same effect as one outlier in seven (+6,768 B).", "issue": "https://github.com/PerryTS/perry/issues/7554" } } From 832bdc0a212eeae7042e5bbbe56e38df0fb2e5d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 7 Aug 2026 03:20:51 +0200 Subject: [PATCH 4/6] docs(changelog): add the #7557 fragment Refs #7554 --- .../7557-gc-ratchet-per-probe-gating.md | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 changelog.d/7557-gc-ratchet-per-probe-gating.md diff --git a/changelog.d/7557-gc-ratchet-per-probe-gating.md b/changelog.d/7557-gc-ratchet-per-probe-gating.md new file mode 100644 index 0000000000..3a508bbf1a --- /dev/null +++ b/changelog.d/7557-gc-ratchet-per-probe-gating.md @@ -0,0 +1,95 @@ +### Fixed + +- **`gc-ratchet` had measured nothing since 2026-08-05 (#7554).** + `test_pinned_artifact_retention_is_deterministic` failed in the *"Harness unit + tests and artifact validation"* step, which runs **before** the measurement + step, so all twelve probes were skipped on every branch — and `gc-ratchet` is + not a required context, so the red blocked nothing. Roughly fifteen + GC-affecting changes landed on 2026-08-06 with no standing gate over them. + + The assertion was right: `12_large_live_set` retention really had stopped + being bit-identical, and `tolerances.json` justifies gating `heap_used_bytes` + on an observed spread of 0.000%, explicitly as anti-brittleness margin rather + than a noise allowance. What was missing was the lever the assertion's own + message points at. `tolerances.json` is keyed per metric per profile, so + "take this metric out of the gating family for this probe" could only be said + by turning `heap_used_bytes` gating off for all twelve. + + `tolerances.json` gains a `probe_overrides` section that removes one + `(probe, metric)` cell from the gating family. A band expresses a machine + class's noise floor, which is per profile; whether a metric is deterministic + enough to gate at all is a property of the workload, which is per probe. + Because an exclusion is a hole in a gate, every property of the mechanism is + a refusal: it may only set `gating` to `false`, never back to `true`; it never + touches the band, so an excluded cell is still measured, still compared, and + still printed as `drift (informational)`, with its full reason printed under + the table on every run; its evidence is checked rather than stored (at least + 21 runs — the same number every band in the file rests on — and a spread that + is actually non-zero, so a metric cannot be excluded without having been shown + ungateable); an override that matches no probe **fails**, the rule + `scripts/gc_root_dominance_allowlist.json` already carries; and an override + set that covers every probe for a metric fails, because assembled one cell at + a time that is a profile-level `"gating": false` with nowhere to read the + reason. + + The bit-identity rule itself moves from the unit tests into + `validate_artifact`, so an artifact carrying a non-deterministic gating cell + can no longer be *pinned*. Had it lived there on 2026-08-05, the re-pin would + have failed on the maintainer's machine at the moment the judgement was made, + instead of silently wedging CI afterwards. + +- **A probe that stopped collecting was reported as passing.** `check` now fails + a probe whose current run reports `minor_cycles == 0` or `copied_objects == 0` + where the baseline reports more, instead of leaving that to the tolerance + arithmetic. The arithmetic could not catch it: six of the twelve probes pin + `minor_cycles` at 1 and the allowance floor is also 1, so a collapse from 1 to + 0 is `delta == -allowance` and scored `ok`. A collector that stops running + copying minors is the largest regression this ratchet exists to catch, and it + was the one shape the gate could not see — CLAUDE.md's fourth failure mode + inside the gate built to close it. + +### Measured + +- **The probes run again.** Full `measure` + `check`, twelve probes each, on two + machine classes: the pinned quiet host (`perry-macos`, M1 mini, load 2.2, + `pinned_host` profile) and a MacBook Pro under load 21 (`shared_ci`). All + twenty-four probe runs compiled, ran, and passed their Node-oracle diff, and + retention and the GC counters reproduced bit-for-bit across the two hosts. + + Both arms report the same ten gating breaches, invisible since 2026-08-05. + Most read as improvements wearing a two-sided band — `03_cross_gen_writes` and + `04_dead_after_deep_stack` shed 40–95% of their copy/promote work while their + retention *fell* 49% and 22%, so objects that used to be copied and tenured + are now recognised as dead. Two are not: `05_closure_capture` retains + **+16.44%** with `copied_objects`, `copied_bytes`, `promoted_*` and + `freed_bytes` all at `+0.00%` — the same collector work, more retained — and + `02_survivor_promotion` is +2.77% on the same shape. This change deliberately + does **not** re-pin: re-pinning to turn a red gate green is what the artifact + exists to prevent, and those two want a look before they are accepted. + +- **`12_large_live_set`'s non-determinism is the conservative stack scan, not + the collector's steady state.** Every probe reads `process.memoryUsage()` + after an explicit `gc()`, and an explicit `gc()` runs a full mark-sweep with a + forced conservative stack scan (`[gc-scan-fallback] site=manual_collect + automatic=false`, printed on every run). Such a scan retains whatever the + native stack looks like a pointer to, and stack residue differs run to run. + Diffing two disagreeing `PERRY_GC_DIAG` traces shows it exactly: the minors, + the tenuring decisions, the step cycles and every copy/promote counter match, + and the sole difference is the *last* collection's `freed_bytes`. Under + `PERRY_CONSERVATIVE_STACK_SCAN=off` the probe reports **51,668,688 bytes on 8 + consecutive runs, bit-identical**, against 59,943,080–59,952,824 by default. + So the variance is entirely false roots, and the conservative scan is + systematically retaining **8.28 MB — 16% of this probe's reported retention**. + The eleven small probes stay bit-identical because their live sets are one to + two orders of magnitude smaller, so a stale stack word is far less likely to + alias a plausible heap address. + +- **No 29% retention win.** The pinned artifact was captured on + `perry-macos.fritz.box` — the *same* Mac mini as the 21-run experiment in + #7554, so there was no host difference to control for. Measured with the + harness's own protocol, `12_large_live_set.heap_used_bytes` is 59,943,824 at + the pinned `5e236e6e2` (2026-08-05), 59,943,224–59,949,920 at `52f7dae1f` (the + commit the 42.6 MB reading was taken at), and 59,943,896 at current `main` on + the pinned host. Retention on that probe has not moved across the whole + 2026-08-06 batch. `PERRY_GC_DIAG=1` does not change it — the harness's + traced/untraced split still holds — and neither does the auto-optimizer. From 0c22a675b2bbde34a030a637b3f4030907c093ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 7 Aug 2026 03:22:29 +0200 Subject: [PATCH 5/6] docs(gc-ratchet): point the override's evidence at the cause issue Refs #7554, #7558 --- benchmarks/gc_ratchet/baseline/gc-ratchet-v1.json | 2 +- benchmarks/gc_ratchet/tolerances.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/benchmarks/gc_ratchet/baseline/gc-ratchet-v1.json b/benchmarks/gc_ratchet/baseline/gc-ratchet-v1.json index 06a7efed76..34232fe49e 100644 --- a/benchmarks/gc_ratchet/baseline/gc-ratchet-v1.json +++ b/benchmarks/gc_ratchet/baseline/gc-ratchet-v1.json @@ -283,7 +283,7 @@ "observed_runs": 36, "observed_spread": 9072, "measured_on": "2026-08-06/07, three independent reproductions at main @ be1fc80f8 (0.5.1315): pinned quiet host perry-macos (Mac mini M1, 8 GB, load 2.2) spread 2,304 over 7 harness repeats while all eleven other probes were spread 0; a MacBook Pro under load 21 spread 9,072 over 7 (59943752 59943752 59943896 59951744 59949656 59952824 59950520); 22 ad-hoc runs of the same binary spanning 59,943,080 to 59,952,824. The 2026-08-05 pin saw the same effect as one outlier in seven (+6,768 B).", - "issue": "https://github.com/PerryTS/perry/issues/7554" + "issue": "https://github.com/PerryTS/perry/issues/7554 (the gate), https://github.com/PerryTS/perry/issues/7558 (the conservative-scan cause)" } } } diff --git a/benchmarks/gc_ratchet/tolerances.json b/benchmarks/gc_ratchet/tolerances.json index 9050e10a34..7e3da89f94 100644 --- a/benchmarks/gc_ratchet/tolerances.json +++ b/benchmarks/gc_ratchet/tolerances.json @@ -212,7 +212,7 @@ "observed_runs": 36, "observed_spread": 9072, "measured_on": "2026-08-06/07, three independent reproductions at main @ be1fc80f8 (0.5.1315): pinned quiet host perry-macos (Mac mini M1, 8 GB, load 2.2) spread 2,304 over 7 harness repeats while all eleven other probes were spread 0; a MacBook Pro under load 21 spread 9,072 over 7 (59943752 59943752 59943896 59951744 59949656 59952824 59950520); 22 ad-hoc runs of the same binary spanning 59,943,080 to 59,952,824. The 2026-08-05 pin saw the same effect as one outlier in seven (+6,768 B).", - "issue": "https://github.com/PerryTS/perry/issues/7554" + "issue": "https://github.com/PerryTS/perry/issues/7554 (the gate), https://github.com/PerryTS/perry/issues/7558 (the conservative-scan cause)" } } } From 2ae1b90f7c125604c5f82eb926e926ba3f640d14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 7 Aug 2026 03:26:12 +0200 Subject: [PATCH 6/6] chore: bump version to 0.5.1316 --- CLAUDE.md | 2 +- Cargo.lock | 152 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 3 files changed, 78 insertions(+), 78 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7da6476bb3..080b6602bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1315 +**Current Version:** 0.5.1316 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 9c9ddce401..ba6fad036f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5547,7 +5547,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "anyhow", "base64", @@ -5607,14 +5607,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "cc", "libc", @@ -5622,7 +5622,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "anyhow", "inkwell", @@ -5639,7 +5639,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "anyhow", "perry-hir", @@ -5647,7 +5647,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "anyhow", "perry-hir", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "anyhow", "perry-dispatch", @@ -5664,7 +5664,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "anyhow", "perry-hir", @@ -5672,7 +5672,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "anyhow", "base64", @@ -5684,7 +5684,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "anyhow", "perry-hir", @@ -5692,7 +5692,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "anyhow", "async-trait", @@ -5721,14 +5721,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "serde", "serde_json", @@ -5736,7 +5736,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1315" +version = "0.5.1316" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5747,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "anyhow", "clap", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "block2", "objc2", @@ -5772,7 +5772,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "argon2", "perry-ffi", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "perry-ffi", "reqwest", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "bcrypt", "perry-ffi", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "perry-ffi", "rusqlite", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "perry-ffi", "scraper", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "perry-ffi", "perry-runtime", @@ -5821,7 +5821,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "chrono", "cron", @@ -5831,7 +5831,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "chrono", "perry-ffi", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "perry-ffi", "rust_decimal", @@ -5847,7 +5847,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "perry-ffi", "serde_json", @@ -5855,7 +5855,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5863,7 +5863,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "perry-ffi", "perry-runtime", @@ -5871,14 +5871,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "bytes", "http-body-util", @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "bytes", "lazy_static", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "bytes", "h2", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "lazy_static", "perry-ffi", @@ -5943,7 +5943,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "base64", "jsonwebtoken", @@ -5954,7 +5954,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "lru", "perry-ffi", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "chrono", "perry-ffi", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "bson", "futures-util", @@ -5983,7 +5983,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "chrono", "perry-ffi", @@ -5993,7 +5993,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "nanoid", "perry-ffi", @@ -6002,7 +6002,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "bytes", "perry-ffi", @@ -6015,7 +6015,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -6034,7 +6034,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "lettre", "perry-ffi", @@ -6044,7 +6044,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "perry-ffi", "printpdf", @@ -6052,7 +6052,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "perry-ffi", "sqlx", @@ -6061,7 +6061,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "governor", "perry-ffi", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "fast_image_resize", "image", @@ -6079,14 +6079,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "lazy_static", "perry-ffi", @@ -6095,7 +6095,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "perry-ffi", "perry-runtime", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "perry-ffi", "uuid", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "perry-ffi", "regex", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "futures-util", "lazy_static", @@ -6135,7 +6135,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "brotli", "flate2", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "dashmap", "once_cell", @@ -6154,7 +6154,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "anyhow", "perry-api-manifest", @@ -6172,7 +6172,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "anyhow", "perry-diagnostics", @@ -6184,7 +6184,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "anyhow", "base64", @@ -6226,14 +6226,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6328,14 +6328,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "anyhow", "perry-hir", @@ -6344,14 +6344,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "base64", "itoa", @@ -6368,7 +6368,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "rand 0.10.1", "serde", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6401,7 +6401,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "base64", "block2", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "base64", "block2", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1315" +version = "0.5.1316" [[package]] name = "perry-ui-test" @@ -6443,11 +6443,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1315" +version = "0.5.1316" [[package]] name = "perry-ui-tvos" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "base64", "block2", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "base64", "block2", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "block2", "libc", @@ -6492,7 +6492,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "base64", "libc", @@ -6509,14 +6509,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "anyhow", "base64", @@ -6532,7 +6532,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1315" +version = "0.5.1316" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 6702578159..4ebf112fc3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -315,7 +315,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1315" +version = "0.5.1316" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry"