Skip to main content

rune_compile/
lib.rs

1//! rune compile back-end — background compilation and transparent hot-swap.
2//!
3//! Architecture:
4//!   - CompileQueue: background thread pool for compilation jobs
5//!   - ArtifactCache: source-hash → compiled artifact mapping
6//!   - TierRouter: per-call-site tier (interpret | compile | compiled)
7//!   - hot-swap: when compile finishes, next call uses compiled path
8//!
9//! The interpreter is always the fallback. Compilation is opportunistic.
10//! Instant start is always preserved — compilation never blocks execution.
11
12use std::collections::HashMap;
13use std::sync::{Arc, Mutex};
14use rune_ast::Noun;
15
16/// A compiled artifact — the result of optimizing a Nox formula.
17/// In M6, this is the same Noun (no actual optimization yet).
18/// In future milestones: TIR-optimized .nox bytecode.
19#[derive(Clone)]
20pub struct CompiledArtifact {
21    /// The source formula hash (placeholder — hemera hash in M2+)
22    pub source_hash: u64,
23    /// The compiled formula (M6: same as source; future: optimized)
24    pub formula: Noun,
25    /// Whether this artifact was produced by the trident compile path
26    pub via_trident: bool,
27}
28
29/// Per-callsite execution tier.
30#[derive(Clone, Copy, Debug, PartialEq)]
31pub enum Tier {
32    /// Default: interpret directly
33    Interpret,
34    /// Submitted for compilation, still interpreting
35    Compiling,
36    /// Use compiled artifact
37    Compiled,
38}
39
40/// Call counter per formula hash — tracks hot paths.
41pub struct CallCounter {
42    counts: HashMap<u64, u64>,
43    threshold: u64,
44}
45
46impl CallCounter {
47    pub fn new(threshold: u64) -> Self {
48        CallCounter { counts: HashMap::new(), threshold }
49    }
50
51    /// Record a call. Returns true if this call crosses the compilation threshold.
52    pub fn record(&mut self, formula_hash: u64) -> bool {
53        let count = self.counts.entry(formula_hash).or_insert(0);
54        *count += 1;
55        *count == self.threshold
56    }
57
58    pub fn count(&self, formula_hash: u64) -> u64 {
59        self.counts.get(&formula_hash).copied().unwrap_or(0)
60    }
61}
62
63/// The artifact cache — maps source hash to compiled artifact.
64pub struct ArtifactCache {
65    inner: Arc<Mutex<HashMap<u64, CompiledArtifact>>>,
66}
67
68impl ArtifactCache {
69    pub fn new() -> Self {
70        ArtifactCache { inner: Arc::new(Mutex::new(HashMap::new())) }
71    }
72
73    pub fn get(&self, source_hash: u64) -> Option<CompiledArtifact> {
74        self.inner.lock().unwrap().get(&source_hash).cloned()
75    }
76
77    pub fn store(&self, artifact: CompiledArtifact) {
78        self.inner.lock().unwrap().insert(artifact.source_hash, artifact.clone());
79    }
80}
81
82impl Default for ArtifactCache {
83    fn default() -> Self { Self::new() }
84}
85
86/// Compile a formula. In M6 this is the trident path stub.
87/// Returns an artifact immediately (background thread in M8+).
88pub fn compile_formula(formula: &Noun, source_hash: u64) -> CompiledArtifact {
89    // M6 stub: try to emit trident source and compile it
90    // For now: return the formula unchanged with via_trident=false
91    // Real trident integration: emit .tri source → trident::compile() → .nox
92    CompiledArtifact {
93        source_hash,
94        formula: formula.clone(),
95        via_trident: false,
96    }
97}
98
99/// Attempt to compile a pure rune formula through trident.
100/// Returns Some(artifact) if compilation succeeded, None if formula contains
101/// dynamic forms (hint/host/eval) that trident cannot accept.
102pub fn try_trident_compile(formula: &Noun, source_hash: u64) -> Option<CompiledArtifact> {
103    // Check if formula is pure (no opcode 16/call or 17/look)
104    if contains_dynamic(formula) {
105        return None;
106    }
107    // M6 stub: real trident::compile() call would go here
108    // When trident dep is added: parse formula as .tri, call trident::compile()
109    Some(CompiledArtifact {
110        source_hash,
111        formula: formula.clone(),
112        via_trident: false,  // will be true when real compile path lands
113    })
114}
115
116/// Check if a formula contains dynamic opcodes (16=hint/call, 17=look).
117/// Pure formulas are trident-compatible.
118fn contains_dynamic(noun: &Noun) -> bool {
119    match noun {
120        Noun::Atom(_) => false,
121        Noun::Cell(h, t) => {
122            // opcode is atom head; if head is 16 or 17, it's dynamic
123            if let Noun::Atom(op) = h.as_ref() {
124                if *op == 16 || *op == 17 { return true; }
125            }
126            contains_dynamic(h) || contains_dynamic(t)
127        }
128    }
129}
130
131/// A simple formula hash — placeholder until hemera lands in M2.
132pub fn formula_hash(noun: &Noun) -> u64 {
133    match noun {
134        Noun::Atom(n) => n.wrapping_mul(0x517cc1b727220a95),
135        Noun::Cell(h, t) => {
136            let hh = formula_hash(h);
137            let th = formula_hash(t);
138            hh.wrapping_mul(31).wrapping_add(th)
139        }
140    }
141}
142
143/// Cache of (formula_hash, subject_hash) → result.
144/// Only used for pure formulas (no dynamic opcodes).
145struct ResultCache {
146    inner: HashMap<(u64, u64), Noun>,
147    max_entries: usize,
148}
149
150impl ResultCache {
151    fn new(max_entries: usize) -> Self {
152        ResultCache { inner: HashMap::new(), max_entries }
153    }
154
155    fn get(&self, formula_hash: u64, subject_hash: u64) -> Option<&Noun> {
156        self.inner.get(&(formula_hash, subject_hash))
157    }
158
159    fn store(&mut self, formula_hash: u64, subject_hash: u64, result: Noun) {
160        if self.inner.len() < self.max_entries {
161            self.inner.insert((formula_hash, subject_hash), result);
162        }
163        // When full: evict nothing (simple LRU-free policy for now)
164        // Future: LRU eviction
165    }
166
167    fn len(&self) -> usize { self.inner.len() }
168}
169
170/// Stats snapshot returned by `TierRouter::stats`.
171pub struct TierStats {
172    pub result_cache_entries: usize,
173}
174
175/// TierRouter — makes the call-tier decision.
176pub struct TierRouter {
177    counter: CallCounter,
178    cache: ArtifactCache,
179    results: ResultCache,
180    hot_threshold: u64,
181}
182
183impl TierRouter {
184    pub fn new(compile_threshold: u64) -> Self {
185        TierRouter {
186            counter: CallCounter::new(compile_threshold),
187            cache: ArtifactCache::new(),
188            results: ResultCache::new(1024),
189            hot_threshold: compile_threshold,
190        }
191    }
192
193    /// Evaluate formula against subject, routing through the appropriate tier.
194    pub fn eval(&mut self, subject: &Noun, formula: &Noun) -> Result<Noun, rune_interp::InterpError> {
195        let fhash = formula_hash(formula);
196        let is_pure = !contains_dynamic(formula);
197
198        // 1. Check result cache (pure formulas only)
199        if is_pure {
200            let shash = formula_hash(subject);
201            if let Some(cached) = self.results.get(fhash, shash) {
202                return Ok(cached.clone());
203            }
204        }
205
206        // 2. Check compiled artifact cache
207        let formula_to_run = if let Some(artifact) = self.cache.get(fhash) {
208            artifact.formula
209        } else {
210            formula.clone()
211        };
212
213        // 3. Interpret
214        let result = rune_interp::eval(subject, &formula_to_run)?;
215
216        // 4. Record call; on threshold: try to compile and cache artifact
217        if self.counter.record(fhash) {
218            if let Some(artifact) = try_trident_compile(formula, fhash) {
219                self.cache.store(artifact);
220            }
221        }
222
223        // 5. Memoize result for pure formulas that were called enough times
224        if is_pure && self.counter.count(fhash) >= self.hot_threshold {
225            let shash = formula_hash(subject);
226            self.results.store(fhash, shash, result.clone());
227        }
228
229        Ok(result)
230    }
231
232    /// Returns stats useful for profiling and debugging.
233    pub fn stats(&self) -> TierStats {
234        TierStats {
235            result_cache_entries: self.results.len(),
236        }
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use rune_ast::Noun;
244
245    #[test]
246    fn call_counter_threshold() {
247        let mut c = CallCounter::new(3);
248        assert!(!c.record(42));
249        assert!(!c.record(42));
250        assert!(c.record(42));  // third call hits threshold
251        assert!(!c.record(42)); // fourth does not re-trigger
252    }
253
254    #[test]
255    fn pure_formula_detection() {
256        // [1 42] — quote literal, pure
257        let pure = Noun::cell(Noun::Atom(1), Noun::Atom(42));
258        assert!(!contains_dynamic(&pure));
259
260        // [16 tag body] — hint, dynamic
261        let dynamic = Noun::cell(Noun::Atom(16), Noun::cell(Noun::Atom(0), Noun::Atom(0)));
262        assert!(contains_dynamic(&dynamic));
263    }
264
265    #[test]
266    fn tier_router_interprets() {
267        let mut router = TierRouter::new(100);
268        let subj = Noun::Atom(0);
269        let formula = Noun::cell(Noun::Atom(1), Noun::Atom(42));
270        let result = router.eval(&subj, &formula).unwrap();
271        assert_eq!(result, Noun::Atom(42));
272    }
273
274    #[test]
275    fn result_cache_hit_after_threshold() {
276        let mut router = TierRouter::new(3);
277        let subj = Noun::Atom(0);
278        // Pure formula: [1 42] = literal 42
279        let formula = Noun::cell(Noun::Atom(1), Noun::Atom(42));
280
281        // Call 3 times to hit threshold
282        for _ in 0..3 {
283            let r = router.eval(&subj, &formula).unwrap();
284            assert_eq!(r, Noun::Atom(42));
285        }
286
287        // 4th call: result should come from cache
288        let r = router.eval(&subj, &formula).unwrap();
289        assert_eq!(r, Noun::Atom(42));
290        assert_eq!(router.stats().result_cache_entries, 1);
291    }
292
293    #[test]
294    fn dynamic_formula_not_memoized() {
295        let mut router = TierRouter::new(2);
296        let subj = Noun::Atom(0);
297        // Dynamic formula: [16 [1 0] [1 42]] — contains hint opcode
298        let hint_meta = Noun::cell(Noun::cell(Noun::Atom(1), Noun::Atom(0)), Noun::cell(Noun::Atom(1), Noun::Atom(0)));
299        let body = Noun::cell(Noun::Atom(1), Noun::Atom(42));
300        let dynamic = Noun::cell(Noun::Atom(16), Noun::cell(hint_meta, body));
301
302        for _ in 0..5 {
303            router.eval(&subj, &dynamic).unwrap();
304        }
305        // Dynamic formulas must not be memoized
306        assert_eq!(router.stats().result_cache_entries, 0);
307    }
308
309    #[test]
310    fn different_subjects_cached_separately() {
311        let mut router = TierRouter::new(2);
312        let formula = Noun::cell(Noun::Atom(1), Noun::Atom(99));
313
314        // Same formula, two different subjects
315        for _ in 0..3 {
316            router.eval(&Noun::Atom(0), &formula).unwrap();
317            router.eval(&Noun::Atom(1), &formula).unwrap();
318        }
319
320        // Each (formula, subject) pair cached separately
321        assert_eq!(router.stats().result_cache_entries, 2);
322    }
323}