diff --git a/crates/mind-body/src/persona.rs b/crates/mind-body/src/persona.rs index daf80c4..13b93c5 100644 --- a/crates/mind-body/src/persona.rs +++ b/crates/mind-body/src/persona.rs @@ -170,7 +170,8 @@ pub(crate) const SHORT_MAX_BYTES: usize = 200; /// The most bytes each open-item list of the responsibilities section carries, outside `GOALS_MAX_BYTES`. const OPEN_COMMITTED_MAX_BYTES: usize = 4 * 1024; const OPEN_ASKED_MAX_BYTES: usize = 4 * 1024; -/// The most open items one list fetches; the rest are counted. +/// The page size of an open-item fetch. It bounds the cost of one read, not what shows: every page is read, because the +/// order is the item's origin time and a page of admission order is not that order. const OPEN_FETCH: u32 = 64; /// The most event ids a Persona memory cites as its sources. pub(crate) const SOURCES_MAX: usize = 8; @@ -2319,6 +2320,22 @@ fn ruling_ids_in(text: &str) -> Vec { .collect() } +/// The campaigns whose dropped follow-ups the note names; the rest are counted together. +const DROPPED_CAMPAIGNS_NAMED: usize = 6; + +/// The note that counts the open follow-ups the bound left out and says where they are, the biggest campaigns first. +fn dropped_note(dropped: &BTreeMap) -> String { + let mut by_campaign: Vec<(&String, &usize)> = dropped.iter().collect(); + by_campaign.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0))); + let total: usize = dropped.values().sum(); + let mut where_: Vec = by_campaign.iter().take(DROPPED_CAMPAIGNS_NAMED).map(|(campaign, count)| format!("{campaign} {count}")).collect(); + if by_campaign.len() > DROPPED_CAMPAIGNS_NAMED { + let rest: usize = by_campaign[DROPPED_CAMPAIGNS_NAMED..].iter().map(|(_, count)| **count).sum(); + where_.push(format!("{} more campaigns {rest}", by_campaign.len() - DROPPED_CAMPAIGNS_NAMED)); + } + format!("({total} more open follow-ups not shown: {}; `huginn open ` lists them.)\n", where_.join(", ")) +} + /// What she is responsible for, from her own mind: her stewardships in force, /// always whole, with each record's note; her open commitments, then her open asks, each oldest first with /// its id and its own bound; her follow-ups in force, oldest @@ -2370,15 +2387,24 @@ pub(crate) fn render_responsibilities(mind: &dyn MindPort, instance: &Slug, name (MemorySort::Committed, "You committed to, oldest first:", "commitments", OPEN_COMMITTED_MAX_BYTES), (MemorySort::Asked, "Asked of you, not yet taken on, oldest first:", "asks", OPEN_ASKED_MAX_BYTES), ] { - // Ascending, which is admission order: the oldest open items first, the newest left to the count. - let selection = Selection { + let mut asking = Selection { limit: Some(OPEN_FETCH), projection: "document".into(), ..any_of(in_force(of_kind(PipelineKind::Memory)), "sort", &[format!("{sort:?}").as_str()]) }; - let page = mind.query(&selection)?; - let matched = page.matched as usize; - let PipelinePageItems::Documents(views) = page.items else { bail!("the mind answered headers to a document selection") }; + let mut views = Vec::new(); + loop { + let page = mind.query(&asking)?; + let PipelinePageItems::Documents(items) = page.items else { bail!("the mind answered headers to a document selection") }; + views.extend(items); + match page.next { + Some(cursor) => asking.cursor = Some(cursor), + None => break, + } + } + // Oldest first by origin: an imported item is as old as it was in the giver, not as its import. + views.sort_by(|a, b| recall::origin_order(&a.admission).cmp(&recall::origin_order(&b.admission))); + let matched = views.len(); let (mut section, mut shown) = (String::new(), 0usize); for view in &views { let Some(line) = recall::memory_line(view, names, true) else { continue }; @@ -2403,10 +2429,11 @@ pub(crate) fn render_responsibilities(mind: &dyn MindPort, instance: &Slug, name } // The open_ids items are outside the follow-ups' and rulings' bound. let open_len = out.len() - open_start; - // Ascending, which is admission order: oldest first is the reminder of work she has carried longest, and the bound drops the newest. - let open = in_force(of_kind(PipelineKind::FollowUp)); - let follow_ups = headers(mind, &open)?; - let mut dropped = 0; + // Oldest first by origin: an imported follow-up is as old as it was in the giver, not as its import. Work that has waited + // longest leads, and the bound drops the newest. + let mut follow_ups = headers(mind, &in_force(of_kind(PipelineKind::FollowUp)))?; + follow_ups.sort_by(|a, b| recall::origin_order(&a.admission).cmp(&recall::origin_order(&b.admission))); + let mut dropped: BTreeMap = BTreeMap::new(); // Every open follow-up counts as open work for its campaign, whether or not the bound below lets its line show. let mut open_roots: Vec = Vec::new(); for follow_up in &follow_ups { @@ -2423,9 +2450,9 @@ pub(crate) fn render_responsibilities(mind: &dyn MindPort, instance: &Slug, name let asked = origin.as_ref().map_or_else(String::new, |origin| format!(", {}", recall::origin_phrase(origin, names))); let campaign = follow_up.id.id.0.split_once(":follow_up:").map_or("", |(campaign, _)| campaign); let held = if claimed.contains(&follow_up.id) { " [claimed by a run]" } else { "" }; - let line = format!("- {campaign}/{} ({}{asked}){held}: {}\n", label.0, repo.0, item.0.lines().next().unwrap_or("")); + let line = format!("- {campaign}/{} ({}{asked}){held}: {}\n", label.0, repo.0, recall::clipped(item.0.lines().next().unwrap_or(""))); if out.len() - open_len + line.len() > GOALS_MAX_BYTES - GOALS_RULINGS_RESERVE { - dropped += 1; + *dropped.entry(campaign.to_owned()).or_default() += 1; continue; } if source.kind == PipelineKind::Ruling { @@ -2438,8 +2465,8 @@ pub(crate) fn render_responsibilities(mind: &dyn MindPort, instance: &Slug, name if follow_ups.is_empty() { out.push_str("- no follow-up is open in your mind.\n"); } - if dropped > 0 { - out.push_str(&format!("({dropped} more open follow-ups not shown: `huginn open ` lists them all.)\n")); + if !dropped.is_empty() { + out.push_str(&dropped_note(&dropped)); } let mut dropped_rulings = cited.len().saturating_sub(GOALS_RULINGS_MAX); cited.truncate(GOALS_RULINGS_MAX); diff --git a/crates/mind-body/src/persona/tests.rs b/crates/mind-body/src/persona/tests.rs index a222b4b..cb8520d 100644 --- a/crates/mind-body/src/persona/tests.rs +++ b/crates/mind-body/src/persona/tests.rs @@ -2289,13 +2289,13 @@ fn a_long_sender_id_stays_out_of_the_schema_and_never_poisons_a_turn() { assert!(input_of(&rig, 0).contains("hello there")); } -/// Twelve open asks of about 900 bytes, heavy memories about the sender and matching the words, and a few short deeds. +/// Twenty open asks of about 900 bytes (each listed clipped), heavy memories about the sender and matching the words, and a few short deeds. #[test] fn caps_count_what_they_leave_out() { let (libby, op) = ("@libby:chat", "@op:chat"); let heavy = |tag: &str| format!("{tag} hello {}", "x".repeat(880)); let rig = Rig::new(vec![delivery(1, "swarm", op, "hello")], &[says(&[("swarm", "hi")])]); - for index in 0..12 { + for index in 0..20 { rig.mind.committed(vec![crate::testkit::memory(&format!("ask-{index:02}"), MemorySort::Asked, &heavy(&format!("ASK{index:02}")), &[libby])]); } for index in 0..6 { @@ -2312,10 +2312,10 @@ fn caps_count_what_they_leave_out() { // The asks: shown with ids, the rest counted exactly, and the settle enum is exactly what was shown. let asked = under(responsibilities_of(&input), "Asked of you, not yet taken on, oldest first:"); let shown_ids: Vec = asked.lines().filter(|line| line.starts_with("- ") && line.contains(":memory:")).map(|line| line[2..].split(' ').next().unwrap().to_string()).collect(); - assert!(!shown_ids.is_empty() && shown_ids.len() < 12, "{} shown: {asked}", shown_ids.len()); + assert!(!shown_ids.is_empty() && shown_ids.len() < 20, "{} shown: {asked}", shown_ids.len()); let expected_oldest: Vec = (0..shown_ids.len()).map(|index| format!("yggdrasil:memory:ask-{index:02}")).collect(); assert_eq!(shown_ids, expected_oldest, "the oldest asks show, in admission order, and the newest are counted"); - assert!(asked.contains(&format!("({} more asks not shown", 12 - shown_ids.len())), "{} shown, ids {shown_ids:?}", shown_ids.len()); + assert!(asked.contains(&format!("({} more asks not shown", 20 - shown_ids.len())), "{} shown, ids {shown_ids:?}", shown_ids.len()); let mut enum_ids = settle_enum(&rig, 0); enum_ids.sort(); let mut expected = shown_ids.clone(); @@ -2367,22 +2367,25 @@ fn each_open_list_holds_its_own_bound_exactly_and_counts_what_it_drops() { (MemorySort::Committed, "You committed to, oldest first:", "commitments", OPEN_COMMITTED_MAX_BYTES), (MemorySort::Asked, "Asked of you, not yet taken on, oldest first:", "asks", OPEN_ASKED_MAX_BYTES), ] { - let render = |adjustable: usize| { + // Short fillers make the lines fine-grained; the oldest item's text (under the clip) tunes the total to the byte. + let render = |fillers: usize, adjustable: usize| { let mind = TestMind::seeded(); mind.committed(vec![crate::testkit::memory("m0", sort, &"w".repeat(adjustable), &[])]); - for index in 1..4 { - mind.committed(vec![crate::testkit::memory(&format!("m{index}"), sort, &"f".repeat(1000), &[])]); + for index in 1..=fillers { + mind.committed(vec![crate::testkit::memory(&format!("m{index:02}"), sort, "f", &[])]); } render_responsibilities(&mind, &slug, &names).unwrap().text }; let used = |text: &str| under(text, heading).lines().filter(|line| line.starts_with("- ") && line.contains(":memory:")).map(|line| line.len() + 1).sum::(); let lines = |text: &str| under(text, heading).lines().filter(|line| line.starts_with("- ") && line.contains(":memory:")).count(); - let fit = 100 + bound - used(&render(100)); - let fits = render(fit); - assert_eq!((used(&fits), lines(&fits)), (bound, 4), "an exact fit is shown: {fits}"); + let fillers = (1..100).take_while(|count| lines(&render(*count, 0)) == count + 1).last().unwrap(); + let fit = bound - used(&render(fillers, 0)); + assert!(fit < 150, "{fit}"); + let fits = render(fillers, fit); + assert_eq!((used(&fits), lines(&fits)), (bound, fillers + 1), "an exact fit is shown: {fits}"); assert!(!fits.contains("not shown"), "{fits}"); - let over = render(fit + 1); - assert_eq!(lines(&over), 3, "{over}"); + let over = render(fillers, fit + 1); + assert_eq!(lines(&over), fillers, "{over}"); assert!(over.contains(&format!("(1 more {noun} not shown: settle what is finished and they come back into view.)")), "{over}"); } // Both lists full and a follow-up still has its room: the open lists are outside the goals bound. @@ -7355,3 +7358,129 @@ fn a_sleep_holder_opens_one_process_with_the_night_schema_and_her_tools_never_a_ assert_eq!(rig.chat.pending_calls.get(), 0); assert!(rig.model.requests.borrow()[0].input.contains("Tonight you consolidate your memory.")); } + +/// A follow-up of `campaign` (whose spec `src` and campaign the caller has admitted), with its item text. +fn follow_up_in(campaign: &str, label: &str, item: &str) -> PipelineDocument { + let PipelineDocument::FollowUp(mut follow_up) = follow_up(label, &reference(K::CutSpec, &format!("{campaign}:cut_spec:cut-src.r1"))) else { unreachable!("a follow-up") }; + follow_up.campaign = Slug(campaign.into()); + follow_up.item = Line(item.into()); + PipelineDocument::FollowUp(follow_up) +} + +/// The labels of the follow-up lines `text` lists, in order. +fn listed_follow_ups(text: &str) -> Vec { + text.lines() + .filter_map(|line| line.strip_prefix("- ")) + .filter(|line| line.contains(" (GameCult/")) + .filter_map(|line| line.split(' ').next()) + .map(|id| id.rsplit('/').next().unwrap().to_owned()) + .collect() +} + +/// An imported item is as old as it was in the giver: it sorts by that time, not by when it was imported. +#[test] +fn an_imported_item_sorts_by_its_origin_time_not_its_import() { + let mind = TestMind::seeded(); + mind.committed(chain(CAMPAIGN, "src", REPO)); + mind.committed(vec![follow_up_in(CAMPAIGN, "native", "Native, admitted first of the two.")]); + mind.committed_imported("2020-01-01T00:00:00Z", vec![follow_up_in(CAMPAIGN, "imported-old", "Imported, born in the giver long ago.")]); + mind.committed_imported("2999-01-01T00:00:00Z", vec![follow_up_in(CAMPAIGN, "imported-new", "Imported, born in the giver later than anything.")]); + mind.committed(vec![crate::testkit::memory("m-native", MemorySort::Asked, "native ask", &[])]); + mind.committed_imported("2020-01-01T00:00:00Z", vec![crate::testkit::memory("m-imported", MemorySort::Asked, "imported ask", &[])]); + let text = render_responsibilities(&mind, &Slug(INSTANCE.into()), &BTreeMap::new()).unwrap().text; + assert_eq!(listed_follow_ups(&text), ["imported-old", "native", "imported-new"], "{text}"); + let at = |needle: &str| text.find(needle).unwrap_or_else(|| panic!("{needle} in {text}")); + assert!(at("m-imported") < at("m-native"), "{text}"); + assert!(text.contains("(2020-01-01, "), "an imported memory shows the date it began: {text}"); +} + +/// A listed line carries about 160 bytes of the item's text, cut on a character boundary with an ellipsis; the whole stays one `huginn view` away. +#[test] +fn a_listed_line_clips_the_item_text() { + assert_eq!(recall::clipped(&"w".repeat(160)), "w".repeat(160)); + assert_eq!(recall::clipped(&"w".repeat(161)), format!("{}…", "w".repeat(160))); + let accents = "é".repeat(100); + assert_eq!(recall::clipped(&accents), format!("{}…", "é".repeat(80)), "a cut inside a character backs up to its start"); + let mind = TestMind::seeded(); + mind.committed(chain(CAMPAIGN, "src", REPO)); + mind.committed(vec![follow_up_in(CAMPAIGN, "long", &"w".repeat(500))]); + mind.committed(vec![crate::testkit::memory("m-long", MemorySort::Committed, &"v".repeat(500), &[])]); + let text = render_responsibilities(&mind, &Slug(INSTANCE.into()), &BTreeMap::new()).unwrap().text; + assert!(text.contains(&format!("{}…", "w".repeat(160))) && !text.contains(&"w".repeat(161)), "{text}"); + assert!(text.contains(&format!("{}…", "v".repeat(160))) && !text.contains(&"v".repeat(161)), "{text}"); + let whole = recall::memory_line(&mind.view(K::Memory, &format!("{INSTANCE}:memory:m-long")).unwrap(), &BTreeMap::new(), false).unwrap(); + assert!(whole.contains(&"v".repeat(500)), "recall shows a memory whole: {whole}"); +} + +/// A line that does not fit is skipped and a later short one may still show, in the follow-ups and in the open memories. +#[test] +fn a_line_that_does_not_fit_is_skipped_and_a_later_short_one_shows() { + // Follow-ups: fill the bound, then an older long-labelled line that cannot fit, then a newer short one. + let build = |fillers: usize, with_short: bool| { + let mind = TestMind::seeded(); + mind.committed(chain(CAMPAIGN, "src", REPO)); + for index in 0..fillers { + mind.committed(vec![follow_up_in(CAMPAIGN, &format!("fill{index:03}"), "filler")]); + } + mind.committed(vec![follow_up_in(CAMPAIGN, &format!("big{}", "g".repeat(190)), &"b".repeat(160))]); + if with_short { + mind.committed(vec![follow_up_in(CAMPAIGN, "short", "s")]); + } + render_responsibilities(&mind, &Slug(INSTANCE.into()), &BTreeMap::new()).unwrap().text + }; + let fillers = (1..200).find(|count| !build(*count, false).contains("big")).expect("the bound is reached"); + let text = build(fillers, true); + assert!(!text.contains("big") && listed_follow_ups(&text).last().map(String::as_str) == Some("short"), "{text}"); + assert!(text.contains("(1 more open follow-ups not shown: eureka-body 1;"), "{text}"); + // Open memories: the same. + for sort in [MemorySort::Committed, MemorySort::Asked] { + let build = |fillers: usize, with_short: bool| { + let mind = TestMind::seeded(); + for index in 0..fillers { + mind.committed(vec![crate::testkit::memory(&format!("fill{index:03}"), sort, "filler", &[])]); + } + let about: Vec = (0..8).map(|index| format!("{index}{}", "p".repeat(190))).collect(); + let about: Vec<&str> = about.iter().map(String::as_str).collect(); + mind.committed(vec![crate::testkit::memory("big", sort, &"b".repeat(160), &about)]); + if with_short { + mind.committed(vec![crate::testkit::memory("short", sort, "s", &[])]); + } + render_responsibilities(&mind, &Slug(INSTANCE.into()), &BTreeMap::new()).unwrap().text + }; + let fillers = (1..200).find(|count| !build(*count, false).contains(":memory:big")).expect("the bound is reached"); + let text = build(fillers, true); + assert!(!text.contains(":memory:big") && text.contains(":memory:short"), "{sort:?}: {text}"); + } +} + +/// The note that counts the dropped follow-ups says where they are, the biggest campaign first, and stays bounded. +#[test] +fn the_dropped_follow_ups_note_names_their_campaigns() { + let mind = TestMind::seeded(); + for campaign in [CAMPAIGN, "hidden-in", "third"] { + if campaign != CAMPAIGN { + mind.committed(campaign_on(campaign, &[REPO])); + } + mind.committed(chain(campaign, "src", REPO)); + } + let long = "w".repeat(400); + for index in 0..30 { + mind.committed(vec![follow_up_in(CAMPAIGN, &format!("fu-{index:02}"), &long)]); + } + for index in 0..5 { + mind.committed(vec![follow_up_in("hidden-in", &format!("h{index}"), &long)]); + } + for index in 0..7 { + mind.committed(vec![follow_up_in("third", &format!("t{index}"), &long)]); + } + let text = render_responsibilities(&mind, &Slug(INSTANCE.into()), &BTreeMap::new()).unwrap().text; + let listed = listed_follow_ups(&text).len(); + let note = text.lines().find(|line| line.contains("more open follow-ups not shown")).unwrap_or_else(|| panic!("{text}")); + let native_dropped = 30 - listed; + assert_eq!(note, format!("({} more open follow-ups not shown: eureka-body {native_dropped}, third 7, hidden-in 5; `huginn open ` lists them.)", native_dropped + 12), "{text}"); + let many: BTreeMap = (0..9).map(|index| (format!("c{index}"), 10 - index)).collect(); + assert_eq!( + dropped_note(&many), + "(54 more open follow-ups not shown: c0 10, c1 9, c2 8, c3 7, c4 6, c5 5, 3 more campaigns 9; `huginn open ` lists them.)\n" + ); +} diff --git a/crates/mind-body/src/recall.rs b/crates/mind-body/src/recall.rs index 5425c12..b9be66f 100644 --- a/crates/mind-body/src/recall.rs +++ b/crates/mind-body/src/recall.rs @@ -29,7 +29,7 @@ use anyhow::{Result, bail}; use chrono::{DateTime, Utc}; use cultnet_rs::Selection; use eureka_pipeline::{MemoryOrigin, MemorySort, PipelineDocument, PipelineKind}; -use huginn_mind::{PipelineDocumentView, PipelinePageItems}; +use huginn_mind::{AdmissionFacts, PipelineDocumentView, PipelinePageItems}; use crate::persona::quoted; use crate::queue::{MindPort, any_of, in_force, of_kind}; @@ -215,8 +215,10 @@ pub(crate) fn origin_phrase(origin: &MemoryOrigin, names: &BTreeMap<&str, &str>) /// 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 { + // An open item of the responsibilities (the one caller that leads with the id) shows a clipped text; recall shows it whole. let PipelineDocument::Memory(memory) = &view.document else { return None }; - let date = view.admission.admitted_at.get(..10).unwrap_or(&view.admission.admitted_at); + let when = origin_time(&view.admission); + let date = when.get(..10).unwrap_or(when); let origin = origin_phrase(&memory.origin, names); let about = who(memory.about.iter().map(|id| id.0.as_str()), names); let sort = match (memory.sort, memory.about.is_empty()) { @@ -227,7 +229,30 @@ pub(crate) fn memory_line(view: &PipelineDocumentView, names: &BTreeMap<&str, &s (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))) + Some(format!("- {lead}({date}, {origin}, {sort}): {}\n", quoted(&if with_id { clipped(&memory.text.0) } else { memory.text.0.clone() }))) +} + +/// The most bytes of an item's text a listed line carries; the rest is one `huginn view` away. +pub(crate) const LINE_TEXT_MAX_BYTES: usize = 160; + +/// `text` cut to about `LINE_TEXT_MAX_BYTES` on a character boundary, with an ellipsis where it was cut. +pub(crate) fn clipped(text: &str) -> String { + if text.len() <= LINE_TEXT_MAX_BYTES { + return text.to_owned(); + } + let cut = (0..=LINE_TEXT_MAX_BYTES).rev().find(|at| text.is_char_boundary(*at)).unwrap_or(0); + format!("{}…", &text[..cut]) +} + +/// When a document first stood, as the time it was admitted where it began: an imported document carries the source +/// receipt's time, any other its own admission. +pub(crate) fn origin_time(admission: &AdmissionFacts) -> &str { + admission.provenance.imported_from.as_ref().map_or(admission.admitted_at.as_str(), |source| source.committed_at.0.as_str()) +} + +/// The sort key of oldest first: origin time, then the mind's own admission ordinal for equal times. +pub(crate) fn origin_order(admission: &AdmissionFacts) -> (&str, u64) { + (origin_time(admission), admission.ordinal) } /// One recalled document as its line, or None for a kind recall does not show. diff --git a/crates/mind-body/src/testkit.rs b/crates/mind-body/src/testkit.rs index ec04a2a..1ad8af9 100644 --- a/crates/mind-body/src/testkit.rs +++ b/crates/mind-body/src/testkit.rs @@ -18,7 +18,7 @@ use eureka_pipeline::{ PipelineTarget, QuestionOption, ResolutionOutcome, RulingAuthority, RunOperator, RunTurn, Sha, Slug, StructuralDelta, TargetInvariant, }; use huginn_mind::{ - Faculty, HuginnMindRequest, HuginnMindResponse, Mind, OwnedRedbMessagePackBackingStore, PipelineAdmissionBatch, PipelineAdmissionOutcome, + Faculty, HuginnMindRequest, ImportSource, ImportSource, HuginnMindResponse, Mind, OwnedRedbMessagePackBackingStore, PipelineAdmissionBatch, PipelineAdmissionOutcome, PipelineDocumentView, PipelineProvenance, PipelineSelectionPage, }; use tempfile::TempDir; @@ -155,6 +155,46 @@ impl TestMind { self.mind.borrow_mut().admit(batch, self.now.get()) } + /// Admits as an import of a giver's receipt committed at `committed_at`, one second after the last admission here. + pub(crate) fn committed_imported(&self, committed_at: &str, documents: Vec) { + self.now.set(self.now.get() + Duration::seconds(1)); + let source = ImportSource { + instance: Slug("giver".into()), + receipt_id: format!("giver-{committed_at}").as_str().into(), + ordinal: 1, + committed_at: committed_at.into(), + faculty: Faculty::Hands, + agent: "test".into(), + session: "test".into(), + tool: "test".into(), + external: vec![], + }; + let provenance = PipelineProvenance { faculty: Faculty::Hands, agent: "test".into(), session: "test".into(), tool: "test".into(), imported_from: Some(source) }; + let batch = PipelineAdmissionBatch { instance: self.instance.clone(), provenance, documents }; + let outcome = self.mind.borrow_mut().admit(batch, self.now.get()); + assert!(matches!(outcome, PipelineAdmissionOutcome::Committed { .. }), "{outcome:?}"); + } + + /// Admits as an import of a giver's receipt committed at `committed_at`, one second after the last admission here. + pub(crate) fn committed_imported(&self, committed_at: &str, documents: Vec) { + self.now.set(self.now.get() + Duration::seconds(1)); + let source = ImportSource { + instance: Slug("giver".into()), + receipt_id: format!("giver-{committed_at}").as_str().into(), + ordinal: 1, + committed_at: committed_at.into(), + faculty: Faculty::Hands, + agent: "test".into(), + session: "test".into(), + tool: "test".into(), + external: vec![], + }; + let provenance = PipelineProvenance { faculty: Faculty::Hands, agent: "test".into(), session: "test".into(), tool: "test".into(), imported_from: Some(source) }; + let batch = PipelineAdmissionBatch { instance: self.instance.clone(), provenance, documents }; + let outcome = self.mind.borrow_mut().admit(batch, self.now.get()); + assert!(matches!(outcome, PipelineAdmissionOutcome::Committed { .. }), "{outcome:?}"); + } + pub(crate) fn committed(&self, documents: Vec) { let outcome = self.put(documents); assert!(matches!(outcome, PipelineAdmissionOutcome::Committed { .. }), "{outcome:?}");