Skip to main content

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, 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
19/// The three recipients an adapter can be handed, one per transport.
20///
21/// Every `Recipient` variant appears here, so a new transport added to the
22/// port makes this list — and therefore every adapter's conformance run —
23/// fail to compile until it is decided what the existing adapters answer.
24fn 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        // Exhaustive by construction: adding a variant breaks this match.
36        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
45/// Runs a [`Push`](cratefield_core::Push) adapter against **every**
46/// [`Recipient`](cratefield_core::Recipient) variant and asserts the port's
47/// contract for transports it does not serve (issue #177): a clean
48/// [`PushError::Rejected`](cratefield_core::PushError::Rejected) naming the
49/// recipient as unsupported — never a panic, never a silent success, and
50/// never a `Transient` that an outbox would retry forever.
51///
52/// `serves` lists the transports this adapter does speak; those must answer
53/// something other than an unsupported-recipient rejection.
54///
55/// ```rust,ignore
56/// // The APNs adapter serves iOS and nothing else.
57/// push_recipient_conformance(&apns, &[Platform::Ios]).await;
58/// ```
59///
60/// # Panics
61///
62/// Panics with the failing recipient named when the contract is broken.
63pub 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, &notification).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
90/// Asserts the named crate's normal dependency tree carries none of the
91/// dependencies a wasm module must never see: `worker`, `wasm-bindgen`,
92/// `tokio`, `reqwest`. Shell-out check (`cargo tree --edges normal`) so
93/// the assertion is made against the graph cargo actually resolves, not
94/// a copy of it that could drift.
95///
96/// # Panics
97///
98/// Panics when a forbidden dependency is found or cargo fails.
99pub 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
119// ---------------------------------------------------------------------------
120// The `Classifier` port suite (issue #456)
121
122/// How far a reported `confidence` may sit from the probability the same
123/// answer reports for its own chosen label and still count as agreeing
124/// (`f32` arithmetic across an adapter's normalisation is not exact).
125const CONFIDENCE_TOLERANCE: f32 = 1e-4;
126
127/// The canonical question set the conformance suite asks: one
128/// [`Question::Choice`], one [`Question::Score`], one [`Question::Noul`],
129/// so every shape of the port is exercised once. The ids are fixed —
130/// `topic`, `severity`, `urgent` — and an adapter test scripts its
131/// transport to answer **exactly these** before running the suite.
132#[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/// The state the conformance suite asks about: short and fixed, so a
165/// scripted transport can match it.
166#[must_use]
167pub fn classifier_conformance_state() -> &'static str {
168    "cratefield classifier conformance state"
169}
170
171/// Asserts one answer agrees with the question it answers. The rules are
172/// numbered as `classifier_conformance` documents them, and every panic
173/// names the rule it caught.
174fn assert_answer_conforms(id: &str, question: &Question, answer: &Answer) {
175    let labels = question.labels();
176
177    // Rule 2: the value's variant matches the question's kind.
178    let chosen: Option<String> = match (question, &answer.value) {
179        (Question::Choice { .. }, AnswerValue::Choice(label)) => {
180            // Rule 3: the chosen label is one the question offered.
181            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            // Rule 3, as far as the port can express it: a score whose
190            // value names one of the levels names it as its label. A
191            // provider that interpolates (`3.5` on a `"1"`/`"5"` scale)
192            // states its confidence through `Answer::new` instead, and no
193            // chosen label is assertable there.
194            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    // Rule 4: every probability is keyed by an offered label, and is a
212    // finite number within 0.0..=1.0. Keys are a subset on purpose: the
213    // port allows a provider that reports only a top-1.
214    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    // Rule 5: `confidence` is finite, within 0.0..=1.0, and agrees with
228    // the probability the same answer reports for its own chosen label.
229    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            // An absent label is the port constructors' 0.0: consistent
244            // only when the confidence says the same.
245            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
256/// Asserts the `Classifier` trait contract against a classifier whose
257/// transport is already scripted to answer
258/// [`classifier_conformance_questions`] about
259/// [`classifier_conformance_state`]. The suite makes **exactly one**
260/// `ask` call — script that one request and no other.
261///
262/// Asserted, each panic naming the rule it caught:
263///
264/// 1. the answer map's keys are **exactly** the asked question ids;
265/// 2. each answer's `value` variant matches its question's kind;
266/// 3. a choice's chosen label, a noul's verdict label, and (where the
267///    value names one) a score's level are labels the question offered;
268/// 4. every key of `probabilities` is an offered label and every value is
269///    finite within `0.0..=1.0` — keys may omit labels (a top-1 provider
270///    is legal), they may never invent one;
271/// 5. `confidence` is finite within `0.0..=1.0` and agrees with the
272///    probability the same answer reports for its own chosen label,
273///    within an explicit tolerance — the invariant that keeps an adapter
274///    from reporting a confidence its own distribution contradicts;
275/// 6. `profile().max_state_chars` is above zero, and `truncate` of an
276///    over-limit state returns something no longer than the limit.
277///
278/// What the suite deliberately does **not** assert: that `confidence`
279/// means the same number on two adapters. Calibration is not portable —
280/// a `0.8` from a purpose-trained classifier and a `0.8` elicited from a
281/// general language model are different numbers — so no assertion here
282/// compares confidence values between adapters, and a threshold tuned
283/// against one stays per-adapter (see `cratefield_core::Calibration`).
284///
285/// ```rust,ignore
286/// // In the adapter's own test, after scripting its transport:
287/// classifier_conformance(&adapter).await;
288/// ```
289///
290/// # Panics
291///
292/// Panics naming the broken rule when the contract is violated, and when
293/// the scripted ask fails.
294pub async fn classifier_conformance(classifier: &dyn Classifier) {
295    // Rule 6: the profile is usable and `truncate` honours its own limit,
296    // in both directions. Needs no transport.
297    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    // The one ask the suite makes.
325    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    // Rule 1: the keys are exactly the asked ids — no missing, no extra.
338    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    // Rules 2-5, per question.
353    for (id, question) in &questions {
354        assert_answer_conforms(id, question, &answers[id]);
355    }
356}
357
358/// Asserts the contract that needs no transport: a malformed question set
359/// — empty, blank id, blank instructions, a one-criterion choice, a
360/// one-level score — comes back as
361/// [`ClassifierError::Rejected`](cratefield_core::ClassifierError::Rejected)
362/// and never panics. Safe to run against an unconfigured adapter: the
363/// set is refused before any provider is reached.
364///
365/// # Panics
366///
367/// Panics when a malformed set is answered, or refused as anything but
368/// `Rejected`.
369pub 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        // The port's own validator refuses these five; if it ever stops
401        // to, this suite's list has to change with it — the guard keeps
402        // the check from passing vacuously.
403        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
419/// Asserts an adapter with no key, binding or model behind it answers
420/// [`ClassifierError::NotConfigured`](cratefield_core::ClassifierError::NotConfigured)
421/// rather than panicking — the unwired port is an error the caller
422/// matches, so a module can degrade on it.
423///
424/// # Panics
425///
426/// Panics when the ask succeeds or fails as anything but `NotConfigured`.
427pub 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
441// ---------------------------------------------------------------------------
442// The `VectorIndex` port suite (issue #561)
443
444/// How far a reported `score` may sit from the exact cosine similarity and
445/// still count as agreeing (`f32` arithmetic differs across adapters).
446const SCORE_TOLERANCE: f32 = 1.0e-3;
447
448use cratefield_core::{VectorFilter, VectorMatch, VectorNamespace, VectorRecord};
449
450/// Asserts the [`VectorIndex`](cratefield_core::VectorIndex) contract
451/// against an adapter, over small three-dimensional vectors (issue #561):
452///
453/// 1. ranking is cosine similarity descending, and a vector is most
454///    similar to itself;
455/// 2. `k` is a limit, not a target — at most `k` matches, none for `k = 0`;
456/// 3. an equality filter keeps only the records whose metadata equals it
457///    on **every** key, and metadata round-trips byte for byte;
458/// 4. namespaces are isolated: the same id upserted into two namespaces
459///    stays two records, and a delete in one cannot touch the other;
460/// 5. an upsert replaces the whole record — values and metadata, no merge;
461/// 6. a delete removes, and deleting a missing id answers `Ok`.
462///
463/// Writes into the namespaces `conformance` and `conformance-other` and
464/// deletes what it wrote, so run it against a fresh or disposable index.
465///
466/// # Panics
467///
468/// Panics naming the broken rule when the contract is violated.
469pub 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
476/// The suite's `query`, asserted to answer.
477async 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
490/// Rules 1-3: nearest-first ordering, `k` as a limit, the every-key
491/// equality filter, and a byte-for-byte metadata round trip.
492async 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    // `chat` is the nearer neighbour, so only the filter keeps it out.
533    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
585/// Rules 4-6: namespace isolation with the *same* id on both sides, an
586/// upsert that replaces instead of merging, and idempotent deletes.
587async 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    // The doc's promise: every id the suite wrote, in both namespaces,
676    // deleted — so a second run against a persistent index starts clean.
677    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}
690/// Asserts that asking with a `state` longer than
691/// `profile().max_state_chars` still succeeds — truncation is the
692/// adapter's job, so an over-limit state must never surface as an error
693/// to the caller.
694///
695/// A separate, opt-in check because it asks a **second** time: an
696/// adapter test whose transport matches one scripted request exactly can
697/// run `classifier_conformance` alone, and add this when its mock can
698/// take the second call.
699///
700/// # Panics
701///
702/// Panics when the over-limit ask fails or its answers break the same
703/// per-answer rules `classifier_conformance` asserts.
704pub 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
729// ---------------------------------------------------------------------------
730// The `TextModel` port suite (issue #560)
731
732/// The canonical reply the conformance suite expects, verbatim: short and
733/// fixed, so an adapter test can script its transport to answer **exactly
734/// this** in its vendor's wire shape.
735pub const TEXT_MODEL_CONFORMANCE_REPLY: &str = "cratefield text model conformance reply";
736
737/// The canonical input-token count: the total prompt tokens the scripted
738/// transport reports. Every `TextModel` completion reports usage, so the
739/// suite asserts this count exactly — an adapter whose vendor omits a
740/// usage block fails here, not silently in a caller's cost accounting.
741pub const TEXT_MODEL_CONFORMANCE_INPUT_TOKENS: u64 = 17;
742
743/// The canonical output-token count the scripted transport reports.
744pub const TEXT_MODEL_CONFORMANCE_OUTPUT_TOKENS: u64 = 5;
745
746/// The canonical cached-input count: the subset of
747/// [`TEXT_MODEL_CONFORMANCE_INPUT_TOKENS`] the scripted transport reports
748/// serving from its vendor's prompt cache. Must stay within the input
749/// count — the suite asserts that, so the script has to satisfy it.
750pub const TEXT_MODEL_CONFORMANCE_CACHED_INPUT_TOKENS: u64 = 9;
751
752/// The canonical prompt the suite asks: one user turn, no system prompt,
753/// no schema, the default token ceiling. An adapter test scripts its
754/// transport to answer [`TEXT_MODEL_CONFORMANCE_REPLY`] with the canonical
755/// counts ([`TEXT_MODEL_CONFORMANCE_INPUT_TOKENS`],
756/// [`TEXT_MODEL_CONFORMANCE_OUTPUT_TOKENS`],
757/// [`TEXT_MODEL_CONFORMANCE_CACHED_INPUT_TOKENS`]) for this prompt.
758#[must_use]
759pub fn text_model_conformance_prompt() -> Prompt {
760    Prompt::new(ModelTier::Fast).user("Reply with the conformance reply, exactly.")
761}
762
763/// Asserts the [`TextModel`] trait contract against a text model whose
764/// transport is already scripted to complete
765/// [`text_model_conformance_prompt`] with
766/// [`TEXT_MODEL_CONFORMANCE_REPLY`] and the canonical counts in its
767/// vendor's wire shape. The suite makes **exactly one** `complete` call —
768/// script that one request and no other.
769///
770/// Asserted, each panic naming the rule it caught:
771///
772/// 1. `text` is the canonical reply, verbatim — no greeting, no chit-chat,
773///    no markdown fence around it;
774/// 2. `model` is non-empty — a completion names what answered, for the
775///    log line;
776/// 3. usage is reported on **every** completion: `input_tokens` and
777///    `output_tokens` equal the scripted counts;
778/// 4. `cached_input_tokens` is the scripted cache read, and never exceeds
779///    `input_tokens` — the cached subset of a total cannot be larger than
780///    the total;
781/// 5. the prompt carried no schema, so `json` is `None`.
782///
783/// ```rust,ignore
784/// // In the adapter's own test, after scripting its transport:
785/// text_model_conformance(&model).await;
786/// ```
787///
788/// # Panics
789///
790/// Panics with the failing rule named when the contract is violated, and
791/// when the scripted completion fails.
792pub 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
840/// Asserts an adapter with no key behind it answers
841/// [`TextModelError::NotConfigured`] rather than panicking — the unwired
842/// port is an error the caller matches, so a module can degrade on it.
843///
844/// # Panics
845///
846/// Panics when the completion succeeds or fails as anything but
847/// `NotConfigured`.
848pub 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
857/// Asserts a [`TextModel`] adapter refuses an over-limit image prompt
858/// (issue #628) **before any network call**: too many images, one image
859/// over [`MAX_IMAGE_ENCODED_BYTES`], or images together over
860/// [`MAX_PROMPT_IMAGE_ENCODED_BYTES`], each refused with the matching
861/// [`TextModelError::ImageLimit`].
862///
863/// Run it over a transport that answers nothing, so a pass proves the
864/// refusal happened locally rather than at the provider.
865///
866/// ```rust,ignore
867/// text_model_image_bounds_conformance(&model).await;
868/// ```
869///
870/// # Panics
871///
872/// Panics, naming the rule it caught, when a prompt over a bound is not
873/// refused with exactly the expected [`ImageLimit`].
874pub async fn text_model_image_bounds_conformance(model: &dyn TextModel) {
875    // More images than a prompt may carry.
876    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    // One image encoding to one base64 group over the per-image ceiling.
887    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    // Two images just under the per-image ceiling, together over the total.
904    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
921/// A raw byte length whose standard, padded base64 encoding is exactly
922/// `encoded`, for `encoded` a multiple of four.
923fn raw_len_for_encoded(encoded: usize) -> usize {
924    (encoded / 4) * 3
925}