@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,254 @@
|
|
|
1
|
+
"""stateful — the ctx.state-backed primitives (D2). Three nodes:
|
|
2
|
+
|
|
3
|
+
* ``ttl_dedup_state`` — the universal TTL signal-dedup map (all five trend/breakout
|
|
4
|
+
packages). Ported from stag/hawk ``scan.scan``.
|
|
5
|
+
* ``snapshot_diff_state`` — a rolling window of per-key numeric snapshots exposing the
|
|
6
|
+
growth (latest−oldest) between them. Ported from whalehunter
|
|
7
|
+
``scoring.update_ledger`` (per-trader-cohort equity/net growth
|
|
8
|
+
over ~10 daily buckets).
|
|
9
|
+
* ``cohort_cache_state`` — cache an expensive upstream fetch across ticks behind a TTL +
|
|
10
|
+
config version, pinning a STABLE cohort for hours and surviving
|
|
11
|
+
restarts. Ported from whalehunter ``scan._build_cohorts``.
|
|
12
|
+
|
|
13
|
+
Each node is compiled onto ``ctx.state`` and SPLIT into a READ half (prologue) and a WRITE
|
|
14
|
+
half (epilogue): the read exposes PRIOR-tick data (the state boundary) BEFORE the pipeline
|
|
15
|
+
runs, and the write records THIS tick's data AFTER it. That is exactly the "stateful outputs
|
|
16
|
+
carry prior-tick data" semantic the validator's cycle check honours — an edge leaving a
|
|
17
|
+
stateful output is not a forward dependency, so wiring a stateful output into the body and a
|
|
18
|
+
body value into its write half never trips CMP035.
|
|
19
|
+
|
|
20
|
+
MULTIPLE stateful nodes per graph (whalehunter runs all three) each append their OWN
|
|
21
|
+
self-contained, single-key fragment to the shared ctx.state series every tick
|
|
22
|
+
(``{"recent": …}`` / ``{"snapshots": …}`` / ``{"cohort": …}``). A read therefore cannot use
|
|
23
|
+
``state.last()`` blindly — the last append may belong to a SIBLING node — so it scans the
|
|
24
|
+
series newest→oldest for the most-recent entry carrying its own key (``_read_key``). Each
|
|
25
|
+
fragment is self-contained (the whole working map/list), so state-history eviction only drops
|
|
26
|
+
stale fragments; the newest per-key fragment always wins and restart resumes from it.
|
|
27
|
+
|
|
28
|
+
STATE IS BOUNDED (an unbounded fleet-box state series is a real ops hazard). ttl_dedup prunes
|
|
29
|
+
beyond ``prune_factor × ttl``; snapshot_diff hard-caps at ``max_snapshots`` buckets;
|
|
30
|
+
cohort_cache stores one cohort. The StateManager deque bound (``state_history_max_count``) is a
|
|
31
|
+
second backstop.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _read_key(state, key):
|
|
38
|
+
"""Most-recent state entry carrying ``key``, scanning the series newest→oldest.
|
|
39
|
+
|
|
40
|
+
Prefers a backward scan (the real StateManager is iterable oldest→newest) so multiple
|
|
41
|
+
stateful nodes can each append a single-key fragment per tick and still find their own
|
|
42
|
+
most-recent write. Falls back to ``state.last()`` for a minimal state object that supports
|
|
43
|
+
neither iteration nor indexing (the unit-test fakes). Returns the value, or None."""
|
|
44
|
+
if state is None or len(state) == 0:
|
|
45
|
+
return None
|
|
46
|
+
try:
|
|
47
|
+
entries = list(state)
|
|
48
|
+
except TypeError:
|
|
49
|
+
entries = None
|
|
50
|
+
if entries is not None:
|
|
51
|
+
for entry in reversed(entries):
|
|
52
|
+
if isinstance(entry, dict) and key in entry:
|
|
53
|
+
return entry[key]
|
|
54
|
+
return None
|
|
55
|
+
last = state.last() or {}
|
|
56
|
+
return last.get(key) if isinstance(last, dict) else None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ============================================================ ttl_dedup_state (dedup map)
|
|
60
|
+
|
|
61
|
+
def _last_map(state) -> dict:
|
|
62
|
+
recent = _read_key(state, "recent")
|
|
63
|
+
return dict(recent) if isinstance(recent, dict) else {}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def ttl_dedup_state_read(state, *, ttl_seconds: int, prune_factor: int, now: float) -> tuple[list, dict]:
|
|
67
|
+
"""READ half: prune the persisted dedup map and return (recent, working_map).
|
|
68
|
+
|
|
69
|
+
``recent`` (the ``recent`` output port, asset_list) = assets signaled within
|
|
70
|
+
``ttl_seconds``. ``working_map`` = the pruned {asset: ts} map, carried to the WRITE
|
|
71
|
+
half (entries older than ``prune_factor * ttl_seconds`` are dropped).
|
|
72
|
+
"""
|
|
73
|
+
cutoff = now - (ttl_seconds * prune_factor)
|
|
74
|
+
working = {k.upper(): v for k, v in _last_map(state).items() if v >= cutoff}
|
|
75
|
+
recent = sorted(a for a, ts in working.items() if (now - ts) < ttl_seconds)
|
|
76
|
+
return recent, working
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def ttl_dedup_state_write(state, working_map: dict, *, emitted: list, now: float) -> None:
|
|
80
|
+
"""WRITE half (state_out port): record this tick's emitted signals and persist.
|
|
81
|
+
|
|
82
|
+
Stamps each emitted asset with ``now`` in the working map and appends the updated
|
|
83
|
+
map to ctx.state. A None state (history disabled) is a no-op. The scaffold owns the
|
|
84
|
+
per-tick commit/rollback around scan().
|
|
85
|
+
"""
|
|
86
|
+
for sig in emitted:
|
|
87
|
+
asset = str(sig.get("asset", "")).upper()
|
|
88
|
+
if asset:
|
|
89
|
+
working_map[asset] = now
|
|
90
|
+
if state is not None:
|
|
91
|
+
state.append({"recent": working_map})
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ===================================================== snapshot_diff_state (rolling window)
|
|
96
|
+
# Port of whalehunter scoring.update_ledger (scoring.py:73-88): maintain a bounded rolling
|
|
97
|
+
# window of per-key numeric snapshots across ticks and expose the growth between the current
|
|
98
|
+
# (latest-stored) snapshot and the oldest one still in the window. whalehunter tracks each smart
|
|
99
|
+
# cohort's per-coin NET positioning across ~10 UTC-daily buckets to derive the "adding daily"
|
|
100
|
+
# signal; the generalization keeps its COUNT bound (max_snapshots ← whalehunter keep=10) and its
|
|
101
|
+
# per-bucket OVERWRITE (bucket_seconds ← whalehunter's per-UTC-day ledger key, made clock-free).
|
|
102
|
+
#
|
|
103
|
+
# HONEST BOUNDARY (documented in the node doc + AUTHORING §7): the growth is PRIOR-tick — it
|
|
104
|
+
# compares the latest STORED snapshot to the oldest, not this tick's fresh input (which the read
|
|
105
|
+
# half cannot see; it is appended by the write half). This is the exact ttl_dedup read/write
|
|
106
|
+
# split; the one-tick lag is negligible against a multi-bucket (multi-day) window and needs a
|
|
107
|
+
# short warmup (growth is empty until ≥2 buckets exist), just as whalehunter's requireGrowing
|
|
108
|
+
# blocks until ~day 2.
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _load_snapshots(state) -> list:
|
|
112
|
+
snaps = _read_key(state, "snapshots")
|
|
113
|
+
if not isinstance(snaps, list):
|
|
114
|
+
return []
|
|
115
|
+
out = []
|
|
116
|
+
for s in snaps:
|
|
117
|
+
if isinstance(s, (list, tuple)) and len(s) == 2 and isinstance(s[1], dict):
|
|
118
|
+
out.append([float(s[0]), {str(k): v for k, v in s[1].items()}])
|
|
119
|
+
return out
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _extract_values(snapshot, value_field: str) -> dict:
|
|
123
|
+
"""A current snapshot (a feature_map {key: value}) -> {key: float}. When ``value_field`` is
|
|
124
|
+
set the value is a per-key record and we read that numeric sub-field (whalehunter tracks the
|
|
125
|
+
``net`` field of aggregate_bias's per-coin record); "" reads the value as a float directly.
|
|
126
|
+
Non-numeric / missing -> the key is dropped from this snapshot (never a crash)."""
|
|
127
|
+
out: dict = {}
|
|
128
|
+
if not isinstance(snapshot, dict):
|
|
129
|
+
return out
|
|
130
|
+
for k in sorted(snapshot):
|
|
131
|
+
v = snapshot[k]
|
|
132
|
+
if value_field:
|
|
133
|
+
v = v.get(value_field) if isinstance(v, dict) else None
|
|
134
|
+
try:
|
|
135
|
+
out[str(k)] = float(v)
|
|
136
|
+
except (TypeError, ValueError):
|
|
137
|
+
continue
|
|
138
|
+
return out
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def snapshot_diff_state_read(
|
|
142
|
+
state, *, value_field: str, bucket_seconds: int, max_snapshots: int, now: float
|
|
143
|
+
) -> tuple[dict, list, list]:
|
|
144
|
+
"""READ half: load + hard-cap the persisted snapshot window and expose the growth.
|
|
145
|
+
|
|
146
|
+
Returns (growth, deltas, working):
|
|
147
|
+
* ``growth`` (feature_map output) = {key: latest_stored − oldest_stored} — the "adding"
|
|
148
|
+
signal whalehunter's cohort scoring reads via ``growth.get(coin)``. Empty until ≥2
|
|
149
|
+
buckets exist (day-1 warmup).
|
|
150
|
+
* ``deltas`` (record_list output) = per-key {key, current, oldest, growth} rows (sorted),
|
|
151
|
+
the richer detail for a pure_fn that wants the absolute values behind the delta.
|
|
152
|
+
* ``working`` = the (count-bounded) snapshot list carried to the WRITE half.
|
|
153
|
+
|
|
154
|
+
THE STATE BOUND: keep only the most-recent ``max_snapshots`` buckets (prune the oldest
|
|
155
|
+
beyond the cap) — state can never exceed max_snapshots × keys.
|
|
156
|
+
"""
|
|
157
|
+
working = _load_snapshots(state)
|
|
158
|
+
if max_snapshots > 0 and len(working) > max_snapshots:
|
|
159
|
+
working = working[-max_snapshots:]
|
|
160
|
+
growth: dict = {}
|
|
161
|
+
deltas: list = []
|
|
162
|
+
if working:
|
|
163
|
+
oldest = working[0][1]
|
|
164
|
+
latest = working[-1][1]
|
|
165
|
+
for key in sorted(set(oldest) | set(latest)):
|
|
166
|
+
cur = float(latest.get(key, 0.0))
|
|
167
|
+
old = float(oldest.get(key, 0.0))
|
|
168
|
+
g = round(cur - old, 6)
|
|
169
|
+
growth[key] = g
|
|
170
|
+
deltas.append({"key": key, "current": cur, "oldest": old, "growth": g})
|
|
171
|
+
return growth, deltas, working
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def snapshot_diff_state_write(
|
|
175
|
+
state, working: list, *, snapshot, value_field: str, bucket_seconds: int,
|
|
176
|
+
max_snapshots: int, now: float,
|
|
177
|
+
) -> None:
|
|
178
|
+
"""WRITE half (state_out port): fold THIS tick's snapshot into the window and persist.
|
|
179
|
+
|
|
180
|
+
Within ``bucket_seconds`` of the newest bucket the current bucket is OVERWRITTEN (whalehunter
|
|
181
|
+
overwrites today's UTC-day ledger entry on every intraday tick); once it has elapsed a NEW
|
|
182
|
+
bucket opens. Then re-apply the ``max_snapshots`` cap. A None state is a no-op."""
|
|
183
|
+
values = _extract_values(snapshot, value_field)
|
|
184
|
+
if working and bucket_seconds > 0 and (now - working[-1][0]) < bucket_seconds:
|
|
185
|
+
working[-1] = [working[-1][0], values]
|
|
186
|
+
else:
|
|
187
|
+
working.append([now, values])
|
|
188
|
+
if max_snapshots > 0 and len(working) > max_snapshots:
|
|
189
|
+
del working[: len(working) - max_snapshots]
|
|
190
|
+
if state is not None:
|
|
191
|
+
state.append({"snapshots": working})
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ======================================================= cohort_cache_state (pinned cohort)
|
|
196
|
+
# Port of whalehunter scan._build_cohorts (scan.py:36-85): cache the expensive paged top-trader
|
|
197
|
+
# fetch across ticks behind a TTL + a config VERSION, so an unstable churning cohort does not
|
|
198
|
+
# poison the multi-day ledger. whalehunter's real need is COHORT STABILITY (a cohort pinned for
|
|
199
|
+
# ~24h that survives restarts), not fetch-rate-limiting.
|
|
200
|
+
#
|
|
201
|
+
# HONEST BOUNDARY (documented in the node doc + AUTHORING §7): sources fire EVERY tick — the
|
|
202
|
+
# compiler emits the upstream fetch unconditionally and the scaffold has no conditional-fetch
|
|
203
|
+
# machinery, so this node does NOT skip the fetch (that would need compiler conditional-emission,
|
|
204
|
+
# out of scope). It implements REUSE-AND-REFRESH: the read half serves the last-known-good cohort
|
|
205
|
+
# from ctx.state (pinned + smoothing fetch gaps/failures), and the write half refreshes the stored
|
|
206
|
+
# cohort only when the TTL lapsed, the version changed, or the cache was empty — and only when the
|
|
207
|
+
# fresh fetch actually returned rows (a failed fetch keeps the old cohort, exactly like
|
|
208
|
+
# whalehunter). ctx.state (not a module-level cache) is used precisely because it SURVIVES RESTARTS
|
|
209
|
+
# — whalehunter's stated requirement; a per-process adapter cache would not.
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _load_cohort(state) -> dict:
|
|
213
|
+
cohort = _read_key(state, "cohort")
|
|
214
|
+
return dict(cohort) if isinstance(cohort, dict) else {}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def cohort_cache_state_read(
|
|
218
|
+
state, *, ttl_hours: float, version: int, now: float
|
|
219
|
+
) -> tuple[list, dict]:
|
|
220
|
+
"""READ half: serve the pinned last-known-good cohort (reuse).
|
|
221
|
+
|
|
222
|
+
Returns (cohort, cached): ``cohort`` (the record_list output) is the stored cohort rows
|
|
223
|
+
whenever the stored config ``version`` still matches (a version change invalidates it — empty
|
|
224
|
+
until the write half rebuilds). ``cached`` is the raw stored dict carried to the WRITE half so
|
|
225
|
+
it can make the refresh decision. Cold start (no cache) serves []; downstream then reads an
|
|
226
|
+
empty cohort for one warmup tick while the write half populates it."""
|
|
227
|
+
cached = _load_cohort(state)
|
|
228
|
+
records = cached.get("records", [])
|
|
229
|
+
active = list(records) if (cached.get("version") == version and isinstance(records, list)) else []
|
|
230
|
+
return active, cached
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def cohort_cache_state_write(
|
|
234
|
+
state, cached: dict, *, fresh, ttl_hours: float, version: int, now: float
|
|
235
|
+
) -> None:
|
|
236
|
+
"""WRITE half (state_out port): refresh the stored cohort when stale, else keep it pinned.
|
|
237
|
+
|
|
238
|
+
Refresh (adopt ``fresh``) only when we HAVE a non-empty fresh fetch AND (the cache is empty OR
|
|
239
|
+
the config version changed OR the TTL lapsed). Otherwise keep the stored cohort — this is what
|
|
240
|
+
smooths a transient fetch failure and pins the cohort for ``ttl_hours``. A None state is a
|
|
241
|
+
no-op."""
|
|
242
|
+
have_fresh = isinstance(fresh, list) and len(fresh) > 0
|
|
243
|
+
stale = (now - float(cached.get("refreshed_at", 0.0))) > (ttl_hours * 3600.0)
|
|
244
|
+
version_changed = cached.get("version") != version
|
|
245
|
+
empty = not cached.get("records")
|
|
246
|
+
if have_fresh and (empty or version_changed or stale):
|
|
247
|
+
new = {"records": list(fresh), "version": version, "refreshed_at": now}
|
|
248
|
+
elif cached:
|
|
249
|
+
new = cached
|
|
250
|
+
else:
|
|
251
|
+
new = {"records": [], "version": version, "refreshed_at": 0.0}
|
|
252
|
+
if state is not None:
|
|
253
|
+
state.append({"cohort": new})
|
|
254
|
+
return None
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""pure_fn — per-graph pure-function nodes (D2 kind 2): the OPEN extension point.
|
|
2
|
+
|
|
3
|
+
The registry (``composer.registry``) is the CLOSED vocabulary of runtime-owned primitive
|
|
4
|
+
nodes. A ``pure_fn`` node is the complement: an LLM-written Python function declared inline
|
|
5
|
+
in the graph, with declared typed input/output ports (same closed :class:`PortType`
|
|
6
|
+
vocabulary) and a function body. Full code flexibility is preserved while the telemetry
|
|
7
|
+
loop learns which functions to promote to primitives (D2), but it is fenced by a static
|
|
8
|
+
purity gate — no ctx, no MCP, no I/O — so it can never be a custom data source (D3).
|
|
9
|
+
|
|
10
|
+
Package surface (kept to the leaf ``models`` so importing this package never pulls the
|
|
11
|
+
validator — the purity/test stages live in submodules imported directly where used, to
|
|
12
|
+
avoid an import cycle with ``composer.validator``):
|
|
13
|
+
|
|
14
|
+
models — PureFnNode / PureFnParam / parse_pure_fn / is_pure_fn_entry.
|
|
15
|
+
purity (submodule) — check_purity: the static AST allowlist (CMP04x teaching errors).
|
|
16
|
+
runtests (submodule) — run_inline_tests: run inline fixture tests (CMP05x).
|
|
17
|
+
checks (submodule) — check: the validator stage tying purity + tests together.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from .models import (
|
|
21
|
+
PureFnNode,
|
|
22
|
+
PureFnParam,
|
|
23
|
+
is_pure_fn_entry,
|
|
24
|
+
parse_pure_fn,
|
|
25
|
+
parse_single_def,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"PureFnNode",
|
|
30
|
+
"PureFnParam",
|
|
31
|
+
"parse_pure_fn",
|
|
32
|
+
"parse_single_def",
|
|
33
|
+
"is_pure_fn_entry",
|
|
34
|
+
]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""checks — the validator stage for pure_fn nodes (purity CMP04x + inline tests CMP05x).
|
|
2
|
+
|
|
3
|
+
Runs AFTER the graph-semantic checks. For each ``kind: pure_fn`` node it statically
|
|
4
|
+
purity-checks the body (``purity.check_purity``) and, only if the body is pure, runs its
|
|
5
|
+
inline fixture tests (``runtests.run_inline_tests``). Errors from every node collect into
|
|
6
|
+
one list so the author sees all defects at once (the SERR037 collection rule).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from composer.validator.errors import TeachingError
|
|
12
|
+
|
|
13
|
+
from .models import is_pure_fn_entry, parse_pure_fn
|
|
14
|
+
from .purity import check_purity
|
|
15
|
+
from .runtests import run_inline_tests
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def check(doc: dict, line_index: dict) -> list[TeachingError]:
|
|
19
|
+
errors: list[TeachingError] = []
|
|
20
|
+
nodes = doc.get("nodes")
|
|
21
|
+
if not isinstance(nodes, list):
|
|
22
|
+
return errors
|
|
23
|
+
|
|
24
|
+
used_fn_names: set[str] = set()
|
|
25
|
+
for entry in nodes:
|
|
26
|
+
if not is_pure_fn_entry(entry):
|
|
27
|
+
continue
|
|
28
|
+
node_id = entry.get("id")
|
|
29
|
+
line = line_index.get("nodes", {}).get(node_id) if isinstance(node_id, str) else None
|
|
30
|
+
pfn = parse_pure_fn(entry, line)
|
|
31
|
+
|
|
32
|
+
purity_errors = check_purity(pfn, used_fn_names)
|
|
33
|
+
errors.extend(purity_errors)
|
|
34
|
+
# Only run the fixtures once the body is known pure — otherwise the failures are
|
|
35
|
+
# noise on top of the real (purity) defect.
|
|
36
|
+
if not purity_errors:
|
|
37
|
+
errors.extend(run_inline_tests(pfn))
|
|
38
|
+
|
|
39
|
+
return errors
|
|
@@ -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
|
+
)
|