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/model.rs

//! The **portable plan-DAG / multi-helix model** — the dependency-free seam.
//!
//! This module (with [`crate::layout`]) is the **portability contract**: it depends
//! on nothing but `serde` + `std`, knows nothing about egui / facett / nornir /
//! modgunn, and is what `git mv`s into **ordning**. Hosts author it; the layout turns
//! it into one or more helixes; the view renders it.
//!
//! ## Shape
//! ```text
//! Scene { coils: Vec<Coil>, links: Vec<InterEdge>, tension }   // a multi-helix scene
//! Coil  { dag: PlanDag, origin, axis, wind }                   // one helix + placement
//! PlanDag { nodes: Vec<Node> }
//! Node  { id, label, t, system, status, kind, deps, events }
//! Dep   { on, level }        // SCIP-style dependency depth (1 = direct, 2.. = transitive)
//! Event { t, kind, label }   // a per-node timeline marker the traverse sweeps through
//! Edge  { from, to, level }  // intra-coil, derived from deps
//! InterEdge { from: HelixRef, to: HelixRef, level }            // cross-coil system links
//! ```
//! `t` is the position **along the time axis**; `system` is the **angular lane**;
//! `wind` reels a coil up onto its central pin (1.0) or splays it along time (0.0).

use serde::{Deserialize, Serialize};

/// A node handle, **local to its coil**. Returned by [`PlanDag::add_idea`]; used in
/// [`PlanDag::add_dep`] / [`Node::deps`]. Transparent over a `u32`.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize)]
#[serde(transparent)]
pub struct NodeId(pub u32);

impl NodeId {
    /// The raw index (for layout slices / array access).
    #[must_use]
    pub fn index(self) -> usize {
        self.0 as usize
    }
}

/// What a plan item *is*. An **open** enum: the named cases cover both consumers
/// (plan-DAG ideas/tasks/tests and modgunn's per-`Crate` coils); [`Kind::Other`]
/// carries any host-specific tag, so the model never needs editing to add one.
#[derive(Clone, PartialEq, Eq, Debug, Default)]
pub enum Kind {
    /// A raw idea / proposal.
    #[default]
    Idea,
    /// A concrete unit of work.
    Task,
    /// A test / gate.
    Test,
    /// A code crate / component (modgunn).
    Crate,
    /// Any host-specific kind.
    Other(String),
}

impl Kind {
    /// A short, stable key (JSON + the colour/angular policy).
    #[must_use]
    pub fn key(&self) -> &str {
        match self {
            Kind::Idea => "idea",
            Kind::Task => "task",
            Kind::Test => "test",
            Kind::Crate => "crate",
            Kind::Other(s) => s,
        }
    }
    /// Parse a key back (unknown ⇒ [`Kind::Other`]).
    #[must_use]
    pub fn from_key(s: &str) -> Kind {
        match s {
            "idea" => Kind::Idea,
            "task" => Kind::Task,
            "test" => Kind::Test,
            "crate" => Kind::Crate,
            o => Kind::Other(o.to_string()),
        }
    }
    /// The named cases, for an author toggle.
    #[must_use]
    pub fn named() -> [Kind; 4] {
        [Kind::Idea, Kind::Task, Kind::Test, Kind::Crate]
    }
}

impl Serialize for Kind {
    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
        s.serialize_str(self.key())
    }
}
impl<'de> Deserialize<'de> for Kind {
    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
        Ok(Kind::from_key(&String::deserialize(d)?))
    }
}

/// A per-node timeline event's kind. Open, like [`Kind`]: `Cve` / `Delta` cover
/// modgunn's security + change markers; [`EventKind::Other`] carries anything else.
#[derive(Clone, PartialEq, Eq, Debug, Default)]
pub enum EventKind {
    /// A security advisory (modgunn).
    #[default]
    Cve,
    /// A version / content delta (modgunn).
    Delta,
    /// Any host-specific event.
    Other(String),
}

impl EventKind {
    #[must_use]
    pub fn key(&self) -> &str {
        match self {
            EventKind::Cve => "cve",
            EventKind::Delta => "delta",
            EventKind::Other(s) => s,
        }
    }
    #[must_use]
    pub fn from_key(s: &str) -> EventKind {
        match s {
            "cve" => EventKind::Cve,
            "delta" => EventKind::Delta,
            o => EventKind::Other(o.to_string()),
        }
    }
}

impl Serialize for EventKind {
    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
        s.serialize_str(self.key())
    }
}
impl<'de> Deserialize<'de> for EventKind {
    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
        Ok(EventKind::from_key(&String::deserialize(d)?))
    }
}

/// Neutral run status.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Status {
    #[default]
    Planned,
    Active,
    Done,
    Blocked,
}

impl Status {
    #[must_use]
    pub fn key(self) -> &'static str {
        match self {
            Status::Planned => "planned",
            Status::Active => "active",
            Status::Done => "done",
            Status::Blocked => "blocked",
        }
    }
}

/// A dependency edge target with its **depth level** — SCIP-style: `1` = a direct
/// dependency, `2..` = transitive at that hop count. The view's level filter hides
/// edges above a threshold (modgunn "direct deps only").
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
pub struct Dep {
    /// The node depended on.
    pub on: NodeId,
    /// Dependency depth (1 = direct).
    pub level: u8,
}

/// A per-node timeline event the **traverse sweeps through** as the time cursor
/// advances (e.g. a CVE landing on a crate at version-time `t`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Event {
    /// Position along the time axis (same units as [`Node::t`]).
    pub t: f64,
    /// What kind of event.
    pub kind: EventKind,
    /// Human label.
    pub label: String,
}

/// One plan item — a node of a coil, tied to a `system` (lane) at a `t` (time).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Node {
    pub id: NodeId,
    pub label: String,
    /// Position **along the time axis**.
    pub t: f64,
    /// The system / component — the **angular lane**.
    pub system: String,
    pub status: Status,
    pub kind: Kind,
    /// Upstream dependencies (time flows dep → this), each with a depth level.
    pub deps: Vec<Dep>,
    /// The node's timeline events (the traverse animates through these).
    #[serde(default)]
    pub events: Vec<Event>,
}

/// A directed **intra-coil** edge `from → to` (a dependency pointing forward to its
/// dependent), carrying the dependency depth `level`.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
pub struct Edge {
    pub from: NodeId,
    pub to: NodeId,
    pub level: u8,
}

/// The portable plan-DAG of a **single coil**. Author it with
/// [`add_idea`](PlanDag::add_idea) + [`add_dep`](PlanDag::add_dep), or the fluent
/// [`builder`](PlanDag::builder).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct PlanDag {
    nodes: Vec<Node>,
}

impl PlanDag {
    #[must_use]
    pub fn new() -> Self {
        Self { nodes: Vec::new() }
    }

    #[must_use]
    pub fn builder() -> PlanDagBuilder {
        PlanDagBuilder { dag: PlanDag::new() }
    }

    /// **The API the owner wants** — append an idea / task / test / crate tied to a
    /// `system` (angular lane) at time `t` (along-axis position), returning its
    /// [`NodeId`]. Status defaults to [`Status::Planned`].
    ///
    /// ```
    /// # use facett_helix::model::{PlanDag, Kind};
    /// let mut dag = PlanDag::new();
    /// let idea = dag.add_idea("helix view", "facett", 0.0, Kind::Idea);
    /// let task = dag.add_idea("layout math", "facett", 1.0, Kind::Task);
    /// dag.add_dep(task, idea); // the task depends on the idea
    /// assert_eq!(dag.len(), 2);
    /// ```
    pub fn add_idea(&mut self, label: impl Into<String>, system: impl Into<String>, t: f64, kind: Kind) -> NodeId {
        let id = NodeId(self.nodes.len() as u32);
        self.nodes.push(Node {
            id,
            label: label.into(),
            t,
            system: system.into(),
            status: Status::default(),
            kind,
            deps: Vec::new(),
            events: Vec::new(),
        });
        id
    }

    /// Record a **direct** dependency (`level = 1`): `node` depends on `dep`, edge
    /// `dep → node`. See [`add_dep_at`](Self::add_dep_at) for a deeper level.
    pub fn add_dep(&mut self, node: NodeId, dep: NodeId) -> bool {
        self.add_dep_at(node, dep, 1)
    }

    /// Record a dependency at a given **depth `level`** (1 = direct, 2.. = transitive).
    /// Rejects self-loops, unknown ids, and a duplicate `on`. Returns `true` if linked.
    pub fn add_dep_at(&mut self, node: NodeId, dep: NodeId, level: u8) -> bool {
        if node == dep || node.index() >= self.nodes.len() || dep.index() >= self.nodes.len() {
            return false;
        }
        let deps = &mut self.nodes[node.index()].deps;
        if deps.iter().any(|d| d.on == dep) {
            return false;
        }
        deps.push(Dep { on: dep, level: level.max(1) });
        true
    }

    /// Attach a timeline event to a node (a CVE / delta / … the traverse sweeps). `true`
    /// if the id was known.
    pub fn add_event(&mut self, node: NodeId, t: f64, kind: EventKind, label: impl Into<String>) -> bool {
        match self.nodes.get_mut(node.index()) {
            Some(n) => {
                n.events.push(Event { t, kind, label: label.into() });
                true
            }
            None => false,
        }
    }

    pub fn set_status(&mut self, node: NodeId, status: Status) -> bool {
        match self.nodes.get_mut(node.index()) {
            Some(n) => {
                n.status = status;
                true
            }
            None => false,
        }
    }

    #[must_use]
    pub fn nodes(&self) -> &[Node] {
        &self.nodes
    }
    #[must_use]
    pub fn node(&self, id: NodeId) -> Option<&Node> {
        self.nodes.get(id.index())
    }
    #[must_use]
    pub fn len(&self) -> usize {
        self.nodes.len()
    }
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.nodes.is_empty()
    }

    /// The distinct `system`s present, **sorted** — the deterministic angular-lane
    /// order the layout indexes (lane `i` = `systems()[i]`).
    #[must_use]
    pub fn systems(&self) -> Vec<String> {
        let mut s: Vec<String> = self.nodes.iter().map(|n| n.system.clone()).collect();
        s.sort();
        s.dedup();
        s
    }

    #[must_use]
    pub fn lane_of(&self, system: &str) -> usize {
        self.systems().iter().position(|s| s == system).unwrap_or(0)
    }

    /// All intra-coil edges derived from every node's `deps` (`dep → node`).
    #[must_use]
    pub fn edges(&self) -> Vec<Edge> {
        let mut out = Vec::new();
        for n in &self.nodes {
            for d in &n.deps {
                out.push(Edge { from: d.on, to: n.id, level: d.level });
            }
        }
        out
    }

    /// Edges at or below a depth `max_level` — the level filter (e.g. `1` = direct
    /// deps only). The view uses this to thin a dense modgunn dep graph.
    #[must_use]
    pub fn edges_at_level(&self, max_level: u8) -> Vec<Edge> {
        self.edges().into_iter().filter(|e| e.level <= max_level).collect()
    }

    /// The deepest dependency level present (1 if none/flat) — the level slider's max.
    #[must_use]
    pub fn max_level(&self) -> u8 {
        self.nodes.iter().flat_map(|n| n.deps.iter()).map(|d| d.level).max().unwrap_or(1).max(1)
    }

    /// Total timeline events across all nodes.
    #[must_use]
    pub fn event_count(&self) -> usize {
        self.nodes.iter().map(|n| n.events.len()).sum()
    }

    /// The `[t_min, t_max]` span across all nodes (`(0.0, 0.0)` when empty).
    #[must_use]
    pub fn t_span(&self) -> (f64, f64) {
        let mut lo = f64::INFINITY;
        let mut hi = f64::NEG_INFINITY;
        for n in &self.nodes {
            lo = lo.min(n.t);
            hi = hi.max(n.t);
        }
        if self.nodes.is_empty() { (0.0, 0.0) } else { (lo, hi) }
    }
}

/// A fluent builder over [`PlanDag`].
pub struct PlanDagBuilder {
    dag: PlanDag,
}

impl PlanDagBuilder {
    pub fn idea(&mut self, label: impl Into<String>, system: impl Into<String>, t: f64, kind: Kind) -> NodeId {
        self.dag.add_idea(label, system, t, kind)
    }
    pub fn dep(&mut self, node: NodeId, dep: NodeId) -> &mut Self {
        self.dag.add_dep(node, dep);
        self
    }
    pub fn dep_at(&mut self, node: NodeId, dep: NodeId, level: u8) -> &mut Self {
        self.dag.add_dep_at(node, dep, level);
        self
    }
    pub fn event(&mut self, node: NodeId, t: f64, kind: EventKind, label: impl Into<String>) -> &mut Self {
        self.dag.add_event(node, t, kind, label);
        self
    }
    pub fn status(&mut self, node: NodeId, status: Status) -> &mut Self {
        self.dag.set_status(node, status);
        self
    }
    #[must_use]
    pub fn build(&mut self) -> PlanDag {
        std::mem::take(&mut self.dag)
    }
}

// ─── multi-helix scene ──────────────────────────────────────────────────────────

/// A scene-global node reference: which coil + which node within it.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
pub struct HelixRef {
    pub helix: u32,
    pub node: NodeId,
}

/// A **cross-coil** (inter-helix) edge — the *system-level* link between two coils
/// (e.g. crate A on coil 0 depends on crate B on coil 1), rendered as the same soft
/// rubberband spline bridging the coils.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
pub struct InterEdge {
    pub from: HelixRef,
    pub to: HelixRef,
    pub level: u8,
}

/// One helix **placed** in the scene: its nodes (`dag`), a world `origin`, an `axis`
/// direction the time axis runs along, and its `wind` (0 = splayed along time, 1 =
/// reeled onto the central pin at the origin end).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Coil {
    pub dag: PlanDag,
    /// World origin of the coil's `t_min` end.
    pub origin: [f32; 3],
    /// Unit direction the time axis advances along (normalised at layout time).
    pub axis: [f32; 3],
    /// Spindle wind: 0 = fully unspooled along the axis, 1 = fully reeled onto the pin.
    pub wind: f32,
}

impl Coil {
    /// A coil at the origin running along +X, fully unspooled.
    #[must_use]
    pub fn new(dag: PlanDag) -> Self {
        Self { dag, origin: [0.0, 0.0, 0.0], axis: [1.0, 0.0, 0.0], wind: 0.0 }
    }
    #[must_use]
    pub fn at(mut self, origin: [f32; 3]) -> Self {
        self.origin = origin;
        self
    }
    #[must_use]
    pub fn along(mut self, axis: [f32; 3]) -> Self {
        self.axis = axis;
        self
    }
    #[must_use]
    pub fn wound(mut self, wind: f32) -> Self {
        self.wind = wind.clamp(0.0, 1.0);
        self
    }
}

/// A **multi-helix scene**: a set of [`Coil`]s plus the [`InterEdge`] links between
/// them, with a shared elastic `tension`. One coil = a plain single-helix view.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Scene {
    pub coils: Vec<Coil>,
    pub links: Vec<InterEdge>,
    /// Elastic-edge tension (rubberband wobble amplitude scale).
    pub tension: f32,
}

impl Scene {
    #[must_use]
    pub fn new() -> Self {
        Self { coils: Vec::new(), links: Vec::new(), tension: 0.5 }
    }

    /// A single-coil scene from one DAG (the common plan-DAG case).
    #[must_use]
    pub fn single(dag: PlanDag) -> Self {
        let mut s = Scene::new();
        s.add_coil(Coil::new(dag));
        s
    }

    /// Append a coil; returns its helix index.
    pub fn add_coil(&mut self, coil: Coil) -> u32 {
        let i = self.coils.len() as u32;
        self.coils.push(coil);
        i
    }

    /// Add a system-level cross-coil link `from → to` at depth `level`. Rejects unknown
    /// refs / self-links. Returns `true` if linked.
    pub fn add_link(&mut self, from: HelixRef, to: HelixRef, level: u8) -> bool {
        if from == to || !self.valid(from) || !self.valid(to) {
            return false;
        }
        self.links.push(InterEdge { from, to, level: level.max(1) });
        true
    }

    fn valid(&self, r: HelixRef) -> bool {
        self.coils.get(r.helix as usize).is_some_and(|c| r.node.index() < c.dag.len())
    }

    /// Reel a coil up (or out) — `wind ∈ [0,1]`. Returns `true` if the coil exists.
    pub fn set_wind(&mut self, helix: u32, wind: f32) -> bool {
        match self.coils.get_mut(helix as usize) {
            Some(c) => {
                c.wind = wind.clamp(0.0, 1.0);
                true
            }
            None => false,
        }
    }

    #[must_use]
    pub fn coil(&self, helix: u32) -> Option<&Coil> {
        self.coils.get(helix as usize)
    }

    /// The deepest dependency level across all coils + links (the slider max).
    #[must_use]
    pub fn max_level(&self) -> u8 {
        let intra = self.coils.iter().map(|c| c.dag.max_level()).max().unwrap_or(1);
        let inter = self.links.iter().map(|l| l.level).max().unwrap_or(1);
        intra.max(inter).max(1)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn add_idea_appends_and_returns_ids() {
        let mut dag = PlanDag::new();
        let a = dag.add_idea("a", "core", 0.0, Kind::Idea);
        let b = dag.add_idea("b", "core", 1.0, Kind::Task);
        assert_eq!(a, NodeId(0));
        assert_eq!(b, NodeId(1));
        assert_eq!(dag.len(), 2);
        assert_eq!(dag.node(b).unwrap().kind, Kind::Task);
    }

    #[test]
    fn add_dep_links_levels_and_rejects_bad_edges() {
        let mut dag = PlanDag::new();
        let a = dag.add_idea("a", "core", 0.0, Kind::Idea);
        let b = dag.add_idea("b", "core", 1.0, Kind::Task);
        let c = dag.add_idea("c", "core", 2.0, Kind::Task);
        assert!(dag.add_dep(b, a), "b directly depends on a");
        assert!(dag.add_dep_at(c, a, 2), "c transitively (level 2) depends on a");
        assert!(!dag.add_dep(b, a), "duplicate dep is rejected");
        assert!(!dag.add_dep(a, a), "self-loop rejected");
        // Level filter: direct-only keeps just b←a; level 2 keeps both.
        assert_eq!(dag.edges_at_level(1).len(), 1, "direct-only shows fewer edges");
        assert_eq!(dag.edges_at_level(2).len(), 2);
        assert_eq!(dag.max_level(), 2);
    }

    #[test]
    fn open_kind_and_events_round_trip_through_serde() {
        let mut b = PlanDag::builder();
        let krate = b.idea("serde", "deps", 3.0, Kind::Crate);
        let custom = b.idea("thing", "deps", 4.0, Kind::Other("milestone".into()));
        b.dep(custom, krate);
        b.event(krate, 3.5, EventKind::Cve, "RUSTSEC-2025-0001");
        b.event(krate, 3.8, EventKind::Other("yank".into()), "1.0.0 yanked");
        let dag = b.build();
        assert_eq!(dag.event_count(), 2);
        let json = serde_json::to_string(&dag).unwrap();
        let back: PlanDag = serde_json::from_str(&json).unwrap();
        assert_eq!(dag, back, "open kinds + events survive a serde round-trip");
        assert_eq!(back.node(krate).unwrap().kind, Kind::Crate);
        assert_eq!(back.node(custom).unwrap().kind, Kind::Other("milestone".into()));
    }

    #[test]
    fn scene_holds_multiple_coils_and_inter_helix_links() {
        let mut a = PlanDag::new();
        let a0 = a.add_idea("a-root", "crateA", 0.0, Kind::Crate);
        let mut b = PlanDag::new();
        let b0 = b.add_idea("b-root", "crateB", 0.0, Kind::Crate);

        let mut scene = Scene::new();
        let ha = scene.add_coil(Coil::new(a).at([0.0, 0.0, 0.0]));
        let hb = scene.add_coil(Coil::new(b).at([4.0, 0.0, 0.0]).wound(0.3));
        assert_eq!(scene.coils.len(), 2);
        // A system-level link from coil A's node to coil B's node.
        assert!(scene.add_link(HelixRef { helix: ha, node: a0 }, HelixRef { helix: hb, node: b0 }, 1));
        assert!(!scene.add_link(HelixRef { helix: ha, node: a0 }, HelixRef { helix: 9, node: b0 }, 1), "unknown coil rejected");
        assert_eq!(scene.links.len(), 1);
        // Wind controls.
        assert!(scene.set_wind(hb, 1.0));
        assert_eq!(scene.coil(hb).unwrap().wind, 1.0);
        // Serde round-trip of the whole scene.
        let json = serde_json::to_string(&scene).unwrap();
        let back: Scene = serde_json::from_str(&json).unwrap();
        assert_eq!(scene, back);
    }
}