velesdb_memory/context.rs
1//! The deterministic context compiler (EPIC-P-070).
2//!
3//! Classifies, deduplicates, and packs caller-supplied context fragments
4//! under a token budget — **no LLM, no network, no clock**: the pipeline is a
5//! sequence of pure stages (`chunk → classify → dedup → score → pack →
6//! assemble`), so the same [`CompileRequest`](crate::context::CompileRequest)
7//! always produces the same
8//! [`CompiledContext`](crate::context::CompiledContext), byte for byte.
9//!
10//! Invariants:
11//! - **Budget**: the assembled content never exceeds the request's token
12//! budget — packing accounts per-piece estimates plus joiner costs *priced
13//! by the injected estimator*, which bounds the whole-text estimate for a
14//! superadditive estimator (the default rounds every piece up).
15//! - **Provenance**: every input fragment gets exactly one
16//! [`ContextDecision`](crate::context::ContextDecision) with a stable rule
17//! id and a content hash; every fragment stays addressable via a
18//! **content-addressed** `ctx://source/<hash>` handle (immune to
19//! caller-id collisions) — hashed over the text for a text fragment, over
20//! the raw decoded media bytes for a media fragment (see
21//! `Analysis::handle_hash`: captions are typically blank, so a
22//! caption-keyed handle would collide every captionless image).
23//! - **Nothing critical is silently lost**: content that cannot fit becomes
24//! a [`RetrievalHandle`](crate::context::RetrievalHandle); losing
25//! preserve-classified content raises
26//! [`CompiledContext::risk`](crate::context::CompiledContext::risk) to
27//! [`FidelityRisk::High`](crate::context::FidelityRisk::High); a critical
28//! fragment is never sacrificed to near-deduplication, and a duplicate of
29//! a twin that did not emit verbatim keeps its own handle and risk.
30//!
31//! Memory-backed fragment selection, persisted working contexts, and
32//! compilation events layer on top in the `persistence`-gated bridge
33//! (US-002); MCP and Node expose the same types unchanged (US-003).
34
35mod budget;
36pub mod chunk;
37mod classify;
38mod dedup;
39pub mod estimator;
40pub mod insights;
41mod log_normalize;
42// `pub(crate)`, not private: the memory bridge (`service::memory_bridge`,
43// physically stored under `context/` but logically a sibling module of
44// `context`, see `service.rs`) decodes media bytes itself (US-009, PR2) to
45// derive a deterministic placeholder embedding for a stored media source.
46pub(crate) mod media;
47pub mod model;
48/// The `suggest_budget` MCP tool's static model→window table (V2a-3 quick
49/// win). No dependency on anything else in the pipeline — a pure lookup.
50pub mod model_windows;
51pub(crate) mod provenance;
52mod relevance;
53/// The id wire contract (decimal-string `u64`) shared by every JS-facing
54/// binding of these types — one source of truth for [`wire::ID_KEYS`]
55/// instead of a Node/WASM copy each.
56pub mod wire;
57
58pub use chunk::{chunk_text, ChunkBoundary, ChunkPolicy, TextChunk};
59pub use estimator::{DynTokenEstimator, HeuristicEstimator, TokenEstimator};
60pub use insights::{CompilationInsights, ModelPricing, PricingTable};
61pub use model::{
62 CompilePolicy, CompileRequest, CompiledContext, CompiledSection, ContextAction,
63 ContextDecision, ContextDecisionRef, ContextFact, ContextFragment, ContextSavings,
64 ContextSource, ContextWarning, FidelityRisk, ImportanceWeights, MediaRef, MemoryScope,
65 RetrievalHandle, SectionKind, SourceReference, WorkingContext, WorkingContextIndex,
66 WorkingContextSession,
67};
68pub use model_windows::{model_window, suggest_token_budget, SuggestedBudget};
69pub use relevance::DeterministicReranker;
70
71use std::collections::BTreeMap;
72
73use crate::error::MemoryError;
74use crate::id::stable_id;
75use crate::limits;
76
77use budget::PackItem;
78use classify::RuleMatch;
79use dedup::{DupKind, Duplicate};
80
81/// The stable, content-addressed id of a fragment whose caller supplied none
82/// — the crate's one id scheme (FNV-1a 64), also used as every decision's
83/// content hash and as the tail of every `ctx://source/<hash>` handle.
84#[must_use]
85pub fn fragment_id(content: &str) -> u64 {
86 stable_id(content)
87}
88
89/// The deterministic context compiler. Build one with a policy, optionally
90/// inject an estimator and a pricing table, then [`compile`](Self::compile).
91///
92/// ```rust
93/// use velesdb_memory::context::{
94/// CompilePolicy, CompileRequest, ContextCompiler, ContextFragment,
95/// };
96///
97/// let compiler = ContextCompiler::new(CompilePolicy::default());
98/// let out = compiler
99/// .compile(&CompileRequest {
100/// query: "deploy status".to_owned(),
101/// fragments: vec![ContextFragment {
102/// id: None,
103/// content: "The deploy pipeline is green.".to_owned(),
104/// kind: None,
105/// priority: None,
106/// metadata: None,
107/// media: None,
108/// }],
109/// project: None,
110/// target_model: None,
111/// token_budget: 1_000,
112/// memory_scope: None,
113/// policy: None,
114/// })
115/// .expect("a generous budget compiles");
116/// assert!(out.content.contains("deploy pipeline"));
117/// ```
118pub struct ContextCompiler {
119 policy: CompilePolicy,
120 estimator: DynTokenEstimator,
121 pricing: Option<PricingTable>,
122}
123
124impl ContextCompiler {
125 /// A compiler over `policy`, with the default char-ratio estimator and
126 /// no pricing (insights then report tokens only).
127 #[must_use]
128 pub fn new(policy: CompilePolicy) -> Self {
129 Self {
130 policy,
131 estimator: Box::new(HeuristicEstimator),
132 pricing: None,
133 }
134 }
135
136 /// Replace the token estimator (e.g. a model-exact tokenizer).
137 #[must_use]
138 pub fn with_estimator(mut self, estimator: DynTokenEstimator) -> Self {
139 self.estimator = estimator;
140 self
141 }
142
143 /// Inject a versioned pricing table so insights also report estimated
144 /// cost savings for the request's target model.
145 #[must_use]
146 pub fn with_pricing(mut self, pricing: PricingTable) -> Self {
147 self.pricing = Some(pricing);
148 self
149 }
150
151 /// The policy this compilation actually runs under: the request's
152 /// override when present, this compiler's otherwise. The memory bridge
153 /// reads it to honor the storage/event opt-outs.
154 pub(crate) fn effective_policy<'a>(&'a self, request: &'a CompileRequest) -> &'a CompilePolicy {
155 request.policy.as_ref().unwrap_or(&self.policy)
156 }
157
158 /// Compile `request` into a budgeted, fully-audited context.
159 ///
160 /// # Errors
161 ///
162 /// [`MemoryError::ContextOverLimit`] when the request exceeds a
163 /// [`crate::limits`] cap (fragment count or single-fragment size),
164 /// [`MemoryError::MetadataTooLarge`] when a fragment's `metadata` exceeds
165 /// [`crate::limits::MAX_METADATA_BYTES`], and
166 /// [`MemoryError::ContextBudget`] when the token budget minus the
167 /// policy's response reserve leaves no room for any context.
168 pub fn compile(&self, request: &CompileRequest) -> Result<CompiledContext, MemoryError> {
169 let compiled = self.compile_raw(request)?;
170 Ok(apply_slim(compiled, self.effective_policy(request)))
171 }
172
173 /// [`Self::compile`] without the [`CompilePolicy::slim_response`]
174 /// post-processing: the memory bridge needs the FULL `decisions` to
175 /// annotate memory provenance and recompute `warnings` (both can change
176 /// a pulled fragment's `relevance`/`reason` after this returns) before
177 /// slimming happens — every other caller should use [`Self::compile`].
178 pub(crate) fn compile_raw(
179 &self,
180 request: &CompileRequest,
181 ) -> Result<CompiledContext, MemoryError> {
182 let policy = self.effective_policy(request);
183 let usable = validate(request, policy)?;
184 let analyses = analyze(request, policy, self.estimator.as_ref());
185 let items = pack_items(&analyses, policy, usable, self.estimator.as_ref());
186 let taken = budget::pack(&items, usable, &self.estimator);
187 let emissions = emissions(&items, &taken);
188 Ok(self.finish(request, &analyses, &emissions))
189 }
190
191 /// Assemble the output, decisions, insights, and risk.
192 fn finish(
193 &self,
194 request: &CompileRequest,
195 analyses: &[Analysis],
196 emissions: &BTreeMap<usize, Emission>,
197 ) -> CompiledContext {
198 let sections = sections(analyses, emissions);
199 let content = sections
200 .iter()
201 .map(|section| section.content.as_str())
202 .collect::<Vec<_>>()
203 .join(budget::JOINER);
204 let decisions: Vec<ContextDecision> = analyses
205 .iter()
206 .map(|analysis| decision(analysis, analyses, emissions))
207 .collect();
208 let insights = self.insights(request, analyses, &decisions, emissions, &content);
209 let warnings = warnings_for(&decisions);
210 CompiledContext {
211 retrieval_handles: retrieval_handles(analyses, &decisions),
212 sources: analyses
213 .iter()
214 .filter(|analysis| analysis.dup.is_none())
215 .map(|analysis| {
216 provenance::source_for(analysis.fragment_id, analysis.handle_hash())
217 })
218 .collect(),
219 risk: decisions
220 .iter()
221 .map(|decision| decision.risk)
222 .max()
223 .unwrap_or_default(),
224 content,
225 sections,
226 decisions,
227 insights,
228 warnings,
229 }
230 }
231
232 /// Token accounting, with cost figures only when pricing knows the model.
233 fn insights(
234 &self,
235 request: &CompileRequest,
236 analyses: &[Analysis],
237 decisions: &[ContextDecision],
238 emissions: &BTreeMap<usize, Emission>,
239 content: &str,
240 ) -> CompilationInsights {
241 let estimator = self.estimator.as_ref();
242 let tokens_in: u64 = analyses
243 .iter()
244 .map(|analysis| analysis.tokens)
245 .fold(0, u64::saturating_add);
246 // `content` already carries every emitted fragment's TEXT — for a
247 // media fragment (US-009, PR1) that means its caption only, since
248 // raw media bytes are never turned into packed text (see `pieces`).
249 // The image's own cost has to be added on top, but only for media
250 // that actually made it into the output (an emissions entry exists)
251 // — an externalized or superseded image contributed nothing and
252 // must not appear here either (see `pack_items`).
253 let media_tokens_out: u64 = analyses
254 .iter()
255 .filter(|analysis| emissions.contains_key(&analysis.seq))
256 .filter_map(|analysis| analysis.media.as_ref())
257 .map(|media| media.image_tokens)
258 .fold(0, u64::saturating_add);
259 let tokens_out = estimator.estimate(content).saturating_add(media_tokens_out);
260 let tokens_saved = tokens_in.saturating_sub(tokens_out);
261 let mut insights = CompilationInsights {
262 tokens_in,
263 tokens_out,
264 tokens_saved,
265 tokens_saved_by_rule: saved_by_rule(analyses, decisions, emissions, estimator),
266 ..CompilationInsights::default()
267 };
268 let cost = request.target_model.as_deref().and_then(|model| {
269 // The request's own table wins (it is the only channel wire
270 // callers — MCP, Node — have); the builder-injected one is the
271 // Rust-embedder fallback.
272 let pricing = self
273 .effective_policy(request)
274 .pricing
275 .as_ref()
276 .or(self.pricing.as_ref())?;
277 let micros = pricing.cost_micros(model, tokens_saved)?;
278 Some((micros, pricing.currency.clone(), pricing.version.clone()))
279 });
280 if let Some((micros, currency, version)) = cost {
281 insights.estimated_cost_saved_micros = Some(micros);
282 insights.currency = Some(currency);
283 insights.pricing_version = Some(version);
284 }
285 insights
286 }
287}
288
289/// Everything the pipeline derived about one input fragment. Borrows the
290/// request (the pipeline never mutates fragments), so a compile at the size
291/// caps does not double the corpus in memory.
292struct Analysis<'a> {
293 /// Input position.
294 seq: usize,
295 /// Caller id, or the content-derived stable id.
296 fragment_id: u64,
297 /// FNV-1a hash of the original content (computed once, reused by ids,
298 /// dedup, and handles).
299 content_hash: u64,
300 /// The original text, borrowed from the request.
301 original: &'a str,
302 /// Estimated tokens of the original (computed once, reused by insights,
303 /// handles, and savings attribution).
304 tokens: u64,
305 /// Classification outcome.
306 rule: RuleMatch,
307 /// Lexical relevance to the query.
308 relevance: f32,
309 /// Caller priority (default 0).
310 priority: u8,
311 /// Set when this fragment duplicates an earlier one it may safely be
312 /// dropped for (see [`retain_safe_duplicates`]).
313 dup: Option<Duplicate>,
314 /// Only set for `abstract.log_dedup`-classified fragments: the
315 /// collapsed single piece, and whether
316 /// [`CompilePolicy::normalize_log_timestamps`] actually changed the
317 /// grouping (ventilated into the decision `reason`). Computed once here
318 /// so [`pieces`] and [`decision`] never redo the line-scan.
319 abstract_collapse: Option<(String, bool)>,
320 /// Set when the fragment carries inline media (US-009, PR1): its
321 /// dedup identity and precomputed image token cost, computed once here
322 /// (decoding is not free) and reused by dedup, packing, and insights.
323 media: Option<media::MediaAnalysis>,
324 /// Set when a LATER fragment in the same request shares this one's
325 /// `kind == "screenshot"` and `metadata.target` value (US-009, PR2 —
326 /// see [`classify::screenshot_supersession`]): excluded from packing
327 /// entirely regardless of budget (see [`pack_items`]) and routed to
328 /// [`superseded_screenshot_verdict`] instead of the ordinary
329 /// pack-outcome verdicts. `analysis.rule` is left untouched (still
330 /// whatever [`classify::classify`] returned) — only this flag steers
331 /// packing and the decision; nothing else needs to know why.
332 superseded: bool,
333}
334
335impl Analysis<'_> {
336 /// The hash every `ctx://source/<hash>` handle (and thus every bridge
337 /// storage slot) for this fragment is minted from. **Media identity is
338 /// the raw decoded BYTES** ([`media::MediaAnalysis::raw_hash`]), exactly
339 /// like PR1's dedup — never the caption text: captions are typically
340 /// blank, and keying on them would collide every captionless image onto
341 /// one handle (serving arbitrary wrong bytes back). Two different
342 /// images therefore always get two different handles; byte-identical
343 /// images share one handle and resolve the same stored bytes (with the
344 /// kept instance's caption — divergent duplicate captions do not
345 /// survive, same as PR1's dedup semantics). Text fragments keep the
346 /// content hash, byte-identical to every pre-PR2 handle.
347 fn handle_hash(&self) -> u64 {
348 self.media
349 .as_ref()
350 .map_or(self.content_hash, |media| media.raw_hash)
351 }
352}
353
354/// A media fragment's total precomputed token cost: the image alone (from
355/// [`media::MediaAnalysis::image_tokens`]) plus its caption's own (usually
356/// tiny, often zero for a blank caption) text cost. Shared by [`analyze`]
357/// (feeds [`Analysis::tokens`]) and [`pieces`] (feeds the packed piece's
358/// cost) so the two can never drift apart — the same total is what gets
359/// budgeted and what gets reported as "emitted" once packed.
360fn media_fragment_tokens(
361 media: &media::MediaAnalysis,
362 caption: &str,
363 estimator: &dyn TokenEstimator,
364) -> u64 {
365 media
366 .image_tokens
367 .saturating_add(estimator.estimate(caption))
368}
369
370/// What actually got emitted for one packed fragment.
371struct Emission {
372 /// The emitted text (a prefix of the fragment's pieces, concatenated).
373 text: String,
374 /// Pieces taken / pieces available.
375 taken: usize,
376 /// Total pieces the fragment was cut into.
377 total: usize,
378}
379
380impl Emission {
381 /// Whether the fragment's pieces were all emitted.
382 fn is_full(&self) -> bool {
383 self.taken == self.total
384 }
385}
386
387/// Reject requests over the [`crate::limits`] caps and compute the usable
388/// budget (`clamped budget − reserve`).
389fn validate(request: &CompileRequest, policy: &CompilePolicy) -> Result<u64, MemoryError> {
390 if request.fragments.len() > limits::MAX_FRAGMENTS {
391 return Err(MemoryError::ContextOverLimit(format!(
392 "{} fragments exceed the cap of {}",
393 request.fragments.len(),
394 limits::MAX_FRAGMENTS
395 )));
396 }
397 if let Some(oversized) = request
398 .fragments
399 .iter()
400 .find(|fragment| fragment.content.len() > limits::MAX_FRAGMENT_BYTES)
401 {
402 return Err(MemoryError::ContextOverLimit(format!(
403 "a fragment of {} bytes exceeds the cap of {} bytes",
404 oversized.content.len(),
405 limits::MAX_FRAGMENT_BYTES
406 )));
407 }
408 for fragment in &request.fragments {
409 let Some(metadata) = fragment.metadata.as_ref() else {
410 continue;
411 };
412 let bytes = limits::metadata_bytes(metadata);
413 if bytes > limits::MAX_METADATA_BYTES {
414 return Err(MemoryError::MetadataTooLarge {
415 bytes,
416 max: limits::MAX_METADATA_BYTES,
417 });
418 }
419 }
420 validate_media(&request.fragments)?;
421 let budget = limits::clamp_token_budget(request.token_budget);
422 let usable = budget.saturating_sub(policy.response_reserve_tokens);
423 if usable == 0 {
424 return Err(MemoryError::ContextBudget {
425 budget,
426 reserve: policy.response_reserve_tokens,
427 });
428 }
429 Ok(usable)
430}
431
432/// Reject a fragment whose media payload violates
433/// [`limits::MAX_MEDIA_BYTES`] or is not well-formed base64 — checked eagerly
434/// here, before any decoding/hashing/estimation downstream, so a malformed
435/// payload never reaches the pipeline (fail fast, same `INVALID_PARAMS`
436/// shape as every other cap in [`validate`]).
437fn validate_media(fragments: &[ContextFragment]) -> Result<(), MemoryError> {
438 let mut total_media_bytes: usize = 0;
439 for (seq, fragment) in fragments.iter().enumerate() {
440 let Some(media_ref) = &fragment.media else {
441 continue;
442 };
443 total_media_bytes = total_media_bytes.saturating_add(media_ref.bytes_b64.len());
444 if total_media_bytes > limits::MAX_TOTAL_MEDIA_BYTES {
445 return Err(MemoryError::ContextOverLimit(format!(
446 "total media payload exceeds the request cap of {} base64 bytes",
447 limits::MAX_TOTAL_MEDIA_BYTES
448 )));
449 }
450 if media_ref.bytes_b64.len() > limits::MAX_MEDIA_BYTES {
451 return Err(MemoryError::ContextOverLimit(format!(
452 "fragment #{seq} media payload of {} base64 bytes exceeds the cap of {} bytes",
453 media_ref.bytes_b64.len(),
454 limits::MAX_MEDIA_BYTES
455 )));
456 }
457 if !media::is_valid_base64(&media_ref.bytes_b64) {
458 return Err(MemoryError::ContextOverLimit(format!(
459 "fragment #{seq} media payload is not valid base64"
460 )));
461 }
462 }
463 Ok(())
464}
465
466/// Run classification, relevance scoring, and duplicate detection over the
467/// input order, hashing and estimating each fragment exactly once.
468fn analyze<'a>(
469 request: &'a CompileRequest,
470 policy: &CompilePolicy,
471 estimator: &dyn TokenEstimator,
472) -> Vec<Analysis<'a>> {
473 let contents: Vec<&str> = request
474 .fragments
475 .iter()
476 .map(|fragment| fragment.content.as_str())
477 .collect();
478 // Decode/analyze media exactly once per fragment (decoding is not
479 // free), reused below both to feed dedup's media namespace and to build
480 // each Analysis's own `media` field.
481 let media_analyses: Vec<Option<media::MediaAnalysis>> = request
482 .fragments
483 .iter()
484 .map(|fragment| fragment.media.as_ref().map(media::analyze))
485 .collect();
486 let media_hashes: Vec<Option<u64>> = media_analyses
487 .iter()
488 .map(|analysis| analysis.as_ref().map(|analysis| analysis.raw_hash))
489 .collect();
490 // Whole-batch pass, symmetric to `dedup::find_duplicates` below: needs
491 // every fragment's `kind` + `metadata.target` at once, which a per-
492 // fragment `classify::classify` call cannot see. Computed *before*
493 // dedup so the media namespace can re-anchor off a superseded fragment
494 // (see `dedup::find_duplicates`'s doc) instead of anchoring dedup on a
495 // screenshot that supersession has already excluded from packing.
496 let superseded_flags = classify::screenshot_supersession(&request.fragments);
497 let duplicates = dedup::find_duplicates(
498 &contents,
499 policy.near_dup_dedup,
500 &media_hashes,
501 &superseded_flags,
502 );
503 let query_terms = relevance::terms(&request.query);
504 let mut analyses: Vec<Analysis<'a>> = request
505 .fragments
506 .iter()
507 .zip(duplicates)
508 .zip(media_analyses)
509 .enumerate()
510 .map(|(seq, ((fragment, dup), media_analysis))| {
511 let content_hash = stable_id(&fragment.content);
512 let rule = classify::classify(fragment, policy);
513 let abstract_collapse = (rule.action == ContextAction::Abstract).then(|| {
514 classify::collapse_repeated_lines(
515 &fragment.content,
516 policy.normalize_log_timestamps,
517 )
518 });
519 let tokens = media_analysis.as_ref().map_or_else(
520 || estimator.estimate(&fragment.content),
521 |media| media_fragment_tokens(media, &fragment.content, estimator),
522 );
523 // A caller can opt out via `disabled_rules`, exactly like every
524 // other named rule — even though this one is not a `RULES` row.
525 let superseded = superseded_flags[seq]
526 && !policy
527 .disabled_rules
528 .iter()
529 .any(|disabled| disabled == classify::SCREENSHOT_SUPERSEDED_RULE_ID);
530 Analysis {
531 seq,
532 fragment_id: fragment.id.unwrap_or(content_hash),
533 content_hash,
534 original: &fragment.content,
535 tokens,
536 rule,
537 relevance: relevance::lexical_relevance(&query_terms, &fragment.content),
538 priority: fragment.priority.unwrap_or(0),
539 dup,
540 abstract_collapse,
541 media: media_analysis,
542 superseded,
543 }
544 })
545 .collect();
546 retain_safe_duplicates(&mut analyses);
547 analyses
548}
549
550/// Keep a duplicate mark only when dropping the fragment loses nothing:
551/// the kept twin must be classified to emit **verbatim** (Preserve or Cache
552/// — an abstracted twin would collapse the duplicate's content), and a
553/// *critical* fragment is never sacrificed to near-deduplication (its bytes
554/// differ from the twin's, and its own classification demands them).
555fn retain_safe_duplicates(analyses: &mut [Analysis<'_>]) {
556 for index in 0..analyses.len() {
557 let Some(dup) = analyses[index].dup else {
558 continue;
559 };
560 let twin_verbatim = matches!(
561 analyses[dup.kept_seq].rule.action,
562 ContextAction::Preserve | ContextAction::Cache
563 );
564 let critical_near = dup.kind == DupKind::Near && analyses[index].rule.critical;
565 if !twin_verbatim || critical_near {
566 analyses[index].dup = None;
567 }
568 }
569}
570
571/// Build the packing input for every non-duplicate fragment: abstracted
572/// fragments emit their collapsed form as one piece, everything else is cut
573/// into budget-sized chunks.
574fn pack_items(
575 analyses: &[Analysis],
576 policy: &CompilePolicy,
577 usable: u64,
578 estimator: &dyn TokenEstimator,
579) -> Vec<PackItem> {
580 let chunk_policy = effective_chunk_policy(policy, usable, estimator);
581 analyses
582 .iter()
583 // A superseded screenshot (US-009, PR2) is never attempted, budget
584 // or no budget — see `Analysis::superseded`.
585 .filter(|analysis| analysis.dup.is_none() && !analysis.superseded)
586 .map(|analysis| PackItem {
587 seq: analysis.seq,
588 critical: analysis.rule.critical,
589 priority: analysis.priority,
590 relevance: analysis.relevance,
591 pieces: pieces(analysis, &chunk_policy, estimator),
592 })
593 .collect()
594}
595
596/// The emission pieces of one fragment.
597///
598/// A media fragment (US-009, PR1) is always exactly one atomic, all-or-
599/// nothing piece — never passed to [`chunk_text`], mirroring the
600/// `abstract.log_dedup` case below: packing can take it whole or not at all,
601/// never a byte-range prefix, so an image can never be cut mid-stream. Its
602/// text is only the caption (raw media bytes never become packable "piece"
603/// text); its cost is the precomputed [`media_fragment_tokens`] total, so
604/// packing never re-derives a media fragment's cost from `estimator.estimate`
605/// over an empty or near-empty caption.
606fn pieces(
607 analysis: &Analysis,
608 chunk_policy: &ChunkPolicy,
609 estimator: &dyn TokenEstimator,
610) -> Vec<budget::Piece> {
611 if let Some(media) = &analysis.media {
612 let cost = media_fragment_tokens(media, analysis.original, estimator);
613 return vec![budget::Piece {
614 text: analysis.original.to_owned(),
615 cost: Some(cost),
616 }];
617 }
618 if let Some((collapsed, _normalized)) = &analysis.abstract_collapse {
619 return vec![budget::Piece {
620 text: collapsed.clone(),
621 cost: None,
622 }];
623 }
624 chunk_text(analysis.original, chunk_policy)
625 .into_iter()
626 .map(|chunk| budget::Piece {
627 text: chunk.text,
628 cost: None,
629 })
630 .collect()
631}
632
633/// Lower bound on the pipeline's effective chunk size, regardless of budget
634/// or caller policy. Guards against memory-amplification: without a floor, a
635/// tiny `token_budget` (or a tiny caller-supplied `max_chunk_bytes`) would
636/// drive the ceiling toward one byte and explode a large fragment into one
637/// heap `String` per byte. At 256 bytes the per-piece `String` overhead is
638/// under 10 %, so pieces stay bounded by ~`input_bytes / 256` — no
639/// amplification beyond the already-capped input size ([`crate::limits`]).
640const MIN_CHUNK_BYTES: usize = 256;
641
642/// The chunk policy the pipeline actually cuts with: the ceiling tracks the
643/// usable budget (sized via the estimator's bytes-per-token hint) but is
644/// **floored at [`MIN_CHUNK_BYTES`]** so neither a tiny budget nor a tiny
645/// caller-supplied `max_chunk_bytes` can drive it toward a byte (a
646/// memory-amplification `DoS`). A budget too small to hold a floored piece
647/// simply externalizes everything, which is the correct outcome anyway.
648/// **Overlap is forced to zero** — pipeline pieces are emitted by
649/// concatenation, and an overlap prefix would duplicate every seam in
650/// content reported as verbatim; overlap stays meaningful only for the
651/// standalone [`chunk_text`] API. The byte ceiling is a *hint*: every piece
652/// is still measured by the injected estimator during packing.
653fn effective_chunk_policy(
654 policy: &CompilePolicy,
655 usable: u64,
656 estimator: &dyn TokenEstimator,
657) -> ChunkPolicy {
658 let budget_bytes = usize::try_from(usable.saturating_mul(estimator.bytes_per_token_hint()))
659 .unwrap_or(usize::MAX);
660 ChunkPolicy {
661 max_chunk_bytes: policy
662 .chunk
663 .max_chunk_bytes
664 .min(budget_bytes)
665 .max(MIN_CHUNK_BYTES),
666 overlap_bytes: 0,
667 boundary: policy.chunk.boundary,
668 }
669}
670
671/// Materialize what each packed fragment emits, keyed by `seq`. A fragment
672/// with no pieces at all (empty content) is kept here with `taken == total
673/// == 0` — trivially fully emitted, since there is nothing to lose — rather
674/// than dropped as "took none of what was offered", which is reserved for a
675/// fragment that had pieces and the budget could not fit any of them.
676fn emissions(items: &[PackItem], taken: &[usize]) -> BTreeMap<usize, Emission> {
677 items
678 .iter()
679 .zip(taken.iter().copied())
680 .filter(|&(item, count)| count > 0 || item.pieces.is_empty())
681 .map(|(item, count)| {
682 (
683 item.seq,
684 Emission {
685 text: item.pieces[..count]
686 .iter()
687 .map(|piece| piece.text.as_str())
688 .collect(),
689 taken: count,
690 total: item.pieces.len(),
691 },
692 )
693 })
694 .collect()
695}
696
697/// The output blocks: the cache-marked prefix first, then the body, both in
698/// input order.
699fn sections(analyses: &[Analysis], emissions: &BTreeMap<usize, Emission>) -> Vec<CompiledSection> {
700 let mut result = Vec::new();
701 for kind in [SectionKind::Cache, SectionKind::Body] {
702 let mut blocks: Vec<&str> = Vec::new();
703 let mut ids: Vec<u64> = Vec::new();
704 for analysis in analyses {
705 let cache = analysis.rule.action == ContextAction::Cache;
706 let wanted = (kind == SectionKind::Cache) == cache;
707 // Skip empty emissions: a trivially-emitted empty fragment
708 // (taken == total == 0) still gets its own decision, but must
709 // contribute no block — otherwise `join(JOINER)` would wrap it in
710 // joiners the packer never accounted for, breaking the budget
711 // invariant once more than one empty fragment is present.
712 if let Some(emission) = emissions
713 .get(&analysis.seq)
714 .filter(|emission| wanted && !emission.text.is_empty())
715 {
716 blocks.push(&emission.text);
717 ids.push(analysis.fragment_id);
718 }
719 }
720 if !blocks.is_empty() {
721 result.push(CompiledSection {
722 kind,
723 content: blocks.join(budget::JOINER),
724 fragment_ids: ids,
725 });
726 }
727 }
728 result
729}
730
731/// The auditable decision for one fragment.
732fn decision(
733 analysis: &Analysis,
734 all: &[Analysis],
735 emissions: &BTreeMap<usize, Emission>,
736) -> ContextDecision {
737 let emission = emissions.get(&analysis.seq);
738 let (action, rule_id, risk, reason, handle) = match (&analysis.dup, emission) {
739 (Some(dup), _) => dup_verdict(analysis, *dup, &all[dup.kept_seq], emissions),
740 // Checked before the emission-based arms: a superseded screenshot
741 // (US-009, PR2) is excluded from packing entirely (see
742 // `pack_items`), so `emission` is always `None` here anyway — this
743 // arm exists to give it its own rule id and reason rather than
744 // falling into the generic `externalized_verdict` below.
745 (None, _) if analysis.superseded => superseded_screenshot_verdict(analysis),
746 (None, Some(emission)) if emission.is_full() => full_verdict(analysis),
747 (None, Some(emission)) => partial_verdict(analysis, emission),
748 // A media fragment's single atomic piece is always taken whole or
749 // not at all (see `pieces`), so a missing emission for one means
750 // "did not fit" — never "took none of what was offered" from a
751 // multi-piece fragment. Media externalizes exactly like text
752 // (US-009, PR2): the memory bridge persists the bytes behind the
753 // handle this mints (see `MemoryService::retrieve_context_source`).
754 (None, None) => externalized_verdict(analysis),
755 };
756 ContextDecision {
757 fragment_id: analysis.fragment_id,
758 content_hash: analysis.content_hash,
759 action,
760 rule_id,
761 relevance: analysis.relevance,
762 risk,
763 reason,
764 memory_id: None,
765 handle,
766 }
767}
768
769/// The decision fields shared by every verdict builder.
770type Verdict = (ContextAction, String, FidelityRisk, String, Option<String>);
771
772/// The fidelity risk of content that did not make it fully into the output:
773/// **High** when the classification marked it critical (its loss matters),
774/// **Medium** otherwise. The single source of this policy — shared by the
775/// duplicate, partial, and externalized verdicts.
776fn critical_risk(critical: bool) -> FidelityRisk {
777 if critical {
778 FidelityRisk::High
779 } else {
780 FidelityRisk::Medium
781 }
782}
783
784/// A duplicate: dropped, and honest about whether its content actually
785/// survived. If the kept twin emitted fully the risk is low; if the twin was
786/// truncated or externalized the duplicate's content is *not* in the prompt,
787/// so the decision carries the elevated risk and stays machine-addressable
788/// through its own content-addressed handle.
789fn dup_verdict(
790 analysis: &Analysis,
791 dup: Duplicate,
792 twin: &Analysis,
793 emissions: &BTreeMap<usize, Emission>,
794) -> Verdict {
795 let (rule_id, variant) = match dup.kind {
796 DupKind::Exact => ("drop.duplicate", "exact duplicate"),
797 DupKind::Near => ("drop.near_duplicate", "near-duplicate"),
798 };
799 let twin_full = emissions.get(&twin.seq).is_some_and(Emission::is_full);
800 if twin_full {
801 // Media dedup keys on the image bytes alone: the twin carries the
802 // image, but a caption that differs from the twin's does NOT
803 // survive — say so instead of claiming full survival.
804 let caption_diverges = analysis.media.is_some() && analysis.original != twin.original;
805 let reason = if caption_diverges {
806 format!(
807 "{variant} of fragment #{} — image survives through it; this fragment's differing caption does not",
808 dup.kept_seq
809 )
810 } else {
811 format!(
812 "{variant} of fragment #{} — content survives through it",
813 dup.kept_seq
814 )
815 };
816 return (
817 ContextAction::Drop,
818 rule_id.to_owned(),
819 FidelityRisk::Low,
820 reason,
821 Some(provenance::handle_for(analysis.handle_hash())),
822 );
823 }
824 // Media dedup is otherwise unremarkable here: the twin's bytes were not
825 // fully packed either, but (US-009, PR2) the memory bridge now persists
826 // every non-duplicate fragment's original — media included — so a
827 // duplicate's own handle resolves exactly like a text duplicate's.
828 (
829 ContextAction::Drop,
830 rule_id.to_owned(),
831 critical_risk(analysis.rule.critical),
832 format!(
833 "{variant} of fragment #{} — but that twin was not fully emitted — recover via the handle",
834 dup.kept_seq
835 ),
836 Some(provenance::handle_for(analysis.handle_hash())),
837 )
838}
839
840/// A screenshot the whole-batch pass reclassified
841/// `retrieve.screenshot_superseded` (US-009, PR2 — see
842/// [`classify::screenshot_supersession`]): excluded from packing entirely,
843/// regardless of budget (see [`pack_items`]), because a LATER fragment in
844/// the same request already carries the current state of the same
845/// `metadata.target`. Always gets a resolvable handle — the memory bridge
846/// stores every non-duplicate fragment's original, media included.
847fn superseded_screenshot_verdict(analysis: &Analysis) -> Verdict {
848 (
849 ContextAction::Retrieve,
850 classify::SCREENSHOT_SUPERSEDED_RULE_ID.to_owned(),
851 FidelityRisk::Medium,
852 classify::SCREENSHOT_SUPERSEDED_REASON.to_owned(),
853 Some(provenance::handle_for(analysis.handle_hash())),
854 )
855}
856
857/// Fully emitted: the classification rule's action stands.
858fn full_verdict(analysis: &Analysis) -> Verdict {
859 let risk = if analysis.rule.action == ContextAction::Abstract {
860 FidelityRisk::Medium
861 } else {
862 FidelityRisk::Low
863 };
864 (
865 analysis.rule.action,
866 analysis.rule.id.to_owned(),
867 risk,
868 reason_with_normalization(analysis),
869 None,
870 )
871}
872
873/// Partially emitted: a chunk prefix is in, the rest stays retrievable.
874fn partial_verdict(analysis: &Analysis, emission: &Emission) -> Verdict {
875 (
876 analysis.rule.action,
877 analysis.rule.id.to_owned(),
878 critical_risk(analysis.rule.critical),
879 format!(
880 "{} — packed {}/{} chunks, the rest stays retrievable",
881 reason_with_normalization(analysis),
882 emission.taken,
883 emission.total
884 ),
885 Some(provenance::handle_for(analysis.handle_hash())),
886 )
887}
888
889/// The rule's base reason, with a mention of timestamp normalization
890/// appended when [`CompilePolicy::normalize_log_timestamps`] actually merged
891/// lines for this fragment (see [`Analysis::abstract_collapse`]) — an
892/// auditor asking "why did this log collapse the way it did?" sees the
893/// normalization in the same `reason` string as the rule that fired.
894fn reason_with_normalization(analysis: &Analysis) -> String {
895 match &analysis.abstract_collapse {
896 Some((_, true)) => {
897 format!(
898 "{} — timestamps normalized before collapsing",
899 analysis.rule.reason
900 )
901 }
902 _ => analysis.rule.reason.to_owned(),
903 }
904}
905
906/// Not emitted at all: externalized behind a retrieval handle.
907fn externalized_verdict(analysis: &Analysis) -> Verdict {
908 (
909 ContextAction::Retrieve,
910 "budget.externalize".to_owned(),
911 critical_risk(analysis.rule.critical),
912 format!(
913 "did not fit the budget ({}); retrievable via its handle",
914 analysis.rule.reason
915 ),
916 Some(provenance::handle_for(analysis.handle_hash())),
917 )
918}
919
920/// Relevance floor a [`ContextAction::Retrieve`] decision must clear to
921/// produce a [`ContextWarning`] (V2a-2 quick win). Chosen so the two
922/// existing compile goldens (neither carries a `Retrieve` decision) stay
923/// byte-identical; recalibrate against a wider corpus if warnings prove too
924/// noisy or too quiet in practice.
925const WARNING_RELEVANCE_THRESHOLD: f32 = 0.35;
926
927/// The warnings computed from `decisions` (V2a-2 quick win): every
928/// [`ContextAction::Retrieve`] decision at or above
929/// [`WARNING_RELEVANCE_THRESHOLD`]. Shared by [`ContextCompiler::finish`]
930/// (the pre-memory-annotation value) and the memory bridge, which
931/// recomputes it AFTER `annotate_memory_provenance` may have rewritten a
932/// pulled fragment's `relevance`/`reason` — a warning must never quote a
933/// stale value.
934///
935/// Deliberately scoped to `Retrieve` only, not `Drop`: every `Drop` this
936/// compiler emits is `dup_verdict`'s byte-identical duplicate, whose content
937/// survives through its kept twin — never a real loss, so including it
938/// would just be noise on every dedup.
939pub(crate) fn warnings_for(decisions: &[ContextDecision]) -> Vec<ContextWarning> {
940 decisions
941 .iter()
942 .filter(|decision| {
943 decision.action == ContextAction::Retrieve
944 && decision.relevance >= WARNING_RELEVANCE_THRESHOLD
945 })
946 .map(|decision| ContextWarning {
947 fragment_id: decision.fragment_id,
948 action: decision.action,
949 relevance: decision.relevance,
950 reason: decision.reason.clone(),
951 })
952 .collect()
953}
954
955/// Apply [`CompilePolicy::slim_response`]: empty `sections`/`decisions`,
956/// leaving `content`/`insights`/`risk`/`warnings`/`sources`/`retrieval_handles`
957/// untouched. Split out of [`ContextCompiler::compile`] so the memory bridge
958/// can call it as its own LAST step, after annotating memory provenance and
959/// recomputing `warnings` on the full `decisions` ([`ContextCompiler::compile_raw`]).
960pub(crate) fn apply_slim(mut compiled: CompiledContext, policy: &CompilePolicy) -> CompiledContext {
961 if policy.slim_response {
962 compiled.sections.clear();
963 compiled.decisions.clear();
964 }
965 compiled
966}
967
968/// The handles of every fully externalized fragment, in decision order.
969fn retrieval_handles(analyses: &[Analysis], decisions: &[ContextDecision]) -> Vec<RetrievalHandle> {
970 analyses
971 .iter()
972 .zip(decisions)
973 .filter(|(_, decision)| decision.action == ContextAction::Retrieve)
974 .map(|(analysis, _)| RetrievalHandle {
975 handle: provenance::handle_for(analysis.handle_hash()),
976 fragment_id: analysis.fragment_id,
977 estimated_tokens: analysis.tokens,
978 })
979 .collect()
980}
981
982/// Tokens actually reflected in the output for one fragment. For an
983/// ordinary fragment this is the injected estimator over whatever prefix of
984/// pieces was emitted (unchanged pre-media behavior). For a media fragment
985/// (US-009, PR1) packing is atomic (see `pieces`): an emission's mere
986/// presence already means the *whole* precomputed cost (image +
987/// caption — [`media_fragment_tokens`], the same total [`Analysis::tokens`]
988/// holds) was spent, never a partial text-estimate of the caption alone —
989/// which would misreport a fully preserved image as almost entirely
990/// "saved" whenever its caption happens to be blank.
991fn emitted_tokens(
992 analysis: &Analysis,
993 emissions: &BTreeMap<usize, Emission>,
994 estimator: &dyn TokenEstimator,
995) -> u64 {
996 let Some(emission) = emissions.get(&analysis.seq) else {
997 return 0;
998 };
999 if analysis.media.is_some() {
1000 analysis.tokens
1001 } else {
1002 estimator.estimate(&emission.text)
1003 }
1004}
1005
1006/// Attribute saved tokens to the rule that saved them. A fully emitted
1007/// verbatim fragment saves nothing, so every attribution comes from drops,
1008/// abstractions, externalizations, and partial packs — and the per-rule map
1009/// reconciles with the total up to joiner effects.
1010fn saved_by_rule(
1011 analyses: &[Analysis],
1012 decisions: &[ContextDecision],
1013 emissions: &BTreeMap<usize, Emission>,
1014 estimator: &dyn TokenEstimator,
1015) -> BTreeMap<String, u64> {
1016 let mut by_rule = BTreeMap::new();
1017 for (analysis, decision) in analyses.iter().zip(decisions) {
1018 let emitted = emitted_tokens(analysis, emissions, estimator);
1019 let saved = analysis.tokens.saturating_sub(emitted);
1020 if saved > 0 {
1021 *by_rule.entry(decision.rule_id.clone()).or_insert(0) += saved;
1022 }
1023 }
1024 by_rule
1025}
1026
1027#[cfg(test)]
1028#[path = "context/media_pipeline_tests.rs"]
1029mod media_pipeline_tests;
1030
1031#[cfg(test)]
1032mod chunk_policy_tests {
1033 use super::{effective_chunk_policy, MIN_CHUNK_BYTES};
1034 use crate::context::estimator::HeuristicEstimator;
1035 use crate::context::model::CompilePolicy;
1036
1037 #[test]
1038 fn test_effective_chunk_policy_floors_chunk_size_under_a_tiny_budget() {
1039 // A budget of one usable token must NOT drive the chunk ceiling down
1040 // toward a byte, which would explode a large fragment into one heap
1041 // String per byte (a caller-controlled memory-amplification DoS).
1042 let policy = CompilePolicy::default();
1043 let effective = effective_chunk_policy(&policy, 1, &HeuristicEstimator);
1044 assert!(
1045 effective.max_chunk_bytes >= MIN_CHUNK_BYTES,
1046 "tiny budget drove chunk size to {} bytes, below the {MIN_CHUNK_BYTES}-byte floor",
1047 effective.max_chunk_bytes
1048 );
1049 }
1050
1051 #[test]
1052 fn test_effective_chunk_policy_floors_a_caller_supplied_tiny_chunk_size() {
1053 // A caller cannot bypass the floor by setting a tiny max_chunk_bytes
1054 // in the request policy — the same amplification vector otherwise.
1055 let mut policy = CompilePolicy::default();
1056 policy.chunk.max_chunk_bytes = 1;
1057 let effective = effective_chunk_policy(&policy, 1_000, &HeuristicEstimator);
1058 assert!(
1059 effective.max_chunk_bytes >= MIN_CHUNK_BYTES,
1060 "caller max_chunk_bytes=1 bypassed the {MIN_CHUNK_BYTES}-byte floor, got {}",
1061 effective.max_chunk_bytes
1062 );
1063 }
1064}