euler-train 2.5.0__tar.gz → 2.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: euler-train
3
- Version: 2.5.0
3
+ Version: 2.7.0
4
4
  Summary: Lightweight file-based experiment logger for PyTorch
5
5
  Requires-Python: >=3.9
6
6
  Requires-Dist: numpy
@@ -49,6 +49,57 @@ with euler_train.init(dir="runs/exp02", config=cfg) as run:
49
49
  ... # if an exception is raised, meta.json records status="crashed" + traceback
50
50
  ```
51
51
 
52
+ ## Euler View stream mode
53
+
54
+ `euler_train` can dual-write runs to Euler View while still keeping the normal file-based run directory as the primary source of truth. Streaming is best-effort: local files are always written first, and stream events are sent alongside them when a stream consumer is configured.
55
+
56
+ Pass the stream config via `stream=...` on `euler_train.init()`:
57
+
58
+ ```python
59
+ import os
60
+ import euler_train
61
+
62
+ run = euler_train.init(
63
+ dir="runs/experiment_01",
64
+ config=cfg,
65
+ stream={
66
+ "base_url": os.environ["EULER_VIEW_BASE_URL"],
67
+ "model_id": 42,
68
+ "api_token": os.environ["EULER_VIEW_API_TOKEN"],
69
+ "stream_attach_token": os.environ.get("EULER_VIEW_STREAM_ATTACH_TOKEN"),
70
+ },
71
+ )
72
+ ```
73
+
74
+ What each field is for:
75
+
76
+ - `base_url`: Euler View server base URL, for example `https://view.example.com`.
77
+ - `model_id`: Euler View model that should receive this run.
78
+ - `api_token` or `access_token`: credential used to open a stream session. This is the normal choice when the package should negotiate its own short-lived ingest token.
79
+ - `stream_attach_token`: optional opaque launch-attachment token. When present, the producer sends it during the session handshake and Euler View attaches the stream to that launch directly. This is the preferred path for interactive or otherwise non-SLURM launches. Internally Euler View currently maps this token to `model_launches.id`, but the package treats it as an opaque token.
80
+ - `stream_token`: optional pre-issued ingest token. If you pass this, the package skips the session handshake entirely and posts events directly to the ingest endpoint.
81
+ - `datasource_id`: optional Euler View datasource hint for the run record.
82
+ - `euler_train_dir` and `run_dir`: optional path overrides. These are auto-derived in normal usage.
83
+ - `session_expires_in_sec`: optional requested session lifetime for the short-lived ingest token.
84
+
85
+ How the handshake works:
86
+
87
+ 1. `euler_train` creates the local run directory and writes `meta.json`, `config.json`, and the other standard files as usual.
88
+ 2. When the first stream flush happens, the package requests a session from Euler View at `/api/models/{model_id}/runs/{run_id}/stream/session`.
89
+ 3. If `stream_attach_token` is configured, it is sent as `streamAttachToken` and used as the explicit launch attachment key.
90
+ 4. If no `stream_attach_token` is configured, the package falls back to the SLURM metadata already captured in `meta.json["slurm"]["job_id"]`.
91
+ 5. Euler View returns a short-lived ingest token plus the canonical `streamAttachToken`; the package caches that returned token and reuses it on later session refreshes.
92
+ 6. Subsequent events are sent to `/api/model-run-stream/ingest`.
93
+
94
+ What has to be configured where:
95
+
96
+ - In the training script or job wrapper: pass `stream={...}` to `euler_train.init(...)`.
97
+ - In Euler View / the launcher: if the run should attach to a specific launch regardless of SLURM availability, provide a `stream_attach_token` to the runtime and forward it into the `stream` config.
98
+ - Under plain SLURM launches: no extra attachment config is required as long as Euler View can uniquely match the run by `slurm.job_id`.
99
+ - If neither `stream_attach_token` nor a unique SLURM match is available, run streaming still works, but launch-to-run linking in Euler View may remain unresolved.
100
+
101
+ For new integrations, prefer `stream_attach_token`. The older `launch_id` / `launchId` config keys are still accepted as compatibility aliases, but new code should treat the attachment value as an opaque stream token rather than a launch ID.
102
+
52
103
  ## Directory structure
53
104
 
54
105
  Each `euler_train.init(dir=...)` call creates a timestamped subdirectory under `{dir}/runs/`:
@@ -67,6 +118,7 @@ Each `euler_train.init(dir=...)` call creates a timestamped subdirectory under `
67
118
  │ └── epoch_{N}.pt
68
119
  └── outputs/
69
120
  └── epoch_{N}_step_{M}/
121
+ ├── manifest.json
70
122
  └── {output_type}/
71
123
  ├── pred/
72
124
  ├── gt/
@@ -80,7 +132,7 @@ The run ID and directory are available as `run.run_id` and `run.dir`.
80
132
 
81
133
  ## API reference
82
134
 
83
- ### `euler_train.init(dir, config=None, meta=None, output_formats=None, output_visualization=None, run_id=None, datasets=None, run_name=None, evaluations=None, mode=None) → Run`
135
+ ### `euler_train.init(dir, config=None, meta=None, output_formats=None, output_visualization=None, run_id=None, datasets=None, run_name=None, evaluations=None, mode=None, stream=None, metric_naming=None) → Run`
84
136
 
85
137
  Creates the run directory and writes `meta.json`, `config.json`, `code_ref.json`, and `run_environment.json`. On resume (`run_id` provided), only `meta.json` and `config.json` are updated. `meta.json` also maintains an `updated_at` map with last-write timestamps for tracked artifacts.
86
138
 
@@ -96,6 +148,8 @@ Creates the run directory and writes `meta.json`, `config.json`, `code_ref.json`
96
148
  | `run_name` | `str \| None` | Optional human-readable run label stored in `meta.json`. |
97
149
  | `evaluations` | `dict[str, dict] \| None` | Optional evaluation key → entry map. See [Evaluations](#evaluations). |
98
150
  | `mode` | `str \| None` | Optional process label such as `"train"`, `"val"`, or `"eval"`. When set, lifecycle and crash details are also written under `meta.json["modes"][mode]`. |
151
+ | `stream` | `dict \| EulerViewStreamConfig \| consumer \| sequence \| None` | Optional output stream config / consumer. A mapping with `base_url`, `model_id`, and `api_token` (or `access_token`) enables best-effort streaming to Euler View. Prefer `stream_attach_token` for explicit launch attachment; otherwise the package falls back to `meta.json["slurm"]["job_id"]` when available. |
152
+ | `metric_naming` | `dict[str, Any] \| None` | Optional structured metric naming declaration stored in `meta.json["metric_naming"]` and included in the stream init event. |
99
153
 
100
154
  ---
101
155
 
@@ -112,7 +166,7 @@ run.log({"rgb.psnr": 28.3, "depth.mae": 0.03}, step=100, epoch=1, mode="val")
112
166
 
113
167
  ---
114
168
 
115
- ### `run.save_outputs(*, epoch=None, step=None, **output_types)`
169
+ ### `run.save_outputs(*, epoch=None, step=None, metadata=None, **output_types)`
116
170
 
117
171
  Saves arrays/images to `outputs/epoch_{N}_step_{M}/{output_type}/{slot}/`.
118
172
 
@@ -125,10 +179,11 @@ Each output type is a dict with these slot keys:
125
179
  | `input` | Model input |
126
180
  | `aux` | Dict of named auxiliary outputs (each becomes a subdirectory) |
127
181
 
128
- Values can be:
182
+ Slot values can be:
129
183
  - A single numpy array, torch tensor, or PIL Image
130
184
  - A list of the above (saved as `0000.ext`, `0001.ext`, ...)
131
185
  - A 4D numpy/torch array (split along dim 0 as a batch)
186
+ - A dict mapping custom string IDs to items (saved as `{id}.ext`)
132
187
 
133
188
  ```python
134
189
  run.save_outputs(
@@ -142,6 +197,61 @@ run.save_outputs(
142
197
  )
143
198
  ```
144
199
 
200
+ #### Named sample IDs
201
+
202
+ When slot values are dicts with string keys, files are saved with the key as the basename instead of sequential indices. This is useful for matching outputs back to specific input samples:
203
+
204
+ ```python
205
+ run.save_outputs(
206
+ epoch=1, step=500,
207
+ rgb=dict(pred={"scene_042": img_a, "scene_117": img_b}),
208
+ )
209
+ # produces: rgb/pred/scene_042.png, rgb/pred/scene_117.png
210
+ ```
211
+
212
+ #### Source metadata
213
+
214
+ The optional `metadata` parameter attaches dataset provenance and other context to the output manifest. When provided, the `dataset` key is required and must be a non-empty string:
215
+
216
+ ```python
217
+ run.save_outputs(
218
+ epoch=1, step=500,
219
+ metadata={"dataset": "vkitti2", "split": "val", "scene": "0001"},
220
+ rgb=dict(pred={"frame_042": img_a}),
221
+ )
222
+ ```
223
+
224
+ #### Output manifest
225
+
226
+ Each `save_outputs` call writes a `manifest.json` alongside the saved files, recording what was written and how. The manifest is also streamed via the `output_snapshot` event when streaming is active.
227
+
228
+ ```json
229
+ {
230
+ "version": 1,
231
+ "epoch": 1,
232
+ "step": 500,
233
+ "metadata": {"dataset": "vkitti2", "split": "val"},
234
+ "output_types": {
235
+ "rgb": {
236
+ "pred": {
237
+ "id_mode": "named",
238
+ "files": [
239
+ {"sample_id": "scene_042", "filename": "scene_042.png", "format": "png"}
240
+ ]
241
+ },
242
+ "gt": {
243
+ "id_mode": "indexed",
244
+ "files": [
245
+ {"sample_id": 0, "filename": "0000.png", "format": "png"}
246
+ ]
247
+ }
248
+ }
249
+ },
250
+ "format_overrides": {},
251
+ "visualization_overrides": {}
252
+ }
253
+ ```
254
+
145
255
  Torch tensors in `(C,H,W)` or `(B,C,H,W)` layout are automatically transposed to channels-last before saving.
146
256
 
147
257
  Pass `None` for any slot or output type to skip it.
@@ -18,7 +18,7 @@ def save_output_tree(
18
18
  format_overrides: dict[str, str],
19
19
  visualization_overrides: dict[str, Any],
20
20
  output_type: str,
21
- ) -> None:
21
+ ) -> dict[str, Any]:
22
22
  """Persist all slots (pred, gt, input, aux/…) for one *output_type*.
23
23
 
24
24
  *slots* example::
@@ -28,7 +28,10 @@ def save_output_tree(
28
28
  "gt": array,
29
29
  "aux": {"transmission": array, "attention": array},
30
30
  }
31
+
32
+ Returns a dict mapping slot keys to their manifest entries.
31
33
  """
34
+ manifest: dict[str, Any] = {}
32
35
  for slot_name, data in slots.items():
33
36
  if data is None:
34
37
  continue
@@ -36,7 +39,7 @@ def save_output_tree(
36
39
  for aux_name, aux_data in data.items():
37
40
  if aux_data is None:
38
41
  continue
39
- _save_slot(
42
+ manifest[f"aux/{aux_name}"] = _save_slot(
40
43
  type_dir / "aux" / aux_name,
41
44
  aux_data,
42
45
  output_type,
@@ -45,7 +48,7 @@ def save_output_tree(
45
48
  visualization_overrides,
46
49
  )
47
50
  else:
48
- _save_slot(
51
+ manifest[slot_name] = _save_slot(
49
52
  type_dir / slot_name,
50
53
  data,
51
54
  output_type,
@@ -53,6 +56,7 @@ def save_output_tree(
53
56
  format_overrides,
54
57
  visualization_overrides,
55
58
  )
59
+ return manifest
56
60
 
57
61
 
58
62
  # ---------------------------------------------------------------------------
@@ -66,7 +70,8 @@ def _save_slot(
66
70
  leaf_name: str,
67
71
  format_overrides: dict[str, str],
68
72
  visualization_overrides: dict[str, Any],
69
- ) -> None:
73
+ ) -> dict[str, Any]:
74
+ """Save items for one slot and return a manifest entry."""
70
75
  slot_dir.mkdir(parents=True, exist_ok=True)
71
76
  visualization = _resolve_visualization(
72
77
  output_type, leaf_name, visualization_overrides,
@@ -74,16 +79,21 @@ def _save_slot(
74
79
 
75
80
  # Dict with string keys → named outputs (e.g. {"scene_042": img}).
76
81
  if isinstance(data, dict):
82
+ files: list[dict[str, Any]] = []
77
83
  for name, raw in data.items():
78
84
  item = _prepare(raw)
79
85
  fmt = _resolve_format(item, output_type, leaf_name, format_overrides)
80
86
  _save_item(slot_dir / f"{name}.{fmt}", item, fmt, visualization)
81
- return
87
+ files.append({"sample_id": name, "filename": f"{name}.{fmt}", "format": fmt})
88
+ return {"id_mode": "named", "files": files}
82
89
 
83
90
  items = _unpack(data)
91
+ files = []
84
92
  for idx, item in enumerate(items):
85
93
  fmt = _resolve_format(item, output_type, leaf_name, format_overrides)
86
94
  _save_item(slot_dir / f"{idx:04d}.{fmt}", item, fmt, visualization)
95
+ files.append({"sample_id": idx, "filename": f"{idx:04d}.{fmt}", "format": fmt})
96
+ return {"id_mode": "indexed", "files": files}
87
97
 
88
98
 
89
99
  # ---- normalisation -------------------------------------------------------
@@ -361,6 +361,7 @@ class Run:
361
361
  *,
362
362
  epoch: int | None = None,
363
363
  step: int | None = None,
364
+ metadata: dict[str, Any] | None = None,
364
365
  **output_types: dict[str, Any],
365
366
  ) -> Path:
366
367
  """Save prediction / ground-truth / auxiliary arrays to disk.
@@ -383,11 +384,26 @@ class Run:
383
384
  rgb=dict(pred={"scene_042": img_a, "scene_117": img_b}),
384
385
  )
385
386
 
387
+ An optional *metadata* dict attaches source context (dataset,
388
+ split, etc.) to the manifest. The ``dataset`` key is required
389
+ when *metadata* is provided::
390
+
391
+ run.save_outputs(
392
+ epoch=1, step=500,
393
+ metadata={"dataset": "vkitti2", "split": "val"},
394
+ rgb=dict(pred=pred_img),
395
+ )
396
+
386
397
  Accepted slot keys: ``pred``, ``gt``, ``input``, ``aux``.
387
398
  ``aux`` expects a sub-dict of named arrays.
388
399
 
389
400
  Returns the directory that was written to.
390
401
  """
402
+ if metadata is not None:
403
+ if not isinstance(metadata.get("dataset"), str) or not metadata["dataset"].strip():
404
+ raise ValueError(
405
+ "metadata must include a non-empty 'dataset' string"
406
+ )
391
407
  parts: list[str] = []
392
408
  if epoch is not None:
393
409
  parts.append(f"epoch_{epoch}")
@@ -396,21 +412,38 @@ class Run:
396
412
  dirname = "_".join(parts) or "unspecified"
397
413
  base = self.dir / "outputs" / dirname
398
414
 
415
+ manifest_types: dict[str, Any] = {}
399
416
  for output_type, slots in output_types.items():
400
417
  if slots is None:
401
418
  continue
402
- save_output_tree(
419
+ slot_manifest = save_output_tree(
403
420
  base / output_type,
404
421
  slots,
405
422
  self._output_formats,
406
423
  self._output_visualization,
407
424
  output_type,
408
425
  )
426
+ if slot_manifest:
427
+ manifest_types[output_type] = slot_manifest
428
+ if manifest_types:
429
+ manifest: dict[str, Any] = {
430
+ "version": 1,
431
+ "epoch": epoch,
432
+ "step": step,
433
+ "output_types": manifest_types,
434
+ "format_overrides": dict(self._output_formats),
435
+ "visualization_overrides": {
436
+ k: dict(v) if isinstance(v, Mapping) else v
437
+ for k, v in self._output_visualization.items()
438
+ } if self._output_visualization else {},
439
+ }
440
+ if metadata is not None:
441
+ manifest["metadata"] = metadata
442
+ write_json(base / "manifest.json", manifest)
409
443
  if base.exists():
410
444
  self._record_artifact_update(base)
411
445
  self._flush_meta()
412
- snapshot = self._build_output_snapshot(epoch, step, output_types)
413
- if snapshot is not None:
446
+ if manifest_types and epoch is not None and step is not None:
414
447
  self._emit_stream_event(
415
448
  {
416
449
  "type": "output_snapshot",
@@ -419,7 +452,13 @@ class Run:
419
452
  epoch,
420
453
  step,
421
454
  ),
422
- "snapshot": snapshot,
455
+ "snapshot": {
456
+ "version": 1,
457
+ "epoch": epoch,
458
+ "step": step,
459
+ "output_types": manifest_types,
460
+ **({"metadata": metadata} if metadata is not None else {}),
461
+ },
423
462
  },
424
463
  )
425
464
  return base
@@ -888,44 +927,6 @@ class Run:
888
927
  ]
889
928
  return tags or None
890
929
 
891
- def _build_output_snapshot(
892
- self,
893
- epoch: int | None,
894
- step: int | None,
895
- output_types: dict[str, Any],
896
- ) -> dict[str, Any] | None:
897
- if epoch is None or step is None:
898
- return None
899
-
900
- snapshot_types: dict[str, list[str]] = {}
901
- for output_type, slots in output_types.items():
902
- if slots is None or not isinstance(slots, Mapping):
903
- continue
904
-
905
- categories: list[str] = []
906
- for slot_name, data in slots.items():
907
- if data is None:
908
- continue
909
- if slot_name == "aux" and isinstance(data, Mapping):
910
- for aux_name, aux_value in data.items():
911
- if aux_value is None:
912
- continue
913
- categories.append(f"aux/{aux_name}")
914
- else:
915
- categories.append(str(slot_name))
916
-
917
- if categories:
918
- snapshot_types[str(output_type)] = sorted(set(categories))
919
-
920
- if not snapshot_types:
921
- return None
922
-
923
- return {
924
- "epoch": epoch,
925
- "step": step,
926
- "types": snapshot_types,
927
- }
928
-
929
930
  def _build_finish_patch(self) -> dict[str, Any]:
930
931
  patch = {
931
932
  "end_time": self._meta.get("end_time"),
@@ -56,6 +56,8 @@ class EulerViewStreamConfig:
56
56
  api_token: str | None = None
57
57
  access_token: str | None = None
58
58
  stream_token: str | None = None
59
+ stream_attach_token: str | None = None
60
+ launch_id: str | None = None
59
61
  datasource_id: int | None = None
60
62
  euler_train_dir: str | None = None
61
63
  run_dir: str | None = None
@@ -84,6 +86,24 @@ class EulerViewStreamConfig:
84
86
  self.stream_token,
85
87
  field_name="stream.stream_token",
86
88
  )
89
+ self.stream_attach_token = _normalize_optional_text(
90
+ self.stream_attach_token,
91
+ field_name="stream.stream_attach_token",
92
+ )
93
+ self.launch_id = _normalize_optional_text(
94
+ self.launch_id,
95
+ field_name="stream.launch_id",
96
+ )
97
+ if (
98
+ self.stream_attach_token is not None
99
+ and self.launch_id is not None
100
+ and self.stream_attach_token != self.launch_id
101
+ ):
102
+ raise ValueError(
103
+ "stream.stream_attach_token and stream.launch_id must match when both are provided",
104
+ )
105
+ if self.stream_attach_token is None:
106
+ self.stream_attach_token = self.launch_id
87
107
  self.euler_train_dir = _normalize_optional_text(
88
108
  self.euler_train_dir,
89
109
  field_name="stream.euler_train_dir",
@@ -191,6 +211,7 @@ class EulerViewStreamConsumer:
191
211
  self._context: StreamContext | None = None
192
212
  self._pending: list[dict[str, Any]] = []
193
213
  self._stream_token: str | None = config.stream_token
214
+ self._resolved_stream_attach_token: str | None = config.stream_attach_token
194
215
  self._session_expires_at = float("inf") if config.stream_token else 0.0
195
216
  self._ingest_url = f"{self.config.base_url}/api/model-run-stream/ingest"
196
217
  self._last_flush_at = 0.0
@@ -299,6 +320,14 @@ class EulerViewStreamConsumer:
299
320
  "runDir": self.config.run_dir or str(self._context.run_dir),
300
321
  "eulerTrainDir": self.config.euler_train_dir or str(self._context.runs_dir),
301
322
  }
323
+ stream_attach_token = (
324
+ self.config.stream_attach_token or self._resolved_stream_attach_token
325
+ )
326
+ if stream_attach_token is not None:
327
+ session_body["streamAttachToken"] = stream_attach_token
328
+ slurm_job_id = _extract_slurm_job_id(self._context.meta)
329
+ if slurm_job_id is not None:
330
+ session_body["slurmJobId"] = slurm_job_id
302
331
  if self.config.datasource_id is not None:
303
332
  session_body["datasourceId"] = self.config.datasource_id
304
333
  if self.config.session_expires_in_sec is not None:
@@ -328,6 +357,10 @@ class EulerViewStreamConsumer:
328
357
  else:
329
358
  self._ingest_url = f"{self.config.base_url}/api/model-run-stream/ingest"
330
359
 
360
+ resolved_stream_attach_token = _extract_session_stream_attach_token(payload)
361
+ if resolved_stream_attach_token is not None:
362
+ self._resolved_stream_attach_token = resolved_stream_attach_token
363
+
331
364
  self._session_expires_at = _parse_timestamp(payload.get("expiresAt"))
332
365
  self._next_retry_at = 0.0
333
366
  return True
@@ -448,6 +481,12 @@ def _config_from_mapping(raw: Mapping[str, Any]) -> EulerViewStreamConfig:
448
481
  access_token=_mapping_get(raw, "access_token", "accessToken"),
449
482
  api_token=_mapping_get(raw, "api_token", "apiToken"),
450
483
  stream_token=_mapping_get(raw, "stream_token", "streamToken"),
484
+ stream_attach_token=_mapping_get(
485
+ raw,
486
+ "stream_attach_token",
487
+ "streamAttachToken",
488
+ ),
489
+ launch_id=_mapping_get(raw, "launch_id", "launchId"),
451
490
  datasource_id=_mapping_get(raw, "datasource_id", "datasourceId"),
452
491
  euler_train_dir=_mapping_get(raw, "euler_train_dir", "eulerTrainDir"),
453
492
  run_dir=_mapping_get(raw, "run_dir", "runDir"),
@@ -484,6 +523,45 @@ def _mapping_get(
484
523
  return default
485
524
 
486
525
 
526
+ def _extract_slurm_job_id(meta: Mapping[str, Any] | None) -> int | None:
527
+ if not isinstance(meta, Mapping):
528
+ return None
529
+ slurm = meta.get("slurm")
530
+ if not isinstance(slurm, Mapping):
531
+ return None
532
+ raw = slurm.get("job_id")
533
+ if isinstance(raw, bool):
534
+ return None
535
+ if isinstance(raw, int):
536
+ return raw if raw > 0 else None
537
+ if isinstance(raw, float):
538
+ return int(raw) if raw.is_integer() and raw > 0 else None
539
+ if isinstance(raw, str):
540
+ text = raw.strip()
541
+ if not text:
542
+ return None
543
+ try:
544
+ value = int(text, 10)
545
+ except ValueError:
546
+ return None
547
+ return value if value > 0 else None
548
+ return None
549
+
550
+
551
+ def _extract_session_stream_attach_token(payload: Mapping[str, Any]) -> str | None:
552
+ run = payload.get("run")
553
+ if not isinstance(run, Mapping):
554
+ return None
555
+ stream_attach_token = run.get("streamAttachToken")
556
+ if not isinstance(stream_attach_token, str):
557
+ legacy_launch_id = run.get("launchId")
558
+ if not isinstance(legacy_launch_id, str):
559
+ return None
560
+ stream_attach_token = legacy_launch_id
561
+ normalized = stream_attach_token.strip()
562
+ return normalized or None
563
+
564
+
487
565
  def _normalize_optional_text(
488
566
  value: Any,
489
567
  *,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: euler-train
3
- Version: 2.5.0
3
+ Version: 2.7.0
4
4
  Summary: Lightweight file-based experiment logger for PyTorch
5
5
  Requires-Python: >=3.9
6
6
  Requires-Dist: numpy
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "euler-train"
7
- version = "2.5.0"
7
+ version = "2.7.0"
8
8
  description = "Lightweight file-based experiment logger for PyTorch"
9
9
  requires-python = ">=3.9"
10
10
  dependencies = ["numpy", "euler-dataset-contract"]
@@ -1488,6 +1488,162 @@ class TestSaveOutputsVariants:
1488
1488
  run.finish()
1489
1489
 
1490
1490
 
1491
+ # ═══════════════════════════════════════════════════════════════════════════
1492
+ # Output manifest
1493
+ # ═══════════════════════════════════════════════════════════════════════════
1494
+
1495
+ class TestSaveOutputsManifest:
1496
+ def test_manifest_written_for_basic_save(self, tmp_path):
1497
+ run = euler_train.init(dir=str(tmp_path / "r"), config={})
1498
+ arr = np.zeros((4, 4, 3), dtype=np.uint8)
1499
+ run.save_outputs(epoch=1, step=500, rgb=dict(pred=arr))
1500
+
1501
+ m = _read_json(run.dir / "outputs" / "epoch_1_step_500" / "manifest.json")
1502
+ assert m["version"] == 1
1503
+ assert m["epoch"] == 1
1504
+ assert m["step"] == 500
1505
+ assert "rgb" in m["output_types"]
1506
+ assert "pred" in m["output_types"]["rgb"]
1507
+ run.finish()
1508
+
1509
+ def test_manifest_indexed_files(self, tmp_path):
1510
+ run = euler_train.init(dir=str(tmp_path / "r"), config={})
1511
+ imgs = [np.zeros((4, 4, 3), dtype=np.uint8) for _ in range(3)]
1512
+ run.save_outputs(epoch=0, step=0, rgb=dict(pred=imgs))
1513
+
1514
+ slot = _read_json(
1515
+ run.dir / "outputs" / "epoch_0_step_0" / "manifest.json",
1516
+ )["output_types"]["rgb"]["pred"]
1517
+ assert slot["id_mode"] == "indexed"
1518
+ assert len(slot["files"]) == 3
1519
+ assert slot["files"][0] == {"sample_id": 0, "filename": "0000.png", "format": "png"}
1520
+ assert slot["files"][2]["sample_id"] == 2
1521
+ run.finish()
1522
+
1523
+ def test_manifest_named_files(self, tmp_path):
1524
+ run = euler_train.init(dir=str(tmp_path / "r"), config={})
1525
+ a = np.zeros((4, 4, 3), dtype=np.uint8)
1526
+ run.save_outputs(epoch=0, step=0, rgb=dict(pred={"scene_a": a, "scene_b": a}))
1527
+
1528
+ slot = _read_json(
1529
+ run.dir / "outputs" / "epoch_0_step_0" / "manifest.json",
1530
+ )["output_types"]["rgb"]["pred"]
1531
+ assert slot["id_mode"] == "named"
1532
+ ids = [f["sample_id"] for f in slot["files"]]
1533
+ assert "scene_a" in ids
1534
+ assert "scene_b" in ids
1535
+ run.finish()
1536
+
1537
+ def test_manifest_aux_slots(self, tmp_path):
1538
+ run = euler_train.init(dir=str(tmp_path / "r"), config={})
1539
+ arr = np.ones((4, 4), dtype=np.float32)
1540
+ run.save_outputs(epoch=0, step=0, depth=dict(aux=dict(transmission=arr)))
1541
+
1542
+ m = _read_json(run.dir / "outputs" / "epoch_0_step_0" / "manifest.json")
1543
+ assert "aux/transmission" in m["output_types"]["depth"]
1544
+ run.finish()
1545
+
1546
+ def test_manifest_none_slots_excluded(self, tmp_path):
1547
+ run = euler_train.init(dir=str(tmp_path / "r"), config={})
1548
+ arr = np.zeros((4, 4, 3), dtype=np.uint8)
1549
+ run.save_outputs(epoch=0, step=0, rgb=dict(pred=arr, gt=None))
1550
+
1551
+ slots = _read_json(
1552
+ run.dir / "outputs" / "epoch_0_step_0" / "manifest.json",
1553
+ )["output_types"]["rgb"]
1554
+ assert "pred" in slots
1555
+ assert "gt" not in slots
1556
+ run.finish()
1557
+
1558
+ def test_manifest_records_format_overrides(self, tmp_path):
1559
+ run = euler_train.init(
1560
+ dir=str(tmp_path / "r"), config={},
1561
+ output_formats={"depth": "npz"},
1562
+ )
1563
+ arr = np.ones((4, 4), dtype=np.float32)
1564
+ run.save_outputs(epoch=0, step=0, depth=dict(pred=arr))
1565
+
1566
+ m = _read_json(run.dir / "outputs" / "epoch_0_step_0" / "manifest.json")
1567
+ assert m["format_overrides"] == {"depth": "npz"}
1568
+ assert m["output_types"]["depth"]["pred"]["files"][0]["format"] == "npz"
1569
+ run.finish()
1570
+
1571
+ def test_manifest_mixed_named_and_indexed(self, tmp_path):
1572
+ run = euler_train.init(dir=str(tmp_path / "r"), config={})
1573
+ arr = np.zeros((4, 4, 3), dtype=np.uint8)
1574
+ run.save_outputs(epoch=0, step=0, rgb=dict(pred={"x": arr}, gt=arr))
1575
+
1576
+ slots = _read_json(
1577
+ run.dir / "outputs" / "epoch_0_step_0" / "manifest.json",
1578
+ )["output_types"]["rgb"]
1579
+ assert slots["pred"]["id_mode"] == "named"
1580
+ assert slots["gt"]["id_mode"] == "indexed"
1581
+ run.finish()
1582
+
1583
+ def test_manifest_metadata(self, tmp_path):
1584
+ run = euler_train.init(dir=str(tmp_path / "r"), config={})
1585
+ arr = np.zeros((4, 4, 3), dtype=np.uint8)
1586
+ run.save_outputs(
1587
+ epoch=0, step=0,
1588
+ metadata={"dataset": "vkitti2", "split": "val"},
1589
+ rgb=dict(pred=arr),
1590
+ )
1591
+
1592
+ m = _read_json(run.dir / "outputs" / "epoch_0_step_0" / "manifest.json")
1593
+ assert m["metadata"] == {"dataset": "vkitti2", "split": "val"}
1594
+ run.finish()
1595
+
1596
+ def test_manifest_metadata_absent_when_not_provided(self, tmp_path):
1597
+ run = euler_train.init(dir=str(tmp_path / "r"), config={})
1598
+ arr = np.zeros((4, 4, 3), dtype=np.uint8)
1599
+ run.save_outputs(epoch=0, step=0, rgb=dict(pred=arr))
1600
+
1601
+ m = _read_json(run.dir / "outputs" / "epoch_0_step_0" / "manifest.json")
1602
+ assert "metadata" not in m
1603
+ run.finish()
1604
+
1605
+ def test_manifest_metadata_requires_dataset(self, tmp_path):
1606
+ run = euler_train.init(dir=str(tmp_path / "r"), config={})
1607
+ arr = np.zeros((4, 4, 3), dtype=np.uint8)
1608
+ with pytest.raises(ValueError, match="dataset"):
1609
+ run.save_outputs(
1610
+ epoch=0, step=0,
1611
+ metadata={"split": "val"},
1612
+ rgb=dict(pred=arr),
1613
+ )
1614
+ run.finish()
1615
+
1616
+ def test_manifest_metadata_rejects_empty_dataset(self, tmp_path):
1617
+ run = euler_train.init(dir=str(tmp_path / "r"), config={})
1618
+ arr = np.zeros((4, 4, 3), dtype=np.uint8)
1619
+ with pytest.raises(ValueError, match="dataset"):
1620
+ run.save_outputs(
1621
+ epoch=0, step=0,
1622
+ metadata={"dataset": " "},
1623
+ rgb=dict(pred=arr),
1624
+ )
1625
+ run.finish()
1626
+
1627
+ def test_no_manifest_when_all_none(self, tmp_path):
1628
+ run = euler_train.init(dir=str(tmp_path / "r"), config={})
1629
+ run.save_outputs(epoch=0, step=0, rgb=None, depth=None)
1630
+
1631
+ manifest_path = run.dir / "outputs" / "epoch_0_step_0" / "manifest.json"
1632
+ assert not manifest_path.exists()
1633
+ run.finish()
1634
+
1635
+ def test_manifest_multiple_output_types(self, tmp_path):
1636
+ run = euler_train.init(dir=str(tmp_path / "r"), config={})
1637
+ img = np.zeros((4, 4, 3), dtype=np.uint8)
1638
+ dep = np.ones((4, 4), dtype=np.float32)
1639
+ run.save_outputs(epoch=0, step=0, rgb=dict(pred=img), depth=dict(pred=dep))
1640
+
1641
+ m = _read_json(run.dir / "outputs" / "epoch_0_step_0" / "manifest.json")
1642
+ assert m["output_types"]["rgb"]["pred"]["files"][0]["format"] == "png"
1643
+ assert m["output_types"]["depth"]["pred"]["files"][0]["format"] == "npy"
1644
+ run.finish()
1645
+
1646
+
1491
1647
  # ═══════════════════════════════════════════════════════════════════════════
1492
1648
  # init_checkpoint_dir
1493
1649
  # ═══════════════════════════════════════════════════════════════════════════
@@ -1766,14 +1922,13 @@ class TestStreaming:
1766
1922
  assert "elapsed_sec" not in val_event["records"][0]
1767
1923
 
1768
1924
  snapshot_event = consumer.events[5]
1769
- assert snapshot_event["snapshot"] == {
1770
- "epoch": 0,
1771
- "step": 1,
1772
- "types": {
1773
- "depth": ["aux/transmission"],
1774
- "rgb": ["gt", "pred"],
1775
- },
1776
- }
1925
+ snapshot = snapshot_event["snapshot"]
1926
+ assert snapshot["version"] == 1
1927
+ assert snapshot["epoch"] == 0
1928
+ assert snapshot["step"] == 1
1929
+ assert set(snapshot["output_types"]) == {"rgb", "depth"}
1930
+ assert set(snapshot["output_types"]["rgb"]) == {"pred", "gt"}
1931
+ assert "aux/transmission" in snapshot["output_types"]["depth"]
1777
1932
 
1778
1933
  checkpoint_event = consumer.events[6]
1779
1934
  assert checkpoint_event["checkpoint"] == {
File without changes