Skip to main content

velesdb_memory/
context.rs

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