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