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