cratefield_testing/port.rs
1//! Port-only conformance helpers (issue #201): checks over a single port
2//! trait that need nothing beyond `cratefield-core`. Gated behind the
3//! light `port-conformance` feature so an adapter whose whole use of the
4//! kit is one free function does not pay for the harness, the router or
5//! a database adapter.
6//!
7//! The helpers that grew heavier stayed in [`crate::conformance`]
8//! (behind `harness`): the module suite needs the `TestHarness`, and the
9//! fakes were left there too because `FakeHttpClient` reads axum bodies.
10
11use std::collections::BTreeMap;
12
13use cratefield_core::{
14 Answer, AnswerValue, Classifier, ClassifierError, PushError, Question, validate_questions,
15};
16
17/// The three recipients an adapter can be handed, one per transport.
18///
19/// Every `Recipient` variant appears here, so a new transport added to the
20/// port makes this list — and therefore every adapter's conformance run —
21/// fail to compile until it is decided what the existing adapters answer.
22fn every_recipient() -> Vec<cratefield_core::Recipient> {
23 let all = [
24 cratefield_core::Recipient::apns("conformance-device-token"),
25 cratefield_core::Recipient::fcm("conformance-registration-token"),
26 cratefield_core::Recipient::web_push(
27 "https://push.example.test/conformance",
28 "BConformanceP256dhKey",
29 "ConformanceAuthKey",
30 ),
31 ];
32 for recipient in &all {
33 // Exhaustive by construction: adding a variant breaks this match.
34 match recipient {
35 cratefield_core::Recipient::Apns { .. }
36 | cratefield_core::Recipient::Fcm { .. }
37 | cratefield_core::Recipient::WebPush { .. } => {}
38 }
39 }
40 all.to_vec()
41}
42
43/// Runs a [`Push`](cratefield_core::Push) adapter against **every**
44/// [`Recipient`](cratefield_core::Recipient) variant and asserts the port's
45/// contract for transports it does not serve (issue #177): a clean
46/// [`PushError::Rejected`](cratefield_core::PushError::Rejected) naming the
47/// recipient as unsupported — never a panic, never a silent success, and
48/// never a `Transient` that an outbox would retry forever.
49///
50/// `serves` lists the transports this adapter does speak; those must answer
51/// something other than an unsupported-recipient rejection.
52///
53/// ```rust,ignore
54/// // The APNs adapter serves iOS and nothing else.
55/// push_recipient_conformance(&apns, &[Platform::Ios]).await;
56/// ```
57///
58/// # Panics
59///
60/// Panics with the failing recipient named when the contract is broken.
61pub async fn push_recipient_conformance(
62 push: &dyn cratefield_core::Push,
63 serves: &[cratefield_core::Platform],
64) {
65 let notification = cratefield_core::Notification::new("conformance", "probe");
66 for recipient in every_recipient() {
67 let platform = recipient.platform();
68 let result = push.send(&recipient, ¬ification).await;
69 let unsupported = matches!(
70 &result,
71 Err(PushError::Rejected(message)) if message.contains("unsupported recipient")
72 );
73 if serves.contains(&platform) {
74 assert!(
75 !unsupported,
76 "adapter claims to serve {platform} but rejected its recipient as unsupported"
77 );
78 } else {
79 assert!(
80 unsupported,
81 "adapter does not serve {platform}: the port's answer is \
82 PushError::Rejected(\"unsupported recipient…\"), got {result:?}"
83 );
84 }
85 }
86}
87
88/// Asserts the named crate's normal dependency tree carries none of the
89/// dependencies a wasm module must never see: `worker`, `wasm-bindgen`,
90/// `tokio`, `reqwest`. Shell-out check (`cargo tree --edges normal`) so
91/// the assertion is made against the graph cargo actually resolves, not
92/// a copy of it that could drift.
93///
94/// # Panics
95///
96/// Panics when a forbidden dependency is found or cargo fails.
97pub fn assert_wasm_safe_deps(module_crate: &str) {
98 let output =
99 std::process::Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()))
100 .args(["tree", "-p", module_crate, "--edges", "normal"])
101 .output()
102 .unwrap_or_else(|err| panic!("cargo tree failed: {err}"));
103 assert!(
104 output.status.success(),
105 "cargo tree failed for {module_crate}"
106 );
107 let tree = String::from_utf8_lossy(&output.stdout);
108 for forbidden in ["worker v", "wasm-bindgen v", "tokio v", "reqwest v"] {
109 assert!(
110 !tree.contains(forbidden),
111 "{module_crate} pulls forbidden dependency `{}`:\n{tree}",
112 forbidden.trim_end_matches(" v")
113 );
114 }
115}
116
117// ---------------------------------------------------------------------------
118// The `Classifier` port suite (issue #456)
119
120/// How far a reported `confidence` may sit from the probability the same
121/// answer reports for its own chosen label and still count as agreeing
122/// (`f32` arithmetic across an adapter's normalisation is not exact).
123const CONFIDENCE_TOLERANCE: f32 = 1e-4;
124
125/// The canonical question set the conformance suite asks: one
126/// [`Question::Choice`], one [`Question::Score`], one [`Question::Noul`],
127/// so every shape of the port is exercised once. The ids are fixed —
128/// `topic`, `severity`, `urgent` — and an adapter test scripts its
129/// transport to answer **exactly these** before running the suite.
130#[must_use]
131pub fn classifier_conformance_questions() -> BTreeMap<String, Question> {
132 BTreeMap::from([
133 (
134 "topic".to_owned(),
135 Question::Choice {
136 instructions: "Which topic does the state belong to?".to_owned(),
137 criteria: BTreeMap::from([
138 ("billing".to_owned(), "money, invoices".to_owned()),
139 ("bugs".to_owned(), "something is broken".to_owned()),
140 ]),
141 },
142 ),
143 (
144 "severity".to_owned(),
145 Question::Score {
146 instructions: "How severe is the state?".to_owned(),
147 levels: vec![
148 ("1".to_owned(), "a typo".to_owned()),
149 ("5".to_owned(), "data loss".to_owned()),
150 ],
151 },
152 ),
153 (
154 "urgent".to_owned(),
155 Question::Noul {
156 instructions: "Does the state need attention now?".to_owned(),
157 },
158 ),
159 ])
160}
161
162/// The state the conformance suite asks about: short and fixed, so a
163/// scripted transport can match it.
164#[must_use]
165pub fn classifier_conformance_state() -> &'static str {
166 "cratefield classifier conformance state"
167}
168
169/// Asserts one answer agrees with the question it answers. The rules are
170/// numbered as `classifier_conformance` documents them, and every panic
171/// names the rule it caught.
172fn assert_answer_conforms(id: &str, question: &Question, answer: &Answer) {
173 let labels = question.labels();
174
175 // Rule 2: the value's variant matches the question's kind.
176 let chosen: Option<String> = match (question, &answer.value) {
177 (Question::Choice { .. }, AnswerValue::Choice(label)) => {
178 // Rule 3: the chosen label is one the question offered.
179 assert!(
180 labels.contains(&label.as_str()),
181 "rule 3 (a choice is of an offered label): question {id:?} offers {labels:?}, \
182 the answer chose {label:?}"
183 );
184 Some(label.clone())
185 }
186 (Question::Score { .. }, AnswerValue::Score(value)) => {
187 // Rule 3, as far as the port can express it: a score whose
188 // value names one of the levels names it as its label. A
189 // provider that interpolates (`3.5` on a `"1"`/`"5"` scale)
190 // states its confidence through `Answer::new` instead, and no
191 // chosen label is assertable there.
192 let label = value.to_string();
193 if labels.contains(&label.as_str()) {
194 Some(label)
195 } else {
196 None
197 }
198 }
199 (Question::Noul { .. }, AnswerValue::Noul(verdict)) => {
200 Some(if *verdict { "true" } else { "false" }.to_owned())
201 }
202 (kind, value) => panic!(
203 "rule 2 (the answer's shape matches the question's): question {id:?} is {}, \
204 the answer carried {value:?}",
205 kind.kind()
206 ),
207 };
208
209 // Rule 4: every probability is keyed by an offered label, and is a
210 // finite number within 0.0..=1.0. Keys are a subset on purpose: the
211 // port allows a provider that reports only a top-1.
212 for (label, probability) in &answer.probabilities {
213 assert!(
214 labels.contains(&label.as_str()),
215 "rule 4 (probabilities are keyed by offered labels): question {id:?} offers \
216 {labels:?}, the answer reported {label:?}"
217 );
218 assert!(
219 probability.is_finite() && (0.0..=1.0).contains(probability),
220 "rule 4 (a probability is finite and within 0.0..=1.0): question {id:?} reported \
221 {label:?} = {probability}"
222 );
223 }
224
225 // Rule 5: `confidence` is finite, within 0.0..=1.0, and agrees with
226 // the probability the same answer reports for its own chosen label.
227 assert!(
228 answer.confidence.is_finite() && (0.0..=1.0).contains(&answer.confidence),
229 "rule 5 (a confidence is finite and within 0.0..=1.0): question {id:?} reported \
230 confidence {}",
231 answer.confidence
232 );
233 if let Some(label) = chosen {
234 match answer.probabilities.get(&label) {
235 Some(probability) => assert!(
236 (answer.confidence - probability).abs() <= CONFIDENCE_TOLERANCE,
237 "rule 5 (confidence agrees with the distribution for the chosen label): \
238 question {id:?} chose {label:?} at {probability} but reported confidence {}",
239 answer.confidence
240 ),
241 // An absent label is the port constructors' 0.0: consistent
242 // only when the confidence says the same.
243 None => assert!(
244 answer.confidence.abs() <= CONFIDENCE_TOLERANCE,
245 "rule 5 (confidence agrees with the distribution for the chosen label): \
246 question {id:?} chose {label:?}, which its probabilities omit, so the only \
247 agreeing confidence is 0.0, got {}",
248 answer.confidence
249 ),
250 }
251 }
252}
253
254/// Asserts the `Classifier` trait contract against a classifier whose
255/// transport is already scripted to answer
256/// [`classifier_conformance_questions`] about
257/// [`classifier_conformance_state`]. The suite makes **exactly one**
258/// `ask` call — script that one request and no other.
259///
260/// Asserted, each panic naming the rule it caught:
261///
262/// 1. the answer map's keys are **exactly** the asked question ids;
263/// 2. each answer's `value` variant matches its question's kind;
264/// 3. a choice's chosen label, a noul's verdict label, and (where the
265/// value names one) a score's level are labels the question offered;
266/// 4. every key of `probabilities` is an offered label and every value is
267/// finite within `0.0..=1.0` — keys may omit labels (a top-1 provider
268/// is legal), they may never invent one;
269/// 5. `confidence` is finite within `0.0..=1.0` and agrees with the
270/// probability the same answer reports for its own chosen label,
271/// within an explicit tolerance — the invariant that keeps an adapter
272/// from reporting a confidence its own distribution contradicts;
273/// 6. `profile().max_state_chars` is above zero, and `truncate` of an
274/// over-limit state returns something no longer than the limit.
275///
276/// What the suite deliberately does **not** assert: that `confidence`
277/// means the same number on two adapters. Calibration is not portable —
278/// a `0.8` from a purpose-trained classifier and a `0.8` elicited from a
279/// general language model are different numbers — so no assertion here
280/// compares confidence values between adapters, and a threshold tuned
281/// against one stays per-adapter (see `cratefield_core::Calibration`).
282///
283/// ```rust,ignore
284/// // In the adapter's own test, after scripting its transport:
285/// classifier_conformance(&adapter).await;
286/// ```
287///
288/// # Panics
289///
290/// Panics naming the broken rule when the contract is violated, and when
291/// the scripted ask fails.
292pub async fn classifier_conformance(classifier: &dyn Classifier) {
293 // Rule 6: the profile is usable and `truncate` honours its own limit,
294 // in both directions. Needs no transport.
295 let profile = classifier.profile();
296 assert!(
297 profile.max_state_chars > 0,
298 "rule 6 (the state ceiling is above zero): max_state_chars is {}",
299 profile.max_state_chars
300 );
301 let over_limit = "x".repeat(profile.max_state_chars + 1);
302 let (sent, dropped) = profile.truncate(&over_limit);
303 assert!(
304 dropped,
305 "rule 6 (truncate reports what it dropped): a state over the limit came back \
306 with dropped = false"
307 );
308 assert!(
309 sent.len() <= profile.max_state_chars,
310 "rule 6 (truncate honours the limit): a state of {} chars came back as {} over a \
311 limit of {}",
312 over_limit.len(),
313 sent.len(),
314 profile.max_state_chars
315 );
316 let (untouched, dropped) = profile.truncate(classifier_conformance_state());
317 assert!(
318 !dropped && untouched == classifier_conformance_state(),
319 "rule 6 (truncate is a no-op under the limit): the canonical state was trimmed"
320 );
321
322 // The one ask the suite makes.
323 let questions = classifier_conformance_questions();
324 let answers = classifier
325 .ask(classifier_conformance_state(), &questions)
326 .await
327 .unwrap_or_else(|error| {
328 panic!(
329 "the scripted ask failed — script the transport to answer \
330 classifier_conformance_questions() about classifier_conformance_state(): \
331 {error:?}"
332 )
333 });
334
335 // Rule 1: the keys are exactly the asked ids — no missing, no extra.
336 let missing: Vec<&String> = questions
337 .keys()
338 .filter(|id| !answers.contains_key(*id))
339 .collect();
340 let extra: Vec<&String> = answers
341 .keys()
342 .filter(|id| !questions.contains_key(*id))
343 .collect();
344 assert!(
345 missing.is_empty() && extra.is_empty(),
346 "rule 1 (the answers are keyed exactly as the questions were asked): missing \
347 {missing:?}, extra {extra:?}"
348 );
349
350 // Rules 2-5, per question.
351 for (id, question) in &questions {
352 assert_answer_conforms(id, question, &answers[id]);
353 }
354}
355
356/// Asserts the contract that needs no transport: a malformed question set
357/// — empty, blank id, blank instructions, a one-criterion choice, a
358/// one-level score — comes back as
359/// [`ClassifierError::Rejected`](cratefield_core::ClassifierError::Rejected)
360/// and never panics. Safe to run against an unconfigured adapter: the
361/// set is refused before any provider is reached.
362///
363/// # Panics
364///
365/// Panics when a malformed set is answered, or refused as anything but
366/// `Rejected`.
367pub async fn classifier_rejects_malformed_questions(classifier: &dyn Classifier) {
368 let malformed: [BTreeMap<String, Question>; 5] = [
369 BTreeMap::new(),
370 BTreeMap::from([(
371 " ".to_owned(),
372 Question::Noul {
373 instructions: "A blank id is malformed.".to_owned(),
374 },
375 )]),
376 BTreeMap::from([(
377 "blank-instructions".to_owned(),
378 Question::Noul {
379 instructions: " ".to_owned(),
380 },
381 )]),
382 BTreeMap::from([(
383 "one-criterion".to_owned(),
384 Question::Choice {
385 instructions: "A choice needs two criteria.".to_owned(),
386 criteria: BTreeMap::from([("only".to_owned(), "the only one".to_owned())]),
387 },
388 )]),
389 BTreeMap::from([(
390 "one-level".to_owned(),
391 Question::Score {
392 instructions: "A score needs two levels.".to_owned(),
393 levels: vec![("3".to_owned(), "middling".to_owned())],
394 },
395 )]),
396 ];
397 for questions in &malformed {
398 // The port's own validator refuses these five; if it ever stops
399 // to, this suite's list has to change with it — the guard keeps
400 // the check from passing vacuously.
401 assert!(
402 validate_questions(questions).is_err(),
403 "the suite's malformed sets stopped being malformed — update this suite \
404 alongside cratefield_core::validate_questions"
405 );
406 let result = classifier
407 .ask(classifier_conformance_state(), questions)
408 .await;
409 assert!(
410 matches!(result, Err(ClassifierError::Rejected(_))),
411 "a malformed question set is Rejected, never a panic and never an answer: got \
412 {result:?}"
413 );
414 }
415}
416
417/// Asserts an adapter with no key, binding or model behind it answers
418/// [`ClassifierError::NotConfigured`](cratefield_core::ClassifierError::NotConfigured)
419/// rather than panicking — the unwired port is an error the caller
420/// matches, so a module can degrade on it.
421///
422/// # Panics
423///
424/// Panics when the ask succeeds or fails as anything but `NotConfigured`.
425pub async fn classifier_not_configured(classifier: &dyn Classifier) {
426 let result = classifier
427 .ask(
428 classifier_conformance_state(),
429 &classifier_conformance_questions(),
430 )
431 .await;
432 assert_eq!(
433 result.err(),
434 Some(ClassifierError::NotConfigured),
435 "an adapter with no key/binding/model answers NotConfigured"
436 );
437}
438
439/// Asserts that asking with a `state` longer than
440/// `profile().max_state_chars` still succeeds — truncation is the
441/// adapter's job, so an over-limit state must never surface as an error
442/// to the caller.
443///
444/// A separate, opt-in check because it asks a **second** time: an
445/// adapter test whose transport matches one scripted request exactly can
446/// run `classifier_conformance` alone, and add this when its mock can
447/// take the second call.
448///
449/// # Panics
450///
451/// Panics when the over-limit ask fails or its answers break the same
452/// per-answer rules `classifier_conformance` asserts.
453pub async fn classifier_truncates_long_state(classifier: &dyn Classifier) {
454 let questions = classifier_conformance_questions();
455 let profile = classifier.profile();
456 let long_state = classifier_conformance_state().repeat(profile.max_state_chars + 1);
457
458 let answers = classifier
459 .ask(&long_state, &questions)
460 .await
461 .unwrap_or_else(|error| {
462 panic!(
463 "an over-limit state still gets answers — truncation is the adapter's job, not \
464 the caller's: {error:?}"
465 )
466 });
467 for (id, question) in &questions {
468 let answer = answers.get(id).unwrap_or_else(|| {
469 panic!(
470 "rule 1 (the answers are keyed exactly as the questions were asked): the \
471 over-limit ask dropped {id:?}"
472 )
473 });
474 assert_answer_conforms(id, question, answer);
475 }
476}