Skip to content

Commit 7422406

Browse files
committed
feat(vbgui-fa.2): cppmega-run CLI + pipeline.yaml + pipeline.run JSON-RPC
Stage F-A.2 of the Visual Builder GUI epic (cppmega-mlx-o0k). Lands the generic runner described in VisualBuilderPlan.md §5.3. - runner/stages.py: 12 built-in stage callables (parse, verify_build_spec, apply_rewrites, resolve_shapes, estimate_memory, check_gotchas, build_model, dry_forward, input_parity_check, loss_smoke, optimizer_smoke, train). Each is a pure function over StageContext; failures fold into StageResult(status="fail"). - runner/pipeline.py: Pipeline manifest (declarative stages + options + continue_on_failure), Pipeline.from_yaml loader, PipelineReport, run_pipeline orchestrator. Stops on first failure by default, marks remaining stages as "skipped". - runner/cli.py: cppmega-run argparse entry. Smoke pipeline by default, --stages all / smoke / csv aliases, --pipeline yaml, --json output, --continue-on-failure. Exit codes 0/1/2. - jsonrpc/dispatcher.py: pipeline.run method now routes through the runner. Lazy import breaks the schema↔runner cycle. - pyproject.toml: console-script cppmega-run + cppmega_v4* added to setuptools packages.find include list. Tests (46 new): pipeline registry sanity, every built-in stage in isolation, pipeline failure cascade + continue_on_failure, yaml round-trip, JSON dumpability, CLI exit codes + JSON output + --stages smoke/all/csv, pipeline.run JSON-RPC dispatch, subprocess system test against `python -m cppmega_v4.runner.cli`. Full v4 regression: 2054 passed / 5 skipped / 15 xfailed / 0 failed. Closes cppmega-mlx-o0k.6.
1 parent e2a52fb commit 7422406

9 files changed

Lines changed: 1112 additions & 1 deletion

File tree

cppmega_v4/jsonrpc/dispatcher.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
JsonRpcError,
2929
JsonRpcRequest,
3030
JsonRpcResponse,
31+
PipelineRunParams,
32+
PipelineRunResult,
3133
ProbeRunParams,
3234
SuggestAdaptersParams,
3335
SuggestShardingParams,
@@ -61,9 +63,26 @@
6163
ProbeRunParams,
6264
lambda p, c: probe_run(p, cache=c),
6365
),
66+
"pipeline.run": (
67+
PipelineRunParams,
68+
lambda p, c: _pipeline_run(p),
69+
),
6470
}
6571

6672

73+
def _pipeline_run(params: PipelineRunParams) -> PipelineRunResult:
74+
# Lazy import — runner package depends on jsonrpc.schema for VerifyParams;
75+
# binding the symbols here breaks the import cycle.
76+
from cppmega_v4.runner import Pipeline, run_pipeline
77+
pipeline = Pipeline.from_dict({
78+
"stages": params.pipeline.stages,
79+
"stage_options": params.pipeline.stage_options,
80+
"continue_on_failure": params.pipeline.continue_on_failure,
81+
})
82+
report = run_pipeline(params.spec, pipeline)
83+
return PipelineRunResult.model_validate(report.to_dict())
84+
85+
6786
def dispatch(
6887
payload: Mapping[str, Any] | JsonRpcRequest,
6988
*,

cppmega_v4/runner/__init__.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""``cppmega-run`` pipeline runner.
2+
3+
See ``VisualBuilderPlan.md`` §5.3 for the design.
4+
5+
Stage F-A.2 surface:
6+
- stages: STAGE_REGISTRY (12 built-in stages, SMOKE_STAGES, FULL_STAGES)
7+
- pipeline: Pipeline / PipelineReport / run_pipeline
8+
- cli: ``cppmega-run`` entry point exposed via console-script
9+
"""
10+
11+
from __future__ import annotations
12+
13+
from cppmega_v4.runner.pipeline import (
14+
Pipeline,
15+
PipelineReport,
16+
run_pipeline,
17+
)
18+
from cppmega_v4.runner.stages import (
19+
FULL_STAGES,
20+
SMOKE_STAGES,
21+
STAGE_REGISTRY,
22+
StageContext,
23+
StageResult,
24+
)
25+
26+
__all__ = [
27+
"FULL_STAGES",
28+
"Pipeline",
29+
"PipelineReport",
30+
"SMOKE_STAGES",
31+
"STAGE_REGISTRY",
32+
"StageContext",
33+
"StageResult",
34+
"run_pipeline",
35+
]

cppmega_v4/runner/cli.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
"""``cppmega-run`` — single CLI entry exposing the pipeline runner.
2+
3+
Usage:
4+
cppmega-run spec.json # smoke pipeline, human
5+
cppmega-run spec.json --json # smoke pipeline, machine
6+
cppmega-run spec.json --stages all # full 11-stage pipeline
7+
cppmega-run spec.json --stages parse,verify_build_spec,resolve_shapes
8+
cppmega-run spec.json --pipeline pipeline.yaml
9+
cppmega-run spec.json --pipeline pipeline.yaml --continue-on-failure
10+
11+
Exit code 0 iff all stages either ``ok`` or ``skipped``. Exit 1 on any
12+
``fail``. Exit 2 on invalid input (spec parse / pipeline parse / argument
13+
errors).
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import argparse
19+
import json
20+
import sys
21+
from pathlib import Path
22+
from typing import Sequence
23+
24+
from pydantic import ValidationError
25+
26+
from cppmega_v4.jsonrpc.schema import VerifyParams
27+
from cppmega_v4.runner.pipeline import (
28+
Pipeline,
29+
PipelineReport,
30+
run_pipeline,
31+
)
32+
from cppmega_v4.runner.stages import FULL_STAGES, SMOKE_STAGES, STAGE_REGISTRY
33+
34+
35+
def _build_parser() -> argparse.ArgumentParser:
36+
p = argparse.ArgumentParser(
37+
prog="cppmega-run",
38+
description="Run the cppmega Visual Builder pipeline on a model_spec.json",
39+
)
40+
p.add_argument("spec", help="Path to model_spec.json (VerifyParams shape)")
41+
p.add_argument("--pipeline", help="Path to pipeline.yaml manifest")
42+
p.add_argument(
43+
"--stages",
44+
help="Comma-separated stage list OR 'smoke' / 'all'. "
45+
"Overrides --pipeline stage list.",
46+
)
47+
p.add_argument(
48+
"--continue-on-failure", action="store_true",
49+
help="Do not stop on first failed stage.",
50+
)
51+
p.add_argument("--json", action="store_true",
52+
help="Emit JSON report to stdout.")
53+
return p
54+
55+
56+
def _resolve_pipeline(args: argparse.Namespace) -> Pipeline:
57+
if args.pipeline:
58+
pipe = Pipeline.from_yaml(args.pipeline)
59+
else:
60+
pipe = Pipeline.smoke()
61+
if args.stages:
62+
stages = _expand_stage_list(args.stages)
63+
pipe = Pipeline(
64+
stages=stages,
65+
stage_options=pipe.stage_options,
66+
continue_on_failure=pipe.continue_on_failure,
67+
)
68+
if args.continue_on_failure:
69+
pipe = Pipeline(
70+
stages=pipe.stages,
71+
stage_options=pipe.stage_options,
72+
continue_on_failure=True,
73+
)
74+
return pipe
75+
76+
77+
def _expand_stage_list(arg: str) -> tuple[str, ...]:
78+
if arg == "smoke":
79+
return SMOKE_STAGES
80+
if arg == "all":
81+
return FULL_STAGES
82+
stages = tuple(s.strip() for s in arg.split(",") if s.strip())
83+
unknown = [s for s in stages if s not in STAGE_REGISTRY]
84+
if unknown:
85+
raise SystemExit(
86+
f"cppmega-run: unknown stage(s) {unknown!r}; "
87+
f"available: {sorted(STAGE_REGISTRY)}"
88+
)
89+
return stages
90+
91+
92+
def _load_spec(path: str) -> VerifyParams:
93+
raw = json.loads(Path(path).read_text())
94+
return VerifyParams.model_validate(raw)
95+
96+
97+
def _print_human(report: PipelineReport) -> None:
98+
for s in report.stages:
99+
marker = {"ok": "PASS", "fail": "FAIL", "skipped": "SKIP"}[s.status]
100+
line = f" [{marker}] {s.name:<22} {s.elapsed_ms:8.2f} ms"
101+
if s.error:
102+
line += f" error={s.error.get('type','?')}: {s.error.get('detail','?')[:80]}"
103+
print(line)
104+
print(f" overall: {report.overall_status} "
105+
f"total {report.total_elapsed_ms:.2f} ms")
106+
107+
108+
def main(argv: Sequence[str] | None = None) -> int:
109+
parser = _build_parser()
110+
args = parser.parse_args(argv)
111+
try:
112+
spec = _load_spec(args.spec)
113+
except (FileNotFoundError, json.JSONDecodeError) as exc:
114+
print(f"cppmega-run: failed to read spec: {exc}", file=sys.stderr)
115+
return 2
116+
except ValidationError as exc:
117+
print(f"cppmega-run: invalid spec: {exc}", file=sys.stderr)
118+
return 2
119+
120+
try:
121+
pipeline = _resolve_pipeline(args)
122+
except (FileNotFoundError, ValueError) as exc:
123+
print(f"cppmega-run: pipeline error: {exc}", file=sys.stderr)
124+
return 2
125+
126+
report = run_pipeline(spec, pipeline)
127+
128+
if args.json:
129+
json.dump(report.to_dict(), sys.stdout, indent=2)
130+
print()
131+
else:
132+
_print_human(report)
133+
134+
return 0 if report.overall_status == "ok" else 1
135+
136+
137+
if __name__ == "__main__":
138+
raise SystemExit(main())

cppmega_v4/runner/pipeline.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""Pipeline orchestrator — walk a list of stages, collect StageResults.
2+
3+
Loads pipeline.yaml manifests, validates stage names, then dispatches
4+
to :data:`cppmega_v4.runner.stages.STAGE_REGISTRY`.
5+
6+
Stops on the first failed stage unless ``continue_on_failure=True`` is
7+
set in the manifest. Skipped stages don't count as failures.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import time
13+
from dataclasses import dataclass, field
14+
from pathlib import Path
15+
from typing import Any, Mapping
16+
17+
import yaml
18+
19+
from cppmega_v4.jsonrpc.schema import VerifyParams
20+
from cppmega_v4.runner.stages import (
21+
SMOKE_STAGES,
22+
STAGE_REGISTRY,
23+
StageContext,
24+
StageResult,
25+
)
26+
27+
28+
@dataclass
29+
class Pipeline:
30+
"""Declarative manifest: which stages, in order, with options."""
31+
32+
stages: tuple[str, ...]
33+
stage_options: dict[str, dict[str, Any]] = field(default_factory=dict)
34+
continue_on_failure: bool = False
35+
36+
def __post_init__(self) -> None:
37+
unknown = [s for s in self.stages if s not in STAGE_REGISTRY]
38+
if unknown:
39+
raise ValueError(
40+
f"unknown stage(s) {unknown!r}; "
41+
f"available: {sorted(STAGE_REGISTRY)}"
42+
)
43+
44+
@classmethod
45+
def smoke(cls) -> Pipeline:
46+
return cls(stages=SMOKE_STAGES)
47+
48+
@classmethod
49+
def from_yaml(cls, path: str | Path) -> Pipeline:
50+
data = yaml.safe_load(Path(path).read_text())
51+
return cls.from_dict(data)
52+
53+
@classmethod
54+
def from_dict(cls, data: Mapping[str, Any]) -> Pipeline:
55+
stages = data.get("stages")
56+
if not stages:
57+
raise ValueError("pipeline manifest missing 'stages'")
58+
return cls(
59+
stages=tuple(stages),
60+
stage_options=dict(data.get("stage_options", {})),
61+
continue_on_failure=bool(data.get("continue_on_failure", False)),
62+
)
63+
64+
65+
@dataclass
66+
class PipelineReport:
67+
"""Final pipeline run rollup."""
68+
69+
stages: list[StageResult]
70+
overall_status: str
71+
total_elapsed_ms: float
72+
73+
def to_dict(self) -> dict[str, Any]:
74+
return {
75+
"stages": [s.to_dict() for s in self.stages],
76+
"overall_status": self.overall_status,
77+
"total_elapsed_ms": round(self.total_elapsed_ms, 3),
78+
}
79+
80+
81+
def run_pipeline(spec: VerifyParams, pipeline: Pipeline) -> PipelineReport:
82+
"""Run ``pipeline`` over ``spec`` and return the report.
83+
84+
Stops on the first failed stage unless ``continue_on_failure`` is set.
85+
"""
86+
t0 = time.perf_counter()
87+
ctx = StageContext(spec=spec, options=pipeline.stage_options)
88+
results: list[StageResult] = []
89+
for name in pipeline.stages:
90+
stage = STAGE_REGISTRY[name]
91+
result = stage(ctx)
92+
results.append(result)
93+
if result.status == "fail" and not pipeline.continue_on_failure:
94+
# Mark every subsequent stage as skipped for visibility.
95+
for remaining in pipeline.stages[len(results):]:
96+
results.append(StageResult(
97+
name=remaining, status="skipped", elapsed_ms=0.0,
98+
))
99+
break
100+
overall = "ok"
101+
if any(r.status == "fail" for r in results):
102+
overall = "fail"
103+
elapsed = (time.perf_counter() - t0) * 1000.0
104+
return PipelineReport(
105+
stages=results, overall_status=overall, total_elapsed_ms=elapsed,
106+
)

0 commit comments

Comments
 (0)