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