1use eredu_core::capture::*;
4
5mod checkpoint;
6#[cfg(test)]
7mod tests;
8pub use checkpoint::{
9 CaptureCheckpoint, CaptureForkRequest, InterventionForkRequest, PreparedCaptureRestore,
10};
11
12pub struct CaptureSession {
15 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 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 pub fn plan(&self) -> &AdmittedCapturePlan {
47 &self.plan
48 }
49
50 pub fn intervention_plan(&self) -> Option<&eredu_core::intervention::AdmittedInterventionPlan> {
52 self.interventions.as_ref().map(|run| &run.plan)
53 }
54
55 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 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 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 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#[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 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
291pub 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
312pub 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
327pub 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#[derive(Debug, thiserror::Error)]
509pub enum CaptureExecutionError<E: std::error::Error + 'static> {
510 #[error(transparent)]
512 Admission(#[from] CaptureError),
513 #[error("native capture failed: {0}")]
515 Backend(E),
516}