@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.
Files changed (94) 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 +287 -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 +124 -0
  9. package/dist/composer/AUTHORING.md +233 -0
  10. package/dist/composer/__init__.py +29 -0
  11. package/dist/composer/authoring/__init__.py +49 -0
  12. package/dist/composer/authoring/answers.py +226 -0
  13. package/dist/composer/authoring/archetypes/__init__.py +25 -0
  14. package/dist/composer/authoring/archetypes/_common.py +218 -0
  15. package/dist/composer/authoring/archetypes/classifier.py +109 -0
  16. package/dist/composer/authoring/archetypes/skeleton.py +54 -0
  17. package/dist/composer/authoring/archetypes/thesis_fund.py +103 -0
  18. package/dist/composer/authoring/archetypes/trend_momentum.py +104 -0
  19. package/dist/composer/authoring/builder.py +191 -0
  20. package/dist/composer/authoring/emit.py +264 -0
  21. package/dist/composer/authoring/gaps.py +115 -0
  22. package/dist/composer/authoring/generate.py +131 -0
  23. package/dist/composer/authoring/presets.py +168 -0
  24. package/dist/composer/authoring/runtime_emit.py +266 -0
  25. package/dist/composer/authoring/strategy_manifest.py +94 -0
  26. package/dist/composer/cli.py +355 -0
  27. package/dist/composer/compiler/__init__.py +22 -0
  28. package/dist/composer/compiler/compile.py +550 -0
  29. package/dist/composer/examples/breakout_swing.graph.yaml +121 -0
  30. package/dist/composer/examples/conviction_scorer.graph.yaml +216 -0
  31. package/dist/composer/examples/macro_thesis_basket.graph.yaml +247 -0
  32. package/dist/composer/examples/pre_listing_classifier.graph.yaml +204 -0
  33. package/dist/composer/examples/trend_momentum_majors.graph.yaml +133 -0
  34. package/dist/composer/generated/README.md +22 -0
  35. package/dist/composer/generated/catalog.md +84 -0
  36. package/dist/composer/generated/describe.md +703 -0
  37. package/dist/composer/generated/graph.schema.json +1203 -0
  38. package/dist/composer/primitives/__init__.py +239 -0
  39. package/dist/composer/primitives/_candles.py +76 -0
  40. package/dist/composer/primitives/adapter.py +120 -0
  41. package/dist/composer/primitives/emitters.py +142 -0
  42. package/dist/composer/primitives/filters.py +71 -0
  43. package/dist/composer/primitives/indicators.py +203 -0
  44. package/dist/composer/primitives/rankers.py +40 -0
  45. package/dist/composer/primitives/scorers.py +100 -0
  46. package/dist/composer/primitives/sources.py +405 -0
  47. package/dist/composer/primitives/stateful.py +52 -0
  48. package/dist/composer/pure_fn/__init__.py +34 -0
  49. package/dist/composer/pure_fn/checks.py +39 -0
  50. package/dist/composer/pure_fn/models.py +177 -0
  51. package/dist/composer/pure_fn/purity.py +311 -0
  52. package/dist/composer/pure_fn/runtests.py +155 -0
  53. package/dist/composer/registry/__init__.py +40 -0
  54. package/dist/composer/registry/generate.py +422 -0
  55. package/dist/composer/registry/models.py +331 -0
  56. package/dist/composer/registry/nodes.py +673 -0
  57. package/dist/composer/requirements.txt +21 -0
  58. package/dist/composer/templates/__init__.py +13 -0
  59. package/dist/composer/templates/scan.py.j2 +31 -0
  60. package/dist/composer/validator/__init__.py +29 -0
  61. package/dist/composer/validator/desugar.py +133 -0
  62. package/dist/composer/validator/errors.py +191 -0
  63. package/dist/composer/validator/graph_checks.py +348 -0
  64. package/dist/composer/validator/loader.py +110 -0
  65. package/dist/composer/validator/schema_checks.py +147 -0
  66. package/dist/composer/validator/validate.py +86 -0
  67. package/dist/composer/verbs/__init__.py +33 -0
  68. package/dist/composer/verbs/check.py +193 -0
  69. package/dist/composer/verbs/deploy.py +223 -0
  70. package/dist/composer/verbs/docs.py +30 -0
  71. package/dist/composer/verbs/runtime_validate.py +262 -0
  72. package/dist/composer/verbs/smoke.py +115 -0
  73. package/dist/composer/verbs/status.py +205 -0
  74. package/dist/index.d.ts.map +1 -1
  75. package/dist/index.js +2 -9
  76. package/dist/index.js.map +1 -1
  77. package/dist/runtime-api/config.d.ts +2 -2
  78. package/dist/runtime-api/config.js +4 -4
  79. package/dist/runtime-api/config.js.map +1 -1
  80. package/dist/runtime-api/handlers/ingest-signals.d.ts +3 -5
  81. package/dist/runtime-api/handlers/ingest-signals.d.ts.map +1 -1
  82. package/dist/runtime-api/handlers/ingest-signals.js +5 -14
  83. package/dist/runtime-api/handlers/ingest-signals.js.map +1 -1
  84. package/dist/runtime-api/intake/intake-mount.d.ts.map +1 -1
  85. package/dist/runtime-api/intake/intake-mount.js +2 -8
  86. package/dist/runtime-api/intake/intake-mount.js.map +1 -1
  87. package/dist/skills-manager/manifest.js +1 -1
  88. package/dist/skills-manager/manifest.js.map +1 -1
  89. package/dist/skills-manager/skills-manifest.json +1 -1
  90. package/package.json +4 -2
  91. package/dist/runtime-api/intake/ingest-mutex.d.ts +0 -16
  92. package/dist/runtime-api/intake/ingest-mutex.d.ts.map +0 -1
  93. package/dist/runtime-api/intake/ingest-mutex.js +0 -18
  94. 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
+ ]