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:
parent
ef7299b673
commit
c3f0e3789c
2 changed files with 145 additions and 35 deletions
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue