@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,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)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""generate — the `composer new` orchestration (D7 mechanics, all five steps in order).
|
|
2
|
+
|
|
3
|
+
1. answers in (typed; the interview output) — see ``answers``.
|
|
4
|
+
2. archetype templates are CODE — see ``archetypes`` (build the graph object).
|
|
5
|
+
3. mechanically-bindable values bound / bespoke slots TODO-marked with working defaults,
|
|
6
|
+
edge verbatim above the scoring slot — see ``builder`` + ``emit``.
|
|
7
|
+
4. feasibility gap report BEFORE anything is written — see ``gaps``; enforced here first.
|
|
8
|
+
5. no-clobber (refuse to overwrite an EDITED graph without --force) + off-map skeleton —
|
|
9
|
+
enforced here.
|
|
10
|
+
|
|
11
|
+
``compose_new`` is a pure planning function: it produces a :class:`NewResult` describing
|
|
12
|
+
what SHOULD happen (the rendered text, a gap report, or a clobber refusal) and never touches
|
|
13
|
+
the filesystem except the single read needed to detect an edited target. The CLI does the
|
|
14
|
+
write, so the behaviour is unit-testable without disk.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Optional
|
|
22
|
+
|
|
23
|
+
from . import emit
|
|
24
|
+
from .answers import Answers
|
|
25
|
+
from .archetypes import ARCHETYPES, SKELETON
|
|
26
|
+
from .gaps import GapReport, detect_gaps
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class NewResult:
|
|
31
|
+
"""The outcome of planning a `composer new` invocation."""
|
|
32
|
+
|
|
33
|
+
ok: bool # true == a graph is ready to write (yaml_text set)
|
|
34
|
+
yaml_text: Optional[str] = None
|
|
35
|
+
output_path: Optional[str] = None # where it would be written (may already exist)
|
|
36
|
+
gap_report: Optional[GapReport] = None # set (and ok=False) when feasibility fails
|
|
37
|
+
clobber_refused: bool = False # set (and ok=False) when the target is edited
|
|
38
|
+
used_skeleton: bool = False # true when the archetype was off-map
|
|
39
|
+
message: str = ""
|
|
40
|
+
|
|
41
|
+
def to_json(self) -> dict:
|
|
42
|
+
out: dict = {
|
|
43
|
+
"ok": self.ok,
|
|
44
|
+
"output_path": self.output_path,
|
|
45
|
+
"used_skeleton": self.used_skeleton,
|
|
46
|
+
"clobber_refused": self.clobber_refused,
|
|
47
|
+
"message": self.message,
|
|
48
|
+
}
|
|
49
|
+
if self.gap_report is not None:
|
|
50
|
+
out["gap_report"] = self.gap_report.to_json()
|
|
51
|
+
return out
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _bind_protection_default(answers: Answers) -> Answers:
|
|
55
|
+
"""PROTECTION BY DEFAULT (slice 9 policy): `composer new` BINDS ``protected_standard`` when
|
|
56
|
+
the author left ``exit_preset`` unset and did not opt out via ``unmanaged``. Overriding
|
|
57
|
+
(a chosen preset) or opting out (``unmanaged=true``) are both honoured untouched. Returns a
|
|
58
|
+
(possibly) updated copy so the emitted graph metadata carries the bound value."""
|
|
59
|
+
from .presets import DEFAULT_PRESET
|
|
60
|
+
if not answers.unmanaged and answers.exit_preset is None:
|
|
61
|
+
return answers.model_copy(update={"exit_preset": DEFAULT_PRESET})
|
|
62
|
+
return answers
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def render_graph(answers: Answers) -> tuple[str, bool]:
|
|
66
|
+
"""Build + render the graph for these answers; returns (yaml_text, used_skeleton)."""
|
|
67
|
+
answers = _bind_protection_default(answers)
|
|
68
|
+
module = ARCHETYPES.get(answers.archetype)
|
|
69
|
+
used_skeleton = module is None
|
|
70
|
+
if used_skeleton:
|
|
71
|
+
module = SKELETON
|
|
72
|
+
builder = module.build(answers)
|
|
73
|
+
return emit.render(builder, answers, module.BANNER), used_skeleton
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def compose_new(
|
|
77
|
+
answers: Answers,
|
|
78
|
+
output_path: str | Path,
|
|
79
|
+
*,
|
|
80
|
+
force: bool = False,
|
|
81
|
+
) -> NewResult:
|
|
82
|
+
"""Plan a `composer new` (does not write). See the module docstring for step order."""
|
|
83
|
+
path = Path(output_path)
|
|
84
|
+
|
|
85
|
+
# Step 4 — feasibility FIRST: a strategy referencing capabilities the registry lacks is
|
|
86
|
+
# not built and nothing is written (D4/D7). This wins over everything below.
|
|
87
|
+
gap_report = detect_gaps(answers)
|
|
88
|
+
if not gap_report.ok:
|
|
89
|
+
return NewResult(
|
|
90
|
+
ok=False,
|
|
91
|
+
output_path=str(path),
|
|
92
|
+
gap_report=gap_report,
|
|
93
|
+
message=(
|
|
94
|
+
f"{len(gap_report.gaps)} capability gap(s); nothing written. "
|
|
95
|
+
f"Add the missing runtime-owned nodes to the registry first."
|
|
96
|
+
),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# Steps 2+3+5(off-map) — build (archetype or skeleton) and render.
|
|
100
|
+
yaml_text, used_skeleton = render_graph(answers)
|
|
101
|
+
|
|
102
|
+
# Step 5 — no-clobber: refuse to overwrite a HAND-EDITED graph without --force. An
|
|
103
|
+
# unedited generated file (its stored content hash still matches) is safe to regenerate.
|
|
104
|
+
if path.exists() and not force:
|
|
105
|
+
existing = path.read_text(encoding="utf-8")
|
|
106
|
+
state = emit.is_unedited_generated(existing)
|
|
107
|
+
if state is not True:
|
|
108
|
+
reason = (
|
|
109
|
+
"the existing file was hand-edited since it was generated"
|
|
110
|
+
if state is False
|
|
111
|
+
else "the existing file was not produced by `composer new` (no content marker)"
|
|
112
|
+
)
|
|
113
|
+
return NewResult(
|
|
114
|
+
ok=False,
|
|
115
|
+
output_path=str(path),
|
|
116
|
+
clobber_refused=True,
|
|
117
|
+
used_skeleton=used_skeleton,
|
|
118
|
+
message=(
|
|
119
|
+
f"refusing to overwrite {path.name}: {reason}. "
|
|
120
|
+
f"Re-run with --force to overwrite (you will lose those edits)."
|
|
121
|
+
),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
verb = "skeleton (off-map archetype)" if used_skeleton else f"archetype {answers.archetype!r}"
|
|
125
|
+
return NewResult(
|
|
126
|
+
ok=True,
|
|
127
|
+
yaml_text=yaml_text,
|
|
128
|
+
output_path=str(path),
|
|
129
|
+
used_skeleton=used_skeleton,
|
|
130
|
+
message=f"generated a valid graph from {verb}.",
|
|
131
|
+
)
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""presets — the exit/risk presets as CI-checked CODE (slice 9 step 2; the PROTECTION policy).
|
|
2
|
+
|
|
3
|
+
Exits live entirely in `runtime.yaml` (never on the scanner's entry-only signal, see
|
|
4
|
+
`exits-dsl-recon.md`), and the runtime schema legally allows an exit-LESS config — positions
|
|
5
|
+
would then run completely unmanaged (no stop-loss, no trailing, no time-cut). That is the
|
|
6
|
+
disease the composer cures: **PROTECTION BY DEFAULT.** `composer new` binds
|
|
7
|
+
``exit_preset=protected_standard`` unless overridden; a unit with no exit and no explicit
|
|
8
|
+
``unmanaged: true`` opt-out is RED at check (CMP120).
|
|
9
|
+
|
|
10
|
+
Same "as-code, CI-checked" discipline as the archetype templates
|
|
11
|
+
(``composer.authoring.archetypes``): each preset is a frozen object with a `dsl_preset` block
|
|
12
|
+
(re-validated LIVE by the real Zod+DSL oracle in the slice-9 test suite — a preset that ever
|
|
13
|
+
stopped parsing fails CI, not a live deploy) and a `guard_rails` default. Derived from the
|
|
14
|
+
whole-corpus medians + the archetype exemplars (`runtime-unit-design.md` §2; the recon table
|
|
15
|
+
in `exits-dsl-recon.md`):
|
|
16
|
+
|
|
17
|
+
* protected_standard (DEFAULT) — corpus-median, thesis-shaped: balanced let-it-work + recycle.
|
|
18
|
+
* protected_tight — hawk-shaped: cut failed entries fast (tight P1).
|
|
19
|
+
* protected_wide — stag/bison-shaped: let runners breathe (wide P1, long timeout).
|
|
20
|
+
|
|
21
|
+
LIVE vs TODO(edit): the preset SELECTION and its phase1 / time-cut scalars are bound live
|
|
22
|
+
(they validate immediately). The phase2 tier ladder and phase1.max_loss_pct carry
|
|
23
|
+
``# TODO(edit)`` slot comments in the emitted runtime.yaml (the ratchet is the most
|
|
24
|
+
thesis-specific knob) while holding the preset default — a small model can tune them without
|
|
25
|
+
ever producing an invalid preset. See ``composer.authoring.runtime_emit``.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from typing import Optional
|
|
31
|
+
|
|
32
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ExitPreset(BaseModel):
|
|
36
|
+
"""One named exit+risk preset (protection profile). Frozen, constructed at import — a
|
|
37
|
+
malformed preset fails at construction (CI), never at a live deploy."""
|
|
38
|
+
|
|
39
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
40
|
+
|
|
41
|
+
name: str
|
|
42
|
+
intent: str
|
|
43
|
+
interval_seconds: int = 60
|
|
44
|
+
# The DSL exit preset block (shape validated LIVE by the Zod+DSL oracle in the tests).
|
|
45
|
+
dsl_preset: dict = Field(default_factory=dict)
|
|
46
|
+
# risk.guard_rails defaults for this preset (answers knobs override per-field, §4).
|
|
47
|
+
guard_rails: dict = Field(default_factory=dict)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _tiers(*pairs: tuple[int, int]) -> list[dict]:
|
|
51
|
+
return [{"trigger_pct": t, "lock_hw_pct": l} for t, l in pairs]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# --------------------------------------------------------------------------- the three presets
|
|
55
|
+
|
|
56
|
+
EXIT_PRESETS: dict[str, ExitPreset] = {
|
|
57
|
+
"protected_standard": ExitPreset(
|
|
58
|
+
name="protected_standard",
|
|
59
|
+
intent="corpus-median balanced protection (thesis-shaped): let it work + recycle.",
|
|
60
|
+
interval_seconds=60,
|
|
61
|
+
dsl_preset={
|
|
62
|
+
"hard_timeout": {"enabled": True, "interval_in_minutes": 4320}, # 3d
|
|
63
|
+
"weak_peak_cut": {"enabled": True, "interval_in_minutes": 240, "min_value": 2.0}, # 4h
|
|
64
|
+
"dead_weight_cut": {"enabled": False, "interval_in_minutes": 480},
|
|
65
|
+
"phase1": {
|
|
66
|
+
"enabled": True,
|
|
67
|
+
"max_loss_pct": 15.0,
|
|
68
|
+
"retrace_threshold": 8,
|
|
69
|
+
"consecutive_breaches_required": 1,
|
|
70
|
+
},
|
|
71
|
+
"phase2": {
|
|
72
|
+
"enabled": True,
|
|
73
|
+
"tiers": _tiers((10, 0), (22, 40), (40, 60), (75, 75), (120, 90)),
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
guard_rails={
|
|
77
|
+
"daily_loss_limit_pct": 12,
|
|
78
|
+
"max_consecutive_losses": 3,
|
|
79
|
+
"cooldown_seconds": 3600,
|
|
80
|
+
"drawdown_halt_pct": 20,
|
|
81
|
+
"drawdown_reset_on_day_rollover": False,
|
|
82
|
+
},
|
|
83
|
+
),
|
|
84
|
+
"protected_tight": ExitPreset(
|
|
85
|
+
name="protected_tight",
|
|
86
|
+
intent="hawk-shaped: TIGHT phase 1 cuts failed entries fast; wide phase-2 runner ladder.",
|
|
87
|
+
interval_seconds=60,
|
|
88
|
+
dsl_preset={
|
|
89
|
+
"hard_timeout": {"enabled": True, "interval_in_minutes": 1440}, # 24h
|
|
90
|
+
"weak_peak_cut": {"enabled": True, "interval_in_minutes": 60, "min_value": 3.0}, # 60m
|
|
91
|
+
"dead_weight_cut": {"enabled": False, "interval_in_minutes": 60},
|
|
92
|
+
"phase1": {
|
|
93
|
+
"enabled": True,
|
|
94
|
+
"max_loss_pct": 8.0,
|
|
95
|
+
"retrace_threshold": 5,
|
|
96
|
+
"consecutive_breaches_required": 1,
|
|
97
|
+
},
|
|
98
|
+
"phase2": {
|
|
99
|
+
"enabled": True,
|
|
100
|
+
"tiers": _tiers((10, 0), (20, 25), (30, 40), (50, 60), (75, 75), (100, 85)),
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
guard_rails={
|
|
104
|
+
"daily_loss_limit_pct": 15,
|
|
105
|
+
"max_consecutive_losses": 3,
|
|
106
|
+
"cooldown_seconds": 3600,
|
|
107
|
+
"drawdown_halt_pct": 18,
|
|
108
|
+
"drawdown_reset_on_day_rollover": False,
|
|
109
|
+
},
|
|
110
|
+
),
|
|
111
|
+
"protected_wide": ExitPreset(
|
|
112
|
+
name="protected_wide",
|
|
113
|
+
intent="stag/bison-shaped: WIDE phase 1 + long timeout — let a runner breathe.",
|
|
114
|
+
interval_seconds=60,
|
|
115
|
+
dsl_preset={
|
|
116
|
+
"hard_timeout": {"enabled": True, "interval_in_minutes": 20160}, # 14d
|
|
117
|
+
"weak_peak_cut": {"enabled": False, "interval_in_minutes": 240},
|
|
118
|
+
"dead_weight_cut": {"enabled": False, "interval_in_minutes": 480},
|
|
119
|
+
"phase1": {
|
|
120
|
+
"enabled": True,
|
|
121
|
+
"max_loss_pct": 25.0,
|
|
122
|
+
"retrace_threshold": 15,
|
|
123
|
+
"consecutive_breaches_required": 2,
|
|
124
|
+
},
|
|
125
|
+
"phase2": {
|
|
126
|
+
"enabled": True,
|
|
127
|
+
"tiers": _tiers((15, 0), (30, 30), (60, 55), (120, 72), (250, 85)),
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
guard_rails={
|
|
131
|
+
"daily_loss_limit_pct": 15,
|
|
132
|
+
"max_consecutive_losses": 4,
|
|
133
|
+
"cooldown_seconds": 3600,
|
|
134
|
+
"drawdown_halt_pct": 25,
|
|
135
|
+
"drawdown_reset_on_day_rollover": False,
|
|
136
|
+
},
|
|
137
|
+
),
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
DEFAULT_PRESET = "protected_standard"
|
|
141
|
+
|
|
142
|
+
# guard_rails keys an answers `risk.*` knob overrides (design §4): each is a declarative
|
|
143
|
+
# runtime.yaml field the runtime enforces. answer-field -> guard_rails key.
|
|
144
|
+
RISK_OVERRIDE_KEYS = {
|
|
145
|
+
"drawdown_halt_pct": "drawdown_halt_pct",
|
|
146
|
+
"max_consecutive_losses": "max_consecutive_losses",
|
|
147
|
+
"cooldown_seconds": "cooldown_seconds",
|
|
148
|
+
"max_entries_per_day": "max_entries_per_day",
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def preset_names() -> list[str]:
|
|
153
|
+
"""Sorted preset names — used by the catalog surface and error hints. Byte-stable."""
|
|
154
|
+
return sorted(EXIT_PRESETS)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def resolve_guard_rails(preset: ExitPreset, risk_overrides: Optional[dict] = None) -> dict:
|
|
158
|
+
"""The preset's guard_rails with any answer-supplied risk knobs layered on top (§4).
|
|
159
|
+
|
|
160
|
+
``risk_overrides`` is a plain dict of the answers ``Risk`` pause knobs; a ``None`` value
|
|
161
|
+
means "not set — use the preset default". Returns an ordered dict for deterministic emit.
|
|
162
|
+
"""
|
|
163
|
+
out = dict(preset.guard_rails)
|
|
164
|
+
for ans_key, gr_key in RISK_OVERRIDE_KEYS.items():
|
|
165
|
+
val = (risk_overrides or {}).get(ans_key)
|
|
166
|
+
if val is not None:
|
|
167
|
+
out[gr_key] = val
|
|
168
|
+
return out
|