Skip to main content

bock_codegen/
ai_synthesis.rs

1//! Tier 1 AI synthesis — selective invocation, confidence gating, and
2//! decision recording (§17.2, §17.3, §17.4).
3//!
4//! The synthesis layer augments the rule-based Tier 2 backends with AI
5//! generation at capability-gap points (§17.6) and target-flagged
6//! constructs (`TargetProfile::ai_hints`). It is the infrastructure half
7//! of "AI-first with deterministic fallback":
8//!
9//! 1. Walk the AIR module and identify nodes that warrant AI synthesis.
10//!    Trivial constructs (literals, arithmetic, direct calls, …) are
11//!    classified as `None` by [`crate::profile::classify_node`] and
12//!    bypass AI entirely — per §17.2 (Q3 amended, 2026-04-20).
13//! 2. For each flagged node, call the provider's `generate` mode.
14//!    Confidence gates acceptance (default `0.75`); pinned cache replays
15//!    (§17.8) bypass the threshold.
16//! 3. Run the deterministic verifier (§17.3) on accepted output.
17//!    Verification lives in this crate — it never goes through the AI
18//!    provider.
19//! 4. Record the accepted choice as a build-scope decision (§17.4)
20//!    routed to `.bock/decisions/build/`.
21//! 5. On rejection, provider error, or verification failure, fall
22//!    through to Tier 2 rule-based generation (preserved guarantee).
23
24use std::path::{Path, PathBuf};
25use std::sync::{Arc, Mutex};
26
27use bock_air::{AIRNode, NodeKind};
28use bock_ai::{
29    compute_key, node_kind_name, AiCache, AiError, AiProvider, Decision, DecisionType,
30    GenerateRequest, GenerateResponse, ManifestWriter, ModuleContext, RuleCache,
31    StrictnessPolicy,
32};
33use bock_types::{AIRModule, Strictness};
34use chrono::Utc;
35
36use crate::profile::{classify_node, TargetProfile};
37
38// ─── Configuration ───────────────────────────────────────────────────────────
39
40/// Runtime knobs for a single AI-augmented module compilation.
41#[derive(Debug, Clone)]
42pub struct SynthesisConfig {
43    /// Minimum AI confidence for auto-acceptance (default `0.75`, §17.4).
44    pub confidence_threshold: f64,
45    /// Fall back to Tier 2 on provider error or low confidence.
46    pub deterministic_fallback: bool,
47    /// Graduated strictness level for the current compilation.
48    pub strictness: Strictness,
49    /// Auto-pin accepted decisions at `development` strictness.
50    pub auto_pin: bool,
51    /// Canonical module path written into each decision record.
52    pub module_path: PathBuf,
53}
54
55impl Default for SynthesisConfig {
56    fn default() -> Self {
57        Self {
58            confidence_threshold: 0.75,
59            deterministic_fallback: true,
60            strictness: Strictness::Development,
61            auto_pin: false,
62            module_path: PathBuf::new(),
63        }
64    }
65}
66
67// ─── Outcome ─────────────────────────────────────────────────────────────────
68
69/// Result of synthesizing a single flagged node.
70#[derive(Debug, Clone, PartialEq)]
71pub enum SynthesisOutcome {
72    /// AI produced code that cleared the confidence threshold (or was
73    /// replayed from the pinned cache) and passed verification.
74    Accepted {
75        /// The synthesized target code snippet.
76        code: String,
77        /// Confidence attached by the provider.
78        confidence: f64,
79        /// `true` when the response came from the content-addressed cache
80        /// — treated as pinned replay per §17.8 (bypasses threshold).
81        from_cache: bool,
82    },
83    /// A cached codegen rule (§17.7) matched this node's kind and was
84    /// applied deterministically — the AI was never called.
85    RuleApplied {
86        /// The code produced by applying the rule's template.
87        code: String,
88        /// Identifier of the rule in the local [`RuleCache`].
89        rule_id: String,
90        /// The [`bock_air::NodeKind`] discriminant the rule matched.
91        node_kind: String,
92        /// Confidence attached to the rule at extraction time.
93        confidence: f64,
94    },
95    /// AI produced code but confidence was below the threshold.
96    RejectedLowConfidence {
97        /// Confidence reported by the provider.
98        confidence: f64,
99    },
100    /// AI produced code but it failed the deterministic verifier (§17.3).
101    RejectedVerification {
102        /// The reason verification failed.
103        error: String,
104    },
105    /// Provider call failed (transport, auth, etc.). Tier 2 handles the node.
106    ProviderError {
107        /// The underlying AI error message.
108        message: String,
109    },
110    /// Production strictness required a pinned decision but none was
111    /// available. The caller decides whether to error or fall through.
112    ProductionUnpinned,
113}
114
115// ─── Stats ───────────────────────────────────────────────────────────────────
116
117/// Aggregate counters across a synthesis pass.
118#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
119pub struct SynthesisStats {
120    /// Total AIR nodes walked.
121    pub total_nodes: usize,
122    /// Nodes flagged by `classify_node` + `ai_hints`.
123    pub flagged_nodes: usize,
124    /// AI calls actually issued (flagged nodes when a provider was present).
125    pub ai_calls: usize,
126    /// Responses accepted (including pinned replay).
127    pub accepted: usize,
128    /// Accepted responses that came from the cache.
129    pub cache_hits: usize,
130    /// Rejected because confidence < threshold.
131    pub rejected_low_confidence: usize,
132    /// Rejected because verification (§17.3) failed.
133    pub rejected_verification: usize,
134    /// Provider returned an error.
135    pub provider_errors: usize,
136    /// Fallback to Tier 2 was triggered.
137    pub fallback_triggered: usize,
138    /// Production-strictness unpinned rejections.
139    pub production_unpinned: usize,
140    /// Flagged nodes served by the [`RuleCache`] before any AI call.
141    pub rule_applied: usize,
142}
143
144// ─── needs_ai_synthesis ──────────────────────────────────────────────────────
145
146/// Returns `true` only when the node is flagged by `target.ai_hints` and
147/// matches a non-trivial [`crate::profile::NodeKindHint`]. Trivial
148/// constructs (literals, arithmetic, direct calls, variable bindings)
149/// always return `false` — the Q3 guarantee from the 2026-04-20 spec
150/// amendment.
151#[must_use]
152pub fn needs_ai_synthesis(target: &TargetProfile, node: &AIRNode) -> bool {
153    let Some(hint) = classify_node(node) else {
154        return false;
155    };
156    target.ai_hints.contains(&hint)
157}
158
159// ─── Verification (§17.3) ────────────────────────────────────────────────────
160
161/// Deterministic, provider-free verification of generated target code.
162///
163/// Minimum bar for D.5: non-empty + balanced brackets outside string
164/// literals / line comments. Full per-target parser integration is future
165/// work — it would live in `TargetProfile` and invoke each target's
166/// toolchain when `bock build --verify` is on.
167///
168/// Python is indentation-sensitive and doesn't carry `{}`, so bracket
169/// balancing is skipped for that target; only the emptiness check runs.
170///
171/// # Errors
172/// Returns `Err(message)` with a human-readable reason when verification
173/// fails.
174pub fn verify_generated(target_id: &str, code: &str) -> Result<(), String> {
175    if code.trim().is_empty() {
176        return Err("generated code is empty".into());
177    }
178    if target_id == "python" || target_id == "py" {
179        return Ok(());
180    }
181    check_bracket_balance(code)
182}
183
184fn check_bracket_balance(code: &str) -> Result<(), String> {
185    let mut stack: Vec<char> = Vec::new();
186    let mut chars = code.chars().peekable();
187    while let Some(c) = chars.next() {
188        match c {
189            '"' => skip_until(&mut chars, '"'),
190            '\'' => skip_until(&mut chars, '\''),
191            '/' if chars.peek() == Some(&'/') => {
192                for next in chars.by_ref() {
193                    if next == '\n' {
194                        break;
195                    }
196                }
197            }
198            '(' | '[' | '{' => stack.push(c),
199            ')' => match stack.pop() {
200                Some('(') => {}
201                _ => return Err("unbalanced `)`".into()),
202            },
203            ']' => match stack.pop() {
204                Some('[') => {}
205                _ => return Err("unbalanced `]`".into()),
206            },
207            '}' => match stack.pop() {
208                Some('{') => {}
209                _ => return Err("unbalanced `}`".into()),
210            },
211            _ => {}
212        }
213    }
214    if !stack.is_empty() {
215        return Err(format!("unclosed `{}`", stack.last().unwrap()));
216    }
217    Ok(())
218}
219
220fn skip_until(chars: &mut std::iter::Peekable<std::str::Chars<'_>>, delim: char) {
221    while let Some(next) = chars.next() {
222        if next == '\\' {
223            chars.next();
224        } else if next == delim {
225            return;
226        }
227    }
228}
229
230// ─── Synthesis driver ────────────────────────────────────────────────────────
231
232/// Machinery driving a single-module AI synthesis pass.
233///
234/// Holds an optional provider (behind a trait object), an optional
235/// content-addressed response cache, an optional manifest writer, and
236/// the synthesis configuration. The driver is constructed once per
237/// build and reused across modules.
238pub struct AiSynthesisDriver {
239    provider: Option<Arc<dyn AiProvider>>,
240    cache: Option<AiCache>,
241    manifest: Option<Arc<Mutex<ManifestWriter>>>,
242    rule_cache: Option<RuleCache>,
243    config: SynthesisConfig,
244}
245
246impl AiSynthesisDriver {
247    /// Constructs a driver with no provider — every flagged node falls
248    /// through to Tier 2. Useful for `--deterministic` builds and for
249    /// projects that haven't configured an `[ai]` section.
250    #[must_use]
251    pub fn deterministic(config: SynthesisConfig) -> Self {
252        Self {
253            provider: None,
254            cache: None,
255            manifest: None,
256            rule_cache: None,
257            config,
258        }
259    }
260
261    /// Constructs a driver backed by `provider`, optionally with a
262    /// response cache and a manifest writer.
263    #[must_use]
264    pub fn new(
265        provider: Arc<dyn AiProvider>,
266        cache: Option<AiCache>,
267        manifest: Option<Arc<Mutex<ManifestWriter>>>,
268        config: SynthesisConfig,
269    ) -> Self {
270        Self {
271            provider: Some(provider),
272            cache,
273            manifest,
274            rule_cache: None,
275            config,
276        }
277    }
278
279    /// Attach a [`RuleCache`] consulted before any AI call (§17.7).
280    ///
281    /// On a rule hit the driver applies the template deterministically
282    /// and records a `RuleApplied` decision instead of calling the
283    /// provider — saving tokens on already-learned patterns. Intended
284    /// for builder-style composition with [`Self::new`] or
285    /// [`Self::deterministic`].
286    #[must_use]
287    pub fn with_rule_cache(mut self, rules: RuleCache) -> Self {
288        self.rule_cache = Some(rules);
289        self
290    }
291
292    /// Access the configured rule cache, if any.
293    #[must_use]
294    pub fn rule_cache(&self) -> Option<&RuleCache> {
295        self.rule_cache.as_ref()
296    }
297
298    /// Access the configured manifest writer, if any.
299    #[must_use]
300    pub fn manifest(&self) -> Option<&Arc<Mutex<ManifestWriter>>> {
301        self.manifest.as_ref()
302    }
303
304    /// Borrow the active config (for diagnostics / tests).
305    #[must_use]
306    pub fn config(&self) -> &SynthesisConfig {
307        &self.config
308    }
309
310    /// Runs a full synthesis pass over `module`, respecting the
311    /// target profile's `ai_hints` and the driver's configuration.
312    ///
313    /// # Errors
314    /// Only returns an error for manifest I/O failures. Every other
315    /// failure is recorded in [`SynthesisStats`] so the caller can
316    /// continue to Tier 2 rule-based generation.
317    pub async fn synthesize_module(
318        &self,
319        module: &AIRModule,
320        target: &TargetProfile,
321        ctx: &ModuleContext,
322    ) -> Result<SynthesisStats, bock_ai::ManifestError> {
323        let mut stats = SynthesisStats::default();
324
325        // Short path: no provider → deterministic only.
326        if self.provider.is_none() {
327            walk_module(module, &mut |n| {
328                stats.total_nodes += 1;
329                if needs_ai_synthesis(target, n) {
330                    stats.flagged_nodes += 1;
331                    stats.fallback_triggered += 1;
332                }
333            });
334            return Ok(stats);
335        }
336
337        // Collect flagged nodes first so we can drive async calls
338        // sequentially (cache hits → determinism), then count totals.
339        let mut flagged: Vec<AIRNode> = Vec::new();
340        walk_module(module, &mut |n| {
341            stats.total_nodes += 1;
342            if needs_ai_synthesis(target, n) {
343                stats.flagged_nodes += 1;
344                flagged.push(n.clone());
345            }
346        });
347
348        for node in &flagged {
349            let outcome = self.synthesize_one(node, target, ctx).await;
350            self.account_outcome(&outcome, &mut stats);
351            match &outcome {
352                SynthesisOutcome::Accepted {
353                    code,
354                    confidence,
355                    from_cache,
356                } => {
357                    self.record_decision(node, target, code, *confidence, *from_cache)?;
358                }
359                SynthesisOutcome::RuleApplied {
360                    code,
361                    rule_id,
362                    node_kind,
363                    confidence,
364                } => {
365                    self.record_rule_applied(
366                        node, target, code, rule_id, node_kind, *confidence,
367                    )?;
368                }
369                _ => {}
370            }
371        }
372
373        Ok(stats)
374    }
375
376    fn account_outcome(&self, outcome: &SynthesisOutcome, stats: &mut SynthesisStats) {
377        match outcome {
378            SynthesisOutcome::RuleApplied { .. } => {
379                stats.rule_applied += 1;
380            }
381            SynthesisOutcome::Accepted {
382                from_cache: true, ..
383            } => {
384                stats.ai_calls += 1;
385                stats.accepted += 1;
386                stats.cache_hits += 1;
387            }
388            SynthesisOutcome::Accepted { .. } => {
389                stats.ai_calls += 1;
390                stats.accepted += 1;
391            }
392            SynthesisOutcome::RejectedLowConfidence { .. } => {
393                stats.ai_calls += 1;
394                stats.rejected_low_confidence += 1;
395                stats.fallback_triggered += 1;
396            }
397            SynthesisOutcome::RejectedVerification { .. } => {
398                stats.ai_calls += 1;
399                stats.rejected_verification += 1;
400                stats.fallback_triggered += 1;
401            }
402            SynthesisOutcome::ProviderError { .. } => {
403                stats.ai_calls += 1;
404                stats.provider_errors += 1;
405                if self.config.deterministic_fallback {
406                    stats.fallback_triggered += 1;
407                }
408            }
409            SynthesisOutcome::ProductionUnpinned => {
410                stats.production_unpinned += 1;
411                if self.config.deterministic_fallback {
412                    stats.fallback_triggered += 1;
413                }
414            }
415        }
416    }
417
418    async fn synthesize_one(
419        &self,
420        node: &AIRNode,
421        target: &TargetProfile,
422        ctx: &ModuleContext,
423    ) -> SynthesisOutcome {
424        // Per §17.7, try the local rule cache *before* any AI call so
425        // already-learned patterns don't spend tokens. Lookup errors
426        // are non-fatal: we fall through to Tier 1 on miss or I/O
427        // error, preserving D.5's guarantee that the AI path is always
428        // reachable for the caller.
429        if let Some(rule) = self.lookup_rule(node, target) {
430            return rule;
431        }
432
433        let request = build_request(node, target, ctx, self.config.strictness);
434        let (response, from_cache) = match self.call_generate(&request).await {
435            Ok(Some(pair)) => pair,
436            Ok(None) => {
437                // Production strictness + cache miss: provider was never
438                // consulted (see `call_generate`). Surface as a distinct
439                // outcome so the caller can fall back to Tier 2.
440                return SynthesisOutcome::ProductionUnpinned;
441            }
442            Err(e) => {
443                return SynthesisOutcome::ProviderError {
444                    message: format!("{e}"),
445                };
446            }
447        };
448
449        let accept = from_cache || response.confidence >= self.config.confidence_threshold;
450        if !accept {
451            return SynthesisOutcome::RejectedLowConfidence {
452                confidence: response.confidence,
453            };
454        }
455
456        if let Err(err) = verify_generated(&target.id, &response.code) {
457            return SynthesisOutcome::RejectedVerification { error: err };
458        }
459
460        SynthesisOutcome::Accepted {
461            code: response.code,
462            confidence: response.confidence,
463            from_cache,
464        }
465    }
466
467    fn lookup_rule(&self, node: &AIRNode, target: &TargetProfile) -> Option<SynthesisOutcome> {
468        let cache = self.rule_cache.as_ref()?;
469        let production_only = matches!(self.config.strictness, Strictness::Production);
470        let rule = cache.lookup(&target.id, node, production_only).ok().flatten()?;
471        Some(SynthesisOutcome::RuleApplied {
472            code: rule.template.clone(),
473            rule_id: rule.id.clone(),
474            node_kind: rule.node_kind.clone(),
475            confidence: rule.confidence,
476        })
477    }
478
479    async fn call_generate(
480        &self,
481        request: &GenerateRequest,
482    ) -> Result<Option<(GenerateResponse, bool)>, AiError> {
483        let provider = self
484            .provider
485            .as_ref()
486            .ok_or_else(|| AiError::Unavailable("no provider configured".into()))?;
487
488        // Cache lookup — canonical key over the request + model id.
489        // Cache reads are always allowed; the governance gate only
490        // blocks *new* AI calls.
491        let cache_key = self.build_cache_key(provider.model_id(), request);
492        if let Some(cache) = &self.cache {
493            if let Some(resp) = cache.get::<_, GenerateResponse>(&cache_key) {
494                return Ok(Some((resp, true)));
495            }
496        }
497
498        // Governance (§17.6): production strictness forbids fresh AI
499        // calls at build time. Return `None` so the caller falls back
500        // to Tier 2 via `SynthesisOutcome::ProductionUnpinned` without
501        // ever touching the provider.
502        let policy = StrictnessPolicy::for_level(self.config.strictness);
503        if !policy.allow_build_ai {
504            return Ok(None);
505        }
506
507        let resp = provider.generate(request).await?;
508        if let Some(cache) = &self.cache {
509            let _ = cache.put(&cache_key, &resp);
510        }
511        Ok(Some((resp, false)))
512    }
513
514    fn build_cache_key(&self, model_id: String, request: &GenerateRequest) -> CacheKey {
515        let prior: Vec<(String, String)> = request
516            .prior_decisions
517            .iter()
518            .map(|d| (d.decision.clone(), d.choice.clone()))
519            .collect();
520        // Strictness is intentionally NOT part of the key — the cache
521        // captures the AI's *decision* (what code to emit), not the
522        // acceptance policy. A decision pinned under `development`
523        // replays identically under `production`. See §17.8.
524        CacheKey {
525            mode: "generate",
526            model_id,
527            target_id: request.target.id.clone(),
528            module_path: request.module_context.module_path.clone(),
529            imports: request.module_context.imports.clone(),
530            siblings: request.module_context.siblings.clone(),
531            annotations: request.module_context.annotations.clone(),
532            prior_decisions: prior,
533            node_debug: format!("{:?}", request.node),
534        }
535    }
536
537    fn record_rule_applied(
538        &self,
539        node: &AIRNode,
540        target: &TargetProfile,
541        _code: &str,
542        rule_id: &str,
543        rule_kind: &str,
544        confidence: f64,
545    ) -> Result<(), bock_ai::ManifestError> {
546        let Some(manifest) = &self.manifest else {
547            return Ok(());
548        };
549        let mut mw = manifest
550            .lock()
551            .expect("manifest writer mutex poisoned");
552
553        let model_id = self
554            .provider
555            .as_ref()
556            .map_or_else(|| "deterministic".into(), |p| p.model_id());
557        let id = rule_decision_id(node, target, rule_id);
558        mw.record(Decision {
559            id,
560            module: self.config.module_path.clone(),
561            target: Some(target.id.clone()),
562            decision_type: DecisionType::RuleApplied,
563            choice: format!("rule {rule_id} matched pattern {rule_kind}"),
564            alternatives: Vec::new(),
565            reasoning: Some(format!(
566                "local rule cache hit for {rule_kind}; no AI call issued"
567            )),
568            model_id,
569            confidence,
570            pinned: true,
571            pin_reason: Some("rule-applied".into()),
572            pinned_at: Some(Utc::now()),
573            pinned_by: Some("rule-cache".into()),
574            superseded_by: None,
575            timestamp: Utc::now(),
576        });
577        Ok(())
578    }
579
580    fn record_decision(
581        &self,
582        node: &AIRNode,
583        target: &TargetProfile,
584        code: &str,
585        confidence: f64,
586        from_cache: bool,
587    ) -> Result<(), bock_ai::ManifestError> {
588        let Some(manifest) = &self.manifest else {
589            return Ok(());
590        };
591        let mut mw = manifest
592            .lock()
593            .expect("manifest writer mutex poisoned");
594
595        let id = decision_id(node, target);
596        let policy = StrictnessPolicy::for_level(self.config.strictness);
597        // Pinning sources (§17.6, §17.8):
598        //   1. Cache hits are pinned replays.
599        //   2. Production governance forces every fresh decision to pinned.
600        //   3. Development respects the per-project `auto_pin` toggle.
601        //   4. Sketch records fresh decisions unpinned.
602        let pinned = from_cache
603            || policy.auto_pin_default
604            || (matches!(self.config.strictness, Strictness::Development) && self.config.auto_pin);
605        let pin_reason = if from_cache {
606            Some("cache-replay".into())
607        } else if policy.auto_pin_default {
608            Some("production-auto".into())
609        } else if pinned {
610            Some("auto-pin".into())
611        } else {
612            None
613        };
614
615        let model_id = self
616            .provider
617            .as_ref()
618            .map_or_else(|| "deterministic".into(), |p| p.model_id());
619
620        mw.record(Decision {
621            id,
622            module: self.config.module_path.clone(),
623            target: Some(target.id.clone()),
624            decision_type: DecisionType::Codegen,
625            choice: code.into(),
626            alternatives: Vec::new(),
627            reasoning: None,
628            model_id,
629            confidence,
630            pinned,
631            pin_reason,
632            pinned_at: pinned.then(Utc::now),
633            pinned_by: pinned.then(|| "auto".into()),
634            superseded_by: None,
635            timestamp: Utc::now(),
636        });
637        Ok(())
638    }
639}
640
641/// Drives synthesis once per module and flushes the manifest writer.
642///
643/// Convenience for tests and build pipelines that want the manifest
644/// flushed at the end of each module.
645///
646/// # Errors
647/// Returns any manifest I/O error surfaced by [`ManifestWriter::flush`].
648pub async fn synthesize_and_flush(
649    driver: &AiSynthesisDriver,
650    module: &AIRModule,
651    target: &TargetProfile,
652    ctx: &ModuleContext,
653) -> Result<SynthesisStats, bock_ai::ManifestError> {
654    let stats = driver.synthesize_module(module, target, ctx).await?;
655    if let Some(m) = driver.manifest() {
656        let mut guard = m.lock().expect("manifest writer mutex poisoned");
657        guard.flush()?;
658    }
659    Ok(stats)
660}
661
662// ─── Cache key ───────────────────────────────────────────────────────────────
663
664#[derive(serde::Serialize)]
665struct CacheKey {
666    mode: &'static str,
667    model_id: String,
668    target_id: String,
669    module_path: String,
670    imports: Vec<String>,
671    siblings: Vec<String>,
672    annotations: Vec<String>,
673    prior_decisions: Vec<(String, String)>,
674    node_debug: String,
675}
676
677// ─── AIR walker ──────────────────────────────────────────────────────────────
678
679/// Visits every AIR node in the module in deterministic pre-order.
680fn walk_module<F: FnMut(&AIRNode)>(module: &AIRModule, f: &mut F) {
681    walk_node(module, f);
682}
683
684fn walk_node<F: FnMut(&AIRNode)>(node: &AIRNode, f: &mut F) {
685    f(node);
686    match &node.kind {
687        NodeKind::Module { imports, items, .. } => {
688            for n in imports {
689                walk_node(n, f);
690            }
691            for n in items {
692                walk_node(n, f);
693            }
694        }
695        NodeKind::FnDecl {
696            params,
697            return_type,
698            body,
699            ..
700        } => {
701            for p in params {
702                walk_node(p, f);
703            }
704            if let Some(rt) = return_type {
705                walk_node(rt, f);
706            }
707            walk_node(body, f);
708        }
709        NodeKind::ClassDecl { methods, .. } => {
710            for m in methods {
711                walk_node(m, f);
712            }
713        }
714        NodeKind::TraitDecl { methods, .. } => {
715            for m in methods {
716                walk_node(m, f);
717            }
718        }
719        NodeKind::ImplBlock { methods, .. } => {
720            for m in methods {
721                walk_node(m, f);
722            }
723        }
724        NodeKind::EnumDecl { variants, .. } => {
725            for v in variants {
726                walk_node(v, f);
727            }
728        }
729        NodeKind::EffectDecl { operations, .. } => {
730            for op in operations {
731                walk_node(op, f);
732            }
733        }
734        NodeKind::Block { stmts, tail } => {
735            for s in stmts {
736                walk_node(s, f);
737            }
738            if let Some(t) = tail {
739                walk_node(t, f);
740            }
741        }
742        NodeKind::If {
743            condition,
744            then_block,
745            else_block,
746            ..
747        } => {
748            walk_node(condition, f);
749            walk_node(then_block, f);
750            if let Some(e) = else_block {
751                walk_node(e, f);
752            }
753        }
754        NodeKind::For {
755            pattern,
756            iterable,
757            body,
758        } => {
759            walk_node(pattern, f);
760            walk_node(iterable, f);
761            walk_node(body, f);
762        }
763        NodeKind::While { condition, body } => {
764            walk_node(condition, f);
765            walk_node(body, f);
766        }
767        NodeKind::Loop { body } => walk_node(body, f),
768        NodeKind::LetBinding {
769            pattern, value, ty, ..
770        } => {
771            walk_node(pattern, f);
772            walk_node(value, f);
773            if let Some(t) = ty {
774                walk_node(t, f);
775            }
776        }
777        NodeKind::Match { scrutinee, arms } => {
778            walk_node(scrutinee, f);
779            for a in arms {
780                walk_node(a, f);
781            }
782        }
783        NodeKind::MatchArm {
784            pattern,
785            guard,
786            body,
787        } => {
788            walk_node(pattern, f);
789            if let Some(g) = guard {
790                walk_node(g, f);
791            }
792            walk_node(body, f);
793        }
794        NodeKind::HandlingBlock { body, .. } => walk_node(body, f),
795        NodeKind::BinaryOp { left, right, .. } => {
796            walk_node(left, f);
797            walk_node(right, f);
798        }
799        NodeKind::UnaryOp { operand, .. } => walk_node(operand, f),
800        NodeKind::Call { callee, args, .. } => {
801            walk_node(callee, f);
802            for a in args {
803                walk_node(&a.value, f);
804            }
805        }
806        NodeKind::MethodCall { receiver, args, .. } => {
807            walk_node(receiver, f);
808            for a in args {
809                walk_node(&a.value, f);
810            }
811        }
812        NodeKind::Lambda { params, body } => {
813            for p in params {
814                walk_node(p, f);
815            }
816            walk_node(body, f);
817        }
818        NodeKind::Return { value } | NodeKind::Break { value } => {
819            if let Some(v) = value {
820                walk_node(v, f);
821            }
822        }
823        NodeKind::Assign { target, value, .. } => {
824            walk_node(target, f);
825            walk_node(value, f);
826        }
827        NodeKind::FieldAccess { object, .. } => walk_node(object, f),
828        NodeKind::Index { object, index } => {
829            walk_node(object, f);
830            walk_node(index, f);
831        }
832        NodeKind::Pipe { left, right } | NodeKind::Compose { left, right } => {
833            walk_node(left, f);
834            walk_node(right, f);
835        }
836        NodeKind::Await { expr } | NodeKind::Propagate { expr } => walk_node(expr, f),
837        NodeKind::Move { expr } | NodeKind::Borrow { expr } | NodeKind::MutableBorrow { expr } => {
838            walk_node(expr, f);
839        }
840        NodeKind::Guard {
841            let_pattern,
842            condition,
843            else_block,
844        } => {
845            if let Some(p) = let_pattern {
846                walk_node(p, f);
847            }
848            walk_node(condition, f);
849            walk_node(else_block, f);
850        }
851        NodeKind::Param {
852            pattern,
853            ty,
854            default,
855        } => {
856            walk_node(pattern, f);
857            if let Some(t) = ty {
858                walk_node(t, f);
859            }
860            if let Some(d) = default {
861                walk_node(d, f);
862            }
863        }
864        NodeKind::ListLiteral { elems }
865        | NodeKind::SetLiteral { elems }
866        | NodeKind::TupleLiteral { elems } => {
867            for e in elems {
868                walk_node(e, f);
869            }
870        }
871        NodeKind::MapLiteral { entries } => {
872            for e in entries {
873                walk_node(&e.key, f);
874                walk_node(&e.value, f);
875            }
876        }
877        NodeKind::RecordConstruct { fields, spread, .. } => {
878            for fld in fields {
879                if let Some(v) = &fld.value {
880                    walk_node(v, f);
881                }
882            }
883            if let Some(s) = spread {
884                walk_node(s, f);
885            }
886        }
887        NodeKind::Range { lo, hi, .. } => {
888            walk_node(lo, f);
889            walk_node(hi, f);
890        }
891        NodeKind::ResultConstruct { value: Some(v), .. } => walk_node(v, f),
892        NodeKind::TypeNamed { args, .. } => {
893            for a in args {
894                walk_node(a, f);
895            }
896        }
897        NodeKind::TypeTuple { elems } => {
898            for e in elems {
899                walk_node(e, f);
900            }
901        }
902        NodeKind::TypeFunction { params, ret, .. } => {
903            for p in params {
904                walk_node(p, f);
905            }
906            walk_node(ret, f);
907        }
908        NodeKind::TypeOptional { inner } => walk_node(inner, f),
909        NodeKind::TypeAlias { ty, .. } => walk_node(ty, f),
910        NodeKind::ConstDecl { ty, value, .. } => {
911            walk_node(ty, f);
912            walk_node(value, f);
913        }
914        NodeKind::ModuleHandle { handler, .. } => walk_node(handler, f),
915        NodeKind::PropertyTest { body, .. } => walk_node(body, f),
916        NodeKind::ConstructorPat { fields, .. } => {
917            for fld in fields {
918                walk_node(fld, f);
919            }
920        }
921        NodeKind::RecordPat { fields, .. } => {
922            for fld in fields {
923                if let Some(p) = &fld.pattern {
924                    walk_node(p, f);
925                }
926            }
927        }
928        NodeKind::TuplePat { elems } => {
929            for e in elems {
930                walk_node(e, f);
931            }
932        }
933        NodeKind::ListPat { elems, rest } => {
934            for e in elems {
935                walk_node(e, f);
936            }
937            if let Some(r) = rest {
938                walk_node(r, f);
939            }
940        }
941        NodeKind::OrPat { alternatives } => {
942            for a in alternatives {
943                walk_node(a, f);
944            }
945        }
946        NodeKind::GuardPat { pattern, guard } => {
947            walk_node(pattern, f);
948            walk_node(guard, f);
949        }
950        NodeKind::RangePat { lo, hi, .. } => {
951            walk_node(lo, f);
952            walk_node(hi, f);
953        }
954        _ => {}
955    }
956}
957
958// ─── Helpers ─────────────────────────────────────────────────────────────────
959
960fn build_request(
961    node: &AIRNode,
962    target: &TargetProfile,
963    ctx: &ModuleContext,
964    strictness: Strictness,
965) -> GenerateRequest {
966    GenerateRequest {
967        node: node.clone(),
968        target: flatten_profile(target),
969        module_context: ctx.clone(),
970        prior_decisions: Vec::new(),
971        strictness,
972    }
973}
974
975fn flatten_profile(target: &TargetProfile) -> bock_ai::TargetProfile {
976    use std::collections::HashMap;
977    let mut capabilities = HashMap::new();
978    capabilities.insert(
979        "memory_model".into(),
980        format!("{}", target.capabilities.memory_model),
981    );
982    capabilities.insert(
983        "async_model".into(),
984        format!("{}", target.capabilities.async_model),
985    );
986    capabilities.insert(
987        "generics".into(),
988        format!("{}", target.capabilities.generics),
989    );
990    capabilities.insert(
991        "pattern_matching".into(),
992        format!("{}", target.capabilities.pattern_matching),
993    );
994    capabilities.insert(
995        "algebraic_types".into(),
996        format!("{}", target.capabilities.algebraic_types),
997    );
998    capabilities.insert(
999        "string_interpolation".into(),
1000        format!("{}", target.capabilities.string_interpolation),
1001    );
1002    capabilities.insert("traits".into(), format!("{}", target.capabilities.traits));
1003    let mut conventions = HashMap::new();
1004    conventions.insert("naming".into(), format!("{}", target.conventions.naming));
1005    conventions.insert(
1006        "error_handling".into(),
1007        format!("{}", target.conventions.error_handling),
1008    );
1009    conventions.insert(
1010        "file_extension".into(),
1011        target.conventions.file_extension.clone(),
1012    );
1013    bock_ai::TargetProfile {
1014        id: target.id.clone(),
1015        display_name: target.display_name.clone(),
1016        capabilities,
1017        conventions,
1018    }
1019}
1020
1021/// Decision id — stable hash of (target, node debug). Keeps manifest
1022/// lookups aligned with the content-addressed cache.
1023fn decision_id(node: &AIRNode, target: &TargetProfile) -> String {
1024    #[derive(serde::Serialize)]
1025    struct Keyed<'a> {
1026        target: &'a str,
1027        node_debug: String,
1028    }
1029    let keyed = Keyed {
1030        target: &target.id,
1031        node_debug: format!("{node:?}"),
1032    };
1033    compute_key(&keyed).unwrap_or_else(|_| format!("{:x}", node.id))
1034}
1035
1036/// Decision id for a rule-applied entry — discriminated by rule id so
1037/// it never collides with a codegen decision for the same node.
1038fn rule_decision_id(node: &AIRNode, target: &TargetProfile, rule_id: &str) -> String {
1039    #[derive(serde::Serialize)]
1040    struct Keyed<'a> {
1041        kind: &'static str,
1042        target: &'a str,
1043        rule_id: &'a str,
1044        node_kind: &'a str,
1045        node_id: u32,
1046    }
1047    let keyed = Keyed {
1048        kind: "rule_applied",
1049        target: &target.id,
1050        rule_id,
1051        node_kind: node_kind_name(&node.kind),
1052        node_id: node.id,
1053    };
1054    compute_key(&keyed).unwrap_or_else(|_| format!("rule-{rule_id}-{:x}", node.id))
1055}
1056
1057/// Convenience for callers that want to build a cache rooted at the
1058/// project directory without importing `bock_ai::AiCache`.
1059#[must_use]
1060pub fn cache_at(project_root: &Path) -> AiCache {
1061    AiCache::new(project_root)
1062}