diff --git a/crates/mind-body/src/bin/mind-heartbeat.rs b/crates/mind-body/src/bin/mind-heartbeat.rs index 96e303f..b394860 100644 --- a/crates/mind-body/src/bin/mind-heartbeat.rs +++ b/crates/mind-body/src/bin/mind-heartbeat.rs @@ -61,12 +61,7 @@ fn config() -> Result { .map(PersonaId::parse) .collect::>>()?; let operators = required("MIND_PERSONA_OPERATOR")?.split(',').map(|id| id.trim().to_string()).filter(|id| !id.is_empty()).collect(); - // The nightly sleep is both settings or neither. - let night = match (setting("HEARTBEAT_SLEEP_UTC"), setting("HEARTBEAT_SLEEP_MINDS")) { - (Some(window), Some(minds)) => Some(Night::parse(&window, &minds)?), - (None, None) => None, - _ => bail!("HEARTBEAT_SLEEP_UTC and HEARTBEAT_SLEEP_MINDS are set together or not at all"), - }; + let night = Night::from_settings(setting("HEARTBEAT_SLEEP_UTC").as_deref(), setting("HEARTBEAT_SLEEP_MINDS").as_deref())?; Config::new(personas, operators, night) } diff --git a/crates/mind-body/src/heartbeat.rs b/crates/mind-body/src/heartbeat.rs index b6af0a5..67d5d42 100644 --- a/crates/mind-body/src/heartbeat.rs +++ b/crates/mind-body/src/heartbeat.rs @@ -119,6 +119,16 @@ pub struct Night { } impl Night { + /// The nightly sleep from the two settings that name it: both or neither, so a half-set deploy fails at start + /// instead of quietly never sleeping, or sleeping no one. + pub fn from_settings(window: Option<&str>, minds: Option<&str>) -> Result> { + match (window, minds) { + (Some(window), Some(minds)) => Self::parse(window, minds).map(Some), + (None, None) => Ok(None), + _ => bail!("HEARTBEAT_SLEEP_UTC and HEARTBEAT_SLEEP_MINDS are set together or not at all"), + } + } + /// `window` is `HH:MM-HH:MM` in UTC, `minds` the slugs comma-separated. pub fn parse(window: &str, minds: &str) -> Result { let minute = |text: &str| -> Result { diff --git a/crates/mind-body/src/heartbeat/tests.rs b/crates/mind-body/src/heartbeat/tests.rs index 1e3bc87..b65f0e3 100644 --- a/crates/mind-body/src/heartbeat/tests.rs +++ b/crates/mind-body/src/heartbeat/tests.rs @@ -1457,6 +1457,37 @@ fn the_window_wraps_midnight_and_names_when_it_opened() { assert!(Night::parse("13:00-13:00", "ann").is_err() && Night::parse("25:00-03:00", "ann").is_err() && Night::parse("13:00-14:00", " , ").is_err()); } +#[test] +fn a_window_keeps_its_minutes_and_its_bounds_are_the_clock() { + let on = |hour, minute| Utc.with_ymd_and_hms(2026, 10, 9, hour, minute, 0).unwrap(); + let night = Night::parse("22:30-04:15", "ann").unwrap(); + assert_eq!((night.from_min, night.to_min), (22 * 60 + 30, 4 * 60 + 15)); + assert_eq!(night.opened(on(22, 29)), None); + assert_eq!(night.opened(on(22, 30)), Some(on(22, 30))); + assert_eq!(night.opened(on(4, 14)), Some(on(22, 30) - Duration::days(1))); + assert_eq!(night.opened(on(4, 15)), None); + // The last minute of the day is a time, and the first one past it is not. + let late = Night::parse("23:59-00:00", "ann").unwrap(); + assert_eq!((late.from_min, late.to_min), (23 * 60 + 59, 0)); + assert_eq!(Night::parse("00:00-23:00", "ann").unwrap().to_min, 23 * 60); + assert_eq!(Night::parse("12:00-12:59", "ann").unwrap().to_min, 12 * 60 + 59); + for bad in ["24:00-03:00", "22:00-24:00", "22:60-03:00", "22:00-03:60", "22:00", "22-03", "aa:bb-03:00"] { + assert!(Night::parse(bad, "ann").is_err(), "{bad}"); + } +} + +#[test] +fn the_nightly_sleep_is_both_settings_or_neither() { + assert_eq!(Night::from_settings(None, None).unwrap(), None); + let night = Night::from_settings(Some("22:30-04:00"), Some("ann, bob")).unwrap().unwrap(); + assert_eq!((night.from_min, night.to_min, night.minds), (22 * 60 + 30, 4 * 60, vec![Slug("ann".into()), Slug("bob".into())])); + for only in [Night::from_settings(Some("22:30-04:00"), None), Night::from_settings(None, Some("ann"))] { + let fault = only.expect_err("a half-set night is refused at start").to_string(); + assert!(fault.contains("HEARTBEAT_SLEEP_UTC") && fault.contains("HEARTBEAT_SLEEP_MINDS"), "{fault}"); + } + assert!(Night::from_settings(Some("nonsense"), Some("ann")).is_err(), "both set, but not a window"); +} + #[test] fn a_listed_idle_mind_sleeps_once_per_window_and_a_missed_night_is_not_made_up() { let rig = nights("13:00-14:00", "ann"); @@ -1466,6 +1497,8 @@ fn a_listed_idle_mind_sleeps_once_per_window_and_a_missed_night_is_not_made_up() // Busy while the night lasts: nothing more. Then her night ends (failed or slept, it counts) and the window is spent for her. assert_eq!(rig.poll(), Outcome::Polled { launched: vec![] }); end_launched(&rig, ANN); + // Her unit is gone as well, so she is idle and only her last Sleep run says the window is spent. + rig.launcher.kill_as(&Slug("ann".into()), RunTurn::Sleep); assert_eq!(rig.poll(), Outcome::Polled { launched: vec![] }, "one night per window, whatever became of it"); assert_eq!(started_turns(&rig).len(), 1); // The next window opens a day later and she sleeps again. diff --git a/crates/mind-body/src/persona/tests.rs b/crates/mind-body/src/persona/tests.rs index ecb4620..e6f09fc 100644 --- a/crates/mind-body/src/persona/tests.rs +++ b/crates/mind-body/src/persona/tests.rs @@ -7120,7 +7120,7 @@ fn the_night_line_holds_the_day_whole_and_prescribes_nothing() { // An earlier night of hers, opened 30 hours before this one, closed. rig.mind.admit_at(at(NIGHT_AT_MS - 30 * HOUR_MS)); rig.mind.committed(vec![crate::testkit::run("slept-before", RunTurn::Sleep, RunOperator::Mind, &[])]); - rig.mind.committed(vec![crate::testkit::close(crate::testkit::run_ref("slept-before"), crate::testkit::recorded())]); + rig.mind.committed(vec![crate::testkit::close(crate::testkit::run_ref("slept-before"), ResolutionOutcome::Recorded { reason: "slept: ok".into() })]); // Her day: after that night opened. A Sleep-origin memory of it is not part of it. rig.remembered("d1", MemorySort::Decided, "Ship on Friday.", &["@op:chat"], room(), 20, &["$e1"]); rig.remembered("d2", MemorySort::Learned, "A private thing.", &["@kiko:chat"], dm("!dm:chat", &["@kiko:chat"]), 10, &[]); diff --git a/crates/mind-body/src/sleep.rs b/crates/mind-body/src/sleep.rs index 6a5e78a..4655bb2 100644 --- a/crates/mind-body/src/sleep.rs +++ b/crates/mind-body/src/sleep.rs @@ -25,7 +25,7 @@ use crate::persona::{ ABOUT_MAX, AGENT, Config, Conversation, Failure, Organs, Outcome, PERSONA_POLL_S, REMEMBER_TEXT_MAX_BYTES, Remember, SOURCES_MAX, Started, WHY_MAX_CHARS, date, label_of, next_sequence, remember_item_schema, start_session, structured_output, }; -use crate::queue::{MindPort, any_of, headers, in_force, of_kind}; +use crate::queue::{MindPort, any_of, citing, headers, in_force, of_kind}; use crate::recall; use crate::self_run::session::{Polled, SessionInbox, drive}; use crate::self_run::{SESSION_NAMESPACE, Why}; @@ -153,60 +153,106 @@ fn parse_time(at: &str) -> Result> { Ok(DateTime::parse_from_rfc3339(at).map_err(|_| anyhow!("an admission time could not be read"))?.with_timezone(&Utc)) } -/// Her memories in force, oldest admitted first: the one read of what she holds. -fn memories_in_force(mind: &dyn MindPort) -> Result> { +/// Every document a selection holds, paged whole. +fn documents(mind: &dyn MindPort, selection: &Selection) -> Result> { let mut found = Vec::new(); - let mut asking = Selection { projection: "document".into(), ..in_force(of_kind(PipelineKind::Memory)) }; + let mut asking = Selection { projection: "document".into(), ..selection.clone() }; loop { let page = mind.query(&asking)?; let PipelinePageItems::Documents(views) = page.items else { bail!("the mind answered headers to a document selection") }; - for view in views { - if let PipelineDocument::Memory(memory) = view.document { - found.push(Held { id: view.id.id.0, memory, at: parse_time(&view.admission.admitted_at)?, ordinal: view.admission.ordinal }); - } - } + found.extend(views); match page.next { Some(cursor) => asking.cursor = Some(cursor), None => break, } } + Ok(found) +} + +/// Her memories in force, oldest admitted first: the one read of what she holds. +fn memories_in_force(mind: &dyn MindPort) -> Result> { + let mut found = Vec::new(); + for view in documents(mind, &in_force(of_kind(PipelineKind::Memory)))? { + if let PipelineDocument::Memory(memory) = view.document { + found.push(Held { id: view.id.id.0, memory, at: parse_time(&view.admission.admitted_at)?, ordinal: view.admission.ordinal }); + } + } found.sort_by_key(|held| held.ordinal); Ok(found) } -/// Her Sleep runs, any status, oldest first, with when each was admitted. -fn sleeps(mind: &dyn MindPort) -> Result)>> { +/// One of her Sleep runs: which, when it was admitted, and its admission ordinal. +struct Night { + run: PipelineRef, + at: DateTime, + ordinal: u64, +} + +/// Her Sleep runs, any status, oldest first. +fn sleeps(mind: &dyn MindPort) -> Result> { let runs = any_of(any_of(any_of(of_kind(PipelineKind::Run), "root", &[&mind.instance().0]), "turn", &["Sleep"]), "operated_by", &["Mind"]); - let mut found = headers(mind, &runs)?.into_iter().map(|run| Ok((run.id, run.admission.ordinal, parse_time(&run.admission.admitted_at)?))).collect::>>()?; - found.sort_by_key(|(_, ordinal, _)| *ordinal); - Ok(found.into_iter().map(|(id, _, at)| (id, at)).collect()) + let mut found = headers(mind, &runs)?.into_iter().map(|run| Ok(Night { run: run.id, ordinal: run.admission.ordinal, at: parse_time(&run.admission.admitted_at)? })).collect::>>()?; + found.sort_by_key(|night| night.ordinal); + Ok(found) +} + +const SLEPT: &str = "slept: "; +const THROUGH: &str = "; day through ordinal "; + +/// Where the newest of `earlier` that closed `slept:` left her day: the admission ordinal of the last memory of +/// the day it was shown (its own run's ordinal when it was shown none). A night that failed, or whose run never +/// closed, left its day uncovered, so it is not an anchor. +fn covered_through(mind: &dyn MindPort, earlier: &[&Night]) -> Result> { + for night in earlier.iter().rev() { + let closings = Selection { cites: Some(citing(&night.run, Some("subject"))), ..of_kind(PipelineKind::Resolution) }; + let latest = documents(mind, &closings)? + .into_iter() + .filter_map(|view| match view.document { + PipelineDocument::Resolution(resolution) => Some(resolution), + _ => None, + }) + .max_by_key(|resolution| resolution.sequence); + if let Some(resolution) = latest + && let ResolutionOutcome::Recorded { reason } = &resolution.outcome + && reason.as_str().starts_with(SLEPT) + { + let through = resolution.rationale.0.rsplit_once(THROUGH).and_then(|(_, ordinal)| ordinal.trim().parse().ok()); + return Ok(Some(through.unwrap_or(night.ordinal))); + } + } + Ok(None) } fn is_open_sort(sort: MemorySort) -> bool { matches!(sort, MemorySort::Asked | MemorySort::Committed) } +/// Made by a night, not lived in the day: formed in sleep, or a consolidation (a DM's keeps its room and voices, +/// so its origin is not Sleep). fn is_sleep_made(held: &Held) -> bool { - matches!(held.memory.origin, MemoryOrigin::Sleep { .. }) + matches!(held.memory.origin, MemoryOrigin::Sleep { .. }) || !held.memory.consolidates.is_empty() } -/// The night, derived. Her day: her memories in force admitted after her previous Sleep run opened (else in the -/// 24 hours before this run opened), formed otherwise than in sleep, oldest first, at most `DAY_MAX`. Her open -/// Asked and Committed items older than the day. A tide of `TIDE` older memories of other sorts, from an offset -/// that moves by `TIDE` with each earlier Sleep run of hers. +/// The night, derived. Her day: her memories in force admitted after what her newest night that closed `slept:` +/// covered (a night that failed covered nothing), else in the 24 hours before her first night opened; formed +/// otherwise than in sleep, oldest first, at most `DAY_MAX`, the rest waiting for the next night. Her open Asked +/// and Committed items older than the day. A tide of `TIDE` older memories of other sorts, from an offset that +/// moves by `TIDE` with each earlier Sleep run of hers. pub(crate) fn context(mind: &dyn MindPort, run: &PipelineRef, now: DateTime) -> Result { let nights = sleeps(mind)?; - let run_at = nights.iter().find(|(other, _)| other == run).map(|(_, at)| *at).ok_or_else(|| anyhow!("the night's run is not in her mind"))?; - let earlier: Vec<&(PipelineRef, DateTime)> = nights.iter().filter(|(other, _)| other != run).collect(); - let day_start = earlier.last().map_or(run_at - Duration::hours(24), |(_, at)| *at); + let tonight = nights.iter().find(|night| &night.run == run).ok_or_else(|| anyhow!("the night's run is not in her mind"))?; + let earlier: Vec<&Night> = nights.iter().filter(|night| night.ordinal < tonight.ordinal).collect(); + let covered = covered_through(mind, &earlier)?; + let first_at = earlier.first().map_or(tonight.at, |night| night.at); + let in_day = |held: &Held| covered.map_or(held.at > first_at - Duration::hours(24), |through| held.ordinal > through); let memories = memories_in_force(mind)?; let mut counts = BTreeMap::new(); for held in &memories { *counts.entry(format!("{:?}", held.memory.sort)).or_insert(0) += 1; } - let day: Vec = memories.iter().filter(|held| held.at > day_start && !is_sleep_made(held)).take(DAY_MAX).cloned().collect(); - let open: Vec = memories.iter().filter(|held| held.at <= day_start && is_open_sort(held.memory.sort)).take(OPEN_MAX).cloned().collect(); - let backlog: Vec<&Held> = memories.iter().filter(|held| held.at <= day_start && !is_open_sort(held.memory.sort) && !is_sleep_made(held)).collect(); + let day: Vec = memories.iter().filter(|held| in_day(held) && !is_sleep_made(held)).take(DAY_MAX).cloned().collect(); + let open: Vec = memories.iter().filter(|held| !in_day(held) && is_open_sort(held.memory.sort)).take(OPEN_MAX).cloned().collect(); + let backlog: Vec<&Held> = memories.iter().filter(|held| !in_day(held) && !is_open_sort(held.memory.sort) && !is_sleep_made(held)).collect(); let tide: Vec = if backlog.is_empty() { Vec::new() } else { @@ -429,7 +475,8 @@ pub(crate) fn pack(units: Vec>) -> Vec }, Failed { refusals: u32, why: String }, } @@ -505,7 +552,7 @@ impl SessionInbox for SleepInbox<'_, C, S> { match decode(&text, self.context) { Ok(reply) => match self.admit(&reply) { Ok(()) => { - self.finish = Some(Finish::Slept { reply }); + self.finish = Some(Finish::Slept { reply, through: self.context.day.last().map(|held| held.ordinal) }); Ok((true, false)) } Err(error) => { @@ -518,22 +565,29 @@ impl SessionInbox for SleepInbox<'_, C, S> { } } -/// Closes the night's run Recorded with her line and the counts, or the failure. -fn close(organs: &Organs, run: &PipelineRef, finish: &Finish) -> Result<()> { - let (reason, rationale) = match finish { - Finish::Slept { reply } => ( - format!("slept: {}", reply.night), +/// What a night closes with: the reason (her line, or the failure) and the rationale (the counts, and where her +/// day was covered through). +fn closing(finish: &Finish) -> (String, String) { + match finish { + Finish::Slept { reply, through } => ( + format!("{SLEPT}{}", reply.night), format!( - "kept {}, consolidated {} from {}, retired {}, formed {}", + "kept {}, consolidated {} from {}, retired {}, formed {}{}", reply.keep.len(), reply.consolidate.len(), reply.consolidate.iter().map(|consolidation| consolidation.consumes.len()).sum::(), reply.retire.len(), - reply.remember.len() + reply.remember.len(), + through.map_or(String::new(), |ordinal| format!("{THROUGH}{ordinal}")) ), ), Finish::Failed { refusals, why } => (format!("failed after {refusals} refusals: {why}"), "The night ended without a reply the ledger took.".to_string()), - }; + } +} + +/// Closes the night's run Recorded with her line and the counts, or the failure. +fn close(organs: &Organs, run: &PipelineRef, finish: &Finish) -> Result<()> { + let (reason, rationale) = closing(finish); let resolution = PipelineResolution { subject: run.clone(), sequence: next_sequence(organs.mind, run)?, diff --git a/crates/mind-body/src/sleep/tests.rs b/crates/mind-body/src/sleep/tests.rs index bd28a8e..5d3fdda 100644 --- a/crates/mind-body/src/sleep/tests.rs +++ b/crates/mind-body/src/sleep/tests.rs @@ -239,24 +239,68 @@ fn retirements_and_remembered_things_become_resolutions_and_sleep_memories() { assert_eq!((remembered.label.0.as_str(), &remembered.origin), ("mind-night-r0", &MemoryOrigin::Sleep { run: "mind-night".into() })); } -/// Her mind with a Sleep run opened at `T0 + 1000` (and `earlier` Sleep runs before it), and `memories` -/// admitted at the given offsets. +/// Her mind with a Sleep run opened at `T0 + 1000` (and `earlier` Sleep runs, slept, a hundred hours before it), +/// and `memories` admitted at the given offsets: everything admitted in the order of its time. fn night_over(earlier: usize, memories: &[(String, MemorySort, MemoryOrigin, i64)]) -> (TestMind, PipelineRef) { let mind = TestMind::seeded(); + let mut events: Vec<(i64, Vec>)> = Vec::new(); for n in 0..earlier { - mind.admit_at(at(T0 - 100 * 3_600_000 + n as i64)); - mind.committed(vec![run(&format!("before{n}"), RunTurn::Sleep, RunOperator::Mind, &[])]); - mind.committed(vec![crate::testkit::close(run_ref(&format!("before{n}")), crate::testkit::recorded())]); + let label = format!("before{n}"); + let closed = crate::testkit::close(run_ref(&label), ResolutionOutcome::Recorded { reason: "slept: ok".into() }); + events.push((T0 - 100 * 3_600_000 + n as i64, vec![vec![run(&label, RunTurn::Sleep, RunOperator::Mind, &[])], vec![closed]])); } for (label, sort, origin, offset) in memories { - mind.admit_at(at(T0 + offset)); - mind.committed(vec![memory_from(label, *sort, "Text.", &[], origin.clone(), 500)]); + events.push((T0 + offset, vec![vec![memory_from(label, *sort, "Text.", &[], origin.clone(), 500)]])); + } + events.push((T0 + 1000, vec![vec![run("tonight", RunTurn::Sleep, RunOperator::Mind, &[])]])); + events.sort_by_key(|(when, _)| *when); + for (when, commits) in events { + mind.admit_at(at(when)); + for commit in commits { + mind.committed(commit); + } } - mind.admit_at(at(T0 + 1000)); - mind.committed(vec![run("tonight", RunTurn::Sleep, RunOperator::Mind, &[])]); (mind, run_ref("tonight")) } +/// A night's own ending, admitted: slept through what it was shown, or failed. +fn close_night(mind: &TestMind, run: &PipelineRef, finish: &Finish) { + let (reason, rationale) = closing(finish); + mind.committed(vec![PipelineDocument::Resolution(PipelineResolution { + subject: run.clone(), + sequence: 1, + outcome: ResolutionOutcome::Recorded { reason: reason.as_str().into() }, + rationale: rationale.as_str().into(), + resolved_on: eureka_pipeline::Date("2026-10-09".into()), + })]); +} + +fn slept_over(context: &Context) -> Finish { + Finish::Slept { reply: SleepReply { night: "n".into(), keep: vec![], consolidate: vec![], retire: vec![], remember: vec![] }, through: context.day.last().map(|held| held.ordinal) } +} + +fn failed() -> Finish { + Finish::Failed { refusals: 3, why: "no reply the ledger took".into() } +} + +/// Opens another night of hers at `when` and reads its day. +fn next_night(mind: &TestMind, label: &str, when: i64) -> (PipelineRef, Context) { + mind.admit_at(at(T0 + when)); + mind.committed(vec![run(label, RunTurn::Sleep, RunOperator::Mind, &[])]); + let next = run_ref(label); + let context = context(mind, &next, at(T0 + when + 5000)).unwrap(); + (next, context) +} + +fn day_ids(context: &Context) -> Vec { + context.day.iter().map(|held| held.id.clone()).collect() +} + +fn remember_at(mind: &TestMind, label: &str, origin: MemoryOrigin, when: i64) { + mind.admit_at(at(T0 + when)); + mind.committed(vec![memory_from(label, MemorySort::Learned, "Text.", &[], origin, 500)]); +} + #[test] fn the_day_is_capped_oldest_first_the_open_items_too_and_the_rest_wait() { // No earlier night: her day is the 24 hours before the run opened; the asks are older than that. @@ -314,3 +358,71 @@ fn the_night_schema_states_the_reply_the_decoder_takes() { assert_eq!(schema["properties"]["remember"]["maxItems"], json!(16)); assert_eq!(schema["properties"]["retire"]["items"]["properties"]["outcome"]["enum"], json!(["done", "declined", "faded", "wrong"])); } + +#[test] +fn what_the_cap_left_waits_for_the_next_night_and_is_not_dropped_to_the_backlog() { + let days: Vec<_> = (0..100).map(|n| (format!("day{n:03}"), MemorySort::Learned, wake(), 10 + n as i64)).collect(); + let (mind, tonight) = night_over(0, &days); + let shown = context(&mind, &tonight, at(T0 + 5000)).unwrap(); + assert_eq!(shown.day.len(), DAY_MAX); + assert!(shown.tide.is_empty() && shown.open.is_empty(), "what waits is neither tide nor open"); + mind.admit_at(at(T0 + 2000)); + close_night(&mind, &tonight, &slept_over(&shown)); + let (_, next) = next_night(&mind, "tomorrow", 3000); + assert_eq!(day_ids(&next), (96..100).map(|n| id(&format!("day{n:03}"))).collect::>(), "the four past the cap are tomorrow's day, and only they"); + assert_eq!(next.tide.len(), 32, "the ninety-six she was shown are backlog now"); + assert!(next.tide.iter().all(|held| !day_ids(&next).contains(&held.id)), "a day memory is not also in the tide"); +} + +#[test] +fn a_night_that_failed_or_never_closed_covers_nothing_so_its_day_comes_back() { + let days: Vec<_> = (0..3).map(|n| (format!("f{n}"), MemorySort::Learned, wake(), 10 + n as i64)).collect(); + let (mind, first) = night_over(0, &days); + let shown = context(&mind, &first, at(T0 + 5000)).unwrap(); + assert_eq!(day_ids(&shown).len(), 3); + mind.admit_at(at(T0 + 2000)); + close_night(&mind, &first, &failed()); + let (second, again) = next_night(&mind, "second", 3000); + assert_eq!(day_ids(&again), vec![id("f0"), id("f1"), id("f2")], "a failed first night: the day is still the 24 hours before the first night opened"); + // The second night never closes (a crash); a third night still owes the same day. + remember_at(&mind, "f3", wake(), 3500); + let (_, third) = next_night(&mind, "third", 4000); + assert_eq!(day_ids(&third), vec![id("f0"), id("f1"), id("f2"), id("f3")]); + // Once a night sleeps through what it was shown, that is covered and the next day begins after it. + mind.admit_at(at(T0 + 4500)); + close_night(&mind, &second, &slept_over(&again)); + remember_at(&mind, "g0", wake(), 5000); + let (_, fourth) = next_night(&mind, "fourth", 6000); + assert_eq!(day_ids(&fourth), vec![id("f3"), id("g0")], "the night that slept covered f0 to f2; f3 arrived while it was open and is still owed"); +} + +#[test] +fn the_first_nights_day_is_the_twenty_four_hours_before_it_opened_to_the_millisecond() { + let hour = 3_600_000; + let memories = vec![ + ("on_the_line".to_string(), MemorySort::Learned, wake(), 1000 - 24 * hour), + ("just_after".to_string(), MemorySort::Learned, wake(), 1001 - 24 * hour), + ]; + let (mind, run) = night_over(0, &memories); + let night = context(&mind, &run, at(T0 + 5000)).unwrap(); + assert_eq!(day_ids(&night), vec![id("just_after")]); + assert!(night.tide.iter().any(|held| held.id == id("on_the_line")), "the older one is backlog"); +} + +#[test] +fn a_consolidation_a_night_made_is_not_the_next_days_memory_whatever_its_origin() { + let (mind, tonight) = night_over(0, &[("d1".to_string(), MemorySort::Learned, dm("!a:chat", &["@a:chat"]), 10)]); + let shown = context(&mind, &tonight, at(T0 + 5000)).unwrap(); + // The night consolidates the DM memory: the consolidation keeps the room, so its origin is Direct, admitted after the night opened. + mind.admit_at(at(T0 + 2000)); + let PipelineDocument::Memory(mut merged) = memory_from("dm-merged", MemorySort::Learned, "Merged.", &[], dm("!a:chat", &["@a:chat"]), 500) else { unreachable!() }; + merged.consolidates = vec![crate::testkit::reference(eureka_pipeline::PipelineKind::Memory, &id("d1"))]; + let by = crate::testkit::reference(eureka_pipeline::PipelineKind::Memory, &id("dm-merged")); + mind.committed(vec![PipelineDocument::Memory(merged), crate::testkit::close(crate::testkit::reference(eureka_pipeline::PipelineKind::Memory, &id("d1")), ResolutionOutcome::Superseded { by: vec![by] })]); + mind.admit_at(at(T0 + 2500)); + close_night(&mind, &tonight, &slept_over(&shown)); + remember_at(&mind, "fresh", wake(), 2800); + let (_, next) = next_night(&mind, "tomorrow", 3000); + assert_eq!(day_ids(&next), vec![id("fresh")], "the consolidation is not shown as her day"); + assert!(next.tide.iter().all(|held| held.id != id("dm-merged")), "nor does it come round as backlog"); +}