1use crate::TrustTier;
19use crate::context::{ContextBudget, ContextLayer, ContextResult, LayerKind};
20use crate::knowledge::{BeliefStatus, EntityId, StatementId};
21use oxibrain_ports::TokenizerPort;
22use serde::{Deserialize, Serialize};
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct ProfileFact {
30 pub subject: EntityId,
31 pub canonical_key: String,
32 pub predicate: String,
33 pub text: String,
34 pub valid_from: i64,
35 pub valid_to: i64,
36 pub confidence: f32,
37 pub trust: TrustTier,
38 pub sources: Vec<String>,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct RenderedBelief {
47 pub statement_id: StatementId,
48 pub subject: EntityId,
49 pub subject_canonical_key: String,
50 pub predicate: String,
51 pub object: String,
52 pub valid_from: i64,
53 pub valid_to: i64,
54 pub confidence: f32,
55 pub status: BeliefStatus,
56 pub support_episodes: u32,
57 pub sources: Vec<String>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct RenderedEdge {
63 pub from: EntityId,
64 pub to: EntityId,
65 pub predicate: String,
66 pub statement_id: StatementId,
67 pub confidence: f32,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct EpisodeExcerpt {
73 pub episode_id: String,
74 pub content: String,
75 pub ingested_at: i64,
76 pub salience: f64,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct SummaryWithUncertainty {
82 pub summary_id: String,
83 pub text: String,
84 pub confidence: f32,
85 pub sources: Vec<String>,
86 #[serde(default)]
89 pub uncertainty: Option<crate::uncertainty::Uncertainty>,
90}
91
92#[derive(Debug, Clone, Default, Serialize, Deserialize)]
94pub struct ContextInput {
95 pub profile: Vec<ProfileFact>,
96 pub beliefs: Vec<RenderedBelief>,
97 pub neighborhood: Vec<RenderedEdge>,
98 pub episodes: Vec<EpisodeExcerpt>,
99 pub summaries: Vec<SummaryWithUncertainty>,
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
106#[serde(rename_all = "snake_case")]
107pub enum BeliefForm {
108 #[default]
110 OneLine,
111 WithValidity,
113 WithProvenance,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
121#[serde(rename_all = "snake_case")]
122pub struct Reserve {
123 pub profile_tokens: usize,
124 pub pinned_tokens: usize,
125 pub beliefs_tokens: usize,
126 pub neighborhood_tokens: usize,
127 pub summaries_tokens: usize,
128 pub episodes_tokens: usize,
129}
130
131impl Reserve {
132 pub fn defaults_for_budget(budget: usize) -> Self {
137 let budget = budget.max(1);
138 let profile = (budget / 15).max(50);
139 let pinned = budget / 20;
140 let beliefs = budget / 3;
141 let neighborhood = budget / 8;
142 let summaries = budget / 10;
143 let episodes = budget.saturating_sub(profile + pinned + beliefs + neighborhood + summaries);
144 Self {
145 profile_tokens: profile,
146 pinned_tokens: pinned,
147 beliefs_tokens: beliefs,
148 neighborhood_tokens: neighborhood,
149 summaries_tokens: summaries,
150 episodes_tokens: episodes,
151 }
152 }
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
158#[serde(rename_all = "snake_case")]
159pub struct PackPolicy {
160 pub expand_top_k: usize,
162 pub belief_form: BeliefForm,
164 pub reserve: Reserve,
166}
167
168impl PackPolicy {
169 pub fn for_budget(budget: usize) -> Self {
170 Self {
171 expand_top_k: 5,
172 belief_form: BeliefForm::OneLine,
173 reserve: Reserve::defaults_for_budget(budget),
174 }
175 }
176}
177
178pub fn pack(
191 input: &ContextInput,
192 budget: &ContextBudget,
193 policy: &PackPolicy,
194 tokenizer: &dyn TokenizerPort,
195) -> ContextResult {
196 let mut layers: Vec<ContextLayer> = Vec::new();
197 let mut total_tokens: usize = 0;
198 let mut remaining = budget.max_tokens;
199
200 if !input.profile.is_empty() {
202 let floor = policy.reserve.profile_tokens;
203 let (text, prov, tokens) = render_profile(&input.profile, floor, remaining, tokenizer);
204 total_tokens += tokens;
205 remaining = remaining.saturating_sub(tokens);
206 if !text.is_empty() {
207 layers.push(ContextLayer {
208 kind: LayerKind::Profile,
209 text,
210 estimated_tokens: tokens,
211 provenance: prov,
212 });
213 }
214 }
215
216 let _ = policy.reserve.pinned_tokens;
219
220 let belief_lines: Vec<(String, String)> = input
222 .beliefs
223 .iter()
224 .map(|b| {
225 (
226 render_belief_line(b, policy.belief_form),
227 b.statement_id.clone(),
228 )
229 })
230 .collect();
231 if !belief_lines.is_empty() {
232 let ceiling = policy.reserve.beliefs_tokens.min(remaining);
233 let (text, prov, tokens) = fill_lines(&belief_lines, ceiling, remaining, tokenizer);
234 total_tokens += tokens;
235 remaining = remaining.saturating_sub(tokens);
236 if !text.is_empty() {
237 layers.push(ContextLayer {
238 kind: LayerKind::HighSalienceBeliefs,
239 text,
240 estimated_tokens: tokens,
241 provenance: prov,
242 });
243 }
244 }
245
246 let edge_lines: Vec<(String, String)> = input
248 .neighborhood
249 .iter()
250 .map(|e| {
251 (
252 format!(
253 "{} -[{}]-> {} (conf={:.2})",
254 short(&e.from),
255 e.predicate,
256 short(&e.to),
257 e.confidence
258 ),
259 e.statement_id.clone(),
260 )
261 })
262 .collect();
263 if !edge_lines.is_empty() {
264 let ceiling = policy.reserve.neighborhood_tokens.min(remaining);
265 let (text, prov, tokens) = fill_lines(&edge_lines, ceiling, remaining, tokenizer);
266 total_tokens += tokens;
267 remaining = remaining.saturating_sub(tokens);
268 if !text.is_empty() {
269 layers.push(ContextLayer {
270 kind: LayerKind::QueryNeighborhood,
271 text,
272 estimated_tokens: tokens,
273 provenance: prov,
274 });
275 }
276 }
277
278 let summary_blocks: Vec<(String, String)> = input
282 .summaries
283 .iter()
284 .filter(|s| !s.sources.is_empty() && s.confidence > 0.0)
285 .map(|s| {
286 let mut text = s.text.clone();
287 if let Some(u) = &s.uncertainty {
289 text.push_str(&format!("\n uncertainty: {:.2}", u.score()));
290 }
291 if !s.sources.is_empty() {
293 text.push_str("\n sources: ");
294 text.push_str(&s.sources.join(", "));
295 }
296 (text, s.summary_id.clone())
297 })
298 .collect();
299 if !summary_blocks.is_empty() {
300 let ceiling = policy.reserve.summaries_tokens.min(remaining);
301 let (text, prov, tokens) = fill_lines(&summary_blocks, ceiling, remaining, tokenizer);
302 total_tokens += tokens;
303 remaining = remaining.saturating_sub(tokens);
304 if !text.is_empty() {
305 layers.push(ContextLayer {
306 kind: LayerKind::Summaries,
307 text,
308 estimated_tokens: tokens,
309 provenance: prov,
310 });
311 }
312 }
313
314 let truncated = render_episodes(
316 &input.episodes,
317 policy.expand_top_k,
318 tokenizer,
319 &mut layers,
320 &mut total_tokens,
321 &mut remaining,
322 );
323
324 debug_assert!(total_tokens <= budget.max_tokens, "pack exceeded budget");
326 ContextResult {
327 layers,
328 total_tokens,
329 budget: budget.clone(),
330 truncated,
331 }
332}
333
334fn render_profile(
337 profile: &[ProfileFact],
338 floor: usize,
339 remaining: usize,
340 tokenizer: &dyn TokenizerPort,
341) -> (String, Vec<String>, usize) {
342 let mut text = String::new();
347 let mut prov: Vec<String> = Vec::new();
348 let mut used = 0usize;
349 for fact in profile {
350 let line = format!(
351 "{} {} {} ({})\n",
352 fact.canonical_key,
353 fact.predicate,
354 fact.text,
355 format_validity(fact.valid_from, fact.valid_to)
356 );
357 let tokens = tokenizer.count(&line);
358 if used + tokens > remaining {
359 break;
360 }
361 text.push_str(&line);
364 for src in &fact.sources {
365 prov.push(src.clone());
366 }
367 used += tokens;
368 if used >= floor && used >= remaining / 2 {
373 break;
376 }
377 }
378 (text, prov, used)
379}
380
381fn fill_lines(
382 lines: &[(String, String)],
383 ceiling: usize,
384 remaining: usize,
385 tokenizer: &dyn TokenizerPort,
386) -> (String, Vec<String>, usize) {
387 let mut text = String::new();
388 let mut prov: Vec<String> = Vec::new();
389 let mut used = 0usize;
390 for (line, id) in lines {
391 let tokens = tokenizer.count(line);
392 if used + tokens > remaining || used + tokens > ceiling.max(used) {
393 break;
394 }
395 text.push_str(line);
396 text.push('\n');
397 prov.push(id.clone());
398 used += tokens;
399 }
400 (text, prov, used)
401}
402
403fn render_episodes(
404 episodes: &[EpisodeExcerpt],
405 expand_top_k: usize,
406 tokenizer: &dyn TokenizerPort,
407 layers: &mut Vec<ContextLayer>,
408 total_tokens: &mut usize,
409 remaining_ref: &mut usize,
410) -> bool {
411 if episodes.is_empty() {
412 return false;
413 }
414 let mut sorted: Vec<&EpisodeExcerpt> = episodes.iter().collect();
416 sorted.sort_by(|a, b| {
417 let sa = expand_score(a);
418 let sb = expand_score(b);
419 sb.partial_cmp(&sa)
420 .unwrap_or(std::cmp::Ordering::Equal)
421 .then_with(|| a.episode_id.cmp(&b.episode_id))
422 });
423 let mut text = String::new();
424 let mut prov: Vec<String> = Vec::new();
425 let mut used = 0usize;
426 let mut truncated = false;
427 for (i, ep) in sorted.iter().enumerate() {
428 let line = if i < expand_top_k {
429 ep.content.clone()
430 } else {
431 let trimmed: String = ep.content.chars().take(120).collect();
433 format!("[{id}] {trimmed}…", id = ep.episode_id)
434 };
435 let tokens = tokenizer.count(&line);
436 if used + tokens > *remaining_ref {
437 truncated = true;
438 break;
439 }
440 text.push_str(&line);
441 text.push('\n');
442 prov.push(ep.episode_id.clone());
443 used += tokens;
444 }
445 if !text.is_empty() {
446 *total_tokens += used;
447 *remaining_ref = remaining_ref.saturating_sub(used);
448 layers.push(ContextLayer {
449 kind: LayerKind::RecentEpisodes,
450 text,
451 estimated_tokens: used,
452 provenance: prov,
453 });
454 }
455 truncated
456}
457
458fn expand_score(ep: &EpisodeExcerpt) -> f64 {
459 let age_ms = (1_700_000_000_000i64 - ep.ingested_at).max(0) as f64;
461 let recency = 1.0 / (1.0 + age_ms / (365.0 * 24.0 * 3600.0 * 1000.0));
462 ep.salience * ep.salience.max(0.5) * recency
463}
464
465fn render_belief_line(b: &RenderedBelief, form: BeliefForm) -> String {
466 match form {
467 BeliefForm::OneLine => {
468 format!(
469 "{subj} {pred} {obj}",
470 subj = b.subject_canonical_key,
471 pred = b.predicate,
472 obj = b.object
473 )
474 }
475 BeliefForm::WithValidity => {
476 format!(
477 "{subj} {pred} {obj} ({valid})",
478 subj = b.subject_canonical_key,
479 pred = b.predicate,
480 obj = b.object,
481 valid = format_validity(b.valid_from, b.valid_to)
482 )
483 }
484 BeliefForm::WithProvenance => {
485 format!(
486 "{subj} {pred} {obj} ({valid}, src=[{src}])",
487 subj = b.subject_canonical_key,
488 pred = b.predicate,
489 obj = b.object,
490 valid = format_validity(b.valid_from, b.valid_to),
491 src = b.sources.join(",")
492 )
493 }
494 }
495}
496
497fn format_validity(from: i64, to: i64) -> String {
498 if to == i64::MAX - 1 {
501 format!("from {from} (open)")
502 } else if from == i64::MIN + 1 {
503 format!("until {to}")
504 } else {
505 format!("{from}..{to}")
506 }
507}
508
509fn short(id: &str) -> &str {
510 if id.len() > 16 { &id[..16] } else { id }
511}
512
513#[cfg(test)]
514mod tests {
515 use super::*;
516 use oxibrain_ports::CharTokenizer;
517
518 fn tok() -> CharTokenizer {
519 CharTokenizer
520 }
521
522 fn profile_fact(canonical: &str) -> ProfileFact {
523 ProfileFact {
524 subject: "subj1".into(),
525 canonical_key: canonical.into(),
526 predicate: "works_on".into(),
527 text: "ProjectX".into(),
528 valid_from: 1,
529 valid_to: i64::MAX - 1,
530 confidence: 0.9,
531 trust: TrustTier::Trusted,
532 sources: vec!["ep1".into()],
533 }
534 }
535
536 #[test]
537 fn pack_returns_empty_when_input_empty() {
538 let input = ContextInput::default();
539 let budget = ContextBudget { max_tokens: 1000 };
540 let policy = PackPolicy::for_budget(1000);
541 let out = pack(&input, &budget, &policy, &tok());
542 assert_eq!(out.total_tokens, 0);
543 assert!(out.layers.is_empty());
544 }
545
546 #[test]
547 fn pack_total_tokens_within_budget() {
548 let mut input = ContextInput::default();
549 for i in 0..50 {
550 input.profile.push(profile_fact(&format!("alice_{i}")));
551 input.beliefs.push(RenderedBelief {
552 statement_id: format!("s{i}"),
553 subject: "alice".into(),
554 subject_canonical_key: format!("alice_{i}"),
555 predicate: "works_on".into(),
556 object: "ProjectX".into(),
557 valid_from: 1,
558 valid_to: 100,
559 confidence: 0.8,
560 status: BeliefStatus::Active,
561 support_episodes: 1,
562 sources: vec![format!("ep{i}")],
563 });
564 }
565 let budget = ContextBudget { max_tokens: 800 };
566 let policy = PackPolicy::for_budget(800);
567 let out = pack(&input, &budget, &policy, &tok());
568 assert!(
569 out.total_tokens <= budget.max_tokens,
570 "total={} > budget={}",
571 out.total_tokens,
572 budget.max_tokens
573 );
574 }
575
576 #[test]
577 fn pack_profile_layer_present_when_input_has_profile() {
578 let mut input = ContextInput::default();
579 input.profile.push(profile_fact("Alice"));
580 let budget = ContextBudget { max_tokens: 1000 };
581 let policy = PackPolicy::for_budget(1000);
582 let out = pack(&input, &budget, &policy, &tok());
583 assert!(
584 out.layers
585 .iter()
586 .any(|l| matches!(l.kind, LayerKind::Profile))
587 );
588 }
589
590 #[test]
591 fn pack_summary_layer_includes_sources() {
592 let mut input = ContextInput::default();
593 input.summaries.push(SummaryWithUncertainty {
594 summary_id: "sm1".into(),
595 text: "A summary of things.".into(),
596 confidence: 0.7,
597 sources: vec!["ep_a".into(), "ep_b".into()],
598 uncertainty: None,
599 });
600 let budget = ContextBudget { max_tokens: 1000 };
601 let policy = PackPolicy::for_budget(1000);
602 let out = pack(&input, &budget, &policy, &tok());
603 let layer = out
604 .layers
605 .iter()
606 .find(|l| matches!(l.kind, LayerKind::Summaries))
607 .expect("summaries layer");
608 assert!(layer.text.contains("ep_a"));
609 assert!(layer.text.contains("ep_b"));
610 }
611
612 #[test]
615 fn pack_drops_sourceless_summary() {
616 let mut input = ContextInput::default();
617 input.summaries.push(SummaryWithUncertainty {
618 summary_id: "sm_bad".into(),
619 text: "Summary without sources.".into(),
620 confidence: 0.7,
621 sources: vec![], uncertainty: None,
623 });
624 input.summaries.push(SummaryWithUncertainty {
625 summary_id: "sm_good".into(),
626 text: "Summary with sources.".into(),
627 confidence: 0.7,
628 sources: vec!["ep_a".into()],
629 uncertainty: None,
630 });
631 let budget = ContextBudget { max_tokens: 1000 };
632 let policy = PackPolicy::for_budget(1000);
633 let out = pack(&input, &budget, &policy, &tok());
634 let summaries_layer = out
635 .layers
636 .iter()
637 .find(|l| matches!(l.kind, LayerKind::Summaries));
638 if let Some(layer) = summaries_layer {
639 assert!(!layer.text.contains("sm_bad"), "sourceless summary leaked");
640 assert!(layer.text.contains("ep_a"), "good summary missing");
641 }
642 }
643
644 #[test]
645 fn pack_drops_zero_confidence_summary() {
646 let mut input = ContextInput::default();
647 input.summaries.push(SummaryWithUncertainty {
648 summary_id: "sm_zero".into(),
649 text: "Zero confidence.".into(),
650 confidence: 0.0,
651 sources: vec!["ep_a".into()],
652 uncertainty: None,
653 });
654 let budget = ContextBudget { max_tokens: 1000 };
655 let policy = PackPolicy::for_budget(1000);
656 let out = pack(&input, &budget, &policy, &tok());
657 let summaries_layer = out
658 .layers
659 .iter()
660 .find(|l| matches!(l.kind, LayerKind::Summaries));
661 if let Some(layer) = summaries_layer {
662 assert!(
663 !layer.text.contains("Zero confidence"),
664 "zero-conf summary leaked"
665 );
666 }
667 }
668
669 #[test]
670 fn pack_deterministic() {
671 let mut input = ContextInput::default();
672 for i in 0..10 {
673 input.profile.push(profile_fact(&format!("a{i}")));
674 }
675 let budget = ContextBudget { max_tokens: 600 };
676 let policy = PackPolicy::for_budget(600);
677 let a = pack(&input, &budget, &policy, &tok());
678 let b = pack(&input, &budget, &policy, &tok());
679 let ja = serde_json::to_string(&a).unwrap();
680 let jb = serde_json::to_string(&b).unwrap();
681 assert_eq!(ja, jb);
682 }
683
684 use proptest::prelude::*;
685 proptest! {
686 #![proptest_config(ProptestConfig::with_cases(64))]
687
688 #[test]
690 fn prop_budget_soundness(
691 n_profile in 0usize..20,
692 n_beliefs in 0usize..30,
693 n_episodes in 0usize..10,
694 budget in 100usize..5_000,
695 ) {
696 use proptest::prelude::*;
697 let mut input = ContextInput::default();
698 for i in 0..n_profile {
699 input.profile.push(profile_fact(&format!("p{i}")));
700 }
701 for i in 0..n_beliefs {
702 input.beliefs.push(RenderedBelief {
703 statement_id: format!("s{i}"),
704 subject: "subj".into(),
705 subject_canonical_key: format!("subj{i}"),
706 predicate: "knows".into(),
707 object: "obj".into(),
708 valid_from: 0,
709 valid_to: 100,
710 confidence: 0.5,
711 status: BeliefStatus::Active,
712 support_episodes: 1,
713 sources: vec![],
714 });
715 }
716 for i in 0..n_episodes {
717 input.episodes.push(EpisodeExcerpt {
718 episode_id: format!("e{i}"),
719 content: format!("content for episode {i}"),
720 ingested_at: 1_700_000_000_000 - (i as i64) * 1_000,
721 salience: 0.5,
722 });
723 }
724 let policy = PackPolicy::for_budget(budget);
725 let out = pack(&input, &ContextBudget { max_tokens: budget }, &policy, &tok());
726 prop_assert!(out.total_tokens <= budget,
727 "total {} > budget {}", out.total_tokens, budget);
728 }
729
730 #[test]
732 fn prop_determinism(
733 n_profile in 0usize..10,
734 budget in 200usize..3_000,
735 ) {
736 let mut input = ContextInput::default();
737 for i in 0..n_profile {
738 input.profile.push(profile_fact(&format!("a{i}")));
739 }
740 let policy = PackPolicy::for_budget(budget);
741 let a = pack(&input, &ContextBudget { max_tokens: budget }, &policy, &tok());
742 let b = pack(&input, &ContextBudget { max_tokens: budget }, &policy, &tok());
743 prop_assert_eq!(serde_json::to_string(&a).unwrap(), serde_json::to_string(&b).unwrap());
744 }
745 }
746}