← Files APODICTIC Development EditorARCHIVED FILE

scripts/honesty_check.py

29.5 KB · Nov 3, 2026 · 18:57 UTC

↓ Download file

#!/usr/bin/env python3
"""APODICTIC honesty gate (Phase 4): Deficit Lock + Softness Gate.

Closes the uniform-charity / silent-softening leak — a letter that under-delivers
a finding it already locked at Triage, while still passing severity-floor and
audit-signal-propagation.

  softness-check <editorial_letter> <findings_ledger>
      Compare the delivered letter against the Triage-locked findings
      (apodictic.finding.v1 blocks in the ledger). For each locked
      Must-Fix/Should-Fix: when the finding carries a Lifecycle ID, match it to the
      letter by ID (exact) — both the body delivery and the Severity Calibration
      record reference the ID; otherwise fall back to evidence-ref / mechanism
      heuristics. ERROR on a recorded downgrade, a finding buried (absent from the
      author-facing body), or a finding dropped (absent from both), unless a body
      override marker is present. Hedged delivery -> WARN. Weak-axis coherence is
      owned by `severity-floor`, not here.

  deficit-lock <findings_ledger>
      Verify EVERY synthesis-bound (Must-Fix/Should-Fix) finding was locked
      structurally: more prose severity labels than apodictic.finding.v1 blocks
      means a finding was left unlocked -> ERROR.

  --self-test     built-in cases (both checks)

Block grammar + severity ordering come from the shared `apodictic_artifacts`
module (schemas/ are the source of truth). The body override marker is
ID-SCOPED: `<!-- override: softness-downgrade F-<ORIGIN>-<NN> — <rationale> -->`
(body-only; appendix markers are non-canonical). It downgrades ERROR→WARN ONLY
for the named Finding Lifecycle ID — a BARE marker with no resolvable ID
acknowledges nothing (so one marker cannot blanket-mask every locked finding's
downgrade; the gate-bypass closed 2026-06-20). Markers inside code spans
(backticks) are ignored. There is intentionally no blanket/all-findings form.

Exit: 0 pass (no ERROR), 1 ERROR(s), 2 usage error.
"""
import re
import sys

import apodictic_artifacts as art
from override_marker import override_targets  # SSoT: code-span-stripped, boundary-matched override scan

SEV_RANK = art.SEVERITY_RANK
SEV_TOKENS = tuple(SEV_RANK)
LOCKED = {"Must-Fix", "Should-Fix"}
HEDGES = ["could perhaps", "might benefit", "somewhat", "arguably",
          "relatively minor", "on the softer side", "not a dealbreaker",
          "could be strengthened", "may want to consider", "if you have time",
          "a minor quibble", "nitpick"]
STOP = {"the", "and", "for", "not", "with", "that", "this", "from", "into",
        "when", "does", "are", "was", "but", "its", "has", "you", "your",
        "than", "then", "doesn", "isn"}
# Prose severity labels marking a synthesis-bound finding (outside any block).
PROSE_SEVERITY_RE = re.compile(
    r"(?:\*\*\s*|Severity[:\s]+|held at\s+|Tier[:\s]+|(?:^|\n)[ \t]*(?:[-*]|\d+[.)])[ \t]+)(Must-Fix|Should-Fix)\b")
APPENDIX_A_RE = re.compile(r"Appendix\s+A\b", re.IGNORECASE)
SEVCAL_RE = re.compile(r"Severity\s+Calibration|Appendix\s+B\b", re.IGNORECASE)
NEXT_APPENDIX_RE = re.compile(r"Appendix\s+[C-Z]\b", re.IGNORECASE)
# An override is acknowledged ONLY when it is an HTML-comment-anchored, ID-scoped directive naming the
# exact Finding Lifecycle ID it downgrades: `<!-- override: softness-downgrade F-P5-01 — <rationale> -->`.
# A BARE marker (no resolvable ID) acknowledges NOTHING — a single unscoped marker must not blanket-mask
# every locked finding's downgrade (that would dismantle the Deficit Lock, severity honesty's core).
# Read via the shared override_marker SSoT (code spans stripped, slug + id boundary-matched).
def soft_overrides(body):
    """The set of Finding Lifecycle IDs a body's ID-scoped softness-downgrade markers acknowledge.

    Via the shared `override_marker.override_targets`, which strips code spans (a documentation EXAMPLE of
    the marker is not honored as a live override) AND boundary-matches both the `softness-downgrade` slug
    and the captured Finding Lifecycle ID — one SSoT for stripping and marker-matching, so neither a
    hand-rolled stripper nor a hand-rolled marker regex can drift back in (gated by M5 / M6)."""
    return {t[0] for t in override_targets(body, "softness-downgrade", r"(F-[A-Za-z0-9]+-[0-9]{2,})")}


def parse_locked_findings(ledger_text):
    """Locked apodictic.finding.v1 objects from the ledger (via the shared parser)."""
    out = []
    for btype, obj, jerr in art.parse_blocks(ledger_text):
        if jerr or btype != "finding" or not isinstance(obj, dict):
            continue
        if str(obj.get("schema", "")).startswith("apodictic.finding."):
            out.append(obj)
    return out


def _mech_tokens(mech):
    words = re.findall(r"[A-Za-z][A-Za-z'\-]{2,}", str(mech).lower())
    return {w for w in words if w not in STOP}


def _ref_present(ref, region):
    """Token-boundary match for an evidence ref (so 'Chapter 3' != 'Chapter 34')."""
    return re.search(r"(?<![\w])%s(?![\w])" % re.escape(ref), region) is not None


def _id_present(region, fid):
    """Exact Lifecycle-ID token match (so F-P5-01 != F-P5-011)."""
    return re.search(r"(?<![\w-])%s(?![\w-])" % re.escape(fid), region) is not None


def _id_delivered_in_body(body, fid, finding, radius=400):
    """The ID LOCATES the delivery, but real author-facing prose must show the
    finding near it — a bare `<!-- finding: ID -->` comment is not a delivery.
    Look in a window around each ID occurrence (HTML comments stripped) for the
    finding via the mechanism/evidence heuristic."""
    for m in re.finditer(r"(?<![\w-])%s(?![\w-])" % re.escape(fid), body):
        window = re.sub(r"<!--.*?-->", " ", body[max(0, m.start() - radius):m.end() + radius], flags=re.DOTALL)
        if _region_contains(window, finding):
            return True
    return False


def _region_contains(region, finding):
    """Heuristic presence: any evidence_ref (token-boundary) or >= 2 mechanism tokens."""
    refs = [r for r in (finding.get("evidence_refs") or []) if isinstance(r, str) and r]
    if any(_ref_present(r, region) for r in refs):
        return True
    low = region.lower()
    toks = _mech_tokens(finding.get("mechanism", ""))
    return sum(1 for t in toks if re.search(r"\b%s\b" % re.escape(t), low)) >= 2


def _body(text):
    m = APPENDIX_A_RE.search(text) or SEVCAL_RE.search(text)
    return text[:m.start()] if m else text


def _sevcal(text):
    m = SEVCAL_RE.search(text)
    if not m:
        return ""
    rest = text[m.end():]
    nxt = NEXT_APPENDIX_RE.search(rest)
    return rest[:nxt.start()] if nxt else rest


def _last_severity_on(line):
    """The DELIVERED severity = last severity token on the line ('softened to X')."""
    delivered, pos = None, -1
    for sev in SEV_TOKENS:
        p = line.rfind(sev)
        if p > pos:
            pos, delivered = p, sev
    return delivered


def _recorded_severity(sevcal, finding):
    """Heuristic: delivered severity on the calibration line matching the finding
    (mechanism / refs). Lowest across matching lines (any downgrade counts)."""
    best = None
    for line in sevcal.splitlines():
        if not _region_contains(line, finding):
            continue
        d = _last_severity_on(line)
        if d is not None and (best is None or SEV_RANK[d] < SEV_RANK[best]):
            best = d
    return best


def _recorded_severity_by_id(sevcal, fid):
    """Exact: delivered severity across ALL calibration lines citing the finding's
    ID; lowest wins, so a later 'softened to Should-Fix' line is authoritative
    (mirrors the heuristic path)."""
    best = None
    for line in sevcal.splitlines():
        if _id_present(line, fid):
            d = _last_severity_on(line)
            if d is not None and (best is None or SEV_RANK[d] < SEV_RANK[best]):
                best = d
    return best


def _structured_calibration(letter_text):
    """(id -> delivered severity, block_errors). Embedded apodictic:severity_calibration blocks
    are read in preference to the prose heuristic (lowest delivered wins on repeats). Each block
    is schema-validated: a malformed or schema-invalid block is an ERROR rather than being
    silently skipped — the structured Severity Calibration is a delivery contract, so an invalid
    block must not quietly fall back to prose delivery."""
    out, errs = {}, []
    schema = art.load_schema("apodictic.severity_calibration.v1")
    n = 0
    for btype, obj, jerr in art.parse_blocks(letter_text):
        if btype != "severity_calibration":
            continue
        n += 1
        where = "Severity Calibration block #%d" % n
        if jerr:
            errs.append("%s: invalid JSON — %s" % (where, jerr))
            continue
        if not isinstance(obj, dict):
            errs.append("%s: not a JSON object" % where)
            continue
        block_errs = art.validate_obj(obj, schema, where) if schema is not None else []
        if block_errs:
            errs.extend(block_errs)
            continue
        fid, delivered = obj.get("id"), obj.get("delivered")
        if fid and delivered in SEV_RANK:
            if fid not in out or SEV_RANK[delivered] < SEV_RANK[out[fid]]:
                out[fid] = delivered
    return out, errs


def _has_hedge(body, finding):
    for line in body.splitlines():
        if _region_contains(line, finding) and any(h in line.lower() for h in HEDGES):
            return True
    return False


def _short(s, n=60):
    s = str(s)
    return s if len(s) <= n else s[:n] + "…"


def softness_check(letter_text, ledger_text):
    """Return (errors, warnings)."""
    errs, warns = [], []
    body = _body(letter_text)
    sevcal = _sevcal(letter_text)
    struct_cal, cal_block_errs = _structured_calibration(letter_text)
    # A malformed/schema-invalid Severity Calibration block is a broken delivery contract —
    # hard ERROR (the softness-downgrade override cannot mask it).
    errs.extend("invalid Severity Calibration block — %s" % e for e in cal_block_errs)
    overrides = soft_overrides(body)
    # parse_locked_findings only yields dicts (it filters non-dict blocks upstream), so the real
    # crash surface here is a dict finding with a NON-STRING `id`: an int/list id reaching
    # re.escape(fid) in _id_delivered_in_body raised an uncaught TypeError. The `isinstance(fid, str)`
    # guards below route a non-string id to the id-less heuristic path (controlled ERROR, no crash).
    for f in parse_locked_findings(ledger_text):
        lock = f.get("severity")
        if lock not in LOCKED:
            continue
        mech = _short(f.get("mechanism", "?"))
        fid = f.get("id")
        if fid and isinstance(fid, str):
            in_body = _id_delivered_in_body(body, fid, f)
            # Prefer the structured Severity Calibration block; fall back to prose.
            rec = struct_cal.get(fid)
            if rec is None:
                rec = _recorded_severity_by_id(sevcal, fid)
            label = "%s — %s" % (fid, mech)
        else:
            in_body = _region_contains(body, f)
            rec = _recorded_severity(sevcal, f)
            label = mech
        problem = None
        if rec is not None and SEV_RANK[rec] < SEV_RANK[lock]:
            problem = "Severity Calibration records %s, below the locked %s" % (rec, lock)
        elif not in_body and rec is None:
            problem = "locked %s finding absent from both the body and Severity Calibration (dropped)" % lock
        elif not in_body:
            problem = "locked %s finding recorded in Severity Calibration but absent from the author-facing body (buried)" % lock
        if problem:
            msg = "%s (%s)" % (problem, label)
            # Acknowledged ONLY by an ID-scoped override naming this finding's Lifecycle ID. An id-less
            # (legacy heuristic) finding has no ID to name, so it cannot be acknowledged — give it a
            # Lifecycle ID (the Deficit Lock already requires structured findings) to override its downgrade.
            if fid and isinstance(fid, str) and fid in overrides:
                warns.append(msg + " — softness-downgrade override for %s present (acknowledged)" % fid)
            elif fid and isinstance(fid, str):
                errs.append(msg + " — no softness-downgrade override naming %s in body" % fid)
            else:
                errs.append(msg + " — no ID-scoped softness-downgrade override (an id-less finding "
                            "cannot be acknowledged; assign it a Finding Lifecycle ID)")
        elif _has_hedge(body, f):
            warns.append("locked %s finding (%s) delivered but hedged in the body" % (lock, label))
    return errs, warns


def deficit_lock(ledger_text):
    """Return (errors, warnings). Verifies EVERY synthesis-bound finding was locked."""
    errs, warns = [], []
    locks = parse_locked_findings(ledger_text)
    finding_schema = art.load_schema("apodictic.finding.v1")
    # Only SCHEMA-VALID locks count — a block missing required fields is not a lock.
    n_struct = sum(1 for f in locks if f.get("severity") in LOCKED
                   and (finding_schema is None or not art.validate_obj(f, finding_schema)))
    prose_labels = len(PROSE_SEVERITY_RE.findall(art.BLOCK_RE.sub("", ledger_text)))
    if prose_labels > n_struct:
        errs.append("ledger has %d synthesis-bound (Must-Fix/Should-Fix) finding label(s) but only "
                    "%d structured apodictic.finding.v1 lock(s) — every synthesis-bound finding must "
                    "be locked (Deficit Lock)" % (prose_labels, n_struct))
    return errs, warns


def report(errs, warns, label):
    for w in warns:
        print("WARN: %s" % w)
    for e in errs:
        print("ERROR: %s" % e)
    if errs:
        print("%s: FAIL (%d error(s), %d warning(s))" % (label, len(errs), len(warns)))
        return 1
    print("%s: PASS%s" % (label, (" (%d warning(s))" % len(warns)) if warns else ""))
    return 0


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

    def check(name, errs, expect_clean):
        if (len(errs) == 0) == expect_clean:
            print("  %s: OK" % name)
        else:
            print("  %s: FAIL (errs=%s)" % (name, errs))
            rc["v"] = 1

    # non-UTF8 artifact on the CLI read path must degrade to the usage-error exit (2),
    # never a traceback (the disposition_check adjacent-exception class, swept repo-wide)
    import os as _os
    import tempfile as _tf
    _fd, _nu = _tf.mkstemp(suffix=".md")
    with _os.fdopen(_fd, "wb") as _fh:
        _fh.write(b"\xff\xfenot utf-8\xff")
    check("non_utf8_cli_degrades",
          [] if main(["honesty_check.py", "deficit-lock", _nu]) == 2 else ["non-2 exit"], True)
    _os.unlink(_nu)

    # ---- heuristic path (id-less findings; backward-compat) ----
    lock = ('<!-- apodictic:finding\n'
            '{"schema":"apodictic.finding.v1","mechanism":"Theo has no arc; protagonist does not change",'
            '"severity":"Must-Fix","confidence":"HIGH","evidence_refs":["Chapter 34"],'
            '"fix_class":"x","risk_if_fixed":"y"}\n-->')

    def letter(body_line, sevcal_sev):
        return ("# Edit\n## What Needs Work\n" + body_line + "\n"
                "## Appendix A: Diagnostic Detail\np\n"
                "## Appendix B: Severity Calibration\n"
                "Theo's zero arc: Pass 5 confirms. Severity held at " + sevcal_sev + ".\n")

    check("good_delivered_and_recorded",
          softness_check(letter("Theo has no arc, and the catalyst defense fails (Chapter 34).", "Must-Fix"), lock)[0], True)
    check("downgrade_in_calibration",
          softness_check(letter("Theo has no arc (Chapter 34).", "Should-Fix"), lock)[0], False)
    check("buried_absent_from_body",
          softness_check(letter("The pacing wanders a little in the middle.", "Must-Fix"), lock)[0], False)
    check("dropped_from_both",
          softness_check("# Edit\n## What Needs Work\nPacing only.\n## Appendix B: Severity Calibration\nNothing relevant.\n", lock)[0], False)
    lock_ch3 = ('<!-- apodictic:finding\n{"schema":"apodictic.finding.v1","mechanism":"subplot stall qrstuv",'
                '"severity":"Must-Fix","confidence":"HIGH","evidence_refs":["Chapter 3"],"fix_class":"x","risk_if_fixed":"y"}\n-->')
    check("prefix_ref_not_matched",
          softness_check("# Edit\n## What Needs Work\nMust-Fix: pacing wanders in Chapter 34.\n"
                         "## Appendix B: Severity Calibration\nChapter 34 pacing: Severity held at Must-Fix.\n", lock_ch3)[0], False)
    # a BARE marker (no Lifecycle ID) acknowledges NOTHING (the 2026-06-20 gate-bypass fix): an id-less
    # finding cannot be named, so its downgrade is a hard ERROR — give it a Lifecycle ID to override.
    body_mk = ("# Edit\n## What Needs Work\nTheo has no arc (Chapter 34).\n"
               "<!-- override: softness-downgrade — over-diagnosed; see Appendix B -->\n"
               "## Appendix B: Severity Calibration\nTheo's zero arc: Severity Should-Fix.\n")
    check("id_less_bare_marker_not_acknowledged", softness_check(body_mk, lock)[0], False)
    e_h, w_h = softness_check(letter("Theo's arc could perhaps be strengthened (Chapter 34).", "Must-Fix"), lock)
    check("hedged_warns_no_error", e_h, True)
    check("hedged_has_warning", [] if w_h else ["no warning"], True)

    # ---- ID path (exact matching) ----
    lock_id = ('<!-- apodictic:finding\n'
               '{"schema":"apodictic.finding.v1","id":"F-P5-02","mechanism":"protagonist never changes; no arc",'
               '"severity":"Must-Fix","confidence":"HIGH","evidence_refs":["Chapter 34"],'
               '"fix_class":"x","risk_if_fixed":"y"}\n-->')

    def letter_id(fid, sevcal_sev, body_has=True, prose=True):
        body = "# Edit\n## What Needs Work\n"
        if prose:
            body += "The protagonist never changes across the novel (Chapter 34).\n"
        if body_has:
            body += "<!-- finding: %s -->\n" % fid
        return (body + "## Appendix A\np\n## Appendix B: Severity Calibration\n"
                + "%s Theo's zero arc: Severity held at %s.\n" % (fid, sevcal_sev))
    check("id_exact_match_pass", softness_check(letter_id("F-P5-02", "Must-Fix"), lock_id)[0], True)
    check("id_downgrade_errors", softness_check(letter_id("F-P5-02", "Should-Fix"), lock_id)[0], False)
    check("id_buried_errors", softness_check(letter_id("F-P5-02", "Must-Fix", body_has=False, prose=False), lock_id)[0], False)
    # [P1] a bare `<!-- finding: ID -->` comment with no author-facing prose is NOT a delivery
    check("id_comment_only_not_delivered", softness_check(letter_id("F-P5-02", "Must-Fix", prose=False), lock_id)[0], False)
    # near-miss ID must NOT match (boundary): letter cites F-P5-021, lock is F-P5-02
    check("id_near_miss_not_matched", softness_check(letter_id("F-P5-021", "Must-Fix"), lock_id)[0], False)
    # a LATER calibration line that downgrades the same ID is authoritative (lowest wins)
    check("id_later_downgrade_caught",
          softness_check("# Edit\n## What Needs Work\nThe protagonist never changes across the novel (Chapter 34).\n"
                         "<!-- finding: F-P5-02 -->\n## Appendix B: Severity Calibration\n"
                         "F-P5-02 Theo's zero arc: Severity held at Must-Fix.\n"
                         "F-P5-02 on reflection: softened to Should-Fix.\n", lock_id)[0], False)

    # ---- ID-scoped softness-downgrade override (2026-06-20 gate-bypass fix) ----
    def letter_id_ov(fid, marker_id, sevcal_sev="Should-Fix"):
        return ("# Edit\n## What Needs Work\nThe protagonist never changes across the novel (Chapter 34).\n"
                "<!-- finding: %s -->\n"
                "<!-- override: softness-downgrade %s — over-diagnosed; see Appendix B -->\n"
                "## Appendix A\np\n## Appendix B: Severity Calibration\n%s Theo's zero arc: Severity %s.\n"
                % (fid, marker_id, fid, sevcal_sev))
    # an override naming the finding's own ID acknowledges its downgrade -> WARN, no ERROR
    check("id_scoped_override_acknowledges", softness_check(letter_id_ov("F-P5-02", "F-P5-02"), lock_id)[0], True)
    # an override for a DIFFERENT id does NOT acknowledge this finding's downgrade -> ERROR
    check("id_scoped_override_wrong_id_errors", softness_check(letter_id_ov("F-P5-02", "F-P5-99"), lock_id)[0], False)
    # a SUFFIXED id must not match (a F-P5-021 marker must not acknowledge F-P5-02) -> ERROR
    check("id_scoped_override_suffixed_id_errors", softness_check(letter_id_ov("F-P5-02", "F-P5-021"), lock_id)[0], False)
    # a HYPHEN-SUFFIXED id must not over-match (a F-P5-02-extra marker must not acknowledge F-P5-02)
    check("id_scoped_override_hyphen_suffix_errors", softness_check(letter_id_ov("F-P5-02", "F-P5-02-extra"), lock_id)[0], False)
    # a marker inside a backtick code span is a documentation example, not a live override -> ERROR
    check("id_scoped_override_in_backticks_ignored",
          softness_check(letter_id_ov("F-P5-02", "F-P5-02").replace(
              "<!-- override: softness-downgrade F-P5-02 — over-diagnosed; see Appendix B -->",
              "Use `<!-- override: softness-downgrade F-P5-02 -->` to acknowledge."), lock_id)[0], False)
    # migration to override_marker (Codex P1): a TILDE-fenced documentation example of the ID-scoped
    # marker must ALSO be ignored (the old local stripper only handled ``` and single backticks)
    check("id_scoped_override_in_tilde_fence_ignored",
          softness_check(letter_id_ov("F-P5-02", "F-P5-02").replace(
              "<!-- override: softness-downgrade F-P5-02 — over-diagnosed; see Appendix B -->",
              "~~~\n<!-- override: softness-downgrade F-P5-02 -->\n~~~"), lock_id)[0], False)
    # THE GATE-BYPASS REGRESSION: an override for ONE finding must NOT mask ANOTHER finding's downgrade
    lock_two = (lock_id + "\n"
                + lock_id.replace('"id":"F-P5-02"', '"id":"F-P5-03"')
                         .replace("protagonist never changes; no arc", "the subplot vanishes without payoff"))
    letter_two = ("# Edit\n## What Needs Work\n"
                  "The protagonist never changes across the novel (Chapter 34).\n<!-- finding: F-P5-02 -->\n"
                  "The subplot vanishes without payoff (Chapter 12).\n<!-- finding: F-P5-03 -->\n"
                  "<!-- override: softness-downgrade F-P5-02 — over-diagnosed -->\n"
                  "## Appendix A\np\n## Appendix B: Severity Calibration\n"
                  "F-P5-02 Theo's zero arc: Severity Should-Fix.\nF-P5-03 subplot: Severity Should-Fix.\n")
    e_two = softness_check(letter_two, lock_two)[0]
    check("override_scoped_not_blanket", e_two, False)  # F-P5-03 is unmasked -> has an ERROR
    check("override_masks_only_named_id",
          [] if (len(e_two) == 1 and "F-P5-03" in e_two[0] and "F-P5-02" not in e_two[0])
          else ["expected exactly 1 ERROR naming F-P5-03 only, got: %s" % e_two], True)

    # ---- structured Severity Calibration (apodictic.severity_calibration.v1) ----
    def letter_struct(delivered, prose_sev="Must-Fix"):
        direction = "unchanged" if delivered == "Must-Fix" else "softened"
        return ("# Edit\n## What Needs Work\n"
                "The protagonist never changes across the novel (Chapter 34).\n"
                "<!-- finding: F-P5-02 -->\n"
                "## Appendix A\np\n## Appendix B: Severity Calibration\n"
                "F-P5-02 Theo's zero arc: Severity held at %s.\n" % prose_sev
                + '<!-- apodictic:severity_calibration\n'
                  '{"schema":"apodictic.severity_calibration.v1","id":"F-P5-02","locked":"Must-Fix",'
                  '"delivered":"%s","direction":"%s"}\n-->\n' % (delivered, direction))
    check("struct_cal_match_pass", softness_check(letter_struct("Must-Fix"), lock_id)[0], True)
    check("struct_cal_downgrade_errors", softness_check(letter_struct("Should-Fix"), lock_id)[0], False)
    # structured block is authoritative over the prose line (prose says Must-Fix, block says Should-Fix)
    check("struct_cal_overrides_prose", softness_check(letter_struct("Should-Fix", prose_sev="Must-Fix"), lock_id)[0], False)

    # A malformed / schema-invalid calibration block is a hard ERROR (delivery contract),
    # even when the body delivers the locked finding at the right tier (no silent prose fallback).
    def letter_bad_block(block_json):
        return ("# Edit\n## What Needs Work\n"
                "The protagonist never changes across the novel (Chapter 34).\n"
                "<!-- finding: F-P5-02 -->\n"
                "## Appendix B: Severity Calibration\n"
                "<!-- apodictic:severity_calibration\n%s\n-->\n" % block_json)
    bad_delivered = ('{"schema":"apodictic.severity_calibration.v1","id":"F-P5-02",'
                     '"locked":"Must-Fix","delivered":"Critical","direction":"unchanged"}')
    missing_direction = ('{"schema":"apodictic.severity_calibration.v1","id":"F-P5-02",'
                         '"locked":"Must-Fix","delivered":"Must-Fix"}')
    bad_json = ('{"schema":"apodictic.severity_calibration.v1","id":"F-P5-02" '
                '"delivered":"Must-Fix","direction":"unchanged"}')  # missing comma -> invalid JSON
    check("struct_cal_bad_delivered_errors", softness_check(letter_bad_block(bad_delivered), lock_id)[0], False)
    check("struct_cal_missing_direction_errors", softness_check(letter_bad_block(missing_direction), lock_id)[0], False)
    check("struct_cal_malformed_json_errors", softness_check(letter_bad_block(bad_json), lock_id)[0], False)
    # broken carrier: payload has no closing brace before --> (was 0 blocks pre-fix) -> hard ERROR
    broken_carrier = ('{"schema":"apodictic.severity_calibration.v1","id":"F-P5-02",'
                      '"locked":"Must-Fix","delivered":"Critical","direction":"unchanged"')  # no closing }
    check("struct_cal_broken_carrier_errors", softness_check(letter_bad_block(broken_carrier), lock_id)[0], False)

    # ---- deficit-lock ----
    valid_block = ('<!-- apodictic:finding\n{"schema":"apodictic.finding.v1","id":"F-P5-01","mechanism":"m",'
                   '"severity":"Must-Fix","confidence":"HIGH","evidence_refs":["c"],"fix_class":"x","risk_if_fixed":"y"}\n-->')
    check("lock_structured_present", deficit_lock(valid_block)[0], True)
    check("lock_prose_without_struct_errors", deficit_lock("## Ledger\n- Must-Fix: agency collapse (prose only)\n")[0], False)
    check("lock_empty_ledger_ok", deficit_lock("## Ledger\n- data-building pass\n")[0], True)
    realistic = "## Pass 5 — Ledger Entry\n### Notable Findings\n1. **Theo's zero arc.** Severity: Must-Fix.\n" + valid_block + "\n"
    check("lock_all_present_ok", deficit_lock(realistic)[0], True)
    check("lock_partial_missing_errors", deficit_lock(realistic + "2. **Reveal fairness.** Severity: Should-Fix.\n")[0], False)
    # [P2] an INVALID finding block (missing required fields) must not count as a lock
    check("lock_invalid_block_not_counted",
          deficit_lock("## Pass 8 — Ledger Entry\n### Notable Findings\n1. **Reveal.** Severity: Must-Fix.\n"
                       '<!-- apodictic:finding\n{"schema":"apodictic.finding.v1","severity":"Must-Fix"}\n-->\n')[0], False)

    # regression (2026-06-20 sweep): a LOCKED dict finding with a NON-STRING `id` must not crash.
    # Pre-fix, a non-string id reached re.escape(fid) in _id_delivered_in_body -> uncaught TypeError;
    # this call RAISED (failing the self-test). Post-fix it routes to the id-less heuristic path and
    # produces a controlled ERROR (an id-less finding can't be acknowledged), so errs is NON-empty.
    # This is the load-bearing assertion: deleting an `isinstance(fid, str)` guard re-introduces the crash.
    nonstr_id_lock = ('<!-- apodictic:finding\n{"schema":"apodictic.finding.v1","id":42,'
                      '"severity":"Must-Fix","mechanism":"x"}\n-->')
    check("crash_nonstring_id_finding",
          softness_check("# Edit\n## What Needs Work\nTheo has no arc.\n", nonstr_id_lock)[0], False)
    # a non-string id is also exercised through the override-acknowledge branch (line ~258): the
    # `isinstance(fid, str)` guard there must keep `fid in overrides` from ever running on a non-str.
    check("crash_nonstring_id_with_override",
          softness_check("# Edit\n## What Needs Work\nTheo has no arc.\n"
                         "<!-- override: softness-downgrade F-P5-01 — x -->\n", nonstr_id_lock)[0], False)
    # smoke: a non-dict block payload is filtered upstream by parse_locked_findings (never reaches the
    # loop) — it stays clean on both pre- and post-fix code, so it is a coverage smoke test, NOT the
    # regression guard for the crash fix (which is crash_nonstring_id_finding above).
    check("nondict_finding_payload_filtered_upstream",
          softness_check("# Edit\n## What Needs Work\np\n", "<!-- apodictic:finding\n42\n-->")[0], True)
    print("Self-test: PASS" if rc["v"] == 0 else "Self-test: FAIL")
    return rc["v"]


def main(argv):
    if len(argv) < 2:
        print(__doc__)
        return 2
    cmd = argv[1]
    if cmd == "--self-test":
        return run_self_test()
    if cmd == "softness-check":
        if len(argv) < 4:
            print("Usage: honesty_check.py softness-check <editorial_letter> <findings_ledger>")
            return 2
        try:
            letter_text = open(argv[2], encoding="utf-8").read()
            ledger_text = open(argv[3], encoding="utf-8").read()
        except (OSError, UnicodeDecodeError) as exc:
            print("Error: %s" % exc)
            return 2
        return report(*softness_check(letter_text, ledger_text), label="softness-check")
    if cmd == "deficit-lock":
        if len(argv) < 3:
            print("Usage: honesty_check.py deficit-lock <findings_ledger>")
            return 2
        try:
            ledger_text = open(argv[2], encoding="utf-8").read()
        except (OSError, UnicodeDecodeError) as exc:
            print("Error: %s" % exc)
            return 2
        return report(*deficit_lock(ledger_text), label="deficit-lock")
    print("Unknown command: %s" % cmd)
    return 2


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

SHA-256: 5da192eba7e51ef738829d390b00be7e3de1b2a3ce28549d63b2f92889158308