1use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10
11mod tensor_wire;
12
13use crate::{
14 ObservationCatalog, ObservationPoint, ObservationSupportReport, ObservationSupportStatus,
15 SymbolicDimension, TensorObservation,
16};
17
18pub const CAPTURE_SCHEMA_VERSION: u32 = 1;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum CapturePhase {
25 Prefill,
27 Decode,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct CaptureSchedule {
34 pub prefill: bool,
36 pub decode: bool,
38 pub first_prediction: u64,
40 pub end_prediction: Option<u64>,
42 pub every: u64,
44}
45
46impl Default for CaptureSchedule {
47 fn default() -> Self {
48 Self {
49 prefill: true,
50 decode: true,
51 first_prediction: 0,
52 end_prediction: None,
53 every: 1,
54 }
55 }
56}
57
58impl CaptureSchedule {
59 pub fn count_and_last(
61 &self,
62 phase: CapturePhase,
63 maximum: u64,
64 ) -> Result<Option<(u64, u64)>, CaptureError> {
65 self.count_and_last_from(phase, 0, maximum)
66 }
67
68 pub fn count_and_last_from(
71 &self,
72 phase: CapturePhase,
73 next_prediction: u64,
74 maximum: u64,
75 ) -> Result<Option<(u64, u64)>, CaptureError> {
76 if self.every == 0 {
77 return Err(CaptureError::Invalid("zero capture frequency".into()));
78 }
79 if phase == CapturePhase::Prefill {
80 return Ok(
81 (next_prediction == 0 && maximum > 0 && self.includes(phase, 0)).then_some((1, 0)),
82 );
83 }
84 if !self.decode {
85 return Ok(None);
86 }
87 let end = self.end_prediction.unwrap_or(maximum).min(maximum);
88 let lower = self.first_prediction.max(1).max(next_prediction);
89 if lower >= end {
90 return Ok(None);
91 }
92 let offset = (lower - self.first_prediction).div_ceil(self.every);
93 let first = add(self.first_prediction, mul(offset, self.every)?)?;
94 if first >= end {
95 return Ok(None);
96 }
97 let steps = (end - 1 - first) / self.every;
98 Ok(Some((add(steps, 1)?, add(first, mul(steps, self.every)?)?)))
99 }
100
101 pub fn includes(&self, phase: CapturePhase, prediction: u64) -> bool {
103 (match phase {
104 CapturePhase::Prefill => self.prefill,
105 CapturePhase::Decode => self.decode,
106 }) && prediction >= self.first_prediction
107 && self.end_prediction.is_none_or(|end| prediction < end)
108 && self.every != 0
109 && (prediction - self.first_prediction).is_multiple_of(self.every)
110 }
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115pub struct CaptureSlice {
116 pub axis: String,
118 pub start: u64,
120 pub end: u64,
122 pub stride: u64,
124}
125
126#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
128#[serde(tag = "kind", rename_all = "snake_case")]
129pub enum CaptureTransform {
130 Preview {
132 max_elements: u64,
134 },
135 Slice,
137 FullTensor,
139 Summary,
141 Histogram {
143 edges: Vec<f32>,
145 },
146 TopCandidates {
148 count: u64,
150 },
151}
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
155#[serde(rename_all = "snake_case")]
156pub enum CaptureTransformKind {
157 Preview,
159 Slice,
161 FullTensor,
163 Summary,
165 Histogram,
167 TopCandidates,
169}
170
171impl CaptureTransform {
172 pub fn kind(&self) -> CaptureTransformKind {
174 match self {
175 Self::Preview { .. } => CaptureTransformKind::Preview,
176 Self::Slice => CaptureTransformKind::Slice,
177 Self::FullTensor => CaptureTransformKind::FullTensor,
178 Self::Summary => CaptureTransformKind::Summary,
179 Self::Histogram { .. } => CaptureTransformKind::Histogram,
180 Self::TopCandidates { .. } => CaptureTransformKind::TopCandidates,
181 }
182 }
183}
184
185#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
188pub struct CaptureCapabilities {
189 pub transformations: Vec<CaptureTransformKind>,
191 pub max_histogram_bins: u64,
193 pub physical_native_limit: bool,
195 pub conditions: Vec<String>,
197}
198
199#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
201pub struct CaptureSelection {
202 pub id: String,
204 pub path: String,
206 pub schedule: CaptureSchedule,
208 pub slices: Vec<CaptureSlice>,
210 pub transform: CaptureTransform,
212}
213
214#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
216#[serde(rename_all = "snake_case")]
217pub enum CaptureLimitPolicy {
218 Fail,
220 Skip,
222}
223
224#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
229pub struct CaptureUsage {
230 pub captures: u64,
232 pub retained_bytes: u64,
234 pub host_bytes: u64,
236 pub encoded_bytes: u64,
238}
239
240impl CaptureUsage {
241 pub fn checked_mul(self, count: u64) -> Result<Self, CaptureError> {
243 Ok(Self {
244 captures: mul(self.captures, count)?,
245 retained_bytes: mul(self.retained_bytes, count)?,
246 host_bytes: mul(self.host_bytes, count)?,
247 encoded_bytes: mul(self.encoded_bytes, count)?,
248 })
249 }
250 pub fn checked_add(self, other: Self) -> Result<Self, CaptureError> {
252 Ok(Self {
253 captures: add(self.captures, other.captures)?,
254 retained_bytes: add(self.retained_bytes, other.retained_bytes)?,
255 host_bytes: add(self.host_bytes, other.host_bytes)?,
256 encoded_bytes: add(self.encoded_bytes, other.encoded_bytes)?,
257 })
258 }
259 pub fn exceeded(self, limit: Self) -> Option<CaptureBudget> {
261 if self.captures > limit.captures {
262 Some(CaptureBudget::Captures)
263 } else if self.retained_bytes > limit.retained_bytes {
264 Some(CaptureBudget::Retention)
265 } else if self.host_bytes > limit.host_bytes {
266 Some(CaptureBudget::Host)
267 } else if self.encoded_bytes > limit.encoded_bytes {
268 Some(CaptureBudget::Encoded)
269 } else {
270 None
271 }
272 }
273}
274
275#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
277pub struct CaptureLimits {
278 pub per_step: CaptureUsage,
280 pub cumulative: CaptureUsage,
282 pub physical_native_bytes: Option<u64>,
284 pub on_limit: CaptureLimitPolicy,
286}
287
288#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
290pub struct CapturePlan {
291 pub schema_version: u32,
293 pub selections: Vec<CaptureSelection>,
295 pub limits: CaptureLimits,
297}
298
299impl CapturePlan {
300 pub fn none() -> Self {
302 Self {
303 schema_version: CAPTURE_SCHEMA_VERSION,
304 selections: Vec::new(),
305 limits: CaptureLimits {
306 per_step: CaptureUsage::default(),
307 cumulative: CaptureUsage::default(),
308 physical_native_bytes: None,
309 on_limit: CaptureLimitPolicy::Fail,
310 },
311 }
312 }
313
314 pub fn admit(
316 self,
317 catalog: &ObservationCatalog,
318 support: &ObservationSupportReport,
319 capabilities: &CaptureCapabilities,
320 request: CaptureRequestShape,
321 ) -> Result<AdmittedCapturePlan, CaptureError> {
322 if self.schema_version != CAPTURE_SCHEMA_VERSION
323 || catalog.schema_version != crate::DISCOVERY_SCHEMA_VERSION
324 || support.schema_version != crate::DISCOVERY_SCHEMA_VERSION
325 {
326 return Err(CaptureError::Invalid("unsupported schema version".into()));
327 }
328 if self.limits.physical_native_bytes.is_some() && !capabilities.physical_native_limit {
329 return Err(CaptureError::Unsupported(
330 "physical native allocator/workspace bound".into(),
331 ));
332 }
333 if request.batch == 0 || request.prompt_tokens == 0 || request.max_predictions == 0 {
334 return Err(CaptureError::Invalid(
335 "batch, prompt, and prediction limits must be positive".into(),
336 ));
337 }
338 add(request.prompt_tokens, request.max_predictions)?;
339 mul(request.batch, request.prompt_tokens)?;
340 let mut ids = std::collections::BTreeSet::new();
341 let mut points = Vec::new();
342 for selection in &self.selections {
343 if selection.id.is_empty() || !ids.insert(selection.id.as_str()) {
344 return Err(CaptureError::Invalid(
345 "capture IDs must be nonempty and unique".into(),
346 ));
347 }
348 let point = catalog
349 .get(&selection.path)
350 .ok_or_else(|| CaptureError::MissingPath(selection.path.clone()))?;
351 if !capabilities
352 .transformations
353 .contains(&selection.transform.kind())
354 {
355 return Err(CaptureError::Unsupported(format!(
356 "{:?}",
357 selection.transform.kind()
358 )));
359 }
360 if selection.schedule.every == 0
361 || selection
362 .schedule
363 .end_prediction
364 .is_some_and(|end| end <= selection.schedule.first_prediction)
365 {
366 return Err(CaptureError::Invalid("invalid capture schedule".into()));
367 }
368 let phase_support = support
369 .points
370 .iter()
371 .find(|p| p.path == selection.path)
372 .ok_or_else(|| {
373 CaptureError::Unsupported(format!("no selected support for {}", selection.path))
374 })?;
375 for (enabled, status) in [
376 (selection.schedule.prefill, &phase_support.prefill),
377 (selection.schedule.decode, &phase_support.decode),
378 ] {
379 if enabled && !matches!(status, ObservationSupportStatus::Supported) {
380 return Err(CaptureError::Unsupported(format!(
381 "{}: {status:?}",
382 selection.path
383 )));
384 }
385 }
386 if matches!(selection.transform, CaptureTransform::Slice) && selection.slices.is_empty()
387 {
388 return Err(CaptureError::Invalid(
389 "slice capture requires an explicit axis slice; use FullTensor to opt in"
390 .into(),
391 ));
392 }
393 if let CaptureTransform::Histogram { edges } = &selection.transform {
394 if edges.len() < 2
395 || (edges.len() - 1) as u64 > capabilities.max_histogram_bins
396 || edges.iter().any(|edge| !edge.is_finite())
397 || edges.windows(2).any(|w| w[0] >= w[1])
398 {
399 return Err(CaptureError::Invalid(
400 "histogram edges must be finite, increasing, and within the bin limit"
401 .into(),
402 ));
403 }
404 }
405 if let CaptureTransform::TopCandidates { count } = selection.transform {
406 if count == 0
407 || selection.path != crate::MODEL_LOGITS_OBSERVATION_PATH
408 || !selection.slices.is_empty()
409 {
410 return Err(CaptureError::Invalid("candidate capture requires positive count, unsliced model.logits, and single-sequence execution".into()));
411 }
412 if request.batch != 1 {
413 return Err(CaptureError::Unsupported(
414 "candidate capture requires batch one".into(),
415 ));
416 }
417 if let Some(SymbolicDimension::Known(vocabulary)) = point
418 .axes
419 .as_ref()
420 .and_then(|axes| axes.last())
421 .map(|a| &a.dimension)
422 {
423 if count > *vocabulary as u64 {
424 return Err(CaptureError::Invalid(
425 "candidate count exceeds vocabulary".into(),
426 ));
427 }
428 }
429 }
430 let mut axes = std::collections::BTreeSet::new();
431 for slice in &selection.slices {
432 if slice.stride == 0 || slice.start > slice.end || !axes.insert(&slice.axis) {
433 return Err(CaptureError::Invalid(
434 "invalid or duplicate axis slice".into(),
435 ));
436 }
437 if !point
438 .axes
439 .as_ref()
440 .is_some_and(|axes| axes.iter().any(|axis| axis.name == slice.axis))
441 {
442 return Err(CaptureError::Invalid(format!(
443 "unknown axis {}",
444 slice.axis
445 )));
446 }
447 }
448 for phase in [CapturePhase::Prefill, CapturePhase::Decode] {
451 if let Some((count, last)) = selection
452 .schedule
453 .count_and_last(phase, request.max_predictions)?
454 {
455 let first = last - mul(count - 1, selection.schedule.every)?;
456 for prediction in [first, last] {
457 for slice in &selection.slices {
458 let axis = point
459 .axes
460 .as_ref()
461 .and_then(|axes| axes.iter().find(|axis| axis.name == slice.axis))
462 .expect("axis was validated");
463 if request
464 .extent(&axis.dimension, phase, prediction)?
465 .is_some_and(|extent| slice.end > extent)
466 {
467 return Err(CaptureError::Invalid(format!(
468 "slice {} exceeds known request extent",
469 slice.axis
470 )));
471 }
472 }
473 if let Some(shape) = request.resolve(point, phase, prediction)? {
474 resolve_slice(point, selection, &shape)?;
475 }
476 }
477 }
478 }
479 points.push(point.clone());
480 }
481 let bytes = serde_json::to_vec(&(&self, &points, request))
483 .map_err(|e| CaptureError::Invalid(e.to_string()))?;
484 let identity = Sha256::digest(bytes)
485 .iter()
486 .map(|byte| format!("{byte:02x}"))
487 .collect();
488 Ok(AdmittedCapturePlan {
489 plan: self,
490 points,
491 request,
492 identity,
493 })
494 }
495}
496
497#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
500pub struct CaptureRequestShape {
501 pub batch: u64,
503 pub prompt_tokens: u64,
505 pub max_predictions: u64,
507}
508
509impl CaptureRequestShape {
510 fn extent(
511 self,
512 dimension: &SymbolicDimension,
513 phase: CapturePhase,
514 prediction: u64,
515 ) -> Result<Option<u64>, CaptureError> {
516 let sequence = if phase == CapturePhase::Prefill {
517 self.prompt_tokens
518 } else {
519 1
520 };
521 Ok(match dimension {
522 SymbolicDimension::Known(n) => {
523 Some(u64::try_from(*n).map_err(|_| CaptureError::Overflow)?)
524 }
525 SymbolicDimension::Batch => Some(self.batch),
526 SymbolicDimension::Sequence => Some(sequence),
527 SymbolicDimension::TokenRows => Some(mul(self.batch, sequence)?),
528 SymbolicDimension::Context => Some(add(self.prompt_tokens, prediction)?),
529 SymbolicDimension::MediaPositions | SymbolicDimension::Unknown => None,
530 })
531 }
532 pub fn validate_actual(
534 self,
535 point: &ObservationPoint,
536 phase: CapturePhase,
537 prediction: u64,
538 shape: &[u64],
539 ) -> Result<(), CaptureError> {
540 let Some(axes) = &point.axes else {
541 if shape.len() > 32 {
542 return Err(CaptureError::Unsupported(
543 "capture rank exceeds the 32-axis metadata bound".into(),
544 ));
545 }
546 return elements(shape).map(|_| ());
547 };
548 if axes.len() != shape.len() {
549 return Err(CaptureError::Invalid(
550 "runtime rank differs from the catalog".into(),
551 ));
552 }
553 for (axis, actual) in axes.iter().zip(shape) {
554 let expected = self.extent(&axis.dimension, phase, prediction)?;
555 if expected.is_some_and(|expected| expected != *actual) {
556 return Err(CaptureError::Invalid(format!(
557 "runtime extent for {} differs from catalog/request",
558 axis.name
559 )));
560 }
561 }
562 elements(shape).map(|_| ())
563 }
564
565 pub fn resolve(
567 self,
568 point: &ObservationPoint,
569 phase: CapturePhase,
570 prediction: u64,
571 ) -> Result<Option<Vec<u64>>, CaptureError> {
572 let Some(axes) = &point.axes else {
573 return Ok(None);
574 };
575 let mut shape = Vec::with_capacity(axes.len());
576 for axis in axes {
577 let Some(extent) = self.extent(&axis.dimension, phase, prediction)? else {
578 return Ok(None);
579 };
580 shape.push(extent);
581 }
582 elements(&shape)?;
583 Ok(Some(shape))
584 }
585}
586
587#[derive(Debug, Clone)]
589pub struct AdmittedCapturePlan {
590 plan: CapturePlan,
591 points: Vec<ObservationPoint>,
592 request: CaptureRequestShape,
593 identity: String,
594}
595
596impl AdmittedCapturePlan {
597 pub fn identity(&self) -> &str {
599 &self.identity
600 }
601 pub fn plan(&self) -> &CapturePlan {
603 &self.plan
604 }
605 pub fn points(&self) -> &[ObservationPoint] {
607 &self.points
608 }
609 pub fn request(&self) -> CaptureRequestShape {
611 self.request
612 }
613 pub fn is_empty(&self) -> bool {
615 self.plan.selections.is_empty()
616 }
617}
618
619#[derive(Debug, Clone, PartialEq, Eq)]
621pub struct ResolvedCaptureSlice {
622 pub starts: Vec<u64>,
624 pub ends: Vec<u64>,
626 pub strides: Vec<u64>,
628 pub shape: Vec<u64>,
630}
631
632pub fn resolve_slice(
634 point: &ObservationPoint,
635 selection: &CaptureSelection,
636 shape: &[u64],
637) -> Result<ResolvedCaptureSlice, CaptureError> {
638 if point
639 .axes
640 .as_ref()
641 .is_some_and(|axes| axes.len() != shape.len())
642 {
643 return Err(CaptureError::Invalid(
644 "runtime tensor rank differs from catalog".into(),
645 ));
646 }
647 let mut output = ResolvedCaptureSlice {
648 starts: vec![0; shape.len()],
649 ends: shape.to_vec(),
650 strides: vec![1; shape.len()],
651 shape: shape.to_vec(),
652 };
653 for slice in &selection.slices {
654 let axis = point
655 .axes
656 .as_ref()
657 .and_then(|axes| axes.iter().position(|axis| axis.name == slice.axis))
658 .ok_or_else(|| CaptureError::Invalid(format!("unknown axis {}", slice.axis)))?;
659 if slice.stride == 0 || slice.start > slice.end || slice.end > shape[axis] {
660 return Err(CaptureError::Invalid(format!(
661 "slice {} exceeds runtime extent",
662 slice.axis
663 )));
664 }
665 output.starts[axis] = slice.start;
666 output.ends[axis] = slice.end;
667 output.strides[axis] = slice.stride;
668 output.shape[axis] = (slice.end - slice.start).div_ceil(slice.stride);
669 }
670 elements(&output.shape)?;
671 Ok(output)
672}
673
674#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
678pub struct CaptureSummary {
679 pub elements: u64,
681 pub finite: u64,
683 pub non_finite: u64,
685 pub nan: u64,
687 pub positive_infinity: u64,
689 pub negative_infinity: u64,
691 pub min: Option<f64>,
693 pub max: Option<f64>,
695 pub mean: Option<f64>,
697 pub rms: Option<f64>,
699}
700
701#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
703pub struct CaptureHistogram {
704 pub edges: Vec<f32>,
706 pub counts: Vec<u64>,
708 pub below: u64,
710 pub above: u64,
712 pub non_finite: u64,
714}
715
716#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
718#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
719pub enum CapturePayload {
720 Tensor(#[serde(with = "tensor_wire")] TensorObservation),
722 Summary(CaptureSummary),
724 Histogram(CaptureHistogram),
726 Candidates(CaptureCandidates),
728}
729
730#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
732#[serde(rename_all = "snake_case")]
733pub enum CandidateScoreStage {
734 RawLogitsBeforeSampling,
737}
738
739#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
741#[serde(rename_all = "snake_case")]
742pub enum CandidateLogitsSource {
743 #[default]
745 Original,
746 Effective,
748}
749
750#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
752pub struct CaptureCandidate {
753 pub token_id: u32,
755 pub score: f32,
757}
758
759#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
761pub struct CaptureCandidates {
762 pub stage: CandidateScoreStage,
764 #[serde(default)]
766 pub source: CandidateLogitsSource,
767 pub candidates: Vec<CaptureCandidate>,
769}
770
771#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
773#[serde(rename_all = "snake_case")]
774pub enum CaptureBudget {
775 Captures,
777 Retention,
779 Host,
781 Encoded,
783}
784
785#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
787#[serde(tag = "kind", rename_all = "snake_case")]
788pub enum CaptureOutcome {
789 Captured,
791 Truncated {
793 available_elements: u64,
795 emitted_elements: u64,
797 },
798 Skipped {
800 reason: CaptureSkipReason,
802 },
803 Missing,
805 Failed {
807 reason: CaptureFailureReason,
809 message: String,
811 },
812}
813
814#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
816#[serde(tag = "kind", rename_all = "snake_case")]
817pub enum CaptureFailureReason {
818 Limit {
820 budget: CaptureBudget,
822 cumulative: bool,
824 },
825 Unsupported,
827 Invalid,
829 Native,
831}
832
833#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
835#[serde(tag = "kind", rename_all = "snake_case")]
836pub enum CaptureSkipReason {
837 Schedule,
839 Limit {
841 budget: CaptureBudget,
843 cumulative: bool,
845 },
846}
847
848#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
851pub struct CaptureRecord {
852 pub schema_version: u32,
854 pub selection_id: String,
856 pub path: String,
858 pub node_id: String,
860 pub position: crate::ObservationPosition,
862 pub source_shape: Option<Vec<u64>>,
864 pub selected_shape: Option<Vec<u64>>,
866 pub outcome: CaptureOutcome,
868 pub payload: Option<CapturePayload>,
870 pub charged: CaptureUsage,
872}
873
874#[derive(Debug)]
876pub struct CaptureLedger {
877 limits: CaptureLimits,
878 step: CaptureUsage,
879 total: CaptureUsage,
880}
881
882impl CaptureLedger {
883 pub fn new(plan: &AdmittedCapturePlan) -> Self {
885 Self {
886 limits: plan.plan.limits.clone(),
887 step: CaptureUsage::default(),
888 total: CaptureUsage::default(),
889 }
890 }
891 pub fn with_inherited_usage(
895 plan: &AdmittedCapturePlan,
896 inherited: CaptureUsage,
897 ) -> Result<Self, CaptureError> {
898 if let Some(budget) = inherited.exceeded(plan.plan.limits.cumulative) {
899 return Err(CaptureError::Limit {
900 budget,
901 cumulative: true,
902 });
903 }
904 Ok(Self {
905 limits: plan.plan.limits.clone(),
906 step: CaptureUsage::default(),
907 total: inherited,
908 })
909 }
910 pub fn begin_step(&mut self) {
912 self.step = CaptureUsage::default();
913 }
914 pub fn step(&self) -> CaptureUsage {
916 self.step
917 }
918 pub fn total(&self) -> CaptureUsage {
920 self.total
921 }
922 pub fn reserve(
924 &mut self,
925 usage: CaptureUsage,
926 ) -> Result<Option<CaptureSkipReason>, CaptureError> {
927 let step = self.step.checked_add(usage)?;
928 let total = self.total.checked_add(usage)?;
929 let exceeded = step
930 .exceeded(self.limits.per_step)
931 .map(|b| (b, false))
932 .or_else(|| total.exceeded(self.limits.cumulative).map(|b| (b, true)));
933 if let Some((budget, cumulative)) = exceeded {
934 return match self.limits.on_limit {
935 CaptureLimitPolicy::Fail => Err(CaptureError::Limit { budget, cumulative }),
936 CaptureLimitPolicy::Skip => {
937 Ok(Some(CaptureSkipReason::Limit { budget, cumulative }))
938 }
939 };
940 }
941 self.step = step;
942 self.total = total;
943 Ok(None)
944 }
945}
946
947#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
949pub enum CaptureError {
950 #[error("invalid capture plan or tensor: {0}")]
952 Invalid(String),
953 #[error("capture operation unsupported: {0}")]
955 Unsupported(String),
956 #[error("capture path absent from catalog: {0}")]
958 MissingPath(String),
959 #[error("capture size arithmetic overflow")]
961 Overflow,
962 #[error("capture {budget:?} limit exceeded (cumulative: {cumulative})")]
964 Limit {
965 budget: CaptureBudget,
967 cumulative: bool,
969 },
970}
971
972pub fn elements(shape: &[u64]) -> Result<u64, CaptureError> {
974 let nonzero = shape
976 .iter()
977 .filter(|dimension| **dimension != 0)
978 .try_fold(1, |count, dimension| mul(count, *dimension))?;
979 Ok(if shape.contains(&0) { 0 } else { nonzero })
980}
981pub fn add(a: u64, b: u64) -> Result<u64, CaptureError> {
983 a.checked_add(b).ok_or(CaptureError::Overflow)
984}
985pub fn mul(a: u64, b: u64) -> Result<u64, CaptureError> {
987 a.checked_mul(b).ok_or(CaptureError::Overflow)
988}
989
990pub trait CaptureBackend {
996 type Tensor;
998 type Error: std::error::Error + 'static;
1000 fn shape(&self, tensor: &Self::Tensor) -> Result<Vec<u64>, Self::Error>;
1002 fn estimate(
1004 &self,
1005 tensor: &Self::Tensor,
1006 selection: &CaptureSelection,
1007 slice: &ResolvedCaptureSlice,
1008 ) -> Result<CaptureUsage, CaptureError>;
1009 fn transform(
1011 &mut self,
1012 tensor: &Self::Tensor,
1013 selection: &CaptureSelection,
1014 slice: &ResolvedCaptureSlice,
1015 ) -> Result<CapturePayload, Self::Error>;
1016}
1017
1018#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1021pub struct CapturedStep {
1022 pub phase: CapturePhase,
1024 pub prediction_index: u64,
1026 pub records: Vec<CaptureRecord>,
1028 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1030 pub interventions: Vec<crate::intervention::InterventionRecord>,
1031 pub step_usage: CaptureUsage,
1033 pub cumulative_usage: CaptureUsage,
1035 pub capture_seconds: f64,
1037}
1038
1039#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1042pub struct CaptureDiscovery {
1043 pub artifact_identity: String,
1045 pub catalog: ObservationCatalog,
1047 pub support: ObservationSupportReport,
1049}