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    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}