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