Skip to main content

velesdb_memory/context/
model.rs

1//! Data model of the context compiler: the request/response value types.
2//!
3//! Like [`crate::model`], these are pure data with `Serialize`/`Deserialize` +
4//! `JsonSchema` derives, so the domain types double as the MCP wire types —
5//! no duplicate DTO layer. Invariants the compiler upholds over these shapes:
6//! same request ⇒ byte-identical [`CompiledContext`] (determinism), and the
7//! assembled content never exceeds the request's token budget.
8
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11use serde_json::{Map, Value};
12
13use super::chunk::ChunkPolicy;
14use super::insights::CompilationInsights;
15
16/// What the compiler decided to do with one fragment.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
18#[serde(rename_all = "lowercase")]
19pub enum ContextAction {
20    /// Emitted verbatim — critical content (code, constraints, exact values).
21    Preserve,
22    /// Emitted as a deterministic structured reduction (never a generative
23    /// summary) — e.g. repeated log lines collapsed with a count.
24    Abstract,
25    /// Not emitted, but recoverable through its `ctx://source/<id>` handle.
26    Retrieve,
27    /// Not emitted and not externalized — redundant content (duplicates).
28    Drop,
29    /// Emitted verbatim at the front of the output, forming a stable prefix
30    /// that maximizes provider prompt-cache hits across compilations.
31    Cache,
32}
33
34/// How much fidelity a compiled context may have lost versus its input.
35///
36/// Ordered: `Low < Medium < High`, so callers can compare against a policy
37/// threshold.
38#[derive(
39    Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
40)]
41#[serde(rename_all = "lowercase")]
42pub enum FidelityRisk {
43    /// Nothing was lost: everything fit, only exact duplicates were dropped.
44    #[default]
45    Low,
46    /// Recoverable reductions happened: abstractions, or non-critical
47    /// fragments externalized behind retrieval handles.
48    Medium,
49    /// Critical content (a preserve-classified fragment) could not be packed
50    /// — the caller should consider retrieving it or raising the budget.
51    High,
52}
53
54/// Inline media payload attached to a [`ContextFragment`] (US-009, PR1:
55/// screenshots/images only). `ContextFragment::content` stays the
56/// text/caption — often empty for a bare screenshot — while the pixels live
57/// here, base64-encoded so the JSON wire never needs a binary frame.
58///
59/// The fragment packs atomically (see [`super::pieces`] in the compiler)
60/// and its token cost comes from [`super::estimator::ImageTokenEstimator`].
61/// A media fragment that cannot fit the budget is externalized behind a
62/// `ctx://source` handle exactly like text (US-009, PR2: the memory bridge
63/// persists the bytes behind it — see
64/// [`crate::MemoryService::retrieve_context_source`]); the memoryless core
65/// compiler mints the same handle either way, since it never knows whether
66/// a resolver is attached. See the crate README's "media fragments"
67/// section.
68#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
69pub struct MediaRef {
70    /// Declared MIME type (e.g. `"image/png"`, `"image/jpeg"`). Only PNG and
71    /// JPEG headers are sniffed for dimensions; any other value (or an
72    /// unreadable header) falls back to a deterministic, safe over-count
73    /// (see [`super::estimator::ImageTokenEstimator`]) — never rejected for
74    /// an unrecognized mime alone.
75    pub mime: String,
76    /// The raw media bytes, base64-encoded (standard alphabet, padded).
77    /// Capped at [`crate::limits::MAX_MEDIA_BYTES`] and validated for
78    /// well-formedness at compile time — a request carrying an oversized or
79    /// malformed payload is rejected before any other work.
80    pub bytes_b64: String,
81}
82
83/// The resolved original behind a `ctx://source/<hash>` handle (US-002:
84/// text sources; US-009 PR2 extends this with the fragment's inline media,
85/// when it carried one). `media` is `#[serde(default)]`: every source
86/// stored before PR2, and every text-only fragment since, round-trips with
87/// `media: None` — the exact pre-PR2 shape for a caller reading only
88/// `.content`.
89#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
90pub struct ContextSource {
91    /// The original fragment content, byte for byte (a media fragment's
92    /// caption — often empty for a bare screenshot).
93    pub content: String,
94    /// The original media payload, when the fragment carried one.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub media: Option<MediaRef>,
97}
98
99/// One unit of caller-supplied context to compile.
100#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
101#[schemars(transform = crate::schema::strip_int_formats)]
102pub struct ContextFragment {
103    /// Caller-side identifier. When absent, the compiler derives a stable
104    /// content-addressed id (see [`super::fragment_id`]). Accepts a JSON
105    /// number or a decimal string on input (see
106    /// [`super::wire::deserialize_optional_id`]) — a caller that got a
107    /// `fragment_id` back as a string (e.g. under
108    /// [`CompilePolicy::ids_as_strings`]) can resubmit it unchanged.
109    #[serde(
110        default,
111        skip_serializing_if = "Option::is_none",
112        deserialize_with = "super::wire::deserialize_optional_id"
113    )]
114    pub id: Option<u64>,
115    /// The fragment text.
116    pub content: String,
117    /// Free-form kind hint (`"code"`, `"log"`, `"prose"`, …) — classification
118    /// works without it, but honors it when present.
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub kind: Option<String>,
121    /// Caller priority, higher packs first (default `0`).
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub priority: Option<u8>,
124    /// Caller metadata. Recognized keys: `"verbatim": true` forces
125    /// [`ContextAction::Preserve`]; `"cache": true` forces
126    /// [`ContextAction::Cache`].
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub metadata: Option<Map<String, Value>>,
129    /// Inline media payload (US-009, PR1). `None` (the default) keeps every
130    /// pre-0.9.0 request wire-compatible. When set, the fragment packs as one
131    /// atomic piece — see [`MediaRef`].
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub media: Option<MediaRef>,
134}
135
136/// Which memories the compiler may pull in alongside the caller's fragments.
137/// Consumed by the memory bridge (US-002); carried in the request shape from
138/// the start so the wire contract does not change when it lands.
139#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
140#[schemars(transform = crate::schema::strip_int_formats)]
141pub struct MemoryScope {
142    /// Restrict recalled memories to this project facet.
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub project: Option<String>,
145    /// How many memories to consider (adapter-clamped).
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub k: Option<usize>,
148    /// Graph-walk depth of the fused recall (default 2). Deeper hops reach
149    /// longer cause/fix chains from the vector seed.
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub hops: Option<usize>,
152    /// Fusion weight added to graph-reached memories (default 0.15). Raise
153    /// it (e.g. `0.5`–`0.8`) when pulling from curated fact chains built
154    /// with `relate`: evidence that shares **no vocabulary** with the query
155    /// can then out-rank lexically-noisy near-misses — the tri-engine's
156    /// answer to the purely lexical relevance of caller fragments.
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub graph_boost: Option<f64>,
159}
160
161/// Usage-driven importance weights of the memory-bridge blend (US-002 of
162/// EPIC-P-071): how much a pulled memory's learned RL confidence and its
163/// batch-relative recency tilt the fused similarity ranking.
164///
165/// The blend only ever applies to the pool the fused vector+graph similarity
166/// already selected — confidence is *not* relevance, so a heavily reinforced
167/// but off-topic fact can never enter the pool through these weights. Per
168/// pulled memory the ranking key becomes
169/// `fused_norm + confidence_weight·(confidence − 0.5)·2 + recency_weight·recency_norm`,
170/// clock-free and deterministic (recency is min-max normalised **within the
171/// pulled batch**, never against wall time).
172///
173/// Both weights at `0.0` disable the blend entirely: the output is
174/// byte-identical to the 0.8.0 behaviour (pinned by a golden test). The
175/// defaults are **active** on purpose — upgrading from 0.8.0 with the
176/// default policy, RL-reinforced memories rank higher out of the box; zero
177/// the weights to restore the exact 0.8.0 ordering.
178///
179/// Recommended range for both weights: `[0.0, 1.0]` (at `1.0` a term can
180/// fully offset the similarity gap within the pool). Values outside that
181/// range are **accepted verbatim, never clamped** — a negative weight
182/// deliberately inverts its term (e.g. demote reinforced facts), a weight
183/// above `1.0` lets the term dominate similarity. Only the recorded
184/// decision `relevance` is clamped into `[0, 1]`; the ranking itself uses
185/// the raw blended score.
186#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
187#[serde(default)]
188pub struct ImportanceWeights {
189    /// Weight of the learned RL confidence (`_veles_rl_*`, fed by
190    /// [`feedback`](crate::MemoryService::feedback)). A memory with no
191    /// feedback history counts as the neutral `0.5`, contributing exactly
192    /// `0`. Default `0.2`.
193    pub confidence: f64,
194    /// Weight of the batch-relative recency term. Inert unless
195    /// [`Self::recency_field`] is also set. Default `0.1`.
196    pub recency: f64,
197    /// Caller metadata key holding each memory's **numeric** timestamp-like
198    /// value. `None` (the default) disables the recency term completely —
199    /// there is no standard key to guess. The scale must be monotone and
200    /// homogeneous across the batch (e.g. `YYYYMMDD` integers as in
201    /// [`crate::format_dated_context`], or an epoch); it is documented, not
202    /// verified at run time. Values are min-max normalised over the pulled
203    /// memories that carry the key; a memory without the key contributes `0`
204    /// (never penalised), and a degenerate batch (`max == min`) contributes
205    /// `0` for all.
206    #[serde(skip_serializing_if = "Option::is_none")]
207    pub recency_field: Option<String>,
208}
209
210impl Default for ImportanceWeights {
211    fn default() -> Self {
212        Self {
213            confidence: 0.2,
214            recency: 0.1,
215            recency_field: None,
216        }
217    }
218}
219
220/// Tuning knobs of one compilation. `Default` is the recommended profile.
221#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
222#[serde(default)]
223#[schemars(transform = crate::schema::strip_int_formats)]
224#[allow(clippy::struct_excessive_bools)]
225pub struct CompilePolicy {
226    /// Tokens kept aside for the model's answer; the compiler packs into
227    /// `token_budget − response_reserve_tokens`. Default `0`: the caller
228    /// knows their generation length, the compiler does not guess it.
229    pub response_reserve_tokens: u64,
230    /// Collapse near-duplicates (case/whitespace variants) in addition to
231    /// exact duplicates. Default `true`.
232    pub near_dup_dedup: bool,
233    /// Rule ids to disable (e.g. `"abstract.log_dedup"`). Disabled rules are
234    /// skipped during classification; their fragments fall through to the
235    /// next matching rule.
236    pub disabled_rules: Vec<String>,
237    /// How oversized fragments are split before packing. Only
238    /// [`ChunkPolicy::max_chunk_bytes`] and [`ChunkPolicy::boundary`] apply
239    /// here — the compiler forces `overlap_bytes` to `0`, since it emits
240    /// pieces by concatenation and an overlap prefix would duplicate content
241    /// reported as verbatim. `overlap_bytes` is honoured only by the
242    /// standalone [`crate::context::chunk::chunk_text`] API.
243    pub chunk: ChunkPolicy,
244    /// Memory bridge only: record a compilation event (metadata and hashes,
245    /// **never fragment content**) so savings stay aggregatable. Default
246    /// `true`; set `false` to opt out entirely.
247    pub record_events: bool,
248    /// Memory bridge only: store each distinct fragment's original (as an
249    /// internal system fact, invisible to normal recall) so its
250    /// `ctx://source/<hash>` handle round-trips. Default `true`.
251    pub store_sources: bool,
252    /// TTL applied to stored sources (`None` keeps them until forgotten).
253    #[serde(skip_serializing_if = "Option::is_none")]
254    pub source_ttl_seconds: Option<u64>,
255    /// TTL applied to compilation events (`None` keeps them).
256    #[serde(skip_serializing_if = "Option::is_none")]
257    pub event_ttl_seconds: Option<u64>,
258    /// Caller-supplied pricing table so the insights also report the
259    /// estimated cost avoided for [`super::model::CompileRequest::target_model`] — the
260    /// **wire channel** for cost accounting (MCP and the bindings cannot
261    /// reach the Rust-only [`super::ContextCompiler::with_pricing`] builder).
262    /// Takes precedence over a builder-injected table. `None` (default)
263    /// reports tokens only.
264    #[serde(skip_serializing_if = "Option::is_none")]
265    pub pricing: Option<super::insights::PricingTable>,
266    /// Memory bridge only: usage-driven importance blend over the pulled
267    /// memories (RL confidence + batch-relative recency). The struct-level
268    /// `#[serde(default)]` keeps 0.8.0 requests wire-compatible.
269    pub importance: ImportanceWeights,
270    /// Opt-in, deterministic: before `abstract.log_dedup` groups a `kind =
271    /// "log"` fragment's repeated lines, mask each line's volatile prefix
272    /// (ISO/syslog timestamps, bracketed hex/pid counters) with **fixed**
273    /// patterns — never a caller-supplied regex, so the collapse stays
274    /// reproducible — so lines identical modulo timestamp collapse into one
275    /// annotated line instead of surviving as distinct entries. The emitted
276    /// line is still the first occurrence's exact bytes; only the grouping
277    /// key changes. Default `false`: masking is opt-in because it changes
278    /// what "duplicate" means for logs, so callers who rely on the previous
279    /// byte-exact grouping keep it unless they ask. See the crate README's
280    /// "Normalizing timestamped logs" section for the exact patterns.
281    pub normalize_log_timestamps: bool,
282    /// Wire-compat opt-in for the MCP context tools (`compile_context`,
283    /// `explain_compilation`): when `true`, every [`super::wire::ID_KEYS`]
284    /// field of the RESPONSE (`fragment_id`, `content_hash`, `memory_id`,
285    /// `fragment_ids`) is rewritten into its decimal-string form, through
286    /// the exact same tree walk the Node and WASM bindings already apply on
287    /// every response ([`super::wire::stringify_id_fields`]). A raw MCP
288    /// client — one that talks JSON-RPC directly, without either binding —
289    /// parses ids as JS `number`s (IEEE-754 doubles), which silently lose
290    /// precision above 2^53; string ids round-trip exactly. Default
291    /// `false`: existing MCP clients keep today's byte-identical numeric
292    /// response unless they opt in.
293    pub ids_as_strings: bool,
294}
295
296impl Default for CompilePolicy {
297    fn default() -> Self {
298        Self {
299            response_reserve_tokens: 0,
300            near_dup_dedup: true,
301            disabled_rules: Vec::new(),
302            chunk: ChunkPolicy::default(),
303            record_events: true,
304            store_sources: true,
305            source_ttl_seconds: None,
306            event_ttl_seconds: None,
307            pricing: None,
308            importance: ImportanceWeights::default(),
309            normalize_log_timestamps: false,
310            ids_as_strings: false,
311        }
312    }
313}
314
315/// Aggregated savings over the recorded compilation events (memory bridge).
316#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
317#[schemars(transform = crate::schema::strip_int_formats)]
318pub struct ContextSavings {
319    /// Number of compilation events aggregated.
320    pub events: u64,
321    /// Sum of estimated input tokens across events.
322    pub tokens_in: u64,
323    /// Sum of estimated output tokens across events.
324    pub tokens_out: u64,
325    /// Sum of estimated tokens saved across events.
326    pub tokens_saved: u64,
327    /// Estimated cost avoided, in micro-units, keyed by currency (events
328    /// priced under different pricing tables never silently mix).
329    pub cost_saved_micros_by_currency: std::collections::BTreeMap<String, u64>,
330    /// `true` when the aggregation hit the recall cap
331    /// ([`crate::limits::MAX_RECALL_LIMIT`]) — older events beyond the cap
332    /// were not folded in.
333    pub truncated: bool,
334}
335
336/// A full compile request: what to compile, under which budget, for whom.
337#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
338#[schemars(transform = crate::schema::strip_int_formats)]
339pub struct CompileRequest {
340    /// What the agent is working on — drives relevance scoring.
341    pub query: String,
342    /// The context fragments to compile.
343    pub fragments: Vec<ContextFragment>,
344    /// Project facet, recorded in provenance and used by the memory bridge.
345    #[serde(default, skip_serializing_if = "Option::is_none")]
346    pub project: Option<String>,
347    /// Target model name — selects the row of the pricing table
348    /// ([`CompilePolicy::pricing`] on the wire, or the Rust
349    /// [`super::ContextCompiler::with_pricing`] builder) for cost insights.
350    /// Without a table, insights report tokens only.
351    #[serde(default, skip_serializing_if = "Option::is_none")]
352    pub target_model: Option<String>,
353    /// Hard token ceiling for the assembled content.
354    pub token_budget: u64,
355    /// Which memories may be pulled in (US-002; ignored by the memoryless core).
356    #[serde(default, skip_serializing_if = "Option::is_none")]
357    pub memory_scope: Option<MemoryScope>,
358    /// Per-request policy override; `None` uses the compiler's policy.
359    #[serde(default, skip_serializing_if = "Option::is_none")]
360    pub policy: Option<CompilePolicy>,
361}
362
363/// Where a section sits in the assembled output.
364#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
365#[serde(rename_all = "lowercase")]
366pub enum SectionKind {
367    /// The stable, cache-marked prefix.
368    Cache,
369    /// The main compiled body.
370    Body,
371}
372
373/// One contiguous block of the assembled output.
374#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
375#[schemars(transform = crate::schema::strip_int_formats)]
376pub struct CompiledSection {
377    /// Which block this is.
378    pub kind: SectionKind,
379    /// The block's text (verbatim slice of [`CompiledContext::content`]).
380    pub content: String,
381    /// Ids of the fragments emitted into this block, in emission order.
382    pub fragment_ids: Vec<u64>,
383}
384
385/// A pointer from a compiled output back to one original fragment.
386#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
387#[schemars(transform = crate::schema::strip_int_formats)]
388pub struct SourceReference {
389    /// The fragment this source refers to.
390    pub fragment_id: u64,
391    /// Recoverable address of the original content (`ctx://source/<id>`).
392    pub handle: String,
393    /// The memory backing this source, when it came from recall (US-002).
394    #[serde(default, skip_serializing_if = "Option::is_none")]
395    pub memory_id: Option<u64>,
396}
397
398/// A not-emitted fragment the caller can fetch back on demand.
399#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
400#[schemars(transform = crate::schema::strip_int_formats)]
401pub struct RetrievalHandle {
402    /// Recoverable address of the original content (`ctx://source/<id>`).
403    pub handle: String,
404    /// The fragment behind the handle.
405    pub fragment_id: u64,
406    /// Estimated token cost of re-injecting the full original.
407    pub estimated_tokens: u64,
408}
409
410/// The auditable record of what happened to one fragment and why.
411#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
412#[schemars(transform = crate::schema::strip_int_formats)]
413pub struct ContextDecision {
414    /// The fragment this decision is about (caller id, or content-derived).
415    pub fragment_id: u64,
416    /// Content hash of the *original* fragment text (FNV-1a 64, the crate's
417    /// [`stable id`](super::fragment_id)) — lets an auditor prove which exact
418    /// bytes the decision covered even when the caller supplied its own id.
419    pub content_hash: u64,
420    /// What was done.
421    pub action: ContextAction,
422    /// The stable id of the rule that decided (e.g. `"preserve.code_fence"`).
423    pub rule_id: String,
424    /// Lexical relevance of the fragment to the request query, in `[0, 1]`.
425    pub relevance: f32,
426    /// Fidelity risk this single decision contributes.
427    pub risk: FidelityRisk,
428    /// Human-readable explanation of the decision.
429    pub reason: String,
430    /// The memory backing this fragment, when it came from recall (US-002).
431    #[serde(default, skip_serializing_if = "Option::is_none")]
432    pub memory_id: Option<u64>,
433    /// Recoverable address of the original content, when not fully emitted.
434    #[serde(default, skip_serializing_if = "Option::is_none")]
435    pub handle: Option<String>,
436}
437
438/// The compiler's output: the assembled context plus its full audit trail.
439#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
440#[schemars(transform = crate::schema::strip_int_formats)]
441pub struct CompiledContext {
442    /// The assembled context, ready to inject into a prompt.
443    pub content: String,
444    /// The output split into ordered blocks (cache prefix first).
445    pub sections: Vec<CompiledSection>,
446    /// One decision per input fragment (duplicates included).
447    pub decisions: Vec<ContextDecision>,
448    /// One source pointer per distinct fragment.
449    pub sources: Vec<SourceReference>,
450    /// Handles for the fragments that were externalized, not emitted.
451    pub retrieval_handles: Vec<RetrievalHandle>,
452    /// Token (and optional cost) savings of this compilation.
453    pub insights: CompilationInsights,
454    /// Overall fidelity risk (the max over all decisions).
455    pub risk: FidelityRisk,
456}
457
458/// One asserted fact inside a [`WorkingContext`].
459#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
460pub struct ContextFact {
461    /// The fact text.
462    pub text: String,
463    /// Where the fact came from, when known.
464    #[serde(default, skip_serializing_if = "Option::is_none")]
465    pub source: Option<SourceReference>,
466}
467
468/// A lightweight pointer to a past [`ContextDecision`].
469#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
470#[schemars(transform = crate::schema::strip_int_formats)]
471pub struct ContextDecisionRef {
472    /// The fragment the decision was about.
473    pub fragment_id: u64,
474    /// The rule that decided.
475    pub rule_id: String,
476}
477
478/// The distilled working state of an agent session — small enough to carry
479/// across sessions, structured enough to resume from. Persisted and reloaded
480/// by the memory bridge (US-002) under `type = working_context` metadata.
481#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
482pub struct WorkingContext {
483    /// What the session is trying to achieve.
484    #[serde(default, skip_serializing_if = "Option::is_none")]
485    pub goal: Option<String>,
486    /// Constraints currently in force (never compressed away).
487    #[serde(default)]
488    pub active_constraints: Vec<ContextFact>,
489    /// Facts that were verified, with their sources.
490    #[serde(default)]
491    pub verified_facts: Vec<ContextFact>,
492    /// Hypotheses still open.
493    #[serde(default)]
494    pub open_hypotheses: Vec<ContextFact>,
495    /// Decisions taken so far.
496    #[serde(default)]
497    pub decisions: Vec<ContextDecisionRef>,
498    /// Exact evidence the session relies on (verbatim, addressable).
499    #[serde(default)]
500    pub exact_evidence: Vec<SourceReference>,
501    /// Actions still to do.
502    #[serde(default)]
503    pub pending_actions: Vec<String>,
504}