Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

.nornir/facet-wrapped-wheel.md

facet-wrapped-web — the wheel, built in a container

Lane SW (oden, tmux streamlit), row 1. 2026-09-05.

Rickard's two rules for this lane, verbatim:

"it must NOT use javascript to do graphics, it must REUSE the RUST components, packaged in wasm. We had a real bad performance a few months ago when Claude reimplemented components in JavaScript."

"make build environment so it can build pip packages, ready for release, use a CONTAINER for the horrible python language"

Both are now mechanical rather than remembered. This document says how, and what is still open.


The two commands

cargo xtask wrap-wasm      # the REAL components → wasm32, wasm-bindgen → facet-wrapped/dist/
cargo xtask publish-pip    # that bundle → a wheel, built INSIDE a container, verified back

Measured on oden, 2026-09-05, at facet-wrapped 0.1.19:

artifact bytes
facet_wrapped_bg.wasm 9 920 451
facet_wrapped.js (wasm-bindgen glue) 100 963
manifest.json (27 components) 4 682
index.html (the loader; draws nothing) 9 277

Output: pkg-facet-wrapped/dist/facet_wrapped_web-0.1.19-py3-none-any.whl + the matching sdist. Before today neither existed: facet-wrapped/dist was absent, no wheel had ever been built, nothing was published.

No python on the host

publish-pip is pure Rust on the host side. It stages bytes, drives podman through draupnir::container (the constellation's one OCI door — the REST API over bollard, the same road knut's SparkServer and dwarves' runa_backend take), copies the artifacts out, and reads the zip back. There is no Command::new("podman"), no shell script, and no python interpreter invoked on oden.

python3 -m build --wheel --sdist --no-isolation runs inside nordisk-pip-toolchain with --network none.

What this deleted

pkg-facet-wrapped/make_wheel.py — gone. It ran python -m build on the host, and it carried two independent guards against one recursion, because its predecessor build.py shadowed the build package on sys.path, re-entered itself, and forked ~10 000 processes until oden died of OOM (2026-07-31 12:53). Two guards against one bug is the tell: the host was never the place to run it. A container has its own pid namespace and a memory limit, so the same mistake kills a container, not the workstation. Both guards are unnecessary now and both are gone with the file.

It also copied the 9.9 MB wasm into pkg-facet-wrapped/src/…/assets/, a tracked source directory, on every run. publish-pip stages into target/pip-build/pkg/ instead. The repo is read from, never written to — three separate lanes lost time on 2026-09-05 to shared checkouts left dirty by someone else's build.

publish_to_holger.sh is still present, and still a LAW breach. It is deleted in row 1b, when the znippy pack + PEP 503 publish is ported into Rust. Deleting it today would remove a working capability before its replacement exists, which is a different kind of dishonest.

The wheel is read BACK — xtask::pip::verify_wheel

The point of the verb is not that podman exited 0. python -m build exits 0 on a package whose data files were never copied in; the wheel installs cleanly and renders nothing. So the produced .whl is re-opened as a plain zip and checked against its own RECORD:

That last clause is LAW 2 applied to bytes. A zero-byte wasm is present, is recorded, hashes correctly, and is not a component — "the file is there" is the identity value of this check, and the length is not.

Proven red (xtask/tests/wheel_verify.rs, 14 tests). Eleven broken wheels are built on disk and each is refused by its own name: tampered bytes at the same length, an unrecorded member, a recorded-but-absent member, a lied size with a correct hash, no RECORD, two RECORDs, each of the four assets dropped individually, a zero-byte asset, and a non-wasm file wearing the wasm name. Blinding three checks in pip.rs (if false &&) fails exactly the three tests that guard them and no others — measured 2026-09-05.

The no-JavaScript-graphics rule — xtask::jsguard

stage_package refuses to build a wheel around a loader page that draws. Banned: canvas drawing verbs (fillRect, beginPath, lineTo, fillText, drawImage, …), SVG construction (createElementNS, a literal <svg), and charting libraries by name (plotly, d3, chart.js, echarts, vega-lite).

getContext is NOT banned outright, and that is deliberate. The shipped page calls it three times, inside one function:

window.facet_canvas_api = () => {
  try { if (c.getContext("webgpu")) return "webgpu"; } catch (_) {}
  try { if (c.getContext("webgl2")) return "webgl2"; } catch (_) {}

That is not a renderer — it is the read-back that proves the renderer is WebGPU, and it is the hook every robot test in this lane asserts on. A substring ban would have deleted the evidence that the law is kept and called itself green. So the guard allows getContext only inside that probe and refuses it anywhere else, and a test asserts the region closes at the end of the probe (otherwise one window.facet_canvas_api line would exempt the whole rest of the file).

Nine tests, both directions: the real shipped page passes, every drawing page is refused with its line number and the reason.

This is the guard that will refuse py/panes/population_pane.py's Plotly rendering (row 8) and knut's hand-built SVG DAG viewer (row 6) — both the same banned class.

Version drift, and why the fix is a test

MEASURED before this row:

Cargo.toml            [workspace.package]  0.1.19
pkg-facet-wrapped/pyproject.toml           0.1.15   (4 behind)
facett-py/Cargo.toml                       0.1.15   (4 behind)
facett-py/pyproject.toml                   0.1.13   (6 behind)

The last two are the same artifact — a maturin wheel built from that Cargo.toml — declaring two different versions of itself. Whichever a consumer reads, the other is a lie, and it is unfalsifiable from outside the wheel.

All three are now 0.1.19, and xtask/tests/python_versions_track_the_workspace.rs holds them there. The edit was not the fix; a number living in four files with no writer between them separates again the next time three of them get bumped. py/ is excluded on purpose (it is the demo app, not a released artifact) and the test says so rather than leaving the omission to be guessed at.

Open, stated plainly

Files

what where
the verb + the verifier xtask/src/pip.rs
the no-JS-graphics guard xtask/src/jsguard.rs
the image recipe xtask/src/containerfiles/pip-toolchain.Containerfile
red tests xtask/tests/{wheel_verify,no_js_graphics,python_versions_track_the_workspace}.rs

Row 1b — published to holger's PEP 503 index, in Rust

2026-09-05, same lane. cargo xtask publish-holger packs pkg-facet-wrapped/dist/ into the archive holger's pip repository serves, and proves the bytes survived the trip.

archive  dist/holger-pip/facett-pip.znippy                          6 328 693 bytes
served   packages/facet-wrapped-web/facet_wrapped_web-0.1.19-py3-none-any.whl
                                            3 164 745 bytes, byte-identical after extract
served   packages/facet-wrapped-web/facet_wrapped_web-0.1.19.tar.gz
                                            3 153 987 bytes, byte-identical after extract

What publish_to_holger.sh was, and why deleting it is not just tidying

It was a shell script, which is a LAW breach on its own. The larger problem was that it shelled out to two binaries, and MEASURED on oden 2026-09-05, neither existed:

/home/rickard/git/znippy/target/release/znippy-cli     — absent
/home/rickard/git/holger/target/release/holger-server  — absent

Its happy path had not been runnable on this box, and its failure would have read ZNIPPY: No such file or directory — a message about a missing binary, from a script whose job is publishing a wheel. znippy-compress and znippy-decompress are now linked as libraries, so the packer cannot be absent. znippy-cli is a dependency only for NativePythonPlugin — the SAME plugin znippy compress --format python selects, so there is one writer for what a python archive is. It is taken from the LEAF crate that defines it rather than through znippy-cli's handler registry, because that registry also hosts the wasm plugins: routing through it put cranelift and wasmtime into this xtask's dependency graph for a single function call. Measured by adding the CLI, reading Cargo.lock, and taking it back out. The leaf needs features = ["host-decompressors"] — the crate's default set is the filename parser only, and without it the type does not exist and the build fails by name.

The bug the shell had, latent

normalize() { echo "$1" | tr '[:upper:]' '[:lower:]' | tr '_.' '--'; }

PEP 503 is re.sub(r"[-_.]+", "-", name).lower() — a run of separators becomes ONE dash. tr maps character-for-character and cannot collapse a run. Measured:

$ echo "facet__wrapped" | tr '[:upper:]' '[:lower:]' | tr '_.' '--'
facet--wrapped                      # PEP 503 says facet-wrapped

For facet-wrapped-web, the only name it was ever run with, the two agree. Add a package with a doubled separator and its wheel lands in a directory pip never asks for: the index resolves, returns a link, and 404s — which reads to a user as a broken server. A function that is correct for the one input someone tried it with. xtask::holger::normalize_name does the real thing and 11 tests hold it there, including idempotence (holger normalizes the incoming request too, so a non-idempotent function would disagree with itself one hop later).

The publish verifies itself — verify_archive_round_trip

pack_index returning Ok means the compressor did not error and wrote a non-zero file. That is a state round-trip. The failure it cannot see is the one that matters: an archive with a correct index and a short payload still has a size, holger still serves it, and pip downloads a truncated wheel and fails its own hash check with a message about the network — pointing at the client for a defect in the publish.

So the finished archive is extracted and every artifact compared byte for byte against what went in. Six tests build real znippy archives and prove it catches: a single flipped byte at the same length, a length mismatch (naming both numbers), an artifact absent from the archive, a truncated archive, and an empty pack. Every wheel is also re-run through verify_wheel at publish time, not just at build time — bytes can rot between the two steps, and re-reading costs milliseconds.

The image ID gap is CLOSED — and what that lets us claim

Row 1 listed "the image ID is not read back" as open. bevis closed it the same morning: draupnir b48d6b6 adds ImageDaemon::image_id, ContainerBoot::image_id and build_image_reproducible_pinned. inspect_image had been flattening the daemon's whole reply to Present/Absent, and the reply is where the id lives. This lane is the first caller.

image   nordisk-pip-toolchain = sha256:508f20deda2842293e35dcadedb9be37745fb8bbb5f42d453efe2d9acf2df8c5

PipBuildOutcome::image_id is Option<String> and a None prints UNPINNED rather than falling back to the tag — a tag sitting in an id field is exactly what a later reader would mistake for evidence of reproducibility.

What three mints measured, stated at its real size. Building the image three times in a row gave ONE id, sha256:508f20de…. That proves podman's timestamp parameter is honoured: with created = now() the config would differ and the id would move between runs, which is the precise complaint bevis raised about pins that hold only until the next mint. It does NOT prove cold-store reproducibility — the layers were cached, and minting from a fresh empty store is a different measurement that has not been run here.

MEASURED: the wheel is byte-reproducible, the sdist is not

Two mints, then sha256sum:

wheel  512553103e529e83853b916d5871a36dbe0bf068ec9a8dd1f44fab134021f902   (both mints)
sdist  216416e645fb50c9e8b7e62398aba41e1cf3e9be23ff719e84aaf0e529f51a70
       75b44dcd0351ec489a6e3add20808af4bc57297e8a93d40063d5ebdedb3baa9f   (different)

The wheel is identical byte for byte — SOURCE_DATE_EPOCH reaches every zip entry. The sdist is not, and it is worth saying exactly where, because "the sdist is not reproducible" would send someone looking in the wrong place:

Note 1788609927 is real wall-clock time, not our SOURCE_DATE_EPOCH of 1788566400: setuptools applies the epoch to the wheel's zip entries but not to the sdist members it generates fresh (the .egg-info/PKG-INFO it writes each run), nor to the gzip wrapper. So the fix is in the sdist path, not in the epoch, and it is a declared next slice rather than a silent absence. The wheel is the release artifact and it is reproducible today.

Still open after 1b


Row 1c — a real pip, a real index, a container that has never seen this repo

2026-09-05. cargo xtask verify-install. This is the step that turns "the archive is correct" into "a client resolved it".

image     nordisk-pip-consumer = sha256:c6da9892fb0944464ea5b2c53d3a98b77f7f7bed580d1cf24c1b5bec87c63d0a
version   0.1.19 (as the INSTALLED package reports it)
assets    /tmp/v/lib/python3.13/site-packages/facet_wrapped_web/assets
installed facet_wrapped_bg.wasm: 9 920 451 bytes  (identical to what wrap-wasm built)
installed facet_wrapped.js:         100 963 bytes  (identical to what wrap-wasm built)
installed manifest.json:              4 682 bytes  (identical to what wrap-wasm built)
installed index.html:                 9 277 bytes  (identical to what wrap-wasm built)
wasm      magic 0061736d — a real module after the whole trip
manifest  27 components

The consumer image carries python3, pip and venv and nothing else — no build backend, no source tree, no bind mounts. The wheel arrives over the index the way a consumer's would. The verdict is made in Rust, by judge_install, against the wheel we actually built — not by the container's exit code, which would be a python script asserting on itself. Seven tests drive that verdict red (wrong version, a source-tree read, a wasm that lost its magic, each asset's size changed individually, a missing asset, an empty manifest).

THE FINDING: row 1b's round-trip check was a green worth less than it looked

Row 1b verified the archive by extracting it and comparing bytes, and it passed. Then holger refused to open the same file:

repository 'pip' DISABLED — backend init failed: failed to open znippy archive
'…/facett-pip.znippy' — corrupt, truncated, or written by an incompatible znippy
version: concat sub-indexes: Invalid argument error: It is not possible to
concatenate arrays of different data types (UInt32, UInt8).

The archive was not corrupt. It extracted byte-identically — through its own writer's decompressor. That is the whole defect in the check: I verified with the same library that wrote the file, so the two agreed by construction and the measurement could not fail. A round-trip through one implementation is a state round-trip, not an applied output. The consumer is the only honest reader, and row 1c is what found it, one row later.

The cause, measured rather than guessed

The fix, and why it is a pin and not a bump

xtask/Cargo.toml pins znippy-compress = "=0.9.13", znippy-common = "=0.9.14", znippy-plugin-python = "=0.9.9" — the registry versions, not the sibling checkout. facett writes with what the CONSUMER reads. Publishing an archive that only an unpublished library can open is not "ready for release"; it is a private format that happens to have a .znippy extension.

The pins come off when znippy 0.9.14+ is published and holger is rebuilt on it. Bumping facett to the local checkout instead would have made this lane green and the deployed server still unable to serve a byte of it.

A second version copy, found the same way

facet_wrapped_web/__init__.py carried __version__ = "0.1.15" — a fourth number, inside a wheel whose metadata said 0.1.19. build_info() returns it and tests/robot_installed.py records it into the saga tape, so the durable record of every drive was stamped with a version the artifact did not have. Nothing inside python could catch it: the number agreed with itself.

Adding it to the three-file equality check would have been the wrong fix — a fourth copy held in place by a test is still a fourth copy, and the next bump has four chances to miss one. It now reads from the installed distribution's own metadata (importlib.metadata.version("facet-wrapped-web")), whose single writer is pyproject.toml, and answers 0+unknown from a source checkout rather than a plausible-looking stale number. The test asserts the absence of a literal, plus the presence of the metadata read — because asserting absence alone would pass on a file that deleted __version__ altogether.

Row 1c is the proof that this works: the installed package reports 0.1.19, read out of a wheel, in a container, over an index.

State after row 1c

wrap-wasm → wheel (in a container) → verified → znippy archive → verified → holger PEP 503 index → pip install in a clean container → the same bytes. Every hop is checked against the previous one, and 53 tests hold the checks.

Still open: the sdist's PAX/gzip timestamps (content is identical, stamps are not); apt is deb.debian.org rather than a snapshot; and cold-store image reproducibility is unmeasured. None of them affect the released wheel.


Row 3 — the GPU lanes were compiled out, and turning them on was only half of it

2026-09-05. The bundle ran facett-map3d, facett-graph3d and facett-graphview on their DEFAULT features, so their GPU lanes were not in the binary. The page reported WebGPU truthfully and projected, culled and lit every triangle in software behind it.

Why nothing caught it

The two things that look like they would are both blind:

readout before row 3 after row 3
facet_backend() "webgpu" (a constant) "webgpu"
facet_canvas_api() "webgpu" (asks the canvas) "webgpu"

Both describe the surface, not the renderer painting onto it. Asserting on them to prove the GPU lane is live is the identity value in LAW 2's sense — a value that cannot distinguish the two cases it is read to distinguish.

The half that is easy to miss

Enabling the features changes what the binary contains. It changes nothing about what runs. Map3D starts with use_gpu: false and a CpuPainterBackend; the lane is selected only by with_gpu(true), which is #[cfg(feature = "wgpu")] — so before the flip the call did not exist, and after it the adapter still did not make it. MEASURED on the bundle with all three features already on:

"gpu_lane_selected": false,
"backend": "cpu-painter",
"lane_flag": "pending",
"used_gpu": null

Every flag said yes and every triangle was still drawn on the CPU. facet-wrapped's osm3d and graph3d adapters now call with_gpu(true). Because that method is feature-gated, the call is also the compile-time proof the feature is on: remove it from the manifest and the build fails by name instead of falling back in silence.

The CPU painter is not removed. default-features stays on for all three — it is the reference path, the snapshot golden path, and the only thing left when requestAdapter() returns null. A test asserts that too.

Applied output, not a feature flag

wasm32 artifact   9 920 451 → 11 604 884 bytes   (+1 684 433, +17 %)
shipped in wheel  9 920 451 → 10 078 599 bytes   (after wasm-bindgen)
js glue             100 963 →    104 112 bytes

and the whole chain re-run end to end: wheel rebuilt in the container, verified against its own RECORD, packed into the znippy archive, served over PEP 503, and pip installed in a container that has never seen this repo — 10 078 599 bytes, magic 0061736d, 27 components, byte-identical to what wrap-wasm built.

Two corrections to how this row was specified

  1. The brief said to assert state_json.renderer == "wgpu". There is no renderer field. map3d publishes lane_flag (gpu/cpu/pending, from facett_core::render::lane::LaneFlag::token()), plus used_gpu, gpu_lane_selected and backend. A robot pointed at renderer would have compared null to "wgpu" and failed forever on a correct build — so the row could only ever have been "done" by deleting its own check. A test keeps the correction visible and fails the day a renderer field appears, so the two names cannot both exist.
  2. I first wrote that LaneFlag::token() had zero callers. That was wrong — my grep pattern missed self.lane_flag().token() at facett-map3d/src/lib.rs:5423. The observability was already complete; what was missing was the selection.

What is NOT verified, said plainly

facett-graph3d publishes no lane in its state_json and has no public getter for use_gpu (private, lib.rs:494). So the with_gpu(true) on the graph cloud is asserted at compile time only — nothing outside that crate can observe whether it took effect. The three-line fix belongs in graph3d's own state_json, mirroring what map3d already does with LaneFlag::token(); it is posted for that crate's owner rather than made here, because the facett lane has live uncommitted work in exactly that GPU cloud path (facett-core/src/render/gpu/graphcloud.rs). A test named graph3d_still_has_no_lane_readout_and_this_test_is_the_reminder fails the day it lands.

A worktree fix that came out of this row

pkg-facet-wrapped/holger-facett-pip.ron hard-coded /home/rickard/git/facett/dist/holger-pip/… — one person's home directory, and specifically the shared checkout. A lane working from a worktree would publish its wheel to its own dist/ and then serve the shared tree's stale archive, with nothing saying so. The file is now a template, rendered against the root the xtask is actually running in, into target/. Four tests, including one that refuses a template whose baked path has moved rather than returning it unrewritten — the first version of that guard checked for the absence of the old path, which passes trivially when nothing was replaced.

One thing I could not explain

verify-install failed once with "the container exited 0 but printed no check line", then passed on the next run with no change to that code path. I could not reproduce it and I am not calling it transient. What did change: the error now prints the last 25 lines the container actually emitted, so a recurrence is diagnosable instead of requiring the whole thing to be re-run by hand.


Row 4 — graphnav threw the caller's coordinates away

2026-09-05. The graph family's payload contract has documented pos and group since it was written, and graph3d / graphpan / graphview all honour pos. graphnav — the surface Rickard names first ("prio FalkorDB graphs") — did not.

MEASURED before this row, feeding pos: [111,222] and [333,444]:

"canvas": { "layout": "force",
            "nodes": [ {"id":"0","x":0.8999999,"y":3.93e-8},
                       {"id":"1","x":-0.8999999,"y":-3.93e-8} ] }

Two nodes on a horizontal line — what force makes of one edge. The coordinates went in and nothing came out that depended on them.

Both halves of the seam already existed

facett-graphnav has with_places(Vec<Place>), a LayoutKind::Geographic, and places_cover as THE one predicate its switcher and its layout share, plus layout.has_places / layout.placed_nodes in state_json. GraphPayload already parsed pos. Nothing needed inventing — the two sides were simply not connected.

Place is Option<[f64;2]> and deliberately not lon/lat: the units are the host's, and geographic only ever takes differences and one uniform scale. A node without pos stays None rather than being given a made-up place, because places_cover demands EVERY node — a geographic layout with holes puts nodes somewhere they are not.

Parsing the places is not enough either

The same shape as row 3. After wiring with_places, has_places read true, the switcher offered geographic, and the canvas still showed a force layout — because layout_kind defaults to Force and nothing had selected the other lane. A caller who sends coordinates has asked for them to be used. The adapter now selects it, gated on graphnav's own has_places() rather than on "did the payload carry any pos", so it can never select a lane the layout would fall back out of.

What is asserted, and a correction to the spec

The brief's red test was state_json.canvas.nodes[i].pos == input. There is no pos under a canvas node — graphnav reports x/y — and the values are normalised into [-1,1]², not echoed, so equality with the input would fail on a correct implementation. What is assertable, and stronger, is that the arrangement is DERIVED from the coordinates: Göteborg and Malmö end up west of Stockholm, Kiruna alone at the far end of the north–south axis, and moving one city moves exactly that node. Three edges cannot fake that — they say nothing about north or west.

group now reaches graphnav as a property, not as a second colour rule: graphnav colours by label and has its own pinned-ontology palette, so re-deriving colour from the group here would be a third palette to drift. An explicit props.group still wins, because the shorthand is written first.

A twin I wrote and removed

The inspector only publishes a node's props when one is SELECTED, and the adapter returns Box<dyn Facet>, which cannot be downcast back to GraphNav. The first version of that test rebuilt the graph by copying the adapter's node loop — it would have kept passing while the adapter drifted underneath it, which is precisely the "guard watching two copies agree" LAW 5 warns about. The node-building is now graphnav_model and the surface is graphnav_view; the adapter and the test call the same functions.

The gallery's graphnav payload is now a placed one (five hosts/services/artifacts at their real coordinates), and a test reads the SHIPPED table and asserts it comes out geographic — so the demo cannot quietly lose its coordinates either.


Row 5 — the Spark viewer had no way to reach the browser

2026-09-05. facett-pipeline already owned the seam: PipelineModel is the normalized, engine-agnostic shape, PipelineView::from_model is the ONE viewer, and knut_pipelines::to_pipeline_model is the Spark/SDP adapter that produces it. The matrix test in facett-pipeline/tests/knut_facett_matrix.rs drives exactly that path and states the rule: "No Spark/SDP type crosses into the viewer."

What was missing is the wire. facet-wrapped's pipeline adapter took a bespoke {nodes: [...], edges: [[0,1]]} shape and built PipelineNodes directly, so a PipelineModel had no JSON encoding the wasm bundle would accept. The Spark DAG could be normalized and then not travel.

A correction to the spec

The brief says the model can be carried "(serde derives exist)". They do not. PipelineModel, Stage, StageMetrics and PipelineEdge derive Clone, Default, Debug (+ PartialEq/Eq on the edge) and nothing else. Serialize/Deserialize are on Status and JobProgress — which is almost certainly where the impression came from.

So the encoding is written in facet-wrapped, in both directions, beside each other, with a round-trip property test: an encoder and a decoder that live apart drift. Upstream derives were the other option and were not taken — facett-pipeline is path-dep'd by knut and carries the workspace's [patch.crates-io] note about two PipelineModel types linking into one build. Changing a shared crate's public surface to serve one consumer is a bigger change than it looks, and lowering JSON into components is what facet-wrapped is for.

What is asserted

The brief's red test was "same stage/edge counts". A count check passes on a wire that drops every status, id, kind, error and metric, so the test compares field by field after a round trip — and singles out the one that a careless wire flattens:

assert_eq!(after.stages[3].status,
           StageStatus::Unknown(UnknownReason::NotAuthorised));
assert_ne!(after.stages[3].status, StageStatus::Idle);

StageStatus's own doc makes that distinction the point of the type: Idle means "we know it has not started", Unknown(reason) means "we could not find out". A wire that collapsed the second into the first turns a permissions failure into a pipeline that merely looks pending.

Proven red by blinding two lines — encoding NotAuthorised as "idle" and dropping kind — which fails exactly the two tests that guard them and no others.

UnknownReason is #[non_exhaustive], so a reason added upstream encodes as a bare unknown rather than silently becoming one of the four spelled out.

Two shapes, dispatched, not replaced

stages present → the normalized model. nodes present → the older hand-written shape, kept because the gallery, the robot and every existing consumer send it. Both are tested, and so is the refusal path: {}, {"title": …}, {"stages": []} and {"nodes": []} all still return None so the loader shows its honest refusal rather than an empty DAG. An edge naming a stage that was never sent is carried on the wire and not drawn — drawing an edge to nowhere is worse than drawing no edge.

The gallery's pipeline payload is now the model shape, including a stage in unknown:not_authorised, and a test reads the SHIPPED table and asserts the reason survives.

What still needs knut (row 6)

Nothing in knut has been touched. knut-pipelines/src/web.rs still serves a hand-written HTML+SVG DAG renderer, which is the class Rickard's rule forbids. The contract it needs is now on this side: to_pipeline_model(vs) → this wire → the pipeline canvas. The exact patch goes on overcom for that crate's owner.


Row 9 — documents that tell you to run commands which do not exist

The removed crates and verbs are named inside blockquotes throughout this section. That is not decoration: the guard this row adds treats a blockquote as a QUOTATION rather than an instruction, and without it this very document — which names them in order to explain them — fails its own check. It did, on the first run.

2026-09-05. facett-streamlit and facett-embed are crates that were removed. cargo xtask release-streamlit and release-embed are the verbs that went with them. The documents naming them were not removed.

MEASURED: 13 dead-command instructions across 8 files, including .nornir/python-packaging-publish-runbook.md — a file marked nornir:class=guide

whose step 2 was cargo xtask release-streamlit. Someone following it gets unknown commandand cannot tell whether they typed it wrong, are on the wrong branch, or are reading a document about a world that ended. Its build step also ranpython -m build` on the host, which is the exact path whose predecessor forked ~10 000 processes and OOM-killed oden on 2026-07-31.

.gitignore carried nine rules for the removed crate’s python/ subtree, a directory that cannot exist — guarding nothing while looking like policy.

The guard, and why it is two guards

  1. No file may name a removed crate, except ones that are explicitly history. Each exception is listed with its reason AND is itself checked to still exist and to still mention a ghost — an allowlist that outlives its files silently excuses nothing, and an entry that no longer needs excusing is an exception nobody can justify.
  2. Every cargo xtask <verb> a document instructs the reader to run must be a verb the dispatcher actually has — read out of main.rs, not listed in the test.

(2) is the one that generalises: it catches the next removed verb without anyone remembering to update the test.

A markdown blockquote is a quotation, not an instruction, and is skipped. The rewritten runbook quotes the verb it replaced so a reader arriving from a stale bookmark recognises it; forbidding that would force every correction to be vaguer than the mistake it corrects. Deliberately narrow — only a leading >. "But it's in a comment" would have excused most of the file.

A second exposure, found while doing this

Three documents — py/README.md, .nornir/facett-demo-python-design.md and the superseded seam design — spelled the run command with the bind address set to every interface. Row 2 fixed the container's binding; these told a reader to reproduce the exposure by hand, container or not. All three now say 127.0.0.1, and the bind guard scans every tracked file rather than only the Containerfile: a reader follows the doc, not the recipe.

What was rewritten rather than deleted

The superseded seam-design doc (allowlisted as history) describes a crate that no longer exists, and its analysis — a component falling back to its own demo fixture is indistinguishable from success, and the fix is structural rather than careful — is still the law the loader's refusal path implements. It keeps a SUPERSEDED banner naming its successor, and its Build+run block is the real chain.

Row 8 is NOT done, and deliberately

Its own stated precondition is row 7 ("deleted once D2 lands"), and row 7 — tonic-web + assets_dir() on korp-server, so the wasm dials korp itself — needs korp-GG and has not landed. MEASURED: panes/graph_pane.py, map_pane.py and population_pane.py all import the korp gRPC client, which is the only path to korp data until the wasm can dial it. Deleting client.py now would satisfy the row's letter (find py -name '*.py' … | wc -l == 2) by removing working functionality and leaving three panes unable to fetch anything.

The panes are not the defect the row was written about, either: the audit's premise was that they were a second Python renderer, and that was already false before this lane started — every one of them mounts wasm components, and there is no pandas/plotly/st.dataframe anywhere in py/. What remains of row 8 is deleting a gRPC client that is still the only wire, and that waits for row 7.

A note on this document tripping its own guards, three times

Row 9's text names the removed crates and verbs in order to explain them, and the paragraph above described the bind flag by spelling it. Both were caught by the guards this lane had just added — the second one after I had already pushed, because I chained the test run and the push with ; instead of && and pushed over a red.

The two guards are deliberately resolved differently, and the difference is the point:


Row 10 — a verified wheel on disk is not a release

2026-09-05. publish-pip produced a wheel and checked it; publish-holger served it. Neither left anything beside the artefacts that lets a consumer check the bytes away from this machine. Every publish-pip now also writes:

pkg-facet-wrapped/dist/
  facet_wrapped_web-0.1.19-py3-none-any.whl   3 221 741 B
  facet_wrapped_web-0.1.19.tar.gz             3 210 882 B
  SHA256SUMS
  provenance.json

Verified with an independent tool rather than with more of my own code:

$ cd pkg-facet-wrapped/dist && sha256sum -c SHA256SUMS
facet_wrapped_web-0.1.19-py3-none-any.whl: OK
facet_wrapped_web-0.1.19.tar.gz: OK

The provenance hashes the produced bytes, not the recipe

BuildProvenance::capture is handed the artefact PATHS and content-hashes what is actually there. A record built from the variant declaration would describe what was asked for, and two builds of one declaration can differ — row 3 measured exactly that, the wasm growing 9 920 451 → 11 604 884 bytes from a feature flip.

"repo": "facett",
"vcs_rev": "616f7f6…", "vcs_dirty": true,
"rustc": "sha256:508f20de…",        ← the CONTAINER's image id
"artifacts": [ { "path": "…0.1.19-py3-none-any.whl",
                 "sha256": "a64ae831…" }, … ]

rustc carries the image id, not the host's toolchain: Toolchain::detect() here would attest to a compiler that did not build the artefact being described. And the two files agree — the sha256 in provenance.json and the line in SHA256SUMS are computed by two independent code paths (this crate's, and nornir-build-thing's) and match on both artefacts.

Written inside publish-pip rather than as its own verb so the set cannot be forgotten: every build leaves a complete, checkable release behind it. SHA256SUMS never lists itself (a self-hash is unverifiable) or a stray file (which would make sha256sum -c fail for a reason unrelated to the release), and rewriting is idempotent — a manifest that grew a line per run would still check clean while quietly ceasing to be a listing. An empty dist/ is an error, because an empty SHA256SUMS passes trivially and proves nothing.

What row 10 does NOT do, and why it is not mine to do

The brief's door is dwarves buildthing facett --variant facet-wrapped-web. The forge has no artifact kind for a package. ArtifactKind is Lib | Exe | Wasm | Kernel | Container | Service | Vm | Iso | Installer. A wheel is none of them, and declaring it Wasm would make the forge build a bare .wasm — not run wasm-bindgen, not build the wheel in a container, not verify it. The variant would "succeed" and produce something that is not the artefact.

Adding a kind is a change to a shared edda crate that every matcher on that enum has to answer, at a time when several lanes are in edda. It is posted rather than made. What is done is the artefact side of that door: run the verbs and the release set exists, checkable by anyone with sha256sum.

facett still declares no .nornir/build-variants.toml, so cargo xtask buildgate runs its weaker WORKSPACE mode and says so. Writing one is the other half, and it wants the same decision — the constellation pattern is an emitted declaration from a Rust impl BuildThing (znippy's says "EMITTED, NOT SOURCE… editing it changes NOTHING"), and facett's facett-buildthing crate is a view of other repos' build spaces, not an impl of its own.


Row 11 — the loader's HOST PROTOCOL, written down, because the host is changing hands

Lane SW (oden), 2026-09-06.

assets/index.html is the one piece of JavaScript this workspace ships, and it is a protocol endpoint: a host names a component, hands it payloads, and reads back what rendered. Until this row that protocol lived in the loader's own comments and in whatever the host's author remembered. That was survivable while the host was facet_host.py in the same repository. It stopped being survivable today.

What changed under it

Rickard ruled, twice, that korp gets no browser surface at all, and that knut is the Streamlit backend. knut serves this page (knut-wasm-host, knut cd7d8b5) and fetches from korp SERVER-TO-SERVER over ordinary gRPC, exactly as the desktop client does. The browser never sees korp. korp's own /facet/* route, its grpc-web layer, its accept_http1 and its facet-web feature were deleted rather than shrunk (korp 7a889e7).

So the words in the loader's comments are now a contract across a repository boundary, and the party on the other side has not been written yet.

Row 2 of the SW brief was CANCELLED by this, and it should have been

The brief said facet-wrapped needed a grpc-web client — tonic-web-wasm-client, with korp.proto's messages mirrored by hand to avoid a facett→korp dependency cycle. None of that is needed and none of it should be built. The wasm side of the demo needs no Rust change at all: the loader has done the live-feed half since row 6.

index.html?component=<name>&events=<sse url>

opens an EventSource, treats each event's data as ONE payload for the named component, mounts on the first frame and swaps every later frame into the running canvas. Two control frames end it: {"done": true} closes the feed and leaves the last frame up; {"error": "<why>"} puts the host's own words in the refusal panel. Until the first frame arrives the page WAITS — the fixture never stands in.

?component= and ?events= are the same two parameter names knut_wasm_host::entry's COMPONENT_PARAM / EVENTS_PARAM already sends. The two halves speak one protocol already; nobody has to build a bridge.

The guard, and why it is not a grep

xtask/tests/the_host_protocol_the_loader_speaks.rs holds eleven promises to the SHIPPED page — the same source no_js_graphics.rs reads, never a copy.

Every one of those tokens also appears in the loader's PROSE. A substring scan would pass a page that documents the protocol beautifully and implements none of it, which is exactly the blind guard LAW 2 warns about. So the page's comments are stripped first (/* … */ blocks, and whole lines beginning // — not trailing //, which would eat a "http://" inside a string), and the assertions run on what is left.

Three floors, because a stripper is itself something that can be wrong:

Seen red on the real shipped page, twice, edited line printed first. Delete if (frame.done === true) { es.close(); return; } and the guard names it: "the loader no longer implements 1 of the 11 promises a host builds against, and a host that sends them will hang waiting for a canvas." The page was restored byte-identical after each.

The second guard: a query parameter a redirect would drop

the_loader_does_not_depend_on_a_query_parameter_a_redirect_would_drop asserts the loader never reads streamlitUrl.

Streamlit appends ?streamlitUrl=<parent origin+path> to a component URL declared with declare_component(url=…) — verified against streamlit 1.60.0's own ComponentInstance chunk in the demo container, 2026-09-06. Under the knut shape the browser lands on knut's bundle mount and is answered with a 303 to this page, and a redirect drops the query string Streamlit added. That is harmless only for as long as the loader does not need it: it posts to window.parent with "*" and never looks the value up.

If someone teaches the loader to read streamlitUrl, the entry redirect starts silently losing it and the failure appears as a component that renders and never talks back. Seen red by adding q.get("streamlitUrl") to the real page.

What this guard CANNOT say, stated so nobody reads more into it

It says what the page PROMISES. It cannot say that knut delivers it — nothing in this repository can — and it does not pretend to. knut's sse.rs framing the two control frames by those two words is knut's half, and it is unwritten.

Verified against Streamlit's own source, not from memory

Read out of the demo container's own site-packages (streamlit 1.60.0, podman overlay 8f68c1cc…). No Python was run — the LAW forbids it on the host, and reading source needs no interpreter.


Row 12 — one GPU component in Streamlit: the chain, and what oden could and could not prove

Lane SW (oden), 2026-09-06. Rickard: make the GPU one work in Streamlit first; use the map or map3d. This row is that chain built end to end, plus two defects it found on the way — one of which is in this crate's own loader.

The launch flags, because a correct build reads as broken without them

Measured by t14s on the Radeon 890M, and the reason this row starts with a paragraph about a browser rather than about code:

Chrome invocation what WebGPU gives
plain google-chrome on Linux no adapter at all
--use-angle=swiftshader an adapter that is SOFTWARE, reporting success
--enable-features=Vulkan --use-angle=vulkan --enable-unsafe-webgpu the real card — amd rdna-3

The middle row is the expensive one. A page that checks an adapter exists passes on it, and a CPU rasteriser paints seconds per frame while every probe says webgpu. That is why row 11's companion, facet_adapter(), exists — see below for the same trap caught on the wire.

The chain, on oden

cargo xtask wrap-wasm                      → facet_wrapped_bg.wasm 10 116 976 B, glue 107 555 B, 27 components
+ assets/index.html beside it              → the bundle directory knut expects
knutpipe web --facet-dist DIR              → /  303 → index.html?component=…&events=…
                                             index.html  text/html · glue  text/javascript
                                             wasm  application/wasm · unlisted file  404
declare_component("facet", url=<knut>)     → the iframe ORIGIN is knut; every data call same-origin

facet_host.py grew the url= road behind $FACETT_COMPONENT_URL. path= remains the default and is untouched — the wheel still ships the bytes and the offline demo still runs exactly as before, which Rickard's law requires.

What the three arms actually reported, and why row 11 was necessary

Driven in headless Chrome on oden against the served bundle, reading the real exports out of the running wasm:

arm navigator.gpu secure facet_backend() facet_canvas_api() facet_mounted() facet_error() facet_adapter()
vulkan + unsafe-webgpu true true webgpu webgpu "" "" null
swiftshader true true webgpu webgpu "" "" null
plain chrome true true webgpu webgpu "" No suitable graphics adapter… null

facet_backend() and facet_canvas_api() answered webgpu in all three — including the arm that failed loudly with no adapter at all. Two probes that robots have been asserting on, agreeing with each other and with the build, and saying nothing about whether a pixel was painted. facet_adapter() was the only one that moved with reality.

The defect this found, and it is in the loader

Two of the three arms failed SILENTLY, forever. At 30 256 ms with a 60 s virtual-time budget, facet_mounted() and facet_error() were BOTH still empty.

wasm.rs's start() carries a paragraph saying that must not happen — "a blank canvas would strand a headless driver waiting on facet_mounted until its timeout with nothing to report". The guard was there and it could not fire. The mechanism: webgpu_unavailable() only asks whether navigator.gpu EXISTS, and it does in every arm; the real failure is requestAdapter() never settling inside eframe, so WebRunner::start().await never returns and its error arm never runs. Plain Chrome rejects fast and gets a loud error; a flag set that asks for a device the box does not have simply hangs.

The loader's watchStartupFailure polled a hundred times, gave up, and clearIntervaled without a word — the one place that knew the watch had been abandoned. It says so now, and names the three facts whose remedies differ: an adapter that never arrived, an absent navigator.gpu, an insecure context.

Proven on the wire, same arm, before and after. Before: #refusal empty and hidden, #loading removed, a black canvas. After:

facet-wrapped cannot render this data — the renderer did not start within 10s —
component osm3d never mounted and the wasm recorded no error · navigator.gpu present ·
secure context yes · adapter NEVER ARRIVED — requestAdapter() did not settle

A hypothesis formed and refuted, kept because it was nearly reported as a finding

Plain Chrome's error reads "…gl not requested, webgpu not requested", which on a build whose mask is Backends::BROWSER_WEBGPU looked exactly like row 3 recurring one level down — the backend compiled OUT while everything still answers webgpu. It is not. facett-core's wgpu dependency carries "webgpu" in its feature list, facett_wgpu_options() does set instance_descriptor.backends = preferred_backends(), and the built artifact contains 1 165 webgpu strings and the BROWSER_WEBGPU symbol. The backend is in the binary.

The honest limit

oden has no GPU. The map cannot render here and no flag will change that. Everything above the adapter is proven on this box; the last inch — a real adapter inside the Streamlit frame — belongs to the 890M. What this row delivers for that inch is a page that NAMES what it got, so looking at it is a read rather than a judgement.

One cost, observed rather than argued

BundleSpec::load reads the bundle once at start-up. Editing a page and re-running served the OLD bytes three times before the cause was obvious — the server was doing exactly what it says. That is the wrap-wasm-and-refresh loop this constellation gave up when korp's per-request read went away, and it costs a restart per edit. An explicit reload() the caller MAY call is the right shape when someone builds the demo host for real; nothing here asks for the old behaviour back.


Row 13 — the wheel is SINGLE-CORE, and nothing here said so

Measured 2026-09-18, by reading the lanes end to end rather than by benchmarking: the wasm this wheel ships runs every CPU preparation lane on one thread, where the same component fans across every core natively. No test, no document and no runtime line said so before this row.

The mechanism, and it is silent by construction

facett's parallelism is znippy's gatling fork-join (ROOT LAW #0: rayon is FORBIDDEN). Its worker count resolves like this:

gatling_forkjoin::default_workers()
    → std::thread::available_parallelism()   // Err(Unsupported) on wasm32
    → .unwrap_or(1)                          // ⇒ 1
resolve_workers(0, n) ⇒ 1
gatling_for_each: if workers == 1 { return (0..n).map(f).collect(); }

It does not panic and it does not spawn — it runs the closure inline on the caller. That is the correct behaviour and gatling's own module docs state it ("collapses to 1 where available_parallelism is unsupported"). What is missing is anyone repeating it here, where the artifact is described: a reader of this document learns the bundle's byte count and its 27 components, and could not learn that the thing they are about to compare against a native run has 1/N of its CPU.

What actually loses its fan in the browser

lane call site native in the wheel
graph force layout (Barnes–Hut + FR) facett-graphnav/src/layout.rs → facett_core::barnes_hut::fr_step every core 1
map3d extrusion fan facett-map3d/src/extrude.rs:2791 every core 1
CPU SDF scanline raster facett-core/src/render/cpu/mod.rs:158,216 every core 1 (largely moot — map-wgpu sends the render to the GPU)

The first two are preparation, not rendering, so WebGPU does not cover them. This paragraph first said both "are the ones that matter". That was written from reading the code, and for the extrusion it was WRONG — see "The extrusion, measured" below. A WebGPU canvas can be wide and fast while the frame it is handed took a single thread to prepare.

The part that is worse than it sounds: the fix ships INSIDE this wheel, unreachable

facett-graphview::gpu_layout is a wgpu compute-shader force layout — CSR streamed into storage buffers, positions ping-ponging in VRAM, no per-step readback. Its own docs carry the numbers (RTX 4090, 100k nodes / 199k edges): one step_cpu step is 23.7 s, fifty GPU steps are 983 ms.

facet-wrapped/Cargo.toml depends on facett-graphview with features = ["gpu"], so that compute shader is compiled into the 10 MB bundle a browser downloads. And it can never run, for two independent reasons:

  1. The selector is compiled out. facett-graphnav::layout::force_selected routes large graphs to gpu_layout::force_layout_gpu under #[cfg(feature = "gpu")]. facett-graphnav's gpu feature is default = [], and facet-wrapped names graphnav with no features — so the arm is not in the build and every graph takes the CPU lane.
  2. It could not run anyway. gpu_layout acquires its own device with pollster::block_on(instance.request_adapter(…)) (gpu_layout.rs:477,489,681,693). Blocking on the browser's main thread is not a slow path, it is a deadlock — and the app already HAS a wgpu::Device (eframe's WebGPU backend) that this code ignores in favour of making its own.

So the wheel carries a compute shader it cannot dispatch, behind a selector it did not compile, against a device it would refuse to borrow. Each of the three is a one-line fact; together they are why the browser lays out graphs on one core.

CLOSED the same day — all three reasons, and what is still open

  1. facet-wrapped names facett-graphnav with features = ["gpu"]; the selector is in the build. Its BLOCKING arm is now not(target_arch = "wasm32"), so turning the feature on did not make pollster::block_on reachable.
  2. install_gpu_lanes LENDS the host's device: gpu_layout::install_host_device(&rs.device, &rs.queue).
  3. The lane that runs is gpu_layout::LayoutJob — start streams the CSR and returns, each poll does one bounded slice (10 steps, or kick the readback, or check the map), and no call waits on the GPU. GraphNav::positions() paints a grid placeholder while it runs and requests repaints so it keeps being polled.

Held by tests, on a real device (RTX 5080): the chunked job returns positions EQUAL to the blocking force_layout_gpu_on, including a ragged last chunk; with no device installed the CPU lane settles on the first call exactly as before; and wasm_bundle_installs_the_lanes_it_selects.rs has a fourth row, so selecting the feature without the loan is a red test. On the built bundle (+35 KB): graphview_layout_job_readback present, graphview_layout_readback (the blocking one) ABSENT — eliminated because nothing on wasm32 reaches it.

MEASURED IN A BROWSER, 2026-09-18, the same evening. Google Chrome 153 (.deb), a real window on Wayland, --enable-unsafe-webgpu --enable-features=Vulkan, the BUILT bundle served same-origin so the driver page could read facet_state(); 3 000 nodes / 4 000 edges posted through the loader's own facet:render message:

adapter   nvidia blackwell (RTX 5080) — hardware, software:false
lanes     ""/pending @2.3 s → … → "gpu-job"/settled @5.8 s      fallback: ""

So the shader the wheel had always carried was dispatched by a browser, on the host's borrowed device, and read back through the callback. Two things that run found:

An unrelated defect the same console surfaced: facett_map3d_water does not compile in a browser — Tint rejects textureSample inside non-uniform control flow (the if (ruv.x >= 0.0 && …) reflection branch), which naga accepts natively. The water pipeline is invalid in every browser today. Not fixed here.

Previously open, kept for the record: nobody had watched a browser dispatch it. state_json().canvas.layout_lane reads "gpu-job" when the lane ran — that readout exists so this becomes an assertion the day a browser with a WebGPU adapter runs the robot. On the box this was built on, snap Chromium has none (no Vulkan ICDs inside the snap). map3d extrusion is still single-core in the wheel — measured below, and it does not need fixing.

The extrusion, measured — and it is not a problem

Row 13 listed map3d's extrusion fan beside the graph layout as a lane that "matters", and the design note called the GPU twin in map3d-flight "a wiring question, not a new shader". Both were claims made by reading. Measured 2026-09-18 (release, loki, ONE core — build_with_water_terrain_styled_with_workers(.., workers = 1), which is exactly the lane wasm32 runs; L-shaped six-corner footprints, pitched roofs on, a third with tagged heights):

buildings triangles one core
2 500 65 002 6.9 ms
22 500 585 002 65.8 ms
90 000 2 340 002 164.2 ms

And it runs ONCE, at Map3D::from_ways…, not per frame. So the single-core cost in the wheel is a mount-time delay of single-digit milliseconds for any payload a Streamlit pane is realistically handed, and a fraction of a second for a city larger than the demo has ever shown. Allowing wasm a generous 2–3x over native (NOT measured in a browser) does not change the conclusion. Contrast the graph layout, where one CPU step at 100k nodes is 23.7 s and the lane runs to convergence — that one mattered, and is fixed.

The "wiring question" was wrong too. map3d-flight::extrude_gpu extrudes flat-roofed PRISMS — walls and caps, with the ear-clip still done on the CPU. The lane it would replace also builds gabled and hipped roofs, facade and roof colours, terrain relief, the ground band stack, water meshes and per-vertex building stamps, and tests/extrude_fanout.rs holds its output BYTE-identical to the serial order because the pixel goldens sit on that order. Swapping it in would delete features to save milliseconds. Not done, on purpose.

(The same run showed the all-cores fan no faster than one core — 173.5 ms vs 164.2 ms at 90 000. That is NOT a finding: another session was converting the OSM planet on 11 of this box's 12 cores at the time. It is recorded only so nobody quotes it.)

What this row does NOT claim

Nothing here is benchmarked in a browser. These are read lanes and compiled features, not measurements: no wasm profile was taken, and the cost of the single-core preparation at the demo's actual graph sizes is unknown. The work is scoped in .nornir/gpu-prep-in-the-browser.md; steps 1–2 of its order are done, step 3 (measure in a browser) is not.

2026-09-19: the water shader, and a browser validator the tree now runs

The row 13 measurement left one browser-only defect behind: Chrome's WGSL validator (Tint) rejected facett-map3d/src/water.wgsl — textureSample inside a non-uniform if — so map3d_water_pipeline was invalid on the web while every native test stayed green. naga 29.0.4 accepts that shader with every validation flag on; checked the same day, so this is not a flag we forgot to set. The shader now samples a clamped UV unconditionally and chooses with select.

The guard is xtask/tests/wgsl_compiles_in_the_browser.rs: every .wgsl in the tree, compiled by headless Chrome, verdicts POSTed back to a TcpListener the test owns. Headless is the RIGHT vehicle for this one thing — validation needs no canvas and no readback — and the wrong one for everything row 13 said it was wrong for. The test first compiles a reduced copy of the defect and requires Tint to reject it; a green from a validator that has stopped enforcing uniformity is not a green. No browser on the box → a printed SKIP, and a failure under CI / FACETT_REQUIRE_GPU, the gpu_skip rule.

Row 14 — the demo's size + generator, Universe and decograph in the wheel (lane F, 2026-10-02)

Owner order 2026-10-02: what facett-demo got on 2026-10-01 (lanes D and E) must also be in the Streamlit facet demo. It had none of it: the wheel lane S built from 85e9135 carried no universe, no decograph, and no way to ask any graph component for a generated graph except by sending every node as JSON.

One writer: facett-graphgen

The generators lived in facett-graphview::generate, but everything between "a generated graph" and "a component on screen" — the knut-popsim population source, the lowerings (to_pan_model, to_view_model, to_propgraph, …), the per-tab builders (framing, Layout3D::Given above 10k, the GPU lanes), GenTab with its summary above 5 000 nodes, and the Universe tab — lived in the facett-demo BINARY crate, which facet-wrapped cannot depend on. A second copy in facet-wrapped would be two writers for "what does sbm:100000:1 draw" (LAW 5). So they moved, unchanged, into a library crate, facett-graphgen; the demo re-exports it under its old module paths (graphgen, universe_tab, gen_build) and keeps only its own tab types' builders and GenSummary impls. VgLabTab::from_generated now takes its navigator from build::graphnav, the function facet-wrapped's graphnav calls; apply_gen_link uses the library's gen_link_json. Held by facett-graphgen a_tab_built_from_a_request_is_the_demo_tabs_model (the seam's state == the demo tab's state for the same request, gen timing stripped).

The seam

{"generate": {"source": "sbm", "size": 100000, "seed": 1}} (or the deep-link string {"generate": "sbm:100000:1"}) goes to an entry's Generator instead of its DataFactory (registry.rs Entry::with_generator, Registry::build_with). The size is clamped to the entry's ceiling; an unknown source, a source the entry does not offer (demo is never offered: there is no fixture to show where generated data was asked for), or a non-number size is refused by name (EmbedError::BadRequest), and a request to an entry without the seam is EmbedError::NotGenerated. The manifest advertises generate: {max, sizes, sources: [{key, label}]} per entry (null without the seam), and the Streamlit picker reads exactly that.

entry ceiling built by what it is
universe (new) 1 000 000 UniverseTab::from_request the demo's Universe: the star-field on the resident cloud, galaxy placement, no CPU painter
decograph (new) 100 000 GenTab + build::decograph the demo's decograph tab: graphview's DecoratedGraphView WITH its size + generator row; fixture = its 4-node DAG; a plain graph payload goes through graphview's adapter
graphpan 100 000 build::graphpan resident above 5 000
graphview 100 000 build::decograph the same component as decograph, without opening on the row
graphnav 100 000 build::graphnav places + GPU cloud (+ GPU layout job)
graph3d 100 000 build::graph3d Given placement above 10k
lineage 100 000 build::lineage was fixture-only; the roster shrank 8 → 7
graph2d 1 000 build::graph fixture scale: an AccessKit button per node

Every generated component is a GenTab: above COMPACT_ABOVE (5 000) facet_state publishes the summary (counts + lane readouts) — the same rule as the pages bundle.

Measured (lane F, 2026-10-02)

Row 15 — two PyPI packages, 0.2.0: facett + facett-demo (lane Y, dwarf, 2026-10-02)

Owner decision 2026-10-02: publish to PyPI as two packages at one version. Names checked free on pypi.org (and facett on test.pypi.org) the same day.

package import extras ships sdist
facett 0.2.0 (was facet-wrapped-web 0.1.19) facett; facet_wrapped_web = alias that warns, removed in 0.3.0 [streamlit] → streamlit>=1.40,<2 the four bundles (core/docs/model3d/sim) + loader + routing table + facett.streamlit none — the payload is prebuilt wasm; a source tarball could not rebuild it
facett-demo 0.2.0 (was the unreleased facett-demo-python) facett_demo, command facett-demo [korp] → grpcio + protobuf the Streamlit app, payload table, error catalog, baked git hash yes (pure Python)

Measured (dwarf + loki + t14s, 2026-10-02, facett 340af30)