"""Draw the two diagrams on docs/ARCHITECTURE.md, each in a wide, a medium and a narrow layout.

    assets/architecture-responsibilities-{wide,medium,narrow}.svg   Figure 1: who does each job
    assets/architecture-request-state-{wide,medium,narrow}.svg      Figure 2: one request's path and state

The content is transcribed from the page and from evidence/architecture-2026-09-28.md, which
cite the source revisions; keep the three in step. The wide layout (960 px) is for screens at
least 940 px wide, the medium one (560 px) for tablets, the narrow one (360 px) for phones; the
page switches with <picture>.
Standard library only. Run from the repository root:
    python -B evidence/render-architecture-diagrams.py
"""
import re
from pathlib import Path
from xml.sax.saxutils import escape

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

INK, MUTED, LINE, PAPER, PANEL = "#283238", "#586269", "#d6d9d8", "#faf9f6", "#eef0ed"
OLD, NEW = "#1d6fa3", "#c0612b"            # the site's engine colors, used for strokes only
OLD_TINT, NEW_TINT = "#e4eef3", "#f7ebe1"  # light fills; ink text on them is at least 10:1
SANS = "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
MONO = "ui-monospace, SFMono-Regular, Consolas, 'Liberation Mono', monospace"
ENGINES = {
    "old": {"name": "Earlier engine", "where": "src/kb/ at e62758c", "stroke": OLD, "tint": OLD_TINT},
    "new": {"name": "Rewrite", "where": "src/cyclops/ at 5d598de", "stroke": NEW, "tint": NEW_TINT},
}

# ---- text measurement (a conservative estimate of system sans-serif advance widths) ----------
_NARROW, _SEMI = set("iljI.,:;'|!`"), set("frt()[]{}/\\-\"")
_WIDE = set("mwMW@%")


def text_width(text, size, mono=False, bold=False):
    if mono:
        return len(text) * 0.6 * size
    em = 0.0
    for ch in text:
        if ch == " ":
            em += 0.28
        elif ch in _NARROW:
            em += 0.28
        elif ch in _SEMI:
            em += 0.38
        elif ch in _WIDE:
            em += 0.86
        elif ch in "→⇄·—–":
            em += 0.9 if ch in "→⇄—" else 0.45
        elif ch.isupper():
            em += 0.66
        elif ch.isdigit():
            em += 0.56
        else:
            em += 0.54
    return em * size * (1.07 if bold else 1.0)


def wrap(text, width, size, mono=False, bold=False):
    """Greedy word wrap. A long unbroken word is split after / or _ when it has to be."""
    lines, line = [], ""
    for word in text.split(" "):
        trial = f"{line} {word}" if line else word
        if text_width(trial, size, mono, bold) <= width:
            line = trial
            continue
        if line:
            lines.append(line)
        line = word
        while text_width(line, size, mono, bold) > width:
            fits = [i for i, c in enumerate(line) if c in "/_." and text_width(line[:i + 1], size, mono, bold) <= width]
            cut = max((i for i in fits if line[i] in "/."), default=max(fits, default=0))
            if not cut:
                break
            lines.append(line[:cut + 1])
            line = line[cut + 1:]
    if line:
        lines.append(line)
    return lines


class Svg:
    def __init__(self):
        self.parts = []

    def add(self, s):
        self.parts.append(s)

    def rect(self, x, y, w, h, fill, stroke="none", sw=1.0, dash=None, rx=4):
        d = f' stroke-dasharray="{dash}"' if dash else ""
        self.add(f'<rect x="{x:.1f}" y="{y:.1f}" width="{w:.1f}" height="{h:.1f}" rx="{rx}" fill="{fill}" '
                 f'stroke="{stroke}" stroke-width="{sw}"{d}/>')

    def text(self, x, y, s, size, fill=INK, bold=False, mono=False, anchor="start"):
        attrs = f' font-size="{size}" fill="{fill}"'
        if bold:
            attrs += ' font-weight="650"'
        if mono:
            attrs += f' font-family="{MONO}"'
        if anchor != "start":
            attrs += f' text-anchor="{anchor}"'
        self.add(f'<text x="{x:.1f}" y="{y:.1f}"{attrs}>{escape(s)}</text>')

    def lines(self, x, y, lines, size, lead, **kw):
        for i, s in enumerate(lines):
            self.text(x, y + size + i * lead, s, size, **kw)
        return len(lines) * lead

    def line(self, x1, y1, x2, y2, stroke=INK, sw=1.5, dash=None, start=None, end=None):
        d = f' stroke-dasharray="{dash}"' if dash else ""
        m = (f' marker-start="url(#{start})"' if start else "") + (f' marker-end="url(#{end})"' if end else "")
        self.add(f'<line x1="{x1:.1f}" y1="{y1:.1f}" x2="{x2:.1f}" y2="{y2:.1f}" stroke="{stroke}" stroke-width="{sw}"{d}{m}/>')

    def path(self, d, stroke=INK, sw=1.5, dash=None, start=None, end=None, fill="none"):
        da = f' stroke-dasharray="{dash}"' if dash else ""
        m = (f' marker-start="url(#{start})"' if start else "") + (f' marker-end="url(#{end})"' if end else "")
        self.add(f'<path d="{d}" fill="{fill}" stroke="{stroke}" stroke-width="{sw}"{da}{m}/>')


def document(ident, width, height, title, desc, body, defs=""):
    return (f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height:.0f}" '
            f'viewBox="0 0 {width} {height:.0f}" role="img" aria-labelledby="{ident}-title {ident}-desc" '
            f'font-family="{SANS}">\n'
            f'<title id="{ident}-title">{escape(title)}</title>\n<desc id="{ident}-desc">{escape(desc)}</desc>\n'
            f'<defs>\n{defs}</defs>\n<rect width="100%" height="100%" fill="{PAPER}"/>\n' + "\n".join(body) + "\n</svg>\n")


def markers(prefix):
    arrow = ('<marker id="{id}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="{s}" markerHeight="{s}" '
             'orient="auto-start-reverse" markerUnits="userSpaceOnUse"><path d="M0,0 L10,5 L0,10 z" fill="{c}"/></marker>\n')
    hatch = (f'<pattern id="{prefix}-hatch" width="7" height="7" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">'
             f'<rect width="7" height="7" fill="{PAPER}"/><line x1="0" y1="0" x2="0" y2="7" stroke="#c9cecb" stroke-width="2.4"/></pattern>\n')
    return (arrow.format(id=f"{prefix}-call", s=9, c=INK) + arrow.format(id=f"{prefix}-back", s=9, c=MUTED)
            + arrow.format(id=f"{prefix}-data", s=11, c=INK) + hatch)


# =============================================================================================
# Figure 1: who does each job
# Chip kinds: part (a component that does the job), combined (done inside another component),
# elsewhere (done, but in a component you would not look in first), absent (not done at all).
# A chip's key marks the ends of the runtime-recursion arrow.
# =============================================================================================
def part(title, code=None, key=None): return ("part", title, code, key)
def combined(title, code=None): return ("combined", title, code, None)
def elsewhere(title, code=None): return ("elsewhere", title, code, None)
def absent(title): return ("absent", title, None, None)


RESPONSIBILITIES = [
    ("Host API and game hooks",
     [part("KBEngine: query, add, snapshot", "kb_engine.py"), part("JSON and game hooks", "integration/")],
     [part("KnowledgeBase: ask, query, explain", "engine.py"), part("JSON and game hooks, loaded on first use", "api/")]),
    ("Getting a goal to its code",
     [part("7-stage query pipeline", "query/pipeline/"), part("handler picked by name", "query/registry.py"),
      part("router: DFS engine or Legacy evaluator", "routing.py")],
     [combined("inside solve(): control constructs, then built-ins, then clauses", "machine.py")]),
    ("Solving rule bodies",
     [part("Legacy evaluator and strategies", "derived_evaluation/", key="solver"),
      part("depth-first (DFS) engine", "backtracking.py"),
      part("tabling; depth cap of 50", "tabling/, constants.py"), part("built-ins in several places")],
     [part("one solver, solve()", "machine.py", key="solver"), part("one built-in table", "builtins.py"),
      part("whole-program analysis; tabling", "analysis.py, tabling.py")]),
    ("Story time",
     [part("temporal package", "reasoning/temporal/", key="story"), part("several holds_at entry points"),
      part("timeline filled while loading", "facts/processor.py")],
     [part("one holds_at; explain shares it", "calculus.py", key="story"),
      elsewhere("timeline built per revision by the facade, through the solver", "engine.py")]),
    ("Terms and clause storage",
     [part("several parsers, two term formats", "text_parser/, parsing/"), part("two unifiers", "unify/, backtracking.py"),
      part("facts and rules stored apart", "storage.py, rule_compiler.py")],
     [part("one reader, one term model", "syntax/, terms.py"), part("one binding store", "bindings.py"),
      part("one clause list per predicate", "program.py")]),
    ("Limits and “didn’t finish”",
     [part("fixed limits", "constants.py"), absent("result status, per-request budget, cancel")],
     [part("per-request budget, cancel and status", "context.py, errors.py")]),
    ("Explanations and provenance",
     [part("why, derivation, evidence_chain", "provenance_handlers.py"),
      part("source spans, incl. source/2", "storage.py")],
     [part("explain: story time only", "calculus.py"), part("spans_for: fact_id, supports; no status", "engine.py"),
      absent("rule-level explanation")]),
    ("Around the kernel, inside the package",
     [part("CLI, rooms game, gameplay", "cli/, gameplay/"), part("legacy facade those use", "core.py"),
      part("metrics, logging, validators", "metrics/, structured_logging/")],
     [absent("applications, legacy facade, logging"),
      combined("diagnostics on the knowledge base; per-query reasons as notes", "engine.py")]),
]

PREFIX = {"combined": "Combined: ", "elsewhere": "Elsewhere: ", "absent": "Absent: "}
PAD_X, PAD_TOP, PAD_BOTTOM, LEAD = 8, 6, 6, 4


def chip_lines(kind, title, code, width, size, code_size, show_code):
    tl = wrap(PREFIX.get(kind, "") + title, width - 2 * PAD_X, size)
    cl = wrap(code, width - 2 * PAD_X, code_size, mono=True) if (code and show_code) else []
    return tl, cl


def chip_height(tl, cl, size, code_size):
    return PAD_TOP + len(tl) * (size + LEAD) + len(cl) * (code_size + LEAD) + PAD_BOTTOM


def chip_natural_width(kind, title, code, size, code_size, show_code):
    w = text_width(PREFIX.get(kind, "") + title, size)
    if code and show_code:
        w = max(w, text_width(code, code_size, mono=True))
    return w + 2 * PAD_X + 2


def draw_chip(svg, prefix, x, y, w, h, kind, engine, tl, cl, size, code_size):
    e = ENGINES[engine]
    if kind == "part":
        svg.rect(x, y, w, h, "#ffffff", e["stroke"], 1.4)
    elif kind == "combined":
        svg.rect(x, y, w, h, e["tint"], e["stroke"], 2.4)
    elif kind == "elsewhere":
        svg.rect(x, y, w, h, "#ffffff", e["stroke"], 1.7, dash="2 3")
    else:
        svg.rect(x, y, w, h, f"url(#{prefix}-hatch)", MUTED, 1.4, dash="6 4")
    ty = y + PAD_TOP
    for i, s in enumerate(tl):
        p = PREFIX.get(kind, "").strip()
        if i == 0 and p and s.startswith(p):
            rest = s[len(p):].lstrip()
            svg.add(f'<text x="{x + PAD_X:.1f}" y="{ty + size:.1f}" font-size="{size}" fill="{INK}">'
                    f'<tspan font-weight="650">{escape(p)}</tspan> {escape(rest)}</text>')
        else:
            svg.text(x + PAD_X, ty + size, s, size)
        ty += size + LEAD
    for s in cl:
        svg.text(x + PAD_X, ty + code_size, s, code_size, fill=MUTED, mono=True)
        ty += code_size + LEAD


def flow_cell(chips, x, w, gap, size, code_size, show_code):
    """Chips sized to their text, placed left to right and wrapped. Returns placements and height."""
    placed, lines, line, used = [], [], [], 0.0
    for kind, title, code, key in chips:
        nat = min(w, chip_natural_width(kind, title, code, size, code_size, show_code))
        if line and used + gap + nat > w:
            lines.append(line)
            line, used = [], 0.0
        line.append((kind, title, code, key, nat))
        used += (gap if len(line) > 1 else 0) + nat
    if line:
        lines.append(line)
    y = 0.0
    for line in lines:
        # share any spare width, so each line of chips fills the cell
        spare = (w - sum(c[4] for c in line) - gap * (len(line) - 1)) / len(line)
        sized = []
        for kind, title, code, key, nat in line:
            cw = nat + spare
            tl, cl = chip_lines(kind, title, code, cw, size, code_size, show_code)
            sized.append((kind, key, cw, tl, cl))
        h = max(chip_height(tl, cl, size, code_size) for _, _, _, tl, cl in sized)
        cx = x
        for kind, key, cw, tl, cl in sized:
            placed.append((kind, key, cx, y, cw, h, tl, cl))
            cx += cw + gap
        y += h + gap
    return placed, y - gap


def legend_fig1(svg, prefix, x, y, w, size, layout):
    """Legend: the four chip styles, drawn as in the figure, and the one arrow type. Returns the bottom y."""
    svg.text(x, y + size, "How to read it", size, bold=True)
    y += size + 12
    items = [("part", "A component that does this job"),
             ("combined", "Combined: done inside another component"),
             ("elsewhere", "Elsewhere: done in a component you wouldn't look in first"),
             ("absent", "Absent: not done")]
    sw, sh, pair = 20, 18, 46  # one swatch per engine color; Absent is gray in both
    per_row = 2 if layout == "wide" else 1
    col_w = w / per_row
    row_y, row_h = y, 0
    for n, (kind, label) in enumerate(items):
        cx = x + (n % per_row) * col_w
        if n % per_row == 0:
            row_y += row_h
            row_h = 0
        lines = wrap(label, col_w - pair - 16, size)
        row_h = max(row_h, max(sh, len(lines) * (size + 5) - 5) + 10)
        for k, eng in enumerate(("old", "new")):
            sx, stroke, tint = cx + k * (sw + 6), ENGINES[eng]["stroke"], ENGINES[eng]["tint"]
            if kind == "part":
                svg.rect(sx, row_y, sw, sh, "#ffffff", stroke, 1.4)
            elif kind == "combined":
                svg.rect(sx, row_y, sw, sh, tint, stroke, 2.4)
            elif kind == "elsewhere":
                svg.rect(sx, row_y, sw, sh, "#ffffff", stroke, 1.7, dash="2 3")
            elif k == 0:
                svg.rect(sx, row_y, sw * 2 + 6, sh, f"url(#{prefix}-hatch)", MUTED, 1.4, dash="6 4")
        p = PREFIX.get(kind, "").strip()
        for i, s in enumerate(lines):
            ty = row_y + sh - 4 + i * (size + 5)
            if i == 0 and p and s.startswith(p):
                svg.add(f'<text x="{cx + pair + 8:.1f}" y="{ty:.1f}" font-size="{size}" fill="{INK}">'
                        f'<tspan font-weight="650">{escape(p)}</tspan> {escape(s[len(p):].lstrip())}</text>')
            else:
                svg.text(cx + pair + 8, ty, s, size)
    y = row_y + row_h + 6
    svg.path(f"M{x + 26},{y + 2} L{x + 8},{y + 2} L{x + 8},{y + 30} L{x + 26},{y + 30}",
             stroke=MUTED, sw=1.8, dash="5 4", start=f"{prefix}-back", end=f"{prefix}-back")
    note = ("Dashed arrow: runtime calls in both directions. The rule solver and story-time evaluation call each "
            "other while a query runs, in both engines." + ("" if layout == "narrow" else
            " Module names are abbreviated; the text equivalent gives full paths."))
    used = svg.lines(x + 40, y - 3, wrap(note, w - 40, size), size, size + 5)
    return y + max(36, used + 4)


def figure1(layout):
    prefix = "f1" + layout[0]
    narrow = layout != "wide"
    svg = Svg()
    gutter = 16  # left of each cell: room for the runtime-recursion arrow
    if layout == "narrow":
        W, gap, split = 360, 5, 8
        size, code_size, show_code, label_size = 13.5, 11, False, 14.5
    elif layout == "medium":
        W, gap, split = 560, 6, 12
        size, code_size, show_code, label_size = 13.5, 11, True, 14.5
    else:
        W, gap, split = 960, 7, 16
        size, code_size, show_code, label_size = 14, 12, True, 15
    cell_w = (W - split) / 2
    cols = {"old": (0, cell_w), "new": (cell_w + split, cell_w)}

    y = 2
    for eng, (x, w) in cols.items():
        e = ENGINES[eng]
        svg.rect(x, y, w, 48, e["tint"], e["stroke"], 1.4)
        svg.text(x + 10, y + 21, e["name"], label_size + 1, bold=True)
        svg.text(x + 10, y + 39, e["where"], 11.5 if layout == "narrow" else 13, mono=True)
    y += 60

    anchors = {}
    for label, old, new in RESPONSIBILITIES:
        y += svg.lines(gutter, y - 2, wrap(label, W - gutter, label_size, bold=True), label_size, label_size + 5, bold=True) + 4
        cells, heights = {}, {}
        for eng, chips in (("old", old), ("new", new)):
            x, w = cols[eng]
            cells[eng], heights[eng] = flow_cell(chips, x + gutter, w - gutter, gap, size, code_size, show_code)
        row_h = max(heights.values())
        for eng, placed in cells.items():
            for kind, key, x, cy, w, h, tl, cl in placed:
                draw_chip(svg, prefix, x, y + cy, w, h, kind, eng, tl, cl, size, code_size)
                if key:
                    anchors[(eng, key)] = (x, y + cy + min(h, 40) / 2)
        y += row_h + 10
        svg.line(0, y - 5, W, y - 5, stroke=LINE, sw=1)
        y += 5

    for eng, (x, w) in cols.items():
        (ax, ay), (bx, by) = anchors[(eng, "solver")], anchors[(eng, "story")]
        gx = x + 5
        svg.path(f"M{ax - 2:.1f},{ay:.1f} L{gx:.1f},{ay:.1f} L{gx:.1f},{by:.1f} L{bx - 2:.1f},{by:.1f}",
                 stroke=MUTED, sw=1.8, dash="5 4", start=f"{prefix}-back", end=f"{prefix}-back")

    y = legend_fig1(svg, prefix, 0, y + 6, W, 13 if narrow else 14, layout)
    title = "Who does each job in the earlier engine and in the rewrite"
    desc = ("Matched responsibility map. For eight jobs, the components that do each one in the earlier engine "
            "(left) and the rewrite (right). The earlier engine has several mechanisms for most jobs, chosen per "
            "request or entry path, and keeps applications in its package. The rewrite has one mechanism of each kind, "
            "routes goals inside its solver, builds its timeline in the facade and reports whether a query finished; "
            "it has no rule-level explanation, applications or logging. In both engines the rule solver and "
            "story-time evaluation call each other at runtime.")
    return document(prefix, W, y + 8, title, desc, svg.parts, markers(prefix))


# =============================================================================================
# Figure 2: one request's path, and how long the state it touches lives
# =============================================================================================
LIFETIMES = {
    "request": ("This request", "#ffffff"),
    "next-query": ("Until the next query", "#f4f5f2"),
    "next-add": ("Until the next add, or 300 s", "#f3eee3"),
    "epoch": ("Until the next add", "#f3eee3"),
    "revision": ("Until the knowledge changes", "#f3eee3"),
    "engine": ("Life of the engine object", "#e6ebe8"),
    "process": ("Whole Python process", "#d9e0dd"),
}

REQUEST = {
    "old": {
        "steps": [
            ("KBEngine.query", "for can_enter(lab)",
             [("request", "pipeline QueryContext: query text, goal, handler, results")]),
            ("Pipeline stages", "cache lookup, validate, parse, route by name",
             [("next-add", "result cache: miss"), ("next-query", "answer tables: cleared here")]),
            ("Reasoner → router → Legacy evaluator", "runs the rule body",
             [("process", "routing allowlist, loggers"), ("request", "substitutions, variable names"),
              ("next-query", "derived-result cache")]),
            ("FluentEvaluator.holds_at", "calls back into the Reasoner for effect rules",
             [("epoch", "holds_at cache"), ("engine", "facts, rules, time order")]),
            ("Store and project the result", None,
             [("next-add", "result cache keeps the list")]),
        ],
        "result": ("Host receives []", "a list of dicts; an identical repeat is answered from the result cache"),
    },
    "new": {
        "steps": [
            ("KnowledgeBase.ask", "for can_enter(lab)",
             [("request", "new QueryContext: bindings, tables, story-time memo, budget, status, notes")]),
            ("_refresh", "if the revision changed: analyze (outside the budget), then build the timeline via solve",
             [("revision", "DerivedState: analysis, timeline, diagnostics; not kept if a budget or cancel stopped it")]),
            ("solve → clauses", "the same function rule bodies use",
             [("engine", "Program: clauses and revision number"),
              ("revision", "first-argument index per predicate, built on first use"),
              ("process", "built-in table (fixed at import); variable counter; interned atoms, never removed; recursion limit, raised for the call")]),
            ("holds_at → _explain", "via the built-in table; calls solve for effect rules",
             [("request", "story-time memo, in the QueryContext"), ("revision", "timeline, read from DerivedState")]),
            ("Fold status into the result", "the QueryContext is discarded", []),
        ],
        "result": ("Host receives Solutions(answers=(), status=complete)", "a new frozen object on every call"),
    },
}
RECURSION_STEP = 3  # index of the step that calls back into rule evaluation


def figure2_panel(svg, prefix, eng, x0, y0, W, narrow):
    e = ENGINES[eng]
    data = REQUEST[eng]
    size, small, tag_size = (13.5, 12.5, 12) if narrow else (14, 12.5, 12)
    lg = 20  # left gutter for the call-back arrow
    sx = x0 + lg
    step_w = W * (0.42 if W < 400 else 0.43) - lg
    state_x = sx + step_w + (20 if W < 400 else 28)
    state_w = x0 + W - state_x
    svg.rect(x0, y0, W, 48, e["tint"], e["stroke"], 1.4)
    svg.text(x0 + 10, y0 + 21, e["name"], size + 2, bold=True)
    svg.text(x0 + 10, y0 + 40, e["where"], small, mono=True)
    y = y0 + 64
    boxes = []
    for i, (title, sub, states) in enumerate(data["steps"]):
        tl = wrap(title, step_w - 16, size, bold=True)
        sl = wrap(sub, step_w - 16, small) if sub else []
        step_h = 10 + len(tl) * (size + 4) + len(sl) * (small + 4) + 6
        chips = []
        for lt, text in states:
            tag, fill = LIFETIMES[lt]
            gl = wrap(tag, state_w - 16, tag_size, bold=True)
            bl = wrap(text, state_w - 16, small)
            chips.append((lt, fill, gl, bl, 8 + len(gl) * (tag_size + 3) + len(bl) * (small + 4) + 6))
        state_h = sum(c[4] for c in chips) + 6 * max(0, len(chips) - 1)
        row_h = max(step_h, state_h)
        # step box
        svg.rect(sx, y, step_w, step_h, "#ffffff", e["stroke"], 1.6)
        ty = y + 8
        for s in tl:
            svg.text(sx + 8, ty + size, s, size, bold=True)
            ty += size + 4
        for s in sl:
            svg.text(sx + 8, ty + small, s, small, fill=MUTED)
            ty += small + 4
        boxes.append((y, step_h))
        # state chips with data-flow connectors
        cy = y
        for lt, fill, gl, bl, ch in chips:
            svg.rect(state_x, cy, state_w, ch, fill, MUTED, 1.1)
            ty = cy + 6
            for s in gl:
                svg.text(state_x + 8, ty + tag_size, s, tag_size, fill=MUTED, bold=True)
                ty += tag_size + 3
            for s in bl:
                svg.text(state_x + 8, ty + small, s, small)
                ty += small + 4
            mid = cy + min(ch, 30) / 2
            svg.path(f"M{sx + step_w:.1f},{y + min(step_h, 30) / 2:.1f} L{state_x - 3:.1f},{mid:.1f}",
                     stroke=MUTED, sw=1.3, dash="1.5 3")
            cy += ch + 6
        y += row_h + 22
    # runtime calls between steps
    for (ya, ha), (yb, _) in zip(boxes, boxes[1:]):
        cx = sx + 26
        svg.line(cx, ya + ha, cx, yb - 1, stroke=INK, sw=1.6, end=f"{prefix}-call")
    # runtime recursion: the story-time step calls back into rule evaluation, drawn in the left gutter
    ya, ha = boxes[RECURSION_STEP]
    yb, hb = boxes[RECURSION_STEP - 1]
    y_from, y_to = ya + min(ha, 34) / 2, yb + min(hb, 34) / 2
    gx = x0 + 4
    svg.path(f"M{sx - 2:.1f},{y_from:.1f} L{gx:.1f},{y_from:.1f} L{gx:.1f},{y_to:.1f} L{sx - 2:.1f},{y_to:.1f}",
             stroke=MUTED, sw=1.8, dash="5 4", start=f"{prefix}-back", end=f"{prefix}-back")
    # result to the host (data flow)
    title, sub = data["result"]
    y -= 6
    tl = wrap(title, W - 20, size, bold=True, mono=False)
    sl = wrap(sub, W - 20, small)
    rh = 10 + len(tl) * (size + 4) + len(sl) * (small + 4) + 6
    svg.line(sx + 26, boxes[-1][0] + boxes[-1][1], sx + 26, y + 6, stroke=INK, sw=3.2, end=f"{prefix}-data")
    y += 8
    svg.rect(x0, y, W, rh, PANEL, INK, 1.2)
    ty = y + 8
    for s in tl:
        svg.text(x0 + 10, ty + size, s, size, bold=True)
        ty += size + 4
    for s in sl:
        svg.text(x0 + 10, ty + small, s, small)
        ty += small + 4
    return y + rh


def legend_fig2(svg, prefix, x, y, w, size, narrow):
    svg.text(x, y + size, "How to read it", size, bold=True)
    y += size + 12
    items = [
        ("call", "Solid arrow: runtime call, in order"),
        ("back", "Dashed double arrow: calls back into rule evaluation (runtime recursion)"),
        ("flow", "Dotted line: reads or writes this state (data flow)"),
        ("data", "Thick arrow: the result returned to the host (data flow)"),
    ]
    col_w = w if narrow else w / 2
    row_h = 0
    for n, (kind, label) in enumerate(items):
        cx = x + (0 if narrow else (n % 2) * col_w)
        lines = wrap(label, col_w - 60, size)
        if narrow or n % 2 == 0:
            y += row_h
            row_h = 0
        cy = y + 9
        if kind == "call":
            svg.line(cx, cy, cx + 40, cy, stroke=INK, sw=1.6, end=f"{prefix}-call")
        elif kind == "back":
            svg.line(cx, cy, cx + 40, cy, stroke=MUTED, sw=1.8, dash="5 4", start=f"{prefix}-back", end=f"{prefix}-back")
        elif kind == "flow":
            svg.line(cx, cy, cx + 40, cy, stroke=MUTED, sw=1.3, dash="1.5 3")
        else:
            svg.line(cx, cy, cx + 40, cy, stroke=INK, sw=3.2, end=f"{prefix}-data")
        used = svg.lines(cx + 52, y - 2, lines, size, size + 5)
        row_h = max(row_h, used + 10)
    y += row_h + 4
    note = ("Each state box is labeled with how long that state lives and so who owns it: the request, the next "
            "query, the next change to the knowledge, the engine object, or the whole Python process. "
            "Shading darkens with lifetime.")
    y += svg.lines(x, y, wrap(note, w, size), size, size + 5)
    return y


def figure2(layout):
    prefix = "f2" + layout[0]
    svg = Svg()
    if layout != "wide":
        W = 360 if layout == "narrow" else 560
        y = figure2_panel(svg, prefix, "old", 0, 4, W, True)
        y = figure2_panel(svg, prefix, "new", 0, y + 28, W, True)
        y = legend_fig2(svg, prefix, 0, y + 24, W, 13, True)
    else:
        W, gap = 960, 36
        pw = (W - gap) / 2
        # draw both panels, then keep the taller bottom
        ya = figure2_panel(svg, prefix, "old", 0, 4, pw, False)
        yb = figure2_panel(svg, prefix, "new", pw + gap, 4, pw, False)
        y = legend_fig2(svg, prefix, 0, max(ya, yb) + 26, W, 14, False)
    title = "One request's path and where its state lives, in each engine"
    desc = ("Request flow and state lifetime for the query behind the game's decision, can_enter(lab). "
            "Earlier engine: query, pipeline stages, name-based handler, router, Legacy evaluator, holds_at calling "
            "back into the Reasoner; state touched lives in the request, until the next query, until the next add "
            "(or 300 seconds), for the engine's life, or process-wide; the result is cached and the host receives a "
            "list. Rewrite: ask, new QueryContext, refresh of the analysis and timeline if the program changed, "
            "solve, holds_at calling back into solve; evaluation state lives in the request's QueryContext; the "
            "analysis, timeline and clause index last until the program changes; a few process-wide items are "
            "touched; the host receives a new frozen Solutions with a status.")
    return document(prefix, W, y + 8, title, desc, svg.parts, markers(prefix))


def main():
    ASSETS.mkdir(exist_ok=True)
    outputs = {}
    for layout in ("wide", "medium", "narrow"):
        outputs[f"architecture-responsibilities-{layout}.svg"] = figure1(layout)
        outputs[f"architecture-request-state-{layout}.svg"] = figure2(layout)
    page = (ROOT / "docs" / "ARCHITECTURE.md").read_text(encoding="utf-8")
    for name, text in outputs.items():
        (ASSETS / name).write_text(text, encoding="utf-8", newline="\n")
        size = re.search(r'width="(\d+)" height="(\d+)"', text).groups()
        print(f"wrote assets/{name} ({size[0]} x {size[1]})")
        # The page gives each image its size so the layout doesn't jump; say so when it is stale.
        tag = re.search(r'<(?:img|source)[^>]*' + re.escape(name) + r'[^>]*>', page)
        found = re.search(r'width="(\d+)" height="(\d+)"', tag.group(0)) if tag else None
        if tag and (found is None or found.groups() != size):
            print(f"  docs/ARCHITECTURE.md: set width=\"{size[0]}\" height=\"{size[1]}\" on the tag for {name}")


if __name__ == "__main__":
    main()
