diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..18454b2 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# The artwork under docs/art/ is generated, and a gate compares the committed +# bytes to a fresh render. Normalizing line endings per platform would make that +# comparison depend on who checked the repository out. +docs/art/*.svg text eol=lf +docs/art/*.json text eol=lf diff --git a/README.md b/README.md index c0329f2..cdf35ab 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -

plexus

+

plexus: toolchain wiring discovery. Point it at your tools and it computes how they plug together.

# plexus @@ -29,6 +29,8 @@ consumable mid-task, not just from a human's terminal. How it compares to MCP / LangGraph / Dagster / CrewAI: see [COMPARISON.md](COMPARISON.md). plexus is the discovery layer that sits *above* an executor, not another executor. +

Eight stages from manifest to verify, ending in still holds or drifted.

+ ## The problem You wire up five tools. Each one produces artifacts and accepts inputs, but @@ -90,6 +92,12 @@ tampered plan is caught, and a tool whose manifest changed since the plan makes drift **visible** instead of letting it silently shift under you. Exit non-zero on drift, so it works as a CI check over your toolchain's wiring. +Two things make that check worth running. `verify` rebuilds the receipt from the +plan it just re-derived, not from the one saved in the file, so editing the saved +body cannot make it agree with itself. And the receipt carries a method version +that has to match before anything else is compared, so a plan written by an older +plexus is reported as failing rather than silently re-interpreted under new rules. + ## How a tool plugs in A manifest is plain JSON. A tool ships one and it joins the mesh. Drop @@ -171,6 +179,15 @@ plexus is also honest about what does **not** connect: - `discover().collisions`: organ ids declared by more than one manifest, named rather than silently resolved last-writer-wins. +

Eight stages from a tool set to a report, ending in wired, colliding, or unmet.

+ +An unmet input is a capability something consumes that nothing in the set emits, +and an unconsumed output is the mirror of it. Both fall out of the same +comparison, so neither is a special case someone remembered to write. The set is +also exactly what you handed it: plexus reads the manifests present and reasons +about nothing else, which is why an unmet input means only that no manifest here +produces it, not that no such tool exists. + ## Receipt `plexus discover` stamps a `receipt` on its output: the plexus version, a UTC diff --git a/docs/art/honesty-lane.svg b/docs/art/honesty-lane.svg new file mode 100644 index 0000000..1b8cef0 --- /dev/null +++ b/docs/art/honesty-lane.svg @@ -0,0 +1,19 @@ +ONE MESH, AND WHAT IT WILL NOT PRETENDHow the parts that do not connect get named instead of hiddenEACH LEFTOVER IS NAMED, ONE BY ONE01Tool setThe manifests present, andnothing else.02EmitsWhat each one declares that itproduces.03ConsumesWhat each one declares that itneeds.04MatchA name on both sides becomesone edge.05LeftoverEvery name that matched on oneside only.06LoopAn edge set that returns whereit started.07CollisionOne organ id claimed by twomanifests.08ReportNamed in the output, neverquietly resolved.WIREDevery input has a producer in the setCOLLIDINGtwo manifests claim the same organ idUNMETan input nothing in the set emitsEvery edge is declared by its producer. Nothing here imports or runs the source it cites. diff --git a/docs/art/plexus-header.svg b/docs/art/plexus-header.svg new file mode 100644 index 0000000..1edfaec --- /dev/null +++ b/docs/art/plexus-header.svg @@ -0,0 +1 @@ +ZENTROPY LABS / TOOLCHAIN WIRING DISCOVERYPLEXUSPoint it at your tools and it computes how they plug together.EMIT / CONSUME / WIRE / PLAN / DECLARESEED 56310 diff --git a/docs/art/plexus.art.json b/docs/art/plexus.art.json new file mode 100644 index 0000000..a3ebe5a --- /dev/null +++ b/docs/art/plexus.art.json @@ -0,0 +1,141 @@ +{ + "header": { + "name": "plexus", + "role": "toolchain wiring discovery", + "tagline": "Point it at your tools and it computes how they plug together.", + "words": [ + "emit", + "consume", + "wire", + "plan", + "declare" + ] + }, + "flows": [ + { + "file": "wiring-lane.svg", + "kicker": "one tool set, one runnable order", + "title": "How a pile of tools becomes a plan you can check later", + "footnote": "Verify re-derives the plan from the manifests. The saved body is never trusted.", + "alt": "Eight stages from manifest to verify: manifest, discover, capability, wiring, goal, plan, receipt, verify. Each tool ships a small manifest naming what it emits and what it consumes. Discovery reads every manifest and hashes its canonical content. A capability is a typed name, and an edge exists wherever one tool emits a name another consumes. Naming a goal produces a dependency order with any feedback loops reported rather than forced flat. The receipt binds the plan to the content hash of every manifest behind it plus a hash over the plan body. Verify re-derives the plan from the live mesh and compares receipts, so a tampered body and a manifest that changed both fail. Two outcomes: still holds, and drifted.", + "stages": [ + { + "title": "Manifest", + "note": "Each tool ships one: what it emits, what it needs." + }, + { + "title": "Discover", + "note": "Every manifest read, and its content hashed." + }, + { + "title": "Capability", + "note": "A typed name, on both sides of an edge." + }, + { + "title": "Wiring", + "note": "An edge wherever a name matches across tools." + }, + { + "title": "Goal", + "note": "The tool you want fed, named on the command line." + }, + { + "title": "Plan", + "note": "An order over the graph, with cycles reported." + }, + { + "title": "Receipt", + "note": "The plan hash, and every manifest hash behind it." + }, + { + "title": "Verify", + "note": "Re-derive from the mesh, then compare receipts." + } + ], + "returns": [ + { + "from": 7, + "to": 5, + "label": "VERIFY NEVER TRUSTS THE SAVED PLAN BODY" + } + ], + "outcomes": [ + { + "label": "STILL HOLDS", + "note": "the same manifests re-derive the same plan", + "tone": "verified" + }, + { + "label": "DRIFTED", + "note": "a manifest moved under the saved plan", + "tone": "drift" + } + ] + }, + { + "file": "honesty-lane.svg", + "kicker": "one mesh, and what it will not pretend", + "title": "How the parts that do not connect get named instead of hidden", + "footnote": "Every edge is declared by its producer. Nothing here imports or runs the source it cites.", + "alt": "Eight stages of the honesty surface: tool set, emits, consumes, match, leftover, loop, collision, report. Only the manifests present are considered. A capability name appearing on both an emit and a consume side becomes an edge. Every name that matched on only one side is a leftover: an unmet input that nothing in the set produces, or an output nobody downstream takes. A cycle is surfaced as a feedback loop rather than forced into a false linear order, and an organ id claimed by two manifests is named as a collision rather than resolved last writer wins. Three outcomes: wired, colliding, and unmet.", + "stages": [ + { + "title": "Tool set", + "note": "The manifests present, and nothing else." + }, + { + "title": "Emits", + "note": "What each one declares that it produces." + }, + { + "title": "Consumes", + "note": "What each one declares that it needs." + }, + { + "title": "Match", + "note": "A name on both sides becomes one edge." + }, + { + "title": "Leftover", + "note": "Every name that matched on one side only." + }, + { + "title": "Loop", + "note": "An edge set that returns where it started." + }, + { + "title": "Collision", + "note": "One organ id claimed by two manifests." + }, + { + "title": "Report", + "note": "Named in the output, never quietly resolved." + } + ], + "returns": [ + { + "from": 7, + "to": 4, + "label": "EACH LEFTOVER IS NAMED, ONE BY ONE" + } + ], + "outcomes": [ + { + "label": "WIRED", + "note": "every input has a producer in the set", + "tone": "verified" + }, + { + "label": "COLLIDING", + "note": "two manifests claim the same organ id", + "tone": "drift" + }, + { + "label": "UNMET", + "note": "an input nothing in the set emits", + "tone": "none" + } + ] + } + ] +} diff --git a/docs/art/wiring-lane.svg b/docs/art/wiring-lane.svg new file mode 100644 index 0000000..e7432c2 --- /dev/null +++ b/docs/art/wiring-lane.svg @@ -0,0 +1,19 @@ +ONE TOOL SET, ONE RUNNABLE ORDERHow a pile of tools becomes a plan you can check laterVERIFY NEVER TRUSTS THE SAVED PLAN BODY01ManifestEach tool ships one: what itemits, what it needs.02DiscoverEvery manifest read, and itscontent hashed.03CapabilityA typed name, on both sides ofan edge.04WiringAn edge wherever a namematches across tools.05GoalThe tool you want fed, namedon the command line.06PlanAn order over the graph, withcycles reported.07ReceiptThe plan hash, and everymanifest hash behind it.08VerifyRe-derive from the mesh, thencompare receipts.STILL HOLDSthe same manifests re-derive the same planDRIFTEDa manifest moved under the saved planVerify re-derives the plan from the manifests. The saved body is never trusted. diff --git a/tests/test_repo_art.py b/tests/test_repo_art.py new file mode 100644 index 0000000..2b1e63f --- /dev/null +++ b/tests/test_repo_art.py @@ -0,0 +1,71 @@ +"""The README's diagrams are generated from a spec, so they can go stale the way any +other derived file goes stale: somebody edits a stage name, nobody re-renders, and the +picture describes a version of plexus that no longer exists. The gate re-renders from +the spec and compares bytes. This runs the gate under pytest and asserts on its receipt, +so a drifted drawing fails the suite instead of quietly shipping.""" + +import json +import subprocess +import sys +from pathlib import Path + +_REPO = Path(__file__).resolve().parents[1] +_GATE = _REPO / "tools" / "check_repo_art.py" + +GATES = ( + "spec.present", + "art.matches_spec", + "art.render_is_deterministic", + "art.identity_per_repository", + "art.seed_is_recorded", + "art.no_local_paths_or_em_dashes", + "art.spec_words_reach_the_drawing", + "art.note_survives_the_wrapper", + "art.return_edge_stays_on_its_row", + "art.every_illustration_is_shown", + "art.tagline_stays_inside_its_rule", + "art.outcome_fits_its_box", +) + +DRAWINGS = ( + "docs/art/plexus-header.svg", + "docs/art/wiring-lane.svg", + "docs/art/honesty-lane.svg", +) + + +def _receipt() -> dict: + out = subprocess.run([sys.executable, str(_GATE), "--json"], + cwd=_REPO, capture_output=True, text=True) + assert out.returncode == 0, out.stdout + out.stderr + return json.loads(out.stdout) + + +def test_every_gate_passes_and_the_receipt_names_what_it_ran(): + receipt = _receipt() + assert receipt["schema"] == "plexus.repo-art/v1" + assert [c["name"] for c in receipt["checks"]] == list(GATES) + assert all(c["passed"] for c in receipt["checks"]), \ + [c for c in receipt["checks"] if not c["passed"]] + + +def test_both_diagrams_and_the_mark_are_accounted_for(): + receipt = _receipt() + assert receipt["specs"] == ["docs/art/plexus.art.json"] + drawn = {out["file"]: out for out in receipt["outputs"]} + assert set(drawn) == set(DRAWINGS) + for path, out in drawn.items(): + assert len(out["sha256"]) == 64, path + assert out["bytes"] > 0, path + + +def test_a_gate_that_cannot_fail_is_not_a_gate(tmp_path, monkeypatch): + """Point the outcome-box check at a note too wide for its box and it has to + complain. Without this, a green suite proves only that the gate ran.""" + sys.path.insert(0, str(_REPO / "tools")) + import check_repo_art as gate + spec = json.loads((_REPO / "docs" / "art" / "plexus.art.json").read_text("utf-8")) + spec["flows"][0]["outcomes"][0]["note"] = "x" * 80 + (tmp_path / "plexus.art.json").write_text(json.dumps(spec), encoding="utf-8") + monkeypatch.setattr(gate, "ART", tmp_path) + assert len(gate.check_outcome_fits_its_box([])) == 1 diff --git a/tools/check_repo_art.py b/tools/check_repo_art.py new file mode 100644 index 0000000..3d70deb --- /dev/null +++ b/tools/check_repo_art.py @@ -0,0 +1,264 @@ +"""Check that the front-page artwork still tells the truth, and say so in a receipt. + + python tools/check_repo_art.py # a readable summary + python tools/check_repo_art.py --json # the same run as a receipt + +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, so this re-renders it and compares bytes. + +The sibling repositories run these same gates from their own test runners. Here +the gates run as a script and emit a receipt, and tests/test_repo_art.py asserts +on that receipt under pytest, so `python -m pytest -q` covers the front page. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import render_repo_art as RENDER # noqa: E402 +import repo_art as ART_LIB # noqa: E402 +import repo_flow as FLOW # noqa: E402 + +ROOT = Path(__file__).resolve().parents[1] +ART = ROOT / "docs" / "art" +SCHEMA = "plexus.repo-art/v1" + +# The widest tagline that has been looked at on a rendered page. It counts +# characters rather than measuring glyphs, so it cannot tell "mmmm" from +# "iiii": a guardrail, not a typographic fact. +TAGLINE_BUDGET = 70 + +# Where an illustration lives. .github/assets/ and docs/brand/ are deliberately +# outside this set: they hold the social-preview source and the flagship heroes, +# which other gates already cover. +SHOWN_DIRS = ("docs/art",) + +EM_DASH = "—" + + +def _rel(path: Path) -> str: + return str(path.relative_to(ROOT)).replace("\\", "/") + + +def _specs() -> list[Path]: + return sorted(ART.glob("*.art.json")) + + +def _loaded() -> list[dict]: + return [json.loads(p.read_text(encoding="utf-8")) for p in _specs()] + + +def check_spec_present(specs: list[Path]) -> list[str]: + return [] if specs else ["docs/art holds no *.art.json spec"] + + +def check_artwork_matches_spec(specs: list[Path]) -> list[str]: + bad = [] + for spec_path in specs: + for path, text in RENDER.rendered(spec_path).items(): + if not path.exists(): + bad.append(f"{_rel(path)} was never rendered") + elif path.read_text(encoding="utf-8") != text + "\n": + bad.append(f"{_rel(path)} is stale; run python tools/render_repo_art.py") + return bad + + +def check_render_is_deterministic(specs: list[Path]) -> list[str]: + """The corona is random draws. Seeded ones, or this fails.""" + return [f"{p.name} renders differently every time" + for p in specs if RENDER.rendered(p) != RENDER.rendered(p)] + + +def check_identity_per_repository(_unused: list[Path]) -> list[str]: + """The identity claim, checked rather than asserted in a doc.""" + names = ["gather", "flywheel", "crucible", "index", "forum", "telos", + "learn", "emet", "relay", "mneme", "plexus"] + marks = {n: ART_LIB.header_svg( + {"name": n, "role": "x", "tagline": "y", "words": ["z"]}) for n in names} + bad = [] + if len({ART_LIB.seed_for(n) for n in names}) != len(names): + bad.append("two repositories share a seed") + bodies = {n: re.sub(r"[A-Z]{3,}", "", svg) for n, svg in marks.items()} + if len(set(bodies.values())) != len(names): + bad.append("two repositories drew alike") + return bad + + +def check_seed_is_recorded(_unused: list[Path]) -> list[str]: + """A generated mark carries the seed that made it.""" + svg = ART_LIB.header_svg( + {"name": "plexus", "role": "x", "tagline": "y", "words": []}) + stamp = f"SEED {ART_LIB.seed_for('plexus') % 100000:05d}" + return [] if stamp in svg else ["the mark does not record its own seed"] + + +def check_no_local_paths_or_em_dashes(_unused: list[Path]) -> list[str]: + bad = [] + for path in sorted(ART.glob("*.svg")): + text = path.read_text(encoding="utf-8") + if EM_DASH in text: + bad.append(f"{path.name} carries an em-dash") + if re.search(r"[A-Z]:[\\/]", text): + bad.append(f"{path.name} names a local path") + return bad + + +def check_spec_words_reach_the_drawing(specs: list[Path]) -> list[str]: + """Guards against a diagram that renders but silently drops content.""" + bad = [] + for spec_path in specs: + spec = json.loads(spec_path.read_text(encoding="utf-8")) + drawn = "".join(RENDER.rendered(spec_path).values()) + if spec["header"]["tagline"] not in drawn: + bad.append(f"{spec_path.name}: the tagline never reaches the drawing") + for flow in spec.get("flows", []): + for stage in flow["stages"]: + if stage["title"] not in drawn: + bad.append(f'{spec_path.name}: {stage["title"]} is missing') + return bad + + +def check_note_survives_the_wrapper(_unused: list[Path]) -> list[str]: + """Card notes wrap 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.""" + bad = [] + for spec in _loaded(): + for flow in spec.get("flows", []): + for stage in flow["stages"]: + drawn = " ".join(FLOW._wrap(stage["note"])) + if drawn != " ".join(stage["note"].split()): + bad.append(f'{stage["title"]}: the drawing cuts off at "{drawn}"') + return bad + + +def check_return_edge_stays_on_its_row(_unused: list[Path]) -> list[str]: + """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.""" + bad = [] + for spec in _loaded(): + for flow in spec.get("flows", []): + for edge in flow.get("returns", []): + if edge["from"] // FLOW.PER_ROW != edge["to"] // FLOW.PER_ROW: + bad.append( + f'return {edge["from"]}->{edge["to"]} crosses a row break') + return bad + + +def check_every_illustration_is_shown(_unused: list[Path]) -> list[str]: + """No orphans. An image nobody links to is an image nobody sees.""" + haystack = (ROOT / "README.md").read_text(encoding="utf-8") + haystack += "".join(p.read_text(encoding="utf-8", errors="ignore") + for p in ROOT.glob("docs/**/*.md")) + images = sorted(p for d in SHOWN_DIRS for p in (ROOT / d).glob("*") + if p.suffix.lower() in {".svg", ".png"}) + return [f"committed but never shown: {_rel(p)}" + for p in images if _rel(p) not in haystack] + + +def check_tagline_stays_inside_its_rule(_unused: list[Path]) -> list[str]: + """The tagline is one unwrapped line under a rule that ends at x=700. Past + that it runs on toward the aperture and nothing about the render fails.""" + bad = [] + for spec in _loaded(): + tagline = spec["header"]["tagline"] + if len(tagline) > TAGLINE_BUDGET: + bad.append(f"{len(tagline)} characters runs past the rule: {tagline!r}") + return bad + + +def _outcome_budgets(count: int) -> tuple[int, int]: + """Label and note budgets for one box in a band of `count` boxes.""" + span = (FLOW.W - FLOW.PAD * 2 - FLOW.GAP * (count - 1)) / count + usable = span - 14 - 10 + return int(usable / 7.0), int(usable / 5.4) + + +def check_outcome_fits_its_box(_unused: list[Path]) -> list[str]: + """An outcome box is one unwrapped label over one unwrapped note, and + neither is clipped, so an over-long note runs into the next box.""" + bad = [] + for spec in _loaded(): + for flow in spec.get("flows", []): + label_budget, note_budget = _outcome_budgets(len(flow["outcomes"])) + for item in flow["outcomes"]: + if len(item["label"]) > label_budget: + bad.append(f'{item["label"]!r} is wider than its box') + if len(item["note"]) > note_budget: + bad.append(f'the note under {item["label"]} is wider than ' + f'its box: {item["note"]!r}') + return bad + + +CHECKS = [ + ("spec.present", check_spec_present), + ("art.matches_spec", check_artwork_matches_spec), + ("art.render_is_deterministic", check_render_is_deterministic), + ("art.identity_per_repository", check_identity_per_repository), + ("art.seed_is_recorded", check_seed_is_recorded), + ("art.no_local_paths_or_em_dashes", check_no_local_paths_or_em_dashes), + ("art.spec_words_reach_the_drawing", check_spec_words_reach_the_drawing), + ("art.note_survives_the_wrapper", check_note_survives_the_wrapper), + ("art.return_edge_stays_on_its_row", check_return_edge_stays_on_its_row), + ("art.every_illustration_is_shown", check_every_illustration_is_shown), + ("art.tagline_stays_inside_its_rule", check_tagline_stays_inside_its_rule), + ("art.outcome_fits_its_box", check_outcome_fits_its_box), +] + + +def _outputs(specs: list[Path]) -> list[dict]: + seen = [] + for spec_path in specs: + for path in RENDER.rendered(spec_path): + body = path.read_bytes() if path.exists() else b"" + seen.append({ + "file": _rel(path), + "spec": _rel(spec_path), + "bytes": len(body), + "sha256": hashlib.sha256(body).hexdigest(), + }) + return sorted(seen, key=lambda item: item["file"]) + + +def receipt() -> dict: + specs = _specs() + results = [{"name": name, "passed": not failures, "failures": failures} + for name, failures in ((n, f(specs)) for n, f in CHECKS)] + return { + "schema": SCHEMA, + "mode": "check", + "specs": [_rel(p) for p in specs], + "outputs": _outputs(specs), + "checks": results, + "passed": all(item["passed"] for item in results), + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--json", action="store_true", + help="emit the run as a receipt instead of a summary") + args = parser.parse_args(argv) + report = receipt() + if args.json: + print(json.dumps(report, indent=2)) + return 0 if report["passed"] else 1 + for item in report["checks"]: + print(f"{'ok ' if item['passed'] else 'FAIL'} {item['name']}") + for failure in item["failures"]: + print(f" {failure}") + print(f"{len(report['outputs'])} files from " + f"{len(report['specs'])} spec files") + return 0 if report["passed"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/render_repo_art.py b/tools/render_repo_art.py new file mode 100644 index 0000000..03533a2 --- /dev/null +++ b/tools/render_repo_art.py @@ -0,0 +1,73 @@ +"""Render a repository's front-page artwork from its spec. + + python tools/render_repo_art.py # write the SVGs + python tools/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 tools/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/tools/repo_art.py b/tools/repo_art.py new file mode 100644 index 0000000..eba6814 --- /dev/null +++ b/tools/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/tools/repo_flow.py b/tools/repo_flow.py new file mode 100644 index 0000000..f858fde --- /dev/null +++ b/tools/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"])}' + "")