1use std::collections::BTreeMap;
12
13use cratefield_core::{
14 Answer, AnswerValue, Classifier, ClassifierError, ImageLimit, ImageMediaType,
15 MAX_IMAGE_ENCODED_BYTES, MAX_PROMPT_IMAGE_ENCODED_BYTES, MAX_PROMPT_IMAGES, ModelTier, Part,
16 Prompt, PushError, Question, TextModel, TextModelError, encoded_image_len, validate_questions,
17};
18
19fn every_recipient() -> Vec<cratefield_core::Recipient> {
25 let all = [
26 cratefield_core::Recipient::apns("conformance-device-token"),
27 cratefield_core::Recipient::fcm("conformance-registration-token"),
28 cratefield_core::Recipient::web_push(
29 "https://push.example.test/conformance",
30 "BConformanceP256dhKey",
31 "ConformanceAuthKey",
32 ),
33 ];
34 for recipient in &all {
35 match recipient {
37 cratefield_core::Recipient::Apns { .. }
38 | cratefield_core::Recipient::Fcm { .. }
39 | cratefield_core::Recipient::WebPush { .. } => {}
40 }
41 }
42 all.to_vec()
43}
44
45pub async fn push_recipient_conformance(
64 push: &dyn cratefield_core::Push,
65 serves: &[cratefield_core::Platform],
66) {
67 let notification = cratefield_core::Notification::new("conformance", "probe");
68 for recipient in every_recipient() {
69 let platform = recipient.platform();
70 let result = push.send(&recipient, ¬ification).await;
71 let unsupported = matches!(
72 &result,
73 Err(PushError::Rejected(message)) if message.contains("unsupported recipient")
74 );
75 if serves.contains(&platform) {
76 assert!(
77 !unsupported,
78 "adapter claims to serve {platform} but rejected its recipient as unsupported"
79 );
80 } else {
81 assert!(
82 unsupported,
83 "adapter does not serve {platform}: the port's answer is \
84 PushError::Rejected(\"unsupported recipient…\"), got {result:?}"
85 );
86 }
87 }
88}
89
90pub fn assert_wasm_safe_deps(module_crate: &str) {
100 let output =
101 std::process::Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()))
102 .args(["tree", "-p", module_crate, "--edges", "normal"])
103 .output()
104 .unwrap_or_else(|err| panic!("cargo tree failed: {err}"));
105 assert!(
106 output.status.success(),
107 "cargo tree failed for {module_crate}"
108 );
109 let tree = String::from_utf8_lossy(&output.stdout);
110 for forbidden in ["worker v", "wasm-bindgen v", "tokio v", "reqwest v"] {
111 assert!(
112 !tree.contains(forbidden),
113 "{module_crate} pulls forbidden dependency `{}`:\n{tree}",
114 forbidden.trim_end_matches(" v")
115 );
116 }
117}
118
119const CONFIDENCE_TOLERANCE: f32 = 1e-4;
126
127#[must_use]
133pub fn classifier_conformance_questions() -> BTreeMap<String, Question> {
134 BTreeMap::from([
135 (
136 "topic".to_owned(),
137 Question::Choice {
138 instructions: "Which topic does the state belong to?".to_owned(),
139 criteria: BTreeMap::from([
140 ("billing".to_owned(), "money, invoices".to_owned()),
141 ("bugs".to_owned(), "something is broken".to_owned()),
142 ]),
143 },
144 ),
145 (
146 "severity".to_owned(),
147 Question::Score {
148 instructions: "How severe is the state?".to_owned(),
149 levels: vec![
150 ("1".to_owned(), "a typo".to_owned()),
151 ("5".to_owned(), "data loss".to_owned()),
152 ],
153 },
154 ),
155 (
156 "urgent".to_owned(),
157 Question::Noul {
158 instructions: "Does the state need attention now?".to_owned(),
159 },
160 ),
161 ])
162}
163
164#[must_use]
167pub fn classifier_conformance_state() -> &'static str {
168 "cratefield classifier conformance state"
169}
170
171fn assert_answer_conforms(id: &str, question: &Question, answer: &Answer) {
175 let labels = question.labels();
176
177 let chosen: Option<String> = match (question, &answer.value) {
179 (Question::Choice { .. }, AnswerValue::Choice(label)) => {
180 assert!(
182 labels.contains(&label.as_str()),
183 "rule 3 (a choice is of an offered label): question {id:?} offers {labels:?}, \
184 the answer chose {label:?}"
185 );
186 Some(label.clone())
187 }
188 (Question::Score { .. }, AnswerValue::Score(value)) => {
189 let label = value.to_string();
195 if labels.contains(&label.as_str()) {
196 Some(label)
197 } else {
198 None
199 }
200 }
201 (Question::Noul { .. }, AnswerValue::Noul(verdict)) => {
202 Some(if *verdict { "true" } else { "false" }.to_owned())
203 }
204 (kind, value) => panic!(
205 "rule 2 (the answer's shape matches the question's): question {id:?} is {}, \
206 the answer carried {value:?}",
207 kind.kind()
208 ),
209 };
210
211 for (label, probability) in &answer.probabilities {
215 assert!(
216 labels.contains(&label.as_str()),
217 "rule 4 (probabilities are keyed by offered labels): question {id:?} offers \
218 {labels:?}, the answer reported {label:?}"
219 );
220 assert!(
221 probability.is_finite() && (0.0..=1.0).contains(probability),
222 "rule 4 (a probability is finite and within 0.0..=1.0): question {id:?} reported \
223 {label:?} = {probability}"
224 );
225 }
226
227 assert!(
230 answer.confidence.is_finite() && (0.0..=1.0).contains(&answer.confidence),
231 "rule 5 (a confidence is finite and within 0.0..=1.0): question {id:?} reported \
232 confidence {}",
233 answer.confidence
234 );
235 if let Some(label) = chosen {
236 match answer.probabilities.get(&label) {
237 Some(probability) => assert!(
238 (answer.confidence - probability).abs() <= CONFIDENCE_TOLERANCE,
239 "rule 5 (confidence agrees with the distribution for the chosen label): \
240 question {id:?} chose {label:?} at {probability} but reported confidence {}",
241 answer.confidence
242 ),
243 None => assert!(
246 answer.confidence.abs() <= CONFIDENCE_TOLERANCE,
247 "rule 5 (confidence agrees with the distribution for the chosen label): \
248 question {id:?} chose {label:?}, which its probabilities omit, so the only \
249 agreeing confidence is 0.0, got {}",
250 answer.confidence
251 ),
252 }
253 }
254}
255
256pub async fn classifier_conformance(classifier: &dyn Classifier) {
295 let profile = classifier.profile();
298 assert!(
299 profile.max_state_chars > 0,
300 "rule 6 (the state ceiling is above zero): max_state_chars is {}",
301 profile.max_state_chars
302 );
303 let over_limit = "x".repeat(profile.max_state_chars + 1);
304 let (sent, dropped) = profile.truncate(&over_limit);
305 assert!(
306 dropped,
307 "rule 6 (truncate reports what it dropped): a state over the limit came back \
308 with dropped = false"
309 );
310 assert!(
311 sent.len() <= profile.max_state_chars,
312 "rule 6 (truncate honours the limit): a state of {} chars came back as {} over a \
313 limit of {}",
314 over_limit.len(),
315 sent.len(),
316 profile.max_state_chars
317 );
318 let (untouched, dropped) = profile.truncate(classifier_conformance_state());
319 assert!(
320 !dropped && untouched == classifier_conformance_state(),
321 "rule 6 (truncate is a no-op under the limit): the canonical state was trimmed"
322 );
323
324 let questions = classifier_conformance_questions();
326 let answers = classifier
327 .ask(classifier_conformance_state(), &questions)
328 .await
329 .unwrap_or_else(|error| {
330 panic!(
331 "the scripted ask failed — script the transport to answer \
332 classifier_conformance_questions() about classifier_conformance_state(): \
333 {error:?}"
334 )
335 });
336
337 let missing: Vec<&String> = questions
339 .keys()
340 .filter(|id| !answers.contains_key(*id))
341 .collect();
342 let extra: Vec<&String> = answers
343 .keys()
344 .filter(|id| !questions.contains_key(*id))
345 .collect();
346 assert!(
347 missing.is_empty() && extra.is_empty(),
348 "rule 1 (the answers are keyed exactly as the questions were asked): missing \
349 {missing:?}, extra {extra:?}"
350 );
351
352 for (id, question) in &questions {
354 assert_answer_conforms(id, question, &answers[id]);
355 }
356}
357
358pub async fn classifier_rejects_malformed_questions(classifier: &dyn Classifier) {
370 let malformed: [BTreeMap<String, Question>; 5] = [
371 BTreeMap::new(),
372 BTreeMap::from([(
373 " ".to_owned(),
374 Question::Noul {
375 instructions: "A blank id is malformed.".to_owned(),
376 },
377 )]),
378 BTreeMap::from([(
379 "blank-instructions".to_owned(),
380 Question::Noul {
381 instructions: " ".to_owned(),
382 },
383 )]),
384 BTreeMap::from([(
385 "one-criterion".to_owned(),
386 Question::Choice {
387 instructions: "A choice needs two criteria.".to_owned(),
388 criteria: BTreeMap::from([("only".to_owned(), "the only one".to_owned())]),
389 },
390 )]),
391 BTreeMap::from([(
392 "one-level".to_owned(),
393 Question::Score {
394 instructions: "A score needs two levels.".to_owned(),
395 levels: vec![("3".to_owned(), "middling".to_owned())],
396 },
397 )]),
398 ];
399 for questions in &malformed {
400 assert!(
404 validate_questions(questions).is_err(),
405 "the suite's malformed sets stopped being malformed — update this suite \
406 alongside cratefield_core::validate_questions"
407 );
408 let result = classifier
409 .ask(classifier_conformance_state(), questions)
410 .await;
411 assert!(
412 matches!(result, Err(ClassifierError::Rejected(_))),
413 "a malformed question set is Rejected, never a panic and never an answer: got \
414 {result:?}"
415 );
416 }
417}
418
419pub async fn classifier_not_configured(classifier: &dyn Classifier) {
428 let result = classifier
429 .ask(
430 classifier_conformance_state(),
431 &classifier_conformance_questions(),
432 )
433 .await;
434 assert_eq!(
435 result.err(),
436 Some(ClassifierError::NotConfigured),
437 "an adapter with no key/binding/model answers NotConfigured"
438 );
439}
440
441const SCORE_TOLERANCE: f32 = 1.0e-3;
447
448use cratefield_core::{VectorFilter, VectorMatch, VectorNamespace, VectorRecord};
449
450pub async fn vector_index_conformance(index: &dyn cratefield_core::VectorIndex) {
470 let ns = VectorNamespace::new("conformance").expect("the suite namespace is valid");
471 let other = VectorNamespace::new("conformance-other").expect("the other namespace is valid");
472 vector_orders_filters_and_limits(index, &ns).await;
473 vector_isolates_replaces_and_deletes(index, &ns, &other).await;
474}
475
476async fn vector_matches(
478 index: &dyn cratefield_core::VectorIndex,
479 ns: &VectorNamespace,
480 vector: &[f32],
481 k: usize,
482 filter: &VectorFilter,
483) -> Vec<VectorMatch> {
484 index
485 .query(ns, vector, k, filter)
486 .await
487 .expect("the scripted query answers")
488}
489
490async fn vector_orders_filters_and_limits(
493 index: &dyn cratefield_core::VectorIndex,
494 ns: &VectorNamespace,
495) {
496 let all = VectorFilter::new();
497 index
498 .upsert(
499 ns,
500 &[
501 VectorRecord::new("east", vec![1.0, 0.0, 0.0]),
502 VectorRecord::new("north", vec![0.0, 1.0, 0.0]),
503 VectorRecord::new("diagonal", vec![1.0, 1.0, 0.0]),
504 ],
505 )
506 .await
507 .expect("the scripted upsert answers");
508 let ranked = vector_matches(index, ns, &[1.0, 0.0, 0.0], 3, &all).await;
509 assert_eq!(
510 ranked.iter().map(|m| m.id.as_str()).collect::<Vec<_>>(),
511 ["east", "diagonal", "north"],
512 "rule 1 (nearest first): cosine similarity must rank descending"
513 );
514 assert!(
515 (ranked[0].score - 1.0).abs() < SCORE_TOLERANCE,
516 "rule 1 (a vector is most similar to itself): got {}",
517 ranked[0].score
518 );
519 assert_eq!(
520 vector_matches(index, ns, &[1.0, 0.0, 0.0], 1, &all)
521 .await
522 .len(),
523 1,
524 "rule 2 (at most k)"
525 );
526 assert!(
527 vector_matches(index, ns, &[1.0, 0.0, 0.0], 0, &all)
528 .await
529 .is_empty(),
530 "rule 2 (k = 0 answers nothing)"
531 );
532 let doc = VectorRecord::new("doc", vec![0.9, 0.1, 0.0])
534 .with_metadata("kind", serde_json::json!("doc"))
535 .with_metadata("lang", serde_json::json!("en"));
536 index
537 .upsert(
538 ns,
539 &[
540 doc.clone(),
541 VectorRecord::new("chat", vec![0.95, 0.05, 0.0])
542 .with_metadata("kind", serde_json::json!("chat")),
543 ],
544 )
545 .await
546 .expect("the scripted upsert answers");
547 let kind_doc = VectorFilter::new().eq("kind", serde_json::json!("doc"));
548 let filtered = vector_matches(index, ns, &[1.0, 0.0, 0.0], 5, &kind_doc).await;
549 assert_eq!(
550 filtered.iter().map(|m| m.id.as_str()).collect::<Vec<_>>(),
551 ["doc"],
552 "rule 3 (the filter keeps only equality matches)"
553 );
554 assert_eq!(
555 filtered[0].metadata, doc.metadata,
556 "rule 3 (metadata round-trips byte for byte)"
557 );
558 assert_eq!(
559 vector_matches(
560 index,
561 ns,
562 &[1.0, 0.0, 0.0],
563 5,
564 &kind_doc.clone().eq("lang", serde_json::json!("en")),
565 )
566 .await
567 .len(),
568 1,
569 "rule 3 (every key of the filter must match)"
570 );
571 assert!(
572 vector_matches(
573 index,
574 ns,
575 &[1.0, 0.0, 0.0],
576 5,
577 &kind_doc.eq("lang", serde_json::json!("fr")),
578 )
579 .await
580 .is_empty(),
581 "rule 3 (one mismatching key keeps nothing)"
582 );
583}
584
585async fn vector_isolates_replaces_and_deletes(
588 index: &dyn cratefield_core::VectorIndex,
589 ns: &VectorNamespace,
590 other: &VectorNamespace,
591) {
592 let all = VectorFilter::new();
593 let theirs = VectorRecord::new("east", vec![0.0, 0.0, 1.0])
594 .with_metadata("owner", serde_json::json!("other"));
595 index
596 .upsert(other, &[theirs])
597 .await
598 .expect("the scripted upsert answers");
599 let owners_other = VectorFilter::new().eq("owner", serde_json::json!("other"));
600 assert_eq!(
601 vector_matches(index, other, &[0.0, 0.0, 1.0], 5, &owners_other)
602 .await
603 .iter()
604 .map(|m| m.id.as_str())
605 .collect::<Vec<_>>(),
606 ["east"],
607 "rule 4 (the other namespace holds its own `east`)"
608 );
609 assert!(
610 vector_matches(index, ns, &[0.0, 0.0, 1.0], 5, &owners_other)
611 .await
612 .is_empty(),
613 "rule 4 (a query never crosses namespaces)"
614 );
615 index
616 .upsert(
617 ns,
618 &[VectorRecord::new("swap", vec![1.0, 0.0, 0.0])
619 .with_metadata("version", serde_json::json!(1))],
620 )
621 .await
622 .expect("the scripted upsert answers");
623 index
624 .upsert(
625 ns,
626 &[VectorRecord::new("swap", vec![0.6, 0.8, 0.0])
627 .with_metadata("version", serde_json::json!(2))],
628 )
629 .await
630 .expect("the scripted upsert answers");
631 let swap = vector_matches(index, ns, &[0.6, 0.8, 0.0], 5, &all)
632 .await
633 .into_iter()
634 .find(|m| m.id == "swap")
635 .expect("rule 5 (the replaced record is still there)");
636 assert!(
637 (swap.score - 1.0).abs() < SCORE_TOLERANCE,
638 "rule 5 (the upsert replaced the values): got {}",
639 swap.score
640 );
641 assert_eq!(
642 swap.metadata["version"],
643 serde_json::json!(2),
644 "rule 5 (the upsert replaced the metadata)"
645 );
646 assert!(
647 vector_matches(
648 index,
649 ns,
650 &[1.0, 0.0, 0.0],
651 5,
652 &VectorFilter::new().eq("version", serde_json::json!(1)),
653 )
654 .await
655 .is_empty(),
656 "rule 5 (the old metadata is gone, not merged)"
657 );
658 index
659 .delete(other, &["east".to_owned(), "never-there".to_owned()])
660 .await
661 .expect("rule 6 (deleting a missing id answers Ok)");
662 assert!(
663 vector_matches(index, other, &[0.0, 0.0, 1.0], 5, &all)
664 .await
665 .is_empty(),
666 "rule 6 (the delete removed the record)"
667 );
668 assert!(
669 vector_matches(index, ns, &[1.0, 0.0, 0.0], 5, &all)
670 .await
671 .iter()
672 .any(|m| m.id == "east"),
673 "rule 4 (a delete in one namespace cannot touch the other)"
674 );
675 let ours: Vec<String> = ["east", "north", "diagonal", "doc", "chat", "swap"]
678 .into_iter()
679 .map(String::from)
680 .collect();
681 index
682 .delete(ns, &ours)
683 .await
684 .expect("the scripted cleanup answers");
685 index
686 .delete(other, &["east".to_owned()])
687 .await
688 .expect("the scripted cleanup answers");
689}
690pub async fn classifier_truncates_long_state(classifier: &dyn Classifier) {
705 let questions = classifier_conformance_questions();
706 let profile = classifier.profile();
707 let long_state = classifier_conformance_state().repeat(profile.max_state_chars + 1);
708
709 let answers = classifier
710 .ask(&long_state, &questions)
711 .await
712 .unwrap_or_else(|error| {
713 panic!(
714 "an over-limit state still gets answers — truncation is the adapter's job, not \
715 the caller's: {error:?}"
716 )
717 });
718 for (id, question) in &questions {
719 let answer = answers.get(id).unwrap_or_else(|| {
720 panic!(
721 "rule 1 (the answers are keyed exactly as the questions were asked): the \
722 over-limit ask dropped {id:?}"
723 )
724 });
725 assert_answer_conforms(id, question, answer);
726 }
727}
728
729pub const TEXT_MODEL_CONFORMANCE_REPLY: &str = "cratefield text model conformance reply";
736
737pub const TEXT_MODEL_CONFORMANCE_INPUT_TOKENS: u64 = 17;
742
743pub const TEXT_MODEL_CONFORMANCE_OUTPUT_TOKENS: u64 = 5;
745
746pub const TEXT_MODEL_CONFORMANCE_CACHED_INPUT_TOKENS: u64 = 9;
751
752#[must_use]
759pub fn text_model_conformance_prompt() -> Prompt {
760 Prompt::new(ModelTier::Fast).user("Reply with the conformance reply, exactly.")
761}
762
763pub async fn text_model_conformance(model: &dyn TextModel) {
793 let prompt = text_model_conformance_prompt();
794 let completion = model.complete(&prompt).await.unwrap_or_else(|error| {
795 panic!(
796 "the scripted completion failed — script the transport to answer \
797 text_model_conformance_prompt() with TEXT_MODEL_CONFORMANCE_REPLY and the \
798 canonical token counts: {error:?}"
799 )
800 });
801
802 assert_eq!(
803 completion.text, TEXT_MODEL_CONFORMANCE_REPLY,
804 "rule 1 (the text is the scripted reply, verbatim): got {:?}",
805 completion.text
806 );
807 assert!(
808 !completion.model.is_empty(),
809 "rule 2 (the completion names the model that answered): model is empty"
810 );
811 assert!(
812 completion.input_tokens == TEXT_MODEL_CONFORMANCE_INPUT_TOKENS
813 && completion.output_tokens == TEXT_MODEL_CONFORMANCE_OUTPUT_TOKENS,
814 "rule 3 (every completion reports its token usage): got input {}, output {}, \
815 expected {TEXT_MODEL_CONFORMANCE_INPUT_TOKENS}/{TEXT_MODEL_CONFORMANCE_OUTPUT_TOKENS}",
816 completion.input_tokens,
817 completion.output_tokens
818 );
819 assert_eq!(
820 completion.cached_input_tokens,
821 Some(TEXT_MODEL_CONFORMANCE_CACHED_INPUT_TOKENS),
822 "rule 4 (the cached subset is reported where the vendor reports one)"
823 );
824 if let Some(cached) = completion.cached_input_tokens {
825 assert!(
826 cached <= completion.input_tokens,
827 "rule 4 (the cached subset never exceeds the total it is a subset of): cached \
828 {cached} over input {}",
829 completion.input_tokens
830 );
831 }
832 assert!(
833 completion.json.is_none(),
834 "rule 5 (a prompt that asked for no schema comes back without a parsed value): got \
835 {:?}",
836 completion.json
837 );
838}
839
840pub async fn text_model_conformance_not_configured(model: &dyn TextModel) {
849 let result = model.complete(&text_model_conformance_prompt()).await;
850 assert_eq!(
851 result.err(),
852 Some(TextModelError::NotConfigured),
853 "an adapter with no key answers NotConfigured"
854 );
855}
856
857pub async fn text_model_image_bounds_conformance(model: &dyn TextModel) {
875 let too_many = Prompt::new(ModelTier::Fast)
877 .user_parts((0..=MAX_PROMPT_IMAGES).map(|_| Part::image(ImageMediaType::Png, vec![0u8])));
878 assert_eq!(
879 model.complete(&too_many).await.err(),
880 Some(TextModelError::ImageLimit(ImageLimit::TooMany {
881 count: MAX_PROMPT_IMAGES + 1,
882 })),
883 "more than {MAX_PROMPT_IMAGES} images is refused with TooMany, before any network call"
884 );
885
886 let over = MAX_IMAGE_ENCODED_BYTES + 4;
888 let over_raw = raw_len_for_encoded(over);
889 assert_eq!(encoded_image_len(over_raw), over);
890 let too_large = Prompt::new(ModelTier::Fast).user_parts([
891 Part::text("a caption"),
892 Part::image(ImageMediaType::Png, vec![0u8; over_raw]),
893 ]);
894 assert_eq!(
895 model.complete(&too_large).await.err(),
896 Some(TextModelError::ImageLimit(ImageLimit::ImageTooLarge {
897 index: 0,
898 encoded_bytes: over,
899 })),
900 "an image over the per-image ceiling is refused with ImageTooLarge, before any network call"
901 );
902
903 let each = MAX_IMAGE_ENCODED_BYTES - 4;
905 let each_raw = raw_len_for_encoded(each);
906 assert!(2 * each > MAX_PROMPT_IMAGE_ENCODED_BYTES);
907 let too_total = Prompt::new(ModelTier::Fast).user_parts([
908 Part::image(ImageMediaType::Png, vec![0u8; each_raw]),
909 Part::image(ImageMediaType::Jpeg, vec![0u8; each_raw]),
910 ]);
911 assert_eq!(
912 model.complete(&too_total).await.err(),
913 Some(TextModelError::ImageLimit(ImageLimit::TotalTooLarge {
914 encoded_bytes: 2 * each,
915 })),
916 "images together over the prompt total are refused with TotalTooLarge, before any network \
917 call"
918 );
919}
920
921fn raw_len_for_encoded(encoded: usize) -> usize {
924 (encoded / 4) * 3
925}