1use 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
17pub struct ContextKernel {
20 providers: Vec<Box<dyn CandidateProvider>>,
21 field: ContextField,
22}
23
24impl ContextKernel {
25 pub fn new(providers: Vec<Box<dyn CandidateProvider>>) -> Self {
27 Self {
28 providers,
29 field: ContextField::active(),
30 }
31 }
32
33 pub fn for_project(project_root: &str) -> Self {
35 Self::new(super::providers::default_providers(project_root))
36 }
37
38 pub fn register(&mut self, provider: Box<dyn CandidateProvider>) {
40 self.providers.push(provider);
41 }
42
43 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 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 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}