Skip to main content

axon_frontend/
module_interface.rs

1//! §Fase 115.b — 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 (D115.3, 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 (§115.f).
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** (§115.e): 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//! §Fase 38 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 (§115.f law 6) — a shape change may never meet stale bytes.
41pub const AXI_FORMAT_VERSION: u32 = 1;
42
43// ════════════════════════════════════════════════════════════════════
44//  Epistemic floor (§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    },
151    Resource {
152        resource_kind: String,
153        #[serde(skip_serializing_if = "Option::is_none", default)]
154        capacity: Option<i64>,
155        lifetime: String,
156    },
157    Other {
158        other_kind: String,
159    },
160}
161
162impl ExportSignature {
163    /// The symbol-table kind string (parity with the type-checker's
164    /// `register_declarations` — see `declaration_surface`).
165    pub fn kind(&self) -> &str {
166        match self {
167            ExportSignature::Persona { .. } => "persona",
168            ExportSignature::Anchor { .. } => "anchor",
169            ExportSignature::Flow { .. } => "flow",
170            ExportSignature::Shield { .. } => "shield",
171            ExportSignature::Tool { .. } => "tool",
172            ExportSignature::Resource { .. } => "resource",
173            ExportSignature::Other { other_kind } => other_kind,
174        }
175    }
176}
177
178fn type_spelling(t: &TypeExpr) -> String {
179    let mut s = t.name.clone();
180    if !t.generic_param.is_empty() {
181        s.push('<');
182        s.push_str(&t.generic_param);
183        s.push('>');
184    }
185    if t.optional {
186        s.push('?');
187    }
188    s
189}
190
191// ════════════════════════════════════════════════════════════════════
192//  CognitiveInterface — the .axi
193// ════════════════════════════════════════════════════════════════════
194
195/// The `.axi` — a module's compiled cognitive interface.
196#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
197pub struct CognitiveInterface {
198    pub format_version: u32,
199    /// Dotted module path (`axon.security`).
200    pub module: String,
201    pub epistemic_floor: EpistemicFloor,
202    /// SHA-256 of the module's source bytes.
203    pub content_hash: String,
204    /// SHA-256 of this interface's canonical JSON (this field excluded).
205    pub interface_hash: String,
206    /// Name → signature, deterministically ordered.
207    pub exports: BTreeMap<String, ExportSignature>,
208}
209
210impl CognitiveInterface {
211    /// Serialize to canonical `.axi` JSON (pretty, stable field order —
212    /// the byte shape the interface hash is computed over and the cache
213    /// persists).
214    pub fn to_axi_json(&self) -> String {
215        serde_json::to_string_pretty(self).expect("interface serializes")
216    }
217
218    pub fn from_axi_json(s: &str) -> Option<CognitiveInterface> {
219        serde_json::from_str(s).ok()
220    }
221}
222
223/// Extract the `.axi` interface of one module (Phase 1).
224pub fn generate_interface(
225    module: &ModulePath,
226    program: &Program,
227    source: &str,
228) -> CognitiveInterface {
229    let mut exports: BTreeMap<String, ExportSignature> = BTreeMap::new();
230
231    fn collect(decls: &[Declaration], exports: &mut BTreeMap<String, ExportSignature>) {
232        for decl in decls {
233            if let Declaration::Epistemic(eb) = decl {
234                collect(&eb.body, exports);
235                continue;
236            }
237            let Some((name, kind, _loc)) = declaration_surface(decl) else {
238                continue;
239            };
240            if name.is_empty() {
241                continue;
242            }
243            let sig = match decl {
244                Declaration::Persona(n) => ExportSignature::Persona {
245                    domain: n.domain.clone(),
246                    tone: n.tone.clone(),
247                    confidence_threshold: n.confidence_threshold,
248                },
249                Declaration::Anchor(n) => {
250                    let mut basis = String::new();
251                    basis.push_str(&n.require);
252                    basis.push('\u{1f}');
253                    basis.push_str(&n.reject.join("\u{1f}"));
254                    basis.push('\u{1f}');
255                    basis.push_str(&n.enforce);
256                    basis.push('\u{1f}');
257                    basis.push_str(&n.unknown_response);
258                    basis.push('\u{1f}');
259                    if let Some(cf) = n.confidence_floor {
260                        basis.push_str(&format!("{cf:.6}"));
261                    }
262                    basis.push('\u{1f}');
263                    basis.push_str(&n.on_violation_target);
264                    ExportSignature::Anchor {
265                        constraint_hash: sha256_hex(basis.as_bytes()),
266                        on_violation: n.on_violation.clone(),
267                    }
268                }
269                Declaration::Flow(n) => ExportSignature::Flow {
270                    params: n
271                        .parameters
272                        .iter()
273                        .map(|p| (p.name.clone(), type_spelling(&p.type_expr)))
274                        .collect(),
275                    output_type: n
276                        .return_type
277                        .as_ref()
278                        .map(type_spelling)
279                        .unwrap_or_default(),
280                    step_count: n.body.len(),
281                },
282                Declaration::Shield(n) => ExportSignature::Shield {
283                    scan: n.scan.clone(),
284                    on_breach: n.on_breach.clone(),
285                },
286                Declaration::Tool(n) => ExportSignature::Tool {
287                    effects: n
288                        .effects
289                        .as_ref()
290                        .map(|e| e.effects.clone())
291                        .unwrap_or_default(),
292                    risk: n.risk.clone(),
293                    provider: n.provider.clone(),
294                },
295                Declaration::Resource(n) => ExportSignature::Resource {
296                    resource_kind: n.kind.clone(),
297                    capacity: n.capacity,
298                    lifetime: n.lifetime.clone(),
299                },
300                _ => ExportSignature::Other { other_kind: kind },
301            };
302            exports.insert(name, sig);
303        }
304    }
305    collect(&program.declarations, &mut exports);
306
307    let mut interface = CognitiveInterface {
308        format_version: AXI_FORMAT_VERSION,
309        module: module.dotted(),
310        epistemic_floor: compute_floor(program),
311        content_hash: sha256_hex(source.as_bytes()),
312        interface_hash: String::new(),
313        exports,
314    };
315    // The interface hash covers ONLY the public surface (module path,
316    // floor, exports) — BOTH hash fields are zeroed in the hashed bytes.
317    // Hashing the content hash too would drag every source edit into the
318    // interface identity and kill early cutoff (the exact property the
319    // dual-hash scheme exists to provide).
320    let mut hashed = interface.clone();
321    hashed.content_hash = String::new();
322    interface.interface_hash = sha256_hex(hashed.to_axi_json().as_bytes());
323    interface
324}
325
326// ════════════════════════════════════════════════════════════════════
327//  ModuleRegistry
328// ════════════════════════════════════════════════════════════════════
329
330/// The resolved interfaces of every module in a compilation, keyed by
331/// module path. What the type-checker's module mode (§115.d) and the ECC
332/// (§115.c) consume. Deterministic by construction.
333#[derive(Debug, Default)]
334pub struct ModuleRegistry {
335    modules: BTreeMap<ModulePath, CognitiveInterface>,
336}
337
338impl ModuleRegistry {
339    pub fn new() -> Self {
340        Self::default()
341    }
342
343    pub fn register(&mut self, path: ModulePath, interface: CognitiveInterface) {
344        self.modules.insert(path, interface);
345    }
346
347    pub fn interface(&self, path: &ModulePath) -> Option<&CognitiveInterface> {
348        self.modules.get(path)
349    }
350
351    /// The exported kind of `name` in module `path`, if any.
352    pub fn export_kind(&self, path: &ModulePath, name: &str) -> Option<&str> {
353        self.modules
354            .get(path)
355            .and_then(|i| i.exports.get(name))
356            .map(|sig| sig.kind())
357    }
358
359    pub fn len(&self) -> usize {
360        self.modules.len()
361    }
362
363    pub fn is_empty(&self) -> bool {
364        self.modules.is_empty()
365    }
366
367    pub fn iter(&self) -> impl Iterator<Item = (&ModulePath, &CognitiveInterface)> {
368        self.modules.iter()
369    }
370}
371
372// ════════════════════════════════════════════════════════════════════
373//  Unit tests (integration suite: tests/fase115_b_interfaces.rs)
374// ════════════════════════════════════════════════════════════════════
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379    use crate::lexer::Lexer;
380    use crate::parser::Parser;
381
382    fn parse(source: &str) -> Program {
383        let tokens = Lexer::new(source, "test.axon").tokenize().expect("lex");
384        Parser::new(tokens).parse().expect("parse")
385    }
386
387    fn mp(dotted: &str) -> ModulePath {
388        ModulePath(dotted.split('.').map(str::to_string).collect())
389    }
390
391    const SECURITY: &str = r#"
392persona Expert {
393  domain: ["medicine", "diagnostics"]
394  tone: precise
395  confidence_threshold: 0.9
396}
397
398anchor NoHallucination {
399  require: source_citation
400  confidence_floor: 0.75
401  on_violation: raise AnchorBreachError
402}
403"#;
404
405    #[test]
406    fn floor_anchor_means_know() {
407        assert_eq!(compute_floor(&parse(SECURITY)), EpistemicFloor::Know);
408    }
409
410    #[test]
411    fn floor_shield_means_believe() {
412        let p = parse("shield S { scan: [pii_leak] on_breach: halt }\n");
413        assert_eq!(compute_floor(&p), EpistemicFloor::Believe);
414    }
415
416    #[test]
417    fn floor_unspecified_without_evidence() {
418        let p = parse("persona P { domain: [\"x\"] }\n");
419        assert_eq!(compute_floor(&p), EpistemicFloor::Unspecified);
420    }
421
422    #[test]
423    fn interface_hides_description_but_carries_signature() {
424        let p = parse(SECURITY);
425        let i = generate_interface(&mp("axon.security"), &p, SECURITY);
426        let json = i.to_axi_json();
427        assert!(json.contains("\"diagnostics\""));
428        assert!(json.contains("constraint_hash"));
429        assert!(!json.contains("source_citation"), "anchor text must be hidden");
430        assert_eq!(i.exports.len(), 2);
431    }
432
433    #[test]
434    fn interface_hash_stable_under_comment_edit() {
435        let with_comment = format!("// a comment\n{SECURITY}");
436        let p1 = parse(SECURITY);
437        let p2 = parse(&with_comment);
438        let i1 = generate_interface(&mp("m"), &p1, SECURITY);
439        let i2 = generate_interface(&mp("m"), &p2, &with_comment);
440        assert_ne!(i1.content_hash, i2.content_hash);
441        assert_eq!(i1.interface_hash, i2.interface_hash, "early-cutoff precondition");
442    }
443
444    #[test]
445    fn interface_hash_changes_when_surface_changes() {
446        let changed = SECURITY.replace("0.9", "0.8");
447        let i1 = generate_interface(&mp("m"), &parse(SECURITY), SECURITY);
448        let i2 = generate_interface(&mp("m"), &parse(&changed), &changed);
449        assert_ne!(i1.interface_hash, i2.interface_hash);
450    }
451
452    #[test]
453    fn roundtrip_axi_json() {
454        let i = generate_interface(&mp("m"), &parse(SECURITY), SECURITY);
455        let back = CognitiveInterface::from_axi_json(&i.to_axi_json()).unwrap();
456        assert_eq!(i, back);
457    }
458
459    #[test]
460    fn hash_determinism() {
461        let a = generate_interface(&mp("m"), &parse(SECURITY), SECURITY);
462        let b = generate_interface(&mp("m"), &parse(SECURITY), SECURITY);
463        assert_eq!(a.interface_hash, b.interface_hash);
464        assert_eq!(a.content_hash, b.content_hash);
465    }
466}