robert-ai 0.1.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.
- robert/__init__.py +13 -0
- robert/adapters.py +97 -0
- robert/checks.py +133 -0
- robert/cli.py +108 -0
- robert/contracts.py +108 -0
- robert/deps.py +245 -0
- robert/engine.py +159 -0
- robert/graph.py +168 -0
- robert/loop.py +675 -0
- robert/mcp_server.py +114 -0
- robert/outputs.py +114 -0
- robert/project.py +167 -0
- robert/scaffold.py +98 -0
- robert/setup.py +69 -0
- robert/stochadex.json +19 -0
- robert/support.py +196 -0
- robert/tools.py +186 -0
- robert_ai-0.1.0.dist-info/METADATA +213 -0
- robert_ai-0.1.0.dist-info/RECORD +23 -0
- robert_ai-0.1.0.dist-info/WHEEL +5 -0
- robert_ai-0.1.0.dist-info/entry_points.txt +2 -0
- robert_ai-0.1.0.dist-info/licenses/LICENSE +21 -0
- robert_ai-0.1.0.dist-info/top_level.txt +1 -0
robert/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""robert: the AI simulation scientist.
|
|
2
|
+
|
|
3
|
+
A thin method layer over the stochadex engine. It owns the method files and the
|
|
4
|
+
gates of the discovery-and-warrant loop, and delegates every simulation action to
|
|
5
|
+
the stochadex binary. The same tool registry drives the CLI and the MCP server, so
|
|
6
|
+
a chat-driven project and a hand-driven one leave the same trail in the repo.
|
|
7
|
+
|
|
8
|
+
The version below is the single source: the package metadata, the plugin manifest
|
|
9
|
+
and the skills version every project records all read it. The stochadex version
|
|
10
|
+
robert supports lives in `stochadex.json`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
__version__ = "0.1.0"
|
robert/adapters.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Render per-agent adapters from the one source of method content in skills/.
|
|
2
|
+
|
|
3
|
+
Method content has one source. These thin adapters only change its packaging: a
|
|
4
|
+
Claude Code plugin, Cursor rules plus MCP config, and an AGENTS.md for anything
|
|
5
|
+
else. Correctness never lives here; it lives in the MCP tools every adapter calls.
|
|
6
|
+
`tests/test_adapters.py` fails when a committed adapter drifts from its source.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from . import __version__
|
|
16
|
+
from .scaffold import MCP_SERVER
|
|
17
|
+
from .tools import TOOLS
|
|
18
|
+
|
|
19
|
+
REPO = Path(__file__).resolve().parents[1]
|
|
20
|
+
AUTHOR = {"name": "umbralcalc", "email": "umbralcalc@gmail.com",
|
|
21
|
+
"url": "https://github.com/umbralcalc/robert"}
|
|
22
|
+
DESCRIPTION = ("The AI simulation scientist: drives stochadex projects through the "
|
|
23
|
+
"discovery-and-warrant loop, with every gate enforced by the robert MCP server.")
|
|
24
|
+
HEADER = "<!-- Generated by `robert adapters` from skills/. Do not edit by hand. -->\n"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def skills(repo: Path = REPO) -> list[tuple[dict[str, str], str]]:
|
|
28
|
+
out = []
|
|
29
|
+
for path in sorted((repo / "skills").glob("*/SKILL.md")):
|
|
30
|
+
_, front, body = path.read_text().split("---", 2)
|
|
31
|
+
# Skill frontmatter is `key: value` lines, not strict YAML (descriptions
|
|
32
|
+
# may contain `{type: ...}`), so split each line at its first colon.
|
|
33
|
+
meta = dict(line.split(":", 1) for line in front.strip().splitlines())
|
|
34
|
+
out.append(({k.strip(): v.strip() for k, v in meta.items()}, body.strip() + "\n"))
|
|
35
|
+
return out
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _nest_headings(body: str) -> str:
|
|
39
|
+
"""Push a skill's headings two levels down, leaving code blocks alone."""
|
|
40
|
+
lines, fenced = [], False
|
|
41
|
+
for line in body.splitlines(keepends=True):
|
|
42
|
+
if line.startswith("```"):
|
|
43
|
+
fenced = not fenced
|
|
44
|
+
elif not fenced and re.match(r"#+ ", line):
|
|
45
|
+
line = "##" + line
|
|
46
|
+
lines.append(line)
|
|
47
|
+
return "".join(lines)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def outputs(repo: Path = REPO) -> dict[str, str]:
|
|
51
|
+
"""Every adapter file, keyed by path relative to the repo."""
|
|
52
|
+
files: dict[str, str] = {}
|
|
53
|
+
files[".claude-plugin/plugin.json"] = json.dumps({
|
|
54
|
+
"name": "robert",
|
|
55
|
+
"description": DESCRIPTION,
|
|
56
|
+
"version": __version__,
|
|
57
|
+
"author": AUTHOR,
|
|
58
|
+
"repository": AUTHOR["url"],
|
|
59
|
+
"license": "MIT",
|
|
60
|
+
"keywords": ["simulation", "stochadex", "scientific-method", "agent-skill"],
|
|
61
|
+
"skills": "./skills/",
|
|
62
|
+
"mcpServers": {"robert": MCP_SERVER},
|
|
63
|
+
}, indent=2) + "\n"
|
|
64
|
+
files[".claude-plugin/marketplace.json"] = json.dumps({
|
|
65
|
+
"name": "robert",
|
|
66
|
+
"owner": AUTHOR,
|
|
67
|
+
"metadata": {"description": DESCRIPTION, "version": __version__},
|
|
68
|
+
"plugins": [{"name": "robert", "source": ".", "description": DESCRIPTION,
|
|
69
|
+
"category": "modeling",
|
|
70
|
+
"keywords": ["simulation", "stochadex", "scientific-method"]}],
|
|
71
|
+
}, indent=2) + "\n"
|
|
72
|
+
files["adapters/cursor/mcp.json"] = json.dumps(
|
|
73
|
+
{"mcpServers": {"robert": MCP_SERVER}}, indent=2) + "\n"
|
|
74
|
+
|
|
75
|
+
agents = [HEADER, "# robert: the simulation scientist\n\n",
|
|
76
|
+
"Connect the `robert` MCP server (`robert mcp`, stdio) and follow the "
|
|
77
|
+
"skills below. The tools enforce the gates; these notes explain them.\n\n",
|
|
78
|
+
"## Tools\n\n"]
|
|
79
|
+
agents += [f"- `{t.name}`: {t.description}\n" for t in TOOLS]
|
|
80
|
+
for front, body in skills(repo):
|
|
81
|
+
files[f"adapters/cursor/rules/{front['name']}.mdc"] = (
|
|
82
|
+
f"---\ndescription: {json.dumps(front['description'], ensure_ascii=False)}\n"
|
|
83
|
+
"alwaysApply: false\n---\n"
|
|
84
|
+
+ HEADER + body)
|
|
85
|
+
agents += [f"\n## Skill: {front['name']}\n\n", _nest_headings(body)]
|
|
86
|
+
files["adapters/agents/AGENTS.md"] = "".join(agents)
|
|
87
|
+
return files
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def render(repo: Path = REPO) -> list[str]:
|
|
91
|
+
written = []
|
|
92
|
+
for rel, text in outputs(repo).items():
|
|
93
|
+
p = repo / rel
|
|
94
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
p.write_text(text)
|
|
96
|
+
written.append(rel)
|
|
97
|
+
return written
|
robert/checks.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Deterministic checks shared by the gates.
|
|
2
|
+
|
|
3
|
+
A gate collects named checks. A failed gate is not an error: the result says which
|
|
4
|
+
check failed and what the method suggests next, and the user decides whether to
|
|
5
|
+
accept that, override it (logged as an intervention) or bench the project.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import filecmp
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from .engine import Engine
|
|
21
|
+
from .project import MethodError, Project
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Gate:
|
|
26
|
+
step: str
|
|
27
|
+
checks: list[dict[str, Any]] = field(default_factory=list)
|
|
28
|
+
notes: list[str] = field(default_factory=list)
|
|
29
|
+
|
|
30
|
+
def check(self, name: str, passed: bool, detail: str = "") -> bool:
|
|
31
|
+
self.checks.append({"check": name, "passed": bool(passed),
|
|
32
|
+
**({"detail": detail} if detail else {})})
|
|
33
|
+
return bool(passed)
|
|
34
|
+
|
|
35
|
+
def all(self, name: str, problems: list[str]) -> bool:
|
|
36
|
+
return self.check(name, not problems, "; ".join(problems))
|
|
37
|
+
|
|
38
|
+
def note(self, text: str) -> None:
|
|
39
|
+
"""Something for the user's judgment that does not block the gate."""
|
|
40
|
+
self.notes.append(text)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def passed(self) -> bool:
|
|
44
|
+
return all(c["passed"] for c in self.checks)
|
|
45
|
+
|
|
46
|
+
def result(self, next_if_passed: str, next_if_failed: str) -> dict[str, Any]:
|
|
47
|
+
failed = [c for c in self.checks if not c["passed"]]
|
|
48
|
+
out: dict[str, Any] = {"passed": self.passed}
|
|
49
|
+
# Compressed by default: show what failed and what needs a judgment.
|
|
50
|
+
out["failed"] = failed
|
|
51
|
+
out["passed_checks"] = [c["check"] for c in self.checks if c["passed"]]
|
|
52
|
+
if self.notes:
|
|
53
|
+
out["for_your_judgment"] = self.notes
|
|
54
|
+
out["next"] = next_if_passed if self.passed else next_if_failed
|
|
55
|
+
return out
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def run_script(project: Project, script: str, config: str | None = None,
|
|
59
|
+
sets: list[str] | None = None, extra_env: dict[str, str] | None = None
|
|
60
|
+
) -> dict[str, Any]:
|
|
61
|
+
"""Run an analysis script and read the JSON object on its last line of stdout.
|
|
62
|
+
|
|
63
|
+
Scripts are functions of committed inputs: they get the project root, the
|
|
64
|
+
config and overrides under test, and the outputs that config writes.
|
|
65
|
+
"""
|
|
66
|
+
path = project.path(script)
|
|
67
|
+
if not path.is_file():
|
|
68
|
+
raise MethodError(f"script `{script}` does not exist")
|
|
69
|
+
env = dict(os.environ)
|
|
70
|
+
env["ROBERT_ROOT"] = str(project.root)
|
|
71
|
+
if config:
|
|
72
|
+
env["ROBERT_CONFIG"] = config
|
|
73
|
+
env["ROBERT_SETS"] = json.dumps(sets or [])
|
|
74
|
+
outs = Engine(project.root).file_outputs(config, sets)
|
|
75
|
+
env["ROBERT_OUTPUTS"] = json.dumps({o["name"]: o["path"] for o in outs})
|
|
76
|
+
env.update(extra_env or {})
|
|
77
|
+
python = os.environ.get("ROBERT_PYTHON", sys.executable)
|
|
78
|
+
proc = subprocess.run([python, str(path)], cwd=project.root, env=env,
|
|
79
|
+
capture_output=True, text=True)
|
|
80
|
+
if proc.returncode != 0:
|
|
81
|
+
raise MethodError(f"script `{script}` failed:\n{proc.stderr.strip()[-2000:]}")
|
|
82
|
+
lines = [ln for ln in proc.stdout.splitlines() if ln.strip()]
|
|
83
|
+
if not lines:
|
|
84
|
+
raise MethodError(f"script `{script}` printed nothing; it must print one JSON object")
|
|
85
|
+
try:
|
|
86
|
+
return json.loads(lines[-1])
|
|
87
|
+
except json.JSONDecodeError as e:
|
|
88
|
+
raise MethodError(f"script `{script}`: last line is not JSON: {lines[-1]!r}") from e
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _canonical_json_log(path: Path) -> list[tuple[float, str, str]]:
|
|
92
|
+
rows = []
|
|
93
|
+
for line in path.read_text().splitlines():
|
|
94
|
+
if line.strip():
|
|
95
|
+
r = json.loads(line)
|
|
96
|
+
rows.append((r.get("time"), r.get("partition_name"), line))
|
|
97
|
+
return sorted(rows)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def harness_twice(project: Project, config: str, sets: list[str] | None = None
|
|
101
|
+
) -> tuple[bool, str]:
|
|
102
|
+
"""Run the config twice with its file outputs redirected, and compare bytes.
|
|
103
|
+
|
|
104
|
+
Catches statefulness residues and unseeded randomness from outside the engine.
|
|
105
|
+
"""
|
|
106
|
+
engine = Engine(project.root)
|
|
107
|
+
outs = engine.file_outputs(config, sets)
|
|
108
|
+
work = project.path(".robert/harness")
|
|
109
|
+
shutil.rmtree(work, ignore_errors=True)
|
|
110
|
+
stdouts = []
|
|
111
|
+
for arm in ("a", "b"):
|
|
112
|
+
redirect = [f"outputs[name={o['name']}].function.path=.robert/harness/{arm}/{Path(o['path']).name}"
|
|
113
|
+
for o in outs]
|
|
114
|
+
project.path(f".robert/harness/{arm}").mkdir(parents=True, exist_ok=True)
|
|
115
|
+
r = engine._call(["--config", config, *engine._sets((sets or []) + redirect)])
|
|
116
|
+
if not r.ok:
|
|
117
|
+
return False, f"run {arm} failed: {r.summary()['error']}"
|
|
118
|
+
stdouts.append(r.stdout)
|
|
119
|
+
for o in outs:
|
|
120
|
+
name = Path(o["path"]).name
|
|
121
|
+
a, b = work / "a" / name, work / "b" / name
|
|
122
|
+
if o["sink"] == "json_log":
|
|
123
|
+
# Partitions write concurrently, so rows within one step may arrive in
|
|
124
|
+
# either order; what must match is the set of rows at each time.
|
|
125
|
+
same = _canonical_json_log(a) == _canonical_json_log(b)
|
|
126
|
+
else:
|
|
127
|
+
same = filecmp.cmp(a, b, shallow=False)
|
|
128
|
+
if not same:
|
|
129
|
+
return False, f"output `{o['name']}` differs between two identical runs"
|
|
130
|
+
if not outs and stdouts[0] != stdouts[1]:
|
|
131
|
+
return False, "stdout differs between two identical runs"
|
|
132
|
+
shutil.rmtree(work, ignore_errors=True)
|
|
133
|
+
return True, f"{len(outs) or 'stdout'} output(s) byte-identical across two runs"
|
robert/cli.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""`robert`: the CLI face of the tool registry.
|
|
2
|
+
|
|
3
|
+
Arguments are generated from each tool's JSON Schema: strings and numbers are
|
|
4
|
+
flags, string lists repeat (`--conditions a --conditions b`), booleans are
|
|
5
|
+
`--x/--no-x`, and objects take JSON. Exit codes: 0 gate passed, 1 gate failed,
|
|
6
|
+
2 the request could not be carried out.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
from . import __version__
|
|
19
|
+
from .project import MethodError
|
|
20
|
+
from .tools import TOOLS
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _add_arg(p: argparse.ArgumentParser, name: str, schema: dict[str, Any],
|
|
24
|
+
required: bool) -> None:
|
|
25
|
+
flag = "--" + name.replace("_", "-")
|
|
26
|
+
t = schema.get("type")
|
|
27
|
+
kw: dict[str, Any] = {"dest": name, "help": schema.get("description")}
|
|
28
|
+
if t == "boolean":
|
|
29
|
+
p.add_argument(flag, action=argparse.BooleanOptionalAction, default=None, **kw)
|
|
30
|
+
return
|
|
31
|
+
if t == "array" and schema.get("items", {}).get("type") == "string":
|
|
32
|
+
kw.update(action="append")
|
|
33
|
+
elif t in ("array", "object"):
|
|
34
|
+
kw.update(type=json.loads, metavar="JSON")
|
|
35
|
+
elif t == "number":
|
|
36
|
+
kw.update(type=float)
|
|
37
|
+
if "enum" in schema:
|
|
38
|
+
kw["choices"] = schema["enum"]
|
|
39
|
+
p.add_argument(flag, required=required, **kw)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
43
|
+
parser = argparse.ArgumentParser(
|
|
44
|
+
prog="robert", description="The AI simulation scientist's method CLI.")
|
|
45
|
+
parser.add_argument("--version", action="version", version=f"robert {__version__}")
|
|
46
|
+
parser.add_argument("--json", action="store_true", help="print JSON instead of YAML")
|
|
47
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
48
|
+
for tool in TOOLS:
|
|
49
|
+
p = sub.add_parser(tool.name, help=tool.description.split(". ")[0],
|
|
50
|
+
description=tool.description)
|
|
51
|
+
schema = tool.input_schema
|
|
52
|
+
for name, prop in schema["properties"].items():
|
|
53
|
+
_add_arg(p, name, prop, name in tool.required)
|
|
54
|
+
mcp = sub.add_parser("mcp", help="Serve every tool over MCP (stdio by default)")
|
|
55
|
+
mcp.add_argument("--http", action="store_true",
|
|
56
|
+
help="serve streamable HTTP at /mcp instead of stdio")
|
|
57
|
+
mcp.add_argument("--host", default="127.0.0.1",
|
|
58
|
+
help="bind address; anything but loopback requires ROBERT_MCP_TOKEN")
|
|
59
|
+
mcp.add_argument("--port", type=int, default=8765)
|
|
60
|
+
setup = sub.add_parser("setup", help="Install the locked stochadex and register agents")
|
|
61
|
+
setup.add_argument("--stochadex", metavar="VERSION",
|
|
62
|
+
help="stochadex version to install; default: the lock's")
|
|
63
|
+
setup.add_argument("--accelerated", action="store_true",
|
|
64
|
+
help="the BLAS + DuckDB build instead of the portable one")
|
|
65
|
+
setup.add_argument("--agents", nargs="+", metavar="AGENT",
|
|
66
|
+
help="agents to register (claude, cursor); default: all found")
|
|
67
|
+
setup.add_argument("--no-agents", action="store_true", help="install stochadex only")
|
|
68
|
+
sub.add_parser("adapters", help="Render agent adapters from the skills source")
|
|
69
|
+
return parser
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def main(argv: list[str] | None = None) -> int:
|
|
73
|
+
parser = build_parser()
|
|
74
|
+
ns = parser.parse_args(argv)
|
|
75
|
+
try:
|
|
76
|
+
if ns.command == "mcp":
|
|
77
|
+
from .mcp_server import serve, serve_http
|
|
78
|
+
if ns.http:
|
|
79
|
+
serve_http(ns.host, ns.port)
|
|
80
|
+
else:
|
|
81
|
+
serve()
|
|
82
|
+
return 0
|
|
83
|
+
if ns.command == "adapters":
|
|
84
|
+
from .adapters import render
|
|
85
|
+
for path in render():
|
|
86
|
+
print(path)
|
|
87
|
+
return 0
|
|
88
|
+
if ns.command == "setup":
|
|
89
|
+
from .setup import setup
|
|
90
|
+
result = setup(ns.stochadex, ns.accelerated, ns.agents, ns.no_agents)
|
|
91
|
+
else:
|
|
92
|
+
tool = next(t for t in TOOLS if t.name == ns.command)
|
|
93
|
+
args = {k: v for k, v in vars(ns).items()
|
|
94
|
+
if k not in ("command", "json") and v is not None}
|
|
95
|
+
result = tool.call(args)
|
|
96
|
+
except MethodError as e:
|
|
97
|
+
print(f"robert {ns.command}: {e}", file=sys.stderr)
|
|
98
|
+
return 2
|
|
99
|
+
if ns.json:
|
|
100
|
+
print(json.dumps(result, indent=2, default=str))
|
|
101
|
+
else:
|
|
102
|
+
print(yaml.safe_dump(result, sort_keys=False, allow_unicode=True, width=100), end="")
|
|
103
|
+
gate = result.get("gate")
|
|
104
|
+
return 1 if (gate and not gate["passed"]) or result.get("ok") is False else 0
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
if __name__ == "__main__":
|
|
108
|
+
raise SystemExit(main())
|
robert/contracts.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Contracts for the method files.
|
|
2
|
+
|
|
3
|
+
Each method file has one job and a validator that says, in plain words, what is
|
|
4
|
+
missing. Nothing here describes the model itself: structure and wiring live only in
|
|
5
|
+
model/sim.yaml, which stochadex validates. These files hold what stochadex does not
|
|
6
|
+
encode: the claim, the user's domain terms, and the role, hypothesis and evidence
|
|
7
|
+
behind each partition.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
# Query modes, in order of the warrant they need (Pearl's ladder, plus deployment).
|
|
15
|
+
MODES = ["explain", "forecast", "decide", "automate", "design", "counterfactual"]
|
|
16
|
+
INTERVENTION_MODES = {"decide", "automate", "design", "counterfactual"}
|
|
17
|
+
|
|
18
|
+
ROLES = ["structural", "nuisance", "surrogate"]
|
|
19
|
+
STATUSES = ["untested", "passed", "failed", "benched"]
|
|
20
|
+
TEST_KINDS = ["backtest", "process", "swap", "generalisation", "outcome"]
|
|
21
|
+
LINEAGE_STEPS = ["conjecture", "discover", "compress", "swap"]
|
|
22
|
+
ACTORS = ["user", "standin"]
|
|
23
|
+
DECISIONS = ["accept", "override", "redirect", "bench"]
|
|
24
|
+
|
|
25
|
+
# iteration types that make a partition "learned" rather than mechanistic.
|
|
26
|
+
LEARNED_TYPES = {"onnx_inference"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _missing(d: dict[str, Any], keys: list[str], where: str) -> list[str]:
|
|
30
|
+
out = []
|
|
31
|
+
for k in keys:
|
|
32
|
+
v = d.get(k)
|
|
33
|
+
if v is None or (isinstance(v, (str, list, dict)) and not v):
|
|
34
|
+
out.append(f"{where}: `{k}` is not named")
|
|
35
|
+
return out
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def check_domain(d: dict[str, Any]) -> list[str]:
|
|
39
|
+
problems = _missing(d, ["description", "user_knows"], "domain.yaml")
|
|
40
|
+
if d.get("level") not in ("novice", "practitioner", "expert"):
|
|
41
|
+
problems.append("domain.yaml: `level` must be novice, practitioner or expert")
|
|
42
|
+
return problems
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def check_claim(c: dict[str, Any]) -> list[str]:
|
|
46
|
+
problems = _missing(
|
|
47
|
+
c, ["question", "claim", "intended_use", "conditions", "observables"], "claim.yaml"
|
|
48
|
+
)
|
|
49
|
+
if c.get("intended_use") and c["intended_use"] not in MODES:
|
|
50
|
+
problems.append(f"claim.yaml: `intended_use` must be one of {', '.join(MODES)}")
|
|
51
|
+
tol = c.get("compress_tolerance")
|
|
52
|
+
if tol is not None and not (isinstance(tol, (int, float)) and tol >= 0):
|
|
53
|
+
problems.append("claim.yaml: `compress_tolerance` must be a non-negative number")
|
|
54
|
+
return problems
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def check_partitions(sidecar: dict[str, Any], sim_names: list[str]) -> list[str]:
|
|
58
|
+
"""One entry per partition in sim.yaml, and no others."""
|
|
59
|
+
entries = sidecar.get("partitions", {}) or {}
|
|
60
|
+
problems = []
|
|
61
|
+
for name in sim_names:
|
|
62
|
+
if name not in entries:
|
|
63
|
+
problems.append(f"partitions.yaml: `{name}` is in sim.yaml but has no entry")
|
|
64
|
+
continue
|
|
65
|
+
e = entries[name] or {}
|
|
66
|
+
if e.get("role") not in ROLES:
|
|
67
|
+
problems.append(f"partitions.yaml: `{name}` needs a role ({', '.join(ROLES)})")
|
|
68
|
+
if not e.get("hypothesis"):
|
|
69
|
+
problems.append(f"partitions.yaml: `{name}` needs a one-line hypothesis")
|
|
70
|
+
if e.get("status", "untested") not in STATUSES:
|
|
71
|
+
problems.append(f"partitions.yaml: `{name}` status must be one of {STATUSES}")
|
|
72
|
+
for name in entries:
|
|
73
|
+
if name not in sim_names:
|
|
74
|
+
problems.append(f"partitions.yaml: `{name}` is not a partition in sim.yaml")
|
|
75
|
+
return problems
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def check_test_spec(t: dict[str, Any]) -> list[str]:
|
|
79
|
+
where = f"tests.yaml check `{t.get('id', '?')}`"
|
|
80
|
+
problems = _missing(t, ["id", "partitions", "kind", "script"], where)
|
|
81
|
+
if t.get("kind") and t["kind"] not in TEST_KINDS:
|
|
82
|
+
problems.append(f"{where}: kind must be one of {', '.join(TEST_KINDS)}")
|
|
83
|
+
if t.get("kind") == "backtest":
|
|
84
|
+
te, ts = t.get("train_end"), t.get("test_start")
|
|
85
|
+
if te is None or ts is None:
|
|
86
|
+
problems.append(f"{where}: a backtest names train_end and test_start")
|
|
87
|
+
elif not ts > te:
|
|
88
|
+
problems.append(f"{where}: test_start must come after train_end (leakage)")
|
|
89
|
+
return problems
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def check_intervention(e: dict[str, Any]) -> list[str]:
|
|
93
|
+
problems = _missing(e, ["step", "actor", "agent_proposal", "decision", "reason"],
|
|
94
|
+
"intervention")
|
|
95
|
+
if e.get("actor") and e["actor"] not in ACTORS:
|
|
96
|
+
problems.append(f"intervention: actor must be one of {', '.join(ACTORS)}")
|
|
97
|
+
if e.get("decision") and e["decision"] not in DECISIONS:
|
|
98
|
+
problems.append(f"intervention: decision must be one of {', '.join(DECISIONS)}")
|
|
99
|
+
return problems
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def sim_partitions(sim: dict[str, Any]) -> list[dict[str, Any]]:
|
|
103
|
+
return list(((sim or {}).get("main") or {}).get("partitions") or [])
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def is_learned(partition: dict[str, Any]) -> bool:
|
|
107
|
+
it = partition.get("iteration") or {}
|
|
108
|
+
return isinstance(it, dict) and it.get("type") in LEARNED_TYPES
|