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
22const KIND_SNIPPET: &str = "snippet";
24const KIND_SYMBOL: &str = "symbol";
25const KIND_FACT: &str = "fact";
26const KIND_DOC: &str = "doc";
27const KIND_MEMORY: &str = "memory";
28const KIND_EPISODE: &str = "episode";
29const KIND_GRAPH: &str = "graph";
30
31#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
69#[non_exhaustive]
70pub enum FrameKind {
71 Snippet,
72 Symbol,
73 Fact,
74 Doc,
75 Memory,
76 Episode,
77 Graph,
78 Unknown(String),
86}
87
88impl FrameKind {
89 pub fn as_str(&self) -> &str {
91 match self {
92 Self::Snippet => KIND_SNIPPET,
93 Self::Symbol => KIND_SYMBOL,
94 Self::Fact => KIND_FACT,
95 Self::Doc => KIND_DOC,
96 Self::Memory => KIND_MEMORY,
97 Self::Episode => KIND_EPISODE,
98 Self::Graph => KIND_GRAPH,
99 Self::Unknown(kind) => kind,
100 }
101 }
102
103 pub fn from_wire(kind: impl Into<String>) -> Self {
107 let kind = kind.into();
108 match kind.as_str() {
109 KIND_SNIPPET => Self::Snippet,
110 KIND_SYMBOL => Self::Symbol,
111 KIND_FACT => Self::Fact,
112 KIND_DOC => Self::Doc,
113 KIND_MEMORY => Self::Memory,
114 KIND_EPISODE => Self::Episode,
115 KIND_GRAPH => Self::Graph,
116 _ => Self::Unknown(kind),
117 }
118 }
119
120 pub fn is_known(&self) -> bool {
122 !matches!(self, Self::Unknown(_))
123 }
124
125 pub const KNOWN: &'static [&'static str] = &[
127 KIND_SNIPPET,
128 KIND_SYMBOL,
129 KIND_FACT,
130 KIND_DOC,
131 KIND_MEMORY,
132 KIND_EPISODE,
133 KIND_GRAPH,
134 ];
135}
136
137impl std::fmt::Display for FrameKind {
138 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139 f.write_str(self.as_str())
140 }
141}
142
143impl Serialize for FrameKind {
144 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
145 serializer.serialize_str(self.as_str())
146 }
147}
148
149impl<'de> Deserialize<'de> for FrameKind {
150 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
151 let kind = String::deserialize(deserializer)?;
152 Ok(Self::from_wire(kind))
153 }
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
169#[serde(rename_all = "snake_case")]
170pub enum Representation {
171 #[default]
172 Full,
173 Compact,
174 Reference,
175}
176
177impl Representation {
178 pub fn is_full(&self) -> bool {
183 matches!(self, Representation::Full)
184 }
185}
186
187#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
190#[serde(rename_all = "snake_case")]
191pub enum ContentFidelity {
192 Exact,
193 Normalized,
194 Summarized,
195 Omitted,
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
203#[serde(rename_all = "snake_case")]
204pub enum InlineContentRequirement {
205 Required,
206 ResolvableReferenceAllowed,
207}
208
209#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
216pub struct ContentRef {
217 pub provider_id: String,
220 pub uri: String,
222 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub expires_at: Option<String>,
225}
226
227#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
230pub struct Transform {
231 pub method: String,
233 pub implementation: String,
235 pub version: String,
236}
237
238#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
240pub struct Provenance {
241 #[serde(rename = "type")]
243 pub kind: String,
244 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub uri: Option<String>,
246 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub range: Option<String>,
248 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub digest: Option<String>,
250 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub method: Option<String>,
252 #[serde(default, skip_serializing_if = "Option::is_none")]
253 pub by: Option<String>,
254}
255
256impl Provenance {
257 pub fn is_file_provenance(&self) -> bool {
259 self.kind == "file"
260 }
261
262 pub fn has_well_formed_digest(&self) -> bool {
268 self.digest.as_deref().is_some_and(is_well_formed_digest)
269 }
270}
271
272pub mod rel {
284 pub const CODE_CALLS: &str = "code.calls";
286 pub const CODE_IMPORTS: &str = "code.imports";
288 pub const CODE_DEFINES: &str = "code.defines";
290 pub const CODE_REFERENCES: &str = "code.references";
292 pub const DOC_DOCUMENTS: &str = "doc.documents";
294 pub const EPISODE_FOLLOWS: &str = "episode.follows";
296
297 pub const RECOMMENDED: &[&str] = &[
299 CODE_CALLS,
300 CODE_IMPORTS,
301 CODE_DEFINES,
302 CODE_REFERENCES,
303 DOC_DOCUMENTS,
304 EPISODE_FOLLOWS,
305 ];
306}
307
308#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
311pub struct Relation {
312 pub rel: String,
315 pub target_uri: String,
316 #[serde(default, skip_serializing_if = "Option::is_none")]
317 pub display_name: Option<String>,
318}
319
320impl Relation {
321 pub fn has_display_name(&self) -> bool {
328 self.display_name
329 .as_deref()
330 .is_some_and(|name| !name.trim().is_empty())
331 }
332
333 pub fn has_target_uri(&self) -> bool {
341 !self.target_uri.trim().is_empty()
342 }
343
344 pub fn uses_recommended_vocabulary(&self) -> bool {
347 rel::RECOMMENDED.contains(&self.rel.as_str())
348 }
349}
350
351#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
354pub struct FrameEmbedding {
355 pub fingerprint: String,
356 #[serde(default, skip_serializing_if = "Option::is_none")]
357 pub vector: Option<Vec<f32>>,
358}
359
360#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
362pub struct ContextFrame {
363 pub id: String,
365 pub kind: FrameKind,
366 pub title: String,
368 #[serde(default, skip_serializing_if = "Option::is_none")]
375 pub content: Option<String>,
376 #[serde(default, skip_serializing_if = "Option::is_none")]
385 pub content_digest: Option<String>,
386 #[serde(default, skip_serializing_if = "Option::is_none")]
387 pub uri: Option<String>,
388 #[serde(default, skip_serializing_if = "Representation::is_full")]
391 pub representation: Representation,
392 #[serde(default, skip_serializing_if = "Option::is_none")]
395 pub content_fidelity: Option<ContentFidelity>,
396 #[serde(default, skip_serializing_if = "Option::is_none")]
400 pub canonical_content_hash: Option<String>,
401 #[serde(default, skip_serializing_if = "Option::is_none")]
403 pub content_ref: Option<ContentRef>,
404 #[serde(default, skip_serializing_if = "Option::is_none")]
407 pub transform: Option<Transform>,
408 #[serde(default, skip_serializing_if = "Option::is_none")]
410 pub minimum_content_fidelity: Option<ContentFidelity>,
411 #[serde(default, skip_serializing_if = "Option::is_none")]
413 pub inline_content_requirement: Option<InlineContentRequirement>,
414 pub score: f32,
416 pub token_cost: u32,
418 #[serde(default, skip_serializing_if = "Option::is_none")]
423 pub canonical_token_cost: Option<u32>,
424 #[serde(default, skip_serializing_if = "Option::is_none")]
427 pub tokenizer_ref: Option<String>,
428 #[serde(default, skip_serializing_if = "Option::is_none")]
429 pub valid_from: Option<String>,
430 #[serde(default, skip_serializing_if = "Option::is_none")]
431 pub valid_to: Option<String>,
432 #[serde(default, skip_serializing_if = "Option::is_none")]
433 pub recorded_at: Option<String>,
434 #[serde(default, skip_serializing_if = "Vec::is_empty")]
435 pub provenance: Vec<Provenance>,
436 #[serde(default, skip_serializing_if = "Option::is_none")]
437 pub citation_label: Option<String>,
438 #[serde(default, skip_serializing_if = "Option::is_none")]
439 pub embedding: Option<FrameEmbedding>,
440 #[serde(default, skip_serializing_if = "Vec::is_empty")]
441 pub relations: Vec<Relation>,
442}
443
444impl ContextFrame {
445 pub fn full(
451 id: impl Into<String>,
452 kind: FrameKind,
453 title: impl Into<String>,
454 content: impl Into<String>,
455 score: f32,
456 token_cost: u32,
457 ) -> Self {
458 Self {
459 id: id.into(),
460 kind,
461 title: title.into(),
462 content: Some(content.into()),
463 content_digest: None,
464 uri: None,
465 representation: Representation::Full,
466 content_fidelity: None,
467 canonical_content_hash: None,
468 content_ref: None,
469 transform: None,
470 minimum_content_fidelity: None,
471 inline_content_requirement: None,
472 score,
473 token_cost,
474 canonical_token_cost: None,
475 tokenizer_ref: None,
476 valid_from: None,
477 valid_to: None,
478 recorded_at: None,
479 provenance: Vec::new(),
480 citation_label: None,
481 embedding: None,
482 relations: Vec::new(),
483 }
484 }
485
486 pub fn reference(
490 id: impl Into<String>,
491 kind: FrameKind,
492 title: impl Into<String>,
493 content_ref: ContentRef,
494 canonical_content_hash: impl Into<String>,
495 score: f32,
496 ) -> Self {
497 Self {
498 representation: Representation::Reference,
499 content: None,
500 content_ref: Some(content_ref),
501 canonical_content_hash: Some(canonical_content_hash.into()),
502 ..Self::full(id, kind, title, String::new(), score, 0)
503 }
504 }
508
509 pub fn has_valid_score(&self) -> bool {
512 (0.0..=1.0).contains(&self.score)
513 }
514
515 pub fn identity(&self, provider_id: impl Into<String>) -> FrameId {
520 FrameId::new(provider_id, self.id.clone(), self.content_digest.clone())
521 }
522
523 pub fn expected_inline_token_cost(&self) -> u32 {
530 budget_tokens(self.content.as_deref().unwrap_or(""))
531 }
532
533 pub fn declares_honest_token_cost(&self) -> bool {
540 self.token_cost == self.expected_inline_token_cost()
541 }
542
543 pub fn invalid_temporal_fields(&self) -> Vec<&'static str> {
551 [
552 ("valid_from", self.valid_from.as_deref()),
553 ("valid_to", self.valid_to.as_deref()),
554 ("recorded_at", self.recorded_at.as_deref()),
555 ]
556 .into_iter()
557 .filter(|(_, value)| value.is_some_and(|v| !is_protocol_timestamp(v)))
558 .map(|(name, _)| name)
559 .collect()
560 }
561
562 pub fn has_valid_temporal_fields(&self) -> bool {
564 self.invalid_temporal_fields().is_empty()
565 }
566
567 pub fn provenance_with_unusable_digests(&self) -> Vec<usize> {
575 self.provenance
576 .iter()
577 .enumerate()
578 .filter(|(_, p)| p.is_file_provenance() && !p.has_well_formed_digest())
579 .map(|(index, _)| index)
580 .collect()
581 }
582
583 pub fn has_usable_content_digest(&self) -> bool {
594 self.content_digest
595 .as_deref()
596 .is_none_or(is_well_formed_digest)
597 }
598
599 pub fn representation_invariants(&self) -> Result<(), String> {
605 match self.representation {
606 Representation::Full => {
607 if self.content.is_none() {
608 return Err("full frame requires inline content".into());
609 }
610 }
611 Representation::Compact => {
612 if self.content.is_none() {
613 return Err("compact frame requires inline content".into());
614 }
615 if self.content_digest.is_none() {
616 return Err(
617 "compact frame requires an inline content hash (content_digest)".into(),
618 );
619 }
620 if self.canonical_content_hash.is_none() {
621 return Err("compact frame requires canonical_content_hash".into());
622 }
623 if self.transform.is_none() {
624 return Err("compact frame requires a transform identity".into());
625 }
626 if self.content_ref.is_none() {
627 return Err("compact frame requires content_ref".into());
628 }
629 }
630 Representation::Reference => {
631 if self.content.is_some() {
634 return Err("reference frame must not carry inline content".into());
635 }
636 if self.content_ref.is_none() {
637 return Err("reference frame requires content_ref".into());
638 }
639 if self.canonical_content_hash.is_none() {
640 return Err("reference frame requires canonical_content_hash".into());
641 }
642 if self.content_digest.is_some() {
643 return Err(
644 "reference frame must omit the inline content hash (content_digest)".into(),
645 );
646 }
647 if self.transform.is_some() {
648 return Err("reference frame must omit transform".into());
649 }
650 }
651 }
652 Ok(())
653 }
654}
655
656#[cfg(test)]
657mod tests {
658 use super::*;
659
660 fn sample_frame() -> ContextFrame {
661 let mut frame = ContextFrame::full(
662 "frm_1",
663 FrameKind::Snippet,
664 "workspace.ts L120-160",
665 "export interface Workspace { ... }",
666 0.83,
667 412,
668 );
669 frame.content_digest = Some("sha256:abc".into());
670 frame.uri = Some("file:///repo/workspace.ts".into());
671 frame.recorded_at = Some("2026-07-10T00:00:00Z".into());
672 frame.provenance = vec![Provenance {
673 kind: "file".into(),
674 uri: Some("file:///repo/workspace.ts".into()),
675 range: Some("L120-160".into()),
676 digest: Some("sha256:abc".into()),
677 method: None,
678 by: None,
679 }];
680 frame.citation_label = Some("workspace.ts L120-160".into());
681 frame
682 }
683
684 #[test]
685 fn context_frame_roundtrips_through_json() {
686 let frame = sample_frame();
687 let json = serde_json::to_string(&frame).unwrap();
688 let back: ContextFrame = serde_json::from_str(&json).unwrap();
689 assert_eq!(back, frame);
690 }
691
692 #[test]
693 fn score_out_of_range_fails_the_conformance_check() {
694 let mut frame = sample_frame();
695 assert!(frame.has_valid_score());
696 frame.score = 1.5;
697 assert!(!frame.has_valid_score());
698 }
699
700 #[test]
701 fn an_honest_frame_declares_the_canonical_cost_of_its_content() {
702 let mut frame = sample_frame();
703 frame.content = Some("abcd".repeat(10)); frame.token_cost = 10;
705 assert!(frame.declares_honest_token_cost());
706 assert_eq!(frame.expected_inline_token_cost(), 10);
707 }
708
709 #[test]
710 fn the_budget_lie_that_used_to_pass_every_check_is_now_caught() {
711 let mut frame = sample_frame();
714 frame.content = Some("x".repeat(10_000));
715 frame.token_cost = 1;
716 assert!(!frame.declares_honest_token_cost());
717 assert_eq!(frame.expected_inline_token_cost(), 2_500);
718 }
719
720 #[test]
721 fn over_reporting_cost_is_a_lie_too_even_though_it_is_self_harming() {
722 let mut frame = sample_frame();
725 frame.content = Some("abcd".into());
726 frame.token_cost = 500;
727 assert!(!frame.declares_honest_token_cost());
728 }
729
730 #[test]
731 fn malformed_temporal_fields_are_reported_by_name() {
732 let mut frame = sample_frame();
733 frame.valid_from = Some("last tuesday".into());
734 frame.valid_to = Some("2026-08-01T00:00:00Z".into());
735 frame.recorded_at = Some("2026-07-10".into());
736
737 assert_eq!(
739 frame.invalid_temporal_fields(),
740 vec!["valid_from", "recorded_at"]
741 );
742 assert!(!frame.has_valid_temporal_fields());
743 }
744
745 #[test]
746 fn absent_temporal_fields_are_valid_because_they_are_optional() {
747 let mut frame = sample_frame();
748 frame.valid_from = None;
749 frame.valid_to = None;
750 frame.recorded_at = None;
751 assert!(frame.has_valid_temporal_fields());
752 }
753
754 #[test]
755 fn file_provenance_without_a_usable_digest_is_flagged_by_index() {
756 let mut frame = sample_frame();
757 assert_eq!(frame.provenance_with_unusable_digests(), vec![0]);
759
760 frame.provenance[0].digest = Some(format!("sha256:{}", "a".repeat(64)));
761 assert!(frame.provenance_with_unusable_digests().is_empty());
762 }
763
764 #[test]
765 fn non_file_provenance_is_not_required_to_carry_a_digest() {
766 let mut frame = sample_frame();
769 frame.provenance = vec![Provenance {
770 kind: "derivation".into(),
771 uri: None,
772 range: None,
773 digest: None,
774 method: Some("summarized".into()),
775 by: Some("contextgraph-docs".into()),
776 }];
777 assert!(frame.provenance_with_unusable_digests().is_empty());
778 }
779
780 #[test]
781 fn a_graph_edge_must_be_citable_by_a_human_label() {
782 let edge = Relation {
783 rel: rel::CODE_CALLS.into(),
784 target_uri: "file:///repo/src/net.rs#retry".into(),
785 display_name: Some("net::retry".into()),
786 };
787 assert!(edge.has_display_name());
788 assert!(edge.uses_recommended_vocabulary());
789
790 let unlabeled = Relation {
793 rel: "myindex.owns".into(),
794 target_uri: "file:///repo/src/net.rs".into(),
795 display_name: None,
796 };
797 assert!(!unlabeled.has_display_name());
798 assert!(!unlabeled.uses_recommended_vocabulary());
800 }
801
802 #[test]
803 fn a_whitespace_only_display_name_does_not_count_as_a_label() {
804 let edge = Relation {
805 rel: rel::DOC_DOCUMENTS.into(),
806 target_uri: "file:///docs/net.md".into(),
807 display_name: Some(" ".into()),
808 };
809 assert!(!edge.has_display_name());
810 }
811
812 #[test]
813 fn a_present_content_digest_must_be_usable_but_an_absent_one_is_fine() {
814 let mut frame = sample_frame();
817 frame.content_digest = None;
818 assert!(frame.has_usable_content_digest(), "absent is permitted");
819
820 frame.content_digest = Some(format!("sha256:{}", "a".repeat(64)));
821 assert!(frame.has_usable_content_digest());
822
823 for malformed in ["sha256:abc", &format!("sha256:{}", "A".repeat(64))] {
824 frame.content_digest = Some(malformed.to_string());
825 assert!(
826 !frame.has_usable_content_digest(),
827 "{malformed} is not a comparable digest"
828 );
829 }
830 }
831
832 #[test]
833 fn an_edge_pointing_nowhere_does_not_satisfy_g2() {
834 let labelled_but_dangling = Relation {
840 rel: rel::DOC_DOCUMENTS.into(),
841 target_uri: String::new(),
842 display_name: Some("Net docs".into()),
843 };
844 assert!(labelled_but_dangling.has_display_name(), "§G1 is satisfied");
845 assert!(!labelled_but_dangling.has_target_uri(), "but §G2 is not");
846
847 let whitespace = Relation {
848 target_uri: " ".into(),
849 ..labelled_but_dangling.clone()
850 };
851 assert!(!whitespace.has_target_uri());
852
853 let real = Relation {
854 target_uri: "file:///docs/net.md".into(),
855 ..labelled_but_dangling
856 };
857 assert!(real.has_target_uri());
858 }
859
860 #[test]
861 fn optional_fields_are_omitted_when_absent() {
862 let frame = sample_frame();
863 let mut minimal = frame.clone();
864 minimal.uri = None;
865 minimal.valid_from = None;
866 minimal.content_digest = None;
867 minimal.provenance.clear();
868 let json = serde_json::to_string(&minimal).unwrap();
869 assert!(!json.contains("\"uri\""));
870 assert!(!json.contains("\"provenance\""));
871 assert!(!json.contains("\"content_digest\""));
872 }
873
874 #[test]
875 fn full_frame_omits_representation_on_the_wire() {
876 let frame = sample_frame();
879 assert_eq!(frame.representation, Representation::Full);
880 let json = serde_json::to_string(&frame).unwrap();
881 assert!(
882 !json.contains("representation"),
883 "full frames must omit the representation field: {json}"
884 );
885 assert!(frame.representation_invariants().is_ok());
886 }
887
888 #[test]
889 fn reference_frame_omits_content_and_round_trips_its_handle() {
890 let frame = ContextFrame::reference(
891 "frm_ref_1",
892 FrameKind::Doc,
893 "Deployment runbook",
894 ContentRef {
895 provider_id: "provider_example".into(),
896 uri: "context://provider_example/records/doc_runbook_v1".into(),
897 expires_at: None,
898 },
899 "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
900 0.9,
901 );
902 frame
903 .representation_invariants()
904 .expect("constructed reference frame must be structurally honest");
905
906 let json = serde_json::to_string(&frame).unwrap();
907 assert!(
908 !json.contains("\"content\""),
909 "a reference frame must not carry inline content: {json}"
910 );
911 assert!(json.contains("\"representation\":\"reference\""));
912
913 let back: ContextFrame = serde_json::from_str(&json).unwrap();
914 assert_eq!(back, frame);
915 assert_eq!(back.representation, Representation::Reference);
916 assert_eq!(
917 back.content_ref.as_ref().unwrap().provider_id,
918 "provider_example"
919 );
920 }
921
922 #[test]
923 fn a_reference_with_inline_content_violates_its_invariants() {
924 let mut frame = ContextFrame::reference(
925 "frm_ref_2",
926 FrameKind::Doc,
927 "Runbook",
928 ContentRef {
929 provider_id: "p".into(),
930 uri: "context://p/r".into(),
931 expires_at: None,
932 },
933 "sha256:aa",
934 0.5,
935 );
936 frame.content = Some(String::new());
938 assert!(frame.representation_invariants().is_err());
939 }
940
941 #[test]
942 fn compact_frame_requires_its_full_metadata_set() {
943 let mut frame = sample_frame();
944 frame.representation = Representation::Compact;
945 assert!(frame.representation_invariants().is_err());
947
948 frame.content_digest = Some("sha256:inline".into());
949 frame.canonical_content_hash = Some("sha256:canonical".into());
950 frame.transform = Some(Transform {
951 method: "extractive_summary".into(),
952 implementation: "provider_default".into(),
953 version: "1".into(),
954 });
955 frame.content_ref = Some(ContentRef {
956 provider_id: "provider_example".into(),
957 uri: "context://provider_example/records/x".into(),
958 expires_at: None,
959 });
960 frame.content = Some("summary…".into());
961 assert!(frame.representation_invariants().is_ok());
962 }
963
964 #[test]
965 fn every_known_kind_round_trips_through_its_canonical_wire_string() {
966 for (kind, wire) in [
967 (FrameKind::Snippet, "snippet"),
968 (FrameKind::Symbol, "symbol"),
969 (FrameKind::Fact, "fact"),
970 (FrameKind::Doc, "doc"),
971 (FrameKind::Memory, "memory"),
972 (FrameKind::Episode, "episode"),
973 (FrameKind::Graph, "graph"),
974 ] {
975 assert_eq!(kind.as_str(), wire);
976 let json = serde_json::to_string(&kind).unwrap();
977 assert_eq!(json, format!("\"{wire}\""));
978 let back: FrameKind = serde_json::from_str(&json).unwrap();
979 assert_eq!(back, kind);
980 assert!(kind.is_known());
981 }
982 }
983
984 #[test]
985 fn a_kind_from_a_later_minor_version_deserializes_instead_of_failing() {
986 let back: FrameKind = serde_json::from_str("\"trajectory\"").unwrap();
991 assert_eq!(back, FrameKind::Unknown("trajectory".into()));
992 assert!(!back.is_known());
993 }
994
995 #[test]
996 fn an_unknown_kind_re_serializes_byte_identically() {
997 let json = "\"trajectory\"";
1002 let kind: FrameKind = serde_json::from_str(json).unwrap();
1003 assert_eq!(serde_json::to_string(&kind).unwrap(), json);
1004 }
1005
1006 #[test]
1007 fn a_whole_frame_with_an_unknown_kind_survives_a_round_trip() {
1008 let wire = r#"{"id":"f1","kind":"trajectory","title":"Run 12","content":"…","score":0.5,"token_cost":1}"#;
1009 let frame: ContextFrame = serde_json::from_str(wire).unwrap();
1010 assert_eq!(frame.kind, FrameKind::Unknown("trajectory".into()));
1011 assert!(frame.has_valid_score());
1014 let back: ContextFrame =
1015 serde_json::from_str(&serde_json::to_string(&frame).unwrap()).unwrap();
1016 assert_eq!(back, frame);
1017 }
1018
1019 #[test]
1020 fn an_unknown_kind_never_collides_with_a_known_one() {
1021 assert_eq!(FrameKind::from_wire("doc"), FrameKind::Doc);
1022 assert_ne!(FrameKind::Unknown("doc".into()), FrameKind::Doc);
1023 for known in FrameKind::KNOWN {
1024 assert!(FrameKind::from_wire(*known).is_known(), "{known}");
1025 }
1026 assert_eq!(FrameKind::KNOWN.len(), 7);
1027 }
1028}