diff --git a/README.md b/README.md index 72cd7443..ba5afce3 100644 --- a/README.md +++ b/README.md @@ -75,9 +75,13 @@ let context = RetrievalContext::new("What happened last Tuesday?") Date match contributes the newest recallable episodes inside the range, including both endpoints, up to the largest requested section cap. All-zero section caps contribute none. Its provisional floor is one and only sets the reservation at roots and sections; it takes no spare root turn. Other cues can still recall episodes outside the range, and a shared hit keeps its score and standing. The reference scene time does not shorten the span. An inverted range matches nothing and is echoed unchanged, as every supplied range is, even with tracing off. With tracing enabled, `time_range_has_more` says whether the range contained more eligible occasions than this source contributed; later caps can still omit those contributions. It reads one extra ID and performs no count query. A crowded range is not a filtered topic search: an older topical memory inside it can still be missed when the unrestricted topic search ranks it below its cap. +The scene time includes the offset supplied by the application. Writing an episode derives its local calendar date from that offset; the stored instant stays in UTC, so reading the scene preserves the instant without reconstructing the original offset. For anniversary matching, retrieval must use the same offset as the writes; the recorded local day is available on `Episode.scene_local_date`. That local date is persisted content: replaying the same ID and instant with an offset that changes its local date is a collision; another offset giving the same local date is the same content. + +The same local month and day in an earlier local year contributes anniversary occasions as date matches, with February 29 matching only February 29. Shared occasions come first, then the most recent by recorded instant and ID, up to the date-match floor and at least one. An anniversary reserves room only when it was shared with a notion that the present scene resolves to one identity. An ambiguous name grants no anniversary reservation. Unshared anniversaries still contribute and compete for unclaimed room; the existing score can leave them out. The range and anniversary share one date-match floor. A time-only reminder brings what rests on the occasion and does not open a notion or thread's history. With tracing enabled, `anniversary_has_more` reports whether another eligible anniversary existed beyond the contribution limit. Episodes stored without a derived local date remain readable and do not match anniversaries; no migration invents their local dates. + Recent recallable episodes at or before the scene time can come to mind without any other cue, together with memories resting on those occasions. Recorded scene time determines recency, not creation time. Recency contributes the newest occasions up to the largest requested section cap, in newest-first selector order; all-zero section caps contribute none. Its floor ships at zero until measured. It takes no spare turn at root selection and uses only room left after the given cue kinds, so full roots remain unchanged at the default floor. The root and section caps still bound what is recalled. Every reminder road, including scene descriptions, stays on its occasion: a person, thread or interpreted memory reached through it is a leaf. Another occasion does not open or inherit the reminder kind through shared work. An object that actually expands as a root keeps the best proximity of every road that reached it; objects reached only through roots inherit proximity from full-standing roads when present. -Once a recent occasion has root room, section selection uses the ordinary score: a low-scoring Topic descendant can yield to a salient recent occasion under a section cap. Recency has zero cue score, so even its most salient root scores at most 0.35; stronger Topic roots keep their places. A raised recency floor reserves the latest contributions at roots and sections. Remaining section room uses score; among contributed time occasions with equal scores, date matches precede recency-only occasions, then recorded scene time breaks the tie before ID. With a zero floor, an older, more salient contribution may survive when the newest does not. Chronological presentation belongs to the renderer: admitted memories already report their recorded source scenes. +Once a recent occasion has root room, section selection uses the ordinary score: a low-scoring Topic descendant can yield to a salient recent occasion under a section cap. Recency has zero cue score, so even its most salient root scores at most 0.35; stronger Topic roots keep their places. A raised recency floor reserves the latest contributions at roots and sections. Remaining section room uses score; among contributed time occasions with equal scores, caller-supplied ranges and shared resolved anniversaries precede recency and unshared anniversaries, then recorded scene time breaks the tie before ID. With a zero floor, an older, more salient contribution may survive when the newest does not. Chronological presentation belongs to the renderer: admitted memories already report their recorded source scenes. Thread members are taken most recent first by creation time; an open loop's source episodes are taken most recent first by recorded scene time. diff --git a/docs/design/database/graph_schema_design.md b/docs/design/database/graph_schema_design.md index ff76b1c4..1d625cda 100644 --- a/docs/design/database/graph_schema_design.md +++ b/docs/design/database/graph_schema_design.md @@ -25,7 +25,11 @@ Hydration reconstructs domain objects and links from the selected RDF named grap Episodes record interaction spans with `summary`, `modality`, one `Scene`, optional `endedAt` and `rawRef`, salience and retention. The scene holds the required experience time, participants with any combination of notion key, supplied name and description, a setting key and/or words, and flat string custom values. Missing scene parts stay unspecified. Observations record `text` tied to an `episode`, with an optional `speakerEntity` and raw reference. Preparation defaults observation time to scene time while preserving an explicit observation time. Involved notions, participants, speakers, thread memberships and the episode end time remain independent. -The episode owns the scene. `sceneTime` stores its timestamp losslessly; `sceneParticipants` and `sceneCustomValues` store JSON literals, preserving participant order, duplicates, spelling and custom string values. `settingKey` is a directly queryable literal, separate from `settingWords`. Keys identify application contexts, not notions; sessions may be recorded in custom values. Embedding construction is defined in the [vector payload design](vector_payload_design.md). +The episode owns the scene. `sceneTime` stores its UTC instant losslessly; `sceneParticipants` and `sceneCustomValues` store JSON literals, preserving participant order, duplicates, spelling and custom string values. `settingKey` is a directly queryable literal, separate from `settingWords`. Keys identify application contexts, not notions; sessions may be recorded in custom values. Embedding construction is defined in the [vector payload design](vector_payload_design.md). + +`Scene.time` accepts an offset-carrying instant. Episode construction derives `scene_local_date` from the supplied offset, and RDF stores it as the `sceneLocalYear` and `sceneMonthDay` literals. It is not a draft input. A UTC calendar day would invent the experienced day when the supplied offset crosses midnight. Hydration reconstructs the date for replay equality while retaining the existing UTC `sceneTime` representation. The original offset is not reconstructed from the date. An episode without these literals has no local date and never matches an anniversary; its existing instant stays readable, with no migration. + +Anniversary selection matches `sceneMonthDay` exactly and compares `sceneLocalYear` with the reference scene's local year. It reads eligible episode IDs with a shared-occasion flag, ordered shared first, then recorded instant descending and ID ascending, and fetches one beyond the contribution limit for the trace marker. Sharing uses both orientations of `Involves` episode links and `Mentions` observation-to-parent-episode links, with the existing retention checks before the cut. Only a resolved scene identity supplies a sharing key; ambiguous names retain their other recall behavior but grant no anniversary reservation. Full episode hydration is bounded to selected roots and their graph expansion. Unshared matches retain date-match provenance while remaining outside that kind's reservation queue. Commit never substitutes a clock reading for a scene time. diff --git a/docs/design/database/schema_cheat_sheet.md b/docs/design/database/schema_cheat_sheet.md index fbb14f5d..41034cea 100644 --- a/docs/design/database/schema_cheat_sheet.md +++ b/docs/design/database/schema_cheat_sheet.md @@ -58,7 +58,8 @@ Predicate names in these tables are suffixes under `urn:cmem:vocab:`. | `objectId`, `objectType`, `graphUri`, `schemaVersion` | Common identity and schema literals | | `createdAt`, `updatedAt` | Timestamps on the types that declare them | | `modality`, `endedAt` | Episode modality and optional interval end | -| `sceneTime` | Required experience time on the episode | +| `sceneTime` | Required experience instant, stored in UTC without precision loss | +| `sceneLocalYear`, `sceneMonthDay` | Local date derived from the supplied scene offset; exact anniversary match and replay equality | | `sceneParticipants` | Lossless JSON array of participants, each with optional key, name and description; a key or nonblank words required | | `settingKey`, `settingWords` | Independently optional context key and words; the key is directly queryable | | `sceneCustomValues` | Lossless JSON object of string custom values | diff --git a/src/adapters/oxigraph/embedded.rs b/src/adapters/oxigraph/embedded.rs index d59a62ff..7742e8e6 100644 --- a/src/adapters/oxigraph/embedded.rs +++ b/src/adapters/oxigraph/embedded.rs @@ -106,6 +106,21 @@ impl OxigraphGraphAuthorityStore { #[async_trait] impl GraphAuthorityStore for OxigraphGraphAuthorityStore { + async fn query_anniversaries( + &self, + date: chrono::NaiveDate, + participants: &[MemoryId], + limit: usize, + policy: GraphExpansionLifecyclePolicy, + ) -> Result, CustomError> { + SparqlGraphSelectors::new(&self.store).select_anniversaries( + date, + participants, + limit, + policy, + ) + } + async fn query_episodes_by_time( &self, start: Option>, diff --git a/src/adapters/oxigraph/rdf_mapping.rs b/src/adapters/oxigraph/rdf_mapping.rs index 9c862431..2a7530d7 100644 --- a/src/adapters/oxigraph/rdf_mapping.rs +++ b/src/adapters/oxigraph/rdf_mapping.rs @@ -134,7 +134,11 @@ fn episode_triples(episode: &Episode) -> Vec { enum_value(episode.retention_state), ), RdfTriple::literal(&subject, vocab::CREATED_AT, timestamp(episode.created_at)), - RdfTriple::literal(&subject, vocab::SCENE_TIME, timestamp(episode.scene.time)), + RdfTriple::literal( + &subject, + vocab::SCENE_TIME, + timestamp(episode.scene.time.to_utc()), + ), RdfTriple::literal( &subject, vocab::SCENE_PARTICIPANTS, @@ -148,6 +152,20 @@ fn episode_triples(episode: &Episode) -> Vec { .expect("scene custom values contain only strings"), ), ]); + if let Some(date) = episode.scene_local_date { + triples.extend([ + RdfTriple::literal( + &subject, + vocab::SCENE_LOCAL_YEAR, + date.format("%Y").to_string(), + ), + RdfTriple::literal( + &subject, + vocab::SCENE_MONTH_DAY, + date.format("%m-%d").to_string(), + ), + ]); + } push_optional_literal( &mut triples, &subject, diff --git a/src/adapters/oxigraph/shared.rs b/src/adapters/oxigraph/shared.rs index 4ce5591c..947587d3 100644 --- a/src/adapters/oxigraph/shared.rs +++ b/src/adapters/oxigraph/shared.rs @@ -292,7 +292,8 @@ pub(super) fn memory_object_from_rdf( object_type, modality: enum_literal(subject, values, super::vocabulary::MODALITY)?, scene: crate::domain::Scene { - time: timestamp_literal(subject, values, super::vocabulary::SCENE_TIME)?, + time: timestamp_literal(subject, values, super::vocabulary::SCENE_TIME)? + .fixed_offset(), participants: serde_json::from_str( &values.literal(subject, super::vocabulary::SCENE_PARTICIPANTS)?, ) @@ -310,6 +311,15 @@ pub(super) fn memory_object_from_rdf( rdf_parse_error(subject, super::vocabulary::SCENE_CUSTOM_VALUES, error) })?, }, + scene_local_date: values + .optional_literal(super::vocabulary::SCENE_LOCAL_YEAR) + .map(|year| { + let month_day = values.literal(subject, super::vocabulary::SCENE_MONTH_DAY)?; + format!("{year}-{month_day}").parse().map_err(|error| { + rdf_parse_error(subject, super::vocabulary::SCENE_LOCAL_YEAR, error) + }) + }) + .transpose()?, ended_at: optional_timestamp_literal(values, super::vocabulary::ENDED_AT)?, summary: values.literal(subject, super::vocabulary::SUMMARY)?, raw_ref: values.optional_literal(super::vocabulary::RAW_REF), diff --git a/src/adapters/oxigraph/sparql_selectors.rs b/src/adapters/oxigraph/sparql_selectors.rs index eb2c39af..b6938f3a 100644 --- a/src/adapters/oxigraph/sparql_selectors.rs +++ b/src/adapters/oxigraph/sparql_selectors.rs @@ -524,6 +524,93 @@ impl<'a> SparqlGraphSelectors<'a> { Ok(occasions) } + pub(crate) fn select_anniversaries( + &self, + date: chrono::NaiveDate, + participants: &[MemoryId], + limit: usize, + policy: GraphExpansionLifecyclePolicy, + ) -> Result, CustomError> { + if limit == 0 { + return Ok(Vec::new()); + } + let shared = if participants.is_empty() { + "false".to_owned() + } else { + let values = participants + .iter() + .map(|id| format!("<{}>", graph_uri(ObjectType::Entity, *id))) + .collect::>() + .join(" "); + format!( + r#"EXISTS {{ + VALUES ?participant {{ {values} }} + {{ + GRAPH ?linkGraph {{ + ?link a <{link_class}> ; <{relation}> "involves" . + {{ ?link <{from}> ?participant ; <{to}> ?episode . }} + UNION {{ ?link <{to}> ?participant ; <{from}> ?episode . }} + }} + }} UNION {{ + GRAPH ?linkGraph {{ + ?link a <{link_class}> ; <{relation}> "mentions" . + {{ ?link <{from}> ?participant ; <{to}> ?neighbor . }} + UNION {{ ?link <{to}> ?participant ; <{from}> ?neighbor . }} + }} + GRAPH ?neighbor {{ ?neighbor <{object_type}> "observation" ; <{episode_pred}> ?episode ; <{retention}> ?neighborRetention . }} + {retention_filter} + }} + }}"#, + link_class = vocab::CLASS_MEMORY_LINK, + from = vocab::FROM, + to = vocab::TO, + relation = vocab::RELATION, + object_type = vocab::OBJECT_TYPE, + episode_pred = vocab::EPISODE, + retention = vocab::RETENTION_STATE, + retention_filter = + occasion_retention_filter(policy).replace("?retention", "?neighborRetention") + ) + }; + let query = format!( + r#" + PREFIX xsd: + SELECT DISTINCT ?episodeId ?time ?shared WHERE {{ + GRAPH ?episode {{ + ?episode <{object_type}> "episode" ; <{object_id}> ?episodeId ; + <{month_day}> {month_day_value} ; <{local_year}> ?localYear ; + <{scene_time}> ?sceneTime ; <{retention}> ?episodeRetention . + }} + FILTER(xsd:integer(?localYear) < {year}) + BIND(?episodeRetention AS ?retention) + {retention_filter} + BIND(xsd:dateTime(?sceneTime) AS ?time) + BIND({shared} AS ?shared) + }} + ORDER BY DESC(?shared) DESC(?time) ?episodeId + LIMIT {limit} + "#, + object_type = vocab::OBJECT_TYPE, + object_id = vocab::OBJECT_ID, + month_day = vocab::SCENE_MONTH_DAY, + local_year = vocab::SCENE_LOCAL_YEAR, + month_day_value = sparql_string_literal(&date.format("%m-%d").to_string()), + year = chrono::Datelike::year(&date), + scene_time = vocab::SCENE_TIME, + retention = vocab::RETENTION_STATE, + retention_filter = occasion_retention_filter(policy) + ); + self.query_solutions(&query)? + .iter() + .map(|row| { + Ok(( + memory_id_binding(row, "episodeId")?, + literal_binding(row, "shared")? == "true", + )) + }) + .collect() + } + pub(crate) fn select_episodes_by_time( &self, start: Option>, diff --git a/src/adapters/oxigraph/tests.rs b/src/adapters/oxigraph/tests.rs index bdea80bd..05067bd4 100644 --- a/src/adapters/oxigraph/tests.rs +++ b/src/adapters/oxigraph/tests.rs @@ -116,7 +116,7 @@ mod tests { let recent = store .query_episodes_by_time( None, - fixtures.episode.scene.time, + fixtures.episode.scene.time.to_utc(), 1, GraphExpansionLifecyclePolicy::default(), ) @@ -125,6 +125,57 @@ mod tests { assert_eq!(recent, [fixtures.episode.id]); } + #[tokio::test] + async fn anniversary_selector_reads_metadata_and_skips_legacy_episodes() { + use super::super::vocabulary as vocab; + let store = OxigraphGraphAuthorityStore::new_in_memory().unwrap(); + let legacy = representative_fixtures().episode; + assert_eq!(legacy.scene_local_date, None); + let mut dated = legacy.clone(); + dated.id = MemoryId::from_u128(900); + dated.scene_local_date = Some("2025-09-21".parse().unwrap()); + dated.scene.participants = vec![crate::SceneParticipant { + key: Some(MemoryId::from_u128(7)), + ..Default::default() + }]; + store + .upsert_objects(&[ + MemoryObject::Episode(legacy.clone()), + MemoryObject::Episode(dated.clone()), + ]) + .await + .unwrap(); + assert_eq!( + store + .query_objects(&GraphObjectQuery::by_ids(vec![legacy.id])) + .await + .unwrap(), + [MemoryObject::Episode(legacy)] + ); + // A selector must not need the episode's content to find a match. + for quad in store.store.iter().map(Result::unwrap).collect::>() { + if quad.predicate.as_str() == vocab::SUMMARY { + store.store.remove(&quad).unwrap(); + } + } + assert_eq!( + store + .query_anniversaries( + "2026-09-21".parse().unwrap(), + &[MemoryId::from_u128(7)], + 1, + GraphExpansionLifecyclePolicy::default() + ) + .await + .unwrap(), + [(dated.id, false)] + ); + assert!(store + .query_objects(&GraphObjectQuery::by_ids(vec![dated.id])) + .await + .is_err()); + } + #[tokio::test] async fn time_store_cut_uses_eligible_metadata() { let store = OxigraphGraphAuthorityStore::new_in_memory().unwrap(); @@ -146,19 +197,29 @@ mod tests { .collect::>(); store.upsert_objects(&objects).await.unwrap(); let ids = store - .query_episodes_by_time(None, reference, 3, GraphExpansionLifecyclePolicy::default()) + .query_episodes_by_time( + None, + reference.to_utc(), + 3, + GraphExpansionLifecyclePolicy::default(), + ) .await .unwrap(); assert_eq!(ids, [1999, 1998, 1997].map(MemoryId::from_u128)); let ids = store - .query_episodes_by_time(None, reference, 0, GraphExpansionLifecyclePolicy::default()) + .query_episodes_by_time( + None, + reference.to_utc(), + 0, + GraphExpansionLifecyclePolicy::default(), + ) .await .unwrap(); assert!(ids.is_empty()); let ids = store .query_episodes_by_time( None, - reference, + reference.to_utc(), 1, GraphExpansionLifecyclePolicy { include_suppressed: true, @@ -170,8 +231,8 @@ mod tests { assert_eq!(ids, [MemoryId::from_u128(2000)]); let ids = store .query_episodes_by_time( - Some(reference - chrono::Duration::milliseconds(3)), - reference, + Some(reference.to_utc() - chrono::Duration::milliseconds(3)), + reference.to_utc(), 4, GraphExpansionLifecyclePolicy::default(), ) @@ -188,7 +249,7 @@ mod tests { .unwrap() .with_timezone(&chrono::Utc); fixtures.episode.created_at = timestamp; - fixtures.episode.scene.time = timestamp; + fixtures.episode.scene.time = timestamp.fixed_offset(); fixtures.episode.ended_at = Some(timestamp); fixtures.salient_observation.created_at = timestamp; fixtures.salient_observation.observed_at = Some(timestamp); diff --git a/src/adapters/oxigraph/vocabulary.rs b/src/adapters/oxigraph/vocabulary.rs index ea021124..18201c4d 100644 --- a/src/adapters/oxigraph/vocabulary.rs +++ b/src/adapters/oxigraph/vocabulary.rs @@ -16,6 +16,8 @@ pub(crate) const UPDATED_AT: &str = "urn:cmem:vocab:updatedAt"; pub(crate) const MODALITY: &str = "urn:cmem:vocab:modality"; pub(crate) const SCENE_TIME: &str = "urn:cmem:vocab:sceneTime"; +pub(crate) const SCENE_LOCAL_YEAR: &str = "urn:cmem:vocab:sceneLocalYear"; +pub(crate) const SCENE_MONTH_DAY: &str = "urn:cmem:vocab:sceneMonthDay"; pub(crate) const SCENE_PARTICIPANTS: &str = "urn:cmem:vocab:sceneParticipants"; pub(crate) const SETTING_KEY: &str = "urn:cmem:vocab:settingKey"; pub(crate) const SETTING_WORDS: &str = "urn:cmem:vocab:settingWords"; diff --git a/src/api/types/draft.rs b/src/api/types/draft.rs index fd18be6b..49123c15 100644 --- a/src/api/types/draft.rs +++ b/src/api/types/draft.rs @@ -146,14 +146,16 @@ impl EpisodeDraft { self, defaults: &mut DraftDefaults, ) -> Result { + let scene = self + .scene + .ok_or(DomainValidationError::MissingScene)? + .without_blank_participants(); let episode = Episode { id: defaults.id(self.id), object_type: ObjectType::Episode, modality: self.modality, - scene: self - .scene - .ok_or(DomainValidationError::MissingScene)? - .without_blank_participants(), + scene_local_date: Some(scene.time.date_naive()), + scene, ended_at: self.ended_at, summary: self.summary, raw_ref: self.raw_ref, @@ -647,7 +649,7 @@ mod tests { let created_at = timestamp("2026-04-28T12:01:00Z"); let mut draft = EpisodeDraft::new("Discussed durable draft inputs."); draft.id = Some(id); - let mut scene = Scene::at(created_at); + let mut scene = Scene::at((created_at).fixed_offset()); scene.setting.key = Some("conversation-42".to_owned()); draft.scene = Some(scene); draft.raw_ref = Some("raw://conversation/42#episode".to_owned()); diff --git a/src/api/types/retrieval.rs b/src/api/types/retrieval.rs index 295d79fa..5b5ece0b 100644 --- a/src/api/types/retrieval.rs +++ b/src/api/types/retrieval.rs @@ -550,6 +550,8 @@ pub struct RetrievalTrace { /// `None` means no range was supplied. Determined by one extra bounded ID read. #[serde(skip_serializing_if = "Option::is_none")] pub time_range_has_more: Option, + /// More eligible occasions share this local calendar date in earlier years. + pub anniversary_has_more: bool, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub scene_cue_searches: Vec, pub floor_admissions: Vec, @@ -567,6 +569,7 @@ impl RetrievalTrace { Self { vector_candidates: Vec::new(), time_range_has_more: None, + anniversary_has_more: false, scene_cue_searches: Vec::new(), floor_admissions: Vec::new(), graph_relations: Vec::new(), @@ -834,6 +837,7 @@ mod tests { fn episode(id: MemoryId) -> Episode { Episode { + scene_local_date: None, id, object_type: ObjectType::Episode, modality: Modality::Chat, @@ -843,7 +847,7 @@ mod tests { words: None, }, - ..crate::domain::Scene::at(timestamp("2026-04-29T10:00:00Z")) + ..crate::domain::Scene::at((timestamp("2026-04-29T10:00:00Z")).fixed_offset()) }, ended_at: Some(timestamp("2026-04-29T10:05:00Z")), summary: "Discussed context packs.".to_owned(), diff --git a/src/domain.rs b/src/domain.rs index 287d6949..b4a23baf 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -298,6 +298,9 @@ pub struct Episode { pub object_type: ObjectType, pub modality: Modality, pub scene: Scene, + /// Calendar date at the supplied scene offset, derived when the episode is built. + /// Episodes stored without this derivation have no anniversary date. + pub scene_local_date: Option, pub ended_at: Option>, pub summary: String, pub raw_ref: Option, diff --git a/src/domain/scene.rs b/src/domain/scene.rs index e2c77ffb..936b60df 100644 --- a/src/domain/scene.rs +++ b/src/domain/scene.rs @@ -1,6 +1,6 @@ use std::collections::BTreeMap; -use chrono::{DateTime, Utc}; +use chrono::{DateTime, FixedOffset, Utc}; use serde::{Deserialize, Serialize}; use super::MemoryId; @@ -8,14 +8,16 @@ use super::MemoryId; /// The situation as perceived. Omitted participants do not mean nobody was present. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct Scene { - pub time: DateTime, + /// Use the same offset for retrieval as for writes when matching anniversaries. + /// The recorded local day is stored on `Episode::scene_local_date`. + pub time: DateTime, pub participants: Vec, pub setting: SceneSetting, pub custom_values: BTreeMap, } impl Scene { - pub fn at(time: DateTime) -> Self { + pub fn at(time: DateTime) -> Self { Self { time, participants: Vec::new(), @@ -25,7 +27,7 @@ impl Scene { } pub fn now() -> Self { - Self::at(Utc::now()) + Self::at(Utc::now().fixed_offset()) } pub(crate) fn without_blank_participants(mut self) -> Self { diff --git a/src/domain/tests.rs b/src/domain/tests.rs index 903d97cf..79b70be1 100644 --- a/src/domain/tests.rs +++ b/src/domain/tests.rs @@ -76,6 +76,7 @@ fn canonical_identity_and_order_ranks_are_stable() { fn representative_episode() -> Episode { Episode { + scene_local_date: None, id: memory_id("550e8400-e29b-41d4-a716-446655440000"), object_type: ObjectType::Episode, modality: Modality::Chat, @@ -88,7 +89,7 @@ fn representative_episode() -> Episode { key: Some(memory_id("550e8400-e29b-41d4-a716-446655440001")), ..Default::default() }], - ..crate::domain::Scene::at(timestamp("2026-04-27T10:00:00Z")) + ..crate::domain::Scene::at((timestamp("2026-04-27T10:00:00Z")).fixed_offset()) }, ended_at: Some(timestamp("2026-04-27T10:05:00Z")), summary: "Discussed the episodic memory domain model.".to_owned(), diff --git a/src/memory.rs b/src/memory.rs index 3098b219..9de20b37 100644 --- a/src/memory.rs +++ b/src/memory.rs @@ -1061,7 +1061,7 @@ mod tests { fn warning_input(offset: u128) -> RememberInput { let mut episode = EpisodeDraft::new("A recorded occasion"); episode.id = Some(Uuid::from_u128(offset)); - episode.scene = Some(Scene::at(warning_time())); + episode.scene = Some(Scene::at(warning_time().fixed_offset())); episode.created_at = Some(warning_time()); let mut observation = ObservationDraft::new(Uuid::from_u128(offset), "The observed details"); @@ -1383,7 +1383,7 @@ mod tests { .await .unwrap(); let mut input = warning_input(300); - let mut scene = Scene::at(warning_time()); + let mut scene = Scene::at(warning_time().fixed_offset()); scene.participants = keys .iter() .map(|key| SceneParticipant { diff --git a/src/memory/notion_tests.rs b/src/memory/notion_tests.rs index cad8d7b3..cb84bb1d 100644 --- a/src/memory/notion_tests.rs +++ b/src/memory/notion_tests.rs @@ -640,7 +640,7 @@ async fn repeated_episode_and_belief_id_sets_commit_and_replay_canonically() { .unwrap(); let mut episode = EpisodeDraft::new("An experience with two notions."); episode.id = Some(episode_id); - let mut scene = crate::Scene::at(belief.created_at.unwrap()); + let mut scene = crate::Scene::at((belief.created_at.unwrap()).fixed_offset()); scene.participants = [subjects[1], subjects[0], subjects[0]] .into_iter() .map(|key| crate::SceneParticipant { diff --git a/src/memory/retrieval_floor_tests.rs b/src/memory/retrieval_floor_tests.rs index 1cf8c972..d554732f 100644 --- a/src/memory/retrieval_floor_tests.rs +++ b/src/memory/retrieval_floor_tests.rs @@ -24,8 +24,10 @@ async fn open_loop_activity_reserves_the_latest_recorded_source() { for (id, days) in [(1, 30), (2, 0), (3, 1)] { let mut draft = EpisodeDraft::new(format!("Source {id}")); draft.id = Some(MemoryId::from_u128(id)); - draft.created_at = Some(occasion().time + chrono::Duration::days(id as i64)); - draft.scene = Some(Scene::at(occasion().time - chrono::Duration::days(days))); + draft.created_at = Some(occasion().time.to_utc() + chrono::Duration::days(id as i64)); + draft.scene = Some(Scene::at( + (occasion().time - chrono::Duration::days(days)).fixed_offset(), + )); draft.schema_version = Some(DEFAULT_SCHEMA_VERSION.to_owned()); draft.salience_score = if id == 1 { 1.0 } else { 0.0 }; plan = plan.with_candidate(MemoryCandidate::Episode(EpisodeCandidate::new( @@ -35,7 +37,7 @@ async fn open_loop_activity_reserves_the_latest_recorded_source() { } let mut draft = DerivedMemoryDraft::new(DerivedType::OpenLoop, "Finish the conversation"); draft.id = Some(MemoryId::from_u128(4)); - draft.created_at = Some(occasion().time); + draft.created_at = Some(occasion().time.to_utc()); draft.updated_at = draft.created_at; draft.schema_version = Some(DEFAULT_SCHEMA_VERSION.to_owned()); draft.derived_from_episode_ids = [1, 2, 3].map(MemoryId::from_u128).to_vec(); @@ -110,7 +112,7 @@ async fn open_loop_activity_reserves_the_latest_recorded_source() { for (id, parent) in [(9, 2), (10, 3), (11, 1)] { let mut draft = ObservationDraft::new(MemoryId::from_u128(parent), "Source observation"); draft.id = Some(MemoryId::from_u128(id)); - draft.created_at = Some(occasion().time); + draft.created_at = Some(occasion().time.to_utc()); draft.schema_version = Some(DEFAULT_SCHEMA_VERSION.to_owned()); plan = plan.with_candidate(MemoryCandidate::Observation(ObservationCandidate::new( draft, @@ -120,7 +122,7 @@ async fn open_loop_activity_reserves_the_latest_recorded_source() { for (id, source) in [(20, 9), (21, 10)] { let mut draft = DerivedMemoryDraft::new(DerivedType::OpenLoop, "Use the observation"); draft.id = Some(MemoryId::from_u128(id)); - draft.created_at = Some(occasion().time); + draft.created_at = Some(occasion().time.to_utc()); draft.updated_at = draft.created_at; draft.schema_version = Some(DEFAULT_SCHEMA_VERSION.to_owned()); draft.derived_from_observation_ids = [source, 11].map(MemoryId::from_u128).to_vec(); @@ -242,11 +244,11 @@ async fn floor_memory(scene_surfaces: bool, overlap: bool) -> CharacterMemory { let mut episode = EpisodeDraft::new("An occasion with several recollections."); episode.id = Some(MemoryId::from_u128(1)); episode.scene = Some(occasion()); - episode.created_at = Some(occasion().time); + episode.created_at = Some(occasion().time.to_utc()); episode.schema_version = Some(DEFAULT_SCHEMA_VERSION.to_owned()); let mut thread = MemoryThreadDraft::new("Work in progress", "The current activity."); thread.id = Some(MemoryId::from_u128(5000)); - thread.created_at = Some(occasion().time); + thread.created_at = Some(occasion().time.to_utc()); thread.updated_at = thread.created_at; thread.last_touched_at = thread.created_at; thread.schema_version = Some(DEFAULT_SCHEMA_VERSION.to_owned()); @@ -283,7 +285,7 @@ async fn floor_memory(scene_surfaces: bool, overlap: bool) -> CharacterMemory { } let mut episode = EpisodeDraft::new(format!("Recollection {id}")); episode.id = Some(MemoryId::from_u128(id)); - episode.created_at = Some(scene.time); + episode.created_at = Some(scene.time.to_utc()); episode.scene = Some(scene); episode.schema_version = Some(DEFAULT_SCHEMA_VERSION.to_owned()); episode.salience_score = 0.0; @@ -295,7 +297,7 @@ async fn floor_memory(scene_surfaces: bool, overlap: bool) -> CharacterMemory { let mut observation = ObservationDraft::new(MemoryId::from_u128(1), format!("Recollection {id}")); observation.id = Some(MemoryId::from_u128(id)); - observation.observed_at = Some(occasion().time); + observation.observed_at = Some(occasion().time.to_utc()); observation.created_at = observation.observed_at; observation.schema_version = Some(DEFAULT_SCHEMA_VERSION.to_owned()); observation.salience_score = 0.0; @@ -371,7 +373,7 @@ async fn overlapping_cue_memory() -> (CharacterMemory, MemoryId) { ); member.id = Some(MemoryId::from_u128(id)); member.thread_ids = vec![MemoryId::from_u128(5000)]; - member.created_at = Some(occasion().time); + member.created_at = Some(occasion().time.to_utc()); input = input.with_derived_memory(member); } let strong_id = MemoryId::from_u128(7000); @@ -402,7 +404,7 @@ async fn overlapping_scene_memory() -> (CharacterMemory, MemoryId) { scene.setting.words = Some(text.to_owned()); let mut episode = EpisodeDraft::new(text); episode.id = Some(MemoryId::from_u128(id)); - episode.created_at = Some(scene.time); + episode.created_at = Some(scene.time.to_utc()); episode.scene = Some(scene); episode.schema_version = Some(DEFAULT_SCHEMA_VERSION.to_owned()); let provenance = CandidateProvenance::caller("overlapping surfaces"); @@ -567,7 +569,7 @@ async fn a_large_activity_shares_roots_with_the_topic() { let mut member = DerivedMemoryDraft::new(DerivedType::Claim, "A detail of the work."); member.id = Some(MemoryId::from_u128(id)); member.thread_ids = vec![MemoryId::from_u128(5000)]; - member.created_at = Some(occasion().time); + member.created_at = Some(occasion().time.to_utc()); input = input.with_derived_memory(member); } memory @@ -605,7 +607,7 @@ async fn configured_root_floors_are_reserved_before_spare_slots_are_shared() { let mut member = DerivedMemoryDraft::new(DerivedType::Claim, "A detail of the work."); member.id = Some(MemoryId::from_u128(id)); member.thread_ids = vec![MemoryId::from_u128(5000)]; - member.created_at = Some(occasion().time); + member.created_at = Some(occasion().time.to_utc()); input = input.with_derived_memory(member); } memory @@ -772,7 +774,7 @@ async fn default_depth_credits_participant_inherited_through_the_episode() { let provenance = || CandidateProvenance::caller("shared occasion"); let mut person = EntityDraft::new(); person.id = Some(MemoryId::from_u128(7)); - person.created_at = Some(occasion().time); + person.created_at = Some(occasion().time.to_utc()); person.schema_version = Some(DEFAULT_SCHEMA_VERSION.to_owned()); let mut plan = RememberWritePlan::new().with_candidate(MemoryCandidate::Entity( EntityCandidate::new(person, provenance()), @@ -785,7 +787,7 @@ async fn default_depth_credits_participant_inherited_through_the_episode() { for (id, scene) in [(1, occasion()), (2, shared_scene.clone())] { let mut episode = EpisodeDraft::new("An occasion."); episode.id = Some(MemoryId::from_u128(id)); - episode.created_at = Some(scene.time); + episode.created_at = Some(scene.time.to_utc()); episode.scene = Some(scene); episode.schema_version = Some(DEFAULT_SCHEMA_VERSION.to_owned()); plan = plan.with_candidate(MemoryCandidate::Episode(EpisodeCandidate::new( @@ -799,7 +801,7 @@ async fn default_depth_credits_participant_inherited_through_the_episode() { let mut observation = ObservationDraft::new(MemoryId::from_u128(episode), format!("Recollection {id}")); observation.id = Some(MemoryId::from_u128(id)); - observation.observed_at = Some(occasion().time); + observation.observed_at = Some(occasion().time.to_utc()); observation.created_at = observation.observed_at; observation.schema_version = Some(DEFAULT_SCHEMA_VERSION.to_owned()); observation.salience_score = 0.0; diff --git a/src/memory/retrieval_scene_tests.rs b/src/memory/retrieval_scene_tests.rs index bfcc8276..9ab6eaa3 100644 --- a/src/memory/retrieval_scene_tests.rs +++ b/src/memory/retrieval_scene_tests.rs @@ -145,9 +145,10 @@ impl MemoryEmbedder for CueEmbedder { fn scene() -> Scene { Scene::at( - chrono::DateTime::parse_from_rfc3339("2026-09-20T10:00:00.123Z") + (chrono::DateTime::parse_from_rfc3339("2026-09-20T10:00:00.123Z") .unwrap() - .with_timezone(&chrono::Utc), + .with_timezone(&chrono::Utc)) + .fixed_offset(), ) } @@ -165,7 +166,7 @@ async fn create_notion(memory: &CharacterMemory, id: u128, name: Option<&str>) { let notion_id = MemoryId::from_u128(id); let mut notion = EntityDraft::new(); notion.id = Some(notion_id); - notion.created_at = Some(scene().time); + notion.created_at = Some(scene().time.to_utc()); notion.schema_version = Some(DEFAULT_SCHEMA_VERSION.to_owned()); let mut plan = RememberWritePlan::new().with_candidate(MemoryCandidate::Entity( EntityCandidate::new(notion, CandidateProvenance::caller("notion")), @@ -174,7 +175,7 @@ async fn create_notion(memory: &CharacterMemory, id: u128, name: Option<&str>) { let mut belief = DerivedMemoryDraft::new(DerivedType::Claim, "The astronomer is known by this name."); belief.id = Some(MemoryId::from_u128(id + 1000)); - belief.created_at = Some(scene().time); + belief.created_at = Some(scene().time.to_utc()); belief.updated_at = belief.created_at; belief.schema_version = Some(DEFAULT_SCHEMA_VERSION.to_owned()); belief.entity_ids = vec![notion_id]; @@ -334,8 +335,8 @@ async fn observation_forget_recounts_notion_presence_without_removing_scene_part write_episode(&memory, 31_000, scene()).await; let mut extra = ObservationDraft::new(episode, "Another remark."); extra.id = Some(MemoryId::from_u128(30_002)); - extra.created_at = Some(scene().time); - extra.observed_at = Some(scene().time); + extra.created_at = Some(scene().time.to_utc()); + extra.observed_at = Some(scene().time.to_utc()); extra.schema_version = Some(DEFAULT_SCHEMA_VERSION.to_owned()); memory .commit( @@ -405,8 +406,8 @@ async fn mentions_count_the_parent_episode_once_and_follow_its_lifecycle() { write_episode(&memory, 31_000, scene()).await; let mut extra = ObservationDraft::new(episode, "Another remark on the same occasion."); extra.id = Some(MemoryId::from_u128(30_002)); - extra.created_at = Some(scene().time); - extra.observed_at = Some(scene().time); + extra.created_at = Some(scene().time.to_utc()); + extra.observed_at = Some(scene().time.to_utc()); extra.schema_version = Some(DEFAULT_SCHEMA_VERSION.to_owned()); memory .commit( @@ -582,8 +583,8 @@ async fn assert_participant_recall_after_suppression(active_sibling: bool) { if active_sibling { let mut observation = ObservationDraft::new(latest_episode, "Another active remark."); observation.id = Some(sibling); - observation.created_at = Some(scene().time); - observation.observed_at = Some(scene().time); + observation.created_at = Some(scene().time.to_utc()); + observation.observed_at = Some(scene().time.to_utc()); observation.schema_version = Some(DEFAULT_SCHEMA_VERSION.to_owned()); memory .commit( @@ -822,7 +823,8 @@ async fn ubiquitous_participant_keeps_the_latest_occasion_across_store_sizes_and let mut episode = EpisodeDraft::new("The participant visited."); episode.id = Some(episode_id); // Authorship order deliberately disagrees with scene recency. - episode.created_at = Some(scene().time - chrono::Duration::hours(count as i64)); + episode.created_at = + Some(scene().time.to_utc() - chrono::Duration::hours(count as i64)); episode.scene = Some(occasion); episode.schema_version = Some(DEFAULT_SCHEMA_VERSION.to_owned()); let mut plan = RememberWritePlan::new().with_candidate(MemoryCandidate::Episode( @@ -832,8 +834,8 @@ async fn ubiquitous_participant_keeps_the_latest_occasion_across_store_sizes_and for offset in 1..=3 { let mut observation = ObservationDraft::new(episode_id, "A remark."); observation.id = Some(MemoryId::from_u128(id + offset)); - observation.created_at = Some(scene().time); - observation.observed_at = Some(scene().time); + observation.created_at = Some(scene().time.to_utc()); + observation.observed_at = Some(scene().time.to_utc()); observation.schema_version = Some(DEFAULT_SCHEMA_VERSION.to_owned()); plan = plan.with_candidate(MemoryCandidate::Observation( ObservationCandidate::new( @@ -957,7 +959,7 @@ async fn ubiquitous_participants_limit_occasions_without_losing_beliefs() { if caller_built { let mut episode = EpisodeDraft::new("A group worked together."); episode.id = Some(MemoryId::from_u128(id)); - episode.created_at = Some(scene().time); + episode.created_at = Some(scene().time.to_utc()); episode.schema_version = Some(DEFAULT_SCHEMA_VERSION.to_owned()); episode.scene = Some(occasion); memory @@ -1052,11 +1054,11 @@ async fn thread_activity_reads_native_members_and_reports_found_after_filtering( thread.status = ThreadStatus::Resolved; let mut member = DerivedMemoryDraft::new(DerivedType::Claim, "The lens needs cleaning."); member.id = Some(member_id); - member.created_at = Some(scene().time); + member.created_at = Some(scene().time.to_utc()); member.thread_ids = vec![thread_id]; let mut older_member = DerivedMemoryDraft::new(DerivedType::Claim, "The lens was installed."); older_member.id = Some(older_member_id); - older_member.created_at = Some(scene().time - chrono::Duration::days(1)); + older_member.created_at = Some(scene().time.to_utc() - chrono::Duration::days(1)); older_member.thread_ids = vec![thread_id]; memory .remember( @@ -1386,7 +1388,7 @@ async fn authored_episode_without_links_is_recalled_by_its_participant() { let episode_id = MemoryId::from_u128(5000); let mut episode = EpisodeDraft::new("An authored experience."); episode.id = Some(episode_id); - episode.created_at = Some(past.time); + episode.created_at = Some(past.time.to_utc()); episode.schema_version = Some(DEFAULT_SCHEMA_VERSION.to_owned()); episode.scene = Some(past.clone()); let plan = RememberWritePlan::new().with_candidate(MemoryCandidate::Episode( @@ -1813,7 +1815,7 @@ async fn write_belief( "The astronomer remembers these experiences.", ); belief.id = Some(MemoryId::from_u128(id)); - belief.created_at = Some(scene().time); + belief.created_at = Some(scene().time.to_utc()); belief.updated_at = belief.created_at; belief.schema_version = Some(DEFAULT_SCHEMA_VERSION.to_owned()); belief.derived_from_episode_ids = episodes.iter().map(|id| MemoryId::from_u128(*id)).collect(); @@ -1853,7 +1855,7 @@ async fn result_reports_all_source_scenes_beside_recent_episodes_without_trace() create_notion(&memory, 100, Some("Mira")).await; let mut thread = MemoryThreadDraft::new("Astronomer", "Plans for tomorrow."); thread.id = Some(MemoryId::from_u128(9000)); - thread.created_at = Some(scene().time); + thread.created_at = Some(scene().time.to_utc()); thread.updated_at = thread.created_at; thread.last_touched_at = thread.created_at; thread.schema_version = Some(DEFAULT_SCHEMA_VERSION.to_owned()); diff --git a/src/memory/retrieval_time_tests.rs b/src/memory/retrieval_time_tests.rs index 9928055b..3d36ef83 100644 --- a/src/memory/retrieval_time_tests.rs +++ b/src/memory/retrieval_time_tests.rs @@ -110,7 +110,7 @@ fn episode( // Deliberately unrelated to scene chronology. draft.created_at = Some(time() + Duration::days(n as i64)); draft.schema_version = Some(DEFAULT_SCHEMA_VERSION.to_owned()); - let mut scene = Scene::at(time() - Duration::days(days)); + let mut scene = Scene::at((time() - Duration::days(days)).fixed_offset()); if n == 100 { scene.time += Duration::milliseconds(125); } @@ -242,7 +242,7 @@ async fn commit(memory: &CharacterMemory, plan: RememberWritePlan) { } fn query(topic: bool, person: bool) -> RetrievalContext { - let mut scene = Scene::at(time()); + let mut scene = Scene::at((time()).fixed_offset()); if person { scene.participants.push(keyed()); } @@ -916,7 +916,7 @@ fn description_fixture(person: bool) -> RememberWritePlan { latest.id = Some(id(900)); latest.created_at = Some(time()); latest.schema_version = Some(DEFAULT_SCHEMA_VERSION.to_owned()); - let mut scene = Scene::at(time()); + let mut scene = Scene::at((time()).fixed_offset()); scene.setting.words = Some("studio".to_owned()); if person { scene.participants.push(keyed()); @@ -1113,7 +1113,7 @@ fn range_episode(plan: RememberWritePlan, n: u128, at: DateTime) -> Remembe for candidate in &mut plan.candidates { if let MemoryCandidate::Episode(candidate) = candidate { if candidate.draft.id == Some(id(n)) { - candidate.draft.scene.as_mut().unwrap().time = at; + candidate.draft.scene.as_mut().unwrap().time = at.fixed_offset(); } } } @@ -1565,3 +1565,644 @@ async fn time_range_section_cap_bounds_contribution_after_lifecycle_filtering() serde_json::to_string(&rows).unwrap() ); } + +fn ann_scene(text: &str, person: bool) -> Scene { + let mut value = serde_json::to_value(Scene::at(time().fixed_offset())).unwrap(); + value["time"] = json!(text); + let mut scene: Scene = serde_json::from_value(value).unwrap(); + if person { + scene.participants.push(keyed()); + } + scene +} + +fn ann_episode( + mut plan: RememberWritePlan, + n: u128, + text: &str, + person: bool, + topic: bool, +) -> RememberWritePlan { + let mut draft = EpisodeDraft::new(if topic { + format!("Strong {}", n % 1000) + } else { + format!("Anniversary {n}") + }); + draft.id = Some(id(n)); + draft.created_at = Some(time()); + draft.schema_version = Some(DEFAULT_SCHEMA_VERSION.to_owned()); + draft.scene = Some(ann_scene(text, person)); + draft.salience_score = if n == 900 { 1.0 } else { 0.0 }; + plan = plan.with_candidate(MemoryCandidate::Episode(EpisodeCandidate::new( + draft, + provenance(), + ))); + if person { + plan = link( + plan, + ObjectType::Episode, + n, + ObjectType::Entity, + 7, + RelationType::Involves, + ); + } + if topic { + plan = indexed(plan, ObjectType::Episode, n); + } + plan +} + +fn ann_query(text: &str, person: bool, topic: bool, cap: usize) -> RetrievalContext { + let mut context = query(topic, false).with_scene(ann_scene(text, person)); + context.section_limits = room(cap); + context +} + +fn ann_entity() -> RememberWritePlan { + let mut draft = EntityDraft::new(); + draft.id = Some(id(7)); + draft.created_at = Some(time()); + draft.schema_version = Some(DEFAULT_SCHEMA_VERSION.to_owned()); + RememberWritePlan::new().with_candidate(MemoryCandidate::Entity(EntityCandidate::new( + draft, + provenance(), + ))) +} + +async fn ann_reopen(root: &std::path::Path) -> CharacterMemory { + let settings = ::config::Config::builder() + .set_override( + "vector_store_path", + root.join("vectors").to_string_lossy().into_owned(), + ) + .unwrap() + .set_override("graph_store_mode", "persistent") + .unwrap() + .set_override( + "oxigraph_path", + root.join("graph").to_string_lossy().into_owned(), + ) + .unwrap() + .set_override("retrieval_stats_store_mode", "sqlite") + .unwrap() + .set_override( + "retrieval_stats_path", + root.join("stats.sqlite3").to_string_lossy().into_owned(), + ) + .unwrap() + .build() + .unwrap(); + CharacterMemory::new_with_embedding_provider( + Settings::new(settings).unwrap(), + "anniversary_roundtrip".to_owned(), + Box::new(TimeProvider), + ) + .await + .unwrap() +} + +#[tokio::test] +async fn anniversary_sources_reserve_only_shared_occasions() { + let mut rows = Vec::new(); + let current = "2026-09-21T20:00:00+09:00"; + let (memory, temp) = open().await; + let mut plan = ann_episode(ann_entity(), 900, "2025-09-21T08:00:00+09:00", true, false); + plan = ann_episode(plan, 700, "2025-09-21T19:00:00+09:00", false, false); + plan = ann_episode(plan, 600, "2024-09-21T08:00:00+09:00", false, true); + plan = ann_episode(plan, 500, "2023-03-02T08:00:00+09:00", true, false); + plan = belief(plan, 300, &[900, 500], false); + // These shield the anniversary from the ordinary latest-occasion/recency roads. + for n in 100..120 { + plan = ann_episode( + plan, + n, + &format!("2026-09-20T10:{:02}:00+09:00", 119 - n), + true, + false, + ); + } + for n in 2000..2048 { + plan = ann_episode(plan, n, "2026-08-10T10:00:00+09:00", false, true); + } + // Both directions and observation-to-episode semantics point to the same occasion. + plan = link( + plan, + ObjectType::Entity, + 7, + ObjectType::Episode, + 900, + RelationType::Involves, + ); + let mut observed = ObservationDraft::new(id(900), "Anniversary witness"); + observed.id = Some(id(400)); + observed.created_at = Some(time()); + observed.schema_version = Some(DEFAULT_SCHEMA_VERSION.to_owned()); + plan = plan.with_candidate(MemoryCandidate::Observation(ObservationCandidate::new( + observed, + provenance(), + ))); + plan = link( + plan, + ObjectType::Observation, + 400, + ObjectType::Entity, + 7, + RelationType::Mentions, + ); + plan = link( + plan, + ObjectType::Observation, + 400, + ObjectType::Episode, + 900, + RelationType::ObservedIn, + ); + commit(&memory, plan).await; + for (name, person, topic, day, cap) in [ + ("shared-quiet", true, false, current, 8), + ("shared-pressure", true, true, current, 8), + ("next-day", true, true, "2026-09-22T20:00:00+09:00", 8), + ("unshared-quiet", false, false, current, 2), + ("unshared-pressure", false, true, current, 8), + ( + "unshared-pressure-control", + false, + true, + "2026-09-22T20:00:00+09:00", + 8, + ), + ] { + let result = record(&mut rows, name, &memory, ann_query(day, person, topic, cap)).await; + match name { + "shared-quiet" => assert!(episodes(&result).contains(&900)), + "shared-pressure" => { + assert!(episodes(&result).contains(&900)); + assert_eq!( + result + .trace + .as_ref() + .unwrap() + .floor_admissions + .iter() + .filter(|a| a.object.id == id(900) && a.cue_kind == CueKind::DateMatch) + .count(), + 2 + ); + } + "next-day" => assert!(ann_dates(&result).is_empty()), + "unshared-quiet" => { + assert!(roots(&result).contains(&700)); + assert_eq!(ann_dates(&result), [700]); + assert!(!result + .trace + .as_ref() + .unwrap() + .floor_admissions + .iter() + .any(|a| a.cue_kind == CueKind::DateMatch)); + } + _ => assert_eq!(episodes(&result), (2000..2008).collect::>()), + } + assert!(!ann_dates(&result).contains(&500)); + } + for person in [false, true] { + let mut context = ann_query(current, person, true, 1); + context.candidate_limits.max_graph_roots = 1; + context.cue_floors.participant = 0; + let result = record( + &mut rows, + &format!("rootcap1-person-{person}"), + &memory, + context, + ) + .await; + assert_eq!(roots(&result), [if person { 900 } else { 2000 }]); + if person { + assert!(result + .pack + .salient_observations + .iter() + .any(|o| o.id == id(400))); + assert!(result + .pack + .derived_memories + .iter() + .any(|m| m.memory.id == id(300))); + assert!(!episodes(&result).contains(&500)); + } else { + assert!(result + .trace + .as_ref() + .unwrap() + .floor_admissions + .iter() + .all(|a| a.cue_kind != CueKind::DateMatch)); + } + } + for floor in [0, 1, 2, 3] { + let mut context = ann_query(current, true, false, 3); + context.cue_floors.date_match = floor; + let result = record( + &mut rows, + &format!("shared-floor-{floor}"), + &memory, + context, + ) + .await; + assert_eq!(ann_dates(&result).len(), floor.max(1)); + assert_eq!( + result.trace.as_ref().unwrap().anniversary_has_more, + floor < 3 + ); + } + let mut context = ann_query(current, true, false, 2); + context.cue_floors.date_match = 2; + context.time_range = Some(TimeRange { + start: "2026-08-10T00:00:00Z".parse().unwrap(), + end: "2026-08-10T23:59:59Z".parse().unwrap(), + }); + record(&mut rows, "range-and-anniversary", &memory, context.clone()).await; + context.cue_floors.participant = 0; + context.cue_floors.date_match = 1; + // Isolate the two direct time contributions from participant descendants. + context.graph_limits.max_depth = 0; + let together = memory.retrieve(context).await.unwrap(); + assert_eq!(episodes(&together), [900, 2000]); + let mut overlapping = ann_query(current, false, true, 1); + overlapping.candidate_limits.max_graph_roots = 1; + overlapping.time_range = Some(TimeRange { + start: "2025-09-20T00:00:00Z".parse().unwrap(), + end: "2025-09-21T23:00:00Z".parse().unwrap(), + }); + let overlapping = memory.retrieve(overlapping).await.unwrap(); + assert_eq!(roots(&overlapping), [700]); + assert!(overlapping + .trace + .as_ref() + .unwrap() + .floor_admissions + .iter() + .any(|a| a.object.id == id(700) && a.cue_kind == CueKind::DateMatch)); + memory + .forget(ForgetMemoryDraft::suppress( + LifecycleTargetRef::Episode(id(900)), + "anniversary eligibility before cut", + )) + .await + .unwrap(); + let forgotten = record( + &mut rows, + "forgotten-shared", + &memory, + ann_query(current, true, false, 2), + ) + .await; + assert_eq!(ann_dates(&forgotten), [700]); + assert!(!roots(&forgotten).contains(&900)); + let mut by_topic = ann_query(current, false, false, 1); + by_topic.topic = Some("anniversary topic".to_owned()); + assert_eq!(episodes(&memory.retrieve(by_topic).await.unwrap()), [600]); + memory.close().await.unwrap(); + temp.close().unwrap(); + + let (memory, temp) = open().await; + let mut plan = ann_episode( + RememberWritePlan::new(), + 900, + "2025-09-21T08:00:00+09:00", + false, + false, + ); + plan = ann_episode(plan, 800, "2026-09-21T08:00:00+09:00", false, false); + plan = ann_episode(plan, 700, "2026-09-21T00:30:00+14:00", false, false); + plan = ann_episode(plan, 600, "2024-02-29T08:00:00+09:00", false, false); + commit(&memory, plan).await; + for (name, day) in [ + ("local-anniversary", current), + ("local-next-day", "2026-09-22T20:00:00+09:00"), + ("leap", "2028-02-29T20:00:00+09:00"), + ("nonleap-feb28", "2027-02-28T20:00:00+09:00"), + ("nonleap-mar1", "2027-03-01T20:00:00+09:00"), + ] { + let result = record(&mut rows, name, &memory, ann_query(day, false, false, 8)).await; + assert_eq!( + ann_dates(&result), + match name { + "local-anniversary" => vec![900], + "leap" => vec![600], + _ => vec![], + } + ); + } + memory.close().await.unwrap(); + temp.close().unwrap(); +} + +fn ann_dates(result: &RetrieveOutcome) -> Vec { + result + .trace + .as_ref() + .unwrap() + .section_assignments + .iter() + .filter(|a| { + a.object.object_type == ObjectType::Episode && a.cue_kinds.contains(&CueKind::DateMatch) + }) + .map(|a| a.object.id.as_u128()) + .collect() +} + +#[tokio::test] +async fn anniversary_local_date_is_persisted_content() { + let root = tempfile::tempdir().unwrap(); + let memory = ann_reopen(root.path()).await; + let plan = ann_episode( + RememberWritePlan::new(), + 900, + "2025-09-21T08:00:00.123456789+09:00", + false, + false, + ); + commit(&memory, plan.clone()).await; + memory.close().await.unwrap(); + let memory = ann_reopen(root.path()).await; + commit(&memory, plan).await; + let collision = memory + .commit( + ann_episode( + RememberWritePlan::new(), + 900, + "2025-09-20T23:00:00.123456789Z", + false, + false, + ), + CommitOptions::default(), + ) + .await; + assert!( + matches!(collision, Err(CustomError::DeterministicIdCollision { object }) if object.id == id(900)) + ); + commit( + &memory, + ann_episode( + RememberWritePlan::new(), + 900, + "2025-09-21T09:00:00.123456789+10:00", + false, + false, + ), + ) + .await; + let context = ann_query("2026-09-21T20:00:00+09:00", false, false, 8); + let result = memory.retrieve(context.clone()).await.unwrap(); + let saved = &result.pack.relevant_episodes[0]; + assert_eq!( + saved + .scene + .time + .to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true), + "2025-09-20T23:00:00.123456789Z" + ); + assert_eq!(saved.scene_local_date, Some("2025-09-21".parse().unwrap())); + assert_eq!(ann_dates(&result), [900]); + let mut no_trace = context; + no_trace.include_trace = false; + assert_eq!(memory.retrieve(no_trace).await.unwrap().pack, result.pack); + memory.close().await.unwrap(); + root.close().unwrap(); +} + +#[tokio::test] +async fn anniversary_reservation_needs_a_resolved_notion() { + let (memory, temp) = open().await; + let mut plan = ann_episode(ann_entity(), 900, "2025-09-21T08:00:00+09:00", true, false); + plan = ann_episode(plan, 2000, "2026-08-10T10:00:00Z", false, true); + let mut other = EntityDraft::new(); + other.id = Some(id(8)); + other.created_at = Some(time()); + other.schema_version = Some(DEFAULT_SCHEMA_VERSION.to_owned()); + plan = plan.with_candidate(MemoryCandidate::Entity(EntityCandidate::new( + other, + provenance(), + ))); + for n in [7, 8] { + let mut draft = DerivedMemoryDraft::new(DerivedType::Claim, "A supplied name"); + draft.id = Some(id(100 + n)); + draft.created_at = Some(time()); + draft.updated_at = Some(time()); + draft.schema_version = Some(DEFAULT_SCHEMA_VERSION.to_owned()); + draft.given_by_application = true; + draft.entity_ids = vec![id(n)]; + draft.assertions.push(BeliefAssertion { + subject: id(n), + predicate: BeliefPredicate::KnownAs { + name: "Twin".to_owned(), + }, + }); + if n == 7 { + draft.assertions.push(BeliefAssertion { + subject: id(n), + predicate: BeliefPredicate::KnownAs { + name: "Only".to_owned(), + }, + }); + } + plan = plan.with_candidate(MemoryCandidate::DerivedMemory(DerivedMemoryCandidate::new( + draft, + provenance(), + ))); + } + commit(&memory, plan).await; + for name in ["Twin", "Only", "Unknown"] { + let mut context = ann_query("2026-09-21T20:00:00+09:00", false, true, 1); + context.scene.participants.push(SceneParticipant { + name: Some(name.to_owned()), + ..Default::default() + }); + context.candidate_limits.max_graph_roots = 1; + context.cue_floors.participant = 0; + let result = memory.retrieve(context).await.unwrap(); + if name == "Only" { + assert!( + matches!(result.scene_references[0].resolution, SceneReferenceResolution::Resolved { notion_id } if notion_id == id(7)) + ); + } + assert_eq!( + roots(&result), + [if name == "Only" { 900 } else { 2000 }], + "{name}" + ); + if name == "Twin" { + assert!( + matches!(&result.scene_references[0].resolution,SceneReferenceResolution::Ambiguous { notion_ids } if notion_ids==&vec![id(7),id(8)]) + ); + assert!(!result + .trace + .as_ref() + .unwrap() + .floor_admissions + .iter() + .any(|a| a.cue_kind == CueKind::DateMatch)); + } + } + memory.close().await.unwrap(); + temp.close().unwrap(); +} + +#[tokio::test] +async fn anniversary_sharing_uses_eligible_links_in_either_direction() { + for (mentions, reverse) in [(false, false), (false, true), (true, false), (true, true)] { + let (memory, temp) = open().await; + // No participant literal on either occasion: sharing comes from the links. + let mut plan = ann_episode(ann_entity(), 900, "2025-09-21T08:00:00+09:00", false, false); + plan = ann_episode(plan, 700, "2025-09-21T19:00:00+09:00", false, false); + plan = ann_episode(plan, 2000, "2026-08-10T10:00:00Z", false, true); + let (kind, n, relation) = if mentions { + let mut draft = ObservationDraft::new(id(900), "Shared occasion"); + draft.id = Some(id(400)); + draft.created_at = Some(time()); + draft.schema_version = Some(DEFAULT_SCHEMA_VERSION.to_owned()); + plan = plan.with_candidate(MemoryCandidate::Observation(ObservationCandidate::new( + draft, + provenance(), + ))); + (ObjectType::Observation, 400, RelationType::Mentions) + } else { + (ObjectType::Episode, 900, RelationType::Involves) + }; + plan = if reverse { + link(plan, ObjectType::Entity, 7, kind, n, relation) + } else { + link(plan, kind, n, ObjectType::Entity, 7, relation) + }; + commit(&memory, plan).await; + let mut context = ann_query("2026-09-21T20:00:00+09:00", true, true, 1); + context.cue_floors.participant = 0; + context.candidate_limits.max_graph_roots = 1; + let shared = memory.retrieve(context.clone()).await.unwrap(); + assert_eq!( + roots(&shared), + [900], + "mentions={mentions} reverse={reverse}" + ); + assert!(shared.trace.as_ref().unwrap().anniversary_has_more); + if mentions { + memory + .forget(ForgetMemoryDraft::suppress( + LifecycleTargetRef::Observation(id(400)), + "Sharing requires an eligible observation", + )) + .await + .unwrap(); + assert_eq!( + roots(&memory.retrieve(context.clone()).await.unwrap()), + [2000] + ); + context.lifecycle_policy.include_suppressed = true; + assert_eq!(roots(&memory.retrieve(context).await.unwrap()), [900]); + } + memory.close().await.unwrap(); + temp.close().unwrap(); + } +} + +#[tokio::test] +async fn unshared_anniversary_competes_with_recent_daily_occasions_by_score() { + let mut selected = Vec::new(); + let mut spare_roots = Vec::new(); + let ordinary_salience = EpisodeDraft::new("An ordinary day").salience_score; + for anniversary_salience in [ordinary_salience, 1.0] { + let (memory, temp) = open().await; + let mut plan = RememberWritePlan::new(); + for days in 0..400 { + plan = episode( + plan, + 1000 + days as u128, + days, + if days == 365 { + anniversary_salience + } else { + ordinary_salience + }, + false, + false, + ); + } + plan = episode(plan, 2000, 800, ordinary_salience, false, true); + commit(&memory, plan).await; + let result = memory + .retrieve(ann_query(&time().to_rfc3339(), false, false, 2)) + .await + .unwrap(); + assert_eq!(ann_dates(&result), [1365]); + assert_eq!(scores(&result, 1365).cue_score, Some(0.0)); + assert!(!result + .trace + .as_ref() + .unwrap() + .floor_admissions + .iter() + .any(|a| a.cue_kind == CueKind::DateMatch)); + selected.push(episodes(&result)); + for topic in [false, true] { + let mut context = ann_query(&time().to_rfc3339(), false, topic, 2); + context.candidate_limits.max_graph_roots = if topic { 2 } else { 1 }; + spare_roots.push(roots(&memory.retrieve(context).await.unwrap())); + } + memory.close().await.unwrap(); + temp.close().unwrap(); + } + assert_eq!(selected, [vec![1000, 1001], vec![1365, 1000]]); + assert_eq!( + spare_roots, + [vec![1000], vec![2000, 1000], vec![1000], vec![2000, 1000]] + ); +} + +#[tokio::test] +async fn unshared_anniversary_can_fill_spare_room_with_its_reminder_leaf() { + let (memory, temp) = open().await; + let mut plan = ann_episode(ann_entity(), 900, "2025-09-21T08:00:00+09:00", true, false); + plan = ann_episode(plan, 500, "2023-03-02T08:00:00+09:00", true, false); + plan = belief(plan, 300, &[900, 500], false); + for n in 100..120 { + plan = ann_episode(plan, n, "2026-09-20T10:00:00+09:00", false, false); + } + for n in 2000..2048 { + plan = ann_episode(plan, n, "2026-08-10T10:00:00Z", false, true); + } + commit(&memory, plan).await; + let current = "2026-09-21T20:00:00+09:00"; + let quiet = memory + .retrieve(ann_query(current, false, false, 8)) + .await + .unwrap(); + assert!(episodes(&quiet).contains(&900)); + assert!(quiet + .pack + .derived_memories + .iter() + .any(|m| m.memory.id == id(300))); + assert!(!episodes(&quiet).contains(&500)); + assert_eq!(scores(&quiet, 900).cue_score, Some(0.0)); + assert!(!quiet + .trace + .as_ref() + .unwrap() + .floor_admissions + .iter() + .any(|a| a.cue_kind == CueKind::DateMatch)); + let pressure = memory + .retrieve(ann_query(current, false, true, 8)) + .await + .unwrap(); + let control = memory + .retrieve(ann_query("2026-09-22T20:00:00+09:00", false, true, 8)) + .await + .unwrap(); + assert_eq!(pressure.pack, control.pack); + assert!(!episodes(&pressure).contains(&900)); + memory.close().await.unwrap(); + temp.close().unwrap(); +} diff --git a/src/memory/retrieval_turn_tests.rs b/src/memory/retrieval_turn_tests.rs index c2a35bf3..ed670822 100644 --- a/src/memory/retrieval_turn_tests.rs +++ b/src/memory/retrieval_turn_tests.rs @@ -76,7 +76,7 @@ fn context() -> RetrievalContext { fn episode(id: u128, summary: String, scene: Scene) -> [MemoryCandidate; 2] { let mut draft = EpisodeDraft::new(summary); draft.id = Some(MemoryId::from_u128(id)); - draft.created_at = Some(scene.time); + draft.created_at = Some(scene.time.to_utc()); draft.scene = Some(scene); draft.schema_version = Some(DEFAULT_SCHEMA_VERSION.to_owned()); [ @@ -99,7 +99,7 @@ async fn cohort_memory() -> CharacterMemory { ); let mut person = EntityDraft::new(); person.id = Some(MemoryId::from_u128(7)); - person.created_at = Some(context().scene.time); + person.created_at = Some(context().scene.time.to_utc()); person.schema_version = Some(DEFAULT_SCHEMA_VERSION.to_owned()); memory .commit( @@ -124,7 +124,7 @@ async fn cohort_memory() -> CharacterMemory { for candidate in episode( 2000 + index, format!("Orchid lesson {index}"), - Scene::at(context().scene.time), + Scene::at((context().scene.time).fixed_offset()), ) { plan = plan.with_candidate(candidate); } diff --git a/src/memory/scene_tests.rs b/src/memory/scene_tests.rs index 3c05eb1f..f063ed22 100644 --- a/src/memory/scene_tests.rs +++ b/src/memory/scene_tests.rs @@ -21,7 +21,7 @@ fn time() -> DateTime { } fn words_scene() -> Scene { - let mut scene = Scene::at(time()); + let mut scene = Scene::at((time()).fixed_offset()); scene.setting.words = Some(" 窓のそば\nquiet café ".to_owned()); scene.participants = vec![ SceneParticipant { @@ -198,7 +198,7 @@ async fn scene_without_words_keeps_the_exact_legacy_embedding_text() { let participant = MemoryId::from_u128(8700); let mut entity = EntityDraft::new(); entity.id = Some(participant); - let mut scene = Scene::at(time()); + let mut scene = Scene::at((time()).fixed_offset()); scene.setting.key = Some("never embed this setting key".to_owned()); scene.custom_values.insert( "session".to_owned(), @@ -277,7 +277,7 @@ async fn remember_keeps_distinct_vectors_for_episode_and_observation_with_the_sa Box::new(TextEmbedder(["volcano", "harbor"])), ); let id = MemoryId::from_u128(8801); - let mut episode = episode_draft(id.as_u128(), Some(Scene::at(time()))); + let mut episode = episode_draft(id.as_u128(), Some(Scene::at((time()).fixed_offset()))); episode.summary = "A volcano erupted".to_owned(); let mut observation = ObservationDraft::new(id, "Ships arrived at the harbor"); observation.id = Some(id); @@ -348,7 +348,7 @@ async fn setting_words_recall_the_episode_when_the_summary_does_not_name_the_pla let control = MemoryId::from_u128(8701); let situated = MemoryId::from_u128(8702); for (id, words) in [(control, None), (situated, Some("observatory"))] { - let mut scene = Scene::at(time()); + let mut scene = Scene::at((time()).fixed_offset()); scene.setting.words = words.map(str::to_owned); let mut episode = episode_draft(id.as_u128(), Some(scene)); episode.summary = "We made a decision".to_owned(); @@ -420,7 +420,7 @@ async fn scene_override_preserves_participants_involvement_threads_interval_and_ observation.observed_at = Some(time() + chrono::Duration::seconds(10)); observation.speaker_entity_id = Some(involved); let ended_at = time() + chrono::Duration::minutes(5); - let mut overridden = Scene::at(time() + chrono::Duration::days(1)); + let mut overridden = Scene::at((time() + chrono::Duration::days(1)).fixed_offset()); overridden .custom_values .insert("input-only".to_owned(), "must not merge".to_owned()); @@ -521,7 +521,7 @@ async fn omitted_scene_time_uses_preparation_instant_instead_of_episode_creation .unwrap(); assert!(scene.time >= before && scene.time <= after); assert_ne!(scene.time, time()); - assert_eq!(scene, Scene::at(scene.time)); + assert_eq!(scene, Scene::at((scene.time).fixed_offset())); let serialized = serde_json::to_string(&plan).unwrap(); for _ in 0..2 { memory @@ -539,7 +539,7 @@ async fn omitted_scene_time_uses_preparation_instant_instead_of_episode_creation assert_eq!(episode.created_at, time()); } MemoryObject::Observation(observation) => { - assert_eq!(observation.observed_at, Some(scene.time)) + assert_eq!(observation.observed_at, Some(scene.time.to_utc())) } _ => unreachable!(), } @@ -571,7 +571,7 @@ async fn writes_reject_missing_scene_and_unknown_keys() { // Missing scene is rejected while materializing request-owned values. assert!(inputs.lock().unwrap().is_empty()); let unknown = MemoryId::from_u128(8499); - let mut scene = Scene::at(time()); + let mut scene = Scene::at((time()).fixed_offset()); scene.participants.push(SceneParticipant { key: Some(unknown), ..Default::default() @@ -623,7 +623,10 @@ async fn default_correction_embeds_its_rationale_and_is_recalled_by_content() { memory .remember( RememberInput::new("We discussed the meeting.") - .with_episode(episode_draft(8901, Some(Scene::at(time())))) + .with_episode(episode_draft( + 8901, + Some(Scene::at((time()).fixed_offset())), + )) .with_derived_memory(old), RememberOptions::default(), ) @@ -669,7 +672,7 @@ async fn source_correction_uses_setting_key_and_preserves_every_source_scene() { let (memory, _) = memory().await; let mut original_scene = words_scene(); original_scene.setting.key = Some("channel/original".to_owned()); - let correction_scene = Scene::at(time() + chrono::Duration::days(1)); + let correction_scene = Scene::at((time() + chrono::Duration::days(1)).fixed_offset()); let original = MemoryId::from_u128(8501); let observation = MemoryId::from_u128(8502); let correction = MemoryId::from_u128(8503); diff --git a/src/memory/write_turn_tests.rs b/src/memory/write_turn_tests.rs index 06853ca7..eef63b32 100644 --- a/src/memory/write_turn_tests.rs +++ b/src/memory/write_turn_tests.rs @@ -547,6 +547,17 @@ struct GatedGraph { #[async_trait] impl GraphAuthorityStore for GatedGraph { + async fn query_anniversaries( + &self, + date: chrono::NaiveDate, + participants: &[crate::domain::MemoryId], + limit: usize, + policy: crate::ports::graph_authority::GraphExpansionLifecyclePolicy, + ) -> Result, CustomError> { + let _ = (date, participants, limit, policy); + Ok(Vec::new()) + } + async fn query_episodes_by_time( &self, start: Option>, diff --git a/src/policy/embedding_surface.rs b/src/policy/embedding_surface.rs index 83ae17d3..74fdd49c 100644 --- a/src/policy/embedding_surface.rs +++ b/src/policy/embedding_surface.rs @@ -276,6 +276,7 @@ mod tests { fn episode_fixture() -> Episode { Episode { + scene_local_date: None, id: id(10), object_type: ObjectType::Episode, modality: Modality::Chat, @@ -288,7 +289,7 @@ mod tests { key: Some(id(1)), ..Default::default() }], - ..crate::domain::Scene::at(timestamp()) + ..crate::domain::Scene::at((timestamp()).fixed_offset()) }, ended_at: Some(timestamp()), summary: " Short summary. ".to_owned(), diff --git a/src/ports/graph_authority.rs b/src/ports/graph_authority.rs index f554942d..f366fa50 100644 --- a/src/ports/graph_authority.rs +++ b/src/ports/graph_authority.rs @@ -358,6 +358,15 @@ pub(crate) trait GraphAuthorityStore: Send + Sync { policy: GraphExpansionLifecyclePolicy, ) -> Result, CustomError>; + /// Matching local calendar dates, shared occasions first, newest instant then ID. + async fn query_anniversaries( + &self, + date: chrono::NaiveDate, + participants: &[MemoryId], + limit: usize, + policy: GraphExpansionLifecyclePolicy, + ) -> Result, CustomError>; + /// Read recorded occasion time and retention for episode or observation references. async fn query_episode_occasions( &self, @@ -429,6 +438,18 @@ pub(crate) trait GraphAuthorityStore: Send + Sync { #[async_trait] impl GraphAuthorityStore for Box { + async fn query_anniversaries( + &self, + date: chrono::NaiveDate, + participants: &[MemoryId], + limit: usize, + policy: GraphExpansionLifecyclePolicy, + ) -> Result, CustomError> { + (**self) + .query_anniversaries(date, participants, limit, policy) + .await + } + async fn query_episodes_by_time( &self, start: Option>, diff --git a/src/ports/retrieval_stats.rs b/src/ports/retrieval_stats.rs index 520885fe..b9f49d8a 100644 --- a/src/ports/retrieval_stats.rs +++ b/src/ports/retrieval_stats.rs @@ -766,6 +766,7 @@ mod tests { let memory_id = id("550e8400-e29b-41d4-a716-446655460023"); let objects = vec![ MemoryObject::Episode(Episode { + scene_local_date: None, id: episode_id, object_type: ObjectType::Episode, modality: Modality::Chat, @@ -774,7 +775,7 @@ mod tests { key: Some(entity_id), ..Default::default() }], - ..crate::domain::Scene::at(timestamp()) + ..crate::domain::Scene::at((timestamp()).fixed_offset()) }, ended_at: None, summary: "episode".to_owned(), diff --git a/src/test_support.rs b/src/test_support.rs index f792fa98..5723b196 100644 --- a/src/test_support.rs +++ b/src/test_support.rs @@ -406,6 +406,7 @@ pub(crate) fn high_fanout_graph_fixture() -> HighFanoutGraphFixture { pub(crate) fn simple_episode() -> Episode { Episode { + scene_local_date: None, id: fixture_id(10), object_type: ObjectType::Episode, modality: Modality::Chat, @@ -424,7 +425,7 @@ pub(crate) fn simple_episode() -> Episode { ..Default::default() }, ], - ..crate::domain::Scene::at(timestamp("2026-04-27T10:00:00Z")) + ..crate::domain::Scene::at((timestamp("2026-04-27T10:00:00Z")).fixed_offset()) }, ended_at: Some(timestamp("2026-04-27T10:10:00Z")), summary: "Discussed deterministic store contract fixtures.".to_owned(), diff --git a/src/usecases/correct_forget.rs b/src/usecases/correct_forget.rs index 4c192ba5..615ddb2a 100644 --- a/src/usecases/correct_forget.rs +++ b/src/usecases/correct_forget.rs @@ -3362,6 +3362,7 @@ mod tests { fn source_episode(ids: &FixedIds) -> Episode { Episode { + scene_local_date: None, id: ids.episode, object_type: ObjectType::Episode, modality: Modality::Chat, @@ -3371,7 +3372,7 @@ mod tests { words: None, }, - ..crate::domain::Scene::at(Utc::now()) + ..crate::domain::Scene::at((Utc::now()).fixed_offset()) }, ended_at: None, summary: "Original source episode.".to_owned(), @@ -3487,6 +3488,17 @@ mod tests { #[async_trait] impl GraphAuthorityStore for RecordingGraphStore { + async fn query_anniversaries( + &self, + date: chrono::NaiveDate, + participants: &[crate::domain::MemoryId], + limit: usize, + policy: crate::ports::graph_authority::GraphExpansionLifecyclePolicy, + ) -> Result, CustomError> { + let _ = (date, participants, limit, policy); + Ok(Vec::new()) + } + async fn query_episodes_by_time( &self, start: Option>, diff --git a/src/usecases/link.rs b/src/usecases/link.rs index f004cd82..24109a08 100644 --- a/src/usecases/link.rs +++ b/src/usecases/link.rs @@ -565,6 +565,17 @@ mod tests { #[async_trait] impl GraphAuthorityStore for QueryObjectsFailingGraph { + async fn query_anniversaries( + &self, + date: chrono::NaiveDate, + participants: &[crate::domain::MemoryId], + limit: usize, + policy: crate::ports::graph_authority::GraphExpansionLifecyclePolicy, + ) -> Result, CustomError> { + let _ = (date, participants, limit, policy); + Ok(Vec::new()) + } + async fn query_episodes_by_time( &self, start: Option>, diff --git a/src/usecases/remember.rs b/src/usecases/remember.rs index 73c0044d..e9cfd5f1 100644 --- a/src/usecases/remember.rs +++ b/src/usecases/remember.rs @@ -1169,6 +1169,17 @@ mod tests { #[async_trait] impl GraphAuthorityStore for RecordingGraphStore { + async fn query_anniversaries( + &self, + date: chrono::NaiveDate, + participants: &[crate::domain::MemoryId], + limit: usize, + policy: crate::ports::graph_authority::GraphExpansionLifecyclePolicy, + ) -> Result, CustomError> { + let _ = (date, participants, limit, policy); + Ok(Vec::new()) + } + async fn query_episodes_by_time( &self, start: Option>, diff --git a/src/usecases/retrieve.rs b/src/usecases/retrieve.rs index f70827fb..acc4b11a 100644 --- a/src/usecases/retrieve.rs +++ b/src/usecases/retrieve.rs @@ -99,6 +99,7 @@ where .participants .iter() .map(|id| CandidateRoot { + date_match_floor_eligible: false, object_id: *id, object_type: ObjectType::Entity, score: 1.0, @@ -161,6 +162,7 @@ where .or_default() .push(cues.participants.len() + offset); explicit_roots.push(CandidateRoot { + date_match_floor_eligible: false, object_id: id, object_type: ObjectType::DerivedMemory, score: 1.0, @@ -220,6 +222,7 @@ where let has_more = matches.len() > time_limit; explicit_roots.extend(matches.into_iter().take(time_limit).map(|object_id| { CandidateRoot { + date_match_floor_eligible: true, object_id, object_type: ObjectType::Episode, score: 0.0, @@ -234,11 +237,50 @@ where } else { None }; + let resolved = cues + .references + .iter() + .filter_map(|reference| match reference.resolution { + crate::api::types::SceneReferenceResolution::Resolved { notion_id } => { + Some(notion_id) + } + _ => None, + }) + .collect::>() + .into_iter() + .collect::>(); + let anniversary_limit = context.cue_floors.date_match.max(1); + let anniversaries = self + .graph_store + .query_anniversaries( + context.scene.time.date_naive(), + &resolved, + anniversary_limit.saturating_add(1), + GraphExpansionLifecyclePolicy { + include_suppressed: context.lifecycle_policy.include_suppressed, + include_superseded: context.lifecycle_policy.include_superseded, + }, + ) + .await?; + let anniversary_has_more = anniversaries.len() > anniversary_limit; + explicit_roots.extend(anniversaries.into_iter().take(anniversary_limit).map( + |(object_id, shared)| CandidateRoot { + object_id, + object_type: ObjectType::Episode, + score: 0.0, + source: GraphRootSource::DateMatch, + vector_score: None, + cue_kinds: BTreeSet::from([CueKind::DateMatch]), + full_standing_score: None, + full_standing_kinds: BTreeSet::new(), + date_match_floor_eligible: shared, + }, + )); let recent = self .graph_store .query_episodes_by_time( None, - context.scene.time, + context.scene.time.to_utc(), time_limit, GraphExpansionLifecyclePolicy { include_suppressed: context.lifecycle_policy.include_suppressed, @@ -247,6 +289,7 @@ where ) .await?; explicit_roots.extend(recent.into_iter().map(|object_id| CandidateRoot { + date_match_floor_eligible: false, object_id, object_type: ObjectType::Episode, score: 0.0, @@ -466,6 +509,7 @@ where let trace = trace_mode.is_enabled().then(|| RetrievalTrace { scene_cue_searches: cues.scene_cue_searches, time_range_has_more, + anniversary_has_more, vector_candidates: vector_candidates .iter() .enumerate() @@ -634,6 +678,8 @@ impl RetrieveAssembly { .and_modify(|ranked| { ranked.cue_component = ranked.cue_component.max(inherited_cue); ranked.cue_kinds.extend(&kinds); + ranked.date_match_floor_eligible |= + candidate.date_match_floor_eligible && kinds.contains(&CueKind::DateMatch); ranked.is_root |= object_ref == candidate_ref; if ranked.is_root || ranked.reminder_only == reminder_only { ranked.graph_component = ranked.graph_component.max(graph_component); @@ -659,6 +705,8 @@ impl RetrieveAssembly { candidate_score, ); ranked.reminder_only = reminder_only; + ranked.date_match_floor_eligible = + candidate.date_match_floor_eligible && kinds.contains(&CueKind::DateMatch); ranked.is_root = object_ref == candidate_ref; ranked }); @@ -741,8 +789,8 @@ impl RetrieveAssembly { ranked_objects.sort_by_key(|ranked| ranked.rank_key()); // Reorder only contributed-occasion slots within an equal-score/type - // tie: the given range precedes recency. Other objects retain the - // general ID order and their positions. + // tie: floor-eligible date matches precede recency and unshared anniversaries. + // Other objects retain the general ID order and their positions. for tied in ranked_objects.chunk_by_mut(|left, right| { left.rank_key().score == right.rank_key().score && left.object.object_type() == right.object.object_type() @@ -766,7 +814,7 @@ impl RetrieveAssembly { .collect::>(); recent.sort_by_key(|ranked| match &ranked.object { MemoryObject::Episode(episode) => ( - !ranked.cue_kinds.contains(&CueKind::DateMatch), + !ranked.date_match_floor_eligible, std::cmp::Reverse(episode.scene.time), episode.id, ), @@ -801,6 +849,7 @@ struct RankedObject { cue_component: f32, cue_kinds: BTreeSet, reminder_only: bool, + date_match_floor_eligible: bool, is_root: bool, vector_candidate_score: Option, graph_component: f32, @@ -822,6 +871,7 @@ impl RankedObject { cue_component, cue_kinds, reminder_only: false, + date_match_floor_eligible: false, is_root: false, vector_candidate_score, graph_component, @@ -892,6 +942,19 @@ struct RankKey { // reserves its latest occasions regardless of which other roads reached them. // Only roots share spare turns; other stages fill from their ranked prefix. // An index outside the old prefix records the floor that changed its admission. +fn floor_kinds( + kinds: &BTreeSet, + date_match_eligible: bool, +) -> std::borrow::Cow<'_, BTreeSet> { + if !date_match_eligible && kinds.contains(&CueKind::DateMatch) { + let mut reserved = kinds.clone(); + reserved.remove(&CueKind::DateMatch); + std::borrow::Cow::Owned(reserved) + } else { + std::borrow::Cow::Borrowed(kinds) + } +} + fn select_with_cue_floors<'a>( candidates: impl IntoIterator, bool)>, orders: &BTreeMap>, @@ -1009,14 +1072,21 @@ fn build_pack( }); orders.entry(kind).or_default().splice(..0, state_order); } + let reserved_kinds = candidates + .iter() + .map(|ranked| floor_kinds(&ranked.cue_kinds, ranked.date_match_floor_eligible)) + .collect::>(); for (index, cause) in select_with_cue_floors( - candidates.iter().map(|ranked| { - ( - ranked.object.object_ref(), - &ranked.cue_kinds, - ranked.reminder_only, - ) - }), + candidates + .iter() + .zip(&reserved_kinds) + .map(|(ranked, kinds)| { + ( + ranked.object.object_ref(), + kinds.as_ref(), + ranked.reminder_only, + ) + }), &orders, section_limit(section, limits), floors, @@ -1264,6 +1334,7 @@ struct CandidateRoot { cue_kinds: BTreeSet, full_standing_score: Option, full_standing_kinds: BTreeSet, + date_match_floor_eligible: bool, } impl CandidateRoot { @@ -1273,6 +1344,7 @@ impl CandidateRoot { topic_score: Option, ) -> Self { Self { + date_match_floor_eligible: false, object_id: candidate.object_id, object_type: candidate.object_type, score: candidate.score, @@ -1349,6 +1421,23 @@ fn select_candidate_roots( }) .then_with(|| left.object_id.cmp(&right.object_id)) }); + let mut time_roots = explicit_roots + .iter() + .filter(|root| { + matches!( + root.source, + GraphRootSource::Recency | GraphRootSource::DateMatch + ) + }) + .collect::>(); + // Only eligible date matches lead. Recency is the newest bounded prefix; + // additional unshared anniversaries are older, and overlaps merge below. + time_roots.sort_by_key(|root| { + ( + !root.date_match_floor_eligible, + root.source == GraphRootSource::DateMatch, + ) + }); let roots = explicit_roots .iter() .filter(|root| { @@ -1360,17 +1449,7 @@ fn select_candidate_roots( .cloned() .chain(content) // Time sources use only their reservations and remaining root room. - .chain( - explicit_roots - .iter() - .filter(|root| { - matches!( - root.source, - GraphRootSource::Recency | GraphRootSource::DateMatch - ) - }) - .cloned(), - ) + .chain(time_roots.into_iter().cloned()) .collect::>(); let mut indices = HashMap::new(); let mut merged: Vec = Vec::new(); @@ -1378,6 +1457,7 @@ fn select_candidate_roots( let object = MemoryObjectRef::new(root.object_type, root.object_id); if let Some(index) = indices.get(&object).copied() { let existing: &mut CandidateRoot = &mut merged[index]; + existing.date_match_floor_eligible |= root.date_match_floor_eligible; existing.cue_kinds.extend(root.cue_kinds); existing.score = existing.score.max(root.score); existing @@ -1443,11 +1523,15 @@ fn select_candidate_roots( orders.entry(*kind).or_default().extend(order); } let unique_count = merged.len(); + let reserved_kinds = merged + .iter() + .map(|root| floor_kinds(&root.cue_kinds, root.date_match_floor_eligible)) + .collect::>(); let selection = select_with_cue_floors( - merged.iter().map(|root| { + merged.iter().zip(&reserved_kinds).map(|(root, kinds)| { ( MemoryObjectRef::new(root.object_type, root.object_id), - &root.cue_kinds, + kinds.as_ref(), root.reminder_only(), ) }), @@ -1510,7 +1594,7 @@ fn graph_query_for_candidate( query.current_subject_state = candidate.object_type == ObjectType::Entity && candidate.source == GraphRootSource::Participant; query.reminder_only = candidate.reminder_only(); - query.participant_reference_time = context.scene.time; + query.participant_reference_time = context.scene.time.to_utc(); query } @@ -1842,16 +1926,15 @@ mod tests { 3 | 5 => CueKind::DateMatch, _ => CueKind::Topic, }; - assembly.objects.insert( - MemoryObjectRef::new(ObjectType::Episode, episode.id), - RankedObject::new( - MemoryObject::Episode(episode), - if n == 6 { 1.0 } else { 0.0 }, - BTreeSet::from([kind]), - 1.0, - None, - ), + let mut ranked = RankedObject::new( + MemoryObject::Episode(episode), + if n == 6 { 1.0 } else { 0.0 }, + BTreeSet::from([kind]), + 1.0, + None, ); + ranked.date_match_floor_eligible = kind == CueKind::DateMatch; + assembly.objects.insert(ranked.object.object_ref(), ranked); } assert_eq!( assembly @@ -1879,6 +1962,7 @@ mod tests { .into_domain() .unwrap(); let full = CandidateRoot { + date_match_floor_eligible: false, object_id: person.id, object_type: ObjectType::Entity, score: 1.0, @@ -1889,6 +1973,7 @@ mod tests { full_standing_kinds: BTreeSet::from([CueKind::Participant]), }; let recent = CandidateRoot { + date_match_floor_eligible: false, object_id: episode.id, object_type: ObjectType::Episode, score: 0.0, @@ -3402,6 +3487,17 @@ mod tests { #[async_trait] impl GraphAuthorityStore for ErrorGraphStore { + async fn query_anniversaries( + &self, + date: chrono::NaiveDate, + participants: &[crate::domain::MemoryId], + limit: usize, + policy: crate::ports::graph_authority::GraphExpansionLifecyclePolicy, + ) -> Result, CustomError> { + let _ = (date, participants, limit, policy); + Ok(Vec::new()) + } + async fn query_episodes_by_time( &self, start: Option>, diff --git a/src/usecases/retrieve/activity.rs b/src/usecases/retrieve/activity.rs index c4b15a62..6c8f1c5b 100644 --- a/src/usecases/retrieve/activity.rs +++ b/src/usecases/retrieve/activity.rs @@ -55,6 +55,7 @@ where resolution: ActivityResolution::Found, }); let root = |reference: MemoryObjectRef| CandidateRoot { + date_match_floor_eligible: false, object_id: reference.id, object_type: reference.object_type, score: 1.0, diff --git a/src/usecases/retrieve/scene.rs b/src/usecases/retrieve/scene.rs index 1f4705c7..8140135c 100644 --- a/src/usecases/retrieve/scene.rs +++ b/src/usecases/retrieve/scene.rs @@ -101,14 +101,14 @@ where for &participant in &participants { let last = self .graph_store - .query_last_interaction(participant, context.scene.time, policy) + .query_last_interaction(participant, context.scene.time.to_utc(), policy) .await?; last_interactions.insert( participant, last.map(|(episode_id, scene_time)| LastInteraction { episode_id, scene_time, - seconds_since: (context.scene.time - scene_time).num_seconds(), + seconds_since: (context.scene.time.to_utc() - scene_time).num_seconds(), }), ); } diff --git a/src/usecases/write_planning.rs b/src/usecases/write_planning.rs index ab4b577f..68e043ba 100644 --- a/src/usecases/write_planning.rs +++ b/src/usecases/write_planning.rs @@ -92,7 +92,7 @@ impl RememberInput { .and_then(|draft| draft.scene.as_ref()) .or(self.scene.as_ref()) .cloned() - .unwrap_or_else(|| Scene::at(defaults.created_at)); + .unwrap_or_else(|| Scene::at((defaults.created_at).fixed_offset())); let mut plan = RememberWritePlan::new(); if let Some(source_input_ref) = self.source_reference() { @@ -319,7 +319,7 @@ impl RememberInput { } else { draft.episode_id }; - draft.observed_at = draft.observed_at.or(Some(scene.time)); + draft.observed_at = draft.observed_at.or(Some(scene.time.to_utc())); if draft.raw_ref.is_none() { draft.raw_ref = self.raw_refs.first().cloned(); } @@ -542,7 +542,7 @@ mod construction_tests { fn same_input_and_fixed_defaults_prepare_identical_plan() { let defaults = RememberPlanDefaults::fixed("fixed-operation", timestamp("2026-07-03T10:00:00Z")); - let mut scene = Scene::at(defaults.created_at); + let mut scene = Scene::at((defaults.created_at).fixed_offset()); scene.participants.push(crate::SceneParticipant { key: Some(memory_id("550e8400-e29b-41d4-a716-446655443003")), ..Default::default() @@ -2699,7 +2699,9 @@ mod tests { } fn complete_episode(mut draft: EpisodeDraft) -> EpisodeDraft { - draft.scene.get_or_insert_with(|| Scene::at(timestamp())); + draft + .scene + .get_or_insert_with(|| Scene::at((timestamp()).fixed_offset())); draft .id .get_or_insert(id("550e8400-e29b-41d4-a716-446655444100")); diff --git a/tests/retrieval_guardrails_tests.rs b/tests/retrieval_guardrails_tests.rs index 5f37bf89..8350fce6 100644 --- a/tests/retrieval_guardrails_tests.rs +++ b/tests/retrieval_guardrails_tests.rs @@ -364,7 +364,7 @@ fn entity(id: MemoryId) -> EntityDraft { fn episode(id: MemoryId, summary: &str, participants: &[MemoryId]) -> EpisodeDraft { let mut draft = EpisodeDraft::new(summary); draft.id = Some(id); - let mut scene = character_memory::Scene::at(timestamp()); + let mut scene = character_memory::Scene::at((timestamp()).fixed_offset()); scene.participants = participants .iter() .copied() diff --git a/tests/retrieval_last_interaction_tests.rs b/tests/retrieval_last_interaction_tests.rs index 8d59f820..254ca01b 100644 --- a/tests/retrieval_last_interaction_tests.rs +++ b/tests/retrieval_last_interaction_tests.rs @@ -29,7 +29,7 @@ fn keyed(n: u128) -> SceneParticipant { } } fn request(participants: Vec) -> RetrievalContext { - let mut scene = Scene::at(reference_time()); + let mut scene = Scene::at((reference_time()).fixed_offset()); scene.participants = participants; RetrievalContext::default().with_scene(scene) } @@ -73,7 +73,7 @@ async fn notion(memory: &CharacterMemory, n: u128, name: Option<&str>) { fn episode(n: u128, time: DateTime) -> EpisodeDraft { let mut episode = EpisodeDraft::new(format!("Meeting {n}")); episode.id = Some(id(n)); - episode.scene = Some(Scene::at(time)); + episode.scene = Some(Scene::at((time).fixed_offset())); episode.created_at = Some(reference_time() + (reference_time() - time)); episode.schema_version = Some(DEFAULT_SCHEMA_VERSION.to_owned()); episode @@ -267,7 +267,7 @@ async fn scene_boundary_and_whole_seconds_distinguish_zero_from_never_met() { BTreeSet::from([id(400), id(402), id(403), notion_episode]) ); let mut before = request(vec![keyed(100)]); - before.scene.time = earlier - Duration::milliseconds(1); + before.scene.time = (earlier - Duration::milliseconds(1)).fixed_offset(); let result = memory.retrieve(before).await.unwrap(); assert_eq!(last(&result), None); assert!(occasions(&result).is_empty()); diff --git a/tests/retrieval_lifecycle_evidence_tests.rs b/tests/retrieval_lifecycle_evidence_tests.rs index 1b3b1aa8..1e7c1f2e 100644 --- a/tests/retrieval_lifecycle_evidence_tests.rs +++ b/tests/retrieval_lifecycle_evidence_tests.rs @@ -28,7 +28,7 @@ async fn lifecycle_evidence_fixture(relation: RelationType) { let (memory, root) = test_support::try_setup_character_memory().await.unwrap(); let mut episode = EpisodeDraft::new("source"); episode.id = Some(id(101)); - episode.scene = Some(Scene::at(at())); + episode.scene = Some(Scene::at((at()).fixed_offset())); episode.created_at = Some(at()); let mut input = RememberInput::new("fixture").with_episode(episode); let mut successor = None; @@ -95,8 +95,8 @@ async fn lifecycle_evidence_fixture(relation: RelationType) { } let mut counts = Vec::new(); for trace in [false, true] { - let mut request = - RetrievalContext::new("quasar hatch calibration").with_scene(Scene::at(at())); + let mut request = RetrievalContext::new("quasar hatch calibration") + .with_scene(Scene::at((at()).fixed_offset())); request.object_type_defaults = vec![ObjectType::DerivedMemory]; request.candidate_limits.max_vector_candidates = 1; request.candidate_limits.max_graph_roots = 1; diff --git a/tests/retrieval_resolution_tests.rs b/tests/retrieval_resolution_tests.rs index afc24b45..d6c1002d 100644 --- a/tests/retrieval_resolution_tests.rs +++ b/tests/retrieval_resolution_tests.rs @@ -56,7 +56,7 @@ async fn fixture(resolvers_share_state: bool) -> (CharacterMemory, tempfile::Tem person.id = Some(id(501)); let mut thread = MemoryThreadDraft::new("shared work", "shared work"); thread.id = Some(id(601)); - let mut original_scene = Scene::at(at(10)); + let mut original_scene = Scene::at((at(10)).fixed_offset()); original_scene.setting.key = Some("workshop".into()); original_scene .custom_values @@ -81,8 +81,8 @@ async fn fixture(resolvers_share_state: bool) -> (CharacterMemory, tempfile::Tem input = input.with_derived_memory(draft); } commit(&memory, input).await; - let mut input = - RememberInput::new("later results").with_episode(episode(102, Scene::at(at(20)))); + let mut input = RememberInput::new("later results") + .with_episode(episode(102, Scene::at((at(20)).fixed_offset()))); for (n, text) in [ (401, "calibration completed successfully"), (402, "delivered all supplies"), @@ -116,7 +116,7 @@ async fn fixture(resolvers_share_state: bool) -> (CharacterMemory, tempfile::Tem } fn request(route: &str) -> RetrievalContext { - let mut scene = Scene::at(at(40)); + let mut scene = Scene::at((at(40)).fixed_offset()); if route == "named" || route == "mixed" { scene.participants.push(SceneParticipant { key: Some(id(501)), diff --git a/tests/retrieval_scope_tests.rs b/tests/retrieval_scope_tests.rs index c8f41774..6aef672a 100644 --- a/tests/retrieval_scope_tests.rs +++ b/tests/retrieval_scope_tests.rs @@ -22,7 +22,7 @@ fn at(n: i64) -> DateTime { + chrono::Duration::minutes(n) } fn scene(setting: Option<&str>, custom: &[(&str, &str)]) -> Scene { - let mut scene = Scene::at(at(10)); + let mut scene = Scene::at((at(10)).fixed_offset()); scene.setting.key = setting.map(str::to_owned); scene.custom_values = custom .iter() diff --git a/tests/retrieval_state_tests.rs b/tests/retrieval_state_tests.rs index b6647843..c25b5c76 100644 --- a/tests/retrieval_state_tests.rs +++ b/tests/retrieval_state_tests.rs @@ -36,7 +36,7 @@ fn entity(id: u128) -> EntityDraft { } fn scene(ids: &[u128], at: i64) -> Scene { - let mut scene = Scene::at(time(at)); + let mut scene = Scene::at((time(at)).fixed_offset()); scene.participants = ids .iter() .copied() diff --git a/tests/scene_reminder_tests.rs b/tests/scene_reminder_tests.rs index 22b29026..b5257213 100644 --- a/tests/scene_reminder_tests.rs +++ b/tests/scene_reminder_tests.rs @@ -169,7 +169,7 @@ async fn commit(memory: &CharacterMemory, plan: RememberWritePlan) { assert!(outcome.vector_indexing_failure.is_none(), "{outcome:?}"); } fn scene() -> Scene { - let mut scene = Scene::at(time()); + let mut scene = Scene::at((time()).fixed_offset()); scene.setting.words = Some("studio".to_owned()); scene.participants.push(described("botanist")); scene @@ -230,12 +230,16 @@ async fn scene_reminders_and_state_score_fill_preserve_their_witnesses() { plan, 2000 + index, &format!("Orchid lesson {index}"), - Scene::at(time() - Duration::days(1)), + Scene::at((time() - Duration::days(1)).fixed_offset()), ); } commit(&memory, plan).await; for (case, current, topic) in [ - ("topic-alone", Scene::at(time()), Some("orchids")), + ( + "topic-alone", + Scene::at((time()).fixed_offset()), + Some("orchids"), + ), ("keyless-topic-and-descriptions", scene(), Some("orchids")), ("descriptions-only", scene(), None), ] { @@ -274,7 +278,7 @@ async fn scene_reminders_and_state_score_fill_preserve_their_witnesses() { plan = add_belief(plan, n, None, true); } commit(&memory, plan).await; - let mut current = Scene::at(time()); + let mut current = Scene::at((time()).fixed_offset()); current.participants.push(keyed(7)); for topic in [None, Some("unrelated")] { for cap in [2, 6, 12] { @@ -293,12 +297,12 @@ async fn scene_reminders_and_state_score_fill_preserve_their_witnesses() { let mut plan = add_entity(add_entity(RememberWritePlan::new(), 7), 8); plan = add_belief(plan, 3000, Some(7), false); plan = add_belief(plan, 3001, Some(8), false); - let mut past = Scene::at(time() - Duration::days(1)); + let mut past = Scene::at((time() - Duration::days(1)).fixed_offset()); past.participants.push(described("stranger")); plan = add_episode(plan, 9000, "Encounter", past); commit(&memory, plan).await; for description in [false, true] { - let mut current = Scene::at(time()); + let mut current = Scene::at((time()).fixed_offset()); current.participants = vec![keyed(7), keyed(8)]; if description { current.participants.push(described("stranger")); @@ -372,7 +376,7 @@ async fn recent_occasions_use_scene_time_across_the_full_pool() { let (memory, root) = open().await; let mut plan = RememberWritePlan::new(); for (n, days) in [(30, -3), (20, -2), (90, -1), (10, 1)] { - let mut past = Scene::at(time() + Duration::days(days)); + let mut past = Scene::at((time() + Duration::days(days)).fixed_offset()); past.setting.words = Some("studio".to_owned()); plan = add_episode(plan, n, "Encounter", past); } @@ -386,7 +390,7 @@ async fn recent_occasions_use_scene_time_across_the_full_pool() { } } commit(&memory, plan).await; - let mut current = Scene::at(time()); + let mut current = Scene::at((time()).fixed_offset()); current.setting.words = Some("studio".to_owned()); for floor in [0, 1, 3] { let mut context = query(None, current.clone()); @@ -447,7 +451,7 @@ async fn recent_occasions_use_scene_time_across_the_full_pool() { 1 ); let topic = memory - .retrieve(query(Some("Encounter"), Scene::at(time()))) + .retrieve(query(Some("Encounter"), Scene::at((time()).fixed_offset()))) .await .unwrap(); assert!( @@ -475,7 +479,7 @@ async fn reminder_stops_at_the_thread_but_a_topic_route_keeps_full_standing() { MemoryThreadCandidate::new(thread, provenance()), )); for n in [10, 11] { - let mut past = Scene::at(time() - Duration::days(n as i64 - 9)); + let mut past = Scene::at((time() - Duration::days(n as i64 - 9)).fixed_offset()); past.setting.words = Some("studio".to_owned()); plan = add_episode(plan, n, "Another ordinary day", past); } @@ -513,7 +517,7 @@ async fn reminder_stops_at_the_thread_but_a_topic_route_keeps_full_standing() { .await .unwrap(); } - let mut current = Scene::at(time()); + let mut current = Scene::at((time()).fixed_offset()); current.setting.words = Some("studio".to_owned()); let result = memory.retrieve(query(None, current.clone())).await.unwrap(); assert_eq!( @@ -620,7 +624,7 @@ async fn descriptions_support_write_recall_and_interpretation_without_applicatio assert!(interpretation.id.is_none()); // The caller-built plan stands in for consolidation; source ids come only from recall. let input = RememberInput::new("Reflection on the visit.") - .with_scene(Scene::at(time() - Duration::hours(1))) + .with_scene(Scene::at((time() - Duration::hours(1)).fixed_offset())) .with_derived_memory(interpretation); let defaults = RememberPlanDefaults::generated(); let interpreted_id = input diff --git a/tests/scene_surface_tests.rs b/tests/scene_surface_tests.rs index 03c56f0a..6a32735c 100644 --- a/tests/scene_surface_tests.rs +++ b/tests/scene_surface_tests.rs @@ -72,7 +72,7 @@ fn id(n: u128) -> MemoryId { MemoryId::from_u128(n) } fn scene(setting: Option<&str>, participants: &[&str]) -> Scene { - let mut scene = Scene::at(time()); + let mut scene = Scene::at((time()).fixed_offset()); scene.setting.words = setting.map(str::to_owned); scene.participants = participants .iter() diff --git a/tests/write_planning_tests.rs b/tests/write_planning_tests.rs index 0a9f0d1c..654bcb1a 100644 --- a/tests/write_planning_tests.rs +++ b/tests/write_planning_tests.rs @@ -215,7 +215,7 @@ async fn remember_wrapper_commits_equivalent_graph_state() { assert_eq!(episode.summary, "Equivalent graph state observation"); assert_eq!( episode.scene, - character_memory::Scene::at(fixed_timestamp()) + character_memory::Scene::at((fixed_timestamp()).fixed_offset()) ); let observation = wrapper_retrieval .pack @@ -384,7 +384,7 @@ fn remember_equivalence_input() -> RememberInput { let mut episode = EpisodeDraft::new("Equivalent graph state observation"); episode.id = Some(episode_id); - episode.scene = Some(character_memory::Scene::at(timestamp)); + episode.scene = Some(character_memory::Scene::at((timestamp).fixed_offset())); episode.created_at = Some(timestamp); let mut observation =