Skip to main content

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;