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):
dwarvesrelease_doctor::graph_diff— the before→afterGraphDiff+GraphDiffArtifact(crates/dwarves/src/release_doctor/graph_diff.rs, 953 LOC, green). This is the topology + node/edge delta source.facett-releaseview— the engine-agnosticReleaseConvergencemodel (facett/facett-releaseview/src/model.rs) + the CPU pseudo-3DPlanes3Dhero andTriBadge2Dfallback (.../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-nodeNodeState(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
Planes3Dfrom CPU pseudo-3D onto realCodeVaultwgpu 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.tomls |
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:
# 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):
/// 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:
-
Nodes — one
CrateConvergenceperafter.nodesentry (graph_diff.rs:98,DepGraphSnapshot.nodes: Vec<GraphNode>). For eachGraphNode { 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", matchingfrom_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-derivesstatusviaderive_status.
-
Edges — map each
after.edgesGraphEdge { from, to }(graph_diff.rs:73) to aDepEdge { from: idx, to: idx }(model.rs:222) by resolving the crate names to node indices (build aname → indexmap from the node vec first). Drop any edge whose endpoint isn't a node (defensive, mirrorsbuild_dag's filter atlib.rs:679). -
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):
/// 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 intoBTreeMap(cargo.rs:1772).aftersnapshot: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 existingpublish_pacingif probing many crates.sparring: when aSparringRegistryis running (rehearse feature),CustomBackend { base_url: sparring.probe_base_url() }.published_max_version(name)(rehearse.rs:95,registry.rs:109); whenNone, seedsparring = local(pre-rehearsal, the sparring plane just tracks local soderive_statusreadsBumpDrift/Converged, never a falseLockDesync).
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: thegraph_diff.rs:658before()/after()chain, three hand-built version maps (one converged crate, oneNeedsRelease= cratesio behind local, oneLockDesync= sparring≠cratesio, oneBumpDrift= sparring≠local). Assertstatus_counts()buckets exactly. RED if the name→index edge mapping or the missing-version→0.0.0rule breaks.missing_cratesio_version_reads_unpublished_not_a_real_version— a crate absent from the cratesio map →cratesio_ver == "0.0.0"and statusNeedsRelease.edges_survive_the_name_to_index_remap— theapp→lib→baseedges land as the rightDepEdgeindices.- Gate behind
#[cfg(feature = "testmatrix")]nornir_testmatrix::functional_status("release-convergence", …)exactly likegraph_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):
[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:
/// 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 intoCodeNode::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 wholeflicker/box_looklanecodevault.rs:325,344); severity 1 (BumpDrift) →Changed(steady amber); converged/publishing →Ok. - Explicit plane placement:
Node3.posis a PUBLIC field and the doc calls it "the shared-placement seam nornir's funnel3d uses" (facett-graph3d/src/lib.rs:117,265). AfterCodeVault::new(graph, Layout3D::Force), overwritegraph.nodes[i].posper plane: reuse theForce/toposort X,Y from the layout, set Z = plane_depth × separation (local Z=-sep, sparring Z=0, cratesio Z=+sep), soseparation = 1 − converged_fraction(model.rs:318) drives the planes together exactly like the CPUseparation()(lib.rs:300) — same math, real depth buffer. Expose a tinyCodeVault::set_positions(&[[f32;3]])OR aGraph3D-level placement setter if one isn't public yet (checkfacett-graph3d/src/lib.rsaroundlayout()at:527; the positions are alreadypub, so a thinwith_positionsbuilder 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>onReleaseView(rebuilt when the model orseparationchanges, likedagis rebuilt atlib.rs:170). BecauseCodeVaultreusesGraph3D's injected clock (codevault.rs:300), threadReleaseState.clock_s(lib.rs:110) intoCodeVault::with_clockso the whole view stays deterministic/headless-snapshottable. CodeVault::viewreturnsCodeVaultMsgs (orbit/select) — translateCamMsg::SelectNode(i)back toMsg::SelectCrateby looking up which crate the boxibelongs to (keep ani → kratemap 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 tograph_gpu::GraphGpu(graph_gpu.rs:152). The egui_wgpu paint-callback wiring is the same onefacett-graph3dalready uses for the cloud (facett-graph3d/src/lib.rs:284with_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(invault.rs) — a diverged model yields 3 boxes for the diverged crate with distinct Z,Glitchstate for the severity-2 crate; assert viaCodeGraph::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:921headless_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):
/// 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:
-
Build the initial
ReleaseConvergenceviaconvergence::gather_convergence(repo_root, graphs, sparring=None)(before any publish → sparring tracks local, cratesio = current published). -
Resolve the events file:
--run-id→release-run-<id>.events.ndjson, else newest by mtime in the log dir (same dirProgressWriter::openwrites,progress.rs:141). -
Tail loop (poll every
interval_ms, seek past consumed bytes): parse each new line asprogress::ReleaseEvent; map:PhaseStart { repo, phase: "publish" }→ for each crate in that repo,model::ReleaseEvent::PublishStart { krate }(flips toPublishing, pulses).PhaseEnd { repo, phase: "publish", ok: true }→model::ReleaseEvent::Published { krate, version: local_ver }(crate lands on the published target → re-derives toConverged).RepoEnd/RunEnd→ final re-probe of crates.io for the just-published repo (authoritative) and a last render. Apply each withconv.apply(ev)(model.rs:330); ontrue(a node changed) re-render.
-
Render per update:
--json: printconvergence_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()}.
-
Exit 0 when
conv.is_converged()(model.rs:311) afterRunEnd; 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 walksPublishing → Convergedandis_converged()flips true atRunEnd. RED if the phase→event mapping or the tail-parse breaks.- CLI smoke in
cli_smoke.rs—dwarves release watch --jsonon a fixture events file prints valid convergence JSON and exits 0.
Sequencing for the build agent
- (a) adapter first — pure
build_convergence+ tests (no facett GPU needed; only the model types). Land green under--features convergence. - (c) watch CLI — depends only on (a) + the existing
progressstream. Land green. - (b) GPU planes — the heaviest; depends on facett-graph3d
wgpu. Land theconvergence_to_codegraphmapping + CPU-headless vault tests first (no GPU), then the egui_wgpu paint-callback wiring behindgraph3d+wgpu. CPUPlanes3Dstays the default the whole time.
Laws honored
- REUSE (L5): three version probes reuse
cargo::publishable_crate_versions+registry::{CratesIoBackend,CustomBackend}; the GPU hero reusesCodeVault/Graph3D/graph_gpuverbatim (only placement + state-mapping is new);watchreuses theprogressNDJSON +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 + afunctional_statusmatrix 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.rsproject_all); no shell, no Python.