From 3a712252e2d9fa98fbd9af8d8b94c88416284426 Mon Sep 17 00:00:00 2001 From: ebigunso Date: Wed, 30 Sep 2026 08:16:46 +0900 Subject: [PATCH] Forward authored obligation roles and due instants in calibration --- .../bin/calibrate_cue_floors/obligations.rs | 125 ++++++++++++-- .../time_and_obligations.rs | 161 ++++++++++++++++-- .../src/bin/measure_person_state.rs | 1 + crates/cmem-eval-continuity/src/driver.rs | 4 + crates/cmem-eval-locomo/src/ingest.rs | 2 + crates/cmem-eval-runner/src/enrichment.rs | 1 + crates/cmem-eval/src/adapter.rs | 135 +++++++++++++++ crates/cmem-eval/src/memory_adapter.rs | 2 + 8 files changed, 404 insertions(+), 27 deletions(-) diff --git a/crates/cmem-eval-continuity/src/bin/calibrate_cue_floors/obligations.rs b/crates/cmem-eval-continuity/src/bin/calibrate_cue_floors/obligations.rs index a27762b..7ddb9aa 100644 --- a/crates/cmem-eval-continuity/src/bin/calibrate_cue_floors/obligations.rs +++ b/crates/cmem-eval-continuity/src/bin/calibrate_cue_floors/obligations.rs @@ -1,15 +1,15 @@ -//! Authored prospective stories; the base writes the same stories without roles or due instants. +//! Authored prospective stories, forwarded through the public write adapter. use super::*; use std::collections::BTreeSet; const MORNING: &str = "2025-09-09T08:00:00+09:00"; const OWN_TOPIC: &str = "Finishing my watercolor of the harbor"; -pub(super) const METHOD: &str = "Prospective obligations in two new stores, leaving every existing family unchanged. Meeting pressure contains both directions, resolved and fulfilled matters, a future-due trigger control, a self-only undated promise, crowded person state, two present people in both orders, and current/superseded controls. Sixty-four unrelated topic memories and their source occasions compete under native caps; two bounded shared occasions per kind make the cohort reachable within the ordinary root budget. The keyless store has 367 equal-salience daily occasions and several overdue obligations. Authored party assertions and UTC due instants are retained separately; the base writes neither. Every supported parent query executes, while future role/identity/trigger/due behavior stays not_run. After wiring those library inputs, only those role/due fields and the required self constructor argument may differ. Both native ID orders reuse the shared public-ingest census and permutation. F1-F9 are readings for cross-pin comparison, not baseline pass claims; F10 compares all existing families. Native paths, limits and floors are recorded separately from authored expectations. No dates or roles are inferred from text, no gold reaches metadata, no wall clock or behavior threshold changes retrieval."; +pub(super) const METHOD: &str = "Prospective obligations in the existing meeting and daily stores, plus a separate due-priority store. Meeting pressure contains both directions, resolved and fulfilled matters, a future-due trigger control, a self-only undated promise, crowded person state, two present people in both orders, and current/superseded controls. Sixty-four unrelated topic memories and their source occasions compete under native caps; two bounded shared occasions per kind make the cohort reachable within the ordinary root budget. The keyless store has 367 equal-salience daily occasions and several overdue obligations. Authored party assertions and UTC due instants are forwarded through typed public writes; forwarding flags compare the emitted fields with those authored values. Full role/identity/trigger/due cases execute only after that check. A separate due-priority store places a salient stale-overdue commitment beside a less salient due-today commitment under the same loud topic, without changing the existing stores. Due-today takes precedence over overdue in the authored F3 expectation. Both native ID orders reuse the shared public-ingest census and permutation. F1-F9 are readings for cross-pin comparison, not baseline pass claims; F10 compares all existing families. Native paths, limits and floors are recorded separately from authored expectations. No dates or roles are inferred from text, no gold reaches metadata, no wall clock or behavior threshold changes retrieval."; pub(super) fn is_family(family: &Family) -> bool { matches!( family.name.as_str(), - "prospective-meeting" | "prospective-daily" + "prospective-meeting" | "prospective-daily" | "prospective-due-priority" ) } @@ -17,7 +17,7 @@ pub(super) fn falsifiers() -> Value { json!({ "F1":"Each loud-topic retrieval retains the first present person's most salient unresolved obligation; report every other person's count separately.", "F2":"At trigger floor two, the first person's two most salient unresolved obligations in opposite directions both survive.", - "F3":"The most salient unresolved due/overdue obligation survives no-topic and loud-topic queries.", + "F3":"The most salient unresolved obligation due today survives no-topic and loud-topic queries; if none is due today, use the most salient overdue obligation.", "F4":"Due never contributes future-due obligations; Trigger/Due never contribute settled obligations; ordinary admission retains resolved_by. Future-due present-person control comes by Trigger with NotYetDue.", "F5":"The self-only undated promise must not appear on every retrieval.", "F6":"The self-only undated promise appears when its topic is asked.", @@ -138,7 +138,11 @@ fn add_probe( } pub(super) fn meeting(config: &BenchmarkRunConfig) -> Family { - let mut family = empty("prospective-meeting"); + meeting_named(config, "prospective-meeting") +} + +fn meeting_named(config: &BenchmarkRunConfig, name: &str) -> Family { + let mut family = empty(name); family.character_entity = Some("self".into()); for person in ["rowan", "nia", "bob", "sage"] { family.graph.entities.push(EntityInput { @@ -504,6 +508,36 @@ pub(super) fn daily(config: &BenchmarkRunConfig) -> Family { family } +pub(super) fn due_priority(config: &BenchmarkRunConfig) -> Family { + let mut family = meeting_named(config, "prospective-due-priority"); + // A separate store preserves the existing meeting experiment. Only these two + // commitments have a due instant; no present person offers a trigger route. + for obligation in &mut family.obligations { + obligation.due_instant = match obligation.label.as_str() { + "iris-outgoing" => Some("2024-09-08T12:00:00+09:00".into()), + "own-due" => Some("2025-09-09T22:00:00+09:00".into()), + _ => None, + }; + } + for memory in &mut family.graph.derived_memories { + match memory.external_id.as_str() { + "iris-outgoing" => memory.salience_score = 0.99, + "own-due" => memory.salience_score = 0.10, + _ => {} + } + } + family.probes.clear(); + add_probe( + &mut family, + config, + "stale-overdue-vs-today-loud", + &[], + Some(TOPIC), + 1, + ); + family +} + fn settled_or_superseded(family: &Family, id: &str) -> (bool, bool) { let settled = family.graph.links.iter().any(|l| { l.to.external_id == id @@ -554,8 +588,10 @@ pub(super) fn ensure_healthy( ), "incomplete obligations vector recall" ); - if family.name == "prospective-meeting" - && input.topic.as_deref() == Some(TOPIC) + if matches!( + family.name.as_str(), + "prospective-meeting" | "prospective-due-priority" + ) && input.topic.as_deref() == Some(TOPIC) && input.scene.participants.is_empty() { let limits = &input.surface_policy.sections; @@ -646,23 +682,30 @@ pub(super) fn reading( .then_with(|| b.1.salience_score.total_cmp(&a.1.salience_score)) .then_with(|| b.1.created_at.cmp(&a.1.created_at)) }); - let person_rows = input.scene.participants.iter().filter_map(|p| p.key.as_deref()).map(|person| { + let person_rows = input.scene.participants.iter().filter_map(|p| p.key.as_deref()).enumerate().map(|(position, person)| { let expected = eligible.iter().filter(|(o, _, _)| o.actor_subjects.iter().chain(&o.counterpart_subjects).any(|p| p == person)).collect::>(); - let all = family.obligations.iter().filter(|o| o.actor_subjects.iter().chain(&o.counterpart_subjects).any(|p| p == person)); + let selected_obligations = family.obligations.iter().filter(|o| o.actor_subjects.iter().chain(&o.counterpart_subjects).any(|p| p == person) && contains(&o.memory_external_id)).map(|o| &o.memory_external_id).collect::>(); let state = family.graph.derived_memories.iter().filter(|m| !matches!(m.derived_type, DerivedType::OpenLoop | DerivedType::Commitment) && m.entity_external_ids.iter().any(|p| p == person)).filter(|m| contains(&m.external_id)).map(|m| &m.external_id).collect::>(); - json!({"person":person,"most_salient_unresolved":expected.first().map(|(o,_,_)| &o.label), + json!({"person":person,"present_person_position":position+1,"most_salient_unresolved":expected.first().map(|(o,_,_)| &o.label), "first_two_unresolved":expected.iter().take(2).map(|(o,_,_)| json!({"label":o.label,"external_id":o.memory_external_id,"selected":contains(&o.memory_external_id)})).collect::>(), - "selected_obligation_count":all.filter(|o| contains(&o.memory_external_id)).count(),"non_obligation_state_count":state.len(),"non_obligation_state_ids":state}) + "selected_obligation_count":selected_obligations.len(),"selected_obligation_ids":selected_obligations,"non_obligation_state_count":state.len(),"non_obligation_state_ids":state}) }).collect::>(); - let top_due = eligible.iter().find(|(o, _, _)| { - o.due_instant.as_deref().is_some_and(|due| { + let due_day = |o: &Obligation| { + o.due_instant.as_deref().map(|due| { timestamp(due) .unwrap() .with_timezone(reference.offset()) .date_naive() - <= reference.date_naive() }) - }); + }; + let top_due = eligible + .iter() + .find(|(o, _, _)| due_day(o) == Some(reference.date_naive())) + .or_else(|| { + eligible + .iter() + .find(|(o, _, _)| due_day(o).is_some_and(|day| day < reference.date_naive())) + }); let topic_ids = family .topic_targets .iter() @@ -716,7 +759,7 @@ pub(super) fn reading( "native_candidate_limits":native::RetrievalCandidateLimits::default(),"native_graph_limits":native::RetrievalGraphLimits::default(), "native_section_limits":native::ContinuitySectionLimits::default(),"executed_floors":input.cue_floors.unwrap_or_default(), "executed_surface_policy":input.surface_policy, - "full_prospective_status":"not_run","role_and_due_fields_forwarded":false, + "full_prospective_status":if prospective_fields_forwarded(family) { "executed" } else { "not_run" },"role_and_due_fields_forwarded":prospective_fields_forwarded(family), "basis":"Counts and eligibility are authored comparison cohorts; selection, root source, admission road, scores and resolution are native observations. First-person order is the caller's order. Supersession priority describes selector/floor expectations, never a claim about final pack order."}), ) } @@ -725,6 +768,56 @@ pub(super) fn reading( mod tests { use super::*; + #[test] + fn due_priority_uses_one_due_seat_under_unchanged_topic_pressure() { + let family = due_priority(&config()); + let due = family + .obligations + .iter() + .filter(|o| o.due_instant.is_some()) + .collect::>(); + assert_eq!(due.len(), 2); + let overdue = due.iter().find(|o| o.label == "iris-outgoing").unwrap(); + let today = due.iter().find(|o| o.label == "own-due").unwrap(); + let reference = timestamp(MORNING).unwrap(); + assert!( + timestamp(overdue.due_instant.as_deref().unwrap()).unwrap() + < reference - Duration::days(365) + ); + assert_eq!( + timestamp(today.due_instant.as_deref().unwrap()) + .unwrap() + .with_timezone(reference.offset()) + .date_naive(), + reference.date_naive() + ); + let memory = |o: &Obligation| { + family + .graph + .derived_memories + .iter() + .find(|m| m.external_id == o.memory_external_id) + .unwrap() + }; + assert_eq!(memory(overdue).derived_type, DerivedType::Commitment); + assert_eq!(memory(today).derived_type, DerivedType::Commitment); + assert!(memory(overdue).salience_score > memory(today).salience_score); + assert_eq!(family.probes.len(), 1); + assert!( + family.probes[0] + .supported_input + .scene + .participants + .is_empty() + ); + assert_eq!( + family.probes[0].supported_input.topic.as_deref(), + Some(TOPIC) + ); + assert_eq!(native::RetrievalCueFloors::default().due, 1); + assert_eq!(family.topic_targets, meeting(&config()).topic_targets); + } + #[test] fn pressure_admission_requires_actual_section_saturation() { let family = meeting(&config()); diff --git a/crates/cmem-eval-continuity/src/bin/calibrate_cue_floors/time_and_obligations.rs b/crates/cmem-eval-continuity/src/bin/calibrate_cue_floors/time_and_obligations.rs index 24c8864..b77d92a 100644 --- a/crates/cmem-eval-continuity/src/bin/calibrate_cue_floors/time_and_obligations.rs +++ b/crates/cmem-eval-continuity/src/bin/calibrate_cue_floors/time_and_obligations.rs @@ -24,7 +24,11 @@ pub(super) async fn run_obligations( let mut result = run_families( stores, config, - &[obligations::meeting, obligations::daily], + &[ + obligations::meeting, + obligations::daily, + obligations::due_priority, + ], false, timings, self_notion_ids, @@ -77,7 +81,7 @@ struct PlannedProbe { struct Obligation { label: String, memory_external_id: String, - // Planned assertion subjects, not inferred labels and not current native fields. + // Authored assertion subjects, never inferred from text. actor_subjects: Vec, counterpart_subjects: Vec, due_instant: Option, @@ -96,6 +100,67 @@ struct Family { topic_targets: Vec, } +fn obligation_assertions(obligation: &Obligation) -> Vec { + obligation + .actor_subjects + .iter() + .map(|subject| (subject, cmem_eval::BeliefPredicate::Actor)) + .chain( + obligation + .counterpart_subjects + .iter() + .map(|subject| (subject, cmem_eval::BeliefPredicate::Counterpart)), + ) + .map(|(subject, predicate)| cmem_eval::BeliefAssertionInput { + subject_external_id: subject.clone(), + predicate, + }) + .collect() +} + +fn forward_obligations(family: &mut Family) -> Result<()> { + for obligation in &family.obligations { + let memory = family + .graph + .derived_memories + .iter_mut() + .find(|memory| memory.external_id == obligation.memory_external_id) + .context("authored obligation has no derived write")?; + ensure!( + memory.assertions.is_empty() && memory.due_at.is_none(), + "prospective fields already authored on write" + ); + memory.assertions = obligation_assertions(obligation); + memory.due_at = obligation + .due_instant + .as_deref() + .map(timestamp) + .transpose()? + .map(|at| at.with_timezone(&Utc)); + } + Ok(()) +} + +fn prospective_fields_forwarded(family: &Family) -> bool { + !family.obligations.is_empty() + && family.character_entity.is_some() + && family.obligations.iter().all(|obligation| { + family + .graph + .derived_memories + .iter() + .find(|memory| memory.external_id == obligation.memory_external_id) + .is_some_and(|memory| { + memory.assertions == obligation_assertions(obligation) + && memory.due_at + == obligation + .due_instant + .as_deref() + .map(|at| timestamp(at).unwrap().with_timezone(&Utc)) + }) + }) +} + fn timestamp(value: &str) -> Result> { chrono::DateTime::parse_from_rfc3339(value).context("authored fixed timestamp") } @@ -440,6 +505,7 @@ fn derived( salience: f32, ) -> DerivedMemoryInput { DerivedMemoryInput { + due_at: None, external_id: id.into(), created_at: Some(at.into()), derived_type: kind, @@ -1265,7 +1331,7 @@ fn obligation_reading( "native_resolved_by":resolvers,"native_direction":included.and_then(|item| item.get("direction")), "native_due_state":included.and_then(|item| item.get("due_state")), "planned_roles":obligation,"authored_due_state_if_forwarded":authored_due, - "role_and_due_fields_forwarded":false})); + "role_and_due_fields_forwarded":prospective_fields_forwarded(family)})); } Ok(json!(rows)) } @@ -1273,9 +1339,23 @@ fn obligation_reading( fn supported_probe_input( probe: &PlannedProbe, anniversary_available: bool, + prospective_available: bool, ) -> Result<(RetrieveInput, Vec)> { let mut input = probe.supported_input.clone(); let mut missing = probe.required_routes.clone(); + if prospective_available { + missing.retain(|route| { + !matches!( + route.as_str(), + "actor_counterpart" + | "character_identity" + | "trigger" + | "due_instant" + | "due" + | "direction" + ) + }); + } if let Some(floor) = probe.trigger_floor { let mut floors = serde_json::to_value(input.cue_floors.unwrap_or_default())?; if let Some(trigger) = floors.get_mut("trigger") { @@ -1483,7 +1563,11 @@ async fn measure( comparison, )) .await?; - let (input, missing) = supported_probe_input(probe, anniversary_available)?; + let (input, missing) = supported_probe_input( + probe, + anniversary_available, + prospective_fields_forwarded(family), + )?; let executed = if probe.required_routes.is_empty() { projection.clone() } else if missing.is_empty() { @@ -1706,7 +1790,11 @@ fn timing_queries( probe.supported_input.clone(), )); if !probe.required_routes.is_empty() { - let (input, missing) = supported_probe_input(probe, anniversary_available)?; + let (input, missing) = supported_probe_input( + probe, + anniversary_available, + prospective_fields_forwarded(family), + )?; if missing.is_empty() { queries.push((format!("{}/full", probe.name), input)); } @@ -1726,7 +1814,8 @@ async fn run_families( let mut inputs = Vec::new(); let mut measurements = Vec::new(); for build in builders { - let original = build(config); + let mut original = build(config); + forward_obligations(&mut original)?; let mut next = original.clone(); let mut order = Value::Null; for opposed_order in [false, true] { @@ -1808,7 +1897,7 @@ async fn run_families( Ok(json!({"inputs":inputs,"measurements":measurements, "full_cases_executed":executed,"full_cases_not_run":not_run,"supported_parent_controls_executed":executed+not_run, "same_day_description_continuity":["/keyless_measurements","/opposed_keyless_measurements"], - "method":"Fixed generated intent for time/prospective plans; original and native-ID-opposed inputs. Unsupported fields are retained as typed planned intent and reported not_run. Each row separately executes a supported parent control with roles, due instant, range and explicit recency floor absent. executed_case records the full supported case; recency overrides and caller-given ranges run only when serialized native types advertise their fields; the adapter checks typed native range admission. Native range echo and has-more are separate from authored range membership. Anniversary support requires a range-free native sentinel to attempt the shared prior-year date_match root; --require-anniversary fails the run if that witness is absent; reference scene and date-match admissions are native, while local dates and anniversary identities are authored expectations. Missing fields remain not_run; no unavailable predicate is silently forwarded. Native resolution is reported; authored due classification is labelled hypothetical. Topic counts use eight graded episodes in each original family; added falsifiers use separate composition and spillover readings. No behavioral pass/fail threshold. No clock, paid calls or fixture changes."})) + "method":"Fixed generated intent for time/prospective plans; original and native-ID-opposed inputs. Unsupported fields are retained as typed planned intent and reported not_run. Authored roles and due instants are forwarded through typed public writes in obligation families. Each row separately executes its recorded parent request with range and explicit floor overrides absent. executed_case records the full supported case; recency overrides and caller-given ranges run only when serialized native types advertise their fields; the adapter checks typed native range admission. Native range echo and has-more are separate from authored range membership. Anniversary support requires a range-free native sentinel to attempt the shared prior-year date_match root; --require-anniversary fails the run if that witness is absent; reference scene and date-match admissions are native, while local dates and anniversary identities are authored expectations. Missing fields remain not_run; no unavailable predicate is silently forwarded. Native resolution, direction and due state are reported separately from authored due classification; forwarding flags compare the actual write fields with authored inputs. Topic counts use eight graded episodes in each original family; added falsifiers use separate composition and spillover readings. No behavioral pass/fail threshold. No clock, paid calls or fixture changes."})) } #[cfg(test)] @@ -1817,6 +1906,51 @@ mod tests { use cmem_eval::character_memory::MemoryObjectRef; use uuid::Uuid; + #[test] + fn prospective_forwarding_changes_only_authored_write_fields() { + for mut family in [ + obligations_family(&config()), + obligations::meeting(&config()), + obligations::daily(&config()), + obligations::due_priority(&config()), + ] { + let before = serde_json::to_value(&family).unwrap(); + assert!(!prospective_fields_forwarded(&family)); + forward_obligations(&mut family).unwrap(); + assert!(prospective_fields_forwarded(&family)); + for probe in &family.probes { + assert!( + supported_probe_input(probe, false, true) + .unwrap() + .1 + .is_empty() + ); + } + let ids = family + .experiences + .iter() + .map(|e| &e.write.episode_external_id) + .chain(family.graph.derived_memories.iter().map(|m| &m.external_id)) + .enumerate() + .map(|(n, id)| (id.clone(), format!("native-{n:04}"))) + .collect(); + let (opposed, _) = opposed(&family, &ids).unwrap(); + assert!(prospective_fields_forwarded(&opposed)); + for memory in &mut family.graph.derived_memories { + if family + .obligations + .iter() + .any(|o| o.memory_external_id == memory.external_id) + { + memory.assertions.clear(); + memory.due_at = None; + } + } + assert_eq!(serde_json::to_value(&family).unwrap(), before); + assert!(!prospective_fields_forwarded(&family)); + } + } + #[test] fn timed_controls_reuse_complete_recorded_requests() { let config = config(); @@ -1909,7 +2043,7 @@ mod tests { .iter() .find(|p| p.name == "anniversary-local-morning") .unwrap(); - let (_, missing) = supported_probe_input(anniversary, available).unwrap(); + let (_, missing) = supported_probe_input(anniversary, available, false).unwrap(); assert_eq!(missing.is_empty(), available); assert!(require_anniversary(available, false).is_ok()); assert_eq!(require_anniversary(available, true).is_ok(), available); @@ -1985,9 +2119,14 @@ mod tests { reference.date_naive(), reference.with_timezone(&Utc).date_naive() ); - assert!(supported_probe_input(morning, true).unwrap().1.is_empty()); + assert!( + supported_probe_input(morning, true, false) + .unwrap() + .1 + .is_empty() + ); assert_eq!( - supported_probe_input(morning, false).unwrap().1, + supported_probe_input(morning, false, false).unwrap().1, ["date_match"] ); let names = time @@ -2143,7 +2282,7 @@ mod tests { let family = time_family(&config()); let defaults = serde_json::to_value(native::RetrievalCueFloors::default()).unwrap(); for probe in family.probes.iter().filter(|p| p.recency_floor.is_some()) { - let (mapped, missing) = supported_probe_input(probe, false).unwrap(); + let (mapped, missing) = supported_probe_input(probe, false, false).unwrap(); if defaults.get("recency").is_some() { assert!(missing.is_empty(), "advertised recency must execute"); let mut actual = serde_json::to_value(&mapped).unwrap(); diff --git a/crates/cmem-eval-continuity/src/bin/measure_person_state.rs b/crates/cmem-eval-continuity/src/bin/measure_person_state.rs index 5de6c52..7430151 100644 --- a/crates/cmem-eval-continuity/src/bin/measure_person_state.rs +++ b/crates/cmem-eval-continuity/src/bin/measure_person_state.rs @@ -176,6 +176,7 @@ fn generated() -> ( 30 + index % 300 }; graph.derived_memories.push(DerivedMemoryInput { + due_at: None, external_id: id, created_at: Some( (AT.parse::>().unwrap() - Duration::days(age_days)) diff --git a/crates/cmem-eval-continuity/src/driver.rs b/crates/cmem-eval-continuity/src/driver.rs index 49bd10d..a0e5832 100644 --- a/crates/cmem-eval-continuity/src/driver.rs +++ b/crates/cmem-eval-continuity/src/driver.rs @@ -152,6 +152,7 @@ fn map_situated_input( _ => bail!("unsupported derived subtype passed the feature gate"), }; input.derived_memories.push(DerivedMemoryInput { + due_at: None, external_id, created_at: Some(timestamp), derived_type, @@ -432,6 +433,7 @@ pub async fn run_continuity_scenario( .entities .iter() .map(|entity| DerivedMemoryInput { + due_at: None, external_id: naming_belief_external_id(&entity.external_id), created_at: None, derived_type: DerivedType::Claim, @@ -799,6 +801,7 @@ pub async fn run_continuity_scenario( namespace: scenario.namespace.clone(), threads, derived_memories: vec![DerivedMemoryInput { + due_at: None, created_at: None, external_id: derived_external_id.clone(), derived_type: DerivedType::Reflection, @@ -876,6 +879,7 @@ pub async fn run_continuity_scenario( targets: vec![target], replacements: vec![ReplacementDerivedMemoryInput { memory: DerivedMemoryInput { + due_at: None, created_at: None, external_id: replacement_external_id.clone(), derived_type: DerivedType::Reflection, diff --git a/crates/cmem-eval-locomo/src/ingest.rs b/crates/cmem-eval-locomo/src/ingest.rs index fce8c53..2f2caca 100644 --- a/crates/cmem-eval-locomo/src/ingest.rs +++ b/crates/cmem-eval-locomo/src/ingest.rs @@ -85,6 +85,7 @@ pub fn to_memory_inputs( .filter(|summary| !summary.is_empty()) { derived_memories.push(DerivedMemoryInput { + due_at: None, created_at: None, external_id: format!("{}:derived:session_summary", session.session_id), derived_type: DerivedType::Reflection, @@ -109,6 +110,7 @@ pub fn to_memory_inputs( continue; } derived_memories.push(DerivedMemoryInput { + due_at: None, created_at: None, external_id: format!( "{}:derived:generated_observation:{}", diff --git a/crates/cmem-eval-runner/src/enrichment.rs b/crates/cmem-eval-runner/src/enrichment.rs index 59e6ebe..f5f4489 100644 --- a/crates/cmem-eval-runner/src/enrichment.rs +++ b/crates/cmem-eval-runner/src/enrichment.rs @@ -260,6 +260,7 @@ mod tests { let input = GraphEnrichmentInput { namespace: "n".into(), derived_memories: vec![DerivedMemoryInput { + due_at: None, created_at: None, external_id: "dm1".into(), derived_type: DerivedType::Reflection, diff --git a/crates/cmem-eval/src/adapter.rs b/crates/cmem-eval/src/adapter.rs index 632f3c8..0913170 100644 --- a/crates/cmem-eval/src/adapter.rs +++ b/crates/cmem-eval/src/adapter.rs @@ -981,6 +981,7 @@ impl CharacterMemoryAdapter { let mut draft = DerivedMemoryDraft::new(memory.derived_type, memory.text); draft.id = Some(id); draft.created_at = parse_timestamp(memory.created_at.as_deref())?; + draft.due_at = memory.due_at; draft.derived_from_episode_ids = resolve_ids( "episode", &memory.source_episode_external_ids, @@ -2233,6 +2234,7 @@ fn replacement_to_live( let memory = &input.memory; let mut draft = ReplacementDerivedMemoryDraft::new(memory.derived_type, memory.text.clone()); draft.id = Some(id); + draft.due_at = memory.due_at; if memory.created_at.is_some() { bail!( "the library correction draft cannot carry created_at for {:?}", @@ -3015,6 +3017,7 @@ mod tests { external_id: "ada".into(), }], derived_memories: vec![DerivedMemoryInput { + due_at: None, external_id: "ada-name".into(), created_at: None, derived_type: DerivedType::Claim, @@ -3083,6 +3086,135 @@ mod tests { adapter.cleanup_namespace("names").await.unwrap(); } + #[tokio::test] + async fn obligation_roles_and_due_survive_public_write_and_correction() { + let directory = tempdir().unwrap(); + let namespace = "obligation-fields"; + let config = adapter_config(namespace.into()); + let adapter = CharacterMemoryAdapter::new_with_binding( + directory.path(), + &config, + EmbeddingRuntimeBinding::Live { + provider: LiveEmbeddingProvider::Deterministic, + model: config.backend.embedding.model.clone(), + }, + Some("alice-entity"), + ) + .await + .unwrap(); + seed_restart_namespace(&adapter, namespace).await; + let mut memory = restart_correction(namespace).replacements.remove(0).memory; + assert!( + serde_json::to_value(&memory) + .unwrap() + .get("due_at") + .is_none() + ); + memory.external_id = "promise".into(); + memory.derived_type = DerivedType::Commitment; + memory.text = "I promised Bob a sketch of the harbor.".into(); + memory.created_at = Some("2025-01-02T00:00:00Z".into()); + memory.due_at = Some("2099-09-09T13:00:00Z".parse().unwrap()); + memory.supersedes_external_ids.clear(); + memory.entity_external_ids.push("bob".into()); + memory.assertions = vec![ + crate::BeliefAssertionInput { + subject_external_id: "alice-entity".into(), + predicate: crate::BeliefPredicate::Actor, + }, + crate::BeliefAssertionInput { + subject_external_id: "bob".into(), + predicate: crate::BeliefPredicate::Counterpart, + }, + ]; + let write = adapter + .remember_enrichment(GraphEnrichmentInput { + namespace: namespace.into(), + entities: vec![EntityInput { + external_id: "bob".into(), + }], + derived_memories: vec![memory.clone()], + ..Default::default() + }) + .await + .unwrap() + .unwrap(); + assert!(write.vector_indexing_failure.is_none()); + assert!(write.stats_update_status.failure.is_none()); + let query = RetrieveInput { + mode: RetrievalMode::Hybrid, + namespace: namespace.into(), + topic: None, + scene: crate::MemorySceneInput { + time: Some("2099-09-09T08:00:00+09:00".into()), + participants: vec![crate::SceneParticipantInput { + key: Some("bob".into()), + ..Default::default() + }], + ..Default::default() + }, + activity: None, + time_range: None, + lifecycle_policy: None, + cue_floors: None, + surface_policy: RetrievalSurfacePolicy::default(), + }; + for (id, expected_due_state) in [ + ("promise", character_memory::api::types::DueState::DueToday), + ( + "revised-promise", + character_memory::api::types::DueState::NotYetDue, + ), + ] { + if id == "revised-promise" { + memory.external_id = id.into(); + memory.created_at = None; + memory.text = "I promised Bob the revised harbor sketch tomorrow.".into(); + memory.due_at = Some("2099-09-10T00:00:00Z".parse().unwrap()); + memory.supersedes_external_ids = vec!["promise".into()]; + let mut correction = restart_correction(namespace); + correction.targets = vec![CorrectionTargetInput::DerivedMemory { + external_id: "promise".into(), + }]; + correction.superseded_derived_memory_external_ids = vec!["promise".into()]; + correction.replacements[0].memory = memory.clone(); + let write = adapter.correct(correction).await.unwrap(); + assert!(write.outcome.vector_maintenance_failure.is_none()); + assert!(write.outcome.stats_update_status.failure.is_none()); + } + let pack = adapter.retrieve(query.clone()).await.unwrap(); + let included = pack.outcomes()[0] + .pack + .commitments + .iter() + .find(|row| row.memory.id == deterministic_id(namespace, "derived_memory", id)) + .unwrap(); + assert_eq!(included.memory.due_at, memory.due_at); + assert_eq!( + included.direction, + Some(character_memory::api::types::ObligationDirection::OwedByCharacter) + ); + assert_eq!(included.due_state, Some(expected_due_state)); + let expected_assertions = [ + character_memory::BeliefAssertion { + subject: adapter.self_notion_id(namespace), + predicate: crate::BeliefPredicate::Actor, + }, + character_memory::BeliefAssertion { + subject: deterministic_id(namespace, "entity", "bob"), + predicate: crate::BeliefPredicate::Counterpart, + }, + ]; + assert_eq!(included.memory.assertions.len(), expected_assertions.len()); + assert!( + expected_assertions + .iter() + .all(|assertion| included.memory.assertions.contains(assertion)) + ); + } + adapter.cleanup_namespace(namespace).await.unwrap(); + } + #[tokio::test] async fn remembered_metadata_and_speaker_never_reach_stores_or_context() { let directory = tempdir().unwrap(); @@ -3129,6 +3261,7 @@ mod tests { .remember_enrichment(GraphEnrichmentInput { namespace: namespace.into(), derived_memories: vec![DerivedMemoryInput { + due_at: None, created_at: None, external_id: "derived".into(), derived_type: DerivedType::Reflection, @@ -4143,6 +4276,7 @@ mod tests { external_id: "alice-entity".to_string(), }], derived_memories: vec![DerivedMemoryInput { + due_at: None, created_at: None, external_id: "pre-correction-memory".to_string(), derived_type: DerivedType::Reflection, @@ -4197,6 +4331,7 @@ mod tests { }], replacements: vec![ReplacementDerivedMemoryInput { memory: DerivedMemoryInput { + due_at: None, created_at: None, external_id: "corrected-memory".to_string(), derived_type: DerivedType::Reflection, diff --git a/crates/cmem-eval/src/memory_adapter.rs b/crates/cmem-eval/src/memory_adapter.rs index fde56b8..e321208 100644 --- a/crates/cmem-eval/src/memory_adapter.rs +++ b/crates/cmem-eval/src/memory_adapter.rs @@ -94,6 +94,8 @@ pub struct DerivedMemoryInput { pub external_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub created_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub due_at: Option>, pub derived_type: DerivedType, pub text: String, #[serde(default)]