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.
Files changed (115) hide show
  1. runops/__init__.py +5 -0
  2. runops/_data/README.md +476 -0
  3. runops/adapters/__init__.py +29 -0
  4. runops/adapters/_utils/__init__.py +36 -0
  5. runops/adapters/_utils/toml_utils.py +81 -0
  6. runops/adapters/base.py +335 -0
  7. runops/adapters/contrib/__init__.py +5 -0
  8. runops/adapters/contrib/beach.py +837 -0
  9. runops/adapters/contrib/emses.py +1010 -0
  10. runops/adapters/generic.py +439 -0
  11. runops/adapters/registry.py +244 -0
  12. runops/cli/__init__.py +3 -0
  13. runops/cli/analyze.py +222 -0
  14. runops/cli/clone.py +104 -0
  15. runops/cli/config.py +217 -0
  16. runops/cli/context.py +56 -0
  17. runops/cli/create.py +263 -0
  18. runops/cli/dashboard.py +179 -0
  19. runops/cli/extend.py +204 -0
  20. runops/cli/history.py +105 -0
  21. runops/cli/init.py +1432 -0
  22. runops/cli/jobs.py +145 -0
  23. runops/cli/knowledge.py +1017 -0
  24. runops/cli/list.py +102 -0
  25. runops/cli/log.py +163 -0
  26. runops/cli/main.py +96 -0
  27. runops/cli/manage.py +231 -0
  28. runops/cli/new.py +343 -0
  29. runops/cli/notes.py +257 -0
  30. runops/cli/run_lookup.py +148 -0
  31. runops/cli/setup.py +174 -0
  32. runops/cli/status.py +187 -0
  33. runops/cli/submit.py +297 -0
  34. runops/cli/update.py +113 -0
  35. runops/cli/update_harness.py +245 -0
  36. runops/cli/update_refs.py +370 -0
  37. runops/core/__init__.py +3 -0
  38. runops/core/actions.py +1186 -0
  39. runops/core/analysis.py +1090 -0
  40. runops/core/campaign.py +156 -0
  41. runops/core/case.py +307 -0
  42. runops/core/context.py +426 -0
  43. runops/core/discovery.py +192 -0
  44. runops/core/environment.py +266 -0
  45. runops/core/exceptions.py +93 -0
  46. runops/core/knowledge.py +595 -0
  47. runops/core/knowledge_source.py +1204 -0
  48. runops/core/manifest.py +219 -0
  49. runops/core/project.py +171 -0
  50. runops/core/provenance.py +147 -0
  51. runops/core/retry.py +193 -0
  52. runops/core/run.py +170 -0
  53. runops/core/run_creation.py +456 -0
  54. runops/core/site.py +337 -0
  55. runops/core/state.py +197 -0
  56. runops/core/survey.py +380 -0
  57. runops/core/validation.py +40 -0
  58. runops/harness/__init__.py +27 -0
  59. runops/harness/builder.py +327 -0
  60. runops/harness/claude.py +189 -0
  61. runops/jobgen/__init__.py +3 -0
  62. runops/jobgen/generator.py +295 -0
  63. runops/launchers/__init__.py +17 -0
  64. runops/launchers/base.py +313 -0
  65. runops/launchers/mpiexec.py +131 -0
  66. runops/launchers/mpirun.py +132 -0
  67. runops/launchers/srun.py +126 -0
  68. runops/sites/__init__.py +0 -0
  69. runops/sites/camphor.md +98 -0
  70. runops/sites/camphor.toml +27 -0
  71. runops/slurm/__init__.py +3 -0
  72. runops/slurm/query.py +384 -0
  73. runops/slurm/submit.py +203 -0
  74. runops/templates/__init__.py +29 -0
  75. runops/templates/adapters/beach/agent_guide.md +50 -0
  76. runops/templates/adapters/beach/beach.toml +19 -0
  77. runops/templates/adapters/beach/case.toml +16 -0
  78. runops/templates/adapters/beach/summarize.py +272 -0
  79. runops/templates/adapters/emses/agent_guide.md +39 -0
  80. runops/templates/adapters/emses/case.toml +18 -0
  81. runops/templates/adapters/emses/plasma.toml +118 -0
  82. runops/templates/adapters/emses/summarize.py +413 -0
  83. runops/templates/adapters/generic/case.toml.j2 +13 -0
  84. runops/templates/adapters/generic/summarize.py +21 -0
  85. runops/templates/agent.md +156 -0
  86. runops/templates/rules/cookbook.md +22 -0
  87. runops/templates/scaffold/campaign.toml.j2 +10 -0
  88. runops/templates/scaffold/cases_claude.md +22 -0
  89. runops/templates/scaffold/facts.toml +2 -0
  90. runops/templates/scaffold/gitignore.txt +30 -0
  91. runops/templates/scaffold/notes/README.md +69 -0
  92. runops/templates/scaffold/rules/plan-before-act.md +17 -0
  93. runops/templates/scaffold/rules/runops-workflow.md +84 -0
  94. runops/templates/scaffold/rules/upstream-feedback.md +85 -0
  95. runops/templates/scaffold/runs_claude.md +24 -0
  96. runops/templates/scaffold/vscode_settings.json +9 -0
  97. runops/templates/skills/analyze/SKILL.md +40 -0
  98. runops/templates/skills/check-status/SKILL.md +29 -0
  99. runops/templates/skills/cleanup/SKILL.md +43 -0
  100. runops/templates/skills/create-run/SKILL.md +135 -0
  101. runops/templates/skills/debug-failed/SKILL.md +38 -0
  102. runops/templates/skills/learn/SKILL.md +54 -0
  103. runops/templates/skills/new-case/SKILL.md +108 -0
  104. runops/templates/skills/note/SKILL.md +107 -0
  105. runops/templates/skills/run-all/SKILL.md +47 -0
  106. runops/templates/skills/runops-reference/SKILL.md +203 -0
  107. runops/templates/skills/setup-campaign/SKILL.md +111 -0
  108. runops/templates/skills/setup-env/SKILL.md +32 -0
  109. runops/templates/skills/survey-design/SKILL.md +73 -0
  110. runops/templates/survey.toml.j2 +22 -0
  111. runops-0.2.0.dist-info/METADATA +491 -0
  112. runops-0.2.0.dist-info/RECORD +115 -0
  113. runops-0.2.0.dist-info/WHEEL +4 -0
  114. runops-0.2.0.dist-info/entry_points.txt +2 -0
  115. 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
+ ]