← Files APODICTIC Development EditorARCHIVED FILE

scripts/world_bible.py

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

↓ Download file

#!/usr/bin/env python3
"""world-bible — structural integrity + contradiction detection for a standalone Worldbuilding Bible.

`validate.sh world-bible <run_folder|files>` shells out here. A worldbuilding bible is the SFF
author's OWN pre-draft reference document — the rules of the magic/tech, the cost of using it, the
geography, the order of events, the factions. Before a single chapter exists, that bible can already
contradict itself. This tool checks a BIBLE (not a manuscript) for that class of self-contradiction —
closed-set rule consistency, magic-system cost accounting, geography/timeline contradiction — and
SURFACES the contradictions the bible has already committed to. It never invents world content, fills
an unstated gap, or resolves a conflict (the Firewall: extract the stated, never author the unstated).
Each fact is an apodictic.world_fact.v1 block; this validator owns the bible's contract and the three
contradiction arms.

  W1 schema           a world_fact block fails its schema (bad category/polarity enum, malformed
                      WF-NN id, missing required field, unquoted-numeric value, empty loci, broken
                      JSON) OR carries an unknown (closed-key) field — a misspelled key the subset
                      schema engine would otherwise admit silently (so the closed-set guarantee is
                      not hollow).
  WD duplicate id     two facts share a WF-NN id (the W-class id-uniqueness check).
  WB-R1 rule          two `rule` facts on the same subject + same normalized value assert opposite
                      polarity (can vs cannot, or requires vs cannot) — a literal closed-set
                      contradiction. ERROR. Override <!-- override: world-rule WF-NN/WF-MM — … -->.
  WB-C1 cost          two `cost`/cost-bearing-`rule` facts on the same subject assign two different
                      non-null, non-"none" cost values — the bible prices the same power two ways.
                      ERROR. Override <!-- override: world-cost WF-NN/WF-MM — … -->.
  WB-C2 free-vs-costed  the same subject is stated free (cost null/"none") in one fact and carries a
                      real cost in another — the pre-draft form of Cost Amnesia. Advisory WARN;
                      ERROR under --strict. Override <!-- override: world-cost WF-NN/WF-MM — … -->.
  WB-G1 distance      the same unordered {subject, pair_subject} edge is assigned two different parsed
                      distances WITHIN ONE commensurable unit class (spatial: mile/league/km;
                      temporal-travel: day/hour). The two axes never collide-check against each other
                      (a 6-day ride and 120 miles can both be true). ERROR. Override
                      <!-- override: world-geo WF-NN/WF-MM — … -->.
  WB-G2 chronology    the directed `event` edges (subject happens-before pair_subject) contain a CYCLE
                      (X<Y, Y<Z, Z<X), OR the same event is stated at two different absolute day
                      anchors (Day N). An unresolvable timeline contradiction. ERROR. Override
                      <!-- override: world-geo WF-NN/WF-MM — … --> (cycle: name any two ids on it).
  WF (firewall)       a reader-facing prose scan: a resolution/invention verb ("the true rule is",
                      "the canonical cost is", "should be N miles") leaking into the bible's prose —
                      the tool SURFACES, it never resolves. Advisory WARN; ERROR under --strict.
                      Override <!-- override: world-firewall — <rationale> -->.

`value` is ALWAYS a string (numerics quoted) so W1 can type-check + the arms can parse it. The three
arms are deterministic, stdlib-only, and CONSERVATIVE: they fire only on confidently parseable,
literally-colliding facts; ambiguous / unparseable / cross-axis / non-literal (implied) contradictions
are left to the author and never become a hard failure. Reuses apodictic_artifacts (block grammar +
schema engine). Each artifact is optional; an empty/absent one is a no-op. See docs/worldbuilding-bible.md.

  world_bible.py world-bible <run_folder|files...> [--strict]
  world_bible.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
import contradiction_state as cstate  # shared State axis: derive_state + State-column parse + X1 firewall

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)."""
    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.world_fact.v1"
_BIBLE_GLOB = "*_Worldbuilding_Bible_*.md"

# The closed key set — every key world_bible.py recognizes on a world_fact block. Because the subset
# schema engine in apodictic_artifacts SILENTLY ADMITS unknown keys (a misspelled field passes), the
# closed-set guarantee would be hollow without this bespoke pass. Mirrors the persona-spec pattern
# continuity-bible cites for its own closed enums.
_KNOWN_KEYS = {"schema", "id", "category", "subject", "attribute", "value",
               "polarity", "cost", "pair_subject", "loci"}

# Per-category KEYED requirements — the flat schema marks the arm-specific fields (polarity, cost,
# pair_subject) optional for every category, so a fact whose category NEEDS one of them could omit it,
# pass W1 + --strict, and be SILENTLY SKIPPED by the very arm that gives the category meaning (a `rule`
# with no `polarity` is skipped by WB-R1; a `distance` with no `pair_subject` by WB-G1; a `cost` with
# no `cost` by WB-C1/C2). That makes an arm "look clean" over a malformed bible. These checks close the
# hole so the keyed field is enforced at W1, mirroring the closed-key + cost-type bespoke passes. Each
# arm was read to derive its category->field pair (review finding P1, round 9):
#   rule     -> polarity     (WB-R1 pairs can/cannot/requires; a rule must state its closed-set claim,
#                             which MAY be the explicit opt-out "n/a" — but it must be stated, not absent)
#   cost     -> cost         (WB-C1/C2 read the `cost` field; a cost fact with no cost field is inert)
#   distance -> pair_subject (WB-G1 keys on the unordered {subject, pair_subject} edge)
#   event    -> pair_subject OR a Day/Week anchor (WB-G2 has two arms: the happens-before CYCLE check
#                             needs pair_subject; the anchor-drift check needs a parseable Day/Week in
#                             value/attribute. An event with NEITHER feeds no arm and is skipped, so it
#                             must carry at least one.)
# place / faction / entity have NO contradiction arm (descriptive-only), so they impose no keyed field.

# Override markers route through the shared `override_marker` SSoT (code spans stripped, slug
# boundary-matched). Two forms: a pair-scoped `<!-- override: world-rule WF-01/WF-02 — … -->` (order-
# insensitive; both "WF-01/WF-02" and "WF-02/WF-01" silence the same conflict), parsed via the pair
# target below; and the pair-free `<!-- override: world-firewall -->` (silences the prose scan, not a
# fact pair), checked with the presence form of `override_targets`.
_WF_PAIR_TARGET = r"(WF-[0-9]+)\s*/\s*(WF-[0-9]+)"
# WF ids referenced inside a Contradiction-Ledger row (the State-axis table parse; mirrors
# continuity_bible._CF_REF_RE).
_WF_REF_RE = re.compile(r"\bWF-[0-9]+\b")

# WF firewall — resolution / invention verbs that would mean the bible's prose RESOLVED a conflict or
# INVENTED canon instead of surfacing it. Kept specific so the advisory rarely misfires; an intended
# phrasing is silenced by the world-firewall override. The tool surfaces; it never adjudicates.
_FIREWALL_RE = re.compile(
    r"\bthe\s+(?:true|correct|real|canonical|actual)\s+(?:rule|cost|price|distance|order|value|answer)\s+is\b"
    r"|\bthe\s+(?:rule|cost|price|distance|order)\s+should\s+(?:be|read)\b"
    r"|\bshould\s+(?:be|read)\s+\d"
    r"|\b(?:resolve[ds]?|reconciled?)\s+(?:to|as)\b"
    r"|\bthe\s+canon(?:ical)?\s+(?:value|version|reading)\s+is\b"
    r"|\bwe\s+(?:pick|choose|adopt|keep)\s+(?:the|WF-)",
    re.IGNORECASE)
_HTML_COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)

# WB-G1 distance normalization — TWO disjoint, commensurable unit classes. A stated travel-TIME and a
# stated spatial DISTANCE are SEPARATE axes that never collide-check against each other (a 6-day ride
# and 120 miles can both be true for one edge), per the spec's own "distinct units that cannot be
# normalized to a common base are not compared" conservatism (review finding P3 #4).
#   spatial  -> normalized to MILES (mile, league=3 mi, km≈0.621371 mi, furlong=0.125 mi)
#   temporal -> normalized to HOURS (day=24, hour, week=168) — travel-time, NOT a chronology anchor
_SPATIAL_UNITS = {
    "mile": 1.0, "mi": 1.0,
    "league": 3.0,
    "km": 0.621371, "kilometer": 0.621371, "kilometre": 0.621371,
    "furlong": 0.125,
}
_TEMPORAL_UNITS = {
    "hour": 1.0, "hr": 1.0,
    "day": 24.0,
    "week": 168.0,
}
# The digit run admits comma thousands-separators (an ordinary way authors write worldbuilding
# numbers: "1,000 miles", "Day 2,000"). The grouped alternative requires AT LEAST ONE ",ddd" group
# (`+`, not `*`), so a comma-free number can ONLY match the plain `\d+` branch — without that, the
# leftmost-FIRST alternation would let the grouped branch match a prefix of an ungrouped run ("1000"
# -> "100") and stop. A grouped numeral is now captured WHOLE rather than truncated to its trailing
# run — `.search()` on the old `\d+` started at the first non-comma digit and dropped the leading
# thousands ("1,000" -> "000" -> 0.0), a false-FAIL on a self-consistent bible and a false-NEGATIVE
# on a real conflict. Commas are stripped via _strip_grouping before float()/int().
_NUM_UNIT_RE = re.compile(r"(-?\d{1,3}(?:,\d{3})+(?:\.\d+)?|-?\d+(?:\.\d+)?)\s*([A-Za-z]+)")
# Day/Week anchor digit run — same comma-grouping tolerance + same grouped-requires-a-comma guard.
_ANCHOR_NUM = r"(\d{1,3}(?:,\d{3})+|\d+)"
_ANCHOR_DAY_RE = re.compile(r"\bDay\s+" + _ANCHOR_NUM, re.IGNORECASE)
_ANCHOR_WEEK_RE = re.compile(r"\bWeek\s+" + _ANCHOR_NUM, re.IGNORECASE)


def _strip_grouping(num):
    """Drop comma thousands-separators from a captured numeral so float()/int() see the real
    magnitude ('1,000' -> '1000'). Comma is the only grouping char authors use here; '.' is the
    decimal point and is preserved."""
    return num.replace(",", "")


def _norm_distance(s):
    """('axis', magnitude) for a stated edge length, or None if not parseable on a known axis.
    axis='spatial' (-> miles) or 'temporal' (-> hours). The two axes are commensurable ONLY within
    themselves; WB-G1 never compares across axes. Reuses the timeline_checks._norm_duration parse
    idiom (number + unit, unit lower-cased + de-pluralized)."""
    if s is None:
        return None
    m = _NUM_UNIT_RE.search(s)
    if not m:
        return None
    value = float(_strip_grouping(m.group(1)))
    unit = m.group(2).lower().rstrip("s")
    if unit in _SPATIAL_UNITS:
        return ("spatial", value * _SPATIAL_UNITS[unit])
    if unit in _TEMPORAL_UNITS:
        return ("temporal", value * _TEMPORAL_UNITS[unit])
    return None


def _norm_anchor_day(*texts):
    """Best-effort absolute day number from any of the given anchor texts. Recognizes 'Day N' and
    'Week M' (-> day (M-1)*7+1). Returns int or None. Mirrors timeline_checks._norm_anchor_day so the
    chronology arm uses the same anchor idiom as the Pass-10 Timeline validator."""
    for t in texts:
        if not t:
            continue
        m = _ANCHOR_DAY_RE.search(t)
        if m:
            return int(_strip_grouping(m.group(1)))
    for t in texts:
        if not t:
            continue
        m = _ANCHOR_WEEK_RE.search(t)
        if m:
            return (int(_strip_grouping(m.group(1))) - 1) * 7 + 1
    return None


def _required_field_errs(obj, where):
    """Per-category KEYED-requirement check (see _KNOWN_KEYS comment). Returns W1 error strings for a
    fact whose category needs an arm-specific field but omits it — so a malformed fact can never make a
    contradiction arm 'look clean' by being silently skipped. `obj` is a validated dict (called after
    the schema pass, so `category` is a known enum). A present-but-empty string counts as omitted for
    the edge endpoints (an empty subject/pair_subject feeds no arm; the arms already `continue` on it),
    matching the arms' own truthiness guards."""
    if not isinstance(obj, dict):
        return []
    cat = obj.get("category")
    errs = []
    if cat == "rule":
        # WB-R1 pairs on polarity; an absent polarity is silently skipped. "n/a" is the explicit
        # opt-out (a rule that makes no closed-set claim) — allowed, but it must be STATED.
        if "polarity" not in obj:
            errs.append("%s: category=rule requires `polarity` (can | cannot | requires | n/a) — "
                        "WB-R1 silently skips a rule with no polarity" % where)
    elif cat == "cost":
        # WB-C1/C2 read the `cost` field; a cost fact with no `cost` key feeds no cost arm.
        if "cost" not in obj:
            errs.append("%s: category=cost requires the `cost` field (a string price, \"none\", or "
                        "null) — WB-C1/C2 skip a cost fact with no cost" % where)
    elif cat == "distance":
        # WB-G1 keys on the {subject, pair_subject} edge; without pair_subject there is no edge.
        if not (obj.get("pair_subject") or "").strip():
            errs.append("%s: category=distance requires a non-empty `pair_subject` (the other edge "
                        "endpoint) — WB-G1 skips a distance with no pair_subject" % where)
    elif cat == "event":
        # WB-G2 is two arms: cycle (needs pair_subject) OR anchor-drift (needs a parseable Day/Week
        # anchor in value/attribute). An event with NEITHER feeds no arm and is silently skipped.
        has_edge = bool((obj.get("pair_subject") or "").strip())
        has_anchor = _norm_anchor_day(obj.get("value"), obj.get("attribute")) is not None
        if not has_edge and not has_anchor:
            errs.append("%s: category=event requires either a non-empty `pair_subject` (a "
                        "happens-before edge) or a parseable Day/Week anchor in value/attribute (a "
                        "chronology anchor) — WB-G2 skips an event with neither" % where)
    return errs


def _norm_value(s):
    """Normalize a rule value for closed-set collision: case-folded, leading-article-stripped,
    whitespace-collapsed. So 'raise the dead' and 'Raise  the Dead' collide; 'fly' and 'flying' do
    NOT (literal, not semantic — the conservative-firing discipline)."""
    s = (s or "").strip().lower()
    s = re.sub(r"^(?:the|a|an)\s+", "", s)
    s = re.sub(r"\s+", " ", s)
    return s


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


def _overrides(text, slug):
    """Set of frozenset({id, id}) unordered pairs overridden for the given slug (world-rule /
    world-cost / world-geo). Via the shared SSoT, so a pair quoted inside a code span is not honored."""
    return {frozenset(pair) for pair in override_targets(text, slug, _WF_PAIR_TARGET)}


def parse_facts(text):
    """[(obj_or_None, schema_errs, index), ...] for each apodictic:world_fact block. Schema errors
    include the bespoke closed-key check (an unknown key fails W1), since the subset engine admits
    unknown keys silently."""
    facts = []
    if not text or art is None:
        return facts
    schema = art.load_schema(_SCHEMA_ID)
    idx = 0
    for btype, obj, jerr in art.parse_blocks(text):
        if btype != "world_fact":
            continue
        idx += 1
        where = "world_fact #%d" % idx
        if jerr:
            facts.append((None, ["%s: invalid JSON — %s" % (where, jerr)], idx))
            continue
        errs = art.validate_obj(obj, schema, where)
        # Closed-key pass — the subset engine admits unknown keys, so a misspelled field
        # ("polairty", "subjct") would pass schema validation. Catch it here or the closed-set
        # guarantee is hollow (spec §Closed-key checking is bespoke).
        if isinstance(obj, dict):
            for k in sorted(obj):
                if k not in _KNOWN_KEYS:
                    errs.append("%s: unknown field %r (closed key set: %s)"
                                % (where, k, ", ".join(sorted(_KNOWN_KEYS))))
            # Bespoke `cost` value-type guard — same philosophy as the closed-key pass. The schema
            # leaves `cost` un-typed (the subset engine cannot express string|null, per its own
            # $comment), so a numeric/list/bool `cost` would pass W1 + closed-key and crash the
            # cost arm's _norm_value(c) -> (c or "").strip(). Catch it here so the malformed bible
            # gets a clean W1 ERROR, matching the spec's "string fields are type-checked so the
            # arms can safely parse them" guarantee (the one un-schema'd field that needed it).
            if "cost" in obj and obj["cost"] is not None and not isinstance(obj["cost"], str):
                errs.append("%s: `cost` must be a string or null, got %s"
                            % (where, type(obj["cost"]).__name__))
            # Per-category KEYED requirement — a fact whose category needs an arm-specific field but
            # omits it must FAIL W1, not pass --strict and get silently skipped by its arm (P1, round 9).
            errs.extend(_required_field_errs(obj, where))
        facts.append((obj, errs, idx))
    return facts


# ------------------------------------------------------- the contradiction arms

def _rule_contradictions(valid, overrides):
    """WB-R1: same subject + same normalized value, opposite polarity (can vs cannot, or requires vs
    cannot). Returns a list of error strings."""
    errs = []
    # group rule facts by (subject, normalized value) -> {polarity: [ids]}
    groups = {}
    for obj in valid:
        if obj.get("category") != "rule":
            continue
        pol = (obj.get("polarity") or "").strip().lower()
        if pol not in ("can", "cannot", "requires"):
            continue  # n/a or unset: not a closed-set assertion to pair
        key = (_norm_value(obj.get("subject")), _norm_value(obj.get("value")))
        groups.setdefault(key, {}).setdefault(pol, []).append(obj.get("id"))
    for (subj, val), bypol in sorted(groups.items()):
        # contradiction: a `cannot` paired with a `can` OR a `requires` on the same value
        if "cannot" not in bypol:
            continue
        opposed = bypol.get("can", []) + bypol.get("requires", [])
        for neg_id in sorted(bypol["cannot"]):
            for pos_id in sorted(opposed):
                if frozenset((neg_id, pos_id)) in overrides:
                    continue
                errs.append("WB-R1 rule: %s and %s assert opposite polarity for the same rule "
                            "(subject=%r, value=%r) — cannot both hold; surface or override"
                            % (neg_id, pos_id, subj, val))
    return errs


def _cost_contradictions(valid, overrides, strict):
    """WB-C1 (ERROR: two different real costs for one subject) + WB-C2 (advisory: free-then-costed).
    Returns (errors, warns)."""
    errs, warns = [], []
    # group cost-bearing facts (category cost, or rule carrying a cost) by subject
    by_subject = {}
    for obj in valid:
        if obj.get("category") not in ("cost", "rule"):
            continue
        if "cost" not in obj:
            continue  # no cost field => nothing for the cost arm to read
        by_subject.setdefault(_norm_value(obj.get("subject")), []).append(obj)
    for subj, objs in sorted(by_subject.items()):
        real, free = [], []  # (id, normalized-cost)
        for o in objs:
            c = o.get("cost")
            if c is None or _norm_value(c) in ("none", "no cost", "free", "nothing"):
                free.append(o.get("id"))
            else:
                real.append((o.get("id"), _norm_value(c)))
        # WB-C1 — two DIFFERENT real cost values for the same subject. Group ids by normalized cost
        # value, then pair ACROSS distinct value-groups (same-value pairs agree and are skipped). One
        # error per colliding pair so each pair is overridable independently.
        by_cost = {}
        for cid, cval in real:
            by_cost.setdefault(cval, []).append(cid)
        groups = sorted(by_cost.items())
        for gi in range(len(groups)):
            for gj in range(gi + 1, len(groups)):
                for a in sorted(groups[gi][1]):
                    for b in sorted(groups[gj][1]):
                        if frozenset((a, b)) in overrides:
                            continue
                        lo, hi = sorted((a, b))
                        errs.append("WB-C1 cost: %s and %s assign different stated costs to subject=%r "
                                    "(the bible prices the same power two ways) — reconcile, stage the "
                                    "escalation, or override" % (lo, hi, subj))
        # WB-C2 — same subject free in one fact, costed in another (advisory)
        if free and real:
            for fid in sorted(free):
                for cid, _cval in real:
                    if frozenset((fid, cid)) in overrides:
                        continue
                    warns.append("WB-C2 free-vs-costed: subject=%r is stated free in %s but costed "
                                 "in %s — a power the bible sometimes prices and sometimes gives away"
                                 % (subj, fid, cid))
    return errs, warns


# WB-G1 distances within 1% of each other are treated as AGREEING — a unit conversion (km->miles,
# leagues->miles) rarely lands on exact equality, and no author means a sub-1% difference as a
# contradiction. The conservative-firing discipline: float noise must never become a false ERROR.
_DIST_REL_TOL = 0.01


def _dist_close(x, y):
    if x == y:
        return True
    return abs(x - y) <= _DIST_REL_TOL * max(abs(x), abs(y))


def _distance_contradictions(valid, overrides):
    """WB-G1: same unordered {subject, pair_subject} edge, two different parsed distances WITHIN one
    commensurable unit class (spatial OR temporal — never across axes). Distances within 1% are
    treated as agreeing (unit-conversion noise is not a contradiction). Returns error strings."""
    errs = []
    # edge -> axis -> [(id, magnitude), ...]
    edges = {}
    for obj in valid:
        if obj.get("category") != "distance":
            continue
        a = _norm_value(obj.get("subject"))
        b = _norm_value(obj.get("pair_subject"))
        if not a or not b:
            continue  # an edge needs both endpoints
        parsed = _norm_distance(obj.get("value"))
        if parsed is None:
            continue  # unparseable / unknown unit -> exempt (left to author)
        axis, mag = parsed
        edge = frozenset((a, b))
        edges.setdefault(edge, {}).setdefault(axis, []).append((obj.get("id"), mag))
    for edge, by_axis in sorted(edges.items(), key=lambda kv: sorted(kv[0])):
        for axis, items in sorted(by_axis.items()):
            items = sorted(items)  # by id
            for i in range(len(items)):
                for j in range(i + 1, len(items)):
                    (a_id, a_mag), (b_id, b_mag) = items[i], items[j]
                    if _dist_close(a_mag, b_mag):
                        continue  # agree (within tolerance) — not a collision
                    if frozenset((a_id, b_id)) in overrides:
                        continue
                    pair = "/".join(sorted(edge))
                    errs.append("WB-G1 distance: %s and %s assign different %s distances to the same "
                                "edge {%s} — one edge, two lengths; reconcile or override"
                                % (a_id, b_id, axis, pair))
    return errs


def _mag_of(fid, by_mag):
    """For WB-G2 anchor-drift: the day-anchor a fact id was bucketed under (by_day maps day -> [ids])."""
    for mag, ids in by_mag.items():
        if fid in ids:
            return mag
    return None


def _chronology_contradictions(valid, overrides):
    """WB-G2: (a) a CYCLE in the directed happens-before graph (subject -> pair_subject), or (b) the
    same event stated at two different absolute Day anchors. Returns error strings."""
    errs = []
    # (b) anchor drift: event subject -> set of distinct day anchors
    by_event = {}
    for obj in valid:
        if obj.get("category") != "event":
            continue
        day = _norm_anchor_day(obj.get("value"), obj.get("attribute"))
        if day is None:
            continue
        subj = _norm_value(obj.get("subject"))
        by_event.setdefault(subj, {}).setdefault(day, []).append(obj.get("id"))
    for subj, by_day in sorted(by_event.items()):
        if len(by_day) < 2:
            continue
        ids = sorted(i for dids in by_day.values() for i in dids)
        # a single override on any pair on this event silences the anchor drift for that pair
        reported = False
        for i in range(len(ids)):
            for j in range(i + 1, len(ids)):
                if frozenset((ids[i], ids[j])) in overrides:
                    continue
                if _mag_of(ids[i], by_day) == _mag_of(ids[j], by_day):
                    continue
                if not reported:
                    days = "/".join("Day %s" % d for d in sorted(by_day))
                    errs.append("WB-G2 chronology: event subject=%r is anchored to two different days "
                                "(%s) — %s; surface or override" % (subj, days, ", ".join(ids)))
                    reported = True

    # (a) cycle in the happens-before edges. Build subject -> {pair_subject} and DFS for a back-edge.
    graph = {}
    edge_ids = {}  # (u, v) -> id, for naming a pair on the cycle in the override hint
    for obj in valid:
        if obj.get("category") != "event":
            continue
        u = _norm_value(obj.get("subject"))
        v = _norm_value(obj.get("pair_subject"))
        if not u or not v:
            continue  # an ordering edge needs both endpoints
        graph.setdefault(u, set()).add(v)
        graph.setdefault(v, set())
        edge_ids[(u, v)] = obj.get("id")
    cycle = _find_cycle(graph)
    if cycle:
        # name the participating fact ids (the edges that make up the cycle). Honor an override if ANY
        # override pair names two ids that both lie on the cycle — the author marks the loop as
        # intentional by citing any two of its edges (order-insensitive), not a specific adjacency.
        cyc_ids = []
        for k in range(len(cycle) - 1):
            fid = edge_ids.get((cycle[k], cycle[k + 1]))
            if fid and fid not in cyc_ids:
                cyc_ids.append(fid)
        cyc_set = set(cyc_ids)
        ov = any(pair <= cyc_set for pair in overrides)
        if not ov:
            errs.append("WB-G2 chronology: the happens-before edges form a cycle (%s) — an "
                        "unresolvable ordering; %s; break the cycle or override"
                        % (" -> ".join(cycle), ", ".join(cyc_ids) or "no addressable ids"))
    return errs


def _find_cycle(graph):
    """Return one cycle as a node list [a, b, ..., a] via stdlib DFS, or None. Deterministic (sorted
    node + neighbor order) so the reported cycle is stable across runs."""
    WHITE, GRAY, BLACK = 0, 1, 2
    color = {n: WHITE for n in graph}
    parent = {}

    def visit(u):
        color[u] = GRAY
        for w in sorted(graph.get(u, ())):
            if color.get(w, WHITE) == WHITE:
                parent[w] = u
                got = visit(w)
                if got:
                    return got
            elif color.get(w) == GRAY:
                # back-edge u->w: reconstruct w ... u, then close with w
                path = [u]
                x = u
                while x != w and x in parent:
                    x = parent[x]
                    path.append(x)
                path.reverse()
                path.append(w)
                return path
        color[u] = BLACK
        return None

    for n in sorted(graph):
        if color[n] == WHITE:
            got = visit(n)
            if got:
                return got
    return None


def _firewall_scan(text, strict):
    """WF: the bible's reader-facing prose must SURFACE, never resolve/invent. Scan the visible prose
    (HTML comments — incl. the world_fact blocks, which ARE comments — stripped first) for a
    resolution/invention verb. Advisory; ERROR under --strict; world-firewall override silences."""
    if override_targets(text or "", "world-firewall"):
        return []
    visible = _HTML_COMMENT_RE.sub("", text or "")
    hits = []
    for ln in visible.split("\n"):
        if _FIREWALL_RE.search(ln):
            hits.append(ln.strip())
    if not hits:
        return []
    return ["WF firewall: the bible prose reads as RESOLVING a conflict or INVENTING canon "
            "(%r) — the tool surfaces both values and leaves the choice to the author; describe "
            "the conflict, do not pick a winner (or override world-firewall)" % hits[0][:80]]


# ------------------------------------------------------- the runner

def bible(text, strict=False):
    """Run the Worldbuilding Bible integrity + contradiction checks. Returns (code, lines)."""
    lines, errs, warns = [], [], []
    facts = parse_facts(text)
    if not facts:
        return 0, ["world-bible: no world_fact blocks found — nothing to check"]

    # W1 — schema / JSON validity / closed-key (per block)
    for _obj, schema_errs, _idx in facts:
        for e in schema_errs:
            errs.append("W1 schema: %s" % e)

    valid_pairs = [(obj, idx) for obj, schema_errs, idx in facts
                   if obj is not None and not schema_errs]
    valid = [obj for obj, _idx in valid_pairs]

    # WD — duplicate id (W-class id-uniqueness)
    seen = {}
    for obj in valid:
        seen.setdefault(obj.get("id"), 0)
        seen[obj.get("id")] += 1
    for wid, n in sorted(seen.items()):
        if n > 1:
            errs.append("WD duplicate id: %s appears %d times (ids must be unique)" % (wid, n))

    rule_ov = _overrides(text, "world-rule")
    cost_ov = _overrides(text, "world-cost")
    geo_ov = _overrides(text, "world-geo")

    # The three contradiction arms (conservative, literal-collision only)
    errs.extend(_rule_contradictions(valid, rule_ov))
    c_errs, c_warns = _cost_contradictions(valid, cost_ov, strict)
    errs.extend(c_errs)
    warns.extend(c_warns)
    errs.extend(_distance_contradictions(valid, geo_ov))
    errs.extend(_chronology_contradictions(valid, geo_ov))

    # X1 — contradiction State axis (§D4 + firewall) on the `## Contradiction Ledger` table. A ledger
    # row is a live collision, so its derived State is `conflicting` unless one of the three pair
    # override slugs (world-rule / world-cost / world-geo) marks the pair intentional (=> `apparent`).
    # The author-written `State` column must be a valid enum token AND match the derivation; the
    # register carries no severity token / no finding block. Mechanical, no model judgment — the shared
    # contradiction_state helper owns the truth table + parse + regex.
    all_pair_overrides = rule_ov | cost_ov | geo_ov

    def _derived_state(ids):
        idset = set(ids)
        overridden = any(pair <= idset for pair in all_pair_overrides)
        return cstate.derive_state(True, overridden)  # a written ledger row is always a collision

    # X1(b) — ledger-row referential integrity (R1(b″), the register-neutral leg). Over EVERY ledger
    # data row (ALL rows, pre-axis included — the check does not depend on the State column). Two legs,
    # both a pure field recompute from the PARSED world_fact blocks — the same integrity class as
    # continuity_bible C3 (no arm restructuring, no semantic judgment):
    #   1. the row must pair >=2 DISTINCT WF ids, all resolving to real, well-formed world_fact blocks;
    #   2. the cited facts must not all record the SAME normalized declaration. A world's collision
    #      universe is ARM-DEFINED, not subject-defined: WB-G1's reversed-distance edge (A->B "6 days"
    #      vs B->A "2 days" — the edge is an unordered frozenset, so the two facts have DIFFERENT
    #      subjects yet collide) and WB-G2's chronology cycle (A->B, B->C, C->A — three DIFFERENT
    #      subjects that collectively form the loop) are both legitimate cross-subject collisions. So
    #      subject-sameness is NOT a precondition for a world contradiction — it was wrongly imported
    #      from continuity's schema in R1(b′), and the same-subject leg is DROPPED. The ONLY relation
    #      that mechanically precludes a collision is IDENTITY: two facts whose normalized
    #      (subject, value, polarity, cost) tuples are IDENTICAL declare the same thing and cannot be
    #      "in tension" (§D4: a no-collision pair is `consistent`, and a written consistent row is
    #      already an error class). Every NON-identical pairing is the author's DECLARED tension — an
    #      arm may or may not see it, but the normative register never adjudicates it. The declaration
    #      compared spans the closed-set fields the arms read — `subject`/`value` (WB-G1/G2
    #      distance/anchor/edge + the rule predicate), `polarity` (WB-R1's can/cannot/requires), and
    #      `cost` (WB-C1/C2's price) — each under the SAME _norm_value normalization. A row whose facts
    #      all collapse to one tuple carries no tension (Codex P1 repro: two `place` facts with the same
    #      subject+value, no polarity, no cost).
    # A fabricated id (WF-88 for a fact that does not exist), a single-id row, or a row of identical
    # declarations would otherwise pass to the conflicting-rollup and cite a phantom contradiction into
    # the editorial letter (Codex P1: two identical `place` facts labeled `conflicting` passed clean and
    # rolled up). This is the id-existence + non-identity class ONLY — it makes NO semantic/polarity
    # judgment (we never ask whether the facts OPPOSE, only whether they are IDENTICAL; a declared
    # cross-subject or same-subject tension the literal arms can't see is the author's call, per the
    # normative register). ERRORs here fail the file BEFORE the rollup runs (the rollup only runs on an
    # empty errs list), so a bad row never reaches the letter.
    valid_ids = {obj.get("id") for obj in valid}
    by_id = {obj.get("id"): obj for obj in valid}
    for ids, _token, _cells in cstate.state_rows(text, _WF_REF_RE):
        uniq = sorted(set(ids))
        tag = "+".join(uniq) if uniq else "<no ids>"
        if len(uniq) < 2:
            errs.append("X1 ledger integrity: Contradiction-Ledger row [%s] references fewer than 2 "
                        "distinct world_fact ids (a contradiction pairs at least two stated facts)" % tag)
            continue
        missing = [i for i in uniq if i not in valid_ids]
        if missing:
            errs.append("X1 ledger integrity: Contradiction-Ledger row [%s] references id(s) %s that "
                        "resolve to no well-formed world_fact block (a ledger row must cite real, "
                        "parsed facts)" % (tag, ", ".join(missing)))
            continue
        # Leg 2 — identity check. C3-parity field recompute over the resolved facts, using the same
        # _norm_value normalization the world arms use on each closed-set field. Identity is the only
        # relation that precludes a collision; every non-identical pairing is a declared tension.
        objs = [by_id[i] for i in uniq]

        def _declaration(o):
            # The normalized recorded declaration — the closed-set fields the arms read. `cost` may be
            # null (its stated-free form); normalize it like a value so null/"none"/"None" agree.
            return (_norm_value(o.get("subject")),
                    _norm_value(o.get("value")),
                    _norm_value(o.get("polarity")),
                    _norm_value(o.get("cost")))
        if len({_declaration(o) for o in objs}) < 2:
            errs.append("X1 ledger integrity: Contradiction-Ledger row [%s] cites facts that record "
                        "IDENTICAL declarations (no collision — a consistent pair must not be written "
                        "as a ledger row; a contradiction pairs facts with DIFFERENT recorded "
                        "declarations)" % tag)

    # Loud absence (adopted P3) — a ledger with data rows but NO `State` column is a pre-axis form; the
    # column is additive and NOT required, so this is a WARN (rc stays 0), not an ERROR. Silence would
    # let a bible sit indefinitely without the fact-state axis; the WARN nudges the author to add it.
    _wf_rows = cstate.state_rows(text, _WF_REF_RE)
    if _wf_rows and not cstate.has_state_column(text):
        warns.append("Contradiction Ledger has %d row(s) but no State column — pre-axis form; add the "
                     "State column per the reference module" % len(_wf_rows))

    errs.extend(cstate.check_row_states(_wf_rows, _derived_state))
    if cstate.severity_leak(text):
        errs.append("X1 firewall: the Contradiction Ledger carries an editorial Must/Should/Could-Fix "
                    "token — a contradiction is a fact-state, not a defect (severity is orthogonal; "
                    "see docs/worldbuilding-bible.md §State axis)")
    if _has_block(text, "finding"):
        errs.append("X1 firewall: the Contradiction Ledger contains an apodictic:finding block — the "
                    "bible records both stated values, it never carries a severity-bearing finding")

    # WF — firewall prose scan (advisory; ERROR under --strict)
    warns.extend(_firewall_scan(text, strict))

    # Report
    lines.append("world-bible: %d fact(s)%s" % (
        len(facts), "" if len(valid) == len(facts) else " (%d well-formed)" % len(valid)))
    for obj in valid:
        lines.append("  %-7s %-9s %s" % (obj.get("id"), obj.get("category"), obj.get("subject")))
    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("world-bible: FAIL (%d error(s)%s)"
                     % (len(errs), ", %d strict warn(s)" % len(warns) if (strict and warns) else ""))
        return 1, lines

    # Conflicting-state rollup — the `conflicting` ledger rows (id-pair) for the editorial letter to
    # cite in prose (Stage A wiring; the Legal-Risk / Content-Advisory / setup-payoff precedent), so an
    # unresolved self-contradiction reaches the author's revision plan. An `apparent` (overridden) row
    # is intentional and is NOT rolled up. These are not errors.
    conflicting = [ids for ids, token, _cells in cstate.state_rows(text, _WF_REF_RE)
                   if token == "conflicting"]
    if conflicting:
        lines.append("world-bible: %d conflicting contradiction(s) — cite in the editorial letter "
                     "(prose):" % len(conflicting))
        for ids in conflicting:
            lines.append("  CONFLICTING %s" % ("+".join(ids) if ids else "<no ids>"))

    if warns:
        lines.append("WARN: world-bible: %d advisory signal(s) — see WB-C2/WF above" % len(warns))
    else:
        lines.append("world-bible: PASS (schema + closed-key + rule/cost/geo contradiction arms + "
                     "State axis + surface-don't-resolve firewall)")
    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], _BIBLE_GLOB)))
    for p in paths:
        if _has_block(_read(p) or "", "world_fact"):
            return p
    return paths[0] if paths else None


def run(paths, strict=False):
    path = resolve(paths)
    if not path:
        return 2, ["world-bible: no Worldbuilding Bible artifact found (need a "
                   "*_Worldbuilding_Bible_*.md or a file with apodictic:world_fact blocks)"]
    text = _read(path)
    if text is None:
        return 2, ["world-bible: cannot read %s" % path]
    return bible(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 fact(wid, category="rule", subject="blood-magic", attribute="limit",
             value="cannot raise the dead", loci=("Bible §Magic ¶3",), **extra):
        obj = {"schema": _SCHEMA_ID, "id": wid, "category": category, "subject": subject,
               "attribute": attribute, "value": value, "loci": list(loci)}
        for k, v in extra.items():
            obj[k] = v
        return "<!-- apodictic:world_fact\n%s\n-->" % _j.dumps(obj)

    # clean single rule fact (no pairing) — PASS
    chk("clean_single", bible(fact("WF-01", polarity="cannot"))[0] == 0)

    # W1 — bad enum / id / missing field / unquoted numeric / empty loci / JSON / closed-key
    chk("w1_bad_category", bible(fact("WF-01", category="thing"))[0] == 1)
    chk("w1_bad_polarity", bible(fact("WF-01", polarity="maybe"))[0] == 1)
    chk("w1_bad_id", bible(fact("WF-1"))[0] == 1)
    chk("w1_missing_field", bible(fact("WF-01").replace('"attribute"', '"attr"'))[0] == 1)
    code, lines = bible('<!-- apodictic:world_fact\n{"schema":"apodictic.world_fact.v1","id":"WF-01",'
                        '"category":"distance","subject":"Karth","attribute":"distance-to",'
                        '"value":120,"pair_subject":"the capital","loci":["Map note 2"]}\n-->')
    chk("w1_unquoted_numeric", code == 1 and any("W1 schema" in ln for ln in lines))
    chk("w1_empty_loci", bible(fact("WF-01", loci=[]))[0] == 1)
    code, lines = bible('<!-- apodictic:world_fact\n{"schema":"apodictic.world_fact.v1"\n-->')
    chk("w1_bad_json", code == 1 and any("W1 schema" in ln for ln in lines))
    # closed-key: a misspelled field the subset engine would ADMIT must be caught (the bespoke pass)
    code, lines = bible(fact("WF-01", polairty="cannot"))
    chk("w1_closed_key_misspell", code == 1 and any("unknown field" in ln for ln in lines))
    chk("w1_known_optional_ok", bible(fact("WF-01", polarity="cannot", cost=None))[0] == 0)
    # closed-key admits `cost` but the schema leaves it un-typed; a non-string/non-null `cost`
    # (numeric, list, bool) must be a clean W1 ERROR, not an uncaught AttributeError in the cost arm.
    code, lines = bible(fact("WF-01", category="cost", subject="bm", attribute="cost",
                             value="p", cost=5))
    chk("w1_cost_not_string_numeric",
        code == 1 and any("W1 schema" in ln and "`cost` must be a string or null" in ln for ln in lines))
    chk("w1_cost_not_string_list",
        bible(fact("WF-01", category="cost", subject="bm", attribute="cost",
                   value="p", cost=["a", "b"]))[0] == 1)
    chk("w1_cost_not_string_bool",
        bible(fact("WF-01", category="cost", subject="bm", attribute="cost",
                   value="p", cost=True))[0] == 1)

    # W1 per-category KEYED requirements (P1, round 9) — a fact whose category needs an arm-specific
    # field but omits it must FAIL W1 (and therefore --strict), not pass and get silently skipped by
    # its arm. Build the malformed facts WITHOUT going through fact() (whose helpers add nothing the
    # check forbids), spelling each category out so the smallest-input-that-breaks is explicit.
    def raw(d):
        return "<!-- apodictic:world_fact\n%s\n-->" % _j.dumps(
            dict({"schema": _SCHEMA_ID, "loci": ["Bible §1"]}, **d))
    # rule with NO polarity -> W1 ERROR (was: PASS, silently skipped by WB-R1)
    code, lines = bible(raw({"id": "WF-01", "category": "rule", "subject": "blood-magic",
                             "attribute": "limit", "value": "raise the dead"}), strict=True)
    chk("w1_rule_requires_polarity",
        code == 1 and any("category=rule requires `polarity`" in ln for ln in lines))
    # explicit polarity="n/a" is the stated opt-out -> allowed (present, just makes no claim)
    chk("w1_rule_polarity_na_ok",
        bible(raw({"id": "WF-01", "category": "rule", "subject": "blood-magic",
                   "attribute": "limit", "value": "x", "polarity": "n/a"}), strict=True)[0] == 0)
    # cost with NO cost field -> W1 ERROR (was: PASS, skipped by WB-C1/C2)
    code, lines = bible(raw({"id": "WF-01", "category": "cost", "subject": "blood-magic",
                             "attribute": "cost", "value": "the price"}), strict=True)
    chk("w1_cost_requires_cost",
        code == 1 and any("category=cost requires the `cost` field" in ln for ln in lines))
    # cost=null IS a stated (free) cost -> allowed (the WB-C2 free form)
    chk("w1_cost_null_ok",
        bible(raw({"id": "WF-01", "category": "cost", "subject": "bm", "attribute": "cost",
                   "value": "v", "cost": None}), strict=True)[0] == 0)
    # distance with NO pair_subject -> W1 ERROR (was: PASS, skipped by WB-G1)
    code, lines = bible(raw({"id": "WF-01", "category": "distance", "subject": "Karth",
                             "attribute": "distance-to", "value": "120 miles"}), strict=True)
    chk("w1_distance_requires_pair_subject",
        code == 1 and any("category=distance requires a non-empty `pair_subject`" in ln for ln in lines))
    # distance with EMPTY pair_subject -> still W1 ERROR (empty feeds no arm)
    chk("w1_distance_empty_pair_subject",
        bible(raw({"id": "WF-01", "category": "distance", "subject": "Karth",
                   "attribute": "distance-to", "value": "120 miles", "pair_subject": "  "}),
              strict=True)[0] == 1)
    # event with NEITHER pair_subject NOR a Day/Week anchor -> W1 ERROR (was: PASS, skipped by WB-G2)
    code, lines = bible(raw({"id": "WF-01", "category": "event", "subject": "the Sundering",
                             "attribute": "when", "value": "long ago"}), strict=True)
    chk("w1_event_requires_edge_or_anchor",
        code == 1 and any("category=event requires either" in ln for ln in lines))
    # event WITH a pair_subject (ordering edge) -> allowed
    chk("w1_event_edge_ok",
        bible(raw({"id": "WF-01", "category": "event", "subject": "X", "attribute": "happens-before",
                   "value": "before", "pair_subject": "Y"}), strict=True)[0] == 0)
    # event WITH a Day anchor (chronology anchor, no edge) -> allowed
    chk("w1_event_anchor_ok",
        bible(raw({"id": "WF-01", "category": "event", "subject": "X", "attribute": "Day 100",
                   "value": "Day 100"}), strict=True)[0] == 0)
    # categories with NO arm impose no keyed field (descriptive-only) -> allowed
    chk("w1_place_no_keyed_field",
        bible(raw({"id": "WF-01", "category": "place", "subject": "Karth",
                   "attribute": "desc", "value": "a port city"}), strict=True)[0] == 0)
    chk("w1_faction_no_keyed_field",
        bible(raw({"id": "WF-01", "category": "faction", "subject": "the Guild",
                   "attribute": "desc", "value": "merchants"}), strict=True)[0] == 0)
    chk("w1_entity_no_keyed_field",
        bible(raw({"id": "WF-01", "category": "entity", "subject": "the Sword",
                   "attribute": "desc", "value": "a relic"}), strict=True)[0] == 0)

    # WD — duplicate id
    code, lines = bible(fact("WF-01", polarity="can", value="fly")
                        + "\n" + fact("WF-01", polarity="cannot", value="swim"))
    chk("wd_duplicate_id", code == 1 and any("WD duplicate id" in ln for ln in lines))

    # WB-R1 — rule contradiction (can vs cannot on the same normalized value); override silences
    can = fact("WF-01", subject="blood-magic", value="raise the dead", polarity="can")
    cannot = fact("WF-02", subject="blood-magic", value="Raise  the  Dead", polarity="cannot")
    code, lines = bible(can + "\n" + cannot)
    chk("wb_r1_fires", code == 1 and any("WB-R1 rule" in ln for ln in lines))
    # requires vs cannot also contradicts
    req = fact("WF-01", subject="blood-magic", value="a blood sacrifice", polarity="requires")
    forb = fact("WF-02", subject="blood-magic", value="a blood sacrifice", polarity="cannot")
    chk("wb_r1_requires_cannot", bible(req + "\n" + forb)[0] == 1)
    # different values do NOT collide (literal, not semantic): "can fly" vs "cannot swim"
    chk("wb_r1_different_value_clean",
        bible(fact("WF-01", value="fly", polarity="can")
              + "\n" + fact("WF-02", value="swim", polarity="cannot"))[0] == 0)
    # same polarity twice is not a contradiction
    chk("wb_r1_same_polarity_clean",
        bible(fact("WF-01", value="fly", polarity="can")
              + "\n" + fact("WF-02", value="fly", polarity="can"))[0] == 0)
    ov = "<!-- override: world-rule WF-01/WF-02 — staged reveal: the limit changes after the Sundering -->\n"
    chk("wb_r1_override", bible(ov + can + "\n" + cannot)[0] == 0)
    chk("wb_r1_override_order_insensitive",
        bible("<!-- override: world-rule WF-02/WF-01 — reversed -->\n" + can + "\n" + cannot)[0] == 0)
    # code-span decoy (bypass closed by the SSoT migration): a pair override quoted inside a code span is
    # a documentation example, not a live directive — WB-R1 must still fire (non-zero exit).
    chk("wb_r1_inline_codespan_override_does_not_silence",
        bible("`" + ov.strip() + "`\n" + can + "\n" + cannot)[0] == 1)
    chk("wb_r1_fenced_codespan_override_does_not_silence",
        bible("```\n" + ov.strip() + "\n```\n" + can + "\n" + cannot)[0] == 1)

    # WB-C1 — two different real costs for one subject; override silences
    c_a = fact("WF-01", category="cost", subject="blood-magic", attribute="cost",
               value="the price of a casting", cost="one year of life")
    c_b = fact("WF-02", category="cost", subject="blood-magic", attribute="cost",
               value="the price of a casting", cost="a drop of blood")
    code, lines = bible(c_a + "\n" + c_b)
    chk("wb_c1_fires", code == 1 and any("WB-C1 cost" in ln for ln in lines))
    # same cost twice — agree, not a collision
    chk("wb_c1_same_cost_clean",
        bible(fact("WF-01", category="cost", subject="x", attribute="cost", value="p", cost="one year of life")
              + "\n" + fact("WF-02", category="cost", subject="x", attribute="cost", value="p", cost="One Year of Life"))[0] == 0)
    ov_c = "<!-- override: world-cost WF-01/WF-02 — documented escalation: novices pay more -->\n"
    chk("wb_c1_override", bible(ov_c + c_a + "\n" + c_b)[0] == 0)

    # WB-C2 — free-then-costed (advisory; ERROR under --strict; override)
    free = fact("WF-01", category="cost", subject="blood-magic", attribute="cost", value="free use", cost="none")
    costed = fact("WF-02", category="cost", subject="blood-magic", attribute="cost",
                  value="the price", cost="one year of life")
    code, lines = bible(free + "\n" + costed)
    chk("wb_c2_advisory", code == 0 and any("WB-C2 free-vs-costed" in ln for ln in lines))
    chk("wb_c2_strict_fails", bible(free + "\n" + costed, strict=True)[0] == 1)
    chk("wb_c2_null_cost_is_free",
        any("WB-C2" in ln for ln in bible(
            fact("WF-01", category="cost", subject="bm", attribute="cost", value="v", cost=None)
            + "\n" + fact("WF-02", category="cost", subject="bm", attribute="cost", value="v2", cost="a life"))[1]))
    ov_c2 = "<!-- override: world-cost WF-01/WF-02 — the free tier is the staged hook -->\n"
    chk("wb_c2_override", not any("WB-C2" in ln for ln in bible(ov_c2 + free + "\n" + costed)[1]))

    # WB-G1 — distance contradiction WITHIN an axis; cross-axis is EXEMPT (P3 #4)
    d6 = fact("WF-01", category="distance", subject="Karth", attribute="distance-to",
              value="6 days", pair_subject="the capital")
    d2 = fact("WF-02", category="distance", subject="Karth", attribute="distance-to",
              value="2 days", pair_subject="the capital")
    code, lines = bible(d6 + "\n" + d2)
    chk("wb_g1_temporal_fires", code == 1 and any("WB-G1 distance" in ln for ln in lines))
    # spatial collision: 120 miles vs 40 leagues(=120mi) AGREE; 120 miles vs 200 miles collide
    sp_a = fact("WF-01", category="distance", subject="Karth", attribute="distance-to",
                value="120 miles", pair_subject="the capital")
    sp_b = fact("WF-02", category="distance", subject="Karth", attribute="distance-to",
                value="40 leagues", pair_subject="the capital")
    chk("wb_g1_spatial_agree_clean", bible(sp_a + "\n" + sp_b)[0] == 0)
    sp_c = fact("WF-02", category="distance", subject="Karth", attribute="distance-to",
                value="200 miles", pair_subject="the capital")
    chk("wb_g1_spatial_collide", bible(sp_a + "\n" + sp_c)[0] == 1)
    # unit-conversion noise within 1% AGREES (160.934 km ≈ 100 miles); float drift is not an ERROR
    sp_km = fact("WF-02", category="distance", subject="Karth", attribute="distance-to",
                 value="160.934 km", pair_subject="the capital")
    sp_100 = fact("WF-01", category="distance", subject="Karth", attribute="distance-to",
                  value="100 miles", pair_subject="the capital")
    chk("wb_g1_conversion_tolerance_clean", bible(sp_100 + "\n" + sp_km)[0] == 0)
    # CROSS-AXIS exemption: "6 days" (temporal, WF-01) vs "120 miles" (spatial, WF-03) for one edge
    # => NOT compared (a travel-time and a mileage can both be true).
    sp_a3 = fact("WF-03", category="distance", subject="Karth", attribute="distance-to",
                 value="120 miles", pair_subject="the capital")
    chk("wb_g1_cross_axis_exempt", bible(d6 + "\n" + sp_a3)[0] == 0)
    # edge is unordered: A->B and B->A are the same edge
    d2_rev = fact("WF-02", category="distance", subject="the capital", attribute="distance-to",
                  value="2 days", pair_subject="Karth")
    chk("wb_g1_edge_unordered", bible(d6 + "\n" + d2_rev)[0] == 1)
    ov_g = "<!-- override: world-geo WF-01/WF-02 — the fast road halves the ride -->\n"
    chk("wb_g1_override", bible(ov_g + d6 + "\n" + d2)[0] == 0)
    # unparseable unit -> exempt, never a false ERROR
    chk("wb_g1_unparseable_exempt",
        bible(fact("WF-01", category="distance", subject="A", attribute="distance-to",
                   value="a fortnight's walk", pair_subject="B")
              + "\n" + fact("WF-02", category="distance", subject="A", attribute="distance-to",
                            value="some way off", pair_subject="B"))[0] == 0)
    # COMMA-GROUPED numerals (regression — pre-fix the search grabbed the trailing digit run, so
    # "1,000 miles" parsed to 0.0): same real length written two ways must be CLEAN, not a false
    # WB-G1 ERROR (the conservative-firing discipline; a comma-grouped number is parseable to a
    # human). Direct unit check:
    chk("comma_distance_parse_eq",
        _norm_distance("1,000 miles") == _norm_distance("1000 miles") == ("spatial", 1000.0))
    chk("comma_distance_grouped_full",
        _norm_distance("2,500 leagues") == ("spatial", 7500.0)
        and _norm_distance("1,234,567 miles") == ("spatial", 1234567.0))
    g_comma = fact("WF-01", category="distance", subject="Karth", attribute="distance-to",
                   value="1,000 miles", pair_subject="the capital")
    g_plain = fact("WF-02", category="distance", subject="Karth", attribute="distance-to",
                   value="1000 miles", pair_subject="the capital")
    chk("wb_g1_comma_grouped_agree_clean", bible(g_comma + "\n" + g_plain)[0] == 0)
    # ...and a GENUINE conflict between two grouped values must still FIRE (pre-fix both -> 0.0 and
    # the real contradiction was MISSED — a false negative):
    g_diff = fact("WF-02", category="distance", subject="Karth", attribute="distance-to",
                  value="2,000 miles", pair_subject="the capital")
    chk("wb_g1_comma_grouped_conflict_fires",
        bible(g_comma + "\n" + g_diff)[0] == 1)

    # WB-G2 — chronology cycle + anchor drift; override silences each
    e1 = fact("WF-01", category="event", subject="the Sundering", attribute="happens-before",
              value="before", pair_subject="the Founding")
    e2 = fact("WF-02", category="event", subject="the Founding", attribute="happens-before",
              value="before", pair_subject="the War")
    e3 = fact("WF-03", category="event", subject="the War", attribute="happens-before",
              value="before", pair_subject="the Sundering")
    code, lines = bible(e1 + "\n" + e2 + "\n" + e3)
    chk("wb_g2_cycle_fires", code == 1 and any("WB-G2 chronology" in ln and "cycle" in ln for ln in lines))
    # acyclic chain is clean
    chk("wb_g2_chain_clean", bible(e1 + "\n" + e2)[0] == 0)
    ov_cyc = "<!-- override: world-geo WF-01/WF-02 — intentional time-loop myth -->\n"
    chk("wb_g2_cycle_override", bible(ov_cyc + e1 + "\n" + e2 + "\n" + e3)[0] == 0)
    # anchor drift: same event at two Day anchors
    a1 = fact("WF-01", category="event", subject="the Sundering", attribute="Day 100", value="Day 100")
    a2 = fact("WF-02", category="event", subject="the Sundering", attribute="Day 200", value="Day 200")
    code, lines = bible(a1 + "\n" + a2)
    chk("wb_g2_anchor_drift_fires", code == 1 and any("WB-G2 chronology" in ln and "different days" in ln for ln in lines))
    ov_a = "<!-- override: world-geo WF-01/WF-02 — two calendars (old/new reckoning) -->\n"
    chk("wb_g2_anchor_override", bible(ov_a + a1 + "\n" + a2)[0] == 0)
    # COMMA-GROUPED Day/Week anchors (regression — pre-fix `\bDay\s+(\d+)` stopped at the comma so
    # "Day 1,000" mis-parsed to 1 and "Day 2,000" to 2, mis-bucketing/mis-reporting the anchor).
    # Direct unit check on the anchor parser:
    chk("comma_anchor_day_parse",
        _norm_anchor_day("Day 1,000") == 1000 and _norm_anchor_day("Day 2,000") == 2000)
    chk("comma_anchor_week_parse", _norm_anchor_day("Week 1,000") == (1000 - 1) * 7 + 1)
    # ...and end-to-end: two DIFFERENT grouped Day anchors for one event must still fire anchor-drift
    # (pre-fix they collapsed to Day 1 / Day 2 — still different, but the magnitudes were wrong; a
    # SAME grouped anchor written two ways must be CLEAN):
    ca1 = fact("WF-01", category="event", subject="the Sundering", attribute="Day 1,000", value="Day 1,000")
    ca2 = fact("WF-02", category="event", subject="the Sundering", attribute="Day 2,000", value="Day 2,000")
    chk("wb_g2_comma_anchor_drift_fires", bible(ca1 + "\n" + ca2)[0] == 1)
    ca1b = fact("WF-02", category="event", subject="the Sundering", attribute="Day 1000", value="Day 1000")
    chk("wb_g2_comma_anchor_same_clean", bible(ca1 + "\n" + ca1b)[0] == 0)

    # WF — firewall prose scan (advisory; ERROR --strict; override)
    resolve_prose = "## Notes\n\nThe true order is Day 100 for the Sundering; WF-02 is wrong.\n"
    code, lines = bible(a1 + "\n" + a2 + "\n" + ov_a + resolve_prose)
    chk("wf_firewall_advisory", any("WF firewall" in ln for ln in lines))
    chk("wf_firewall_strict_fails",
        bible(a1 + "\n" + a2 + "\n" + ov_a + resolve_prose, strict=True)[0] == 1)
    fw_ov = "<!-- override: world-firewall — quoting the author's own reconciliation note -->\n"
    chk("wf_firewall_override",
        not any("WF firewall" in ln for ln in bible(a1 + "\n" + a2 + "\n" + ov_a + fw_ov + resolve_prose)[1]))
    # code-span decoy (bypass closed by the SSoT migration): a firewall override quoted inside a code
    # span is a documentation example, not a live directive — WF must still fire.
    chk("wf_firewall_inline_codespan_override_does_not_silence",
        any("WF firewall" in ln for ln in bible(a1 + "\n" + a2 + "\n" + ov_a + "`" + fw_ov.strip() + "`\n" + resolve_prose)[1]))
    chk("wf_firewall_fenced_codespan_override_does_not_silence",
        any("WF firewall" in ln for ln in bible(a1 + "\n" + a2 + "\n" + ov_a + "```\n" + fw_ov.strip() + "\n```\n" + resolve_prose)[1]))
    # clean descriptive prose does not trip WF
    chk("wf_clean_prose_ok",
        not any("WF firewall" in ln for ln in bible(
            fact("WF-01", polarity="cannot") + "\n## Notes\n\nThe bible states one rule, cited.\n")[1]))

    # X1 — contradiction State axis (§D4 mechanical derivation + firewall) on the ledger table. The
    # world-bible ledger is Subject | Arm | Conflicting facts | State | Author's note.
    W_LEDGER = ("\n## Contradiction Ledger\n\n"
                "| Subject | Arm | Conflicting facts | State | Author's note |\n"
                "|---|---|---|---|---|\n")
    # An arm-firing pair (WB-R1) that IS overridden -> the arm is silenced AND the derived State is
    # `apparent`; an honest `apparent` State row is clean and is NOT rolled up as conflicting.
    ov_rule = "<!-- override: world-rule WF-01/WF-02 — staged reveal after the Sundering -->\n"
    code, lines = bible(ov_rule + can + "\n" + cannot + W_LEDGER
                        + "| blood-magic | rule (WB-R1) | WF-01, WF-02 | apparent | staged, overridden |\n")
    chk("x1_world_apparent_with_override_clean", code == 0)
    chk("x1_world_apparent_not_rolled_up", not any("CONFLICTING" in ln for ln in lines))
    # HOSTILE: the pair is overridden (apparent) but the row asserts `conflicting` -> X1 mismatch FAIL
    code, lines = bible(ov_rule + can + "\n" + cannot + W_LEDGER
                        + "| blood-magic | rule (WB-R1) | WF-01, WF-02 | conflicting | mislabel |\n")
    chk("x1_world_conflicting_but_overridden_fails",
        code == 1 and any("X1 State agreement" in ln for ln in lines))
    # A genuinely-conflicting row (NO override on the pair) — but then the arm itself FAILs (WB-R1),
    # so use a distance collision that IS overridden-as-apparent vs one that is NOT: instead, verify
    # the conflicting rollup on an un-overridden ledger row whose facts DON'T also trip an arm (a row
    # naming facts with no live arm collision is a stray, but the State parse still derives conflicting
    # and rolls it up). Use a benign descriptive pair (place facts, no arm) so only the ledger drives it.
    # The two facts share the SAME subject (Karth) but record DIFFERENT values ("a port" vs "a fortress")
    # — a genuine §D4 collision, so the R1(b′) same-subject + differing-value legs PASS and the row rolls
    # up. (WAS Codex's P1 repro: this fixture previously gave BOTH facts value="a port" — two identical
    # `place` facts labeled `conflicting` passed clean and rolled up a phantom contradiction into the
    # letter. The identical-value fixture encoded the bug; leg 3 now rejects it — see
    # x1_world_identical_value_row_fails below for that exact repro as a negative test.)
    pl1 = fact("WF-10", category="place", subject="Karth", attribute="desc", value="a port")
    pl2 = fact("WF-11", category="place", subject="Karth", attribute="desc", value="a fortress")
    code, lines = bible(pl1 + "\n" + pl2 + W_LEDGER
                        + "| Karth | place | WF-10, WF-11 | conflicting | surfaced |\n")
    chk("x1_world_conflicting_rollup",
        code == 0 and any("CONFLICTING WF-10+WF-11" in ln for ln in lines))
    # Codex P1 repro AS A NEGATIVE TEST — two IDENTICAL `place` facts (same subject, same value) labeled
    # `conflicting` must now FAIL leg 2 (identity) and must NOT roll up (identical declarations are not
    # "in tension"; a consistent pair must not be written as a ledger row). Fail-before-fix: pre-fold
    # this exact input returned code 0 AND emitted `CONFLICTING WF-10+WF-12`.
    pl_dup = fact("WF-12", category="place", subject="Karth", attribute="desc", value="a port")
    code, lines = bible(pl1 + "\n" + pl_dup + W_LEDGER
                        + "| Karth | place | WF-10, WF-12 | conflicting | identical facts (Codex repro) |\n")
    chk("x1_world_identical_value_row_fails",
        code == 1 and any("X1 ledger integrity" in ln and "IDENTICAL declarations" in ln for ln in lines))
    chk("x1_world_identical_value_row_not_rolled_up",
        not any("CONFLICTING" in ln for ln in lines))
    # R1(b″) — a cross-subject pair is now LEGAL (different subjects => different (subject, ...) tuples
    # => a declared tension; world's collision universe is ARM-defined, not subject-defined). These two
    # checks WAS the round-1 overcorrection: pre-R1(b″) they pinned cross-subject-FAILs (leg "different
    # subjects"); R1(b″) drops the same-subject leg, so the row PASSES and rolls up.
    pl_other = fact("WF-13", category="place", subject="Vhey", attribute="desc", value="a fortress")
    code, lines = bible(pl1 + "\n" + pl_other + W_LEDGER
                        + "| ? | place | WF-10, WF-13 | conflicting | cross-subject, legal |\n")
    chk("x1_world_cross_subject_row_passes",
        code == 0 and not any("different subjects" in ln for ln in lines))
    chk("x1_world_cross_subject_row_rolls_up",
        any("CONFLICTING WF-10+WF-13" in ln for ln in lines))
    # R1(b″) POSITIVE repros — the two world-geo collision shapes Codex named that the round-1
    # same-subject leg wrongly rejected. Both cite facts with DIFFERENT subjects but are legitimate
    # geo collisions; overridden so the arm derives `apparent` and the row is clean. Fail-before-fix:
    # pre-R1(b″) BOTH returned code 1 with "different subjects" (X1 ledger integrity).
    #   (a) reversed distance edge — WF-01 Karth->capital "6 days", WF-02 capital->Karth "2 days": the
    #       WB-G1 edge is an unordered frozenset, so the two facts collide despite different subjects.
    dd6 = fact("WF-01", category="distance", subject="Karth", attribute="distance-to",
               value="6 days", pair_subject="the capital")
    dd2rev = fact("WF-02", category="distance", subject="the capital", attribute="distance-to",
                  value="2 days", pair_subject="Karth")
    ovg = "<!-- override: world-geo WF-01/WF-02 — the fast road halves the ride -->\n"
    code, lines = bible(ovg + dd6 + "\n" + dd2rev + W_LEDGER
                        + "| Karth<->capital | geo (WB-G1) | WF-01, WF-02 | apparent | reversed edge, overridden |\n")
    chk("x1_world_reversed_distance_edge_passes",
        code == 0 and not any("X1 ledger integrity" in ln for ln in lines))
    #   (b) chronology cycle — WF-01 Sundering->Founding, WF-02 Founding->War, WF-03 War->Sundering:
    #       three DIFFERENT subjects forming a WB-G2 loop; overridden (any two cycle ids) => apparent.
    ce1 = fact("WF-01", category="event", subject="the Sundering", attribute="happens-before",
               value="before", pair_subject="the Founding")
    ce2 = fact("WF-02", category="event", subject="the Founding", attribute="happens-before",
               value="before", pair_subject="the War")
    ce3 = fact("WF-03", category="event", subject="the War", attribute="happens-before",
               value="before", pair_subject="the Sundering")
    ovcyc = "<!-- override: world-geo WF-01/WF-02 — intentional time-loop myth -->\n"
    code, lines = bible(ovcyc + ce1 + "\n" + ce2 + "\n" + ce3 + W_LEDGER
                        + "| cycle | geo (WB-G2) | WF-01, WF-02, WF-03 | apparent | time-loop, overridden |\n")
    chk("x1_world_chronology_cycle_passes",
        code == 0 and not any("X1 ledger integrity" in ln for ln in lines))
    # leg positive — same subject, DIFFERENT value, un-overridden -> PASS + rollup (already covered by
    # x1_world_conflicting_rollup above; asserted here explicitly for the leg-4 pairing).
    code, lines = bible(pl1 + "\n" + pl2 + W_LEDGER
                        + "| Karth | place | WF-10, WF-11 | conflicting | genuine collision |\n")
    chk("x1_world_same_subject_diff_value_rolls_up",
        code == 0 and any("CONFLICTING WF-10+WF-11" in ln for ln in lines))
    # X1(b) ledger-row referential integrity (R1(b)) — a row that cites FABRICATED ids (no such facts)
    # must FAIL, and must NOT reach the conflicting rollup (a phantom contradiction must never be cited
    # into the editorial letter). Fail-before-fix: pre-fold this row PASSed (code 0) and emitted a
    # CONFLICTING line for ids that resolve to nothing.
    code, lines = bible(pl1 + "\n" + pl2 + W_LEDGER
                        + "| Karth | place | WF-88, WF-89 | conflicting | fabricated ids |\n")
    chk("x1_world_fabricated_id_row_fails",
        code == 1 and any("X1 ledger integrity" in ln for ln in lines))
    chk("x1_world_fabricated_id_row_not_rolled_up",
        not any("CONFLICTING" in ln for ln in lines))
    # a SINGLE-id row (fewer than 2 distinct facts) must FAIL and must NOT roll up (a contradiction
    # pairs at least two stated facts).
    code, lines = bible(pl1 + "\n" + pl2 + W_LEDGER
                        + "| Karth | place | WF-10 | conflicting | single id |\n")
    chk("x1_world_single_id_row_fails",
        code == 1 and any("X1 ledger integrity" in ln and "fewer than 2" in ln for ln in lines))
    chk("x1_world_single_id_row_not_rolled_up",
        not any("CONFLICTING" in ln for ln in lines))
    # HOSTILE: that same un-overridden pair asserted `apparent` -> derives conflicting -> mismatch FAIL
    code, lines = bible(pl1 + "\n" + pl2 + W_LEDGER
                        + "| Karth | place | WF-10, WF-11 | apparent | claims staged |\n")
    chk("x1_world_author_asserted_apparent_no_override_fails",
        code == 1 and any("X1 State agreement" in ln for ln in lines))
    # HOSTILE: a `consistent` row is written -> FAIL (consistent needs no row)
    code, lines = bible(pl1 + "\n" + pl2 + W_LEDGER
                        + "| Karth | place | WF-10, WF-11 | consistent | stray |\n")
    chk("x1_world_consistent_row_fails",
        code == 1 and any("X1 State agreement" in ln and "needs no" in ln for ln in lines))
    # HOSTILE: a bad enum State token -> FAIL
    code, lines = bible(pl1 + "\n" + pl2 + W_LEDGER
                        + "| Karth | place | WF-10, WF-11 | resolved | bad enum |\n")
    chk("x1_world_bad_enum_fails",
        code == 1 and any("X1 State enum" in ln for ln in lines))
    # HOSTILE: a planted Must-Fix severity token -> X1 firewall FAIL
    code, lines = bible(pl1 + "\n" + pl2 + W_LEDGER
                        + "| Karth | place | WF-10, WF-11 | conflicting | x |\n"
                        + "\n## Notes\n\n- WF-11 is a Must-Fix.\n")
    chk("x1_world_severity_token_fails",
        code == 1 and any("X1 firewall" in ln and "Must/Should/Could-Fix" in ln for ln in lines))
    # HOSTILE: a planted apodictic:finding block -> X1 firewall FAIL
    _wfinding = ('<!-- apodictic:finding\n{"schema":"apodictic.finding.v1","id":"F-P5-01",'
                 '"mechanism":"m","severity":"Must-Fix","confidence":"HIGH","evidence_refs":["c"],'
                 '"fix_class":"x","risk_if_fixed":"y"}\n-->')
    code, lines = bible(pl1 + "\n" + pl2 + W_LEDGER
                        + "| Karth | place | WF-10, WF-11 | conflicting | x |\n" + _wfinding)
    chk("x1_world_finding_block_fails",
        code == 1 and any("X1 firewall" in ln and "apodictic:finding block" in ln for ln in lines))
    # a code-span-quoted override is a documentation example -> author-asserted `apparent` still FAILs
    code, lines = bible("`" + ov_rule.strip() + "`\n" + can + "\n" + cannot + W_LEDGER
                        + "| blood-magic | rule (WB-R1) | WF-01, WF-02 | apparent | quoted decoy |\n")
    # (the arm ALSO fires here because the override is a decoy; either way exit is non-zero and the
    #  State parse sees NO live override -> derives conflicting, so the apparent label mismatches too)
    chk("x1_world_codespan_override_does_not_silence",
        code == 1 and (any("X1 State agreement" in ln for ln in lines) or any("WB-R1" in ln for ln in lines)))
    # a pre-axis ledger (no State column) is still accepted (rc 0 — the column is additive) but now
    # WARNs loudly (adopted P3: silence would let a bible sit indefinitely without the fact-state axis).
    _pre_code, _pre_lines = bible(pl1 + "\n" + pl2 + "\n## Contradiction Ledger\n\n"
                                  "| Subject | Arm | Conflicting facts | Author's note |\n|---|---|---|---|\n"
                                  "| Karth | place | WF-10, WF-11 | surfaced |\n")
    chk("x1_world_preaxis_ledger_ok", _pre_code == 0)
    chk("x1_world_preaxis_ledger_warns_no_state",
        any("no State column" in ln for ln in _pre_lines))
    # ...and under --strict the additive-column nudge escalates to an ERROR (rc 1), like the other warns.
    chk("x1_world_preaxis_ledger_strict_fails",
        bible(pl1 + "\n" + pl2 + "\n## Contradiction Ledger\n\n"
              "| Subject | Arm | Conflicting facts | Author's note |\n|---|---|---|---|\n"
              "| Karth | place | WF-10, WF-11 | surfaced |\n", strict=True)[0] == 1)

    # no blocks -> no-op
    chk("no_facts_noop", bible("# Notes\nnothing structured\n")[0] == 0)

    # run-folder + explicit-file resolution
    import tempfile
    import shutil
    d = tempfile.mkdtemp()
    try:
        p = os.path.join(d, "Proj_Worldbuilding_Bible_run.md")
        with open(p, "w", encoding="utf-8", newline="") as fh:
            fh.write("# Worldbuilding Bible\n" + fact("WF-01", polarity="cannot") + "\n")
        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 != "world-bible"]
    strict = "--strict" in args
    paths = [a for a in args if not a.startswith("--")]
    if not paths:
        print("Usage: world_bible.py world-bible <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: d940f7a52382d1c3e7f0ad285a9e2109d034e423745f423a31ef88fd13c38a80