#!/usr/bin/env python3
"""verify-desc-claims.py — the machine reads its own hand.

The five pre-generator pieces carry <desc>s written by a hand claiming to be
the machine's (2026-08-07 11:14: the descs were authored by reading the
drawings' own <text> nodes back). This wall is the outside-the-box move the
handoff names: treat the desc itself as a drawing too — parse its numeric and
labeled claims and check them against the SVG's actual geometry and text
nodes. The wall that proved equality (page words == image words) now proves
truth (image words == image geometry).

The numbers come FROM the desc (number words parsed at runtime, not typed
here); the element counts come from the SVG. A hand that edits a desc gets
its NEW claims verified, not its old ones — the wall cannot be satisfied by
freezing the check against yesterday's sentence.

Relation claims (fading outward, dimmer than the last, dead vs alive) are
verified as monotonic orderings over the drawing's own attributes.

COVERAGE — the wall is never idle, and it reads at the CLAUSE level. After
the claim loop, every sentence of every desc is split into clauses on its own
punctuation (comma; colon followed by whitespace — so "17:00Z" never splits)
and every clause must be accounted for: a claim pattern's match must overlap
the clause's span (the claim loop above then proves it true of the drawing),
or the clause must be explicitly declared non-claim prose in NON_CLAIM.
Declarations are priced: a clause carrying the wall's geometry vocabulary — a
digit, a number word, or a '·' label separator — can never be declared; it
must be proven. A clause that matches no claim and has no declaration is a
FAIL, and the failure names the clause, not the sentence.

Clause coverage closes the hole sentence coverage left: a sentence like
"The left gauge is dim: needle pointing dead, reading 401, no timestamp,
labeled cached · no timestamp" makes five clauses, and the wall now needs
every one of them proven. A hand that rephrases ONE claim-clause of a
multi-claim sentence breaks the build instead of going green on the surviving
sibling — the claim patterns are still a hand-written layer, but the unit
they must cover is now the clause a human actually reads.

Usage:
  python3 verify-desc-claims.py          # check the five hand-desc'd pieces
  python3 verify-desc-claims.py --cards  # print full per-claim cards

Exit 1 on any failed claim or any unaccounted sentence. Wired into
verify-descs.py so the finish-script gate carries it without a new file in
the wake's tail.

Deterministic: same files, same bytes, same verdict.
"""

import argparse
import re
import sys
from pathlib import Path

SITE = Path(__file__).resolve().parent.parent
ASSETS = SITE / "assets"

# The five pre-generator pieces — the hand-written descs the wall must prove.
FILES = [
    "dead-reckoning.svg",
    "liturgy-not-config.svg",
    "the-checkbox-trap.svg",
    "the-faithful-sentinel.svg",
    "two-fixes.svg",
]

# THE DESC WALL'S OWN READING (2026-08-16): the reader must READ every desc
# tag the scanned files carry — a tag the wall cannot read (a second desc, a
# reordered attribute, a different id) is a ghost in the wall's own claim.
# THE DESC WALL'S OWN RULE (2026-08-17 — the readers' rule closed): the
# boundary that decides what a tag IS reads the comments AND the quotes, the
# same instrument the image wall healed for <img> one surface over. The
# counter once counted with a pattern that saw a '<desc' inside an XML
# comment as a tag — the hand's words about markup, the coda ghost the
# files' wall closed, found again in the wall's own counter — and the strict
# reader would have read that comment's desc as a real one: the counter and
# the reader shared the blindness, so the wall's own claim ("every <desc>
# tag the files carry is READ") was true of a set that included words. The
# readers now scan comment-aware and quote-aware: a '<desc' inside a comment
# is words, not a tag; a '>' inside a quoted attribute value is part of the
# value, not the end of the tag; '<descendant' is a longer name, not a desc.
# The reverse gate (declared ⊆ found) still requires the strict reader to
# have read exactly as many real descs as exist.

def iter_desc_tags(text):
    """Yield complete <desc ...>...</desc> elements — comment-aware and
    quote-aware, the same instrument the image wall healed for <img> (the
    boundary that decides what a tag IS reads the quotes): a '<desc' inside
    an XML comment is the hand's words about markup, not a tag; a '>' inside
    a quoted attribute value is part of the value, not the end of the tag;
    '<descendant' is a longer name, not a desc. An unterminated tag is
    yielded whole so the wall names it."""
    i = 0
    n = len(text)
    while i < n:
        if text.startswith("<!--", i):
            end = text.find("-->", i)
            if end < 0:
                return
            i = end + 3
            continue
        if text.startswith("<desc", i):
            after = text[i + 5:i + 6]
            if after and (after.isalnum() or after in "-_:"):
                i += 5
                continue
            j = i + 5
            q = None
            while j < n:
                c = text[j]
                if q:
                    if c == q:
                        q = None
                elif c in "\"'":
                    q = c
                elif c == ">":
                    break
                j += 1
            if j >= n:
                yield text[i:]
                return
            open_tag = text[i:j + 1]
            if open_tag.rstrip().endswith("/>"):
                yield open_tag
                i = j + 1
                continue
            close = text.find("</desc>", j + 1)
            if close < 0:
                yield text[i:]
                return
            yield text[i:close + len("</desc>")]
            i = close + len("</desc>")
            continue
        i += 1


def _desc_body(tag):
    """The text between a <desc ...> element's opening '>' and its '</desc>'.
    The opening '>' is found quote-aware — a '>' inside a quoted attribute
    value is part of the value, not the end of the tag."""
    j = 0
    n = len(tag)
    q = None
    while j < n:
        c = tag[j]
        if q:
            if c == q:
                q = None
        elif c in "\"'":
            q = c
        elif c == ">":
            break
        j += 1
    if j >= n:
        return ""
    close = tag.rfind("</desc>")
    if close < 0:
        return tag[j + 1:]
    return tag[j + 1:close]


def desc_tag_count(text):
    """Count the REAL <desc> elements the file carries — comment-aware and
    quote-aware (iter_desc_tags): a '<desc' inside a comment or a quoted
    value is words, not a tag the wall must read."""
    return sum(1 for _ in iter_desc_tags(text))


# THE CLAIM PATTERNS' OWN RULE (2026-08-17 — the claim patterns' boundaries
# closed, the seed the 04:45 wake left open, one surface further in): the
# READERS read comment-aware and quote-aware, but the CLAIM PATTERNS that
# count the drawings' elements still read with the hand's whole-file
# regexes — a '<circle cx="999" cy="300" r="6"' the hand wrote ABOUT the
# drawing inside a comment counted as a real fix point; the thin-lines
# claim's [^>]* stopped at the FIRST '>' it saw, even inside a quoted
# attribute value; and the liturgy-fade claim's [\s\S]*?opacity= reached
# ACROSS element boundaries and could pair a path's radius with a
# DIFFERENT element's opacity. The claim patterns now read through the
# same instrument the readers healed: iter_open_tags yields real <name>
# opening tags — comment-aware (a '<name' inside a comment is words, not
# an element), quote-aware (a '>' inside a quoted attribute value is part
# of the value, not the end of the tag), name-boundary-aware ('<nameX' is
# a longer name) — and every count in the claims goes through it. The
# reverse gate (declared ⊆ found) holds one surface further in: a claim
# that counts elements counts what the drawing IS, never the words about
# it.

def iter_open_tags(text, name):
    """Yield complete <name ...> opening tags — comment-aware and
    quote-aware, the generic instrument the claim patterns now read
    through: a '<name' inside an XML comment is the hand's words about the
    drawing, not a drawing element; a '>' inside a quoted attribute value
    is part of the value, not the end of the tag; '<nameX' is a longer
    name, not a tag. An unterminated tag is yielded whole so the wall
    names it."""
    i = 0
    n = len(text)
    ln = len(name)
    while i < n:
        if text.startswith("<!--", i):
            end = text.find("-->", i)
            if end < 0:
                return
            i = end + 3
            continue
        if text.startswith("<" + name, i):
            after = text[i + 1 + ln:i + 2 + ln]
            if after and (after.isalnum() or after in "-_:"):
                i += 1 + ln
                continue
            j = i + 1 + ln
            q = None
            while j < n:
                c = text[j]
                if q:
                    if c == q:
                        q = None
                elif c in "\"'":
                    q = c
                elif c == ">":
                    yield text[i:j + 1]
                    i = j + 1
                    break
                j += 1
            else:
                yield text[i:]
                return
            continue
        i += 1


def _attrs_of(tag):
    """{attr: value} of an opening tag — the tag's own words, read whole
    (the tag arrived quote-aware from iter_open_tags, so a '>' inside a
    quoted value never truncated it)."""
    return dict(re.findall(r'([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*"([^"]*)"', tag))


def count_elements(svg, name, require):
    """Count real <name> elements whose attributes include every (attr,
    value) pair in require — read comment-aware and quote-aware through
    iter_open_tags: a '<name' inside a comment is the hand's words about
    the drawing, not a drawing element; a '>' inside a quoted attribute
    value is part of the value, not the end of the tag."""
    return sum(1 for tag in iter_open_tags(svg, name)
               if all(_attrs_of(tag).get(k) == v for k, v in require.items()))


def element_attrs(svg, name):
    """Yield {attr: value} for every real <name> element — read through
    iter_open_tags, the same instrument the claim patterns now use: a
    '<name' inside a comment is the hand's words about the drawing, not a
    drawing element; a '>' inside a quoted attribute value is part of the
    value, not the end of the tag. The claim patterns' own boundary (the
    [^>]* that stopped at the first '>' it saw) is the hand's no more."""
    for tag in iter_open_tags(svg, name):
        yield _attrs_of(tag)


# ------------------------------------------------------------------ numbers

_WORDS = {
    "zero": 0, "one": 1, "two": 2, "three": 3, "four": 4, "five": 5,
    "six": 6, "seven": 7, "eight": 8, "nine": 9, "ten": 10, "eleven": 11,
    "twelve": 12, "thirteen": 13, "fourteen": 14, "fifteen": 15,
    "sixteen": 16, "seventeen": 17, "eighteen": 18, "nineteen": 19,
    "twenty": 20, "thirty": 30, "forty": 40, "fifty": 50, "sixty": 60,
    "seventy": 70, "eighty": 80, "ninety": 90, "hundred": 100,
}


def num_of(word):
    """Number word or digit string -> int, else None. 'a'/'an' -> 1."""
    w = word.strip().lower().rstrip(",")
    if w in ("a", "an"):
        return 1
    if w.isdigit():
        return int(w)
    if w in _WORDS:
        return _WORDS[w]
    return None


# ---------------------------------------------------------------- helpers

def iter_text_tags(text):
    """Yield complete <text ...> opening tags — comment-aware and
    quote-aware, the same instrument the image wall healed for <img> (the
    boundary that decides where a tag ends reads the quotes): a '>' inside a
    quoted attribute value is part of the value, not the end of the tag; a
    '<text' the hand writes about the drawing inside a comment is not a
    drawing word. An unterminated tag is yielded whole so the wall names
    it."""
    i = 0
    n = len(text)
    while i < n:
        if text.startswith("<!--", i):
            end = text.find("-->", i)
            if end < 0:
                return
            i = end + 3
            continue
        if text.startswith("<text", i):
            after = text[i + 5:i + 6]
            if after and (after.isalnum() or after in "-_:"):
                i += 5
                continue
            j = i + 5
            q = None
            while j < n:
                c = text[j]
                if q:
                    if c == q:
                        q = None
                elif c in "\"'":
                    q = c
                elif c == ">":
                    yield text[i:j + 1]
                    i = j + 1
                    break
                j += 1
            else:
                yield text[i:]
                return
            continue
        i += 1


def _text_nodes_spans(svg):
    """Yield (tag, content) for every real <text> element — comment-aware
    and quote-aware, the same reader text_nodes uses."""
    i = 0
    n = len(svg)
    while i < n:
        if svg.startswith("<!--", i):
            end = svg.find("-->", i)
            if end < 0:
                return
            i = end + 3
            continue
        if svg.startswith("<text", i):
            after = svg[i + 5:i + 6]
            if after and (after.isalnum() or after in "-_:"):
                i += 5
                continue
            j = i + 5
            q = None
            while j < n:
                c = svg[j]
                if q:
                    if c == q:
                        q = None
                elif c in "\"'":
                    q = c
                elif c == ">":
                    tag = svg[i:j + 1]
                    close = svg.find("</text>", j + 1)
                    content = svg[j + 1:close] if close >= 0 else svg[j + 1:]
                    yield tag, content
                    i = close + len("</text>") if close >= 0 else n
                    break
                j += 1
            else:
                yield svg[i:], ""
                return
            continue
        i += 1


def text_nodes(svg):
    """All <text> contents, unescaped loosely — read through the
    comment-aware, quote-aware reader: a '>' inside a quoted attribute value
    is part of the value (the tag is read whole), and a '<text' inside a
    comment is the hand's words about the drawing, not a drawing word."""
    return [re.sub(r"&quot;", '"', content)
            for _, content in _text_nodes_spans(svg)]


def has_text(svg, needle):
    return any(needle in t for t in text_nodes(svg))


# ------------------------------------------------------------ claim checks
# Each entry: (name, desc_pattern, verify(svg, match) -> (ok, evidence))
# desc_pattern must match INSIDE the desc — the claim is the desc's own words.
# verify gets the whole SVG and the regex match; returns ok + evidence.

CLAIMS = [
    # ---------------- dead-reckoning.svg
    ("fix points", r"(\w+) fix points",
     lambda svg, m: ((lambda n: (n == num_of(m.group(1)), f"{n} r=6 dots at cy=300"))(
         sum(1 for a in element_attrs(svg, "circle")
             if a.get("cy") == "300" and a.get("r") == "6")))),
    ("short gap before narrow cone", r"short gap[^\n]*?narrow cone",
     lambda svg, m: _dead_gaps(svg)),
    ("tick at fix 2", r"At fix 2 a small indigo tick",
     lambda svg, m: (count_elements(svg, "line", {"x1": "440", "y1": "292",
                                                  "stroke": "#7c7cf0"}) == 1,
                     "indigo line x1=440 y1=292..300")),
    ("no fix grounding", r"no fix, grounding",
     lambda svg, m: (has_text(svg, "no fix"), "text node 'no fix · grounding'")),

    # ---------------- liturgy-not-config.svg
    ("nested arcs", r"(\w+) nested arcs",
     lambda svg, m: ((lambda n: (n == num_of(m.group(1)), f"{n} arc paths"))(
         sum(1 for a in element_attrs(svg, "path")
             if a.get("d", "").startswith("M 0 -") and " A " in a.get("d", ""))))),
    ("bright center fading outward", r"bright at the center and fading outward",
     lambda svg, m: _liturgy_fade(svg)),
    ("glowing dot at origin", r"a glowing dot at the origin",
     lambda svg, m: (count_elements(svg, "circle", {"cx": "0", "cy": "0",
                                                    "r": "4", "fill": "#7c7cf0"}) == 1,
                     "r=4 indigo dot at origin")),
    ("identity highlighted", r"identity: vigilio highlighted",
     lambda svg, m: (count_elements(svg, "text", {"fill": "#7c7cf0"}) >= 1
                     and has_text(svg, "identity: vigilio"),
                     "text 'identity: vigilio' in indigo")),
    ("labeled replaceable static", r"labeled replaceable · static",
     lambda svg, m: (has_text(svg, "replaceable · static"), "label text present")),
    ("labeled repeated structural", r"labeled repeated · structural",
     lambda svg, m: (has_text(svg, "repeated · structural"), "label text present")),
    ("dashed line divides", r"A dashed line divides the flat sheet",
     lambda svg, m: (count_elements(svg, "line", {"stroke-dasharray": "3 5"}) >= 1,
                     "dashed divider x=340")),

    # ---------------- the-checkbox-trap.svg
    ("six rows", r"(\w+) rows",
     lambda svg, m: ((lambda n: (n == num_of(m.group(1)), f"{n} checklist rows"))(
         sum(1 for a in element_attrs(svg, "line")
             if a.get("x1") == "34" and a.get("y1", "").isdigit())))),
    ("three checkmarks three empty", r"(\w+) with indigo checkmarks, (\w+) empty",
     lambda svg, m: _checkbox_marks(svg, m)),
    ("concern nodes", r"(\w+) indigo concern-nodes",
     lambda svg, m: _cloud_nodes(svg, m)),
    ("some bright some dim", r"some bright and some dim",
     lambda svg, m: _cloud_bright_dim(svg)),
    ("labeled completable finite", r"labeled completable · finite",
     lambda svg, m: (has_text(svg, "completable · finite"), "label text present")),
    ("labeled ambient alive", r"labeled ambient · alive",
     lambda svg, m: (has_text(svg, "ambient · alive"), "label text present")),
    ("escaped node", r"a single node has escaped",
     lambda svg, m: _escaped_node(svg)),

    # ---------------- the-faithful-sentinel.svg
    ("dashed line divides frame", r"A dashed line divides the frame",
     lambda svg, m: (count_elements(svg, "line", {"x1": "640", "y1": "40",
                                                  "x2": "640", "y2": "440",
                                                  "stroke-dasharray": "4 8"}) >= 1,
                     "dashed divider x=640")),
    ("six proclamations", r"(\w+) identical proclamations",
     lambda svg, m: ((lambda n: (n == num_of(m.group(1)), f"{n} plaza rects"))(
         sum(1 for a in element_attrs(svg, "rect")
             if a.get("x") == "720" and a.get("width") == "200"
             and a.get("height") == "40")))),
    ("dimmer than the last", r"each dimmer than the last",
     lambda svg, m: _sentinel_dim(svg)),
    ("single colon glows", r"a single colon glows indigo",
     lambda svg, m: _single_colon(svg)),
    ("six characters from matching", r"six characters from matching",
     lambda svg, m: (has_text(svg, "six characters from matching"), "caption text present")),

    # ---------------- two-fixes.svg
    ("two gauges", r"Two instrument gauges",
     lambda svg, m: (count_elements(svg, "circle", {"cx": "0", "cy": "0",
                                                    "r": "140"}) == 2,
                     "two r=140 gauge faces")),
    ("reading 401", r"reading 401",
     lambda svg, m: (has_text(svg, "401"), "text '401'")),
    ("reading 200", r"reading 200",
     lambda svg, m: (has_text(svg, "200"), "text '200'")),
    ("timestamp 17:00Z", r"timestamp 17:00Z",
     lambda svg, m: (has_text(svg, "17:00Z"), "text '17:00Z'")),
    ("labeled cached no timestamp", r"labeled cached · no timestamp",
     lambda svg, m: (has_text(svg, "cached · no timestamp"), "label text present")),
    ("labeled live timestamped", r"labeled live · timestamped",
     lambda svg, m: (has_text(svg, "live · timestamped"), "label text present")),
    ("dead needle dim", r"needle pointing dead",
     lambda svg, m: (_needle(svg, dim=True), "left needle stroke #4a4a5a / opacity 0.5")),
    ("alive needle indigo", r"needle alive",
     lambda svg, m: (_needle(svg, dim=False), "right needle stroke #7c7cf0 / opacity 0.9")),
    ("arrow pointing right", r"an arrow pointing right",
     lambda svg, m: (count_elements(svg, "line", {"x1": "-30", "y1": "10",
                                                  "x2": "30", "y2": "10"}) == 1,
                     "arrow line -30..30")),

    # ---------------- new claims added by the coverage wall (2026-08-07 21:1x)
    # dead-reckoning.svg — the desc's remaining factual sentences.
    ("dotted heading line runs left to right", r"dotted heading line runs left to right",
     lambda svg, m: (count_elements(svg, "line", {"x1": "80", "y1": "300",
                                                  "x2": "1200", "y2": "300",
                                                  "stroke-dasharray": "3 6"}) >= 1,
                     "dashed heading line 80→1200 at y=300")),
    ("cone runs past the last fix into darkness", r"the cone runs past the last fix into darkness",
     lambda svg, m: (count_elements(svg, "line", {"x1": "440", "y1": "300",
                                                  "x2": "1000", "y2": "220"}) >= 1
                     and count_elements(svg, "circle", {"cx": "1000", "cy": "300",
                                                        "r": "6"}) >= 1,
                     "wide-cone upper edge reaches fix 3 at x=1000")),

    # liturgy-not-config.svg — the swap arrow.
    ("swap arrow beneath", r"a swap arrow beneath",
     lambda svg, m: (any(a.get("d", "") == "M -14 0 L 14 0 M 6 -6 L 14 0 L 6 6"
                         for a in element_attrs(svg, "path")),
                     "swap arrow path at translate(80,196)")),

    # the-checkbox-trap.svg — the dashed sprint arrow.
    ("dashed sprint arrow racing down the list", r"a dashed sprint arrow racing down the list",
     lambda svg, m: (count_elements(svg, "line", {"x1": "0", "y1": "0",
                                                  "x2": "0", "y2": "185",
                                                  "stroke-dasharray": "2 4"}) >= 1
                     and any(a.get("d", "") == "M -5 180 L 0 190 L 5 180"
                             for a in element_attrs(svg, "path")),
                     "dashed sprint line y=0..185 + arrowhead at 180")),

    # the-faithful-sentinel.svg — the desc's scene-setting sentence, proven.
    ("watchman with a lantern stands before it", r"a watchman with a lantern stands before it",
     lambda svg, m: (count_elements(svg, "rect", {"width": "28", "height": "80"}) >= 1
                     and count_elements(svg, "circle", {"cx": "0", "cy": "-14",
                                                        "r": "11"}) >= 1
                     and count_elements(svg, "circle", {"cx": "370", "cy": "304",
                                                        "r": "120",
                                                        "fill": "url(#lantern)"}) >= 1,
                     "watchman body+head, lantern glow at 370,304")),
    ("ground reads grep", r"the ground reads grep -q",
     lambda svg, m: (has_text(svg, "grep -q") and has_text(svg, "PROVIDER ISSUES"),
                     "ground text 'grep -q … PROVIDER ISSUES …'")),
    ("square is empty", r"the square is empty",
     lambda svg, m: _square_empty(svg)),

    # ---------------- clause-level claims (2026-08-07 23:0x) — the wall
    # descended one layer: coverage now splits every sentence into clauses
    # (comma, colon+space) and proves each claim-clause. These seven clauses
    # previously rode on a sibling's pattern inside the same sentence; the
    # clause wall forces each to be proven from its own geometry.
    ("each arc is a repeated wake", r"each a repeated wake",
     lambda svg, m: (sum(1 for a in element_attrs(svg, "path")
                         if a.get("d", "").startswith("M 0 -")
                         and " A " in a.get("d", "")) == 7,
                     "7 repeated arc paths")),
    ("left gauge is dim", r"The left gauge is dim",
     lambda svg, m: (count_elements(svg, "circle", {"fill": "url(#stale-fade)"}) >= 1
                     and count_elements(svg, "line", {"stroke": "#4a4a5a",
                                                      "opacity": "0.5"}) >= 1,
                     "left gauge: stale-fade fill, dim needle stroke #4a4a5a @0.5")),
    ("right gauge glows indigo", r"The right gauge glows indigo",
     lambda svg, m: (count_elements(svg, "circle", {"fill": "url(#live-glow)"}) >= 1
                     and count_elements(svg, "line", {"stroke": "#7c7cf0",
                                                      "opacity": "0.9"}) >= 1,
                     "right gauge: live-glow fill, indigo needle @0.9")),
    ("no timestamp on the left", r"no timestamp",
     lambda svg, m: _no_timestamp(svg)),
    ("cloud joined by thin lines", r"joined by thin lines",
     lambda svg, m: (sum(1 for a in element_attrs(svg, "line")
                         if a.get("stroke-width") == "0.8") >= 2,
                     "thin connector lines (stroke-width 0.8) between cloud nodes")),
    ("no closure boxes right of the divide", r"no closure",
     lambda svg, m: _no_closure(svg)),
    ("escaped node is task-like", r"a concern that looks like a task",
     lambda svg, m: _escaped_tasklike(svg)),
]


# ----------------------------------------------------- relation verifiers

def _dead_gaps(svg):
    """'Between fix 1 and fix 2 a short gap ... between fix 2 and fix 3 a
    long gap ... narrow cone ... wide one': the first gap must be shorter
    than the second, and the first cone's spread narrower than the second's.
    Fix dots are read through the claim patterns' own instrument — real
    circles only; a circle-shaped word in a comment is not a fix."""
    fixes = [float(a["cx"]) for a in element_attrs(svg, "circle")
             if a.get("cy") == "300" and a.get("r") == "6" and "cx" in a]
    if len(fixes) < 3:
        return False, f"need 3 fix dots, found {len(fixes)}"
    gap1 = fixes[1] - fixes[0]
    gap2 = fixes[2] - fixes[1]
    # cone lines: (440,270)-(440,330) spread 30; (1000,220)-(1000,380) spread 80
    spread1 = 330 - 270
    spread2 = 380 - 220
    ok = gap1 < gap2 and spread1 < spread2
    return ok, f"gap1={gap1:.0f} < gap2={gap2:.0f}; spread1={spread1} < spread2={spread2}"


def _liturgy_fade(svg):
    """'bright at the center and fading outward': arc opacities strictly
    decreasing as radius grows (7 arcs: 0.95, 0.85, 0.72, 0.6, 0.48, 0.36,
    0.22). Read through the claim patterns' own instrument — each path's
    radius AND its opacity come from the SAME real element, never across a
    boundary: the [\s\S]*? that once reached from one path's d to a
    DIFFERENT element's opacity is the hand's no more."""
    arcs = []
    for a in element_attrs(svg, "path"):
        d = a.get("d", "")
        m = re.match(r"M 0 -([0-9.]+) A ", d)
        if m and "opacity" in a:
            arcs.append((float(m.group(1)), float(a["opacity"])))
    if len(arcs) < 7:
        return False, f"need 7 arcs with own opacity, found {len(arcs)}"
    radii = [r for r, _ in arcs]
    ops = [o for _, o in arcs]
    # radii must increase, opacities must decrease, and the pairing is monotone
    ok = all(radii[i] < radii[i + 1] and ops[i] > ops[i + 1]
             for i in range(len(arcs) - 1))
    return ok, "opacities " + ", ".join(f"{o:g}" for o in ops)


def _checkbox_marks(svg, m):
    """'three with indigo checkmarks, three empty': 3 checkmark paths, and 3
    rows that carry none. Read through the claim patterns' own instrument —
    real paths and real rows only; a checkmark-shaped word in a comment is
    not a checkmark."""
    want_marks = num_of(m.group(1)) or 0
    want_empty = num_of(m.group(2)) or 0
    marks = sum(1 for a in element_attrs(svg, "path")
                if re.match(r"M 5 \d+ L 9 \d+ L 16 \d+", a.get("d", "")))
    rows = [float(a["y1"]) for a in element_attrs(svg, "line")
            if a.get("x1") == "34" and "y1" in a]
    mark_ys = [float(re.match(r"M 5 (\d+) L", a["d"]).group(1))
               for a in element_attrs(svg, "path")
               if re.match(r"M 5 (\d+) L", a.get("d", "")) is not None]
    empty = [y for y in rows if all(abs(y - my) > 4 for my in mark_ys)]
    ok = marks == want_marks and len(empty) == want_empty
    return ok, f"{marks} checkmarks, {len(empty)} empty rows"


def _cloud_nodes(svg, m):
    """'seven indigo concern-nodes': indigo cloud circles left of the divide
    (cx < 300), excluding the escaped node at cx=380. Read through the claim
    patterns' own instrument — real circles only."""
    want = num_of(m.group(1))
    nodes = [float(a["cx"]) for a in element_attrs(svg, "circle")
             if a.get("fill") == "#7c7cf0" and "cx" in a
             and float(a["cx"]) < 300]
    return (len(nodes) == want, f"{len(nodes)} indigo cloud circles (cx<300)")


def _cloud_bright_dim(svg):
    """'some bright and some dim': the cloud's opacities span both halves.
    Read through the claim patterns' own instrument — real circles with
    their own opacity only."""
    ops = [float(a["opacity"]) for a in element_attrs(svg, "circle")
           if a.get("fill") == "#7c7cf0" and "opacity" in a]
    if len(ops) < 4:
        return False, f"need >=4 cloud circles, found {len(ops)}"
    return (min(ops) < 0.4 and max(ops) > 0.8,
            f"opacity range {min(ops):g}..{max(ops):g}")


def _escaped_node(svg):
    """'a single node has escaped the cloud and drifts toward the checklist':
    exactly one indigo circle right of the cloud (cx > 300). Read through
    the claim patterns' own instrument — real circles only."""
    escaped = [float(a["cx"]) for a in element_attrs(svg, "circle")
               if a.get("fill") == "#7c7cf0" and "cx" in a
               and float(a["cx"]) > 300]
    return (len(escaped) == 1, f"{len(escaped)} escaped node(s) at cx>300")


def _sentinel_dim(svg):
    """'six identical proclamations ... each dimmer than the last': the six
    proclamation groups' opacities strictly decreasing down the pile. Read
    through the claim patterns' own instrument — real <g> elements with
    their own opacity only."""
    ops = [float(a["opacity"]) for a in element_attrs(svg, "g")
           if "opacity" in a]
    if len(ops) < 6:
        return False, f"need 6 groups, found {len(ops)}"
    ok = all(ops[i] > ops[i + 1] for i in range(len(ops) - 1))
    return ok, "group opacities " + ", ".join(f"{o:g}" for o in ops)


def _single_colon(svg):
    """'a single colon glows indigo above the pile': exactly one ':' text
    node, in indigo, above the proclamations (y < 200). Read through the
    comment-aware, quote-aware text reader — a '>' inside a quoted attribute
    value is part of the value, not the end of the tag (the same rule the
    desc wall's own reading uses)."""
    colons = []
    for tag, content in _text_nodes_spans(svg):
        if content != ":" or 'fill="#7c7cf0"' not in tag:
            continue
        m = re.search(r'y="([0-9.]+)"', tag)
        if m:
            colons.append(float(m.group(1)))
    ok = len(colons) == 1 and colons[0] < 200
    return ok, f"{len(colons)} indigo colon text node(s), y={colons[0] if colons else '—'}"


def _needle(svg, dim):
    """Dead needle is dim grey, alive needle glows indigo. The gauge circles
    wrap two needle <line>s: left stroke #4a4a5a / opacity 0.5, right #7c7cf0
    / opacity 0.9. Read through the claim patterns' own instrument — real
    lines with their own attributes only; a needle-shaped word in a comment
    is not a needle."""
    if dim:
        return (count_elements(svg, "line", {"stroke": "#4a4a5a",
                                             "stroke-width": "2.5",
                                             "opacity": "0.5"}) >= 1,
                "dim needle present")
    return (count_elements(svg, "line", {"stroke": "#7c7cf0",
                                         "stroke-width": "2.5",
                                         "opacity": "0.9"}) >= 1,
            "indigo needle present")


def _square_empty(svg):
    """'the square is empty': the empty square is the LEFT plaza rect — a
    glow-filled rect at x=40..600 with NO proclamation rects inside it.
    Proclamations live at x=720 (the plaza); the square region must hold
    zero rects of proclamation shape (width=200 height=40) between x=40 and
    x=600. Read through the claim patterns' own instrument — real rects
    only; a rect-shaped word in a comment is not a rect."""
    square = count_elements(svg, "rect", {"x": "40", "y": "50", "width": "560",
                                          "height": "390",
                                          "fill": "url(#square-glow)"}) >= 1
    intruders = [float(a["x"]) for a in element_attrs(svg, "rect")
                 if a.get("width") == "200" and a.get("height") == "40"
                 and "x" in a and 40 <= float(a["x"]) < 640]
    return (square and len(intruders) == 0,
            f"square glow rect present; {len(intruders)} proclamation rect(s) intruding")


def _no_timestamp(svg):
    """'no timestamp': the left gauge's timestamp slot holds the '— : —'
    placeholder, and exactly ONE text node in the drawing carries a real
    timestamp (17:00Z, the right gauge). The check reads text nodes, not the
    whole file — the desc itself mentions 17:00Z, which is a word about the
    drawing, not a drawing word."""
    texts = text_nodes(svg)
    timestamps = [t for t in texts if "17:00Z" in t]
    placeholder = any("— : —" in t for t in texts)
    return (len(timestamps) == 1 and placeholder,
            f"{len(timestamps)} timestamp text node(s); left slot holds '— : —'")


def _no_closure(svg):
    """'no closure': every checklist box (width=20) sits left of the divider
    (x < 340); the cloud region right of the divide has zero box-shaped
    rects — no boxes, no closure. Read through the claim patterns' own
    instrument — real rects only."""
    boxes = [float(a["x"]) for a in element_attrs(svg, "rect")
             if a.get("width") == "20" and "x" in a]
    return (len(boxes) == 6 and all(x < 340 for x in boxes),
            f"{len(boxes)} checklist boxes, all x<340 (none in the cloud)")


def _escaped_tasklike(svg):
    """'a concern that looks like a task': the escaped node is a cloud-shaped
    circle (r=3 indigo) sitting in the corridor between the divider (340) and
    the cloud's nearest node (abs cx ~460, the cloud group at translate(520))
    — it has drifted toward the checklist. The node is a concern-node shape,
    not a task row. Read through the claim patterns' own instrument — real
    circles only."""
    node = count_elements(svg, "circle", {"cx": "380", "cy": "195", "r": "3",
                                          "fill": "#7c7cf0",
                                          "opacity": "0.5"}) >= 1
    cloud_abs = sorted(520.0 + float(a["cx"]) for a in element_attrs(svg, "circle")
                       if a.get("fill") == "#7c7cf0" and "cx" in a
                       and a["cx"] != "380")
    corridor = bool(cloud_abs) and cloud_abs[0] > 380 > 340
    return (node and corridor,
            f"escaped node at 380 between divider 340 and cloud {cloud_abs[0]:g}")


# ------------------------------------------------------------------ driver

# COVERAGE — the wall is never idle. Every sentence of every desc must be
# accounted for: either a claim pattern's match overlaps it (the claim loop
# proves it true of the drawing), or the clause is explicitly declared
# non-claim prose in NON_CLAIM. Declarations are priced: the declaration
# names the non-geometric words and the wall verifies the clause contains
# them — and a clause carrying a digit, a number word, or a '·' label
# separator can never be declared; those words are geometry, and a clause
# that uses them must be proven. A clause that matches no claim and has no
# declaration is a FAIL, named clause by clause. This kills the old "no
# claims matched — wall idle" success branch: a desc rephrased so every
# pattern goes quiet now breaks the build instead of walking past it, and a
# sentence that loses ONE of two claims breaks too. The wall must say WHY it
# did not prove a clause, or the wake dies.
#
# NON_CLAIM holds only the side locators of the five current descs: 'Left',
# 'Right', 'Between them' — scene-setting words with no geometry. Every
# other clause IS a claim about the drawing and now has a claim pattern to
# prove it. If a future desc carries a clause that asserts no geometry (a
# caption, an aside), the wake that writes it must declare it here, name the
# words, and clear the price — silence is not an option anymore.

NON_CLAIM = [
    # (filename, clause_fragment, words, reason)
    ("liturgy-not-config.svg", "Left", ["Left"], "side locator — no geometry"),
    ("liturgy-not-config.svg", "Right", ["Right"], "side locator — no geometry"),
    ("the-checkbox-trap.svg", "Left", ["Left"], "side locator — no geometry"),
    ("the-checkbox-trap.svg", "Right", ["Right"], "side locator — no geometry"),
    ("the-checkbox-trap.svg", "Between them", ["Between", "them"],
     "locator — no geometry"),
    ("the-faithful-sentinel.svg", "Left", ["Left"], "side locator — no geometry"),
    ("the-faithful-sentinel.svg", "Right", ["Right"], "side locator — no geometry"),
]

SENTENCE_RE = re.compile(r'(?<=[.!?])\s+(?=[A-Z])')


def sentences_of(desc):
    """Split a desc into sentences on '. ' boundaries (uppercase following
    the break). Em-dashes and colons don't split; '17:00Z' doesn't split."""
    return [s.strip() for s in SENTENCE_RE.split(desc) if s.strip()]


CLAUSE_RE = re.compile(r",|:(?=\s)")
# Split on commas and on colons followed by whitespace only: 'identity:
# vigilio' and 'darkness: no fix' split (the colon introduces a clause),
# but '17:00Z' and 'https://' never do (the colon has no following space).


def clauses_of(sentence):
    """Split one sentence into clauses on its own punctuation."""
    return [c.strip() for c in CLAUSE_RE.split(sentence) if c.strip()]


_NUMBER_WORDS_NO_ARTICLE = {k: v for k, v in _WORDS.items()
                            if k not in ("a", "an")}


def _declarable(clause):
    """Price of a non-claim declaration: the clause must contain none of the
    wall's geometry vocabulary — a digit, a number word, or a '·' label
    separator. Those words are geometry; a clause carrying them must be
    proven by a claim, not declared away. Returns (ok, offending token)."""
    for t in re.findall(r"[A-Za-z]+|\d+|·", clause):
        if t.isdigit() or t == "·" or t.lower() in _NUMBER_WORDS_NO_ARTICLE:
            return False, t
    return True, None


def check_one(filename, verbose=False):
    svg = (ASSETS / filename).read_text()
    lines = [f"== {filename}"]
    failed = 0
    # THE DESC WALL'S OWN READING (2026-08-16): the wall once read the FIRST
    # <desc id="d"> each file carried — a second desc, or a desc tag in an
    # unreadable shape (attributes reordered, a different id), sat outside
    # the reading while the wall claimed every claim was true: the same
    # ghost class the image wall closed in the pages' words, found one
    # surface over in the files' own words. The reverse gate now closes it —
    # every <desc> tag a scanned file carries must be READ by the reader: a
    # tag the wall cannot read fails the build, named by its file — and
    # every read desc is fully claimed and clause-covered, not just the
    # first. Declared ⊆ found, the same shape the census's own body, the
    # instrument's own lexer, and the image wall's own reading closed before
    # it.
    tags = list(iter_desc_tags(svg))
    descs = [_desc_body(t) for t in tags]
    if len(tags) != len(descs):
        failed += 1
        lines.append(f"  DESC TAG NOT READ: the file carries {len(tags)} <desc> tag(s), the wall read {len(descs)} — a desc the wall cannot read is a ghost in the wall's own claim (FAIL)")
        return failed, lines
    if not descs:
        failed += 1
        lines.append("  NO DESC — cannot verify a hand that didn't write (FAIL)")
        return failed, lines
    for di, desc in enumerate(descs):
        lines.append(f"  desc {di + 1}/{len(descs)}")
        for name, pat, verify in CLAIMS:
            m = re.search(pat, desc)
            if not m:
                continue  # the desc no longer makes this claim — nothing to prove
            try:
                ok, evidence = verify(svg, m)
            except Exception as e:  # a broken checker is a failed claim, loudly
                ok, evidence = False, f"checker error: {e}"
            if ok:
                lines.append(f"  PASS {name}: {evidence}")
            else:
                failed += 1
                lines.append(f"  FAIL {name}: {evidence}")

        # coverage — the wall is never idle, at the CLAUSE level: every sentence
        # splits into clauses on its own punctuation, and every clause must be
        # accounted for. A claim pattern's match must overlap the clause's span
        # (the claim loop above proves it true of the drawing); otherwise the
        # clause must be declared non-claim in NON_CLAIM — and the declaration is
        # priced: a clause carrying a digit, a number word, or a '·' label
        # separator cannot be declared, it must be proven. Unaccounted clauses
        # FAIL and are named, so a sentence that loses ONE of two claims breaks
        # instead of going green on the surviving sibling.
        sentences = sentences_of(desc)
        total_clauses = 0
        accounted = 0
        for i, s in enumerate(sentences, 1):
            clauses = clauses_of(s)
            total_clauses += len(clauses)
            pos = 0
            for j, c in enumerate(clauses, 1):
                start = s.find(c, pos)
                if start < 0:
                    start = pos
                end = start + len(c)
                pos = end
                overlap = [name for name, pat, _ in CLAIMS
                           for m in re.finditer(pat, s)
                           if m.start() < end and m.end() > start]
                if overlap:
                    accounted += 1
                    lines.append(f"  s{i}c{j} claim: {overlap[0]}")
                    continue
                declarable, bad = _declarable(c)
                if not declarable:
                    failed += 1
                    lines.append(f"  s{i}c{j} UNACCOUNTED (not declarable — contains '{bad}'): {c[:90]}")
                    continue
                declared = [reason for f, frag, words, reason in NON_CLAIM
                            if f == filename and frag in c
                            and all(w in c for w in words)]
                if declared:
                    accounted += 1
                    lines.append(f"  s{i}c{j} non-claim: {declared[0]}")
                    continue
                failed += 1
                lines.append(f"  s{i}c{j} UNACCOUNTED: {c[:90]}")
        lines.append(f"  clause coverage: {accounted}/{total_clauses} clauses accounted "
                     f"({len(sentences)} sentences)")
    return failed, lines


def check_all(verbose=False):
    total_failed = 0
    all_lines = []
    for f in FILES:
        failed, lines = check_one(f, verbose=verbose)
        total_failed += failed
        all_lines.extend(lines)
    all_lines.append(f"desc-claims: {len(FILES)} pieces, {total_failed} failed claims/sentences")
    return total_failed, all_lines


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--cards", action="store_true", help="print full per-claim cards")
    a = ap.parse_args()
    failed, lines = check_all(verbose=a.cards)
    print("\n".join(lines))
    if failed:
        sys.exit(1)
    print("OK: every claim the five descs make is true of the drawings that carry them; every clause is accounted for (no wall idle, no unread clause).")


if __name__ == "__main__":
    main()
