@senpi/runtime 3.0.9-dev.20260710204324 → 3.0.9-dev.feat-strategy-compos.20260716013009
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 +287 -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 +124 -0
- package/dist/composer/AUTHORING.md +233 -0
- package/dist/composer/__init__.py +29 -0
- package/dist/composer/authoring/__init__.py +49 -0
- package/dist/composer/authoring/answers.py +226 -0
- package/dist/composer/authoring/archetypes/__init__.py +25 -0
- package/dist/composer/authoring/archetypes/_common.py +218 -0
- package/dist/composer/authoring/archetypes/classifier.py +109 -0
- package/dist/composer/authoring/archetypes/skeleton.py +54 -0
- package/dist/composer/authoring/archetypes/thesis_fund.py +103 -0
- package/dist/composer/authoring/archetypes/trend_momentum.py +104 -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 +355 -0
- package/dist/composer/compiler/__init__.py +22 -0
- package/dist/composer/compiler/compile.py +550 -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 +84 -0
- package/dist/composer/generated/describe.md +703 -0
- package/dist/composer/generated/graph.schema.json +1203 -0
- package/dist/composer/primitives/__init__.py +239 -0
- package/dist/composer/primitives/_candles.py +76 -0
- package/dist/composer/primitives/adapter.py +120 -0
- package/dist/composer/primitives/emitters.py +142 -0
- package/dist/composer/primitives/filters.py +71 -0
- package/dist/composer/primitives/indicators.py +203 -0
- package/dist/composer/primitives/rankers.py +40 -0
- package/dist/composer/primitives/scorers.py +100 -0
- package/dist/composer/primitives/sources.py +405 -0
- package/dist/composer/primitives/stateful.py +52 -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 +422 -0
- package/dist/composer/registry/models.py +331 -0
- package/dist/composer/registry/nodes.py +673 -0
- package/dist/composer/requirements.txt +21 -0
- package/dist/composer/templates/__init__.py +13 -0
- package/dist/composer/templates/scan.py.j2 +31 -0
- package/dist/composer/validator/__init__.py +29 -0
- package/dist/composer/validator/desugar.py +133 -0
- package/dist/composer/validator/errors.py +191 -0
- package/dist/composer/validator/graph_checks.py +348 -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/verbs/__init__.py +33 -0
- package/dist/composer/verbs/check.py +193 -0
- package/dist/composer/verbs/deploy.py +223 -0
- package/dist/composer/verbs/docs.py +30 -0
- package/dist/composer/verbs/runtime_validate.py +262 -0
- package/dist/composer/verbs/smoke.py +115 -0
- package/dist/composer/verbs/status.py +205 -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-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.js +1 -1
- package/dist/skills-manager/manifest.js.map +1 -1
- package/dist/skills-manager/skills-manifest.json +1 -1
- 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,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,31 @@
|
|
|
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
|
+
from composer.primitives import emitters, filters, indicators, rankers, scorers, sources, stateful
|
|
14
|
+
from composer.primitives.adapter import make_adapter
|
|
15
|
+
|
|
16
|
+
# Base universe — the candles_fetch whitelist, overridable via inputs["whitelist"].
|
|
17
|
+
_BASE_UNIVERSE = {{ base_universe }}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def scan(inputs, ctx):
|
|
21
|
+
_now = time.time()
|
|
22
|
+
src = make_adapter(ctx)
|
|
23
|
+
base_universe = [str(_a).upper() for _a in inputs.get("whitelist", _BASE_UNIVERSE)]
|
|
24
|
+
|
|
25
|
+
{% for line in prologue %} {{ line }}
|
|
26
|
+
{% endfor %}
|
|
27
|
+
{% for line in body %} {{ line }}
|
|
28
|
+
{% endfor %}
|
|
29
|
+
{% for line in epilogue %} {{ line }}
|
|
30
|
+
{% endfor %}
|
|
31
|
+
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,191 @@
|
|
|
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
|
+
# ---- pure_fn purity (D2 kind 2): static AST allowlist on the fn body ----
|
|
55
|
+
"CMP040": "A pure_fn `fn:` block is not exactly one well-formed top-level function def.",
|
|
56
|
+
"CMP041": "A pure_fn body imports a module outside the allowlist (math, statistics only).",
|
|
57
|
+
"CMP042": "A pure_fn body uses a disallowed builtin or reflection escape (I/O, eval, dunder).",
|
|
58
|
+
"CMP043": "A pure_fn body references a forbidden/undeclared name (ctx, MCP, or an external).",
|
|
59
|
+
"CMP044": "A pure_fn signature does not match its declared input ports + params.",
|
|
60
|
+
"CMP045": "A pure_fn does not return its declared outputs (arity mismatch).",
|
|
61
|
+
"CMP046": "A pure_fn writes global/nonlocal state.",
|
|
62
|
+
# ---- pure_fn inline fixture tests (run at validate time) ----
|
|
63
|
+
"CMP050": "A pure_fn inline test produced the wrong output.",
|
|
64
|
+
"CMP051": "A pure_fn inline test raised an exception when run.",
|
|
65
|
+
"CMP052": "A pure_fn inline test is malformed (unknown/ unbindable input or output).",
|
|
66
|
+
# ---- runtime-unit (slice 9): the emitted runtime.yaml, judged by the real Zod+DSL oracle ----
|
|
67
|
+
"CMP100": "Missing a required top-level runtime field (`name` or `strategy.wallet`).",
|
|
68
|
+
"CMP101": "The `risk` block has an unknown or renamed-legacy key (`.strict()` object).",
|
|
69
|
+
"CMP102": "A `risk.guard_rails` value is out of its allowed range.",
|
|
70
|
+
"CMP110": "The exit DSL preset is invalid (breaches / tier order / preset config).",
|
|
71
|
+
"CMP111": "`dsl_preset` is misplaced at the top level (must be nested under `exit:`).",
|
|
72
|
+
"CMP120": "Unmanaged strategy with no exit and no explicit `unmanaged` opt-out (composer policy).",
|
|
73
|
+
"CMP199": "The runtime.yaml failed the Zod schema for another reason.",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class Location:
|
|
79
|
+
"""Where an error is, as precisely as the document allows.
|
|
80
|
+
|
|
81
|
+
``node``/``port``/``line`` locate a graph defect; ``path`` locates a runtime.yaml defect
|
|
82
|
+
as the Zod/DSL dot-path (e.g. ``exit.dsl_preset.phase1.consecutive_breaches_required``) —
|
|
83
|
+
the CMP1xx runtime-unit errors (slice 9) carry it verbatim so the fix is pin-pointable."""
|
|
84
|
+
|
|
85
|
+
node: Optional[str] = None
|
|
86
|
+
port: Optional[str] = None
|
|
87
|
+
line: Optional[int] = None
|
|
88
|
+
path: Optional[str] = None
|
|
89
|
+
|
|
90
|
+
def render(self) -> str:
|
|
91
|
+
parts: list[str] = []
|
|
92
|
+
if self.node is not None:
|
|
93
|
+
parts.append(f"node {self.node!r}")
|
|
94
|
+
if self.port is not None:
|
|
95
|
+
parts.append(f"port {self.port!r}")
|
|
96
|
+
if self.path is not None:
|
|
97
|
+
parts.append(f"runtime.yaml {self.path or '(root)'}")
|
|
98
|
+
if self.line is not None:
|
|
99
|
+
parts.append(f"line {self.line}")
|
|
100
|
+
return ", ".join(parts) if parts else "document"
|
|
101
|
+
|
|
102
|
+
def to_dict(self) -> dict:
|
|
103
|
+
return {"node": self.node, "port": self.port, "line": self.line, "path": self.path}
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def _sort_key(self) -> tuple:
|
|
107
|
+
# Order by line first (defects the author sees top-to-bottom), then node/port/path.
|
|
108
|
+
return (
|
|
109
|
+
self.line if self.line is not None else 1_000_000,
|
|
110
|
+
self.node or "",
|
|
111
|
+
self.port or "",
|
|
112
|
+
self.path or "",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True)
|
|
117
|
+
class TeachingError:
|
|
118
|
+
"""A single located, actionable validation error."""
|
|
119
|
+
|
|
120
|
+
code: str
|
|
121
|
+
message: str
|
|
122
|
+
fix_hint: str
|
|
123
|
+
location: Location = field(default_factory=Location)
|
|
124
|
+
|
|
125
|
+
def __post_init__(self) -> None:
|
|
126
|
+
if self.code not in ERROR_CODES:
|
|
127
|
+
raise KeyError(
|
|
128
|
+
f"unknown error code {self.code!r}; add it to ERROR_CODES in "
|
|
129
|
+
f"composer/validator/errors.py so every code has a documented class"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def sort_key(self) -> tuple:
|
|
134
|
+
return self.location._sort_key + (self.code,)
|
|
135
|
+
|
|
136
|
+
def render(self) -> str:
|
|
137
|
+
return (
|
|
138
|
+
f"[{self.code}] {self.location.render()}: {self.message}\n"
|
|
139
|
+
f" fix: {self.fix_hint}"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def to_dict(self) -> dict:
|
|
143
|
+
return {
|
|
144
|
+
"code": self.code,
|
|
145
|
+
"message": self.message,
|
|
146
|
+
"fix_hint": self.fix_hint,
|
|
147
|
+
"location": self.location.to_dict(),
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass
|
|
152
|
+
class ValidationResult:
|
|
153
|
+
"""The outcome of one validation run: every error, collected and reportable together."""
|
|
154
|
+
|
|
155
|
+
source: str # a label for the validated document (path or "<text>")
|
|
156
|
+
errors: list[TeachingError] = field(default_factory=list)
|
|
157
|
+
|
|
158
|
+
def add(self, error: TeachingError) -> None:
|
|
159
|
+
self.errors.append(error)
|
|
160
|
+
|
|
161
|
+
def extend(self, errors: list[TeachingError]) -> None:
|
|
162
|
+
self.errors.extend(errors)
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def ok(self) -> bool:
|
|
166
|
+
return not self.errors
|
|
167
|
+
|
|
168
|
+
def sorted_errors(self) -> list[TeachingError]:
|
|
169
|
+
return sorted(self.errors, key=lambda e: e.sort_key)
|
|
170
|
+
|
|
171
|
+
def render(self) -> str:
|
|
172
|
+
"""Plain-text verdict + every error, ordered by location — for an LLM to read."""
|
|
173
|
+
if self.ok:
|
|
174
|
+
return f"OK: {self.source} is a valid strategy graph ({0} errors)."
|
|
175
|
+
n = len(self.errors)
|
|
176
|
+
lines = [
|
|
177
|
+
f"INVALID: {self.source} has {n} error{'s' if n != 1 else ''}.",
|
|
178
|
+
"",
|
|
179
|
+
]
|
|
180
|
+
for err in self.sorted_errors():
|
|
181
|
+
lines.append(err.render())
|
|
182
|
+
lines.append("")
|
|
183
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
184
|
+
|
|
185
|
+
def to_json(self) -> dict:
|
|
186
|
+
return {
|
|
187
|
+
"source": self.source,
|
|
188
|
+
"ok": self.ok,
|
|
189
|
+
"error_count": len(self.errors),
|
|
190
|
+
"errors": [e.to_dict() for e in self.sorted_errors()],
|
|
191
|
+
}
|