cellstate_core/
context_validator.rs

1// Copyright 2024-2026 CELLSTATE Contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Context Validator — runtime validation, checkpointing, and dosage enforcement.
5//!
6//! Formerly `PCPRuntime` when PCP validation logic lived in a separate crate.
7//! Renamed to `ContextValidator` to better describe its role.
8
9use crate::{
10    lint::lint_markdown_semantics,
11    pcp::{
12        CheckpointState, Contradiction, DosageResult, IssueType, LintIssue, LintResult,
13        PCPCheckpoint, PCPConfig, RecoveryResult, Severity, ValidationIssue, ValidationResult,
14    },
15    AbstractionLevel, Artifact, CellstateError, CellstateResult, EntityIdType, NoteId, Scope,
16    ScopeId, SummarizationPolicy, SummarizationPolicyId, SummarizationTrigger, ValidationError,
17};
18use chrono::Utc;
19use uuid::Uuid;
20
21/// Backward-compat alias.
22pub type PCPRuntime = ContextValidator;
23
24/// The main validation and checkpoint engine (formerly `PCPRuntime`).
25#[derive(Debug, Clone)]
26pub struct ContextValidator {
27    config: PCPConfig,
28    checkpoints: Vec<PCPCheckpoint>,
29}
30
31impl ContextValidator {
32    /// Create a new context validator with the given configuration.
33    pub fn new(config: PCPConfig) -> CellstateResult<Self> {
34        config.validate()?;
35        Ok(Self {
36            config,
37            checkpoints: Vec::new(),
38        })
39    }
40
41    /// Get the configuration.
42    pub fn config(&self) -> &PCPConfig {
43        &self.config
44    }
45}
46
47// ============================================================================
48// VALIDATION
49// ============================================================================
50
51impl ContextValidator {
52    /// Validate context integrity.
53    pub fn validate_context_integrity(
54        &self,
55        scope: &Scope,
56        artifacts: &[Artifact],
57        current_tokens: i32,
58    ) -> CellstateResult<ValidationResult> {
59        let mut result = ValidationResult::valid();
60        self.check_dosage_limits(&mut result, artifacts.len() as i32, current_tokens);
61        self.check_stale_scope(&mut result, scope);
62        for artifact in artifacts {
63            self.check_artifact_integrity(&mut result, artifact);
64        }
65        Ok(result)
66    }
67
68    fn check_dosage_limits(
69        &self,
70        result: &mut ValidationResult,
71        artifact_count: i32,
72        token_count: i32,
73    ) {
74        if token_count > self.config.dosage.max_tokens_per_scope {
75            result.add_issue(ValidationIssue {
76                severity: Severity::Warning,
77                issue_type: IssueType::DosageExceeded,
78                message: format!(
79                    "Token count ({}) exceeds limit ({})",
80                    token_count, self.config.dosage.max_tokens_per_scope
81                ),
82                entity_id: None,
83            });
84        }
85        if artifact_count > self.config.dosage.max_artifacts_per_scope {
86            result.add_issue(ValidationIssue {
87                severity: Severity::Warning,
88                issue_type: IssueType::DosageExceeded,
89                message: format!(
90                    "Artifact count ({}) exceeds limit ({})",
91                    artifact_count, self.config.dosage.max_artifacts_per_scope
92                ),
93                entity_id: None,
94            });
95        }
96    }
97
98    fn check_stale_scope(&self, result: &mut ValidationResult, scope: &Scope) {
99        let now = Utc::now();
100        let age = now.signed_duration_since(scope.created_at);
101        if age.num_hours() > self.config.staleness.stale_hours && scope.is_active {
102            result.add_issue(ValidationIssue {
103                severity: Severity::Warning,
104                issue_type: IssueType::StaleData,
105                message: format!(
106                    "Scope {} is {} hours old and still active (threshold: {} hours)",
107                    scope.scope_id,
108                    age.num_hours(),
109                    self.config.staleness.stale_hours
110                ),
111                entity_id: Some(scope.scope_id.as_uuid()),
112            });
113        }
114    }
115
116    fn check_artifact_integrity(&self, result: &mut ValidationResult, artifact: &Artifact) {
117        if self.config.grounding.require_artifact_backing && artifact.embedding.is_none() {
118            result.add_issue(ValidationIssue {
119                severity: Severity::Warning,
120                issue_type: IssueType::MissingReference,
121                message: format!(
122                    "Artifact {} is missing embedding (required for grounding)",
123                    artifact.artifact_id
124                ),
125                entity_id: Some(artifact.artifact_id.as_uuid()),
126            });
127        }
128        if let Some(confidence) = artifact.provenance.confidence {
129            if confidence < self.config.linting.min_confidence_threshold {
130                result.add_issue(ValidationIssue {
131                    severity: Severity::Warning,
132                    issue_type: IssueType::MissingReference,
133                    message: format!(
134                        "Artifact {} has low confidence ({})",
135                        artifact.artifact_id, confidence
136                    ),
137                    entity_id: Some(artifact.artifact_id.as_uuid()),
138                });
139            }
140        }
141    }
142}
143
144// ============================================================================
145// CONTRADICTION DETECTION
146// ============================================================================
147
148impl ContextValidator {
149    /// Detect contradictions between artifacts using embedding similarity.
150    pub fn detect_contradictions(
151        &self,
152        artifacts: &[Artifact],
153    ) -> CellstateResult<Vec<Contradiction>> {
154        let mut contradictions = Vec::new();
155        for i in 0..artifacts.len() {
156            for j in (i + 1)..artifacts.len() {
157                let artifact_a = &artifacts[i];
158                let artifact_b = &artifacts[j];
159                let (embedding_a, embedding_b) =
160                    match (&artifact_a.embedding, &artifact_b.embedding) {
161                        (Some(a), Some(b)) => (a, b),
162                        _ => continue,
163                    };
164                let similarity = match embedding_a.cosine_similarity(embedding_b) {
165                    Ok(s) => s,
166                    Err(_) => continue,
167                };
168                if similarity >= self.config.grounding.contradiction_threshold
169                    && artifact_a.content != artifact_b.content
170                {
171                    contradictions.push(Contradiction {
172                        artifact_a: artifact_a.artifact_id,
173                        artifact_b: artifact_b.artifact_id,
174                        similarity_score: similarity,
175                        description: format!(
176                            "Artifacts have high similarity ({:.2}) but different content",
177                            similarity
178                        ),
179                    });
180                }
181            }
182        }
183        Ok(contradictions)
184    }
185}
186
187// ============================================================================
188// DOSAGE LIMITS
189// ============================================================================
190
191impl ContextValidator {
192    /// Apply dosage limits to artifacts and tokens.
193    pub fn apply_dosage_limits(
194        &self,
195        artifacts: &[Artifact],
196        current_tokens: i32,
197    ) -> CellstateResult<DosageResult> {
198        let mut result = DosageResult::within_limits();
199        if current_tokens > self.config.dosage.max_tokens_per_scope {
200            result.exceeded = true;
201            result.tokens_trimmed = current_tokens - self.config.dosage.max_tokens_per_scope;
202            result.add_warning(format!(
203                "Token limit exceeded: {} > {}. Need to trim {} tokens.",
204                current_tokens, self.config.dosage.max_tokens_per_scope, result.tokens_trimmed
205            ));
206        }
207        let artifact_count = artifacts.len() as i32;
208        if artifact_count > self.config.dosage.max_artifacts_per_scope {
209            result.exceeded = true;
210            let excess = artifact_count - self.config.dosage.max_artifacts_per_scope;
211            let start_prune = artifacts.len() - excess as usize;
212            for artifact in &artifacts[start_prune..] {
213                result.add_pruned(artifact.artifact_id);
214            }
215            result.add_warning(format!(
216                "Artifact limit exceeded: {} > {}. Pruning {} artifacts.",
217                artifact_count, self.config.dosage.max_artifacts_per_scope, excess
218            ));
219        }
220        Ok(result)
221    }
222
223    /// Check if adding more content would exceed dosage limits.
224    pub fn would_exceed_limits(
225        &self,
226        current_artifacts: i32,
227        current_tokens: i32,
228        additional_artifacts: i32,
229        additional_tokens: i32,
230    ) -> bool {
231        let new_artifacts = current_artifacts + additional_artifacts;
232        let new_tokens = current_tokens + additional_tokens;
233        new_artifacts > self.config.dosage.max_artifacts_per_scope
234            || new_tokens > self.config.dosage.max_tokens_per_scope
235    }
236}
237
238// ============================================================================
239// ARTIFACT LINTING
240// ============================================================================
241
242impl ContextValidator {
243    /// Lint an artifact for quality issues.
244    pub fn lint_artifact(
245        &self,
246        artifact: &Artifact,
247        existing_artifacts: &[Artifact],
248    ) -> CellstateResult<LintResult> {
249        let mut result = LintResult::passed();
250        self.check_artifact_size(&mut result, artifact);
251        self.check_artifact_duplicates(&mut result, artifact, existing_artifacts);
252        self.check_artifact_embedding(&mut result, artifact);
253        self.check_artifact_confidence(&mut result, artifact);
254        self.check_artifact_semantics(&mut result, artifact);
255        Ok(result)
256    }
257
258    fn check_artifact_size(&self, result: &mut LintResult, artifact: &Artifact) {
259        let max_size = self.config.linting.max_artifact_size;
260        if artifact.content.len() > max_size {
261            result.add_issue(LintIssue {
262                issue_type: crate::pcp::LintIssueType::TooLarge,
263                message: format!(
264                    "Artifact content size ({} bytes) exceeds maximum ({} bytes)",
265                    artifact.content.len(),
266                    max_size
267                ),
268                artifact_id: artifact.artifact_id,
269            });
270        }
271    }
272
273    fn check_artifact_duplicates(
274        &self,
275        result: &mut LintResult,
276        artifact: &Artifact,
277        existing_artifacts: &[Artifact],
278    ) {
279        for existing in existing_artifacts {
280            if existing.artifact_id == artifact.artifact_id {
281                continue;
282            }
283            if existing.content_hash == artifact.content_hash {
284                result.add_issue(LintIssue {
285                    issue_type: crate::pcp::LintIssueType::Duplicate,
286                    message: format!(
287                        "Artifact is a duplicate of existing artifact {}",
288                        existing.artifact_id
289                    ),
290                    artifact_id: artifact.artifact_id,
291                });
292                break;
293            }
294        }
295    }
296
297    fn check_artifact_embedding(&self, result: &mut LintResult, artifact: &Artifact) {
298        if self.config.grounding.require_artifact_backing && artifact.embedding.is_none() {
299            result.add_issue(LintIssue {
300                issue_type: crate::pcp::LintIssueType::MissingEmbedding,
301                message: "Artifact is missing embedding (required for grounding)".to_string(),
302                artifact_id: artifact.artifact_id,
303            });
304        }
305    }
306
307    fn check_artifact_confidence(&self, result: &mut LintResult, artifact: &Artifact) {
308        let min_threshold = self.config.linting.min_confidence_threshold;
309        if let Some(confidence) = artifact.provenance.confidence {
310            if confidence < min_threshold {
311                result.add_issue(LintIssue {
312                    issue_type: crate::pcp::LintIssueType::LowConfidence,
313                    message: format!(
314                        "Artifact confidence ({:.2}) is below threshold ({:.2})",
315                        confidence, min_threshold
316                    ),
317                    artifact_id: artifact.artifact_id,
318                });
319            }
320        }
321    }
322
323    fn check_artifact_semantics(&self, result: &mut LintResult, artifact: &Artifact) {
324        for issue in lint_markdown_semantics(&artifact.content) {
325            result.add_issue(LintIssue {
326                issue_type: issue.issue_type,
327                message: format!("line {}: {}", issue.line, issue.message),
328                artifact_id: artifact.artifact_id,
329            });
330        }
331    }
332
333    /// Lint multiple artifacts at once.
334    pub fn lint_artifacts(&self, artifacts: &[Artifact]) -> CellstateResult<LintResult> {
335        let mut combined_result = LintResult::passed();
336        for artifact in artifacts {
337            let result = self.lint_artifact(artifact, artifacts)?;
338            for issue in result.issues {
339                combined_result.add_issue(issue);
340            }
341        }
342        Ok(combined_result)
343    }
344}
345
346// ============================================================================
347// CHECKPOINT CREATION AND RECOVERY
348// ============================================================================
349
350impl ContextValidator {
351    /// Create a checkpoint for a scope.
352    pub fn create_checkpoint(
353        &mut self,
354        scope: &Scope,
355        artifacts: &[Artifact],
356        note_ids: &[NoteId],
357    ) -> CellstateResult<PCPCheckpoint> {
358        if !self.config.recovery.enabled {
359            return Err(CellstateError::Validation(
360                ValidationError::ConstraintViolation {
361                    constraint: "recovery.enabled".to_string(),
362                    reason: "Recovery is disabled in configuration".to_string(),
363                },
364            ));
365        }
366        let context_snapshot = serde_json::to_vec(scope).map_err(|e| {
367            CellstateError::Validation(ValidationError::InvalidValue {
368                field: "scope".to_string(),
369                reason: format!("Failed to serialize scope: {}", e),
370            })
371        })?;
372        let artifact_ids = artifacts.iter().map(|a| a.artifact_id).collect();
373        let state = CheckpointState {
374            context_snapshot,
375            artifact_ids,
376            note_ids: note_ids.to_vec(),
377        };
378        let checkpoint = PCPCheckpoint {
379            checkpoint_id: Uuid::now_v7(),
380            scope_id: scope.scope_id,
381            state,
382            created_at: Utc::now(),
383        };
384        self.checkpoints.push(checkpoint.clone());
385        self.enforce_checkpoint_limit();
386        Ok(checkpoint)
387    }
388
389    fn enforce_checkpoint_limit(&mut self) {
390        let max = self.config.recovery.max_checkpoints as usize;
391        if self.checkpoints.len() > max {
392            self.checkpoints
393                .sort_by_key(|checkpoint| checkpoint.created_at);
394            let excess = self.checkpoints.len() - max;
395            self.checkpoints.drain(0..excess);
396        }
397    }
398
399    /// Recover a scope from a checkpoint.
400    pub fn recover_from_checkpoint(
401        &self,
402        checkpoint: &PCPCheckpoint,
403    ) -> CellstateResult<RecoveryResult> {
404        if !self.config.recovery.enabled {
405            return Ok(RecoveryResult::failure(vec![
406                "Recovery is disabled in configuration".to_string(),
407            ]));
408        }
409        let scope: Scope = match serde_json::from_slice(&checkpoint.state.context_snapshot) {
410            Ok(s) => s,
411            Err(e) => {
412                return Ok(RecoveryResult::failure(vec![format!(
413                    "Failed to deserialize scope: {}",
414                    e
415                )]));
416            }
417        };
418        Ok(RecoveryResult::success(scope))
419    }
420
421    /// Get the latest checkpoint for a scope.
422    pub fn get_latest_checkpoint(&self, scope_id: ScopeId) -> Option<&PCPCheckpoint> {
423        self.checkpoints
424            .iter()
425            .filter(|c| c.scope_id == scope_id)
426            .max_by_key(|c| c.created_at)
427    }
428
429    /// Get all checkpoints for a scope.
430    pub fn get_checkpoints_for_scope(&self, scope_id: ScopeId) -> Vec<&PCPCheckpoint> {
431        self.checkpoints
432            .iter()
433            .filter(|c| c.scope_id == scope_id)
434            .collect()
435    }
436
437    /// Delete a checkpoint.
438    pub fn delete_checkpoint(&mut self, checkpoint_id: Uuid) -> bool {
439        let initial_len = self.checkpoints.len();
440        self.checkpoints
441            .retain(|c| c.checkpoint_id != checkpoint_id);
442        self.checkpoints.len() < initial_len
443    }
444
445    /// Clear all checkpoints for a scope.
446    pub fn clear_checkpoints_for_scope(&mut self, scope_id: ScopeId) -> usize {
447        let initial_len = self.checkpoints.len();
448        self.checkpoints.retain(|c| c.scope_id != scope_id);
449        initial_len - self.checkpoints.len()
450    }
451}
452
453// ============================================================================
454// SUMMARIZATION TRIGGER CHECKING
455// ============================================================================
456
457impl ContextValidator {
458    /// Check which summarization triggers should fire based on current scope state.
459    pub fn check_summarization_triggers(
460        &self,
461        scope: &Scope,
462        turn_count: i32,
463        artifact_count: i32,
464        policies: &[SummarizationPolicy],
465    ) -> CellstateResult<Vec<(SummarizationPolicyId, SummarizationTrigger)>> {
466        let mut triggered = Vec::new();
467        let token_usage_percent = if scope.token_budget > 0 {
468            ((scope.tokens_used as f32 / scope.token_budget as f32) * 100.0) as u8
469        } else {
470            0
471        };
472        for policy in policies {
473            for trigger in &policy.triggers {
474                let should_fire = match trigger {
475                    SummarizationTrigger::DosageThreshold { percent } => {
476                        token_usage_percent >= *percent
477                    }
478                    SummarizationTrigger::ScopeClose => !scope.is_active,
479                    SummarizationTrigger::TurnCount { count } => {
480                        turn_count >= *count && turn_count % *count == 0
481                    }
482                    SummarizationTrigger::ArtifactCount { count } => {
483                        artifact_count >= *count && artifact_count % *count == 0
484                    }
485                    SummarizationTrigger::Manual => false,
486                };
487                if should_fire {
488                    triggered.push((policy.summarization_policy_id, *trigger));
489                }
490            }
491        }
492        Ok(triggered)
493    }
494
495    /// Calculate what abstraction level transition should occur for a policy.
496    pub fn get_abstraction_transition(
497        &self,
498        policy: &SummarizationPolicy,
499    ) -> (AbstractionLevel, AbstractionLevel) {
500        (policy.source_level, policy.target_level)
501    }
502
503    /// Validate an abstraction level transition.
504    pub fn validate_abstraction_transition(
505        &self,
506        source: AbstractionLevel,
507        target: AbstractionLevel,
508    ) -> bool {
509        matches!(
510            (source, target),
511            (AbstractionLevel::Raw, AbstractionLevel::Summary)
512                | (AbstractionLevel::Raw, AbstractionLevel::Principle)
513                | (AbstractionLevel::Summary, AbstractionLevel::Principle)
514        )
515    }
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521    use crate::pcp::*;
522    use crate::{
523        ArtifactType, ConflictResolution, ExtractionMethod, Provenance, ScopeId, TrajectoryId, TTL,
524    };
525
526    fn make_test_pcp_config() -> PCPConfig {
527        PCPConfig {
528            context_dag: ContextDagConfig {
529                max_depth: 10,
530                prune_strategy: PruneStrategy::OldestFirst,
531            },
532            recovery: RecoveryConfig {
533                enabled: true,
534                frequency: RecoveryFrequency::OnScopeClose,
535                max_checkpoints: 5,
536            },
537            dosage: DosageConfig {
538                max_tokens_per_scope: 8000,
539                max_artifacts_per_scope: 100,
540                max_notes_per_trajectory: 500,
541            },
542            anti_sprawl: AntiSprawlConfig {
543                max_trajectory_depth: 5,
544                max_concurrent_scopes: 10,
545            },
546            grounding: GroundingConfig {
547                require_artifact_backing: false,
548                contradiction_threshold: 0.85,
549                conflict_resolution: ConflictResolution::LastWriteWins,
550            },
551            linting: LintingConfig {
552                max_artifact_size: 1024 * 1024,
553                min_confidence_threshold: 0.3,
554            },
555            staleness: StalenessConfig {
556                stale_hours: 24 * 30,
557            },
558        }
559    }
560
561    fn make_test_scope() -> Scope {
562        Scope {
563            scope_id: ScopeId::now_v7(),
564            trajectory_id: TrajectoryId::now_v7(),
565            parent_scope_id: None,
566            name: "Test Scope".to_string(),
567            purpose: Some("Testing".to_string()),
568            is_active: true,
569            created_at: Utc::now(),
570            closed_at: None,
571            checkpoint: None,
572            token_budget: 8000,
573            tokens_used: 0,
574            metadata: None,
575        }
576    }
577
578    fn make_test_artifact(content: &str) -> Artifact {
579        Artifact {
580            artifact_id: crate::ArtifactId::now_v7(),
581            trajectory_id: TrajectoryId::now_v7(),
582            scope_id: ScopeId::now_v7(),
583            artifact_type: ArtifactType::Fact,
584            name: "Test Artifact".to_string(),
585            content: content.to_string(),
586            content_hash: crate::compute_content_hash(content.as_bytes()),
587            embedding: None,
588            provenance: Provenance {
589                source_turn: 1,
590                extraction_method: ExtractionMethod::Explicit,
591                confidence: Some(0.9),
592            },
593            ttl: TTL::Persistent,
594            created_at: Utc::now(),
595            updated_at: Utc::now(),
596            superseded_by: None,
597            metadata: None,
598        }
599    }
600
601    #[test]
602    fn test_context_validator_new() {
603        let config = make_test_pcp_config();
604        let runtime =
605            ContextValidator::new(config).expect("ContextValidator creation should succeed");
606        assert!(runtime.config().recovery.enabled);
607    }
608
609    #[test]
610    fn test_validate_context_integrity() {
611        let config = make_test_pcp_config();
612        let runtime =
613            ContextValidator::new(config).expect("ContextValidator creation should succeed");
614        let scope = make_test_scope();
615        let artifacts = vec![make_test_artifact("test content")];
616        let result = runtime
617            .validate_context_integrity(&scope, &artifacts, 1000)
618            .expect("validate_context_integrity should succeed");
619        assert!(result.valid);
620    }
621
622    #[test]
623    fn test_validate_context_dosage_exceeded() {
624        let mut config = make_test_pcp_config();
625        config.dosage.max_tokens_per_scope = 100;
626        let runtime =
627            ContextValidator::new(config).expect("ContextValidator creation should succeed");
628        let scope = make_test_scope();
629        let result = runtime
630            .validate_context_integrity(&scope, &[], 1000)
631            .expect("validate_context_integrity should succeed");
632        assert!(result
633            .issues
634            .iter()
635            .any(|i| i.issue_type == IssueType::DosageExceeded));
636    }
637
638    #[test]
639    fn test_create_checkpoint() {
640        let config = make_test_pcp_config();
641        let mut runtime =
642            ContextValidator::new(config).expect("ContextValidator creation should succeed");
643        let scope = make_test_scope();
644        let artifacts = vec![make_test_artifact("test")];
645        let note_ids = vec![crate::NoteId::now_v7()];
646        let checkpoint = runtime
647            .create_checkpoint(&scope, &artifacts, &note_ids)
648            .expect("create_checkpoint should succeed");
649        assert_eq!(checkpoint.scope_id, scope.scope_id);
650        assert_eq!(checkpoint.state.artifact_ids.len(), 1);
651        assert_eq!(checkpoint.state.note_ids.len(), 1);
652    }
653
654    #[test]
655    fn test_recover_from_checkpoint() {
656        let config = make_test_pcp_config();
657        let mut runtime =
658            ContextValidator::new(config).expect("ContextValidator creation should succeed");
659        let scope = make_test_scope();
660        let checkpoint = runtime
661            .create_checkpoint(&scope, &[], &[])
662            .expect("create_checkpoint should succeed");
663        let result = runtime
664            .recover_from_checkpoint(&checkpoint)
665            .expect("recover_from_checkpoint should succeed");
666        assert!(result.success);
667        assert_eq!(
668            result.recovered_scope.expect("recovered_scope").scope_id,
669            scope.scope_id
670        );
671    }
672
673    #[test]
674    fn test_lint_artifact_passes() {
675        let config = make_test_pcp_config();
676        let runtime =
677            ContextValidator::new(config).expect("ContextValidator creation should succeed");
678        let artifact = make_test_artifact("test content");
679        let result = runtime
680            .lint_artifact(&artifact, &[])
681            .expect("lint_artifact should succeed");
682        assert!(result.passed);
683    }
684
685    #[test]
686    fn test_lint_artifact_duplicate() {
687        let config = make_test_pcp_config();
688        let runtime =
689            ContextValidator::new(config).expect("ContextValidator creation should succeed");
690        let artifact1 = make_test_artifact("same content");
691        let mut artifact2 = make_test_artifact("same content");
692        artifact2.artifact_id = crate::ArtifactId::now_v7();
693        let result = runtime
694            .lint_artifact(&artifact2, &[artifact1])
695            .expect("lint_artifact should succeed");
696        assert!(!result.passed);
697        assert!(result
698            .issues
699            .iter()
700            .any(|i| i.issue_type == LintIssueType::Duplicate));
701    }
702
703    #[test]
704    fn test_lint_artifact_reserved_character_leak() {
705        let config = make_test_pcp_config();
706        let runtime =
707            ContextValidator::new(config).expect("ContextValidator creation should succeed");
708        let artifact = make_test_artifact("Please import @my_skill before execution.");
709        let result = runtime
710            .lint_artifact(&artifact, &[])
711            .expect("lint_artifact should succeed");
712        assert!(!result.passed);
713        assert!(result
714            .issues
715            .iter()
716            .any(|i| i.issue_type == LintIssueType::ReservedCharacterLeak));
717    }
718
719    #[test]
720    fn test_lint_artifact_unterminated_fence() {
721        let config = make_test_pcp_config();
722        let runtime =
723            ContextValidator::new(config).expect("ContextValidator creation should succeed");
724        let artifact = make_test_artifact("```markdown\n# title\nstill open");
725        let result = runtime
726            .lint_artifact(&artifact, &[])
727            .expect("lint_artifact should succeed");
728        assert!(!result.passed);
729        assert!(result
730            .issues
731            .iter()
732            .any(|i| i.issue_type == LintIssueType::SyntaxError));
733    }
734
735    #[test]
736    fn test_get_latest_checkpoint_none() {
737        let config = make_test_pcp_config();
738        let runtime = ContextValidator::new(config).unwrap();
739        assert!(runtime.get_latest_checkpoint(ScopeId::now_v7()).is_none());
740    }
741
742    #[test]
743    fn test_get_checkpoints_for_scope_empty() {
744        let config = make_test_pcp_config();
745        let runtime = ContextValidator::new(config).unwrap();
746        assert!(runtime
747            .get_checkpoints_for_scope(ScopeId::now_v7())
748            .is_empty());
749    }
750
751    #[test]
752    fn test_delete_checkpoint() {
753        let config = make_test_pcp_config();
754        let mut runtime = ContextValidator::new(config).unwrap();
755        let scope = make_test_scope();
756        let cp = runtime.create_checkpoint(&scope, &[], &[]).unwrap();
757        assert!(runtime.delete_checkpoint(cp.checkpoint_id));
758        assert!(!runtime.delete_checkpoint(cp.checkpoint_id));
759    }
760
761    #[test]
762    fn test_clear_checkpoints_for_scope() {
763        let config = make_test_pcp_config();
764        let mut runtime = ContextValidator::new(config).unwrap();
765        let scope = make_test_scope();
766        runtime.create_checkpoint(&scope, &[], &[]).unwrap();
767        runtime.create_checkpoint(&scope, &[], &[]).unwrap();
768        let cleared = runtime.clear_checkpoints_for_scope(scope.scope_id);
769        assert_eq!(cleared, 2);
770        assert!(runtime.get_checkpoints_for_scope(scope.scope_id).is_empty());
771    }
772
773    #[test]
774    fn test_checkpoint_limit_enforcement() {
775        let mut config = make_test_pcp_config();
776        config.recovery.max_checkpoints = 2;
777        let mut runtime = ContextValidator::new(config).unwrap();
778        let scope = make_test_scope();
779        runtime.create_checkpoint(&scope, &[], &[]).unwrap();
780        runtime.create_checkpoint(&scope, &[], &[]).unwrap();
781        runtime.create_checkpoint(&scope, &[], &[]).unwrap();
782        let cps = runtime.get_checkpoints_for_scope(scope.scope_id);
783        assert!(cps.len() <= 2);
784    }
785
786    // PCPRuntime alias test
787    #[test]
788    fn test_pcp_runtime_alias() {
789        let config = make_test_pcp_config();
790        let _runtime: PCPRuntime = PCPRuntime::new(config).unwrap();
791    }
792}