Skip to main content

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}