Skip to main content

bock_ai/
rules.rs

1//! Local codegen rule cache (§17.7).
2//!
3//! Rules live under `<project_root>/.bock/rules/{target_id}/{rule_id}.json`.
4//! They are consulted before Tier 1 AI synthesis: if a rule matches the
5//! AIR node's discriminant, its template is applied deterministically,
6//! saving the AI round-trip and turning a previously learned pattern
7//! into a pure lookup.
8//!
9//! The cache is append-only from the compiler's perspective: repair
10//! produces a [`crate::request::CandidateRule`] which is
11//! upgraded to a [`Rule`] (with provenance, id, timestamp) and written
12//! to disk. Human curation (pinning, deleting) happens through
13//! `bock override` and is out of scope for this module.
14//!
15//! `node_kind` is the discriminant string of [`bock_air::NodeKind`]
16//! (`"Match"`, `"Call"`, …). It is captured from the node the rule was
17//! extracted from; matching is kind-equal for v1.
18
19use std::fs;
20use std::io;
21use std::path::{Path, PathBuf};
22
23use bock_air::{AIRNode, NodeKind};
24use chrono::{DateTime, Utc};
25use serde::{Deserialize, Serialize};
26
27use crate::cache::compute_key;
28use crate::request::CandidateRule;
29
30// ─── Provenance ──────────────────────────────────────────────────────────────
31
32/// Where a rule came from.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum Provenance {
36    /// Ships with the compiler.
37    Builtin,
38    /// Distilled from a successful repair pass.
39    Extracted,
40    /// Authored by a human and committed to the project.
41    Manual,
42}
43
44// ─── Rule ────────────────────────────────────────────────────────────────────
45
46/// A single pattern-template mapping for deterministic codegen.
47#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
48pub struct Rule {
49    /// Stable identifier — SHA-256 of `(target_id, node_kind, template)`.
50    pub id: String,
51    /// Target language id (e.g. `"js"`, `"rust"`).
52    pub target_id: String,
53    /// AIR node-kind discriminant the rule matches.
54    pub node_kind: String,
55    /// Free-form pattern description. Not used for matching in v1; kept
56    /// so humans and future pattern engines can see what the AI had in
57    /// mind when it extracted the rule.
58    pub pattern: String,
59    /// Code template with interpolation slots (format TBD per §17.7).
60    pub template: String,
61    /// Where the rule came from.
62    pub provenance: Provenance,
63    /// Whether a human has pinned this rule (required in `production`).
64    pub pinned: bool,
65    /// Confidence the AI assigned at extraction time.
66    pub confidence: f64,
67    /// Priority for conflict resolution (higher wins).
68    pub priority: i32,
69    /// When the rule was recorded.
70    pub created: DateTime<Utc>,
71}
72
73impl Rule {
74    /// Lifts a provider-returned [`CandidateRule`] into a stored rule.
75    ///
76    /// `node_kind` is derived from the AIR node that triggered the
77    /// repair (the provider's textual pattern is free-form, so the
78    /// caller supplies the actual discriminant). Provenance defaults to
79    /// [`Provenance::Extracted`]; callers at `production` strictness
80    /// may pin after curation.
81    #[must_use]
82    pub fn from_candidate(candidate: &CandidateRule, node_kind: &str, confidence: f64) -> Self {
83        let id = compute_rule_id(&candidate.target_id, node_kind, &candidate.template);
84        Self {
85            id,
86            target_id: candidate.target_id.clone(),
87            node_kind: node_kind.into(),
88            pattern: candidate.pattern.clone(),
89            template: candidate.template.clone(),
90            provenance: Provenance::Extracted,
91            pinned: false,
92            confidence,
93            priority: candidate.priority,
94            created: Utc::now(),
95        }
96    }
97}
98
99/// Stable content-addressed id for a rule.
100///
101/// Used so repeated extractions of the same `(target, kind, template)`
102/// triple collapse to one file on disk rather than accumulating
103/// near-duplicates.
104#[must_use]
105pub fn compute_rule_id(target_id: &str, node_kind: &str, template: &str) -> String {
106    #[derive(Serialize)]
107    struct Keyed<'a> {
108        target: &'a str,
109        kind: &'a str,
110        template: &'a str,
111    }
112    let keyed = Keyed {
113        target: target_id,
114        kind: node_kind,
115        template,
116    };
117    compute_key(&keyed).unwrap_or_else(|_| format!("fallback-{target_id}-{node_kind}"))
118}
119
120// ─── RuleCache ───────────────────────────────────────────────────────────────
121
122/// Errors produced by the rule cache.
123#[derive(Debug, thiserror::Error)]
124pub enum RuleCacheError {
125    /// Filesystem I/O failed.
126    #[error("rule cache I/O error: {0}")]
127    Io(#[from] io::Error),
128    /// JSON parse failed reading a stored rule file.
129    #[error("rule parse error in {path}: {source}")]
130    Parse {
131        /// Offending file path.
132        path: PathBuf,
133        /// Underlying serde error.
134        #[source]
135        source: serde_json::Error,
136    },
137    /// JSON serialization failed writing a rule.
138    #[error("rule serialize error: {0}")]
139    Serialize(#[from] serde_json::Error),
140}
141
142/// On-disk rule cache rooted at `<project_root>/.bock/rules/`.
143#[derive(Debug, Clone)]
144pub struct RuleCache {
145    root: PathBuf,
146}
147
148impl RuleCache {
149    /// Cache rooted at `<project_root>/.bock/rules/`.
150    ///
151    /// The directory is not created eagerly — it is materialised on
152    /// first [`insert`](Self::insert).
153    #[must_use]
154    pub fn new(project_root: &Path) -> Self {
155        Self {
156            root: project_root.join(".bock").join("rules"),
157        }
158    }
159
160    /// Cache rooted at an explicit directory. Mainly for tests.
161    #[must_use]
162    pub fn with_root(root: PathBuf) -> Self {
163        Self { root }
164    }
165
166    /// Path to the cache root.
167    #[must_use]
168    pub fn root(&self) -> &Path {
169        &self.root
170    }
171
172    /// Directory where rules for `target_id` are stored.
173    #[must_use]
174    pub fn target_dir(&self, target_id: &str) -> PathBuf {
175        self.root.join(target_id)
176    }
177
178    /// Writes `rule` to disk. Creates parent directories on demand.
179    ///
180    /// Idempotent: two inserts of the same [`Rule::id`] end in one file.
181    ///
182    /// # Errors
183    /// Returns [`RuleCacheError`] on I/O or serialization failure.
184    pub fn insert(&self, rule: &Rule) -> Result<(), RuleCacheError> {
185        let dir = self.target_dir(&rule.target_id);
186        fs::create_dir_all(&dir)?;
187        let path = dir.join(format!("{}.json", rule.id));
188        let bytes = serde_json::to_vec_pretty(rule)?;
189        fs::write(&path, bytes)?;
190        Ok(())
191    }
192
193    /// Loads every rule stored for `target_id`.
194    ///
195    /// # Errors
196    /// Returns [`RuleCacheError`] on I/O or parse failure.
197    pub fn load_for_target(&self, target_id: &str) -> Result<Vec<Rule>, RuleCacheError> {
198        let dir = self.target_dir(target_id);
199        if !dir.exists() {
200            return Ok(Vec::new());
201        }
202        let mut out = Vec::new();
203        for entry in fs::read_dir(&dir)? {
204            let entry = entry?;
205            let path = entry.path();
206            if path.extension().and_then(|e| e.to_str()) != Some("json") {
207                continue;
208            }
209            let bytes = fs::read(&path)?;
210            let rule: Rule =
211                serde_json::from_slice(&bytes).map_err(|source| RuleCacheError::Parse {
212                    path: path.clone(),
213                    source,
214                })?;
215            out.push(rule);
216        }
217        Ok(out)
218    }
219
220    /// Looks up the best-matching rule for `node` under `target_id`.
221    ///
222    /// Matching is kind-equal against [`node_kind_name`]. Under
223    /// [`Strictness::Production`](bock_types::Strictness::Production)
224    /// only pinned rules are considered — per the §17.7 strictness
225    /// table. Highest priority wins; ties broken by pinned, then
226    /// most-recently-created.
227    ///
228    /// # Errors
229    /// Returns [`RuleCacheError`] on I/O or parse failure.
230    pub fn lookup(
231        &self,
232        target_id: &str,
233        node: &AIRNode,
234        production_only_pinned: bool,
235    ) -> Result<Option<Rule>, RuleCacheError> {
236        let kind = node_kind_name(&node.kind);
237        let rules = self.load_for_target(target_id)?;
238        let best = rules
239            .into_iter()
240            .filter(|r| r.node_kind == kind)
241            .filter(|r| !production_only_pinned || r.pinned)
242            .max_by(|a, b| {
243                a.priority
244                    .cmp(&b.priority)
245                    .then(a.pinned.cmp(&b.pinned))
246                    .then(a.created.cmp(&b.created))
247            });
248        Ok(best)
249    }
250}
251
252// ─── Node-kind discriminant name ─────────────────────────────────────────────
253
254/// Short discriminant name for a [`NodeKind`]. Kept in sync with the
255/// `NodeKind` variants; used as the cache key dimension for lookup.
256#[must_use]
257pub fn node_kind_name(kind: &NodeKind) -> &'static str {
258    match kind {
259        NodeKind::Module { .. } => "Module",
260        NodeKind::ImportDecl { .. } => "ImportDecl",
261        NodeKind::FnDecl { .. } => "FnDecl",
262        NodeKind::RecordDecl { .. } => "RecordDecl",
263        NodeKind::EnumDecl { .. } => "EnumDecl",
264        NodeKind::ClassDecl { .. } => "ClassDecl",
265        NodeKind::TraitDecl { .. } => "TraitDecl",
266        NodeKind::ImplBlock { .. } => "ImplBlock",
267        NodeKind::EffectDecl { .. } => "EffectDecl",
268        NodeKind::ConstDecl { .. } => "ConstDecl",
269        NodeKind::TypeAlias { .. } => "TypeAlias",
270        NodeKind::Param { .. } => "Param",
271        NodeKind::Block { .. } => "Block",
272        NodeKind::If { .. } => "If",
273        NodeKind::For { .. } => "For",
274        NodeKind::While { .. } => "While",
275        NodeKind::Loop { .. } => "Loop",
276        NodeKind::Match { .. } => "Match",
277        NodeKind::MatchArm { .. } => "MatchArm",
278        NodeKind::Guard { .. } => "Guard",
279        NodeKind::HandlingBlock { .. } => "HandlingBlock",
280        NodeKind::LetBinding { .. } => "LetBinding",
281        NodeKind::Return { .. } => "Return",
282        NodeKind::Break { .. } => "Break",
283        NodeKind::Assign { .. } => "Assign",
284        NodeKind::BinaryOp { .. } => "BinaryOp",
285        NodeKind::UnaryOp { .. } => "UnaryOp",
286        NodeKind::Call { .. } => "Call",
287        NodeKind::MethodCall { .. } => "MethodCall",
288        NodeKind::Lambda { .. } => "Lambda",
289        NodeKind::FieldAccess { .. } => "FieldAccess",
290        NodeKind::Index { .. } => "Index",
291        NodeKind::Pipe { .. } => "Pipe",
292        NodeKind::Compose { .. } => "Compose",
293        NodeKind::Await { .. } => "Await",
294        NodeKind::Propagate { .. } => "Propagate",
295        NodeKind::Move { .. } => "Move",
296        NodeKind::Borrow { .. } => "Borrow",
297        NodeKind::MutableBorrow { .. } => "MutableBorrow",
298        NodeKind::ListLiteral { .. } => "ListLiteral",
299        NodeKind::SetLiteral { .. } => "SetLiteral",
300        NodeKind::TupleLiteral { .. } => "TupleLiteral",
301        NodeKind::MapLiteral { .. } => "MapLiteral",
302        NodeKind::RecordConstruct { .. } => "RecordConstruct",
303        NodeKind::Range { .. } => "Range",
304        NodeKind::ResultConstruct { .. } => "ResultConstruct",
305        NodeKind::TypeNamed { .. } => "TypeNamed",
306        NodeKind::TypeTuple { .. } => "TypeTuple",
307        NodeKind::TypeFunction { .. } => "TypeFunction",
308        NodeKind::TypeOptional { .. } => "TypeOptional",
309        NodeKind::ModuleHandle { .. } => "ModuleHandle",
310        NodeKind::PropertyTest { .. } => "PropertyTest",
311        NodeKind::ConstructorPat { .. } => "ConstructorPat",
312        NodeKind::RecordPat { .. } => "RecordPat",
313        NodeKind::TuplePat { .. } => "TuplePat",
314        NodeKind::ListPat { .. } => "ListPat",
315        NodeKind::OrPat { .. } => "OrPat",
316        NodeKind::GuardPat { .. } => "GuardPat",
317        NodeKind::RangePat { .. } => "RangePat",
318        _ => "Other",
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325    use bock_air::{NodeIdGen, NodeKind};
326    use bock_errors::Span;
327
328    fn match_node() -> AIRNode {
329        let gen = NodeIdGen::new();
330        let scrutinee = AIRNode::new(
331            gen.next(),
332            Span::dummy(),
333            NodeKind::Block {
334                stmts: Vec::new(),
335                tail: None,
336            },
337        );
338        AIRNode::new(
339            gen.next(),
340            Span::dummy(),
341            NodeKind::Match {
342                scrutinee: Box::new(scrutinee),
343                arms: Vec::new(),
344            },
345        )
346    }
347
348    fn candidate() -> CandidateRule {
349        CandidateRule {
350            target_id: "js".into(),
351            pattern: "match on string scrutinee".into(),
352            template: "switch ({{ scrutinee }}) { {{ arms }} }".into(),
353            priority: 10,
354        }
355    }
356
357    #[test]
358    fn candidate_lifts_to_extracted_rule() {
359        let rule = Rule::from_candidate(&candidate(), "Match", 0.88);
360        assert_eq!(rule.provenance, Provenance::Extracted);
361        assert_eq!(rule.node_kind, "Match");
362        assert_eq!(rule.target_id, "js");
363        assert!(!rule.pinned);
364        assert!((rule.confidence - 0.88).abs() < f64::EPSILON);
365    }
366
367    #[test]
368    fn rule_id_is_stable_across_calls() {
369        let a = compute_rule_id("js", "Match", "switch x {}");
370        let b = compute_rule_id("js", "Match", "switch x {}");
371        assert_eq!(a, b);
372        let c = compute_rule_id("js", "Match", "switch y {}");
373        assert_ne!(a, c);
374    }
375
376    #[test]
377    fn insert_then_load() {
378        let dir = tempfile::tempdir().unwrap();
379        let cache = RuleCache::new(dir.path());
380        let rule = Rule::from_candidate(&candidate(), "Match", 0.9);
381        cache.insert(&rule).unwrap();
382
383        let loaded = cache.load_for_target("js").unwrap();
384        assert_eq!(loaded.len(), 1);
385        assert_eq!(loaded[0].id, rule.id);
386        assert_eq!(loaded[0].node_kind, "Match");
387    }
388
389    #[test]
390    fn lookup_matches_by_node_kind() {
391        let dir = tempfile::tempdir().unwrap();
392        let cache = RuleCache::new(dir.path());
393        let rule = Rule::from_candidate(&candidate(), "Match", 0.9);
394        cache.insert(&rule).unwrap();
395
396        let hit = cache.lookup("js", &match_node(), false).unwrap();
397        assert!(hit.is_some());
398        assert_eq!(hit.unwrap().node_kind, "Match");
399    }
400
401    #[test]
402    fn lookup_misses_on_different_kind() {
403        let dir = tempfile::tempdir().unwrap();
404        let cache = RuleCache::new(dir.path());
405        let rule = Rule::from_candidate(&candidate(), "Call", 0.9);
406        cache.insert(&rule).unwrap();
407
408        let hit = cache.lookup("js", &match_node(), false).unwrap();
409        assert!(hit.is_none());
410    }
411
412    #[test]
413    fn production_mode_ignores_unpinned_rules() {
414        let dir = tempfile::tempdir().unwrap();
415        let cache = RuleCache::new(dir.path());
416        let rule = Rule::from_candidate(&candidate(), "Match", 0.9);
417        cache.insert(&rule).unwrap();
418
419        assert!(cache.lookup("js", &match_node(), true).unwrap().is_none());
420
421        let mut pinned = rule.clone();
422        pinned.pinned = true;
423        pinned.id = format!("{}-pinned", rule.id);
424        cache.insert(&pinned).unwrap();
425
426        let hit = cache.lookup("js", &match_node(), true).unwrap().unwrap();
427        assert!(hit.pinned);
428    }
429
430    #[test]
431    fn lookup_misses_on_empty_directory() {
432        let dir = tempfile::tempdir().unwrap();
433        let cache = RuleCache::new(dir.path());
434        let hit = cache.lookup("js", &match_node(), false).unwrap();
435        assert!(hit.is_none());
436    }
437
438    #[test]
439    fn load_skips_non_json_files() {
440        let dir = tempfile::tempdir().unwrap();
441        let cache = RuleCache::new(dir.path());
442        fs::create_dir_all(cache.target_dir("js")).unwrap();
443        fs::write(cache.target_dir("js").join("junk.txt"), "not json").unwrap();
444        let rules = cache.load_for_target("js").unwrap();
445        assert!(rules.is_empty());
446    }
447
448    #[test]
449    fn priority_breaks_ties() {
450        let dir = tempfile::tempdir().unwrap();
451        let cache = RuleCache::new(dir.path());
452
453        let low = Rule {
454            id: "low".into(),
455            target_id: "js".into(),
456            node_kind: "Match".into(),
457            pattern: "low".into(),
458            template: "low()".into(),
459            provenance: Provenance::Extracted,
460            pinned: false,
461            confidence: 0.5,
462            priority: 1,
463            created: Utc::now(),
464        };
465        let high = Rule {
466            id: "high".into(),
467            priority: 99,
468            template: "high()".into(),
469            ..low.clone()
470        };
471        cache.insert(&low).unwrap();
472        cache.insert(&high).unwrap();
473
474        let hit = cache.lookup("js", &match_node(), false).unwrap().unwrap();
475        assert_eq!(hit.id, "high");
476    }
477
478    #[test]
479    fn node_kind_name_covers_common_variants() {
480        let gen = NodeIdGen::new();
481        let block = AIRNode::new(
482            gen.next(),
483            Span::dummy(),
484            NodeKind::Block {
485                stmts: Vec::new(),
486                tail: None,
487            },
488        );
489        assert_eq!(node_kind_name(&block.kind), "Block");
490    }
491}