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

nordisk/facett

facett-helix/src/stability.rs

//! **Mental-map stability for dynamic graphs** — the helix differentiator (#4).
//!
//! A dependency graph evolves over commits / runs: crates appear, deps rewire,
//! metrics shift. If every timestep is laid out *from scratch*, nodes **teleport**
//! between frames and the user loses their spatial memory — the structural change is
//! drowned out by the relayout churn. The cure is **anchor constraints**: minimise a
//! node's travel between timestep `T` and `T+1` so that what *moves* is exactly what
//! *changed*.
//!
//! ## The anchor constraint (closed form)
//! Per node, per step, we minimise
//! ```text
//!     ‖pₜ − rawₜ‖²  +  λ · ‖pₜ − pₜ₋₁‖²
//! ```
//! — fidelity to this step's raw target traded against staying put. The minimiser is
//! a one-line exponential anchor:
//! ```text
//!     pₜ = (rawₜ + λ · pₜ₋₁) / (1 + λ)  =  pₜ₋₁ + (rawₜ − pₜ₋₁)/(1+λ)
//! ```
//! so a node's per-step displacement is **bounded** by `‖rawₜ − pₜ₋₁‖ / (1+λ)` — it
//! eases toward the new structure instead of snapping. `λ = 0` recovers the
//! from-scratch layout (the baseline the stability test beats). The angular
//! coordinate eases along the **shortest arc** (wrap-aware) so a node never takes the
//! long way round the ring.
//!
//! ## Time → helix depth (flying through history)
//! Each timestep is a cross-section of the helix at a depth proportional to its
//! index, so the sequence stacks ALONG the time axis — the user **flies through
//! structural history**. A node's anchored positions across the timesteps are the
//! control points of a **rubberband spline** ([`trajectory_world`] → the same
//! `catmull_rom_chain` the edge splines use), which traces how that node migrated
//! across system boundaries over the commits/runs.
//!
//! Pure + dependency-free (only [`crate::layout`]'s math), so it ships in the same
//! portable seam as `model` + `layout` and is inject-and-assert testable as DATA.

use crate::layout::{catmull_rom_chain, lane_offset, Frame};
use crate::model::{NodeId, PlanDag};
use std::collections::BTreeMap;
use std::f32::consts::{PI, TAU};

/// A 3-vector in the helix's world space (re-exported shape, no math dep).
pub type V3 = [f32; 3];

/// A node's raw cross-section **target** at one timestep: the polar angle (`theta`,
/// radians — its angular lane / system) and `radius` (e.g. an importance metric) in
/// the helix cross-section plane. The along-axis (depth) coordinate is the timestep
/// index, applied by [`trajectory_world`].
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Target {
    pub theta: f32,
    pub radius: f32,
}

impl Target {
    #[must_use]
    pub fn new(theta: f32, radius: f32) -> Self {
        Self { theta, radius }
    }
    /// The cross-section cartesian point `(r·cosθ, r·sinθ)` — the metric displacement
    /// is measured here.
    #[must_use]
    pub fn xy(self) -> [f32; 2] {
        [self.radius * self.theta.cos(), self.radius * self.theta.sin()]
    }
}

/// One timestep's raw targets, keyed by a **stable** node id (the same id must denote
/// the same logical node across steps — e.g. a crate-name hash — so anchoring can
/// follow it). A node absent from a step has simply not been placed that step.
pub type Snapshot = BTreeMap<u64, Target>;

/// Signed shortest angular delta `to − from`, wrapped to `(−π, π]` — so anchoring
/// eases a node along the short arc, never the long way around the ring.
#[must_use]
pub fn shortest_arc(from: f32, to: f32) -> f32 {
    let mut d = (to - from) % TAU;
    if d > PI {
        d -= TAU;
    } else if d < -PI {
        d += TAU;
    }
    d
}

/// Apply the **anchor constraint** across a raw timestep sequence, returning the
/// stabilised sequence. `lambda ≥ 0` is the anchor weight: `0` = from-scratch
/// (returns the input unchanged), larger = stickier (less travel). Deterministic in
/// `(raw, lambda)`.
///
/// A node's first appearance is placed at its raw target (no prior to anchor to);
/// thereafter it eases `pₜ = pₜ₋₁ + Δ/(1+λ)` with `Δ` the wrap-aware target delta.
#[must_use]
pub fn anchor_sequence(raw: &[Snapshot], lambda: f32) -> Vec<Snapshot> {
    let lambda = lambda.max(0.0);
    let inv = 1.0 / (1.0 + lambda);
    let mut out: Vec<Snapshot> = Vec::with_capacity(raw.len());
    let mut prev: Snapshot = Snapshot::new();
    for step in raw {
        let mut cur = Snapshot::new();
        for (&id, &tgt) in step {
            let placed = match prev.get(&id) {
                Some(p) => {
                    let dtheta = shortest_arc(p.theta, tgt.theta) * inv;
                    let dr = (tgt.radius - p.radius) * inv;
                    Target { theta: p.theta + dtheta, radius: p.radius + dr }
                }
                None => tgt, // first sighting: snap to raw
            };
            cur.insert(id, placed);
        }
        prev = cur.clone();
        out.push(cur);
    }
    out
}

/// Per-step displacement statistics over a sequence — the **stability metric**, read
/// as DATA. Displacement is the cross-section euclidean move of a node present in two
/// consecutive steps; nodes that appear/vanish don't count (no phantom jump).
#[derive(Clone, Copy, Debug, PartialEq, Default)]
pub struct Stability {
    /// The largest single-node per-step move (the "teleport" the cure must bound).
    pub max_step: f32,
    /// The mean per-step move across all tracked node-steps.
    pub mean_step: f32,
    /// How many node-steps were measured.
    pub samples: usize,
}

/// Measure [`Stability`] of a (raw or anchored) sequence.
#[must_use]
pub fn stability(seq: &[Snapshot]) -> Stability {
    let mut max_step = 0.0f32;
    let mut sum = 0.0f32;
    let mut n = 0usize;
    for w in seq.windows(2) {
        for (id, t1) in &w[1] {
            if let Some(t0) = w[0].get(id) {
                let (a, b) = (t0.xy(), t1.xy());
                let d = ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2)).sqrt();
                max_step = max_step.max(d);
                sum += d;
                n += 1;
            }
        }
    }
    Stability { max_step, mean_step: if n > 0 { sum / n as f32 } else { 0.0 }, samples: n }
}

/// A node's **world-space migration trajectory** across the sequence: each timestep's
/// (anchored) cross-section target placed at depth `index · depth_per_step` along the
/// coil `frame`, then smoothed into a **rubberband spline** (`catmull_rom_chain`).
/// `None` if the node appears in fewer than two steps. This is the wire that shows
/// "how a node migrated across boundaries over commits/runs" as the user flies
/// through the time-depth.
#[must_use]
pub fn trajectory_world(
    seq: &[Snapshot],
    node: u64,
    depth_per_step: f32,
    frame: &Frame,
    sub: usize,
) -> Option<Vec<V3>> {
    let mut ctrl: Vec<V3> = Vec::new();
    for (i, step) in seq.iter().enumerate() {
        if let Some(t) = step.get(&node) {
            let xy = t.xy();
            ctrl.push(frame.to_world([i as f32 * depth_per_step, xy[0], xy[1]]));
        }
    }
    if ctrl.len() < 2 {
        return None;
    }
    Some(catmull_rom_chain(&ctrl, sub.max(1)))
}

/// Build a [`Snapshot`] from a [`PlanDag`] — the convenience bridge from the model.
/// `theta` is the node's angular **lane** (`system`), `radius` is `radius_of(node)`
/// (a host metric, e.g. dep degree / importance). Keyed by the node's [`NodeId`] —
/// pass DAGs whose ids denote the same logical node across steps for anchoring to
/// track them.
#[must_use]
pub fn snapshot_from_dag(dag: &PlanDag, base_radius: f32, radius_of: impl Fn(&crate::model::Node) -> f32) -> Snapshot {
    let systems = dag.systems();
    let lanes = systems.len().max(1);
    let mut snap = Snapshot::new();
    for n in dag.nodes() {
        let lane = systems.iter().position(|s| *s == n.system).unwrap_or(0);
        let theta = lane_offset(lane, lanes);
        snap.insert(u64::from(n.id.0), Target { theta, radius: base_radius + radius_of(n) });
    }
    snap
}

/// Convenience: the stable id for a node by index (mirrors [`snapshot_from_dag`]'s key).
#[must_use]
pub fn stable_id(node: NodeId) -> u64 {
    u64::from(node.0)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::layout::HelixParams;
    use crate::model::{Kind, PlanDag};

    /// A 3-timestep fixture where node `7` would TELEPORT under a from-scratch
    /// layout: its angular target swings 0 → π → 0 (half-way round the ring and back)
    /// while node `1` stays put. Radius held constant so the metric is pure angle.
    fn teleporting() -> Vec<Snapshot> {
        let mk = |theta7: f32| -> Snapshot {
            [(1u64, Target::new(0.0, 1.0)), (7u64, Target::new(theta7, 1.0))].into_iter().collect()
        };
        vec![mk(0.0), mk(PI), mk(0.0)]
    }

    #[test]
    fn lambda_zero_is_the_from_scratch_baseline() {
        let raw = teleporting();
        let anchored = anchor_sequence(&raw, 0.0);
        assert_eq!(anchored, raw, "λ=0 reproduces the from-scratch layout exactly");
    }

    #[test]
    fn anchoring_bounds_per_step_travel_vs_from_scratch() {
        let raw = teleporting();
        let from_scratch = stability(&raw);
        let anchored = anchor_sequence(&raw, 4.0);
        let stable = stability(&anchored);

        // The from-scratch jump is the full chord across the ring (θ: 0→π at r=1 ⇒ 2.0).
        assert!(from_scratch.max_step > 1.9, "from-scratch teleports ({})", from_scratch.max_step);
        // Anchoring strictly bounds it — and well below the raw jump.
        assert!(stable.max_step < from_scratch.max_step * 0.5, "anchored travel halved+ ({} vs {})", stable.max_step, from_scratch.max_step);
        // The per-step bound: ‖Δ‖/(1+λ). First move is θ 0→π eased by 1/5 ⇒ θ≈0.628,
        // chord = 2·sin(θ/2) ≈ 0.618 — assert we're at/under that ceiling.
        assert!(stable.max_step <= 0.63, "per-step move respects the Δ/(1+λ) ceiling ({})", stable.max_step);
        assert!(stable.samples > 0);
    }

    #[test]
    fn anchoring_is_deterministic() {
        let raw = teleporting();
        assert_eq!(anchor_sequence(&raw, 3.0), anchor_sequence(&raw, 3.0));
    }

    #[test]
    fn shortest_arc_takes_the_short_way() {
        // 0.1 → 6.2 rad is −0.18 the short way (not +6.1 the long way).
        let d = shortest_arc(0.1, 6.2);
        assert!(d < 0.0 && d.abs() < 0.2, "wraps to the short arc (got {d})");
        assert!((shortest_arc(0.0, PI).abs() - PI).abs() < 1e-5, "antipode is ±π");
    }

    #[test]
    fn stable_node_never_moves_under_anchoring() {
        let raw = teleporting();
        let anchored = anchor_sequence(&raw, 2.0);
        // Node 1 had a constant target → zero displacement in every step.
        for w in anchored.windows(2) {
            let a = w[0][&1].xy();
            let b = w[1][&1].xy();
            assert!(((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2)).sqrt() < 1e-6, "the unchanged node stays anchored");
        }
    }

    #[test]
    fn trajectory_flies_through_depth_and_is_a_smooth_spline() {
        let anchored = anchor_sequence(&teleporting(), 4.0);
        let frame = Frame::identity(&HelixParams::default());
        let traj = trajectory_world(&anchored, 7, 2.0, &frame, 8).expect("node 7 spans ≥2 steps");
        assert!(traj.len() > 3, "subdivided into a smooth polyline");
        // Depth (world X, the identity along-axis) advances monotonically — we fly
        // THROUGH the timesteps, never backward.
        for w in traj.windows(2) {
            assert!(w[1][0] >= w[0][0] - 1e-4, "trajectory advances along the time-depth axis");
        }
        // It spans the full depth: first step at 0, last at (steps−1)·depth = 4.0.
        assert!((traj.first().unwrap()[0] - 0.0).abs() < 1e-4);
        assert!((traj.last().unwrap()[0] - 4.0).abs() < 1e-4);
        // A node present in <2 steps has no trajectory.
        let single: Vec<Snapshot> = vec![[(9u64, Target::new(0.0, 1.0))].into_iter().collect()];
        assert!(trajectory_world(&single, 9, 1.0, &frame, 4).is_none());
    }

    #[test]
    fn snapshot_from_dag_keys_lanes_and_radius() {
        let mut dag = PlanDag::new();
        let a = dag.add_idea("a", "alpha", 0.0, Kind::Crate);
        let b = dag.add_idea("b", "beta", 1.0, Kind::Crate);
        dag.add_dep(b, a);
        // radius = base + dep-count.
        let snap = snapshot_from_dag(&dag, 1.0, |n| n.deps.len() as f32);
        assert_eq!(snap.len(), 2);
        assert_eq!(snap[&stable_id(a)].radius, 1.0, "a has no deps → base radius");
        assert_eq!(snap[&stable_id(b)].radius, 2.0, "b has one dep → base + 1");
        // Distinct systems land on distinct lanes (angles).
        assert!((snap[&stable_id(a)].theta - snap[&stable_id(b)].theta).abs() > 1e-3);
    }
}