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}