@senpi/runtime 3.0.9-dev.20260710204324 → 3.0.9-dev.feat-strategy-compos.20260716233548
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/composer-commands.d.ts +37 -0
- package/dist/cli/composer-commands.d.ts.map +1 -0
- package/dist/cli/composer-commands.js +396 -0
- package/dist/cli/composer-commands.js.map +1 -0
- package/dist/cli/senpi-commands.d.ts.map +1 -1
- package/dist/cli/senpi-commands.js +2 -0
- package/dist/cli/senpi-commands.js.map +1 -1
- package/dist/cli/validate-runtime-config.mjs +190 -0
- package/dist/composer/AUTHORING.md +435 -0
- package/dist/composer/__init__.py +29 -0
- package/dist/composer/authoring/__init__.py +59 -0
- package/dist/composer/authoring/answers.py +297 -0
- package/dist/composer/authoring/archetypes/__init__.py +26 -0
- package/dist/composer/authoring/archetypes/_common.py +410 -0
- package/dist/composer/authoring/archetypes/breakout.py +134 -0
- package/dist/composer/authoring/archetypes/classifier.py +109 -0
- package/dist/composer/authoring/archetypes/skeleton.py +76 -0
- package/dist/composer/authoring/archetypes/thesis_fund.py +103 -0
- package/dist/composer/authoring/archetypes/trend_momentum.py +132 -0
- package/dist/composer/authoring/builder.py +191 -0
- package/dist/composer/authoring/emit.py +264 -0
- package/dist/composer/authoring/gaps.py +115 -0
- package/dist/composer/authoring/generate.py +131 -0
- package/dist/composer/authoring/presets.py +168 -0
- package/dist/composer/authoring/runtime_emit.py +266 -0
- package/dist/composer/authoring/strategy_manifest.py +94 -0
- package/dist/composer/cli.py +487 -0
- package/dist/composer/compiler/__init__.py +22 -0
- package/dist/composer/compiler/compile.py +739 -0
- package/dist/composer/examples/breakout_swing.graph.yaml +121 -0
- package/dist/composer/examples/conviction_scorer.graph.yaml +216 -0
- package/dist/composer/examples/macro_thesis_basket.graph.yaml +247 -0
- package/dist/composer/examples/pre_listing_classifier.graph.yaml +204 -0
- package/dist/composer/examples/trend_momentum_majors.graph.yaml +133 -0
- package/dist/composer/generated/README.md +22 -0
- package/dist/composer/generated/catalog.md +118 -0
- package/dist/composer/generated/describe.md +1168 -0
- package/dist/composer/generated/graph.schema.json +1946 -0
- package/dist/composer/primitives/__init__.py +320 -0
- package/dist/composer/primitives/_candles.py +76 -0
- package/dist/composer/primitives/adapter.py +363 -0
- package/dist/composer/primitives/emitters.py +142 -0
- package/dist/composer/primitives/filters.py +71 -0
- package/dist/composer/primitives/indicators.py +326 -0
- package/dist/composer/primitives/rankers.py +40 -0
- package/dist/composer/primitives/scorers.py +100 -0
- package/dist/composer/primitives/sources.py +979 -0
- package/dist/composer/primitives/stateful.py +254 -0
- package/dist/composer/pure_fn/__init__.py +34 -0
- package/dist/composer/pure_fn/checks.py +39 -0
- package/dist/composer/pure_fn/models.py +177 -0
- package/dist/composer/pure_fn/purity.py +311 -0
- package/dist/composer/pure_fn/runtests.py +155 -0
- package/dist/composer/registry/__init__.py +40 -0
- package/dist/composer/registry/generate.py +425 -0
- package/dist/composer/registry/mcp-tools-snapshot.json +193 -0
- package/dist/composer/registry/mcp_closure.py +311 -0
- package/dist/composer/registry/models.py +417 -0
- package/dist/composer/registry/nodes.py +1297 -0
- package/dist/composer/requirements.txt +21 -0
- package/dist/composer/scaffold_path.py +118 -0
- package/dist/composer/templates/__init__.py +13 -0
- package/dist/composer/templates/scan.py.j2 +34 -0
- package/dist/composer/validator/__init__.py +29 -0
- package/dist/composer/validator/desugar.py +133 -0
- package/dist/composer/validator/errors.py +227 -0
- package/dist/composer/validator/graph_checks.py +443 -0
- package/dist/composer/validator/loader.py +110 -0
- package/dist/composer/validator/schema_checks.py +147 -0
- package/dist/composer/validator/validate.py +86 -0
- package/dist/composer/vendoring.py +103 -0
- package/dist/composer/verbs/__init__.py +49 -0
- package/dist/composer/verbs/_box.py +308 -0
- package/dist/composer/verbs/check.py +254 -0
- package/dist/composer/verbs/close.py +494 -0
- package/dist/composer/verbs/deploy.py +279 -0
- package/dist/composer/verbs/docs.py +30 -0
- package/dist/composer/verbs/fund.py +498 -0
- package/dist/composer/verbs/install.py +586 -0
- package/dist/composer/verbs/runtime_validate.py +422 -0
- package/dist/composer/verbs/smoke.py +134 -0
- package/dist/composer/verbs/status.py +383 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -9
- package/dist/index.js.map +1 -1
- package/dist/runtime/load-runtime-config.d.ts +17 -0
- package/dist/runtime/load-runtime-config.d.ts.map +1 -1
- package/dist/runtime/load-runtime-config.js +2 -2
- package/dist/runtime/load-runtime-config.js.map +1 -1
- package/dist/runtime-api/config.d.ts +2 -2
- package/dist/runtime-api/config.js +4 -4
- package/dist/runtime-api/config.js.map +1 -1
- package/dist/runtime-api/handlers/ingest-signals.d.ts +3 -5
- package/dist/runtime-api/handlers/ingest-signals.d.ts.map +1 -1
- package/dist/runtime-api/handlers/ingest-signals.js +5 -14
- package/dist/runtime-api/handlers/ingest-signals.js.map +1 -1
- package/dist/runtime-api/intake/intake-mount.d.ts.map +1 -1
- package/dist/runtime-api/intake/intake-mount.js +2 -8
- package/dist/runtime-api/intake/intake-mount.js.map +1 -1
- package/dist/skills-manager/manifest.d.ts.map +1 -1
- package/dist/skills-manager/manifest.js +1 -3
- package/dist/skills-manager/manifest.js.map +1 -1
- package/dist/skills-manager/skills-manifest.json +1 -3
- package/package.json +4 -2
- package/dist/runtime-api/intake/ingest-mutex.d.ts +0 -16
- package/dist/runtime-api/intake/ingest-mutex.d.ts.map +0 -1
- package/dist/runtime-api/intake/ingest-mutex.js +0 -18
- package/dist/runtime-api/intake/ingest-mutex.js.map +0 -1
|
@@ -0,0 +1,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
|
+
}
|