euler-train 2.1.0__tar.gz → 2.3.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.1.0
3
+ Version: 2.3.0
4
4
  Summary: Lightweight file-based experiment logger for PyTorch
5
5
  Requires-Python: >=3.9
6
6
  Requires-Dist: numpy
@@ -82,7 +82,7 @@ The run ID and directory are available as `run.run_id` and `run.dir`.
82
82
 
83
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`
84
84
 
85
- 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.
85
+ 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
86
 
87
87
  | Parameter | Type | Description |
88
88
  |---|---|---|
@@ -365,6 +365,24 @@ Auto-managed, not written to directly.
365
365
  "traceback": "Traceback (most recent call last):\n ..."
366
366
  }
367
367
  },
368
+ "updated_at": {
369
+ "meta.json": {
370
+ "time": 1706403300.0,
371
+ "iso": "2025-01-28T16:25:00"
372
+ },
373
+ "config.json": {
374
+ "time": 1706400000.0,
375
+ "iso": "2025-01-28T15:30:42"
376
+ },
377
+ "train.jsonl": {
378
+ "time": 1706402999.0,
379
+ "iso": "2025-01-28T16:19:59"
380
+ },
381
+ "outputs/epoch_12_step_4800": {
382
+ "time": 1706403010.0,
383
+ "iso": "2025-01-28T16:20:10"
384
+ }
385
+ },
368
386
  "error": "RuntimeError: CUDA OOM",
369
387
  "traceback": "Traceback (most recent call last):\n ..."
370
388
  }
@@ -375,6 +393,7 @@ Auto-managed, not written to directly.
375
393
  - `datasets` is only present when `datasets=...` is passed to `euler_train.init`.
376
394
  - `evaluations` is only present when evaluations are provided via `evaluations=...` on `init()` or added via `run.add_evaluation()`.
377
395
  - `modes` is only present when `mode=...` is passed to `euler_train.init`; each key stores the latest lifecycle snapshot for that mode.
396
+ - `updated_at` stores last-write timestamps for tracked artifacts. Keys are run-relative paths when the artifact lives inside the run directory (for example `train.jsonl` or `outputs/epoch_2_step_500`) and absolute paths for external artifacts such as custom checkpoint locations.
378
397
  - `error` is only present when `status` is `"crashed"` (context manager / excepthook) or `"interrupted"` (SIGTERM/SIGINT). `traceback` is only present when `status` is `"crashed"`. When `mode=...` is set, the same fields are mirrored under `modes[mode]`.
379
398
 
380
399
  A formal JSON Schema for `meta.json` is available at [`meta-schema.json`](meta-schema.json).
@@ -6,8 +6,24 @@ from typing import Any
6
6
  from . import _dataset_contract # noqa: F401
7
7
  from .run import Run
8
8
  from .architecture import export_architecture
9
+ from .stream import (
10
+ EulerViewStreamConfig,
11
+ EulerViewStreamConsumer,
12
+ OutputStream,
13
+ OutputStreamConsumer,
14
+ StreamContext,
15
+ )
9
16
 
10
- __all__ = ["init", "Run", "export_architecture"]
17
+ __all__ = [
18
+ "EulerViewStreamConfig",
19
+ "EulerViewStreamConsumer",
20
+ "OutputStream",
21
+ "OutputStreamConsumer",
22
+ "StreamContext",
23
+ "init",
24
+ "Run",
25
+ "export_architecture",
26
+ ]
11
27
  __version__ = "0.1.0"
12
28
 
13
29
 
@@ -22,6 +38,7 @@ def init(
22
38
  run_name: str | None = None,
23
39
  evaluations: dict[str, dict] | None = None,
24
40
  mode: str | None = None,
41
+ stream: Any = None,
25
42
  ) -> Run:
26
43
  """Create a new run — or resume an existing one — and return the handle.
27
44
 
@@ -89,6 +106,11 @@ def init(
89
106
  ``"train"``, ``"val"``, or ``"eval"``). When provided,
90
107
  lifecycle fields and crash details are mirrored into
91
108
  ``meta.json`` under ``modes[mode]``.
109
+ stream:
110
+ Optional output-stream consumer or config. Passing a mapping
111
+ with ``base_url`` plus either ``stream_token`` or the pair
112
+ ``model_id`` / ``access_token`` enables best-effort dual-write
113
+ streaming to the Euler View model-run ingest API.
92
114
  """
93
115
  return Run(
94
116
  dir=dir, config=config, meta=meta,
@@ -97,4 +119,5 @@ def init(
97
119
  run_id=run_id,
98
120
  datasets=datasets, run_name=run_name,
99
121
  evaluations=evaluations, mode=mode,
122
+ stream=stream,
100
123
  )
@@ -2,15 +2,16 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import atexit
5
+ import copy
5
6
  import logging
6
7
  import os
7
8
  import re
8
9
  import secrets
9
- import warnings
10
10
  import signal
11
11
  import sys
12
12
  import time
13
13
  import traceback
14
+ import warnings
14
15
  from collections.abc import Mapping
15
16
  from pathlib import Path
16
17
  from typing import Any
@@ -20,6 +21,7 @@ from .outputs import save_output_tree
20
21
  from .slurm import get_slurm_info
21
22
  from .git_info import get_code_ref
22
23
  from .environment import get_run_environment
24
+ from .stream import OutputStream, StreamContext, coerce_output_stream
23
25
 
24
26
  log = logging.getLogger("euler_train")
25
27
 
@@ -45,6 +47,7 @@ class Run:
45
47
  run_name: str | None = None,
46
48
  evaluations: dict[str, dict[str, Any]] | None = None,
47
49
  mode: str | None = None,
50
+ stream: Any = None,
48
51
  ) -> None:
49
52
  # ── detect run-directory shorthand ────────────────────────
50
53
  # When `dir` points to an existing run directory (contains
@@ -82,6 +85,9 @@ class Run:
82
85
  self._gpu_stats_every: int = gpu_stats_every
83
86
  self.run_name: str | None = run_name
84
87
  self.mode: str | None = _normalize_mode(mode)
88
+ self._pending_updated_at: dict[str, dict[str, Any]] = {}
89
+ self._stream: OutputStream | None = coerce_output_stream(stream)
90
+ self._stream_event_counters: dict[str, int] = {}
85
91
 
86
92
  # ── config ────────────────────────────────────────────────
87
93
  if resuming:
@@ -92,12 +98,30 @@ class Run:
92
98
  else:
93
99
  self.config: dict = normalize_config(config)
94
100
 
95
- write_json(self.dir / "config.json", self.config)
101
+ config_path = self.dir / "config.json"
102
+ write_json(config_path, self.config)
103
+ self._record_artifact_update(config_path)
104
+
105
+ # ── code ref & run environment ───────────────────────────
106
+ code_ref_path = self.dir / "code_ref.json"
107
+ run_environment_path = self.dir / "run_environment.json"
108
+ if resuming:
109
+ self._code_ref_payload = (
110
+ read_json(code_ref_path) if code_ref_path.exists() else None
111
+ )
112
+ self._run_environment_payload = (
113
+ read_json(run_environment_path)
114
+ if run_environment_path.exists()
115
+ else None
116
+ )
117
+ else:
118
+ self._code_ref_payload = get_code_ref()
119
+ write_json(code_ref_path, self._code_ref_payload)
120
+ self._record_artifact_update(code_ref_path)
96
121
 
97
- # ── code ref & run environment (fresh runs only) ─────────
98
- if not resuming:
99
- write_json(self.dir / "code_ref.json", get_code_ref())
100
- write_json(self.dir / "run_environment.json", get_run_environment())
122
+ self._run_environment_payload = get_run_environment()
123
+ write_json(run_environment_path, self._run_environment_payload)
124
+ self._record_artifact_update(run_environment_path)
101
125
 
102
126
  # ── meta ──────────────────────────────────────────────────
103
127
  if resuming:
@@ -153,6 +177,8 @@ class Run:
153
177
  )
154
178
  self._mark_mode_running()
155
179
  self._flush_meta()
180
+ self._bind_stream()
181
+ self._emit_stream_init()
156
182
  self._setup_hooks()
157
183
 
158
184
  verb = "Resumed" if resuming else "Started"
@@ -194,6 +220,19 @@ class Run:
194
220
  existing=self._meta.get("evaluations"),
195
221
  )
196
222
  self._flush_meta()
223
+ evaluations = self._meta.get("evaluations", {})
224
+ if isinstance(evaluations, dict) and key in evaluations:
225
+ self._emit_stream_event(
226
+ {
227
+ "type": "meta",
228
+ "eventId": self._make_stream_event_id("evaluation", key),
229
+ "patch": {
230
+ "evaluations": {
231
+ key: copy.deepcopy(evaluations[key]),
232
+ },
233
+ },
234
+ },
235
+ )
197
236
 
198
237
  def finish_evaluation(self, key: str, status: str = "completed") -> None:
199
238
  """Mark an existing evaluation as finished.
@@ -208,6 +247,23 @@ class Run:
208
247
  )
209
248
  evals[key]["status"] = status
210
249
  self._flush_meta()
250
+ self._emit_stream_event(
251
+ {
252
+ "type": "meta",
253
+ "eventId": self._make_stream_event_id(
254
+ "evaluation_status",
255
+ key,
256
+ status,
257
+ ),
258
+ "patch": {
259
+ "evaluations": {
260
+ key: {
261
+ "status": status,
262
+ },
263
+ },
264
+ },
265
+ },
266
+ )
211
267
 
212
268
  # ── logging ───────────────────────────────────────────────────
213
269
 
@@ -238,7 +294,19 @@ class Run:
238
294
  record.update(self._get_gpu_stats())
239
295
 
240
296
  filename = "train.jsonl" if mode == "train" else "val.jsonl"
241
- append_jsonl(self.dir / filename, record)
297
+ log_path = self.dir / filename
298
+ append_jsonl(log_path, record)
299
+ self._record_artifact_update(log_path)
300
+ self._flush_meta()
301
+ split = "train" if mode == "train" else "val"
302
+ self._emit_stream_event(
303
+ {
304
+ "type": "metric",
305
+ "eventId": self._make_stream_event_id(split, epoch, step),
306
+ "split": split,
307
+ "records": [copy.deepcopy(record)],
308
+ },
309
+ )
242
310
 
243
311
  # ── GPU stats ─────────────────────────────────────────────────
244
312
 
@@ -313,6 +381,22 @@ class Run:
313
381
  self._output_visualization,
314
382
  output_type,
315
383
  )
384
+ if base.exists():
385
+ self._record_artifact_update(base)
386
+ self._flush_meta()
387
+ snapshot = self._build_output_snapshot(epoch, step, output_types)
388
+ if snapshot is not None:
389
+ self._emit_stream_event(
390
+ {
391
+ "type": "output_snapshot",
392
+ "eventId": self._make_stream_event_id(
393
+ "outputs",
394
+ epoch,
395
+ step,
396
+ ),
397
+ "snapshot": snapshot,
398
+ },
399
+ )
316
400
  return base
317
401
 
318
402
  # ── checkpoints ───────────────────────────────────────────────
@@ -339,6 +423,7 @@ class Run:
339
423
  if self.checkpoint_dir is not None:
340
424
  self.checkpoint_dir.mkdir(parents=True, exist_ok=True)
341
425
  self._meta["checkpoint_dir"] = str(self.checkpoint_dir)
426
+ self._record_artifact_update(self.checkpoint_dir)
342
427
  self._flush_meta()
343
428
  log.info("Checkpoint dir: %s", self.checkpoint_dir)
344
429
  return self.checkpoint_dir
@@ -357,6 +442,7 @@ class Run:
357
442
  )
358
443
  self.checkpoint_dir.mkdir(parents=True, exist_ok=True)
359
444
  self._meta["checkpoint_dir"] = str(self.checkpoint_dir)
445
+ self._record_artifact_update(self.checkpoint_dir)
360
446
  self._flush_meta()
361
447
  log.info("Checkpoint dir: %s", self.checkpoint_dir)
362
448
  return self.checkpoint_dir
@@ -415,6 +501,7 @@ class Run:
415
501
  :meth:`save_checkpoint` call it automatically. When *is_best*
416
502
  is ``True``, any previous checkpoint marked as best is cleared.
417
503
  """
504
+ checkpoint_path = Path(path)
418
505
  checkpoints: list[dict[str, Any]] = self._meta.get("checkpoints", [])
419
506
 
420
507
  if is_best:
@@ -422,7 +509,7 @@ class Run:
422
509
  entry.pop("is_best", None)
423
510
 
424
511
  record: dict[str, Any] = {
425
- "path": str(path),
512
+ "path": str(checkpoint_path),
426
513
  "epoch": epoch,
427
514
  "step": step,
428
515
  }
@@ -431,7 +518,19 @@ class Run:
431
518
 
432
519
  checkpoints.append(record)
433
520
  self._meta["checkpoints"] = checkpoints
521
+ self._record_artifact_update(checkpoint_path)
434
522
  self._flush_meta()
523
+ self._emit_stream_event(
524
+ {
525
+ "type": "checkpoint",
526
+ "eventId": self._make_stream_event_id(
527
+ "checkpoint",
528
+ epoch,
529
+ step,
530
+ ),
531
+ "checkpoint": copy.deepcopy(record),
532
+ },
533
+ )
435
534
 
436
535
  # ── architecture export ─────────────────────────────────────────
437
536
 
@@ -467,6 +566,7 @@ class Run:
467
566
  model, dummy_input, self.dir / "architecture.onnx"
468
567
  )
469
568
  self._meta["architecture"] = "architecture.onnx"
569
+ self._record_artifact_update(output_path)
470
570
  self._flush_meta()
471
571
  return output_path
472
572
 
@@ -489,6 +589,16 @@ class Run:
489
589
  self._clear_terminal_fields(self._meta, status=status)
490
590
  self._finish_mode(status=status, end=end, duration=duration)
491
591
  self._flush_meta()
592
+ self._emit_stream_event(
593
+ {
594
+ "type": "finish",
595
+ "eventId": self._make_stream_event_id("finish", status),
596
+ "status": status,
597
+ "patch": self._build_finish_patch(),
598
+ },
599
+ flush=True,
600
+ )
601
+ self._close_stream()
492
602
 
493
603
  def detach(self) -> None:
494
604
  """Disconnect from the run without changing its status.
@@ -508,6 +618,7 @@ class Run:
508
618
  self._finished = True
509
619
  self._teardown_hooks()
510
620
  self._flush_meta()
621
+ self._close_stream()
511
622
 
512
623
  # ── context manager ───────────────────────────────────────────
513
624
 
@@ -652,9 +763,177 @@ class Run:
652
763
  self._meta["modes"] = modes
653
764
  return modes
654
765
 
766
+ def _updated_at_store(self) -> dict[str, Any]:
767
+ if not hasattr(self, "_meta"):
768
+ return self._pending_updated_at
769
+
770
+ updated_at = self._meta.get("updated_at")
771
+ if not isinstance(updated_at, dict):
772
+ updated_at = {}
773
+ self._meta["updated_at"] = updated_at
774
+
775
+ if self._pending_updated_at:
776
+ updated_at.update(self._pending_updated_at)
777
+ self._pending_updated_at.clear()
778
+
779
+ return updated_at
780
+
781
+ def _record_artifact_update(
782
+ self,
783
+ path: str | Path,
784
+ *,
785
+ when: float | None = None,
786
+ ) -> None:
787
+ artifact_path = Path(path)
788
+ ts = when if when is not None else _artifact_mtime(artifact_path)
789
+ if ts is None:
790
+ ts = time.time()
791
+ self._updated_at_store()[self._artifact_key(artifact_path)] = (
792
+ _updated_at_entry(ts)
793
+ )
794
+
795
+ def _artifact_key(self, path: Path) -> str:
796
+ if path.is_absolute():
797
+ try:
798
+ return path.relative_to(self.dir).as_posix()
799
+ except ValueError:
800
+ return str(path)
801
+ return path.as_posix()
802
+
655
803
  def _flush_meta(self) -> None:
804
+ self._record_artifact_update(self.dir / "meta.json", when=time.time())
656
805
  write_json(self.dir / "meta.json", self._meta)
657
806
 
807
+ def _bind_stream(self) -> None:
808
+ if self._stream is None:
809
+ return
810
+ self._stream.bind(
811
+ StreamContext(
812
+ run_id=self.run_id,
813
+ project_dir=self.project_dir,
814
+ runs_dir=self.dir.parent,
815
+ run_dir=self.dir,
816
+ meta=copy.deepcopy(self._meta),
817
+ config=copy.deepcopy(self.config),
818
+ code_ref=copy.deepcopy(self._code_ref_payload),
819
+ run_environment=copy.deepcopy(self._run_environment_payload),
820
+ ),
821
+ )
822
+
823
+ def _emit_stream_init(self) -> None:
824
+ tags = self._extract_stream_tags()
825
+ self._emit_stream_event(
826
+ {
827
+ "type": "init",
828
+ "eventId": self._make_stream_event_id("init"),
829
+ "meta": copy.deepcopy(self._meta),
830
+ "config": copy.deepcopy(self.config),
831
+ "codeRef": copy.deepcopy(self._code_ref_payload),
832
+ "runEnvironment": copy.deepcopy(self._run_environment_payload),
833
+ "tags": tags,
834
+ },
835
+ flush=True,
836
+ )
837
+
838
+ def _emit_stream_event(
839
+ self,
840
+ event: dict[str, Any],
841
+ *,
842
+ flush: bool = False,
843
+ ) -> None:
844
+ if self._stream is None:
845
+ return
846
+ self._stream.emit(event)
847
+ if flush:
848
+ self._stream.flush()
849
+
850
+ def _close_stream(self) -> None:
851
+ if self._stream is None:
852
+ return
853
+ self._stream.close()
854
+
855
+ def _extract_stream_tags(self) -> list[str] | None:
856
+ raw_tags = self._meta.get("tags")
857
+ if not isinstance(raw_tags, list):
858
+ return None
859
+ tags = [
860
+ str(tag).strip()
861
+ for tag in raw_tags
862
+ if isinstance(tag, str) and str(tag).strip()
863
+ ]
864
+ return tags or None
865
+
866
+ def _build_output_snapshot(
867
+ self,
868
+ epoch: int | None,
869
+ step: int | None,
870
+ output_types: dict[str, Any],
871
+ ) -> dict[str, Any] | None:
872
+ if epoch is None or step is None:
873
+ return None
874
+
875
+ snapshot_types: dict[str, list[str]] = {}
876
+ for output_type, slots in output_types.items():
877
+ if slots is None or not isinstance(slots, Mapping):
878
+ continue
879
+
880
+ categories: list[str] = []
881
+ for slot_name, data in slots.items():
882
+ if data is None:
883
+ continue
884
+ if slot_name == "aux" and isinstance(data, Mapping):
885
+ for aux_name, aux_value in data.items():
886
+ if aux_value is None:
887
+ continue
888
+ categories.append(f"aux/{aux_name}")
889
+ else:
890
+ categories.append(str(slot_name))
891
+
892
+ if categories:
893
+ snapshot_types[str(output_type)] = sorted(set(categories))
894
+
895
+ if not snapshot_types:
896
+ return None
897
+
898
+ return {
899
+ "epoch": epoch,
900
+ "step": step,
901
+ "types": snapshot_types,
902
+ }
903
+
904
+ def _build_finish_patch(self) -> dict[str, Any]:
905
+ patch = {
906
+ "end_time": self._meta.get("end_time"),
907
+ "end_iso": self._meta.get("end_iso"),
908
+ "duration_sec": self._meta.get("duration_sec"),
909
+ }
910
+
911
+ if "error" in self._meta:
912
+ patch["error"] = self._meta["error"]
913
+ if "traceback" in self._meta:
914
+ patch["traceback"] = self._meta["traceback"]
915
+
916
+ if self.mode is not None:
917
+ modes = self._meta.get("modes")
918
+ if isinstance(modes, dict) and self.mode in modes:
919
+ patch["modes"] = {
920
+ self.mode: copy.deepcopy(modes[self.mode]),
921
+ }
922
+
923
+ return patch
924
+
925
+ def _make_stream_event_id(self, prefix: str, *parts: Any) -> str:
926
+ counter = self._stream_event_counters.get(prefix, 0)
927
+ self._stream_event_counters[prefix] = counter + 1
928
+ tokens = [prefix]
929
+ tokens.extend(
930
+ str(part)
931
+ for part in parts
932
+ if part is not None and str(part).strip()
933
+ )
934
+ tokens.append(str(counter))
935
+ return ":".join(tokens)
936
+
658
937
  def __repr__(self) -> str:
659
938
  return f"Run(id={self.run_id!r}, dir={str(self.dir)!r}, status={self._meta['status']!r})"
660
939
 
@@ -732,6 +1011,20 @@ def _isotime(ts: float) -> str:
732
1011
  return time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(ts))
733
1012
 
734
1013
 
1014
+ def _updated_at_entry(ts: float) -> dict[str, Any]:
1015
+ return {
1016
+ "time": ts,
1017
+ "iso": _isotime(ts),
1018
+ }
1019
+
1020
+
1021
+ def _artifact_mtime(path: Path) -> float | None:
1022
+ try:
1023
+ return path.stat().st_mtime
1024
+ except OSError:
1025
+ return None
1026
+
1027
+
735
1028
  def _normalize_mode(mode: str | None) -> str | None:
736
1029
  if mode is None:
737
1030
  return None
@@ -1050,6 +1343,9 @@ def _read_ds_crawler_descriptor(path: str) -> dict[str, Any]:
1050
1343
  try:
1051
1344
  cfg = load_dataset_config({"path": path})
1052
1345
  except Exception:
1346
+ descriptor = _read_output_json_descriptor(path)
1347
+ if descriptor:
1348
+ return descriptor
1053
1349
  try:
1054
1350
  contract = get_dataset_contract(path)
1055
1351
  except Exception:
@@ -1082,6 +1378,69 @@ def _read_ds_crawler_descriptor(path: str) -> dict[str, Any]:
1082
1378
  return descriptor
1083
1379
 
1084
1380
 
1381
+ def _read_output_json_descriptor(path: str) -> dict[str, Any]:
1382
+ try:
1383
+ from ds_crawler import zip_utils
1384
+ except Exception:
1385
+ return {}
1386
+
1387
+ try:
1388
+ raw = zip_utils.read_metadata_json(Path(path), "output.json")
1389
+ except Exception:
1390
+ return {}
1391
+
1392
+ return _descriptor_from_output_json(raw, path)
1393
+
1394
+
1395
+ def _descriptor_from_output_json(raw: Any, path: str) -> dict[str, Any]:
1396
+ if isinstance(raw, Mapping):
1397
+ candidates: list[Any]
1398
+ modalities = raw.get("modalities")
1399
+ if isinstance(modalities, list):
1400
+ candidates = modalities
1401
+ else:
1402
+ candidates = [raw]
1403
+ elif isinstance(raw, list):
1404
+ candidates = raw
1405
+ else:
1406
+ return {}
1407
+
1408
+ selected: Mapping[str, Any] | None = None
1409
+ path_name = Path(path).name
1410
+ for candidate in candidates:
1411
+ if not isinstance(candidate, Mapping):
1412
+ continue
1413
+ if _as_non_empty_str(candidate.get("name")) == path_name:
1414
+ selected = candidate
1415
+ break
1416
+ if selected is None:
1417
+ selected = candidate
1418
+
1419
+ if selected is None:
1420
+ return {}
1421
+
1422
+ descriptor: dict[str, Any] = {}
1423
+ properties = selected.get("properties")
1424
+ merged_properties = dict(properties) if isinstance(properties, Mapping) else {}
1425
+
1426
+ euler_train_props = selected.get("euler_train")
1427
+ if isinstance(euler_train_props, Mapping):
1428
+ merged_properties["euler_train"] = dict(euler_train_props)
1429
+
1430
+ if merged_properties:
1431
+ descriptor["properties"] = merged_properties
1432
+
1433
+ modality_key = _as_non_empty_str(selected.get("type"))
1434
+ if modality_key is not None:
1435
+ descriptor["modality_key"] = modality_key
1436
+
1437
+ hierarchy_regex = _as_non_empty_str(selected.get("hierarchy_regex"))
1438
+ if hierarchy_regex is not None:
1439
+ descriptor["hierarchy_regex"] = hierarchy_regex
1440
+
1441
+ return descriptor
1442
+
1443
+
1085
1444
  def _build_modality_entry(
1086
1445
  name: str,
1087
1446
  path: str,