← Files APODICTIC Development EditorARCHIVED FILE

scripts/argument_spine.py

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

↓ Download file

#!/usr/bin/env python3
"""argument-spine — structural integrity for the Nonfiction Pre-Draft Pathway (Increments 1–3 + 5).

`validate.sh argument-spine <run_folder|files>` shells out here. Before a draft exists, a writer
plans the argument: the thesis, the claim ladder, and the opposing view the argument must defeat.
That plan is one apodictic.argument_spine.v1 block, and it SEEDS the shared Argument_State.md
artifact (docs/argument-state-schema.md) — thesis -> §2 C0; subclaims -> §2 ladder; anti_thesis ->
§6 Objection 1; the §1 classification fields. The Dialectical Clarity audit fills the
draft-dependent sections later. This validator owns the pre-draft contract AND mechanizes the
seed-Argument_State integration.

  A1 invalid spine    the argument_spine block fails its schema (bad argument_type / burden_level /
                      audience_* / stakes_type enum, missing required field, <1 subclaim, bad JSON).
  A2 unseeded         a spine block is present but the artifact is not a seeded Argument_State — it
                      lacks the canonical '## 1. Context and Classification' / '## 2. Claim
                      Architecture' headings. The spine must seed the shared artifact, not float free.
  A3 thesis/C0 drift  the seeded §2 'C0 (main claim):' line does not carry the spine's `thesis` — the
                      structured spine and the human-readable Argument_State disagree.
  W1 anti-thesis echo the `anti_thesis` is empty or a normalized echo of the `thesis` (advisory;
                      ERROR --strict). A pre-draft plan must name a GENUINE opposing view, not a
                      restatement. Override: <!-- override: argument-spine-antithesis — <reason> -->.

Increment 2 — the source/evidence map, planned per subclaim as apodictic.support_plan.v1 blocks
that SEED §3 Support Map:
  A4 invalid support  a support_plan block fails its schema (bad support_type / scheme_hint / status
                      enum, malformed subclaim_id, missing field, bad JSON).
  A5 dangling subclaim a support_plan's subclaim_id is not a Cn declared in the spine's ladder.
  A6 support unseeded  support_plan blocks are present but the artifact has no '## 3. Support Map'
                      heading (the support map must seed §3 — parallel to A2).
  W2 bare assertion   once support planning has started (>=1 support_plan), a declared subclaim with
                      NO planned support (advisory; ERROR --strict). Staged, so a spine-only
                      (Increment 1) artifact is never nagged.

Increment 3 — the warrant pre-check, planned per subclaim as apodictic.warrant_plan.v1 blocks that
SEED §4 Warrant and Inference Map:
  A7 invalid warrant  a warrant_plan block fails its schema (bad warrant_status / backing / qualifier
                      enum, malformed subclaim_id, missing field, bad JSON).
  A8 dangling subclaim a warrant_plan's subclaim_id is not a Cn declared in the spine's ladder.
  A9 warrant unseeded warrant_plan blocks present but no '## 4. Warrant and Inference Map' heading.
  W3 implicit warrant for a HOSTILE audience (per the spine's audience_receptivity), a warrant that
                      is not EXPLICIT or has ABSENT backing — make it explicit and back it before
                      drafting (advisory; ERROR --strict). Audience-calibrated. Override:
                      <!-- override: argument-spine-warrant — <reason> -->.

Increment 5 — the genre layer: a genre holds itself to its GENRE-REQUIRED argument structure. One
apodictic.genre_profile.v1 block names the genre (grant-proposal / academic-article / pitch-deck) and
declares the genre-required section ROLES the pre-draft Argument_State must seed (as ### sub-headings
under the canonical §1–§6 — no new top-level section, so argument-state-schema.md stays v0.1.1):
  B1 invalid genre profile  a genre_profile block fails its schema (bad genre / evaluator / seeded_by
                      enum, empty required_sections, missing field, bad per-section shape, bad JSON).
  B2 section unseeded a declared required_sections[].heading has no matching heading in the artifact —
                      the genre's structural skeleton must be SEEDED, not merely declared (parallels
                      A2/A6/A9, the seed-don't-float-free signature).
  B3 genre/form mismatch the genre_profile.genre is incompatible with the argument_spine.form (the
                      genre and the spine disagree about what the piece is). Parallels A3 thesis/C0
                      drift. ONLY fires when a spine block is also present (a genre profile may exist in
                      an early pre-draft before the full spine; absent spine -> skip, not fail).
                      Compatibility is normalized (lowercase, collapse spaces/hyphens) so the spaced
                      doc-enum spelling ('grant proposal') matches the hyphenated genre ('grant-proposal').
  B4 duplicate genre profile  more than one genre_profile block (the piece is one genre; parallels E2).
  B5 dangling objection target (R2)  a typed reviewer_objections item's target names a Cn not declared
                      in the spine's ladder (the A5/A8 machinery; skip-not-fail when no valid spine —
                      the B3 precedent). Item SHAPE (bare string non-empty, or object with a required
                      non-whitespace 'objection' + optional target/relation typed against the
                      Argument_State §6 Relation vocabulary; post-draft keys rejected) is B1-class,
                      hand-rolled in _reviewer_objection_errs (the schema's items is untyped — the
                      subset validator has no union support).
  W4 thin genre skeleton  a declared genre's CANONICAL required section is missing from
                      required_sections (e.g. a grant-proposal that omits approach). Advisory (ERROR
                      --strict): the writer may be working a non-standard variant. Override:
                      <!-- override: argument-spine-genre <genre> — <rationale> -->.

Increment 2 of the genre layer — reviewer-anticipation, the writer's pre-list of the evaluator's likely
objections (the genre_profile's reviewer_objections array), surfaced as an empty required class:
  W5 reviewer-anticipation  a genre_profile is present but its reviewer_objections array is empty or
                      absent — pre-list the evaluator's likely objections (which seed §6, The Strongest
                      Case Against) before drafting (advisory; ERROR --strict). FIREWALL: W5 checks ONLY
                      that the writer's list is NON-EMPTY; it never authors, suggests, or validates the
                      CONTENT of an objection (the engine never writes the writer's objections). Fires
                      only when a genre_profile is present (parallels W4). Override:
                      <!-- override: argument-spine-reviewer <genre> — <rationale> -->.

A2/A3 (spine), B2/B3 (genre), and the seeding checks verify the plan actually populated Argument_State
(the chosen integration). Reuses apodictic_artifacts (block grammar + schema engine). An artifact with
no spine / support_plan / warrant_plan / genre_profile block is a no-op. See docs/nonfiction-pre-draft.md.

  argument_spine.py argument-spine <run_folder|files...> [--strict]
  argument_spine.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


def _has_block(text, btype):
    """True if `text` carries a real apodictic:<btype> block (a parsed carrier, not a prose mention).

    Classifying on parsed blocks — not a raw substring — keeps a file that merely *names* the marker
    in prose from being misrouted/skipped (the 2026-06-20 resolver-hardening sweep). Gated by
    validate.sh validator-conventions (M2)."""
    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 ""))

_SCHEMA_ID = "apodictic.argument_spine.v1"
_SUPPORT_SCHEMA_ID = "apodictic.support_plan.v1"   # Increment 2: source/evidence map (seeds §3)
_WARRANT_SCHEMA_ID = "apodictic.warrant_plan.v1"   # Increment 3: warrant pre-check (seeds §4)
_GENRE_SCHEMA_ID = "apodictic.genre_profile.v1"    # Increment 5: genre layer (seeds the genre skeleton)
_STATE_GLOB = "Argument_State*.md"
_SCORE_ENUMS = ("argument_type", "burden_level", "audience_expertise", "audience_receptivity")
# Canonical Argument_State headings the spine must seed (docs/argument-state-schema.md §1–§4).
_SEC1_RE = re.compile(r"^##\s+1\.\s+Context and Classification", re.IGNORECASE | re.MULTILINE)
_SEC2_RE = re.compile(r"^##\s+2\.\s+Claim Architecture", re.IGNORECASE | re.MULTILINE)
_SEC3_RE = re.compile(r"^##\s+3\.\s+Support Map", re.IGNORECASE | re.MULTILINE)
_SEC4_RE = re.compile(r"^##\s+4\.\s+Warrant and Inference Map", re.IGNORECASE | re.MULTILINE)
# The §2 main-claim line: "C0 (main claim): <thesis>".
_C0_RE = re.compile(r"^\s*C0\s*\(main claim\)\s*:\s*(.+?)\s*$", re.IGNORECASE | re.MULTILINE)
# A subclaim string carries a leading Cn id ("C1: …") — the link target for a support plan.
_SUBCLAIM_ID_RE = re.compile(r"^\s*(C[0-9]+)\b")
# The three argument-spine overrides (antithesis / warrant / genre) are id-less presence markers; they
# route through the shared override_marker SSoT (code spans stripped, slug boundary-matched) via the
# presence form of override_targets(text, slug).

# Increment 5 — the genre layer (apodictic.genre_profile.v1). Per-section seeded_by enum (the stdlib
# subset validator type-checks array items but does not recurse into object items, so B1 enforces the
# per-section {role, heading, seeded_by} shape here). Mirrors the schema's seeded_by enum.
_GENRE_SEEDED_BY = ("C0+ladder", "stakes", "subclaim", "support_plan", "warrant_plan", "objection", "none")
# R2 — the Argument_State §6 objection-typing vocabulary (docs/argument-state-schema.md §6). Mirrors
# the schema $comments; the writer's OPTIONAL pre-draft target/relation metadata is checked against it.
_OBJECTION_RELATIONS = ("WARRANT-DEFEATER", "CLAIM-CHALLENGE", "EVIDENCE-CHALLENGE",
                        "VALUE-CONFLICT", "ALTERNATIVE")
_OBJECTION_TARGET_RE = re.compile(r"^(C[0-9]+(\.warrant|\.support)?|argument-wide)$")
# Post-draft audit-result keys that have no business on a PRE-DRAFT writer item (wrong-surface guard).
_OBJECTION_POSTDRAFT_KEYS = ("basis", "condition", "derivation_anchors", "engaged", "quality", "codes")
# The genre-canonical required-section ROLE sets — the small in-validator table W4 advises against
# (a declared genre whose required_sections omit a canonical role). Roles, not headings (headings are
# writer-declared per funder/venue; D2). Drawn from convention; deliberately MINIMAL (the writer may
# carry more, and W4 only nudges on a missing canonical role, overridably).
_GENRE_CANONICAL = {
    "grant-proposal": ("specific-aims", "significance", "innovation", "approach"),
    "academic-article": ("contribution", "related-work", "method", "limitations"),
    "pitch-deck": ("problem", "solution", "traction"),
}


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


def _norm(s):
    return re.sub(r"\s+", " ", (s or "").strip()).lower()


def _echo_norm(s):
    """Normalization for the W1 echo check: lowercase, collapse whitespace, drop punctuation — so a
    restated thesis ('Fund ramps now.') still reads as an echo of the thesis ('fund ramps now')."""
    return re.sub(r"[^a-z0-9 ]", "", _norm(s)).strip()


def _genre_norm(s):
    """Normalization for the B3 genre/form compatibility check: lowercase, then collapse any run of
    whitespace OR hyphens to a single space. So the spaced doc-enum spelling ('grant proposal',
    'academic article') matches the hyphenated genre token ('grant-proposal') without a brittle
    exact-match."""
    return re.sub(r"[\s-]+", " ", (s or "").strip().lower()).strip()


def parse_spine(text):
    """(obj_or_None, schema_errs) for the FIRST apodictic:argument_spine block ('' errs if absent)."""
    if not text or art is None:
        return None, []
    schema = art.load_schema(_SCHEMA_ID)
    for btype, obj, jerr in art.parse_blocks(text):
        if btype != "argument_spine":
            continue
        if jerr:
            return None, ["invalid JSON — %s" % jerr]
        return obj, art.validate_obj(obj, schema, "argument_spine")
    return None, []


def spine_subclaim_ids(obj):
    """Set of Cn ids declared in the spine's subclaim ladder ('C1: …' -> 'C1'). The link targets a
    support plan resolves against (Increment 2)."""
    ids = set()
    if isinstance(obj, dict):
        for s in (obj.get("subclaims") or []):
            if isinstance(s, str):
                m = _SUBCLAIM_ID_RE.match(s)
                if m:
                    ids.add(m.group(1))
    return ids


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


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


def _genre_section_errs(obj, where):
    """Per-section shape validation for required_sections (B1). The stdlib subset validator
    (apodictic_artifacts.validate_obj) type-checks array items but does NOT recurse into object items,
    so the {role, heading, seeded_by} contract — incl. the seeded_by enum — is enforced here, returning
    the same flat list of strings the schema engine returns. Only run on an already-schema-valid block
    (required_sections is a non-empty list of something).

    `role` and `heading` must be NON-WHITESPACE strings, and roles/headings must be unique across the
    section list. A bare type check ('is it a str?') let an empty-or-whitespace heading slip past B1 —
    and B2 (the seed check) skips empty headings ('if not heading: continue'), so an all-blank-heading
    profile carrying the canonical roles falsely reported the genre seeded and PASSED --strict. The
    seeding contract is a per-section heading that must appear in the artifact; a blank or duplicate
    heading can't carry it, so it's rejected here at the contract gate, before B2 ever runs."""
    errs = []
    seen_roles, seen_headings = {}, {}
    for i, sec in enumerate((obj.get("required_sections") or [])):
        at = "%s required_sections[%d]" % (where, i)
        if not isinstance(sec, dict):
            errs.append("%s: must be an object" % at)
            continue
        for k in ("role", "heading", "seeded_by"):
            if k not in sec:
                errs.append("%s: missing required field '%s'" % (at, k))
        for k in ("role", "heading"):
            if k not in sec:
                continue
            v = sec[k]
            if not isinstance(v, str):
                errs.append("%s: '%s' must be type string" % (at, k))
            elif not v.strip():
                errs.append("%s: '%s' must be a non-empty (non-whitespace) string" % (at, k))
            else:
                # uniqueness key matches how each field is later consumed: roles on _genre_norm
                # (W4's role table key — 'related-work'=='related work'); headings case-insensitively
                # on trimmed text (B2's exact case-insensitive heading match), so 'Approach' /
                # ' approach ' / 'APPROACH' collide as one declared section without over-merging
                # hyphen variants B2 would treat as distinct heading text.
                norm = _genre_norm(v) if k == "role" else v.strip().lower()
                seen = seen_roles if k == "role" else seen_headings
                if norm in seen:
                    errs.append("%s: duplicate %s %r (already declared at required_sections[%d]) — "
                                "each section's %s must be unique" % (at, k, v, seen[norm], k))
                else:
                    seen[norm] = i
        if "seeded_by" in sec and sec["seeded_by"] not in _GENRE_SEEDED_BY:
            errs.append("%s: 'seeded_by'=%r not in %s" % (at, sec.get("seeded_by"), list(_GENRE_SEEDED_BY)))
    return errs


def _reviewer_objection_errs(obj, where):
    """Per-item shape validation for reviewer_objections (B1 class; R2). The schema's `items` is
    deliberately UNTYPED — the subset validator has no union support, and a typed `items` would reject
    the object form before this arm ever ran — so ALL per-item validation lives here (the
    _genre_section_errs pattern). An item is either:
      - a legacy bare string (the writer's objection text) — ANY string is shape-valid; blank-ness
        stays W5's ADVISORY concern (the legacy contract, behavior-preserved); or
      - an object {objection: REQUIRED non-whitespace string, target?: Cn[.warrant|.support] or
        'argument-wide', relation?: the Argument_State §6 Relation vocabulary}.
    Post-draft audit-result keys (basis/condition/derivation_anchors/engaged/quality/codes) are
    REJECTED on a pre-draft item — those are Step-6 outputs, not writer plans (wrong-surface guard).
    The writer owns every objection's CONTENT; this checks shape only, never authors or suggests.
    Whether a `target`'s Cn resolves against the spine ladder is checked in check() (B5), where the
    spine is available — skip-not-fail when no valid spine exists (the B3 precedent)."""
    errs = []
    objections = obj.get("reviewer_objections")
    if objections is None or not isinstance(objections, list):
        return errs  # absence / container type is the schema's concern (and W5's)
    for i, o in enumerate(objections):
        at = "%s reviewer_objections[%d]" % (where, i)
        if isinstance(o, str):
            # LEGACY CONTRACT: any string is shape-valid — blank-ness was never a schema error and
            # remains W5's ADVISORY concern (the documented "whitespace-only still fires W5" arm).
            # Hardening it to ERROR here would silently change W5's contract; don't.
            continue
        if not isinstance(o, dict):
            errs.append("%s: must be a string or an object (got %s)" % (at, type(o).__name__))
            continue
        txt = o.get("objection")
        if not (isinstance(txt, str) and txt.strip()):
            errs.append("%s: object form requires a non-empty (non-whitespace) 'objection' string" % at)
        tgt = o.get("target")
        if "target" in o and not (isinstance(tgt, str) and _OBJECTION_TARGET_RE.match(tgt)):
            errs.append("%s: 'target'=%r must match C<n>[.warrant|.support] or 'argument-wide'" % (at, tgt))
        rel = o.get("relation")
        if "relation" in o and rel not in _OBJECTION_RELATIONS:
            errs.append("%s: 'relation'=%r not in %s" % (at, rel, list(_OBJECTION_RELATIONS)))
        for k in o:
            if k in _OBJECTION_POSTDRAFT_KEYS:
                errs.append("%s: %r is a POST-DRAFT audit result (Argument_State §6, Step 6) — it has "
                            "no business on a pre-draft writer item" % (at, k))
            elif k not in ("objection", "target", "relation"):
                errs.append("%s: unknown key %r (allowed: objection, target, relation)" % (at, k))
    return errs


def parse_genre_profiles(text):
    """[(obj_or_None, schema_errs, index), ...] for each apodictic:genre_profile block (Increment 5).

    Returns ALL genre_profile blocks (B4 fires on >1). schema_errs folds the subset-schema errors
    (B1: genre / evaluator enum, empty required_sections, missing field, bad JSON) AND the per-section
    {role, heading, seeded_by} shape the subset validator can't reach (also B1)."""
    plans = []
    if not text or art is None:
        return plans
    schema = art.load_schema(_GENRE_SCHEMA_ID)
    idx = 0
    for btype, obj, jerr in art.parse_blocks(text):
        if btype != "genre_profile":
            continue
        idx += 1
        where = "genre_profile #%d" % idx
        if jerr:
            plans.append((None, ["%s: invalid JSON — %s" % (where, jerr)], idx))
            continue
        serrs = art.validate_obj(obj, schema, where)
        if not serrs:
            # per-section + per-objection shape, only when the container is valid (both hand-rolled —
            # the subset validator does not recurse into object array items)
            serrs = _genre_section_errs(obj, where) + _reviewer_objection_errs(obj, where)
        plans.append((obj, serrs, idx))
    return plans


def check(text, strict=False):
    """Run the argument-spine integrity checks. Returns (code, lines)."""
    lines, errs, warns = [], [], []
    obj, schema_errs = parse_spine(text)
    supports = parse_support_plans(text)
    warrants = parse_warrant_plans(text)
    genres = parse_genre_profiles(text)
    if obj is None and not schema_errs and not supports and not warrants and not genres:
        return 0, ["argument-spine: no argument_spine / support_plan / warrant_plan / genre_profile "
                   "blocks found — nothing to check"]

    # A1 — schema / JSON validity
    for e in schema_errs:
        errs.append("A1 invalid spine: %s" % e)

    if obj is not None and not schema_errs:
        # A2 — the spine must seed Argument_State (the chosen integration), not float free
        seeded_1 = bool(_SEC1_RE.search(text))
        seeded_2 = bool(_SEC2_RE.search(text))
        if not (seeded_1 and seeded_2):
            missing = " + ".join(
                h for h, ok in (("## 1. Context and Classification", seeded_1),
                                ("## 2. Claim Architecture", seeded_2)) if not ok)
            errs.append("A2 unseeded: spine present but the artifact is not a seeded Argument_State "
                        "(missing heading: %s) — the spine must seed the shared artifact" % missing)
        else:
            # A3 — the seeded §2 C0 line must carry the spine's thesis
            m = _C0_RE.search(text)
            if not m:
                errs.append("A3 thesis/C0 drift: §2 has no 'C0 (main claim):' line to carry the "
                            "spine's thesis")
            elif _norm(obj.get("thesis")) not in _norm(m.group(1)):
                errs.append("A3 thesis/C0 drift: the seeded C0 (main claim) does not carry the "
                            "spine's thesis — the spine and Argument_State disagree")

        # W1 — anti-thesis must name a genuine opposing view, not echo the thesis
        anti, thesis = _echo_norm(obj.get("anti_thesis")), _echo_norm(obj.get("thesis"))
        if (not anti or anti == thesis) and not override_targets(text, "argument-spine-antithesis"):
            warns.append("W1 anti-thesis echo: the anti_thesis is empty or restates the thesis — "
                         "name the genuine opposing view the argument must defeat")

    # ---- Increment 2: source/evidence map (seeds §3 Support Map) ----
    # A4 — schema validity per support_plan
    for _o, serrs, _i in supports:
        for e in serrs:
            errs.append("A4 invalid support plan: %s" % e)
    valid_supports = [o for o, serrs, _i in supports if o is not None and not serrs]
    if valid_supports:
        # A6 — support plans must seed §3 Support Map (parallel to A2's seeding discipline)
        if not _SEC3_RE.search(text):
            errs.append("A6 support not seeded: support_plan blocks present but no '## 3. Support "
                        "Map' heading — the support map must seed Argument_State §3")
        declared = spine_subclaim_ids(obj) if (obj is not None and not schema_errs) else set()
        planned_ids = set()
        # A5 — dangling subclaim_id (a support plan that doesn't attach to a declared spine subclaim)
        for o in valid_supports:
            sid = o.get("subclaim_id")
            planned_ids.add(sid)
            if sid not in declared:
                errs.append("A5 dangling subclaim_id: support_plan references %s — not a declared "
                            "spine subclaim (declared: %s)" % (sid, ", ".join(sorted(declared)) or "none"))
        # W2 — bare assertion: a declared subclaim with no planned support. Staged — only once support
        # planning has started (>=1 plan), so a spine-only (Increment 1) artifact is never nagged.
        for sid in sorted(declared - planned_ids):
            warns.append("W2 bare assertion: %s has no planned support — name the intended support, "
                         "or mark it a known gap, before drafting" % sid)

    # ---- Increment 3: warrant pre-check (seeds §4 Warrant and Inference Map) ----
    # A7 — schema validity per warrant_plan
    for _o, werrs, _i in warrants:
        for e in werrs:
            errs.append("A7 invalid warrant plan: %s" % e)
    valid_warrants = [o for o, werrs, _i in warrants if o is not None and not werrs]
    if valid_warrants:
        # A9 — warrant plans must seed §4 Warrant and Inference Map (parallel to A2/A6)
        if not _SEC4_RE.search(text):
            errs.append("A9 warrant unseeded: warrant_plan blocks present but no '## 4. Warrant and "
                        "Inference Map' heading — the warrant map must seed Argument_State §4")
        declared = spine_subclaim_ids(obj) if (obj is not None and not schema_errs) else set()
        hostile = (obj.get("audience_receptivity") == "HOSTILE") if (obj is not None and not schema_errs) else False
        # A8 — dangling subclaim_id
        for o in valid_warrants:
            if o.get("subclaim_id") not in declared:
                errs.append("A8 dangling subclaim_id: warrant_plan references %s — not a declared "
                            "spine subclaim (declared: %s)"
                            % (o.get("subclaim_id"), ", ".join(sorted(declared)) or "none"))
        # W3 — for a HOSTILE audience, an implicit (non-EXPLICIT) or unbacked (ABSENT) warrant must be
        # made explicit and backed before drafting. Audience-calibrated against the spine. Override.
        if hostile and not override_targets(text, "argument-spine-warrant"):
            for o in valid_warrants:
                ws, bk = o.get("warrant_status"), o.get("backing")
                if ws != "EXPLICIT" or bk == "ABSENT":
                    reason = ("status=%s" % ws if ws != "EXPLICIT" else "") + (
                        (", " if ws != "EXPLICIT" else "") + "backing=ABSENT" if bk == "ABSENT" else "")
                    warns.append("W3 implicit warrant for hostile audience: %s (%s) — a HOSTILE "
                                 "audience won't grant it; make the warrant explicit and back it before "
                                 "drafting" % (o.get("subclaim_id"), reason))

    # ---- Increment 5: the genre layer (genre_profile seeds the genre's required-section skeleton) ----
    # B4 — duplicate genre profile (the piece is one genre). Parallels scene-ethics E2. Fires on the
    # COUNT, independent of per-block validity, so two malformed blocks still surface the duplication.
    if len(genres) > 1:
        errs.append("B4 duplicate genre profile: %d genre_profile blocks present — a pre-draft is one "
                    "genre; keep exactly one" % len(genres))
    # B1 — schema / per-section / JSON validity, per genre_profile block
    for _o, gerrs, _i in genres:
        for e in gerrs:
            errs.append("B1 invalid genre profile: %s" % e)
    valid_genres = [o for o, gerrs, _i in genres if o is not None and not gerrs]
    for gobj in valid_genres:
        sections = gobj.get("required_sections") or []
        # B2 — each declared required section must be SEEDED (a matching heading present in the artifact),
        # not merely declared. The genre analogue of A2/A6/A9's seed-don't-float-free signature. Headings
        # seed as ### sub-headings under the canonical §1–§6 (D3), so we match the heading TEXT (case-
        # insensitive) anywhere a markdown heading carries it, NOT a fixed §-number. The declared text must
        # be the heading line's ACTUAL content (the full run after the #s, modulo a trailing colon /
        # whitespace) — a substring of an unrelated heading does NOT seed it (declared 'Approach' is not
        # seeded by '### Approaching the Funder Landscape', 'Aims' is not seeded by '### Specific Aims').
        for sec in sections:
            heading = (sec.get("heading") or "").strip()
            if not heading:
                continue
            hre = re.compile(r"^#{1,6}[ \t]+%s[ \t]*:?[ \t]*$" % re.escape(heading),
                             re.IGNORECASE | re.MULTILINE)
            if not hre.search(text):
                errs.append("B2 section unseeded: declared genre section '%s' (role %s) has no matching "
                            "heading in the artifact — the genre skeleton must be seeded, not just "
                            "declared" % (heading, sec.get("role")))
        # B3 — the genre and the spine must agree about what the piece is. ONLY when a valid spine is
        # also present (a genre profile may precede the full spine in an early pre-draft -> skip, not
        # fail). Compatibility is normalized (spaces/hyphens, case), so the spaced doc-enum form
        # ('grant proposal') matches the hyphenated genre ('grant-proposal').
        if obj is not None and not schema_errs:
            if _genre_norm(gobj.get("genre")) != _genre_norm(obj.get("form")):
                errs.append("B3 genre/form mismatch: genre_profile.genre=%r is incompatible with the "
                            "spine's form=%r — the genre and the spine disagree about what the piece is"
                            % (gobj.get("genre"), obj.get("form")))
        # B5 — dangling objection target (R2): a typed reviewer_objections item whose target names a
        # Cn must resolve against the spine's declared ladder (the A5/A8 machinery). SKIP-not-fail
        # when no valid spine exists (the B3 precedent — a genre profile may predate the spine); the
        # target's SHAPE was already checked in _reviewer_objection_errs regardless. 'argument-wide'
        # and C0 need no ladder resolution (C0 is the thesis, always implicitly declared).
        if obj is not None and not schema_errs:
            declared_cn = spine_subclaim_ids(obj)
            for oi, o in enumerate(gobj.get("reviewer_objections") or []):
                if not isinstance(o, dict):
                    continue
                tgt = o.get("target")
                if not (isinstance(tgt, str) and _OBJECTION_TARGET_RE.match(tgt)):
                    continue  # bad shape already errored in _reviewer_objection_errs
                cn = tgt.split(".", 1)[0]
                if cn in ("argument-wide", "C0") or cn in declared_cn:
                    continue
                errs.append("B5 dangling objection target: reviewer_objections[%d] targets %s — %s is "
                            "not a declared spine subclaim" % (oi, tgt, cn))
        # W4 — thin genre skeleton: a declared genre's CANONICAL required role is missing from
        # required_sections (advisory; ERROR --strict; override silences). Genre conventions vary, so
        # this only nudges; the hard B-codes carry structural integrity.
        if not override_targets(text, "argument-spine-genre"):
            declared_roles = {_genre_norm(s.get("role")) for s in sections}
            for canon in _GENRE_CANONICAL.get(gobj.get("genre"), ()):
                if _genre_norm(canon) not in declared_roles:
                    warns.append("W4 thin genre skeleton: a %s profile omits the canonical section "
                                 "'%s' — add it, or override if this is a non-standard variant"
                                 % (gobj.get("genre"), canon))
        # W5 — reviewer-anticipation (Increment 2 of the genre layer): the writer must pre-list the
        # evaluator's likely objections (reviewer_objections). Empty or absent -> advisory (ERROR
        # --strict); override silences. FIREWALL: W5 checks ONLY that the writer's list is NON-EMPTY
        # (a real, non-whitespace entry); it never authors, suggests, or validates the CONTENT of an
        # objection — the engine never writes the writer's objections, it only surfaces the empty class.
        # The objections seed §6 (The Strongest Case Against); the core check is non-emptiness. A
        # W5-specific override slug (argument-spine-reviewer) keeps this decision decoupled from W4's.
        if not override_targets(text, "argument-spine-reviewer"):
            objections = gobj.get("reviewer_objections")
            # An item counts as real if it is a non-whitespace bare string OR (R2) an object form
            # carrying a non-whitespace 'objection' — an all-object-form list must NOT false-fire
            # "declares no reviewer_objections" (that regression would ERROR under --strict).
            non_empty = isinstance(objections, list) and any(
                (isinstance(o, str) and o.strip())
                or (isinstance(o, dict) and isinstance(o.get("objection"), str)
                    and o.get("objection").strip())
                for o in objections)
            if not non_empty:
                warns.append("W5 reviewer-anticipation: %s pre-draft declares no reviewer_objections — "
                             "pre-list the evaluator's likely objections (seeds §6, The Strongest Case "
                             "Against) before drafting; the engine never authors them." % gobj.get("genre"))

    # Report
    if obj is not None and not schema_errs:
        lines.append("argument-spine: %s / burden=%s / audience=%s,%s; %d subclaim(s)"
                     % (obj.get("argument_type"), obj.get("burden_level"),
                        obj.get("audience_expertise"), obj.get("audience_receptivity"),
                        len(obj.get("subclaims") or [])))
    if valid_supports:
        lines.append("argument-spine: %d support plan(s) over %d declared subclaim(s)"
                     % (len(valid_supports), len(spine_subclaim_ids(obj))))
    if valid_warrants:
        lines.append("argument-spine: %d warrant plan(s)" % len(valid_warrants))
    if valid_genres:
        g = valid_genres[0]
        lines.append("argument-spine: genre=%s / evaluator=%s; %d required section(s)"
                     % (g.get("genre"), g.get("evaluator"), len(g.get("required_sections") or [])))
    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("argument-spine: 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: argument-spine: %d advisory gap(s) — see W1/W2/W3/W4/W5 above" % len(warns))
    else:
        seeded = "§1/§2" + ("/§3" if valid_supports else "") + ("/§4" if valid_warrants else "")
        genre_tag = (" + genre %s" % valid_genres[0].get("genre")) if valid_genres else ""
        lines.append("argument-spine: PASS (contract + seeds Argument_State %s + anti-thesis%s)"
                     % (seeded, genre_tag))
    return 0, lines


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

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


def resolve(paths):
    if len(paths) == 1 and os.path.isdir(paths[0]):
        return _newest(glob.glob(os.path.join(paths[0], _STATE_GLOB)))
    for p in paths:
        if _has_block(_read(p) or "", "argument_spine"):
            return p
    return paths[0] if paths else None


def run(paths, strict=False):
    path = resolve(paths)
    if not path:
        return 2, ["argument-spine: no pre-draft Argument_State found (need an Argument_State*.md or "
                   "a file with an apodictic:argument_spine block)"]
    text = _read(path)
    if text is None:
        return 2, ["argument-spine: cannot read %s" % path]
    return check(text, 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 spine(thesis="the city should fund curb-cut ramps citywide",
              subclaims=("C1: ramps remove a documented mobility barrier",),
              anti="ramps are a low priority next to road repair", **over):
        obj = {"schema": _SCHEMA_ID, "form": "op-ed", "goal": "persuade the council to fund ramps",
               "argument_type": "AT3", "burden_level": "HIGH", "audience_expertise": "MIXED",
               "audience_receptivity": "HOSTILE", "thesis": thesis, "subclaims": list(subclaims),
               "anti_thesis": anti}
        obj.update(over)
        return "<!-- apodictic:argument_spine\n%s\n-->" % _j.dumps(obj)

    def seeded(thesis="the city should fund curb-cut ramps citywide", block=None):
        # a seeded Argument_State.md: canonical §1/§2 headings + a C0 line carrying the thesis
        return ("# Argument State\n\n## 1. Context and Classification\n\nForm: op-ed\n\n"
                "## 2. Claim Architecture\n\nC0 (main claim): %s\n\n## 6. Objection and "
                "Dialectical Integrity Map\n\nObjection 1: ramps are a low priority\n\n%s\n"
                % (thesis, block if block is not None else spine(thesis=thesis)))

    def support(sub="C1", stype="DATA", planned="city accessibility audit counts",
                status="to-acquire", **over):
        o = {"schema": _SUPPORT_SCHEMA_ID, "subclaim_id": sub, "support_type": stype,
             "planned_support": planned, "status": status}
        o.update(over)
        return "<!-- apodictic:support_plan\n%s\n-->" % _j.dumps(o)

    def seeded3(subclaims, supports, thesis="the city should fund curb-cut ramps citywide"):
        # a seeded Argument_State with §1/§2/§3, a spine whose ladder = subclaims, + support blocks
        return ("# Argument State\n## 1. Context and Classification\nForm: op-ed\n"
                "## 2. Claim Architecture\nC0 (main claim): %s\n## 3. Support Map\n%s\n"
                "## 6. Objection and Dialectical Integrity Map\nObjection 1: low priority\n%s\n"
                % (thesis, supports, spine(thesis=thesis, subclaims=subclaims)))

    def warrant(sub="C1", ws="EXPLICIT", bk="PRESENT", q="MATCHED",
                w="removing a documented barrier is a legitimate use of public funds", **over):
        o = {"schema": _WARRANT_SCHEMA_ID, "subclaim_id": sub, "warrant": w,
             "warrant_status": ws, "backing": bk, "qualifier": q}
        o.update(over)
        return "<!-- apodictic:warrant_plan\n%s\n-->" % _j.dumps(o)

    def seeded4(subclaims, warrants, receptivity="HOSTILE",
                thesis="the city should fund curb-cut ramps citywide"):
        # a seeded Argument_State with §1/§2/§4 + a spine whose ladder = subclaims, + warrant blocks
        return ("# Argument State\n## 1. Context and Classification\nForm: op-ed\n"
                "## 2. Claim Architecture\nC0 (main claim): %s\n"
                "## 4. Warrant and Inference Map\n%s\n"
                "## 6. Objection and Dialectical Integrity Map\nObjection 1: low priority\n%s\n"
                % (thesis, warrants, spine(thesis=thesis, subclaims=subclaims,
                                          audience_receptivity=receptivity)))

    # clean: a well-formed spine that seeds Argument_State §1/§2 with a matching C0
    chk("clean", check(seeded())[0] == 0)
    chk("a1_at5_generative_type_valid", check(seeded(block=spine(argument_type="AT5")))[0] == 0)
    # no block -> no-op
    chk("no_block_noop", check("# notes\nno spine yet\n")[0] == 0)

    # A1 — bad enum / missing field / empty ladder / JSON
    chk("a1_bad_argument_type", check(seeded(block=spine(argument_type="AT9")))[0] == 1)
    chk("a1_bad_burden", check(seeded(block=spine(burden_level="EXTREME")))[0] == 1)
    chk("a1_bad_audience", check(seeded(block=spine(audience_receptivity="WARM")))[0] == 1)
    chk("a1_empty_ladder", check(seeded(block=spine(subclaims=())))[0] == 1)
    chk("a1_missing_field",
        check(seeded(block=spine().replace('"anti_thesis"', '"anti"')))[0] == 1)
    code, lines = check('<!-- apodictic:argument_spine\n{"schema":"apodictic.argument_spine.v1"\n-->')
    chk("a1_bad_json", code == 1 and any("A1 invalid spine" in ln for ln in lines))

    # A2 — spine present but artifact is not a seeded Argument_State (no §1/§2 headings)
    code, lines = check(spine())   # the block alone, no Argument_State scaffolding
    chk("a2_unseeded", code == 1 and any("A2 unseeded" in ln for ln in lines))
    # only §1 present, §2 missing -> still A2
    code, lines = check("## 1. Context and Classification\n\n" + spine())
    chk("a2_partial_seed", code == 1 and any("A2 unseeded" in ln and "Claim Architecture" in ln for ln in lines))

    # A3 — C0 line does not carry the spine's thesis (drift between block and seeded markdown)
    code, lines = check(seeded(thesis="ramps citywide").replace(
        "C0 (main claim): ramps citywide", "C0 (main claim): something entirely different"))
    chk("a3_thesis_drift", code == 1 and any("A3 thesis/C0 drift" in ln for ln in lines))
    # §2 present but no C0 line at all -> A3
    code, lines = check("## 1. Context and Classification\n## 2. Claim Architecture\nno c0 line\n" + spine())
    chk("a3_no_c0_line", code == 1 and any("A3 thesis/C0 drift" in ln for ln in lines))

    # W1 — anti-thesis echoes the thesis (advisory; ERROR --strict; override silences)
    code, lines = check(seeded(block=spine(thesis="fund ramps now", anti="Fund ramps now."),
                               thesis="fund ramps now"))
    chk("w1_antithesis_echo", code == 0 and any("W1 anti-thesis echo" in ln for ln in lines))
    chk("w1_echo_strict_fails",
        check(seeded(block=spine(thesis="fund ramps now", anti="fund ramps now"),
                     thesis="fund ramps now"), strict=True)[0] == 1)
    ov = "<!-- override: argument-spine-antithesis — the inverse is genuinely the live debate -->\n"
    code, lines = check(seeded(block=ov + spine(thesis="fund ramps now", anti="fund ramps now"),
                               thesis="fund ramps now"))
    chk("w1_override", code == 0 and not any("WARN" in ln and "anti-thesis" in ln for ln in lines))
    # a genuine (non-echo) anti-thesis does not trip W1
    chk("w1_genuine_clean", not any("W1" in ln for ln in check(seeded())[1]))

    # ---- Increment 2: source/evidence map (support plans seed §3) ----
    TWO = ("C1: ramps remove a documented mobility barrier", "C2: the phased cost fits the budget")
    # clean: both subclaims have a support plan, §3 present
    chk("inc2_clean", check(seeded3(TWO, support("C1") + "\n" + support("C2")))[0] == 0)
    # A4 — bad support_type / status / subclaim_id format
    chk("a4_bad_support_type", check(seeded3(TWO, support("C1", stype="VIBES") + support("C2")))[0] == 1)
    chk("a4_bad_status", check(seeded3(TWO, support("C1", status="someday") + support("C2")))[0] == 1)
    chk("a4_bad_subclaim_fmt", check(seeded3(TWO, support("C1x") + support("C2")))[0] == 1)
    code, lines = check(seeded3(TWO, '<!-- apodictic:support_plan\n{"schema":"apodictic.support_plan.v1"\n-->'))
    chk("a4_bad_json", code == 1 and any("A4 invalid support plan" in ln for ln in lines))
    # A5 — dangling subclaim_id (C9 not declared in the spine ladder)
    code, lines = check(seeded3(TWO, support("C9")))
    chk("a5_dangling_subclaim", code == 1 and any("A5 dangling subclaim_id" in ln and "C9" in ln for ln in lines))
    # A6 — support plans present but no §3 heading (inject a support block into the §3-less seeded())
    code, lines = check(seeded().replace("## 6.", support("C1") + "\n## 6.", 1))
    chk("a6_support_unseeded", code == 1 and any("A6 support not seeded" in ln for ln in lines))
    # W2 — bare assertion: C2 has no support plan (staged ON; advisory; ERROR --strict)
    code, lines = check(seeded3(TWO, support("C1")))
    chk("w2_bare_assertion", code == 0 and any("W2 bare assertion" in ln and "C2" in ln for ln in lines))
    chk("w2_bare_strict_fails", check(seeded3(TWO, support("C1")), strict=True)[0] == 1)
    # W2 staged OFF: a spine with two subclaims but NO support plans -> no W2 (don't nag Increment 1)
    code, lines = check(seeded(block=spine(subclaims=TWO)))
    chk("w2_staged_off", code == 0 and not any("W2" in ln for ln in lines))

    # ---- Increment 3: warrant pre-check (warrant plans seed §4) ----
    ONE = ("C1: ramps remove a documented mobility barrier",)
    # clean: an explicit, backed warrant is fine even for a HOSTILE audience -> no W3
    chk("inc3_clean", check(seeded4(ONE, warrant("C1", ws="EXPLICIT", bk="PRESENT")))[0] == 0)
    # A7 — bad enum
    chk("a7_bad_status", check(seeded4(ONE, warrant("C1", ws="VAGUE")))[0] == 1)
    chk("a7_bad_backing", check(seeded4(ONE, warrant("C1", bk="SOME")))[0] == 1)
    # A8 — dangling subclaim_id
    code, lines = check(seeded4(ONE, warrant("C9")))
    chk("a8_dangling", code == 1 and any("A8 dangling subclaim_id" in ln and "C9" in ln for ln in lines))
    # A9 — warrants present but no §4 heading (inject into the §4-less seeded())
    code, lines = check(seeded().replace("## 6.", warrant("C1") + "\n## 6.", 1))
    chk("a9_warrant_unseeded", code == 1 and any("A9 warrant unseeded" in ln for ln in lines))
    # W3 — HOSTILE audience + implicit (RECOVERABLE) warrant -> advisory; ERROR --strict
    code, lines = check(seeded4(ONE, warrant("C1", ws="RECOVERABLE"), receptivity="HOSTILE"))
    chk("w3_implicit_hostile", code == 0 and any("W3 implicit warrant" in ln and "C1" in ln for ln in lines))
    chk("w3_implicit_strict_fails",
        check(seeded4(ONE, warrant("C1", ws="RECOVERABLE"), receptivity="HOSTILE"), strict=True)[0] == 1)
    # W3 — ABSENT backing also fires for HOSTILE (even when EXPLICIT)
    chk("w3_absent_backing_hostile",
        any("W3 implicit warrant" in ln
            for ln in check(seeded4(ONE, warrant("C1", ws="EXPLICIT", bk="ABSENT")))[1]))
    # W3 audience-calibrated OFF: same implicit warrant for a SYMPATHETIC audience -> no W3
    code, lines = check(seeded4(ONE, warrant("C1", ws="RECOVERABLE"), receptivity="SYMPATHETIC"))
    chk("w3_sympathetic_no_warn", code == 0 and not any("W3" in ln for ln in lines))
    # W3 override silences
    ovw = "<!-- override: argument-spine-warrant — the implicit warrant is shared ground here -->\n"
    code, lines = check(seeded4(ONE, ovw + warrant("C1", ws="RECOVERABLE"), receptivity="HOSTILE"))
    chk("w3_override", code == 0 and not any("W3" in ln for ln in lines))

    # ---- Increment 5: the genre layer (genre_profile seeds the genre's required-section skeleton) ----
    # canonical role->heading per genre (the full canonical set; a clean profile carries all of them)
    _G_SECTIONS = {
        "grant-proposal": [("specific-aims", "Specific Aims", "C0+ladder"),
                           ("significance", "Significance", "stakes"),
                           ("innovation", "Innovation", "subclaim"),
                           ("approach", "Approach", "support_plan")],
        "academic-article": [("contribution", "Contribution Claim", "C0+ladder"),
                             ("related-work", "Related-Work Positioning", "subclaim"),
                             ("method", "Method and Evidence", "support_plan"),
                             ("limitations", "Limitations and Scope", "none")],
        "pitch-deck": [("problem", "Problem", "stakes"),
                       ("solution", "Solution", "C0+ladder"),
                       ("traction", "Traction", "support_plan")],
    }

    # `objections` default is a NON-EMPTY list so a clean genre profile is also W5-clean (Increment 2
    # of the genre layer): pass objections=() for the empty-list W5 case, objections=None to omit the
    # key entirely (the absent W5 case). Content is placeholder — W5 only checks non-emptiness.
    def genre(g="grant-proposal", evaluator="panel-reviewer", sections=None,
              objections=("the evaluator will doubt feasibility",), **over):
        secs = [{"role": r, "heading": h, "seeded_by": s}
                for (r, h, s) in (sections if sections is not None else _G_SECTIONS[g])]
        o = {"schema": _GENRE_SCHEMA_ID, "genre": g, "required_sections": secs, "evaluator": evaluator}
        if objections is not None:
            o["reviewer_objections"] = list(objections)
        o.update(over)
        return "<!-- apodictic:genre_profile\n%s\n-->" % _j.dumps(o)

    def seededG(g="grant-proposal", evaluator="panel-reviewer", form=None, sections=None,
                thesis="the city should fund curb-cut ramps citywide", extra="",
                objections=("the evaluator will doubt feasibility",), **gover):
        # a seeded Argument_State whose spine form = the genre, whose §1/§2 carry the genre's required
        # section headings (as ### sub-headings), + a spine + the genre_profile block. `form` defaults
        # to the genre token so B3 passes; pass form= to force a mismatch (B3 reads the SPINE's form,
        # so the spine block carries form_val, not just the §1 markdown line). `objections` forwards to
        # genre() (non-empty by default -> W5-clean; () empty / None absent -> W5 fires).
        secs = sections if sections is not None else _G_SECTIONS[g]
        form_val = form if form is not None else g
        heads = "".join("### %s\n\n_seeded_\n\n" % h for (_r, h, _s) in secs)
        gblock = genre(g=g, evaluator=evaluator, sections=sections, objections=objections, **gover)
        return ("# Argument State\n## 1. Context and Classification\nForm: %s\n%s"
                "## 2. Claim Architecture\nC0 (main claim): %s\n%s"
                "## 6. Objection and Dialectical Integrity Map\nObjection 1: low priority\n%s\n"
                % (form_val, heads, thesis, extra,
                   gblock + "\n" + spine(thesis=thesis, form=form_val)))

    # clean: all three genres, fully seeded, form == genre -> PASS, no B/W
    for _g in ("grant-proposal", "academic-article", "pitch-deck"):
        code, lines = check(seededG(_g, evaluator={"grant-proposal": "panel-reviewer",
                                                   "academic-article": "peer-reviewer",
                                                   "pitch-deck": "investor"}[_g]))
        chk("genre_clean_%s" % _g, code == 0 and not any("B1" in ln or "B2" in ln or "B3" in ln
                                                         or "B4" in ln or "W4" in ln or "W5" in ln for ln in lines))
    # genre layer staged OFF: a plain spine (no genre_profile) behaves exactly as today -> no B/W codes
    chk("genre_staged_off", not any(("B1" in ln or "B2" in ln or "B3" in ln or "B4" in ln or "W4" in ln
                                     or "W5" in ln)
                                    for ln in check(seeded())[1]))
    # genre_profile alone (no spine) still no-op-resolves the spine but checks the genre -> not a crash;
    # B3 is SKIPPED when no valid spine is present
    code, lines = check("# notes\n### Specific Aims\n_x_\n### Significance\n_x_\n### Innovation\n_x_\n"
                        "### Approach\n_x_\n" + genre("grant-proposal"))
    chk("genre_no_spine_skips_b3", code == 0 and not any("B3" in ln for ln in lines))

    # B1 — bad genre enum / bad evaluator enum / empty required_sections / bad seeded_by / missing field / JSON
    # (swap the genre token inside the genre_profile JSON to an out-of-enum value; B3 also fires, but B1 is the point)
    chk("b1_bad_genre", check(seededG().replace('"genre": "grant-proposal"', '"genre": "white-paper"', 1))[0] == 1)
    chk("b1_bad_evaluator", check(seededG(evaluator="czar"))[0] == 1)
    code, lines = check(seededG(sections=[]))
    chk("b1_empty_sections", code == 1 and any("B1 invalid genre profile" in ln for ln in lines))
    code, lines = check(seededG(sections=[("specific-aims", "Specific Aims", "telepathy")]))
    chk("b1_bad_seeded_by", code == 1 and any("B1 invalid genre profile" in ln and "seeded_by" in ln for ln in lines))
    # missing a per-section field (drop heading) -> B1 (the per-section shape enforced in-validator)
    bad_sec_block = ('<!-- apodictic:genre_profile\n'
                     '{"schema":"apodictic.genre_profile.v1","genre":"pitch-deck","evaluator":"investor",'
                     '"required_sections":[{"role":"problem","seeded_by":"stakes"}]}\n-->')
    code, lines = check("# Argument State\n## 1. Context and Classification\nForm: pitch-deck\n"
                        "## 2. Claim Architecture\nC0 (main claim): x\n" + bad_sec_block)
    chk("b1_section_missing_field", code == 1 and any("B1 invalid genre profile" in ln and "heading" in ln for ln in lines))
    code, lines = check("## 1. Context and Classification\n## 2. Claim Architecture\n"
                        '<!-- apodictic:genre_profile\n{"schema":"apodictic.genre_profile.v1"\n-->')
    chk("b1_bad_json", code == 1 and any("B1 invalid genre profile" in ln for ln in lines))

    # B2 — a declared section heading is absent from the artifact (declared but not seeded)
    code, lines = check(seededG("grant-proposal").replace("### Approach\n\n_seeded_\n\n", "", 1))
    chk("b2_section_unseeded", code == 1 and any("B2 section unseeded" in ln and "Approach" in ln for ln in lines))
    # B2 lookalike guard: a heading that merely CONTAINS the word elsewhere as prose (not a heading) is
    # still unseeded — drop the §heading but leave a prose mention
    code, lines = check(seededG("pitch-deck").replace("### Traction\n\n_seeded_\n\n",
                                                      "We will discuss Traction in the deck.\n\n", 1))
    chk("b2_prose_not_heading", code == 1 and any("B2 section unseeded" in ln and "Traction" in ln for ln in lines))
    # B2 substring guard: a declared section is NOT seeded by a DIFFERENT heading that merely contains its
    # text as a substring. Required 'Approach' must not be satisfied by '### Approaching the Funder
    # Landscape'; required 'Aims' must not be satisfied by '### Specific Aims'. (These PASSED before the
    # match was tightened from substring-anywhere to full-heading-text — the exact false-pass B2 exists to catch.)
    code, lines = check(seededG("grant-proposal").replace("### Approach\n\n_seeded_\n\n",
                                                          "### Approaching the Funder Landscape\n\n_seeded_\n\n", 1))
    chk("b2_substring_diff_heading", code == 1 and any("B2 section unseeded" in ln and "Approach" in ln for ln in lines))
    AIMS_SECTIONS = [("aims", "Aims", "C0+ladder"), ("significance", "Significance", "stakes"),
                     ("innovation", "Innovation", "subclaim"), ("approach", "Approach", "support_plan")]
    # declare 'Aims' but seed only '### Specific Aims' (a longer, different heading) -> Aims is unseeded
    base = seededG("grant-proposal", sections=AIMS_SECTIONS)
    code, lines = check(base.replace("### Aims\n\n_seeded_\n\n", "### Specific Aims\n\n_seeded_\n\n", 1))
    chk("b2_substring_specific_aims", code == 1 and any("B2 section unseeded" in ln and "'Aims'" in ln for ln in lines))
    # the seeding IS satisfied by an exact heading, optionally with a trailing colon (the tightened form tolerates ':')
    code, lines = check(seededG("grant-proposal").replace("### Approach\n\n_seeded_\n\n",
                                                          "### Approach:\n\n_seeded_\n\n", 1))
    chk("b2_trailing_colon_ok", code == 0 and not any("B2 section unseeded" in ln and "Approach" in ln for ln in lines))

    # B3 — genre/form mismatch (genre says grant-proposal, the spine's form says pitch-deck)
    code, lines = check(seededG("grant-proposal", form="pitch-deck"))
    chk("b3_genre_form_mismatch", code == 1 and any("B3 genre/form mismatch" in ln for ln in lines))
    # B3 normalization: the spaced doc-enum form 'grant proposal' is COMPATIBLE with 'grant-proposal'
    code, lines = check(seededG("grant-proposal", form="grant proposal"))
    chk("b3_spaced_form_ok", code == 0 and not any("B3" in ln for ln in lines))
    # B3 normalization: 'academic article' (spaced) vs 'academic-article' -> compatible
    code, lines = check(seededG("academic-article", evaluator="peer-reviewer", form="Academic Article"))
    chk("b3_spaced_academic_ok", code == 0 and not any("B3" in ln for ln in lines))

    # B4 — two genre_profile blocks (the piece is one genre)
    code, lines = check(seededG("grant-proposal", extra=genre("grant-proposal") + "\n"))
    chk("b4_duplicate", code == 1 and any("B4 duplicate genre profile" in ln for ln in lines))

    # W4 — a declared genre omits a canonical role (grant without 'approach') -> advisory; ERROR --strict; override
    GRANT_NO_APPROACH = [("specific-aims", "Specific Aims", "C0+ladder"),
                         ("significance", "Significance", "stakes"),
                         ("innovation", "Innovation", "subclaim")]
    code, lines = check(seededG("grant-proposal", sections=GRANT_NO_APPROACH))
    chk("w4_thin_skeleton", code == 0 and any("W4 thin genre skeleton" in ln and "approach" in ln for ln in lines))
    chk("w4_strict_fails", check(seededG("grant-proposal", sections=GRANT_NO_APPROACH), strict=True)[0] == 1)
    ovg = "<!-- override: argument-spine-genre grant-proposal — LOI variant has no Approach section -->\n"
    code, lines = check(seededG("grant-proposal", sections=GRANT_NO_APPROACH, extra=ovg))
    chk("w4_override", code == 0 and not any("W4" in ln for ln in lines))
    # W4 academic: a contribution claim with NO related-work positioning is the academic signature thin-skeleton
    ACAD_NO_RELWORK = [("contribution", "Contribution Claim", "C0+ladder"),
                       ("method", "Method and Evidence", "support_plan"),
                       ("limitations", "Limitations and Scope", "none")]
    code, lines = check(seededG("academic-article", evaluator="peer-reviewer", sections=ACAD_NO_RELWORK))
    chk("w4_academic_no_relwork",
        code == 0 and any("W4 thin genre skeleton" in ln and "related-work" in ln for ln in lines))

    # ---- W5: reviewer-anticipation (Increment 2 of the genre layer) ----
    # clean: a non-empty reviewer_objections list -> no W5 (the writer pre-listed the objections)
    code, lines = check(seededG("grant-proposal", objections=("the eval design can't isolate the effect",)))
    chk("w5_clean_nonempty", code == 0 and not any("W5" in ln for ln in lines))
    # W5 default: an EMPTY reviewer_objections list -> advisory (code 0, WARN); ERROR under --strict
    code, lines = check(seededG("grant-proposal", objections=()))
    chk("w5_empty_warn",
        code == 0 and any("W5 reviewer-anticipation" in ln and "grant-proposal" in ln for ln in lines))
    chk("w5_empty_strict_fails", check(seededG("grant-proposal", objections=()), strict=True)[0] == 1)
    # W5 absent: no reviewer_objections key at all (the optional field) -> same advisory
    code, lines = check(seededG("pitch-deck", evaluator="investor", objections=None))
    chk("w5_absent_warn",
        code == 0 and any("W5 reviewer-anticipation" in ln and "pitch-deck" in ln for ln in lines))
    # W5 non-emptiness means REAL content: a list of only whitespace-blank strings still fires W5
    code, lines = check(seededG("grant-proposal", objections=("   ", "")))
    chk("w5_whitespace_only_warn", code == 0 and any("W5 reviewer-anticipation" in ln for ln in lines))
    # W5 override silences — a W5-SPECIFIC slug (argument-spine-reviewer), decoupled from W4's genre slug
    ovr = "<!-- override: argument-spine-reviewer grant-proposal — objections tracked in the sponsor portal -->\n"
    code, lines = check(seededG("grant-proposal", objections=(), extra=ovr))
    chk("w5_override", code == 0 and not any("W5" in ln for ln in lines))
    # W5 decoupled from W4: overriding W4 (argument-spine-genre) does NOT silence W5, and vice versa
    code, lines = check(seededG("grant-proposal", sections=GRANT_NO_APPROACH, objections=(),
                                extra="<!-- override: argument-spine-genre grant-proposal — LOI variant -->\n"))
    chk("w5_not_silenced_by_w4_override",
        code == 0 and not any("W4 thin genre skeleton" in ln for ln in lines)
        and any("W5 reviewer-anticipation" in ln for ln in lines))
    # W5 staged OFF: a plain spine (no genre_profile) never fires W5 (parallels W4's staged-off)
    chk("w5_staged_off", not any("W5" in ln for ln in check(seeded())[1]))
    # W5 FIREWALL: the surfaced warning names NO objection content — it only reports the empty class and
    # points at where the writer should pre-list. The engine must never author the objection text.
    code, lines = check(seededG("academic-article", evaluator="peer-reviewer", objections=()))
    w5_line = next((ln for ln in lines if "W5 reviewer-anticipation" in ln), "")
    chk("w5_firewall_authors_no_content",
        bool(w5_line) and "the engine never authors them" in w5_line
        and not any(term in w5_line for term in ("this is already known", "the gap isn't real",
                                                 "the method doesn't support")))

    # ---- R2: typed reviewer_objections (bare-string OR object form) + anti_thesis sibling metadata ----
    R2_OBJ = {"objection": "the eval design can't isolate the effect",
              "target": "C1.warrant", "relation": "WARRANT-DEFEATER"}
    # mixed bare + typed object -> clean (shape arm quiet, B5 quiet, W5 quiet)
    code, lines = check(seededG("grant-proposal", objections=("a bare objection", R2_OBJ)))
    chk("r2_mixed_clean", code == 0 and not any(
        "reviewer_objections[" in ln or "B5" in ln or "W5" in ln for ln in lines))
    # all valid object variants -> clean (objection-only / +target / +relation / all three)
    code, lines = check(seededG("grant-proposal", objections=(
        {"objection": "o1"}, {"objection": "o2", "target": "C1"},
        {"objection": "o3", "relation": "ALTERNATIVE"}, R2_OBJ)))
    chk("r2_object_variants_clean", code == 0 and not any("reviewer_objections[" in ln for ln in lines))
    # W5 REGRESSION GUARD: an ALL-object-form list is real content — must stay W5-green under --strict
    chk("r2_all_object_w5_green_strict",
        check(seededG("pitch-deck", evaluator="investor", objections=(R2_OBJ,)), strict=True)[0] == 0)
    # hostile: object missing 'objection' -> ERROR
    chk("r2_missing_objection_key", check(seededG("grant-proposal", objections=(
        {"target": "C1.warrant"},)))[0] == 1)
    # hostile: whitespace-only 'objection' -> ERROR
    chk("r2_blank_objection", check(seededG("grant-proposal", objections=(
        {"objection": "   "},)))[0] == 1)
    # hostile: a numeric item is neither string nor object -> ERROR
    chk("r2_numeric_item", check(seededG("grant-proposal", objections=(42,)))[0] == 1)
    # legacy bare-string blankness stays W5-ADVISORY, never a shape ERROR (behavior preservation:
    # the pre-existing w5_whitespace_only_warn arm is the contract; a blank item alongside a real
    # one was legacy-clean and must stay clean)
    code, lines = check(seededG("grant-proposal", objections=("  ", R2_OBJ)))
    chk("r2_blank_bare_string_stays_advisory",
        code == 0 and not any("reviewer_objections[" in ln for ln in lines))
    # hostile: bad target shape -> ERROR
    chk("r2_bad_target_shape", check(seededG("grant-proposal", objections=(
        {"objection": "x", "target": "warrant"},)))[0] == 1)
    # hostile: an explicitly present JSON null is not the same as omitting the optional typed field
    chk("r2_null_target", check(seededG("grant-proposal", objections=(
        {"objection": "x", "target": None},)))[0] == 1)
    # hostile: bad relation enum -> ERROR
    chk("r2_bad_relation", check(seededG("grant-proposal", objections=(
        {"objection": "x", "relation": "MITIGATED"},)))[0] == 1)
    chk("r2_null_relation", check(seededG("grant-proposal", objections=(
        {"objection": "x", "relation": None},)))[0] == 1)
    # hostile: stray POST-DRAFT audit key on a pre-draft item -> ERROR (wrong-surface guard)
    code, lines = check(seededG("grant-proposal", objections=(
        {"objection": "x", "engaged": "N"},)))
    chk("r2_postdraft_key_rejected", code == 1 and any("POST-DRAFT" in ln for ln in lines))
    # hostile: unknown key -> ERROR (closed item shape)
    chk("r2_unknown_key", check(seededG("grant-proposal", objections=(
        {"objection": "x", "weight": 3},)))[0] == 1)
    # B5: dangling target (spine present, C9 undeclared) -> ERROR naming B5
    code, lines = check(seededG("grant-proposal", objections=(
        {"objection": "x", "target": "C9.warrant"},)))
    chk("r2_b5_dangling_target", code == 1 and any("B5 dangling objection target" in ln for ln in lines))
    # B5 skip-not-fail: same dangling target with NO spine block -> B5 must NOT fire (B3 precedent)
    _, lines = check(genre(objections=({"objection": "x", "target": "C9.warrant"},)))
    chk("r2_b5_skip_without_spine", not any("B5" in ln for ln in lines))
    # B5: C0 + argument-wide targets need no ladder resolution
    code, lines = check(seededG("grant-proposal", objections=(
        {"objection": "x", "target": "C0"}, {"objection": "y", "target": "argument-wide"})))
    chk("r2_b5_c0_argumentwide_ok", code == 0 and not any("B5" in ln for ln in lines))
    # anti_thesis sibling metadata: valid pair -> clean; bad values -> schema ERROR (pattern / enum)
    chk("r2_anti_siblings_clean", check(seeded(block=spine(
        anti_thesis_target="C1.warrant", anti_thesis_relation="WARRANT-DEFEATER")))[0] == 0)
    code, lines = check(seeded(block=spine(anti_thesis_relation="MITIGATED")))
    chk("r2_anti_relation_bad", code == 1 and any("anti_thesis_relation" in ln for ln in lines))
    code, lines = check(seeded(block=spine(anti_thesis_target="the warrant")))
    chk("r2_anti_target_bad", code == 1 and any("anti_thesis_target" in ln for ln in lines))

    # B1 blank/duplicate role|heading — a non-whitespace, unique contract, enforced at the gate.
    # The smallest-input-that-breaks: all four canonical grant roles but heading="" everywhere. B2
    # SKIPS empty headings ('if not heading: continue'), so before this guard the profile reported the
    # genre SEEDED and PASSED --strict — the exact false-pass Codex reproduced. It must FAIL (B1), not
    # report seeded, in BOTH default and --strict mode (the seeding contract is not advisory).
    ALL_BLANK_HEAD = [(r, "", s) for (r, _h, s) in _G_SECTIONS["grant-proposal"]]
    code, lines = check(seededG("grant-proposal", sections=ALL_BLANK_HEAD))
    chk("b1_all_blank_heading_fails",
        code == 1 and any("B1 invalid genre profile" in ln and "heading" in ln for ln in lines)
        and not any("PASS" in ln for ln in lines))
    chk("b1_all_blank_heading_strict_fails",
        check(seededG("grant-proposal", sections=ALL_BLANK_HEAD), strict=True)[0] == 1)
    # one whitespace-only heading is just as empty as "" — a single tab must fail, not pass-through B2
    ONE_WS_HEAD = [("specific-aims", "Specific Aims", "C0+ladder"),
                   ("significance", "\t ", "stakes"),
                   ("innovation", "Innovation", "subclaim"),
                   ("approach", "Approach", "support_plan")]
    code, lines = check(seededG("grant-proposal", sections=ONE_WS_HEAD))
    chk("b1_whitespace_heading_fails",
        code == 1 and any("B1 invalid genre profile" in ln and "heading" in ln for ln in lines))
    # blank ROLE is rejected on the same gate (sibling of blank heading)
    ONE_BLANK_ROLE = [("", "Specific Aims", "C0+ladder"),
                      ("significance", "Significance", "stakes"),
                      ("innovation", "Innovation", "subclaim"),
                      ("approach", "Approach", "support_plan")]
    code, lines = check(seededG("grant-proposal", sections=ONE_BLANK_ROLE))
    chk("b1_blank_role_fails",
        code == 1 and any("B1 invalid genre profile" in ln and "role" in ln for ln in lines))
    # duplicate heading (case-insensitive) — two sections claim the same seed -> B1
    DUP_HEAD = [("specific-aims", "Specific Aims", "C0+ladder"),
                ("significance", "specific aims", "stakes"),
                ("innovation", "Innovation", "subclaim"),
                ("approach", "Approach", "support_plan")]
    code, lines = check(seededG("grant-proposal", sections=DUP_HEAD))
    chk("b1_duplicate_heading_fails",
        code == 1 and any("B1 invalid genre profile" in ln and "duplicate" in ln and "heading" in ln
                          for ln in lines))
    # duplicate role (normalized) — 'related work' and 'related-work' are the same declared role -> B1
    DUP_ROLE = [("approach", "Specific Aims", "C0+ladder"),
                ("approach", "Significance", "stakes"),
                ("innovation", "Innovation", "subclaim")]
    code, lines = check(seededG("grant-proposal", sections=DUP_ROLE))
    chk("b1_duplicate_role_fails",
        code == 1 and any("B1 invalid genre profile" in ln and "duplicate" in ln and "role" in ln
                          for ln in lines))
    # regression guard: the clean fully-seeded profiles (distinct, non-blank role+heading) still PASS,
    # so the new contract didn't over-reject legitimate genre profiles
    for _g, _ev in (("grant-proposal", "panel-reviewer"), ("academic-article", "peer-reviewer"),
                    ("pitch-deck", "investor")):
        chk("b1_clean_unique_%s" % _g,
            not any("B1 invalid genre profile" in ln for ln in check(seededG(_g, evaluator=_ev))[1]))

    # resolution: run-folder (Argument_State*.md) + explicit file
    import tempfile
    import shutil
    d = tempfile.mkdtemp()
    try:
        p = os.path.join(d, "Argument_State.md")
        with open(p, "w", encoding="utf-8", newline="") as fh:
            fh.write(seeded())
        chk("run_folder_resolution", run([d])[0] == 0)
        chk("explicit_file_resolution", run([p])[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 != "argument-spine"]
    strict = "--strict" in args
    paths = [a for a in args if not a.startswith("--")]
    if not paths:
        print("Usage: argument_spine.py argument-spine <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: f8fdfb78312d06a35f45049a7ada7be9327da71b6cc786f5d659f76d9df5a98e