← Files APODICTIC Development EditorARCHIVED FILE

scripts/argument_aif.py

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

↓ Download file

#!/usr/bin/env python3
"""Deterministic, loss-aware Argument_State -> AIF-Core-subset export (R4B)."""
from __future__ import annotations

import argparse
import copy
import hashlib
import json
import os
import re
import sys
import tempfile
from pathlib import Path

SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
    sys.path.insert(0, str(SCRIPT_DIR))
import apodictic_artifacts as artifacts  # noqa: E402
from override_marker import mask_code_spans  # noqa: E402

SCHEMA = "apodictic.aif-core-export.v1"
STATE_SCHEMA = "0.2.0"
NODE_TYPES = {"I-node", "RA-node", "CA-node"}
LOSS_CODES = {
    "CLAIM-LADDER-TOPOLOGY-UNDECLARED", "UNMAPPED-PA", "UNTYPED-OBJECTION",
    "UNRESOLVED-TARGET", "ALTERNATIVE-NO-CONFLICT-CRITERION",
    "ARGUMENT-WIDE-PROJECTED-TO-C0", "APODICTIC-METADATA",
    "OUT-OF-PROFILE-SECTION", "PREDRAFT-SUPERSEDED-BY-FINAL",
    "MISSING-SUPPORT-TOPOLOGY", "MISSING-WARRANT-TOPOLOGY",
    "FINAL-SECTION-INCOMPLETE", "SOURCE-ID-COLLISION",
}
BLOCKING = {"UNRESOLVED-TARGET", "FINAL-SECTION-INCOMPLETE", "SOURCE-ID-COLLISION"}
LOSS_DETAILS = {
    "CLAIM-LADDER-TOPOLOGY-UNDECLARED": "Argument_State declares no claim-ladder incidence; no RA-node was invented.",
    "UNMAPPED-PA": "No typed preference application is present; no PA-node was invented.",
    "UNTYPED-OBJECTION": "The objection has no complete Target/Relation pair; no CA-node was invented.",
    "UNRESOLVED-TARGET": "The typed objection target does not resolve under its declared relation.",
    "ALTERNATIVE-NO-CONFLICT-CRITERION": "The alternative declares no conflict criterion; no CA-node was invented.",
    "ARGUMENT-WIDE-PROJECTED-TO-C0": "The argument-wide target was projected to the C0 root.",
    "APODICTIC-METADATA": "APODICTIC metadata is not serialized as AIF graph semantics.",
    "OUT-OF-PROFILE-SECTION": "A populated Argument_State section is outside the AIF-Core subset profile.",
    "PREDRAFT-SUPERSEDED-BY-FINAL": "Final prose supersedes the corresponding pre-draft carrier population.",
    "MISSING-SUPPORT-TOPOLOGY": "No support topology is declared for the subclaim.",
    "MISSING-WARRANT-TOPOLOGY": "No warrant topology is declared for the subclaim.",
    "FINAL-SECTION-INCOMPLETE": "The final section began but lacks a complete record for the subclaim.",
    "SOURCE-ID-COLLISION": "Source-derived node IDs collide.",
}
LOSS_KEYS = {"code", "source_ref", "subject_id", "detail", "blocking"}
TOP_KEYS = {"schema", "source", "profile", "nodes", "edges", "losses"}
SOURCE_KEYS = {"artifact", "sha256", "argument_state_schema", "schema_version_basis"}
PROFILE_KEYS = {"name", "aif_reference", "loss_policy"}
CA_SCHEMES = {
    "WARRANT-DEFEATER": "apodictic:warrant-defeater",
    "CLAIM-CHALLENGE": "apodictic:claim-challenge",
    "EVIDENCE-CHALLENGE": "apodictic:evidence-challenge",
    "VALUE-CONFLICT": "apodictic:value-conflict",
}
SUPPORT_TYPES = {"REASON", "EXAMPLE", "DATA", "AUTHORITY", "EXPERIENCE"}
SCHEME_HINTS = {"AUTHORITY", "CONSEQUENCE", "CAUSAL", "ANALOGY", "EXAMPLE", "TESTIMONY", "PRACTICAL REASONING", "SIGN"}
WARRANT_STATUS = {"EXPLICIT", "RECOVERABLE", "MISSING", "CONTESTED"}
BACKING = {"PRESENT", "THIN", "ABSENT"}
QUALIFIER = {"MATCHED", "OVERCONFIDENT", "UNDERCLAIMED"}
_CID = re.compile(r"C\d+")
_ABS = re.compile(r"(?:^|[\s\"'])(?:/Users/|/home/|/private/tmp/|[A-Za-z]:\\)")
# Reserved machine-seeded placeholder GRAMMAR — per-keyword, exactly the live emitters' forms, never
# a keyword×opener cross-product (Codex P2: `_pending by …_` / `_seeded — …_` / `_seeded…_` are NOT
# emitter forms and must disclose). argument_spine emits `_seeded_` / `_seeded by …_`; the Step-9
# backfill emits `_pending_` / `_pending — …_` / `_pending…_`. Those structural stubs must NOT
# disclose an out-of-profile loss; every OTHER fully-italic line — authored content — MUST.
#   keyword -> (allowed annotation openers immediately after the keyword)
_PLACEHOLDER_GRAMMAR = {"seeded": (" by ",), "pending": (" — ",)}
# keywords with a live bare-ellipsis form (`_pending…_` / `_pending..._`); `_seeded…_` has none
_PLACEHOLDER_ELLIPSIS = ("pending",)
# Canonical key EMISSION order (mirrors build_export / _loss). run_check re-emits the parsed object in
# this order and compares bytes, so a key-reordered artifact fails even without --source (validate_export
# only checks membership via the order-blind *_KEYS sets). Kept beside those shape sets.
_ORDER_TOP = ("schema", "source", "profile", "nodes", "edges", "losses")
_ORDER_SOURCE = ("artifact", "sha256", "argument_state_schema", "schema_version_basis")
_ORDER_PROFILE = ("name", "aif_reference", "loss_policy")
_ORDER_NODE = {"I-node": ("id", "type", "text", "source_ref"),
               "RA-node": ("id", "type", "scheme", "scheme_text", "source_ref"),
               "CA-node": ("id", "type", "scheme", "source_ref")}
_ORDER_EDGE = ("from", "to")
_ORDER_LOSS = ("code", "source_ref", "subject_id", "detail", "blocking")


class ExportError(Exception):
    """A safe, bounded exporter failure class (never embeds source content or paths)."""


def _no_dupes(pairs):
    out = {}
    for key, value in pairs:
        if key in out:
            raise ValueError("duplicate-key")
        out[key] = value
    return out


def canonical(obj):
    return json.dumps(obj, ensure_ascii=False, indent=2, sort_keys=False) + "\n"


def _canon_keys(order, d):
    """Re-key `d` into `order` (known keys first, in canonical order), appending any unknown keys in
    their original order so nothing is dropped — validate_export owns rejecting unknown keys."""
    out = {k: d[k] for k in order if k in d}
    for k in d:
        if k not in out:
            out[k] = d[k]
    return out


def _reorder_keys(obj):
    """Reconstruct a parsed export with keys in build_export's canonical emission order. A shape the
    validator has already rejected passes through untouched (the byte compare fails regardless)."""
    if not isinstance(obj, dict):
        return obj
    out = _canon_keys(_ORDER_TOP, obj)
    if isinstance(out.get("source"), dict):
        out["source"] = _canon_keys(_ORDER_SOURCE, out["source"])
    if isinstance(out.get("profile"), dict):
        out["profile"] = _canon_keys(_ORDER_PROFILE, out["profile"])
    if isinstance(out.get("nodes"), list):
        out["nodes"] = [_canon_keys(_ORDER_NODE.get(n.get("type"), ()), n) if isinstance(n, dict) else n
                        for n in out["nodes"]]
    if isinstance(out.get("edges"), list):
        out["edges"] = [_canon_keys(_ORDER_EDGE, e) if isinstance(e, dict) else e for e in out["edges"]]
    if isinstance(out.get("losses"), list):
        out["losses"] = [_canon_keys(_ORDER_LOSS, x) if isinstance(x, dict) else x for x in out["losses"]]
    return out


def _loss(code, source_ref, subject_id=None):
    return {"code": code, "source_ref": source_ref, "subject_id": subject_id,
            "detail": LOSS_DETAILS[code], "blocking": code in BLOCKING}


def _loss_key(item):
    return (item["code"], item["source_ref"], item["subject_id"] or "", item["detail"])


def _section(text, masked, number):
    pat = re.compile(r"(?m)^##\s+%d\.[^\n]*\n" % number)
    hits = list(pat.finditer(masked))
    if len(hits) > 1:
        raise ExportError("duplicate-section-heading")
    if not hits:
        return None
    start = hits[0].end()
    nxt = re.search(r"(?m)^##\s+(?:\d+\.|Pending\b)", masked[start:])
    end = start + nxt.start() if nxt else len(masked)
    return text[start:end], masked[start:end]


def _structural_mask(text):
    """Coordinate-preserving activity mask backed by the shared code-span state machine.

    The shared stripper decides what is inactive. Equal spans are projected back onto the
    original line coordinates; stripped spans become spaces. Consumers may therefore locate
    active syntax in this mask and slice verbatim values from ``text`` with the same offsets.
    """
    masked = mask_code_spans(text)
    if len(masked) != len(text):
        raise ExportError("structural-mask-invalid")
    return masked


def _load_carriers(text, masked):
    wanted = {"argument_spine", "support_plan", "warrant_plan"}
    out = {kind: [] for kind in wanted}
    active_blocks = []
    for match in artifacts.BLOCK_RE.finditer(masked):
        parsed = artifacts.parse_blocks(text[match.start():match.end()])
        if len(parsed) != 1:
            raise ExportError("carrier-json-invalid")
        active_blocks.append(parsed[0])
    for kind, obj, parse_error in active_blocks:
        if kind not in wanted:
            continue
        if parse_error or not isinstance(obj, dict):
            raise ExportError("carrier-json-invalid")
        schema_id = "apodictic.%s.v1" % kind
        schema = artifacts.load_schema(schema_id)
        errors = artifacts.validate_obj(obj, schema, where=kind)
        if schema is None or set(obj) - set((schema.get("properties") or {})):
            errors.append("closed-shape")
        if errors:
            raise ExportError("carrier-schema-invalid")
        out[kind].append(obj)
    return out


def _spine_data(spines):
    if not spines:
        return None
    if len(spines) != 1:
        raise ExportError("carrier-cardinality-invalid")
    spine = spines[0]
    claims = {"C0": spine["thesis"].strip()}
    if not claims["C0"]:
        raise ExportError("carrier-value-invalid")
    for raw in spine["subclaims"]:
        match = re.fullmatch(r"(C\d+):\s*(\S.*)", raw)
        if not match or match.group(1) in claims:
            raise ExportError("carrier-value-invalid")
        claims[match.group(1)] = match.group(2)
    return claims


def _final_claims(sec):
    if sec is None:
        return None, False
    text, masked = sec
    started = bool(re.search(r"(?m)^C0 \(main claim\):|^Subclaims:\s*$|^\s{2}C\d+:", masked))
    if not started:
        return None, False
    allowed_meta = {"Stakes", "Stakes type", "Key terms", "Fired codes"}
    claims, c0_seen = {}, False
    for line_match in re.finditer(r"(?m)^.*$", masked):
        line = line_match.group(0)
        if not line.strip() or line.strip() == "Subclaims:":
            continue
        m = re.fullmatch(r"C0 \(main claim\):[ \t]*?(.*)", line)
        if m:
            if c0_seen: raise ExportError("final-claim-duplicate")
            line_start = line_match.start()
            value = text[line_start + m.start(1):line_start + m.end(1)].strip()
            if not value: raise ExportError("final-claim-incomplete")
            claims["C0"], c0_seen = value, True; continue
        m = re.fullmatch(r"\s{2}(C\d+):[ \t]*?(.*)", line)
        if m:
            if m.group(1) in claims: raise ExportError("final-claim-duplicate")
            line_start = line_match.start()
            value = text[line_start + m.start(2):line_start + m.end(2)].strip()
            if not value: raise ExportError("final-claim-incomplete")
            claims[m.group(1)] = value; continue
        m = re.match(r"^([A-Za-z][A-Za-z ]+):", line)
        if m and m.group(1) not in allowed_meta:
            raise ExportError("final-claim-field-unknown")
    if "C0" not in claims or not any(cid != "C0" for cid in claims):
        raise ExportError("final-claim-incomplete")
    return claims, True


def _parse_record_fields(body_text, body_masked, allowed, required, enums, kind):
    fields = {}
    offset = 0
    for line in body_masked.splitlines(keepends=True):
        bare = line.rstrip("\r\n")
        if not bare.strip(): offset += len(line); continue
        m = re.fullmatch(r"[ \t]{2}([A-Za-z][A-Za-z ]*):[ \t]*?(.*)", bare)
        if not m:
            if bare.strip().startswith("_"): offset += len(line); continue
            raise ExportError("final-%s-line-invalid" % kind)
        key = m.group(1)
        value = body_text[offset + m.start(2):offset + m.end(2)].strip()
        if not value: raise ExportError("final-%s-record-incomplete" % kind)
        if key not in allowed: raise ExportError("final-%s-field-unknown" % kind)
        if key in fields: raise ExportError("final-%s-field-duplicate" % kind)
        if key in enums and value not in enums[key]: raise ExportError("final-%s-enum-invalid" % kind)
        fields[key] = value
        offset += len(line)
    if not required <= set(fields): raise ExportError("final-%s-record-incomplete" % kind)
    return fields


def _records(sec, kind):
    if sec is None: return None, False
    text, masked = sec
    if kind == "support":
        allowed = {"Support", "Support type", "Scheme hint", "Codes"}
        required = {"Support", "Support type"}
        enums = {"Support type": SUPPORT_TYPES, "Scheme hint": SCHEME_HINTS}
        projection = lambda f: {"support": f["Support"], "support_type": f["Support type"], "scheme_hint": f.get("Scheme hint")}
        token = r"Support(?: type)?|Scheme hint"
    else:
        allowed = {"Warrant", "Warrant status", "Backing", "Qualifier", "Defeater refs", "Codes"}
        required = {"Warrant", "Warrant status", "Backing", "Qualifier"}
        enums = {"Warrant status": WARRANT_STATUS, "Backing": BACKING, "Qualifier": QUALIFIER}
        projection = lambda f: {"warrant": f["Warrant"], "warrant_status": f["Warrant status"], "backing": f["Backing"], "qualifier": f["Qualifier"]}
        token = r"Warrant(?: status)?|Backing|Qualifier"
    started = bool(re.search(r"(?m)^\s{2}(?:%s):" % token, masked))
    if not started: return None, False
    heads = list(re.finditer(r"(?m)^(C\d+):[^\n]*\n", masked))
    if not heads: raise ExportError("final-%s-record-incomplete" % kind)
    out = {}
    for index, head in enumerate(heads):
        cid = head.group(1)
        if cid in out: raise ExportError("final-%s-record-duplicate" % kind)
        end = heads[index + 1].start() if index + 1 < len(heads) else len(masked)
        fields = _parse_record_fields(text[head.end():end], masked[head.end():end], allowed, required, enums, kind)
        out[cid] = projection(fields)
    return out, True


def _plan_map(plans, kind):
    out = {}
    for plan in plans:
        cid = plan["subclaim_id"]
        if cid in out: raise ExportError("carrier-id-duplicate")
        if kind == "support":
            out[cid] = {"support": plan["planned_support"], "support_type": plan["support_type"], "scheme_hint": plan.get("scheme_hint")}
        else:
            out[cid] = {k: plan[k] for k in ("warrant", "warrant_status", "backing", "qualifier")}
    return out


def _objections(sec):
    if sec is None: return []
    text, masked = sec
    heads = list(re.finditer(r"(?m)^Objection\s+(\d+)(?:[^:]*):[ \t]*?(.*)$", masked))
    out, seen = [], set()
    allowed = {"Target", "Relation", "Basis", "Condition", "Derivation anchors", "Engaged", "Quality"}
    for i, head in enumerate(heads):
        oid = "O" + head.group(1)
        if oid in seen: raise ExportError("objection-duplicate")
        seen.add(oid)
        objection_text = text[head.start(2):head.end(2)].strip()
        if not objection_text: raise ExportError("objection-incomplete")
        body_end = heads[i + 1].start() if i + 1 < len(heads) else len(masked)
        body_masked = masked[head.end():body_end]
        body_text = text[head.end():body_end]
        fields = {}
        for field in re.finditer(r"(?m)^[ \t]{2}([A-Za-z][A-Za-z ]*):[ \t]*?(.*)$", body_masked):
            key = field.group(1)
            value = body_text[field.start(2):field.end(2)].strip()
            if not value: raise ExportError("objection-incomplete")
            if key not in allowed: raise ExportError("objection-field-unknown")
            if key in fields: raise ExportError("objection-field-duplicate")
            fields[key] = value
        out.append({"id": oid, "text": objection_text, "fields": fields})
    return out


def _is_reserved_placeholder(line):
    """True for a reserved machine-seeded stub only: a fully-italic `_seeded…_` / `_pending…_` (or an
    empty `__`) marker, or a fully-bracketed `[…]` stub. ONLY these enumerated shapes count as
    unpopulated; every other line — including arbitrary italic authored content — is real content that
    must disclose a loss. Replaces the old `startswith('_')` heuristic that swallowed authored italics
    (`_smuggled real out-of-profile content_`) as if the section were an empty placeholder (R4B fix)."""
    if line.startswith("[") and line.endswith("]"):
        return True
    if len(line) >= 2 and line.startswith("_") and line.endswith("_"):
        # BYTE-LITERAL per-keyword grammar — the live emitters' forms and
        # nothing else: no case folding, no whitespace normalization, no
        # open-ended dot-stripping (Codex P2 ×3: `_pending by …_`/`_seeded — …_`
        # cross-products, then `_pending._`/case/padding variants were each
        # swallowed by successive normalization generosity). Emitters write
        # exactly `_seeded_`, `_seeded by …_`, `_pending_`, `_pending — …_`,
        # `_pending…_`/`_pending..._` — lowercase, unpadded. Anything else in
        # full italics is authored content and DISCLOSES (over-disclosure on a
        # hand-cased stub is visible and fixable; a swallowed authored line is
        # silent). Residual floor: authored prose that itself opens with an
        # exact emitter prefix (`seeded by …` / `pending — …`) collides with
        # the annotation grammar.
        inner = line[1:-1]
        if inner == "":
            return True
        for kw, openers in _PLACEHOLDER_GRAMMAR.items():
            if inner == kw or any(inner.startswith(kw + op) for op in openers):
                return True
        for kw in _PLACEHOLDER_ELLIPSIS:
            if inner in (kw + "…", kw + "..."):
                return True
    return False


def _populated(sec):
    if sec is None: return False
    body = sec[1]
    lines = [x.strip() for x in body.splitlines() if x.strip()]
    return any(not _is_reserved_placeholder(x) for x in lines)


def build_export(source, *, artifact="Argument_State.md", state_schema=STATE_SCHEMA):
    if state_schema != STATE_SCHEMA: raise ExportError("state-schema-unsupported")
    raw = source if isinstance(source, bytes) else source.encode("utf-8")
    try: text = raw.decode("utf-8")
    except UnicodeDecodeError: raise ExportError("source-encoding-invalid")
    text = text.replace("\r\n", "\n").replace("\r", "\n")
    masked = _structural_mask(text)
    carriers = _load_carriers(text, masked)
    sections = {n: _section(text, masked, n) for n in range(1, 11)}
    spine_claims = _spine_data(carriers["argument_spine"])
    final_claims, claims_started = _final_claims(sections[2])
    claims_final = claims_started and (spine_claims is None or final_claims != spine_claims)
    claims = final_claims if claims_final else (spine_claims or final_claims)
    if not claims or "C0" not in claims: raise ExportError("claim-population-missing")
    subclaims = sorted((c for c in claims if c != "C0"), key=lambda x: int(x[1:]))
    final_support, support_started = _records(sections[3], "support")
    final_warrant, warrant_started = _records(sections[4], "warrant")
    plan_support = _plan_map(carriers["support_plan"], "support")
    plan_warrant = _plan_map(carriers["warrant_plan"], "warrant")
    declared = set(subclaims)
    populations = {
        "support-plan": set(plan_support), "warrant-plan": set(plan_warrant),
        "final-support": set(final_support or {}), "final-warrant": set(final_warrant or {}),
    }
    if any(ids - declared for ids in populations.values()):
        raise ExportError("subclaim-id-undeclared")
    support_final = support_started and (not plan_support or final_support != plan_support)
    warrant_final = warrant_started and (not plan_warrant or final_warrant != plan_warrant)
    supports = final_support if support_final else plan_support
    warrants = final_warrant if warrant_final else plan_warrant

    losses = [_loss("CLAIM-LADDER-TOPOLOGY-UNDECLARED", "§2")]
    if _populated(sections[5]): losses.append(_loss("UNMAPPED-PA", "§5"))
    for number in (1, 7, 8, 9, 10):
        if _populated(sections[number]): losses.append(_loss("OUT-OF-PROFILE-SECTION", "§%d" % number))
    if claims_started and re.search(r"(?m)^(?:Stakes|Stakes type|Key terms|Fired codes):", sections[2][1]):
        losses.append(_loss("APODICTIC-METADATA", "§2"))
    if spine_claims:
        losses.append(_loss("APODICTIC-METADATA", "predraft:argument_spine.v1", "argument_spine"))
    if claims_final and spine_claims: losses.append(_loss("PREDRAFT-SUPERSEDED-BY-FINAL", "§2", "argument_spine"))
    if support_final and plan_support: losses.append(_loss("PREDRAFT-SUPERSEDED-BY-FINAL", "§3", "support_plan"))
    if warrant_final and plan_warrant: losses.append(_loss("PREDRAFT-SUPERSEDED-BY-FINAL", "§4", "warrant_plan"))

    nodes = [{"id": "i:" + cid, "type": "I-node", "text": claims[cid], "source_ref": "§2 " + cid}
             for cid in sorted(claims, key=lambda x: int(x[1:]))]
    edges = []
    for cid in subclaims:
        support, warrant = supports.get(cid), warrants.get(cid)
        if support_final and not support: losses.append(_loss("FINAL-SECTION-INCOMPLETE", "§3 " + cid, cid))
        elif not support: losses.append(_loss("MISSING-SUPPORT-TOPOLOGY", "predraft:support_plan.v1:" + cid, cid))
        if warrant_final and not warrant: losses.append(_loss("FINAL-SECTION-INCOMPLETE", "§4 " + cid, cid))
        elif not warrant: losses.append(_loss("MISSING-WARRANT-TOPOLOGY", "predraft:warrant_plan.v1:" + cid, cid))
        if support: losses.append(_loss("APODICTIC-METADATA", ("§3 " if support_final else "predraft:support_plan.v1:") + cid, cid))
        if warrant: losses.append(_loss("APODICTIC-METADATA", ("§4 " if warrant_final else "predraft:warrant_plan.v1:") + cid, cid))
        if support and warrant:
            nodes.extend([
                {"id": "i:%s.support" % cid, "type": "I-node", "text": support["support"], "source_ref": ("§3 " if support_final else "predraft:support_plan.v1:") + cid},
                {"id": "s:RA:" + cid, "type": "RA-node", "scheme": "apodictic:warrant-application", "scheme_text": warrant["warrant"], "source_ref": ("§4 " if warrant_final else "predraft:warrant_plan.v1:") + cid},
            ])
            edges.extend([{"from": "i:%s.support" % cid, "to": "s:RA:" + cid}, {"from": "s:RA:" + cid, "to": "i:" + cid}])

    for objection in _objections(sections[6]):
        oid, fields = objection["id"], objection["fields"]
        nodes.append({"id": "i:" + oid, "type": "I-node", "text": objection["text"], "source_ref": "§6 " + oid})
        relation, target = fields.get("Relation"), fields.get("Target")
        if not relation or not target:
            losses.append(_loss("UNTYPED-OBJECTION", "§6 " + oid, oid)); continue
        if relation == "ALTERNATIVE":
            losses.append(_loss("ALTERNATIVE-NO-CONFLICT-CRITERION", "§6 " + oid, oid)); continue
        destination = None
        if target == "argument-wide":
            destination = "i:C0"; losses.append(_loss("ARGUMENT-WIDE-PROJECTED-TO-C0", "§6 " + oid, oid))
        elif re.fullmatch(r"C\d+\.warrant", target): destination = "s:RA:" + target.split(".")[0]
        elif re.fullmatch(r"C\d+\.support", target): destination = "i:" + target
        elif _CID.fullmatch(target): destination = "i:" + target
        ids = {node["id"] for node in nodes}
        if relation not in CA_SCHEMES or destination not in ids or (relation == "WARRANT-DEFEATER") != str(destination).startswith("s:RA:"):
            losses.append(_loss("UNRESOLVED-TARGET", "§6 " + oid, oid)); continue
        nodes.append({"id": "s:CA:" + oid, "type": "CA-node", "scheme": CA_SCHEMES[relation], "source_ref": "§6 " + oid})
        edges.extend([{"from": "i:" + oid, "to": "s:CA:" + oid}, {"from": "s:CA:" + oid, "to": destination}])
        if set(fields) & {"Basis", "Condition", "Derivation anchors", "Engaged", "Quality"}:
            losses.append(_loss("APODICTIC-METADATA", "§6 " + oid, oid))

    ids = [node["id"] for node in nodes]
    if len(ids) != len(set(ids)): losses.append(_loss("SOURCE-ID-COLLISION", "§2–§6"))
    losses = sorted({_loss_key(x): x for x in losses}.values(), key=_loss_key)
    nodes.sort(key=lambda x: x["id"]); edges.sort(key=lambda x: (x["from"], x["to"]))
    obj = {"schema": SCHEMA,
           "source": {"artifact": Path(artifact).name, "sha256": hashlib.sha256(raw).hexdigest(), "argument_state_schema": STATE_SCHEMA, "schema_version_basis": "operator-declared"},
           "profile": {"name": "aif-core-subset", "aif_reference": "AIF Specification, Definition 1.1", "loss_policy": "explicit"},
           "nodes": nodes, "edges": edges, "losses": losses}
    errors = validate_export(obj)
    if errors: raise ExportError("generated-export-invalid")
    if any(x["blocking"] for x in losses): raise ExportError("blocking-loss")
    return obj


def validate_export(obj):
    errors = []
    if not isinstance(obj, dict) or set(obj) != TOP_KEYS: return ["top-level closed shape"]
    if obj.get("schema") != SCHEMA: errors.append("schema literal")
    source, profile = obj.get("source"), obj.get("profile")
    if not isinstance(source, dict) or set(source) != SOURCE_KEYS: errors.append("source closed shape")
    else:
        if not isinstance(source["artifact"], str) or not source["artifact"] or Path(source["artifact"]).name != source["artifact"]: errors.append("source artifact")
        if not isinstance(source["sha256"], str) or not re.fullmatch(r"[0-9a-f]{64}", source["sha256"]): errors.append("source sha256")
        if source["argument_state_schema"] != STATE_SCHEMA: errors.append("state schema literal")
        if source["schema_version_basis"] != "operator-declared": errors.append("schema basis literal")
    expected_profile = {"name": "aif-core-subset", "aif_reference": "AIF Specification, Definition 1.1", "loss_policy": "explicit"}
    if not isinstance(profile, dict) or set(profile) != PROFILE_KEYS: errors.append("profile closed shape")
    elif profile != expected_profile: errors.append("profile literals")
    nodes, edges, losses = obj.get("nodes"), obj.get("edges"), obj.get("losses")
    if not all(isinstance(x, list) for x in (nodes, edges, losses)): return errors + ["arrays required"]
    ids, types, schemes, node_shapes_ok = [], {}, {}, True
    for node in nodes:
        if not isinstance(node, dict): errors.append("node object"); node_shapes_ok = False; continue
        typ, keys = node.get("type"), set(node)
        wanted = {"id", "type", "text", "source_ref"} if typ == "I-node" else ({"id", "type", "scheme", "scheme_text", "source_ref"} if typ == "RA-node" else {"id", "type", "scheme", "source_ref"})
        if typ not in NODE_TYPES or keys != wanted: errors.append("node closed shape"); node_shapes_ok = False; continue
        nid = node["id"]
        id_ok = (typ == "I-node" and re.fullmatch(r"i:(?:C\d+(?:\.support)?|O\d+)", nid or "")) or (typ == "RA-node" and re.fullmatch(r"s:RA:C\d+", nid or "")) or (typ == "CA-node" and re.fullmatch(r"s:CA:O\d+", nid or ""))
        if not isinstance(nid, str) or not id_ok: errors.append("node id")
        if not isinstance(node["source_ref"], str) or not node["source_ref"]: errors.append("node source_ref")
        if typ == "I-node" and (not isinstance(node["text"], str) or not node["text"]): errors.append("I text")
        if typ == "RA-node" and (node["scheme"] != "apodictic:warrant-application" or not isinstance(node["scheme_text"], str) or not node["scheme_text"]): errors.append("RA schema")
        if typ == "CA-node" and node["scheme"] not in set(CA_SCHEMES.values()): errors.append("CA scheme")
        ids.append(nid); types[nid] = typ; schemes[nid] = node.get("scheme")
    if (node_shapes_ok and nodes != sorted(nodes, key=lambda x: x["id"])) or len(ids) != len(set(ids)): errors.append("node order/unique")
    pairs, edge_shapes_ok = [], True
    for edge in edges:
        if not isinstance(edge, dict) or set(edge) != {"from", "to"} or not all(isinstance(edge.get(k), str) for k in ("from", "to")):
            errors.append("edge closed shape"); edge_shapes_ok = False; continue
        a, b = edge["from"], edge["to"]; pairs.append((a, b))
        if a not in types or b not in types: errors.append("dangling edge"); continue
        if (types[a], types[b]) not in {("I-node", "RA-node"), ("RA-node", "I-node"), ("I-node", "CA-node"), ("CA-node", "I-node"), ("CA-node", "RA-node")}: errors.append("edge direction")
    if (edge_shapes_ok and edges != sorted(edges, key=lambda x: (x["from"], x["to"]))) or len(pairs) != len(set(pairs)): errors.append("edge order/unique")
    for nid, typ in types.items():
        incoming = [a for a, b in pairs if b == nid]; outgoing = [b for a, b in pairs if a == nid]
        if typ == "RA-node" and (not any(types.get(x) == "I-node" for x in incoming) or any(types.get(x) not in {"I-node", "CA-node"} for x in incoming) or len(outgoing) != 1 or types.get(outgoing[0]) != "I-node"): errors.append("RA incidence " + nid)
        if typ == "CA-node":
            dest_type = types.get(outgoing[0]) if len(outgoing) == 1 else None
            expected_dest = "RA-node" if schemes.get(nid) == CA_SCHEMES["WARRANT-DEFEATER"] else "I-node"
            if len(incoming) != 1 or types.get(incoming[0]) != "I-node" or len(outgoing) != 1 or dest_type != expected_dest: errors.append("CA incidence " + nid)
    loss_keys, loss_shapes_ok = [], True
    for item in losses:
        if not isinstance(item, dict) or set(item) != LOSS_KEYS: errors.append("loss closed shape"); loss_shapes_ok = False; continue
        code = item.get("code")
        # type-exact bool: Python's `0 == False` would let a numeric 0/1 satisfy the recompute AND
        # survive canonical re-emission byte-identically (json emits ints as digits) — Codex P2
        if code not in LOSS_CODES or not isinstance(item.get("blocking"), bool) or item.get("blocking") != (code in BLOCKING): errors.append("loss enum/blocking")
        if code in LOSS_DETAILS and item.get("detail") != LOSS_DETAILS[code]: errors.append("loss detail")
        if not isinstance(item.get("source_ref"), str) or not item["source_ref"] or (item.get("subject_id") is not None and (not isinstance(item["subject_id"], str) or not item["subject_id"])): errors.append("loss fields")
        loss_keys.append(_loss_key(item))
    if (loss_shapes_ok and losses != sorted(losses, key=_loss_key)) or len(loss_keys) != len(set(loss_keys)): errors.append("loss order/unique")
    if any(x.get("blocking") for x in losses if isinstance(x, dict)): errors.append("blocking loss stored")
    meta = copy.deepcopy(obj)
    for node in meta.get("nodes", []):
        if isinstance(node, dict): node.pop("text", None); node.pop("scheme_text", None)
    if _ABS.search(json.dumps(meta, ensure_ascii=False)): errors.append("generated path leakage")
    return errors


def atomic_write(path, data):
    target = Path(path)
    fd, temp = tempfile.mkstemp(prefix="." + target.name + ".", dir=str(target.parent))
    try:
        with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
            handle.write(data); handle.flush(); os.fsync(handle.fileno())
        os.replace(temp, target)
    except Exception:
        try: os.unlink(temp)
        except OSError: pass
        raise


def run_check(path, source=None):
    try: raw = Path(path).read_bytes()
    except OSError: print("argument-aif-check: FAIL [artifact-read]"); return 1
    try: text = raw.decode("utf-8"); obj = json.loads(text, object_pairs_hook=_no_dupes)
    except (UnicodeError, ValueError, json.JSONDecodeError): print("argument-aif-check: FAIL [artifact-json]"); return 1
    errors = validate_export(obj)
    # Byte-canonicality: sourceless too. The first compare catches indentation/whitespace/encoding
    # drift; `sort_keys=False` preserves the parsed key order, so a key-reordered-but-otherwise-canonical
    # artifact slips past it — the elif re-emits in the canonical key order and re-compares, so a
    # reordered artifact fails even without --source. (Loss-set completeness still needs the source.)
    if canonical(obj).encode("utf-8") != raw: errors.append("non-canonical JSON bytes")
    elif canonical(_reorder_keys(obj)).encode("utf-8") != raw: errors.append("non-canonical key order")
    # `is not None`, not truthiness: an explicit `--source ''` must FAIL the source-mode checks
    # (basename mismatch — Path('').name is ''), never silently DOWNGRADE to a passing sourceless
    # validation (Codex P2).
    if source is not None:
        src_obj = obj.get("source") if isinstance(obj, dict) else None
        recorded = src_obj.get("artifact") if isinstance(src_obj, dict) else None
        # Bind the export's self-attested artifact name to the supplied source. The rebuild adopts
        # `recorded`, so without this an arbitrary/tampered basename would ride through (the sha256 is
        # recomputed, so it is cosmetic provenance — but bind it so a mislabeled export cannot claim a
        # clean source-closure check).
        if not isinstance(recorded, str) or Path(recorded).name != Path(source).name:
            errors.append("source artifact basename mismatch")
        else:
            try: source_raw = Path(source).read_bytes(); rebuilt = build_export(source_raw, artifact=recorded, state_schema=src_obj.get("argument_state_schema"))
            except (OSError, ExportError): errors.append("source rebuild")
            else:
                if canonical(rebuilt).encode("utf-8") != raw: errors.append("source-derived byte closure")
    if errors:
        print("argument-aif-check: FAIL (%d)" % len(errors)); [print("  - " + x) for x in errors]; return 1
    print("argument-aif-check: PASS — %d nodes, %d edges, %d disclosed losses" % (len(obj["nodes"]), len(obj["edges"]), len(obj["losses"]))); return 0


def selftest():
    final = '''## 2. Claim Architecture\nC0 (main claim): root /Users/authored/path\n\nSubclaims:\n  C1: one\n\n## 3. Support Map\nC1: one\n  Support: because evidence\n  Support type: DATA\n  Scheme hint: SIGN\n\n## 4. Warrant and Inference Map\nC1: one\n  Warrant: evidence bears on the claim\n  Warrant status: EXPLICIT\n  Backing: PRESENT\n  Qualifier: MATCHED\n\n## 5. Burden, Scope, and Comparative Assessment\nClaim scope: LOCAL\n\n## 6. Objection and Dialectical Integrity Map\nObjection 1: unless the sample is biased\n  Target: C1.warrant\n  Relation: WARRANT-DEFEATER\n  Basis: IMPORTED\n'''
    failures, arms = [], []
    def check(name, condition):
        arms.append(name)
        if not condition: failures.append(name)
    obj = build_export(final.encode())
    check("support", any(n["id"] == "i:C1.support" for n in obj["nodes"]))
    check("CA attacks RA", {"from": "s:CA:O1", "to": "s:RA:C1"} in obj["edges"])
    check("authored path", "/Users/authored/path" in canonical(obj))
    for name, mutate in [
        ("bad CA scheme", lambda x: next(n for n in x["nodes"] if n["type"] == "CA-node").update({"scheme": "bad"})),
        ("wrong CA destination", lambda x: next(e for e in x["edges"] if e["from"].startswith("s:CA:")).update({"to": "i:C1"})),
        ("bad profile", lambda x: x["profile"].update({"loss_policy": "maybe"})),
        ("altered loss detail", lambda x: x["losses"][0].update({"detail": "changed"})),
    ]:
        bad = copy.deepcopy(obj); mutate(bad); check(name, bool(validate_export(bad)))
    fenced = "```md\n" + final + "\n```\n" + final
    check("fence-aware", build_export(fenced.encode())["source"]["sha256"] == hashlib.sha256(fenced.encode()).hexdigest())
    crlf = final.replace("\n", "\r\n").encode(); lf = final.encode()
    check("raw-byte sha", build_export(crlf)["source"]["sha256"] != build_export(lf)["source"]["sha256"])
    section_ten = build_export((final + "\n## 10. Audit Trail\nResearch disposition: retained\n").encode())
    check("section 10 loss", any(x["code"] == "OUT-OF-PROFILE-SECTION" and x["source_ref"] == "§10" for x in section_ten["losses"]))
    inline = (final.replace("root /Users/authored/path", "root `claim` verbatim")
              .replace("because evidence", "because `support` verbatim")
              .replace("evidence bears on the claim", "`warrant` bears verbatim")
              .replace("unless the sample is biased", "unless `objection` verbatim"))
    inline_obj = build_export(inline.encode())
    rendered = canonical(inline_obj)
    check("inline values verbatim", all(value in rendered for value in
          ("root `claim` verbatim", "because `support` verbatim", "`warrant` bears verbatim", "unless `objection` verbatim")))
    carrier = '''<!-- apodictic:argument_spine\n{"schema":"apodictic.argument_spine.v1","form":"policy `brief`","goal":"persuade","argument_type":"AT1","burden_level":"MEDIUM","audience_expertise":"MIXED","audience_receptivity":"MIXED","thesis":"keep `carrier value` verbatim","subclaims":["C1: one"],"anti_thesis":"no"}\n-->'''
    carrier_obj = build_export(carrier.encode())
    check("inline carrier value verbatim", next(n for n in carrier_obj["nodes"] if n["id"] == "i:C0")["text"] == "keep `carrier value` verbatim")
    multiline_decoy = ("quote `opens here\n## 2. Claim Architecture\n"
                       "C0 (main claim): decoy claim\nSubclaims:\n  C99: decoy subclaim\n"
                       "and closes here` outside\n" + final)
    decoy_obj = build_export(multiline_decoy.encode())
    check("multiline inline heading decoy",
          next(n for n in decoy_obj["nodes"] if n["id"] == "i:C0")["text"] == "root /Users/authored/path"
          and not any(n["id"] == "i:C99" for n in decoy_obj["nodes"]))
    incomplete = final.replace("  Support type: DATA\n", "")
    try: build_export(incomplete.encode()); raised = False
    except ExportError: raised = True
    check("incomplete final", raised)
    # final-support-enum-invalid: a §3 support record with an out-of-enum Support type must fail at build.
    bad_support = final.replace("  Support type: DATA\n", "  Support type: VIBES\n")
    try: build_export(bad_support.encode()); enum_raised = False
    except ExportError as exc: enum_raised = str(exc) == "final-support-enum-invalid"
    check("final-support-enum-invalid", enum_raised)
    # Out-of-profile under-disclosure (R4B): authored italic content in an out-of-profile section is
    # disclosed, while a genuinely-seeded `_seeded…_` stub is NOT (no false OUT-OF-PROFILE loss).
    smuggled = build_export((final + "\n## 7. Out Of Profile\n_smuggled real out-of-profile content_\n").encode())
    check("out-of-profile italic authored content discloses loss",
          any(x["code"] == "OUT-OF-PROFILE-SECTION" and x["source_ref"] == "§7" for x in smuggled["losses"]))
    seeded_stub = build_export((final + "\n## 7. Out Of Profile\n_seeded by argument_spine_\n").encode())
    check("reserved placeholder stays unpopulated",
          not any(x["code"] == "OUT-OF-PROFILE-SECTION" and x["source_ref"] == "§7" for x in seeded_stub["losses"]))
    # Codex P2: the keyword must be the WHOLE stub or carry its machine-annotation opener — authored
    # italic prose whose FIRST WORD happens to be seeded/pending must disclose.
    for authored in ("_Pending litigation changes the stakes._",
                     "_Seeded distrust changes how readers assess the claim._",
                     # no keyword×opener cross-product: these are NOT emitter forms (Codex P2, round 2)
                     "_pending by court order remains unresolved._",
                     "_seeded — the mistrust changes the stakes._",
                     "_seeded…_",
                     # byte-literal: no dot-rstrip, no case fold, no padding (Codex P2, round 3)
                     "_pending._",
                     "_Pending_",
                     "_ pending _"):
        got = build_export((final + "\n## 7. Out Of Profile\n%s\n" % authored).encode())
        check("authored %s-led italic discloses" % authored[1:8].strip().lower(),
              any(x["code"] == "OUT-OF-PROFILE-SECTION" and x["source_ref"] == "§7" for x in got["losses"]))
    # …while the REAL annotation stubs stay suppressed.
    for stub in ("_pending — backfilled by Step 9 ..._", "_seeded by support_plan blocks_",
                 "_pending…_", "_pending..._"):
        got = build_export((final + "\n## 7. Out Of Profile\n%s\n" % stub).encode())
        check("reserved stub %r stays unpopulated" % stub[1:9],
              not any(x["code"] == "OUT-OF-PROFILE-SECTION" and x["source_ref"] == "§7" for x in got["losses"]))
    # Codex P2: type-exact blocking — a numeric 0 satisfies `0 == False` and survives canonical
    # re-emission byte-identically, so validate_export must reject non-bool blocking outright.
    typed = build_export((final + "\n## 7. Out Of Profile\nReal content.\n").encode())
    typed["losses"][0]["blocking"] = 0
    check("numeric blocking rejected (bool-exact)",
          any(e == "loss enum/blocking" for e in validate_export(typed)))
    # Codex P2: an explicit --source '' must FAIL source-mode (basename mismatch), never silently
    # downgrade to a passing sourceless check.
    import contextlib, io, tempfile as _tf
    with _tf.TemporaryDirectory(prefix="aif_selftest_") as td:
        p = os.path.join(td, "export.json")
        atomic_write(p, canonical(obj))
        buf = io.StringIO()
        with contextlib.redirect_stdout(buf):
            rc_empty = run_check(p, source="")
        check("--source '' fails closed (no sourceless downgrade)",
              rc_empty == 1 and "basename mismatch" in buf.getvalue())
        buf = io.StringIO()
        with contextlib.redirect_stdout(buf):
            rc_none = run_check(p, source=None)
        check("sourceless check on a canonical artifact still passes", rc_none == 0)
    if failures:
        print("Self-test: FAIL"); [print("  - " + x) for x in failures]; return 1
    print("Self-test: PASS (argument-aif; %d focused arms)" % len(arms)); return 0


def main(argv=None):
    argv = list(sys.argv[1:] if argv is None else argv)
    if argv == ["--self-test"]: return selftest()
    parser = argparse.ArgumentParser(); sub = parser.add_subparsers(dest="cmd", required=True)
    export = sub.add_parser("export"); export.add_argument("source"); export.add_argument("--state-schema", required=True); export.add_argument("--out"); export.add_argument("--strict", action="store_true")
    check = sub.add_parser("check"); check.add_argument("artifact"); check.add_argument("--source")
    args = parser.parse_args(argv)
    if args.cmd == "check": return run_check(args.artifact, args.source)
    try:
        raw = Path(args.source).read_bytes(); obj = build_export(raw, artifact=Path(args.source).name, state_schema=args.state_schema)
        if args.strict and obj["losses"]: raise ExportError("strict-loss")
        data = canonical(obj)
        if args.out: atomic_write(args.out, data)
        else: sys.stdout.write(data)
        return 0
    except OSError:
        print("argument-aif-export: FAIL [source-read]", file=sys.stderr); return 1
    except ExportError as exc:
        print("argument-aif-export: FAIL [%s]" % str(exc), file=sys.stderr); return 1


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

SHA-256: 158b298de68eed4aa7221cb1d2057d62e1555ca5a3f54329b8cbc3c06bf3b2c5