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