@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,191 @@
|
|
|
1
|
+
"""builder — construct a strategy graph from the SAME registry types (D7 step 2).
|
|
2
|
+
|
|
3
|
+
Archetype templates are CODE, not YAML-with-holes: they call this builder, which resolves
|
|
4
|
+
every node against the registry (``NODES_BY_NAME``) and every edge port against the node's
|
|
5
|
+
declared ``Port``s. A template that names a nonexistent node raises ``KeyError`` at
|
|
6
|
+
``add()``; a template that wires a nonexistent port raises ``ValueError`` at ``connect()``.
|
|
7
|
+
Both fire at construction time, so "construct every archetype and validate its output" (the
|
|
8
|
+
CI test) is a hard guarantee that a template can never drift from the vocabulary — the
|
|
9
|
+
build-guide's archetype table cannot rot the way an 83-package clone corpus did.
|
|
10
|
+
|
|
11
|
+
A :class:`GraphBuilder` accumulates an ORDERED node list and edge list plus presentation
|
|
12
|
+
metadata (per-node header comments, per-param TODO-slot comments, edge section labels, and
|
|
13
|
+
which node is the scoring "edge anchor"). ``composer.authoring.emit`` turns that into the
|
|
14
|
+
teaching YAML. The builder itself emits nothing and interprets no edge text — it only
|
|
15
|
+
guarantees structural validity against the registry.
|
|
16
|
+
|
|
17
|
+
pure_fn nodes (D2 kind 2) are added via :meth:`add_pure_fn`; their declared inputs/outputs
|
|
18
|
+
are validated as ports here too, so wiring against a pure_fn is checked identically to a
|
|
19
|
+
primitive.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from typing import Optional
|
|
26
|
+
|
|
27
|
+
from composer.registry import NODES_BY_NAME, Port, PortType
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class BuiltNode:
|
|
32
|
+
"""One node in the builder's ordered list (primitive or pure_fn)."""
|
|
33
|
+
|
|
34
|
+
id: str
|
|
35
|
+
node: Optional[str] = None # registry node name (primitive)
|
|
36
|
+
kind: Optional[str] = None # "pure_fn" or None
|
|
37
|
+
params: dict = field(default_factory=dict) # `with:` values (primitive)
|
|
38
|
+
# pure_fn payload
|
|
39
|
+
inputs: tuple[dict, ...] = ()
|
|
40
|
+
outputs: tuple[dict, ...] = ()
|
|
41
|
+
pure_params: tuple[dict, ...] = ()
|
|
42
|
+
fn: Optional[str] = None
|
|
43
|
+
tests: tuple[dict, ...] = ()
|
|
44
|
+
# presentation
|
|
45
|
+
header: tuple[str, ...] = () # comment lines emitted above the node
|
|
46
|
+
param_comments: dict = field(default_factory=dict) # param -> trailing comment
|
|
47
|
+
slot_params: tuple[str, ...] = () # params that are TODO(edit) slots
|
|
48
|
+
|
|
49
|
+
def out_ports(self) -> set[str]:
|
|
50
|
+
if self.kind == "pure_fn":
|
|
51
|
+
return {p["name"] for p in self.outputs}
|
|
52
|
+
return {p.name for p in NODES_BY_NAME[self.node].outputs}
|
|
53
|
+
|
|
54
|
+
def in_ports(self) -> set[str]:
|
|
55
|
+
if self.kind == "pure_fn":
|
|
56
|
+
return {p["name"] for p in self.inputs}
|
|
57
|
+
return {p.name for p in NODES_BY_NAME[self.node].inputs}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class BuiltEdge:
|
|
62
|
+
frm: str # "node_id.port"
|
|
63
|
+
to: str # "node_id.port"
|
|
64
|
+
section: Optional[str] = None # comment line emitted before this edge (a group label)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class GraphBuilder:
|
|
68
|
+
"""Accumulates nodes + edges against the registry; the archetype templates' one API."""
|
|
69
|
+
|
|
70
|
+
def __init__(self) -> None:
|
|
71
|
+
self._nodes: list[BuiltNode] = []
|
|
72
|
+
self._edges: list[BuiltEdge] = []
|
|
73
|
+
self._by_id: dict[str, BuiltNode] = {}
|
|
74
|
+
self.edge_anchor: Optional[str] = None # node the EDGE comment block sits above
|
|
75
|
+
|
|
76
|
+
# ------------------------------------------------------------------ node construction
|
|
77
|
+
def add(
|
|
78
|
+
self,
|
|
79
|
+
node_id: str,
|
|
80
|
+
node: str,
|
|
81
|
+
params: Optional[dict] = None,
|
|
82
|
+
*,
|
|
83
|
+
header: tuple[str, ...] = (),
|
|
84
|
+
param_comments: Optional[dict] = None,
|
|
85
|
+
slots: tuple[str, ...] = (),
|
|
86
|
+
) -> "GraphBuilder":
|
|
87
|
+
"""Add a primitive node. ``KeyError`` if ``node`` is not in the registry; a param
|
|
88
|
+
naming a key the node does not declare is a ``ValueError`` (CI catches template drift)."""
|
|
89
|
+
nd = NODES_BY_NAME[node] # KeyError = nonexistent node -> CI fail
|
|
90
|
+
params = dict(params or {})
|
|
91
|
+
declared = {p.name for p in nd.params}
|
|
92
|
+
for key in params:
|
|
93
|
+
if key not in declared:
|
|
94
|
+
raise ValueError(
|
|
95
|
+
f"archetype template error: node {node_id!r} ({node}) sets unknown "
|
|
96
|
+
f"param {key!r} (declared: {sorted(declared)})"
|
|
97
|
+
)
|
|
98
|
+
self._register(
|
|
99
|
+
BuiltNode(
|
|
100
|
+
id=node_id,
|
|
101
|
+
node=node,
|
|
102
|
+
params=params,
|
|
103
|
+
header=tuple(header),
|
|
104
|
+
param_comments=dict(param_comments or {}),
|
|
105
|
+
slot_params=tuple(slots),
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
return self
|
|
109
|
+
|
|
110
|
+
def add_pure_fn(
|
|
111
|
+
self,
|
|
112
|
+
node_id: str,
|
|
113
|
+
*,
|
|
114
|
+
inputs: tuple[dict, ...],
|
|
115
|
+
outputs: tuple[dict, ...],
|
|
116
|
+
params: tuple[dict, ...],
|
|
117
|
+
fn: str,
|
|
118
|
+
tests: tuple[dict, ...] = (),
|
|
119
|
+
header: tuple[str, ...] = (),
|
|
120
|
+
) -> "GraphBuilder":
|
|
121
|
+
"""Add a per-graph pure_fn node (D2 kind 2). Its declared ports must use the closed
|
|
122
|
+
PortType vocabulary — a bad type is a ``ValueError`` (CI catches template drift)."""
|
|
123
|
+
for side, ports in (("input", inputs), ("output", outputs)):
|
|
124
|
+
for p in ports:
|
|
125
|
+
try:
|
|
126
|
+
PortType(p["type"])
|
|
127
|
+
except ValueError as exc:
|
|
128
|
+
raise ValueError(
|
|
129
|
+
f"archetype template error: pure_fn {node_id!r} {side} port "
|
|
130
|
+
f"{p.get('name')!r} has bad type {p.get('type')!r}"
|
|
131
|
+
) from exc
|
|
132
|
+
self._register(
|
|
133
|
+
BuiltNode(
|
|
134
|
+
id=node_id,
|
|
135
|
+
kind="pure_fn",
|
|
136
|
+
inputs=tuple(inputs),
|
|
137
|
+
outputs=tuple(outputs),
|
|
138
|
+
pure_params=tuple(params),
|
|
139
|
+
fn=fn,
|
|
140
|
+
tests=tuple(tests),
|
|
141
|
+
header=tuple(header),
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
return self
|
|
145
|
+
|
|
146
|
+
def connect(
|
|
147
|
+
self, frm_id: str, frm_port: str, to_id: str, to_port: str, *, section: Optional[str] = None
|
|
148
|
+
) -> "GraphBuilder":
|
|
149
|
+
"""Wire ``frm_id.frm_port`` -> ``to_id.to_port``, validating both ports exist.
|
|
150
|
+
|
|
151
|
+
A port the node does not declare is a ``ValueError`` here (construction time), so a
|
|
152
|
+
template can never emit a graph that references a nonexistent port — the CI guarantee.
|
|
153
|
+
"""
|
|
154
|
+
frm = self._by_id.get(frm_id)
|
|
155
|
+
to = self._by_id.get(to_id)
|
|
156
|
+
if frm is None:
|
|
157
|
+
raise ValueError(f"archetype template error: edge from unknown node {frm_id!r}")
|
|
158
|
+
if to is None:
|
|
159
|
+
raise ValueError(f"archetype template error: edge to unknown node {to_id!r}")
|
|
160
|
+
if frm_port not in frm.out_ports():
|
|
161
|
+
raise ValueError(
|
|
162
|
+
f"archetype template error: node {frm_id!r} has no output port {frm_port!r} "
|
|
163
|
+
f"(has: {sorted(frm.out_ports())})"
|
|
164
|
+
)
|
|
165
|
+
if to_port not in to.in_ports():
|
|
166
|
+
raise ValueError(
|
|
167
|
+
f"archetype template error: node {to_id!r} has no input port {to_port!r} "
|
|
168
|
+
f"(has: {sorted(to.in_ports())})"
|
|
169
|
+
)
|
|
170
|
+
self._edges.append(BuiltEdge(frm=f"{frm_id}.{frm_port}", to=f"{to_id}.{to_port}", section=section))
|
|
171
|
+
return self
|
|
172
|
+
|
|
173
|
+
def set_edge_anchor(self, node_id: str) -> "GraphBuilder":
|
|
174
|
+
"""Mark the scoring node above which the verbatim EDGE comment block is emitted."""
|
|
175
|
+
self.edge_anchor = node_id
|
|
176
|
+
return self
|
|
177
|
+
|
|
178
|
+
# ------------------------------------------------------------------ accessors
|
|
179
|
+
@property
|
|
180
|
+
def nodes(self) -> list[BuiltNode]:
|
|
181
|
+
return list(self._nodes)
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def edges(self) -> list[BuiltEdge]:
|
|
185
|
+
return list(self._edges)
|
|
186
|
+
|
|
187
|
+
def _register(self, node: BuiltNode) -> None:
|
|
188
|
+
if node.id in self._by_id:
|
|
189
|
+
raise ValueError(f"archetype template error: duplicate node id {node.id!r}")
|
|
190
|
+
self._nodes.append(node)
|
|
191
|
+
self._by_id[node.id] = node
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""emit — render a built graph into teaching YAML (D7 step 3; the machine-generated anchor).
|
|
2
|
+
|
|
3
|
+
The emitted graph must be a COMPLETE, VALID, COMPILING artifact before any edit (the
|
|
4
|
+
zero-edit oracle), and it must teach in place so a small model can move it toward the edge
|
|
5
|
+
by editing marked slots only (D7). This renderer therefore does three things beyond dumping
|
|
6
|
+
data:
|
|
7
|
+
|
|
8
|
+
* slot comments — every bespoke slot (weights, thresholds, fn bodies) carries a
|
|
9
|
+
``# TODO(edit): ...`` comment in the hand-written examples' voice, WITH a working
|
|
10
|
+
default already in place so the graph validates and compiles unedited.
|
|
11
|
+
* verbatim edge — the user's EDGE free-text is emitted as a comment block directly above
|
|
12
|
+
the scoring slot; the machine never interprets it (that is the model's only job).
|
|
13
|
+
* content marker — a ``metadata.generated.content_hash`` over the emitted text (with the
|
|
14
|
+
hash field itself blanked). ``composer new`` uses it for no-clobber: an unchanged file
|
|
15
|
+
re-hashes to its stored marker (regenerable), an edited one does not (refused; D7 step 5).
|
|
16
|
+
|
|
17
|
+
Determinism is a hard property (the byte-identical oracle): the renderer is a pure function
|
|
18
|
+
of the builder + answers, emits dicts/lists in a fixed order, and carries no timestamps.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import hashlib
|
|
24
|
+
import re
|
|
25
|
+
|
|
26
|
+
from .answers import Answers
|
|
27
|
+
from .builder import BuiltNode, GraphBuilder
|
|
28
|
+
|
|
29
|
+
_HASH_PLACEHOLDER = "0" * 64
|
|
30
|
+
_HASH_RE = re.compile(r'(content_hash: ")([0-9a-f]{64})(")')
|
|
31
|
+
|
|
32
|
+
_PLAIN_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_.-]*$")
|
|
33
|
+
_YAML_KEYWORDS = {"true", "false", "null", "yes", "no", "on", "off", "~"}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# --------------------------------------------------------------------------- scalars
|
|
37
|
+
|
|
38
|
+
def _dq(s: str) -> str:
|
|
39
|
+
escaped = s.replace("\\", "\\\\").replace('"', '\\"')
|
|
40
|
+
escaped = escaped.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t")
|
|
41
|
+
return '"' + escaped + '"'
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _plain_ok(s: str) -> bool:
|
|
45
|
+
return bool(_PLAIN_RE.match(s)) and s.lower() not in _YAML_KEYWORDS
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _float(v: float) -> str:
|
|
49
|
+
"""Render a float so PyYAML parses it back as a float (D6: YAML authored, JSON canonical).
|
|
50
|
+
|
|
51
|
+
PyYAML's implicit float resolver requires a decimal point in the mantissa, so bare
|
|
52
|
+
scientific notation (``repr(1e-7) == '1e-07'``) round-trips as a STRING and fails schema
|
|
53
|
+
typing. Insert the missing ``.0`` into the mantissa (e.g. ``1e-07`` -> ``1.0e-07``).
|
|
54
|
+
"""
|
|
55
|
+
s = repr(v)
|
|
56
|
+
if "e" in s or "E" in s:
|
|
57
|
+
mant, _, exp = s.replace("E", "e").partition("e")
|
|
58
|
+
if "." not in mant:
|
|
59
|
+
mant += ".0"
|
|
60
|
+
return f"{mant}e{exp}"
|
|
61
|
+
return s
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _scalar(v) -> str:
|
|
65
|
+
"""A single YAML scalar. Strings are emitted plain when unambiguous, else double-quoted;
|
|
66
|
+
types like ``list[str]`` (which carry brackets) fall to the quoted branch automatically."""
|
|
67
|
+
if v is None:
|
|
68
|
+
return "null"
|
|
69
|
+
if isinstance(v, bool):
|
|
70
|
+
return "true" if v else "false"
|
|
71
|
+
if isinstance(v, int):
|
|
72
|
+
return str(v)
|
|
73
|
+
if isinstance(v, float):
|
|
74
|
+
return _float(v)
|
|
75
|
+
s = str(v)
|
|
76
|
+
return s if _plain_ok(s) else _dq(s)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _inline(v) -> str:
|
|
80
|
+
"""A value in YAML flow style (used for `with:` params, ports, and leaf test maps).
|
|
81
|
+
|
|
82
|
+
Strings are always quoted here (matches the hand-written examples' `dex: "xyz"` /
|
|
83
|
+
`interval: "4h"` aesthetic); numbers/bools bare; dicts/lists recurse in flow style.
|
|
84
|
+
"""
|
|
85
|
+
if isinstance(v, dict):
|
|
86
|
+
if not v:
|
|
87
|
+
return "{}"
|
|
88
|
+
items = [f"{_key(k)}: {_inline(val)}" for k, val in v.items()]
|
|
89
|
+
return "{ " + ", ".join(items) + " }"
|
|
90
|
+
if isinstance(v, (list, tuple)):
|
|
91
|
+
if not v:
|
|
92
|
+
return "[]"
|
|
93
|
+
return "[" + ", ".join(_inline(x) for x in v) + "]"
|
|
94
|
+
if isinstance(v, str):
|
|
95
|
+
return _dq(v)
|
|
96
|
+
return _scalar(v)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _key(k) -> str:
|
|
100
|
+
ks = str(k)
|
|
101
|
+
return ks if _plain_ok(ks) else _dq(ks)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# --------------------------------------------------------------------------- block emitter
|
|
105
|
+
|
|
106
|
+
def _emit_kv(key, value, indent: int) -> list[str]:
|
|
107
|
+
"""Emit `key: value` in block style; dicts recurse, scalar-lists inline, and lists of
|
|
108
|
+
mappings become `- ` items. Used for metadata and pure_fn inline tests."""
|
|
109
|
+
pad = " " * indent
|
|
110
|
+
if isinstance(value, dict):
|
|
111
|
+
if not value:
|
|
112
|
+
return [f"{pad}{_key(key)}: {{}}"]
|
|
113
|
+
out = [f"{pad}{_key(key)}:"]
|
|
114
|
+
for k, v in value.items():
|
|
115
|
+
out.extend(_emit_kv(k, v, indent + 2))
|
|
116
|
+
return out
|
|
117
|
+
if isinstance(value, (list, tuple)):
|
|
118
|
+
if not value:
|
|
119
|
+
return [f"{pad}{_key(key)}: []"]
|
|
120
|
+
if all(_is_scalar(x) for x in value):
|
|
121
|
+
return [f"{pad}{_key(key)}: {_inline(list(value))}"]
|
|
122
|
+
out = [f"{pad}{_key(key)}:"]
|
|
123
|
+
for item in value:
|
|
124
|
+
item_lines: list[str] = []
|
|
125
|
+
for k, v in item.items():
|
|
126
|
+
item_lines.extend(_emit_kv(k, v, indent + 4))
|
|
127
|
+
out.append(f"{pad} - " + item_lines[0].strip())
|
|
128
|
+
out.extend(item_lines[1:])
|
|
129
|
+
return out
|
|
130
|
+
return [f"{pad}{_key(key)}: {_scalar(value)}"]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _is_scalar(x) -> bool:
|
|
134
|
+
return x is None or isinstance(x, (str, int, float, bool))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# --------------------------------------------------------------------------- ports/params
|
|
138
|
+
|
|
139
|
+
def _inline_port(p: dict) -> str:
|
|
140
|
+
return f"{{ name: {_scalar(p['name'])}, type: {_scalar(p['type'])}, doc: {_dq(p.get('doc', ''))} }}"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _inline_pure_param(p: dict) -> str:
|
|
144
|
+
parts = [f"name: {_scalar(p['name'])}", f"type: {_scalar(p['type'])}"]
|
|
145
|
+
if "default" in p:
|
|
146
|
+
parts.append(f"default: {_inline(p['default'])}")
|
|
147
|
+
parts.append(f"doc: {_dq(p.get('doc', ''))}")
|
|
148
|
+
return "{ " + ", ".join(parts) + " }"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# --------------------------------------------------------------------------- node emitter
|
|
152
|
+
|
|
153
|
+
def _emit_node(bn: BuiltNode) -> list[str]:
|
|
154
|
+
lines: list[str] = []
|
|
155
|
+
for h in bn.header:
|
|
156
|
+
lines.append(f" # {h}")
|
|
157
|
+
lines.append(f" - id: {bn.id}")
|
|
158
|
+
|
|
159
|
+
if bn.kind == "pure_fn":
|
|
160
|
+
lines.append(" kind: pure_fn")
|
|
161
|
+
lines.append(" inputs:")
|
|
162
|
+
for p in bn.inputs:
|
|
163
|
+
lines.append(f" - {_inline_port(p)}")
|
|
164
|
+
lines.append(" outputs:")
|
|
165
|
+
for p in bn.outputs:
|
|
166
|
+
lines.append(f" - {_inline_port(p)}")
|
|
167
|
+
if bn.pure_params:
|
|
168
|
+
lines.append(" params:")
|
|
169
|
+
for p in bn.pure_params:
|
|
170
|
+
lines.append(f" - {_inline_pure_param(p)}")
|
|
171
|
+
lines.append(" fn: |")
|
|
172
|
+
for fl in (bn.fn or "").rstrip("\n").split("\n"):
|
|
173
|
+
lines.append(f" {fl}" if fl.strip() else "")
|
|
174
|
+
if bn.tests:
|
|
175
|
+
for line in _emit_kv("tests", list(bn.tests), 4):
|
|
176
|
+
lines.append(line)
|
|
177
|
+
return lines
|
|
178
|
+
|
|
179
|
+
lines.append(f" node: {bn.node}")
|
|
180
|
+
if bn.params:
|
|
181
|
+
lines.append(" with:")
|
|
182
|
+
for k, v in bn.params.items():
|
|
183
|
+
line = f" {_key(k)}: {_inline(v)}"
|
|
184
|
+
comment = bn.param_comments.get(k)
|
|
185
|
+
if comment:
|
|
186
|
+
line += f" # {comment}"
|
|
187
|
+
lines.append(line)
|
|
188
|
+
return lines
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# --------------------------------------------------------------------------- driver
|
|
192
|
+
|
|
193
|
+
def render(builder: GraphBuilder, answers: Answers, banner: list[str]) -> str:
|
|
194
|
+
"""Render the built graph to teaching YAML text with a stamped content hash."""
|
|
195
|
+
lines: list[str] = []
|
|
196
|
+
for b in banner:
|
|
197
|
+
lines.append(f"# {b}" if b else "#")
|
|
198
|
+
lines.append("")
|
|
199
|
+
lines.append("version: 1")
|
|
200
|
+
lines.append("")
|
|
201
|
+
|
|
202
|
+
# --- metadata (D7: the spec file IS the state — answers live here) ---
|
|
203
|
+
lines.append("metadata:")
|
|
204
|
+
meta = {
|
|
205
|
+
"archetype": answers.archetype,
|
|
206
|
+
"strategy": answers.strategy,
|
|
207
|
+
"generated": {"by": "composer new", "content_hash": _HASH_PLACEHOLDER},
|
|
208
|
+
"answers": answers.metadata_block(),
|
|
209
|
+
}
|
|
210
|
+
for k, v in meta.items():
|
|
211
|
+
lines.extend(_emit_kv(k, v, 2))
|
|
212
|
+
if answers.edge:
|
|
213
|
+
lines.append(
|
|
214
|
+
f" # EDGE (verbatim, machine never interprets): {_dq(answers.edge)}"
|
|
215
|
+
)
|
|
216
|
+
lines.append("")
|
|
217
|
+
|
|
218
|
+
# --- nodes ---
|
|
219
|
+
lines.append("nodes:")
|
|
220
|
+
for bn in builder.nodes:
|
|
221
|
+
if builder.edge_anchor == bn.id and answers.edge:
|
|
222
|
+
lines.append(" # ---------------------------------------------------------------")
|
|
223
|
+
lines.append(" # EDGE (verbatim — the machine did NOT interpret this; encode it")
|
|
224
|
+
lines.append(" # by editing the TODO slots on this scoring node):")
|
|
225
|
+
for eline in answers.edge.split("\n"):
|
|
226
|
+
lines.append(f" # {eline}")
|
|
227
|
+
lines.append(" # ---------------------------------------------------------------")
|
|
228
|
+
lines.extend(_emit_node(bn))
|
|
229
|
+
lines.append("")
|
|
230
|
+
|
|
231
|
+
# --- edges ---
|
|
232
|
+
lines.append("edges:")
|
|
233
|
+
for e in builder.edges:
|
|
234
|
+
if e.section:
|
|
235
|
+
lines.append(f" # {e.section}")
|
|
236
|
+
lines.append(f" - {{ from: {e.frm}, to: {e.to} }}")
|
|
237
|
+
|
|
238
|
+
text = "\n".join(ln.rstrip() for ln in lines).rstrip("\n") + "\n"
|
|
239
|
+
digest = hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
240
|
+
return text.replace(_HASH_PLACEHOLDER, digest, 1)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# --------------------------------------------------------------------------- no-clobber
|
|
244
|
+
|
|
245
|
+
def stored_hash(text: str):
|
|
246
|
+
"""The content_hash marker in an emitted file, or None if it carries none."""
|
|
247
|
+
m = _HASH_RE.search(text)
|
|
248
|
+
return m.group(2) if m else None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def is_unedited_generated(text: str):
|
|
252
|
+
"""True if ``text`` is an unmodified composer-generated graph, False if edited, None if
|
|
253
|
+
it is not a composer-generated file at all (no marker).
|
|
254
|
+
|
|
255
|
+
Recomputes the hash the generator would have produced for this exact content (with the
|
|
256
|
+
marker blanked) and compares to the stored marker: equal => nobody touched it since
|
|
257
|
+
generation (safe to regenerate over); unequal => hand-edited (refuse without --force).
|
|
258
|
+
"""
|
|
259
|
+
stored = stored_hash(text)
|
|
260
|
+
if stored is None:
|
|
261
|
+
return None
|
|
262
|
+
blanked = _HASH_RE.sub(rf"\g<1>{_HASH_PLACEHOLDER}\g<3>", text, count=1)
|
|
263
|
+
recomputed = hashlib.sha256(blanked.encode("utf-8")).hexdigest()
|
|
264
|
+
return recomputed == stored
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""gaps — feasibility gap report at `new` time (D7 step 4; D4 made mechanical).
|
|
2
|
+
|
|
3
|
+
Feasibility is enforced BEFORE anything is written: an answer that references a source or
|
|
4
|
+
capability the registry does not have does not crash and does not silently emit a broken
|
|
5
|
+
graph — it produces a structured, printable, ``--json``-able gap report naming each missing
|
|
6
|
+
capability, and ``composer new`` writes no file.
|
|
7
|
+
|
|
8
|
+
This is D4 ("the agent can authoritatively know what is impossible") turned into a build
|
|
9
|
+
step: the closed registry vocabulary is the oracle, so "can this strategy be built?" is a
|
|
10
|
+
set-membership check, not a judgement call. The report is a teaching artifact in the same
|
|
11
|
+
spirit as the validator's errors — for each missing capability it names what was asked for,
|
|
12
|
+
what kind of node it would be, why the strategy needs it, and the nearest things the
|
|
13
|
+
registry DOES have — so the boundary reads as an honest "here is what's pending", exactly
|
|
14
|
+
the whalehunter paper-exercise story (its ~6 tier-3 pieces are deliberately absent).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import difflib
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
|
|
22
|
+
from composer.registry import NODES_BY_NAME
|
|
23
|
+
|
|
24
|
+
from .answers import Answers
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Gap:
|
|
29
|
+
"""One missing capability: what was asked for and the nearest registry alternatives."""
|
|
30
|
+
|
|
31
|
+
name: str
|
|
32
|
+
kind: str
|
|
33
|
+
need: str
|
|
34
|
+
nearest: tuple[str, ...] = ()
|
|
35
|
+
|
|
36
|
+
def render(self) -> str:
|
|
37
|
+
head = f" - {self.name} ({self.kind})"
|
|
38
|
+
if self.need:
|
|
39
|
+
head += f": {self.need}"
|
|
40
|
+
near = (
|
|
41
|
+
f" nearest in registry: {', '.join(self.nearest)}"
|
|
42
|
+
if self.nearest
|
|
43
|
+
else " nearest in registry: (nothing comparable — this is a new capability)"
|
|
44
|
+
)
|
|
45
|
+
return head + "\n" + near
|
|
46
|
+
|
|
47
|
+
def to_dict(self) -> dict:
|
|
48
|
+
return {
|
|
49
|
+
"name": self.name,
|
|
50
|
+
"kind": self.kind,
|
|
51
|
+
"need": self.need,
|
|
52
|
+
"nearest": list(self.nearest),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class GapReport:
|
|
58
|
+
"""The result of the feasibility check: every capability the registry lacks."""
|
|
59
|
+
|
|
60
|
+
strategy: str
|
|
61
|
+
archetype: str
|
|
62
|
+
gaps: list[Gap] = field(default_factory=list)
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def ok(self) -> bool:
|
|
66
|
+
return not self.gaps
|
|
67
|
+
|
|
68
|
+
def render(self) -> str:
|
|
69
|
+
if self.ok:
|
|
70
|
+
return (
|
|
71
|
+
f"OK: every capability {self.strategy!r} references is in the registry "
|
|
72
|
+
f"(no gaps)."
|
|
73
|
+
)
|
|
74
|
+
n = len(self.gaps)
|
|
75
|
+
lines = [
|
|
76
|
+
f"GAP REPORT: strategy {self.strategy!r} (archetype {self.archetype!r}) "
|
|
77
|
+
f"references {n} capabilit{'ies' if n != 1 else 'y'} the registry does not have.",
|
|
78
|
+
"Nothing was written — these must be added to the registry (runtime-owned) first:",
|
|
79
|
+
"",
|
|
80
|
+
]
|
|
81
|
+
for gap in self.gaps:
|
|
82
|
+
lines.append(gap.render())
|
|
83
|
+
lines.append("")
|
|
84
|
+
lines.append(
|
|
85
|
+
f"{n} pending capabilit{'ies' if n != 1 else 'y'}. Run `composer catalog` to "
|
|
86
|
+
f"see what IS available; a strategy that needs these is not yet buildable."
|
|
87
|
+
)
|
|
88
|
+
return "\n".join(lines) + "\n"
|
|
89
|
+
|
|
90
|
+
def to_json(self) -> dict:
|
|
91
|
+
return {
|
|
92
|
+
"strategy": self.strategy,
|
|
93
|
+
"archetype": self.archetype,
|
|
94
|
+
"ok": self.ok,
|
|
95
|
+
"gap_count": len(self.gaps),
|
|
96
|
+
"gaps": [g.to_dict() for g in self.gaps],
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def detect_gaps(answers: Answers) -> GapReport:
|
|
101
|
+
"""Check every registry-name reference in the answers against the closed vocabulary.
|
|
102
|
+
|
|
103
|
+
Only the ``requires`` list is checked here: an archetype template supplies its own
|
|
104
|
+
(known-good) nodes, so the only place an answer can name a capability the machine cannot
|
|
105
|
+
provide is when it explicitly declares a needed capability that the archetype cannot
|
|
106
|
+
cover. Each unknown name becomes a :class:`Gap` with the nearest registry alternatives.
|
|
107
|
+
"""
|
|
108
|
+
valid = sorted(NODES_BY_NAME)
|
|
109
|
+
gaps: list[Gap] = []
|
|
110
|
+
for cap in answers.requires:
|
|
111
|
+
if cap.name in NODES_BY_NAME:
|
|
112
|
+
continue
|
|
113
|
+
nearest = tuple(difflib.get_close_matches(cap.name, valid, n=3, cutoff=0.3))
|
|
114
|
+
gaps.append(Gap(name=cap.name, kind=cap.kind, need=cap.need, nearest=nearest))
|
|
115
|
+
return GapReport(strategy=answers.strategy, archetype=answers.archetype, gaps=gaps)
|