contextgraph_conformance/composition_conformance.rs
1//! Composition conformance (`SPEC.md` §11.1) — the suite a **downstream** host
2//! can run against its own composition layer.
3//!
4//! [`run_host_conformance`](crate::run_host_conformance) drives
5//! [`contextgraph_host::Host`] itself, so it certifies the reference host and
6//! nothing else. That leaves a real gap, because `Host::query_all` is not the
7//! whole host: it audits budget honesty **per provider**, and then hands back a
8//! fan-out. Something above it has to turn N providers' accepted frames into the
9//! one frame set that reaches a prompt, and that step is where a host makes its
10//! own decisions — which frames win a shared budget, what happens to the losers,
11//! and in what order the survivors render.
12//!
13//! That step is not covered by the per-provider audit, and the gap is not
14//! theoretical. Three providers each returning one honest 400-token frame against
15//! a 1000-token query are *individually* conformant — no `token_cost` lie, no
16//! frame flood — and `FanOut::accepted_frames()` yields all three, for 1200
17//! tokens. Whether the prompt ends up over budget, and whether anyone is told
18//! which evidence was dropped to keep it under, is entirely up to the composing
19//! host. A downstream host that got this wrong would pass every check in the
20//! provider suite and every check in the host suite.
21//!
22//! So this module inverts the dependency: instead of driving a fixed host, it
23//! takes a [`ComposingHost`] — anything that can answer "given these providers
24//! and this query, what reaches the prompt, and what did you drop getting
25//! there?" — and holds it to the rules that bind that answer. The reference
26//! implementation is [`compose_for_prompt`](contextgraph_host::compose_for_prompt),
27//! which passes; a downstream host with its own merge (stella's `recall_via_host`
28//! is the known one) implements the trait and gets the same audit.
29//!
30//! # The rules checked
31//!
32//! - **[`CCHECK_BUDGET_BOUND`]** — the admitted set's summed token cost does not
33//! exceed the query's `max_tokens`, *including* when every individual provider
34//! was honest and only the sum overflows (§7).
35//! - **[`CCHECK_TOTAL_PARTITION`]** — every frame the host was offered is either
36//! admitted or reported as dropped. A frame that is neither has been *silently
37//! truncated*, which is the one outcome an evidence audit cannot tolerate
38//! (issue #15's total-partition requirement).
39//! - **[`CCHECK_QUARANTINE`]** — frames from a provider the host's own audit
40//! rejected never reach the prompt. A composing host that reads raw provider
41//! results instead of `accepted_frames()` re-admits exactly what B2/B4 dropped.
42//! - **[`CCHECK_DETERMINISM`]** — the same frame set composes to the same
43//! admitted sequence twice running. This is the prompt-cache guarantee
44//! (`docs/context-reuse.md` §1): a turn whose underlying frames did not change
45//! must emit byte-identical text, so selection may depend on score but
46//! *rendering* must not.
47//!
48//! Every check is **adversarial by construction**, the same discipline
49//! [`host_conformance`](crate::host_conformance) uses: each one points the host at
50//! input that tries to make it fail *and* at a well-behaved counterpart it must
51//! accept, so a check can only pass if the host **discriminates**. A host that
52//! admitted nothing at all, or reported every frame as dropped, would fail its
53//! counterpart rather than passing vacuously.
54//!
55//! # Honest residual
56//!
57//! This suite sees a host's composition as a black box over frames: it cannot
58//! check *rendering* (R3 fencing is [`host_conformance`]'s `host-content-quoting`,
59//! against the reference renderer), and it cannot check that a host's stated drop
60//! *reason* is the true one — only that a drop is reported at all. A host that
61//! reported every over-budget drop as a duplicate would pass. Reason fidelity
62//! needs a vocabulary this trait deliberately does not impose, because a
63//! downstream host's drop reasons are its own (stella has `FrameCount`,
64//! `TokenBudget`, `RequiredOverBudget`; the reference has `Duplicate` and
65//! `OverBudget`).
66
67use std::collections::BTreeSet;
68
69use async_trait::async_trait;
70use contextgraph_types::{
71 BYTES_PER_BUDGET_TOKEN, ContextFrame, ContextQuery, FrameId, FrameKind, budget_tokens,
72};
73
74use contextgraph_host::{ContextProvider, Host, ProviderResult, compose_for_prompt};
75
76use crate::host_conformance::{ProbeProvider, probe_query};
77use crate::report::{CheckResult, ConformanceReport};
78
79/// §7 — the admitted set fits the query's token budget, including when only the
80/// cross-provider sum overflows.
81pub const CCHECK_BUDGET_BOUND: &str = "composition-budget-bound";
82/// Issue #15 — every offered frame is admitted or reported dropped, never
83/// silently truncated.
84pub const CCHECK_TOTAL_PARTITION: &str = "composition-total-partition";
85/// §7 B2/B4 — frames the host's own audit rejected never reach the prompt.
86pub const CCHECK_QUARANTINE: &str = "composition-quarantine";
87/// `docs/context-reuse.md` §1 — an unchanged frame set composes identically.
88pub const CCHECK_DETERMINISM: &str = "composition-determinism";
89
90/// One frame a composing host declined to admit.
91///
92/// Deliberately does **not** carry a reason enum. The suite's contract is that a
93/// dropped frame is *accounted for*, not that it is accounted for in the
94/// protocol's vocabulary — a downstream host's drop reasons are its own product
95/// vocabulary (see the module's honest residual). Imposing one here would make
96/// the trait unimplementable without a lossy mapping, and a lossy mapping is
97/// worse evidence than an honest identity.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct ExcludedFrame {
100 /// The provider that served it.
101 pub provider_id: String,
102 /// The provider's own frame id.
103 pub frame_id: String,
104}
105
106/// What a composing host did with a fan-out: what reaches the prompt, and what
107/// it dropped getting there.
108#[derive(Debug, Clone, Default)]
109pub struct Composition {
110 /// The frames that reach the prompt, in the order the host renders them,
111 /// each paired with the provider that served it.
112 pub admitted: Vec<(String, ContextFrame)>,
113 /// Every frame the host was offered and did not admit.
114 pub dropped: Vec<ExcludedFrame>,
115}
116
117impl Composition {
118 /// The summed declared token cost of the admitted frames.
119 fn admitted_tokens(&self) -> u64 {
120 self.admitted
121 .iter()
122 .map(|(_, frame)| u64::from(frame.token_cost))
123 .sum()
124 }
125
126 /// The `(provider, frame id)` pairs accounted for — admitted or dropped.
127 fn accounted(&self) -> BTreeSet<(String, String)> {
128 self.admitted
129 .iter()
130 .map(|(provider, frame)| (provider.clone(), frame.id.clone()))
131 .chain(
132 self.dropped
133 .iter()
134 .map(|drop| (drop.provider_id.clone(), drop.frame_id.clone())),
135 )
136 .collect()
137 }
138
139 /// The admitted frames as identity pairs, in render order — the sequence
140 /// [`CCHECK_DETERMINISM`] compares across runs.
141 fn render_order(&self) -> Vec<(String, String)> {
142 self.admitted
143 .iter()
144 .map(|(provider, frame)| (provider.clone(), frame.id.clone()))
145 .collect()
146 }
147}
148
149/// A host's composition layer, as this suite needs to see it.
150///
151/// One method, because one method is the whole contract: a composing host is a
152/// function from *(providers, query)* to *what reached the prompt*. Taking the
153/// providers rather than a pre-built fan-out is deliberate — it lets the suite
154/// hand over adversarial providers and still exercise the host's **own** fan-out
155/// and audit path, which is where [`CCHECK_QUARANTINE`] lives. A trait that took
156/// an already-audited frame list could not tell whether the host ran the audit.
157#[async_trait]
158pub trait ComposingHost: Send + Sync {
159 /// Register exactly `providers`, execute `query`, and report the result.
160 ///
161 /// Implementations should build a fresh host per call: the suite relies on
162 /// calls being independent, and reuses ids across checks.
163 async fn compose(
164 &self,
165 providers: Vec<Box<dyn ContextProvider>>,
166 query: &ContextQuery,
167 ) -> Composition;
168}
169
170/// Run every composition check against `host`, returning a typed
171/// [`ConformanceReport`].
172///
173/// `target` names the implementation under test and appears in the report, so a
174/// downstream host's CI output says which host was certified rather than just
175/// "passed".
176pub async fn run_composition_conformance(
177 host: &dyn ComposingHost,
178 target: impl Into<String>,
179) -> ConformanceReport {
180 let checks = vec![
181 check_budget_bound(host).await,
182 check_total_partition(host).await,
183 check_quarantine(host).await,
184 check_determinism(host).await,
185 ];
186 ConformanceReport {
187 target: target.into(),
188 checks,
189 }
190}
191
192/// **§7** — the cross-provider budget bound.
193///
194/// The adversarial input is the case the per-provider audit structurally cannot
195/// catch: three providers, each returning a single honest 400-token frame against
196/// `max_tokens: 1000`. Nobody lied — every provider is within budget on its own —
197/// and the sum is 1200. A composing host must drop something.
198///
199/// The well-behaved counterpart is the same shape under the budget (3 × 200 =
200/// 600), where the host must admit **all three**. Without it a host that always
201/// returned an empty composition would pass.
202async fn check_budget_bound(host: &dyn ComposingHost) -> CheckResult {
203 let query = probe_query(); // max_tokens: 1000, max_frames: 8
204 let over = host
205 .compose(three_providers_each_costing(400), &query)
206 .await;
207 let within_budget = over.admitted_tokens() <= u64::from(query.max_tokens);
208 let dropped_something = !over.dropped.is_empty();
209
210 let under = host
211 .compose(three_providers_each_costing(200), &query)
212 .await;
213 let kept_all = under.admitted.len() == 3 && under.dropped.is_empty();
214
215 CheckResult::from_bool(
216 CCHECK_BUDGET_BOUND,
217 within_budget && dropped_something && kept_all,
218 format!(
219 "§7 (composition): three individually-honest providers summing 1200 against a \
220 1000-token budget compose within budget={within_budget} \
221 (admitted {} tokens) and report a drop={dropped_something}; the same shape summing \
222 600 keeps all three frames={kept_all}",
223 over.admitted_tokens()
224 ),
225 )
226}
227
228/// **Issue #15 total partition** — no frame vanishes unaccounted.
229///
230/// Uses the same over-budget input as [`check_budget_bound`], because that is the
231/// case where a host *must* shed frames and therefore the case where silent
232/// truncation is tempting: the naive implementation stops walking the moment the
233/// budget fills, and everything after it disappears uncounted as well as unkept.
234///
235/// The counterpart is the under-budget input, where the host must report *no*
236/// drops — otherwise a host could pass by declaring every frame dropped.
237async fn check_total_partition(host: &dyn ComposingHost) -> CheckResult {
238 let query = probe_query();
239 let offered: BTreeSet<(String, String)> = (0..3)
240 .map(|i| (format!("p{i}"), format!("p{i}-f")))
241 .collect();
242
243 let over = host
244 .compose(three_providers_each_costing(400), &query)
245 .await;
246 let accounted = over.accounted();
247 let missing: Vec<_> = offered.difference(&accounted).collect();
248 let total = missing.is_empty();
249 // A host cannot satisfy the partition by inventing frames it was never
250 // offered, either: the accounted set must not exceed the offered one.
251 let no_phantoms = accounted.difference(&offered).count() == 0;
252
253 let under = host
254 .compose(three_providers_each_costing(200), &query)
255 .await;
256 let no_spurious_drops = under.dropped.is_empty();
257
258 CheckResult::from_bool(
259 CCHECK_TOTAL_PARTITION,
260 total && no_phantoms && no_spurious_drops,
261 format!(
262 "issue #15 (composition): every offered frame is admitted or reported \
263 dropped={total} (unaccounted: {missing:?}), no frame is reported that was never \
264 offered={no_phantoms}; a composition that drops nothing reports \
265 nothing={no_spurious_drops}"
266 ),
267 )
268}
269
270/// **§7 B2/B4** — a provider the audit rejected stays out of the prompt.
271///
272/// The adversarial provider is a **frame flooder**: `max_frames + 9` frames, each
273/// individually cheap. The host's own audit is required to reject the whole set
274/// (B4) and does. The composing layer must not put it back.
275///
276/// A flooder rather than a `token_cost` liar, and the distinction matters. A
277/// liar's frames are *also* over the token budget, so a host that skipped the
278/// audit entirely would still drop them while packing — and would pass this check
279/// by accident, for the wrong reason. (That is not hypothetical; it is what the
280/// first version of this check did.) A flooder's frames are cheap: they sail
281/// through any token-budget pack, so the **only** thing that keeps them out of
282/// the prompt is having consulted the audit. That makes the check load-bearing
283/// instead of incidental.
284///
285/// The counterpart pairs the flooder with an honest provider whose frame **must**
286/// still arrive: quarantining one leg may not take the other down with it, which
287/// is the crash-isolation posture applied to the budget audit.
288async fn check_quarantine(host: &dyn ComposingHost) -> CheckResult {
289 let query = probe_query(); // max_frames: 8, max_tokens: 1000
290 let flood: Vec<ContextFrame> = (0..query.max_frames + 9)
291 .map(|i| honest_frame(&format!("flood-{i}"), 1))
292 .collect();
293 let providers: Vec<Box<dyn ContextProvider>> = vec![
294 Box::new(ProbeProvider::local("flooder", flood)),
295 Box::new(ProbeProvider::local(
296 "honest",
297 vec![honest_frame("honest-f", 100)],
298 )),
299 ];
300 let composed = host.compose(providers, &query).await;
301
302 let flooder_excluded = !composed
303 .admitted
304 .iter()
305 .any(|(provider, _)| provider == "flooder");
306 let honest_admitted = composed
307 .admitted
308 .iter()
309 .any(|(provider, frame)| provider == "honest" && frame.id == "honest-f");
310
311 CheckResult::from_bool(
312 CCHECK_QUARANTINE,
313 flooder_excluded && honest_admitted,
314 format!(
315 "§7 B2/B4 (composition): the frames of a provider the audit rejected (a frame \
316 flooder, whose frames are individually cheap enough to pass any token pack) never \
317 reach the prompt={flooder_excluded}; an honest provider queried alongside it still \
318 arrives={honest_admitted}"
319 ),
320 )
321}
322
323/// **`docs/context-reuse.md` §1** — an unchanged frame set composes identically.
324///
325/// Composes the same input twice and compares the admitted sequence. This is the
326/// prompt-cache guarantee: selection may depend on score, but *rendering order*
327/// must be a function of the frame set alone, so a turn whose underlying frames
328/// did not change emits byte-identical text and rides the provider's cache
329/// instead of busting it.
330///
331/// The frames are given deliberately **tied scores** — the reference frame
332/// fixture scores every frame 0.5 — because a tie is where an unstable sort or a
333/// hash-ordered map leaks nondeterminism. A host that ordered by score alone
334/// would pass on distinct scores and fail here, which is the point.
335///
336/// The counterpart is inverted: rather than a second input the host must accept,
337/// the check also asserts the composition is **non-empty**, so a host that
338/// admitted nothing cannot pass by being trivially stable.
339async fn check_determinism(host: &dyn ComposingHost) -> CheckResult {
340 let query = probe_query();
341 let first = host
342 .compose(three_providers_each_costing(100), &query)
343 .await;
344 let second = host
345 .compose(three_providers_each_costing(100), &query)
346 .await;
347
348 let stable = first.render_order() == second.render_order();
349 let non_empty = !first.admitted.is_empty();
350
351 CheckResult::from_bool(
352 CCHECK_DETERMINISM,
353 stable && non_empty,
354 format!(
355 "context-reuse §1 (composition): an unchanged frame set composes to the same render \
356 order twice={stable} (first {:?}, second {:?}); the composition is non-empty, so \
357 stability is not vacuous={non_empty}",
358 first.render_order(),
359 second.render_order()
360 ),
361 )
362}
363
364/// The reference composing host: [`Host::query_all`] for the fan-out and audit,
365/// then [`compose_for_prompt`] for the shared-budget pack.
366///
367/// Two jobs. It is the proof the suite is **satisfiable** — a suite no
368/// implementation passes is a suite with a bug, not a bar — and it is the worked
369/// example a downstream host implements against, which is why the body is short
370/// enough to read: fan out, keep what the audit accepted, pack it, read the
371/// partition back off the audit.
372///
373/// Note what it can and cannot report. Frames the audit quarantined (a
374/// `token_cost` liar's whole set) are **absent** from `dropped`, because
375/// [`ProviderResult::BudgetLie`](contextgraph_host::ProviderResult) carries a
376/// *count* of dropped frames and not their ids — the host knows how many it threw
377/// out, not which. That is why [`CCHECK_QUARANTINE`] asserts only that those
378/// frames stay out of the prompt, and why [`CCHECK_TOTAL_PARTITION`] is posed
379/// over honest providers: demanding that a quarantined frame be named would be
380/// demanding information the fan-out does not carry.
381pub struct ReferenceComposingHost;
382
383#[async_trait]
384impl ComposingHost for ReferenceComposingHost {
385 async fn compose(
386 &self,
387 providers: Vec<Box<dyn ContextProvider>>,
388 query: &ContextQuery,
389 ) -> Composition {
390 let mut host = Host::new();
391 for provider in providers {
392 host.register(provider);
393 }
394 let fanout = host.query_all(query).await;
395
396 // Compose from the **audited** accepted set, never from raw provider
397 // results — this line is the whole of `CCHECK_QUARANTINE`.
398 let offered: Vec<(String, ContextFrame)> = fanout
399 .outcomes
400 .iter()
401 .filter_map(|outcome| match &outcome.result {
402 ProviderResult::Frames(result) => Some(
403 result
404 .frames
405 .iter()
406 .map(|frame| (outcome.provider_id.clone(), frame.clone())),
407 ),
408 _ => None,
409 })
410 .flatten()
411 .collect();
412
413 let composed = compose_for_prompt(
414 offered
415 .iter()
416 .map(|(provider, frame)| (provider.as_str(), frame)),
417 query.max_tokens,
418 );
419
420 // The audit is a total partition of `offered`, so reading `admitted` and
421 // `dropped` straight off it is what makes this host's own
422 // `CCHECK_TOTAL_PARTITION` hold by construction rather than by care.
423 let included: Vec<&FrameId> = composed.audit.included().collect();
424 let admitted = included
425 .iter()
426 .filter_map(|id| {
427 offered
428 .iter()
429 .find(|(provider, frame)| {
430 provider == &id.provider_id && frame.id == id.frame_id
431 })
432 .cloned()
433 })
434 .collect();
435 let dropped = composed
436 .audit
437 .excluded()
438 .map(|entry| ExcludedFrame {
439 provider_id: entry.frame.provider_id.clone(),
440 frame_id: entry.frame.frame_id.clone(),
441 })
442 .collect();
443
444 Composition { admitted, dropped }
445 }
446}
447
448/// A frame whose declared `token_cost` is the **honest** canonical count for its
449/// own content (§7 B3): the content is exactly `token_cost *
450/// BYTES_PER_BUDGET_TOKEN` bytes, so `ceil(len / 4) == token_cost`.
451///
452/// This is load-bearing, and getting it wrong is the first mistake this suite
453/// invites — it is the bug the suite's own first run had. A composing host is
454/// entitled to pack by a frame's **canonical** cost rather than its declared one;
455/// the reference host does, deliberately, so an under-declared frame cannot sneak
456/// past the budget. A fixture declaring `token_cost: 400` on a one-byte body is
457/// therefore measured as costing 1 by the host and 400 by the suite, and the
458/// check fails a *correct* host over a fixture defect.
459///
460/// So every frame this suite offers satisfies B3. The rule under test is the
461/// cross-provider **sum**; posing it over frames that already lie about their
462/// individual cost would test something else entirely — something the provider
463/// suite's `budget-honesty` check already covers.
464fn honest_frame(id: &str, token_cost: u32) -> ContextFrame {
465 let content = "x".repeat(token_cost as usize * BYTES_PER_BUDGET_TOKEN);
466 debug_assert_eq!(
467 budget_tokens(&content),
468 token_cost,
469 "the fixture must satisfy B3 or the suite measures the wrong thing"
470 );
471 let mut frame = ContextFrame::full(id, FrameKind::Doc, id, &content, 0.5, token_cost);
472 frame.citation_label = Some(id.into());
473 frame
474}
475
476/// Three local providers, each serving exactly one B3-honest frame costing
477/// `token_cost`.
478///
479/// Each is individually honest for any budget at or above `token_cost`, so
480/// whether the set overflows is purely a property of the *sum* — which is what
481/// makes this the fixture the per-provider audit cannot help with.
482fn three_providers_each_costing(token_cost: u32) -> Vec<Box<dyn ContextProvider>> {
483 (0..3)
484 .map(|i| {
485 let id = format!("p{i}");
486 let frame = honest_frame(&format!("{id}-f"), token_cost);
487 Box::new(ProbeProvider::local(&id, vec![frame])) as Box<dyn ContextProvider>
488 })
489 .collect()
490}
491
492#[cfg(test)]
493mod tests;