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