Verse run: the frozen cover owns a tick's membership, and an undecodable grouped member is refused

absorb now folds a cell's whole outcome (result or error) and reconciles a grouped cell against its frozen members: a persisted row's members outside the frozen cell are never listed, a frozen member the row never mentions is refused (ScopeMoved), and a member refreshed away is only its OpportunityGone refusal. A grouped member whose own tool call failed to decode and which never finished is refused (ProposalUndecodable) and no Decline is submitted for it.

Test-only overtaken_narrative_run builds the Interrupted outcome for a driver test. Tests follow in the next commit; the test target does not compile against this commit alone only if tests are built, the library builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-11 16:25:46 +02:00
commit c3f0e3789c
2 changed files with 145 additions and 35 deletions

View file

@ -1456,6 +1456,34 @@ fn record_persona_turn(
))
}
/// A narrative turn the world overtook, for a driver test that has no
/// narrative subject to run one: the same value `interrupted` builds, bound to
/// the opportunity it is given.
#[cfg(test)]
pub(crate) fn overtaken_narrative_run(opportunity: &DecisionOpportunity) -> NarrativeRun {
let turn = record_persona_turn(
opportunity,
"projector".into(),
"persona".into(),
"Prose.".into(),
)
.expect("a fixture opportunity encodes");
NarrativeRun::Interrupted(NarrativeInterruption {
subject: opportunity.scope.subject_id,
bound_scope_digest: opportunity.scope_digest.as_str().to_owned(),
fresh_scope_digest: None,
gap: TranslationGapSummary {
kind: TranslationGapKind::Unresolved,
source: SourceRange {
start_byte: 0,
end_byte: 6,
},
detail: OVERTAKEN_DETAIL.into(),
},
turn,
})
}
/// One opportunity's selection against one snapshot.
///
/// The expected controller mode is read from the opportunity rather than
@ -2883,6 +2911,10 @@ pub enum ConstituentRefusalReason {
ScopeChanged,
/// The kernel refused its command for any other reason.
WorldRefused,
/// The member's own tool call could not be decoded and it proposed nothing
/// else, so no turn was submitted for it: a decode failure is not a
/// decision to decline.
ProposalUndecodable,
}
/// One coarse cell's outcome: one inference, N ordinary one-opportunity
@ -3737,6 +3769,13 @@ impl ControllerRunner {
(exercises, if exercises { place } else { 0 })
});
for (handle, constituent) in ordered {
if capture.undecodable.contains(&handle) {
refused.push(ConstituentRefusal {
subject: constituent.subject,
reason: ConstituentRefusalReason::ProposalUndecodable,
});
continue;
}
let command = match capture.proposals.get(&handle) {
Some(invocation) => ControllerWorldCommand::Exercise(invocation.clone()),
None => ControllerWorldCommand::Decline,
@ -5846,8 +5885,12 @@ fn split_handle(name: &str, constituents: usize) -> Option<(usize, &str)> {
/// The batched decode. Vector-valued by construction: the singleton evaluator
/// keeps its scalar proposal and is not touched.
struct GroupedCapture {
/// Handle to proposal. A handle absent here proposed nothing and declines.
/// Handle to proposal. A handle absent here proposed nothing and declines,
/// unless it is in `undecodable`.
proposals: BTreeMap<usize, DecisionInvocation>,
/// Handles whose own tool call failed to decode and which never finished
/// otherwise: nothing is submitted for them.
undecodable: BTreeSet<usize>,
needs: Vec<ControllerNeed>,
}
@ -5861,6 +5904,9 @@ enum GroupedLoopEvaluation {
pub(super) struct GroupedFold {
proposals: BTreeMap<usize, DecisionInvocation>,
terminal: BTreeSet<usize>,
/// Handles with a call that failed to decode, whether or not a later call
/// finished them.
decode_failed: BTreeSet<usize>,
needs: Vec<ControllerNeed>,
}
@ -5869,6 +5915,7 @@ impl GroupedFold {
Self {
proposals: BTreeMap::new(),
terminal: BTreeSet::new(),
decode_failed: BTreeSet::new(),
needs: Vec::new(),
}
}
@ -5917,6 +5964,7 @@ pub(super) fn grouped_tool_result(
"invocation captured".into()
}
Err(detail) => {
fold.decode_failed.insert(handle);
fold.needs.push(tool_decode_need(name, arguments, &detail));
"arguments recorded as a need".into()
}
@ -5931,6 +5979,7 @@ pub(super) fn grouped_tool_result(
"need recorded".into()
}
Err(error) => {
fold.decode_failed.insert(handle);
fold.needs
.push(tool_decode_need(name, arguments, &error.to_string()));
"arguments recorded as a need".into()
@ -5951,6 +6000,7 @@ pub(super) fn grouped_tool_result(
"decision finished without a proposal".into()
}
Err(error) => {
fold.decode_failed.insert(handle);
fold.needs
.push(tool_decode_need(name, arguments, &error.to_string()));
"arguments recorded as a need".into()
@ -5958,6 +6008,7 @@ pub(super) fn grouped_tool_result(
}
}
_ => {
fold.decode_failed.insert(handle);
fold.needs.push(tool_decode_need(
name,
arguments,
@ -6075,6 +6126,11 @@ fn evaluate_grouped_loop(
return Ok(GroupedLoopEvaluation::Complete {
capture: GroupedCapture {
proposals: fold.proposals,
undecodable: fold
.decode_failed
.difference(&fold.terminal)
.copied()
.collect(),
needs: fold.needs,
},
});

View file

@ -104,6 +104,9 @@ pub enum RefusalReason {
/// this way: the controller answers it with its receipt whatever the scope
/// has done since.
ScopeMoved,
/// A grouped member's own tool call could not be decoded and it proposed
/// nothing else. No turn was submitted for it.
ProposalUndecodable,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
@ -290,6 +293,7 @@ impl std::fmt::Display for TickReport {
RefusalReason::WorldRejected => "world_rejected",
RefusalReason::OpportunityGone => "opportunity_gone",
RefusalReason::ScopeMoved => "scope_moved",
RefusalReason::ProposalUndecodable => "proposal_undecodable",
};
let label = self.labels.get(&refusal.subject).map_or("-", String::as_str);
writeln!(f, " refused: cell {} {label} {reason}", refusal.cell)?;
@ -650,12 +654,38 @@ fn command_of(submission: &SubmissionDisposition) -> CommandId {
}
}
/// Folds one finished cell into the tick. Returns the code of the pending state
/// that ends the run, when the cell did not finish. A pending cell's unreached
/// members are not refusals: the tick stays open and they resume.
/// Folds one cell's outcome into the tick. Returns the status that ends the
/// run, when the cell did not finish. A pending cell's unreached members are
/// not refusals: the tick stays open and they resume.
fn absorb(
result: Result<CellRun, ControllerError>,
cell: &Cell,
frozen: &FrozenCell,
gone: &[SubjectId],
position: u32,
committed: &mut Vec<CommandId>,
refusals: &mut Vec<Refusal>,
) -> Option<RunStatus> {
match result {
Ok(run) => absorb_run(run, cell, frozen, gone, position, committed, refusals).map(failed),
Err(error) => match classify(&error) {
CellEnd::Budget => Some(RunStatus::BudgetExhausted),
CellEnd::Fatal(code) => Some(failed(code)),
CellEnd::Refused(reason) => {
refusals.extend(refuse_all(cell, position, reason));
None
}
},
}
}
/// A finished cell's commits and refusals; the code of the pending state when
/// it did not finish.
fn absorb_run(
run: CellRun,
cell: &Cell,
frozen: &FrozenCell,
gone: &[SubjectId],
position: u32,
committed: &mut Vec<CommandId>,
refusals: &mut Vec<Refusal>,
@ -678,16 +708,48 @@ fn absorb(
Some(pending_code(pending.reason()))
}
CellRun::Grouped(run) => {
committed.extend(run.submissions.iter().map(|entry| command_of(&entry.submission)));
refusals.extend(run.refused.iter().map(|refused| Refusal {
cell: position,
subject: refused.subject,
reason: match refused.reason {
ConstituentRefusalReason::LeftBeforeSelection => RefusalReason::OpportunityGone,
ConstituentRefusalReason::ScopeChanged => RefusalReason::ScopeMoved,
ConstituentRefusalReason::WorldRefused => RefusalReason::WorldRejected,
},
}));
// The frozen cover owns who is in the cell. A resumed cell's
// persisted row may name other members: those are not listed, and a
// frozen member the row never mentions is refused, so a closed
// tick accounts for every frozen member once.
let listed = |subject: SubjectId| {
frozen.members.contains(&subject) && !gone.contains(&subject)
};
let mut accounted = Vec::new();
for entry in run.submissions.iter().filter(|entry| listed(entry.subject)) {
committed.push(command_of(&entry.submission));
accounted.push(entry.subject);
}
for refused in run.refused.iter().filter(|refused| listed(refused.subject)) {
accounted.push(refused.subject);
refusals.push(Refusal {
cell: position,
subject: refused.subject,
reason: match refused.reason {
ConstituentRefusalReason::LeftBeforeSelection => {
RefusalReason::OpportunityGone
}
ConstituentRefusalReason::ScopeChanged => RefusalReason::ScopeMoved,
ConstituentRefusalReason::WorldRefused => RefusalReason::WorldRejected,
ConstituentRefusalReason::ProposalUndecodable => {
RefusalReason::ProposalUndecodable
}
},
});
}
if run.pending.is_none() {
refusals.extend(
frozen
.members
.iter()
.filter(|member| listed(**member) && !accounted.contains(member))
.map(|member| Refusal {
cell: position,
subject: *member,
reason: RefusalReason::ScopeMoved,
}),
);
}
run.pending.map(pending_code)
}
}
@ -933,7 +995,7 @@ where
}
};
let (cell, gone) = refreshed(frozen, index, &fresh);
refusals.extend(gone.into_iter().map(|subject| Refusal {
refusals.extend(gone.iter().map(|&subject| Refusal {
cell: position,
subject,
reason: RefusalReason::OpportunityGone,
@ -941,26 +1003,18 @@ where
let Some(cell) = cell else {
continue;
};
match runner.run_cell(&cell).await {
Ok(run) => {
if let Some(code) = absorb(run, &cell, position, &mut committed, &mut refusals) {
open = Some(failed(code));
break;
}
}
Err(error) => match classify(&error) {
CellEnd::Budget => {
open = Some(RunStatus::BudgetExhausted);
break;
}
CellEnd::Fatal(code) => {
open = Some(failed(code));
break;
}
CellEnd::Refused(reason) => {
refusals.extend(refuse_all(&cell, position, reason));
}
},
let result = runner.run_cell(&cell).await;
if let Some(status) = absorb(
result,
&cell,
frozen,
&gone,
position,
&mut committed,
&mut refusals,
) {
open = Some(status);
break;
}
}