@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.
- package/dist/cli/composer-commands.d.ts +37 -0
- package/dist/cli/composer-commands.d.ts.map +1 -0
- package/dist/cli/composer-commands.js +396 -0
- package/dist/cli/composer-commands.js.map +1 -0
- package/dist/cli/senpi-commands.d.ts.map +1 -1
- package/dist/cli/senpi-commands.js +2 -0
- package/dist/cli/senpi-commands.js.map +1 -1
- package/dist/cli/validate-runtime-config.mjs +190 -0
- package/dist/composer/AUTHORING.md +435 -0
- package/dist/composer/__init__.py +29 -0
- package/dist/composer/authoring/__init__.py +59 -0
- package/dist/composer/authoring/answers.py +297 -0
- package/dist/composer/authoring/archetypes/__init__.py +26 -0
- package/dist/composer/authoring/archetypes/_common.py +410 -0
- package/dist/composer/authoring/archetypes/breakout.py +134 -0
- package/dist/composer/authoring/archetypes/classifier.py +109 -0
- package/dist/composer/authoring/archetypes/skeleton.py +76 -0
- package/dist/composer/authoring/archetypes/thesis_fund.py +103 -0
- package/dist/composer/authoring/archetypes/trend_momentum.py +132 -0
- package/dist/composer/authoring/builder.py +191 -0
- package/dist/composer/authoring/emit.py +264 -0
- package/dist/composer/authoring/gaps.py +115 -0
- package/dist/composer/authoring/generate.py +131 -0
- package/dist/composer/authoring/presets.py +168 -0
- package/dist/composer/authoring/runtime_emit.py +266 -0
- package/dist/composer/authoring/strategy_manifest.py +94 -0
- package/dist/composer/cli.py +487 -0
- package/dist/composer/compiler/__init__.py +22 -0
- package/dist/composer/compiler/compile.py +739 -0
- package/dist/composer/examples/breakout_swing.graph.yaml +121 -0
- package/dist/composer/examples/conviction_scorer.graph.yaml +216 -0
- package/dist/composer/examples/macro_thesis_basket.graph.yaml +247 -0
- package/dist/composer/examples/pre_listing_classifier.graph.yaml +204 -0
- package/dist/composer/examples/trend_momentum_majors.graph.yaml +133 -0
- package/dist/composer/generated/README.md +22 -0
- package/dist/composer/generated/catalog.md +118 -0
- package/dist/composer/generated/describe.md +1168 -0
- package/dist/composer/generated/graph.schema.json +1946 -0
- package/dist/composer/primitives/__init__.py +320 -0
- package/dist/composer/primitives/_candles.py +76 -0
- package/dist/composer/primitives/adapter.py +363 -0
- package/dist/composer/primitives/emitters.py +142 -0
- package/dist/composer/primitives/filters.py +71 -0
- package/dist/composer/primitives/indicators.py +326 -0
- package/dist/composer/primitives/rankers.py +40 -0
- package/dist/composer/primitives/scorers.py +100 -0
- package/dist/composer/primitives/sources.py +979 -0
- package/dist/composer/primitives/stateful.py +254 -0
- package/dist/composer/pure_fn/__init__.py +34 -0
- package/dist/composer/pure_fn/checks.py +39 -0
- package/dist/composer/pure_fn/models.py +177 -0
- package/dist/composer/pure_fn/purity.py +311 -0
- package/dist/composer/pure_fn/runtests.py +155 -0
- package/dist/composer/registry/__init__.py +40 -0
- package/dist/composer/registry/generate.py +425 -0
- package/dist/composer/registry/mcp-tools-snapshot.json +193 -0
- package/dist/composer/registry/mcp_closure.py +311 -0
- package/dist/composer/registry/models.py +417 -0
- package/dist/composer/registry/nodes.py +1297 -0
- package/dist/composer/requirements.txt +21 -0
- package/dist/composer/scaffold_path.py +118 -0
- package/dist/composer/templates/__init__.py +13 -0
- package/dist/composer/templates/scan.py.j2 +34 -0
- package/dist/composer/validator/__init__.py +29 -0
- package/dist/composer/validator/desugar.py +133 -0
- package/dist/composer/validator/errors.py +227 -0
- package/dist/composer/validator/graph_checks.py +443 -0
- package/dist/composer/validator/loader.py +110 -0
- package/dist/composer/validator/schema_checks.py +147 -0
- package/dist/composer/validator/validate.py +86 -0
- package/dist/composer/vendoring.py +103 -0
- package/dist/composer/verbs/__init__.py +49 -0
- package/dist/composer/verbs/_box.py +308 -0
- package/dist/composer/verbs/check.py +254 -0
- package/dist/composer/verbs/close.py +494 -0
- package/dist/composer/verbs/deploy.py +279 -0
- package/dist/composer/verbs/docs.py +30 -0
- package/dist/composer/verbs/fund.py +498 -0
- package/dist/composer/verbs/install.py +586 -0
- package/dist/composer/verbs/runtime_validate.py +422 -0
- package/dist/composer/verbs/smoke.py +134 -0
- package/dist/composer/verbs/status.py +383 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -9
- package/dist/index.js.map +1 -1
- package/dist/runtime/load-runtime-config.d.ts +17 -0
- package/dist/runtime/load-runtime-config.d.ts.map +1 -1
- package/dist/runtime/load-runtime-config.js +2 -2
- package/dist/runtime/load-runtime-config.js.map +1 -1
- package/dist/runtime-api/config.d.ts +2 -2
- package/dist/runtime-api/config.js +4 -4
- package/dist/runtime-api/config.js.map +1 -1
- package/dist/runtime-api/handlers/ingest-signals.d.ts +3 -5
- package/dist/runtime-api/handlers/ingest-signals.d.ts.map +1 -1
- package/dist/runtime-api/handlers/ingest-signals.js +5 -14
- package/dist/runtime-api/handlers/ingest-signals.js.map +1 -1
- package/dist/runtime-api/intake/intake-mount.d.ts.map +1 -1
- package/dist/runtime-api/intake/intake-mount.js +2 -8
- package/dist/runtime-api/intake/intake-mount.js.map +1 -1
- package/dist/skills-manager/manifest.d.ts.map +1 -1
- package/dist/skills-manager/manifest.js +1 -3
- package/dist/skills-manager/manifest.js.map +1 -1
- package/dist/skills-manager/skills-manifest.json +1 -3
- package/package.json +4 -2
- package/dist/runtime-api/intake/ingest-mutex.d.ts +0 -16
- package/dist/runtime-api/intake/ingest-mutex.d.ts.map +0 -1
- package/dist/runtime-api/intake/ingest-mutex.js +0 -18
- 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
|
+
]
|