persona: the mind says whether a heard reply took; tests kill the keyless read and the lost resolution

hear_replies attempts each resolution and untell asks the mind which replies are still in force, so an Ok/Err reading of the
admission is no second opinion. The out-of-force test holds replies past the first line so a read by kind alone would retell the
resolved one.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-11-06 23:15:25 +01:00
commit 987c6ec74d
2 changed files with 21 additions and 14 deletions

View file

@ -1215,7 +1215,7 @@ impl<'a, C: ControlSource, S: ControlSource> PersonaInbox<'a, C, S> {
}
/// Replies that were told and not answered are told again: the line failed, was stale or was refused. A reply no longer in force
/// (resolved by someone, or by a resolution whose answer was lost) leaves for good: it stays in `told_replies`, so no read returns it.
/// (resolved by her answer, by someone else, or by a resolution whose answer was lost) leaves for good: it stays in `told_replies`, so no read returns it.
fn untell(&mut self, replies: Vec<HeardReply>) {
for reply in replies {
if matches!(reply_in_force(self.organs.mind, &reply.id), Ok(false)) {
@ -1425,8 +1425,8 @@ impl<C: ControlSource, S: ControlSource> SessionInbox for PersonaInbox<'_, C, S>
match step {
Step::Answered(started, intent, opened) => {
// The result answers the replies its lines carried: they are heard now, and only now.
let unresolved = hear_replies(self.organs, self.run, heard, started.as_ref().map(|(_, follow_up)| follow_up));
self.untell(unresolved);
hear_replies(self.organs, self.run, &heard, started.as_ref().map(|(_, follow_up)| follow_up));
self.untell(heard);
// 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 });
// The one ack rule: through the highest answered seq, never past a delivery still told
@ -1838,10 +1838,13 @@ fn unheard_replies(mind: &dyn MindPort, told: &BTreeSet<String>, waiting: &[Hear
}
/// The result that answers a line resolves each reply the line carried, one by one: Answered by the follow-up her answer's
/// `start_self_run` admitted, else Recorded as heard. A reply resolved is out of force, so no later process tells it. A reply whose
/// resolution fails does not hold the others hostage: it is returned, to be told again if it is still in force.
fn hear_replies(organs: &Organs, run: &PipelineRef, heard: Vec<HeardReply>, follow_up: Option<&PipelineRef>) -> Vec<HeardReply> {
heard.into_iter().filter(|reply| resolve_reply(organs, run, reply, follow_up).is_err()).collect()
/// `start_self_run` admitted, else Recorded as heard. A resolved reply is out of force, so no later process tells it. Whether it took is
/// read from the mind (`reply_in_force`, in `untell`), not from this answer: a reply whose resolution failed stays in force and is told
/// again, one that someone else resolved, or whose answer was lost, is not; and one failure holds no other reply hostage.
fn hear_replies(organs: &Organs, run: &PipelineRef, heard: &[HeardReply], follow_up: Option<&PipelineRef>) {
for reply in heard {
let _ = resolve_reply(organs, run, reply, follow_up);
}
}
fn resolve_reply(organs: &Organs, run: &PipelineRef, reply: &HeardReply, follow_up: Option<&PipelineRef>) -> Result<()> {
@ -1857,10 +1860,8 @@ fn resolve_reply(organs: &Organs, run: &PipelineRef, reply: &HeardReply, follow_
rationale: "Her Persona heard this reply of her Self in a turn and answered it.".into(),
resolved_on: date(organs.clock.now()),
});
match organs.mind.admit(AGENT, label_of(run), vec![resolution])? {
PipelineAdmissionOutcome::Committed { .. } | PipelineAdmissionOutcome::AlreadyAdmitted { .. } => Ok(()),
_ => bail!("the reply could not be resolved"),
}
organs.mind.admit(AGENT, label_of(run), vec![resolution])?;
Ok(())
}
/// Whether the reply is still in force in her mind.

View file

@ -4483,7 +4483,9 @@ fn a_refused_result_leaves_its_replies_in_force_and_they_are_told_again() {
fn a_reply_put_out_of_force_after_it_was_told_leaves_for_good_and_holds_no_other_reply_hostage() {
let rig = Rig::new(vec![operator_says(1, "hello")], &[says(&[]), says(&[]), says(&[]), says(&[])]);
rig.mind.committed(her_self_asked());
rig.mind.committed(vec![self_reply("self-r-r1", "One."), self_reply("self-r-r2", "Two.")]);
// Five long replies after the first: the first line cannot hold them all, so some wait for a later line while the first is untold.
let long = "x".repeat(3900);
rig.mind.committed(vec![self_reply("self-r-r1", "One."), self_reply("self-r-r2", &long), self_reply("self-r-r3", &long), self_reply("self-r-r4", &long), self_reply("self-r-r5", &long), self_reply("self-r-r6", &long)]);
rig.clock.step.set(1);
rig.model.linger.set(true);
rig.open_the_run();
@ -4512,10 +4514,14 @@ fn a_reply_put_out_of_force_after_it_was_told_leaves_for_good_and_holds_no_other
assert_eq!(failure_of(rig.start_over(&timeline, &config())), None);
let lines = told(&rig);
let told_of = |label: &str| all_replies(&lines).iter().filter(|line| line.contains(&reply_id(label))).count();
assert_eq!((told_of("self-r-r1"), told_of("self-r-r2")), (1, 1), "each told once: {lines:?}");
for n in 1..=6 {
assert_eq!(told_of(&format!("self-r-r{n}")), 1, "r{n} told once: {}", lines.len());
}
assert!(lines.len() >= 3, "later lines followed: {lines:?}");
assert_eq!(outcome_of(&rig, &reply_id("self-r-r1")), Some(ResolutionOutcome::Recorded { reason: "elsewhere".into() }));
assert_eq!(outcome_of(&rig, &reply_id("self-r-r2")), heard_outcome(), "the reply beside it was heard");
for n in 2..=6 {
assert_eq!(outcome_of(&rig, &reply_id(&format!("self-r-r{n}"))), heard_outcome(), "r{n}, beside it or on a later line, was heard");
}
}
/// A reply that reaches a session with no chat pending is read at the next interval, told on the line it makes, and resolved.