@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,417 @@
1
+ """models — pydantic v2 node-definition models; the registry's typed vocabulary (D4, D8).
2
+
3
+ A ``NodeDef`` is a single declaration in the registry: a name, a ``kind``, typed
4
+ input/output ports drawn from a small closed :class:`PortType` vocabulary, a typed and
5
+ bounded parameter schema, a docstring, a one-line YAML example, and a cost note. These
6
+ models are the ONLY source of truth (D4): the graph JSON Schema, the catalog index, the
7
+ per-node describe text, the validator's type rules, and (later) the compiler's emission
8
+ all derive from ``NODES``. Nothing downstream is hand-maintained, so nothing can drift.
9
+
10
+ Invalid declarations fail **at model construction** (this is the slice-1 oracle), not at
11
+ use: an unknown port type, duplicate port names, a stateful node missing its state
12
+ in/out pair, a numeric default outside its bounds, or an example ``with:`` key naming a
13
+ param the node does not declare all raise ``ValidationError`` when ``NODES`` is built at
14
+ import time.
15
+
16
+ Port-type vocabulary (kept deliberately small; every type is justified by a real corpus
17
+ usage — see the per-member comments):
18
+
19
+ asset_list — a set of tradeable assets / instruments.
20
+ candles — multi-interval OHLCV candle data keyed by asset.
21
+ feature_map — a per-asset computed feature (float / bool / direction), keyed by asset.
22
+ score_map — a per-asset {score, reasons[], ...} record.
23
+ signals — the emitted signal-envelope list; the ``scan()`` return value.
24
+ scalar — a single account-level numeric value (equity).
25
+ state — the ``ctx.state`` carrier; stateful nodes only (D2).
26
+ record_list — an ORDERED list of typed dict records with NO asset-keying invariant
27
+ (strategy metadata / open orders / closed-trade rows). Distinct from
28
+ ``asset_list`` (whose entries are coin symbols every consumer relies on,
29
+ e.g. ``held_asset_exclusion``): a ``record_list`` is generic tabular data
30
+ whose only intended consumer is a ``kind: pure_fn`` node that declares a
31
+ ``record_list`` input and reads the record fields itself.
32
+ label — a market-wide categorical string: a SINGLE enum value describing the whole
33
+ board, NOT a value per asset (the funding-regime vocabulary addition, 2026-
34
+ 07-16 — see the enum member's justification memo).
35
+ raw — the ONE escape type: a generic opaque payload consumable ONLY by a
36
+ ``kind: pure_fn`` node (never a curated node). The single boundary type that
37
+ makes every reachable MCP shape consumable without letting a wire bypass
38
+ typing — see the enum member's justification memo (2026-07-16).
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ from enum import Enum
44
+ from typing import Literal, Optional, Union
45
+
46
+ import yaml
47
+ from pydantic import BaseModel, ConfigDict, model_validator
48
+
49
+ SCHEMA_VERSION = 1
50
+
51
+
52
+ class PortType(str, Enum):
53
+ """Closed vocabulary of data types that can flow along an edge (D1 typed ports).
54
+
55
+ Small by design: every member cites the corpus strategy that forces its existence.
56
+ An edge port declared with any value outside this enum fails at construction.
57
+ """
58
+
59
+ # stag's fixed whitelist BTC/ETH/SOL/HYPE; magpie's discovered IPOP universe;
60
+ # the held-position set from clearinghouse_state (all five packages exclude held).
61
+ asset_list = "asset_list"
62
+ # candles_fetch output; consumed by every indicator (stag 4h, hawk 1h+4h, bison 15m/1h/4h).
63
+ candles = "candles"
64
+ # per-asset indicator outputs (sma/pct_change/trend_structure/rsi/...), sm_lean tilt,
65
+ # own_return_24h — every indicator in stag/hawk/bison emits a per-asset feature.
66
+ feature_map = "feature_map"
67
+ # scorer output: weighted-sum score + reasons[] per asset (stag/hawk/bison/thesis-fund).
68
+ score_map = "score_map"
69
+ # signal_envelope output — the scan() list[dict] return (all five packages).
70
+ signals = "signals"
71
+ # clearinghouse_state dual-DEX max() account equity (stag/hawk/bison read it).
72
+ scalar = "scalar"
73
+ # ctx.state carrier owned by the stateful ttl_dedup_state node's in/out pair (D2);
74
+ # the dedup pattern is universal across the corpus.
75
+ state = "state"
76
+ # list of non-asset-keyed dict records (strategy_list / open_orders / trader_history
77
+ # rows) — the world-coverage read surface (batch C) exposes tabular reads that are not
78
+ # asset-keyed; a pure_fn declaring a record_list input is the intended consumer.
79
+ record_list = "record_list"
80
+ # JUSTIFICATION MEMO (the 9th sanctioned port type; additions remain rare, deliberate,
81
+ # maintainer-owned decisions — the 10th, the generic `raw` escape type, landed 2026-07-16
82
+ # below; the vocabulary is closed and deliberately so). A market-wide CATEGORICAL string: one
83
+ # enum value describing the WHOLE board, not a value per asset. market_get_funding_regime's
84
+ # `regime` (LONG_CROWDED | SHORT_CROWDED | NEUTRAL) is the first and motivating case — a
85
+ # single string for the entire market, read once per tick. Why not an existing type:
86
+ # * feature_map is per-asset KEYED — a regime has no asset key (a single-key {"_": ...}
87
+ # map is a lie about the shape and every feature consumer joins on the asset key);
88
+ # * scalar is a single NUMBER (account equity) — a regime is categorical, not arithmetic
89
+ # (widening scalar to carry strings breaks its documented numeric invariant);
90
+ # * record_list is a LIST of dict records — a single categorical value forced into a
91
+ # one-row list is the awkwardness the funding-regime audit explicitly flagged.
92
+ # CONSUMER EXPECTATIONS (what any node declaring a `label` input may assume): the value is
93
+ # a bare string from a small, tool-documented enum (or "" when the read failed/degraded);
94
+ # there is NO per-asset structure to index and NO arithmetic to do. The intended consumer
95
+ # is a `kind: pure_fn` node (which reads the string and branches on it — e.g. veto entries
96
+ # while the regime is crowded); any other node declaring a `label` input treats it as an
97
+ # opaque category. The regime's numeric companions ride a SEPARATE record_list output, so
98
+ # `label` stays a pure categorical.
99
+ label = "label"
100
+ # JUSTIFICATION MEMO (the 10th port type — the ONE escape type; 2026-07-16). A generic,
101
+ # OPAQUE payload: the unwrapped `data` of an arbitrary MCP read, carried untyped along a
102
+ # single wire. It exists to close the "unwrapped MCP reads" wall — the mcp_read source node
103
+ # emits `raw` so any reachable shape the curated source vocabulary hasn't wrapped is still
104
+ # consumable — WITHOUT letting a wire bypass typing. The mechanism that makes that safe:
105
+ # * `raw` is consumable ONLY by a `kind: pure_fn` node (enforced in the validator, CMP039:
106
+ # "raw may only feed a pure_fn — write a normalizer"). No curated node may declare a
107
+ # `raw` input; every curated port stays one of the 9 real shapes.
108
+ # * the consuming pure_fn is a NORMALIZER: it declares TYPED outputs (feature_map /
109
+ # record_list / score_map / ...) and carries inline fixture tests, so the opaque payload
110
+ # is pinned to a real shape by AUTHOR-OWNED fixtures at the moment it re-enters the typed
111
+ # graph. Nothing downstream of the normalizer ever sees `raw`.
112
+ # This is the deliberate division of labour the whole registry rests on: AGENTS BUILD NODES
113
+ # AND NORMALIZERS, NEVER TYPES. `raw` is the single boundary type a maintainer sanctions so
114
+ # that agents never need to (the type vocabulary stays closed at 10). CONSUMER EXPECTATIONS:
115
+ # there are none — `raw` is arbitrary JSON-ish data (or None on a failed/degraded read); a
116
+ # pure_fn normalizer must defensively read it and is the only place that may.
117
+ raw = "raw"
118
+
119
+
120
+ class NodeKind(str, Enum):
121
+ """The node kinds (D2). ``pure_fn`` is reserved for slice 4 and has no slice-1 entry.
122
+
123
+ ``stateful`` also carries a cycle-detection semantic (D4, moved off the validator in
124
+ slice 2.5): a stateful node's outputs carry PRIOR-tick data (the ctx.state boundary),
125
+ so edges leaving a stateful node's outputs do not count as forward dependencies when
126
+ the validator's DFS looks for cycles — that is what lets the universal ttl-dedup
127
+ feedback loop (``recent`` -> filter -> ... -> ``ttl_dedup_state`` -> ``recent``) wire up
128
+ without tripping CMP035. This is keyed off ``kind``, not a per-port flag, because it is
129
+ a property of the whole node's tick semantics, not of any one port.
130
+ """
131
+
132
+ source = "source"
133
+ indicator = "indicator"
134
+ filter = "filter"
135
+ scorer = "scorer"
136
+ ranker = "ranker"
137
+ emitter = "emitter"
138
+ stateful = "stateful"
139
+ pure_fn = "pure_fn"
140
+
141
+
142
+ # Closed param-type vocabulary. Scalars + a string list (whitelists/intervals) + a
143
+ # str->float map (every scorer in the corpus is a weighted sum, so weight/tier maps are
144
+ # a first-class shape, not free-form JSON) + a str-keyed JSON map (map_str_json). The last
145
+ # was added 2026-07-16 for the mcp_read escape hatch ONLY: an MCP tool's argument object is
146
+ # genuinely arbitrary JSON (mixed str/number/bool/list values, e.g.
147
+ # {"time_frame": "ALL_TIME", "limit": 100}), which NO existing param type can hold —
148
+ # map_str_float is number-valued, list[str] is string-valued, and there is no other free-form
149
+ # param shape in the registry. It is BOUNDED (str keys; JSON-primitive/list/dict values only,
150
+ # passed verbatim to the tool) and is the sole map_str_json param in the vocabulary.
151
+ ParamType = Literal[
152
+ "int", "float", "str", "bool", "list[str]", "map_str_float", "map_str_json"
153
+ ]
154
+
155
+ # The JSON value types a map_str_json param's values may take (bounded: no arbitrary objects).
156
+ _JSON_VALUE_TYPES = (str, int, float, bool, list, dict, type(None))
157
+
158
+ ParamDefault = Union[int, float, str, bool, tuple, dict, None]
159
+
160
+
161
+ class Port(BaseModel):
162
+ """A named, typed input or output port on a node (D1).
163
+
164
+ ``required`` and ``merge`` are INPUT-port semantics (D4): the registry is the single
165
+ source of truth for both, so the validator reads them off the port rather than
166
+ special-casing port names or types. ``NodeDef._check_port_flags`` rejects either flag
167
+ set to a non-default value on an OUTPUT port, since neither concept applies there.
168
+
169
+ required — False marks an input that may be left unwired: the strategy's
170
+ configured universe / preset supplies it at runtime instead of a graph edge
171
+ (e.g. an ``asset_list`` input named ``assets``/``candidates``, the
172
+ ``signal_envelope.equity`` sizing input, or a stateful node's ``state`` input,
173
+ which is wired by the runtime tick boundary — ctx.state — not a graph edge).
174
+ Defaults to True (unwired = CMP036).
175
+ merge — True marks an input that legally accepts more than one writer (a
176
+ key-union merge across writers), rather than the default single-writer rule
177
+ (a second writer = CMP037). Currently only the scorer/gate ``features``
178
+ (feature_map) inputs, since every indicator in the corpus emits one feature
179
+ and scorers consume the merged record. Defaults to False.
180
+ """
181
+
182
+ model_config = ConfigDict(frozen=True, extra="forbid")
183
+
184
+ name: str
185
+ type: PortType
186
+ doc: str
187
+ required: bool = True
188
+ merge: bool = False
189
+
190
+ @model_validator(mode="after")
191
+ def _check(self) -> "Port":
192
+ if not self.name.strip():
193
+ raise ValueError("port name must be non-empty")
194
+ if not self.doc.strip():
195
+ raise ValueError(f"port {self.name!r} must have a doc string")
196
+ return self
197
+
198
+
199
+ class Param(BaseModel):
200
+ """A typed, bounded parameter with an optional corpus-median default.
201
+
202
+ A ``None`` default means the param is required in the graph document; any other value
203
+ makes it optional and is emitted into the JSON Schema as the ``default``.
204
+ """
205
+
206
+ model_config = ConfigDict(frozen=True, extra="forbid")
207
+
208
+ name: str
209
+ type: ParamType
210
+ doc: str
211
+ default: ParamDefault = None
212
+ minimum: Optional[float] = None
213
+ maximum: Optional[float] = None
214
+ choices: Optional[tuple[str, ...]] = None
215
+
216
+ @model_validator(mode="after")
217
+ def _check(self) -> "Param":
218
+ if not self.name.strip():
219
+ raise ValueError("param name must be non-empty")
220
+ if not self.doc.strip():
221
+ raise ValueError(f"param {self.name!r} must have a doc string")
222
+
223
+ numeric = self.type in ("int", "float")
224
+ if not numeric and (self.minimum is not None or self.maximum is not None):
225
+ raise ValueError(
226
+ f"param {self.name!r}: bounds only valid on int/float, not {self.type}"
227
+ )
228
+ if (
229
+ self.minimum is not None
230
+ and self.maximum is not None
231
+ and self.minimum > self.maximum
232
+ ):
233
+ raise ValueError(
234
+ f"param {self.name!r}: minimum {self.minimum} > maximum {self.maximum}"
235
+ )
236
+ if self.choices is not None and self.type != "str":
237
+ raise ValueError(f"param {self.name!r}: choices only valid on str params")
238
+
239
+ if self.default is not None:
240
+ self._check_default_type()
241
+ if numeric:
242
+ d = float(self.default) # type: ignore[arg-type]
243
+ if self.minimum is not None and d < self.minimum:
244
+ raise ValueError(
245
+ f"param {self.name!r}: default {self.default} < minimum {self.minimum}"
246
+ )
247
+ if self.maximum is not None and d > self.maximum:
248
+ raise ValueError(
249
+ f"param {self.name!r}: default {self.default} > maximum {self.maximum}"
250
+ )
251
+ if self.choices is not None and self.default not in self.choices:
252
+ raise ValueError(
253
+ f"param {self.name!r}: default {self.default!r} not in choices {self.choices}"
254
+ )
255
+ return self
256
+
257
+ def _check_default_type(self) -> None:
258
+ d = self.default
259
+ is_bool = type(d) is bool
260
+ if self.type == "int":
261
+ if not (isinstance(d, int) and not is_bool):
262
+ raise ValueError(f"param {self.name!r}: default must be int, got {d!r}")
263
+ elif self.type == "float":
264
+ if is_bool or not isinstance(d, (int, float)):
265
+ raise ValueError(f"param {self.name!r}: default must be a number, got {d!r}")
266
+ elif self.type == "str":
267
+ if not isinstance(d, str):
268
+ raise ValueError(f"param {self.name!r}: default must be str, got {d!r}")
269
+ elif self.type == "bool":
270
+ if not is_bool:
271
+ raise ValueError(f"param {self.name!r}: default must be bool, got {d!r}")
272
+ elif self.type == "list[str]":
273
+ if not (isinstance(d, tuple) and all(isinstance(x, str) for x in d)):
274
+ raise ValueError(
275
+ f"param {self.name!r}: default must be a tuple[str, ...], got {d!r}"
276
+ )
277
+ elif self.type == "map_str_float":
278
+ ok = isinstance(d, dict) and all(
279
+ isinstance(k, str) and isinstance(v, (int, float)) and type(v) is not bool
280
+ for k, v in d.items()
281
+ )
282
+ if not ok:
283
+ raise ValueError(
284
+ f"param {self.name!r}: default must be a dict[str, float], got {d!r}"
285
+ )
286
+ elif self.type == "map_str_json":
287
+ ok = isinstance(d, dict) and all(
288
+ isinstance(k, str) and isinstance(v, _JSON_VALUE_TYPES) for k, v in d.items()
289
+ )
290
+ if not ok:
291
+ raise ValueError(
292
+ f"param {self.name!r}: default must be a str-keyed JSON map "
293
+ f"(str keys, JSON-primitive/list/dict values), got {d!r}"
294
+ )
295
+
296
+
297
+ class NodeDef(BaseModel):
298
+ """A single node declaration — the atom of the registry."""
299
+
300
+ model_config = ConfigDict(frozen=True, extra="forbid")
301
+
302
+ name: str
303
+ kind: NodeKind
304
+ summary: str # one-liner for the catalog index
305
+ doc: str # full description for `composer describe`
306
+ inputs: tuple[Port, ...] = ()
307
+ outputs: tuple[Port, ...] = ()
308
+ params: tuple[Param, ...] = ()
309
+ example: str # a single-node YAML snippet
310
+ cost: str # cost note (MCP calls / pure compute)
311
+ # For kind=source: the MCP read tool this node wraps, declared MACHINE-READABLY so the
312
+ # CI closure check (composer/registry/mcp_closure.py) can prove the source vocabulary
313
+ # still fully covers the scanner-usable read surface without parsing prose docs. None for
314
+ # a pure/derived source (universe_discover, own_return_24h — no MCP read) and for every
315
+ # non-source node. Two source nodes may legally wrap the SAME tool (the single-wallet and
316
+ # batch discovery_get_trader_state readers).
317
+ mcp_tool: Optional[str] = None
318
+
319
+ @model_validator(mode="after")
320
+ def _check(self) -> "NodeDef":
321
+ if not self.name.strip():
322
+ raise ValueError("node name must be non-empty")
323
+ for label, text in (("summary", self.summary), ("doc", self.doc), ("cost", self.cost)):
324
+ if not text.strip():
325
+ raise ValueError(f"node {self.name!r}: {label} must be non-empty")
326
+
327
+ self._check_unique(self.inputs, "input port")
328
+ self._check_unique(self.outputs, "output port")
329
+ self._check_unique(self.params, "param")
330
+
331
+ self._check_state_ports()
332
+ self._check_port_flags()
333
+ self._check_example()
334
+ self._check_mcp_tool()
335
+ return self
336
+
337
+ def _check_mcp_tool(self) -> None:
338
+ # mcp_tool is a SOURCE-only declaration (the closure check scans sources). A non-source
339
+ # node carrying it is an authoring error; a blank string is never a valid tool name.
340
+ if self.mcp_tool is None:
341
+ return
342
+ if not self.mcp_tool.strip():
343
+ raise ValueError(f"node {self.name!r}: mcp_tool must be a non-empty tool name")
344
+ if self.kind != NodeKind.source:
345
+ raise ValueError(
346
+ f"node {self.name!r}: mcp_tool is only valid on kind=source "
347
+ f"(the closure check scans sources); {self.kind.value} node declares one"
348
+ )
349
+
350
+ def _check_unique(self, items: tuple, label: str) -> None:
351
+ seen: set[str] = set()
352
+ for item in items:
353
+ if item.name in seen:
354
+ raise ValueError(f"node {self.name!r}: duplicate {label} {item.name!r}")
355
+ seen.add(item.name)
356
+
357
+ def _check_state_ports(self) -> None:
358
+ state_ins = [p for p in self.inputs if p.type == PortType.state]
359
+ state_outs = [p for p in self.outputs if p.type == PortType.state]
360
+ if self.kind == NodeKind.stateful:
361
+ # D2: a stateful node declares exactly one state in/out port pair.
362
+ if len(state_ins) != 1 or len(state_outs) != 1:
363
+ raise ValueError(
364
+ f"node {self.name!r}: a stateful node must declare exactly one "
365
+ f"state input and one state output (the ctx.state in/out pair, D2); "
366
+ f"got {len(state_ins)} in / {len(state_outs)} out"
367
+ )
368
+ else:
369
+ if state_ins or state_outs:
370
+ raise ValueError(
371
+ f"node {self.name!r}: only kind=stateful may use the 'state' port "
372
+ f"type (D2); {self.kind.value} node declares a state port"
373
+ )
374
+
375
+ def _check_port_flags(self) -> None:
376
+ # required/merge are input-port concepts (see Port docstring); an output setting
377
+ # either to its non-default value is a registry authoring error, not a real state.
378
+ for port in self.outputs:
379
+ if not port.required:
380
+ raise ValueError(
381
+ f"node {self.name!r}: output port {port.name!r} cannot set "
382
+ f"required=False (required only applies to input ports)"
383
+ )
384
+ if port.merge:
385
+ raise ValueError(
386
+ f"node {self.name!r}: output port {port.name!r} cannot set "
387
+ f"merge=True (merge only applies to input ports)"
388
+ )
389
+
390
+ def _check_example(self) -> None:
391
+ try:
392
+ parsed = yaml.safe_load(self.example)
393
+ except yaml.YAMLError as exc: # pragma: no cover - defensive
394
+ raise ValueError(f"node {self.name!r}: example is not valid YAML: {exc}")
395
+ if not isinstance(parsed, dict):
396
+ raise ValueError(f"node {self.name!r}: example must be a single YAML mapping")
397
+ if parsed.get("node") != self.name:
398
+ raise ValueError(
399
+ f"node {self.name!r}: example 'node:' must equal {self.name!r}, "
400
+ f"got {parsed.get('node')!r}"
401
+ )
402
+ param_names = {p.name for p in self.params}
403
+ with_block = parsed.get("with", {}) or {}
404
+ if not isinstance(with_block, dict):
405
+ raise ValueError(f"node {self.name!r}: example 'with:' must be a mapping")
406
+ for key in with_block:
407
+ if key not in param_names:
408
+ raise ValueError(
409
+ f"node {self.name!r}: example references unknown param {key!r} "
410
+ f"(declared params: {sorted(param_names)})"
411
+ )
412
+ extra = set(parsed) - {"node", "with", "id"}
413
+ if extra:
414
+ raise ValueError(
415
+ f"node {self.name!r}: example has unexpected keys {sorted(extra)} "
416
+ f"(allowed: id, node, with)"
417
+ )