hypergan 2.0.0a1__py3-none-any.whl

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.
Files changed (140) hide show
  1. hypergan/__init__.py +39 -0
  2. hypergan/__main__.py +5 -0
  3. hypergan/_build_provenance.json +1 -0
  4. hypergan/_file_lock.py +32 -0
  5. hypergan/api.py +2221 -0
  6. hypergan/artifacts.py +334 -0
  7. hypergan/autoencoder_components.py +60 -0
  8. hypergan/background_poll.py +74 -0
  9. hypergan/bounded_cli_output.py +562 -0
  10. hypergan/bounded_observer.py +278 -0
  11. hypergan/checkpoint_compatibility.py +203 -0
  12. hypergan/checkpoints.py +902 -0
  13. hypergan/cli.py +2165 -0
  14. hypergan/color_words.py +288 -0
  15. hypergan/colorization_components.py +360 -0
  16. hypergan/colorization_data.py +256 -0
  17. hypergan/colorization_metrics.py +168 -0
  18. hypergan/config.py +1041 -0
  19. hypergan/console_settings.py +87 -0
  20. hypergan/cpu_worker_service.py +575 -0
  21. hypergan/cpu_workers.py +125 -0
  22. hypergan/data.py +429 -0
  23. hypergan/datasets.py +6 -0
  24. hypergan/diff_augment.py +131 -0
  25. hypergan/distributed.py +110 -0
  26. hypergan/distributed_checkpoints.py +558 -0
  27. hypergan/distributed_commit.py +297 -0
  28. hypergan/distributed_training.py +733 -0
  29. hypergan/diversity_metrics.py +175 -0
  30. hypergan/evaluation_cli.py +22 -0
  31. hypergan/evaluation_snapshot.py +28 -0
  32. hypergan/event_views.py +500 -0
  33. hypergan/execution.py +360 -0
  34. hypergan/execution_preflight.py +264 -0
  35. hypergan/execution_profiles.py +142 -0
  36. hypergan/gpu_inventory.py +687 -0
  37. hypergan/hndl_augmentation.py +40 -0
  38. hypergan/hndl_networks.py +42 -0
  39. hypergan/image_cache.py +66 -0
  40. hypergan/image_components.py +326 -0
  41. hypergan/image_data.py +96 -0
  42. hypergan/image_grids.py +151 -0
  43. hypergan/image_metrics.py +121 -0
  44. hypergan/inspection.py +1956 -0
  45. hypergan/interval_evaluation.py +294 -0
  46. hypergan/item_data.py +165 -0
  47. hypergan/metric_evaluation.py +425 -0
  48. hypergan/metric_evaluation_worker.py +136 -0
  49. hypergan/metric_examples.py +81 -0
  50. hypergan/metric_plugins.py +339 -0
  51. hypergan/metrics.py +407 -0
  52. hypergan/metrics_reducer/__init__.py +12 -0
  53. hypergan/model_description.py +651 -0
  54. hypergan/model_file.py +1014 -0
  55. hypergan/models/gaussian-grid.toml +48 -0
  56. hypergan/network_config.py +131 -0
  57. hypergan/networks/autoencoder_offset.hndl +1 -0
  58. hypergan/networks/colorization_conv_head.hndl +6 -0
  59. hypergan/networks/colorization_dcgan256.hndl +14 -0
  60. hypergan/networks/colorization_dinov3_multidepth.hndl +20 -0
  61. hypergan/networks/colorization_dinov3_patch_tokens.hndl +19 -0
  62. hypergan/networks/colorization_encoder_features.hndl +19 -0
  63. hypergan/networks/colorization_feature_concat.hndl +2 -0
  64. hypergan/networks/colorization_feature_project.hndl +3 -0
  65. hypergan/networks/colorization_generator16.hndl +69 -0
  66. hypergan/networks/colorization_generator32.hndl +69 -0
  67. hypergan/networks/colorization_joint_score.hndl +2 -0
  68. hypergan/networks/colorization_linear_head.hndl +3 -0
  69. hypergan/networks/colorization_multidepth_projection.hndl +18 -0
  70. hypergan/networks/colorization_multiscale_score.hndl +2 -0
  71. hypergan/networks/colorization_pixel_features.hndl +8 -0
  72. hypergan/networks/colorization_pixel_head.hndl +17 -0
  73. hypergan/networks/colorization_query.hndl +2 -0
  74. hypergan/networks/colorization_random_project.hndl +2 -0
  75. hypergan/networks/colorization_scale_head16.hndl +8 -0
  76. hypergan/networks/colorization_scale_head32.hndl +10 -0
  77. hypergan/networks/colorization_scale_head4.hndl +4 -0
  78. hypergan/networks/colorization_scale_head8.hndl +6 -0
  79. hypergan/networks/image_attention.hndl +15 -0
  80. hypergan/networks/image_critic_score.hndl +6 -0
  81. hypergan/networks/image_encoder.hndl +17 -0
  82. hypergan/networks/image_feature_head.hndl +9 -0
  83. hypergan/networks/image_generator.hndl +13 -0
  84. hypergan/networks/image_pixel.hndl +7 -0
  85. hypergan/networks/image_pixel_block.hndl +16 -0
  86. hypergan/networks/image_resnet_stage1.hndl +7 -0
  87. hypergan/networks/image_resnet_stage2.hndl +2 -0
  88. hypergan/networks/image_resnet_stage3.hndl +2 -0
  89. hypergan/networks/reference.hndl +6 -0
  90. hypergan/numerical_policy.py +95 -0
  91. hypergan/objective_program.py +425 -0
  92. hypergan/observation_io.py +150 -0
  93. hypergan/particlegan_integration.py +134 -0
  94. hypergan/plugin_functions.py +94 -0
  95. hypergan/ports.py +9 -0
  96. hypergan/pretrained_providers.py +104 -0
  97. hypergan/preview_snapshot.py +229 -0
  98. hypergan/preview_worker.py +128 -0
  99. hypergan/previews.py +590 -0
  100. hypergan/provenance.py +187 -0
  101. hypergan/queue.py +819 -0
  102. hypergan/recipe_generator.py +691 -0
  103. hypergan/recipes.py +279 -0
  104. hypergan/registry.py +1337 -0
  105. hypergan/replicated_execution.py +487 -0
  106. hypergan/replicated_worker.py +184 -0
  107. hypergan/run_controller.py +990 -0
  108. hypergan/run_events.py +185 -0
  109. hypergan/run_requests.py +277 -0
  110. hypergan/run_signals.py +42 -0
  111. hypergan/run_state.py +80 -0
  112. hypergan/single_execution.py +225 -0
  113. hypergan/snapshot_renderer.py +71 -0
  114. hypergan/summary.py +336 -0
  115. hypergan/toml_writer.py +125 -0
  116. hypergan/training.py +543 -0
  117. hypergan/web_assets/THIRD_PARTY_LICENSES.txt +289 -0
  118. hypergan/web_assets/__init__.py +25 -0
  119. hypergan/web_assets/app.js +22 -0
  120. hypergan/web_assets/index.html +225 -0
  121. hypergan/web_assets/logo.png +0 -0
  122. hypergan/web_assets/runs.css +75 -0
  123. hypergan/web_assets/runs.js +203 -0
  124. hypergan/web_assets/style.css +871 -0
  125. hypergan/web_assets/view-worker.js +204 -0
  126. hypergan/web_autostart.py +1283 -0
  127. hypergan/web_dev.py +132 -0
  128. hypergan/web_files.py +83 -0
  129. hypergan/web_launch.py +272 -0
  130. hypergan/web_schema.py +165 -0
  131. hypergan/web_server.py +1323 -0
  132. hypergan/web_service.py +651 -0
  133. hypergan/web_session.py +191 -0
  134. hypergan/workspace.py +325 -0
  135. hypergan-2.0.0a1.dist-info/METADATA +328 -0
  136. hypergan-2.0.0a1.dist-info/RECORD +140 -0
  137. hypergan-2.0.0a1.dist-info/WHEEL +5 -0
  138. hypergan-2.0.0a1.dist-info/entry_points.txt +2 -0
  139. hypergan-2.0.0a1.dist-info/licenses/LICENSE +21 -0
  140. hypergan-2.0.0a1.dist-info/top_level.txt +1 -0
hypergan/api.py ADDED
@@ -0,0 +1,2221 @@
1
+ """HyperGAN's Python API: a thin, functional layer over model files.
2
+
3
+ The model file is the source of truth (see ``hypergan.model_file``). This module
4
+ loads one, lets a script change values, trains it and reads the run back::
5
+
6
+ import hypergan.api as hg
7
+
8
+ model = hg.load("model.toml")
9
+ model = hg.override(model, {"train.steps": 200, "losses.reconstruction.weight": 0.5})
10
+ run = hg.train(model, "runs/demo", previews=50)
11
+ hg.metrics(run)["loss/d_total"] # [(step, value), ...]
12
+
13
+ Everything here is a plain function over two data objects, ``Model`` (the
14
+ model file as a dictionary plus the directory its relative paths use) and
15
+ ``Run`` (a run directory and its manifest). Nothing is copied into a run
16
+ directory: the run records the lowered recipe, and user code stays where the
17
+ model file's import paths point.
18
+ """
19
+ from copy import deepcopy
20
+ from dataclasses import dataclass, field
21
+ import hashlib
22
+ import inspect as _py_inspect
23
+ import json
24
+ from pathlib import Path
25
+ import re
26
+
27
+ from .model_file import FORMAT, ConfigError, ModelFileError, is_model_file, lower as _lower, model_warnings, upstream, producer
28
+ from .toml_writer import dumps as _dumps
29
+
30
+ try:
31
+ import tomllib
32
+ except ModuleNotFoundError: # Python 3.10
33
+ import tomli as tomllib
34
+
35
+ from .workspace import find_workspace, load_workspace, interpolate_variables, resolve_device_aliases
36
+ from .model_file import resolve_inheritance, collect_source_files
37
+
38
+ PACKAGED = Path(__file__).with_name('models')
39
+
40
+ __all__ = ['Model', 'Run', 'Sample', 'Image', 'Audio', 'ModelFileError', 'ConfigError',
41
+ 'model', 'load', 'load_bundle', 'packaged', 'packaged_list', 'from_dict', 'override', 'save', 'dumps', 'lower', 'resolve', 'validate',
42
+ 'validate_config', 'init_project', 'new', 'list_templates',
43
+ 'fingerprint', 'explain', 'import_path', 'plain', 'hndl', 'module', 'sampler', 'adversarial',
44
+ 'reconstruction', 'objective', 'prior_loss', 'prepare', 'prepare_resume',
45
+ 'train', 'resume', 'open_run', 'inspect', 'metrics', 'evaluations', 'observations', 'catalog', 'events',
46
+ 'samples', 'sample', 'load_inference', 'view', 'evaluate', 'image', 'audio',
47
+ 'lift', 'load_recipe', 'describe', 'describe_model', 'data_check', 'check_data', 'preflight', 'preflight_check', 'serve', 'viewer_status',
48
+ 'stop_viewer', 'contributions', 'project', 'checkpoint_status', 'request_checkpoint',
49
+ 'export', 'prune_checkpoints', 'resolve_checkpoint', 'extract_ema_bundle',
50
+ 'ensure_dashboard_running', 'stop_all_servers', 'dashboard_status', 'stop_dashboard',
51
+ 'find_runs_root', 'stop_projector',
52
+ 'find_workspace', 'load_workspace', 'interpolate_variables', 'resolve_device_aliases',
53
+ 'resolve_inheritance', 'collect_source_files',
54
+ 'status', 'ps', 'stop', 'logs', 'tag', 'rm', 'list_runs', 'runs', 'runs_ls', 'runs_compare', 'compare', 'diff', 'gc', 'prune', 'gc_all', 'compact_events',
55
+ 'resolve_run_target', 'register_run', 'sync_registry', 'generate_run_slug',
56
+ 'queue_add', 'queue_ls', 'queue_status', 'queue_stop', 'queue_run', 'queue_install_service', 'queue_clear']
57
+
58
+
59
+
60
+
61
+ # --------------------------------------------------------------------------- model files
62
+
63
+ @dataclass(frozen=True)
64
+ class Model:
65
+ """A model file's contents. ``base`` resolves relative ``hndl`` paths."""
66
+ spec: dict
67
+ base: Path | None = None
68
+ path: Path | None = None
69
+
70
+ @property
71
+ def name(self):
72
+ return self.spec.get('name', 'custom/model')
73
+
74
+
75
+ def _model(value):
76
+ if isinstance(value, Model):
77
+ return value
78
+ if isinstance(value, (str, Path)):
79
+ return load(value)
80
+ if isinstance(value, dict):
81
+ return from_dict(value)
82
+ raise TypeError(f'Expected a Model, model file path or dictionary, got {type(value).__name__}')
83
+
84
+
85
+ def load(path):
86
+ """Load a model file, an engine recipe TOML, a run's stored Model, or a .pt bundle."""
87
+ if isinstance(path, Run):
88
+ return _load_stored_run_model(path.path)
89
+ raw = str(path)
90
+ path = Path(path)
91
+ if path.suffix.lower() == '.pt':
92
+ return load_bundle(path)
93
+ if not path.exists() and path.suffix.lower() not in ('.toml', '.pt'):
94
+ try:
95
+ resolved_run = resolve_run_target(raw, allow_latest=(raw == 'latest'))
96
+ if resolved_run.is_dir():
97
+ return _load_stored_run_model(resolved_run)
98
+ except Exception:
99
+ pass
100
+ if path.is_dir():
101
+ if (path / 'model.toml').is_file():
102
+ path = path / 'model.toml'
103
+ elif (path / 'config.toml').is_file():
104
+ path = path / 'config.toml'
105
+ elif any(
106
+ (path / name).exists()
107
+ for name in ('recipe.toml', 'manifest.json', 'summary.json', 'config-source')
108
+ ):
109
+ return _load_stored_run_model(path)
110
+ else:
111
+ path = path / 'config.toml'
112
+ with path.open('rb') as stream:
113
+ spec = tomllib.load(stream)
114
+
115
+ if isinstance(spec, dict):
116
+ ws_path = find_workspace(path)
117
+ ws_dict = load_workspace(ws_path) if ws_path else None
118
+ ws_root = ws_path.parent if ws_path else None
119
+
120
+ if 'extends' in spec:
121
+ spec = resolve_inheritance(spec, config_path=path, workspace_root=ws_root)
122
+ elif is_model_file(spec) and 'name' not in spec:
123
+ spec['name'] = path.stem
124
+
125
+ if ws_dict is not None:
126
+ spec = interpolate_variables(spec, ws_dict)
127
+
128
+ return Model(spec, base=path.parent.resolve(), path=path.resolve())
129
+
130
+
131
+
132
+ def packaged(name):
133
+ """A model file shipped with HyperGAN, e.g. ``packaged("gaussian-grid")``."""
134
+ path = PACKAGED / f'{name}.toml'
135
+ if re.fullmatch(r'[a-z0-9][a-z0-9-]*', name) is None or not path.is_file():
136
+ choices = sorted(p.stem for p in PACKAGED.glob('*.toml'))
137
+ raise ValueError(f'Unknown packaged model {name!r}; choose one of {", ".join(choices)}')
138
+ return load(path)
139
+
140
+
141
+ def packaged_list():
142
+ """List available packaged model files."""
143
+ results = []
144
+ for path in sorted(PACKAGED.glob('*.toml')):
145
+ try:
146
+ m = load(path)
147
+ results.append({
148
+ 'name': path.stem,
149
+ 'path': str(path),
150
+ 'description': m.spec.get('name', path.stem),
151
+ 'scope': 'numerical-reference' if path.stem == 'gaussian-grid' else 'packaged',
152
+ })
153
+ except Exception:
154
+ results.append({'name': path.stem, 'path': str(path)})
155
+ return results
156
+
157
+
158
+ def from_dict(spec, base=None):
159
+ """A model built in Python; ``base`` resolves any relative ``hndl`` paths."""
160
+ if not isinstance(spec, dict):
161
+ raise TypeError('A model is a dictionary shaped like a model file')
162
+ return Model(deepcopy(spec), base=Path(base).resolve() if base is not None else None)
163
+
164
+
165
+ def model(name='custom/model', *, data=None, prior=None, networks=None, losses=None,
166
+ penalty=None, train=None, sampling=None, samplers=None, metrics=None,
167
+ evaluations=None, defaults=None, base=None, **kwargs):
168
+ """Keyword constructor for a Model (front end over from_dict)."""
169
+ spec = {'format': kwargs.pop('format', FORMAT)}
170
+ if name is not None:
171
+ spec['name'] = name
172
+ if defaults is not None:
173
+ spec['defaults'] = defaults
174
+ if data is not None:
175
+ spec['data'] = data
176
+ if prior is not None:
177
+ spec['prior'] = prior
178
+ if networks is not None:
179
+ spec['networks'] = networks
180
+ if losses is not None:
181
+ spec['losses'] = losses
182
+ if penalty is not None:
183
+ spec['penalty'] = penalty
184
+ if train is not None:
185
+ spec['train'] = train
186
+ if sampling is not None:
187
+ spec['sampling'] = sampling
188
+ if samplers is not None:
189
+ spec['samplers'] = samplers
190
+ if metrics is not None:
191
+ spec['metrics'] = metrics
192
+ if evaluations is not None:
193
+ spec['evaluations'] = evaluations
194
+ spec.update(kwargs)
195
+ return from_dict(spec, base=base)
196
+
197
+
198
+ def _segment(container, key, location):
199
+ if isinstance(container, list):
200
+ if key.isdigit() and int(key) < len(container):
201
+ return int(key)
202
+ # Losses, like other lists of tables, can be addressed by id, critic, or type.
203
+ for index, item in enumerate(container):
204
+ if isinstance(item, dict) and key in (item.get('id'), item.get('critic')):
205
+ return index
206
+ for index, item in enumerate(container):
207
+ if isinstance(item, dict) and key == item.get('type'):
208
+ return index
209
+ raise KeyError(f'{location}: no list element {key!r} (use an index, id, critic, or type name)')
210
+ return key
211
+
212
+
213
+ def override(model, changes=None, /, **sections):
214
+ """Return a copy with dotted-path values set, e.g. ``{"train.steps": 10}``.
215
+
216
+ List elements are addressed by index, ``id`` or ``critic``:
217
+ ``"losses.reconstruction.weight"``. Keyword sections deep-merge tables:
218
+ ``override(model, train={"steps": 10})``.
219
+ """
220
+ from .model_file import OPTIMIZER_FIELDS, is_model_file
221
+ model = _model(model)
222
+ spec = deepcopy(model.spec)
223
+ is_model = is_model_file(spec)
224
+
225
+ norm_changes = {}
226
+ for dotted, value in (changes or {}).items():
227
+ if is_model and dotted in ('trainer', 'training', 'optimizer') and isinstance(value, dict):
228
+ for k, v in value.items():
229
+ norm_changes[f'train.{k}'] = v
230
+ continue
231
+ if not is_model and dotted in ('train', 'trainer') and isinstance(value, dict):
232
+ for k, v in value.items():
233
+ if k in OPTIMIZER_FIELDS and 'optimizer' in spec:
234
+ norm_changes[f'optimizer.{k}'] = v
235
+ else:
236
+ norm_changes[f'training.{k}'] = v
237
+ continue
238
+ if dotted.startswith('trainer.'):
239
+ dotted = 'train.' + dotted[len('trainer.'):]
240
+ if not is_model and (dotted.startswith('train.') or dotted.startswith('training.')):
241
+ field = dotted.split('.', 1)[1]
242
+ if field in OPTIMIZER_FIELDS and 'optimizer' in spec:
243
+ norm_changes[f'optimizer.{field}'] = value
244
+ elif 'training' in spec:
245
+ norm_changes[f'training.{field}'] = value
246
+ else:
247
+ norm_changes[dotted] = value
248
+ elif is_model and (dotted.startswith('training.') or dotted.startswith('optimizer.')):
249
+ field = dotted.split('.', 1)[1]
250
+ norm_changes[f'train.{field}'] = value
251
+ else:
252
+ norm_changes[dotted] = value
253
+
254
+ for dotted, value in norm_changes.items():
255
+ parts = dotted.split('.')
256
+ target = spec
257
+ for depth, part in enumerate(parts[:-1]):
258
+ key = _segment(target, part, '.'.join(parts[:depth + 1]))
259
+ if isinstance(target, dict) and key not in target:
260
+ target[key] = {}
261
+ target = target[key]
262
+ last_key = _segment(target, parts[-1], dotted) if isinstance(target, list) else parts[-1]
263
+ target[last_key] = deepcopy(value)
264
+ if isinstance(target, dict) and last_key == 'id' and value is None:
265
+ target['__id_required__'] = True
266
+
267
+ norm_sections = {}
268
+ for key, value in sections.items():
269
+ if is_model and key in ('trainer', 'training', 'optimizer') and isinstance(value, dict):
270
+ norm_sections.setdefault('train', {}).update(deepcopy(value))
271
+ else:
272
+ norm_sections[key] = value
273
+
274
+ def merge(into, update):
275
+ for key, value in update.items():
276
+ if isinstance(value, dict) and isinstance(into.get(key), dict):
277
+ merge(into[key], value)
278
+ else:
279
+ into[key] = deepcopy(value)
280
+ merge(spec, norm_sections)
281
+ return Model(spec, base=model.base, path=None)
282
+
283
+
284
+ def _relocated(spec, base, destination):
285
+ """Rewrite relative hndl paths so a saved copy still reads the same files."""
286
+ spec = deepcopy(spec)
287
+ def relocated(file):
288
+ if not isinstance(file, str) or Path(file).is_absolute():
289
+ return file
290
+ if base is None:
291
+ raise ModelFileError('A model with relative hndl paths needs a base directory to be saved elsewhere')
292
+ import os
293
+ source = (base / file).resolve()
294
+ try:
295
+ shared = Path(os.path.commonpath([source, destination]))
296
+ except ValueError:
297
+ # Windows volumes have no shared directory or relative path.
298
+ return source.as_posix()
299
+ # Relative when the files share a project directory, else absolute.
300
+ return (Path(os.path.relpath(source, destination)).as_posix()
301
+ if shared != Path(shared.anchor) else source.as_posix())
302
+
303
+ for network in (spec.get('networks') or {}).values():
304
+ if isinstance(network, dict):
305
+ for hndl_key in ('hndl', 'file'):
306
+ if network.get(hndl_key) is not None:
307
+ network[hndl_key] = relocated(network[hndl_key])
308
+ for name, file in (network.get('network_files') or {}).items():
309
+ network['network_files'][name] = relocated(file)
310
+ for component in (spec.get('components') or {}).values():
311
+ args = component.get('args', {})
312
+ if component.get('factory') == 'hndl' and 'file' in args:
313
+ args['file'] = relocated(args['file'])
314
+ for name, file in args.get('network_files', {}).items():
315
+ args['network_files'][name] = relocated(file)
316
+ return spec
317
+
318
+
319
+ def dumps(model):
320
+ """The model file text (TOML)."""
321
+ model = _model(model)
322
+ return _dumps(model.spec, table_depth=3)
323
+
324
+
325
+ _HASH = re.compile(r"^# body-sha256: ([0-9a-f]{64})$", re.M)
326
+
327
+
328
+ def save(model, path, *, overwrite=False):
329
+ """Write the model file; referenced .hndl files and Python are not copied."""
330
+ model = _model(model)
331
+ path = Path(path)
332
+ if path.suffix != '.toml':
333
+ raise ValueError('Model files use the .toml suffix')
334
+ spec = _relocated(model.spec, model.base, path.parent.resolve())
335
+ if is_model_file(spec):
336
+ spec = {'format': FORMAT, **{k: v for k, v in spec.items() if k != 'format'}}
337
+ body = _dumps(spec, table_depth=3)
338
+ digest = hashlib.sha256(body.encode()).hexdigest()
339
+ header = (
340
+ "# HyperGAN model written by hypergan.api (hg.save). Train it with `hypergan train` or hg.train.\n"
341
+ "# Networks and data are referenced by import path; edit those modules, not copies.\n"
342
+ "# hg.save replaces this file only while it is unedited; after editing it here, load it with hg.load.\n"
343
+ f"# body-sha256: {digest}\n\n"
344
+ )
345
+ text = header + body
346
+ if path.exists() and not overwrite:
347
+ existing = path.read_text(encoding="utf-8")
348
+ match = _HASH.search(existing)
349
+ if existing != text:
350
+ if match is None:
351
+ raise FileExistsError(f"{path} exists and was not written by hg.save; pass overwrite=True or choose another path")
352
+ current = existing.split("\n\n", 1)[1] if "\n\n" in existing else ""
353
+ if hashlib.sha256(current.encode()).hexdigest() != match.group(1):
354
+ raise FileExistsError(f"{path} was edited after hg.save wrote it; load it with hg.load(path) "
355
+ "to train the edited version, or pass overwrite=True to replace it")
356
+ path.parent.mkdir(parents=True, exist_ok=True)
357
+ temporary = path.with_name(path.name + '.tmp')
358
+ temporary.write_text(text, encoding="utf-8")
359
+ temporary.replace(path)
360
+ return path
361
+
362
+
363
+ def lower(model):
364
+ """The engine recipe for this model (torch-free, no user code imported)."""
365
+ model = _model(model)
366
+ if is_model_file(model.spec):
367
+ return _lower(model.spec, model.base)
368
+ if model.base is not None:
369
+ from .config import inline_network_sources
370
+ return inline_network_sources(model.spec, model.base)
371
+ return deepcopy(model.spec)
372
+
373
+
374
+ def resolve(model):
375
+ """The complete resolved configuration a run would record."""
376
+ from .config import load_config
377
+ return load_config(lower(model))
378
+
379
+
380
+ def validate(model):
381
+ """Check the model without Torch or user code; return warnings, raise on errors."""
382
+ model = _model(model)
383
+ resolved = resolve(model)
384
+ extra = model_warnings(model.spec) if is_model_file(model.spec) else []
385
+ return extra + list(resolved['warnings'])
386
+
387
+
388
+ validate_config = validate
389
+
390
+
391
+ def init_project(project_dir, *, template="dcgan", resolution=None, data_dir=None, output_config=None, device="cuda", overwrite=False):
392
+ """Initialize a HyperGAN project directory with hypergan.toml, runs/, and configs/<template>.toml."""
393
+ from .recipe_generator import init_project as _init_project
394
+ return _init_project(project_dir, template=template, resolution=resolution, data_dir=data_dir,
395
+ output_config=output_config, device=device, overwrite=overwrite)
396
+
397
+
398
+ def new(data_dir=None, output_path=None, *, template=None, resolution=None, device="cuda",
399
+ project=None, overwrite=False, **kwargs):
400
+ """Scaffold a new HyperGAN model file (or initialize a project directory when project is set)."""
401
+ if project is not None:
402
+ return init_project(
403
+ project,
404
+ template=template or "dcgan",
405
+ resolution=resolution,
406
+ data_dir=data_dir,
407
+ output_config=output_path,
408
+ device=device,
409
+ overwrite=overwrite,
410
+ )
411
+ from .recipe_generator import generate_recipe as _generate_recipe
412
+ ds_dir = data_dir
413
+ out_p = output_path
414
+ if out_p is None and ds_dir is not None:
415
+ p = Path(ds_dir)
416
+ if p.suffix.lower() == ".toml" or not p.is_dir():
417
+ out_p = ds_dir
418
+ ds_dir = None
419
+ name_arg = str(out_p) if out_p is not None else kwargs.pop("name", None)
420
+ return _generate_recipe(
421
+ name=name_arg,
422
+ template=template or "dcgan",
423
+ resolution=resolution,
424
+ dataset_dir=ds_dir,
425
+ device=device,
426
+ overwrite=overwrite,
427
+ **kwargs,
428
+ )
429
+
430
+
431
+ def fingerprint(model):
432
+ """Numerical identity: equal fingerprints train the same recipe."""
433
+ from .config import fingerprint as recipe_fingerprint
434
+ return recipe_fingerprint(resolve(model))
435
+
436
+
437
+ def explain(model):
438
+ """Per network: role, optimizer group, own losses, and gradient that reaches it."""
439
+ model = _model(model)
440
+ recipe = lower(model)
441
+ components = recipe.get('components', {})
442
+ name_map = recipe.get('name_map', {})
443
+ inv_name_map = {v: k for k, v in name_map.items()}
444
+ opt_cfg = recipe.get('optimizer', {})
445
+ rec_cfg = recipe.get('recipe', {})
446
+ eff_lr = opt_cfg.get('lr', rec_cfg.get('lr', 0.0002))
447
+ eff_d_mult = opt_cfg.get('d_lr_mult', rec_cfg.get('d_lr_mult', 1.0))
448
+ eff_d_lr = eff_lr * eff_d_mult if isinstance(eff_lr, (int, float)) and isinstance(eff_d_mult, (int, float)) else None
449
+ g_lr_str = f'optimizer.lr ({eff_lr:g})' if isinstance(eff_lr, (int, float)) else 'optimizer.lr'
450
+ d_lr_str = f'optimizer.lr * d_lr_mult ({eff_d_lr:g})' if isinstance(eff_d_lr, (int, float)) else 'optimizer.lr * d_lr_mult'
451
+ reach = {name: [] for name in components}
452
+ for term in recipe.get('objectives', []):
453
+ detached = set(term.get('detach', ['target'] if 'target' in term.get('inputs', {}) else []))
454
+ seen = set()
455
+ for key, path in term.get('inputs', {}).items():
456
+ if key not in detached:
457
+ upstream(path, components, seen=seen)
458
+ label = term.get('id') or term.get('factory')
459
+ for name in seen:
460
+ reach[name].append(f'loss {label}')
461
+ terms = []
462
+ if 'adversarial' in recipe:
463
+ disc_inputs = components.get('discriminator', {}).get('inputs', {})
464
+ fake_path = recipe['adversarial'].get('fake', 'generated')
465
+ if any(p == 'candidate' or p.startswith('candidate.') or producer(p) == 'generator' for p in disc_inputs.values()):
466
+ terms.append(('discriminator', fake_path))
467
+ for t in recipe.get('adversarial_terms', ()):
468
+ terms.append((t['component'], t['fake']))
469
+ for critic_name, fake in terms:
470
+ orig_critic = inv_name_map.get(critic_name, critic_name)
471
+ critic_label = f'{critic_name} ({orig_critic})' if orig_critic != critic_name else critic_name
472
+ for name in upstream(fake, components):
473
+ reach[name].append(f'fooling {critic_label}')
474
+
475
+ lines = []
476
+ networks = model.spec.get('networks') if is_model_file(model.spec) else None
477
+ if networks:
478
+ for orig_name, net_spec in networks.items():
479
+ comp_name = name_map.get(orig_name, orig_name)
480
+ role = net_spec.get('role', 'generator')
481
+ spec = components.get(comp_name, {})
482
+ factory = spec.get('factory', net_spec.get('hndl') or net_spec.get('file') or net_spec.get('module') or 'unknown')
483
+ if role == 'critic':
484
+ group = f'critic Adam (shared by all critics), lr = {d_lr_str}'
485
+ elif 'reuse' in net_spec or 'reuse' in spec:
486
+ reuse_target = net_spec.get('reuse') or spec.get('reuse')
487
+ group = f'parameters of {reuse_target}'
488
+ elif net_spec.get('trainable') is False or spec.get('trainable') is False or net_spec.get('freeze_parameters') or spec.get('freeze_parameters') or role == 'frozen':
489
+ group = 'none (frozen)'
490
+ elif role == 'shared':
491
+ group = f'parameters of {spec.get("reuse")}'
492
+ else:
493
+ group = f'generator-side Adam, lr = {g_lr_str}'
494
+ lines.append(f'{orig_name} [{role}] {factory}')
495
+ inputs_str = ', '.join(f'{k} <- {v}' for k, v in net_spec.get('inputs', {}).items())
496
+ lines.append(f' inputs: {inputs_str}')
497
+ lines.append(f' optimizer: {group}')
498
+ if role != 'critic':
499
+ received = sorted(set(reach.get(comp_name, [])))
500
+ lines.append(' gradient from: ' + (', '.join(received) or '-'))
501
+ else:
502
+ for name, spec in components.items():
503
+ orig_name = inv_name_map.get(name, name)
504
+ factory = spec.get('factory', 'reuse')
505
+ role = 'critic' if name == 'discriminator' or any(t.get('component') == name for t in recipe.get('adversarial_terms', [])) else 'generator'
506
+ if role == 'critic':
507
+ group = f'critic Adam (shared by all critics), lr = {d_lr_str}'
508
+ else:
509
+ group = f'generator-side Adam, lr = {g_lr_str}'
510
+ lines.append(f'{orig_name} [{role}] {factory}')
511
+ inputs_str = ', '.join(f'{k} <- {v}' for k, v in spec.get('inputs', {}).items())
512
+ lines.append(f' inputs: {inputs_str}')
513
+ lines.append(f' optimizer: {group}')
514
+ received = sorted(set(reach.get(name, [])))
515
+ lines.append(' gradient from: ' + (', '.join(received) or '-'))
516
+ return '\n'.join(lines)
517
+
518
+
519
+ # --------------------------------------------------------------------------- building in Python
520
+
521
+ def plain(value, location):
522
+ """Check that an argument survives the config file unchanged (no None, NaN or objects)."""
523
+ if value is None:
524
+ raise ValueError(f"{location} is None; the config file has no null, so omit the argument instead")
525
+ if isinstance(value, bool) or isinstance(value, (int, str)):
526
+ return value
527
+ if isinstance(value, float):
528
+ import math
529
+ if not math.isfinite(value):
530
+ raise ValueError(f"{location} must be finite")
531
+ return value
532
+ if isinstance(value, (list, tuple)):
533
+ return [plain(v, f"{location}[{i}]") for i, v in enumerate(value)]
534
+ if isinstance(value, dict):
535
+ if not all(isinstance(k, str) for k in value):
536
+ raise ValueError(f"{location} keys must be strings")
537
+ return {k: plain(v, f"{location}.{k}") for k, v in value.items()}
538
+ raise TypeError(f"{location} is a {type(value).__name__}; config arguments must be numbers, strings, lists or dicts")
539
+
540
+
541
+ def import_path(obj, what):
542
+ """``module:qualname`` for a class or function that other processes can import."""
543
+ if isinstance(obj, str):
544
+ if obj.count(":") != 1 or not all(obj.split(":")):
545
+ raise ValueError(f"{what} must be an importable object or a 'module:object' string, got {obj!r}")
546
+ return obj
547
+ module_name, qualname = getattr(obj, "__module__", None), getattr(obj, "__qualname__", None)
548
+ if not module_name or not qualname:
549
+ raise TypeError(f"{what} must be a class or function, got {type(obj).__name__}")
550
+ if module_name == "__main__":
551
+ raise ValueError(
552
+ f"{what} {qualname} is defined in the script being run (__main__). Every training process "
553
+ f"rebuilds the model from the config file by import path, so move {qualname} into an "
554
+ f"importable module (for example my_project/networks.py) and import it from there."
555
+ )
556
+ if "<locals>" in qualname or "<lambda>" in qualname:
557
+ raise ValueError(f"{what} {qualname} is local to a function; define it at module level so it can be imported")
558
+ return f"{module_name}:{qualname}"
559
+
560
+
561
+ def hndl(source=None, *, file=None, input_shape, output_shape, inputs, role, **args):
562
+ """A network written in HNDL, inline (``source``) or in a file (``file``)."""
563
+ if (source is None) == (file is None):
564
+ raise ValueError('Pass exactly one of source or file')
565
+ network = {'role': role, 'inputs': dict(inputs), 'input_shape': input_shape, 'output_shape': output_shape}
566
+ network.update({'source': source} if source is not None else {'hndl': str(file)})
567
+ if args:
568
+ network['args'] = args
569
+ return network
570
+
571
+
572
+ def module(path, *, inputs, role, trainable=None, **args):
573
+ """A network implemented as an importable ``torch.nn.Module`` class or factory."""
574
+ path_str = import_path(path, 'Network') if callable(path) else path
575
+ if isinstance(path_str, str) and (path_str.count(':') != 1 or not all(path_str.split(':'))):
576
+ raise ValueError(f"Network must be an importable object or a 'module:object' string, got {path_str!r}")
577
+ if callable(path):
578
+ try:
579
+ sig = _py_inspect.signature(path)
580
+ if not any(p.kind == _py_inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()):
581
+ for arg_name in args:
582
+ if arg_name not in sig.parameters:
583
+ raise TypeError(f"Network {getattr(path, '__name__', path)} got unexpected keyword argument {arg_name!r}")
584
+ except (ValueError, TypeError) as exc:
585
+ if "unexpected keyword argument" in str(exc):
586
+ raise
587
+ network = {'role': role, 'module': path_str, 'inputs': dict(inputs)}
588
+ if args:
589
+ network['args'] = args
590
+ if trainable is not None:
591
+ network['trainable'] = trainable
592
+ return network
593
+
594
+
595
+ def sampler(fn, *, inputs=None, count=None, seed=None, **args):
596
+ """Declare a sampler entry for [samplers.<name>]."""
597
+ fn_str = import_path(fn, 'Sampler') if callable(fn) else fn
598
+ entry = {'fn': fn_str}
599
+ if inputs is not None:
600
+ entry['inputs'] = dict(inputs)
601
+ if count is not None:
602
+ entry['count'] = count
603
+ if seed is not None:
604
+ entry['seed'] = seed
605
+ if args:
606
+ entry['args'] = args
607
+ return entry
608
+
609
+
610
+ def adversarial(critic, *, weight=1.0, penalty=1.0, real=None, fake=None, inputs=None, id=None):
611
+ """Define an adversarial loss term binding a critic network to real and generated inputs."""
612
+ loss = {'type': 'adversarial', 'critic': critic, 'weight': weight, 'penalty': penalty}
613
+ for key, value in (('real', real), ('fake', fake), ('inputs', inputs), ('id', id)):
614
+ if value is not None:
615
+ loss[key] = value
616
+ return loss
617
+
618
+
619
+ def objective(id, fn='mse', *, input=None, target=None, inputs=None, weight=1.0, args=None, type='objective'):
620
+ """Define a loss objective term computing a loss function over specified input tensors."""
621
+ if callable(fn):
622
+ fn_str = import_path(fn, f'Objective {id}')
623
+ else:
624
+ fn_str = fn
625
+ loss = {'type': type, 'id': id, 'fn': fn_str, 'weight': weight}
626
+ if inputs is not None:
627
+ loss['inputs'] = inputs
628
+ else:
629
+ loss['input'] = input
630
+ if target is not None:
631
+ loss['target'] = target
632
+ if args:
633
+ loss['args'] = args
634
+ return loss
635
+
636
+
637
+ def reconstruction(id='reconstruction', fn='mse', *, input='generated', target='batch.real', weight=1.0):
638
+ """Define a reconstruction loss term between generated output and a target tensor (default MSE)."""
639
+ return objective(id, fn, input=input, target=target, weight=weight, type='reconstruction')
640
+
641
+
642
+ def prior_loss(weight=1.0, **settings):
643
+ """Define a prior regularization loss term with an optional weight and settings."""
644
+ return {'type': 'prior', 'weight': weight, **settings}
645
+
646
+
647
+ # --------------------------------------------------------------------------- training
648
+
649
+ @dataclass(frozen=True)
650
+ class Run:
651
+ """A run directory and the manifest it recorded."""
652
+ path: Path
653
+ manifest: dict = field(repr=False)
654
+
655
+ @property
656
+ def status(self):
657
+ """Current lifecycle status of the run (running, completed, stopped, etc.)."""
658
+ return self.manifest.get('status')
659
+
660
+ @property
661
+ def step(self):
662
+ """Latest recorded training step count."""
663
+ return self.manifest.get('steps')
664
+
665
+ @property
666
+ def config(self):
667
+ """Resolved configuration recorded in the run manifest."""
668
+ return self.manifest.get('config')
669
+
670
+ @property
671
+ def fingerprint(self):
672
+ """Numerical recipe fingerprint (config_sha256)."""
673
+ return self.manifest.get('config_sha256')
674
+
675
+ @property
676
+ def run_id(self) -> str:
677
+ """Unique 32-character hexadecimal run identifier."""
678
+ rid = self.manifest.get('run_id')
679
+ if rid:
680
+ return str(rid)
681
+ summary_p = self.path / 'summary.json'
682
+ if summary_p.is_file():
683
+ try:
684
+ data = json.loads(summary_p.read_text(encoding='utf-8'))
685
+ if isinstance(data, dict) and data.get('run_id'):
686
+ return str(data['run_id'])
687
+ except Exception:
688
+ pass
689
+ try:
690
+ from .registry import _read_registry_unlocked, get_registry_dir
691
+ reg_data = _read_registry_unlocked(get_registry_dir(self.path))
692
+ entry = reg_data.get('runs', {}).get(str(self.path.resolve()))
693
+ if entry and entry.get('run_id'):
694
+ return str(entry['run_id'])
695
+ except Exception:
696
+ pass
697
+ return self.path.name
698
+
699
+ @property
700
+ def short_id(self) -> str:
701
+ """First 8 characters of the run identifier."""
702
+ sid = self.manifest.get('short_id')
703
+ if sid:
704
+ return str(sid)
705
+ summary_p = self.path / 'summary.json'
706
+ if summary_p.is_file():
707
+ try:
708
+ data = json.loads(summary_p.read_text(encoding='utf-8'))
709
+ if isinstance(data, dict) and data.get('short_id'):
710
+ return str(data['short_id'])
711
+ except Exception:
712
+ pass
713
+ return str(self.run_id)[:8]
714
+
715
+ @property
716
+ def name(self) -> str:
717
+ """Human-readable experiment name or custom tag."""
718
+ nm = self.manifest.get('name') or (self.manifest.get('config') or {}).get('name')
719
+ if nm:
720
+ return str(nm)
721
+ summary_p = self.path / 'summary.json'
722
+ if summary_p.is_file():
723
+ try:
724
+ data = json.loads(summary_p.read_text(encoding='utf-8'))
725
+ if isinstance(data, dict) and data.get('name'):
726
+ return str(data['name'])
727
+ except Exception:
728
+ pass
729
+ try:
730
+ from .registry import _read_registry_unlocked, get_registry_dir
731
+ reg_data = _read_registry_unlocked(get_registry_dir(self.path))
732
+ entry = reg_data.get('runs', {}).get(str(self.path.resolve()))
733
+ if entry and entry.get('name'):
734
+ return str(entry['name'])
735
+ except Exception:
736
+ pass
737
+ return self.path.name
738
+
739
+ @property
740
+ def slug(self) -> str:
741
+ """Memorable '<adj>-<noun>' run slug."""
742
+ sl = self.manifest.get('slug')
743
+ if sl:
744
+ return str(sl)
745
+ summary_p = self.path / 'summary.json'
746
+ if summary_p.is_file():
747
+ try:
748
+ data = json.loads(summary_p.read_text(encoding='utf-8'))
749
+ if isinstance(data, dict) and data.get('slug'):
750
+ return str(data['slug'])
751
+ except Exception:
752
+ pass
753
+ try:
754
+ from .registry import _read_registry_unlocked, get_registry_dir
755
+ reg_data = _read_registry_unlocked(get_registry_dir(self.path))
756
+ entry = reg_data.get('runs', {}).get(str(self.path.resolve()))
757
+ if entry and entry.get('slug'):
758
+ return str(entry['slug'])
759
+ except Exception:
760
+ pass
761
+ from .registry import extract_or_derive_slug
762
+ return extract_or_derive_slug(str(self.run_id), name=str(self.name), run_dir_name=self.path.name)
763
+
764
+
765
+ def open_run(run_dir=None):
766
+ """Open and load a Run instance and its manifest by directory, slug, tag, or short ID."""
767
+ if run_dir is not None and Path(run_dir).suffix == ".json":
768
+ manifest_path = Path(run_dir).resolve()
769
+ run_path = manifest_path.parent
770
+ else:
771
+ from .registry import resolve_run_target
772
+ run_path = resolve_run_target(run_dir)
773
+ if run_path.is_file():
774
+ manifest_path = run_path
775
+ run_path = run_path.parent
776
+ else:
777
+ manifest_path = run_path / 'manifest.json'
778
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
779
+ if not isinstance(manifest, dict):
780
+ raise ValueError("run manifest must contain a JSON object")
781
+ return Run(run_path, manifest)
782
+
783
+
784
+ def _run(value):
785
+ return value if isinstance(value, Run) else open_run(value)
786
+
787
+
788
+ def _run_path(value=None):
789
+ if isinstance(value, Run):
790
+ return value.path
791
+ from .registry import resolve_run_target
792
+ return resolve_run_target(value)
793
+
794
+
795
+ def _default_run_dir(model_obj):
796
+ """Determine default run directory <runs_root>/<slug> for a Model or path."""
797
+ cfg_path = getattr(model_obj, "path", None)
798
+ ws_path = find_workspace(cfg_path or Path.cwd())
799
+ if ws_path:
800
+ ws_data = load_workspace(ws_path)
801
+ ws_sec = ws_data.get("workspace", {})
802
+ runs_root = Path(ws_sec["runs_root"]).resolve() if "runs_root" in ws_sec else (ws_path.parent / "runs").resolve()
803
+ stem = cfg_path.stem if cfg_path else (model_obj.name.split("/")[-1] if model_obj.name else "run")
804
+ custom_name = model_obj.spec.get("name") if (model_obj.spec.get("name") and model_obj.spec.get("name") != "custom/model") else None
805
+ slug_dir_name, short_slug = (custom_name, custom_name) if custom_name else generate_run_slug(stem, parent_dir=runs_root)
806
+ if "run_dir" in ws_sec:
807
+ run_dir_str = interpolate_variables(
808
+ ws_sec["run_dir"],
809
+ ws_data,
810
+ {"stem": custom_name or stem, "name": slug_dir_name, "slug": short_slug},
811
+ )
812
+ return Path(run_dir_str).resolve()
813
+ return (runs_root / slug_dir_name).resolve()
814
+ from .web_autostart import find_runs_root as autostart_find
815
+ runs_root = autostart_find(Path.cwd())
816
+ if not runs_root.name == "runs" and (runs_root / "runs").is_dir():
817
+ runs_root = (runs_root / "runs").resolve()
818
+ stem = cfg_path.stem if cfg_path else (model_obj.name.split("/")[-1] if model_obj.name else "run")
819
+ custom_name = model_obj.spec.get("name") if (model_obj.spec.get("name") and model_obj.spec.get("name") != "custom/model") else None
820
+ slug_dir_name, short_slug = (custom_name, custom_name) if custom_name else generate_run_slug(stem, parent_dir=runs_root)
821
+ return (runs_root / slug_dir_name).resolve()
822
+
823
+
824
+ def prepare(model, run_dir=None, steps=None, *, profile=None, service_policy=None,
825
+ devices=None,
826
+ checkpoint_every=None, max_seconds=None, stop_after_steps=None,
827
+ preview_every=None, previews=None, preview_keep=None, preview_keep_source=None,
828
+ preview_name=None, **extra):
829
+ """Validate and prepare a training run, returning a PreparedExecution."""
830
+ from .execution import prepare_train as _prepare_train
831
+ model = _model(model)
832
+ if run_dir is None:
833
+ run_dir = _default_run_dir(model)
834
+ pe = preview_every if preview_every is not None else previews
835
+ lowered = lower(model)
836
+ if getattr(model, 'path', None):
837
+ lowered['_source_path'] = str(model.path)
838
+ return _prepare_train(lowered, run_dir, steps=steps, profile=profile,
839
+ service_policy=service_policy, devices=devices,
840
+ checkpoint_every=checkpoint_every,
841
+ max_seconds=max_seconds, stop_after_steps=stop_after_steps,
842
+ preview_every=pe, preview_keep=preview_keep,
843
+ preview_keep_source=preview_keep_source, preview_name=preview_name,
844
+ **extra)
845
+
846
+
847
+ def _load_stored_run_model(run_path: Path):
848
+ """Load a run's stored Model from recipe.toml, model.toml, config-source/*.toml, or manifest.json."""
849
+ if (run_path / 'model.toml').is_file():
850
+ return load(run_path / 'model.toml')
851
+ if (run_path / 'recipe.toml').is_file():
852
+ return load(run_path / 'recipe.toml')
853
+ if (run_path / 'config.toml').is_file():
854
+ return load(run_path / 'config.toml')
855
+ cs_dir = run_path / 'config-source'
856
+ if cs_dir.is_dir():
857
+ cs_manifest = cs_dir / 'manifest.json'
858
+ if cs_manifest.is_file():
859
+ try:
860
+ cs_data = json.loads(cs_manifest.read_text(encoding='utf-8'))
861
+ files = cs_data.get('files') or []
862
+ for f_entry in files:
863
+ if isinstance(f_entry, dict) and f_entry.get('snapshot'):
864
+ snap = cs_dir / f_entry['snapshot']
865
+ if snap.is_file() and snap.name != 'hypergan.toml':
866
+ return load(snap)
867
+ except Exception:
868
+ pass
869
+ tomls = [p for p in sorted(cs_dir.rglob('*.toml')) if p.name != 'hypergan.toml']
870
+ if tomls:
871
+ return load(tomls[0])
872
+ if (run_path / 'manifest.json').is_file():
873
+ with (run_path / 'manifest.json').open('rb') as f:
874
+ manifest = json.load(f)
875
+ saved_config = manifest.get('config')
876
+ if not isinstance(saved_config, dict):
877
+ raise ValueError(f"Run {run_path} has no recorded configuration in manifest.json")
878
+ if 'components' in saved_config and 'networks' not in saved_config:
879
+ try:
880
+ return lift(saved_config, base=run_path)
881
+ except Exception:
882
+ pass
883
+ return from_dict(saved_config, base=run_path)
884
+ raise ValueError(f"Run directory {run_path} does not exist or has no configuration")
885
+
886
+
887
+ def prepare_resume(run_dir, checkpoint=None, model=None, *, steps=None,
888
+ profile=None, service_policy=None, devices=None,
889
+ checkpoint_every=None, max_seconds=None, stop_after_steps=None,
890
+ preview_every=None, previews=None, preview_keep=None, preview_keep_source=None,
891
+ preview_name=None, sets=None, overrides=None, **extra):
892
+ """Validate and prepare a run resumption, returning a PreparedExecution."""
893
+ from .execution import prepare_resume as _prepare_resume
894
+ run_path = _run_path(run_dir)
895
+ merged_sets = dict(sets or {})
896
+ if overrides:
897
+ merged_sets.update(overrides)
898
+ sets = merged_sets or None
899
+ if model is None and (sets or steps is not None):
900
+ model = _load_stored_run_model(run_path)
901
+ if sets:
902
+ normalized_sets = {}
903
+ for k, v in sets.items():
904
+ if k.startswith('trainer.'):
905
+ k = 'train.' + k[len('trainer.'):]
906
+ if k.startswith('training.') and hasattr(model, 'spec') and 'train' in model.spec:
907
+ normalized_sets['train.' + k[len('training.'):]] = v
908
+ elif k.startswith('train.') and hasattr(model, 'spec') and 'training' in model.spec and 'train' not in model.spec:
909
+ normalized_sets['training.' + k[len('train.'):]] = v
910
+ else:
911
+ normalized_sets[k] = v
912
+ model = override(model, normalized_sets)
913
+ if steps is None:
914
+ step_val = normalized_sets.get('train.steps', normalized_sets.get('training.steps'))
915
+ if step_val is not None:
916
+ steps = int(step_val)
917
+
918
+ config_path = lower(model) if model is not None else None
919
+ pe = preview_every if preview_every is not None else previews
920
+ return _prepare_resume(run_path, checkpoint=checkpoint, config_path=config_path, steps=steps,
921
+ profile=profile, service_policy=service_policy, devices=devices,
922
+ checkpoint_every=checkpoint_every, max_seconds=max_seconds,
923
+ stop_after_steps=stop_after_steps, preview_every=pe,
924
+ preview_keep=preview_keep, preview_keep_source=preview_keep_source,
925
+ preview_name=preview_name, **extra)
926
+
927
+
928
+ def train(model, run_dir=None, *, steps=None, previews=None, preview_every=None, checkpoint_every=None, profile=None,
929
+ devices=None, on_event=None, **controls):
930
+ """Train (or continue, if ``run_dir`` exists) and return the ``Run``.
931
+
932
+ If ``run_dir`` is omitted or None, automatically creates `<runs_root>/<slug>`.
933
+ """
934
+ model_obj = _model(model)
935
+ if run_dir is None:
936
+ run_dir = _default_run_dir(model_obj)
937
+ pe = preview_every if preview_every is not None else previews
938
+ prep = prepare(model_obj, run_dir, steps=steps, profile=profile, devices=devices,
939
+ checkpoint_every=checkpoint_every, preview_every=pe, **controls)
940
+ prep.run(on_event=on_event)
941
+ return open_run(run_dir)
942
+
943
+
944
+ def resume(run, model=None, *, steps=None, devices=None, on_event=None, **controls):
945
+ """Continue a run from its latest checkpoint (optionally with changed observation settings)."""
946
+ run = _run(run)
947
+ prep = prepare_resume(run.path, model=model, steps=steps, devices=devices, **controls)
948
+ prep.run(on_event=on_event)
949
+ return open_run(run.path)
950
+
951
+
952
+ # --------------------------------------------------------------------------- reading runs
953
+
954
+ def _events(path):
955
+ from .run_events import read_event_page
956
+ cursor = None
957
+ while True:
958
+ page = read_event_page(path, cursor, limit=1000)
959
+ yield from page['events']
960
+ cursor = page['cursor']
961
+ if not page['has_more']:
962
+ return
963
+
964
+
965
+ def metrics(run, names=None):
966
+ """Published scalar metrics: ``{name: [(step, value), ...]}`` in step order."""
967
+ run = _run(run)
968
+ wanted = set(names) if names is not None else None
969
+ series = {}
970
+ for event in _events(run.path):
971
+ for name, value in (event.get('metrics') or {}).items():
972
+ if wanted is None or name in wanted:
973
+ series.setdefault(name, []).append((event['step'], value))
974
+ return series
975
+
976
+
977
+ def evaluations(run):
978
+ """Snapshot evaluation results: ``{metric_id: [{step, value, status, ...}, ...]}``."""
979
+ run = _run(run)
980
+ results = {}
981
+ for receipt in sorted((run.path / 'metrics' / 'evaluations').glob('*/receipt.json')):
982
+ data = json.loads(receipt.read_text())
983
+ result = data.get('result') or {}
984
+ results.setdefault(data.get('metric_id'), []).append({
985
+ 'step': result.get('step', data.get('source_step')), 'value': result.get('value'),
986
+ 'status': data.get('status'), 'evaluation_id': data.get('evaluation_id'),
987
+ 'error': result.get('error')})
988
+ for rows in results.values():
989
+ rows.sort(key=lambda row: (row['step'] is None, row['step'] or 0))
990
+ return results
991
+
992
+
993
+ def observations(run):
994
+ """Why a value is or is not there: [(step, id, status, reason)] for custom metrics,
995
+ evaluations and previews (queued, dropped, scheduled, skipped, ...)."""
996
+ run = _run(run)
997
+ rows = []
998
+ for event in _events(run.path):
999
+ kind = event.get('event')
1000
+ for name, status in (event.get('measurement_status') or {}).items():
1001
+ if status.get('status') not in ('available', 'unavailable'):
1002
+ rows.append((event['step'], name, status.get('status'), status.get('reason')))
1003
+ if kind in ('evaluation_scheduled', 'evaluation_skipped', 'evaluation_complete'):
1004
+ rows.append((event['step'], event.get('metric_id', 'evaluation'), kind.split('_', 1)[1],
1005
+ event.get('reason')))
1006
+ elif kind in ('preview', 'preview_skipped'):
1007
+ rows.append((event['step'], 'preview', 'published' if kind == 'preview' else 'skipped',
1008
+ event.get('reason')))
1009
+ return rows
1010
+
1011
+
1012
+ def catalog(run, revision=None):
1013
+ """Immutable metric definitions recorded by a run."""
1014
+ from .metrics import read_catalog
1015
+ run = _run(run)
1016
+ return read_catalog(run.path, revision)
1017
+
1018
+
1019
+ def events(run, cursor=None, limit=100, max_bytes=1048576):
1020
+ """Read a bounded page of run events without loading training dependencies."""
1021
+ from .run_events import read_event_page
1022
+ run = _run(run)
1023
+ return read_event_page(run.path, cursor=cursor, limit=limit, max_bytes=max_bytes)
1024
+
1025
+
1026
+ class InferenceBundle(tuple):
1027
+ """Loaded inference bundle supporting both 5-tuple unpacking and dictionary access."""
1028
+
1029
+ def __contains__(self, key):
1030
+ if len(self) > 0 and isinstance(self[0], dict) and key in self[0]:
1031
+ return True
1032
+ return super().__contains__(key)
1033
+
1034
+ def __getitem__(self, item):
1035
+ if isinstance(item, str) and len(self) > 0 and isinstance(self[0], dict) and item in self[0]:
1036
+ return self[0][item]
1037
+ return super().__getitem__(item)
1038
+
1039
+
1040
+ def load_inference(target=None):
1041
+ """Verify and load a run's EMA inference bundle on CPU in evaluation mode.
1042
+
1043
+ Accepts an exported .pt bundle file, a run directory, slug, tag, short ID, or Run instance.
1044
+ Returns (state_dict, config_dict, ComponentGraph, prior, bundle_sha256).
1045
+ """
1046
+ import torch
1047
+ from .recipes import ComponentGraph, make_prior
1048
+ from .config import resolve_config
1049
+
1050
+ if isinstance(target, Run):
1051
+ run_dir = target.path
1052
+ is_direct_pt = False
1053
+ elif target is not None and (str(target).endswith(".pt") or (Path(target).is_file() and Path(target).suffix.lower() == ".pt")):
1054
+ is_direct_pt = True
1055
+ pt_path = Path(target).resolve()
1056
+ else:
1057
+ is_direct_pt = False
1058
+ run_dir = _run_path(target)
1059
+
1060
+ if is_direct_pt:
1061
+ path = pt_path
1062
+ checksum_path = pt_path.with_name("model.sha256") if pt_path.name == "model.pt" else pt_path.with_suffix(".sha256")
1063
+ if checksum_path.is_file():
1064
+ expected = checksum_path.read_text(encoding="utf-8").strip()
1065
+ if hashlib.sha256(path.read_bytes()).hexdigest() != expected:
1066
+ raise ValueError("Inference artifact hash mismatch")
1067
+ else:
1068
+ expected = hashlib.sha256(path.read_bytes()).hexdigest()
1069
+ else:
1070
+ bundle_dir = run_dir
1071
+ manifest_path = run_dir / "manifest.json"
1072
+ if manifest_path.is_file():
1073
+ try:
1074
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
1075
+ if manifest.get("bundle_path"):
1076
+ bp = Path(manifest["bundle_path"])
1077
+ bundle_dir = (bp if bp.is_absolute() else run_dir / bp).parent
1078
+ except Exception:
1079
+ pass
1080
+ path = bundle_dir / "model.pt"
1081
+ if not path.is_file():
1082
+ try:
1083
+ from .checkpoints import resolve_checkpoint, export_checkpoint_bundle
1084
+ ckpt_dir = resolve_checkpoint(run_dir, target="latest")
1085
+ path, checksum_path = export_checkpoint_bundle(ckpt_dir, output_dir=run_dir)
1086
+ bundle_dir = run_dir
1087
+ except Exception:
1088
+ pass
1089
+ if not path.is_file():
1090
+ raise FileNotFoundError(f"No inference bundle or checkpoint found for {target or run_dir}")
1091
+ sha_file = bundle_dir / "model.sha256"
1092
+ if sha_file.is_file():
1093
+ expected = sha_file.read_text(encoding="utf-8").strip()
1094
+ if hashlib.sha256(path.read_bytes()).hexdigest() != expected:
1095
+ raise ValueError("Inference artifact hash mismatch")
1096
+ else:
1097
+ expected = hashlib.sha256(path.read_bytes()).hexdigest()
1098
+
1099
+ state = torch.load(path, map_location="cpu", weights_only=True)
1100
+ if not isinstance(state, dict) or state.get("schema_version") != 1 or state.get("kind") != "ema-inference":
1101
+ raise ValueError("Unsupported inference artifact schema")
1102
+ for key in ("config", "components", "model_states", "prior", "example_inputs"):
1103
+ if not isinstance(state.get(key), dict):
1104
+ raise ValueError(f"Invalid inference artifact: missing or invalid {key}")
1105
+ if "generator" not in state["components"] or set(state["model_states"]) != {name for name, spec in state["components"].items() if 'reuse' not in spec}:
1106
+ raise ValueError("Invalid inference artifact: component states must exactly match the generator graph")
1107
+ config = resolve_config(state["config"])
1108
+ graph = ComponentGraph(state["components"]).float().eval().requires_grad_(False)
1109
+ for name, weights in state["model_states"].items():
1110
+ graph.models[name].load_state_dict(weights)
1111
+ if "model_buffers" in state and name in state["model_buffers"]:
1112
+ buf = state["model_buffers"][name]
1113
+ for bname, bval in (buf or {}).items():
1114
+ if hasattr(graph.models[name], bname):
1115
+ getattr(graph.models[name], bname).copy_(bval)
1116
+ prior = make_prior(config["prior"], device='cpu', recipe=config.get('recipe')).float().eval().requires_grad_(False)
1117
+ prior.load_state_dict(state["prior"])
1118
+ if "prior_buffers" in state:
1119
+ for bname, bval in (state["prior_buffers"] or {}).items():
1120
+ if hasattr(prior, bname):
1121
+ getattr(prior, bname).copy_(bval)
1122
+ return InferenceBundle((state, config, graph, prior, expected))
1123
+
1124
+
1125
+ @dataclass(frozen=True)
1126
+ class Sample:
1127
+ """Generated output recorded by a run, in whatever structure the model produces.
1128
+
1129
+ ``data`` holds the generated tensor (``None`` when a large image preview was
1130
+ stored only as a PNG grid, then in ``png``). ``inputs`` holds the batch
1131
+ conditioning it was generated from.
1132
+ """
1133
+ step: int
1134
+ name: str
1135
+ data: object
1136
+ inputs: dict
1137
+ particle_ids: list | None
1138
+ source: str
1139
+ path: Path
1140
+ png: bytes | None = None
1141
+
1142
+
1143
+ def _tensor(value):
1144
+ import torch
1145
+ return None if value is None else torch.tensor(value)
1146
+
1147
+
1148
+ def samples(run, *, name=None):
1149
+ """Periodic EMA previews, oldest first (enable with ``train(..., previews=N)``)."""
1150
+ import base64
1151
+ run = _run(run)
1152
+ index = run.path / 'previews' / 'index.json'
1153
+ if not index.is_file():
1154
+ return []
1155
+ result = []
1156
+ for entry in json.loads(index.read_text())['previews']:
1157
+ if name is not None and entry.get('name') != name:
1158
+ continue
1159
+ path = Path(entry['path'])
1160
+ payload = json.loads(path.read_text())
1161
+ grid = payload.get('image_grid')
1162
+ result.append(Sample(step=payload['step'], name=payload['name'], data=_tensor(payload.get('samples')),
1163
+ inputs={key: _tensor(value) for key, value in payload.get('inputs', {}).items()
1164
+ if isinstance(value, list)},
1165
+ particle_ids=payload.get('particle_ids'), source='preview', path=path,
1166
+ png=base64.b64decode(grid['png_base64']) if grid else None))
1167
+ return sorted(result, key=lambda item: item.step)
1168
+
1169
+
1170
+ def sample(run=None, count=16, seed=None, *, inputs=None, output=None, checkpoint=None, default_image_png=False):
1171
+ """Fresh samples from the run's final EMA model (or target checkpoint or .pt bundle)."""
1172
+ from .artifacts import sample as draw
1173
+ if isinstance(run, (str, Path)) and (str(run).endswith('.pt') or (Path(run).is_file() and Path(run).suffix.lower() == '.pt')):
1174
+ target_path = Path(run)
1175
+ else:
1176
+ run_obj = _run(run)
1177
+ target_path = run_obj.path
1178
+
1179
+ if seed is None:
1180
+ cfg = getattr(run, "config", None) if isinstance(run, Run) else None
1181
+ if cfg is None and target_path.is_dir() and (target_path / "manifest.json").is_file():
1182
+ try:
1183
+ m_data = json.loads((target_path / "manifest.json").read_text(encoding="utf-8"))
1184
+ cfg = m_data.get("config")
1185
+ except Exception:
1186
+ pass
1187
+ seed = (cfg or {}).get("sampling", {}).get("seed", 42) if isinstance(cfg, dict) else 42
1188
+
1189
+ path = Path(draw(target_path, count=count, seed=seed, inputs=inputs, output=output,
1190
+ checkpoint=checkpoint, default_image_png=default_image_png))
1191
+ if path.suffix.lower() == '.png':
1192
+ from PIL import Image
1193
+ with Image.open(path, formats=["PNG"]) as img:
1194
+ raw_meta = img.info.get('hypergan', '{}')
1195
+ payload = json.loads(raw_meta) if raw_meta else {}
1196
+ return Sample(step=payload.get('step', 0), name='sample', data=None,
1197
+ inputs={}, particle_ids=payload.get('particle_ids'),
1198
+ source='inference', path=path, png=path.read_bytes())
1199
+ payload = json.loads(path.read_text())
1200
+ return Sample(step=payload['step'], name='sample', data=_tensor(payload['samples']),
1201
+ inputs={key: _tensor(value) for key, value in payload.get('inputs', {}).items()},
1202
+ particle_ids=payload.get('particle_ids'), source='inference', path=path)
1203
+
1204
+
1205
+ def view(item, sampler, *, run=None):
1206
+ """Apply a sampler to a Sample: a name from the model's ``[samplers]``, a
1207
+ ``module:function`` path, or a callable. Returns what the sampler returns
1208
+ (``Image``/``Audio`` for the built-in helpers)."""
1209
+ from .item_data import import_object
1210
+ args = {}
1211
+ if isinstance(sampler, str) and ':' not in sampler:
1212
+ config = _run(run).config if run is not None else None
1213
+ declared = (config or {}).get('samplers', {})
1214
+ if sampler not in declared:
1215
+ raise KeyError(f'Sampler {sampler!r} is not declared in this run ({", ".join(declared) or "none"})')
1216
+ args = declared[sampler]['args']
1217
+ sampler = declared[sampler]['fn']
1218
+ fn = import_object(sampler) if isinstance(sampler, str) else sampler
1219
+ return fn(item, **args)
1220
+
1221
+
1222
+ def evaluate(run, metric_id=None, *, checkpoint=None, devices=None, model=None, config=None, bundle=None):
1223
+ """Run one snapshot evaluation now on a run (finished or running); returns its receipt."""
1224
+ from .metric_evaluation import evaluate as run_evaluation
1225
+ run = _run(run)
1226
+ config_p = lower(model) if model is not None else config
1227
+ return run_evaluation(run.path, metric_id, checkpoint=checkpoint, devices=devices,
1228
+ config_path=config_p, bundle=bundle)
1229
+
1230
+
1231
+ def resolve_run_target(target=None, *, cwd=None, allow_latest=True, require_active=False):
1232
+ """Resolve a run reference (short ID, slug, name, path, @latest, or None for active/latest)."""
1233
+ from .registry import resolve_run_target as _resolve_target
1234
+ return _resolve_target(target, cwd=cwd, allow_latest=allow_latest, require_active=require_active)
1235
+
1236
+
1237
+ def register_run(run_dir, **kwargs):
1238
+ """Register a run directory in the global runs registry (runs.json)."""
1239
+ from .registry import register_run as _register
1240
+ return _register(run_dir, **kwargs)
1241
+
1242
+
1243
+ def sync_registry(cwd=None, extra_roots=None):
1244
+ """Synchronize runs in workspace and runs roots into the global registry."""
1245
+ from .registry import sync_registry as _sync
1246
+ return _sync(cwd=cwd, extra_roots=extra_roots)
1247
+
1248
+
1249
+ def generate_run_slug(stem, parent_dir=None):
1250
+ """Generate a unique run name and short '<adj>-<noun>' slug for a model stem."""
1251
+ from .registry import generate_run_slug as _gen_slug
1252
+ return _gen_slug(stem, parent_dir=parent_dir)
1253
+
1254
+
1255
+ def export(run=None, checkpoint="latest", output=None):
1256
+ """Export a standalone EMA inference model bundle from a run checkpoint."""
1257
+ from .checkpoints import resolve_checkpoint as _resolve, export_checkpoint_bundle as _export
1258
+ run_dir = _run_path(run)
1259
+ ckpt_dir = _resolve(run_dir, target=checkpoint)
1260
+ dest, _ = _export(ckpt_dir, output_dir=output or run_dir)
1261
+ return dest
1262
+
1263
+
1264
+ def prune_checkpoints(run=None, policy=None, dry_run=False):
1265
+ """Prune obsolete checkpoints in run under the retention policy."""
1266
+ from .checkpoints import prune_checkpoints as _prune
1267
+ return _prune(_run_path(run), policy=policy, dry_run=dry_run)
1268
+
1269
+
1270
+ def resolve_checkpoint(run=None, target="latest"):
1271
+ """Resolve target ('latest', 'best', step int/str, or path) to a checkpoint directory."""
1272
+ from .checkpoints import resolve_checkpoint as _resolve
1273
+ return _resolve(_run_path(run), target=target)
1274
+
1275
+
1276
+ def extract_ema_bundle(checkpoint_dir, manifest=None):
1277
+ """Extract EMA weights from checkpoint_dir into an in-memory bundle."""
1278
+ from .checkpoints import extract_ema_bundle as _extract
1279
+ return _extract(checkpoint_dir, manifest=manifest)
1280
+
1281
+
1282
+ def load_bundle(path_or_run, *, checkpoint="latest", device="cpu"):
1283
+ """Load an exported .pt bundle file or extract the EMA bundle from a run checkpoint."""
1284
+ if isinstance(path_or_run, Run):
1285
+ run_dir = path_or_run.path
1286
+ else:
1287
+ p = Path(path_or_run)
1288
+ if p.suffix.lower() == ".pt" or (p.is_file() and p.name.endswith(".pt")):
1289
+ import torch
1290
+ return torch.load(p, map_location=device, weights_only=True)
1291
+ run_dir = _run_path(path_or_run)
1292
+ try:
1293
+ ckpt_dir = resolve_checkpoint(run_dir, target=checkpoint)
1294
+ return extract_ema_bundle(ckpt_dir)
1295
+ except FileNotFoundError:
1296
+ model_pt = run_dir / "model.pt"
1297
+ if model_pt.is_file():
1298
+ import torch
1299
+ return torch.load(model_pt, map_location=device, weights_only=True)
1300
+ raise
1301
+
1302
+
1303
+ def inspect(run=None):
1304
+ """Inspect a run and return its parsed manifest and run identity dictionary."""
1305
+ if isinstance(run, Run):
1306
+ run_path = run.path
1307
+ data = dict(run.manifest)
1308
+ else:
1309
+ try:
1310
+ opened = open_run(run)
1311
+ run_path = opened.path
1312
+ data = dict(opened.manifest)
1313
+ except FileNotFoundError:
1314
+ run_path = _run_path(run)
1315
+ if run_path.is_dir():
1316
+ return status(run_path)
1317
+ raise
1318
+ data.setdefault("run_dir", str(run_path))
1319
+ summary_p = run_path / "summary.json"
1320
+ summary = {}
1321
+ if summary_p.is_file():
1322
+ try:
1323
+ loaded = json.loads(summary_p.read_text(encoding="utf-8"))
1324
+ if isinstance(loaded, dict):
1325
+ summary = loaded
1326
+ except Exception:
1327
+ summary = {}
1328
+ if "name" not in data:
1329
+ cfg_name = data.get("config", {}).get("name") if isinstance(data.get("config"), dict) else None
1330
+ data["name"] = summary.get("name") or cfg_name or run_path.name
1331
+ elif summary.get("name") and data.get("name") != summary.get("name"):
1332
+ data["name"] = summary["name"]
1333
+ if "slug" not in data:
1334
+ from .registry import extract_or_derive_slug
1335
+ run_id = str(data.get("run_id") or summary.get("run_id") or run_path.name)
1336
+ data["slug"] = summary.get("slug") or extract_or_derive_slug(run_id, name=str(data.get("name", "")), run_dir_name=run_path.name)
1337
+ if "short_id" not in data and (data.get("run_id") or summary.get("run_id")):
1338
+ data["short_id"] = str(data.get("run_id") or summary.get("run_id"))[:8]
1339
+ if "step" not in data:
1340
+ if "step" in summary:
1341
+ data["step"] = summary["step"]
1342
+ elif "steps" in data:
1343
+ data["step"] = data["steps"]
1344
+ return data
1345
+
1346
+
1347
+ def status(run=None):
1348
+ """Compile human-friendly status summary for a run."""
1349
+ from .inspection import run_status as _status
1350
+ return _status(_run_path(run))
1351
+
1352
+
1353
+ def ps(all_runs=False, cwd=None, *, all=None, root=None):
1354
+ """List active training runs (or all runs if all_runs=True) enriched with GPU VRAM and queue status."""
1355
+ if all is not None:
1356
+ all_runs = bool(all)
1357
+ target_cwd = root if root is not None else cwd
1358
+ from .inspection import ps_runs as _ps
1359
+ return _ps(all_runs=all_runs, cwd=target_cwd)
1360
+
1361
+
1362
+ def stop(run=None, *, stop_all=False, timeout=15.0):
1363
+ """Gracefully stop an active training run (or all active runs) via SIGINT."""
1364
+ from .inspection import stop_run as _stop
1365
+ return _stop(run, stop_all=stop_all, timeout=timeout)
1366
+
1367
+
1368
+ def logs(run=None, *, tail=50, follow=False, raw=False, stream=None):
1369
+ """Read or follow formatted training events from a run."""
1370
+ import io
1371
+ from .inspection import read_run_logs as _logs
1372
+ if stream is None and not follow:
1373
+ frame = _py_inspect.currentframe()
1374
+ caller = frame.f_back if frame is not None else None
1375
+ caller_mod = caller.f_globals.get("__name__", "") if caller is not None else ""
1376
+ target_stream = None if caller_mod.endswith(".cli") or caller_mod == "hypergan.cli" else io.StringIO()
1377
+ else:
1378
+ target_stream = stream
1379
+ if raw:
1380
+ sig = _py_inspect.signature(_logs)
1381
+ if "raw" in sig.parameters:
1382
+ return _logs(run, tail=tail, follow=follow, raw=raw, stream=target_stream)
1383
+ run_dir = _run_path(run)
1384
+ events_file = run_dir / "events.jsonl"
1385
+ if not events_file.is_file():
1386
+ return []
1387
+ raw_lines = [ln for ln in events_file.read_text(encoding="utf-8", errors="replace").splitlines() if ln.strip()]
1388
+ if tail and tail > 0:
1389
+ raw_lines = raw_lines[-tail:]
1390
+ out = target_stream or sys.stdout
1391
+ for ln in raw_lines:
1392
+ out.write(ln + "\n")
1393
+ if hasattr(out, "flush"):
1394
+ out.flush()
1395
+ return raw_lines
1396
+ return _logs(run, tail=tail, follow=follow, stream=target_stream)
1397
+
1398
+
1399
+ def tag(run, name):
1400
+ """Rename or tag a training run in its manifest, summary, and global registry."""
1401
+ from . import inspection
1402
+ if hasattr(inspection, "tag_run"):
1403
+ res = dict(inspection.tag_run(run, name))
1404
+ run_id = str(res.get("run_id") or res.get("short_id") or "")
1405
+ r_dir = res.get("run_dir", "")
1406
+ r_name = res.get("name", name)
1407
+ res.setdefault("short_id", run_id[:8])
1408
+ res.setdefault("message", f"Tagged {run_id[:8]} as '{r_name}' ({r_dir})")
1409
+ return res
1410
+
1411
+ from .summary import atomic_write_json, read_summary_json
1412
+ run_dir = _run_path(run)
1413
+ if not run_dir.is_dir():
1414
+ raise FileNotFoundError(f"Run directory not found: {run_dir}")
1415
+ new_name = str(name).strip()
1416
+ if not new_name:
1417
+ raise ValueError("Tag name must be a non-empty string")
1418
+ manifest_p = run_dir / "manifest.json"
1419
+ manifest = {}
1420
+ if manifest_p.is_file():
1421
+ try:
1422
+ manifest = json.loads(manifest_p.read_text(encoding="utf-8"))
1423
+ except Exception:
1424
+ manifest = {}
1425
+ manifest["name"] = new_name
1426
+ if isinstance(manifest.get("config"), dict):
1427
+ manifest["config"]["name"] = new_name
1428
+ atomic_write_json(manifest_p, manifest)
1429
+ summary_p = run_dir / "summary.json"
1430
+ summary = read_summary_json(run_dir) or {}
1431
+ if summary_p.is_file() or summary:
1432
+ summary["name"] = new_name
1433
+ atomic_write_json(summary_p, summary)
1434
+ run_id = str(summary.get("run_id") or manifest.get("run_id") or run_dir.name)
1435
+ register_run(run_dir, name=new_name)
1436
+ return {
1437
+ "run_id": run_id,
1438
+ "short_id": run_id[:8],
1439
+ "name": new_name,
1440
+ "run_dir": str(run_dir),
1441
+ "message": f"Tagged {run_id[:8]} as '{new_name}' ({run_dir})",
1442
+ }
1443
+
1444
+
1445
+ def rm(run, *, force=False):
1446
+ """Remove a run directory and its registry entry (stops active run if force=True)."""
1447
+ from . import inspection
1448
+ if hasattr(inspection, "remove_run"):
1449
+ res = dict(inspection.remove_run(run, force=force))
1450
+ run_id = str(res.get("run_id") or res.get("short_id") or "")
1451
+ r_dir = res.get("run_dir", "")
1452
+ r_name = res.get("name", Path(r_dir).name if r_dir else "")
1453
+ res.setdefault("short_id", run_id[:8])
1454
+ res.setdefault("message", f"Removed run '{r_name}' ({run_id[:8]}) at {r_dir}")
1455
+ return res
1456
+
1457
+ import shutil
1458
+ from .summary import read_summary_json
1459
+ run_dir = _run_path(run)
1460
+ if not run_dir.exists():
1461
+ raise FileNotFoundError(f"Run directory not found: {run_dir}")
1462
+ is_active, _ = inspection.check_run_active(run_dir)
1463
+ if is_active:
1464
+ if not force:
1465
+ raise RuntimeError(f"Cannot remove active run '{run_dir.name}' without --force")
1466
+ try:
1467
+ stop(run_dir, timeout=10.0)
1468
+ except Exception:
1469
+ pass
1470
+ summary = read_summary_json(run_dir) or {}
1471
+ manifest = {}
1472
+ if (run_dir / "manifest.json").is_file():
1473
+ try:
1474
+ manifest = json.loads((run_dir / "manifest.json").read_text(encoding="utf-8"))
1475
+ except Exception:
1476
+ pass
1477
+ run_id = str(summary.get("run_id") or manifest.get("run_id") or run_dir.name)
1478
+ r_name = str(summary.get("name") or manifest.get("name") or manifest.get("config", {}).get("name") or run_dir.name)
1479
+ shutil.rmtree(run_dir)
1480
+ sync_registry(cwd=run_dir.parent)
1481
+ return {
1482
+ "run_id": run_id,
1483
+ "short_id": run_id[:8],
1484
+ "name": r_name,
1485
+ "run_dir": str(run_dir),
1486
+ "removed": True,
1487
+ "message": f"Removed run '{r_name}' ({run_id[:8]}) at {run_dir}",
1488
+ }
1489
+
1490
+
1491
+ def list_runs(root=None, *, sort="updated", status=None, status_filter=None, cwd=None):
1492
+ """Discover and list all runs under root with filtering and sorting."""
1493
+ from .inspection import list_runs as _list_runs
1494
+ filt = status if status is not None else status_filter
1495
+ return _list_runs(root, sort=sort, status_filter=filt, cwd=cwd)
1496
+
1497
+
1498
+ runs = list_runs
1499
+
1500
+
1501
+ def runs_ls(root=None, sort="fid", status_filter=None, *, status=None, cwd=None):
1502
+ """Discover and list all runs under root with filtering and sorting."""
1503
+ return list_runs(root, sort=sort, status=status, status_filter=status_filter, cwd=cwd)
1504
+
1505
+
1506
+ def runs_compare(runs, metric="fid50k_train", at_steps=None):
1507
+ """Compare multiple runs for a given metric across step milestones."""
1508
+ from .inspection import compare_runs as _compare_runs
1509
+ return _compare_runs(runs, metric=metric, at_steps=at_steps)
1510
+
1511
+
1512
+ def gc(run=None, dry_run=False, stride=10, *, all_runs=False, all=None):
1513
+ """Clean rebuildable views and compact events on a finished run (or all runs if all_runs=True)."""
1514
+ if all is not None:
1515
+ all_runs = bool(all)
1516
+ if all_runs:
1517
+ return gc_all(root=run, dry_run=dry_run, stride=stride)
1518
+ from .inspection import gc_run as _gc_run
1519
+ return _gc_run(_run_path(run), dry_run=dry_run, stride=stride)
1520
+
1521
+
1522
+ prune = gc
1523
+
1524
+
1525
+ def gc_all(root=None, dry_run=False, stride=10):
1526
+ """Clean all finished runs under root."""
1527
+ from .inspection import gc_all as _gc_all
1528
+ return _gc_all(root, dry_run=dry_run, stride=stride)
1529
+
1530
+
1531
+ def compact_events(run, stride=10):
1532
+ """Compact redundant train events in events.jsonl, returning reclaimed bytes."""
1533
+ from .inspection import compact_events as _compact
1534
+ return _compact(_run_path(run), stride=stride)
1535
+
1536
+
1537
+ # --------------------------------------------------------------------------- viewables
1538
+
1539
+ @dataclass(frozen=True)
1540
+ class Image:
1541
+ """8-bit pixels, row-major HWC (1 or 3 channels)."""
1542
+ pixels: bytes
1543
+ width: int
1544
+ height: int
1545
+ channels: int
1546
+
1547
+ def png(self):
1548
+ from .image_grids import encode_png
1549
+ return encode_png(self.pixels, self.width, self.height, self.channels)
1550
+
1551
+ def save(self, path):
1552
+ Path(path).write_bytes(self.png())
1553
+ return Path(path)
1554
+
1555
+
1556
+ @dataclass(frozen=True)
1557
+ class Audio:
1558
+ """Mono or interleaved float samples in [-1, 1]."""
1559
+ samples: list
1560
+ rate: int
1561
+ channels: int = 1
1562
+
1563
+ def save(self, path):
1564
+ import struct
1565
+ import wave
1566
+ with wave.open(str(path), 'wb') as stream:
1567
+ stream.setnchannels(self.channels)
1568
+ stream.setsampwidth(2)
1569
+ stream.setframerate(self.rate)
1570
+ stream.writeframes(b''.join(struct.pack('<h', int(max(-1.0, min(1.0, x)) * 32767)) for x in self.samples))
1571
+ return Path(path)
1572
+
1573
+
1574
+ def image(tensor, *, low=-1.0, high=1.0):
1575
+ """An ``Image`` from a CHW or HW tensor/array in [low, high]."""
1576
+ import torch
1577
+ value = torch.as_tensor(tensor, dtype=torch.float32).detach().cpu()
1578
+ if value.ndim == 2:
1579
+ value = value[None]
1580
+ if value.ndim != 3 or value.shape[0] not in (1, 3):
1581
+ raise ValueError('image() expects CHW with 1 or 3 channels, or HW')
1582
+ pixels = ((value - low) / (high - low)).clamp(0, 1).mul(255).round().to(torch.uint8)
1583
+ channels, height, width = pixels.shape
1584
+ return Image(pixels.permute(1, 2, 0).contiguous().numpy().tobytes(), width, height, channels)
1585
+
1586
+
1587
+ def audio(tensor, *, rate):
1588
+ """Construct an Audio object from a 1D (mono) or 2D (channels, samples) tensor/array with sample rate."""
1589
+ import torch
1590
+ value = torch.as_tensor(tensor, dtype=torch.float32).detach().cpu()
1591
+ channels = 1 if value.ndim == 1 else value.shape[0]
1592
+ return Audio(value.T.reshape(-1).tolist() if value.ndim == 2 else value.tolist(), rate, channels)
1593
+
1594
+
1595
+ # --------------------------------------------------------------------------- recipe lifting & CLI services
1596
+
1597
+ def load_recipe(path):
1598
+ """Read a recipe TOML file without lowering or resolving."""
1599
+ path = Path(path)
1600
+ with path.open('rb') as stream:
1601
+ return tomllib.load(stream)
1602
+
1603
+
1604
+ def lift(raw, *, base=None):
1605
+ """Lift an engine recipe dictionary into a Model conforming to hypergan-model/1."""
1606
+ from .config import DEFAULT
1607
+ from .model_file import PENALTY_FIELDS
1608
+ if isinstance(raw, Model):
1609
+ return raw if is_model_file(raw.spec) else lift(raw.spec, base=base or raw.base)
1610
+ raw = deepcopy(raw)
1611
+ base = Path.cwd() if base is None else Path(base)
1612
+ if is_model_file(raw):
1613
+ return from_dict(raw, base=base)
1614
+ components = raw.get('components', {}) or deepcopy(DEFAULT.get('components', {}))
1615
+ networks = {}
1616
+ losses = []
1617
+
1618
+ critics_in_terms = {term.get('component') for term in raw.get('adversarial_terms', [])}
1619
+ if 'discriminator' in components:
1620
+ critics_in_terms.add('discriminator')
1621
+
1622
+ for name, spec in components.items():
1623
+ if name == 'discriminator' or name in critics_in_terms:
1624
+ role = 'critic'
1625
+ elif name == 'generator':
1626
+ role = 'generator'
1627
+ elif 'encoder' in name or spec.get('factory', '').endswith('Encoder'):
1628
+ role = 'encoder'
1629
+ else:
1630
+ role = 'auxiliary'
1631
+
1632
+ net = {'role': role, 'inputs': dict(spec.get('inputs', {}))}
1633
+ if 'trainable' in spec:
1634
+ net['trainable'] = spec['trainable']
1635
+ if 'freeze_parameters' in spec:
1636
+ net['freeze_parameters'] = spec['freeze_parameters']
1637
+ if 'reuse' in spec:
1638
+ net['reuse'] = spec['reuse']
1639
+ elif spec.get('factory') == 'hndl':
1640
+ args = dict(spec.get('args', {}))
1641
+ if 'file' in args:
1642
+ net['hndl'] = args.pop('file')
1643
+ elif 'source' in args:
1644
+ net['source'] = args.pop('source')
1645
+ if 'input_shape' in args:
1646
+ net['input_shape'] = args.pop('input_shape')
1647
+ if 'output_shape' in args:
1648
+ net['output_shape'] = args.pop('output_shape')
1649
+ if args:
1650
+ net['args'] = args
1651
+ else:
1652
+ net['module'] = spec.get('factory')
1653
+ if spec.get('args'):
1654
+ net['args'] = dict(spec['args'])
1655
+ networks[name] = net
1656
+
1657
+ adv_idx = None
1658
+ if 'adversarial' in raw:
1659
+ adv_idx = len(losses)
1660
+ losses.append({'type': 'adversarial', 'critic': 'discriminator', 'weight': raw['adversarial'].get('weight', 1.0)})
1661
+ elif 'discriminator' in components:
1662
+ adv_idx = len(losses)
1663
+ losses.append({'type': 'adversarial', 'critic': 'discriminator', 'weight': 1.0})
1664
+
1665
+ if adv_idx is not None and 'gradient_penalty' in raw and 'coeff' in raw['gradient_penalty']:
1666
+ losses[adv_idx]['penalty'] = float(raw['gradient_penalty']['coeff'])
1667
+
1668
+ for term in raw.get('adversarial_terms', []):
1669
+ entry = {'type': 'adversarial', 'critic': term['component'], 'weight': term.get('weight', 1.0)}
1670
+ for k in ('real', 'fake', 'inputs', 'id'):
1671
+ if k in term:
1672
+ entry[k] = term[k]
1673
+ if term.get('penalty') is False:
1674
+ entry['penalty'] = 0.0
1675
+ elif term.get('penalty_coeff') is not None:
1676
+ entry['penalty'] = float(term['penalty_coeff'])
1677
+ losses.append(entry)
1678
+
1679
+ for term in raw.get('objectives', []):
1680
+ factory = term.get('factory', 'mse')
1681
+ args = dict(term.get('args', {}))
1682
+ if factory == 'hypergan.plugin_functions:FunctionLoss' and 'fn' in args:
1683
+ fn = args.pop('fn')
1684
+ else:
1685
+ fn = factory
1686
+ kind = 'reconstruction' if term.get('id') == 'reconstruction' else 'objective'
1687
+ entry = {'type': kind, 'id': term.get('id', 'loss'), 'fn': fn, 'weight': term.get('weight', 1.0)}
1688
+ if 'inputs' in term:
1689
+ entry['inputs'] = dict(term['inputs'])
1690
+ if 'detach' in term:
1691
+ entry['detach'] = list(term['detach'])
1692
+ if args:
1693
+ entry['args'] = args
1694
+ losses.append(entry)
1695
+
1696
+ if 'prior_regularizer' in raw:
1697
+ losses.append({'type': 'prior', **deepcopy(raw['prior_regularizer'])})
1698
+
1699
+ model_spec = {'format': FORMAT, 'networks': networks, 'losses': losses}
1700
+ if 'name' in raw:
1701
+ model_spec['name'] = raw['name']
1702
+ if 'defaults' in raw:
1703
+ model_spec['defaults'] = raw['defaults']
1704
+ if 'run' in raw:
1705
+ model_spec['run'] = deepcopy(raw['run'])
1706
+ if 'checkpoints' in raw:
1707
+ model_spec['checkpoints'] = deepcopy(raw['checkpoints'])
1708
+ if 'data' in raw:
1709
+ data = deepcopy(raw['data'])
1710
+ if data.get('factory') == 'hypergan.item_data:ItemData' and 'dataset' in data.get('args', {}):
1711
+ model_spec['data'] = {'dataset': data['args']['dataset'], **{k: v for k, v in data['args'].items() if k != 'dataset'}}
1712
+ else:
1713
+ model_spec['data'] = data
1714
+ if 'prior' in raw:
1715
+ prior_spec = deepcopy(raw['prior'])
1716
+ prior_args = prior_spec.pop('args', None)
1717
+ if isinstance(prior_args, dict):
1718
+ for k, v in prior_args.items():
1719
+ prior_spec.setdefault(k, v)
1720
+ model_spec['prior'] = prior_spec
1721
+ if 'training' in raw or 'optimizer' in raw:
1722
+ train_table = deepcopy(raw.get('training', {}))
1723
+ if 'optimizer' in raw:
1724
+ train_table.update(deepcopy(raw['optimizer']))
1725
+ model_spec['train'] = train_table
1726
+ gp = raw.get('penalty') or raw.get('gradient_penalty')
1727
+ if gp:
1728
+ penalty_table = {k: deepcopy(v) for k, v in gp.items() if k != 'coeff' and k in PENALTY_FIELDS}
1729
+ if penalty_table:
1730
+ model_spec['penalty'] = penalty_table
1731
+ if 'sampling' in raw:
1732
+ sampling_spec = deepcopy(raw['sampling'])
1733
+ sampling_spec.pop('output_noise', None)
1734
+ if 'generated' in sampling_spec:
1735
+ sampling_spec['output'] = sampling_spec.pop('generated')
1736
+ model_spec['sampling'] = sampling_spec
1737
+ if 'samplers' in raw:
1738
+ model_spec['samplers'] = deepcopy(raw['samplers'])
1739
+
1740
+
1741
+ if 'metrics' in raw:
1742
+ metrics_table = deepcopy(raw['metrics'])
1743
+ custom = metrics_table.pop('custom', {})
1744
+ scalar_custom = {}
1745
+ evaluations = {}
1746
+ for name, metric in custom.items():
1747
+ if 'evaluation' in metric or metric.get('mode') == 'snapshot' or metric.get('trigger') in ('interval', 'manual'):
1748
+ eval_entry = {}
1749
+ if 'factory' in metric:
1750
+ eval_entry['factory'] = metric['factory']
1751
+ if 'fn' in metric:
1752
+ eval_entry['fn'] = metric['fn']
1753
+ if 'args' in metric:
1754
+ eval_entry['args'] = deepcopy(metric['args'])
1755
+ if 'inputs' in metric:
1756
+ norm_inputs = {}
1757
+ for ik, iv in metric['inputs'].items():
1758
+ if iv == 'evaluation.generated':
1759
+ norm_inputs[ik] = 'generated'
1760
+ elif iv == 'evaluation.reference':
1761
+ norm_inputs[ik] = 'reference'
1762
+ else:
1763
+ norm_inputs[ik] = iv
1764
+ eval_entry['inputs'] = norm_inputs
1765
+ if metric.get('trigger') == 'interval' and 'every_steps' in metric:
1766
+ eval_entry['every'] = metric['every_steps']
1767
+ elif 'every_steps' in metric and metric.get('trigger') != 'manual':
1768
+ eval_entry['every'] = metric['every_steps']
1769
+ for k in ('timeout', 'on_error', 'on_busy'):
1770
+ if k in metric:
1771
+ eval_entry[k] = metric[k]
1772
+ if 'factory' not in eval_entry and eval_entry.get('kind') != 'factory':
1773
+ for k in ('label', 'unit', 'direction', 'description', 'reduce', 'kind'):
1774
+ if k in metric:
1775
+ eval_entry[k] = metric[k]
1776
+ proto = metric.get('evaluation', {})
1777
+ if isinstance(proto, str):
1778
+ if 'factory' not in eval_entry and eval_entry.get('kind') != 'factory':
1779
+ eval_entry.setdefault('kind', proto)
1780
+ proto = {}
1781
+ elif not isinstance(proto, dict):
1782
+ proto = {}
1783
+ if 'device' in proto:
1784
+ eval_entry['device'] = proto['device']
1785
+ elif 'device' in metric:
1786
+ eval_entry['device'] = metric['device']
1787
+ if 'sample_count' in proto:
1788
+ eval_entry['samples'] = proto['sample_count']
1789
+ elif 'samples' in proto:
1790
+ eval_entry['samples'] = proto['samples']
1791
+ elif 'sample_count' in metric:
1792
+ eval_entry['samples'] = metric['sample_count']
1793
+ elif 'samples' in metric:
1794
+ eval_entry['samples'] = metric['samples']
1795
+ if 'batch_size' in proto:
1796
+ eval_entry['batch_size'] = proto['batch_size']
1797
+ elif 'batch_size' in metric:
1798
+ eval_entry['batch_size'] = metric['batch_size']
1799
+ if 'seed' in proto:
1800
+ eval_entry['seed'] = proto['seed']
1801
+ elif 'seed' in metric:
1802
+ eval_entry['seed'] = metric['seed']
1803
+ proto_data = proto.get('data') or metric.get('data')
1804
+ if proto_data is not None:
1805
+ d = deepcopy(proto_data)
1806
+ if d.get('factory') == 'hypergan.item_data:ItemData' and 'dataset' in d.get('args', {}):
1807
+ eval_entry['data'] = {'dataset': d['args']['dataset'], **{k: v for k, v in d['args'].items() if k != 'dataset'}}
1808
+ else:
1809
+ eval_entry['data'] = d
1810
+ if 'output' in proto:
1811
+ eval_entry['output'] = proto['output']
1812
+ elif 'output' in metric:
1813
+ eval_entry['output'] = metric['output']
1814
+ evaluations[name] = eval_entry
1815
+ else:
1816
+ scalar_entry = deepcopy(metric)
1817
+ scalar_entry.pop('mode', None)
1818
+ scalar_custom[name] = scalar_entry
1819
+
1820
+ if scalar_custom:
1821
+ metrics_table['custom'] = scalar_custom
1822
+ if metrics_table:
1823
+ model_spec['metrics'] = metrics_table
1824
+ if evaluations:
1825
+ model_spec['evaluations'] = evaluations
1826
+
1827
+ return Model(model_spec, base=base)
1828
+
1829
+
1830
+ def describe(path=None, networks=False, write=False):
1831
+ """Describe a model's formulation, losses, optimizers, and network architectures."""
1832
+ from .model_description import MODEL_FILE, build_networks, describe_run, load as load_model_desc
1833
+ manifest = {}
1834
+ config = None
1835
+ resolved_path = None
1836
+
1837
+ if isinstance(path, Model):
1838
+ config = resolve(path)
1839
+ manifest = {"config": config, "config_sha256": fingerprint(path)}
1840
+ elif isinstance(path, Run):
1841
+ resolved_path = path.path
1842
+ manifest, config = load_model_desc(resolved_path)
1843
+ elif isinstance(path, dict):
1844
+ if "components" in path or "networks" in path:
1845
+ config = path
1846
+ manifest = {"config": config}
1847
+ else:
1848
+ manifest, config = load_model_desc(path)
1849
+ else:
1850
+ if path is None or (not Path(path).exists() and not str(path).endswith(".toml")):
1851
+ from .registry import resolve_run_target
1852
+ try:
1853
+ resolved_path = resolve_run_target(path)
1854
+ except Exception:
1855
+ if path is None:
1856
+ raise
1857
+ resolved_path = Path(path)
1858
+ else:
1859
+ resolved_path = Path(path)
1860
+ manifest, config = load_model_desc(resolved_path)
1861
+
1862
+ if write and (resolved_path is None or not (resolved_path.is_dir() and manifest)):
1863
+ raise ValueError("--write needs a run directory")
1864
+ if not isinstance(config, dict):
1865
+ raise ValueError("No model configuration recorded for this run")
1866
+ recorded = None
1867
+ if networks or write:
1868
+ recorded = build_networks(config, manifest.get("config_sha256"))
1869
+ if write and resolved_path is not None:
1870
+ from .run_state import atomic_json
1871
+ atomic_json(resolved_path / MODEL_FILE, recorded)
1872
+ return describe_run(manifest if manifest else {"config": config}, recorded=recorded)
1873
+
1874
+
1875
+ describe_model = describe
1876
+
1877
+
1878
+ def data_check(config, output=None):
1879
+ """Inspect and inventory a dataset folder defined in config, optionally writing a manifest."""
1880
+ from .data import ImageFolder
1881
+ from .config import load_config
1882
+ cfg = resolve(config) if isinstance(config, Model) or is_model_file(config) else load_config(config)
1883
+ if cfg["data"]["factory"] != "image_folder":
1884
+ raise ValueError("data-check currently supports data.factory='image_folder'")
1885
+ data = ImageFolder(**cfg["data"]["args"])
1886
+ manifest = {"schema_version": 1, "data": data.resume_identity(), "inventory": data.inventory}
1887
+ if output is not None:
1888
+ out_path = Path(output)
1889
+ with out_path.open("x", encoding="utf-8") as stream:
1890
+ json.dump(manifest, stream, indent=2, allow_nan=False)
1891
+ stream.write("\n")
1892
+ return manifest
1893
+
1894
+
1895
+ check_data = data_check
1896
+
1897
+
1898
+ def preflight(config, profile=None, runtime=False):
1899
+ """Validate execution environment, hardware, and configuration compatibility."""
1900
+ from .execution_profiles import load_execution_profile
1901
+ from .execution_preflight import _resolve_profile, preflight as run_preflight
1902
+ from .config import load_config
1903
+ cfg = resolve(config) if isinstance(config, Model) or is_model_file(config) else load_config(config)
1904
+ prof = (load_execution_profile(profile, cfg) if profile is not None
1905
+ else _resolve_profile(None, cfg))
1906
+ if runtime:
1907
+ return run_preflight(cfg, prof)
1908
+ return {"schema_version": 1, "stage": "structural", "profile": prof, "runtime_checked": False}
1909
+
1910
+
1911
+ def preflight_check(config, profile=None, runtime=True):
1912
+ """Run preflight check on config (defaults to runtime=True)."""
1913
+ return preflight(config, profile=profile, runtime=runtime)
1914
+
1915
+
1916
+ def serve(run_dir=None, port=None, host="0.0.0.0", auth="none", session_file=None, open_browser=False, public_origin=None, dev=False):
1917
+ """Launch the Web Observatory server for a runs root or run directory."""
1918
+ if dev:
1919
+ from .web_dev import enable
1920
+ enable()
1921
+ from .web_launch import serve as launch_serve
1922
+ target = find_runs_root(Path.cwd()) if run_dir is None else run_dir
1923
+ launch_serve(target, port=port, host=host, auth=auth,
1924
+ session_file=session_file, open_browser=open_browser,
1925
+ public_origin=public_origin)
1926
+
1927
+
1928
+ def viewer_status(run_dir=None):
1929
+ """Check the running status and port of a viewer process for a run or runs root."""
1930
+ from .web_autostart import viewer_status as autostart_status
1931
+ return autostart_status(_run_path(run_dir))
1932
+
1933
+
1934
+ def stop_viewer(run_dir=None):
1935
+ """Stop a running viewer process for a run directory."""
1936
+ from .web_autostart import stop_viewer as autostart_stop
1937
+ return autostart_stop(_run_path(run_dir))
1938
+
1939
+
1940
+ def contributions(run_dir=None, map_revision=None, cursor=None, limit=100, max_bytes=1048576):
1941
+ """Read a page of mapped contribution events, auto-projecting if needed."""
1942
+ from .event_views import MapSpec, read_projection_page
1943
+ run_path = _run_path(run_dir)
1944
+ rev = map_revision or MapSpec().revision
1945
+ view_dir = run_path / 'views' / rev
1946
+ has_events = (run_path / 'events.jsonl').is_file()
1947
+ has_views = (view_dir / 'contributions.jsonl').is_file() and (view_dir / 'metadata.json').is_file()
1948
+ if has_events and not has_views:
1949
+ project(run_path)
1950
+ return read_projection_page(run_path, rev, cursor, limit=limit, max_bytes=max_bytes)
1951
+
1952
+
1953
+ def project(run_dir=None, map_spec=None, follow=False, limit=100, on_progress=None):
1954
+ """Project run events into the mapped contribution views."""
1955
+ from .event_views import MapSpec, Projector
1956
+ import time
1957
+ run_path = _run_path(run_dir)
1958
+ spec = MapSpec(**json.loads(Path(map_spec).read_text(encoding="utf-8"))) if map_spec else MapSpec()
1959
+ following = True
1960
+ last_progress = None
1961
+ while following:
1962
+ opened = time.monotonic()
1963
+ with Projector(run_path, spec) as projector:
1964
+ while True:
1965
+ progress = projector.project(limit=limit)
1966
+ last_progress = dict(progress, map_revision=spec.revision)
1967
+ if follow and progress["documents"] and on_progress:
1968
+ on_progress(last_progress)
1969
+ if not follow and not progress["has_more"]:
1970
+ following = False
1971
+ return last_progress
1972
+ if time.monotonic() - opened >= 240:
1973
+ break
1974
+ if not progress["has_more"]:
1975
+ time.sleep(0.25)
1976
+ return last_progress
1977
+
1978
+
1979
+ def checkpoint_status(run_dir=None, request_id=None):
1980
+ """Check the status of an asynchronous checkpoint request."""
1981
+ from .run_requests import checkpoint_request_status
1982
+ return checkpoint_request_status(_run_path(run_dir), request_id)
1983
+
1984
+
1985
+ def request_checkpoint(run_dir=None, attempt_id=None, request_id=None):
1986
+ """Submit an asynchronous request for the training process to save a checkpoint."""
1987
+ from .run_requests import submit_checkpoint_request
1988
+ run_dir = _run_path(run_dir)
1989
+ manifest_path = run_dir / "manifest.json"
1990
+ if not manifest_path.is_file():
1991
+ from .inspection import check_run_active
1992
+ if check_run_active(run_dir)[0]:
1993
+ raise ValueError("Run is still initializing step 0; checkpoint will be available once step 1 starts")
1994
+ raise FileNotFoundError(f"Run manifest not found: {manifest_path}")
1995
+ with manifest_path.open(encoding="utf-8") as stream:
1996
+ manifest = json.load(stream)
1997
+ if not isinstance(manifest, dict) or not manifest.get("run_id") or not manifest.get("attempt_id"):
1998
+ from .inspection import check_run_active
1999
+ if check_run_active(run_dir)[0] or (isinstance(manifest, dict) and manifest.get("status") == "initializing"):
2000
+ raise ValueError("Run is still initializing step 0; checkpoint will be available once step 1 starts")
2001
+ raise ValueError("Run manifest has no run/attempt identity for a checkpoint request")
2002
+ if manifest.get("status") == "initializing":
2003
+ raise ValueError("Run is still initializing step 0; checkpoint will be available once step 1 starts")
2004
+ if manifest.get("status") != "running":
2005
+ from .inspection import check_run_active
2006
+ if check_run_active(run_dir)[0] and int(manifest.get("steps", 0) or 0) == 0:
2007
+ raise ValueError("Run is still initializing step 0; checkpoint will be available once step 1 starts")
2008
+ if request_id is None:
2009
+ raise ValueError("Run is not running; use resume to continue a stopped run")
2010
+ try:
2011
+ return checkpoint_status(run_dir, request_id)
2012
+ except FileNotFoundError as exc:
2013
+ raise ValueError("Run is not running; use resume to continue a stopped run") from exc
2014
+ return submit_checkpoint_request(run_dir, run_id=manifest["run_id"],
2015
+ attempt_id=attempt_id or manifest["attempt_id"],
2016
+ request_id=request_id)
2017
+
2018
+
2019
+ def ensure_dashboard_running(runs_root=None, check_version=True, run_id=None):
2020
+ """Ensure the persistent root dashboard is running for runs_root, returning its URL."""
2021
+ from .web_autostart import ensure_dashboard_running as autostart_ensure
2022
+ target = Path(runs_root) if runs_root is not None else None
2023
+ return autostart_ensure(target, check_version=check_version, run_id=run_id)
2024
+
2025
+
2026
+ def stop_all_servers(force=False):
2027
+ """Terminate all background viewer, dashboard, and supervisor processes on the host."""
2028
+ from .web_autostart import stop_all_servers as autostart_stop_all
2029
+ return autostart_stop_all(force=force)
2030
+
2031
+
2032
+ def dashboard_status(runs_root=None):
2033
+ """Query the status of the persistent Web Observatory dashboard for a runs root or run."""
2034
+ from .web_autostart import dashboard_status as autostart_dstatus
2035
+ target = Path(runs_root) if runs_root is not None else None
2036
+ return autostart_dstatus(target)
2037
+
2038
+
2039
+ def stop_dashboard(runs_root=None, timeout=8.0):
2040
+ """Stop the persistent Web Observatory dashboard for a runs root or run."""
2041
+ from .web_autostart import stop_dashboard as autostart_sstop
2042
+ target = Path(runs_root) if runs_root is not None else None
2043
+ return autostart_sstop(target, timeout=timeout)
2044
+
2045
+
2046
+ def find_runs_root(path=None):
2047
+ """Locate the root directory containing training runs for a workspace or path."""
2048
+ from .web_autostart import find_runs_root as autostart_find
2049
+ return autostart_find(path)
2050
+
2051
+
2052
+ def stop_projector(run_dir):
2053
+ """Stop the background projection worker for a run directory."""
2054
+ from .web_autostart import stop_projector as autostart_sproj
2055
+ return autostart_sproj(Path(run_dir))
2056
+
2057
+
2058
+ # --------------------------------------------------------------------------- job queue
2059
+
2060
+ def queue_add(model_file, devices=None, run_dir=None, name=None, steps=None, sets=None, no_web=False, wait=False, queue_file=None, **kwargs):
2061
+ """Enqueue a training experiment or wait directive into the job queue."""
2062
+ from .queue import queue_add as _add
2063
+ return _add(model_file, devices=devices, run_dir=run_dir, name=name, steps=steps, sets=sets, no_web=no_web, wait=wait, queue_file=queue_file, **kwargs)
2064
+
2065
+
2066
+ def queue_ls(status=None, queue_file=None):
2067
+ """List queued, running, and completed jobs from the job queue."""
2068
+ from .queue import queue_ls as _ls
2069
+ return _ls(status_filter=status, queue_file=queue_file)
2070
+
2071
+
2072
+ def queue_status(job_id=None, queue_file=None, lines=20):
2073
+ """Check the execution status and recent logs of a queued job."""
2074
+ from .queue import queue_status as _status
2075
+ return _status(job_id=job_id, queue_file=queue_file, lines=lines)
2076
+
2077
+
2078
+ def queue_stop(job_id=None, stop_all=False, queue_file=None):
2079
+ """Stop or cancel a queued job, or all jobs if stop_all=True."""
2080
+ from .queue import queue_stop as _stop
2081
+ return _stop(job_id=job_id, stop_all=stop_all, queue_file=queue_file)
2082
+
2083
+
2084
+ def queue_run(devices=None, poll_interval=5.0, once=False, queue_file=None):
2085
+ """Process and run jobs sequentially from the queue until drained."""
2086
+ from .queue import queue_run as _run
2087
+ return _run(devices=devices, poll_interval=poll_interval, once=once, queue_file=queue_file)
2088
+
2089
+
2090
+ def queue_install_service(user=True, devices=None, name=None, dry_run=False):
2091
+ """Generate or install a systemd service unit for the HyperGAN queue runner."""
2092
+ from .queue import install_service as _install
2093
+ return _install(user=user, devices=devices, name=name, dry_run=dry_run)
2094
+
2095
+
2096
+ def queue_clear(queue_file=None):
2097
+ """Clear all queued and running jobs in the job queue."""
2098
+ from .queue import queue_clear as _clear
2099
+ return _clear(queue_file=queue_file)
2100
+
2101
+
2102
+ # --------------------------------------------------------------------------- convenience exports
2103
+
2104
+ def compare(runs, metric="fid50k_train", at_steps=None):
2105
+ """Compare multiple runs for a given metric across step milestones."""
2106
+ return runs_compare(runs, metric=metric, at_steps=at_steps)
2107
+
2108
+
2109
+ def _clean_diff_dict(cfg):
2110
+ if not isinstance(cfg, dict):
2111
+ return cfg
2112
+ cleaned = deepcopy(cfg)
2113
+ for k in ("warnings", "qualification", "_source_path", "name_map", "schema_version", "format"):
2114
+ cleaned.pop(k, None)
2115
+ if isinstance(cleaned.get("recipe"), dict):
2116
+ cleaned.pop("recipe", None)
2117
+ return cleaned
2118
+
2119
+
2120
+ def _extract_diff_config(target):
2121
+ if isinstance(target, Model):
2122
+ return _clean_diff_dict(dict(resolve(target)))
2123
+ if isinstance(target, Run):
2124
+ if target.manifest.get("config") and isinstance(target.manifest["config"], dict):
2125
+ return _clean_diff_dict(target.manifest["config"])
2126
+ target = target.path
2127
+ if isinstance(target, dict):
2128
+ if "components" in target or "networks" in target:
2129
+ return _clean_diff_dict(dict(resolve(from_dict(target))))
2130
+ if "config" in target and isinstance(target["config"], dict):
2131
+ return _clean_diff_dict(target["config"])
2132
+ return _clean_diff_dict(target)
2133
+ target_str = str(target)
2134
+ target_p = Path(target_str)
2135
+ if target_str.endswith(".toml") or (target_p.is_file() and target_p.suffix == ".toml"):
2136
+ if not target_p.is_file():
2137
+ raise FileNotFoundError(f"Recipe target '{target_str}' not found")
2138
+ m = load(target_p)
2139
+ return _clean_diff_dict(dict(resolve(m)))
2140
+ if target_str.endswith(".pt") or (target_p.is_file() and target_p.suffix == ".pt"):
2141
+ if not target_p.is_file():
2142
+ raise FileNotFoundError(f"Checkpoint file '{target_str}' not found")
2143
+ import torch
2144
+ state = torch.load(target_p, map_location="cpu", weights_only=True)
2145
+ if isinstance(state, dict):
2146
+ if "config" in state and isinstance(state["config"], dict):
2147
+ return _clean_diff_dict(state["config"])
2148
+ if "model_state_dict" in state:
2149
+ return {k: list(v.shape) for k, v in state["model_state_dict"].items() if hasattr(v, "shape")}
2150
+ return {}
2151
+ resolved = resolve_run_target(target_str)
2152
+ manifest_p = resolved / "manifest.json"
2153
+ if manifest_p.is_file():
2154
+ try:
2155
+ m_data = json.loads(manifest_p.read_text(encoding="utf-8"))
2156
+ if "config" in m_data and isinstance(m_data["config"], dict):
2157
+ return _clean_diff_dict(m_data["config"])
2158
+ except Exception:
2159
+ pass
2160
+ for fname in ("model.toml", "recipe.toml", "config.toml"):
2161
+ recipe_p = resolved / fname
2162
+ if recipe_p.is_file():
2163
+ m = load(recipe_p)
2164
+ return _clean_diff_dict(dict(resolve(m)))
2165
+ raise FileNotFoundError(f"Run or recipe target '{target_str}' not found or contains no configuration")
2166
+
2167
+
2168
+ def _flatten_diff_dict(d, prefix=""):
2169
+ flat = {}
2170
+ if isinstance(d, dict):
2171
+ for k, v in d.items():
2172
+ full = f"{prefix}.{k}" if prefix else str(k)
2173
+ if isinstance(v, dict) and v:
2174
+ flat.update(_flatten_diff_dict(v, full))
2175
+ else:
2176
+ flat[full] = v
2177
+ else:
2178
+ flat[prefix] = d
2179
+ return flat
2180
+
2181
+
2182
+ def diff(target_a, target_b=None, *, sets=None, overrides=None):
2183
+ """Compare two configurations, models, runs, or checkpoints, returning differences."""
2184
+ merged_sets = dict(sets or {})
2185
+ if overrides:
2186
+ merged_sets.update(overrides)
2187
+ if target_b is None:
2188
+ if not merged_sets:
2189
+ raise ValueError("diff requires target_b or overrides/sets")
2190
+ m_b = override(_model(target_a), merged_sets)
2191
+ target_b_label = f"{getattr(target_a, 'path', target_a)} (with overrides)"
2192
+ cfg_b = _extract_diff_config(m_b)
2193
+ elif merged_sets:
2194
+ m_b = override(_model(target_b), merged_sets)
2195
+ target_b_label = str(getattr(target_b, "path", target_b))
2196
+ cfg_b = _extract_diff_config(m_b)
2197
+ else:
2198
+ target_b_label = str(getattr(target_b, "path", target_b))
2199
+ cfg_b = _extract_diff_config(target_b)
2200
+ cfg_a = _extract_diff_config(target_a)
2201
+ flat_a = _flatten_diff_dict(cfg_a)
2202
+ flat_b = _flatten_diff_dict(cfg_b)
2203
+ all_keys = sorted(set(flat_a.keys()) | set(flat_b.keys()))
2204
+ differences = {}
2205
+ for k in all_keys:
2206
+ val_a = flat_a.get(k)
2207
+ val_b = flat_b.get(k)
2208
+ if val_a != val_b:
2209
+ differences[k] = {"a": val_a, "b": val_b}
2210
+ return {
2211
+ "target_a": str(getattr(target_a, "path", target_a)),
2212
+ "target_b": target_b_label,
2213
+ "identical": len(differences) == 0,
2214
+ "differences": differences,
2215
+ }
2216
+
2217
+
2218
+ def list_templates():
2219
+ """List available starter templates (e.g. gaussian-grid, dcgan, resnet, tiny-transformer)."""
2220
+ from .recipe_generator import list_starter_templates
2221
+ return list_starter_templates()