@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.
Files changed (108) 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 +396 -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 +190 -0
  9. package/dist/composer/AUTHORING.md +435 -0
  10. package/dist/composer/__init__.py +29 -0
  11. package/dist/composer/authoring/__init__.py +59 -0
  12. package/dist/composer/authoring/answers.py +297 -0
  13. package/dist/composer/authoring/archetypes/__init__.py +26 -0
  14. package/dist/composer/authoring/archetypes/_common.py +410 -0
  15. package/dist/composer/authoring/archetypes/breakout.py +134 -0
  16. package/dist/composer/authoring/archetypes/classifier.py +109 -0
  17. package/dist/composer/authoring/archetypes/skeleton.py +76 -0
  18. package/dist/composer/authoring/archetypes/thesis_fund.py +103 -0
  19. package/dist/composer/authoring/archetypes/trend_momentum.py +132 -0
  20. package/dist/composer/authoring/builder.py +191 -0
  21. package/dist/composer/authoring/emit.py +264 -0
  22. package/dist/composer/authoring/gaps.py +115 -0
  23. package/dist/composer/authoring/generate.py +131 -0
  24. package/dist/composer/authoring/presets.py +168 -0
  25. package/dist/composer/authoring/runtime_emit.py +266 -0
  26. package/dist/composer/authoring/strategy_manifest.py +94 -0
  27. package/dist/composer/cli.py +487 -0
  28. package/dist/composer/compiler/__init__.py +22 -0
  29. package/dist/composer/compiler/compile.py +739 -0
  30. package/dist/composer/examples/breakout_swing.graph.yaml +121 -0
  31. package/dist/composer/examples/conviction_scorer.graph.yaml +216 -0
  32. package/dist/composer/examples/macro_thesis_basket.graph.yaml +247 -0
  33. package/dist/composer/examples/pre_listing_classifier.graph.yaml +204 -0
  34. package/dist/composer/examples/trend_momentum_majors.graph.yaml +133 -0
  35. package/dist/composer/generated/README.md +22 -0
  36. package/dist/composer/generated/catalog.md +118 -0
  37. package/dist/composer/generated/describe.md +1168 -0
  38. package/dist/composer/generated/graph.schema.json +1946 -0
  39. package/dist/composer/primitives/__init__.py +320 -0
  40. package/dist/composer/primitives/_candles.py +76 -0
  41. package/dist/composer/primitives/adapter.py +363 -0
  42. package/dist/composer/primitives/emitters.py +142 -0
  43. package/dist/composer/primitives/filters.py +71 -0
  44. package/dist/composer/primitives/indicators.py +326 -0
  45. package/dist/composer/primitives/rankers.py +40 -0
  46. package/dist/composer/primitives/scorers.py +100 -0
  47. package/dist/composer/primitives/sources.py +979 -0
  48. package/dist/composer/primitives/stateful.py +254 -0
  49. package/dist/composer/pure_fn/__init__.py +34 -0
  50. package/dist/composer/pure_fn/checks.py +39 -0
  51. package/dist/composer/pure_fn/models.py +177 -0
  52. package/dist/composer/pure_fn/purity.py +311 -0
  53. package/dist/composer/pure_fn/runtests.py +155 -0
  54. package/dist/composer/registry/__init__.py +40 -0
  55. package/dist/composer/registry/generate.py +425 -0
  56. package/dist/composer/registry/mcp-tools-snapshot.json +193 -0
  57. package/dist/composer/registry/mcp_closure.py +311 -0
  58. package/dist/composer/registry/models.py +417 -0
  59. package/dist/composer/registry/nodes.py +1297 -0
  60. package/dist/composer/requirements.txt +21 -0
  61. package/dist/composer/scaffold_path.py +118 -0
  62. package/dist/composer/templates/__init__.py +13 -0
  63. package/dist/composer/templates/scan.py.j2 +34 -0
  64. package/dist/composer/validator/__init__.py +29 -0
  65. package/dist/composer/validator/desugar.py +133 -0
  66. package/dist/composer/validator/errors.py +227 -0
  67. package/dist/composer/validator/graph_checks.py +443 -0
  68. package/dist/composer/validator/loader.py +110 -0
  69. package/dist/composer/validator/schema_checks.py +147 -0
  70. package/dist/composer/validator/validate.py +86 -0
  71. package/dist/composer/vendoring.py +103 -0
  72. package/dist/composer/verbs/__init__.py +49 -0
  73. package/dist/composer/verbs/_box.py +308 -0
  74. package/dist/composer/verbs/check.py +254 -0
  75. package/dist/composer/verbs/close.py +494 -0
  76. package/dist/composer/verbs/deploy.py +279 -0
  77. package/dist/composer/verbs/docs.py +30 -0
  78. package/dist/composer/verbs/fund.py +498 -0
  79. package/dist/composer/verbs/install.py +586 -0
  80. package/dist/composer/verbs/runtime_validate.py +422 -0
  81. package/dist/composer/verbs/smoke.py +134 -0
  82. package/dist/composer/verbs/status.py +383 -0
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +2 -9
  85. package/dist/index.js.map +1 -1
  86. package/dist/runtime/load-runtime-config.d.ts +17 -0
  87. package/dist/runtime/load-runtime-config.d.ts.map +1 -1
  88. package/dist/runtime/load-runtime-config.js +2 -2
  89. package/dist/runtime/load-runtime-config.js.map +1 -1
  90. package/dist/runtime-api/config.d.ts +2 -2
  91. package/dist/runtime-api/config.js +4 -4
  92. package/dist/runtime-api/config.js.map +1 -1
  93. package/dist/runtime-api/handlers/ingest-signals.d.ts +3 -5
  94. package/dist/runtime-api/handlers/ingest-signals.d.ts.map +1 -1
  95. package/dist/runtime-api/handlers/ingest-signals.js +5 -14
  96. package/dist/runtime-api/handlers/ingest-signals.js.map +1 -1
  97. package/dist/runtime-api/intake/intake-mount.d.ts.map +1 -1
  98. package/dist/runtime-api/intake/intake-mount.js +2 -8
  99. package/dist/runtime-api/intake/intake-mount.js.map +1 -1
  100. package/dist/skills-manager/manifest.d.ts.map +1 -1
  101. package/dist/skills-manager/manifest.js +1 -3
  102. package/dist/skills-manager/manifest.js.map +1 -1
  103. package/dist/skills-manager/skills-manifest.json +1 -3
  104. package/package.json +4 -2
  105. package/dist/runtime-api/intake/ingest-mutex.d.ts +0 -16
  106. package/dist/runtime-api/intake/ingest-mutex.d.ts.map +0 -1
  107. package/dist/runtime-api/intake/ingest-mutex.js +0 -18
  108. 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