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