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.
--no-isolationis not a shortcut. With isolation,python -m buildcreates a venv and DOWNLOADS setuptools from PyPI — impossible on--network none, and a different backend version every run. The image carries the exact backend instead, and asserts at image-build time that it can actually use it: the recipe builds a throwaway wheel with the same flags and fails by name if the result is missing, empty, or not a zip. (Thecargo-toolchain.Containerfilelesson:rustc --print target-listprinted a target it had no std for and exited 0.)- The base is pinned by digest (
debian@sha256:6788062a…), andbuild_image_reproduciblesets podman'stimestamp+outputformatso the image ID is a function of the recipe rather than of the minute it was built.
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:
- every recorded file is present, and its sha256 and size match;
- every file in the archive is recorded (an unrecorded file is one
pipinstalls and never uninstalls); - all four shipped assets exist, are non-empty, and
facet_wrapped_bg.wasmactually begins with the\0asmmagic.
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
- The image ID is not read back.
draupnir::container::build_image_reproduciblereturns the tag it was asked for, and draupnir'sinspect_imagediscards the daemon's reply (Ok(_) => Present), so nothing here can name the id of a freshly built image without exporting the whole thing to a tar. The build genuinely is reproducible — the parameters that make an id a function of the recipe are set — butPipBuildOutcome::image_refis not the evidence of that, and both the field doc and the printed line say so. Closing it is one line in draupnir (carry the id out ofinspect_image), owned by whoever is in that repo. - The apt archive is
deb.debian.org, notsnapshot.debian.org. Same reproducibility level as the cargo and kernel toolchain images: the base layer is pinned to the byte, the packages on top are pinned to whatever trixie serves that day. Moving all three to a snapshot URL is a deliberate change to make together — doing it for this file alone would make facett's wheel "more reproducible" than the binaries it ships beside, which is a claim, not a property. - Nothing is published yet. The wheel exists and verifies on disk; holger's PEP 503 index has not been written and no
pip installhas been run from it. That is row 1b, and until it lands, "ready for release" means the artifact is ready, not that a release happened.
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:
- the extracted content is IDENTICAL (
diff -rqover both unpacked trees: no differences, and thetar -tvlistings match line for line); - the difference is timestamps only — a PAX
mtime=1788609927.7613797header (wall clock, sub-second) versusmtime=1788609943.2204928sixteen seconds later, and the gzip header's own MTIME field (87 05 9c 6avs97 05 9c 6a).
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
- Nothing has been INSTALLED from the index yet. The archive is packed and its bytes verified;
holger-serverhas not been built on this box, so nopip install --index-url http://127.0.0.1:8455/pip/simple/ facet-wrapped-webhas run, in a container or anywhere else. Until that happens, "published" means the archive is correct — not that a client resolved it. That is row 1c, and it needs aholger-serverrelease build, which is CPU this box's bench lane is measuring on. - The sdist's timestamp non-determinism, above.
- apt is still
deb.debian.orgrather than a snapshot — unchanged from row 1, and bevis is holding the same call for the cargo and kernel images so all three move together.
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 local
~/git/znippyis at compress 0.9.14 / common 0.9.15. - holger-server on this box links compress 0.9.13 / common 0.9.14 from the registry.
- The on-disk sub-index schema changed between those (a column moved
UInt8↔UInt32), so anything the local checkout writes is unreadable by the deployed reader. - Ruled out first: it is not a mixed-extension-schema problem from packing a
.whland a.tar.gztogether — an archive holding only the wheel fails identically. - holger's own manifest dates the split. On 2026-08-23 it removed its znippy path patches on the stated grounds "znippy-compress registry 0.9.13 == local 0.9.13". That was true then. The local checkout has moved since, and nothing noticed, because nobody had asked holger to read a freshly written archive.
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
- The brief said to assert
state_json.renderer == "wgpu". There is norendererfield. map3d publisheslane_flag(gpu/cpu/pending, fromfacett_core::render::lane::LaneFlag::token()), plusused_gpu,gpu_lane_selectedandbackend. A robot pointed atrendererwould have comparednullto"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 arendererfield appears, so the two names cannot both exist. - I first wrote that
LaneFlag::token()had zero callers. That was wrong — my grep pattern missedself.lane_flag().token()atfacett-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-streamlitandfacett-embedare crates that were removed.cargo xtask release-streamlitandrelease-embedare 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 getsunknowncommandand 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
- 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.
- Every
cargo xtask <verb>a document instructs the reader to run must be a verb the dispatcher actually has — read out ofmain.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:
- the ghost-name guard has a quotation rule (a markdown
>blockquote), because a document that corrects an old instruction has to be able to show it, and forbidding that makes every correction vaguer than the mistake; - the bind guard has none, and the prose was reworded instead. An exemption mechanism in a security guard is a hole in it. "It's only in a comment" is exactly what someone will say about the line that re-opens the port.
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:
- the stripped page must still contain
<script type="module">and >2 000 bytes — a stripper that returned""would fail every assertion loudly, but one that returned the whole file would make them all pass silently; - the stripped page must NOT still contain a sentence that only exists in a comment;
a_page_that_only_documents_the_protocol_is_refusedruns every assertion against a page whose entire protocol is comments, and requires each to fail.
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.
declare_component(name, path=None, url=None); "Either 'path' or 'url' must be set."; "If both are specified, thenurlwill take precedence."urlis used verbatim — Streamlit does NOT appendindex.htmlto it. The join happens on thepath=branch only. knut answers that withEntryPoint's303; korp could not, because tonic mounts a named service at/{NAME}/{*rest}and axum's{*rest}will not match an empty tail.- the iframe's
sandboxincludesallow-same-origin, so the iframe keeps knut as a real origin andfetch/EventSourceback to knut are genuinely same-origin. No CORS anywhere. That is the whole point of the shape. - the constraint nobody named, and it can kill the demo silently: a secure context requires EVERY ANCESTOR to be one.
Window.isSecureContextis false if any ancestor browsing context is insecure, so it is not enough for knut to be onlocalhost— the Streamlit page itself must be a secure context too. Streamlit onhttp://oden:8501with knut onhttp://localhost:PORTgivesnavigator.gpu === undefinedinside the iframe, and facet-wrapped correctly recordsfacet-gpu-1and refuses. Both origins on localhost, or both on https. That belongs in the skidbladnir declaration of the demo host, not in a comment. - a risk, not a finding, because it cannot be tested from this box: the iframe's
allowlist does not includewebgpu. Today that costs nothing — WebGPU is not a policy-controlled feature in shipping browsers — but if it becomes one, a cross-origin iframe loses it by default. A robot answers that; a reading cannot. - an argument for the server-to-server shape that had not been made: Streamlit posts the render args with
targetOrigin: "*". Under the old shape that is where a korp bearer was going to ride — broadcast to whatever origin the iframe held, with no origin check. The new shape deletes the question instead of answering it: the browser never gets a korp token because the browser never talks to korp.
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:
- The selector is compiled out.
facett-graphnav::layout::force_selectedroutes large graphs togpu_layout::force_layout_gpuunder#[cfg(feature = "gpu")].facett-graphnav'sgpufeature isdefault = [], andfacet-wrappednames graphnav with no features — so the arm is not in the build and every graph takes the CPU lane. - It could not run anyway.
gpu_layoutacquires its own device withpollster::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 awgpu::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
facet-wrappednamesfacett-graphnavwithfeatures = ["gpu"]; the selector is in the build. Its BLOCKING arm is nownot(target_arch = "wasm32"), so turning the feature on did not makepollster::block_onreachable.install_gpu_lanesLENDS the host's device:gpu_layout::install_host_device(&rs.device, &rs.queue).- The lane that runs is
gpu_layout::LayoutJob—startstreams the CSR and returns, eachpolldoes 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:
- Headless cannot prove this, and says something misleading instead. Headless Chrome only offers SwiftShader, the job STARTS there (
pendingfor ~2 s) and then the readback'smapAsyncis rejected with Dawn's "A valid external Instance reference no longer exists" — while plain-JSmapAsyncon the same browser works. The same run logsCould not find SharedImageBackingFactory … WebgpuSwapChainTexture: headless SwiftShader cannot present a WebGPU canvas at all. The job falls back to the CPU lane correctly; the point is that a headless robot would report "GPU layout broken" about a lane that works. The readout now carries the reason (canvas.layout_fallback), because"cpu"alone could not tell "never eligible" from "tried and failed" and those need opposite fixes. - A probe that uses
--virtual-time-budgetunder-reports WebGPU. Virtual time does not wait for the GPU process, sorequestAdapter()looks like it never settles. Hold the load event open in REAL time instead. (This cost a wrong conclusion about snap Chromium before it was caught.)
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)
- Rust: facet-wrapped lib 66 (incl. 3 generated::tests), facett-graphgen 9, xtask (11 test binaries, all green) incl.
gallery_covers_the_manifest5/5, facet-wrapped-group plain/docs/model3d/sim 66/59/58/58, facett-demo lib 143 +graph_tabs_at_scale4 — green on dwarf. - The wheel road:
cargo xtask wrap-wasm(4 groups, pinned image),cargo ops publish-pip(wheel sha2563852bcf7…, 19 members, 18 hash-checked),publish-holger,verify-install(every asset identical, 51 components),test-demo58 passed, 5 skipped in the container. - GPU: gallery probe 51/51 + wrong-bundle 3/3 on the t14s (Radeon) and on loki (RTX 5080); numbers in
facett-benchmarks.md("The Streamlit wheel's generated graphs at 100k"). - Found on the way: (1) the first probe froze its tab on
lineage— graphview's cycle walk (find_cycles) enumerated simple PATHS without a bound; a 120-node generated graph never returned fromstate_json. Bounded at 200 000 DFS steps (CYCLE_STEPS), f125c0a. (2)simview(the sim bundle) had no gallery payload, so the suite against a 4-bundle wheel was 2 red. (3)facet-wrapped/tests/inventory.rsdid not listfacett-ops.
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) |
- The Streamlit glue moved from the demo's
facet_host.pyintofacett.streamlit(facett_component(name, *, data, height, key, dev, nav)), import-guarded: plainimport facettimports no Streamlit, andimport facett.streamlitwithout it raises anImportErrornamingpip install 'facett[streamlit]'. The module name is reserved infacett/tests/no_module_shadowing.rs; it is exempted there as a src-layout package SUBMODULE, and the exemption's three conditions are asserted. facett-demostarts Streamlit in-process (streamlit.web.cli, no executable on PATH, 127.0.0.1 by default) and depends onfacett[streamlit]>=0.2.0,<0.3. Without[korp],client.korp_available()is false, the sidebar says "offline mode" and each korp tab mounts the same components on the gallery's payloads under a "NOT live data" banner (panes/offline_pane.py).- Recipe: rows
facett-wheel+facett-demo-wheel(kind package, ORIGINAL),PYPI_VERSION = "0.2.0"the one writer (both pyprojects and the demo's pin are held to it bypython_versions_track_the_workspace.rs); 12 rows emitted. cargo ops publish-pipbuilds both through edda'spackage::build_script, so--sdistfollows each cell.verify_demo_wheelrefuses a demo wheel without its JSON tables (setuptools leaves non-.pyfiles out unlesspackage-datanames them — the pre-0.2.0 pyproject listed onlysaga.proto), without thefacett-democonsole script, without an unconditionalfacett[streamlit]requirement or without thekorpextra; both wheels must carryPYPI_VERSIONand pass the uploader's offline PyPI check.cargo ops publish-pypi: the warehouse legacy upload API in Rust (ureq + rustls), token from$PYPI_API_TOKEN/$TEST_PYPI_API_TOKENor~/.pypirc, never printed (13 mock-warehouse tests, one with a server that echoes the Authorization header; blindingredactfails exactly the two tests that guard it). Default index testpypi.
Measured (dwarf + loki + t14s, 2026-10-02, facett 340af30)
- wasm (
cargo xtask wrap-wasm, pinned cargo image): core 12 912 341 B, docs 9 058 752, model3d 7 251 923, sim 9 575 300; 51 components. facett-0.2.0-py3-none-any.whl12 509 079 B, sha2569fec3f1e1b3b042886f3a13cbe8825ab8737256a66da6ece4b9e7730f5d79163— byte-identical over three builds (and across two commits that touched only docs).facett_demo-0.2.0-py3-none-any.whl88 388 B, sha2560cefda67e20ff3ae2b633b8e3d6e600621986c5a1ee7eda7fceaa8c1956c8ad3— identical over two builds of one clean commit (the first build differed only in its stamp,+dirty: a doc was being edited during the build — the stamp is honest).facett_demo-0.2.0.tar.gz82 555 B — content identical across builds, bytes NOT (tar mtimes + gzip header are wall clock in setuptools' sdist; the row 1b finding, unchanged).cargo ops verify-demo-install(clean consumer image, Streamlit 1.64.0 + tree from PyPI):facettalone → 51 components, 4 bundles, Streamlit absent, the glue's refusal names the extra, the alias re-exports and warns; both wheels →facett-demo 0.2.0 (340af30),/_stcore/healthok, page 200, the INSTALLED app under AppTest: 0 exceptions, grpc absent, 3 offline banners.cargo ops test-demo: 59 passed, 5 skipped, against the installed wheels.- Streamlit on dwarf (
draupnir-facet-wrapped-demo, 127.0.0.1:8501): the INSTALLED wheels (facett-demo[korp]from the read-only wheelhouse bind), health ok, also through the t14s reverse tunnel. - gallery probe on loki (RTX 5080, headless, the INSTALLED assets copied out of that container, byte-identical to
wrap-wasm's): 51/51 PASS, wrong-bundle 3/3, every adapternvidia blackwell. publish-pypi --repository pypi --dry-runon the t14s (token from~/.pypirc [pypi], not shown, 0 occurrences in the log): all checks pass, 896 classifiers known, 0.2.0 not on pypi.org. Nothing uploaded — the upload is the owner's go.