@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.
Files changed (94) 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 +287 -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 +124 -0
  9. package/dist/composer/AUTHORING.md +233 -0
  10. package/dist/composer/__init__.py +29 -0
  11. package/dist/composer/authoring/__init__.py +49 -0
  12. package/dist/composer/authoring/answers.py +226 -0
  13. package/dist/composer/authoring/archetypes/__init__.py +25 -0
  14. package/dist/composer/authoring/archetypes/_common.py +218 -0
  15. package/dist/composer/authoring/archetypes/classifier.py +109 -0
  16. package/dist/composer/authoring/archetypes/skeleton.py +54 -0
  17. package/dist/composer/authoring/archetypes/thesis_fund.py +103 -0
  18. package/dist/composer/authoring/archetypes/trend_momentum.py +104 -0
  19. package/dist/composer/authoring/builder.py +191 -0
  20. package/dist/composer/authoring/emit.py +264 -0
  21. package/dist/composer/authoring/gaps.py +115 -0
  22. package/dist/composer/authoring/generate.py +131 -0
  23. package/dist/composer/authoring/presets.py +168 -0
  24. package/dist/composer/authoring/runtime_emit.py +266 -0
  25. package/dist/composer/authoring/strategy_manifest.py +94 -0
  26. package/dist/composer/cli.py +355 -0
  27. package/dist/composer/compiler/__init__.py +22 -0
  28. package/dist/composer/compiler/compile.py +550 -0
  29. package/dist/composer/examples/breakout_swing.graph.yaml +121 -0
  30. package/dist/composer/examples/conviction_scorer.graph.yaml +216 -0
  31. package/dist/composer/examples/macro_thesis_basket.graph.yaml +247 -0
  32. package/dist/composer/examples/pre_listing_classifier.graph.yaml +204 -0
  33. package/dist/composer/examples/trend_momentum_majors.graph.yaml +133 -0
  34. package/dist/composer/generated/README.md +22 -0
  35. package/dist/composer/generated/catalog.md +84 -0
  36. package/dist/composer/generated/describe.md +703 -0
  37. package/dist/composer/generated/graph.schema.json +1203 -0
  38. package/dist/composer/primitives/__init__.py +239 -0
  39. package/dist/composer/primitives/_candles.py +76 -0
  40. package/dist/composer/primitives/adapter.py +120 -0
  41. package/dist/composer/primitives/emitters.py +142 -0
  42. package/dist/composer/primitives/filters.py +71 -0
  43. package/dist/composer/primitives/indicators.py +203 -0
  44. package/dist/composer/primitives/rankers.py +40 -0
  45. package/dist/composer/primitives/scorers.py +100 -0
  46. package/dist/composer/primitives/sources.py +405 -0
  47. package/dist/composer/primitives/stateful.py +52 -0
  48. package/dist/composer/pure_fn/__init__.py +34 -0
  49. package/dist/composer/pure_fn/checks.py +39 -0
  50. package/dist/composer/pure_fn/models.py +177 -0
  51. package/dist/composer/pure_fn/purity.py +311 -0
  52. package/dist/composer/pure_fn/runtests.py +155 -0
  53. package/dist/composer/registry/__init__.py +40 -0
  54. package/dist/composer/registry/generate.py +422 -0
  55. package/dist/composer/registry/models.py +331 -0
  56. package/dist/composer/registry/nodes.py +673 -0
  57. package/dist/composer/requirements.txt +21 -0
  58. package/dist/composer/templates/__init__.py +13 -0
  59. package/dist/composer/templates/scan.py.j2 +31 -0
  60. package/dist/composer/validator/__init__.py +29 -0
  61. package/dist/composer/validator/desugar.py +133 -0
  62. package/dist/composer/validator/errors.py +191 -0
  63. package/dist/composer/validator/graph_checks.py +348 -0
  64. package/dist/composer/validator/loader.py +110 -0
  65. package/dist/composer/validator/schema_checks.py +147 -0
  66. package/dist/composer/validator/validate.py +86 -0
  67. package/dist/composer/verbs/__init__.py +33 -0
  68. package/dist/composer/verbs/check.py +193 -0
  69. package/dist/composer/verbs/deploy.py +223 -0
  70. package/dist/composer/verbs/docs.py +30 -0
  71. package/dist/composer/verbs/runtime_validate.py +262 -0
  72. package/dist/composer/verbs/smoke.py +115 -0
  73. package/dist/composer/verbs/status.py +205 -0
  74. package/dist/index.d.ts.map +1 -1
  75. package/dist/index.js +2 -9
  76. package/dist/index.js.map +1 -1
  77. package/dist/runtime-api/config.d.ts +2 -2
  78. package/dist/runtime-api/config.js +4 -4
  79. package/dist/runtime-api/config.js.map +1 -1
  80. package/dist/runtime-api/handlers/ingest-signals.d.ts +3 -5
  81. package/dist/runtime-api/handlers/ingest-signals.d.ts.map +1 -1
  82. package/dist/runtime-api/handlers/ingest-signals.js +5 -14
  83. package/dist/runtime-api/handlers/ingest-signals.js.map +1 -1
  84. package/dist/runtime-api/intake/intake-mount.d.ts.map +1 -1
  85. package/dist/runtime-api/intake/intake-mount.js +2 -8
  86. package/dist/runtime-api/intake/intake-mount.js.map +1 -1
  87. package/dist/skills-manager/manifest.js +1 -1
  88. package/dist/skills-manager/manifest.js.map +1 -1
  89. package/dist/skills-manager/skills-manifest.json +1 -1
  90. package/package.json +4 -2
  91. package/dist/runtime-api/intake/ingest-mutex.d.ts +0 -16
  92. package/dist/runtime-api/intake/ingest-mutex.d.ts.map +0 -1
  93. package/dist/runtime-api/intake/ingest-mutex.js +0 -18
  94. 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)