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