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 <noreply@anthropic.com>
This commit is contained in:
parent
3829625067
commit
688b221213
4 changed files with 424 additions and 212 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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<Entry>,
|
||||
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<Entry>,
|
||||
pub others: Vec<Entry>,
|
||||
pub lanes: Vec<Shown>,
|
||||
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<String> {
|
||||
self.asks.iter().map(|entry| entry.id.clone()).collect()
|
||||
/// Every item shown, in merge order.
|
||||
pub(crate) fn entries(&self) -> impl Iterator<Item = &Entry> {
|
||||
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<Utc>,
|
||||
}
|
||||
|
||||
/// 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<Utc>) -> 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<PipelineDocumentView>, now: DateTime<Utc>) -> Vec<PipelineDocumentView> {
|
||||
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<Item = &'a str>, 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<String> {
|
||||
/// The ids a memory's `about` and origin name.
|
||||
pub(crate) fn people_of(memory: &eureka_pipeline::PipelineMemory) -> Vec<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<PipelineDocumentView>, matched: usize, limit: usize, budget: usize, seen: &mut Vec<String>, 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<Recalled> {
|
||||
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::<Vec<_>>(), 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::<String>::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::<String>::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 <id>` 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
||||
entries.iter().map(|entry| entry.id.clone()).collect()
|
||||
fn ids(shown: &Shown) -> Vec<String> {
|
||||
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<String> {
|
||||
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::<Vec<_>>(), [Lane::About, Lane::Relevant, Lane::Salient]);
|
||||
assert_eq!(recalled.search, Search::Ran);
|
||||
let lanes: Vec<Lane> = 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<String> = 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::<usize>() <= 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::<usize>(), 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::<Vec<_>>().concat()
|
||||
recalled.entries().map(|entry| entry.line.clone()).collect::<Vec<_>>().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")]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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![],
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue