@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,443 @@
|
|
|
1
|
+
"""graph_checks — stage (d): the graph-semantic checks a JSON Schema cannot express.
|
|
2
|
+
|
|
3
|
+
These are the checks that LAYER on top of schema validation (supervisor decision): a
|
|
4
|
+
schema can say "this param is an int in [1,1000]" but not "this edge connects a
|
|
5
|
+
``score_map`` output to a ``candles`` input", "this input has no writer", or "these three
|
|
6
|
+
nodes form a cycle". ~All plain Python over the registry — no networkx, no framework (D8):
|
|
7
|
+
the DAG never executes as a DAG, so a few dictionaries and a depth-first search suffice.
|
|
8
|
+
|
|
9
|
+
Each function appends :class:`TeachingError` s to a shared list and never raises — every
|
|
10
|
+
defect is collected so the author sees them all at once. Checks that depend on a node's
|
|
11
|
+
type being known (ports, types, connectivity) simply skip nodes/edges whose type is
|
|
12
|
+
unknown; the unknown-node check (CMP030) has already named those.
|
|
13
|
+
|
|
14
|
+
A node's INTERFACE (its typed ports + kind) is resolved uniformly whether it is a registry
|
|
15
|
+
primitive (``NODES_BY_NAME`` — the closed vocabulary) or a per-graph ``pure_fn`` node
|
|
16
|
+
(``composer.pure_fn`` — the open extension point, D2 kind 2). Both present the same
|
|
17
|
+
duck surface (``.name``, ``.kind``, ``.inputs``, ``.outputs``), so all the edge/type/
|
|
18
|
+
connectivity/cycle logic below is written once and reads a pure_fn node's declared ports
|
|
19
|
+
exactly as it reads a primitive's registered ports. The purity/signature/inline-test
|
|
20
|
+
checks on a pure_fn's BODY are a separate stage (``composer.pure_fn.checks``).
|
|
21
|
+
|
|
22
|
+
Two graph-semantic conventions this validator enforces, both read directly off the
|
|
23
|
+
registry's ``Port`` model (D4 — the registry is the single source of truth; see
|
|
24
|
+
``composer/registry/models.py`` for the field semantics):
|
|
25
|
+
|
|
26
|
+
* ``port.merge`` — feature_map inputs are MERGE points (every indicator in the corpus
|
|
27
|
+
emits a per-asset feature and the scorer consumes a single "merged feature record"),
|
|
28
|
+
so multiple edges into a ``merge=True`` input are legal. Every other input accepts
|
|
29
|
+
exactly one writer — a second writer is CMP037.
|
|
30
|
+
* ``port.required`` — an unwired input with ``required=False`` (an ``asset_list``
|
|
31
|
+
universe input, ``signal_envelope.equity``, or a stateful node's ``state`` input,
|
|
32
|
+
which is supplied by the runtime tick boundary rather than a graph edge) is fine;
|
|
33
|
+
every other unwired input is CMP036.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import difflib
|
|
39
|
+
|
|
40
|
+
from composer.pure_fn import is_pure_fn_entry, parse_pure_fn
|
|
41
|
+
from composer.registry import NODES_BY_NAME, NodeKind, PortType
|
|
42
|
+
|
|
43
|
+
from .errors import Location, TeachingError
|
|
44
|
+
|
|
45
|
+
# Tokens that mark an unknown node name as a reach for BESPOKE scoring/classification.
|
|
46
|
+
# When one appears, CMP030 steers to the settled stance: there is no scorer primitive for
|
|
47
|
+
# these — the one generic scorer is `hard_gates_all`, everything else is a `kind: pure_fn`
|
|
48
|
+
# node (registry stance 2026-07-15; the 3 speculative scorers were removed). Deliberately
|
|
49
|
+
# narrow so plain typos (e.g. `rsii`) keep the clean nearest-match hint.
|
|
50
|
+
_SCORING_TOKENS = (
|
|
51
|
+
"score", "scoring", "scorer", "direction", "confirm", "conviction",
|
|
52
|
+
"classif", "waterfall", "tier", "gate",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _node_line(line_index: dict, node_id) -> int | None:
|
|
57
|
+
return line_index.get("nodes", {}).get(node_id)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _split_ref(ref: str) -> tuple[str, str] | None:
|
|
61
|
+
if not isinstance(ref, str) or ref.count(".") != 1:
|
|
62
|
+
return None
|
|
63
|
+
node_id, port = ref.split(".", 1)
|
|
64
|
+
if not node_id or not port:
|
|
65
|
+
return None
|
|
66
|
+
return node_id, port
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _describe_hint(iface) -> str:
|
|
70
|
+
"""Where the author should look to fix a bad port reference on this node."""
|
|
71
|
+
if iface.kind == NodeKind.pure_fn:
|
|
72
|
+
return "check the node's declared `inputs:`/`outputs:` ports"
|
|
73
|
+
return f"see `composer describe {iface.name}`"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def check(doc: dict, line_index: dict) -> list[TeachingError]:
|
|
77
|
+
errors: list[TeachingError] = []
|
|
78
|
+
nodes = doc.get("nodes")
|
|
79
|
+
if not isinstance(nodes, list):
|
|
80
|
+
return errors # schema already flagged a missing/!list nodes field
|
|
81
|
+
edges = doc.get("edges") or []
|
|
82
|
+
if not isinstance(edges, list):
|
|
83
|
+
edges = []
|
|
84
|
+
|
|
85
|
+
# ------------------------------------------------------------------ node-level checks
|
|
86
|
+
# iface_by_id: ids whose interface (ports+kind) is known — primitive OR pure_fn.
|
|
87
|
+
iface_by_id: dict[str, object] = {}
|
|
88
|
+
all_ids: set[str] = set() # every declared id (dangling-edge check uses this)
|
|
89
|
+
seen_ids: set[str] = set()
|
|
90
|
+
valid_names = sorted(NODES_BY_NAME)
|
|
91
|
+
|
|
92
|
+
for entry in nodes:
|
|
93
|
+
if not isinstance(entry, dict):
|
|
94
|
+
continue
|
|
95
|
+
node_id = entry.get("id")
|
|
96
|
+
node_type = entry.get("node")
|
|
97
|
+
line = _node_line(line_index, node_id)
|
|
98
|
+
|
|
99
|
+
if isinstance(node_id, str):
|
|
100
|
+
all_ids.add(node_id)
|
|
101
|
+
if node_id in seen_ids:
|
|
102
|
+
errors.append(
|
|
103
|
+
TeachingError(
|
|
104
|
+
code="CMP031",
|
|
105
|
+
message=f"duplicate node id {node_id!r}.",
|
|
106
|
+
fix_hint=f"Rename one of the two nodes with id {node_id!r} to a unique id.",
|
|
107
|
+
location=Location(node=node_id, line=line),
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
seen_ids.add(node_id)
|
|
111
|
+
|
|
112
|
+
if is_pure_fn_entry(entry):
|
|
113
|
+
# A pure_fn node's interface comes from its declared inputs/outputs (the body
|
|
114
|
+
# is validated separately). Register it so edges can typecheck against it.
|
|
115
|
+
if isinstance(node_id, str):
|
|
116
|
+
iface_by_id[node_id] = parse_pure_fn(entry, line)
|
|
117
|
+
elif isinstance(node_type, str) and node_type in NODES_BY_NAME:
|
|
118
|
+
if isinstance(node_id, str):
|
|
119
|
+
iface_by_id[node_id] = NODES_BY_NAME[node_type]
|
|
120
|
+
elif isinstance(node_type, str):
|
|
121
|
+
suggestions = difflib.get_close_matches(node_type, valid_names, n=3, cutoff=0.4)
|
|
122
|
+
if suggestions:
|
|
123
|
+
near = ", ".join(f"`{s}`" for s in suggestions)
|
|
124
|
+
hint = f"did you mean {near}? run `composer describe {suggestions[0]}`"
|
|
125
|
+
else:
|
|
126
|
+
hint = "run `composer catalog` to list the available node types"
|
|
127
|
+
if any(tok in node_type.lower() for tok in _SCORING_TOKENS):
|
|
128
|
+
hint += (
|
|
129
|
+
"; for bespoke scoring/classification there is no such primitive — use "
|
|
130
|
+
"`hard_gates_all` for a simple gate-and-weigh, otherwise write a "
|
|
131
|
+
"`kind: pure_fn` node"
|
|
132
|
+
)
|
|
133
|
+
errors.append(
|
|
134
|
+
TeachingError(
|
|
135
|
+
code="CMP030",
|
|
136
|
+
message=f"unknown node type {node_type!r}.",
|
|
137
|
+
fix_hint=hint,
|
|
138
|
+
location=Location(node=node_id, line=line),
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
else:
|
|
142
|
+
# Neither a registry `node:` nor `kind: pure_fn` — the entry has no interface.
|
|
143
|
+
errors.append(
|
|
144
|
+
TeachingError(
|
|
145
|
+
code="CMP038",
|
|
146
|
+
message=(
|
|
147
|
+
f"node {node_id!r} declares neither a registry `node:` type nor "
|
|
148
|
+
f"`kind: pure_fn`."
|
|
149
|
+
),
|
|
150
|
+
fix_hint=(
|
|
151
|
+
"Add a `node:` from `composer catalog`, or declare `kind: pure_fn` "
|
|
152
|
+
"with `inputs:`/`outputs:`/`fn:`."
|
|
153
|
+
),
|
|
154
|
+
location=Location(node=node_id, line=line),
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# ------------------------------------------------------------------ edge-level checks
|
|
159
|
+
# incoming[(node_id, port)] = list of (from_ref, port_type_or_None)
|
|
160
|
+
incoming: dict[tuple[str, str], list[tuple[str, object]]] = {}
|
|
161
|
+
dep_edges: list[tuple[str, str]] = [] # (producer_node, consumer_node) for cycle graph
|
|
162
|
+
|
|
163
|
+
for i, edge in enumerate(edges):
|
|
164
|
+
if not isinstance(edge, dict):
|
|
165
|
+
continue
|
|
166
|
+
line = line_index.get("edges", {}).get(i)
|
|
167
|
+
src = _split_ref(edge.get("from"))
|
|
168
|
+
dst = _split_ref(edge.get("to"))
|
|
169
|
+
if src is None or dst is None:
|
|
170
|
+
continue # schema's `pattern` check already flagged the malformed ref
|
|
171
|
+
(from_id, from_port), (to_id, to_port) = src, dst
|
|
172
|
+
|
|
173
|
+
# dangling references (against every declared id — a node whose *type* is unknown
|
|
174
|
+
# still exists, so it is not "dangling"; CMP030 already named its bad type).
|
|
175
|
+
if from_id not in all_ids:
|
|
176
|
+
errors.append(
|
|
177
|
+
TeachingError(
|
|
178
|
+
code="CMP032",
|
|
179
|
+
message=f"edge `from` references unknown node id {from_id!r}.",
|
|
180
|
+
fix_hint=f"Point `from` at an existing node id, or add a node with id {from_id!r}.",
|
|
181
|
+
location=Location(node=from_id, port=from_port, line=line),
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
if to_id not in all_ids:
|
|
185
|
+
errors.append(
|
|
186
|
+
TeachingError(
|
|
187
|
+
code="CMP032",
|
|
188
|
+
message=f"edge `to` references unknown node id {to_id!r}.",
|
|
189
|
+
fix_hint=f"Point `to` at an existing node id, or add a node with id {to_id!r}.",
|
|
190
|
+
location=Location(node=to_id, port=to_port, line=line),
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
# Port/type checks need a known interface on both ends.
|
|
194
|
+
if from_id not in iface_by_id or to_id not in iface_by_id:
|
|
195
|
+
continue
|
|
196
|
+
|
|
197
|
+
from_def = iface_by_id[from_id]
|
|
198
|
+
to_def = iface_by_id[to_id]
|
|
199
|
+
out_ports = {p.name: p for p in from_def.outputs}
|
|
200
|
+
in_ports = {p.name: p for p in to_def.inputs}
|
|
201
|
+
|
|
202
|
+
out_port = out_ports.get(from_port)
|
|
203
|
+
in_port = in_ports.get(to_port)
|
|
204
|
+
|
|
205
|
+
if out_port is None:
|
|
206
|
+
avail = ", ".join(f"`{p.name}`" for p in from_def.outputs) or "(none)"
|
|
207
|
+
errors.append(
|
|
208
|
+
TeachingError(
|
|
209
|
+
code="CMP033",
|
|
210
|
+
message=f"node {from_id!r} ({from_def.name}) has no output port {from_port!r}.",
|
|
211
|
+
fix_hint=f"Use one of its outputs: {avail} ({_describe_hint(from_def)}).",
|
|
212
|
+
location=Location(node=from_id, port=from_port, line=line),
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
if in_port is None:
|
|
216
|
+
avail = ", ".join(f"`{p.name}`" for p in to_def.inputs) or "(none)"
|
|
217
|
+
errors.append(
|
|
218
|
+
TeachingError(
|
|
219
|
+
code="CMP033",
|
|
220
|
+
message=f"node {to_id!r} ({to_def.name}) has no input port {to_port!r}.",
|
|
221
|
+
fix_hint=f"Use one of its inputs: {avail} ({_describe_hint(to_def)}).",
|
|
222
|
+
location=Location(node=to_id, port=to_port, line=line),
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
if out_port is not None and in_port is not None:
|
|
227
|
+
# The `raw` escape type may feed ONLY a pure_fn normalizer (D-wave escape hatch):
|
|
228
|
+
# a raw wire into any curated node would let opaque, untyped data bypass the typed
|
|
229
|
+
# port contract. This fires regardless of the in_port's declared type (a curated
|
|
230
|
+
# node has no raw input, so the mismatch would otherwise surface as CMP034; this
|
|
231
|
+
# gives the actionable "write a normalizer" lesson instead).
|
|
232
|
+
raw_to_non_pure_fn = (
|
|
233
|
+
out_port.type == PortType.raw and to_def.kind != NodeKind.pure_fn
|
|
234
|
+
)
|
|
235
|
+
if raw_to_non_pure_fn:
|
|
236
|
+
errors.append(
|
|
237
|
+
TeachingError(
|
|
238
|
+
code="CMP039",
|
|
239
|
+
message=(
|
|
240
|
+
f"raw payload {from_id}.{from_port} feeds {to_id} "
|
|
241
|
+
f"({to_def.name}), which is not a pure_fn."
|
|
242
|
+
),
|
|
243
|
+
fix_hint=(
|
|
244
|
+
"raw may only feed a pure_fn — write a normalizer: add a "
|
|
245
|
+
"`kind: pure_fn` node with a `raw` input that reads the payload "
|
|
246
|
+
"and returns TYPED outputs (with inline tests), then wire "
|
|
247
|
+
f"{from_id}.{from_port} into it."
|
|
248
|
+
),
|
|
249
|
+
location=Location(node=to_id, port=to_port, line=line),
|
|
250
|
+
)
|
|
251
|
+
)
|
|
252
|
+
# CMP034 (type mismatch) would also fire on a raw->curated edge, but CMP039 is the
|
|
253
|
+
# better lesson there, so suppress the duplicate. A raw->pure_fn edge whose pure_fn
|
|
254
|
+
# input is NOT declared `raw` still (correctly) reports CMP034.
|
|
255
|
+
if out_port.type != in_port.type and not raw_to_non_pure_fn:
|
|
256
|
+
errors.append(
|
|
257
|
+
TeachingError(
|
|
258
|
+
code="CMP034",
|
|
259
|
+
message=(
|
|
260
|
+
f"type mismatch: {from_id}.{from_port} is {out_port.type.value} "
|
|
261
|
+
f"but {to_id}.{to_port} expects {in_port.type.value}."
|
|
262
|
+
),
|
|
263
|
+
fix_hint=(
|
|
264
|
+
f"Wire a {in_port.type.value} output into {to_id}.{to_port}, "
|
|
265
|
+
f"or feed {from_id}.{from_port} into a port that accepts {out_port.type.value}."
|
|
266
|
+
),
|
|
267
|
+
location=Location(node=to_id, port=to_port, line=line),
|
|
268
|
+
)
|
|
269
|
+
)
|
|
270
|
+
incoming.setdefault((to_id, to_port), []).append(
|
|
271
|
+
(f"{from_id}.{from_port}", in_port.type)
|
|
272
|
+
)
|
|
273
|
+
# Cycle graph: a stateful node's OUTPUTS carry prior-tick data (the state
|
|
274
|
+
# boundary), so they do not create a forward dependency — this is what breaks
|
|
275
|
+
# the universal ttl-dedup feedback loop without a false cycle. Omit edges that
|
|
276
|
+
# originate at a stateful node.
|
|
277
|
+
if from_def.kind != NodeKind.stateful:
|
|
278
|
+
dep_edges.append((from_id, to_id))
|
|
279
|
+
|
|
280
|
+
# ------------------------------------------------- connectivity + multiple-writer checks
|
|
281
|
+
for entry in nodes:
|
|
282
|
+
if not isinstance(entry, dict):
|
|
283
|
+
continue
|
|
284
|
+
node_id = entry.get("id")
|
|
285
|
+
if not isinstance(node_id, str) or node_id not in iface_by_id:
|
|
286
|
+
continue
|
|
287
|
+
node_def = iface_by_id[node_id]
|
|
288
|
+
line = _node_line(line_index, node_id)
|
|
289
|
+
for port in node_def.inputs:
|
|
290
|
+
writers = incoming.get((node_id, port.name), [])
|
|
291
|
+
if len(writers) == 0 and port.required:
|
|
292
|
+
errors.append(
|
|
293
|
+
TeachingError(
|
|
294
|
+
code="CMP036",
|
|
295
|
+
message=(
|
|
296
|
+
f"required input {node_id}.{port.name} ({port.type.value}) "
|
|
297
|
+
f"has no incoming edge."
|
|
298
|
+
),
|
|
299
|
+
fix_hint=(
|
|
300
|
+
f"Add an edge whose `to` is `{node_id}.{port.name}` from a "
|
|
301
|
+
f"{port.type.value} output ({_describe_hint(node_def)})."
|
|
302
|
+
),
|
|
303
|
+
location=Location(node=node_id, port=port.name, line=line),
|
|
304
|
+
)
|
|
305
|
+
)
|
|
306
|
+
elif len(writers) > 1 and not port.merge:
|
|
307
|
+
srcs = ", ".join(sorted(f"`{w[0]}`" for w in writers))
|
|
308
|
+
errors.append(
|
|
309
|
+
TeachingError(
|
|
310
|
+
code="CMP037",
|
|
311
|
+
message=(
|
|
312
|
+
f"input {node_id}.{port.name} ({port.type.value}) has "
|
|
313
|
+
f"{len(writers)} writers: {srcs}."
|
|
314
|
+
),
|
|
315
|
+
fix_hint=(
|
|
316
|
+
f"Keep exactly one edge into `{node_id}.{port.name}` "
|
|
317
|
+
f"(only merge-capable inputs may accept multiple writers; "
|
|
318
|
+
f"{_describe_hint(node_def)})."
|
|
319
|
+
),
|
|
320
|
+
location=Location(node=node_id, port=port.name, line=line),
|
|
321
|
+
)
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
# ---------------------------------------------------- escape-hatch (mcp_read) tool checks
|
|
325
|
+
errors.extend(_check_mcp_read_tools(nodes, line_index))
|
|
326
|
+
|
|
327
|
+
# ------------------------------------------------------------------- cycle detection
|
|
328
|
+
errors.extend(_detect_cycles(iface_by_id, dep_edges, line_index))
|
|
329
|
+
return errors
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _check_mcp_read_tools(nodes: list, line_index: dict) -> list[TeachingError]:
|
|
333
|
+
"""CHECK-time validation for the mcp_read escape hatch: the named `tool` must exist in the
|
|
334
|
+
committed MCP snapshot (CMP060) and must not be a mutation (CMP061). Both are teaching
|
|
335
|
+
errors, not runtime surprises. The wrapped-tool / hatch-use ANNOTATIONS are non-blocking and
|
|
336
|
+
are surfaced by the check verb (composer/verbs/check.py), not here."""
|
|
337
|
+
errors: list[TeachingError] = []
|
|
338
|
+
read_nodes = [
|
|
339
|
+
n for n in nodes
|
|
340
|
+
if isinstance(n, dict) and n.get("node") == "mcp_read" and isinstance(n.get("id"), str)
|
|
341
|
+
]
|
|
342
|
+
if not read_nodes:
|
|
343
|
+
return errors
|
|
344
|
+
|
|
345
|
+
# Import lazily so the validator has no import-time dependency on the snapshot file.
|
|
346
|
+
from composer.registry.mcp_closure import MUTATION_TOOLS, snapshot_tool_names
|
|
347
|
+
|
|
348
|
+
known = snapshot_tool_names()
|
|
349
|
+
for entry in read_nodes:
|
|
350
|
+
node_id = entry["id"]
|
|
351
|
+
line = _node_line(line_index, node_id)
|
|
352
|
+
with_block = entry.get("with") or {}
|
|
353
|
+
tool = with_block.get("tool") if isinstance(with_block, dict) else None
|
|
354
|
+
if not isinstance(tool, str) or not tool.strip():
|
|
355
|
+
continue # a missing/blank required `tool` is CMP013 from the schema stage
|
|
356
|
+
if tool in MUTATION_TOOLS:
|
|
357
|
+
errors.append(
|
|
358
|
+
TeachingError(
|
|
359
|
+
code="CMP061",
|
|
360
|
+
message=(
|
|
361
|
+
f"mcp_read tool {tool!r} is a mutation (write) tool; a scanner is a "
|
|
362
|
+
f"read-only producer."
|
|
363
|
+
),
|
|
364
|
+
fix_hint=(
|
|
365
|
+
"Remove it — mutations (open/close/edit positions, strategy/fund/"
|
|
366
|
+
"ratchet writes) are never a data source. The runtime blocks them too."
|
|
367
|
+
),
|
|
368
|
+
location=Location(node=node_id, line=line),
|
|
369
|
+
)
|
|
370
|
+
)
|
|
371
|
+
elif tool not in known:
|
|
372
|
+
near = difflib.get_close_matches(tool, sorted(known), n=3, cutoff=0.5)
|
|
373
|
+
if near:
|
|
374
|
+
hint = (
|
|
375
|
+
f"did you mean {', '.join(repr(m) for m in near)}? "
|
|
376
|
+
"If this is a genuinely-new tool, refresh the snapshot "
|
|
377
|
+
"(MCP_SNAPSHOT_REFRESH=1 pytest python-tests/composer/test_mcp_closure.py)."
|
|
378
|
+
)
|
|
379
|
+
else:
|
|
380
|
+
hint = (
|
|
381
|
+
"No close match in the committed snapshot. If this is a genuinely-new "
|
|
382
|
+
"server-side read, refresh the snapshot (MCP_SNAPSHOT_REFRESH=1 pytest "
|
|
383
|
+
"python-tests/composer/test_mcp_closure.py); otherwise fix the tool name."
|
|
384
|
+
)
|
|
385
|
+
errors.append(
|
|
386
|
+
TeachingError(
|
|
387
|
+
code="CMP060",
|
|
388
|
+
message=f"mcp_read tool {tool!r} is not in the committed MCP snapshot.",
|
|
389
|
+
fix_hint=hint,
|
|
390
|
+
location=Location(node=node_id, line=line),
|
|
391
|
+
)
|
|
392
|
+
)
|
|
393
|
+
return errors
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _detect_cycles(
|
|
397
|
+
iface_by_id: dict, dep_edges: list[tuple[str, str]], line_index: dict
|
|
398
|
+
) -> list[TeachingError]:
|
|
399
|
+
adj: dict[str, list[str]] = {nid: [] for nid in iface_by_id}
|
|
400
|
+
for src, dst in dep_edges:
|
|
401
|
+
adj.setdefault(src, []).append(dst)
|
|
402
|
+
|
|
403
|
+
WHITE, GRAY, BLACK = 0, 1, 2
|
|
404
|
+
color: dict[str, int] = {nid: WHITE for nid in adj}
|
|
405
|
+
reported: set[frozenset] = set()
|
|
406
|
+
errors: list[TeachingError] = []
|
|
407
|
+
|
|
408
|
+
def dfs(node: str, stack: list[str]) -> None:
|
|
409
|
+
color[node] = GRAY
|
|
410
|
+
stack.append(node)
|
|
411
|
+
for nxt in adj.get(node, []):
|
|
412
|
+
if color.get(nxt) == GRAY:
|
|
413
|
+
# back edge -> cycle is stack[idx:] + nxt
|
|
414
|
+
idx = stack.index(nxt)
|
|
415
|
+
cycle = stack[idx:] + [nxt]
|
|
416
|
+
key = frozenset(cycle[:-1])
|
|
417
|
+
if key not in reported:
|
|
418
|
+
reported.add(key)
|
|
419
|
+
path = " -> ".join(cycle)
|
|
420
|
+
head = cycle[0]
|
|
421
|
+
errors.append(
|
|
422
|
+
TeachingError(
|
|
423
|
+
code="CMP035",
|
|
424
|
+
message=f"cycle detected: {path}.",
|
|
425
|
+
fix_hint=(
|
|
426
|
+
"Break the loop by removing one edge in the cycle; a "
|
|
427
|
+
"strategy graph must be acyclic (state feedback belongs "
|
|
428
|
+
"on a stateful node's state ports, not a data edge)."
|
|
429
|
+
),
|
|
430
|
+
location=Location(
|
|
431
|
+
node=head, line=line_index.get("nodes", {}).get(head)
|
|
432
|
+
),
|
|
433
|
+
)
|
|
434
|
+
)
|
|
435
|
+
elif color.get(nxt) == WHITE:
|
|
436
|
+
dfs(nxt, stack)
|
|
437
|
+
stack.pop()
|
|
438
|
+
color[node] = BLACK
|
|
439
|
+
|
|
440
|
+
for nid in sorted(adj):
|
|
441
|
+
if color[nid] == WHITE:
|
|
442
|
+
dfs(nid, [])
|
|
443
|
+
return errors
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""loader — safe YAML load, anchor/alias/merge-key rejection, and a line index (D6).
|
|
2
|
+
|
|
3
|
+
Stage (a) of the pipeline. Three responsibilities:
|
|
4
|
+
|
|
5
|
+
1. Parse the authored YAML with a *safe* loader (never the full loader — the document is
|
|
6
|
+
untrusted agent output).
|
|
7
|
+
2. REJECT YAML anchors, aliases, and merge keys with a teaching error. These are banned
|
|
8
|
+
(D6, non-goal list) because they let a document say the same thing two ways, which
|
|
9
|
+
defeats the "explainable artifact" guarantee and confuses a small model. Detection is
|
|
10
|
+
structural — it walks the parse *event* stream and flags any node carrying an anchor,
|
|
11
|
+
any ``AliasEvent``, and any ``<<`` merge key — not string matching on the source text
|
|
12
|
+
(which would false-positive on a legitimate ``<<`` inside a quoted string value... and
|
|
13
|
+
miss anchors written with unusual spacing).
|
|
14
|
+
3. Build a best-effort line index (node id -> line, edge index -> line) so later stages
|
|
15
|
+
can point the author at the exact line where a defect lives.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import yaml
|
|
21
|
+
from yaml import MappingNode, ScalarEvent, SequenceNode
|
|
22
|
+
from yaml.composer import ComposerError
|
|
23
|
+
from yaml.parser import ParserError
|
|
24
|
+
from yaml.scanner import ScannerError
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class YamlLoadError(Exception):
|
|
28
|
+
"""A YAML syntax error (maps to CMP001)."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, message: str, line: int | None = None) -> None:
|
|
31
|
+
super().__init__(message)
|
|
32
|
+
self.message = message
|
|
33
|
+
self.line = line
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AnchorError(Exception):
|
|
37
|
+
"""An anchor/alias/merge key was used (maps to CMP002)."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, what: str, line: int | None = None) -> None:
|
|
40
|
+
super().__init__(what)
|
|
41
|
+
self.what = what
|
|
42
|
+
self.line = line
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _mark_line(mark) -> int | None:
|
|
46
|
+
return (mark.line + 1) if mark is not None else None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _reject_anchors(text: str) -> None:
|
|
50
|
+
"""Raise :class:`AnchorError` on the first anchor, alias, or merge key seen.
|
|
51
|
+
|
|
52
|
+
Walks the event stream so this is independent of source formatting and cannot be
|
|
53
|
+
fooled by whitespace. ``AliasEvent`` covers ``*ref``; a non-null ``event.anchor``
|
|
54
|
+
covers ``&name``; a bare ``<<`` scalar covers the (alias-less) merge key.
|
|
55
|
+
"""
|
|
56
|
+
try:
|
|
57
|
+
events = list(yaml.parse(text))
|
|
58
|
+
except (ParserError, ScannerError, ComposerError):
|
|
59
|
+
# A genuine syntax error surfaces from the loader below; not our concern here.
|
|
60
|
+
return
|
|
61
|
+
for event in events:
|
|
62
|
+
if isinstance(event, yaml.AliasEvent):
|
|
63
|
+
raise AnchorError("alias (`*ref`)", _mark_line(event.start_mark))
|
|
64
|
+
anchor = getattr(event, "anchor", None)
|
|
65
|
+
if anchor:
|
|
66
|
+
raise AnchorError(f"anchor (`&{anchor}`)", _mark_line(event.start_mark))
|
|
67
|
+
if isinstance(event, ScalarEvent) and event.value == "<<":
|
|
68
|
+
raise AnchorError("merge key (`<<`)", _mark_line(event.start_mark))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _build_line_index(text: str) -> dict:
|
|
72
|
+
"""Map ``nodes[].id`` -> line and ``edges`` index -> line, best effort."""
|
|
73
|
+
index: dict = {"nodes": {}, "edges": {}}
|
|
74
|
+
try:
|
|
75
|
+
root = yaml.compose(text)
|
|
76
|
+
except (ParserError, ScannerError, ComposerError):
|
|
77
|
+
return index
|
|
78
|
+
if not isinstance(root, MappingNode):
|
|
79
|
+
return index
|
|
80
|
+
for key_node, value_node in root.value:
|
|
81
|
+
key = getattr(key_node, "value", None)
|
|
82
|
+
if key == "nodes" and isinstance(value_node, SequenceNode):
|
|
83
|
+
for item in value_node.value:
|
|
84
|
+
if not isinstance(item, MappingNode):
|
|
85
|
+
continue
|
|
86
|
+
node_id = None
|
|
87
|
+
for k, v in item.value:
|
|
88
|
+
if getattr(k, "value", None) == "id":
|
|
89
|
+
node_id = getattr(v, "value", None)
|
|
90
|
+
if node_id is not None:
|
|
91
|
+
index["nodes"][node_id] = _mark_line(item.start_mark)
|
|
92
|
+
elif key == "edges" and isinstance(value_node, SequenceNode):
|
|
93
|
+
for i, item in enumerate(value_node.value):
|
|
94
|
+
index["edges"][i] = _mark_line(item.start_mark)
|
|
95
|
+
return index
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def load_graph(text: str) -> tuple[object, dict]:
|
|
99
|
+
"""Load an authored graph document.
|
|
100
|
+
|
|
101
|
+
Returns ``(data, line_index)``. Raises :class:`AnchorError` (CMP002) or
|
|
102
|
+
:class:`YamlLoadError` (CMP001). The caller turns those into teaching errors.
|
|
103
|
+
"""
|
|
104
|
+
_reject_anchors(text)
|
|
105
|
+
try:
|
|
106
|
+
data = yaml.safe_load(text)
|
|
107
|
+
except (ParserError, ScannerError, ComposerError) as exc:
|
|
108
|
+
mark = getattr(exc, "problem_mark", None)
|
|
109
|
+
raise YamlLoadError(str(exc).strip().replace("\n", " "), _mark_line(mark))
|
|
110
|
+
return data, _build_line_index(text)
|