1use std::collections::{BTreeMap, BTreeSet};
11use std::fs;
12use std::path::{Component, Path, PathBuf};
13
14use anyhow::{bail, Context, Result};
15use serde::{Deserialize, Serialize};
16
17use crate::artifact::verify_bundle;
18use crate::trace::schema::GradientState;
19use crate::trace::{parse_trace, TraceDocument};
20
21pub const CAMPAIGN_SCHEMA: &str = "candle-graph/campaign/1";
22pub const CAMPAIGN_STATUS_SCHEMA: &str = "candle-graph/campaign-status/1";
23pub const SERIES_SCHEMA: &str = "candle-graph/series/1";
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct PlannedCapture {
29 pub capture_step: u64,
30 pub bundle: String,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct CampaignManifest {
36 pub schema: String,
37 pub campaign_id: String,
38 pub entrypoint: String,
39 pub planned: Vec<PlannedCapture>,
40}
41
42impl CampaignManifest {
43 pub fn load(path: &Path) -> Result<Self> {
45 let bytes =
46 fs::read(path).with_context(|| format!("read campaign manifest {}", path.display()))?;
47 let manifest: Self = serde_json::from_slice(&bytes)
48 .with_context(|| format!("parse campaign manifest {}", path.display()))?;
49 manifest
50 .validate()
51 .with_context(|| format!("validate campaign manifest {}", path.display()))?;
52 Ok(manifest)
53 }
54
55 pub fn validate(&self) -> Result<()> {
57 if self.schema != CAMPAIGN_SCHEMA {
58 bail!(
59 "unsupported campaign schema {:?}; expected {CAMPAIGN_SCHEMA:?}",
60 self.schema
61 );
62 }
63 if self.campaign_id.trim().is_empty() {
64 bail!("campaign manifest requires a non-empty campaign_id");
65 }
66 if self.entrypoint.trim().is_empty() {
67 bail!("campaign manifest requires a non-empty entrypoint");
68 }
69 if self.planned.is_empty() {
70 bail!("campaign manifest requires at least one planned capture");
71 }
72 let mut steps = BTreeSet::new();
73 let mut bundles = BTreeSet::new();
74 for capture in &self.planned {
75 if !steps.insert(capture.capture_step) {
76 bail!(
77 "campaign manifest plans duplicate capture_step {}",
78 capture.capture_step
79 );
80 }
81 if !is_safe_relative_path(&capture.bundle) {
82 bail!(
83 "campaign manifest has an unsafe bundle path {:?}; \
84 bundle paths must be non-empty relative paths without `..` or backslashes",
85 capture.bundle
86 );
87 }
88 if !bundles.insert(capture.bundle.as_str()) {
89 bail!(
90 "campaign manifest plans duplicate bundle path {:?}",
91 capture.bundle
92 );
93 }
94 }
95 Ok(())
96 }
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(tag = "state", rename_all = "snake_case")]
102pub enum CaptureState {
103 Missing,
104 Published {
105 run_id: String,
106 manifest_sha256: String,
107 },
108 FailedRun {
109 run_id: String,
110 reason: Option<String>,
111 },
112 VerificationFailed {
113 message: String,
114 },
115 IdentityMismatch {
116 message: String,
117 },
118}
119
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122pub struct CaptureStatus {
123 pub capture_step: u64,
124 pub bundle: String,
125 pub state: CaptureState,
126}
127
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130pub struct CampaignStatus {
131 pub schema: String,
132 pub campaign_id: String,
133 pub entrypoint: String,
134 pub planned: usize,
135 pub published: usize,
136 pub missing: usize,
137 pub failed: usize,
138 pub captures: Vec<CaptureStatus>,
139}
140
141pub fn campaign_status(manifest_path: &Path) -> Result<CampaignStatus> {
145 let manifest = CampaignManifest::load(manifest_path)?;
146 let base = manifest_path.parent().unwrap_or_else(|| Path::new("."));
147
148 let mut captures = Vec::with_capacity(manifest.planned.len());
149 for planned in &manifest.planned {
150 let root = base.join(&planned.bundle);
151 let state = reconcile_capture(&root, &manifest.entrypoint, planned.capture_step);
152 captures.push(CaptureStatus {
153 capture_step: planned.capture_step,
154 bundle: planned.bundle.clone(),
155 state,
156 });
157 }
158 captures.sort_by_key(|capture| capture.capture_step);
159
160 let published = captures
161 .iter()
162 .filter(|capture| matches!(capture.state, CaptureState::Published { .. }))
163 .count();
164 let missing = captures
165 .iter()
166 .filter(|capture| matches!(capture.state, CaptureState::Missing))
167 .count();
168 let failed = captures.len() - published - missing;
169
170 Ok(CampaignStatus {
171 schema: CAMPAIGN_STATUS_SCHEMA.into(),
172 campaign_id: manifest.campaign_id,
173 entrypoint: manifest.entrypoint,
174 planned: captures.len(),
175 published,
176 missing,
177 failed,
178 captures,
179 })
180}
181
182fn reconcile_capture(root: &Path, entrypoint: &str, capture_step: u64) -> CaptureState {
183 if !root.exists() {
184 return CaptureState::Missing;
185 }
186 let receipt = match verify_bundle(root) {
187 Ok(receipt) => receipt,
188 Err(error) => {
189 return CaptureState::VerificationFailed {
190 message: format!("{error:#}"),
191 }
192 }
193 };
194 let document = match parse_trace(root.join("trace.jsonl")) {
195 Ok(document) => document,
196 Err(error) => {
197 return CaptureState::VerificationFailed {
198 message: format!("{error:#}"),
199 }
200 }
201 };
202 if document.run.entrypoint != entrypoint {
203 return CaptureState::IdentityMismatch {
204 message: format!(
205 "entrypoint mismatch: manifest declares {:?}, bundle trace observes {:?}",
206 entrypoint, document.run.entrypoint
207 ),
208 };
209 }
210 if document.run.capture_step != capture_step {
211 return CaptureState::IdentityMismatch {
212 message: format!(
213 "capture_step mismatch: manifest plans {}, bundle trace observes {}",
214 capture_step, document.run.capture_step
215 ),
216 };
217 }
218 match document.terminal.outcome {
219 crate::trace::RunOutcome::Failed => CaptureState::FailedRun {
220 run_id: document.run.run_id,
221 reason: document.terminal.reason,
222 },
223 crate::trace::RunOutcome::Complete => CaptureState::Published {
224 run_id: document.run.run_id,
225 manifest_sha256: receipt.manifest_sha256,
226 },
227 }
228}
229
230#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
232pub struct SeriesInput {
233 pub capture_step: u64,
234 pub run_id: String,
235 pub bundle: String,
236 pub manifest_sha256: String,
237}
238
239#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
242pub struct TimingSeriesPoint {
243 pub capture_step: u64,
244 pub run_id: String,
245 pub outer_wall_time_ns: Option<u64>,
246}
247
248#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
250pub struct ScalarSeriesPoint {
251 pub capture_step: u64,
252 pub run_id: String,
253 pub rms: f64,
254 pub abs_max: f64,
255 pub mean: f64,
256 pub non_finite: u64,
257 pub elements: u64,
258}
259
260#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
262pub struct GradientSeriesPoint {
263 pub capture_step: u64,
264 pub run_id: String,
265 pub state: GradientState,
266 pub norm: Option<f64>,
267}
268
269#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
271pub struct SeriesReport {
272 pub schema: String,
273 pub entrypoint: String,
274 pub coordinate: String,
275 pub label_prefix: Option<String>,
276 pub inputs: Vec<SeriesInput>,
277 pub outer_wall_time: Vec<TimingSeriesPoint>,
278 pub tensor_stats: BTreeMap<String, Vec<ScalarSeriesPoint>>,
279 pub gradients: BTreeMap<String, Vec<GradientSeriesPoint>>,
280}
281
282pub fn build_series(bundle_roots: &[PathBuf], label_prefix: Option<&str>) -> Result<SeriesReport> {
289 if bundle_roots.is_empty() {
290 bail!("series requires at least one bundle");
291 }
292
293 struct SeriesEntry {
294 bundle: String,
295 manifest_sha256: String,
296 document: TraceDocument,
297 }
298
299 let mut entries = Vec::with_capacity(bundle_roots.len());
300 for root in bundle_roots {
301 let receipt = verify_bundle(root)
302 .with_context(|| format!("verify series bundle {}", root.display()))?;
303 let document = parse_trace(root.join("trace.jsonl"))
304 .with_context(|| format!("parse trace of series bundle {}", root.display()))?;
305 entries.push(SeriesEntry {
306 bundle: root.display().to_string(),
307 manifest_sha256: receipt.manifest_sha256,
308 document,
309 });
310 }
311
312 let entrypoint = entries[0].document.run.entrypoint.clone();
313 let phase = entries[0].document.run.phase;
314 let mut seen_steps: BTreeMap<u64, &str> = BTreeMap::new();
315 for entry in &entries {
316 let run = &entry.document.run;
317 if run.entrypoint != entrypoint {
318 bail!(
319 "series bundles mix entrypoints: {} observes {:?}, expected {:?} from {}",
320 entry.bundle,
321 run.entrypoint,
322 entrypoint,
323 entries[0].bundle
324 );
325 }
326 if run.phase != phase {
327 bail!(
328 "series bundles mix phases: {} observes {:?}, expected {:?} from {}",
329 entry.bundle,
330 run.phase.as_str(),
331 phase.as_str(),
332 entries[0].bundle
333 );
334 }
335 if let Some(existing) = seen_steps.insert(run.capture_step, entry.bundle.as_str()) {
336 bail!(
337 "ambiguous series coordinate: capture_step {} is provided by both {} and {}",
338 run.capture_step,
339 existing,
340 entry.bundle
341 );
342 }
343 }
344 entries.sort_by_key(|entry| entry.document.run.capture_step);
345
346 let matches_prefix = |value: &str| label_prefix.is_none_or(|prefix| value.starts_with(prefix));
347
348 let mut inputs = Vec::with_capacity(entries.len());
349 let mut outer_wall_time = Vec::with_capacity(entries.len());
350 let mut tensor_stats: BTreeMap<String, Vec<ScalarSeriesPoint>> = BTreeMap::new();
351 let mut gradients: BTreeMap<String, Vec<GradientSeriesPoint>> = BTreeMap::new();
352 for entry in &entries {
353 let run = &entry.document.run;
354 inputs.push(SeriesInput {
355 capture_step: run.capture_step,
356 run_id: run.run_id.clone(),
357 bundle: entry.bundle.clone(),
358 manifest_sha256: entry.manifest_sha256.clone(),
359 });
360
361 let mut measured = entry
362 .document
363 .spans
364 .iter()
365 .filter(|span| span.measured && span.closed);
366 let outer_wall_time_ns = match (measured.next(), measured.next()) {
367 (Some(span), None) => Some(span.duration_ns),
368 _ => None,
369 };
370 outer_wall_time.push(TimingSeriesPoint {
371 capture_step: run.capture_step,
372 run_id: run.run_id.clone(),
373 outer_wall_time_ns,
374 });
375
376 for stats in &entry.document.tensor_stats {
377 if !matches_prefix(&stats.label) {
378 continue;
379 }
380 tensor_stats
381 .entry(stats.label.clone())
382 .or_default()
383 .push(ScalarSeriesPoint {
384 capture_step: run.capture_step,
385 run_id: run.run_id.clone(),
386 rms: stats.rms,
387 abs_max: stats.abs_max,
388 mean: stats.mean,
389 non_finite: stats.non_finite,
390 elements: stats.elements,
391 });
392 }
393
394 for gradient in &entry.document.gradients {
395 let key = format!("{}/{}", gradient.root, gradient.key);
396 if !matches_prefix(&key) {
397 continue;
398 }
399 gradients.entry(key).or_default().push(GradientSeriesPoint {
400 capture_step: run.capture_step,
401 run_id: run.run_id.clone(),
402 state: gradient.state,
403 norm: gradient.norm,
404 });
405 }
406 }
407
408 Ok(SeriesReport {
409 schema: SERIES_SCHEMA.into(),
410 entrypoint,
411 coordinate: "capture_step".into(),
412 label_prefix: label_prefix.map(str::to_owned),
413 inputs,
414 outer_wall_time,
415 tensor_stats,
416 gradients,
417 })
418}
419
420fn is_safe_relative_path(value: &str) -> bool {
421 let path = Path::new(value);
422 !path.as_os_str().is_empty()
423 && !value.contains('\\')
424 && !path.is_absolute()
425 && path
426 .components()
427 .all(|component| matches!(component, Component::Normal(_)))
428}
429
430#[cfg(test)]
431mod tests {
432 use super::*;
433 use crate::artifact::publish_bundle;
434 use crate::capability::CaptureContract;
435 use crate::trace::{
436 write_jsonl, GradientEvent, RunOutcome, SpanKind, SpanRecord, TensorStatsEvent,
437 TerminalEvent, TimingMode, TraceDocument, TraceRunMeta, SCHEMA as TRACE_SCHEMA,
438 };
439 use std::time::{SystemTime, UNIX_EPOCH};
440
441 fn temp_root(label: &str) -> PathBuf {
442 let nonce = SystemTime::now()
443 .duration_since(UNIX_EPOCH)
444 .unwrap()
445 .as_nanos();
446 let root = std::env::temp_dir().join(format!(
447 "candle-graph-campaign-{label}-{}-{nonce}",
448 std::process::id()
449 ));
450 fs::create_dir_all(&root).unwrap();
451 root
452 }
453
454 fn fixture_document(run_id: &str, entrypoint: &str, capture_step: u64) -> TraceDocument {
455 TraceDocument {
456 schema: TRACE_SCHEMA.into(),
457 run: TraceRunMeta {
458 run_id: run_id.into(),
459 correlation_id: format!("campaign/{run_id}"),
460 entrypoint: entrypoint.into(),
461 phase: crate::ExecutionPhase::Train,
462 timestamp: "2026-08-28T00:00:00Z".into(),
463 capture_step,
464 warmup_steps: 0,
465 device: "cpu".into(),
466 measured_region_device_synchronized: false,
467 timing_mode: TimingMode::Host,
468 capture_contract: CaptureContract::default(),
469 comparison_identity: None,
470 tags: Default::default(),
471 candle_version: None,
472 },
473 spans: vec![SpanRecord {
474 id: "root".into(),
475 parent_id: None,
476 name: entrypoint.into(),
477 kind: SpanKind::Function,
478 measured: true,
479 start_ns: 0,
480 closed: true,
481 duration_ns: 10 + capture_step,
482 step: None,
483 }],
484 ops: vec![],
485 tensors: vec![],
486 tensor_stats: vec![
487 TensorStatsEvent {
488 span_id: "root".into(),
489 label: "loss/total".into(),
490 shape: vec![1],
491 dtype: "f32".into(),
492 elements: 1,
493 non_finite: 0,
494 rms: 2.0 + capture_step as f64,
495 abs_max: 2.0 + capture_step as f64,
496 mean: -(capture_step as f64),
497 },
498 TensorStatsEvent {
499 span_id: "root".into(),
500 label: "act/mlp".into(),
501 shape: vec![2, 3],
502 dtype: "f32".into(),
503 elements: 6,
504 non_finite: 0,
505 rms: 1.0,
506 abs_max: 3.0,
507 mean: 0.5,
508 },
509 ],
510 memory: vec![],
511 device_memory: vec![],
512 device_intervals: vec![],
513 gradients: vec![
514 GradientEvent {
515 event_id: format!("{run_id}-g1"),
516 root: "vb".into(),
517 key: "encoder.weight".into(),
518 state: GradientState::Present,
519 norm: Some(0.5),
520 },
521 GradientEvent {
522 event_id: format!("{run_id}-g2"),
523 root: "vb".into(),
524 key: "decoder.bias".into(),
525 state: GradientState::Zero,
526 norm: Some(0.0),
527 },
528 ],
529 edges: vec![],
530 terminal: TerminalEvent {
531 outcome: RunOutcome::Complete,
532 timestamp_ns: 10 + capture_step,
533 reason: None,
534 },
535 }
536 }
537
538 fn publish_fixture(root: &Path, name: &str, document: &TraceDocument) -> PathBuf {
539 let trace = root.join(format!("{name}.input.jsonl"));
540 write_jsonl(&trace, &document.to_events()).unwrap();
541 let destination = root.join(name);
542 publish_bundle(&destination, &trace, None).unwrap();
543 fs::remove_file(trace).unwrap();
544 destination
545 }
546
547 fn write_manifest(root: &Path, manifest: &CampaignManifest) -> PathBuf {
548 let path = root.join("campaign.json");
549 fs::write(&path, serde_json::to_string_pretty(manifest).unwrap()).unwrap();
550 path
551 }
552
553 #[test]
554 fn campaign_status_reconciles_every_planned_capture() {
555 let root = temp_root("status");
556 publish_fixture(
557 &root,
558 "b100",
559 &fixture_document("run-100", "demo::train", 100),
560 );
561 publish_fixture(
562 &root,
563 "b200",
564 &fixture_document("run-200", "demo::train", 200),
565 );
566 publish_fixture(
568 &root,
569 "b300",
570 &fixture_document("run-999", "demo::train", 999),
571 );
572 let manifest = CampaignManifest {
573 schema: CAMPAIGN_SCHEMA.into(),
574 campaign_id: "demo-campaign".into(),
575 entrypoint: "demo::train".into(),
576 planned: vec![
577 PlannedCapture {
578 capture_step: 300,
579 bundle: "b300".into(),
580 },
581 PlannedCapture {
582 capture_step: 100,
583 bundle: "b100".into(),
584 },
585 PlannedCapture {
586 capture_step: 400,
587 bundle: "b400".into(),
588 },
589 PlannedCapture {
590 capture_step: 200,
591 bundle: "b200".into(),
592 },
593 ],
594 };
595 let manifest_path = write_manifest(&root, &manifest);
596
597 let status = campaign_status(&manifest_path).unwrap();
598 assert_eq!(status.schema, CAMPAIGN_STATUS_SCHEMA);
599 assert_eq!(status.campaign_id, "demo-campaign");
600 assert_eq!(status.entrypoint, "demo::train");
601 assert_eq!(status.planned, 4);
602 assert_eq!(status.published, 2);
603 assert_eq!(status.missing, 1);
604 assert_eq!(status.failed, 1);
605 let steps: Vec<u64> = status
606 .captures
607 .iter()
608 .map(|capture| capture.capture_step)
609 .collect();
610 assert_eq!(steps, vec![100, 200, 300, 400]);
611 match &status.captures[0].state {
612 CaptureState::Published {
613 run_id,
614 manifest_sha256,
615 } => {
616 assert_eq!(run_id, "run-100");
617 assert_eq!(manifest_sha256.len(), 64);
618 }
619 other => panic!("expected published capture, got {other:?}"),
620 }
621 assert!(matches!(
622 status.captures[1].state,
623 CaptureState::Published { .. }
624 ));
625 match &status.captures[2].state {
626 CaptureState::IdentityMismatch { message } => {
627 assert!(message.contains("capture_step mismatch"));
628 assert!(message.contains("300"));
629 assert!(message.contains("999"));
630 }
631 other => panic!("expected identity mismatch, got {other:?}"),
632 }
633 assert_eq!(status.captures[3].state, CaptureState::Missing);
634 fs::remove_dir_all(root).unwrap();
635 }
636
637 #[test]
638 fn manifest_validation_rejects_bad_plans() {
639 let good = CampaignManifest {
640 schema: CAMPAIGN_SCHEMA.into(),
641 campaign_id: "c".into(),
642 entrypoint: "demo::train".into(),
643 planned: vec![PlannedCapture {
644 capture_step: 1,
645 bundle: "bundles/b1".into(),
646 }],
647 };
648 good.validate().unwrap();
649
650 let mut wrong_schema = good.clone();
651 wrong_schema.schema = "candle-graph/campaign/0".into();
652 assert!(wrong_schema
653 .validate()
654 .unwrap_err()
655 .to_string()
656 .contains("schema"));
657
658 let mut duplicate_steps = good.clone();
659 duplicate_steps.planned = vec![
660 PlannedCapture {
661 capture_step: 1,
662 bundle: "a".into(),
663 },
664 PlannedCapture {
665 capture_step: 1,
666 bundle: "b".into(),
667 },
668 ];
669 assert!(duplicate_steps
670 .validate()
671 .unwrap_err()
672 .to_string()
673 .contains("duplicate capture_step"));
674
675 let mut duplicate_bundles = good.clone();
676 duplicate_bundles.planned = vec![
677 PlannedCapture {
678 capture_step: 1,
679 bundle: "same".into(),
680 },
681 PlannedCapture {
682 capture_step: 2,
683 bundle: "same".into(),
684 },
685 ];
686 assert!(duplicate_bundles
687 .validate()
688 .unwrap_err()
689 .to_string()
690 .contains("duplicate bundle path"));
691
692 for unsafe_path in ["", "/abs/b1", "../escape", "a\\b", "a/../b"] {
693 let mut manifest = good.clone();
694 manifest.planned[0].bundle = unsafe_path.into();
695 assert!(
696 manifest
697 .validate()
698 .unwrap_err()
699 .to_string()
700 .contains("unsafe bundle path"),
701 "path {unsafe_path:?} must be rejected"
702 );
703 }
704
705 let mut empty_plan = good.clone();
706 empty_plan.planned.clear();
707 assert!(empty_plan
708 .validate()
709 .unwrap_err()
710 .to_string()
711 .contains("at least one planned capture"));
712
713 let mut empty_id = good;
714 empty_id.campaign_id = " ".into();
715 assert!(empty_id
716 .validate()
717 .unwrap_err()
718 .to_string()
719 .contains("campaign_id"));
720 }
721
722 #[test]
723 fn series_sorts_filters_and_rejects_ambiguity() {
724 let root = temp_root("series");
725 let b100 = publish_fixture(
726 &root,
727 "b100",
728 &fixture_document("run-100", "demo::train", 100),
729 );
730 let b200 = publish_fixture(
731 &root,
732 "b200",
733 &fixture_document("run-200", "demo::train", 200),
734 );
735 let mut two_measured = fixture_document("run-300", "demo::train", 300);
737 two_measured.spans.push(SpanRecord {
738 id: "root-b".into(),
739 parent_id: None,
740 name: "demo::train#b".into(),
741 kind: SpanKind::Function,
742 measured: true,
743 start_ns: 0,
744 closed: true,
745 duration_ns: 5,
746 step: None,
747 });
748 let b300 = publish_fixture(&root, "b300", &two_measured);
749
750 let report = build_series(&[b300.clone(), b100.clone(), b200.clone()], None).unwrap();
752 assert_eq!(report.schema, SERIES_SCHEMA);
753 assert_eq!(report.entrypoint, "demo::train");
754 assert_eq!(report.coordinate, "capture_step");
755 assert_eq!(report.label_prefix, None);
756 let input_steps: Vec<u64> = report
757 .inputs
758 .iter()
759 .map(|input| input.capture_step)
760 .collect();
761 assert_eq!(input_steps, vec![100, 200, 300]);
762 assert!(report
763 .inputs
764 .iter()
765 .all(|input| input.manifest_sha256.len() == 64));
766 assert_eq!(report.inputs[0].run_id, "run-100");
767 assert_eq!(
768 report
769 .outer_wall_time
770 .iter()
771 .map(|point| point.outer_wall_time_ns)
772 .collect::<Vec<_>>(),
773 vec![Some(110), Some(210), None]
774 );
775 assert_eq!(
776 report.tensor_stats.keys().collect::<Vec<_>>(),
777 vec!["act/mlp", "loss/total"]
778 );
779 let loss = &report.tensor_stats["loss/total"];
780 assert_eq!(
781 loss.iter()
782 .map(|point| point.capture_step)
783 .collect::<Vec<_>>(),
784 vec![100, 200, 300]
785 );
786 assert_eq!(loss[0].rms, 102.0);
787 assert_eq!(
788 report.gradients.keys().collect::<Vec<_>>(),
789 vec!["vb/decoder.bias", "vb/encoder.weight"]
790 );
791 let encoder = &report.gradients["vb/encoder.weight"];
792 assert_eq!(encoder.len(), 3);
793 assert_eq!(encoder[0].state, GradientState::Present);
794 assert_eq!(encoder[0].norm, Some(0.5));
795
796 let filtered = build_series(&[b100.clone(), b200.clone()], Some("loss")).unwrap();
797 assert_eq!(filtered.label_prefix.as_deref(), Some("loss"));
798 assert_eq!(
799 filtered.tensor_stats.keys().collect::<Vec<_>>(),
800 vec!["loss/total"]
801 );
802 assert!(filtered.gradients.is_empty());
803
804 let gradient_only = build_series(std::slice::from_ref(&b100), Some("vb/enc")).unwrap();
805 assert!(gradient_only.tensor_stats.is_empty());
806 assert_eq!(
807 gradient_only.gradients.keys().collect::<Vec<_>>(),
808 vec!["vb/encoder.weight"]
809 );
810
811 let duplicate = publish_fixture(
812 &root,
813 "b100-dup",
814 &fixture_document("run-100-dup", "demo::train", 100),
815 );
816 let error = build_series(&[b100.clone(), duplicate], None).unwrap_err();
817 assert!(error.to_string().contains("ambiguous series coordinate"));
818
819 let other = publish_fixture(
820 &root,
821 "other",
822 &fixture_document("run-other", "other::train", 400),
823 );
824 let error = build_series(&[b100.clone(), other], None).unwrap_err();
825 assert!(error.to_string().contains("mix entrypoints"));
826
827 assert!(build_series(&[], None)
828 .unwrap_err()
829 .to_string()
830 .contains("at least one bundle"));
831
832 let absent = root.join("never-published");
833 let error = build_series(&[absent], None).unwrap_err();
834 assert!(format!("{error:#}").contains("verify series bundle"));
835
836 fs::remove_dir_all(root).unwrap();
837 }
838}