Skip to main content

eredu_runtime/
capture.rs

1//! Budget admission at the observation boundary, before native handles are retained.
2
3use eredu_core::capture::*;
4
5mod checkpoint;
6#[cfg(test)]
7mod tests;
8pub use checkpoint::{
9    CaptureCheckpoint, CaptureForkRequest, InterventionForkRequest, PreparedCaptureRestore,
10};
11
12/// A run owns one ledger and at most one step of host records. Consumers must drain
13/// each step before another is started; there is no producer queue.
14pub struct CaptureSession {
15    // Identity is deliberately not serialized or copied into child sessions.
16    owner: std::sync::Arc<()>,
17    checkpoint_ready: bool,
18    has_step: bool,
19    pub(crate) plan: AdmittedCapturePlan,
20    pub(crate) ledger: CaptureLedger,
21    pub(crate) records: Option<Vec<CaptureRecord>>,
22    pub(crate) prediction: u64,
23    pub(crate) phase: CapturePhase,
24    pub(crate) capture_seconds: f64,
25    pub(crate) interventions: Option<crate::intervention::InterventionRun>,
26}
27
28impl CaptureSession {
29    /// Creates an unstarted capture run owning its admission and ledger.
30    pub fn new(plan: AdmittedCapturePlan) -> Self {
31        Self {
32            owner: std::sync::Arc::new(()),
33            checkpoint_ready: true,
34            has_step: false,
35            ledger: CaptureLedger::new(&plan),
36            plan,
37            records: None,
38            prediction: 0,
39            phase: CapturePhase::Prefill,
40            capture_seconds: 0.0,
41            interventions: None,
42        }
43    }
44
45    /// Borrows this run's immutable admission.
46    pub fn plan(&self) -> &AdmittedCapturePlan {
47        &self.plan
48    }
49
50    /// Immutable intervention admission currently paired with this shared owner.
51    pub fn intervention_plan(&self) -> Option<&eredu_core::intervention::AdmittedInterventionPlan> {
52        self.interventions.as_ref().map(|run| &run.plan)
53    }
54
55    /// Reserves diagnostic envelopes before execution, including scheduled skips and
56    /// missing values. Exhaustion here fails the step: emitting an unaccounted skip
57    /// record would itself violate the export limit.
58    pub fn begin_step(&mut self, phase: CapturePhase, prediction: u64) -> Result<(), CaptureError> {
59        if self.records.is_some() {
60            return Err(CaptureError::Invalid(
61                "previous capture step has not been consumed".into(),
62            ));
63        }
64        if prediction >= self.plan.request().max_predictions {
65            return Err(CaptureError::Invalid(
66                "generation exceeds admitted prediction range".into(),
67            ));
68        }
69        // Even a failed reservation can consume cumulative resources. A failed
70        // attempt is not a resumable boundary merely because records were drained.
71        self.checkpoint_ready = false;
72        self.has_step = true;
73        self.ledger.begin_step();
74        let mut records = Vec::new();
75        for (selection, point) in self.plan.plan().selections.iter().zip(self.plan.points()) {
76            let charged = metadata_reservation(selection, point)?;
77            if let Some(CaptureSkipReason::Limit { budget, cumulative }) =
78                self.ledger.reserve(charged)?
79            {
80                return Err(CaptureError::Limit { budget, cumulative });
81            }
82            records.push(CaptureRecord {
83                schema_version: CAPTURE_SCHEMA_VERSION,
84                selection_id: selection.id.clone(),
85                path: selection.path.clone(),
86                node_id: point.node_id.clone(),
87                position: point.position,
88                source_shape: None,
89                selected_shape: None,
90                outcome: if selection.schedule.includes(phase, prediction) {
91                    CaptureOutcome::Missing
92                } else {
93                    CaptureOutcome::Skipped {
94                        reason: CaptureSkipReason::Schedule,
95                    }
96                },
97                payload: None,
98                charged,
99            });
100        }
101        self.records = Some(records);
102        self.phase = phase;
103        self.prediction = prediction;
104        self.capture_seconds = 0.0;
105        if let Some(interventions) = &mut self.interventions {
106            interventions.begin_step(&mut self.ledger, phase, prediction)?;
107        }
108        Ok(())
109    }
110
111    /// Invoked while the architecture borrows a tensor. A skipped point never calls
112    /// the native transform and never clones a native tensor handle.
113    pub fn observe<B: CaptureBackend>(
114        &mut self,
115        backend: &mut B,
116        path: &str,
117        tensor: &B::Tensor,
118    ) -> Result<(), CaptureExecutionError<B::Error>> {
119        let Some(records) = self.records.as_mut() else {
120            return Err(CaptureError::Invalid("capture step not started".into()).into());
121        };
122        for ((selection, point), record) in self
123            .plan
124            .plan()
125            .selections
126            .iter()
127            .zip(self.plan.points())
128            .zip(records)
129        {
130            if selection.path != path || matches!(record.outcome, CaptureOutcome::Skipped { .. }) {
131                continue;
132            }
133            if !matches!(record.outcome, CaptureOutcome::Missing) {
134                return Err(
135                    CaptureError::Invalid(format!("observation emitted twice: {path}")).into(),
136                );
137            }
138            let started = std::time::Instant::now();
139            let result = capture_value(
140                backend,
141                tensor,
142                selection,
143                point,
144                record,
145                self.plan.request(),
146                self.phase,
147                self.prediction,
148                &mut self.ledger,
149            );
150            self.capture_seconds += started.elapsed().as_secs_f64();
151            if let Err(error) = result {
152                let reason = match &error {
153                    CaptureExecutionError::Admission(CaptureError::Limit {
154                        budget,
155                        cumulative,
156                    }) => CaptureFailureReason::Limit {
157                        budget: *budget,
158                        cumulative: *cumulative,
159                    },
160                    CaptureExecutionError::Admission(CaptureError::Unsupported(_)) => {
161                        CaptureFailureReason::Unsupported
162                    }
163                    CaptureExecutionError::Admission(_) => CaptureFailureReason::Invalid,
164                    CaptureExecutionError::Backend(_) => CaptureFailureReason::Native,
165                };
166                record.payload = None;
167                record.outcome = CaptureOutcome::Failed {
168                    reason,
169                    message: bounded_diagnostic(&error),
170                };
171                return Err(error);
172            }
173        }
174        Ok(())
175    }
176
177    /// Moves the current bounded record batch to the consumer.
178    pub fn take_step(&mut self) -> Option<CapturedStep> {
179        if let Some(records) = &self.records {
180            self.checkpoint_ready = self.finish_interventions().is_ok()
181                && !records
182                    .iter()
183                    .any(|record| matches!(record.outcome, CaptureOutcome::Failed { .. }));
184        }
185        self.records.take().map(|records| CapturedStep {
186            phase: self.phase,
187            prediction_index: self.prediction,
188            records,
189            interventions: self
190                .interventions
191                .as_mut()
192                .map_or_else(Vec::new, |run| run.take_records()),
193            step_usage: self.ledger.step(),
194            cumulative_usage: self.ledger.total(),
195            capture_seconds: self.capture_seconds,
196        })
197    }
198}
199
200/// Shared transformation path for ordinary captures and intervention evidence.
201#[allow(clippy::too_many_arguments)]
202pub(crate) fn capture_value<B: CaptureBackend>(
203    backend: &mut B,
204    tensor: &B::Tensor,
205    selection: &CaptureSelection,
206    point: &eredu_core::ObservationPoint,
207    record: &mut CaptureRecord,
208    request: CaptureRequestShape,
209    phase: CapturePhase,
210    prediction: u64,
211    ledger: &mut CaptureLedger,
212) -> Result<(), CaptureExecutionError<B::Error>> {
213    let path = &selection.path;
214
215    let shape = backend
216        .shape(tensor)
217        .map_err(CaptureExecutionError::Backend)?;
218    request.validate_actual(point, phase, prediction, &shape)?;
219    if let Some(expected) = request.resolve(point, phase, prediction)? {
220        if expected != shape {
221            return Err(CaptureError::Invalid(format!(
222                "runtime shape for {path}: expected {expected:?}, got {shape:?}"
223            ))
224            .into());
225        }
226    }
227    let slice = resolve_slice(point, selection, &shape)?;
228    let usage = backend.estimate(tensor, selection, &slice)?;
229    record.source_shape = Some(shape);
230    record.selected_shape = Some(slice.shape.clone());
231    if let Some(reason) = ledger.reserve(usage)? {
232        record.outcome = CaptureOutcome::Skipped { reason };
233        return Ok(());
234    }
235    record.charged = record.charged.checked_add(usage)?;
236    let mut payload = backend
237        .transform(tensor, selection, &slice)
238        .map_err(CaptureExecutionError::Backend)?;
239    if let CapturePayload::Candidates(candidates) = &mut payload {
240        candidates.source = if record.position == eredu_core::ObservationPosition::AfterIntervention
241        {
242            CandidateLogitsSource::Effective
243        } else {
244            CandidateLogitsSource::Original
245        };
246    }
247    let available = elements(&slice.shape)?;
248    record.outcome = match selection.transform {
249        CaptureTransform::Preview { max_elements } if max_elements < available => {
250            CaptureOutcome::Truncated {
251                available_elements: available,
252                emitted_elements: max_elements,
253            }
254        }
255        _ => CaptureOutcome::Captured,
256    };
257    record.payload = Some(payload);
258    // Count through a bounded sink, without allocating a second JSON buffer.
259    // This is a backend-contract check, not a substitute for the pre-copy estimate.
260    let mut sink = CountingWriter {
261        written: 0,
262        limit: record.charged.encoded_bytes,
263    };
264    serde_json::to_writer(&mut sink, record)
265        .map_err(|_| CaptureError::Invalid("backend underestimated encoded capture size".into()))?;
266    Ok(())
267}
268
269pub(crate) fn bounded_diagnostic(error: &impl std::fmt::Display) -> String {
270    use std::fmt::Write;
271    struct Message(String);
272    impl std::fmt::Write for Message {
273        fn write_str(&mut self, text: &str) -> std::fmt::Result {
274            let mut end = text.len().min(256 - self.0.len());
275            while !text.is_char_boundary(end) {
276                end -= 1;
277            }
278            self.0.push_str(&text[..end]);
279            if end < text.len() {
280                Err(std::fmt::Error)
281            } else {
282                Ok(())
283            }
284        }
285    }
286    let mut message = Message(String::with_capacity(256));
287    let _ = write!(&mut message, "{error}");
288    message.0
289}
290
291/// Conservative JSON envelope reservation: every UTF-8 byte can escape to at most
292/// six bytes; each dimension can use twenty decimal digits. Numeric payloads are
293/// reserved by the native estimator. Selection count and metadata are bounded before
294/// generation begins, so missing/skipped points cannot form an unbounded queue.
295pub fn metadata_reservation(
296    selection: &CaptureSelection,
297    point: &eredu_core::ObservationPoint,
298) -> Result<CaptureUsage, CaptureError> {
299    let strings = add(
300        add(selection.id.len() as u64, selection.path.len() as u64)?,
301        point.node_id.len() as u64,
302    )?;
303    let rank = point.axes.as_ref().map_or(32, |axes| axes.len() as u64);
304    Ok(CaptureUsage {
305        captures: 0,
306        retained_bytes: 0,
307        host_bytes: add(512, add(strings, mul(rank, 128)?)?)?,
308        encoded_bytes: add(2048, add(mul(strings, 6)?, mul(rank, 64)?)?)?,
309    })
310}
311
312/// Checks conservative known per-step and run-total costs before execution.
313/// Backends supply only their side-effect-free storage/transfer estimator. Unknown
314/// dimensions remain subject to exact observation-time reservation. Step estimates
315/// conservatively allow every phase-enabled selection to coincide.
316pub fn preflight(
317    plan: &AdmittedCapturePlan,
318    estimate: impl FnMut(
319        &[u64],
320        &CaptureSelection,
321        &ResolvedCaptureSlice,
322    ) -> Result<CaptureUsage, CaptureError>,
323) -> Result<(), CaptureError> {
324    preflight_with_extra(plan, &[], CaptureUsage::default(), &[], estimate)
325}
326
327/// Revalidates admission against the loaded catalog, then checks known geometry
328/// and budgets with backend estimates. Native execution-mode checks stay with the
329/// caller; this helper never accesses a device or starts a submission.
330pub fn validate_session(
331    plan: &AdmittedCapturePlan,
332    discovery: &CaptureDiscovery,
333    estimate: impl FnMut(
334        &[u64],
335        &CaptureSelection,
336        &ResolvedCaptureSlice,
337    ) -> Result<CaptureUsage, CaptureError>,
338) -> Result<(), CaptureError> {
339    validate_continuation(plan, discovery, 0, CaptureUsage::default(), estimate)
340}
341
342pub(crate) fn validate_continuation(
343    plan: &AdmittedCapturePlan,
344    discovery: &CaptureDiscovery,
345    next_prediction: u64,
346    inherited: CaptureUsage,
347    estimate: impl FnMut(
348        &[u64],
349        &CaptureSelection,
350        &ResolvedCaptureSlice,
351    ) -> Result<CaptureUsage, CaptureError>,
352) -> Result<(), CaptureError> {
353    let checked = plan.plan().clone().admit(
354        &discovery.catalog,
355        &discovery.support,
356        &discovery.support.capture,
357        plan.request(),
358    )?;
359    if checked.identity() != plan.identity() {
360        return Err(CaptureError::Invalid(
361            "capture admission does not match this session's catalog".into(),
362        ));
363    }
364    preflight_continuation(
365        &checked,
366        &[],
367        CaptureUsage::default(),
368        &[],
369        next_prediction,
370        inherited,
371        estimate,
372    )
373}
374
375pub(crate) fn preflight_with_extra(
376    plan: &AdmittedCapturePlan,
377    extra: &[(CaptureSelection, eredu_core::ObservationPoint)],
378    base: CaptureUsage,
379    scheduled_costs: &[(CaptureSchedule, [CaptureUsage; 2])],
380    estimate: impl FnMut(
381        &[u64],
382        &CaptureSelection,
383        &ResolvedCaptureSlice,
384    ) -> Result<CaptureUsage, CaptureError>,
385) -> Result<(), CaptureError> {
386    preflight_continuation(
387        plan,
388        extra,
389        base,
390        scheduled_costs,
391        0,
392        CaptureUsage::default(),
393        estimate,
394    )
395}
396
397#[allow(clippy::too_many_arguments)]
398pub(crate) fn preflight_continuation(
399    plan: &AdmittedCapturePlan,
400    extra: &[(CaptureSelection, eredu_core::ObservationPoint)],
401    mut base: CaptureUsage,
402    scheduled_costs: &[(CaptureSchedule, [CaptureUsage; 2])],
403    next_prediction: u64,
404    inherited: CaptureUsage,
405    mut estimate: impl FnMut(
406        &[u64],
407        &CaptureSelection,
408        &ResolvedCaptureSlice,
409    ) -> Result<CaptureUsage, CaptureError>,
410) -> Result<(), CaptureError> {
411    let remaining = plan
412        .request()
413        .max_predictions
414        .checked_sub(next_prediction)
415        .ok_or_else(|| {
416            CaptureError::Invalid("continuation exceeds admitted prediction range".into())
417        })?;
418    let entries: Vec<_> = plan
419        .plan()
420        .selections
421        .iter()
422        .zip(plan.points())
423        .chain(extra.iter().map(|(selection, point)| (selection, point)))
424        .collect();
425    for &(selection, point) in &entries {
426        base = base.checked_add(metadata_reservation(selection, point)?)?;
427    }
428    if let Some(budget) = base.exceeded(plan.plan().limits.per_step) {
429        return Err(CaptureError::Limit {
430            budget,
431            cumulative: false,
432        });
433    }
434    let mut total = inherited.checked_add(base.checked_mul(remaining)?)?;
435    for phase in [CapturePhase::Prefill, CapturePhase::Decode] {
436        if remaining == 0 || (phase == CapturePhase::Prefill && next_prediction > 0) {
437            continue;
438        }
439        if phase == CapturePhase::Decode && plan.request().max_predictions <= 1 {
440            continue;
441        }
442        let mut step = base;
443        for (schedule, costs) in scheduled_costs {
444            if let Some((count, _)) = schedule.count_and_last_from(
445                phase,
446                next_prediction,
447                plan.request().max_predictions,
448            )? {
449                let cost = costs[if phase == CapturePhase::Prefill { 0 } else { 1 }];
450                step = step.checked_add(cost)?;
451                total = total.checked_add(cost.checked_mul(count)?)?;
452            }
453        }
454        for &(selection, point) in &entries {
455            let Some((count, last)) = selection.schedule.count_and_last_from(
456                phase,
457                next_prediction,
458                plan.request().max_predictions,
459            )?
460            else {
461                continue;
462            };
463            if let Some(shape) = plan.request().resolve(point, phase, last)? {
464                let slice = resolve_slice(point, selection, &shape)?;
465                let cost = estimate(&shape, selection, &slice)?;
466                if plan.plan().limits.on_limit == CaptureLimitPolicy::Fail {
467                    step = step.checked_add(cost)?;
468                    total = total.checked_add(cost.checked_mul(count)?)?;
469                }
470            }
471        }
472        if let Some(budget) = step.exceeded(plan.plan().limits.per_step) {
473            return Err(CaptureError::Limit {
474                budget,
475                cumulative: false,
476            });
477        }
478    }
479    if let Some(budget) = total.exceeded(plan.plan().limits.cumulative) {
480        return Err(CaptureError::Limit {
481            budget,
482            cumulative: true,
483        });
484    }
485    Ok(())
486}
487
488struct CountingWriter {
489    written: u64,
490    limit: u64,
491}
492impl std::io::Write for CountingWriter {
493    fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
494        let next = self
495            .written
496            .checked_add(bytes.len() as u64)
497            .filter(|next| *next <= self.limit)
498            .ok_or_else(|| std::io::Error::other("capture JSON budget exceeded"))?;
499        self.written = next;
500        Ok(bytes.len())
501    }
502    fn flush(&mut self) -> std::io::Result<()> {
503        Ok(())
504    }
505}
506
507/// Failure before reservation or during the reserved native transformation.
508#[derive(Debug, thiserror::Error)]
509pub enum CaptureExecutionError<E: std::error::Error + 'static> {
510    /// Invalid geometry or rejected budget/capability.
511    #[error(transparent)]
512    Admission(#[from] CaptureError),
513    /// Native transformation failed under the backend's recovery owner.
514    #[error("native capture failed: {0}")]
515    Backend(E),
516}