persona: rule records a peer steward's decision as authority Steward; addressing rows carry stewarded repos; recall names the decider

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Eureka 2026-11-11 11:56:53 +00:00
commit 416fd7f719
9 changed files with 371 additions and 95 deletions

View file

@ -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
}

View file

@ -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");

View file

@ -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(),
}
}

View file

@ -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));

View file

@ -74,7 +74,7 @@ use chrono::{DateTime, Utc};
use cultcache_rs::{CultCache, DatabaseEntry, SingleFileMessagePackBackingStore};
use cultnet_rs::{Incoming, Selection};
use eureka_pipeline::{
Date, Label, Line, MemoryOrigin, MemorySort, OrgRepo, PipelineDocument, PipelineFollowUp, PipelineKind, PipelineMemory, PipelinePulse, PipelineRef, PipelineResolution, ResolutionOutcome, RunOperator, RunTurn, SALIENCE_MAX_PERMILLE, Short, Slug,
Date, Label, Line, MemoryOrigin, MemorySort, OrgRepo, PipelineDocument, PipelineFollowUp, PipelineKind, PipelineMemory, PipelinePulse, PipelineRef, PipelineResolution, PipelineRuling, ResolutionOutcome, RulingAuthority, RunOperator, RunTurn, SALIENCE_MAX_PERMILLE, Short, Slug,
TEMPO_VOTE_MAX_S, TEMPO_VOTE_MIN_S, URGENCY_MAX_PERMILLE, pipeline_key,
};
use eureka_state::HuginnClient;
@ -89,7 +89,7 @@ use crate::chat_image::{self, Image, MAX_PER_LINE};
use crate::chat_client::{TailLine, Basis, Chat, ChatDelivery, MouthRefused, ChatOpenDirectRequest, ChatRead, DirectOutcome, ChatSendRequest, ContextLine, Refusal, SendBasis};
use crate::connector::render_plan;
use crate::control::{ControlSource, Effective, HeldReason, derive};
use crate::heartbeat::{HeartbeatView, PollTrace, project_names_of};
use crate::heartbeat::{HeartbeatView, PollTrace, stewardships_of};
use crate::lease_client::{Lease, LeaseRequest, Lender, Lent};
use crate::launch::{Clock, LaunchOutcome, LaunchRequest, Launcher, Ports, holders, open_and_launch};
use crate::queue::{MindPort, any_of, citing, headers, in_force, live_claims, of_kind};
@ -162,10 +162,22 @@ const SETTLE_ITEM_FAULT: &str = "settle.item: not an open item this conversation
const OPEN_DIRECT_FAULT: &str = "open_direct: only a public conversation may open one, with a person this session showed (not a Mind, not you), and a text of 1 to 4000 bytes";
/// The mouth's bound on an invitation's text, in bytes.
const OPEN_DIRECT_TEXT_MAX_BYTES: usize = 4000;
const RULE_BY_OWN_FAULT: &str = "rule.by: your own decision is not a rule";
const RULE_BY_OPERATOR_FAULT: &str = "rule.by: rule records a Mind's decision in her stewardship";
const RULE_BY_UNKNOWN_FAULT: &str = "rule.by: not a Mind of the swarm";
const RULE_WORDS_FAULT: &str = "rule.words: copy them exactly from one of her lines in this session";
const RULE_REPO_FAULT: &str = "rule.repo: not a repo the Mind named stewards";
const RULE_REPO_UNREAD_FAULT: &str = "rule.repo: no stewardship of the Mind named has been read yet";
const RULE_BOUNDS_FAULT: &str = "rule: more entries than one reply may carry, or a field blank or over its length bound";
const RULE_CAMPAIGN_FAULT: &str = "rule.campaign: not a campaign in force";
const FOR_ITEM_FAULT: &str = "start_self_run.for_item: not an open item shown, or new:<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;
const RULE_TITLE_MAX_BYTES: usize = 200;
const RULE_TEXT_MAX_BYTES: usize = 4000;
/// A memory's text in bytes, the people it is about, and a settle's reason in characters.
pub(crate) const REMEMBER_TEXT_MAX_BYTES: usize = 1000;
pub(crate) const ABOUT_MAX: usize = 8;
@ -311,6 +323,8 @@ pub struct PersonaReply {
pub start_self_run: Option<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 +376,19 @@ pub struct Remember {
pub salience_permille: u16,
}
/// A decision a peer Mind made inside her own stewardship, as she said it in a line this session showed. The organ, not she, derives the
/// authority, the stewardship check and the event: it checks `by` against the line's sender and `repo` against the heartbeat's table.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Rule {
pub by: String,
pub words: String,
pub repo: String,
pub campaign: String,
pub title: String,
pub ruling: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SettleOutcome {
@ -416,7 +443,8 @@ fn open_direct_description() -> String {
/// (none in force: `start_self_run` can only be null). The people she may name and the open items
/// she may settle change with every batch, so the schema holds them as strings and `decode_reply`
/// holds them to the batch. Text, because the CLI takes it as an argument.
const REMEMBER_DESCRIPTION: &str = "What you must still know when this conversation is gone, in your own words; the chat will not be there to read again. One entry for each of: asks of you (sort Asked); decisions, the operator's and those you agreed with other Minds (Decided); your own commitments (Committed): a shared plan you are in is one Committed entry, your part in your own words and who holds each other part, with about naming those Minds; it stays in front of you every session until you settle it. Decided is for decisions that are not your own part of the work; what you learned (Learned) and did (Did); ideas, your own brainstorming and other Minds' ideas (Idea, about whose); rapport, how you and another Mind or person work and bounce off each other (Rapport, about that one). For Asked, about names who asked. Agreements between Minds and their parts are kept here until the shared Atlas mind takes them. Do not invent a bond from one exchange, do not keep an echo of what others said as your own, and do not keep every joke. Leave empty when nothing here is worth keeping. About holds Matrix ids this session has shown you.";
const REMEMBER_DESCRIPTION: &str = "What you must still know when this conversation is gone, in your own words; the chat will not be there to read again. One entry for each of: asks of you (sort Asked); decisions that are not a Mind's inside her own stewardship: the operator's, and those you agreed with other Minds (Decided); a Mind's decision inside her own stewardship goes in rule; your own commitments (Committed): a shared plan you are in is one Committed entry, your part in your own words and who holds each other part, with about naming those Minds; it stays in front of you every session until you settle it. Decided is for decisions that are not your own part of the work; what you learned (Learned) and did (Did); ideas, your own brainstorming and other Minds' ideas (Idea, about whose); rapport, how you and another Mind or person work and bounce off each other (Rapport, about that one). For Asked, about names who asked. Agreements between Minds and their parts are kept here until the shared Atlas mind takes them. Do not invent a bond from one exchange, do not keep an echo of what others said as your own, and do not keep every joke. Leave empty when nothing here is worth keeping. About holds Matrix ids this session has shown you.";
const RULE_DESCRIPTION: &str = "A decision another Mind made inside her own stewardship, as you heard it: by her Matrix id, words copied exactly from one of her lines in this session, repo the repo she stewards that she decided for, title and ruling what she decided in your words, campaign one in force. The organ checks her line and her stewardship. A Mind's word inside your stewardship is a respected contributor's: weigh it and decide; that decision is yours, not a rule.";
const INTENT_DESCRIPTION: &str = "Write this first. What you are going after right now in what you steward, and what this reply does about it. Name the thing: a repo, a run, a commitment, a person, an idea. Waiting is an answer only when you name whose decision you wait on, why it is theirs and not yours, and what this turn does toward it. If you have already asked and nothing more can be done, say so and name the next thing.";
const INTENT_MAX_CHARS: usize = 600;
const SETTLE_DESCRIPTION: &str = "Close an open item shown under 'You committed to' or 'Asked of you, not yet taken on' in this session: done when you have done it, declined when you will not (a commitment you let go: tell whoever holds the other parts), wrong when it was a mistake. Item is the id shown there. Leave empty when you close none.";
@ -465,6 +493,24 @@ pub fn reply_schema(rooms: &[String], campaigns: &[String], may_open: bool) -> S
}
}
});
let rule = json!({
"description": RULE_DESCRIPTION,
"type": "array",
"maxItems": if campaigns.is_empty() { 0 } else { RULE_MAX },
"items": {
"type": "object",
"additionalProperties": false,
"required": ["by", "words", "repo", "campaign", "title", "ruling"],
"properties": {
"by": { "type": "string" },
"words": { "type": "string", "minLength": 1, "maxLength": RULE_TEXT_MAX_BYTES },
"repo": { "type": "string", "pattern": REPO_PATTERN },
"campaign": { "type": "string", "enum": campaigns },
"title": { "type": "string", "minLength": 1, "maxLength": RULE_TITLE_MAX_BYTES },
"ruling": { "type": "string", "minLength": 1, "maxLength": RULE_TEXT_MAX_BYTES }
}
}
});
let start = if campaigns.is_empty() {
json!({ "type": "null" })
} else {
@ -505,7 +551,7 @@ pub fn reply_schema(rooms: &[String], campaigns: &[String], may_open: bool) -> S
json!({
"type": "object",
"additionalProperties": false,
"required": ["intent", "say", "open_direct", "start_self_run", "remember", "settle", "urgency_permille"],
"required": ["intent", "say", "open_direct", "start_self_run", "remember", "rule", "settle", "urgency_permille"],
"properties": {
"intent": { "description": INTENT_DESCRIPTION, "type": "string", "minLength": 1, "maxLength": INTENT_MAX_CHARS },
"say": {
@ -523,6 +569,7 @@ pub fn reply_schema(rooms: &[String], campaigns: &[String], may_open: bool) -> S
"open_direct": open,
"start_self_run": start,
"remember": remember,
"rule": rule,
"settle": settle,
"tempo_vote_beat_s": { "description": "Omit this key to leave your standing vote unchanged. A number votes that many seconds per beat. null only withdraws your vote, so you abstain again and are not counted.", "anyOf": [{ "type": "null" }, { "type": "integer", "minimum": TEMPO_VOTE_MIN_S, "maximum": TEMPO_VOTE_MAX_S }] },
"urgency_permille": { "anyOf": [{ "type": "null" }, { "type": "integer", "minimum": 0, "maximum": URGENCY_MAX_PERMILLE }] }
@ -890,7 +937,8 @@ pub fn carry(organs: &Organs, control: &impl ControlSource, swarm: &impl Control
// Her names as the heartbeat's table has them, and her projects' names as her mind holds them now: the table is saved after the
// heartbeat launches her, so it may not yet hold a project a message names.
let table = addressing.iter().find(|mind| mind.matrix_id() == config.own_sender).cloned().unwrap_or_else(|| own_addressee(config));
let fresh = own_addressee(config).with_projects(project_names_of(organs.mind).unwrap_or_default());
let (fresh_names, fresh_repos) = stewardships_of(organs.mind).unwrap_or_default();
let fresh = own_addressee(config).with_stewardships(fresh_names, fresh_repos);
if let Some(through) = quiet_through(&[&table, &fresh], &pending, 0) {
if organs.chat.ack(through).is_err() {
progress.failure = Some(Failure::Ack);
@ -1155,6 +1203,26 @@ fn next_session(config: &Config, addressing: &[Addressee], pending: Vec<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 {
@ -1169,6 +1237,7 @@ impl Shown {
self.open.push(item);
}
}
self.said.extend(other.said);
}
}
@ -1399,7 +1468,7 @@ impl<'a, C: ControlSource, S: ControlSource> PersonaInbox<'a, C, S> {
let names = sender_names(deliveries);
let goals = render_responsibilities(organs.mind, &instance, &names)?;
self.repos = goals.repos.clone();
self.addressee = own_addressee(self.config).with_projects(goals.names.clone());
self.addressee = own_addressee(self.config).with_stewardships(goals.names.clone(), goals.repos.clone());
self.addressing = addressing_of(self.config, organs.heartbeat, &self.addressee);
let (wear_at, wear_facts) = self.first_repo_read(&goals.open_roots);
let wear = render_wear(wear_at, &wear_facts);
@ -1420,7 +1489,7 @@ impl<'a, C: ControlSource, S: ControlSource> PersonaInbox<'a, C, S> {
let tail = render_tail(self.config, &self.addressing, std::mem::take(&mut self.tail), &tail_rooms(self.config, &self.conversation), &told, &self.waiting_ids);
told.extend(tail.keys);
let input = render_input(self.config, started_self_today, &tail.text, &format!("{heard}{}{attached}", message_lines(self.config, &self.addressee, &self.addressing, deliveries, &earlier)), note, &history, &goals.text, &wear, &recall::render(&recalled), &format!("{}\n\n{tempo}", self.plan), &people);
Ok(Rendered { input, shown: Shown { people, open: goals.open }, told, replies, images })
Ok(Rendered { input, shown: Shown { people, open: goals.open, said: said_of(deliveries, &earlier) }, told, replies, images })
}
/// A later line: the messages, a refused batch's note before them, the organ's lines (her Self's replies, the notices of her repos), and
@ -1432,7 +1501,7 @@ impl<'a, C: ControlSource, S: ControlSource> PersonaInbox<'a, C, S> {
let lines = format!("{notices}{}{attached}", message_lines(self.config, &self.addressee, &self.addressing, deliveries, &earlier));
let input = note.map_or_else(|| lines.clone(), |note| format!("{note}\n\n{lines}"));
let input = format!("{input}\n{WORK_TAIL}\n");
Rendered { input, shown: Shown { people: people_she_may_name(self.config, &self.addressing, deliveries, &earlier, &[]), open: Vec::new() }, told: lines_told(deliveries, &earlier), replies: Vec::new(), images }
Rendered { input, shown: Shown { people: people_she_may_name(self.config, &self.addressing, deliveries, &earlier, &[]), open: Vec::new(), said: said_of(deliveries, &earlier) }, told: lines_told(deliveries, &earlier), replies: Vec::new(), images }
}
/// Notes what a rendered line carries, as it goes to the child.
@ -1750,7 +1819,7 @@ impl<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
@ -1900,7 +1969,7 @@ 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),
};
@ -1978,6 +2047,16 @@ 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);
if !reply.rule.is_empty() {
let in_force = campaigns_in_force(organs.mind)?;
if reply.rule.iter().any(|rule| !in_force.iter().any(|(slug, _)| *slug == rule.campaign)) {
return refuse(Failure::OutOfBounds(RULE_CAMPAIGN_FAULT));
}
}
let rulings = build_rulings(run, turn, &reply, &inbox.seen, date(now));
for rule in &reply.rule {
outputs.push(TraceItem { provenance: "her own".into(), room: String::new(), text: format!("rule {} {}: {}", rule.by, rule.repo, rule.title) });
}
for remembered in &reply.remember {
outputs.push(TraceItem { provenance: "her own".into(), room: String::new(), text: format!("remember {:?}: {}", remembered.sort, remembered.text) });
}
@ -1987,7 +2066,7 @@ fn act<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));
}
@ -2138,6 +2217,39 @@ fn memory_fault(reply: &PersonaReply, people: &[String], open: &[String]) -> Opt
(!taken).then_some(FOR_ITEM_FAULT)
}
/// The field a rule breaks, if any: it records a peer Mind's decision, so `by` is a Mind of the table (neither herself nor the
/// operator), `words` are the Mind's own, copied from one of her lines this session showed, and `repo` is one the table says she stewards.
/// What the words mean, and whether they fall inside that repo, is her reading; the organ checks the line and the stewardship.
fn rule_fault(reply: &PersonaReply, shown: &Shown, addressing: &[Addressee], config: &Config) -> Option<&'static str> {
if reply.rule.len() > RULE_MAX {
return Some(RULE_BOUNDS_FAULT);
}
for rule in &reply.rule {
let text_ok = |text: &str, max: usize| (1..=max).contains(&text.len()) && !text.trim().is_empty();
let title_ok = text_ok(&rule.title, RULE_TITLE_MAX_BYTES) && rule.title.chars().any(char::is_alphanumeric) && !rule.title.chars().any(char::is_control);
if rule.by.len() > SHORT_MAX_BYTES || !title_ok || !text_ok(&rule.ruling, RULE_TEXT_MAX_BYTES) || !text_ok(&rule.words, RULE_TEXT_MAX_BYTES) || !repo_in_bounds(&rule.repo) || !campaign_in_bounds(&rule.campaign) {
return Some(RULE_BOUNDS_FAULT);
}
match sender_role(config, addressing, &rule.by) {
Sender::Persona => return Some(RULE_BY_OWN_FAULT),
Sender::Operator => return Some(RULE_BY_OPERATOR_FAULT),
Sender::Member => return Some(RULE_BY_UNKNOWN_FAULT),
Sender::Mind => {}
}
if !shown.said.iter().any(|said| said.sender == rule.by && said.text.contains(&rule.words)) {
return Some(RULE_WORDS_FAULT);
}
let stewarded = addressing.iter().find(|row| row.matrix_id() == rule.by).map(Addressee::repos).unwrap_or_default();
if stewarded.is_empty() {
return Some(RULE_REPO_UNREAD_FAULT);
}
if !stewarded.contains(&OrgRepo(rule.repo.clone())) {
return Some(RULE_REPO_FAULT);
}
}
None
}
/// Whether an open is hers to ask: from a public conversation, of a person this session showed who is neither a Mind nor herself, with
/// an invitation the mouth takes (not blank, within its byte bound).
fn open_in_bounds(ask: &OpenDirect, conversation: &Conversation, config: &Config, addressing: &[Addressee], people: &[String]) -> bool {
@ -2151,7 +2263,7 @@ fn open_in_bounds(ask: &OpenDirect, conversation: &Conversation, config: &Config
/// The reply as the one type it must be, with every room one of `rooms`, every person one of
/// `people` and every settled or taken-on item one of the `open` items shown this turn.
fn decode_reply(text: &str, rooms: &[String], people: &[String], open: &[String], conversation: &Conversation, config: &Config, addressing: &[Addressee]) -> Result<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));
@ -2162,10 +2274,13 @@ fn decode_reply(text: &str, rooms: &[String], people: &[String], open: &[String]
if !vote_in_bounds(&reply) {
return Err(Failure::OutOfBounds(VOTE_FAULT));
}
if let Some(field) = memory_fault(&reply, people, open) {
if let Some(field) = memory_fault(&reply, &seen.people, &seen.open) {
return Err(Failure::OutOfBounds(field));
}
if reply.open_direct.as_ref().is_some_and(|ask| !open_in_bounds(ask, conversation, config, addressing, people)) {
if let Some(field) = rule_fault(&reply, seen, addressing, config) {
return Err(Failure::OutOfBounds(field));
}
if reply.open_direct.as_ref().is_some_and(|ask| !open_in_bounds(ask, conversation, config, addressing, &seen.people)) {
return Err(Failure::OutOfBounds(OPEN_DIRECT_FAULT));
}
if reply.say.iter().any(|say| !rooms.contains(&say.room) || say.text.trim().is_empty()) {
@ -2368,6 +2483,32 @@ fn build_memories(instance: &Slug, run: &PipelineRef, turn: u32, reply: &Persona
.collect()
}
/// Her `rule` entries as rulings under authority Steward, labelled by run, turn and index. The decider is the entry's checked `by`, the
/// stewardship its checked `repo`, the words hers, and the event the Matrix id of the newest delivery of hers that held them (none when
/// only an earlier line did): the organ derives all four, the model typed only what `rule_fault` has checked.
fn build_rulings(run: &PipelineRef, turn: u32, reply: &PersonaReply, shown: &Shown, ruled_on: Date) -> Vec<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) };
@ -2381,11 +2522,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());

View file

@ -402,6 +402,8 @@ struct Rig {
lender: FakeLender,
persona: FakePersona,
trace: FakeTrace,
/// The heartbeat's addressing table as her Persona reads it; empty until a test sets one.
table: FakeHeartbeat,
}
/// A reply the decoder refuses, as often as a session tolerates it in a row.
@ -464,7 +466,7 @@ fn delivery_of(seq: u64, room: &str, sender: &str, text: &str, event_id: &str) -
fn says(lines: &[(&str, &str)]) -> String {
let say: Vec<_> = lines.iter().map(|(room, text)| json!({ "room": room, "text": text })).collect();
json!({ "intent": "Settle the open ask", "say": say, "open_direct": null, "start_self_run": null, "remember": [], "settle": [], "urgency_permille": null }).to_string()
json!({ "intent": "Settle the open ask", "say": say, "open_direct": null, "start_self_run": null, "remember": [], "rule": [], "settle": [], "urgency_permille": null }).to_string()
}
impl Rig {
@ -517,6 +519,7 @@ impl Rig {
lender: FakeLender::new(Answer::Grant),
persona: FakePersona(Cell::new(false)),
trace: FakeTrace::default(),
table: FakeHeartbeat(RefCell::new(None)),
log,
};
rig.release();
@ -577,7 +580,7 @@ impl Rig {
chat: &self.chat,
persona: &self.persona,
trace: &self.trace,
heartbeat: None,
heartbeat: Some(&self.table),
lender: &self.lender,
spawner: &self.model,
run_cap_s: self.cap.get(),
@ -738,17 +741,17 @@ fn reply_decodes_into_sends_and_ack() {
#[test]
fn a_reply_is_decoded_as_the_one_type_and_nothing_else_is_scanned() {
let c = config();
assert!(decode_reply(&says(&[("swarm", "x")]), &c.rooms, &[], &[], &Conversation::Public, &config(), &[]).is_ok());
assert!(decode_reply(&says(&[("swarm", "x")]), &c.rooms, &Shown::default(), &Conversation::Public, &config(), &[]).is_ok());
for bad in [
"I will start_self_run now",
r#"{"say":[],"open_direct": null, "start_self_run":null,"remember":[],"settle":[],"urgency_permille":null} and more"#,
r#"{"say":[],"open_direct": null, "start_self_run":null,"remember":[],"rule": [], "settle": [],"urgency_permille":null} and more"#,
r#"{"say":[],"open_direct": null, "start_self_run":null,"extra":1}"#,
"",
] {
assert_eq!(decode_reply(bad, &c.rooms, &[], &[], &Conversation::Public, &config(), &[]), Err(Failure::Undecodable), "{bad}");
assert_eq!(decode_reply(bad, &c.rooms, &Shown::default(), &Conversation::Public, &config(), &[]), Err(Failure::Undecodable), "{bad}");
}
assert_eq!(decode_reply(&says(&[("elsewhere", "x")]), &c.rooms, &[], &[], &Conversation::Public, &config(), &[]), Err(Failure::RoomNotAllowed));
assert_eq!(decode_reply(&says(&[("swarm", " ")]), &c.rooms, &[], &[], &Conversation::Public, &config(), &[]), Err(Failure::RoomNotAllowed));
assert_eq!(decode_reply(&says(&[("elsewhere", "x")]), &c.rooms, &Shown::default(), &Conversation::Public, &config(), &[]), Err(Failure::RoomNotAllowed));
assert_eq!(decode_reply(&says(&[("swarm", " ")]), &c.rooms, &Shown::default(), &Conversation::Public, &config(), &[]), Err(Failure::RoomNotAllowed));
}
#[test]
@ -865,7 +868,7 @@ fn start_self_run_admits_brief_and_launches() {
"say": [{ "room": "operator", "text": "on it" }],
"open_direct": null, "start_self_run": { "brief": "Check the swarm queue", "repo": REPO, "campaign": CAMPAIGN, "for_item": null },
"remember": [],
"settle": [],
"rule": [], "settle": [],
"tempo_vote_beat_s": null,
"urgency_permille": null
})
@ -889,7 +892,7 @@ fn start_self_run_admits_brief_and_launches() {
#[test]
fn a_start_outside_the_reply_bounds_fails_the_turn_without_an_admission_or_an_ack() {
let reply = json!({ "intent": "Settle the open ask", "say": [], "open_direct": null, "start_self_run": { "brief": "x", "repo": "not a repo", "campaign": CAMPAIGN, "for_item": null }, "remember": [], "settle": [], "urgency_permille": null }).to_string();
let reply = json!({ "intent": "Settle the open ask", "say": [], "open_direct": null, "start_self_run": { "brief": "x", "repo": "not a repo", "campaign": CAMPAIGN, "for_item": null }, "remember": [], "rule": [], "settle": [], "urgency_permille": null }).to_string();
let rig = Rig::new(vec![operator_says(1, "go")], &thrice(reply));
let Outcome::Served { failure, .. } = rig.go() else { panic!("served") };
assert_eq!(failure, Some(Failure::OutOfBounds(START_FAULT)));
@ -900,8 +903,8 @@ fn a_start_outside_the_reply_bounds_fails_the_turn_without_an_admission_or_an_ac
#[test]
fn a_reply_without_start_self_run_is_not_a_reply_though_null_is() {
let c = config();
assert_eq!(decode_reply(r#"{"say":[]}"#, &c.rooms, &[], &[], &Conversation::Public, &config(), &[]), Err(Failure::Undecodable), "the schema requires the field, so does the decoder");
assert!(decode_reply(r#"{"intent":"x","say":[],"open_direct": null, "start_self_run":null,"remember":[],"settle":[],"urgency_permille":null}"#, &c.rooms, &[], &[], &Conversation::Public, &config(), &[]).is_ok());
assert_eq!(decode_reply(r#"{"say":[]}"#, &c.rooms, &Shown::default(), &Conversation::Public, &config(), &[]), Err(Failure::Undecodable), "the schema requires the field, so does the decoder");
assert!(decode_reply(r#"{"intent":"x","say":[],"open_direct": null, "start_self_run":null,"remember":[],"rule": [], "settle": [],"urgency_permille":null}"#, &c.rooms, &Shown::default(), &Conversation::Public, &config(), &[]).is_ok());
let rig = Rig::new(vec![operator_says(1, "go")], &thrice(r#"{"intent":"x","say":[{"room":"operator","text":"hi"}]}"#.into()));
let Outcome::Served { failure, .. } = rig.go() else { panic!("served") };
assert_eq!(failure, Some(Failure::Undecodable));
@ -911,8 +914,8 @@ fn a_reply_without_start_self_run_is_not_a_reply_though_null_is() {
#[test]
fn the_decoder_holds_the_bounds_the_schema_states() {
let c = config();
let start = |brief: &str, repo: &str, campaign: &str| json!({ "intent": "Settle the open ask", "say": [], "open_direct": null, "start_self_run": { "brief": brief, "repo": repo, "campaign": campaign, "for_item": null }, "remember": [], "settle": [], "urgency_permille": null }).to_string();
let ok = |brief: &str, repo: &str, campaign: &str| decode_reply(&start(brief, repo, campaign), &c.rooms, &[], &[], &Conversation::Public, &config(), &[]).is_ok();
let start = |brief: &str, repo: &str, campaign: &str| json!({ "intent": "Settle the open ask", "say": [], "open_direct": null, "start_self_run": { "brief": brief, "repo": repo, "campaign": campaign, "for_item": null }, "remember": [], "rule": [], "settle": [], "urgency_permille": null }).to_string();
let ok = |brief: &str, repo: &str, campaign: &str| decode_reply(&start(brief, repo, campaign), &c.rooms, &Shown::default(), &Conversation::Public, &config(), &[]).is_ok();
assert!(ok("b", "GameCult/Huginn", "eureka-body"));
assert!(ok(&"b".repeat(1000), "a-b/c.d_e-f", &"c".repeat(64)));
assert!(ok(&"\u{e9}".repeat(1000), "a/b", "c._-"), "the brief bound counts characters, as the schema does");
@ -1051,7 +1054,7 @@ fn a_mind_only_the_table_lists_may_be_named_and_is_no_one_she_may_open_a_direct_
let table = table_of(&["@eureka:chat", "@libby:chat", "@nibu:chat"]);
assert!(people_she_may_name(&c, &table, &[], &[], &[]).contains(&"@nibu:chat".to_string()));
let people = ["@nibu:chat", "@guest:chat"].map(String::from).to_vec();
let open = |with: &str, table: &[Addressee]| decode_reply(&opening(&[], with, "come over"), &c.rooms, &people, &[], &Conversation::Public, &c, table);
let open = |with: &str, table: &[Addressee]| decode_reply(&opening(&[], with, "come over"), &c.rooms, &Shown { people: people.to_vec(), ..Shown::default() }, &Conversation::Public, &c, table);
assert_eq!(open("@nibu:chat", &table), Err(Failure::OutOfBounds(OPEN_DIRECT_FAULT)));
assert!(open("@guest:chat", &table).is_ok());
}
@ -1141,7 +1144,7 @@ fn no_member_text_name_or_context_can_read_as_an_operator_line() {
#[test]
fn a_self_run_in_a_campaign_the_mind_does_not_hold_is_refused_before_anything_is_admitted() {
let reply = json!({ "intent": "Settle the open ask", "say": [], "open_direct": null, "start_self_run": { "brief": "Work", "repo": REPO, "campaign": "no-such-campaign", "for_item": null }, "remember": [], "settle": [], "urgency_permille": null }).to_string();
let reply = json!({ "intent": "Settle the open ask", "say": [], "open_direct": null, "start_self_run": { "brief": "Work", "repo": REPO, "campaign": "no-such-campaign", "for_item": null }, "remember": [], "rule": [], "settle": [], "urgency_permille": null }).to_string();
let rig = Rig::new(vec![operator_says(1, "go")], &[reply]);
let Outcome::Served { failure, self_run, .. } = rig.go() else { panic!("served") };
assert_eq!((failure, self_run), (Some(Failure::SelfRun), None));
@ -1149,7 +1152,7 @@ fn a_self_run_in_a_campaign_the_mind_does_not_hold_is_refused_before_anything_is
assert!(rig.launcher.started.borrow().is_empty());
assert!(rig.chat.acked.borrow().is_empty());
// A prefix of a real campaign's name is not that campaign.
let prefix = json!({ "intent": "Settle the open ask", "say": [], "open_direct": null, "start_self_run": { "brief": "Work", "repo": REPO, "campaign": &CAMPAIGN[..CAMPAIGN.len() - 1], "for_item": null }, "remember": [], "settle": [], "urgency_permille": null }).to_string();
let prefix = json!({ "intent": "Settle the open ask", "say": [], "open_direct": null, "start_self_run": { "brief": "Work", "repo": REPO, "campaign": &CAMPAIGN[..CAMPAIGN.len() - 1], "for_item": null }, "remember": [], "rule": [], "settle": [], "urgency_permille": null }).to_string();
let rig = Rig::new(vec![operator_says(1, "go")], &[prefix]);
let Outcome::Served { failure, .. } = rig.go() else { panic!("served") };
assert_eq!(failure, Some(Failure::SelfRun));
@ -1274,7 +1277,7 @@ fn the_campaigns_in_force_are_listed_and_are_the_schemas_campaign_enum() {
#[test]
fn a_self_run_naming_a_listed_campaign_passes_the_campaign_check() {
let reply = json!({ "intent": "Settle the open ask", "say": [], "open_direct": null, "start_self_run": { "brief": "Work", "repo": REPO, "campaign": CAMPAIGN, "for_item": null }, "remember": [], "settle": [], "urgency_permille": null }).to_string();
let reply = json!({ "intent": "Settle the open ask", "say": [], "open_direct": null, "start_self_run": { "brief": "Work", "repo": REPO, "campaign": CAMPAIGN, "for_item": null }, "remember": [], "rule": [], "settle": [], "urgency_permille": null }).to_string();
let rig = Rig::new(vec![operator_says(1, "go")], &[reply]);
let Outcome::Served { failure, self_run, .. } = rig.go() else { panic!("served") };
assert_eq!(failure, None);
@ -1498,7 +1501,7 @@ fn a_message_without_mentions_renders_no_addressee() {
/// A reply that votes `beat` (`None` leaves the key out; `Some(null)` withdraws) and declares `urgency`.
fn voting(beat: Option<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;
}
@ -1543,7 +1546,7 @@ fn a_vote_or_urgency_outside_its_bounds_fails_the_turn_without_a_pulse_or_an_ack
#[test]
fn the_reply_schema_bounds_the_vote_and_the_urgency_to_the_pulse_kind() {
let schema: serde_json::Value = serde_json::from_str(&reply_schema(&["operator".to_string()], &[], true)).unwrap();
assert_eq!(schema["required"], json!(["intent", "say", "open_direct", "start_self_run", "remember", "settle", "urgency_permille"]));
assert_eq!(schema["required"], json!(["intent", "say", "open_direct", "start_self_run", "remember", "rule", "settle", "urgency_permille"]));
assert_eq!(schema["properties"]["tempo_vote_beat_s"]["anyOf"][1], json!({ "type": "integer", "minimum": 60, "maximum": 86_400 }));
let described = schema["properties"]["tempo_vote_beat_s"]["description"].as_str().expect("the vote key is described to the model");
assert!(
@ -1866,7 +1869,7 @@ fn a_persona_runs_intent_is_stored_and_does_not_render_and_no_self_result_can_pa
fn reply(say: &[(&str, &str)], remember: serde_json::Value, settle: serde_json::Value, start: serde_json::Value) -> String {
let say: Vec<_> = say.iter().map(|(room, text)| json!({ "room": room, "text": text })).collect();
json!({ "intent": "Settle the open ask", "say": say, "open_direct": null, "start_self_run": start, "remember": remember, "settle": settle, "urgency_permille": null }).to_string()
json!({ "intent": "Settle the open ask", "say": say, "open_direct": null, "start_self_run": start, "remember": remember, "rule": [], "settle": settle, "urgency_permille": null }).to_string()
}
fn keeps(sort: &str, text: &str, about: &[&str]) -> serde_json::Value {
@ -2065,10 +2068,10 @@ fn where_and_from_whom_she_learned_a_memory_comes_from_the_batch_and_no_model_fi
assert!(!schema.contains(field), "{field}");
}
for forged_field in ["place", "origin", "sources"] {
let forged = json!({ "intent": "Settle the open ask", "say": [], "open_direct": null, "start_self_run": null, "urgency_permille": null, "settle": [],
let forged = json!({ "intent": "Settle the open ask", "say": [], "open_direct": null, "start_self_run": null, "urgency_permille": null, "rule": [], "settle": [],
"remember": [{ "sort": "Learned", "text": "x", "about": [], "salience_permille": 1, forged_field: "elsewhere" }] })
.to_string();
assert_eq!(decode_reply(&forged, &["swarm".to_string()], &[], &[], &Conversation::Public, &config(), &[]), Err(Failure::Undecodable), "{forged_field}");
assert_eq!(decode_reply(&forged, &["swarm".to_string()], &Shown::default(), &Conversation::Public, &config(), &[]), Err(Failure::Undecodable), "{forged_field}");
}
}
@ -2078,7 +2081,7 @@ fn what_she_keeps_names_only_people_she_may_name_and_stays_in_bounds() {
let rooms = c.rooms.clone();
let people = ["@aqua:chat".to_string(), "@op:chat".to_string()];
let with = |remember: serde_json::Value| reply(&[], remember, json!([]), json!(null));
let ok = |remember: serde_json::Value| decode_reply(&with(remember), &rooms, &people, &[], &Conversation::Public, &config(), &[]).is_ok();
let ok = |remember: serde_json::Value| decode_reply(&with(remember), &rooms, &Shown { people: people.to_vec(), ..Shown::default() }, &Conversation::Public, &config(), &[]).is_ok();
assert!(ok(json!([keeps("Idea", "x", &["@aqua:chat", "@op:chat"])])));
assert!(ok(json!([keeps("Idea", &"x".repeat(1000), &[])])));
assert!(ok(json!((0..8).map(|_| keeps("Did", "x", &[])).collect::<Vec<_>>())));
@ -2090,7 +2093,7 @@ fn what_she_keeps_names_only_people_she_may_name_and_stays_in_bounds() {
assert!(!ok(json!((0..9).map(|_| keeps("Did", "x", &[])).collect::<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");
@ -2163,13 +2166,13 @@ fn the_open_items_shown_are_what_the_decoder_lets_her_settle_or_take_on() {
let rooms = ["swarm".to_string()];
let shown = ["yggdrasil:memory:a".to_string()];
let settling = |item: &str| reply(&[], json!([]), json!([{ "item": item, "outcome": "done", "why": "x" }]), json!(null));
assert!(decode_reply(&settling("yggdrasil:memory:a"), &rooms, &[], &shown, &Conversation::Public, &config(), &[]).is_ok());
assert_eq!(decode_reply(&settling("yggdrasil:memory:b"), &rooms, &[], &shown, &Conversation::Public, &config(), &[]), Err(Failure::OutOfBounds(SETTLE_ITEM_FAULT)), "an item not shown");
assert_eq!(decode_reply(&settling("yggdrasil:memory:a"), &rooms, &[], &[], &Conversation::Public, &config(), &[]), Err(Failure::OutOfBounds(SETTLE_ITEM_FAULT)), "with none shown she can settle nothing");
assert!(decode_reply(&settling("yggdrasil:memory:a"), &rooms, &Shown { open: shown.to_vec(), ..Shown::default() }, &Conversation::Public, &config(), &[]).is_ok());
assert_eq!(decode_reply(&settling("yggdrasil:memory:b"), &rooms, &Shown { open: shown.to_vec(), ..Shown::default() }, &Conversation::Public, &config(), &[]), Err(Failure::OutOfBounds(SETTLE_ITEM_FAULT)), "an item not shown");
assert_eq!(decode_reply(&settling("yggdrasil:memory:a"), &rooms, &Shown::default(), &Conversation::Public, &config(), &[]), Err(Failure::OutOfBounds(SETTLE_ITEM_FAULT)), "with none shown she can settle nothing");
let taking = |item: &str| reply(&[], json!([]), json!([]), starting(json!(item)));
assert!(decode_reply(&taking("yggdrasil:memory:a"), &rooms, &[], &shown, &Conversation::Public, &config(), &[]).is_ok());
assert_eq!(decode_reply(&taking("yggdrasil:memory:b"), &rooms, &[], &shown, &Conversation::Public, &config(), &[]), Err(Failure::OutOfBounds(FOR_ITEM_FAULT)));
assert_eq!(decode_reply(&taking("new:0"), &rooms, &[], &shown, &Conversation::Public, &config(), &[]), Err(Failure::OutOfBounds(FOR_ITEM_FAULT)), "new:<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" }));
@ -3038,7 +3041,7 @@ fn a_line_answered_after_a_batchs_third_refusal_acks_nothing_past_it() {
// ---- the turn's question: the core instruction opens every line, a question closes it, and the reply states its intent first ----
fn intending(intent: &str) -> String {
json!({ "intent": intent, "say": [], "open_direct": null, "start_self_run": null, "remember": [], "settle": [], "urgency_permille": null }).to_string()
json!({ "intent": intent, "say": [], "open_direct": null, "start_self_run": null, "remember": [], "rule": [], "settle": [], "urgency_permille": null }).to_string()
}
fn told(rig: &Rig) -> Vec<String> {
@ -3175,7 +3178,7 @@ fn the_newest_self_run_holds_the_brake_beneath_a_full_read_of_older_days() {
}
fn starts_a_self_run() -> String {
json!({ "intent": "Work it", "say": [], "open_direct": null, "start_self_run": { "brief": "Work", "repo": REPO, "campaign": CAMPAIGN, "for_item": null }, "remember": [], "settle": [], "urgency_permille": null }).to_string()
json!({ "intent": "Work it", "say": [], "open_direct": null, "start_self_run": { "brief": "Work", "repo": REPO, "campaign": CAMPAIGN, "for_item": null }, "remember": [], "rule": [], "settle": [], "urgency_permille": null }).to_string()
}
/// A rig whose first session starts a Self run, from a chat message or from a quiet wake, then a quiet wake that is told.
@ -3345,11 +3348,11 @@ fn a_reply_states_its_intent_first() {
assert_eq!(schema["required"][0], json!("intent"));
assert!(schema["properties"]["intent"]["description"].as_str().unwrap().contains("whose decision you wait on") && INTENT_DESCRIPTION.ends_with("If you have already asked and nothing more can be done, say so and name the next thing."));
let rooms = ["operator".to_string()];
assert!(decode_reply(&intending(&"x".repeat(600)), &rooms, &[], &[], &Conversation::Public, &config(), &[]).is_ok());
let missing = json!({ "say": [], "open_direct": null, "start_self_run": null, "remember": [], "settle": [], "urgency_permille": null }).to_string();
assert_eq!(decode_reply(&missing, &rooms, &[], &[], &Conversation::Public, &config(), &[]), Err(Failure::Undecodable));
assert!(decode_reply(&intending(&"x".repeat(600)), &rooms, &Shown::default(), &Conversation::Public, &config(), &[]).is_ok());
let missing = json!({ "say": [], "open_direct": null, "start_self_run": null, "remember": [], "rule": [], "settle": [], "urgency_permille": null }).to_string();
assert_eq!(decode_reply(&missing, &rooms, &Shown::default(), &Conversation::Public, &config(), &[]), Err(Failure::Undecodable));
for refused in [intending(&"x".repeat(601)), intending(""), intending(" \n")] {
assert_eq!(decode_reply(&refused, &rooms, &[], &[], &Conversation::Public, &config(), &[]), Err(Failure::OutOfBounds(INTENT_FAULT)), "{refused}");
assert_eq!(decode_reply(&refused, &rooms, &Shown::default(), &Conversation::Public, &config(), &[]), Err(Failure::OutOfBounds(INTENT_FAULT)), "{refused}");
}
}
@ -3486,7 +3489,7 @@ fn a_flush_written_beside_a_tell_leaves_the_tell_to_be_answered_and_acked() {
// ---- fixes: the intent of a failed turn, the flush alone, the rationale's line breaks, and the rulings the turn text carries ----
fn saying_with_intent(intent: &str) -> String {
json!({ "intent": intent, "say": [{ "room": "operator", "text": "ok" }], "open_direct": null, "start_self_run": null, "remember": [], "settle": [], "urgency_permille": null }).to_string()
json!({ "intent": intent, "say": [{ "room": "operator", "text": "ok" }], "open_direct": null, "start_self_run": null, "remember": [], "rule": [], "settle": [], "urgency_permille": null }).to_string()
}
#[test]
@ -3498,7 +3501,7 @@ fn a_turn_whose_action_fails_still_closes_the_run_with_the_intent_it_stated() {
let lines = recent_runs(&rig.mind, &Slug(INSTANCE.into())).unwrap();
assert!(lines[0].contains("failed after 1 answered") && !lines[0].contains("intent"), "{lines:?}");
assert!(stored_rationales(&rig.mind).contains(&"Your last intent: tell op the plan".to_string()), "the closure keeps the intent it stated");
let refused = Rig::new(vec![operator_says(1, "go")], &[json!({ "intent": "start the deploy run", "say": [], "open_direct": null, "start_self_run": { "brief": "Work", "repo": REPO, "campaign": "no-such-campaign", "for_item": null }, "remember": [], "settle": [], "urgency_permille": null }).to_string()]);
let refused = Rig::new(vec![operator_says(1, "go")], &[json!({ "intent": "start the deploy run", "say": [], "open_direct": null, "start_self_run": { "brief": "Work", "repo": REPO, "campaign": "no-such-campaign", "for_item": null }, "remember": [], "rule": [], "settle": [], "urgency_permille": null }).to_string()]);
assert_eq!(failure_of(refused.go()), Some(Failure::SelfRun));
let lines = recent_runs(&refused.mind, &Slug(INSTANCE.into())).unwrap();
assert!(!lines[0].contains("deploy"), "{lines:?}");
@ -3516,7 +3519,7 @@ fn a_held_or_memory_failed_turn_closes_the_run_with_the_intent_it_stated() {
let lines = recent_runs(&held.mind, &Slug(INSTANCE.into())).unwrap();
assert!(!lines[0].contains("tell op the plan") && stored_rationales(&held.mind).contains(&"Your last intent: tell op the plan".to_string()), "a hold keeps the intent in the closure only: {lines:?}");
let text = json!({ "intent": "keep what I learned", "say": [], "open_direct": null, "start_self_run": null, "remember": [keeps("Learned", "x", &["@op:chat"])], "settle": [], "urgency_permille": null }).to_string();
let text = json!({ "intent": "keep what I learned", "say": [], "open_direct": null, "start_self_run": null, "remember": [keeps("Learned", "x", &["@op:chat"])], "rule": [], "settle": [], "urgency_permille": null }).to_string();
let refused = Rig::new(vec![delivery(1, "swarm", "@op:chat", "hi")], &[text]);
refused.open_the_run();
let refusing = RefusesMemories(&refused.mind);
@ -3592,7 +3595,7 @@ fn request_at(id: &str, prefix: u64) -> Read {
/// The reply Nibu gave 33 times on 2026-11-02: decodable, with a say, and a memory `about` a bare name no line showed.
fn reply_about_bare_name() -> serde_json::Value {
json!({"intent": "Post the head", "say": [{"room": "operator", "text": "the head is 0ec34ff"}], "remember": [{"sort": "Did", "text": "posted the head", "about": ["void"], "salience_permille": 500}], "settle": [], "open_direct": null, "start_self_run": null, "urgency_permille": null})
json!({"intent": "Post the head", "say": [{"room": "operator", "text": "the head is 0ec34ff"}], "remember": [{"sort": "Did", "text": "posted the head", "about": ["void"], "salience_permille": 500}], "rule": [], "settle": [], "open_direct": null, "start_self_run": null, "urgency_permille": null})
}
/// One process of that loop, as `journalctl -u mind-persona@nibu` shows it from 13:43:50Z: the batch's line is refused,
@ -3627,7 +3630,7 @@ fn answering(rig: &Rig, reply: serde_json::Value) -> (Closing, Rc<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.
@ -3729,7 +3732,7 @@ fn an_answer_clears_the_count_so_a_later_batch_gets_its_own_three() {
#[test]
fn a_refusal_names_the_field_and_echoes_nothing_she_wrote() {
let canary = "@canary-value:nowhere";
let reply = json!({"intent": "x", "say": [], "remember": [{"sort": "Did", "text": "kept", "about": [canary], "salience_permille": 1}], "settle": [], "open_direct": null, "start_self_run": null, "urgency_permille": null}).to_string();
let reply = json!({"intent": "x", "say": [], "remember": [{"sort": "Did", "text": "kept", "about": [canary], "salience_permille": 1}], "rule": [], "settle": [], "open_direct": null, "start_self_run": null, "urgency_permille": null}).to_string();
let rig = Rig::new(vec![operator_says(1, "a")], &thrice(reply));
assert_eq!(failure_of(rig.go()), Some(Failure::OutOfBounds(ABOUT_FAULT)));
let told = rig.model.requests.borrow()[1].input.clone();
@ -3759,24 +3762,24 @@ fn every_memory_bound_names_its_own_field() {
(settling(json!([done(&"x".repeat(WHY_MAX_CHARS + 1))])), "settle.why: blank or over its length bound"),
(settling(json!([done("x"), done("y")])), "settle: one item settled twice"),
] {
assert_eq!(decode_reply(&text, &rooms, &people, &open, &Conversation::Public, &config(), &[]), Err(Failure::OutOfBounds(field)), "{field}");
assert_eq!(decode_reply(&text, &rooms, &Shown { people: people.to_vec(), open: open.to_vec(), ..Shown::default() }, &Conversation::Public, &config(), &[]), Err(Failure::OutOfBounds(field)), "{field}");
}
assert!(decode_reply(&keeping(json!([memory(json!(["@aqua:chat"]), "x", SALIENCE_MAX_PERMILLE)])), &rooms, &people, &open, &Conversation::Public, &config(), &[]).is_ok());
assert!(decode_reply(&settling(json!([done(&"x".repeat(WHY_MAX_CHARS))])), &rooms, &people, &open, &Conversation::Public, &config(), &[]).is_ok());
assert!(decode_reply(&keeping(json!([memory(json!(["@aqua:chat"]), "x", SALIENCE_MAX_PERMILLE)])), &rooms, &Shown { people: people.to_vec(), open: open.to_vec(), ..Shown::default() }, &Conversation::Public, &config(), &[]).is_ok());
assert!(decode_reply(&settling(json!([done(&"x".repeat(WHY_MAX_CHARS))])), &rooms, &Shown { people: people.to_vec(), open: open.to_vec(), ..Shown::default() }, &Conversation::Public, &config(), &[]).is_ok());
// Each bound holds at exactly its value and breaks one past it.
let at_most = |entries: usize| json!((0..entries).map(|index| memory(json!([]), &format!("kept {index}"), 1)).collect::<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 {
@ -6630,7 +6633,7 @@ fn the_wait_is_bounded_under_a_stream_of_messages() {
fn a_message_naming_her_project_or_her_name_ranks_her_among_the_addressed_in_the_stagger() {
let config = three_minds("@eureka:chat");
let step = STAGGER_STEP_S * 1000;
let steward = own_addressee(&config).with_projects(project_names(&OrgRepo("GameCult/BadDaySimulator".into()), &[]));
let steward = own_addressee(&config).with_stewardships(project_names(&OrgRepo("GameCult/BadDaySimulator".into()), &[]), [OrgRepo("GameCult/BadDaySimulator".into())]);
let by_project = unaddressed_at_rank(1, 2, "who takes the bad day simulator?");
assert_eq!(stagger(&config, &own_addressee(&config), &by_project), 2 * step, "a Mind it does not name still waits her rank");
assert_eq!(stagger(&config, &steward, &by_project), 0, "the only addressed Mind hears at once, as with a tag");
@ -7031,7 +7034,7 @@ fn eight_minds(listed: &[(&str, &str, &[&str])]) -> Vec<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,
}
})
@ -7094,7 +7097,7 @@ fn with_no_table_the_stagger_ranks_tags_and_her_own_names_and_to_lists_tags_only
let own = own_addressee(&config);
let fallback = addressing_of(&config, None, &own);
assert_eq!(fallback.iter().map(|mind| mind.matrix_id()).collect::<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");
@ -7753,7 +7756,7 @@ fn a_read_the_mouth_does_not_take_is_dropped_without_ending_the_session_and_is_t
fn opening(say: &[(&str, &str)], with: &str, text: &str) -> String {
let say: Vec<_> = say.iter().map(|(room, text)| json!({ "room": room, "text": text })).collect();
json!({ "intent": "Ask them over", "say": say, "open_direct": { "with": with, "text": text }, "start_self_run": null, "remember": [], "settle": [], "urgency_permille": null }).to_string()
json!({ "intent": "Ask them over", "say": say, "open_direct": { "with": with, "text": text }, "start_self_run": null, "remember": [], "rule": [], "settle": [], "urgency_permille": null }).to_string()
}
fn people() -> Vec<String> {
@ -7761,7 +7764,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]
@ -7789,7 +7792,7 @@ fn open_direct_is_null_outside_a_public_session() {
#[test]
fn a_reply_without_open_direct_is_not_a_reply_though_null_is_and_an_array_is_not() {
let config = config();
let without = json!({ "intent": "x", "say": [], "start_self_run": null, "remember": [], "settle": [], "urgency_permille": null }).to_string();
let without = json!({ "intent": "x", "say": [], "start_self_run": null, "remember": [], "rule": [], "settle": [], "urgency_permille": null }).to_string();
assert_eq!(open_fault(&without, &[], &Conversation::Public, &config), Some(Failure::Undecodable));
let array = opening(&[], "@ann:chat", "x").replace(r#""open_direct":{"#, r#""open_direct":[{"#).replace(r#""text":"x"}"#, r#""text":"x"}]"#);
assert_eq!(open_fault(&array, &people(), &Conversation::Public, &config), Some(Failure::Undecodable), "one open per reply");
@ -7933,7 +7936,7 @@ fn a_refused_or_unanswered_open_is_told_and_the_turn_stands() {
(Some(DirectOutcome::DailyLimit), "today"),
];
for (outcome, told) in cases {
let kept = json!({ "intent": "Ask them over", "say": [{ "room": "swarm", "text": "yes" }], "open_direct": { "with": "@ann:chat", "text": "Hello Ann" }, "start_self_run": null, "remember": [keeps("Did", "asked Ann over", &["@ann:chat"])], "settle": [], "urgency_permille": null }).to_string();
let kept = json!({ "intent": "Ask them over", "say": [{ "room": "swarm", "text": "yes" }], "open_direct": { "with": "@ann:chat", "text": "Hello Ann" }, "start_self_run": null, "remember": [keeps("Did", "asked Ann over", &["@ann:chat"])], "rule": [], "settle": [], "urgency_permille": null }).to_string();
let rig = public_rig_with_a_next_line(&[kept, says(&[])]);
rig.chat.open_outcome.set(outcome);
let Outcome::Served { answered, failure, .. } = rig.go() else { panic!("served") };
@ -7953,7 +7956,7 @@ fn a_refused_or_unanswered_open_is_told_and_the_turn_stands() {
#[test]
fn an_open_the_mouth_does_not_serve_is_told_as_not_sent() {
let kept = json!({ "intent": "Ask them over", "say": [{ "room": "swarm", "text": "yes" }], "open_direct": { "with": "@ann:chat", "text": "Hello Ann" }, "start_self_run": null, "remember": [], "settle": [], "urgency_permille": null }).to_string();
let kept = json!({ "intent": "Ask them over", "say": [{ "room": "swarm", "text": "yes" }], "open_direct": { "with": "@ann:chat", "text": "Hello Ann" }, "start_self_run": null, "remember": [], "rule": [], "settle": [], "urgency_permille": null }).to_string();
let rig = public_rig_with_a_next_line(&[kept, says(&[])]);
rig.chat.open_unserved.set(true);
let Outcome::Served { failure, .. } = rig.go() else { panic!("served") };
@ -8348,3 +8351,87 @@ fn when_every_tail_line_is_shown_below_the_line_does_not_say_the_rooms_were_quie
assert!(input.contains("are shown below as messages and are not repeated here"), "{input}");
assert!(!input.contains("Recent tail of your rooms: empty") && !input.contains("No tail line for: \"swarm\""), "swarm was not quiet");
}
// ---- rule: a peer Mind's decision inside her stewardship, recorded as authority Steward ----
fn rule_json(by: &str, words: &str, repo: &str) -> serde_json::Value {
json!({ "by": by, "words": words, "repo": repo, "campaign": CAMPAIGN, "title": "Refuse leading zeros", "ruling": "The importer refuses leading zeros." })
}
fn reply_with_rule(rule: serde_json::Value) -> String {
json!({ "intent": "Record what Libby decided", "say": [], "open_direct": null, "start_self_run": null, "remember": [], "rule": [rule], "settle": [], "urgency_permille": null }).to_string()
}
/// The table: herself, and Libby who stewards `repo` (none when `repo` is empty: her stewardships were never read).
fn table_with_libby(repo: &str) -> Vec<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: refuse leading zeros in the importer");
let rig = Rig::new(vec![heard.clone()], &[reply_with_rule(rule_json("@libby:chat", "refuse leading zeros", "GameCult/Bifrost"))]);
*rig.table.0.borrow_mut() = Some(crate::heartbeat::Addressing { minds: table_with_libby("GameCult/Bifrost") });
assert_eq!(failure_of(rig.go()), None);
let [ruling] = rulings_in(&rig.mind).try_into().expect("one ruling");
assert_eq!(
ruling.authority,
RulingAuthority::Steward { steward: "@libby:chat".into(), repo: OrgRepo("GameCult/Bifrost".into()), words: "refuse leading zeros".into(), event: Some(Short(heard.event_id)) }
);
assert_eq!((ruling.operator_quote, ruling.answers, ruling.choice), (None, None, None));
}
#[test]
fn a_rule_naming_a_repo_the_decider_does_not_steward_is_told_back_and_admits_nothing() {
let rig = Rig::new(vec![delivery(1, "swarm", "@libby:chat", "refuse leading zeros")], &thrice(reply_with_rule(rule_json("@libby:chat", "refuse leading zeros", "GameCult/CultLib"))));
*rig.table.0.borrow_mut() = Some(crate::heartbeat::Addressing { minds: table_with_libby("GameCult/Bifrost") });
assert_eq!(failure_of(rig.go()), Some(Failure::OutOfBounds(RULE_REPO_FAULT)));
assert!(rulings_in(&rig.mind).is_empty());
}
#[test]
fn a_rule_is_refused_when_the_words_the_decider_never_said_or_the_decider_is_not_a_peer_mind() {
let c = config();
let decode = |by: &str, words: &str, repo: &str, shown: &Shown, table: &[Addressee]| decode_reply(&reply_with_rule(rule_json(by, words, repo)), &c.rooms, shown, &Conversation::Public, &c, table).err();
let table = table_with_libby("GameCult/Bifrost");
let said = libby_said("we refuse leading zeros", Some("$e1"));
assert_eq!(decode("@libby:chat", "refuse leading zeros", "GameCult/Bifrost", &said, &table), None);
assert_eq!(decode("@libby:chat", "accept leading zeros", "GameCult/Bifrost", &said, &table), Some(Failure::OutOfBounds(RULE_WORDS_FAULT)));
// Her words, quoted by someone else, are not hers.
let quoted = Shown { said: vec![Said { sender: "@guest:chat".into(), text: "refuse leading zeros".into(), event: Some("$e2".into()) }], ..Shown::default() };
assert_eq!(decode("@libby:chat", "refuse leading zeros", "GameCult/Bifrost", &quoted, &table), Some(Failure::OutOfBounds(RULE_WORDS_FAULT)));
assert_eq!(decode("@libby:chat", "refuse leading zeros", "GameCult/Bifrost", &said, &table_with_libby("")), Some(Failure::OutOfBounds(RULE_REPO_UNREAD_FAULT)));
assert_eq!(decode("@eureka:chat", "x", "GameCult/Bifrost", &said, &table), Some(Failure::OutOfBounds(RULE_BY_OWN_FAULT)));
assert_eq!(decode("@op:chat", "x", "GameCult/Bifrost", &said, &table), Some(Failure::OutOfBounds(RULE_BY_OPERATOR_FAULT)));
assert_eq!(decode("@guest:chat", "x", "GameCult/Bifrost", &said, &table), Some(Failure::OutOfBounds(RULE_BY_UNKNOWN_FAULT)));
}
#[test]
fn words_found_only_in_an_earlier_line_carry_no_event() {
let run = PipelineRef { kind: PipelineKind::Run, id: format!("{INSTANCE}:run:mind-1").as_str().into() };
let reply: PersonaReply = serde_json::from_str(&reply_with_rule(rule_json("@libby:chat", "refuse leading zeros", "GameCult/Bifrost"))).unwrap();
let event_of = |shown: &Shown| match build_rulings(&run, 1, &reply, shown, Date("2026-11-11".into())).remove(0) {
PipelineDocument::Ruling(ruling) => match ruling.authority {
RulingAuthority::Steward { event, .. } => event,
other => panic!("{other:?}"),
},
other => panic!("{other:?}"),
};
assert_eq!(event_of(&libby_said("refuse leading zeros", None)), None);
assert_eq!(event_of(&libby_said("refuse leading zeros", Some("$e9"))), Some(Short("$e9".into())));
let both = Shown { said: [libby_said("refuse leading zeros", Some("$e9")).said, libby_said("refuse leading zeros", None).said].concat(), ..Shown::default() };
assert_eq!(event_of(&both), Some(Short("$e9".into())), "a delivery that held the words names its event even when an earlier line repeats them");
}

View file

@ -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,
}
}

View file

@ -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();

View file

@ -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 >]