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;
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            // Query-independent selection tier (issue #1455): see
622            // `budget::selection_order`.
623            cache: analysis.rule.action == ContextAction::Cache,
624            pieces: pieces(analysis, &chunk_policy, estimator),
625        })
626        .collect()
627}
628
629/// The emission pieces of one fragment.
630///
631/// A media fragment (US-009, PR1) is always exactly one atomic, all-or-
632/// nothing piece — never passed to [`chunk_text`], mirroring the
633/// `abstract.log_dedup` case below: packing can take it whole or not at all,
634/// never a byte-range prefix, so an image can never be cut mid-stream. Its
635/// text is only the caption (raw media bytes never become packable "piece"
636/// text); its cost is the precomputed [`media_fragment_tokens`] total, so
637/// packing never re-derives a media fragment's cost from `estimator.estimate`
638/// over an empty or near-empty caption.
639fn pieces(
640    analysis: &Analysis,
641    chunk_policy: &ChunkPolicy,
642    estimator: &dyn TokenEstimator,
643) -> Vec<budget::Piece> {
644    if let Some(media) = &analysis.media {
645        let cost = media_fragment_tokens(media, analysis.original, estimator);
646        return vec![budget::Piece {
647            text: analysis.original.to_owned(),
648            cost: Some(cost),
649        }];
650    }
651    if let Some((collapsed, _normalized)) = &analysis.abstract_collapse {
652        return vec![budget::Piece {
653            text: collapsed.clone(),
654            cost: None,
655        }];
656    }
657    chunk_text(analysis.original, chunk_policy)
658        .into_iter()
659        .map(|chunk| budget::Piece {
660            text: chunk.text,
661            cost: None,
662        })
663        .collect()
664}
665
666/// Lower bound on the pipeline's effective chunk size, regardless of budget
667/// or caller policy. Guards against memory-amplification: without a floor, a
668/// tiny `token_budget` (or a tiny caller-supplied `max_chunk_bytes`) would
669/// drive the ceiling toward one byte and explode a large fragment into one
670/// heap `String` per byte. At 256 bytes the per-piece `String` overhead is
671/// under 10 %, so pieces stay bounded by ~`input_bytes / 256` — no
672/// amplification beyond the already-capped input size ([`crate::limits`]).
673const MIN_CHUNK_BYTES: usize = 256;
674
675/// The chunk policy the pipeline actually cuts with: the ceiling tracks the
676/// usable budget (sized via the estimator's bytes-per-token hint) but is
677/// **floored at [`MIN_CHUNK_BYTES`]** so neither a tiny budget nor a tiny
678/// caller-supplied `max_chunk_bytes` can drive it toward a byte (a
679/// memory-amplification `DoS`). A budget too small to hold a floored piece
680/// simply externalizes everything, which is the correct outcome anyway.
681/// **Overlap is forced to zero** — pipeline pieces are emitted by
682/// concatenation, and an overlap prefix would duplicate every seam in
683/// content reported as verbatim; overlap stays meaningful only for the
684/// standalone [`chunk_text`] API. The byte ceiling is a *hint*: every piece
685/// is still measured by the injected estimator during packing.
686fn effective_chunk_policy(
687    policy: &CompilePolicy,
688    usable: u64,
689    estimator: &dyn TokenEstimator,
690) -> ChunkPolicy {
691    let budget_bytes = usize::try_from(usable.saturating_mul(estimator.bytes_per_token_hint()))
692        .unwrap_or(usize::MAX);
693    ChunkPolicy {
694        max_chunk_bytes: policy
695            .chunk
696            .max_chunk_bytes
697            .min(budget_bytes)
698            .max(MIN_CHUNK_BYTES),
699        overlap_bytes: 0,
700        boundary: policy.chunk.boundary,
701    }
702}
703
704/// Materialize what each packed fragment emits, keyed by `seq`. A fragment
705/// with no pieces at all (empty content) is kept here with `taken == total
706/// == 0` — trivially fully emitted, since there is nothing to lose — rather
707/// than dropped as "took none of what was offered", which is reserved for a
708/// fragment that had pieces and the budget could not fit any of them.
709fn emissions(items: &[PackItem], taken: &[usize]) -> BTreeMap<usize, Emission> {
710    items
711        .iter()
712        .zip(taken.iter().copied())
713        .filter(|&(item, count)| count > 0 || item.pieces.is_empty())
714        .map(|(item, count)| {
715            (
716                item.seq,
717                Emission {
718                    text: item.pieces[..count]
719                        .iter()
720                        .map(|piece| piece.text.as_str())
721                        .collect(),
722                    taken: count,
723                    total: item.pieces.len(),
724                },
725            )
726        })
727        .collect()
728}
729
730/// The output blocks: the cache-marked prefix first, then the body, both in
731/// input order.
732fn sections(analyses: &[Analysis], emissions: &BTreeMap<usize, Emission>) -> Vec<CompiledSection> {
733    let mut result = Vec::new();
734    for kind in [SectionKind::Cache, SectionKind::Body] {
735        let mut blocks: Vec<&str> = Vec::new();
736        let mut ids: Vec<u64> = Vec::new();
737        for analysis in analyses {
738            let cache = analysis.rule.action == ContextAction::Cache;
739            let wanted = (kind == SectionKind::Cache) == cache;
740            // Skip empty emissions: a trivially-emitted empty fragment
741            // (taken == total == 0) still gets its own decision, but must
742            // contribute no block — otherwise `join(JOINER)` would wrap it in
743            // joiners the packer never accounted for, breaking the budget
744            // invariant once more than one empty fragment is present.
745            if let Some(emission) = emissions
746                .get(&analysis.seq)
747                .filter(|emission| wanted && !emission.text.is_empty())
748            {
749                blocks.push(&emission.text);
750                ids.push(analysis.fragment_id);
751            }
752        }
753        if !blocks.is_empty() {
754            result.push(CompiledSection {
755                kind,
756                content: blocks.join(budget::JOINER),
757                fragment_ids: ids,
758            });
759        }
760    }
761    result
762}
763
764/// The auditable decision for one fragment.
765fn decision(
766    analysis: &Analysis,
767    all: &[Analysis],
768    emissions: &BTreeMap<usize, Emission>,
769) -> ContextDecision {
770    let emission = emissions.get(&analysis.seq);
771    let (action, rule_id, risk, reason, handle) = match (&analysis.dup, emission) {
772        (Some(dup), _) => dup_verdict(analysis, *dup, &all[dup.kept_seq], emissions),
773        // Checked before the emission-based arms: a superseded screenshot
774        // (US-009, PR2) is excluded from packing entirely (see
775        // `pack_items`), so `emission` is always `None` here anyway — this
776        // arm exists to give it its own rule id and reason rather than
777        // falling into the generic `externalized_verdict` below.
778        (None, _) if analysis.superseded => superseded_screenshot_verdict(analysis),
779        (None, Some(emission)) if emission.is_full() => full_verdict(analysis),
780        (None, Some(emission)) => partial_verdict(analysis, emission),
781        // A media fragment's single atomic piece is always taken whole or
782        // not at all (see `pieces`), so a missing emission for one means
783        // "did not fit" — never "took none of what was offered" from a
784        // multi-piece fragment. Media externalizes exactly like text
785        // (US-009, PR2): the memory bridge persists the bytes behind the
786        // handle this mints (see `MemoryService::retrieve_context_source`).
787        (None, None) => externalized_verdict(analysis),
788    };
789    ContextDecision {
790        fragment_id: analysis.fragment_id,
791        content_hash: analysis.content_hash,
792        action,
793        rule_id,
794        relevance: analysis.relevance,
795        risk,
796        reason,
797        memory_id: None,
798        handle,
799    }
800}
801
802/// The decision fields shared by every verdict builder.
803type Verdict = (ContextAction, String, FidelityRisk, String, Option<String>);
804
805/// The fidelity risk of content that did not make it fully into the output:
806/// **High** when the classification marked it critical (its loss matters),
807/// **Medium** otherwise. The single source of this policy — shared by the
808/// duplicate, partial, and externalized verdicts.
809fn critical_risk(critical: bool) -> FidelityRisk {
810    if critical {
811        FidelityRisk::High
812    } else {
813        FidelityRisk::Medium
814    }
815}
816
817/// A duplicate: dropped, and honest about whether its content actually
818/// survived. If the kept twin emitted fully the risk is low; if the twin was
819/// truncated or externalized the duplicate's content is *not* in the prompt,
820/// so the decision carries the elevated risk and stays machine-addressable
821/// through its own content-addressed handle.
822fn dup_verdict(
823    analysis: &Analysis,
824    dup: Duplicate,
825    twin: &Analysis,
826    emissions: &BTreeMap<usize, Emission>,
827) -> Verdict {
828    let (rule_id, variant) = match dup.kind {
829        DupKind::Exact => ("drop.duplicate", "exact duplicate"),
830        DupKind::Near => ("drop.near_duplicate", "near-duplicate"),
831    };
832    let twin_full = emissions.get(&twin.seq).is_some_and(Emission::is_full);
833    if twin_full {
834        // Media dedup keys on the image bytes alone: the twin carries the
835        // image, but a caption that differs from the twin's does NOT
836        // survive — say so instead of claiming full survival.
837        let caption_diverges = analysis.media.is_some() && analysis.original != twin.original;
838        let reason = if caption_diverges {
839            format!(
840                "{variant} of fragment #{} — image survives through it; this fragment's differing caption does not",
841                dup.kept_seq
842            )
843        } else {
844            format!(
845                "{variant} of fragment #{} — content survives through it",
846                dup.kept_seq
847            )
848        };
849        return (
850            ContextAction::Drop,
851            rule_id.to_owned(),
852            FidelityRisk::Low,
853            reason,
854            Some(provenance::handle_for(analysis.handle_hash())),
855        );
856    }
857    // Media dedup is otherwise unremarkable here: the twin's bytes were not
858    // fully packed either, but (US-009, PR2) the memory bridge now persists
859    // every non-duplicate fragment's original — media included — so a
860    // duplicate's own handle resolves exactly like a text duplicate's.
861    (
862        ContextAction::Drop,
863        rule_id.to_owned(),
864        critical_risk(analysis.rule.critical),
865        format!(
866            "{variant} of fragment #{} — but that twin was not fully emitted — recover via the handle",
867            dup.kept_seq
868        ),
869        Some(provenance::handle_for(analysis.handle_hash())),
870    )
871}
872
873/// A screenshot the whole-batch pass reclassified
874/// `retrieve.screenshot_superseded` (US-009, PR2 — see
875/// [`classify::screenshot_supersession`]): excluded from packing entirely,
876/// regardless of budget (see [`pack_items`]), because a LATER fragment in
877/// the same request already carries the current state of the same
878/// `metadata.target`. Always gets a resolvable handle — the memory bridge
879/// stores every non-duplicate fragment's original, media included.
880fn superseded_screenshot_verdict(analysis: &Analysis) -> Verdict {
881    (
882        ContextAction::Retrieve,
883        classify::SCREENSHOT_SUPERSEDED_RULE_ID.to_owned(),
884        FidelityRisk::Medium,
885        classify::SCREENSHOT_SUPERSEDED_REASON.to_owned(),
886        Some(provenance::handle_for(analysis.handle_hash())),
887    )
888}
889
890/// Fully emitted: the classification rule's action stands.
891fn full_verdict(analysis: &Analysis) -> Verdict {
892    let risk = if analysis.rule.action == ContextAction::Abstract {
893        FidelityRisk::Medium
894    } else {
895        FidelityRisk::Low
896    };
897    (
898        analysis.rule.action,
899        analysis.rule.id.to_owned(),
900        risk,
901        reason_with_normalization(analysis),
902        None,
903    )
904}
905
906/// Partially emitted: a chunk prefix is in, the rest stays retrievable.
907fn partial_verdict(analysis: &Analysis, emission: &Emission) -> Verdict {
908    (
909        analysis.rule.action,
910        analysis.rule.id.to_owned(),
911        critical_risk(analysis.rule.critical),
912        format!(
913            "{} — packed {}/{} chunks, the rest stays retrievable",
914            reason_with_normalization(analysis),
915            emission.taken,
916            emission.total
917        ),
918        Some(provenance::handle_for(analysis.handle_hash())),
919    )
920}
921
922/// The rule's base reason, with a mention of timestamp normalization
923/// appended when [`CompilePolicy::normalize_log_timestamps`] actually merged
924/// lines for this fragment (see [`Analysis::abstract_collapse`]) — an
925/// auditor asking "why did this log collapse the way it did?" sees the
926/// normalization in the same `reason` string as the rule that fired.
927fn reason_with_normalization(analysis: &Analysis) -> String {
928    match &analysis.abstract_collapse {
929        Some((_, true)) => {
930            format!(
931                "{} — timestamps normalized before collapsing",
932                analysis.rule.reason
933            )
934        }
935        _ => analysis.rule.reason.to_owned(),
936    }
937}
938
939/// Not emitted at all: externalized behind a retrieval handle.
940fn externalized_verdict(analysis: &Analysis) -> Verdict {
941    (
942        ContextAction::Retrieve,
943        "budget.externalize".to_owned(),
944        critical_risk(analysis.rule.critical),
945        format!(
946            "did not fit the budget ({}); retrievable via its handle",
947            analysis.rule.reason
948        ),
949        Some(provenance::handle_for(analysis.handle_hash())),
950    )
951}
952
953/// Relevance floor a [`ContextAction::Retrieve`] decision must clear to
954/// produce a [`ContextWarning`] (V2a-2 quick win). Chosen so the two
955/// existing compile goldens (neither carries a `Retrieve` decision) stay
956/// byte-identical; recalibrate against a wider corpus if warnings prove too
957/// noisy or too quiet in practice.
958const WARNING_RELEVANCE_THRESHOLD: f32 = 0.35;
959
960/// The warnings computed from `decisions` (V2a-2 quick win): every
961/// [`ContextAction::Retrieve`] decision at or above
962/// [`WARNING_RELEVANCE_THRESHOLD`]. Shared by [`ContextCompiler::finish`]
963/// (the pre-memory-annotation value) and the memory bridge, which
964/// recomputes it AFTER `annotate_memory_provenance` may have rewritten a
965/// pulled fragment's `relevance`/`reason` — a warning must never quote a
966/// stale value.
967///
968/// Deliberately scoped to `Retrieve` only, not `Drop`: every `Drop` this
969/// compiler emits is `dup_verdict`'s byte-identical duplicate, whose content
970/// survives through its kept twin — never a real loss, so including it
971/// would just be noise on every dedup.
972pub(crate) fn warnings_for(decisions: &[ContextDecision]) -> Vec<ContextWarning> {
973    decisions
974        .iter()
975        .filter(|decision| {
976            decision.action == ContextAction::Retrieve
977                && decision.relevance >= WARNING_RELEVANCE_THRESHOLD
978        })
979        .map(|decision| ContextWarning {
980            fragment_id: decision.fragment_id,
981            action: decision.action,
982            relevance: decision.relevance,
983            reason: decision.reason.clone(),
984        })
985        .collect()
986}
987
988/// Apply [`CompilePolicy::slim_response`]: empty `sections`/`decisions`,
989/// leaving `content`/`insights`/`risk`/`warnings`/`sources`/`retrieval_handles`
990/// untouched. Split out of [`ContextCompiler::compile`] so the memory bridge
991/// can call it as its own LAST step, after annotating memory provenance and
992/// recomputing `warnings` on the full `decisions` ([`ContextCompiler::compile_raw`]).
993pub(crate) fn apply_slim(mut compiled: CompiledContext, policy: &CompilePolicy) -> CompiledContext {
994    if policy.slim_response {
995        compiled.sections.clear();
996        compiled.decisions.clear();
997    }
998    compiled
999}
1000
1001/// The handles of every fully externalized fragment, in decision order.
1002fn retrieval_handles(analyses: &[Analysis], decisions: &[ContextDecision]) -> Vec<RetrievalHandle> {
1003    analyses
1004        .iter()
1005        .zip(decisions)
1006        .filter(|(_, decision)| decision.action == ContextAction::Retrieve)
1007        .map(|(analysis, _)| RetrievalHandle {
1008            handle: provenance::handle_for(analysis.handle_hash()),
1009            fragment_id: analysis.fragment_id,
1010            estimated_tokens: analysis.tokens,
1011        })
1012        .collect()
1013}
1014
1015/// Tokens actually reflected in the output for one fragment. For an
1016/// ordinary fragment this is the injected estimator over whatever prefix of
1017/// pieces was emitted (unchanged pre-media behavior). For a media fragment
1018/// (US-009, PR1) packing is atomic (see `pieces`): an emission's mere
1019/// presence already means the *whole* precomputed cost (image +
1020/// caption — [`media_fragment_tokens`], the same total [`Analysis::tokens`]
1021/// holds) was spent, never a partial text-estimate of the caption alone —
1022/// which would misreport a fully preserved image as almost entirely
1023/// "saved" whenever its caption happens to be blank.
1024fn emitted_tokens(
1025    analysis: &Analysis,
1026    emissions: &BTreeMap<usize, Emission>,
1027    estimator: &dyn TokenEstimator,
1028) -> u64 {
1029    let Some(emission) = emissions.get(&analysis.seq) else {
1030        return 0;
1031    };
1032    if analysis.media.is_some() {
1033        analysis.tokens
1034    } else {
1035        estimator.estimate(&emission.text)
1036    }
1037}
1038
1039/// Attribute saved tokens to the rule that saved them. A fully emitted
1040/// verbatim fragment saves nothing, so every attribution comes from drops,
1041/// abstractions, externalizations, and partial packs — and the per-rule map
1042/// reconciles with the total up to joiner effects.
1043fn saved_by_rule(
1044    analyses: &[Analysis],
1045    decisions: &[ContextDecision],
1046    emissions: &BTreeMap<usize, Emission>,
1047    estimator: &dyn TokenEstimator,
1048) -> BTreeMap<String, u64> {
1049    let mut by_rule = BTreeMap::new();
1050    for (analysis, decision) in analyses.iter().zip(decisions) {
1051        let emitted = emitted_tokens(analysis, emissions, estimator);
1052        let saved = analysis.tokens.saturating_sub(emitted);
1053        if saved > 0 {
1054            *by_rule.entry(decision.rule_id.clone()).or_insert(0) += saved;
1055        }
1056    }
1057    by_rule
1058}
1059
1060#[cfg(test)]
1061#[path = "context/media_pipeline_tests.rs"]
1062mod media_pipeline_tests;
1063
1064#[cfg(test)]
1065mod chunk_policy_tests {
1066    use super::{effective_chunk_policy, MIN_CHUNK_BYTES};
1067    use crate::context::estimator::HeuristicEstimator;
1068    use crate::context::model::CompilePolicy;
1069
1070    #[test]
1071    fn test_effective_chunk_policy_floors_chunk_size_under_a_tiny_budget() {
1072        // A budget of one usable token must NOT drive the chunk ceiling down
1073        // toward a byte, which would explode a large fragment into one heap
1074        // String per byte (a caller-controlled memory-amplification DoS).
1075        let policy = CompilePolicy::default();
1076        let effective = effective_chunk_policy(&policy, 1, &HeuristicEstimator);
1077        assert!(
1078            effective.max_chunk_bytes >= MIN_CHUNK_BYTES,
1079            "tiny budget drove chunk size to {} bytes, below the {MIN_CHUNK_BYTES}-byte floor",
1080            effective.max_chunk_bytes
1081        );
1082    }
1083
1084    #[test]
1085    fn test_effective_chunk_policy_floors_a_caller_supplied_tiny_chunk_size() {
1086        // A caller cannot bypass the floor by setting a tiny max_chunk_bytes
1087        // in the request policy — the same amplification vector otherwise.
1088        let mut policy = CompilePolicy::default();
1089        policy.chunk.max_chunk_bytes = 1;
1090        let effective = effective_chunk_policy(&policy, 1_000, &HeuristicEstimator);
1091        assert!(
1092            effective.max_chunk_bytes >= MIN_CHUNK_BYTES,
1093            "caller max_chunk_bytes=1 bypassed the {MIN_CHUNK_BYTES}-byte floor, got {}",
1094            effective.max_chunk_bytes
1095        );
1096    }
1097}