diff --git a/crates/ghostlight/src/controllers.rs b/crates/ghostlight/src/controllers.rs index 908988b..6b47bd6 100644 --- a/crates/ghostlight/src/controllers.rs +++ b/crates/ghostlight/src/controllers.rs @@ -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, + /// Handles whose own tool call failed to decode and which never finished + /// otherwise: nothing is submitted for them. + undecodable: BTreeSet, needs: Vec, } @@ -5861,6 +5904,9 @@ enum GroupedLoopEvaluation { pub(super) struct GroupedFold { proposals: BTreeMap, terminal: BTreeSet, + /// Handles with a call that failed to decode, whether or not a later call + /// finished them. + decode_failed: BTreeSet, needs: Vec, } @@ -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, }, }); diff --git a/crates/ghostlight/src/verse/run.rs b/crates/ghostlight/src/verse/run.rs index 60c3c18..9a46902 100644 --- a/crates/ghostlight/src/verse/run.rs +++ b/crates/ghostlight/src/verse/run.rs @@ -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, + cell: &Cell, + frozen: &FrozenCell, + gone: &[SubjectId], + position: u32, + committed: &mut Vec, + refusals: &mut Vec, +) -> Option { + 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, refusals: &mut Vec, @@ -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; } }