diff --git a/README.md b/README.md index 1905ded5..0fb8e4f7 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,15 @@ This is useful when you want to: Character Memory separates planning from persistence. Use `prepare` to build an inspectable `RememberWritePlan`, `validate_plan` to check it against the current graph, and `commit` to persist it. `prepare` and `validate_plan` do not write graph objects, vector entries, retrieval statistics, or raw source data. ```rust -let input = RememberInput::new("caller-provided note or transcript reference"); +use character_memory::{Scene, SceneParticipant}; + +let mut scene = Scene::now(); +scene.participants.push(SceneParticipant { + description: Some("a visitor".to_owned()), + ..Default::default() +}); +scene.setting.words = Some("a quiet room".to_owned()); +let input = RememberInput::new("caller-provided note or transcript reference").with_scene(scene); let plan = memory.prepare(input, PrepareOptions::default()).await?; let validation = memory.validate_plan(&plan).await?; @@ -155,6 +163,8 @@ if validation.iter().all(|candidate| candidate.status == CandidateValidationStat For callers that want the standard write lifecycle in one call, `remember(RememberInput, RememberOptions)` composes `prepare`, `validate_plan`, and `commit` over the same graph-authoritative machinery. +An episode stores one `Scene` with its time, participants, setting and custom values as supplied. Scene words join its single summary embedding; keys and custom values do not. If the input and episode draft omit the scene, `prepare` fixes the current time once; a caller-built episode must supply its scene. Writes reject participants with neither a key nor nonblank words, and keys that identify no existing or same-plan notion. + The write path is deliberately not an extraction system. Character Memory core does not infer preferences, commitments, corrections, character signals, thread membership, or entity identity from raw text. It does not store raw logs, and `raw_ref` values remain opaque caller-managed provenance pointers. Candidates in a `RememberWritePlan` are not memory until a valid plan is committed. ## Notions and naming beliefs diff --git a/docs/design/database/graph_schema_design.md b/docs/design/database/graph_schema_design.md index aea1223c..821b58bd 100644 --- a/docs/design/database/graph_schema_design.md +++ b/docs/design/database/graph_schema_design.md @@ -23,7 +23,11 @@ Hydration reconstructs domain objects and links from the selected RDF named grap ## Experiences, Notions And Threads -Episodes record interaction spans with `summary`, `modality`, optional source and time metadata, `participantEntity` references, salience and retention. Observations record `text` tied to an `episode`, with observation time, an optional `speakerEntity` and an optional raw reference. Dedicated source references keep experience-based provenance queryable. +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. Each participant supplies a key or nonblank name or description; a keyless participant with absent or blank words is rejected. Participant keys must identify existing or same-plan notions. Names, descriptions and setting words are preserved as given and also indexed with the episode summary in its one embedding; they create no notions or links. Setting words follow a `Setting:` label and each participant has one `With:` line joining its nonblank name and description with a comma, in authored order, including when a key is also present. The embedding builder folds whitespace and omits blank word values; the stored scene is unchanged. Keys and custom values are never embedded. Without scene words the episode embedding text is unchanged. + +An explicit draft scene replaces the input scene as a whole. If both are absent, preparation fixes the current experience time in the plan; commit never substitutes the episode creation time or a later clock reading. Caller-authored episode candidates require a scene. A derived memory cites its source experiences without copying their scenes, and correction leaves those source scenes intact. The source-object correction guard compares `original_setting_key` with the source episode setting key, including when the target is an observation; it never compares setting words. A notion stores only its identity, `createdAt` and schema metadata. Its names and descriptions are carried by interpreted memories about it. Multiple notions may share a name, and one notion may have multiple naming beliefs. @@ -43,7 +47,7 @@ A `DerivedMemory` stores `derivedType`, `text`, `salienceScore`, `retentionState An interpreted memory either cites at least one episode or observation, or declares `given_by_application=true`. The latter is persisted as `givenByApplication`, requires at least one notion subject, and excludes experience source references. Application-given beliefs carry application-supplied grounding without inventing an experience. Corrections of such beliefs require an explicit replacement with its grounding declared. -The reference lists have set semantics: IDs are sorted and deduplicated at draft conversion for stable persistence and replay. This applies to episode participant IDs and to the source, thread, subject and predecessor lists on ordinary and replacement interpreted-memory drafts. +The reference lists have set semantics: IDs are sorted and deduplicated at draft conversion for stable persistence and replay. This applies to the source, thread, subject and predecessor lists on ordinary and replacement interpreted-memory drafts. Scene participants retain authored order and repeated values; generated participant links and retrieval-stat edges count each keyed participant once. ### Assertions And Name Lookup diff --git a/docs/design/database/schema_cheat_sheet.md b/docs/design/database/schema_cheat_sheet.md index 4be9f6b3..047ace93 100644 --- a/docs/design/database/schema_cheat_sheet.md +++ b/docs/design/database/schema_cheat_sheet.md @@ -27,7 +27,7 @@ These are the five emitted payload fields. The service indexes `object_id` and ` | Object type | Surface | Text source | Maximum surfaces | |---|---|---|---| -| `episode` | `summary` | Episode summary | 1 | +| `episode` | `summary` | Episode summary followed by labelled scene words; keys and custom values excluded | 1 | | `observation` | `text` | Observation text | 1 | | `memory_thread` | `summary` | Thread title and summary | 1 | | `derived_memory` | `derived_text` | Interpreted-memory text | 1 | @@ -55,8 +55,11 @@ 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`, `sourceConversationId`, `startedAt`, `endedAt` | Episode source and time metadata | -| `participantEntity` | Episode participant notion | +| `modality`, `endedAt` | Episode modality and optional interval end | +| `sceneTime` | Required experience time on the episode | +| `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 | | `summary` | Episode or thread summary | | `rawRef` | Episode or observation source pointer | | `episode`, `speakerEntity`, `observedAt` | Observation source, optional speaker and time | @@ -91,7 +94,7 @@ Notions carry only common identity/schema literals and `createdAt`. The followin | `assertionName` | Supplied name spelling | | `normalizedName` | NFKC, lowercase and whitespace-folded lookup spelling | -Assertion resources use `:assertion:`. Their ordering and repeated values are preserved by the [assertion reader (`shared.rs:389`)](../../../src/adapters/oxigraph/shared.rs#L389). ID lists use set semantics: conversion sorts IDs and removes duplicates. This includes episode participants and ordinary/replacement memory source, thread, subject and predecessor IDs. +Assertion resources use `:assertion:`. Their ordering and repeated values are preserved by the [assertion reader (`shared.rs:389`)](../../../src/adapters/oxigraph/shared.rs#L389). Interpreted-memory source, thread, subject and supersedes ID lists use set semantics: conversion sorts IDs and removes duplicates, including on replacement drafts. Scene participants preserve authored order and duplicates. Name lookup reads active beliefs with no incoming `Supersedes` link. It can resolve the same normalized name to several notion IDs; see the [name selector (`sparql_selectors.rs:104`)](../../../src/adapters/oxigraph/sparql_selectors.rs#L104). diff --git a/docs/design/database/vector_payload_design.md b/docs/design/database/vector_payload_design.md index a13133a9..8b904a14 100644 --- a/docs/design/database/vector_payload_design.md +++ b/docs/design/database/vector_payload_design.md @@ -18,19 +18,21 @@ Each emitted point has exactly five payload fields: The service indexes `object_id` and `object_type`. The remaining fields describe the record; they are not prefilter columns. Readable result content, graph URIs, assertion and grounding data, relationships, lifecycle values, timestamps and raw references are hydrated from Oxigraph. -The [payload writer (`payload.rs:164`)](../../../src/adapters/qdrant/payload.rs#L164) enforces the supported schema marker and emits the five fields. The [candidate reader (`payload.rs:115`)](../../../src/adapters/qdrant/payload.rs#L115) reads `object_id`, `object_type` and `surface`, then combines them with the vector score. It does not read `schema_version`, `embedding_text` or extra payload fields to construct a candidate. Unknown type/surface tokens and malformed IDs fail decoding. +The [payload writer](../../../src/adapters/qdrant/payload.rs) enforces the supported schema marker and emits the five fields. The [candidate reader](../../../src/adapters/qdrant/payload.rs) reads `object_id`, `object_type` and `surface`, then combines them with the vector score. It does not read `schema_version`, `embedding_text` or extra payload fields to construct a candidate. Unknown type/surface tokens and malformed IDs fail decoding. ## Indexed Objects And Surfaces | Object type | Surface | Embedding text | |---|---|---| -| `episode` | `summary` | `Episode summary: ` followed by the episode summary | +| `episode` | `summary` | `Episode summary: ` followed by the summary, then labelled scene words when supplied | | `observation` | `text` | `Observation excerpt: ` followed by observation text | | `memory_thread` | `summary` | `Thread summary: ` followed by title and summary | | `derived_memory` | `derived_text` | A category label followed by interpreted-memory text | Each indexed object has at most one surface. `max_embedding_surfaces` returns zero for `entity` and `memory_link`; neither has an object vector builder. The [embedding builders](../../../src/policy/embedding_surface.rs) define these limits and fold whitespace in the natural-language input. The `query` surface identifies query embeddings rather than stored memory content. +The [episode builder](../../../src/policy/embedding_surface.rs) appends nonblank setting words on a new `Setting:` line, followed by one `With:` line per participant joining its nonblank name and description with a comma, in authored order, even when the participant also has an identity key. It folds whitespace just as it does for the summary, while Oxigraph retains the original scene values. Keys and custom values are excluded. With no scene words the embedding text is byte-identical to the summary-only form. These words share the episode's existing `summary` surface; there is no second vector or scene surface. + The closed surface tokens are `summary`, `text`, `derived_text` and `query`. The domain enums own their persisted spelling and parsing. Graph object kinds still include notions and links even though those kinds have no emitted embedding surface. ## Belief Content And Graph Authority @@ -41,7 +43,7 @@ Embedding text uses natural language, for example `User preference: Prefer deter ## Indexing And Currency -Embedding happens before a write takes its turn, so no turn holds a model call; inside the turn, indexing consults incoming graph supersession evidence before writing any interpreted memory's vector. Already-superseded memories are excluded, including when an older plan is replayed. A graph-query failure is reported through the typed vector-indexing repair outcome; it does not permit unverified re-indexing. The [indexing service (`vector_indexing.rs:36`)](../../../src/usecases/vector_indexing.rs#L36) owns this admission check. +Embedding happens before a write takes its turn, so no turn holds a model call; inside the turn, indexing consults incoming graph supersession evidence before writing any interpreted memory's vector. Already-superseded memories are excluded, including when an older plan is replayed. A graph-query failure is reported through the typed vector-indexing repair outcome; it does not permit unverified re-indexing. The [indexing service](../../../src/usecases/vector_indexing.rs) owns this admission check. Record embeddings with zero norm are rejected before the adapter call, and dimensions must match the configured store. Query embeddings must satisfy the same cosine-search constraints. A successor derives `Supersedes` links from its predecessor list and schedules predecessor-vector deletion. If deletion fails, graph supersession still excludes those predecessors from default retrieval and the write outcome reports the maintenance failure. diff --git a/src/adapters/oxigraph/rdf_mapping.rs b/src/adapters/oxigraph/rdf_mapping.rs index d5d06528..4fe513db 100644 --- a/src/adapters/oxigraph/rdf_mapping.rs +++ b/src/adapters/oxigraph/rdf_mapping.rs @@ -134,18 +134,31 @@ 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_PARTICIPANTS, + serde_json::to_string(&episode.scene.participants) + .expect("scene participants contain only strings and UUIDs"), + ), + RdfTriple::literal( + &subject, + vocab::SCENE_CUSTOM_VALUES, + serde_json::to_string(&episode.scene.custom_values) + .expect("scene custom values contain only strings"), + ), ]); push_optional_literal( &mut triples, &subject, - vocab::SOURCE_CONVERSATION_ID, - episode.source_conversation_id.as_deref(), + vocab::SETTING_KEY, + episode.scene.setting.key.as_deref(), ); push_optional_literal( &mut triples, &subject, - vocab::STARTED_AT, - episode.started_at.map(timestamp), + vocab::SETTING_WORDS, + episode.scene.setting.words.as_deref(), ); push_optional_literal( &mut triples, @@ -159,13 +172,6 @@ fn episode_triples(episode: &Episode) -> Vec { vocab::RAW_REF, episode.raw_ref.as_deref(), ); - for entity_id in &episode.participant_entity_ids { - triples.push(RdfTriple::resource( - &subject, - vocab::PARTICIPANT_ENTITY, - graph_uri(ObjectType::Entity, *entity_id), - )); - } triples } diff --git a/src/adapters/oxigraph/shared.rs b/src/adapters/oxigraph/shared.rs index f897cfd3..44564713 100644 --- a/src/adapters/oxigraph/shared.rs +++ b/src/adapters/oxigraph/shared.rs @@ -290,13 +290,26 @@ pub(super) fn memory_object_from_rdf( id: memory_id_literal(subject, values, super::vocabulary::OBJECT_ID)?, object_type, modality: enum_literal(subject, values, super::vocabulary::MODALITY)?, - source_conversation_id: values - .optional_literal(super::vocabulary::SOURCE_CONVERSATION_ID), - started_at: optional_timestamp_literal(values, super::vocabulary::STARTED_AT)?, + scene: crate::domain::Scene { + time: timestamp_literal(subject, values, super::vocabulary::SCENE_TIME)?, + participants: serde_json::from_str( + &values.literal(subject, super::vocabulary::SCENE_PARTICIPANTS)?, + ) + .map_err(|error| { + rdf_parse_error(subject, super::vocabulary::SCENE_PARTICIPANTS, error) + })?, + setting: crate::domain::SceneSetting { + key: values.optional_literal(super::vocabulary::SETTING_KEY), + words: values.optional_literal(super::vocabulary::SETTING_WORDS), + }, + custom_values: serde_json::from_str( + &values.literal(subject, super::vocabulary::SCENE_CUSTOM_VALUES)?, + ) + .map_err(|error| { + rdf_parse_error(subject, super::vocabulary::SCENE_CUSTOM_VALUES, error) + })?, + }, ended_at: optional_timestamp_literal(values, super::vocabulary::ENDED_AT)?, - participant_entity_ids: memory_ids_from_resources( - values.resource_values(super::vocabulary::PARTICIPANT_ENTITY), - )?, summary: values.literal(subject, super::vocabulary::SUMMARY)?, raw_ref: values.optional_literal(super::vocabulary::RAW_REF), salience_score: f32_literal(subject, values, super::vocabulary::SALIENCE_SCORE)?, diff --git a/src/adapters/oxigraph/tests.rs b/src/adapters/oxigraph/tests.rs index 430cfbc0..f7c52d79 100644 --- a/src/adapters/oxigraph/tests.rs +++ b/src/adapters/oxigraph/tests.rs @@ -74,7 +74,7 @@ mod tests { .unwrap() .with_timezone(&chrono::Utc); fixtures.episode.created_at = timestamp; - fixtures.episode.started_at = Some(timestamp); + fixtures.episode.scene.time = timestamp; fixtures.episode.ended_at = Some(timestamp); fixtures.salient_observation.created_at = timestamp; fixtures.salient_observation.observed_at = Some(timestamp); @@ -105,6 +105,73 @@ mod tests { ); } + #[tokio::test] + async fn persisted_scene_round_trips_and_setting_key_is_independently_queryable() { + use crate::domain::{graph_uri, SceneParticipant}; + use oxigraph::sparql::{QueryResults, SparqlEvaluator}; + + let graph_dir = TempGraphDir::new(); + let mut episode = representative_fixtures().episode; + episode.scene.setting.key = Some("room/shared".to_owned()); + episode.scene.participants[0].name = Some("Alice".to_owned()); + episode.scene.setting.words = Some(" a quiet room ".to_owned()); + episode.scene.participants.extend([ + SceneParticipant { + name: Some(" Alice\n".to_owned()), + ..Default::default() + }, + SceneParticipant { + description: Some("a visitor".to_owned()), + ..Default::default() + }, + SceneParticipant { + name: Some(" Alice\n".to_owned()), + ..Default::default() + }, + ]); + episode + .scene + .custom_values + .insert("session".to_owned(), " session/42 ".to_owned()); + episode + .scene + .custom_values + .insert("empty".to_owned(), String::new()); + let mut words_only = episode.clone(); + words_only.id = MemoryId::from_u128(8601); + words_only.scene.setting.key = None; + words_only.scene.setting.words = Some("room/shared".to_owned()); + let expected = vec![ + MemoryObject::Episode(episode.clone()), + MemoryObject::Episode(words_only), + ]; + { + let store = OxigraphGraphAuthorityStore::new_persistent(graph_dir.path()).unwrap(); + store.upsert_objects(&expected).await.unwrap(); + } + let reopened = OxigraphGraphAuthorityStore::new_persistent(graph_dir.path()).unwrap(); + let actual = reopened + .query_objects(&GraphObjectQuery::by_types(vec![ObjectType::Episode], None)) + .await + .unwrap(); + assert_eq!(actual.len(), 2); + for object in expected { + assert!(actual.contains(&object)); + } + let results = SparqlEvaluator::new() + .parse_query(r#"SELECT ?episode WHERE { GRAPH ?g { ?episode "room/shared" } }"#).unwrap() + .on_store(&reopened.store).execute().unwrap(); + let QueryResults::Solutions(solutions) = results else { + panic!("expected SELECT rows") + }; + let rows = solutions.collect::, _>>().unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!( + rows[0].get("episode").unwrap().to_string(), + format!("<{}>", graph_uri(ObjectType::Episode, episode.id)) + ); + } + #[tokio::test] async fn oxigraph_object_selection_obeys_predicates_ordering_and_limits() { let oxigraph = OxigraphGraphAuthorityStore::new_in_memory().unwrap(); diff --git a/src/adapters/oxigraph/vocabulary.rs b/src/adapters/oxigraph/vocabulary.rs index 93a79a7a..3b4f99df 100644 --- a/src/adapters/oxigraph/vocabulary.rs +++ b/src/adapters/oxigraph/vocabulary.rs @@ -15,10 +15,12 @@ pub(crate) const CREATED_AT: &str = "urn:cmem:vocab:createdAt"; pub(crate) const UPDATED_AT: &str = "urn:cmem:vocab:updatedAt"; pub(crate) const MODALITY: &str = "urn:cmem:vocab:modality"; -pub(crate) const SOURCE_CONVERSATION_ID: &str = "urn:cmem:vocab:sourceConversationId"; -pub(crate) const STARTED_AT: &str = "urn:cmem:vocab:startedAt"; +pub(crate) const SCENE_TIME: &str = "urn:cmem:vocab:sceneTime"; +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"; +pub(crate) const SCENE_CUSTOM_VALUES: &str = "urn:cmem:vocab:sceneCustomValues"; pub(crate) const ENDED_AT: &str = "urn:cmem:vocab:endedAt"; -pub(crate) const PARTICIPANT_ENTITY: &str = "urn:cmem:vocab:participantEntity"; pub(crate) const SUMMARY: &str = "urn:cmem:vocab:summary"; pub(crate) const RAW_REF: &str = "urn:cmem:vocab:rawRef"; pub(crate) const SALIENCE_SCORE: &str = "urn:cmem:vocab:salienceScore"; diff --git a/src/api/types/draft.rs b/src/api/types/draft.rs index beb513f0..7ae8eec7 100644 --- a/src/api/types/draft.rs +++ b/src/api/types/draft.rs @@ -7,7 +7,7 @@ use super::write_plan::{RememberDiagnostics, RepairMarker, StatsUpdateStatus}; use crate::domain::{ BeliefAssertion, DerivedMemory, DerivedType, DomainValidationError, Entity, Episode, MemoryId, MemoryLink, MemoryObject, MemoryObjectRef, MemoryThread, Modality, ObjectType, Observation, - RelationType, RetentionState, ThreadStatus, DEFAULT_SCHEMA_VERSION, + RelationType, RetentionState, Scene, ThreadStatus, DEFAULT_SCHEMA_VERSION, }; use crate::errors::VectorIndexingCause; @@ -110,10 +110,9 @@ impl TryFrom for Entity { pub struct EpisodeDraft { pub id: Option, pub modality: Modality, - pub source_conversation_id: Option, - pub started_at: Option>, + /// Overrides the input scene; a caller-built episode candidate must supply it. + pub scene: Option, pub ended_at: Option>, - pub participant_entity_ids: Vec, pub summary: String, pub raw_ref: Option, pub salience_score: f32, @@ -127,10 +126,8 @@ impl EpisodeDraft { Self { id: None, modality: Modality::Chat, - source_conversation_id: None, - started_at: None, + scene: None, ended_at: None, - participant_entity_ids: Vec::new(), summary: summary.into(), raw_ref: None, salience_score: 0.5, @@ -149,14 +146,12 @@ impl EpisodeDraft { self, defaults: &mut DraftDefaults, ) -> Result { - let mut episode = Episode { + let episode = Episode { id: defaults.id(self.id), object_type: ObjectType::Episode, modality: self.modality, - source_conversation_id: self.source_conversation_id, - started_at: self.started_at, + scene: self.scene.ok_or(DomainValidationError::MissingScene)?, ended_at: self.ended_at, - participant_entity_ids: self.participant_entity_ids, summary: self.summary, raw_ref: self.raw_ref, salience_score: self.salience_score, @@ -164,8 +159,6 @@ impl EpisodeDraft { created_at: defaults.timestamp(self.created_at), schema_version: defaults.schema_version(self.schema_version), }; - episode.participant_entity_ids.sort_unstable(); - episode.participant_entity_ids.dedup(); episode.validate()?; Ok(episode) } @@ -650,7 +643,9 @@ mod tests { let created_at = timestamp("2026-04-28T12:01:00Z"); let mut draft = EpisodeDraft::new("Discussed durable draft inputs."); draft.id = Some(id); - draft.source_conversation_id = Some("conversation-42".to_owned()); + let mut scene = Scene::at(created_at); + scene.setting.key = Some("conversation-42".to_owned()); + draft.scene = Some(scene); draft.raw_ref = Some("raw://conversation/42#episode".to_owned()); draft.salience_score = 0.8; draft.created_at = Some(created_at); @@ -681,6 +676,7 @@ mod tests { fn draft_validation_reuses_domain_validation_errors() { let mut episode = EpisodeDraft::new(" "); episode.salience_score = 0.5; + episode.scene = Some(Scene::now()); assert_eq!( episode.into_domain(), Err(DomainValidationError::EmptyEpisodeSummary) diff --git a/src/api/types/lifecycle.rs b/src/api/types/lifecycle.rs index 077069bd..481ece80 100644 --- a/src/api/types/lifecycle.rs +++ b/src/api/types/lifecycle.rs @@ -149,12 +149,12 @@ pub enum SourceObjectCorrectionTarget { Episode { id: MemoryId, original_raw_ref: Option, - original_source_ref: Option, + original_setting_key: Option, }, Observation { id: MemoryId, original_raw_ref: Option, - original_source_ref: Option, + original_setting_key: Option, }, } @@ -697,7 +697,7 @@ mod tests { CorrectionTarget::source_object(SourceObjectCorrectionTarget::Episode { id: episode_id(), original_raw_ref: Some("raw://original/episode".to_owned()), - original_source_ref: Some("conversation://original".to_owned()), + original_setting_key: Some("conversation://original".to_owned()), }), "Correct source episode summary and derived claims.", ); diff --git a/src/api/types/retrieval.rs b/src/api/types/retrieval.rs index cb6addda..539cfae7 100644 --- a/src/api/types/retrieval.rs +++ b/src/api/types/retrieval.rs @@ -622,10 +622,15 @@ mod tests { id, object_type: ObjectType::Episode, modality: Modality::Chat, - source_conversation_id: Some("conversation-42".to_owned()), - started_at: Some(timestamp("2026-04-29T10:00:00Z")), + scene: crate::domain::Scene { + setting: crate::domain::SceneSetting { + key: Some("conversation-42".to_owned()), + words: None, + }, + + ..crate::domain::Scene::at(timestamp("2026-04-29T10:00:00Z")) + }, ended_at: Some(timestamp("2026-04-29T10:05:00Z")), - participant_entity_ids: Vec::new(), summary: "Discussed context packs.".to_owned(), raw_ref: Some("raw://conversation/42#episode".to_owned()), salience_score: 0.8, diff --git a/src/api/types/write_plan.rs b/src/api/types/write_plan.rs index c088e737..f517c68b 100644 --- a/src/api/types/write_plan.rs +++ b/src/api/types/write_plan.rs @@ -8,7 +8,7 @@ use super::draft::{ }; use super::lifecycle::ExternalSourceReference; use crate::domain::{ - CandidateValidation, MemoryCandidateKind, MemoryId, MemoryObjectRef, RelationType, + CandidateValidation, MemoryCandidateKind, MemoryId, MemoryObjectRef, RelationType, Scene, }; use crate::errors::{StatsUpdateCause, VectorIndexingCause}; @@ -17,8 +17,8 @@ pub struct RememberInput { pub content: String, pub entity_ids: Vec, pub thread_ids: Vec, - pub participant_entity_ids: Vec, - pub started_at: Option>, + /// When absent, preparation records its current time once in the episode candidate. + pub scene: Option, pub ended_at: Option>, pub raw_refs: Vec, pub source_spans: Vec, @@ -36,8 +36,7 @@ impl RememberInput { content: content.into(), entity_ids: Vec::new(), thread_ids: Vec::new(), - participant_entity_ids: Vec::new(), - started_at: None, + scene: None, ended_at: None, raw_refs: Vec::new(), source_spans: Vec::new(), @@ -60,13 +59,8 @@ impl RememberInput { self } - pub fn with_participant_entity_id(mut self, participant_entity_id: MemoryId) -> Self { - self.participant_entity_ids.push(participant_entity_id); - self - } - - pub fn with_started_at(mut self, started_at: DateTime) -> Self { - self.started_at = Some(started_at); + pub fn with_scene(mut self, scene: Scene) -> Self { + self.scene = Some(scene); self } diff --git a/src/domain.rs b/src/domain.rs index 81a596c4..0e059e9d 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -2,6 +2,7 @@ pub(crate) mod belief; mod lifecycle; mod object_ref; mod retrieval; +mod scene; pub(crate) mod schema; mod write_validation; @@ -11,6 +12,7 @@ pub use object_ref::MemoryObjectRef; pub use retrieval::{ GraphExpansionBoundedFailureTrace, GraphExpansionBoundedReason, GraphFailureMode, }; +pub use scene::{Scene, SceneParticipant, SceneSetting}; pub use write_validation::{ CandidateProvenanceIssue, CandidateReferenceRole, CandidateScoreField, CandidateSourceSpanIssue, CandidateTimestampField, CandidateValidation, @@ -228,6 +230,12 @@ pub enum DomainValidationError { #[error("episode summary must not be empty")] EmptyEpisodeSummary, + #[error("a caller-built episode must state its scene")] + MissingScene, + + #[error("a scene participant must have a key or nonblank name or description")] + EmptySceneParticipant, + #[error("observation episode_id must reference an episode")] MissingEpisodeReference, @@ -285,10 +293,8 @@ pub struct Episode { pub id: MemoryId, pub object_type: ObjectType, pub modality: Modality, - pub source_conversation_id: Option, - pub started_at: Option>, + pub scene: Scene, pub ended_at: Option>, - pub participant_entity_ids: Vec, pub summary: String, pub raw_ref: Option, pub salience_score: f32, @@ -303,6 +309,15 @@ impl Episode { if self.summary.trim().is_empty() { return Err(DomainValidationError::EmptyEpisodeSummary); } + if self.scene.participants.iter().any(|participant| { + participant.key.is_none() + && [&participant.name, &participant.description] + .into_iter() + .flatten() + .all(|words| words.trim().is_empty()) + }) { + return Err(DomainValidationError::EmptySceneParticipant); + } validate_score("Episode.salience_score", self.salience_score) } } diff --git a/src/domain/lifecycle.rs b/src/domain/lifecycle.rs index 9b55350e..d1a1388f 100644 --- a/src/domain/lifecycle.rs +++ b/src/domain/lifecycle.rs @@ -5,7 +5,7 @@ use super::ObjectType; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SourceReferenceKind { Raw, - Source, + SettingKey, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] diff --git a/src/domain/scene.rs b/src/domain/scene.rs new file mode 100644 index 00000000..e54f9ade --- /dev/null +++ b/src/domain/scene.rs @@ -0,0 +1,51 @@ +use std::collections::BTreeMap; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +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, + pub participants: Vec, + pub setting: SceneSetting, + pub custom_values: BTreeMap, +} + +impl Scene { + pub fn at(time: DateTime) -> Self { + Self { + time, + participants: Vec::new(), + setting: SceneSetting::default(), + custom_values: BTreeMap::new(), + } + } + + pub fn now() -> Self { + Self::at(Utc::now()) + } + + pub(crate) fn participant_keys(&self) -> impl Iterator + '_ { + self.participants + .iter() + .filter_map(|participant| participant.key) + } +} + +/// Perceived words and a known identity can coexist; writes require a key or nonblank words. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct SceneParticipant { + pub key: Option, + pub name: Option, + pub description: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct SceneSetting { + /// An application-owned context key, not a notion identity. + pub key: Option, + pub words: Option, +} diff --git a/src/domain/tests.rs b/src/domain/tests.rs index 22b6c530..952759ec 100644 --- a/src/domain/tests.rs +++ b/src/domain/tests.rs @@ -79,10 +79,18 @@ fn representative_episode() -> Episode { id: memory_id("550e8400-e29b-41d4-a716-446655440000"), object_type: ObjectType::Episode, modality: Modality::Chat, - source_conversation_id: Some("conversation-2026-04-27".to_owned()), - started_at: Some(timestamp("2026-04-27T10:00:00Z")), + scene: crate::domain::Scene { + setting: crate::domain::SceneSetting { + key: Some("conversation-2026-04-27".to_owned()), + words: None, + }, + participants: vec![crate::domain::SceneParticipant { + key: Some(memory_id("550e8400-e29b-41d4-a716-446655440001")), + ..Default::default() + }], + ..crate::domain::Scene::at(timestamp("2026-04-27T10:00:00Z")) + }, ended_at: Some(timestamp("2026-04-27T10:05:00Z")), - participant_entity_ids: vec![memory_id("550e8400-e29b-41d4-a716-446655440001")], summary: "Discussed the episodic memory domain model.".to_owned(), raw_ref: Some("raw://conversation/2026-04-27#episode-1".to_owned()), salience_score: 0.8, diff --git a/src/domain/write_validation.rs b/src/domain/write_validation.rs index 42386b6e..0f12c77f 100644 --- a/src/domain/write_validation.rs +++ b/src/domain/write_validation.rs @@ -72,6 +72,8 @@ pub enum CandidateValidationIssue { }, #[error("episode summary must not be empty")] EmptyEpisodeSummary, + #[error("a scene participant must have a key or nonblank name or description")] + EmptySceneParticipant, #[error("observation episode_id must reference an episode")] MissingEpisodeReference, #[error("derived memory must cite a source episode or observation, or declare application-given grounding")] @@ -127,6 +129,7 @@ pub enum CandidateValidationIssue { #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum CandidateTimestampField { + SceneTime, CreatedAt, UpdatedAt, LastTouchedAt, @@ -172,6 +175,7 @@ pub enum CandidateSourceSpanIssue { #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum CandidateReferenceRole { + SceneParticipant, DerivedSourceEpisode, BeliefSubject, DerivedSourceObservation, diff --git a/src/errors.rs b/src/errors.rs index d36c7dbd..969597e6 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -430,7 +430,9 @@ pub enum CustomError { #[error("low-information co-occurrence link rejected: {link_id}")] LowInformationCoOccurrence { link_id: MemoryId }, - #[error("source-object correction requires an original raw or source reference: {target:?}")] + #[error( + "source-object correction requires an original raw reference or setting key: {target:?}" + )] MissingOriginalSourceReference { target: MemoryObjectRef }, #[error("original {kind:?} reference does not match source object {target:?}")] diff --git a/src/lib.rs b/src/lib.rs index e83e503d..43a8fb29 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -56,9 +56,9 @@ pub use crate::domain::{ DerivedType, DomainValidationError, Entity, Episode, GraphExpansionBoundedFailureTrace, GraphExpansionBoundedReason, GraphFailureMode, LifecycleDtoValidationError, MemoryCandidateKind, MemoryId, MemoryLink, MemoryLinkEndpoint, MemoryObject, MemoryObjectRef, - MemoryThread, Modality, ObjectType, Observation, RelationType, RetentionState, - SourceReferenceKind, ThreadStatus, VectorSurface, CURRENT_SCHEMA_VERSION, - DEFAULT_SCHEMA_VERSION, EPISODIC_MEMORY_SCHEMA_VERSION, + MemoryThread, Modality, ObjectType, Observation, RelationType, RetentionState, Scene, + SceneParticipant, SceneSetting, SourceReferenceKind, ThreadStatus, VectorSurface, + CURRENT_SCHEMA_VERSION, DEFAULT_SCHEMA_VERSION, EPISODIC_MEMORY_SCHEMA_VERSION, }; pub use crate::errors::{ CollectionCompatibilityError, CollectionMismatch, ConfigValidationError, diff --git a/src/memory.rs b/src/memory.rs index c3f49646..efd013c7 100644 --- a/src/memory.rs +++ b/src/memory.rs @@ -13,6 +13,8 @@ use crate::usecases::{ #[cfg(test)] mod notion_tests; #[cfg(test)] +mod scene_tests; +#[cfg(test)] mod write_turn_tests; /// CharacterMemory provides a high-level API for memory operations. @@ -872,7 +874,7 @@ mod tests { SourceObjectCorrectionTarget::Episode { id: fixtures.episode.id, original_raw_ref: fixtures.episode.raw_ref.clone(), - original_source_ref: fixtures.episode.source_conversation_id.clone(), + original_setting_key: fixtures.episode.scene.setting.key.clone(), }, episode_replacement_id, )) @@ -912,7 +914,7 @@ mod tests { SourceObjectCorrectionTarget::Observation { id: fixtures.salient_observation.id, original_raw_ref: fixtures.salient_observation.raw_ref.clone(), - original_source_ref: fixtures.episode.source_conversation_id.clone(), + original_setting_key: fixtures.episode.scene.setting.key.clone(), }, observation_replacement_id, )) diff --git a/src/memory/notion_tests.rs b/src/memory/notion_tests.rs index 53779f9c..57d883f8 100644 --- a/src/memory/notion_tests.rs +++ b/src/memory/notion_tests.rs @@ -639,7 +639,15 @@ 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); - episode.participant_entity_ids = vec![subjects[1], subjects[0], subjects[0]]; + let mut scene = crate::Scene::at(belief.created_at.unwrap()); + scene.participants = [subjects[1], subjects[0], subjects[0]] + .into_iter() + .map(|key| crate::SceneParticipant { + key: Some(key), + ..Default::default() + }) + .collect(); + episode.scene = Some(scene.clone()); episode.created_at = belief.created_at; episode.schema_version = belief.schema_version.clone(); plan = plan.with_candidate(MemoryCandidate::Episode(EpisodeCandidate::new( @@ -662,7 +670,7 @@ async fn repeated_episode_and_belief_id_sets_commit_and_replay_canonically() { assert_eq!(objects.len(), 2); for object in objects { match object { - MemoryObject::Episode(episode) => assert_eq!(episode.participant_entity_ids, subjects), + MemoryObject::Episode(episode) => assert_eq!(episode.scene, scene), MemoryObject::DerivedMemory(memory) => { assert_eq!(memory.entity_ids, subjects); assert_eq!(memory.supersedes, predecessors); diff --git a/src/memory/scene_tests.rs b/src/memory/scene_tests.rs new file mode 100644 index 00000000..95abec12 --- /dev/null +++ b/src/memory/scene_tests.rs @@ -0,0 +1,648 @@ +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; + +use crate::api::types::*; +use crate::domain::*; +use crate::models::vector::EmbeddingInput; +use crate::ports::embedder::MemoryEmbedder; +use crate::ports::graph_authority::GraphObjectQuery; +use crate::ports::retrieval_stats::RetrievalStatsCounterKey; +use crate::test_support::{ + in_memory_graph_store, DeterministicMemoryEmbedder, TemporaryVectorCandidateStore, +}; +use crate::{CharacterMemory, CustomError}; + +fn time() -> DateTime { + DateTime::parse_from_rfc3339("2026-09-20T10:00:00.123456789Z") + .unwrap() + .with_timezone(&Utc) +} + +fn words_scene() -> Scene { + let mut scene = Scene::at(time()); + scene.setting.words = Some(" 窓のそば\nquiet café ".to_owned()); + scene.participants = vec![ + SceneParticipant { + name: Some(" Alice ".to_owned()), + ..Default::default() + }, + SceneParticipant { + description: Some("a visitor in blue".to_owned()), + ..Default::default() + }, + SceneParticipant { + name: Some(" Alice ".to_owned()), + ..Default::default() + }, + ]; + scene + .custom_values + .insert("session".to_owned(), " session/42 ".to_owned()); + scene + .custom_values + .insert("empty".to_owned(), String::new()); + scene +} + +// Observe provider input while using the same deterministic embedder and real stores. +struct RecordingEmbedder(Arc>>); + +#[async_trait] +impl MemoryEmbedder for RecordingEmbedder { + async fn embed(&self, input: &EmbeddingInput) -> Result, CustomError> { + self.0.lock().unwrap().push(input.clone()); + DeterministicMemoryEmbedder::new(8).embed(input).await + } + async fn embed_batch(&self, inputs: &[EmbeddingInput]) -> Result>, CustomError> { + self.0.lock().unwrap().extend_from_slice(inputs); + DeterministicMemoryEmbedder::new(8) + .embed_batch(inputs) + .await + } +} + +async fn memory() -> (CharacterMemory, Arc>>) { + let inputs = Arc::new(Mutex::new(Vec::new())); + let memory = CharacterMemory::from_parts( + Box::new(in_memory_graph_store()), + Box::new(TemporaryVectorCandidateStore::open(8).await), + Box::new(RecordingEmbedder(inputs.clone())), + ); + (memory, inputs) +} + +fn episode_draft(id: u128, scene: Option) -> EpisodeDraft { + let mut draft = EpisodeDraft::new("An experience"); + draft.id = Some(MemoryId::from_u128(id)); + draft.created_at = Some(time()); + draft.schema_version = Some(DEFAULT_SCHEMA_VERSION.to_owned()); + draft.scene = scene; + draft +} + +fn episode_plan(draft: EpisodeDraft) -> RememberWritePlan { + RememberWritePlan::new().with_candidate(MemoryCandidate::Episode(EpisodeCandidate::new( + draft, + CandidateProvenance::caller("experience"), + ))) +} + +async fn objects(memory: &CharacterMemory, types: Vec) -> Vec { + memory + .memory_composition + .graph_store + .query_objects(&GraphObjectQuery::by_types(types, None)) + .await + .unwrap() +} + +fn assert_issue(error: CustomError, expected: CandidateValidationIssue) { + let CustomError::WritePlanValidationRejected { validations } = error else { + panic!("unexpected error: {error:?}") + }; + assert!( + validations + .iter() + .flat_map(|v| &v.errors) + .any(|issue| issue == &expected), + "missing {expected:?}: {validations:?}" + ); +} + +#[tokio::test] +async fn scene_words_round_trip_through_remember_and_authored_plan_without_inference() { + for direct in [false, true] { + let (memory, inputs) = memory().await; + let scene = words_scene(); + let draft = episode_draft(8101, Some(scene.clone())); + let outcome = if direct { + let plan = episode_plan(draft).with_candidate(MemoryCandidate::VectorIndex( + VectorIndexCandidate::new( + MemoryObjectRef::new(ObjectType::Episode, MemoryId::from_u128(8101)), + CandidateProvenance::caller("summary"), + ), + )); + let plan: RememberWritePlan = + serde_json::from_str(&serde_json::to_string(&plan).unwrap()).unwrap(); + let first = memory + .commit(plan.clone(), CommitOptions::default()) + .await + .unwrap(); + let replay = memory.commit(plan, CommitOptions::default()).await.unwrap(); + assert_eq!(first.persisted_object_ids, replay.persisted_object_ids); + first + } else { + memory + .remember( + RememberInput::new("An experience") + .with_scene(scene.clone()) + .with_episode(episode_draft(8101, None)), + RememberOptions::default(), + ) + .await + .unwrap() + }; + let saved = objects(&memory, vec![ObjectType::Episode]).await; + let [MemoryObject::Episode(episode)] = saved.as_slice() else { + panic!("one episode expected") + }; + assert_eq!(episode.scene, scene); + assert!( + objects(&memory, vec![ObjectType::Entity, ObjectType::DerivedMemory]) + .await + .is_empty() + ); + assert!(outcome.persisted_link_ids.is_empty()); + assert_eq!( + outcome.vector_indexed_object_ids.len(), + if direct { 1 } else { 2 } + ); + let recorded = inputs.lock().unwrap().clone(); + assert_eq!(recorded.len(), 2); + for input in recorded { + assert_eq!( + input.text, + match input.object_type.unwrap() { + ObjectType::Episode => "Episode summary: An experience\nSetting: 窓のそば quiet café\nWith: Alice\nWith: a visitor in blue\nWith: Alice", + ObjectType::Observation => "Observation excerpt: An experience", + other => panic!("unexpected embedding: {other:?}"), + } + ); + } + memory.close().await.unwrap(); + } +} + +#[tokio::test] +async fn scene_without_words_keeps_the_exact_legacy_embedding_text() { + for blank_words in [false, true] { + let (memory, inputs) = memory().await; + let participant = MemoryId::from_u128(8700); + let mut entity = EntityDraft::new(); + entity.id = Some(participant); + let mut scene = Scene::at(time()); + scene.setting.key = Some("never embed this setting key".to_owned()); + scene.custom_values.insert( + "session".to_owned(), + "never embed this custom value".to_owned(), + ); + scene.participants.push(SceneParticipant { + key: Some(participant), + ..Default::default() + }); + if blank_words { + scene.setting.words = Some(" \n ".to_owned()); + scene.participants[0].name = Some(" \t ".to_owned()); + scene.participants[0].description = Some(String::new()); + } + memory + .remember( + RememberInput::new(" An\n experience ") + .with_scene(scene) + .with_entity(entity), + RememberOptions::default(), + ) + .await + .unwrap(); + let recorded = inputs.lock().unwrap().clone(); + assert_eq!(recorded.len(), 2); + assert_eq!( + recorded + .iter() + .find(|input| input.object_type == Some(ObjectType::Episode)) + .unwrap() + .text, + "Episode summary: An experience" + ); + assert_eq!( + recorded + .iter() + .find(|input| input.object_type == Some(ObjectType::Observation)) + .unwrap() + .text, + "Observation excerpt: An experience" + ); + memory.close().await.unwrap(); + } +} + +#[tokio::test] +async fn setting_words_recall_the_episode_when_the_summary_does_not_name_the_place() { + // A lexical provider makes the recall test depend on words, not hash collisions. + struct PlaceEmbedder; + #[async_trait] + impl MemoryEmbedder for PlaceEmbedder { + async fn embed(&self, input: &EmbeddingInput) -> Result, CustomError> { + Ok(vec![ + 1.0, + input + .text + .split_whitespace() + .filter(|word| *word == "observatory") + .count() as f32, + ]) + } + async fn embed_batch( + &self, + inputs: &[EmbeddingInput], + ) -> Result>, CustomError> { + let mut embeddings = Vec::new(); + for input in inputs { + embeddings.push(self.embed(input).await?); + } + Ok(embeddings) + } + } + let memory = CharacterMemory::from_parts( + Box::new(in_memory_graph_store()), + Box::new(TemporaryVectorCandidateStore::open(2).await), + Box::new(PlaceEmbedder), + ); + 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()); + 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(); + let outcome = memory + .remember( + RememberInput::new("We made a decision").with_episode(episode), + RememberOptions::default(), + ) + .await + .unwrap(); + assert_eq!(outcome.vector_indexed_object_ids.len(), 2); + } + let mut query = RetrievalContext::new("observatory"); + query.object_type_defaults = vec![ObjectType::Episode]; + query.candidate_limits.max_vector_candidates = 1; + query.candidate_limits.max_graph_roots = 1; + query.graph_limits.max_depth = 0; + let recalled = memory.retrieve(query).await.unwrap(); + assert_eq!(recalled.pack.relevant_episodes.len(), 1); + let episode = &recalled.pack.relevant_episodes[0]; + assert_eq!(episode.id, situated); + assert!(!episode.summary.contains("observatory")); + assert_eq!(episode.scene.setting.words.as_deref(), Some("observatory")); + memory.close().await.unwrap(); +} + +#[tokio::test] +async fn scene_override_preserves_participants_involvement_threads_interval_and_observation() { + let (memory, inputs) = memory().await; + let episode_id = MemoryId::from_u128(8220); + let observation_id = MemoryId::from_u128(8221); + let present = MemoryId::from_u128(8201); + let involved = MemoryId::from_u128(8202); + let existing = MemoryId::from_u128(8203); + let thread_ids = [MemoryId::from_u128(8211), MemoryId::from_u128(8212)]; + let entity = |id| { + let mut draft = EntityDraft::new(); + draft.id = Some(id); + draft + }; + memory + .remember( + RememberInput::new("Earlier experience").with_entity(entity(existing)), + RememberOptions::default(), + ) + .await + .unwrap(); + let mut scene = words_scene(); + scene.setting.key = Some("room/42".to_owned()); + scene.participants.extend([ + SceneParticipant { + key: Some(present), + name: Some("Mira".to_owned()), + description: Some("the host".to_owned()), + }, + SceneParticipant { + key: Some(existing), + ..Default::default() + }, + SceneParticipant { + key: Some(present), + ..Default::default() + }, + ]); + let episode = episode_draft(8220, Some(scene.clone())); + let mut observation = ObservationDraft::new(episode_id, "Explicitly timed statement"); + observation.id = Some(observation_id); + 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)); + overridden + .custom_values + .insert("input-only".to_owned(), "must not merge".to_owned()); + overridden.participants.push(SceneParticipant { + key: Some(MemoryId::from_u128(9999)), + ..Default::default() + }); + let mut input = RememberInput::new("An experience") + .with_scene(overridden) + .with_episode(episode) + .with_observation(observation.clone()) + .with_ended_at(ended_at) + .with_entity(entity(present)) + .with_entity(entity(involved)) + .with_entity_id(involved); + for id in thread_ids { + let mut thread = MemoryThreadDraft::new("A thread", "An ongoing topic"); + thread.id = Some(id); + input = input.with_memory_thread(thread).with_thread_id(id); + } + let outcome = memory + .remember(input, RememberOptions::default()) + .await + .unwrap(); + assert!(inputs + .lock() + .unwrap() + .iter() + .any(|input| input.object_id == Some(episode_id) + && input.text.ends_with("\nWith: Mira, the host"))); + + let graph = &memory.memory_composition.graph_store; + let saved = graph + .query_objects(&GraphObjectQuery::by_ids(vec![episode_id, observation_id])) + .await + .unwrap(); + let [MemoryObject::Episode(saved_episode), MemoryObject::Observation(saved_observation)] = + saved.as_slice() + else { + panic!("expected the source episode and observation") + }; + assert_eq!(saved_episode.scene, scene); + assert_eq!(saved_episode.ended_at, Some(ended_at)); + assert_eq!(saved_observation.observed_at, observation.observed_at); + assert_eq!(saved_observation.speaker_entity_id, Some(involved)); + let links = graph + .query_links_by_ids(&outcome.persisted_link_ids) + .await + .unwrap(); + for (from, relation, to) in [ + (episode_id, RelationType::Involves, involved), + (observation_id, RelationType::Mentions, present), + (observation_id, RelationType::Mentions, existing), + (observation_id, RelationType::PartOfThread, thread_ids[0]), + (observation_id, RelationType::PartOfThread, thread_ids[1]), + ] { + assert!(links + .iter() + .any(|link| (link.from_id, link.relation, link.to_id) == (from, relation, to))); + } + for participant in [present, existing] { + let counter = memory + .memory_composition + .stats_store + .counter(&RetrievalStatsCounterKey { + entity_id: participant, + relation_kind: RelationType::Involves, + object_type: ObjectType::Episode, + }) + .await + .unwrap() + .unwrap(); + assert_eq!(counter.total_count, 1, "repeated keys must not count twice"); + } + memory.close().await.unwrap(); +} + +#[tokio::test] +async fn omitted_scene_time_is_fixed_at_prepare_and_replayed_without_using_created_at() { + let (memory, _) = memory().await; + let before = Utc::now(); + let plan = memory + .prepare( + RememberInput::new("An experience").with_episode(episode_draft(8301, None)), + PrepareOptions::default(), + ) + .await + .unwrap(); + let after = Utc::now(); + let scene = plan + .candidates + .iter() + .find_map(|candidate| match candidate { + MemoryCandidate::Episode(candidate) => candidate.draft.scene.clone(), + _ => None, + }) + .unwrap(); + assert!(scene.time >= before && scene.time <= after); + assert_ne!(scene.time, time()); + assert_eq!(scene, Scene::at(scene.time)); + let serialized = serde_json::to_string(&plan).unwrap(); + for _ in 0..2 { + memory + .commit( + serde_json::from_str(&serialized).unwrap(), + CommitOptions::default(), + ) + .await + .unwrap(); + } + for object in objects(&memory, vec![ObjectType::Episode, ObjectType::Observation]).await { + match object { + MemoryObject::Episode(episode) => { + assert_eq!(episode.scene, scene); + assert_eq!(episode.created_at, time()); + } + MemoryObject::Observation(observation) => { + assert_eq!(observation.observed_at, Some(scene.time)) + } + _ => unreachable!(), + } + } + memory.close().await.unwrap(); +} + +#[tokio::test] +async fn writes_reject_missing_scene_empty_participants_and_unknown_keys() { + let (memory, inputs) = memory().await; + assert_eq!( + episode_draft(8401, None).into_domain(), + Err(DomainValidationError::MissingScene) + ); + let error = memory + .commit( + episode_plan(episode_draft(8401, None)), + CommitOptions::default(), + ) + .await + .unwrap_err(); + assert_issue( + error, + CandidateValidationIssue::MissingTimestamp { + field: CandidateTimestampField::SceneTime, + }, + ); + for participant in [ + SceneParticipant::default(), + SceneParticipant { + name: Some(" \n ".to_owned()), + ..Default::default() + }, + SceneParticipant { + description: Some("\t".to_owned()), + ..Default::default() + }, + SceneParticipant { + name: Some(String::new()), + description: Some(" \t ".to_owned()), + key: None, + }, + ] { + let mut empty_participant = Scene::at(time()); + empty_participant.participants.push(participant); + assert_eq!( + episode_draft(8401, Some(empty_participant.clone())).into_domain(), + Err(DomainValidationError::EmptySceneParticipant) + ); + let error = memory + .remember( + RememberInput::new("An experience").with_scene(empty_participant.clone()), + RememberOptions::default(), + ) + .await + .unwrap_err(); + assert_issue(error, CandidateValidationIssue::EmptySceneParticipant); + let error = memory + .commit( + episode_plan(episode_draft(8401, Some(empty_participant))), + CommitOptions::default(), + ) + .await + .unwrap_err(); + assert_issue(error, CandidateValidationIssue::EmptySceneParticipant); + } + let unknown = MemoryId::from_u128(8499); + let mut scene = Scene::at(time()); + scene.participants.push(SceneParticipant { + key: Some(unknown), + ..Default::default() + }); + let error = memory + .commit( + episode_plan(episode_draft(8401, Some(scene))), + CommitOptions::default(), + ) + .await + .unwrap_err(); + assert_issue( + error, + CandidateValidationIssue::UnknownObjectRef { + role: CandidateReferenceRole::SceneParticipant, + referenced: MemoryObjectRef::new(ObjectType::Entity, unknown), + }, + ); + assert!(inputs.lock().unwrap().is_empty()); + assert!(objects( + &memory, + vec![ + ObjectType::Episode, + ObjectType::Observation, + ObjectType::Entity + ] + ) + .await + .is_empty()); + memory.close().await.unwrap(); +} + +#[tokio::test] +async fn source_correction_uses_setting_key_and_preserves_every_source_scene() { + for observation_target in [false, true] { + 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 original = MemoryId::from_u128(8501); + let observation = MemoryId::from_u128(8502); + let correction = MemoryId::from_u128(8503); + let old = MemoryId::from_u128(8504); + let new = MemoryId::from_u128(8505); + let mut source_observation = ObservationDraft::new(original, "Original statement"); + source_observation.id = Some(observation); + let mut belief = DerivedMemoryDraft::new(DerivedType::Claim, "Original interpretation") + .with_source_episode(original) + .with_source_observation(observation); + belief.id = Some(old); + memory + .remember( + RememberInput::new("An experience") + .with_episode(episode_draft(8501, Some(original_scene.clone()))) + .with_observation(source_observation) + .with_derived_memory(belief), + RememberOptions::default(), + ) + .await + .unwrap(); + memory + .remember( + RememberInput::new("Correction experience") + .with_episode(episode_draft(8503, Some(correction_scene))), + RememberOptions::default(), + ) + .await + .unwrap(); + let sources_before = + objects(&memory, vec![ObjectType::Episode, ObjectType::Observation]).await; + let target = |key| { + if observation_target { + SourceObjectCorrectionTarget::Observation { + id: observation, + original_raw_ref: None, + original_setting_key: Some(key), + } + } else { + SourceObjectCorrectionTarget::Episode { + id: original, + original_raw_ref: None, + original_setting_key: Some(key), + } + } + }; + let mut replacement = + ReplacementDerivedMemoryDraft::new(DerivedType::Correction, "Revised interpretation") + .with_source_episode(original) + .with_source_episode(correction) + .with_source_observation(observation); + replacement.id = Some(new); + replacement.original_source_provenance = SourceProvenanceReference::episode(original); + replacement.correction_origin_provenance = SourceProvenanceReference::episode(correction); + let mut draft = CorrectMemoryDraft::new( + CorrectionTarget::source_object(target(original_scene.setting.words.clone().unwrap())), + "Correct the interpretation", + ) + .with_replacement(replacement); + draft.correction_origin = SourceProvenanceReference::episode(correction); + let error = memory.correct(draft.clone()).await.unwrap_err(); + assert!( + matches!(error, CustomError::OriginalSourceReferenceMismatch { kind: SourceReferenceKind::SettingKey, provided, stored, .. } if Some(provided.as_str()) == original_scene.setting.words.as_deref() && stored == original_scene.setting.key) + ); + draft.targets = vec![CorrectionTarget::source_object(target( + original_scene.setting.key.clone().unwrap(), + ))]; + memory.correct(draft).await.unwrap(); + assert_eq!( + objects(&memory, vec![ObjectType::Episode, ObjectType::Observation]).await, + sources_before + ); + let beliefs = objects(&memory, vec![ObjectType::DerivedMemory]).await; + let updated = beliefs + .iter() + .find_map(|object| match object { + MemoryObject::DerivedMemory(memory) if memory.id == new => Some(memory), + _ => None, + }) + .unwrap(); + assert_eq!(updated.derived_from_episode_ids, vec![original, correction]); + assert_eq!(updated.derived_from_observation_ids, vec![observation]); + assert!(updated.supersedes.contains(&old)); + memory.close().await.unwrap(); + } +} diff --git a/src/memory/write_turn_tests.rs b/src/memory/write_turn_tests.rs index c914ca90..3d665523 100644 --- a/src/memory/write_turn_tests.rs +++ b/src/memory/write_turn_tests.rs @@ -442,7 +442,7 @@ async fn correction_rebuilds_the_cascade_after_embedding_and_preserves_batch_ide SourceObjectCorrectionTarget::Episode { id: SOURCE, original_raw_ref: Some("raw://write-turn/source".to_owned()), - original_source_ref: None, + original_setting_key: None, }, )]; let mut second = diff --git a/src/policy/embedding_surface.rs b/src/policy/embedding_surface.rs index 1470d29f..570cda40 100644 --- a/src/policy/embedding_surface.rs +++ b/src/policy/embedding_surface.rs @@ -17,12 +17,35 @@ pub const fn max_embedding_surfaces(object_type: ObjectType) -> usize { } pub(crate) fn episode_vector_record(episode: &Episode) -> VectorRecord { + let mut text = prefixed_text("Episode summary", &episode.summary); + let setting = episode + .scene + .setting + .words + .as_deref() + .map(|words| ("Setting", clean_text(words))); + let participants = episode.scene.participants.iter().map(|participant| { + let words = [&participant.name, &participant.description] + .into_iter() + .flatten() + .map(|words| clean_text(words)) + .filter(|words| !words.is_empty()) + .collect::>() + .join(", "); + ("With", words) + }); + for (label, words) in setting.into_iter().chain(participants) { + if !words.is_empty() { + text.push('\n'); + text.push_str(&prefixed_text(label, &words)); + } + } VectorRecord::new( episode.id, ObjectType::Episode, VectorSurface::Summary, episode.schema_version.clone(), - prefixed_text("Episode summary", &episode.summary), + text, ) } @@ -230,10 +253,18 @@ mod tests { id: id(10), object_type: ObjectType::Episode, modality: Modality::Chat, - source_conversation_id: Some("conversation-1".to_owned()), - started_at: Some(timestamp()), + scene: crate::domain::Scene { + setting: crate::domain::SceneSetting { + key: Some("conversation-1".to_owned()), + words: None, + }, + participants: vec![crate::domain::SceneParticipant { + key: Some(id(1)), + ..Default::default() + }], + ..crate::domain::Scene::at(timestamp()) + }, ended_at: Some(timestamp()), - participant_entity_ids: vec![id(1)], summary: " Short summary. ".to_owned(), raw_ref: Some("raw://episode".to_owned()), salience_score: 0.42, diff --git a/src/ports/retrieval_stats.rs b/src/ports/retrieval_stats.rs index a2326cc9..e4565bf6 100644 --- a/src/ports/retrieval_stats.rs +++ b/src/ports/retrieval_stats.rs @@ -191,11 +191,11 @@ fn append_intrinsic_edges( ) { match object { MemoryObject::Episode(episode) => { - for entity_id in &episode.participant_entity_ids { + for entity_id in episode.scene.participant_keys() { insert_edge( edges, edge( - *entity_id, + entity_id, RelationType::Involves, episode.id, ObjectType::Episode, @@ -674,10 +674,14 @@ mod tests { id: episode_id, object_type: ObjectType::Episode, modality: Modality::Chat, - source_conversation_id: None, - started_at: None, + scene: crate::domain::Scene { + participants: vec![crate::domain::SceneParticipant { + key: Some(entity_id), + ..Default::default() + }], + ..crate::domain::Scene::at(timestamp()) + }, ended_at: None, - participant_entity_ids: vec![entity_id], summary: "episode".to_owned(), raw_ref: None, salience_score: 0.5, diff --git a/src/test_support.rs b/src/test_support.rs index 35b080d2..f79c146b 100644 --- a/src/test_support.rs +++ b/src/test_support.rs @@ -409,10 +409,24 @@ pub(crate) fn simple_episode() -> Episode { id: fixture_id(10), object_type: ObjectType::Episode, modality: Modality::Chat, - source_conversation_id: Some("conversation:contract-fixture".to_owned()), - started_at: Some(timestamp("2026-04-27T10:00:00Z")), + scene: crate::domain::Scene { + setting: crate::domain::SceneSetting { + key: Some("conversation:contract-fixture".to_owned()), + words: None, + }, + participants: vec![ + crate::domain::SceneParticipant { + key: Some(fixture_id(1)), + ..Default::default() + }, + crate::domain::SceneParticipant { + key: Some(fixture_id(2)), + ..Default::default() + }, + ], + ..crate::domain::Scene::at(timestamp("2026-04-27T10:00:00Z")) + }, ended_at: Some(timestamp("2026-04-27T10:10:00Z")), - participant_entity_ids: vec![fixture_id(1), fixture_id(2)], summary: "Discussed deterministic store contract fixtures.".to_owned(), raw_ref: Some("file:fixtures/raw/simple-episode.txt".to_owned()), salience_score: 0.8, diff --git a/src/usecases/correct_forget.rs b/src/usecases/correct_forget.rs index 5f9dcc33..55fc0aae 100644 --- a/src/usecases/correct_forget.rs +++ b/src/usecases/correct_forget.rs @@ -563,9 +563,9 @@ where let object_ref = source_correction_lifecycle_ref(target).as_memory_object_ref(); let has_raw_ref = source_target_original_raw_ref(target).is_some_and(|value| !value.trim().is_empty()); - let has_source_ref = - source_target_original_source_ref(target).is_some_and(|value| !value.trim().is_empty()); - if !has_raw_ref && !has_source_ref { + let has_setting_key = source_target_original_setting_key(target) + .is_some_and(|value| !value.trim().is_empty()); + if !has_raw_ref && !has_setting_key { return Err(CustomError::MissingOriginalSourceReference { target: object_ref }); } @@ -573,7 +573,7 @@ where SourceObjectCorrectionTarget::Episode { id, original_raw_ref, - original_source_ref, + original_setting_key, } => { let episode = self.fetch_episode(*id).await?; validate_optional_original_ref( @@ -584,15 +584,15 @@ where )?; validate_optional_original_ref( object_ref, - SourceReferenceKind::Source, - original_source_ref.as_deref(), - episode.source_conversation_id.as_deref(), + SourceReferenceKind::SettingKey, + original_setting_key.as_deref(), + episode.scene.setting.key.as_deref(), ) } SourceObjectCorrectionTarget::Observation { id, original_raw_ref, - original_source_ref, + original_setting_key, } => { let observation = self.fetch_observation(*id).await?; validate_optional_original_ref( @@ -601,16 +601,16 @@ where original_raw_ref.as_deref(), observation.raw_ref.as_deref(), )?; - if original_source_ref + if original_setting_key .as_deref() .is_some_and(|value| !value.trim().is_empty()) { let episode = self.fetch_episode(observation.episode_id).await?; validate_optional_original_ref( object_ref, - SourceReferenceKind::Source, - original_source_ref.as_deref(), - episode.source_conversation_id.as_deref(), + SourceReferenceKind::SettingKey, + original_setting_key.as_deref(), + episode.scene.setting.key.as_deref(), )?; } Ok(()) @@ -1037,16 +1037,16 @@ fn source_target_original_raw_ref(target: &SourceObjectCorrectionTarget) -> Opti } } -fn source_target_original_source_ref(target: &SourceObjectCorrectionTarget) -> Option<&str> { +fn source_target_original_setting_key(target: &SourceObjectCorrectionTarget) -> Option<&str> { match target { SourceObjectCorrectionTarget::Episode { - original_source_ref, + original_setting_key, .. } | SourceObjectCorrectionTarget::Observation { - original_source_ref, + original_setting_key, .. - } => original_source_ref.as_deref(), + } => original_setting_key.as_deref(), } } @@ -2484,7 +2484,7 @@ mod tests { CorrectionTarget::source_object(SourceObjectCorrectionTarget::Episode { id: fixtures.episode.id, original_raw_ref: fixtures.episode.raw_ref.clone(), - original_source_ref: fixtures.episode.source_conversation_id.clone(), + original_setting_key: fixtures.episode.scene.setting.key.clone(), }), "Correct episode-derived behavior.", ) @@ -2581,7 +2581,7 @@ mod tests { CorrectionTarget::source_object(SourceObjectCorrectionTarget::Episode { id: fixtures.episode.id, original_raw_ref: fixtures.episode.raw_ref.clone(), - original_source_ref: fixtures.episode.source_conversation_id.clone(), + original_setting_key: fixtures.episode.scene.setting.key.clone(), }), "Correct episode-derived observation-only behavior.", ) @@ -2639,7 +2639,7 @@ mod tests { CorrectionTarget::source_object(SourceObjectCorrectionTarget::Episode { id: ids.episode, original_raw_ref: None, - original_source_ref: None, + original_setting_key: None, }), "Correct source episode.", ); @@ -2670,7 +2670,7 @@ mod tests { CorrectionTarget::source_object(SourceObjectCorrectionTarget::Episode { id: ids.episode, original_raw_ref: Some("raw://wrong".to_owned()), - original_source_ref: Some("conversation://original".to_owned()), + original_setting_key: Some("conversation://original".to_owned()), }), "Correct source episode.", ); @@ -2706,7 +2706,7 @@ mod tests { CorrectionTarget::source_object(SourceObjectCorrectionTarget::Observation { id: ids.observation, original_raw_ref: Some("raw://original/observation".to_owned()), - original_source_ref: Some("conversation://wrong".to_owned()), + original_setting_key: Some("conversation://wrong".to_owned()), }), "Correct source observation.", ); @@ -2719,7 +2719,7 @@ mod tests { assert!( matches!(error, CustomError::OriginalSourceReferenceMismatch { - target, kind: SourceReferenceKind::Source, provided, stored, + target, kind: SourceReferenceKind::SettingKey, provided, stored, } if target == MemoryObjectRef::new(ObjectType::Observation, ids.observation) && provided == "conversation://wrong" && stored.as_deref() == Some("conversation://original")) ); @@ -2758,7 +2758,7 @@ mod tests { CorrectionTarget::source_object(SourceObjectCorrectionTarget::Episode { id: fixtures.episode.id, original_raw_ref: fixtures.episode.raw_ref.clone(), - original_source_ref: fixtures.episode.source_conversation_id.clone(), + original_setting_key: fixtures.episode.scene.setting.key.clone(), }), "Correct the source again.", ) @@ -3319,7 +3319,7 @@ mod tests { CorrectionTarget::source_object(SourceObjectCorrectionTarget::Episode { id: ids.episode, original_raw_ref: Some("raw://original/episode".to_owned()), - original_source_ref: None, + original_setting_key: None, }), "Replace stale derived memory.", ) @@ -3366,10 +3366,15 @@ mod tests { id: ids.episode, object_type: ObjectType::Episode, modality: Modality::Chat, - source_conversation_id: Some("conversation://original".to_owned()), - started_at: None, + scene: crate::domain::Scene { + setting: crate::domain::SceneSetting { + key: Some("conversation://original".to_owned()), + words: None, + }, + + ..crate::domain::Scene::at(Utc::now()) + }, ended_at: None, - participant_entity_ids: Vec::new(), summary: "Original source episode.".to_owned(), raw_ref: Some("raw://original/episode".to_owned()), salience_score: 0.8, diff --git a/src/usecases/write_planning.rs b/src/usecases/write_planning.rs index b3710844..283d2f79 100644 --- a/src/usecases/write_planning.rs +++ b/src/usecases/write_planning.rs @@ -13,7 +13,7 @@ use crate::api::types::{ ObservationDraft, }; use crate::domain::MemoryObjectRef; -use crate::domain::{graph_uri, MemoryId, ObjectType, RelationType, DEFAULT_SCHEMA_VERSION}; +use crate::domain::{graph_uri, MemoryId, ObjectType, RelationType, Scene, DEFAULT_SCHEMA_VERSION}; /// Stable UUIDv5 namespace for write-plan IDs. IDs remain stable across releases as long as this /// namespace and `deterministic_uuid` label framing stay fixed. @@ -86,6 +86,13 @@ impl RememberInput { include_stats_update_candidates: bool, ) -> RememberWritePlan { let refs = self.prepared_candidate_refs(defaults); + let scene = self + .episode_drafts + .first() + .and_then(|draft| draft.scene.as_ref()) + .or(self.scene.as_ref()) + .cloned() + .unwrap_or_else(|| Scene::at(defaults.created_at)); let mut plan = RememberWritePlan::new(); if let Some(source_input_ref) = self.source_reference() { @@ -93,15 +100,17 @@ impl RememberInput { } let episode_provenance = self.helper_provenance(); - let episode = - EpisodeCandidate::new(self.episode_candidate_draft(defaults), episode_provenance); + let episode = EpisodeCandidate::new( + self.episode_candidate_draft(defaults, &scene), + episode_provenance, + ); plan = plan.with_candidate(MemoryCandidate::Episode(episode)); let observation_provenance = self .helper_provenance() .with_source_episode(refs.episode_id); let observation = ObservationCandidate::new( - self.observation_candidate_draft(defaults, refs.episode_id), + self.observation_candidate_draft(defaults, refs.episode_id, &scene), observation_provenance, ); plan = plan.with_candidate(MemoryCandidate::Observation(observation)); @@ -155,7 +164,7 @@ impl RememberInput { ))); } - for link in self.caller_hint_links(defaults, refs.episode_id, refs.observation_id) { + for link in self.caller_hint_links(defaults, refs.episode_id, refs.observation_id, &scene) { plan = plan.with_candidate(MemoryCandidate::MemoryLink(MemoryLinkCandidate::new( link, self.helper_provenance() @@ -266,7 +275,11 @@ impl RememberInput { SourceSpan::source(source_ref) } - fn episode_candidate_draft(&self, defaults: &RememberPlanDefaults) -> EpisodeDraft { + fn episode_candidate_draft( + &self, + defaults: &RememberPlanDefaults, + scene: &Scene, + ) -> EpisodeDraft { let mut draft = self .episode_drafts .first() @@ -275,11 +288,8 @@ impl RememberInput { draft .id .get_or_insert_with(|| defaults.stable_id("episode:0")); - draft.started_at = draft.started_at.or(self.started_at); + draft.scene = Some(scene.clone()); draft.ended_at = draft.ended_at.or(self.ended_at); - if draft.participant_entity_ids.is_empty() { - draft.participant_entity_ids = self.participant_entity_ids.clone(); - } if draft.raw_ref.is_none() { draft.raw_ref = self.raw_refs.first().cloned(); } @@ -294,6 +304,7 @@ impl RememberInput { &self, defaults: &RememberPlanDefaults, episode_id: MemoryId, + scene: &Scene, ) -> ObservationDraft { let mut draft = self .observation_drafts @@ -308,7 +319,7 @@ impl RememberInput { } else { draft.episode_id }; - draft.observed_at = draft.observed_at.or(self.started_at); + draft.observed_at = draft.observed_at.or(Some(scene.time)); if draft.raw_ref.is_none() { draft.raw_ref = self.raw_refs.first().cloned(); } @@ -324,6 +335,7 @@ impl RememberInput { defaults: &RememberPlanDefaults, episode_id: MemoryId, observation_id: MemoryId, + scene: &Scene, ) -> Vec { let mut links = Vec::new(); for (index, entity_id) in self.entity_ids.iter().copied().enumerate() { @@ -339,7 +351,12 @@ impl RememberInput { defaults.stable_id(format!("hint-link:entity:{index}")), )); } - for (index, participant_id) in self.participant_entity_ids.iter().copied().enumerate() { + let mut seen = HashSet::new(); + for (index, participant_id) in scene + .participant_keys() + .filter(|id| seen.insert(*id)) + .enumerate() + { links.push(complete_link_draft( MemoryLinkDraft::new( ObjectType::Observation, @@ -525,12 +542,17 @@ 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); + scene.participants.push(crate::SceneParticipant { + key: Some(memory_id("550e8400-e29b-41d4-a716-446655443003")), + ..Default::default() + }); let input = RememberInput::new("Caller said they prefer terse planning notes.") .with_raw_ref("raw://conversation/7#turn=2") .with_source_span(SourceSpan::raw("raw://conversation/7#turn=2").with_turn_range(2, 2)) .with_entity_id(memory_id("550e8400-e29b-41d4-a716-446655443001")) .with_thread_id(memory_id("550e8400-e29b-41d4-a716-446655443002")) - .with_participant_entity_id(memory_id("550e8400-e29b-41d4-a716-446655443003")) + .with_scene(scene) .with_derived_memory(DerivedMemoryDraft::new( DerivedType::Reflection, "Caller-provided reflection text.", @@ -788,6 +810,13 @@ impl PlanValidationContext { fn collect_referenced_refs(&mut self, candidate: &MemoryCandidate) { match candidate { + MemoryCandidate::Episode(candidate) => { + if let Some(scene) = &candidate.draft.scene { + for id in scene.participant_keys() { + self.add_ref_to_check(MemoryObjectRef::new(ObjectType::Entity, id)); + } + } + } MemoryCandidate::DerivedMemory(candidate) => { for entity_id in &candidate.draft.entity_ids { self.add_ref_to_check(MemoryObjectRef::new(ObjectType::Entity, *entity_id)); @@ -870,6 +899,14 @@ impl PlanValidationContext { candidate.draft.schema_version.as_deref(), )); errors.extend(validate_episode_timestamps(&candidate.draft)); + if let Some(scene) = &candidate.draft.scene { + for id in scene.participant_keys() { + errors.extend(self.validate_graph_authoritative_ref( + MemoryObjectRef::new(ObjectType::Entity, id), + CandidateReferenceRole::SceneParticipant, + )); + } + } match candidate .draft .clone() @@ -1292,6 +1329,9 @@ fn stable_episode_draft(draft: EpisodeDraft) -> Result CandidateV CandidateValidationIssue::AuthoredBeliefAboutLink } DomainValidationError::EmptyEpisodeSummary => CandidateValidationIssue::EmptyEpisodeSummary, + DomainValidationError::MissingScene => CandidateValidationIssue::MissingTimestamp { + field: CandidateTimestampField::SceneTime, + }, + DomainValidationError::EmptySceneParticipant => { + CandidateValidationIssue::EmptySceneParticipant + } DomainValidationError::MissingEpisodeReference => { CandidateValidationIssue::MissingEpisodeReference } @@ -2542,6 +2588,7 @@ mod tests { } fn complete_episode(mut draft: EpisodeDraft) -> EpisodeDraft { + draft.scene.get_or_insert_with(|| Scene::at(timestamp())); 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 28e8a349..5f37bf89 100644 --- a/tests/retrieval_guardrails_tests.rs +++ b/tests/retrieval_guardrails_tests.rs @@ -361,11 +361,19 @@ fn entity(id: MemoryId) -> EntityDraft { draft } -fn episode(id: MemoryId, summary: &str, participant_entity_ids: &[MemoryId]) -> EpisodeDraft { +fn episode(id: MemoryId, summary: &str, participants: &[MemoryId]) -> EpisodeDraft { let mut draft = EpisodeDraft::new(summary); draft.id = Some(id); - draft.participant_entity_ids = participant_entity_ids.to_vec(); - draft.started_at = Some(timestamp()); + let mut scene = character_memory::Scene::at(timestamp()); + scene.participants = participants + .iter() + .copied() + .map(|key| character_memory::SceneParticipant { + key: Some(key), + ..Default::default() + }) + .collect(); + draft.scene = Some(scene); draft.ended_at = Some(timestamp()); draft.created_at = Some(timestamp()); draft.raw_ref = Some(format!("raw://integration/v0-1-2/{id}")); diff --git a/tests/write_planning_tests.rs b/tests/write_planning_tests.rs index 3b370b00..0a9f0d1c 100644 --- a/tests/write_planning_tests.rs +++ b/tests/write_planning_tests.rs @@ -213,6 +213,10 @@ async fn remember_wrapper_commits_equivalent_graph_state() { .find(|episode| episode.id == episode_id) .expect("caller-supplied episode should be in canonical graph state"); assert_eq!(episode.summary, "Equivalent graph state observation"); + assert_eq!( + episode.scene, + character_memory::Scene::at(fixed_timestamp()) + ); let observation = wrapper_retrieval .pack .salient_observations @@ -380,7 +384,7 @@ fn remember_equivalence_input() -> RememberInput { let mut episode = EpisodeDraft::new("Equivalent graph state observation"); episode.id = Some(episode_id); - episode.participant_entity_ids.push(entity_id); + episode.scene = Some(character_memory::Scene::at(timestamp)); episode.created_at = Some(timestamp); let mut observation =