@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.
Files changed (94) 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 +287 -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 +124 -0
  9. package/dist/composer/AUTHORING.md +233 -0
  10. package/dist/composer/__init__.py +29 -0
  11. package/dist/composer/authoring/__init__.py +49 -0
  12. package/dist/composer/authoring/answers.py +226 -0
  13. package/dist/composer/authoring/archetypes/__init__.py +25 -0
  14. package/dist/composer/authoring/archetypes/_common.py +218 -0
  15. package/dist/composer/authoring/archetypes/classifier.py +109 -0
  16. package/dist/composer/authoring/archetypes/skeleton.py +54 -0
  17. package/dist/composer/authoring/archetypes/thesis_fund.py +103 -0
  18. package/dist/composer/authoring/archetypes/trend_momentum.py +104 -0
  19. package/dist/composer/authoring/builder.py +191 -0
  20. package/dist/composer/authoring/emit.py +264 -0
  21. package/dist/composer/authoring/gaps.py +115 -0
  22. package/dist/composer/authoring/generate.py +131 -0
  23. package/dist/composer/authoring/presets.py +168 -0
  24. package/dist/composer/authoring/runtime_emit.py +266 -0
  25. package/dist/composer/authoring/strategy_manifest.py +94 -0
  26. package/dist/composer/cli.py +355 -0
  27. package/dist/composer/compiler/__init__.py +22 -0
  28. package/dist/composer/compiler/compile.py +550 -0
  29. package/dist/composer/examples/breakout_swing.graph.yaml +121 -0
  30. package/dist/composer/examples/conviction_scorer.graph.yaml +216 -0
  31. package/dist/composer/examples/macro_thesis_basket.graph.yaml +247 -0
  32. package/dist/composer/examples/pre_listing_classifier.graph.yaml +204 -0
  33. package/dist/composer/examples/trend_momentum_majors.graph.yaml +133 -0
  34. package/dist/composer/generated/README.md +22 -0
  35. package/dist/composer/generated/catalog.md +84 -0
  36. package/dist/composer/generated/describe.md +703 -0
  37. package/dist/composer/generated/graph.schema.json +1203 -0
  38. package/dist/composer/primitives/__init__.py +239 -0
  39. package/dist/composer/primitives/_candles.py +76 -0
  40. package/dist/composer/primitives/adapter.py +120 -0
  41. package/dist/composer/primitives/emitters.py +142 -0
  42. package/dist/composer/primitives/filters.py +71 -0
  43. package/dist/composer/primitives/indicators.py +203 -0
  44. package/dist/composer/primitives/rankers.py +40 -0
  45. package/dist/composer/primitives/scorers.py +100 -0
  46. package/dist/composer/primitives/sources.py +405 -0
  47. package/dist/composer/primitives/stateful.py +52 -0
  48. package/dist/composer/pure_fn/__init__.py +34 -0
  49. package/dist/composer/pure_fn/checks.py +39 -0
  50. package/dist/composer/pure_fn/models.py +177 -0
  51. package/dist/composer/pure_fn/purity.py +311 -0
  52. package/dist/composer/pure_fn/runtests.py +155 -0
  53. package/dist/composer/registry/__init__.py +40 -0
  54. package/dist/composer/registry/generate.py +422 -0
  55. package/dist/composer/registry/models.py +331 -0
  56. package/dist/composer/registry/nodes.py +673 -0
  57. package/dist/composer/requirements.txt +21 -0
  58. package/dist/composer/templates/__init__.py +13 -0
  59. package/dist/composer/templates/scan.py.j2 +31 -0
  60. package/dist/composer/validator/__init__.py +29 -0
  61. package/dist/composer/validator/desugar.py +133 -0
  62. package/dist/composer/validator/errors.py +191 -0
  63. package/dist/composer/validator/graph_checks.py +348 -0
  64. package/dist/composer/validator/loader.py +110 -0
  65. package/dist/composer/validator/schema_checks.py +147 -0
  66. package/dist/composer/validator/validate.py +86 -0
  67. package/dist/composer/verbs/__init__.py +33 -0
  68. package/dist/composer/verbs/check.py +193 -0
  69. package/dist/composer/verbs/deploy.py +223 -0
  70. package/dist/composer/verbs/docs.py +30 -0
  71. package/dist/composer/verbs/runtime_validate.py +262 -0
  72. package/dist/composer/verbs/smoke.py +115 -0
  73. package/dist/composer/verbs/status.py +205 -0
  74. package/dist/index.d.ts.map +1 -1
  75. package/dist/index.js +2 -9
  76. package/dist/index.js.map +1 -1
  77. package/dist/runtime-api/config.d.ts +2 -2
  78. package/dist/runtime-api/config.js +4 -4
  79. package/dist/runtime-api/config.js.map +1 -1
  80. package/dist/runtime-api/handlers/ingest-signals.d.ts +3 -5
  81. package/dist/runtime-api/handlers/ingest-signals.d.ts.map +1 -1
  82. package/dist/runtime-api/handlers/ingest-signals.js +5 -14
  83. package/dist/runtime-api/handlers/ingest-signals.js.map +1 -1
  84. package/dist/runtime-api/intake/intake-mount.d.ts.map +1 -1
  85. package/dist/runtime-api/intake/intake-mount.js +2 -8
  86. package/dist/runtime-api/intake/intake-mount.js.map +1 -1
  87. package/dist/skills-manager/manifest.js +1 -1
  88. package/dist/skills-manager/manifest.js.map +1 -1
  89. package/dist/skills-manager/skills-manifest.json +1 -1
  90. package/package.json +4 -2
  91. package/dist/runtime-api/intake/ingest-mutex.d.ts +0 -16
  92. package/dist/runtime-api/intake/ingest-mutex.d.ts.map +0 -1
  93. package/dist/runtime-api/intake/ingest-mutex.js +0 -18
  94. 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
+ }