Skip to main content

cortexkit_model_batch/
admission.rs

1//! Admission rules every model-batch role shares (`RULES.md` §1, §5, §8, §9):
2//! the `batch_id` grammar, count bounds, the request size, the catalog row,
3//! and the `max_cost_usd` ceiling.
4
5use serde_json::Number;
6
7use crate::errors::Refusal;
8
9/// Longest `batch_id`, in bytes.
10pub const BATCH_ID_MAX_LEN: usize = 128;
11
12/// Whether `batch_id` is 1 to [`BATCH_ID_MAX_LEN`] printable ASCII
13/// characters (space through tilde).
14pub fn is_batch_id(batch_id: &str) -> bool {
15    !batch_id.is_empty()
16        && batch_id.len() <= BATCH_ID_MAX_LEN
17        && batch_id.bytes().all(|b| (0x20..=0x7e).contains(&b))
18}
19
20/// `batch_id` checked, or `invalid_params` naming `batch_id`.
21pub fn check_batch_id(batch_id: &str) -> Result<(), Refusal> {
22    if is_batch_id(batch_id) {
23        Ok(())
24    } else {
25        Err(Refusal::invalid_params(
26            "batch_id",
27            "batch_id is 1-128 printable ASCII characters",
28        ))
29    }
30}
31
32/// `count` within `min..=max`, or an over-limit refusal naming `field` and
33/// the bound it broke. `what` names the counted things in the message.
34pub fn bounded(
35    count: usize,
36    min: usize,
37    max: usize,
38    field: &str,
39    what: &str,
40) -> Result<(), Refusal> {
41    if count < min {
42        Err(Refusal::over_limit(
43            field,
44            min as u64,
45            format!("{count} {what}; at least {min}"),
46        ))
47    } else if count > max {
48        Err(Refusal::over_limit(
49            field,
50            max as u64,
51            format!("{count} {what}; at most {max}"),
52        ))
53    } else {
54        Ok(())
55    }
56}
57
58/// Check a request body's size against the module's `max_request_bytes`.
59/// The refusal names `params` and the limit: the size belongs to the
60/// request as a whole, so no narrower path applies.
61pub fn check_request_size(len: usize, max_request_bytes: u64) -> Result<(), Refusal> {
62    if len as u64 > max_request_bytes {
63        Err(Refusal::over_limit(
64            "params",
65            max_request_bytes,
66            format!("the request is {len} bytes; at most {max_request_bytes}"),
67        ))
68    } else {
69        Ok(())
70    }
71}
72
73/// `max_cost_usd`, when present, is a number of USD, zero or more;
74/// otherwise `invalid_params` naming `max_cost_usd`.
75pub fn check_max_cost_usd(max_cost_usd: Option<&Number>) -> Result<(), Refusal> {
76    match max_cost_usd {
77        Some(max) if max.as_f64().is_none_or(|usd| usd < 0.0) => Err(Refusal::invalid_params(
78            "max_cost_usd",
79            "max_cost_usd is a number of USD, zero or more",
80        )),
81        _ => Ok(()),
82    }
83}
84
85/// The ceiling a call works under, and the batch's recorded ceiling from
86/// then on: the lower of the ceiling recorded so far and the one this call
87/// carries. `None` when neither names one.
88///
89/// The recorded ceiling is not fixed at first admission. A runner records
90/// this value durably before the call sends anything, so after any call the
91/// recorded ceiling is `effective_ceiling(recorded, sent)`. That is what
92/// makes a tightening stick: a later re-send carrying the original, higher
93/// value works under the lower one, and a caller can tighten a batch's
94/// ceiling but never loosen it.
95pub fn effective_ceiling(recorded: Option<f64>, sent: Option<f64>) -> Option<f64> {
96    match (recorded, sent) {
97        (Some(recorded), Some(sent)) => Some(recorded.min(sent)),
98        (one, None) | (None, one) => one,
99    }
100}
101
102/// What the runner's catalog says about the requested model. Three cases,
103/// so a row that states no kind is never mistaken for a missing row.
104#[derive(Clone, Copy, Debug, PartialEq, Eq)]
105pub enum CatalogRow<'a> {
106    /// The catalog has no row for the model.
107    Absent,
108    /// The row exists and states no `kind`.
109    Kindless,
110    /// The row states this `kind`.
111    Kind(&'a str),
112}
113
114/// Check the model's catalog row against the role's `expected_kind`:
115/// `model_unknown` for no row, `wrong_kind(model, kind)` for a row of
116/// another kind or of none (`kind` is `None` then; the refusal never
117/// invents one). The model is never substituted.
118pub fn check_catalog_row(
119    model: &str,
120    row: CatalogRow<'_>,
121    expected_kind: &str,
122    wrong_kind: impl FnOnce(&str, Option<&str>) -> Refusal,
123) -> Result<(), Refusal> {
124    match row {
125        CatalogRow::Absent => Err(Refusal::model_unknown(model)),
126        CatalogRow::Kind(kind) if kind == expected_kind => Ok(()),
127        CatalogRow::Kind(kind) => Err(wrong_kind(model, Some(kind))),
128        CatalogRow::Kindless => Err(wrong_kind(model, None)),
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use crate::errors::{MODEL_NOT_EXTRACTOR, MODEL_UNKNOWN};
136
137    #[test]
138    fn batch_ids_are_printable_ascii_up_to_128() {
139        assert!(is_batch_id("b-1"));
140        assert!(!is_batch_id(""));
141        assert!(!is_batch_id("tab\there"));
142        assert!(!is_batch_id(&"x".repeat(129)));
143        assert!(is_batch_id(&"x".repeat(128)));
144    }
145
146    #[test]
147    fn the_ceiling_tightens_and_never_loosens() {
148        let mut recorded = None;
149        let mut worked_under = Vec::new();
150        for sent in [5.0, 1.0, 5.0] {
151            recorded = effective_ceiling(recorded, Some(sent));
152            worked_under.push(recorded);
153        }
154        assert_eq!(worked_under, [Some(5.0), Some(1.0), Some(1.0)]);
155        assert_eq!(effective_ceiling(None, None), None);
156    }
157
158    #[test]
159    fn a_kindless_row_is_not_a_missing_row() {
160        let wrong = |model: &str, kind: Option<&str>| Refusal::model_not_extractor(model, kind);
161        let absent = check_catalog_row("m", CatalogRow::Absent, "extraction", wrong).unwrap_err();
162        assert_eq!(absent.code, MODEL_UNKNOWN);
163        let kindless =
164            check_catalog_row("m", CatalogRow::Kindless, "extraction", wrong).unwrap_err();
165        assert_eq!(kindless.code, MODEL_NOT_EXTRACTOR);
166        assert_eq!(kindless.detail.kind, None);
167        assert!(
168            check_catalog_row("m", CatalogRow::Kind("extraction"), "extraction", wrong).is_ok()
169        );
170    }
171}