@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.
Files changed (108) 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 +396 -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 +190 -0
  9. package/dist/composer/AUTHORING.md +435 -0
  10. package/dist/composer/__init__.py +29 -0
  11. package/dist/composer/authoring/__init__.py +59 -0
  12. package/dist/composer/authoring/answers.py +297 -0
  13. package/dist/composer/authoring/archetypes/__init__.py +26 -0
  14. package/dist/composer/authoring/archetypes/_common.py +410 -0
  15. package/dist/composer/authoring/archetypes/breakout.py +134 -0
  16. package/dist/composer/authoring/archetypes/classifier.py +109 -0
  17. package/dist/composer/authoring/archetypes/skeleton.py +76 -0
  18. package/dist/composer/authoring/archetypes/thesis_fund.py +103 -0
  19. package/dist/composer/authoring/archetypes/trend_momentum.py +132 -0
  20. package/dist/composer/authoring/builder.py +191 -0
  21. package/dist/composer/authoring/emit.py +264 -0
  22. package/dist/composer/authoring/gaps.py +115 -0
  23. package/dist/composer/authoring/generate.py +131 -0
  24. package/dist/composer/authoring/presets.py +168 -0
  25. package/dist/composer/authoring/runtime_emit.py +266 -0
  26. package/dist/composer/authoring/strategy_manifest.py +94 -0
  27. package/dist/composer/cli.py +487 -0
  28. package/dist/composer/compiler/__init__.py +22 -0
  29. package/dist/composer/compiler/compile.py +739 -0
  30. package/dist/composer/examples/breakout_swing.graph.yaml +121 -0
  31. package/dist/composer/examples/conviction_scorer.graph.yaml +216 -0
  32. package/dist/composer/examples/macro_thesis_basket.graph.yaml +247 -0
  33. package/dist/composer/examples/pre_listing_classifier.graph.yaml +204 -0
  34. package/dist/composer/examples/trend_momentum_majors.graph.yaml +133 -0
  35. package/dist/composer/generated/README.md +22 -0
  36. package/dist/composer/generated/catalog.md +118 -0
  37. package/dist/composer/generated/describe.md +1168 -0
  38. package/dist/composer/generated/graph.schema.json +1946 -0
  39. package/dist/composer/primitives/__init__.py +320 -0
  40. package/dist/composer/primitives/_candles.py +76 -0
  41. package/dist/composer/primitives/adapter.py +363 -0
  42. package/dist/composer/primitives/emitters.py +142 -0
  43. package/dist/composer/primitives/filters.py +71 -0
  44. package/dist/composer/primitives/indicators.py +326 -0
  45. package/dist/composer/primitives/rankers.py +40 -0
  46. package/dist/composer/primitives/scorers.py +100 -0
  47. package/dist/composer/primitives/sources.py +979 -0
  48. package/dist/composer/primitives/stateful.py +254 -0
  49. package/dist/composer/pure_fn/__init__.py +34 -0
  50. package/dist/composer/pure_fn/checks.py +39 -0
  51. package/dist/composer/pure_fn/models.py +177 -0
  52. package/dist/composer/pure_fn/purity.py +311 -0
  53. package/dist/composer/pure_fn/runtests.py +155 -0
  54. package/dist/composer/registry/__init__.py +40 -0
  55. package/dist/composer/registry/generate.py +425 -0
  56. package/dist/composer/registry/mcp-tools-snapshot.json +193 -0
  57. package/dist/composer/registry/mcp_closure.py +311 -0
  58. package/dist/composer/registry/models.py +417 -0
  59. package/dist/composer/registry/nodes.py +1297 -0
  60. package/dist/composer/requirements.txt +21 -0
  61. package/dist/composer/scaffold_path.py +118 -0
  62. package/dist/composer/templates/__init__.py +13 -0
  63. package/dist/composer/templates/scan.py.j2 +34 -0
  64. package/dist/composer/validator/__init__.py +29 -0
  65. package/dist/composer/validator/desugar.py +133 -0
  66. package/dist/composer/validator/errors.py +227 -0
  67. package/dist/composer/validator/graph_checks.py +443 -0
  68. package/dist/composer/validator/loader.py +110 -0
  69. package/dist/composer/validator/schema_checks.py +147 -0
  70. package/dist/composer/validator/validate.py +86 -0
  71. package/dist/composer/vendoring.py +103 -0
  72. package/dist/composer/verbs/__init__.py +49 -0
  73. package/dist/composer/verbs/_box.py +308 -0
  74. package/dist/composer/verbs/check.py +254 -0
  75. package/dist/composer/verbs/close.py +494 -0
  76. package/dist/composer/verbs/deploy.py +279 -0
  77. package/dist/composer/verbs/docs.py +30 -0
  78. package/dist/composer/verbs/fund.py +498 -0
  79. package/dist/composer/verbs/install.py +586 -0
  80. package/dist/composer/verbs/runtime_validate.py +422 -0
  81. package/dist/composer/verbs/smoke.py +134 -0
  82. package/dist/composer/verbs/status.py +383 -0
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +2 -9
  85. package/dist/index.js.map +1 -1
  86. package/dist/runtime/load-runtime-config.d.ts +17 -0
  87. package/dist/runtime/load-runtime-config.d.ts.map +1 -1
  88. package/dist/runtime/load-runtime-config.js +2 -2
  89. package/dist/runtime/load-runtime-config.js.map +1 -1
  90. package/dist/runtime-api/config.d.ts +2 -2
  91. package/dist/runtime-api/config.js +4 -4
  92. package/dist/runtime-api/config.js.map +1 -1
  93. package/dist/runtime-api/handlers/ingest-signals.d.ts +3 -5
  94. package/dist/runtime-api/handlers/ingest-signals.d.ts.map +1 -1
  95. package/dist/runtime-api/handlers/ingest-signals.js +5 -14
  96. package/dist/runtime-api/handlers/ingest-signals.js.map +1 -1
  97. package/dist/runtime-api/intake/intake-mount.d.ts.map +1 -1
  98. package/dist/runtime-api/intake/intake-mount.js +2 -8
  99. package/dist/runtime-api/intake/intake-mount.js.map +1 -1
  100. package/dist/skills-manager/manifest.d.ts.map +1 -1
  101. package/dist/skills-manager/manifest.js +1 -3
  102. package/dist/skills-manager/manifest.js.map +1 -1
  103. package/dist/skills-manager/skills-manifest.json +1 -3
  104. package/package.json +4 -2
  105. package/dist/runtime-api/intake/ingest-mutex.d.ts +0 -16
  106. package/dist/runtime-api/intake/ingest-mutex.d.ts.map +0 -1
  107. package/dist/runtime-api/intake/ingest-mutex.js +0 -18
  108. 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
+ )