use std::collections::BTreeMap;
use cratefield_core::{
Answer, AnswerValue, Classifier, ClassifierError, ImageLimit, ImageMediaType,
MAX_IMAGE_ENCODED_BYTES, MAX_PROMPT_IMAGE_ENCODED_BYTES, MAX_PROMPT_IMAGES, ModelTier, Part,
Prompt, PushError, Question, TextModel, TextModelError, encoded_image_len, validate_questions,
};
fn every_recipient() -> Vec<cratefield_core::Recipient> {
let all = [
cratefield_core::Recipient::apns("conformance-device-token"),
cratefield_core::Recipient::fcm("conformance-registration-token"),
cratefield_core::Recipient::web_push(
"https://push.example.test/conformance",
"BConformanceP256dhKey",
"ConformanceAuthKey",
),
];
for recipient in &all {
match recipient {
cratefield_core::Recipient::Apns { .. }
| cratefield_core::Recipient::Fcm { .. }
| cratefield_core::Recipient::WebPush { .. } => {}
}
}
all.to_vec()
}
pub async fn push_recipient_conformance(
push: &dyn cratefield_core::Push,
serves: &[cratefield_core::Platform],
) {
let notification = cratefield_core::Notification::new("conformance", "probe");
for recipient in every_recipient() {
let platform = recipient.platform();
let result = push.send(&recipient, ¬ification).await;
let unsupported = matches!(
&result,
Err(PushError::Rejected(message)) if message.contains("unsupported recipient")
);
if serves.contains(&platform) {
assert!(
!unsupported,
"adapter claims to serve {platform} but rejected its recipient as unsupported"
);
} else {
assert!(
unsupported,
"adapter does not serve {platform}: the port's answer is \
PushError::Rejected(\"unsupported recipient…\"), got {result:?}"
);
}
}
}
pub fn assert_wasm_safe_deps(module_crate: &str) {
let output =
std::process::Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()))
.args(["tree", "-p", module_crate, "--edges", "normal"])
.output()
.unwrap_or_else(|err| panic!("cargo tree failed: {err}"));
assert!(
output.status.success(),
"cargo tree failed for {module_crate}"
);
let tree = String::from_utf8_lossy(&output.stdout);
for forbidden in ["worker v", "wasm-bindgen v", "tokio v", "reqwest v"] {
assert!(
!tree.contains(forbidden),
"{module_crate} pulls forbidden dependency `{}`:\n{tree}",
forbidden.trim_end_matches(" v")
);
}
}
const CONFIDENCE_TOLERANCE: f32 = 1e-4;
#[must_use]
pub fn classifier_conformance_questions() -> BTreeMap<String, Question> {
BTreeMap::from([
(
"topic".to_owned(),
Question::Choice {
instructions: "Which topic does the state belong to?".to_owned(),
criteria: BTreeMap::from([
("billing".to_owned(), "money, invoices".to_owned()),
("bugs".to_owned(), "something is broken".to_owned()),
]),
},
),
(
"severity".to_owned(),
Question::Score {
instructions: "How severe is the state?".to_owned(),
levels: vec![
("1".to_owned(), "a typo".to_owned()),
("5".to_owned(), "data loss".to_owned()),
],
},
),
(
"urgent".to_owned(),
Question::Noul {
instructions: "Does the state need attention now?".to_owned(),
},
),
])
}
#[must_use]
pub fn classifier_conformance_state() -> &'static str {
"cratefield classifier conformance state"
}
fn assert_answer_conforms(id: &str, question: &Question, answer: &Answer) {
let labels = question.labels();
let chosen: Option<String> = match (question, &answer.value) {
(Question::Choice { .. }, AnswerValue::Choice(label)) => {
assert!(
labels.contains(&label.as_str()),
"rule 3 (a choice is of an offered label): question {id:?} offers {labels:?}, \
the answer chose {label:?}"
);
Some(label.clone())
}
(Question::Score { .. }, AnswerValue::Score(value)) => {
let label = value.to_string();
if labels.contains(&label.as_str()) {
Some(label)
} else {
None
}
}
(Question::Noul { .. }, AnswerValue::Noul(verdict)) => {
Some(if *verdict { "true" } else { "false" }.to_owned())
}
(kind, value) => panic!(
"rule 2 (the answer's shape matches the question's): question {id:?} is {}, \
the answer carried {value:?}",
kind.kind()
),
};
for (label, probability) in &answer.probabilities {
assert!(
labels.contains(&label.as_str()),
"rule 4 (probabilities are keyed by offered labels): question {id:?} offers \
{labels:?}, the answer reported {label:?}"
);
assert!(
probability.is_finite() && (0.0..=1.0).contains(probability),
"rule 4 (a probability is finite and within 0.0..=1.0): question {id:?} reported \
{label:?} = {probability}"
);
}
assert!(
answer.confidence.is_finite() && (0.0..=1.0).contains(&answer.confidence),
"rule 5 (a confidence is finite and within 0.0..=1.0): question {id:?} reported \
confidence {}",
answer.confidence
);
if let Some(label) = chosen {
match answer.probabilities.get(&label) {
Some(probability) => assert!(
(answer.confidence - probability).abs() <= CONFIDENCE_TOLERANCE,
"rule 5 (confidence agrees with the distribution for the chosen label): \
question {id:?} chose {label:?} at {probability} but reported confidence {}",
answer.confidence
),
None => assert!(
answer.confidence.abs() <= CONFIDENCE_TOLERANCE,
"rule 5 (confidence agrees with the distribution for the chosen label): \
question {id:?} chose {label:?}, which its probabilities omit, so the only \
agreeing confidence is 0.0, got {}",
answer.confidence
),
}
}
}
pub async fn classifier_conformance(classifier: &dyn Classifier) {
let profile = classifier.profile();
assert!(
profile.max_state_chars > 0,
"rule 6 (the state ceiling is above zero): max_state_chars is {}",
profile.max_state_chars
);
let over_limit = "x".repeat(profile.max_state_chars + 1);
let (sent, dropped) = profile.truncate(&over_limit);
assert!(
dropped,
"rule 6 (truncate reports what it dropped): a state over the limit came back \
with dropped = false"
);
assert!(
sent.len() <= profile.max_state_chars,
"rule 6 (truncate honours the limit): a state of {} chars came back as {} over a \
limit of {}",
over_limit.len(),
sent.len(),
profile.max_state_chars
);
let (untouched, dropped) = profile.truncate(classifier_conformance_state());
assert!(
!dropped && untouched == classifier_conformance_state(),
"rule 6 (truncate is a no-op under the limit): the canonical state was trimmed"
);
let questions = classifier_conformance_questions();
let answers = classifier
.ask(classifier_conformance_state(), &questions)
.await
.unwrap_or_else(|error| {
panic!(
"the scripted ask failed — script the transport to answer \
classifier_conformance_questions() about classifier_conformance_state(): \
{error:?}"
)
});
let missing: Vec<&String> = questions
.keys()
.filter(|id| !answers.contains_key(*id))
.collect();
let extra: Vec<&String> = answers
.keys()
.filter(|id| !questions.contains_key(*id))
.collect();
assert!(
missing.is_empty() && extra.is_empty(),
"rule 1 (the answers are keyed exactly as the questions were asked): missing \
{missing:?}, extra {extra:?}"
);
for (id, question) in &questions {
assert_answer_conforms(id, question, &answers[id]);
}
}
pub async fn classifier_rejects_malformed_questions(classifier: &dyn Classifier) {
let malformed: [BTreeMap<String, Question>; 5] = [
BTreeMap::new(),
BTreeMap::from([(
" ".to_owned(),
Question::Noul {
instructions: "A blank id is malformed.".to_owned(),
},
)]),
BTreeMap::from([(
"blank-instructions".to_owned(),
Question::Noul {
instructions: " ".to_owned(),
},
)]),
BTreeMap::from([(
"one-criterion".to_owned(),
Question::Choice {
instructions: "A choice needs two criteria.".to_owned(),
criteria: BTreeMap::from([("only".to_owned(), "the only one".to_owned())]),
},
)]),
BTreeMap::from([(
"one-level".to_owned(),
Question::Score {
instructions: "A score needs two levels.".to_owned(),
levels: vec![("3".to_owned(), "middling".to_owned())],
},
)]),
];
for questions in &malformed {
assert!(
validate_questions(questions).is_err(),
"the suite's malformed sets stopped being malformed — update this suite \
alongside cratefield_core::validate_questions"
);
let result = classifier
.ask(classifier_conformance_state(), questions)
.await;
assert!(
matches!(result, Err(ClassifierError::Rejected(_))),
"a malformed question set is Rejected, never a panic and never an answer: got \
{result:?}"
);
}
}
pub async fn classifier_not_configured(classifier: &dyn Classifier) {
let result = classifier
.ask(
classifier_conformance_state(),
&classifier_conformance_questions(),
)
.await;
assert_eq!(
result.err(),
Some(ClassifierError::NotConfigured),
"an adapter with no key/binding/model answers NotConfigured"
);
}
const SCORE_TOLERANCE: f32 = 1.0e-3;
use cratefield_core::{VectorFilter, VectorMatch, VectorNamespace, VectorRecord};
pub async fn vector_index_conformance(index: &dyn cratefield_core::VectorIndex) {
let ns = VectorNamespace::new("conformance").expect("the suite namespace is valid");
let other = VectorNamespace::new("conformance-other").expect("the other namespace is valid");
vector_orders_filters_and_limits(index, &ns).await;
vector_isolates_replaces_and_deletes(index, &ns, &other).await;
}
async fn vector_matches(
index: &dyn cratefield_core::VectorIndex,
ns: &VectorNamespace,
vector: &[f32],
k: usize,
filter: &VectorFilter,
) -> Vec<VectorMatch> {
index
.query(ns, vector, k, filter)
.await
.expect("the scripted query answers")
}
async fn vector_orders_filters_and_limits(
index: &dyn cratefield_core::VectorIndex,
ns: &VectorNamespace,
) {
let all = VectorFilter::new();
index
.upsert(
ns,
&[
VectorRecord::new("east", vec![1.0, 0.0, 0.0]),
VectorRecord::new("north", vec![0.0, 1.0, 0.0]),
VectorRecord::new("diagonal", vec![1.0, 1.0, 0.0]),
],
)
.await
.expect("the scripted upsert answers");
let ranked = vector_matches(index, ns, &[1.0, 0.0, 0.0], 3, &all).await;
assert_eq!(
ranked.iter().map(|m| m.id.as_str()).collect::<Vec<_>>(),
["east", "diagonal", "north"],
"rule 1 (nearest first): cosine similarity must rank descending"
);
assert!(
(ranked[0].score - 1.0).abs() < SCORE_TOLERANCE,
"rule 1 (a vector is most similar to itself): got {}",
ranked[0].score
);
assert_eq!(
vector_matches(index, ns, &[1.0, 0.0, 0.0], 1, &all)
.await
.len(),
1,
"rule 2 (at most k)"
);
assert!(
vector_matches(index, ns, &[1.0, 0.0, 0.0], 0, &all)
.await
.is_empty(),
"rule 2 (k = 0 answers nothing)"
);
let doc = VectorRecord::new("doc", vec![0.9, 0.1, 0.0])
.with_metadata("kind", serde_json::json!("doc"))
.with_metadata("lang", serde_json::json!("en"));
index
.upsert(
ns,
&[
doc.clone(),
VectorRecord::new("chat", vec![0.95, 0.05, 0.0])
.with_metadata("kind", serde_json::json!("chat")),
],
)
.await
.expect("the scripted upsert answers");
let kind_doc = VectorFilter::new().eq("kind", serde_json::json!("doc"));
let filtered = vector_matches(index, ns, &[1.0, 0.0, 0.0], 5, &kind_doc).await;
assert_eq!(
filtered.iter().map(|m| m.id.as_str()).collect::<Vec<_>>(),
["doc"],
"rule 3 (the filter keeps only equality matches)"
);
assert_eq!(
filtered[0].metadata, doc.metadata,
"rule 3 (metadata round-trips byte for byte)"
);
assert_eq!(
vector_matches(
index,
ns,
&[1.0, 0.0, 0.0],
5,
&kind_doc.clone().eq("lang", serde_json::json!("en")),
)
.await
.len(),
1,
"rule 3 (every key of the filter must match)"
);
assert!(
vector_matches(
index,
ns,
&[1.0, 0.0, 0.0],
5,
&kind_doc.eq("lang", serde_json::json!("fr")),
)
.await
.is_empty(),
"rule 3 (one mismatching key keeps nothing)"
);
}
async fn vector_isolates_replaces_and_deletes(
index: &dyn cratefield_core::VectorIndex,
ns: &VectorNamespace,
other: &VectorNamespace,
) {
let all = VectorFilter::new();
let theirs = VectorRecord::new("east", vec![0.0, 0.0, 1.0])
.with_metadata("owner", serde_json::json!("other"));
index
.upsert(other, &[theirs])
.await
.expect("the scripted upsert answers");
let owners_other = VectorFilter::new().eq("owner", serde_json::json!("other"));
assert_eq!(
vector_matches(index, other, &[0.0, 0.0, 1.0], 5, &owners_other)
.await
.iter()
.map(|m| m.id.as_str())
.collect::<Vec<_>>(),
["east"],
"rule 4 (the other namespace holds its own `east`)"
);
assert!(
vector_matches(index, ns, &[0.0, 0.0, 1.0], 5, &owners_other)
.await
.is_empty(),
"rule 4 (a query never crosses namespaces)"
);
index
.upsert(
ns,
&[VectorRecord::new("swap", vec![1.0, 0.0, 0.0])
.with_metadata("version", serde_json::json!(1))],
)
.await
.expect("the scripted upsert answers");
index
.upsert(
ns,
&[VectorRecord::new("swap", vec![0.6, 0.8, 0.0])
.with_metadata("version", serde_json::json!(2))],
)
.await
.expect("the scripted upsert answers");
let swap = vector_matches(index, ns, &[0.6, 0.8, 0.0], 5, &all)
.await
.into_iter()
.find(|m| m.id == "swap")
.expect("rule 5 (the replaced record is still there)");
assert!(
(swap.score - 1.0).abs() < SCORE_TOLERANCE,
"rule 5 (the upsert replaced the values): got {}",
swap.score
);
assert_eq!(
swap.metadata["version"],
serde_json::json!(2),
"rule 5 (the upsert replaced the metadata)"
);
assert!(
vector_matches(
index,
ns,
&[1.0, 0.0, 0.0],
5,
&VectorFilter::new().eq("version", serde_json::json!(1)),
)
.await
.is_empty(),
"rule 5 (the old metadata is gone, not merged)"
);
index
.delete(other, &["east".to_owned(), "never-there".to_owned()])
.await
.expect("rule 6 (deleting a missing id answers Ok)");
assert!(
vector_matches(index, other, &[0.0, 0.0, 1.0], 5, &all)
.await
.is_empty(),
"rule 6 (the delete removed the record)"
);
assert!(
vector_matches(index, ns, &[1.0, 0.0, 0.0], 5, &all)
.await
.iter()
.any(|m| m.id == "east"),
"rule 4 (a delete in one namespace cannot touch the other)"
);
let ours: Vec<String> = ["east", "north", "diagonal", "doc", "chat", "swap"]
.into_iter()
.map(String::from)
.collect();
index
.delete(ns, &ours)
.await
.expect("the scripted cleanup answers");
index
.delete(other, &["east".to_owned()])
.await
.expect("the scripted cleanup answers");
}
pub async fn classifier_truncates_long_state(classifier: &dyn Classifier) {
let questions = classifier_conformance_questions();
let profile = classifier.profile();
let long_state = classifier_conformance_state().repeat(profile.max_state_chars + 1);
let answers = classifier
.ask(&long_state, &questions)
.await
.unwrap_or_else(|error| {
panic!(
"an over-limit state still gets answers — truncation is the adapter's job, not \
the caller's: {error:?}"
)
});
for (id, question) in &questions {
let answer = answers.get(id).unwrap_or_else(|| {
panic!(
"rule 1 (the answers are keyed exactly as the questions were asked): the \
over-limit ask dropped {id:?}"
)
});
assert_answer_conforms(id, question, answer);
}
}
pub const TEXT_MODEL_CONFORMANCE_REPLY: &str = "cratefield text model conformance reply";
pub const TEXT_MODEL_CONFORMANCE_INPUT_TOKENS: u64 = 17;
pub const TEXT_MODEL_CONFORMANCE_OUTPUT_TOKENS: u64 = 5;
pub const TEXT_MODEL_CONFORMANCE_CACHED_INPUT_TOKENS: u64 = 9;
#[must_use]
pub fn text_model_conformance_prompt() -> Prompt {
Prompt::new(ModelTier::Fast).user("Reply with the conformance reply, exactly.")
}
pub async fn text_model_conformance(model: &dyn TextModel) {
let prompt = text_model_conformance_prompt();
let completion = model.complete(&prompt).await.unwrap_or_else(|error| {
panic!(
"the scripted completion failed — script the transport to answer \
text_model_conformance_prompt() with TEXT_MODEL_CONFORMANCE_REPLY and the \
canonical token counts: {error:?}"
)
});
assert_eq!(
completion.text, TEXT_MODEL_CONFORMANCE_REPLY,
"rule 1 (the text is the scripted reply, verbatim): got {:?}",
completion.text
);
assert!(
!completion.model.is_empty(),
"rule 2 (the completion names the model that answered): model is empty"
);
assert!(
completion.input_tokens == TEXT_MODEL_CONFORMANCE_INPUT_TOKENS
&& completion.output_tokens == TEXT_MODEL_CONFORMANCE_OUTPUT_TOKENS,
"rule 3 (every completion reports its token usage): got input {}, output {}, \
expected {TEXT_MODEL_CONFORMANCE_INPUT_TOKENS}/{TEXT_MODEL_CONFORMANCE_OUTPUT_TOKENS}",
completion.input_tokens,
completion.output_tokens
);
assert_eq!(
completion.cached_input_tokens,
Some(TEXT_MODEL_CONFORMANCE_CACHED_INPUT_TOKENS),
"rule 4 (the cached subset is reported where the vendor reports one)"
);
if let Some(cached) = completion.cached_input_tokens {
assert!(
cached <= completion.input_tokens,
"rule 4 (the cached subset never exceeds the total it is a subset of): cached \
{cached} over input {}",
completion.input_tokens
);
}
assert!(
completion.json.is_none(),
"rule 5 (a prompt that asked for no schema comes back without a parsed value): got \
{:?}",
completion.json
);
}
pub async fn text_model_conformance_not_configured(model: &dyn TextModel) {
let result = model.complete(&text_model_conformance_prompt()).await;
assert_eq!(
result.err(),
Some(TextModelError::NotConfigured),
"an adapter with no key answers NotConfigured"
);
}
pub async fn text_model_image_bounds_conformance(model: &dyn TextModel) {
let too_many = Prompt::new(ModelTier::Fast)
.user_parts((0..=MAX_PROMPT_IMAGES).map(|_| Part::image(ImageMediaType::Png, vec![0u8])));
assert_eq!(
model.complete(&too_many).await.err(),
Some(TextModelError::ImageLimit(ImageLimit::TooMany {
count: MAX_PROMPT_IMAGES + 1,
})),
"more than {MAX_PROMPT_IMAGES} images is refused with TooMany, before any network call"
);
let over = MAX_IMAGE_ENCODED_BYTES + 4;
let over_raw = raw_len_for_encoded(over);
assert_eq!(encoded_image_len(over_raw), over);
let too_large = Prompt::new(ModelTier::Fast).user_parts([
Part::text("a caption"),
Part::image(ImageMediaType::Png, vec![0u8; over_raw]),
]);
assert_eq!(
model.complete(&too_large).await.err(),
Some(TextModelError::ImageLimit(ImageLimit::ImageTooLarge {
index: 0,
encoded_bytes: over,
})),
"an image over the per-image ceiling is refused with ImageTooLarge, before any network call"
);
let each = MAX_IMAGE_ENCODED_BYTES - 4;
let each_raw = raw_len_for_encoded(each);
assert!(2 * each > MAX_PROMPT_IMAGE_ENCODED_BYTES);
let too_total = Prompt::new(ModelTier::Fast).user_parts([
Part::image(ImageMediaType::Png, vec![0u8; each_raw]),
Part::image(ImageMediaType::Jpeg, vec![0u8; each_raw]),
]);
assert_eq!(
model.complete(&too_total).await.err(),
Some(TextModelError::ImageLimit(ImageLimit::TotalTooLarge {
encoded_bytes: 2 * each,
})),
"images together over the prompt total are refused with TotalTooLarge, before any network \
call"
);
}
fn raw_len_for_encoded(encoded: usize) -> usize {
(encoded / 4) * 3
}