Skip to main content

contextgraph_types/
frame.rs

1//! `ContextFrame` — the unit of exchange between a CGP host and a provider.
2//! `SPEC.md` §6 fixes this exact
3//! shape; frames, never blobs, carry relevance, cost, and provenance so a
4//! budgeting, citing host can compose sources honestly.
5//!
6//! ## Frame representations (CGEP lifecycle, phase 2)
7//!
8//! A frame states how it carries its content through [`Representation`]:
9//! `full` inlines the content (the legacy default), `compact` inlines a
10//! transformed rendering alongside a resolver handle, and `reference` carries
11//! no inline content at all — only a [`ContentRef`] and a
12//! [`canonical_content_hash`](ContextFrame::canonical_content_hash) so a host
13//! can rehydrate honestly and verifiably. `representation` absent means `full`,
14//! so pre-representation providers and stored frames deserialize unchanged.
15
16use serde::{Deserialize, Serialize};
17
18use crate::identity::FrameId;
19use crate::token::budget_tokens;
20use crate::validate::{is_protocol_timestamp, is_well_formed_digest};
21
22/// What kind of thing a frame represents.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum FrameKind {
26    Snippet,
27    Symbol,
28    Fact,
29    Doc,
30    Memory,
31    Episode,
32    Graph,
33}
34
35/// How a frame carries its content
36/// (CGEP lifecycle build prompt, §"ContextFrame representations").
37///
38/// - `full`: canonical inline [`content`](ContextFrame::content) is required.
39/// - `compact`: inline content, inline hash, canonical hash, a [`Transform`]
40///   identity, and a [`ContentRef`] are all required.
41/// - `reference`: inline content is **absent**; a [`ContentRef`] and
42///   [`canonical_content_hash`](ContextFrame::canonical_content_hash) are
43///   required; the inline content hash and transform are omitted.
44///
45/// A `full` frame omits this field on the wire ([`is_full`](Self::is_full)) so
46/// legacy frames round-trip byte-for-byte.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49pub enum Representation {
50    #[default]
51    Full,
52    Compact,
53    Reference,
54}
55
56impl Representation {
57    /// Whether this is the legacy default representation. A `full` frame omits
58    /// the `representation` field on the wire, so a frame emitted before this
59    /// field existed round-trips unchanged and `representation` absent means
60    /// `full`.
61    pub fn is_full(&self) -> bool {
62        matches!(self, Representation::Full)
63    }
64}
65
66/// The fidelity of a frame's carried content relative to its canonical source.
67/// A missing value means `exact` for a legacy full frame.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "snake_case")]
70pub enum ContentFidelity {
71    Exact,
72    Normalized,
73    Summarized,
74    Omitted,
75}
76
77/// Whether a frame's point of use requires the content inline, or accepts a
78/// resolvable reference. Blocking constraints, guarded rules, ordered
79/// procedures, and executable contracts require inline content at their point
80/// of use; this keeps a reference choice from being confused with fidelity.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "snake_case")]
83pub enum InlineContentRequirement {
84    Required,
85    ResolvableReferenceAllowed,
86}
87
88/// An opaque resolver handle for a `compact`/`reference` frame's content.
89///
90/// [`ContextFrame::uri`] identifies the source resource; `ContentRef::uri` is a
91/// **distinct** opaque resolver handle. A `ContentRef` also names the exact
92/// [`provider_id`](Self::provider_id) that returned it, so a fan-out host routes
93/// resolution back to that provider.
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95pub struct ContentRef {
96    /// The exact provider that returned this frame; a fan-out host routes
97    /// `context/resolve` back to it.
98    pub provider_id: String,
99    /// Opaque resolver handle, distinct from [`ContextFrame::uri`].
100    pub uri: String,
101    /// When the handle stops resolving. Absent ⇒ no declared expiry.
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub expires_at: Option<String>,
104}
105
106/// The transformation identity a `compact` frame applies to its source to
107/// produce the inline rendering, so a consumer knows what it is reading.
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109pub struct Transform {
110    /// e.g. `extractive_summary`, `truncation`.
111    pub method: String,
112    /// e.g. `provider_default`, or a named implementation.
113    pub implementation: String,
114    pub version: String,
115}
116
117/// One link in a frame's provenance chain, ordered closest-to-source first.
118#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
119pub struct Provenance {
120    /// e.g. "file", "derivation", "episode".
121    #[serde(rename = "type")]
122    pub kind: String,
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub uri: Option<String>,
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub range: Option<String>,
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub digest: Option<String>,
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub method: Option<String>,
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub by: Option<String>,
133}
134
135impl Provenance {
136    /// Whether this link addresses bytes a host could independently re-read.
137    pub fn is_file_provenance(&self) -> bool {
138        self.kind == "file"
139    }
140
141    /// Whether the digest, if present, matches the grammar in `SPEC.md` §F5.
142    ///
143    /// Absent counts as *not* well-formed: for file provenance the digest is
144    /// what makes tamper-detection possible at all, and treating "no digest"
145    /// as acceptable is how the guarantee stayed decorative for so long.
146    pub fn has_well_formed_digest(&self) -> bool {
147        self.digest.as_deref().is_some_and(is_well_formed_digest)
148    }
149}
150
151/// The recommended relation vocabulary (`SPEC.md` §Graph).
152///
153/// `Relation.rel` is an **open** vocabulary — a provider may emit any string,
154/// and a host must not reject an unknown one. These constants exist so that
155/// independent providers converge on the same spelling for the same edge
156/// instead of each inventing `calls` / `call` / `code.call`. Using them is
157/// SHOULD-level, not MUST.
158///
159/// Namespacing is the part that matters: a provider-specific edge belongs
160/// under its own prefix (`myindex.owns`), which keeps the shared namespace
161/// meaningful and makes a future registry possible.
162pub mod rel {
163    /// The subject calls the target.
164    pub const CODE_CALLS: &str = "code.calls";
165    /// The subject imports the target.
166    pub const CODE_IMPORTS: &str = "code.imports";
167    /// The subject defines the target.
168    pub const CODE_DEFINES: &str = "code.defines";
169    /// The subject references the target without calling it.
170    pub const CODE_REFERENCES: &str = "code.references";
171    /// The subject documents the target.
172    pub const DOC_DOCUMENTS: &str = "doc.documents";
173    /// The subject episode follows the target episode in time.
174    pub const EPISODE_FOLLOWS: &str = "episode.follows";
175
176    /// Every relation this revision names. A registry, not a restriction.
177    pub const RECOMMENDED: &[&str] = &[
178        CODE_CALLS,
179        CODE_IMPORTS,
180        CODE_DEFINES,
181        CODE_REFERENCES,
182        DOC_DOCUMENTS,
183        EPISODE_FOLLOWS,
184    ];
185}
186
187/// A graph relation a frame participates in, surfaced with a human label —
188/// raw ids are never the primary identifier (`SPEC.md` §G1).
189#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
190pub struct Relation {
191    /// The edge label. See [`rel`] for the recommended vocabulary; unknown
192    /// values are valid and **MUST NOT** be rejected by a host.
193    pub rel: String,
194    pub target_uri: String,
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub display_name: Option<String>,
197}
198
199impl Relation {
200    /// Whether this edge can be surfaced to a human by name.
201    ///
202    /// The "never a raw id" rule has been documented since the protocol's first
203    /// draft and was checked by nothing; `SPEC.md` §G1 now makes it a
204    /// conformance requirement for graph-capable providers, and this is the
205    /// predicate behind it.
206    pub fn has_display_name(&self) -> bool {
207        self.display_name
208            .as_deref()
209            .is_some_and(|name| !name.trim().is_empty())
210    }
211
212    /// Whether this edge points somewhere (`SPEC.md` §G2).
213    ///
214    /// `target_uri` is a required field, so serde guarantees it is *present* —
215    /// but `""` deserializes happily, and an edge to nowhere is not an edge.
216    /// The schema has carried `minLength: 1` here from the start; §G2 claimed
217    /// `frame-validity` verified it and no code did, which is the
218    /// self-attestation §11.1 exists to rule out.
219    pub fn has_target_uri(&self) -> bool {
220        !self.target_uri.trim().is_empty()
221    }
222
223    /// Whether `rel` uses the recommended vocabulary. Advisory only — a `false`
224    /// here is a hint for a provider author, never a conformance failure.
225    pub fn uses_recommended_vocabulary(&self) -> bool {
226        rel::RECOMMENDED.contains(&self.rel.as_str())
227    }
228}
229
230/// The optional embedding carried by a frame. The vector itself is
231/// elidable — a host may want the fingerprint without the payload.
232#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
233pub struct FrameEmbedding {
234    pub fingerprint: String,
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub vector: Option<Vec<f32>>,
237}
238
239/// One context frame returned from `context/query`.
240#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
241pub struct ContextFrame {
242    /// Provider-scoped, stable for dedup across queries.
243    pub id: String,
244    pub kind: FrameKind,
245    /// Human label — never a bare uuid.
246    pub title: String,
247    /// Text the host may quote into a prompt. Untrusted data: a conforming
248    /// host delimits this as quoted material, never as instructions.
249    ///
250    /// Present for `full`/`compact` frames; **absent** for `reference` frames,
251    /// which carry only a [`content_ref`](Self::content_ref). A reference is
252    /// never encoded as `content: ""` — the field is omitted entirely.
253    #[serde(default, skip_serializing_if = "Option::is_none")]
254    pub content: Option<String>,
255    /// The provider-declared digest of this frame's **inline** content bytes —
256    /// the third component of its stable [`FrameId`](crate::FrameId) identity,
257    /// opaque to the protocol (e.g. `sha256:<hex>`). This is the spec's
258    /// `content_hash` (SHA-256 over the exact inline UTF-8 content) under its
259    /// established name; see [`canonical_content_hash`](Self::canonical_content_hash)
260    /// for the full-source hash. Absent ⇒ the frame is not verifiable and a
261    /// host re-queries it rather than reusing it unchecked
262    /// (`docs/context-reuse.md` §1, §4). A `reference` frame omits it.
263    #[serde(default, skip_serializing_if = "Option::is_none")]
264    pub content_digest: Option<String>,
265    #[serde(default, skip_serializing_if = "Option::is_none")]
266    pub uri: Option<String>,
267    /// How this frame carries its content. Absent ⇒ [`Representation::Full`],
268    /// so legacy frames deserialize unchanged and full frames omit the field.
269    #[serde(default, skip_serializing_if = "Representation::is_full")]
270    pub representation: Representation,
271    /// Fidelity of the carried content relative to the source. Absent ⇒ `exact`
272    /// for a legacy full frame.
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub content_fidelity: Option<ContentFidelity>,
275    /// SHA-256 over the **complete source content** bytes (distinct from the
276    /// inline [`content_digest`](Self::content_digest)). Required for
277    /// `compact`/`reference` frames so a resolved rehydration is verifiable.
278    #[serde(default, skip_serializing_if = "Option::is_none")]
279    pub canonical_content_hash: Option<String>,
280    /// The opaque resolver handle for a `compact`/`reference` frame's content.
281    #[serde(default, skip_serializing_if = "Option::is_none")]
282    pub content_ref: Option<ContentRef>,
283    /// The transformation a `compact` frame applied to its source. Omitted for
284    /// `full`/`reference` frames.
285    #[serde(default, skip_serializing_if = "Option::is_none")]
286    pub transform: Option<Transform>,
287    /// The lowest content fidelity acceptable at this frame's point of use.
288    #[serde(default, skip_serializing_if = "Option::is_none")]
289    pub minimum_content_fidelity: Option<ContentFidelity>,
290    /// Whether this frame's point of use requires inline content.
291    #[serde(default, skip_serializing_if = "Option::is_none")]
292    pub inline_content_requirement: Option<InlineContentRequirement>,
293    /// Provider-normalized relevance in `[0, 1]`.
294    pub score: f32,
295    /// Honest, conformance-audited token cost of the **inline** rendering.
296    pub token_cost: u32,
297    /// Token cost of the complete canonical source content, when the provider
298    /// declares it. If present, [`tokenizer_ref`](Self::tokenizer_ref) SHOULD
299    /// name the tokenizer it was measured with. Hosts compute model-specific
300    /// costs when providers omit it.
301    #[serde(default, skip_serializing_if = "Option::is_none")]
302    pub canonical_token_cost: Option<u32>,
303    /// Identifies the tokenizer that produced the declared costs
304    /// (e.g. `openai:o200k_base`).
305    #[serde(default, skip_serializing_if = "Option::is_none")]
306    pub tokenizer_ref: Option<String>,
307    #[serde(default, skip_serializing_if = "Option::is_none")]
308    pub valid_from: Option<String>,
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub valid_to: Option<String>,
311    #[serde(default, skip_serializing_if = "Option::is_none")]
312    pub recorded_at: Option<String>,
313    #[serde(default, skip_serializing_if = "Vec::is_empty")]
314    pub provenance: Vec<Provenance>,
315    #[serde(default, skip_serializing_if = "Option::is_none")]
316    pub citation_label: Option<String>,
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    pub embedding: Option<FrameEmbedding>,
319    #[serde(default, skip_serializing_if = "Vec::is_empty")]
320    pub relations: Vec<Relation>,
321}
322
323impl ContextFrame {
324    /// A `full`-representation frame carrying inline `content` — the shape every
325    /// legacy provider emits. The representation/cost/resolver fields default to
326    /// absent, so a call site need only supply the core, then set extras as
327    /// needed (the build prompt asks for constructors to reduce source
328    /// breakage).
329    pub fn full(
330        id: impl Into<String>,
331        kind: FrameKind,
332        title: impl Into<String>,
333        content: impl Into<String>,
334        score: f32,
335        token_cost: u32,
336    ) -> Self {
337        Self {
338            id: id.into(),
339            kind,
340            title: title.into(),
341            content: Some(content.into()),
342            content_digest: None,
343            uri: None,
344            representation: Representation::Full,
345            content_fidelity: None,
346            canonical_content_hash: None,
347            content_ref: None,
348            transform: None,
349            minimum_content_fidelity: None,
350            inline_content_requirement: None,
351            score,
352            token_cost,
353            canonical_token_cost: None,
354            tokenizer_ref: None,
355            valid_from: None,
356            valid_to: None,
357            recorded_at: None,
358            provenance: Vec::new(),
359            citation_label: None,
360            embedding: None,
361            relations: Vec::new(),
362        }
363    }
364
365    /// A `reference`-representation frame: no inline content, only a resolver
366    /// handle and the canonical source hash for honest, verifiable rehydration.
367    /// `token_cost` is the inline cost (0 — nothing is inlined).
368    pub fn reference(
369        id: impl Into<String>,
370        kind: FrameKind,
371        title: impl Into<String>,
372        content_ref: ContentRef,
373        canonical_content_hash: impl Into<String>,
374        score: f32,
375    ) -> Self {
376        Self {
377            representation: Representation::Reference,
378            content: None,
379            content_ref: Some(content_ref),
380            canonical_content_hash: Some(canonical_content_hash.into()),
381            ..Self::full(id, kind, title, String::new(), score, 0)
382        }
383        // `..full(..)` seeds every other field; the overrides above make this a
384        // structurally honest reference (content absent, content_digest None,
385        // transform None) that satisfies `representation_invariants`.
386    }
387
388    /// Score must be normalized into `[0, 1]` per the protocol contract.
389    /// Conformance suites assert this; providers should self-check too.
390    pub fn has_valid_score(&self) -> bool {
391        (0.0..=1.0).contains(&self.score)
392    }
393
394    /// The frame's stable identity under the given provider: `(provider id,
395    /// frame id, content digest)`. The digest is carried through from
396    /// [`content_digest`](Self::content_digest), so a frame without one yields
397    /// an unverifiable identity (`docs/context-reuse.md` §1).
398    pub fn identity(&self, provider_id: impl Into<String>) -> FrameId {
399        FrameId::new(provider_id, self.id.clone(), self.content_digest.clone())
400    }
401
402    /// The token cost this frame's **inline** content is *required* to declare
403    /// (`SPEC.md` §B3) — see [`budget_tokens`](crate::budget_tokens). A
404    /// `reference` frame carries no inline content, so its expected cost is 0.
405    ///
406    /// Distinct from the [`canonical_token_cost`](Self::canonical_token_cost)
407    /// *field*, which is the provider-declared cost of the full source content.
408    pub fn expected_inline_token_cost(&self) -> u32 {
409        budget_tokens(self.content.as_deref().unwrap_or(""))
410    }
411
412    /// Whether `token_cost` matches the canonical count for this frame's
413    /// content.
414    ///
415    /// This is the check that turned budget honesty from arithmetic into
416    /// truth: previously a provider could declare `token_cost: 1` on a
417    /// ten-thousand-token frame and pass every check in the suite.
418    pub fn declares_honest_token_cost(&self) -> bool {
419        self.token_cost == self.expected_inline_token_cost()
420    }
421
422    /// The names of any temporal fields that are not in the protocol's
423    /// timestamp profile (`SPEC.md` §F4).
424    ///
425    /// Returns the field *names* rather than a bare bool so a conformance
426    /// failure can say which field was wrong — an evidence string reading
427    /// "valid_from" is actionable in a way that "temporal validation failed"
428    /// is not.
429    pub fn invalid_temporal_fields(&self) -> Vec<&'static str> {
430        [
431            ("valid_from", self.valid_from.as_deref()),
432            ("valid_to", self.valid_to.as_deref()),
433            ("recorded_at", self.recorded_at.as_deref()),
434        ]
435        .into_iter()
436        .filter(|(_, value)| value.is_some_and(|v| !is_protocol_timestamp(v)))
437        .map(|(name, _)| name)
438        .collect()
439    }
440
441    /// Whether every temporal field present on this frame is well-formed.
442    pub fn has_valid_temporal_fields(&self) -> bool {
443        self.invalid_temporal_fields().is_empty()
444    }
445
446    /// Provenance entries that address a file but carry a malformed or missing
447    /// digest (`SPEC.md` §F5).
448    ///
449    /// File provenance is held to a stricter standard than other kinds because
450    /// it is the one the host can independently verify: the bytes are on disk.
451    /// A `derivation` or `episode` link has no addressable bytes, so requiring
452    /// a digest of it would be theatre.
453    pub fn provenance_with_unusable_digests(&self) -> Vec<usize> {
454        self.provenance
455            .iter()
456            .enumerate()
457            .filter(|(_, p)| p.is_file_provenance() && !p.has_well_formed_digest())
458            .map(|(index, _)| index)
459            .collect()
460    }
461
462    /// Whether this frame's own `content_digest`, if it carries one, is in the
463    /// protocol's digest form (`SPEC.md` §D1).
464    ///
465    /// Absent is fine — §D1 binds the digest only "when present". What is not
466    /// fine is a *present* digest that no host can compare against, which is
467    /// what `sha256:abc` is. §D1 named `frame-validity` as its verifier while
468    /// nothing read this field: the digest that anchors deterministic
469    /// composition, usage reports, and `context/verify` was the one digest in
470    /// the protocol that went unchecked, and §F5 held provenance to a stricter
471    /// standard than the frame's own identity.
472    pub fn has_usable_content_digest(&self) -> bool {
473        self.content_digest
474            .as_deref()
475            .is_none_or(is_well_formed_digest)
476    }
477
478    /// Whether this frame's fields satisfy the invariants of its declared
479    /// [`representation`](Self::representation). Providers emit conforming
480    /// frames; hosts reject a frame that lies about its shape (e.g. a
481    /// `reference` carrying inline content, or a `compact` missing its
482    /// canonical hash). The `Err` string names the exact violation.
483    pub fn representation_invariants(&self) -> Result<(), String> {
484        match self.representation {
485            Representation::Full => {
486                if self.content.is_none() {
487                    return Err("full frame requires inline content".into());
488                }
489            }
490            Representation::Compact => {
491                if self.content.is_none() {
492                    return Err("compact frame requires inline content".into());
493                }
494                if self.content_digest.is_none() {
495                    return Err(
496                        "compact frame requires an inline content hash (content_digest)".into(),
497                    );
498                }
499                if self.canonical_content_hash.is_none() {
500                    return Err("compact frame requires canonical_content_hash".into());
501                }
502                if self.transform.is_none() {
503                    return Err("compact frame requires a transform identity".into());
504                }
505                if self.content_ref.is_none() {
506                    return Err("compact frame requires content_ref".into());
507                }
508            }
509            Representation::Reference => {
510                // "Never encode a reference as content: \"\"" — any inline
511                // content, empty or not, is a violation.
512                if self.content.is_some() {
513                    return Err("reference frame must not carry inline content".into());
514                }
515                if self.content_ref.is_none() {
516                    return Err("reference frame requires content_ref".into());
517                }
518                if self.canonical_content_hash.is_none() {
519                    return Err("reference frame requires canonical_content_hash".into());
520                }
521                if self.content_digest.is_some() {
522                    return Err(
523                        "reference frame must omit the inline content hash (content_digest)".into(),
524                    );
525                }
526                if self.transform.is_some() {
527                    return Err("reference frame must omit transform".into());
528                }
529            }
530        }
531        Ok(())
532    }
533}
534
535#[cfg(test)]
536mod tests {
537    use super::*;
538
539    fn sample_frame() -> ContextFrame {
540        let mut frame = ContextFrame::full(
541            "frm_1",
542            FrameKind::Snippet,
543            "workspace.ts L120-160",
544            "export interface Workspace { ... }",
545            0.83,
546            412,
547        );
548        frame.content_digest = Some("sha256:abc".into());
549        frame.uri = Some("file:///repo/workspace.ts".into());
550        frame.recorded_at = Some("2026-07-10T00:00:00Z".into());
551        frame.provenance = vec![Provenance {
552            kind: "file".into(),
553            uri: Some("file:///repo/workspace.ts".into()),
554            range: Some("L120-160".into()),
555            digest: Some("sha256:abc".into()),
556            method: None,
557            by: None,
558        }];
559        frame.citation_label = Some("workspace.ts L120-160".into());
560        frame
561    }
562
563    #[test]
564    fn context_frame_roundtrips_through_json() {
565        let frame = sample_frame();
566        let json = serde_json::to_string(&frame).unwrap();
567        let back: ContextFrame = serde_json::from_str(&json).unwrap();
568        assert_eq!(back, frame);
569    }
570
571    #[test]
572    fn score_out_of_range_fails_the_conformance_check() {
573        let mut frame = sample_frame();
574        assert!(frame.has_valid_score());
575        frame.score = 1.5;
576        assert!(!frame.has_valid_score());
577    }
578
579    #[test]
580    fn an_honest_frame_declares_the_canonical_cost_of_its_content() {
581        let mut frame = sample_frame();
582        frame.content = Some("abcd".repeat(10)); // 40 bytes -> 10 budget tokens
583        frame.token_cost = 10;
584        assert!(frame.declares_honest_token_cost());
585        assert_eq!(frame.expected_inline_token_cost(), 10);
586    }
587
588    #[test]
589    fn the_budget_lie_that_used_to_pass_every_check_is_now_caught() {
590        // Issue #8's headline case: a provider reporting `token_cost: 1` on a
591        // huge frame satisfied `sum(token_cost) <= max_tokens` perfectly.
592        let mut frame = sample_frame();
593        frame.content = Some("x".repeat(10_000));
594        frame.token_cost = 1;
595        assert!(!frame.declares_honest_token_cost());
596        assert_eq!(frame.expected_inline_token_cost(), 2_500);
597    }
598
599    #[test]
600    fn over_reporting_cost_is_a_lie_too_even_though_it_is_self_harming() {
601        // Exact equality, not an upper bound: an inflated cost would let a
602        // provider crowd honest peers out of a shared budget.
603        let mut frame = sample_frame();
604        frame.content = Some("abcd".into());
605        frame.token_cost = 500;
606        assert!(!frame.declares_honest_token_cost());
607    }
608
609    #[test]
610    fn malformed_temporal_fields_are_reported_by_name() {
611        let mut frame = sample_frame();
612        frame.valid_from = Some("last tuesday".into());
613        frame.valid_to = Some("2026-08-01T00:00:00Z".into());
614        frame.recorded_at = Some("2026-07-10".into());
615
616        // The names are what make a conformance failure actionable.
617        assert_eq!(
618            frame.invalid_temporal_fields(),
619            vec!["valid_from", "recorded_at"]
620        );
621        assert!(!frame.has_valid_temporal_fields());
622    }
623
624    #[test]
625    fn absent_temporal_fields_are_valid_because_they_are_optional() {
626        let mut frame = sample_frame();
627        frame.valid_from = None;
628        frame.valid_to = None;
629        frame.recorded_at = None;
630        assert!(frame.has_valid_temporal_fields());
631    }
632
633    #[test]
634    fn file_provenance_without_a_usable_digest_is_flagged_by_index() {
635        let mut frame = sample_frame();
636        // `sha256:abc` is the placeholder the pre-spec fixtures used.
637        assert_eq!(frame.provenance_with_unusable_digests(), vec![0]);
638
639        frame.provenance[0].digest = Some(format!("sha256:{}", "a".repeat(64)));
640        assert!(frame.provenance_with_unusable_digests().is_empty());
641    }
642
643    #[test]
644    fn non_file_provenance_is_not_required_to_carry_a_digest() {
645        // A derivation has no addressable bytes to digest, so demanding one
646        // would be theatre rather than integrity.
647        let mut frame = sample_frame();
648        frame.provenance = vec![Provenance {
649            kind: "derivation".into(),
650            uri: None,
651            range: None,
652            digest: None,
653            method: Some("summarized".into()),
654            by: Some("contextgraph-docs".into()),
655        }];
656        assert!(frame.provenance_with_unusable_digests().is_empty());
657    }
658
659    #[test]
660    fn a_graph_edge_must_be_citable_by_a_human_label() {
661        let edge = Relation {
662            rel: rel::CODE_CALLS.into(),
663            target_uri: "file:///repo/src/net.rs#retry".into(),
664            display_name: Some("net::retry".into()),
665        };
666        assert!(edge.has_display_name());
667        assert!(edge.uses_recommended_vocabulary());
668
669        // A raw id with no label is exactly what the "never a bare uuid" rule
670        // forbids, and nothing checked it before.
671        let unlabeled = Relation {
672            rel: "myindex.owns".into(),
673            target_uri: "file:///repo/src/net.rs".into(),
674            display_name: None,
675        };
676        assert!(!unlabeled.has_display_name());
677        // ...but an out-of-vocabulary `rel` is perfectly legal.
678        assert!(!unlabeled.uses_recommended_vocabulary());
679    }
680
681    #[test]
682    fn a_whitespace_only_display_name_does_not_count_as_a_label() {
683        let edge = Relation {
684            rel: rel::DOC_DOCUMENTS.into(),
685            target_uri: "file:///docs/net.md".into(),
686            display_name: Some("   ".into()),
687        };
688        assert!(!edge.has_display_name());
689    }
690
691    #[test]
692    fn a_present_content_digest_must_be_usable_but_an_absent_one_is_fine() {
693        // §D1 binds the digest only "when present": a frame that carries none
694        // is conformant, a frame that carries `sha256:abc` is not.
695        let mut frame = sample_frame();
696        frame.content_digest = None;
697        assert!(frame.has_usable_content_digest(), "absent is permitted");
698
699        frame.content_digest = Some(format!("sha256:{}", "a".repeat(64)));
700        assert!(frame.has_usable_content_digest());
701
702        for malformed in ["sha256:abc", &format!("sha256:{}", "A".repeat(64))] {
703            frame.content_digest = Some(malformed.to_string());
704            assert!(
705                !frame.has_usable_content_digest(),
706                "{malformed} is not a comparable digest"
707            );
708        }
709    }
710
711    #[test]
712    fn an_edge_pointing_nowhere_does_not_satisfy_g2() {
713        // §G2 says `target_uri` MUST be a non-empty URI and claimed
714        // `frame-validity` checked it; nothing read the field at all. serde
715        // guarantees presence (it is not an Option), so the reachable breach is
716        // the empty — or whitespace-only — string, which the schema's
717        // `minLength: 1` rejects and the Rust side silently accepted.
718        let labelled_but_dangling = Relation {
719            rel: rel::DOC_DOCUMENTS.into(),
720            target_uri: String::new(),
721            display_name: Some("Net docs".into()),
722        };
723        assert!(labelled_but_dangling.has_display_name(), "§G1 is satisfied");
724        assert!(!labelled_but_dangling.has_target_uri(), "but §G2 is not");
725
726        let whitespace = Relation {
727            target_uri: "   ".into(),
728            ..labelled_but_dangling.clone()
729        };
730        assert!(!whitespace.has_target_uri());
731
732        let real = Relation {
733            target_uri: "file:///docs/net.md".into(),
734            ..labelled_but_dangling
735        };
736        assert!(real.has_target_uri());
737    }
738
739    #[test]
740    fn optional_fields_are_omitted_when_absent() {
741        let frame = sample_frame();
742        let mut minimal = frame.clone();
743        minimal.uri = None;
744        minimal.valid_from = None;
745        minimal.content_digest = None;
746        minimal.provenance.clear();
747        let json = serde_json::to_string(&minimal).unwrap();
748        assert!(!json.contains("\"uri\""));
749        assert!(!json.contains("\"provenance\""));
750        assert!(!json.contains("\"content_digest\""));
751    }
752
753    #[test]
754    fn full_frame_omits_representation_on_the_wire() {
755        // A full frame keeps the legacy wire shape: `representation` is absent,
756        // so pre-representation consumers see no new field.
757        let frame = sample_frame();
758        assert_eq!(frame.representation, Representation::Full);
759        let json = serde_json::to_string(&frame).unwrap();
760        assert!(
761            !json.contains("representation"),
762            "full frames must omit the representation field: {json}"
763        );
764        assert!(frame.representation_invariants().is_ok());
765    }
766
767    #[test]
768    fn reference_frame_omits_content_and_round_trips_its_handle() {
769        let frame = ContextFrame::reference(
770            "frm_ref_1",
771            FrameKind::Doc,
772            "Deployment runbook",
773            ContentRef {
774                provider_id: "provider_example".into(),
775                uri: "context://provider_example/records/doc_runbook_v1".into(),
776                expires_at: None,
777            },
778            "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
779            0.9,
780        );
781        frame
782            .representation_invariants()
783            .expect("constructed reference frame must be structurally honest");
784
785        let json = serde_json::to_string(&frame).unwrap();
786        assert!(
787            !json.contains("\"content\""),
788            "a reference frame must not carry inline content: {json}"
789        );
790        assert!(json.contains("\"representation\":\"reference\""));
791
792        let back: ContextFrame = serde_json::from_str(&json).unwrap();
793        assert_eq!(back, frame);
794        assert_eq!(back.representation, Representation::Reference);
795        assert_eq!(
796            back.content_ref.as_ref().unwrap().provider_id,
797            "provider_example"
798        );
799    }
800
801    #[test]
802    fn a_reference_with_inline_content_violates_its_invariants() {
803        let mut frame = ContextFrame::reference(
804            "frm_ref_2",
805            FrameKind::Doc,
806            "Runbook",
807            ContentRef {
808                provider_id: "p".into(),
809                uri: "context://p/r".into(),
810                expires_at: None,
811            },
812            "sha256:aa",
813            0.5,
814        );
815        // Even an empty string is a lie for a reference.
816        frame.content = Some(String::new());
817        assert!(frame.representation_invariants().is_err());
818    }
819
820    #[test]
821    fn compact_frame_requires_its_full_metadata_set() {
822        let mut frame = sample_frame();
823        frame.representation = Representation::Compact;
824        // full()-seeded frame lacks the compact metadata → invalid.
825        assert!(frame.representation_invariants().is_err());
826
827        frame.content_digest = Some("sha256:inline".into());
828        frame.canonical_content_hash = Some("sha256:canonical".into());
829        frame.transform = Some(Transform {
830            method: "extractive_summary".into(),
831            implementation: "provider_default".into(),
832            version: "1".into(),
833        });
834        frame.content_ref = Some(ContentRef {
835            provider_id: "provider_example".into(),
836            uri: "context://provider_example/records/x".into(),
837            expires_at: None,
838        });
839        frame.content = Some("summary…".into());
840        assert!(frame.representation_invariants().is_ok());
841    }
842}