@senpi/runtime 3.0.9-dev.feat-strategy-compos.20260718011320 → 3.0.9-dev.feat-strategy-compos.20260719221425

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.
@@ -20,6 +20,7 @@ from .answers import (
20
20
  Answers,
21
21
  ExitSignals,
22
22
  Indicator,
23
+ Protection,
23
24
  Risk,
24
25
  Scoring,
25
26
  Universe,
@@ -38,6 +39,7 @@ __all__ = [
38
39
  "Answers",
39
40
  "ExitSignals",
40
41
  "Indicator",
42
+ "Protection",
41
43
  "Risk",
42
44
  "Scoring",
43
45
  "Universe",
@@ -90,6 +90,31 @@ class ExitSignals(BaseModel):
90
90
  min_divergence: float = 0.0 # per-window vote noise gate (0.0 = no-op); relative_value only
91
91
 
92
92
 
93
+ class Protection(BaseModel):
94
+ """The MATERIALIZED exit-protection spec — the FULL resolved protection carried IN the graph
95
+ (F1 "the graph IS the spec"). Before F1 the exit values were resolved from a named preset only
96
+ at EMIT time, invisible in the graph and un-editable (a hand-edit of the emitted runtime.yaml
97
+ breaks the content-addressed install hash). Now ``composer new`` RESOLVES the seeding preset's
98
+ ``dsl_preset`` (time-cuts + phase1 floor + the phase2 tier ladder) and the risk-overridden
99
+ ``guard_rails`` into ``metadata.spec.protection``, so every tunable exit knob lives in the graph
100
+ and survives regeneration. Edit it there, then ``composer check`` (which re-validates the
101
+ ladder through the LIVE Zod+DSL oracle over the emitted runtime.yaml) + ``deploy``.
102
+
103
+ ``seeded_from`` is PROVENANCE only — the preset name that seeded these values at generation
104
+ (an edit does not change it; it never re-resolves from the preset). ``dsl_preset`` and
105
+ ``guard_rails`` are kept as plain dicts, NOT re-typed here: the single authority on ladder
106
+ validity is the live oracle over the emitted runtime.yaml (a re-typed twin would drift from the
107
+ real Zod schema). A structurally-malformed hand-edit is caught pure-Python (CMP124) before emit;
108
+ a value-level one (tier order, ranges, breaches) is caught by the oracle (CMP110)."""
109
+
110
+ model_config = ConfigDict(extra="forbid")
111
+
112
+ seeded_from: str
113
+ interval_seconds: int = 60
114
+ dsl_preset: dict = Field(default_factory=dict)
115
+ guard_rails: dict = Field(default_factory=dict)
116
+
117
+
93
118
  class Universe(BaseModel):
94
119
  """How the tradeable set is chosen. A superset of the corpus shapes; each archetype
95
120
  reads the fields it needs (D4 — the answer stays declarative, the template binds it):
@@ -196,6 +221,14 @@ class Answers(BaseModel):
196
221
  unmanaged: bool = False # explicit opt-out: no exit section (else CMP120 RED at check)
197
222
  risk: Risk = Field(default_factory=Risk)
198
223
  edge: str = "" # the user's EDGE, verbatim; the machine never interprets it
224
+ # F1 — the MATERIALIZED protection spec + a free-prose thesis, both carried in metadata.spec.
225
+ # ``protection`` is None on directly-constructed answers (byte-identical to before) and is set by
226
+ # `composer new` (generate._materialize_protection) so the resolved exit ladder lives IN the graph
227
+ # and survives regeneration + edit. ``thesis`` is an optional one-line intent summary generation
228
+ # derives from the edge (never blocking). Both emit into metadata.spec ONLY when set (round-trip
229
+ # discipline: an answers object without them round-trips byte-identically).
230
+ protection: Optional[Protection] = None
231
+ thesis: str = ""
199
232
  requires: tuple[RequiredCapability, ...] = ()
200
233
  # F1 — OPTIONAL expressiveness surface. Absent (the defaults below) => byte-identical to
201
234
  # the pre-F1 output; present => the archetype re-selects gates/weights (scoring) and adds
@@ -243,6 +276,9 @@ class Answers(BaseModel):
243
276
  block: dict = {
244
277
  "archetype": self.archetype,
245
278
  "strategy": self.strategy,
279
+ # F1: a one-line intent thesis, emitted ONLY when generation derived one (round-trip
280
+ # discipline — an answers object with thesis="" carries no key and round-trips as before).
281
+ **({"thesis": self.thesis} if self.thesis else {}),
246
282
  "universe": {
247
283
  "whitelist": list(self.universe.whitelist),
248
284
  "long_basket": list(self.universe.long_basket),
@@ -256,8 +292,25 @@ class Answers(BaseModel):
256
292
  "intervals": list(self.intervals),
257
293
  "max_positions": self.max_positions,
258
294
  "ttl_seconds": self.ttl_seconds,
259
- "exit_preset": self.exit_preset, # LIVE: the emitted runtime.yaml exit preset
295
+ "exit_preset": self.exit_preset, # LIVE: the emitted runtime.yaml exit preset (SEED)
260
296
  "unmanaged": self.unmanaged, # explicit protection opt-out
297
+ # F1: the MATERIALIZED protection block — the resolved exit ladder + guard rails carried
298
+ # IN the graph so they are visible + editable + survive regeneration. Emitted ONLY when
299
+ # set (a directly-constructed answers has protection=None and round-trips byte-identically;
300
+ # `composer new` materializes it — generate._materialize_protection). runtime_emit reads
301
+ # this when present; the preset is only the SEED at generation time.
302
+ **(
303
+ {
304
+ "protection": {
305
+ "seeded_from": self.protection.seeded_from,
306
+ "interval_seconds": self.protection.interval_seconds,
307
+ "dsl_preset": self.protection.dsl_preset,
308
+ "guard_rails": self.protection.guard_rails,
309
+ }
310
+ }
311
+ if self.protection is not None
312
+ else {}
313
+ ),
261
314
  "risk": {
262
315
  "margin_pct": self.risk.margin_pct,
263
316
  "leverage": self.risk.leverage,
@@ -89,6 +89,7 @@ def build(answers: Answers) -> GraphBuilder:
89
89
  fn=_common.CLASSIFIER_FN,
90
90
  tests=_common.classifier_tests(_MIN_SCORE),
91
91
  header=("pure_fn classifier — THE EDGE SLOT (edit the body; see the EDGE block above).",),
92
+ title="direction classifier from 4h/1h trend + smart-money lean",
92
93
  )
93
94
  b.set_edge_anchor("classify")
94
95
 
@@ -337,6 +337,7 @@ def build(answers: Answers) -> GraphBuilder:
337
337
  min_divergence),
338
338
  header=("pure_fn signed-net-score comparison — THE EDGE SLOT (edit the body; see the EDGE",
339
339
  "block above). TWO outputs: entry_scores (entry tail) + exit_scores (exit tail).",),
340
+ title="signed net-score: target vs benchmark across lookbacks",
340
341
  )
341
342
  b.set_edge_anchor("score")
342
343
 
@@ -84,6 +84,7 @@ def build(answers: Answers) -> GraphBuilder:
84
84
  fn=_common.THESIS_FN,
85
85
  tests=_common.thesis_tests(list(u.long_basket), list(u.short_basket), _MIN_SCORE),
86
86
  header=("pure_fn confirmation scorer — THE EDGE SLOT (edit the body; see the EDGE block above).",),
87
+ title="per-leg thesis confirmation score",
87
88
  )
88
89
  b.set_edge_anchor("score")
89
90
 
@@ -41,6 +41,7 @@ class BuiltNode:
41
41
  pure_params: tuple[dict, ...] = ()
42
42
  fn: Optional[str] = None
43
43
  tests: tuple[dict, ...] = ()
44
+ title: Optional[str] = None # pure_fn OPTIONAL one-line title (one semantic unit)
44
45
  # presentation
45
46
  header: tuple[str, ...] = () # comment lines emitted above the node
46
47
  param_comments: dict = field(default_factory=dict) # param -> trailing comment
@@ -117,9 +118,13 @@ class GraphBuilder:
117
118
  fn: str,
118
119
  tests: tuple[dict, ...] = (),
119
120
  header: tuple[str, ...] = (),
121
+ title: Optional[str] = None,
120
122
  ) -> "GraphBuilder":
121
123
  """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)."""
124
+ PortType vocabulary — a bad type is a ``ValueError`` (CI catches template drift).
125
+
126
+ ``title`` is the OPTIONAL one-line "one titled semantic unit" label. Generation SHOULD
127
+ pass it for every pure_fn it emits (a missing title is a non-blocking check annotation)."""
123
128
  for side, ports in (("input", inputs), ("output", outputs)):
124
129
  for p in ports:
125
130
  try:
@@ -139,6 +144,7 @@ class GraphBuilder:
139
144
  fn=fn,
140
145
  tests=tuple(tests),
141
146
  header=tuple(header),
147
+ title=title,
142
148
  )
143
149
  )
144
150
  return self
@@ -158,6 +158,10 @@ def _emit_node(bn: BuiltNode) -> list[str]:
158
158
 
159
159
  if bn.kind == "pure_fn":
160
160
  lines.append(" kind: pure_fn")
161
+ if bn.title:
162
+ # OPTIONAL one-line title (one titled semantic unit); emitted only when set so
163
+ # title-less pure_fns round-trip byte-identically.
164
+ lines.append(f" title: {_dq(bn.title)}")
161
165
  lines.append(" inputs:")
162
166
  for p in bn.inputs:
163
167
  lines.append(f" - {_inline_port(p)}")
@@ -199,13 +203,15 @@ def render(builder: GraphBuilder, answers: Answers, banner: list[str]) -> str:
199
203
  lines.append("version: 1")
200
204
  lines.append("")
201
205
 
202
- # --- metadata (D7: the spec file IS the state — answers live here) ---
206
+ # --- metadata (D7: the spec file IS the state — the interview spec lives under `spec`; F1
207
+ # renamed the block `answers` -> `spec` since it now carries the MATERIALIZED protection too.
208
+ # The loader still reads a legacy `answers` block for back-compat) ---
203
209
  lines.append("metadata:")
204
210
  meta = {
205
211
  "archetype": answers.archetype,
206
212
  "strategy": answers.strategy,
207
213
  "generated": {"by": "composer new", "content_hash": _HASH_PLACEHOLDER},
208
- "answers": answers.metadata_block(),
214
+ "spec": answers.metadata_block(),
209
215
  }
210
216
  for k, v in meta.items():
211
217
  lines.extend(_emit_kv(k, v, 2))
@@ -21,7 +21,7 @@ from pathlib import Path
21
21
  from typing import Optional
22
22
 
23
23
  from . import emit
24
- from .answers import Answers, defaulted_interview_fields
24
+ from .answers import Answers, Protection, defaulted_interview_fields
25
25
  from .archetypes import ARCHETYPES, SKELETON
26
26
  from .gaps import GapReport, detect_gaps
27
27
 
@@ -73,6 +73,50 @@ def _bind_protection_default(answers: Answers) -> Answers:
73
73
  return answers
74
74
 
75
75
 
76
+ def _materialize_protection(answers: Answers) -> Answers:
77
+ """F1 — MATERIALIZE the resolved protection into the graph spec (the graph IS the spec).
78
+
79
+ Before F1 the exit ladder was resolved from a named preset only at EMIT time — invisible in the
80
+ graph, un-editable (a hand-edit of the emitted runtime.yaml breaks the content-addressed install
81
+ hash). Now generation writes the FULL resolved block into ``metadata.spec.protection``: the
82
+ seeding preset's ``dsl_preset`` (deep-copied so a later graph edit never mutates the shared
83
+ preset) and the guard_rails resolved through the SAME helper the emit path uses (byte-identity),
84
+ with ``seeded_from`` recording the provenance preset. Only a PROTECTED unit materializes — an
85
+ unmanaged opt-out has no exit block, and an explicit ``protection`` (a re-generation over an
86
+ already-materialized answers) is left untouched. Returns a (possibly) updated copy."""
87
+ import copy
88
+
89
+ from .presets import EXIT_PRESETS
90
+ from .runtime_emit import resolve_emit_guard_rails
91
+
92
+ if answers.unmanaged or answers.exit_preset is None or answers.protection is not None:
93
+ return answers
94
+ preset = EXIT_PRESETS[answers.exit_preset]
95
+ protection = Protection(
96
+ seeded_from=preset.name,
97
+ interval_seconds=preset.interval_seconds,
98
+ dsl_preset=copy.deepcopy(preset.dsl_preset),
99
+ guard_rails=resolve_emit_guard_rails(answers, preset),
100
+ )
101
+ return answers.model_copy(update={"protection": protection})
102
+
103
+
104
+ def _derive_thesis(answers: Answers) -> Answers:
105
+ """F1 — write a short, deterministic intent thesis into the graph spec when none was supplied.
106
+
107
+ Derived from the interview intent (the verbatim edge, normalized to one line and capped); when
108
+ there is no edge, a generic archetype descriptor. Purely descriptive graph metadata — it never
109
+ reaches the runtime.yaml and never blocks generation. An explicit thesis is left untouched."""
110
+ if answers.thesis:
111
+ return answers
112
+ edge = " ".join((answers.edge or "").split()).strip()
113
+ if edge:
114
+ thesis = edge if len(edge) <= 200 else edge[:197].rstrip() + "..."
115
+ else:
116
+ thesis = f"A {answers.archetype} strategy ({answers.strategy})."
117
+ return answers.model_copy(update={"thesis": thesis})
118
+
119
+
76
120
  def _fmt_default_value(value) -> str:
77
121
  """Render a baked default value for the DEFAULTED echo: ``None`` (a preset-default knob like
78
122
  drawdown_halt_pct) reads as ``none`` so the reader sees it was never offered."""
@@ -107,6 +151,8 @@ def _defaulted_message(lines: list[str]) -> str:
107
151
  def render_graph(answers: Answers) -> tuple[str, bool]:
108
152
  """Build + render the graph for these answers; returns (yaml_text, used_skeleton)."""
109
153
  answers = _bind_protection_default(answers)
154
+ answers = _materialize_protection(answers) # F1: resolved protection INTO the graph spec
155
+ answers = _derive_thesis(answers) # F1: a one-line intent thesis in the graph spec
110
156
  module = ARCHETYPES.get(answers.archetype)
111
157
  used_skeleton = module is None
112
158
  if used_skeleton:
@@ -6,12 +6,14 @@ The composer no longer stages a bare scanner (entry-less, exit-less — the inco
6
6
  (NOT nodes in the DAG — they are consumed by the DslPlugin / ActionRegistry at runtime boot,
7
7
  a different lifecycle than the ``nodes``/``edges`` compiled into ``scan.py``; design §6).
8
8
 
9
- Everything derives from the graph's ``metadata.answers`` (the single source of truth both
10
- artifacts share, D7), so recompiling regenerates ``scan.py`` AND ``runtime.yaml``
11
- byte-identically. LIVE bindings: strategy sizing, the scanner cadence, the exit preset
12
- SELECTION and its phase1 / time-cut scalars, the guard_rails. TODO(edit) slots (working
13
- default in place, still valid): ``exit.dsl_preset.phase1.max_loss_pct`` and each
14
- ``exit.dsl_preset.phase2.tiers`` entry (the ratchet is the most thesis-specific knob).
9
+ Everything derives from the graph's ``metadata.spec`` (the single source of truth both
10
+ artifacts share, D7; the legacy ``metadata.answers`` key is still read for back-compat), so
11
+ recompiling regenerates ``scan.py`` AND ``runtime.yaml`` byte-identically. The exit ``dsl_preset``
12
+ (time-cuts + phase1 floor + the phase2 tier ladder) and ``risk.guard_rails`` are rendered from
13
+ ``spec.protection`` when the graph carries it (F1 — the graph IS the spec; the emitted file is
14
+ INSPECT-ONLY build output). An older graph with no materialized ``spec.protection`` falls back to
15
+ resolving them live from the named preset (byte-identical). To tune protection, edit
16
+ ``spec.protection`` in the GRAPH, then ``composer check`` + ``deploy`` — never hand-edit this file.
15
17
 
16
18
  ``unmanaged=True`` emits NO ``exit`` section plus a loud warning banner — the deliberate,
17
19
  explicit opt-out of protection (a graph that reaches here with ``unmanaged=False`` and no
@@ -42,6 +44,28 @@ _UNMANAGED_BANNER = [
42
44
  ]
43
45
 
44
46
 
47
+ def resolve_emit_guard_rails(answers: Answers, preset) -> dict:
48
+ """The guard_rails dict the runtime.yaml ``risk:`` section renders — the preset's guard_rails
49
+ with the answers ``risk.*`` overrides layered on, plus the signal-exit-derived per-asset cooldown
50
+ (an explicit risk override always wins; else a signal-exit pair supplies its reentry cooldown;
51
+ else absent). Extracted so BOTH generation (materialize into ``spec.protection.guard_rails``) and
52
+ the emit-time fallback (an older graph with no materialized protection) compute the SAME dict —
53
+ the guarantee that a freshly-materialized graph emits a BYTE-IDENTICAL runtime.yaml."""
54
+ per_asset_cooldown = answers.risk.per_asset_cooldown_seconds
55
+ if per_asset_cooldown is None and answers.exit_signals is not None:
56
+ per_asset_cooldown = answers.exit_signals.reentry_cooldown_seconds
57
+ return resolve_guard_rails(
58
+ preset,
59
+ {
60
+ "drawdown_halt_pct": answers.risk.drawdown_halt_pct,
61
+ "max_consecutive_losses": answers.risk.max_consecutive_losses,
62
+ "cooldown_seconds": answers.risk.cooldown_seconds,
63
+ "max_entries_per_day": answers.risk.max_entries_per_day,
64
+ "per_asset_cooldown_seconds": per_asset_cooldown,
65
+ },
66
+ )
67
+
68
+
45
69
  def _env_wallet(strategy: str) -> str:
46
70
  slug = re.sub(r"[^0-9a-zA-Z]+", "_", strategy).strip("_").upper() or "STRATEGY"
47
71
  return "${" + f"{slug}_WALLET" + "}"
@@ -121,12 +145,15 @@ def render_runtime_yaml(answers: Answers) -> str:
121
145
  lines.append(f"# {b}")
122
146
  elif has_exit:
123
147
  preset = EXIT_PRESETS[answers.exit_preset]
148
+ seeded = answers.protection.seeded_from if answers.protection is not None else answers.exit_preset
124
149
  lines.append(f"# {strategy} — complete standalone strategy runtime (composer deploy).")
125
150
  lines.append("#")
126
- lines.append("# PROTECTED BY DEFAULT: the exit: section below is the resolved preset")
127
- lines.append(f"# '{answers.exit_preset}' — {preset.intent}")
151
+ lines.append("# PROTECTED BY DEFAULT: the exit: / risk: sections below are BUILD OUTPUT, rendered")
152
+ lines.append(f"# from the graph's metadata.spec.protection (seeded from preset '{seeded}').")
128
153
  lines.append("# A standalone runtime in its own funded wallet; ops mints the wallet + installs")
129
- lines.append("# (one wallet per instance). Edit ONLY the TODO(edit) slots to tune the exit.")
154
+ lines.append("# (one wallet per instance). This file is INSPECT-ONLY: to tune protection, edit")
155
+ lines.append("# spec.protection in the graph, then re-run composer check + deploy + update. Do")
156
+ lines.append("# NOT hand-edit this file — a content-addressed install verifies its hash.")
130
157
  else:
131
158
  # exit_preset unset AND not opted out — the CMP120 contradiction (check flags it RED).
132
159
  lines.append(f"# {strategy} — runtime with NO exit preset set and NO unmanaged opt-out.")
@@ -215,14 +242,23 @@ def render_runtime_yaml(answers: Answers) -> str:
215
242
  lines.append(f" execution_timeout_seconds: {_ENTRY_EXECUTION_TIMEOUT}")
216
243
  lines.append("")
217
244
 
218
- # --- exit: the resolved DSL preset (LIVE scalars; tiers + max_loss are TODO slots) ---
245
+ # --- exit: the resolved DSL protection (materialized from metadata.spec.protection when present;
246
+ # else seeded live from the named preset — an older graph with no materialized block). The values
247
+ # are BUILD OUTPUT; the editable source is spec.protection in the graph, not this file. ---
219
248
  if has_exit:
220
249
  preset = EXIT_PRESETS[answers.exit_preset]
221
- dp = preset.dsl_preset
222
- lines.append(f"# EXIT — DSL preset '{preset.name}': {preset.intent}")
250
+ if answers.protection is not None:
251
+ dp = answers.protection.dsl_preset
252
+ exit_interval = answers.protection.interval_seconds
253
+ seeded_from = answers.protection.seeded_from
254
+ else:
255
+ dp = preset.dsl_preset
256
+ exit_interval = preset.interval_seconds
257
+ seeded_from = preset.name
258
+ lines.append(f"# EXIT — from spec.protection (seeded '{seeded_from}': {preset.intent})")
223
259
  lines.append("exit:")
224
260
  lines.append(" engine: dsl")
225
- lines.append(f" interval_seconds: {preset.interval_seconds}")
261
+ lines.append(f" interval_seconds: {exit_interval}")
226
262
  lines.append(" order_type: FEE_OPTIMIZED_LIMIT")
227
263
  lines.append(" fee_optimized_limit_options:")
228
264
  lines.append(" ensure_execution_as_taker: true") # exit closes MUST happen
@@ -240,17 +276,16 @@ def render_runtime_yaml(answers: Answers) -> str:
240
276
  lines.append(f" enabled: {_num(p1['enabled'])}")
241
277
  lines.append(
242
278
  f" max_loss_pct: {_num(p1['max_loss_pct'])}"
243
- " # TODO(edit): phase-1 hard-floor loss cap (%). Preset default; tighten/loosen for your edge."
279
+ " # from spec.protection.dsl_preset.phase1.max_loss_pct (edit in the graph, not here)."
244
280
  )
245
281
  lines.append(f" retrace_threshold: {_num(p1['retrace_threshold'])}")
246
282
  lines.append(f" consecutive_breaches_required: {_num(p1['consecutive_breaches_required'])}")
247
283
  p2 = dp["phase2"]
248
284
  lines.append(" phase2:")
249
285
  lines.append(f" enabled: {_num(p2['enabled'])}")
250
- lines.append(" tiers: # TODO(edit): the phase-2 ratchet — trigger_pct -> lock_hw_pct.")
251
- for i, tier in enumerate(p2["tiers"]):
252
- note = " # TODO(edit): tier — lock-in floor at this ROE" if i == 0 else ""
253
- lines.append(f" - {_tier_inline(tier)}{note}")
286
+ lines.append(" tiers: # from spec.protection.dsl_preset.phase2.tiers (edit in the graph).")
287
+ for tier in p2["tiers"]:
288
+ lines.append(f" - {_tier_inline(tier)}")
254
289
  lines.append("")
255
290
 
256
291
  # --- risk.guard_rails: the automated pause / circuit breaker (design §4) ---
@@ -260,19 +295,16 @@ def render_runtime_yaml(answers: Answers) -> str:
260
295
  # signal-exit pair supplies exit_signals.reentry_cooldown_seconds. None (exit-less, no override)
261
296
  # => absent from guard_rails => byte-identical to today. resolve_guard_rails only adds a key
262
297
  # whose value is not None, so the emit loop below stays silent for the exit-less case.
263
- per_asset_cooldown = answers.risk.per_asset_cooldown_seconds
264
- if per_asset_cooldown is None and answers.exit_signals is not None:
265
- per_asset_cooldown = answers.exit_signals.reentry_cooldown_seconds
266
- guard_rails = resolve_guard_rails(
267
- EXIT_PRESETS[answers.exit_preset or DEFAULT_PRESET],
268
- {
269
- "drawdown_halt_pct": answers.risk.drawdown_halt_pct,
270
- "max_consecutive_losses": answers.risk.max_consecutive_losses,
271
- "cooldown_seconds": answers.risk.cooldown_seconds,
272
- "max_entries_per_day": answers.risk.max_entries_per_day,
273
- "per_asset_cooldown_seconds": per_asset_cooldown,
274
- },
275
- )
298
+ # A materialized protection block carries the ALREADY-resolved guard_rails (generation ran the
299
+ # SAME resolve_emit_guard_rails, so this is byte-identical); an older graph with no materialized
300
+ # block falls back to resolving live from the seeding preset. unmanaged (has_exit False, never
301
+ # materialized) always falls back — the drawdown circuit breaker ships from the default preset.
302
+ if answers.protection is not None and has_exit:
303
+ guard_rails = answers.protection.guard_rails
304
+ else:
305
+ guard_rails = resolve_emit_guard_rails(
306
+ answers, EXIT_PRESETS[answers.exit_preset or DEFAULT_PRESET]
307
+ )
276
308
  lines.append("# RISK guard rails — the automated pause / circuit breaker (composer owns policy; ops owns manual stop).")
277
309
  lines.append("risk:")
278
310
  lines.append(f" data_retention_seconds: {_DATA_RETENTION_SECONDS}")
@@ -298,13 +330,17 @@ def answers_from_metadata(metadata: dict) -> Answers:
298
330
  from). Used by ``check``/``deploy`` to emit runtime.yaml from the graph.
299
331
 
300
332
  Two shapes are accepted:
301
- * ``composer new`` output carries a full ``metadata.answers`` block -> construct from it.
333
+ * ``composer new`` output carries a full ``metadata.spec`` block -> construct from it.
334
+ BACK-COMPAT: an older graph carries the same block under the legacy key ``metadata.answers``
335
+ (the writer now always emits ``spec``); it is read identically when ``spec`` is absent.
302
336
  * A hand-authored example carries only top-level ``metadata`` keys (archetype, strategy,
303
337
  and optionally ``exit_preset`` / ``unmanaged``) -> construct a minimal Answers, letting
304
338
  the exit_preset default to protected_standard (PROTECTION BY DEFAULT).
305
339
  """
306
340
  metadata = metadata or {}
307
- block = metadata.get("answers")
341
+ block = metadata.get("spec")
342
+ if not (isinstance(block, dict) and block):
343
+ block = metadata.get("answers") # BACK-COMPAT: legacy `answers` key on older graphs
308
344
  if isinstance(block, dict) and block:
309
345
  return Answers(**block)
310
346
  kwargs: dict = {
@@ -4,9 +4,11 @@ Schema version: 1. Generated from composer/registry/nodes.py — do not edit by
4
4
 
5
5
  Progressive-disclosure index (D4): node names, one-liners, param summaries. `composer describe <node>` gives full ports/params/examples.
6
6
 
7
- ## scoring
7
+ ## compute
8
8
 
9
- Bespoke scoring/classification is a `kind: pure_fn` node (typed ports, one `def`, math/statistics only, inline tests), never a primitive; `hard_gates_all` covers simple gate-and-weigh. Repeated shapes promote post-MVP. Run `composer describe pure_fn` for the authoring contract.
9
+ Compute is yours to write: a `kind: pure_fn` node — typed inputs in, ONE titled semantic unit out, inline tests. That is the first-class way to express any indicator / score / classification / normalization math (`hard_gates_all` covers simple gate-and-weigh). Wire sources, stateful nodes, and emitters from the catalog below. Run `composer describe pure_fn` for the authoring contract + reference formulas.
10
+
11
+ A curated library of common indicator formulas (sma, rsi, pct_change, trend_structure, …) also exists: add one BY NAME under an answers `indicators:` entry when a formula fits EXACTLY. Run `composer describe indicators` to list them (`composer describe <name>` for one) — prefer a `pure_fn` unless a curated formula fits exactly.
10
12
 
11
13
  ## source
12
14
 
@@ -62,28 +64,6 @@ Bespoke scoring/classification is a `kind: pure_fn` node (typed ports, one `def`
62
64
  - **universe_discover** — Derive a tradeable universe by filtering live instruments on a predicate.
63
65
  - params: min_day_volume (float=0.0, 0.0..); max_leverage_cap (float=5.0, 1.0..); name_prefix (str='')
64
66
 
65
- ## indicator
66
-
67
- - **acceleration** — Whether a short-window move is at least half a long-window move.
68
- - params: short_window (int=3, 1..); long_window (int=6, 2..); interval (str='4h')
69
- - **aggregate_bias** — Cross-trader net/gross cohort bias per coin.
70
- - **breakout_detect** — Directional breakout of the prior-window high/low.
71
- - params: window (int=168, 2..); interval (str='1h')
72
- - **pct_change** — Percent change of close over a lookback window.
73
- - params: lookback (int=42, 1..); interval (str='4h')
74
- - **recent_high_bars_ago** — Bars elapsed since the highest high in a window (fresh-high check).
75
- - params: window (int=60, 2..); interval (str='4h')
76
- - **rsi** — Relative Strength Index over the last N bars.
77
- - params: period (int=14, 2..100); interval (str='1h')
78
- - **sma** — Simple moving average of close and an above/below flag.
79
- - params: period (int=200, 1..1000); interval (str='4h')
80
- - **threshold_gate** — Compare a per-asset feature against a threshold, emitting a 1.0/0.0 gate.
81
- - params: op (str='lt'); value (float=0.0)
82
- - **trend_structure** — Higher-lows / lower-highs trend direction over a lookback.
83
- - params: lookback (int=6, 2..); interval (str='4h')
84
- - **volume_surge_ratio** — Recent-window mean volume divided by a baseline-window mean.
85
- - params: recent_window (int=6, 1..); baseline_window (int=42, 1..); interval (str='4h')
86
-
87
67
  ## filter
88
68
 
89
69
  - **classifier_predicate** — Gate universe membership on a set of instrument-field predicates.
@@ -701,6 +701,8 @@ with:
701
701
  long_window: 6
702
702
  ```
703
703
 
704
+ > Note: compute is normally yours to write as a `kind: pure_fn` (typed inputs in, one titled semantic unit, inline tests — `composer describe pure_fn`). This curated formula remains available: add it by name under an answers `indicators:` entry when it fits exactly (`composer describe indicators` lists the library).
705
+
704
706
  ## aggregate_bias (indicator)
705
707
 
706
708
  The ONE sanctioned promote-loop primitive (fn->primitive): the cross-trader net/gross bias per coin, shared near-verbatim by whalehunter and the chimp/gibbon/gorilla/orangutan cohort packages. Takes a record_list of TraderState records (from trader_states_fetch — each row's openPositions[] carrying coin, szi, positionValue/entry_px) and sums each coin's SIGNED notional across every trader, emitting a per-coin feature_map: {net, gross, bias, n_long, n_short, direction} where bias = net/gross in [-1,+1] (+1 all long, -1 all short) and direction = LONG|SHORT|NEUTRAL from the sign of net. A feature_map (the indicator output contract) rather than a scorer's score_map, so kind=indicator; its non-candle record_list input mirrors threshold_gate's non-candle feature_map input.
@@ -723,6 +725,8 @@ Example:
723
725
  node: aggregate_bias
724
726
  ```
725
727
 
728
+ > Note: compute is normally yours to write as a `kind: pure_fn` (typed inputs in, one titled semantic unit, inline tests — `composer describe pure_fn`). This curated formula remains available: add it by name under an answers `indicators:` entry when it fits exactly (`composer describe indicators` lists the library).
729
+
726
730
  ## breakout_detect (indicator)
727
731
 
728
732
  hawk's breakout: LONG if the latest close breaks the prior-`window` high, SHORT if it breaks the low, else NEUTRAL.
@@ -747,6 +751,8 @@ with:
747
751
  interval: "1h"
748
752
  ```
749
753
 
754
+ > Note: compute is normally yours to write as a `kind: pure_fn` (typed inputs in, one titled semantic unit, inline tests — `composer describe pure_fn`). This curated formula remains available: add it by name under an answers `indicators:` entry when it fits exactly (`composer describe indicators` lists the library).
755
+
750
756
  ## pct_change (indicator)
751
757
 
752
758
  Percent change of close over `lookback` bars (stag 7d trend %; bison/thesis-fund price momentum — same shape, different window).
@@ -771,6 +777,8 @@ with:
771
777
  interval: "4h"
772
778
  ```
773
779
 
780
+ > Note: compute is normally yours to write as a `kind: pure_fn` (typed inputs in, one titled semantic unit, inline tests — `composer describe pure_fn`). This curated formula remains available: add it by name under an answers `indicators:` entry when it fits exactly (`composer describe indicators` lists the library).
781
+
774
782
  ## recent_high_bars_ago (indicator)
775
783
 
776
784
  Counts bars since the highest high within `window` (stag's fresh-high freshness check).
@@ -794,6 +802,8 @@ with:
794
802
  window: 60
795
803
  ```
796
804
 
805
+ > Note: compute is normally yours to write as a `kind: pure_fn` (typed inputs in, one titled semantic unit, inline tests — `composer describe pure_fn`). This curated formula remains available: add it by name under an answers `indicators:` entry when it fits exactly (`composer describe indicators` lists the library).
806
+
797
807
  ## rsi (indicator)
798
808
 
799
809
  Trailing-window RSI over the last `period` gains/losses (duplicated verbatim in bison and thesis-fund).
@@ -818,6 +828,8 @@ with:
818
828
  interval: "1h"
819
829
  ```
820
830
 
831
+ > Note: compute is normally yours to write as a `kind: pure_fn` (typed inputs in, one titled semantic unit, inline tests — `composer describe pure_fn`). This curated formula remains available: add it by name under an answers `indicators:` entry when it fits exactly (`composer describe indicators` lists the library).
832
+
821
833
  ## sma (indicator)
822
834
 
823
835
  Simple moving average over `period` closes at `interval`, plus a close>SMA flag (stag SMA-200 trend gate).
@@ -843,6 +855,8 @@ with:
843
855
  interval: "4h"
844
856
  ```
845
857
 
858
+ > Note: compute is normally yours to write as a `kind: pure_fn` (typed inputs in, one titled semantic unit, inline tests — `composer describe pure_fn`). This curated formula remains available: add it by name under an answers `indicators:` entry when it fits exactly (`composer describe indicators` lists the library).
859
+
846
860
  ## threshold_gate (indicator)
847
861
 
848
862
  Takes a feature_map (a RAW indicator output) and emits 1.0 where value `op` threshold holds, else 0.0 — a truthy/falsy gate the scorer's required_gates can consume. `op` is one of lt|lte|gt|gte. Unlike every other indicator this reads a feature_map, not candles: wire its `values` input from a feature node's output (answers `indicators[].source`). Need demonstrated TWICE in ONE real dev transcript (D2 known-need bar): the 'fresh entry <= 5 bars' gate over recent_high_bars_ago (a truthiness gate INVERTED it — a fresh 0-bars-ago high read falsy and FAILED), and the 'don't chase if own_return_24h >= 15%' runaway filter. A raw-bars-ago-as-hard-gate has no correct truthiness reading; this node is the missing thresholded gate.
@@ -867,6 +881,8 @@ with:
867
881
  value: 5
868
882
  ```
869
883
 
884
+ > Note: compute is normally yours to write as a `kind: pure_fn` (typed inputs in, one titled semantic unit, inline tests — `composer describe pure_fn`). This curated formula remains available: add it by name under an answers `indicators:` entry when it fits exactly (`composer describe indicators` lists the library).
885
+
870
886
  ## trend_structure (indicator)
871
887
 
872
888
  Classifies LONG (higher lows) / SHORT (lower highs) / NEUTRAL over `lookback` bars. This is the single highest-value dedup target: literally duplicated across hawk, bison (x2), magpie, and thesis-fund — 6 corpus call-sites, one primitive.
@@ -891,6 +907,8 @@ with:
891
907
  interval: "4h"
892
908
  ```
893
909
 
910
+ > Note: compute is normally yours to write as a `kind: pure_fn` (typed inputs in, one titled semantic unit, inline tests — `composer describe pure_fn`). This curated formula remains available: add it by name under an answers `indicators:` entry when it fits exactly (`composer describe indicators` lists the library).
911
+
894
912
  ## volume_surge_ratio (indicator)
895
913
 
896
914
  Unifies stag's volume_surge, hawk's volume_ratio, and bison's volume_trend into one parameterized primitive: mean(recent_window) / mean(baseline_window) volume.
@@ -916,6 +934,8 @@ with:
916
934
  baseline_window: 42
917
935
  ```
918
936
 
937
+ > Note: compute is normally yours to write as a `kind: pure_fn` (typed inputs in, one titled semantic unit, inline tests — `composer describe pure_fn`). This curated formula remains available: add it by name under an answers `indicators:` entry when it fits exactly (`composer describe indicators` lists the library).
938
+
919
939
  ## classifier_predicate (filter)
920
940
 
921
941
  magpie's funding-signature IPOP test generalized: a named boolean predicate over instrument fields (funding magnitude, max leverage, delisting). Distinct from universe_discover in that it classifies an existing candidate set rather than deriving one.
@@ -1963,6 +1963,10 @@
1963
1963
  },
1964
1964
  "type": "array"
1965
1965
  },
1966
+ "title": {
1967
+ "description": "pure_fn OPTIONAL one-line title: what this compute unit computes, as one titled semantic unit. Generation writes it; a missing title on a hand-authored pure_fn is a non-blocking `composer check` annotation, never an error.",
1968
+ "type": "string"
1969
+ },
1966
1970
  "with": {
1967
1971
  "description": "Node parameters.",
1968
1972
  "type": "object"
@@ -91,6 +91,10 @@ class PureFnNode:
91
91
  fn_src: str
92
92
  tests: tuple[dict, ...] = ()
93
93
  line: Optional[int] = None
94
+ # OPTIONAL one-line title: what this compute unit computes, as one titled semantic unit.
95
+ # Generation writes it; a missing title is a non-blocking `composer check` annotation
96
+ # (see composer.verbs.check._pure_fn_title_annotations), never a validation error.
97
+ title: Optional[str] = None
94
98
 
95
99
  # -- registry-NodeDef duck surface ------------------------------------------------
96
100
  @property
@@ -166,6 +170,9 @@ def parse_pure_fn(entry: dict, line: Optional[int] = None) -> PureFnNode:
166
170
 
167
171
  tests = tuple(t for t in (entry.get("tests") or []) if isinstance(t, dict))
168
172
 
173
+ title = entry.get("title")
174
+ title = title.strip() if isinstance(title, str) and title.strip() else None
175
+
169
176
  return PureFnNode(
170
177
  id=node_id,
171
178
  inputs=inputs,
@@ -174,4 +181,5 @@ def parse_pure_fn(entry: dict, line: Optional[int] = None) -> PureFnNode:
174
181
  fn_src=fn_src,
175
182
  tests=tests,
176
183
  line=line,
184
+ title=title,
177
185
  )