Gunnar

nordisk/facett

.nornir/facett-embed-data-seam-design.md

> **SUPERSEDED — kept for the analysis, not for the instructions.**
>
> The `facett-embed` crate described here no longer exists (it was `facett-streamlit`
> before that). Its successor is `facet-wrapped` + the `facet-wrapped-web` wheel, and
> the current runbook is `.nornir/python-packaging-publish-runbook.md`; the rows and
> measurements are in `.nornir/facet-wrapped-wheel.md`.
>
> **What survives, and is why this file is still here:** the analysis below — that a
> component falling back to its own demo fixture is indistinguishable from success,
> and that the fix is structural rather than careful. That is still the law the
> loader's refusal path implements.

# facett-embed — the data seam (and why demo fixtures were the bug)

`facett-embed` (formerly `facett-streamlit`) embeds the **real** facett components
into any web host as wasm. This document is the "perfect wrap" analysis: what went
wrong, why it was structural rather than careless, and what changed in facett.

## The failure, precisely

The Python Streamlit companion (`facett/py/facett_demo_python`) shipped a Sweden map
built with **pydeck**, a graph built with **networkx + plotly**, and zero facett code.
Rickard spotted it instantly — *"the map looks a bit too good … is it really our map
tech?"* It wasn't.

The lookalike then drifted, immediately and visibly: pydeck defaults to
`map_provider=CARTO`, which drew a **third-party world basemap** underneath our data,
so a correctly-clipped Sweden appeared to include **Africa**. The data was never
wrong — measured at the source, **0 of 55 526 way vertices** fall outside the Sweden
bbox. The second renderer was wrong.

## Why it happened — the structural cause

It is tempting to file this as "someone took a shortcut". It was not. The bridge
**could not** have rendered korp data, for two independent reasons:

1. **Every registry factory was a zero-argument demo fixture.**
   `GraphNav::demo()`, `TubeView::demo()`, `StatGridView::local()`, … — 13 entries,
   all fixtures, and **no data-injection API anywhere in the crate**. A host holding a
   live korp scene had no way to hand it in. The bridge was a *showcase*, not an
   embed. Faced with "render this data", bypassing it was the only option.
2. **The map was excluded by construction.** The dependency comment scoped entries to
   components "with a zero-arg `local()`/`demo()` Facet builder (pure egui/serde, no
   wgpu/gix/fs)" — precisely the predicate that leaves the OSM map out. The single
   most-wanted component was unwrappable, so it got hand-rolled.

The lesson generalises: **a wrapper that cannot accept data will be bypassed**, and
every bypass becomes a second implementation that drifts. LAW #2 (both demos in
lockstep) cannot be satisfied by discipline when the architecture forbids it; one
implementation compiled to two targets satisfies it by construction.

## What changed in facett

### 1. Entries are data-driven (`registry.rs`)

```rust
pub type DataFactory = fn(&serde_json::Value) -> Option<Box<dyn Facet>>;

Entry::new("osm2d", "…", || Box::new(OsmView::local()))   // fixture (showcase)
    .with_data(crate::data::osm2d_from_json)              // LIVE (host rows)
```

`Entry::build()` still returns the fixture. `Entry::build_with(&payload)` builds the
same component **from the host's data**.

### 2. Refusal is typed and loud — never a silent fixture

```rust
pub enum EmbedError {
    UnknownComponent(String),
    NotDataDriven(&'static str),   // "…must not be shown as live data"
    EmptyPayload(&'static str),
}
```

There is deliberately **no fixture fallback** on the data path. A caller that asked
for *its own data* and silently received canned demo data is the exact failure we are
engineering against — a fixture rendered as if it were live is worse than an error,
because it looks fine. The manifest carries `data_driven: bool` per component so a
host can grey out or refuse up-front, and the frontend loader calls `facett_check_data`
**before** mounting, showing the refusal text *instead of* a canvas.

### 3. The MAP wave (the gap that caused this)

| name | component | payload |
|------|-----------|---------|
| `osm2d` | `facett_geomap::OsmView` — the OSM 2D navigator | points + ways (+ raw OSM tags) |
| `geomap` | `facett_geomap::GeoMap` — geo points/hotspots | points + hotspots |
| `map` | `facett_map::MapView` — point/line viewer | points |
| `graphnav` | `facett_graphnav::GraphNav` — Graph-DB IDE | nodes + edges |

All four build for `wasm32` on the default path (their wgpu fast-paths stay off, so
the pure-egui CPU painter runs in the browser canvas).

Crucially, ways are reconstructed with **`facett_map::Way::from_lonlat(osm_id, lonlat,
tags, theme)`** — korp's raw OSM tag JSON is passed straight through, so the
*component* performs classification and styling. The host never picks a colour. This
is what makes the embedded map identical to the native one rather than similar to it.

### 4. Payload shapes (mirror the source system's own reply)

```json
// osm2d / geomap / map  — mirrors korp MapQueryReply
{ "title": "Sweden",
  "points": [[lon, lat], …],
  "ways":   [{ "osm_id": 12, "coords": [lon,lat,lon,lat,…], "tags": {"highway":"primary"} }] }

// graphnav — mirrors korp GraphReply
{ "title": "korp graph",
  "nodes": [{ "id": 1, "label": "Person", "key": "Person/1" }],
  "edges": [{ "src": 1, "dst": 2, "rel": "OWNS" }] }
```

`coords` is korp's flat `[lon,lat,…]` run exactly as it arrives on the wire; `tags`
accepts korp's raw JSON *string* or a real object. Adapters are **total** — a
malformed or empty payload yields `EmptyPayload`, never a panic inside the canvas.

### 5. Embedding fix in `facett-geomap`

`OsmView` fits its camera to the data on first paint, then latches `fitted = true`. An
embedded canvas is laid out **asynchronously** — the first frames can arrive with a
0-sized or still-growing rect, and committing the one-shot fit to one of those strands
the camera at the wrong zoom for the whole session. The fit is now only *persisted*
once the pane exceeds `MIN_FIT_RECT_PX`; until then it re-fits the local copy each
frame (correct paint, no cost). This is a fit-*gating* change, not a rendering change.

## The rename

`facett-streamlit` → **`facett-embed`**. The crate is not Streamlit-specific: it
embeds real facett components into any web host. Streamlit is one consumer. Updated
in lockstep: workspace `members`, `facett-py`'s path dep + `use facett_embed::Registry`,
the xtask (`release-embed`, with `release-streamlit` kept as a back-compat alias), the
Python companion package (`facett_streamlit` → `facett_embed`), and the wasm exports
(`facett_embed_state` / `facett_embed_mounted` / `facett_embed_error`).

## What the Python demo looks like now

| pane | renders via | why |
|------|-------------|-----|
| 🗺 Map | `facett_component("osm2d", data=…)` | a facett component exists — use it |
| 🕸 Graph | `facett_component("graphnav", data=…)` | a facett component exists — use it |
| 📊 Population | pandas + plotly | **no facett component is duplicated** — Iceberg aggregates are pure data analytics, which is what Python is genuinely good for |

Deleted as they were replaced (never leaving :8501 broken): the pydeck map body, the
networkx/plotly graph body, and `theme.py`'s OSM classification/colour tables (that
logic belongs to `facett_map`, and a second copy is exactly the drift we removed).
`pydeck` and `networkx` are gone from `requirements.txt`.

## Guards (so this cannot regress)

Rust (`cargo test -p facett-embed`, 27 tests):
- `map_and_graph_are_data_driven` — the map + graph MUST accept live data.
- `the_fixture_only_set_only_shrinks` — a frozen roster of fixture-only entries;
  adding a new one to a data-bearing surface fails the build.
- `live_build_refuses_loudly_instead_of_faking_it` — every refusal is typed; none
  returns canned data.
- `data_payload_builds_a_live_component` — the host's payload really reaches the
  component (its title comes from the host, not the fixture).

Python (`pytest`, 18 tests):
- `test_component_pane_has_no_lookalike_renderer` — a facett-component pane may not
  import pydeck/plotly/networkx/altair/folium/matplotlib/bokeh.
- `test_component_pane_embeds_the_real_component` — it must call `facett_component()`
  with the right registry name.
- `test_the_registry_exposes_the_data_driven_components_the_panes_need` — fails if the
  wasm bundle is missing or was built from a fixture-only registry.
- `test_analytics_pane_may_use_plotly` — asserts the *allowed* case explicitly, so the
  guard above is never "fixed" by banning plotly outright.

## Render proof

Driven headless (Playwright/Chromium) against the shipped app on `:8501`:

```
mounted   : osm2d          ← the REAL facett component, not a lookalike
hasCanvas : True [1140, 620]
refusal   : ''             ← no fixture substitution
state     : center [17.146, 63.108]   ← central Sweden
            lod_sizes {country: 401, region: 10764, city: 16219}
            fps 42, gpu_enabled false (CPU painter)
```

The component paints its own status bar — `sweden — korp MapQuery · 4000 pts / 27384
ways · zoom 5517px/u · 63.1079,17.1461 · 40 fps` — which is facett chrome, not
Streamlit's. **No third-party basemap**: the canvas is our geometry on our background.
`graphnav` likewise mounts with korp's `Company`/`Vessel` nodes.

## Build + run

```bash
cargo xtask wrap-wasm        # the REAL components -> wasm32 + wasm-bindgen + manifest
cargo xtask publish-pip      # -> the wheel, built IN A CONTAINER, verified back
cargo xtask publish-holger   # -> holger's PEP 503 index (znippy as a library)
cargo xtask streamlit        # -> the demo, in a container, on the INSTALLED wheel
cargo xtask test-demo        # -> its python suite, in the same container

On oden it runs as the systemd `--user` unit `facett-demo-python.service` (port 8501).