**A judgment engine: register a thesis, steelman each claim, measure against a substrate, refine the weakest axis.**
@@ -27,6 +27,21 @@ crucible turns a thesis into a set of claims, each paired with the observation t
- **Batch manifests, Markdown reports, creative measurement gates, native MCP.** `crucible batch` runs a manifest of theses into one registry, `crucible report` renders deterministic Markdown, `crucible measurement-gate` verifies Telos creative measurement packets, and `crucible mcp` serves 13 tools over stdio.
- **Zero third-party runtime dependencies.** The core is pure standard library, Python 3.11+.
+## How a thesis run works
+
+`crucible run` takes one thesis and produces a packet a reviewer can check
+without rerunning anything, and without trusting the run that made it.
+
+
+
+Two edges carry the design. The one that loops back is the refine step: grading
+every claim yields a margin, the harmonic mean of those margins is the cohesion,
+and a round that is not cohesive names its weakest axis and measures that axis
+again rather than reporting a thesis that only half held. The one that does not
+loop is `verdict_for`, which is a pure function of a deviation and a tolerance.
+No model sits in it, so a fluent assertion has no route into a rechecked result,
+and an axis that could not be measured reads UNVERIFIABLE instead of holding.
+
## Install
```bash
diff --git a/docs/art/crucible-header.svg b/docs/art/crucible-header.svg
new file mode 100644
index 0000000..ff731ab
--- /dev/null
+++ b/docs/art/crucible-header.svg
@@ -0,0 +1 @@
+
diff --git a/docs/art/crucible.art.json b/docs/art/crucible.art.json
new file mode 100644
index 0000000..484da6a
--- /dev/null
+++ b/docs/art/crucible.art.json
@@ -0,0 +1,36 @@
+{
+ "$comment": "Source of truth for the artwork on this repository's front page. Edit a sentence here and re-run scripts/render_repo_art.py; tests/test_repo_art.py fails if the committed SVG stops matching.",
+ "header": {
+ "name": "crucible",
+ "role": "adversarial claim measurement",
+ "tagline": "Register a thesis, measure each claim, name the weakest axis.",
+ "words": ["thesis", "steelman", "measure", "verdict", "refine"]
+ },
+ "flows": [
+ {
+ "file": "thesis-lifecycle.svg",
+ "kicker": "one thesis, end to end",
+ "title": "How a claim earns its standing, and how it loses it",
+ "alt": "Eight stages from a registered thesis to a self-contained review packet. Each claim is steelmanned, measured against a substrate, and graded; a round that is not cohesive reflects on the weakest axis and re-measures it. The verdict is a pure function of the measurement and ends as match, drift or unverifiable.",
+ "footnote": "The verdict recomputes from the stored record, so a confident assertion cannot move it.",
+ "stages": [
+ {"title": "Thesis", "note": "A claim set under one seal, each with what would refute it."},
+ {"title": "Steelman", "note": "The strongest test is proposed before anything is measured."},
+ {"title": "Measure", "note": "Each claim meets a substrate: a deviation, and its tolerance."},
+ {"title": "Cohesion", "note": "The harmonic mean of margins, so one weak axis drags it down."},
+ {"title": "Verdict", "note": "A pure function of deviation and tolerance. No model here."},
+ {"title": "Registry", "note": "The assessment is stored under a sha256 receipt with the seal."},
+ {"title": "Recheck", "note": "Rows are re-read from disk and the verdicts recompute."},
+ {"title": "Packet", "note": "A bundle that carries its own zero-import verifier."}
+ ],
+ "returns": [
+ {"from": 3, "to": 2, "label": "NOT COHESIVE: THE WEAKEST AXIS IS RE-MEASURED"}
+ ],
+ "outcomes": [
+ {"label": "MATCH", "note": "the measurement sits inside tolerance", "tone": "verified"},
+ {"label": "DRIFT", "note": "the measurement sits outside it", "tone": "drift"},
+ {"label": "UNVERIFIABLE", "note": "the axis could not be measured at all", "tone": "none"}
+ ]
+ }
+ ]
+}
diff --git a/docs/art/thesis-lifecycle.svg b/docs/art/thesis-lifecycle.svg
new file mode 100644
index 0000000..84b53cb
--- /dev/null
+++ b/docs/art/thesis-lifecycle.svg
@@ -0,0 +1,19 @@
+
diff --git a/scripts/render_repo_art.py b/scripts/render_repo_art.py
new file mode 100644
index 0000000..1b40d8b
--- /dev/null
+++ b/scripts/render_repo_art.py
@@ -0,0 +1,73 @@
+"""Render a repository's front-page artwork from its spec.
+
+ python scripts/render_repo_art.py # write the SVGs
+ python scripts/render_repo_art.py --check # fail if any is stale
+
+The check mode is the point. Committed artwork drifts from the words it
+illustrates the moment someone edits one and not the other, and nobody
+notices, because a picture in a README is never diffed. Here the picture is
+a pure function of a spec that IS diffable, so a test can re-render and
+compare bytes.
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+
+from repo_art import header_svg # noqa: E402
+from repo_flow import flow_svg # noqa: E402
+
+ART = Path(__file__).resolve().parents[1] / "docs" / "art"
+
+
+def rendered(spec_path: Path) -> dict[Path, str]:
+ """Every file one spec produces, as path to text."""
+ spec = json.loads(spec_path.read_text(encoding="utf-8"))
+ stem = spec_path.name.removesuffix(".art.json")
+ out = {spec_path.parent / f"{stem}-header.svg": header_svg(spec["header"])}
+ for flow in spec.get("flows", []):
+ out[spec_path.parent / flow["file"]] = flow_svg(flow)
+ return out
+
+
+def specs() -> list[Path]:
+ return sorted(ART.glob("*.art.json"))
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--check", action="store_true",
+ help="report stale artwork instead of rewriting it")
+ args = parser.parse_args(argv)
+
+ stale: list[str] = []
+ for spec_path in specs():
+ for path, text in rendered(spec_path).items():
+ body = text + "\n"
+ if args.check:
+ current = path.read_text(encoding="utf-8") if path.exists() else ""
+ if current != body:
+ stale.append(str(path.relative_to(ART.parents[1])))
+ continue
+ # newline="" so a Windows run writes the same bytes a Linux
+ # run does. The whole point of this file is that committed
+ # artwork and a fresh render are comparable.
+ path.write_text(body, encoding="utf-8", newline="")
+ print(f"wrote {path.relative_to(ART.parents[1])} ({len(body)} bytes)")
+
+ if stale:
+ print("stale artwork, re-run scripts/render_repo_art.py:", file=sys.stderr)
+ for name in stale:
+ print(f" {name}", file=sys.stderr)
+ return 1
+ if args.check:
+ print(f"artwork matches its spec ({len(specs())} spec files)")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/repo_art.py b/scripts/repo_art.py
new file mode 100644
index 0000000..eba6814
--- /dev/null
+++ b/scripts/repo_art.py
@@ -0,0 +1,276 @@
+"""repo_art.py -- deterministic SVG artwork for a repository's front page.
+
+Two renderers, one design language, no binary blobs.
+
+`header_svg` draws the identity card: a seeded aperture on pure black, with the
+repository's name, what it does, and the words it works in. The aperture is the
+one form the whole visual corpus returns to, a luminous core with fine radial
+line-work resolving around it. Every repository gets the SAME form and a
+DIFFERENT drawing of it, because the corona, the halftone screen and the hue
+are all derived from a hash of the repository's own name. That is where the
+sense of identity comes from: sibling projects, not a template applied twice.
+
+`flow_svg` draws a workflow: cards on a calm ground, hairline connectors, and
+color used only to say what a path means. It reads the same in a light or a
+dark reader because it defines both and lets the reader's own setting pick.
+
+The split between the two is deliberate and is the figure-ground rule made
+concrete. Generative energy belongs where the art is the subject, so it is
+contained inside the header. Where words have to be read it recedes to a
+hairline, so the diagrams carry no texture at all.
+
+Both renderers are pure functions of their spec: same spec, same bytes. That is
+what lets a test re-render the committed art and fail on drift, rather than
+trusting that whoever last touched the file also regenerated it.
+"""
+from __future__ import annotations
+
+import colorsys
+import hashlib
+import random
+
+# Pure black, because the corpus grounds on pure black rather than a soft
+# near-black. The two inks are the warm bone the rest of the ecosystem uses.
+VOID = "#07080A"
+BONE = "#F2F4F1"
+SOFT = "#8E9AA0"
+
+# The hues a corona is allowed to take, drawn from the electric-neon-on-black
+# and luminous-warm-core poles of the inspiration corpus. Angles in degrees.
+HUES = (188, 168, 96, 44, 22, 286, 322)
+
+GROTESK = "Hanken Grotesk, Segoe UI, ui-sans-serif, system-ui, sans-serif"
+MONO = "Conso, ui-monospace, Cascadia Mono, Consolas, monospace"
+
+
+def _num(value: float) -> str:
+ """Two decimal places, no trailing noise. Byte-stability starts here."""
+ return f"{value:.2f}".rstrip("0").rstrip(".") or "0"
+
+
+def _esc(text: str) -> str:
+ return (str(text).replace("&", "&").replace("<", "<")
+ .replace(">", ">").replace('"', """))
+
+
+def seed_for(name: str) -> int:
+ """A stable seed from the repository's own name.
+
+ sha256 rather than hash(): CPython randomises str hashing per process, so
+ the built-in would give a different drawing on every run.
+ """
+ return int(hashlib.sha256(name.encode("utf-8")).hexdigest()[:8], 16)
+
+
+def _hue_pair(rng: random.Random) -> tuple[str, str]:
+ """The corona hue and its cooler companion, as hex."""
+ # Anchor on one of the corpus hues, then drift up to 14 degrees off it.
+ # Seven fixed hues collide across a dozen repositories; the drift keeps
+ # two siblings that land on the same anchor from reading as one project.
+ hue = (HUES[rng.randrange(len(HUES))] + rng.uniform(-14, 14)) % 360
+ warm = colorsys.hls_to_rgb(hue / 360.0, 0.62, 0.92)
+ cool = colorsys.hls_to_rgb(((hue + 34) % 360) / 360.0, 0.44, 0.70)
+ return ("#%02X%02X%02X" % tuple(round(c * 255) for c in warm),
+ "#%02X%02X%02X" % tuple(round(c * 255) for c in cool))
+
+
+def _spokes(cx: float, cy: float, rng: random.Random, count: int = 300) -> str:
+ """The corona, as a field of short radial dashes rather than long spokes.
+
+ Continuous spokes read as a star. Broken ones read as line-work: the eye
+ resolves a texture instead of counting rays, and the corona can be dense
+ without becoming loud. Each angle walks outward emitting a dash, then a
+ gap, until it runs out of reach, and the reach is set by a two-wave field
+ so the ring thickens and thins instead of sitting perfectly even.
+
+ Bucketed into five opacity groups, brightest nearest the aperture, so the
+ whole corona is five path elements rather than a thousand.
+ """
+ import math
+
+ buckets: list[list[str]] = [[] for _ in range(5)]
+ for i in range(count):
+ angle = (i / count) * math.tau + rng.uniform(-0.008, 0.008)
+ field = (math.sin(angle * 3 + rng.random() * 0.3) * 0.22
+ + math.sin(angle * 7) * 0.13 + 0.68)
+ cos_a, sin_a = math.cos(angle), math.sin(angle)
+ limit = 92 + field * 52
+ radius = 76 + rng.uniform(0, 9)
+ while radius < limit:
+ far = min(limit, radius + rng.uniform(3.0, 15.0))
+ level = min(4, max(0, int((1.0 - (radius - 76) / 78.0) * 4.6)))
+ buckets[level].append(
+ f"M{_num(cx + cos_a * radius)} {_num(cy + sin_a * radius)}"
+ f"L{_num(cx + cos_a * far)} {_num(cy + sin_a * far)}")
+ radius = far + rng.uniform(2.5, 13.0)
+ out = []
+ for level, segs in enumerate(buckets):
+ if not segs:
+ continue
+ out.append(f'')
+ return "".join(out)
+
+
+def _rings(cx: float, cy: float, rng: random.Random) -> str:
+ """Concentric broken arcs. This is the mesh half of the corona.
+
+ Each ring is one circle with a seeded dash pattern, so a whole band of
+ fine arc-work costs one element and varies per repository.
+ """
+ out = []
+ for ring in range(14):
+ radius = 80 + ring * 4.6
+ fall = 1.0 - (ring / 14.0) ** 1.5
+ dash = rng.uniform(2.2, 13.0)
+ gap = dash * rng.uniform(0.7, 3.4)
+ out.append(f'')
+ return "".join(out)
+
+
+def _blades(cx: float, cy: float, rng: random.Random, accent: str) -> str:
+ """A dot screen in a narrow band around the core, the way a halftone
+ plate carries the shoulder of a highlight."""
+ import math
+
+ dots = []
+ for ring in range(1, 7):
+ radius = 41 + ring * 5.1
+ n = max(10, int(radius * 0.85))
+ for k in range(n):
+ angle = (k / n) * math.tau + ring * 0.27
+ fall = max(0.0, 1.0 - (ring / 7.0) ** 1.2)
+ if rng.random() > fall * 0.86 + 0.1:
+ continue
+ dots.append(f'')
+ return f'{"".join(dots)}'
+
+
+def _core(cx: float, cy: float, cool: str) -> str:
+ """The aperture itself, and the one spectral flare.
+
+ The middle is a void with an incandescent rim, not a bright ball. That
+ is the form the whole visual corpus keeps returning to, and it is also
+ the more honest picture: what the tool gives you is an opening onto
+ something, with the light at its edge.
+
+ The flare is an anamorphic streak, white where the light is hottest and
+ splitting to red at one end and blue at the other, which is what a lens
+ does at the edge of a bright source. It is the single hot mark the art
+ is allowed and the only place the full spectrum appears.
+ """
+ ticks = "".join(
+ f''
+ for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)))
+ return (
+ f''
+ f''
+ f''
+ f''
+ f''
+ f''
+ f''
+ # The reticle: a measured circle and four ticks, so the aperture
+ # reads as something being observed rather than admired.
+ f''
+ f''
+ f"{ticks}")
+
+
+def _defs(accent: str, cool: str, seed: int) -> str:
+ """Gradients, the scanline screen, the grain, and the wash that keeps
+ text legible.
+
+ feTurbulence carries an explicit seed for the same reason the corona
+ does: an unseeded filter is a different image on every render, and a
+ test that compares bytes would never pass twice.
+ """
+ return (
+ ""
+ f''
+ ''
+ ''
+ f''
+ f''
+ f''
+ f''
+ f''
+ ''
+ ''
+ f''
+ f''
+ # The refraction: red at one end, white where it is hottest, blue at
+ # the other. One mark, and the only full spectrum in the kit.
+ ''
+ ''
+ ''
+ ''
+ ''
+ ''
+ ''
+ f''
+ f''
+ f''
+ ''
+ f''
+ ''
+ ''
+ ''
+ "")
+
+
+def header_svg(spec: dict) -> str:
+ """The identity card. 1280x340, the proportion a README header wants."""
+ name = spec["name"]
+ seed = seed_for(name)
+ rng = random.Random(seed)
+ accent, cool = _hue_pair(rng)
+ cx, cy = 1012.0, 168.0
+ words = " / ".join(w.upper() for w in spec.get("words", []))
+ meta = f'{spec.get("publisher", "ZENTROPY LABS")} / {spec["role"].upper()}'
+ return (
+ f'")
diff --git a/scripts/repo_flow.py b/scripts/repo_flow.py
new file mode 100644
index 0000000..f858fde
--- /dev/null
+++ b/scripts/repo_flow.py
@@ -0,0 +1,169 @@
+"""repo_flow.py -- a workflow diagram rendered from a spec, not hand-placed.
+
+The picture a reader actually needs is what happens to one piece of work as it
+moves through the tool: what it passes through, what can send it back, and what
+it ends up as. This draws that from a list of stages, so the diagram is data in
+the repository and stays correctable by editing a sentence rather than by
+nudging coordinates in a drawing program.
+
+Color says one thing here and nothing else. The forward path is the verified
+green, the edge that sends work back is the drift iris, and everything that is
+merely structure is a hairline. Both a light and a dark palette are defined and
+the reader's own setting picks between them, so the diagram is legible in a
+README either way without shipping two files.
+
+Cards carry a 3px corner. A full round would read as a capsule, which is the
+default shape of every generated interface and says nothing about what the
+thing is; a small radius reads as drawn.
+"""
+from __future__ import annotations
+
+from repo_art import GROTESK, MONO, _esc, _num
+
+W = 960
+PAD = 44
+GAP = 26
+CARD_H = 96
+PER_ROW = 4
+ROW_GAP = 96
+
+# The two palettes, matching the tokens the repository's existing schematics
+# already use so the whole set reads as one hand.
+STYLE = """
+ :root{ --void:#f4f3ef; --bone:#0b0c0e; --muted:#43474e;
+ --hairline:rgba(11,12,14,.16); --card:rgba(255,255,255,.66);
+ --verified:#1f7a52; --drift:#3a2bd6; }
+ @media (prefers-color-scheme: dark){
+ :root{ --void:#0b0e0f; --bone:#eef1ee; --muted:#9aa39c;
+ --hairline:rgba(238,241,238,.18); --card:rgba(255,255,255,.05);
+ --verified:#5fae93; --drift:#a99cf5; } }
+ .bg{ fill:var(--void); }
+ .card{ fill:var(--card); stroke:var(--hairline); stroke-width:1.4; }
+ .n{ fill:var(--bone); font-size:15px; font-weight:650; }
+ .s{ fill:var(--muted); font-size:11.5px; }
+ .k{ fill:var(--muted); font-size:11px; letter-spacing:.16em; }
+ .h{ fill:var(--bone); font-size:21px; font-weight:700; }
+ .fwd{ stroke:var(--verified); stroke-width:2; fill:none; }
+ .back{ stroke:var(--drift); stroke-width:1.8; fill:none; stroke-dasharray:5 4; }
+ .thin{ stroke:var(--hairline); stroke-width:1.4; fill:none; }
+ .step{ fill:var(--muted); font-size:11px; font-weight:700; letter-spacing:.1em; }
+"""
+
+
+def _wrap(text: str, width: int = 30) -> list[str]:
+ lines: list[str] = []
+ line = ""
+ for word in text.split():
+ candidate = f"{line} {word}".strip()
+ if len(candidate) > width and line:
+ lines.append(line)
+ line = word
+ else:
+ line = candidate
+ if line:
+ lines.append(line)
+ return lines[:3]
+
+
+def _card_box(index: int) -> tuple[float, float, float]:
+ """Left edge, top edge and width for the card at `index`."""
+ width = (W - PAD * 2 - GAP * (PER_ROW - 1)) / PER_ROW
+ row, col = divmod(index, PER_ROW)
+ return (PAD + col * (width + GAP), 110 + row * (CARD_H + ROW_GAP), width)
+
+
+def _card(index: int, stage: dict) -> str:
+ x, y, w = _card_box(index)
+ notes = "".join(
+ f''
+ f"{_esc(line)}"
+ for i, line in enumerate(_wrap(stage.get("note", ""))))
+ return (f''
+ f''
+ f"{index + 1:02d}"
+ f''
+ f'{_esc(stage["title"])}{notes}')
+
+
+def _forward(index: int) -> str:
+ """The edge from card `index` to card `index + 1`."""
+ x0, y0, w = _card_box(index)
+ x1, y1, _ = _card_box(index + 1)
+ mid0, mid1 = y0 + CARD_H / 2, y1 + CARD_H / 2
+ if y0 == y1:
+ return (f'')
+ # The wrap between rows, routed through the gutter so it never crosses a
+ # card: out to the right margin, back across the empty band, then in.
+ gut = _num(y0 + CARD_H + ROW_GAP - 26)
+ return (f'')
+
+
+def _return(edge: dict) -> str:
+ """A dashed edge that sends work back, dipping below the row it leaves."""
+ x0, y0, w0 = _card_box(edge["from"])
+ x1, y1, w1 = _card_box(edge["to"])
+ dip = y0 + CARD_H + 22
+ label_x = (x0 + w0 / 2 + x1 + w1 / 2) / 2
+ return (f''
+ f''
+ f'{_esc(edge["label"])}')
+
+
+def _outcomes(items: list[dict], top: float, source: int) -> str:
+ x0, y0, w0 = _card_box(source)
+ span = (W - PAD * 2 - GAP * (len(items) - 1)) / len(items)
+ trunk = x0 + w0 / 2
+ tone = {"verified": "var(--verified)", "drift": "var(--drift)",
+ "none": "var(--muted)"}
+ out = [f'']
+ for i, item in enumerate(items):
+ x = PAD + i * (span + GAP)
+ out.append(
+ f''
+ f''
+ f'{_esc(item["label"])}'
+ f'{_esc(item["note"])}')
+ return "".join(out)
+
+
+def flow_svg(spec: dict) -> str:
+ """Stages, the edges between them, and what the work ends up as."""
+ stages = spec["stages"]
+ rows = (len(stages) + PER_ROW - 1) // PER_ROW
+ body = 110 + rows * CARD_H + (rows - 1) * ROW_GAP
+ top = body + 74
+ height = top + 46 + 46
+ cards = "".join(_card(i, s) for i, s in enumerate(stages))
+ edges = "".join(_forward(i) for i in range(len(stages) - 1))
+ backs = "".join(_return(e) for e in spec.get("returns", []))
+ ends = _outcomes(spec["outcomes"], top, len(stages) - 1)
+ return (
+ f'")
diff --git a/tests/test_readiness.py b/tests/test_readiness.py
index 8c9af10..8355400 100644
--- a/tests/test_readiness.py
+++ b/tests/test_readiness.py
@@ -23,8 +23,12 @@ def test_flagship_brand_assets_exist_and_are_referenced():
"examples/crucible-demo.html",
]:
assert (root / rel).exists(), rel
+ # The banner is the 1280x640 social-preview image, uploaded through the
+ # repository settings rather than linked from prose. What the README shows
+ # a reader is the generated header and the thesis-lifecycle diagram.
for rel in [
- ".github/assets/zentropy-banner.png",
+ "docs/art/crucible-header.svg",
+ "docs/art/thesis-lifecycle.svg",
"examples/crucible-demo.html",
]:
assert rel in readme, rel
diff --git a/tests/test_repo_art.py b/tests/test_repo_art.py
new file mode 100644
index 0000000..d76889c
--- /dev/null
+++ b/tests/test_repo_art.py
@@ -0,0 +1,154 @@
+"""The front-page artwork stays true to the words it illustrates.
+
+A picture in a README is never diffed, so it drifts from the text silently:
+somebody edits a stage name, nobody re-renders, and the diagram now describes
+a version of the tool that no longer exists. Here the picture is a pure
+function of a spec that IS diffable, and this re-renders it and compares
+bytes.
+
+The truncation test is the one that earns its place. Card notes are wrapped to
+three lines and the wrapper drops the rest, so an edited sentence can lose its
+ending in the drawing while reading fine in the spec.
+"""
+import importlib.util
+import json
+import re
+import sys
+from pathlib import Path
+
+import pytest
+
+ROOT = Path(__file__).resolve().parent.parent
+ART = ROOT / "docs" / "art"
+SCRIPTS = ROOT / "scripts"
+
+
+def _load(name):
+ # repo_flow imports repo_art by bare name, the way the renderer runs it.
+ if str(SCRIPTS) not in sys.path:
+ sys.path.insert(0, str(SCRIPTS))
+ spec = importlib.util.spec_from_file_location(
+ name, SCRIPTS / f"{name}.py")
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+R = _load("repo_art")
+FLOW = _load("repo_flow")
+RENDER = _load("render_repo_art")
+
+SPECS = sorted(ART.glob("*.art.json"))
+
+
+def test_there_is_at_least_one_spec():
+ assert SPECS, "docs/art holds no *.art.json spec"
+
+
+@pytest.mark.parametrize("spec_path", SPECS, ids=lambda p: p.name)
+def test_committed_artwork_matches_its_spec(spec_path):
+ for path, text in RENDER.rendered(spec_path).items():
+ assert path.exists(), f"{path.name} was never rendered"
+ assert path.read_text(encoding="utf-8") == text + "\n", (
+ f"{path.name} is stale; run python scripts/render_repo_art.py")
+
+
+@pytest.mark.parametrize("spec_path", SPECS, ids=lambda p: p.name)
+def test_rendering_twice_gives_the_same_bytes(spec_path):
+ """The corona is random draws. Seeded ones, or this fails."""
+ assert RENDER.rendered(spec_path) == RENDER.rendered(spec_path)
+
+
+def test_each_repository_gets_its_own_drawing():
+ """The identity claim, checked rather than asserted in a doc."""
+ names = ["gather", "flywheel", "crucible", "index", "forum", "telos"]
+ marks = {n: R.header_svg(
+ {"name": n, "role": "x", "tagline": "y", "words": ["z"]}) for n in names}
+ assert len({R.seed_for(n) for n in names}) == len(names)
+ bodies = {n: re.sub(r"[A-Z]{3,}", "", svg) for n, svg in marks.items()}
+ assert len(set(bodies.values())) == len(names), "two repositories drew alike"
+
+
+def test_the_seed_is_recorded_on_the_mark():
+ """A generated mark carries the seed that made it."""
+ svg = R.header_svg({"name": "crucible", "role": "x", "tagline": "y",
+ "words": []})
+ assert f"SEED {R.seed_for('crucible') % 100000:05d}" in svg
+
+
+def test_no_local_paths_or_em_dashes_in_the_art():
+ for path in sorted(ART.glob("*.svg")):
+ text = path.read_text(encoding="utf-8")
+ assert "\u2014" not in text, f"{path.name} carries an em-dash"
+ assert not re.search(r"[A-Z]:[\/]", text), f"{path.name} names a path"
+
+
+def _specs():
+ return [json.loads(p.read_text(encoding="utf-8")) for p in SPECS]
+
+
+def test_the_spec_words_reach_the_drawing():
+ """Guards against a diagram that renders but silently drops content."""
+ for spec_path in SPECS:
+ spec = json.loads(spec_path.read_text(encoding="utf-8"))
+ rendered = "".join(RENDER.rendered(spec_path).values())
+ assert spec["header"]["tagline"] in rendered
+ for flow in spec.get("flows", []):
+ for stage in flow["stages"]:
+ assert stage["title"] in rendered, stage["title"]
+
+
+def test_no_card_note_loses_its_ending_to_the_wrapper():
+ for spec in _specs():
+ for flow in spec.get("flows", []):
+ for stage in flow["stages"]:
+ note = stage["note"]
+ drawn = " ".join(FLOW._wrap(note))
+ assert drawn == " ".join(note.split()), (
+ f'{stage["title"]}: the drawing cuts off at "{drawn}"')
+
+
+def test_a_return_edge_stays_on_its_own_row():
+ """A backward edge is routed under the row it leaves, so a cross-row one
+ would be drawn straight through whatever cards sit in between."""
+ for spec in _specs():
+ for flow in spec.get("flows", []):
+ for edge in flow.get("returns", []):
+ assert edge["from"] // FLOW.PER_ROW == edge["to"] // FLOW.PER_ROW, (
+ f'return {edge["from"]}->{edge["to"]} crosses a row break')
+
+
+# Where an illustration lives. `.github/assets/` and `docs/brand/` are
+# deliberately outside this set: they hold the 1280x640 social-preview source,
+# which is uploaded through the repository settings rather than linked from
+# prose. Their presence is covered by tests/test_readiness.py.
+SHOWN_DIRS = ("docs/art",)
+
+
+def test_every_illustration_is_actually_shown_to_a_reader():
+ """No orphans. An image nobody links to is an image nobody sees."""
+ readme = (ROOT / "README.md").read_text(encoding="utf-8")
+ docs = "".join(p.read_text(encoding="utf-8", errors="ignore")
+ for p in ROOT.glob("docs/**/*.md"))
+ haystack = readme + docs
+ images = sorted(p for d in SHOWN_DIRS for p in (ROOT / d).glob("*")
+ if p.suffix.lower() in {".svg", ".png"})
+ orphans = [str(p.relative_to(ROOT)).replace("\\", "/")
+ for p in images
+ if str(p.relative_to(ROOT)).replace("\\", "/") not in haystack]
+ assert not orphans, f"committed but never shown: {orphans}"
+
+
+# The tagline is one unwrapped line of 21px text starting at x=66, under a rule
+# that ends at x=700. Past that it runs on toward the aperture and stops being
+# readable, and nothing about the render fails. This is a guardrail at the
+# widest tagline that has been looked at on a rendered page, not a typographic
+# measurement: it counts characters, so it cannot tell "mmmm" from "iiii".
+TAGLINE_BUDGET = 70
+
+
+def test_the_tagline_stays_inside_its_rule():
+ for spec in _specs():
+ tagline = spec["header"]["tagline"]
+ assert len(tagline) <= TAGLINE_BUDGET, (
+ f"{len(tagline)} characters runs past the rule: {tagline!r}")