Merge pull request 'Steward rulings: a peer steward's decision is a typed ruling in the follower's mind (stacked on #46)' (#48) from hands/eureka-body-steward-rulings into main
This commit is contained in:
commit
ceb1dbdb86
19 changed files with 922 additions and 137 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<Short> },
|
||||
}
|
||||
|
||||
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,50 @@ 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) }),
|
||||
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", "authority.event"]) {
|
||||
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 +2059,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 +2136,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![
|
||||
|
|
|
|||
|
|
@ -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::<SourceHits>(&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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
|||
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);
|
||||
|
|
@ -247,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<MemoryStore>) -> BTreeMap<String, String> {
|
||||
mind.index_entries(None).unwrap().into_iter().map(|entry| (entry.id.id.0, entry.text)).collect()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"]),
|
||||
|
|
|
|||
|
|
@ -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<Name>,
|
||||
/// 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<OrgRepo>,
|
||||
}
|
||||
|
||||
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<Item = &'a str>) -> 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<Item = Name>) -> 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<Item = Name>, repos: impl IntoIterator<Item = OrgRepo>) -> 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ fn names(texts: &[&str]) -> Vec<Short> {
|
|||
}
|
||||
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -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<Option<PipelineDo
|
|||
Ok(found.into_iter().next())
|
||||
}
|
||||
|
||||
/// The chat names of every repo she stewards in force: one query on her mind.
|
||||
pub(crate) fn project_names_of(mind: &dyn MindPort) -> Result<Vec<Name>> {
|
||||
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<Name>, Vec<OrgRepo>)> {
|
||||
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<Outcome> {
|
|||
}
|
||||
// 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(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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,24 @@ 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, 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";
|
||||
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:<i> 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;
|
||||
/// 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.
|
||||
pub(crate) const REMEMBER_TEXT_MAX_BYTES: usize = 1000;
|
||||
pub(crate) const ABOUT_MAX: usize = 8;
|
||||
|
|
@ -311,6 +325,8 @@ pub struct PersonaReply {
|
|||
pub start_self_run: Option<StartSelfRun>,
|
||||
/// Required, empty when she keeps nothing: what she must still know when this conversation is gone.
|
||||
pub remember: Vec<Remember>,
|
||||
/// Required, empty when she records none: a decision another Mind made inside her own stewardship, as she heard it.
|
||||
pub rule: Vec<Rule>,
|
||||
/// Required, empty when she settles nothing: the open commitments and asks shown this turn that she closes.
|
||||
pub settle: Vec<Settle>,
|
||||
/// The beat she votes for, in seconds. Omitted, she says nothing about her
|
||||
|
|
@ -362,6 +378,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 +445,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 +495,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 +553,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 +571,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 }] }
|
||||
|
|
@ -703,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,
|
||||
|
|
@ -719,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,
|
||||
|
|
@ -739,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"),
|
||||
|
|
@ -809,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,
|
||||
|
|
@ -890,7 +942,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);
|
||||
|
|
@ -919,7 +972,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 })
|
||||
}
|
||||
|
||||
|
|
@ -1158,6 +1211,26 @@ fn next_session(config: &Config, addressing: &[Addressee], pending: Vec<ChatDeli
|
|||
struct Shown {
|
||||
people: Vec<String>,
|
||||
open: Vec<String>,
|
||||
/// 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<Said>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// The lines `message_lines` prints for a batch, in print order.
|
||||
fn said_of(deliveries: &[ChatDelivery], earlier: &[Vec<&ContextLine>]) -> Vec<Said> {
|
||||
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 {
|
||||
|
|
@ -1172,6 +1245,7 @@ impl Shown {
|
|||
self.open.push(item);
|
||||
}
|
||||
}
|
||||
self.said.extend(other.said);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1405,7 +1479,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);
|
||||
|
|
@ -1426,7 +1500,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), &std::mem::take(&mut self.tail_gaps), &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
|
||||
|
|
@ -1438,7 +1512,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.
|
||||
|
|
@ -1600,7 +1674,7 @@ impl<C: ControlSource, S: ControlSource> SessionInbox for PersonaInbox<'_, C, S>
|
|||
fn start_line(&mut self) -> Result<String> {
|
||||
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))
|
||||
}
|
||||
|
|
@ -1756,7 +1830,7 @@ impl<C: ControlSource, S: ControlSource> 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
|
||||
|
|
@ -1765,7 +1839,7 @@ impl<C: ControlSource, S: ControlSource> 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
|
||||
|
|
@ -1835,9 +1909,10 @@ impl<C: ControlSource, S: ControlSource> 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))
|
||||
}
|
||||
|
|
@ -1906,11 +1981,18 @@ fn act<C: ControlSource, S: ControlSource>(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),
|
||||
};
|
||||
|
||||
// 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}"),
|
||||
|
|
@ -1984,6 +2066,10 @@ fn act<C: ControlSource, S: ControlSource>(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);
|
||||
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) });
|
||||
}
|
||||
|
|
@ -1993,7 +2079,7 @@ fn act<C: ControlSource, S: ControlSource>(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));
|
||||
}
|
||||
|
|
@ -2144,6 +2230,88 @@ 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<Failure> {
|
||||
use Failure::OutOfBounds as Out;
|
||||
if reply.rule.len() > RULE_MAX {
|
||||
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(Out(RULE_BOUNDS_FAULT));
|
||||
}
|
||||
match sender_role(config, addressing, &rule.by) {
|
||||
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 && 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(Out(RULE_REPO_UNREAD_FAULT));
|
||||
}
|
||||
let repo = OrgRepo(rule.repo.clone());
|
||||
if !stewarded.contains(&repo) {
|
||||
let list = stewarded.iter().map(|repo| repo.0.as_str()).collect::<Vec<_>>().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: 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.split_whitespace().filter(|word| word.chars().any(char::is_alphanumeric)).count() < RULE_WORDS_MIN_WORDS || words.contains(QUOTE_MARKS) {
|
||||
return false;
|
||||
}
|
||||
let own = line.lines().filter(|line| !line.trim_start().starts_with('>')).collect::<Vec<_>>().join("\n");
|
||||
own.match_indices(words).any(|(at, found)| {
|
||||
let edge = |c: Option<char>| c.is_none_or(|c| !c.is_alphanumeric());
|
||||
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<char> = text.chars().collect();
|
||||
let upto = text[..upto].chars().count();
|
||||
let mut closer: Option<char> = 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 {
|
||||
|
|
@ -2157,7 +2325,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<PersonaReply, Failure> {
|
||||
fn decode_reply(text: &str, rooms: &[String], seen: &Shown, conversation: &Conversation, config: &Config, addressing: &[Addressee]) -> Result<PersonaReply, Failure> {
|
||||
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));
|
||||
|
|
@ -2168,10 +2336,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(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));
|
||||
}
|
||||
if reply.say.iter().any(|say| !rooms.contains(&say.room) || say.text.trim().is_empty()) {
|
||||
|
|
@ -2374,6 +2545,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<PipelineDocument> {
|
||||
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:<i>`.
|
||||
fn item_taken(start: &StartSelfRun, memories: &[PipelineDocument]) -> Result<Option<PipelineRef>> {
|
||||
let Some(item) = start.for_item.as_deref() else { return Ok(None) };
|
||||
|
|
@ -2387,11 +2584,12 @@ fn item_taken(start: &StartSelfRun, memories: &[PipelineDocument]) -> Result<Opt
|
|||
Ok(Some(PipelineRef { kind: PipelineKind::Memory, id: id.as_str().into() }))
|
||||
}
|
||||
|
||||
/// One admission per turn: her memories, the resolutions that settle open items, and her vote and
|
||||
/// One admission per turn: her memories, the rulings she records, the resolutions that settle open items, and her vote and
|
||||
/// declared urgency as one Pulse (its label is her run label and the turn, which sorts in time
|
||||
/// order like a run label). True when there was nothing to admit or her mind took it.
|
||||
fn admit_turn_documents(organs: &Organs, run: &PipelineRef, turn: u32, reply: &PersonaReply, memories: Vec<PipelineDocument>) -> Result<bool> {
|
||||
fn admit_turn_documents(organs: &Organs, run: &PipelineRef, turn: u32, reply: &PersonaReply, memories: Vec<PipelineDocument>, rulings: Vec<PipelineDocument>) -> Result<bool> {
|
||||
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());
|
||||
|
|
|
|||
|
|
@ -404,6 +404,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.
|
||||
|
|
@ -466,7 +468,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 {
|
||||
|
|
@ -520,6 +522,7 @@ impl Rig {
|
|||
lender: FakeLender::new(Answer::Grant),
|
||||
persona: FakePersona(Cell::new(false)),
|
||||
trace: FakeTrace::default(),
|
||||
table: FakeHeartbeat(RefCell::new(None)),
|
||||
log,
|
||||
};
|
||||
rig.release();
|
||||
|
|
@ -580,7 +583,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(),
|
||||
|
|
@ -741,17 +744,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]
|
||||
|
|
@ -868,7 +871,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
|
||||
})
|
||||
|
|
@ -892,7 +895,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)));
|
||||
|
|
@ -903,8 +906,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));
|
||||
|
|
@ -914,8 +917,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");
|
||||
|
|
@ -1054,7 +1057,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());
|
||||
}
|
||||
|
|
@ -1233,7 +1236,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));
|
||||
|
|
@ -1241,7 +1244,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));
|
||||
|
|
@ -1366,7 +1369,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);
|
||||
|
|
@ -1590,7 +1593,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<serde_json::Value>, 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;
|
||||
}
|
||||
|
|
@ -1635,7 +1638,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!(
|
||||
|
|
@ -1783,10 +1786,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");
|
||||
}
|
||||
|
||||
|
|
@ -1958,7 +1961,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 {
|
||||
|
|
@ -2157,10 +2160,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}");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2170,7 +2173,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::<Vec<_>>())));
|
||||
|
|
@ -2182,7 +2185,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::<Vec<_>>())));
|
||||
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");
|
||||
|
|
@ -2255,13 +2258,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:<i> 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:<i> 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" }));
|
||||
|
|
@ -3130,7 +3133,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<String> {
|
||||
|
|
@ -3267,7 +3270,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.
|
||||
|
|
@ -3437,11 +3440,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}");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3578,7 +3581,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]
|
||||
|
|
@ -3590,7 +3593,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:?}");
|
||||
|
|
@ -3608,7 +3611,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);
|
||||
|
|
@ -3684,7 +3687,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,
|
||||
|
|
@ -3719,7 +3722,7 @@ fn answering(rig: &Rig, reply: serde_json::Value) -> (Closing, Rc<RefCell<Vec<St
|
|||
}
|
||||
|
||||
fn empty_reply() -> 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.
|
||||
|
|
@ -3821,7 +3824,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();
|
||||
|
|
@ -3851,24 +3854,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::<Vec<_>>());
|
||||
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<String> = (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<String> = (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::<Vec<_>>()));
|
||||
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 {
|
||||
|
|
@ -6387,7 +6390,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 +6447,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 +6582,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();
|
||||
|
|
@ -6722,7 +6725,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");
|
||||
|
|
@ -7123,7 +7126,7 @@ fn eight_minds(listed: &[(&str, &str, &[&str])]) -> Vec<Addressee> {
|
|||
.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::<Vec<_>>())),
|
||||
Some((_, repo, names)) => row.with_stewardships(project_names(&OrgRepo((*repo).into()), &names.iter().map(|name| eureka_pipeline::Short((*name).into())).collect::<Vec<_>>()), [OrgRepo((*repo).into())]),
|
||||
None => row,
|
||||
}
|
||||
})
|
||||
|
|
@ -7186,7 +7189,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::<Vec<_>>(), ["@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::<Vec<_>>(), [true, false, false], "only her own row is her session addressee");
|
||||
|
|
@ -7865,7 +7868,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<String> {
|
||||
|
|
@ -7873,7 +7876,7 @@ fn people() -> Vec<String> {
|
|||
}
|
||||
|
||||
fn open_fault(reply: &str, people: &[String], conversation: &Conversation, config: &Config) -> Option<Failure> {
|
||||
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]
|
||||
|
|
@ -7901,7 +7904,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");
|
||||
|
|
@ -8045,7 +8048,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") };
|
||||
|
|
@ -8065,7 +8068,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") };
|
||||
|
|
@ -8461,6 +8464,230 @@ fn when_every_tail_line_is_shown_below_the_line_does_not_say_the_rooms_were_quie
|
|||
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<Addressee> {
|
||||
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<eureka_pipeline::PipelineRuling> {
|
||||
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: 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: "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", "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());
|
||||
}
|
||||
|
||||
#[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 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: "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)));
|
||||
}
|
||||
|
||||
#[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", "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,
|
||||
other => panic!("{other:?}"),
|
||||
},
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
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");
|
||||
}
|
||||
|
||||
|
||||
#[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));
|
||||
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(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]
|
||||
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 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", "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 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());
|
||||
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 always refuse leading zeros", Some("$e1"));
|
||||
let many = |n: usize| {
|
||||
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());
|
||||
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", "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"));
|
||||
|
||||
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"))]);
|
||||
}
|
||||
|
||||
/// A room the mouth names as a gap reads as unknown, whether it has lines or none; a room it does not name and that has no line is quiet.
|
||||
/// The same bytes a four-field mouth sends decode into the gaps the first line renders.
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
@ -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
|
||||
|
|
@ -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 >]
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
Loading…
Reference in a new issue