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/knowledge.py
ADDED
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
"""Knowledge layer: local insights and structured facts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
if sys.version_info >= (3, 11):
|
|
14
|
+
import tomllib
|
|
15
|
+
else:
|
|
16
|
+
import tomli as tomllib
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
import tomli_w
|
|
20
|
+
except ImportError:
|
|
21
|
+
tomli_w = None # type: ignore[assignment]
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
_SIMCTL_DIR = ".runops"
|
|
26
|
+
_INSIGHTS_DIR = "insights"
|
|
27
|
+
_KNOWLEDGE_DIR = "knowledge"
|
|
28
|
+
_CANDIDATE_FACTS_DIR = "candidates/facts"
|
|
29
|
+
|
|
30
|
+
# Valid insight types
|
|
31
|
+
INSIGHT_TYPES = frozenset(
|
|
32
|
+
{
|
|
33
|
+
"constraint", # Stability/constraint findings
|
|
34
|
+
"result", # Experiment result summaries
|
|
35
|
+
"analysis", # Physical interpretation / discussion
|
|
36
|
+
"dependency", # Parameter dependency trends
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Valid fact types
|
|
41
|
+
FACT_TYPES = frozenset(
|
|
42
|
+
{
|
|
43
|
+
"observation", # Directly observed from run output
|
|
44
|
+
"constraint", # Stability / CFL / resolution constraint
|
|
45
|
+
"dependency", # Parameter dependency relationship
|
|
46
|
+
"policy", # Operational rule (e.g. "always use dt < X")
|
|
47
|
+
"hypothesis", # Unverified conjecture
|
|
48
|
+
}
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class Insight:
|
|
54
|
+
"""A single knowledge insight.
|
|
55
|
+
|
|
56
|
+
Attributes:
|
|
57
|
+
name: Filename stem (e.g. ``"emses_cfl_limit"``).
|
|
58
|
+
type: One of :data:`INSIGHT_TYPES`.
|
|
59
|
+
simulator: Simulator name this insight applies to.
|
|
60
|
+
tags: Searchable tags.
|
|
61
|
+
source_project: Project where this insight originated.
|
|
62
|
+
created: ISO-format creation timestamp.
|
|
63
|
+
content: Markdown body of the insight.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
name: str
|
|
67
|
+
type: str
|
|
68
|
+
simulator: str
|
|
69
|
+
tags: list[str] = field(default_factory=list)
|
|
70
|
+
source_project: str = ""
|
|
71
|
+
created: str = ""
|
|
72
|
+
content: str = ""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def get_runops_dir(project_root: Path) -> Path:
|
|
76
|
+
"""Return the .runops directory for a project, creating if needed."""
|
|
77
|
+
d = project_root / _SIMCTL_DIR
|
|
78
|
+
d.mkdir(exist_ok=True)
|
|
79
|
+
return d
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def get_insights_dir(project_root: Path) -> Path:
|
|
83
|
+
"""Return the .runops/insights directory, creating if needed."""
|
|
84
|
+
d = get_runops_dir(project_root) / _INSIGHTS_DIR
|
|
85
|
+
d.mkdir(exist_ok=True)
|
|
86
|
+
return d
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def get_knowledge_dir(project_root: Path) -> Path:
|
|
90
|
+
"""Return the .runops/knowledge directory, creating if needed."""
|
|
91
|
+
d = get_runops_dir(project_root) / _KNOWLEDGE_DIR
|
|
92
|
+
d.mkdir(exist_ok=True)
|
|
93
|
+
return d
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def get_candidate_facts_dir(project_root: Path) -> Path:
|
|
97
|
+
"""Return the candidate fact transport directory, creating if needed."""
|
|
98
|
+
d = get_knowledge_dir(project_root) / _CANDIDATE_FACTS_DIR
|
|
99
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
return d
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ---------- Insight I/O ----------
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def parse_insight(path: Path) -> Insight | None:
|
|
107
|
+
"""Parse an insight markdown file with YAML-like frontmatter.
|
|
108
|
+
|
|
109
|
+
The frontmatter is delimited by ``---`` lines and contains
|
|
110
|
+
key-value pairs.
|
|
111
|
+
"""
|
|
112
|
+
try:
|
|
113
|
+
text = path.read_text()
|
|
114
|
+
except OSError:
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
lines = text.split("\n")
|
|
118
|
+
if not lines or lines[0].strip() != "---":
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
# Find closing ---
|
|
122
|
+
end_idx = -1
|
|
123
|
+
for i in range(1, len(lines)):
|
|
124
|
+
if lines[i].strip() == "---":
|
|
125
|
+
end_idx = i
|
|
126
|
+
break
|
|
127
|
+
if end_idx < 0:
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
# Parse frontmatter
|
|
131
|
+
meta: dict[str, Any] = {}
|
|
132
|
+
for line in lines[1:end_idx]:
|
|
133
|
+
if ":" not in line:
|
|
134
|
+
continue
|
|
135
|
+
key, _, raw_value = line.partition(":")
|
|
136
|
+
key = key.strip()
|
|
137
|
+
stripped = raw_value.strip()
|
|
138
|
+
# Handle list values: [a, b, c]
|
|
139
|
+
if stripped.startswith("[") and stripped.endswith("]"):
|
|
140
|
+
meta[key] = [
|
|
141
|
+
v.strip().strip("\"'") for v in stripped[1:-1].split(",") if v.strip()
|
|
142
|
+
]
|
|
143
|
+
else:
|
|
144
|
+
meta[key] = stripped
|
|
145
|
+
|
|
146
|
+
content = "\n".join(lines[end_idx + 1 :]).strip()
|
|
147
|
+
|
|
148
|
+
tags = meta.get("tags", [])
|
|
149
|
+
if isinstance(tags, str):
|
|
150
|
+
tags = [tags]
|
|
151
|
+
|
|
152
|
+
return Insight(
|
|
153
|
+
name=path.stem,
|
|
154
|
+
type=meta.get("type", "result"),
|
|
155
|
+
simulator=meta.get("simulator", ""),
|
|
156
|
+
tags=tags,
|
|
157
|
+
source_project=meta.get("source_project", ""),
|
|
158
|
+
created=meta.get("created", ""),
|
|
159
|
+
content=content,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def write_insight(insights_dir: Path, insight: Insight) -> Path:
|
|
164
|
+
"""Write an insight to a markdown file.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
Path to the written file.
|
|
168
|
+
"""
|
|
169
|
+
tags_str = ", ".join(insight.tags) if insight.tags else ""
|
|
170
|
+
created = insight.created or datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
171
|
+
|
|
172
|
+
frontmatter = [
|
|
173
|
+
"---",
|
|
174
|
+
f"type: {insight.type}",
|
|
175
|
+
f"simulator: {insight.simulator}",
|
|
176
|
+
]
|
|
177
|
+
if tags_str:
|
|
178
|
+
frontmatter.append(f"tags: [{tags_str}]")
|
|
179
|
+
if insight.source_project:
|
|
180
|
+
frontmatter.append(f"source_project: {insight.source_project}")
|
|
181
|
+
frontmatter.append(f"created: {created}")
|
|
182
|
+
frontmatter.append("---")
|
|
183
|
+
|
|
184
|
+
text = "\n".join(frontmatter) + "\n\n" + insight.content + "\n"
|
|
185
|
+
filepath = insights_dir / f"{insight.name}.md"
|
|
186
|
+
filepath.write_text(text)
|
|
187
|
+
return filepath
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def list_insights(
|
|
191
|
+
project_root: Path,
|
|
192
|
+
*,
|
|
193
|
+
simulator: str = "",
|
|
194
|
+
insight_type: str = "",
|
|
195
|
+
tag: str = "",
|
|
196
|
+
) -> list[Insight]:
|
|
197
|
+
"""List insights, optionally filtered."""
|
|
198
|
+
insights_dir = project_root / _SIMCTL_DIR / _INSIGHTS_DIR
|
|
199
|
+
if not insights_dir.is_dir():
|
|
200
|
+
return []
|
|
201
|
+
|
|
202
|
+
results: list[Insight] = []
|
|
203
|
+
for md_file in sorted(insights_dir.glob("*.md")):
|
|
204
|
+
insight = parse_insight(md_file)
|
|
205
|
+
if insight is None:
|
|
206
|
+
continue
|
|
207
|
+
if simulator and insight.simulator != simulator:
|
|
208
|
+
continue
|
|
209
|
+
if insight_type and insight.type != insight_type:
|
|
210
|
+
continue
|
|
211
|
+
if tag and tag not in insight.tags:
|
|
212
|
+
continue
|
|
213
|
+
results.append(insight)
|
|
214
|
+
return results
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ---------- Structured Facts ----------
|
|
218
|
+
|
|
219
|
+
_FACTS_FILE = "facts.toml"
|
|
220
|
+
_FACT_ID_RE = re.compile(r"f(\d+)")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@dataclass
|
|
224
|
+
class Fact:
|
|
225
|
+
"""A structured, machine-readable knowledge claim.
|
|
226
|
+
|
|
227
|
+
Unlike Insight (free-form markdown), a Fact is designed for
|
|
228
|
+
programmatic consumption by AI agents and validation tools.
|
|
229
|
+
|
|
230
|
+
Attributes:
|
|
231
|
+
id: Unique fact identifier (e.g. ``"f001"``).
|
|
232
|
+
claim: The knowledge claim (one sentence).
|
|
233
|
+
fact_type: One of :data:`FACT_TYPES`
|
|
234
|
+
(``"observation"``, ``"constraint"``, ``"dependency"``,
|
|
235
|
+
``"policy"``, ``"hypothesis"``).
|
|
236
|
+
simulator: Simulator this fact applies to (empty = general).
|
|
237
|
+
scope_case: Case pattern this applies to (e.g. ``"mag_scan"``).
|
|
238
|
+
scope_text: Free-text scope description for humans.
|
|
239
|
+
param_name: Parameter name this fact is about (dot-notation).
|
|
240
|
+
confidence: ``"high"``, ``"medium"``, or ``"low"``.
|
|
241
|
+
source_run: Run ID that produced this evidence (if any).
|
|
242
|
+
source_project: Project where the fact was established.
|
|
243
|
+
evidence_kind: Type of evidence (``"run_observation"``,
|
|
244
|
+
``"calculation"``, ``"literature"``, ``"heuristic"``).
|
|
245
|
+
evidence_ref: Reference to evidence source
|
|
246
|
+
(e.g. ``"run:R20260330-0004"``).
|
|
247
|
+
created_at: ISO-format timestamp.
|
|
248
|
+
tags: Searchable tags.
|
|
249
|
+
supersedes: ID of the fact this one replaces (if any).
|
|
250
|
+
storage: ``"local"`` or ``"candidate"``.
|
|
251
|
+
transport_source: Candidate source identifier for imported facts.
|
|
252
|
+
transport_kind: Candidate source kind (``project`` / ``insights``).
|
|
253
|
+
transport_path: Source path used during transport.
|
|
254
|
+
upstream_id: Original fact ID before local namespacing.
|
|
255
|
+
"""
|
|
256
|
+
|
|
257
|
+
id: str
|
|
258
|
+
claim: str
|
|
259
|
+
fact_type: str = "observation"
|
|
260
|
+
simulator: str = ""
|
|
261
|
+
scope_case: str = ""
|
|
262
|
+
scope_text: str = ""
|
|
263
|
+
param_name: str = ""
|
|
264
|
+
confidence: str = "medium"
|
|
265
|
+
source_run: str = ""
|
|
266
|
+
source_project: str = ""
|
|
267
|
+
evidence_kind: str = ""
|
|
268
|
+
evidence_ref: str = ""
|
|
269
|
+
created_at: str = ""
|
|
270
|
+
tags: list[str] = field(default_factory=list)
|
|
271
|
+
supersedes: str = ""
|
|
272
|
+
storage: str = "local"
|
|
273
|
+
transport_source: str = ""
|
|
274
|
+
transport_kind: str = ""
|
|
275
|
+
transport_path: str = ""
|
|
276
|
+
upstream_id: str = ""
|
|
277
|
+
|
|
278
|
+
# Kept for backward compatibility with old facts.toml files
|
|
279
|
+
# that use "scope" and "evidence" as flat strings.
|
|
280
|
+
@property
|
|
281
|
+
def scope(self) -> str:
|
|
282
|
+
"""Backward-compatible scope string."""
|
|
283
|
+
parts = []
|
|
284
|
+
if self.simulator:
|
|
285
|
+
parts.append(self.simulator)
|
|
286
|
+
if self.scope_case:
|
|
287
|
+
parts.append(self.scope_case)
|
|
288
|
+
if self.scope_text:
|
|
289
|
+
parts.append(self.scope_text)
|
|
290
|
+
return ", ".join(parts) if parts else ""
|
|
291
|
+
|
|
292
|
+
@property
|
|
293
|
+
def evidence(self) -> str:
|
|
294
|
+
"""Backward-compatible evidence string."""
|
|
295
|
+
parts = []
|
|
296
|
+
if self.evidence_kind:
|
|
297
|
+
parts.append(self.evidence_kind)
|
|
298
|
+
if self.evidence_ref:
|
|
299
|
+
parts.append(self.evidence_ref)
|
|
300
|
+
return ": ".join(parts) if parts else ""
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _load_facts_document(path: Path) -> dict[str, Any]:
|
|
304
|
+
with open(path, "rb") as f:
|
|
305
|
+
raw = tomllib.load(f)
|
|
306
|
+
if not isinstance(raw, dict):
|
|
307
|
+
msg = f"Invalid facts document: {path}"
|
|
308
|
+
raise RuntimeError(msg)
|
|
309
|
+
return raw
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _coerce_fact_entry(
|
|
313
|
+
d: dict[str, Any],
|
|
314
|
+
*,
|
|
315
|
+
storage: str,
|
|
316
|
+
transport_source: str,
|
|
317
|
+
transport_kind: str,
|
|
318
|
+
transport_path: str,
|
|
319
|
+
) -> Fact:
|
|
320
|
+
raw_id = str(d.get("id", "")).strip()
|
|
321
|
+
fact_id = raw_id
|
|
322
|
+
upstream_id = ""
|
|
323
|
+
if storage != "local" and transport_source:
|
|
324
|
+
fact_id = f"{transport_source}:{raw_id}" if raw_id else transport_source
|
|
325
|
+
upstream_id = raw_id
|
|
326
|
+
|
|
327
|
+
# Migrate legacy "scope" string -> scope_text
|
|
328
|
+
scope_case = d.get("scope_case", "")
|
|
329
|
+
scope_text = d.get("scope_text", "")
|
|
330
|
+
if not scope_case and not scope_text:
|
|
331
|
+
legacy_scope = d.get("scope", "")
|
|
332
|
+
if legacy_scope:
|
|
333
|
+
scope_text = legacy_scope
|
|
334
|
+
|
|
335
|
+
# Migrate legacy "evidence" string -> evidence_kind
|
|
336
|
+
evidence_kind = d.get("evidence_kind", "")
|
|
337
|
+
evidence_ref = d.get("evidence_ref", "")
|
|
338
|
+
if not evidence_kind and not evidence_ref:
|
|
339
|
+
legacy_evidence = d.get("evidence", "")
|
|
340
|
+
if legacy_evidence:
|
|
341
|
+
evidence_kind = legacy_evidence
|
|
342
|
+
|
|
343
|
+
supersedes = str(d.get("supersedes", "")).strip()
|
|
344
|
+
if supersedes and storage != "local" and transport_source:
|
|
345
|
+
supersedes = f"{transport_source}:{supersedes}"
|
|
346
|
+
|
|
347
|
+
return Fact(
|
|
348
|
+
id=fact_id,
|
|
349
|
+
claim=d.get("claim", ""),
|
|
350
|
+
fact_type=d.get("fact_type", "observation"),
|
|
351
|
+
simulator=d.get("simulator", ""),
|
|
352
|
+
scope_case=scope_case,
|
|
353
|
+
scope_text=scope_text,
|
|
354
|
+
param_name=d.get("param_name", ""),
|
|
355
|
+
confidence=d.get("confidence", "medium"),
|
|
356
|
+
source_run=d.get("source_run", ""),
|
|
357
|
+
source_project=d.get("source_project", ""),
|
|
358
|
+
evidence_kind=evidence_kind,
|
|
359
|
+
evidence_ref=evidence_ref,
|
|
360
|
+
created_at=d.get("created_at", ""),
|
|
361
|
+
tags=list(d.get("tags", [])),
|
|
362
|
+
supersedes=supersedes,
|
|
363
|
+
storage=storage,
|
|
364
|
+
transport_source=transport_source,
|
|
365
|
+
transport_kind=transport_kind,
|
|
366
|
+
transport_path=transport_path,
|
|
367
|
+
upstream_id=upstream_id,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def load_facts_file(
|
|
372
|
+
path: Path,
|
|
373
|
+
*,
|
|
374
|
+
storage: str = "local",
|
|
375
|
+
transport_source: str = "",
|
|
376
|
+
transport_kind: str = "",
|
|
377
|
+
transport_path: str = "",
|
|
378
|
+
) -> list[Fact]:
|
|
379
|
+
"""Load facts from an arbitrary facts TOML document.
|
|
380
|
+
|
|
381
|
+
Handles both the new structured schema (with ``fact_type``,
|
|
382
|
+
``simulator``, ``scope_case``, etc.) and the legacy flat schema
|
|
383
|
+
(with ``scope`` and ``evidence`` as plain strings).
|
|
384
|
+
"""
|
|
385
|
+
raw = _load_facts_document(path)
|
|
386
|
+
transport = raw.get("transport", {})
|
|
387
|
+
resolved_source = transport_source or str(transport.get("source", "")).strip()
|
|
388
|
+
resolved_kind = transport_kind or str(transport.get("kind", "")).strip()
|
|
389
|
+
resolved_path = transport_path or str(transport.get("source_path", "")).strip()
|
|
390
|
+
|
|
391
|
+
facts: list[Fact] = []
|
|
392
|
+
for d in raw.get("facts", []):
|
|
393
|
+
if not isinstance(d, dict):
|
|
394
|
+
continue
|
|
395
|
+
facts.append(
|
|
396
|
+
_coerce_fact_entry(
|
|
397
|
+
d,
|
|
398
|
+
storage=storage,
|
|
399
|
+
transport_source=resolved_source,
|
|
400
|
+
transport_kind=resolved_kind,
|
|
401
|
+
transport_path=resolved_path,
|
|
402
|
+
)
|
|
403
|
+
)
|
|
404
|
+
return facts
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def load_facts(project_root: Path) -> list[Fact]:
|
|
408
|
+
"""Load structured facts from .runops/facts.toml."""
|
|
409
|
+
facts_file = project_root / _SIMCTL_DIR / _FACTS_FILE
|
|
410
|
+
if not facts_file.is_file():
|
|
411
|
+
return []
|
|
412
|
+
return load_facts_file(facts_file)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def load_candidate_facts(project_root: Path) -> list[Fact]:
|
|
416
|
+
"""Load imported candidate facts from .runops/knowledge/candidates/facts/."""
|
|
417
|
+
facts_dir = project_root / _SIMCTL_DIR / _KNOWLEDGE_DIR / _CANDIDATE_FACTS_DIR
|
|
418
|
+
if not facts_dir.is_dir():
|
|
419
|
+
return []
|
|
420
|
+
|
|
421
|
+
facts: list[Fact] = []
|
|
422
|
+
for facts_file in sorted(facts_dir.glob("*.toml")):
|
|
423
|
+
facts.extend(load_facts_file(facts_file, storage="candidate"))
|
|
424
|
+
return facts
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def save_fact(project_root: Path, fact: Fact) -> None:
|
|
428
|
+
"""Append a structured fact to .runops/facts.toml.
|
|
429
|
+
|
|
430
|
+
Uses the new structured schema. Facts are append-only by convention;
|
|
431
|
+
to supersede an existing fact, create a new one with ``supersedes``
|
|
432
|
+
set to the old fact's ID.
|
|
433
|
+
"""
|
|
434
|
+
if tomli_w is None:
|
|
435
|
+
msg = "tomli_w is required to write facts.toml"
|
|
436
|
+
raise RuntimeError(msg)
|
|
437
|
+
|
|
438
|
+
runops_dir = project_root / _SIMCTL_DIR
|
|
439
|
+
runops_dir.mkdir(exist_ok=True)
|
|
440
|
+
facts_file = runops_dir / _FACTS_FILE
|
|
441
|
+
|
|
442
|
+
# Load existing
|
|
443
|
+
existing: list[dict[str, Any]] = []
|
|
444
|
+
if facts_file.is_file():
|
|
445
|
+
with open(facts_file, "rb") as f:
|
|
446
|
+
raw = tomllib.load(f)
|
|
447
|
+
existing = list(raw.get("facts", []))
|
|
448
|
+
|
|
449
|
+
# Build new entry (structured schema)
|
|
450
|
+
entry: dict[str, Any] = {
|
|
451
|
+
"id": fact.id,
|
|
452
|
+
"claim": fact.claim,
|
|
453
|
+
"fact_type": fact.fact_type,
|
|
454
|
+
}
|
|
455
|
+
if fact.simulator:
|
|
456
|
+
entry["simulator"] = fact.simulator
|
|
457
|
+
if fact.scope_case:
|
|
458
|
+
entry["scope_case"] = fact.scope_case
|
|
459
|
+
if fact.scope_text:
|
|
460
|
+
entry["scope_text"] = fact.scope_text
|
|
461
|
+
if fact.param_name:
|
|
462
|
+
entry["param_name"] = fact.param_name
|
|
463
|
+
entry["confidence"] = fact.confidence
|
|
464
|
+
if fact.source_run:
|
|
465
|
+
entry["source_run"] = fact.source_run
|
|
466
|
+
if fact.source_project:
|
|
467
|
+
entry["source_project"] = fact.source_project
|
|
468
|
+
if fact.evidence_kind:
|
|
469
|
+
entry["evidence_kind"] = fact.evidence_kind
|
|
470
|
+
if fact.evidence_ref:
|
|
471
|
+
entry["evidence_ref"] = fact.evidence_ref
|
|
472
|
+
entry["created_at"] = fact.created_at or datetime.now(timezone.utc).isoformat(
|
|
473
|
+
timespec="seconds"
|
|
474
|
+
)
|
|
475
|
+
if fact.tags:
|
|
476
|
+
entry["tags"] = fact.tags
|
|
477
|
+
if fact.supersedes:
|
|
478
|
+
entry["supersedes"] = fact.supersedes
|
|
479
|
+
|
|
480
|
+
existing.append(entry)
|
|
481
|
+
|
|
482
|
+
with open(facts_file, "wb") as f:
|
|
483
|
+
tomli_w.dump({"facts": existing}, f)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def next_fact_id(project_root: Path) -> str:
|
|
487
|
+
"""Return the next sequential fact ID.
|
|
488
|
+
|
|
489
|
+
Fact IDs follow the ``fNNN`` convention used throughout the docs.
|
|
490
|
+
Non-matching IDs are ignored when computing the next value.
|
|
491
|
+
"""
|
|
492
|
+
max_num = 0
|
|
493
|
+
for fact in load_facts(project_root):
|
|
494
|
+
match = _FACT_ID_RE.fullmatch(fact.id)
|
|
495
|
+
if match is None:
|
|
496
|
+
continue
|
|
497
|
+
max_num = max(max_num, int(match.group(1)))
|
|
498
|
+
return f"f{max_num + 1:03d}"
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def promote_candidate_fact(project_root: Path, fact_id: str) -> Fact:
|
|
502
|
+
"""Copy one imported candidate fact into the local curated facts store."""
|
|
503
|
+
source_fact = next(
|
|
504
|
+
(fact for fact in load_candidate_facts(project_root) if fact.id == fact_id),
|
|
505
|
+
None,
|
|
506
|
+
)
|
|
507
|
+
if source_fact is None:
|
|
508
|
+
msg = f"Candidate fact not found: {fact_id}"
|
|
509
|
+
raise LookupError(msg)
|
|
510
|
+
|
|
511
|
+
promoted = Fact(
|
|
512
|
+
id=next_fact_id(project_root),
|
|
513
|
+
claim=source_fact.claim,
|
|
514
|
+
fact_type=source_fact.fact_type,
|
|
515
|
+
simulator=source_fact.simulator,
|
|
516
|
+
scope_case=source_fact.scope_case,
|
|
517
|
+
scope_text=source_fact.scope_text,
|
|
518
|
+
param_name=source_fact.param_name,
|
|
519
|
+
confidence=source_fact.confidence,
|
|
520
|
+
source_run=source_fact.source_run,
|
|
521
|
+
source_project=source_fact.source_project or source_fact.transport_source,
|
|
522
|
+
evidence_kind=source_fact.evidence_kind
|
|
523
|
+
or ("shared_fact" if source_fact.transport_source else ""),
|
|
524
|
+
evidence_ref=source_fact.evidence_ref
|
|
525
|
+
or (
|
|
526
|
+
f"fact:{source_fact.transport_source}:{source_fact.upstream_id}"
|
|
527
|
+
if source_fact.transport_source and source_fact.upstream_id
|
|
528
|
+
else ""
|
|
529
|
+
),
|
|
530
|
+
created_at=source_fact.created_at,
|
|
531
|
+
tags=list(source_fact.tags),
|
|
532
|
+
)
|
|
533
|
+
save_fact(project_root, promoted)
|
|
534
|
+
return promoted
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def query_facts(
|
|
538
|
+
project_root: Path,
|
|
539
|
+
*,
|
|
540
|
+
scope: str = "",
|
|
541
|
+
tag: str = "",
|
|
542
|
+
min_confidence: str = "",
|
|
543
|
+
simulator: str = "",
|
|
544
|
+
fact_type: str = "",
|
|
545
|
+
param_name: str = "",
|
|
546
|
+
exclude_superseded: bool = True,
|
|
547
|
+
include_candidates: bool = False,
|
|
548
|
+
) -> list[Fact]:
|
|
549
|
+
"""Query facts with optional filters.
|
|
550
|
+
|
|
551
|
+
Args:
|
|
552
|
+
project_root: Project root directory.
|
|
553
|
+
scope: Free-text scope substring match (searches scope property).
|
|
554
|
+
tag: Must appear in tags list.
|
|
555
|
+
min_confidence: Minimum confidence level.
|
|
556
|
+
simulator: Must match simulator field exactly.
|
|
557
|
+
fact_type: Must match fact_type field exactly.
|
|
558
|
+
param_name: Must match param_name field exactly.
|
|
559
|
+
exclude_superseded: If True, exclude facts that have been
|
|
560
|
+
superseded by newer facts.
|
|
561
|
+
include_candidates: If True, include imported candidate facts
|
|
562
|
+
from external knowledge transport in addition to local facts.
|
|
563
|
+
"""
|
|
564
|
+
confidence_order = {"high": 3, "medium": 2, "low": 1}
|
|
565
|
+
min_level = confidence_order.get(min_confidence, 0)
|
|
566
|
+
|
|
567
|
+
facts = list(load_facts(project_root))
|
|
568
|
+
if include_candidates:
|
|
569
|
+
facts.extend(load_candidate_facts(project_root))
|
|
570
|
+
|
|
571
|
+
# Build set of superseded IDs
|
|
572
|
+
superseded_ids: set[str] = set()
|
|
573
|
+
if exclude_superseded:
|
|
574
|
+
for f in facts:
|
|
575
|
+
if f.supersedes:
|
|
576
|
+
superseded_ids.add(f.supersedes)
|
|
577
|
+
|
|
578
|
+
results: list[Fact] = []
|
|
579
|
+
for f in facts:
|
|
580
|
+
if exclude_superseded and f.id in superseded_ids:
|
|
581
|
+
continue
|
|
582
|
+
if scope and scope not in f.scope:
|
|
583
|
+
continue
|
|
584
|
+
if tag and tag not in f.tags:
|
|
585
|
+
continue
|
|
586
|
+
if min_level and confidence_order.get(f.confidence, 0) < min_level:
|
|
587
|
+
continue
|
|
588
|
+
if simulator and f.simulator != simulator:
|
|
589
|
+
continue
|
|
590
|
+
if fact_type and f.fact_type != fact_type:
|
|
591
|
+
continue
|
|
592
|
+
if param_name and f.param_name != param_name:
|
|
593
|
+
continue
|
|
594
|
+
results.append(f)
|
|
595
|
+
return results
|