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 stamp_usage_units(&mut payload);
188 payload
189}
190
191/// ADR-103 Amendment 2: freeze the dispatch-accounting context (first freeze
192/// wins) and stamp the snapshot as `resource.units`. The resource payload is
193/// built immediately before the enclosing audit row is appended, which is
194/// exactly the amendment's snapshot point — the same frozen object is what
195/// the response envelope later reads. No armed context (direct registry
196/// callers, background work), or an unmeasured context, means no `units` key;
197/// reporting never fails the dispatch.
198fn stamp_usage_units(payload: &mut Value) {
199 if let Some(ctx) = crate::usage::current() {
200 ctx.freeze();
201 if let Value::Object(map) = payload {
202 if let Some(snapshot) = ctx.shipping_snapshot() {
203 map.insert("units".to_string(), snapshot);
204 }
205 }
206 }
207}
208
209/// Build the `resource` payload object for a dispatch that did not resolve
210/// `Ok`: `{"work_class": "interactive"}`, with no `cost_unit` key.
211///
212/// ADR-103 Decision (a) stamps the closed `work_class` enum on every event,
213/// with no exception for a denied, errored, or unknown-verb dispatch. Only
214/// `resource.cost_unit` is scoped to a successful dispatch by Amendment 1's
215/// "absence has exactly two meanings" rule (a pre-amendment event, or a
216/// dispatch that errored): `work_class` itself is not one of those two
217/// omission cases, so it must still be present. Every dispatch through
218/// `VerbRegistry::dispatch*` is `work_class: "interactive"` regardless of
219/// outcome; there is no non-interactive outcome for a verb dispatch.
220///
221/// `request_id` (khive#948) is stamped the same way on this payload as on
222/// [`resource_payload`]'s: failure rows must be joinable by the same key as
223/// success rows.
224pub fn base_resource_payload(request_id: Option<u64>) -> Value {
225 let mut payload = serde_json::json!({ "work_class": "interactive" });
226 if let Some(id) = request_id {
227 if let Value::Object(ref mut map) = payload {
228 map.insert("request_id".to_string(), serde_json::json!(id));
229 }
230 }
231 stamp_usage_units(&mut payload);
232 payload
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238 use serde_json::json;
239
240 fn unreachable_model_count() -> i64 {
241 panic!("registered_model_count must not be called for a non-embedding-bearing verb")
242 }
243
244 // ---- Formula arithmetic ----
245
246 #[test]
247 fn non_embedding_verb_is_base_weight_only() {
248 let cost = cost_unit_for_dispatch("stats", &json!({}), &json!({}), unreachable_model_count);
249 assert_eq!(
250 cost, 1,
251 "non-embedding-bearing verb must be base_weight(verb) alone"
252 );
253 }
254
255 #[test]
256 fn link_is_base_weight_only_regardless_of_bulk_shape() {
257 let singleton = cost_unit_for_dispatch(
258 "link",
259 &json!({"source_id": "a", "target_id": "b", "relation": "extends"}),
260 &json!({"id": "edge-1"}),
261 unreachable_model_count,
262 );
263 let bulk = cost_unit_for_dispatch(
264 "link",
265 &json!({"links": [{}, {}, {}]}),
266 &json!({"attempted": 3, "created": 3}),
267 unreachable_model_count,
268 );
269 assert_eq!(singleton, 1);
270 assert_eq!(
271 bulk, 1,
272 "link has no embedding-bearing path, singleton or bulk"
273 );
274 }
275
276 #[test]
277 fn create_singleton_scales_with_registered_model_count() {
278 let cost = cost_unit_for_dispatch("create", &json!({"kind": "concept"}), &json!({}), || 3);
279 // base_weight(1) + per_item_weight(1) * item_count(1) * model_count(3)
280 assert_eq!(cost, 4);
281 }
282
283 // ---- Zero-model vanishing ----
284
285 #[test]
286 fn zero_registered_models_vanishes_the_term() {
287 let cost = cost_unit_for_dispatch("create", &json!({"kind": "concept"}), &json!({}), || 0);
288 assert_eq!(
289 cost, 1,
290 "no embedding model registered -> cost_unit reduces to base_weight(verb) alone"
291 );
292 }
293
294 #[test]
295 fn memory_remember_zero_registered_models_vanishes_the_term() {
296 let cost = cost_unit_for_dispatch("memory.remember", &json!({}), &json!({}), || 0);
297 assert_eq!(cost, 1);
298 }
299
300 // ---- Bulk create is base-only ----
301
302 #[test]
303 fn bulk_create_is_base_weight_only_never_touches_model_count() {
304 let cost = cost_unit_for_dispatch(
305 "create",
306 &json!({"items": [{"kind": "concept", "name": "a"}, {"kind": "concept", "name": "b"}]}),
307 &json!({"attempted": 2, "created": 2}),
308 unreachable_model_count,
309 );
310 assert_eq!(
311 cost, 1,
312 "bulk create(items=[...]) skips embedding entirely -> base_weight(verb) alone"
313 );
314 }
315
316 #[test]
317 fn bulk_create_is_base_only_regardless_of_created_count() {
318 // The amendment is explicit: this holds "regardless of its
319 // created/attempted summary counts" -- a large batch must not
320 // change the result.
321 let items: Vec<Value> = (0..250)
322 .map(|i| json!({"kind": "concept", "name": format!("item-{i}")}))
323 .collect();
324 let cost = cost_unit_for_dispatch(
325 "create",
326 &json!({"items": items}),
327 &json!({"attempted": 250, "created": 250}),
328 unreachable_model_count,
329 );
330 assert_eq!(cost, 1);
331 }
332
333 // ---- knowledge.index full total, not batch_size ceiling ----
334
335 #[test]
336 fn knowledge_index_uses_full_paged_total_not_batch_size_ceiling() {
337 // batch_size only bounds the internal SQL page / embed-grouping
338 // size; the dispatch can process far more than 1000 items in one
339 // call, and item_count must reflect that full total.
340 let cost = cost_unit_for_dispatch(
341 "knowledge.index",
342 &json!({"batch_size": 1000}),
343 &json!({"indexed": 4500, "skipped": 0, "failed": 0, "total": 4500}),
344 || 1,
345 );
346 // base_weight(1) + per_item_weight(1) * item_count(4500) * model_count(1)
347 assert_eq!(cost, 4501);
348 }
349
350 #[test]
351 fn knowledge_index_missing_total_defaults_to_zero_items_not_a_panic() {
352 let cost = cost_unit_for_dispatch("knowledge.index", &json!({}), &json!({}), || 1);
353 assert_eq!(cost, 1);
354 }
355
356 #[test]
357 fn knowledge_index_model_count_is_constant_one_never_reads_registry() {
358 let cost = cost_unit_for_dispatch(
359 "knowledge.index",
360 &json!({}),
361 &json!({"total": 10}),
362 unreachable_model_count,
363 );
364 assert_eq!(cost, 11);
365 }
366
367 // ---- memory.remember explicit-model override ----
368
369 #[test]
370 fn memory_remember_explicit_model_overrides_registry_count() {
371 let cost = cost_unit_for_dispatch(
372 "memory.remember",
373 &json!({"content": "x", "embedding_model": "paraphrase"}),
374 &json!({}),
375 unreachable_model_count,
376 );
377 // explicit single model -> model_count = 1, registry never consulted
378 assert_eq!(cost, 2);
379 }
380
381 #[test]
382 fn memory_remember_no_explicit_model_reads_registered_count() {
383 let cost = cost_unit_for_dispatch(
384 "memory.remember",
385 &json!({"content": "x"}),
386 &json!({}),
387 || 4,
388 );
389 assert_eq!(cost, 5);
390 }
391
392 // ---- Constant-model-count families never touch the registry ----
393
394 #[test]
395 fn update_and_recall_and_search_never_touch_registry() {
396 for verb in [
397 "update",
398 "memory.recall",
399 "knowledge.search",
400 "knowledge.compose",
401 ] {
402 let cost =
403 cost_unit_for_dispatch(verb, &json!({}), &json!({}), unreachable_model_count);
404 assert_eq!(cost, 2, "verb {verb}: base_weight(1) + 1*1*1");
405 }
406 }
407
408 // ---- Overflow clamps to i64::MAX ----
409
410 #[test]
411 fn compute_clamps_multiplication_overflow_to_i64_max() {
412 assert_eq!(compute(1, i64::MAX, 2, 1), i64::MAX);
413 }
414
415 #[test]
416 fn compute_clamps_addition_overflow_to_i64_max() {
417 assert_eq!(compute(i64::MAX, 1, 1, 1), i64::MAX);
418 }
419
420 #[test]
421 fn knowledge_index_extreme_total_clamps_to_i64_max() {
422 let cost = cost_unit_for_dispatch(
423 "knowledge.index",
424 &json!({}),
425 &json!({"total": i64::MAX}),
426 || 2,
427 );
428 assert_eq!(cost, i64::MAX);
429 }
430
431 // ---- resource_payload shape ----
432
433 #[test]
434 fn resource_payload_shape_is_work_class_and_cost_unit_only() {
435 let payload = resource_payload(
436 "stats",
437 &json!({}),
438 &json!({}),
439 unreachable_model_count,
440 None,
441 );
442 assert_eq!(
443 payload,
444 json!({"work_class": "interactive", "cost_unit": 1}),
445 "resource payload must be exactly {{work_class, cost_unit}}, no request_id key \
446 when the caller supplied none"
447 );
448 }
449
450 #[test]
451 fn resource_payload_stamps_request_id_when_supplied() {
452 let payload = resource_payload(
453 "stats",
454 &json!({}),
455 &json!({}),
456 unreachable_model_count,
457 Some(42),
458 );
459 assert_eq!(
460 payload,
461 json!({"work_class": "interactive", "cost_unit": 1, "request_id": 42}),
462 );
463 }
464
465 #[test]
466 fn base_resource_payload_omits_request_id_when_absent() {
467 assert_eq!(
468 base_resource_payload(None),
469 json!({"work_class": "interactive"}),
470 );
471 }
472
473 #[test]
474 fn base_resource_payload_stamps_request_id_when_supplied() {
475 assert_eq!(
476 base_resource_payload(Some(7)),
477 json!({"work_class": "interactive", "request_id": 7}),
478 );
479 }
480
481 #[tokio::test]
482 async fn unmeasured_context_omits_audit_units_for_success_and_failure() {
483 for succeeded in [false, true] {
484 let ctx = crate::usage::UsageContext::new();
485 ctx.add(crate::usage::UsageUnit::EventRows, 1);
486 ctx.mark_unmeasured();
487 let payload = crate::usage::scope(ctx.clone(), async {
488 if succeeded {
489 resource_payload(
490 "stats",
491 &json!({}),
492 &json!({}),
493 unreachable_model_count,
494 Some(7),
495 )
496 } else {
497 base_resource_payload(Some(7))
498 }
499 })
500 .await;
501 assert!(payload.get("units").is_none(), "{payload}");
502 assert_eq!(payload["work_class"], "interactive");
503 assert_eq!(payload["request_id"], 7);
504 assert_eq!(payload.get("cost_unit").is_some(), succeeded);
505 assert_eq!(ctx.freeze(), json!({"event_rows": 1}));
506 assert_eq!(ctx.shipping_snapshot(), None);
507 }
508 }
509
510 #[tokio::test]
511 async fn measured_audit_units_keep_the_frozen_shipping_snapshot() {
512 let ctx = crate::usage::UsageContext::new();
513 ctx.add(crate::usage::UsageUnit::EventRows, 2);
514 let payload = crate::usage::scope(ctx.clone(), async { base_resource_payload(None) }).await;
515 assert_eq!(payload["units"], json!({"event_rows": 2}));
516 ctx.add(crate::usage::UsageUnit::EventRows, 1);
517 assert_eq!(ctx.shipping_snapshot(), Some(payload["units"].clone()));
518 }
519}