@senpi/runtime 3.0.9-dev.20260710204324 → 3.0.9-dev.feat-strategy-compos.20260716233548

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 (108) hide show
  1. package/dist/cli/composer-commands.d.ts +37 -0
  2. package/dist/cli/composer-commands.d.ts.map +1 -0
  3. package/dist/cli/composer-commands.js +396 -0
  4. package/dist/cli/composer-commands.js.map +1 -0
  5. package/dist/cli/senpi-commands.d.ts.map +1 -1
  6. package/dist/cli/senpi-commands.js +2 -0
  7. package/dist/cli/senpi-commands.js.map +1 -1
  8. package/dist/cli/validate-runtime-config.mjs +190 -0
  9. package/dist/composer/AUTHORING.md +435 -0
  10. package/dist/composer/__init__.py +29 -0
  11. package/dist/composer/authoring/__init__.py +59 -0
  12. package/dist/composer/authoring/answers.py +297 -0
  13. package/dist/composer/authoring/archetypes/__init__.py +26 -0
  14. package/dist/composer/authoring/archetypes/_common.py +410 -0
  15. package/dist/composer/authoring/archetypes/breakout.py +134 -0
  16. package/dist/composer/authoring/archetypes/classifier.py +109 -0
  17. package/dist/composer/authoring/archetypes/skeleton.py +76 -0
  18. package/dist/composer/authoring/archetypes/thesis_fund.py +103 -0
  19. package/dist/composer/authoring/archetypes/trend_momentum.py +132 -0
  20. package/dist/composer/authoring/builder.py +191 -0
  21. package/dist/composer/authoring/emit.py +264 -0
  22. package/dist/composer/authoring/gaps.py +115 -0
  23. package/dist/composer/authoring/generate.py +131 -0
  24. package/dist/composer/authoring/presets.py +168 -0
  25. package/dist/composer/authoring/runtime_emit.py +266 -0
  26. package/dist/composer/authoring/strategy_manifest.py +94 -0
  27. package/dist/composer/cli.py +487 -0
  28. package/dist/composer/compiler/__init__.py +22 -0
  29. package/dist/composer/compiler/compile.py +739 -0
  30. package/dist/composer/examples/breakout_swing.graph.yaml +121 -0
  31. package/dist/composer/examples/conviction_scorer.graph.yaml +216 -0
  32. package/dist/composer/examples/macro_thesis_basket.graph.yaml +247 -0
  33. package/dist/composer/examples/pre_listing_classifier.graph.yaml +204 -0
  34. package/dist/composer/examples/trend_momentum_majors.graph.yaml +133 -0
  35. package/dist/composer/generated/README.md +22 -0
  36. package/dist/composer/generated/catalog.md +118 -0
  37. package/dist/composer/generated/describe.md +1168 -0
  38. package/dist/composer/generated/graph.schema.json +1946 -0
  39. package/dist/composer/primitives/__init__.py +320 -0
  40. package/dist/composer/primitives/_candles.py +76 -0
  41. package/dist/composer/primitives/adapter.py +363 -0
  42. package/dist/composer/primitives/emitters.py +142 -0
  43. package/dist/composer/primitives/filters.py +71 -0
  44. package/dist/composer/primitives/indicators.py +326 -0
  45. package/dist/composer/primitives/rankers.py +40 -0
  46. package/dist/composer/primitives/scorers.py +100 -0
  47. package/dist/composer/primitives/sources.py +979 -0
  48. package/dist/composer/primitives/stateful.py +254 -0
  49. package/dist/composer/pure_fn/__init__.py +34 -0
  50. package/dist/composer/pure_fn/checks.py +39 -0
  51. package/dist/composer/pure_fn/models.py +177 -0
  52. package/dist/composer/pure_fn/purity.py +311 -0
  53. package/dist/composer/pure_fn/runtests.py +155 -0
  54. package/dist/composer/registry/__init__.py +40 -0
  55. package/dist/composer/registry/generate.py +425 -0
  56. package/dist/composer/registry/mcp-tools-snapshot.json +193 -0
  57. package/dist/composer/registry/mcp_closure.py +311 -0
  58. package/dist/composer/registry/models.py +417 -0
  59. package/dist/composer/registry/nodes.py +1297 -0
  60. package/dist/composer/requirements.txt +21 -0
  61. package/dist/composer/scaffold_path.py +118 -0
  62. package/dist/composer/templates/__init__.py +13 -0
  63. package/dist/composer/templates/scan.py.j2 +34 -0
  64. package/dist/composer/validator/__init__.py +29 -0
  65. package/dist/composer/validator/desugar.py +133 -0
  66. package/dist/composer/validator/errors.py +227 -0
  67. package/dist/composer/validator/graph_checks.py +443 -0
  68. package/dist/composer/validator/loader.py +110 -0
  69. package/dist/composer/validator/schema_checks.py +147 -0
  70. package/dist/composer/validator/validate.py +86 -0
  71. package/dist/composer/vendoring.py +103 -0
  72. package/dist/composer/verbs/__init__.py +49 -0
  73. package/dist/composer/verbs/_box.py +308 -0
  74. package/dist/composer/verbs/check.py +254 -0
  75. package/dist/composer/verbs/close.py +494 -0
  76. package/dist/composer/verbs/deploy.py +279 -0
  77. package/dist/composer/verbs/docs.py +30 -0
  78. package/dist/composer/verbs/fund.py +498 -0
  79. package/dist/composer/verbs/install.py +586 -0
  80. package/dist/composer/verbs/runtime_validate.py +422 -0
  81. package/dist/composer/verbs/smoke.py +134 -0
  82. package/dist/composer/verbs/status.py +383 -0
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +2 -9
  85. package/dist/index.js.map +1 -1
  86. package/dist/runtime/load-runtime-config.d.ts +17 -0
  87. package/dist/runtime/load-runtime-config.d.ts.map +1 -1
  88. package/dist/runtime/load-runtime-config.js +2 -2
  89. package/dist/runtime/load-runtime-config.js.map +1 -1
  90. package/dist/runtime-api/config.d.ts +2 -2
  91. package/dist/runtime-api/config.js +4 -4
  92. package/dist/runtime-api/config.js.map +1 -1
  93. package/dist/runtime-api/handlers/ingest-signals.d.ts +3 -5
  94. package/dist/runtime-api/handlers/ingest-signals.d.ts.map +1 -1
  95. package/dist/runtime-api/handlers/ingest-signals.js +5 -14
  96. package/dist/runtime-api/handlers/ingest-signals.js.map +1 -1
  97. package/dist/runtime-api/intake/intake-mount.d.ts.map +1 -1
  98. package/dist/runtime-api/intake/intake-mount.js +2 -8
  99. package/dist/runtime-api/intake/intake-mount.js.map +1 -1
  100. package/dist/skills-manager/manifest.d.ts.map +1 -1
  101. package/dist/skills-manager/manifest.js +1 -3
  102. package/dist/skills-manager/manifest.js.map +1 -1
  103. package/dist/skills-manager/skills-manifest.json +1 -3
  104. package/package.json +4 -2
  105. package/dist/runtime-api/intake/ingest-mutex.d.ts +0 -16
  106. package/dist/runtime-api/intake/ingest-mutex.d.ts.map +0 -1
  107. package/dist/runtime-api/intake/ingest-mutex.js +0 -18
  108. package/dist/runtime-api/intake/ingest-mutex.js.map +0 -1
@@ -0,0 +1,311 @@
1
+ """purity — the static AST gate on a pure_fn body (D2 kind 2; CMP04x teaching errors).
2
+
3
+ Sandbox MECHANICS are punted for the POC (HLD open question): there is no RestrictedPython
4
+ and no OS sandbox. The one gate is this static AST allowlist, run in the validator BEFORE
5
+ anything executes, so an impure function is refused with a located, fix-hinted teaching
6
+ error rather than doing I/O at runtime. The rules (each its own CMP04x class):
7
+
8
+ CMP040 the `fn:` block is not exactly one well-formed top-level `def`
9
+ (also: reserved / duplicate-across-nodes function name, unsupported construct).
10
+ CMP041 imports a module outside the allowlist — only `math` and `statistics`.
11
+ CMP042 uses a disallowed builtin or reflection escape (open/exec/eval/__import__/
12
+ getattr/print/... or any dunder attribute access like `x.__globals__`).
13
+ CMP043 references a forbidden or undeclared NAME (`ctx`, `senpi_mcp`, an un-imported
14
+ module, an external global) — this is where a ctx/MCP reference is caught.
15
+ CMP044 the function signature does not match the declared input ports + params.
16
+ CMP045 the function does not return its declared outputs (arity mismatch).
17
+ CMP046 writes global/nonlocal state (a `global`/`nonlocal` statement).
18
+
19
+ Purity is checked against the DECLARED ports/params, not the wired graph, so it is a
20
+ property of the node alone and reproducible in isolation (the fixture unit-test story).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import ast
26
+ from typing import Optional
27
+
28
+ from composer.validator.errors import Location, TeachingError
29
+
30
+ from .models import PureFnNode
31
+
32
+ # Only these two modules may be imported by a pure_fn body (POC allowlist).
33
+ ALLOWED_MODULES = frozenset({"math", "statistics"})
34
+
35
+ # Builtins a pure_fn MAY reference. Deliberately small — pure numeric/collection work only.
36
+ SAFE_BUILTINS = frozenset({
37
+ "abs", "all", "any", "bool", "dict", "divmod", "enumerate", "filter", "float",
38
+ "frozenset", "int", "len", "list", "map", "max", "min", "pow", "range", "reversed",
39
+ "round", "set", "sorted", "str", "sum", "tuple", "zip", "isinstance",
40
+ })
41
+
42
+ # Builtins whose mere reference is an escape hatch (I/O, dynamic eval, reflection).
43
+ DANGEROUS_BUILTINS = frozenset({
44
+ "open", "exec", "eval", "compile", "__import__", "input", "globals", "locals",
45
+ "vars", "getattr", "setattr", "delattr", "hasattr", "dir", "memoryview",
46
+ "breakpoint", "exit", "quit", "help", "print", "__builtins__",
47
+ })
48
+
49
+ # Names a pure_fn's function may NOT take, because the emitted scan.py binds them for the
50
+ # primitives/scaffold surface (a pure_fn def is emitted nested inside scan()).
51
+ RESERVED_FN_NAMES = frozenset({
52
+ "scan", "inputs", "ctx", "src", "time", "base_universe", "make_adapter",
53
+ "emitters", "filters", "indicators", "rankers", "scorers", "sources", "stateful",
54
+ "math", "statistics",
55
+ })
56
+
57
+
58
+ def _err(code: str, message: str, fix_hint: str, node_id: str, line: Optional[int]) -> TeachingError:
59
+ return TeachingError(
60
+ code=code, message=message, fix_hint=fix_hint,
61
+ location=Location(node=node_id, line=line),
62
+ )
63
+
64
+
65
+ def _bound_names(fn: ast.FunctionDef) -> set[str]:
66
+ """Every name bound anywhere in the function (params, assignments, loops, imports,
67
+ comprehensions, except-as, nested defs/lambdas + their args) plus the fn's own name."""
68
+ bound: set[str] = {fn.name}
69
+
70
+ def add_args(args: ast.arguments) -> None:
71
+ for a in (*args.posonlyargs, *args.args, *args.kwonlyargs):
72
+ bound.add(a.arg)
73
+ if args.vararg:
74
+ bound.add(args.vararg.arg)
75
+ if args.kwarg:
76
+ bound.add(args.kwarg.arg)
77
+
78
+ add_args(fn.args)
79
+ for node in ast.walk(fn):
80
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)):
81
+ if node is not fn:
82
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
83
+ bound.add(node.name)
84
+ add_args(node.args)
85
+ elif isinstance(node, ast.Name) and isinstance(node.ctx, (ast.Store, ast.Del)):
86
+ bound.add(node.id)
87
+ elif isinstance(node, (ast.Import, ast.ImportFrom)):
88
+ for alias in node.names:
89
+ bound.add((alias.asname or alias.name).split(".")[0])
90
+ elif isinstance(node, ast.ExceptHandler) and node.name:
91
+ bound.add(node.name)
92
+ elif isinstance(node, (ast.comprehension,)):
93
+ for t in ast.walk(node.target):
94
+ if isinstance(t, ast.Name):
95
+ bound.add(t.id)
96
+ return bound
97
+
98
+
99
+ def _check_signature(fn: ast.FunctionDef, pfn: PureFnNode, errors: list, line) -> None:
100
+ a = fn.args
101
+ expected = [p.name for p in pfn.inputs] + [p.name for p in pfn.params]
102
+ got = [arg.arg for arg in (*a.posonlyargs, *a.args)]
103
+ extra = []
104
+ if a.vararg:
105
+ extra.append("*" + a.vararg.arg)
106
+ if a.kwarg:
107
+ extra.append("**" + a.kwarg.arg)
108
+ for kw in a.kwonlyargs:
109
+ extra.append(kw.arg)
110
+ if got != expected or extra:
111
+ errors.append(_err(
112
+ "CMP044",
113
+ f"pure_fn {pfn.id!r} signature is ({', '.join(got + extra) or ''}) but its "
114
+ f"declared inputs+params require ({', '.join(expected)}).",
115
+ f"Write `def {fn.name}({', '.join(expected)}):` — the parameters must be exactly "
116
+ f"the declared input ports then the params, in order, with no *args/**kwargs.",
117
+ pfn.id, line,
118
+ ))
119
+
120
+
121
+ def _check_returns(fn: ast.FunctionDef, pfn: PureFnNode, errors: list, line) -> None:
122
+ n_out = len(pfn.outputs)
123
+ value_returns = [
124
+ n for n in ast.walk(fn)
125
+ if isinstance(n, ast.Return) and n.value is not None
126
+ ]
127
+ out_names = ", ".join(p.name for p in pfn.outputs)
128
+ if not value_returns:
129
+ errors.append(_err(
130
+ "CMP045",
131
+ f"pure_fn {pfn.id!r} never returns a value but declares output(s) ({out_names}).",
132
+ f"End the function with `return {out_names}` producing its declared output(s).",
133
+ pfn.id, line,
134
+ ))
135
+ return
136
+ for ret in value_returns:
137
+ is_tuple = isinstance(ret.value, ast.Tuple)
138
+ if n_out == 1 and is_tuple and len(ret.value.elts) != 1:
139
+ errors.append(_err(
140
+ "CMP045",
141
+ f"pure_fn {pfn.id!r} returns {len(ret.value.elts)} values but declares 1 "
142
+ f"output ({out_names}).",
143
+ f"Return a single {out_names} value, not a tuple.",
144
+ pfn.id, line,
145
+ ))
146
+ return
147
+ if n_out > 1:
148
+ if not is_tuple:
149
+ errors.append(_err(
150
+ "CMP045",
151
+ f"pure_fn {pfn.id!r} declares {n_out} outputs ({out_names}) but a return "
152
+ f"does not return a tuple of {n_out} values.",
153
+ f"Return a {n_out}-tuple `return {out_names}` matching the declared outputs.",
154
+ pfn.id, line,
155
+ ))
156
+ return
157
+ if len(ret.value.elts) != n_out:
158
+ errors.append(_err(
159
+ "CMP045",
160
+ f"pure_fn {pfn.id!r} returns {len(ret.value.elts)} values but declares "
161
+ f"{n_out} outputs ({out_names}).",
162
+ f"Return exactly {n_out} values `return {out_names}`.",
163
+ pfn.id, line,
164
+ ))
165
+ return
166
+
167
+
168
+ def check_purity(pfn: PureFnNode, used_fn_names: set[str]) -> list[TeachingError]:
169
+ """Return every purity teaching error for one pure_fn node (empty == pure).
170
+
171
+ ``used_fn_names`` accumulates function names already claimed by earlier pure_fn nodes,
172
+ so a duplicate name (which would shadow in the shared emitted scan.py) is a CMP040.
173
+ """
174
+ errors: list[TeachingError] = []
175
+ line = pfn.line
176
+
177
+ if not pfn.fn_src.strip():
178
+ errors.append(_err(
179
+ "CMP040", f"pure_fn {pfn.id!r} has no `fn:` function body.",
180
+ "Add an `fn:` block containing exactly one top-level `def`.", pfn.id, line,
181
+ ))
182
+ return errors
183
+ if not pfn.outputs:
184
+ errors.append(_err(
185
+ "CMP040", f"pure_fn {pfn.id!r} declares no `outputs:`.",
186
+ "Declare at least one typed output port under `outputs:`.", pfn.id, line,
187
+ ))
188
+
189
+ try:
190
+ tree = ast.parse(pfn.fn_src)
191
+ except SyntaxError as exc:
192
+ errors.append(_err(
193
+ "CMP040", f"pure_fn {pfn.id!r} body is not valid Python: {exc.msg}.",
194
+ "Fix the syntax; the `fn:` block must be one well-formed `def`.", pfn.id, line,
195
+ ))
196
+ return errors
197
+
198
+ defs = [n for n in tree.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))]
199
+ others = [
200
+ n for n in tree.body
201
+ if not isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Import, ast.ImportFrom))
202
+ ]
203
+ if others:
204
+ errors.append(_err(
205
+ "CMP040", f"pure_fn {pfn.id!r} body has top-level code outside its `def`.",
206
+ "The `fn:` block must be exactly one `def` (plus, at most, allow-listed imports).",
207
+ pfn.id, line,
208
+ ))
209
+ if len(defs) != 1 or isinstance(defs[0], ast.AsyncFunctionDef):
210
+ errors.append(_err(
211
+ "CMP040",
212
+ f"pure_fn {pfn.id!r} must contain exactly one synchronous top-level `def` "
213
+ f"(found {len(defs)}).",
214
+ "Write a single plain `def name(...):` (no `async def`, no extra defs).",
215
+ pfn.id, line,
216
+ ))
217
+ return errors
218
+
219
+ fn = defs[0]
220
+ if fn.name in RESERVED_FN_NAMES:
221
+ errors.append(_err(
222
+ "CMP040",
223
+ f"pure_fn {pfn.id!r} function name {fn.name!r} is reserved by the emitted scan.py.",
224
+ f"Rename the function (reserved: sorted({sorted(RESERVED_FN_NAMES)[:4]})... and the "
225
+ f"primitive modules).",
226
+ pfn.id, line,
227
+ ))
228
+ elif fn.name in used_fn_names:
229
+ errors.append(_err(
230
+ "CMP040",
231
+ f"pure_fn {pfn.id!r} function name {fn.name!r} is already used by another pure_fn.",
232
+ "Give each pure_fn a distinct function name so they do not collide in scan.py.",
233
+ pfn.id, line,
234
+ ))
235
+ used_fn_names.add(fn.name)
236
+
237
+ _check_signature(fn, pfn, errors, line)
238
+ _check_returns(fn, pfn, errors, line)
239
+
240
+ bound = _bound_names(fn)
241
+
242
+ for node in ast.walk(fn):
243
+ # imports (anywhere) — allowlist only
244
+ if isinstance(node, ast.Import):
245
+ for alias in node.names:
246
+ if alias.name.split(".")[0] not in ALLOWED_MODULES:
247
+ errors.append(_err(
248
+ "CMP041",
249
+ f"pure_fn {pfn.id!r} imports {alias.name!r}, which is not allowed.",
250
+ f"A pure_fn may import only {sorted(ALLOWED_MODULES)} — remove this import.",
251
+ pfn.id, line,
252
+ ))
253
+ elif isinstance(node, ast.ImportFrom):
254
+ if (node.module or "").split(".")[0] not in ALLOWED_MODULES:
255
+ errors.append(_err(
256
+ "CMP041",
257
+ f"pure_fn {pfn.id!r} imports from {node.module!r}, which is not allowed.",
258
+ f"A pure_fn may import only {sorted(ALLOWED_MODULES)} — remove this import.",
259
+ pfn.id, line,
260
+ ))
261
+ # global / nonlocal writes
262
+ elif isinstance(node, (ast.Global, ast.Nonlocal)):
263
+ kw = "global" if isinstance(node, ast.Global) else "nonlocal"
264
+ errors.append(_err(
265
+ "CMP046",
266
+ f"pure_fn {pfn.id!r} uses `{kw}` — a pure_fn may not write outside its own scope.",
267
+ f"Remove the `{kw}` statement; compute and `return` values instead.",
268
+ pfn.id, line,
269
+ ))
270
+ # generators / awaits are not pure straight-line functions
271
+ elif isinstance(node, (ast.Yield, ast.YieldFrom, ast.Await)):
272
+ errors.append(_err(
273
+ "CMP042",
274
+ f"pure_fn {pfn.id!r} uses a generator/await construct.",
275
+ "A pure_fn must be a plain synchronous function that returns a value.",
276
+ pfn.id, line,
277
+ ))
278
+ # dunder attribute access (reflection escape: x.__globals__ / ().__class__)
279
+ elif isinstance(node, ast.Attribute):
280
+ if node.attr.startswith("__") and node.attr.endswith("__"):
281
+ errors.append(_err(
282
+ "CMP042",
283
+ f"pure_fn {pfn.id!r} accesses dunder attribute {node.attr!r} (reflection escape).",
284
+ "Remove the dunder attribute access; a pure_fn may not reach runtime internals.",
285
+ pfn.id, line,
286
+ ))
287
+ # name loads
288
+ elif isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load):
289
+ n = node.id
290
+ if n in bound or n in SAFE_BUILTINS or n in ALLOWED_MODULES:
291
+ continue
292
+ if n in DANGEROUS_BUILTINS:
293
+ errors.append(_err(
294
+ "CMP042",
295
+ f"pure_fn {pfn.id!r} uses the disallowed builtin {n!r} (I/O or dynamic execution).",
296
+ f"Remove {n!r}; a pure_fn may only do numeric/collection work "
297
+ f"(no I/O, no eval/exec/import).",
298
+ pfn.id, line,
299
+ ))
300
+ else:
301
+ errors.append(_err(
302
+ "CMP043",
303
+ f"pure_fn {pfn.id!r} references undeclared name {n!r} "
304
+ f"(no ctx/MCP/I/O and no external globals are available).",
305
+ f"A pure_fn sees only its parameters ({', '.join(a.arg for a in fn.args.args)}), "
306
+ f"locals, {sorted(ALLOWED_MODULES)}, and safe builtins — declare {n!r} as an "
307
+ f"input port or compute it locally.",
308
+ pfn.id, line,
309
+ ))
310
+
311
+ return errors
@@ -0,0 +1,155 @@
1
+ """runtests — run a pure_fn's inline fixture tests at validation time (CMP05x).
2
+
3
+ A pure_fn node may carry inline ``tests:`` — a list of ``{name, inputs, expect}`` fixture
4
+ cases. Because a pure_fn is pure by construction (statically enforced in
5
+ ``composer.pure_fn.purity``), a failing unit test is a DEFECT IN THE FUNCTION, catchable
6
+ before deploy — so the validator RUNS the cases (same-process, the POC decision) and
7
+ turns any failure into a teaching error:
8
+
9
+ CMP050 a case produced the wrong output (shows expected vs got).
10
+ CMP051 a case raised an exception when run.
11
+ CMP052 a case is malformed (does not bind a required arg, or names an unknown output).
12
+
13
+ Execution is only reached AFTER purity passes for the node, so the body is already known
14
+ to touch nothing but its parameters, ``math``/``statistics``, and safe builtins. We still
15
+ exec it under a restricted ``__builtins__`` (the same allowlist) so the test harness cannot
16
+ accidentally widen what the function can do.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import builtins as _builtins
22
+ import math
23
+ import statistics
24
+ from typing import Optional
25
+
26
+ from composer.validator.errors import Location, TeachingError
27
+
28
+ from .models import PureFnNode
29
+ from .purity import SAFE_BUILTINS
30
+
31
+
32
+ def _safe_import(name, *_a, **_k):
33
+ if name in ("math", "statistics"):
34
+ return __import__(name)
35
+ raise ImportError(f"pure_fn may not import {name!r}")
36
+
37
+
38
+ def _safe_namespace() -> dict:
39
+ safe = {n: getattr(_builtins, n) for n in SAFE_BUILTINS if hasattr(_builtins, n)}
40
+ safe["__import__"] = _safe_import
41
+ return {"__builtins__": safe, "math": math, "statistics": statistics}
42
+
43
+
44
+ def _short(value) -> str:
45
+ text = repr(value)
46
+ return text if len(text) <= 200 else text[:197] + "..."
47
+
48
+
49
+ def run_inline_tests(pfn: PureFnNode) -> list[TeachingError]:
50
+ """Run every inline test for one (already-pure) pure_fn node; return teaching errors."""
51
+ if not pfn.tests:
52
+ return []
53
+
54
+ fd = pfn.function_def()
55
+ if fd is None: # pragma: no cover - purity guarantees a def by the time we run
56
+ return []
57
+
58
+ ns = _safe_namespace()
59
+ try:
60
+ exec(compile(pfn.fn_src, f"<pure_fn {pfn.id}>", "exec"), ns) # noqa: S102 - purity-gated
61
+ except Exception as exc: # noqa: BLE001
62
+ return [TeachingError(
63
+ code="CMP051",
64
+ message=f"pure_fn {pfn.id!r} body failed to load for testing: {exc!r}.",
65
+ fix_hint="Fix the function body so it defines cleanly.",
66
+ location=Location(node=pfn.id, line=pfn.line),
67
+ )]
68
+ fn = ns.get(fd.name)
69
+
70
+ sig_names = [p.name for p in pfn.inputs] + [p.name for p in pfn.params]
71
+ param_defaults = {p.name: p.default for p in pfn.params}
72
+ out_names = [p.name for p in pfn.outputs]
73
+
74
+ errors: list[TeachingError] = []
75
+ for i, case in enumerate(pfn.tests):
76
+ name = case.get("name", f"#{i + 1}")
77
+ loc = Location(node=pfn.id, line=pfn.line)
78
+ given = case.get("inputs") or {}
79
+ expect = case.get("expect")
80
+
81
+ if not isinstance(given, dict) or not isinstance(expect, dict):
82
+ errors.append(TeachingError(
83
+ code="CMP052",
84
+ message=f"pure_fn {pfn.id!r} test {name!r} is malformed (needs mapping `inputs`/`expect`).",
85
+ fix_hint="Give the test `inputs:` (a mapping of port/param -> value) and `expect:` "
86
+ "(a mapping of output-port -> expected value).",
87
+ location=loc,
88
+ ))
89
+ continue
90
+
91
+ if set(expect) != set(out_names):
92
+ errors.append(TeachingError(
93
+ code="CMP052",
94
+ message=f"pure_fn {pfn.id!r} test {name!r} `expect:` keys {sorted(expect)} "
95
+ f"are not the declared outputs {out_names}.",
96
+ fix_hint=f"`expect:` must have exactly the output-port keys {out_names}.",
97
+ location=loc,
98
+ ))
99
+ continue
100
+
101
+ try:
102
+ args = []
103
+ for arg_name in sig_names:
104
+ if arg_name in given:
105
+ args.append(given[arg_name])
106
+ elif arg_name in param_defaults:
107
+ args.append(param_defaults[arg_name])
108
+ else:
109
+ raise KeyError(arg_name)
110
+ except KeyError as missing:
111
+ errors.append(TeachingError(
112
+ code="CMP052",
113
+ message=f"pure_fn {pfn.id!r} test {name!r} does not bind required argument {missing}.",
114
+ fix_hint=f"Provide a value for {missing} under the test's `inputs:` "
115
+ f"(signature: {sig_names}).",
116
+ location=loc,
117
+ ))
118
+ continue
119
+
120
+ try:
121
+ result = fn(*args)
122
+ except Exception as exc: # noqa: BLE001
123
+ errors.append(TeachingError(
124
+ code="CMP051",
125
+ message=f"pure_fn {pfn.id!r} test {name!r} raised {exc!r}.",
126
+ fix_hint="Make the function handle this input without raising (guard missing keys, "
127
+ "empty series, division by zero, etc.).",
128
+ location=loc,
129
+ ))
130
+ continue
131
+
132
+ if len(out_names) == 1:
133
+ actual = {out_names[0]: result}
134
+ else:
135
+ if not isinstance(result, (tuple, list)) or len(result) != len(out_names):
136
+ errors.append(TeachingError(
137
+ code="CMP050",
138
+ message=f"pure_fn {pfn.id!r} test {name!r} returned {_short(result)}, "
139
+ f"expected {len(out_names)} outputs {out_names}.",
140
+ fix_hint=f"Return a {len(out_names)}-tuple matching {out_names}.",
141
+ location=loc,
142
+ ))
143
+ continue
144
+ actual = dict(zip(out_names, result))
145
+
146
+ if actual != expect:
147
+ errors.append(TeachingError(
148
+ code="CMP050",
149
+ message=f"pure_fn {pfn.id!r} test {name!r} failed: expected {_short(expect)}, "
150
+ f"got {_short(actual)}.",
151
+ fix_hint="Fix the function (or the expected value) so this fixture case passes.",
152
+ location=loc,
153
+ ))
154
+
155
+ return errors
@@ -0,0 +1,40 @@
1
+ """registry — the node registry, single source of truth for the whole composer (D4, D8).
2
+
3
+ Every node the composer knows is declared here once as a pydantic model carrying its
4
+ name, typed input/output ports, parameter schema, docstring, example snippet, and cost
5
+ note. The JSON Schema for the graph format, the ~1-2k-token catalog index, the per-node
6
+ ``describe`` text, the validator's type rules, and the compiler's emission all derive
7
+ from these models, so none of them can drift and a registry entry with a bogus port type
8
+ fails at model construction rather than at use. This closed world is what lets the agent
9
+ know authoritatively what is possible: an unknown node becomes a precise validator
10
+ refusal and feasibility is checkable at interview time. (Populated in slice 1 with the
11
+ primitive subset the POC graphs need — sources, indicators, filters, rankers, emitters.)
12
+
13
+ Public API:
14
+ models — NodeDef / Port / Param / PortType / NodeKind and the construction validators.
15
+ nodes — NODES (the 26 corpus primitives), NODES_BY_NAME, sorted_nodes(), KIND_ORDER.
16
+ generate — deterministic artifact generation (JSON Schema + catalog + describe).
17
+ """
18
+
19
+ from .models import (
20
+ SCHEMA_VERSION,
21
+ NodeDef,
22
+ NodeKind,
23
+ Param,
24
+ Port,
25
+ PortType,
26
+ )
27
+ from .nodes import KIND_ORDER, NODES, NODES_BY_NAME, sorted_nodes
28
+
29
+ __all__ = [
30
+ "SCHEMA_VERSION",
31
+ "NodeDef",
32
+ "NodeKind",
33
+ "Param",
34
+ "Port",
35
+ "PortType",
36
+ "KIND_ORDER",
37
+ "NODES",
38
+ "NODES_BY_NAME",
39
+ "sorted_nodes",
40
+ ]