velesdb_memory/limits.rs
1//! Resource caps shared by every adapter (the MCP server and the language
2//! bindings).
3//!
4//! These are security-relevant DoS limits. They live here — not inside any one
5//! adapter — so every transport enforces the *same* numbers without a manual
6//! "keep in sync" comment, and so a build without the `mcp` feature still sees
7//! them. Each adapter formats its own transport-native error; only the values
8//! and the clamping policy are shared.
9
10use crate::service::Metadata;
11
12/// Default hop budget for `why` traversal when the caller supplies none.
13pub const DEFAULT_WHY_HOPS: usize = 2;
14
15/// Maximum accepted fact size (1 MiB) — prevents allocating huge embeddings.
16pub const MAX_FACT_BYTES: usize = 1_048_576;
17
18/// Maximum accepted size of a fact that has to be **embedded** (2 KiB).
19///
20/// Much tighter than [`MAX_FACT_BYTES`], and for a different reason: that cap
21/// bounds an *allocation*, this one bounds what an embedding model actually
22/// accepts. The default backend (`all-minilm`, see
23/// [`crate::embedder::DEFAULT_OLLAMA_MODEL`]) has a 512-token context window;
24/// at this crate's own prose rate of roughly 3–4 bytes per token (see
25/// `context::estimator::TokenEstimator::bytes_per_token_hint`) that is about
26/// 2 KiB. Measured against the 0.11.4 daemon: a 2 000-byte fact embeds, an
27/// 8 000-byte one fails with `ollama embeddings call failed` — a raw backend
28/// error naming neither a limit nor the offending size.
29///
30/// A guard rail, not a claim of exactness: a caller running a different
31/// embedding model may have a wider or narrower real window. It turns the
32/// *common* failure into an actionable message instead of an opaque backend
33/// fault, and it sits at the largest size measured to work rather than at a
34/// value that would reject facts the backend accepts today.
35pub const MAX_EMBEDDABLE_TEXT_BYTES: usize = 2048;
36
37/// Maximum accepted size of caller-supplied `metadata` (64 KiB), measured as
38/// its serialized JSON form. Metadata is a keyed lookup facet (project,
39/// author, status, …) — a porte-clés, not a payload — so it gets a much
40/// tighter ceiling than [`MAX_FACT_BYTES`]: without one, a caller could smuggle
41/// an arbitrarily large JSON blob through `metadata` on every write path
42/// (`remember`, `remember_with_ttl`, `remember_extracted`, and each
43/// context-compiler fragment's own `metadata`) and force the same unbounded
44/// allocation and storage growth the fact-size cap exists to prevent.
45pub const MAX_METADATA_BYTES: usize = 64 * 1024;
46
47/// The serialized JSON size of `meta`, in bytes. Returns `usize::MAX` if the
48/// map somehow fails to serialize (it never should — `Metadata` is always
49/// valid JSON), so a serialization hiccup fails a size check closed rather
50/// than silently passing an unmeasured payload.
51#[must_use]
52pub fn metadata_bytes(meta: &Metadata) -> usize {
53 serde_json::to_vec(meta).map_or(usize::MAX, |v| v.len())
54}
55
56/// Cap on a `recall` limit — prevents unbounded vector scans (core does not
57/// cap `k`, so the adapters do).
58pub const MAX_RECALL_LIMIT: usize = 1_000;
59
60/// Cap on `why`/`recall_fused` hop depth. Bounds DEPTH only: an entity hub
61/// reached at any hop is, by construction, a super-node whose degree scales
62/// with the whole store, so this alone does not bound how much a walk
63/// returns — see [`MAX_WHY_NODE_DEGREE`] and [`MAX_WHY_NODES`] for the width
64/// budget (issue #1743: a hub dumped its entire neighborhood, full fact
65/// content included, into a single response).
66pub const MAX_WHY_HOPS: usize = 10;
67
68/// Maximum outgoing edges a `why`/`recall_fused` graph walk follows from any
69/// ONE node. Without this, expanding a single entity hub pushes one edge
70/// (and, for each unseen target, a full-content node) per fact that ever
71/// mentioned it — the walk's cost is then `O(store size)` at a single hop,
72/// no matter how shallow [`MAX_WHY_HOPS`] is set.
73pub const MAX_WHY_NODE_DEGREE: usize = 64;
74
75/// Maximum number of nodes one `why`/`recall_fused` graph walk may collect,
76/// seed included. [`MAX_WHY_NODE_DEGREE`] bounds any single node's
77/// contribution; this bounds the walk's total size across every node it
78/// expands, so many hubs each under the per-node cap still cannot together
79/// grow a response past a fixed ceiling.
80///
81/// An exact ceiling, enforced at the push site: the expansion that reaches it
82/// stops mid-node. Checking only between expansions read as the same
83/// guarantee but let the crossing expansion finish its whole degree first —
84/// a measured 522 nodes of a documented 500.
85pub const MAX_WHY_NODES: usize = 500;
86
87/// Maximum edges one `why`/`recall_fused` graph walk may record.
88///
89/// [`MAX_WHY_NODES`] alone does not bound a response: every edge FOLLOWED is
90/// recorded even when its target is already visited, so a dense subgraph far
91/// under the node budget can still return on the order of
92/// `nodes x MAX_WHY_NODE_DEGREE` edges — tens of thousands at the caps, a
93/// multi-megabyte response, which is the other half of what issue #1743
94/// asked to bound ("nombre maximal de noeuds et d'aretes retournes"). Four
95/// edges per node of budget covers a spanning forest (which needs fewer than
96/// one edge per node) plus three cross-links per node on top — a fixed
97/// ceiling on the response, not a tuning knob.
98pub const MAX_WHY_EDGES: usize = MAX_WHY_NODES * 4;
99
100/// Bound on the background autograph queue (#1846): how many just-stored
101/// facts may wait for their graph enrichment before new enrichments are
102/// DROPPED (counted by `MemoryService::autograph_dropped`, logged, never
103/// blocking the write path). 64 outstanding generations is already minutes
104/// of extractor work — a burst deeper than this is the extractor falling
105/// behind for good, and stalling `remember` behind it is exactly what
106/// #1846 removed. The fact itself is always stored; a dropped enrichment
107/// is rebuilt by re-remembering.
108pub const MAX_AUTOGRAPH_QUEUE: usize = 64;
109
110/// Maximum typed edges an `entity` profile resolves and returns PER
111/// DIRECTION (`relations` and `relations_in` each) — the same width grammar
112/// as [`MAX_WHY_NODE_DEGREE`], on the surface #1743 never covered: resolving
113/// an entity followed every non-scaffolding edge of its hub, full target
114/// content included, so `entity("X")` on a name mentioned by thousands of
115/// facts was a constructible multi-megabyte response (#1820). Truncation is
116/// REPORTED (`relations_truncated`/`relations_in_truncated` on the profile),
117/// never silent: a profile with exactly this many edges is otherwise
118/// indistinguishable from a cut one.
119pub const MAX_ENTITY_RELATIONS: usize = 64;
120
121/// Maximum RAW edges an `entity` profile may scan per direction while
122/// looking for its [`MAX_ENTITY_RELATIONS`] typed ones. A hub's edges are
123/// mostly bipartite scaffolding (`mentions`/`about`, one per mentioning
124/// fact), filtered out AFTER the store hands them over — so the resolution
125/// cap alone would leave the scan O(degree), and a scan capped at the
126/// resolution cap would return scaffolding-only windows on any busy hub.
127/// Two named numbers, one per concern: this one bounds the transient scan
128/// (the #1743 cost class), the other bounds the resolved response. A typed
129/// edge sitting past this window is not found — that blindness is declared
130/// by the same truncation flags, not masked.
131pub const MAX_ENTITY_SCAN_EDGES: usize = 4_096;
132
133/// Maximum accepted size of a single context-compiler fragment (1 MiB, the
134/// same ceiling as [`MAX_FACT_BYTES`]) — prevents a single fragment from
135/// forcing huge allocations in the compile pipeline.
136pub const MAX_FRAGMENT_BYTES: usize = 1_048_576;
137
138/// Cap on the number of fragments in one compile request — bounds the work a
139/// single call can demand across every adapter.
140pub const MAX_FRAGMENTS: usize = 1_024;
141
142/// Maximum accepted size of a fragment's base64-encoded media payload
143/// (US-009, PR1: inline images) — 4 MiB of base64 text, roughly 3 MiB of raw
144/// bytes once decoded. Deliberately separate from [`MAX_FRAGMENT_BYTES`],
145/// which only ever measures [`crate::context::model::ContextFragment::content`]
146/// (the caption): a screenshot is not text, and capping it at the 1 MiB text
147/// ceiling would reject ordinary screenshots outright. Measured against
148/// `bytes_b64.len()` (the encoded string), so the cap can reject an
149/// oversized payload before any base64 decoding is attempted.
150pub const MAX_MEDIA_BYTES: usize = 4 * 1024 * 1024;
151
152/// Aggregate cap on ALL media payloads of one request (base64 length,
153/// summed). Without it, `MAX_FRAGMENTS` fragments each at [`MAX_MEDIA_BYTES`]
154/// would let a single request carry 4 GiB of media — far past the ~1 GiB
155/// worst case the text caps allow. 64 MiB comfortably fits a real
156/// screenshot-heavy session while bounding decode work.
157pub const MAX_TOTAL_MEDIA_BYTES: usize = 64 * 1024 * 1024;
158
159/// Maximum accepted size of a single file read through a `path`-referenced
160/// context fragment (V2b-1 path ingestion) — 1 MiB, the same ceiling as
161/// [`MAX_FRAGMENT_BYTES`]: an ingested file becomes an ordinary fragment's
162/// `content`, so it must not exceed what a fragment is allowed to carry.
163/// Checked from `fs::metadata` BEFORE the file is read, and re-checked after
164/// (`fs::read` can race a concurrent write) — never clamped, always refused,
165/// so a truncated read can never silently masquerade as the whole file.
166pub const MAX_INGEST_FILE_BYTES: usize = 1_048_576;
167
168/// Maximum number of `path`-referenced fragments accepted in one compile
169/// request — bounds the filesystem work (and open-file churn) a single call
170/// can demand, symmetric to [`MAX_FRAGMENTS`] for inline fragments.
171pub const MAX_INGEST_FILES: usize = 64;
172
173/// Aggregate cap on the bytes read across every `path`-referenced fragment of
174/// one request (64 MiB) — symmetric to [`MAX_TOTAL_MEDIA_BYTES`]. Without it,
175/// [`MAX_INGEST_FILES`] fragments each at [`MAX_INGEST_FILE_BYTES`] would
176/// still admit 64 MiB (the two caps happen to coincide at these values), but
177/// this cap is checked independently and first — a future change to either
178/// per-item constant must not silently loosen the aggregate ceiling.
179pub const MAX_TOTAL_INGEST_BYTES: usize = 64 * 1024 * 1024;
180
181/// Maximum accepted size of a `compile_transcript` transcript (V2b-2), inline
182/// or `path`-referenced — 8 MiB. The ONE caller-facing shape allowed to read
183/// past the ordinary [`MAX_INGEST_FILE_BYTES`]/[`MAX_FRAGMENT_BYTES`] 1 MiB
184/// ceiling: a transcript is segmented into sub-1-MiB pieces immediately after
185/// being read (see `context::segment`), so it is never itself compiled as one
186/// oversized fragment — only the raw pre-segmentation read gets the wider cap.
187pub const MAX_TRANSCRIPT_BYTES: usize = 8 * 1024 * 1024;
188
189/// Cap on a caller-supplied token budget. A budget cannot force allocations
190/// by itself, but an absurd value would make the savings arithmetic
191/// meaningless, so adapters clamp to this ceiling instead of erroring.
192pub const MAX_TOKEN_BUDGET: u64 = 10_000_000;
193
194/// Clamp a caller-supplied token budget to [`MAX_TOKEN_BUDGET`].
195#[must_use]
196pub fn clamp_token_budget(budget: u64) -> u64 {
197 budget.min(MAX_TOKEN_BUDGET)
198}
199
200/// Clamp a caller-supplied recall limit to [`MAX_RECALL_LIMIT`].
201#[must_use]
202pub fn clamp_recall_limit(k: usize) -> usize {
203 k.min(MAX_RECALL_LIMIT)
204}
205
206/// Clamp a caller-supplied `why` hop budget to [`MAX_WHY_HOPS`].
207#[must_use]
208pub fn clamp_hops(hops: usize) -> usize {
209 hops.min(MAX_WHY_HOPS)
210}