Skip to main content

axon_frontend/
module_interface.rs

1//! v2.76.0 — Phase 1 of the Epistemic Module System: `.axi` interfaces.
2//!
3//! A [`CognitiveInterface`] is the public surface of one module — every
4//! top-level *named* declaration (the design decision, via [`crate::ast::declaration_surface`])
5//! reduced to its **signature**: what an importer must know to type-check
6//! and to trust, never the implementation (ask text, step bodies, PID
7//! gains, endpoint literals).
8//!
9//! # The dual-hash scheme (GHC ABI hash)
10//!
11//! - [`CognitiveInterface::content_hash`] — SHA-256 of the source bytes;
12//!   changes on ANY edit.
13//! - [`CognitiveInterface::interface_hash`] — SHA-256 of the canonical
14//!   `.axi` JSON (with the hash field itself excluded); changes only when
15//!   the PUBLIC surface changes. A comment-only edit keeps it stable —
16//! the precondition for early cutoff (v2.76.0).
17//!
18//! # Where the soundness line sits (AST-merge architecture)
19//!
20//! Signatures deliberately hide bodies. That is sound because the linked
21//! program is built by **merging the module ASTs** (v2.76.0): the merged
22//! semantic revalidation and the single IR generation always see full
23//! declarations. Per-module validation consumes ONLY what the signature
24//! carries (name + kind + the fields below), so a body-only edit never
25//! invalidates a dependent's per-module pass — and can never make it
26//! stale, because nothing body-derived is ever cached per-dependent.
27//!
28//! Hashing honors the crate's zero-runtime-dep discipline: SHA-256 is the
29//! v1.31.0 hand-rolled FIPS 180-4 [`crate::store_schema_manifest::sha256_hex`].
30
31use std::collections::BTreeMap;
32
33use serde::{Deserialize, Serialize};
34
35use crate::ast::{declaration_surface, Declaration, Program, TypeExpr};
36use crate::module_resolver::ModulePath;
37use crate::store_schema_manifest::sha256_hex;
38
39/// `.axi` format version. Bumping it busts every compilation cache
40/// wholesale (v2.76.0 law 6) — a shape change may never meet stale bytes.
41pub const AXI_FORMAT_VERSION: u32 = 1;
42
43// ════════════════════════════════════════════════════════════════════
44// Epistemic floor (section 3.4 of the EMS paper)
45// ════════════════════════════════════════════════════════════════════
46
47/// The module-level epistemic guarantee, derived from content — never
48/// from an annotation an author could forget.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
50#[serde(rename_all = "lowercase")]
51pub enum EpistemicFloor {
52    Unspecified = 0,
53    Speculate = 1,
54    Doubt = 2,
55    Believe = 3,
56    Know = 4,
57}
58
59impl EpistemicFloor {
60    pub fn as_str(self) -> &'static str {
61        match self {
62            EpistemicFloor::Unspecified => "unspecified",
63            EpistemicFloor::Speculate => "speculate",
64            EpistemicFloor::Doubt => "doubt",
65            EpistemicFloor::Believe => "believe",
66            EpistemicFloor::Know => "know",
67        }
68    }
69
70    pub fn rank(self) -> u8 {
71        self as u8
72    }
73
74    fn from_mode(mode: &str) -> EpistemicFloor {
75        match mode {
76            "know" => EpistemicFloor::Know,
77            "believe" => EpistemicFloor::Believe,
78            "doubt" => EpistemicFloor::Doubt,
79            "speculate" => EpistemicFloor::Speculate,
80            _ => EpistemicFloor::Unspecified,
81        }
82    }
83}
84
85/// Floor rules, highest wins (recursing into epistemic blocks):
86/// anchors ⇒ know · shields ⇒ believe · `know|believe|doubt|speculate`
87/// block ⇒ its level · otherwise unspecified.
88pub fn compute_floor(program: &Program) -> EpistemicFloor {
89    fn walk(decls: &[Declaration], floor: &mut EpistemicFloor) {
90        for decl in decls {
91            let candidate = match decl {
92                Declaration::Anchor(_) => EpistemicFloor::Know,
93                Declaration::Shield(_) => EpistemicFloor::Believe,
94                Declaration::Epistemic(eb) => {
95                    let level = EpistemicFloor::from_mode(&eb.mode);
96                    walk(&eb.body, floor);
97                    level
98                }
99                _ => EpistemicFloor::Unspecified,
100            };
101            if candidate > *floor {
102                *floor = candidate;
103            }
104        }
105    }
106    let mut floor = EpistemicFloor::Unspecified;
107    walk(&program.declarations, &mut floor);
108    floor
109}
110
111// ════════════════════════════════════════════════════════════════════
112//  Export signatures
113// ════════════════════════════════════════════════════════════════════
114
115/// One exported declaration's signature. Six kinds carry structured
116/// fields (the ones cross-module validation and trust decisions consume);
117/// every other named kind exports as `Other { kind }` — name + kind is
118/// exactly what the checker's symbol table needs for it, and nothing
119/// body-derived is ever consumed cross-module before the merged
120/// revalidation (which sees full declarations).
121#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
122#[serde(tag = "kind", rename_all = "lowercase")]
123pub enum ExportSignature {
124    Persona {
125        domain: Vec<String>,
126        tone: String,
127        #[serde(skip_serializing_if = "Option::is_none", default)]
128        confidence_threshold: Option<f64>,
129    },
130    Anchor {
131        /// SHA-256 over the constraint's semantic fields — hides the
132        /// text, detects any change to the enforced meaning.
133        constraint_hash: String,
134        on_violation: String,
135    },
136    Flow {
137        params: Vec<(String, String)>,
138        output_type: String,
139        step_count: usize,
140    },
141    Shield {
142        scan: Vec<String>,
143        on_breach: String,
144    },
145    Tool {
146        effects: Vec<String>,
147        #[serde(skip_serializing_if = "Option::is_none", default)]
148        risk: Option<String>,
149        provider: String,
150        /// v2.77.0 — the authorization scopes the tool requires.
151        /// PUBLIC surface: an importer must see the scope demands (axon-T956
152        /// coverage), and the interface_hash must cover it so a `requires:`
153        /// change invalidates every dependent (early-cutoff soundness, v2.76.0).
154        /// Elided when empty ⇒ every pre-v2.77.0 tool's `.axi` is byte-identical.
155        #[serde(skip_serializing_if = "Vec::is_empty", default)]
156        requires: Vec<String>,
157    },
158    Resource {
159        resource_kind: String,
160        #[serde(skip_serializing_if = "Option::is_none", default)]
161        capacity: Option<i64>,
162        lifetime: String,
163    },
164    Other {
165        other_kind: String,
166    },
167}
168
169impl ExportSignature {
170    /// The symbol-table kind string (parity with the type-checker's
171    /// `register_declarations` — see `declaration_surface`).
172    pub fn kind(&self) -> &str {
173        match self {
174            ExportSignature::Persona { .. } => "persona",
175            ExportSignature::Anchor { .. } => "anchor",
176            ExportSignature::Flow { .. } => "flow",
177            ExportSignature::Shield { .. } => "shield",
178            ExportSignature::Tool { .. } => "tool",
179            ExportSignature::Resource { .. } => "resource",
180            ExportSignature::Other { other_kind } => other_kind,
181        }
182    }
183}
184
185fn type_spelling(t: &TypeExpr) -> String {
186    let mut s = t.name.clone();
187    if !t.generic_param.is_empty() {
188        s.push('<');
189        s.push_str(&t.generic_param);
190        s.push('>');
191    }
192    if t.optional {
193        s.push('?');
194    }
195    s
196}
197
198// ════════════════════════════════════════════════════════════════════
199//  CognitiveInterface — the .axi
200// ════════════════════════════════════════════════════════════════════
201
202/// The `.axi` — a module's compiled cognitive interface.
203#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
204pub struct CognitiveInterface {
205    pub format_version: u32,
206    /// Dotted module path (`axon.security`).
207    pub module: String,
208    pub epistemic_floor: EpistemicFloor,
209    /// SHA-256 of the module's source bytes.
210    pub content_hash: String,
211    /// SHA-256 of this interface's canonical JSON (this field excluded).
212    pub interface_hash: String,
213    /// Name → signature, deterministically ordered.
214    pub exports: BTreeMap<String, ExportSignature>,
215}
216
217impl CognitiveInterface {
218    /// Serialize to canonical `.axi` JSON (pretty, stable field order —
219    /// the byte shape the interface hash is computed over and the cache
220    /// persists).
221    pub fn to_axi_json(&self) -> String {
222        serde_json::to_string_pretty(self).expect("interface serializes")
223    }
224
225    pub fn from_axi_json(s: &str) -> Option<CognitiveInterface> {
226        serde_json::from_str(s).ok()
227    }
228}
229
230/// Extract the `.axi` interface of one module (Phase 1).
231pub fn generate_interface(
232    module: &ModulePath,
233    program: &Program,
234    source: &str,
235) -> CognitiveInterface {
236    let mut exports: BTreeMap<String, ExportSignature> = BTreeMap::new();
237
238    fn collect(decls: &[Declaration], exports: &mut BTreeMap<String, ExportSignature>) {
239        for decl in decls {
240            if let Declaration::Epistemic(eb) = decl {
241                collect(&eb.body, exports);
242                continue;
243            }
244            let Some((name, kind, _loc)) = declaration_surface(decl) else {
245                continue;
246            };
247            if name.is_empty() {
248                continue;
249            }
250            let sig = match decl {
251                Declaration::Persona(n) => ExportSignature::Persona {
252                    domain: n.domain.clone(),
253                    tone: n.tone.clone(),
254                    confidence_threshold: n.confidence_threshold,
255                },
256                Declaration::Anchor(n) => {
257                    let mut basis = String::new();
258                    basis.push_str(&n.require);
259                    basis.push('\u{1f}');
260                    basis.push_str(&n.reject.join("\u{1f}"));
261                    basis.push('\u{1f}');
262                    basis.push_str(&n.enforce);
263                    basis.push('\u{1f}');
264                    basis.push_str(&n.unknown_response);
265                    basis.push('\u{1f}');
266                    if let Some(cf) = n.confidence_floor {
267                        basis.push_str(&format!("{cf:.6}"));
268                    }
269                    basis.push('\u{1f}');
270                    basis.push_str(&n.on_violation_target);
271                    ExportSignature::Anchor {
272                        constraint_hash: sha256_hex(basis.as_bytes()),
273                        on_violation: n.on_violation.clone(),
274                    }
275                }
276                Declaration::Flow(n) => ExportSignature::Flow {
277                    params: n
278                        .parameters
279                        .iter()
280                        .map(|p| (p.name.clone(), type_spelling(&p.type_expr)))
281                        .collect(),
282                    output_type: n
283                        .return_type
284                        .as_ref()
285                        .map(type_spelling)
286                        .unwrap_or_default(),
287                    step_count: n.body.len(),
288                },
289                Declaration::Shield(n) => ExportSignature::Shield {
290                    scan: n.scan.clone(),
291                    on_breach: n.on_breach.clone(),
292                },
293                Declaration::Tool(n) => ExportSignature::Tool {
294                    effects: n
295                        .effects
296                        .as_ref()
297                        .map(|e| e.effects.clone())
298                        .unwrap_or_default(),
299                    risk: n.risk.clone(),
300                    provider: n.provider.clone(),
301                    requires: n.requires.clone(),
302                },
303                Declaration::Resource(n) => ExportSignature::Resource {
304                    resource_kind: n.kind.clone(),
305                    capacity: n.capacity,
306                    lifetime: n.lifetime.clone(),
307                },
308                _ => ExportSignature::Other { other_kind: kind },
309            };
310            exports.insert(name, sig);
311        }
312    }
313    collect(&program.declarations, &mut exports);
314
315    let mut interface = CognitiveInterface {
316        format_version: AXI_FORMAT_VERSION,
317        module: module.dotted(),
318        epistemic_floor: compute_floor(program),
319        content_hash: sha256_hex(source.as_bytes()),
320        interface_hash: String::new(),
321        exports,
322    };
323    // The interface hash covers ONLY the public surface (module path,
324    // floor, exports) — BOTH hash fields are zeroed in the hashed bytes.
325    // Hashing the content hash too would drag every source edit into the
326    // interface identity and kill early cutoff (the exact property the
327    // dual-hash scheme exists to provide).
328    let mut hashed = interface.clone();
329    hashed.content_hash = String::new();
330    interface.interface_hash = sha256_hex(hashed.to_axi_json().as_bytes());
331    interface
332}
333
334// ════════════════════════════════════════════════════════════════════
335//  ModuleRegistry
336// ════════════════════════════════════════════════════════════════════
337
338/// The resolved interfaces of every module in a compilation, keyed by
339/// module path. What the type-checker's module mode (v2.76.0) and the ECC
340/// (v2.76.0) consume. Deterministic by construction.
341#[derive(Debug, Default)]
342pub struct ModuleRegistry {
343    modules: BTreeMap<ModulePath, CognitiveInterface>,
344}
345
346impl ModuleRegistry {
347    pub fn new() -> Self {
348        Self::default()
349    }
350
351    pub fn register(&mut self, path: ModulePath, interface: CognitiveInterface) {
352        self.modules.insert(path, interface);
353    }
354
355    pub fn interface(&self, path: &ModulePath) -> Option<&CognitiveInterface> {
356        self.modules.get(path)
357    }
358
359    /// The exported kind of `name` in module `path`, if any.
360    pub fn export_kind(&self, path: &ModulePath, name: &str) -> Option<&str> {
361        self.modules
362            .get(path)
363            .and_then(|i| i.exports.get(name))
364            .map(|sig| sig.kind())
365    }
366
367    pub fn len(&self) -> usize {
368        self.modules.len()
369    }
370
371    pub fn is_empty(&self) -> bool {
372        self.modules.is_empty()
373    }
374
375    pub fn iter(&self) -> impl Iterator<Item = (&ModulePath, &CognitiveInterface)> {
376        self.modules.iter()
377    }
378}
379
380// ════════════════════════════════════════════════════════════════════
381//  Unit tests (integration suite: tests/interfaces.rs)
382// ════════════════════════════════════════════════════════════════════
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387    use crate::lexer::Lexer;
388    use crate::parser::Parser;
389
390    fn parse(source: &str) -> Program {
391        let tokens = Lexer::new(source, "test.axon").tokenize().expect("lex");
392        Parser::new(tokens).parse().expect("parse")
393    }
394
395    fn mp(dotted: &str) -> ModulePath {
396        ModulePath(dotted.split('.').map(str::to_string).collect())
397    }
398
399    const SECURITY: &str = r#"
400persona Expert {
401  domain: ["medicine", "diagnostics"]
402  tone: precise
403  confidence_threshold: 0.9
404}
405
406anchor NoHallucination {
407  require: source_citation
408  confidence_floor: 0.75
409  on_violation: raise AnchorBreachError
410}
411"#;
412
413    #[test]
414    fn floor_anchor_means_know() {
415        assert_eq!(compute_floor(&parse(SECURITY)), EpistemicFloor::Know);
416    }
417
418    #[test]
419    fn floor_shield_means_believe() {
420        let p = parse("shield S { scan: [pii_leak] on_breach: halt }\n");
421        assert_eq!(compute_floor(&p), EpistemicFloor::Believe);
422    }
423
424    #[test]
425    fn floor_unspecified_without_evidence() {
426        let p = parse("persona P { domain: [\"x\"] }\n");
427        assert_eq!(compute_floor(&p), EpistemicFloor::Unspecified);
428    }
429
430    #[test]
431    fn interface_hides_description_but_carries_signature() {
432        let p = parse(SECURITY);
433        let i = generate_interface(&mp("axon.security"), &p, SECURITY);
434        let json = i.to_axi_json();
435        assert!(json.contains("\"diagnostics\""));
436        assert!(json.contains("constraint_hash"));
437        assert!(!json.contains("source_citation"), "anchor text must be hidden");
438        assert_eq!(i.exports.len(), 2);
439    }
440
441    #[test]
442    fn interface_hash_stable_under_comment_edit() {
443        let with_comment = format!("// a comment\n{SECURITY}");
444        let p1 = parse(SECURITY);
445        let p2 = parse(&with_comment);
446        let i1 = generate_interface(&mp("m"), &p1, SECURITY);
447        let i2 = generate_interface(&mp("m"), &p2, &with_comment);
448        assert_ne!(i1.content_hash, i2.content_hash);
449        assert_eq!(i1.interface_hash, i2.interface_hash, "early-cutoff precondition");
450    }
451
452    #[test]
453    fn interface_hash_changes_when_surface_changes() {
454        let changed = SECURITY.replace("0.9", "0.8");
455        let i1 = generate_interface(&mp("m"), &parse(SECURITY), SECURITY);
456        let i2 = generate_interface(&mp("m"), &parse(&changed), &changed);
457        assert_ne!(i1.interface_hash, i2.interface_hash);
458    }
459
460    #[test]
461    fn roundtrip_axi_json() {
462        let i = generate_interface(&mp("m"), &parse(SECURITY), SECURITY);
463        let back = CognitiveInterface::from_axi_json(&i.to_axi_json()).unwrap();
464        assert_eq!(i, back);
465    }
466
467    #[test]
468    fn hash_determinism() {
469        let a = generate_interface(&mp("m"), &parse(SECURITY), SECURITY);
470        let b = generate_interface(&mp("m"), &parse(SECURITY), SECURITY);
471        assert_eq!(a.interface_hash, b.interface_hash);
472        assert_eq!(a.content_hash, b.content_hash);
473    }
474}