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