diff --git a/crates/eureka-pipeline/golden/envelopes.txt b/crates/eureka-pipeline/golden/envelopes.txt index e7b50f8..84966a2 100644 --- a/crates/eureka-pipeline/golden/envelopes.txt +++ b/crates/eureka-pipeline/golden/envelopes.txt @@ -37,6 +37,7 @@ resolution.fixed-by-some eureka-state:resolution:question.Q1.n1 epiphany.pipelin resolution.deferred eureka-state:resolution:question.Q1.n1 epiphany.pipeline.resolution.v2 EpiphanyPipelineResolutionDocument 9185a77375626a65637482a46b696e64a85175657374696f6ea26964b8657572656b612d73746174653a7175657374696f6e3a5131a873657175656e636501a76f7574636f6d6581a8446566657272656481a2746f82a46b696e64a8466f6c6c6f775570a26964bb657572656b612d73746174653a666f6c6c6f775f75703a46552d34a9726174696f6e616c65a852756c656420412eab7265736f6c7665645f6f6eaa323032362d30392d3135 resolution.recorded eureka-state:resolution:question.Q1.n1 epiphany.pipeline.resolution.v2 EpiphanyPipelineResolutionDocument 9185a77375626a65637482a46b696e64a85175657374696f6ea26964b8657572656b612d73746174653a7175657374696f6e3a5131a873657175656e636501a76f7574636f6d6581a85265636f7264656481a6726561736f6ea64e6f7465642ea9726174696f6e616c65a852756c656420412eab7265736f6c7665645f6f6eaa323032362d30392d3135 resolution.withdrawn eureka-state:resolution:question.Q1.n1 epiphany.pipeline.resolution.v2 EpiphanyPipelineResolutionDocument 9185a77375626a65637482a46b696e64a85175657374696f6ea26964b8657572656b612d73746174653a7175657374696f6e3a5131a873657175656e636501a76f7574636f6d6581a957697468647261776e81a6726561736f6ea94d697374616b656e2ea9726174696f6e616c65a852756c656420412eab7265736f6c7665645f6f6eaa323032362d30392d3135 +resolution.slept eureka-state:resolution:question.Q1.n1 epiphany.pipeline.resolution.v2 EpiphanyPipelineResolutionDocument 9185a77375626a65637482a46b696e64a85175657374696f6ea26964b8657572656b612d73746174653a7175657374696f6e3a5131a873657175656e636501a76f7574636f6d6581a5536c65707482a46c696e65b04c657420746865207265737420676f2eab6461795f7468726f75676882a46b696e64a64d656d6f7279a26964b7657572656b612d73746174653a6d656d6f72793a6d2d31a9726174696f6e616c65a852756c656420412eab7265736f6c7665645f6f6eaa323032362d30392d3135 ruling.authority-standing eureka-state:ruling:R8 epiphany.pipeline.ruling.v2 EpiphanyPipelineRulingDocument 918aa863616d706169676eac657572656b612d7374617465a56c6162656ca25238a57469746c65b9416e20696e7374616e6365206f776e7320697473206d696e64a7616e7377657273b8657572656b612d73746174653a7175657374696f6e3a5131a663686f696365a141a672756c696e67ba416e20696e7374616e6365206f776e7320697473206d696e642eae6f70657261746f725f71756f7465bd616c6c207265636f6d6d656e646174696f6e732c20676f206168656164a872756c65645f6f6eaa323032362d30392d3135aa707265636564656e74739185a47265706fb147616d6543756c742f4165746865726961a6636f6d6d6974d92861616161616161616161616161616161616161616161616161616161616161616161616161616161a46b696e64a652756c696e67a26964b363756c7463616368653a72756c696e673a5231ae7061796c6f61645f736861323536d94062626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262a9617574686f72697479a85374616e64696e67 ruling.authority-defaulted eureka-state:ruling:R8 epiphany.pipeline.ruling.v2 EpiphanyPipelineRulingDocument 918aa863616d706169676eac657572656b612d7374617465a56c6162656ca25238a57469746c65b9416e20696e7374616e6365206f776e7320697473206d696e64a7616e7377657273b8657572656b612d73746174653a7175657374696f6e3a5131a663686f696365a141a672756c696e67ba416e20696e7374616e6365206f776e7320697473206d696e642eae6f70657261746f725f71756f7465bd616c6c207265636f6d6d656e646174696f6e732c20676f206168656164a872756c65645f6f6eaa323032362d30392d3135aa707265636564656e74739185a47265706fb147616d6543756c742f4165746865726961a6636f6d6d6974d92861616161616161616161616161616161616161616161616161616161616161616161616161616161a46b696e64a652756c696e67a26964b363756c7463616368653a72756c696e673a5231ae7061796c6f61645f736861323536d94062626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262a9617574686f72697479a944656661756c746564 ruling.options-none eureka-state:ruling:R8 epiphany.pipeline.ruling.v2 EpiphanyPipelineRulingDocument 918aa863616d706169676eac657572656b612d7374617465a56c6162656ca25238a57469746c65b9416e20696e7374616e6365206f776e7320697473206d696e64a7616e7377657273c0a663686f696365c0a672756c696e67ba416e20696e7374616e6365206f776e7320697473206d696e642eae6f70657261746f725f71756f7465c0a872756c65645f6f6eaa323032362d30392d3135aa707265636564656e747390a9617574686f72697479a84f70657261746f72 diff --git a/crates/eureka-pipeline/src/lib.rs b/crates/eureka-pipeline/src/lib.rs index 54c2f43..1aec6dc 100644 --- a/crates/eureka-pipeline/src/lib.rs +++ b/crates/eureka-pipeline/src/lib.rs @@ -1096,6 +1096,9 @@ pub enum ResolutionOutcome { Deferred { to: PipelineRef }, Recorded { reason: Line }, Withdrawn { reason: Line }, + /// How a Sleep run ended when the ledger took her reply: `line` is her one line, `day_through` the last memory + /// of her day she was shown (none when the day was empty). It is the one place a night's cut line is kept. + Slept { line: Line, day_through: Option }, } impl Bounded for ResolutionOutcome { @@ -1109,6 +1112,10 @@ impl Bounded for ResolutionOutcome { } Self::Deferred { to } => to.validate(&format!("{field}.to")), Self::Recorded { reason } | Self::Withdrawn { reason } => reason.validate(&format!("{field}.reason")), + Self::Slept { line, day_through } => { + line.validate(&format!("{field}.line"))?; + day_through.validate(&format!("{field}.day_through")) + } } } } @@ -1892,6 +1899,7 @@ mod tests { out.push(("resolution.deferred".into(), resolution(ResolutionOutcome::Deferred { to: by(PipelineKind::FollowUp, "follow_up", "FU-4") }))); out.push(("resolution.recorded".into(), resolution(ResolutionOutcome::Recorded { reason: "Noted.".into() }))); out.push(("resolution.withdrawn".into(), resolution(ResolutionOutcome::Withdrawn { reason: "Mistaken.".into() }))); + out.push(("resolution.slept".into(), resolution(ResolutionOutcome::Slept { line: "Let the rest go.".into(), day_through: Some(by(PipelineKind::Memory, "memory", "m-1")) }))); for (name, authority) in [("standing", RulingAuthority::Standing), ("defaulted", RulingAuthority::Defaulted)] { let mut r = ruling_sample(); r.authority = authority; @@ -3069,6 +3077,16 @@ mod tests { Err(PipelineRefusal::FieldBound { field: "resolution.outcome.reason".into(), limit: 1000, actual: 1001 }) ); } + + // A night's closing is bounded on both of its fields: her line like any `Line`, and the memory it names + // like any referent. + let run = PipelineRef { kind: PipelineKind::Run, id: id("run", "night") }; + let memory = PipelineRef { kind: PipelineKind::Memory, id: id("memory", "m-1") }; + let slept = |line: Line, day_through: Option| resolved(run.clone(), ResolutionOutcome::Slept { line, day_through }); + assert_eq!(slept("ok".into(), Some(memory.clone())), Ok(())); + assert_eq!(slept("ok".into(), None), Ok(())); + assert_eq!(slept(Line("a".repeat(1001)), None), Err(PipelineRefusal::FieldBound { field: "resolution.outcome.line".into(), limit: 1000, actual: 1001 })); + refused("resolution.outcome.day_through.id", slept("ok".into(), Some(PipelineRef { kind: PipelineKind::Memory, id: id("ruling", "R8") }))); } /// Ruling B: a fix names the tree where the finding stopped being true, diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs index 710b38a..b7e9ac3 100644 --- a/crates/huginn-mind/src/admission.rs +++ b/crates/huginn-mind/src/admission.rs @@ -32,7 +32,7 @@ use chrono::{DateTime, Utc}; use cultcache_rs::{CultCache, CultCacheEnvelope}; use eureka_pipeline::{ ClaimOutcome, FindingConfidence, Line, PipelineDocument, PipelineKind, PipelineMemory, PipelineRef, PipelineRefusal, - PipelineResolution, PipelineRun, PipelineStewardship, ResolutionOutcome, RulingAuthority, Short, Slug, pipeline_key, + PipelineResolution, PipelineRun, PipelineStewardship, ResolutionOutcome, RulingAuthority, RunTurn, Short, Slug, pipeline_key, validate_pipeline_write_envelope, }; use schemars::JsonSchema; @@ -709,6 +709,7 @@ fn outcome_name(outcome: &ResolutionOutcome) -> &'static str { ResolutionOutcome::Deferred { .. } => "Deferred", ResolutionOutcome::Recorded { .. } => "Recorded", ResolutionOutcome::Withdrawn { .. } => "Withdrawn", + ResolutionOutcome::Slept { .. } => "Slept", } } @@ -743,6 +744,7 @@ fn matrix(subject: PipelineKind, outcome: &ResolutionOutcome) -> bool { (K::Stewardship, O::Superseded { by }) => all(by, K::Stewardship), (K::Stewardship, O::Withdrawn { .. }) => true, (K::Run, O::Recorded { .. } | O::Withdrawn { .. }) => true, + (K::Run, O::Slept { day_through, .. }) => day_through.as_ref().is_none_or(|memory| memory.kind == K::Memory), (K::Memory, O::Deferred { to }) => to.kind == K::FollowUp, (K::Memory, O::Recorded { .. } | O::Withdrawn { .. }) => true, (K::Memory, O::Superseded { by }) => all(by, K::Memory), @@ -833,8 +835,9 @@ fn resolution_rule(docs: &Docs<'_>, resolution: &PipelineResolution) -> Result<( { run_rule(docs, &reinstating.subject.id.0, run, false)?; } - for (_, referent) in outcome_citations(&resolution.outcome) { - if !docs.in_force(referent.kind, &referent.id.0) { + for (role, referent) in outcome_citations(&resolution.outcome) { + // The last memory a night was shown may have been consumed by that night's own batches. + if role != CitationRole::DayThrough && !docs.in_force(referent.kind, &referent.id.0) { return Err(MindRefusal::CitesResolvedDocument { kind: referent.kind, id: referent.id.0 }); } } @@ -863,6 +866,18 @@ fn resolution_rule(docs: &Docs<'_>, resolution: &PipelineResolution) -> Result<( return Err(incompatible()); } } + ResolutionOutcome::Slept { day_through, .. } => { + // A night's closing is a Sleep run's, and the memory it names was admitted before this batch (every + // memory in the image is hers: A5). + if !matches!(docs.find(K::Run, subject_id), Some(D::Run(run)) if run.turn == RunTurn::Sleep) { + return Err(incompatible()); + } + if let Some(memory) = day_through + && docs.in_image(K::Memory, &memory.id.0).is_none() + { + return Err(incompatible()); + } + } _ => {} } Ok(()) @@ -2719,6 +2734,46 @@ mod tests { assert_eq!(refusal(admit(&mut mind, vec![mind_run("s2", RunTurn::Sleep, &[])])), live("s"), "one night at a time"); } + /// A Slept closing ends a Sleep run, and only that; the memory it names was admitted before it, and may have + /// been consumed since. + #[test] + fn a_slept_closing_is_a_sleep_runs_and_names_an_earlier_memory_of_hers() { + let incompatible = |subject_kind: K| MindRefusal::IncompatibleResolution { subject_kind, outcome: "Slept".into() }; + let m = |label: &str| r(K::Memory, &memory_key(label)); + let slept = |subject: PipelineRef, through: Option| resolution(subject, ResolutionOutcome::Slept { line: "ok".into(), day_through: through }); + let mut mind = with_specs(); + committed(admit(&mut mind, vec![D::Ruling(ruling("R1")), memory("m1", MemorySort::Idea, "one", &[]), memory("m2", MemorySort::Idea, "two", &[])])); + committed(admit(&mut mind, vec![mind_run("p", RunTurn::PersonaTurn, &[])])); + assert_eq!(refusal(admit(&mut mind, vec![slept(r(K::Run, &run_key("p")), None)])), incompatible(K::Run), "a persona run is not a night"); + committed(admit(&mut mind, vec![recorded("p")])); + committed(admit(&mut mind, vec![mind_run("self", RunTurn::SelfRun, &[])])); + assert_eq!(refusal(admit(&mut mind, vec![slept(r(K::Run, &run_key("self")), None)])), incompatible(K::Run), "a Self run is not a night"); + committed(admit(&mut mind, vec![recorded("self")])); + committed(admit(&mut mind, vec![mind_run("s", RunTurn::Sleep, &[])])); + let night = || r(K::Run, &run_key("s")); + assert_eq!(refusal(admit(&mut mind, vec![slept(m("m1"), None)])), incompatible(K::Memory), "a memory is not slept"); + let ruling_ref = r(K::Ruling, &id("ruling", "R1")); + assert_eq!(refusal(admit(&mut mind, vec![slept(night(), Some(ruling_ref))])), incompatible(K::Run), "the cut line is a memory"); + let same_batch = vec![memory("m3", MemorySort::Idea, "three", &[]), slept(night(), Some(m("m3")))]; + assert_eq!(refusal(admit(&mut mind, same_batch)), incompatible(K::Run), "a memory of this very batch was not shown"); + assert_eq!( + refusal(admit(&mut mind, vec![slept(night(), Some(m("nope")))])), + MindRefusal::MissingReference { kind: K::Memory, id: memory_key("nope") }, + "a memory that does not exist" + ); + // A memory the night itself consumed is still the last it was shown. + committed(admit(&mut mind, consolidation("m4", &["m1"]))); + committed(admit(&mut mind, vec![slept(night(), Some(m("m1")))])); + assert_eq!( + refusal(admit(&mut mind, vec![resolution_n(night(), 2, ResolutionOutcome::Slept { line: "again".into(), day_through: None })])), + MindRefusal::AlreadyResolved { subject: run_key("s") }, + "the first closing in force is the only one" + ); + // An empty day names no memory. + committed(admit(&mut mind, vec![mind_run("s2", RunTurn::Sleep, &[])])); + committed(admit(&mut mind, vec![slept(r(K::Run, &run_key("s2")), None)])); + } + fn research_key(pass: &str, label: &str) -> String { format!("{CAMPAIGN}:research:{pass}.{label}") } diff --git a/crates/huginn-mind/src/docs.rs b/crates/huginn-mind/src/docs.rs index eab5479..8198eb4 100644 --- a/crates/huginn-mind/src/docs.rs +++ b/crates/huginn-mind/src/docs.rs @@ -397,10 +397,11 @@ pub enum CitationRole { Toward, Run, Reads, + DayThrough, } impl CitationRole { - pub const ALL: [CitationRole; 23] = [ + pub const ALL: [CitationRole; 24] = [ Self::RaisedIn, Self::Answers, Self::Rulings, @@ -424,6 +425,7 @@ impl CitationRole { Self::Toward, Self::Run, Self::Reads, + Self::DayThrough, ]; /// The wire spelling, equal to the referring field's name. @@ -452,6 +454,7 @@ impl CitationRole { Self::Toward => "toward", Self::Run => "run", Self::Reads => "reads", + Self::DayThrough => "day_through", } } @@ -470,7 +473,7 @@ impl CitationRole { Self::CutReport | Self::Findings => K::Verdict, Self::Verdict => K::Finding, Self::Source => K::FollowUp, - Self::Subject | Self::SupersededBy | Self::ResolvedBy | Self::DeferredTo => K::Resolution, + Self::Subject | Self::SupersededBy | Self::ResolvedBy | Self::DeferredTo | Self::DayThrough => K::Resolution, Self::Documents => K::HandOff, Self::Claims => K::Run, Self::Consolidates => K::Memory, @@ -494,7 +497,7 @@ impl CitationRole { Self::Findings => vec![K::Finding], Self::Verdict => vec![K::Verdict], Self::Claims => vec![K::CutSpec, K::Finding, K::FollowUp], - Self::Consolidates => vec![K::Memory], + Self::Consolidates | Self::DayThrough => vec![K::Memory], Self::Run => vec![K::Run], Self::RaisedIn | Self::Source @@ -530,6 +533,7 @@ pub(crate) fn outcome_citations(outcome: &ResolutionOutcome) -> Vec<(CitationRol ResolutionOutcome::Answered { by } => vec![(CitationRole::ResolvedBy, by.clone())], ResolutionOutcome::Fixed { by: Some(by), .. } => vec![(CitationRole::ResolvedBy, by.clone())], ResolutionOutcome::Deferred { to } => vec![(CitationRole::DeferredTo, to.clone())], + ResolutionOutcome::Slept { day_through, .. } => day_through.iter().map(|memory| (CitationRole::DayThrough, memory.clone())).collect(), ResolutionOutcome::Fixed { by: None, .. } | ResolutionOutcome::Recorded { .. } | ResolutionOutcome::Withdrawn { .. } => { vec![] } diff --git a/crates/huginn-mind/src/query.rs b/crates/huginn-mind/src/query.rs index a3a43b0..670e345 100644 --- a/crates/huginn-mind/src/query.rs +++ b/crates/huginn-mind/src/query.rs @@ -1796,6 +1796,10 @@ mod tests { let (older, newer) = (format!("{INSTANCE}:memory:m1"), format!("{INSTANCE}:memory:m2")); committed(admit(&mut mind, vec![memory("m1", MemorySort::Idea, "one", &[])])); committed(admit(&mut mind, crate::fixtures::consolidation("m2", &["m1"]))); + let night_id = format!("{INSTANCE}:run:night"); + committed(admit(&mut mind, vec![crate::fixtures::mind_run("night", eureka_pipeline::RunTurn::Sleep, &[])])); + committed(admit(&mut mind, vec![resolution(r(K::Run, &night_id), ResolutionOutcome::Slept { line: "ok".into(), day_through: Some(r(K::Memory, &older)) })])); + let night_closed = format!("{INSTANCE}:resolution:run.night.n1"); let (note, way) = (id("research", "p.a"), id("idea", "p.i")); let D::Research(mut note_document) = research("p", "a", "A fact.") else { unreachable!() }; note_document.serves = vec![r(K::Question, &q1)]; @@ -1835,6 +1839,7 @@ mod tests { (CitationRole::Subject, &f2_closed, &f2), (CitationRole::Subject, &spec_closed, &spec), (CitationRole::Subject, &older_closed, &older), + (CitationRole::Subject, &night_closed, &night_id), (CitationRole::SupersededBy, &spec_closed, &spec_r2), (CitationRole::SupersededBy, &older_closed, &newer), (CitationRole::ResolvedBy, &n1, &r1), @@ -1844,6 +1849,7 @@ mod tests { (CitationRole::Claims, &run_id, &spec_r2), (CitationRole::Claims, &run_id, &fu), (CitationRole::Run, &reply_id, &run_id), + (CitationRole::DayThrough, &night_closed, &older), (CitationRole::Reads, &thought_id, &f1), (CitationRole::Reads, &thought_id, &q1), (CitationRole::Consolidates, &newer, &older), @@ -1852,7 +1858,7 @@ mod tests { (CitationRole::RestsOn, &way, ¬e), (CitationRole::Toward, &way, &q1), ]; - assert_eq!(CitationRole::ALL.len(), 23); + assert_eq!(CitationRole::ALL.len(), 24); for role in CitationRole::ALL { let selection = Selection { cited: Some(Incoming { role: role.name().into(), exists: true }), ..Selection::default() }; diff --git a/crates/huginn-mind/src/rows.rs b/crates/huginn-mind/src/rows.rs index 6daec9c..425eac3 100644 --- a/crates/huginn-mind/src/rows.rs +++ b/crates/huginn-mind/src/rows.rs @@ -64,7 +64,7 @@ pub(crate) enum Alias { } const FACULTIES: [&str; 7] = ["SelfFaculty", "Imagination", "Hands", "Soul", "Life", "Eyes", "Operator"]; -const OUTCOMES: [&str; 6] = ["Superseded", "Answered", "Fixed", "Deferred", "Recorded", "Withdrawn"]; +const OUTCOMES: [&str; 7] = ["Superseded", "Answered", "Fixed", "Deferred", "Recorded", "Withdrawn", "Slept"]; const BOOLEANS: [&str; 2] = ["true", "false"]; const MEMORY_ORIGINS: [&str; 5] = ["Room", "Direct", "Wake", "SelfRun", "Sleep"]; /// The one value a presence alias answers with; a row without it answers nothing. @@ -199,6 +199,7 @@ fn outcome_name(outcome: &ResolutionOutcome) -> &'static str { ResolutionOutcome::Deferred { .. } => "Deferred", ResolutionOutcome::Recorded { .. } => "Recorded", ResolutionOutcome::Withdrawn { .. } => "Withdrawn", + ResolutionOutcome::Slept { .. } => "Slept", } } @@ -330,7 +331,7 @@ impl Row for SelectionRow { } } -/// The organ's closed lists: twenty schemas, twenty aliases, twenty-three roles. +/// The organ's closed lists: twenty schemas, twenty aliases, twenty-four roles. /// A schema's name is its id, so the substrate's alias matching resolves /// nothing beyond exact ids. pub(crate) struct Vocabulary; @@ -489,7 +490,7 @@ mod tests { (K::Verdict, &["cut_report", "findings"]), (K::Finding, &["verdict"]), (K::FollowUp, &["source"]), - (K::Resolution, &["subject", "superseded_by", "resolved_by", "deferred_to"]), + (K::Resolution, &["subject", "superseded_by", "resolved_by", "deferred_to", "day_through"]), (K::Instance, &[]), (K::Stewardship, &[]), (K::HandOff, &["documents"]), diff --git a/crates/mind-body/src/persona.rs b/crates/mind-body/src/persona.rs index e13a968..5b5aa96 100644 --- a/crates/mind-body/src/persona.rs +++ b/crates/mind-body/src/persona.rs @@ -2638,6 +2638,7 @@ pub(crate) fn outcome_words(outcome: &ResolutionOutcome) -> String { ResolutionOutcome::Answered { by } => format!("answered by {}", by.id.0), ResolutionOutcome::Fixed { commit, .. } => format!("fixed by {}", commit.0), ResolutionOutcome::Deferred { to } => format!("deferred to {}", to.id.0), + ResolutionOutcome::Slept { line, .. } => format!("slept: {}", line.0), } } diff --git a/crates/mind-body/src/persona/tests.rs b/crates/mind-body/src/persona/tests.rs index e6f09fc..425d620 100644 --- a/crates/mind-body/src/persona/tests.rs +++ b/crates/mind-body/src/persona/tests.rs @@ -7089,8 +7089,15 @@ impl Rig { let label = label_of(run); let view = self.mind.view(K::Resolution, &format!("{INSTANCE}:resolution:run.{label}.n1")).expect("the night is closed"); let PipelineDocument::Resolution(resolution) = view.document else { unreachable!() }; - let ResolutionOutcome::Recorded { reason } = resolution.outcome else { panic!("a night is closed Recorded") }; - reason.0 + outcome_words(&resolution.outcome) + } + + /// The memory the Sleep run's closing names as the last of her day she was shown. + fn night_through(&self, run: &PipelineRef) -> Option { + let view = self.mind.view(K::Resolution, &format!("{INSTANCE}:resolution:run.{}.n1", label_of(run))).expect("the night is closed"); + let PipelineDocument::Resolution(resolution) = view.document else { unreachable!() }; + let ResolutionOutcome::Slept { day_through, .. } = resolution.outcome else { panic!("a night that slept closes Slept") }; + day_through.map(|memory| memory.id.0) } fn the_night(&self) -> Outcome { @@ -7120,7 +7127,7 @@ fn the_night_line_holds_the_day_whole_and_prescribes_nothing() { // An earlier night of hers, opened 30 hours before this one, closed. rig.mind.admit_at(at(NIGHT_AT_MS - 30 * HOUR_MS)); rig.mind.committed(vec![crate::testkit::run("slept-before", RunTurn::Sleep, RunOperator::Mind, &[])]); - rig.mind.committed(vec![crate::testkit::close(crate::testkit::run_ref("slept-before"), ResolutionOutcome::Recorded { reason: "slept: ok".into() })]); + rig.mind.committed(vec![crate::testkit::close(crate::testkit::run_ref("slept-before"), ResolutionOutcome::Slept { line: "ok".into(), day_through: None })]); // Her day: after that night opened. A Sleep-origin memory of it is not part of it. rig.remembered("d1", MemorySort::Decided, "Ship on Friday.", &["@op:chat"], room(), 20, &["$e1"]); rig.remembered("d2", MemorySort::Learned, "A private thing.", &["@kiko:chat"], dm("!dm:chat", &["@kiko:chat"]), 10, &[]); @@ -7228,6 +7235,7 @@ fn a_decoded_night_admits_whole_operations_and_the_run_closes_with_her_line() { assert_eq!(rig.the_memory(&format!("{tag}-c0")).origin, MemoryOrigin::Sleep { run: tag.as_str().into() }, "a public consolidation is formed in sleep"); assert_eq!(rig.the_memory(&format!("{tag}-r0")).origin, MemoryOrigin::Sleep { run: tag.as_str().into() }); assert_eq!(rig.night_closing(&run), "slept: I looked at the release again and let the rest go."); + assert_eq!(rig.night_through(&run), Some(memory_id("wrong")), "the closing names the last memory of her day she was shown"); // She reads it on waking, as the line of her last runs. let lines = recent_runs(&rig.mind, &Slug(INSTANCE.into())).unwrap(); assert!(lines[0].starts_with(&format!("{tag} Sleep: slept: I looked")), "{lines:?}"); diff --git a/crates/mind-body/src/sleep.rs b/crates/mind-body/src/sleep.rs index 11af68e..7dfbea5 100644 --- a/crates/mind-body/src/sleep.rs +++ b/crates/mind-body/src/sleep.rs @@ -196,29 +196,22 @@ fn sleeps(mind: &dyn MindPort) -> Result> { Ok(found) } -const SLEPT: &str = "slept: "; -const THROUGH: &str = "; day through ordinal "; - -/// Where the newest of `earlier` that closed `slept:` left her day: the admission ordinal of the last memory of -/// the day it was shown (its own run's ordinal when it was shown none). A night that failed, or whose run never -/// closed, left its day uncovered, so it is not an anchor. +/// Where the newest of `earlier` whose closing in force is `Slept` left her day: the admission ordinal of the +/// memory it names as the last of the day it was shown, in force or not (its own run's ordinal when it was shown +/// none). A night with no such closing (it failed, its run never closed, a hand closed it, or its `Slept` closing +/// was withdrawn) left its day uncovered, so it is not an anchor. fn covered_through(mind: &dyn MindPort, earlier: &[&Night]) -> Result> { for night in earlier.iter().rev() { - let closings = Selection { cites: Some(citing(&night.run, Some("subject"))), ..of_kind(PipelineKind::Resolution) }; - let latest = documents(mind, &closings)? - .into_iter() - .filter_map(|view| match view.document { - PipelineDocument::Resolution(resolution) => Some(resolution), - _ => None, - }) - .max_by_key(|resolution| resolution.sequence); - if let Some(resolution) = latest - && let ResolutionOutcome::Recorded { reason } = &resolution.outcome - && reason.0.as_str().starts_with(SLEPT) - { - let through = resolution.rationale.0.rsplit_once(THROUGH).and_then(|(_, ordinal)| ordinal.trim().parse().ok()); - return Ok(Some(through.unwrap_or(night.ordinal))); - } + let closings = in_force(any_of(Selection { cites: Some(citing(&night.run, Some("subject"))), ..of_kind(PipelineKind::Resolution) }, "outcome", &["Slept"])); + let slept = documents(mind, &closings)?.into_iter().find_map(|view| match view.document { + PipelineDocument::Resolution(PipelineResolution { outcome: ResolutionOutcome::Slept { day_through, .. }, .. }) => Some(day_through), + _ => None, + }); + let Some(through) = slept else { continue }; + let Some(memory) = through else { return Ok(Some(night.ordinal)) }; + let named = Selection { keys: Some(vec![memory.id.0.clone()]), ..of_kind(PipelineKind::Memory) }; + let held = headers(mind, &named)?.into_iter().next().ok_or_else(|| anyhow!("the last memory a night was shown could not be read"))?; + return Ok(Some(held.admission.ordinal)); } Ok(None) } @@ -233,7 +226,7 @@ fn is_sleep_made(held: &Held) -> bool { matches!(held.memory.origin, MemoryOrigin::Sleep { .. }) || !held.memory.consolidates.is_empty() } -/// The night, derived. Her day: her memories in force admitted after what her newest night that closed `slept:` +/// The night, derived. Her day: her memories in force admitted after what her newest night whose closing in force is `Slept` /// covered (a night that failed covered nothing), else in the 24 hours before her first night opened; formed /// otherwise than in sleep, oldest first, at most `DAY_MAX`, the rest waiting for the next night. Her open Asked /// and Committed items older than the day. A tide of `TIDE` older memories of other sorts, from an offset that @@ -475,8 +468,8 @@ pub(crate) fn pack(units: Vec>) -> Vec }, + /// `through` is the last memory of the day the night was shown, if it was shown any. + Slept { reply: SleepReply, through: Option }, Failed { refusals: u32, why: String }, } @@ -552,7 +545,7 @@ impl SessionInbox for SleepInbox<'_, C, S> { match decode(&text, self.context) { Ok(reply) => match self.admit(&reply) { Ok(()) => { - self.finish = Some(Finish::Slept { reply, through: self.context.day.last().map(|held| held.ordinal) }); + self.finish = Some(Finish::Slept { reply, through: self.context.day.last().map(|held| reference(&held.id)) }); Ok((true, false)) } Err(error) => { @@ -565,33 +558,35 @@ impl SessionInbox for SleepInbox<'_, C, S> { } } -/// What a night closes with: the reason (her line, or the failure) and the rationale (the counts, and where her -/// day was covered through). -fn closing(finish: &Finish) -> (String, String) { +/// What a night closes with: the outcome (`Slept` with her line and where her day was covered through, or +/// `Recorded` with the failure) and the rationale (the counts, display only: nothing reads it). +fn closing(finish: &Finish) -> (ResolutionOutcome, String) { match finish { Finish::Slept { reply, through } => ( - format!("{SLEPT}{}", reply.night), + ResolutionOutcome::Slept { line: reply.night.as_str().into(), day_through: through.clone() }, format!( - "kept {}, consolidated {} from {}, retired {}, formed {}{}", + "kept {}, consolidated {} from {}, retired {}, formed {}", reply.keep.len(), reply.consolidate.len(), reply.consolidate.iter().map(|consolidation| consolidation.consumes.len()).sum::(), reply.retire.len(), - reply.remember.len(), - through.map_or(String::new(), |ordinal| format!("{THROUGH}{ordinal}")) + reply.remember.len() ), ), - Finish::Failed { refusals, why } => (format!("failed after {refusals} refusals: {why}"), "The night ended without a reply the ledger took.".to_string()), + Finish::Failed { refusals, why } => ( + ResolutionOutcome::Recorded { reason: format!("failed after {refusals} refusals: {why}").as_str().into() }, + "The night ended without a reply the ledger took.".to_string(), + ), } } -/// Closes the night's run Recorded with her line and the counts, or the failure. +/// Closes the night's run: `Slept` with her line and the cut line, or `Recorded` with the failure. fn close(organs: &Organs, run: &PipelineRef, finish: &Finish) -> Result<()> { - let (reason, rationale) = closing(finish); + let (outcome, rationale) = closing(finish); let resolution = PipelineResolution { subject: run.clone(), sequence: next_sequence(organs.mind, run)?, - outcome: ResolutionOutcome::Recorded { reason: reason.as_str().into() }, + outcome, rationale: rationale.as_str().into(), resolved_on: date(organs.clock.now()), }; diff --git a/crates/mind-body/src/sleep/tests.rs b/crates/mind-body/src/sleep/tests.rs index bab175b..bec44a0 100644 --- a/crates/mind-body/src/sleep/tests.rs +++ b/crates/mind-body/src/sleep/tests.rs @@ -246,7 +246,7 @@ fn night_over(earlier: usize, memories: &[(String, MemorySort, MemoryOrigin, i64 let mut events: Vec<(i64, Vec>)> = Vec::new(); for n in 0..earlier { let label = format!("before{n}"); - let closed = crate::testkit::close(run_ref(&label), ResolutionOutcome::Recorded { reason: "slept: ok".into() }); + let closed = crate::testkit::close(run_ref(&label), ResolutionOutcome::Slept { line: "ok".into(), day_through: None }); events.push((T0 - 100 * 3_600_000 + n as i64, vec![vec![run(&label, RunTurn::Sleep, RunOperator::Mind, &[])], vec![closed]])); } for (label, sort, origin, offset) in memories { @@ -265,18 +265,29 @@ fn night_over(earlier: usize, memories: &[(String, MemorySort, MemoryOrigin, i64 /// A night's own ending, admitted: slept through what it was shown, or failed. fn close_night(mind: &TestMind, run: &PipelineRef, finish: &Finish) { - let (reason, rationale) = closing(finish); + let (outcome, rationale) = closing(finish); mind.committed(vec![PipelineDocument::Resolution(PipelineResolution { subject: run.clone(), sequence: 1, - outcome: ResolutionOutcome::Recorded { reason: reason.as_str().into() }, + outcome, rationale: rationale.as_str().into(), resolved_on: eureka_pipeline::Date("2026-10-09".into()), })]); } fn slept_over(context: &Context) -> Finish { - Finish::Slept { reply: SleepReply { night: "n".into(), keep: vec![], consolidate: vec![], retire: vec![], remember: vec![] }, through: context.day.last().map(|held| held.ordinal) } + Finish::Slept { reply: SleepReply { night: "n".into(), keep: vec![], consolidate: vec![], retire: vec![], remember: vec![] }, through: context.day.last().map(|held| reference(&held.id)) } +} + +/// A closing someone other than the organ wrote: a Recorded one, whatever its words say. +fn closed_by_hand(mind: &TestMind, run: &PipelineRef, reason: &str, rationale: &str) { + mind.committed(vec![PipelineDocument::Resolution(PipelineResolution { + subject: run.clone(), + sequence: 1, + outcome: ResolutionOutcome::Recorded { reason: reason.into() }, + rationale: rationale.into(), + resolved_on: eureka_pipeline::Date("2026-10-09".into()), + })]); } fn failed() -> Finish { @@ -406,6 +417,73 @@ fn a_night_that_failed_or_never_ran_covers_nothing_so_its_day_comes_back() { assert_eq!(day_ids(&fifth), vec![id("g0"), id("g1"), id("g2")]); } +#[test] +fn a_hand_written_closing_moves_no_cut_line() { + // Closed Recorded with the words and the numbers the old reader trusted: the day is owed all the same. + let days: Vec<_> = (0..3).map(|n| (format!("g{n}"), MemorySort::Learned, wake(), 10 + n as i64)).collect(); + let (mind, first) = night_over(0, &days); + mind.admit_at(at(T0 + 2000)); + closed_by_hand(&mind, &first, "slept: closed by hand", "kept 0; day through ordinal 999999999"); + let (_, next) = next_night(&mind, "second", 3000); + assert_eq!(day_ids(&next), vec![id("g0"), id("g1"), id("g2")], "nothing was shown, so nothing is dropped"); + + // A hundred-memory day closed by hand covers none of it; a Slept close over what was then shown covers it. + let days: Vec<_> = (0..100).map(|n| (format!("day{n:03}"), MemorySort::Learned, wake(), 10 + n as i64)).collect(); + let (mind, first) = night_over(0, &days); + mind.admit_at(at(T0 + 2000)); + closed_by_hand(&mind, &first, "slept: closed by hand", "kept 96 (by hand)"); + let (second, owed) = next_night(&mind, "second", 3000); + assert_eq!(day_ids(&owed), (0..96).map(|n| id(&format!("day{n:03}"))).collect::>(), "the overflow is not dropped by a hand"); + mind.admit_at(at(T0 + 3600)); + close_night(&mind, &second, &slept_over(&owed)); + let (_, third) = next_night(&mind, "third", 4000); + assert_eq!(day_ids(&third), (96..100).map(|n| id(&format!("day{n:03}"))).collect::>()); +} + +#[test] +fn a_slept_nights_closing_names_the_last_memory_it_was_shown_even_when_that_night_consumed_it() { + let days: Vec<_> = (0..100).map(|n| (format!("day{n:03}"), MemorySort::Learned, wake(), 10 + n as i64)).collect(); + let (mind, tonight) = night_over(0, &days); + let shown = context(&mind, &tonight, at(T0 + 5000)).unwrap(); + let (outcome, rationale) = closing(&slept_over(&shown)); + assert_eq!(outcome, ResolutionOutcome::Slept { line: "n".into(), day_through: Some(crate::testkit::reference(eureka_pipeline::PipelineKind::Memory, &id("day095"))) }); + assert_eq!(rationale, "kept 0, consolidated 0 from 0, retired 0, formed 0", "the rationale is the counts and says nothing of the cut line"); + // The night folds the last memory it was shown into a consolidation, so day095 leaves force. + mind.admit_at(at(T0 + 2000)); + let PipelineDocument::Memory(mut merged) = memory_from("merged", MemorySort::Learned, "Merged.", &[], wake(), 500) else { unreachable!() }; + let consumed = crate::testkit::reference(eureka_pipeline::PipelineKind::Memory, &id("day095")); + merged.consolidates = vec![consumed.clone()]; + let by = crate::testkit::reference(eureka_pipeline::PipelineKind::Memory, &id("merged")); + mind.committed(vec![PipelineDocument::Memory(merged), crate::testkit::close(consumed, ResolutionOutcome::Superseded { by: vec![by] })]); + mind.admit_at(at(T0 + 2500)); + close_night(&mind, &tonight, &slept_over(&shown)); + let (_, next) = next_night(&mind, "tomorrow", 3000); + assert_eq!(day_ids(&next), (96..100).map(|n| id(&format!("day{n:03}"))).collect::>(), "the cut line is read from the memory that is no longer in force"); +} + +#[test] +fn a_slept_closing_that_was_withdrawn_covers_nothing() { + let days: Vec<_> = (0..3).map(|n| (format!("f{n}"), MemorySort::Learned, wake(), 10 + n as i64)).collect(); + let (mind, first) = night_over(0, &days); + let shown = context(&mind, &first, at(T0 + 5000)).unwrap(); + mind.admit_at(at(T0 + 2000)); + close_night(&mind, &first, &slept_over(&shown)); + // The closing is withdrawn (the run is live again) and the run ends in failure: no Slept closing stands. + mind.admit_at(at(T0 + 2100)); + let closing_ref = crate::testkit::reference(eureka_pipeline::PipelineKind::Resolution, &format!("{INSTANCE}:resolution:run.tonight.n1")); + mind.committed(vec![crate::testkit::close(closing_ref, crate::testkit::withdrawn())]); + mind.admit_at(at(T0 + 2200)); + mind.committed(vec![PipelineDocument::Resolution(PipelineResolution { + subject: first.clone(), + sequence: 2, + outcome: ResolutionOutcome::Recorded { reason: "failed after 3 refusals: no reply the ledger took".into() }, + rationale: "The night ended without a reply the ledger took.".into(), + resolved_on: eureka_pipeline::Date("2026-10-09".into()), + })]); + let (_, next) = next_night(&mind, "second", 3000); + assert_eq!(day_ids(&next), vec![id("f0"), id("f1"), id("f2")], "a withdrawn Slept closing leaves its day owed"); +} + #[test] fn the_first_nights_day_is_the_twenty_four_hours_before_it_opened_to_the_second() { let hour = 3_600_000; diff --git a/schemas/cultnet/epiphany.pipeline.resolution.v2.schema.json b/schemas/cultnet/epiphany.pipeline.resolution.v2.schema.json index 09cab1d..3baa334 100644 --- a/schemas/cultnet/epiphany.pipeline.resolution.v2.schema.json +++ b/schemas/cultnet/epiphany.pipeline.resolution.v2.schema.json @@ -193,6 +193,37 @@ "Withdrawn" ], "type": "object" + }, + { + "additionalProperties": false, + "description": "How a Sleep run ended when the ledger took her reply: `line` is her one line, `day_through` the last memory\nof her day she was shown (none when the day was empty). It is the one place a night's cut line is kept.", + "properties": { + "Slept": { + "properties": { + "day_through": { + "anyOf": [ + { + "$ref": "#/$defs/PipelineRef" + }, + { + "type": "null" + } + ] + }, + "line": { + "$ref": "#/$defs/Line" + } + }, + "required": [ + "line" + ], + "type": "object" + } + }, + "required": [ + "Slept" + ], + "type": "object" } ] }, diff --git a/schemas/cultnet/huginn.mind_request.v1.schema.json b/schemas/cultnet/huginn.mind_request.v1.schema.json index df6bb55..a67e159 100644 --- a/schemas/cultnet/huginn.mind_request.v1.schema.json +++ b/schemas/cultnet/huginn.mind_request.v1.schema.json @@ -2609,6 +2609,37 @@ "required": [ "Withdrawn" ] + }, + { + "description": "How a Sleep run ended when the ledger took her reply: `line` is her one line, `day_through` the last memory\nof her day she was shown (none when the day was empty). It is the one place a night's cut line is kept.", + "type": "object", + "properties": { + "Slept": { + "type": "object", + "properties": { + "day_through": { + "anyOf": [ + { + "$ref": "#/$defs/PipelineRef" + }, + { + "type": "null" + } + ] + }, + "line": { + "$ref": "#/$defs/Line" + } + }, + "required": [ + "line" + ] + } + }, + "additionalProperties": false, + "required": [ + "Slept" + ] } ] }, diff --git a/schemas/cultnet/huginn.mind_response.v1.schema.json b/schemas/cultnet/huginn.mind_response.v1.schema.json index 93c58c7..33a5915 100644 --- a/schemas/cultnet/huginn.mind_response.v1.schema.json +++ b/schemas/cultnet/huginn.mind_response.v1.schema.json @@ -262,7 +262,8 @@ "rests_on", "toward", "run", - "reads" + "reads", + "day_through" ] }, "ClaimOutcome": { @@ -5325,6 +5326,37 @@ "required": [ "Withdrawn" ] + }, + { + "description": "How a Sleep run ended when the ledger took her reply: `line` is her one line, `day_through` the last memory\nof her day she was shown (none when the day was empty). It is the one place a night's cut line is kept.", + "type": "object", + "properties": { + "Slept": { + "type": "object", + "properties": { + "day_through": { + "anyOf": [ + { + "$ref": "#/$defs/PipelineRef" + }, + { + "type": "null" + } + ] + }, + "line": { + "$ref": "#/$defs/Line" + } + }, + "required": [ + "line" + ] + } + }, + "additionalProperties": false, + "required": [ + "Slept" + ] } ] },