|
| 1 | +//! Strategy #18: StyleStrategy — the thinking-style planning substrate. |
| 2 | +//! |
| 3 | +//! Thinking styles are THE planning substrate (not recipes in isolation): a style |
| 4 | +//! carries both the *selection* (which way to think) and, via its τ (tau) address, |
| 5 | +//! the *executable* JIT path. This strategy wires the shipped contract substrate |
| 6 | +//! into the planner's default registry — mirroring the `mul::escalation` precedent |
| 7 | +//! (a thin planner module that `pub use`s the zero-dep contract + one adapter). |
| 8 | +//! |
| 9 | +//! ## The pipeline this attaches to (all shipped in `lance-graph-contract`) |
| 10 | +//! |
| 11 | +//! ```text |
| 12 | +//! ThinkingStyle ─cluster()─▶ StyleCluster ─▶ Mechanism ─▶ the recipes that fire |
| 13 | +//! │ tau() (recipe_kernels::Tactic) |
| 14 | +//! ▼ |
| 15 | +//! τ macro address ──▶ JitTemplate ──▶ KernelHandle (ExecTarget::Jit; jit.rs) |
| 16 | +//! ``` |
| 17 | +//! |
| 18 | +//! The **style selects the recipe** (by cluster→mechanism affinity), runs the |
| 19 | +//! selected `Tactic` kernels over a `ThoughtCtx` built from the `PlanContext` |
| 20 | +//! markers, and surfaces the style's τ address (the JIT entry point) on the result. |
| 21 | +//! `ExecTarget::Jit` = the τ→template→Cranelift→`KernelHandle` path; `ExecTarget::Elixir` |
| 22 | +//! = the interpreted `recipe_kernels` layer this slice exercises. |
| 23 | +//! |
| 24 | +//! ## Slice scope (D-MBX-A6-P3a) |
| 25 | +//! |
| 26 | +//! First cut: resolve the style → select + run its cluster's recipe kernels over a |
| 27 | +//! `ThoughtCtx` (the recipe substrate the planner did not consume before). The plan |
| 28 | +//! passes through unchanged — this wires the cognitive substrate, not plan semantics. |
| 29 | +//! Deferred: `Outcome`→`Candidate`/`KanbanMove` adapter, the JIT compile call, and the |
| 30 | +//! membrane commit path (see the D-MBX-COMPLETION-MAP / board). |
| 31 | +
|
| 32 | +use lance_graph_contract::recipe_kernels::{kernel, ThoughtCtx}; |
| 33 | +use lance_graph_contract::recipes::{Mechanism, Recipe, RECIPES}; |
| 34 | +use lance_graph_contract::thinking::{StyleCluster, ThinkingStyle}; |
| 35 | + |
| 36 | +use crate::ir::{Arena, LogicalOp}; |
| 37 | +use crate::traits::{PlanCapability, PlanContext, PlanInput, PlanStrategy}; |
| 38 | +use crate::PlanError; |
| 39 | + |
| 40 | +/// Default thinking style when the `PlanContext` carries no explicit style. |
| 41 | +/// |
| 42 | +/// `Analytical` (the Analytical cluster) is the conservative convergent default — |
| 43 | +/// it selects truth-aware/parallel recipes, never the divergent/randomizing ones. |
| 44 | +pub const DEFAULT_STYLE: ThinkingStyle = ThinkingStyle::Analytical; |
| 45 | + |
| 46 | +/// The thinking-style planning substrate strategy. |
| 47 | +#[derive(Debug, Default)] |
| 48 | +pub struct StyleStrategy; |
| 49 | + |
| 50 | +impl StyleStrategy { |
| 51 | + /// Map a behavioural cluster to the recipe [`Mechanism`] it preferentially fires. |
| 52 | + /// |
| 53 | + /// This is the **style → recipe selector** (the load-bearing link): a style does |
| 54 | + /// not name recipe ids, it names a *way of thinking*, and the cluster's mechanism |
| 55 | + /// chooses which of the 34 recipes are in-character. |
| 56 | + fn cluster_mechanism(cluster: StyleCluster) -> Mechanism { |
| 57 | + match cluster { |
| 58 | + // Analytical / Direct = convergent, truth-aware (deduce, revise, critique). |
| 59 | + StyleCluster::Analytical | StyleCluster::Direct => Mechanism::TruthAwareInference, |
| 60 | + // Creative / Exploratory = divergent (randomize, reframe, analogize). |
| 61 | + StyleCluster::Creative | StyleCluster::Exploratory => Mechanism::StructuralDivergence, |
| 62 | + // Empathic = parallel-independent perspective taking. |
| 63 | + StyleCluster::Empathic => Mechanism::ParallelIndependence, |
| 64 | + // Meta = the cross-cutting infrastructure tactics (meta-cognition, framing). |
| 65 | + StyleCluster::Meta => Mechanism::Infrastructure, |
| 66 | + } |
| 67 | + } |
| 68 | + |
| 69 | + /// The recipes a given style fires: those whose mechanism matches the style's |
| 70 | + /// cluster mechanism. (`by_mechanism` is a contract lookup; inlined here to keep |
| 71 | + /// the borrow `'static`.) |
| 72 | + fn recipes_for(style: ThinkingStyle) -> impl Iterator<Item = &'static Recipe> { |
| 73 | + let want = Self::cluster_mechanism(style.cluster()); |
| 74 | + RECIPES.iter().filter(move |r| r.mechanism == want) |
| 75 | + } |
| 76 | + |
| 77 | + /// Build the recipe substrate's [`ThoughtCtx`] from the available `PlanContext` |
| 78 | + /// markers. Today the planner exposes `free_will_modifier` (→ temperature) and the |
| 79 | + /// query feature richness (→ candidate seeds); richer markers (real sd / free-energy |
| 80 | + /// from the live cognitive cycle) wire in later. |
| 81 | + fn thought_ctx_from(ctx: &PlanContext) -> ThoughtCtx { |
| 82 | + // free_will_modifier ∈ ~[0,1+] biases explore↔exploit temperature. |
| 83 | + let mut tc = ThoughtCtx::new(vec![ctx.features.estimated_complexity as f32]); |
| 84 | + tc.temperature = (ctx.free_will_modifier as f32).clamp(0.0, 1.0); |
| 85 | + tc |
| 86 | + } |
| 87 | + |
| 88 | + /// Resolve the active thinking style from the context, or the default. |
| 89 | + /// |
| 90 | + /// `PlanContext.thinking_style` is an `Option<Vec<f64>>` style *vector* (the i4-32D |
| 91 | + /// style projection in f64 form); a present vector means "a style was selected |
| 92 | + /// upstream". First slice: presence → keep `DEFAULT_STYLE` (decoding the vector to a |
| 93 | + /// specific `ThinkingStyle` is the i4-32D argmax wiring, deferred). Absence → default. |
| 94 | + fn resolve_style(ctx: &PlanContext) -> ThinkingStyle { |
| 95 | + // DECISION (follow-up): decode ctx.thinking_style (i4-32D vec) → argmax ThinkingStyle. |
| 96 | + // First slice keeps DEFAULT_STYLE regardless; the selector machinery below is what |
| 97 | + // this slice proves out, not the vector decode. |
| 98 | + let _ = ctx.thinking_style.as_ref(); |
| 99 | + DEFAULT_STYLE |
| 100 | + } |
| 101 | +} |
| 102 | + |
| 103 | +impl PlanStrategy for StyleStrategy { |
| 104 | + fn name(&self) -> &str { |
| 105 | + "style_strategy" |
| 106 | + } |
| 107 | + |
| 108 | + fn capability(&self) -> PlanCapability { |
| 109 | + // Physicalize-phase: selects the cognitive substrate, does not gate the scan. |
| 110 | + PlanCapability::Extension |
| 111 | + } |
| 112 | + |
| 113 | + fn affinity(&self, _ctx: &PlanContext) -> f32 { |
| 114 | + // Low, always-eligible: the style substrate is a default cross-cutting layer, |
| 115 | + // not a dialect that wins/loses on keyword match. |
| 116 | + 0.3 |
| 117 | + } |
| 118 | + |
| 119 | + fn plan( |
| 120 | + &self, |
| 121 | + input: PlanInput, |
| 122 | + _arena: &mut Arena<LogicalOp>, |
| 123 | + ) -> Result<PlanInput, PlanError> { |
| 124 | + let style = Self::resolve_style(&input.context); |
| 125 | + let mut tc = Self::thought_ctx_from(&input.context); |
| 126 | + |
| 127 | + // Run the style-selected recipe kernels over the ThoughtCtx (the substrate the |
| 128 | + // planner did not consume before). `run` = gate + apply + clamp (contract-tested). |
| 129 | + for recipe in Self::recipes_for(style) { |
| 130 | + if let Some(k) = kernel(recipe.id) { |
| 131 | + let _outcome = k.run(&mut tc); |
| 132 | + } |
| 133 | + } |
| 134 | + |
| 135 | + // First slice: the recipe pass refines the ThoughtCtx (confidence/temperature); |
| 136 | + // the LogicalPlan passes through unchanged. Outcome→Candidate/KanbanMove + |
| 137 | + // the τ→JIT compile + membrane commit are deferred (D-MBX-A6-P3 follow-ups). |
| 138 | + Ok(input) |
| 139 | + } |
| 140 | +} |
| 141 | + |
| 142 | +#[cfg(test)] |
| 143 | +mod tests { |
| 144 | + use super::*; |
| 145 | + |
| 146 | + #[test] |
| 147 | + fn analytical_default_selects_truth_aware_recipes() { |
| 148 | + // DEFAULT_STYLE (Analytical) → TruthAwareInference mechanism. |
| 149 | + assert_eq!(DEFAULT_STYLE.cluster(), StyleCluster::Analytical); |
| 150 | + assert_eq!( |
| 151 | + StyleStrategy::cluster_mechanism(DEFAULT_STYLE.cluster()), |
| 152 | + Mechanism::TruthAwareInference |
| 153 | + ); |
| 154 | + // It selects a non-empty, in-character recipe set, and every selected recipe |
| 155 | + // genuinely carries that mechanism. |
| 156 | + let fired: Vec<_> = StyleStrategy::recipes_for(DEFAULT_STYLE).collect(); |
| 157 | + assert!(!fired.is_empty(), "Analytical must fire some recipes"); |
| 158 | + assert!(fired |
| 159 | + .iter() |
| 160 | + .all(|r| r.mechanism == Mechanism::TruthAwareInference)); |
| 161 | + } |
| 162 | + |
| 163 | + #[test] |
| 164 | + fn each_cluster_maps_to_a_mechanism_and_fires_recipes() { |
| 165 | + for style in ThinkingStyle::ALL { |
| 166 | + // tau() is the JIT address — every style has one (grounds ExecTarget::Jit). |
| 167 | + let _tau = style.tau(); |
| 168 | + let mech = StyleStrategy::cluster_mechanism(style.cluster()); |
| 169 | + // The selector is total: every cluster's mechanism exists in the catalogue. |
| 170 | + assert!( |
| 171 | + RECIPES.iter().any(|r| r.mechanism == mech), |
| 172 | + "cluster {:?} mechanism {:?} must match >=1 recipe", |
| 173 | + style.cluster(), |
| 174 | + mech |
| 175 | + ); |
| 176 | + } |
| 177 | + } |
| 178 | + |
| 179 | + #[test] |
| 180 | + fn plan_runs_style_recipes_without_error() { |
| 181 | + let s = StyleStrategy; |
| 182 | + let mut arena = Arena::new(); |
| 183 | + let input = PlanInput { |
| 184 | + plan: None, |
| 185 | + context: PlanContext { |
| 186 | + query: "MATCH (n:Person) RETURN n".into(), |
| 187 | + features: crate::traits::QueryFeatures::default(), |
| 188 | + free_will_modifier: 0.7, |
| 189 | + thinking_style: None, |
| 190 | + nars_hint: None, |
| 191 | + }, |
| 192 | + }; |
| 193 | + let out = s.plan(input, &mut arena); |
| 194 | + assert!( |
| 195 | + out.is_ok(), |
| 196 | + "style strategy plan() must pass through cleanly" |
| 197 | + ); |
| 198 | + } |
| 199 | +} |
0 commit comments