@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,21 @@
1
+ # Dev/CI Python dependencies for the strategy composer (composer/).
2
+ #
3
+ # Mirrors the scaffold's requirements.txt convention: this repo is otherwise pure
4
+ # TypeScript/vitest and pytest is a CI-only toolchain, so composer's Python deps are
5
+ # declared here (not in pyproject) and installed in the `python-tests` CI job.
6
+ #
7
+ # pydantic — the node registry is pydantic models; the single source of truth from
8
+ # which JSON Schema + catalog + validator + compiler all derive (D4, D8).
9
+ # jinja2 — per-node emission templates the compiler walks to produce scan.py (D8).
10
+ # pyyaml — parse the authored YAML graph before validating the JSON canonical form
11
+ # (D6: YAML authored, JSON Schema canonical; anchors/merge keys banned).
12
+ # jsonschema — validate the desugared graph against composer/generated/graph.schema.json
13
+ # (Draft 2020-12); the validator translates its findings into teaching errors
14
+ # rather than re-implementing the registry's per-node param validation (slice 2).
15
+ #
16
+ # NO orchestration/DAG framework (LangGraph/Dagster/Prefect/Hamilton/networkx): the DAG
17
+ # never executes as a DAG — the compiler linearizes topo order into straight-line Python.
18
+ pydantic>=2.0
19
+ jinja2>=3.0
20
+ pyyaml>=6.0
21
+ jsonschema>=4.18
@@ -0,0 +1,118 @@
1
+ """Locate the ``scaffold`` harness package across the dev checkout AND the claw dist install.
2
+
3
+ ``scaffold`` (``src/scanners/external/scaffold``) is the FROZEN runtime's scanner harness — the
4
+ ``ScanContext`` / ``StateManager`` an emitted ``scan.py`` runs under. The composer's in-checkout
5
+ liveness harnesses (``composer.verbs.smoke`` and ``composer.eval.replay``) import it to exec a
6
+ compiled ``scan.py`` and run one real tick. It is NOT distributed on PyPI — and there is an
7
+ UNRELATED package literally named ``scaffold`` on PyPI, so ``pip install scaffold`` NEVER fixes a
8
+ missing import here; it silently installs the wrong thing (the original dev-box failure: the bare
9
+ ``No module named 'scaffold'`` led straight to that dead end).
10
+
11
+ ``scaffold`` ships in two layouts, and this module resolves BOTH with no manual ``PYTHONPATH``:
12
+
13
+ * **dev checkout** — ``composer/`` sits at the trading_runtime repo root, and scaffold lives at
14
+ ``<repo>/src/scanners/external`` (with the ``src/`` segment).
15
+ * **claw dist install** — ``npm run build`` runs ``copy:composer`` (lands the package at
16
+ ``dist/composer``) and ``copy:external-scanners`` (lands scaffold at ``dist/scanners/external``,
17
+ with NO ``src/`` segment). The old ``repo_root/src/scanners/external`` computation pointed at
18
+ ``dist/src/scanners/external`` — which does not exist — so scaffold never went on ``sys.path``.
19
+
20
+ Both are found RELATIVE to this file (which travels with the ``composer`` package, so it is right
21
+ in either layout). An explicit ``COMPOSER_SCAFFOLD_DIR`` override (the dir that CONTAINS
22
+ ``scaffold/``) wins over auto-detection; ``COMPOSER_ROOT`` (the dir that contains ``composer/`` —
23
+ the TS rim's dev-checkout override) is honored as a search base too, so the Python side agrees
24
+ with the rim without a second env var to set.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import os
30
+ import sys
31
+ from pathlib import Path
32
+ from typing import List, Optional
33
+
34
+ # The dir that CONTAINS the `composer/` package (repo root in a dev checkout; `dist/` in a claw
35
+ # install). `composer/scaffold_path.py` -> parents[0]=composer, parents[1]=that containing dir.
36
+ _COMPOSER_PARENT = Path(__file__).resolve().parents[1]
37
+
38
+ # `scaffold`'s parent dir under a search base, in each layout.
39
+ _DEV_SUBPATH = ("src", "scanners", "external") # dev checkout: repo/src/scanners/external
40
+ _DIST_SUBPATH = ("scanners", "external") # claw dist: dist/scanners/external
41
+
42
+ # The env var name (documented in the teaching error) — kept in sync with the rim's COMPOSER_ prefix.
43
+ SCAFFOLD_DIR_ENV = "COMPOSER_SCAFFOLD_DIR"
44
+
45
+
46
+ class ScaffoldNotFound(RuntimeError):
47
+ """The ``scaffold`` harness package could not be located in any known layout.
48
+
49
+ Carries a teaching-shaped message that names the ACTUAL fix (build step / env var) and warns
50
+ explicitly against ``pip install scaffold`` (an unrelated PyPI package)."""
51
+
52
+
53
+ def _search_bases() -> List[Path]:
54
+ """The dirs under which to try ``<base>/src/scanners/external`` and ``<base>/scanners/external``.
55
+
56
+ ``COMPOSER_ROOT`` (the rim's dev override, the dir that holds ``composer/``) is tried first when
57
+ set, then the package's own containing dir (self-location — correct in both layouts)."""
58
+ bases: List[Path] = []
59
+ root = os.environ.get("COMPOSER_ROOT", "").strip()
60
+ if root:
61
+ bases.append(Path(root).expanduser())
62
+ bases.append(_COMPOSER_PARENT)
63
+ return bases
64
+
65
+
66
+ def _candidate_parents() -> List[Path]:
67
+ """Every dir that MIGHT contain ``scaffold/``, in resolution order (override → dev → dist)."""
68
+ candidates: List[Path] = []
69
+ override = os.environ.get(SCAFFOLD_DIR_ENV, "").strip()
70
+ if override:
71
+ candidates.append(Path(override).expanduser())
72
+ for base in _search_bases():
73
+ candidates.append(base.joinpath(*_DEV_SUBPATH))
74
+ candidates.append(base.joinpath(*_DIST_SUBPATH))
75
+ return candidates
76
+
77
+
78
+ def scaffold_parent() -> Optional[Path]:
79
+ """Return the dir that CONTAINS the ``scaffold`` package, or ``None`` if none is found.
80
+
81
+ Resolution order: ``COMPOSER_SCAFFOLD_DIR`` (explicit override) → dev-checkout layout → claw
82
+ dist layout, under ``COMPOSER_ROOT`` first (if set) then this package's own location."""
83
+ for cand in _candidate_parents():
84
+ if (cand / "scaffold").is_dir():
85
+ return cand
86
+ return None
87
+
88
+
89
+ def _teaching_message() -> str:
90
+ looked = "\n".join(f" - {c}" for c in _candidate_parents())
91
+ return (
92
+ "the `scaffold` harness package could not be located. composer smoke/replay import it to "
93
+ "run a compiled scan.py under the real runtime ScanContext/StateManager, so `check` (and "
94
+ "therefore `deploy`) cannot prove liveness without it.\n"
95
+ f" Looked for a `scaffold/` dir under:\n{looked}\n"
96
+ " Fix, depending on how the composer is installed:\n"
97
+ " - claw dist install: run `npm run build` (its copy:external-scanners step stages "
98
+ "scaffold into dist/scanners/external) and run the composer from that dist tree.\n"
99
+ " - dev checkout: run the composer from the trading_runtime repo root, OR set "
100
+ f"{SCAFFOLD_DIR_ENV} to the dir that CONTAINS `scaffold/` (i.e. .../src/scanners/external).\n"
101
+ " Do NOT `pip install scaffold`: that is an UNRELATED PyPI package and will NOT satisfy "
102
+ "this import — scaffold ships with the runtime, never from PyPI."
103
+ )
104
+
105
+
106
+ def ensure_scaffold_on_syspath() -> Path:
107
+ """Resolve the scaffold parent and put it on ``sys.path`` (idempotently); return that dir.
108
+
109
+ Raises :class:`ScaffoldNotFound` with the teaching message when no layout resolves — callers
110
+ surface that as their own teaching-shaped failure (e.g. a RED smoke stage) rather than letting
111
+ a bare ``No module named 'scaffold'`` escape and mislead the reader toward PyPI."""
112
+ parent = scaffold_parent()
113
+ if parent is None:
114
+ raise ScaffoldNotFound(_teaching_message())
115
+ s = str(parent)
116
+ if s not in sys.path:
117
+ sys.path.insert(0, s)
118
+ return parent
@@ -0,0 +1,13 @@
1
+ """templates — per-node emission templates and archetype templates-as-code (D7, D8).
2
+
3
+ Two template families live here. Per-node emission templates (Jinja2, or plain f-string
4
+ templates) sit next to their registry entries and are what the compiler walks in topo
5
+ order to emit straight-line ``scan.py``. Archetype templates are the machine-generated
6
+ first-draft anchors for ``composer new``: they are CODE built from the same pydantic
7
+ registry types (never YAML-with-holes), so a template referencing a nonexistent node or
8
+ port fails CI and cannot drift from the vocabulary. ``composer new`` binds mechanically
9
+ bindable answers (universe, intervals, top-N, TTL, exit preset) directly and leaves
10
+ genuinely bespoke slots (weights, thresholds, edge logic) TODO-marked WITH working
11
+ defaults, so the emitted graph validates and compiles before any edit. (Only the
12
+ archetypes the POC graphs need are added, in slice 6 — not all ten.)
13
+ """
@@ -0,0 +1,34 @@
1
+ {{ '"""' }}AUTOGENERATED by `composer compile` from {{ source_name }} — do not edit by hand.
2
+
3
+ Strategy: {{ strategy }} (archetype: {{ archetype }}).
4
+ Emitted onto the external_scanner contract: scan(inputs, ctx) -> list[dict].
5
+ The graph is materialized straight-line in topological order; each block below is
6
+ named with its graph node id. Regenerate with `composer compile` — edits are lost.
7
+
8
+ Nodes ({{ node_count }}): {{ node_ids }}.
9
+ {{ '"""' }}
10
+ import time
11
+ {% for imp in extra_imports %}import {{ imp }}
12
+ {% endfor %}
13
+ # strategy_primitives is a self-contained, vendored SNAPSHOT of the composer primitives,
14
+ # staged as a sibling package of this scan.py (the scanner dir is on sys.path). The composer
15
+ # checkout does NOT ship to the runtime box — this package is what makes the strategy standalone.
16
+ from strategy_primitives import emitters, filters, indicators, rankers, scorers, sources, stateful
17
+ from strategy_primitives.adapter import make_adapter
18
+
19
+ # Base universe — the candles_fetch whitelist, overridable via inputs["whitelist"].
20
+ _BASE_UNIVERSE = {{ base_universe }}
21
+
22
+
23
+ def scan(inputs, ctx):
24
+ _now = time.time()
25
+ src = make_adapter(ctx)
26
+ base_universe = [str(_a).upper() for _a in inputs.get("whitelist", _BASE_UNIVERSE)]
27
+
28
+ {% for line in prologue %} {{ line }}
29
+ {% endfor %}
30
+ {% for line in body %} {{ line }}
31
+ {% endfor %}
32
+ {% for line in epilogue %} {{ line }}
33
+ {% endfor %}
34
+ return {{ return_var }}
@@ -0,0 +1,29 @@
1
+ """validator — schema and graph typecheck whose teaching errors are the product.
2
+
3
+ The validator parses authored YAML to JSON (anchors/merge keys banned per D6), validates
4
+ it against the registry-generated JSON Schema, then runs the graph checks: cycle
5
+ detection, topo-sort, port typechecking, parameter-bound checks, and unknown-node refusal
6
+ that names the nearest valid alternatives. The graph never executes as a DAG, so these are
7
+ ~50-100 lines of plain checks, not an orchestration framework. Error format is a
8
+ first-class deliverable (the SERR037 lesson): every error carries location (node id /
9
+ port / line), what is wrong, and a concrete fix hint, so a small model can repair it in
10
+ one iteration. (Implemented in slice 2 as a table of one-error-class-per-test cases.)
11
+
12
+ Public API:
13
+ validate_text / validate_file — run the full pipeline, return a ValidationResult.
14
+ ValidationResult — collected errors; .ok, .render() (text), .to_json().
15
+ TeachingError / Location — the located, fix-hinted error model.
16
+ ERROR_CODES — stable code -> error-class table.
17
+ """
18
+
19
+ from .errors import ERROR_CODES, Location, TeachingError, ValidationResult
20
+ from .validate import validate_file, validate_text
21
+
22
+ __all__ = [
23
+ "validate_text",
24
+ "validate_file",
25
+ "ValidationResult",
26
+ "TeachingError",
27
+ "Location",
28
+ "ERROR_CODES",
29
+ ]
@@ -0,0 +1,133 @@
1
+ """desugar — expand the ``pipeline:`` shorthand into the canonical nodes+edges DAG (D1).
2
+
3
+ Stage (b), applied BEFORE schema validation so every later stage (schema, graph checks,
4
+ and eventually the compiler) sees exactly one representation: the DAG. The shorthand
5
+ exists because the common case is a straight line — ``candles -> indicator -> score ->
6
+ rank -> emit`` — and making the agent hand-wire that by port is needless friction. Once a
7
+ strategy needs fan-out/join it drops the shorthand and writes nodes+edges directly (D1:
8
+ "once you need joins you need named references, which is a DAG").
9
+
10
+ A ``pipeline`` is a list of steps; each step is either a bare node-type string
11
+ (``candles_fetch``) or a mapping in the same shape as a node entry (``{id?, node,
12
+ with?}``). Consecutive steps are wired primary-output -> primary-input, where "primary"
13
+ is the first non-``state`` output / input port the registry declares for that node. Ids
14
+ default to the node type, de-duplicated with a ``_2`` suffix. The result is byte-for-byte
15
+ the DAG the author would have written by hand — proven by the desugar-equivalence test.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from composer.registry import NODES_BY_NAME, PortType
21
+
22
+ from .errors import Location, TeachingError
23
+
24
+
25
+ def has_pipeline(doc: object) -> bool:
26
+ return isinstance(doc, dict) and "pipeline" in doc
27
+
28
+
29
+ def _primary_output(node_name: str) -> str | None:
30
+ node = NODES_BY_NAME.get(node_name)
31
+ if node is None:
32
+ return None
33
+ for port in node.outputs:
34
+ if port.type != PortType.state:
35
+ return port.name
36
+ return None
37
+
38
+
39
+ def _primary_input(node_name: str) -> str | None:
40
+ node = NODES_BY_NAME.get(node_name)
41
+ if node is None:
42
+ return None
43
+ for port in node.inputs:
44
+ if port.type != PortType.state:
45
+ return port.name
46
+ return None
47
+
48
+
49
+ def desugar(doc: dict) -> tuple[dict, list[TeachingError]]:
50
+ """Return ``(canonical_doc, errors)``.
51
+
52
+ ``canonical_doc`` has the ``pipeline`` key replaced by generated ``nodes`` and
53
+ ``edges``. On a malformed pipeline the returned doc still carries whatever nodes could
54
+ be built, so downstream stages can pile on additional findings (errors collect, they
55
+ do not short-circuit).
56
+ """
57
+ errors: list[TeachingError] = []
58
+ steps = doc.get("pipeline")
59
+ if not isinstance(steps, list):
60
+ errors.append(
61
+ TeachingError(
62
+ code="CMP020",
63
+ message="`pipeline:` must be a list of node steps.",
64
+ fix_hint="Write `pipeline:` as a YAML list, e.g. `pipeline: [candles_fetch, sma]`.",
65
+ )
66
+ )
67
+ return doc, errors
68
+
69
+ if "nodes" in doc or "edges" in doc:
70
+ errors.append(
71
+ TeachingError(
72
+ code="CMP020",
73
+ message="`pipeline:` cannot be combined with explicit `nodes:`/`edges:`.",
74
+ fix_hint="Use EITHER the `pipeline:` shorthand OR an explicit `nodes:`+`edges:` graph, not both.",
75
+ )
76
+ )
77
+
78
+ nodes: list[dict] = []
79
+ edges: list[dict] = []
80
+ id_counts: dict[str, int] = {}
81
+ prev: dict | None = None # {"id":..., "node":...}
82
+
83
+ for i, step in enumerate(steps):
84
+ if isinstance(step, str):
85
+ entry: dict = {"node": step}
86
+ elif isinstance(step, dict):
87
+ entry = dict(step)
88
+ else:
89
+ errors.append(
90
+ TeachingError(
91
+ code="CMP020",
92
+ message=f"pipeline step #{i + 1} is neither a node name nor a node mapping.",
93
+ fix_hint="Each step must be a node-type string or a `{node: <name>, with: {...}}` mapping.",
94
+ )
95
+ )
96
+ continue
97
+
98
+ node_name = entry.get("node")
99
+ if not isinstance(node_name, str) or not node_name:
100
+ errors.append(
101
+ TeachingError(
102
+ code="CMP020",
103
+ message=f"pipeline step #{i + 1} has no `node:` type.",
104
+ fix_hint="Give the step a registry node type, e.g. `- node: sma`.",
105
+ )
106
+ )
107
+ continue
108
+
109
+ base_id = entry.get("id") or node_name
110
+ count = id_counts.get(base_id, 0) + 1
111
+ id_counts[base_id] = count
112
+ node_id = base_id if count == 1 else f"{base_id}_{count}"
113
+
114
+ node: dict = {"id": node_id, "node": node_name}
115
+ if "with" in entry:
116
+ node["with"] = entry["with"]
117
+ nodes.append(node)
118
+
119
+ if prev is not None:
120
+ out_port = _primary_output(prev["node"])
121
+ in_port = _primary_input(node_name)
122
+ if out_port and in_port:
123
+ edges.append(
124
+ {"from": f"{prev['id']}.{out_port}", "to": f"{node_id}.{in_port}"}
125
+ )
126
+ # If either node type is unknown the auto-edge is skipped; the unknown-node
127
+ # graph check (CMP030) will name it — no need to guess a wiring.
128
+ prev = node
129
+
130
+ canonical = {k: v for k, v in doc.items() if k != "pipeline"}
131
+ canonical["nodes"] = nodes
132
+ canonical["edges"] = edges
133
+ return canonical, errors
@@ -0,0 +1,227 @@
1
+ """errors — the teaching-error model; the slice-2 product (the SERR037 lesson).
2
+
3
+ An unhelpful validator error ("schema violation at $.nodes[3].with") makes a small model
4
+ guess; a *teaching* error tells it exactly where, what, and the one concrete thing to do
5
+ next, so it repairs in a single iteration. Every error the validator emits is a
6
+ :class:`TeachingError` carrying:
7
+
8
+ code — a stable identifier (``CMP0xx``); never changes meaning across releases,
9
+ so agents/tests can match on it.
10
+ location — where: node id, port, and YAML line where each is available.
11
+ message — what is wrong, in one sentence.
12
+ fix_hint — one CONCRETE next action (a name to use, a command to run, a bound to
13
+ respect) — not "fix the schema".
14
+
15
+ All errors from one run are collected into a :class:`ValidationResult` and reported
16
+ together, ordered by location, so the agent sees every defect at once rather than
17
+ peeling them off one round-trip at a time. The result renders to plain text an LLM reads
18
+ directly, and to a JSON structure for the ``--json`` CLI surface.
19
+
20
+ The codes are the layered graph-semantic checks the validator adds ON TOP of JSON Schema
21
+ param validation (which the registry-generated schema already carries in full) — the
22
+ validator does not re-check param bounds itself, it translates the schema's findings into
23
+ this same teaching shape and adds the checks a schema cannot express (ports, types,
24
+ cycles, connectivity).
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from dataclasses import dataclass, field
30
+ from typing import Optional
31
+
32
+ # ---------------------------------------------------------------- stable error-code table
33
+ # code -> one-line description of the error class it marks. Kept here so it is impossible
34
+ # to add an ad-hoc code without also naming its class (and so tests can enumerate them).
35
+ ERROR_CODES: dict[str, str] = {
36
+ "CMP001": "YAML could not be parsed (syntax error).",
37
+ "CMP002": "YAML anchors, aliases, or merge keys are not allowed (D6).",
38
+ "CMP003": "Document root is not a mapping / is empty.",
39
+ "CMP010": "Unknown parameter passed to a node's `with:` block.",
40
+ "CMP011": "Parameter value is out of its allowed range.",
41
+ "CMP012": "Parameter value has the wrong type.",
42
+ "CMP013": "A required parameter or top-level field is missing.",
43
+ "CMP014": "Document violates the graph schema (other).",
44
+ "CMP020": "The `pipeline:` shorthand is malformed.",
45
+ "CMP030": "Unknown node type (names nearest valid alternatives).",
46
+ "CMP031": "Duplicate node id.",
47
+ "CMP032": "Edge references a node id that does not exist.",
48
+ "CMP033": "Edge references a port that the node does not declare.",
49
+ "CMP034": "Edge connects two ports of incompatible type.",
50
+ "CMP035": "The graph contains a cycle (reports the cycle path).",
51
+ "CMP036": "A required input port has no incoming edge.",
52
+ "CMP037": "Two or more edges write to the same (non-mergeable) input port.",
53
+ "CMP038": "A node declares neither a registry `node:` type nor `kind: pure_fn`.",
54
+ "CMP039": "A `raw` port feeds a non-pure_fn node (raw may only feed a pure_fn normalizer).",
55
+ # ---- pure_fn purity (D2 kind 2): static AST allowlist on the fn body ----
56
+ "CMP040": "A pure_fn `fn:` block is not exactly one well-formed top-level function def.",
57
+ "CMP041": "A pure_fn body imports a module outside the allowlist (math, statistics only).",
58
+ "CMP042": "A pure_fn body uses a disallowed builtin or reflection escape (I/O, eval, dunder).",
59
+ "CMP043": "A pure_fn body references a forbidden/undeclared name (ctx, MCP, or an external).",
60
+ "CMP044": "A pure_fn signature does not match its declared input ports + params.",
61
+ "CMP045": "A pure_fn does not return its declared outputs (arity mismatch).",
62
+ "CMP046": "A pure_fn writes global/nonlocal state.",
63
+ # ---- escape hatch (mcp_read source): read-only, snapshot-bounded tool checks ----
64
+ "CMP060": "An mcp_read `tool` is not in the committed MCP snapshot (unknown tool).",
65
+ "CMP061": "An mcp_read `tool` is a mutation (write) tool — scanners are read-only producers.",
66
+ # ---- pure_fn inline fixture tests (run at validate time) ----
67
+ "CMP050": "A pure_fn inline test produced the wrong output.",
68
+ "CMP051": "A pure_fn inline test raised an exception when run.",
69
+ "CMP052": "A pure_fn inline test is malformed (unknown/ unbindable input or output).",
70
+ # ---- runtime-unit (slice 9): the emitted runtime.yaml, judged by the real Zod+DSL oracle ----
71
+ "CMP100": "Missing a required top-level runtime field (`name` or `strategy.wallet`).",
72
+ "CMP101": "The `risk` block has an unknown or renamed-legacy key (`.strict()` object).",
73
+ "CMP102": "A `risk.guard_rails` value is out of its allowed range.",
74
+ "CMP110": "The exit DSL preset is invalid (breaches / tier order / preset config).",
75
+ "CMP111": "`dsl_preset` is misplaced at the top level (must be nested under `exit:`).",
76
+ # ---- A3 install-time parity: checks that only fire when the runtime BOOTS (not just parses) ----
77
+ "CMP112": "DSL exit active but position-tracking wiring is missing/broken (position_tracker scanner + POSITION_TRACKER action wired).",
78
+ "CMP113": "A scanner or action `name` is duplicated (names must be unique within one runtime).",
79
+ "CMP114": "An action `decision_prompt` references a `{{placeholder}}` no context entry or param resolves.",
80
+ "CMP115": "An unbound `${VAR}` env placeholder would resolve to empty at install (only `${..._WALLET}` is ops-bound).",
81
+ "CMP120": "Unmanaged strategy with no exit and no explicit `unmanaged` opt-out (composer policy).",
82
+ "CMP199": "The runtime.yaml failed the Zod schema for another reason.",
83
+ # ---- A2 install verb: preflight + copy + wallet-bind of a staged unit onto a box ----
84
+ "CMP200": "The install target is not a staged unit (no manifest.json / staged tree not found).",
85
+ "CMP201": "A staged artifact's content does not match its manifest hash (corrupt or hand-edited staging).",
86
+ "CMP202": "The staged unit is not installable — its recorded oracle verdict is INVALID (not GREEN).",
87
+ "CMP203": "A file copied into the canonical hash dir does not match its manifest hash (tamper / IO fault).",
88
+ "CMP204": "The canonical hash dir is already bound to a DIFFERENT wallet (phase-1: one wallet per content dir).",
89
+ "CMP205": "The `--wallet` argument is missing or not a 0x… address.",
90
+ "CMP206": "Install `--wallet` omitted and no ACTIVE funded wallet.json to auto-bind (missing / pending / stale).",
91
+ # ---- A2 install verb: mapped installRuntime (frozen consumer) failure classes ----
92
+ "CMP210": "Install rejected — a runtime with this id already exists on the box.",
93
+ "CMP211": "Install rejected — a runtime for this wallet is already running (one running runtime per wallet).",
94
+ "CMP212": "Install rejected — the strategy wallet is not ACTIVE (or not found in your Senpi account).",
95
+ "CMP213": "Install rejected — the runtime YAML was invalid/incomplete at load.",
96
+ "CMP214": "Install rejected — the external-scanner entrypoint (scan.py) was not found on disk.",
97
+ "CMP219": "Install rejected — `openclaw senpi runtime create` failed for another reason.",
98
+ # ---- B1 close verb: safe teardown (stop runtime → confirm gone → trigger strategy_close) ----
99
+ "CMP220": "Close aborted — `openclaw senpi runtime delete` failed; strategy_close withheld.",
100
+ "CMP221": "Close aborted — the runtime is still present after delete (teardown unconfirmed); strategy_close withheld.",
101
+ "CMP222": "Close failed — MCP `strategy_close` submit failed.",
102
+ "CMP223": "MCP credentials missing (SENPI_AUTH_TOKEN / SENPI_API_KEY) — close/fund need the MCP server.",
103
+ "CMP224": "Close failed — resolved a wallet but no strategyId (nothing to close, or MCP unreachable).",
104
+ "CMP225": "Close refused — the target could not be resolved to any strategy.",
105
+ # ---- B3 fund verb: create + fund a strategy's wallet as a first-class composer step ----
106
+ "CMP230": "Fund refused — `--budget` is missing, non-numeric, or below the $100 platform minimum.",
107
+ "CMP231": "Fund refused — the recorded wallet.json is stale (its strategy is closed or not found).",
108
+ "CMP232": "Fund refused — the visible balance is short of the requested budget (states shortfall + bridged-funds lag).",
109
+ "CMP233": "Fund failed — MCP `strategy_create_custom_strategy` submit failed or returned no strategyId.",
110
+ }
111
+
112
+
113
+ @dataclass(frozen=True)
114
+ class Location:
115
+ """Where an error is, as precisely as the document allows.
116
+
117
+ ``node``/``port``/``line`` locate a graph defect; ``path`` locates a runtime.yaml defect
118
+ as the Zod/DSL dot-path (e.g. ``exit.dsl_preset.phase1.consecutive_breaches_required``) —
119
+ the CMP1xx runtime-unit errors (slice 9) carry it verbatim so the fix is pin-pointable."""
120
+
121
+ node: Optional[str] = None
122
+ port: Optional[str] = None
123
+ line: Optional[int] = None
124
+ path: Optional[str] = None
125
+
126
+ def render(self) -> str:
127
+ parts: list[str] = []
128
+ if self.node is not None:
129
+ parts.append(f"node {self.node!r}")
130
+ if self.port is not None:
131
+ parts.append(f"port {self.port!r}")
132
+ if self.path is not None:
133
+ parts.append(f"runtime.yaml {self.path or '(root)'}")
134
+ if self.line is not None:
135
+ parts.append(f"line {self.line}")
136
+ return ", ".join(parts) if parts else "document"
137
+
138
+ def to_dict(self) -> dict:
139
+ return {"node": self.node, "port": self.port, "line": self.line, "path": self.path}
140
+
141
+ @property
142
+ def _sort_key(self) -> tuple:
143
+ # Order by line first (defects the author sees top-to-bottom), then node/port/path.
144
+ return (
145
+ self.line if self.line is not None else 1_000_000,
146
+ self.node or "",
147
+ self.port or "",
148
+ self.path or "",
149
+ )
150
+
151
+
152
+ @dataclass(frozen=True)
153
+ class TeachingError:
154
+ """A single located, actionable validation error."""
155
+
156
+ code: str
157
+ message: str
158
+ fix_hint: str
159
+ location: Location = field(default_factory=Location)
160
+
161
+ def __post_init__(self) -> None:
162
+ if self.code not in ERROR_CODES:
163
+ raise KeyError(
164
+ f"unknown error code {self.code!r}; add it to ERROR_CODES in "
165
+ f"composer/validator/errors.py so every code has a documented class"
166
+ )
167
+
168
+ @property
169
+ def sort_key(self) -> tuple:
170
+ return self.location._sort_key + (self.code,)
171
+
172
+ def render(self) -> str:
173
+ return (
174
+ f"[{self.code}] {self.location.render()}: {self.message}\n"
175
+ f" fix: {self.fix_hint}"
176
+ )
177
+
178
+ def to_dict(self) -> dict:
179
+ return {
180
+ "code": self.code,
181
+ "message": self.message,
182
+ "fix_hint": self.fix_hint,
183
+ "location": self.location.to_dict(),
184
+ }
185
+
186
+
187
+ @dataclass
188
+ class ValidationResult:
189
+ """The outcome of one validation run: every error, collected and reportable together."""
190
+
191
+ source: str # a label for the validated document (path or "<text>")
192
+ errors: list[TeachingError] = field(default_factory=list)
193
+
194
+ def add(self, error: TeachingError) -> None:
195
+ self.errors.append(error)
196
+
197
+ def extend(self, errors: list[TeachingError]) -> None:
198
+ self.errors.extend(errors)
199
+
200
+ @property
201
+ def ok(self) -> bool:
202
+ return not self.errors
203
+
204
+ def sorted_errors(self) -> list[TeachingError]:
205
+ return sorted(self.errors, key=lambda e: e.sort_key)
206
+
207
+ def render(self) -> str:
208
+ """Plain-text verdict + every error, ordered by location — for an LLM to read."""
209
+ if self.ok:
210
+ return f"OK: {self.source} is a valid strategy graph ({0} errors)."
211
+ n = len(self.errors)
212
+ lines = [
213
+ f"INVALID: {self.source} has {n} error{'s' if n != 1 else ''}.",
214
+ "",
215
+ ]
216
+ for err in self.sorted_errors():
217
+ lines.append(err.render())
218
+ lines.append("")
219
+ return "\n".join(lines).rstrip() + "\n"
220
+
221
+ def to_json(self) -> dict:
222
+ return {
223
+ "source": self.source,
224
+ "ok": self.ok,
225
+ "error_count": len(self.errors),
226
+ "errors": [e.to_dict() for e in self.sorted_errors()],
227
+ }