Skip to main content

lean_ctx/core/context_kernel/
orchestrator.rs

1//! Candidate orchestration, scoring, and feedback artifacts for the context kernel.
2
3use std::collections::{HashMap, HashSet};
4
5use crate::core::context_compiler::{CompileCandidate, CompileMode, CompileResult, compile};
6use crate::core::context_field::{
7    ContextField, ContextKind, ContextState, FieldSignals, ViewCosts, ViewKind,
8    normalize_token_cost,
9};
10
11use super::types::{
12    CandidateProvider, ContextObjectKind, ContextObjectV1, ContextPlanV1, ContextReceiptV1,
13    ExcludedEntry, PlanBudget, PlanEntry, ProviderStat, QualitySignal, ReceiptOutcome,
14    RetrievalContext,
15};
16
17/// The Context Control Kernel — orchestrates candidate gathering, Phi scoring,
18/// budget-optimal selection, and plan/receipt generation.
19pub struct ContextKernel {
20    providers: Vec<Box<dyn CandidateProvider>>,
21    field: ContextField,
22}
23
24impl ContextKernel {
25    /// Create a new kernel with the given providers.
26    pub fn new(providers: Vec<Box<dyn CandidateProvider>>) -> Self {
27        Self {
28            providers,
29            field: ContextField::active(),
30        }
31    }
32
33    /// Create a kernel with default providers for a project.
34    pub fn for_project(project_root: &str) -> Self {
35        Self::new(super::providers::default_providers(project_root))
36    }
37
38    /// Register an additional provider.
39    pub fn register(&mut self, provider: Box<dyn CandidateProvider>) {
40        self.providers.push(provider);
41    }
42
43    /// Gather and content-deduplicate candidates from all registered providers.
44    pub fn gather(&self, ctx: &RetrievalContext) -> Vec<ContextObjectV1> {
45        let mut all = Vec::new();
46        for provider in &self.providers {
47            all.extend(provider.candidates(ctx));
48        }
49        dedup_by_content_ref(&mut all);
50        all
51    }
52
53    /// Score candidates, compile the best package under budget, and return its plan.
54    pub fn plan(&self, ctx: &RetrievalContext) -> ContextPlanV1 {
55        let candidates = self.gather(ctx);
56        let scored: Vec<_> = candidates
57            .into_iter()
58            .map(|object| {
59                let signals = signals_from_object(&object, ctx);
60                let phi = self.field.compute_phi(&signals);
61                (object, phi)
62            })
63            .collect();
64        let compile_candidates: Vec<_> = scored
65            .iter()
66            .map(|(object, phi)| to_compile_candidate(object, *phi))
67            .collect();
68        let result = compile(&compile_candidates, ctx.budget, CompileMode::HandleManifest);
69
70        build_plan(ctx, &scored, &result)
71    }
72
73    /// Record delivery outcome and provider-level feedback for a completed plan.
74    pub fn record_receipt(
75        &self,
76        plan: &ContextPlanV1,
77        delivered_tokens: usize,
78        outcome: ReceiptOutcome,
79    ) -> ContextReceiptV1 {
80        let outcome_value = outcome_value(outcome);
81        let total_phi: f64 = plan.selected.iter().map(|entry| entry.phi.max(0.0)).sum();
82        let mut feedback_attribution = HashMap::new();
83        if total_phi > 0.0 {
84            for entry in &plan.selected {
85                let contribution = entry.phi.max(0.0) / total_phi * outcome_value;
86                *feedback_attribution
87                    .entry(entry.provider.clone())
88                    .or_insert(0.0) += contribution;
89            }
90        }
91
92        let receipt_material = format!(
93            "{}|{}|{}",
94            plan.plan_id,
95            delivered_tokens,
96            receipt_outcome_name(outcome)
97        );
98        ContextReceiptV1 {
99            receipt_id: format!("receipt_{}", short_hash(&receipt_material)),
100            plan_id: plan.plan_id.clone(),
101            delivered_tokens,
102            cache_hits: 0,
103            cache_misses: 0,
104            outcome,
105            quality_signals: vec![QualitySignal {
106                signal_type: "outcome".to_string(),
107                value: outcome_value,
108            }],
109            feedback_attribution,
110        }
111    }
112}
113
114fn dedup_by_content_ref(objects: &mut Vec<ContextObjectV1>) {
115    let mut retained = HashMap::<String, usize>::new();
116    let mut deduplicated: Vec<ContextObjectV1> = Vec::with_capacity(objects.len());
117    for object in objects.drain(..) {
118        match retained.get(&object.content_ref).copied() {
119            Some(index) if object.confidence > deduplicated[index].confidence => {
120                deduplicated[index] = object;
121            }
122            Some(_) => {}
123            None => {
124                retained.insert(object.content_ref.clone(), deduplicated.len());
125                deduplicated.push(object);
126            }
127        }
128    }
129    *objects = deduplicated;
130}
131
132fn signals_from_object(object: &ContextObjectV1, ctx: &RetrievalContext) -> FieldSignals {
133    FieldSignals {
134        relevance: keyword_overlap(&object.title, object.content.as_deref(), &ctx.query),
135        surprise: 0.5,
136        graph_proximity: 0.5,
137        history_signal: object.confidence.clamp(0.0, 1.0) as f64,
138        token_cost_norm: normalize_token_cost(object.token_estimate, ctx.budget.total),
139        redundancy: 0.0,
140    }
141}
142
143fn keyword_overlap(title: &str, content: Option<&str>, query: &str) -> f64 {
144    let query_terms = terms(query);
145    if query_terms.is_empty() {
146        return 0.0;
147    }
148    let mut object_terms = terms(title);
149    if let Some(content) = content {
150        object_terms.extend(terms(content));
151    }
152    let overlap = query_terms.intersection(&object_terms).count();
153    overlap as f64 / query_terms.len() as f64
154}
155
156fn terms(text: &str) -> HashSet<String> {
157    text.split(|character: char| !character.is_alphanumeric())
158        .filter(|term| !term.is_empty())
159        .map(str::to_lowercase)
160        .collect()
161}
162
163fn to_compile_candidate(object: &ContextObjectV1, phi: f64) -> CompileCandidate {
164    let view_costs = if object.view_costs.estimates.is_empty() {
165        ViewCosts::from_full_tokens(object.token_estimate.max(1))
166    } else {
167        object.view_costs.clone()
168    };
169    let (selected_view, selected_tokens) = view_costs
170        .cheapest_content_view()
171        .unwrap_or((ViewKind::Full, object.token_estimate.max(1)));
172
173    CompileCandidate {
174        id: object.id.clone(),
175        kind: context_kind(object.kind),
176        path: object.content_ref.clone(),
177        state: if object.freshness.stale {
178            ContextState::Stale
179        } else {
180            ContextState::Candidate
181        },
182        phi,
183        view_costs,
184        selected_view,
185        selected_tokens,
186        pinned: false,
187        content_sketch: object
188            .semantic_fingerprint
189            .clone()
190            .or_else(|| Some(object.content_ref.clone())),
191    }
192}
193
194fn context_kind(kind: ContextObjectKind) -> ContextKind {
195    match kind {
196        ContextObjectKind::File => ContextKind::File,
197        ContextObjectKind::Fact => ContextKind::Knowledge,
198        ContextObjectKind::Episode
199        | ContextObjectKind::Procedure
200        | ContextObjectKind::SessionItem => ContextKind::Memory,
201        ContextObjectKind::SearchChunk => ContextKind::Provider,
202    }
203}
204
205fn build_plan(
206    ctx: &RetrievalContext,
207    scored: &[(ContextObjectV1, f64)],
208    result: &CompileResult,
209) -> ContextPlanV1 {
210    let objects: HashMap<_, _> = scored
211        .iter()
212        .map(|(object, phi)| (object.id.to_string(), (object, *phi)))
213        .collect();
214    let mut provider_stats = HashMap::new();
215    for (object, _) in scored {
216        provider_stats
217            .entry(object.source.clone())
218            .or_insert(ProviderStat {
219                candidates_offered: 0,
220                candidates_selected: 0,
221                tokens_used: 0,
222            })
223            .candidates_offered += 1;
224    }
225
226    let selected = result
227        .selected
228        .iter()
229        .map(|item| {
230            let (object, phi) = objects
231                .get(&item.id)
232                .copied()
233                .unwrap_or_else(|| panic!("compiler selected unknown candidate: {}", item.id));
234            let stat = provider_stats
235                .get_mut(&object.source)
236                .unwrap_or_else(|| panic!("missing provider statistics: {}", object.source));
237            stat.candidates_selected += 1;
238            stat.tokens_used = stat.tokens_used.saturating_add(item.tokens);
239            PlanEntry {
240                object_id: item.id.clone(),
241                provider: object.source.clone(),
242                view: item.view.clone(),
243                tokens: item.tokens,
244                phi,
245                reason: "selected by compiler".to_string(),
246            }
247        })
248        .collect();
249    let excluded = result
250        .excluded_reasons
251        .iter()
252        .map(|item| ExcludedEntry {
253            object_id: item.id.clone(),
254            provider: objects.get(&item.id).map_or_else(
255                || "unknown".to_string(),
256                |(object, _)| object.source.clone(),
257            ),
258            reason: item.reason.clone(),
259        })
260        .collect();
261    let plan_material = plan_material(ctx, scored, result);
262
263    ContextPlanV1 {
264        plan_id: format!("plan_{}", short_hash(&plan_material)),
265        intent: ctx.task.clone().unwrap_or_else(|| ctx.query.clone()),
266        budget: PlanBudget {
267            total_tokens: ctx.budget.total,
268            used_tokens: result.budget_used,
269            remaining_tokens: ctx.budget.total.saturating_sub(result.budget_used),
270        },
271        selected,
272        excluded,
273        deferred: Vec::new(),
274        provider_stats,
275    }
276}
277
278fn plan_material(
279    ctx: &RetrievalContext,
280    scored: &[(ContextObjectV1, f64)],
281    result: &CompileResult,
282) -> String {
283    let mut entries: Vec<_> = scored
284        .iter()
285        .map(|(object, phi)| format!("{}:{}:{phi:.12}", object.id, object.content_ref))
286        .collect();
287    entries.sort_unstable();
288    format!(
289        "{}|{}|{}|{}|{}",
290        ctx.query,
291        ctx.task.as_deref().unwrap_or_default(),
292        ctx.budget.total,
293        result.budget_used,
294        entries.join("|")
295    )
296}
297
298fn short_hash(value: &str) -> String {
299    blake3::hash(value.as_bytes()).to_hex()[..16].to_string()
300}
301
302fn outcome_value(outcome: ReceiptOutcome) -> f64 {
303    match outcome {
304        ReceiptOutcome::Accepted => 1.0,
305        ReceiptOutcome::Partial => 0.5,
306        ReceiptOutcome::Rejected | ReceiptOutcome::Unknown => 0.0,
307    }
308}
309
310fn receipt_outcome_name(outcome: ReceiptOutcome) -> &'static str {
311    match outcome {
312        ReceiptOutcome::Accepted => "accepted",
313        ReceiptOutcome::Rejected => "rejected",
314        ReceiptOutcome::Partial => "partial",
315        ReceiptOutcome::Unknown => "unknown",
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::super::types::{Freshness, SensitivityLevel, SideEffectPolicy};
322    use std::collections::HashMap;
323
324    use crate::core::context_field::{ContextItemId, Provenance, TokenBudget, ViewCosts};
325
326    use super::*;
327
328    struct MockProvider {
329        items: Vec<ContextObjectV1>,
330    }
331
332    impl CandidateProvider for MockProvider {
333        #[allow(clippy::unnecessary_literal_bound)]
334        fn provider_id(&self) -> &str {
335            "test.mock"
336        }
337
338        fn candidates(&self, _ctx: &RetrievalContext) -> Vec<ContextObjectV1> {
339            self.items.clone()
340        }
341
342        fn side_effect_policy(&self) -> SideEffectPolicy {
343            SideEffectPolicy::ReadOnly
344        }
345    }
346
347    fn context() -> RetrievalContext {
348        RetrievalContext {
349            query: "context kernel".to_string(),
350            task: Some("build kernel".to_string()),
351            project_root: "/project".to_string(),
352            budget: TokenBudget {
353                total: 200,
354                used: 0,
355            },
356            max_candidates: 10,
357        }
358    }
359
360    fn object(id: &str, content_ref: &str, confidence: f32) -> ContextObjectV1 {
361        ContextObjectV1 {
362            id: ContextItemId::from_provider("test.mock", id),
363            kind: ContextObjectKind::Fact,
364            source: "test.mock".to_string(),
365            content_ref: content_ref.to_string(),
366            title: "context kernel".to_string(),
367            content: Some("context kernel orchestration".to_string()),
368            freshness: Freshness {
369                created_at: "2026-01-01T00:00:00Z".to_string(),
370                ttl_secs: None,
371                stale: false,
372            },
373            confidence,
374            sensitivity: SensitivityLevel::Internal,
375            token_estimate: 50,
376            view_costs: ViewCosts::from_full_tokens(50),
377            provenance: Provenance::default(),
378            semantic_fingerprint: None,
379            metadata: HashMap::new(),
380        }
381    }
382
383    #[test]
384    fn empty_kernel_gathers_nothing() {
385        assert!(ContextKernel::new(Vec::new()).gather(&context()).is_empty());
386    }
387
388    #[test]
389    fn gather_keeps_highest_confidence_duplicate() {
390        let kernel = ContextKernel::new(vec![Box::new(MockProvider {
391            items: vec![object("one", "same", 0.2), object("two", "same", 0.9)],
392        })]);
393
394        let gathered = kernel.gather(&context());
395        assert_eq!(gathered.len(), 1);
396        assert_eq!(gathered[0].confidence, 0.9);
397    }
398
399    #[test]
400    fn object_signals_are_normalized() {
401        let signals = signals_from_object(&object("one", "reference", 0.8), &context());
402        for signal in [
403            signals.relevance,
404            signals.surprise,
405            signals.graph_proximity,
406            signals.history_signal,
407            signals.token_cost_norm,
408            signals.redundancy,
409        ] {
410            assert!((0.0..=1.0).contains(&signal));
411        }
412    }
413
414    #[test]
415    fn compiler_candidate_maps_object_fields() {
416        let source = object("one", "reference", 0.8);
417        let candidate = to_compile_candidate(&source, 0.75);
418        assert_eq!(candidate.id, source.id);
419        assert_eq!(candidate.kind, ContextKind::Knowledge);
420        assert_eq!(candidate.path, source.content_ref);
421        assert_eq!(candidate.phi, 0.75);
422    }
423
424    #[test]
425    fn empty_plan_preserves_budget() {
426        let plan = ContextKernel::new(Vec::new()).plan(&context());
427        assert!(plan.selected.is_empty());
428        assert_eq!(plan.budget.total_tokens, 200);
429        assert_eq!(plan.budget.used_tokens, 0);
430        assert_eq!(plan.budget.remaining_tokens, 200);
431    }
432
433    #[test]
434    fn plan_selects_high_phi_candidate() {
435        let low = object("low", "low", 0.1);
436        let mut high = object("high", "high", 1.0);
437        high.title = "context kernel context kernel".to_string();
438        let kernel = ContextKernel::new(vec![Box::new(MockProvider {
439            items: vec![low, high.clone()],
440        })]);
441
442        let plan = kernel.plan(&context());
443        assert!(
444            plan.selected
445                .iter()
446                .any(|entry| entry.object_id == high.id.to_string())
447        );
448    }
449
450    #[test]
451    fn receipt_references_plan() {
452        let plan = ContextKernel::new(Vec::new()).plan(&context());
453        let receipt =
454            ContextKernel::new(Vec::new()).record_receipt(&plan, 0, ReceiptOutcome::Accepted);
455        assert_eq!(receipt.plan_id, plan.plan_id);
456    }
457}