@senpi/runtime 3.0.9-dev.20260710204324 → 3.0.9-dev.feat-strategy-compos.20260716013009
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 +287 -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 +124 -0
- package/dist/composer/AUTHORING.md +233 -0
- package/dist/composer/__init__.py +29 -0
- package/dist/composer/authoring/__init__.py +49 -0
- package/dist/composer/authoring/answers.py +226 -0
- package/dist/composer/authoring/archetypes/__init__.py +25 -0
- package/dist/composer/authoring/archetypes/_common.py +218 -0
- package/dist/composer/authoring/archetypes/classifier.py +109 -0
- package/dist/composer/authoring/archetypes/skeleton.py +54 -0
- package/dist/composer/authoring/archetypes/thesis_fund.py +103 -0
- package/dist/composer/authoring/archetypes/trend_momentum.py +104 -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 +355 -0
- package/dist/composer/compiler/__init__.py +22 -0
- package/dist/composer/compiler/compile.py +550 -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 +84 -0
- package/dist/composer/generated/describe.md +703 -0
- package/dist/composer/generated/graph.schema.json +1203 -0
- package/dist/composer/primitives/__init__.py +239 -0
- package/dist/composer/primitives/_candles.py +76 -0
- package/dist/composer/primitives/adapter.py +120 -0
- package/dist/composer/primitives/emitters.py +142 -0
- package/dist/composer/primitives/filters.py +71 -0
- package/dist/composer/primitives/indicators.py +203 -0
- package/dist/composer/primitives/rankers.py +40 -0
- package/dist/composer/primitives/scorers.py +100 -0
- package/dist/composer/primitives/sources.py +405 -0
- package/dist/composer/primitives/stateful.py +52 -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 +422 -0
- package/dist/composer/registry/models.py +331 -0
- package/dist/composer/registry/nodes.py +673 -0
- package/dist/composer/requirements.txt +21 -0
- package/dist/composer/templates/__init__.py +13 -0
- package/dist/composer/templates/scan.py.j2 +31 -0
- package/dist/composer/validator/__init__.py +29 -0
- package/dist/composer/validator/desugar.py +133 -0
- package/dist/composer/validator/errors.py +191 -0
- package/dist/composer/validator/graph_checks.py +348 -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/verbs/__init__.py +33 -0
- package/dist/composer/verbs/check.py +193 -0
- package/dist/composer/verbs/deploy.py +223 -0
- package/dist/composer/verbs/docs.py +30 -0
- package/dist/composer/verbs/runtime_validate.py +262 -0
- package/dist/composer/verbs/smoke.py +115 -0
- package/dist/composer/verbs/status.py +205 -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-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.js +1 -1
- package/dist/skills-manager/manifest.js.map +1 -1
- package/dist/skills-manager/skills-manifest.json +1 -1
- 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,177 @@
|
|
|
1
|
+
"""models — the per-graph pure-function node (D2 kind 2).
|
|
2
|
+
|
|
3
|
+
A ``pure_fn`` node is NOT a registry entry. The registry stays the closed vocabulary of
|
|
4
|
+
runtime-owned primitives (D4); ``pure_fn`` is the OPEN extension point — an LLM-written
|
|
5
|
+
Python function with declared, typed input/output ports (drawn from the SAME closed
|
|
6
|
+
:class:`PortType` vocabulary as the registry) and an inline function body. It is declared
|
|
7
|
+
inline in the graph document:
|
|
8
|
+
|
|
9
|
+
- id: score_conviction
|
|
10
|
+
kind: pure_fn
|
|
11
|
+
inputs:
|
|
12
|
+
- { name: trend4h, type: feature_map }
|
|
13
|
+
- { name: momentum, type: feature_map }
|
|
14
|
+
outputs:
|
|
15
|
+
- { name: scores, type: score_map }
|
|
16
|
+
params:
|
|
17
|
+
- { name: min_score, type: float, default: 5 }
|
|
18
|
+
fn: |
|
|
19
|
+
def score_conviction(trend4h, momentum, min_score):
|
|
20
|
+
...
|
|
21
|
+
return scores
|
|
22
|
+
|
|
23
|
+
The declared ports (``inputs``/``outputs``) are what the graph WIRES against and are
|
|
24
|
+
independent of the ``fn`` body — port typechecking, connectivity, and cycle detection all
|
|
25
|
+
read them exactly as they read a registry NodeDef's ports (see ``resolve`` in
|
|
26
|
+
``composer.pure_fn.interfaces``). The ``fn`` body is STATICALLY purity-checked and its
|
|
27
|
+
signature is required to match ``inputs`` + ``params`` exactly (see
|
|
28
|
+
``composer.pure_fn.purity``); inline ``tests`` are run at validation time
|
|
29
|
+
(``composer.pure_fn.runtests``).
|
|
30
|
+
|
|
31
|
+
This module only parses the declaration into a typed object and exposes it through the
|
|
32
|
+
same duck-typed surface a registry NodeDef presents (``.name``, ``.kind``, ``.inputs``,
|
|
33
|
+
``.outputs``, ``.summary``), so the validator's graph checks and the compiler can treat a
|
|
34
|
+
pure_fn node like any other node.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import ast
|
|
40
|
+
from dataclasses import dataclass
|
|
41
|
+
from typing import Optional
|
|
42
|
+
|
|
43
|
+
from composer.registry import NodeKind, Port, PortType
|
|
44
|
+
|
|
45
|
+
# The pure_fn param-type vocabulary mirrors the registry ParamType literals.
|
|
46
|
+
_PARAM_TYPES = {"int", "float", "str", "bool", "list[str]", "map_str_float"}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def is_pure_fn_entry(entry: object) -> bool:
|
|
50
|
+
"""True when a graph node entry declares ``kind: pure_fn`` (the open extension point)."""
|
|
51
|
+
return isinstance(entry, dict) and entry.get("kind") == "pure_fn"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def parse_single_def(src: str) -> Optional[ast.FunctionDef]:
|
|
55
|
+
"""Return the sole top-level ``def`` in ``src`` (ignoring allow-listed imports), else None.
|
|
56
|
+
|
|
57
|
+
Purity (``composer.pure_fn.purity``) is what REJECTS a malformed body with a teaching
|
|
58
|
+
error; this helper is the tolerant reader the compiler uses once a graph is known valid.
|
|
59
|
+
"""
|
|
60
|
+
try:
|
|
61
|
+
tree = ast.parse(src)
|
|
62
|
+
except SyntaxError:
|
|
63
|
+
return None
|
|
64
|
+
defs = [n for n in tree.body if isinstance(n, ast.FunctionDef)]
|
|
65
|
+
return defs[0] if len(defs) == 1 else None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class PureFnParam:
|
|
70
|
+
"""A typed literal parameter passed to the fn by the compiler (config, not an edge)."""
|
|
71
|
+
|
|
72
|
+
name: str
|
|
73
|
+
type: str
|
|
74
|
+
default: object = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class PureFnNode:
|
|
79
|
+
"""A parsed pure_fn node, presenting the registry-NodeDef duck surface.
|
|
80
|
+
|
|
81
|
+
``inputs``/``outputs`` reuse the registry :class:`Port` model so the validator's
|
|
82
|
+
existing port/type/connectivity/cycle logic operates on a pure_fn node unchanged.
|
|
83
|
+
Ports are single-writer, required (``merge=False``, ``required=True``) — a pure_fn
|
|
84
|
+
declares each typed input explicitly (D2), so there is no feature-merge port here.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
id: str
|
|
88
|
+
inputs: tuple[Port, ...]
|
|
89
|
+
outputs: tuple[Port, ...]
|
|
90
|
+
params: tuple[PureFnParam, ...]
|
|
91
|
+
fn_src: str
|
|
92
|
+
tests: tuple[dict, ...] = ()
|
|
93
|
+
line: Optional[int] = None
|
|
94
|
+
|
|
95
|
+
# -- registry-NodeDef duck surface ------------------------------------------------
|
|
96
|
+
@property
|
|
97
|
+
def name(self) -> str:
|
|
98
|
+
return self.id
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def kind(self) -> NodeKind:
|
|
102
|
+
return NodeKind.pure_fn
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def summary(self) -> str:
|
|
106
|
+
return f"pure_fn node (LLM-written {len(self.inputs)}->{len(self.outputs)} function)"
|
|
107
|
+
|
|
108
|
+
# -- fn body ----------------------------------------------------------------------
|
|
109
|
+
def function_def(self) -> Optional[ast.FunctionDef]:
|
|
110
|
+
return parse_single_def(self.fn_src)
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def fn_name(self) -> Optional[str]:
|
|
114
|
+
fd = self.function_def()
|
|
115
|
+
return fd.name if fd is not None else None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _port(entry: dict, *, input_side: bool) -> Optional[Port]:
|
|
119
|
+
"""Build a registry Port from a pure_fn port mapping, or None if the type is not in
|
|
120
|
+
the closed vocabulary (the schema stage already reports that; we skip it here)."""
|
|
121
|
+
if not isinstance(entry, dict):
|
|
122
|
+
return None
|
|
123
|
+
name = entry.get("name")
|
|
124
|
+
type_str = entry.get("type")
|
|
125
|
+
if not isinstance(name, str) or not name.strip():
|
|
126
|
+
return None
|
|
127
|
+
try:
|
|
128
|
+
ptype = PortType(type_str)
|
|
129
|
+
except ValueError:
|
|
130
|
+
return None
|
|
131
|
+
doc = entry.get("doc")
|
|
132
|
+
if not isinstance(doc, str) or not doc.strip():
|
|
133
|
+
doc = f"pure_fn {'input' if input_side else 'output'} {name!r}."
|
|
134
|
+
# pure_fn ports are single-writer, required inputs (no merge point on a pure_fn).
|
|
135
|
+
return Port(name=name, type=ptype, doc=doc, required=True, merge=False)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def parse_pure_fn(entry: dict, line: Optional[int] = None) -> PureFnNode:
|
|
139
|
+
"""Parse a graph node entry (kind: pure_fn) into a :class:`PureFnNode`.
|
|
140
|
+
|
|
141
|
+
Tolerant by design — malformed ports/params are dropped (the schema + the purity/test
|
|
142
|
+
stages carry the teaching errors); this only builds the structure downstream stages
|
|
143
|
+
walk. Ports whose ``type`` is outside the closed vocabulary are skipped.
|
|
144
|
+
"""
|
|
145
|
+
node_id = entry.get("id")
|
|
146
|
+
node_id = node_id if isinstance(node_id, str) else "<pure_fn>"
|
|
147
|
+
|
|
148
|
+
inputs = tuple(
|
|
149
|
+
p for p in (_port(e, input_side=True) for e in (entry.get("inputs") or [])) if p is not None
|
|
150
|
+
)
|
|
151
|
+
outputs = tuple(
|
|
152
|
+
p for p in (_port(e, input_side=False) for e in (entry.get("outputs") or [])) if p is not None
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
params: list[PureFnParam] = []
|
|
156
|
+
for e in entry.get("params") or []:
|
|
157
|
+
if not isinstance(e, dict):
|
|
158
|
+
continue
|
|
159
|
+
name = e.get("name")
|
|
160
|
+
ptype = e.get("type")
|
|
161
|
+
if isinstance(name, str) and ptype in _PARAM_TYPES:
|
|
162
|
+
params.append(PureFnParam(name=name, type=ptype, default=e.get("default")))
|
|
163
|
+
|
|
164
|
+
fn_src = entry.get("fn")
|
|
165
|
+
fn_src = fn_src if isinstance(fn_src, str) else ""
|
|
166
|
+
|
|
167
|
+
tests = tuple(t for t in (entry.get("tests") or []) if isinstance(t, dict))
|
|
168
|
+
|
|
169
|
+
return PureFnNode(
|
|
170
|
+
id=node_id,
|
|
171
|
+
inputs=inputs,
|
|
172
|
+
outputs=outputs,
|
|
173
|
+
params=tuple(params),
|
|
174
|
+
fn_src=fn_src,
|
|
175
|
+
tests=tests,
|
|
176
|
+
line=line,
|
|
177
|
+
)
|
|
@@ -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
|
+
]
|