From 688b221213057461fd567b89268d36c2906a2e4f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 19:44:06 +0100 Subject: [PATCH] recall: three lanes, none showing an open item; salience and recency rank; every lane counts what it leaves out; one memory_line says where and from whom (recall.rs, testkit) Co-Authored-By: Claude Sonnet 5.5 --- crates/huginn-mind/src/rows.rs | 2 +- crates/mind-body/src/recall.rs | 288 +++++++++++++++-------- crates/mind-body/src/recall/tests.rs | 330 ++++++++++++++++++--------- crates/mind-body/src/testkit.rs | 14 +- 4 files changed, 423 insertions(+), 211 deletions(-) diff --git a/crates/huginn-mind/src/rows.rs b/crates/huginn-mind/src/rows.rs index 83fcdfd..49adeab 100644 --- a/crates/huginn-mind/src/rows.rs +++ b/crates/huginn-mind/src/rows.rs @@ -436,7 +436,7 @@ mod tests { (K::HandOff, &["repo"]), (K::Run, &["turn", "operated_by", "ran"]), (K::Pulse, &["voted", "urged"]), - (K::Memory, &["sort", "about", "direct"]), + (K::Memory, &["sort", "about", "memory_origin"]), ]; for (kind, extra) in table { let want = [&["root", "in_force", "faculty"][..], extra].concat(); diff --git a/crates/mind-body/src/recall.rs b/crates/mind-body/src/recall.rs index 08c73b7..fc5dea4 100644 --- a/crates/mind-body/src/recall.rs +++ b/crates/mind-body/src/recall.rs @@ -1,68 +1,92 @@ -//! What of her own mind enters a Persona turn, and in what words. +//! What of her own mind enters a Persona turn besides what she is always shown, and in what words. //! //! Ruling `eureka-body:ruling:persona-memory-written-not-transcript`: what she //! must still know when the conversation is gone is written into her mind as -//! typed memory, and a later turn reads it back from there. This module owns -//! the reading and nothing else. It reads only her own mind through `MindPort`; -//! it reads no chat (the lines shown beside a message are conversation, never memory), +//! typed memory, and a later turn reads it back from there. Open asks and open +//! commitments are not recalled: `persona::render_responsibilities` shows them +//! on every turn beside her stewardships, because they stay in front of her +//! until a resolution closes them. This module owns the cued, ranked remainder +//! and nothing else. It reads only her own mind through `MindPort`; it reads no +//! chat (the lines shown beside a message are conversation, never memory), //! keeps no cache between turns, and holds no index: the daemon's index answers //! `MindPort::query_near`, and a rank only orders. //! -//! Four lanes, merged in this order and de-duplicated by id: -//! - `asks`: memory in force with sort `Asked`, newest first, on every turn -//! including a wake. An ask stays in front of her until a resolution closes -//! it, so nothing stores an open flag. -//! - `about`: memory in force about any other sender of this batch (rapport, -//! ideas and commitments with them come here). +//! Three lanes, merged in this order and de-duplicated by id, none showing an +//! open item: +//! - `about`: memory in force about any other sender of this batch, ranked by +//! `weight` from the newest `POOL` of them. //! - `relevant`: memory, follow-up and ruling in force nearest the batch's -//! words. A wake has no words and skips it. -//! - `newest`: the latest memory in force. +//! words, in the daemon's order. A wake has no words and skips it. +//! - `salient`: memory in force ranked by `weight` from the newest `POOL`. +//! On a wake, which has no senders and no words, this is all that runs. //! -//! The merge is capped at `MAX_ITEMS` items and `MAX_BYTES` of rendered text, -//! the asks lane first, so a long tail never pushes an ask out. +//! Each lane has an item limit and a byte budget, so no one signal crowds out +//! another; a lane that leaves something out says how many. use std::collections::BTreeMap; use anyhow::{Result, bail}; +use chrono::{DateTime, Utc}; use cultnet_rs::Selection; -use eureka_pipeline::{PipelineDocument, PipelineKind}; +use eureka_pipeline::{MemoryOrigin, MemorySort, PipelineDocument, PipelineKind}; use huginn_mind::{PipelineDocumentView, PipelinePageItems}; use crate::persona::quoted; use crate::queue::{MindPort, any_of, in_force, of_kind}; -/// Open asks shown per turn. -const ASKS_LIMIT: u32 = 8; -/// Memories about the batch's other senders. -const ABOUT_LIMIT: u32 = 6; -/// Nearest documents asked of the index. -const RELEVANT_TOP_K: u32 = 8; -/// The latest memories. -const NEWEST_LIMIT: u32 = 4; -/// The most items one turn recalls. -pub(crate) const MAX_ITEMS: usize = 20; -/// The most bytes of rendered lines one turn recalls. -pub(crate) const MAX_BYTES: usize = 16 * 1024; -/// The longest id the index of `about` takes; a longer sender id is not asked about. -const ABOUT_ID_MAX_BYTES: usize = 200; +/// What each lane shows, and the bytes of rendered lines it may spend. +pub(crate) const ABOUT_LIMIT: usize = 6; +pub(crate) const ABOUT_BYTES: usize = 5 * 1024; +pub(crate) const RELEVANT_LIMIT: usize = 8; +pub(crate) const RELEVANT_BYTES: usize = 7 * 1024; +pub(crate) const SALIENT_LIMIT: usize = 4; +pub(crate) const SALIENT_BYTES: usize = 4 * 1024; +/// The most bytes of rendered lines one turn recalls: the lanes' budgets. +#[cfg(test)] +pub(crate) const MAX_BYTES: usize = ABOUT_BYTES + RELEVANT_BYTES + SALIENT_BYTES; +/// The newest memories a ranked lane ranks, and the nearest documents the index is asked for: open +/// items among them are dropped, so asking past the lane's limit keeps its items in reach. +const POOL: u32 = 32; +/// Hours over which a memory's recency halves. Calibration, not a gate. +pub(crate) const RECENCY_HALF_LIFE_H: f64 = 72.0; /// The longest semantic text, in bytes, cut on a character boundary. pub(crate) const NEAR_TEXT_MAX: usize = 1000; +/// The sorts recall may show: the ones that are not open items. +const RECALLED_SORTS: [&str; 5] = ["Decided", "Learned", "Did", "Idea", "Rapport"]; /// Which lane brought an item. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] pub(crate) enum Lane { - Asks, About, Relevant, - Newest, + Salient, } -/// One recalled document as a line of the turn's input. +impl Lane { + fn heading(self) -> &'static str { + match self { + Self::About => "About the people here:", + Self::Relevant => "Near what was said:", + Self::Salient => "Weighed most:", + } + } +} + +/// One recalled document as a line of the turn's input, and the people it names. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct Entry { pub id: String, - pub lane: Lane, pub line: String, + /// The Matrix ids its `about` and origin name. + pub people: Vec, +} + +/// One lane's result: what it shows and how many it left out. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Shown { + pub lane: Lane, + pub entries: Vec, + pub left_out: usize, } /// Whether the semantic lane ran. @@ -75,18 +99,17 @@ pub(crate) enum Search { Unavailable, } -/// What a turn recalls: the open asks she may settle or take on, then the rest. +/// What a turn recalls, lane by lane in merge order. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct Recalled { - pub asks: Vec, - pub others: Vec, + pub lanes: Vec, pub search: Search, } impl Recalled { - /// The ids of the open asks shown: the only ones a reply may settle or take on. - pub(crate) fn ask_ids(&self) -> Vec { - self.asks.iter().map(|entry| entry.id.clone()).collect() + /// Every item shown, in merge order. + pub(crate) fn entries(&self) -> impl Iterator { + self.lanes.iter().flat_map(|shown| shown.entries.iter()) } } @@ -100,6 +123,8 @@ pub(crate) struct Cue<'a> { pub names: BTreeMap<&'a str, &'a str>, /// Follow-ups and rulings the responsibilities section already lists. pub listed: &'a [String], + /// The time recency is measured against. + pub now: DateTime, } /// The semantic text: message texts newest first, then the senders' names, @@ -131,13 +156,30 @@ fn newest(selection: Selection, limit: u32) -> Selection { Selection { descending: true, limit: Some(limit), projection: "document".into(), ..selection } } -fn memories_in_force() -> Selection { - in_force(of_kind(PipelineKind::Memory)) +fn recalled_memories_in_force() -> Selection { + any_of(in_force(of_kind(PipelineKind::Memory)), "sort", &RECALLED_SORTS) } -/// Where a memory was learned, as she should read it. -fn where_learned(direct: bool, learned_in: &str, who: &str) -> String { - if direct { format!("privately, with {who}") } else { format!("in #{learned_in}") } +/// How much a memory weighs: its salience in thousandths of one, plus a +/// recency that is 1 when admitted now and halves every `RECENCY_HALF_LIFE_H` +/// hours. A time that cannot be read has no recency. Relevance is its own +/// lane, not a summand, so no one signal can crowd out another. +pub(crate) fn weight(salience_permille: u16, admitted_at: &str, now: DateTime) -> f64 { + let recency = DateTime::parse_from_rfc3339(admitted_at).map_or(0.0, |at| { + let age_hours = (now - at.with_timezone(&Utc)).num_seconds().max(0) as f64 / 3600.0; + 0.5_f64.powf(age_hours / RECENCY_HALF_LIFE_H) + }); + f64::from(salience_permille) / 1000.0 + recency +} + +/// The views ranked by weight, heaviest first; equal weights keep their order, which is newest first. +fn ranked(mut views: Vec, now: DateTime) -> Vec { + let weight_of = |view: &PipelineDocumentView| match &view.document { + PipelineDocument::Memory(memory) => weight(memory.salience_permille, &view.admission.admitted_at, now), + _ => 0.0, + }; + views.sort_by(|a, b| weight_of(b).total_cmp(&weight_of(a))); + views } /// The people a memory names, each quoted, by display name where this turn's senders showed one. @@ -146,88 +188,144 @@ fn who<'a>(ids: impl Iterator, names: &BTreeMap<&str, &str>) -> if people.is_empty() { "someone".into() } else { people.join(", ") } } -/// One recalled document as its line, or None for a kind recall does not show. -fn line_of(view: &PipelineDocumentView, lane: Lane, names: &BTreeMap<&str, &str>) -> Option { +/// The ids a memory's `about` and origin name. +pub(crate) fn people_of(memory: &eureka_pipeline::PipelineMemory) -> Vec { + let origin: &[eureka_pipeline::Short] = match &memory.origin { + MemoryOrigin::Room { heard_from, .. } => heard_from, + MemoryOrigin::Direct { with, .. } => with, + MemoryOrigin::Wake | MemoryOrigin::SelfRun { .. } => &[], + }; + memory.about.iter().chain(origin).map(|id| id.0.clone()).collect() +} + +/// One memory as its line: date, where and from whom she learned it, what it +/// is and whom it concerns, then her words. The one way a memory reads, for +/// recall and for the open items the responsibilities section shows, which pass +/// `with_id` to lead with the id she settles it by. `about` is never the place. +pub(crate) fn memory_line(view: &PipelineDocumentView, names: &BTreeMap<&str, &str>, with_id: bool) -> Option { + let PipelineDocument::Memory(memory) = &view.document else { return None }; let date = view.admission.admitted_at.get(..10).unwrap_or(&view.admission.admitted_at); + let origin = match &memory.origin { + MemoryOrigin::Room { rooms, heard_from } if heard_from.is_empty() => format!("in #{}", rooms.0), + MemoryOrigin::Room { rooms, heard_from } => format!("in #{}, with {}", rooms.0, who(heard_from.iter().map(|id| id.0.as_str()), names)), + MemoryOrigin::Direct { with, .. } => format!("privately, with {}", who(with.iter().map(|id| id.0.as_str()), names)), + MemoryOrigin::Wake => "on a wake".to_string(), + MemoryOrigin::SelfRun { run } => format!("in your Self run {}", run.0), + }; + let about = who(memory.about.iter().map(|id| id.0.as_str()), names); + let sort = match (memory.sort, memory.about.is_empty()) { + (MemorySort::Asked, _) => format!("asked by {about}"), + (MemorySort::Committed, true) => "committed".to_string(), + (MemorySort::Committed, false) => format!("committed, with {about}"), + (sort, true) => format!("{sort:?}"), + (sort, false) => format!("{sort:?} about {about}"), + }; + let lead = if with_id { format!("{} ", view.id.id.0) } else { String::new() }; + Some(format!("- {lead}({date}, {origin}, {sort}): {}\n", quoted(&memory.text.0))) +} + +/// One recalled document as its line, or None for a kind recall does not show. +fn line_of(view: &PipelineDocumentView, names: &BTreeMap<&str, &str>) -> Option { match &view.document { - PipelineDocument::Memory(memory) => { - let people = who(memory.about.iter().map(|id| id.0.as_str()), names); - let place = where_learned(memory.direct, &memory.learned_in.0, &people); - if lane == Lane::Asks { - Some(format!("- {} ({date}, {place}, asked by {people}): {}\n", view.id.id.0, quoted(&memory.text.0))) - } else if memory.about.is_empty() { - Some(format!("- {date}, {place}, {:?}: {}\n", memory.sort, quoted(&memory.text.0))) - } else { - Some(format!("- {date}, {place}, {:?} about {people}: {}\n", memory.sort, quoted(&memory.text.0))) - } - } + PipelineDocument::Memory(_) => memory_line(view, names, false), PipelineDocument::FollowUp(follow_up) => Some(format!("- You set yourself to: {}\n", quoted(follow_up.item.0.lines().next().unwrap_or("")))), PipelineDocument::Ruling(ruling) => Some(format!("- Ruled: {}\n", quoted(&ruling.title.0))), _ => None, } } +/// Fills one lane from its ranked candidates: each not yet shown or listed +/// elsewhere, within the lane's item limit and byte budget (a line that does +/// not fit is skipped, a later shorter one may). `matched` is how many the +/// lane's query matched, so what it left out is counted exactly. +fn fill(lane: Lane, candidates: Vec, matched: usize, limit: usize, budget: usize, seen: &mut Vec, cue: &Cue) -> Shown { + let (mut entries, mut bytes, mut elsewhere) = (Vec::new(), 0usize, 0usize); + for view in candidates { + let id = view.id.id.0.clone(); + if seen.contains(&id) || cue.listed.contains(&id) { + elsewhere += 1; + continue; + } + let Some(line) = line_of(&view, &cue.names) else { + elsewhere += 1; + continue; + }; + if entries.len() == limit || bytes + line.len() > budget { + continue; + } + seen.push(id.clone()); + bytes += line.len(); + let people = match &view.document { + PipelineDocument::Memory(memory) => people_of(memory), + _ => Vec::new(), + }; + entries.push(Entry { id, line, people }); + } + let left_out = matched.saturating_sub(entries.len() + elsewhere); + Shown { lane, entries, left_out } +} + +/// Whether a view is an open ask or commitment: a memory of those sorts. +fn is_open_item(view: &PipelineDocumentView) -> bool { + matches!(&view.document, PipelineDocument::Memory(memory) if matches!(memory.sort, MemorySort::Asked | MemorySort::Committed)) +} + /// Selects what her mind gives this turn. A mind that cannot be read fails the /// turn; an index that cannot be asked only loses the relevant lane. pub(crate) fn select(mind: &dyn MindPort, cue: &Cue) -> Result { - let asks = documents(mind.query(&newest(any_of(memories_in_force(), "sort", &["Asked"]), ASKS_LIMIT))?)?; - let askable: Vec<&str> = cue.senders.iter().copied().filter(|sender| sender.len() <= ABOUT_ID_MAX_BYTES).collect(); - let about = if askable.is_empty() { - Vec::new() + let (about, about_matched) = if cue.senders.is_empty() { + (Vec::new(), 0) } else { - documents(mind.query(&newest(any_of(memories_in_force(), "about", &askable), ABOUT_LIMIT))?)? + let page = mind.query(&newest(any_of(recalled_memories_in_force(), "about", &cue.senders), POOL))?; + let matched = page.matched as usize; + (ranked(documents(page)?, cue.now), matched) }; let (relevant, search) = match near_text(cue) { None => (Vec::new(), Search::Skipped), Some(text) => { let kinds = [PipelineKind::Memory, PipelineKind::FollowUp, PipelineKind::Ruling]; let members = in_force(Selection { schemas: Some(kinds.iter().map(|kind| kind.type_id().to_string()).collect()), projection: "document".into(), ..Selection::default() }); - match mind.query_near(&members, &text, RELEVANT_TOP_K).and_then(documents) { - Ok(found) => (found, Search::Ran), + match mind.query_near(&members, &text, POOL).and_then(documents) { + // An open ask or commitment is shown by the responsibilities section; it never comes here. + Ok(found) => (found.into_iter().filter(|view| !is_open_item(view)).collect::>(), Search::Ran), Err(_) => (Vec::new(), Search::Unavailable), } } }; - let latest = documents(mind.query(&newest(memories_in_force(), NEWEST_LIMIT))?)?; + let page = mind.query(&newest(recalled_memories_in_force(), POOL))?; + let salient_matched = page.matched as usize; + let salient = ranked(documents(page)?, cue.now); - let mut recalled = Recalled { asks: Vec::new(), others: Vec::new(), search }; - let (mut seen, mut count, mut bytes) = (Vec::::new(), 0usize, 0usize); - for (lane, views) in [(Lane::Asks, asks), (Lane::About, about), (Lane::Relevant, relevant), (Lane::Newest, latest)] { - for view in views { - let id = view.id.id.0.clone(); - if seen.contains(&id) || cue.listed.contains(&id) { - continue; - } - let Some(line) = line_of(&view, lane, &cue.names) else { continue }; - if count == MAX_ITEMS || bytes + line.len() > MAX_BYTES { - continue; - } - seen.push(id.clone()); - count += 1; - bytes += line.len(); - let entry = Entry { id, lane, line }; - if lane == Lane::Asks { recalled.asks.push(entry) } else { recalled.others.push(entry) } - } - } - Ok(recalled) + let mut seen = Vec::::new(); + let relevant_found = relevant.len(); + let lanes = vec![ + fill(Lane::About, about, about_matched, ABOUT_LIMIT, ABOUT_BYTES, &mut seen, cue), + fill(Lane::Relevant, relevant, relevant_found, RELEVANT_LIMIT, RELEVANT_BYTES, &mut seen, cue), + fill(Lane::Salient, salient, salient_matched, SALIENT_LIMIT, SALIENT_BYTES, &mut seen, cue), + ]; + Ok(Recalled { lanes, search }) } -/// The section of a turn's input: the open asks first, then the rest, one line each. +/// The section of a turn's input: one heading per lane that shows or left out anything, one line each, +/// and after a lane that left something out a line saying how many. pub(crate) fn render(recalled: &Recalled) -> String { let mut out = String::from("What you remember (from your own mind; `huginn view ` reads a record):\n"); - if !recalled.asks.is_empty() { - out.push_str("Asked of you, not yet taken on:\n"); - recalled.asks.iter().for_each(|entry| out.push_str(&entry.line)); + for shown in &recalled.lanes { + if shown.entries.is_empty() && shown.left_out == 0 { + continue; + } + out.push_str(shown.lane.heading()); + out.push('\n'); + shown.entries.iter().for_each(|entry| out.push_str(&entry.line)); + if shown.left_out > 0 { + out.push_str(&format!("({} more not shown.)\n", shown.left_out)); + } } - if !recalled.others.is_empty() { - out.push_str("Also remembered:\n"); - recalled.others.iter().for_each(|entry| out.push_str(&entry.line)); - } - if recalled.asks.is_empty() && recalled.others.is_empty() { + if recalled.lanes.iter().all(|shown| shown.entries.is_empty() && shown.left_out == 0) { out.push_str("- nothing is remembered yet.\n"); } if recalled.search == Search::Unavailable { - out.push_str("(The search of your memory is unavailable this turn; only the newest and what concerns the senders are shown.)\n"); + out.push_str("(The search of your memory is unavailable this turn; only what concerns the senders and what weighs most are shown.)\n"); } out } diff --git a/crates/mind-body/src/recall/tests.rs b/crates/mind-body/src/recall/tests.rs index 8bbb4a2..4db8223 100644 --- a/crates/mind-body/src/recall/tests.rs +++ b/crates/mind-body/src/recall/tests.rs @@ -4,7 +4,8 @@ use std::cell::RefCell; use anyhow::Result; use cultnet_rs::Selection; -use eureka_pipeline::{MemorySort, PipelineDocument, PipelineKind as K, Slug}; +use chrono::Duration; +use eureka_pipeline::{MemoryOrigin, MemorySort, PipelineDocument, PipelineKind as K, Slug}; use huginn_mind::{PipelineAdmissionOutcome, PipelineSelectionPage}; use super::*; @@ -66,66 +67,80 @@ impl MindPort for NoIndex<'_> { } const LIBBY: &str = "@libby:chat"; +/// When a turn runs, a minute after the first admissions. +const NOW_MS: i64 = 60_000; fn key(label: &str) -> String { format!("{INSTANCE}:memory:{label}") } fn cue<'a>(senders: &[&'a str], texts: &[&'a str], listed: &'a [String]) -> Cue<'a> { - Cue { senders: senders.to_vec(), texts: texts.to_vec(), names: BTreeMap::from([(LIBBY, "Libby")]), listed } + Cue { senders: senders.to_vec(), texts: texts.to_vec(), names: BTreeMap::from([(LIBBY, "Libby")]), listed, now: at(NOW_MS) } } -fn ids(entries: &[Entry]) -> Vec { - entries.iter().map(|entry| entry.id.clone()).collect() +fn ids(shown: &Shown) -> Vec { + shown.entries.iter().map(|entry| entry.id.clone()).collect() } +fn lane(recalled: &Recalled, which: Lane) -> &Shown { + recalled.lanes.iter().find(|shown| shown.lane == which).unwrap() +} + +fn all_ids(recalled: &Recalled) -> Vec { + recalled.entries().map(|entry| entry.id.clone()).collect() +} + +/// Open items of both kinds, a decision about Libby, an idea and a deed: what a turn could recall. fn seeded() -> TestMind { let mind = TestMind::seeded(); mind.committed(vec![memory("old-ask", MemorySort::Asked, "Prioritise the fork", &["@op:chat"])]); mind.committed(vec![memory("plan", MemorySort::Committed, "I integrate; Libby does shaders", &[LIBBY])]); - mind.committed(vec![memory("new-ask", MemorySort::Asked, "Write the notes", &["@op:chat"])]); + mind.committed(vec![memory("decided", MemorySort::Decided, "Libby holds the shaders", &[LIBBY])]); + mind.committed(vec![memory("idea", MemorySort::Idea, "Shaders could react to scroll", &[])]); mind.committed(vec![memory("lately", MemorySort::Did, "Looked at the shaders", &[])]); mind } #[test] -fn lanes_come_in_order_asks_about_relevant_newest_and_an_item_appears_once() { +fn lanes_come_in_order_about_relevant_salient_and_an_item_appears_once() { let mind = seeded(); let port = Recording::over(&mind); let recalled = select(&port, &cue(&[LIBBY], &["shaders"], &[])).unwrap(); - assert_eq!(ids(&recalled.asks), vec![key("new-ask"), key("old-ask")], "open asks, newest first"); + assert_eq!(recalled.lanes.iter().map(|shown| shown.lane).collect::>(), [Lane::About, Lane::Relevant, Lane::Salient]); assert_eq!(recalled.search, Search::Ran); - let lanes: Vec = recalled.others.iter().map(|entry| entry.lane).collect(); - let mut sorted = lanes.clone(); - sorted.sort(); - assert_eq!(lanes, sorted, "asks, then about, relevant, newest"); - assert_eq!(recalled.others[0].id, key("plan"), "the about lane leads: memory about Libby"); - let all: Vec = ids(&recalled.asks).into_iter().chain(ids(&recalled.others)).collect(); + assert_eq!(ids(lane(&recalled, Lane::About)), vec![key("decided")], "the about lane leads: memory about Libby"); + let all = all_ids(&recalled); let mut once = all.clone(); once.sort(); once.dedup(); assert_eq!(all.len(), once.len(), "de-duplicated: {all:?}"); - assert!(all.contains(&key("lately")), "the newest lane reaches the latest memory"); + assert!(all.contains(&key("lately")) && all.contains(&key("idea")), "the other lanes reach the rest: {all:?}"); } #[test] -fn an_ask_that_is_also_about_a_sender_is_shown_once_in_the_asks() { - let mind = TestMind::seeded(); - mind.committed(vec![memory("tied", MemorySort::Asked, "Libby wants the notes", &[LIBBY])]); - let recalled = select(&Recording::over(&mind), &cue(&[LIBBY], &["notes"], &[])).unwrap(); - assert_eq!(ids(&recalled.asks), vec![key("tied")]); - assert!(recalled.others.iter().all(|entry| entry.id != key("tied"))); +fn an_open_ask_or_commitment_is_never_recalled_by_any_lane_on_any_turn() { + let mind = seeded(); + // The ask is about Libby, matches the words, and is the heaviest memory. + mind.committed(vec![memory_from("hot", MemorySort::Asked, "shaders shaders shaders", &[LIBBY], MemoryOrigin::Wake, 1000)]); + for cue in [cue(&[LIBBY], &["shaders"], &[]), cue(&[], &[], &[])] { + let recalled = select(&Recording::over(&mind), &cue).unwrap(); + let shown = all_ids(&recalled); + for open in ["old-ask", "plan", "hot"] { + assert!(!shown.contains(&key(open)), "{open} must not be recalled: {shown:?}"); + } + assert!(render(&recalled).lines().all(|line| !line.contains("Asked of you") && !line.contains("asked by") && !line.contains("committed"))); + } } #[test] -fn a_wake_reads_the_asks_and_the_newest_and_asks_the_index_nothing() { +fn a_wake_keeps_only_the_salient_lane_and_asks_the_index_nothing() { let mind = seeded(); let port = Recording::over(&mind); let recalled = select(&port, &cue(&[], &[], &[])).unwrap(); - assert_eq!(ids(&recalled.asks), vec![key("new-ask"), key("old-ask")]); assert_eq!(recalled.search, Search::Skipped); assert!(port.asked.borrow().near.is_empty()); - assert!(recalled.others.iter().all(|entry| matches!(entry.lane, Lane::Newest)), "no senders, no words: only the newest lane remains"); + assert!(lane(&recalled, Lane::About).entries.is_empty() && lane(&recalled, Lane::Relevant).entries.is_empty()); + assert_eq!(ids(lane(&recalled, Lane::Salient)).len(), 3, "the three non-open memories"); } #[test] @@ -136,7 +151,7 @@ fn the_semantic_ask_is_the_batchs_words_newest_first_plus_names_cut_on_a_charact { let asked = port.asked.borrow(); let (selection, text, top_k) = &asked.near[0]; - assert_eq!((text.as_str(), *top_k), ("newest words\nolder words\nLibby", 8)); + assert_eq!((text.as_str(), *top_k), ("newest words\nolder words\nLibby", POOL)); let kinds: Vec<_> = selection.schemas.clone().unwrap(); assert_eq!(kinds, [K::Memory, K::FollowUp, K::Ruling].map(|kind| kind.type_id().to_string())); assert!(selection.cursor.is_none() && !selection.descending); @@ -153,29 +168,127 @@ fn the_semantic_ask_is_the_batchs_words_newest_first_plus_names_cut_on_a_charact assert_eq!(near_text(&cue(&[], &[" ", ""], &[])), None); } +/// A recency of 1 now, 0.5 at the half-life: the weight is pinned at the points the ranking leans on. #[test] -fn recall_stops_at_twenty_items_and_sixteen_kib_and_the_asks_come_first() { +fn weight_is_salience_plus_a_recency_that_halves_every_seventy_two_hours() { + let now = at(0); + let ago = |hours: i64| (now - Duration::hours(hours)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + let close = |left: f64, right: f64| (left - right).abs() < 1e-9; + assert!(close(weight(1000, &ago(0), now), 2.0)); + assert!(close(weight(0, &ago(72), now), 0.5)); + assert!(close(weight(0, &ago(144), now), 0.25)); + assert!(close(weight(0, &ago(36), now), 0.5_f64.sqrt())); + assert!(close(weight(300, "not a time", now), 0.3), "an unparseable time has no recency"); + assert!(close(weight(0, &ago(-72), now), 1.0), "a time ahead of now is no older than now"); +} + +#[test] +fn salience_ranks_with_recency_inside_the_about_and_salient_lanes() { + // Ten fresh, low-salience Rapport and one Decided of 900 a day old: the Decided ranks first. let mind = TestMind::seeded(); - for index in 0..30 { - mind.committed(vec![memory(&format!("m{index:02}"), MemorySort::Learned, &format!("fact {index}"), &[LIBBY])]); + mind.admit_at(at(NOW_MS) - Duration::hours(24)); + mind.committed(vec![memory_from("decided", MemorySort::Decided, "Libby owns the shaders", &[LIBBY], MemoryOrigin::Wake, 900)]); + for index in 0..10 { + mind.admit_at(at(NOW_MS) - Duration::seconds(30)); + mind.committed(vec![memory_from(&format!("r{index:02}"), MemorySort::Rapport, &format!("cats {index}"), &[LIBBY], MemoryOrigin::Wake, 100)]); } - mind.committed(vec![memory("ask", MemorySort::Asked, "Do the thing", &["@op:chat"])]); - for index in 0..30 { - mind.committed(vec![memory(&format!("n{index:02}"), MemorySort::Learned, &format!("fact {index}"), &[LIBBY])]); + let recalled = select(&Recording::over(&mind), &cue(&[LIBBY], &[], &[])).unwrap(); + assert_eq!(lane(&recalled, Lane::About).entries[0].id, key("decided")); + assert_eq!(lane(&recalled, Lane::About).entries.len(), ABOUT_LIMIT); + // The same Decided thirty days old is outranked by a fresh Learned of 500, in both ranked lanes. + let old = TestMind::seeded(); + old.admit_at(at(NOW_MS) - Duration::days(30)); + old.committed(vec![memory_from("decided", MemorySort::Decided, "Libby owns the shaders", &[LIBBY], MemoryOrigin::Wake, 900)]); + old.admit_at(at(NOW_MS) - Duration::seconds(30)); + old.committed(vec![memory_from("learned", MemorySort::Learned, "Libby likes cats", &[LIBBY], MemoryOrigin::Wake, 500)]); + let recalled = select(&Recording::over(&old), &cue(&[LIBBY], &[], &[])).unwrap(); + assert_eq!(ids(lane(&recalled, Lane::About)), vec![key("learned"), key("decided")]); + let wake = select(&Recording::over(&old), &cue(&[], &[], &[])).unwrap(); + assert_eq!(ids(lane(&wake, Lane::Salient)), vec![key("learned"), key("decided")]); +} + +#[test] +fn equal_weights_keep_the_newest_first() { + let mind = TestMind::seeded(); + for index in 0..3 { + mind.admit_at(at(NOW_MS) - Duration::seconds(30)); + mind.committed(vec![memory_from(&format!("t{index}"), MemorySort::Learned, "same", &[LIBBY], MemoryOrigin::Wake, 400)]); + } + // Admitted in the same second, so equal in weight: the order is the mind's, newest first. + let recalled = select(&Recording::over(&mind), &cue(&[LIBBY], &[], &[])).unwrap(); + assert_eq!(ids(lane(&recalled, Lane::About)), vec![key("t2"), key("t1"), key("t0")]); +} + +#[test] +fn lane_limits_and_budgets_fit_the_total_and_nothing_exceeds_them() { + assert_eq!(MAX_BYTES, ABOUT_BYTES + RELEVANT_BYTES + SALIENT_BYTES); + assert_eq!(ABOUT_LIMIT + RELEVANT_LIMIT + SALIENT_LIMIT, 18); + let mind = TestMind::seeded(); + for index in 0..40 { + mind.committed(vec![memory(&format!("m{index:02}"), MemorySort::Learned, &format!("fact {index} {}", "w".repeat(400)), &[LIBBY])]); } let recalled = select(&Recording::over(&mind), &cue(&[LIBBY], &["fact"], &[])).unwrap(); - let total = recalled.asks.len() + recalled.others.len(); - assert!((10..=MAX_ITEMS).contains(&total), "{total}"); - assert_eq!(ids(&recalled.asks), vec![key("ask")], "the ask is not pushed out by a long tail"); - - // Bytes: memories of 1000 bytes each stop well before the item cap. - let heavy = TestMind::seeded(); - for index in 0..30 { - heavy.committed(vec![memory(&format!("h{index:02}"), MemorySort::Learned, &"w".repeat(1000), &[LIBBY])]); + assert!(recalled.entries().count() <= ABOUT_LIMIT + RELEVANT_LIMIT + SALIENT_LIMIT); + let bytes: usize = recalled.entries().map(|entry| entry.line.len()).sum(); + assert!(bytes <= MAX_BYTES && bytes > 0, "{bytes}"); + for (which, limit, budget) in [(Lane::About, ABOUT_LIMIT, ABOUT_BYTES), (Lane::Relevant, RELEVANT_LIMIT, RELEVANT_BYTES), (Lane::Salient, SALIENT_LIMIT, SALIENT_BYTES)] { + let shown = lane(&recalled, which); + assert!(shown.entries.len() <= limit && shown.entries.iter().map(|entry| entry.line.len()).sum::() <= budget, "{which:?}"); } - let recalled = select(&Recording::over(&heavy), &cue(&[LIBBY], &["w"], &[])).unwrap(); - let bytes: usize = recalled.others.iter().map(|entry| entry.line.len()).sum(); - assert!(bytes <= MAX_BYTES && !recalled.others.is_empty(), "{bytes}"); +} + +#[test] +fn a_line_that_exactly_fits_a_lane_budget_is_shown_and_one_byte_more_is_not() { + // The oldest memory is the one that varies; the newer ones are fixed and rank above it. + for (which, fixed, size, budget, senders) in [(Lane::About, 5, 800, ABOUT_BYTES, vec![LIBBY]), (Lane::Salient, 3, 1000, SALIENT_BYTES, vec![])] { + let lane_of = |adjustable: usize| { + let mind = TestMind::seeded(); + mind.committed(vec![memory("m0", MemorySort::Learned, &"w".repeat(adjustable), &[LIBBY])]); + for index in 1..=fixed { + mind.committed(vec![memory(&format!("m{index}"), MemorySort::Learned, &"f".repeat(size), &[LIBBY])]); + } + let recalled = select(&Recording::over(&mind), &cue(&senders, &[], &[])).unwrap(); + lane(&recalled, which).clone() + }; + let probe: usize = lane_of(100).entries.iter().map(|entry| entry.line.len()).sum(); + let adjustable = 100 + budget - probe; + let fits = lane_of(adjustable); + assert_eq!((fits.entries.iter().map(|entry| entry.line.len()).sum::(), fits.entries.len(), fits.left_out), (budget, fixed + 1, 0), "{which:?}: an exact fit is shown"); + let over = lane_of(adjustable + 1); + assert_eq!((over.entries.len(), over.left_out), (fixed, 1), "{which:?}: one byte over is left out and counted"); + } +} + +#[test] +fn each_lane_that_leaves_something_out_counts_it_exactly() { + let mind = TestMind::seeded(); + // 10 heavy Rapport about Libby: the about lane fits 4 of ~1.1 KiB in 5 KiB, the rest is counted. + for index in 0..10 { + mind.committed(vec![memory(&format!("h{index:02}"), MemorySort::Rapport, &format!("{} {index}", "h".repeat(998)), &[LIBBY])]); + } + for index in 0..12 { + mind.committed(vec![memory(&format!("z{index:02}"), MemorySort::Learned, "zebra stripe", &[])]); + } + let recalled = select(&Recording::over(&mind), &cue(&[LIBBY], &["zebra stripe"], &[])).unwrap(); + let about = lane(&recalled, Lane::About); + assert_eq!(about.entries.len(), 4); + assert_eq!(about.left_out, 10 - 4, "matched 10, shown 4"); + let section = render(&recalled); + assert!(section.contains("About the people here:") && section.contains("(6 more not shown.)"), "{section}"); + assert!(recalled.entries().all(|entry| !entry.line.contains("asked by"))); +} + +#[test] +fn what_another_lane_showed_is_not_counted_as_left_out() { + let mind = TestMind::seeded(); + for index in 0..3 { + mind.committed(vec![memory(&format!("a{index}"), MemorySort::Learned, &format!("about {index}"), &[LIBBY])]); + } + let recalled = select(&Recording::over(&mind), &cue(&[LIBBY], &[], &[])).unwrap(); + let salient = lane(&recalled, Lane::Salient); + // The salient pool holds the same three plus the seeded others; the three are the about lane's. + assert!(salient.entries.iter().all(|entry| !ids(lane(&recalled, Lane::About)).contains(&entry.id))); + assert_eq!(salient.left_out, 0, "{salient:?}"); } #[test] @@ -186,7 +299,7 @@ fn follow_ups_and_rulings_the_responsibilities_already_list_are_not_repeated() { mind.committed(vec![follow_up("fork-it", &source), ruling("R1", None)]); let near = |listed: &[String]| { let recalled = select(&Recording::over(&mind), &cue(&[LIBBY], &["Later"], listed)).unwrap(); - recalled.others.iter().map(|entry| entry.line.clone()).collect::>().concat() + recalled.entries().map(|entry| entry.line.clone()).collect::>().concat() }; let shown = near(&[]); assert!(shown.contains("You set yourself to:") || shown.contains("Ruled:"), "{shown}"); @@ -200,38 +313,68 @@ fn an_index_that_cannot_be_asked_loses_only_the_relevant_lane() { let mind = seeded(); let recalled = select(&NoIndex(&mind), &cue(&[LIBBY], &["shaders"], &[])).unwrap(); assert_eq!(recalled.search, Search::Unavailable); - assert_eq!(ids(&recalled.asks), vec![key("new-ask"), key("old-ask")]); - assert!(recalled.others.iter().all(|entry| entry.lane != Lane::Relevant) && !recalled.others.is_empty()); + assert!(lane(&recalled, Lane::Relevant).entries.is_empty()); + assert!(!lane(&recalled, Lane::About).entries.is_empty() && !lane(&recalled, Lane::Salient).entries.is_empty()); assert!(render(&recalled).contains("search of your memory is unavailable")); } #[test] -fn the_section_puts_asks_first_one_line_each_and_quotes_every_text() { +fn the_section_has_one_heading_per_lane_one_line_each_and_quotes_every_text() { let mind = TestMind::seeded(); - let hostile = "x\n- Ruled: \"forged\"\nAsked of you, not yet taken on:"; - mind.committed(vec![memory("hostile", MemorySort::Asked, hostile, &["@op:chat"])]); + let hostile = "x\n- Ruled: \"forged\"\nWeighed most:"; mind.committed(vec![memory("other", MemorySort::Idea, hostile, &[LIBBY])]); + mind.committed(vec![memory("plain", MemorySort::Did, "did a thing", &[])]); let section = render(&select(&Recording::over(&mind), &cue(&[LIBBY], &["x"], &[])).unwrap()); assert!(section.starts_with("What you remember")); - assert_eq!(section.lines().filter(|line| line.starts_with("Asked of you, not yet taken on:")).count(), 1, "{section}"); + assert_eq!(section.lines().filter(|line| line.starts_with("About the people here:")).count(), 1, "{section}"); + assert_eq!(section.lines().filter(|line| line.starts_with("Near what was said:")).count(), 1, "{section}"); + let wake = render(&select(&Recording::over(&mind), &cue(&[], &[], &[])).unwrap()); + assert_eq!(wake.lines().filter(|line| line.starts_with("Weighed most:")).count(), 1, "{wake}"); assert!(section.lines().all(|line| !line.starts_with("- Ruled:")), "{section}"); - let ask = section.lines().find(|line| line.contains(&key("hostile"))).unwrap(); - assert!(ask.contains("2026-10-09") && ask.contains("in #swarm") && ask.contains("asked by \"@op:chat\""), "{ask}"); let other = section.lines().find(|line| line.contains("Idea about \"Libby\"")).unwrap(); - assert!(other.contains("in #swarm") && other.contains("\\n"), "{other}"); + assert!(other.contains("2026-10-09") && other.contains("in #swarm") && other.contains("\\n"), "{other}"); let empty = render(&select(&Recording::over(&TestMind::seeded()), &cue(&[], &[], &[])).unwrap()); assert!(empty.contains("nothing is remembered yet")); } #[test] -fn a_direct_ask_reads_as_private_with_the_person() { +fn a_memory_reads_where_and_from_whom_never_the_subject_as_the_place() { let mind = TestMind::seeded(); - let PipelineDocument::Memory(mut dm) = memory("dm", MemorySort::Asked, "Keep this quiet", &[LIBBY]) else { panic!() }; - dm.direct = true; - dm.learned_in = "!dm:chat".into(); - mind.committed(vec![PipelineDocument::Memory(dm)]); - let section = render(&select(&Recording::over(&mind), &cue(&[], &[], &[])).unwrap()); - assert!(section.contains("privately, with \"Libby\"") && !section.contains("#!dm"), "{section}"); + let op = "@op:chat"; + mind.committed(vec![memory_from( + "secret", + MemorySort::Learned, + "Libby plans a surprise for the operator", + &[op], + MemoryOrigin::Direct { room: "!dm-libby:chat".into(), with: vec![LIBBY.into()] }, + 500, + )]); + mind.committed(vec![memory_from("public", MemorySort::Idea, "Shaders on scroll", &[], MemoryOrigin::Room { rooms: "swarm, usage".into(), heard_from: vec![LIBBY.into(), "@aqua:chat".into()] }, 500)]); + mind.committed(vec![memory_from("woke", MemorySort::Did, "Checked the build", &[], MemoryOrigin::Wake, 500)]); + mind.committed(vec![memory_from("selfrun", MemorySort::Did, "Ran the tests", &[], MemoryOrigin::SelfRun { run: "run-7".into() }, 500)]); + let recalled = select(&Recording::over(&mind), &cue(&[op], &[], &[])).unwrap(); + let section = render(&recalled); + let line = |needle: &str| section.lines().find(|line| line.contains(needle)).unwrap_or_else(|| panic!("{needle} in {section}")).to_string(); + let secret = line("surprise"); + assert!(secret.contains("privately, with \"Libby\"") && secret.contains("Learned about \"@op:chat\"") && !secret.contains("privately, with \"@op:chat\""), "{secret}"); + assert!(!secret.contains("!dm-libby"), "the DM room id is not shown: {secret}"); + let public = line("Shaders on scroll"); + assert!(public.contains("in #swarm, usage, with \"Libby\", \"@aqua:chat\""), "{public}"); + assert!(line("Checked the build").contains("on a wake")); + assert!(line("Ran the tests").contains("in your Self run run-7")); +} + +#[test] +fn an_open_item_reads_with_its_id_first_and_who_asked_or_who_holds_the_rest() { + let mind = TestMind::seeded(); + mind.committed(vec![memory("asked-one", MemorySort::Asked, "Write the notes", &["@op:chat"]), memory("part", MemorySort::Committed, "I integrate", &[LIBBY]), memory("solo", MemorySort::Committed, "I clean up", &[])]); + let names = BTreeMap::from([(LIBBY, "Libby")]); + let line = |label: &str| memory_line(&mind.view(K::Memory, &key(label)).unwrap(), &names, true).unwrap(); + assert!(line("asked-one").starts_with(&format!("- {} (2026-10-09, in #swarm, asked by \"@op:chat\"): \"Write the notes\"", key("asked-one"))), "{}", line("asked-one")); + assert!(line("part").starts_with(&format!("- {} (2026-10-09, in #swarm, committed, with \"Libby\"):", key("part"))), "{}", line("part")); + assert!(line("solo").contains(", committed):"), "{}", line("solo")); + let plain = memory_line(&mind.view(K::Memory, &key("asked-one")).unwrap(), &names, false).unwrap(); + assert!(plain.starts_with("- (2026"), "{plain}"); } #[test] @@ -246,70 +389,37 @@ fn recall_reads_only_the_memory_follow_up_and_ruling_kinds() { } } -#[test] -fn each_lane_is_limited_and_the_merge_stops_at_twenty_items_from_disjoint_lanes() { - let mind = TestMind::seeded(); - for index in 0..10 { - mind.committed(vec![memory(&format!("a{index:02}"), MemorySort::Asked, &format!("ask {index}"), &["@op:chat"])]); - } - for index in 0..8 { - mind.committed(vec![memory(&format!("b{index:02}"), MemorySort::Learned, &format!("about {index}"), &[LIBBY])]); - } - for index in 0..8 { - mind.committed(vec![memory(&format!("z{index:02}"), MemorySort::Learned, "zebra stripe", &[])]); - } - for index in 0..4 { - mind.committed(vec![memory(&format!("n{index:02}"), MemorySort::Did, &format!("newest {index}"), &[])]); - } - let recalled = select(&Recording::over(&mind), &cue(&[LIBBY], &["zebra stripe"], &[])).unwrap(); - assert_eq!(recalled.asks.len(), 8, "the asks lane is limited to eight"); - let lane = |lane: Lane| recalled.others.iter().filter(|entry| entry.lane == lane).count(); - assert_eq!(lane(Lane::About), 6, "the about lane is limited to six"); - assert_eq!(recalled.asks.len() + recalled.others.len(), MAX_ITEMS, "twenty items, the later lanes cut"); - assert_eq!(lane(Lane::Newest), 0, "the newest lane is last and is cut first"); - // A wake: the newest lane is limited to four. - let wake = select(&Recording::over(&mind), &cue(&[], &[], &[])).unwrap(); - assert_eq!(wake.others.len(), 4); -} - -#[test] -fn the_byte_cap_stops_the_merge_before_it_is_crossed() { - let mind = TestMind::seeded(); - for index in 0..8 { - mind.committed(vec![memory(&format!("a{index:02}"), MemorySort::Asked, &"a".repeat(1000), &["@op:chat"])]); - } - for index in 0..6 { - mind.committed(vec![memory(&format!("b{index:02}"), MemorySort::Learned, &"b".repeat(1000), &[LIBBY])]); - } - for index in 0..8 { - mind.committed(vec![memory(&format!("z{index:02}"), MemorySort::Learned, &format!("{} zebra stripe", "z".repeat(980)), &[])]); - } - let recalled = select(&Recording::over(&mind), &cue(&[LIBBY], &["zebra stripe"], &[])).unwrap(); - let bytes: usize = recalled.asks.iter().chain(&recalled.others).map(|entry| entry.line.len()).sum(); - assert!(bytes <= MAX_BYTES, "{bytes}"); - assert!(bytes > MAX_BYTES - 1300, "the cap is reached, not undershot: {bytes}"); - assert!(recalled.asks.len() + recalled.others.len() < MAX_ITEMS); -} - #[test] fn a_follow_up_and_a_ruling_read_as_what_she_set_herself_and_what_was_ruled() { let mind = TestMind::seeded(); mind.committed(vec![spec("x")]); mind.committed(vec![follow_up("fork-it", &spec_ref("x")), ruling("R1", None)]); let recalled = select(&Recording::over(&mind), &cue(&[LIBBY], &["Later"], &[])).unwrap(); - let lines: Vec<&str> = recalled.others.iter().map(|entry| entry.line.as_str()).collect(); + let lines: Vec<&str> = recalled.entries().map(|entry| entry.line.as_str()).collect(); assert!(lines.contains(&"- You set yourself to: \"Later.\"\n"), "{lines:?}"); assert!(lines.contains(&"- Ruled: \"A ruling\"\n"), "{lines:?}"); } #[test] -fn the_empty_notice_appears_only_when_nothing_is_remembered() { - let asks_only = TestMind::seeded(); - asks_only.committed(vec![memory("ask", MemorySort::Asked, "Do it", &["@op:chat"])]); - let section = render(&Recalled { asks: select(&Recording::over(&asks_only), &cue(&[], &[], &[])).unwrap().asks, others: vec![], search: Search::Skipped }); - assert!(!section.contains("nothing is remembered yet") && section.contains("Asked of you"), "{section}"); +fn the_empty_notice_appears_only_when_nothing_is_remembered_or_left_out() { + let open_only = TestMind::seeded(); + open_only.committed(vec![memory("asked-one", MemorySort::Asked, "Do it", &["@op:chat"])]); + let section = render(&select(&Recording::over(&open_only), &cue(&[], &[], &[])).unwrap()); + assert!(section.contains("nothing is remembered yet"), "open items are not recall territory: {section}"); let others_only = TestMind::seeded(); others_only.committed(vec![memory("did", MemorySort::Did, "Did it", &[])]); let section = render(&select(&Recording::over(&others_only), &cue(&[], &[], &[])).unwrap()); - assert!(!section.contains("nothing is remembered yet") && section.contains("Also remembered"), "{section}"); + assert!(!section.contains("nothing is remembered yet") && section.contains("Weighed most:"), "{section}"); + // A lane that shows nothing but left something out still says so. + let shown = Shown { lane: Lane::About, entries: vec![], left_out: 3 }; + let section = render(&Recalled { lanes: vec![shown], search: Search::Skipped }); + assert!(section.contains("About the people here:") && section.contains("(3 more not shown.)") && !section.contains("nothing is remembered"), "{section}"); +} + +#[test] +fn an_open_item_that_is_the_nearest_match_is_dropped_from_the_relevant_lane() { + let mind = TestMind::seeded(); + mind.committed(vec![memory("zask", MemorySort::Asked, "zebra stripe zebra stripe", &[]), memory("zlearned", MemorySort::Learned, "zebra", &[])]); + let recalled = select(&Recording::over(&mind), &cue(&[], &["zebra stripe"], &[])).unwrap(); + assert_eq!(ids(lane(&recalled, Lane::Relevant)), vec![key("zlearned")]); } diff --git a/crates/mind-body/src/testkit.rs b/crates/mind-body/src/testkit.rs index f513c35..529f106 100644 --- a/crates/mind-body/src/testkit.rs +++ b/crates/mind-body/src/testkit.rs @@ -8,7 +8,7 @@ use anyhow::{Result, anyhow}; use chrono::{DateTime, Duration, TimeZone, Utc}; use cultnet_rs::Selection; use eureka_pipeline::{ - CommitRange, CutVerification, Date, DocRef, MemorySort, PipelineCampaign, PipelineCutReport, PipelineCutSpec, PipelineDocument, PipelineFollowUp, + CommitRange, CutVerification, Date, DocRef, MemoryOrigin, MemorySort, PipelineCampaign, PipelineCutReport, PipelineCutSpec, PipelineDocument, PipelineFollowUp, PipelineInstance, PipelineKind, PipelineMemory, PipelineQuestion, PipelineRef, PipelineResolution, PipelineRuling, PipelineRun, PipelineStewardship, PipelineTarget, QuestionOption, ResolutionOutcome, RulingAuthority, RunOperator, RunTurn, Sha, Slug, StructuralDelta, TargetInvariant, }; @@ -221,18 +221,22 @@ impl MindPort for Answering<'_> { } } -/// One of her memories: `sort` and `text`, about `about`, learned in #swarm, not direct. +/// One of her memories: `sort` and `text`, about `about`, learned in #swarm, salience 500. pub(crate) fn memory(label: &str, sort: MemorySort, text: &str, about: &[&str]) -> PipelineDocument { + memory_from(label, sort, text, about, MemoryOrigin::Room { rooms: "swarm".into(), heard_from: vec![] }, 500) +} + +/// One of her memories with the origin and salience the ranking tests need. +pub(crate) fn memory_from(label: &str, sort: MemorySort, text: &str, about: &[&str], origin: MemoryOrigin, salience_permille: u16) -> PipelineDocument { PipelineDocument::Memory(PipelineMemory { instance: Slug(INSTANCE.into()), label: eureka_pipeline::Label(label.into()), sort, text: text.into(), about: about.iter().map(|who| (*who).into()).collect(), - learned_in: "swarm".into(), - direct: false, + origin, sources: vec![], - salience_permille: 500, + salience_permille, consolidates: vec![], }) }