ms-moe-maker 0.4.0__py3-none-any.whl

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,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())
@@ -0,0 +1,47 @@
1
+ """ms-moe-maker's identity card. STDLIB ONLY - nothing imported, nothing read.
2
+
3
+ Same contract and the same reason as every Seren service's `_describe`, even
4
+ though ms-moe-maker is NOT a Seren package and never imports one: `--describe` has to
5
+ answer on a half-installed tool, so it cannot need torch, pydantic, or yaml.
6
+ The moment you most want something to be able to say its own name is when its
7
+ install is broken.
8
+
9
+ ms-moe-maker is deliberately Seren-agnostic. No seren-* dependency, no assumption
10
+ that Lodestar exists, no Seren in the name. It is a pipeline for building a
11
+ specified mixture of experts, usable by someone who has never heard of any of
12
+ this. seren-theatre[stagehand] depends on ms-moe-maker; ms-moe-maker depends on nothing of
13
+ Chad's. Mandate is not ethos - the connection is opt-in from the Seren side,
14
+ and the run DIRECTORY is the only thing the two ever share.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ NAME = "ms-moe-maker"
19
+ DESCRIPTION = ("Build a mixture of experts from deliberately chosen "
20
+ "specialists. Not a coding model - a coding model shaped like "
21
+ "your stack.")
22
+
23
+ # The three verbs. Named here so a front-end can offer them without parsing
24
+ # --help, and so `stagehand` can check the tool it forked speaks the version of
25
+ # the contract it expects.
26
+ COMMANDS = ("build", "validate", "describe")
27
+
28
+ # The event vocabulary emitted under --json. A consumer that does not know an
29
+ # event kind must ignore it, so adding one is not a breaking change; removing
30
+ # or renaming one is.
31
+ EVENTS = ("started", "stage", "progress", "refused", "warning", "error", "done")
32
+
33
+ DESCRIBE = {
34
+ "name": NAME,
35
+ "kind": "pipeline",
36
+ "description": DESCRIPTION,
37
+ "commands": list(COMMANDS),
38
+ "events": list(EVENTS),
39
+ # The manifest schema this build writes into a run directory. A reader
40
+ # (seren-theatre) can check compatibility before it trusts a file.
41
+ "manifest_schema_version": 1,
42
+ "recipe_schema_version": 1,
43
+ # Requires nothing, of anyone. The heavy deps (torch, transformers,
44
+ # datasets) belong to the PIPELINE it forks, not to this CLI - which is
45
+ # what lets `ms-moe-maker validate` run on a laptop with no GPU and no CUDA.
46
+ "requires": [],
47
+ }
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.4.0'
22
+ __version_tuple__ = version_tuple = (0, 4, 0)
23
+
24
+ __commit_id__ = commit_id = 'g9c59d38f4'
ms_moe_maker/events.py ADDED
@@ -0,0 +1,70 @@
1
+ """JSON Lines events - the machine-readable twin of the human output.
2
+
3
+ The Starwright contract, reused: human prose on stderr, one JSON object per
4
+ line on stdout when --json is on. Two channels, never interleaved, so a
5
+ consumer can parse stdout without a heuristic for "is this line prose".
6
+
7
+ WHY STDERR FOR THE PROSE. The other direction - prose on stdout, events on a
8
+ side channel - means anything piping the tool has to know about the side
9
+ channel. Putting the machine stream on stdout makes `ms-moe-maker build r.yaml --json
10
+ | jq` work with no ceremony, which is the whole point of having it.
11
+
12
+ ONE RULE, and it is the one that gets broken: flush every line. A consumer
13
+ following a build is reading a pipe, and Python block-buffers a pipe by
14
+ default. Without the flush, a run that takes six hours emits its first event
15
+ when the buffer fills or the process exits, and a dashboard watching it shows
16
+ nothing at all for hours while everything is completely fine. That failure
17
+ looks exactly like a hang.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import sys
23
+ from typing import Any, TextIO
24
+
25
+
26
+ class Events:
27
+ """Emitter. Inert unless --json was passed, so call sites need no branch."""
28
+
29
+ def __init__(self, enabled: bool = False, stream: TextIO | None = None,
30
+ prose: TextIO | None = None) -> None:
31
+ self.enabled = enabled
32
+ self._out = stream or sys.stdout
33
+ self._prose = prose or sys.stderr
34
+
35
+ def emit(self, kind: str, **fields: Any) -> None:
36
+ if not self.enabled:
37
+ return
38
+ # default=str so a Path or a dataclass never turns a progress report
39
+ # into a crash. An event stream that can kill the build it is
40
+ # describing is worse than no event stream.
41
+ self._out.write(json.dumps({"event": kind, **fields}, default=str) + "\n")
42
+ self._out.flush()
43
+
44
+ def say(self, message: str) -> None:
45
+ """Human line. Always stderr, never the event stream."""
46
+ self._prose.write(message + "\n")
47
+ self._prose.flush()
48
+
49
+ # -- the vocabulary, as methods so typos are import errors not silence ---
50
+
51
+ def started(self, **kw: Any) -> None:
52
+ self.emit("started", **kw)
53
+
54
+ def stage(self, id: str, status: str, **kw: Any) -> None:
55
+ self.emit("stage", id=id, status=status, **kw)
56
+
57
+ def progress(self, id: str, **kw: Any) -> None:
58
+ self.emit("progress", id=id, **kw)
59
+
60
+ def refused(self, reasons: list[str]) -> None:
61
+ self.emit("refused", reasons=reasons, count=len(reasons))
62
+
63
+ def warning(self, message: str) -> None:
64
+ self.emit("warning", message=message)
65
+
66
+ def error(self, stage: str, message: str) -> None:
67
+ self.emit("error", stage=stage, message=message)
68
+
69
+ def done(self, ok: bool, **kw: Any) -> None:
70
+ self.emit("done", ok=ok, **kw)