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