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,
193 DurableStaged,
196}
197
198pub trait CompactionCommitCoordinator: Send + Sync {
204 fn authorize_projection(
205 &self,
206 projection: &CompactionProjectionId,
207 ) -> Result<(), CompactionCommitCoordinationError>;
208}
209
210#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
211pub enum CompactionCommitCoordinationError {
212 #[error(
213 "compaction projection session mismatch: coordinator owns {expected}, projection owns {actual}"
214 )]
215 SessionMismatch {
216 expected: crate::types::SessionId,
217 actual: crate::types::SessionId,
218 },
219 #[error("compaction projection coordinator rejected the handoff: {0}")]
220 Rejected(String),
221}
222
223#[derive(Debug, Clone, PartialEq, Eq)]
225pub struct CompactionStageReceipt {
226 pub projection: CompactionProjectionId,
227 pub staged_entries: usize,
228}
229
230#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
232pub struct CompactionStageReconcileReceipt {
233 pub retained_committed: usize,
234 pub aborted_orphans: usize,
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
239pub struct MemoryOwner {
240 session_id: crate::types::SessionId,
242}
243
244impl MemoryOwner {
245 pub fn canonical_session(session_id: crate::types::SessionId) -> Self {
246 Self { session_id }
247 }
248
249 pub fn session_id(&self) -> &crate::types::SessionId {
250 &self.session_id
251 }
252
253 fn includes(&self, metadata: &MemoryMetadata) -> bool {
254 metadata.session_id == self.session_id
255 }
256}
257
258#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
265pub struct MessageRange {
266 start: u64,
267 end: u64,
268}
269
270impl MessageRange {
271 pub fn new(start: u64, end: u64) -> Result<Self, MemoryStoreError> {
276 if start > end {
277 return Err(MemoryStoreError::SourceRange { start, end });
278 }
279 Ok(Self { start, end })
280 }
281
282 pub fn single(offset: u64) -> Self {
284 Self {
285 start: offset,
286 end: offset.saturating_add(1),
287 }
288 }
289
290 pub fn start(&self) -> u64 {
291 self.start
292 }
293
294 pub fn end(&self) -> u64 {
295 self.end
296 }
297
298 pub fn len(&self) -> u64 {
300 self.end - self.start
301 }
302
303 pub fn is_empty(&self) -> bool {
304 self.start == self.end
305 }
306
307 pub fn overlaps(&self, other: &MessageRange) -> bool {
312 !self.is_empty() && !other.is_empty() && self.start < other.end && other.start < self.end
313 }
314}
315
316#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
322#[serde(tag = "kind", rename_all = "snake_case")]
323pub enum MemorySource {
324 Compaction {
327 source_range: MessageRange,
329 },
330}
331
332impl MemorySource {
333 pub fn source_range(&self) -> Option<MessageRange> {
335 match self {
336 MemorySource::Compaction { source_range } => Some(*source_range),
337 }
338 }
339}
340
341#[derive(Debug, Clone, Serialize, Deserialize)]
343pub struct MemoryMetadata {
344 pub session_id: crate::types::SessionId,
346 pub source: MemorySource,
348 pub indexed_at: crate::time_compat::SystemTime,
350}
351
352#[derive(Debug, Clone)]
354pub struct MemoryResult {
355 pub content: String,
357 pub metadata: MemoryMetadata,
359 pub score: f32,
361}
362
363#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
365pub struct MemorySearchScope {
366 pub owner: MemoryOwner,
368}
369
370impl MemorySearchScope {
371 pub fn for_session(session_id: crate::types::SessionId) -> Self {
372 Self {
373 owner: MemoryOwner::canonical_session(session_id),
374 }
375 }
376
377 pub fn for_owner(owner: MemoryOwner) -> Self {
378 Self { owner }
379 }
380
381 pub fn session_id(&self) -> &crate::types::SessionId {
382 self.owner.session_id()
383 }
384
385 pub fn includes(&self, metadata: &MemoryMetadata) -> bool {
386 self.owner.includes(metadata)
387 }
388}
389
390#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
392pub struct MemoryIndexScope {
393 pub owner: MemoryOwner,
395}
396
397impl MemoryIndexScope {
398 pub fn for_session(session_id: crate::types::SessionId) -> Self {
399 Self {
400 owner: MemoryOwner::canonical_session(session_id),
401 }
402 }
403
404 pub fn for_owner(owner: MemoryOwner) -> Self {
405 Self { owner }
406 }
407
408 pub fn session_id(&self) -> &crate::types::SessionId {
409 self.owner.session_id()
410 }
411
412 pub fn includes(&self, metadata: &MemoryMetadata) -> bool {
413 self.owner.includes(metadata)
414 }
415}
416
417#[derive(Debug, Clone)]
426pub struct MemoryIndexRequest {
427 scope: MemoryIndexScope,
428 content: crate::types::MemoryIndexableContent,
429 metadata: MemoryMetadata,
430}
431
432impl MemoryIndexRequest {
433 pub fn new(
434 scope: MemoryIndexScope,
435 content: crate::types::MemoryIndexableContent,
436 metadata: MemoryMetadata,
437 ) -> Result<Self, MemoryStoreError> {
438 if !scope.includes(&metadata) {
439 return Err(MemoryStoreError::Scope(format!(
440 "memory metadata session {} is outside indexing scope {}",
441 metadata.session_id,
442 scope.session_id()
443 )));
444 }
445 Ok(Self {
446 scope,
447 content,
448 metadata,
449 })
450 }
451
452 pub fn scope(&self) -> &MemoryIndexScope {
453 &self.scope
454 }
455
456 pub fn content(&self) -> &crate::types::MemoryIndexableContent {
458 &self.content
459 }
460
461 pub fn indexable_text(&self) -> Option<&str> {
463 self.content.indexable_text()
464 }
465
466 pub fn metadata(&self) -> &MemoryMetadata {
467 &self.metadata
468 }
469
470 pub fn into_parts(
471 self,
472 ) -> (
473 MemoryIndexScope,
474 crate::types::MemoryIndexableContent,
475 MemoryMetadata,
476 ) {
477 (self.scope, self.content, self.metadata)
478 }
479}
480
481#[derive(Debug, Clone)]
483pub struct MemoryIndexBatch {
484 scope: MemoryIndexScope,
485 requests: Vec<MemoryIndexRequest>,
486}
487
488impl MemoryIndexBatch {
489 pub fn new(
490 scope: MemoryIndexScope,
491 requests: Vec<MemoryIndexRequest>,
492 ) -> Result<Self, MemoryStoreError> {
493 for request in &requests {
494 if request.scope() != &scope {
495 return Err(MemoryStoreError::Scope(format!(
496 "memory index request scope {} is outside batch scope {}",
497 request.scope().session_id(),
498 scope.session_id()
499 )));
500 }
501 }
502 Ok(Self { scope, requests })
503 }
504
505 pub fn single(request: MemoryIndexRequest) -> Self {
506 Self {
507 scope: request.scope.clone(),
508 requests: vec![request],
509 }
510 }
511
512 pub fn scope(&self) -> &MemoryIndexScope {
513 &self.scope
514 }
515
516 pub fn len(&self) -> usize {
517 self.requests.len()
518 }
519
520 pub fn is_empty(&self) -> bool {
521 self.requests.is_empty()
522 }
523
524 pub fn into_parts(self) -> (MemoryIndexScope, Vec<MemoryIndexRequest>) {
525 (self.scope, self.requests)
526 }
527}
528
529#[derive(Debug, Clone)]
531pub struct MemoryIndexReceipt {
532 pub scope: MemoryIndexScope,
533 pub indexed_entries: usize,
534}
535
536#[derive(Debug)]
538pub enum MemoryIndexDelivery {
539 NoStore {
540 scope: MemoryIndexScope,
541 },
542 Delivered(MemoryIndexReceipt),
543 Rejected {
544 scope: MemoryIndexScope,
545 attempted_entries: usize,
546 error: MemoryStoreError,
547 },
548}
549
550#[derive(Debug, Clone)]
552pub struct MemoryScopeDropReceipt {
553 pub owner: MemoryOwner,
555 pub dropped_entries: usize,
557}
558
559#[derive(Debug, Clone, Copy)]
566pub struct MemoryEnumerationRequest {
567 pub limit: usize,
569 pub offset: usize,
571 pub source_overlap: Option<MessageRange>,
574 pub indexed_after: Option<crate::time_compat::SystemTime>,
578}
579
580impl MemoryEnumerationRequest {
581 pub fn admits(&self, metadata: &MemoryMetadata) -> bool {
589 if let Some(range) = self.source_overlap {
590 match metadata.source.source_range() {
591 Some(source_range) if source_range.overlaps(&range) => {}
592 _ => return false,
593 }
594 }
595 if let Some(after) = self.indexed_after
596 && metadata.indexed_at <= after
597 {
598 return false;
599 }
600 true
601 }
602}
603
604#[derive(Debug, Clone)]
606pub struct MemoryEnumerationPage {
607 pub records: Vec<MemoryRecord>,
609 pub next_offset: Option<usize>,
612}
613
614#[derive(Debug, Clone)]
617pub struct MemoryRecord {
618 pub content: String,
620 pub metadata: MemoryMetadata,
622}
623
624pub trait EmbeddingModel: Send + Sync {
629 fn dimension(&self) -> usize;
634
635 fn embed(&self, text: &str) -> Vec<f32>;
637}
638
639#[derive(Debug, Clone, Copy, PartialEq, Eq)]
644pub struct HnswParams {
645 pub max_nb_connection: usize,
647 pub max_layer: usize,
649 pub ef_construction: usize,
651 pub ef_search: usize,
653}
654
655impl Default for HnswParams {
656 fn default() -> Self {
657 Self {
658 max_nb_connection: 16,
659 max_layer: 16,
660 ef_construction: 200,
661 ef_search: 200,
662 }
663 }
664}
665
666#[derive(Clone)]
669pub struct MemoryRankingPolicy {
670 embedding_model: std::sync::Arc<dyn EmbeddingModel>,
671 hnsw_params: HnswParams,
672}
673
674impl MemoryRankingPolicy {
675 pub fn new(
677 embedding_model: std::sync::Arc<dyn EmbeddingModel>,
678 hnsw_params: HnswParams,
679 ) -> Self {
680 Self {
681 embedding_model,
682 hnsw_params,
683 }
684 }
685
686 pub fn embedding_model(&self) -> &std::sync::Arc<dyn EmbeddingModel> {
688 &self.embedding_model
689 }
690
691 pub fn hnsw_params(&self) -> HnswParams {
693 self.hnsw_params
694 }
695
696 pub fn dimension(&self) -> usize {
698 self.embedding_model.dimension()
699 }
700
701 pub fn embed(&self, text: &str) -> Vec<f32> {
703 self.embedding_model.embed(text)
704 }
705}
706
707impl std::fmt::Debug for MemoryRankingPolicy {
708 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
709 f.debug_struct("MemoryRankingPolicy")
710 .field("dimension", &self.embedding_model.dimension())
711 .field("hnsw_params", &self.hnsw_params)
712 .finish()
713 }
714}
715
716#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
718#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
719pub trait MemoryStore: Send + Sync {
720 fn compaction_projection_persistence(&self) -> CompactionProjectionPersistence {
722 CompactionProjectionPersistence::Unsupported
725 }
726
727 async fn index_scoped(
729 &self,
730 request: MemoryIndexRequest,
731 ) -> Result<MemoryIndexReceipt, MemoryStoreError> {
732 self.index_scoped_batch(MemoryIndexBatch::single(request))
733 .await
734 }
735
736 async fn index_scoped_batch(
741 &self,
742 batch: MemoryIndexBatch,
743 ) -> Result<MemoryIndexReceipt, MemoryStoreError>;
744
745 async fn stage_compaction_batch(
750 &self,
751 projection: CompactionProjectionId,
752 batch: MemoryIndexBatch,
753 ) -> Result<CompactionStageReceipt, MemoryStoreError> {
754 let _ = (projection, batch);
755 Err(MemoryStoreError::Unsupported {
756 operation: "stage_compaction_batch",
757 })
758 }
759
760 async fn finalize_compaction_batch(
762 &self,
763 projection: &CompactionProjectionId,
764 ) -> Result<MemoryIndexReceipt, MemoryStoreError> {
765 let _ = projection;
766 Err(MemoryStoreError::Unsupported {
767 operation: "finalize_compaction_batch",
768 })
769 }
770
771 async fn abort_compaction_batch(
773 &self,
774 projection: &CompactionProjectionId,
775 ) -> Result<(), MemoryStoreError> {
776 let _ = projection;
777 Err(MemoryStoreError::Unsupported {
778 operation: "abort_compaction_batch",
779 })
780 }
781
782 async fn reconcile_compaction_stages(
789 &self,
790 owner: &MemoryOwner,
791 committed: &[CompactionProjectionId],
792 ) -> Result<CompactionStageReconcileReceipt, MemoryStoreError> {
793 let _ = (owner, committed);
794 Err(MemoryStoreError::Unsupported {
795 operation: "reconcile_compaction_stages",
796 })
797 }
798
799 async fn search(
801 &self,
802 scope: &MemorySearchScope,
803 query: &str,
804 limit: usize,
805 ) -> Result<Vec<MemoryResult>, MemoryStoreError>;
806
807 async fn drop_scope(
817 &self,
818 owner: &MemoryOwner,
819 ) -> Result<MemoryScopeDropReceipt, MemoryStoreError> {
820 let _ = owner;
821 Err(MemoryStoreError::Unsupported {
822 operation: "drop_scope",
823 })
824 }
825
826 async fn enumerate_scoped(
834 &self,
835 scope: &MemorySearchScope,
836 request: MemoryEnumerationRequest,
837 ) -> Result<MemoryEnumerationPage, MemoryStoreError> {
838 let _ = (scope, request);
839 Err(MemoryStoreError::Unsupported {
840 operation: "enumerate_scoped",
841 })
842 }
843}
844
845#[derive(Debug, thiserror::Error)]
851pub enum MemoryStoreError {
852 #[error("Scope error: {0}")]
854 Scope(String),
855
856 #[error("invalid memory source range: start {start} > end {end}")]
858 SourceRange { start: u64, end: u64 },
859
860 #[error("Embedding error: {0}")]
862 Embedding(String),
863
864 #[error("Storage error: {0}")]
866 Storage(String),
867
868 #[error("memory index lock poisoned")]
870 LockPoisoned,
871
872 #[error("memory point ID out of range")]
874 PointIdOutOfRange,
875
876 #[error("memory point ID overflow")]
878 PointIdOverflow,
879
880 #[error("memory store task join failed: {0}")]
882 TaskJoin(String),
883
884 #[error("memory text corruption at point {point_id}: stored bytes are not valid UTF-8")]
888 TextCorruption { point_id: i64 },
889
890 #[error(
894 "memory index/store divergence at point {point_id}: live index references a missing durable row"
895 )]
896 IndexDivergence { point_id: i64 },
897
898 #[error("memory scope index is poisoned pending rebuild from durable state")]
902 ScopePoisoned,
903
904 #[error(
907 "memory scope repair failed after partial index failure: {repair} (original failure: {original})"
908 )]
909 ScopeRepairFailed {
910 original: Box<MemoryStoreError>,
911 repair: Box<MemoryStoreError>,
912 },
913
914 #[error("memory store operation '{operation}' is unsupported by this store")]
916 Unsupported { operation: &'static str },
917
918 #[error("memory enumeration limit must be non-zero")]
922 EnumerationLimitZero,
923
924 #[error("IO error: {0}")]
926 Io(#[from] std::io::Error),
927}
928
929impl MemoryStoreError {
930 pub fn error_code(&self) -> &'static str {
935 match self {
936 Self::Scope(_) => "memory_scope",
937 Self::SourceRange { .. } => "memory_source_range",
938 Self::Embedding(_) => "memory_embedding",
939 Self::Storage(_) => "memory_storage",
940 Self::LockPoisoned => "memory_lock_poisoned",
941 Self::PointIdOutOfRange => "memory_point_id_out_of_range",
942 Self::PointIdOverflow => "memory_point_id_overflow",
943 Self::TaskJoin(_) => "memory_task_join",
944 Self::TextCorruption { .. } => "memory_text_corruption",
945 Self::IndexDivergence { .. } => "memory_index_divergence",
946 Self::ScopePoisoned => "memory_scope_poisoned",
947 Self::ScopeRepairFailed { .. } => "memory_scope_repair_failed",
948 Self::Unsupported { .. } => "memory_unsupported",
949 Self::EnumerationLimitZero => "memory_enumeration_limit_zero",
950 Self::Io(_) => "memory_io",
951 }
952 }
953}
954
955#[cfg(test)]
956#[allow(clippy::unwrap_used, clippy::expect_used)]
957mod tests {
958 use super::*;
959 use crate::time_compat::{Duration, UNIX_EPOCH};
960
961 fn range(start: u64, end: u64) -> MessageRange {
962 MessageRange::new(start, end).unwrap()
963 }
964
965 fn compaction_commit(
966 committed_at: crate::time_compat::SystemTime,
967 ) -> (
968 crate::TranscriptRewriteCommit,
969 crate::agent::compact::ValidatedCompactionRewrite,
970 ) {
971 let mut session = crate::Session::new();
972 session.push(crate::types::Message::User(
973 crate::types::UserMessage::text("verbose context one"),
974 ));
975 session.push(crate::types::Message::User(
976 crate::types::UserMessage::text("verbose context two"),
977 ));
978 let replacement = vec![crate::types::Message::User(
979 crate::types::UserMessage::compaction_summary("compacted context"),
980 )];
981 let authority = crate::agent::compact::ValidatedCompactionRewrite::for_test(
982 session.messages(),
983 &replacement,
984 )
985 .unwrap();
986 let mut commit = session
987 .replace_messages_for_compaction_internal(replacement, &authority)
988 .unwrap()
989 .unwrap();
990 commit.committed_at = committed_at;
991 (commit, authority)
992 }
993
994 #[test]
995 fn projection_identity_fingerprints_semantic_commit_but_excludes_wall_time() {
996 let session_id = crate::types::SessionId::new();
997 let (first_commit, first_authority) =
998 compaction_commit(UNIX_EPOCH + Duration::from_secs(1));
999 let first = CompactionProjectionId::from_validated_transcript_rewrite(
1000 session_id.clone(),
1001 &first_commit,
1002 &first_authority,
1003 )
1004 .unwrap();
1005 let (retry_commit, retry_authority) =
1006 compaction_commit(UNIX_EPOCH + Duration::from_secs(2));
1007 let retry = CompactionProjectionId::from_validated_transcript_rewrite(
1008 session_id.clone(),
1009 &retry_commit,
1010 &retry_authority,
1011 )
1012 .unwrap();
1013 assert_eq!(first, retry, "wall time must not break cancellation retry");
1014
1015 let (mut distinct_commit, distinct_authority) =
1016 compaction_commit(UNIX_EPOCH + Duration::from_secs(1));
1017 distinct_commit.actor = Some("agent-b".to_string());
1018 let distinct = CompactionProjectionId::from_validated_transcript_rewrite(
1019 session_id,
1020 &distinct_commit,
1021 &distinct_authority,
1022 )
1023 .unwrap();
1024 assert_ne!(first, distinct, "semantic commit fields must fence aliases");
1025 assert_ne!(first.commit_fingerprint(), distinct.commit_fingerprint());
1026
1027 let (mut presentation_only, presentation_authority) =
1028 compaction_commit(UNIX_EPOCH + Duration::from_secs(3));
1029 presentation_only.reason = crate::TranscriptRewriteReason {
1030 kind: "context_reduction".to_string(),
1031 note: Some("free-form audit wording changed".to_string()),
1032 };
1033 let presentation_only = CompactionProjectionId::from_validated_transcript_rewrite(
1034 first.session_id().clone(),
1035 &presentation_only,
1036 &presentation_authority,
1037 )
1038 .unwrap();
1039 assert_eq!(
1040 first, presentation_only,
1041 "free-form audit reason must not participate in projection authority or identity"
1042 );
1043 }
1044
1045 #[test]
1046 fn free_form_compaction_reason_cannot_mint_projection_authority() {
1047 let (mut generic, authority) = compaction_commit(UNIX_EPOCH);
1048 generic.selection = crate::TranscriptRewriteSelection::MessageRange { start: 0, end: 2 };
1049 generic.reason = crate::TranscriptRewriteReason::new("compaction");
1050 assert!(
1051 CompactionProjectionId::from_validated_transcript_rewrite(
1052 crate::types::SessionId::new(),
1053 &generic,
1054 &authority,
1055 )
1056 .is_none(),
1057 "display reason text is never a compaction witness"
1058 );
1059 }
1060
1061 #[test]
1062 fn typed_compaction_commit_accepts_exact_pre_semantic_fingerprint_for_prior_data() {
1063 let session_id = crate::types::SessionId::new();
1064 let (commit, authority) = compaction_commit(UNIX_EPOCH);
1065 let current = CompactionProjectionId::from_validated_transcript_rewrite(
1066 session_id.clone(),
1067 &commit,
1068 &authority,
1069 )
1070 .unwrap();
1071 let legacy =
1072 CompactionProjectionId::legacy_from_typed_compaction(session_id.clone(), &commit)
1073 .unwrap();
1074 assert_ne!(
1075 legacy, current,
1076 "typed semantic changes the canonical fingerprint"
1077 );
1078 assert!(legacy.matches_transcript_rewrite(&session_id, &commit));
1079 }
1080
1081 #[test]
1082 fn overlaps_is_half_open() {
1083 assert!(range(0, 5).overlaps(&range(4, 6)));
1085 assert!(range(4, 6).overlaps(&range(0, 5)));
1086 assert!(range(0, 10).overlaps(&range(3, 4)));
1088 assert!(range(3, 4).overlaps(&range(0, 10)));
1089 assert!(!range(0, 5).overlaps(&range(5, 10)));
1091 assert!(!range(5, 10).overlaps(&range(0, 5)));
1092 assert!(!range(0, 2).overlaps(&range(7, 9)));
1094 }
1095
1096 #[test]
1097 fn empty_range_never_overlaps() {
1098 assert!(!range(3, 3).overlaps(&range(0, 10)));
1099 assert!(!range(0, 10).overlaps(&range(3, 3)));
1100 assert!(!range(3, 3).overlaps(&range(3, 3)));
1101 }
1102
1103 #[test]
1104 fn unsupported_error_code_is_stable() {
1105 assert_eq!(
1106 MemoryStoreError::Unsupported {
1107 operation: "drop_scope",
1108 }
1109 .error_code(),
1110 "memory_unsupported"
1111 );
1112 }
1113
1114 fn metadata_at(
1115 indexed_at: crate::time_compat::SystemTime,
1116 source: MemorySource,
1117 ) -> MemoryMetadata {
1118 MemoryMetadata {
1119 session_id: crate::types::SessionId::new(),
1120 source,
1121 indexed_at,
1122 }
1123 }
1124
1125 #[test]
1126 fn enumeration_request_admits_on_source_overlap() {
1127 let request = MemoryEnumerationRequest {
1128 limit: 10,
1129 offset: 0,
1130 source_overlap: Some(range(4, 6)),
1131 indexed_after: None,
1132 };
1133 let overlapping = metadata_at(
1134 UNIX_EPOCH,
1135 MemorySource::Compaction {
1136 source_range: range(0, 5),
1137 },
1138 );
1139 let disjoint = metadata_at(
1140 UNIX_EPOCH,
1141 MemorySource::Compaction {
1142 source_range: range(6, 9),
1143 },
1144 );
1145 assert!(request.admits(&overlapping));
1146 assert!(!request.admits(&disjoint));
1147 }
1148
1149 #[test]
1150 fn enumeration_request_indexed_after_is_strict() {
1151 let boundary = UNIX_EPOCH + Duration::from_secs(100);
1152 let request = MemoryEnumerationRequest {
1153 limit: 10,
1154 offset: 0,
1155 source_overlap: None,
1156 indexed_after: Some(boundary),
1157 };
1158 let at_boundary = metadata_at(
1159 boundary,
1160 MemorySource::Compaction {
1161 source_range: range(0, 1),
1162 },
1163 );
1164 let after_boundary = metadata_at(
1165 boundary + Duration::from_secs(1),
1166 MemorySource::Compaction {
1167 source_range: range(0, 1),
1168 },
1169 );
1170 let before_boundary = metadata_at(
1171 UNIX_EPOCH,
1172 MemorySource::Compaction {
1173 source_range: range(0, 1),
1174 },
1175 );
1176 assert!(!request.admits(&at_boundary));
1177 assert!(request.admits(&after_boundary));
1178 assert!(!request.admits(&before_boundary));
1179 }
1180
1181 #[test]
1182 fn enumeration_request_filters_compose() {
1183 let request = MemoryEnumerationRequest {
1184 limit: 10,
1185 offset: 0,
1186 source_overlap: Some(range(0, 5)),
1187 indexed_after: Some(UNIX_EPOCH + Duration::from_secs(100)),
1188 };
1189 let both = metadata_at(
1190 UNIX_EPOCH + Duration::from_secs(200),
1191 MemorySource::Compaction {
1192 source_range: range(2, 3),
1193 },
1194 );
1195 let wrong_range = metadata_at(
1196 UNIX_EPOCH + Duration::from_secs(200),
1197 MemorySource::Compaction {
1198 source_range: range(5, 9),
1199 },
1200 );
1201 let too_early = metadata_at(
1202 UNIX_EPOCH,
1203 MemorySource::Compaction {
1204 source_range: range(2, 3),
1205 },
1206 );
1207 assert!(request.admits(&both));
1208 assert!(!request.admits(&wrong_range));
1209 assert!(!request.admits(&too_early));
1210 }
1211
1212 struct MinimalStore;
1216
1217 #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
1218 #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
1219 impl MemoryStore for MinimalStore {
1220 async fn index_scoped_batch(
1221 &self,
1222 batch: MemoryIndexBatch,
1223 ) -> Result<MemoryIndexReceipt, MemoryStoreError> {
1224 let (scope, requests) = batch.into_parts();
1225 Ok(MemoryIndexReceipt {
1226 scope,
1227 indexed_entries: requests.len(),
1228 })
1229 }
1230
1231 async fn search(
1232 &self,
1233 _scope: &MemorySearchScope,
1234 _query: &str,
1235 _limit: usize,
1236 ) -> Result<Vec<MemoryResult>, MemoryStoreError> {
1237 Ok(Vec::new())
1238 }
1239 }
1240
1241 #[tokio::test]
1242 async fn drop_scope_default_is_typed_unsupported() {
1243 let store = MinimalStore;
1244 assert_eq!(
1245 store.compaction_projection_persistence(),
1246 CompactionProjectionPersistence::Unsupported
1247 );
1248 let owner = MemoryOwner::canonical_session(crate::types::SessionId::new());
1249 let error = store.drop_scope(&owner).await.unwrap_err();
1250 assert!(matches!(
1251 error,
1252 MemoryStoreError::Unsupported {
1253 operation: "drop_scope",
1254 }
1255 ));
1256 assert_eq!(error.error_code(), "memory_unsupported");
1257 }
1258
1259 #[tokio::test]
1260 async fn reconcile_compaction_stages_default_is_typed_unsupported() {
1261 let store = MinimalStore;
1262 let owner = MemoryOwner::canonical_session(crate::types::SessionId::new());
1263 let error = store
1264 .reconcile_compaction_stages(&owner, &[])
1265 .await
1266 .unwrap_err();
1267 assert!(matches!(
1268 error,
1269 MemoryStoreError::Unsupported {
1270 operation: "reconcile_compaction_stages",
1271 }
1272 ));
1273 }
1274
1275 #[tokio::test]
1276 async fn enumerate_scoped_default_is_typed_unsupported() {
1277 let store = MinimalStore;
1278 let scope = MemorySearchScope::for_session(crate::types::SessionId::new());
1279 let error = store
1280 .enumerate_scoped(
1281 &scope,
1282 MemoryEnumerationRequest {
1283 limit: 10,
1284 offset: 0,
1285 source_overlap: None,
1286 indexed_after: None,
1287 },
1288 )
1289 .await
1290 .unwrap_err();
1291 assert!(matches!(
1292 error,
1293 MemoryStoreError::Unsupported {
1294 operation: "enumerate_scoped",
1295 }
1296 ));
1297 assert_eq!(error.error_code(), "memory_unsupported");
1298 }
1299}