runops 0.2.0__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.
- runops/__init__.py +5 -0
- runops/_data/README.md +476 -0
- runops/adapters/__init__.py +29 -0
- runops/adapters/_utils/__init__.py +36 -0
- runops/adapters/_utils/toml_utils.py +81 -0
- runops/adapters/base.py +335 -0
- runops/adapters/contrib/__init__.py +5 -0
- runops/adapters/contrib/beach.py +837 -0
- runops/adapters/contrib/emses.py +1010 -0
- runops/adapters/generic.py +439 -0
- runops/adapters/registry.py +244 -0
- runops/cli/__init__.py +3 -0
- runops/cli/analyze.py +222 -0
- runops/cli/clone.py +104 -0
- runops/cli/config.py +217 -0
- runops/cli/context.py +56 -0
- runops/cli/create.py +263 -0
- runops/cli/dashboard.py +179 -0
- runops/cli/extend.py +204 -0
- runops/cli/history.py +105 -0
- runops/cli/init.py +1432 -0
- runops/cli/jobs.py +145 -0
- runops/cli/knowledge.py +1017 -0
- runops/cli/list.py +102 -0
- runops/cli/log.py +163 -0
- runops/cli/main.py +96 -0
- runops/cli/manage.py +231 -0
- runops/cli/new.py +343 -0
- runops/cli/notes.py +257 -0
- runops/cli/run_lookup.py +148 -0
- runops/cli/setup.py +174 -0
- runops/cli/status.py +187 -0
- runops/cli/submit.py +297 -0
- runops/cli/update.py +113 -0
- runops/cli/update_harness.py +245 -0
- runops/cli/update_refs.py +370 -0
- runops/core/__init__.py +3 -0
- runops/core/actions.py +1186 -0
- runops/core/analysis.py +1090 -0
- runops/core/campaign.py +156 -0
- runops/core/case.py +307 -0
- runops/core/context.py +426 -0
- runops/core/discovery.py +192 -0
- runops/core/environment.py +266 -0
- runops/core/exceptions.py +93 -0
- runops/core/knowledge.py +595 -0
- runops/core/knowledge_source.py +1204 -0
- runops/core/manifest.py +219 -0
- runops/core/project.py +171 -0
- runops/core/provenance.py +147 -0
- runops/core/retry.py +193 -0
- runops/core/run.py +170 -0
- runops/core/run_creation.py +456 -0
- runops/core/site.py +337 -0
- runops/core/state.py +197 -0
- runops/core/survey.py +380 -0
- runops/core/validation.py +40 -0
- runops/harness/__init__.py +27 -0
- runops/harness/builder.py +327 -0
- runops/harness/claude.py +189 -0
- runops/jobgen/__init__.py +3 -0
- runops/jobgen/generator.py +295 -0
- runops/launchers/__init__.py +17 -0
- runops/launchers/base.py +313 -0
- runops/launchers/mpiexec.py +131 -0
- runops/launchers/mpirun.py +132 -0
- runops/launchers/srun.py +126 -0
- runops/sites/__init__.py +0 -0
- runops/sites/camphor.md +98 -0
- runops/sites/camphor.toml +27 -0
- runops/slurm/__init__.py +3 -0
- runops/slurm/query.py +384 -0
- runops/slurm/submit.py +203 -0
- runops/templates/__init__.py +29 -0
- runops/templates/adapters/beach/agent_guide.md +50 -0
- runops/templates/adapters/beach/beach.toml +19 -0
- runops/templates/adapters/beach/case.toml +16 -0
- runops/templates/adapters/beach/summarize.py +272 -0
- runops/templates/adapters/emses/agent_guide.md +39 -0
- runops/templates/adapters/emses/case.toml +18 -0
- runops/templates/adapters/emses/plasma.toml +118 -0
- runops/templates/adapters/emses/summarize.py +413 -0
- runops/templates/adapters/generic/case.toml.j2 +13 -0
- runops/templates/adapters/generic/summarize.py +21 -0
- runops/templates/agent.md +156 -0
- runops/templates/rules/cookbook.md +22 -0
- runops/templates/scaffold/campaign.toml.j2 +10 -0
- runops/templates/scaffold/cases_claude.md +22 -0
- runops/templates/scaffold/facts.toml +2 -0
- runops/templates/scaffold/gitignore.txt +30 -0
- runops/templates/scaffold/notes/README.md +69 -0
- runops/templates/scaffold/rules/plan-before-act.md +17 -0
- runops/templates/scaffold/rules/runops-workflow.md +84 -0
- runops/templates/scaffold/rules/upstream-feedback.md +85 -0
- runops/templates/scaffold/runs_claude.md +24 -0
- runops/templates/scaffold/vscode_settings.json +9 -0
- runops/templates/skills/analyze/SKILL.md +40 -0
- runops/templates/skills/check-status/SKILL.md +29 -0
- runops/templates/skills/cleanup/SKILL.md +43 -0
- runops/templates/skills/create-run/SKILL.md +135 -0
- runops/templates/skills/debug-failed/SKILL.md +38 -0
- runops/templates/skills/learn/SKILL.md +54 -0
- runops/templates/skills/new-case/SKILL.md +108 -0
- runops/templates/skills/note/SKILL.md +107 -0
- runops/templates/skills/run-all/SKILL.md +47 -0
- runops/templates/skills/runops-reference/SKILL.md +203 -0
- runops/templates/skills/setup-campaign/SKILL.md +111 -0
- runops/templates/skills/setup-env/SKILL.md +32 -0
- runops/templates/skills/survey-design/SKILL.md +73 -0
- runops/templates/survey.toml.j2 +22 -0
- runops-0.2.0.dist-info/METADATA +491 -0
- runops-0.2.0.dist-info/RECORD +115 -0
- runops-0.2.0.dist-info/WHEEL +4 -0
- runops-0.2.0.dist-info/entry_points.txt +2 -0
- runops-0.2.0.dist-info/licenses/LICENSE +201 -0
runops/core/survey.py
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"""Survey expansion and parameter sweep.
|
|
2
|
+
|
|
3
|
+
Reads survey.toml and expands parameter axes into individual run configurations.
|
|
4
|
+
Supports both Cartesian product (axes) and co-varying (linked) parameters.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import itertools
|
|
10
|
+
import sys
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
if sys.version_info >= (3, 11):
|
|
16
|
+
import tomllib
|
|
17
|
+
else:
|
|
18
|
+
import tomli as tomllib
|
|
19
|
+
|
|
20
|
+
from runops.core.case import (
|
|
21
|
+
ClassificationData,
|
|
22
|
+
JobData,
|
|
23
|
+
_parse_classification,
|
|
24
|
+
_parse_job,
|
|
25
|
+
)
|
|
26
|
+
from runops.core.exceptions import SurveyConfigError
|
|
27
|
+
|
|
28
|
+
_SURVEY_FILE = "survey.toml"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class SurveyData:
|
|
33
|
+
"""Immutable representation of a survey.toml configuration.
|
|
34
|
+
|
|
35
|
+
Matches SPEC section 11.2.
|
|
36
|
+
|
|
37
|
+
Attributes:
|
|
38
|
+
id: Survey identifier (e.g. "S20260327-cavity-u-a").
|
|
39
|
+
name: Human-readable survey name.
|
|
40
|
+
base_case: Name of the base case to derive runs from.
|
|
41
|
+
simulator: Simulator name.
|
|
42
|
+
launcher: Launcher profile name.
|
|
43
|
+
classification: Classification metadata.
|
|
44
|
+
axes: Parameter axes for cartesian product expansion.
|
|
45
|
+
linked: List of co-varying parameter groups (zip expansion).
|
|
46
|
+
naming_template: Template string for generating display_name.
|
|
47
|
+
job: Slurm job configuration.
|
|
48
|
+
survey_dir: Absolute path to the survey directory.
|
|
49
|
+
raw: The raw parsed survey.toml dictionary.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
id: str
|
|
53
|
+
name: str
|
|
54
|
+
base_case: str
|
|
55
|
+
simulator: str
|
|
56
|
+
launcher: str
|
|
57
|
+
classification: ClassificationData = field(default_factory=ClassificationData)
|
|
58
|
+
axes: dict[str, list[Any]] = field(default_factory=dict)
|
|
59
|
+
linked: list[dict[str, list[Any]]] = field(default_factory=list)
|
|
60
|
+
naming_template: str = ""
|
|
61
|
+
job: JobData = field(default_factory=JobData)
|
|
62
|
+
survey_dir: Path = field(default_factory=lambda: Path("."))
|
|
63
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def load_survey(survey_dir: Path) -> SurveyData:
|
|
67
|
+
"""Load and validate a survey.toml file.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
survey_dir: Directory containing survey.toml.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Validated SurveyData instance.
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
SurveyConfigError: If survey.toml is missing, invalid, or lacks
|
|
77
|
+
required fields.
|
|
78
|
+
"""
|
|
79
|
+
survey_dir = survey_dir.resolve()
|
|
80
|
+
survey_file = survey_dir / _SURVEY_FILE
|
|
81
|
+
|
|
82
|
+
if not survey_file.exists():
|
|
83
|
+
raise SurveyConfigError(f"{_SURVEY_FILE} not found in {survey_dir}")
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
with open(survey_file, "rb") as f:
|
|
87
|
+
raw = tomllib.load(f)
|
|
88
|
+
except tomllib.TOMLDecodeError as e:
|
|
89
|
+
raise SurveyConfigError(f"Invalid TOML in {survey_file}: {e}") from e
|
|
90
|
+
|
|
91
|
+
survey_section = raw.get("survey")
|
|
92
|
+
if not isinstance(survey_section, dict):
|
|
93
|
+
raise SurveyConfigError(f"Missing or invalid [survey] section in {survey_file}")
|
|
94
|
+
|
|
95
|
+
survey_id = survey_section.get("id")
|
|
96
|
+
if not isinstance(survey_id, str) or not survey_id:
|
|
97
|
+
# Auto-generate survey id from date and directory name
|
|
98
|
+
from datetime import datetime, timezone
|
|
99
|
+
|
|
100
|
+
date_str = datetime.now(tz=timezone.utc).strftime("%Y%m%d")
|
|
101
|
+
dir_slug = survey_dir.name.replace(" ", "-")
|
|
102
|
+
survey_id = f"S{date_str}-{dir_slug}"
|
|
103
|
+
|
|
104
|
+
name = str(survey_section.get("name", ""))
|
|
105
|
+
|
|
106
|
+
# Validate required fields, collecting all errors at once
|
|
107
|
+
errors: list[str] = []
|
|
108
|
+
base_case = survey_section.get("base_case")
|
|
109
|
+
if not isinstance(base_case, str) or not base_case:
|
|
110
|
+
errors.append("survey.base_case")
|
|
111
|
+
|
|
112
|
+
simulator = survey_section.get("simulator")
|
|
113
|
+
if not isinstance(simulator, str) or not simulator:
|
|
114
|
+
errors.append("survey.simulator")
|
|
115
|
+
|
|
116
|
+
launcher = survey_section.get("launcher")
|
|
117
|
+
if not isinstance(launcher, str) or not launcher:
|
|
118
|
+
errors.append("survey.launcher")
|
|
119
|
+
|
|
120
|
+
if errors:
|
|
121
|
+
missing = ", ".join(errors)
|
|
122
|
+
raise SurveyConfigError(
|
|
123
|
+
f"Missing or empty required fields in {survey_file}: {missing}"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
classification = _parse_classification(raw.get("classification", {}))
|
|
127
|
+
|
|
128
|
+
# Parse axes
|
|
129
|
+
axes_section = raw.get("axes", {})
|
|
130
|
+
if not isinstance(axes_section, dict):
|
|
131
|
+
raise SurveyConfigError(f"Invalid [axes] section in {survey_file}")
|
|
132
|
+
axes: dict[str, list[Any]] = {}
|
|
133
|
+
for key, values in axes_section.items():
|
|
134
|
+
if not isinstance(values, list):
|
|
135
|
+
raise SurveyConfigError(f"Axis '{key}' must be a list in {survey_file}")
|
|
136
|
+
if len(values) == 0:
|
|
137
|
+
raise SurveyConfigError(f"Axis '{key}' must not be empty in {survey_file}")
|
|
138
|
+
axes[key] = values
|
|
139
|
+
|
|
140
|
+
# Parse linked parameter groups
|
|
141
|
+
linked_section = raw.get("linked", [])
|
|
142
|
+
linked: list[dict[str, list[Any]]] = []
|
|
143
|
+
if isinstance(linked_section, list):
|
|
144
|
+
for i, group in enumerate(linked_section):
|
|
145
|
+
if not isinstance(group, dict):
|
|
146
|
+
raise SurveyConfigError(
|
|
147
|
+
f"[[linked]] entry {i} must be a table in {survey_file}"
|
|
148
|
+
)
|
|
149
|
+
parsed_group: dict[str, list[Any]] = {}
|
|
150
|
+
lengths: set[int] = set()
|
|
151
|
+
for key, values in group.items():
|
|
152
|
+
if not isinstance(values, list):
|
|
153
|
+
raise SurveyConfigError(
|
|
154
|
+
f"Linked parameter '{key}' in group {i} must be a list"
|
|
155
|
+
f" in {survey_file}"
|
|
156
|
+
)
|
|
157
|
+
if len(values) == 0:
|
|
158
|
+
raise SurveyConfigError(
|
|
159
|
+
f"Linked parameter '{key}' in group {i} must not be empty"
|
|
160
|
+
f" in {survey_file}"
|
|
161
|
+
)
|
|
162
|
+
lengths.add(len(values))
|
|
163
|
+
parsed_group[key] = values
|
|
164
|
+
if len(lengths) > 1:
|
|
165
|
+
raise SurveyConfigError(
|
|
166
|
+
f"All parameters in [[linked]] group {i} must have the same"
|
|
167
|
+
f" number of values (got {sorted(lengths)}) in {survey_file}"
|
|
168
|
+
)
|
|
169
|
+
if parsed_group:
|
|
170
|
+
linked.append(parsed_group)
|
|
171
|
+
elif isinstance(linked_section, dict):
|
|
172
|
+
raise SurveyConfigError(
|
|
173
|
+
f"[linked] must be an array of tables ([[linked]]), not a single"
|
|
174
|
+
f" table in {survey_file}"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# Validate no overlap between axes and linked keys
|
|
178
|
+
axes_keys = set(axes.keys())
|
|
179
|
+
for i, group in enumerate(linked):
|
|
180
|
+
linked_keys = set(group.keys())
|
|
181
|
+
overlap = axes_keys & linked_keys
|
|
182
|
+
if overlap:
|
|
183
|
+
raise SurveyConfigError(
|
|
184
|
+
f"Parameters {overlap} appear in both [axes] and [[linked]]"
|
|
185
|
+
f" group {i} in {survey_file}"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Parse naming template
|
|
189
|
+
naming_section = raw.get("naming", {})
|
|
190
|
+
naming_template = ""
|
|
191
|
+
if isinstance(naming_section, dict):
|
|
192
|
+
naming_template = str(naming_section.get("display_name", ""))
|
|
193
|
+
|
|
194
|
+
job = _parse_job(raw.get("job", {}))
|
|
195
|
+
|
|
196
|
+
return SurveyData(
|
|
197
|
+
id=survey_id,
|
|
198
|
+
name=name,
|
|
199
|
+
base_case=str(base_case),
|
|
200
|
+
simulator=str(simulator),
|
|
201
|
+
launcher=str(launcher),
|
|
202
|
+
classification=classification,
|
|
203
|
+
axes=axes,
|
|
204
|
+
linked=linked,
|
|
205
|
+
naming_template=naming_template,
|
|
206
|
+
job=job,
|
|
207
|
+
survey_dir=survey_dir,
|
|
208
|
+
raw=raw,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def expand_axes(axes: dict[str, list[Any]]) -> list[dict[str, Any]]:
|
|
213
|
+
"""Compute the cartesian product of parameter axes.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
axes: Mapping of parameter names to lists of values.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
List of parameter dictionaries, one per combination.
|
|
220
|
+
Empty list if axes is empty.
|
|
221
|
+
|
|
222
|
+
Example:
|
|
223
|
+
>>> expand_axes({"a": [1, 2], "b": [10, 20]})
|
|
224
|
+
[{"a": 1, "b": 10}, {"a": 1, "b": 20}, {"a": 2, "b": 10}, {"a": 2, "b": 20}]
|
|
225
|
+
"""
|
|
226
|
+
if not axes:
|
|
227
|
+
return []
|
|
228
|
+
|
|
229
|
+
keys = list(axes.keys())
|
|
230
|
+
value_lists = [axes[k] for k in keys]
|
|
231
|
+
|
|
232
|
+
return [
|
|
233
|
+
dict(zip(keys, combo, strict=True)) for combo in itertools.product(*value_lists)
|
|
234
|
+
]
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _expand_linked(linked: list[dict[str, list[Any]]]) -> list[dict[str, Any]]:
|
|
238
|
+
"""Expand linked parameter groups via zip, then Cartesian product across groups.
|
|
239
|
+
|
|
240
|
+
Each group's parameters co-vary (zip). Multiple groups are combined via
|
|
241
|
+
Cartesian product with each other.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
linked: List of linked parameter groups.
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
List of parameter dictionaries from linked expansion.
|
|
248
|
+
Returns [{}] if linked is empty (identity for Cartesian product).
|
|
249
|
+
"""
|
|
250
|
+
if not linked:
|
|
251
|
+
return [{}]
|
|
252
|
+
|
|
253
|
+
# Each group produces a list of dicts (zip within group)
|
|
254
|
+
group_expansions: list[list[dict[str, Any]]] = []
|
|
255
|
+
for group in linked:
|
|
256
|
+
keys = list(group.keys())
|
|
257
|
+
n = len(group[keys[0]])
|
|
258
|
+
zipped = [{k: group[k][i] for k in keys} for i in range(n)]
|
|
259
|
+
group_expansions.append(zipped)
|
|
260
|
+
|
|
261
|
+
# Cartesian product across groups
|
|
262
|
+
if len(group_expansions) == 1:
|
|
263
|
+
return group_expansions[0]
|
|
264
|
+
|
|
265
|
+
result: list[dict[str, Any]] = []
|
|
266
|
+
for combo in itertools.product(*group_expansions):
|
|
267
|
+
merged: dict[str, Any] = {}
|
|
268
|
+
for d in combo:
|
|
269
|
+
merged.update(d)
|
|
270
|
+
result.append(merged)
|
|
271
|
+
return result
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def expand_survey(
|
|
275
|
+
axes: dict[str, list[Any]],
|
|
276
|
+
linked: list[dict[str, list[Any]]],
|
|
277
|
+
) -> list[dict[str, Any]]:
|
|
278
|
+
"""Expand both axes (Cartesian product) and linked (co-varying) parameters.
|
|
279
|
+
|
|
280
|
+
The final result is the Cartesian product of:
|
|
281
|
+
- The axes expansion (Cartesian product of independent axes)
|
|
282
|
+
- The linked expansion (zip within each group, Cartesian across groups)
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
axes: Parameter axes for Cartesian product.
|
|
286
|
+
linked: List of co-varying parameter groups.
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
List of parameter dictionaries, one per combination.
|
|
290
|
+
|
|
291
|
+
Example:
|
|
292
|
+
>>> expand_survey({"seed": [1, 2]}, [{"nx": [32, 64], "ny": [32, 64]}])
|
|
293
|
+
[
|
|
294
|
+
{"seed": 1, "nx": 32, "ny": 32},
|
|
295
|
+
{"seed": 1, "nx": 64, "ny": 64},
|
|
296
|
+
{"seed": 2, "nx": 32, "ny": 32},
|
|
297
|
+
{"seed": 2, "nx": 64, "ny": 64},
|
|
298
|
+
]
|
|
299
|
+
"""
|
|
300
|
+
axes_combos = expand_axes(axes)
|
|
301
|
+
linked_combos = _expand_linked(linked)
|
|
302
|
+
|
|
303
|
+
if not axes_combos and not linked:
|
|
304
|
+
return []
|
|
305
|
+
if not axes_combos:
|
|
306
|
+
return linked_combos
|
|
307
|
+
if not linked:
|
|
308
|
+
return axes_combos
|
|
309
|
+
|
|
310
|
+
result: list[dict[str, Any]] = []
|
|
311
|
+
for axes_dict, linked_dict in itertools.product(axes_combos, linked_combos):
|
|
312
|
+
result.append({**axes_dict, **linked_dict})
|
|
313
|
+
return result
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def generate_display_name(template: str, params: dict[str, Any]) -> str:
|
|
317
|
+
"""Generate a display_name from a naming template and parameters.
|
|
318
|
+
|
|
319
|
+
Uses Python str.format_map with parameter values. Non-string
|
|
320
|
+
values are formatted directly (floats use default repr).
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
template: Naming template string (e.g. "u{u}_a{aspect}_s{seed}").
|
|
324
|
+
params: Parameter dictionary to substitute into the template.
|
|
325
|
+
|
|
326
|
+
Returns:
|
|
327
|
+
Rendered display name string. Returns empty string if template
|
|
328
|
+
is empty.
|
|
329
|
+
"""
|
|
330
|
+
if not template:
|
|
331
|
+
return ""
|
|
332
|
+
|
|
333
|
+
# Build a string-safe mapping, including short aliases for dotted keys
|
|
334
|
+
fmt_params: dict[str, str] = {}
|
|
335
|
+
for key, value in params.items():
|
|
336
|
+
formatted = f"{value:g}" if isinstance(value, float) else str(value)
|
|
337
|
+
fmt_params[key] = formatted
|
|
338
|
+
|
|
339
|
+
# Normalize brackets: "species[2].ray_zenith_angle_deg"
|
|
340
|
+
# → "species_2_ray_zenith_angle_deg" (underscore form)
|
|
341
|
+
# → "ray_zenith_angle_deg" (leaf)
|
|
342
|
+
normalized = key.replace("[", "_").replace("]", "")
|
|
343
|
+
if normalized != key:
|
|
344
|
+
fmt_params[normalized] = formatted
|
|
345
|
+
|
|
346
|
+
# For dotted keys like "plasma.wc", also register the leaf name "wc"
|
|
347
|
+
# and the underscore form "plasma_wc" for use in templates.
|
|
348
|
+
effective = normalized if normalized != key else key
|
|
349
|
+
if "." in effective:
|
|
350
|
+
leaf = effective.rsplit(".", 1)[1]
|
|
351
|
+
if leaf not in fmt_params:
|
|
352
|
+
fmt_params[leaf] = formatted
|
|
353
|
+
fmt_params[effective.replace(".", "_")] = formatted
|
|
354
|
+
|
|
355
|
+
try:
|
|
356
|
+
return template.format_map(fmt_params)
|
|
357
|
+
except KeyError:
|
|
358
|
+
# Missing key in params - return template with available substitutions
|
|
359
|
+
return template.format_map(
|
|
360
|
+
{**{k: f"{{{k}}}" for k in _extract_keys(template)}, **fmt_params}
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _extract_keys(template: str) -> list[str]:
|
|
365
|
+
"""Extract format keys from a template string.
|
|
366
|
+
|
|
367
|
+
Args:
|
|
368
|
+
template: A Python format string.
|
|
369
|
+
|
|
370
|
+
Returns:
|
|
371
|
+
List of key names found in the template.
|
|
372
|
+
"""
|
|
373
|
+
import string
|
|
374
|
+
|
|
375
|
+
formatter = string.Formatter()
|
|
376
|
+
keys: list[str] = []
|
|
377
|
+
for _, field_name, _, _ in formatter.parse(template):
|
|
378
|
+
if field_name is not None:
|
|
379
|
+
keys.append(field_name)
|
|
380
|
+
return keys
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Validation data structures for parameter checking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class ValidationIssue:
|
|
11
|
+
"""A single validation finding for a parameter set.
|
|
12
|
+
|
|
13
|
+
Attributes:
|
|
14
|
+
severity: ``"error"`` (run will likely fail) or ``"warning"``
|
|
15
|
+
(suboptimal but may work).
|
|
16
|
+
message: Human-readable explanation.
|
|
17
|
+
parameter: Dot-notation path to the offending parameter
|
|
18
|
+
(empty if the issue involves multiple parameters).
|
|
19
|
+
constraint_name: Machine-readable constraint identifier
|
|
20
|
+
(e.g. ``"cfl_condition"``, ``"debye_resolution"``).
|
|
21
|
+
details: Numeric context for programmatic consumers and
|
|
22
|
+
AI agents (e.g. computed value, threshold, formula).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
severity: str
|
|
26
|
+
message: str
|
|
27
|
+
parameter: str = ""
|
|
28
|
+
constraint_name: str = ""
|
|
29
|
+
details: dict[str, Any] = field(default_factory=dict)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Valid insight types for .runops/insights/ files
|
|
33
|
+
INSIGHT_TYPES = frozenset(
|
|
34
|
+
{
|
|
35
|
+
"constraint", # Stability/constraint findings (learned from failures)
|
|
36
|
+
"result", # Experiment result summaries
|
|
37
|
+
"analysis", # Physical interpretation / discussion
|
|
38
|
+
"dependency", # Parameter dependency trends
|
|
39
|
+
}
|
|
40
|
+
)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Agent harness helpers."""
|
|
2
|
+
|
|
3
|
+
from runops.harness.builder import (
|
|
4
|
+
HARNESS_LOCK_PATH,
|
|
5
|
+
HarnessBundle,
|
|
6
|
+
build_harness_bundle,
|
|
7
|
+
hash_file,
|
|
8
|
+
hash_text,
|
|
9
|
+
is_harness_path,
|
|
10
|
+
load_harness_lock,
|
|
11
|
+
read_upstream_feedback_setting,
|
|
12
|
+
save_harness_lock,
|
|
13
|
+
)
|
|
14
|
+
from runops.harness.claude import build_claude_settings
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"HARNESS_LOCK_PATH",
|
|
18
|
+
"HarnessBundle",
|
|
19
|
+
"build_claude_settings",
|
|
20
|
+
"build_harness_bundle",
|
|
21
|
+
"hash_file",
|
|
22
|
+
"hash_text",
|
|
23
|
+
"is_harness_path",
|
|
24
|
+
"load_harness_lock",
|
|
25
|
+
"read_upstream_feedback_setting",
|
|
26
|
+
"save_harness_lock",
|
|
27
|
+
]
|