Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

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):

Phase 2/3 delivers three things:


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:

  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):

/// 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>

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)]):


(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]>)

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:

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:


(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:

  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):


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