@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,739 @@
|
|
|
1
|
+
"""compile — the deterministic materializer: graph -> straight-line scan.py (D5, D8).
|
|
2
|
+
|
|
3
|
+
Pipeline (HLD D8, no IR, no optimization):
|
|
4
|
+
|
|
5
|
+
validate (reuse composer.validator) -> a hard gate; refuse to compile an invalid
|
|
6
|
+
graph, surfacing the teaching-error report.
|
|
7
|
+
load + desugar -> canonical nodes + edges (the graph IS the IR).
|
|
8
|
+
topo-sort (non-stateful nodes) -> a stable straight-line order.
|
|
9
|
+
Jinja2 template walk -> a single self-contained-except-primitives
|
|
10
|
+
scan.py on the external_scanner contract.
|
|
11
|
+
|
|
12
|
+
Determinism is a hard requirement (the recompile oracle): stable node iteration
|
|
13
|
+
(topo order with an id tie-break), sorted keys in every emitted literal, no timestamps
|
|
14
|
+
in the artifact. Same graph -> byte-identical scan.py.
|
|
15
|
+
|
|
16
|
+
Stateful nodes are compiled onto ctx.state and SPLIT into a prologue (read prior-tick
|
|
17
|
+
state -> the `recent` exclusion set) and an epilogue (append this tick's emitted
|
|
18
|
+
signals). Data-source nodes compile onto the ctx MCP surface via the thin
|
|
19
|
+
``primitives.adapter`` (the golden-test stubbing seam). Emitted code is readable:
|
|
20
|
+
every block is commented with its node id.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import ast
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from jinja2 import Environment, FileSystemLoader
|
|
29
|
+
|
|
30
|
+
from composer.pure_fn import is_pure_fn_entry, parse_pure_fn
|
|
31
|
+
from composer.registry import NODES_BY_NAME, NodeKind, PortType
|
|
32
|
+
from composer.validator import validate_text
|
|
33
|
+
from composer.validator.desugar import desugar, has_pipeline
|
|
34
|
+
from composer.validator.loader import load_graph
|
|
35
|
+
|
|
36
|
+
_TEMPLATE_DIR = Path(__file__).resolve().parent.parent / "templates"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class CompileError(Exception):
|
|
40
|
+
"""Raised when a graph cannot be compiled (invalid graph, or a structural gap)."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# --------------------------------------------------------------------------- literals
|
|
44
|
+
|
|
45
|
+
def _pyliteral(value) -> str:
|
|
46
|
+
"""Deterministic Python-literal rendering: dict keys sorted, tuples -> lists."""
|
|
47
|
+
if isinstance(value, dict):
|
|
48
|
+
items = ", ".join(f"{_pyliteral(k)}: {_pyliteral(v)}" for k, v in sorted(value.items()))
|
|
49
|
+
return "{" + items + "}"
|
|
50
|
+
if isinstance(value, (list, tuple)):
|
|
51
|
+
return "[" + ", ".join(_pyliteral(v) for v in value) + "]"
|
|
52
|
+
if isinstance(value, bool):
|
|
53
|
+
return "True" if value else "False"
|
|
54
|
+
return repr(value)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _sanitize(node_id: str) -> str:
|
|
58
|
+
return "".join(ch if (ch.isalnum() or ch == "_") else "_" for ch in node_id)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _var(node_id: str, port: str) -> str:
|
|
62
|
+
return f"{_sanitize(node_id)}__{port}"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# --------------------------------------------------------------------------- model
|
|
66
|
+
|
|
67
|
+
class _Graph:
|
|
68
|
+
"""The parsed, validated graph in the shape the emitter walks."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, doc: dict):
|
|
71
|
+
self.nodes = doc.get("nodes") or []
|
|
72
|
+
self.edges = doc.get("edges") or []
|
|
73
|
+
self.metadata = doc.get("metadata") or {}
|
|
74
|
+
self.id_to_type: dict[str, str] = {}
|
|
75
|
+
self.with_of: dict[str, dict] = {}
|
|
76
|
+
self.pure_fns: dict[str, object] = {} # id -> PureFnNode (open extension, D2 kind 2)
|
|
77
|
+
for n in self.nodes:
|
|
78
|
+
nid = n["id"]
|
|
79
|
+
if is_pure_fn_entry(n):
|
|
80
|
+
self.pure_fns[nid] = parse_pure_fn(n)
|
|
81
|
+
else:
|
|
82
|
+
self.id_to_type[nid] = n["node"]
|
|
83
|
+
self.with_of[nid] = n.get("with") or {}
|
|
84
|
+
# incoming[(to_id, to_port)] = [(from_id, from_port), ...]
|
|
85
|
+
self.incoming: dict[tuple[str, str], list[tuple[str, str]]] = {}
|
|
86
|
+
for e in self.edges:
|
|
87
|
+
fid, fport = e["from"].split(".", 1)
|
|
88
|
+
tid, tport = e["to"].split(".", 1)
|
|
89
|
+
self.incoming.setdefault((tid, tport), []).append((fid, fport))
|
|
90
|
+
|
|
91
|
+
def nodedef(self, node_id: str):
|
|
92
|
+
if node_id in self.pure_fns:
|
|
93
|
+
return self.pure_fns[node_id]
|
|
94
|
+
return NODES_BY_NAME[self.id_to_type[node_id]]
|
|
95
|
+
|
|
96
|
+
def param(self, node_id: str, param_name: str):
|
|
97
|
+
"""The `with:` value for a param, else its (registry / pure_fn) default."""
|
|
98
|
+
if node_id in self.pure_fns:
|
|
99
|
+
for p in self.pure_fns[node_id].params:
|
|
100
|
+
if p.name == param_name:
|
|
101
|
+
return p.default
|
|
102
|
+
return None
|
|
103
|
+
w = self.with_of[node_id]
|
|
104
|
+
if param_name in w:
|
|
105
|
+
return w[param_name]
|
|
106
|
+
for p in self.nodedef(node_id).params:
|
|
107
|
+
if p.name == param_name:
|
|
108
|
+
return p.default
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
def base_universe(self) -> list:
|
|
112
|
+
"""The candles_fetch whitelist (there is one in the POC graphs), for the
|
|
113
|
+
_BASE_UNIVERSE constant and unwired asset_list defaults."""
|
|
114
|
+
for nid, ntype in self.id_to_type.items():
|
|
115
|
+
if ntype == "candles_fetch":
|
|
116
|
+
wl = self.param(nid, "whitelist") or ()
|
|
117
|
+
return [str(a).upper() for a in wl]
|
|
118
|
+
return []
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# --------------------------------------------------------------------------- topo sort
|
|
122
|
+
|
|
123
|
+
def _topo_order(g: _Graph) -> list[str]:
|
|
124
|
+
"""Deterministic topo order over the NON-stateful nodes.
|
|
125
|
+
|
|
126
|
+
Edges out of a stateful node carry prior-tick data (its `recent` output is
|
|
127
|
+
prologue-provided), and edges into a stateful node are handled in the epilogue, so
|
|
128
|
+
neither constrains the body order. Ties broken by node id for byte-stability.
|
|
129
|
+
"""
|
|
130
|
+
body = [n["id"] for n in g.nodes if g.nodedef(n["id"]).kind != NodeKind.stateful]
|
|
131
|
+
body_set = set(body)
|
|
132
|
+
deps: dict[str, set] = {nid: set() for nid in body}
|
|
133
|
+
for (tid, _tport), writers in g.incoming.items():
|
|
134
|
+
if tid not in body_set:
|
|
135
|
+
continue
|
|
136
|
+
for fid, _fport in writers:
|
|
137
|
+
if fid in body_set and g.nodedef(fid).kind != NodeKind.stateful:
|
|
138
|
+
deps[tid].add(fid)
|
|
139
|
+
order: list[str] = []
|
|
140
|
+
done: set = set()
|
|
141
|
+
remaining = set(body)
|
|
142
|
+
while remaining:
|
|
143
|
+
ready = sorted(nid for nid in remaining if deps[nid] <= done)
|
|
144
|
+
if not ready:
|
|
145
|
+
raise CompileError(f"cycle among body nodes: {sorted(remaining)}")
|
|
146
|
+
pick = ready[0]
|
|
147
|
+
order.append(pick)
|
|
148
|
+
done.add(pick)
|
|
149
|
+
remaining.discard(pick)
|
|
150
|
+
return order
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# --------------------------------------------------------------------------- inputs
|
|
154
|
+
|
|
155
|
+
def _resolve_input(g: _Graph, node_id: str, port) -> str:
|
|
156
|
+
"""A Python expression for the value flowing into ``node_id.port.name``."""
|
|
157
|
+
writers = g.incoming.get((node_id, port.name), [])
|
|
158
|
+
if port.merge:
|
|
159
|
+
# merge_features keyed by SOURCE NODE ID (see scorers.merge_features).
|
|
160
|
+
items = ", ".join(
|
|
161
|
+
f"{_pyliteral(fid)}: {_var(fid, fport)}"
|
|
162
|
+
for fid, fport in sorted(writers)
|
|
163
|
+
)
|
|
164
|
+
return "{" + items + "}"
|
|
165
|
+
if writers:
|
|
166
|
+
fid, fport = writers[0]
|
|
167
|
+
return _var(fid, fport)
|
|
168
|
+
# unwired optional input
|
|
169
|
+
if port.type == PortType.asset_list:
|
|
170
|
+
return "base_universe"
|
|
171
|
+
return "None"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# --------------------------------------------------------------------------- emitters
|
|
175
|
+
|
|
176
|
+
def _kwargs(g: _Graph, node_id: str, param_names) -> str:
|
|
177
|
+
parts = []
|
|
178
|
+
for name in param_names:
|
|
179
|
+
parts.append(f"{name}={_pyliteral(g.param(node_id, name))}")
|
|
180
|
+
return ", ".join(parts)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _emit_pure_fn(g: _Graph, node_id: str) -> list[str]:
|
|
184
|
+
"""Materialize a pure_fn node: its declared `def` verbatim (readable, node-id
|
|
185
|
+
commented), then a call binding its declared outputs. The def is emitted nested inside
|
|
186
|
+
scan() at the node's topological position and called like any other node. Determinism
|
|
187
|
+
is preserved: the body is emitted byte-for-byte as authored and args are resolved in
|
|
188
|
+
declared port/param order.
|
|
189
|
+
"""
|
|
190
|
+
pfn = g.pure_fns[node_id]
|
|
191
|
+
fd = pfn.function_def()
|
|
192
|
+
fn_name = fd.name # purity guaranteed exactly one def by the time we compile
|
|
193
|
+
lines = [
|
|
194
|
+
f"# node: {node_id} (pure_fn) — LLM-written, purity-checked "
|
|
195
|
+
f"(no ctx/MCP/I/O; math/statistics only)",
|
|
196
|
+
]
|
|
197
|
+
# the authored def, verbatim (each line is indented +4 by the template -> nested).
|
|
198
|
+
lines.extend(pfn.fn_src.strip("\n").split("\n"))
|
|
199
|
+
port_exprs = [_resolve_input(g, node_id, p) for p in pfn.inputs]
|
|
200
|
+
param_vals = [_pyliteral(g.param(node_id, p.name)) for p in pfn.params]
|
|
201
|
+
args = ", ".join(port_exprs + param_vals)
|
|
202
|
+
outs = ", ".join(_var(node_id, p.name) for p in pfn.outputs)
|
|
203
|
+
lines.append(f"{outs} = {fn_name}({args})")
|
|
204
|
+
return lines
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _emit_body_node(g: _Graph, node_id: str) -> list[str]:
|
|
208
|
+
nd = g.nodedef(node_id)
|
|
209
|
+
if nd.kind == NodeKind.pure_fn:
|
|
210
|
+
return _emit_pure_fn(g, node_id)
|
|
211
|
+
ntype = nd.name
|
|
212
|
+
lines = [f"# node: {node_id} ({ntype}) — {nd.summary}"]
|
|
213
|
+
|
|
214
|
+
if ntype == "candles_fetch":
|
|
215
|
+
assets_input = next(p for p in nd.inputs if p.name == "assets")
|
|
216
|
+
assets_expr = _resolve_input(g, node_id, assets_input)
|
|
217
|
+
intervals = list(g.param(node_id, "intervals") or ())
|
|
218
|
+
inc_f = bool(g.param(node_id, "include_funding"))
|
|
219
|
+
inc_ac = bool(g.param(node_id, "include_asset_context"))
|
|
220
|
+
raw = f"_raw_{_sanitize(node_id)}"
|
|
221
|
+
lines.append(
|
|
222
|
+
f"{raw} = {{_a: src.asset_data(_a, {_pyliteral(intervals)}, "
|
|
223
|
+
f"include_funding={_pyliteral(inc_f)}, "
|
|
224
|
+
f"include_asset_context={_pyliteral(inc_ac)}) for _a in {assets_expr}}}"
|
|
225
|
+
)
|
|
226
|
+
lines.append(
|
|
227
|
+
f"{_var(node_id, 'candles')} = sources.candles_fetch({raw}, "
|
|
228
|
+
f"intervals={_pyliteral(intervals)})"
|
|
229
|
+
)
|
|
230
|
+
return lines
|
|
231
|
+
|
|
232
|
+
if ntype == "clearinghouse_state":
|
|
233
|
+
lines.append(
|
|
234
|
+
f"{_var(node_id, 'held')}, {_var(node_id, 'equity')} = "
|
|
235
|
+
f"sources.clearinghouse_state(src.clearinghouse())"
|
|
236
|
+
)
|
|
237
|
+
return lines
|
|
238
|
+
|
|
239
|
+
if ntype == "sm_lean":
|
|
240
|
+
assets_input = next(p for p in nd.inputs if p.name == "assets")
|
|
241
|
+
assets_expr = _resolve_input(g, node_id, assets_input)
|
|
242
|
+
thr = g.param(node_id, "agree_threshold")
|
|
243
|
+
lines.append(
|
|
244
|
+
f"{_var(node_id, 'lean')} = sources.sm_lean(src.leaderboard_markets(), "
|
|
245
|
+
f"assets={assets_expr}, agree_threshold={_pyliteral(thr)})"
|
|
246
|
+
)
|
|
247
|
+
return lines
|
|
248
|
+
|
|
249
|
+
if ntype == "instrument_list":
|
|
250
|
+
dex = g.param(node_id, "dex") or ""
|
|
251
|
+
lines.append(
|
|
252
|
+
f"{_var(node_id, 'instruments')} = sources.instrument_list("
|
|
253
|
+
f"src.instruments(dex={_pyliteral(dex)}))"
|
|
254
|
+
)
|
|
255
|
+
return lines
|
|
256
|
+
|
|
257
|
+
if ntype == "universe_discover":
|
|
258
|
+
insts = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "instruments"))
|
|
259
|
+
kw = _kwargs(g, node_id, ["min_day_volume", "max_leverage_cap", "name_prefix"])
|
|
260
|
+
lines.append(
|
|
261
|
+
f"{_var(node_id, 'universe')} = sources.universe_discover({insts}, {kw})"
|
|
262
|
+
)
|
|
263
|
+
return lines
|
|
264
|
+
|
|
265
|
+
if ntype == "own_return_24h":
|
|
266
|
+
candles_expr = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "candles"))
|
|
267
|
+
lines.append(
|
|
268
|
+
f"{_var(node_id, 'return_24h')} = sources.own_return_24h({candles_expr})"
|
|
269
|
+
)
|
|
270
|
+
return lines
|
|
271
|
+
|
|
272
|
+
# ---- batch C: world-coverage sources (recon §2) ---------------------------------
|
|
273
|
+
if ntype == "market_prices_fetch":
|
|
274
|
+
assets_expr = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "assets"))
|
|
275
|
+
dex = g.param(node_id, "dex") or ""
|
|
276
|
+
lines.append(
|
|
277
|
+
f"{_var(node_id, 'prices')} = sources.market_prices("
|
|
278
|
+
f"src.market_prices(dex={_pyliteral(dex)}), assets={assets_expr})"
|
|
279
|
+
)
|
|
280
|
+
return lines
|
|
281
|
+
|
|
282
|
+
if ntype == "strategy_list_fetch":
|
|
283
|
+
status = g.param(node_id, "status") or ""
|
|
284
|
+
stype = g.param(node_id, "strategy_type") or ""
|
|
285
|
+
lines.append(
|
|
286
|
+
f"{_var(node_id, 'strategies')} = sources.strategy_list("
|
|
287
|
+
f"src.strategy_list(status={_pyliteral(status)}, strategy_type={_pyliteral(stype)}))"
|
|
288
|
+
)
|
|
289
|
+
return lines
|
|
290
|
+
|
|
291
|
+
if ntype == "open_orders_fetch":
|
|
292
|
+
lines.append(
|
|
293
|
+
f"{_var(node_id, 'orders')} = sources.open_orders(src.open_orders())"
|
|
294
|
+
)
|
|
295
|
+
return lines
|
|
296
|
+
|
|
297
|
+
if ntype == "trader_history_fetch":
|
|
298
|
+
ta = g.param(node_id, "trader_address")
|
|
299
|
+
limit = g.param(node_id, "limit")
|
|
300
|
+
lines.append(
|
|
301
|
+
f"{_var(node_id, 'closed_positions')} = sources.trader_history("
|
|
302
|
+
f"src.trader_history(trader_address={_pyliteral(ta)}, limit={_pyliteral(limit)}))"
|
|
303
|
+
)
|
|
304
|
+
return lines
|
|
305
|
+
|
|
306
|
+
if ntype == "asset_trading_limits_fetch":
|
|
307
|
+
assets_expr = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "assets"))
|
|
308
|
+
dex = g.param(node_id, "dex") or ""
|
|
309
|
+
raw = f"_raw_{_sanitize(node_id)}"
|
|
310
|
+
lines.append(
|
|
311
|
+
f"{raw} = {{_a: src.asset_trading_limits(_a, dex={_pyliteral(dex)}) "
|
|
312
|
+
f"for _a in {assets_expr}}}"
|
|
313
|
+
)
|
|
314
|
+
lines.append(
|
|
315
|
+
f"{_var(node_id, 'max_leverage')} = sources.asset_trading_limits({raw})"
|
|
316
|
+
)
|
|
317
|
+
return lines
|
|
318
|
+
|
|
319
|
+
if ntype == "strategy_pnl_snapshot":
|
|
320
|
+
period = g.param(node_id, "period") or ""
|
|
321
|
+
dd = bool(g.param(node_id, "drawdown_reset_on_day_rollover"))
|
|
322
|
+
lines.append(
|
|
323
|
+
f"{_var(node_id, 'today_pnl')}, {_var(node_id, 'today_account_value')}, "
|
|
324
|
+
f"{_var(node_id, 'today_drawdown')} = sources.strategy_pnl_snapshot("
|
|
325
|
+
f"src.pnl_snapshot(period={_pyliteral(period)}, "
|
|
326
|
+
f"drawdown_reset_on_day_rollover={_pyliteral(dd)}))"
|
|
327
|
+
)
|
|
328
|
+
return lines
|
|
329
|
+
|
|
330
|
+
if ntype == "trader_state_fetch":
|
|
331
|
+
ta = g.param(node_id, "trader_address")
|
|
332
|
+
lines.append(
|
|
333
|
+
f"{_var(node_id, 'held')}, {_var(node_id, 'equity')} = "
|
|
334
|
+
f"sources.trader_state(src.trader_state(trader_address={_pyliteral(ta)}))"
|
|
335
|
+
)
|
|
336
|
+
return lines
|
|
337
|
+
|
|
338
|
+
# ---- wave A: remaining world-coverage sources (2026-07-16) ----------------------
|
|
339
|
+
if ntype == "funding_regime_fetch":
|
|
340
|
+
lines.append(
|
|
341
|
+
f"{_var(node_id, 'regime')}, {_var(node_id, 'companions')} = "
|
|
342
|
+
f"sources.funding_regime(src.funding_regime())"
|
|
343
|
+
)
|
|
344
|
+
return lines
|
|
345
|
+
|
|
346
|
+
if ntype == "top_traders_fetch":
|
|
347
|
+
kw = _kwargs(g, node_id, ["time_frame", "sort_by", "bias", "limit", "max_pages",
|
|
348
|
+
"open_position_filter", "activity_labels", "consistency",
|
|
349
|
+
"risk_labels"])
|
|
350
|
+
lines.append(
|
|
351
|
+
f"{_var(node_id, 'traders')} = sources.top_traders(src.top_traders({kw}))"
|
|
352
|
+
)
|
|
353
|
+
return lines
|
|
354
|
+
|
|
355
|
+
if ntype == "trader_states_fetch":
|
|
356
|
+
records_expr = _resolve_input(
|
|
357
|
+
g, node_id, next(p for p in nd.inputs if p.name == "traders"))
|
|
358
|
+
kw = _kwargs(g, node_id, ["wallets", "latest", "include_position_age",
|
|
359
|
+
"sort_open_positions_by", "sort_open_positions_direction"])
|
|
360
|
+
lines.append(
|
|
361
|
+
f"{_var(node_id, 'traders')} = sources.trader_states("
|
|
362
|
+
f"src.trader_states(records={records_expr}, {kw}))"
|
|
363
|
+
)
|
|
364
|
+
return lines
|
|
365
|
+
|
|
366
|
+
if ntype == "funding_history_fetch":
|
|
367
|
+
kw = _kwargs(g, node_id, ["asset", "min_annualized_pct", "min_persistence_hours"])
|
|
368
|
+
lines.append(
|
|
369
|
+
f"{_var(node_id, 'assets')} = sources.funding_history(src.funding_history({kw}))"
|
|
370
|
+
)
|
|
371
|
+
return lines
|
|
372
|
+
|
|
373
|
+
if ntype == "cross_asset_flows_fetch":
|
|
374
|
+
kw = _kwargs(g, node_id, ["leader_asset", "min_move_pct", "window"])
|
|
375
|
+
lines.append(
|
|
376
|
+
f"{_var(node_id, 'laggards')}, {_var(node_id, 'leader')} = "
|
|
377
|
+
f"sources.cross_asset_flows(src.cross_asset_flows({kw}))"
|
|
378
|
+
)
|
|
379
|
+
return lines
|
|
380
|
+
|
|
381
|
+
if ntype == "trader_positions_fetch":
|
|
382
|
+
tid = g.param(node_id, "trader_id")
|
|
383
|
+
lines.append(
|
|
384
|
+
f"{_var(node_id, 'positions')}, {_var(node_id, 'header')} = "
|
|
385
|
+
f"sources.trader_positions(src.trader_positions(trader_id={_pyliteral(tid)}))"
|
|
386
|
+
)
|
|
387
|
+
return lines
|
|
388
|
+
|
|
389
|
+
# ---- wave B: the last scanner-usable world-coverage sources (2026-07-16) --------
|
|
390
|
+
if ntype == "momentum_events_fetch":
|
|
391
|
+
kw = _kwargs(g, node_id, ["limit", "tier", "asset", "from_time", "to_time"])
|
|
392
|
+
lines.append(
|
|
393
|
+
f"{_var(node_id, 'events')}, {_var(node_id, 'header')} = "
|
|
394
|
+
f"sources.momentum_events(src.momentum_events({kw}))"
|
|
395
|
+
)
|
|
396
|
+
return lines
|
|
397
|
+
|
|
398
|
+
if ntype == "top_strategies_fetch":
|
|
399
|
+
kw = _kwargs(g, node_id, ["limit", "offset", "force_fetch", "max_pages"])
|
|
400
|
+
lines.append(
|
|
401
|
+
f"{_var(node_id, 'strategies')} = sources.top_strategies(src.top_strategies({kw}))"
|
|
402
|
+
)
|
|
403
|
+
return lines
|
|
404
|
+
|
|
405
|
+
if ntype == "arena_leaderboard_fetch":
|
|
406
|
+
kw = _kwargs(g, node_id, ["period_type", "period_number", "limit", "offset", "qualified"])
|
|
407
|
+
lines.append(
|
|
408
|
+
f"{_var(node_id, 'entries')} = sources.arena_leaderboard(src.arena_leaderboard({kw}))"
|
|
409
|
+
)
|
|
410
|
+
return lines
|
|
411
|
+
|
|
412
|
+
if ntype == "leaderboard_top_fetch":
|
|
413
|
+
kw = _kwargs(g, node_id, ["limit"])
|
|
414
|
+
lines.append(
|
|
415
|
+
f"{_var(node_id, 'traders')}, {_var(node_id, 'header')} = "
|
|
416
|
+
f"sources.leaderboard_top(src.leaderboard_top({kw}))"
|
|
417
|
+
)
|
|
418
|
+
return lines
|
|
419
|
+
|
|
420
|
+
if ntype == "leaderboard_trader_fetch":
|
|
421
|
+
tid = g.param(node_id, "trader_id")
|
|
422
|
+
lines.append(
|
|
423
|
+
f"{_var(node_id, 'trader')} = "
|
|
424
|
+
f"sources.leaderboard_trader(src.leaderboard_trader(trader_id={_pyliteral(tid)}))"
|
|
425
|
+
)
|
|
426
|
+
return lines
|
|
427
|
+
|
|
428
|
+
if ntype == "open_positions_realized_pnl_fetch":
|
|
429
|
+
records_expr = _resolve_input(
|
|
430
|
+
g, node_id, next(p for p in nd.inputs if p.name == "positions"))
|
|
431
|
+
kw = _kwargs(g, node_id, ["latest"])
|
|
432
|
+
lines.append(
|
|
433
|
+
f"{_var(node_id, 'realized')} = sources.open_positions_realized_pnl("
|
|
434
|
+
f"src.open_positions_realized_pnl(records={records_expr}, {kw}))"
|
|
435
|
+
)
|
|
436
|
+
return lines
|
|
437
|
+
|
|
438
|
+
if ntype == "account_history_fetch":
|
|
439
|
+
kw = _kwargs(g, node_id, ["bucket_duration_ms"])
|
|
440
|
+
lines.append(
|
|
441
|
+
f"{_var(node_id, 'periods')} = sources.account_history(src.account_history({kw}))"
|
|
442
|
+
)
|
|
443
|
+
return lines
|
|
444
|
+
|
|
445
|
+
if ntype == "mcp_read":
|
|
446
|
+
# the escape hatch: a generic passthrough read. tool + verbatim args (snake_case) ->
|
|
447
|
+
# the adapter unwraps the standard envelope and returns the raw `data` payload, which a
|
|
448
|
+
# pure_fn normalizer downstream turns back into a typed shape.
|
|
449
|
+
tool = g.param(node_id, "tool")
|
|
450
|
+
args = g.param(node_id, "args") or {}
|
|
451
|
+
timeout = g.param(node_id, "timeout_seconds")
|
|
452
|
+
tail = ""
|
|
453
|
+
if isinstance(timeout, (int, float)) and not isinstance(timeout, bool) and timeout > 0:
|
|
454
|
+
tail = f", timeout_seconds={_pyliteral(timeout)}"
|
|
455
|
+
lines.append(
|
|
456
|
+
f"{_var(node_id, 'payload')} = src.raw_read("
|
|
457
|
+
f"{_pyliteral(tool)}, {_pyliteral(args)}{tail})"
|
|
458
|
+
)
|
|
459
|
+
return lines
|
|
460
|
+
|
|
461
|
+
if ntype == "threshold_gate":
|
|
462
|
+
# feature_map -> feature_map (not candles): its `values` input is wired from another
|
|
463
|
+
# feature node's output (answers indicators[].source), so it can't use the generic
|
|
464
|
+
# candles-fed indicator path below.
|
|
465
|
+
values_expr = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "values"))
|
|
466
|
+
kw = _kwargs(g, node_id, ["op", "value"])
|
|
467
|
+
lines.append(f"{_var(node_id, 'passed')} = indicators.threshold_gate({values_expr}, {kw})")
|
|
468
|
+
return lines
|
|
469
|
+
|
|
470
|
+
if ntype == "aggregate_bias":
|
|
471
|
+
# record_list -> feature_map (not candles): its `traders` input is a cohort's TraderState
|
|
472
|
+
# records (from trader_states_fetch), so it can't use the generic candles-fed path below.
|
|
473
|
+
traders_expr = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "traders"))
|
|
474
|
+
lines.append(f"{_var(node_id, 'bias')} = indicators.aggregate_bias({traders_expr})")
|
|
475
|
+
return lines
|
|
476
|
+
|
|
477
|
+
if nd.kind == NodeKind.indicator:
|
|
478
|
+
candles_expr = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "candles"))
|
|
479
|
+
outs = ", ".join(_var(node_id, p.name) for p in nd.outputs)
|
|
480
|
+
kw = _kwargs(g, node_id, [p.name for p in nd.params])
|
|
481
|
+
lines.append(f"{outs} = indicators.{ntype}({candles_expr}, {kw})")
|
|
482
|
+
return lines
|
|
483
|
+
|
|
484
|
+
if ntype == "held_asset_exclusion":
|
|
485
|
+
cand = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "candidates"))
|
|
486
|
+
held = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "held"))
|
|
487
|
+
lines.append(
|
|
488
|
+
f"{_var(node_id, 'kept')} = filters.held_asset_exclusion("
|
|
489
|
+
f"candidates={cand}, held={held})"
|
|
490
|
+
)
|
|
491
|
+
return lines
|
|
492
|
+
|
|
493
|
+
if ntype == "recent_signal_ttl_dedup":
|
|
494
|
+
cand = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "candidates"))
|
|
495
|
+
recent = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "recent"))
|
|
496
|
+
lines.append(
|
|
497
|
+
f"{_var(node_id, 'kept')} = filters.recent_signal_ttl_dedup("
|
|
498
|
+
f"candidates={cand}, recent={recent})"
|
|
499
|
+
)
|
|
500
|
+
return lines
|
|
501
|
+
|
|
502
|
+
if ntype == "classifier_predicate":
|
|
503
|
+
cand = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "candidates"))
|
|
504
|
+
kw = _kwargs(g, node_id, ["max_funding_abs", "max_leverage", "exclude_delisted"])
|
|
505
|
+
lines.append(
|
|
506
|
+
f"{_var(node_id, 'kept')} = filters.classifier_predicate(candidates={cand}, {kw})"
|
|
507
|
+
)
|
|
508
|
+
return lines
|
|
509
|
+
|
|
510
|
+
if ntype == "live_instrument_validation":
|
|
511
|
+
cand = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "candidates"))
|
|
512
|
+
insts = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "instruments"))
|
|
513
|
+
lines.append(
|
|
514
|
+
f"{_var(node_id, 'kept')} = filters.live_instrument_validation("
|
|
515
|
+
f"candidates={cand}, instruments={insts})"
|
|
516
|
+
)
|
|
517
|
+
return lines
|
|
518
|
+
|
|
519
|
+
if ntype == "hard_gates_all":
|
|
520
|
+
feats = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "features"))
|
|
521
|
+
merged = f"_features_{_sanitize(node_id)}"
|
|
522
|
+
kw = _kwargs(g, node_id, ["required_gates", "weights", "min_score"])
|
|
523
|
+
lines.append(f"{merged} = scorers.merge_features({feats})")
|
|
524
|
+
lines.append(f"{_var(node_id, 'scores')} = scorers.hard_gates_all({merged}, {kw})")
|
|
525
|
+
return lines
|
|
526
|
+
|
|
527
|
+
if ntype == "emit_single_best":
|
|
528
|
+
scores = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "scores"))
|
|
529
|
+
kw = _kwargs(g, node_id, ["min_score"])
|
|
530
|
+
lines.append(f"{_var(node_id, 'selected')} = rankers.emit_single_best({scores}, {kw})")
|
|
531
|
+
return lines
|
|
532
|
+
|
|
533
|
+
if ntype == "rank_pool_cap_to_open_slots":
|
|
534
|
+
scores = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "scores"))
|
|
535
|
+
held = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "held"))
|
|
536
|
+
kw = _kwargs(g, node_id, ["max_slots", "min_score"])
|
|
537
|
+
lines.append(
|
|
538
|
+
f"{_var(node_id, 'selected')} = rankers.rank_pool_cap_to_open_slots("
|
|
539
|
+
f"{scores}, held={held}, {kw})"
|
|
540
|
+
)
|
|
541
|
+
return lines
|
|
542
|
+
|
|
543
|
+
if ntype == "signal_envelope":
|
|
544
|
+
selected = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "selected"))
|
|
545
|
+
equity = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "equity"))
|
|
546
|
+
kw = _kwargs(g, node_id, ["margin_pct", "leverage", "clamp_leverage_to_venue"])
|
|
547
|
+
# Optional parity kwargs are appended ONLY when used, so graphs that set none of them
|
|
548
|
+
# emit byte-identically to the pre-batch-D compiler (existing goldens unchanged):
|
|
549
|
+
# - instruments (live board) is emitted only when a graph wires it (the real clamp);
|
|
550
|
+
# - valid_for_seconds only when a positive TTL override is set;
|
|
551
|
+
# - signal_type only when a non-empty label is set.
|
|
552
|
+
extra: list[str] = []
|
|
553
|
+
if g.incoming.get((node_id, "instruments")):
|
|
554
|
+
insts = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "instruments"))
|
|
555
|
+
extra.append(f"instruments={insts}")
|
|
556
|
+
vfs = g.param(node_id, "valid_for_seconds")
|
|
557
|
+
if isinstance(vfs, int) and not isinstance(vfs, bool) and vfs > 0:
|
|
558
|
+
extra.append(f"valid_for_seconds={_pyliteral(vfs)}")
|
|
559
|
+
st = g.param(node_id, "signal_type")
|
|
560
|
+
if isinstance(st, str) and st:
|
|
561
|
+
extra.append(f"signal_type={_pyliteral(st)}")
|
|
562
|
+
tail = (", " + ", ".join(extra)) if extra else ""
|
|
563
|
+
lines.append(
|
|
564
|
+
f"{_var(node_id, 'signals')} = emitters.signal_envelope({selected}, "
|
|
565
|
+
f"equity={equity}, {kw}{tail})"
|
|
566
|
+
)
|
|
567
|
+
return lines
|
|
568
|
+
|
|
569
|
+
raise CompileError(
|
|
570
|
+
f"node {node_id!r} has type {ntype!r} which the compiler cannot emit. Every registry "
|
|
571
|
+
f"primitive is emittable; bespoke scoring/classification is not a primitive — express "
|
|
572
|
+
f"that edge as a pure_fn node, as bison/magpie/thesis-fund do."
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _emit_stateful_prologue(g: _Graph, node_id: str) -> list[str]:
|
|
577
|
+
"""READ half of a stateful node (runs BEFORE the body): load prior-tick state and expose the
|
|
578
|
+
node's prior-tick output ports + a working-state var carried to the epilogue. Dispatched by
|
|
579
|
+
node name; each stateful node appends its own self-contained single-key fragment to the shared
|
|
580
|
+
ctx.state series, so multiple stateful nodes coexist (stateful.py `_read_key` scans back)."""
|
|
581
|
+
nd = g.nodedef(node_id)
|
|
582
|
+
read = [f"# node: {node_id} ({nd.name}) [read] — {nd.summary}"]
|
|
583
|
+
|
|
584
|
+
if nd.name == "ttl_dedup_state":
|
|
585
|
+
kw = _kwargs(g, node_id, ["ttl_seconds", "prune_factor"])
|
|
586
|
+
statemap = f"_statemap_{_sanitize(node_id)}"
|
|
587
|
+
read.append(
|
|
588
|
+
f"{_var(node_id, 'recent')}, {statemap} = stateful.ttl_dedup_state_read("
|
|
589
|
+
f"ctx.state, {kw}, now=_now)")
|
|
590
|
+
return read
|
|
591
|
+
|
|
592
|
+
if nd.name == "snapshot_diff_state":
|
|
593
|
+
kw = _kwargs(g, node_id, ["value_field", "bucket_seconds", "max_snapshots"])
|
|
594
|
+
working = f"_snapwindow_{_sanitize(node_id)}"
|
|
595
|
+
read.append(
|
|
596
|
+
f"{_var(node_id, 'growth')}, {_var(node_id, 'deltas')}, {working} = "
|
|
597
|
+
f"stateful.snapshot_diff_state_read(ctx.state, {kw}, now=_now)")
|
|
598
|
+
return read
|
|
599
|
+
|
|
600
|
+
if nd.name == "cohort_cache_state":
|
|
601
|
+
kw = _kwargs(g, node_id, ["ttl_hours", "version"])
|
|
602
|
+
working = f"_cohortcache_{_sanitize(node_id)}"
|
|
603
|
+
read.append(
|
|
604
|
+
f"{_var(node_id, 'cohort')}, {working} = "
|
|
605
|
+
f"stateful.cohort_cache_state_read(ctx.state, {kw}, now=_now)")
|
|
606
|
+
return read
|
|
607
|
+
|
|
608
|
+
raise CompileError(f"stateful node {node_id!r} has unknown type {nd.name!r} (no prologue emitter)")
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _emit_stateful_epilogue(g: _Graph, node_id: str) -> list[str]:
|
|
612
|
+
"""WRITE half of a stateful node (runs AFTER the body): fold THIS tick's late input into the
|
|
613
|
+
working state and persist a self-contained fragment. Dispatched by node name."""
|
|
614
|
+
nd = g.nodedef(node_id)
|
|
615
|
+
|
|
616
|
+
if nd.name == "ttl_dedup_state":
|
|
617
|
+
emitted = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "emitted"))
|
|
618
|
+
statemap = f"_statemap_{_sanitize(node_id)}"
|
|
619
|
+
return [
|
|
620
|
+
f"# node: {node_id} ({nd.name}) [write] — record this tick's signals in ctx.state",
|
|
621
|
+
f"stateful.ttl_dedup_state_write(ctx.state, {statemap}, emitted={emitted}, now=_now)",
|
|
622
|
+
]
|
|
623
|
+
|
|
624
|
+
if nd.name == "snapshot_diff_state":
|
|
625
|
+
snapshot = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "snapshot"))
|
|
626
|
+
kw = _kwargs(g, node_id, ["value_field", "bucket_seconds", "max_snapshots"])
|
|
627
|
+
working = f"_snapwindow_{_sanitize(node_id)}"
|
|
628
|
+
return [
|
|
629
|
+
f"# node: {node_id} ({nd.name}) [write] — fold this tick's snapshot into ctx.state",
|
|
630
|
+
f"stateful.snapshot_diff_state_write(ctx.state, {working}, snapshot={snapshot}, "
|
|
631
|
+
f"{kw}, now=_now)",
|
|
632
|
+
]
|
|
633
|
+
|
|
634
|
+
if nd.name == "cohort_cache_state":
|
|
635
|
+
fresh = _resolve_input(g, node_id, next(p for p in nd.inputs if p.name == "fresh"))
|
|
636
|
+
kw = _kwargs(g, node_id, ["ttl_hours", "version"])
|
|
637
|
+
working = f"_cohortcache_{_sanitize(node_id)}"
|
|
638
|
+
return [
|
|
639
|
+
f"# node: {node_id} ({nd.name}) [write] — refresh-or-pin the cohort in ctx.state",
|
|
640
|
+
f"stateful.cohort_cache_state_write(ctx.state, {working}, fresh={fresh}, "
|
|
641
|
+
f"{kw}, now=_now)",
|
|
642
|
+
]
|
|
643
|
+
|
|
644
|
+
raise CompileError(f"stateful node {node_id!r} has unknown type {nd.name!r} (no epilogue emitter)")
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
# --------------------------------------------------------------------------- driver
|
|
648
|
+
|
|
649
|
+
def _pure_fn_imports(g: _Graph) -> list[str]:
|
|
650
|
+
"""The sorted set of allow-listed modules (math/statistics) any pure_fn body uses."""
|
|
651
|
+
allowed = {"math", "statistics"}
|
|
652
|
+
used: set[str] = set()
|
|
653
|
+
for pfn in g.pure_fns.values():
|
|
654
|
+
fd = pfn.function_def()
|
|
655
|
+
if fd is None:
|
|
656
|
+
continue
|
|
657
|
+
for node in ast.walk(fd):
|
|
658
|
+
if isinstance(node, ast.Name) and node.id in allowed:
|
|
659
|
+
used.add(node.id)
|
|
660
|
+
return sorted(used)
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _return_var(g: _Graph) -> str:
|
|
664
|
+
emitters_ids = [nid for nid in g.id_to_type if g.nodedef(nid).kind == NodeKind.emitter]
|
|
665
|
+
if len(emitters_ids) != 1:
|
|
666
|
+
raise CompileError(
|
|
667
|
+
f"a graph must have exactly one emitter (the scan() return); got {emitters_ids}"
|
|
668
|
+
)
|
|
669
|
+
return _var(emitters_ids[0], "signals")
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def compile_text(text: str, source_name: str = "<graph>") -> str:
|
|
673
|
+
"""Validate then compile graph YAML text into scan.py source (a str)."""
|
|
674
|
+
result = validate_text(text, source=source_name)
|
|
675
|
+
if not result.ok:
|
|
676
|
+
raise CompileError(
|
|
677
|
+
f"refusing to compile an invalid graph ({source_name}):\n{result.render()}"
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
data, _line_index = load_graph(text)
|
|
681
|
+
if has_pipeline(data):
|
|
682
|
+
data, _errs = desugar(data)
|
|
683
|
+
g = _Graph(data)
|
|
684
|
+
|
|
685
|
+
stateful_ids = sorted(
|
|
686
|
+
nid for nid in g.id_to_type if g.nodedef(nid).kind == NodeKind.stateful
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
prologue: list[str] = []
|
|
690
|
+
for sid in stateful_ids:
|
|
691
|
+
prologue.extend(_emit_stateful_prologue(g, sid))
|
|
692
|
+
|
|
693
|
+
body: list[str] = []
|
|
694
|
+
for nid in _topo_order(g):
|
|
695
|
+
block = _emit_body_node(g, nid)
|
|
696
|
+
if body:
|
|
697
|
+
body.append("")
|
|
698
|
+
body.extend(block)
|
|
699
|
+
|
|
700
|
+
epilogue: list[str] = []
|
|
701
|
+
for sid in stateful_ids:
|
|
702
|
+
if epilogue:
|
|
703
|
+
epilogue.append("")
|
|
704
|
+
epilogue.extend(_emit_stateful_epilogue(g, sid))
|
|
705
|
+
|
|
706
|
+
# Allow-listed modules any pure_fn body references (math / statistics) become
|
|
707
|
+
# module-level imports so the nested pure_fn defs can reach them. Sorted for
|
|
708
|
+
# determinism; empty (and thus absent) for pure-primitive graphs like stag/hawk.
|
|
709
|
+
extra_imports = _pure_fn_imports(g)
|
|
710
|
+
|
|
711
|
+
env = Environment(
|
|
712
|
+
loader=FileSystemLoader(str(_TEMPLATE_DIR)),
|
|
713
|
+
trim_blocks=True,
|
|
714
|
+
lstrip_blocks=True,
|
|
715
|
+
keep_trailing_newline=True,
|
|
716
|
+
)
|
|
717
|
+
template = env.get_template("scan.py.j2")
|
|
718
|
+
node_ids = ", ".join(n["id"] for n in g.nodes)
|
|
719
|
+
rendered = template.render(
|
|
720
|
+
source_name=source_name,
|
|
721
|
+
strategy=g.metadata.get("strategy", "unnamed"),
|
|
722
|
+
archetype=g.metadata.get("archetype", "unknown"),
|
|
723
|
+
node_count=len(g.nodes),
|
|
724
|
+
node_ids=node_ids,
|
|
725
|
+
base_universe=_pyliteral(g.base_universe()),
|
|
726
|
+
extra_imports=extra_imports,
|
|
727
|
+
prologue=prologue,
|
|
728
|
+
body=body,
|
|
729
|
+
epilogue=epilogue,
|
|
730
|
+
return_var=_return_var(g),
|
|
731
|
+
)
|
|
732
|
+
# normalize any trailing whitespace introduced by the template loop, keep 1 newline.
|
|
733
|
+
lines = [ln.rstrip() for ln in rendered.split("\n")]
|
|
734
|
+
return "\n".join(lines).rstrip("\n") + "\n"
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def compile_file(path, source_name: str | None = None) -> str:
|
|
738
|
+
p = Path(path)
|
|
739
|
+
return compile_text(p.read_text(encoding="utf-8"), source_name or p.name)
|