cellstate_pcp/
lib.rs

1// Copyright 2024-2026 CELLSTATE Contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! CELLSTATE PCP - Persistent Context Protocol
5//!
6//! Provides validation, checkpointing, and harm reduction for AI agent memory.
7//! Implements the PCP protocol for context integrity, contradiction detection,
8//! and recovery mechanisms.
9
10use cellstate_core::{
11    AbstractionLevel, AgentId, Artifact, ArtifactId, CellstateConfig, CellstateError,
12    CellstateResult, ConflictResolution, EntityIdType, NoteId, RawContent, Scope, ScopeId,
13    SummarizationPolicy, SummarizationPolicyId, SummarizationTrigger, Timestamp, TrajectoryId,
14    ValidationError,
15};
16use chrono::Utc;
17use regex::Regex;
18use serde::{Deserialize, Serialize};
19use std::collections::HashMap;
20use std::sync::OnceLock;
21use uuid::Uuid;
22
23// ============================================================================
24// MEMORY COMMIT (Task 8.1)
25// ============================================================================
26
27/// Memory commit - versioned record of an interaction.
28/// Enables: "Last time we decided X because Y"
29/// Every interaction creates a versioned commit for recall, rollback, audit, and rehydration.
30#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
31pub struct MemoryCommit {
32    /// Unique identifier for this commit
33    pub commit_id: Uuid,
34    /// Trajectory this commit belongs to
35    pub trajectory_id: TrajectoryId,
36    /// Scope this commit belongs to
37    pub scope_id: ScopeId,
38    /// Agent that created this commit (if multi-agent)
39    pub agent_id: Option<AgentId>,
40
41    /// The user's query/input
42    pub query: String,
43    /// The system's response
44    pub response: String,
45
46    /// Mode of interaction (e.g., "standard", "deep_work", "super_think")
47    pub mode: String,
48    /// Reasoning trace (if available)
49    pub reasoning_trace: Option<serde_json::Value>,
50
51    /// Whether RAG contributed to this response
52    pub rag_contributed: bool,
53    /// Artifacts referenced in this interaction
54    pub artifacts_referenced: Vec<ArtifactId>,
55    /// Notes referenced in this interaction
56    pub notes_referenced: Vec<NoteId>,
57
58    /// Tools invoked during this interaction
59    pub tools_invoked: Vec<String>,
60
61    /// Input token count
62    pub tokens_input: i64,
63    /// Output token count
64    pub tokens_output: i64,
65    /// Estimated cost (if available)
66    pub estimated_cost: Option<f64>,
67
68    /// When this commit was created
69    pub created_at: Timestamp,
70}
71
72impl MemoryCommit {
73    /// Create a new memory commit.
74    #[tracing::instrument(skip_all)]
75    pub fn new(
76        trajectory_id: TrajectoryId,
77        scope_id: ScopeId,
78        query: String,
79        response: String,
80        mode: String,
81    ) -> Self {
82        Self {
83            commit_id: Uuid::now_v7(),
84            trajectory_id,
85            scope_id,
86            agent_id: None,
87            query,
88            response,
89            mode,
90            reasoning_trace: None,
91            rag_contributed: false,
92            artifacts_referenced: Vec::new(),
93            notes_referenced: Vec::new(),
94            tools_invoked: Vec::new(),
95            tokens_input: 0,
96            tokens_output: 0,
97            estimated_cost: None,
98            created_at: Utc::now(),
99        }
100    }
101
102    /// Set the agent ID.
103    #[tracing::instrument(skip_all)]
104    pub fn with_agent_id(mut self, agent_id: AgentId) -> Self {
105        self.agent_id = Some(agent_id);
106        self
107    }
108
109    /// Set the reasoning trace.
110    #[tracing::instrument(skip_all)]
111    pub fn with_reasoning_trace(mut self, trace: serde_json::Value) -> Self {
112        self.reasoning_trace = Some(trace);
113        self
114    }
115
116    /// Set RAG contribution flag.
117    #[tracing::instrument(skip_all)]
118    pub fn with_rag_contributed(mut self, contributed: bool) -> Self {
119        self.rag_contributed = contributed;
120        self
121    }
122
123    /// Set referenced artifacts.
124    #[tracing::instrument(skip_all)]
125    pub fn with_artifacts_referenced(mut self, artifacts: Vec<ArtifactId>) -> Self {
126        self.artifacts_referenced = artifacts;
127        self
128    }
129
130    /// Set referenced notes.
131    #[tracing::instrument(skip_all)]
132    pub fn with_notes_referenced(mut self, notes: Vec<NoteId>) -> Self {
133        self.notes_referenced = notes;
134        self
135    }
136
137    /// Set tools invoked.
138    #[tracing::instrument(skip_all)]
139    pub fn with_tools_invoked(mut self, tools: Vec<String>) -> Self {
140        self.tools_invoked = tools;
141        self
142    }
143
144    /// Set token counts.
145    #[tracing::instrument(skip_all)]
146    pub fn with_tokens(mut self, input: i64, output: i64) -> Self {
147        self.tokens_input = input;
148        self.tokens_output = output;
149        self
150    }
151
152    /// Set estimated cost.
153    #[tracing::instrument(skip_all)]
154    pub fn with_estimated_cost(mut self, cost: f64) -> Self {
155        self.estimated_cost = Some(cost);
156        self
157    }
158
159    /// Get total tokens (input + output).
160    #[tracing::instrument(skip_all)]
161    pub fn total_tokens(&self) -> i64 {
162        self.tokens_input + self.tokens_output
163    }
164}
165
166// ============================================================================
167// RECALL SERVICE (Task 8.2)
168// ============================================================================
169
170/// Recall of a decision from past interactions.
171#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
172pub struct DecisionRecall {
173    /// Commit ID this decision came from
174    pub commit_id: Uuid,
175    /// Original query
176    pub query: String,
177    /// Extracted decision summary
178    pub decision_summary: String,
179    /// Mode of the interaction
180    pub mode: String,
181    /// When the decision was made
182    pub created_at: Timestamp,
183}
184
185/// History of a scope's interactions.
186#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
187pub struct ScopeHistory {
188    /// Scope ID
189    pub scope_id: ScopeId,
190    /// Number of interactions in this scope
191    pub interaction_count: i32,
192    /// Total tokens used in this scope
193    pub total_tokens: i64,
194    /// Total cost for this scope
195    pub total_cost: f64,
196    /// All commits in this scope
197    pub commits: Vec<MemoryCommit>,
198}
199
200/// Memory statistics for analytics.
201#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
202pub struct MemoryStats {
203    /// Total number of interactions
204    pub total_interactions: i64,
205    /// Total tokens used
206    pub total_tokens: i64,
207    /// Total cost
208    pub total_cost: f64,
209    /// Number of unique scopes
210    pub unique_scopes: i64,
211    /// Interactions by mode
212    pub by_mode: HashMap<String, i64>,
213    /// Average tokens per interaction
214    pub avg_tokens_per_interaction: i64,
215}
216
217impl Default for MemoryStats {
218    fn default() -> Self {
219        Self {
220            total_interactions: 0,
221            total_tokens: 0,
222            total_cost: 0.0,
223            unique_scopes: 0,
224            by_mode: HashMap::new(),
225            avg_tokens_per_interaction: 0,
226        }
227    }
228}
229
230/// Service for recalling past interactions and decisions.
231/// Provides query methods for memory commits.
232#[derive(Debug, Clone)]
233pub struct RecallService {
234    config: CellstateConfig,
235    /// In-memory storage for commits (in production, this would be backed by storage)
236    commits: Vec<MemoryCommit>,
237}
238
239impl RecallService {
240    /// Create a new recall service with the given configuration.
241    #[tracing::instrument(skip_all)]
242    pub fn new(config: CellstateConfig) -> CellstateResult<Self> {
243        config.validate()?;
244        Ok(Self {
245            config,
246            commits: Vec::new(),
247        })
248    }
249
250    /// Get the configuration.
251    #[tracing::instrument(skip_all)]
252    pub fn config(&self) -> &CellstateConfig {
253        &self.config
254    }
255
256    /// Add a commit to the service.
257    #[tracing::instrument(skip_all)]
258    pub fn add_commit(&mut self, commit: MemoryCommit) {
259        self.commits.push(commit);
260    }
261
262    /// Recall previous interactions for context.
263    /// "Last time we decided X because Y"
264    ///
265    /// # Arguments
266    /// * `trajectory_id` - Filter by trajectory (optional)
267    /// * `scope_id` - Filter by scope (optional)
268    /// * `limit` - Maximum number of commits to return
269    ///
270    /// # Returns
271    /// Vector of memory commits matching the criteria
272    #[tracing::instrument(skip_all)]
273    pub fn recall_previous(
274        &self,
275        trajectory_id: Option<TrajectoryId>,
276        scope_id: Option<ScopeId>,
277        limit: i32,
278    ) -> CellstateResult<Vec<MemoryCommit>> {
279        let mut results: Vec<MemoryCommit> = self
280            .commits
281            .iter()
282            .filter(|c| {
283                let traj_match = trajectory_id.is_none_or(|t| c.trajectory_id == t);
284                let scope_match = scope_id.is_none_or(|s| c.scope_id == s);
285                traj_match && scope_match
286            })
287            .cloned()
288            .collect();
289
290        // Sort by created_at descending (most recent first)
291        results.sort_by_key(|commit| std::cmp::Reverse(commit.created_at));
292
293        // Apply limit
294        results.truncate(limit as usize);
295
296        Ok(results)
297    }
298
299    /// Search interactions by content.
300    ///
301    /// # Arguments
302    /// * `search_text` - Text to search for in query or response
303    /// * `limit` - Maximum number of commits to return
304    ///
305    /// # Returns
306    /// Vector of memory commits containing the search text
307    #[tracing::instrument(skip_all)]
308    pub fn search_interactions(
309        &self,
310        search_text: &str,
311        limit: i32,
312    ) -> CellstateResult<Vec<MemoryCommit>> {
313        let search_lower = search_text.to_lowercase();
314
315        let mut results: Vec<MemoryCommit> = self
316            .commits
317            .iter()
318            .filter(|c| {
319                c.query.to_lowercase().contains(&search_lower)
320                    || c.response.to_lowercase().contains(&search_lower)
321            })
322            .cloned()
323            .collect();
324
325        // Sort by created_at descending
326        results.sort_by_key(|commit| std::cmp::Reverse(commit.created_at));
327
328        // Apply limit
329        results.truncate(limit as usize);
330
331        Ok(results)
332    }
333
334    /// Recall decisions made in past interactions.
335    /// Filters for decision-support interactions.
336    ///
337    /// # Arguments
338    /// * `topic` - Filter by topic in query (optional)
339    /// * `limit` - Maximum number of decisions to return
340    ///
341    /// # Returns
342    /// Vector of decision recalls
343    #[tracing::instrument(skip_all)]
344    pub fn recall_decisions(
345        &self,
346        topic: Option<&str>,
347        limit: i32,
348    ) -> CellstateResult<Vec<DecisionRecall>> {
349        let mut results: Vec<DecisionRecall> = self
350            .commits
351            .iter()
352            .filter(|c| {
353                // Filter by mode (deep_work or super_think) OR response contains decision keywords
354                let mode_match = c.mode == "deep_work" || c.mode == "super_think";
355                let has_decision = contains_decision_keywords(&c.response);
356
357                // Filter by topic if provided
358                let topic_match =
359                    topic.is_none_or(|t| c.query.to_lowercase().contains(&t.to_lowercase()));
360
361                (mode_match || has_decision) && topic_match
362            })
363            .map(|c| DecisionRecall {
364                commit_id: c.commit_id,
365                query: c.query.clone(),
366                decision_summary: extract_decision(&c.response),
367                mode: c.mode.clone(),
368                created_at: c.created_at,
369            })
370            .collect();
371
372        // Sort by created_at descending
373        results.sort_by_key(|commit| std::cmp::Reverse(commit.created_at));
374
375        // Apply limit
376        results.truncate(limit as usize);
377
378        Ok(results)
379    }
380
381    /// Get session/scope history.
382    ///
383    /// # Arguments
384    /// * `scope_id` - The scope to get history for
385    ///
386    /// # Returns
387    /// Scope history with all commits
388    #[tracing::instrument(skip_all)]
389    pub fn get_scope_history(&self, scope_id: ScopeId) -> CellstateResult<ScopeHistory> {
390        let commits: Vec<MemoryCommit> = self
391            .commits
392            .iter()
393            .filter(|c| c.scope_id == scope_id)
394            .cloned()
395            .collect();
396
397        let total_tokens: i64 = commits.iter().map(|c| c.total_tokens()).sum();
398        let total_cost: f64 = commits.iter().filter_map(|c| c.estimated_cost).sum();
399
400        Ok(ScopeHistory {
401            scope_id,
402            interaction_count: commits.len() as i32,
403            total_tokens,
404            total_cost,
405            commits,
406        })
407    }
408
409    /// Get memory stats for analytics.
410    ///
411    /// # Arguments
412    /// * `trajectory_id` - Filter by trajectory (optional)
413    ///
414    /// # Returns
415    /// Memory statistics
416    #[tracing::instrument(skip_all)]
417    pub fn get_memory_stats(
418        &self,
419        trajectory_id: Option<TrajectoryId>,
420    ) -> CellstateResult<MemoryStats> {
421        let filtered: Vec<&MemoryCommit> = self
422            .commits
423            .iter()
424            .filter(|c| trajectory_id.is_none_or(|t| c.trajectory_id == t))
425            .collect();
426
427        if filtered.is_empty() {
428            return Ok(MemoryStats::default());
429        }
430
431        let total_interactions = filtered.len() as i64;
432        let total_tokens: i64 = filtered.iter().map(|c| c.total_tokens()).sum();
433        let total_cost: f64 = filtered.iter().filter_map(|c| c.estimated_cost).sum();
434
435        // Count unique scopes
436        let mut unique_scope_ids: Vec<ScopeId> = filtered.iter().map(|c| c.scope_id).collect();
437        unique_scope_ids.sort_by_key(|id| id.as_uuid());
438        unique_scope_ids.dedup();
439        let unique_scopes = unique_scope_ids.len() as i64;
440
441        // Count by mode
442        let mut by_mode: HashMap<String, i64> = HashMap::new();
443        for commit in &filtered {
444            *by_mode.entry(commit.mode.clone()).or_insert(0) += 1;
445        }
446
447        let avg_tokens_per_interaction = if total_interactions > 0 {
448            total_tokens / total_interactions
449        } else {
450            0
451        };
452
453        Ok(MemoryStats {
454            total_interactions,
455            total_tokens,
456            total_cost,
457            unique_scopes,
458            by_mode,
459            avg_tokens_per_interaction,
460        })
461    }
462}
463
464// ============================================================================
465// DECISION EXTRACTION (Task 8.3)
466// ============================================================================
467
468/// Decision keywords to look for in responses.
469const DECISION_KEYWORDS: &[&str] = &[
470    "recommend",
471    "should",
472    "decision",
473    "conclude",
474    "suggest",
475    "advise",
476    "propose",
477    "determine",
478    "choose",
479    "select",
480];
481
482/// Check if a response contains decision keywords.
483fn contains_decision_keywords(response: &str) -> bool {
484    let response_lower = response.to_lowercase();
485    DECISION_KEYWORDS
486        .iter()
487        .any(|kw| response_lower.contains(kw))
488}
489
490/// Extract decision summary from response.
491/// Looks for recommendation patterns.
492///
493/// # Arguments
494/// * `response` - The response text to extract from
495///
496/// # Returns
497/// Extracted decision summary
498#[tracing::instrument(skip_all)]
499pub fn extract_decision(response: &str) -> String {
500    // Patterns to look for (case-insensitive)
501    let patterns = [
502        r"(?i)I recommend[^\n.]*[.]",
503        r"(?i)I suggest[^\n.]*[.]",
504        r"(?i)you should[^\n.]*[.]",
505        r"(?i)we should[^\n.]*[.]",
506        r"(?i)the decision[^\n.]*[.]",
507        r"(?i)I conclude[^\n.]*[.]",
508        r"(?i)my recommendation[^\n.]*[.]",
509        r"(?i)I advise[^\n.]*[.]",
510        r"(?i)I propose[^\n.]*[.]",
511        r"(?i)the best approach[^\n.]*[.]",
512        r"(?i)the recommended[^\n.]*[.]",
513    ];
514
515    for pattern in patterns {
516        if let Ok(re) = Regex::new(pattern) {
517            if let Some(m) = re.find(response) {
518                return m.as_str().trim().to_string();
519            }
520        }
521    }
522
523    // Fall back to first sentence
524    extract_first_sentence(response)
525}
526
527/// Extract the first sentence from text (Unicode-safe).
528fn extract_first_sentence(text: &str) -> String {
529    // Find the first sentence-ending punctuation
530    let end_chars = ['.', '!', '?'];
531    let max_chars = 200;
532
533    let mut char_count = 0;
534    let mut last_valid_pos = 0;
535
536    for (i, c) in text.char_indices() {
537        if end_chars.contains(&c) {
538            // Include the punctuation - use byte position after the char
539            return text[..i + c.len_utf8()].trim().to_string();
540        }
541
542        char_count += 1;
543        last_valid_pos = i + c.len_utf8();
544
545        if char_count >= max_chars {
546            break;
547        }
548    }
549
550    // No sentence ending found within limit
551    if char_count >= max_chars {
552        format!("{}...", text[..last_valid_pos].trim())
553    } else {
554        text.to_string()
555    }
556}
557
558// ============================================================================
559// PCP CONFIG (Task 8.4)
560// ============================================================================
561
562/// Strategy for pruning context DAG.
563#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
564#[serde(rename_all = "snake_case")]
565pub enum PruneStrategy {
566    /// Remove oldest entries first
567    OldestFirst,
568    /// Remove lowest relevance entries first
569    LowestRelevance,
570    /// Hybrid approach combining age and relevance
571    Hybrid,
572}
573
574/// Frequency for recovery checkpoints.
575#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
576#[serde(rename_all = "snake_case")]
577pub enum RecoveryFrequency {
578    /// Create checkpoint when scope closes
579    OnScopeClose,
580    /// Create checkpoint on every mutation
581    OnMutation,
582    /// Manual checkpoint creation only
583    Manual,
584}
585
586/// Configuration for context DAG management.
587#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
588pub struct ContextDagConfig {
589    /// Maximum depth of the context DAG
590    pub max_depth: i32,
591    /// Strategy for pruning when limits are exceeded
592    pub prune_strategy: PruneStrategy,
593}
594
595/// Configuration for recovery and checkpointing.
596#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
597pub struct RecoveryConfig {
598    /// Whether recovery is enabled
599    pub enabled: bool,
600    /// How often to create checkpoints
601    pub frequency: RecoveryFrequency,
602    /// Maximum number of checkpoints to retain
603    pub max_checkpoints: i32,
604}
605
606/// Configuration for dosage limits (harm reduction).
607#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
608pub struct DosageConfig {
609    /// Maximum tokens per scope
610    pub max_tokens_per_scope: i32,
611    /// Maximum artifacts per scope
612    pub max_artifacts_per_scope: i32,
613    /// Maximum notes per trajectory
614    pub max_notes_per_trajectory: i32,
615}
616
617/// Configuration for anti-sprawl measures.
618#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
619pub struct AntiSprawlConfig {
620    /// Maximum trajectory depth (nested trajectories)
621    pub max_trajectory_depth: i32,
622    /// Maximum concurrent scopes
623    pub max_concurrent_scopes: i32,
624}
625
626/// Configuration for grounding and fact-checking.
627#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
628pub struct GroundingConfig {
629    /// Whether to require artifact backing for facts
630    pub require_artifact_backing: bool,
631    /// Threshold for contradiction detection (0.0-1.0)
632    pub contradiction_threshold: f32,
633    /// How to resolve conflicts
634    pub conflict_resolution: ConflictResolution,
635}
636
637/// Configuration for artifact linting.
638#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
639pub struct LintingConfig {
640    /// Maximum artifact content size in bytes
641    pub max_artifact_size: usize,
642    /// Minimum confidence threshold for artifacts (0.0-1.0)
643    pub min_confidence_threshold: f32,
644}
645
646/// Configuration for staleness detection.
647#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
648pub struct StalenessConfig {
649    /// Number of hours after which a scope is considered stale
650    pub stale_hours: i64,
651}
652
653/// Master PCP configuration.
654#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
655pub struct PCPConfig {
656    /// Context DAG configuration
657    pub context_dag: ContextDagConfig,
658    /// Recovery configuration
659    pub recovery: RecoveryConfig,
660    /// Dosage limits configuration
661    pub dosage: DosageConfig,
662    /// Anti-sprawl configuration
663    pub anti_sprawl: AntiSprawlConfig,
664    /// Grounding configuration
665    pub grounding: GroundingConfig,
666    /// Linting configuration
667    pub linting: LintingConfig,
668    /// Staleness configuration
669    pub staleness: StalenessConfig,
670}
671
672impl PCPConfig {
673    /// Validate the PCP configuration.
674    #[tracing::instrument(skip_all)]
675    pub fn validate(&self) -> CellstateResult<()> {
676        // Validate context DAG config
677        if self.context_dag.max_depth <= 0 {
678            return Err(CellstateError::Validation(ValidationError::InvalidValue {
679                field: "context_dag.max_depth".to_string(),
680                reason: "must be positive".to_string(),
681            }));
682        }
683
684        // Validate recovery config
685        if self.recovery.max_checkpoints < 0 {
686            return Err(CellstateError::Validation(ValidationError::InvalidValue {
687                field: "recovery.max_checkpoints".to_string(),
688                reason: "must be non-negative".to_string(),
689            }));
690        }
691
692        // Validate dosage config
693        if self.dosage.max_tokens_per_scope <= 0 {
694            return Err(CellstateError::Validation(ValidationError::InvalidValue {
695                field: "dosage.max_tokens_per_scope".to_string(),
696                reason: "must be positive".to_string(),
697            }));
698        }
699        if self.dosage.max_artifacts_per_scope <= 0 {
700            return Err(CellstateError::Validation(ValidationError::InvalidValue {
701                field: "dosage.max_artifacts_per_scope".to_string(),
702                reason: "must be positive".to_string(),
703            }));
704        }
705        if self.dosage.max_notes_per_trajectory <= 0 {
706            return Err(CellstateError::Validation(ValidationError::InvalidValue {
707                field: "dosage.max_notes_per_trajectory".to_string(),
708                reason: "must be positive".to_string(),
709            }));
710        }
711
712        // Validate anti-sprawl config
713        if self.anti_sprawl.max_trajectory_depth <= 0 {
714            return Err(CellstateError::Validation(ValidationError::InvalidValue {
715                field: "anti_sprawl.max_trajectory_depth".to_string(),
716                reason: "must be positive".to_string(),
717            }));
718        }
719        if self.anti_sprawl.max_concurrent_scopes <= 0 {
720            return Err(CellstateError::Validation(ValidationError::InvalidValue {
721                field: "anti_sprawl.max_concurrent_scopes".to_string(),
722                reason: "must be positive".to_string(),
723            }));
724        }
725
726        // Validate grounding config
727        if self.grounding.contradiction_threshold < 0.0
728            || self.grounding.contradiction_threshold > 1.0
729        {
730            return Err(CellstateError::Validation(ValidationError::InvalidValue {
731                field: "grounding.contradiction_threshold".to_string(),
732                reason: "must be between 0.0 and 1.0".to_string(),
733            }));
734        }
735
736        // Validate linting config
737        if self.linting.max_artifact_size == 0 {
738            return Err(CellstateError::Validation(ValidationError::InvalidValue {
739                field: "linting.max_artifact_size".to_string(),
740                reason: "must be positive".to_string(),
741            }));
742        }
743        if self.linting.min_confidence_threshold < 0.0
744            || self.linting.min_confidence_threshold > 1.0
745        {
746            return Err(CellstateError::Validation(ValidationError::InvalidValue {
747                field: "linting.min_confidence_threshold".to_string(),
748                reason: "must be between 0.0 and 1.0".to_string(),
749            }));
750        }
751
752        // Validate staleness config
753        if self.staleness.stale_hours <= 0 {
754            return Err(CellstateError::Validation(ValidationError::InvalidValue {
755                field: "staleness.stale_hours".to_string(),
756                reason: "must be positive".to_string(),
757            }));
758        }
759
760        Ok(())
761    }
762}
763
764// NOTE: Default impl intentionally removed per REQ-6 (PCP Configuration Without Defaults)
765// All PCPConfig values must be explicitly provided by the user.
766// This follows the CELLSTATE philosophy: "NOTHING HARD-CODED. This is a FRAMEWORK, not a product."
767
768// ============================================================================
769// VALIDATION TYPES (Task 8.5, 8.6)
770// ============================================================================
771
772/// Severity of a validation issue.
773#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
774#[serde(rename_all = "snake_case")]
775pub enum Severity {
776    /// Warning - operation can proceed
777    Warning,
778    /// Error - operation should not proceed
779    Error,
780    /// Critical - immediate attention required
781    Critical,
782}
783
784/// Type of validation issue.
785#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
786#[serde(rename_all = "snake_case")]
787pub enum IssueType {
788    /// Data is stale (older than threshold)
789    StaleData,
790    /// Contradiction detected between artifacts
791    Contradiction,
792    /// Missing reference to required entity
793    MissingReference,
794    /// Dosage limit exceeded
795    DosageExceeded,
796    /// Circular dependency detected
797    CircularDependency,
798}
799
800/// A validation issue found during context validation.
801#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
802pub struct ValidationIssue {
803    /// Severity of the issue
804    pub severity: Severity,
805    /// Type of issue
806    pub issue_type: IssueType,
807    /// Human-readable message
808    pub message: String,
809    /// Entity ID related to this issue (if applicable)
810    pub entity_id: Option<Uuid>,
811}
812
813/// Result of context validation.
814#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
815pub struct ValidationResult {
816    /// Whether validation passed (no errors or critical issues)
817    pub valid: bool,
818    /// List of issues found
819    pub issues: Vec<ValidationIssue>,
820}
821
822impl ValidationResult {
823    /// Create a valid result with no issues.
824    #[tracing::instrument(skip_all)]
825    pub fn valid() -> Self {
826        Self {
827            valid: true,
828            issues: Vec::new(),
829        }
830    }
831
832    /// Create an invalid result with issues.
833    #[tracing::instrument(skip_all)]
834    pub fn invalid(issues: Vec<ValidationIssue>) -> Self {
835        Self {
836            valid: false,
837            issues,
838        }
839    }
840
841    /// Add an issue to the result.
842    #[tracing::instrument(skip_all)]
843    pub fn add_issue(&mut self, issue: ValidationIssue) {
844        // If we add an error or critical issue, mark as invalid
845        if issue.severity == Severity::Error || issue.severity == Severity::Critical {
846            self.valid = false;
847        }
848        self.issues.push(issue);
849    }
850
851    /// Check if there are any critical issues.
852    #[tracing::instrument(skip_all)]
853    pub fn has_critical(&self) -> bool {
854        self.issues.iter().any(|i| i.severity == Severity::Critical)
855    }
856
857    /// Check if there are any errors.
858    #[tracing::instrument(skip_all)]
859    pub fn has_errors(&self) -> bool {
860        self.issues
861            .iter()
862            .any(|i| i.severity == Severity::Error || i.severity == Severity::Critical)
863    }
864
865    /// Get all issues of a specific type.
866    #[tracing::instrument(skip_all)]
867    pub fn issues_of_type(&self, issue_type: IssueType) -> Vec<&ValidationIssue> {
868        self.issues
869            .iter()
870            .filter(|i| i.issue_type == issue_type)
871            .collect()
872    }
873}
874
875/// Type of lint issue for artifacts.
876#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
877#[serde(rename_all = "snake_case")]
878pub enum LintIssueType {
879    /// Artifact is too large
880    TooLarge,
881    /// Duplicate artifact detected
882    Duplicate,
883    /// Missing embedding
884    MissingEmbedding,
885    /// Low confidence score
886    LowConfidence,
887    /// Syntax-level markdown/content issue detected
888    SyntaxError,
889    /// Reserved character sequence leaked into context (e.g. unescaped @mention)
890    ReservedCharacterLeak,
891}
892
893/// A lint issue found during artifact linting.
894#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
895pub struct LintIssue {
896    /// Type of lint issue
897    pub issue_type: LintIssueType,
898    /// Human-readable message
899    pub message: String,
900    /// Artifact ID this issue relates to
901    pub artifact_id: ArtifactId,
902}
903
904/// Semantic lint issue for markdown/text content.
905#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
906pub struct MarkdownSemanticIssue {
907    /// Type of lint issue
908    pub issue_type: LintIssueType,
909    /// 1-based line number where the issue was detected
910    pub line: usize,
911    /// Human-readable message
912    pub message: String,
913}
914
915fn reserved_at_regex() -> &'static Regex {
916    static RE: OnceLock<Regex> = OnceLock::new();
917    RE.get_or_init(|| {
918        Regex::new(r"(^|[^\w\\])@([A-Za-z0-9_./-]+)")
919            .expect("reserved @ mention regex must compile")
920    })
921}
922
923fn markdown_table_separator_regex() -> &'static Regex {
924    static RE: OnceLock<Regex> = OnceLock::new();
925    RE.get_or_init(|| {
926        Regex::new(r"^:?-{3,}:?$").expect("markdown table separator regex must compile")
927    })
928}
929
930fn markdown_table_column_count(line: &str) -> usize {
931    let trimmed = line.trim();
932    if !trimmed.contains('|') {
933        return 0;
934    }
935    let core = trimmed.trim_matches('|').trim();
936    if core.is_empty() {
937        return 0;
938    }
939    core.split('|').count()
940}
941
942fn is_markdown_separator_row(line: &str) -> bool {
943    let core = line.trim().trim_matches('|').trim();
944    if core.is_empty() {
945        return false;
946    }
947    core.split('|')
948        .map(str::trim)
949        .all(|seg| markdown_table_separator_regex().is_match(seg))
950}
951
952/// Lint markdown/text for semantic hazards that commonly break context tooling.
953///
954/// This is intentionally lightweight and deterministic:
955/// - rejects unescaped mention-like `@` tokens (`@foo`, `@path/to/file`)
956/// - flags unterminated fenced code blocks
957/// - flags malformed markdown table rows with inconsistent column counts
958pub fn lint_markdown_semantics(content: &str) -> Vec<MarkdownSemanticIssue> {
959    let mut issues = Vec::new();
960    let lines: Vec<&str> = content.lines().collect();
961
962    // Reserved `@` leak detection (mention-like tokens, not emails).
963    for (idx, line) in lines.iter().enumerate() {
964        if reserved_at_regex().is_match(line) {
965            issues.push(MarkdownSemanticIssue {
966                issue_type: LintIssueType::ReservedCharacterLeak,
967                line: idx + 1,
968                message: "unescaped '@' token detected; escape as '\\@' to avoid agent import side-effects".to_string(),
969            });
970        }
971    }
972
973    // Unterminated fenced block detection.
974    let mut open_fence_line: Option<usize> = None;
975    for (idx, line) in lines.iter().enumerate() {
976        if line.trim_start().starts_with("```") {
977            if open_fence_line.is_some() {
978                open_fence_line = None;
979            } else {
980                open_fence_line = Some(idx + 1);
981            }
982        }
983    }
984    if let Some(line) = open_fence_line {
985        issues.push(MarkdownSemanticIssue {
986            issue_type: LintIssueType::SyntaxError,
987            line,
988            message: format!("unterminated fenced code block opened at line {}", line),
989        });
990    }
991
992    // Markdown table shape validation.
993    let mut i = 0usize;
994    while i + 1 < lines.len() {
995        if !lines[i].contains('|') || !is_markdown_separator_row(lines[i + 1]) {
996            i += 1;
997            continue;
998        }
999
1000        let expected_cols = markdown_table_column_count(lines[i]);
1001        if expected_cols == 0 {
1002            i += 1;
1003            continue;
1004        }
1005
1006        let mut j = i + 2;
1007        while j < lines.len() && lines[j].contains('|') {
1008            let cols = markdown_table_column_count(lines[j]);
1009            if cols != expected_cols {
1010                issues.push(MarkdownSemanticIssue {
1011                    issue_type: LintIssueType::SyntaxError,
1012                    line: j + 1,
1013                    message: format!(
1014                        "malformed markdown table row: expected {} columns, found {}",
1015                        expected_cols, cols
1016                    ),
1017                });
1018                break;
1019            }
1020            j += 1;
1021        }
1022
1023        i = j;
1024    }
1025
1026    issues
1027}
1028
1029/// Result of artifact linting.
1030#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1031pub struct LintResult {
1032    /// Whether linting passed
1033    pub passed: bool,
1034    /// List of issues found
1035    pub issues: Vec<LintIssue>,
1036}
1037
1038impl LintResult {
1039    /// Create a passing lint result.
1040    #[tracing::instrument(skip_all)]
1041    pub fn passed() -> Self {
1042        Self {
1043            passed: true,
1044            issues: Vec::new(),
1045        }
1046    }
1047
1048    /// Create a failing lint result.
1049    #[tracing::instrument(skip_all)]
1050    pub fn failed(issues: Vec<LintIssue>) -> Self {
1051        Self {
1052            passed: false,
1053            issues,
1054        }
1055    }
1056
1057    /// Add an issue to the result.
1058    #[tracing::instrument(skip_all)]
1059    pub fn add_issue(&mut self, issue: LintIssue) {
1060        self.passed = false;
1061        self.issues.push(issue);
1062    }
1063}
1064
1065/// State captured in a checkpoint.
1066#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1067pub struct CheckpointState {
1068    /// Serialized context snapshot
1069    pub context_snapshot: RawContent,
1070    /// Artifact IDs at checkpoint time
1071    pub artifact_ids: Vec<ArtifactId>,
1072    /// Note IDs at checkpoint time
1073    pub note_ids: Vec<NoteId>,
1074}
1075
1076/// A PCP checkpoint for recovery.
1077#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1078pub struct PCPCheckpoint {
1079    /// Unique identifier for this checkpoint
1080    pub checkpoint_id: Uuid,
1081    /// Scope this checkpoint belongs to
1082    pub scope_id: ScopeId,
1083    /// State captured in this checkpoint
1084    pub state: CheckpointState,
1085    /// When this checkpoint was created
1086    pub created_at: Timestamp,
1087}
1088
1089/// Result of recovery operation.
1090#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1091pub struct RecoveryResult {
1092    /// Whether recovery succeeded
1093    pub success: bool,
1094    /// Recovered scope (if successful)
1095    pub recovered_scope: Option<Scope>,
1096    /// Errors encountered during recovery
1097    pub errors: Vec<String>,
1098}
1099
1100impl RecoveryResult {
1101    /// Create a successful recovery result.
1102    #[tracing::instrument(skip_all)]
1103    pub fn success(scope: Scope) -> Self {
1104        Self {
1105            success: true,
1106            recovered_scope: Some(scope),
1107            errors: Vec::new(),
1108        }
1109    }
1110
1111    /// Create a failed recovery result.
1112    #[tracing::instrument(skip_all)]
1113    pub fn failure(errors: Vec<String>) -> Self {
1114        Self {
1115            success: false,
1116            recovered_scope: None,
1117            errors,
1118        }
1119    }
1120}
1121
1122/// Detected contradiction between artifacts.
1123#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1124pub struct Contradiction {
1125    /// First artifact in the contradiction
1126    pub artifact_a: ArtifactId,
1127    /// Second artifact in the contradiction
1128    pub artifact_b: ArtifactId,
1129    /// Similarity score that triggered detection
1130    pub similarity_score: f32,
1131    /// Description of the contradiction
1132    pub description: String,
1133}
1134
1135/// PCP Runtime - the main validation and checkpoint engine.
1136#[derive(Debug, Clone)]
1137pub struct PCPRuntime {
1138    /// PCP configuration
1139    config: PCPConfig,
1140    /// Checkpoints stored in memory (in production, backed by storage)
1141    checkpoints: Vec<PCPCheckpoint>,
1142}
1143
1144impl PCPRuntime {
1145    /// Create a new PCP runtime with the given configuration.
1146    #[tracing::instrument(skip_all)]
1147    pub fn new(config: PCPConfig) -> CellstateResult<Self> {
1148        config.validate()?;
1149        Ok(Self {
1150            config,
1151            checkpoints: Vec::new(),
1152        })
1153    }
1154
1155    /// Get the configuration.
1156    #[tracing::instrument(skip_all)]
1157    pub fn config(&self) -> &PCPConfig {
1158        &self.config
1159    }
1160}
1161
1162// ============================================================================
1163// VALIDATION IMPLEMENTATION (Task 8.6)
1164// ============================================================================
1165
1166impl PCPRuntime {
1167    /// Validate context integrity.
1168    /// Checks for stale data, missing references, and dosage limits.
1169    ///
1170    /// # Arguments
1171    /// * `scope` - The scope to validate
1172    /// * `artifacts` - Artifacts in the scope
1173    /// * `current_tokens` - Current token count in the scope
1174    ///
1175    /// # Returns
1176    /// ValidationResult with any issues found
1177    #[tracing::instrument(skip_all)]
1178    pub fn validate_context_integrity(
1179        &self,
1180        scope: &Scope,
1181        artifacts: &[Artifact],
1182        current_tokens: i32,
1183    ) -> CellstateResult<ValidationResult> {
1184        let mut result = ValidationResult::valid();
1185
1186        // Check dosage limits
1187        self.check_dosage_limits(&mut result, artifacts.len() as i32, current_tokens);
1188
1189        // Check for stale scope
1190        self.check_stale_scope(&mut result, scope);
1191
1192        // Check artifact integrity
1193        for artifact in artifacts {
1194            self.check_artifact_integrity(&mut result, artifact);
1195        }
1196
1197        Ok(result)
1198    }
1199
1200    /// Check dosage limits.
1201    fn check_dosage_limits(
1202        &self,
1203        result: &mut ValidationResult,
1204        artifact_count: i32,
1205        token_count: i32,
1206    ) {
1207        // Check token limit
1208        if token_count > self.config.dosage.max_tokens_per_scope {
1209            result.add_issue(ValidationIssue {
1210                severity: Severity::Warning,
1211                issue_type: IssueType::DosageExceeded,
1212                message: format!(
1213                    "Token count ({}) exceeds limit ({})",
1214                    token_count, self.config.dosage.max_tokens_per_scope
1215                ),
1216                entity_id: None,
1217            });
1218        }
1219
1220        // Check artifact limit
1221        if artifact_count > self.config.dosage.max_artifacts_per_scope {
1222            result.add_issue(ValidationIssue {
1223                severity: Severity::Warning,
1224                issue_type: IssueType::DosageExceeded,
1225                message: format!(
1226                    "Artifact count ({}) exceeds limit ({})",
1227                    artifact_count, self.config.dosage.max_artifacts_per_scope
1228                ),
1229                entity_id: None,
1230            });
1231        }
1232    }
1233
1234    /// Check if scope is stale.
1235    fn check_stale_scope(&self, result: &mut ValidationResult, scope: &Scope) {
1236        let now = Utc::now();
1237        let age = now.signed_duration_since(scope.created_at);
1238
1239        // Use configured staleness threshold
1240        if age.num_hours() > self.config.staleness.stale_hours && scope.is_active {
1241            result.add_issue(ValidationIssue {
1242                severity: Severity::Warning,
1243                issue_type: IssueType::StaleData,
1244                message: format!(
1245                    "Scope {} is {} hours old and still active (threshold: {} hours)",
1246                    scope.scope_id,
1247                    age.num_hours(),
1248                    self.config.staleness.stale_hours
1249                ),
1250                entity_id: Some(scope.scope_id.as_uuid()),
1251            });
1252        }
1253    }
1254
1255    /// Check artifact integrity.
1256    fn check_artifact_integrity(&self, result: &mut ValidationResult, artifact: &Artifact) {
1257        // Check for missing embedding if grounding requires it
1258        if self.config.grounding.require_artifact_backing && artifact.embedding.is_none() {
1259            result.add_issue(ValidationIssue {
1260                severity: Severity::Warning,
1261                issue_type: IssueType::MissingReference,
1262                message: format!(
1263                    "Artifact {} is missing embedding (required for grounding)",
1264                    artifact.artifact_id
1265                ),
1266                entity_id: Some(artifact.artifact_id.as_uuid()),
1267            });
1268        }
1269
1270        // Check for low confidence
1271        if let Some(confidence) = artifact.provenance.confidence {
1272            if confidence < self.config.linting.min_confidence_threshold {
1273                result.add_issue(ValidationIssue {
1274                    severity: Severity::Warning,
1275                    issue_type: IssueType::MissingReference,
1276                    message: format!(
1277                        "Artifact {} has low confidence ({})",
1278                        artifact.artifact_id, confidence
1279                    ),
1280                    entity_id: Some(artifact.artifact_id.as_uuid()),
1281                });
1282            }
1283        }
1284    }
1285}
1286
1287// ============================================================================
1288// CONTRADICTION DETECTION (Task 8.7)
1289// ============================================================================
1290
1291impl PCPRuntime {
1292    /// Detect contradictions between artifacts using embedding similarity.
1293    /// Two artifacts are considered potentially contradictory if:
1294    /// 1. They have high embedding similarity (similar topic)
1295    /// 2. Their content differs significantly
1296    ///
1297    /// # Arguments
1298    /// * `artifacts` - Artifacts to check for contradictions
1299    ///
1300    /// # Returns
1301    /// Vector of detected contradictions
1302    #[tracing::instrument(skip_all)]
1303    pub fn detect_contradictions(
1304        &self,
1305        artifacts: &[Artifact],
1306    ) -> CellstateResult<Vec<Contradiction>> {
1307        let mut contradictions = Vec::new();
1308
1309        // Compare each pair of artifacts
1310        for i in 0..artifacts.len() {
1311            for j in (i + 1)..artifacts.len() {
1312                let artifact_a = &artifacts[i];
1313                let artifact_b = &artifacts[j];
1314
1315                // Skip if either artifact lacks an embedding
1316                let (embedding_a, embedding_b) =
1317                    match (&artifact_a.embedding, &artifact_b.embedding) {
1318                        (Some(a), Some(b)) => (a, b),
1319                        _ => continue,
1320                    };
1321
1322                // Calculate similarity
1323                let similarity = match embedding_a.cosine_similarity(embedding_b) {
1324                    Ok(s) => s,
1325                    Err(_) => continue, // Skip if dimension mismatch
1326                };
1327
1328                // Check if similarity exceeds threshold
1329                if similarity >= self.config.grounding.contradiction_threshold {
1330                    // High similarity - check if content differs
1331                    if artifact_a.content != artifact_b.content {
1332                        // Same topic, different content - potential contradiction
1333                        contradictions.push(Contradiction {
1334                            artifact_a: artifact_a.artifact_id,
1335                            artifact_b: artifact_b.artifact_id,
1336                            similarity_score: similarity,
1337                            description: format!(
1338                                "Artifacts have high similarity ({:.2}) but different content",
1339                                similarity
1340                            ),
1341                        });
1342                    }
1343                }
1344            }
1345        }
1346
1347        Ok(contradictions)
1348    }
1349}
1350
1351// ============================================================================
1352// DOSAGE LIMITS (Task 8.8)
1353// ============================================================================
1354
1355/// Result of applying dosage limits.
1356#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1357pub struct DosageResult {
1358    /// Whether limits were exceeded
1359    pub exceeded: bool,
1360    /// Artifacts that were pruned (if any)
1361    pub pruned_artifacts: Vec<ArtifactId>,
1362    /// Tokens that were trimmed
1363    pub tokens_trimmed: i32,
1364    /// Warning messages
1365    pub warnings: Vec<String>,
1366}
1367
1368impl DosageResult {
1369    /// Create a result indicating no limits were exceeded.
1370    #[tracing::instrument(skip_all)]
1371    pub fn within_limits() -> Self {
1372        Self {
1373            exceeded: false,
1374            pruned_artifacts: Vec::new(),
1375            tokens_trimmed: 0,
1376            warnings: Vec::new(),
1377        }
1378    }
1379
1380    /// Create a result indicating limits were exceeded.
1381    #[tracing::instrument(skip_all)]
1382    pub fn exceeded_limits() -> Self {
1383        Self {
1384            exceeded: true,
1385            pruned_artifacts: Vec::new(),
1386            tokens_trimmed: 0,
1387            warnings: Vec::new(),
1388        }
1389    }
1390
1391    /// Add a pruned artifact.
1392    #[tracing::instrument(skip_all)]
1393    pub fn add_pruned(&mut self, artifact_id: ArtifactId) {
1394        self.pruned_artifacts.push(artifact_id);
1395    }
1396
1397    /// Add a warning.
1398    #[tracing::instrument(skip_all)]
1399    pub fn add_warning(&mut self, warning: String) {
1400        self.warnings.push(warning);
1401    }
1402}
1403
1404impl PCPRuntime {
1405    /// Apply dosage limits to artifacts and tokens.
1406    /// Returns which artifacts should be pruned to stay within limits.
1407    ///
1408    /// # Arguments
1409    /// * `artifacts` - Current artifacts (sorted by priority/recency)
1410    /// * `current_tokens` - Current token count
1411    ///
1412    /// # Returns
1413    /// DosageResult indicating what needs to be pruned
1414    #[tracing::instrument(skip_all)]
1415    pub fn apply_dosage_limits(
1416        &self,
1417        artifacts: &[Artifact],
1418        current_tokens: i32,
1419    ) -> CellstateResult<DosageResult> {
1420        let mut result = DosageResult::within_limits();
1421
1422        // Check token limit
1423        if current_tokens > self.config.dosage.max_tokens_per_scope {
1424            result.exceeded = true;
1425            result.tokens_trimmed = current_tokens - self.config.dosage.max_tokens_per_scope;
1426            result.add_warning(format!(
1427                "Token limit exceeded: {} > {}. Need to trim {} tokens.",
1428                current_tokens, self.config.dosage.max_tokens_per_scope, result.tokens_trimmed
1429            ));
1430        }
1431
1432        // Check artifact limit
1433        let artifact_count = artifacts.len() as i32;
1434        if artifact_count > self.config.dosage.max_artifacts_per_scope {
1435            result.exceeded = true;
1436            let excess = artifact_count - self.config.dosage.max_artifacts_per_scope;
1437
1438            // Mark excess artifacts for pruning (from the end, assuming sorted by priority)
1439            let start_prune = artifacts.len() - excess as usize;
1440            for artifact in &artifacts[start_prune..] {
1441                result.add_pruned(artifact.artifact_id);
1442            }
1443
1444            result.add_warning(format!(
1445                "Artifact limit exceeded: {} > {}. Pruning {} artifacts.",
1446                artifact_count, self.config.dosage.max_artifacts_per_scope, excess
1447            ));
1448        }
1449
1450        Ok(result)
1451    }
1452
1453    /// Check if adding more content would exceed dosage limits.
1454    ///
1455    /// # Arguments
1456    /// * `current_artifacts` - Current artifact count
1457    /// * `current_tokens` - Current token count
1458    /// * `additional_artifacts` - Artifacts to add
1459    /// * `additional_tokens` - Tokens to add
1460    ///
1461    /// # Returns
1462    /// true if adding would exceed limits
1463    #[tracing::instrument(skip_all)]
1464    pub fn would_exceed_limits(
1465        &self,
1466        current_artifacts: i32,
1467        current_tokens: i32,
1468        additional_artifacts: i32,
1469        additional_tokens: i32,
1470    ) -> bool {
1471        let new_artifacts = current_artifacts + additional_artifacts;
1472        let new_tokens = current_tokens + additional_tokens;
1473
1474        new_artifacts > self.config.dosage.max_artifacts_per_scope
1475            || new_tokens > self.config.dosage.max_tokens_per_scope
1476    }
1477}
1478
1479// ============================================================================
1480// ARTIFACT LINTING (Task 8.9)
1481// ============================================================================
1482
1483// NOTE: MAX_ARTIFACT_SIZE and MIN_CONFIDENCE_THRESHOLD removed per REQ-6.
1484// These values must come from PCPConfig - no hard-coded defaults.
1485
1486impl PCPRuntime {
1487    /// Lint an artifact for quality issues.
1488    /// Checks for size, duplicates, missing embeddings, and low confidence.
1489    ///
1490    /// # Arguments
1491    /// * `artifact` - The artifact to lint
1492    /// * `existing_artifacts` - Existing artifacts to check for duplicates
1493    ///
1494    /// # Returns
1495    /// LintResult with any issues found
1496    #[tracing::instrument(skip_all)]
1497    pub fn lint_artifact(
1498        &self,
1499        artifact: &Artifact,
1500        existing_artifacts: &[Artifact],
1501    ) -> CellstateResult<LintResult> {
1502        let mut result = LintResult::passed();
1503
1504        // Check size
1505        self.check_artifact_size(&mut result, artifact);
1506
1507        // Check for duplicates
1508        self.check_artifact_duplicates(&mut result, artifact, existing_artifacts);
1509
1510        // Check for missing embedding
1511        self.check_artifact_embedding(&mut result, artifact);
1512
1513        // Check confidence
1514        self.check_artifact_confidence(&mut result, artifact);
1515
1516        // Check semantic markdown/context hazards
1517        self.check_artifact_semantics(&mut result, artifact);
1518
1519        Ok(result)
1520    }
1521
1522    /// Check if artifact content is too large.
1523    fn check_artifact_size(&self, result: &mut LintResult, artifact: &Artifact) {
1524        let max_size = self.config.linting.max_artifact_size;
1525        if artifact.content.len() > max_size {
1526            result.add_issue(LintIssue {
1527                issue_type: LintIssueType::TooLarge,
1528                message: format!(
1529                    "Artifact content size ({} bytes) exceeds maximum ({} bytes)",
1530                    artifact.content.len(),
1531                    max_size
1532                ),
1533                artifact_id: artifact.artifact_id,
1534            });
1535        }
1536    }
1537
1538    /// Check for duplicate artifacts by content hash.
1539    fn check_artifact_duplicates(
1540        &self,
1541        result: &mut LintResult,
1542        artifact: &Artifact,
1543        existing_artifacts: &[Artifact],
1544    ) {
1545        for existing in existing_artifacts {
1546            // Skip self-comparison
1547            if existing.artifact_id == artifact.artifact_id {
1548                continue;
1549            }
1550
1551            // Check content hash for exact duplicates
1552            if existing.content_hash == artifact.content_hash {
1553                result.add_issue(LintIssue {
1554                    issue_type: LintIssueType::Duplicate,
1555                    message: format!(
1556                        "Artifact is a duplicate of existing artifact {}",
1557                        existing.artifact_id
1558                    ),
1559                    artifact_id: artifact.artifact_id,
1560                });
1561                break; // Only report first duplicate
1562            }
1563        }
1564    }
1565
1566    /// Check if artifact is missing embedding.
1567    fn check_artifact_embedding(&self, result: &mut LintResult, artifact: &Artifact) {
1568        if self.config.grounding.require_artifact_backing && artifact.embedding.is_none() {
1569            result.add_issue(LintIssue {
1570                issue_type: LintIssueType::MissingEmbedding,
1571                message: "Artifact is missing embedding (required for grounding)".to_string(),
1572                artifact_id: artifact.artifact_id,
1573            });
1574        }
1575    }
1576
1577    /// Check artifact confidence score.
1578    fn check_artifact_confidence(&self, result: &mut LintResult, artifact: &Artifact) {
1579        let min_threshold = self.config.linting.min_confidence_threshold;
1580        if let Some(confidence) = artifact.provenance.confidence {
1581            if confidence < min_threshold {
1582                result.add_issue(LintIssue {
1583                    issue_type: LintIssueType::LowConfidence,
1584                    message: format!(
1585                        "Artifact confidence ({:.2}) is below threshold ({:.2})",
1586                        confidence, min_threshold
1587                    ),
1588                    artifact_id: artifact.artifact_id,
1589                });
1590            }
1591        }
1592    }
1593
1594    /// Check semantic markdown/context hazards inside artifact content.
1595    fn check_artifact_semantics(&self, result: &mut LintResult, artifact: &Artifact) {
1596        for issue in lint_markdown_semantics(&artifact.content) {
1597            result.add_issue(LintIssue {
1598                issue_type: issue.issue_type,
1599                message: format!("line {}: {}", issue.line, issue.message),
1600                artifact_id: artifact.artifact_id,
1601            });
1602        }
1603    }
1604
1605    /// Lint multiple artifacts at once.
1606    ///
1607    /// # Arguments
1608    /// * `artifacts` - Artifacts to lint
1609    ///
1610    /// # Returns
1611    /// Combined LintResult for all artifacts
1612    #[tracing::instrument(skip_all)]
1613    pub fn lint_artifacts(&self, artifacts: &[Artifact]) -> CellstateResult<LintResult> {
1614        let mut combined_result = LintResult::passed();
1615
1616        for artifact in artifacts {
1617            let result = self.lint_artifact(artifact, artifacts)?;
1618            for issue in result.issues {
1619                combined_result.add_issue(issue);
1620            }
1621        }
1622
1623        Ok(combined_result)
1624    }
1625}
1626
1627// ============================================================================
1628// CHECKPOINT CREATION AND RECOVERY (Task 8.10)
1629// ============================================================================
1630
1631impl PCPRuntime {
1632    /// Create a checkpoint for a scope.
1633    /// Captures the current state for potential recovery.
1634    ///
1635    /// # Arguments
1636    /// * `scope` - The scope to checkpoint
1637    /// * `artifacts` - Current artifacts in the scope
1638    /// * `note_ids` - Current note IDs referenced by the scope
1639    ///
1640    /// # Returns
1641    /// The created checkpoint
1642    #[tracing::instrument(skip_all)]
1643    pub fn create_checkpoint(
1644        &mut self,
1645        scope: &Scope,
1646        artifacts: &[Artifact],
1647        note_ids: &[NoteId],
1648    ) -> CellstateResult<PCPCheckpoint> {
1649        // Check if recovery is enabled
1650        if !self.config.recovery.enabled {
1651            return Err(CellstateError::Validation(
1652                ValidationError::ConstraintViolation {
1653                    constraint: "recovery.enabled".to_string(),
1654                    reason: "Recovery is disabled in configuration".to_string(),
1655                },
1656            ));
1657        }
1658
1659        // Serialize the scope state
1660        let context_snapshot = serde_json::to_vec(scope).map_err(|e| {
1661            CellstateError::Validation(ValidationError::InvalidValue {
1662                field: "scope".to_string(),
1663                reason: format!("Failed to serialize scope: {}", e),
1664            })
1665        })?;
1666
1667        // Collect artifact IDs
1668        let artifact_ids: Vec<ArtifactId> = artifacts.iter().map(|a| a.artifact_id).collect();
1669
1670        // Create checkpoint state
1671        let state = CheckpointState {
1672            context_snapshot,
1673            artifact_ids,
1674            note_ids: note_ids.to_vec(),
1675        };
1676
1677        // Create the checkpoint
1678        let checkpoint = PCPCheckpoint {
1679            checkpoint_id: Uuid::now_v7(),
1680            scope_id: scope.scope_id,
1681            state,
1682            created_at: Utc::now(),
1683        };
1684
1685        // Store the checkpoint
1686        self.checkpoints.push(checkpoint.clone());
1687
1688        // Enforce max checkpoints limit
1689        self.enforce_checkpoint_limit();
1690
1691        Ok(checkpoint)
1692    }
1693
1694    /// Enforce the maximum checkpoint limit by removing oldest checkpoints.
1695    fn enforce_checkpoint_limit(&mut self) {
1696        let max = self.config.recovery.max_checkpoints as usize;
1697        if self.checkpoints.len() > max {
1698            // Sort by created_at ascending (oldest first)
1699            self.checkpoints
1700                .sort_by_key(|checkpoint| checkpoint.created_at);
1701
1702            // Remove oldest checkpoints
1703            let excess = self.checkpoints.len() - max;
1704            self.checkpoints.drain(0..excess);
1705        }
1706    }
1707
1708    /// Recover a scope from a checkpoint.
1709    ///
1710    /// # Arguments
1711    /// * `checkpoint` - The checkpoint to recover from
1712    ///
1713    /// # Returns
1714    /// RecoveryResult with the recovered scope
1715    #[tracing::instrument(skip_all)]
1716    pub fn recover_from_checkpoint(
1717        &self,
1718        checkpoint: &PCPCheckpoint,
1719    ) -> CellstateResult<RecoveryResult> {
1720        // Check if recovery is enabled
1721        if !self.config.recovery.enabled {
1722            return Ok(RecoveryResult::failure(vec![
1723                "Recovery is disabled in configuration".to_string(),
1724            ]));
1725        }
1726
1727        // Deserialize the scope
1728        let scope: Scope = match serde_json::from_slice(&checkpoint.state.context_snapshot) {
1729            Ok(s) => s,
1730            Err(e) => {
1731                return Ok(RecoveryResult::failure(vec![format!(
1732                    "Failed to deserialize scope: {}",
1733                    e
1734                )]));
1735            }
1736        };
1737
1738        Ok(RecoveryResult::success(scope))
1739    }
1740
1741    /// Get the latest checkpoint for a scope.
1742    ///
1743    /// # Arguments
1744    /// * `scope_id` - The scope to get checkpoint for
1745    ///
1746    /// # Returns
1747    /// The latest checkpoint if found
1748    #[tracing::instrument(skip_all)]
1749    pub fn get_latest_checkpoint(&self, scope_id: ScopeId) -> Option<&PCPCheckpoint> {
1750        self.checkpoints
1751            .iter()
1752            .filter(|c| c.scope_id == scope_id)
1753            .max_by_key(|c| c.created_at)
1754    }
1755
1756    /// Get all checkpoints for a scope.
1757    ///
1758    /// # Arguments
1759    /// * `scope_id` - The scope to get checkpoints for
1760    ///
1761    /// # Returns
1762    /// Vector of checkpoints for the scope
1763    #[tracing::instrument(skip_all)]
1764    pub fn get_checkpoints_for_scope(&self, scope_id: ScopeId) -> Vec<&PCPCheckpoint> {
1765        self.checkpoints
1766            .iter()
1767            .filter(|c| c.scope_id == scope_id)
1768            .collect()
1769    }
1770
1771    /// Delete a checkpoint.
1772    ///
1773    /// # Arguments
1774    /// * `checkpoint_id` - The checkpoint to delete
1775    ///
1776    /// # Returns
1777    /// true if checkpoint was deleted
1778    #[tracing::instrument(skip_all)]
1779    pub fn delete_checkpoint(&mut self, checkpoint_id: Uuid) -> bool {
1780        let initial_len = self.checkpoints.len();
1781        self.checkpoints
1782            .retain(|c| c.checkpoint_id != checkpoint_id);
1783        self.checkpoints.len() < initial_len
1784    }
1785
1786    /// Clear all checkpoints for a scope.
1787    ///
1788    /// # Arguments
1789    /// * `scope_id` - The scope to clear checkpoints for
1790    ///
1791    /// # Returns
1792    /// Number of checkpoints deleted
1793    #[tracing::instrument(skip_all)]
1794    pub fn clear_checkpoints_for_scope(&mut self, scope_id: ScopeId) -> usize {
1795        let initial_len = self.checkpoints.len();
1796        self.checkpoints.retain(|c| c.scope_id != scope_id);
1797        initial_len - self.checkpoints.len()
1798    }
1799}
1800
1801// ============================================================================
1802// BATTLE INTEL FEATURE 4: SUMMARIZATION TRIGGER CHECKING
1803// ============================================================================
1804
1805impl PCPRuntime {
1806    /// Check which summarization triggers should fire based on current scope state.
1807    ///
1808    /// This method evaluates all provided summarization policies against the
1809    /// current state of a scope and returns which triggers should activate.
1810    /// Inspired by EVOLVE-MEM's self-improvement engine.
1811    ///
1812    /// # Arguments
1813    /// * `scope` - The scope to evaluate triggers for
1814    /// * `turn_count` - Number of turns in the scope
1815    /// * `artifact_count` - Number of artifacts in the scope
1816    /// * `policies` - Summarization policies to check
1817    ///
1818    /// # Returns
1819    /// Vector of (summarization_policy_id, triggered_trigger) pairs for policies that should fire
1820    ///
1821    /// # Example
1822    /// ```ignore
1823    /// let triggered = runtime.check_summarization_triggers(
1824    ///     &scope,
1825    ///     turns.len() as i32,
1826    ///     artifacts.len() as i32,
1827    ///     &policies,
1828    /// )?;
1829    ///
1830    /// for (summarization_policy_id, trigger) in triggered {
1831    ///     // Execute summarization for this policy
1832    /// }
1833    /// ```
1834    #[tracing::instrument(skip_all)]
1835    pub fn check_summarization_triggers(
1836        &self,
1837        scope: &Scope,
1838        turn_count: i32,
1839        artifact_count: i32,
1840        policies: &[SummarizationPolicy],
1841    ) -> CellstateResult<Vec<(SummarizationPolicyId, SummarizationTrigger)>> {
1842        let mut triggered = Vec::new();
1843
1844        // Calculate current token usage percentage
1845        let token_usage_percent = if scope.token_budget > 0 {
1846            ((scope.tokens_used as f32 / scope.token_budget as f32) * 100.0) as u8
1847        } else {
1848            0
1849        };
1850
1851        for policy in policies {
1852            for trigger in &policy.triggers {
1853                let should_fire = match trigger {
1854                    SummarizationTrigger::DosageThreshold { percent } => {
1855                        token_usage_percent >= *percent
1856                    }
1857                    SummarizationTrigger::ScopeClose => {
1858                        // Fires when scope is no longer active
1859                        !scope.is_active
1860                    }
1861                    SummarizationTrigger::TurnCount { count } => {
1862                        // Fires when turn count reaches threshold
1863                        turn_count >= *count && turn_count % *count == 0
1864                    }
1865                    SummarizationTrigger::ArtifactCount { count } => {
1866                        // Fires when artifact count reaches threshold
1867                        artifact_count >= *count && artifact_count % *count == 0
1868                    }
1869                    SummarizationTrigger::Manual => {
1870                        // Manual triggers never auto-fire
1871                        false
1872                    }
1873                };
1874
1875                if should_fire {
1876                    triggered.push((policy.summarization_policy_id, *trigger));
1877                }
1878            }
1879        }
1880
1881        Ok(triggered)
1882    }
1883
1884    /// Calculate what abstraction level transition should occur for a policy.
1885    ///
1886    /// # Arguments
1887    /// * `policy` - The policy defining source->target transition
1888    ///
1889    /// # Returns
1890    /// Tuple of (source_level, target_level) for the summarization operation
1891    #[tracing::instrument(skip_all)]
1892    pub fn get_abstraction_transition(
1893        &self,
1894        policy: &SummarizationPolicy,
1895    ) -> (AbstractionLevel, AbstractionLevel) {
1896        (policy.source_level, policy.target_level)
1897    }
1898
1899    /// Validate an abstraction level transition.
1900    ///
1901    /// Valid transitions are:
1902    /// - Raw -> Summary (L0 -> L1)
1903    /// - Summary -> Principle (L1 -> L2)
1904    /// - Raw -> Principle (L0 -> L2, skipping L1)
1905    ///
1906    /// Invalid transitions:
1907    /// - Summary -> Raw (downgrade)
1908    /// - Principle -> Summary/Raw (downgrade)
1909    /// - Same level to same level
1910    ///
1911    /// # Arguments
1912    /// * `source` - Source abstraction level
1913    /// * `target` - Target abstraction level
1914    ///
1915    /// # Returns
1916    /// true if the transition is valid
1917    #[tracing::instrument(skip_all)]
1918    pub fn validate_abstraction_transition(
1919        &self,
1920        source: AbstractionLevel,
1921        target: AbstractionLevel,
1922    ) -> bool {
1923        match (source, target) {
1924            // Valid upward transitions
1925            (AbstractionLevel::Raw, AbstractionLevel::Summary) => true,
1926            (AbstractionLevel::Raw, AbstractionLevel::Principle) => true,
1927            (AbstractionLevel::Summary, AbstractionLevel::Principle) => true,
1928            // Invalid: same level or downgrade
1929            _ => false,
1930        }
1931    }
1932}
1933
1934// ============================================================================
1935// TESTS
1936// ============================================================================
1937
1938#[cfg(test)]
1939mod tests {
1940    use super::*;
1941    use cellstate_core::{
1942        ArtifactType, ContextPersistence, ExtractionMethod, Provenance, RetryConfig,
1943        SectionPriorities, ValidationMode, TTL,
1944    };
1945    use std::time::Duration;
1946
1947    pub(crate) fn make_test_cellstate_config() -> CellstateConfig {
1948        CellstateConfig {
1949            token_budget: 8000,
1950            section_priorities: SectionPriorities {
1951                user: 100,
1952                system: 90,
1953                persona: 85,
1954                artifacts: 80,
1955                notes: 70,
1956                history: 60,
1957                custom: vec![],
1958            },
1959            checkpoint_retention: 10,
1960            stale_threshold: Duration::from_secs(3600),
1961            contradiction_threshold: 0.8,
1962            context_window_persistence: ContextPersistence::Ephemeral,
1963            validation_mode: ValidationMode::OnMutation,
1964            embedding_provider: None,
1965            summarization_provider: None,
1966            llm_retry_config: RetryConfig {
1967                max_retries: 3,
1968                initial_backoff: Duration::from_millis(100),
1969                max_backoff: Duration::from_secs(10),
1970                backoff_multiplier: 2.0,
1971            },
1972            lock_timeout: Duration::from_secs(30),
1973            message_retention: Duration::from_secs(86400),
1974            delegation_timeout: Duration::from_secs(300),
1975        }
1976    }
1977
1978    fn make_test_pcp_config() -> PCPConfig {
1979        PCPConfig {
1980            context_dag: ContextDagConfig {
1981                max_depth: 10,
1982                prune_strategy: PruneStrategy::OldestFirst,
1983            },
1984            recovery: RecoveryConfig {
1985                enabled: true,
1986                frequency: RecoveryFrequency::OnScopeClose,
1987                max_checkpoints: 5,
1988            },
1989            dosage: DosageConfig {
1990                max_tokens_per_scope: 8000,
1991                max_artifacts_per_scope: 100,
1992                max_notes_per_trajectory: 500,
1993            },
1994            anti_sprawl: AntiSprawlConfig {
1995                max_trajectory_depth: 5,
1996                max_concurrent_scopes: 10,
1997            },
1998            grounding: GroundingConfig {
1999                require_artifact_backing: false,
2000                contradiction_threshold: 0.85,
2001                conflict_resolution: ConflictResolution::LastWriteWins,
2002            },
2003            linting: LintingConfig {
2004                max_artifact_size: 1024 * 1024, // 1MB
2005                min_confidence_threshold: 0.3,
2006            },
2007            staleness: StalenessConfig {
2008                stale_hours: 24 * 30, // 30 days
2009            },
2010        }
2011    }
2012
2013    fn make_test_scope() -> Scope {
2014        Scope {
2015            scope_id: ScopeId::now_v7(),
2016            trajectory_id: TrajectoryId::now_v7(),
2017            parent_scope_id: None,
2018            name: "Test Scope".to_string(),
2019            purpose: Some("Testing".to_string()),
2020            is_active: true,
2021            created_at: Utc::now(),
2022            closed_at: None,
2023            checkpoint: None,
2024            token_budget: 8000,
2025            tokens_used: 0,
2026            metadata: None,
2027        }
2028    }
2029
2030    fn make_test_artifact(content: &str) -> Artifact {
2031        Artifact {
2032            artifact_id: ArtifactId::now_v7(),
2033            trajectory_id: TrajectoryId::now_v7(),
2034            scope_id: ScopeId::now_v7(),
2035            artifact_type: ArtifactType::Fact,
2036            name: "Test Artifact".to_string(),
2037            content: content.to_string(),
2038            content_hash: cellstate_core::compute_content_hash(content.as_bytes()),
2039            embedding: None,
2040            provenance: Provenance {
2041                source_turn: 1,
2042                extraction_method: ExtractionMethod::Explicit,
2043                confidence: Some(0.9),
2044            },
2045            ttl: TTL::Persistent,
2046            created_at: Utc::now(),
2047            updated_at: Utc::now(),
2048            superseded_by: None,
2049            metadata: None,
2050        }
2051    }
2052
2053    // ========================================================================
2054    // MemoryCommit Tests
2055    // ========================================================================
2056
2057    #[test]
2058    fn test_memory_commit_new() {
2059        let traj_id = TrajectoryId::now_v7();
2060        let scope_id = ScopeId::now_v7();
2061        let commit = MemoryCommit::new(
2062            traj_id,
2063            scope_id,
2064            "What is the weather?".to_string(),
2065            "The weather is sunny.".to_string(),
2066            "standard".to_string(),
2067        );
2068
2069        assert_eq!(commit.trajectory_id, traj_id);
2070        assert_eq!(commit.scope_id, scope_id);
2071        assert_eq!(commit.query, "What is the weather?");
2072        assert_eq!(commit.response, "The weather is sunny.");
2073        assert_eq!(commit.mode, "standard");
2074        assert!(commit.agent_id.is_none());
2075    }
2076
2077    #[test]
2078    fn test_memory_commit_with_tokens() {
2079        let commit = MemoryCommit::new(
2080            TrajectoryId::now_v7(),
2081            ScopeId::now_v7(),
2082            "query".to_string(),
2083            "response".to_string(),
2084            "standard".to_string(),
2085        )
2086        .with_tokens(100, 200);
2087
2088        assert_eq!(commit.tokens_input, 100);
2089        assert_eq!(commit.tokens_output, 200);
2090        assert_eq!(commit.total_tokens(), 300);
2091    }
2092
2093    // ========================================================================
2094    // RecallService Tests
2095    // ========================================================================
2096
2097    #[test]
2098    fn test_recall_service_add_and_recall() {
2099        let config = make_test_cellstate_config();
2100        let mut service =
2101            RecallService::new(config).expect("RecallService creation should succeed");
2102
2103        let traj_id = TrajectoryId::now_v7();
2104        let scope_id = ScopeId::now_v7();
2105
2106        let commit = MemoryCommit::new(
2107            traj_id,
2108            scope_id,
2109            "query".to_string(),
2110            "response".to_string(),
2111            "standard".to_string(),
2112        );
2113
2114        service.add_commit(commit);
2115
2116        let results = service
2117            .recall_previous(Some(traj_id), None, 10)
2118            .expect("recall_previous should succeed");
2119        assert_eq!(results.len(), 1);
2120        assert_eq!(results[0].query, "query");
2121    }
2122
2123    #[test]
2124    fn test_recall_service_search() {
2125        let config = make_test_cellstate_config();
2126        let mut service =
2127            RecallService::new(config).expect("RecallService creation should succeed");
2128
2129        service.add_commit(MemoryCommit::new(
2130            TrajectoryId::now_v7(),
2131            ScopeId::now_v7(),
2132            "weather query".to_string(),
2133            "sunny response".to_string(),
2134            "standard".to_string(),
2135        ));
2136
2137        service.add_commit(MemoryCommit::new(
2138            TrajectoryId::now_v7(),
2139            ScopeId::now_v7(),
2140            "code query".to_string(),
2141            "code response".to_string(),
2142            "standard".to_string(),
2143        ));
2144
2145        let results = service
2146            .search_interactions("weather", 10)
2147            .expect("search_interactions should succeed");
2148        assert_eq!(results.len(), 1);
2149        assert!(results[0].query.contains("weather"));
2150    }
2151
2152    // ========================================================================
2153    // Decision Extraction Tests
2154    // ========================================================================
2155
2156    #[test]
2157    fn test_extract_decision_recommend() {
2158        let response = "Based on the analysis, I recommend using Rust for this project.";
2159        let decision = extract_decision(response);
2160        assert!(decision.contains("recommend"));
2161    }
2162
2163    #[test]
2164    fn test_extract_decision_should() {
2165        let response = "You should consider using a database for persistence.";
2166        let decision = extract_decision(response);
2167        assert!(decision.contains("should"));
2168    }
2169
2170    #[test]
2171    fn test_extract_decision_fallback() {
2172        let response = "This is a simple response without decision keywords";
2173        let decision = extract_decision(response);
2174        // Should fall back to first sentence
2175        assert!(!decision.is_empty());
2176    }
2177
2178    // ========================================================================
2179    // PCPConfig Tests
2180    // ========================================================================
2181
2182    #[test]
2183    fn test_pcp_config_valid() {
2184        let config = make_test_pcp_config();
2185        assert!(config.validate().is_ok());
2186    }
2187
2188    #[test]
2189    fn test_pcp_config_invalid_max_depth() {
2190        let mut config = make_test_pcp_config();
2191        config.context_dag.max_depth = 0;
2192        assert!(config.validate().is_err());
2193    }
2194
2195    #[test]
2196    fn test_pcp_config_invalid_threshold() {
2197        let mut config = make_test_pcp_config();
2198        config.grounding.contradiction_threshold = 1.5;
2199        assert!(config.validate().is_err());
2200    }
2201
2202    // ========================================================================
2203    // PCPRuntime Tests
2204    // ========================================================================
2205
2206    #[test]
2207    fn test_pcp_runtime_new() {
2208        let config = make_test_pcp_config();
2209        let runtime = PCPRuntime::new(config).expect("PCPRuntime creation should succeed");
2210        assert!(runtime.config().recovery.enabled);
2211    }
2212
2213    #[test]
2214    fn test_validate_context_integrity() {
2215        let config = make_test_pcp_config();
2216        let runtime = PCPRuntime::new(config).expect("PCPRuntime creation should succeed");
2217
2218        let scope = make_test_scope();
2219        let artifacts = vec![make_test_artifact("test content")];
2220
2221        let result = runtime
2222            .validate_context_integrity(&scope, &artifacts, 1000)
2223            .expect("validate_context_integrity should succeed");
2224        assert!(result.valid);
2225    }
2226
2227    #[test]
2228    fn test_validate_context_dosage_exceeded() {
2229        let mut config = make_test_pcp_config();
2230        config.dosage.max_tokens_per_scope = 100;
2231        let runtime = PCPRuntime::new(config).expect("PCPRuntime creation should succeed");
2232
2233        let scope = make_test_scope();
2234        let artifacts = vec![];
2235
2236        let result = runtime
2237            .validate_context_integrity(&scope, &artifacts, 1000)
2238            .expect("validate_context_integrity should succeed");
2239        // Should have a warning about dosage
2240        assert!(result
2241            .issues
2242            .iter()
2243            .any(|i| i.issue_type == IssueType::DosageExceeded));
2244    }
2245
2246    // ========================================================================
2247    // Checkpoint Tests
2248    // ========================================================================
2249
2250    #[test]
2251    fn test_create_checkpoint() {
2252        let config = make_test_pcp_config();
2253        let mut runtime = PCPRuntime::new(config).expect("PCPRuntime creation should succeed");
2254
2255        let scope = make_test_scope();
2256        let artifacts = vec![make_test_artifact("test")];
2257        let note_ids = vec![NoteId::now_v7()];
2258
2259        let checkpoint = runtime
2260            .create_checkpoint(&scope, &artifacts, &note_ids)
2261            .expect("create_checkpoint should succeed");
2262
2263        assert_eq!(checkpoint.scope_id, scope.scope_id);
2264        assert_eq!(checkpoint.state.artifact_ids.len(), 1);
2265        assert_eq!(checkpoint.state.note_ids.len(), 1);
2266    }
2267
2268    #[test]
2269    fn test_recover_from_checkpoint() {
2270        let config = make_test_pcp_config();
2271        let mut runtime = PCPRuntime::new(config).expect("PCPRuntime creation should succeed");
2272
2273        let scope = make_test_scope();
2274        let checkpoint = runtime
2275            .create_checkpoint(&scope, &[], &[])
2276            .expect("create_checkpoint should succeed");
2277
2278        let result = runtime
2279            .recover_from_checkpoint(&checkpoint)
2280            .expect("recover_from_checkpoint should succeed");
2281        assert!(result.success);
2282        assert!(result.recovered_scope.is_some());
2283        assert_eq!(
2284            result
2285                .recovered_scope
2286                .expect("recovered_scope should be present")
2287                .scope_id,
2288            scope.scope_id
2289        );
2290    }
2291
2292    // ========================================================================
2293    // Lint Tests
2294    // ========================================================================
2295
2296    #[test]
2297    fn test_lint_artifact_passes() {
2298        let config = make_test_pcp_config();
2299        let runtime = PCPRuntime::new(config).expect("PCPRuntime creation should succeed");
2300
2301        let artifact = make_test_artifact("test content");
2302        let result = runtime
2303            .lint_artifact(&artifact, &[])
2304            .expect("lint_artifact should succeed");
2305        assert!(result.passed);
2306    }
2307
2308    #[test]
2309    fn test_lint_artifact_duplicate() {
2310        let config = make_test_pcp_config();
2311        let runtime = PCPRuntime::new(config).expect("PCPRuntime creation should succeed");
2312
2313        let artifact1 = make_test_artifact("same content");
2314        let mut artifact2 = make_test_artifact("same content");
2315        artifact2.artifact_id = ArtifactId::now_v7(); // Different ID, same content
2316
2317        let result = runtime
2318            .lint_artifact(&artifact2, &[artifact1])
2319            .expect("lint_artifact should succeed");
2320        assert!(!result.passed);
2321        assert!(result
2322            .issues
2323            .iter()
2324            .any(|i| i.issue_type == LintIssueType::Duplicate));
2325    }
2326
2327    #[test]
2328    fn test_lint_artifact_reserved_character_leak() {
2329        let config = make_test_pcp_config();
2330        let runtime = PCPRuntime::new(config).expect("PCPRuntime creation should succeed");
2331
2332        let artifact = make_test_artifact("Please import @my_skill before execution.");
2333        let result = runtime
2334            .lint_artifact(&artifact, &[])
2335            .expect("lint_artifact should succeed");
2336
2337        assert!(!result.passed);
2338        assert!(result
2339            .issues
2340            .iter()
2341            .any(|i| i.issue_type == LintIssueType::ReservedCharacterLeak));
2342    }
2343
2344    #[test]
2345    fn test_lint_artifact_unterminated_fence() {
2346        let config = make_test_pcp_config();
2347        let runtime = PCPRuntime::new(config).expect("PCPRuntime creation should succeed");
2348
2349        let artifact = make_test_artifact("```markdown\n# title\nstill open");
2350        let result = runtime
2351            .lint_artifact(&artifact, &[])
2352            .expect("lint_artifact should succeed");
2353
2354        assert!(!result.passed);
2355        assert!(result
2356            .issues
2357            .iter()
2358            .any(|i| i.issue_type == LintIssueType::SyntaxError));
2359    }
2360
2361    #[test]
2362    fn test_lint_markdown_semantics_detects_table_shape_mismatch() {
2363        let content = r#"
2364| a | b |
2365| --- | --- |
2366| 1 | 2 | 3 |
2367"#;
2368        let issues = lint_markdown_semantics(content);
2369        assert!(issues
2370            .iter()
2371            .any(|i| i.issue_type == LintIssueType::SyntaxError));
2372    }
2373
2374    // ========================================================================
2375    // MemoryCommit builder chain tests
2376    // ========================================================================
2377
2378    #[test]
2379    fn test_memory_commit_with_agent_id() {
2380        let agent_id = AgentId::now_v7();
2381        let commit = MemoryCommit::new(
2382            TrajectoryId::now_v7(),
2383            ScopeId::now_v7(),
2384            "q".into(),
2385            "r".into(),
2386            "standard".into(),
2387        )
2388        .with_agent_id(agent_id);
2389        assert_eq!(commit.agent_id, Some(agent_id));
2390    }
2391
2392    #[test]
2393    fn test_memory_commit_with_reasoning_trace() {
2394        let trace = serde_json::json!({"step": 1});
2395        let commit = MemoryCommit::new(
2396            TrajectoryId::now_v7(),
2397            ScopeId::now_v7(),
2398            "q".into(),
2399            "r".into(),
2400            "standard".into(),
2401        )
2402        .with_reasoning_trace(trace.clone());
2403        assert_eq!(commit.reasoning_trace, Some(trace));
2404    }
2405
2406    #[test]
2407    fn test_memory_commit_with_rag_contributed() {
2408        let commit = MemoryCommit::new(
2409            TrajectoryId::now_v7(),
2410            ScopeId::now_v7(),
2411            "q".into(),
2412            "r".into(),
2413            "standard".into(),
2414        )
2415        .with_rag_contributed(true);
2416        assert!(commit.rag_contributed);
2417    }
2418
2419    #[test]
2420    fn test_memory_commit_with_artifacts_referenced() {
2421        let ids = vec![ArtifactId::now_v7(), ArtifactId::now_v7()];
2422        let commit = MemoryCommit::new(
2423            TrajectoryId::now_v7(),
2424            ScopeId::now_v7(),
2425            "q".into(),
2426            "r".into(),
2427            "standard".into(),
2428        )
2429        .with_artifacts_referenced(ids.clone());
2430        assert_eq!(commit.artifacts_referenced.len(), 2);
2431    }
2432
2433    #[test]
2434    fn test_memory_commit_with_notes_referenced() {
2435        let ids = vec![NoteId::now_v7()];
2436        let commit = MemoryCommit::new(
2437            TrajectoryId::now_v7(),
2438            ScopeId::now_v7(),
2439            "q".into(),
2440            "r".into(),
2441            "standard".into(),
2442        )
2443        .with_notes_referenced(ids);
2444        assert_eq!(commit.notes_referenced.len(), 1);
2445    }
2446
2447    #[test]
2448    fn test_memory_commit_with_tools_invoked() {
2449        let commit = MemoryCommit::new(
2450            TrajectoryId::now_v7(),
2451            ScopeId::now_v7(),
2452            "q".into(),
2453            "r".into(),
2454            "standard".into(),
2455        )
2456        .with_tools_invoked(vec!["search".into(), "write".into()]);
2457        assert_eq!(commit.tools_invoked.len(), 2);
2458    }
2459
2460    #[test]
2461    fn test_memory_commit_with_estimated_cost() {
2462        let commit = MemoryCommit::new(
2463            TrajectoryId::now_v7(),
2464            ScopeId::now_v7(),
2465            "q".into(),
2466            "r".into(),
2467            "standard".into(),
2468        )
2469        .with_estimated_cost(0.05);
2470        assert_eq!(commit.estimated_cost, Some(0.05));
2471    }
2472
2473    #[test]
2474    fn test_memory_commit_serde_roundtrip() {
2475        let commit = MemoryCommit::new(
2476            TrajectoryId::now_v7(),
2477            ScopeId::now_v7(),
2478            "query".into(),
2479            "response".into(),
2480            "standard".into(),
2481        )
2482        .with_tokens(100, 200)
2483        .with_estimated_cost(0.01);
2484        let json = serde_json::to_string(&commit).unwrap();
2485        let d: MemoryCommit = serde_json::from_str(&json).unwrap();
2486        assert_eq!(d.query, "query");
2487        assert_eq!(d.total_tokens(), 300);
2488    }
2489
2490    // ========================================================================
2491    // MemoryStats tests
2492    // ========================================================================
2493
2494    #[test]
2495    fn test_memory_stats_default() {
2496        let stats = MemoryStats::default();
2497        assert_eq!(stats.total_interactions, 0);
2498        assert_eq!(stats.total_tokens, 0);
2499        assert_eq!(stats.total_cost, 0.0);
2500        assert_eq!(stats.unique_scopes, 0);
2501        assert!(stats.by_mode.is_empty());
2502        assert_eq!(stats.avg_tokens_per_interaction, 0);
2503    }
2504
2505    // ========================================================================
2506    // RecallService: recall_decisions, get_scope_history, get_memory_stats
2507    // ========================================================================
2508
2509    #[test]
2510    fn test_recall_decisions_deep_work_mode() {
2511        let config = make_test_cellstate_config();
2512        let mut service = RecallService::new(config).unwrap();
2513        service.add_commit(MemoryCommit::new(
2514            TrajectoryId::now_v7(),
2515            ScopeId::now_v7(),
2516            "architecture".into(),
2517            "plain response".into(),
2518            "deep_work".into(),
2519        ));
2520        let decisions = service.recall_decisions(None, 10).unwrap();
2521        assert_eq!(decisions.len(), 1);
2522        assert_eq!(decisions[0].mode, "deep_work");
2523    }
2524
2525    #[test]
2526    fn test_recall_decisions_keyword_filter() {
2527        let config = make_test_cellstate_config();
2528        let mut service = RecallService::new(config).unwrap();
2529        service.add_commit(MemoryCommit::new(
2530            TrajectoryId::now_v7(),
2531            ScopeId::now_v7(),
2532            "database choice".into(),
2533            "I recommend PostgreSQL.".into(),
2534            "standard".into(),
2535        ));
2536        let decisions = service.recall_decisions(Some("database"), 10).unwrap();
2537        assert_eq!(decisions.len(), 1);
2538        assert!(decisions[0].decision_summary.contains("recommend"));
2539    }
2540
2541    #[test]
2542    fn test_recall_decisions_topic_filter_excludes() {
2543        let config = make_test_cellstate_config();
2544        let mut service = RecallService::new(config).unwrap();
2545        service.add_commit(MemoryCommit::new(
2546            TrajectoryId::now_v7(),
2547            ScopeId::now_v7(),
2548            "database choice".into(),
2549            "I recommend PostgreSQL.".into(),
2550            "standard".into(),
2551        ));
2552        let decisions = service.recall_decisions(Some("weather"), 10).unwrap();
2553        assert!(decisions.is_empty());
2554    }
2555
2556    #[test]
2557    fn test_get_scope_history_empty() {
2558        let config = make_test_cellstate_config();
2559        let service = RecallService::new(config).unwrap();
2560        let history = service.get_scope_history(ScopeId::now_v7()).unwrap();
2561        assert_eq!(history.interaction_count, 0);
2562        assert_eq!(history.total_tokens, 0);
2563    }
2564
2565    #[test]
2566    fn test_get_scope_history_aggregates() {
2567        let config = make_test_cellstate_config();
2568        let mut service = RecallService::new(config).unwrap();
2569        let scope_id = ScopeId::now_v7();
2570        for _ in 0..3 {
2571            service.add_commit(
2572                MemoryCommit::new(
2573                    TrajectoryId::now_v7(),
2574                    scope_id,
2575                    "q".into(),
2576                    "r".into(),
2577                    "standard".into(),
2578                )
2579                .with_tokens(50, 100)
2580                .with_estimated_cost(0.01),
2581            );
2582        }
2583        let history = service.get_scope_history(scope_id).unwrap();
2584        assert_eq!(history.interaction_count, 3);
2585        assert_eq!(history.total_tokens, 450);
2586        assert!((history.total_cost - 0.03).abs() < 1e-10);
2587    }
2588
2589    #[test]
2590    fn test_get_memory_stats_empty() {
2591        let config = make_test_cellstate_config();
2592        let service = RecallService::new(config).unwrap();
2593        let stats = service.get_memory_stats(None).unwrap();
2594        assert_eq!(stats.total_interactions, 0);
2595    }
2596
2597    #[test]
2598    fn test_get_memory_stats_with_data() {
2599        let config = make_test_cellstate_config();
2600        let mut service = RecallService::new(config).unwrap();
2601        let traj_id = TrajectoryId::now_v7();
2602        service.add_commit(
2603            MemoryCommit::new(
2604                traj_id,
2605                ScopeId::now_v7(),
2606                "q".into(),
2607                "r".into(),
2608                "standard".into(),
2609            )
2610            .with_tokens(100, 200),
2611        );
2612        service.add_commit(
2613            MemoryCommit::new(
2614                traj_id,
2615                ScopeId::now_v7(),
2616                "q2".into(),
2617                "r2".into(),
2618                "deep_work".into(),
2619            )
2620            .with_tokens(200, 300),
2621        );
2622        let stats = service.get_memory_stats(Some(traj_id)).unwrap();
2623        assert_eq!(stats.total_interactions, 2);
2624        assert_eq!(stats.total_tokens, 800);
2625        assert_eq!(stats.unique_scopes, 2);
2626        assert_eq!(*stats.by_mode.get("standard").unwrap(), 1);
2627        assert_eq!(*stats.by_mode.get("deep_work").unwrap(), 1);
2628        assert_eq!(stats.avg_tokens_per_interaction, 400);
2629    }
2630
2631    // ========================================================================
2632    // ValidationResult tests
2633    // ========================================================================
2634
2635    #[test]
2636    fn test_validation_result_valid() {
2637        let result = ValidationResult::valid();
2638        assert!(result.valid);
2639        assert!(result.issues.is_empty());
2640        assert!(!result.has_critical());
2641        assert!(!result.has_errors());
2642    }
2643
2644    #[test]
2645    fn test_validation_result_invalid() {
2646        let result = ValidationResult::invalid(vec![ValidationIssue {
2647            severity: Severity::Error,
2648            issue_type: IssueType::StaleData,
2649            message: "stale".into(),
2650            entity_id: None,
2651        }]);
2652        assert!(!result.valid);
2653        assert_eq!(result.issues.len(), 1);
2654        assert!(result.has_errors());
2655    }
2656
2657    #[test]
2658    fn test_validation_result_add_warning_stays_valid() {
2659        let mut result = ValidationResult::valid();
2660        result.add_issue(ValidationIssue {
2661            severity: Severity::Warning,
2662            issue_type: IssueType::StaleData,
2663            message: "warning".into(),
2664            entity_id: None,
2665        });
2666        assert!(result.valid); // warnings don't invalidate
2667        assert_eq!(result.issues.len(), 1);
2668    }
2669
2670    #[test]
2671    fn test_validation_result_add_error_invalidates() {
2672        let mut result = ValidationResult::valid();
2673        result.add_issue(ValidationIssue {
2674            severity: Severity::Error,
2675            issue_type: IssueType::Contradiction,
2676            message: "error".into(),
2677            entity_id: None,
2678        });
2679        assert!(!result.valid);
2680    }
2681
2682    #[test]
2683    fn test_validation_result_add_critical_invalidates() {
2684        let mut result = ValidationResult::valid();
2685        result.add_issue(ValidationIssue {
2686            severity: Severity::Critical,
2687            issue_type: IssueType::CircularDependency,
2688            message: "critical".into(),
2689            entity_id: Some(Uuid::now_v7()),
2690        });
2691        assert!(!result.valid);
2692        assert!(result.has_critical());
2693    }
2694
2695    #[test]
2696    fn test_validation_result_issues_of_type() {
2697        let mut result = ValidationResult::valid();
2698        result.add_issue(ValidationIssue {
2699            severity: Severity::Warning,
2700            issue_type: IssueType::StaleData,
2701            message: "stale1".into(),
2702            entity_id: None,
2703        });
2704        result.add_issue(ValidationIssue {
2705            severity: Severity::Warning,
2706            issue_type: IssueType::MissingReference,
2707            message: "missing".into(),
2708            entity_id: None,
2709        });
2710        result.add_issue(ValidationIssue {
2711            severity: Severity::Warning,
2712            issue_type: IssueType::StaleData,
2713            message: "stale2".into(),
2714            entity_id: None,
2715        });
2716        let stale = result.issues_of_type(IssueType::StaleData);
2717        assert_eq!(stale.len(), 2);
2718    }
2719
2720    // ========================================================================
2721    // LintResult tests
2722    // ========================================================================
2723
2724    #[test]
2725    fn test_lint_result_passed() {
2726        let result = LintResult::passed();
2727        assert!(result.passed);
2728        assert!(result.issues.is_empty());
2729    }
2730
2731    #[test]
2732    fn test_lint_result_failed() {
2733        let result = LintResult::failed(vec![LintIssue {
2734            issue_type: LintIssueType::TooLarge,
2735            message: "too big".into(),
2736            artifact_id: ArtifactId::now_v7(),
2737        }]);
2738        assert!(!result.passed);
2739        assert_eq!(result.issues.len(), 1);
2740    }
2741
2742    #[test]
2743    fn test_lint_result_add_issue() {
2744        let mut result = LintResult::passed();
2745        assert!(result.passed);
2746        result.add_issue(LintIssue {
2747            issue_type: LintIssueType::LowConfidence,
2748            message: "low".into(),
2749            artifact_id: ArtifactId::now_v7(),
2750        });
2751        assert!(!result.passed);
2752        assert_eq!(result.issues.len(), 1);
2753    }
2754
2755    // ========================================================================
2756    // RecoveryResult tests
2757    // ========================================================================
2758
2759    #[test]
2760    fn test_recovery_result_success() {
2761        let scope = make_test_scope();
2762        let result = RecoveryResult::success(scope.clone());
2763        assert!(result.success);
2764        assert_eq!(result.recovered_scope.unwrap().scope_id, scope.scope_id);
2765        assert!(result.errors.is_empty());
2766    }
2767
2768    #[test]
2769    fn test_recovery_result_failure() {
2770        let result = RecoveryResult::failure(vec!["disk full".into()]);
2771        assert!(!result.success);
2772        assert!(result.recovered_scope.is_none());
2773        assert_eq!(result.errors.len(), 1);
2774    }
2775
2776    // ========================================================================
2777    // DosageResult tests
2778    // ========================================================================
2779
2780    #[test]
2781    fn test_dosage_result_within_limits() {
2782        let result = DosageResult::within_limits();
2783        assert!(!result.exceeded);
2784        assert!(result.pruned_artifacts.is_empty());
2785        assert!(result.warnings.is_empty());
2786    }
2787
2788    #[test]
2789    fn test_dosage_result_exceeded() {
2790        let mut result = DosageResult::exceeded_limits();
2791        assert!(result.exceeded);
2792        let id = ArtifactId::now_v7();
2793        result.add_pruned(id);
2794        result.add_warning("over budget".into());
2795        assert_eq!(result.pruned_artifacts.len(), 1);
2796        assert_eq!(result.warnings.len(), 1);
2797    }
2798
2799    // ========================================================================
2800    // PCPConfig validation edge cases
2801    // ========================================================================
2802
2803    #[test]
2804    fn test_pcp_config_invalid_negative_checkpoints() {
2805        let mut config = make_test_pcp_config();
2806        config.recovery.max_checkpoints = -1;
2807        assert!(config.validate().is_err());
2808    }
2809
2810    #[test]
2811    fn test_pcp_config_invalid_zero_tokens() {
2812        let mut config = make_test_pcp_config();
2813        config.dosage.max_tokens_per_scope = 0;
2814        assert!(config.validate().is_err());
2815    }
2816
2817    #[test]
2818    fn test_pcp_config_invalid_zero_artifacts() {
2819        let mut config = make_test_pcp_config();
2820        config.dosage.max_artifacts_per_scope = 0;
2821        assert!(config.validate().is_err());
2822    }
2823
2824    #[test]
2825    fn test_pcp_config_invalid_zero_notes() {
2826        let mut config = make_test_pcp_config();
2827        config.dosage.max_notes_per_trajectory = 0;
2828        assert!(config.validate().is_err());
2829    }
2830
2831    #[test]
2832    fn test_pcp_config_invalid_zero_trajectory_depth() {
2833        let mut config = make_test_pcp_config();
2834        config.anti_sprawl.max_trajectory_depth = 0;
2835        assert!(config.validate().is_err());
2836    }
2837
2838    #[test]
2839    fn test_pcp_config_invalid_zero_concurrent_scopes() {
2840        let mut config = make_test_pcp_config();
2841        config.anti_sprawl.max_concurrent_scopes = 0;
2842        assert!(config.validate().is_err());
2843    }
2844
2845    #[test]
2846    fn test_pcp_config_invalid_negative_threshold() {
2847        let mut config = make_test_pcp_config();
2848        config.grounding.contradiction_threshold = -0.1;
2849        assert!(config.validate().is_err());
2850    }
2851
2852    #[test]
2853    fn test_pcp_config_invalid_zero_artifact_size() {
2854        let mut config = make_test_pcp_config();
2855        config.linting.max_artifact_size = 0;
2856        assert!(config.validate().is_err());
2857    }
2858
2859    #[test]
2860    fn test_pcp_config_invalid_confidence_threshold() {
2861        let mut config = make_test_pcp_config();
2862        config.linting.min_confidence_threshold = 1.5;
2863        assert!(config.validate().is_err());
2864    }
2865
2866    #[test]
2867    fn test_pcp_config_invalid_zero_stale_hours() {
2868        let mut config = make_test_pcp_config();
2869        config.staleness.stale_hours = 0;
2870        assert!(config.validate().is_err());
2871    }
2872
2873    // ========================================================================
2874    // Enum serde roundtrips
2875    // ========================================================================
2876
2877    #[test]
2878    fn test_prune_strategy_serde() {
2879        for s in [
2880            PruneStrategy::OldestFirst,
2881            PruneStrategy::LowestRelevance,
2882            PruneStrategy::Hybrid,
2883        ] {
2884            let json = serde_json::to_string(&s).unwrap();
2885            let d: PruneStrategy = serde_json::from_str(&json).unwrap();
2886            assert_eq!(d, s);
2887        }
2888    }
2889
2890    #[test]
2891    fn test_recovery_frequency_serde() {
2892        for f in [
2893            RecoveryFrequency::OnScopeClose,
2894            RecoveryFrequency::OnMutation,
2895            RecoveryFrequency::Manual,
2896        ] {
2897            let json = serde_json::to_string(&f).unwrap();
2898            let d: RecoveryFrequency = serde_json::from_str(&json).unwrap();
2899            assert_eq!(d, f);
2900        }
2901    }
2902
2903    #[test]
2904    fn test_severity_serde() {
2905        for s in [Severity::Warning, Severity::Error, Severity::Critical] {
2906            let json = serde_json::to_string(&s).unwrap();
2907            let d: Severity = serde_json::from_str(&json).unwrap();
2908            assert_eq!(d, s);
2909        }
2910    }
2911
2912    #[test]
2913    fn test_issue_type_serde() {
2914        for t in [
2915            IssueType::StaleData,
2916            IssueType::Contradiction,
2917            IssueType::MissingReference,
2918            IssueType::DosageExceeded,
2919            IssueType::CircularDependency,
2920        ] {
2921            let json = serde_json::to_string(&t).unwrap();
2922            let d: IssueType = serde_json::from_str(&json).unwrap();
2923            assert_eq!(d, t);
2924        }
2925    }
2926
2927    #[test]
2928    fn test_lint_issue_type_serde() {
2929        for t in [
2930            LintIssueType::TooLarge,
2931            LintIssueType::Duplicate,
2932            LintIssueType::MissingEmbedding,
2933            LintIssueType::LowConfidence,
2934            LintIssueType::SyntaxError,
2935            LintIssueType::ReservedCharacterLeak,
2936        ] {
2937            let json = serde_json::to_string(&t).unwrap();
2938            let d: LintIssueType = serde_json::from_str(&json).unwrap();
2939            assert_eq!(d, t);
2940        }
2941    }
2942
2943    // ========================================================================
2944    // Decision extraction edge cases
2945    // ========================================================================
2946
2947    #[test]
2948    fn test_extract_decision_advise() {
2949        let response = "I advise caution when deploying.";
2950        let decision = extract_decision(response);
2951        assert!(decision.contains("advise"));
2952    }
2953
2954    #[test]
2955    fn test_extract_decision_propose() {
2956        let response = "I propose we use microservices.";
2957        let decision = extract_decision(response);
2958        assert!(decision.contains("propose"));
2959    }
2960
2961    #[test]
2962    fn test_extract_decision_best_approach() {
2963        let response = "The best approach is to refactor first.";
2964        let decision = extract_decision(response);
2965        assert!(decision.contains("best approach"));
2966    }
2967
2968    #[test]
2969    fn test_extract_first_sentence_simple() {
2970        let text = "First sentence. Second sentence.";
2971        let result = extract_first_sentence(text);
2972        assert_eq!(result, "First sentence.");
2973    }
2974
2975    #[test]
2976    fn test_extract_first_sentence_exclamation() {
2977        let text = "Wow! That's great.";
2978        let result = extract_first_sentence(text);
2979        assert_eq!(result, "Wow!");
2980    }
2981
2982    #[test]
2983    fn test_extract_first_sentence_question() {
2984        let text = "Is this right? Yes.";
2985        let result = extract_first_sentence(text);
2986        assert_eq!(result, "Is this right?");
2987    }
2988
2989    #[test]
2990    fn test_extract_first_sentence_long_truncates() {
2991        let long_text = "a ".repeat(200);
2992        let result = extract_first_sentence(&long_text);
2993        assert!(result.ends_with("..."));
2994    }
2995
2996    #[test]
2997    fn test_contains_decision_keywords_true() {
2998        assert!(contains_decision_keywords("I recommend this"));
2999        assert!(contains_decision_keywords("You should try"));
3000        assert!(contains_decision_keywords("The decision was made"));
3001    }
3002
3003    #[test]
3004    fn test_contains_decision_keywords_false() {
3005        assert!(!contains_decision_keywords("Hello world"));
3006        assert!(!contains_decision_keywords("The weather is nice"));
3007    }
3008
3009    // ========================================================================
3010    // Markdown semantic lint edge cases
3011    // ========================================================================
3012
3013    #[test]
3014    fn test_lint_markdown_semantics_clean() {
3015        let issues = lint_markdown_semantics("# Hello\n\nSome text.");
3016        assert!(issues.is_empty());
3017    }
3018
3019    #[test]
3020    fn test_lint_markdown_semantics_unterminated_fence() {
3021        let content = "```rust\nfn main() {}\n";
3022        let issues = lint_markdown_semantics(content);
3023        assert!(issues
3024            .iter()
3025            .any(|i| i.issue_type == LintIssueType::SyntaxError));
3026    }
3027
3028    #[test]
3029    fn test_lint_markdown_semantics_reserved_at_mention() {
3030        let content = "Please use @my_tool for this.";
3031        let issues = lint_markdown_semantics(content);
3032        assert!(issues
3033            .iter()
3034            .any(|i| i.issue_type == LintIssueType::ReservedCharacterLeak));
3035    }
3036
3037    // ========================================================================
3038    // Contradiction serde
3039    // ========================================================================
3040
3041    #[test]
3042    fn test_contradiction_serde() {
3043        let c = Contradiction {
3044            artifact_a: ArtifactId::now_v7(),
3045            artifact_b: ArtifactId::now_v7(),
3046            similarity_score: 0.95,
3047            description: "Contradictory".into(),
3048        };
3049        let json = serde_json::to_string(&c).unwrap();
3050        let d: Contradiction = serde_json::from_str(&json).unwrap();
3051        assert_eq!(d.similarity_score, 0.95);
3052    }
3053
3054    // ========================================================================
3055    // CheckpointState / PCPCheckpoint serde
3056    // ========================================================================
3057
3058    #[test]
3059    fn test_checkpoint_state_serde() {
3060        let state = CheckpointState {
3061            context_snapshot: b"snapshot".to_vec(),
3062            artifact_ids: vec![ArtifactId::now_v7()],
3063            note_ids: vec![NoteId::now_v7()],
3064        };
3065        let json = serde_json::to_string(&state).unwrap();
3066        let d: CheckpointState = serde_json::from_str(&json).unwrap();
3067        assert_eq!(d.artifact_ids.len(), 1);
3068        assert_eq!(d.note_ids.len(), 1);
3069    }
3070
3071    // ========================================================================
3072    // PCPRuntime: checkpoint management
3073    // ========================================================================
3074
3075    #[test]
3076    fn test_get_latest_checkpoint_none() {
3077        let config = make_test_pcp_config();
3078        let runtime = PCPRuntime::new(config).unwrap();
3079        assert!(runtime.get_latest_checkpoint(ScopeId::now_v7()).is_none());
3080    }
3081
3082    #[test]
3083    fn test_get_checkpoints_for_scope_empty() {
3084        let config = make_test_pcp_config();
3085        let runtime = PCPRuntime::new(config).unwrap();
3086        assert!(runtime
3087            .get_checkpoints_for_scope(ScopeId::now_v7())
3088            .is_empty());
3089    }
3090
3091    #[test]
3092    fn test_delete_checkpoint() {
3093        let config = make_test_pcp_config();
3094        let mut runtime = PCPRuntime::new(config).unwrap();
3095        let scope = make_test_scope();
3096        let cp = runtime.create_checkpoint(&scope, &[], &[]).unwrap();
3097        assert!(runtime.delete_checkpoint(cp.checkpoint_id));
3098        assert!(!runtime.delete_checkpoint(cp.checkpoint_id)); // already deleted
3099    }
3100
3101    #[test]
3102    fn test_clear_checkpoints_for_scope() {
3103        let config = make_test_pcp_config();
3104        let mut runtime = PCPRuntime::new(config).unwrap();
3105        let scope = make_test_scope();
3106        runtime.create_checkpoint(&scope, &[], &[]).unwrap();
3107        runtime.create_checkpoint(&scope, &[], &[]).unwrap();
3108        let cleared = runtime.clear_checkpoints_for_scope(scope.scope_id);
3109        assert_eq!(cleared, 2);
3110        assert!(runtime.get_checkpoints_for_scope(scope.scope_id).is_empty());
3111    }
3112
3113    #[test]
3114    fn test_checkpoint_limit_enforcement() {
3115        let mut config = make_test_pcp_config();
3116        config.recovery.max_checkpoints = 2;
3117        let mut runtime = PCPRuntime::new(config).unwrap();
3118        let scope = make_test_scope();
3119        runtime.create_checkpoint(&scope, &[], &[]).unwrap();
3120        runtime.create_checkpoint(&scope, &[], &[]).unwrap();
3121        runtime.create_checkpoint(&scope, &[], &[]).unwrap();
3122        // Should have pruned to max_checkpoints
3123        let cps = runtime.get_checkpoints_for_scope(scope.scope_id);
3124        assert!(cps.len() <= 2);
3125    }
3126
3127    // ========================================================================
3128    // DecisionRecall / ScopeHistory serde
3129    // ========================================================================
3130
3131    #[test]
3132    fn test_decision_recall_serde() {
3133        let dr = DecisionRecall {
3134            commit_id: Uuid::now_v7(),
3135            query: "q".into(),
3136            decision_summary: "I recommend X.".into(),
3137            mode: "standard".into(),
3138            created_at: Utc::now(),
3139        };
3140        let json = serde_json::to_string(&dr).unwrap();
3141        let d: DecisionRecall = serde_json::from_str(&json).unwrap();
3142        assert_eq!(d.query, "q");
3143    }
3144
3145    #[test]
3146    fn test_scope_history_serde() {
3147        let sh = ScopeHistory {
3148            scope_id: ScopeId::now_v7(),
3149            interaction_count: 5,
3150            total_tokens: 1000,
3151            total_cost: 0.05,
3152            commits: vec![],
3153        };
3154        let json = serde_json::to_string(&sh).unwrap();
3155        let d: ScopeHistory = serde_json::from_str(&json).unwrap();
3156        assert_eq!(d.interaction_count, 5);
3157    }
3158
3159    #[test]
3160    fn test_memory_stats_serde() {
3161        let stats = MemoryStats {
3162            total_interactions: 10,
3163            total_tokens: 5000,
3164            total_cost: 0.50,
3165            unique_scopes: 3,
3166            by_mode: HashMap::from([("standard".into(), 7), ("deep_work".into(), 3)]),
3167            avg_tokens_per_interaction: 500,
3168        };
3169        let json = serde_json::to_string(&stats).unwrap();
3170        let d: MemoryStats = serde_json::from_str(&json).unwrap();
3171        assert_eq!(d.total_interactions, 10);
3172        assert_eq!(d.by_mode.len(), 2);
3173    }
3174
3175    // -----------------------------------------------------------------------
3176    // Moved from integration test: battle_intel_e2e.rs
3177    // -----------------------------------------------------------------------
3178
3179    fn battle_intel_pcp_config() -> PCPConfig {
3180        PCPConfig {
3181            context_dag: ContextDagConfig {
3182                max_depth: 10,
3183                prune_strategy: PruneStrategy::OldestFirst,
3184            },
3185            recovery: RecoveryConfig {
3186                enabled: true,
3187                frequency: RecoveryFrequency::OnScopeClose,
3188                max_checkpoints: 5,
3189            },
3190            dosage: DosageConfig {
3191                max_tokens_per_scope: 8000,
3192                max_artifacts_per_scope: 100,
3193                max_notes_per_trajectory: 500,
3194            },
3195            anti_sprawl: AntiSprawlConfig {
3196                max_trajectory_depth: 5,
3197                max_concurrent_scopes: 10,
3198            },
3199            grounding: GroundingConfig {
3200                require_artifact_backing: false,
3201                contradiction_threshold: 0.85,
3202                conflict_resolution: ConflictResolution::LastWriteWins,
3203            },
3204            linting: LintingConfig {
3205                max_artifact_size: 1024 * 1024,
3206                min_confidence_threshold: 0.3,
3207            },
3208            staleness: StalenessConfig {
3209                stale_hours: 24 * 30,
3210            },
3211        }
3212    }
3213
3214    fn battle_intel_runtime() -> PCPRuntime {
3215        PCPRuntime::new(battle_intel_pcp_config()).expect("PCP runtime creation should succeed")
3216    }
3217
3218    fn battle_intel_scope(token_budget: i32, tokens_used: i32, is_active: bool) -> Scope {
3219        let now = Utc::now();
3220        Scope {
3221            scope_id: ScopeId::now_v7(),
3222            trajectory_id: TrajectoryId::now_v7(),
3223            parent_scope_id: None,
3224            name: "test-scope".to_string(),
3225            purpose: Some("Battle Intel testing".to_string()),
3226            is_active,
3227            created_at: now,
3228            closed_at: if is_active { None } else { Some(now) },
3229            checkpoint: None,
3230            token_budget,
3231            tokens_used,
3232            metadata: None,
3233        }
3234    }
3235
3236    fn battle_intel_policy(
3237        name: &str,
3238        triggers: Vec<SummarizationTrigger>,
3239        source_level: AbstractionLevel,
3240        target_level: AbstractionLevel,
3241    ) -> SummarizationPolicy {
3242        SummarizationPolicy {
3243            summarization_policy_id: SummarizationPolicyId::now_v7(),
3244            name: name.to_string(),
3245            triggers,
3246            source_level,
3247            target_level,
3248            max_sources: 10,
3249            create_edges: true,
3250            created_at: Utc::now(),
3251            metadata: None,
3252        }
3253    }
3254
3255    #[test]
3256    fn battle_intel_trigger_turn_count() {
3257        let runtime = battle_intel_runtime();
3258        let scope = battle_intel_scope(8000, 1000, true);
3259
3260        let policy = battle_intel_policy(
3261            "turn-count-5",
3262            vec![SummarizationTrigger::TurnCount { count: 5 }],
3263            AbstractionLevel::Raw,
3264            AbstractionLevel::Summary,
3265        );
3266
3267        let triggered = runtime
3268            .check_summarization_triggers(&scope, 3, 0, std::slice::from_ref(&policy))
3269            .expect("trigger check should succeed");
3270        assert!(triggered.is_empty());
3271
3272        let triggered = runtime
3273            .check_summarization_triggers(&scope, 5, 0, std::slice::from_ref(&policy))
3274            .expect("trigger check should succeed");
3275        assert_eq!(triggered.len(), 1);
3276        assert_eq!(triggered[0].0, policy.summarization_policy_id);
3277        assert_eq!(triggered[0].1, SummarizationTrigger::TurnCount { count: 5 });
3278
3279        let triggered = runtime
3280            .check_summarization_triggers(&scope, 7, 0, std::slice::from_ref(&policy))
3281            .expect("trigger check should succeed");
3282        assert!(triggered.is_empty());
3283
3284        let triggered = runtime
3285            .check_summarization_triggers(&scope, 10, 0, std::slice::from_ref(&policy))
3286            .expect("trigger check should succeed");
3287        assert_eq!(triggered.len(), 1);
3288
3289        let triggered = runtime
3290            .check_summarization_triggers(&scope, 0, 0, std::slice::from_ref(&policy))
3291            .expect("trigger check should succeed");
3292        assert!(triggered.is_empty());
3293    }
3294
3295    #[test]
3296    fn battle_intel_trigger_scope_close() {
3297        let runtime = battle_intel_runtime();
3298
3299        let policy = battle_intel_policy(
3300            "scope-close",
3301            vec![SummarizationTrigger::ScopeClose],
3302            AbstractionLevel::Raw,
3303            AbstractionLevel::Summary,
3304        );
3305
3306        let active_scope = battle_intel_scope(8000, 1000, true);
3307        let triggered = runtime
3308            .check_summarization_triggers(&active_scope, 5, 2, std::slice::from_ref(&policy))
3309            .expect("trigger check should succeed");
3310        assert!(triggered.is_empty());
3311
3312        let closed_scope = battle_intel_scope(8000, 1000, false);
3313        let triggered = runtime
3314            .check_summarization_triggers(&closed_scope, 5, 2, std::slice::from_ref(&policy))
3315            .expect("trigger check should succeed");
3316        assert_eq!(triggered.len(), 1);
3317        assert_eq!(triggered[0].1, SummarizationTrigger::ScopeClose);
3318    }
3319
3320    #[test]
3321    fn battle_intel_trigger_dosage_threshold() {
3322        let runtime = battle_intel_runtime();
3323
3324        let policy = battle_intel_policy(
3325            "dosage-80",
3326            vec![SummarizationTrigger::DosageThreshold { percent: 80 }],
3327            AbstractionLevel::Raw,
3328            AbstractionLevel::Summary,
3329        );
3330
3331        let scope_50 = battle_intel_scope(8000, 4000, true);
3332        let triggered = runtime
3333            .check_summarization_triggers(&scope_50, 10, 5, std::slice::from_ref(&policy))
3334            .expect("trigger check should succeed");
3335        assert!(triggered.is_empty());
3336
3337        let scope_79 = battle_intel_scope(8000, 6320, true);
3338        let triggered = runtime
3339            .check_summarization_triggers(&scope_79, 10, 5, std::slice::from_ref(&policy))
3340            .expect("trigger check should succeed");
3341        assert!(triggered.is_empty());
3342
3343        let scope_80 = battle_intel_scope(8000, 6400, true);
3344        let triggered = runtime
3345            .check_summarization_triggers(&scope_80, 10, 5, std::slice::from_ref(&policy))
3346            .expect("trigger check should succeed");
3347        assert_eq!(triggered.len(), 1);
3348        assert_eq!(
3349            triggered[0].1,
3350            SummarizationTrigger::DosageThreshold { percent: 80 }
3351        );
3352
3353        let scope_100 = battle_intel_scope(8000, 8000, true);
3354        let triggered = runtime
3355            .check_summarization_triggers(&scope_100, 10, 5, std::slice::from_ref(&policy))
3356            .expect("trigger check should succeed");
3357        assert_eq!(triggered.len(), 1);
3358
3359        let scope_zero = battle_intel_scope(0, 0, true);
3360        let triggered = runtime
3361            .check_summarization_triggers(&scope_zero, 10, 5, std::slice::from_ref(&policy))
3362            .expect("trigger check should succeed");
3363        assert!(triggered.is_empty());
3364    }
3365
3366    #[test]
3367    fn battle_intel_manual_never_auto_fires() {
3368        let runtime = battle_intel_runtime();
3369
3370        let policy = battle_intel_policy(
3371            "manual-only",
3372            vec![SummarizationTrigger::Manual],
3373            AbstractionLevel::Raw,
3374            AbstractionLevel::Summary,
3375        );
3376
3377        let scenarios: Vec<(Scope, i32, i32, &str)> = vec![
3378            (
3379                battle_intel_scope(8000, 0, true),
3380                0,
3381                0,
3382                "empty active scope",
3383            ),
3384            (
3385                battle_intel_scope(8000, 7000, true),
3386                100,
3387                50,
3388                "heavily used active scope",
3389            ),
3390            (
3391                battle_intel_scope(8000, 8000, false),
3392                200,
3393                100,
3394                "maxed out closed scope",
3395            ),
3396            (
3397                battle_intel_scope(0, 0, false),
3398                0,
3399                0,
3400                "zero budget closed scope",
3401            ),
3402        ];
3403
3404        for (scope, turn_count, artifact_count, desc) in scenarios {
3405            let triggered = runtime
3406                .check_summarization_triggers(
3407                    &scope,
3408                    turn_count,
3409                    artifact_count,
3410                    std::slice::from_ref(&policy),
3411                )
3412                .expect("trigger check should succeed");
3413            assert!(
3414                triggered.is_empty(),
3415                "Manual trigger should never auto-fire. Scenario: {}",
3416                desc
3417            );
3418        }
3419    }
3420
3421    #[test]
3422    fn battle_intel_abstraction_level_transitions() {
3423        let runtime = battle_intel_runtime();
3424
3425        assert!(runtime
3426            .validate_abstraction_transition(AbstractionLevel::Raw, AbstractionLevel::Summary));
3427        assert!(runtime.validate_abstraction_transition(
3428            AbstractionLevel::Summary,
3429            AbstractionLevel::Principle
3430        ));
3431        assert!(runtime
3432            .validate_abstraction_transition(AbstractionLevel::Raw, AbstractionLevel::Principle));
3433
3434        assert!(!runtime
3435            .validate_abstraction_transition(AbstractionLevel::Summary, AbstractionLevel::Raw));
3436        assert!(!runtime
3437            .validate_abstraction_transition(AbstractionLevel::Principle, AbstractionLevel::Raw));
3438        assert!(!runtime.validate_abstraction_transition(
3439            AbstractionLevel::Principle,
3440            AbstractionLevel::Summary
3441        ));
3442
3443        assert!(
3444            !runtime.validate_abstraction_transition(AbstractionLevel::Raw, AbstractionLevel::Raw)
3445        );
3446        assert!(!runtime
3447            .validate_abstraction_transition(AbstractionLevel::Summary, AbstractionLevel::Summary));
3448        assert!(!runtime.validate_abstraction_transition(
3449            AbstractionLevel::Principle,
3450            AbstractionLevel::Principle
3451        ));
3452    }
3453
3454    #[test]
3455    fn battle_intel_multiple_policies_different_triggers() {
3456        let runtime = battle_intel_runtime();
3457
3458        let policy_a = battle_intel_policy(
3459            "turn-count-10",
3460            vec![SummarizationTrigger::TurnCount { count: 10 }],
3461            AbstractionLevel::Raw,
3462            AbstractionLevel::Summary,
3463        );
3464
3465        let policy_b = battle_intel_policy(
3466            "scope-close",
3467            vec![SummarizationTrigger::ScopeClose],
3468            AbstractionLevel::Raw,
3469            AbstractionLevel::Summary,
3470        );
3471
3472        let policy_c = battle_intel_policy(
3473            "dosage-90",
3474            vec![SummarizationTrigger::DosageThreshold { percent: 90 }],
3475            AbstractionLevel::Summary,
3476            AbstractionLevel::Principle,
3477        );
3478
3479        let all_policies = vec![policy_a.clone(), policy_b.clone(), policy_c.clone()];
3480
3481        // Scenario 1: Active scope, turn_count=10, 50% usage -> only Policy A
3482        let scope_1 = battle_intel_scope(8000, 4000, true);
3483        let triggered = runtime
3484            .check_summarization_triggers(&scope_1, 10, 5, &all_policies)
3485            .expect("trigger check should succeed");
3486        assert_eq!(triggered.len(), 1);
3487        assert_eq!(triggered[0].0, policy_a.summarization_policy_id);
3488
3489        // Scenario 2: Closed scope, turn_count=3, 50% usage -> only Policy B
3490        let scope_2 = battle_intel_scope(8000, 4000, false);
3491        let triggered = runtime
3492            .check_summarization_triggers(&scope_2, 3, 5, &all_policies)
3493            .expect("trigger check should succeed");
3494        assert_eq!(triggered.len(), 1);
3495        assert_eq!(triggered[0].0, policy_b.summarization_policy_id);
3496
3497        // Scenario 3: Closed scope, turn_count=10, 95% usage -> all three
3498        let scope_3 = battle_intel_scope(8000, 7600, false);
3499        let triggered = runtime
3500            .check_summarization_triggers(&scope_3, 10, 5, &all_policies)
3501            .expect("trigger check should succeed");
3502        assert_eq!(triggered.len(), 3);
3503
3504        let fired_ids: Vec<SummarizationPolicyId> = triggered.iter().map(|(id, _)| *id).collect();
3505        assert!(fired_ids.contains(&policy_a.summarization_policy_id));
3506        assert!(fired_ids.contains(&policy_b.summarization_policy_id));
3507        assert!(fired_ids.contains(&policy_c.summarization_policy_id));
3508
3509        // Scenario 4: Active scope, turn_count=3, 50% usage -> none
3510        let scope_4 = battle_intel_scope(8000, 4000, true);
3511        let triggered = runtime
3512            .check_summarization_triggers(&scope_4, 3, 5, &all_policies)
3513            .expect("trigger check should succeed");
3514        assert!(triggered.is_empty());
3515    }
3516
3517    #[test]
3518    fn battle_intel_prompt_building() {
3519        let runtime = battle_intel_runtime();
3520
3521        assert!(runtime
3522            .validate_abstraction_transition(AbstractionLevel::Raw, AbstractionLevel::Summary));
3523        assert!(runtime.validate_abstraction_transition(
3524            AbstractionLevel::Summary,
3525            AbstractionLevel::Principle
3526        ));
3527        assert!(runtime
3528            .validate_abstraction_transition(AbstractionLevel::Raw, AbstractionLevel::Principle));
3529    }
3530
3531    #[test]
3532    fn battle_intel_trigger_artifact_count() {
3533        let runtime = battle_intel_runtime();
3534        let scope = battle_intel_scope(8000, 1000, true);
3535
3536        let policy = battle_intel_policy(
3537            "artifact-count-5",
3538            vec![SummarizationTrigger::ArtifactCount { count: 5 }],
3539            AbstractionLevel::Raw,
3540            AbstractionLevel::Summary,
3541        );
3542
3543        let triggered = runtime
3544            .check_summarization_triggers(&scope, 10, 3, std::slice::from_ref(&policy))
3545            .expect("trigger check should succeed");
3546        assert!(triggered.is_empty());
3547
3548        let triggered = runtime
3549            .check_summarization_triggers(&scope, 10, 5, std::slice::from_ref(&policy))
3550            .expect("trigger check should succeed");
3551        assert_eq!(triggered.len(), 1);
3552
3553        let triggered = runtime
3554            .check_summarization_triggers(&scope, 10, 10, std::slice::from_ref(&policy))
3555            .expect("trigger check should succeed");
3556        assert_eq!(triggered.len(), 1);
3557
3558        let triggered = runtime
3559            .check_summarization_triggers(&scope, 10, 6, std::slice::from_ref(&policy))
3560            .expect("trigger check should succeed");
3561        assert!(triggered.is_empty());
3562    }
3563
3564    #[test]
3565    fn battle_intel_get_abstraction_transition_from_policy() {
3566        let runtime = battle_intel_runtime();
3567
3568        let policy = battle_intel_policy(
3569            "raw-to-summary",
3570            vec![SummarizationTrigger::Manual],
3571            AbstractionLevel::Raw,
3572            AbstractionLevel::Summary,
3573        );
3574
3575        let (source, target) = runtime.get_abstraction_transition(&policy);
3576        assert_eq!(source, AbstractionLevel::Raw);
3577        assert_eq!(target, AbstractionLevel::Summary);
3578    }
3579}
3580
3581// ============================================================================
3582// PROPERTY-BASED TESTS (Task 8.11)
3583// ============================================================================
3584
3585#[cfg(test)]
3586mod prop_tests {
3587    use super::*;
3588    use proptest::prelude::*;
3589
3590    fn make_test_cellstate_config() -> CellstateConfig {
3591        super::tests::make_test_cellstate_config()
3592    }
3593
3594    // Strategy for generating arbitrary queries
3595    fn arb_query() -> impl Strategy<Value = String> {
3596        "[a-zA-Z0-9 ]{1,100}".prop_map(|s| s.trim().to_string())
3597    }
3598
3599    // Strategy for generating arbitrary responses
3600    fn arb_response() -> impl Strategy<Value = String> {
3601        "[a-zA-Z0-9 .,!?]{1,500}".prop_map(|s| s.trim().to_string())
3602    }
3603
3604    // Strategy for generating arbitrary modes
3605    fn arb_mode() -> impl Strategy<Value = String> {
3606        prop_oneof![
3607            Just("standard".to_string()),
3608            Just("deep_work".to_string()),
3609            Just("super_think".to_string()),
3610        ]
3611    }
3612
3613    // ========================================================================
3614    // Property 14: Memory commit preserves query/response
3615    // Feature: cellstate-core-implementation, Property 14: Memory commit preserves query/response
3616    // Validates: Requirements 10.1, 10.2
3617    // ========================================================================
3618
3619    proptest! {
3620        #![proptest_config(ProptestConfig::with_cases(100))]
3621
3622        /// Property 14: For any MemoryCommit created with query Q and response R,
3623        /// recall SHALL return the same Q and R
3624        #[test]
3625        fn prop_memory_commit_preserves_query_response(
3626            query in arb_query(),
3627            response in arb_response(),
3628            mode in arb_mode()
3629        ) {
3630            let config = make_test_cellstate_config();
3631            let mut service = RecallService::new(config).expect("RecallService creation should succeed");
3632
3633            let traj_id = TrajectoryId::now_v7();
3634            let scope_id = ScopeId::now_v7();
3635
3636            // Create and add commit
3637            let commit = MemoryCommit::new(
3638                traj_id,
3639                scope_id,
3640                query.clone(),
3641                response.clone(),
3642                mode.clone(),
3643            );
3644
3645            service.add_commit(commit);
3646
3647            // Recall the commit
3648            let results = service.recall_previous(Some(traj_id), Some(scope_id), 10).expect("recall_previous should succeed");
3649
3650            // Verify query and response are preserved
3651            prop_assert!(!results.is_empty(), "Should have at least one result");
3652            prop_assert_eq!(&results[0].query, &query, "Query should be preserved");
3653            prop_assert_eq!(&results[0].response, &response, "Response should be preserved");
3654            prop_assert_eq!(&results[0].mode, &mode, "Mode should be preserved");
3655        }
3656    }
3657
3658    // ========================================================================
3659    // Property 15: Recall decisions filters correctly
3660    // Feature: cellstate-core-implementation, Property 15: Recall decisions filters correctly
3661    // Validates: Requirements 10.1, 10.2
3662    // ========================================================================
3663
3664    proptest! {
3665        #![proptest_config(ProptestConfig::with_cases(100))]
3666
3667        /// Property 15: For any set of MemoryCommits, recall_decisions() SHALL only return
3668        /// commits where mode is "deep_work" or "super_think" OR response contains decision keywords
3669        #[test]
3670        fn prop_recall_decisions_filters_correctly(
3671            query in arb_query(),
3672            mode in arb_mode()
3673        ) {
3674            let config = make_test_cellstate_config();
3675            let mut service = RecallService::new(config).expect("RecallService creation should succeed");
3676
3677            // Create commits with different modes
3678            let traj_id = TrajectoryId::now_v7();
3679
3680            // Add a standard mode commit without decision keywords
3681            let standard_commit = MemoryCommit::new(
3682                traj_id,
3683                ScopeId::now_v7(),
3684                "simple query".to_string(),
3685                "simple response without any decision words".to_string(),
3686                "standard".to_string(),
3687            );
3688            service.add_commit(standard_commit);
3689
3690            // Add a commit with the test mode
3691            let test_commit = MemoryCommit::new(
3692                traj_id,
3693                ScopeId::now_v7(),
3694                query.clone(),
3695                "I recommend this approach for the solution.".to_string(),
3696                mode.clone(),
3697            );
3698            service.add_commit(test_commit);
3699
3700            // Recall decisions
3701            let decisions = service.recall_decisions(None, 100).expect("recall_decisions should succeed");
3702
3703            // Verify filtering
3704            for decision in &decisions {
3705                let is_decision_mode = decision.mode == "deep_work" || decision.mode == "super_think";
3706                let has_decision_keywords = contains_decision_keywords(&decision.decision_summary)
3707                    || contains_decision_keywords(&decision.query);
3708
3709                // Each returned decision should either be from a decision mode
3710                // or contain decision keywords
3711                prop_assert!(
3712                    is_decision_mode || has_decision_keywords,
3713                    "Decision should be from decision mode or contain decision keywords. Mode: {}, Summary: {}",
3714                    decision.mode,
3715                    decision.decision_summary
3716                );
3717            }
3718
3719            // The key property is: if a commit IS in the results, it must satisfy the filter criteria.
3720        }
3721    }
3722
3723    // ========================================================================
3724    // Additional Property Tests
3725    // ========================================================================
3726
3727    proptest! {
3728        #![proptest_config(ProptestConfig::with_cases(100))]
3729
3730        /// Property: Token counts are always non-negative and sum correctly
3731        #[test]
3732        fn prop_token_counts_sum_correctly(
3733            input_tokens in 0i64..1000000,
3734            output_tokens in 0i64..1000000
3735        ) {
3736            let commit = MemoryCommit::new(
3737                TrajectoryId::now_v7(),
3738                ScopeId::now_v7(),
3739                "query".to_string(),
3740                "response".to_string(),
3741                "standard".to_string(),
3742            )
3743            .with_tokens(input_tokens, output_tokens);
3744
3745            prop_assert_eq!(commit.total_tokens(), input_tokens + output_tokens);
3746            prop_assert!(commit.total_tokens() >= 0);
3747        }
3748
3749        /// Property: Scope history correctly aggregates commits
3750        #[test]
3751        fn prop_scope_history_aggregates_correctly(
3752            num_commits in 1usize..10
3753        ) {
3754            let config = make_test_cellstate_config();
3755            let mut service = RecallService::new(config).expect("RecallService creation should succeed");
3756
3757            let scope_id = ScopeId::now_v7();
3758            let traj_id = TrajectoryId::now_v7();
3759
3760            // Add commits
3761            for i in 0..num_commits {
3762                let commit = MemoryCommit::new(
3763                    traj_id,
3764                    scope_id,
3765                    format!("query {}", i),
3766                    format!("response {}", i),
3767                    "standard".to_string(),
3768                )
3769                .with_tokens(100, 200);
3770
3771                service.add_commit(commit);
3772            }
3773
3774            // Get scope history
3775            let history = service.get_scope_history(scope_id).expect("get_scope_history should succeed");
3776
3777            prop_assert_eq!(history.interaction_count as usize, num_commits);
3778            prop_assert_eq!(history.total_tokens, (num_commits as i64) * 300);
3779            prop_assert_eq!(history.commits.len(), num_commits);
3780        }
3781    }
3782}