Gunnar

nordisk/dwarves

.nornir/convergence-ph2-plan.md

# Release-Convergence Viz — Phase 2/3 plan (#28)

**Status:** design (READ-ONLY authored; no code touched). A build agent executes this directly.

**The artifact chain already landed (Phase 1):**
- `dwarves` `release_doctor::graph_diff` — the before→after `GraphDiff` + `GraphDiffArtifact` (`crates/dwarves/src/release_doctor/graph_diff.rs`, 953 LOC, green). This is the **topology + node/edge delta** source.
- `facett-releaseview` — the engine-agnostic `ReleaseConvergence` model (`facett/facett-releaseview/src/model.rs`) + the CPU pseudo-3D `Planes3D` hero and `TriBadge2D` fallback (`.../src/lib.rs`). Pure egui-painter, wasm-clean, green.
- `facett-graph3d::CodeVault` — the wgpu-capable 3D box vault (`facett/facett-graph3d/src/codevault.rs`) with a real orbit camera, depth-sorted projection, GPU bloom lane (`gpu_scene`, `graph_gpu.rs`), and per-node `NodeState` (Ok/Changed/Glitch).

**Phase 2/3 delivers three things:**
- **(a)** the DWARVES-SIDE one-way adapter `graph_diff + 3 version sources → ReleaseConvergence` (facett stays pure).
- **(b)** upgrade `Planes3D` from CPU pseudo-3D onto real `CodeVault` wgpu depth planes.
- **(c)** `dwarves release watch` — a CLI that streams the convergence live.

---

## The three version sources (all already exist in dwarves — REUSE, do not twin)

| role | source of truth | API to call | notes |
|---|---|---|---|
| **LOCAL** (manifest) | working-tree `Cargo.toml`s | `release_doctor::cargo::publishable_crate_versions(repo_root) -> Vec<(String,String)>` (`cargo.rs:1772`) | walks publishable `[package]`s, workspace-inherited version resolved (`cargo.rs:1662`). |
| **SPARRING** (Gate-B rehearsal resolve) | served holger `/sparring` sparse index | `registry::CustomBackend { base_url: SparringRegistry::probe_base_url() }.published_max_version(krate)` (`registry.rs:109`, `rehearse.rs:95`) | REUSES the same registry probe as crates.io, pointed at the `/sparring` base URL; UA baked in (`registry.rs:37`). |
| **CRATES_IO** (published target) | crates.io | `registry::CratesIoBackend.published_max_version(krate)` (`registry.rs:71`) | sparse index → JSON-API fallback; UA=`RELEASE_USER_AGENT` baked in (`registry.rs:37`, `publish_pacing::RELEASE_USER_AGENT`). |

`derive_status(local, sparring, cratesio)` (`model.rs:131`) is the pure kernel the model already ships — the adapter only has to feed it the three strings per crate.

---

## (a) The dwarves-side adapter — `release_doctor::convergence`

**New file:** `crates/dwarves/src/release_doctor/convergence.rs`; register `pub mod convergence;` in `mod.rs` (after `pub mod graph_diff;` at `mod.rs:295`).

**Dependency direction (one-way, facett stays pure):** dwarves gains `facett-releaseview` as an OPTIONAL dep behind a new `convergence` feature — dwarves depends on facett, never the reverse. Mirror the existing `facett-adapter` feature (`Cargo.toml`, the `facett-adapter = ["dep:facett-pipeline"]` line) and `brokkr/facett_adapter.rs`:

```toml
# Cargo.toml [features]
convergence = ["dep:facett-releaseview"]      # engine → model, one-way (like facett-adapter)
# Cargo.toml [dependencies]
facett-releaseview = { path = "../../../facett/facett-releaseview", version = "0.1", optional = true }
```

Gate the whole module `#![cfg(feature = "convergence")]`. The adapter imports facett types (`facett_releaseview::model::{ReleaseConvergence, CrateConvergence, DepEdge}`) and produces a `ReleaseConvergence` — exactly the `ForgePipelineAdapter::from_report` shape in `brokkr/facett_adapter.rs`, but for the convergence model.

**Core function (pure over already-gathered inputs — golden-testable, no I/O):**

```rust
/// Build the engine-agnostic convergence model from the graph-diff topology + the
/// three resolved version maps. PURE (no probes here — the caller gathers the maps),
/// so it golden-tests on fixtures exactly like graph_diff::GraphDiff::between.
pub fn build_convergence(
    title: &str,
    after: &graph_diff::DepGraphSnapshot,          // the AFTER snapshot = the topology to show
    local:    &BTreeMap<String, String>,
    sparring: &BTreeMap<String, String>,
    cratesio: &BTreeMap<String, String>,
) -> ReleaseConvergence
```

Mapping rules, all grounded in the existing shapes:
1. **Nodes** — one `CrateConvergence` per `after.nodes` entry (`graph_diff.rs:98`, `DepGraphSnapshot.nodes: Vec<GraphNode>`). For each `GraphNode { name, .. }`:
   - `local = local.get(name)`, `sparring = sparring.get(name)`, `cratesio = cratesio.get(name)`.
   - **Missing-version discipline:** a crate absent from a map (e.g. never published) → `"0.0.0"`, matching `from_repo_graphs`'s explicit-UNKNOWN rule (`graph_diff.rs:160`). Never silently a real version.
   - `CrateConvergence::new(name, local, sparring, cratesio)` (`model.rs:174`) auto-derives `status` via `derive_status`.
2. **Edges** — map each `after.edges` `GraphEdge { from, to }` (`graph_diff.rs:73`) to a `DepEdge { from: idx, to: idx }` (`model.rs:222`) by resolving the crate names to node indices (build a `name → index` map from the node vec first). Drop any edge whose endpoint isn't a node (defensive, mirrors `build_dag`'s filter at `lib.rs:679`).
3. **Title** — the run id / workspace name.

**Why `after` (not the diff) drives the topology:** the convergence view shows the CURRENT constellation snapping to the published target; `after` = what we're releasing. The `GraphDiff` (`before→after`) is orthogonal — it's the semver-delta artifact, not the live 3-graph. If a build agent wants the diff's bump classification shown as a node badge, thread `GraphDiff::bumped_nodes` (`graph_diff.rs:254`) in as an optional per-node annotation, but that's additive polish, not required for convergence.

**The gathering seam (does the I/O, calls `build_convergence`):**

```rust
/// Probe the three sources for a checked-out constellation and assemble the model.
/// Best-effort probes (a miss → the crate reads Unpublished/0.0.0, never a hard block),
/// mirroring registry.rs's degrade-safe contract (registry.rs:51 doc).
pub fn gather_convergence(
    repo_root: &Path,
    graphs: &[model::RepoGraph],
    sparring: Option<&SparringRegistry>,   // Some when a Gate-B rehearsal is live; None ⇒ sparring==local
) -> Result<ReleaseConvergence>
```
- `local`: `cargo::publishable_crate_versions(repo_root)` → collect into `BTreeMap` (`cargo.rs:1772`).
- `after` snapshot: `DepGraphSnapshot::from_repo_graphs(graphs, &local)` (`graph_diff.rs:149`) — REUSES the produced-crate/edge granularity the publish plan already orders.
- `cratesio`: for each node, `CratesIoBackend.published_max_version(name)` (`registry.rs:71`); pace with the existing `publish_pacing` if probing many crates.
- `sparring`: when a `SparringRegistry` is running (rehearse feature), `CustomBackend { base_url: sparring.probe_base_url() }.published_max_version(name)` (`rehearse.rs:95`, `registry.rs:109`); when `None`, seed `sparring = local` (pre-rehearsal, the sparring plane just tracks local so `derive_status` reads `BumpDrift`/`Converged`, never a false `LockDesync`).

**Robot/observable (LAW 6):** add `convergence_state_json(&ReleaseConvergence) -> serde_json::Value` reusing the same tally shape the model already exposes via `status_counts` (`model.rs:295`) + `converged_fraction` (`model.rs:318`) + per-node `chip_divergence` (`model.rs:204`), so a headless test asserts the whole thing from one value.

**Tests (in `convergence.rs`, `#[cfg(test)]`):**
- `builds_model_from_diff_and_three_version_maps` — fixture: the `graph_diff.rs:658` `before()`/`after()` chain, three hand-built version maps (one converged crate, one `NeedsRelease` = cratesio behind local, one `LockDesync` = sparring≠cratesio, one `BumpDrift` = sparring≠local). Assert `status_counts()` buckets exactly. RED if the name→index edge mapping or the missing-version→`0.0.0` rule breaks.
- `missing_cratesio_version_reads_unpublished_not_a_real_version` — a crate absent from the cratesio map → `cratesio_ver == "0.0.0"` and status `NeedsRelease`.
- `edges_survive_the_name_to_index_remap` — the `app→lib→base` edges land as the right `DepEdge` indices.
- Gate behind `#[cfg(feature = "testmatrix")]` `nornir_testmatrix::functional_status("release-convergence", …)` exactly like `graph_diff.rs:770`.

---

## (b) Upgrade `Planes3D` onto real `CodeVault` wgpu depth planes

**Philosophy stays intact** (`facett-releaseview/src/lib.rs:8` "GPU-hero + CPU-honest"): the CPU `Planes3D` painter (`lib.rs:344`) is the always-works fallback and does **not** get deleted. We ADD a GPU hero backed by `CodeVault`, and make it the default `ViewMode` when the `graph3d` feature is on. This is precisely the follow-up the crate doc already promises (`lib.rs:19-20`).

**b.1 — deps + feature (`facett-releaseview/Cargo.toml`):**
```toml
[features]
graph3d = ["dep:facett-graph3d"]          # opt-in GPU hero; CPU planes stay default-clean
[dependencies]
facett-graph3d = { path = "../facett-graph3d", version = "0.1", optional = true }
```
Keep `graph3d` OFF by default so the published/wasm build stays CPU-only and light (matches how `facett-graph3d`'s own `wgpu` is off by default, `facett-graph3d/Cargo.toml` `default = []`). A host that wants the GPU hero turns on `facett-releaseview/graph3d` + `facett-graph3d/wgpu`.

**b.2 — map `ReleaseConvergence` → a 3-plane `CodeGraph`** (new `facett-releaseview/src/vault.rs`, `#![cfg(feature = "graph3d")]`):

The clean reuse move — express the three planes as **depth-staggered copies of the DAG in ONE `CodeGraph`**, then hand it to `CodeVault`, whose orbit camera + depth-sorted projection + GPU bloom give the *real* depth the CPU parallelogram fakes:

```rust
/// Lower the convergence model to a CodeGraph whose nodes are placed on three depth
/// planes (LOCAL front, SPARRING mid, CRATES_IO back). A CONVERGED crate contributes
/// ONE box at the merged position; a DIVERGED crate contributes a box on each plane it
/// differs on, joined by a convergence "beam" edge. Divergence maps to CodeVault's
/// NodeState so the existing red-flicker / amber-glow IS the fix signal.
pub fn convergence_to_codegraph(conv: &ReleaseConvergence, separation: f32) -> (CodeGraph, Vec<[f32;3]>)
```
- **Colour per plane box:** reuse the existing `role_color` (`lib.rs:92`) — local=blue, sparring=amber, cratesio=green — fed into `CodeNode::color` (`codevault.rs:114`).
- **State per box:** map `ConvergeStatus::severity()` (`model.rs:82`) → `NodeState` (`codevault.rs:55`): severity 2 (`NeedsRelease`/`LockDesync`) → `Glitch` (red flicker = the release/lock blocker throbbing — reuses the whole `flicker`/`box_look` lane `codevault.rs:325,344`); severity 1 (`BumpDrift`) → `Changed` (steady amber); converged/publishing → `Ok`.
- **Explicit plane placement:** `Node3.pos` is a PUBLIC field and the doc calls it "the shared-placement seam nornir's funnel3d uses" (`facett-graph3d/src/lib.rs:117,265`). After `CodeVault::new(graph, Layout3D::Force)`, overwrite `graph.nodes[i].pos` per plane: reuse the `Force`/toposort X,Y from the layout, set **Z = plane_depth × separation** (local Z=`-sep`, sparring Z=`0`, cratesio Z=`+sep`), so `separation = 1 − converged_fraction` (`model.rs:318`) drives the planes together exactly like the CPU `separation()` (`lib.rs:300`) — same math, real depth buffer. Expose a tiny `CodeVault::set_positions(&[[f32;3]])` OR a `Graph3D`-level placement setter if one isn't public yet (check `facett-graph3d/src/lib.rs` around `layout()` at `:527`; the positions are already `pub`, so a thin `with_positions` builder is the minimal add).
- **Edges:** intra-plane dep edges (from the model's `DepEdge`, `model.rs:222`) on each plane the crate is present, PLUS the cross-plane convergence beams for a diverged crate (connect its local/sparring/cratesio copies). CodeVault already renders glitchy edges red-flickering (`codevault.rs:473`), so a beam into a glitch box reads as the corrupted convergence line — the CPU render's beam (`lib.rs:417`) upgraded to 3D + bloom.

**b.3 — the hero render path.** Add `ViewMode::Vault3D` (third variant, `lib.rs:53`). In `paint` (`lib.rs:327`) route `Vault3D` to a `CodeVault::view` call:
- Hold an `Option<CodeVault>` on `ReleaseView` (rebuilt when the model or `separation` changes, like `dag` is rebuilt at `lib.rs:170`). Because `CodeVault` reuses `Graph3D`'s injected clock (`codevault.rs:300`), thread `ReleaseState.clock_s` (`lib.rs:110`) into `CodeVault::with_clock` so the whole view stays deterministic/headless-snapshottable.
- `CodeVault::view` returns `CodeVaultMsg`s (orbit/select) — translate `CamMsg::SelectNode(i)` back to `Msg::SelectCrate` by looking up which crate the box `i` belongs to (keep an `i → krate` map alongside the codegraph). Orbit/spin msgs pass straight through to the embedded camera.
- The GPU bloom lane is `CodeVault::gpu_scene(w, h, phase)` (`codevault.rs:562`, `#[cfg(feature="wgpu")]`) → feed to `graph_gpu::GraphGpu` (`graph_gpu.rs:152`). The egui_wgpu paint-callback wiring is the same one `facett-graph3d` already uses for the cloud (`facett-graph3d/src/lib.rs:284` `with_gpu`); reuse it, don't hand-roll a pipeline.

**b.4 — keep CPU planes green.** `ViewMode::Planes3D` (`lib.rs:344`) and `TriBadge2D` (`lib.rs:467`) are untouched and remain the default when `graph3d` is off. The toolbar (`lib.rs:578`) gains the `Vault3D` toggle only under `#[cfg(feature = "graph3d")]`.

**Tests:**
- `convergence_to_codegraph_places_three_planes_and_maps_severity` (in `vault.rs`) — a diverged model yields 3 boxes for the diverged crate with distinct Z, `Glitch` state for the severity-2 crate; assert via `CodeGraph::glitch_count` (`codevault.rs:154`) and the returned position Z spread. Converged crate → 1 box, `Ok`.
- `separation_collapses_planes_as_convergence_completes` — `separation=0` (fully converged) ⇒ all three Z coincide (RED-when-broken: the planes must actually snap together).
- Reuse `CodeVault`'s existing headless render proof pattern (`codevault.rs:921` `headless_render`) to assert the vault draws + `state_json["glitch_count"]` matches the diverged count.
- These run under `--features graph3d`; the existing CPU render tests (`facett-releaseview/tests/render.rs`) stay feature-free and green.

---

## (c) `dwarves release watch` — stream the convergence

**The stream already exists.** The release cascade writes an append-only NDJSON event log `release-run-<run_id>.events.ndjson` via `progress::ProgressWriter` (`progress.rs:134,141`), one `progress::ReleaseEvent` per line (`progress.rs:42`). `watch` tails that file and maps engine events → facett `model::ReleaseEvent` (`model.rs:255`) → `ReleaseConvergence::apply` (`model.rs:330`), which flips one crate at a time and drives the animation.

**c.1 — CLI subcommand** (`main.rs`, in `enum Cmd` after `Release` at `main.rs:113`):
```rust
/// Watch a release cascade converge live: tail the run's progress events and render
/// the three-graph convergence (LOCAL · SPARRING · CRATES_IO snapping together).
/// Text by default; `--json` streams the convergence state_json per update.
Release... /* add: */
Watch {
    #[arg(default_value = ".")] path: PathBuf,
    /// The run to follow (else the newest release-run-*.events.ndjson under the log dir).
    #[arg(long)] run_id: Option<String>,
    /// Emit convergence state_json per update instead of the text readout.
    #[arg(long)] json: bool,
    /// Poll interval ms for the tail.
    #[arg(long, default_value = "500")] interval_ms: u64,
},
```
Wire it in `fn main`'s match (`main.rs:324`) → `watch_cmd(path, run_id, json, interval_ms)`. Gate behind `#[cfg(feature = "convergence")]` (it needs the adapter + model).

**c.2 — `watch_cmd`:**
1. Build the initial `ReleaseConvergence` via `convergence::gather_convergence(repo_root, graphs, sparring=None)` (before any publish → sparring tracks local, cratesio = current published).
2. Resolve the events file: `--run-id` → `release-run-<id>.events.ndjson`, else newest by mtime in the log dir (same dir `ProgressWriter::open` writes, `progress.rs:141`).
3. Tail loop (poll every `interval_ms`, seek past consumed bytes): parse each new line as `progress::ReleaseEvent`; map:
   - `PhaseStart { repo, phase: "publish" }` → for each crate in that repo, `model::ReleaseEvent::PublishStart { krate }` (flips to `Publishing`, pulses).
   - `PhaseEnd { repo, phase: "publish", ok: true }` → `model::ReleaseEvent::Published { krate, version: local_ver }` (crate lands on the published target → re-derives to `Converged`).
   - `RepoEnd`/`RunEnd` → final re-probe of crates.io for the just-published repo (authoritative) and a last render.
   Apply each with `conv.apply(ev)` (`model.rs:330`); on `true` (a node changed) re-render.
4. Render per update:
   - `--json`: print `convergence_state_json(&conv)` (the LAW-6 blob from (a)).
   - default text: a compact readout REUSING the model's own tallies — `converged_fraction` (`model.rs:318`) as a percent bar + `status_counts` (`model.rs:295`) as `✓N ⚑N ⌥N ~N ⟳N`, mirroring the view's legend line (`lib.rs:543`). One line per changed crate: `krate  L{local} S{sparring} C{cratesio}  {status.label()}`.
5. Exit 0 when `conv.is_converged()` (`model.rs:311`) after `RunEnd`; non-zero if the run ended with a non-converged/blocked crate.

**No new event schema** — this is a pure consumer of the existing `progress` NDJSON, mapped through the model's existing realtime seam. (If a build agent later wants the WAREHOUSE-backed stream instead of the file tail, the `release_events::ReleaseEventRow` + `read_release_events`/`EventSelector` path `release_events.rs:283,295` is the drop-in richer source over the `Viz.ReleaseEvents` RPC; the file tail is the fat/serverless default.)

**Tests (`crates/dwarves/tests/`, e.g. `convergence_watch.rs`):**
- `watch_maps_publish_events_to_convergence` — feed a synthetic NDJSON (`PhaseStart publish` → `PhaseEnd publish ok`) through the mapper against a fixture model; assert the crate walks `Publishing → Converged` and `is_converged()` flips true at `RunEnd`. RED if the phase→event mapping or the tail-parse breaks.
- CLI smoke in `cli_smoke.rs` — `dwarves release watch --json` on a fixture events file prints valid convergence JSON and exits 0.

---

## Sequencing for the build agent

1. **(a) adapter first** — pure `build_convergence` + tests (no facett GPU needed; only the model types). Land green under `--features convergence`.
2. **(c) watch CLI** — depends only on (a) + the existing `progress` stream. Land green.
3. **(b) GPU planes** — the heaviest; depends on facett-graph3d `wgpu`. Land the `convergence_to_codegraph` mapping + CPU-headless vault tests first (no GPU), then the egui_wgpu paint-callback wiring behind `graph3d`+`wgpu`. CPU `Planes3D` stays the default the whole time.

## Laws honored
- **REUSE (L5):** three version probes reuse `cargo::publishable_crate_versions` + `registry::{CratesIoBackend,CustomBackend}`; the GPU hero reuses `CodeVault`/`Graph3D`/`graph_gpu` verbatim (only placement + state-mapping is new); `watch` reuses the `progress` NDJSON + `ReleaseConvergence::apply`. No twins.
- **One-way / facett-pure:** dwarves→facett dep only; the adapter feeds the neutral model IN. facett-releaseview never learns dwarves (`model.rs:1-6`).
- **Testable + emitters + robot (L6):** every new seam has a `state_json` + a red-when-broken test + a `functional_status` matrix row.
- **No new content-id / no rayon / no Python:** nothing here spawns a CPU pool (the projection fan-out is already gatling inside facett-graph3d, `facett-graph3d/src/lib.rs` `project_all`); no shell, no Python.