← Files APODICTIC Development EditorARCHIVED FILE

scripts/persona_divergence.py

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

↓ Download file

#!/usr/bin/env python3
"""persona-divergence — structural integrity for Reader-Persona Simulation.

`validate.sh persona-divergence <run_folder|files>` shells out here. Pass 1 maps the experience of
ONE composite reader; a manuscript meets an audience with different tolerances. Persona simulation
runs the reader-experience lens through several declared reading DISPOSITIONS and surfaces where the
predicted experience DIVERGES — divergence is the diagnostic signal, as contract-mismatch is. This
validator owns the divergence-prediction contract and the three mechanical guards that keep it a
LENS, never the non-viable Simulated Reader Focus Group (Horizon item 17): predictions are grounded
(D2), personas are closed-key disposition vectors (D5), and fabricated testimony is scanned (D4).

  D1 schema            a persona / divergence block fails its schema (bad disposition enum, bad
                       target type, malformed P-NN / D-NN id, missing anchor/magnitude/experiences,
                       broken JSON, duplicate id); a nested `experiences` value is not one of
                       engaged|neutral|friction|disengage, or names a persona id that was not declared.
  D2 grounded          a divergence `anchor` resolves to NEITHER a real apodictic.finding.v1 id in the
     prediction       Ledger NOR a real Timeline scene id (locus). An ungrounded prediction is a
                       fabricated one — the signature firewall gate (ERROR).
  D3 target-severity   not exactly one persona is target:true; OR a divergence's optional
     anchoring         `asserted_severity` DIFFERS FROM its anchored finding's locked Ledger severity,
                       OR is asserted on an anchor with NO locked severity (a Timeline locus). The
                       overlay is descriptive: a severity may be asserted only against a Ledger finding
                       whose locked severity it equals — neither downgrade NOR inflate (ERROR).
  D4 no fabricated     the map presents a first-person reader-reaction QUOTE ("I got bored …") as data
     testimony        — invented reader testimony, the #17 boundary. Advisory; ERROR under --strict.
                       Override: <!-- override: persona-quote D-NN — quoting the manuscript -->, where
                       D-NN must be a DECLARED divergence id (a bogus id does not silence the scan).
  D5 disposition,      a persona block carries ANY key outside {schema, id, target, the five
     not character     disposition axes} — a persona is a parameterization, never an invented
                       character. A closed-key ERROR, NON-overridable (one of the three #17 guards;
                       the subset engine silently allows unknown keys, so this is the real guarantee).
  W1 coverage          fewer than two personas, or no disposition axis varies across them — there is no
                       contrast for divergence to surface. Advisory; ERROR under --strict.

The map (persona + divergence blocks) is read with the Findings Ledger (anchor resolution + locked
severities) and optionally the Timeline (locus anchors). Reuses apodictic_artifacts. See
docs/reader-persona-simulation.md.

  persona_divergence.py persona-divergence <run_folder|files...> [--strict]
  persona_divergence.py --self-test

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

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

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

_PERSONA_SCHEMA = "apodictic.persona.v1"
_DIVERGENCE_SCHEMA = "apodictic.divergence.v1"
_FINDING_SCHEMA = "apodictic.finding.v1"
_MAP_GLOB = "*_Persona_Divergence_Map_*.md"
_LEDGER_GLOB = "*_Findings_Ledger_*.md"

# D5 — the closed set of keys a persona block may carry. Anything else makes it a character, not a
# disposition vector. This is the real guarantee (the subset engine allows unknown keys).
_PERSONA_KEYS = {"schema", "id", "target", "pace_tolerance", "genre_familiarity",
                 "content_sensitivity", "thematic_receptivity", "continuity_attention"}
_DISPOSITION_AXES = _PERSONA_KEYS - {"schema", "id", "target"}
_EXPERIENCE_ENUM = {"engaged", "neutral", "friction", "disengage"}
_RANK = {"Could-Fix": 1, "Should-Fix": 2, "Must-Fix": 3}

# Finding Lifecycle-ID token (mirrors finding_trace._ID_RE) — for the finding-anchor path of D2.
_FINDING_ID_RE = re.compile(r"^F-[A-Za-z0-9]+-[0-9]{2,}$")
# D4 — a first-person reader-reaction quote presented as data (the fabricated-testimony signature).
_QUOTE_RE = re.compile(
    r'["“]\s*I\s+(?:got|felt|was|wasn\'?t|couldn\'?t|did\s?n\'?t|started|stopped|loved|hated|'
    r'skimmed|skipped|put\s+it\s+down|put\s+the\s+book\s+down|gave\s+up|lost\s+interest|'
    r'wanted|found\s+myself|kept\s+reading|raced\s+through)\b',
    re.IGNORECASE)
# Persona-quote overrides (`<!-- override: persona-quote D-NN -->`) route through the shared
# `override_marker` SSoT — code spans stripped, slug boundary-matched.
# A divergence-id reference in reader-facing PROSE (e.g. a "### D-NN" section heading) — used to scope
# a persona-quote override to the divergence whose section a quote sits under (D4).
_DIV_ID_RE = re.compile(r"\bD-[0-9]+\b")
_HTML_COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)


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


def _overrides(text, slug):
    """The set of D-NN ids overridden for `slug` (`persona-quote`) — via the shared SSoT, so a marker
    quoted inside a code span is not honored as a live directive."""
    return {t[0] for t in override_targets(text, slug, r"(D-[0-9]+)")}


def _section_divergence(prose_before):
    """The divergence id of the ACTIVE section at the end of `prose_before`: the D-NN in the most recent
    markdown HEADING line, or None. Only a heading changes the active section — an inline cross-reference
    ("Compared with D-01, …") does not, and a heading without a D-NN ("### Notes") clears it (so a quote
    in a later/unrelated section is not silenced by an earlier divergence's override; Codex P2)."""
    gov = None
    for line in prose_before.split("\n"):
        if re.match(r"\s{0,3}#{1,6}\s", line):       # a markdown heading line
            m = _DIV_ID_RE.search(line)
            gov = m.group(0) if m else None          # non-divergence heading ends the active section
    return gov


def _parse(text, btype, schema_id):
    """[(obj_or_None, schema_errs, index), ...] for each apodictic:<btype> block."""
    out = []
    if not text or art is None:
        return out
    schema = art.load_schema(schema_id)
    idx = 0
    for bt, obj, jerr in art.parse_blocks(text):
        if bt != btype:
            continue
        idx += 1
        where = "%s #%d" % (btype, idx)
        if jerr:
            out.append((None, ["%s: invalid JSON — %s" % (where, jerr)], idx))
            continue
        out.append((obj, art.validate_obj(obj, schema, where), idx))
    return out


def ledger_index(ledger_text):
    """{finding_id: obj} for the ledger's SCHEMA-VALID apodictic.finding.v1 blocks. A malformed finding
    (invalid JSON, bad/missing severity, missing required field) is NOT indexed — it cannot ground a
    prediction (D2) nor anchor a severity (D3), so a divergence anchored to one surfaces as ungrounded
    instead of silently passing D2 while escaping D3 (Codex P1)."""
    out = {}
    if not ledger_text or art is None:
        return out
    schema = art.load_schema(_FINDING_SCHEMA)
    for bt, obj, jerr in art.parse_blocks(ledger_text):
        if bt != "finding" or jerr or not isinstance(obj, dict) or not obj.get("id"):
            continue
        if schema is not None and art.validate_obj(obj, schema, "finding %s" % obj.get("id")):
            continue  # schema-invalid finding is not a valid anchor
        out[obj["id"]] = obj
    return out


def timeline_scene_ids(timeline_text):
    """The set of Scene IDs in a Timeline Event Ledger (the addressable locus set)."""
    ids = set()
    if not timeline_text:
        return ids
    header = None
    for ln in timeline_text.split("\n"):
        if not ln.lstrip().startswith("|"):
            header = None
            continue
        cells = [c.strip() for c in ln.strip().strip("|").split("|")]
        if all(set(c) <= set("-: ") for c in cells):
            continue
        low = [c.lower() for c in cells]
        if any("scene id" in c for c in low):
            header = low
            continue
        if header is None:
            continue
        for i, c in enumerate(cells):
            if i < len(header) and "scene id" in header[i] and c and c.lower() != "n/a":
                ids.add(c.strip())
    return ids


def divergence(map_text, ledger_text=None, timeline_text=None, strict=False):
    """Run the Persona Divergence integrity checks. Returns (code, lines)."""
    lines, errs, warns = [], [], []
    personas = _parse(map_text, "persona", _PERSONA_SCHEMA)
    divergences = _parse(map_text, "divergence", _DIVERGENCE_SCHEMA)
    # A map with no parsed persona/divergence blocks (prose-only, or truncated/malformed so the blocks
    # don't parse) is NOT a free pass: D4 anti-fabrication scans the reader-facing PROSE and must still
    # run — a prose-only map presenting "I got bored …" as data would bypass it otherwise (Codex P1).
    # The block-bound checks (D1/D2/D3/D5/W1) no-op naturally on the empty lists below.
    if not personas and not divergences:
        lines.append("persona-divergence: no persona / divergence blocks found — prose-level D4 only")

    index = ledger_index(ledger_text)
    scene_ids = timeline_scene_ids(timeline_text)

    # D1 — schema (per block) + duplicate ids
    for _o, schema_errs, _i in personas + divergences:
        for e in schema_errs:
            errs.append("D1 schema: %s" % e)
    valid_personas = [(o, i) for o, se, i in personas if o is not None and not se]
    valid_divs = [(o, i) for o, se, i in divergences if o is not None and not se]
    seen = {}
    for o, i in valid_personas + valid_divs:
        seen.setdefault(o.get("id"), []).append(i)
    for bid, where in sorted(seen.items()):
        if len(where) > 1:
            errs.append("D1 schema: %s appears %d times (ids must be unique)" % (bid, len(where)))

    persona_ids = {o.get("id") for o, _ in valid_personas}

    # D5 — persona is a disposition, not a character (closed-key; non-overridable ERROR)
    for o, _i in valid_personas:
        extra = sorted(k for k in o.keys() if k not in _PERSONA_KEYS)
        if extra:
            errs.append("D5 disposition-not-character: persona %s carries key(s) outside the closed "
                        "disposition set: %s — a persona is a parameterization, not a character"
                        % (o.get("id"), ", ".join(extra)))

    # D1 (nested) — experiences enum membership + keys resolve to a declared persona
    for o, _i in valid_divs:
        exp = o.get("experiences")
        if not isinstance(exp, dict):
            continue  # the schema's type:object check already reported a non-object
        if not exp:
            errs.append("D1 schema: divergence %s has an empty `experiences` map" % o.get("id"))
        for pid, val in exp.items():
            if not isinstance(val, str) or val not in _EXPERIENCE_ENUM:
                errs.append("D1 schema: divergence %s experiences[%s]=%r not in %s"
                            % (o.get("id"), pid, val, sorted(_EXPERIENCE_ENUM)))
            if pid not in persona_ids:
                errs.append("D1 schema: divergence %s experiences references undeclared persona %r"
                            % (o.get("id"), pid))

    # D2 — grounded prediction (anchor resolves to a real finding id OR a real Timeline scene id)
    for o, _i in valid_divs:
        anchor = (o.get("anchor") or "").strip()
        grounded = anchor in index or anchor in scene_ids
        if not grounded:
            hint = (" (looks like a finding id but is not in the Ledger)"
                    if _FINDING_ID_RE.match(anchor) else "")
            errs.append("D2 grounded-prediction: divergence %s anchor %r resolves to neither a "
                        "Ledger finding nor a Timeline scene id%s — an ungrounded prediction is a "
                        "fabricated one" % (o.get("id"), anchor, hint))

    # D3 — target-severity anchoring (exactly one target; no asserted_severity below the locked one)
    targets = [o.get("id") for o, _ in valid_personas if o.get("target") is True]
    if valid_personas and len(targets) != 1:
        errs.append("D3 target-severity: exactly one persona must be target:true (found %d: %s) — "
                    "severity anchors to the target audience" % (len(targets), ", ".join(targets) or "none"))
    for o, _i in valid_divs:
        asserted = o.get("asserted_severity")
        if asserted not in _RANK:
            continue  # no severity asserted (the descriptive default), or a bad enum D1 owns
        anchor = (o.get("anchor") or "").strip()
        locked = index.get(anchor, {}).get("severity")
        if locked not in _RANK:
            # The anchor resolves to no locked Ledger severity — a Timeline-locus anchor, or a
            # finding without a parseable severity. There is nothing to equal, so asserting a
            # severity here is a free-floating verdict the descriptive overlay must not introduce
            # (it would propagate while §4e declares the overlay non-propagating).
            errs.append("D3 target-severity: divergence %s asserts %s but its anchor %r resolves to "
                        "no locked Ledger severity (e.g. a Timeline locus) — a severity may be "
                        "asserted ONLY against a Ledger finding whose locked severity it equals; the "
                        "overlay carries no severity of its own, so drop asserted_severity or anchor "
                        "to the finding that locks it" % (o.get("id"), asserted, anchor))
        elif _RANK[asserted] != _RANK[locked]:
            direction = "lower than" if _RANK[asserted] < _RANK[locked] else "higher than"
            errs.append("D3 target-severity: divergence %s asserts %s, %s the anchored "
                        "finding's locked %s — the overlay is descriptive; severity stays "
                        "locked to the target's finding (segmentation may neither downgrade "
                        "nor inflate the verdict)"
                        % (o.get("id"), asserted, direction, locked))

    # D3b — a divergence must actually DIVERGE across personas, anchored to the target. An
    # experiences map that omits the target persona, or assigns every persona the SAME experience,
    # predicts no cross-persona divergence at all — the whole point of the block (Codex P1). Only
    # meaningful when a contrast is structurally possible (>= 2 personas); with fewer, W1 coverage
    # advises instead.
    target_id = targets[0] if len(targets) == 1 else None
    for o, _i in (valid_divs if len(valid_personas) >= 2 else []):
        exp = o.get("experiences")
        if not isinstance(exp, dict) or not exp:
            continue  # an empty/non-object experiences map is already a D1 error
        if target_id is not None and target_id not in exp:
            errs.append("D3 divergence-content: divergence %s omits the target persona %s from its "
                        "experiences — divergence anchors to the target audience's experience"
                        % (o.get("id"), target_id))
        # …and a divergence should predict EVERY declared persona, not only some — an omitted persona
        # is an incomplete prediction (Codex P2). Advisory (ERROR --strict); the target omission above
        # is the harder ERROR, so don't double-report it here.
        missing = [pid for pid in sorted(persona_ids) if pid not in exp and pid != target_id]
        if missing:
            warns.append("D3 coverage: divergence %s omits declared persona(s) %s from its experiences "
                         "— predict every declared persona's experience" % (o.get("id"), ", ".join(missing)))
        vals = [v for v in exp.values() if isinstance(v, str)]
        if len(vals) == len(exp) and len(set(vals)) < 2:
            only = ", ".join(sorted(set(vals))) or "—"
            errs.append("D3 divergence-content: divergence %s assigns every persona the same "
                        "experience (%s) — there is no cross-persona divergence to surface"
                        % (o.get("id"), only))

    # D4 — no fabricated testimony (advisory; ERROR --strict). A persona-quote override silences a
    # quote ONLY when it names a REAL divergence id AND that divergence is the quote's ACTIVE SECTION —
    # the D-NN in the most recent markdown heading above the quote (see _section_divergence). An inline
    # cross-reference ("Compared with D-01, …") inside a "### D-02" section does NOT change the scope,
    # and a quote under a non-divergence heading ("### Notes") is unscoped; so a D-01 override never
    # silences a D-02 (or Notes) quote, and a bogus id silences nothing (Codex P2). Scan the VISIBLE
    # prose only — the persona/divergence blocks (and any note, incl. the override markers) are HTML
    # comments stripped here; fabricated testimony presented *as data* lives in the reader-facing prose.
    visible = _HTML_COMMENT_RE.sub("", map_text or "")
    divergence_ids = {o.get("id") for o, _ in valid_divs}
    q_overrides = _overrides(map_text, "persona-quote") & divergence_ids
    for m in _QUOTE_RE.finditer(visible):
        if _section_divergence(visible[:m.start()]) in q_overrides:
            continue  # the quote sits under an overridden divergence's section heading
        warns.append("D4 no-fabricated-testimony: the map presents a first-person reader-reaction "
                     "quote ('I got bored …') as data — reason about reader experience structurally, "
                     "never manufacture reader testimony (the #17 boundary)")
        break

    # W1 — coverage (need >=2 personas AND some disposition axis that varies)
    if valid_personas:
        if len(valid_personas) < 2:
            warns.append("W1 coverage: only %d persona — there is no contrast for divergence to "
                         "surface (declare at least one contrasting disposition)" % len(valid_personas))
        else:
            varies = any(len({o.get(axis) for o, _ in valid_personas if axis in o}) > 1
                         for axis in _DISPOSITION_AXES)
            if not varies:
                warns.append("W1 coverage: no disposition axis varies across the personas — they are "
                             "the same reader, so nothing can diverge")

    # Report
    lines.append("persona-divergence: %d persona(s), %d divergence(s)"
                 % (len(personas), len(divergences)))
    for o, _i in valid_personas:
        lines.append("  %-6s target=%s %s" % (o.get("id"), o.get("target"),
                     " ".join("%s=%s" % (a, o[a]) for a in sorted(_DISPOSITION_AXES) if a in o)))
    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("persona-divergence: 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("WARN: persona-divergence: %d advisory gap(s) — see D4/W1 above" % len(warns))
    else:
        lines.append("persona-divergence: PASS (schema + grounded prediction + target-severity "
                     "anchoring + closed-key persona + anti-fabrication)")
    return 0, lines


# ---------------------------------------------------------------- resolution

def _newest(paths):
    return max(paths, key=os.path.getmtime) if paths else None


def _has_block(text, btype):
    if art is None:
        return ("apodictic:%s" % btype) in (text or "")
    return any(bt == btype for bt, _o, _e in art.parse_blocks(text or ""))


def resolve(paths):
    """Return (map_path, ledger_path_or_None, timeline_path_or_None)."""
    if len(paths) == 1 and os.path.isdir(paths[0]):
        d = paths[0]
        return (_newest(glob.glob(os.path.join(d, _MAP_GLOB))),
                _newest(glob.glob(os.path.join(d, _LEDGER_GLOB))),
                os.path.join(d, "Timeline.md") if os.path.isfile(os.path.join(d, "Timeline.md")) else None)
    map_path = ledger_path = timeline_path = None
    for p in paths:
        text = _read(p) or ""
        if _has_block(text, "persona") or _has_block(text, "divergence"):
            map_path = map_path or p
        elif _has_block(text, "finding") or "Findings Ledger" in text:
            ledger_path = ledger_path or p
        elif "Scene ID" in text or "timeline" in os.path.basename(p).lower():
            timeline_path = timeline_path or p
    if map_path is None and paths:
        map_path = paths[0]
    return map_path, ledger_path, timeline_path


def run(paths, strict=False):
    map_path, ledger_path, timeline_path = resolve(paths)
    if not map_path:
        return 2, ["persona-divergence: no Persona Divergence Map found (need a "
                   "*_Persona_Divergence_Map_*.md or a file with apodictic:persona/divergence blocks)"]
    text = _read(map_path)
    if text is None:
        return 2, ["persona-divergence: cannot read %s" % map_path]
    return divergence(text, ledger_text=_read(ledger_path) if ledger_path else None,
                      timeline_text=_read(timeline_path) if timeline_path else None, strict=strict)


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

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

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

    # non-UTF8 artifact: _read must degrade to the unreadable path (None), never
    # a traceback (the disposition_check adjacent-exception class, swept repo-wide)
    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")
    chk("non_utf8_read_returns_none", _read(_nu) is None)
    os.unlink(_nu)

    def persona(pid, target=False, **axes):
        obj = {"schema": _PERSONA_SCHEMA, "id": pid, "target": target}
        obj.update(axes)
        return "<!-- apodictic:persona\n%s\n-->" % _j.dumps(obj)

    def div(did, anchor="F-P1-04", experiences=None, magnitude="high", asserted=None):
        obj = {"schema": _DIVERGENCE_SCHEMA, "id": did, "anchor": anchor, "magnitude": magnitude,
               "experiences": experiences if experiences is not None else {"P-01": "disengage", "P-02": "engaged"}}
        if asserted is not None:
            obj["asserted_severity"] = asserted
        return "<!-- apodictic:divergence\n%s\n-->" % _j.dumps(obj)

    def finding(fid, severity="Must-Fix", conf="HIGH"):
        obj = {"schema": _FINDING_SCHEMA, "id": fid, "mechanism": "m", "severity": severity,
               "confidence": conf, "evidence_refs": ["Ch 3"], "fix_class": "x", "risk_if_fixed": "y"}
        return "<!-- apodictic:finding\n%s\n-->" % _j.dumps(obj)

    LEDGER = finding("F-P1-04", "Must-Fix")
    TARGET = persona("P-01", target=True, pace_tolerance="low", genre_familiarity="newcomer")
    EXPERT = persona("P-02", target=False, pace_tolerance="high", genre_familiarity="expert")
    MAP = TARGET + "\n" + EXPERT + "\n" + div("D-01")

    # clean
    chk("clean", divergence(MAP, LEDGER)[0] == 0)

    # D1 — schema
    chk("d1_bad_axis_enum", divergence(persona("P-01", target=True, pace_tolerance="glacial") + EXPERT + div("D-01"), LEDGER)[0] == 1)
    chk("d1_bad_target_type", divergence(persona("P-01", target="yes") + EXPERT + div("D-01"), LEDGER)[0] == 1)
    chk("d1_bad_persona_id", divergence(persona("P1", target=True) + EXPERT + div("D-01"), LEDGER)[0] == 1)
    chk("d1_div_missing_anchor",
        divergence(TARGET + EXPERT + '<!-- apodictic:divergence\n{"schema":"apodictic.divergence.v1",'
                   '"id":"D-01","magnitude":"high","experiences":{"P-01":"friction","P-02":"engaged"}}\n-->',
                   LEDGER)[0] == 1)
    chk("d1_bad_experience_enum",
        divergence(TARGET + EXPERT + div("D-01", experiences={"P-01": "bored", "P-02": "engaged"}), LEDGER)[0] == 1)
    chk("d1_experience_undeclared_persona",
        any("undeclared persona" in ln for ln in divergence(TARGET + EXPERT + div("D-01", experiences={"P-99": "friction"}), LEDGER)[1]))
    code, lines = divergence(TARGET + EXPERT + div("D-01") + "\n" + div("D-01"), LEDGER)
    chk("d1_duplicate_id", code == 1 and any("appears 2 times" in ln for ln in lines))

    # D2 — grounded prediction
    code, lines = divergence(TARGET + EXPERT + div("D-01", anchor="F-P9-99"), LEDGER)
    chk("d2_dangling_finding_anchor", code == 1 and any("D2 grounded-prediction" in ln for ln in lines))
    code, lines = divergence(TARGET + EXPERT + div("D-01", anchor="nowhere"), LEDGER)
    chk("d2_unground_anchor", code == 1 and any("D2 grounded-prediction" in ln for ln in lines))
    # a Timeline scene-id anchor resolves
    TL = "## Section 1: Event Ledger\n| Scene ID | POV |\n|---|---|\n| Ch 4 §2 | Mara |\n"
    chk("d2_timeline_locus_resolves",
        divergence(TARGET + EXPERT + div("D-01", anchor="Ch 4 §2"), LEDGER, TL)[0] == 0)

    # D3 — target-severity anchoring
    chk("d3_zero_targets", divergence(persona("P-01") + EXPERT + div("D-01"), LEDGER)[0] == 1)
    chk("d3_two_targets", divergence(TARGET + persona("P-02", target=True) + div("D-01"), LEDGER)[0] == 1)
    code, lines = divergence(MAP.replace(div("D-01"), div("D-01", asserted="Could-Fix")), LEDGER)
    chk("d3_downgrade_blocked", code == 1 and any("D3 target-severity" in ln and "lower than" in ln for ln in lines))
    # asserting the SAME severity is fine — the overlay is descriptive (severity == the locked finding)
    chk("d3_equal_severity_ok",
        divergence(MAP.replace(div("D-01"), div("D-01", asserted="Must-Fix")), LEDGER)[0] == 0)
    # asserting HIGHER than the locked severity is ALSO blocked — descriptive overlay re-segments who
    # experiences a finding, it does not re-verdict it; an upshift would be a silent high-severity sink
    # the §4e "no propagating signal" row depends on never happening (Codex P1)
    code, lines = divergence(MAP.replace(div("D-01"), div("D-01", asserted="Must-Fix")), finding("F-P1-04", "Could-Fix"))
    chk("d3_upshift_blocked", code == 1 and any("D3 target-severity" in ln and "higher than" in ln for ln in lines))
    # a Timeline-locus anchor has NO locked Ledger severity, so an asserted severity would bypass the
    # equality check and propagate a verdict the §4e "no propagating signal" row denies — a severity may
    # be asserted ONLY against a Ledger finding (Codex P1 follow-up)
    code, lines = divergence(MAP.replace(div("D-01"), div("D-01", anchor="Ch 4 §2", asserted="Must-Fix")), LEDGER, TL)
    chk("d3_timeline_anchor_asserted_blocked",
        code == 1 and any("D3 target-severity" in ln and "no locked Ledger severity" in ln for ln in lines))
    # but a Timeline-anchored divergence with NO asserted_severity is purely descriptive -> OK
    chk("d3_timeline_anchor_no_severity_ok",
        divergence(MAP.replace(div("D-01"), div("D-01", anchor="Ch 4 §2")), LEDGER, TL)[0] == 0)
    # D3b — a divergence must include the target persona AND actually diverge (Codex P1)
    chk("d3b_omits_target",
        any("omits the target" in ln for ln in
            divergence(TARGET + EXPERT + div("D-01", experiences={"P-02": "engaged"}), LEDGER)[1]))
    chk("d3b_no_divergence",
        any("no cross-persona divergence" in ln for ln in
            divergence(TARGET + EXPERT + div("D-01", experiences={"P-01": "friction", "P-02": "friction"}),
                       LEDGER)[1]))
    # a non-hashable experiences value must not crash D3b's set() — D1 owns that error (Codex P2 sibling)
    chk("d3b_nonhashable_experiences_no_crash",
        divergence(TARGET + EXPERT + div("D-01", experiences={"P-01": ["a"], "P-02": "engaged"}), LEDGER)[0] == 1)

    # D4 — no fabricated testimony (advisory; ERROR --strict; override)
    quote = MAP + '\n\nP-01: "I got bored in chapter 3 and put it down."\n'
    code, lines = divergence(quote, LEDGER)
    chk("d4_quote_advisory", code == 0 and any("D4 no-fabricated-testimony" in ln for ln in lines))
    chk("d4_quote_strict_fails", divergence(quote, LEDGER, strict=True)[0] == 1)
    ov = "<!-- override: persona-quote D-01 — quoting the manuscript, not a fabricated reader -->\n"
    # an override silences a quote ONLY within its divergence's prose scope (under "### D-NN")
    scoped_d01 = MAP + '\n\n### D-01\nP-01: "I got bored in chapter 3 and put it down."\n'
    chk("d4_override_silences", not any("D4" in ln for ln in divergence(ov + scoped_d01, LEDGER)[1]))
    # code-span decoy (bypass closed by the SSoT migration): an override quoted inside a code span is a
    # documentation example, not a live directive — D4 must still fire, in EITHER CommonMark form.
    chk("d4_inline_codespan_override_does_not_silence",
        any("D4" in ln for ln in divergence("`" + ov.strip() + "`\n" + scoped_d01, LEDGER)[1]))
    chk("d4_fenced_codespan_override_does_not_silence",
        any("D4" in ln for ln in divergence("```\n" + ov.strip() + "\n```\n" + scoped_d01, LEDGER)[1]))
    # Codex P2 repro: a D-01 override must NOT silence a quote scoped to a DIFFERENT divergence (D-02)
    MAP2 = TARGET + "\n" + EXPERT + "\n" + div("D-01") + "\n" + div("D-02", experiences={"P-01": "engaged", "P-02": "friction"})
    cross = ov + MAP2 + '\n\n### D-02\nP-02: "I got bored and stopped reading."\n'
    chk("d4_override_scoped_other_div_fires", any("D4" in ln for ln in divergence(cross, LEDGER)[1]))
    chk("d4_override_scoped_other_div_strict_fails", divergence(cross, LEDGER, strict=True)[0] == 1)
    # Codex P2 (tighter): an inline D-01 cross-reference INSIDE the "### D-02" section must not change
    # scope — the active section heading is D-02, so the D-01 override does not silence the quote.
    xref = ov + MAP2 + '\n\n### D-02\nCompared with D-01, P-02: "I got bored and stopped reading."\n'
    chk("d4_inline_xref_does_not_rescope", divergence(xref, LEDGER, strict=True)[0] == 1)
    # …and a trailing non-divergence section ("### Notes") after a D-01 section is unscoped — the D-01
    # override must not leak into it.
    notes = ov + MAP2 + '\n\n### D-01\n(structural prediction)\n\n### Notes\nP-02: "I got bored and gave up."\n'
    chk("d4_trailing_notes_unscoped_fires", divergence(notes, LEDGER, strict=True)[0] == 1)
    # a structural prediction (no first-person quote) is clean
    chk("d4_structural_clean",
        not any("D4" in ln for ln in divergence(MAP + "\n\nA pace-sensitive disposition is at elevated disengagement risk across Ch 3.\n", LEDGER)[1]))
    # a quote INSIDE an HTML comment (an explanatory note, not reader-facing data) is clean
    chk("d4_quote_in_comment_clean",
        not any("D4" in ln for ln in divergence(MAP + '\n<!-- note: D4 catches "I got bored" quotes -->\n', LEDGER)[1]))

    # --- Codex round 2 ---
    # P1: a prose-only / truncated map (NO parsed blocks) must still run D4 — it bypassed it entirely.
    prose_only = 'P-01: "I got bored in chapter 3 and put it down."\n'
    chk("d4_prose_only_fires", any("D4" in ln for ln in divergence(prose_only, LEDGER)[1]))
    chk("d4_prose_only_strict_fails", divergence(prose_only, LEDGER, strict=True)[0] == 1)
    # P1: a MALFORMED finding (invalid severity enum) must NOT satisfy D2 grounding — so it cannot
    # silently pass D2 while escaping D3's severity anchoring. Anchoring to it surfaces as ungrounded.
    bad_ledger = finding("F-P1-04", severity="Whatever")
    code, lines = divergence(MAP, bad_ledger)
    chk("d2_malformed_finding_not_grounded", code == 1 and any("D2 grounded-prediction" in ln for ln in lines))
    chk("d2_malformed_finding_downgrade_not_masked",
        divergence(MAP.replace(div("D-01"), div("D-01", asserted="Could-Fix")), bad_ledger)[0] == 1)
    # P2: a divergence that omits a declared (non-target) persona is incomplete coverage.
    THIRD = persona("P-03", target=False, pace_tolerance="high", genre_familiarity="newcomer")
    omit3 = TARGET + EXPERT + THIRD + div("D-01", experiences={"P-01": "friction", "P-02": "engaged"})
    chk("d3_omits_declared_persona_warns", any("D3 coverage" in ln and "P-03" in ln for ln in divergence(omit3, LEDGER)[1]))
    chk("d3_omits_declared_persona_strict_fails", divergence(omit3, LEDGER, strict=True)[0] == 1)
    # P2: a bogus persona-quote override (id not a declared divergence) must NOT disable D4 globally;
    # the real-id override still silences.
    bogus_ov = "<!-- override: persona-quote D-99 — no such divergence -->\n"
    chk("d4_bogus_override_still_fires", any("D4" in ln for ln in divergence(bogus_ov + quote, LEDGER)[1]))
    chk("d4_bogus_override_strict_fails", divergence(bogus_ov + quote, LEDGER, strict=True)[0] == 1)
    chk("d4_real_override_still_silences", not any("D4" in ln for ln in divergence(ov + scoped_d01, LEDGER)[1]))

    # D5 — closed-key (non-overridable)
    code, lines = divergence(persona("P-01", target=True, name="Sarah, 34, a teacher") + EXPERT + div("D-01"), LEDGER)
    chk("d5_stray_key", code == 1 and any("D5 disposition-not-character" in ln and "name" in ln for ln in lines))
    # D5 is non-overridable: even with a (wrong) override marker it still fails
    chk("d5_non_overridable",
        divergence("<!-- override: persona-quote D-01 — x -->\n" + persona("P-01", target=True, backstory="orphan") + EXPERT + div("D-01"), LEDGER)[0] == 1)

    # W1 — coverage (advisory)
    code, lines = divergence(TARGET + div("D-01", experiences={"P-01": "friction"}), LEDGER)
    chk("w1_single_persona", code == 0 and any("W1 coverage" in ln for ln in lines))
    same = persona("P-01", target=True, pace_tolerance="low") + persona("P-02", target=False, pace_tolerance="low")
    chk("w1_no_variation",
        any("W1 coverage" in ln and "no disposition axis varies" in ln for ln in divergence(same + div("D-01"), LEDGER)[1]))
    chk("w1_strict_fails", divergence(same + div("D-01"), LEDGER, strict=True)[0] == 1)

    # no blocks -> no-op
    chk("no_blocks_noop", divergence("# Notes\nnothing\n")[0] == 0)

    # resolution
    import tempfile
    import shutil
    d = tempfile.mkdtemp()
    try:
        mp = os.path.join(d, "Proj_Persona_Divergence_Map_run.md")
        with open(mp, "w", encoding="utf-8", newline="") as fh:
            fh.write("# Persona Divergence Map\n" + MAP + "\n")
        lp = os.path.join(d, "Proj_Findings_Ledger_run.md")
        with open(lp, "w", encoding="utf-8", newline="") as fh:
            fh.write("# Findings Ledger\n" + LEDGER + "\n")
        chk("run_folder_resolution", run([d])[0] == 0)
        chk("explicit_files_resolution", run([mp, lp])[0] == 0)
        chk("explicit_files_order_independent", run([lp, mp])[0] == 0)
        chk("missing_artifact_usage", run([os.path.join(d, "nope.md")])[0] == 2)
    finally:
        shutil.rmtree(d, ignore_errors=True)

    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 != "persona-divergence"]
    strict = "--strict" in args
    paths = [a for a in args if not a.startswith("--")]
    if not paths:
        print("Usage: persona_divergence.py persona-divergence <run_folder|files...> [--strict] | --self-test")
        return 2
    code, lines = run(paths, strict=strict)
    for ln in lines:
        print(ln)
    return code


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

SHA-256: 0ce59f40ebd50a706b99a69f3f6becccc3b70f3e626998e3425788d066ba8140