@senpi/runtime 3.0.9-dev.20260710204324 → 3.0.9-dev.feat-strategy-compos.20260716013009
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +287 -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 +124 -0
- package/dist/composer/AUTHORING.md +233 -0
- package/dist/composer/__init__.py +29 -0
- package/dist/composer/authoring/__init__.py +49 -0
- package/dist/composer/authoring/answers.py +226 -0
- package/dist/composer/authoring/archetypes/__init__.py +25 -0
- package/dist/composer/authoring/archetypes/_common.py +218 -0
- package/dist/composer/authoring/archetypes/classifier.py +109 -0
- package/dist/composer/authoring/archetypes/skeleton.py +54 -0
- package/dist/composer/authoring/archetypes/thesis_fund.py +103 -0
- package/dist/composer/authoring/archetypes/trend_momentum.py +104 -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 +355 -0
- package/dist/composer/compiler/__init__.py +22 -0
- package/dist/composer/compiler/compile.py +550 -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 +84 -0
- package/dist/composer/generated/describe.md +703 -0
- package/dist/composer/generated/graph.schema.json +1203 -0
- package/dist/composer/primitives/__init__.py +239 -0
- package/dist/composer/primitives/_candles.py +76 -0
- package/dist/composer/primitives/adapter.py +120 -0
- package/dist/composer/primitives/emitters.py +142 -0
- package/dist/composer/primitives/filters.py +71 -0
- package/dist/composer/primitives/indicators.py +203 -0
- package/dist/composer/primitives/rankers.py +40 -0
- package/dist/composer/primitives/scorers.py +100 -0
- package/dist/composer/primitives/sources.py +405 -0
- package/dist/composer/primitives/stateful.py +52 -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 +422 -0
- package/dist/composer/registry/models.py +331 -0
- package/dist/composer/registry/nodes.py +673 -0
- package/dist/composer/requirements.txt +21 -0
- package/dist/composer/templates/__init__.py +13 -0
- package/dist/composer/templates/scan.py.j2 +31 -0
- package/dist/composer/validator/__init__.py +29 -0
- package/dist/composer/validator/desugar.py +133 -0
- package/dist/composer/validator/errors.py +191 -0
- package/dist/composer/validator/graph_checks.py +348 -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/verbs/__init__.py +33 -0
- package/dist/composer/verbs/check.py +193 -0
- package/dist/composer/verbs/deploy.py +223 -0
- package/dist/composer/verbs/docs.py +30 -0
- package/dist/composer/verbs/runtime_validate.py +262 -0
- package/dist/composer/verbs/smoke.py +115 -0
- package/dist/composer/verbs/status.py +205 -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-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.js +1 -1
- package/dist/skills-manager/manifest.js.map +1 -1
- package/dist/skills-manager/skills-manifest.json +1 -1
- 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,331 @@
|
|
|
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
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from enum import Enum
|
|
37
|
+
from typing import Literal, Optional, Union
|
|
38
|
+
|
|
39
|
+
import yaml
|
|
40
|
+
from pydantic import BaseModel, ConfigDict, model_validator
|
|
41
|
+
|
|
42
|
+
SCHEMA_VERSION = 1
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class PortType(str, Enum):
|
|
46
|
+
"""Closed vocabulary of data types that can flow along an edge (D1 typed ports).
|
|
47
|
+
|
|
48
|
+
Small by design: every member cites the corpus strategy that forces its existence.
|
|
49
|
+
An edge port declared with any value outside this enum fails at construction.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
# stag's fixed whitelist BTC/ETH/SOL/HYPE; magpie's discovered IPOP universe;
|
|
53
|
+
# the held-position set from clearinghouse_state (all five packages exclude held).
|
|
54
|
+
asset_list = "asset_list"
|
|
55
|
+
# candles_fetch output; consumed by every indicator (stag 4h, hawk 1h+4h, bison 15m/1h/4h).
|
|
56
|
+
candles = "candles"
|
|
57
|
+
# per-asset indicator outputs (sma/pct_change/trend_structure/rsi/...), sm_lean tilt,
|
|
58
|
+
# own_return_24h — every indicator in stag/hawk/bison emits a per-asset feature.
|
|
59
|
+
feature_map = "feature_map"
|
|
60
|
+
# scorer output: weighted-sum score + reasons[] per asset (stag/hawk/bison/thesis-fund).
|
|
61
|
+
score_map = "score_map"
|
|
62
|
+
# signal_envelope output — the scan() list[dict] return (all five packages).
|
|
63
|
+
signals = "signals"
|
|
64
|
+
# clearinghouse_state dual-DEX max() account equity (stag/hawk/bison read it).
|
|
65
|
+
scalar = "scalar"
|
|
66
|
+
# ctx.state carrier owned by the stateful ttl_dedup_state node's in/out pair (D2);
|
|
67
|
+
# the dedup pattern is universal across the corpus.
|
|
68
|
+
state = "state"
|
|
69
|
+
# list of non-asset-keyed dict records (strategy_list / open_orders / trader_history
|
|
70
|
+
# rows) — the world-coverage read surface (batch C) exposes tabular reads that are not
|
|
71
|
+
# asset-keyed; a pure_fn declaring a record_list input is the intended consumer.
|
|
72
|
+
record_list = "record_list"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class NodeKind(str, Enum):
|
|
76
|
+
"""The node kinds (D2). ``pure_fn`` is reserved for slice 4 and has no slice-1 entry.
|
|
77
|
+
|
|
78
|
+
``stateful`` also carries a cycle-detection semantic (D4, moved off the validator in
|
|
79
|
+
slice 2.5): a stateful node's outputs carry PRIOR-tick data (the ctx.state boundary),
|
|
80
|
+
so edges leaving a stateful node's outputs do not count as forward dependencies when
|
|
81
|
+
the validator's DFS looks for cycles — that is what lets the universal ttl-dedup
|
|
82
|
+
feedback loop (``recent`` -> filter -> ... -> ``ttl_dedup_state`` -> ``recent``) wire up
|
|
83
|
+
without tripping CMP035. This is keyed off ``kind``, not a per-port flag, because it is
|
|
84
|
+
a property of the whole node's tick semantics, not of any one port.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
source = "source"
|
|
88
|
+
indicator = "indicator"
|
|
89
|
+
filter = "filter"
|
|
90
|
+
scorer = "scorer"
|
|
91
|
+
ranker = "ranker"
|
|
92
|
+
emitter = "emitter"
|
|
93
|
+
stateful = "stateful"
|
|
94
|
+
pure_fn = "pure_fn"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# Closed param-type vocabulary. Scalars + a string list (whitelists/intervals) + a
|
|
98
|
+
# str->float map (every scorer in the corpus is a weighted sum, so weight/tier maps are
|
|
99
|
+
# a first-class shape, not free-form JSON).
|
|
100
|
+
ParamType = Literal["int", "float", "str", "bool", "list[str]", "map_str_float"]
|
|
101
|
+
|
|
102
|
+
ParamDefault = Union[int, float, str, bool, tuple, dict, None]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class Port(BaseModel):
|
|
106
|
+
"""A named, typed input or output port on a node (D1).
|
|
107
|
+
|
|
108
|
+
``required`` and ``merge`` are INPUT-port semantics (D4): the registry is the single
|
|
109
|
+
source of truth for both, so the validator reads them off the port rather than
|
|
110
|
+
special-casing port names or types. ``NodeDef._check_port_flags`` rejects either flag
|
|
111
|
+
set to a non-default value on an OUTPUT port, since neither concept applies there.
|
|
112
|
+
|
|
113
|
+
required — False marks an input that may be left unwired: the strategy's
|
|
114
|
+
configured universe / preset supplies it at runtime instead of a graph edge
|
|
115
|
+
(e.g. an ``asset_list`` input named ``assets``/``candidates``, the
|
|
116
|
+
``signal_envelope.equity`` sizing input, or a stateful node's ``state`` input,
|
|
117
|
+
which is wired by the runtime tick boundary — ctx.state — not a graph edge).
|
|
118
|
+
Defaults to True (unwired = CMP036).
|
|
119
|
+
merge — True marks an input that legally accepts more than one writer (a
|
|
120
|
+
key-union merge across writers), rather than the default single-writer rule
|
|
121
|
+
(a second writer = CMP037). Currently only the scorer/gate ``features``
|
|
122
|
+
(feature_map) inputs, since every indicator in the corpus emits one feature
|
|
123
|
+
and scorers consume the merged record. Defaults to False.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
127
|
+
|
|
128
|
+
name: str
|
|
129
|
+
type: PortType
|
|
130
|
+
doc: str
|
|
131
|
+
required: bool = True
|
|
132
|
+
merge: bool = False
|
|
133
|
+
|
|
134
|
+
@model_validator(mode="after")
|
|
135
|
+
def _check(self) -> "Port":
|
|
136
|
+
if not self.name.strip():
|
|
137
|
+
raise ValueError("port name must be non-empty")
|
|
138
|
+
if not self.doc.strip():
|
|
139
|
+
raise ValueError(f"port {self.name!r} must have a doc string")
|
|
140
|
+
return self
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class Param(BaseModel):
|
|
144
|
+
"""A typed, bounded parameter with an optional corpus-median default.
|
|
145
|
+
|
|
146
|
+
A ``None`` default means the param is required in the graph document; any other value
|
|
147
|
+
makes it optional and is emitted into the JSON Schema as the ``default``.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
151
|
+
|
|
152
|
+
name: str
|
|
153
|
+
type: ParamType
|
|
154
|
+
doc: str
|
|
155
|
+
default: ParamDefault = None
|
|
156
|
+
minimum: Optional[float] = None
|
|
157
|
+
maximum: Optional[float] = None
|
|
158
|
+
choices: Optional[tuple[str, ...]] = None
|
|
159
|
+
|
|
160
|
+
@model_validator(mode="after")
|
|
161
|
+
def _check(self) -> "Param":
|
|
162
|
+
if not self.name.strip():
|
|
163
|
+
raise ValueError("param name must be non-empty")
|
|
164
|
+
if not self.doc.strip():
|
|
165
|
+
raise ValueError(f"param {self.name!r} must have a doc string")
|
|
166
|
+
|
|
167
|
+
numeric = self.type in ("int", "float")
|
|
168
|
+
if not numeric and (self.minimum is not None or self.maximum is not None):
|
|
169
|
+
raise ValueError(
|
|
170
|
+
f"param {self.name!r}: bounds only valid on int/float, not {self.type}"
|
|
171
|
+
)
|
|
172
|
+
if (
|
|
173
|
+
self.minimum is not None
|
|
174
|
+
and self.maximum is not None
|
|
175
|
+
and self.minimum > self.maximum
|
|
176
|
+
):
|
|
177
|
+
raise ValueError(
|
|
178
|
+
f"param {self.name!r}: minimum {self.minimum} > maximum {self.maximum}"
|
|
179
|
+
)
|
|
180
|
+
if self.choices is not None and self.type != "str":
|
|
181
|
+
raise ValueError(f"param {self.name!r}: choices only valid on str params")
|
|
182
|
+
|
|
183
|
+
if self.default is not None:
|
|
184
|
+
self._check_default_type()
|
|
185
|
+
if numeric:
|
|
186
|
+
d = float(self.default) # type: ignore[arg-type]
|
|
187
|
+
if self.minimum is not None and d < self.minimum:
|
|
188
|
+
raise ValueError(
|
|
189
|
+
f"param {self.name!r}: default {self.default} < minimum {self.minimum}"
|
|
190
|
+
)
|
|
191
|
+
if self.maximum is not None and d > self.maximum:
|
|
192
|
+
raise ValueError(
|
|
193
|
+
f"param {self.name!r}: default {self.default} > maximum {self.maximum}"
|
|
194
|
+
)
|
|
195
|
+
if self.choices is not None and self.default not in self.choices:
|
|
196
|
+
raise ValueError(
|
|
197
|
+
f"param {self.name!r}: default {self.default!r} not in choices {self.choices}"
|
|
198
|
+
)
|
|
199
|
+
return self
|
|
200
|
+
|
|
201
|
+
def _check_default_type(self) -> None:
|
|
202
|
+
d = self.default
|
|
203
|
+
is_bool = type(d) is bool
|
|
204
|
+
if self.type == "int":
|
|
205
|
+
if not (isinstance(d, int) and not is_bool):
|
|
206
|
+
raise ValueError(f"param {self.name!r}: default must be int, got {d!r}")
|
|
207
|
+
elif self.type == "float":
|
|
208
|
+
if is_bool or not isinstance(d, (int, float)):
|
|
209
|
+
raise ValueError(f"param {self.name!r}: default must be a number, got {d!r}")
|
|
210
|
+
elif self.type == "str":
|
|
211
|
+
if not isinstance(d, str):
|
|
212
|
+
raise ValueError(f"param {self.name!r}: default must be str, got {d!r}")
|
|
213
|
+
elif self.type == "bool":
|
|
214
|
+
if not is_bool:
|
|
215
|
+
raise ValueError(f"param {self.name!r}: default must be bool, got {d!r}")
|
|
216
|
+
elif self.type == "list[str]":
|
|
217
|
+
if not (isinstance(d, tuple) and all(isinstance(x, str) for x in d)):
|
|
218
|
+
raise ValueError(
|
|
219
|
+
f"param {self.name!r}: default must be a tuple[str, ...], got {d!r}"
|
|
220
|
+
)
|
|
221
|
+
elif self.type == "map_str_float":
|
|
222
|
+
ok = isinstance(d, dict) and all(
|
|
223
|
+
isinstance(k, str) and isinstance(v, (int, float)) and type(v) is not bool
|
|
224
|
+
for k, v in d.items()
|
|
225
|
+
)
|
|
226
|
+
if not ok:
|
|
227
|
+
raise ValueError(
|
|
228
|
+
f"param {self.name!r}: default must be a dict[str, float], got {d!r}"
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class NodeDef(BaseModel):
|
|
233
|
+
"""A single node declaration — the atom of the registry."""
|
|
234
|
+
|
|
235
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
236
|
+
|
|
237
|
+
name: str
|
|
238
|
+
kind: NodeKind
|
|
239
|
+
summary: str # one-liner for the catalog index
|
|
240
|
+
doc: str # full description for `composer describe`
|
|
241
|
+
inputs: tuple[Port, ...] = ()
|
|
242
|
+
outputs: tuple[Port, ...] = ()
|
|
243
|
+
params: tuple[Param, ...] = ()
|
|
244
|
+
example: str # a single-node YAML snippet
|
|
245
|
+
cost: str # cost note (MCP calls / pure compute)
|
|
246
|
+
|
|
247
|
+
@model_validator(mode="after")
|
|
248
|
+
def _check(self) -> "NodeDef":
|
|
249
|
+
if not self.name.strip():
|
|
250
|
+
raise ValueError("node name must be non-empty")
|
|
251
|
+
for label, text in (("summary", self.summary), ("doc", self.doc), ("cost", self.cost)):
|
|
252
|
+
if not text.strip():
|
|
253
|
+
raise ValueError(f"node {self.name!r}: {label} must be non-empty")
|
|
254
|
+
|
|
255
|
+
self._check_unique(self.inputs, "input port")
|
|
256
|
+
self._check_unique(self.outputs, "output port")
|
|
257
|
+
self._check_unique(self.params, "param")
|
|
258
|
+
|
|
259
|
+
self._check_state_ports()
|
|
260
|
+
self._check_port_flags()
|
|
261
|
+
self._check_example()
|
|
262
|
+
return self
|
|
263
|
+
|
|
264
|
+
def _check_unique(self, items: tuple, label: str) -> None:
|
|
265
|
+
seen: set[str] = set()
|
|
266
|
+
for item in items:
|
|
267
|
+
if item.name in seen:
|
|
268
|
+
raise ValueError(f"node {self.name!r}: duplicate {label} {item.name!r}")
|
|
269
|
+
seen.add(item.name)
|
|
270
|
+
|
|
271
|
+
def _check_state_ports(self) -> None:
|
|
272
|
+
state_ins = [p for p in self.inputs if p.type == PortType.state]
|
|
273
|
+
state_outs = [p for p in self.outputs if p.type == PortType.state]
|
|
274
|
+
if self.kind == NodeKind.stateful:
|
|
275
|
+
# D2: a stateful node declares exactly one state in/out port pair.
|
|
276
|
+
if len(state_ins) != 1 or len(state_outs) != 1:
|
|
277
|
+
raise ValueError(
|
|
278
|
+
f"node {self.name!r}: a stateful node must declare exactly one "
|
|
279
|
+
f"state input and one state output (the ctx.state in/out pair, D2); "
|
|
280
|
+
f"got {len(state_ins)} in / {len(state_outs)} out"
|
|
281
|
+
)
|
|
282
|
+
else:
|
|
283
|
+
if state_ins or state_outs:
|
|
284
|
+
raise ValueError(
|
|
285
|
+
f"node {self.name!r}: only kind=stateful may use the 'state' port "
|
|
286
|
+
f"type (D2); {self.kind.value} node declares a state port"
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
def _check_port_flags(self) -> None:
|
|
290
|
+
# required/merge are input-port concepts (see Port docstring); an output setting
|
|
291
|
+
# either to its non-default value is a registry authoring error, not a real state.
|
|
292
|
+
for port in self.outputs:
|
|
293
|
+
if not port.required:
|
|
294
|
+
raise ValueError(
|
|
295
|
+
f"node {self.name!r}: output port {port.name!r} cannot set "
|
|
296
|
+
f"required=False (required only applies to input ports)"
|
|
297
|
+
)
|
|
298
|
+
if port.merge:
|
|
299
|
+
raise ValueError(
|
|
300
|
+
f"node {self.name!r}: output port {port.name!r} cannot set "
|
|
301
|
+
f"merge=True (merge only applies to input ports)"
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
def _check_example(self) -> None:
|
|
305
|
+
try:
|
|
306
|
+
parsed = yaml.safe_load(self.example)
|
|
307
|
+
except yaml.YAMLError as exc: # pragma: no cover - defensive
|
|
308
|
+
raise ValueError(f"node {self.name!r}: example is not valid YAML: {exc}")
|
|
309
|
+
if not isinstance(parsed, dict):
|
|
310
|
+
raise ValueError(f"node {self.name!r}: example must be a single YAML mapping")
|
|
311
|
+
if parsed.get("node") != self.name:
|
|
312
|
+
raise ValueError(
|
|
313
|
+
f"node {self.name!r}: example 'node:' must equal {self.name!r}, "
|
|
314
|
+
f"got {parsed.get('node')!r}"
|
|
315
|
+
)
|
|
316
|
+
param_names = {p.name for p in self.params}
|
|
317
|
+
with_block = parsed.get("with", {}) or {}
|
|
318
|
+
if not isinstance(with_block, dict):
|
|
319
|
+
raise ValueError(f"node {self.name!r}: example 'with:' must be a mapping")
|
|
320
|
+
for key in with_block:
|
|
321
|
+
if key not in param_names:
|
|
322
|
+
raise ValueError(
|
|
323
|
+
f"node {self.name!r}: example references unknown param {key!r} "
|
|
324
|
+
f"(declared params: {sorted(param_names)})"
|
|
325
|
+
)
|
|
326
|
+
extra = set(parsed) - {"node", "with", "id"}
|
|
327
|
+
if extra:
|
|
328
|
+
raise ValueError(
|
|
329
|
+
f"node {self.name!r}: example has unexpected keys {sorted(extra)} "
|
|
330
|
+
f"(allowed: id, node, with)"
|
|
331
|
+
)
|