1use std::sync::Arc;
24use std::time::Duration;
25
26use serde::{Deserialize, Serialize};
27use zeph_common::task_supervisor::TaskSupervisor;
28use zeph_common::text::truncate_to_bytes_ref;
29
30#[non_exhaustive]
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
43#[serde(rename_all = "snake_case")]
44pub enum PageType {
45 ToolOutput,
47 ConversationTurn,
49 MemoryExcerpt,
51 SystemContext,
53}
54
55impl std::fmt::Display for PageType {
56 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57 match self {
58 Self::ToolOutput => f.write_str("tool_output"),
59 Self::ConversationTurn => f.write_str("conversation_turn"),
60 Self::MemoryExcerpt => f.write_str("memory_excerpt"),
61 Self::SystemContext => f.write_str("system_context"),
62 }
63 }
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
70#[serde(tag = "kind", rename_all = "snake_case")]
71#[non_exhaustive]
72pub enum PageOrigin {
73 ToolPair {
75 tool_name: String,
77 },
78 Turn {
80 message_id: String,
82 },
83 Excerpt {
85 source_label: String,
87 },
88 System {
90 key: String,
92 },
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(rename_all = "snake_case")]
102#[non_exhaustive]
103pub enum SchemaHint {
104 Json,
106 Text,
108 Diff,
110 Table,
112 Binary,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
132pub struct PageId(pub String);
133
134impl PageId {
135 #[must_use]
137 pub fn compute(page_type: PageType, origin_key: &str, body: &[u8]) -> Self {
138 let mut hasher = blake3::Hasher::new();
139 hasher.update(page_type.to_string().as_bytes());
140 hasher.update(b"|");
141 hasher.update(origin_key.as_bytes());
142 hasher.update(b"|");
143 hasher.update(body);
144 let hash = hasher.finalize();
145 let mut hex = String::with_capacity(32);
147 for b in &hash.as_bytes()[..16] {
148 use std::fmt::Write as _;
149 let _ = write!(hex, "{b:02x}");
150 }
151 Self(format!("blake3:{hex}"))
152 }
153}
154
155#[derive(Debug, Clone)]
163pub struct TypedPage {
164 pub page_id: PageId,
166 pub page_type: PageType,
168 pub origin: PageOrigin,
170 pub tokens: u32,
172 pub body: Arc<str>,
174 pub schema_hint: Option<SchemaHint>,
176}
177
178impl TypedPage {
179 #[must_use]
181 pub fn new(
182 page_type: PageType,
183 origin: PageOrigin,
184 tokens: u32,
185 body: Arc<str>,
186 schema_hint: Option<SchemaHint>,
187 ) -> Self {
188 let origin_key = origin_key_for(&origin);
189 let page_id = PageId::compute(page_type, &origin_key, body.as_bytes());
190 Self {
191 page_id,
192 page_type,
193 origin,
194 tokens,
195 body,
196 schema_hint,
197 }
198 }
199}
200
201fn origin_key_for(origin: &PageOrigin) -> String {
202 match origin {
203 PageOrigin::ToolPair { tool_name } => format!("tool:{tool_name}"),
204 PageOrigin::Turn { message_id } => format!("turn:{message_id}"),
205 PageOrigin::Excerpt { source_label } => format!("excerpt:{source_label}"),
206 PageOrigin::System { key } => format!("system:{key}"),
207 }
208}
209
210#[derive(Debug, Clone)]
217pub struct FidelityContract {
218 pub fidelity_level: &'static str,
220 pub invariant_version: u8,
222 pub required_fields: &'static [&'static str],
224}
225
226#[derive(Debug, Clone, Serialize)]
233pub struct FidelityViolation {
234 pub missing_field: String,
236 pub detail: String,
238}
239
240#[derive(Debug, Clone)]
244pub struct CompactedPage {
245 pub body: Arc<str>,
247 pub tokens: u32,
249}
250
251pub trait PageInvariant: Send + Sync {
270 fn page_type(&self) -> PageType;
272
273 fn minimum_fidelity(&self, page: &TypedPage) -> FidelityContract;
275
276 fn verify(
283 &self,
284 original: &TypedPage,
285 compacted: &CompactedPage,
286 ) -> Result<(), Vec<FidelityViolation>>;
287}
288
289pub struct ToolOutputInvariant;
296
297impl PageInvariant for ToolOutputInvariant {
298 fn page_type(&self) -> PageType {
299 PageType::ToolOutput
300 }
301
302 fn minimum_fidelity(&self, _page: &TypedPage) -> FidelityContract {
303 FidelityContract {
304 fidelity_level: "structured_summary_v1",
305 invariant_version: 1,
306 required_fields: &["tool_name", "exit_status"],
307 }
308 }
309
310 fn verify(
311 &self,
312 original: &TypedPage,
313 compacted: &CompactedPage,
314 ) -> Result<(), Vec<FidelityViolation>> {
315 let body = compacted.body.as_ref();
316 if original.schema_hint == Some(SchemaHint::Binary) {
318 return Ok(());
319 }
320
321 let mut violations = Vec::new();
322
323 let tool_name = match &original.origin {
325 PageOrigin::ToolPair { tool_name } => tool_name.as_str(),
326 _ => "",
327 };
328 if !tool_name.is_empty() && !body.contains(tool_name) {
329 violations.push(FidelityViolation {
330 missing_field: "tool_name".into(),
331 detail: format!("compacted body does not reference tool '{tool_name}'"),
332 });
333 }
334
335 let has_status = body.contains("exit_status")
337 || body.contains("exit_code")
338 || body.contains("status:")
339 || body.contains("Status:")
340 || body.contains("exit:")
341 || body.contains("rc:");
342 if !has_status {
343 violations.push(FidelityViolation {
344 missing_field: "exit_status".into(),
345 detail: "compacted body does not contain an exit status indicator".into(),
346 });
347 }
348
349 if original.schema_hint == Some(SchemaHint::Json) {
353 let original_body = original.body.as_ref();
354 let preserved = check_json_structural_key(original_body, body);
355 if !preserved {
356 violations.push(FidelityViolation {
357 missing_field: "structural_key".into(),
358 detail: "compacted JSON tool output does not reference any top-level field \
359 name from the original output"
360 .into(),
361 });
362 }
363 }
364
365 if violations.is_empty() {
366 Ok(())
367 } else {
368 Err(violations)
369 }
370 }
371}
372
373fn check_json_structural_key(original: &str, compacted: &str) -> bool {
389 let Ok(value) = serde_json::from_str::<serde_json::Value>(original) else {
390 return true;
391 };
392 let Some(obj) = value.as_object() else {
393 return true;
394 };
395 if obj.is_empty() {
396 return true;
397 }
398 obj.keys()
399 .any(|k| key_appears_as_word(compacted, k.as_str()))
400}
401
402fn key_appears_as_word(haystack: &str, key: &str) -> bool {
410 if key.is_empty() {
411 return true;
412 }
413 let quoted = format!("\"{key}\"");
414 if haystack.contains("ed) {
415 return true;
416 }
417 let is_boundary = |c: Option<char>| !c.is_some_and(|c| c.is_alphanumeric() || c == '_');
418 haystack.match_indices(key).any(|(idx, matched)| {
419 let before = haystack[..idx].chars().next_back();
420 let after = haystack[idx + matched.len()..].chars().next();
421 is_boundary(before) && is_boundary(after)
422 })
423}
424
425pub struct ConversationTurnInvariant;
429
430impl PageInvariant for ConversationTurnInvariant {
431 fn page_type(&self) -> PageType {
432 PageType::ConversationTurn
433 }
434
435 fn minimum_fidelity(&self, _page: &TypedPage) -> FidelityContract {
436 FidelityContract {
437 fidelity_level: "semantic_summary_v1",
438 invariant_version: 1,
439 required_fields: &["role"],
440 }
441 }
442
443 fn verify(
444 &self,
445 _original: &TypedPage,
446 compacted: &CompactedPage,
447 ) -> Result<(), Vec<FidelityViolation>> {
448 let body = compacted.body.as_ref();
449 let has_role =
450 body.contains("user") || body.contains("assistant") || body.contains("system");
451 if !has_role {
452 return Err(vec![FidelityViolation {
453 missing_field: "role".into(),
454 detail: "compacted turn does not identify a speaker role".into(),
455 }]);
456 }
457 Ok(())
458 }
459}
460
461pub struct MemoryExcerptInvariant;
465
466impl PageInvariant for MemoryExcerptInvariant {
467 fn page_type(&self) -> PageType {
468 PageType::MemoryExcerpt
469 }
470
471 fn minimum_fidelity(&self, _page: &TypedPage) -> FidelityContract {
472 FidelityContract {
473 fidelity_level: "excerpt_summary_v1",
474 invariant_version: 1,
475 required_fields: &["source_label"],
476 }
477 }
478
479 fn verify(
480 &self,
481 original: &TypedPage,
482 compacted: &CompactedPage,
483 ) -> Result<(), Vec<FidelityViolation>> {
484 let source_label = match &original.origin {
485 PageOrigin::Excerpt { source_label } => source_label.as_str(),
486 _ => return Ok(()),
487 };
488 if !compacted.body.contains(source_label) {
489 return Err(vec![FidelityViolation {
490 missing_field: "source_label".into(),
491 detail: format!("compacted excerpt does not contain source label '{source_label}'"),
492 }]);
493 }
494 Ok(())
495 }
496}
497
498pub struct SystemContextInvariant;
503
504pub const SYSTEM_POINTER_PREFIX: &str = "[system-ptr:";
506
507impl PageInvariant for SystemContextInvariant {
508 fn page_type(&self) -> PageType {
509 PageType::SystemContext
510 }
511
512 fn minimum_fidelity(&self, _page: &TypedPage) -> FidelityContract {
513 FidelityContract {
514 fidelity_level: "pointer_replace_v1",
515 invariant_version: 1,
516 required_fields: &["pointer"],
517 }
518 }
519
520 fn verify(
521 &self,
522 _original: &TypedPage,
523 compacted: &CompactedPage,
524 ) -> Result<(), Vec<FidelityViolation>> {
525 if !compacted.body.starts_with(SYSTEM_POINTER_PREFIX) {
526 return Err(vec![FidelityViolation {
527 missing_field: "pointer".into(),
528 detail: format!(
529 "SystemContext page was not pointer-replaced \
530 (body does not start with '{SYSTEM_POINTER_PREFIX}')"
531 ),
532 }]);
533 }
534 Ok(())
535 }
536}
537
538pub struct InvariantRegistry {
554 tool_output: Box<dyn PageInvariant>,
555 conversation_turn: Box<dyn PageInvariant>,
556 memory_excerpt: Box<dyn PageInvariant>,
557 system_context: Box<dyn PageInvariant>,
558}
559
560impl Default for InvariantRegistry {
561 fn default() -> Self {
562 Self {
563 tool_output: Box::new(ToolOutputInvariant),
564 conversation_turn: Box::new(ConversationTurnInvariant),
565 memory_excerpt: Box::new(MemoryExcerptInvariant),
566 system_context: Box::new(SystemContextInvariant),
567 }
568 }
569}
570
571impl InvariantRegistry {
572 #[must_use]
576 pub fn get(&self, page_type: PageType) -> Option<&dyn PageInvariant> {
577 match page_type {
578 PageType::ToolOutput => Some(self.tool_output.as_ref()),
579 PageType::ConversationTurn => Some(self.conversation_turn.as_ref()),
580 PageType::MemoryExcerpt => Some(self.memory_excerpt.as_ref()),
581 PageType::SystemContext => Some(self.system_context.as_ref()),
582 }
583 }
584
585 pub fn enforce(
596 &self,
597 original: &TypedPage,
598 compacted: &CompactedPage,
599 ) -> Result<(), Vec<FidelityViolation>> {
600 let _span = tracing::info_span!(
601 "context.compaction.typed_page",
602 page_type = %original.page_type,
603 page_id = %original.page_id.0,
604 original_tokens = original.tokens,
605 compacted_tokens = compacted.tokens,
606 )
607 .entered();
608
609 if let Some(inv) = self.get(original.page_type) {
610 inv.verify(original, compacted)
611 } else {
612 tracing::warn!(
613 page_type = %original.page_type,
614 "no invariant registered for page type — skipping verification"
615 );
616 Ok(())
617 }
618 }
619}
620
621#[must_use]
650pub fn classify(body: &str) -> PageType {
651 classify_with_role(body, false)
652}
653
654#[must_use]
674pub fn classify_with_role(body: &str, is_system_role: bool) -> PageType {
675 tracing::info_span!(
676 "context.compaction.typed_page.classify",
677 body_len = body.len()
678 )
679 .in_scope(|| classify_with_role_inner(body, is_system_role))
680}
681
682fn classify_with_role_inner(body: &str, is_system_role: bool) -> PageType {
683 const TOOL_PREFIXES: &[&str] = &["[tool_output]", "[tool:", "[Tool output]"];
685 const MEMORY_PREFIXES: &[&str] = &[
686 "[cross-session context]",
687 "[semantic recall]",
688 "[known facts]",
689 "[conversation summaries]",
690 "[past corrections]",
691 "## Relevant documents",
692 ];
693 const SYSTEM_PREFIXES: &[&str] = &[
694 "[Persona context]",
695 "[Past experience]",
696 "[Memory summary]",
697 "[system",
698 "[skill",
699 "[persona",
700 "[digest",
701 "[compression",
702 ];
703
704 let trimmed = body.trim_start();
705
706 for prefix in TOOL_PREFIXES {
707 if trimmed.starts_with(prefix) {
708 return PageType::ToolOutput;
709 }
710 }
711 for prefix in MEMORY_PREFIXES {
712 if trimmed.starts_with(prefix) {
713 return PageType::MemoryExcerpt;
714 }
715 }
716 for prefix in SYSTEM_PREFIXES {
717 if trimmed.starts_with(prefix) {
718 return PageType::SystemContext;
719 }
720 }
721
722 if is_system_role {
727 return PageType::SystemContext;
728 }
729
730 tracing::warn!(
731 body_prefix = truncate_to_bytes_ref(body, 80),
732 "typed-page classification fallback to ConversationTurn"
733 );
734 PageType::ConversationTurn
735}
736
737#[must_use]
743pub fn detect_schema_hint(body: &str, is_binary: bool) -> SchemaHint {
744 if is_binary || body.contains('\u{FFFD}') {
745 return SchemaHint::Binary;
746 }
747 let trimmed = body.trim_start();
748 if trimmed.starts_with('{') || trimmed.starts_with('[') {
749 return SchemaHint::Json;
750 }
751 if trimmed.starts_with("--- ")
752 || trimmed.starts_with("+++ ")
753 || trimmed.starts_with("diff --git")
754 || trimmed.starts_with("diff -")
755 {
756 return SchemaHint::Diff;
757 }
758 let first_line = trimmed.lines().next().unwrap_or("");
760 if first_line.matches('\t').count() >= 2 || first_line.matches('|').count() >= 2 {
761 return SchemaHint::Table;
762 }
763 SchemaHint::Text
764}
765
766#[derive(Debug, Serialize)]
773pub struct CompactedPageRecord {
774 pub ts: String,
776 pub turn_id: String,
778 pub page_id: String,
780 pub page_type: PageType,
782 pub origin: PageOrigin,
784 pub original_tokens: u32,
786 pub compacted_tokens: u32,
788 pub fidelity_level: String,
790 pub invariant_version: u8,
792 pub provider_name: String,
794 pub violations: Vec<FidelityViolation>,
796 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
798 pub classification_fallback: bool,
799}
800
801#[derive(Debug, Clone, Serialize)]
805pub struct BatchViolation {
806 pub assertion: String,
808 pub detail: String,
810}
811
812#[derive(Debug, Clone, Default)]
836pub struct BatchAssertions {
837 pub tool_names_in_batch: Vec<String>,
839 pub has_memory_excerpt: bool,
841 pub excerpt_labels: Vec<String>,
843}
844
845impl BatchAssertions {
846 #[must_use]
850 pub fn check(&self, summary: &str) -> Vec<BatchViolation> {
851 let mut violations = Vec::new();
852
853 if !self.tool_names_in_batch.is_empty() {
855 let any_tool_mentioned = self
856 .tool_names_in_batch
857 .iter()
858 .any(|name| !name.is_empty() && summary.contains(name.as_str()));
859 if !any_tool_mentioned {
860 violations.push(BatchViolation {
861 assertion: "tool_coverage".into(),
862 detail: format!(
863 "summary mentions none of the {} tool(s) in batch: {:?}",
864 self.tool_names_in_batch.len(),
865 self.tool_names_in_batch
866 ),
867 });
868 }
869 }
870
871 if self.has_memory_excerpt && !self.excerpt_labels.is_empty() {
873 let any_label_mentioned = self
874 .excerpt_labels
875 .iter()
876 .any(|label| !label.is_empty() && summary.contains(label.as_str()));
877 if !any_label_mentioned {
878 violations.push(BatchViolation {
879 assertion: "excerpt_label_coverage".into(),
880 detail: format!(
881 "summary mentions none of the memory excerpt labels: {:?}",
882 self.excerpt_labels
883 ),
884 });
885 }
886 }
887
888 violations
889 }
890}
891
892pub struct TypedPagesState {
899 pub registry: InvariantRegistry,
901 pub audit_sink: Option<CompactionAuditSink>,
903 pub is_active: bool,
906}
907
908enum AuditCommand {
912 Record(CompactedPageRecord),
914 Flush(tokio::sync::oneshot::Sender<()>),
916}
917
918#[derive(Debug, Clone)]
946pub struct CompactionAuditSink {
947 tx: tokio::sync::mpsc::Sender<AuditCommand>,
948 drop_counter: Arc<std::sync::atomic::AtomicU64>,
949}
950
951impl CompactionAuditSink {
952 #[tracing::instrument(name = "context.typed_page.open", skip_all)]
965 pub async fn open(
966 path: &std::path::Path,
967 capacity: usize,
968 supervisor: Option<&TaskSupervisor>,
969 ) -> Result<Self, std::io::Error> {
970 use tokio::io::AsyncWriteExt as _;
971
972 if let Some(parent) = path.parent() {
973 tokio::fs::create_dir_all(parent).await?;
974 }
975 let file = tokio::fs::OpenOptions::new()
976 .create(true)
977 .append(true)
978 .open(path)
979 .await?;
980
981 let (tx, mut rx) = tokio::sync::mpsc::channel::<AuditCommand>(capacity.max(1));
982 let drop_counter = Arc::new(std::sync::atomic::AtomicU64::new(0));
983 let drop_counter_bg = drop_counter.clone();
984
985 let fut = async move {
986 let mut writer = tokio::io::BufWriter::new(file);
987 while let Some(cmd) = rx.recv().await {
988 match cmd {
989 AuditCommand::Record(record) => match serde_json::to_string(&record) {
990 Ok(mut line) => {
991 line.push('\n');
992 if let Err(e) = writer.write_all(line.as_bytes()).await {
993 tracing::error!("compaction audit write failed: {e:#}");
994 }
995 }
996 Err(e) => {
997 tracing::error!("compaction audit serialization failed: {e:#}");
998 }
999 },
1000 AuditCommand::Flush(responder) => {
1001 let _ = writer.flush().await;
1002 let _ = responder.send(());
1003 }
1004 }
1005 }
1006 let _ = writer.flush().await;
1008
1009 let dropped = drop_counter_bg.load(std::sync::atomic::Ordering::Relaxed);
1010 if dropped > 0 {
1011 tracing::warn!(dropped, "compaction audit sink closed with dropped records");
1012 }
1013 };
1014
1015 if let Some(sup) = supervisor {
1016 drop(sup.spawn_oneshot(Arc::from("context.audit_sink"), move || fut));
1017 } else {
1018 tokio::spawn(fut); }
1020
1021 Ok(Self { tx, drop_counter })
1022 }
1023
1024 pub fn send(&self, record: CompactedPageRecord) {
1028 match self.tx.try_send(AuditCommand::Record(record)) {
1029 Ok(()) => {}
1030 Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => {
1031 let prev = self
1032 .drop_counter
1033 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1034 tracing::warn!(
1035 dropped_total = prev + 1,
1036 "compaction audit sink full — record dropped (best-effort audit)"
1037 );
1038 }
1039 Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => {
1040 tracing::error!("compaction audit sink closed unexpectedly");
1041 }
1042 }
1043 }
1044
1045 #[tracing::instrument(name = "context.typed_page.flush", skip_all)]
1051 pub async fn flush(&self) {
1052 let (tx, rx) = tokio::sync::oneshot::channel::<()>();
1053 if self.tx.send(AuditCommand::Flush(tx)).await.is_ok() {
1054 let _ = tokio::time::timeout(Duration::from_millis(100), rx).await;
1055 }
1056 }
1057
1058 #[must_use]
1060 pub fn dropped_count(&self) -> u64 {
1061 self.drop_counter.load(std::sync::atomic::Ordering::Relaxed)
1062 }
1063}
1064
1065#[cfg(test)]
1068mod tests {
1069 use super::*;
1070
1071 #[test]
1074 fn page_id_same_input_same_output() {
1075 let a = PageId::compute(PageType::ToolOutput, "tool:shell", b"exit_code: 0");
1076 let b = PageId::compute(PageType::ToolOutput, "tool:shell", b"exit_code: 0");
1077 assert_eq!(a, b);
1078 }
1079
1080 #[test]
1081 fn page_id_different_type_different_id() {
1082 let a = PageId::compute(PageType::ToolOutput, "tool:shell", b"body");
1083 let b = PageId::compute(PageType::ConversationTurn, "tool:shell", b"body");
1084 assert_ne!(a, b);
1085 }
1086
1087 #[test]
1088 fn page_id_starts_with_blake3_prefix() {
1089 let id = PageId::compute(PageType::SystemContext, "system:persona", b"some content");
1090 assert!(id.0.starts_with("blake3:"));
1091 }
1092
1093 #[test]
1096 fn classify_tool_output_prefix() {
1097 assert_eq!(
1098 classify("[tool_output] shell exit_code: 0"),
1099 PageType::ToolOutput
1100 );
1101 assert_eq!(classify("[tool:shell] result"), PageType::ToolOutput);
1102 }
1103
1104 #[test]
1105 fn classify_memory_prefixes() {
1106 assert_eq!(
1107 classify("[cross-session context]\nsome recall"),
1108 PageType::MemoryExcerpt
1109 );
1110 assert_eq!(
1111 classify("[semantic recall]\n- [user] hello"),
1112 PageType::MemoryExcerpt
1113 );
1114 assert_eq!(classify("[known facts]\n- fact"), PageType::MemoryExcerpt);
1115 assert_eq!(
1116 classify("[conversation summaries]\n- 1-10: summary"),
1117 PageType::MemoryExcerpt
1118 );
1119 }
1120
1121 #[test]
1122 fn classify_system_prefixes() {
1123 assert_eq!(classify("[Persona context]\nfact"), PageType::SystemContext);
1124 assert_eq!(classify("[system prompt]"), PageType::SystemContext);
1125 }
1126
1127 #[test]
1128 fn classify_fallback_is_conversation_turn() {
1129 assert_eq!(classify("Hello, world!"), PageType::ConversationTurn);
1130 assert_eq!(classify(""), PageType::ConversationTurn);
1131 }
1132
1133 #[test]
1136 fn detect_schema_hint_json() {
1137 assert_eq!(
1138 detect_schema_hint(r#"{"key": "val"}"#, false),
1139 SchemaHint::Json
1140 );
1141 assert_eq!(detect_schema_hint("[1,2,3]", false), SchemaHint::Json);
1142 }
1143
1144 #[test]
1145 fn detect_schema_hint_diff() {
1146 assert_eq!(detect_schema_hint("--- a\n+++ b", false), SchemaHint::Diff);
1147 }
1148
1149 #[test]
1150 fn detect_schema_hint_binary() {
1151 assert_eq!(detect_schema_hint("anything", true), SchemaHint::Binary);
1152 }
1153
1154 #[test]
1155 fn detect_schema_hint_text_fallback() {
1156 assert_eq!(detect_schema_hint("plain text", false), SchemaHint::Text);
1157 }
1158
1159 #[test]
1162 fn tool_output_invariant_passes_when_fields_present() {
1163 let inv = ToolOutputInvariant;
1164 let page = TypedPage::new(
1165 PageType::ToolOutput,
1166 PageOrigin::ToolPair {
1167 tool_name: "shell".into(),
1168 },
1169 100,
1170 Arc::from("[tool_output] shell exit_code: 0\nsome output"),
1171 Some(SchemaHint::Text),
1172 );
1173 let compacted = CompactedPage {
1174 body: Arc::from("shell exit_status: 0\nkey: value"),
1175 tokens: 10,
1176 };
1177 assert!(inv.verify(&page, &compacted).is_ok());
1178 }
1179
1180 #[test]
1181 fn tool_output_invariant_fails_missing_tool_name() {
1182 let inv = ToolOutputInvariant;
1183 let page = TypedPage::new(
1184 PageType::ToolOutput,
1185 PageOrigin::ToolPair {
1186 tool_name: "my_tool".into(),
1187 },
1188 100,
1189 Arc::from("[tool_output] my_tool exit_code: 0"),
1190 Some(SchemaHint::Text),
1191 );
1192 let compacted = CompactedPage {
1193 body: Arc::from("exit_status: 0"),
1194 tokens: 5,
1195 };
1196 let result = inv.verify(&page, &compacted);
1197 assert!(result.is_err());
1198 let violations = result.unwrap_err();
1199 assert!(violations.iter().any(|v| v.missing_field == "tool_name"));
1200 }
1201
1202 #[test]
1203 fn tool_output_invariant_passes_for_binary() {
1204 let inv = ToolOutputInvariant;
1205 let page = TypedPage::new(
1206 PageType::ToolOutput,
1207 PageOrigin::ToolPair {
1208 tool_name: "binary_tool".into(),
1209 },
1210 100,
1211 Arc::from("<binary:1024 bytes>"),
1212 Some(SchemaHint::Binary),
1213 );
1214 let compacted = CompactedPage {
1215 body: Arc::from("<binary:1024 bytes> (archived)"),
1216 tokens: 5,
1217 };
1218 assert!(inv.verify(&page, &compacted).is_ok());
1219 }
1220
1221 #[test]
1224 fn system_context_invariant_passes_with_pointer() {
1225 let inv = SystemContextInvariant;
1226 let page = TypedPage::new(
1227 PageType::SystemContext,
1228 PageOrigin::System {
1229 key: "persona".into(),
1230 },
1231 200,
1232 Arc::from("[Persona context]\nsome persona info"),
1233 None,
1234 );
1235 let compacted = CompactedPage {
1236 body: Arc::from("[system-ptr:blake3:abcdef123456]"),
1237 tokens: 3,
1238 };
1239 assert!(inv.verify(&page, &compacted).is_ok());
1240 }
1241
1242 #[test]
1243 fn system_context_invariant_fails_without_pointer() {
1244 let inv = SystemContextInvariant;
1245 let page = TypedPage::new(
1246 PageType::SystemContext,
1247 PageOrigin::System {
1248 key: "persona".into(),
1249 },
1250 200,
1251 Arc::from("[Persona context]\nsome persona info"),
1252 None,
1253 );
1254 let compacted = CompactedPage {
1255 body: Arc::from("This is a paraphrase of persona info"),
1256 tokens: 10,
1257 };
1258 let result = inv.verify(&page, &compacted);
1259 assert!(result.is_err());
1260 let violations = result.unwrap_err();
1261 assert!(violations.iter().any(|v| v.missing_field == "pointer"));
1262 }
1263
1264 #[test]
1267 fn registry_covers_all_page_types() {
1268 let reg = InvariantRegistry::default();
1269 for pt in [
1270 PageType::ToolOutput,
1271 PageType::ConversationTurn,
1272 PageType::MemoryExcerpt,
1273 PageType::SystemContext,
1274 ] {
1275 assert!(reg.get(pt).is_some(), "missing invariant for {pt:?}");
1276 }
1277 }
1278
1279 #[test]
1280 fn registry_returns_correct_page_type() {
1281 let reg = InvariantRegistry::default();
1282 assert_eq!(
1283 reg.get(PageType::ToolOutput).unwrap().page_type(),
1284 PageType::ToolOutput
1285 );
1286 assert_eq!(
1287 reg.get(PageType::SystemContext).unwrap().page_type(),
1288 PageType::SystemContext
1289 );
1290 }
1291
1292 #[test]
1295 fn enforce_ok_for_valid_system_pointer() {
1296 let reg = InvariantRegistry::default();
1297 let page = TypedPage::new(
1298 PageType::SystemContext,
1299 PageOrigin::System {
1300 key: "persona".into(),
1301 },
1302 50,
1303 Arc::from("[Persona context]\nrules"),
1304 None,
1305 );
1306 let compacted = CompactedPage {
1307 body: Arc::from("[system-ptr:blake3:aabbccdd11223344]"),
1308 tokens: 3,
1309 };
1310 assert!(reg.enforce(&page, &compacted).is_ok());
1311 }
1312
1313 #[test]
1314 fn enforce_err_for_paraphrased_system_context() {
1315 let reg = InvariantRegistry::default();
1316 let page = TypedPage::new(
1317 PageType::SystemContext,
1318 PageOrigin::System {
1319 key: "persona".into(),
1320 },
1321 50,
1322 Arc::from("[Persona context]\nrules"),
1323 None,
1324 );
1325 let compacted = CompactedPage {
1326 body: Arc::from("The persona says to be helpful."),
1327 tokens: 7,
1328 };
1329 let result = reg.enforce(&page, &compacted);
1330 assert!(result.is_err());
1331 assert!(
1332 result
1333 .unwrap_err()
1334 .iter()
1335 .any(|v| v.missing_field == "pointer")
1336 );
1337 }
1338
1339 #[test]
1340 fn enforce_ok_for_conversation_turn_with_role() {
1341 let reg = InvariantRegistry::default();
1342 let page = TypedPage::new(
1343 PageType::ConversationTurn,
1344 PageOrigin::Turn {
1345 message_id: "42".into(),
1346 },
1347 30,
1348 Arc::from("Hello from user"),
1349 None,
1350 );
1351 let compacted = CompactedPage {
1352 body: Arc::from("user asked about Rust"),
1353 tokens: 5,
1354 };
1355 assert!(reg.enforce(&page, &compacted).is_ok());
1356 }
1357
1358 #[test]
1361 fn memory_excerpt_invariant_passes_when_label_present() {
1362 let inv = MemoryExcerptInvariant;
1363 let label = "semantic_recall";
1364 let page = TypedPage::new(
1365 PageType::MemoryExcerpt,
1366 PageOrigin::Excerpt {
1367 source_label: label.into(),
1368 },
1369 80,
1370 Arc::from("[semantic recall]\n- [user] hello"),
1371 None,
1372 );
1373 let compacted = CompactedPage {
1374 body: Arc::from(format!("Summary from {label}: user greeted")),
1375 tokens: 6,
1376 };
1377 assert!(inv.verify(&page, &compacted).is_ok());
1378 }
1379
1380 #[test]
1381 fn memory_excerpt_invariant_fails_when_label_missing() {
1382 let inv = MemoryExcerptInvariant;
1383 let page = TypedPage::new(
1384 PageType::MemoryExcerpt,
1385 PageOrigin::Excerpt {
1386 source_label: "graph_facts".into(),
1387 },
1388 80,
1389 Arc::from("[known facts]\n- Alice works at Acme"),
1390 None,
1391 );
1392 let compacted = CompactedPage {
1393 body: Arc::from("Alice is employed somewhere"),
1394 tokens: 5,
1395 };
1396 let result = inv.verify(&page, &compacted);
1397 assert!(result.is_err());
1398 assert!(
1399 result
1400 .unwrap_err()
1401 .iter()
1402 .any(|v| v.missing_field == "source_label")
1403 );
1404 }
1405
1406 #[test]
1407 fn memory_excerpt_invariant_passes_for_non_excerpt_origin() {
1408 let inv = MemoryExcerptInvariant;
1409 let page = TypedPage::new(
1410 PageType::MemoryExcerpt,
1411 PageOrigin::System {
1412 key: "digests".into(),
1413 },
1414 40,
1415 Arc::from("[system]"),
1416 None,
1417 );
1418 let compacted = CompactedPage {
1419 body: Arc::from("anything"),
1420 tokens: 1,
1421 };
1422 assert!(inv.verify(&page, &compacted).is_ok());
1423 }
1424
1425 #[test]
1428 fn conversation_turn_invariant_passes_with_role_word() {
1429 let inv = ConversationTurnInvariant;
1430 let page = TypedPage::new(
1431 PageType::ConversationTurn,
1432 PageOrigin::Turn {
1433 message_id: "1".into(),
1434 },
1435 20,
1436 Arc::from("Hello world"),
1437 None,
1438 );
1439 for body in &["user: hi", "assistant replied", "system note"] {
1440 let compacted = CompactedPage {
1441 body: Arc::from(*body),
1442 tokens: 2,
1443 };
1444 assert!(inv.verify(&page, &compacted).is_ok(), "body={body}");
1445 }
1446 }
1447
1448 #[test]
1449 fn conversation_turn_invariant_fails_without_role_word() {
1450 let inv = ConversationTurnInvariant;
1451 let page = TypedPage::new(
1452 PageType::ConversationTurn,
1453 PageOrigin::Turn {
1454 message_id: "2".into(),
1455 },
1456 20,
1457 Arc::from("some turn content"),
1458 None,
1459 );
1460 let compacted = CompactedPage {
1461 body: Arc::from("content was summarized"),
1462 tokens: 3,
1463 };
1464 let result = inv.verify(&page, &compacted);
1465 assert!(result.is_err());
1466 assert!(
1467 result
1468 .unwrap_err()
1469 .iter()
1470 .any(|v| v.missing_field == "role")
1471 );
1472 }
1473
1474 #[tokio::test]
1477 async fn audit_sink_jsonl_roundtrip() {
1478 let dir = tempfile::tempdir().unwrap();
1479 let path = dir.path().join("audit.jsonl");
1480
1481 let sink = CompactionAuditSink::open(&path, 64, None).await.unwrap();
1482 let record = CompactedPageRecord {
1483 ts: "2026-04-19T00:00:00Z".into(),
1484 turn_id: "1".into(),
1485 page_id: "blake3:aabbccdd".into(),
1486 page_type: PageType::ToolOutput,
1487 origin: PageOrigin::ToolPair {
1488 tool_name: "shell".into(),
1489 },
1490 original_tokens: 100,
1491 compacted_tokens: 20,
1492 fidelity_level: "structured_summary_v1".into(),
1493 invariant_version: 1,
1494 provider_name: "test".into(),
1495 violations: vec![],
1496 classification_fallback: false,
1497 };
1498 sink.send(record);
1499
1500 drop(sink);
1502 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1504
1505 let contents = std::fs::read_to_string(&path).unwrap();
1506 assert!(!contents.is_empty(), "audit file should not be empty");
1507 let parsed: serde_json::Value = serde_json::from_str(contents.trim()).unwrap();
1508 assert_eq!(parsed["page_type"], "tool_output");
1509 assert_eq!(parsed["turn_id"], "1");
1510 assert_eq!(parsed["provider_name"], "test");
1511 }
1512
1513 #[tokio::test]
1514 async fn audit_sink_drop_counter_increments_when_full() {
1515 let dir = tempfile::tempdir().unwrap();
1516 let path = dir.path().join("audit_full.jsonl");
1517
1518 let sink = CompactionAuditSink::open(&path, 1, None).await.unwrap();
1520
1521 let make_record = || CompactedPageRecord {
1522 ts: "2026-04-19T00:00:00Z".into(),
1523 turn_id: "x".into(),
1524 page_id: "blake3:00".into(),
1525 page_type: PageType::ConversationTurn,
1526 origin: PageOrigin::Turn {
1527 message_id: "0".into(),
1528 },
1529 original_tokens: 10,
1530 compacted_tokens: 5,
1531 fidelity_level: "semantic_summary_v1".into(),
1532 invariant_version: 1,
1533 provider_name: "test".into(),
1534 violations: vec![],
1535 classification_fallback: false,
1536 };
1537
1538 for _ in 0..10 {
1540 sink.send(make_record());
1541 }
1542
1543 assert!(
1544 sink.dropped_count() > 0,
1545 "expected at least one dropped record"
1546 );
1547 }
1548
1549 #[tokio::test]
1550 async fn audit_sink_flush_does_not_panic() {
1551 let dir = tempfile::tempdir().unwrap();
1552 let path = dir.path().join("audit_flush.jsonl");
1553 let sink = CompactionAuditSink::open(&path, 16, None).await.unwrap();
1554 sink.flush().await;
1556 }
1557
1558 #[test]
1561 fn classify_with_role_system_flag_overrides_fallback() {
1562 assert_eq!(
1563 classify_with_role("You are a helpful assistant.", true),
1564 PageType::SystemContext
1565 );
1566 }
1567
1568 #[test]
1569 fn classify_with_role_prefix_wins_over_system_flag() {
1570 assert_eq!(
1571 classify_with_role("[tool_output] exit_code: 0", false),
1572 PageType::ToolOutput
1573 );
1574 }
1575
1576 #[test]
1577 fn classify_with_role_false_still_falls_back_to_conversation_turn() {
1578 assert_eq!(
1579 classify_with_role("random prose without markers", false),
1580 PageType::ConversationTurn
1581 );
1582 }
1583
1584 #[test]
1587 fn tool_output_json_structural_check_passes_when_key_preserved() {
1588 let inv = ToolOutputInvariant;
1589 let original_body = r#"{"exit_code": 0, "stdout": "ok"}"#;
1590 let page = TypedPage::new(
1591 PageType::ToolOutput,
1592 PageOrigin::ToolPair {
1593 tool_name: "shell".into(),
1594 },
1595 50,
1596 Arc::from(original_body),
1597 Some(SchemaHint::Json),
1598 );
1599 let compacted = CompactedPage {
1601 body: Arc::from("shell exit_code: 0, stdout was ok"),
1602 tokens: 8,
1603 };
1604 assert!(inv.verify(&page, &compacted).is_ok());
1605 }
1606
1607 #[test]
1608 fn tool_output_json_structural_check_fails_when_no_key_preserved() {
1609 let inv = ToolOutputInvariant;
1610 let original_body = r#"{"some_field": "value", "other_field": 42}"#;
1611 let page = TypedPage::new(
1612 PageType::ToolOutput,
1613 PageOrigin::ToolPair {
1614 tool_name: "my_tool".into(),
1615 },
1616 50,
1617 Arc::from(original_body),
1618 Some(SchemaHint::Json),
1619 );
1620 let compacted = CompactedPage {
1622 body: Arc::from("my_tool exit_status: 0 completed successfully"),
1623 tokens: 7,
1624 };
1625 let result = inv.verify(&page, &compacted);
1626 assert!(result.is_err());
1627 let violations = result.unwrap_err();
1628 assert!(
1629 violations
1630 .iter()
1631 .any(|v| v.missing_field == "structural_key")
1632 );
1633 }
1634
1635 #[test]
1636 fn tool_output_json_structural_check_fails_on_short_key_substring_match() {
1637 let inv = ToolOutputInvariant;
1638 let original_body = r#"{"id": 123, "ok": true}"#;
1639 let page = TypedPage::new(
1640 PageType::ToolOutput,
1641 PageOrigin::ToolPair {
1642 tool_name: "my_tool".into(),
1643 },
1644 50,
1645 Arc::from(original_body),
1646 Some(SchemaHint::Json),
1647 );
1648 let compacted = CompactedPage {
1651 body: Arc::from("my_tool exit_status: 0, the idea was okay"),
1652 tokens: 9,
1653 };
1654 let result = inv.verify(&page, &compacted);
1655 assert!(result.is_err());
1656 let violations = result.unwrap_err();
1657 assert!(
1658 violations
1659 .iter()
1660 .any(|v| v.missing_field == "structural_key")
1661 );
1662 }
1663
1664 #[test]
1665 fn tool_output_json_structural_check_passes_on_short_key_whole_word_match() {
1666 let inv = ToolOutputInvariant;
1667 let original_body = r#"{"id": 123, "ok": true}"#;
1668 let page = TypedPage::new(
1669 PageType::ToolOutput,
1670 PageOrigin::ToolPair {
1671 tool_name: "my_tool".into(),
1672 },
1673 50,
1674 Arc::from(original_body),
1675 Some(SchemaHint::Json),
1676 );
1677 let compacted = CompactedPage {
1679 body: Arc::from("my_tool exit_status: 0, id 123 confirmed"),
1680 tokens: 8,
1681 };
1682 assert!(inv.verify(&page, &compacted).is_ok());
1683 }
1684
1685 #[test]
1686 fn tool_output_json_structural_check_utf8_boundary_does_not_panic() {
1687 let inv = ToolOutputInvariant;
1688 let original_body = r#"{"id": 123}"#;
1689 let page = TypedPage::new(
1690 PageType::ToolOutput,
1691 PageOrigin::ToolPair {
1692 tool_name: "my_tool".into(),
1693 },
1694 50,
1695 Arc::from(original_body),
1696 Some(SchemaHint::Json),
1697 );
1698 let compacted = CompactedPage {
1702 body: Arc::from("my_tool exit_status: 0, ключid тест"),
1703 tokens: 6,
1704 };
1705 let _ = inv.verify(&page, &compacted);
1707 }
1708
1709 #[test]
1710 fn tool_output_json_structural_check_passes_for_empty_key() {
1711 let inv = ToolOutputInvariant;
1712 let original_body = r#"{"": 1}"#;
1713 let page = TypedPage::new(
1714 PageType::ToolOutput,
1715 PageOrigin::ToolPair {
1716 tool_name: "my_tool".into(),
1717 },
1718 50,
1719 Arc::from(original_body),
1720 Some(SchemaHint::Json),
1721 );
1722 let compacted = CompactedPage {
1725 body: Arc::from("my_tool exit_status: 0, arbitrary unrelated text"),
1726 tokens: 6,
1727 };
1728 assert!(inv.verify(&page, &compacted).is_ok());
1729 }
1730
1731 #[tokio::test]
1734 async fn audit_sink_capacity_zero_does_not_panic() {
1735 let dir = tempfile::tempdir().unwrap();
1736 let path = dir.path().join("cap0.jsonl");
1737 let sink = CompactionAuditSink::open(&path, 0, None).await.unwrap();
1739 sink.flush().await;
1740 }
1741
1742 #[tokio::test]
1743 async fn audit_sink_open_with_supervisor_registers_task() {
1744 use tokio_util::sync::CancellationToken;
1745 use zeph_common::TaskSupervisor;
1746
1747 let dir = tempfile::tempdir().unwrap();
1748 let path = dir.path().join("audit_sup.jsonl");
1749 let cancel = CancellationToken::new();
1750 let supervisor = TaskSupervisor::new(cancel.clone());
1751
1752 let sink = CompactionAuditSink::open(&path, 64, Some(&supervisor))
1753 .await
1754 .unwrap();
1755 sink.flush().await;
1756
1757 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1759
1760 let names: Vec<String> = supervisor
1761 .snapshot()
1762 .into_iter()
1763 .map(|s| s.name.to_string())
1764 .collect();
1765 assert!(
1766 names.iter().any(|n| n == "context.audit_sink"),
1767 "supervisor must have a task named 'context.audit_sink', got: {names:?}"
1768 );
1769
1770 cancel.cancel();
1771 }
1772
1773 #[test]
1776 fn classify_with_role_non_ascii_body_does_not_panic() {
1777 let cjk = "你好世界".repeat(20); let emoji = "🦀".repeat(30); let mixed = "abc🦀中文".repeat(15);
1782
1783 let _ = classify_with_role(&cjk, false);
1785 let _ = classify_with_role(&emoji, false);
1786 let _ = classify_with_role(&mixed, false);
1787 }
1788}