Skip to main content

cortexkit_role_classifier/
identity.rs

1//! The batch body identity: what decides whether a re-sent `batch_id` is a
2//! replay or `batch_id_reuse`.
3//!
4//! The identity is the model, the questions, and each item, the questions
5//! and every item hashed as RFC 8785 (JCS) canonical JSON, with an item's
6//! images in their canonical form. These are the shared model-batch rules
7//! (`cortexkit-model-batch`, its `RULES.md` ยง2); this module names the
8//! parts `classifier/v1` hashes. Canonicalizing first means the key order
9//! of an object `state`, whitespace, number spellings JSON treats as equal
10//! (`1.0` and `1`, `1e30` and `1E+30`), and the spelling of an image (a
11//! `data:` URL or `{content_type, base64}`) can't turn a retry into a
12//! reuse. `max_cost_usd` is not part of it: a re-send may carry another
13//! ceiling (see [`effective_ceiling`](crate::request::effective_ceiling)).
14
15use serde::{Deserialize, Serialize};
16
17pub use cortexkit_model_batch::identity::{canonical_json, digest, IdentityError};
18use cortexkit_model_batch::identity::{first_difference, item_digest};
19
20use crate::request::ClassifyRequest;
21
22/// A batch's body identity, as a runner records it at first admission.
23#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
24#[non_exhaustive]
25pub struct BatchIdentity {
26    /// The catalog id, compared as is.
27    pub model: String,
28    /// SHA-256 of the JCS of `questions`, lowercase hex.
29    pub questions: String,
30    /// SHA-256 of the JCS of each item, its images in canonical form,
31    /// lowercase hex, in request order.
32    pub items: Vec<String>,
33}
34
35impl BatchIdentity {
36    /// The identity of `request`. Fails only for an image that does not
37    /// decode, which validation refuses first.
38    pub fn of(request: &ClassifyRequest) -> Result<Self, IdentityError> {
39        Ok(Self {
40            model: request.model.clone(),
41            questions: digest(&request.questions)?,
42            items: request
43                .items
44                .iter()
45                .map(|item| item_digest(item, item.images.as_deref()))
46                .collect::<Result<_, _>>()?,
47        })
48    }
49
50    /// The first field in which `sent` differs from this recorded identity,
51    /// as `batch_id_reuse` names it: `model`, `questions`, `items` (a
52    /// different item count) or `items[i]`. `None` when they are the same
53    /// body, which makes the re-send a replay.
54    pub fn first_difference(&self, sent: &Self) -> Option<String> {
55        first_difference(
56            &[
57                ("model", &self.model, &sent.model),
58                ("questions", &self.questions, &sent.questions),
59            ],
60            &self.items,
61            &sent.items,
62        )
63    }
64}
65
66#[cfg(test)]
67mod tests {
68    use serde_json::{json, Number};
69
70    use super::*;
71    use crate::{
72        question::Question,
73        request::{parse_request, Item},
74    };
75
76    fn request(states: Vec<serde_json::Value>) -> ClassifyRequest {
77        ClassifyRequest::new(
78            "b",
79            "cloudflare-workers-ai/@cf/cloudflare/clef",
80            [("q".to_owned(), Question::noul("i"))].into(),
81            states.into_iter().map(Item::new).collect(),
82        )
83    }
84
85    #[test]
86    fn key_order_and_number_spelling_do_not_change_the_identity() {
87        let first = parse_request(
88            r#"{"batch_id":"b","model":"m","questions":{"q":{"type":"noul","instructions":"i"}},"items":[{"state":{"b":1.0,"a":1e30,"c":{"y":[2.50],"x":0}}}]}"#,
89        )
90        .unwrap();
91        let second = parse_request(
92            r#"{"batch_id":"b","model":"m","questions":{"q":{"type":"noul","instructions":"i"}},"items":[{"state":{"c":{"x":0,"y":[2.5]},"a":1E+30,"b":1}}]}"#,
93        )
94        .unwrap();
95        assert_eq!(
96            String::from_utf8(canonical_json(&first.items[0].state).unwrap()).unwrap(),
97            r#"{"a":1e+30,"b":1,"c":{"x":0,"y":[2.5]}}"#
98        );
99        assert_eq!(
100            BatchIdentity::of(&first).unwrap(),
101            BatchIdentity::of(&second).unwrap()
102        );
103    }
104
105    #[test]
106    fn plain_json_would_have_called_the_same_state_different() {
107        // The reason for canonicalizing: serde_json writes the float `1.0`
108        // and the integer `1` differently, so hashing its text would refuse
109        // a faithful retry as reuse.
110        let float: serde_json::Value = serde_json::from_str(r#"{"a":1.0}"#).unwrap();
111        let int: serde_json::Value = serde_json::from_str(r#"{"a":1}"#).unwrap();
112        assert_ne!(
113            serde_json::to_string(&float).unwrap(),
114            serde_json::to_string(&int).unwrap()
115        );
116        assert_eq!(digest(&float).unwrap(), digest(&int).unwrap());
117    }
118
119    #[test]
120    fn the_first_difference_is_named() {
121        let recorded = BatchIdentity::of(&request(vec![json!("a"), json!("b")])).unwrap();
122        let same = BatchIdentity::of(&request(vec![json!("a"), json!("b")])).unwrap();
123        assert_eq!(recorded.first_difference(&same), None);
124        let changed = BatchIdentity::of(&request(vec![json!("a"), json!("c")])).unwrap();
125        assert_eq!(recorded.first_difference(&changed), Some("items[1]".into()));
126        let longer = BatchIdentity::of(&request(vec![json!("a"), json!("b"), json!("c")])).unwrap();
127        assert_eq!(recorded.first_difference(&longer), Some("items".into()));
128        let mut other_questions = request(vec![json!("a"), json!("b")]);
129        other_questions
130            .questions
131            .insert("r".into(), Question::noul("j"));
132        assert_eq!(
133            recorded.first_difference(&BatchIdentity::of(&other_questions).unwrap()),
134            Some("questions".into())
135        );
136        let mut other_model = request(vec![json!("a"), json!("b")]);
137        other_model.model = "typesafe/jev-1.13.0".into();
138        assert_eq!(
139            recorded.first_difference(&BatchIdentity::of(&other_model).unwrap()),
140            Some("model".into())
141        );
142    }
143
144    #[test]
145    fn one_image_in_either_spelling_is_one_identity() {
146        use crate::question::{Image, InlineImage};
147        let with = |image: Image| {
148            let mut request = request(vec![json!("s")]);
149            request.items[0] = Item::new("s").with_images(vec![image]);
150            BatchIdentity::of(&request).unwrap()
151        };
152        let url = with(Image::DataUrl("data:image/png;base64,AAECAw".into()));
153        let inline = with(Image::Inline(InlineImage::new("image/png", "AAECAw==")));
154        assert_eq!(url.first_difference(&inline), None);
155        let other = with(Image::Inline(InlineImage::new("image/png", "AAECBA==")));
156        assert_eq!(url.first_difference(&other), Some("items[0]".into()));
157    }
158
159    #[test]
160    fn max_cost_usd_is_not_part_of_the_identity() {
161        let plain = request(vec![json!("a")]);
162        let capped = request(vec![json!("a")]).with_max_cost_usd(Number::from_f64(0.01).unwrap());
163        assert_eq!(
164            BatchIdentity::of(&plain).unwrap(),
165            BatchIdentity::of(&capped).unwrap()
166        );
167    }
168}