Skip to main content

candle_graph/
runtime.rs

1//! Bounded runtime evidence and gradient-audit protocol.
2//!
3//! Transport/import layer for target-side probe emissions. Schema:
4//! `candle-graph/runtime/1`. No Candle dependency.
5
6use std::collections::{BTreeMap, BTreeSet, HashSet};
7use std::fmt;
8use std::io::Write;
9
10use anyhow::{bail, Context, Result};
11use serde::{Deserialize, Serialize};
12
13/// Schema identifier written into every document and expected on parse.
14pub const SCHEMA: &str = "candle-graph/runtime/1";
15pub const SCHEMA_V2: &str = "candle-graph/runtime/2";
16pub const SCHEMA_V3: &str = "candle-graph/runtime/3";
17
18/// Build / process metadata for a single probe run.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct RunMetadata {
21    /// Analyzed or probed entrypoint, e.g. `train` or `Model::forward`.
22    pub entrypoint: String,
23    /// Cargo profile, e.g. `debug` or `release`.
24    pub profile: String,
25    /// Enabled Cargo features at probe time.
26    #[serde(default)]
27    pub cargo_features: Vec<String>,
28    /// Relevant `cfg` flags observed by the probe.
29    #[serde(default)]
30    pub cfg: Vec<String>,
31    /// Optional analysis identity this trace was captured against (e.g. model IR `analysis_id`).
32    /// Omitted for backward compatibility; when present, importers must check it.
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub analysis_id: Option<String>,
35    /// Optional build identity (package/features/profile fingerprint) for the probed binary.
36    /// Omitted for backward compatibility; when present, importers must check it.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub build_id: Option<String>,
39    /// Train vs inference phase for this trace (`train` / `infer`).
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub phase: Option<String>,
42}
43
44/// Expected analysis/build identity supplied by an importer when correlating a trace.
45///
46/// Only fields that are `Some` on **both** this value and the trace metadata are compared.
47/// Omitted optional identity on either side is compatible (backward compatible).
48#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
49pub struct ExpectedIdentity {
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub analysis_id: Option<String>,
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub build_id: Option<String>,
54}
55
56/// Which optional identity field disagreed.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
58#[serde(rename_all = "snake_case")]
59pub enum IdentityField {
60    AnalysisId,
61    BuildId,
62}
63
64impl fmt::Display for IdentityField {
65    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66        match self {
67            Self::AnalysisId => write!(f, "analysis_id"),
68            Self::BuildId => write!(f, "build_id"),
69        }
70    }
71}
72
73/// Mismatch between trace metadata and an importer-supplied expected identity.
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75pub struct IdentityMismatch {
76    pub field: IdentityField,
77    pub expected: String,
78    pub observed: String,
79}
80
81/// Confidence for a refined runtime observation.
82///
83/// Importers must not treat [`ObservationConfidence::Unknown`] conflicts as proven facts.
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
85#[serde(rename_all = "snake_case")]
86pub enum ObservationConfidence {
87    /// Observations for this identity agree.
88    Proven,
89    /// Observations disagree, identity does not match, or evidence is otherwise insufficient.
90    Unknown,
91}
92
93/// One tensor snapshot emitted by the probe.
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95pub struct TensorObservation {
96    /// Stable event identity within a trace; used for duplicate detection.
97    pub event_id: String,
98    /// Optional stable static identity shared across related observations.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub static_id: Option<String>,
101    /// Optional source location or label from the probe.
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub source: Option<String>,
104    /// Optional training step index when the trace is a time series.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub step: Option<u64>,
107    pub shape: Vec<usize>,
108    pub dtype: String,
109    pub device: String,
110    /// Whether storage is contiguous (layout fact).
111    pub contiguous: bool,
112    pub requires_grad: bool,
113    /// Optional opaque storage identity from the runtime.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub storage_id: Option<String>,
116}
117
118/// One operation observation emitted by the probe.
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120pub struct OperationObservation {
121    pub event_id: String,
122    /// Operation name / kind, e.g. `matmul`, `add`.
123    pub op: String,
124    /// Optional stable static identity for correlating with static analysis.
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub static_id: Option<String>,
127    /// Optional source location or label.
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub source: Option<String>,
130    /// Input tensor event or storage ids observed at the call.
131    #[serde(default)]
132    pub inputs: Vec<String>,
133    /// Output tensor event or storage id, when known.
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub output: Option<String>,
136    /// Optional training step index when the trace is a time series.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub step: Option<u64>,
139    /// Wall time spent in this operation (nanoseconds), when profiled.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub duration_ns: Option<u64>,
142}
143
144/// Observed data-flow edge timing between two static ids (profiler output).
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146pub struct EdgeTimingObservation {
147    pub event_id: String,
148    pub from_static_id: String,
149    pub to_static_id: String,
150    pub duration_ns: u64,
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub step: Option<u64>,
153}
154
155/// Builder-root + parameter-key identity for gradient facts.
156#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
157pub struct ParamIdentity {
158    /// Builder root namespace (e.g. `vb`), matching static analysis roots.
159    pub root: String,
160    /// Parameter key under that root.
161    pub key: String,
162}
163
164/// Observed gradient presence / quality for one parameter.
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
166#[serde(rename_all = "snake_case")]
167pub enum GradientState {
168    Present,
169    Missing,
170    Zero,
171    NonFinite,
172}
173
174impl fmt::Display for GradientState {
175    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
176        match self {
177            Self::Present => write!(f, "present"),
178            Self::Missing => write!(f, "missing"),
179            Self::Zero => write!(f, "zero"),
180            Self::NonFinite => write!(f, "non_finite"),
181        }
182    }
183}
184
185/// Per-parameter gradient fact keyed by builder root + parameter key.
186#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
187pub struct GradientFact {
188    pub event_id: String,
189    pub root: String,
190    pub key: String,
191    pub state: GradientState,
192    /// Optional training step index when the trace is a time series.
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub step: Option<u64>,
195    /// Optional L2 (or probe-defined) norm; must be consistent with [`GradientState`].
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub norm: Option<f64>,
198}
199
200impl GradientFact {
201    pub fn identity(&self) -> ParamIdentity {
202        ParamIdentity {
203            root: self.root.clone(),
204            key: self.key.clone(),
205        }
206    }
207
208    /// Returns an error when state and optional norm contradict each other.
209    pub fn validate(&self) -> Result<()> {
210        match self.state {
211            GradientState::Missing => {
212                if self.norm.is_some() {
213                    bail!(
214                        "invalid gradient fact {}:{}: missing state must not carry a norm",
215                        self.root,
216                        self.key
217                    );
218                }
219            }
220            GradientState::Zero => {
221                if let Some(n) = self.norm {
222                    if n != 0.0 || !n.is_finite() {
223                        bail!(
224                            "invalid gradient fact {}:{}: zero state requires norm 0.0 when set, got {n}",
225                            self.root,
226                            self.key
227                        );
228                    }
229                }
230            }
231            GradientState::Present => {
232                if let Some(n) = self.norm {
233                    if !n.is_finite() {
234                        bail!(
235                            "invalid gradient fact {}:{}: present state cannot have non-finite norm",
236                            self.root,
237                            self.key
238                        );
239                    }
240                    if n == 0.0 {
241                        bail!(
242                            "invalid gradient fact {}:{}: present state cannot have zero norm (use zero)",
243                            self.root,
244                            self.key
245                        );
246                    }
247                }
248            }
249            GradientState::NonFinite => {
250                if let Some(n) = self.norm {
251                    if n.is_finite() {
252                        bail!(
253                            "invalid gradient fact {}:{}: non_finite state cannot have finite norm {n}",
254                            self.root,
255                            self.key
256                        );
257                    }
258                }
259            }
260        }
261        Ok(())
262    }
263}
264
265/// Forward value-range observation for numeric-domain runtime audits.
266#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
267pub struct ValueObservation {
268    pub event_id: String,
269    #[serde(default, skip_serializing_if = "Option::is_none")]
270    pub static_id: Option<String>,
271    #[serde(default, skip_serializing_if = "Option::is_none")]
272    pub source: Option<String>,
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub step: Option<u64>,
275    pub min: f64,
276    pub max: f64,
277    pub abs_max: f64,
278    #[serde(default)]
279    pub nonfinite_count: u64,
280    #[serde(default)]
281    pub saturated_count: u64,
282}
283
284/// Full runtime trace document (`candle-graph/runtime/1` or `/2`).
285#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
286pub struct RuntimeTrace {
287    pub schema: String,
288    pub run: RunMetadata,
289    #[serde(default)]
290    pub tensors: Vec<TensorObservation>,
291    #[serde(default)]
292    pub operations: Vec<OperationObservation>,
293    #[serde(default)]
294    pub gradients: Vec<GradientFact>,
295    #[serde(default)]
296    pub values: Vec<ValueObservation>,
297    #[serde(default)]
298    pub edge_timings: Vec<EdgeTimingObservation>,
299}
300
301/// JSONL / streaming event records that assemble into a [`RuntimeTrace`].
302#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
303#[serde(tag = "kind", rename_all = "snake_case")]
304pub enum RuntimeEvent {
305    /// Declares schema + run metadata (at most one per stream).
306    Meta {
307        schema: String,
308        #[serde(flatten)]
309        run: RunMetadata,
310    },
311    Tensor(TensorObservation),
312    Operation(OperationObservation),
313    Gradient(GradientFact),
314    Value(ValueObservation),
315    EdgeTiming(EdgeTimingObservation),
316}
317
318/// Streaming JSONL emitter for instrumented CPU or synthetic rollouts.
319///
320/// The writer emits metadata immediately, rejects duplicate event ids before writing an event,
321/// and uses the same validation rules as the importer. Tensor callers should populate
322/// [`TensorObservation::static_id`] with an id copied from the model IR or a `tensor` query.
323pub struct RuntimeTraceWriter<W: Write> {
324    writer: W,
325    seen_event_ids: HashSet<String>,
326}
327
328impl<W: Write> RuntimeTraceWriter<W> {
329    /// Start a JSONL stream by writing its required metadata event (schema v1).
330    pub fn new(writer: W, run: RunMetadata) -> Result<Self> {
331        Self::new_with_schema(writer, SCHEMA, run)
332    }
333
334    /// Start a JSONL stream with an explicit schema (`/1`, `/2`, or `/3`).
335    pub fn new_with_schema(writer: W, schema: &str, run: RunMetadata) -> Result<Self> {
336        let mut output = Self {
337            writer,
338            seen_event_ids: HashSet::new(),
339        };
340        output.write_event(&RuntimeEvent::Meta {
341            schema: schema.to_string(),
342            run,
343        })?;
344        Ok(output)
345    }
346
347    /// Emit one tensor observation. `static_id` is the static/runtime correlation key.
348    pub fn tensor(&mut self, observation: TensorObservation) -> Result<()> {
349        self.reserve_event_id(&observation.event_id)?;
350        self.write_event(&RuntimeEvent::Tensor(observation))
351    }
352
353    /// Emit one operation observation.
354    pub fn operation(&mut self, observation: OperationObservation) -> Result<()> {
355        self.reserve_event_id(&observation.event_id)?;
356        self.write_event(&RuntimeEvent::Operation(observation))
357    }
358
359    /// Emit one parameter-gradient fact after checking state/norm consistency.
360    pub fn gradient(&mut self, fact: GradientFact) -> Result<()> {
361        fact.validate()?;
362        self.reserve_event_id(&fact.event_id)?;
363        self.write_event(&RuntimeEvent::Gradient(fact))
364    }
365
366    /// Emit one forward value-range observation.
367    pub fn value(&mut self, observation: ValueObservation) -> Result<()> {
368        self.reserve_event_id(&observation.event_id)?;
369        self.write_event(&RuntimeEvent::Value(observation))
370    }
371
372    /// Emit one data-flow edge timing observation (runtime v3).
373    pub fn edge_timing(&mut self, observation: EdgeTimingObservation) -> Result<()> {
374        self.reserve_event_id(&observation.event_id)?;
375        self.write_event(&RuntimeEvent::EdgeTiming(observation))
376    }
377
378    pub fn flush(&mut self) -> Result<()> {
379        self.writer.flush().context("flushing runtime JSONL trace")
380    }
381
382    /// Flush the stream and return the underlying writer.
383    pub fn finish(mut self) -> Result<W> {
384        self.writer
385            .flush()
386            .context("flushing runtime JSONL trace")?;
387        Ok(self.writer)
388    }
389
390    fn reserve_event_id(&mut self, event_id: &str) -> Result<()> {
391        if event_id.is_empty() {
392            bail!("empty event_id is not allowed");
393        }
394        if !self.seen_event_ids.insert(event_id.to_string()) {
395            bail!("duplicate event_id `{event_id}`");
396        }
397        Ok(())
398    }
399
400    fn write_event(&mut self, event: &RuntimeEvent) -> Result<()> {
401        let mut line = serde_json::to_vec(event).context("serializing runtime JSONL event")?;
402        line.push(b'\n');
403        self.writer
404            .write_all(&line)
405            .context("writing runtime JSONL event")
406    }
407}
408
409/// Which tensor attribute disagreed across observations sharing a `static_id`.
410#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
411#[serde(rename_all = "snake_case")]
412pub enum TensorConflictKind {
413    Dtype,
414    Device,
415    Shape,
416    Layout,
417    RequiresGrad,
418}
419
420fn unknown_confidence() -> ObservationConfidence {
421    ObservationConfidence::Unknown
422}
423
424/// Conflict among repeated tensor observations for one static id.
425#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
426pub struct TensorConflict {
427    pub static_id: String,
428    pub kind: TensorConflictKind,
429    /// Distinct observed values, sorted.
430    pub values: Vec<String>,
431    /// Always [`ObservationConfidence::Unknown`]: do not refine as proven.
432    #[serde(default = "unknown_confidence")]
433    pub confidence: ObservationConfidence,
434}
435
436/// Conflict among gradient facts for one parameter identity.
437#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
438pub struct GradientConflict {
439    pub identity: ParamIdentity,
440    /// Distinct observed states, sorted.
441    pub states: Vec<String>,
442    /// Event ids that participated, sorted.
443    pub event_ids: Vec<String>,
444    /// Always [`ObservationConfidence::Unknown`]: do not pick a winning state.
445    #[serde(default = "unknown_confidence")]
446    pub confidence: ObservationConfidence,
447}
448
449/// Compact audit summary over a normalized trace.
450#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
451pub struct RuntimeAudit {
452    pub missing_gradients: Vec<ParamIdentity>,
453    pub non_finite_gradients: Vec<ParamIdentity>,
454    pub zero_gradients: Vec<ParamIdentity>,
455    #[serde(default)]
456    pub tensor_conflicts: Vec<TensorConflict>,
457    #[serde(default)]
458    pub gradient_conflicts: Vec<GradientConflict>,
459    #[serde(default)]
460    pub identity_mismatches: Vec<IdentityMismatch>,
461    /// First step at which any parameter gradient was `non_finite`, when steps are present.
462    #[serde(default, skip_serializing_if = "Option::is_none")]
463    pub first_non_finite_step: Option<u64>,
464    /// Parameter identities whose observed forward `abs_max` crossed a saturation threshold.
465    #[serde(default)]
466    pub saturating_activations: Vec<ParamIdentity>,
467}
468
469impl RuntimeAudit {
470    pub fn is_clean(&self) -> bool {
471        self.missing_gradients.is_empty()
472            && self.non_finite_gradients.is_empty()
473            && self.zero_gradients.is_empty()
474            && self.tensor_conflicts.is_empty()
475            && self.gradient_conflicts.is_empty()
476            && self.identity_mismatches.is_empty()
477    }
478
479    /// True when any conflict or identity mismatch requires Unknown (not Proven) treatment.
480    pub fn has_unknown_evidence(&self) -> bool {
481        !self.tensor_conflicts.is_empty()
482            || !self.gradient_conflicts.is_empty()
483            || !self.identity_mismatches.is_empty()
484    }
485}
486
487impl RuntimeTrace {
488    /// Sort collections deterministically for stable diffs and audits.
489    pub fn normalize(&mut self) {
490        self.run.cargo_features.sort();
491        self.run.cargo_features.dedup();
492        self.run.cfg.sort();
493        self.run.cfg.dedup();
494
495        self.tensors.sort_by(|a, b| {
496            a.event_id
497                .cmp(&b.event_id)
498                .then_with(|| a.static_id.cmp(&b.static_id))
499                .then_with(|| a.source.cmp(&b.source))
500        });
501        self.operations.sort_by(|a, b| {
502            a.event_id
503                .cmp(&b.event_id)
504                .then_with(|| a.op.cmp(&b.op))
505                .then_with(|| a.static_id.cmp(&b.static_id))
506        });
507        self.gradients.sort_by(|a, b| {
508            a.root
509                .cmp(&b.root)
510                .then_with(|| a.key.cmp(&b.key))
511                .then_with(|| a.step.cmp(&b.step))
512                .then_with(|| a.event_id.cmp(&b.event_id))
513        });
514        self.values.sort_by(|a, b| {
515            a.event_id
516                .cmp(&b.event_id)
517                .then_with(|| a.step.cmp(&b.step))
518                .then_with(|| a.source.cmp(&b.source))
519        });
520        self.edge_timings.sort_by(|a, b| {
521            a.from_static_id
522                .cmp(&b.from_static_id)
523                .then_with(|| a.to_static_id.cmp(&b.to_static_id))
524                .then_with(|| a.step.cmp(&b.step))
525                .then_with(|| a.event_id.cmp(&b.event_id))
526        });
527    }
528
529    /// Reject duplicate event ids and invalid gradient facts.
530    ///
531    /// Contradictory tensor/gradient *observations* are not rejected here; they are reported by
532    /// [`Self::audit`] as Unknown conflicts so importers never silently overwrite a winner.
533    pub fn validate(&self) -> Result<()> {
534        if self.schema != SCHEMA && self.schema != SCHEMA_V2 && self.schema != SCHEMA_V3 {
535            bail!(
536                "unsupported runtime schema {:?}; expected {:?}, {:?}, or {:?}",
537                self.schema,
538                SCHEMA,
539                SCHEMA_V2,
540                SCHEMA_V3
541            );
542        }
543
544        let mut seen = HashSet::new();
545        let mut push_id = |id: &str| -> Result<()> {
546            if id.is_empty() {
547                bail!("empty event_id is not allowed");
548            }
549            if !seen.insert(id.to_string()) {
550                bail!("duplicate event_id `{id}`");
551            }
552            Ok(())
553        };
554
555        for t in &self.tensors {
556            push_id(&t.event_id)?;
557        }
558        for op in &self.operations {
559            push_id(&op.event_id)?;
560        }
561        for g in &self.gradients {
562            push_id(&g.event_id)?;
563            g.validate()?;
564        }
565        for v in &self.values {
566            push_id(&v.event_id)?;
567        }
568        for edge in &self.edge_timings {
569            push_id(&edge.event_id)?;
570        }
571        Ok(())
572    }
573
574    /// Normalize then validate; returns a ready-to-query trace.
575    pub fn finalize(mut self) -> Result<Self> {
576        self.normalize();
577        self.validate()?;
578        Ok(self)
579    }
580
581    /// Compare optional trace identity fields against an importer-supplied expectation.
582    ///
583    /// Fields omitted on either side are skipped (backward compatible). When both sides supply a
584    /// field and they differ, a mismatch is returned — callers must reject or report it as Unknown.
585    pub fn check_identity(&self, expected: &ExpectedIdentity) -> Vec<IdentityMismatch> {
586        let mut out = Vec::new();
587        if let (Some(expected_id), Some(observed)) = (
588            expected.analysis_id.as_deref(),
589            self.run.analysis_id.as_deref(),
590        ) {
591            if expected_id != observed {
592                out.push(IdentityMismatch {
593                    field: IdentityField::AnalysisId,
594                    expected: expected_id.to_string(),
595                    observed: observed.to_string(),
596                });
597            }
598        }
599        if let (Some(expected_id), Some(observed)) =
600            (expected.build_id.as_deref(), self.run.build_id.as_deref())
601        {
602            if expected_id != observed {
603                out.push(IdentityMismatch {
604                    field: IdentityField::BuildId,
605                    expected: expected_id.to_string(),
606                    observed: observed.to_string(),
607                });
608            }
609        }
610        out
611    }
612
613    /// Reject when supplied identity disagrees with `expected`.
614    ///
615    /// Compatible when either side omits a field.
616    pub fn require_identity(&self, expected: &ExpectedIdentity) -> Result<()> {
617        let mismatches = self.check_identity(expected);
618        if let Some(first) = mismatches.first() {
619            bail!(
620                "runtime {} mismatch: expected {:?}, observed {:?}",
621                first.field,
622                first.expected,
623                first.observed
624            );
625        }
626        Ok(())
627    }
628
629    /// All tensor observations carrying `static_id`.
630    pub fn tensors_by_static_id(&self, static_id: &str) -> Vec<&TensorObservation> {
631        self.tensors
632            .iter()
633            .filter(|t| t.static_id.as_deref() == Some(static_id))
634            .collect()
635    }
636
637    /// Agreed tensor observation for `static_id`, if any.
638    ///
639    /// Returns `None` when there are no observations or when shape/dtype/device conflict — never
640    /// silently picks a contradictory winner.
641    pub fn agreed_tensor(&self, static_id: &str) -> Option<&TensorObservation> {
642        let obs = self.tensors_by_static_id(static_id);
643        if obs.is_empty() {
644            return None;
645        }
646        let first = obs[0];
647        for other in &obs[1..] {
648            if other.shape != first.shape
649                || other.dtype != first.dtype
650                || other.device != first.device
651                || other.contiguous != first.contiguous
652                || other.requires_grad != first.requires_grad
653            {
654                return None;
655            }
656        }
657        Some(first)
658    }
659
660    /// Confidence for refining a static tensor from this trace.
661    pub fn tensor_confidence(&self, static_id: &str) -> ObservationConfidence {
662        match self.agreed_tensor(static_id) {
663            Some(_) => ObservationConfidence::Proven,
664            None => ObservationConfidence::Unknown,
665        }
666    }
667
668    /// Gradient facts for a builder-root + parameter-key identity.
669    pub fn gradients_for(&self, root: &str, key: &str) -> Vec<&GradientFact> {
670        self.gradients
671            .iter()
672            .filter(|g| g.root == root && g.key == key)
673            .collect()
674    }
675
676    /// Agreed gradient fact for the identity, if any.
677    ///
678    /// Returns `None` when facts are missing or contradictory (state/norm disagree). Does not
679    /// silently overwrite by returning the first observation.
680    pub fn gradient(&self, root: &str, key: &str) -> Option<&GradientFact> {
681        let facts = self.gradients_for(root, key);
682        agreed_gradient(&facts)
683    }
684
685    /// Confidence for refining a parameter gradient from this trace.
686    pub fn gradient_confidence(&self, root: &str, key: &str) -> ObservationConfidence {
687        match self.gradient(root, key) {
688            Some(_) => ObservationConfidence::Proven,
689            None => ObservationConfidence::Unknown,
690        }
691    }
692
693    /// Compact audit: gradient quality, tensor/gradient conflicts, optional identity mismatches.
694    pub fn audit(&self) -> RuntimeAudit {
695        self.audit_with_identity(None)
696    }
697
698    /// Like [`Self::audit`], also reporting identity mismatches when `expected` is supplied.
699    pub fn audit_with_identity(&self, expected: Option<&ExpectedIdentity>) -> RuntimeAudit {
700        let gradient_conflicts = self.collect_gradient_conflicts();
701        let conflicted: HashSet<ParamIdentity> = gradient_conflicts
702            .iter()
703            .map(|c| c.identity.clone())
704            .collect();
705
706        let mut missing = BTreeSet::new();
707        let mut non_finite = BTreeSet::new();
708        let mut zero = BTreeSet::new();
709
710        // Only classify agreed (non-conflicted) gradient identities.
711        let mut by_param: BTreeMap<ParamIdentity, Vec<&GradientFact>> = BTreeMap::new();
712        for g in &self.gradients {
713            by_param.entry(g.identity()).or_default().push(g);
714        }
715        for (id, facts) in by_param {
716            if conflicted.contains(&id) {
717                continue;
718            }
719            let Some(g) = latest_agreed_gradient(&facts).or_else(|| agreed_gradient(&facts)) else {
720                continue;
721            };
722            match g.state {
723                GradientState::Missing => {
724                    missing.insert(id);
725                }
726                GradientState::NonFinite => {
727                    non_finite.insert(id);
728                }
729                GradientState::Zero => {
730                    zero.insert(id);
731                }
732                GradientState::Present => {}
733            }
734        }
735
736        let mut by_static: BTreeMap<&str, Vec<&TensorObservation>> = BTreeMap::new();
737        for t in &self.tensors {
738            if let Some(sid) = t.static_id.as_deref() {
739                by_static.entry(sid).or_default().push(t);
740            }
741        }
742
743        let mut conflicts = Vec::new();
744        for (static_id, obs) in by_static {
745            if obs.len() < 2 {
746                continue;
747            }
748            push_conflict(
749                &mut conflicts,
750                static_id,
751                TensorConflictKind::Dtype,
752                |t| t.dtype.clone(),
753                &obs,
754            );
755            push_conflict(
756                &mut conflicts,
757                static_id,
758                TensorConflictKind::Device,
759                |t| t.device.clone(),
760                &obs,
761            );
762            push_conflict(
763                &mut conflicts,
764                static_id,
765                TensorConflictKind::Shape,
766                |t| format_shape(&t.shape),
767                &obs,
768            );
769            push_conflict(
770                &mut conflicts,
771                static_id,
772                TensorConflictKind::Layout,
773                |t| t.contiguous.to_string(),
774                &obs,
775            );
776            push_conflict(
777                &mut conflicts,
778                static_id,
779                TensorConflictKind::RequiresGrad,
780                |t| t.requires_grad.to_string(),
781                &obs,
782            );
783        }
784        conflicts.sort_by(|a, b| {
785            a.static_id
786                .cmp(&b.static_id)
787                .then_with(|| a.kind.cmp(&b.kind))
788        });
789
790        let identity_mismatches = expected
791            .map(|expected| self.check_identity(expected))
792            .unwrap_or_default();
793
794        let first_non_finite_step = self
795            .gradients
796            .iter()
797            .filter(|g| matches!(g.state, GradientState::NonFinite))
798            .filter_map(|g| g.step)
799            .min();
800
801        let mut saturating = BTreeSet::new();
802        for value in &self.values {
803            if value.saturated_count == 0 {
804                continue;
805            }
806            if let Some(source) = &value.source {
807                saturating.insert(ParamIdentity {
808                    root: "value".into(),
809                    key: source.clone(),
810                });
811            }
812        }
813
814        RuntimeAudit {
815            missing_gradients: missing.into_iter().collect(),
816            non_finite_gradients: non_finite.into_iter().collect(),
817            zero_gradients: zero.into_iter().collect(),
818            tensor_conflicts: conflicts,
819            gradient_conflicts,
820            identity_mismatches,
821            first_non_finite_step,
822            saturating_activations: saturating.into_iter().collect(),
823        }
824    }
825
826    fn collect_gradient_conflicts(&self) -> Vec<GradientConflict> {
827        let mut by_param: BTreeMap<ParamIdentity, Vec<&GradientFact>> = BTreeMap::new();
828        for g in &self.gradients {
829            by_param.entry(g.identity()).or_default().push(g);
830        }
831        let mut out = Vec::new();
832        for (identity, facts) in by_param {
833            if facts.len() < 2 {
834                continue;
835            }
836            // Distinct steps with one observation each are a time series, not a conflict.
837            if facts.iter().all(|g| g.step.is_some()) {
838                let steps: BTreeSet<_> = facts.iter().filter_map(|g| g.step).collect();
839                if steps.len() == facts.len() {
840                    continue;
841                }
842            }
843            if latest_agreed_gradient(&facts).is_some() || agreed_gradient(&facts).is_some() {
844                continue;
845            }
846            let mut states: BTreeSet<String> = BTreeSet::new();
847            let mut event_ids: BTreeSet<String> = BTreeSet::new();
848            for g in &facts {
849                states.insert(g.state.to_string());
850                event_ids.insert(g.event_id.clone());
851            }
852            // Distinct norms with same state also conflict (e.g. two present norms).
853            let mut norms: BTreeSet<String> = BTreeSet::new();
854            for g in &facts {
855                norms.insert(match g.norm {
856                    Some(n) => format!("{n}"),
857                    None => "none".to_string(),
858                });
859            }
860            if states.len() <= 1 && norms.len() <= 1 {
861                continue;
862            }
863            out.push(GradientConflict {
864                identity,
865                states: states.into_iter().collect(),
866                event_ids: event_ids.into_iter().collect(),
867                confidence: ObservationConfidence::Unknown,
868            });
869        }
870        out.sort_by(|a, b| a.identity.cmp(&b.identity));
871        out
872    }
873}
874
875/// Returns the sole agreed fact when all entries share state and norm; otherwise `None`.
876fn agreed_gradient<'a>(facts: &[&'a GradientFact]) -> Option<&'a GradientFact> {
877    let first = facts.first().copied()?;
878    for other in facts.iter().skip(1) {
879        if other.state != first.state {
880            return None;
881        }
882        match (first.norm, other.norm) {
883            (None, None) => {}
884            (Some(a), Some(b)) if float_eq(a, b) => {}
885            (None, Some(_)) | (Some(_), None) => return None,
886            (Some(_), Some(_)) => return None,
887        }
888    }
889    Some(first)
890}
891
892/// When every fact carries a step, return the latest-step observation (time-series rollup).
893fn latest_agreed_gradient<'a>(facts: &[&'a GradientFact]) -> Option<&'a GradientFact> {
894    if facts.is_empty() || !facts.iter().all(|g| g.step.is_some()) {
895        return None;
896    }
897    facts.iter().copied().max_by_key(|g| g.step.unwrap_or(0))
898}
899
900fn float_eq(a: f64, b: f64) -> bool {
901    if a.is_nan() && b.is_nan() {
902        return true;
903    }
904    a == b
905}
906
907fn format_shape(shape: &[usize]) -> String {
908    format!(
909        "[{}]",
910        shape
911            .iter()
912            .map(|d| d.to_string())
913            .collect::<Vec<_>>()
914            .join(", ")
915    )
916}
917
918fn push_conflict(
919    out: &mut Vec<TensorConflict>,
920    static_id: &str,
921    kind: TensorConflictKind,
922    project: impl Fn(&TensorObservation) -> String,
923    obs: &[&TensorObservation],
924) {
925    let mut values: BTreeSet<String> = BTreeSet::new();
926    for t in obs {
927        values.insert(project(t));
928    }
929    if values.len() > 1 {
930        out.push(TensorConflict {
931            static_id: static_id.to_string(),
932            kind,
933            values: values.into_iter().collect(),
934            confidence: ObservationConfidence::Unknown,
935        });
936    }
937}
938
939/// Parse a single JSON document into a validated, normalized trace.
940pub fn parse_json(input: &str) -> Result<RuntimeTrace> {
941    let trace: RuntimeTrace =
942        serde_json::from_str(input).context("failed to parse runtime JSON document")?;
943    trace.finalize()
944}
945
946/// Parse JSONL event records into a validated, normalized trace.
947pub fn parse_jsonl(input: &str) -> Result<RuntimeTrace> {
948    let mut schema: Option<String> = None;
949    let mut run: Option<RunMetadata> = None;
950    let mut tensors = Vec::new();
951    let mut operations = Vec::new();
952    let mut gradients = Vec::new();
953    let mut values = Vec::new();
954    let mut edge_timings = Vec::new();
955
956    for (line_no, line) in input.lines().enumerate() {
957        let line = line.trim();
958        if line.is_empty() {
959            continue;
960        }
961        let event: RuntimeEvent = serde_json::from_str(line)
962            .with_context(|| format!("failed to parse runtime JSONL line {}", line_no + 1))?;
963        match event {
964            RuntimeEvent::Meta {
965                schema: s,
966                run: meta,
967            } => {
968                if schema.is_some() || run.is_some() {
969                    bail!(
970                        "duplicate meta event on JSONL line {}; only one meta record is allowed",
971                        line_no + 1
972                    );
973                }
974                schema = Some(s);
975                run = Some(meta);
976            }
977            RuntimeEvent::Tensor(t) => tensors.push(t),
978            RuntimeEvent::Operation(op) => operations.push(op),
979            RuntimeEvent::Gradient(g) => gradients.push(g),
980            RuntimeEvent::Value(v) => values.push(v),
981            RuntimeEvent::EdgeTiming(edge) => edge_timings.push(edge),
982        }
983    }
984
985    let schema = schema.unwrap_or_else(|| SCHEMA.to_string());
986    let run = run.context("JSONL stream is missing a meta event with run metadata")?;
987
988    RuntimeTrace {
989        schema,
990        run,
991        tensors,
992        operations,
993        gradients,
994        values,
995        edge_timings,
996    }
997    .finalize()
998}
999
1000/// Auto-detect a single JSON object versus JSONL event records.
1001pub fn parse(input: &str) -> Result<RuntimeTrace> {
1002    let trimmed = input.trim();
1003    if trimmed.is_empty() {
1004        bail!("empty runtime evidence input");
1005    }
1006    // A document starts with `{` and is a single JSON value; JSONL is line-oriented events.
1007    if trimmed.starts_with('{') && !trimmed.contains('\n') {
1008        return parse_json(trimmed);
1009    }
1010    if trimmed.starts_with('{') {
1011        // Multi-line JSON document vs JSONL: try document first when the whole buffer is one value.
1012        if let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) {
1013            if value.is_object() && value.get("schema").is_some() && value.get("run").is_some() {
1014                let trace: RuntimeTrace = serde_json::from_value(value)
1015                    .context("failed to parse runtime JSON document")?;
1016                return trace.finalize();
1017            }
1018        }
1019    }
1020    parse_jsonl(input)
1021}