Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions docs/plans/required-floor-in-flight-shared-fill-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Required-floor in-flight shared-fill refusal design

Status: implementation design, following
`required-floor-in-flight-shared-fill-findings.md`.

## Decision

Keep the required floor's existing 500ms attempt-safety ceiling. When its CPU poll fires during
an admitted cross-claim fill, classify the interruption as `FillBudgetExceeded` only when the
claim's marginal CPU without that still-active fill remains within the ceiling. The refusal
carries the claim entry, prospective producer, fill CPU lower bound, marginal CPU lower bound,
and unchanged limit.

This is not pre-publication fill credit. The clock still charges all uncommitted work and stops at
the same point. The change repairs the subject of the refusal: first-touch order no longer appears
as intrinsic claim cost when the active prospective fill is what crossed the bound.

## Boundary and nesting

`CrossClaimFillGuard` remains the one lifetime bracket. Its stack frame additionally retains the
producer and CPU start. At a poll, the outermost active frame identifies the prospective artifact;
its CPU is inclusive elapsed CPU less already-stored descendants. Those descendants remain netted
by the existing committed-fill accumulator, exactly as before.

If marginal CPU alone already exceeds the ceiling, the ordinary evaluation-budget refusal wins.
If no admitted fill is active, behavior is unchanged. Store, abandon, fill receipts, completion
accounting, wall accounting, memo admission, and memo serving are unchanged.

## Safety and evidence

The refusal does not make an over-budget fill pass, raise a ceiling, retry a row, or create an
artifact receipt. Its stable cause token makes the population countable. A real evaluator-path
test must force the poll while an admitted fill is active and assert the producer is carried; a
matched unadmitted control must retain the ordinary evaluation-budget refusal. This instrument
can provide evidence for a separately modeled fill envelope later, but creates no such policy.
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ Under the model implemented here, an in-progress first touch is not yet a shared
attributable to the triggering row until its terminal disposition is known. This is not merely an
accounting convenience: publication can refuse because arguments are not hashable or portable,
the value is not portable, the producer is not admitted, or a capacity or byte bound is reached.
`AlreadyPresent` is servable and is not one of those refusal arms.
An abandoned computation never becomes available to another claim.

Successful store retroactively classifies the admitted computation as shared fill; abandonment
Expand Down
6 changes: 6 additions & 0 deletions src/v1/stage0/src/cli_run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8861,6 +8861,10 @@ pub enum WitnessRuntimeCause {
HostToolRelativePathAmbiguous,
ShellOutputLimitExceeded,
CallContractMismatch,
/// An admitted cross-claim producer was the active subject when the unchanged CPU safety
/// ceiling fired. The token makes the prospective-fill population countable without
/// treating first-touch order as intrinsic claim cost.
FillBudgetExceeded,
Comment on lines +8864 to +8867

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add the new cause to the modeled mirror

This enum is documented as a one-for-one mirror of gunbc.observation_ci_render.CiWitnessRuntimeCause, but the authoritative enum in dag/gunbc/observation_ci_render.dag:831-851 and its token function have no fill-budget arm. Rust can therefore emit cause=fill-budget-exceeded while the modeled renderer and consumers cannot construct or recognize the corresponding cause, leaving the mirror/parity contract incomplete; add the modeled variant and token alongside this Rust arm.

Useful? React with 👍 / 👎.

/// An `InterpError` with its own `ClaimOutcome` arm reached the untyped classifier anyway.
/// Loud rather than absorbed: this is a defect in the mapping above, and a run that produces
/// it should say so on the row rather than presenting the throw as an ordinary one.
Expand Down Expand Up @@ -8893,6 +8897,7 @@ impl WitnessRuntimeCause {
}
WitnessRuntimeCause::ShellOutputLimitExceeded => "shell-output-limit-exceeded",
WitnessRuntimeCause::CallContractMismatch => "call-contract-mismatch",
WitnessRuntimeCause::FillBudgetExceeded => "fill-budget-exceeded",
WitnessRuntimeCause::MappedOutcomeEscaped => "mapped-outcome-escaped",
}
}
Expand Down Expand Up @@ -8926,6 +8931,7 @@ impl WitnessRuntimeCause {
}
E::ShellOutputLimitExceeded { .. } => WitnessRuntimeCause::ShellOutputLimitExceeded,
E::CallContractMismatch { .. } => WitnessRuntimeCause::CallContractMismatch,
E::FillBudgetExceeded { .. } => WitnessRuntimeCause::FillBudgetExceeded,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep fill crossings on the budget-interruption path

When an admitted fill crosses the CPU ceiling, map_budget_error_to_witness_refusal leaves FillBudgetExceeded unchanged, so run_claim reaches this mapping and produces ClaimOutcome::RuntimeError rather than BudgetInterrupted. Consequently, required_floor_runner.rs routes an unenrolled crossing into failures and an expected-red crossing through RuntimeErrored; if that identity is already enrolled in floor_non_verdict, the latter can even satisfy the non-verdict roster instead of populating the always-blocking interrupted_before_verdict collection. Preserve a distinct fill cause while retaining the interruption/budget fields and blocking semantics.

Useful? React with 👍 / 👎.

// The five that should never arrive. See the type comment.
E::HostToolUnresolved { .. }
| E::HermeticHostEffectRefused { .. }
Expand Down
165 changes: 153 additions & 12 deletions src/v1/stage0/src/v1_interpreter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1108,6 +1108,17 @@ pub enum InterpError {
elapsed_nanos: u128,
limit_ms: u64,
},
/// The CPU safety ceiling fired while an admitted cross-claim fill was in flight. The
/// computation has not published, so it is not a shared artifact; naming the producer keeps
/// the refusal on the prospective fill instead of silently presenting first-touch order as
/// intrinsic claim cost.
FillBudgetExceeded {
entry: String,
producer: String,
fill_cpu_nanos: u128,
marginal_cpu_nanos: u128,
limit_ms: u64,
},
/// The fast-lane per-witness eval budget, enforced on THREAD CPU by the cooperative
/// stride-poll in `eval_expr`. The measured field is named for its clock: this and the
/// wall-clock budget below are different quantities of one occurrence, and a shared
Expand Down Expand Up @@ -1226,6 +1237,18 @@ impl fmt::Display for InterpError {
limit_ms
)
}
InterpError::FillBudgetExceeded {
entry,
producer,
fill_cpu_nanos,
marginal_cpu_nanos,
limit_ms,
} => write!(
f,
"in-flight shared fill exceeded CPU safety budget: entry={} producer={} \
fill_cpu_ns={} marginal_cpu_ns={} limit_ms={}",
entry, producer, fill_cpu_nanos, marginal_cpu_nanos, limit_ms
),
InterpError::EvalBudgetExceeded {
cpu_ms: elapsed_ms,
budget_ms,
Expand Down Expand Up @@ -1838,7 +1861,14 @@ thread_local! {
/// of STORED fills completed inside it. Enrolled producers compose (a full target model
/// reads its staging core), and netting outer and inner inclusive CPU from the paying claim
/// would net the inner twice — the shared-fill ledger's child stack, at this tier's grain.
static CROSS_CLAIM_FILL_CHILDREN: RefCell<Vec<(u128, u128)>> = const { RefCell::new(Vec::new()) };
static CROSS_CLAIM_FILL_FRAMES: RefCell<Vec<CrossClaimFillFrame>> = const { RefCell::new(Vec::new()) };
}

struct CrossClaimFillFrame {
producer: String,
cpu_started: u128,
stored_children_cpu: u128,
stored_children_wall: u128,
}

/// RAII scope for one admitted call that missed the cross-claim memo. Every path out of the
Expand All @@ -1853,15 +1883,23 @@ struct CrossClaimFillGuard {

impl CrossClaimFillGuard {
fn enter(func_name: &str) -> CrossClaimFillGuard {
CROSS_CLAIM_FILL_CHILDREN.with(|s| s.borrow_mut().push((0, 0)));
let cpu_started = thread_cpu_nanos();
CROSS_CLAIM_FILL_FRAMES.with(|s| {
s.borrow_mut().push(CrossClaimFillFrame {
producer: func_name.to_string(),
cpu_started,
stored_children_cpu: 0,
stored_children_wall: 0,
})
});
CROSS_CLAIM_SHARE_OBSERVER.with(|o| {
if let Some(obs) = o.borrow().as_ref() {
(obs.on_fill_begin)();
}
});
CrossClaimFillGuard {
func_name: func_name.to_string(),
cpu_started: thread_cpu_nanos(),
cpu_started,
wall_started: Instant::now(),
stored: std::cell::Cell::new(false),
}
Expand All @@ -1876,17 +1914,26 @@ impl Drop for CrossClaimFillGuard {
fn drop(&mut self) {
let inclusive_cpu = thread_cpu_nanos().saturating_sub(self.cpu_started);
let inclusive_wall = self.wall_started.elapsed().as_nanos();
let (children_cpu, children_wall) = CROSS_CLAIM_FILL_CHILDREN
let frame = CROSS_CLAIM_FILL_FRAMES
.with(|s| s.borrow_mut().pop())
.unwrap_or((0, 0));
.unwrap_or(CrossClaimFillFrame {
producer: self.func_name.clone(),
cpu_started: self.cpu_started,
stored_children_cpu: 0,
stored_children_wall: 0,
});
let children_cpu = frame.stored_children_cpu;
let children_wall = frame.stored_children_wall;
if self.stored.get() {
// Net SELF time only: stored descendants already netted their own inclusive time.
record_shared_artifact_fill_cpu_nanos(inclusive_cpu.saturating_sub(children_cpu));
let self_wall = inclusive_wall.saturating_sub(children_wall);
CROSS_CLAIM_FILL_CHILDREN.with(|s| {
CROSS_CLAIM_FILL_FRAMES.with(|s| {
if let Some(parent) = s.borrow_mut().last_mut() {
parent.0 = parent.0.saturating_add(inclusive_cpu);
parent.1 = parent.1.saturating_add(inclusive_wall);
parent.stored_children_cpu =
parent.stored_children_cpu.saturating_add(inclusive_cpu);
parent.stored_children_wall =
parent.stored_children_wall.saturating_add(inclusive_wall);
}
});
CROSS_CLAIM_SHARE_OBSERVER.with(|o| {
Expand All @@ -1897,10 +1944,12 @@ impl Drop for CrossClaimFillGuard {
} else {
// Not stored: this frame's own cost stays the caller's, but its STORED
// descendants were netted, so their total rolls up for the parent to subtract.
CROSS_CLAIM_FILL_CHILDREN.with(|s| {
CROSS_CLAIM_FILL_FRAMES.with(|s| {
if let Some(parent) = s.borrow_mut().last_mut() {
parent.0 = parent.0.saturating_add(children_cpu);
parent.1 = parent.1.saturating_add(children_wall);
parent.stored_children_cpu =
parent.stored_children_cpu.saturating_add(children_cpu);
parent.stored_children_wall =
parent.stored_children_wall.saturating_add(children_wall);
}
});
CROSS_CLAIM_SHARE_OBSERVER.with(|o| {
Expand Down Expand Up @@ -3037,6 +3086,68 @@ mod cross_claim_memo_tests {
assert_eq!(items, vec![Value::Int(7)]);
});
}

/// The real-path discriminating pair for the in-flight boundary. Both arms evaluate the
/// same compiled producer through `eval_pure_named_call` and cross the same 5ms CPU
/// ceiling. Resolved-identity admission is the only changed fact: without it the ordinary
/// entry budget fires; with it the refusal names the prospective shared-fill producer.
#[test]
fn admitted_in_flight_fill_crossing_is_typed_and_unadmitted_control_is_ordinary() {
use super::InterpError;
use crate::v1_compiler_compile::SourceFile;

super::clear_cross_claim_pure_memos();
let items = std::iter::repeat("0")
.take(200_000)
.collect::<Vec<_>>()
.join(",");
let result =
crate::v1_compiler_compile::compile_to_resolved(Rc::new(im::vector![Rc::new(
SourceFile {
path: "workspace/src/fill_budget_fixture.dag".to_string(),
content: format!(
"module fixture.fill_budget\nfn producer() -> List<Int> {{ [{items}] }}\n\
fn use_producer() -> List<Int> {{ producer() }}\n"
),
}
),]));
let graph = result.graph.as_ref().expect("fixture graph");
let fresh = || {
InterpContext::new(
graph,
result.source_indices.clone(),
ExecutionMode::Hermetic,
)
};

let control = fresh();
control.arm_eval_deadline(5);
match super::run_in_context(&control, "fixture.fill_budget.use_producer", false) {
Err(InterpError::EvaluationBudgetExceeded { .. }) => {}
other => panic!("unadmitted control must keep the ordinary refusal: {other:?}"),
}

let admitted = fresh();
let producer = admitted
.lookup_fn_node("fixture.fill_budget.producer")
.expect("fixture producer");
super::install_cross_claim_pure_share_roster([producer]);
admitted.arm_eval_deadline(5);
match super::run_in_context(&admitted, "fixture.fill_budget.use_producer", false) {
Err(InterpError::FillBudgetExceeded {
producer,
fill_cpu_nanos,
limit_ms,
..
}) => {
assert_eq!(producer, "producer");
assert!(fill_cpu_nanos >= 5_000_000, "fill CPU must cross 5ms");
assert_eq!(limit_ms, 5);
}
other => panic!("admitted specimen must name its in-flight fill: {other:?}"),
}
super::clear_cross_claim_pure_memos();
}
}

#[derive(Default)]
Expand Down Expand Up @@ -4940,6 +5051,19 @@ pub fn shared_artifact_fill_cpu_nanos() -> u128 {
SHARED_ARTIFACT_FILL_CPU_NANOS.with(|c| c.get())
}

fn in_flight_cross_claim_fill(raw_cpu_nanos: u128) -> Option<(String, u128)> {
CROSS_CLAIM_FILL_FRAMES.with(|frames| {
frames.borrow().first().map(|outermost| {
(
outermost.producer.clone(),
raw_cpu_nanos
.saturating_sub(outermost.cpu_started)
.saturating_sub(outermost.stored_children_cpu),
)
})
})
}

/// THE CLOCK EVERY CPU BUDGET IS MEASURED ON: thread CPU, less what this thread spent filling
/// shared artifacts that every later claim naming the same source reads free.
///
Expand Down Expand Up @@ -5114,8 +5238,25 @@ fn eval_expr(node: &Rc<Node>, env: &Rc<Env>, ctx: &InterpContext) -> InterpResul
ctx.eval_deadline_stride.set(stride);
if stride % 4096 == 0 {
if let Some((cpu_baseline_nanos, budget_ms)) = cpu_armed {
let elapsed_nanos = budgeted_cpu_nanos().saturating_sub(cpu_baseline_nanos);
let raw_cpu_nanos = thread_cpu_nanos();
let elapsed_nanos = raw_cpu_nanos
.saturating_sub(shared_artifact_fill_cpu_nanos())
.saturating_sub(cpu_baseline_nanos);
if (elapsed_nanos / 1_000_000) as u64 > budget_ms {
if let Some((producer, fill_cpu_nanos)) =
in_flight_cross_claim_fill(raw_cpu_nanos)
{
let marginal_cpu_nanos = elapsed_nanos.saturating_sub(fill_cpu_nanos);
if (marginal_cpu_nanos / 1_000_000) as u64 <= budget_ms {
return Err(InterpError::FillBudgetExceeded {
entry: ctx.budget_entry_or_unnamed(),
producer,
fill_cpu_nanos,
marginal_cpu_nanos,
limit_ms: budget_ms,
});
}
}
return Err(InterpError::EvaluationBudgetExceeded {
entry: ctx.budget_entry_or_unnamed(),
clock: EvaluationClock::ThreadCpu,
Expand Down
Loading