Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,13 @@ let context = RetrievalContext::new("What happened last Tuesday?")

Date match contributes the newest recallable episodes inside the range, including both endpoints, up to the largest requested section cap. All-zero section caps contribute none. Its provisional floor is one and only sets the reservation at roots and sections; it takes no spare root turn. Other cues can still recall episodes outside the range, and a shared hit keeps its score and standing. The reference scene time does not shorten the span. An inverted range matches nothing and is echoed unchanged, as every supplied range is, even with tracing off. With tracing enabled, `time_range_has_more` says whether the range contained more eligible occasions than this source contributed; later caps can still omit those contributions. It reads one extra ID and performs no count query. A crowded range is not a filtered topic search: an older topical memory inside it can still be missed when the unrestricted topic search ranks it below its cap.

The scene time includes the offset supplied by the application. Writing an episode derives its local calendar date from that offset; the stored instant stays in UTC, so reading the scene preserves the instant without reconstructing the original offset. For anniversary matching, retrieval must use the same offset as the writes; the recorded local day is available on `Episode.scene_local_date`. That local date is persisted content: replaying the same ID and instant with an offset that changes its local date is a collision; another offset giving the same local date is the same content.

The same local month and day in an earlier local year contributes anniversary occasions as date matches, with February 29 matching only February 29. Shared occasions come first, then the most recent by recorded instant and ID, up to the date-match floor and at least one. An anniversary reserves room only when it was shared with a notion that the present scene resolves to one identity. An ambiguous name grants no anniversary reservation. Unshared anniversaries still contribute and compete for unclaimed room; the existing score can leave them out. The range and anniversary share one date-match floor. A time-only reminder brings what rests on the occasion and does not open a notion or thread's history. With tracing enabled, `anniversary_has_more` reports whether another eligible anniversary existed beyond the contribution limit. Episodes stored without a derived local date remain readable and do not match anniversaries; no migration invents their local dates.

Recent recallable episodes at or before the scene time can come to mind without any other cue, together with memories resting on those occasions. Recorded scene time determines recency, not creation time. Recency contributes the newest occasions up to the largest requested section cap, in newest-first selector order; all-zero section caps contribute none. Its floor ships at zero until measured. It takes no spare turn at root selection and uses only room left after the given cue kinds, so full roots remain unchanged at the default floor. The root and section caps still bound what is recalled. Every reminder road, including scene descriptions, stays on its occasion: a person, thread or interpreted memory reached through it is a leaf. Another occasion does not open or inherit the reminder kind through shared work. An object that actually expands as a root keeps the best proximity of every road that reached it; objects reached only through roots inherit proximity from full-standing roads when present.

Once a recent occasion has root room, section selection uses the ordinary score: a low-scoring Topic descendant can yield to a salient recent occasion under a section cap. Recency has zero cue score, so even its most salient root scores at most 0.35; stronger Topic roots keep their places. A raised recency floor reserves the latest contributions at roots and sections. Remaining section room uses score; among contributed time occasions with equal scores, date matches precede recency-only occasions, then recorded scene time breaks the tie before ID. With a zero floor, an older, more salient contribution may survive when the newest does not. Chronological presentation belongs to the renderer: admitted memories already report their recorded source scenes.
Once a recent occasion has root room, section selection uses the ordinary score: a low-scoring Topic descendant can yield to a salient recent occasion under a section cap. Recency has zero cue score, so even its most salient root scores at most 0.35; stronger Topic roots keep their places. A raised recency floor reserves the latest contributions at roots and sections. Remaining section room uses score; among contributed time occasions with equal scores, caller-supplied ranges and shared resolved anniversaries precede recency and unshared anniversaries, then recorded scene time breaks the tie before ID. With a zero floor, an older, more salient contribution may survive when the newest does not. Chronological presentation belongs to the renderer: admitted memories already report their recorded source scenes.

Thread members are taken most recent first by creation time; an open loop's source episodes are taken most recent first by recorded scene time.

Expand Down
6 changes: 5 additions & 1 deletion docs/design/database/graph_schema_design.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@ Hydration reconstructs domain objects and links from the selected RDF named grap

Episodes record interaction spans with `summary`, `modality`, one `Scene`, optional `endedAt` and `rawRef`, salience and retention. The scene holds the required experience time, participants with any combination of notion key, supplied name and description, a setting key and/or words, and flat string custom values. Missing scene parts stay unspecified. Observations record `text` tied to an `episode`, with an optional `speakerEntity` and raw reference. Preparation defaults observation time to scene time while preserving an explicit observation time. Involved notions, participants, speakers, thread memberships and the episode end time remain independent.

The episode owns the scene. `sceneTime` stores its timestamp losslessly; `sceneParticipants` and `sceneCustomValues` store JSON literals, preserving participant order, duplicates, spelling and custom string values. `settingKey` is a directly queryable literal, separate from `settingWords`. Keys identify application contexts, not notions; sessions may be recorded in custom values. Embedding construction is defined in the [vector payload design](vector_payload_design.md).
The episode owns the scene. `sceneTime` stores its UTC instant losslessly; `sceneParticipants` and `sceneCustomValues` store JSON literals, preserving participant order, duplicates, spelling and custom string values. `settingKey` is a directly queryable literal, separate from `settingWords`. Keys identify application contexts, not notions; sessions may be recorded in custom values. Embedding construction is defined in the [vector payload design](vector_payload_design.md).

`Scene.time` accepts an offset-carrying instant. Episode construction derives `scene_local_date` from the supplied offset, and RDF stores it as the `sceneLocalYear` and `sceneMonthDay` literals. It is not a draft input. A UTC calendar day would invent the experienced day when the supplied offset crosses midnight. Hydration reconstructs the date for replay equality while retaining the existing UTC `sceneTime` representation. The original offset is not reconstructed from the date. An episode without these literals has no local date and never matches an anniversary; its existing instant stays readable, with no migration.

Anniversary selection matches `sceneMonthDay` exactly and compares `sceneLocalYear` with the reference scene's local year. It reads eligible episode IDs with a shared-occasion flag, ordered shared first, then recorded instant descending and ID ascending, and fetches one beyond the contribution limit for the trace marker. Sharing uses both orientations of `Involves` episode links and `Mentions` observation-to-parent-episode links, with the existing retention checks before the cut. Only a resolved scene identity supplies a sharing key; ambiguous names retain their other recall behavior but grant no anniversary reservation. Full episode hydration is bounded to selected roots and their graph expansion. Unshared matches retain date-match provenance while remaining outside that kind's reservation queue.

Commit never substitutes a clock reading for a scene time.

Expand Down
3 changes: 2 additions & 1 deletion docs/design/database/schema_cheat_sheet.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ Predicate names in these tables are suffixes under `urn:cmem:vocab:`.
| `objectId`, `objectType`, `graphUri`, `schemaVersion` | Common identity and schema literals |
| `createdAt`, `updatedAt` | Timestamps on the types that declare them |
| `modality`, `endedAt` | Episode modality and optional interval end |
| `sceneTime` | Required experience time on the episode |
| `sceneTime` | Required experience instant, stored in UTC without precision loss |
| `sceneLocalYear`, `sceneMonthDay` | Local date derived from the supplied scene offset; exact anniversary match and replay equality |
| `sceneParticipants` | Lossless JSON array of participants, each with optional key, name and description; a key or nonblank words required |
| `settingKey`, `settingWords` | Independently optional context key and words; the key is directly queryable |
| `sceneCustomValues` | Lossless JSON object of string custom values |
Expand Down
15 changes: 15 additions & 0 deletions src/adapters/oxigraph/embedded.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,21 @@ impl OxigraphGraphAuthorityStore {

#[async_trait]
impl GraphAuthorityStore for OxigraphGraphAuthorityStore {
async fn query_anniversaries(
&self,
date: chrono::NaiveDate,
participants: &[MemoryId],
limit: usize,
policy: GraphExpansionLifecyclePolicy,
) -> Result<Vec<(MemoryId, bool)>, CustomError> {
SparqlGraphSelectors::new(&self.store).select_anniversaries(
date,
participants,
limit,
policy,
)
}

async fn query_episodes_by_time(
&self,
start: Option<chrono::DateTime<chrono::Utc>>,
Expand Down
20 changes: 19 additions & 1 deletion src/adapters/oxigraph/rdf_mapping.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,11 @@ fn episode_triples(episode: &Episode) -> Vec<RdfTriple> {
enum_value(episode.retention_state),
),
RdfTriple::literal(&subject, vocab::CREATED_AT, timestamp(episode.created_at)),
RdfTriple::literal(&subject, vocab::SCENE_TIME, timestamp(episode.scene.time)),
RdfTriple::literal(
&subject,
vocab::SCENE_TIME,
timestamp(episode.scene.time.to_utc()),
),
RdfTriple::literal(
&subject,
vocab::SCENE_PARTICIPANTS,
Expand All @@ -148,6 +152,20 @@ fn episode_triples(episode: &Episode) -> Vec<RdfTriple> {
.expect("scene custom values contain only strings"),
),
]);
if let Some(date) = episode.scene_local_date {
triples.extend([
RdfTriple::literal(
&subject,
vocab::SCENE_LOCAL_YEAR,
date.format("%Y").to_string(),
),
RdfTriple::literal(
&subject,
vocab::SCENE_MONTH_DAY,
date.format("%m-%d").to_string(),
),
]);
}
push_optional_literal(
&mut triples,
&subject,
Expand Down
12 changes: 11 additions & 1 deletion src/adapters/oxigraph/shared.rs
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,8 @@ pub(super) fn memory_object_from_rdf(
object_type,
modality: enum_literal(subject, values, super::vocabulary::MODALITY)?,
scene: crate::domain::Scene {
time: timestamp_literal(subject, values, super::vocabulary::SCENE_TIME)?,
time: timestamp_literal(subject, values, super::vocabulary::SCENE_TIME)?
.fixed_offset(),
participants: serde_json::from_str(
&values.literal(subject, super::vocabulary::SCENE_PARTICIPANTS)?,
)
Expand All @@ -310,6 +311,15 @@ pub(super) fn memory_object_from_rdf(
rdf_parse_error(subject, super::vocabulary::SCENE_CUSTOM_VALUES, error)
})?,
},
scene_local_date: values
.optional_literal(super::vocabulary::SCENE_LOCAL_YEAR)
.map(|year| {
let month_day = values.literal(subject, super::vocabulary::SCENE_MONTH_DAY)?;
format!("{year}-{month_day}").parse().map_err(|error| {
rdf_parse_error(subject, super::vocabulary::SCENE_LOCAL_YEAR, error)
})
})
.transpose()?,
ended_at: optional_timestamp_literal(values, super::vocabulary::ENDED_AT)?,
summary: values.literal(subject, super::vocabulary::SUMMARY)?,
raw_ref: values.optional_literal(super::vocabulary::RAW_REF),
Expand Down
87 changes: 87 additions & 0 deletions src/adapters/oxigraph/sparql_selectors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,93 @@ impl<'a> SparqlGraphSelectors<'a> {
Ok(occasions)
}

pub(crate) fn select_anniversaries(
&self,
date: chrono::NaiveDate,
participants: &[MemoryId],
limit: usize,
policy: GraphExpansionLifecyclePolicy,
) -> Result<Vec<(MemoryId, bool)>, CustomError> {
if limit == 0 {
return Ok(Vec::new());
}
let shared = if participants.is_empty() {
"false".to_owned()
} else {
let values = participants
.iter()
.map(|id| format!("<{}>", graph_uri(ObjectType::Entity, *id)))
.collect::<Vec<_>>()
.join(" ");
format!(
r#"EXISTS {{
VALUES ?participant {{ {values} }}
{{
GRAPH ?linkGraph {{
?link a <{link_class}> ; <{relation}> "involves" .
{{ ?link <{from}> ?participant ; <{to}> ?episode . }}
UNION {{ ?link <{to}> ?participant ; <{from}> ?episode . }}
}}
}} UNION {{
GRAPH ?linkGraph {{
?link a <{link_class}> ; <{relation}> "mentions" .
{{ ?link <{from}> ?participant ; <{to}> ?neighbor . }}
UNION {{ ?link <{to}> ?participant ; <{from}> ?neighbor . }}
}}
GRAPH ?neighbor {{ ?neighbor <{object_type}> "observation" ; <{episode_pred}> ?episode ; <{retention}> ?neighborRetention . }}
{retention_filter}
}}
}}"#,
link_class = vocab::CLASS_MEMORY_LINK,
from = vocab::FROM,
to = vocab::TO,
relation = vocab::RELATION,
object_type = vocab::OBJECT_TYPE,
episode_pred = vocab::EPISODE,
retention = vocab::RETENTION_STATE,
retention_filter =
occasion_retention_filter(policy).replace("?retention", "?neighborRetention")
)
};
let query = format!(
r#"
PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
SELECT DISTINCT ?episodeId ?time ?shared WHERE {{
GRAPH ?episode {{
?episode <{object_type}> "episode" ; <{object_id}> ?episodeId ;
<{month_day}> {month_day_value} ; <{local_year}> ?localYear ;
<{scene_time}> ?sceneTime ; <{retention}> ?episodeRetention .
}}
FILTER(xsd:integer(?localYear) < {year})
BIND(?episodeRetention AS ?retention)
{retention_filter}
BIND(xsd:dateTime(?sceneTime) AS ?time)
BIND({shared} AS ?shared)
}}
ORDER BY DESC(?shared) DESC(?time) ?episodeId
LIMIT {limit}
"#,
object_type = vocab::OBJECT_TYPE,
object_id = vocab::OBJECT_ID,
month_day = vocab::SCENE_MONTH_DAY,
local_year = vocab::SCENE_LOCAL_YEAR,
month_day_value = sparql_string_literal(&date.format("%m-%d").to_string()),
year = chrono::Datelike::year(&date),
scene_time = vocab::SCENE_TIME,
retention = vocab::RETENTION_STATE,
retention_filter = occasion_retention_filter(policy)
);
self.query_solutions(&query)?
.iter()
.map(|row| {
Ok((
memory_id_binding(row, "episodeId")?,
literal_binding(row, "shared")? == "true",
))
})
.collect()
}

pub(crate) fn select_episodes_by_time(
&self,
start: Option<DateTime<Utc>>,
Expand Down
Loading
Loading