ms-moe-maker 0.4.0__tar.gz

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.
@@ -0,0 +1,54 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003" ToolsVersion="4.0">
3
+ <PropertyGroup>
4
+ <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
5
+ <SchemaVersion>2.0</SchemaVersion>
6
+ <ProjectGuid>7c1f4a92-3e6d-4b18-9a4c-2f8b6d5e1a03</ProjectGuid>
7
+ <ProjectHome>.</ProjectHome>
8
+ <!-- F5 runs the CLI. Pass a recipe in the project's debug arguments. -->
9
+ <StartupFile>ms_moe_maker\__main__.py</StartupFile>
10
+ <SearchPath>.</SearchPath>
11
+ <WorkingDirectory>.</WorkingDirectory>
12
+ <OutputPath>.</OutputPath>
13
+ <Name>MsMoEMaker</Name>
14
+ <RootNamespace>ms_moe_maker</RootNamespace>
15
+ <InterpreterId>Global|PythonCore|3.11</InterpreterId>
16
+ <IsWindowsApplication>False</IsWindowsApplication>
17
+ <Description>Build a mixture of experts from deliberately chosen specialists.</Description>
18
+ <TestFramework>Pytest</TestFramework>
19
+ </PropertyGroup>
20
+ <PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
21
+ <DebugSymbols>true</DebugSymbols>
22
+ <EnableUnmanagedDebugging>false</EnableUnmanagedDebugging>
23
+ </PropertyGroup>
24
+ <PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
25
+ <DebugSymbols>true</DebugSymbols>
26
+ <EnableUnmanagedDebugging>false</EnableUnmanagedDebugging>
27
+ </PropertyGroup>
28
+ <ItemGroup>
29
+ <Compile Include="ms_moe_maker\__init__.py" />
30
+ <Compile Include="ms_moe_maker\__main__.py" />
31
+ <Compile Include="ms_moe_maker\_describe.py" />
32
+ <Compile Include="ms_moe_maker\events.py" />
33
+ <Compile Include="ms_moe_maker\levers.py" />
34
+ <Compile Include="ms_moe_maker\manifest.py" />
35
+ <Compile Include="ms_moe_maker\recipe.py" />
36
+ <Compile Include="ms_moe_maker\runner.py" />
37
+ <Compile Include="ms_moe_maker\stages.py" />
38
+ <Compile Include="tests\test_cli.py" />
39
+ <Compile Include="tests\test_runner.py" />
40
+ </ItemGroup>
41
+ <ItemGroup>
42
+ <Folder Include="ms_moe_maker\" />
43
+ <Folder Include="tests\" />
44
+ </ItemGroup>
45
+ <ItemGroup>
46
+ <Content Include="pyproject.toml" />
47
+ <Content Include="README.md" />
48
+ <Content Include="recipe.example.yaml" />
49
+ </ItemGroup>
50
+ <ItemGroup>
51
+ <InterpreterReference Include="Global|PythonCore|3.11" />
52
+ </ItemGroup>
53
+ <Import Project="$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\Python Tools\Microsoft.PythonTools.targets" />
54
+ </Project>
@@ -0,0 +1,175 @@
1
+ Metadata-Version: 2.4
2
+ Name: ms-moe-maker
3
+ Version: 0.4.0
4
+ Summary: Build a mixture of experts from deliberately chosen specialists. Not a coding model - a coding model shaped like your stack.
5
+ Author: Chad Roesler
6
+ License-Expression: GPL-3.0-only
7
+ Project-URL: Homepage, https://github.com/ChadRoesler/MsMoEMaker
8
+ Project-URL: Repository, https://github.com/ChadRoesler/MsMoEMaker
9
+ Project-URL: Bug Tracker, https://github.com/ChadRoesler/MsMoEMaker/issues
10
+ Keywords: moe,mixture-of-experts,qwen,fine-tuning,gguf,llm
11
+ Requires-Python: >=3.10
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: pyyaml>=6.0
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest>=8.0; extra == "dev"
16
+
17
+ # Ms.MoE
18
+
19
+ **Multi-Specified Mixture of Experts.** Five deliberate experts instead of a
20
+ hundred lottery tickets.
21
+
22
+ The design thesis is the inverse of a frontier MoE. Instead of training many
23
+ experts and hoping specialisation emerges — then fighting dead and collapsed
24
+ experts with a load-balancing auxiliary loss — you *hand-assign* the domains.
25
+ Every expert has a guaranteed constituency, so none of them can go dead,
26
+ because none of them was speculative.
27
+
28
+ The corollary is what makes it maintainable by one person: because each expert
29
+ does exactly one thing, you can retrain **one** and re-splice without touching
30
+ the others.
31
+
32
+ > Not a coding model. A coding model shaped like *your* stack.
33
+
34
+ The real product is the factory, not the model. Swap the expert list and
35
+ someone else gets their own Ms.MoE.
36
+
37
+ ---
38
+
39
+ ## Install
40
+
41
+ ```bash
42
+ pip install ms-moe-maker
43
+ ```
44
+
45
+ That gets you the CLI and the contract — about a megabyte, no torch. The heavy
46
+ machinery lives in the pipeline this forks, in whatever venv you train in. That
47
+ split is the point: `ms-moe-maker validate` runs on a laptop, so you can check a
48
+ recipe and see what it will cost before going near a machine that can run it.
49
+
50
+ ## Use
51
+
52
+ ```bash
53
+ ms-moe-maker describe # one line of JSON, exit 0, no side effects
54
+ ms-moe-maker validate recipe.yaml # parse, check, translate — touches nothing
55
+ ms-moe-maker build recipe.yaml # run it
56
+ ms-moe-maker build recipe.yaml --json # JSON Lines on stdout, prose on stderr
57
+ ```
58
+
59
+ `ms-moe-maker build recipe.yaml` is the literal command. It's what's in this README,
60
+ it's what a person types, and it's exactly what `seren-theatre[stagehand]`
61
+ forks — no separate API path with different defaults. If those two ever
62
+ diverged, the hand-run path would rot, because it's the one with no automated
63
+ users. Making them identical removes the possibility.
64
+
65
+ ## The recipe
66
+
67
+ A build, as a document. The point is that you can hand it to someone who
68
+ doesn't have your box and they get your run — that's the difference between
69
+ "it works, look" and a result.
70
+
71
+ ```yaml
72
+ schema_version: 1
73
+ name: msmoe-coder-5x-dryrun
74
+ size: 0.5B
75
+ base: huihui-ai/Qwen2.5-Coder-0.5B-Instruct-abliterated
76
+
77
+ experts:
78
+ - name: powershell
79
+ source: { kind: hf, repo: SaeedRahmani/codeparrot_github_code_powershell, text_field: code }
80
+ - name: python
81
+ source: { kind: stack, language: Python }
82
+ # ...
83
+
84
+ budget:
85
+ target_steps: 150 # 1200 for a real rung; 150 is the shakedown
86
+ max_seq_length: 2048
87
+ per_device_batch: 4
88
+ grad_accum: 2
89
+ ```
90
+
91
+ See `recipe.example.yaml` for the annotated version — every field carries the
92
+ measurement that chose it.
93
+
94
+ Budgets are in **tokens**, derived from steps. Capping documents instead looked
95
+ like balance and wasn't: at 10,000 documents each, PowerShell received 4.3× the
96
+ gradient updates Shell did, and a different LR curve besides.
97
+
98
+ ## Refusals — read this bit
99
+
100
+ Right now `ms-moe-maker build` drives an existing pipeline script by setting
101
+ environment variables and forking it. That script exposes sixteen levers. A
102
+ recipe declares far more than sixteen things.
103
+
104
+ So the naive wrapper would accept `per_device_batch: 8`, run the build at 4,
105
+ and report success — leaving you with a document that *looks* authoritative and
106
+ silently isn't. That's the worst possible place to put that trap, in the one
107
+ file whose entire selling point is reproducing someone else's run.
108
+
109
+ **So a recipe field is honoured, or the build refuses. Never ignored.**
110
+
111
+ The check isn't "is there a lever" — it's "will the run actually do what the
112
+ document says". Ms.MoE reads the pipeline's own constants statically (via
113
+ `ast`, never importing — importing it would cost you a CUDA context) and
114
+ compares each field against the value that will really be used. Agreement is
115
+ silence. Only disagreement refuses.
116
+
117
+ ```
118
+ $ ms-moe-maker build recipe.yaml
119
+ 2 recipe field(s) cannot be honoured by fraunkenstein_universal.py:
120
+ · budget.per_device_batch=8 cannot be applied: the pipeline uses 4 from
121
+ PER_DEVICE_BATCH and exposes no environment lever for it.
122
+ · gates.main_evals='manual' cannot be honoured: the pipeline runs end to
123
+ end with no stage boundary a gate could pause at.
124
+ REFUSED - nothing was run.
125
+ ```
126
+
127
+ `--allow-refusals` proceeds anyway. The refusals are recorded in the run
128
+ manifest either way, because the person who needs to know a lever was ignored
129
+ is the one reading the dashboard six hours later, not the one who saw the
130
+ terminal at kickoff.
131
+
132
+ **The refusal list is the roadmap.** Each entry is a field somebody wanted to
133
+ set and couldn't — which is exactly the priority order for pulling that part of
134
+ the script into a real stage. When the list is empty, the decomposition is
135
+ finished, and nobody had to guess when.
136
+
137
+ ## The run manifest
138
+
139
+ A build writes `msmoe-run.json` into its run directory: what the run is, the
140
+ ordered stage list, each stage's status and artifact, and any refusals.
141
+
142
+ That file is the **only** interface between this package and any viewer.
143
+ Nothing imports anything. `seren-theatre` reads the manifest when it's there
144
+ and falls back to reading the directory when it isn't — so an instrumented run
145
+ is exact and an uninstrumented directory still works. Neither package is
146
+ required, and neither knows the other exists.
147
+
148
+ ## Events
149
+
150
+ Under `--json`: one JSON object per line on stdout, prose on stderr, never
151
+ interleaved.
152
+
153
+ | event | when |
154
+ |---|---|
155
+ | `started` | the build begins; carries the resolved env and run dir |
156
+ | `stage` | a stage changes status |
157
+ | `progress` | something worth knowing inside a stage |
158
+ | `refused` | recipe fields that couldn't be honoured |
159
+ | `warning` / `error` | trouble |
160
+ | `done` | terminal, with `ok` |
161
+
162
+ Every line is flushed. A consumer following a six-hour build through a pipe
163
+ would otherwise see nothing until the buffer filled — and that looks exactly
164
+ like a hang.
165
+
166
+ ## Status
167
+
168
+ The stage machinery, contract and CLI are real. The pipeline itself is still
169
+ the original 2483-line script, driven from the outside — **wrap-then-carve**.
170
+ The contract is the product; the internals move behind it without anything
171
+ downstream noticing.
172
+
173
+ ## Licence
174
+
175
+ GPL-3.0-only.
@@ -0,0 +1,159 @@
1
+ # Ms.MoE
2
+
3
+ **Multi-Specified Mixture of Experts.** Five deliberate experts instead of a
4
+ hundred lottery tickets.
5
+
6
+ The design thesis is the inverse of a frontier MoE. Instead of training many
7
+ experts and hoping specialisation emerges — then fighting dead and collapsed
8
+ experts with a load-balancing auxiliary loss — you *hand-assign* the domains.
9
+ Every expert has a guaranteed constituency, so none of them can go dead,
10
+ because none of them was speculative.
11
+
12
+ The corollary is what makes it maintainable by one person: because each expert
13
+ does exactly one thing, you can retrain **one** and re-splice without touching
14
+ the others.
15
+
16
+ > Not a coding model. A coding model shaped like *your* stack.
17
+
18
+ The real product is the factory, not the model. Swap the expert list and
19
+ someone else gets their own Ms.MoE.
20
+
21
+ ---
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ pip install ms-moe-maker
27
+ ```
28
+
29
+ That gets you the CLI and the contract — about a megabyte, no torch. The heavy
30
+ machinery lives in the pipeline this forks, in whatever venv you train in. That
31
+ split is the point: `ms-moe-maker validate` runs on a laptop, so you can check a
32
+ recipe and see what it will cost before going near a machine that can run it.
33
+
34
+ ## Use
35
+
36
+ ```bash
37
+ ms-moe-maker describe # one line of JSON, exit 0, no side effects
38
+ ms-moe-maker validate recipe.yaml # parse, check, translate — touches nothing
39
+ ms-moe-maker build recipe.yaml # run it
40
+ ms-moe-maker build recipe.yaml --json # JSON Lines on stdout, prose on stderr
41
+ ```
42
+
43
+ `ms-moe-maker build recipe.yaml` is the literal command. It's what's in this README,
44
+ it's what a person types, and it's exactly what `seren-theatre[stagehand]`
45
+ forks — no separate API path with different defaults. If those two ever
46
+ diverged, the hand-run path would rot, because it's the one with no automated
47
+ users. Making them identical removes the possibility.
48
+
49
+ ## The recipe
50
+
51
+ A build, as a document. The point is that you can hand it to someone who
52
+ doesn't have your box and they get your run — that's the difference between
53
+ "it works, look" and a result.
54
+
55
+ ```yaml
56
+ schema_version: 1
57
+ name: msmoe-coder-5x-dryrun
58
+ size: 0.5B
59
+ base: huihui-ai/Qwen2.5-Coder-0.5B-Instruct-abliterated
60
+
61
+ experts:
62
+ - name: powershell
63
+ source: { kind: hf, repo: SaeedRahmani/codeparrot_github_code_powershell, text_field: code }
64
+ - name: python
65
+ source: { kind: stack, language: Python }
66
+ # ...
67
+
68
+ budget:
69
+ target_steps: 150 # 1200 for a real rung; 150 is the shakedown
70
+ max_seq_length: 2048
71
+ per_device_batch: 4
72
+ grad_accum: 2
73
+ ```
74
+
75
+ See `recipe.example.yaml` for the annotated version — every field carries the
76
+ measurement that chose it.
77
+
78
+ Budgets are in **tokens**, derived from steps. Capping documents instead looked
79
+ like balance and wasn't: at 10,000 documents each, PowerShell received 4.3× the
80
+ gradient updates Shell did, and a different LR curve besides.
81
+
82
+ ## Refusals — read this bit
83
+
84
+ Right now `ms-moe-maker build` drives an existing pipeline script by setting
85
+ environment variables and forking it. That script exposes sixteen levers. A
86
+ recipe declares far more than sixteen things.
87
+
88
+ So the naive wrapper would accept `per_device_batch: 8`, run the build at 4,
89
+ and report success — leaving you with a document that *looks* authoritative and
90
+ silently isn't. That's the worst possible place to put that trap, in the one
91
+ file whose entire selling point is reproducing someone else's run.
92
+
93
+ **So a recipe field is honoured, or the build refuses. Never ignored.**
94
+
95
+ The check isn't "is there a lever" — it's "will the run actually do what the
96
+ document says". Ms.MoE reads the pipeline's own constants statically (via
97
+ `ast`, never importing — importing it would cost you a CUDA context) and
98
+ compares each field against the value that will really be used. Agreement is
99
+ silence. Only disagreement refuses.
100
+
101
+ ```
102
+ $ ms-moe-maker build recipe.yaml
103
+ 2 recipe field(s) cannot be honoured by fraunkenstein_universal.py:
104
+ · budget.per_device_batch=8 cannot be applied: the pipeline uses 4 from
105
+ PER_DEVICE_BATCH and exposes no environment lever for it.
106
+ · gates.main_evals='manual' cannot be honoured: the pipeline runs end to
107
+ end with no stage boundary a gate could pause at.
108
+ REFUSED - nothing was run.
109
+ ```
110
+
111
+ `--allow-refusals` proceeds anyway. The refusals are recorded in the run
112
+ manifest either way, because the person who needs to know a lever was ignored
113
+ is the one reading the dashboard six hours later, not the one who saw the
114
+ terminal at kickoff.
115
+
116
+ **The refusal list is the roadmap.** Each entry is a field somebody wanted to
117
+ set and couldn't — which is exactly the priority order for pulling that part of
118
+ the script into a real stage. When the list is empty, the decomposition is
119
+ finished, and nobody had to guess when.
120
+
121
+ ## The run manifest
122
+
123
+ A build writes `msmoe-run.json` into its run directory: what the run is, the
124
+ ordered stage list, each stage's status and artifact, and any refusals.
125
+
126
+ That file is the **only** interface between this package and any viewer.
127
+ Nothing imports anything. `seren-theatre` reads the manifest when it's there
128
+ and falls back to reading the directory when it isn't — so an instrumented run
129
+ is exact and an uninstrumented directory still works. Neither package is
130
+ required, and neither knows the other exists.
131
+
132
+ ## Events
133
+
134
+ Under `--json`: one JSON object per line on stdout, prose on stderr, never
135
+ interleaved.
136
+
137
+ | event | when |
138
+ |---|---|
139
+ | `started` | the build begins; carries the resolved env and run dir |
140
+ | `stage` | a stage changes status |
141
+ | `progress` | something worth knowing inside a stage |
142
+ | `refused` | recipe fields that couldn't be honoured |
143
+ | `warning` / `error` | trouble |
144
+ | `done` | terminal, with `ok` |
145
+
146
+ Every line is flushed. A consumer following a six-hour build through a pipe
147
+ would otherwise see nothing until the buffer filled — and that looks exactly
148
+ like a hang.
149
+
150
+ ## Status
151
+
152
+ The stage machinery, contract and CLI are real. The pipeline itself is still
153
+ the original 2483-line script, driven from the outside — **wrap-then-carve**.
154
+ The contract is the product; the internals move behind it without anything
155
+ downstream noticing.
156
+
157
+ ## Licence
158
+
159
+ GPL-3.0-only.
@@ -0,0 +1,28 @@
1
+ """Ms.MoE - Multi-Specified Mixture of Experts.
2
+
3
+ Five deliberate experts instead of a hundred lottery tickets. The design thesis
4
+ is the INVERSE of a frontier MoE: hand-assign the domains so every expert has a
5
+ guaranteed constituency, which eliminates dead and collapsed experts by
6
+ construction rather than fighting them with a load-balancing auxiliary loss.
7
+ Because each expert does exactly one thing, you can retrain ONE and re-splice
8
+ without touching the others - which is what makes it maintainable by one
9
+ person.
10
+
11
+ The real product is the factory, not the model. Swap the expert list and
12
+ someone else gets their own Ms.MoE, shaped like THEIR stack.
13
+
14
+ This package deliberately depends on nothing of Seren's. seren-theatre can
15
+ watch a run, and seren-theatre[stagehand] can start one, but the arrow only
16
+ points that way - and even then the two never speak, they share a directory.
17
+ Opt in, never opt out.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ try:
22
+ from ._version import version as __version__
23
+ except Exception: # noqa: BLE001 - source checkout without a build
24
+ __version__ = "0.0.0+unknown"
25
+
26
+ from ._describe import DESCRIBE, NAME # noqa: F401 (stdlib-only, safe here)
27
+
28
+ __all__ = ["DESCRIBE", "NAME", "__version__"]
@@ -0,0 +1,253 @@
1
+ """The ms-moe-maker CLI - the command stagehand forks and a person types.
2
+
3
+ Three verbs:
4
+
5
+ ms-moe-maker describe one line of JSON, exit 0, no side effects
6
+ ms-moe-maker validate recipe.yaml parse + check, touching nothing
7
+ ms-moe-maker build recipe.yaml translate, fork the pipeline, report
8
+
9
+ `ms-moe-maker build recipe.yaml` is deliberately the literal string in the README and
10
+ the literal string seren-theatre[stagehand] forks. Not a Python API call, not
11
+ an internal entry point with different defaults - the same command. If the two
12
+ ever diverge, the hand-run path is the one that rots, because it is the one
13
+ with no automated users; making them identical removes the possibility.
14
+
15
+ --describe is scanned before argparse for the same reason every Seren installer
16
+ does it: it has to answer on a broken install, so nothing may run first.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import json
22
+ import os
23
+ import sys
24
+ from pathlib import Path
25
+
26
+ from ._describe import DESCRIBE
27
+ from .events import Events
28
+
29
+
30
+ def _force_utf8_stdio() -> None:
31
+ """UTF-8 regardless of console codepage. Windows defaults to legacy, and
32
+ the pipeline prints emoji milestones - a UnicodeEncodeError mid-build would
33
+ kill a run over a decorative character."""
34
+ for stream in (sys.stdout, sys.stderr):
35
+ try:
36
+ stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
37
+ except (AttributeError, ValueError):
38
+ pass
39
+
40
+
41
+ def _find_pipeline(explicit: str | None, recipe_path: Path,
42
+ required: bool) -> Path | None:
43
+ """Locate fraunkenstein_universal.py. None if absent and not required.
44
+
45
+ Looked up rather than assumed, and reported when missing, because "wrapped
46
+ a script that isn't there" should be one clear error and not a traceback
47
+ from subprocess. Order: --pipeline, beside the recipe, cwd, then upward.
48
+
49
+ `required` is the difference between the two verbs, and it is not a
50
+ convenience:
51
+
52
+ build - REQUIRED. There is nothing to fork without it.
53
+ validate - OPTIONAL. The README promises `ms-moe-maker validate` runs on a
54
+ laptop with no GPU so you can check a recipe BEFORE going
55
+ near a machine that can run it. Demanding the pipeline made
56
+ that promise false: a stranger with a recipe and no checkout
57
+ got "could not find fraunkenstein_universal.py" and no
58
+ validation at all. Recipe SHAPE is checkable on its own; only
59
+ the refusal analysis needs a pipeline to compare against.
60
+
61
+ An explicit --pipeline that does not exist is always an error, for either
62
+ verb. Being told where it is and being wrong is different from not saying.
63
+ """
64
+ from .levers import DEFAULT_PIPELINE
65
+
66
+ if explicit:
67
+ p = Path(explicit)
68
+ if not p.is_file():
69
+ raise SystemExit(f"--pipeline {p} does not exist")
70
+ return p.resolve()
71
+
72
+ for candidate in (recipe_path.parent / DEFAULT_PIPELINE,
73
+ Path.cwd() / DEFAULT_PIPELINE):
74
+ if candidate.is_file():
75
+ return candidate.resolve()
76
+ for parent in [Path.cwd(), *Path.cwd().parents]:
77
+ candidate = parent / DEFAULT_PIPELINE
78
+ if candidate.is_file():
79
+ return candidate.resolve()
80
+ if required:
81
+ raise SystemExit(
82
+ f"could not find {DEFAULT_PIPELINE}. Pass --pipeline PATH, or run "
83
+ f"from the directory that holds it.")
84
+ return None
85
+
86
+
87
+ def main(argv: list[str] | None = None) -> int:
88
+ _force_utf8_stdio()
89
+ argv = list(sys.argv[1:] if argv is None else argv)
90
+
91
+ # Before argparse: zero side effects, works half-installed.
92
+ if "--describe" in argv or (argv and argv[0] == "describe"):
93
+ print(json.dumps(DESCRIBE))
94
+ return 0
95
+
96
+ ap = argparse.ArgumentParser(
97
+ prog="ms-moe-maker",
98
+ description="Build a mixture of experts from a recipe.")
99
+ ap.add_argument("command", choices=["build", "validate", "describe"])
100
+ ap.add_argument("recipe", nargs="?", help="path to the recipe .yaml")
101
+ ap.add_argument("--pipeline", default=None,
102
+ help="path to fraunkenstein_universal.py (default: found "
103
+ "beside the recipe, then upward from cwd)")
104
+ ap.add_argument("--json", action="store_true",
105
+ help="JSON Lines events on stdout, prose on stderr")
106
+ ap.add_argument("--python", default=None,
107
+ help="interpreter to run the pipeline with (default: the "
108
+ "one running ms-moe-maker). Use this when the trainer lives "
109
+ "in a different venv - which is the normal case, "
110
+ "since ms-moe-maker is deliberately small and torch is not.")
111
+ ap.add_argument("--dryrun", action="store_true",
112
+ help="FRAUNK_DRYRUN=1 - the whole pipeline, small")
113
+ ap.add_argument("--force", action="store_true",
114
+ help="FRAUNK_FORCE=1 - redo stages whose artifacts exist")
115
+ ap.add_argument("--allow-refusals", action="store_true",
116
+ help="run even though some recipe fields cannot be "
117
+ "honoured. They are recorded in the manifest either "
118
+ "way; this only removes the stop.")
119
+ a = ap.parse_args(argv)
120
+
121
+ ev = Events(enabled=a.json)
122
+
123
+ if a.command == "describe":
124
+ print(json.dumps(DESCRIBE))
125
+ return 0
126
+
127
+ if not a.recipe:
128
+ ap.error("a recipe path is required")
129
+
130
+ from .recipe import load, resolve, validate
131
+
132
+ recipe_path = Path(a.recipe).resolve()
133
+ try:
134
+ rec, parse_warns = load(str(recipe_path))
135
+ except Exception as exc: # noqa: BLE001 - the message IS the product
136
+ ev.error("parse", str(exc))
137
+ ev.say(f"FAILED to parse {recipe_path}: {exc}")
138
+ return 2
139
+
140
+ errs, warns = validate(rec)
141
+ warns = parse_warns + warns
142
+ for w in warns:
143
+ ev.warning(w)
144
+ ev.say(f" WARN {w}")
145
+ for e in errs:
146
+ ev.error("validate", e)
147
+ ev.say(f" ERROR {e}")
148
+ if errs:
149
+ ev.done(ok=False, stage="validate")
150
+ return 1
151
+
152
+ # RESOLVED BEFORE THE PIPELINE LOOKUP, on purpose. Both are explicit
153
+ # arguments, and an explicit argument that is wrong should say so no matter
154
+ # what else is also missing - same rule as --pipeline. Reporting "could not
155
+ # find fraunkenstein_universal.py" to someone who mistyped --python sends
156
+ # them to fix the wrong thing.
157
+ #
158
+ # MSMOE_PYTHON as well as --python: the interpreter is a property of the
159
+ # BOX, not of the run, so it belongs somewhere you set once. Same shape as
160
+ # the family's SEREN_<X>_* levers - a flag for the one-off, an env var for
161
+ # the machine.
162
+ interpreter = a.python or os.environ.get("MSMOE_PYTHON") or None
163
+ if interpreter:
164
+ ipath = Path(interpreter)
165
+ if not ipath.is_file():
166
+ raise SystemExit(f"--python {ipath} does not exist")
167
+ # abspath, NEVER resolve(). A venv's bin/python is a SYMLINK to the
168
+ # base interpreter, and resolving it throws the venv away: you asked
169
+ # for /lab/bin/python and got /usr/bin/python3.12, whose sys.prefix is
170
+ # /usr and whose site-packages has none of your training deps. The
171
+ # failure then reads as "No module named 'torch'" from an interpreter
172
+ # you never named, which is about as misleading as it gets.
173
+ #
174
+ # Measured: running the symlink gives sys.prefix=/tmp/venvtest;
175
+ # running its target gives sys.prefix=/usr. Same file, different venv.
176
+ # abspath normalises the path without following the link.
177
+ interpreter = os.path.abspath(str(ipath))
178
+
179
+ pipeline = _find_pipeline(a.pipeline, recipe_path,
180
+ required=(a.command == "build"))
181
+
182
+ from .levers import Translation, translate
183
+
184
+ if pipeline is None:
185
+ # Recipe-only validation. Say so LOUDLY rather than reporting a clean
186
+ # bill of health: "valid" and "valid, and nothing checked whether the
187
+ # pipeline can honour it" are different answers, and quietly giving
188
+ # the first when you mean the second is how a document that lies gets
189
+ # blessed on its way out the door.
190
+ tr = Translation()
191
+ no_pipeline_note = (
192
+ "no pipeline found, so ONLY the recipe's own shape was checked. "
193
+ "Refusals could not be computed - run this again beside "
194
+ "fraunkenstein_universal.py, or pass --pipeline PATH, to find out "
195
+ "whether a build would actually honour these fields.")
196
+ ev.warning(no_pipeline_note)
197
+ else:
198
+ no_pipeline_note = ""
199
+ tr = translate(rec, pipeline, force=a.force)
200
+
201
+ if tr.refusals:
202
+ ev.refused(tr.refusals)
203
+ ev.say("")
204
+ ev.say(f" {len(tr.refusals)} recipe field(s) cannot be honoured by "
205
+ f"{pipeline.name}:")
206
+ for r in tr.refusals:
207
+ ev.say(f" · {r}")
208
+ ev.say("")
209
+ ev.say(" These are not warnings. A recipe is a document you hand to "
210
+ "someone so they get YOUR run, and a field that is silently "
211
+ "ignored makes it a document that lies. Fix the recipe, carve "
212
+ "the stage out, or pass --allow-refusals to proceed knowing "
213
+ "the build will not match the file.")
214
+
215
+ if a.command == "validate":
216
+ eff = resolve(rec)
217
+ ev.emit("resolved", **eff)
218
+ ev.say("")
219
+ ev.say(f"Ms.MoE recipe {rec.name} [{eff['recipe_id']}]")
220
+ ev.say(f" pipeline {pipeline if pipeline else '(none found)'}")
221
+ ev.say(f" honoured {len(tr.agreed)} field(s), "
222
+ f"{len(tr.env)} env lever(s) set")
223
+ ev.say(f" refused {len(tr.refusals)} field(s)")
224
+ if no_pipeline_note:
225
+ ev.say("")
226
+ ev.say(f" NOTE {no_pipeline_note}")
227
+ ok = not tr.refusals
228
+ ev.done(ok=ok, refusals=len(tr.refusals), agreed=len(tr.agreed),
229
+ env=tr.env, pipeline=str(pipeline) if pipeline else None,
230
+ # The consumer needs to be able to tell "no refusals" from
231
+ # "refusals were never computed". Same key set either way, one
232
+ # honest flag - the alternative is a caller inferring depth of
233
+ # analysis from an empty list, which it cannot do.
234
+ refusals_checked=pipeline is not None)
235
+ return 0 if ok else 1
236
+
237
+ # build
238
+ if tr.refusals and not a.allow_refusals:
239
+ ev.done(ok=False, stage="translate", refusals=len(tr.refusals))
240
+ ev.say(" REFUSED - nothing was run.")
241
+ return 3
242
+
243
+ from .runner import Runner
244
+
245
+ if interpreter:
246
+ ev.say(f" pipeline interpreter: {interpreter}")
247
+ runner = Runner(rec, pipeline, tr, ev, cwd=pipeline.parent,
248
+ dryrun=a.dryrun, python=interpreter)
249
+ return runner.run()
250
+
251
+
252
+ if __name__ == "__main__":
253
+ raise SystemExit(main())