Skip to main content

eredu_core/
capture.rs

1//! Admitted, bounded observation contracts. Empty selections mean capture none.
2//!
3//! Budgets measure capture-owned logical storage and transfers, not an accelerator's
4//! allocator, inference state, or a consumer's retained history. A physical native
5//! allocation ceiling is a separate capability and must never be inferred from these
6//! logical bounds. Backends must reserve before retaining or materializing a value.
7
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10
11mod tensor_wire;
12
13use crate::{
14    ObservationCatalog, ObservationPoint, ObservationSupportReport, ObservationSupportStatus,
15    SymbolicDimension, TensorObservation,
16};
17
18/// Wire version for capture plans and records.
19pub const CAPTURE_SCHEMA_VERSION: u32 = 1;
20
21/// One ordinary forward operation. Prediction zero is prefill; decode starts at one.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum CapturePhase {
25    /// Prompt execution producing the first prediction.
26    Prefill,
27    /// Cached execution producing a later prediction.
28    Decode,
29}
30
31/// Half-open selection of prediction indices (zero is the prefill prediction).
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct CaptureSchedule {
34    /// Whether prompt-prefill observations are selected.
35    pub prefill: bool,
36    /// Whether cached-decode observations are selected.
37    pub decode: bool,
38    /// First selected prediction index, inclusive.
39    pub first_prediction: u64,
40    /// Exclusive end of the prediction range; absent means the request limit.
41    pub end_prediction: Option<u64>,
42    /// Capture every Nth prediction relative to first_prediction; must be positive.
43    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    /// Returns selected prediction count and last index within a bounded run.
60    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    /// Counts the remaining absolute schedule without moving its frequency
69    /// origin when a continuation resumes at `next_prediction`.
70    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    /// Whether a phase and prediction are selected by this schedule.
102    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/// Half-open, positive-stride slice of a catalog axis. Unmentioned axes are whole.
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115pub struct CaptureSlice {
116    /// Exact semantic axis name from the discovery catalog.
117    pub axis: String,
118    /// Inclusive element offset.
119    pub start: u64,
120    /// Exclusive element offset.
121    pub end: u64,
122    /// Positive step between selected elements.
123    pub stride: u64,
124}
125
126/// A transform is applied after axis selection. Full tensors require explicit opt-in.
127#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
128#[serde(tag = "kind", rename_all = "snake_case")]
129pub enum CaptureTransform {
130    /// Row-major prefix; omitted values are explicitly reported as truncated.
131    Preview {
132        /// Maximum row-major values to materialize.
133        max_elements: u64,
134    },
135    /// All values of a nonempty list of axis slices, preserving integer IDs.
136    Slice,
137    /// All values, still subject to every budget.
138    FullTensor,
139    /// Finite-only statistics with explicit non-finite counts.
140    Summary,
141    /// Fixed finite increasing edges. Bins are [lo, hi), last bin includes hi.
142    Histogram {
143        /// Finite, strictly increasing bin boundaries.
144        edges: Vec<f32>,
145    },
146    /// Highest raw logits for the current prediction; no sampling/RNG is performed.
147    TopCandidates {
148        /// Maximum candidate count, bounded by the vocabulary extent.
149        count: u64,
150    },
151}
152
153/// Portable transformation categories used by capability discovery.
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
155#[serde(rename_all = "snake_case")]
156pub enum CaptureTransformKind {
157    /// Bounded row-major tensor prefix.
158    Preview,
159    /// Axis-selected tensor values.
160    Slice,
161    /// Explicitly requested complete tensor values.
162    FullTensor,
163    /// Finite/non-finite counts and finite-only statistics.
164    Summary,
165    /// Bounded fixed-edge histogram.
166    Histogram,
167    /// Bounded raw model-score candidates before sampler processing.
168    TopCandidates,
169}
170
171impl CaptureTransform {
172    /// Transformation category for capability matching.
173    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/// Selected backend facts; absent transformations are unsupported, never emulated
186/// with an unbounded host copy.
187#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
188pub struct CaptureCapabilities {
189    /// Transformations implemented before host materialization.
190    pub transformations: Vec<CaptureTransformKind>,
191    /// Maximum admitted number of histogram bins.
192    pub max_histogram_bins: u64,
193    /// Whether physical allocator/workspace limits can be enforced.
194    pub physical_native_limit: bool,
195    /// Execution, precision, and memory-accounting conditions.
196    pub conditions: Vec<String>,
197}
198
199/// One exact path, schedule, axis selection, and transformation.
200#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
201pub struct CaptureSelection {
202    /// Unique caller identity, allowing several transforms at one observation point.
203    pub id: String,
204    /// Exact observation selector in the retained catalog.
205    pub path: String,
206    /// Phase, frequency, and prediction-range selection.
207    pub schedule: CaptureSchedule,
208    /// Semantic axis selection applied before the transform.
209    pub slices: Vec<CaptureSlice>,
210    /// Requested native transformation.
211    pub transform: CaptureTransform,
212}
213
214/// Action taken when a value reservation would exceed an admitted limit.
215#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
216#[serde(rename_all = "snake_case")]
217pub enum CaptureLimitPolicy {
218    /// Fail before performing the prohibited retention or copy.
219    Fail,
220    /// Omit the value and emit a structured budget reason.
221    Skip,
222}
223
224/// Upper bound on one step or the whole run. Retention includes capture-owned
225/// source storage/dependencies and logical temporary arrays. Host bytes include
226/// every transferred scalar and host result buffer, including intermediate reductions.
227/// Encoded bytes cover UTF-8 JSON capture records, including metadata and escaping.
228#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
229pub struct CaptureUsage {
230    /// Number of successfully reserved value transformations, excluding diagnostic-only records.
231    pub captures: u64,
232    /// Conservative logical source/backing and temporary native storage reservation.
233    pub retained_bytes: u64,
234    /// Conservative host-buffer and materialization reservation, including intermediate scalars.
235    pub host_bytes: u64,
236    /// Conservative UTF-8 JSON capture-record reservation.
237    pub encoded_bytes: u64,
238}
239
240impl CaptureUsage {
241    /// Multiplies a conservative reservation by a bounded occurrence count.
242    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    /// Adds reservations with checked arithmetic in every dimension.
251    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    /// First accounting dimension exceeding its limit, in stable diagnostic order.
260    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/// Independent per-step and cumulative limits with explicit failure policy.
276#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
277pub struct CaptureLimits {
278    /// Limits reset at each prefill or decode step.
279    pub per_step: CaptureUsage,
280    /// Limits summed across all steps in the run.
281    pub cumulative: CaptureUsage,
282    /// Optional physical allocator ceiling. Rejected if the backend cannot prove it.
283    pub physical_native_bytes: Option<u64>,
284    /// Whether a value-budget miss fails execution or emits an explicit skip.
285    pub on_limit: CaptureLimitPolicy,
286}
287
288/// Serializable request; only admission produces execution authority.
289#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
290pub struct CapturePlan {
291    /// Wire schema version; unsupported versions are rejected.
292    pub schema_version: u32,
293    /// Empty means none. There is deliberately no implicit capture-all mode.
294    pub selections: Vec<CaptureSelection>,
295    /// Mandatory storage, materialization, and export limits.
296    pub limits: CaptureLimits,
297}
298
299impl CapturePlan {
300    /// Creates an explicit capture-none plan with zero capture budgets.
301    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    /// Validates selectors, capabilities, geometry, and static constraints, returning an immutable proof.
315    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            // Resolve every known shape before execution, and defer only genuinely
449            // runtime-dependent dimensions. Unknown never becomes a zero extent.
450            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        // Identity includes catalog semantics and request shape, not just caller labels.
482        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/// Request geometry used for admission. This initial protocol is ordinary committed
498/// text generation; media, speculative and rank-partitioned runs need separate support.
499#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
500pub struct CaptureRequestShape {
501    /// Number of sequences in the request.
502    pub batch: u64,
503    /// Number of prefill positions per sequence.
504    pub prompt_tokens: u64,
505    /// Maximum number of generated predictions, including the prefill prediction.
506    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    /// Checks every known or request-resolved axis, even when another axis is unknown.
533    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    /// Resolves symbolic axes against the request; an unknown extent returns None.
566    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/// Immutable admission proof. Deserialization cannot forge admission.
588#[derive(Debug, Clone)]
589pub struct AdmittedCapturePlan {
590    plan: CapturePlan,
591    points: Vec<ObservationPoint>,
592    request: CaptureRequestShape,
593    identity: String,
594}
595
596impl AdmittedCapturePlan {
597    /// Stable digest of the plan, selected catalog semantics, and request shape.
598    pub fn identity(&self) -> &str {
599        &self.identity
600    }
601    /// Borrows the validated plan.
602    pub fn plan(&self) -> &CapturePlan {
603        &self.plan
604    }
605    /// Borrows selected catalog points in selection order.
606    pub fn points(&self) -> &[ObservationPoint] {
607        &self.points
608    }
609    /// Returns admitted request geometry.
610    pub fn request(&self) -> CaptureRequestShape {
611        self.request
612    }
613    /// Whether this plan captures no observation points.
614    pub fn is_empty(&self) -> bool {
615        self.plan.selections.is_empty()
616    }
617}
618
619/// Numeric axis slices resolved against an actual tensor before any retention/copy.
620#[derive(Debug, Clone, PartialEq, Eq)]
621pub struct ResolvedCaptureSlice {
622    /// Inclusive numeric offsets in storage-axis order.
623    pub starts: Vec<u64>,
624    /// Exclusive numeric offsets in storage-axis order.
625    pub ends: Vec<u64>,
626    /// Positive numeric strides in storage-axis order.
627    pub strides: Vec<u64>,
628    /// Resulting selected tensor extents in storage order.
629    pub shape: Vec<u64>,
630}
631
632/// Checks axis selection against actual extents without touching tensor payloads.
633pub 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/// Values are converted to F32 before statistics; finite-only aggregates are then
675/// accumulated in F64. Integer raw captures remain exact; integer statistics may
676/// round. Empty/all-nonfinite inputs have None aggregates, never fabricated zeros.
677#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
678pub struct CaptureSummary {
679    /// Total element count, including non-finite values.
680    pub elements: u64,
681    /// Number of finite values after F32 conversion.
682    pub finite: u64,
683    /// Combined NaN and infinity count after F32 conversion.
684    pub non_finite: u64,
685    /// Number of NaNs after F32 conversion.
686    pub nan: u64,
687    /// Number of positive infinities after F32 conversion.
688    pub positive_infinity: u64,
689    /// Number of negative infinities after F32 conversion.
690    pub negative_infinity: u64,
691    /// Minimum finite value; absent when there are no finite values.
692    pub min: Option<f64>,
693    /// Maximum finite value; absent when there are no finite values.
694    pub max: Option<f64>,
695    /// Finite-only arithmetic mean; absent when there are no finite values.
696    pub mean: Option<f64>,
697    /// Finite-only root mean square; absent when there are no finite values.
698    pub rms: Option<f64>,
699}
700
701/// Bounded finite-value histogram with explicit excluded-value counts.
702#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
703pub struct CaptureHistogram {
704    /// Finite, strictly increasing bin edges.
705    pub edges: Vec<f32>,
706    /// Counts for adjacent edge pairs; the last interval includes its upper endpoint.
707    pub counts: Vec<u64>,
708    /// Finite values strictly below the first edge.
709    pub below: u64,
710    /// Finite values strictly above the last edge.
711    pub above: u64,
712    /// NaNs and infinities excluded from bins and finite under/overflow counts.
713    pub non_finite: u64,
714}
715
716/// Bounded portable output from one native transformation.
717#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
718#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
719pub enum CapturePayload {
720    /// Raw or sliced tensor values; integer identifiers retain exact precision.
721    Tensor(#[serde(with = "tensor_wire")] TensorObservation),
722    /// Finite/non-finite counts and finite-only statistics.
723    Summary(CaptureSummary),
724    /// Bounded fixed-edge histogram.
725    Histogram(CaptureHistogram),
726    /// Native top-k extraction with an explicit score-processing stage.
727    Candidates(CaptureCandidates),
728}
729
730/// The processing stage represented by a candidate score.
731#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
732#[serde(rename_all = "snake_case")]
733pub enum CandidateScoreStage {
734    /// Raw model logits before token filtering, penalties, temperature, top-k/p,
735    /// Mirostat processing or normalization. These are not probabilities.
736    RawLogitsBeforeSampling,
737}
738
739/// Whether raw candidates precede or follow mutation at the logits hook.
740#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
741#[serde(rename_all = "snake_case")]
742pub enum CandidateLogitsSource {
743    /// Before interventions at this hook; earlier hooks may already have changed it.
744    #[default]
745    Original,
746    /// After interventions at this hook, before ordinary sampler processing.
747    Effective,
748}
749
750/// One exact vocabulary identity and finite raw model score.
751#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
752pub struct CaptureCandidate {
753    /// Canonical tokenizer vocabulary ID.
754    pub token_id: u32,
755    /// F32 score at the declared processing stage.
756    pub score: f32,
757}
758
759/// Bounded highest-score candidates for the last row of the current model logits.
760#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
761pub struct CaptureCandidates {
762    /// Explicit score-processing stage; never inferred to be a probability.
763    pub stage: CandidateScoreStage,
764    /// Explicit intervention position. Older records describe original logits.
765    #[serde(default)]
766    pub source: CandidateLogitsSource,
767    /// Descending scores; equal-score ordering follows the native sorter.
768    pub candidates: Vec<CaptureCandidate>,
769}
770
771/// Independently enforced accounting dimensions.
772#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
773#[serde(rename_all = "snake_case")]
774pub enum CaptureBudget {
775    /// Number of value transformations.
776    Captures,
777    /// Logical native capture storage.
778    Retention,
779    /// Host buffers and materialization.
780    Host,
781    /// UTF-8 JSON record size.
782    Encoded,
783}
784
785/// Measured data and diagnostic outcomes remain distinct.
786#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
787#[serde(tag = "kind", rename_all = "snake_case")]
788pub enum CaptureOutcome {
789    /// The requested result is present.
790    Captured,
791    /// An explicit preview omitted part of the selected tensor.
792    Truncated {
793        /// Number of values in the selected tensor.
794        available_elements: u64,
795        /// Number of materialized preview values.
796        emitted_elements: u64,
797    },
798    /// Schedule or budget explicitly omitted this result.
799    Skipped {
800        /// Structured omission reason.
801        reason: CaptureSkipReason,
802    },
803    /// The selected point was not emitted by execution.
804    Missing,
805    /// Capture execution failed.
806    Failed {
807        /// Structured category independent of the bounded human diagnostic.
808        reason: CaptureFailureReason,
809        /// Failure description.
810        message: String,
811    },
812}
813
814/// Portable capture-failure categories; diagnostic text is separately bounded.
815#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
816#[serde(tag = "kind", rename_all = "snake_case")]
817pub enum CaptureFailureReason {
818    /// The requested reservation exceeded an admitted budget.
819    Limit {
820        /// Rejected accounting dimension.
821        budget: CaptureBudget,
822        /// Whether the run-total rather than step limit was exceeded.
823        cumulative: bool,
824    },
825    /// Backend cannot implement the requested operation on this value.
826    Unsupported,
827    /// Shape, arithmetic, or protocol constraints were invalid.
828    Invalid,
829    /// The reserved native transformation failed.
830    Native,
831}
832
833/// Structured explanations for an intentionally omitted value.
834#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
835#[serde(tag = "kind", rename_all = "snake_case")]
836pub enum CaptureSkipReason {
837    /// The current phase or prediction is not selected.
838    Schedule,
839    /// A value reservation exceeded its budget.
840    Limit {
841        /// Rejected accounting dimension.
842        budget: CaptureBudget,
843        /// True for a run-total limit; false for a step limit.
844        cumulative: bool,
845    },
846}
847
848/// One result linked back to a catalog point. The enclosing generation record owns
849/// run/session identity, prediction token, phase, positions, and timing.
850#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
851pub struct CaptureRecord {
852    /// Wire schema version; unsupported versions are rejected.
853    pub schema_version: u32,
854    /// Caller identity of the selected transformation.
855    pub selection_id: String,
856    /// Exact observation selector in the retained catalog.
857    pub path: String,
858    /// Logical architecture node associated with this observation.
859    pub node_id: String,
860    /// Position relative to intervention at this path.
861    pub position: crate::ObservationPosition,
862    /// Actual source extents, absent if this point was never reached.
863    pub source_shape: Option<Vec<u64>>,
864    /// Actual extents after semantic axis slicing.
865    pub selected_shape: Option<Vec<u64>>,
866    /// Distinguishes captured, truncated, skipped, missing, and failed values.
867    pub outcome: CaptureOutcome,
868    /// Captured data; absent for skipped, missing, or failed values.
869    pub payload: Option<CapturePayload>,
870    /// Conservative reservation charged before capture. Not allocator telemetry.
871    pub charged: CaptureUsage,
872}
873
874/// Monotone per-run ledger. Reservations cannot be refunded after work begins.
875#[derive(Debug)]
876pub struct CaptureLedger {
877    limits: CaptureLimits,
878    step: CaptureUsage,
879    total: CaptureUsage,
880}
881
882impl CaptureLedger {
883    /// Creates a fresh run ledger with the admitted limits.
884    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    /// Starts an independently admitted child ledger with the usage already
892    /// consumed at its fork boundary. Inherited usage counts against the child's
893    /// cumulative limits, but not its next step. Later parent work is separate.
894    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    /// Resets step reservations while preserving cumulative accounting.
911    pub fn begin_step(&mut self) {
912        self.step = CaptureUsage::default();
913    }
914    /// Returns current step reservations.
915    pub fn step(&self) -> CaptureUsage {
916        self.step
917    }
918    /// Returns cumulative reservations.
919    pub fn total(&self) -> CaptureUsage {
920        self.total
921    }
922    /// Charges a conservative reservation before native work; skipped reservations are not charged.
923    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/// Invalid, unsupported, or oversized capture requests.
948#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
949pub enum CaptureError {
950    /// Plan or runtime tensor constraints are inconsistent.
951    #[error("invalid capture plan or tensor: {0}")]
952    Invalid(String),
953    /// The selected backend or execution cannot guarantee the requested operation.
954    #[error("capture operation unsupported: {0}")]
955    Unsupported(String),
956    /// The exact selector is absent from the retained catalog.
957    #[error("capture path absent from catalog: {0}")]
958    MissingPath(String),
959    /// Checked size arithmetic overflowed.
960    #[error("capture size arithmetic overflow")]
961    Overflow,
962    /// A per-step or cumulative reservation exceeded its limit.
963    #[error("capture {budget:?} limit exceeded (cumulative: {cumulative})")]
964    Limit {
965        /// Rejected accounting dimension.
966        budget: CaptureBudget,
967        /// True for a run-total limit; false for a step limit.
968        cumulative: bool,
969    },
970}
971
972/// Computes tensor element count using checked multiplication.
973pub fn elements(shape: &[u64]) -> Result<u64, CaptureError> {
974    // Even a shape containing zero must not conceal overflow in another extent.
975    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}
981/// Adds two size quantities with overflow rejection.
982pub fn add(a: u64, b: u64) -> Result<u64, CaptureError> {
983    a.checked_add(b).ok_or(CaptureError::Overflow)
984}
985/// Multiplies two size quantities with overflow rejection.
986pub fn mul(a: u64, b: u64) -> Result<u64, CaptureError> {
987    a.checked_mul(b).ok_or(CaptureError::Overflow)
988}
989
990/// Backend mechanism for capture. The estimator is side-effect-free and must
991/// conservatively cover all collector-owned native storage (including retained
992/// backing arrays and dependencies), host buffers/transfers, and JSON payloads.
993/// `transform` may run only after successful reservation. It must not retain the
994/// tensor beyond the call or fall back to copying an unsliced source to the host.
995pub trait CaptureBackend {
996    /// Backend-native tensor type; never crosses the host-record boundary.
997    type Tensor;
998    /// Backend transformation error.
999    type Error: std::error::Error + 'static;
1000    /// Reads tensor shape without evaluation, copying, or retaining the tensor.
1001    fn shape(&self, tensor: &Self::Tensor) -> Result<Vec<u64>, Self::Error>;
1002    /// Computes a conservative reservation without evaluating or retaining the tensor.
1003    fn estimate(
1004        &self,
1005        tensor: &Self::Tensor,
1006        selection: &CaptureSelection,
1007        slice: &ResolvedCaptureSlice,
1008    ) -> Result<CaptureUsage, CaptureError>;
1009    /// Performs the reserved transformation without retaining native handles after return.
1010    fn transform(
1011        &mut self,
1012        tensor: &Self::Tensor,
1013        selection: &CaptureSelection,
1014        slice: &ResolvedCaptureSlice,
1015    ) -> Result<CapturePayload, Self::Error>;
1016}
1017
1018/// One completed step's bounded records. Timing is measured separately from native
1019/// forward/sampling time; synchronous callback costs are included in end-to-end time.
1020#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1021pub struct CapturedStep {
1022    /// Forward phase that produced these captures.
1023    pub phase: CapturePhase,
1024    /// Run-relative prediction index; zero is predicted by prefill.
1025    pub prediction_index: u64,
1026    /// At most one record for each admitted selection.
1027    pub records: Vec<CaptureRecord>,
1028    /// Attributed intervention outcomes and optional evidence sharing these budgets.
1029    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1030    pub interventions: Vec<crate::intervention::InterventionRecord>,
1031    /// Reservations charged to this forward step.
1032    pub step_usage: CaptureUsage,
1033    /// Reservations charged since the start of this run.
1034    pub cumulative_usage: CaptureUsage,
1035    /// Wall time spent in capture transforms and capture JSON accounting.
1036    pub capture_seconds: f64,
1037}
1038
1039/// Retained discovery and exact source identity of the loaded session. Admission
1040/// uses these facts rather than accepting an unrelated caller-supplied catalog.
1041#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1042pub struct CaptureDiscovery {
1043    /// Content-exact identity of the prepared physical source graph.
1044    pub artifact_identity: String,
1045    /// Architecture-declared points retained from preparation.
1046    pub catalog: ObservationCatalog,
1047    /// Support for the execution actually realized by the session.
1048    pub support: ObservationSupportReport,
1049}