Skip to main content

zeph_context/
typed_page.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Typed page classification and minimum-fidelity invariants for context compaction.
5//!
6//! Every context segment entering the assembler is tagged with a [`PageType`] and
7//! wrapped in a [`TypedPage`]. The [`PageInvariant`] trait declares the fidelity
8//! contract enforced at every compaction boundary.
9//!
10//! Classification is deterministic and side-effect free — no I/O, no LLM calls.
11//!
12//! # Architecture
13//!
14//! This module lives in `zeph-context` to keep classification logic co-located with
15//! the assembler. No dependency on `zeph-memory` is introduced here.
16//!
17//! # Feature flag
18//!
19//! All typed-page functionality is gated behind the
20//! `[memory.compaction.typed_pages] enabled = true` config key. When disabled the
21//! assembler falls back to the legacy untyped path without behaviour change.
22
23use 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// ── PageType ──────────────────────────────────────────────────────────────────
31
32/// Classification of a context segment for compaction purposes.
33///
34/// The variant determines which [`PageInvariant`] is enforced and what shape the
35/// compacted summary must have.
36///
37/// # Invariant
38///
39/// Every [`TypedPage`] carries exactly one `PageType`. Unclassifiable segments
40/// default to [`PageType::ConversationTurn`] (see [`classify`]).
41#[non_exhaustive]
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
43#[serde(rename_all = "snake_case")]
44pub enum PageType {
45    /// A tool request/response pair sourced from memory or the current turn.
46    ToolOutput,
47    /// A user or assistant message that does not carry a tool role.
48    ConversationTurn,
49    /// Cross-session context, past summaries, or graph-fact recall injections.
50    MemoryExcerpt,
51    /// Session digest, persona, skill instructions, or compression guidelines.
52    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// ── PageOrigin ────────────────────────────────────────────────────────────────
67
68/// Provenance of a [`TypedPage`], serialised into audit records.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70#[serde(tag = "kind", rename_all = "snake_case")]
71#[non_exhaustive]
72pub enum PageOrigin {
73    /// Tool request/response pair.
74    ToolPair {
75        /// Name of the tool that produced this output.
76        tool_name: String,
77    },
78    /// User or assistant conversation turn.
79    Turn {
80        /// Opaque message identifier (numeric message id as string).
81        message_id: String,
82    },
83    /// Injected from memory (cross-session, summary, graph-facts, etc.).
84    Excerpt {
85        /// Human-readable label identifying the memory source.
86        source_label: String,
87    },
88    /// Session-level system context (persona, skills, digest).
89    System {
90        /// Logical key for this system context block (e.g. `"persona"`, `"skills"`).
91        key: String,
92    },
93}
94
95// ── SchemaHint ────────────────────────────────────────────────────────────────
96
97/// Body format hint for [`PageType::ToolOutput`] pages.
98///
99/// Used by the invariant to select the correct structured-summary prompt.
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(rename_all = "snake_case")]
102#[non_exhaustive]
103pub enum SchemaHint {
104    /// Body is valid JSON (object or array).
105    Json,
106    /// Body is UTF-8 text (log lines, prose, etc.).
107    Text,
108    /// Body is a unified diff.
109    Diff,
110    /// Body is a tab- or comma-separated table.
111    Table,
112    /// Body is non-UTF-8 binary data.
113    Binary,
114}
115
116// ── PageId ────────────────────────────────────────────────────────────────────
117
118/// Stable content-addressed identifier for a [`TypedPage`].
119///
120/// Computed as BLAKE3 over `page_type_tag || origin_tag || body_bytes`, encoded
121/// as lowercase hex (first 16 bytes = 32 hex chars). The same input always
122/// produces the same `PageId`, enabling deduplication across turns.
123///
124/// # Semantics
125///
126/// `PageId` is a **content hash**: identical source bytes (same page type, same
127/// origin key, same body) always produce the same id. This means that the same
128/// tool output appearing in two different turns produces the same `PageId`.
129/// Callers that need per-turn provenance must use `turn_id` from the audit record
130/// — `PageId` is for deduplication, not for uniqueness across turns.
131#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
132pub struct PageId(pub String);
133
134impl PageId {
135    /// Compute a [`PageId`] from the page type, origin key, and body bytes.
136    #[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        // Use first 16 bytes (128 bits) — sufficient for deduplication purposes.
146        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// ── TypedPage ─────────────────────────────────────────────────────────────────
156
157/// A classified context segment ready for invariant-aware compaction.
158///
159/// `TypedPage` is the unit of work passed to compaction boundaries. The
160/// [`PageId`] is content-stable: the same source bytes always produce the same
161/// id, enabling the compactor to skip already-compacted pages.
162#[derive(Debug, Clone)]
163pub struct TypedPage {
164    /// Stable content-addressed identifier.
165    pub page_id: PageId,
166    /// Classification determining which invariant applies.
167    pub page_type: PageType,
168    /// Provenance of this page (for audit records).
169    pub origin: PageOrigin,
170    /// Token count of the original body.
171    pub tokens: u32,
172    /// Body text shared across potential clones.
173    pub body: Arc<str>,
174    /// Body format hint (populated for `ToolOutput` only; `None` otherwise).
175    pub schema_hint: Option<SchemaHint>,
176}
177
178impl TypedPage {
179    /// Construct a new [`TypedPage`], computing its [`PageId`] from content.
180    #[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// ── FidelityContract ──────────────────────────────────────────────────────────
211
212/// The set of fields that must be present in a compacted page.
213///
214/// Returned by [`PageInvariant::minimum_fidelity`] and checked by
215/// [`PageInvariant::verify`] after summarization.
216#[derive(Debug, Clone)]
217pub struct FidelityContract {
218    /// Human-readable label for this contract version (e.g. `"structured_summary_v1"`).
219    pub fidelity_level: &'static str,
220    /// Schema version integer included in audit records.
221    pub invariant_version: u8,
222    /// Fields that must appear in the compacted body text.
223    pub required_fields: &'static [&'static str],
224}
225
226// ── FidelityViolation ─────────────────────────────────────────────────────────
227
228/// Describes why an invariant check failed after compaction.
229///
230/// A violation is a hard error: the compacted page is dropped and an audit
231/// record with `violations` is emitted.
232#[derive(Debug, Clone, Serialize)]
233pub struct FidelityViolation {
234    /// The field or property that was expected but missing.
235    pub missing_field: String,
236    /// Human-readable explanation of the violation.
237    pub detail: String,
238}
239
240// ── CompactedPage ─────────────────────────────────────────────────────────────
241
242/// The output of a compaction attempt, passed to [`PageInvariant::verify`].
243#[derive(Debug, Clone)]
244pub struct CompactedPage {
245    /// The summarized body text produced by the compaction provider.
246    pub body: Arc<str>,
247    /// Token count of the compacted body.
248    pub tokens: u32,
249}
250
251// ── PageInvariant trait ───────────────────────────────────────────────────────
252
253/// Minimum-fidelity contract for a single [`PageType`].
254///
255/// Implementors declare what a compacted page must contain ([`minimum_fidelity`])
256/// and verify that the actual output honours the contract ([`verify`]).
257///
258/// The trait is object-safe so implementations can be stored in a
259/// `HashMap<PageType, Box<dyn PageInvariant>>` registry.
260///
261/// # Contract
262///
263/// - [`verify`] MUST NOT perform I/O or call an LLM.
264/// - A failed [`verify`] means the compacted page is dropped — it is NEVER
265///   injected in degraded form.
266///
267/// [`minimum_fidelity`]: PageInvariant::minimum_fidelity
268/// [`verify`]: PageInvariant::verify
269pub trait PageInvariant: Send + Sync {
270    /// The page type this invariant governs.
271    fn page_type(&self) -> PageType;
272
273    /// Return the fidelity contract required for a given page.
274    fn minimum_fidelity(&self, page: &TypedPage) -> FidelityContract;
275
276    /// Verify that `compacted` satisfies the fidelity contract derived from `original`.
277    ///
278    /// # Errors
279    ///
280    /// Returns a non-empty [`Vec<FidelityViolation>`] when one or more required
281    /// fields are absent from the compacted body.
282    fn verify(
283        &self,
284        original: &TypedPage,
285        compacted: &CompactedPage,
286    ) -> Result<(), Vec<FidelityViolation>>;
287}
288
289// ── Per-type invariant implementations ───────────────────────────────────────
290
291/// Invariant for [`PageType::ToolOutput`] pages.
292///
293/// The compacted body must contain the tool name, an exit/status indicator,
294/// and at least one structural key from the original output.
295pub 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        // For binary pages the body marker is injected by the compactor, skip field checks.
317        if original.schema_hint == Some(SchemaHint::Binary) {
318            return Ok(());
319        }
320
321        let mut violations = Vec::new();
322
323        // The compacted body must reference the tool name.
324        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        // The compacted body must contain at least one exit / status indicator.
336        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        // For JSON-schema tool outputs, verify that at least one top-level JSON
350        // field name from the original body is present in the compacted body
351        // (FR-003: structural keys must be preserved, not just exit-status markers).
352        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
373/// Check that at least one top-level JSON key from `original` appears in `compacted`.
374///
375/// Parses `original` as a JSON object and returns `true` when any top-level key
376/// string appears in `compacted` as a JSON-quoted key or a whole word (see
377/// [`key_appears_as_word`]) — a raw substring test would let short, common keys
378/// like `"id"` or `"ok"` match inside unrelated prose (e.g. "the **id**ea was
379/// **ok**ay"), defeating the FR-003 fidelity gate. Returns `true` (no violation)
380/// when `original` is not a valid JSON object — the caller already checked schema
381/// hint.
382///
383/// Residual limitation: whole-word matching closes the subword-substring false
384/// positive class above, but not the dictionary-word class — a short common-English
385/// key (`"id"`, `"ok"`, `"data"`, `"status"`) that genuinely appears as a standalone
386/// word in unrelated prose (e.g. "the id was ok") still satisfies the check. This is
387/// a disclosed partial mitigation, not a full close of all accidental matches.
388fn 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
402/// Returns `true` when `key` occurs in `haystack` either JSON-quoted (`"key"`) or as
403/// a standalone word, i.e. not embedded inside a larger identifier or English word
404/// (`"id"` must not match inside `"idea"`).
405///
406/// An empty `key` carries no structural information to verify, so it always passes
407/// — preserving the prior permissive behavior for `{"": ...}` rather than turning a
408/// vacuous case into a spurious fidelity violation.
409fn 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(&quoted) {
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
425/// Invariant for [`PageType::ConversationTurn`] pages.
426///
427/// The compacted body must preserve a role indicator and some meaningful content.
428pub 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
461/// Invariant for [`PageType::MemoryExcerpt`] pages.
462///
463/// The compacted body must retain the source label and a message id reference.
464pub 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
498/// Invariant for [`PageType::SystemContext`] pages.
499///
500/// System context MUST NOT be paraphrased. Compaction replaces it with a
501/// pointer record; any body other than the pointer prefix is a violation.
502pub struct SystemContextInvariant;
503
504/// Pointer prefix that the compactor writes for `SystemContext` pages.
505pub 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
538// ── InvariantRegistry ─────────────────────────────────────────────────────────
539
540/// Registry mapping each [`PageType`] to its [`PageInvariant`] implementation.
541///
542/// Built once and shared via `Arc` so tests can swap in a mock registry.
543///
544/// # Examples
545///
546/// ```
547/// use zeph_context::typed_page::{InvariantRegistry, PageType};
548///
549/// let reg = InvariantRegistry::default();
550/// let inv = reg.get(PageType::ToolOutput).unwrap();
551/// assert_eq!(inv.page_type(), PageType::ToolOutput);
552/// ```
553pub 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    /// Look up the invariant for a given [`PageType`].
573    ///
574    /// Always returns `Some` for the four built-in variants.
575    #[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    /// Verify that `compacted` satisfies the invariant for `original` at a compaction boundary.
586    ///
587    /// This is the primary entry point for the compactor — it wraps `verify()` in a
588    /// `tracing::info_span!` per NFR-009 so every compaction boundary is observable.
589    ///
590    /// Returns `Ok(())` when the invariant is satisfied, or the violation list on failure.
591    ///
592    /// # Errors
593    ///
594    /// Propagates [`FidelityViolation`]s from the registered invariant implementation.
595    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// ── Classification helpers ────────────────────────────────────────────────────
622
623/// Classify a context segment by examining well-known prefix markers.
624///
625/// Classification is deterministic and performs no I/O. When the input does not
626/// match any known prefix the function defaults to [`PageType::ConversationTurn`]
627/// and logs at `WARN` level per FR-008.
628///
629/// The function emits a `context.compaction.typed_page.classify` span per NFR-009
630/// so every classification is observable in traces.
631///
632/// | Source marker | Assigned [`PageType`] |
633/// |---|---|
634/// | Starts with `[tool_output]` or `[tool:` | [`PageType::ToolOutput`] |
635/// | Starts with `[cross-session context]`, `[semantic recall]`, `[known facts]`, `[conversation summaries]` | [`PageType::MemoryExcerpt`] |
636/// | Starts with `[Persona context]`, `[Past experience]`, `[Memory summary]`, `[system` | [`PageType::SystemContext`] |
637/// | Everything else | [`PageType::ConversationTurn`] |
638///
639/// # Examples
640///
641/// ```
642/// use zeph_context::typed_page::{classify, PageType};
643///
644/// assert_eq!(classify("[tool_output] exit_code: 0"), PageType::ToolOutput);
645/// assert_eq!(classify("[cross-session context]\nsome recall"), PageType::MemoryExcerpt);
646/// assert_eq!(classify("[Persona context]\nfact"), PageType::SystemContext);
647/// assert_eq!(classify("Hello, world!"), PageType::ConversationTurn);
648/// ```
649#[must_use]
650pub fn classify(body: &str) -> PageType {
651    classify_with_role(body, false)
652}
653
654/// Classify a context segment, with an explicit `is_system_role` hint.
655///
656/// When `is_system_role` is `true` the segment is classified as
657/// [`PageType::SystemContext`] without prefix matching, preventing arbitrary
658/// system messages injected by the assembler from silently falling back to
659/// `ConversationTurn` (Key Invariant: "`SystemContext` pages are never paraphrased").
660///
661/// Use this variant when the caller has access to the message `Role`.
662///
663/// # Examples
664///
665/// ```
666/// use zeph_context::typed_page::{classify_with_role, PageType};
667///
668/// // A plain system message without a known prefix is still SystemContext.
669/// assert_eq!(classify_with_role("You are a helpful assistant.", true), PageType::SystemContext);
670/// // Role hint does not override ToolOutput prefix detection.
671/// assert_eq!(classify_with_role("[tool_output] exit_code: 0", false), PageType::ToolOutput);
672/// ```
673#[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    // Use the same prefix constants as the assembler for consistency.
684    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    // When the caller signals Role::System, classify as SystemContext even if
723    // the body does not start with a known prefix.  This prevents system
724    // context injected by the assembler (e.g. plain instructions, directives)
725    // from being eligible for paraphrase.
726    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/// Detect [`SchemaHint`] for a [`PageType::ToolOutput`] body.
738///
739/// Returns [`SchemaHint::Binary`] when the body is not valid UTF-8 (detected via
740/// presence of replacement characters) or when the caller passes `is_binary =
741/// true`. JSON detection is heuristic (starts with `{` or `[`).
742#[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    // Simple table heuristic: first line contains multiple tab or pipe separators.
759    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// ── Audit record ──────────────────────────────────────────────────────────────
767
768/// One JSONL audit record emitted per compacted page (FR-007).
769///
770/// Written to `[memory.compaction.typed_pages] audit_path` by the audit sink
771/// before the compacted context is handed to the LLM.
772#[derive(Debug, Serialize)]
773pub struct CompactedPageRecord {
774    /// ISO-8601 timestamp when the compaction occurred.
775    pub ts: String,
776    /// Opaque turn identifier (agent turn counter as string).
777    pub turn_id: String,
778    /// Stable content-addressed page identifier.
779    pub page_id: String,
780    /// Page classification.
781    pub page_type: PageType,
782    /// Serialised page origin.
783    pub origin: PageOrigin,
784    /// Token count of the original page.
785    pub original_tokens: u32,
786    /// Token count of the compacted page.
787    pub compacted_tokens: u32,
788    /// Fidelity level label from the invariant contract.
789    pub fidelity_level: String,
790    /// Schema version integer.
791    pub invariant_version: u8,
792    /// Provider name used for summarization.
793    pub provider_name: String,
794    /// Fidelity violations encountered (empty on success).
795    pub violations: Vec<FidelityViolation>,
796    /// `true` when classification fell back to `ConversationTurn`.
797    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
798    pub classification_fallback: bool,
799}
800
801// ── Batch assertions ──────────────────────────────────────────────────────────
802
803/// A failed batch-level compaction assertion.
804#[derive(Debug, Clone, Serialize)]
805pub struct BatchViolation {
806    /// Short label for the assertion that failed.
807    pub assertion: String,
808    /// Human-readable explanation.
809    pub detail: String,
810}
811
812/// Batch-level compaction assertions for typed-page enforcement.
813///
814/// Unlike per-page [`PageInvariant`] which checks one page against its compacted form,
815/// batch assertions verify aggregate properties of the entire summary against the set
816/// of classified pages that were sent to the LLM.
817///
818/// Violations are observational — they never block compaction. They are logged and
819/// emitted to the audit trail.
820///
821/// # Examples
822///
823/// ```
824/// use zeph_context::typed_page::BatchAssertions;
825///
826/// let assertions = BatchAssertions {
827///     tool_names_in_batch: vec!["shell".to_string()],
828///     has_memory_excerpt: false,
829///     excerpt_labels: vec![],
830/// };
831/// // Summary that mentions the tool — all assertions pass.
832/// let violations = assertions.check("shell ran and exited 0");
833/// assert!(violations.is_empty());
834/// ```
835#[derive(Debug, Clone, Default)]
836pub struct BatchAssertions {
837    /// Tool names collected from `ToolOutput` pages in the batch.
838    pub tool_names_in_batch: Vec<String>,
839    /// Whether any `MemoryExcerpt` pages were in the batch.
840    pub has_memory_excerpt: bool,
841    /// Source labels from `MemoryExcerpt` pages.
842    pub excerpt_labels: Vec<String>,
843}
844
845impl BatchAssertions {
846    /// Check the summary against batch-level assertions.
847    ///
848    /// Returns a list of assertion failures (empty = all pass). Failures are never fatal.
849    #[must_use]
850    pub fn check(&self, summary: &str) -> Vec<BatchViolation> {
851        let mut violations = Vec::new();
852
853        // At least one tool name from the batch must appear in the summary.
854        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 memory excerpts were present, at least one source label should appear.
872        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
892// ── TypedPagesState ───────────────────────────────────────────────────────────
893
894/// Shared runtime state for typed-page compaction, created once at agent startup.
895///
896/// Bundles the invariant registry and optional audit sink so they can be shared
897/// via `Arc` across compaction calls without per-call allocation.
898pub struct TypedPagesState {
899    /// Invariant registry shared across all compaction calls.
900    pub registry: InvariantRegistry,
901    /// Optional JSONL audit sink. `None` when audit is disabled.
902    pub audit_sink: Option<CompactionAuditSink>,
903    /// Whether enforcement is `Active` (pointer-replace `SystemContext` + batch assertions).
904    /// `false` = `Observe` mode (classify and audit only, no behavioral change).
905    pub is_active: bool,
906}
907
908// ── Audit command ─────────────────────────────────────────────────────────────
909
910/// Internal command sent through the audit sink channel.
911enum AuditCommand {
912    /// Write a compaction record.
913    Record(CompactedPageRecord),
914    /// Flush all preceding records and signal completion via the oneshot.
915    Flush(tokio::sync::oneshot::Sender<()>),
916}
917
918// ── Audit sink ────────────────────────────────────────────────────────────────
919
920/// Async bounded-mpsc audit sink for compaction records.
921///
922/// The sink serialises [`CompactedPageRecord`] values to a JSONL file via a
923/// background writer task, mirroring the `zeph-tools` audit pattern. Dropped
924/// records (when the channel is full) are counted and logged.
925///
926/// # Invariant
927///
928/// [`CompactionAuditSink::flush`] sends a rendezvous sentinel through the channel
929/// and awaits the writer task's confirmation with a 100 ms timeout. Records accepted
930/// into the channel before `flush` is called are guaranteed to be written before the
931/// flush responder fires.
932///
933/// # Examples
934///
935/// ```no_run
936/// use zeph_context::typed_page::CompactionAuditSink;
937/// use std::path::Path;
938///
939/// # async fn example() {
940/// let sink = CompactionAuditSink::open(Path::new(".local/audit/compaction.jsonl"), 256, None)
941///     .await
942///     .unwrap();
943/// # }
944/// ```
945#[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    /// Open a new audit sink writing to `path`.
953    ///
954    /// `capacity` is the bounded channel depth; records dropped when full are counted
955    /// in the internal drop counter and logged at WARN.
956    ///
957    /// When `supervisor` is `Some`, the background writer task is registered as
958    /// `"context.audit_sink"` for lifecycle management. When `None`, the task is
959    /// spawned directly (test environments only).
960    ///
961    /// # Errors
962    ///
963    /// Returns an error when `path` cannot be opened for appending.
964    #[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            // Flush remaining bytes when channel closes.
1007            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); // EXEMPT: supervisor=None fallback (test environments only)
1019        }
1020
1021        Ok(Self { tx, drop_counter })
1022    }
1023
1024    /// Send a record to the audit sink.
1025    ///
1026    /// If the channel is full the record is dropped and the drop counter is incremented.
1027    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    /// Flush all pending records with bounded 100 ms timeout.
1046    ///
1047    /// Sends a `Flush` sentinel through the same channel as records, so ordering is
1048    /// preserved — the writer task responds only after all preceding records are written.
1049    /// If the writer task does not respond within 100 ms, the flush times out silently.
1050    #[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    /// Number of records dropped due to a full channel.
1059    #[must_use]
1060    pub fn dropped_count(&self) -> u64 {
1061        self.drop_counter.load(std::sync::atomic::Ordering::Relaxed)
1062    }
1063}
1064
1065// ── Tests ─────────────────────────────────────────────────────────────────────
1066
1067#[cfg(test)]
1068mod tests {
1069    use super::*;
1070
1071    // ── PageId ────────────────────────────────────────────────────────────────
1072
1073    #[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    // ── classify ──────────────────────────────────────────────────────────────
1094
1095    #[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    // ── detect_schema_hint ────────────────────────────────────────────────────
1134
1135    #[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    // ── ToolOutputInvariant ───────────────────────────────────────────────────
1160
1161    #[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    // ── SystemContextInvariant ────────────────────────────────────────────────
1222
1223    #[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    // ── InvariantRegistry ─────────────────────────────────────────────────────
1265
1266    #[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    // ── InvariantRegistry::enforce ────────────────────────────────────────────
1293
1294    #[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    // ── MemoryExcerptInvariant ────────────────────────────────────────────────
1359
1360    #[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    // ── ConversationTurnInvariant ─────────────────────────────────────────────
1426
1427    #[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    // ── CompactionAuditSink ───────────────────────────────────────────────────
1475
1476    #[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 the sink to close the channel and let the writer task flush.
1501        drop(sink);
1502        // Give the writer task time to finish.
1503        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        // Capacity 1: first send fills the channel, subsequent sends are dropped.
1519        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        // Send enough records to guarantee overflow.
1539        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        // flush on an empty sink must not panic or deadlock.
1555        sink.flush().await;
1556    }
1557
1558    // ── classify_with_role ────────────────────────────────────────────────────
1559
1560    #[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    // ── check_json_structural_key (via ToolOutputInvariant) ───────────────────
1585
1586    #[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        // Compacted body references "exit_code" and "shell".
1600        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        // Compacted body references tool name and status but none of the JSON keys.
1621        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        // "idea" and "okay" contain "id"/"ok" as raw substrings but not as whole
1649        // words or quoted keys — the check must not be fooled by this.
1650        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        // "id" appears here as a standalone word, so the check must accept it.
1678        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        // "id" is immediately adjacent to multi-byte Cyrillic characters on both
1699        // sides — the char-boundary slicing in key_appears_as_word must not panic
1700        // and must correctly treat non-alphanumeric Cyrillic as a word boundary.
1701        let compacted = CompactedPage {
1702            body: Arc::from("my_tool exit_status: 0, ключid тест"),
1703            tokens: 6,
1704        };
1705        // Must not panic; result correctness is secondary to boundary safety here.
1706        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        // An empty JSON key carries no structural information — the fidelity gate
1723        // must not fail over it, preserving the prior permissive behavior.
1724        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    // ── Regression: F1 — capacity=0 must not panic ────────────────────────────
1732
1733    #[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        // capacity=0 used to panic in tokio::sync::mpsc::channel(0); must clamp to 1.
1738        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        // Give the supervisor time to register the task before checking.
1758        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    // ── Regression: F3 — non-ASCII body must not panic on prefix slice ────────
1774
1775    #[test]
1776    fn classify_with_role_non_ascii_body_does_not_panic() {
1777        // CJK and emoji span multiple bytes; a naive &body[..80] would panic at a
1778        // mid-character byte boundary. classify_with_role must not panic for any input.
1779        let cjk = "你好世界".repeat(20); // 80+ bytes, 4 bytes each
1780        let emoji = "🦀".repeat(30); // 120+ bytes, 4 bytes each
1781        let mixed = "abc🦀中文".repeat(15);
1782
1783        // None of these must panic:
1784        let _ = classify_with_role(&cjk, false);
1785        let _ = classify_with_role(&emoji, false);
1786        let _ = classify_with_role(&mixed, false);
1787    }
1788}