fix(674,688): cross-check frontmatter against PyYAML; split the ceiling calibration claim

Two defects in scripts/decisions_validate.py, fixed together because they share the
validator and its pytest suite.

#674 — the validator reported OK on frontmatter PyYAML rejects. The hand-rolled reader
is deliberately dependency-free (decisions-guard and the Husky hooks install nothing),
so it cannot see a bare apostrophe closing a single-quoted scalar. Hit twice in one
session by two independent agents. `pyyaml_frontmatter_faults()` now cross-checks the
parse against PyYAML whenever PyYAML is importable, and is SKIPPED with a ::notice::
when it is not — the read path stays dependency-free.

The two known hazards fail differently and the fix covers both: the apostrophe makes
PyYAML reject the document, while an unquoted ` #` parses fine and silently TRUNCATES
the value. So the check compares parsed results key by key rather than try/except-ing
the load, which is also what makes it generalize past the two known characters. PyYAML
wrote these files, so on disagreement it is authoritative and the file is the defect.
The comparison has one implementation, called by the validator and by the existing
test_decisions_lib agreement test, so the tool and the suite cannot drift.

#688 — test_real_corpus_ceiling_sits_at_the_TAIL_BOUNDARY asserted p90 <= 60 <= p95 in
the BLOCKING script-tests job. p90 sat exactly on the ceiling and the distribution above
it is sparse, so one ordinary record moved p90 by twenty lines and reddened CI for
whoever wrote it; it reproduced twice live (#672, #706) and both times the only in-scope
remedy was trimming the new record to fit the constant.

v5 splits the claim by robustness instead of hunting for a better single assertion. The
blocking test now asserts only the coarse, non-ratcheting property (the ceiling flags a
nonempty proper minority, 0 < fraction_over < 1/3); the fine tail-boundary claim is
measured every run and REPORTED as a ::notice::, on the same reasoning stale_records
already uses — a constant going out of date is the passage of corpus growth, not a
defect in the commit under test. The fine property is still asserted, against synthetic
distributions the test owns. The ceiling stays 60.

Verification: 424 scripts/tests pass; ruff at baseline parity (47 before and after);
the cross-check is clean on all 183 real records; a positive control pins that
record_wing_faults alone still reports both hazard files as clean, so the new red cannot
pass for the wrong reason; and a test demonstrates that appending #672's 62-line and
#687's 107-line records to the real corpus does not red the blocking property.

Docs: new record docs.frontmatter-pyyaml-crosscheck, docs.corpus-size-signal updated for
the v5 split, catalog regenerated, docs/ci-cd.md updated for both.

fixes #674
fixes #688

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-04 20:15:16 +02:00
co-authored by Claude Opus 5
parent aa79ec59c9
commit 9d2b30dc3b
7 changed files with 505 additions and 47 deletions
+17 -5
View File
@@ -47,7 +47,12 @@ active record, relocate the predecessor to `docs/decisions/archive/` with recipr
`supersedes`/`superseded-by` links), not a release-boundary batch job — most of the old "consolidate"
step is now continuous. The release boundary is instead where you:
1. Run `PYTHONPATH=. python3 scripts/decisions_validate.py` — confirms lifecycle metadata is
well-formed and every `supersedes`/`superseded-by` link resolves both ways.
well-formed and every `supersedes`/`superseded-by` link resolves both ways. Since **ersatztv#674**
it also cross-checks its dependency-free frontmatter parse against **PyYAML when PyYAML is
importable**, failing on any file PyYAML rejects (a bare apostrophe in a single-quoted value) or
reads differently (an unquoted ` #`, which YAML truncates as a comment). Where PyYAML is absent —
the `decisions-guard` job, the Husky hooks — the cross-check is **skipped with a `::notice::`**
and every other check still runs; the read path stays dependency-free.
2. Confirm every record already classified `superseded`/`retired` actually lives under
`docs/decisions/archive/` (the validator fails this, but eyeball it at the boundary too).
3. Regenerate the active catalog: `PYTHONPATH=. python3 scripts/build_decisions_catalog.py` and
@@ -56,10 +61,17 @@ step is now continuous. The release boundary is instead where you:
- a **per-record prose ceiling** (`decisions_validate.py --record-ceiling <n>`, default **60**)
— a **non-blocking `::warning::`** naming every record over it. This is the actionable signal:
it points at a file. The 60 is derived from the distribution, not picked as a round number.
A test pins what that derivation MEANS rather than any particular numbers: the ceiling must sit
between the **90th and 95th percentile** of record lengths, i.e. at the tail boundary. Stated
as percentiles it is scale-free, so ordinary corpus growth cannot ratchet it — it fires only
when the ceiling genuinely stops marking the tail and should be re-derived.
Its **calibration is guarded in two pieces of different robustness** (ersatztv#688), because
four earlier single-assertion versions all ratcheted:
- **blocking** (`script-tests`) — only the coarse property that the ceiling flags a nonempty
**proper minority** of records (`0 < fraction_over < 1/3`). One record moves a fraction by at
most 1/N, so routine growth cannot cross it.
- **reported, never asserted** — the fine claim that the ceiling sits between the **90th and
95th percentile**, i.e. at the tail boundary. `main()` prints a `::notice::` when it drifts.
It is an order statistic over a sparse distribution, so a single new record could move p90 by
twenty lines and red the blocking job for whoever wrote it; a ceiling going out of date is
the passage of corpus growth, not a defect in the commit under test, so it is treated like
`stale-after`. Re-derive the constant when the notice says so.
- the **aggregate prose total**, printed every run as an unthresholded `::notice::` **trend**.
It has no pass/fail. A total over a monotonically growing corpus can only ratchet: the old
4800→5600 budget went quiet at 5228 after #610 changed the metric and was back over at 5658
+2 -1
View File
@@ -72,10 +72,11 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
| `concurrency.replace-all-contract` | Replace-all aggregate PUTs carry a uniform plain `int Version` concurrency token (EF `.IsConcurrencyToken()`), checked pre-save and enforced by the EF UPDATE guard, returning 412 (not 409) on a stale `If-Match`. | 2026-07-11 | [link](records/concurrency/replace-all-contract.md) |
| `concurrency.schedule-item-child-identity` | `PUT /api/schedules/{id}/items` reconciles by an optional round-tripped child `Id` (null/absent/0 ⇒ new item), never by array position, so fill-group/shuffle state follows the logical item across reorders; an unknown or duplicate id is rejected 422 (checked after the §7a `CheckVersion`, so 412 precedes 422). | 2026-07-11 | [link](records/concurrency/schedule-item-child-identity.md) |
| `docs.convention-docs-session-start` | Docs-first, not source-first: conventions (api-conventions, spa-conventions, e2e-local, blazor-route-parity, domain-model, decisions, README) are read from docs, not reverse-engineered from code, via `docs/README.md`'s task-signal map — only the sections it points to for the task at hand, not the whole set. Each doc is updated in the same PR that changes what it documents, replacing deferred/follow-up doc updates. | 2026-07-07 | [link](records/docs/convention-docs-session-start.md) |
| `docs.corpus-size-signal` | The corpus's size signal is a per-record prose ceiling (`decisions_validate.py --record-ceiling`, default 60, chosen at a natural gap in the distribution), reported as a NON-BLOCKING `::warning::` naming each record over it. The aggregate prose total is still printed every run but carries NO threshold — it is a `::notice::` trend only — because a total over a monotonically growing corpus can only ratchet, and the generated catalog (`docs/decisions/README.md`) is no longer counted at all since it gains one row per record and cannot be consolidated away. Being listed by the ceiling is an invitation to check for REDUNDANCY, never an instruction to cut: a long record that is all distinct findings is a legitimate decline, and should be recorded as one. | 2026-07-26 | [link](records/docs/corpus-size-signal.md) |
| `docs.corpus-size-signal` | The corpus's size signal is a per-record prose ceiling (`decisions_validate.py --record-ceiling`, default 60, chosen at a natural gap in the distribution), reported as a NON-BLOCKING `::warning::` naming each record over it. The aggregate prose total is still printed every run but carries NO threshold — it is a `::notice::` trend only — because a total over a monotonically growing corpus can only ratchet, and the generated catalog (`docs/decisions/README.md`) is no longer counted at all since it gains one row per record and cannot be consolidated away. Being listed by the ceiling is an invitation to check for REDUNDANCY, never an instruction to cut: a long record that is all distinct findings is a legitimate decline, and should be recorded as one. The ceiling's CALIBRATION is guarded in two pieces of different robustness (#688): the blocking test asserts only the coarse, non-ratcheting property that the ceiling flags a nonempty PROPER MINORITY of records (`0 < fraction_over < 1/3`), while the fine claim — that it sits between p90 and p95 — is REPORTED by `main()` as a `::notice::` and never asserted against the live corpus. A ceiling drifting out of date is the passage of corpus growth, not a defect in the commit under test, so it gets `stale_records`' treatment rather than a red in the blocking `script-tests` job. | 2026-07-26 | [link](records/docs/corpus-size-signal.md) |
| `docs.decision-lifecycle` | every decision `##` record (active or archived) carries a 5-field metadata block (`key`, `status`, `since`, `supersedes`, `superseded-by`) checked by `scripts/decisions_validate.py`; a record is never deleted or line-edited to reverse a call — it is moved to `docs/decisions/archive/` with `status: superseded`/`retired` and a reciprocal `superseded-by`/`supersedes` key pair to its replacement. | 2026-07-21 | [link](records/docs/decision-lifecycle.md) |
| `docs.decision-one-file-per-record` | Each decision record is its own file at `docs/decisions/records/<area>/<topic>.md` (archived ones at `docs/decisions/archive/<area>/<topic>.md`) with YAML frontmatter; the filename IS the key, so one-active-record-per-key is a filesystem property rather than a validator check, and supersession is a `git mv`. | 2026-07-25 | [link](records/docs/decision-one-file-per-record.md) |
| `docs.decision-optional-provenance` | Decision records gain two OPTIONAL fields — `stale-after: YYYY-MM-DD` on the metadata line and a `**Sources:**` line in the metadata block; the Open Knowledge Format (OKF) itself is NOT adopted as the record format. | 2026-07-25 | [link](records/docs/decision-optional-provenance.md) |
| `docs.frontmatter-pyyaml-crosscheck` | `decisions_validate.py` runs `pyyaml_frontmatter_faults()` over every record-wing file: it loads the frontmatter with PyYAML and reports an ERROR when PyYAML rejects the document OR when any key's value differs from what the dependency-free `dl._read_frontmatter` read. PyYAML is the WRITER of these files (`migrate_decisions_split.render_record` emits them with `yaml.safe_dump`), so on any disagreement PyYAML is authoritative and the defect is in the FILE, not in either parser. The check is strictly additive: when PyYAML is not importable it is SKIPPED and `main()` says so with a `::notice::`, never silently — the read path stays dependency-free because `decisions-guard`, the Husky hooks and contributor machines install nothing. The comparison has exactly ONE implementation, called by both the validator and `test_frontmatter_reader_matches_pyyaml_on_every_real_record`, so the suite and the tool cannot drift on what "matches PyYAML" means. | 2026-08-04 | [link](records/docs/frontmatter-pyyaml-crosscheck.md) |
| `docs.record-wing-parse-guard` | `decisions_validate.py` asserts, per PATH, that every `*.md` under `docs/decisions/records/**` and `docs/decisions/archive/**` parses to exactly one record carrying a `key` — an ERROR, not a warning, since a file in the record wings that is not a record is a mistake by definition. A file sitting DIRECTLY in `archive/` is exempt only when it actually looks like a #610 stripped index — exactly one keyless record with a known generated heading — never merely by living there. The one other exemption, `archive/README.md`, is by exact RELATIVE PATH; nothing is ever exempt by BASENAME, since that would exempt the same filename in the active wing too. `_read_frontmatter` is deliberately NOT extended to accept YAML block scalars: every record value goes on ONE line, and the structural check is what makes that limitation loud instead of silent. | 2026-07-26 | [link](records/docs/record-wing-parse-guard.md) |
| `docs.tracker-comment-retrofit` | When the knowledge exporter flags an over-cap tracker issue and excludes it from ingestion, triage its comments instead of assuming a retrofit is owed — and for each decision-shaped item check the **worked issue first**, because a tracker session comment is by construction a précis of the fuller closing record posted on the issue it narrates. Applied to #237 (111 comments) this yielded **zero** records, so server-management#642's "a fact found only in a #237 comment" retrieval row has no valid subject and its interim target (an already-migrated record) is permanent. | 2026-07-21 | [link](records/docs/tracker-comment-retrofit.md) |
| `ffmpeg.external-logo-graphics-engine` | External-URL channel logos pass through to the graphics engine like any other watermark source; `WatermarkSelector` must never gate them on `File.Exists` (always false for a URL) and never route them through the ffmpeg-native overlay shortcut. | 2026-07-20 | [link](records/ffmpeg/external-logo-graphics-engine.md) |
@@ -5,8 +5,8 @@ status: active
since: '2026-07-26'
supersedes: none
superseded-by: none
rule: 'The corpus''s size signal is a per-record prose ceiling (`decisions_validate.py --record-ceiling`, default 60, chosen at a natural gap in the distribution), reported as a NON-BLOCKING `::warning::` naming each record over it. The aggregate prose total is still printed every run but carries NO threshold — it is a `::notice::` trend only — because a total over a monotonically growing corpus can only ratchet, and the generated catalog (`docs/decisions/README.md`) is no longer counted at all since it gains one row per record and cannot be consolidated away. Being listed by the ceiling is an invitation to check for REDUNDANCY, never an instruction to cut: a long record that is all distinct findings is a legitimate decline, and should be recorded as one.'
signals: 'aggregate active-corpus budget, schedule a consolidation warning, permanently red ratchet, per-record ceiling, 60-line prose ceiling, generated catalog counted in budget, corpus consolidation has no owner, size is not redundancy · paths: `scripts/decisions_validate.py`, `scripts/tests/test_decisions_validate.py`, `docs/ci-cd.md` · issues: #620, #610, #603, #542, #520'
rule: 'The corpus''s size signal is a per-record prose ceiling (`decisions_validate.py --record-ceiling`, default 60, chosen at a natural gap in the distribution), reported as a NON-BLOCKING `::warning::` naming each record over it. The aggregate prose total is still printed every run but carries NO threshold — it is a `::notice::` trend only — because a total over a monotonically growing corpus can only ratchet, and the generated catalog (`docs/decisions/README.md`) is no longer counted at all since it gains one row per record and cannot be consolidated away. Being listed by the ceiling is an invitation to check for REDUNDANCY, never an instruction to cut: a long record that is all distinct findings is a legitimate decline, and should be recorded as one. The ceiling''s CALIBRATION is guarded in two pieces of different robustness (#688): the blocking test asserts only the coarse, non-ratcheting property that the ceiling flags a nonempty PROPER MINORITY of records (`0 < fraction_over < 1/3`), while the fine claim — that it sits between p90 and p95 — is REPORTED by `main()` as a `::notice::` and never asserted against the live corpus. A ceiling drifting out of date is the passage of corpus growth, not a defect in the commit under test, so it gets `stale_records`'' treatment rather than a red in the blocking `script-tests` job.'
signals: 'aggregate active-corpus budget, schedule a consolidation warning, permanently red ratchet, per-record ceiling, 60-line prose ceiling, generated catalog counted in budget, corpus consolidation has no owner, size is not redundancy, ceiling calibration reddens script-tests, adding a record fails CI on length, p90 sits on the ceiling, tail-boundary drift notice · paths: `scripts/decisions_validate.py`, `scripts/tests/test_decisions_validate.py`, `docs/ci-cd.md` · issues: #688, #620, #610, #603, #542, #520'
mechanics: '`scripts/decisions_validate.py` -> `oversized_records` / `_budget_total`; `docs/ci-cd.md` -> "`decisions-guard` job"'
---
@@ -46,19 +46,34 @@ meant to prevent exactly that could not see it: `max(under) <= 60 < min(over)` i
construction** of the two lists it builds, and passes on a distribution with no gap at all. A
rationale-guarding test that cannot fail is worse than none, because it launders the claim.
It is replaced by `test_real_corpus_ceiling_sits_at_the_TAIL_BOUNDARY_of_the_distribution`, which
states the property directly and scale-free: **the ceiling sits between the 90th and 95th percentile
of record lengths** — that is what "marks the start of the tail" means — and reads the value from
`RECORD_CEILING_DEFAULT` so test and CLI cannot drift.
It is replaced by `test_real_corpus_ceiling_flags_a_nonempty_proper_minority`, which reads the value
from `RECORD_CEILING_DEFAULT` so test and CLI cannot drift.
Getting there took four versions, and the failures are the useful part:
Getting there took five versions, and the failures are the useful part:
| | assertion | why it failed |
|---|---|---|
| v1 | `max(under) <= 60 < min(over)` | true **by construction** of those two lists |
| v2 | a minimum gap WIDTH | a ceiling of 200 also sits in a wide gap — it passed |
| v3 | 2-12% fraction band + "clear air" above | **hostage to an unrelated record**: one ordinary 62-line addition reddened it with the ceiling correctly placed, and the only remedy was to RAISE the ceiling — this very treadmill, as a hard failure in what #631 makes a blocking job. The fraction band had the same coupling more slowly (12 more long records breached it), and `0 <= headroom` was vacuous. |
| v4 | `p90 <= ceiling <= p95` | percentiles move WITH the corpus, so routine growth cannot ratchet it; it fires only when the ceiling genuinely stops marking the tail |
| v4 | `p90 <= ceiling <= p95` | percentiles move with the corpus, but an order statistic over a SPARSE distribution is a STEP function. The lengths above the ceiling ran 60, 61, 62, 63 then jumped to 81, so ONE record moved p90 by twenty lines. It reddened the blocking job twice live (#672, #706), and both times the only in-scope remedy was to trim the new record to fit the constant — the v3 ratchet, pointed at record authors |
| v5 | coarse `0 < fraction_over < 1/3` asserted; fine `p90 <= ceiling <= p95` REPORTED | splits the claim by robustness instead of hunting for a better single assertion (#688) |
**v5 is not a fifth attempt at the same shape — it stops trying.** Four versions failed because they
all asserted, in the blocking job, a property of a corpus the commit under test does not control.
The fine claim is genuinely useful and genuinely fragile, so it is now measured on every run and
printed as a `::notice::` — the same treatment `stale_records` gets, and for the same stated reason:
a constant going out of date is the passage of time, not a defect in this change. What stays
blocking is only what routine growth cannot break — each record moves a fraction by at most 1/N, so
from 18/182 over the ceiling it would take ~40 consecutive over-ceiling additions to reach a third,
against ~1 record to break v4. The coarse bound still catches real mis-calibration in both
directions: a ceiling of 200 flags nobody, a ceiling of 20 flags most of the corpus.
The honest cost, stated rather than buried: **nothing now forces a re-derivation.** The ceiling can
drift while only a notice complains. That is accepted on the same reasoning this record already
applies to its two "keep listed" consolidation candidates — the warning names it on every run, which
tracks it better than a red that gets trimmed around, and a red an author can only clear by editing
an unrelated constant is not enforcement, it is a toll.
Two rules came out of that sequence, and they outlive this metric:
**a guard test must depend only on the thing it guards**, and
@@ -0,0 +1,66 @@
---
key: docs.frontmatter-pyyaml-crosscheck
title: '2026-08-04 — `decisions_validate.py` cross-checks its dependency-free frontmatter parse against PyYAML whenever PyYAML is importable (#674)'
status: active
since: '2026-08-04'
supersedes: none
superseded-by: none
rule: '`decisions_validate.py` runs `pyyaml_frontmatter_faults()` over every record-wing file: it loads the frontmatter with PyYAML and reports an ERROR when PyYAML rejects the document OR when any key''s value differs from what the dependency-free `dl._read_frontmatter` read. PyYAML is the WRITER of these files (`migrate_decisions_split.render_record` emits them with `yaml.safe_dump`), so on any disagreement PyYAML is authoritative and the defect is in the FILE, not in either parser. The check is strictly additive: when PyYAML is not importable it is SKIPPED and `main()` says so with a `::notice::`, never silently — the read path stays dependency-free because `decisions-guard`, the Husky hooks and contributor machines install nothing. The comparison has exactly ONE implementation, called by both the validator and `test_frontmatter_reader_matches_pyyaml_on_every_real_record`, so the suite and the tool cannot drift on what "matches PyYAML" means.'
signals: 'validator reports OK on a broken record, bare apostrophe in single-quoted frontmatter, unquoted hash truncates a value, hand-rolled frontmatter parser, PyYAML rejects the file but decisions-validate passes, dependency-free read path, frontmatter cross-check skipped · paths: `scripts/decisions_validate.py`, `scripts/decisions_lib.py`, `scripts/tests/test_decisions_validate.py`, `scripts/tests/test_decisions_lib.py` · issues: #674, #578, #651, #621'
mechanics: '`scripts/decisions_validate.py` -> `pyyaml_frontmatter_faults` / `_frontmatter_block`, wired into `main()` alongside `record_wing_faults`'
---
The validator read ordinary English prose in a `rule:` field and reported **OK** on a file PyYAML
refuses to parse. It was hit **twice in one session by two independent agents** on unrelated
branches (#578, #651), which is what makes it a guard rather than a note: it is not an exotic edge
case, it is what happens when anyone writes `SQLite's LOWER()` into a single-quoted scalar.
**Why the hand parser exists, and why it stays.** `dl._read_frontmatter` is deliberately
dependency-free — it runs in `decisions-guard`, in the Husky hooks, and on every contributor
machine, none of which install anything. Requiring PyYAML there once made the validator crash with
`ModuleNotFoundError` on the very records the split had just written. So the fix could not be
"import yaml in the reader". It is a second, optional opinion layered on top.
**The two known hazards fail DIFFERENTLY, and that shaped the fix.**
| input | dependency-free reader | PyYAML |
|---|---|---|
| `rule: 'SQLite's LOWER()'` | `SQLite's LOWER()` | **`ParserError`** — the bare apostrophe closes the scalar early |
| `rule: use --flag #2` | `use --flag #2` | `use --flag` — ` #` starts a comment, **silently truncating** |
A `try/except yaml.YAMLError` would have caught only the first row. The second produces no
exception at all: a valid record whose `rule` has quietly lost its tail — the `parse-to-WRONG` case
`docs.record-wing-parse-guard` explicitly names as the gap its structural check cannot see. So the
cross-check compares the parsed **result** key by key, and reports a rejection and a mismatch as two
distinct faults with different remedies.
**That is also what makes it general.** #674 asked for a fix that catches the *next* character class
rather than enumerating hazards one at a time — the mistake the surrounding sessions kept making
elsewhere. Comparing against the writer's own library is that: any construct where the two parsers
disagree surfaces as a diff, with nobody having to predict it. Enumerating apostrophes and hashes
would have left the third character to be discovered the same way the first two were.
**Direction is the load-bearing part.** PyYAML is not a second opinion of equal standing — it WROTE
these files. When the two disagree the on-disk bytes mean what PyYAML says they mean, so the record
is corrupt and the permissive reader is the one hiding it. Stating the direction is what turns an
ambiguous "parsers differ" report into an actionable "this record is silently wrong".
**A skip is announced, not silent.** When PyYAML is absent the check does not run, which is correct
and expected on the dependency-free path — but `main()` prints a `::notice::` saying so. A check
that reports success while doing nothing is the defect this corpus keeps re-learning (#603's
`stale-after` that never fired, #609's marker that exempted everything while printing OK), and
adding a quiet skip while fixing a quiet pass would have reintroduced it one level up.
**One implementation, two callers.** The comparison already existed — in the test suite only, which
is exactly why the validator could disagree with `scripts/tests` about the same file. Rather than
leaving a second copy, `test_frontmatter_reader_matches_pyyaml_on_every_real_record` now delegates
to `pyyaml_frontmatter_faults`, so the tool and the suite cannot drift apart on the definition.
**What it still does not catch.** A mis-parse both parsers agree on, and anything in a file PyYAML
accepts as a mapping with the schema's own key names. The guard is strong, not total — the same
qualification `record_wing_faults` carries.
**Tested with a positive control.** The suite pins that `record_wing_faults` alone reports both
hazard files as CLEAN. Without it the cross-check could be deleted and the tests would stay green
while the guard vanished; asserting the pre-#674 machinery's blindness is what makes the new red
mean something.
+176 -1
View File
@@ -17,6 +17,7 @@ import subprocess
import sys
from datetime import date
from pathlib import Path
from typing import NamedTuple
import scripts.decisions_lib as dl # noqa: E402 (run with PYTHONPATH=. or as module)
@@ -52,6 +53,11 @@ _STALE_AFTER_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
# guards the calibration claim asserts against the SAME value the CLI uses and the two cannot drift.
RECORD_CEILING_DEFAULT = 60
# The coarse, non-ratcheting calibration bound (#688): the ceiling must flag a nonempty PROPER
# MINORITY of records. Above a third it is cutting into the bulk rather than marking a tail. See
# `ceiling_calibration` for why the fine percentile claim is reported instead of asserted.
CEILING_MINORITY_MAX = 1 / 3
def _parse_stale_after(value: str | None) -> date | None:
"""`stale-after` as a date, or None if absent, empty, or malformed.
@@ -541,6 +547,92 @@ def record_wing_faults(records_dir: Path | None = None, archive_dir: Path | None
return faults
def _frontmatter_block(text: str) -> str | None:
"""The raw YAML between the opening `---` and the next `---`, or None if there isn't one."""
if not dl.has_frontmatter(text):
return None
lines = text.splitlines()
end = next((i for i, ln in enumerate(lines[1:], start=1) if ln.rstrip() == "---"), None)
if end is None:
return None
return "\n".join(lines[1:end])
def pyyaml_frontmatter_faults(files) -> tuple[list[str], bool]:
"""Faults where PyYAML disagrees with the dependency-free reader. Returns (faults, ran).
#674: the two known hazards are a bare apostrophe inside a single-quoted scalar
(`rule: 'SQLite's LOWER()'`) and an unquoted ` #` (`rule: use --flag #2`). Before this check the
validator reported OK on both, because `dl._read_frontmatter` is a hand parser that cannot see
either. It was hit TWICE in one session by two independent agents, which is what makes it worth
a guard rather than a note.
The two hazards fail DIFFERENTLY, and catching only the first would have missed half of it:
* the apostrophe makes PyYAML **reject** the document outright (`ParserError`);
* the unquoted ` #` parses fine and **silently truncates** the value — PyYAML reads
`use --flag`, the hand parser reads `use --flag #2`. No exception, a wrong value.
So this compares the parsed RESULT and does not merely try/except the load. That is also why it
generalizes past the two known characters, which is the property #674 asked for: any future
construct where the writer's library and our reader disagree shows up as a diff, without anyone
enumerating it first.
DIRECTION MATTERS: PyYAML is the WRITER (`migrate_decisions_split.render_record` emits these
files with `yaml.safe_dump`), so it is the authority on what the on-disk bytes mean. The hand
reader is the permissive one, and a disagreement is a defect in the FILE, not in either parser.
`ran` is False when PyYAML is not importable. The read path is deliberately dependency-free —
`decisions-guard`, the Husky hooks and every contributor machine install nothing — so this check
is strictly additive: it must never be the reason the validator cannot run. main() announces the
skip rather than passing quietly, because a check that reports success while doing nothing is
the exact defect class this corpus keeps re-learning (#603's `stale-after`, #609's marker).
"""
try:
import yaml # pyright: ignore[reportMissingImports]
except Exception:
return [], False
faults: list[str] = []
for p in files:
try:
text = p.read_text(encoding="utf-8")
except Exception: # noqa: S112 (record_wing_faults already reports the unreadable file by name)
continue
block = _frontmatter_block(text)
if block is None:
continue # no/unterminated frontmatter: reported by record_wing_faults
try:
theirs_raw = yaml.safe_load(block)
except yaml.YAMLError as exc:
first = str(exc).splitlines()[0] if str(exc).strip() else exc.__class__.__name__
faults.append(
f"{p}: PyYAML REJECTS this frontmatter, though the dependency-free reader accepted "
f"it ({first}). PyYAML is what WROTE these files, so its verdict is authoritative. "
f"Common cause: a bare apostrophe inside a single-quoted value (write '' for a "
f"literal apostrophe, as `yaml.safe_dump` does)."
)
continue
if theirs_raw is None:
theirs_raw = {}
if not isinstance(theirs_raw, dict):
faults.append(f"{p}: frontmatter parses as {type(theirs_raw).__name__}, not a mapping.")
continue
mine = dl._read_frontmatter(block)
if mine is None:
continue # the reader bailed: reported by record_wing_faults as parse-to-0
theirs = {k: ("" if v is None else str(v)) for k, v in theirs_raw.items()}
for k in sorted(set(mine) | set(theirs)):
if mine.get(k) != theirs.get(k):
faults.append(
f"{p}: frontmatter key {k!r} means different things to the two parsers — "
f"reader={mine.get(k)!r} but PyYAML={theirs.get(k)!r}. PyYAML wrote this file, "
f"so its reading is the real value and the record is silently corrupt. Common "
f"cause: an unquoted ` #`, which YAML treats as a comment and truncates there."
)
return faults, True
def _unknown_frontmatter_keys(path: Path) -> set[str]:
"""Frontmatter keys outside the known schema. Empty on any read/parse failure (reported elsewhere)."""
try:
@@ -642,6 +734,62 @@ def oversized_records(records, ceiling: int) -> list[tuple[str, int]]:
return sorted([kv for kv in out if kv[1] > ceiling], key=lambda kv: -kv[1])
class CeilingCalibration(NamedTuple):
n: int
p90: int
p95: int
n_over: int
fraction_over: float
marks_tail: bool # the FINE claim: p90 <= ceiling <= p95
flags_minority: bool # the COARSE claim: 0 < fraction_over < CEILING_MINORITY_MAX
def ceiling_calibration(records, ceiling: int) -> CeilingCalibration:
"""How well `ceiling` still marks the start of the corpus's tail (#688).
Two claims of DIFFERENT robustness, deliberately separated, because conflating them is what
made the previous guard a ratchet:
`marks_tail` — `p90 <= ceiling <= p95`. Correct as a definition of "start of the tail", but an
order statistic over a SPARSE distribution is a STEP function: at #688 the lengths just above
the ceiling ran 60, 61, 62, 63, then jumped to 81, so ONE new record could move p90 by twenty
lines and the only remedy the assertion admitted was to raise the ceiling. It is real signal,
but it is signal about the CONSTANT drifting, not a defect in the commit under test — the same
shape as `stale_records`, and it is reported the same way: a notice, never a failure.
`flags_minority` — `0 < fraction_over < 1/3`. Deliberately coarse, and what the blocking test
asserts. Each added record moves a fraction by at most 1/N, so routine growth cannot cross it:
at 17/176 over the ceiling, reaching a third would take ~42 consecutive over-ceiling additions.
It still catches genuine mis-calibration in both directions — a ceiling of 200 flags nobody
(fraction 0, signals nothing), a ceiling of 20 flags most of the corpus (cutting into the bulk,
so every author learns to ignore it).
Why not simply re-derive the constant instead: re-deriving fixes the instance and keeps the
mechanism. The v3 fraction band and the v4 percentile containment both failed the same way, one
faster than the other, and picking a new number would queue up the fifth version.
"""
lengths = sorted(record_prose_lines(r) for r in records if r.key)
n = len(lengths)
if n == 0:
return CeilingCalibration(0, 0, 0, 0, 0.0, False, False)
def pct(q: float) -> int:
return lengths[min(int(n * q), n - 1)]
n_over = sum(1 for v in lengths if v > ceiling)
frac = n_over / n
p90, p95 = pct(0.90), pct(0.95)
return CeilingCalibration(
n=n,
p90=p90,
p95=p95,
n_over=n_over,
fraction_over=frac,
marks_tail=p90 <= ceiling <= p95,
flags_minority=0 < frac < CEILING_MINORITY_MAX,
)
def _catalog_ok() -> bool:
try:
import scripts.build_decisions_catalog as bc # pyright: ignore[reportMissingImports]
@@ -684,6 +832,19 @@ def main(argv=None) -> int:
archive_records += dl.parse_file(f)
removed, rewritten, demoted = _diff_findings(args.base, args.head) if args.base and args.head else ([], [], [])
oversized = oversized_records(records, args.record_ceiling)
# #674: cross-check the dependency-free reader against the library that WROTE these files.
# Strictly additive — absent PyYAML skips the check (and SAYS so) rather than failing the run.
yaml_faults, yaml_ran = pyyaml_frontmatter_faults(record_wing_files())
if not yaml_ran:
print(
"::notice::decisions-validate: PyYAML is not importable, so the frontmatter cross-check "
"was SKIPPED — every other check ran. This is the expected state on the dependency-free "
"read path (decisions-guard, the Husky hooks); CI's script-tests job runs it with PyYAML "
"present.",
file=sys.stderr,
)
errs = validate(
records,
archive_keys=_archive_keys(),
@@ -692,7 +853,7 @@ def main(argv=None) -> int:
rewritten=rewritten,
archive_records=archive_records,
demoted=demoted,
wing_faults=record_wing_faults(),
wing_faults=record_wing_faults() + yaml_faults,
)
# Aggregate: an unthresholded TREND, not a gate (#620). Printed every run so the number stays
@@ -723,6 +884,20 @@ def main(argv=None) -> int:
file=sys.stderr,
)
# Ceiling calibration drift (#688): a NOTICE, never a failure. The ceiling drifting away from
# the tail boundary is the passage of corpus growth, not a defect in the commit under test — the
# same reasoning `stale_records` is built on. Asserting it in the blocking `script-tests` job
# made the next author of a substantial record pay for an unrelated constant going out of date.
cal = ceiling_calibration(records, args.record_ceiling)
if cal.n and not cal.marks_tail:
print(
f"::notice::decisions-validate: the {args.record_ceiling}-line ceiling has drifted from "
f"the tail boundary of the distribution (p90={cal.p90}, p95={cal.p95}, "
f"{cal.n_over}/{cal.n} records over it). Re-derive it when convenient — this is a "
f"maintenance signal about the constant, not a problem with this change.",
file=sys.stderr,
)
stale = stale_records(records, date.today())
if stale:
listed = "; ".join(f"{h} (stale-after {d})" for h, d in stale)
+11 -14
View File
@@ -71,23 +71,20 @@ def test_frontmatter_reader_matches_pyyaml_on_every_real_record():
made the validator crash with ModuleNotFoundError once the corpus was migrated.) A hand parser
is only safe if it provably matches the library that WROTE the files, so this compares the two
across every record rather than on a sample.
Since #674 the comparison itself lives in `decisions_validate.pyyaml_frontmatter_faults`, which
the VALIDATOR now runs too — before that it existed only here, so `decisions_validate.py`
happily reported OK on a record PyYAML rejects. This test delegates to that one implementation
rather than keeping a second copy of the comparison, so the suite and the validator cannot
drift apart and agree on what "matches PyYAML" means.
"""
yaml = pytest.importorskip("yaml")
pytest.importorskip("yaml")
import scripts.decisions_validate as dv
files = [p for p in dl.RECORDS_DIR.rglob("*.md")] + [p for p in dl.ARCHIVE_DIR.rglob("*.md")]
files = [f for f in files if dl.has_frontmatter(f.read_text(encoding="utf-8"))]
assert len(files) > 100, f"only {len(files)} frontmatter files found — test would be near-vacuous"
diffs = []
for f in files:
lines = f.read_text(encoding="utf-8").splitlines()
end = next(i for i, ln in enumerate(lines[1:], start=1) if ln.rstrip() == "---")
block = "\n".join(lines[1:end])
mine = dl._read_frontmatter(block)
theirs = yaml.safe_load(block) or {}
theirs = {k: ("" if v is None else str(v)) for k, v in theirs.items()}
if mine != theirs:
for k in set(mine or {}) | set(theirs):
if (mine or {}).get(k) != theirs.get(k):
diffs.append(f"{f.name}:{k}\n mine ={(mine or {}).get(k)!r}\n pyyaml={theirs.get(k)!r}")
assert not diffs, f"{len(diffs)} field(s) differ from PyYAML:\n" + "\n".join(diffs[:5])
faults, ran = dv.pyyaml_frontmatter_faults(files)
assert ran, "PyYAML is importable here, so the comparison must have actually run"
assert not faults, f"{len(faults)} field(s) differ from PyYAML:\n" + "\n".join(faults[:5])
+210 -18
View File
@@ -1002,39 +1002,215 @@ def test_budget_total_excludes_the_generated_catalog(tmp_path, monkeypatch):
assert total < 100, f"the 500-line generated catalog leaked into the total ({total})"
def test_real_corpus_ceiling_sits_at_the_TAIL_BOUNDARY_of_the_distribution():
"""Guards the calibration claim. This is the FOURTH version; the failures are the lesson.
def test_real_corpus_ceiling_flags_a_nonempty_proper_minority():
"""Guards the calibration claim. This is the FIFTH version; the failures are the lesson.
v1 `max(under) <= 60 < min(over)` — true by construction of those two lists.
v2 a minimum gap WIDTH — but a ceiling of 200 also sits in a wide gap, so it passed.
v3 a 2-12% fraction band plus "clear air" measured against `min(over)` — the nearest
record ABOVE the ceiling. That made the test a hostage to an unrelated record: one
ordinary 62-line addition reddened it with the ceiling correctly placed, and the only
remedy the assertion admitted was to RAISE the ceiling. That is the ratchet this whole
change abolishes, reinstated as a hard failure in what #631 makes a blocking CI job.
The fraction band had the same coupling more slowly (12 more long records breached it),
and `0 <= headroom` was vacuous — `max(under)` is by construction <= ceiling.
remedy the assertion admitted was to RAISE the ceiling.
v4 `p90 <= ceiling <= p95`. Scale-free and correct AS A DEFINITION, but an order statistic
over a SPARSE distribution is a STEP function. At #688 the lengths above the ceiling ran
60, 61, 62, 63 and then jumped to 81, so ONE new record moved p90 by twenty lines and
reddened the BLOCKING `script-tests` job for whoever happened to write it. It reproduced
twice live (#672, #706) and both times the only in-scope remedy was to trim the new
record to fit the constant — the ratchet pointed at record authors, which is precisely
what the v3 note says this whole design abolishes.
v4 states the property directly and scale-free: **the ceiling marks the start of the tail**,
i.e. it sits between the 90th and 95th percentile of record lengths. Percentiles move WITH the
corpus, so routine growth cannot ratchet this; it fires only when the ceiling genuinely stops
marking the tail boundary, which is exactly when it should be re-derived.
v5 SPLITS the claim by robustness instead of hunting for a better single assertion:
* the COARSE property — the ceiling flags a nonempty proper minority — is asserted HERE,
blocking. One record moves a fraction by at most 1/N, so routine growth cannot cross it.
* the FINE property — `p90 <= ceiling <= p95` — is now REPORTED by `main()` as a notice.
It is real signal about the CONSTANT drifting out of date, which is the passage of corpus
growth rather than a defect in the commit under test. That is the same reasoning
`stale_records` is built on, and it gets the same treatment.
Note what did NOT change: the ceiling is still 60, and the fine claim is still measured on
every run. v5 moves where each claim is enforced, it does not stop making them.
"""
recs = [r for r in dl.all_active_records() if r.key]
assert len(recs) > 100, f"corpus looks empty ({len(recs)}) — this check would be vacuous"
ceiling = dv.RECORD_CEILING_DEFAULT # the value the CLI actually uses; cannot drift from here
lengths = sorted(dv.record_prose_lines(r) for r in recs)
p90 = lengths[int(len(lengths) * 0.90)]
p95 = lengths[int(len(lengths) * 0.95)]
ceiling = dv.RECORD_CEILING_DEFAULT # the value the CLI actually uses; cannot drift from here
cal = dv.ceiling_calibration(recs, ceiling)
assert p90 <= ceiling <= p95, (
f"the ceiling ({ceiling}) no longer marks the tail boundary: p90={p90}, p95={p95}. "
f"Below p90 it cuts into the bulk and every author will learn to ignore it; above p95 it is "
f"parked among the outliers and signals nothing. Re-derive it from the distribution."
assert cal.flags_minority, (
f"the ceiling ({ceiling}) no longer flags a nonempty proper minority of records: "
f"{cal.n_over}/{cal.n} = {cal.fraction_over:.1%} are over it. At 0% it names nobody and "
f"signals nothing; above {dv.CEILING_MINORITY_MAX:.0%} it is cutting into the bulk of the "
f"corpus rather than marking its tail. Re-derive it from the distribution."
)
def test_ceiling_calibration_detects_drift_in_BOTH_directions():
"""The fine claim is asserted here, on a distribution the test OWNS.
This is the point of the v5 split: the property is still pinned, but against synthetic data
instead of the live corpus, so it cannot be reddened by someone else's record landing.
"""
# 100 records: 95 of 20 lines, 5 of 200. Index 90 lands in the short block and index 95 in the
# long one, so p90 == 20 and p95 == 200 — a wide, unambiguous tail boundary to aim at.
recs = [_rec_body(f"a.s{i}", 20) for i in range(95)] + [_rec_body(f"a.l{i}", 200) for i in range(5)]
assert [dv.ceiling_calibration(recs, 60).p90, dv.ceiling_calibration(recs, 60).p95] == [20, 200]
assert dv.ceiling_calibration(recs, 60).marks_tail, "60 sits between p90=20 and p95=200"
assert not dv.ceiling_calibration(recs, 10).marks_tail, "below p90 it cuts into the bulk"
assert not dv.ceiling_calibration(recs, 999).marks_tail, "above p95 it is parked among outliers"
# and the coarse property separates the same two failure modes
assert not dv.ceiling_calibration(recs, 999).flags_minority, "a ceiling nobody is over signals nothing"
assert not dv.ceiling_calibration(recs, 10).flags_minority, "90% over the ceiling is not a tail"
assert dv.ceiling_calibration(recs, 60).flags_minority
def test_ceiling_calibration_is_empty_safe():
"""A vacuous corpus must report both claims FALSE, never a passing default."""
cal = dv.ceiling_calibration([], 60)
assert cal.n == 0 and not cal.marks_tail and not cal.flags_minority
def test_adding_ordinary_records_cannot_RED_the_blocking_property():
"""#688's Done-when, demonstrated rather than assumed.
Takes the REAL corpus and appends the two record shapes that actually reddened v4 in the wild:
#672's 62-line record and #687's 107-line one. Under v4 the 107-line addition moved p90 into
the sparse gap above the ceiling and failed the blocking job. Under v5 the coarse property
still holds, and it holds with room to spare.
"""
real = [r for r in dl.all_active_records() if r.key]
ceiling = dv.RECORD_CEILING_DEFAULT
grown = real + [_rec_body("new.typical", 62), _rec_body("new.substantial", 107)]
cal = dv.ceiling_calibration(grown, ceiling)
assert cal.flags_minority, f"two ordinary additions reddened the blocking property: {cal}"
# Headroom, stated as a number so a future reader can see how much slack there is: how many
# consecutive over-ceiling records it would take to breach the bound.
n, over = cal.n, cal.n_over
need = 0
while (over + need) / (n + need) < dv.CEILING_MINORITY_MAX:
need += 1
assert need > 20, f"only {need} over-ceiling additions would breach the bound — too tight"
# --- #674: the validator cross-checks its own parse against PyYAML ------------------------------
_HAZARDS = {
# PyYAML REJECTS: the bare apostrophe closes the single-quoted scalar early.
"apostrophe": "rule: 'SQLite's LOWER() folds ASCII only'",
# PyYAML ACCEPTS but reads a DIFFERENT value: ` #` starts a comment, truncating the rule.
"unquoted-hash": "rule: use --flag #2 for this",
}
def _wing_with(tmp_path: Path, frontmatter_line: str) -> tuple[Path, Path]:
"""A record wing containing one file whose frontmatter carries `frontmatter_line`."""
records = tmp_path / "records" / "ci"
records.mkdir(parents=True)
(records / "a.md").write_text(
"---\n"
"key: ci.a\n"
"title: 'T'\n"
"status: active\n"
"since: '2026-01-01'\n"
"supersedes: none\n"
"superseded-by: none\n"
f"{frontmatter_line}\n"
"signals: 's'\n"
"---\n\nprose.\n"
)
archive = tmp_path / "archive"
archive.mkdir(parents=True)
return records, archive
@pytest.mark.parametrize("hazard", sorted(_HAZARDS))
def test_pyyaml_crosscheck_catches_frontmatter_the_hand_reader_accepts(tmp_path, hazard):
"""#674, both shapes. Hit twice in one session by two independent agents (#578, #651)."""
pytest.importorskip("yaml")
records, _ = _wing_with(tmp_path, _HAZARDS[hazard])
faults, ran = dv.pyyaml_frontmatter_faults(sorted(records.rglob("*.md")))
assert ran, "PyYAML is installed here, so the cross-check must have run"
assert faults, f"the {hazard} hazard slipped through the cross-check"
@pytest.mark.parametrize("hazard", sorted(_HAZARDS))
def test_the_hand_reader_really_IS_blind_to_these(tmp_path, hazard):
"""The positive control: pin the MECHANISM, so this suite cannot pass for the wrong reason.
If `record_wing_faults` ever started catching these on its own, the cross-check above could be
deleted and the tests would stay green while the guard vanished. Asserting that the pre-#674
machinery reports these files as CLEAN is what makes the cross-check's red meaningful — and it
is the exact state #674 was filed about: `decisions_validate.py` printed OK on input the
writer's own library rejects.
"""
records, archive = _wing_with(tmp_path, _HAZARDS[hazard])
assert dv.record_wing_faults(records, archive) == [], (
"the structural guard now catches this by itself — re-derive whether the PyYAML "
"cross-check is still the thing closing this gap"
)
def test_pyyaml_crosscheck_is_clean_on_the_REAL_corpus():
"""No false positives. A cross-check that flags correct records would be reverted within a day."""
pytest.importorskip("yaml")
files = dv.record_wing_files()
assert len(files) > 100, f"only {len(files)} wing files found — this check would be near-vacuous"
faults, ran = dv.pyyaml_frontmatter_faults(files)
assert ran
assert faults == [], "the cross-check disagrees with the live corpus:\n" + "\n".join(faults[:5])
def test_crosscheck_skips_cleanly_when_pyyaml_is_absent(tmp_path, monkeypatch):
"""The read path stays dependency-free (#674's second Done-when box).
`decisions-guard`, the Husky hooks and every contributor machine install nothing, so an absent
PyYAML must SKIP the cross-check rather than fault or crash — while every other check runs.
"""
records, _ = _wing_with(tmp_path, _HAZARDS["apostrophe"])
import builtins
real_import = builtins.__import__
def no_yaml(name, *a, **kw):
if name == "yaml":
raise ImportError("no yaml here")
return real_import(name, *a, **kw)
monkeypatch.setattr(builtins, "__import__", no_yaml)
faults, ran = dv.pyyaml_frontmatter_faults(sorted(records.rglob("*.md")))
assert ran is False, "an absent PyYAML must report that the check did not run"
assert faults == [], "a skipped check must not manufacture faults"
def test_main_ANNOUNCES_a_skipped_crosscheck(capsys, monkeypatch):
"""A skipped check that says nothing is the '#603 stale-after' defect: reports success, does
nothing. The skip is legitimate; staying quiet about it is not."""
monkeypatch.setattr(dv, "pyyaml_frontmatter_faults", lambda files: ([], False))
assert dv.main([]) == 0
err = capsys.readouterr().err
assert "cross-check" in err and "SKIPPED" in err, err
def test_main_FAILS_when_the_crosscheck_reports_a_fault(capsys, monkeypatch):
"""Wiring test: the faults must reach the exit code, not just be computed.
Without this, `wing_faults=record_wing_faults() + yaml_faults` could drop the second term and
every other test here would still pass.
"""
monkeypatch.setattr(dv, "pyyaml_frontmatter_faults", lambda files: (["x.md: bogus fault"], True))
assert dv.main([]) == 1
assert "bogus fault" in capsys.readouterr().err
def test_retired_budget_flag_says_it_is_ignored(capsys):
"""A retired flag must announce itself, not no-op silently.
@@ -1051,6 +1227,22 @@ def test_no_budget_flag_means_no_retirement_warning(capsys):
def test_main_reports_ceiling_drift_as_a_NOTICE_and_still_exits_0(capsys):
"""The fine claim's live wiring (#688). A ceiling of 999 is parked far above p95, so the drift
notice must fire — and must NOT turn the run red, which is the entire point of the v5 split."""
assert dv.main(["--record-ceiling", "999"]) == 0
err = capsys.readouterr().err
drift = [ln for ln in err.splitlines() if "drifted from the tail boundary" in ln]
assert len(drift) == 1, err
assert drift[0].startswith("::notice::"), f"drift must be a notice, not a warning: {drift[0]}"
def test_main_is_QUIET_about_drift_when_the_ceiling_is_calibrated(capsys):
"""The complement — otherwise the notice above could be unconditional and the test vacuous."""
dv.main([])
assert "drifted from the tail boundary" not in capsys.readouterr().err
def test_main_actually_REPORTS_the_ceiling_and_the_trend(capsys):
"""The new signal's live wiring was untested: `if oversized:` -> `if False:`, or bumping the
default ceiling to 999999, left every test green while main() reported nothing. Only the pure