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