# facett-demo (was facett-demo-python) — the Streamlit companion to facett-demo
> **0.2.0 (2026-10-02): a PyPI package.** `pip install facett-demo`, command
> `facett-demo` (Streamlit started in-process via `streamlit.web.cli`, 127.0.0.1:8501),
> import `facett_demo` (was `facett_demo_python`, run from source). It depends on
> `facett[streamlit]` — the components with their Streamlit glue, which moved from this
> app's `facet_host.py` into `facett.streamlit` (the app keeps only its dev-mode toggle
> there). The korp gRPC wire (`grpcio` + `protobuf`) is the optional `[korp]` extra:
> without it `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`). The version is read from the installed
> metadata; the git hash is baked into `_buildinfo.py` by `cargo ops publish-pip` (Rust,
> into the staged package — `scripts/stamp.py` is gone). The container runs the
> INSTALLED wheel, never the source tree (`cargo ops streamlit`, `cargo ops test-demo`:
> the tests locate the app through `facett_demo.__file__`). Paths below that say
> `facett_demo_python/` are now `facett_demo/`; publishing: `.nornir/pypi-publish.md`.
`facett-demo-python` is the **Python track** of facett-demo: a Streamlit app that
mirrors facett-demo's three core surfaces using the Python data-science stack,
driven by **LIVE** data from the `korp-server` gRPC control plane. It is a
documented exception to the suite's Rust-100% law — demo/companion tooling for the
Python/data-science ecosystem, NOT a replacement for the Rust `facett` framework.
> **Superseded in part.** The map and graph panes originally re-implemented facett
> surfaces in pydeck / networkx+plotly. They now embed the REAL components as wasm
> through `facet-wrapped-web`. See `facet-wrapped-wheel.md` for the analysis and
> the data seam; this file documents the demo's placement and its korp data plumbing.
## Placement (why `facett/py/`)
It lives at **`facett/py/`** — a pip-installable package (`facett-demo-python`,
module `facett_demo_python`) beside the Rust crates, so the Python demo sits next
to the framework it mirrors without polluting the Cargo workspace. It is NOT a
workspace crate and is not built by cargo.
```
facett/py/
pyproject.toml # pip-installable; console script `facett-demo-python`
requirements.txt # pinned, verified on Python 3.14 (oden)
README.md
scripts/stamp.py # bakes git-hash+build-time -> _buildinfo.py at build
facett_demo_python/
__init__.py # exports version/build_info
buildinfo.py # version+git-hash (baked -> live-git -> "unknown")
client.py # ONE gRPC seam over korp-server; plain-dataclass returns
theme.py # palette + OSM feature-class classification
app.py # Streamlit entrypoint (sidebar + 3 tabs); `streamlit run`
cli.py # `--version` / `about` / `run`
proto/ # generated korp_pb2 / korp_pb2_grpc (+ korp.proto copy)
panes/
map_pane.py # 🗺 Sweden map (pydeck / deck.gl)
graph_pane.py # 🕸 Graph (networkx + plotly)
population_pane.py # 📊 Iceberg browser + Population diagrams
tests/test_app.py # headless robot-UI (Streamlit AppTest)
```
The existing sibling crates `facett-py` (PyO3 native bindings) and
`facet-wrapped` (the wasm bundle) / `facet-wrapped-web` (the wheel) (a WASM Streamlit *component*) are unrelated: this is a
standalone Streamlit **application**, not a component or a native binding.
## Data source — LIVE korp-server, one RPC per pane
The control plane is `korp-server` gRPC at `:50051` (override with
`$KORP_SERVER`). Stubs are generated from `korp/proto/korp.proto` with
`grpc_tools.protoc` (the grpc stub's top-level `import korp_pb2` is rewritten to
`from . import korp_pb2` so it imports as a package submodule). Every pane reads
through `client.KorpClient`, which returns plain dataclasses (no protobuf leaks
into the UI) and raises `KorpUnavailable` carrying the server's own error string.
| Pane | Viz library | korp-server RPC(s) | Notes |
|------|-------------|--------------------|-------|
| 🗺 **Map** | `pydeck` PathLayer + Scatterplot + Text | `Assets` → region roster; `MapQuery` → viewport geometry | Ways coloured by OSM feature class (`theme.classify_way`); place-name nodes labelled. Server prunes GeoParquet row-groups — ZERO local files, ZERO S3 on the client. |
| 🕸 **Graph** | `networkx` spring layout + `plotly` | `Graph` (Cypher; blank graph ⇒ server default) | Node colour encodes node label (fixed categorical order). Fat node returns a seed scene when no live FalkorDB is attached. |
| 📊 **Population** | `pandas` + `plotly` bars (the ONLY pane allowed a Python renderer — it duplicates no facett component) | `IcebergTables` (catalog+schema+stats); `TablePreview` (schema + capped, server-formatted rows); `Population` (aggregate tables + chart series) | Bars encode magnitude → single sequential hue. All 3 RPCs over the bound skade/demo warehouse. |
| sidebar | — | `Health` | Shows the connected server's `version (hash)` + uptime, so it is always obvious WHICH build backs the demo. |
## Honest states (LAW #6 — no blank false-green)
Each pane either shows live data or a loud panel: `st.error` for an unreachable
server / failed render (carrying the server's error string), `st.warning` for an
honest empty result, `st.info` for empty sub-sections. The sidebar shows
`connected` (green) or `server unreachable` (red) from the `Health` probe. There
is no code path that renders an empty-but-green pane.
Known live caveat on oden: MinIO/S3 (`:9000`) may be offline, so `Assets` returns
its listing error — but `MapQuery` still renders from the **local** `sweden`
GeoParquet (`~/.korp/work/sweden`), so the map is populated regardless. Both facts
are surfaced (the S3 warning + the row-group provenance in the status line).
## Version + git-hash (build-version-githash LAW)
`buildinfo.build_info()` resolves the hash three tiers deep: a baked
`_buildinfo.py` (written by `scripts/stamp.py` at build/wheel time) → a live
`git rev-parse --short HEAD` of the checkout → the honest literal `"unknown"`.
Shown in the **sidebar** ("App build") AND answered by `facett-demo-python
--version` / `about`. `_buildinfo.py` is git-ignored so dev checkouts always show
the live hash; wheels carry the baked stamp.
## Headless robot-UI test (LAW)
`tests/test_app.py` drives the app with `streamlit.testing.v1.AppTest` (no
browser). It auto-detects whether korp-server is reachable and asserts the
matching contract, so it is green both on oden (server up) and in a bare CI:
- app runs without exception; title + sidebar build stamp present;
- all three pane headers render (tabs execute all children in one pass);
- **server up**: sidebar shows `connected` + the server `version`; Map/Graph/
Population metrics are present (they only appear when live data arrived), and
at least one dataframe (the Iceberg catalog) is non-empty — never a blank green;
- **bad target**: pointing the sidebar server box at an unreachable target reruns
to an HONEST error panel, not a crash and not a silent blank.
`AppTest` result on oden (server up, korp `0.1.0`): **6 passed**.
## Run it
```bash
cd facett/py && python -m venv .venv && . .venv/bin/activate
pip install -r requirements.txt && pip install -e .
streamlit run facett_demo_python/app.py --server.port 8501 --server.address 127.0.0.1
```
On oden it is deployed as a systemd **--user** transient unit
(`facett-demo-python.service`, port 8501, linger on) so it survives the shell that
launched it — mirroring the oden rootless-podman/linger infra pattern.
## Grouped like facett-demo, with the demo's size + generator (lane F, 2026-10-02)
Owner order 2026-10-02: what facett-demo got on 2026-10-01 (grouped tabs, the size +
generator control, the Universe tab, decograph/graphpan on the resident GPU lane) must
also be in this Streamlit demo.
* **Top tabs = facett-demo's tab groups.** `gallery_pane.GROUPS` maps each registry
namespace to the demo's group (`facett-demo/src/lib.rs` `TAB_GROUPS`): graph →
🕸 Grafer, map → 🗺 Kartor, data → 📊 Tabeller & diagram (the registry's `data`
namespace holds both the demo's Diagram and Tabeller members), ops → ⚙ Bygg & jobb,
ui → 🎛 App & UI, anything else → Övrigt. Each group opens on **🎨 Komponenter** (the
gallery's wasm components of that group) and carries its live korp pane beside it:
Graph under Grafer, Map under Kartor, Population under Tabeller & diagram, Errors under
App & UI (where the demo keeps `Errors`). The gallery heading and its mode switch sit
above the tabs, once.
* **Size + generator next to every generated component.** A manifest entry with
`generate` (`graphnav`, `graph2d`, `graph3d`, `graphpan`, `graphview`, `lineage`, and
the new `universe` and `decograph`) gets a picker column: data source, size, seed —
the sources and sizes read off the SHIPPED manifest (`generate.sources`,
`generate.sizes`, `generate.max`), so the picker cannot offer what the component will
not build. The pick goes to the wasm as a generator REQUEST,
`{"generate": {"source": "sbm", "size": 100000, "seed": 1}}`; the graph is generated
inside the wasm by `facett-graphgen` (the demo's own generators and lowerings, moved to
a library for this), so 100 000 nodes never cross from Python. `?gen=sbm:100000:1`
preselects it (the demo's deep-link shape), `?pick=` still picks the component.
* Above 5 000 nodes the component's `facet_state` is its summary (counts + lane
readouts), exactly as on the demo's pages bundle.
* Tests: `py/tests/test_gallery_pane.py` (the wheel ships the seam with the right
ceilings; `?gen=` mounts the request; the picker drives it; the tabs are the demo's
groups; the link parser); `facet-wrapped/src/generated.rs` and
`facett-graphgen/src/lib.rs` (Rust: every generated entry builds from a request, bad
requests are refused by name, sizes clamp, the summary above 5 000, and the seam builds
the same model as the demo tab for the same request).