From c9a8e73dbcde2878ba5cf499438c05b7a9957796 Mon Sep 17 00:00:00 2001 From: Eureka Date: Wed, 11 Nov 2026 11:39:17 +0000 Subject: [PATCH 1/5] pipeline: RulingAuthority::Steward names a peer steward's decision, her stewardship and her words Co-Authored-By: Claude Sonnet 5.5 --- crates/eureka-pipeline/golden/envelopes.txt | 1 + crates/eureka-pipeline/src/lib.rs | 96 ++++++++++++++++++- .../epiphany.pipeline.ruling.v2.schema.json | 57 +++++++++-- 3 files changed, 145 insertions(+), 9 deletions(-) diff --git a/crates/eureka-pipeline/golden/envelopes.txt b/crates/eureka-pipeline/golden/envelopes.txt index 3f8d54c..d1b1489 100644 --- a/crates/eureka-pipeline/golden/envelopes.txt +++ b/crates/eureka-pipeline/golden/envelopes.txt @@ -51,6 +51,7 @@ finding.plausible-blocker-preexisting-kinds eureka-state:finding:cut-3a.s2.F4 ep finding.severity-medium eureka-state:finding:cut-3a.s2.F4 epiphany.pipeline.finding.v2 EpiphanyPipelineFindingDocument 918da863616d706169676eac657572656b612d7374617465a776657264696374be657572656b612d73746174653a766572646963743a6375742d33612e7332a56c6162656ca24634a572616e676582a462617365a83566393832323864a468656164a83566393832323864aa636f6e666964656e6365a9436f6e6669726d6564a87365766572697479a64d656469756da5636c61696dd9214120646f74746564206c6162656c20636f6d706f7365732074776f206b6579732eaa696e76617269616e747391ab6d696e642d61646d697473a96c6f636174696f6e739183a470617468d9216372617465732f657572656b612d706970656c696e652f7372632f6c69622e7273a46c696e6501a8656e645f6c696e6509b06661696c7572655f7363656e6172696fbc54776f20646f63756d656e747320636c61696d206f6e65206b65792ea865766964656e63659183a46b696e64a454657374a76c6f6361746f72aa636172676f2074657374a6726573756c74a26f6baa707265636564656e747390a66f726967696eaa496e74726f6475636564 finding.severity-low eureka-state:finding:cut-3a.s2.F4 epiphany.pipeline.finding.v2 EpiphanyPipelineFindingDocument 918da863616d706169676eac657572656b612d7374617465a776657264696374be657572656b612d73746174653a766572646963743a6375742d33612e7332a56c6162656ca24634a572616e676582a462617365a83566393832323864a468656164a83566393832323864aa636f6e666964656e6365a9436f6e6669726d6564a87365766572697479a34c6f77a5636c61696dd9214120646f74746564206c6162656c20636f6d706f7365732074776f206b6579732eaa696e76617269616e747391ab6d696e642d61646d697473a96c6f636174696f6e739183a470617468d9216372617465732f657572656b612d706970656c696e652f7372632f6c69622e7273a46c696e6501a8656e645f6c696e6509b06661696c7572655f7363656e6172696fbc54776f20646f63756d656e747320636c61696d206f6e65206b65792ea865766964656e63659183a46b696e64a454657374a76c6f6361746f72aa636172676f2074657374a6726573756c74a26f6baa707265636564656e747390a66f726967696eaa496e74726f6475636564 ruling.authority-mind eureka-state:ruling:R8 epiphany.pipeline.ruling.v2 EpiphanyPipelineRulingDocument 918aa863616d706169676eac657572656b612d7374617465a56c6162656ca25238a57469746c65b9416e20696e7374616e6365206f776e7320697473206d696e64a7616e7377657273c0a663686f696365c0a672756c696e67ba416e20696e7374616e6365206f776e7320697473206d696e642eae6f70657261746f725f71756f7465c0a872756c65645f6f6eaa323032362d30392d3135aa707265636564656e74739185a47265706fb147616d6543756c742f4165746865726961a6636f6d6d6974d92861616161616161616161616161616161616161616161616161616161616161616161616161616161a46b696e64a652756c696e67a26964b363756c7463616368653a72756c696e673a5231ae7061796c6f61645f736861323536d94062626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262a9617574686f72697479a44d696e64 +ruling.authority-steward eureka-state:ruling:R8 epiphany.pipeline.ruling.v2 EpiphanyPipelineRulingDocument 918aa863616d706169676eac657572656b612d7374617465a56c6162656ca25238a57469746c65b9416e20696e7374616e6365206f776e7320697473206d696e64a7616e7377657273c0a663686f696365c0a672756c696e67ba416e20696e7374616e6365206f776e7320697473206d696e642eae6f70657261746f725f71756f7465c0a872756c65645f6f6eaa323032362d30392d3135aa707265636564656e74739185a47265706fb147616d6543756c742f4165746865726961a6636f6d6d6974d92861616161616161616161616161616161616161616161616161616161616161616161616161616161a46b696e64a652756c696e67a26964b363756c7463616368653a72756c696e673a5231ae7061796c6f61645f736861323536d94062626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262a9617574686f7269747981a75374657761726484a773746577617264ae406865696d64616c6c3a63686174a47265706fb047616d6543756c742f426966726f7374a5776f726473b4726566757365206c656164696e67207a65726f73a56576656e74a424657631 cut_spec.reads-and-symbol eureka-state:cut_spec:cut-3a.r1 epiphany.pipeline.cut_spec.v2 EpiphanyPipelineCutSpecDocument 91de0013a863616d706169676eac657572656b612d7374617465a3637574a23361a87265766973696f6e01a57469746c65b2506970656c696e6520646f63756d656e7473a47265706fb147616d6543756c742f4570697068616e79a66272616e6368bb636f6465782f657572656b612d706970656c696e652d7374617465a462617365a83566393832323864aa646570656e64735f6f6e91a132a5666972737491ae526561642074686520737065632ea764656c657465739183a470617468a66f6c642e7273a56c696e657303a46e6f7465a464656164ab6b656570735f6d6f76657391ac636f6d6d6974206f776e6572a46164647391b5706970656c696e655f646f63756d656e74732e7273ac66696c655f6368616e6765739182a86c6f636174696f6e84a470617468d9216372617465732f657572656b612d706970656c696e652f7372632f6c69622e7273a46c696e6501a8656e645f6c696e6509a673796d626f6cab76616c75655f7479706573a66368616e6765a3616464a572656164739282a86c6f636174696f6e84a470617468d9216372617465732f657572656b612d706970656c696e652f7372632f6c69622e7273a46c696e650aa8656e645f6c696e6514a673796d626f6caf506970656c696e6543757453706563a3776879b274686520646f63756d656e7420736861706582a86c6f636174696f6e83a470617468d9216372617465732f657572656b612d706970656c696e652f7372632f6c69622e7273a46c696e651ea8656e645f6c696e65c0a3776879a86f6e65206c696e65ad617574686f726974795f6d617087a56f776e6572a4636f7265a6696e7075747391af747970656420646f63756d656e7473a76f75747075747391a9656e76656c6f706573ad646572697665645f737461746591ac64657269766564206b657973b1666f7262696464656e5f7772697465727391a34d4350ac7368617265645f706174687391a961646d697373696f6ead64656c6574696f6e5f6c696e65a36e2f61ac766572696669636174696f6e84a66275696c647391ab636172676f20636865636ba574657374739182a46e616d65a46b657973a470696e73af6f6e652064657269766564206b6579a86e656761746976659182a77061747465726ea75665633c75383ea573636f7065a9646f63756d656e7473a86f70657261746f7291a46e6f6e65a8657374696d61746588ab6c696e65735f6164646564cd0384ad6c696e65735f72656d6f76656400b2646570656e64656e636965735f616464656491a8736368656d617273b4646570656e64656e636965735f72656d6f76656490ad666f726d6174735f616464656491b66570697068616e792e706970656c696e652e2a2e7631af666f726d6174735f72656d6f76656490ad746172676574735f616464656490af746172676574735f72656d6f76656490a772756c696e677391b6657572656b612d73746174653a72756c696e673a5238a97175657374696f6e7391b8657572656b612d73746174653a7175657374696f6e3a5131 finding.location-symbol eureka-state:finding:cut-3a.s2.F4 epiphany.pipeline.finding.v2 EpiphanyPipelineFindingDocument 918da863616d706169676eac657572656b612d7374617465a776657264696374be657572656b612d73746174653a766572646963743a6375742d33612e7332a56c6162656ca24634a572616e676582a462617365a83566393832323864a468656164a83566393832323864aa636f6e666964656e6365a9436f6e6669726d6564a87365766572697479a448696768a5636c61696dd9214120646f74746564206c6162656c20636f6d706f7365732074776f206b6579732eaa696e76617269616e747391ab6d696e642d61646d697473a96c6f636174696f6e739184a470617468d9216372617465732f657572656b612d706970656c696e652f7372632f6c69622e7273a46c696e6501a8656e645f6c696e6509a673796d626f6cab2631313533303930343938b06661696c7572655f7363656e6172696fbc54776f20646f63756d656e747320636c61696d206f6e65206b65792ea865766964656e63659183a46b696e64a454657374a76c6f6361746f72aa636172676f2074657374a6726573756c74a26f6baa707265636564656e747390a66f726967696eaa496e74726f6475636564 target.repos eureka-state:target:r2 epiphany.pipeline.target.v2 EpiphanyPipelineTargetDocument 9187a863616d706169676eac657572656b612d7374617465a87265766973696f6e02aa696e76617269616e74739182a56c6162656cab6d696e642d61646d697473a973746174656d656e74b64f6e6c792061646d697373696f6e207772697465732eac6e6f745f696e5f73636f706591ac4576652062726f7773696e67b963616e6f6e6963616c5f696d706c656d656e746174696f6e7391a743756c744c6962a57265706f7392b147616d6543756c742f4570697068616e79af47616d6543756c742f487567696e6ea3646f6384a470617468d9256e6f7465732f657572656b612d706970656c696e652d73746174652d7461726765742e6d64aa73746172745f6c696e6501a8656e645f6c696e6509a6636f6d6d6974a83566393832323864 diff --git a/crates/eureka-pipeline/src/lib.rs b/crates/eureka-pipeline/src/lib.rs index 8dda40c..4541ae7 100644 --- a/crates/eureka-pipeline/src/lib.rs +++ b/crates/eureka-pipeline/src/lib.rs @@ -759,7 +759,6 @@ unit_enums! { ClaimOutcome { Holds, Falsified, Unproven } FindingConfidence { Confirmed, Plausible } FindingSeverity { Blocker, High, Medium, Low } - RulingAuthority { Operator, Standing, Defaulted, Mind } FindingOrigin { Introduced, PreExisting } RunOperator { Operator, Mind } RunTurn { PersonaTurn, SelfRun, Sleep } @@ -1127,6 +1126,55 @@ impl Bounded for ResearchSource { } } +/// Who decided a ruling, and under what. `Mind` is this mind's own decision, admitted by her Self. `Steward` is +/// a named peer's decision inside the peer's stewardship, as this mind heard it: `steward` is the deciding Mind's +/// Matrix id, `repo` the stewardship she decided under, `words` her own words copied from the line this mind heard, +/// `event` that line's Matrix event id when it was a delivery. The four unit variants stay bare strings on the wire. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema)] +pub enum RulingAuthority { + Operator, + Standing, + Defaulted, + Mind, + Steward { steward: Short, repo: OrgRepo, words: Para, event: Option }, +} + +impl RulingAuthority { + /// The variants' spellings, in declaration order: the names the wire and listings use. + pub const NAMES: &'static [&'static str] = &["Operator", "Standing", "Defaulted", "Mind", "Steward"]; + + /// The variant's spelling, for rows, headers and listings. + pub fn name(&self) -> &'static str { + match self { + Self::Operator => "Operator", + Self::Standing => "Standing", + Self::Defaulted => "Defaulted", + Self::Mind => "Mind", + Self::Steward { .. } => "Steward", + } + } +} + +impl Bounded for RulingAuthority { + fn validate(&self, field: &str) -> Result<(), PipelineRefusal> { + match self { + Self::Steward { steward, repo, words, event } => { + steward.validate(&format!("{field}.steward"))?; + if steward.0.trim().is_empty() { + return Err(format_error(&format!("{field}.steward"), "a steward naming no Mind")); + } + repo.validate(&format!("{field}.repo"))?; + words.validate(&format!("{field}.words"))?; + if words.0.trim().is_empty() { + return Err(format_error(&format!("{field}.words"), "a steward ruling with no words")); + } + event.validate(&format!("{field}.event")) + } + _ => Ok(()), + } + } +} + /// How a subject was resolved. Every referent is a parsed `PipelineRef`, so a /// resolution names its records by ids of the kinds they declare, validated /// where every other referent is; a `Fixed` commit is a `Sha` whose referent @@ -1923,6 +1971,49 @@ mod tests { ruling } + fn steward_ruling() -> PipelineRuling { + let mut ruling = mind_ruling(); + ruling.authority = RulingAuthority::Steward { + steward: "@heimdall:chat".into(), + repo: OrgRepo("GameCult/Bifrost".into()), + words: "refuse leading zeros".into(), + event: Some("$ev1".into()), + }; + ruling + } + + #[test] + fn a_steward_ruling_round_trips_and_leaves_the_unit_authorities_bare_strings() -> Result<()> { + let cache = schema_cache()?; + let document = PipelineDocument::Ruling(steward_ruling()); + document.validate()?; + assert_eq!(PipelineDocument::decode(&document.prepare(&cache)?)?, document); + let json = serde_json::to_value(&document)?; + assert_eq!(json["value"]["authority"]["Steward"]["steward"], "@heimdall:chat"); + let mut unit = ruling_sample(); + unit.authority = RulingAuthority::Defaulted; + assert_eq!(serde_json::to_value(PipelineDocument::Ruling(unit))?["value"]["authority"], "Defaulted"); + Ok(()) + } + + #[test] + fn a_steward_ruling_refuses_blank_words_blank_steward_and_a_bad_repo() { + let refused = |edit: fn(&mut RulingAuthority)| { + let mut ruling = steward_ruling(); + edit(&mut ruling.authority); + PipelineDocument::Ruling(ruling).validate().expect_err("refused").to_string() + }; + let fields = [ + refused(|a| if let RulingAuthority::Steward { words, .. } = a { words.0 = " ".into() }), + refused(|a| if let RulingAuthority::Steward { steward, .. } = a { steward.0 = String::new() }), + refused(|a| if let RulingAuthority::Steward { repo, .. } = a { repo.0 = "nope".into() }), + refused(|a| if let RulingAuthority::Steward { words, .. } = a { words.0 = "w".repeat(4001) }), + ]; + for (fault, field) in fields.iter().zip(["authority.words", "authority.steward", "authority.repo", "authority.words"]) { + assert!(fault.contains(field), "{fault}"); + } + } + /// A withdrawal is a pulse that says only `tempo_vote_withdrawn`: it must survive /// encoding to an envelope, JSON and named MessagePack as a withdrawal, and an /// unwithdrawn pulse must not write the key. @@ -1967,7 +2058,7 @@ mod tests { #[test] fn unit_enum_names_are_the_variants_in_declaration_order() { - assert_eq!(RulingAuthority::NAMES, ["Operator", "Standing", "Defaulted", "Mind"]); + assert_eq!(RulingAuthority::NAMES, ["Operator", "Standing", "Defaulted", "Mind", "Steward"]); assert_eq!(FindingOrigin::NAMES, ["Introduced", "PreExisting"]); } @@ -2044,6 +2135,7 @@ mod tests { out.push((format!("finding.severity-{name}"), D::Finding(f))); } out.push(("ruling.authority-mind".into(), D::Ruling(mind_ruling()))); + out.push(("ruling.authority-steward".into(), D::Ruling(steward_ruling()))); let PipelineDocument::CutSpec(mut spec) = samples().remove(4).0 else { unreachable!() }; spec.file_changes[0].location.symbol = Some("value_types".into()); spec.reads = vec![ diff --git a/schemas/cultnet/epiphany.pipeline.ruling.v2.schema.json b/schemas/cultnet/epiphany.pipeline.ruling.v2.schema.json index f555a4e..2a816e5 100644 --- a/schemas/cultnet/epiphany.pipeline.ruling.v2.schema.json +++ b/schemas/cultnet/epiphany.pipeline.ruling.v2.schema.json @@ -80,13 +80,56 @@ "type": "string" }, "RulingAuthority": { - "enum": [ - "Operator", - "Standing", - "Defaulted", - "Mind" - ], - "type": "string" + "description": "Who decided a ruling, and under what. `Mind` is this mind's own decision, admitted by her Self. `Steward` is\na named peer's decision inside the peer's stewardship, as this mind heard it: `steward` is the deciding Mind's\nMatrix id, `repo` the stewardship she decided under, `words` her own words copied from the line this mind heard,\n`event` that line's Matrix event id when it was a delivery. The four unit variants stay bare strings on the wire.", + "oneOf": [ + { + "enum": [ + "Operator", + "Standing", + "Defaulted", + "Mind" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "Steward": { + "properties": { + "event": { + "anyOf": [ + { + "$ref": "#/$defs/Short" + }, + { + "type": "null" + } + ] + }, + "repo": { + "$ref": "#/$defs/OrgRepo" + }, + "steward": { + "$ref": "#/$defs/Short" + }, + "words": { + "$ref": "#/$defs/Para" + } + }, + "required": [ + "steward", + "repo", + "words" + ], + "type": "object" + } + }, + "required": [ + "Steward" + ], + "type": "object" + } + ] }, "Sha256Hex": { "description": "A SHA-256 digest: exactly 64 lowercase hex characters.", From c2f251cb2679313329b22089878943cd2ec91247 Mon Sep 17 00:00:00 2001 From: Eureka Date: Wed, 11 Nov 2026 11:56:53 +0000 Subject: [PATCH 2/5] huginn-mind: a Steward ruling is selected by decider and repo, indexed by her words, and printed with who decided it Co-Authored-By: Claude Sonnet 5.5 --- crates/eureka-state/src/bin/huginn/render.rs | 38 ++++++++++++- crates/huginn-mind/src/admission.rs | 16 ++++++ crates/huginn-mind/src/index.rs | 5 +- crates/huginn-mind/src/query.rs | 21 ++++++- crates/huginn-mind/src/rows.rs | 17 ++++-- .../huginn.mind_request.v1.schema.json | 55 +++++++++++++++++-- .../huginn.mind_response.v1.schema.json | 55 +++++++++++++++++-- 7 files changed, 187 insertions(+), 20 deletions(-) diff --git a/crates/eureka-state/src/bin/huginn/render.rs b/crates/eureka-state/src/bin/huginn/render.rs index 94a69fe..68e5f7a 100644 --- a/crates/eureka-state/src/bin/huginn/render.rs +++ b/crates/eureka-state/src/bin/huginn/render.rs @@ -100,7 +100,17 @@ pub fn header(item: &PipelineDocumentSummary) -> String { let _ = write!(line, " {name}"); } for (key, value) in facts { - if key != "title" + if key == "authority" + && let Some(Value::Object(steward)) = value.get("Steward") + { + // Her words stay out of the header: it names who decided and under what. + let _ = write!(line, " authority=Steward"); + for field in ["steward", "repo"] { + if let Some(shown) = steward.get(field).and_then(scalar) { + let _ = write!(line, " {field}={shown}"); + } + } + } else if key != "title" && key != "label" && let Some(shown) = scalar(value) { @@ -256,6 +266,32 @@ two assert_eq!(serde_json::from_str::(&json).unwrap(), found, "--json round-trips"); } + #[test] + fn a_steward_ruling_header_names_the_decider_and_her_stewardship_but_not_her_words() { + use huginn_mind::eureka_pipeline::{OrgRepo, PipelineRuling, RulingAuthority}; + let mut view = viewed(None, PipelineStatus::InForce); + view.document = PipelineDocument::Ruling(PipelineRuling { + campaign: Slug("c".into()), + label: "R1".into(), + title: "Zeros".into(), + answers: None, + choice: None, + ruling: "Refuse them.".into(), + operator_quote: None, + ruled_on: Date("2026-11-11".into()), + precedents: vec![], + authority: RulingAuthority::Steward { + steward: "@heimdall:chat".into(), + repo: OrgRepo("GameCult/Bifrost".into()), + words: "refuse leading zeros".into(), + event: None, + }, + }); + let line = header(&PipelineDocumentSummary::of(&view, &|_| None)); + assert!(line.contains("authority=Steward steward=@heimdall:chat repo=GameCult/Bifrost"), "{line}"); + assert!(!line.contains("zeros"), "{line}"); + } + #[test] fn a_fact_is_one_token_or_nothing() { assert_eq!(scalar(&json!("")), None); diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs index 8f8a744..da0681c 100644 --- a/crates/huginn-mind/src/admission.rs +++ b/crates/huginn-mind/src/admission.rs @@ -2345,6 +2345,22 @@ mod tests { committed(admit(&mut mind, vec![D::Ruling(quoted)])); } + #[test] + fn a_steward_ruling_takes_no_operator_quote() { + let mut mind = seeded(); + let mut peer = ruling("R1"); + peer.authority = RulingAuthority::Steward { + steward: "@heimdall:chat".into(), + repo: eureka_pipeline::OrgRepo("GameCult/Bifrost".into()), + words: "refuse leading zeros".into(), + event: None, + }; + peer.operator_quote = Some("all recommendations, go ahead".into()); + assert_eq!(refusal(admit(&mut mind, vec![D::Ruling(peer.clone())])), MindRefusal::QuoteWithoutOperator { ruling: id("ruling", "R1") }); + peer.operator_quote = None; + committed(admit(&mut mind, vec![D::Ruling(peer)])); + } + #[test] fn a_mind_ruling_without_a_quote_is_admitted() { let mut mind = seeded(); diff --git a/crates/huginn-mind/src/index.rs b/crates/huginn-mind/src/index.rs index 2d01966..2d83c5a 100644 --- a/crates/huginn-mind/src/index.rs +++ b/crates/huginn-mind/src/index.rs @@ -7,7 +7,7 @@ //! how a change to it makes every projection rebuild. No network, no JSON, no //! store handle: the daemon owns the vector store and the embedder. -use eureka_pipeline::{PipelineDocument, PipelineKind, PipelineRef}; +use eureka_pipeline::{PipelineDocument, PipelineKind, PipelineRef, RulingAuthority}; use sha2::{Digest, Sha256}; use crate::mind::Mind; @@ -56,6 +56,9 @@ pub fn index_text(document: &PipelineDocument) -> Option { lines.push(&ruling.title.0); lines.push(&ruling.ruling.0); lines.extend(ruling.operator_quote.iter().map(|quote| quote.0.as_str())); + if let RulingAuthority::Steward { words, .. } = &ruling.authority { + lines.push(&words.0); + } } D::CutSpec(spec) => { lines.push(&spec.title.0); diff --git a/crates/huginn-mind/src/query.rs b/crates/huginn-mind/src/query.rs index 22292a6..eeb8ee7 100644 --- a/crates/huginn-mind/src/query.rs +++ b/crates/huginn-mind/src/query.rs @@ -191,7 +191,7 @@ impl PipelineFacts { title: value.title.clone(), answers: value.answers.clone(), choice: value.choice.clone(), - authority: value.authority, + authority: value.authority.clone(), ruled_on: value.ruled_on.clone(), }, D::CutSpec(value) => Self::CutSpec { @@ -1504,6 +1504,25 @@ mod tests { assert!(matches!(refused(&mind, &misspelt), MindRefusal::SelectionInvalid { value: Some(v), .. } if v == "Minds")); } + /// A peer's ruling is found by the Mind who decided, by the stewardship she decided under, and by authority. + #[test] + fn a_peers_rulings_are_selected_by_mind_and_by_repo() { + let mut mind = seeded(); + let mut peer = ruling("R1"); + peer.authority = RulingAuthority::Steward { + steward: "@heimdall:chat".into(), + repo: eureka_pipeline::OrgRepo("GameCult/Bifrost".into()), + words: "refuse leading zeros".into(), + event: None, + }; + committed(admit(&mut mind, vec![D::Ruling(peer), D::Ruling(ruling("R2"))])); + let found = |alias: &str, word: &str| mind.query(&with(of_kinds(&[K::Ruling]), any_of(alias, &[word]))).unwrap().matched; + assert_eq!(found("authority", "Steward"), 1); + assert_eq!(found("about", "@heimdall:chat"), 1); + assert_eq!(found("repo", "GameCult/Bifrost"), 1); + assert_eq!(found("repo", "GameCult/CultLib"), 0); + } + #[test] fn an_unknown_schema_or_malformed_key_is_refused() { let mind = worked(); diff --git a/crates/huginn-mind/src/rows.rs b/crates/huginn-mind/src/rows.rs index c8370bd..852fecc 100644 --- a/crates/huginn-mind/src/rows.rs +++ b/crates/huginn-mind/src/rows.rs @@ -13,7 +13,7 @@ use std::collections::BTreeMap; use cultnet_rs::{Row, RowSet, Selection}; use eureka_pipeline::{ - FindingConfidence, FindingOrigin, FindingSeverity, Label, MemoryOrigin, MemorySort, OrgRepo, PipelineDocument, PipelineKind, PipelineRef, ResolutionOutcome, + FindingConfidence, FindingOrigin, FindingSeverity, Label, MemoryOrigin, MemorySort, OrgRepo, PipelineDocument, PipelineKind, PipelineRef, PipelineRuling, ResolutionOutcome, RulingAuthority, RunOperator, RunTurn, Slug, }; @@ -134,7 +134,7 @@ impl Alias { match self { Self::Root | Self::InForce | Self::Faculty => true, Self::Repo => { - matches!(kind, K::Campaign | K::CutSpec | K::CutReport | K::FollowUp | K::Stewardship | K::HandOff | K::Resolution) + matches!(kind, K::Campaign | K::CutSpec | K::CutReport | K::FollowUp | K::Stewardship | K::HandOff | K::Resolution | K::Ruling) } Self::Cut => matches!(kind, K::CutSpec | K::CutReport | K::Verdict | K::Finding | K::Resolution), Self::Severity | Self::Confidence | Self::Origin => kind == K::Finding, @@ -143,7 +143,8 @@ impl Alias { Self::Outcome => kind == K::Resolution, Self::Turn | Self::OperatedBy | Self::Ran => kind == K::Run, Self::Voted | Self::Urged => kind == K::Pulse, - Self::Sort | Self::About | Self::MemoryOrigin => kind == K::Memory, + Self::Sort | Self::MemoryOrigin => kind == K::Memory, + Self::About => matches!(kind, K::Memory | K::Ruling), Self::Pass => matches!(kind, K::Research | K::Idea), } } @@ -246,6 +247,7 @@ impl SelectionRow { D::FollowUp(follow_up) => vec![follow_up.repo.identity()], D::Stewardship(stewardship) => vec![stewardship.repo.identity()], D::HandOff(hand_off) => vec![hand_off.repo.identity()], + D::Ruling(PipelineRuling { authority: RulingAuthority::Steward { repo, .. }, .. }) => vec![repo.identity()], D::Target(_) | D::Question(_) | D::Ruling(_) | D::Verdict(_) | D::Finding(_) | D::Instance(_) | D::Resolution(_) | D::Run(_) | D::Pulse(_) | D::Reply(_) | D::Thought(_) | D::Memory(_) | D::Research(_) | D::Idea(_) | D::DeskAnswer(_) => { Vec::new() } @@ -267,7 +269,12 @@ impl SelectionRow { put(Alias::Confidence, vec![format!("{:?}", finding.confidence)]); put(Alias::Origin, vec![format!("{:?}", finding.origin)]); } - D::Ruling(ruling) => put(Alias::Authority, vec![format!("{:?}", ruling.authority)]), + D::Ruling(ruling) => { + put(Alias::Authority, vec![ruling.authority.name().to_string()]); + if let RulingAuthority::Steward { steward, .. } = &ruling.authority { + put(Alias::About, vec![steward.0.clone()]); + } + } D::Verdict(verdict) => { put(Alias::ClaimOutcome, verdict.claims.iter().map(|claim| format!("{:?}", claim.outcome)).collect()) } @@ -463,7 +470,7 @@ mod tests { (K::Campaign, &["repo"]), (K::Target, &[]), (K::Question, &[]), - (K::Ruling, &["authority"]), + (K::Ruling, &["authority", "repo", "about"]), (K::CutSpec, &["repo", "cut"]), (K::CutReport, &["repo", "cut"]), (K::Verdict, &["cut", "claim_outcome"]), diff --git a/schemas/cultnet/huginn.mind_request.v1.schema.json b/schemas/cultnet/huginn.mind_request.v1.schema.json index d3fa787..48b10ed 100644 --- a/schemas/cultnet/huginn.mind_request.v1.schema.json +++ b/schemas/cultnet/huginn.mind_request.v1.schema.json @@ -2929,12 +2929,55 @@ ] }, "RulingAuthority": { - "type": "string", - "enum": [ - "Operator", - "Standing", - "Defaulted", - "Mind" + "description": "Who decided a ruling, and under what. `Mind` is this mind's own decision, admitted by her Self. `Steward` is\na named peer's decision inside the peer's stewardship, as this mind heard it: `steward` is the deciding Mind's\nMatrix id, `repo` the stewardship she decided under, `words` her own words copied from the line this mind heard,\n`event` that line's Matrix event id when it was a delivery. The four unit variants stay bare strings on the wire.", + "oneOf": [ + { + "type": "string", + "enum": [ + "Operator", + "Standing", + "Defaulted", + "Mind" + ] + }, + { + "type": "object", + "properties": { + "Steward": { + "type": "object", + "properties": { + "event": { + "anyOf": [ + { + "$ref": "#/$defs/Short" + }, + { + "type": "null" + } + ] + }, + "repo": { + "$ref": "#/$defs/OrgRepo" + }, + "steward": { + "$ref": "#/$defs/Short" + }, + "words": { + "$ref": "#/$defs/Para" + } + }, + "required": [ + "steward", + "repo", + "words" + ] + } + }, + "additionalProperties": false, + "required": [ + "Steward" + ] + } ] }, "RunOperator": { diff --git a/schemas/cultnet/huginn.mind_response.v1.schema.json b/schemas/cultnet/huginn.mind_response.v1.schema.json index c3d6e4e..9ea5faa 100644 --- a/schemas/cultnet/huginn.mind_response.v1.schema.json +++ b/schemas/cultnet/huginn.mind_response.v1.schema.json @@ -5891,12 +5891,55 @@ ] }, "RulingAuthority": { - "type": "string", - "enum": [ - "Operator", - "Standing", - "Defaulted", - "Mind" + "description": "Who decided a ruling, and under what. `Mind` is this mind's own decision, admitted by her Self. `Steward` is\na named peer's decision inside the peer's stewardship, as this mind heard it: `steward` is the deciding Mind's\nMatrix id, `repo` the stewardship she decided under, `words` her own words copied from the line this mind heard,\n`event` that line's Matrix event id when it was a delivery. The four unit variants stay bare strings on the wire.", + "oneOf": [ + { + "type": "string", + "enum": [ + "Operator", + "Standing", + "Defaulted", + "Mind" + ] + }, + { + "type": "object", + "properties": { + "Steward": { + "type": "object", + "properties": { + "event": { + "anyOf": [ + { + "$ref": "#/$defs/Short" + }, + { + "type": "null" + } + ] + }, + "repo": { + "$ref": "#/$defs/OrgRepo" + }, + "steward": { + "$ref": "#/$defs/Short" + }, + "words": { + "$ref": "#/$defs/Para" + } + }, + "required": [ + "steward", + "repo", + "words" + ] + } + }, + "additionalProperties": false, + "required": [ + "Steward" + ] + } ] }, "RunId": { From 416fd7f7199d0a44ba138fbd18d30dd1de4ee013 Mon Sep 17 00:00:00 2001 From: Eureka Date: Wed, 11 Nov 2026 11:56:53 +0000 Subject: [PATCH 3/5] persona: rule records a peer steward's decision as authority Steward; addressing rows carry stewarded repos; recall names the decider Co-Authored-By: Claude Sonnet 5.5 --- crates/mind-body/src/address.rs | 17 +- crates/mind-body/src/address/tests.rs | 8 +- crates/mind-body/src/heartbeat.rs | 15 +- crates/mind-body/src/heartbeat/tests.rs | 4 +- crates/mind-body/src/persona.rs | 174 ++++++++++++++++-- crates/mind-body/src/persona/tests.rs | 207 +++++++++++++++------- crates/mind-body/src/recall.rs | 16 +- crates/mind-body/src/recall/tests.rs | 22 +++ crates/mind-body/tests/public_surface.txt | 3 +- 9 files changed, 371 insertions(+), 95 deletions(-) diff --git a/crates/mind-body/src/address.rs b/crates/mind-body/src/address.rs index d2a9ee0..89fc7b5 100644 --- a/crates/mind-body/src/address.rs +++ b/crates/mind-body/src/address.rs @@ -16,25 +16,34 @@ pub struct Name { pub(crate) exact: bool, } -/// Whom a delivery may be addressing: one Mind's Matrix id and her names. +/// Whom a delivery may be addressing: one Mind's Matrix id, her names and the repos she stewards. #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct Addressee { matrix_id: String, names: Vec, + /// The repos her in-force stewardships name, as the one read that gave `names` found them: what a ruling of hers may be decided under. + #[serde(default)] + repos: Vec, } impl Addressee { /// A Mind by her id and her own names, each matched in any case. pub(crate) fn new<'a>(matrix_id: &str, own_names: impl IntoIterator) -> Self { - Self { matrix_id: matrix_id.to_string(), names: own_names.into_iter().map(|text| Name { text: text.to_string(), exact: false }).collect() } + Self { matrix_id: matrix_id.to_string(), names: own_names.into_iter().map(|text| Name { text: text.to_string(), exact: false }).collect(), repos: Vec::new() } } - /// The same Mind, also addressed by her projects' names. - pub(crate) fn with_projects(mut self, names: impl IntoIterator) -> Self { + /// The same Mind, also addressed by her projects' names, with the repos those stewardships name. + pub(crate) fn with_stewardships(mut self, names: impl IntoIterator, repos: impl IntoIterator) -> Self { self.names.extend(names); + self.repos.extend(repos); self } + /// The repos she stewards, as the heartbeat last read them. + pub(crate) fn repos(&self) -> &[OrgRepo] { + &self.repos + } + pub(crate) fn matrix_id(&self) -> &str { &self.matrix_id } diff --git a/crates/mind-body/src/address/tests.rs b/crates/mind-body/src/address/tests.rs index 08abac3..79ae86b 100644 --- a/crates/mind-body/src/address/tests.rs +++ b/crates/mind-body/src/address/tests.rs @@ -27,7 +27,7 @@ fn names(texts: &[&str]) -> Vec { } fn steward(repo: &str, listed: &[&str]) -> Addressee { - Addressee::new("@nibu:chat", ["Nibu"]).with_projects(project_names(&OrgRepo(repo.into()), &names(listed))) + Addressee::new("@nibu:chat", ["Nibu"]).with_stewardships(project_names(&OrgRepo(repo.into()), &names(listed)), [OrgRepo(repo.into())]) } #[test] @@ -64,7 +64,7 @@ fn project_names_derives_the_repo_name_and_its_split_form_and_listed_names_repla #[test] fn a_listed_name_matches_exactly_as_written_a_derived_one_word_name_as_spelled_and_a_split_phrase_in_any_case() { - let libby = Addressee::new("@libby:chat", ["Libby"]).with_projects(project_names(&OrgRepo("GameCult/Thing".into()), &names(&["Thing"]))); + let libby = Addressee::new("@libby:chat", ["Libby"]).with_stewardships(project_names(&OrgRepo("GameCult/Thing".into()), &names(&["Thing"])), [OrgRepo("GameCult/Thing".into())]); assert!(addresses(&libby, &said("Thing is down", &[]))); assert!(!addresses(&libby, &said("one thing at a time", &[]))); assert!(!addresses(&libby, &said("THING", &[]))); @@ -87,11 +87,11 @@ fn a_listed_name_matches_exactly_as_written_a_derived_one_word_name_as_spelled_a #[test] fn one_name_addresses_every_mind_that_lists_it_each_on_her_own_record() { let lore = steward("GameCult/AetheriaLore", &[]); - let aetheria = Addressee::new("@aqua:chat", ["Aqua"]).with_projects(project_names(&OrgRepo("GameCult/Aetheria".into()), &[])); + let aetheria = Addressee::new("@aqua:chat", ["Aqua"]).with_stewardships(project_names(&OrgRepo("GameCult/Aetheria".into()), &[]), [OrgRepo("GameCult/Aetheria".into())]); let both = said("Aetheria lore needs a pass", &[]); assert!(addresses(&aetheria, &both), "a name inside another Mind's longer name addresses her too"); assert!(addresses(&lore, &both)); - let two_stewards = [steward("GameCult/Thing", &["Thing"]), Addressee::new("@libby:chat", ["Libby"]).with_projects(project_names(&OrgRepo("GameCult/Thing".into()), &[]))]; + let two_stewards = [steward("GameCult/Thing", &["Thing"]), Addressee::new("@libby:chat", ["Libby"]).with_stewardships(project_names(&OrgRepo("GameCult/Thing".into()), &[]), [OrgRepo("GameCult/Thing".into())])]; assert!(two_stewards.iter().all(|who| addresses(who, &said("Thing is broken", &[]))), "a project with two stewards addresses each"); assert!(!addresses(&two_stewards[0], &said("a thing", &[])), "listed Thing is exact"); assert!(!addresses(&two_stewards[1], &said("a thing", &[])), "derived Thing is exact as the repo spells it"); diff --git a/crates/mind-body/src/heartbeat.rs b/crates/mind-body/src/heartbeat.rs index 33b2fd9..3f6155b 100644 --- a/crates/mind-body/src/heartbeat.rs +++ b/crates/mind-body/src/heartbeat.rs @@ -36,7 +36,7 @@ use anyhow::{Context, Result, anyhow, bail}; use chrono::{DateTime, SecondsFormat, Timelike, Utc}; use cultcache_rs::{CultCache, DatabaseEntry, SingleFileMessagePackBackingStore}; use cultnet_rs::Selection; -use eureka_pipeline::{PipelineKind, PipelineRef, ResolutionOutcome, RunOperator, RunTurn, Slug}; +use eureka_pipeline::{OrgRepo, PipelineKind, PipelineRef, ResolutionOutcome, RunOperator, RunTurn, Slug}; use huginn_mind::{PipelineDocumentSummary, PipelineFacts, PipelinePageItems}; use crate::address::{Addressee, Name, is_for, project_names}; @@ -502,15 +502,16 @@ fn newest(mind: &dyn MindPort, selection: Selection) -> Result Result> { - let mut names = Vec::new(); +/// Every repo she stewards in force and the chat names of those repos: one query on her mind. +pub(crate) fn stewardships_of(mind: &dyn MindPort) -> Result<(Vec, Vec)> { + let (mut names, mut repos) = (Vec::new(), Vec::new()); for header in headers(mind, &in_force(of_kind(PipelineKind::Stewardship)))? { if let PipelineFacts::Stewardship { repo, names: listed, .. } = &header.facts { names.extend(project_names(repo, listed)); + repos.push(repo.clone()); } } - Ok(names) + Ok((names, repos)) } /// Whether a delivery wakes `who` off the beat: the operator or another Mind of @@ -771,8 +772,8 @@ pub fn poll(organs: &Organs, config: &Config) -> Result { } // Her projects' names address her too; read only when something is pending. A failed read keeps her last-read row. if !row.pending.is_empty() { - match project_names_of(seat.mind) { - Ok(names) => row.addressee = roster.with_projects(names), + match stewardships_of(seat.mind) { + Ok((names, repos)) => row.addressee = roster.with_stewardships(names, repos), Err(error) => row.note = format!("{} stewardship names unread: {error}", row.note).trim().to_string(), } } diff --git a/crates/mind-body/src/heartbeat/tests.rs b/crates/mind-body/src/heartbeat/tests.rs index 18cd04d..7be7619 100644 --- a/crates/mind-body/src/heartbeat/tests.rs +++ b/crates/mind-body/src/heartbeat/tests.rs @@ -641,6 +641,8 @@ fn the_heartbeat_writes_the_table_it_read_and_only_when_it_changed() { rig.poll(); assert_eq!(rig.store.addressing_saves.get(), 2); assert!(addresses(&table_row(&rig, "@bob:chat"), &delivery(9, "@op:chat", "BDS is down", &[]))); + assert!(table_row(&rig, "@bob:chat").repos().contains(&eureka_pipeline::OrgRepo("GameCult/BadDaySimulator".into())), "the row carries the repos her names came from"); + assert!(!table_row(&rig, "@ann:chat").repos().contains(&eureka_pipeline::OrgRepo("GameCult/BadDaySimulator".into()))); assert!(addresses(&table_row(&rig, "@ann:chat"), &delivery(9, "@op:chat", "Ann?", &[])) && !addresses(&table_row(&rig, "@ann:chat"), &delivery(9, "@op:chat", "BDS", &[]))); rig.poll(); assert_eq!(rig.store.addressing_saves.get(), 2, "an identical poll writes nothing"); @@ -1430,7 +1432,7 @@ fn the_file_store_reads_back_the_addressing_it_wrote_to_the_view() { let dir = TempDir::new().unwrap(); let store = FileStore::new(dir.path().join("heartbeat.cc")); assert_eq!(store.addressing().unwrap(), None); - let table = Addressing { minds: vec![Addressee::new("@bob:chat", ["Bob"]).with_projects([Name { text: "BDS".into(), exact: true }])] }; + let table = Addressing { minds: vec![Addressee::new("@bob:chat", ["Bob"]).with_stewardships([Name { text: "BDS".into(), exact: true }], [])] }; store.save_addressing(&table).unwrap(); assert_eq!(store.addressing().unwrap(), Some(table.clone())); assert_eq!(store.load_addressing().unwrap(), Some(table)); diff --git a/crates/mind-body/src/persona.rs b/crates/mind-body/src/persona.rs index b3261c2..a835fce 100644 --- a/crates/mind-body/src/persona.rs +++ b/crates/mind-body/src/persona.rs @@ -74,7 +74,7 @@ use chrono::{DateTime, Utc}; use cultcache_rs::{CultCache, DatabaseEntry, SingleFileMessagePackBackingStore}; use cultnet_rs::{Incoming, Selection}; use eureka_pipeline::{ - Date, Label, Line, MemoryOrigin, MemorySort, OrgRepo, PipelineDocument, PipelineFollowUp, PipelineKind, PipelineMemory, PipelinePulse, PipelineRef, PipelineResolution, ResolutionOutcome, RunOperator, RunTurn, SALIENCE_MAX_PERMILLE, Short, Slug, + Date, Label, Line, MemoryOrigin, MemorySort, OrgRepo, PipelineDocument, PipelineFollowUp, PipelineKind, PipelineMemory, PipelinePulse, PipelineRef, PipelineResolution, PipelineRuling, ResolutionOutcome, RulingAuthority, RunOperator, RunTurn, SALIENCE_MAX_PERMILLE, Short, Slug, TEMPO_VOTE_MAX_S, TEMPO_VOTE_MIN_S, URGENCY_MAX_PERMILLE, pipeline_key, }; use eureka_state::HuginnClient; @@ -89,7 +89,7 @@ use crate::chat_image::{self, Image, MAX_PER_LINE}; use crate::chat_client::{TailLine, Basis, Chat, ChatDelivery, MouthRefused, ChatOpenDirectRequest, ChatRead, DirectOutcome, ChatSendRequest, ContextLine, Refusal, SendBasis}; use crate::connector::render_plan; use crate::control::{ControlSource, Effective, HeldReason, derive}; -use crate::heartbeat::{HeartbeatView, PollTrace, project_names_of}; +use crate::heartbeat::{HeartbeatView, PollTrace, stewardships_of}; use crate::lease_client::{Lease, LeaseRequest, Lender, Lent}; use crate::launch::{Clock, LaunchOutcome, LaunchRequest, Launcher, Ports, holders, open_and_launch}; use crate::queue::{MindPort, any_of, citing, headers, in_force, live_claims, of_kind}; @@ -162,10 +162,22 @@ const SETTLE_ITEM_FAULT: &str = "settle.item: not an open item this conversation const OPEN_DIRECT_FAULT: &str = "open_direct: only a public conversation may open one, with a person this session showed (not a Mind, not you), and a text of 1 to 4000 bytes"; /// The mouth's bound on an invitation's text, in bytes. const OPEN_DIRECT_TEXT_MAX_BYTES: usize = 4000; +const RULE_BY_OWN_FAULT: &str = "rule.by: your own decision is not a rule"; +const RULE_BY_OPERATOR_FAULT: &str = "rule.by: rule records a Mind's decision in her stewardship"; +const RULE_BY_UNKNOWN_FAULT: &str = "rule.by: not a Mind of the swarm"; +const RULE_WORDS_FAULT: &str = "rule.words: copy them exactly from one of her lines in this session"; +const RULE_REPO_FAULT: &str = "rule.repo: not a repo the Mind named stewards"; +const RULE_REPO_UNREAD_FAULT: &str = "rule.repo: no stewardship of the Mind named has been read yet"; +const RULE_BOUNDS_FAULT: &str = "rule: more entries than one reply may carry, or a field blank or over its length bound"; +const RULE_CAMPAIGN_FAULT: &str = "rule.campaign: not a campaign in force"; const FOR_ITEM_FAULT: &str = "start_self_run.for_item: not an open item shown, or new: for an Asked or Committed entry of this reply"; /// The most memories, and the most settles, one reply carries. const REMEMBER_MAX: usize = 8; const SETTLE_MAX: usize = 8; +/// The most rulings of other Minds one reply records. +const RULE_MAX: usize = 4; +const RULE_TITLE_MAX_BYTES: usize = 200; +const RULE_TEXT_MAX_BYTES: usize = 4000; /// A memory's text in bytes, the people it is about, and a settle's reason in characters. pub(crate) const REMEMBER_TEXT_MAX_BYTES: usize = 1000; pub(crate) const ABOUT_MAX: usize = 8; @@ -311,6 +323,8 @@ pub struct PersonaReply { pub start_self_run: Option, /// Required, empty when she keeps nothing: what she must still know when this conversation is gone. pub remember: Vec, + /// Required, empty when she records none: a decision another Mind made inside her own stewardship, as she heard it. + pub rule: Vec, /// Required, empty when she settles nothing: the open commitments and asks shown this turn that she closes. pub settle: Vec, /// The beat she votes for, in seconds. Omitted, she says nothing about her @@ -362,6 +376,19 @@ pub struct Remember { pub salience_permille: u16, } +/// A decision a peer Mind made inside her own stewardship, as she said it in a line this session showed. The organ, not she, derives the +/// authority, the stewardship check and the event: it checks `by` against the line's sender and `repo` against the heartbeat's table. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Rule { + pub by: String, + pub words: String, + pub repo: String, + pub campaign: String, + pub title: String, + pub ruling: String, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum SettleOutcome { @@ -416,7 +443,8 @@ fn open_direct_description() -> String { /// (none in force: `start_self_run` can only be null). The people she may name and the open items /// she may settle change with every batch, so the schema holds them as strings and `decode_reply` /// holds them to the batch. Text, because the CLI takes it as an argument. -const REMEMBER_DESCRIPTION: &str = "What you must still know when this conversation is gone, in your own words; the chat will not be there to read again. One entry for each of: asks of you (sort Asked); decisions, the operator's and those you agreed with other Minds (Decided); your own commitments (Committed): a shared plan you are in is one Committed entry, your part in your own words and who holds each other part, with about naming those Minds; it stays in front of you every session until you settle it. Decided is for decisions that are not your own part of the work; what you learned (Learned) and did (Did); ideas, your own brainstorming and other Minds' ideas (Idea, about whose); rapport, how you and another Mind or person work and bounce off each other (Rapport, about that one). For Asked, about names who asked. Agreements between Minds and their parts are kept here until the shared Atlas mind takes them. Do not invent a bond from one exchange, do not keep an echo of what others said as your own, and do not keep every joke. Leave empty when nothing here is worth keeping. About holds Matrix ids this session has shown you."; +const REMEMBER_DESCRIPTION: &str = "What you must still know when this conversation is gone, in your own words; the chat will not be there to read again. One entry for each of: asks of you (sort Asked); decisions that are not a Mind's inside her own stewardship: the operator's, and those you agreed with other Minds (Decided); a Mind's decision inside her own stewardship goes in rule; your own commitments (Committed): a shared plan you are in is one Committed entry, your part in your own words and who holds each other part, with about naming those Minds; it stays in front of you every session until you settle it. Decided is for decisions that are not your own part of the work; what you learned (Learned) and did (Did); ideas, your own brainstorming and other Minds' ideas (Idea, about whose); rapport, how you and another Mind or person work and bounce off each other (Rapport, about that one). For Asked, about names who asked. Agreements between Minds and their parts are kept here until the shared Atlas mind takes them. Do not invent a bond from one exchange, do not keep an echo of what others said as your own, and do not keep every joke. Leave empty when nothing here is worth keeping. About holds Matrix ids this session has shown you."; +const RULE_DESCRIPTION: &str = "A decision another Mind made inside her own stewardship, as you heard it: by her Matrix id, words copied exactly from one of her lines in this session, repo the repo she stewards that she decided for, title and ruling what she decided in your words, campaign one in force. The organ checks her line and her stewardship. A Mind's word inside your stewardship is a respected contributor's: weigh it and decide; that decision is yours, not a rule."; const INTENT_DESCRIPTION: &str = "Write this first. What you are going after right now in what you steward, and what this reply does about it. Name the thing: a repo, a run, a commitment, a person, an idea. Waiting is an answer only when you name whose decision you wait on, why it is theirs and not yours, and what this turn does toward it. If you have already asked and nothing more can be done, say so and name the next thing."; const INTENT_MAX_CHARS: usize = 600; const SETTLE_DESCRIPTION: &str = "Close an open item shown under 'You committed to' or 'Asked of you, not yet taken on' in this session: done when you have done it, declined when you will not (a commitment you let go: tell whoever holds the other parts), wrong when it was a mistake. Item is the id shown there. Leave empty when you close none."; @@ -465,6 +493,24 @@ pub fn reply_schema(rooms: &[String], campaigns: &[String], may_open: bool) -> S } } }); + let rule = json!({ + "description": RULE_DESCRIPTION, + "type": "array", + "maxItems": if campaigns.is_empty() { 0 } else { RULE_MAX }, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["by", "words", "repo", "campaign", "title", "ruling"], + "properties": { + "by": { "type": "string" }, + "words": { "type": "string", "minLength": 1, "maxLength": RULE_TEXT_MAX_BYTES }, + "repo": { "type": "string", "pattern": REPO_PATTERN }, + "campaign": { "type": "string", "enum": campaigns }, + "title": { "type": "string", "minLength": 1, "maxLength": RULE_TITLE_MAX_BYTES }, + "ruling": { "type": "string", "minLength": 1, "maxLength": RULE_TEXT_MAX_BYTES } + } + } + }); let start = if campaigns.is_empty() { json!({ "type": "null" }) } else { @@ -505,7 +551,7 @@ pub fn reply_schema(rooms: &[String], campaigns: &[String], may_open: bool) -> S json!({ "type": "object", "additionalProperties": false, - "required": ["intent", "say", "open_direct", "start_self_run", "remember", "settle", "urgency_permille"], + "required": ["intent", "say", "open_direct", "start_self_run", "remember", "rule", "settle", "urgency_permille"], "properties": { "intent": { "description": INTENT_DESCRIPTION, "type": "string", "minLength": 1, "maxLength": INTENT_MAX_CHARS }, "say": { @@ -523,6 +569,7 @@ pub fn reply_schema(rooms: &[String], campaigns: &[String], may_open: bool) -> S "open_direct": open, "start_self_run": start, "remember": remember, + "rule": rule, "settle": settle, "tempo_vote_beat_s": { "description": "Omit this key to leave your standing vote unchanged. A number votes that many seconds per beat. null only withdraws your vote, so you abstain again and are not counted.", "anyOf": [{ "type": "null" }, { "type": "integer", "minimum": TEMPO_VOTE_MIN_S, "maximum": TEMPO_VOTE_MAX_S }] }, "urgency_permille": { "anyOf": [{ "type": "null" }, { "type": "integer", "minimum": 0, "maximum": URGENCY_MAX_PERMILLE }] } @@ -890,7 +937,8 @@ pub fn carry(organs: &Organs, control: &impl ControlSource, swarm: &impl Control // Her names as the heartbeat's table has them, and her projects' names as her mind holds them now: the table is saved after the // heartbeat launches her, so it may not yet hold a project a message names. let table = addressing.iter().find(|mind| mind.matrix_id() == config.own_sender).cloned().unwrap_or_else(|| own_addressee(config)); - let fresh = own_addressee(config).with_projects(project_names_of(organs.mind).unwrap_or_default()); + let (fresh_names, fresh_repos) = stewardships_of(organs.mind).unwrap_or_default(); + let fresh = own_addressee(config).with_stewardships(fresh_names, fresh_repos); if let Some(through) = quiet_through(&[&table, &fresh], &pending, 0) { if organs.chat.ack(through).is_err() { progress.failure = Some(Failure::Ack); @@ -1155,6 +1203,26 @@ fn next_session(config: &Config, addressing: &[Addressee], pending: Vec, open: Vec, + /// Every message line a rendered line showed (a delivery carries its event id) and every earlier line (none): what a peer's rule may quote. + said: Vec, +} + +/// One line a session showed her: who said it, the words, and the Matrix event of its delivery when it was one. +#[derive(Clone, Debug, PartialEq, Eq)] +struct Said { + sender: String, + text: String, + event: Option, +} + +/// The lines `message_lines` prints for a batch, in print order. +fn said_of(deliveries: &[ChatDelivery], earlier: &[Vec<&ContextLine>]) -> Vec { + let mut said = Vec::new(); + for (delivery, lines) in deliveries.iter().zip(earlier) { + said.extend(lines.iter().map(|line| Said { sender: line.sender.clone(), text: line.text.clone(), event: None })); + said.push(Said { sender: delivery.sender.clone(), text: delivery.text.clone(), event: (!delivery.event_id.is_empty()).then(|| delivery.event_id.clone()) }); + } + said } impl Shown { @@ -1169,6 +1237,7 @@ impl Shown { self.open.push(item); } } + self.said.extend(other.said); } } @@ -1399,7 +1468,7 @@ impl<'a, C: ControlSource, S: ControlSource> PersonaInbox<'a, C, S> { let names = sender_names(deliveries); let goals = render_responsibilities(organs.mind, &instance, &names)?; self.repos = goals.repos.clone(); - self.addressee = own_addressee(self.config).with_projects(goals.names.clone()); + self.addressee = own_addressee(self.config).with_stewardships(goals.names.clone(), goals.repos.clone()); self.addressing = addressing_of(self.config, organs.heartbeat, &self.addressee); let (wear_at, wear_facts) = self.first_repo_read(&goals.open_roots); let wear = render_wear(wear_at, &wear_facts); @@ -1420,7 +1489,7 @@ impl<'a, C: ControlSource, S: ControlSource> PersonaInbox<'a, C, S> { let tail = render_tail(self.config, &self.addressing, std::mem::take(&mut self.tail), &tail_rooms(self.config, &self.conversation), &told, &self.waiting_ids); told.extend(tail.keys); let input = render_input(self.config, started_self_today, &tail.text, &format!("{heard}{}{attached}", message_lines(self.config, &self.addressee, &self.addressing, deliveries, &earlier)), note, &history, &goals.text, &wear, &recall::render(&recalled), &format!("{}\n\n{tempo}", self.plan), &people); - Ok(Rendered { input, shown: Shown { people, open: goals.open }, told, replies, images }) + Ok(Rendered { input, shown: Shown { people, open: goals.open, said: said_of(deliveries, &earlier) }, told, replies, images }) } /// A later line: the messages, a refused batch's note before them, the organ's lines (her Self's replies, the notices of her repos), and @@ -1432,7 +1501,7 @@ impl<'a, C: ControlSource, S: ControlSource> PersonaInbox<'a, C, S> { let lines = format!("{notices}{}{attached}", message_lines(self.config, &self.addressee, &self.addressing, deliveries, &earlier)); let input = note.map_or_else(|| lines.clone(), |note| format!("{note}\n\n{lines}")); let input = format!("{input}\n{WORK_TAIL}\n"); - Rendered { input, shown: Shown { people: people_she_may_name(self.config, &self.addressing, deliveries, &earlier, &[]), open: Vec::new() }, told: lines_told(deliveries, &earlier), replies: Vec::new(), images } + Rendered { input, shown: Shown { people: people_she_may_name(self.config, &self.addressing, deliveries, &earlier, &[]), open: Vec::new(), said: said_of(deliveries, &earlier) }, told: lines_told(deliveries, &earlier), replies: Vec::new(), images } } /// Notes what a rendered line carries, as it goes to the child. @@ -1750,7 +1819,7 @@ impl SessionInbox for PersonaInbox<'_, C, S> self.answered_replies.extend(heard.into_iter().map(|reply| (reply, by.clone()))); self.settle_replies(); // What she admitted this turn is hers to settle or take on for the rest of the session, though no line showed its id. - self.seen.add(Shown { people: Vec::new(), open: opened }); + self.seen.add(Shown { people: Vec::new(), open: opened, said: Vec::new() }); // The one ack rule: through the highest answered seq, never past a delivery still told // and unanswered (a retold batch, or a line written and not yet echoed), and nothing once the session has // failed (a batch's third refusal leaves no retell to hold the ack back). Deliveries are @@ -1900,7 +1969,7 @@ fn act(inbox: &PersonaInbox<'_, C, S>, batch organs.trace.record(&record(vec![raw], format!("failed: {why}")))?; Ok(Step::Refused(why)) }; - let mut reply = match decode_reply(text, &inbox.rooms, &inbox.seen.people, &inbox.seen.open, &inbox.conversation, config, &inbox.addressing) { + let mut reply = match decode_reply(text, &inbox.rooms, &inbox.seen, &inbox.conversation, config, &inbox.addressing) { Ok(reply) => reply, Err(why) => return refuse(why), }; @@ -1978,6 +2047,16 @@ fn act(inbox: &PersonaInbox<'_, C, S>, batch // The one origin of this turn: its memories and its brief record the same conversation. let origin = origin_of(config, batch, &inbox.origin); let memories = build_memories(&instance, run, turn, &reply, &origin); + if !reply.rule.is_empty() { + let in_force = campaigns_in_force(organs.mind)?; + if reply.rule.iter().any(|rule| !in_force.iter().any(|(slug, _)| *slug == rule.campaign)) { + return refuse(Failure::OutOfBounds(RULE_CAMPAIGN_FAULT)); + } + } + let rulings = build_rulings(run, turn, &reply, &inbox.seen, date(now)); + for rule in &reply.rule { + outputs.push(TraceItem { provenance: "her own".into(), room: String::new(), text: format!("rule {} {}: {}", rule.by, rule.repo, rule.title) }); + } for remembered in &reply.remember { outputs.push(TraceItem { provenance: "her own".into(), room: String::new(), text: format!("remember {:?}: {}", remembered.sort, remembered.text) }); } @@ -1987,7 +2066,7 @@ fn act(inbox: &PersonaInbox<'_, C, S>, batch if votes(&reply) { outputs.push(TraceItem { provenance: "her own".into(), room: String::new(), text: format!("pulse tempo_vote_beat_s={:?} urgency_permille={:?}", reply.tempo_vote_beat_s, reply.urgency_permille) }); } - if !admit_turn_documents(organs, run, turn, &reply, memories.clone())? { + if !admit_turn_documents(organs, run, turn, &reply, memories.clone(), rulings)? { organs.trace.record(&record(outputs, "failed: memory".into()))?; return Ok(Step::Failed(Failure::Memory, reply.intent)); } @@ -2138,6 +2217,39 @@ fn memory_fault(reply: &PersonaReply, people: &[String], open: &[String]) -> Opt (!taken).then_some(FOR_ITEM_FAULT) } +/// The field a rule breaks, if any: it records a peer Mind's decision, so `by` is a Mind of the table (neither herself nor the +/// operator), `words` are the Mind's own, copied from one of her lines this session showed, and `repo` is one the table says she stewards. +/// What the words mean, and whether they fall inside that repo, is her reading; the organ checks the line and the stewardship. +fn rule_fault(reply: &PersonaReply, shown: &Shown, addressing: &[Addressee], config: &Config) -> Option<&'static str> { + if reply.rule.len() > RULE_MAX { + return Some(RULE_BOUNDS_FAULT); + } + for rule in &reply.rule { + let text_ok = |text: &str, max: usize| (1..=max).contains(&text.len()) && !text.trim().is_empty(); + let title_ok = text_ok(&rule.title, RULE_TITLE_MAX_BYTES) && rule.title.chars().any(char::is_alphanumeric) && !rule.title.chars().any(char::is_control); + if rule.by.len() > SHORT_MAX_BYTES || !title_ok || !text_ok(&rule.ruling, RULE_TEXT_MAX_BYTES) || !text_ok(&rule.words, RULE_TEXT_MAX_BYTES) || !repo_in_bounds(&rule.repo) || !campaign_in_bounds(&rule.campaign) { + return Some(RULE_BOUNDS_FAULT); + } + match sender_role(config, addressing, &rule.by) { + Sender::Persona => return Some(RULE_BY_OWN_FAULT), + Sender::Operator => return Some(RULE_BY_OPERATOR_FAULT), + Sender::Member => return Some(RULE_BY_UNKNOWN_FAULT), + Sender::Mind => {} + } + if !shown.said.iter().any(|said| said.sender == rule.by && said.text.contains(&rule.words)) { + return Some(RULE_WORDS_FAULT); + } + let stewarded = addressing.iter().find(|row| row.matrix_id() == rule.by).map(Addressee::repos).unwrap_or_default(); + if stewarded.is_empty() { + return Some(RULE_REPO_UNREAD_FAULT); + } + if !stewarded.contains(&OrgRepo(rule.repo.clone())) { + return Some(RULE_REPO_FAULT); + } + } + None +} + /// Whether an open is hers to ask: from a public conversation, of a person this session showed who is neither a Mind nor herself, with /// an invitation the mouth takes (not blank, within its byte bound). fn open_in_bounds(ask: &OpenDirect, conversation: &Conversation, config: &Config, addressing: &[Addressee], people: &[String]) -> bool { @@ -2151,7 +2263,7 @@ fn open_in_bounds(ask: &OpenDirect, conversation: &Conversation, config: &Config /// The reply as the one type it must be, with every room one of `rooms`, every person one of /// `people` and every settled or taken-on item one of the `open` items shown this turn. -fn decode_reply(text: &str, rooms: &[String], people: &[String], open: &[String], conversation: &Conversation, config: &Config, addressing: &[Addressee]) -> Result { +fn decode_reply(text: &str, rooms: &[String], seen: &Shown, conversation: &Conversation, config: &Config, addressing: &[Addressee]) -> Result { let reply: PersonaReply = serde_json::from_str(text).map_err(|_| Failure::Undecodable)?; if reply.intent.trim().is_empty() || reply.intent.chars().count() > INTENT_MAX_CHARS { return Err(Failure::OutOfBounds(INTENT_FAULT)); @@ -2162,10 +2274,13 @@ fn decode_reply(text: &str, rooms: &[String], people: &[String], open: &[String] if !vote_in_bounds(&reply) { return Err(Failure::OutOfBounds(VOTE_FAULT)); } - if let Some(field) = memory_fault(&reply, people, open) { + if let Some(field) = memory_fault(&reply, &seen.people, &seen.open) { return Err(Failure::OutOfBounds(field)); } - if reply.open_direct.as_ref().is_some_and(|ask| !open_in_bounds(ask, conversation, config, addressing, people)) { + if let Some(field) = rule_fault(&reply, seen, addressing, config) { + return Err(Failure::OutOfBounds(field)); + } + if reply.open_direct.as_ref().is_some_and(|ask| !open_in_bounds(ask, conversation, config, addressing, &seen.people)) { return Err(Failure::OutOfBounds(OPEN_DIRECT_FAULT)); } if reply.say.iter().any(|say| !rooms.contains(&say.room) || say.text.trim().is_empty()) { @@ -2368,6 +2483,32 @@ fn build_memories(instance: &Slug, run: &PipelineRef, turn: u32, reply: &Persona .collect() } +/// Her `rule` entries as rulings under authority Steward, labelled by run, turn and index. The decider is the entry's checked `by`, the +/// stewardship its checked `repo`, the words hers, and the event the Matrix id of the newest delivery of hers that held them (none when +/// only an earlier line did): the organ derives all four, the model typed only what `rule_fault` has checked. +fn build_rulings(run: &PipelineRef, turn: u32, reply: &PersonaReply, shown: &Shown, ruled_on: Date) -> Vec { + reply + .rule + .iter() + .enumerate() + .map(|(index, rule)| { + let event = shown.said.iter().rev().find(|said| said.sender == rule.by && said.text.contains(&rule.words) && said.event.is_some()).and_then(|said| said.event.clone()); + PipelineDocument::Ruling(PipelineRuling { + campaign: Slug(rule.campaign.clone()), + label: Label(format!("{}-t{turn}-r{index}", label_of(run))), + title: rule.title.as_str().into(), + answers: None, + choice: None, + ruling: rule.ruling.as_str().into(), + operator_quote: None, + ruled_on: ruled_on.clone(), + precedents: vec![], + authority: RulingAuthority::Steward { steward: Short(rule.by.clone()), repo: OrgRepo(rule.repo.clone()), words: rule.words.as_str().into(), event: event.map(Short) }, + }) + }) + .collect() +} + /// The open item a Self run takes on: a shown item's memory, or the memory this reply wrote at `new:`. fn item_taken(start: &StartSelfRun, memories: &[PipelineDocument]) -> Result> { let Some(item) = start.for_item.as_deref() else { return Ok(None) }; @@ -2381,11 +2522,12 @@ fn item_taken(start: &StartSelfRun, memories: &[PipelineDocument]) -> Result) -> Result { +fn admit_turn_documents(organs: &Organs, run: &PipelineRef, turn: u32, reply: &PersonaReply, memories: Vec, rulings: Vec) -> Result { let mut documents = memories; + documents.extend(rulings); for settle in &reply.settle { let subject = PipelineRef { kind: PipelineKind::Memory, id: settle.item.as_str().into() }; let reason = Line(settle.why.clone()); diff --git a/crates/mind-body/src/persona/tests.rs b/crates/mind-body/src/persona/tests.rs index 8282307..8331d61 100644 --- a/crates/mind-body/src/persona/tests.rs +++ b/crates/mind-body/src/persona/tests.rs @@ -402,6 +402,8 @@ struct Rig { lender: FakeLender, persona: FakePersona, trace: FakeTrace, + /// The heartbeat's addressing table as her Persona reads it; empty until a test sets one. + table: FakeHeartbeat, } /// A reply the decoder refuses, as often as a session tolerates it in a row. @@ -464,7 +466,7 @@ fn delivery_of(seq: u64, room: &str, sender: &str, text: &str, event_id: &str) - fn says(lines: &[(&str, &str)]) -> String { let say: Vec<_> = lines.iter().map(|(room, text)| json!({ "room": room, "text": text })).collect(); - json!({ "intent": "Settle the open ask", "say": say, "open_direct": null, "start_self_run": null, "remember": [], "settle": [], "urgency_permille": null }).to_string() + json!({ "intent": "Settle the open ask", "say": say, "open_direct": null, "start_self_run": null, "remember": [], "rule": [], "settle": [], "urgency_permille": null }).to_string() } impl Rig { @@ -517,6 +519,7 @@ impl Rig { lender: FakeLender::new(Answer::Grant), persona: FakePersona(Cell::new(false)), trace: FakeTrace::default(), + table: FakeHeartbeat(RefCell::new(None)), log, }; rig.release(); @@ -577,7 +580,7 @@ impl Rig { chat: &self.chat, persona: &self.persona, trace: &self.trace, - heartbeat: None, + heartbeat: Some(&self.table), lender: &self.lender, spawner: &self.model, run_cap_s: self.cap.get(), @@ -738,17 +741,17 @@ fn reply_decodes_into_sends_and_ack() { #[test] fn a_reply_is_decoded_as_the_one_type_and_nothing_else_is_scanned() { let c = config(); - assert!(decode_reply(&says(&[("swarm", "x")]), &c.rooms, &[], &[], &Conversation::Public, &config(), &[]).is_ok()); + assert!(decode_reply(&says(&[("swarm", "x")]), &c.rooms, &Shown::default(), &Conversation::Public, &config(), &[]).is_ok()); for bad in [ "I will start_self_run now", - r#"{"say":[],"open_direct": null, "start_self_run":null,"remember":[],"settle":[],"urgency_permille":null} and more"#, + r#"{"say":[],"open_direct": null, "start_self_run":null,"remember":[],"rule": [], "settle": [],"urgency_permille":null} and more"#, r#"{"say":[],"open_direct": null, "start_self_run":null,"extra":1}"#, "", ] { - assert_eq!(decode_reply(bad, &c.rooms, &[], &[], &Conversation::Public, &config(), &[]), Err(Failure::Undecodable), "{bad}"); + assert_eq!(decode_reply(bad, &c.rooms, &Shown::default(), &Conversation::Public, &config(), &[]), Err(Failure::Undecodable), "{bad}"); } - assert_eq!(decode_reply(&says(&[("elsewhere", "x")]), &c.rooms, &[], &[], &Conversation::Public, &config(), &[]), Err(Failure::RoomNotAllowed)); - assert_eq!(decode_reply(&says(&[("swarm", " ")]), &c.rooms, &[], &[], &Conversation::Public, &config(), &[]), Err(Failure::RoomNotAllowed)); + assert_eq!(decode_reply(&says(&[("elsewhere", "x")]), &c.rooms, &Shown::default(), &Conversation::Public, &config(), &[]), Err(Failure::RoomNotAllowed)); + assert_eq!(decode_reply(&says(&[("swarm", " ")]), &c.rooms, &Shown::default(), &Conversation::Public, &config(), &[]), Err(Failure::RoomNotAllowed)); } #[test] @@ -865,7 +868,7 @@ fn start_self_run_admits_brief_and_launches() { "say": [{ "room": "operator", "text": "on it" }], "open_direct": null, "start_self_run": { "brief": "Check the swarm queue", "repo": REPO, "campaign": CAMPAIGN, "for_item": null }, "remember": [], - "settle": [], + "rule": [], "settle": [], "tempo_vote_beat_s": null, "urgency_permille": null }) @@ -889,7 +892,7 @@ fn start_self_run_admits_brief_and_launches() { #[test] fn a_start_outside_the_reply_bounds_fails_the_turn_without_an_admission_or_an_ack() { - let reply = json!({ "intent": "Settle the open ask", "say": [], "open_direct": null, "start_self_run": { "brief": "x", "repo": "not a repo", "campaign": CAMPAIGN, "for_item": null }, "remember": [], "settle": [], "urgency_permille": null }).to_string(); + let reply = json!({ "intent": "Settle the open ask", "say": [], "open_direct": null, "start_self_run": { "brief": "x", "repo": "not a repo", "campaign": CAMPAIGN, "for_item": null }, "remember": [], "rule": [], "settle": [], "urgency_permille": null }).to_string(); let rig = Rig::new(vec![operator_says(1, "go")], &thrice(reply)); let Outcome::Served { failure, .. } = rig.go() else { panic!("served") }; assert_eq!(failure, Some(Failure::OutOfBounds(START_FAULT))); @@ -900,8 +903,8 @@ fn a_start_outside_the_reply_bounds_fails_the_turn_without_an_admission_or_an_ac #[test] fn a_reply_without_start_self_run_is_not_a_reply_though_null_is() { let c = config(); - assert_eq!(decode_reply(r#"{"say":[]}"#, &c.rooms, &[], &[], &Conversation::Public, &config(), &[]), Err(Failure::Undecodable), "the schema requires the field, so does the decoder"); - assert!(decode_reply(r#"{"intent":"x","say":[],"open_direct": null, "start_self_run":null,"remember":[],"settle":[],"urgency_permille":null}"#, &c.rooms, &[], &[], &Conversation::Public, &config(), &[]).is_ok()); + assert_eq!(decode_reply(r#"{"say":[]}"#, &c.rooms, &Shown::default(), &Conversation::Public, &config(), &[]), Err(Failure::Undecodable), "the schema requires the field, so does the decoder"); + assert!(decode_reply(r#"{"intent":"x","say":[],"open_direct": null, "start_self_run":null,"remember":[],"rule": [], "settle": [],"urgency_permille":null}"#, &c.rooms, &Shown::default(), &Conversation::Public, &config(), &[]).is_ok()); let rig = Rig::new(vec![operator_says(1, "go")], &thrice(r#"{"intent":"x","say":[{"room":"operator","text":"hi"}]}"#.into())); let Outcome::Served { failure, .. } = rig.go() else { panic!("served") }; assert_eq!(failure, Some(Failure::Undecodable)); @@ -911,8 +914,8 @@ fn a_reply_without_start_self_run_is_not_a_reply_though_null_is() { #[test] fn the_decoder_holds_the_bounds_the_schema_states() { let c = config(); - let start = |brief: &str, repo: &str, campaign: &str| json!({ "intent": "Settle the open ask", "say": [], "open_direct": null, "start_self_run": { "brief": brief, "repo": repo, "campaign": campaign, "for_item": null }, "remember": [], "settle": [], "urgency_permille": null }).to_string(); - let ok = |brief: &str, repo: &str, campaign: &str| decode_reply(&start(brief, repo, campaign), &c.rooms, &[], &[], &Conversation::Public, &config(), &[]).is_ok(); + let start = |brief: &str, repo: &str, campaign: &str| json!({ "intent": "Settle the open ask", "say": [], "open_direct": null, "start_self_run": { "brief": brief, "repo": repo, "campaign": campaign, "for_item": null }, "remember": [], "rule": [], "settle": [], "urgency_permille": null }).to_string(); + let ok = |brief: &str, repo: &str, campaign: &str| decode_reply(&start(brief, repo, campaign), &c.rooms, &Shown::default(), &Conversation::Public, &config(), &[]).is_ok(); assert!(ok("b", "GameCult/Huginn", "eureka-body")); assert!(ok(&"b".repeat(1000), "a-b/c.d_e-f", &"c".repeat(64))); assert!(ok(&"\u{e9}".repeat(1000), "a/b", "c._-"), "the brief bound counts characters, as the schema does"); @@ -1051,7 +1054,7 @@ fn a_mind_only_the_table_lists_may_be_named_and_is_no_one_she_may_open_a_direct_ let table = table_of(&["@eureka:chat", "@libby:chat", "@nibu:chat"]); assert!(people_she_may_name(&c, &table, &[], &[], &[]).contains(&"@nibu:chat".to_string())); let people = ["@nibu:chat", "@guest:chat"].map(String::from).to_vec(); - let open = |with: &str, table: &[Addressee]| decode_reply(&opening(&[], with, "come over"), &c.rooms, &people, &[], &Conversation::Public, &c, table); + let open = |with: &str, table: &[Addressee]| decode_reply(&opening(&[], with, "come over"), &c.rooms, &Shown { people: people.to_vec(), ..Shown::default() }, &Conversation::Public, &c, table); assert_eq!(open("@nibu:chat", &table), Err(Failure::OutOfBounds(OPEN_DIRECT_FAULT))); assert!(open("@guest:chat", &table).is_ok()); } @@ -1141,7 +1144,7 @@ fn no_member_text_name_or_context_can_read_as_an_operator_line() { #[test] fn a_self_run_in_a_campaign_the_mind_does_not_hold_is_refused_before_anything_is_admitted() { - let reply = json!({ "intent": "Settle the open ask", "say": [], "open_direct": null, "start_self_run": { "brief": "Work", "repo": REPO, "campaign": "no-such-campaign", "for_item": null }, "remember": [], "settle": [], "urgency_permille": null }).to_string(); + let reply = json!({ "intent": "Settle the open ask", "say": [], "open_direct": null, "start_self_run": { "brief": "Work", "repo": REPO, "campaign": "no-such-campaign", "for_item": null }, "remember": [], "rule": [], "settle": [], "urgency_permille": null }).to_string(); let rig = Rig::new(vec![operator_says(1, "go")], &[reply]); let Outcome::Served { failure, self_run, .. } = rig.go() else { panic!("served") }; assert_eq!((failure, self_run), (Some(Failure::SelfRun), None)); @@ -1149,7 +1152,7 @@ fn a_self_run_in_a_campaign_the_mind_does_not_hold_is_refused_before_anything_is assert!(rig.launcher.started.borrow().is_empty()); assert!(rig.chat.acked.borrow().is_empty()); // A prefix of a real campaign's name is not that campaign. - let prefix = json!({ "intent": "Settle the open ask", "say": [], "open_direct": null, "start_self_run": { "brief": "Work", "repo": REPO, "campaign": &CAMPAIGN[..CAMPAIGN.len() - 1], "for_item": null }, "remember": [], "settle": [], "urgency_permille": null }).to_string(); + let prefix = json!({ "intent": "Settle the open ask", "say": [], "open_direct": null, "start_self_run": { "brief": "Work", "repo": REPO, "campaign": &CAMPAIGN[..CAMPAIGN.len() - 1], "for_item": null }, "remember": [], "rule": [], "settle": [], "urgency_permille": null }).to_string(); let rig = Rig::new(vec![operator_says(1, "go")], &[prefix]); let Outcome::Served { failure, .. } = rig.go() else { panic!("served") }; assert_eq!(failure, Some(Failure::SelfRun)); @@ -1274,7 +1277,7 @@ fn the_campaigns_in_force_are_listed_and_are_the_schemas_campaign_enum() { #[test] fn a_self_run_naming_a_listed_campaign_passes_the_campaign_check() { - let reply = json!({ "intent": "Settle the open ask", "say": [], "open_direct": null, "start_self_run": { "brief": "Work", "repo": REPO, "campaign": CAMPAIGN, "for_item": null }, "remember": [], "settle": [], "urgency_permille": null }).to_string(); + let reply = json!({ "intent": "Settle the open ask", "say": [], "open_direct": null, "start_self_run": { "brief": "Work", "repo": REPO, "campaign": CAMPAIGN, "for_item": null }, "remember": [], "rule": [], "settle": [], "urgency_permille": null }).to_string(); let rig = Rig::new(vec![operator_says(1, "go")], &[reply]); let Outcome::Served { failure, self_run, .. } = rig.go() else { panic!("served") }; assert_eq!(failure, None); @@ -1498,7 +1501,7 @@ fn a_message_without_mentions_renders_no_addressee() { /// A reply that votes `beat` (`None` leaves the key out; `Some(null)` withdraws) and declares `urgency`. fn voting(beat: Option, urgency: serde_json::Value) -> String { - let mut reply = json!({ "intent": "Settle the open ask", "say": [{ "room": "operator", "text": "ok" }], "open_direct": null, "start_self_run": null, "remember": [], "settle": [], "urgency_permille": urgency }); + let mut reply = json!({ "intent": "Settle the open ask", "say": [{ "room": "operator", "text": "ok" }], "open_direct": null, "start_self_run": null, "remember": [], "rule": [], "settle": [], "urgency_permille": urgency }); if let Some(beat) = beat { reply["tempo_vote_beat_s"] = beat; } @@ -1543,7 +1546,7 @@ fn a_vote_or_urgency_outside_its_bounds_fails_the_turn_without_a_pulse_or_an_ack #[test] fn the_reply_schema_bounds_the_vote_and_the_urgency_to_the_pulse_kind() { let schema: serde_json::Value = serde_json::from_str(&reply_schema(&["operator".to_string()], &[], true)).unwrap(); - assert_eq!(schema["required"], json!(["intent", "say", "open_direct", "start_self_run", "remember", "settle", "urgency_permille"])); + assert_eq!(schema["required"], json!(["intent", "say", "open_direct", "start_self_run", "remember", "rule", "settle", "urgency_permille"])); assert_eq!(schema["properties"]["tempo_vote_beat_s"]["anyOf"][1], json!({ "type": "integer", "minimum": 60, "maximum": 86_400 })); let described = schema["properties"]["tempo_vote_beat_s"]["description"].as_str().expect("the vote key is described to the model"); assert!( @@ -1866,7 +1869,7 @@ fn a_persona_runs_intent_is_stored_and_does_not_render_and_no_self_result_can_pa fn reply(say: &[(&str, &str)], remember: serde_json::Value, settle: serde_json::Value, start: serde_json::Value) -> String { let say: Vec<_> = say.iter().map(|(room, text)| json!({ "room": room, "text": text })).collect(); - json!({ "intent": "Settle the open ask", "say": say, "open_direct": null, "start_self_run": start, "remember": remember, "settle": settle, "urgency_permille": null }).to_string() + json!({ "intent": "Settle the open ask", "say": say, "open_direct": null, "start_self_run": start, "remember": remember, "rule": [], "settle": settle, "urgency_permille": null }).to_string() } fn keeps(sort: &str, text: &str, about: &[&str]) -> serde_json::Value { @@ -2065,10 +2068,10 @@ fn where_and_from_whom_she_learned_a_memory_comes_from_the_batch_and_no_model_fi assert!(!schema.contains(field), "{field}"); } for forged_field in ["place", "origin", "sources"] { - let forged = json!({ "intent": "Settle the open ask", "say": [], "open_direct": null, "start_self_run": null, "urgency_permille": null, "settle": [], + let forged = json!({ "intent": "Settle the open ask", "say": [], "open_direct": null, "start_self_run": null, "urgency_permille": null, "rule": [], "settle": [], "remember": [{ "sort": "Learned", "text": "x", "about": [], "salience_permille": 1, forged_field: "elsewhere" }] }) .to_string(); - assert_eq!(decode_reply(&forged, &["swarm".to_string()], &[], &[], &Conversation::Public, &config(), &[]), Err(Failure::Undecodable), "{forged_field}"); + assert_eq!(decode_reply(&forged, &["swarm".to_string()], &Shown::default(), &Conversation::Public, &config(), &[]), Err(Failure::Undecodable), "{forged_field}"); } } @@ -2078,7 +2081,7 @@ fn what_she_keeps_names_only_people_she_may_name_and_stays_in_bounds() { let rooms = c.rooms.clone(); let people = ["@aqua:chat".to_string(), "@op:chat".to_string()]; let with = |remember: serde_json::Value| reply(&[], remember, json!([]), json!(null)); - let ok = |remember: serde_json::Value| decode_reply(&with(remember), &rooms, &people, &[], &Conversation::Public, &config(), &[]).is_ok(); + let ok = |remember: serde_json::Value| decode_reply(&with(remember), &rooms, &Shown { people: people.to_vec(), ..Shown::default() }, &Conversation::Public, &config(), &[]).is_ok(); assert!(ok(json!([keeps("Idea", "x", &["@aqua:chat", "@op:chat"])]))); assert!(ok(json!([keeps("Idea", &"x".repeat(1000), &[])]))); assert!(ok(json!((0..8).map(|_| keeps("Did", "x", &[])).collect::>()))); @@ -2090,7 +2093,7 @@ fn what_she_keeps_names_only_people_she_may_name_and_stays_in_bounds() { assert!(!ok(json!((0..9).map(|_| keeps("Did", "x", &[])).collect::>()))); assert!(!ok(json!([{ "sort": "Idea", "text": "x", "about": [], "salience_permille": 1001 }]))); let without = reply(&[], json!([]), json!([]), json!(null)).replace("\"remember\":[],", ""); - assert_eq!(decode_reply(&without, &rooms, &people, &[], &Conversation::Public, &config(), &[]), Err(Failure::Undecodable), "remember is required"); + assert_eq!(decode_reply(&without, &rooms, &Shown { people: people.to_vec(), ..Shown::default() }, &Conversation::Public, &config(), &[]), Err(Failure::Undecodable), "remember is required"); let schema: serde_json::Value = serde_json::from_str(&reply_schema(&rooms, &[CAMPAIGN.to_string()], true)).unwrap(); let items = &schema["properties"]["remember"]["items"]["properties"]; assert_eq!(items["about"]["items"], json!({ "type": "string" }), "the people she may name change with every batch: the decoder holds them, the line lists them"); @@ -2163,13 +2166,13 @@ fn the_open_items_shown_are_what_the_decoder_lets_her_settle_or_take_on() { let rooms = ["swarm".to_string()]; let shown = ["yggdrasil:memory:a".to_string()]; let settling = |item: &str| reply(&[], json!([]), json!([{ "item": item, "outcome": "done", "why": "x" }]), json!(null)); - assert!(decode_reply(&settling("yggdrasil:memory:a"), &rooms, &[], &shown, &Conversation::Public, &config(), &[]).is_ok()); - assert_eq!(decode_reply(&settling("yggdrasil:memory:b"), &rooms, &[], &shown, &Conversation::Public, &config(), &[]), Err(Failure::OutOfBounds(SETTLE_ITEM_FAULT)), "an item not shown"); - assert_eq!(decode_reply(&settling("yggdrasil:memory:a"), &rooms, &[], &[], &Conversation::Public, &config(), &[]), Err(Failure::OutOfBounds(SETTLE_ITEM_FAULT)), "with none shown she can settle nothing"); + assert!(decode_reply(&settling("yggdrasil:memory:a"), &rooms, &Shown { open: shown.to_vec(), ..Shown::default() }, &Conversation::Public, &config(), &[]).is_ok()); + assert_eq!(decode_reply(&settling("yggdrasil:memory:b"), &rooms, &Shown { open: shown.to_vec(), ..Shown::default() }, &Conversation::Public, &config(), &[]), Err(Failure::OutOfBounds(SETTLE_ITEM_FAULT)), "an item not shown"); + assert_eq!(decode_reply(&settling("yggdrasil:memory:a"), &rooms, &Shown::default(), &Conversation::Public, &config(), &[]), Err(Failure::OutOfBounds(SETTLE_ITEM_FAULT)), "with none shown she can settle nothing"); let taking = |item: &str| reply(&[], json!([]), json!([]), starting(json!(item))); - assert!(decode_reply(&taking("yggdrasil:memory:a"), &rooms, &[], &shown, &Conversation::Public, &config(), &[]).is_ok()); - assert_eq!(decode_reply(&taking("yggdrasil:memory:b"), &rooms, &[], &shown, &Conversation::Public, &config(), &[]), Err(Failure::OutOfBounds(FOR_ITEM_FAULT))); - assert_eq!(decode_reply(&taking("new:0"), &rooms, &[], &shown, &Conversation::Public, &config(), &[]), Err(Failure::OutOfBounds(FOR_ITEM_FAULT)), "new: names a memory this reply writes"); + assert!(decode_reply(&taking("yggdrasil:memory:a"), &rooms, &Shown { open: shown.to_vec(), ..Shown::default() }, &Conversation::Public, &config(), &[]).is_ok()); + assert_eq!(decode_reply(&taking("yggdrasil:memory:b"), &rooms, &Shown { open: shown.to_vec(), ..Shown::default() }, &Conversation::Public, &config(), &[]), Err(Failure::OutOfBounds(FOR_ITEM_FAULT))); + assert_eq!(decode_reply(&taking("new:0"), &rooms, &Shown { open: shown.to_vec(), ..Shown::default() }, &Conversation::Public, &config(), &[]), Err(Failure::OutOfBounds(FOR_ITEM_FAULT)), "new: names a memory this reply writes"); // The schema leaves the ids to the decoder and the line, and still offers the one-reply form. let schema: serde_json::Value = serde_json::from_str(&reply_schema(&rooms, &[CAMPAIGN.to_string()], true)).unwrap(); assert_eq!(schema["properties"]["settle"]["items"]["properties"]["item"], json!({ "type": "string" })); @@ -3038,7 +3041,7 @@ fn a_line_answered_after_a_batchs_third_refusal_acks_nothing_past_it() { // ---- the turn's question: the core instruction opens every line, a question closes it, and the reply states its intent first ---- fn intending(intent: &str) -> String { - json!({ "intent": intent, "say": [], "open_direct": null, "start_self_run": null, "remember": [], "settle": [], "urgency_permille": null }).to_string() + json!({ "intent": intent, "say": [], "open_direct": null, "start_self_run": null, "remember": [], "rule": [], "settle": [], "urgency_permille": null }).to_string() } fn told(rig: &Rig) -> Vec { @@ -3175,7 +3178,7 @@ fn the_newest_self_run_holds_the_brake_beneath_a_full_read_of_older_days() { } fn starts_a_self_run() -> String { - json!({ "intent": "Work it", "say": [], "open_direct": null, "start_self_run": { "brief": "Work", "repo": REPO, "campaign": CAMPAIGN, "for_item": null }, "remember": [], "settle": [], "urgency_permille": null }).to_string() + json!({ "intent": "Work it", "say": [], "open_direct": null, "start_self_run": { "brief": "Work", "repo": REPO, "campaign": CAMPAIGN, "for_item": null }, "remember": [], "rule": [], "settle": [], "urgency_permille": null }).to_string() } /// A rig whose first session starts a Self run, from a chat message or from a quiet wake, then a quiet wake that is told. @@ -3345,11 +3348,11 @@ fn a_reply_states_its_intent_first() { assert_eq!(schema["required"][0], json!("intent")); assert!(schema["properties"]["intent"]["description"].as_str().unwrap().contains("whose decision you wait on") && INTENT_DESCRIPTION.ends_with("If you have already asked and nothing more can be done, say so and name the next thing.")); let rooms = ["operator".to_string()]; - assert!(decode_reply(&intending(&"x".repeat(600)), &rooms, &[], &[], &Conversation::Public, &config(), &[]).is_ok()); - let missing = json!({ "say": [], "open_direct": null, "start_self_run": null, "remember": [], "settle": [], "urgency_permille": null }).to_string(); - assert_eq!(decode_reply(&missing, &rooms, &[], &[], &Conversation::Public, &config(), &[]), Err(Failure::Undecodable)); + assert!(decode_reply(&intending(&"x".repeat(600)), &rooms, &Shown::default(), &Conversation::Public, &config(), &[]).is_ok()); + let missing = json!({ "say": [], "open_direct": null, "start_self_run": null, "remember": [], "rule": [], "settle": [], "urgency_permille": null }).to_string(); + assert_eq!(decode_reply(&missing, &rooms, &Shown::default(), &Conversation::Public, &config(), &[]), Err(Failure::Undecodable)); for refused in [intending(&"x".repeat(601)), intending(""), intending(" \n")] { - assert_eq!(decode_reply(&refused, &rooms, &[], &[], &Conversation::Public, &config(), &[]), Err(Failure::OutOfBounds(INTENT_FAULT)), "{refused}"); + assert_eq!(decode_reply(&refused, &rooms, &Shown::default(), &Conversation::Public, &config(), &[]), Err(Failure::OutOfBounds(INTENT_FAULT)), "{refused}"); } } @@ -3486,7 +3489,7 @@ fn a_flush_written_beside_a_tell_leaves_the_tell_to_be_answered_and_acked() { // ---- fixes: the intent of a failed turn, the flush alone, the rationale's line breaks, and the rulings the turn text carries ---- fn saying_with_intent(intent: &str) -> String { - json!({ "intent": intent, "say": [{ "room": "operator", "text": "ok" }], "open_direct": null, "start_self_run": null, "remember": [], "settle": [], "urgency_permille": null }).to_string() + json!({ "intent": intent, "say": [{ "room": "operator", "text": "ok" }], "open_direct": null, "start_self_run": null, "remember": [], "rule": [], "settle": [], "urgency_permille": null }).to_string() } #[test] @@ -3498,7 +3501,7 @@ fn a_turn_whose_action_fails_still_closes_the_run_with_the_intent_it_stated() { let lines = recent_runs(&rig.mind, &Slug(INSTANCE.into())).unwrap(); assert!(lines[0].contains("failed after 1 answered") && !lines[0].contains("intent"), "{lines:?}"); assert!(stored_rationales(&rig.mind).contains(&"Your last intent: tell op the plan".to_string()), "the closure keeps the intent it stated"); - let refused = Rig::new(vec![operator_says(1, "go")], &[json!({ "intent": "start the deploy run", "say": [], "open_direct": null, "start_self_run": { "brief": "Work", "repo": REPO, "campaign": "no-such-campaign", "for_item": null }, "remember": [], "settle": [], "urgency_permille": null }).to_string()]); + let refused = Rig::new(vec![operator_says(1, "go")], &[json!({ "intent": "start the deploy run", "say": [], "open_direct": null, "start_self_run": { "brief": "Work", "repo": REPO, "campaign": "no-such-campaign", "for_item": null }, "remember": [], "rule": [], "settle": [], "urgency_permille": null }).to_string()]); assert_eq!(failure_of(refused.go()), Some(Failure::SelfRun)); let lines = recent_runs(&refused.mind, &Slug(INSTANCE.into())).unwrap(); assert!(!lines[0].contains("deploy"), "{lines:?}"); @@ -3516,7 +3519,7 @@ fn a_held_or_memory_failed_turn_closes_the_run_with_the_intent_it_stated() { let lines = recent_runs(&held.mind, &Slug(INSTANCE.into())).unwrap(); assert!(!lines[0].contains("tell op the plan") && stored_rationales(&held.mind).contains(&"Your last intent: tell op the plan".to_string()), "a hold keeps the intent in the closure only: {lines:?}"); - let text = json!({ "intent": "keep what I learned", "say": [], "open_direct": null, "start_self_run": null, "remember": [keeps("Learned", "x", &["@op:chat"])], "settle": [], "urgency_permille": null }).to_string(); + let text = json!({ "intent": "keep what I learned", "say": [], "open_direct": null, "start_self_run": null, "remember": [keeps("Learned", "x", &["@op:chat"])], "rule": [], "settle": [], "urgency_permille": null }).to_string(); let refused = Rig::new(vec![delivery(1, "swarm", "@op:chat", "hi")], &[text]); refused.open_the_run(); let refusing = RefusesMemories(&refused.mind); @@ -3592,7 +3595,7 @@ fn request_at(id: &str, prefix: u64) -> Read { /// The reply Nibu gave 33 times on 2026-11-02: decodable, with a say, and a memory `about` a bare name no line showed. fn reply_about_bare_name() -> serde_json::Value { - json!({"intent": "Post the head", "say": [{"room": "operator", "text": "the head is 0ec34ff"}], "remember": [{"sort": "Did", "text": "posted the head", "about": ["void"], "salience_permille": 500}], "settle": [], "open_direct": null, "start_self_run": null, "urgency_permille": null}) + json!({"intent": "Post the head", "say": [{"room": "operator", "text": "the head is 0ec34ff"}], "remember": [{"sort": "Did", "text": "posted the head", "about": ["void"], "salience_permille": 500}], "rule": [], "settle": [], "open_direct": null, "start_self_run": null, "urgency_permille": null}) } /// One process of that loop, as `journalctl -u mind-persona@nibu` shows it from 13:43:50Z: the batch's line is refused, @@ -3627,7 +3630,7 @@ fn answering(rig: &Rig, reply: serde_json::Value) -> (Closing, Rc serde_json::Value { - json!({"intent": "Nothing is stranded", "say": [], "remember": [], "settle": [], "open_direct": null, "start_self_run": null, "urgency_permille": null}) + json!({"intent": "Nothing is stranded", "say": [], "remember": [], "rule": [], "settle": [], "open_direct": null, "start_self_run": null, "urgency_permille": null}) } /// The text of the first line a process was told. @@ -3729,7 +3732,7 @@ fn an_answer_clears_the_count_so_a_later_batch_gets_its_own_three() { #[test] fn a_refusal_names_the_field_and_echoes_nothing_she_wrote() { let canary = "@canary-value:nowhere"; - let reply = json!({"intent": "x", "say": [], "remember": [{"sort": "Did", "text": "kept", "about": [canary], "salience_permille": 1}], "settle": [], "open_direct": null, "start_self_run": null, "urgency_permille": null}).to_string(); + let reply = json!({"intent": "x", "say": [], "remember": [{"sort": "Did", "text": "kept", "about": [canary], "salience_permille": 1}], "rule": [], "settle": [], "open_direct": null, "start_self_run": null, "urgency_permille": null}).to_string(); let rig = Rig::new(vec![operator_says(1, "a")], &thrice(reply)); assert_eq!(failure_of(rig.go()), Some(Failure::OutOfBounds(ABOUT_FAULT))); let told = rig.model.requests.borrow()[1].input.clone(); @@ -3759,24 +3762,24 @@ fn every_memory_bound_names_its_own_field() { (settling(json!([done(&"x".repeat(WHY_MAX_CHARS + 1))])), "settle.why: blank or over its length bound"), (settling(json!([done("x"), done("y")])), "settle: one item settled twice"), ] { - assert_eq!(decode_reply(&text, &rooms, &people, &open, &Conversation::Public, &config(), &[]), Err(Failure::OutOfBounds(field)), "{field}"); + assert_eq!(decode_reply(&text, &rooms, &Shown { people: people.to_vec(), open: open.to_vec(), ..Shown::default() }, &Conversation::Public, &config(), &[]), Err(Failure::OutOfBounds(field)), "{field}"); } - assert!(decode_reply(&keeping(json!([memory(json!(["@aqua:chat"]), "x", SALIENCE_MAX_PERMILLE)])), &rooms, &people, &open, &Conversation::Public, &config(), &[]).is_ok()); - assert!(decode_reply(&settling(json!([done(&"x".repeat(WHY_MAX_CHARS))])), &rooms, &people, &open, &Conversation::Public, &config(), &[]).is_ok()); + assert!(decode_reply(&keeping(json!([memory(json!(["@aqua:chat"]), "x", SALIENCE_MAX_PERMILLE)])), &rooms, &Shown { people: people.to_vec(), open: open.to_vec(), ..Shown::default() }, &Conversation::Public, &config(), &[]).is_ok()); + assert!(decode_reply(&settling(json!([done(&"x".repeat(WHY_MAX_CHARS))])), &rooms, &Shown { people: people.to_vec(), open: open.to_vec(), ..Shown::default() }, &Conversation::Public, &config(), &[]).is_ok()); // Each bound holds at exactly its value and breaks one past it. let at_most = |entries: usize| json!((0..entries).map(|index| memory(json!([]), &format!("kept {index}"), 1)).collect::>()); - assert!(decode_reply(&keeping(at_most(REMEMBER_MAX)), &rooms, &people, &open, &Conversation::Public, &config(), &[]).is_ok()); + assert!(decode_reply(&keeping(at_most(REMEMBER_MAX)), &rooms, &Shown { people: people.to_vec(), open: open.to_vec(), ..Shown::default() }, &Conversation::Public, &config(), &[]).is_ok()); let many: Vec = (0..=ABOUT_MAX).map(|index| format!("@p{index}:chat")).collect(); let about = |count: usize| keeping(json!([memory(json!(many[..count]), "x", 1)])); - assert!(decode_reply(&about(ABOUT_MAX), &rooms, &many, &open, &Conversation::Public, &config(), &[]).is_ok()); - assert_eq!(decode_reply(&about(ABOUT_MAX + 1), &rooms, &many, &open, &Conversation::Public, &config(), &[]), Err(Failure::OutOfBounds("remember.about: too many entries or one repeated"))); + assert!(decode_reply(&about(ABOUT_MAX), &rooms, &Shown { people: many.to_vec(), open: open.to_vec(), ..Shown::default() }, &Conversation::Public, &config(), &[]).is_ok()); + assert_eq!(decode_reply(&about(ABOUT_MAX + 1), &rooms, &Shown { people: many.to_vec(), open: open.to_vec(), ..Shown::default() }, &Conversation::Public, &config(), &[]), Err(Failure::OutOfBounds("remember.about: too many entries or one repeated"))); // A reason in four-byte characters: within its characters, and bound by its bytes too. - assert!(decode_reply(&settling(json!([done(&"\u{1F40D}".repeat(250))])), &rooms, &people, &open, &Conversation::Public, &config(), &[]).is_ok()); - assert_eq!(decode_reply(&settling(json!([done(&"\u{1F40D}".repeat(251))])), &rooms, &people, &open, &Conversation::Public, &config(), &[]), Err(Failure::OutOfBounds("settle.why: blank or over its length bound"))); + assert!(decode_reply(&settling(json!([done(&"\u{1F40D}".repeat(250))])), &rooms, &Shown { people: people.to_vec(), open: open.to_vec(), ..Shown::default() }, &Conversation::Public, &config(), &[]).is_ok()); + assert_eq!(decode_reply(&settling(json!([done(&"\u{1F40D}".repeat(251))])), &rooms, &Shown { people: people.to_vec(), open: open.to_vec(), ..Shown::default() }, &Conversation::Public, &config(), &[]), Err(Failure::OutOfBounds("settle.why: blank or over its length bound"))); let items: Vec = (0..=SETTLE_MAX).map(|index| format!("yggdrasil:memory:i{index}")).collect(); let settles = |count: usize| settling(json!(items[..count].iter().map(|item| json!({ "item": item, "outcome": "done", "why": "x" })).collect::>())); - assert!(decode_reply(&settles(SETTLE_MAX), &rooms, &people, &items, &Conversation::Public, &config(), &[]).is_ok()); - assert_eq!(decode_reply(&settles(SETTLE_MAX + 1), &rooms, &people, &items, &Conversation::Public, &config(), &[]), Err(Failure::OutOfBounds("settle: more entries than one reply may carry"))); + assert!(decode_reply(&settles(SETTLE_MAX), &rooms, &Shown { people: people.to_vec(), open: items.to_vec(), ..Shown::default() }, &Conversation::Public, &config(), &[]).is_ok()); + assert_eq!(decode_reply(&settles(SETTLE_MAX + 1), &rooms, &Shown { people: people.to_vec(), open: items.to_vec(), ..Shown::default() }, &Conversation::Public, &config(), &[]), Err(Failure::OutOfBounds("settle: more entries than one reply may carry"))); } fn earlier(sender: &str, text: &str) -> ContextLine { @@ -6630,7 +6633,7 @@ fn the_wait_is_bounded_under_a_stream_of_messages() { fn a_message_naming_her_project_or_her_name_ranks_her_among_the_addressed_in_the_stagger() { let config = three_minds("@eureka:chat"); let step = STAGGER_STEP_S * 1000; - let steward = own_addressee(&config).with_projects(project_names(&OrgRepo("GameCult/BadDaySimulator".into()), &[])); + let steward = own_addressee(&config).with_stewardships(project_names(&OrgRepo("GameCult/BadDaySimulator".into()), &[]), [OrgRepo("GameCult/BadDaySimulator".into())]); let by_project = unaddressed_at_rank(1, 2, "who takes the bad day simulator?"); assert_eq!(stagger(&config, &own_addressee(&config), &by_project), 2 * step, "a Mind it does not name still waits her rank"); assert_eq!(stagger(&config, &steward, &by_project), 0, "the only addressed Mind hears at once, as with a tag"); @@ -7031,7 +7034,7 @@ fn eight_minds(listed: &[(&str, &str, &[&str])]) -> Vec { .map(|id| { let row = Addressee::new(id, [localpart(id)]); match listed.iter().find(|(who, _, _)| who == id) { - Some((_, repo, names)) => row.with_projects(project_names(&OrgRepo((*repo).into()), &names.iter().map(|name| eureka_pipeline::Short((*name).into())).collect::>())), + Some((_, repo, names)) => row.with_stewardships(project_names(&OrgRepo((*repo).into()), &names.iter().map(|name| eureka_pipeline::Short((*name).into())).collect::>()), [OrgRepo((*repo).into())]), None => row, } }) @@ -7094,7 +7097,7 @@ fn with_no_table_the_stagger_ranks_tags_and_her_own_names_and_to_lists_tags_only let own = own_addressee(&config); let fallback = addressing_of(&config, None, &own); assert_eq!(fallback.iter().map(|mind| mind.matrix_id()).collect::>(), ["@eureka:chat", "@libby:chat", "@aqua:chat"]); - let steward = own.clone().with_projects([crate::address::Name { text: "BDS".into(), exact: true }]); + let steward = own.clone().with_stewardships([crate::address::Name { text: "BDS".into(), exact: true }], []); let with_projects = addressing_of(&config, None, &steward); let bds = delivery_of(1, "swarm", "@op:chat", "BDS is down", "$bds"); assert_eq!(with_projects.iter().map(|mind| addresses(mind, &bds)).collect::>(), [true, false, false], "only her own row is her session addressee"); @@ -7753,7 +7756,7 @@ fn a_read_the_mouth_does_not_take_is_dropped_without_ending_the_session_and_is_t fn opening(say: &[(&str, &str)], with: &str, text: &str) -> String { let say: Vec<_> = say.iter().map(|(room, text)| json!({ "room": room, "text": text })).collect(); - json!({ "intent": "Ask them over", "say": say, "open_direct": { "with": with, "text": text }, "start_self_run": null, "remember": [], "settle": [], "urgency_permille": null }).to_string() + json!({ "intent": "Ask them over", "say": say, "open_direct": { "with": with, "text": text }, "start_self_run": null, "remember": [], "rule": [], "settle": [], "urgency_permille": null }).to_string() } fn people() -> Vec { @@ -7761,7 +7764,7 @@ fn people() -> Vec { } fn open_fault(reply: &str, people: &[String], conversation: &Conversation, config: &Config) -> Option { - decode_reply(reply, &config.rooms, people, &[], conversation, config, &addressing_of(config, None, &own_addressee(config))).err() + decode_reply(reply, &config.rooms, &Shown { people: people.to_vec(), ..Shown::default() }, conversation, config, &addressing_of(config, None, &own_addressee(config))).err() } #[test] @@ -7789,7 +7792,7 @@ fn open_direct_is_null_outside_a_public_session() { #[test] fn a_reply_without_open_direct_is_not_a_reply_though_null_is_and_an_array_is_not() { let config = config(); - let without = json!({ "intent": "x", "say": [], "start_self_run": null, "remember": [], "settle": [], "urgency_permille": null }).to_string(); + let without = json!({ "intent": "x", "say": [], "start_self_run": null, "remember": [], "rule": [], "settle": [], "urgency_permille": null }).to_string(); assert_eq!(open_fault(&without, &[], &Conversation::Public, &config), Some(Failure::Undecodable)); let array = opening(&[], "@ann:chat", "x").replace(r#""open_direct":{"#, r#""open_direct":[{"#).replace(r#""text":"x"}"#, r#""text":"x"}]"#); assert_eq!(open_fault(&array, &people(), &Conversation::Public, &config), Some(Failure::Undecodable), "one open per reply"); @@ -7933,7 +7936,7 @@ fn a_refused_or_unanswered_open_is_told_and_the_turn_stands() { (Some(DirectOutcome::DailyLimit), "today"), ]; for (outcome, told) in cases { - let kept = json!({ "intent": "Ask them over", "say": [{ "room": "swarm", "text": "yes" }], "open_direct": { "with": "@ann:chat", "text": "Hello Ann" }, "start_self_run": null, "remember": [keeps("Did", "asked Ann over", &["@ann:chat"])], "settle": [], "urgency_permille": null }).to_string(); + let kept = json!({ "intent": "Ask them over", "say": [{ "room": "swarm", "text": "yes" }], "open_direct": { "with": "@ann:chat", "text": "Hello Ann" }, "start_self_run": null, "remember": [keeps("Did", "asked Ann over", &["@ann:chat"])], "rule": [], "settle": [], "urgency_permille": null }).to_string(); let rig = public_rig_with_a_next_line(&[kept, says(&[])]); rig.chat.open_outcome.set(outcome); let Outcome::Served { answered, failure, .. } = rig.go() else { panic!("served") }; @@ -7953,7 +7956,7 @@ fn a_refused_or_unanswered_open_is_told_and_the_turn_stands() { #[test] fn an_open_the_mouth_does_not_serve_is_told_as_not_sent() { - let kept = json!({ "intent": "Ask them over", "say": [{ "room": "swarm", "text": "yes" }], "open_direct": { "with": "@ann:chat", "text": "Hello Ann" }, "start_self_run": null, "remember": [], "settle": [], "urgency_permille": null }).to_string(); + let kept = json!({ "intent": "Ask them over", "say": [{ "room": "swarm", "text": "yes" }], "open_direct": { "with": "@ann:chat", "text": "Hello Ann" }, "start_self_run": null, "remember": [], "rule": [], "settle": [], "urgency_permille": null }).to_string(); let rig = public_rig_with_a_next_line(&[kept, says(&[])]); rig.chat.open_unserved.set(true); let Outcome::Served { failure, .. } = rig.go() else { panic!("served") }; @@ -8348,3 +8351,87 @@ fn when_every_tail_line_is_shown_below_the_line_does_not_say_the_rooms_were_quie assert!(input.contains("are shown below as messages and are not repeated here"), "{input}"); assert!(!input.contains("Recent tail of your rooms: empty") && !input.contains("No tail line for: \"swarm\""), "swarm was not quiet"); } + + +// ---- rule: a peer Mind's decision inside her stewardship, recorded as authority Steward ---- + +fn rule_json(by: &str, words: &str, repo: &str) -> serde_json::Value { + json!({ "by": by, "words": words, "repo": repo, "campaign": CAMPAIGN, "title": "Refuse leading zeros", "ruling": "The importer refuses leading zeros." }) +} + +fn reply_with_rule(rule: serde_json::Value) -> String { + json!({ "intent": "Record what Libby decided", "say": [], "open_direct": null, "start_self_run": null, "remember": [], "rule": [rule], "settle": [], "urgency_permille": null }).to_string() +} + +/// The table: herself, and Libby who stewards `repo` (none when `repo` is empty: her stewardships were never read). +fn table_with_libby(repo: &str) -> Vec { + let libby = Addressee::new("@libby:chat", ["Libby"]); + let libby = if repo.is_empty() { libby } else { libby.with_stewardships([], [OrgRepo(repo.into())]) }; + vec![own_addressee(&config()), libby] +} + +fn libby_said(text: &str, event: Option<&str>) -> Shown { + Shown { said: vec![Said { sender: "@libby:chat".into(), text: text.into(), event: event.map(String::from) }], ..Shown::default() } +} + +fn rulings_in(mind: &dyn MindPort) -> Vec { + let all = Selection { projection: "document".into(), ..of_kind(PipelineKind::Ruling) }; + let PipelinePageItems::Documents(views) = mind.query(&all).unwrap().items else { panic!("documents") }; + views.into_iter().filter_map(|view| if let PipelineDocument::Ruling(ruling) = view.document { Some(ruling) } else { None }).collect() +} + +#[test] +fn a_rule_from_a_minds_own_line_inside_her_stewardship_is_admitted_as_steward_with_the_deliverys_event() { + let heard = delivery(1, "swarm", "@libby:chat", "I decided: refuse leading zeros in the importer"); + let rig = Rig::new(vec![heard.clone()], &[reply_with_rule(rule_json("@libby:chat", "refuse leading zeros", "GameCult/Bifrost"))]); + *rig.table.0.borrow_mut() = Some(crate::heartbeat::Addressing { minds: table_with_libby("GameCult/Bifrost") }); + assert_eq!(failure_of(rig.go()), None); + let [ruling] = rulings_in(&rig.mind).try_into().expect("one ruling"); + assert_eq!( + ruling.authority, + RulingAuthority::Steward { steward: "@libby:chat".into(), repo: OrgRepo("GameCult/Bifrost".into()), words: "refuse leading zeros".into(), event: Some(Short(heard.event_id)) } + ); + assert_eq!((ruling.operator_quote, ruling.answers, ruling.choice), (None, None, None)); +} + +#[test] +fn a_rule_naming_a_repo_the_decider_does_not_steward_is_told_back_and_admits_nothing() { + let rig = Rig::new(vec![delivery(1, "swarm", "@libby:chat", "refuse leading zeros")], &thrice(reply_with_rule(rule_json("@libby:chat", "refuse leading zeros", "GameCult/CultLib")))); + *rig.table.0.borrow_mut() = Some(crate::heartbeat::Addressing { minds: table_with_libby("GameCult/Bifrost") }); + assert_eq!(failure_of(rig.go()), Some(Failure::OutOfBounds(RULE_REPO_FAULT))); + assert!(rulings_in(&rig.mind).is_empty()); +} + +#[test] +fn a_rule_is_refused_when_the_words_the_decider_never_said_or_the_decider_is_not_a_peer_mind() { + let c = config(); + let decode = |by: &str, words: &str, repo: &str, shown: &Shown, table: &[Addressee]| decode_reply(&reply_with_rule(rule_json(by, words, repo)), &c.rooms, shown, &Conversation::Public, &c, table).err(); + let table = table_with_libby("GameCult/Bifrost"); + let said = libby_said("we refuse leading zeros", Some("$e1")); + assert_eq!(decode("@libby:chat", "refuse leading zeros", "GameCult/Bifrost", &said, &table), None); + assert_eq!(decode("@libby:chat", "accept leading zeros", "GameCult/Bifrost", &said, &table), Some(Failure::OutOfBounds(RULE_WORDS_FAULT))); + // Her words, quoted by someone else, are not hers. + let quoted = Shown { said: vec![Said { sender: "@guest:chat".into(), text: "refuse leading zeros".into(), event: Some("$e2".into()) }], ..Shown::default() }; + assert_eq!(decode("@libby:chat", "refuse leading zeros", "GameCult/Bifrost", "ed, &table), Some(Failure::OutOfBounds(RULE_WORDS_FAULT))); + assert_eq!(decode("@libby:chat", "refuse leading zeros", "GameCult/Bifrost", &said, &table_with_libby("")), Some(Failure::OutOfBounds(RULE_REPO_UNREAD_FAULT))); + assert_eq!(decode("@eureka:chat", "x", "GameCult/Bifrost", &said, &table), Some(Failure::OutOfBounds(RULE_BY_OWN_FAULT))); + assert_eq!(decode("@op:chat", "x", "GameCult/Bifrost", &said, &table), Some(Failure::OutOfBounds(RULE_BY_OPERATOR_FAULT))); + assert_eq!(decode("@guest:chat", "x", "GameCult/Bifrost", &said, &table), Some(Failure::OutOfBounds(RULE_BY_UNKNOWN_FAULT))); +} + +#[test] +fn words_found_only_in_an_earlier_line_carry_no_event() { + let run = PipelineRef { kind: PipelineKind::Run, id: format!("{INSTANCE}:run:mind-1").as_str().into() }; + let reply: PersonaReply = serde_json::from_str(&reply_with_rule(rule_json("@libby:chat", "refuse leading zeros", "GameCult/Bifrost"))).unwrap(); + let event_of = |shown: &Shown| match build_rulings(&run, 1, &reply, shown, Date("2026-11-11".into())).remove(0) { + PipelineDocument::Ruling(ruling) => match ruling.authority { + RulingAuthority::Steward { event, .. } => event, + other => panic!("{other:?}"), + }, + other => panic!("{other:?}"), + }; + assert_eq!(event_of(&libby_said("refuse leading zeros", None)), None); + assert_eq!(event_of(&libby_said("refuse leading zeros", Some("$e9"))), Some(Short("$e9".into()))); + let both = Shown { said: [libby_said("refuse leading zeros", Some("$e9")).said, libby_said("refuse leading zeros", None).said].concat(), ..Shown::default() }; + assert_eq!(event_of(&both), Some(Short("$e9".into())), "a delivery that held the words names its event even when an earlier line repeats them"); +} diff --git a/crates/mind-body/src/recall.rs b/crates/mind-body/src/recall.rs index 8055ec4..c6855bd 100644 --- a/crates/mind-body/src/recall.rs +++ b/crates/mind-body/src/recall.rs @@ -28,7 +28,7 @@ use std::collections::BTreeMap; use anyhow::{Result, bail}; use chrono::{DateTime, Utc}; use cultnet_rs::Selection; -use eureka_pipeline::{MemoryOrigin, MemorySort, PipelineDocument, PipelineKind}; +use eureka_pipeline::{MemoryOrigin, MemorySort, PipelineDocument, PipelineKind, RulingAuthority}; use huginn_mind::{PipelineDocumentView, PipelinePageItems}; use crate::persona::quoted; @@ -248,7 +248,19 @@ fn line_of(view: &PipelineDocumentView, names: &BTreeMap<&str, &str>) -> Option< match &view.document { 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))), + PipelineDocument::Ruling(ruling) => { + let title = quoted(&ruling.title.0); + Some(match &ruling.authority { + RulingAuthority::Steward { steward, repo, .. } => { + // A display name is a member's text: keep it to one line. + let who: String = names.get(steward.0.as_str()).copied().unwrap_or(steward.0.as_str()).chars().filter(|c| !c.is_control()).collect(); + format!("- {who} ruled, as steward of {}: {title}\n", repo.0) + } + RulingAuthority::Mind => format!("- You ruled: {title}\n"), + RulingAuthority::Operator => format!("- The operator ruled: {title}\n"), + RulingAuthority::Standing | RulingAuthority::Defaulted => format!("- Ruled: {title}\n"), + }) + } _ => None, } } diff --git a/crates/mind-body/src/recall/tests.rs b/crates/mind-body/src/recall/tests.rs index e1a2901..db5718c 100644 --- a/crates/mind-body/src/recall/tests.rs +++ b/crates/mind-body/src/recall/tests.rs @@ -402,6 +402,28 @@ fn a_follow_up_and_a_ruling_read_as_what_she_set_herself_and_what_was_ruled() { assert!(lines.contains(&"- Ruled: \"A ruling\"\n"), "{lines:?}"); } +#[test] +fn a_ruling_names_who_decided_it() { + let mind = TestMind::seeded(); + mind.committed(vec![spec("x")]); + let by = |label: &str, authority: RulingAuthority| match ruling(label, None) { + PipelineDocument::Ruling(mut ruling) => { + ruling.authority = authority; + PipelineDocument::Ruling(ruling) + } + other => other, + }; + let steward = RulingAuthority::Steward { steward: "@heimdall:chat".into(), repo: eureka_pipeline::OrgRepo("GameCult/Bifrost".into()), words: "no zeros".into(), event: None }; + mind.committed(vec![follow_up("fork-it", &spec_ref("x")), by("R1", steward), by("R2", RulingAuthority::Mind), by("R3", RulingAuthority::Operator)]); + let mut asking = cue(&[LIBBY], &["Later"], &[]); + asking.names = BTreeMap::from([("@heimdall:chat", "Heimdall")]); + let recalled = select(&Recording::over(&mind), &asking).unwrap(); + let lines: Vec<&str> = recalled.entries().map(|entry| entry.line.as_str()).collect(); + for expected in ["- Heimdall ruled, as steward of GameCult/Bifrost: \"A ruling\"\n", "- You ruled: \"A ruling\"\n", "- The operator ruled: \"A ruling\"\n"] { + assert!(lines.contains(&expected), "{expected}: {lines:?}"); + } +} + #[test] fn the_empty_notice_appears_only_when_nothing_is_remembered_or_left_out() { let open_only = TestMind::seeded(); diff --git a/crates/mind-body/tests/public_surface.txt b/crates/mind-body/tests/public_surface.txt index 018ae4f..b923c13 100644 --- a/crates/mind-body/tests/public_surface.txt +++ b/crates/mind-body/tests/public_surface.txt @@ -233,8 +233,9 @@ mind_body::persona: struct FileTrace pub-fields[] mind_body::persona: struct HuginnPersona pub-fields[] mind_body::persona: struct OpenDirect # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[with: String, text: String] mind_body::persona: struct Organs< 'a > pub-fields[mind: & 'a dyn MindPort, launcher: & 'a dyn Launcher, clock: & 'a dyn Clock, host: & 'a str, chat: & 'a dyn Chat, persona: & 'a dyn PersonaState, trace: & 'a dyn Trace, heartbeat: Option < & 'a dyn HeartbeatView >, lender: & 'a dyn Lender, spawner: & 'a dyn Spawner, run_cap_s: u64, sandbox: & 'a Path, scratch: & 'a Path] -mind_body::persona: struct PersonaReply # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[intent: String, say: Vec < Say >, open_direct: Option < OpenDirect >, start_self_run: Option < StartSelfRun >, remember: Vec < Remember >, settle: Vec < Settle >, tempo_vote_beat_s: Option < Option < u32 > >, urgency_permille: Option < u16 >] +mind_body::persona: struct PersonaReply # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[intent: String, say: Vec < Say >, open_direct: Option < OpenDirect >, start_self_run: Option < StartSelfRun >, remember: Vec < Remember >, rule: Vec < Rule >, settle: Vec < Settle >, tempo_vote_beat_s: Option < Option < u32 > >, urgency_permille: Option < u16 >] mind_body::persona: struct Remember # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[sort: MemorySort, text: String, about: Vec < String >, salience_permille: u16] +mind_body::persona: struct Rule # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[by: String, words: String, repo: String, campaign: String, title: String, ruling: String] mind_body::persona: struct Say # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[room: String, text: String] mind_body::persona: struct Settle # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[item: String, outcome: SettleOutcome, why: String] mind_body::persona: struct StartSelfRun # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[brief: String, repo: String, campaign: String, for_item: Option < String >] From a26bce249dead9394db97b98f486b301257f9df8 Mon Sep 17 00:00:00 2001 From: Eureka Date: Wed, 11 Nov 2026 12:26:47 +0000 Subject: [PATCH 4/5] persona: a rule's words are a clause of her own line outside quotation, its campaign is checked before any effect, a co-stewarded repo is not a rule, and the repo fault names her repos; tests pin the survivors Co-Authored-By: Claude Sonnet 5.5 --- crates/eureka-pipeline/src/lib.rs | 3 +- crates/huginn-mind/src/index.rs | 9 +++ crates/mind-body/src/persona.rs | 91 ++++++++++++++++------- crates/mind-body/src/persona/tests.rs | 82 ++++++++++++++++++-- crates/mind-body/tests/public_surface.txt | 2 +- 5 files changed, 152 insertions(+), 35 deletions(-) diff --git a/crates/eureka-pipeline/src/lib.rs b/crates/eureka-pipeline/src/lib.rs index 4541ae7..08b0bf2 100644 --- a/crates/eureka-pipeline/src/lib.rs +++ b/crates/eureka-pipeline/src/lib.rs @@ -2008,8 +2008,9 @@ mod tests { refused(|a| if let RulingAuthority::Steward { steward, .. } = a { steward.0 = String::new() }), refused(|a| if let RulingAuthority::Steward { repo, .. } = a { repo.0 = "nope".into() }), refused(|a| if let RulingAuthority::Steward { words, .. } = a { words.0 = "w".repeat(4001) }), + refused(|a| if let RulingAuthority::Steward { event, .. } = a { *event = Some("e".repeat(201).as_str().into()) }), ]; - for (fault, field) in fields.iter().zip(["authority.words", "authority.steward", "authority.repo", "authority.words"]) { + for (fault, field) in fields.iter().zip(["authority.words", "authority.steward", "authority.repo", "authority.words", "authority.event"]) { assert!(fault.contains(field), "{fault}"); } } diff --git a/crates/huginn-mind/src/index.rs b/crates/huginn-mind/src/index.rs index 2d83c5a..13ccff1 100644 --- a/crates/huginn-mind/src/index.rs +++ b/crates/huginn-mind/src/index.rs @@ -250,6 +250,15 @@ More documents."); assert_eq!(INDEX_TEXT_VERSION, 1); } + /// A Steward ruling is found by the words the peer said as well as by its title and ruling. + #[test] + fn a_steward_rulings_words_are_indexed() { + let mut peer = ruling("R1"); + peer.operator_quote = None; + peer.authority = RulingAuthority::Steward { steward: s("@heimdall:chat"), repo: eureka_pipeline::OrgRepo(REPO.into()), words: "refuse leading zeros".into(), event: None }; + assert!(index_text(&D::Ruling(peer)).unwrap().lines().any(|line| line == "refuse leading zeros")); + } + fn texts(mind: &Mind) -> BTreeMap { mind.index_entries(None).unwrap().into_iter().map(|entry| (entry.id.id.0, entry.text)).collect() } diff --git a/crates/mind-body/src/persona.rs b/crates/mind-body/src/persona.rs index a835fce..f597215 100644 --- a/crates/mind-body/src/persona.rs +++ b/crates/mind-body/src/persona.rs @@ -165,8 +165,8 @@ const OPEN_DIRECT_TEXT_MAX_BYTES: usize = 4000; const RULE_BY_OWN_FAULT: &str = "rule.by: your own decision is not a rule"; const RULE_BY_OPERATOR_FAULT: &str = "rule.by: rule records a Mind's decision in her stewardship"; const RULE_BY_UNKNOWN_FAULT: &str = "rule.by: not a Mind of the swarm"; -const RULE_WORDS_FAULT: &str = "rule.words: copy them exactly from one of her lines in this session"; -const RULE_REPO_FAULT: &str = "rule.repo: not a repo the Mind named stewards"; +const RULE_WORDS_FAULT: &str = "rule.words: copy them exactly, at least 12 bytes, from one of her lines in this session, outside any quotation"; +const RULE_REPO_OWN_FAULT: &str = "rule.repo: inside your own stewardship, where a Mind's word is a respected contributor's: weigh it and decide, it is not a rule"; const RULE_REPO_UNREAD_FAULT: &str = "rule.repo: no stewardship of the Mind named has been read yet"; const RULE_BOUNDS_FAULT: &str = "rule: more entries than one reply may carry, or a field blank or over its length bound"; const RULE_CAMPAIGN_FAULT: &str = "rule.campaign: not a campaign in force"; @@ -176,6 +176,8 @@ const REMEMBER_MAX: usize = 8; const SETTLE_MAX: usize = 8; /// The most rulings of other Minds one reply records. const RULE_MAX: usize = 4; +/// The fewest bytes of her line `words` must copy: a clause, not a letter. +const RULE_WORDS_MIN_BYTES: usize = 12; const RULE_TITLE_MAX_BYTES: usize = 200; const RULE_TEXT_MAX_BYTES: usize = 4000; /// A memory's text in bytes, the people it is about, and a settle's reason in characters. @@ -750,7 +752,7 @@ pub struct Organs<'a> { } /// Why a start did not complete. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq)] pub enum Failure { Held(HeldReason), Chat, @@ -766,6 +768,8 @@ pub enum Failure { Undecodable, /// A reply that parsed and broke the bound of the field this names: the organ's words, never the reply's. OutOfBounds(&'static str), + /// A rule named a repo the decider does not steward: the organ's own words, naming what the heartbeat's table says she stewards. + RuleRepo(String), RoomNotAllowed, Send, SelfRun, @@ -786,6 +790,7 @@ impl fmt::Display for Failure { Self::Session => f.write_str("your session could not be started or ended without answering"), Self::Undecodable => f.write_str("the reply is not a persona reply"), Self::OutOfBounds(field) => f.write_str(field), + Self::RuleRepo(told) => f.write_str(told), Self::RoomNotAllowed => f.write_str("the reply speaks outside your rooms"), Self::Send => f.write_str("a send was refused or failed"), Self::SelfRun => f.write_str("the Self run could not be started"), @@ -856,7 +861,7 @@ struct Progress { } /// A refused batch as the start remembers it. The mouth still holds its deliveries, so this needs only their lowest seq. -#[derive(Clone, Copy)] +#[derive(Clone)] struct Refused { lowest: u64, why: Failure, @@ -966,7 +971,7 @@ pub fn carry(organs: &Organs, control: &impl ControlSource, swarm: &impl Control break; } } - close_run(organs, &run, progress.answered, progress.last_intent.as_deref(), progress.failure, noted.failures.get())?; + close_run(organs, &run, progress.answered, progress.last_intent.as_deref(), progress.failure.clone(), noted.failures.get())?; Ok(Outcome::Served { run, answered: progress.answered, failure: progress.failure, self_run: progress.self_run }) } @@ -1663,7 +1668,7 @@ impl SessionInbox for PersonaInbox<'_, C, S> fn start_line(&mut self) -> Result { self.read_replies(); let batch = std::mem::take(&mut self.start); - let (note, refusals) = self.progress.refused.map_or((None, 0), |Refused { why, refusals, .. }| (Some(refused_note(why)), refusals)); + let (note, refusals) = self.progress.refused.clone().map_or((None, 0), |Refused { why, refusals, .. }| (Some(refused_note(why)), refusals)); let line = self.whole_line(&batch, note.as_deref())?; Ok(self.write(batch, line, refusals)) } @@ -1828,7 +1833,7 @@ impl SessionInbox for PersonaInbox<'_, C, S> // Her final answer lands after she took in every line, so a revision still waiting is answered by it and its note, that a send was not sent, would be false. let spent = if final_reply { self.revision.take().map(|revision| revision.batch) } else { None }; self.answered_through = self.answered_through.max(batch.iter().chain(spent.iter().flatten()).map(|delivery| delivery.seq).max().unwrap_or(0)); - if self.progress.refused.is_some_and(|refused| batch.iter().any(|delivery| delivery.seq == refused.lowest)) { + if self.progress.refused.as_ref().is_some_and(|refused| batch.iter().any(|delivery| delivery.seq == refused.lowest)) { self.progress.refused = None; } let unanswered = self @@ -1898,9 +1903,10 @@ impl SessionInbox for PersonaInbox<'_, C, S> merged.dedup_by_key(|delivery| delivery.seq); // A line in flight may hold a batch at a higher count than this result's, so the start's record only rises. A refusal always has a batch: the result that carries none is a flush alone, and a flush is written alone only when no refused batch waits, so one in flight has its own result first, whose refusal puts it back in the retell this one merges. let held = self.progress.refused.take(); + let held = held.as_ref(); self.progress.refused = merged .first() - .map(|first| Refused { lowest: held.map_or(first.seq, |held| held.lowest.min(first.seq)), why, refusals: held.map_or(most, |held| held.refusals.max(most)) }); + .map(|first| Refused { lowest: held.map_or(first.seq, |held| held.lowest.min(first.seq)), why: why.clone(), refusals: held.map_or(most, |held| held.refusals.max(most)) }); self.retell = Some(Retell { batch: merged, why, refusals: most, revision }); Ok((true, false)) } @@ -1974,6 +1980,13 @@ fn act(inbox: &PersonaInbox<'_, C, S>, batch Err(why) => return refuse(why), }; + // A campaign closed since the schema was fixed refuses the reply before any say or open has gone out, so the retell repeats no effect. + if !reply.rule.is_empty() { + let in_force = campaigns_in_force(organs.mind)?; + if reply.rule.iter().any(|rule| !in_force.iter().any(|(slug, _)| *slug == rule.campaign)) { + return refuse(Failure::OutOfBounds(RULE_CAMPAIGN_FAULT)); + } + } // Request ids name the batch of deliveries and the say, so a repeat is the same send. let batch_id = match (batch.iter().map(|delivery| delivery.seq).min(), batch.iter().map(|delivery| delivery.seq).max()) { (Some(first), Some(last)) => format!("s{first}-{last}"), @@ -2047,12 +2060,6 @@ fn act(inbox: &PersonaInbox<'_, C, S>, batch // The one origin of this turn: its memories and its brief record the same conversation. let origin = origin_of(config, batch, &inbox.origin); let memories = build_memories(&instance, run, turn, &reply, &origin); - if !reply.rule.is_empty() { - let in_force = campaigns_in_force(organs.mind)?; - if reply.rule.iter().any(|rule| !in_force.iter().any(|(slug, _)| *slug == rule.campaign)) { - return refuse(Failure::OutOfBounds(RULE_CAMPAIGN_FAULT)); - } - } let rulings = build_rulings(run, turn, &reply, &inbox.seen, date(now)); for rule in &reply.rule { outputs.push(TraceItem { provenance: "her own".into(), room: String::new(), text: format!("rule {} {}: {}", rule.by, rule.repo, rule.title) }); @@ -2220,36 +2227,66 @@ fn memory_fault(reply: &PersonaReply, people: &[String], open: &[String]) -> Opt /// The field a rule breaks, if any: it records a peer Mind's decision, so `by` is a Mind of the table (neither herself nor the /// operator), `words` are the Mind's own, copied from one of her lines this session showed, and `repo` is one the table says she stewards. /// What the words mean, and whether they fall inside that repo, is her reading; the organ checks the line and the stewardship. -fn rule_fault(reply: &PersonaReply, shown: &Shown, addressing: &[Addressee], config: &Config) -> Option<&'static str> { +fn rule_fault(reply: &PersonaReply, shown: &Shown, addressing: &[Addressee], config: &Config) -> Option { + use Failure::OutOfBounds as Out; if reply.rule.len() > RULE_MAX { - return Some(RULE_BOUNDS_FAULT); + return Some(Out(RULE_BOUNDS_FAULT)); } for rule in &reply.rule { let text_ok = |text: &str, max: usize| (1..=max).contains(&text.len()) && !text.trim().is_empty(); let title_ok = text_ok(&rule.title, RULE_TITLE_MAX_BYTES) && rule.title.chars().any(char::is_alphanumeric) && !rule.title.chars().any(char::is_control); if rule.by.len() > SHORT_MAX_BYTES || !title_ok || !text_ok(&rule.ruling, RULE_TEXT_MAX_BYTES) || !text_ok(&rule.words, RULE_TEXT_MAX_BYTES) || !repo_in_bounds(&rule.repo) || !campaign_in_bounds(&rule.campaign) { - return Some(RULE_BOUNDS_FAULT); + return Some(Out(RULE_BOUNDS_FAULT)); } match sender_role(config, addressing, &rule.by) { - Sender::Persona => return Some(RULE_BY_OWN_FAULT), - Sender::Operator => return Some(RULE_BY_OPERATOR_FAULT), - Sender::Member => return Some(RULE_BY_UNKNOWN_FAULT), + Sender::Persona => return Some(Out(RULE_BY_OWN_FAULT)), + Sender::Operator => return Some(Out(RULE_BY_OPERATOR_FAULT)), + Sender::Member => return Some(Out(RULE_BY_UNKNOWN_FAULT)), Sender::Mind => {} } - if !shown.said.iter().any(|said| said.sender == rule.by && said.text.contains(&rule.words)) { - return Some(RULE_WORDS_FAULT); + if !shown.said.iter().any(|said| said.sender == rule.by && in_her_own_voice(&said.text, &rule.words)) { + return Some(Out(RULE_WORDS_FAULT)); } let stewarded = addressing.iter().find(|row| row.matrix_id() == rule.by).map(Addressee::repos).unwrap_or_default(); if stewarded.is_empty() { - return Some(RULE_REPO_UNREAD_FAULT); + return Some(Out(RULE_REPO_UNREAD_FAULT)); } - if !stewarded.contains(&OrgRepo(rule.repo.clone())) { - return Some(RULE_REPO_FAULT); + let repo = OrgRepo(rule.repo.clone()); + if !stewarded.contains(&repo) { + let list = stewarded.iter().map(|repo| repo.0.as_str()).collect::>().join(", "); + return Some(Failure::RuleRepo(format!("rule.repo: {} stewards {list}", rule.by))); + } + if addressing.iter().find(|row| row.matrix_id() == config.own_sender).is_some_and(|own| own.repos().contains(&repo)) { + return Some(Out(RULE_REPO_OWN_FAULT)); } } None } +/// Whether `words` are her own: at least `RULE_WORDS_MIN_BYTES`, copied from `line` on whole-word boundaries and outside any quotation +/// (straight or curly), so a letter of a greeting, or what she was quoting (the operator's words included), is not hers. +fn in_her_own_voice(line: &str, words: &str) -> bool { + if words.trim().len() < RULE_WORDS_MIN_BYTES { + return false; + } + line.match_indices(words).any(|(at, found)| { + let before = line[..at].chars().next_back(); + let after = line[at + found.len()..].chars().next(); + let edge = |c: Option| c.is_none_or(|c| !c.is_alphanumeric()); + let (mut straight, mut curly) = (false, 0i32); + for c in line[..at].chars() { + match c { + '"' => straight = !straight, + '\u{201C}' => curly += 1, + '\u{201D}' => curly = (curly - 1).max(0), + _ => {} + } + } + let quoted = straight || curly > 0 || found.contains(['"', '\u{201C}', '\u{201D}']); + edge(before) && edge(after) && !quoted + }) +} + /// Whether an open is hers to ask: from a public conversation, of a person this session showed who is neither a Mind nor herself, with /// an invitation the mouth takes (not blank, within its byte bound). fn open_in_bounds(ask: &OpenDirect, conversation: &Conversation, config: &Config, addressing: &[Addressee], people: &[String]) -> bool { @@ -2277,8 +2314,8 @@ fn decode_reply(text: &str, rooms: &[String], seen: &Shown, conversation: &Conve if let Some(field) = memory_fault(&reply, &seen.people, &seen.open) { return Err(Failure::OutOfBounds(field)); } - if let Some(field) = rule_fault(&reply, seen, addressing, config) { - return Err(Failure::OutOfBounds(field)); + if let Some(failure) = rule_fault(&reply, seen, addressing, config) { + return Err(failure); } if reply.open_direct.as_ref().is_some_and(|ask| !open_in_bounds(ask, conversation, config, addressing, &seen.people)) { return Err(Failure::OutOfBounds(OPEN_DIRECT_FAULT)); diff --git a/crates/mind-body/src/persona/tests.rs b/crates/mind-body/src/persona/tests.rs index a924758..4f64af4 100644 --- a/crates/mind-body/src/persona/tests.rs +++ b/crates/mind-body/src/persona/tests.rs @@ -1783,10 +1783,10 @@ fn outcomes_and_failures_read_as_one_line_each_without_values() { Failure::Memory, Failure::Ack, ]; - for failure in all { + for failure in all.clone() { assert!(!failure.to_string().is_empty()); } - let texts: std::collections::BTreeSet<_> = all.map(|failure| failure.to_string()).into(); + let texts: std::collections::BTreeSet<_> = all.iter().map(|failure| failure.to_string()).collect(); assert_eq!(texts.len(), all.len(), "each failure reads differently"); } @@ -6387,7 +6387,7 @@ fn a_stale_refusal_is_her_next_input_and_costs_no_refusal() { assert_eq!(sent[1].basis, None, "one revision, then her choice stands, though the room moved again"); assert!(sent[1].request_id.contains("-r1-"), "{}", sent[1].request_id); assert!(rig.chat.landed.borrow().values().any(|(_, text)| text == "then the cellar is mine")); - assert_eq!((inbox.progress.answered, inbox.progress.failure), (1, None)); + assert_eq!((inbox.progress.answered, inbox.progress.failure.clone()), (1, None)); assert_eq!(rig.chat.acked.borrow().as_slice(), &[2], "the batch is acked once the revision is answered"); }); } @@ -6444,7 +6444,7 @@ fn eight_stale_refusals_in_a_row_in_different_batches_never_end_the_session() { inbox.tell(false).unwrap(); inbox.echoed(); inbox.result(&reply_result(&says(&[("swarm", "revised")])), true, false).unwrap(); - assert_eq!((inbox.progress.failure, inbox.progress.refused.is_none()), (None, true), "round {round}"); + assert_eq!((inbox.progress.failure.clone(), inbox.progress.refused.is_none()), (None, true), "round {round}"); } assert_eq!((inbox.progress.answered, inbox.progress.turn), (8, 16)); }); @@ -6579,7 +6579,7 @@ fn an_undecodable_revision_stays_final() { inbox.tell(false).unwrap(); inbox.echoed(); inbox.result(&json!({"type": "result", "is_error": false, "structured_output": {"nonsense": 1}}).to_string(), true, false).unwrap(); - assert_eq!(inbox.progress.refused.map(|refused| refused.refusals), Some(1), "an undecodable reply is a refusal, as ever"); + assert_eq!(inbox.progress.refused.as_ref().map(|refused| refused.refusals), Some(1), "an undecodable reply is a refusal, as ever"); assert!(inbox.poll().unwrap().pending); inbox.tell(false).unwrap(); inbox.echoed(); @@ -8507,7 +8507,7 @@ fn a_rule_from_a_minds_own_line_inside_her_stewardship_is_admitted_as_steward_wi fn a_rule_naming_a_repo_the_decider_does_not_steward_is_told_back_and_admits_nothing() { let rig = Rig::new(vec![delivery(1, "swarm", "@libby:chat", "refuse leading zeros")], &thrice(reply_with_rule(rule_json("@libby:chat", "refuse leading zeros", "GameCult/CultLib")))); *rig.table.0.borrow_mut() = Some(crate::heartbeat::Addressing { minds: table_with_libby("GameCult/Bifrost") }); - assert_eq!(failure_of(rig.go()), Some(Failure::OutOfBounds(RULE_REPO_FAULT))); + assert_eq!(failure_of(rig.go()), Some(Failure::RuleRepo("rule.repo: @libby:chat stewards GameCult/Bifrost".into()))); assert!(rulings_in(&rig.mind).is_empty()); } @@ -8544,3 +8544,73 @@ fn words_found_only_in_an_earlier_line_carry_no_event() { let both = Shown { said: [libby_said("refuse leading zeros", Some("$e9")).said, libby_said("refuse leading zeros", None).said].concat(), ..Shown::default() }; assert_eq!(event_of(&both), Some(Short("$e9".into())), "a delivery that held the words names its event even when an earlier line repeats them"); } + + +#[test] +fn rule_words_must_be_a_clause_of_her_own_line_outside_any_quotation() { + let c = config(); + let table = table_with_libby("GameCult/Bifrost"); + let decode = |line: &str, words: &str| decode_reply(&reply_with_rule(rule_json("@libby:chat", words, "GameCult/Bifrost")), &c.rooms, &libby_said(line, Some("$e1")), &Conversation::Public, &c, &table).err(); + let fault = Some(Failure::OutOfBounds(RULE_WORDS_FAULT)); + assert_eq!(decode("refuse leading zeros", "refuse leading zeros"), None, "the whole line"); + assert_eq!(decode("he said \"no\" and we refuse leading zeros", "refuse leading zeros"), None, "after a closed quotation"); + assert_eq!(decode("hello everyone, nice weather today", "e"), fault, "a letter of a greeting"); + assert_eq!(decode("refuse leading zeros", "refuse"), fault, "under the floor"); + assert_eq!(decode("xrefuse leading zerosy", "refuse leading zeros"), fault, "inside words"); + assert_eq!(decode("The operator told me: \"refuse leading zeros\"", "refuse leading zeros"), fault, "the operator's words in straight quotes"); + assert_eq!(decode("The operator told me: \u{201C}refuse leading zeros\u{201D}", "refuse leading zeros"), fault, "in curly quotes"); + assert_eq!(decode("she said \"x\"; refuse leading zeros", "x\"; refuse leading"), fault, "words that carry a quotation mark"); +} + +#[test] +fn a_rule_inside_the_followers_own_stewardship_is_not_a_rule_even_when_the_decider_co_stewards_it() { + let c = config(); + let mut table = table_with_libby("GameCult/Thing"); + table[0] = own_addressee(&c).with_stewardships([], [OrgRepo("GameCult/Thing".into())]); + let said = libby_said("we refuse leading zeros", Some("$e1")); + let decode = |repo: &str, table: &[Addressee]| decode_reply(&reply_with_rule(rule_json("@libby:chat", "refuse leading zeros", repo)), &c.rooms, &said, &Conversation::Public, &c, table).err(); + assert_eq!(decode("GameCult/Thing", &table), Some(Failure::OutOfBounds(RULE_REPO_OWN_FAULT))); + assert_eq!(decode("GameCult/Thing", &table_with_libby("GameCult/Thing")), None, "a repo she does not steward herself stands"); +} + +#[test] +fn a_rule_whose_campaign_is_not_in_force_is_refused_before_any_say_goes_out() { + let mut rule = rule_json("@libby:chat", "refuse leading zeros", "GameCult/Bifrost"); + rule["campaign"] = json!("closed-since"); + let reply = json!({ "intent": "Record it", "say": [{ "room": "swarm", "text": "noted" }], "open_direct": null, "start_self_run": null, "remember": [], "rule": [rule], "settle": [], "urgency_permille": null }).to_string(); + let rig = Rig::new(vec![delivery(1, "swarm", "@libby:chat", "we refuse leading zeros")], &thrice(reply)); + *rig.table.0.borrow_mut() = Some(crate::heartbeat::Addressing { minds: table_with_libby("GameCult/Bifrost") }); + assert_eq!(failure_of(rig.go()), Some(Failure::OutOfBounds(RULE_CAMPAIGN_FAULT))); + assert!(rig.sent_texts().is_empty(), "no say goes out under a refused reply: {:?}", rig.sent_texts()); + assert!(rulings_in(&rig.mind).is_empty()); +} + +#[test] +fn one_reply_records_at_most_four_rules() { + let c = config(); + let table = table_with_libby("GameCult/Bifrost"); + let said = libby_said("we refuse leading zeros", Some("$e1")); + let many = |n: usize| { + let rules: Vec<_> = (0..n).map(|_| rule_json("@libby:chat", "refuse leading zeros", "GameCult/Bifrost")).collect(); + json!({ "intent": "x", "say": [], "open_direct": null, "start_self_run": null, "remember": [], "rule": rules, "settle": [], "urgency_permille": null }).to_string() + }; + assert!(decode_reply(&many(4), &c.rooms, &said, &Conversation::Public, &c, &table).is_ok()); + assert_eq!(decode_reply(&many(5), &c.rooms, &said, &Conversation::Public, &c, &table), Err(Failure::OutOfBounds(RULE_BOUNDS_FAULT))); +} + +#[test] +fn the_event_is_the_newest_delivery_and_said_holds_every_line_a_session_showed_in_print_order() { + let run = PipelineRef { kind: PipelineKind::Run, id: format!("{INSTANCE}:run:mind-1").as_str().into() }; + let reply: PersonaReply = serde_json::from_str(&reply_with_rule(rule_json("@libby:chat", "refuse leading zeros", "GameCult/Bifrost"))).unwrap(); + let said = |event: &str| Said { sender: "@libby:chat".into(), text: "refuse leading zeros".into(), event: Some(event.into()) }; + let shown = Shown { said: vec![said("$old"), said("$new")], ..Shown::default() }; + let PipelineDocument::Ruling(ruling) = build_rulings(&run, 1, &reply, &shown, Date("2026-11-11".into())).remove(0) else { panic!() }; + assert!(matches!(ruling.authority, RulingAuthority::Steward { event: Some(Short(ref e)), .. } if e == "$new")); + + let first = delivery_of(1, "swarm", "@libby:chat", "first", "$a"); + let second = delivery_of(2, "swarm", "@guest:chat", "second", "$b"); + let context = ContextLine { sender: "@libby:chat".into(), text: "before".into(), at_unix_ms: 1 }; + let lines = said_of(&[first, second], &[vec![&context], vec![]]); + let seen: Vec<(&str, &str, Option<&str>)> = lines.iter().map(|l| (l.sender.as_str(), l.text.as_str(), l.event.as_deref())).collect(); + assert_eq!(seen, [("@libby:chat", "before", None), ("@libby:chat", "first", Some("$a")), ("@guest:chat", "second", Some("$b"))]); +} diff --git a/crates/mind-body/tests/public_surface.txt b/crates/mind-body/tests/public_surface.txt index b923c13..a6bc864 100644 --- a/crates/mind-body/tests/public_surface.txt +++ b/crates/mind-body/tests/public_surface.txt @@ -189,7 +189,7 @@ mind_body::lease_client: struct SocketLender pub-fields[] mind_body::lease_client: struct TokenFd pub-fields[] mind_body::lease_client::socket: impl Lease for Granted mind_body::persona: # [derive (Clone , Copy , Debug , PartialEq , Eq , Serialize , Deserialize)] # [serde (rename_all = "lowercase")] pub enum SettleOutcome { Done , Declined , Wrong , } -mind_body::persona: # [derive (Clone , Copy , Debug , PartialEq , Eq)] pub enum Failure { Held (HeldReason) , Chat , Persona , Doctrine , DoctrineTooLarge , Lease , Session , Undecodable , OutOfBounds (& 'static str) , RoomNotAllowed , Send , SelfRun , Memory , Ack , } +mind_body::persona: # [derive (Clone , Debug , PartialEq , Eq)] pub enum Failure { Held (HeldReason) , Chat , Persona , Doctrine , DoctrineTooLarge , Lease , Session , Undecodable , OutOfBounds (& 'static str) , RuleRepo (String) , RoomNotAllowed , Send , SelfRun , Memory , Ack , } mind_body::persona: # [derive (Debug , PartialEq , Eq)] pub enum Outcome { Dead (HeldReason) , NoRun , Served { run : PipelineRef , answered : u32 , failure : Option < Failure > , self_run : Option < PipelineRef > } , } mind_body::persona: const AGENT : & str mind_body::persona: const DOCTRINE_MAX_BYTES : usize From f8558a12a3a08919f089df5fd6c51666c0be978d Mon Sep 17 00:00:00 2001 From: Eureka Date: Wed, 11 Nov 2026 12:48:32 +0000 Subject: [PATCH 5/5] persona: a rule's words are four words of her own line outside any quotation form or quoted reply; tests pin later lines and title control Co-Authored-By: Claude Sonnet 5.5 --- crates/mind-body/src/persona.rs | 59 ++++++---- crates/mind-body/src/persona/tests.rs | 126 +++++++++++++++++----- crates/mind-body/tests/public_surface.txt | 2 +- 3 files changed, 138 insertions(+), 49 deletions(-) diff --git a/crates/mind-body/src/persona.rs b/crates/mind-body/src/persona.rs index 24976e5..34a6bd7 100644 --- a/crates/mind-body/src/persona.rs +++ b/crates/mind-body/src/persona.rs @@ -165,7 +165,7 @@ const OPEN_DIRECT_TEXT_MAX_BYTES: usize = 4000; const RULE_BY_OWN_FAULT: &str = "rule.by: your own decision is not a rule"; const RULE_BY_OPERATOR_FAULT: &str = "rule.by: rule records a Mind's decision in her stewardship"; const RULE_BY_UNKNOWN_FAULT: &str = "rule.by: not a Mind of the swarm"; -const RULE_WORDS_FAULT: &str = "rule.words: copy them exactly, at least 12 bytes, from one of her lines in this session, outside any quotation"; +const RULE_WORDS_FAULT: &str = "rule.words: copy them exactly, at least 4 words, from one of her own lines in this session, outside any quotation or quoted reply"; const RULE_REPO_OWN_FAULT: &str = "rule.repo: inside your own stewardship, where a Mind's word is a respected contributor's: weigh it and decide, it is not a rule"; const RULE_REPO_UNREAD_FAULT: &str = "rule.repo: no stewardship of the Mind named has been read yet"; const RULE_BOUNDS_FAULT: &str = "rule: more entries than one reply may carry, or a field blank or over its length bound"; @@ -176,8 +176,8 @@ const REMEMBER_MAX: usize = 8; const SETTLE_MAX: usize = 8; /// The most rulings of other Minds one reply records. const RULE_MAX: usize = 4; -/// The fewest bytes of her line `words` must copy: a clause, not a letter. -const RULE_WORDS_MIN_BYTES: usize = 12; +/// The fewest words of her line `words` must copy: a clause, not a letter or a stopword. +const RULE_WORDS_MIN_WORDS: usize = 4; const RULE_TITLE_MAX_BYTES: usize = 200; const RULE_TEXT_MAX_BYTES: usize = 4000; /// A memory's text in bytes, the people it is about, and a settle's reason in characters. @@ -2269,30 +2269,49 @@ fn rule_fault(reply: &PersonaReply, shown: &Shown, addressing: &[Addressee], con None } -/// Whether `words` are her own: at least `RULE_WORDS_MIN_BYTES`, copied from `line` on whole-word boundaries and outside any quotation -/// (straight or curly), so a letter of a greeting, or what she was quoting (the operator's words included), is not hers. +/// Whether `words` are her own: a clause of at least `RULE_WORDS_MIN_WORDS` words, copied on word boundaries from `line` as she wrote it +/// herself. A quoted line of a reply (`>` lines, the Matrix reply fallback) is not hers, and neither is anything inside a quotation: +/// straight, curly or single quotes, backticks and guillemets. A scanner of the forms a chat line takes, not a reading of meaning: it +/// stops the ordinary ways a Mind relays someone else's decision, and says nothing of a quotation written some other way. fn in_her_own_voice(line: &str, words: &str) -> bool { - if words.trim().len() < RULE_WORDS_MIN_BYTES { + if words.split_whitespace().filter(|word| word.chars().any(char::is_alphanumeric)).count() < RULE_WORDS_MIN_WORDS || words.contains(QUOTE_MARKS) { return false; } - line.match_indices(words).any(|(at, found)| { - let before = line[..at].chars().next_back(); - let after = line[at + found.len()..].chars().next(); + let own = line.lines().filter(|line| !line.trim_start().starts_with('>')).collect::>().join("\n"); + own.match_indices(words).any(|(at, found)| { let edge = |c: Option| c.is_none_or(|c| !c.is_alphanumeric()); - let (mut straight, mut curly) = (false, 0i32); - for c in line[..at].chars() { - match c { - '"' => straight = !straight, - '\u{201C}' => curly += 1, - '\u{201D}' => curly = (curly - 1).max(0), - _ => {} - } - } - let quoted = straight || curly > 0 || found.contains(['"', '\u{201C}', '\u{201D}']); - edge(before) && edge(after) && !quoted + edge(own[..at].chars().next_back()) && edge(own[at + found.len()..].chars().next()) && !inside_quotation(&own, at) }) } +/// Every character that opens or closes a quotation in a chat line. +const QUOTE_MARKS: [char; 9] = ['"', '\'', '`', '\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}', '\u{00AB}', '\u{00BB}']; + +/// Whether byte `upto` of `text` is inside an unclosed quotation. A single quote opens one only at the start of a word and closes it only +/// at the end of one, so an apostrophe inside a word (don't, Libby's) is none. +fn inside_quotation(text: &str, upto: usize) -> bool { + let chars: Vec = text.chars().collect(); + let upto = text[..upto].chars().count(); + let mut closer: Option = None; + for (at, c) in chars.iter().copied().enumerate().take(upto) { + let (prev, next) = (at.checked_sub(1).map(|p| chars[p]), chars.get(at + 1).copied()); + let word_before = prev.is_some_and(char::is_alphanumeric); + let word_after = next.is_some_and(char::is_alphanumeric); + closer = match (closer, c) { + (None, '"') => Some('"'), + (None, '`') => Some('`'), + (None, '\u{201C}') => Some('\u{201D}'), + (None, '\u{00AB}') => Some('\u{00BB}'), + (None, '\u{2018}') => Some('\u{2019}'), + (None, '\'') if !word_before && word_after => Some('\''), + (Some('\u{2019}'), '\u{2019}') | (Some('\''), '\'') if !word_after => None, + (Some(open), c) if open == c && !matches!(c, '\'' | '\u{2019}') => None, + (kept, _) => kept, + }; + } + closer.is_some() +} + /// Whether an open is hers to ask: from a public conversation, of a person this session showed who is neither a Mind nor herself, with /// an invitation the mouth takes (not blank, within its byte bound). fn open_in_bounds(ask: &OpenDirect, conversation: &Conversation, config: &Config, addressing: &[Addressee], people: &[String]) -> bool { diff --git a/crates/mind-body/src/persona/tests.rs b/crates/mind-body/src/persona/tests.rs index 7e904df..8051ab0 100644 --- a/crates/mind-body/src/persona/tests.rs +++ b/crates/mind-body/src/persona/tests.rs @@ -8494,21 +8494,21 @@ fn rulings_in(mind: &dyn MindPort) -> Vec { #[test] fn a_rule_from_a_minds_own_line_inside_her_stewardship_is_admitted_as_steward_with_the_deliverys_event() { - let heard = delivery(1, "swarm", "@libby:chat", "I decided: refuse leading zeros in the importer"); - let rig = Rig::new(vec![heard.clone()], &[reply_with_rule(rule_json("@libby:chat", "refuse leading zeros", "GameCult/Bifrost"))]); + let heard = delivery(1, "swarm", "@libby:chat", "I decided: always refuse leading zeros in the importer"); + let rig = Rig::new(vec![heard.clone()], &[reply_with_rule(rule_json("@libby:chat", "always refuse leading zeros", "GameCult/Bifrost"))]); *rig.table.0.borrow_mut() = Some(crate::heartbeat::Addressing { minds: table_with_libby("GameCult/Bifrost") }); assert_eq!(failure_of(rig.go()), None); let [ruling] = rulings_in(&rig.mind).try_into().expect("one ruling"); assert_eq!( ruling.authority, - RulingAuthority::Steward { steward: "@libby:chat".into(), repo: OrgRepo("GameCult/Bifrost".into()), words: "refuse leading zeros".into(), event: Some(Short(heard.event_id)) } + RulingAuthority::Steward { steward: "@libby:chat".into(), repo: OrgRepo("GameCult/Bifrost".into()), words: "always refuse leading zeros".into(), event: Some(Short(heard.event_id)) } ); assert_eq!((ruling.operator_quote, ruling.answers, ruling.choice), (None, None, None)); } #[test] fn a_rule_naming_a_repo_the_decider_does_not_steward_is_told_back_and_admits_nothing() { - let rig = Rig::new(vec![delivery(1, "swarm", "@libby:chat", "refuse leading zeros")], &thrice(reply_with_rule(rule_json("@libby:chat", "refuse leading zeros", "GameCult/CultLib")))); + let rig = Rig::new(vec![delivery(1, "swarm", "@libby:chat", "always refuse leading zeros")], &thrice(reply_with_rule(rule_json("@libby:chat", "always refuse leading zeros", "GameCult/CultLib")))); *rig.table.0.borrow_mut() = Some(crate::heartbeat::Addressing { minds: table_with_libby("GameCult/Bifrost") }); assert_eq!(failure_of(rig.go()), Some(Failure::RuleRepo("rule.repo: @libby:chat stewards GameCult/Bifrost".into()))); assert!(rulings_in(&rig.mind).is_empty()); @@ -8519,13 +8519,13 @@ fn a_rule_is_refused_when_the_words_the_decider_never_said_or_the_decider_is_not let c = config(); let decode = |by: &str, words: &str, repo: &str, shown: &Shown, table: &[Addressee]| decode_reply(&reply_with_rule(rule_json(by, words, repo)), &c.rooms, shown, &Conversation::Public, &c, table).err(); let table = table_with_libby("GameCult/Bifrost"); - let said = libby_said("we refuse leading zeros", Some("$e1")); - assert_eq!(decode("@libby:chat", "refuse leading zeros", "GameCult/Bifrost", &said, &table), None); + let said = libby_said("we always refuse leading zeros", Some("$e1")); + assert_eq!(decode("@libby:chat", "always refuse leading zeros", "GameCult/Bifrost", &said, &table), None); assert_eq!(decode("@libby:chat", "accept leading zeros", "GameCult/Bifrost", &said, &table), Some(Failure::OutOfBounds(RULE_WORDS_FAULT))); // Her words, quoted by someone else, are not hers. - let quoted = Shown { said: vec![Said { sender: "@guest:chat".into(), text: "refuse leading zeros".into(), event: Some("$e2".into()) }], ..Shown::default() }; - assert_eq!(decode("@libby:chat", "refuse leading zeros", "GameCult/Bifrost", "ed, &table), Some(Failure::OutOfBounds(RULE_WORDS_FAULT))); - assert_eq!(decode("@libby:chat", "refuse leading zeros", "GameCult/Bifrost", &said, &table_with_libby("")), Some(Failure::OutOfBounds(RULE_REPO_UNREAD_FAULT))); + let quoted = Shown { said: vec![Said { sender: "@guest:chat".into(), text: "always refuse leading zeros".into(), event: Some("$e2".into()) }], ..Shown::default() }; + assert_eq!(decode("@libby:chat", "always refuse leading zeros", "GameCult/Bifrost", "ed, &table), Some(Failure::OutOfBounds(RULE_WORDS_FAULT))); + assert_eq!(decode("@libby:chat", "always refuse leading zeros", "GameCult/Bifrost", &said, &table_with_libby("")), Some(Failure::OutOfBounds(RULE_REPO_UNREAD_FAULT))); assert_eq!(decode("@eureka:chat", "x", "GameCult/Bifrost", &said, &table), Some(Failure::OutOfBounds(RULE_BY_OWN_FAULT))); assert_eq!(decode("@op:chat", "x", "GameCult/Bifrost", &said, &table), Some(Failure::OutOfBounds(RULE_BY_OPERATOR_FAULT))); assert_eq!(decode("@guest:chat", "x", "GameCult/Bifrost", &said, &table), Some(Failure::OutOfBounds(RULE_BY_UNKNOWN_FAULT))); @@ -8534,7 +8534,7 @@ fn a_rule_is_refused_when_the_words_the_decider_never_said_or_the_decider_is_not #[test] fn words_found_only_in_an_earlier_line_carry_no_event() { let run = PipelineRef { kind: PipelineKind::Run, id: format!("{INSTANCE}:run:mind-1").as_str().into() }; - let reply: PersonaReply = serde_json::from_str(&reply_with_rule(rule_json("@libby:chat", "refuse leading zeros", "GameCult/Bifrost"))).unwrap(); + let reply: PersonaReply = serde_json::from_str(&reply_with_rule(rule_json("@libby:chat", "always refuse leading zeros", "GameCult/Bifrost"))).unwrap(); let event_of = |shown: &Shown| match build_rulings(&run, 1, &reply, shown, Date("2026-11-11".into())).remove(0) { PipelineDocument::Ruling(ruling) => match ruling.authority { RulingAuthority::Steward { event, .. } => event, @@ -8542,9 +8542,9 @@ fn words_found_only_in_an_earlier_line_carry_no_event() { }, other => panic!("{other:?}"), }; - assert_eq!(event_of(&libby_said("refuse leading zeros", None)), None); - assert_eq!(event_of(&libby_said("refuse leading zeros", Some("$e9"))), Some(Short("$e9".into()))); - let both = Shown { said: [libby_said("refuse leading zeros", Some("$e9")).said, libby_said("refuse leading zeros", None).said].concat(), ..Shown::default() }; + assert_eq!(event_of(&libby_said("always refuse leading zeros", None)), None); + assert_eq!(event_of(&libby_said("always refuse leading zeros", Some("$e9"))), Some(Short("$e9".into()))); + let both = Shown { said: [libby_said("always refuse leading zeros", Some("$e9")).said, libby_said("always refuse leading zeros", None).said].concat(), ..Shown::default() }; assert_eq!(event_of(&both), Some(Short("$e9".into())), "a delivery that held the words names its event even when an earlier line repeats them"); } @@ -8555,14 +8555,84 @@ fn rule_words_must_be_a_clause_of_her_own_line_outside_any_quotation() { let table = table_with_libby("GameCult/Bifrost"); let decode = |line: &str, words: &str| decode_reply(&reply_with_rule(rule_json("@libby:chat", words, "GameCult/Bifrost")), &c.rooms, &libby_said(line, Some("$e1")), &Conversation::Public, &c, &table).err(); let fault = Some(Failure::OutOfBounds(RULE_WORDS_FAULT)); - assert_eq!(decode("refuse leading zeros", "refuse leading zeros"), None, "the whole line"); - assert_eq!(decode("he said \"no\" and we refuse leading zeros", "refuse leading zeros"), None, "after a closed quotation"); + let words = "always refuse leading zeros"; + assert_eq!(decode(words, words), None, "the whole line"); + assert_eq!(decode("he said \"no\" and we always refuse leading zeros", words), None, "after a closed quotation"); + assert_eq!(decode("Libby's rule, and we don't budge: always refuse leading zeros", words), None, "an apostrophe inside a word is no quotation"); + assert_eq!(decode("the op said 'fine'. always refuse leading zeros", words), None, "after a closed single quotation"); assert_eq!(decode("hello everyone, nice weather today", "e"), fault, "a letter of a greeting"); - assert_eq!(decode("refuse leading zeros", "refuse"), fault, "under the floor"); - assert_eq!(decode("xrefuse leading zerosy", "refuse leading zeros"), fault, "inside words"); - assert_eq!(decode("The operator told me: \"refuse leading zeros\"", "refuse leading zeros"), fault, "the operator's words in straight quotes"); - assert_eq!(decode("The operator told me: \u{201C}refuse leading zeros\u{201D}", "refuse leading zeros"), fault, "in curly quotes"); - assert_eq!(decode("she said \"x\"; refuse leading zeros", "x\"; refuse leading"), fault, "words that carry a quotation mark"); + assert_eq!(decode(words, "always refuse"), fault, "under the floor"); + assert_eq!(decode("xalways refuse leading zerosy", words), fault, "inside words"); + assert_eq!(decode("a \"b\" always refuse leading zeros", "\"b\" always refuse leading zeros"), fault, "words that carry a quotation mark"); +} + +/// What a Persona is shown is the raw text of a line, so a Mind relaying someone else's decision writes it in one of these forms. +#[test] +fn rule_words_quoted_in_any_form_a_line_carries_are_not_the_minds_own() { + let c = config(); + let table = table_with_libby("GameCult/Bifrost"); + let words = "ship the importer now please"; + let fault = Some(Failure::OutOfBounds(RULE_WORDS_FAULT)); + let decode = |line: &str| decode_reply(&reply_with_rule(rule_json("@libby:chat", words, "GameCult/Bifrost")), &c.rooms, &libby_said(line, Some("$e1")), &Conversation::Public, &c, &table).err(); + for (form, line) in [ + ("straight double", "the op said \"ship the importer now please\""), + ("curly double", "the op said \u{201C}ship the importer now please\u{201D}"), + ("single", "the op said 'ship the importer now please'"), + ("curly single", "the op said \u{2018}ship the importer now please\u{2019}"), + ("backtick", "the op said `ship the importer now please`"), + ("guillemet", "the op said \u{00AB}ship the importer now please\u{00BB}"), + ("blockquote", "> ship the importer now please\n\nagreed"), + ("reply fallback", "> <@op:chat> ship the importer now please\n\nagreed, going ahead"), + ("indented blockquote", "ok\n > ship the importer now please"), + ] { + assert_eq!(decode(line), fault, "{form}"); + } + assert_eq!(decode("> <@op:chat> hold on\n\nagreed, so ship the importer now please"), None, "her own text after a quoted reply is hers"); +} + +#[test] +fn rule_words_need_four_words_of_content_not_four_bytes() { + let c = config(); + let table = table_with_libby("GameCult/Bifrost"); + let decode = |words: &str| decode_reply(&reply_with_rule(rule_json("@libby:chat", words, "GameCult/Bifrost")), &c.rooms, &libby_said(&format!("{words} and more"), Some("$e1")), &Conversation::Public, &c, &table).err(); + let fault = Some(Failure::OutOfBounds(RULE_WORDS_FAULT)); + assert_eq!(decode("\u{1F600} \u{1F600} \u{1F600} \u{1F600}"), fault, "symbols are not words"); + assert_eq!(decode("I think that"), fault, "three words"); + assert_eq!(decode("- - - - always refuse"), fault, "punctuation is not a word"); + assert_eq!(decode("we always refuse zeros"), None); +} + +#[test] +fn a_rule_title_with_a_control_character_is_refused() { + let c = config(); + let table = table_with_libby("GameCult/Bifrost"); + let decode = |title: &str| { + let mut rule = rule_json("@libby:chat", "always refuse leading zeros", "GameCult/Bifrost"); + rule["title"] = json!(title); + decode_reply(&reply_with_rule(rule), &c.rooms, &libby_said("we always refuse leading zeros", Some("$e1")), &Conversation::Public, &c, &table).err() + }; + assert_eq!(decode("Refuse\nzeros"), Some(Failure::OutOfBounds(RULE_BOUNDS_FAULT))); + assert_eq!(decode("Refuse\u{7}zeros"), Some(Failure::OutOfBounds(RULE_BOUNDS_FAULT))); + assert_eq!(decode("Refuse zeros"), None); +} + +#[test] +fn a_rule_may_quote_a_line_delivered_after_the_sessions_first_line() { + let rig = Rig::new(vec![operator_says(1, "first")], &[says(&[]), reply_with_rule(rule_json("@libby:chat", "always refuse leading zeros", "GameCult/Bifrost"))]); + rig.clock.step.set(0); + *rig.table.0.borrow_mut() = Some(crate::heartbeat::Addressing { minds: table_with_libby("GameCult/Bifrost") }); + let (deliveries, now, arrived) = (rig.chat.deliveries.clone(), rig.clock.now.clone(), Cell::new(false)); + let later = delivery(2, "swarm", "@libby:chat", "we always refuse leading zeros"); + let event = later.event_id.clone(); + *rig.model.after_echo.borrow_mut() = Some(Box::new(move || { + if !arrived.replace(true) { + deliveries.borrow_mut().push(later.clone()); + } + now.set(now.get() + 6_000); + })); + assert_eq!(failure_of(rig.go()), None); + let [ruling] = rulings_in(&rig.mind).try_into().expect("one ruling"); + assert!(matches!(ruling.authority, RulingAuthority::Steward { event: Some(Short(ref e)), .. } if *e == event)); } #[test] @@ -8570,18 +8640,18 @@ fn a_rule_inside_the_followers_own_stewardship_is_not_a_rule_even_when_the_decid let c = config(); let mut table = table_with_libby("GameCult/Thing"); table[0] = own_addressee(&c).with_stewardships([], [OrgRepo("GameCult/Thing".into())]); - let said = libby_said("we refuse leading zeros", Some("$e1")); - let decode = |repo: &str, table: &[Addressee]| decode_reply(&reply_with_rule(rule_json("@libby:chat", "refuse leading zeros", repo)), &c.rooms, &said, &Conversation::Public, &c, table).err(); + let said = libby_said("we always refuse leading zeros", Some("$e1")); + let decode = |repo: &str, table: &[Addressee]| decode_reply(&reply_with_rule(rule_json("@libby:chat", "always refuse leading zeros", repo)), &c.rooms, &said, &Conversation::Public, &c, table).err(); assert_eq!(decode("GameCult/Thing", &table), Some(Failure::OutOfBounds(RULE_REPO_OWN_FAULT))); assert_eq!(decode("GameCult/Thing", &table_with_libby("GameCult/Thing")), None, "a repo she does not steward herself stands"); } #[test] fn a_rule_whose_campaign_is_not_in_force_is_refused_before_any_say_goes_out() { - let mut rule = rule_json("@libby:chat", "refuse leading zeros", "GameCult/Bifrost"); + let mut rule = rule_json("@libby:chat", "always refuse leading zeros", "GameCult/Bifrost"); rule["campaign"] = json!("closed-since"); let reply = json!({ "intent": "Record it", "say": [{ "room": "swarm", "text": "noted" }], "open_direct": null, "start_self_run": null, "remember": [], "rule": [rule], "settle": [], "urgency_permille": null }).to_string(); - let rig = Rig::new(vec![delivery(1, "swarm", "@libby:chat", "we refuse leading zeros")], &thrice(reply)); + let rig = Rig::new(vec![delivery(1, "swarm", "@libby:chat", "we always refuse leading zeros")], &thrice(reply)); *rig.table.0.borrow_mut() = Some(crate::heartbeat::Addressing { minds: table_with_libby("GameCult/Bifrost") }); assert_eq!(failure_of(rig.go()), Some(Failure::OutOfBounds(RULE_CAMPAIGN_FAULT))); assert!(rig.sent_texts().is_empty(), "no say goes out under a refused reply: {:?}", rig.sent_texts()); @@ -8592,9 +8662,9 @@ fn a_rule_whose_campaign_is_not_in_force_is_refused_before_any_say_goes_out() { fn one_reply_records_at_most_four_rules() { let c = config(); let table = table_with_libby("GameCult/Bifrost"); - let said = libby_said("we refuse leading zeros", Some("$e1")); + let said = libby_said("we always refuse leading zeros", Some("$e1")); let many = |n: usize| { - let rules: Vec<_> = (0..n).map(|_| rule_json("@libby:chat", "refuse leading zeros", "GameCult/Bifrost")).collect(); + let rules: Vec<_> = (0..n).map(|_| rule_json("@libby:chat", "always refuse leading zeros", "GameCult/Bifrost")).collect(); json!({ "intent": "x", "say": [], "open_direct": null, "start_self_run": null, "remember": [], "rule": rules, "settle": [], "urgency_permille": null }).to_string() }; assert!(decode_reply(&many(4), &c.rooms, &said, &Conversation::Public, &c, &table).is_ok()); @@ -8604,8 +8674,8 @@ fn one_reply_records_at_most_four_rules() { #[test] fn the_event_is_the_newest_delivery_and_said_holds_every_line_a_session_showed_in_print_order() { let run = PipelineRef { kind: PipelineKind::Run, id: format!("{INSTANCE}:run:mind-1").as_str().into() }; - let reply: PersonaReply = serde_json::from_str(&reply_with_rule(rule_json("@libby:chat", "refuse leading zeros", "GameCult/Bifrost"))).unwrap(); - let said = |event: &str| Said { sender: "@libby:chat".into(), text: "refuse leading zeros".into(), event: Some(event.into()) }; + let reply: PersonaReply = serde_json::from_str(&reply_with_rule(rule_json("@libby:chat", "always refuse leading zeros", "GameCult/Bifrost"))).unwrap(); + let said = |event: &str| Said { sender: "@libby:chat".into(), text: "always refuse leading zeros".into(), event: Some(event.into()) }; let shown = Shown { said: vec![said("$old"), said("$new")], ..Shown::default() }; let PipelineDocument::Ruling(ruling) = build_rulings(&run, 1, &reply, &shown, Date("2026-11-11".into())).remove(0) else { panic!() }; assert!(matches!(ruling.authority, RulingAuthority::Steward { event: Some(Short(ref e)), .. } if e == "$new")); diff --git a/crates/mind-body/tests/public_surface.txt b/crates/mind-body/tests/public_surface.txt index a6bc864..2382c83 100644 --- a/crates/mind-body/tests/public_surface.txt +++ b/crates/mind-body/tests/public_surface.txt @@ -57,7 +57,7 @@ mind_body::chat_client: struct ChatDelivery # [derive (Clone , Debug , PartialEq mind_body::chat_client: struct ChatImage # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[path: String, mime: String, size: u64, sha256: String] mind_body::chat_client: struct ChatOpenDirectReceipt # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[request_id: String, outcome: DirectOutcome, room_id: Option < String >, event_id: Option < String >] mind_body::chat_client: struct ChatOpenDirectRequest # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[request_id: String, persona: String, user_id: String, text: String] -mind_body::chat_client: struct ChatPending # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[persona: String, deliveries: Vec < ChatDelivery >, tail: Option < Vec < TailLine > >] +mind_body::chat_client: struct ChatPending # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[persona: String, deliveries: Vec < ChatDelivery >, tail: Option < Vec < TailLine > >, tail_gaps: Vec < String >] mind_body::chat_client: struct ChatPendingRequest # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[persona: String, after_seq: u64] mind_body::chat_client: struct ChatRead # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[persona: String, room: String, event_id: String, at_unix_ms: u64] mind_body::chat_client: struct ChatRefusal # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[persona: String, refusal: Refusal]