From 0e3ab9ffc1c09408966ffbf567993f971567fe4e Mon Sep 17 00:00:00 2001 From: ebigunso Date: Tue, 22 Sep 2026 23:39:50 +0900 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=94=8E=20Trace=20each=20shared=20scen?= =?UTF-8?q?e=20search=20match?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 12 ++- docs/design/database/schema_cheat_sheet.md | 4 +- docs/design/database/vector_payload_design.md | 12 ++- src/api/types.rs | 8 +- src/api/types/retrieval.rs | 12 +++ src/lib.rs | 2 +- src/memory/retrieval_scene_tests.rs | 83 +++++++++++++++++++ src/usecases/retrieve.rs | 1 + src/usecases/retrieve/scene.rs | 29 ++++++- 9 files changed, 152 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 91170b3e..1fafd1d8 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ Character Memory is a Rust library for giving LLM assistants memory that shapes behavior over time. +A recognized key or name brings back where things stand with that person or in that place. A description such as "a man with a deep voice" or "at my desk" brings recent occasions like this to mind, without deciding who the person is or treating the description as a place's identity. + It is built for persistent AI assistants and companions that should remember past interactions, recognize recurring entities, and maintain character continuity across sessions. Instead of treating memory as a chat log or simple vector search, Character Memory stores episodic memories with temporal and relational structure. @@ -48,7 +50,9 @@ Retrieval is graph-authoritative and hybrid: - **Entity-based retrieval:** includes memories involving the same people, projects, places, or concepts - **Continuity retrieval:** returns a structured `ContinuityContextPack` rather than a generic ranked list -`RetrievalContext` carries a `Scene` and an optional topic; its time defaults to now. Participant keys and exact names cue notions. The topic searches content surfaces; setting words search episode setting surfaces, and all participants' names and descriptions join into one search of episode participant surfaces. Scene words are indexed separately from the episode summary, with up to three embeddings per episode in the existing write batch. A setting key or custom value recalls current beliefs grounded in experiences with that context; beliefs with several sources can return in any of their recorded places or named contexts. The result returns the present scene, its reference resolutions, and each admitted memory's recorded source scenes even without a trace. Forgotten sources are explicit; forgotten scenes follow `include_suppressed`. Scene differences never exclude a memory or determine who may hear it. +`RetrievalContext` carries a `Scene` and an optional topic; its time defaults to now. The topic recalls what happened or was learned, while descriptions recall occasions with similar surroundings or people. A setting key or custom value recalls current beliefs grounded in experiences with that context; beliefs with several sources can return in any of their recorded places or named contexts. The result returns the present scene, its reference resolutions, and each admitted memory's recorded source scenes even without a trace. Forgotten sources are explicit; forgotten scenes follow `include_suppressed`. Scene differences never exclude a memory or determine who may hear it. + +An episode has a content search surface for its summary and up to two separate scene surfaces: the setting words and the participants' words. Scene words are not appended to the content text. All participants' names and descriptions are searched together, as they were recorded, so any similarity score for them belongs to that shared search. See the [vector design](docs/design/database/vector_payload_design.md#indexed-objects-and-surfaces) for the stored forms. For each recognized participant, `last_interactions` says when the character last met them and how much time has passed. If a name could mean several participants, each has its own answer. No eligible encounter at or before the scene time means never met. These facts remain available even when no memories fit the requested amount. Forgotten encounters count only when `include_suppressed` is enabled. @@ -56,12 +60,16 @@ Use `.with_activity(ActivityRef::Thread(thread_id))` or `.with_activity(Activity Thread members are taken most recent first. -Candidate, graph root and section selection serve each cue kind's floor from its own order. Keyed and named routes take their kind's floor before reminders. Descriptions contribute their most recent recallable occasions at or before the scene time, as many as the kind's floor and at least one. Spare room follows score order at candidate and section caps; only root selection shares spare turns among kinds. A reminder can bring memories resting on its occasion. A person or thread reached only through that reminder is a leaf: it can be included, but recall does not continue through it. When a topic, key or name also reaches the occasion, linked history inherits that route’s score; the occasion itself keeps its best matching score. The pack keeps its final score order. +A description brings the most recent few recallable occasions among its fetched matches, at or before the scene time: as many as its cue kind's reserved amount, called its floor, and at least the latest one. Its other matches contribute nothing; the trace counts those left out by this limit. What was brought then competes by relevance. Keys and names take their kind's reserved room first, so adding a description does not displace what is already known merely to fill that reservation. Only graph-root selection shares spare room by turns among cue kinds; candidate and section selection fill spare room by score. + +An occasion recalled by a description can bring what was observed and concluded there. It does not by itself bring the whole history of a person or thread mentioned there. A topic, key or name can still recall that history on its own strength. Each cue kind has a floor at the merged candidate cap, the graph root cap and each section cap. The **provisional** defaults are one slot each for participant, place, activity and topic. Floors apply per kind, not per person or place: five people share one participant floor. `context.cue_floors` is a calibration knob for measured defaults; applications are not expected to set it. Slots are reserved in successive rounds in that order. Unused room returns to the common pool, and selected memories keep their original order. A zero floor reserves nothing for that kind; a zero cap admits nothing. A single kind also receives its floor from its own queue, then fills remaining room in final-ranked order. Topic-only retrieval keeps its selection. Calibration uses the public companion evaluation repository, a development aid outside the core library. With tracing enabled, `floor_admissions` identifies the object, stage and cue kind when a reserved or spare turn admitted an object outside that stage's original capped prefix. Earlier-stage evidence remains even when a later stage omits the object. +The trace's `scene_cue_searches` reports the best similarity found for the setting and for the joined participant words, with the references sharing each search; a search with no matches has no score. `scene_cue_omitted_counts` counts recallable matches left out by each description's occasion limit. There is no minimum similarity: whether a description is too weak to remind the character of anything remains a question for measurement, not a decision made by this score. + A participant present in most experiences brings fewer past encounters to mind, while retaining the latest eligible one within the requested limits. A participant recognized by key or name brings only encounters at or before the scene time. Several remarks or participants in one encounter do not make it count more than once. Familiarity limits recalled encounters, not beliefs about that participant. This applies to every notion, including whichever one the application regards as the character. Meeting a participant recognized by key or name brings current beliefs about them, even if those beliefs were learned elsewhere. More salient beliefs come first, with newer beliefs breaking ties. Replaced beliefs appear only when `include_superseded` is enabled, and current beliefs get priority. When several participants bring beliefs to mind, they take turns in scene order; a shared belief counts for each participant. The scene time limits recalled encounters, not these beliefs. See the [retrieval design](docs/design/database/graph_schema_design.md#state-and-last-interaction) for selection details. diff --git a/docs/design/database/schema_cheat_sheet.md b/docs/design/database/schema_cheat_sheet.md index 0e04823f..41eb9c5d 100644 --- a/docs/design/database/schema_cheat_sheet.md +++ b/docs/design/database/schema_cheat_sheet.md @@ -34,7 +34,9 @@ These are the five emitted payload fields. Both adapters index `object_id`, `obj | `entity` | — | Graph notion identity | 0 | | `memory_link` | — | Graph relationship | 0 | -The `query` surface represents search input. It is not emitted for a stored memory object. A topic searches content surfaces; scene words search their respective episode surfaces. Exact names also resolve through graph assertions. +The `query` surface represents search input. It is not emitted for a stored memory object. A topic searches content surfaces; scene words search their respective episode surfaces and are not part of the summary text. Exact names also resolve through graph assertions. + +Descriptions contribute their most recent recallable occasions from the fetched matches, as many as the kind's floor and at least one; the rest of their matches contribute nothing. Trace-only `scene_cue_searches` lists a best score and the references sharing each search, once for the setting and once for all participant words; no returned match means `null`. `scene_cue_omitted_counts` records matches excluded by the occasion limit. These are retrieval diagnostics, not stored vector payload fields. See [description recall and trace](vector_payload_design.md#description-recall-and-trace) for score, admission and identity boundaries. ## Graph Resources diff --git a/docs/design/database/vector_payload_design.md b/docs/design/database/vector_payload_design.md index 2713f475..a0fed907 100644 --- a/docs/design/database/vector_payload_design.md +++ b/docs/design/database/vector_payload_design.md @@ -37,7 +37,17 @@ The [plural builder](../../../src/policy/embedding_surface.rs) emits the unchang The closed surface tokens are `summary`, `text`, `derived_text`, `scene_setting`, `scene_participants` 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. -Topic searches use only the three content surfaces. Setting words search `scene_setting`; participant words are joined with the same composition as the write surface and searched once on `scene_participants`. Query embeddings may be reused for equal text, but results are cached by text and surface scope. Nearest search, zero-norm scroll and completeness counts share that scope in both adapters. Existing indexes must be rebuilt to add scene surfaces; no migration is provided. +Topic searches use only the three content surfaces. Setting words search `scene_setting`; participant words are joined with the same composition as the write surface and searched once on `scene_participants`. Query embeddings may be reused for equal text, but results are cached by text and surface scope. Nearest search, zero-norm scroll and completeness counts share that scope in both adapters. An index built without scene surfaces returns no description matches until rebuilt; no migration is provided. + +## Description Recall And Trace + +A key or a known name brings current beliefs about the person or context it identifies. A description brings recent occasions with similar recorded words, without establishing an identity. Each description search contributes only its most recent recallable episodes at or before the retrieval scene time, as many as its cue kind's floor and never fewer than one. Selection uses the fetched search pool, including the fetched tie at the cutoff; an open search boundary limits this claim to the fetched matches. Other matches do not enter the candidate set through that description. The trace's `scene_cue_omitted_counts` records the number of recallable matches excluded by this occasion limit, once for the setting and once for the joined participants. + +The contributed occasions compete by their similarity scores within the existing relevance ranking. Keys, names and current state take their kind's floor before description matches. Spare room follows score order when candidates and context-pack sections are selected; only graph-root selection shares spare room by turns among kinds. An occasion recalled through words can bring its observations and conclusions, but does not by itself open the other occasions of a person or thread reached from it. When the topic or a known reference also reaches it, linked history inherits that source's score; a strong description match cannot lend its score to a weak topic match. + +With tracing enabled, [scene recall (`scene.rs:30`)](../../../src/usecases/retrieve/scene.rs#L30) emits one `scene_cue_searches` entry for each scene search. Each [entry (`retrieval.rs:558`)](../../../src/api/types/retrieval.rs#L558) lists the `SceneReference` values sharing that search and its `best_score`: the highest fetched scene-surface score before graph eligibility and occasion selection, or `null` if no scene-surface match was returned. The setting entry refers to `SettingWords`; the participants entry lists all nonblank names and descriptions that contributed to the single joined search, including names whose identity lookup was unknown. Its score is shared, never a separate score for each person. Keys do not contribute words. An empty scene produces no scene-search entries, and tracing disabled produces no trace. + +This score describes the search, not whether the best match was admitted or whether the description identifies someone. No minimum similarity is applied. These scores support measuring whether a useful boundary for descriptions that remind of nothing exists. That measurement belongs in the public companion evaluation repository, `CharacterMemoryEvals`, whose evaluation tooling is a development aid outside the core library. A decision to apply such a boundary requires that evidence. ## Belief Content And Graph Authority diff --git a/src/api/types.rs b/src/api/types.rs index 6c299648..de18b345 100644 --- a/src/api/types.rs +++ b/src/api/types.rs @@ -26,10 +26,10 @@ pub use retrieval::{ LifecycleFilterDecision, LifecycleFilterReason, LifecycleOmissionSummary, MemoryScenes, RetrievalCandidateLimits, RetrievalContext, RetrievalCueFloors, RetrievalGraphLimits, RetrievalLifecyclePolicy, RetrievalRationale, RetrievalTelemetry, RetrievalTrace, - RetrieveOutcome, SceneReference, SceneReferenceResolution, SceneReferenceResult, - SectionAssignment, SectionAssignmentReason, SectionPressureSummary, SectionScoreComponents, - SelectivityCountScope, SelectivityDecision, SelectivityTelemetry, SelectivityTrace, - SourceScene, SourceSceneUnavailableReason, StaleCandidateOmission, + RetrieveOutcome, SceneCueSearchTrace, SceneReference, SceneReferenceResolution, + SceneReferenceResult, SectionAssignment, SectionAssignmentReason, SectionPressureSummary, + SectionScoreComponents, SelectivityCountScope, SelectivityDecision, SelectivityTelemetry, + SelectivityTrace, SourceScene, SourceSceneUnavailableReason, StaleCandidateOmission, StaleCandidateOmissionSummary, StaleCandidateReason, VectorCandidateTrace, VectorRecallCompleteness, }; diff --git a/src/api/types/retrieval.rs b/src/api/types/retrieval.rs index 8ae5896d..8d339ef1 100644 --- a/src/api/types/retrieval.rs +++ b/src/api/types/retrieval.rs @@ -520,6 +520,7 @@ pub struct LifecycleOmissionSummary { #[non_exhaustive] pub struct RetrievalTrace { pub vector_candidates: Vec, + pub scene_cue_searches: Vec, /// Recallable scene matches left out by each description search's occasion limit. /// Participant words share one search and therefore one count. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] @@ -538,6 +539,7 @@ impl RetrievalTrace { pub fn empty() -> Self { Self { vector_candidates: Vec::new(), + scene_cue_searches: Vec::new(), scene_cue_omitted_counts: BTreeMap::new(), floor_admissions: Vec::new(), graph_relations: Vec::new(), @@ -551,6 +553,16 @@ impl RetrievalTrace { } } +/// One setting search or one shared search of all participants' names and descriptions. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct SceneCueSearchTrace { + /// Scene references sharing this search; the score is not per person or reference. + pub references: Vec, + /// Best fetched scene-surface score, before occasion selection and graph eligibility. + /// `None` means the search returned no scene-surface matches. + pub best_score: Option, +} + impl Default for RetrievalTrace { fn default() -> Self { Self::empty() diff --git a/src/lib.rs b/src/lib.rs index be3996bb..2f9fb182 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -37,7 +37,7 @@ pub use crate::api::types::{ RememberOptions, RememberOutcome, RememberWritePlan, RepairMarker, ReplacementDerivedMemoryDraft, RetrievalCandidateLimits, RetrievalContext, RetrievalGraphLimits, RetrievalLifecyclePolicy, RetrievalRationale, RetrievalTelemetry, - RetrievalTrace, RetrieveOutcome, SceneReference, SceneReferenceResolution, + RetrievalTrace, RetrieveOutcome, SceneCueSearchTrace, SceneReference, SceneReferenceResolution, SceneReferenceResult, SectionAssignment, SectionAssignmentReason, SectionPressureSummary, SectionScoreComponents, SelectivityCountScope, SelectivityDecision, SelectivityTelemetry, SelectivityTrace, SourceObjectCorrectionTarget, SourceProvenance, SourceProvenanceReference, diff --git a/src/memory/retrieval_scene_tests.rs b/src/memory/retrieval_scene_tests.rs index 45de31ba..e9aeb997 100644 --- a/src/memory/retrieval_scene_tests.rs +++ b/src/memory/retrieval_scene_tests.rs @@ -1560,6 +1560,13 @@ async fn descriptions_and_setting_words_recall_content_once_and_merge_with_topic assert_eq!(*queries.lock().unwrap(), ["astronomer\nastronomer"]); assert_eq!(description_only.pack.relevant_episodes[0].id, episode_id); assert!(description_only.pack.derived_memories.is_empty()); + assert_eq!( + description_only.trace.as_ref().unwrap().scene_cue_searches[0].references, + [ + SceneReference::ParticipantDescription { index: 0 }, + SceneReference::ParticipantDescription { index: 1 }, + ] + ); assert_eq!( description_only.trace.as_ref().unwrap().vector_candidates[0].surface, VectorSurface::SceneParticipants @@ -1627,6 +1634,82 @@ async fn descriptions_and_setting_words_recall_content_once_and_merge_with_topic )); } +#[tokio::test] +async fn description_search_scores_are_shared_and_precede_occasion_selection() { + let (memory, _) = scene_memory().await; + let mut present = scene(); + present.setting.words = Some("observatory".to_owned()); + present.participants = vec![ + SceneParticipant { + name: Some("astronomer".to_owned()), + ..Default::default() + }, + SceneParticipant { + description: Some("navigator".to_owned()), + ..Default::default() + }, + ]; + let mut past = present.clone(); + past.time -= chrono::Duration::days(1); + write_episode(&memory, 9000, past).await; + let context = RetrievalContext::default() + .with_scene(present.clone()) + .with_trace(); + let identical = memory.retrieve(context.clone()).await.unwrap(); + let searches = &identical.trace.as_ref().unwrap().scene_cue_searches; + assert_eq!(searches.len(), 2); + assert_eq!(searches[0].references, [SceneReference::SettingWords]); + assert_eq!( + searches[1].references, + [ + SceneReference::ParticipantName { index: 0 }, + SceneReference::ParticipantDescription { index: 1 }, + ] + ); + assert!(searches + .iter() + .all(|search| (search.best_score.unwrap() - 1.0).abs() < 1e-6)); + + let mut reworded = context.clone(); + reworded.scene.setting.words = Some("astronomer observatory".to_owned()); + reworded.scene.participants[0].name = Some("stargazer".to_owned()); + let reworded = memory.retrieve(reworded).await.unwrap(); + let searches = &reworded.trace.unwrap().scene_cue_searches; + assert!((searches[0].best_score.unwrap() - 0.70886356).abs() < 1e-6); + assert!((searches[1].best_score.unwrap() - 0.09950372).abs() < 1e-6); + + // Oppose IDs to time; the selected latest occasion is not the best match. + let mut latest = present; + latest.setting.words = Some("planetarium".to_owned()); + latest.participants[0].name = Some("stargazer".to_owned()); + write_episode(&memory, 8000, latest).await; + let recent = memory.retrieve(context.clone()).await.unwrap(); + let trace = recent.trace.as_ref().unwrap(); + assert_eq!(trace.vector_candidates.len(), 1); + assert_eq!( + trace.vector_candidates[0].object.id, + MemoryId::from_u128(8000) + ); + assert!((trace.vector_candidates[0].score - 0.09950372).abs() < 1e-6); + assert!(trace + .scene_cue_searches + .iter() + .all(|search| (search.best_score.unwrap() - 1.0).abs() < 1e-6)); + let mut untraced_context = context.clone(); + untraced_context.include_trace = false; + let untraced = memory.retrieve(untraced_context).await.unwrap(); + let mut expected = recent; + expected.trace = None; + assert_eq!(untraced, expected); + + let (without_surfaces, _) = scene_memory().await; + write_episode(&without_surfaces, 7000, scene()).await; + let empty = without_surfaces.retrieve(context).await.unwrap(); + let searches = empty.trace.unwrap().scene_cue_searches; + assert_eq!(searches.len(), 2); + assert!(searches.iter().all(|search| search.best_score.is_none())); +} + #[tokio::test] async fn time_only_scene_is_echoed_without_embedding_or_completeness_claim() { let (memory, queries) = scene_memory().await; diff --git a/src/usecases/retrieve.rs b/src/usecases/retrieve.rs index ea223b7a..a37feb3b 100644 --- a/src/usecases/retrieve.rs +++ b/src/usecases/retrieve.rs @@ -409,6 +409,7 @@ where }; let trace = trace_mode.is_enabled().then(|| RetrievalTrace { scene_cue_omitted_counts: cues.scene_cue_omitted_counts, + scene_cue_searches: cues.scene_cue_searches, vector_candidates: vector_candidates .iter() .enumerate() diff --git a/src/usecases/retrieve/scene.rs b/src/usecases/retrieve/scene.rs index 9ff92a41..40289c26 100644 --- a/src/usecases/retrieve/scene.rs +++ b/src/usecases/retrieve/scene.rs @@ -1,7 +1,7 @@ use super::*; use crate::api::types::{ - LastInteraction, MemoryScenes, SceneReference, SceneReferenceResolution, SceneReferenceResult, - SourceScene, SourceSceneUnavailableReason, VectorRecallCompleteness, + LastInteraction, MemoryScenes, SceneCueSearchTrace, SceneReference, SceneReferenceResolution, + SceneReferenceResult, SourceScene, SourceSceneUnavailableReason, VectorRecallCompleteness, }; use crate::domain::RetentionState; use crate::models::vector::CanonicalCandidates; @@ -18,6 +18,7 @@ pub(super) struct RecallCues { pub floor_admissions: Vec, pub topic_scores: HashMap, pub scene_cue_omitted_counts: BTreeMap, + pub scene_cue_searches: Vec, } impl RetrievePipeline<'_, G, V, E> @@ -131,6 +132,7 @@ where let mut candidates_by_kind: BTreeMap> = BTreeMap::new(); let mut topic_scores = HashMap::::new(); let mut scene_cue_omitted_counts = BTreeMap::new(); + let mut scene_cue_searches = Vec::new(); let mut dimension = 0; let mut completeness = VectorRecallCompleteness::NotRequested; let topic = nonblank(context.topic.as_deref()).map(|text| { @@ -179,6 +181,28 @@ where recall.candidates.iter().cloned().collect::>() } else { let pool = &recall.scene_pool; + if context.include_trace { + scene_cue_searches.push(SceneCueSearchTrace { + references: references + .iter() + .map(|result| &result.reference) + .chain(&descriptions) + .filter(|reference| { + matches!( + (kind, reference), + (CueKind::Place, SceneReference::SettingWords) + | ( + CueKind::Participant, + SceneReference::ParticipantName { .. } + | SceneReference::ParticipantDescription { .. } + ) + ) + }) + .cloned() + .collect(), + best_score: pool.first().map(|candidate| candidate.score), + }); + } let objects = pool .iter() .map(|candidate| { @@ -311,6 +335,7 @@ where completeness, floor_admissions, scene_cue_omitted_counts, + scene_cue_searches, }) } From 4cf97631c5f0f8268a26f66b7fc0c819ae7e783e Mon Sep 17 00:00:00 2001 From: ebigunso Date: Wed, 23 Sep 2026 00:07:34 +0900 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=94=8E=20Keep=20scene=20search=20trac?= =?UTF-8?q?es=20readable=20across=20pins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- docs/design/database/vector_payload_design.md | 2 +- src/api/types/retrieval.rs | 10 ++++++++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1fafd1d8..9d4b7b13 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ Use `.with_activity(ActivityRef::Thread(thread_id))` or `.with_activity(Activity Thread members are taken most recent first. -A description brings the most recent few recallable occasions among its fetched matches, at or before the scene time: as many as its cue kind's reserved amount, called its floor, and at least the latest one. Its other matches contribute nothing; the trace counts those left out by this limit. What was brought then competes by relevance. Keys and names take their kind's reserved room first, so adding a description does not displace what is already known merely to fill that reservation. Only graph-root selection shares spare room by turns among cue kinds; candidate and section selection fill spare room by score. +A description brings the most recent few recallable occasions among its fetched matches, at or before the scene time: as many as its cue kind's reserved amount, called its floor, and at least the latest one. Its other matches contribute nothing; the trace counts those left out by this limit. What was brought then competes by relevance. Keys and names take their kind's reserved room first, so adding a description does not displace what is already known merely to fill that reservation. Turns among cue kinds apply only where the memories to expand from are chosen; everywhere else, spare room goes by score. An occasion recalled by a description can bring what was observed and concluded there. It does not by itself bring the whole history of a person or thread mentioned there. A topic, key or name can still recall that history on its own strength. diff --git a/docs/design/database/vector_payload_design.md b/docs/design/database/vector_payload_design.md index a0fed907..8499c2eb 100644 --- a/docs/design/database/vector_payload_design.md +++ b/docs/design/database/vector_payload_design.md @@ -45,7 +45,7 @@ A key or a known name brings current beliefs about the person or context it iden The contributed occasions compete by their similarity scores within the existing relevance ranking. Keys, names and current state take their kind's floor before description matches. Spare room follows score order when candidates and context-pack sections are selected; only graph-root selection shares spare room by turns among kinds. An occasion recalled through words can bring its observations and conclusions, but does not by itself open the other occasions of a person or thread reached from it. When the topic or a known reference also reaches it, linked history inherits that source's score; a strong description match cannot lend its score to a weak topic match. -With tracing enabled, [scene recall (`scene.rs:30`)](../../../src/usecases/retrieve/scene.rs#L30) emits one `scene_cue_searches` entry for each scene search. Each [entry (`retrieval.rs:558`)](../../../src/api/types/retrieval.rs#L558) lists the `SceneReference` values sharing that search and its `best_score`: the highest fetched scene-surface score before graph eligibility and occasion selection, or `null` if no scene-surface match was returned. The setting entry refers to `SettingWords`; the participants entry lists all nonblank names and descriptions that contributed to the single joined search, including names whose identity lookup was unknown. Its score is shared, never a separate score for each person. Keys do not contribute words. An empty scene produces no scene-search entries, and tracing disabled produces no trace. +With tracing enabled, [scene search trace emission (`scene.rs:185`)](../../../src/usecases/retrieve/scene.rs#L185) emits one `scene_cue_searches` entry for each scene search. Each [entry (`retrieval.rs:559`)](../../../src/api/types/retrieval.rs#L559) lists the `SceneReference` values sharing that search and its `best_score`: the highest fetched scene-surface score before graph eligibility and occasion selection, or `null` if no scene-surface match was returned. The setting entry refers to `SettingWords`; the participants entry lists all nonblank names and descriptions that contributed to the single joined search, including names whose identity lookup was unknown. Its score is shared, never a separate score for each person. Keys do not contribute words. An empty scene omits the scene-search field; readers treat an absent field as an empty list. Tracing disabled produces no trace. This score describes the search, not whether the best match was admitted or whether the description identifies someone. No minimum similarity is applied. These scores support measuring whether a useful boundary for descriptions that remind of nothing exists. That measurement belongs in the public companion evaluation repository, `CharacterMemoryEvals`, whose evaluation tooling is a development aid outside the core library. A decision to apply such a boundary requires that evidence. diff --git a/src/api/types/retrieval.rs b/src/api/types/retrieval.rs index 8d339ef1..81ada708 100644 --- a/src/api/types/retrieval.rs +++ b/src/api/types/retrieval.rs @@ -520,6 +520,7 @@ pub struct LifecycleOmissionSummary { #[non_exhaustive] pub struct RetrievalTrace { pub vector_candidates: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub scene_cue_searches: Vec, /// Recallable scene matches left out by each description search's occasion limit. /// Participant words share one search and therefore one count. @@ -889,6 +890,15 @@ mod tests { .allows_retention_state(RetentionState::Suppressed)); } + #[test] + fn empty_scene_search_trace_reads_across_pins() { + let mut prior = serde_json::to_value(RetrievalTrace::empty()).unwrap(); + prior.as_object_mut().unwrap().remove("scene_cue_searches"); + let trace: RetrievalTrace = serde_json::from_value(prior.clone()).unwrap(); + assert!(trace.scene_cue_searches.is_empty()); + assert_eq!(serde_json::to_value(trace).unwrap(), prior); + } + #[test] fn section_assignment_shape_reports_final_section() { let assignment = SectionAssignment { From d7c0a1b3d192bd8088cce6729f4ec87afad79f09 Mon Sep 17 00:00:00 2001 From: ebigunso Date: Wed, 23 Sep 2026 01:04:00 +0900 Subject: [PATCH 3/4] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Consolidate=20scene=20?= =?UTF-8?q?search=20traces=20and=20close=20the=20scene-words=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- .../v0-2-scene-words-plan.md | 17 ++- docs/design/database/schema_cheat_sheet.md | 2 +- docs/design/database/vector_payload_design.md | 4 +- src/adapters/qdrant/tie_closure.rs | 8 +- src/api/types/retrieval.rs | 8 +- src/memory/retrieval_scene_tests.rs | 9 ++ src/memory/retrieval_turn_tests.rs | 10 +- src/models/vector/candidate_record.rs | 1 + src/usecases/retrieve.rs | 106 +++++++++--------- src/usecases/retrieve/scene.rs | 92 ++++++++------- tests/scene_reminder_tests.rs | 31 +++-- 12 files changed, 169 insertions(+), 121 deletions(-) rename docs/coding-agent/plans/{active => completed}/v0-2-scene-words-plan.md (93%) diff --git a/README.md b/README.md index 9d4b7b13..3a210f22 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ Each cue kind has a floor at the merged candidate cap, the graph root cap and ea With tracing enabled, `floor_admissions` identifies the object, stage and cue kind when a reserved or spare turn admitted an object outside that stage's original capped prefix. Earlier-stage evidence remains even when a later stage omits the object. -The trace's `scene_cue_searches` reports the best similarity found for the setting and for the joined participant words, with the references sharing each search; a search with no matches has no score. `scene_cue_omitted_counts` counts recallable matches left out by each description's occasion limit. There is no minimum similarity: whether a description is too weak to remind the character of anything remains a question for measurement, not a decision made by this score. +The trace's `scene_cue_searches` reports the best similarity found for the setting and for the joined participant words, with the references sharing each search; a search with no matches has no score. Each entry identifies its cue kind and counts recallable matches left out by the description's occasion limit in `omitted_count`. There is no minimum similarity: whether a description is too weak to remind the character of anything remains a question for measurement, not a decision made by this score. A participant present in most experiences brings fewer past encounters to mind, while retaining the latest eligible one within the requested limits. A participant recognized by key or name brings only encounters at or before the scene time. Several remarks or participants in one encounter do not make it count more than once. Familiarity limits recalled encounters, not beliefs about that participant. This applies to every notion, including whichever one the application regards as the character. diff --git a/docs/coding-agent/plans/active/v0-2-scene-words-plan.md b/docs/coding-agent/plans/completed/v0-2-scene-words-plan.md similarity index 93% rename from docs/coding-agent/plans/active/v0-2-scene-words-plan.md rename to docs/coding-agent/plans/completed/v0-2-scene-words-plan.md index 61fea1f8..d8cdf038 100644 --- a/docs/coding-agent/plans/active/v0-2-scene-words-plan.md +++ b/docs/coding-agent/plans/completed/v0-2-scene-words-plan.md @@ -1,8 +1,8 @@ # Plan: where and with whom, given in words, reminds the character of the last times like this, and never crowds out what is being talked about -- status: in-progress +- status: completed - generated: 2026-09-21 -- last_updated: 2026-09-21 +- last_updated: 2026-09-23 - work_type: code ## Goal @@ -214,7 +214,10 @@ One crate, shared files: sequential, one worker at a time, about 10, 12 and 4 wo ## Progress Log (append-only) -- (none yet) +- 2026-09-23 Task_1 completed at reviewed tip `610b191` ([PR 135](https://github.com/ebigunso/character-memory/pull/135)): content and scene words use separate search surfaces. +- 2026-09-23 Task_2 completed at reviewed tip `63f176f` ([PR 137](https://github.com/ebigunso/character-memory/pull/137)): descriptions contribute recent occasions, score fills spare room, and turns remain at root selection. +- 2026-09-23 Task_3 completed at reviewed tip `4cf9763` ([PR 141](https://github.com/ebigunso/character-memory/pull/141)): per-search best scores and shared references are traced, with consumer and schema documentation. +- 2026-09-23 Completion value audit verdict: COMPLETE against the after-the-fix measurements. Closeout consolidates each search's kind, score, references and omission count in one trace entry, copies only returned vector candidates, and centralizes vector-root construction and the reminder-only predicate; recall behavior is preserved. ## Decision Log (append-only; re-plans and major discoveries) @@ -231,6 +234,14 @@ One crate, shared files: sequential, one worker at a time, about 10, 12 and 4 wo - User approval: ruled by the decider, 2026-09-21. - Record proposed: none in this slice. One proposed design record with one decision (a description never takes an identity's standing in recall) is drafted only after the fourth-pin measurement. +- 2026-09-23 Decision: accept the completion audit verdict and close the scene-words plan without a similarity bound. + - Trigger / new insight: the public companion evaluation repository, `CharacterMemoryEvals`, provides development evidence outside the core library in [scene reminder calibration, after the fix](https://github.com/ebigunso/character-memory-evals/blob/e06c0d76c34bf5849ee70ae158e3aaec04fe4c2d/docs/evidence/calibration/scene-reminders-2026-09-21/after-the-fix.md). Under its controlled synthetic geometry, default-floor topic targets improve from 3 of 8 to the topic-alone 6 of 8 in all twelve pressure cases spanning identical/reworded descriptions, place/participant/both pressure and both native ID orders. Keyless and scene-only probes bring exactly the latest N occasions at the tested floors. + - Evidence boundary: the orthogonal participant/place shares of 72.7% and 50.0% become structural zeros because that corpus has no stored scene words. Separately, weak stranger and unfamiliar-place queries against populated scene stores contribute one occasion and one exclusive slot at the default floor. The unlived-topic share is 29.4% after the fix, unchanged from the intermediate pin and down from 35.3% at the before pin. These are distinct controls, not one percentage-to-slot comparison. Controlled paraphrase distributions have no separable band and are not a production-language threshold estimate, so no bound ships. + - Plan delta (what changed): mark completed and move this plan to the completed folder; retain the scope and reviewed task results. Consolidate the parallel trace fields while preserving the best-score reader, remove the fetched-pool copy, and use one vector-root constructor and reminder-only predicate. The proposed test-file merge is deferred to phase closeout. + - Tradeoffs considered: retain parallel trace summaries versus one entry per actual search; the latter keeps attribution and omission counts together. A threshold from the controlled geometry was rejected because the measurement cannot choose a production embedder's bound. + - Decider approval: completion accepted through the completion audit and the delegated closeout ruling, 2026-09-22. + - Record proposed: none in this closeout. + ## Notes - Risks: a description brings only as many occasions as its floor value (one at a floor of zero), so in a keyless deployment with default floors the scene's share is small by design, and calibration of the floor values is what widens it; the closed pool for a label repeated for years is a few thousand refs and one bounded read, and if that read measures as costly the worker reports it and builds nothing else; tests that used appended words to make an episode findable by topic fail for a reason unrelated to their intent and are rewritten to say what they mean; embedding cost for a worded scene rises by up to two inputs per episode, not per observation. - Edge cases, with the expected result: a participant given by key and by description is knowing through the key, and its words still join the participants cue; a memory reached both ways has full standing. The same words as topic and as setting run two scoped searches. A name nobody bears activates nothing as a name, as today, and its words still join the participants cue. A scene with only a time writes exactly what it writes today. A store indexed before this slice has no scene surfaces: every description search reports no best score and brings nothing. diff --git a/docs/design/database/schema_cheat_sheet.md b/docs/design/database/schema_cheat_sheet.md index 41eb9c5d..fbb14f5d 100644 --- a/docs/design/database/schema_cheat_sheet.md +++ b/docs/design/database/schema_cheat_sheet.md @@ -36,7 +36,7 @@ These are the five emitted payload fields. Both adapters index `object_id`, `obj The `query` surface represents search input. It is not emitted for a stored memory object. A topic searches content surfaces; scene words search their respective episode surfaces and are not part of the summary text. Exact names also resolve through graph assertions. -Descriptions contribute their most recent recallable occasions from the fetched matches, as many as the kind's floor and at least one; the rest of their matches contribute nothing. Trace-only `scene_cue_searches` lists a best score and the references sharing each search, once for the setting and once for all participant words; no returned match means `null`. `scene_cue_omitted_counts` records matches excluded by the occasion limit. These are retrieval diagnostics, not stored vector payload fields. See [description recall and trace](vector_payload_design.md#description-recall-and-trace) for score, admission and identity boundaries. +Descriptions contribute their most recent recallable occasions from the fetched matches, as many as the kind's floor and at least one; the rest of their matches contribute nothing. Trace-only `scene_cue_searches` lists a best score and the references sharing each search, once for the setting and once for all participant words; no returned match means `null`. Each search entry also carries its `cue_kind` and `omitted_count`, the number of recallable matches excluded by the occasion limit. These are retrieval diagnostics, not stored vector payload fields. See [description recall and trace](vector_payload_design.md#description-recall-and-trace) for score, admission and identity boundaries. ## Graph Resources diff --git a/docs/design/database/vector_payload_design.md b/docs/design/database/vector_payload_design.md index 8499c2eb..b9c4886a 100644 --- a/docs/design/database/vector_payload_design.md +++ b/docs/design/database/vector_payload_design.md @@ -41,11 +41,11 @@ Topic searches use only the three content surfaces. Setting words search `scene_ ## Description Recall And Trace -A key or a known name brings current beliefs about the person or context it identifies. A description brings recent occasions with similar recorded words, without establishing an identity. Each description search contributes only its most recent recallable episodes at or before the retrieval scene time, as many as its cue kind's floor and never fewer than one. Selection uses the fetched search pool, including the fetched tie at the cutoff; an open search boundary limits this claim to the fetched matches. Other matches do not enter the candidate set through that description. The trace's `scene_cue_omitted_counts` records the number of recallable matches excluded by this occasion limit, once for the setting and once for the joined participants. +A key or a known name brings current beliefs about the person or context it identifies. A description brings recent occasions with similar recorded words, without establishing an identity. Each description search contributes only its most recent recallable episodes at or before the retrieval scene time, as many as its cue kind's floor and never fewer than one. Selection uses the fetched search pool, including the fetched tie at the cutoff; an open search boundary limits this claim to the fetched matches. Other matches do not enter the candidate set through that description. Each trace entry in `scene_cue_searches` records in `omitted_count` the number of recallable matches excluded by this occasion limit, once for the setting and once for the joined participants. The contributed occasions compete by their similarity scores within the existing relevance ranking. Keys, names and current state take their kind's floor before description matches. Spare room follows score order when candidates and context-pack sections are selected; only graph-root selection shares spare room by turns among kinds. An occasion recalled through words can bring its observations and conclusions, but does not by itself open the other occasions of a person or thread reached from it. When the topic or a known reference also reaches it, linked history inherits that source's score; a strong description match cannot lend its score to a weak topic match. -With tracing enabled, [scene search trace emission (`scene.rs:185`)](../../../src/usecases/retrieve/scene.rs#L185) emits one `scene_cue_searches` entry for each scene search. Each [entry (`retrieval.rs:559`)](../../../src/api/types/retrieval.rs#L559) lists the `SceneReference` values sharing that search and its `best_score`: the highest fetched scene-surface score before graph eligibility and occasion selection, or `null` if no scene-surface match was returned. The setting entry refers to `SettingWords`; the participants entry lists all nonblank names and descriptions that contributed to the single joined search, including names whose identity lookup was unknown. Its score is shared, never a separate score for each person. Keys do not contribute words. An empty scene omits the scene-search field; readers treat an absent field as an empty list. Tracing disabled produces no trace. +With tracing enabled, [scene search trace emission](../../../src/usecases/retrieve/scene.rs) emits one `scene_cue_searches` entry for each scene search. Each [entry](../../../src/api/types/retrieval.rs) lists its `cue_kind`, the `SceneReference` values sharing that search, its `omitted_count`, and its `best_score`: the highest fetched scene-surface score before graph eligibility and occasion selection, or `null` if no scene-surface match was returned. The setting entry refers to `SettingWords`; the participants entry lists all nonblank names and descriptions that contributed to the single joined search, including names whose identity lookup was unknown. Its score is shared, never a separate score for each person. Keys do not contribute words. An empty scene omits the scene-search field; readers treat an absent field as an empty list. Tracing disabled produces no trace. This score describes the search, not whether the best match was admitted or whether the description identifies someone. No minimum similarity is applied. These scores support measuring whether a useful boundary for descriptions that remind of nothing exists. That measurement belongs in the public companion evaluation repository, `CharacterMemoryEvals`, whose evaluation tooling is a development aid outside the core library. A decision to apply such a boundary requires that evidence. diff --git a/src/adapters/qdrant/tie_closure.rs b/src/adapters/qdrant/tie_closure.rs index e1479992..37128dcd 100644 --- a/src/adapters/qdrant/tie_closure.rs +++ b/src/adapters/qdrant/tie_closure.rs @@ -71,7 +71,9 @@ where FetchDecision::Grow(next_limit) => fetch_limit = next_limit, FetchDecision::Return => { return Ok(TieClosureResult { - candidates: candidates.clone().truncated(admitted_limit), + candidates: CanonicalCandidates::new( + candidates.iter().take(admitted_limit).cloned(), + ), pool: candidates, fetched: fetched_count, fetch_bound, @@ -80,7 +82,9 @@ where } FetchDecision::ReturnAtBound => { return Ok(TieClosureResult { - candidates: candidates.clone().truncated(admitted_limit), + candidates: CanonicalCandidates::new( + candidates.iter().take(admitted_limit).cloned(), + ), pool: candidates, fetched: fetched_count, fetch_bound, diff --git a/src/api/types/retrieval.rs b/src/api/types/retrieval.rs index 81ada708..a41d09c2 100644 --- a/src/api/types/retrieval.rs +++ b/src/api/types/retrieval.rs @@ -522,10 +522,6 @@ pub struct RetrievalTrace { pub vector_candidates: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub scene_cue_searches: Vec, - /// Recallable scene matches left out by each description search's occasion limit. - /// Participant words share one search and therefore one count. - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub scene_cue_omitted_counts: BTreeMap, pub floor_admissions: Vec, pub graph_relations: Vec, pub graph_expansions: Vec, @@ -541,7 +537,6 @@ impl RetrievalTrace { Self { vector_candidates: Vec::new(), scene_cue_searches: Vec::new(), - scene_cue_omitted_counts: BTreeMap::new(), floor_admissions: Vec::new(), graph_relations: Vec::new(), graph_expansions: Vec::new(), @@ -557,11 +552,14 @@ impl RetrievalTrace { /// One setting search or one shared search of all participants' names and descriptions. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct SceneCueSearchTrace { + pub cue_kind: CueKind, /// Scene references sharing this search; the score is not per person or reference. pub references: Vec, /// Best fetched scene-surface score, before occasion selection and graph eligibility. /// `None` means the search returned no scene-surface matches. pub best_score: Option, + /// Recallable matches left out by this search's occasion limit. + pub omitted_count: usize, } impl Default for RetrievalTrace { diff --git a/src/memory/retrieval_scene_tests.rs b/src/memory/retrieval_scene_tests.rs index e9aeb997..07372b2e 100644 --- a/src/memory/retrieval_scene_tests.rs +++ b/src/memory/retrieval_scene_tests.rs @@ -1658,6 +1658,11 @@ async fn description_search_scores_are_shared_and_precede_occasion_selection() { let identical = memory.retrieve(context.clone()).await.unwrap(); let searches = &identical.trace.as_ref().unwrap().scene_cue_searches; assert_eq!(searches.len(), 2); + assert_eq!( + (searches[0].cue_kind, searches[1].cue_kind), + (crate::CueKind::Place, crate::CueKind::Participant) + ); + assert!(searches.iter().all(|search| search.omitted_count == 0)); assert_eq!(searches[0].references, [SceneReference::SettingWords]); assert_eq!( searches[1].references, @@ -1695,6 +1700,10 @@ async fn description_search_scores_are_shared_and_precede_occasion_selection() { .scene_cue_searches .iter() .all(|search| (search.best_score.unwrap() - 1.0).abs() < 1e-6)); + assert!(trace + .scene_cue_searches + .iter() + .all(|search| search.omitted_count == 1)); let mut untraced_context = context.clone(); untraced_context.include_trace = false; let untraced = memory.retrieve(untraced_context).await.unwrap(); diff --git a/src/memory/retrieval_turn_tests.rs b/src/memory/retrieval_turn_tests.rs index 98504342..85dbdc94 100644 --- a/src/memory/retrieval_turn_tests.rs +++ b/src/memory/retrieval_turn_tests.rs @@ -181,8 +181,14 @@ async fn shared_scene_cohort_keeps_the_latest_occasion_and_score_fills_the_pack( [1000, 2000, 2001, 2002, 2003, 2004, 2005, 2006] ); assert!(trace.floor_admissions.is_empty()); - assert_eq!(trace.scene_cue_omitted_counts[&CueKind::Participant], 47); - assert_eq!(trace.scene_cue_omitted_counts[&CueKind::Place], 47); + assert_eq!( + trace + .scene_cue_searches + .iter() + .map(|search| (search.cue_kind, search.omitted_count)) + .collect::>(), + [(CueKind::Place, 47), (CueKind::Participant, 47)] + ); } memory.close().await.unwrap(); } diff --git a/src/models/vector/candidate_record.rs b/src/models/vector/candidate_record.rs index f5f99e63..0657eb58 100644 --- a/src/models/vector/candidate_record.rs +++ b/src/models/vector/candidate_record.rs @@ -90,6 +90,7 @@ impl CanonicalCandidates { Self(canonicalize_vector_candidates(candidates)) } + #[cfg(test)] pub(crate) fn truncated(mut self, limit: usize) -> Self { self.0.truncate(limit); self diff --git a/src/usecases/retrieve.rs b/src/usecases/retrieve.rs index a37feb3b..e7d44bea 100644 --- a/src/usecases/retrieve.rs +++ b/src/usecases/retrieve.rs @@ -200,10 +200,9 @@ where } explicit_roots.extend(activity_roots); let root_selection = select_candidate_roots( - &vector_candidates, - &cues.kinds, + cues.roots, &explicit_roots, - (&cues.orders, &cues.topic_scores), + &cues.orders, context.candidate_limits.max_graph_roots, context.cue_floors, (&state_scopes, &root_order, &scope_kinds), @@ -408,7 +407,6 @@ where section_pressure, }; let trace = trace_mode.is_enabled().then(|| RetrievalTrace { - scene_cue_omitted_counts: cues.scene_cue_omitted_counts, scene_cue_searches: cues.scene_cue_searches, vector_candidates: vector_candidates .iter() @@ -566,7 +564,7 @@ impl RetrieveAssembly { } else { score * 0.75 }; - let reminder_only = candidate.full_standing_score.is_none(); + let reminder_only = candidate.reminder_only(); let candidate_score = candidate .vector_score .filter(|_| object_ref == candidate_ref); @@ -1152,6 +1150,33 @@ struct CandidateRoot { full_standing_kinds: BTreeSet, } +impl CandidateRoot { + fn from_vector( + candidate: &VectorCandidateMatch, + cue_kinds: BTreeSet, + topic_score: Option, + ) -> Self { + Self { + object_id: candidate.object_id, + object_type: candidate.object_type, + score: candidate.score, + source: GraphRootSource::Vector, + vector_score: Some(candidate.score), + full_standing_score: topic_score, + full_standing_kinds: cue_kinds + .iter() + .filter(|&&kind| kind == CueKind::Topic) + .copied() + .collect(), + cue_kinds, + } + } + + fn reminder_only(&self) -> bool { + self.full_standing_score.is_none() + } +} + #[derive(Debug)] struct CandidateRootSelection { roots: Vec, @@ -1162,13 +1187,9 @@ struct CandidateRootSelection { } fn select_candidate_roots( - candidates: &[VectorCandidateMatch], - kinds: &HashMap>, + candidates: Vec, explicit_roots: &[CandidateRoot], - (content_orders, topic_scores): ( - &BTreeMap>, - &HashMap, - ), + content_orders: &BTreeMap>, max_graph_roots: usize, floors: RetrievalCueFloors, (scopes, root_order, scope_kinds): ( @@ -1186,17 +1207,19 @@ fn select_candidate_roots( .push(MemoryObjectRef::new(root.object_type, root.object_id)); } } - let mut by_ref: HashMap = HashMap::new(); + let mut by_ref: HashMap = HashMap::new(); for candidate in candidates { let object_ref = MemoryObjectRef::from_id_type(candidate.object_id, candidate.object_type); - by_ref - .entry(object_ref) - .and_modify(|existing| { - if candidate.score.total_cmp(&existing.score).is_gt() { - *existing = candidate; + match by_ref.entry(object_ref) { + std::collections::hash_map::Entry::Occupied(mut entry) => { + if candidate.score.total_cmp(&entry.get().score).is_gt() { + entry.insert(candidate); } - }) - .or_insert(candidate); + } + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(candidate); + } + } } let mut content = by_ref.into_values().collect::>(); content.sort_by(|left, right| { @@ -1213,38 +1236,7 @@ fn select_candidate_roots( let roots = explicit_roots .iter() .cloned() - .chain(content.into_iter().map(|candidate| { - CandidateRoot { - object_id: candidate.object_id, - object_type: candidate.object_type, - score: candidate.score, - source: GraphRootSource::Vector, - vector_score: Some(candidate.score), - full_standing_score: topic_scores - .get(&MemoryObjectRef::new( - candidate.object_type, - candidate.object_id, - )) - .copied(), - full_standing_kinds: kinds - .get(&MemoryObjectRef::new( - candidate.object_type, - candidate.object_id, - )) - .into_iter() - .flatten() - .filter(|&&kind| kind == CueKind::Topic) - .copied() - .collect(), - cue_kinds: kinds - .get(&MemoryObjectRef::new( - candidate.object_type, - candidate.object_id, - )) - .cloned() - .unwrap_or_default(), - } - })) + .chain(content) .collect::>(); let mut indices = HashMap::new(); let mut merged: Vec = Vec::new(); @@ -1320,7 +1312,7 @@ fn select_candidate_roots( ( MemoryObjectRef::new(root.object_type, root.object_id), &root.cue_kinds, - root.full_standing_score.is_none(), + root.reminder_only(), ) }), &orders, @@ -1381,7 +1373,7 @@ fn graph_query_for_candidate( }); query.current_subject_state = candidate.object_type == ObjectType::Entity && candidate.source == GraphRootSource::Participant; - query.reminder_only = candidate.full_standing_score.is_none(); + query.reminder_only = candidate.reminder_only(); query.participant_reference_time = context.scene.time; query } @@ -1736,10 +1728,12 @@ mod tests { let high = vector_candidate(MemoryId::from_u128(3), ObjectType::Episode, 0.9); let selected = select_candidate_roots( - &[middle.clone(), low, high.clone()], - &HashMap::new(), + [middle.clone(), low, high.clone()] + .iter() + .map(|candidate| CandidateRoot::from_vector(candidate, BTreeSet::new(), None)) + .collect(), &[], - (&BTreeMap::new(), &HashMap::new()), + &BTreeMap::new(), 2, RetrievalCueFloors::default(), (&state::StateScopes::new(), &HashMap::new(), &[]), diff --git a/src/usecases/retrieve/scene.rs b/src/usecases/retrieve/scene.rs index 40289c26..1f4705c7 100644 --- a/src/usecases/retrieve/scene.rs +++ b/src/usecases/retrieve/scene.rs @@ -9,15 +9,13 @@ use crate::ports::graph_authority::GraphObjectQuery; pub(super) struct RecallCues { pub candidates: CanonicalCandidates, - pub kinds: HashMap>, + pub roots: Vec, pub orders: BTreeMap>, pub participants: Vec, pub references: Vec, pub dimension: usize, pub completeness: VectorRecallCompleteness, pub floor_admissions: Vec, - pub topic_scores: HashMap, - pub scene_cue_omitted_counts: BTreeMap, pub scene_cue_searches: Vec, } @@ -131,7 +129,6 @@ where let mut all_candidates = Vec::new(); let mut candidates_by_kind: BTreeMap> = BTreeMap::new(); let mut topic_scores = HashMap::::new(); - let mut scene_cue_omitted_counts = BTreeMap::new(); let mut scene_cue_searches = Vec::new(); let mut dimension = 0; let mut completeness = VectorRecallCompleteness::NotRequested; @@ -181,28 +178,6 @@ where recall.candidates.iter().cloned().collect::>() } else { let pool = &recall.scene_pool; - if context.include_trace { - scene_cue_searches.push(SceneCueSearchTrace { - references: references - .iter() - .map(|result| &result.reference) - .chain(&descriptions) - .filter(|reference| { - matches!( - (kind, reference), - (CueKind::Place, SceneReference::SettingWords) - | ( - CueKind::Participant, - SceneReference::ParticipantName { .. } - | SceneReference::ParticipantDescription { .. } - ) - ) - }) - .cloned() - .collect(), - best_score: pool.first().map(|candidate| candidate.score), - }); - } let objects = pool .iter() .map(|candidate| { @@ -238,7 +213,30 @@ where } .max(1); eligible.truncate(limit); - scene_cue_omitted_counts.insert(kind, count - eligible.len()); + if context.include_trace { + scene_cue_searches.push(SceneCueSearchTrace { + cue_kind: kind, + references: references + .iter() + .map(|result| &result.reference) + .chain(&descriptions) + .filter(|reference| { + matches!( + (kind, reference), + (CueKind::Place, SceneReference::SettingWords) + | ( + CueKind::Participant, + SceneReference::ParticipantName { .. } + | SceneReference::ParticipantDescription { .. } + ) + ) + }) + .cloned() + .collect(), + best_score: pool.first().map(|candidate| candidate.score), + omitted_count: count - eligible.len(), + }); + } eligible }; all_candidates.extend(candidates.iter().cloned()); @@ -295,13 +293,23 @@ where (kind, order) }) .collect(); - let selection = select_with_cue_floors( - candidates.iter().map(|candidate| { + let roots = candidates + .iter() + .map(|candidate| { let object = MemoryObjectRef::new(candidate.object_type, candidate.object_id); + CandidateRoot::from_vector( + candidate, + kinds[&object].clone(), + topic_scores.get(&object).copied(), + ) + }) + .collect::>(); + let selection = select_with_cue_floors( + roots.iter().map(|root| { ( - object, - &kinds[&object], - !kinds[&object].contains(&CueKind::Topic), + MemoryObjectRef::new(root.object_type, root.object_id), + &root.cue_kinds, + root.reminder_only(), ) }), &orders, @@ -310,10 +318,11 @@ where CueFloorStage::CandidateMerge, ); let mut floor_admissions = Vec::new(); - let selected = selection - .into_iter() - .map(|(index, cause)| { - let candidate = &candidates[index]; + let mut selection = selection.into_iter().peekable(); + let mut selected = Vec::new(); + let mut selected_roots = Vec::new(); + for (index, (candidate, root)) in candidates.into_iter().zip(roots).enumerate() { + if let Some((_, cause)) = selection.next_if(|(chosen, _)| *chosen == index) { if let Some(cue_kind) = cause { floor_admissions.push(CueFloorAdmission { object: MemoryObjectRef::new(candidate.object_type, candidate.object_id), @@ -321,20 +330,19 @@ where cue_kind, }); } - candidate.clone() - }) - .collect::>(); + selected.push(candidate); + selected_roots.push(root); + } + } Ok(RecallCues { - topic_scores, candidates: CanonicalCandidates::new(selected), - kinds, + roots: selected_roots, orders, participants, references, dimension, completeness, floor_admissions, - scene_cue_omitted_counts, scene_cue_searches, }) } diff --git a/tests/scene_reminder_tests.rs b/tests/scene_reminder_tests.rs index fc9bf370..c1eda3c9 100644 --- a/tests/scene_reminder_tests.rs +++ b/tests/scene_reminder_tests.rs @@ -210,7 +210,7 @@ fn snapshot(case: &str, outcome: &RetrieveOutcome) -> Value { "floor_admissions": trace.floor_admissions, "scene_references": outcome.scene_references, "completeness": outcome.rationale.telemetry.vector_recall_completeness, - "scene_cue_omitted_counts": trace.scene_cue_omitted_counts, + "scene_cue_searches": trace.scene_cue_searches, }) } @@ -335,10 +335,15 @@ async fn scene_reminders_and_state_score_fill_preserve_their_witnesses() { }; assert_eq!(row["episode_ids"], json!(expected), "{case}"); let omitted = 48 - expected.len(); - assert_eq!( - row["scene_cue_omitted_counts"], - json!({"participant": omitted, "place": omitted}) - ); + let searches = row["scene_cue_searches"].as_array().unwrap(); + assert_eq!(searches.len(), 2); + for kind in ["participant", "place"] { + let search = searches + .iter() + .find(|search| search["cue_kind"] == kind) + .unwrap(); + assert_eq!(search["omitted_count"], json!(omitted)); + } } else if case.starts_with("keyed-state") { let cap: usize = case.rsplit('=').next().unwrap().parse().unwrap(); let topic_floor = usize::from(case.contains("Some")); @@ -391,7 +396,12 @@ async fn recent_occasions_use_scene_time_across_the_full_pool() { expected ); assert_eq!( - trace.scene_cue_omitted_counts[&CueKind::Place], + trace + .scene_cue_searches + .iter() + .find(|search| search.cue_kind == CueKind::Place) + .unwrap() + .omitted_count, 3 - floor.max(1) ); assert!(trace @@ -413,7 +423,14 @@ async fn recent_occasions_use_scene_time_across_the_full_pool() { let result = memory.retrieve(query(None, current)).await.unwrap(); assert_eq!(result.pack.relevant_episodes[0].id, id(20)); assert_eq!( - result.trace.unwrap().scene_cue_omitted_counts[&CueKind::Place], + result + .trace + .unwrap() + .scene_cue_searches + .iter() + .find(|search| search.cue_kind == CueKind::Place) + .unwrap() + .omitted_count, 1 ); let topic = memory From 4fa8239e035df8b709d354c0ecbf6b3a455a27f8 Mon Sep 17 00:00:00 2001 From: ebigunso Date: Wed, 23 Sep 2026 07:01:18 +0900 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=93=9D=20The=20scene-words=20plan=20l?= =?UTF-8?q?inks=20the=20scene=20reminder=20evidence=20where=20it=20is=20ke?= =?UTF-8?q?pt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/coding-agent/plans/completed/v0-2-scene-words-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/coding-agent/plans/completed/v0-2-scene-words-plan.md b/docs/coding-agent/plans/completed/v0-2-scene-words-plan.md index d8cdf038..9f811215 100644 --- a/docs/coding-agent/plans/completed/v0-2-scene-words-plan.md +++ b/docs/coding-agent/plans/completed/v0-2-scene-words-plan.md @@ -235,7 +235,7 @@ One crate, shared files: sequential, one worker at a time, about 10, 12 and 4 wo - Record proposed: none in this slice. One proposed design record with one decision (a description never takes an identity's standing in recall) is drafted only after the fourth-pin measurement. - 2026-09-23 Decision: accept the completion audit verdict and close the scene-words plan without a similarity bound. - - Trigger / new insight: the public companion evaluation repository, `CharacterMemoryEvals`, provides development evidence outside the core library in [scene reminder calibration, after the fix](https://github.com/ebigunso/character-memory-evals/blob/e06c0d76c34bf5849ee70ae158e3aaec04fe4c2d/docs/evidence/calibration/scene-reminders-2026-09-21/after-the-fix.md). Under its controlled synthetic geometry, default-floor topic targets improve from 3 of 8 to the topic-alone 6 of 8 in all twelve pressure cases spanning identical/reworded descriptions, place/participant/both pressure and both native ID orders. Keyless and scene-only probes bring exactly the latest N occasions at the tested floors. + - Trigger / new insight: the public companion evaluation repository, `CharacterMemoryEvals`, provides development evidence outside the core library in [scene reminder calibration, after the fix](https://github.com/ebigunso/character-memory-evals/tree/784c2784260196352d7cc5184e0992260f0b1b0b/docs/evidence/calibration/scene-reminders-2026-09-21). Under its controlled synthetic geometry, default-floor topic targets improve from 3 of 8 to the topic-alone 6 of 8 in all twelve pressure cases spanning identical/reworded descriptions, place/participant/both pressure and both native ID orders. Keyless and scene-only probes bring exactly the latest N occasions at the tested floors. - Evidence boundary: the orthogonal participant/place shares of 72.7% and 50.0% become structural zeros because that corpus has no stored scene words. Separately, weak stranger and unfamiliar-place queries against populated scene stores contribute one occasion and one exclusive slot at the default floor. The unlived-topic share is 29.4% after the fix, unchanged from the intermediate pin and down from 35.3% at the before pin. These are distinct controls, not one percentage-to-slot comparison. Controlled paraphrase distributions have no separable band and are not a production-language threshold estimate, so no bound ships. - Plan delta (what changed): mark completed and move this plan to the completed folder; retain the scope and reviewed task results. Consolidate the parallel trace fields while preserving the best-score reader, remove the fetched-pool copy, and use one vector-root constructor and reminder-only predicate. The proposed test-file merge is deferred to phase closeout. - Tradeoffs considered: retain parallel trace summaries versus one entry per actual search; the latter keeps attribution and omission counts together. A threshold from the controlled geometry was rejected because the measurement cannot choose a production embedder's bound.