cortexkit_role_classifier/
identity.rs1use 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#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
24#[non_exhaustive]
25pub struct BatchIdentity {
26 pub model: String,
28 pub questions: String,
30 pub items: Vec<String>,
33}
34
35impl BatchIdentity {
36 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 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 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}