← Files APODICTIC Development EditorARCHIVED FILE

scripts/specificity_floor.py

54.1 KB · Nov 9, 2026 · 00:53 UTC

↓ Download file

#!/usr/bin/env python3
"""specificity-floor — pre-letter re-grounding fidelity gate (docs/synthesis-regrounding.md, M2).

`validate.sh specificity-floor <editorial_letter> <findings_ledger> [--strict]` shells out here.
The Pre-Letter Re-Grounding step (run-synthesis.md §Processing Protocol, after the step-9b
Synthesis Coverage Manifest, before the Step 10 pre-output gate) re-reads the consolidated
Findings Ledger verbatim from disk and re-reads bounded manuscript spans for synthesis-bound
findings, restoring the counts / names / quote anchors that context-salience decay smears into
vague prose ("nine belief failures" -> "several belief failures"). This validator is the
checkable half of that step: it holds the delivered letter to the SPECIFICITY the ledger already
locked. Re-grounding may only ADD specificity — never a new finding, never a severity/confidence
change (those are Step 5 / Step 6b property, enforced elsewhere).

  Count floor    for each delivered finding ID, the finding's LEDGER ENTRY (the Notable Finding
                 prose sentence + its structured apodictic.finding.v1 block) is scanned for count
                 tokens (integers 2-99; number-words two..ninety-nine are NORMALIZED to digit
                 strings at extraction, so a ledger "nine" and a letter "9" are the SAME count
                 token in both directions; matching is CASE-INSENSITIVE — a sentence-initial
                 "Nine" satisfies a ledger "nine"). FAIL when that ledger entry carries >= 1 count
                 token AND the finding's LETTER WINDOW carries a vague quantifier from
                 VAGUE_QUANTIFIERS AND the window carries NONE of the ledger's count tokens — the
                 decayed-to-vague case. Override: an ID-scoped
                 `<!-- override: specificity-floor F-... — <rationale> -->` (for legitimately
                 non-countable findings) + an Appendix B entry.
  Anchor floor   each delivered Must-Fix window must carry >= 1 evidence reference that
                 token-matches one of that finding's locked evidence_refs — so a restored number
                 rides a ledger-matching anchor, bounding count-hallucination. Chapter refs match
                 on the chapter NUMBER, surface-form independent ("Chapter 12", "Ch.12", and a
                 spelled-out "Chapter Twelve" all satisfy a ledger "Ch 12"). Override: same
                 ID-scoped marker.
  Malformed      a ledger in which >= 1 apodictic:finding block appears but ZERO parse to a valid
                 finding REFUSES with a named ERROR (malformed-ledger refusal, exit 1) instead of
                 the vacuous "no ledger findings" pass — an all-malformed ledger must not silently
                 disarm the floor. PARTIAL malformation (some blocks parse) proceeds on the parsed
                 subset: per-block schema repair is structured-findings' gate (single ownership),
                 and refusing here on a partial would double-enforce it.
  Presence       the `<!-- regrounding: done -->` letter marker surfaces as an ADVISORY W when the
                 re-grounding step ran on a full-letter run but left no trace (never a hard FAIL —
                 the count/anchor floors are the teeth; the marker is a hint). The OTHER half of
                 the spec's degrade-path presence pair — the manifest's `regrounded` annotation —
                 is deliberately NOT checked here (as-built scope-down, docs/synthesis-regrounding.md
                 §M2 as-built corrections): this validator's spec-pinned CLI receives no
                 manifest/run-folder path, and synthesis_coverage.py is the manifest's ONLY parser
                 (run-synthesis.md step 9b row grammar) — a second manifest parser here is exactly
                 the dual-enforcer drift risk single ownership exists to prevent.

NOT this validator's job (single-ownership, spec §M2.3): the reverse-direction
letter-ID-must-exist-in-ledger check is `finding-trace` E1's — the sole owner. specificity-floor
implements NO reverse ID check; two enforcers with subtly different ID grammars is the drift risk
single ownership exists to prevent. The smuggled-finding gate is `validate.sh finding-trace`.

Firewall (spec §M2.3): re-grounding is read-only on the ledger. This validator reads severity to
select synthesis-bound findings and Must-Fix windows; it NEVER emits, compares, or rewrites a
severity/confidence value. Deficit Lock + softness-check (downstream) remain the severity gates.

LAUNCH POSTURE: the count floor and anchor floor are BLOCKING (ERROR, exit 1) — they are the
mechanical restoration teeth, and a decayed count or an unanchored restored number is a fidelity
failure, not an advisory. The regrounding-trace presence check is ADVISORY (WARN, exit 0);
--strict promotes it. (Mirrors finding-trace: hard errors block, coverage W is advisory.)

Untrusted-input containment: nothing in the letter or ledger drives a filesystem read or write —
both paths are CLI args, read once; all matching is in-memory string work.

  specificity_floor.py specificity-floor <editorial_letter> <findings_ledger> [--strict]
  specificity_floor.py --self-test

Exit: 0 clean / WARN-only, 1 ERROR (or WARN under --strict), 2 usage.
"""
import json
import os
import re
import sys

try:
    import apodictic_artifacts as art
except ImportError:
    art = None

# override_marker is the SSoT for override detection (code-span-stripped, boundary-matched); it
# always ships in the mirrored script dir. If it is somehow unimportable, override detection is
# DISABLED (fail-closed: no marker is honored) rather than reintroducing the bare-substring
# anti-pattern the M5 meta-linter forbids — a missed override is a loud, fixable FAIL, never a
# silent bypass.
try:
    from override_marker import has_override, strip_code_spans
except ImportError:  # pragma: no cover — the shared helper always ships in-repo
    def strip_code_spans(body):
        return body or ""

    def has_override(body, slug):
        return False

# ---------------------------------------------------------------- pinned vocabulary

# The vague-quantifier list, PINNED and tunable in this ONE place (mirrored byte-identical in the
# root scripts/ copy per the dual-mirror rule). Spec §M2.2 initial value, verbatim. Order is not
# semantic; kept as the spec lists it. Multi-word entries ("a few") are matched as phrases.
VAGUE_QUANTIFIERS = ("several", "some", "many", "a few", "numerous", "various", "multiple",
                     "a number of", "a handful", "repeated", "repeatedly")

# Number-words two..ninety-nine (the count range the floor cares about; "one" and 0/1 are not
# counts worth restoring — a single belief failure does not decay to "several"). NORMALIZED to
# digit strings at extraction time, so a ledger "nine" and a letter "9" are the same canonical
# count token — word<->digit equivalence in BOTH directions (a faithful "nine" -> "9" restoration
# must never be a false FAIL). Case-insensitive; stored lowercase.
_ONES = {"two": 2, "three": 3, "four": 4, "five": 5, "six": 6, "seven": 7, "eight": 8,
         "nine": 9, "ten": 10, "eleven": 11, "twelve": 12, "thirteen": 13, "fourteen": 14,
         "fifteen": 15, "sixteen": 16, "seventeen": 17, "eighteen": 18, "nineteen": 19}
_TENS = {"twenty": 20, "thirty": 30, "forty": 40, "fifty": 50, "sixty": 60, "seventy": 70,
         "eighty": 80, "ninety": 90}
# word -> value map: the ones/teens, the round tens, and "tens-ones" ("twenty-one", "twenty one").
_WORD_VALUES = dict(_ONES)
_WORD_VALUES.update(_TENS)
for _t, _tv in _TENS.items():
    for _o, _ov in (("one", 1), ("two", 2), ("three", 3), ("four", 4), ("five", 5), ("six", 6),
                    ("seven", 7), ("eight", 8), ("nine", 9)):
        for _sep in ("-", " "):
            _WORD_VALUES["%s%s%s" % (_t, _sep, _o)] = _tv + _ov
_NUMBER_WORD_RE = re.compile(
    r"\b(" + "|".join(sorted((re.escape(w) for w in _WORD_VALUES), key=len, reverse=True)) + r")\b",
    re.IGNORECASE)


def _normalize_number_words(text):
    """Replace every number-word token two..ninety-nine with its digit string ("nine" -> "9",
    "Twenty-one" -> "21"), longest-first so compounds win over their parts. Applied BEFORE
    locator stripping and count extraction, which buys three properties at once: word<->digit
    count equivalence (ledger "nine" == letter "9"), spelled-out locators become strippable
    ("Chapter Nine" -> "Chapter 9" -> locator-stripped, so it can never masquerade as a restored
    count), and all downstream matching is single-form (digits only)."""
    return _NUMBER_WORD_RE.sub(lambda m: str(_WORD_VALUES[m.group(1).lower()]), text or "")


# A count token in prose is a 2-99 integer (after number-word normalization). The lookarounds
# keep "19" out of "1984" and "220" from yielding "22"/"20" — a count is a WHOLE 1-2 digit
# number, not a slice of a longer one.
_DIGIT_RE = re.compile(r"(?<!\d)([1-9]\d|[2-9])(?!\d)")       # 2..99, no leading/trailing digit

# Evidence-LOCATOR numbers are not semantic counts: "Ch 12", "sc. 30-31", "scenes 30-31", "p. 40",
# "line 220", "Pass 5", "Chapter 9", "Chapter Nine" (normalized first). They must be stripped
# before count extraction, or a scene/page number in the window would masquerade as a "restored
# count" and let a decayed "nine -> several" slip the floor (the window "several belief failures
# (sc. 30-31)" would otherwise share {30,31} with the ledger and pass). Every keyword accepts its
# plural — "scenes 30-31" is as much a locator as "sc. 30-31". Applied to BOTH the ledger entry
# and the letter window, symmetrically.
_LOCATOR_RE = re.compile(
    r"\b(?:ch(?:apters?)?\.?|sc(?:enes?)?\.?|p(?:ages?|p)?\.?|lines?|pass(?:es)?"
    r"|vol(?:umes?)?\.?|books?|parts?|acts?|sections?|§)\s*\.?\s*\d+(?:\s*[–—-]\s*\d+)?",
    re.IGNORECASE)

# Count-phrase RESCUE channel (locator/count collision): in "in the confession scene 9 belief
# failures cluster", the locator regex eats "scene 9" although the 9 is a real count heading a
# count-noun phrase — which would false-FAIL a faithful restoration. The rule: a number followed
# (optionally via one modifier word) by a plural head word ("9 belief failures", "12 failures")
# is recorded as a (count, head) PHRASE, and a count eaten by the locator strip is rescued iff
# the LEDGER entry and the letter window share the same (count, head) phrase AND that count is
# one the ledger locks. ASYMMETRY (Codex P2, PR #163): the LEDGER side of the match is extracted
# from the locator-STRIPPED entry, so a ledger phrase is always headed by the noun a LOCKED
# (non-locator) count actually counts ("belief failures") — never by an s-ending token trailing a
# locator phrase ("Chapter 9 opens"). The previous both-sides-raw rule assumed a real ledger
# count sentence could never carry a (number, s-verb) locator bigram — false: the ledger's own
# count sentence may OPEN with the locator ("Chapter 9 opens with nine belief failures"), so
# whenever the chapter/scene number equals the locked count, the shared raw (9, opens) bigram
# rescued a window that had decayed to "several". The WINDOW side stays raw — un-eating the
# window's locator-consumed restoration is the channel's whole purpose — so what remains
# rescuable is exactly a window that puts the locked count in front of the ledger's own
# count-noun head. A bare locator ("at scene 9", "sc. 30-31") still produces no phrase (no
# plural head follows), and phrases NEVER arm the floor (only safe ledger counts do); they only
# un-eat a locator-consumed restoration.
_COUNT_PHRASE_HEAD_RE = re.compile(r"(?<!\d)(\d{1,2})\s+([A-Za-z]{2,}s)\b")
_COUNT_PHRASE_MOD_RE = re.compile(r"(?<!\d)(\d{1,2})\s+[A-Za-z]+\s+([A-Za-z]{2,}s)\b")

# The one block-location regex, shared by ledger_entries (span -> section mapping) and the
# malformed-ledger refusal counter (attempted vs parsed) — a single source so the two can never
# disagree about what "a finding block" is.
_FINDING_BLOCK_RE = re.compile(r"<!--\s*apodictic:finding\s*(.*?)\s*-->", re.DOTALL)

# The letter cites a delivered finding with the pinned canonical HTML-comment form
# `<!-- finding: F-... -->` (output-policy.md §Deficit Lock), placed at the END of the finding's
# descriptive prose (the in-repo convention: example-editorial-letter.md — "...two sentences.
# <!-- finding: F-RR-01 -->"). The window for a finding is therefore the prose BLOCK the marker
# belongs to: bounded on BOTH sides by the nearest other finding marker or `^##`/`###` column-0
# header. The evidence-density window rule (run-synthesis.md §Processing Protocol) is
# "from each marker to the next marker OR the next `^##` header"; because the descriptive text
# precedes the marker, the window is taken between the previous boundary and the next one, so it
# captures the sentence the marker terminates. IDs are the exact art.FID_RE token (F-P5-01 !=
# F-P5-011).
_FID_RE = art.FID_RE if art is not None else re.compile(r"(?<![\w-])F-[A-Za-z0-9]+-[0-9]{2,}(?![\w-])")
_FINDING_MARKER_RE = re.compile(r"<!--\s*finding:\s*(" + _FID_RE.pattern + r")\s*-->")
# A window boundary: a `##`/`###` column-0 section header. (Finding markers are the other
# boundary, handled directly in letter_windows.)
_SECTION_HEADER_RE = re.compile(r"^#{2,3}\s", re.MULTILINE)
_REGROUNDING_DONE_RE = re.compile(r"<!--\s*regrounding:\s*done\s*-->", re.IGNORECASE)
# Chapter references in the letter window, any surface form ("Chapter 9", "Ch 12", "Ch.3") — the
# anchor floor matches on the chapter NUMBER, so the letter's spelling need not equal the ledger's.
_WINDOW_CH_RE = re.compile(r"\bch(?:apter)?\.?\s*(\d+)\b", re.IGNORECASE)

# Full-letter detection (the coverage obligation shape, run-synthesis.md §Annotated-Manuscript
# offer) — used only to scope the advisory regrounding-trace check to runs that actually wrote a
# letter; it never gates the count/anchor floors, which apply to any letter passed in.
_LETTER_TITLE_RE = re.compile(r"^#\s", re.MULTILINE)


def _read(path):
    try:
        with open(path, encoding="utf-8") as fh:
            return fh.read()
    except (OSError, UnicodeDecodeError):
        return None


# ---------------------------------------------------------------- count-token extraction

def count_tokens(text):
    """The set of canonical SEMANTIC count tokens in `text`: integers 2-99 as digit strings.
    Number-words are normalized to digits FIRST ("nine" -> "9", so word and digit spellings are
    one token — and a spelled-out locator "Chapter Nine" becomes "Chapter 9", strippable), THEN
    evidence-locator numbers (Ch 12, sc. 30-31, scenes 30-31, p. 40, Pass 5, line 220) are
    stripped so a scene/page number never masquerades as a restored count. Case-insensitive.
    Applied to BOTH the ledger entry and the letter window; a ledger count re-appears in the
    window iff the two sets intersect (or the count is rescued via a shared count phrase —
    count_phrases below)."""
    body = _LOCATOR_RE.sub(" ", _normalize_number_words(text))
    return {m.group(1) for m in _DIGIT_RE.finditer(body)}


def count_phrases(text, strip_locators=False):
    """The set of (count_token, plural_head) phrases in `text`: every whole 2-99 number followed
    by a plural-looking head word, directly ("12 failures") or via one modifier word ("9 belief
    failures"), after number-word normalization. The count floor's rescue channel for
    locator/count collisions ("...scene 9 belief failures..."): a phrase only rescues when
    ledger and window AGREE on it (see check), so bare locators — no plural head — contribute
    nothing, and the strip's masquerade protection stays intact. strip_locators=True is the
    LEDGER side: phrases come from the locator-STRIPPED body, so a rescue head can only be the
    noun a locked count counts, never a locator-adjacent s-token ("Chapter 9 opens" — Codex P2,
    PR #163). The strip substitutes a non-word placeholder (never a bare space) so removal
    cannot butt a preceding count against a following plural word and mint a phrase that never
    existed in the text ("saw 12 Chapter 9 openings" must not yield (12, openings))."""
    body = _normalize_number_words(text)
    if strip_locators:
        body = _LOCATOR_RE.sub(" ~ ", body)
    out = set()
    for rx in (_COUNT_PHRASE_HEAD_RE, _COUNT_PHRASE_MOD_RE):
        for m in rx.finditer(body):
            num, head = m.group(1), m.group(2).lower()
            if 2 <= int(num) <= 99 and not head.endswith("ss"):
                out.add((num, head))
    return out


def has_vague_quantifier(text):
    """The vague quantifiers present in `text` (case-insensitive, word-boundary; multi-word
    entries matched as whitespace-flexible phrases). Returns the matched surface list (for the
    error message), or []."""
    hits = []
    low = (text or "")
    for q in VAGUE_QUANTIFIERS:
        # whitespace-flexible phrase match, word-bounded, case-insensitive
        pat = r"\b" + r"\s+".join(re.escape(w) for w in q.split()) + r"\b"
        if re.search(pat, low, re.IGNORECASE):
            hits.append(q)
    return hits


# ---------------------------------------------------------------- ledger entry parsing

def ledger_entries(ledger_text):
    """{fid: entry_text} — for each apodictic.finding.v1 block in the ledger, the ENTRY text: the
    region from the nearest preceding `##`/`###` header (the Mechanism section, carrying the
    Notable Finding prose sentence) through the finding block itself, up to the next `##`/`###`
    header. So the count floor reads the ledger's count both from the prose ("nine belief
    failures") and from the structured block.

    Each block is located INDEPENDENTLY (its own regex span parsed to an id) rather than by zipping
    a filtered parse_blocks list against a filtered span list — so a malformed sibling block can
    never desync the id<->span pairing. A non-string id is fid_key-coerced (crash-class SSoT).
    Duplicate ids (author error; structured-findings owns that) union their sections so the floor
    never under-reads a count."""
    entries = {}
    if not ledger_text:
        return entries
    lines = ledger_text.split("\n")
    header_idx = [i for i, ln in enumerate(lines) if re.match(r"#{2,3}\s", ln)]

    def section_of_offset(char_pos):
        """The text of the `##`/`###` section containing char offset char_pos."""
        running, line_of = 0, len(lines) - 1
        for i, ln in enumerate(lines):
            if running + len(ln) >= char_pos:
                line_of = i
                break
            running += len(ln) + 1
        start, end = 0, len(lines)
        for h in header_idx:
            if h <= line_of:
                start = h
            elif h > line_of:
                end = h
                break
        return "\n".join(lines[start:end])

    # Each finding block, located by its own span; the id is parsed from that span's payload so
    # span and id always belong to the same block (no cross-list zip).
    for mo in _FINDING_BLOCK_RE.finditer(ledger_text):
        try:
            obj = json.loads(mo.group(1).strip())
        except (ValueError, TypeError):
            continue
        if not isinstance(obj, dict):
            continue
        fid = art.fid_key(obj.get("id")) if art is not None else (
            obj.get("id") if isinstance(obj.get("id"), str) else None)
        if not fid:
            continue
        entry = section_of_offset(mo.start())
        entries[fid] = (entries.get(fid, "") + "\n" + entry) if fid in entries else entry
    return entries


def ledger_parse_stats(ledger_text):
    """(attempted, parsed): finding-typed blocks SEEN in the ledger vs blocks that parse to a
    dict carrying a valid finding id. Drives the malformed-ledger refusal: attempted >= 1 with
    parsed == 0 means the ledger is non-empty but the floor would silently disarm — refuse,
    never a vacuous pass. Classification is on parsed payloads (json.loads per block /
    art.parse_blocks), not raw marker text (meta-lint M2)."""
    attempted = parsed = 0
    if not ledger_text:
        return 0, 0
    if art is not None:
        for bt, obj, _e in art.parse_blocks(ledger_text):
            if bt != "finding":
                continue
            attempted += 1
            if isinstance(obj, dict) and art.fid_key(obj.get("id")):
                parsed += 1
        return attempted, parsed
    for mo in _FINDING_BLOCK_RE.finditer(ledger_text):
        attempted += 1
        try:
            obj = json.loads(mo.group(1).strip())
        except (ValueError, TypeError):
            continue
        if isinstance(obj, dict) and isinstance(obj.get("id"), str) and obj["id"].strip():
            parsed += 1
    return attempted, parsed


def ledger_severities(ledger_text):
    """{fid: severity} for the ledger's finding blocks (used to select synthesis-bound findings
    and Must-Fix windows). Severity is read here ONLY to CLASSIFY — never emitted or compared as a
    fidelity signal (the firewall: this validator does not touch severity)."""
    sev = {}
    if not ledger_text or art is None:
        return sev
    for bt, obj, _e in art.parse_blocks(ledger_text):
        if bt == "finding" and isinstance(obj, dict):
            fid = art.fid_key(obj.get("id"))
            if fid:
                sev[fid] = obj.get("severity")
    return sev


def ledger_evidence_refs(ledger_text):
    """{fid: [ref, ...]} — the locked evidence_refs of each finding (string entries only; a
    non-string ref is structured-findings' schema gate, skipped here)."""
    refs = {}
    if not ledger_text or art is None:
        return refs
    for bt, obj, _e in art.parse_blocks(ledger_text):
        if bt == "finding" and isinstance(obj, dict):
            fid = art.fid_key(obj.get("id"))
            if not fid:
                continue
            raw = obj.get("evidence_refs")
            refs[fid] = [r for r in raw if isinstance(r, str)] if isinstance(raw, list) else []
    return refs


# ---------------------------------------------------------------- letter window parsing

def letter_windows(letter_text):
    """{fid: window_text} — for each `<!-- finding: F-... -->` marker in the letter (code spans
    stripped, so a marker quoted as a doc example is not honored), the prose BLOCK the marker
    TERMINATES: from the nearest preceding boundary (the previous finding marker's END or the
    previous `^##`/`###` header, whichever is closer) up to and including THIS marker. Because the
    marker sits at the END of the finding's descriptive sentence (example-editorial-letter.md:
    "...two sentences. <!-- finding: F-RR-01 -->"), the window bounded on the RIGHT by the marker's
    own end captures exactly that finding's prose — and does NOT bleed into the NEXT finding's
    sentence (which the next marker terminates). A finding cited twice unions its windows (the
    floor must see every place a count could be restored)."""
    stripped = strip_code_spans(letter_text or "")
    markers = [(m.start(), m.end(), m.group(1)) for m in _FINDING_MARKER_RE.finditer(stripped)]
    headers = [m.start() for m in _SECTION_HEADER_RE.finditer(stripped)]
    marker_ends = [e for _s, e, _f in markers]
    windows = {}
    for start, end, fid in markers:
        # left boundary: the closest of {a header start before this marker, a PRIOR marker's end};
        # right boundary: this marker's own end (the marker terminates the finding's sentence).
        left = max([0] + [h for h in headers if h < start]
                   + [me for me in marker_ends if me <= start], default=0)
        w = stripped[left:end]
        windows[fid] = (windows.get(fid, "") + "\n" + w) if fid in windows else w
    return windows


def _window_chapters(text):
    """The set of chapter NUMBERS named anywhere in `text` (any surface form — "Chapter 9",
    "Ch 12", "Ch.3", "ch 4", and via number-word normalization "Chapter Twelve"). Compared
    against a ref's normalized chapter number, so the anchor floor matches on the chapter the
    ledger locked regardless of how the letter spelled it."""
    return {int(m.group(1)) for m in _WINDOW_CH_RE.finditer(_normalize_number_words(text))}


def ref_token_match(window_text, refs):
    """True iff the window carries at least one evidence reference matching a locked evidence_ref.
    A chapter ref matches when its chapter NUMBER appears anywhere in the window (surface-form
    independent — "Chapter 9" in the letter matches a ledger ref "Chapter 9"/"Ch 9"). A ref with no
    chapter (a contract-artifact ref) matches on any distinctive >=4-char token. Conservative: the
    anchor floor only needs ONE ledger-matching anchor."""
    if not refs:
        return True  # no locked refs to tie to (schema requires minItems 1, but be graceful)
    win_chs = _window_chapters(window_text)
    low = window_text.lower()
    for r in refs:
        ch = art.chapter_token(r) if art is not None else None
        if ch is not None:
            m = re.search(r"(\d+)", ch)
            if m and int(m.group(1)) in win_chs:
                return True
        else:
            # non-chapter ref: match on any distinctive token of length >= 4
            for w in re.findall(r"[A-Za-z0-9][A-Za-z0-9-]{3,}", r):
                if w.lower() in low:
                    return True
    return False


# ---------------------------------------------------------------- the check

def check(letter_text, ledger_text, strict=False):
    """Run the count floor + anchor floor + regrounding-trace advisory. Returns (code, lines)."""
    lines, errs, warns = [], [], []

    sev = ledger_severities(ledger_text)
    if not sev:
        attempted, parsed = ledger_parse_stats(ledger_text)
        if attempted and not parsed:
            # ALL-malformed ledger: finding blocks exist but none parse. A vacuous "no ledger
            # findings" PASS here would let a corrupted ledger silently disarm the floor —
            # refuse instead. (Partial malformation — some blocks parse — proceeds on the
            # parsed subset below: per-block repair is structured-findings' gate, single
            # ownership, and this refusal only covers the floor-disarming ALL-malformed case.)
            return 1, [
                "specificity-floor: %d apodictic:finding block(s) in the ledger, 0 parsed"
                % attempted,
                "  ERROR: malformed-ledger refusal — the findings ledger is non-empty but ZERO "
                "finding blocks parse to a valid apodictic.finding.v1 record, so the specificity "
                "floor has nothing to hold the letter to and will not issue a vacuous PASS. Fix "
                "the ledger (validate.sh structured-findings owns per-block schema repair) and "
                "re-run.",
                "specificity-floor: FAIL (1 error(s))"]
        return 0, ["specificity-floor: no ledger findings found — nothing to hold to specificity"]

    entries = ledger_entries(ledger_text)
    refs = ledger_evidence_refs(ledger_text)
    windows = letter_windows(letter_text)
    letter_stripped = strip_code_spans(letter_text or "")

    delivered = sorted(windows)   # findings actually cited in the letter body
    for fid in delivered:
        window = windows[fid]
        # per-ID override (legitimately non-countable / non-anchorable) — code-span-stripped,
        # boundary-matched, ID-scoped via the shared helper (meta-lint M5).
        if _fid_override(letter_stripped, fid):
            continue

        # ---- count floor ----
        entry = entries.get(fid, "")
        ledger_counts = count_tokens(entry)
        if ledger_counts:
            window_counts = count_tokens(window)
            vague = has_vague_quantifier(window)
            # Rescue channel (locator/count collision): a restored count eaten by the locator
            # strip ("...in the confession scene 9 belief failures cluster...") is honored iff
            # the ledger entry and the window share the same (count, plural-head) phrase AND the
            # count is one the ledger locks. The LEDGER side is locator-stripped first, so the
            # shared head must be the ledger's own count-noun ("belief failures") — a
            # locator-adjacent s-token ("Chapter 9 opens") can never rescue, even when the
            # chapter number equals the locked count (Codex P2, PR #163). Phrases never ARM the
            # floor — only safe ledger counts do — so this can only un-eat a real restoration,
            # never widen enforcement.
            rescued = {n for (n, _h) in (count_phrases(entry, strip_locators=True)
                                         & count_phrases(window))}
            if vague and not (ledger_counts & (window_counts | rescued)):
                errs.append("count floor: %s — the ledger locks count(s) {%s} but the delivered "
                            "window says %r with no restored count (re-grounding must restore the "
                            "exact number, not decay it to a vague quantifier). Override with "
                            "<!-- override: specificity-floor %s — <why non-countable> --> + an "
                            "Appendix B entry if this finding is legitimately non-countable."
                            % (fid, ", ".join(sorted(ledger_counts)), vague[0], fid))

        # ---- anchor floor (Must-Fix only) ----
        if sev.get(fid) == "Must-Fix":
            if not ref_token_match(window, refs.get(fid, [])):
                errs.append("anchor floor: Must-Fix %s — the delivered window carries no evidence "
                            "reference matching the finding's locked evidence_refs (%s); a restored "
                            "number must ride a ledger-matching anchor. Override with "
                            "<!-- override: specificity-floor %s — <why> --> + Appendix B."
                            % (fid, refs.get(fid, []), fid))

    # ---- regrounding-trace advisory (scoped to full-letter runs) ----
    is_full_letter = _LETTER_TITLE_RE.search(letter_stripped) is not None
    if is_full_letter and not _REGROUNDING_DONE_RE.search(letter_stripped):
        warns.append("regrounding trace: no `<!-- regrounding: done -->` marker in the letter — "
                     "the Pre-Letter Re-Grounding step writes it after re-reading the ledger and "
                     "restoring specificity (run-synthesis.md §Processing Protocol). Presence is "
                     "advisory; the count/anchor floors are the teeth.")

    # ---- report ----
    lines.append("specificity-floor: %d ledger finding(s), %d delivered in the letter"
                 % (len(sev), len(delivered)))
    for e in errs:
        lines.append("  ERROR: %s" % e)
    for w in warns:
        lines.append("  %s: %s" % ("ERROR (--strict)" if strict else "WARN", w))

    if errs or (strict and warns):
        lines.append("specificity-floor: FAIL (%d error(s)%s)"
                     % (len(errs), ", %d strict warn(s)" % len(warns) if (strict and warns) else ""))
        return 1, lines
    if warns:
        lines.append("specificity-floor: PASS with %d WARN (regrounding-trace advisory; --strict "
                     "promotes)" % len(warns))
        return 0, lines
    lines.append("specificity-floor: PASS (count floor + anchor floor clean)")
    return 0, lines


def _fid_override(letter_stripped, fid):
    """True iff a live ID-scoped `<!-- override: specificity-floor F-... -->` marker names `fid`.

    Uses the shared override_marker discipline (code spans already stripped by the caller, but
    has_override re-strips harmlessly) with the ID as part of the slug so the acknowledgement is
    finding-scoped: a bare `specificity-floor` marker acknowledges nothing (mirrors the
    softness-downgrade ID-scoping in run-synthesis.md §Deficit Lock)."""
    return has_override(letter_stripped, "specificity-floor %s" % fid)


# ---------------------------------------------------------------- self-test

def _finding(fid, severity, refs, mechanism="m"):
    return ('<!-- apodictic:finding\n{"schema":"apodictic.finding.v1","id":"%s","mechanism":"%s",'
            '"severity":"%s","confidence":"HIGH","evidence_refs":%s,"fix_class":"x",'
            '"risk_if_fixed":"y"}\n-->' % (fid, mechanism, severity, json.dumps(refs)))


# The founding example (docs/subagent-architecture-design.md:15): Pass 1's "nine belief failures".
_LEDGER = (
    "# Findings Ledger — Example (consolidated)\n\n"
    "## Mechanism: Unmotivated Confession\n\n### Notable Findings\n\n"
    "The narrator's Ch 12 confession (sc. 30-31) lands with no visible pressure — nine\n"
    "belief failures cluster here (confirmed by Pass 1, Pass 5).\n\n"
    + _finding("F-P5-01", "Must-Fix", ["Ch 12 (sc. 30-31)"], "unmotivated confession") + "\n\n"
    "## Mechanism: Opening Orientation Drag\n\n### Notable Findings\n\n"
    "Ch 3 opens without re-anchoring the timeline after the Ch 2 jump.\n\n"
    + _finding("F-P1-02", "Should-Fix", ["Ch 3"], "orientation drag") + "\n")


def _letter(short, whatneeds, regrounded=True):
    """A minimal letter: title block, optional regrounding marker, a Short Version, and a
    'What Needs Work' section carrying the finding windows."""
    rg = "<!-- regrounding: done -->\n" if regrounded else ""
    return ("# Development Edit: Example\n### A. Author | 118,000 words | Draft 3\n"
            "*APODICTIC Development Editor v2 — 2026-01-01*\n\n"
            "<!-- coverage: ok -->\n" + rg + "\n"
            "## The Short Version\n\n" + short + "\n\n"
            "## What Needs Work\n\n" + whatneeds + "\n")


def run_self_test():
    rc = {"v": 0}

    def chk(name, cond):
        print("  %s: %s" % (name, "OK" if cond else "FAIL"))
        if not cond:
            rc["v"] = 1

    def out_has(lines, token):
        return any(token in ln for ln in lines)

    # 0. NON-UTF8 REQUIRED artifact on the CLI read path must FAIL CLOSED — a named usage error
    #    and exit 2 (never a raw UnicodeDecodeError traceback, and never a vacuous PASS). The #163
    #    sweep matched the disposition_check advisory degrade-to-None sibling, but this floor is
    #    BLOCKING: collapsing an unreadable letter/ledger to "" bypasses the count/anchor floor with
    #    a real ledger (Codex #164 P2). The reader still returns None on a read/decode failure; it is
    #    main() that now refuses to run rather than degrade. Both the letter-read and ledger-read
    #    failure paths are pinned, and the readable-empty (letter="") case stays green below.
    import tempfile as _tempfile
    _nud = _tempfile.mkdtemp()
    try:
        _nu = os.path.join(_nud, "letter.md")
        _ok = os.path.join(_nud, "ledger.md")
        with open(_nu, "wb") as _fh:
            _fh.write(b"\xff\xfenot utf-8\xff")
        with open(_ok, "w", encoding="utf-8") as _fh:
            _fh.write("# ok\n")
        chk("non_utf8_read_returns_none", _read(_nu) is None)
        # non-UTF8 LETTER + a real ledger with findings: fail closed (exit 2), not a vacuous PASS.
        _real = _LEDGER
        _real_p = os.path.join(_nud, "real_ledger.md")
        with open(_real_p, "w", encoding="utf-8") as _fh:
            _fh.write(_real)
        chk("non_utf8_letter_fails_closed",
            main(["specificity_floor.py", "specificity-floor", _nu, _real_p]) == 2)
        # non-UTF8 LEDGER (readable letter): fail closed too — a real ledger must never read as empty.
        chk("non_utf8_ledger_fails_closed",
            main(["specificity_floor.py", "specificity-floor", _ok, _nu]) == 2)
        # readable-but-EMPTY letter (readable "") must NOT be swept up by the fail-closed change:
        # it reads as "", not None, so the CLI runs; an empty letter delivers 0 findings and the
        # floor has nothing to hold, exit 0 (unchanged readable-empty semantics).
        _empty = os.path.join(_nud, "empty.md")
        with open(_empty, "w", encoding="utf-8") as _fh:
            _fh.write("")
        chk("readable_empty_letter_still_runs",
            main(["specificity_floor.py", "specificity-floor", _empty, _real_p]) == 0)
    finally:
        import shutil as _shutil
        _shutil.rmtree(_nud)

    # 1. GREEN: the restored letter names "nine belief failures" with the Ch 12 anchor
    green = _letter(
        "The book works; targeted revision.",
        "The Ch 12 confession triggers nine belief failures (sc. 30-31). "
        "<!-- finding: F-P5-01 -->\n\n"
        "Ch 3 opens without re-anchoring the timeline. <!-- finding: F-P1-02 -->")
    code, lines = check(green, _LEDGER)
    chk("green_restored_pass", code == 0 and out_has(lines, "count floor + anchor floor clean"))
    chk("green_restored_strict", check(green, _LEDGER, strict=True)[0] == 0)

    # 2. SALIENCE-DECAY (the headline, spec fixture M2-1): "several belief failures", no count
    decayed = _letter(
        "The book works; targeted revision.",
        "The Ch 12 confession triggers several belief failures (sc. 30-31). "
        "<!-- finding: F-P5-01 -->")
    code, lines = check(decayed, _LEDGER)
    chk("decay_count_floor_fail", code == 1 and out_has(lines, "ERROR: count floor")
        and out_has(lines, "F-P5-01") and out_has(lines, "several"))

    # 3. CASING (spec fixture M2-6): a sentence-initial "Nine" satisfies the ledger "nine"
    cased = _letter(
        "The book works.",
        "Nine belief failures cluster at the Ch 12 confession (sc. 30-31), even after several "
        "smaller beats. <!-- finding: F-P5-01 -->")
    code, lines = check(cased, _LEDGER)
    chk("casing_pass", code == 0 and out_has(lines, "count floor + anchor floor clean"))

    # 4. ANCHOR-DRIFT (spec fixture M2-2): a Must-Fix window with a vague quantifier avoided but no
    #    ledger-matching evidence reference -> anchor-floor FAIL
    drift = _letter(
        "The book works.",
        "The confession triggers nine belief failures, though the chapter is hard to place. "
        "<!-- finding: F-P5-01 -->")
    code, lines = check(drift, _LEDGER)
    chk("anchor_drift_fail", code == 1 and out_has(lines, "ERROR: anchor floor")
        and out_has(lines, "F-P5-01"))

    # 5. LEGITIMATE-VAGUENESS (spec fixture M2-5): ID-scoped override + (Appendix B is prose) -> pass
    overridden = _letter(
        "The book works.",
        "The confession triggers several belief failures at Ch 12 (sc. 30-31). "
        "<!-- finding: F-P5-01 --> "
        "<!-- override: specificity-floor F-P5-01 — the cluster count varies by reader; not a "
        "fixed number -->")
    code, lines = check(overridden, _LEDGER)
    chk("override_pass", code == 0 and out_has(lines, "count floor + anchor floor clean"))

    # 6. SMUGGLED FINDING is finding-trace E1's, NOT ours (spec §M2.3 / fixture M2-3): a letter
    #    citing an ID absent from the ledger is simply NOT a delivered window here — no false
    #    count/anchor failure, no reverse-ID enforcement duplicated.
    smuggled = _letter(
        "The book works.",
        "A new problem: several structural gaps. <!-- finding: F-P5-99 -->")
    code, lines = check(smuggled, _LEDGER)
    chk("smuggled_not_our_job", code == 0 and not out_has(lines, "F-P5-99"))

    # 7. MASKING is M1's V5, NOT ours (spec fixture M2-4): re-grounding cannot silence the coverage
    #    note. Here we only confirm specificity-floor does not spuriously pass/fail on a
    #    regrounding marker — the manifest/marker masking is synthesis-coverage's tooth.
    #    (specificity-floor is orthogonal: it holds counts, not coverage.)
    code, lines = check(green, _LEDGER)
    chk("masking_is_m1s_job", code == 0)

    # 8. no finding markers in the letter -> nothing delivered -> vacuous pass (a legacy/no-marker
    #    letter is graceful, not a failure — mirrors crosslink's no-marker handling)
    nomarkers = _letter("The book works.", "The Ch 12 confession needs pressure.")
    code, lines = check(nomarkers, _LEDGER)
    chk("no_markers_graceful", code == 0 and out_has(lines, "0 delivered"))

    # 9. REGROUNDING-TRACE advisory: a full letter with delivered findings but no regrounding
    #    marker -> WARN by default (exit 0), ERROR under --strict
    no_trace = _letter(
        "The book works.",
        "The confession triggers nine belief failures at Ch 12 (sc. 30-31). "
        "<!-- finding: F-P5-01 -->", regrounded=False)
    code, lines = check(no_trace, _LEDGER)
    chk("no_trace_warn", code == 0 and out_has(lines, "WARN") and out_has(lines, "regrounding trace"))
    chk("no_trace_strict_fails", check(no_trace, _LEDGER, strict=True)[0] == 1)

    # 10. VAGUE MULTI-WORD quantifier ("a number of") is caught
    multiword = _letter(
        "The book works.",
        "The confession triggers a number of belief failures at Ch 12 (sc. 30-31). "
        "<!-- finding: F-P5-01 -->")
    code, lines = check(multiword, _LEDGER)
    chk("multiword_vague_fail", code == 1 and out_has(lines, "count floor")
        and out_has(lines, "a number of"))

    # 11. HOSTILE: an override marker quoted inside a code fence must NOT be honored (M5 helper)
    fenced_override = _letter(
        "The book works.",
        "The confession triggers several belief failures at Ch 12 (sc. 30-31). "
        "<!-- finding: F-P5-01 -->\n\n```\n<!-- override: specificity-floor F-P5-01 — decoy -->\n```")
    code, lines = check(fenced_override, _LEDGER)
    chk("fenced_override_not_honored", code == 1 and out_has(lines, "count floor"))

    # 12. HOSTILE: a suffixed override slug (`specificity-floor-extra`) must NOT satisfy the
    #     ID-scoped override for F-P5-01 (boundary discipline via override_marker)
    suffixed = _letter(
        "The book works.",
        "The confession triggers several belief failures at Ch 12 (sc. 30-31). "
        "<!-- finding: F-P5-01 --> "
        "<!-- override: specificity-floor-extra F-P5-01 — not the right slug -->")
    code, lines = check(suffixed, _LEDGER)
    chk("suffixed_override_rejected", code == 1 and out_has(lines, "count floor"))

    # 13. HOSTILE: the count token appears only inside a manuscript QUOTE in the window — it still
    #     counts (the floor matches over the window's full text; documented). A restored quote that
    #     carries the number satisfies the floor.
    quoted_count = _letter(
        "The book works.",
        'The confession — "the nine of them each recanted" — lands unmotivated at Ch 12 '
        "(sc. 30-31). <!-- finding: F-P5-01 -->")
    code, lines = check(quoted_count, _LEDGER)
    chk("count_in_quote_counts", code == 0)

    # 14. FIREWALL: a finding delivered with a DIFFERENT severity word in its window must not be
    #     read as a severity signal — the validator only classifies from the ledger. A window that
    #     merely contains the word "Must-Fix" in prose changes nothing.
    sev_word = _letter(
        "The book works.",
        "The confession triggers nine belief failures at Ch 12 (sc. 30-31); this is Should-Fix "
        "at most. <!-- finding: F-P1-02 -->")
    code, lines = check(sev_word, _LEDGER)
    chk("severity_word_ignored", code == 0)

    # 15. digit form: the ledger prose could carry "9" and the letter "9" — digit restoration works
    led_digit = _LEDGER.replace("nine\nbelief", "9\nbelief")
    dig = _letter(
        "The book works.",
        "The confession triggers 9 belief failures at Ch 12 (sc. 30-31). <!-- finding: F-P5-01 -->")
    code, lines = check(dig, led_digit)
    chk("digit_restoration_pass", code == 0)
    dig_bad = _letter(
        "The book works.",
        "The confession triggers several belief failures at Ch 12 (sc. 30-31). "
        "<!-- finding: F-P5-01 -->")
    code, lines = check(dig_bad, led_digit)
    chk("digit_decay_fail", code == 1 and out_has(lines, "count floor"))

    # 16. a ledger finding with NO count token in its entry never fires the count floor even under
    #     a vague window (nothing to restore) — F-P1-02 has no count in prose or block
    no_count = _letter(
        "The book works.",
        "Ch 3 opens without re-anchoring after several jumps. <!-- finding: F-P1-02 -->")
    code, lines = check(no_count, _LEDGER)
    chk("no_ledger_count_no_fire", code == 0)

    # 17. empty ledger -> nothing to trace (no finding blocks at all: graceful, NOT the
    #     malformed-ledger refusal — that needs blocks present, see 23)
    code, lines = check(green, "# empty ledger\n")
    chk("empty_ledger_noop", code == 0 and out_has(lines, "nothing to hold"))

    # 18. TEEN COUNT (matcher P2-1): _DIGIT_RE must cover 10-19 — a ledger "12 belief failures"
    #     decayed to "several" is a count-floor FAIL, and the restored "12" passes.
    led_teen = _LEDGER.replace("nine\nbelief", "12\nbelief")
    teen_decay = _letter(
        "The book works.",
        "The Ch 12 confession triggers several belief failures (sc. 30-31). "
        "<!-- finding: F-P5-01 -->")
    code, lines = check(teen_decay, led_teen)
    chk("teen_decay_fail", code == 1 and out_has(lines, "count floor") and out_has(lines, "12"))
    teen_ok = _letter(
        "The book works.",
        "The Ch 12 confession triggers 12 belief failures (sc. 30-31). <!-- finding: F-P5-01 -->")
    chk("teen_restored_pass", check(teen_ok, led_teen)[0] == 0)

    # 19. PLURAL LOCATORS (matcher P2-2): "scenes 30-31" / "chapters 30-31" / "sections 40-41"
    #     are locators — their numbers must be stripped on BOTH sides, so a decayed window can't
    #     borrow them as "restored counts" when the ledger prose uses the same plural spelling.
    led_plural = _LEDGER.replace("(sc. 30-31)", "(scenes 30-31)")
    plural_decay = _letter(
        "The book works.",
        "The Ch 12 confession triggers several belief failures (scenes 30-31). "
        "<!-- finding: F-P5-01 -->")
    code, lines = check(plural_decay, led_plural)
    chk("plural_scenes_fail", code == 1 and out_has(lines, "count floor"))
    plural_more = _letter(
        "The book works.",
        "The Ch 12 confession triggers several belief failures across chapters 30-31 and "
        "sections 40-41. <!-- finding: F-P5-01 -->")
    code, lines = check(plural_more, led_plural)
    chk("plural_chapters_sections_fail", code == 1 and out_has(lines, "count floor"))

    # 20. WORD<->DIGIT EQUIVALENCE (matcher P2-3), both directions: a faithful restoration in
    #     the other spelling (+ an incidental vague word elsewhere in the window) must PASS.
    word_to_digit = _letter(
        "The book works.",
        "The Ch 12 confession triggers 9 belief failures (sc. 30-31), echoed in several "
        "smaller beats. <!-- finding: F-P5-01 -->")
    chk("word_ledger_digit_letter_pass", check(word_to_digit, _LEDGER)[0] == 0)
    led_digit9 = _LEDGER.replace("nine\nbelief", "9\nbelief")
    digit_to_word = _letter(
        "The book works.",
        "The Ch 12 confession triggers Nine belief failures (sc. 30-31), echoed in several "
        "smaller beats. <!-- finding: F-P5-01 -->")
    chk("digit_ledger_word_letter_pass", check(digit_to_word, led_digit9)[0] == 0)

    # 21. LOCATOR/COUNT COLLISION (matcher P3-1), both fixtures: (a) a real count heading a
    #     count-noun phrase right after a locator keyword ("...scene 9 belief failures...") is
    #     RESCUED via the shared ledger<->window count phrase — no false FAIL; (b) a bare
    #     locator number ("at scene 9") still never counts as a restoration.
    collision_ok = _letter(
        "The book works.",
        "In the confession scene 9 belief failures cluster (Ch 12, sc. 30-31), echoed in "
        "several smaller beats. <!-- finding: F-P5-01 -->")
    chk("locator_count_rescued_pass", check(collision_ok, _LEDGER)[0] == 0)
    bare_locator = _letter(
        "The book works.",
        "The confession triggers several belief failures at scene 9 (Ch 12). "
        "<!-- finding: F-P5-01 -->")
    code, lines = check(bare_locator, _LEDGER)
    chk("bare_locator_no_credit_fail", code == 1 and out_has(lines, "count floor"))

    # 22. SPELLED-OUT LOCATOR (matcher P3-2): "Chapter Nine" normalizes to "Chapter 9" and is
    #     stripped as a locator — its "Nine" must NOT masquerade as the restored ledger count.
    spelled_locator = _letter(
        "The book works.",
        "The Chapter Nine confession triggers several belief failures (Ch 12, sc. 30-31). "
        "<!-- finding: F-P5-01 -->")
    code, lines = check(spelled_locator, _LEDGER)
    chk("spelled_locator_no_credit_fail", code == 1 and out_has(lines, "count floor"))

    # 23. MALFORMED-LEDGER REFUSAL (matcher P3-4): finding blocks present but ZERO parse ->
    #     named ERROR, exit 1 — never a vacuous "no ledger findings" PASS. Partial malformation
    #     (one broken sibling + one good block) proceeds on the parsed subset (single ownership:
    #     per-block repair is structured-findings').
    all_malformed = (
        "# Findings Ledger\n\n## Mechanism: X\n\n### Notable Findings\n\nnine belief failures.\n\n"
        '<!-- apodictic:finding\n{"schema":"apodictic.finding.v1","id":"F-P5-01",  BROKEN\n-->\n')
    code, lines = check(green, all_malformed)
    chk("all_malformed_refused", code == 1 and out_has(lines, "malformed-ledger refusal"))
    partial_malformed = _LEDGER + (
        '\n<!-- apodictic:finding\n{"schema":"apodictic.finding.v1","id":"F-P9-77",  BROKEN\n-->\n')
    chk("partial_malformed_proceeds", check(green, partial_malformed)[0] == 0)

    # 24. CHAPTER SURFACE FORMS (matcher P3-7): the anchor floor matches on the chapter NUMBER,
    #     independent of the letter's spelling — "Chapter 12", "Ch.12", and the spelled-out
    #     "Chapter Twelve" all satisfy the locked ref "Ch 12 (sc. 30-31)".
    for surface in ("Chapter 12", "Ch.12", "Chapter Twelve"):
        anchored = _letter(
            "The book works.",
            "The %s confession triggers nine belief failures. <!-- finding: F-P5-01 -->" % surface)
        chk("anchor_surface_%s" % surface.replace(" ", "_").replace(".", ""),
            check(anchored, _LEDGER)[0] == 0)

    # 25. LOCATOR-COINCIDENCE RESCUE (Codex P2, PR #163): when the locked count EQUALS a
    #     chapter/scene/section number, a shared locator+s-token bigram ("Chapter 9 opens") must
    #     NOT rescue a decayed window — the rescue head must be the LEDGER's own count-noun,
    #     which is why the ledger side of the phrase match is locator-stripped. Exact Codex
    #     repro first, then the sibling coincidence paths (other s-verbs, other locator
    #     keywords, the spelled-out locator, and the one-modifier-word MOD_RE bridge); the
    #     faithful restoration under the SAME ledger stays green.
    def _coincidence_ledger(prose):
        return ("# Findings Ledger\n\n## Mechanism: X\n\n### Notable Findings\n\n" + prose
                + "\n\n" + _finding("F-P5-01", "Must-Fix", ["Ch 9"]) + "\n")

    led_ch9_opens = _coincidence_ledger(
        "Chapter 9 opens with nine belief failures that cluster with no visible pressure.")
    codex_repro = _letter(
        "The book works.",
        "Chapter 9 opens with several belief failures. <!-- finding: F-P5-01 -->")
    code, lines = check(codex_repro, led_ch9_opens)
    chk("locator_coincidence_opens_fail", code == 1 and out_has(lines, "count floor")
        and out_has(lines, "F-P5-01"))
    codex_restored = _letter(
        "The book works.",
        "Chapter 9 opens with nine belief failures. <!-- finding: F-P5-01 -->")
    chk("locator_coincidence_restored_pass", check(codex_restored, led_ch9_opens)[0] == 0)
    led_ch9_stresses = _coincidence_ledger(
        "Chapter 9 stresses the nine belief failures that cluster here.")
    stresses = _letter(
        "The book works.",
        "Chapter 9 stresses several belief failures. <!-- finding: F-P5-01 -->")
    code, lines = check(stresses, led_ch9_stresses)
    chk("locator_coincidence_stresses_fail", code == 1 and out_has(lines, "count floor"))
    led_sec3 = _coincidence_ledger("Section 3 collapses under three timeline breaks (Ch 9).")
    collapses = _letter(
        "The book works.",
        "Section 3 collapses under several timeline breaks (Chapter 9). "
        "<!-- finding: F-P5-01 -->")
    code, lines = check(collapses, led_sec3)
    chk("locator_coincidence_sections_fail", code == 1 and out_has(lines, "count floor"))
    led_spelled = _coincidence_ledger(
        "Chapter Nine opens with nine belief failures that cluster with no visible pressure.")
    spelled = _letter(
        "The book works.",
        "Chapter Nine opens with several belief failures. <!-- finding: F-P5-01 -->")
    code, lines = check(spelled, led_spelled)
    chk("locator_coincidence_spelled_fail", code == 1 and out_has(lines, "count floor"))
    # the MOD_RE bridge: the locked count appears ONLY inside the locator phrase on both sides,
    # and the s-token sits one modifier word after it ("scene 9 confession triggers")
    led_bridge = _coincidence_ledger("The scene 9 confession triggers nine belief failures (Ch 9).")
    bridge = _letter(
        "The book works.",
        "The scene 9 confession triggers several belief failures (Chapter 9). "
        "<!-- finding: F-P5-01 -->")
    code, lines = check(bridge, led_bridge)
    chk("locator_coincidence_bridge_fail", code == 1 and out_has(lines, "count floor"))

    print("Self-test: PASS" if rc["v"] == 0 else "Self-test: FAIL")
    return rc["v"]


def main(argv):
    if "--self-test" in argv:
        return run_self_test()
    args = [a for a in argv[1:] if a != "specificity-floor"]
    strict = "--strict" in args
    paths = [a for a in args if not a.startswith("--")]
    if len(paths) != 2 or not os.path.isfile(paths[0]) or not os.path.isfile(paths[1]):
        print("Usage: specificity_floor.py specificity-floor <editorial_letter> <findings_ledger> "
              "[--strict] | --self-test")
        return 2
    # An unreadable/undecodable REQUIRED artifact FAILS CLOSED on this blocking floor: a read
    # failure returns None, which we must NOT collapse to "" (that would degrade an unreadable
    # letter to a vacuous "0 delivered" PASS and an unreadable ledger to "no findings — nothing
    # to hold", silently bypassing the count/anchor floor — a fail-OPEN on a blocking gate,
    # Codex #164 P2). Emit a named usage error and exit 2, mirroring refutation_check._read_file's
    # (None, msg) -> "Error:" -> exit-2 shape. A legitimately-empty-but-READABLE artifact returns
    # "" (not None) and is unaffected — its existing readable-empty semantics are preserved.
    letter_text = _read(paths[0])
    ledger_text = _read(paths[1])
    for text, path, what in ((letter_text, paths[0], "editorial letter"),
                             (ledger_text, paths[1], "findings ledger")):
        if text is None:
            print("Error: cannot read %s %s (unreadable or not UTF-8) — the specificity floor is "
                  "blocking and refuses to run on an unreadable artifact rather than degrade to an "
                  "empty input and pass vacuously" % (what, path))
            return 2
    code, lines = check(letter_text, ledger_text, strict=strict)
    for ln in lines:
        print(ln)
    return code


if __name__ == "__main__":
    sys.exit(main(sys.argv))

SHA-256: 25da989b7a15bd4e4204bb64c2b0afc54d8dff6fe3659b531bc18e8c67034c7a