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);
}
}