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
@@ -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