1use 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
31pub struct MemoryCommit {
32 pub commit_id: Uuid,
34 pub trajectory_id: TrajectoryId,
36 pub scope_id: ScopeId,
38 pub agent_id: Option<AgentId>,
40
41 pub query: String,
43 pub response: String,
45
46 pub mode: String,
48 pub reasoning_trace: Option<serde_json::Value>,
50
51 pub rag_contributed: bool,
53 pub artifacts_referenced: Vec<ArtifactId>,
55 pub notes_referenced: Vec<NoteId>,
57
58 pub tools_invoked: Vec<String>,
60
61 pub tokens_input: i64,
63 pub tokens_output: i64,
65 pub estimated_cost: Option<f64>,
67
68 pub created_at: Timestamp,
70}
71
72impl MemoryCommit {
73 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[tracing::instrument(skip_all)]
161 pub fn total_tokens(&self) -> i64 {
162 self.tokens_input + self.tokens_output
163 }
164}
165
166#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
172pub struct DecisionRecall {
173 pub commit_id: Uuid,
175 pub query: String,
177 pub decision_summary: String,
179 pub mode: String,
181 pub created_at: Timestamp,
183}
184
185#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
187pub struct ScopeHistory {
188 pub scope_id: ScopeId,
190 pub interaction_count: i32,
192 pub total_tokens: i64,
194 pub total_cost: f64,
196 pub commits: Vec<MemoryCommit>,
198}
199
200#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
202pub struct MemoryStats {
203 pub total_interactions: i64,
205 pub total_tokens: i64,
207 pub total_cost: f64,
209 pub unique_scopes: i64,
211 pub by_mode: HashMap<String, i64>,
213 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#[derive(Debug, Clone)]
233pub struct RecallService {
234 config: CellstateConfig,
235 commits: Vec<MemoryCommit>,
237}
238
239impl RecallService {
240 #[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 #[tracing::instrument(skip_all)]
252 pub fn config(&self) -> &CellstateConfig {
253 &self.config
254 }
255
256 #[tracing::instrument(skip_all)]
258 pub fn add_commit(&mut self, commit: MemoryCommit) {
259 self.commits.push(commit);
260 }
261
262 #[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 results.sort_by_key(|commit| std::cmp::Reverse(commit.created_at));
292
293 results.truncate(limit as usize);
295
296 Ok(results)
297 }
298
299 #[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 results.sort_by_key(|commit| std::cmp::Reverse(commit.created_at));
327
328 results.truncate(limit as usize);
330
331 Ok(results)
332 }
333
334 #[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 let mode_match = c.mode == "deep_work" || c.mode == "super_think";
355 let has_decision = contains_decision_keywords(&c.response);
356
357 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 results.sort_by_key(|commit| std::cmp::Reverse(commit.created_at));
374
375 results.truncate(limit as usize);
377
378 Ok(results)
379 }
380
381 #[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 #[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 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 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
464const 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
482fn 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#[tracing::instrument(skip_all)]
499pub fn extract_decision(response: &str) -> String {
500 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 extract_first_sentence(response)
525}
526
527fn extract_first_sentence(text: &str) -> String {
529 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 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 if char_count >= max_chars {
552 format!("{}...", text[..last_valid_pos].trim())
553 } else {
554 text.to_string()
555 }
556}
557
558#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
564#[serde(rename_all = "snake_case")]
565pub enum PruneStrategy {
566 OldestFirst,
568 LowestRelevance,
570 Hybrid,
572}
573
574#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
576#[serde(rename_all = "snake_case")]
577pub enum RecoveryFrequency {
578 OnScopeClose,
580 OnMutation,
582 Manual,
584}
585
586#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
588pub struct ContextDagConfig {
589 pub max_depth: i32,
591 pub prune_strategy: PruneStrategy,
593}
594
595#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
597pub struct RecoveryConfig {
598 pub enabled: bool,
600 pub frequency: RecoveryFrequency,
602 pub max_checkpoints: i32,
604}
605
606#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
608pub struct DosageConfig {
609 pub max_tokens_per_scope: i32,
611 pub max_artifacts_per_scope: i32,
613 pub max_notes_per_trajectory: i32,
615}
616
617#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
619pub struct AntiSprawlConfig {
620 pub max_trajectory_depth: i32,
622 pub max_concurrent_scopes: i32,
624}
625
626#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
628pub struct GroundingConfig {
629 pub require_artifact_backing: bool,
631 pub contradiction_threshold: f32,
633 pub conflict_resolution: ConflictResolution,
635}
636
637#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
639pub struct LintingConfig {
640 pub max_artifact_size: usize,
642 pub min_confidence_threshold: f32,
644}
645
646#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
648pub struct StalenessConfig {
649 pub stale_hours: i64,
651}
652
653#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
655pub struct PCPConfig {
656 pub context_dag: ContextDagConfig,
658 pub recovery: RecoveryConfig,
660 pub dosage: DosageConfig,
662 pub anti_sprawl: AntiSprawlConfig,
664 pub grounding: GroundingConfig,
666 pub linting: LintingConfig,
668 pub staleness: StalenessConfig,
670}
671
672impl PCPConfig {
673 #[tracing::instrument(skip_all)]
675 pub fn validate(&self) -> CellstateResult<()> {
676 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 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 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 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 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
774#[serde(rename_all = "snake_case")]
775pub enum Severity {
776 Warning,
778 Error,
780 Critical,
782}
783
784#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
786#[serde(rename_all = "snake_case")]
787pub enum IssueType {
788 StaleData,
790 Contradiction,
792 MissingReference,
794 DosageExceeded,
796 CircularDependency,
798}
799
800#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
802pub struct ValidationIssue {
803 pub severity: Severity,
805 pub issue_type: IssueType,
807 pub message: String,
809 pub entity_id: Option<Uuid>,
811}
812
813#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
815pub struct ValidationResult {
816 pub valid: bool,
818 pub issues: Vec<ValidationIssue>,
820}
821
822impl ValidationResult {
823 #[tracing::instrument(skip_all)]
825 pub fn valid() -> Self {
826 Self {
827 valid: true,
828 issues: Vec::new(),
829 }
830 }
831
832 #[tracing::instrument(skip_all)]
834 pub fn invalid(issues: Vec<ValidationIssue>) -> Self {
835 Self {
836 valid: false,
837 issues,
838 }
839 }
840
841 #[tracing::instrument(skip_all)]
843 pub fn add_issue(&mut self, issue: ValidationIssue) {
844 if issue.severity == Severity::Error || issue.severity == Severity::Critical {
846 self.valid = false;
847 }
848 self.issues.push(issue);
849 }
850
851 #[tracing::instrument(skip_all)]
853 pub fn has_critical(&self) -> bool {
854 self.issues.iter().any(|i| i.severity == Severity::Critical)
855 }
856
857 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
877#[serde(rename_all = "snake_case")]
878pub enum LintIssueType {
879 TooLarge,
881 Duplicate,
883 MissingEmbedding,
885 LowConfidence,
887 SyntaxError,
889 ReservedCharacterLeak,
891}
892
893#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
895pub struct LintIssue {
896 pub issue_type: LintIssueType,
898 pub message: String,
900 pub artifact_id: ArtifactId,
902}
903
904#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
906pub struct MarkdownSemanticIssue {
907 pub issue_type: LintIssueType,
909 pub line: usize,
911 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
952pub fn lint_markdown_semantics(content: &str) -> Vec<MarkdownSemanticIssue> {
959 let mut issues = Vec::new();
960 let lines: Vec<&str> = content.lines().collect();
961
962 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 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 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1031pub struct LintResult {
1032 pub passed: bool,
1034 pub issues: Vec<LintIssue>,
1036}
1037
1038impl LintResult {
1039 #[tracing::instrument(skip_all)]
1041 pub fn passed() -> Self {
1042 Self {
1043 passed: true,
1044 issues: Vec::new(),
1045 }
1046 }
1047
1048 #[tracing::instrument(skip_all)]
1050 pub fn failed(issues: Vec<LintIssue>) -> Self {
1051 Self {
1052 passed: false,
1053 issues,
1054 }
1055 }
1056
1057 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1067pub struct CheckpointState {
1068 pub context_snapshot: RawContent,
1070 pub artifact_ids: Vec<ArtifactId>,
1072 pub note_ids: Vec<NoteId>,
1074}
1075
1076#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1078pub struct PCPCheckpoint {
1079 pub checkpoint_id: Uuid,
1081 pub scope_id: ScopeId,
1083 pub state: CheckpointState,
1085 pub created_at: Timestamp,
1087}
1088
1089#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1091pub struct RecoveryResult {
1092 pub success: bool,
1094 pub recovered_scope: Option<Scope>,
1096 pub errors: Vec<String>,
1098}
1099
1100impl RecoveryResult {
1101 #[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 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1124pub struct Contradiction {
1125 pub artifact_a: ArtifactId,
1127 pub artifact_b: ArtifactId,
1129 pub similarity_score: f32,
1131 pub description: String,
1133}
1134
1135#[derive(Debug, Clone)]
1137pub struct PCPRuntime {
1138 config: PCPConfig,
1140 checkpoints: Vec<PCPCheckpoint>,
1142}
1143
1144impl PCPRuntime {
1145 #[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 #[tracing::instrument(skip_all)]
1157 pub fn config(&self) -> &PCPConfig {
1158 &self.config
1159 }
1160}
1161
1162impl PCPRuntime {
1167 #[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 self.check_dosage_limits(&mut result, artifacts.len() as i32, current_tokens);
1188
1189 self.check_stale_scope(&mut result, scope);
1191
1192 for artifact in artifacts {
1194 self.check_artifact_integrity(&mut result, artifact);
1195 }
1196
1197 Ok(result)
1198 }
1199
1200 fn check_dosage_limits(
1202 &self,
1203 result: &mut ValidationResult,
1204 artifact_count: i32,
1205 token_count: i32,
1206 ) {
1207 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 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 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 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 fn check_artifact_integrity(&self, result: &mut ValidationResult, artifact: &Artifact) {
1257 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 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
1287impl PCPRuntime {
1292 #[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 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 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 let similarity = match embedding_a.cosine_similarity(embedding_b) {
1324 Ok(s) => s,
1325 Err(_) => continue, };
1327
1328 if similarity >= self.config.grounding.contradiction_threshold {
1330 if artifact_a.content != artifact_b.content {
1332 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1357pub struct DosageResult {
1358 pub exceeded: bool,
1360 pub pruned_artifacts: Vec<ArtifactId>,
1362 pub tokens_trimmed: i32,
1364 pub warnings: Vec<String>,
1366}
1367
1368impl DosageResult {
1369 #[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 #[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 #[tracing::instrument(skip_all)]
1393 pub fn add_pruned(&mut self, artifact_id: ArtifactId) {
1394 self.pruned_artifacts.push(artifact_id);
1395 }
1396
1397 #[tracing::instrument(skip_all)]
1399 pub fn add_warning(&mut self, warning: String) {
1400 self.warnings.push(warning);
1401 }
1402}
1403
1404impl PCPRuntime {
1405 #[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 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 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 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 #[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
1479impl PCPRuntime {
1487 #[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 self.check_artifact_size(&mut result, artifact);
1506
1507 self.check_artifact_duplicates(&mut result, artifact, existing_artifacts);
1509
1510 self.check_artifact_embedding(&mut result, artifact);
1512
1513 self.check_artifact_confidence(&mut result, artifact);
1515
1516 self.check_artifact_semantics(&mut result, artifact);
1518
1519 Ok(result)
1520 }
1521
1522 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 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 if existing.artifact_id == artifact.artifact_id {
1548 continue;
1549 }
1550
1551 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; }
1563 }
1564 }
1565
1566 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 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 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 #[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
1627impl PCPRuntime {
1632 #[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 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 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 let artifact_ids: Vec<ArtifactId> = artifacts.iter().map(|a| a.artifact_id).collect();
1669
1670 let state = CheckpointState {
1672 context_snapshot,
1673 artifact_ids,
1674 note_ids: note_ids.to_vec(),
1675 };
1676
1677 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 self.checkpoints.push(checkpoint.clone());
1687
1688 self.enforce_checkpoint_limit();
1690
1691 Ok(checkpoint)
1692 }
1693
1694 fn enforce_checkpoint_limit(&mut self) {
1696 let max = self.config.recovery.max_checkpoints as usize;
1697 if self.checkpoints.len() > max {
1698 self.checkpoints
1700 .sort_by_key(|checkpoint| checkpoint.created_at);
1701
1702 let excess = self.checkpoints.len() - max;
1704 self.checkpoints.drain(0..excess);
1705 }
1706 }
1707
1708 #[tracing::instrument(skip_all)]
1716 pub fn recover_from_checkpoint(
1717 &self,
1718 checkpoint: &PCPCheckpoint,
1719 ) -> CellstateResult<RecoveryResult> {
1720 if !self.config.recovery.enabled {
1722 return Ok(RecoveryResult::failure(vec![
1723 "Recovery is disabled in configuration".to_string(),
1724 ]));
1725 }
1726
1727 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 #[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 #[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 #[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 #[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
1801impl PCPRuntime {
1806 #[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 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 !scope.is_active
1860 }
1861 SummarizationTrigger::TurnCount { count } => {
1862 turn_count >= *count && turn_count % *count == 0
1864 }
1865 SummarizationTrigger::ArtifactCount { count } => {
1866 artifact_count >= *count && artifact_count % *count == 0
1868 }
1869 SummarizationTrigger::Manual => {
1870 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 #[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 #[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 (AbstractionLevel::Raw, AbstractionLevel::Summary) => true,
1926 (AbstractionLevel::Raw, AbstractionLevel::Principle) => true,
1927 (AbstractionLevel::Summary, AbstractionLevel::Principle) => true,
1928 _ => false,
1930 }
1931 }
1932}
1933
1934#[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, min_confidence_threshold: 0.3,
2006 },
2007 staleness: StalenessConfig {
2008 stale_hours: 24 * 30, },
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 #[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 #[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 #[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 assert!(!decision.is_empty());
2176 }
2177
2178 #[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 #[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 assert!(result
2241 .issues
2242 .iter()
2243 .any(|i| i.issue_type == IssueType::DosageExceeded));
2244 }
2245
2246 #[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, ¬e_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 #[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(); 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 #[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 #[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 #[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 #[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); 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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)); }
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 let cps = runtime.get_checkpoints_for_scope(scope.scope_id);
3124 assert!(cps.len() <= 2);
3125 }
3126
3127 #[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 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 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 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 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 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#[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 fn arb_query() -> impl Strategy<Value = String> {
3596 "[a-zA-Z0-9 ]{1,100}".prop_map(|s| s.trim().to_string())
3597 }
3598
3599 fn arb_response() -> impl Strategy<Value = String> {
3601 "[a-zA-Z0-9 .,!?]{1,500}".prop_map(|s| s.trim().to_string())
3602 }
3603
3604 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 proptest! {
3620 #![proptest_config(ProptestConfig::with_cases(100))]
3621
3622 #[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 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 let results = service.recall_previous(Some(traj_id), Some(scope_id), 10).expect("recall_previous should succeed");
3649
3650 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 proptest! {
3665 #![proptest_config(ProptestConfig::with_cases(100))]
3666
3667 #[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 let traj_id = TrajectoryId::now_v7();
3679
3680 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 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 let decisions = service.recall_decisions(None, 100).expect("recall_decisions should succeed");
3702
3703 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 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 }
3721 }
3722
3723 proptest! {
3728 #![proptest_config(ProptestConfig::with_cases(100))]
3729
3730 #[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 #[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 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 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}