Skip to main content

active_infinity/
ir.rs

1//! Active Infinity's in-memory module model.
2//!
3//! This is the *canonical* IR: the encoder is a pure function of it, and the
4//! decoder reproduces it exactly. It deliberately does **not** mirror the
5//! `active-core` arena layout: the wire format is versioned independently of
6//! any dependency's semver (see ADR-0002). Interop with the text language IR
7//! lives in [`crate::interop`] behind the `active-core` feature.
8//!
9//! Structural invariants upheld by [`InfinityModule::validate_structure`]:
10//!
11//! - every referenced node id exists (no dangling references);
12//! - table fields, binding names and array nodes are unique where required;
13//! - the dependency graph is acyclic (Active rejects cycles before any value
14//!   exists);
15//! - nesting depth stays within the caller's budget.
16//!
17//! Secret material is held in [`SecretBytes`], which has **no** `Display`,
18//! `Debug`, `Serialize` or equality path that can reveal it.
19
20// The variant and field names in this module are the wire contract itself and
21// are documented normatively in `docs/spec/layout.md`; per-variant rustdoc would
22// duplicate that table without adding information.
23#![allow(missing_docs)]
24
25use std::collections::HashMap;
26use std::fmt;
27
28/// Index into the module arena.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
30pub struct NodeId(pub u32);
31
32impl NodeId {
33    /// Sentinel for "no node".
34    pub const NONE: NodeId = NodeId(u32::MAX);
35
36    pub fn index(self) -> usize {
37        self.0 as usize
38    }
39
40    pub fn is_none(self) -> bool {
41        self == NodeId::NONE
42    }
43}
44
45impl fmt::Display for NodeId {
46    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47        write!(f, "n{}", self.0)
48    }
49}
50
51/// A secret string held structurally.
52///
53/// The type exposes no way to read the bytes back through a public API: the
54/// wire codec and the `active-core` bridge are the only in-crate readers, and
55/// both are audited. `Debug`, `Display`, `PartialEq`, `Ord` and `Hash` are all
56/// deliberately absent or redacting, so a secret can never leak through
57/// logging, assertion output, sorting or map keys, and can never be compared
58/// for equality (the anti-oracle rule).
59#[derive(Clone)]
60pub struct SecretBytes(Vec<u8>);
61
62impl SecretBytes {
63    /// Wrap raw bytes as a secret.
64    pub fn new(bytes: impl Into<Vec<u8>>) -> Self {
65        SecretBytes(bytes.into())
66    }
67
68    /// Number of bytes — length is not secret material and is used by
69    /// budgets and diagnostics.
70    pub fn len(&self) -> usize {
71        self.0.len()
72    }
73
74    pub fn is_empty(&self) -> bool {
75        self.0.is_empty()
76    }
77
78    /// Read the raw bytes. Crate-internal: only the audited codec and the
79    /// `active-core` bridge call this. There is no public unlock API.
80    pub(crate) fn expose(&self) -> &[u8] {
81        &self.0
82    }
83}
84
85impl fmt::Debug for SecretBytes {
86    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87        f.write_str("Secret(***)")
88    }
89}
90
91impl fmt::Display for SecretBytes {
92    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93        f.write_str("***SECRET***")
94    }
95}
96
97impl PartialEq for SecretBytes {
98    /// Anti-oracle: a secret never equals anything, including itself.
99    fn eq(&self, _other: &Self) -> bool {
100        false
101    }
102}
103
104impl From<&str> for SecretBytes {
105    fn from(value: &str) -> Self {
106        SecretBytes(value.as_bytes().to_vec())
107    }
108}
109
110impl From<String> for SecretBytes {
111    fn from(value: String) -> Self {
112        SecretBytes(value.into_bytes())
113    }
114}
115
116/// Binary operators, with permanent wire codes.
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
118pub enum BinOp {
119    Add,
120    Sub,
121    Mul,
122    Div,
123    Eq,
124    Ne,
125    Lt,
126    Le,
127    Gt,
128    Ge,
129    And,
130    Or,
131}
132
133impl BinOp {
134    pub const fn code(self) -> u32 {
135        match self {
136            BinOp::Add => 0,
137            BinOp::Sub => 1,
138            BinOp::Mul => 2,
139            BinOp::Div => 3,
140            BinOp::Eq => 4,
141            BinOp::Ne => 5,
142            BinOp::Lt => 6,
143            BinOp::Le => 7,
144            BinOp::Gt => 8,
145            BinOp::Ge => 9,
146            BinOp::And => 10,
147            BinOp::Or => 11,
148        }
149    }
150
151    pub const fn from_code(code: u32) -> Option<Self> {
152        let op = match code {
153            0 => BinOp::Add,
154            1 => BinOp::Sub,
155            2 => BinOp::Mul,
156            3 => BinOp::Div,
157            4 => BinOp::Eq,
158            5 => BinOp::Ne,
159            6 => BinOp::Lt,
160            7 => BinOp::Le,
161            8 => BinOp::Gt,
162            9 => BinOp::Ge,
163            10 => BinOp::And,
164            11 => BinOp::Or,
165            _ => return None,
166        };
167        Some(op)
168    }
169
170    pub const fn as_str(self) -> &'static str {
171        match self {
172            BinOp::Add => "+",
173            BinOp::Sub => "-",
174            BinOp::Mul => "*",
175            BinOp::Div => "/",
176            BinOp::Eq => "==",
177            BinOp::Ne => "!=",
178            BinOp::Lt => "<",
179            BinOp::Le => "<=",
180            BinOp::Gt => ">",
181            BinOp::Ge => ">=",
182            BinOp::And => "and",
183            BinOp::Or => "or",
184        }
185    }
186}
187
188/// Unary operators, with permanent wire codes.
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
190pub enum UnOp {
191    Neg,
192    Not,
193}
194
195impl UnOp {
196    pub const fn code(self) -> u32 {
197        match self {
198            UnOp::Neg => 0,
199            UnOp::Not => 1,
200        }
201    }
202
203    pub const fn from_code(code: u32) -> Option<Self> {
204        match code {
205            0 => Some(UnOp::Neg),
206            1 => Some(UnOp::Not),
207            _ => None,
208        }
209    }
210
211    pub const fn as_str(self) -> &'static str {
212        match self {
213            UnOp::Neg => "-",
214            UnOp::Not => "!",
215        }
216    }
217}
218
219/// Semantic tag for a multi-line string block.
220#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
221pub enum SemanticTag {
222    Prompt,
223    Policy,
224    Template,
225}
226
227impl SemanticTag {
228    pub const fn code(self) -> u32 {
229        match self {
230            SemanticTag::Prompt => 0,
231            SemanticTag::Policy => 1,
232            SemanticTag::Template => 2,
233        }
234    }
235
236    pub const fn from_code(code: u32) -> Option<Self> {
237        match code {
238            0 => Some(SemanticTag::Prompt),
239            1 => Some(SemanticTag::Policy),
240            2 => Some(SemanticTag::Template),
241            _ => None,
242        }
243    }
244
245    pub const fn as_str(self) -> &'static str {
246        match self {
247            SemanticTag::Prompt => "prompt",
248            SemanticTag::Policy => "policy",
249            SemanticTag::Template => "template",
250        }
251    }
252}
253
254/// One interpolation part.
255#[derive(Clone, PartialEq)]
256pub enum InterpPart {
257    Literal(String),
258    Expr(NodeId),
259}
260
261impl fmt::Debug for InterpPart {
262    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
263        match self {
264            InterpPart::Literal(text) => write!(f, "Literal(<{} bytes>)", text.len()),
265            InterpPart::Expr(id) => write!(f, "Expr({id})"),
266        }
267    }
268}
269
270/// A runtime constraint attached to a node (`@constraint` predicates and
271/// schema bounds).
272#[derive(Clone, PartialEq)]
273pub enum Constraint {
274    MinMax { min: Option<i64>, max: Option<i64> },
275    Enum(Vec<String>),
276    Pattern(String),
277    Odd,
278    Custom(String),
279}
280
281impl fmt::Debug for Constraint {
282    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
283        match self {
284            Constraint::MinMax { min, max } => {
285                write!(f, "MinMax {{ min: {min:?}, max: {max:?} }}")
286            }
287            Constraint::Enum(values) => write!(f, "Enum(<{} values>)", values.len()),
288            Constraint::Pattern(_) => f.write_str("Pattern(<redacted>)"),
289            Constraint::Odd => f.write_str("Odd"),
290            Constraint::Custom(_) => f.write_str("Custom(<redacted>)"),
291        }
292    }
293}
294
295/// Declared value type of a schema field.
296#[derive(Clone, PartialEq)]
297pub enum TypeSpec {
298    Null,
299    Bool,
300    Int,
301    Float,
302    String,
303    Duration,
304    Bytes,
305    Table { closed: bool },
306    Enum(Vec<String>),
307    Secret,
308    Any,
309    Blob,
310}
311
312impl fmt::Debug for TypeSpec {
313    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
314        match self {
315            TypeSpec::Table { closed } => write!(f, "Table {{ closed: {closed} }}"),
316            TypeSpec::Enum(values) => write!(f, "Enum(<{} values>)", values.len()),
317            other => f.write_str(other.name().as_str()),
318        }
319    }
320}
321
322impl TypeSpec {
323    pub const fn code(&self) -> u32 {
324        match self {
325            TypeSpec::Null => 0,
326            TypeSpec::Bool => 1,
327            TypeSpec::Int => 2,
328            TypeSpec::Float => 3,
329            TypeSpec::String => 4,
330            TypeSpec::Duration => 5,
331            TypeSpec::Bytes => 6,
332            TypeSpec::Table { .. } => 7,
333            TypeSpec::Enum(_) => 8,
334            TypeSpec::Secret => 9,
335            TypeSpec::Any => 10,
336            TypeSpec::Blob => 11,
337        }
338    }
339
340    /// Human-readable name for diagnostics (mirrors `active-core`).
341    pub fn name(&self) -> String {
342        match self {
343            TypeSpec::Null => "Null".into(),
344            TypeSpec::Bool => "Bool".into(),
345            TypeSpec::Int => "Int".into(),
346            TypeSpec::Float => "Float".into(),
347            TypeSpec::String => "String".into(),
348            TypeSpec::Duration => "Duration".into(),
349            TypeSpec::Bytes => "Bytes".into(),
350            TypeSpec::Table { closed } => {
351                if *closed {
352                    "Table(closed)".into()
353                } else {
354                    "Table".into()
355                }
356            }
357            TypeSpec::Enum(values) => format!("Enum({})", values.join(", ")),
358            TypeSpec::Secret => "Secret".into(),
359            TypeSpec::Any => "Any".into(),
360            TypeSpec::Blob => "Blob".into(),
361        }
362    }
363}
364
365/// A structural patch operation.
366#[derive(Clone, PartialEq)]
367pub enum PatchOp {
368    Set { path: String, value: NodeId },
369    Remove { path: String },
370    Rename { from: String, to: String },
371}
372
373impl fmt::Debug for PatchOp {
374    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
375        match self {
376            PatchOp::Set { value, .. } => write!(f, "Set {{ path: <redacted>, value: {value} }}"),
377            PatchOp::Remove { .. } => f.write_str("Remove { path: <redacted> }"),
378            PatchOp::Rename { .. } => f.write_str("Rename { from: <redacted>, to: <redacted> }"),
379        }
380    }
381}
382
383/// One schema field's constraints.
384#[derive(Clone, PartialEq)]
385pub struct SchemaFieldSpec {
386    pub typ: TypeSpec,
387    pub min: Option<NodeId>,
388    pub max: Option<NodeId>,
389    pub min_len: Option<NodeId>,
390    pub max_len: Option<NodeId>,
391    pub pattern: Option<NodeId>,
392    pub default: Option<NodeId>,
393    pub deprecated: Option<String>,
394}
395
396impl fmt::Debug for SchemaFieldSpec {
397    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
398        f.debug_struct("SchemaFieldSpec")
399            .field("typ", &self.typ)
400            .field("min", &self.min)
401            .field("max", &self.max)
402            .field("min_len", &self.min_len)
403            .field("max_len", &self.max_len)
404            .field("pattern", &self.pattern)
405            .field("default", &self.default)
406            .field(
407                "deprecated",
408                &self.deprecated.as_ref().map(|text| text.len()),
409            )
410            .finish()
411    }
412}
413
414impl SchemaFieldSpec {
415    pub fn bare(typ: TypeSpec) -> Self {
416        Self {
417            typ,
418            min: None,
419            max: None,
420            min_len: None,
421            max_len: None,
422            pattern: None,
423            default: None,
424            deprecated: None,
425        }
426    }
427}
428
429/// A module node.
430///
431/// Every variant is a value of the Active IR after name resolution; `Ident`
432/// is retained only because decoded third-party modules may still contain
433/// one (the encoder never emits it for canonical modules produced from
434/// `active-core`).
435///
436/// `Debug` is written by hand (never derived): blob payloads are summarized
437/// by identity and secrets render as `Secret(***)`, so logging a module can
438/// never dump payload or secret bytes.
439#[derive(Clone, PartialEq)]
440pub enum Inode {
441    Null,
442    Bool(bool),
443    Int(i64),
444    Float(f64),
445    Str(String),
446    DurationMs(u64),
447    Quantity {
448        value: NodeId,
449        unit: super::UnitTag,
450    },
451    Array {
452        elements: Vec<NodeId>,
453    },
454    Table {
455        fields: Vec<(String, NodeId)>,
456    },
457    /// A structural secret literal. Never equals anything.
458    Secret(SecretBytes),
459    /// A secret derived from a locator node (`secret.env(NAME)`,
460    /// `secret(<expr>)`).
461    SecretRef {
462        locator: NodeId,
463    },
464    Ref(NodeId),
465    Env {
466        name: String,
467        default: Option<NodeId>,
468    },
469    Ident(String),
470    Interpolate {
471        parts: Vec<InterpPart>,
472    },
473    Binary {
474        op: BinOp,
475        lhs: NodeId,
476        rhs: NodeId,
477    },
478    Unary {
479        op: UnOp,
480        expr: NodeId,
481    },
482    Constrained {
483        target: NodeId,
484        constraint: Constraint,
485    },
486    Patch(PatchOp),
487    SchemaField(SchemaFieldSpec),
488    SchemaTable {
489        fields: Vec<(String, NodeId)>,
490        closed: bool,
491    },
492    SchemaArray {
493        element: NodeId,
494    },
495    Semantic {
496        tag: SemanticTag,
497        inner: NodeId,
498    },
499    /// `blob(media_type, data)` construction site (reserved kind 24).
500    ContentAddressed {
501        media_type: NodeId,
502        data: NodeId,
503    },
504    /// A materialised content-addressed blob identity.
505    ///
506    /// `payload` is present only for untainted blobs that were materialised;
507    /// a tainted blob never carries bytes, and the codec refuses a tainted
508    /// blob entry whose payload is non-empty.
509    Blob {
510        hash: [u8; 32],
511        size: u64,
512        tainted: bool,
513        media_type: String,
514        payload: Option<Vec<u8>>,
515    },
516}
517
518impl fmt::Debug for Inode {
519    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
520        match self {
521            Inode::Null => f.write_str("Null"),
522            Inode::Bool(value) => write!(f, "Bool({value})"),
523            Inode::Int(value) => write!(f, "Int({value})"),
524            Inode::Float(value) => write!(f, "Float({value})"),
525            Inode::Str(value) => write!(f, "Str(<{} bytes>)", value.len()),
526            Inode::DurationMs(ms) => write!(f, "DurationMs({ms})"),
527            Inode::Quantity { value, .. } => write!(f, "Quantity({value}, <unit>)"),
528            Inode::Array { elements } => write!(f, "Array({elements:?})"),
529            Inode::Table { fields } => write!(f, "Table(<{} fields>)", fields.len()),
530            Inode::Secret(_) => f.write_str("Secret(***)"),
531            Inode::SecretRef { locator } => write!(f, "SecretRef({locator})"),
532            Inode::Ref(id) => write!(f, "Ref({id})"),
533            Inode::Env { name, default } => write!(f, "Env(<{} bytes>, {default:?})", name.len()),
534            Inode::Ident(name) => write!(f, "Ident(<{} bytes>)", name.len()),
535            Inode::Interpolate { parts } => write!(f, "Interpolate(<{} parts>)", parts.len()),
536            Inode::Binary { op, lhs, rhs } => write!(f, "Binary({}, {lhs}, {rhs})", op.as_str()),
537            Inode::Unary { op, expr } => write!(f, "Unary({}, {expr})", op.as_str()),
538            Inode::Constrained { target, .. } => {
539                write!(f, "Constrained({target}, <constraint>)")
540            }
541            Inode::Patch(_) => f.write_str("Patch(<op>)"),
542            Inode::SchemaField(_) => f.write_str("SchemaField(<spec>)"),
543            Inode::SchemaTable { fields, closed } => {
544                write!(f, "SchemaTable(<{} fields>, closed={closed})", fields.len())
545            }
546            Inode::SchemaArray { element } => write!(f, "SchemaArray({element})"),
547            Inode::Semantic { tag, inner } => write!(f, "Semantic({}, {inner})", tag.as_str()),
548            Inode::ContentAddressed { data, .. } => {
549                write!(f, "ContentAddressed(<media>, {data})")
550            }
551            Inode::Blob {
552                hash,
553                size,
554                tainted,
555                media_type,
556                payload,
557            } => write!(
558                f,
559                "Blob(blake3:{}, size={size}, media=<{} bytes>, tainted={tainted}, payload={})",
560                hash_hex(hash),
561                media_type.len(),
562                match payload {
563                    Some(bytes) => format!("{} bytes", bytes.len()),
564                    None => "absent".to_string(),
565                }
566            ),
567        }
568    }
569}
570
571/// Lowercase hex of a 32-byte digest.
572pub fn hash_hex(hash: &[u8; 32]) -> String {
573    let mut out = String::with_capacity(64);
574    for byte in hash {
575        out.push_str(&format!("{byte:02x}"));
576    }
577    out
578}
579
580/// Parse a 64-character hex digest.
581pub fn parse_hash_hex(text: &str) -> Option<[u8; 32]> {
582    if text.len() != 64 {
583        return None;
584    }
585    let mut out = [0u8; 32];
586    for (index, pair) in text.as_bytes().chunks_exact(2).enumerate() {
587        let hi = (pair[0] as char).to_digit(16)?;
588        let lo = (pair[1] as char).to_digit(16)?;
589        out[index] = (hi * 16 + lo) as u8;
590    }
591    Some(out)
592}
593
594/// Stable node-kind identity, usable without depending on wire ids.
595///
596/// Downstream tools (the RPC layer, projections, diagnostics) name kinds through
597/// this enum; `code()` is the permanent wire discriminant.
598#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
599#[allow(missing_docs)]
600pub enum SerializedKind {
601    Null,
602    Bool,
603    Int,
604    Float,
605    Str,
606    DurationMs,
607    Quantity,
608    Array,
609    Table,
610    Secret,
611    SecretRef,
612    Ref,
613    Env,
614    Ident,
615    Interpolate,
616    Binary,
617    Unary,
618    Constrained,
619    Patch,
620    SchemaField,
621    SchemaTable,
622    SchemaArray,
623    Semantic,
624    Blob,
625    ContentAddressed,
626}
627
628impl SerializedKind {
629    pub const fn code(self) -> u8 {
630        match self {
631            SerializedKind::Null => 0,
632            SerializedKind::Bool => 1,
633            SerializedKind::Int => 2,
634            SerializedKind::Float => 3,
635            SerializedKind::Str => 4,
636            SerializedKind::DurationMs => 5,
637            SerializedKind::Quantity => 6,
638            SerializedKind::Array => 7,
639            SerializedKind::Table => 8,
640            SerializedKind::Secret => 9,
641            SerializedKind::SecretRef => 10,
642            SerializedKind::Ref => 11,
643            SerializedKind::Env => 12,
644            SerializedKind::Ident => 13,
645            SerializedKind::Interpolate => 14,
646            SerializedKind::Binary => 15,
647            SerializedKind::Unary => 16,
648            SerializedKind::Constrained => 17,
649            SerializedKind::Patch => 18,
650            SerializedKind::SchemaField => 19,
651            SerializedKind::SchemaTable => 20,
652            SerializedKind::SchemaArray => 21,
653            SerializedKind::Semantic => 22,
654            SerializedKind::Blob => 23,
655            SerializedKind::ContentAddressed => 24,
656        }
657    }
658
659    pub const fn from_code(code: u8) -> Option<Self> {
660        let kind = match code {
661            0 => SerializedKind::Null,
662            1 => SerializedKind::Bool,
663            2 => SerializedKind::Int,
664            3 => SerializedKind::Float,
665            4 => SerializedKind::Str,
666            5 => SerializedKind::DurationMs,
667            6 => SerializedKind::Quantity,
668            7 => SerializedKind::Array,
669            8 => SerializedKind::Table,
670            9 => SerializedKind::Secret,
671            10 => SerializedKind::SecretRef,
672            11 => SerializedKind::Ref,
673            12 => SerializedKind::Env,
674            13 => SerializedKind::Ident,
675            14 => SerializedKind::Interpolate,
676            15 => SerializedKind::Binary,
677            16 => SerializedKind::Unary,
678            17 => SerializedKind::Constrained,
679            18 => SerializedKind::Patch,
680            19 => SerializedKind::SchemaField,
681            20 => SerializedKind::SchemaTable,
682            21 => SerializedKind::SchemaArray,
683            22 => SerializedKind::Semantic,
684            23 => SerializedKind::Blob,
685            24 => SerializedKind::ContentAddressed,
686            _ => return None,
687        };
688        Some(kind)
689    }
690
691    pub const fn name(self) -> &'static str {
692        crate::view::node_kind_name(self.code())
693    }
694}
695
696impl Inode {
697    /// Stable kind identity of this node.
698    pub const fn kind(&self) -> SerializedKind {
699        match self {
700            Inode::Null => SerializedKind::Null,
701            Inode::Bool(_) => SerializedKind::Bool,
702            Inode::Int(_) => SerializedKind::Int,
703            Inode::Float(_) => SerializedKind::Float,
704            Inode::Str(_) => SerializedKind::Str,
705            Inode::DurationMs(_) => SerializedKind::DurationMs,
706            Inode::Quantity { .. } => SerializedKind::Quantity,
707            Inode::Array { .. } => SerializedKind::Array,
708            Inode::Table { .. } => SerializedKind::Table,
709            Inode::Secret(_) => SerializedKind::Secret,
710            Inode::SecretRef { .. } => SerializedKind::SecretRef,
711            Inode::Ref(_) => SerializedKind::Ref,
712            Inode::Env { .. } => SerializedKind::Env,
713            Inode::Ident(_) => SerializedKind::Ident,
714            Inode::Interpolate { .. } => SerializedKind::Interpolate,
715            Inode::Binary { .. } => SerializedKind::Binary,
716            Inode::Unary { .. } => SerializedKind::Unary,
717            Inode::Constrained { .. } => SerializedKind::Constrained,
718            Inode::Patch(_) => SerializedKind::Patch,
719            Inode::SchemaField(_) => SerializedKind::SchemaField,
720            Inode::SchemaTable { .. } => SerializedKind::SchemaTable,
721            Inode::SchemaArray { .. } => SerializedKind::SchemaArray,
722            Inode::Semantic { .. } => SerializedKind::Semantic,
723            Inode::Blob { .. } => SerializedKind::Blob,
724            Inode::ContentAddressed { .. } => SerializedKind::ContentAddressed,
725        }
726    }
727}
728
729impl Inode {
730    /// Stable kind discriminant (wire contract, `docs/spec/layout.md` §5.1).
731    pub const fn kind_code(&self) -> u8 {
732        match self {
733            Inode::Null => 0,
734            Inode::Bool(_) => 1,
735            Inode::Int(_) => 2,
736            Inode::Float(_) => 3,
737            Inode::Str(_) => 4,
738            Inode::DurationMs(_) => 5,
739            Inode::Quantity { .. } => 6,
740            Inode::Array { .. } => 7,
741            Inode::Table { .. } => 8,
742            Inode::Secret(_) => 9,
743            Inode::SecretRef { .. } => 10,
744            Inode::Ref(_) => 11,
745            Inode::Env { .. } => 12,
746            Inode::Ident(_) => 13,
747            Inode::Interpolate { .. } => 14,
748            Inode::Binary { .. } => 15,
749            Inode::Unary { .. } => 16,
750            Inode::Constrained { .. } => 17,
751            Inode::Patch(_) => 18,
752            Inode::SchemaField(_) => 19,
753            Inode::SchemaTable { .. } => 20,
754            Inode::SchemaArray { .. } => 21,
755            Inode::Semantic { .. } => 22,
756            Inode::Blob { .. } => 23,
757            Inode::ContentAddressed { .. } => 24,
758        }
759    }
760
761    /// Whether this node carries secret material or a secret locator.
762    pub const fn is_secret(&self) -> bool {
763        matches!(self, Inode::Secret(_) | Inode::SecretRef { .. })
764    }
765
766    /// Node references in canonical child order.
767    ///
768    /// This is the single source of truth for the dependency edges, the
769    /// canonical numbering traversal, cycle detection and graph queries. It
770    /// must list *every* node reference the variant holds.
771    pub fn children(&self) -> Vec<NodeId> {
772        match self {
773            Inode::Quantity { value, .. } => vec![*value],
774            Inode::Array { elements } => elements.clone(),
775            Inode::Table { fields } => fields.iter().map(|(_, id)| *id).collect(),
776            Inode::SecretRef { locator } => vec![*locator],
777            Inode::Ref(id) => vec![*id],
778            Inode::Env { default, .. } => default.iter().copied().collect(),
779            Inode::Interpolate { parts } => parts
780                .iter()
781                .filter_map(|part| match part {
782                    InterpPart::Expr(id) => Some(*id),
783                    InterpPart::Literal(_) => None,
784                })
785                .collect(),
786            Inode::Binary { lhs, rhs, .. } => vec![*lhs, *rhs],
787            Inode::Unary { expr, .. } => vec![*expr],
788            Inode::Constrained { target, .. } => vec![*target],
789            Inode::Patch(op) => match op {
790                PatchOp::Set { value, .. } => vec![*value],
791                PatchOp::Remove { .. } | PatchOp::Rename { .. } => Vec::new(),
792            },
793            Inode::SchemaField(spec) => {
794                let mut out = Vec::new();
795                for id in [
796                    spec.min,
797                    spec.max,
798                    spec.min_len,
799                    spec.max_len,
800                    spec.pattern,
801                    spec.default,
802                ]
803                .into_iter()
804                .flatten()
805                {
806                    out.push(id);
807                }
808                out
809            }
810            Inode::SchemaTable { fields, .. } => fields.iter().map(|(_, id)| *id).collect(),
811            Inode::SchemaArray { element } => vec![*element],
812            Inode::Semantic { inner, .. } => vec![*inner],
813            Inode::ContentAddressed { media_type, data } => vec![*media_type, *data],
814            Inode::Null
815            | Inode::Bool(_)
816            | Inode::Int(_)
817            | Inode::Float(_)
818            | Inode::Str(_)
819            | Inode::DurationMs(_)
820            | Inode::Secret(_)
821            | Inode::Ident(_)
822            | Inode::Blob { .. } => Vec::new(),
823        }
824    }
825
826    /// Every string this node owns, for heap construction.
827    pub fn strings(&self, out: &mut Vec<String>) {
828        match self {
829            Inode::Str(s) | Inode::Ident(s) => out.push(s.clone()),
830            Inode::Table { fields } => {
831                for (name, _) in fields {
832                    out.push(name.clone());
833                }
834            }
835            Inode::Env { name, .. } => out.push(name.clone()),
836            Inode::Interpolate { parts } => {
837                for part in parts {
838                    if let InterpPart::Literal(s) = part {
839                        out.push(s.clone());
840                    }
841                }
842            }
843            Inode::Constrained { constraint, .. } => match constraint {
844                Constraint::Enum(items) => out.extend(items.iter().cloned()),
845                Constraint::Pattern(s) | Constraint::Custom(s) => out.push(s.clone()),
846                Constraint::MinMax { .. } | Constraint::Odd => {}
847            },
848            Inode::Patch(op) => match op {
849                PatchOp::Set { path, .. } | PatchOp::Remove { path } => out.push(path.clone()),
850                PatchOp::Rename { from, to } => {
851                    out.push(from.clone());
852                    out.push(to.clone());
853                }
854            },
855            Inode::SchemaField(spec) => {
856                if let TypeSpec::Enum(items) = &spec.typ {
857                    out.extend(items.iter().cloned());
858                }
859                if let Some(deprecated) = &spec.deprecated {
860                    out.push(deprecated.clone());
861                }
862            }
863            Inode::SchemaTable { fields, .. } => {
864                for (name, _) in fields {
865                    out.push(name.clone());
866                }
867            }
868            Inode::Blob { media_type, .. } => out.push(media_type.clone()),
869            Inode::Null
870            | Inode::Bool(_)
871            | Inode::Int(_)
872            | Inode::Float(_)
873            | Inode::DurationMs(_)
874            | Inode::Quantity { .. }
875            | Inode::Array { .. }
876            | Inode::Secret(_)
877            | Inode::SecretRef { .. }
878            | Inode::Ref(_)
879            | Inode::Unary { .. }
880            | Inode::Binary { .. }
881            | Inode::SchemaArray { .. }
882            | Inode::Semantic { .. }
883            | Inode::ContentAddressed { .. } => {}
884        }
885    }
886}
887
888/// Annotation kinds (spec/annotations.md §2–§4).
889#[derive(Clone, PartialEq)]
890pub enum AnnotationKind {
891    /// Documentation only, never enforced.
892    Intent(String),
893    /// A constraint predicate attached to a node.
894    Constraint { source: String, expr: NodeId },
895    /// Agent-immutable flag.
896    AiImmutable(bool),
897}
898
899impl fmt::Debug for AnnotationKind {
900    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
901        match self {
902            AnnotationKind::Intent(text) => write!(f, "Intent(<{} bytes>)", text.len()),
903            AnnotationKind::Constraint { expr, .. } => {
904                write!(f, "Constraint {{ source: <redacted>, expr: {expr} }}")
905            }
906            AnnotationKind::AiImmutable(flag) => write!(f, "AiImmutable({flag})"),
907        }
908    }
909}
910
911/// One annotation attached to a node.
912#[derive(Debug, Clone, PartialEq)]
913pub struct Annotation {
914    pub node: NodeId,
915    pub kind: AnnotationKind,
916}
917
918/// Kinds of provenance entries.
919#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
920pub enum ProvKind {
921    Text,
922    Int,
923    Flag,
924    Hash,
925    /// Input module content hash (delta/derivation lineage).
926    Input,
927}
928
929impl ProvKind {
930    pub const fn code(self) -> u32 {
931        match self {
932            ProvKind::Text => 0,
933            ProvKind::Int => 1,
934            ProvKind::Flag => 2,
935            ProvKind::Hash => 3,
936            ProvKind::Input => 4,
937        }
938    }
939
940    pub const fn from_code(code: u32) -> Option<Self> {
941        match code {
942            0 => Some(ProvKind::Text),
943            1 => Some(ProvKind::Int),
944            2 => Some(ProvKind::Flag),
945            3 => Some(ProvKind::Hash),
946            4 => Some(ProvKind::Input),
947            _ => None,
948        }
949    }
950}
951
952/// A provenance value.
953#[derive(Clone, PartialEq)]
954pub enum ProvValue {
955    Text(String),
956    Int(i64),
957    Flag(bool),
958    Hash([u8; 32]),
959}
960
961impl fmt::Debug for ProvValue {
962    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
963        match self {
964            ProvValue::Text(text) => write!(f, "Text(<{} bytes>)", text.len()),
965            ProvValue::Int(value) => write!(f, "Int({value})"),
966            ProvValue::Flag(flag) => write!(f, "Flag({flag})"),
967            ProvValue::Hash(hash) => write!(f, "Hash(blake3:{})", hash_hex(hash)),
968        }
969    }
970}
971
972/// One provenance entry: a typed key/value pair.
973#[derive(Clone, PartialEq)]
974pub struct ProvEntry {
975    pub key: String,
976    pub value: ProvValue,
977}
978
979impl fmt::Debug for ProvEntry {
980    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
981        write!(
982            f,
983            "ProvEntry {{ key: <{} bytes>, value: {:?} }}",
984            self.key.len(),
985            self.value
986        )
987    }
988}
989
990impl ProvEntry {
991    pub fn kind(&self) -> ProvKind {
992        match self.value {
993            ProvValue::Text(_) => ProvKind::Text,
994            ProvValue::Int(_) => ProvKind::Int,
995            ProvValue::Flag(_) => ProvKind::Flag,
996            ProvValue::Hash(_) => ProvKind::Hash,
997        }
998    }
999}
1000
1001/// Module provenance: who produced this module, when, with what, from what.
1002#[derive(Clone, PartialEq, Default)]
1003pub struct Provenance {
1004    /// Producer identity (agent, tool, human, pipeline).
1005    pub producer: Option<String>,
1006    /// Unix epoch milliseconds; `0` means "unrecorded".
1007    pub timestamp_ms: u64,
1008    /// Model identity when an AI produced the module.
1009    pub model: Option<String>,
1010    /// Tool identity.
1011    pub tool: Option<String>,
1012    /// Content hash of the parent module this one derives from.
1013    pub parent: Option<[u8; 32]>,
1014    /// Free-form typed entries, sorted by key in canonical form.
1015    pub entries: Vec<ProvEntry>,
1016}
1017
1018impl fmt::Debug for Provenance {
1019    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1020        f.debug_struct("Provenance")
1021            .field("producer", &self.producer.as_ref().map(|text| text.len()))
1022            .field("timestamp_ms", &self.timestamp_ms)
1023            .field("model", &self.model.as_ref().map(|text| text.len()))
1024            .field("tool", &self.tool.as_ref().map(|text| text.len()))
1025            .field("parent", &self.parent)
1026            .field("entries", &self.entries)
1027            .finish()
1028    }
1029}
1030
1031/// A canonical Active Infinity module.
1032///
1033/// The struct is an arena of [`Inode`] plus roots, bindings, annotations and
1034/// provenance. Edges are *derived* from node children, so a module can never
1035/// hold an edge table inconsistent with its nodes.
1036#[derive(Clone, Default)]
1037pub struct InfinityModule {
1038    nodes: Vec<Inode>,
1039    root: Option<NodeId>,
1040    bindings: Vec<(String, NodeId)>,
1041    annotations: Vec<Annotation>,
1042    provenance: Option<Provenance>,
1043    extensions: Vec<(u64, Vec<u8>)>,
1044}
1045
1046/// Debug is an export path (I-3). Binding names, table keys, annotations,
1047/// provenance text and every other string field can contain a copy of a secret
1048/// literal, so the module is cloned and redacted before it is formatted.
1049/// Extension payloads print as a length: a payload can contain secret-shaped
1050/// bytes and has no safe renderer.
1051impl fmt::Debug for InfinityModule {
1052    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1053        let secrets = crate::interop::secret_texts(self);
1054        let redact = |text: &str| crate::canonical::redact_text(text, &secrets);
1055        let mut redacted = self.clone();
1056        redact_for_debug(&mut redacted, &redact);
1057        let extensions: Vec<(u64, usize)> = redacted
1058            .extensions
1059            .iter()
1060            .map(|(tag, payload)| (*tag, payload.len()))
1061            .collect();
1062        f.debug_struct("InfinityModule")
1063            .field("nodes", &redacted.nodes)
1064            .field("root", &redacted.root)
1065            .field("bindings", &redacted.bindings)
1066            .field("annotations", &redacted.annotations)
1067            .field("provenance", &redacted.provenance)
1068            .field("extensions", &extensions)
1069            .finish()
1070    }
1071}
1072
1073fn redact_for_debug(module: &mut InfinityModule, redact: &impl Fn(&str) -> String) {
1074    for node in &mut module.nodes {
1075        redact_inode(node, redact);
1076    }
1077    for (name, _) in &mut module.bindings {
1078        *name = redact(name);
1079    }
1080    for annotation in &mut module.annotations {
1081        match &mut annotation.kind {
1082            AnnotationKind::Intent(text) => *text = redact(text),
1083            AnnotationKind::Constraint { source, .. } => *source = redact(source),
1084            AnnotationKind::AiImmutable(_) => {}
1085        }
1086    }
1087    if let Some(prov) = &mut module.provenance {
1088        if let Some(text) = &mut prov.producer {
1089            *text = redact(text);
1090        }
1091        if let Some(text) = &mut prov.model {
1092            *text = redact(text);
1093        }
1094        if let Some(text) = &mut prov.tool {
1095            *text = redact(text);
1096        }
1097        for entry in &mut prov.entries {
1098            entry.key = redact(&entry.key);
1099            if let ProvValue::Text(text) = &mut entry.value {
1100                *text = redact(text);
1101            }
1102        }
1103    }
1104}
1105
1106fn redact_inode(node: &mut Inode, redact: &impl Fn(&str) -> String) {
1107    match node {
1108        Inode::Str(text) => *text = redact(text),
1109        Inode::Quantity { unit, .. } => {
1110            if let super::UnitTag::Named(name) = unit {
1111                *name = redact(name);
1112            }
1113        }
1114        Inode::Table { fields } | Inode::SchemaTable { fields, .. } => {
1115            for (name, _) in fields {
1116                *name = redact(name);
1117            }
1118        }
1119        Inode::Env { name, .. } => *name = redact(name),
1120        Inode::Ident(text) => *text = redact(text),
1121        Inode::Interpolate { parts } => {
1122            for part in parts {
1123                if let InterpPart::Literal(text) = part {
1124                    *text = redact(text);
1125                }
1126            }
1127        }
1128        Inode::Constrained { constraint, .. } => match constraint {
1129            Constraint::Enum(values) => {
1130                for value in values {
1131                    *value = redact(value);
1132                }
1133            }
1134            Constraint::Pattern(text) | Constraint::Custom(text) => *text = redact(text),
1135            Constraint::MinMax { .. } | Constraint::Odd => {}
1136        },
1137        Inode::Patch(op) => match op {
1138            PatchOp::Set { path, .. } | PatchOp::Remove { path } => *path = redact(path),
1139            PatchOp::Rename { from, to } => {
1140                *from = redact(from);
1141                *to = redact(to);
1142            }
1143        },
1144        Inode::SchemaField(spec) => {
1145            if let TypeSpec::Enum(values) = &mut spec.typ {
1146                for value in values {
1147                    *value = redact(value);
1148                }
1149            }
1150            if let Some(text) = &mut spec.deprecated {
1151                *text = redact(text);
1152            }
1153        }
1154        Inode::Blob { media_type, .. } => *media_type = redact(media_type),
1155        Inode::Null
1156        | Inode::Bool(_)
1157        | Inode::Int(_)
1158        | Inode::Float(_)
1159        | Inode::DurationMs(_)
1160        | Inode::Array { .. }
1161        | Inode::Secret(_)
1162        | Inode::SecretRef { .. }
1163        | Inode::Ref(_)
1164        | Inode::Binary { .. }
1165        | Inode::Unary { .. }
1166        | Inode::SchemaArray { .. }
1167        | Inode::Semantic { .. }
1168        | Inode::ContentAddressed { .. } => {}
1169    }
1170}
1171
1172impl PartialEq for InfinityModule {
1173    /// Canonical structural equality: two modules are equal when their
1174    /// canonical forms match, so arena order, table field order and blob
1175    /// numbering are irrelevant.
1176    ///
1177    /// Secret *contents* are never compared — only the fact that a secret is
1178    /// there — so equality can never become a guessing oracle, and a module
1179    /// holding a secret is equal to a structurally identical copy of itself.
1180    /// Use [`SecretBytes`] directly when you need the anti-oracle behaviour of
1181    /// two secret values.
1182    fn eq(&self, other: &Self) -> bool {
1183        self.structurally_eq(other)
1184    }
1185}
1186
1187/// Compare two canonical nodes, blinding secret payloads.
1188fn node_eq(left: &Inode, right: &Inode) -> bool {
1189    match (left, right) {
1190        (Inode::Secret(_), Inode::Secret(_)) => true,
1191        (Inode::Secret(_), _) | (_, Inode::Secret(_)) => false,
1192        // Canonicalization normalizes floats; compare their bits so a NaN is
1193        // equal to itself.
1194        (Inode::Float(a), Inode::Float(b)) => a.to_bits() == b.to_bits(),
1195        _ => left == right,
1196    }
1197}
1198
1199impl InfinityModule {
1200    pub fn new() -> Self {
1201        Self::default()
1202    }
1203
1204    /// Add a node, returning its id.
1205    pub fn push(&mut self, node: Inode) -> NodeId {
1206        let id = NodeId(u32::try_from(self.nodes.len()).expect("arena exceeds u32 ids"));
1207        self.nodes.push(node);
1208        id
1209    }
1210
1211    /// Alias for [`InfinityModule::push`], mirroring `Module::add_node`.
1212    pub fn add_node(&mut self, node: Inode) -> NodeId {
1213        self.push(node)
1214    }
1215
1216    pub fn get(&self, id: NodeId) -> Option<&Inode> {
1217        self.nodes.get(id.index())
1218    }
1219
1220    pub fn get_mut(&mut self, id: NodeId) -> Option<&mut Inode> {
1221        self.nodes.get_mut(id.index())
1222    }
1223
1224    pub fn node_mut(&mut self, id: NodeId) -> Result<&mut Inode, super::Error> {
1225        self.nodes
1226            .get_mut(id.index())
1227            .ok_or(super::Error::NodeRef(id.0))
1228    }
1229
1230    pub fn nodes(&self) -> &[Inode] {
1231        &self.nodes
1232    }
1233
1234    pub fn node_count(&self) -> usize {
1235        self.nodes.len()
1236    }
1237
1238    pub fn is_empty(&self) -> bool {
1239        self.nodes.is_empty()
1240    }
1241
1242    pub fn root(&self) -> Option<NodeId> {
1243        self.root
1244    }
1245
1246    pub fn set_root(&mut self, root: Option<NodeId>) {
1247        self.root = root;
1248    }
1249
1250    pub fn bindings(&self) -> &[(String, NodeId)] {
1251        &self.bindings
1252    }
1253
1254    /// Bind a name to a node. Replaces an existing binding with the same name.
1255    pub fn bind(&mut self, name: impl Into<String>, id: NodeId) {
1256        let name = name.into();
1257        if let Some(slot) = self.bindings.iter_mut().find(|(n, _)| *n == name) {
1258            slot.1 = id;
1259        } else {
1260            self.bindings.push((name, id));
1261        }
1262    }
1263
1264    pub fn annotations(&self) -> &[Annotation] {
1265        &self.annotations
1266    }
1267
1268    pub fn annotate(&mut self, node: NodeId, kind: AnnotationKind) {
1269        self.annotations.push(Annotation { node, kind });
1270    }
1271
1272    /// Drop every annotation.
1273    pub fn clear_annotations(&mut self) {
1274        self.annotations.clear();
1275    }
1276
1277    /// Remove a binding by name; returns the old target when present.
1278    pub fn remove_binding(&mut self, name: &str) -> Option<NodeId> {
1279        let position = self.bindings.iter().position(|(key, _)| key == name)?;
1280        Some(self.bindings.remove(position).1)
1281    }
1282
1283    pub fn provenance(&self) -> Option<&Provenance> {
1284        self.provenance.as_ref()
1285    }
1286
1287    /// True when the module holds a structural secret literal.
1288    pub fn has_secret(&self) -> bool {
1289        self.nodes
1290            .iter()
1291            .any(|node| matches!(node, Inode::Secret(_)))
1292    }
1293
1294    /// Canonical structural equality, blinding secret payloads.
1295    ///
1296    /// This is what [`PartialEq`] implements; it is also available by name for
1297    /// readers who want the intent spelled out.
1298    pub fn structurally_eq(&self, other: &Self) -> bool {
1299        let (Ok(left), Ok(right)) = (
1300            crate::canonical::prepare(self),
1301            crate::canonical::prepare(other),
1302        ) else {
1303            return false;
1304        };
1305        left.root == right.root
1306            && left.bindings == right.bindings
1307            && left.annotations == right.annotations
1308            && left.provenance == right.provenance
1309            && left.strings == right.strings
1310            && left.units == right.units
1311            && left.blobs == right.blobs
1312            && left.hashes == right.hashes
1313            && left.numbers == right.numbers
1314            && left.secrets.len() == right.secrets.len()
1315            && left.types.len() == right.types.len()
1316            && left
1317                .types
1318                .iter()
1319                .zip(right.types.iter())
1320                .all(|(a, b)| a.spec == b.spec && a.aux == b.aux)
1321            && left.nodes.len() == right.nodes.len()
1322            && left
1323                .nodes
1324                .iter()
1325                .zip(right.nodes.iter())
1326                .all(|(a, b)| node_eq(a, b))
1327    }
1328
1329    /// Append an extension block (tag, payload).
1330    ///
1331    /// Tags are unique; the canonical encoder sorts blocks by tag and refuses a
1332    /// duplicate. Extension payloads are opaque bytes owned by the application;
1333    /// the codec never interprets them.
1334    pub fn add_extension(&mut self, tag: u64, payload: impl Into<Vec<u8>>) {
1335        self.extensions.push((tag, payload.into()));
1336    }
1337
1338    /// Extension blocks, in stored order.
1339    pub fn extensions(&self) -> &[(u64, Vec<u8>)] {
1340        &self.extensions
1341    }
1342
1343    /// Replace the extension set.
1344    pub fn set_extensions(&mut self, extensions: Vec<(u64, Vec<u8>)>) {
1345        self.extensions = extensions;
1346    }
1347
1348    pub fn set_provenance(&mut self, provenance: Option<Provenance>) {
1349        // Provenance is a map keyed by name: the last value for a key wins, so
1350        // the stored form can never contain the duplicate keys that validation
1351        // (correctly) rejects. Entries stay sorted for canonical equality.
1352        self.provenance = provenance.map(|prov| {
1353            let mut by_key: std::collections::BTreeMap<String, ProvValue> =
1354                std::collections::BTreeMap::new();
1355            for entry in prov.entries {
1356                by_key.insert(entry.key, entry.value);
1357            }
1358            let entries: Vec<ProvEntry> = by_key
1359                .into_iter()
1360                .map(|(key, value)| ProvEntry { key, value })
1361                .collect();
1362            Provenance { entries, ..prov }
1363        });
1364    }
1365
1366    /// Roots used to define the reachable graph: the module root, every
1367    /// binding, every annotation subject, and provenance-only references.
1368    pub fn roots(&self) -> Vec<NodeId> {
1369        let mut roots = Vec::new();
1370        if let Some(root) = self.root {
1371            roots.push(root);
1372        }
1373        let mut bindings: Vec<&(String, NodeId)> = self.bindings.iter().collect();
1374        bindings.sort_by(|a, b| a.0.cmp(&b.0));
1375        roots.extend(bindings.into_iter().map(|(_, id)| *id));
1376        for annotation in &self.annotations {
1377            roots.push(annotation.node);
1378            if let AnnotationKind::Constraint { expr, .. } = &annotation.kind {
1379                roots.push(*expr);
1380            }
1381        }
1382        roots
1383    }
1384
1385    /// Derived dependency edges, direction dependency → dependent, sorted and
1386    /// deduplicated. Nodes that reference where they are referenced from are
1387    /// normal; the graph is a DAG after [`Self::validate_structure`].
1388    pub fn edges(&self) -> Vec<(NodeId, NodeId)> {
1389        let mut edges = Vec::new();
1390        for (index, node) in self.nodes.iter().enumerate() {
1391            let dependent = NodeId(index as u32);
1392            for child in node.children() {
1393                edges.push((child, dependent));
1394            }
1395        }
1396        edges.sort_unstable();
1397        edges.dedup();
1398        edges
1399    }
1400
1401    /// Reverse adjacency (dependent → dependencies).
1402    pub fn dependency_map(&self) -> HashMap<NodeId, Vec<NodeId>> {
1403        let mut map: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
1404        for (index, node) in self.nodes.iter().enumerate() {
1405            map.insert(NodeId(index as u32), node.children());
1406        }
1407        map
1408    }
1409
1410    /// Check every structural invariant the wire format also enforces.
1411    ///
1412    /// `max_nodes` and `max_depth` bound the work so a hostile module cannot
1413    /// make this function hang or recurse without limit.
1414    pub fn validate_structure(&self, max_nodes: u64, max_depth: u32) -> Result<(), super::Error> {
1415        use super::Error;
1416        if self.nodes.len() as u64 > max_nodes {
1417            return Err(Error::Limit {
1418                kind: "nodes",
1419                limit: max_nodes,
1420            });
1421        }
1422        let count = self.nodes.len() as u32;
1423        let check = |id: NodeId| -> Result<(), Error> {
1424            if id.0 >= count {
1425                return Err(Error::NodeRef(id.0));
1426            }
1427            Ok(())
1428        };
1429        for (index, node) in self.nodes.iter().enumerate() {
1430            let id = NodeId(index as u32);
1431            for child in node.children() {
1432                check(child)?;
1433            }
1434            match node {
1435                Inode::Table { fields } | Inode::SchemaTable { fields, .. } => {
1436                    // O(k) duplicate detection: a 256-field table is common and
1437                    // the quadratic form dominated encoding time.
1438                    let mut seen = std::collections::HashSet::with_capacity(fields.len());
1439                    for (name, _) in fields {
1440                        if !seen.insert(name.as_str()) {
1441                            return Err(Error::DuplicateField { node: id.0 });
1442                        }
1443                    }
1444                }
1445                Inode::Float(value)
1446                    if value.is_nan() && !crate::canonical::is_canonical_nan(*value) =>
1447                {
1448                    return Err(Error::Node {
1449                        id: id.0,
1450                        reason: "non-canonical NaN",
1451                    });
1452                }
1453                Inode::Constrained { constraint, .. } => match constraint {
1454                    Constraint::MinMax {
1455                        min: Some(min),
1456                        max: Some(max),
1457                    } if min > max => {
1458                        return Err(Error::Node {
1459                            id: id.0,
1460                            reason: "constraint min exceeds max",
1461                        });
1462                    }
1463                    Constraint::Enum(items) if items.is_empty() => {
1464                        return Err(Error::Node {
1465                            id: id.0,
1466                            reason: "empty enum constraint",
1467                        });
1468                    }
1469                    _ => {}
1470                },
1471                _ => {}
1472            }
1473        }
1474        if let Some(root) = self.root {
1475            check(root)?;
1476        }
1477        let mut seen = std::collections::HashSet::new();
1478        for (name, id) in &self.bindings {
1479            check(*id)?;
1480            if !seen.insert(name.as_str()) {
1481                return Err(Error::DuplicateBinding);
1482            }
1483        }
1484        for annotation in &self.annotations {
1485            check(annotation.node)?;
1486            if let AnnotationKind::Constraint { expr, .. } = &annotation.kind {
1487                check(*expr)?;
1488            }
1489        }
1490        self.check_acyclic(count)?;
1491        self.check_depth(max_depth)?;
1492        Ok(())
1493    }
1494
1495    /// Cycle detection over the derived dependency graph (Kahn sweep).
1496    ///
1497    /// One child-list derivation per node, so a wide table stays linear; the
1498    /// sweep is iterative by construction, because a hostile 4M-node chain must
1499    /// not overflow the thread stack (`active-core` treats cycles as fatal, so
1500    /// this is a hard invariant, not a hint).
1501    pub fn check_acyclic(&self, count: u32) -> Result<(), super::Error> {
1502        use super::Error;
1503        let mut incoming = vec![0u32; count as usize];
1504        for node in &self.nodes {
1505            for child in node.children() {
1506                if child.0 >= count {
1507                    return Err(Error::NodeRef(child.0));
1508                }
1509                let slot = &mut incoming[child.index()];
1510                *slot = slot.saturating_add(1);
1511            }
1512        }
1513        let mut queue: std::collections::VecDeque<u32> = incoming
1514            .iter()
1515            .enumerate()
1516            .filter(|(_, degree)| **degree == 0)
1517            .map(|(node, _)| node as u32)
1518            .collect();
1519        let mut processed = 0u32;
1520        while let Some(node) = queue.pop_front() {
1521            processed += 1;
1522            for child in self.nodes[node as usize].children() {
1523                let slot = &mut incoming[child.index()];
1524                *slot = slot.saturating_sub(1);
1525                if *slot == 0 {
1526                    queue.push_back(child.0);
1527                }
1528            }
1529        }
1530        if processed != count {
1531            // A node that could not be ordered depends on a cycle; report the
1532            // lowest one so the answer is stable.
1533            let node = incoming.iter().position(|degree| *degree > 0).unwrap_or(0) as u32;
1534            return Err(Error::Cycle { node });
1535        }
1536        Ok(())
1537    }
1538
1539    /// Depth check over the derived dependency graph (topological sweep).
1540    ///
1541    /// Each edge is examined exactly once, so a wide or diamond-shaped graph is
1542    /// linear rather than polynomial.
1543    pub fn check_depth(&self, max_depth: u32) -> Result<(), super::Error> {
1544        use super::Error;
1545        let count = self.nodes.len();
1546        if count == 0 {
1547            return Ok(());
1548        }
1549        if max_depth == 0 {
1550            // Zero means "no depth ceiling", not "no checks": a direct caller
1551            // still gets cycle detection.
1552            return self.check_acyclic(count as u32);
1553        }
1554        let mut incoming = vec![0u32; count];
1555        for node in &self.nodes {
1556            for child in node.children() {
1557                if child.index() >= count {
1558                    return Err(Error::NodeRef(child.0));
1559                }
1560                incoming[child.index()] = incoming[child.index()].saturating_add(1);
1561            }
1562        }
1563        let mut depth = vec![1u32; count];
1564        let mut queue: std::collections::VecDeque<u32> = incoming
1565            .iter()
1566            .enumerate()
1567            .filter(|(_, degree)| **degree == 0)
1568            .map(|(node, _)| node as u32)
1569            .collect();
1570        if queue.is_empty() {
1571            // Acyclicity runs first; stay total for a direct caller.
1572            return Err(Error::Cycle { node: 0 });
1573        }
1574        let mut processed = 0u32;
1575        while let Some(node) = queue.pop_front() {
1576            processed += 1;
1577            let next = depth[node as usize] + 1;
1578            for child in self.nodes[node as usize].children() {
1579                if next > max_depth {
1580                    return Err(Error::Depth { limit: max_depth });
1581                }
1582                if next > depth[child.index()] {
1583                    depth[child.index()] = next;
1584                }
1585                let slot = &mut incoming[child.index()];
1586                *slot = slot.saturating_sub(1);
1587                if *slot == 0 {
1588                    queue.push_back(child.0);
1589                }
1590            }
1591        }
1592        if processed as usize != count {
1593            // Acyclicity runs first, so this is defensive; report the lowest
1594            // node that could not be ordered, like `check_acyclic`.
1595            let node = incoming.iter().position(|degree| *degree > 0).unwrap_or(0) as u32;
1596            return Err(Error::Cycle { node });
1597        }
1598        Ok(())
1599    }
1600
1601    /// Total string payload bytes (heap size before deduplication).
1602    pub fn string_bytes(&self) -> u64 {
1603        let mut strings = Vec::new();
1604        for node in &self.nodes {
1605            node.strings(&mut strings);
1606        }
1607        strings.iter().map(|s| s.len() as u64).sum()
1608    }
1609
1610    /// Rebuild a module from decoded parts (used by the decoder and the
1611    /// stream reassembler).
1612    pub fn from_parts(
1613        nodes: Vec<Inode>,
1614        root: Option<NodeId>,
1615        bindings: Vec<(String, NodeId)>,
1616        mut annotations: Vec<Annotation>,
1617        provenance: Option<Provenance>,
1618    ) -> Self {
1619        annotations.sort_by(|a, b| {
1620            (a.node.0, annotation_order(&a.kind)).cmp(&(b.node.0, annotation_order(&b.kind)))
1621        });
1622        InfinityModule {
1623            nodes,
1624            root,
1625            bindings,
1626            annotations,
1627            provenance,
1628            extensions: Vec::new(),
1629        }
1630    }
1631}
1632
1633fn annotation_order(kind: &AnnotationKind) -> u32 {
1634    match kind {
1635        AnnotationKind::Intent(_) => 0,
1636        AnnotationKind::Constraint { .. } => 1,
1637        AnnotationKind::AiImmutable(_) => 2,
1638    }
1639}
1640
1641/// Builder helpers that keep the node graph consistent while constructing a
1642/// module by hand or from another IR.
1643#[derive(Debug, Default)]
1644pub struct ModuleBuilder {
1645    module: InfinityModule,
1646}
1647
1648impl ModuleBuilder {
1649    pub fn new() -> Self {
1650        Self::default()
1651    }
1652
1653    pub fn push(&mut self, node: Inode) -> NodeId {
1654        self.module.push(node)
1655    }
1656
1657    pub fn root(&mut self, id: NodeId) -> &mut Self {
1658        self.module.set_root(Some(id));
1659        self
1660    }
1661
1662    pub fn bind(&mut self, name: impl Into<String>, id: NodeId) -> &mut Self {
1663        self.module.bind(name, id);
1664        self
1665    }
1666
1667    pub fn annotate(&mut self, node: NodeId, kind: AnnotationKind) -> &mut Self {
1668        self.module.annotate(node, kind);
1669        self
1670    }
1671
1672    pub fn provenance(&mut self, provenance: Provenance) -> &mut Self {
1673        self.module.set_provenance(Some(provenance));
1674        self
1675    }
1676
1677    pub fn build(self) -> InfinityModule {
1678        self.module
1679    }
1680}