From cb4261edb2326b4ac96a91abbd37a10b5ec42adf Mon Sep 17 00:00:00 2001 From: ebigunso Date: Mon, 21 Sep 2026 05:32:56 +0900 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8=20Store=20the=20perceived=20scene?= =?UTF-8?q?=20on=20each=20episode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- README.md | 9 +- docs/design/database/graph_schema_design.md | 8 +- docs/design/database/schema_cheat_sheet.md | 7 +- src/adapters/oxigraph/rdf_mapping.rs | 28 +- src/adapters/oxigraph/shared.rs | 26 +- src/adapters/oxigraph/tests.rs | 59 ++- src/adapters/oxigraph/vocabulary.rs | 8 +- src/api/types/draft.rs | 24 +- src/api/types/lifecycle.rs | 6 +- src/api/types/retrieval.rs | 11 +- src/api/types/write_plan.rs | 18 +- src/domain.rs | 15 +- src/domain/lifecycle.rs | 2 +- src/domain/scene.rs | 65 +++ src/domain/tests.rs | 13 +- src/domain/write_validation.rs | 4 + src/errors.rs | 4 +- src/lib.rs | 6 +- src/memory.rs | 6 +- src/memory/notion_tests.rs | 9 +- src/memory/scene_tests.rs | 509 ++++++++++++++++++++ src/memory/write_turn_tests.rs | 2 +- src/policy/embedding_surface.rs | 11 +- src/ports/retrieval_stats.rs | 11 +- src/test_support.rs | 14 +- src/usecases/correct_forget.rs | 59 +-- src/usecases/write_planning.rs | 74 ++- tests/retrieval_guardrails_tests.rs | 11 +- tests/write_planning_tests.rs | 14 +- 29 files changed, 904 insertions(+), 129 deletions(-) create mode 100644 src/domain/scene.rs create mode 100644 src/memory/scene_tests.rs diff --git a/README.md b/README.md index 1905ded5..86d6b832 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,12 @@ 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("a visitor".to_owned())); +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 +160,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. +The episode stores one `Scene`: the experience time, optional keyed or word-described participants, a setting key and/or words, and flat string custom values. Unspecified details stay unspecified. Words are preserved as supplied; they create no notions, links or embeddings. Participant keys must identify existing notions or notions in the same plan. A supplied `EpisodeDraft.scene` overrides the input scene as a whole. Without a scene, `prepare` records the current time once, meaning the experience is happening now; historical writes must provide their time. An authored episode candidate must include a scene, and writes reject `Scene.activity`. Observation time defaults to scene time; an explicit observation time, speaker, episode end time, involved notions and multiple thread memberships remain independent. Corrections preserve source scenes and may guard the original context with `original_setting_key`. + 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..151f9dce 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 as notion keys or supplied names/descriptions, 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. Participant keys must identify existing or same-plan notions. Names, descriptions, setting words and custom values only round-trip on writes: they create no notions, links, recall cues or embeddings. Writes reject an activity. + +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..0be03b3c 100644 --- a/docs/design/database/schema_cheat_sheet.md +++ b/docs/design/database/schema_cheat_sheet.md @@ -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 participant keys, names or descriptions | +| `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 | 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..bb429933 100644 --- a/src/adapters/oxigraph/shared.rs +++ b/src/adapters/oxigraph/shared.rs @@ -290,13 +290,27 @@ 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) + })?, + activity: None, + }, 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..9ca56097 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,63 @@ 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.setting.words = Some(" a quiet room ".to_owned()); + episode.scene.participants.extend([ + SceneParticipant::Name(" Alice\n".to_owned()), + SceneParticipant::Description("a visitor".to_owned()), + SceneParticipant::Name(" Alice\n".to_owned()), + ]); + 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..48a851c9 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::MissingSceneTime)?, 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..97568fd6 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, SceneActivity, 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("an episode scene must state its time")] + MissingSceneTime, + + #[error("an activity cannot be supplied on a write")] + SceneActivityOnWrite, + #[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,9 @@ impl Episode { if self.summary.trim().is_empty() { return Err(DomainValidationError::EmptyEpisodeSummary); } + if self.scene.activity.is_some() { + return Err(DomainValidationError::SceneActivityOnWrite); + } 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..f55964b5 --- /dev/null +++ b/src/domain/scene.rs @@ -0,0 +1,65 @@ +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, + /// Thread or open-loop context; writes reject it. + pub activity: Option, +} + +impl Scene { + pub fn at(time: DateTime) -> Self { + Self { + time, + participants: Vec::new(), + setting: SceneSetting::default(), + custom_values: BTreeMap::new(), + activity: None, + } + } + + pub fn now() -> Self { + Self::at(Utc::now()) + } + + pub(crate) fn participant_keys(&self) -> impl Iterator + '_ { + self.participants + .iter() + .filter_map(|participant| match participant { + SceneParticipant::Key(id) => Some(*id), + SceneParticipant::Name(_) | SceneParticipant::Description(_) => None, + }) + } +} + +/// Names and descriptions on a write are preserved as words, without resolution. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", content = "value", rename_all = "snake_case")] +pub enum SceneParticipant { + Key(MemoryId), + Name(String), + Description(String), +} + +#[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, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", content = "id", rename_all = "snake_case")] +pub enum SceneActivity { + Thread(MemoryId), + OpenLoop(MemoryId), +} diff --git a/src/domain/tests.rs b/src/domain/tests.rs index 22b6c530..84d7d7bd 100644 --- a/src/domain/tests.rs +++ b/src/domain/tests.rs @@ -79,10 +79,17 @@ 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(memory_id( + "550e8400-e29b-41d4-a716-446655440001", + ))], + ..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..1907f551 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("an activity cannot be supplied on a write")] + SceneActivityOnWrite, #[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..45abcec2 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, + SceneActivity, 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..37edf5c0 100644 --- a/src/memory/notion_tests.rs +++ b/src/memory/notion_tests.rs @@ -639,7 +639,12 @@ 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(crate::SceneParticipant::Key) + .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 +667,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..e4a671bc --- /dev/null +++ b/src/memory/scene_tests.rs @@ -0,0 +1,509 @@ +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(" Alice ".to_owned()), + SceneParticipant::Description("a visitor in blue".to_owned()), + SceneParticipant::Name(" Alice ".to_owned()), + ]; + 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", + ObjectType::Observation => "Observation excerpt: An experience", + other => panic!("unexpected embedding: {other:?}"), + } + ); + } + memory.close().await.unwrap(); + } +} + +#[tokio::test] +async fn scene_override_preserves_participants_involvement_threads_interval_and_observation() { + let (memory, _) = memory().await; + 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(present), + SceneParticipant::Key(existing), + SceneParticipant::Key(present), + ]); + let episode = episode_draft(8220, Some(scene.clone())); + let mut observation = ObservationDraft::new(episode.id.unwrap(), "Explicitly timed statement"); + observation.id = Some(MemoryId::from_u128(8221)); + 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(MemoryId::from_u128(9999))); + 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(); + let saved = memory + .memory_composition + .graph_store + .query_objects(&GraphObjectQuery::by_ids(vec![ + MemoryId::from_u128(8220), + MemoryId::from_u128(8221), + ])) + .await + .unwrap(); + assert_eq!(saved.len(), 2); + for object in saved { + match object { + MemoryObject::Episode(episode) => { + assert_eq!(episode.scene, scene); + assert_eq!(episode.ended_at, Some(ended_at)); + } + MemoryObject::Observation(saved) => { + assert_eq!(saved.observed_at, observation.observed_at); + assert_eq!(saved.speaker_entity_id, Some(involved)); + } + _ => panic!("unexpected object"), + } + } + let links = memory + .memory_composition + .graph_store + .query_links_by_ids(&outcome.persisted_link_ids) + .await + .unwrap(); + let actual = links + .iter() + .map(|link| (link.from_id, link.relation, link.to_id)) + .collect::>(); + assert_eq!(actual.len(), 5); + assert!(actual.contains(&(MemoryId::from_u128(8220), RelationType::Involves, involved))); + for participant in [present, existing] { + assert!(actual.contains(&( + MemoryId::from_u128(8221), + RelationType::Mentions, + participant + ))); + } + for thread in thread_ids { + assert!(actual.contains(&( + MemoryId::from_u128(8221), + RelationType::PartOfThread, + thread + ))); + } + 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_time_activity_and_unknown_participant_keys() { + let (memory, inputs) = memory().await; + assert_eq!( + episode_draft(8401, None).into_domain(), + Err(DomainValidationError::MissingSceneTime) + ); + let error = memory + .commit( + episode_plan(episode_draft(8401, None)), + CommitOptions::default(), + ) + .await + .unwrap_err(); + assert_issue( + error, + CandidateValidationIssue::MissingTimestamp { + field: CandidateTimestampField::SceneTime, + }, + ); + for activity in [ + SceneActivity::Thread(MemoryId::from_u128(8411)), + SceneActivity::OpenLoop(MemoryId::from_u128(8412)), + ] { + let mut scene = Scene::at(time()); + scene.activity = Some(activity); + assert_eq!( + episode_draft(8401, Some(scene.clone())).into_domain(), + Err(DomainValidationError::SceneActivityOnWrite) + ); + let error = memory + .remember( + RememberInput::new("An experience").with_scene(scene.clone()), + RememberOptions::default(), + ) + .await + .unwrap_err(); + assert_issue(error, CandidateValidationIssue::SceneActivityOnWrite); + let error = memory + .commit( + episode_plan(episode_draft(8401, Some(scene))), + CommitOptions::default(), + ) + .await + .unwrap_err(); + assert_issue(error, CandidateValidationIssue::SceneActivityOnWrite); + } + let unknown = MemoryId::from_u128(8499); + let mut scene = Scene::at(time()); + scene.participants.push(SceneParticipant::Key(unknown)); + 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..57a120ce 100644 --- a/src/policy/embedding_surface.rs +++ b/src/policy/embedding_surface.rs @@ -230,10 +230,15 @@ 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(id(1))], + ..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..5bf3ac48 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,11 @@ 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(entity_id)], + ..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..926b2742 100644 --- a/src/test_support.rs +++ b/src/test_support.rs @@ -409,10 +409,18 @@ 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(fixture_id(1)), + crate::domain::SceneParticipant::Key(fixture_id(2)), + ], + ..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..62f0020c 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,18 @@ 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(memory_id( + "550e8400-e29b-41d4-a716-446655443003", + ))); 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 +811,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 +900,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 +1330,9 @@ fn stable_episode_draft(draft: EpisodeDraft) -> Result CandidateV CandidateValidationIssue::AuthoredBeliefAboutLink } DomainValidationError::EmptyEpisodeSummary => CandidateValidationIssue::EmptyEpisodeSummary, + DomainValidationError::MissingSceneTime => CandidateValidationIssue::MissingTimestamp { + field: CandidateTimestampField::SceneTime, + }, + DomainValidationError::SceneActivityOnWrite => { + CandidateValidationIssue::SceneActivityOnWrite + } DomainValidationError::MissingEpisodeReference => { CandidateValidationIssue::MissingEpisodeReference } @@ -2542,6 +2589,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..2c8c9223 100644 --- a/tests/retrieval_guardrails_tests.rs +++ b/tests/retrieval_guardrails_tests.rs @@ -361,11 +361,16 @@ 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(character_memory::SceneParticipant::Key) + .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..b813c44c 100644 --- a/tests/write_planning_tests.rs +++ b/tests/write_planning_tests.rs @@ -213,6 +213,12 @@ 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.participants, + vec![character_memory::SceneParticipant::Name( + "A visitor".to_owned() + )] + ); let observation = wrapper_retrieval .pack .salient_observations @@ -380,7 +386,13 @@ 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); + let mut scene = character_memory::Scene::at(timestamp); + scene + .participants + .push(character_memory::SceneParticipant::Name( + "A visitor".to_owned(), + )); + episode.scene = Some(scene); episode.created_at = Some(timestamp); let mut observation = From c5b3e1414b3ff9d2f7a4b7b19a68fb206d099288 Mon Sep 17 00:00:00 2001 From: ebigunso Date: Mon, 21 Sep 2026 05:53:10 +0900 Subject: [PATCH 2/3] =?UTF-8?q?=E2=9C=A8=20Recall=20scene=20words=20alongs?= =?UTF-8?q?ide=20known=20participant=20identities?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- README.md | 7 +- docs/design/database/graph_schema_design.md | 4 +- docs/design/database/schema_cheat_sheet.md | 4 +- docs/design/database/vector_payload_design.md | 4 +- src/adapters/oxigraph/tests.rs | 16 +- src/domain.rs | 10 + src/domain/scene.rs | 18 +- src/domain/tests.rs | 7 +- src/domain/write_validation.rs | 2 + src/memory/notion_tests.rs | 5 +- src/memory/scene_tests.rs | 193 ++++++++++++++++-- src/policy/embedding_surface.rs | 29 ++- src/ports/retrieval_stats.rs | 5 +- src/test_support.rs | 10 +- src/usecases/write_planning.rs | 12 +- tests/retrieval_guardrails_tests.rs | 5 +- tests/write_planning_tests.rs | 14 +- 17 files changed, 286 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 86d6b832..85500a5e 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,10 @@ Character Memory separates planning from persistence. Use `prepare` to build an use character_memory::{Scene, SceneParticipant}; let mut scene = Scene::now(); -scene.participants.push(SceneParticipant::Description("a visitor".to_owned())); +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); @@ -160,7 +163,7 @@ 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. -The episode stores one `Scene`: the experience time, optional keyed or word-described participants, a setting key and/or words, and flat string custom values. Unspecified details stay unspecified. Words are preserved as supplied; they create no notions, links or embeddings. Participant keys must identify existing notions or notions in the same plan. A supplied `EpisodeDraft.scene` overrides the input scene as a whole. Without a scene, `prepare` records the current time once, meaning the experience is happening now; historical writes must provide their time. An authored episode candidate must include a scene, and writes reject `Scene.activity`. Observation time defaults to scene time; an explicit observation time, speaker, episode end time, involved notions and multiple thread memberships remain independent. Corrections preserve source scenes and may guard the original context with `original_setting_key`. +The episode stores one `Scene`: the experience time, participants with any combination of an identity key, name and description, a setting key and/or words, and flat string custom values. Unspecified details stay unspecified. Words are preserved as supplied and appended with `Setting:` and `With:` labels to the episode summary in its single embedding; they create no notions or links. Without scene words, embedding text is unchanged. Keys and custom values are never embedded. Each participant must supply at least one of key, name or description. Participant keys must identify existing notions or notions in the same plan; knowing a key does not replace the supplied words. A supplied `EpisodeDraft.scene` overrides the input scene as a whole. Without a scene, `prepare` records the current time once, meaning the experience is happening now; historical writes must provide their time. An authored episode candidate must include a scene, and writes reject `Scene.activity`. Observation time defaults to scene time; an explicit observation time, speaker, episode end time, involved notions and multiple thread memberships remain independent. Corrections preserve source scenes and may guard the original context with `original_setting_key`. 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. diff --git a/docs/design/database/graph_schema_design.md b/docs/design/database/graph_schema_design.md index 151f9dce..410ddf02 100644 --- a/docs/design/database/graph_schema_design.md +++ b/docs/design/database/graph_schema_design.md @@ -23,9 +23,9 @@ Hydration reconstructs domain objects and links from the selected RDF named grap ## Experiences, Notions And Threads -Episodes record interaction spans with `summary`, `modality`, one `Scene`, optional `endedAt` and `rawRef`, salience and retention. The scene holds the required experience time, participants as notion keys or supplied names/descriptions, 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. +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. Participant keys must identify existing or same-plan notions. Names, descriptions, setting words and custom values only round-trip on writes: they create no notions, links, recall cues or embeddings. Writes reject an activity. +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 at least one of key, name or description; an all-absent participant 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 name and description follows a `With:` label 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. Writes reject an activity. 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. diff --git a/docs/design/database/schema_cheat_sheet.md b/docs/design/database/schema_cheat_sheet.md index 0be03b3c..cd952d43 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 | @@ -57,7 +57,7 @@ Predicate names in these tables are suffixes under `urn:cmem:vocab:`. | `createdAt`, `updatedAt` | Timestamps on the types that declare them | | `modality`, `endedAt` | Episode modality and optional interval end | | `sceneTime` | Required experience time on the episode | -| `sceneParticipants` | Lossless JSON array of participant keys, names or descriptions | +| `sceneParticipants` | Lossless JSON array of participants, each with optional key, name and description; at least one supplied | | `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 | diff --git a/docs/design/database/vector_payload_design.md b/docs/design/database/vector_payload_design.md index ebd02d12..9d9ddaa2 100644 --- a/docs/design/database/vector_payload_design.md +++ b/docs/design/database/vector_payload_design.md @@ -24,13 +24,15 @@ The [payload writer (`payload.rs:164`)](../../../src/adapters/qdrant/payload.rs# | 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 (`embedding_surface.rs:19`)](../../../src/policy/embedding_surface.rs#L19) appends nonblank setting words on a new `Setting:` line, followed by a `With:` line for each participant name and description 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 diff --git a/src/adapters/oxigraph/tests.rs b/src/adapters/oxigraph/tests.rs index 9ca56097..f7c52d79 100644 --- a/src/adapters/oxigraph/tests.rs +++ b/src/adapters/oxigraph/tests.rs @@ -113,11 +113,21 @@ mod tests { 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(" Alice\n".to_owned()), - SceneParticipant::Description("a visitor".to_owned()), - SceneParticipant::Name(" Alice\n".to_owned()), + 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 diff --git a/src/domain.rs b/src/domain.rs index 97568fd6..b84b099d 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -236,6 +236,9 @@ pub enum DomainValidationError { #[error("an activity cannot be supplied on a write")] SceneActivityOnWrite, + #[error("a scene participant must have a key, name or description")] + EmptySceneParticipant, + #[error("observation episode_id must reference an episode")] MissingEpisodeReference, @@ -312,6 +315,13 @@ impl Episode { if self.scene.activity.is_some() { return Err(DomainValidationError::SceneActivityOnWrite); } + if self.scene.participants.iter().any(|participant| { + participant.key.is_none() + && participant.name.is_none() + && participant.description.is_none() + }) { + return Err(DomainValidationError::EmptySceneParticipant); + } validate_score("Episode.salience_score", self.salience_score) } } diff --git a/src/domain/scene.rs b/src/domain/scene.rs index f55964b5..8864d0c6 100644 --- a/src/domain/scene.rs +++ b/src/domain/scene.rs @@ -34,20 +34,16 @@ impl Scene { pub(crate) fn participant_keys(&self) -> impl Iterator + '_ { self.participants .iter() - .filter_map(|participant| match participant { - SceneParticipant::Key(id) => Some(*id), - SceneParticipant::Name(_) | SceneParticipant::Description(_) => None, - }) + .filter_map(|participant| participant.key) } } -/// Names and descriptions on a write are preserved as words, without resolution. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(tag = "kind", content = "value", rename_all = "snake_case")] -pub enum SceneParticipant { - Key(MemoryId), - Name(String), - Description(String), +/// Perceived words and a known identity can coexist; writes require at least one field. +#[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)] diff --git a/src/domain/tests.rs b/src/domain/tests.rs index 84d7d7bd..952759ec 100644 --- a/src/domain/tests.rs +++ b/src/domain/tests.rs @@ -84,9 +84,10 @@ fn representative_episode() -> Episode { key: Some("conversation-2026-04-27".to_owned()), words: None, }, - participants: vec![crate::domain::SceneParticipant::Key(memory_id( - "550e8400-e29b-41d4-a716-446655440001", - ))], + 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")), diff --git a/src/domain/write_validation.rs b/src/domain/write_validation.rs index 1907f551..93ac4c78 100644 --- a/src/domain/write_validation.rs +++ b/src/domain/write_validation.rs @@ -74,6 +74,8 @@ pub enum CandidateValidationIssue { EmptyEpisodeSummary, #[error("an activity cannot be supplied on a write")] SceneActivityOnWrite, + #[error("a scene participant must have a key, 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")] diff --git a/src/memory/notion_tests.rs b/src/memory/notion_tests.rs index 37edf5c0..57d883f8 100644 --- a/src/memory/notion_tests.rs +++ b/src/memory/notion_tests.rs @@ -642,7 +642,10 @@ async fn repeated_episode_and_belief_id_sets_commit_and_replay_canonically() { let mut scene = crate::Scene::at(belief.created_at.unwrap()); scene.participants = [subjects[1], subjects[0], subjects[0]] .into_iter() - .map(crate::SceneParticipant::Key) + .map(|key| crate::SceneParticipant { + key: Some(key), + ..Default::default() + }) .collect(); episode.scene = Some(scene.clone()); episode.created_at = belief.created_at; diff --git a/src/memory/scene_tests.rs b/src/memory/scene_tests.rs index e4a671bc..050698ec 100644 --- a/src/memory/scene_tests.rs +++ b/src/memory/scene_tests.rs @@ -24,9 +24,18 @@ fn words_scene() -> Scene { let mut scene = Scene::at(time()); scene.setting.words = Some(" 窓のそば\nquiet café ".to_owned()); scene.participants = vec![ - SceneParticipant::Name(" Alice ".to_owned()), - SceneParticipant::Description("a visitor in blue".to_owned()), - SceneParticipant::Name(" Alice ".to_owned()), + 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 @@ -156,7 +165,7 @@ async fn scene_words_round_trip_through_remember_and_authored_plan_without_infer assert_eq!( input.text, match input.object_type.unwrap() { - ObjectType::Episode => "Episode summary: An experience", + 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:?}"), } @@ -166,9 +175,124 @@ async fn scene_words_round_trip_through_remember_and_authored_plan_without_infer } } +#[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, _) = memory().await; + let (memory, inputs) = memory().await; let present = MemoryId::from_u128(8201); let involved = MemoryId::from_u128(8202); let existing = MemoryId::from_u128(8203); @@ -188,9 +312,19 @@ async fn scene_override_preserves_participants_involvement_threads_interval_and_ let mut scene = words_scene(); scene.setting.key = Some("room/42".to_owned()); scene.participants.extend([ - SceneParticipant::Key(present), - SceneParticipant::Key(existing), - SceneParticipant::Key(present), + 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.unwrap(), "Explicitly timed statement"); @@ -202,9 +336,10 @@ async fn scene_override_preserves_participants_involvement_threads_interval_and_ overridden .custom_values .insert("input-only".to_owned(), "must not merge".to_owned()); - overridden - .participants - .push(SceneParticipant::Key(MemoryId::from_u128(9999))); + 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) @@ -222,6 +357,13 @@ async fn scene_override_preserves_participants_involvement_threads_interval_and_ .remember(input, RememberOptions::default()) .await .unwrap(); + assert!(inputs + .lock() + .unwrap() + .iter() + .any(|input| input.object_id == Some(MemoryId::from_u128(8220)) + && input.text.ends_with("\nWith: Mira\nWith: the host"))); + let saved = memory .memory_composition .graph_store @@ -383,9 +525,36 @@ async fn writes_reject_missing_scene_time_activity_and_unknown_participant_keys( .unwrap_err(); assert_issue(error, CandidateValidationIssue::SceneActivityOnWrite); } + let mut empty_participant = Scene::at(time()); + empty_participant + .participants + .push(SceneParticipant::default()); + 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(unknown)); + scene.participants.push(SceneParticipant { + key: Some(unknown), + ..Default::default() + }); let error = memory .commit( episode_plan(episode_draft(8401, Some(scene))), diff --git a/src/policy/embedding_surface.rs b/src/policy/embedding_surface.rs index 57a120ce..db5f6ea6 100644 --- a/src/policy/embedding_surface.rs +++ b/src/policy/embedding_surface.rs @@ -17,12 +17,34 @@ 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", words)); + let participants = episode.scene.participants.iter().flat_map(|participant| { + [ + participant.name.as_deref(), + participant.description.as_deref(), + ] + .into_iter() + .flatten() + .map(|words| ("With", words)) + }); + for (label, words) in setting.into_iter().chain(participants) { + if !words.trim().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, ) } @@ -235,7 +257,10 @@ mod tests { key: Some("conversation-1".to_owned()), words: None, }, - participants: vec![crate::domain::SceneParticipant::Key(id(1))], + participants: vec![crate::domain::SceneParticipant { + key: Some(id(1)), + ..Default::default() + }], ..crate::domain::Scene::at(timestamp()) }, ended_at: Some(timestamp()), diff --git a/src/ports/retrieval_stats.rs b/src/ports/retrieval_stats.rs index 5bf3ac48..e4565bf6 100644 --- a/src/ports/retrieval_stats.rs +++ b/src/ports/retrieval_stats.rs @@ -675,7 +675,10 @@ mod tests { object_type: ObjectType::Episode, modality: Modality::Chat, scene: crate::domain::Scene { - participants: vec![crate::domain::SceneParticipant::Key(entity_id)], + participants: vec![crate::domain::SceneParticipant { + key: Some(entity_id), + ..Default::default() + }], ..crate::domain::Scene::at(timestamp()) }, ended_at: None, diff --git a/src/test_support.rs b/src/test_support.rs index 926b2742..f79c146b 100644 --- a/src/test_support.rs +++ b/src/test_support.rs @@ -415,8 +415,14 @@ pub(crate) fn simple_episode() -> Episode { words: None, }, participants: vec![ - crate::domain::SceneParticipant::Key(fixture_id(1)), - crate::domain::SceneParticipant::Key(fixture_id(2)), + 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")) }, diff --git a/src/usecases/write_planning.rs b/src/usecases/write_planning.rs index 62f0020c..b517c858 100644 --- a/src/usecases/write_planning.rs +++ b/src/usecases/write_planning.rs @@ -543,11 +543,10 @@ mod construction_tests { 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(memory_id( - "550e8400-e29b-41d4-a716-446655443003", - ))); + 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)) @@ -1508,6 +1507,9 @@ fn candidate_issue_from_domain_error(error: DomainValidationError) -> CandidateV DomainValidationError::SceneActivityOnWrite => { CandidateValidationIssue::SceneActivityOnWrite } + DomainValidationError::EmptySceneParticipant => { + CandidateValidationIssue::EmptySceneParticipant + } DomainValidationError::MissingEpisodeReference => { CandidateValidationIssue::MissingEpisodeReference } diff --git a/tests/retrieval_guardrails_tests.rs b/tests/retrieval_guardrails_tests.rs index 2c8c9223..5f37bf89 100644 --- a/tests/retrieval_guardrails_tests.rs +++ b/tests/retrieval_guardrails_tests.rs @@ -368,7 +368,10 @@ fn episode(id: MemoryId, summary: &str, participants: &[MemoryId]) -> EpisodeDra scene.participants = participants .iter() .copied() - .map(character_memory::SceneParticipant::Key) + .map(|key| character_memory::SceneParticipant { + key: Some(key), + ..Default::default() + }) .collect(); draft.scene = Some(scene); draft.ended_at = Some(timestamp()); diff --git a/tests/write_planning_tests.rs b/tests/write_planning_tests.rs index b813c44c..0a9f0d1c 100644 --- a/tests/write_planning_tests.rs +++ b/tests/write_planning_tests.rs @@ -214,10 +214,8 @@ async fn remember_wrapper_commits_equivalent_graph_state() { .expect("caller-supplied episode should be in canonical graph state"); assert_eq!(episode.summary, "Equivalent graph state observation"); assert_eq!( - episode.scene.participants, - vec![character_memory::SceneParticipant::Name( - "A visitor".to_owned() - )] + episode.scene, + character_memory::Scene::at(fixed_timestamp()) ); let observation = wrapper_retrieval .pack @@ -386,13 +384,7 @@ fn remember_equivalence_input() -> RememberInput { let mut episode = EpisodeDraft::new("Equivalent graph state observation"); episode.id = Some(episode_id); - let mut scene = character_memory::Scene::at(timestamp); - scene - .participants - .push(character_memory::SceneParticipant::Name( - "A visitor".to_owned(), - )); - episode.scene = Some(scene); + episode.scene = Some(character_memory::Scene::at(timestamp)); episode.created_at = Some(timestamp); let mut observation = From 5ea091be539409323a98c3e06304acacaf81aafb Mon Sep 17 00:00:00 2001 From: ebigunso Date: Mon, 21 Sep 2026 06:09:03 +0900 Subject: [PATCH 3/3] =?UTF-8?q?=E2=9C=82=EF=B8=8F=20Keep=20scene=20partici?= =?UTF-8?q?pants=20meaningful=20and=20grouped?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- README.md | 2 +- docs/design/database/graph_schema_design.md | 2 +- docs/design/database/schema_cheat_sheet.md | 4 +- docs/design/database/vector_payload_design.md | 6 +- src/adapters/oxigraph/shared.rs | 1 - src/api/types/draft.rs | 2 +- src/domain.rs | 20 +-- src/domain/scene.rs | 12 +- src/domain/write_validation.rs | 4 +- src/lib.rs | 4 +- src/memory/scene_tests.rs | 138 +++++++----------- src/policy/embedding_surface.rs | 23 +-- src/usecases/write_planning.rs | 5 +- 13 files changed, 87 insertions(+), 136 deletions(-) diff --git a/README.md b/README.md index 85500a5e..0fb8e4f7 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,7 @@ 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. -The episode stores one `Scene`: the experience time, participants with any combination of an identity key, name and description, a setting key and/or words, and flat string custom values. Unspecified details stay unspecified. Words are preserved as supplied and appended with `Setting:` and `With:` labels to the episode summary in its single embedding; they create no notions or links. Without scene words, embedding text is unchanged. Keys and custom values are never embedded. Each participant must supply at least one of key, name or description. Participant keys must identify existing notions or notions in the same plan; knowing a key does not replace the supplied words. A supplied `EpisodeDraft.scene` overrides the input scene as a whole. Without a scene, `prepare` records the current time once, meaning the experience is happening now; historical writes must provide their time. An authored episode candidate must include a scene, and writes reject `Scene.activity`. Observation time defaults to scene time; an explicit observation time, speaker, episode end time, involved notions and multiple thread memberships remain independent. Corrections preserve source scenes and may guard the original context with `original_setting_key`. +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. diff --git a/docs/design/database/graph_schema_design.md b/docs/design/database/graph_schema_design.md index 410ddf02..821b58bd 100644 --- a/docs/design/database/graph_schema_design.md +++ b/docs/design/database/graph_schema_design.md @@ -25,7 +25,7 @@ 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. Each participant supplies at least one of key, name or description; an all-absent participant 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 name and description follows a `With:` label 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. Writes reject an activity. +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. diff --git a/docs/design/database/schema_cheat_sheet.md b/docs/design/database/schema_cheat_sheet.md index cd952d43..047ace93 100644 --- a/docs/design/database/schema_cheat_sheet.md +++ b/docs/design/database/schema_cheat_sheet.md @@ -57,7 +57,7 @@ Predicate names in these tables are suffixes under `urn:cmem:vocab:`. | `createdAt`, `updatedAt` | Timestamps on the types that declare them | | `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; at least one supplied | +| `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 | @@ -94,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 9d9ddaa2..fdafe93f 100644 --- a/docs/design/database/vector_payload_design.md +++ b/docs/design/database/vector_payload_design.md @@ -18,7 +18,7 @@ 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 @@ -31,7 +31,7 @@ The [payload writer (`payload.rs:164`)](../../../src/adapters/qdrant/payload.rs# 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 (`embedding_surface.rs:19`)](../../../src/policy/embedding_surface.rs#L19) appends nonblank setting words on a new `Setting:` line, followed by a `With:` line for each participant name and description 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 [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. @@ -43,7 +43,7 @@ Embedding text uses natural language, for example `User preference: Prefer deter ## Indexing And Currency -Indexing consults incoming graph supersession evidence before embedding interpreted memories. 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. +Indexing consults incoming graph supersession evidence before embedding interpreted memories. 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/shared.rs b/src/adapters/oxigraph/shared.rs index bb429933..44564713 100644 --- a/src/adapters/oxigraph/shared.rs +++ b/src/adapters/oxigraph/shared.rs @@ -308,7 +308,6 @@ pub(super) fn memory_object_from_rdf( .map_err(|error| { rdf_parse_error(subject, super::vocabulary::SCENE_CUSTOM_VALUES, error) })?, - activity: None, }, ended_at: optional_timestamp_literal(values, super::vocabulary::ENDED_AT)?, summary: values.literal(subject, super::vocabulary::SUMMARY)?, diff --git a/src/api/types/draft.rs b/src/api/types/draft.rs index 48a851c9..7ae8eec7 100644 --- a/src/api/types/draft.rs +++ b/src/api/types/draft.rs @@ -150,7 +150,7 @@ impl EpisodeDraft { id: defaults.id(self.id), object_type: ObjectType::Episode, modality: self.modality, - scene: self.scene.ok_or(DomainValidationError::MissingSceneTime)?, + scene: self.scene.ok_or(DomainValidationError::MissingScene)?, ended_at: self.ended_at, summary: self.summary, raw_ref: self.raw_ref, diff --git a/src/domain.rs b/src/domain.rs index b84b099d..0e059e9d 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -12,7 +12,7 @@ pub use object_ref::MemoryObjectRef; pub use retrieval::{ GraphExpansionBoundedFailureTrace, GraphExpansionBoundedReason, GraphFailureMode, }; -pub use scene::{Scene, SceneActivity, SceneParticipant, SceneSetting}; +pub use scene::{Scene, SceneParticipant, SceneSetting}; pub use write_validation::{ CandidateProvenanceIssue, CandidateReferenceRole, CandidateScoreField, CandidateSourceSpanIssue, CandidateTimestampField, CandidateValidation, @@ -230,13 +230,10 @@ pub enum DomainValidationError { #[error("episode summary must not be empty")] EmptyEpisodeSummary, - #[error("an episode scene must state its time")] - MissingSceneTime, + #[error("a caller-built episode must state its scene")] + MissingScene, - #[error("an activity cannot be supplied on a write")] - SceneActivityOnWrite, - - #[error("a scene participant must have a key, name or description")] + #[error("a scene participant must have a key or nonblank name or description")] EmptySceneParticipant, #[error("observation episode_id must reference an episode")] @@ -312,13 +309,12 @@ impl Episode { if self.summary.trim().is_empty() { return Err(DomainValidationError::EmptyEpisodeSummary); } - if self.scene.activity.is_some() { - return Err(DomainValidationError::SceneActivityOnWrite); - } if self.scene.participants.iter().any(|participant| { participant.key.is_none() - && participant.name.is_none() - && participant.description.is_none() + && [&participant.name, &participant.description] + .into_iter() + .flatten() + .all(|words| words.trim().is_empty()) }) { return Err(DomainValidationError::EmptySceneParticipant); } diff --git a/src/domain/scene.rs b/src/domain/scene.rs index 8864d0c6..e54f9ade 100644 --- a/src/domain/scene.rs +++ b/src/domain/scene.rs @@ -12,8 +12,6 @@ pub struct Scene { pub participants: Vec, pub setting: SceneSetting, pub custom_values: BTreeMap, - /// Thread or open-loop context; writes reject it. - pub activity: Option, } impl Scene { @@ -23,7 +21,6 @@ impl Scene { participants: Vec::new(), setting: SceneSetting::default(), custom_values: BTreeMap::new(), - activity: None, } } @@ -38,7 +35,7 @@ impl Scene { } } -/// Perceived words and a known identity can coexist; writes require at least one field. +/// 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, @@ -52,10 +49,3 @@ pub struct SceneSetting { pub key: Option, pub words: Option, } - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(tag = "kind", content = "id", rename_all = "snake_case")] -pub enum SceneActivity { - Thread(MemoryId), - OpenLoop(MemoryId), -} diff --git a/src/domain/write_validation.rs b/src/domain/write_validation.rs index 93ac4c78..0f12c77f 100644 --- a/src/domain/write_validation.rs +++ b/src/domain/write_validation.rs @@ -72,9 +72,7 @@ pub enum CandidateValidationIssue { }, #[error("episode summary must not be empty")] EmptyEpisodeSummary, - #[error("an activity cannot be supplied on a write")] - SceneActivityOnWrite, - #[error("a scene participant must have a key, name or description")] + #[error("a scene participant must have a key or nonblank name or description")] EmptySceneParticipant, #[error("observation episode_id must reference an episode")] MissingEpisodeReference, diff --git a/src/lib.rs b/src/lib.rs index 45abcec2..43a8fb29 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -57,8 +57,8 @@ pub use crate::domain::{ GraphExpansionBoundedReason, GraphFailureMode, LifecycleDtoValidationError, MemoryCandidateKind, MemoryId, MemoryLink, MemoryLinkEndpoint, MemoryObject, MemoryObjectRef, MemoryThread, Modality, ObjectType, Observation, RelationType, RetentionState, Scene, - SceneActivity, SceneParticipant, SceneSetting, SourceReferenceKind, ThreadStatus, - VectorSurface, CURRENT_SCHEMA_VERSION, DEFAULT_SCHEMA_VERSION, EPISODIC_MEMORY_SCHEMA_VERSION, + 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/scene_tests.rs b/src/memory/scene_tests.rs index 050698ec..95abec12 100644 --- a/src/memory/scene_tests.rs +++ b/src/memory/scene_tests.rs @@ -293,6 +293,8 @@ async fn setting_words_recall_the_episode_when_the_summary_does_not_name_the_pla #[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); @@ -327,8 +329,8 @@ async fn scene_override_preserves_participants_involvement_threads_interval_and_ }, ]); let episode = episode_draft(8220, Some(scene.clone())); - let mut observation = ObservationDraft::new(episode.id.unwrap(), "Explicitly timed statement"); - observation.id = Some(MemoryId::from_u128(8221)); + 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); @@ -361,57 +363,37 @@ async fn scene_override_preserves_participants_involvement_threads_interval_and_ .lock() .unwrap() .iter() - .any(|input| input.object_id == Some(MemoryId::from_u128(8220)) - && input.text.ends_with("\nWith: Mira\nWith: the host"))); + .any(|input| input.object_id == Some(episode_id) + && input.text.ends_with("\nWith: Mira, the host"))); - let saved = memory - .memory_composition - .graph_store - .query_objects(&GraphObjectQuery::by_ids(vec![ - MemoryId::from_u128(8220), - MemoryId::from_u128(8221), - ])) + let graph = &memory.memory_composition.graph_store; + let saved = graph + .query_objects(&GraphObjectQuery::by_ids(vec![episode_id, observation_id])) .await .unwrap(); - assert_eq!(saved.len(), 2); - for object in saved { - match object { - MemoryObject::Episode(episode) => { - assert_eq!(episode.scene, scene); - assert_eq!(episode.ended_at, Some(ended_at)); - } - MemoryObject::Observation(saved) => { - assert_eq!(saved.observed_at, observation.observed_at); - assert_eq!(saved.speaker_entity_id, Some(involved)); - } - _ => panic!("unexpected object"), - } - } - let links = memory - .memory_composition - .graph_store + 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(); - let actual = links - .iter() - .map(|link| (link.from_id, link.relation, link.to_id)) - .collect::>(); - assert_eq!(actual.len(), 5); - assert!(actual.contains(&(MemoryId::from_u128(8220), RelationType::Involves, involved))); - for participant in [present, existing] { - assert!(actual.contains(&( - MemoryId::from_u128(8221), - RelationType::Mentions, - participant - ))); - } - for thread in thread_ids { - assert!(actual.contains(&( - MemoryId::from_u128(8221), - RelationType::PartOfThread, - thread - ))); + 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 @@ -479,11 +461,11 @@ async fn omitted_scene_time_is_fixed_at_prepare_and_replayed_without_using_creat } #[tokio::test] -async fn writes_reject_missing_scene_time_activity_and_unknown_participant_keys() { +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::MissingSceneTime) + Err(DomainValidationError::MissingScene) ); let error = memory .commit( @@ -498,57 +480,45 @@ async fn writes_reject_missing_scene_time_activity_and_unknown_participant_keys( field: CandidateTimestampField::SceneTime, }, ); - for activity in [ - SceneActivity::Thread(MemoryId::from_u128(8411)), - SceneActivity::OpenLoop(MemoryId::from_u128(8412)), + 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 scene = Scene::at(time()); - scene.activity = Some(activity); + let mut empty_participant = Scene::at(time()); + empty_participant.participants.push(participant); assert_eq!( - episode_draft(8401, Some(scene.clone())).into_domain(), - Err(DomainValidationError::SceneActivityOnWrite) + episode_draft(8401, Some(empty_participant.clone())).into_domain(), + Err(DomainValidationError::EmptySceneParticipant) ); let error = memory .remember( - RememberInput::new("An experience").with_scene(scene.clone()), + RememberInput::new("An experience").with_scene(empty_participant.clone()), RememberOptions::default(), ) .await .unwrap_err(); - assert_issue(error, CandidateValidationIssue::SceneActivityOnWrite); + assert_issue(error, CandidateValidationIssue::EmptySceneParticipant); let error = memory .commit( - episode_plan(episode_draft(8401, Some(scene))), + episode_plan(episode_draft(8401, Some(empty_participant))), CommitOptions::default(), ) .await .unwrap_err(); - assert_issue(error, CandidateValidationIssue::SceneActivityOnWrite); + assert_issue(error, CandidateValidationIssue::EmptySceneParticipant); } - let mut empty_participant = Scene::at(time()); - empty_participant - .participants - .push(SceneParticipant::default()); - 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 { diff --git a/src/policy/embedding_surface.rs b/src/policy/embedding_surface.rs index db5f6ea6..570cda40 100644 --- a/src/policy/embedding_surface.rs +++ b/src/policy/embedding_surface.rs @@ -23,20 +23,21 @@ pub(crate) fn episode_vector_record(episode: &Episode) -> VectorRecord { .setting .words .as_deref() - .map(|words| ("Setting", words)); - let participants = episode.scene.participants.iter().flat_map(|participant| { - [ - participant.name.as_deref(), - participant.description.as_deref(), - ] - .into_iter() - .flatten() - .map(|words| ("With", words)) + .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.trim().is_empty() { + if !words.is_empty() { text.push('\n'); - text.push_str(&prefixed_text(label, words)); + text.push_str(&prefixed_text(label, &words)); } } VectorRecord::new( diff --git a/src/usecases/write_planning.rs b/src/usecases/write_planning.rs index b517c858..283d2f79 100644 --- a/src/usecases/write_planning.rs +++ b/src/usecases/write_planning.rs @@ -1501,12 +1501,9 @@ fn candidate_issue_from_domain_error(error: DomainValidationError) -> CandidateV CandidateValidationIssue::AuthoredBeliefAboutLink } DomainValidationError::EmptyEpisodeSummary => CandidateValidationIssue::EmptyEpisodeSummary, - DomainValidationError::MissingSceneTime => CandidateValidationIssue::MissingTimestamp { + DomainValidationError::MissingScene => CandidateValidationIssue::MissingTimestamp { field: CandidateTimestampField::SceneTime, }, - DomainValidationError::SceneActivityOnWrite => { - CandidateValidationIssue::SceneActivityOnWrite - } DomainValidationError::EmptySceneParticipant => { CandidateValidationIssue::EmptySceneParticipant }