From 90318952f66ceda7333a030072eb40a0bbce8f40 Mon Sep 17 00:00:00 2001 From: Zain Dana Harper <17142659+HarperZ9@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:16:40 -0700 Subject: [PATCH] Draw the thesis lifecycle, and give crucible its own generated header The README explained the engine in prose and showed nothing. A reader had to assemble the run from a feature list. This adds two pictures and the machinery that keeps them true. `docs/art/crucible.art.json` is the source of truth. Both SVGs are pure functions of it, so `tests/test_repo_art.py` re-renders and compares bytes: artwork that stops matching its own words fails the suite instead of drifting quietly, which is what happens to any picture in a README. The diagram is grounded in `run_cmd._run_once` and `refine`, not in the highlights list. Two edges carry it. The loop back from Cohesion to Measure is the refine step: grading yields a margin per claim, the harmonic mean is the cohesion, and a round that is not cohesive names its weakest axis and measures that axis again. The edge that does not loop is `verdict_for`, a pure function of a deviation and a tolerance with no model in it. The header seeds its corona from sha256 of the repository name, so crucible draws a different aperture from flywheel and gather while keeping one form. New gate: `test_the_tagline_stays_inside_its_rule`. The header tagline is one unwrapped line under a rule that ends at x=700, and a longer one runs on toward the aperture and stops being readable with nothing failing. The bound counts characters, so it is a guardrail at the widest tagline that has been looked at on a rendered page, not a typographic measurement. `.github/assets/zentropy-banner.png` stays in the repository as the 1280x640 social-preview source, uploaded through repository settings. It is no longer the README's first image, so `test_readiness` now requires the two generated files in the README instead. ruff, mypy and 372 tests pass. Co-Authored-By: Claude Opus 5 --- README.md | 17 ++- docs/art/crucible-header.svg | 1 + docs/art/crucible.art.json | 36 +++++ docs/art/thesis-lifecycle.svg | 19 +++ scripts/render_repo_art.py | 73 +++++++++ scripts/repo_art.py | 276 ++++++++++++++++++++++++++++++++++ scripts/repo_flow.py | 169 +++++++++++++++++++++ tests/test_readiness.py | 6 +- tests/test_repo_art.py | 154 +++++++++++++++++++ 9 files changed, 749 insertions(+), 2 deletions(-) create mode 100644 docs/art/crucible-header.svg create mode 100644 docs/art/crucible.art.json create mode 100644 docs/art/thesis-lifecycle.svg create mode 100644 scripts/render_repo_art.py create mode 100644 scripts/repo_art.py create mode 100644 scripts/repo_flow.py create mode 100644 tests/test_repo_art.py diff --git a/README.md b/README.md index eb6ba48..2d534b3 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -

crucible: A judgment engine: register a thesis, steelman each claim, measure against a substrate, refine the weakest axis.

+

crucible: register a thesis, measure each claim, name the weakest axis.

**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. + +

Eight stages from a registered thesis to a self-contained review packet: thesis, steelman, measure, cohesion, verdict, registry, recheck, packet. 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.

+ +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 @@ +ZENTROPY LABS / ADVERSARIAL CLAIM MEASUREMENTCRUCIBLERegister a thesis, measure each claim, name the weakest axis.THESIS / STEELMAN / MEASURE / VERDICT / REFINESEED 94777 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 @@ +ONE THESIS, END TO ENDHow a claim earns its standing, and how it loses itNOT COHESIVE: THE WEAKEST AXIS IS RE-MEASURED01ThesisA claim set under one seal,each with what would refuteit.02SteelmanThe strongest test is proposedbefore anything is measured.03MeasureEach claim meets a substrate:a deviation, and itstolerance.04CohesionThe harmonic mean of margins,so one weak axis drags itdown.05VerdictA pure function of deviationand tolerance. No model here.06RegistryThe assessment is stored undera sha256 receipt with theseal.07RecheckRows are re-read from disk andthe verdicts recompute.08PacketA bundle that carries its ownzero-import verifier.MATCHthe measurement sits inside toleranceDRIFTthe measurement sits outside itUNVERIFIABLEthe axis could not be measured at allThe verdict recomputes from the stored record, so a confident assertion cannot move it. 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'' + f"{_defs(accent, cool, seed)}" + f'' + f'{_rings(cx, cy, rng)}{_spokes(cx, cy, rng)}' + f'{_blades(cx, cy, rng, accent)}{_core(cx, cy, cool)}' + # Scanline screen, then grain. Both are whispers: they sit the art + # behind glass instead of decorating it. + f'' + f'' + f'' + f'' + f'{_esc(meta)}' + f'' + f"{_esc(name.upper())}" + f'{_esc(spec["tagline"])}' + f'' + f'{_esc(words)}' + f'SEED {seed % 100000:05d}' + "") 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'' + f"" + '' + '' + f'' + f'' + f'{_esc(spec["kicker"].upper())}' + f'{_esc(spec["title"])}' + f"{edges}{backs}{cards}{ends}" + f'' + f'{_esc(spec["footnote"])}' + "") 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}")