Skip to main content

sui_spec/
module_graph.rs

1//! L4 ModuleGraph — typed IR for a NixOS/nix-darwin/home-manager
2//! module system, compiled into worker/wrapper-split assignment
3//! closures with slice-keyed re-firing.
4//!
5//! ## Why this exists
6//!
7//! Tvix has not staked out the module-system; cppnix re-runs the
8//! entire fixed point on every rebuild. The single biggest sui-vs-nix
9//! win on the rio sweep — 24 s on `nixosConfigurations.rio.config.
10//! system.build.toplevel` — lives here.
11//!
12//! ## Pipeline shape
13//!
14//! ```text
15//! AstGraph for each module .nix file (or .tlisp once dialect lands)
16//!   → ModuleNode (declared options + config setter + import edges)
17//!     → ModuleGraph (typed IR with dense ids + slice-keyed setters)
18//!       → graph-hash cache key
19//!         → compiled closure (defunctionalized setters, topo order)
20//!           → fixed-point execution (Rayon per SCC, slice-keyed re-fire)
21//!             → final config attrset
22//!               → derivation graph
23//! ```
24//!
25//! This module ships the **typed IR + builder skeleton**. The
26//! compilation pipeline (worker/wrapper synthesis, defunctionalization,
27//! NbE execution) lands in subsequent commits — the IR is its anchor.
28//!
29//! ## Invariants
30//!
31//! - Module ids are dense u32s, root module is always 0.
32//! - Every `ImportEdge::target` points at a valid module id.
33//! - `ConfigSetter::slice` lists every option path the setter reads.
34//!   Empty slice = setter writes but doesn't read config (a "leaf"
35//!   setter; rebuilds only when its own source hash changes).
36//! - `canonical_hash` is the BLAKE3 of the rkyv archive bytes. Same
37//!   module sources + same slices → same hash → cached compiled
38//!   closure hits.
39
40use std::collections::BTreeMap;
41
42use rkyv::{Archive, Deserialize as RkyvDeserialize, Serialize as RkyvSerialize};
43use serde::{Deserialize, Serialize};
44use tatara_lisp::DeriveTataraDomain;
45
46use crate::ast_graph::{AstGraph, NodeId as AstNodeId};
47use crate::SpecError;
48
49/// Dense module identifier. Root module is always 0.
50pub type ModuleId = u32;
51
52/// Dense setter identifier within a module. A module typically has
53/// exactly one setter (the function body), but `mkMerge` and `mkIf`
54/// constructs can split a logical setter into multiple typed
55/// fragments — the IR keeps them separate so each can be fired
56/// independently.
57pub type SetterId = u32;
58
59/// 32-byte BLAKE3 hash. Same shape as the lockfile / AST graph hashes;
60/// kept separate so the type system distinguishes which graph kind
61/// a hash refers to.
62#[derive(
63    DeriveTataraDomain,
64    Serialize,
65    Deserialize,
66    Archive,
67    RkyvSerialize,
68    RkyvDeserialize,
69    Debug,
70    Clone,
71    Copy,
72    PartialEq,
73    Eq,
74    Hash,
75)]
76#[tatara(keyword = "defmodule-graph-hash")]
77#[rkyv(derive(Debug))]
78pub struct ModuleGraphHash {
79    pub bytes: [u8; 32],
80}
81
82/// The compiled module graph.
83#[derive(
84    DeriveTataraDomain,
85    Serialize,
86    Deserialize,
87    Archive,
88    RkyvSerialize,
89    RkyvDeserialize,
90    Debug,
91    Clone,
92    PartialEq,
93)]
94#[tatara(keyword = "defmodule-graph")]
95#[rkyv(derive(Debug))]
96pub struct ModuleGraph {
97    /// Bumped on every breaking change to the IR shape. Today: 1.
98    pub schema_version: u32,
99    /// Always 0 — root module interned first (BFS from the entrypoint).
100    pub root_id: ModuleId,
101    /// Dense module table indexed by `ModuleId as usize`.
102    pub modules: Vec<ModuleNode>,
103    /// BLAKE3 of the rkyv archive bytes. Populated by
104    /// [`ModuleGraph::archive_and_hash`].
105    pub canonical_hash: ModuleGraphHash,
106}
107
108/// One module: the typed projection of one `.nix` (or future `.tlisp`)
109/// file's contribution to the system.
110#[derive(
111    DeriveTataraDomain,
112    Serialize,
113    Deserialize,
114    Archive,
115    RkyvSerialize,
116    RkyvDeserialize,
117    Debug,
118    Clone,
119    PartialEq,
120)]
121#[tatara(keyword = "defmodule-node")]
122#[rkyv(derive(Debug))]
123pub struct ModuleNode {
124    pub id: ModuleId,
125    /// Human-readable label (the file path relative to the flake root,
126    /// e.g. `"profiles/nixos-attic-cache-warmer/default.nix"`).
127    pub label: String,
128    /// Hash of the AstGraph this module was lowered from. Two modules
129    /// with byte-identical AST contribute identically; the compiler can
130    /// memoize on this.
131    pub ast_graph_hash: [u8; 32],
132    /// Option declarations this module contributes to the schema.
133    pub option_decls: Vec<OptionDecl>,
134    /// Config-setter fragments. One per logical assignment in the
135    /// module's body. `mkMerge` / `mkIf` are pre-split here.
136    pub setters: Vec<ConfigSetter>,
137    /// Import edges: which other modules this one pulls into the
138    /// system. Resolved at parse time so the runtime view is flat.
139    pub imports: Vec<ImportEdge>,
140    /// Env-prefix bindings captured from the module's outer wrappers
141    /// (let-in bindings, with-scope attrsets). Each entry maps an
142    /// identifier name to the AST node id whose evaluation produces
143    /// the binding's value. The evaluator seeds each setter's
144    /// evaluation env with these BEFORE adding `config` — so a setter
145    /// body that references `cfg` (bound by an outer `let cfg =
146    /// config.foo;`) resolves correctly.
147    ///
148    /// Two entry kinds today:
149    ///   * Named bindings from outer `let ... in BODY` clauses.
150    ///   * Synthetic `__with_<n>` entries for each outer `with X;`
151    ///     scope, where X's evaluation result is unpacked into the
152    ///     env (its attrset attrs become top-level idents).
153    ///     `__with_<n>` itself is never directly referenced; it's
154    ///     a placeholder so the evaluator knows to unpack the value.
155    pub body_env_prefix: Vec<EnvPrefixBinding>,
156}
157
158/// One env-prefix binding captured from a module's outer wrapping.
159/// See [`ModuleNode::body_env_prefix`].
160#[derive(
161    Serialize,
162    Deserialize,
163    Archive,
164    RkyvSerialize,
165    RkyvDeserialize,
166    Debug,
167    Clone,
168    PartialEq,
169)]
170#[rkyv(derive(Debug))]
171pub struct EnvPrefixBinding {
172    /// Identifier name this binding is reachable under. For synthetic
173    /// `with X;` entries, this is `"__with_N"` where N is the unwrap
174    /// depth — uniquely identifies which scope the entry came from.
175    pub name: String,
176    /// AST node id whose evaluation produces the binding's value.
177    pub value_node_id: super::ast_graph::NodeId,
178    /// Kind of binding — let-bound name, or `with`-scope attrset to
179    /// be unpacked.
180    pub kind: EnvPrefixKind,
181}
182
183/// Distinguishes let-bindings (just bind name=value) from
184/// with-clauses (unpack the value's attrset attrs as top-level
185/// names in the env).
186#[derive(
187    Serialize,
188    Deserialize,
189    Archive,
190    RkyvSerialize,
191    RkyvDeserialize,
192    Debug,
193    Clone,
194    Copy,
195    PartialEq,
196    Eq,
197)]
198#[rkyv(derive(Debug))]
199pub enum EnvPrefixKind {
200    /// Bound by name: `let foo = …; in BODY`.
201    Let,
202    /// Unpacked attrset: `with foo; BODY`.
203    With,
204}
205
206/// One `options.foo.bar = mkOption { ... }` declaration.
207#[derive(
208    Serialize,
209    Deserialize,
210    Archive,
211    RkyvSerialize,
212    RkyvDeserialize,
213    Debug,
214    Clone,
215    PartialEq,
216)]
217#[rkyv(derive(Debug))]
218pub struct OptionDecl {
219    /// Dotted path: `["services", "atticd", "enable"]`.
220    pub path: Vec<String>,
221    /// Type-tag name (e.g. `"bool"`, `"str"`, `"submodule"`). Free-
222    /// form for now; tightens to an enum in a later ship when the
223    /// type lattice is enumerated.
224    pub type_tag: String,
225    /// Whether the option carries a default.
226    pub has_default: bool,
227    /// Human-readable description (the `description` arg of mkOption).
228    pub description: Option<String>,
229}
230
231/// One config-setter fragment. The worker/wrapper-split shape is
232/// captured in the types: `body_ast` is the worker (computes the
233/// contribution); `slice` is the wrapper's declared input projection
234/// (what the worker reads from `config`).
235#[derive(
236    Serialize,
237    Deserialize,
238    Archive,
239    RkyvSerialize,
240    RkyvDeserialize,
241    Debug,
242    Clone,
243    PartialEq,
244)]
245#[rkyv(derive(Debug))]
246pub struct ConfigSetter {
247    pub id: SetterId,
248    /// Dotted path being assigned, e.g.
249    /// `["services", "atticd", "settings", "listen"]`.
250    pub assigns_path: Vec<String>,
251    /// Slice of `config` this setter reads. Each entry is a dotted
252    /// path. Empty list = "doesn't read config at all" (leaf setter).
253    pub slice: Vec<Vec<String>>,
254    /// Pointer into the source [`AstGraph`]: the [`AstNodeId`] whose
255    /// subgraph evaluates to the assignment's RHS. The IR doesn't
256    /// embed the AST — it references it, so the same AST blob backs
257    /// every setter that names a node in it.
258    pub body_ast_root: AstNodeId,
259    /// `mkIf` condition wrapping this assignment, if any. Pointer to
260    /// the boolean expression in the AST. `None` = unconditional.
261    pub condition_ast_root: Option<AstNodeId>,
262    /// `mkOverride` priority. Defaults to 100 (cppnix default). Lower
263    /// numbers win — `mkForce` = 50, `mkVMOverride` = 10.
264    pub priority: u32,
265}
266
267/// One `imports = [ ... ];` edge.
268#[derive(
269    Serialize,
270    Deserialize,
271    Archive,
272    RkyvSerialize,
273    RkyvDeserialize,
274    Debug,
275    Clone,
276    PartialEq,
277)]
278#[rkyv(derive(Debug))]
279pub struct ImportEdge {
280    /// Target module id. Resolved at build time, so runtime is O(1).
281    pub target: ModuleId,
282    /// Optional `mkIf` condition gating the import.
283    pub condition_ast_root: Option<AstNodeId>,
284}
285
286/// Errors from the module-graph builder.
287#[derive(Debug, thiserror::Error)]
288pub enum ModuleGraphError {
289    #[error("rkyv archive failed: {0}")]
290    Archive(String),
291    #[error("module {label:?} declared an import path that didn't resolve")]
292    UnresolvedImport { label: String },
293    #[error("module compiler failed: {source}")]
294    Compiler {
295        #[from]
296        source: crate::module_compiler::ModuleCompilerError,
297    },
298}
299
300impl ModuleGraph {
301    /// Allocate an empty graph. Builders use [`Self::push_module`] +
302    /// [`Self::set_root`] to populate.
303    #[must_use]
304    pub fn new() -> Self {
305        Self {
306            schema_version: SCHEMA_VERSION,
307            root_id: 0,
308            modules: Vec::new(),
309            canonical_hash: ModuleGraphHash { bytes: [0u8; 32] },
310        }
311    }
312
313    /// Add a module; return its assigned id. First module pushed gets
314    /// id 0 (the root by convention; callers should ensure that's the
315    /// entrypoint module).
316    pub fn push_module(&mut self, node: ModuleNode) -> ModuleId {
317        let id = self.modules.len() as ModuleId;
318        let mut n = node;
319        n.id = id;
320        self.modules.push(n);
321        id
322    }
323
324    /// Set which module id is the root. Defaults to 0; override only
325    /// if the build order put the root elsewhere.
326    pub fn set_root(&mut self, id: ModuleId) {
327        self.root_id = id;
328    }
329
330    /// Build a `ModuleGraph` from a slice of `(label, AstGraph)` pairs.
331    /// The first pair becomes the root.
332    ///
333    /// Each module is run through [`crate::module_compiler::compile_module`]
334    /// to extract its typed surface (option declarations, config setters
335    /// with slice metadata, import edges). Caller-order is preserved —
336    /// full BFS-from-root topological discovery + import-target
337    /// resolution lands when import paths get typed in a follow-up
338    /// ship (today, [`ImportEdge::target`] is the `u32::MAX` sentinel
339    /// for unresolved edges).
340    ///
341    /// # Errors
342    ///
343    /// - [`ModuleGraphError::Archive`] on rkyv failure during
344    ///   subsequent `archive_and_hash` calls.
345    /// - Module-compiler errors are surfaced as `ModuleGraphError`
346    ///   variants — a module with an unrecognizable root shape causes
347    ///   the whole build to fail rather than silently emit a partial
348    ///   graph (operators should see the offending file).
349    pub fn from_ast_graphs(modules: &[(String, AstGraph)]) -> Result<Self, ModuleGraphError> {
350        let mut g = Self::new();
351        let mut label_to_id: BTreeMap<String, ModuleId> = BTreeMap::new();
352        for (label, ast) in modules {
353            let next_id = g.modules.len() as ModuleId;
354            let node = crate::module_compiler::compile_module(label, ast, next_id)
355                .map_err(|e| ModuleGraphError::Compiler { source: e })?;
356            let id = g.push_module(node);
357            label_to_id.insert(label.clone(), id);
358        }
359        Ok(g)
360    }
361
362    /// Two-pass archive: serialize → BLAKE3 the bytes → stamp hash →
363    /// serialize again. Mirrors `LockfileGraph::archive_and_hash` and
364    /// `AstGraph::archive_and_hash`.
365    ///
366    /// # Errors
367    ///
368    /// [`ModuleGraphError::Archive`] on rkyv failure.
369    pub fn archive_and_hash(mut self) -> Result<(Self, Vec<u8>), ModuleGraphError> {
370        let initial = rkyv::to_bytes::<rkyv::rancor::Error>(&self)
371            .map_err(|e| ModuleGraphError::Archive(e.to_string()))?;
372        let hash = blake3::hash(&initial);
373        self.canonical_hash = ModuleGraphHash { bytes: hash.into() };
374        let stamped = rkyv::to_bytes::<rkyv::rancor::Error>(&self)
375            .map_err(|e| ModuleGraphError::Archive(e.to_string()))?;
376        Ok((self, stamped.to_vec()))
377    }
378}
379
380impl Default for ModuleGraph {
381    fn default() -> Self {
382        Self::new()
383    }
384}
385
386/// Bumped on every breaking change to the IR. Today: 1.
387pub const SCHEMA_VERSION: u32 = 1;
388
389// ── Lisp fixtures loader ──────────────────────────────────────────
390
391#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
392#[tatara(keyword = "defmodule-graph-fixture")]
393pub struct ModuleGraphFixture {
394    pub name: String,
395    #[serde(rename = "moduleCount")]
396    pub module_count: u32,
397    #[serde(rename = "setterCount")]
398    pub setter_count: u32,
399    #[serde(rename = "optionCount")]
400    pub option_count: u32,
401    #[serde(rename = "sliceCount")]
402    pub slice_count: u32,
403    pub notes: String,
404}
405
406pub const CANONICAL_MODULE_GRAPH_FIXTURES_LISP: &str =
407    include_str!("../specs/module_graph.lisp");
408
409/// Load every authored fixture.
410///
411/// # Errors
412///
413/// Fails if the `.lisp` source can't be parsed.
414pub fn load_fixtures() -> Result<Vec<ModuleGraphFixture>, SpecError> {
415    crate::loader::load_all::<ModuleGraphFixture>(CANONICAL_MODULE_GRAPH_FIXTURES_LISP)
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421    use pretty_assertions::assert_eq;
422
423    fn ast(src: &str) -> AstGraph {
424        AstGraph::from_source(src).unwrap()
425    }
426
427    #[test]
428    fn empty_graph_round_trips() {
429        let g = ModuleGraph::new();
430        assert_eq!(g.schema_version, SCHEMA_VERSION);
431        assert_eq!(g.root_id, 0);
432        assert!(g.modules.is_empty());
433    }
434
435    #[test]
436    fn push_module_assigns_dense_ids() {
437        let mut g = ModuleGraph::new();
438        let id0 = g.push_module(ModuleNode {
439            id: 99, // ignored — push_module overrides
440            label: "root".into(),
441            ast_graph_hash: [0u8; 32],
442            option_decls: Vec::new(),
443            setters: Vec::new(),
444            imports: Vec::new(),
445            body_env_prefix: Vec::new(),
446        });
447        let id1 = g.push_module(ModuleNode {
448            id: 99,
449            label: "child".into(),
450            ast_graph_hash: [0u8; 32],
451            option_decls: Vec::new(),
452            setters: Vec::new(),
453            imports: Vec::new(),
454            body_env_prefix: Vec::new(),
455        });
456        assert_eq!(id0, 0);
457        assert_eq!(id1, 1);
458        assert_eq!(g.modules[0].id, 0);
459        assert_eq!(g.modules[1].id, 1);
460    }
461
462    #[test]
463    fn from_ast_graphs_seeds_per_module_hash() {
464        let a = ast("{ networking.hostName = \"rio\"; }");
465        let b = ast("{ boot.kernelParams = [ \"amd_pstate=active\" ]; }");
466        let modules = vec![
467            ("root.nix".to_string(), a.clone()),
468            ("child.nix".to_string(), b.clone()),
469        ];
470        let g = ModuleGraph::from_ast_graphs(&modules).unwrap();
471        assert_eq!(g.modules.len(), 2);
472        assert_eq!(g.modules[0].label, "root.nix");
473        assert_eq!(g.modules[0].ast_graph_hash, a.canonical_hash.bytes);
474        assert_eq!(g.modules[1].ast_graph_hash, b.canonical_hash.bytes);
475    }
476
477    #[test]
478    fn archive_and_hash_stamps_canonical_hash() {
479        let mut g = ModuleGraph::new();
480        g.push_module(ModuleNode {
481            id: 0,
482            label: "x".into(),
483            ast_graph_hash: [1u8; 32],
484            option_decls: Vec::new(),
485            setters: Vec::new(),
486            imports: Vec::new(),
487            body_env_prefix: Vec::new(),
488        });
489        assert_eq!(g.canonical_hash.bytes, [0u8; 32]);
490        let (stamped, bytes) = g.archive_and_hash().unwrap();
491        assert_ne!(stamped.canonical_hash.bytes, [0u8; 32]);
492        assert!(!bytes.is_empty());
493    }
494
495    #[test]
496    fn archive_is_deterministic_for_same_input() {
497        let mk = || {
498            let mut g = ModuleGraph::new();
499            g.push_module(ModuleNode {
500                id: 0,
501                label: "y".into(),
502                ast_graph_hash: [7u8; 32],
503                option_decls: Vec::new(),
504                setters: Vec::new(),
505                imports: Vec::new(),
506            body_env_prefix: Vec::new(),
507            });
508            g
509        };
510        let (a, ba) = mk().archive_and_hash().unwrap();
511        let (b, bb) = mk().archive_and_hash().unwrap();
512        assert_eq!(a.canonical_hash.bytes, b.canonical_hash.bytes);
513        assert_eq!(ba, bb);
514    }
515
516    #[test]
517    fn archive_roundtrips_via_rkyv() {
518        let mut g = ModuleGraph::new();
519        g.push_module(ModuleNode {
520            id: 0,
521            label: "rt".into(),
522            ast_graph_hash: [3u8; 32],
523            option_decls: vec![OptionDecl {
524                path: vec!["services".into(), "atticd".into(), "enable".into()],
525                type_tag: "bool".into(),
526                has_default: true,
527                description: Some("enable atticd".into()),
528            }],
529            setters: vec![ConfigSetter {
530                id: 0,
531                assigns_path: vec!["services".into(), "atticd".into(), "enable".into()],
532                slice: Vec::new(),
533                body_ast_root: 0,
534                condition_ast_root: None,
535                priority: 100,
536            }],
537            imports: Vec::new(),
538            body_env_prefix: Vec::new(),
539        });
540        let (_, bytes) = g.archive_and_hash().unwrap();
541        let archived =
542            rkyv::access::<ArchivedModuleGraph, rkyv::rancor::Error>(&bytes).unwrap();
543        assert_eq!(archived.modules.len(), 1);
544        assert_eq!(archived.modules[0].label.as_str(), "rt");
545        assert_eq!(archived.modules[0].option_decls.len(), 1);
546        assert_eq!(archived.modules[0].setters.len(), 1);
547    }
548
549    #[test]
550    fn fixtures_load_from_lisp() {
551        let f = load_fixtures().unwrap();
552        let names: Vec<_> = f.iter().map(|f| f.name.as_str()).collect();
553        assert!(names.contains(&"single-module-hostname"));
554        assert!(names.contains(&"two-modules-with-slice"));
555        assert!(names.contains(&"imports-chain-depth-4"));
556    }
557}