1use async_trait::async_trait;
6use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256};
8
9pub const SESSION_COMPACTION_PROJECTION_INTENTS_KEY: &str = "session_compaction_projection_intents";
11
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
20pub struct CompactionProjectionId {
21 session_id: crate::types::SessionId,
22 parent_revision: String,
23 revision: String,
24 commit_fingerprint: String,
28}
29
30#[derive(Serialize)]
31struct CompactionCommitFingerprint<'a> {
32 selection: &'a crate::TranscriptRewriteSelection,
33 original_span_digest: &'a str,
34 replacement_digest: &'a str,
35 messages_before: usize,
36 messages_after: usize,
37 actor: &'a Option<String>,
38}
39
40#[derive(Serialize)]
43struct LegacyCompactionCommitFingerprint<'a> {
44 selection: &'a crate::TranscriptRewriteSelection,
45 original_span_digest: &'a str,
46 replacement_digest: &'a str,
47 messages_before: usize,
48 messages_after: usize,
49 reason: &'a crate::TranscriptRewriteReason,
50 actor: &'a Option<String>,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
56pub struct CompactionProjectionIntent {
57 pub projection: CompactionProjectionId,
58 pub summary_tokens: u64,
59 pub messages_before: usize,
60 pub messages_after: usize,
61}
62
63impl CompactionProjectionId {
64 pub(crate) fn from_validated_transcript_rewrite(
71 session_id: crate::types::SessionId,
72 commit: &crate::TranscriptRewriteCommit,
73 authority: &crate::agent::compact::ValidatedCompactionRewrite,
74 ) -> Option<Self> {
75 if !authority.authorizes_commit(commit) {
76 return None;
77 }
78 Self::derive_from_typed_transcript_rewrite(session_id, commit)
79 }
80
81 fn derive_from_typed_transcript_rewrite(
82 session_id: crate::types::SessionId,
83 commit: &crate::TranscriptRewriteCommit,
84 ) -> Option<Self> {
85 if commit.selection.semantic() != crate::TranscriptRewriteSemantic::Compaction {
86 return None;
87 }
88 let canonical = serde_json::to_vec(&CompactionCommitFingerprint {
89 selection: &commit.selection,
90 original_span_digest: &commit.original_span_digest,
91 replacement_digest: &commit.replacement_digest,
92 messages_before: commit.messages_before,
93 messages_after: commit.messages_after,
94 actor: &commit.actor,
95 })
96 .ok()?;
97 let digest = Sha256::digest(canonical);
98 let mut commit_fingerprint = String::with_capacity("sha256:".len() + digest.len() * 2);
99 commit_fingerprint.push_str("sha256:");
100 const HEX: &[u8; 16] = b"0123456789abcdef";
101 for byte in digest {
102 commit_fingerprint.push(HEX[(byte >> 4) as usize] as char);
103 commit_fingerprint.push(HEX[(byte & 0x0f) as usize] as char);
104 }
105 Some(Self {
106 session_id,
107 parent_revision: commit.parent_revision.clone(),
108 revision: commit.revision.clone(),
109 commit_fingerprint,
110 })
111 }
112
113 pub(crate) fn matches_transcript_rewrite(
116 &self,
117 session_id: &crate::types::SessionId,
118 commit: &crate::TranscriptRewriteCommit,
119 ) -> bool {
120 if Self::derive_from_typed_transcript_rewrite(session_id.clone(), commit).as_ref()
121 == Some(self)
122 {
123 return true;
124 }
125 if commit.selection.semantic() != crate::TranscriptRewriteSemantic::Compaction {
126 return false;
127 }
128 Self::legacy_from_typed_compaction(session_id.clone(), commit).as_ref() == Some(self)
129 }
130
131 fn legacy_from_typed_compaction(
132 session_id: crate::types::SessionId,
133 commit: &crate::TranscriptRewriteCommit,
134 ) -> Option<Self> {
135 if commit.selection.semantic() != crate::TranscriptRewriteSemantic::Compaction {
136 return None;
137 }
138 let (start, end) = commit.selection.bounds();
139 let legacy_selection = crate::TranscriptRewriteSelection::MessageRange { start, end };
140 let canonical = serde_json::to_vec(&LegacyCompactionCommitFingerprint {
141 selection: &legacy_selection,
142 original_span_digest: &commit.original_span_digest,
143 replacement_digest: &commit.replacement_digest,
144 messages_before: commit.messages_before,
145 messages_after: commit.messages_after,
146 reason: &commit.reason,
147 actor: &commit.actor,
148 })
149 .ok()?;
150 let digest = Sha256::digest(canonical);
151 let mut fingerprint = String::with_capacity("sha256:".len() + digest.len() * 2);
152 fingerprint.push_str("sha256:");
153 const HEX: &[u8; 16] = b"0123456789abcdef";
154 for byte in digest {
155 fingerprint.push(HEX[(byte >> 4) as usize] as char);
156 fingerprint.push(HEX[(byte & 0x0f) as usize] as char);
157 }
158 Some(Self {
159 session_id,
160 parent_revision: commit.parent_revision.clone(),
161 revision: commit.revision.clone(),
162 commit_fingerprint: fingerprint,
163 })
164 }
165
166 pub fn session_id(&self) -> &crate::types::SessionId {
167 &self.session_id
168 }
169
170 pub fn parent_revision(&self) -> &str {
171 &self.parent_revision
172 }
173
174 pub fn revision(&self) -> &str {
175 &self.revision
176 }
177
178 pub fn commit_fingerprint(&self) -> &str {
179 &self.commit_fingerprint
180 }
181}
182
183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
185pub enum CompactionProjectionPersistence {
186 Unsupported,
190 EphemeralImmediate,
197 DurableStaged,
200}
201
202pub trait CompactionCommitCoordinator: Send + Sync {
208 fn authorize_projection(
209 &self,
210 projection: &CompactionProjectionId,
211 ) -> Result<(), CompactionCommitCoordinationError>;
212}
213
214#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
215pub enum CompactionCommitCoordinationError {
216 #[error(
217 "compaction projection session mismatch: coordinator owns {expected}, projection owns {actual}"
218 )]
219 SessionMismatch {
220 expected: crate::types::SessionId,
221 actual: crate::types::SessionId,
222 },
223 #[error("compaction projection coordinator rejected the handoff: {0}")]
224 Rejected(String),
225}
226
227#[derive(Debug, Clone, PartialEq, Eq)]
229pub struct CompactionStageReceipt {
230 pub projection: CompactionProjectionId,
231 pub staged_entries: usize,
232}
233
234#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
236pub struct CompactionStageReconcileReceipt {
237 pub retained_committed: usize,
238 pub aborted_orphans: usize,
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
243pub struct MemoryOwner {
244 session_id: crate::types::SessionId,
246}
247
248impl MemoryOwner {
249 pub fn canonical_session(session_id: crate::types::SessionId) -> Self {
250 Self { session_id }
251 }
252
253 pub fn session_id(&self) -> &crate::types::SessionId {
254 &self.session_id
255 }
256
257 fn includes(&self, metadata: &MemoryMetadata) -> bool {
258 metadata.session_id == self.session_id
259 }
260}
261
262#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
269pub struct MessageRange {
270 start: u64,
271 end: u64,
272}
273
274impl MessageRange {
275 pub fn new(start: u64, end: u64) -> Result<Self, MemoryStoreError> {
280 if start > end {
281 return Err(MemoryStoreError::SourceRange { start, end });
282 }
283 Ok(Self { start, end })
284 }
285
286 pub fn single(offset: u64) -> Self {
288 Self {
289 start: offset,
290 end: offset.saturating_add(1),
291 }
292 }
293
294 pub fn start(&self) -> u64 {
295 self.start
296 }
297
298 pub fn end(&self) -> u64 {
299 self.end
300 }
301
302 pub fn len(&self) -> u64 {
304 self.end - self.start
305 }
306
307 pub fn is_empty(&self) -> bool {
308 self.start == self.end
309 }
310
311 pub fn overlaps(&self, other: &MessageRange) -> bool {
316 !self.is_empty() && !other.is_empty() && self.start < other.end && other.start < self.end
317 }
318}
319
320#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
326#[serde(tag = "kind", rename_all = "snake_case")]
327pub enum MemorySource {
328 Compaction {
331 source_range: MessageRange,
333 },
334}
335
336impl MemorySource {
337 pub fn source_range(&self) -> Option<MessageRange> {
339 match self {
340 MemorySource::Compaction { source_range } => Some(*source_range),
341 }
342 }
343}
344
345#[derive(Debug, Clone, Serialize, Deserialize)]
347pub struct MemoryMetadata {
348 pub session_id: crate::types::SessionId,
350 pub source: MemorySource,
352 pub indexed_at: crate::time_compat::SystemTime,
354}
355
356#[derive(Debug, Clone)]
358pub struct MemoryResult {
359 pub content: String,
361 pub metadata: MemoryMetadata,
363 pub score: f32,
365}
366
367#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
369pub struct MemorySearchScope {
370 pub owner: MemoryOwner,
372}
373
374impl MemorySearchScope {
375 pub fn for_session(session_id: crate::types::SessionId) -> Self {
376 Self {
377 owner: MemoryOwner::canonical_session(session_id),
378 }
379 }
380
381 pub fn for_owner(owner: MemoryOwner) -> Self {
382 Self { owner }
383 }
384
385 pub fn session_id(&self) -> &crate::types::SessionId {
386 self.owner.session_id()
387 }
388
389 pub fn includes(&self, metadata: &MemoryMetadata) -> bool {
390 self.owner.includes(metadata)
391 }
392}
393
394#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
396pub struct MemoryIndexScope {
397 pub owner: MemoryOwner,
399}
400
401impl MemoryIndexScope {
402 pub fn for_session(session_id: crate::types::SessionId) -> Self {
403 Self {
404 owner: MemoryOwner::canonical_session(session_id),
405 }
406 }
407
408 pub fn for_owner(owner: MemoryOwner) -> Self {
409 Self { owner }
410 }
411
412 pub fn session_id(&self) -> &crate::types::SessionId {
413 self.owner.session_id()
414 }
415
416 pub fn includes(&self, metadata: &MemoryMetadata) -> bool {
417 self.owner.includes(metadata)
418 }
419}
420
421#[derive(Debug, Clone)]
430pub struct MemoryIndexRequest {
431 scope: MemoryIndexScope,
432 content: crate::types::MemoryIndexableContent,
433 metadata: MemoryMetadata,
434}
435
436impl MemoryIndexRequest {
437 pub fn new(
438 scope: MemoryIndexScope,
439 content: crate::types::MemoryIndexableContent,
440 metadata: MemoryMetadata,
441 ) -> Result<Self, MemoryStoreError> {
442 if !scope.includes(&metadata) {
443 return Err(MemoryStoreError::Scope(format!(
444 "memory metadata session {} is outside indexing scope {}",
445 metadata.session_id,
446 scope.session_id()
447 )));
448 }
449 Ok(Self {
450 scope,
451 content,
452 metadata,
453 })
454 }
455
456 pub fn scope(&self) -> &MemoryIndexScope {
457 &self.scope
458 }
459
460 pub fn content(&self) -> &crate::types::MemoryIndexableContent {
462 &self.content
463 }
464
465 pub fn indexable_text(&self) -> Option<&str> {
467 self.content.indexable_text()
468 }
469
470 pub fn metadata(&self) -> &MemoryMetadata {
471 &self.metadata
472 }
473
474 pub fn into_parts(
475 self,
476 ) -> (
477 MemoryIndexScope,
478 crate::types::MemoryIndexableContent,
479 MemoryMetadata,
480 ) {
481 (self.scope, self.content, self.metadata)
482 }
483}
484
485#[derive(Debug, Clone)]
487pub struct MemoryIndexBatch {
488 scope: MemoryIndexScope,
489 requests: Vec<MemoryIndexRequest>,
490}
491
492impl MemoryIndexBatch {
493 pub fn new(
494 scope: MemoryIndexScope,
495 requests: Vec<MemoryIndexRequest>,
496 ) -> Result<Self, MemoryStoreError> {
497 for request in &requests {
498 if request.scope() != &scope {
499 return Err(MemoryStoreError::Scope(format!(
500 "memory index request scope {} is outside batch scope {}",
501 request.scope().session_id(),
502 scope.session_id()
503 )));
504 }
505 }
506 Ok(Self { scope, requests })
507 }
508
509 pub fn single(request: MemoryIndexRequest) -> Self {
510 Self {
511 scope: request.scope.clone(),
512 requests: vec![request],
513 }
514 }
515
516 pub fn scope(&self) -> &MemoryIndexScope {
517 &self.scope
518 }
519
520 pub fn len(&self) -> usize {
521 self.requests.len()
522 }
523
524 pub fn is_empty(&self) -> bool {
525 self.requests.is_empty()
526 }
527
528 pub fn into_parts(self) -> (MemoryIndexScope, Vec<MemoryIndexRequest>) {
529 (self.scope, self.requests)
530 }
531}
532
533#[derive(Debug, Clone)]
535pub struct MemoryIndexReceipt {
536 pub scope: MemoryIndexScope,
537 pub indexed_entries: usize,
538}
539
540#[derive(Debug)]
542pub enum MemoryIndexDelivery {
543 NoStore {
544 scope: MemoryIndexScope,
545 },
546 Delivered(MemoryIndexReceipt),
547 Rejected {
548 scope: MemoryIndexScope,
549 attempted_entries: usize,
550 error: MemoryStoreError,
551 },
552}
553
554#[derive(Debug, Clone)]
556pub struct MemoryScopeDropReceipt {
557 pub owner: MemoryOwner,
559 pub dropped_entries: usize,
561}
562
563#[derive(Debug, Clone, Copy)]
570pub struct MemoryEnumerationRequest {
571 pub limit: usize,
573 pub offset: usize,
575 pub source_overlap: Option<MessageRange>,
578 pub indexed_after: Option<crate::time_compat::SystemTime>,
582}
583
584impl MemoryEnumerationRequest {
585 pub fn admits(&self, metadata: &MemoryMetadata) -> bool {
593 if let Some(range) = self.source_overlap {
594 match metadata.source.source_range() {
595 Some(source_range) if source_range.overlaps(&range) => {}
596 _ => return false,
597 }
598 }
599 if let Some(after) = self.indexed_after
600 && metadata.indexed_at <= after
601 {
602 return false;
603 }
604 true
605 }
606}
607
608#[derive(Debug, Clone)]
610pub struct MemoryEnumerationPage {
611 pub records: Vec<MemoryRecord>,
613 pub next_offset: Option<usize>,
616}
617
618#[derive(Debug, Clone)]
621pub struct MemoryRecord {
622 pub content: String,
624 pub metadata: MemoryMetadata,
626}
627
628pub trait EmbeddingModel: Send + Sync {
633 fn dimension(&self) -> usize;
638
639 fn embed(&self, text: &str) -> Vec<f32>;
641}
642
643#[derive(Debug, Clone, Copy, PartialEq, Eq)]
648pub struct HnswParams {
649 pub max_nb_connection: usize,
651 pub max_layer: usize,
653 pub ef_construction: usize,
655 pub ef_search: usize,
657}
658
659impl Default for HnswParams {
660 fn default() -> Self {
661 Self {
662 max_nb_connection: 16,
663 max_layer: 16,
664 ef_construction: 200,
665 ef_search: 200,
666 }
667 }
668}
669
670#[derive(Clone)]
673pub struct MemoryRankingPolicy {
674 embedding_model: std::sync::Arc<dyn EmbeddingModel>,
675 hnsw_params: HnswParams,
676}
677
678impl MemoryRankingPolicy {
679 pub fn new(
681 embedding_model: std::sync::Arc<dyn EmbeddingModel>,
682 hnsw_params: HnswParams,
683 ) -> Self {
684 Self {
685 embedding_model,
686 hnsw_params,
687 }
688 }
689
690 pub fn embedding_model(&self) -> &std::sync::Arc<dyn EmbeddingModel> {
692 &self.embedding_model
693 }
694
695 pub fn hnsw_params(&self) -> HnswParams {
697 self.hnsw_params
698 }
699
700 pub fn dimension(&self) -> usize {
702 self.embedding_model.dimension()
703 }
704
705 pub fn embed(&self, text: &str) -> Vec<f32> {
707 self.embedding_model.embed(text)
708 }
709}
710
711impl std::fmt::Debug for MemoryRankingPolicy {
712 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
713 f.debug_struct("MemoryRankingPolicy")
714 .field("dimension", &self.embedding_model.dimension())
715 .field("hnsw_params", &self.hnsw_params)
716 .finish()
717 }
718}
719
720#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
722#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
723pub trait MemoryStore: Send + Sync {
724 fn compaction_projection_persistence(&self) -> CompactionProjectionPersistence {
726 CompactionProjectionPersistence::Unsupported
729 }
730
731 async fn index_scoped(
733 &self,
734 request: MemoryIndexRequest,
735 ) -> Result<MemoryIndexReceipt, MemoryStoreError> {
736 self.index_scoped_batch(MemoryIndexBatch::single(request))
737 .await
738 }
739
740 async fn index_scoped_batch(
745 &self,
746 batch: MemoryIndexBatch,
747 ) -> Result<MemoryIndexReceipt, MemoryStoreError>;
748
749 fn publish_ephemeral_compaction_batch(
765 &self,
766 batch: MemoryIndexBatch,
767 ) -> Result<MemoryIndexReceipt, MemoryStoreError> {
768 let _ = batch;
769 Err(MemoryStoreError::Unsupported {
770 operation: "publish_ephemeral_compaction_batch",
771 })
772 }
773
774 async fn stage_compaction_batch(
779 &self,
780 projection: CompactionProjectionId,
781 batch: MemoryIndexBatch,
782 ) -> Result<CompactionStageReceipt, MemoryStoreError> {
783 let _ = (projection, batch);
784 Err(MemoryStoreError::Unsupported {
785 operation: "stage_compaction_batch",
786 })
787 }
788
789 async fn finalize_compaction_batch(
791 &self,
792 projection: &CompactionProjectionId,
793 ) -> Result<MemoryIndexReceipt, MemoryStoreError> {
794 let _ = projection;
795 Err(MemoryStoreError::Unsupported {
796 operation: "finalize_compaction_batch",
797 })
798 }
799
800 async fn abort_compaction_batch(
802 &self,
803 projection: &CompactionProjectionId,
804 ) -> Result<(), MemoryStoreError> {
805 let _ = projection;
806 Err(MemoryStoreError::Unsupported {
807 operation: "abort_compaction_batch",
808 })
809 }
810
811 async fn reconcile_compaction_stages(
818 &self,
819 owner: &MemoryOwner,
820 committed: &[CompactionProjectionId],
821 ) -> Result<CompactionStageReconcileReceipt, MemoryStoreError> {
822 let _ = (owner, committed);
823 Err(MemoryStoreError::Unsupported {
824 operation: "reconcile_compaction_stages",
825 })
826 }
827
828 async fn search(
830 &self,
831 scope: &MemorySearchScope,
832 query: &str,
833 limit: usize,
834 ) -> Result<Vec<MemoryResult>, MemoryStoreError>;
835
836 async fn drop_scope(
846 &self,
847 owner: &MemoryOwner,
848 ) -> Result<MemoryScopeDropReceipt, MemoryStoreError> {
849 let _ = owner;
850 Err(MemoryStoreError::Unsupported {
851 operation: "drop_scope",
852 })
853 }
854
855 async fn enumerate_scoped(
863 &self,
864 scope: &MemorySearchScope,
865 request: MemoryEnumerationRequest,
866 ) -> Result<MemoryEnumerationPage, MemoryStoreError> {
867 let _ = (scope, request);
868 Err(MemoryStoreError::Unsupported {
869 operation: "enumerate_scoped",
870 })
871 }
872}
873
874#[derive(Debug, thiserror::Error)]
880pub enum MemoryStoreError {
881 #[error("Scope error: {0}")]
883 Scope(String),
884
885 #[error("invalid memory source range: start {start} > end {end}")]
887 SourceRange { start: u64, end: u64 },
888
889 #[error("Embedding error: {0}")]
891 Embedding(String),
892
893 #[error("Storage error: {0}")]
895 Storage(String),
896
897 #[error("memory index lock poisoned")]
899 LockPoisoned,
900
901 #[error("memory point ID out of range")]
903 PointIdOutOfRange,
904
905 #[error("memory point ID overflow")]
907 PointIdOverflow,
908
909 #[error("memory store task join failed: {0}")]
911 TaskJoin(String),
912
913 #[error("memory text corruption at point {point_id}: stored bytes are not valid UTF-8")]
917 TextCorruption { point_id: i64 },
918
919 #[error(
923 "memory index/store divergence at point {point_id}: live index references a missing durable row"
924 )]
925 IndexDivergence { point_id: i64 },
926
927 #[error("memory scope index is poisoned pending rebuild from durable state")]
931 ScopePoisoned,
932
933 #[error(
936 "memory scope repair failed after partial index failure: {repair} (original failure: {original})"
937 )]
938 ScopeRepairFailed {
939 original: Box<MemoryStoreError>,
940 repair: Box<MemoryStoreError>,
941 },
942
943 #[error("memory store operation '{operation}' is unsupported by this store")]
945 Unsupported { operation: &'static str },
946
947 #[error("memory enumeration limit must be non-zero")]
951 EnumerationLimitZero,
952
953 #[error("IO error: {0}")]
955 Io(#[from] std::io::Error),
956}
957
958impl MemoryStoreError {
959 pub fn error_code(&self) -> &'static str {
964 match self {
965 Self::Scope(_) => "memory_scope",
966 Self::SourceRange { .. } => "memory_source_range",
967 Self::Embedding(_) => "memory_embedding",
968 Self::Storage(_) => "memory_storage",
969 Self::LockPoisoned => "memory_lock_poisoned",
970 Self::PointIdOutOfRange => "memory_point_id_out_of_range",
971 Self::PointIdOverflow => "memory_point_id_overflow",
972 Self::TaskJoin(_) => "memory_task_join",
973 Self::TextCorruption { .. } => "memory_text_corruption",
974 Self::IndexDivergence { .. } => "memory_index_divergence",
975 Self::ScopePoisoned => "memory_scope_poisoned",
976 Self::ScopeRepairFailed { .. } => "memory_scope_repair_failed",
977 Self::Unsupported { .. } => "memory_unsupported",
978 Self::EnumerationLimitZero => "memory_enumeration_limit_zero",
979 Self::Io(_) => "memory_io",
980 }
981 }
982}
983
984#[cfg(test)]
985#[allow(clippy::unwrap_used, clippy::expect_used)]
986mod tests {
987 use super::*;
988 use crate::time_compat::{Duration, UNIX_EPOCH};
989
990 fn range(start: u64, end: u64) -> MessageRange {
991 MessageRange::new(start, end).unwrap()
992 }
993
994 fn compaction_commit(
995 committed_at: crate::time_compat::SystemTime,
996 ) -> (
997 crate::TranscriptRewriteCommit,
998 crate::agent::compact::ValidatedCompactionRewrite,
999 ) {
1000 let mut session = crate::Session::new();
1001 session.push(crate::types::Message::User(
1002 crate::types::UserMessage::text("verbose context one"),
1003 ));
1004 session.push(crate::types::Message::User(
1005 crate::types::UserMessage::text("verbose context two"),
1006 ));
1007 let replacement = vec![crate::types::Message::User(
1008 crate::types::UserMessage::compaction_summary("compacted context"),
1009 )];
1010 let authority = crate::agent::compact::ValidatedCompactionRewrite::for_test(
1011 session.messages(),
1012 &replacement,
1013 )
1014 .unwrap();
1015 let mut commit = session
1016 .replace_messages_for_compaction_internal(replacement, &authority)
1017 .unwrap()
1018 .unwrap();
1019 commit.committed_at = committed_at;
1020 (commit, authority)
1021 }
1022
1023 #[test]
1024 fn projection_identity_fingerprints_semantic_commit_but_excludes_wall_time() {
1025 let session_id = crate::types::SessionId::new();
1026 let (first_commit, first_authority) =
1027 compaction_commit(UNIX_EPOCH + Duration::from_secs(1));
1028 let first = CompactionProjectionId::from_validated_transcript_rewrite(
1029 session_id.clone(),
1030 &first_commit,
1031 &first_authority,
1032 )
1033 .unwrap();
1034 let (retry_commit, retry_authority) =
1035 compaction_commit(UNIX_EPOCH + Duration::from_secs(2));
1036 let retry = CompactionProjectionId::from_validated_transcript_rewrite(
1037 session_id.clone(),
1038 &retry_commit,
1039 &retry_authority,
1040 )
1041 .unwrap();
1042 assert_eq!(first, retry, "wall time must not break cancellation retry");
1043
1044 let (mut distinct_commit, distinct_authority) =
1045 compaction_commit(UNIX_EPOCH + Duration::from_secs(1));
1046 distinct_commit.actor = Some("agent-b".to_string());
1047 let distinct = CompactionProjectionId::from_validated_transcript_rewrite(
1048 session_id,
1049 &distinct_commit,
1050 &distinct_authority,
1051 )
1052 .unwrap();
1053 assert_ne!(first, distinct, "semantic commit fields must fence aliases");
1054 assert_ne!(first.commit_fingerprint(), distinct.commit_fingerprint());
1055
1056 let (mut presentation_only, presentation_authority) =
1057 compaction_commit(UNIX_EPOCH + Duration::from_secs(3));
1058 presentation_only.reason = crate::TranscriptRewriteReason {
1059 kind: "context_reduction".to_string(),
1060 note: Some("free-form audit wording changed".to_string()),
1061 };
1062 let presentation_only = CompactionProjectionId::from_validated_transcript_rewrite(
1063 first.session_id().clone(),
1064 &presentation_only,
1065 &presentation_authority,
1066 )
1067 .unwrap();
1068 assert_eq!(
1069 first, presentation_only,
1070 "free-form audit reason must not participate in projection authority or identity"
1071 );
1072 }
1073
1074 #[test]
1075 fn free_form_compaction_reason_cannot_mint_projection_authority() {
1076 let (mut generic, authority) = compaction_commit(UNIX_EPOCH);
1077 generic.selection = crate::TranscriptRewriteSelection::MessageRange { start: 0, end: 2 };
1078 generic.reason = crate::TranscriptRewriteReason::new("compaction");
1079 assert!(
1080 CompactionProjectionId::from_validated_transcript_rewrite(
1081 crate::types::SessionId::new(),
1082 &generic,
1083 &authority,
1084 )
1085 .is_none(),
1086 "display reason text is never a compaction witness"
1087 );
1088 }
1089
1090 #[test]
1091 fn typed_compaction_commit_accepts_exact_pre_semantic_fingerprint_for_prior_data() {
1092 let session_id = crate::types::SessionId::new();
1093 let (commit, authority) = compaction_commit(UNIX_EPOCH);
1094 let current = CompactionProjectionId::from_validated_transcript_rewrite(
1095 session_id.clone(),
1096 &commit,
1097 &authority,
1098 )
1099 .unwrap();
1100 let legacy =
1101 CompactionProjectionId::legacy_from_typed_compaction(session_id.clone(), &commit)
1102 .unwrap();
1103 assert_ne!(
1104 legacy, current,
1105 "typed semantic changes the canonical fingerprint"
1106 );
1107 assert!(legacy.matches_transcript_rewrite(&session_id, &commit));
1108 }
1109
1110 #[test]
1111 fn overlaps_is_half_open() {
1112 assert!(range(0, 5).overlaps(&range(4, 6)));
1114 assert!(range(4, 6).overlaps(&range(0, 5)));
1115 assert!(range(0, 10).overlaps(&range(3, 4)));
1117 assert!(range(3, 4).overlaps(&range(0, 10)));
1118 assert!(!range(0, 5).overlaps(&range(5, 10)));
1120 assert!(!range(5, 10).overlaps(&range(0, 5)));
1121 assert!(!range(0, 2).overlaps(&range(7, 9)));
1123 }
1124
1125 #[test]
1126 fn empty_range_never_overlaps() {
1127 assert!(!range(3, 3).overlaps(&range(0, 10)));
1128 assert!(!range(0, 10).overlaps(&range(3, 3)));
1129 assert!(!range(3, 3).overlaps(&range(3, 3)));
1130 }
1131
1132 #[test]
1133 fn unsupported_error_code_is_stable() {
1134 assert_eq!(
1135 MemoryStoreError::Unsupported {
1136 operation: "drop_scope",
1137 }
1138 .error_code(),
1139 "memory_unsupported"
1140 );
1141 }
1142
1143 fn metadata_at(
1144 indexed_at: crate::time_compat::SystemTime,
1145 source: MemorySource,
1146 ) -> MemoryMetadata {
1147 MemoryMetadata {
1148 session_id: crate::types::SessionId::new(),
1149 source,
1150 indexed_at,
1151 }
1152 }
1153
1154 #[test]
1155 fn enumeration_request_admits_on_source_overlap() {
1156 let request = MemoryEnumerationRequest {
1157 limit: 10,
1158 offset: 0,
1159 source_overlap: Some(range(4, 6)),
1160 indexed_after: None,
1161 };
1162 let overlapping = metadata_at(
1163 UNIX_EPOCH,
1164 MemorySource::Compaction {
1165 source_range: range(0, 5),
1166 },
1167 );
1168 let disjoint = metadata_at(
1169 UNIX_EPOCH,
1170 MemorySource::Compaction {
1171 source_range: range(6, 9),
1172 },
1173 );
1174 assert!(request.admits(&overlapping));
1175 assert!(!request.admits(&disjoint));
1176 }
1177
1178 #[test]
1179 fn enumeration_request_indexed_after_is_strict() {
1180 let boundary = UNIX_EPOCH + Duration::from_secs(100);
1181 let request = MemoryEnumerationRequest {
1182 limit: 10,
1183 offset: 0,
1184 source_overlap: None,
1185 indexed_after: Some(boundary),
1186 };
1187 let at_boundary = metadata_at(
1188 boundary,
1189 MemorySource::Compaction {
1190 source_range: range(0, 1),
1191 },
1192 );
1193 let after_boundary = metadata_at(
1194 boundary + Duration::from_secs(1),
1195 MemorySource::Compaction {
1196 source_range: range(0, 1),
1197 },
1198 );
1199 let before_boundary = metadata_at(
1200 UNIX_EPOCH,
1201 MemorySource::Compaction {
1202 source_range: range(0, 1),
1203 },
1204 );
1205 assert!(!request.admits(&at_boundary));
1206 assert!(request.admits(&after_boundary));
1207 assert!(!request.admits(&before_boundary));
1208 }
1209
1210 #[test]
1211 fn enumeration_request_filters_compose() {
1212 let request = MemoryEnumerationRequest {
1213 limit: 10,
1214 offset: 0,
1215 source_overlap: Some(range(0, 5)),
1216 indexed_after: Some(UNIX_EPOCH + Duration::from_secs(100)),
1217 };
1218 let both = metadata_at(
1219 UNIX_EPOCH + Duration::from_secs(200),
1220 MemorySource::Compaction {
1221 source_range: range(2, 3),
1222 },
1223 );
1224 let wrong_range = metadata_at(
1225 UNIX_EPOCH + Duration::from_secs(200),
1226 MemorySource::Compaction {
1227 source_range: range(5, 9),
1228 },
1229 );
1230 let too_early = metadata_at(
1231 UNIX_EPOCH,
1232 MemorySource::Compaction {
1233 source_range: range(2, 3),
1234 },
1235 );
1236 assert!(request.admits(&both));
1237 assert!(!request.admits(&wrong_range));
1238 assert!(!request.admits(&too_early));
1239 }
1240
1241 struct MinimalStore;
1245
1246 #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1247 #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1248 impl MemoryStore for MinimalStore {
1249 async fn index_scoped_batch(
1250 &self,
1251 batch: MemoryIndexBatch,
1252 ) -> Result<MemoryIndexReceipt, MemoryStoreError> {
1253 let (scope, requests) = batch.into_parts();
1254 Ok(MemoryIndexReceipt {
1255 scope,
1256 indexed_entries: requests.len(),
1257 })
1258 }
1259
1260 async fn search(
1261 &self,
1262 _scope: &MemorySearchScope,
1263 _query: &str,
1264 _limit: usize,
1265 ) -> Result<Vec<MemoryResult>, MemoryStoreError> {
1266 Ok(Vec::new())
1267 }
1268 }
1269
1270 #[tokio::test]
1271 async fn drop_scope_default_is_typed_unsupported() {
1272 let store = MinimalStore;
1273 assert_eq!(
1274 store.compaction_projection_persistence(),
1275 CompactionProjectionPersistence::Unsupported
1276 );
1277 let owner = MemoryOwner::canonical_session(crate::types::SessionId::new());
1278 let error = store.drop_scope(&owner).await.unwrap_err();
1279 assert!(matches!(
1280 error,
1281 MemoryStoreError::Unsupported {
1282 operation: "drop_scope",
1283 }
1284 ));
1285 assert_eq!(error.error_code(), "memory_unsupported");
1286 }
1287
1288 #[tokio::test]
1289 async fn reconcile_compaction_stages_default_is_typed_unsupported() {
1290 let store = MinimalStore;
1291 let owner = MemoryOwner::canonical_session(crate::types::SessionId::new());
1292 let error = store
1293 .reconcile_compaction_stages(&owner, &[])
1294 .await
1295 .unwrap_err();
1296 assert!(matches!(
1297 error,
1298 MemoryStoreError::Unsupported {
1299 operation: "reconcile_compaction_stages",
1300 }
1301 ));
1302 }
1303
1304 #[tokio::test]
1305 async fn enumerate_scoped_default_is_typed_unsupported() {
1306 let store = MinimalStore;
1307 let scope = MemorySearchScope::for_session(crate::types::SessionId::new());
1308 let error = store
1309 .enumerate_scoped(
1310 &scope,
1311 MemoryEnumerationRequest {
1312 limit: 10,
1313 offset: 0,
1314 source_overlap: None,
1315 indexed_after: None,
1316 },
1317 )
1318 .await
1319 .unwrap_err();
1320 assert!(matches!(
1321 error,
1322 MemoryStoreError::Unsupported {
1323 operation: "enumerate_scoped",
1324 }
1325 ));
1326 assert_eq!(error.error_code(), "memory_unsupported");
1327 }
1328}