sui-spec 0.1.13

Declarative Lisp-authored specs for CppNix-parity behaviors. Rust types are the hard boundary; Lisp forms are the free-middle authoring surface. Both engines (tree-walker + VM) drive the same spec, so they cannot drift.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
//! L4 ModuleGraph — typed IR for a NixOS/nix-darwin/home-manager
//! module system, compiled into worker/wrapper-split assignment
//! closures with slice-keyed re-firing.
//!
//! ## Why this exists
//!
//! Tvix has not staked out the module-system; cppnix re-runs the
//! entire fixed point on every rebuild. The single biggest sui-vs-nix
//! win on the rio sweep — 24 s on `nixosConfigurations.rio.config.
//! system.build.toplevel` — lives here.
//!
//! ## Pipeline shape
//!
//! ```text
//! AstGraph for each module .nix file (or .tlisp once dialect lands)
//!   → ModuleNode (declared options + config setter + import edges)
//!     → ModuleGraph (typed IR with dense ids + slice-keyed setters)
//!       → graph-hash cache key
//!         → compiled closure (defunctionalized setters, topo order)
//!           → fixed-point execution (Rayon per SCC, slice-keyed re-fire)
//!             → final config attrset
//!               → derivation graph
//! ```
//!
//! This module ships the **typed IR + builder skeleton**. The
//! compilation pipeline (worker/wrapper synthesis, defunctionalization,
//! NbE execution) lands in subsequent commits — the IR is its anchor.
//!
//! ## Invariants
//!
//! - Module ids are dense u32s, root module is always 0.
//! - Every `ImportEdge::target` points at a valid module id.
//! - `ConfigSetter::slice` lists every option path the setter reads.
//!   Empty slice = setter writes but doesn't read config (a "leaf"
//!   setter; rebuilds only when its own source hash changes).
//! - `canonical_hash` is the BLAKE3 of the rkyv archive bytes. Same
//!   module sources + same slices → same hash → cached compiled
//!   closure hits.

use std::collections::BTreeMap;

use rkyv::{Archive, Deserialize as RkyvDeserialize, Serialize as RkyvSerialize};
use serde::{Deserialize, Serialize};
use tatara_lisp::DeriveTataraDomain;

use crate::ast_graph::{AstGraph, NodeId as AstNodeId};
use crate::SpecError;

/// Dense module identifier. Root module is always 0.
pub type ModuleId = u32;

/// Dense setter identifier within a module. A module typically has
/// exactly one setter (the function body), but `mkMerge` and `mkIf`
/// constructs can split a logical setter into multiple typed
/// fragments — the IR keeps them separate so each can be fired
/// independently.
pub type SetterId = u32;

/// 32-byte BLAKE3 hash. Same shape as the lockfile / AST graph hashes;
/// kept separate so the type system distinguishes which graph kind
/// a hash refers to.
#[derive(
    DeriveTataraDomain,
    Serialize,
    Deserialize,
    Archive,
    RkyvSerialize,
    RkyvDeserialize,
    Debug,
    Clone,
    Copy,
    PartialEq,
    Eq,
    Hash,
)]
#[tatara(keyword = "defmodule-graph-hash")]
#[rkyv(derive(Debug))]
pub struct ModuleGraphHash {
    pub bytes: [u8; 32],
}

/// The compiled module graph.
#[derive(
    DeriveTataraDomain,
    Serialize,
    Deserialize,
    Archive,
    RkyvSerialize,
    RkyvDeserialize,
    Debug,
    Clone,
    PartialEq,
)]
#[tatara(keyword = "defmodule-graph")]
#[rkyv(derive(Debug))]
pub struct ModuleGraph {
    /// Bumped on every breaking change to the IR shape. Today: 1.
    pub schema_version: u32,
    /// Always 0 — root module interned first (BFS from the entrypoint).
    pub root_id: ModuleId,
    /// Dense module table indexed by `ModuleId as usize`.
    pub modules: Vec<ModuleNode>,
    /// BLAKE3 of the rkyv archive bytes. Populated by
    /// [`ModuleGraph::archive_and_hash`].
    pub canonical_hash: ModuleGraphHash,
}

/// One module: the typed projection of one `.nix` (or future `.tlisp`)
/// file's contribution to the system.
#[derive(
    DeriveTataraDomain,
    Serialize,
    Deserialize,
    Archive,
    RkyvSerialize,
    RkyvDeserialize,
    Debug,
    Clone,
    PartialEq,
)]
#[tatara(keyword = "defmodule-node")]
#[rkyv(derive(Debug))]
pub struct ModuleNode {
    pub id: ModuleId,
    /// Human-readable label (the file path relative to the flake root,
    /// e.g. `"profiles/nixos-attic-cache-warmer/default.nix"`).
    pub label: String,
    /// Hash of the AstGraph this module was lowered from. Two modules
    /// with byte-identical AST contribute identically; the compiler can
    /// memoize on this.
    pub ast_graph_hash: [u8; 32],
    /// Option declarations this module contributes to the schema.
    pub option_decls: Vec<OptionDecl>,
    /// Config-setter fragments. One per logical assignment in the
    /// module's body. `mkMerge` / `mkIf` are pre-split here.
    pub setters: Vec<ConfigSetter>,
    /// Import edges: which other modules this one pulls into the
    /// system. Resolved at parse time so the runtime view is flat.
    pub imports: Vec<ImportEdge>,
    /// Env-prefix bindings captured from the module's outer wrappers
    /// (let-in bindings, with-scope attrsets). Each entry maps an
    /// identifier name to the AST node id whose evaluation produces
    /// the binding's value. The evaluator seeds each setter's
    /// evaluation env with these BEFORE adding `config` — so a setter
    /// body that references `cfg` (bound by an outer `let cfg =
    /// config.foo;`) resolves correctly.
    ///
    /// Two entry kinds today:
    ///   * Named bindings from outer `let ... in BODY` clauses.
    ///   * Synthetic `__with_<n>` entries for each outer `with X;`
    ///     scope, where X's evaluation result is unpacked into the
    ///     env (its attrset attrs become top-level idents).
    ///     `__with_<n>` itself is never directly referenced; it's
    ///     a placeholder so the evaluator knows to unpack the value.
    pub body_env_prefix: Vec<EnvPrefixBinding>,
}

/// One env-prefix binding captured from a module's outer wrapping.
/// See [`ModuleNode::body_env_prefix`].
#[derive(
    Serialize,
    Deserialize,
    Archive,
    RkyvSerialize,
    RkyvDeserialize,
    Debug,
    Clone,
    PartialEq,
)]
#[rkyv(derive(Debug))]
pub struct EnvPrefixBinding {
    /// Identifier name this binding is reachable under. For synthetic
    /// `with X;` entries, this is `"__with_N"` where N is the unwrap
    /// depth — uniquely identifies which scope the entry came from.
    pub name: String,
    /// AST node id whose evaluation produces the binding's value.
    pub value_node_id: super::ast_graph::NodeId,
    /// Kind of binding — let-bound name, or `with`-scope attrset to
    /// be unpacked.
    pub kind: EnvPrefixKind,
}

/// Distinguishes let-bindings (just bind name=value) from
/// with-clauses (unpack the value's attrset attrs as top-level
/// names in the env).
#[derive(
    Serialize,
    Deserialize,
    Archive,
    RkyvSerialize,
    RkyvDeserialize,
    Debug,
    Clone,
    Copy,
    PartialEq,
    Eq,
)]
#[rkyv(derive(Debug))]
pub enum EnvPrefixKind {
    /// Bound by name: `let foo = …; in BODY`.
    Let,
    /// Unpacked attrset: `with foo; BODY`.
    With,
}

/// One `options.foo.bar = mkOption { ... }` declaration.
#[derive(
    Serialize,
    Deserialize,
    Archive,
    RkyvSerialize,
    RkyvDeserialize,
    Debug,
    Clone,
    PartialEq,
)]
#[rkyv(derive(Debug))]
pub struct OptionDecl {
    /// Dotted path: `["services", "atticd", "enable"]`.
    pub path: Vec<String>,
    /// Type-tag name (e.g. `"bool"`, `"str"`, `"submodule"`). Free-
    /// form for now; tightens to an enum in a later ship when the
    /// type lattice is enumerated.
    pub type_tag: String,
    /// Whether the option carries a default.
    pub has_default: bool,
    /// Human-readable description (the `description` arg of mkOption).
    pub description: Option<String>,
}

/// One config-setter fragment. The worker/wrapper-split shape is
/// captured in the types: `body_ast` is the worker (computes the
/// contribution); `slice` is the wrapper's declared input projection
/// (what the worker reads from `config`).
#[derive(
    Serialize,
    Deserialize,
    Archive,
    RkyvSerialize,
    RkyvDeserialize,
    Debug,
    Clone,
    PartialEq,
)]
#[rkyv(derive(Debug))]
pub struct ConfigSetter {
    pub id: SetterId,
    /// Dotted path being assigned, e.g.
    /// `["services", "atticd", "settings", "listen"]`.
    pub assigns_path: Vec<String>,
    /// Slice of `config` this setter reads. Each entry is a dotted
    /// path. Empty list = "doesn't read config at all" (leaf setter).
    pub slice: Vec<Vec<String>>,
    /// Pointer into the source [`AstGraph`]: the [`AstNodeId`] whose
    /// subgraph evaluates to the assignment's RHS. The IR doesn't
    /// embed the AST — it references it, so the same AST blob backs
    /// every setter that names a node in it.
    pub body_ast_root: AstNodeId,
    /// `mkIf` condition wrapping this assignment, if any. Pointer to
    /// the boolean expression in the AST. `None` = unconditional.
    pub condition_ast_root: Option<AstNodeId>,
    /// `mkOverride` priority. Defaults to 100 (cppnix default). Lower
    /// numbers win — `mkForce` = 50, `mkVMOverride` = 10.
    pub priority: u32,
}

/// One `imports = [ ... ];` edge.
#[derive(
    Serialize,
    Deserialize,
    Archive,
    RkyvSerialize,
    RkyvDeserialize,
    Debug,
    Clone,
    PartialEq,
)]
#[rkyv(derive(Debug))]
pub struct ImportEdge {
    /// Target module id. Resolved at build time, so runtime is O(1).
    pub target: ModuleId,
    /// Optional `mkIf` condition gating the import.
    pub condition_ast_root: Option<AstNodeId>,
}

/// Errors from the module-graph builder.
#[derive(Debug, thiserror::Error)]
pub enum ModuleGraphError {
    #[error("rkyv archive failed: {0}")]
    Archive(String),
    #[error("module {label:?} declared an import path that didn't resolve")]
    UnresolvedImport { label: String },
    #[error("module compiler failed: {source}")]
    Compiler {
        #[from]
        source: crate::module_compiler::ModuleCompilerError,
    },
}

impl ModuleGraph {
    /// Allocate an empty graph. Builders use [`Self::push_module`] +
    /// [`Self::set_root`] to populate.
    #[must_use]
    pub fn new() -> Self {
        Self {
            schema_version: SCHEMA_VERSION,
            root_id: 0,
            modules: Vec::new(),
            canonical_hash: ModuleGraphHash { bytes: [0u8; 32] },
        }
    }

    /// Add a module; return its assigned id. First module pushed gets
    /// id 0 (the root by convention; callers should ensure that's the
    /// entrypoint module).
    pub fn push_module(&mut self, node: ModuleNode) -> ModuleId {
        let id = self.modules.len() as ModuleId;
        let mut n = node;
        n.id = id;
        self.modules.push(n);
        id
    }

    /// Set which module id is the root. Defaults to 0; override only
    /// if the build order put the root elsewhere.
    pub fn set_root(&mut self, id: ModuleId) {
        self.root_id = id;
    }

    /// Build a `ModuleGraph` from a slice of `(label, AstGraph)` pairs.
    /// The first pair becomes the root.
    ///
    /// Each module is run through [`crate::module_compiler::compile_module`]
    /// to extract its typed surface (option declarations, config setters
    /// with slice metadata, import edges). Caller-order is preserved —
    /// full BFS-from-root topological discovery + import-target
    /// resolution lands when import paths get typed in a follow-up
    /// ship (today, [`ImportEdge::target`] is the `u32::MAX` sentinel
    /// for unresolved edges).
    ///
    /// # Errors
    ///
    /// - [`ModuleGraphError::Archive`] on rkyv failure during
    ///   subsequent `archive_and_hash` calls.
    /// - Module-compiler errors are surfaced as `ModuleGraphError`
    ///   variants — a module with an unrecognizable root shape causes
    ///   the whole build to fail rather than silently emit a partial
    ///   graph (operators should see the offending file).
    pub fn from_ast_graphs(modules: &[(String, AstGraph)]) -> Result<Self, ModuleGraphError> {
        let mut g = Self::new();
        let mut label_to_id: BTreeMap<String, ModuleId> = BTreeMap::new();
        for (label, ast) in modules {
            let next_id = g.modules.len() as ModuleId;
            let node = crate::module_compiler::compile_module(label, ast, next_id)
                .map_err(|e| ModuleGraphError::Compiler { source: e })?;
            let id = g.push_module(node);
            label_to_id.insert(label.clone(), id);
        }
        Ok(g)
    }

    /// Two-pass archive: serialize → BLAKE3 the bytes → stamp hash →
    /// serialize again. Mirrors `LockfileGraph::archive_and_hash` and
    /// `AstGraph::archive_and_hash`.
    ///
    /// # Errors
    ///
    /// [`ModuleGraphError::Archive`] on rkyv failure.
    pub fn archive_and_hash(mut self) -> Result<(Self, Vec<u8>), ModuleGraphError> {
        let initial = rkyv::to_bytes::<rkyv::rancor::Error>(&self)
            .map_err(|e| ModuleGraphError::Archive(e.to_string()))?;
        let hash = blake3::hash(&initial);
        self.canonical_hash = ModuleGraphHash { bytes: hash.into() };
        let stamped = rkyv::to_bytes::<rkyv::rancor::Error>(&self)
            .map_err(|e| ModuleGraphError::Archive(e.to_string()))?;
        Ok((self, stamped.to_vec()))
    }
}

impl Default for ModuleGraph {
    fn default() -> Self {
        Self::new()
    }
}

/// Bumped on every breaking change to the IR. Today: 1.
pub const SCHEMA_VERSION: u32 = 1;

// ── Lisp fixtures loader ──────────────────────────────────────────

#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
#[tatara(keyword = "defmodule-graph-fixture")]
pub struct ModuleGraphFixture {
    pub name: String,
    #[serde(rename = "moduleCount")]
    pub module_count: u32,
    #[serde(rename = "setterCount")]
    pub setter_count: u32,
    #[serde(rename = "optionCount")]
    pub option_count: u32,
    #[serde(rename = "sliceCount")]
    pub slice_count: u32,
    pub notes: String,
}

pub const CANONICAL_MODULE_GRAPH_FIXTURES_LISP: &str =
    include_str!("../specs/module_graph.lisp");

/// Load every authored fixture.
///
/// # Errors
///
/// Fails if the `.lisp` source can't be parsed.
pub fn load_fixtures() -> Result<Vec<ModuleGraphFixture>, SpecError> {
    crate::loader::load_all::<ModuleGraphFixture>(CANONICAL_MODULE_GRAPH_FIXTURES_LISP)
}

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

    fn ast(src: &str) -> AstGraph {
        AstGraph::from_source(src).unwrap()
    }

    #[test]
    fn empty_graph_round_trips() {
        let g = ModuleGraph::new();
        assert_eq!(g.schema_version, SCHEMA_VERSION);
        assert_eq!(g.root_id, 0);
        assert!(g.modules.is_empty());
    }

    #[test]
    fn push_module_assigns_dense_ids() {
        let mut g = ModuleGraph::new();
        let id0 = g.push_module(ModuleNode {
            id: 99, // ignored — push_module overrides
            label: "root".into(),
            ast_graph_hash: [0u8; 32],
            option_decls: Vec::new(),
            setters: Vec::new(),
            imports: Vec::new(),
            body_env_prefix: Vec::new(),
        });
        let id1 = g.push_module(ModuleNode {
            id: 99,
            label: "child".into(),
            ast_graph_hash: [0u8; 32],
            option_decls: Vec::new(),
            setters: Vec::new(),
            imports: Vec::new(),
            body_env_prefix: Vec::new(),
        });
        assert_eq!(id0, 0);
        assert_eq!(id1, 1);
        assert_eq!(g.modules[0].id, 0);
        assert_eq!(g.modules[1].id, 1);
    }

    #[test]
    fn from_ast_graphs_seeds_per_module_hash() {
        let a = ast("{ networking.hostName = \"rio\"; }");
        let b = ast("{ boot.kernelParams = [ \"amd_pstate=active\" ]; }");
        let modules = vec![
            ("root.nix".to_string(), a.clone()),
            ("child.nix".to_string(), b.clone()),
        ];
        let g = ModuleGraph::from_ast_graphs(&modules).unwrap();
        assert_eq!(g.modules.len(), 2);
        assert_eq!(g.modules[0].label, "root.nix");
        assert_eq!(g.modules[0].ast_graph_hash, a.canonical_hash.bytes);
        assert_eq!(g.modules[1].ast_graph_hash, b.canonical_hash.bytes);
    }

    #[test]
    fn archive_and_hash_stamps_canonical_hash() {
        let mut g = ModuleGraph::new();
        g.push_module(ModuleNode {
            id: 0,
            label: "x".into(),
            ast_graph_hash: [1u8; 32],
            option_decls: Vec::new(),
            setters: Vec::new(),
            imports: Vec::new(),
            body_env_prefix: Vec::new(),
        });
        assert_eq!(g.canonical_hash.bytes, [0u8; 32]);
        let (stamped, bytes) = g.archive_and_hash().unwrap();
        assert_ne!(stamped.canonical_hash.bytes, [0u8; 32]);
        assert!(!bytes.is_empty());
    }

    #[test]
    fn archive_is_deterministic_for_same_input() {
        let mk = || {
            let mut g = ModuleGraph::new();
            g.push_module(ModuleNode {
                id: 0,
                label: "y".into(),
                ast_graph_hash: [7u8; 32],
                option_decls: Vec::new(),
                setters: Vec::new(),
                imports: Vec::new(),
            body_env_prefix: Vec::new(),
            });
            g
        };
        let (a, ba) = mk().archive_and_hash().unwrap();
        let (b, bb) = mk().archive_and_hash().unwrap();
        assert_eq!(a.canonical_hash.bytes, b.canonical_hash.bytes);
        assert_eq!(ba, bb);
    }

    #[test]
    fn archive_roundtrips_via_rkyv() {
        let mut g = ModuleGraph::new();
        g.push_module(ModuleNode {
            id: 0,
            label: "rt".into(),
            ast_graph_hash: [3u8; 32],
            option_decls: vec![OptionDecl {
                path: vec!["services".into(), "atticd".into(), "enable".into()],
                type_tag: "bool".into(),
                has_default: true,
                description: Some("enable atticd".into()),
            }],
            setters: vec![ConfigSetter {
                id: 0,
                assigns_path: vec!["services".into(), "atticd".into(), "enable".into()],
                slice: Vec::new(),
                body_ast_root: 0,
                condition_ast_root: None,
                priority: 100,
            }],
            imports: Vec::new(),
            body_env_prefix: Vec::new(),
        });
        let (_, bytes) = g.archive_and_hash().unwrap();
        let archived =
            rkyv::access::<ArchivedModuleGraph, rkyv::rancor::Error>(&bytes).unwrap();
        assert_eq!(archived.modules.len(), 1);
        assert_eq!(archived.modules[0].label.as_str(), "rt");
        assert_eq!(archived.modules[0].option_decls.len(), 1);
        assert_eq!(archived.modules[0].setters.len(), 1);
    }

    #[test]
    fn fixtures_load_from_lisp() {
        let f = load_fixtures().unwrap();
        let names: Vec<_> = f.iter().map(|f| f.name.as_str()).collect();
        assert!(names.contains(&"single-module-hostname"));
        assert!(names.contains(&"two-modules-with-slice"));
        assert!(names.contains(&"imports-chain-depth-4"));
    }
}