1use 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#[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#[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 pub fn is_full(&self) -> bool {
62 matches!(self, Representation::Full)
63 }
64}
65
66#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "snake_case")]
83pub enum InlineContentRequirement {
84 Required,
85 ResolvableReferenceAllowed,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95pub struct ContentRef {
96 pub provider_id: String,
99 pub uri: String,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub expires_at: Option<String>,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109pub struct Transform {
110 pub method: String,
112 pub implementation: String,
114 pub version: String,
115}
116
117#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
119pub struct Provenance {
120 #[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 pub fn is_file_provenance(&self) -> bool {
138 self.kind == "file"
139 }
140
141 pub fn has_well_formed_digest(&self) -> bool {
147 self.digest.as_deref().is_some_and(is_well_formed_digest)
148 }
149}
150
151pub mod rel {
163 pub const CODE_CALLS: &str = "code.calls";
165 pub const CODE_IMPORTS: &str = "code.imports";
167 pub const CODE_DEFINES: &str = "code.defines";
169 pub const CODE_REFERENCES: &str = "code.references";
171 pub const DOC_DOCUMENTS: &str = "doc.documents";
173 pub const EPISODE_FOLLOWS: &str = "episode.follows";
175
176 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
190pub struct Relation {
191 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 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 pub fn has_target_uri(&self) -> bool {
220 !self.target_uri.trim().is_empty()
221 }
222
223 pub fn uses_recommended_vocabulary(&self) -> bool {
226 rel::RECOMMENDED.contains(&self.rel.as_str())
227 }
228}
229
230#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
241pub struct ContextFrame {
242 pub id: String,
244 pub kind: FrameKind,
245 pub title: String,
247 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub content: Option<String>,
255 #[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 #[serde(default, skip_serializing_if = "Representation::is_full")]
270 pub representation: Representation,
271 #[serde(default, skip_serializing_if = "Option::is_none")]
274 pub content_fidelity: Option<ContentFidelity>,
275 #[serde(default, skip_serializing_if = "Option::is_none")]
279 pub canonical_content_hash: Option<String>,
280 #[serde(default, skip_serializing_if = "Option::is_none")]
282 pub content_ref: Option<ContentRef>,
283 #[serde(default, skip_serializing_if = "Option::is_none")]
286 pub transform: Option<Transform>,
287 #[serde(default, skip_serializing_if = "Option::is_none")]
289 pub minimum_content_fidelity: Option<ContentFidelity>,
290 #[serde(default, skip_serializing_if = "Option::is_none")]
292 pub inline_content_requirement: Option<InlineContentRequirement>,
293 pub score: f32,
295 pub token_cost: u32,
297 #[serde(default, skip_serializing_if = "Option::is_none")]
302 pub canonical_token_cost: Option<u32>,
303 #[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 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 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 }
387
388 pub fn has_valid_score(&self) -> bool {
391 (0.0..=1.0).contains(&self.score)
392 }
393
394 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 pub fn expected_inline_token_cost(&self) -> u32 {
409 budget_tokens(self.content.as_deref().unwrap_or(""))
410 }
411
412 pub fn declares_honest_token_cost(&self) -> bool {
419 self.token_cost == self.expected_inline_token_cost()
420 }
421
422 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 pub fn has_valid_temporal_fields(&self) -> bool {
443 self.invalid_temporal_fields().is_empty()
444 }
445
446 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 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 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 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)); 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 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 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 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 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 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 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 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 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 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 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 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 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}