khive_runtime/cost_unit.rs
1//! ADR-103 Amendment 1: deterministic `cost_unit` for the per-dispatch
2//! audit-row `resource` payload enrichment.
3//!
4//! `cost_unit = base_weight(verb) + per_item_weight(verb) x item_count x
5//! model_count`, computed with checked `i64` arithmetic and clamped to
6//! `i64::MAX` on overflow rather than omitted (ADR-103 Amendment 1 Part 1).
7//!
8//! Amendment 1 commits only to the formula's *shape* and to
9//! `per_item_weight(verb) = 0` for every verb outside its closed
10//! embedding-bearing family. The `base_weight` / `per_item_weight`
11//! magnitudes are left as "deterministic, hand-set constants ... fixed at
12//! implementation time and not measured". This module ships `base_weight =
13//! 1` uniformly across every verb and `per_item_weight = 1` for every
14//! embedding-bearing verb, as the documented default pending a dedicated
15//! per-verb weights table (see the PR body that introduced this module).
16
17use serde_json::Value;
18
19/// True when `verb` is one of ADR-103 Amendment 1's closed
20/// embedding-bearing verb families, given the request's own top-level
21/// params.
22///
23/// `params` is needed only to tell a singleton `create` from a bulk
24/// `create(items=[...])`: the amendment explicitly carves bulk create out as
25/// non-embedding-bearing (`create_many` intentionally skips embedding and
26/// backfills vectors later via a separate `reindex` call,
27/// `crates/khive-runtime/src/operations.rs:4698-4709`), regardless of its
28/// own `created`/`attempted` summary counts.
29fn is_embedding_bearing(verb: &str, params: &Value) -> bool {
30 match verb {
31 "create" => params.get("items").is_none(),
32 "update" | "memory.remember" | "memory.recall" | "knowledge.search"
33 | "knowledge.compose" | "knowledge.index" => true,
34 _ => false,
35 }
36}
37
38/// `base_weight(verb)`: every verb dispatch, `1` (documented default; see
39/// module docs: the amendment leaves specific weight VALUES unspecified).
40fn base_weight(_verb: &str) -> i64 {
41 1
42}
43
44/// `per_item_weight(verb)`: `1` for every embedding-bearing verb family
45/// (documented default; see module docs), `0` for everything else. The `0`
46/// case is not a default: it is ADR-103 Amendment 1's normative
47/// requirement that a non-embedding-bearing verb's `cost_unit` reduces to
48/// `base_weight(verb)` alone, `item_count`/`model_count` playing no role.
49fn per_item_weight(verb: &str, params: &Value) -> i64 {
50 if is_embedding_bearing(verb, params) {
51 1
52 } else {
53 0
54 }
55}
56
57/// `item_count` for one dispatch, per ADR-103 Amendment 1's per-verb-family
58/// table. Only meaningful (and only called by [`cost_unit_for_dispatch`])
59/// when `per_item_weight` is nonzero for this verb.
60///
61/// - `create` singleton, `memory.remember`, `update`, `memory.recall`,
62/// `knowledge.search`, `knowledge.compose`: always `1`, each is a single
63/// entity/note write or a single query embedding, never a batch.
64/// - `knowledge.index`: `result["total"]`, the full paged corpus count
65/// computed across all internally paged reads, never the internal
66/// `batch_size` chunk ceiling (`clamp(1, 1000)` on the embed-grouping
67/// page size only, not the dispatch's total work).
68fn item_count(verb: &str, result: &Value) -> i64 {
69 if verb == "knowledge.index" {
70 result.get("total").and_then(Value::as_i64).unwrap_or(0)
71 } else {
72 1
73 }
74}
75
76/// `model_count` for one dispatch, per ADR-103 Amendment 1's per-verb-family
77/// table. Only meaningful (and only called by [`cost_unit_for_dispatch`])
78/// when `per_item_weight` is nonzero for this verb.
79///
80/// `registered_model_count` is evaluated lazily via `FnOnce`, called only
81/// for the two verb families whose `model_count` is not a per-dispatch
82/// constant (`memory.remember`'s implicit-model case, and singleton
83/// `create`), so every other dispatch never touches the runtime's embedder
84/// registry.
85///
86/// `0` is a valid, deliberate result when no embedding model is registered
87/// at all: no embed call is issued, so the whole
88/// `per_item_weight x item_count x model_count` term is `0` and
89/// `cost_unit` reduces to `base_weight(verb)` alone. The dispatch still
90/// happened; no embedding work backs its cost.
91fn model_count(verb: &str, params: &Value, registered_model_count: impl FnOnce() -> i64) -> i64 {
92 match verb {
93 "memory.remember" => {
94 let explicit_single_model = params
95 .get("embedding_model")
96 .and_then(Value::as_str)
97 .is_some();
98 if explicit_single_model {
99 1
100 } else {
101 registered_model_count()
102 }
103 }
104 "create" => registered_model_count(),
105 // update, memory.recall, knowledge.search / compose, knowledge.index:
106 // each invokes exactly one embedding model (a query-embedding model,
107 // or the single configured default embedder), never a fan-out.
108 _ => 1,
109 }
110}
111
112/// Checked-arithmetic `cost_unit`:
113/// `base_weight + per_item_weight x item_count x model_count`.
114///
115/// `checked_mul` at each product step, `checked_add` for the final sum: any
116/// overflow at any step clamps the WHOLE expression to `i64::MAX` rather
117/// than omitting the field (ADR-103 Amendment 1 Part 1). All inputs are
118/// non-negative in practice, so overflow can only occur in the positive
119/// direction.
120fn compute(base_weight: i64, per_item_weight: i64, item_count: i64, model_count: i64) -> i64 {
121 let term = per_item_weight
122 .checked_mul(item_count)
123 .and_then(|p| p.checked_mul(model_count))
124 .unwrap_or(i64::MAX);
125 base_weight.checked_add(term).unwrap_or(i64::MAX)
126}
127
128/// Compute `resource.cost_unit` for one successful dispatch.
129///
130/// `params` is the original request's top-level arguments (`GateRequest::args`,
131/// already in scope, read-only, at the audit-row emission seam in
132/// `crates/khive-runtime/src/pack.rs`); `result` is the dispatch's own
133/// successful `Value`; `registered_model_count` reads
134/// `PackRuntime::registered_embedding_model_names().len()` for the pack that
135/// owns `verb`, lazily.
136///
137/// Callers MUST only invoke this for a successful (`Ok`) dispatch result.
138/// Error-outcome dispatches omit `resource.cost_unit` entirely (ADR-103
139/// Amendment 1's "absence has exactly two meanings" rule: a pre-amendment
140/// event, or a dispatch that errored) and must never call into this
141/// function: there is no successful handler `Value` to read `item_count`
142/// from for an errored dispatch.
143pub fn cost_unit_for_dispatch(
144 verb: &str,
145 params: &Value,
146 result: &Value,
147 registered_model_count: impl FnOnce() -> i64,
148) -> i64 {
149 let weight = per_item_weight(verb, params);
150 if weight == 0 {
151 return compute(base_weight(verb), 0, 0, 0);
152 }
153 let items = item_count(verb, result);
154 let models = model_count(verb, params, registered_model_count);
155 compute(base_weight(verb), weight, items, models)
156}
157
158/// Build the `resource` payload object, `{"work_class": "interactive",
159/// "cost_unit": N}`, for one successful verb dispatch.
160///
161/// Every dispatch through `VerbRegistry::dispatch*` is `work_class:
162/// "interactive"` (ADR-103 Decision (a): "Request-driven synchronous verb
163/// dispatch. Default for all handlers."). Background phase work (embedder
164/// warmup, ANN rebuild, etc.) uses the separate `PhaseStarted` /
165/// `PhaseCompleted` / `PhaseCancelled` event family and never this payload.
166///
167/// `request_id` (khive#948) is the caller-supplied correlation id threaded in
168/// from the daemon frame via `RequestIdentity`, stamped alongside
169/// `work_class`/`cost_unit` when the caller supplied one. Its absence has
170/// exactly one meaning (no id was supplied, e.g. a pre-#948 client or an
171/// internal/non-benchmark caller) — unlike `cost_unit`, it is never
172/// conditionally omitted on an otherwise-successful row.
173pub fn resource_payload(
174 verb: &str,
175 params: &Value,
176 result: &Value,
177 registered_model_count: impl FnOnce() -> i64,
178 request_id: Option<u64>,
179) -> Value {
180 let cost_unit = cost_unit_for_dispatch(verb, params, result, registered_model_count);
181 let mut payload = serde_json::json!({ "work_class": "interactive", "cost_unit": cost_unit });
182 if let Some(id) = request_id {
183 if let Value::Object(ref mut map) = payload {
184 map.insert("request_id".to_string(), serde_json::json!(id));
185 }
186 }
187 payload
188}
189
190/// Build the `resource` payload object for a dispatch that did not resolve
191/// `Ok`: `{"work_class": "interactive"}`, with no `cost_unit` key.
192///
193/// ADR-103 Decision (a) stamps the closed `work_class` enum on every event,
194/// with no exception for a denied, errored, or unknown-verb dispatch. Only
195/// `resource.cost_unit` is scoped to a successful dispatch by Amendment 1's
196/// "absence has exactly two meanings" rule (a pre-amendment event, or a
197/// dispatch that errored): `work_class` itself is not one of those two
198/// omission cases, so it must still be present. Every dispatch through
199/// `VerbRegistry::dispatch*` is `work_class: "interactive"` regardless of
200/// outcome; there is no non-interactive outcome for a verb dispatch.
201///
202/// `request_id` (khive#948) is stamped the same way on this payload as on
203/// [`resource_payload`]'s: failure rows must be joinable by the same key as
204/// success rows.
205pub fn base_resource_payload(request_id: Option<u64>) -> Value {
206 let mut payload = serde_json::json!({ "work_class": "interactive" });
207 if let Some(id) = request_id {
208 if let Value::Object(ref mut map) = payload {
209 map.insert("request_id".to_string(), serde_json::json!(id));
210 }
211 }
212 payload
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218 use serde_json::json;
219
220 fn unreachable_model_count() -> i64 {
221 panic!("registered_model_count must not be called for a non-embedding-bearing verb")
222 }
223
224 // ---- Formula arithmetic ----
225
226 #[test]
227 fn non_embedding_verb_is_base_weight_only() {
228 let cost = cost_unit_for_dispatch("stats", &json!({}), &json!({}), unreachable_model_count);
229 assert_eq!(
230 cost, 1,
231 "non-embedding-bearing verb must be base_weight(verb) alone"
232 );
233 }
234
235 #[test]
236 fn link_is_base_weight_only_regardless_of_bulk_shape() {
237 let singleton = cost_unit_for_dispatch(
238 "link",
239 &json!({"source_id": "a", "target_id": "b", "relation": "extends"}),
240 &json!({"id": "edge-1"}),
241 unreachable_model_count,
242 );
243 let bulk = cost_unit_for_dispatch(
244 "link",
245 &json!({"links": [{}, {}, {}]}),
246 &json!({"attempted": 3, "created": 3}),
247 unreachable_model_count,
248 );
249 assert_eq!(singleton, 1);
250 assert_eq!(
251 bulk, 1,
252 "link has no embedding-bearing path, singleton or bulk"
253 );
254 }
255
256 #[test]
257 fn create_singleton_scales_with_registered_model_count() {
258 let cost = cost_unit_for_dispatch("create", &json!({"kind": "concept"}), &json!({}), || 3);
259 // base_weight(1) + per_item_weight(1) * item_count(1) * model_count(3)
260 assert_eq!(cost, 4);
261 }
262
263 // ---- Zero-model vanishing ----
264
265 #[test]
266 fn zero_registered_models_vanishes_the_term() {
267 let cost = cost_unit_for_dispatch("create", &json!({"kind": "concept"}), &json!({}), || 0);
268 assert_eq!(
269 cost, 1,
270 "no embedding model registered -> cost_unit reduces to base_weight(verb) alone"
271 );
272 }
273
274 #[test]
275 fn memory_remember_zero_registered_models_vanishes_the_term() {
276 let cost = cost_unit_for_dispatch("memory.remember", &json!({}), &json!({}), || 0);
277 assert_eq!(cost, 1);
278 }
279
280 // ---- Bulk create is base-only ----
281
282 #[test]
283 fn bulk_create_is_base_weight_only_never_touches_model_count() {
284 let cost = cost_unit_for_dispatch(
285 "create",
286 &json!({"items": [{"kind": "concept", "name": "a"}, {"kind": "concept", "name": "b"}]}),
287 &json!({"attempted": 2, "created": 2}),
288 unreachable_model_count,
289 );
290 assert_eq!(
291 cost, 1,
292 "bulk create(items=[...]) skips embedding entirely -> base_weight(verb) alone"
293 );
294 }
295
296 #[test]
297 fn bulk_create_is_base_only_regardless_of_created_count() {
298 // The amendment is explicit: this holds "regardless of its
299 // created/attempted summary counts" -- a large batch must not
300 // change the result.
301 let items: Vec<Value> = (0..250)
302 .map(|i| json!({"kind": "concept", "name": format!("item-{i}")}))
303 .collect();
304 let cost = cost_unit_for_dispatch(
305 "create",
306 &json!({"items": items}),
307 &json!({"attempted": 250, "created": 250}),
308 unreachable_model_count,
309 );
310 assert_eq!(cost, 1);
311 }
312
313 // ---- knowledge.index full total, not batch_size ceiling ----
314
315 #[test]
316 fn knowledge_index_uses_full_paged_total_not_batch_size_ceiling() {
317 // batch_size only bounds the internal SQL page / embed-grouping
318 // size; the dispatch can process far more than 1000 items in one
319 // call, and item_count must reflect that full total.
320 let cost = cost_unit_for_dispatch(
321 "knowledge.index",
322 &json!({"batch_size": 1000}),
323 &json!({"indexed": 4500, "skipped": 0, "failed": 0, "total": 4500}),
324 || 1,
325 );
326 // base_weight(1) + per_item_weight(1) * item_count(4500) * model_count(1)
327 assert_eq!(cost, 4501);
328 }
329
330 #[test]
331 fn knowledge_index_missing_total_defaults_to_zero_items_not_a_panic() {
332 let cost = cost_unit_for_dispatch("knowledge.index", &json!({}), &json!({}), || 1);
333 assert_eq!(cost, 1);
334 }
335
336 #[test]
337 fn knowledge_index_model_count_is_constant_one_never_reads_registry() {
338 let cost = cost_unit_for_dispatch(
339 "knowledge.index",
340 &json!({}),
341 &json!({"total": 10}),
342 unreachable_model_count,
343 );
344 assert_eq!(cost, 11);
345 }
346
347 // ---- memory.remember explicit-model override ----
348
349 #[test]
350 fn memory_remember_explicit_model_overrides_registry_count() {
351 let cost = cost_unit_for_dispatch(
352 "memory.remember",
353 &json!({"content": "x", "embedding_model": "paraphrase"}),
354 &json!({}),
355 unreachable_model_count,
356 );
357 // explicit single model -> model_count = 1, registry never consulted
358 assert_eq!(cost, 2);
359 }
360
361 #[test]
362 fn memory_remember_no_explicit_model_reads_registered_count() {
363 let cost = cost_unit_for_dispatch(
364 "memory.remember",
365 &json!({"content": "x"}),
366 &json!({}),
367 || 4,
368 );
369 assert_eq!(cost, 5);
370 }
371
372 // ---- Constant-model-count families never touch the registry ----
373
374 #[test]
375 fn update_and_recall_and_search_never_touch_registry() {
376 for verb in [
377 "update",
378 "memory.recall",
379 "knowledge.search",
380 "knowledge.compose",
381 ] {
382 let cost =
383 cost_unit_for_dispatch(verb, &json!({}), &json!({}), unreachable_model_count);
384 assert_eq!(cost, 2, "verb {verb}: base_weight(1) + 1*1*1");
385 }
386 }
387
388 // ---- Overflow clamps to i64::MAX ----
389
390 #[test]
391 fn compute_clamps_multiplication_overflow_to_i64_max() {
392 assert_eq!(compute(1, i64::MAX, 2, 1), i64::MAX);
393 }
394
395 #[test]
396 fn compute_clamps_addition_overflow_to_i64_max() {
397 assert_eq!(compute(i64::MAX, 1, 1, 1), i64::MAX);
398 }
399
400 #[test]
401 fn knowledge_index_extreme_total_clamps_to_i64_max() {
402 let cost = cost_unit_for_dispatch(
403 "knowledge.index",
404 &json!({}),
405 &json!({"total": i64::MAX}),
406 || 2,
407 );
408 assert_eq!(cost, i64::MAX);
409 }
410
411 // ---- resource_payload shape ----
412
413 #[test]
414 fn resource_payload_shape_is_work_class_and_cost_unit_only() {
415 let payload = resource_payload(
416 "stats",
417 &json!({}),
418 &json!({}),
419 unreachable_model_count,
420 None,
421 );
422 assert_eq!(
423 payload,
424 json!({"work_class": "interactive", "cost_unit": 1}),
425 "resource payload must be exactly {{work_class, cost_unit}}, no request_id key \
426 when the caller supplied none"
427 );
428 }
429
430 #[test]
431 fn resource_payload_stamps_request_id_when_supplied() {
432 let payload = resource_payload(
433 "stats",
434 &json!({}),
435 &json!({}),
436 unreachable_model_count,
437 Some(42),
438 );
439 assert_eq!(
440 payload,
441 json!({"work_class": "interactive", "cost_unit": 1, "request_id": 42}),
442 );
443 }
444
445 #[test]
446 fn base_resource_payload_omits_request_id_when_absent() {
447 assert_eq!(
448 base_resource_payload(None),
449 json!({"work_class": "interactive"}),
450 );
451 }
452
453 #[test]
454 fn base_resource_payload_stamps_request_id_when_supplied() {
455 assert_eq!(
456 base_resource_payload(Some(7)),
457 json!({"work_class": "interactive", "request_id": 7}),
458 );
459 }
460}