cellstate_core/
pcp.rs

1// Copyright 2024-2026 CELLSTATE Contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! PCP configuration and validation types.
5//!
6//! Persistent Context Protocol types for validation, checkpointing, dosage,
7//! and harm reduction. Moved from the standalone `cellstate-server` crate into core.
8
9use crate::{
10    ArtifactId, CellstateError, CellstateResult, ConflictResolution, NoteId, RawContent, Scope,
11    ScopeId, Timestamp, ValidationError,
12};
13use serde::{Deserialize, Serialize};
14use uuid::Uuid;
15
16// ============================================================================
17// PCP CONFIG
18// ============================================================================
19
20/// Strategy for pruning context DAG.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum PruneStrategy {
24    /// Remove oldest entries first
25    OldestFirst,
26    /// Remove lowest relevance entries first
27    LowestRelevance,
28    /// Hybrid approach combining age and relevance
29    Hybrid,
30}
31
32/// Frequency for recovery checkpoints.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum RecoveryFrequency {
36    /// Create checkpoint when scope closes
37    OnScopeClose,
38    /// Create checkpoint on every mutation
39    OnMutation,
40    /// Manual checkpoint creation only
41    Manual,
42}
43
44/// Configuration for context DAG management.
45#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
46pub struct ContextDagConfig {
47    /// Maximum depth of the context DAG
48    pub max_depth: i32,
49    /// Strategy for pruning when limits are exceeded
50    pub prune_strategy: PruneStrategy,
51}
52
53/// Configuration for recovery and checkpointing.
54#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
55pub struct RecoveryConfig {
56    /// Whether recovery is enabled
57    pub enabled: bool,
58    /// How often to create checkpoints
59    pub frequency: RecoveryFrequency,
60    /// Maximum number of checkpoints to retain
61    pub max_checkpoints: i32,
62}
63
64/// Configuration for dosage limits (harm reduction).
65#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
66pub struct DosageConfig {
67    /// Maximum tokens per scope
68    pub max_tokens_per_scope: i32,
69    /// Maximum artifacts per scope
70    pub max_artifacts_per_scope: i32,
71    /// Maximum notes per trajectory
72    pub max_notes_per_trajectory: i32,
73}
74
75/// Configuration for anti-sprawl measures.
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
77pub struct AntiSprawlConfig {
78    /// Maximum trajectory depth (nested trajectories)
79    pub max_trajectory_depth: i32,
80    /// Maximum concurrent scopes
81    pub max_concurrent_scopes: i32,
82}
83
84/// Configuration for grounding and fact-checking.
85#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
86pub struct GroundingConfig {
87    /// Whether to require artifact backing for facts
88    pub require_artifact_backing: bool,
89    /// Threshold for contradiction detection (0.0-1.0)
90    pub contradiction_threshold: f32,
91    /// How to resolve conflicts
92    pub conflict_resolution: ConflictResolution,
93}
94
95/// Configuration for artifact linting.
96#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
97pub struct LintingConfig {
98    /// Maximum artifact content size in bytes
99    pub max_artifact_size: usize,
100    /// Minimum confidence threshold for artifacts (0.0-1.0)
101    pub min_confidence_threshold: f32,
102}
103
104/// Configuration for staleness detection.
105#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
106pub struct StalenessConfig {
107    /// Number of hours after which a scope is considered stale
108    pub stale_hours: i64,
109}
110
111/// Master PCP configuration.
112#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
113pub struct PCPConfig {
114    /// Context DAG configuration
115    pub context_dag: ContextDagConfig,
116    /// Recovery configuration
117    pub recovery: RecoveryConfig,
118    /// Dosage limits configuration
119    pub dosage: DosageConfig,
120    /// Anti-sprawl configuration
121    pub anti_sprawl: AntiSprawlConfig,
122    /// Grounding configuration
123    pub grounding: GroundingConfig,
124    /// Linting configuration
125    pub linting: LintingConfig,
126    /// Staleness configuration
127    pub staleness: StalenessConfig,
128}
129
130// NOTE: Default impl intentionally removed per REQ-6 (PCP Configuration Without Defaults)
131// All PCPConfig values must be explicitly provided by the user.
132
133impl PCPConfig {
134    /// Validate the PCP configuration.
135    pub fn validate(&self) -> CellstateResult<()> {
136        if self.context_dag.max_depth <= 0 {
137            return Err(CellstateError::Validation(ValidationError::InvalidValue {
138                field: "context_dag.max_depth".to_string(),
139                reason: "must be positive".to_string(),
140            }));
141        }
142
143        if self.recovery.max_checkpoints < 0 {
144            return Err(CellstateError::Validation(ValidationError::InvalidValue {
145                field: "recovery.max_checkpoints".to_string(),
146                reason: "must be non-negative".to_string(),
147            }));
148        }
149
150        if self.dosage.max_tokens_per_scope <= 0 {
151            return Err(CellstateError::Validation(ValidationError::InvalidValue {
152                field: "dosage.max_tokens_per_scope".to_string(),
153                reason: "must be positive".to_string(),
154            }));
155        }
156        if self.dosage.max_artifacts_per_scope <= 0 {
157            return Err(CellstateError::Validation(ValidationError::InvalidValue {
158                field: "dosage.max_artifacts_per_scope".to_string(),
159                reason: "must be positive".to_string(),
160            }));
161        }
162        if self.dosage.max_notes_per_trajectory <= 0 {
163            return Err(CellstateError::Validation(ValidationError::InvalidValue {
164                field: "dosage.max_notes_per_trajectory".to_string(),
165                reason: "must be positive".to_string(),
166            }));
167        }
168
169        if self.anti_sprawl.max_trajectory_depth <= 0 {
170            return Err(CellstateError::Validation(ValidationError::InvalidValue {
171                field: "anti_sprawl.max_trajectory_depth".to_string(),
172                reason: "must be positive".to_string(),
173            }));
174        }
175        if self.anti_sprawl.max_concurrent_scopes <= 0 {
176            return Err(CellstateError::Validation(ValidationError::InvalidValue {
177                field: "anti_sprawl.max_concurrent_scopes".to_string(),
178                reason: "must be positive".to_string(),
179            }));
180        }
181
182        if self.grounding.contradiction_threshold < 0.0
183            || self.grounding.contradiction_threshold > 1.0
184        {
185            return Err(CellstateError::Validation(ValidationError::InvalidValue {
186                field: "grounding.contradiction_threshold".to_string(),
187                reason: "must be between 0.0 and 1.0".to_string(),
188            }));
189        }
190
191        if self.linting.max_artifact_size == 0 {
192            return Err(CellstateError::Validation(ValidationError::InvalidValue {
193                field: "linting.max_artifact_size".to_string(),
194                reason: "must be positive".to_string(),
195            }));
196        }
197        if self.linting.min_confidence_threshold < 0.0
198            || self.linting.min_confidence_threshold > 1.0
199        {
200            return Err(CellstateError::Validation(ValidationError::InvalidValue {
201                field: "linting.min_confidence_threshold".to_string(),
202                reason: "must be between 0.0 and 1.0".to_string(),
203            }));
204        }
205
206        if self.staleness.stale_hours <= 0 {
207            return Err(CellstateError::Validation(ValidationError::InvalidValue {
208                field: "staleness.stale_hours".to_string(),
209                reason: "must be positive".to_string(),
210            }));
211        }
212
213        Ok(())
214    }
215}
216
217// ============================================================================
218// VALIDATION TYPES
219// ============================================================================
220
221/// Severity of a validation issue.
222#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
223#[serde(rename_all = "snake_case")]
224pub enum Severity {
225    /// Warning - operation can proceed
226    Warning,
227    /// Error - operation should not proceed
228    Error,
229    /// Critical - immediate attention required
230    Critical,
231}
232
233/// Type of validation issue.
234#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
235#[serde(rename_all = "snake_case")]
236pub enum IssueType {
237    /// Data is stale (older than threshold)
238    StaleData,
239    /// Contradiction detected between artifacts
240    Contradiction,
241    /// Missing reference to required entity
242    MissingReference,
243    /// Dosage limit exceeded
244    DosageExceeded,
245    /// Circular dependency detected
246    CircularDependency,
247}
248
249/// A validation issue found during context validation.
250#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
251pub struct ValidationIssue {
252    /// Severity of the issue
253    pub severity: Severity,
254    /// Type of issue
255    pub issue_type: IssueType,
256    /// Human-readable message
257    pub message: String,
258    /// Entity ID related to this issue (if applicable)
259    pub entity_id: Option<Uuid>,
260}
261
262/// Result of context validation.
263#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
264pub struct ValidationResult {
265    /// Whether validation passed (no errors or critical issues)
266    pub valid: bool,
267    /// List of issues found
268    pub issues: Vec<ValidationIssue>,
269}
270
271impl ValidationResult {
272    /// Create a valid result with no issues.
273    pub fn valid() -> Self {
274        Self {
275            valid: true,
276            issues: Vec::new(),
277        }
278    }
279
280    /// Create an invalid result with issues.
281    pub fn invalid(issues: Vec<ValidationIssue>) -> Self {
282        Self {
283            valid: false,
284            issues,
285        }
286    }
287
288    /// Add an issue to the result.
289    pub fn add_issue(&mut self, issue: ValidationIssue) {
290        if issue.severity == Severity::Error || issue.severity == Severity::Critical {
291            self.valid = false;
292        }
293        self.issues.push(issue);
294    }
295
296    /// Check if there are any critical issues.
297    pub fn has_critical(&self) -> bool {
298        self.issues.iter().any(|i| i.severity == Severity::Critical)
299    }
300
301    /// Check if there are any errors.
302    pub fn has_errors(&self) -> bool {
303        self.issues
304            .iter()
305            .any(|i| i.severity == Severity::Error || i.severity == Severity::Critical)
306    }
307
308    /// Get all issues of a specific type.
309    pub fn issues_of_type(&self, issue_type: IssueType) -> Vec<&ValidationIssue> {
310        self.issues
311            .iter()
312            .filter(|i| i.issue_type == issue_type)
313            .collect()
314    }
315}
316
317// ============================================================================
318// LINT TYPES
319// ============================================================================
320
321/// Type of lint issue for artifacts.
322#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
323#[serde(rename_all = "snake_case")]
324pub enum LintIssueType {
325    /// Artifact is too large
326    TooLarge,
327    /// Duplicate artifact detected
328    Duplicate,
329    /// Missing embedding
330    MissingEmbedding,
331    /// Low confidence score
332    LowConfidence,
333    /// Syntax-level markdown/content issue detected
334    SyntaxError,
335    /// Reserved character sequence leaked into context (e.g. unescaped @mention)
336    ReservedCharacterLeak,
337}
338
339/// A lint issue found during artifact linting.
340#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
341pub struct LintIssue {
342    /// Type of lint issue
343    pub issue_type: LintIssueType,
344    /// Human-readable message
345    pub message: String,
346    /// Artifact ID this issue relates to
347    pub artifact_id: ArtifactId,
348}
349
350/// Semantic lint issue for markdown/text content.
351#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
352pub struct MarkdownSemanticIssue {
353    /// Type of lint issue
354    pub issue_type: LintIssueType,
355    /// 1-based line number where the issue was detected
356    pub line: usize,
357    /// Human-readable message
358    pub message: String,
359}
360
361/// Result of artifact linting.
362#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
363pub struct LintResult {
364    /// Whether linting passed
365    pub passed: bool,
366    /// List of issues found
367    pub issues: Vec<LintIssue>,
368}
369
370impl LintResult {
371    /// Create a passing lint result.
372    pub fn passed() -> Self {
373        Self {
374            passed: true,
375            issues: Vec::new(),
376        }
377    }
378
379    /// Create a failing lint result.
380    pub fn failed(issues: Vec<LintIssue>) -> Self {
381        Self {
382            passed: false,
383            issues,
384        }
385    }
386
387    /// Add an issue to the result.
388    pub fn add_issue(&mut self, issue: LintIssue) {
389        self.passed = false;
390        self.issues.push(issue);
391    }
392}
393
394// ============================================================================
395// CHECKPOINT TYPES
396// ============================================================================
397
398/// State captured in a checkpoint.
399#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
400pub struct CheckpointState {
401    /// Serialized context snapshot
402    pub context_snapshot: RawContent,
403    /// Artifact IDs at checkpoint time
404    pub artifact_ids: Vec<ArtifactId>,
405    /// Note IDs at checkpoint time
406    pub note_ids: Vec<NoteId>,
407}
408
409/// A PCP checkpoint for recovery.
410#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
411pub struct PCPCheckpoint {
412    /// Unique identifier for this checkpoint
413    pub checkpoint_id: Uuid,
414    /// Scope this checkpoint belongs to
415    pub scope_id: ScopeId,
416    /// State captured in this checkpoint
417    pub state: CheckpointState,
418    /// When this checkpoint was created
419    pub created_at: Timestamp,
420}
421
422/// Result of recovery operation.
423#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
424pub struct RecoveryResult {
425    /// Whether recovery succeeded
426    pub success: bool,
427    /// Recovered scope (if successful)
428    pub recovered_scope: Option<Scope>,
429    /// Errors encountered during recovery
430    pub errors: Vec<String>,
431}
432
433impl RecoveryResult {
434    /// Create a successful recovery result.
435    pub fn success(scope: Scope) -> Self {
436        Self {
437            success: true,
438            recovered_scope: Some(scope),
439            errors: Vec::new(),
440        }
441    }
442
443    /// Create a failed recovery result.
444    pub fn failure(errors: Vec<String>) -> Self {
445        Self {
446            success: false,
447            recovered_scope: None,
448            errors,
449        }
450    }
451}
452
453// ============================================================================
454// CONTRADICTION + DOSAGE TYPES
455// ============================================================================
456
457/// Detected contradiction between artifacts.
458#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
459pub struct Contradiction {
460    /// First artifact in the contradiction
461    pub artifact_a: ArtifactId,
462    /// Second artifact in the contradiction
463    pub artifact_b: ArtifactId,
464    /// Similarity score that triggered detection
465    pub similarity_score: f32,
466    /// Description of the contradiction
467    pub description: String,
468}
469
470/// Result of applying dosage limits.
471#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
472pub struct DosageResult {
473    /// Whether limits were exceeded
474    pub exceeded: bool,
475    /// Artifacts that were pruned (if any)
476    pub pruned_artifacts: Vec<ArtifactId>,
477    /// Tokens that were trimmed
478    pub tokens_trimmed: i32,
479    /// Warning messages
480    pub warnings: Vec<String>,
481}
482
483impl DosageResult {
484    /// Create a result indicating no limits were exceeded.
485    pub fn within_limits() -> Self {
486        Self {
487            exceeded: false,
488            pruned_artifacts: Vec::new(),
489            tokens_trimmed: 0,
490            warnings: Vec::new(),
491        }
492    }
493
494    /// Create a result indicating limits were exceeded.
495    pub fn exceeded_limits() -> Self {
496        Self {
497            exceeded: true,
498            pruned_artifacts: Vec::new(),
499            tokens_trimmed: 0,
500            warnings: Vec::new(),
501        }
502    }
503
504    /// Add a pruned artifact.
505    pub fn add_pruned(&mut self, artifact_id: ArtifactId) {
506        self.pruned_artifacts.push(artifact_id);
507    }
508
509    /// Add a warning.
510    pub fn add_warning(&mut self, warning: String) {
511        self.warnings.push(warning);
512    }
513}
514
515#[cfg(test)]
516mod tests {
517    use super::*;
518    use crate::{ArtifactId, EntityIdType, NoteId, ScopeId};
519    use chrono::Utc;
520
521    fn make_test_pcp_config() -> PCPConfig {
522        PCPConfig {
523            context_dag: ContextDagConfig {
524                max_depth: 10,
525                prune_strategy: PruneStrategy::OldestFirst,
526            },
527            recovery: RecoveryConfig {
528                enabled: true,
529                frequency: RecoveryFrequency::OnScopeClose,
530                max_checkpoints: 5,
531            },
532            dosage: DosageConfig {
533                max_tokens_per_scope: 8000,
534                max_artifacts_per_scope: 100,
535                max_notes_per_trajectory: 500,
536            },
537            anti_sprawl: AntiSprawlConfig {
538                max_trajectory_depth: 5,
539                max_concurrent_scopes: 10,
540            },
541            grounding: GroundingConfig {
542                require_artifact_backing: false,
543                contradiction_threshold: 0.85,
544                conflict_resolution: ConflictResolution::LastWriteWins,
545            },
546            linting: LintingConfig {
547                max_artifact_size: 1024 * 1024,
548                min_confidence_threshold: 0.3,
549            },
550            staleness: StalenessConfig {
551                stale_hours: 24 * 30,
552            },
553        }
554    }
555
556    #[test]
557    fn test_pcp_config_valid() {
558        let config = make_test_pcp_config();
559        assert!(config.validate().is_ok());
560    }
561
562    #[test]
563    fn test_pcp_config_invalid_max_depth() {
564        let mut config = make_test_pcp_config();
565        config.context_dag.max_depth = 0;
566        assert!(config.validate().is_err());
567    }
568
569    #[test]
570    fn test_pcp_config_invalid_threshold() {
571        let mut config = make_test_pcp_config();
572        config.grounding.contradiction_threshold = 1.5;
573        assert!(config.validate().is_err());
574    }
575
576    #[test]
577    fn test_pcp_config_invalid_negative_checkpoints() {
578        let mut config = make_test_pcp_config();
579        config.recovery.max_checkpoints = -1;
580        assert!(config.validate().is_err());
581    }
582
583    #[test]
584    fn test_pcp_config_invalid_zero_tokens() {
585        let mut config = make_test_pcp_config();
586        config.dosage.max_tokens_per_scope = 0;
587        assert!(config.validate().is_err());
588    }
589
590    #[test]
591    fn test_pcp_config_invalid_zero_artifacts() {
592        let mut config = make_test_pcp_config();
593        config.dosage.max_artifacts_per_scope = 0;
594        assert!(config.validate().is_err());
595    }
596
597    #[test]
598    fn test_pcp_config_invalid_zero_notes() {
599        let mut config = make_test_pcp_config();
600        config.dosage.max_notes_per_trajectory = 0;
601        assert!(config.validate().is_err());
602    }
603
604    #[test]
605    fn test_pcp_config_invalid_zero_trajectory_depth() {
606        let mut config = make_test_pcp_config();
607        config.anti_sprawl.max_trajectory_depth = 0;
608        assert!(config.validate().is_err());
609    }
610
611    #[test]
612    fn test_pcp_config_invalid_zero_concurrent_scopes() {
613        let mut config = make_test_pcp_config();
614        config.anti_sprawl.max_concurrent_scopes = 0;
615        assert!(config.validate().is_err());
616    }
617
618    #[test]
619    fn test_pcp_config_invalid_negative_threshold() {
620        let mut config = make_test_pcp_config();
621        config.grounding.contradiction_threshold = -0.1;
622        assert!(config.validate().is_err());
623    }
624
625    #[test]
626    fn test_pcp_config_invalid_zero_artifact_size() {
627        let mut config = make_test_pcp_config();
628        config.linting.max_artifact_size = 0;
629        assert!(config.validate().is_err());
630    }
631
632    #[test]
633    fn test_pcp_config_invalid_confidence_threshold() {
634        let mut config = make_test_pcp_config();
635        config.linting.min_confidence_threshold = 1.5;
636        assert!(config.validate().is_err());
637    }
638
639    #[test]
640    fn test_pcp_config_invalid_zero_stale_hours() {
641        let mut config = make_test_pcp_config();
642        config.staleness.stale_hours = 0;
643        assert!(config.validate().is_err());
644    }
645
646    // Enum serde roundtrips
647    #[test]
648    fn test_prune_strategy_serde() {
649        for s in [
650            PruneStrategy::OldestFirst,
651            PruneStrategy::LowestRelevance,
652            PruneStrategy::Hybrid,
653        ] {
654            let json = serde_json::to_string(&s).unwrap();
655            let d: PruneStrategy = serde_json::from_str(&json).unwrap();
656            assert_eq!(d, s);
657        }
658    }
659
660    #[test]
661    fn test_recovery_frequency_serde() {
662        for f in [
663            RecoveryFrequency::OnScopeClose,
664            RecoveryFrequency::OnMutation,
665            RecoveryFrequency::Manual,
666        ] {
667            let json = serde_json::to_string(&f).unwrap();
668            let d: RecoveryFrequency = serde_json::from_str(&json).unwrap();
669            assert_eq!(d, f);
670        }
671    }
672
673    #[test]
674    fn test_severity_serde() {
675        for s in [Severity::Warning, Severity::Error, Severity::Critical] {
676            let json = serde_json::to_string(&s).unwrap();
677            let d: Severity = serde_json::from_str(&json).unwrap();
678            assert_eq!(d, s);
679        }
680    }
681
682    #[test]
683    fn test_issue_type_serde() {
684        for t in [
685            IssueType::StaleData,
686            IssueType::Contradiction,
687            IssueType::MissingReference,
688            IssueType::DosageExceeded,
689            IssueType::CircularDependency,
690        ] {
691            let json = serde_json::to_string(&t).unwrap();
692            let d: IssueType = serde_json::from_str(&json).unwrap();
693            assert_eq!(d, t);
694        }
695    }
696
697    #[test]
698    fn test_lint_issue_type_serde() {
699        for t in [
700            LintIssueType::TooLarge,
701            LintIssueType::Duplicate,
702            LintIssueType::MissingEmbedding,
703            LintIssueType::LowConfidence,
704            LintIssueType::SyntaxError,
705            LintIssueType::ReservedCharacterLeak,
706        ] {
707            let json = serde_json::to_string(&t).unwrap();
708            let d: LintIssueType = serde_json::from_str(&json).unwrap();
709            assert_eq!(d, t);
710        }
711    }
712
713    // ValidationResult tests
714    #[test]
715    fn test_validation_result_valid() {
716        let result = ValidationResult::valid();
717        assert!(result.valid);
718        assert!(result.issues.is_empty());
719        assert!(!result.has_critical());
720        assert!(!result.has_errors());
721    }
722
723    #[test]
724    fn test_validation_result_invalid() {
725        let result = ValidationResult::invalid(vec![ValidationIssue {
726            severity: Severity::Error,
727            issue_type: IssueType::StaleData,
728            message: "stale".into(),
729            entity_id: None,
730        }]);
731        assert!(!result.valid);
732        assert_eq!(result.issues.len(), 1);
733        assert!(result.has_errors());
734    }
735
736    #[test]
737    fn test_validation_result_add_warning_stays_valid() {
738        let mut result = ValidationResult::valid();
739        result.add_issue(ValidationIssue {
740            severity: Severity::Warning,
741            issue_type: IssueType::StaleData,
742            message: "warning".into(),
743            entity_id: None,
744        });
745        assert!(result.valid);
746        assert_eq!(result.issues.len(), 1);
747    }
748
749    #[test]
750    fn test_validation_result_add_error_invalidates() {
751        let mut result = ValidationResult::valid();
752        result.add_issue(ValidationIssue {
753            severity: Severity::Error,
754            issue_type: IssueType::Contradiction,
755            message: "error".into(),
756            entity_id: None,
757        });
758        assert!(!result.valid);
759    }
760
761    #[test]
762    fn test_validation_result_add_critical_invalidates() {
763        let mut result = ValidationResult::valid();
764        result.add_issue(ValidationIssue {
765            severity: Severity::Critical,
766            issue_type: IssueType::CircularDependency,
767            message: "critical".into(),
768            entity_id: Some(Uuid::now_v7()),
769        });
770        assert!(!result.valid);
771        assert!(result.has_critical());
772    }
773
774    #[test]
775    fn test_validation_result_issues_of_type() {
776        let mut result = ValidationResult::valid();
777        result.add_issue(ValidationIssue {
778            severity: Severity::Warning,
779            issue_type: IssueType::StaleData,
780            message: "stale1".into(),
781            entity_id: None,
782        });
783        result.add_issue(ValidationIssue {
784            severity: Severity::Warning,
785            issue_type: IssueType::MissingReference,
786            message: "missing".into(),
787            entity_id: None,
788        });
789        result.add_issue(ValidationIssue {
790            severity: Severity::Warning,
791            issue_type: IssueType::StaleData,
792            message: "stale2".into(),
793            entity_id: None,
794        });
795        let stale = result.issues_of_type(IssueType::StaleData);
796        assert_eq!(stale.len(), 2);
797    }
798
799    // LintResult tests
800    #[test]
801    fn test_lint_result_passed() {
802        let result = LintResult::passed();
803        assert!(result.passed);
804        assert!(result.issues.is_empty());
805    }
806
807    #[test]
808    fn test_lint_result_failed() {
809        let result = LintResult::failed(vec![LintIssue {
810            issue_type: LintIssueType::TooLarge,
811            message: "too big".into(),
812            artifact_id: ArtifactId::now_v7(),
813        }]);
814        assert!(!result.passed);
815        assert_eq!(result.issues.len(), 1);
816    }
817
818    #[test]
819    fn test_lint_result_add_issue() {
820        let mut result = LintResult::passed();
821        assert!(result.passed);
822        result.add_issue(LintIssue {
823            issue_type: LintIssueType::LowConfidence,
824            message: "low".into(),
825            artifact_id: ArtifactId::now_v7(),
826        });
827        assert!(!result.passed);
828        assert_eq!(result.issues.len(), 1);
829    }
830
831    // RecoveryResult tests
832    #[test]
833    fn test_recovery_result_success() {
834        let scope = crate::Scope {
835            scope_id: ScopeId::now_v7(),
836            trajectory_id: crate::TrajectoryId::now_v7(),
837            parent_scope_id: None,
838            name: "Test Scope".to_string(),
839            purpose: Some("Testing".to_string()),
840            is_active: true,
841            created_at: Utc::now(),
842            closed_at: None,
843            checkpoint: None,
844            token_budget: 8000,
845            tokens_used: 0,
846            metadata: None,
847        };
848        let result = RecoveryResult::success(scope.clone());
849        assert!(result.success);
850        assert_eq!(result.recovered_scope.unwrap().scope_id, scope.scope_id);
851        assert!(result.errors.is_empty());
852    }
853
854    #[test]
855    fn test_recovery_result_failure() {
856        let result = RecoveryResult::failure(vec!["disk full".into()]);
857        assert!(!result.success);
858        assert!(result.recovered_scope.is_none());
859        assert_eq!(result.errors.len(), 1);
860    }
861
862    // DosageResult tests
863    #[test]
864    fn test_dosage_result_within_limits() {
865        let result = DosageResult::within_limits();
866        assert!(!result.exceeded);
867        assert!(result.pruned_artifacts.is_empty());
868        assert!(result.warnings.is_empty());
869    }
870
871    #[test]
872    fn test_dosage_result_exceeded() {
873        let mut result = DosageResult::exceeded_limits();
874        assert!(result.exceeded);
875        let id = ArtifactId::now_v7();
876        result.add_pruned(id);
877        result.add_warning("over budget".into());
878        assert_eq!(result.pruned_artifacts.len(), 1);
879        assert_eq!(result.warnings.len(), 1);
880    }
881
882    // Contradiction serde
883    #[test]
884    fn test_contradiction_serde() {
885        let c = Contradiction {
886            artifact_a: ArtifactId::now_v7(),
887            artifact_b: ArtifactId::now_v7(),
888            similarity_score: 0.95,
889            description: "Contradictory".into(),
890        };
891        let json = serde_json::to_string(&c).unwrap();
892        let d: Contradiction = serde_json::from_str(&json).unwrap();
893        assert_eq!(d.similarity_score, 0.95);
894    }
895
896    // CheckpointState serde
897    #[test]
898    fn test_checkpoint_state_serde() {
899        let state = CheckpointState {
900            context_snapshot: b"snapshot".to_vec(),
901            artifact_ids: vec![ArtifactId::now_v7()],
902            note_ids: vec![NoteId::now_v7()],
903        };
904        let json = serde_json::to_string(&state).unwrap();
905        let d: CheckpointState = serde_json::from_str(&json).unwrap();
906        assert_eq!(d.artifact_ids.len(), 1);
907        assert_eq!(d.note_ids.len(), 1);
908    }
909}