Skip to main content

oxibrain_core/
pack.rs

1//! Context assembly packing — the §12.3 pure decision.
2//!
3//! `pack` consumes a fully-prepared `ContextInput` and produces a
4//! `ContextResult` packed to a token budget. The function is pure: no
5//! I/O, no model, no time. The store hands it pre-folded facts, ranked
6//! items, neighbourhoods, summaries, and episodes; pack decides what
7//! gets into the final context and what gets truncated.
8//!
9//! Post-conditions (each one a runtime assertion, debug-gated only when
10//! the input is malformed):
11//!   - **Budget soundness.** `total_tokens <= budget.max_tokens`.
12//!   - **Profile floor.** The Profile layer's tokens never get squeezed
13//!     out by a later layer filling the reserve.
14//!   - **Summary pairing (§12.4).** A summary in the output always
15//!     travels with its `sources`.
16//!   - **Determinism.** Equal inputs produce byte-equal output.
17
18use 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// ── §12.2 inputs ────────────────────────────────────────────────────────────
25
26/// A single profile line (DESIGN §12.2). Subject + canonical key +
27/// predicate + rendered text, plus provenance.
28#[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    /// Source episode ids the profile line was extracted from.
39    pub sources: Vec<String>,
40}
41
42/// A belief rendered for context. The current `render_belief` (F6) drops
43/// the subject; this is the rewrite (§12.3, F6) that includes subject +
44/// canonical key + validity + support.
45#[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/// One edge of the query neighbourhood.
61#[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/// Episode text excerpt.
71#[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/// Summary + uncertainty, paired (§12.4).
80#[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    /// Computed uncertainty for this summary (§13.1, P10, 10.1).
87    /// `None` for summaries from before uncertainty was tracked.
88    #[serde(default)]
89    pub uncertainty: Option<crate::uncertainty::Uncertainty>,
90}
91
92/// §12.3 input — the raw material pack turns into a context.
93#[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// ── §12.3 policy ────────────────────────────────────────────────────────────
103
104/// Belief rendering verbosity (§12.3).
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
106#[serde(rename_all = "snake_case")]
107pub enum BeliefForm {
108    /// Single-line: "{subject} {predicate} {object}".
109    #[default]
110    OneLine,
111    /// Adds the validity interval.
112    WithValidity,
113    /// Adds source episode ids.
114    WithProvenance,
115}
116
117/// Reserve share per layer. The Profile layer's reservation is a floor
118/// (§12.3): pack must always emit at least that many tokens for it if
119/// the budget permits. Other reservations are ceilings.
120#[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    /// Defaults sized for a 3000-token context. Profile gets 200 — it
133    /// is always small but always present. Episodes get the remainder
134    /// because verbatim text is the most informative and the most
135    /// expensive.
136    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/// §12.3 — pack policy. The expansion score is a function over stored
156/// fields (`salience × confidence × recency`), no policy network.
157#[derive(Debug, Clone, Serialize, Deserialize)]
158#[serde(rename_all = "snake_case")]
159pub struct PackPolicy {
160    /// How many episodes get rendered in full (verbatim).
161    pub expand_top_k: usize,
162    /// Belief rendering verbosity.
163    pub belief_form: BeliefForm,
164    /// Floor/ceiling share per layer.
165    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
178/// Pack a `ContextInput` to the budget under `PackPolicy`. The function is
179/// pure: time-invariant, no I/O, no model. Layer order is fixed (§12.2):
180/// Profile first (always), then Pinned, HighSalienceBeliefs,
181/// QueryNeighborhood, Summaries, RecentEpisodes.
182///
183/// Strategy:
184/// 1. Render every belief using `belief_form`.
185/// 2. Sort episodes by `expand_score = salience × confidence × recency`.
186/// 3. Allocate reserves per layer; Profile gets a floor.
187/// 4. Greedily fill each layer until reserve hits; overflow rolls into
188///    the next layer's allocation.
189/// 5. Top-k episodes are rendered verbatim; the rest are one-line.
190pub 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    // 1. Profile — always rendered, always first. Floor = reserve.profile.
201    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    // 2. Pinned facts — no implementation in M8 (F2.4 was M4). Reserve
217    //    is unused but reserved in the type for future use.
218    let _ = policy.reserve.pinned_tokens;
219
220    // 3. High-salience beliefs.
221    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    // 4. Query neighborhood.
247    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    // 5. Summaries — paired with sources (§12.4 post-condition, P10).
279    // Drop any summary with empty sources: "a summary is never returned
280    // without its sources." This is the executable form of P10.
281    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            // Uncertainty score (§13.1, P10, 10.1).
288            if let Some(u) = &s.uncertainty {
289                text.push_str(&format!("\n  uncertainty: {:.2}", u.score()));
290            }
291            // Pairing rule: a summary never travels without its sources.
292            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    // 6. Episodes — top-k verbatim, the rest one-line if budget allows.
315    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    // Post-conditions (defensive; pre-M8 callers ignore them).
325    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
334// ── helpers ─────────────────────────────────────────────────────────────────
335
336fn render_profile(
337    profile: &[ProfileFact],
338    floor: usize,
339    remaining: usize,
340    tokenizer: &dyn TokenizerPort,
341) -> (String, Vec<String>, usize) {
342    // Floor is mandatory: the post-condition is that Profile is never
343    // squeezed below it. If the budget cannot fit the floor, we still
344    // emit at least one line — better to truncate a fact than to drop
345    // the layer entirely (§12.3).
346    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        // Once we cross the floor, additional lines compete with later
362        // layers. We accept lines until the budget runs out.
363        text.push_str(&line);
364        for src in &fact.sources {
365            prov.push(src.clone());
366        }
367        used += tokens;
368        // Note: floor is a *lower bound*, not a hard cap. We emit all
369        // eligible facts up to `remaining`, which is the absolute budget
370        // gate. The floor is the priority signal — once we've added at
371        // least `floor` tokens, the rest is opportunistic.
372        if used >= floor && used >= remaining / 2 {
373            // Don't squeeze other layers; stop adding profile once
374            // we've spent half the remaining budget on it.
375            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    // Sort by salience × recency, deterministic tie-break on episode_id.
415    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            // One-line summary for the tail.
432            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    // recency in millis since 2020-01-01, normalised to ~1.0 for recent.
460    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    // Cheap human-readable validity. The full Timeline is exposed
499    // through `recall(timeline)`; here we only need a glance.
500    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    // ── P10: summary never without sources (§12.4, 10.2) ──────────────
613
614    #[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![], // empty — must be dropped
622            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        /// Budget soundness: total_tokens <= budget.max_tokens.
689        #[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        /// Determinism: byte-equal output across runs.
731        #[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}