runapprentice 0.4.3__tar.gz → 0.4.4__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.
Files changed (35) hide show
  1. {runapprentice-0.4.3 → runapprentice-0.4.4}/.gitignore +5 -0
  2. {runapprentice-0.4.3 → runapprentice-0.4.4}/PKG-INFO +3 -3
  3. {runapprentice-0.4.3 → runapprentice-0.4.4}/pyproject.toml +5 -2
  4. runapprentice-0.4.4/src/runapprentice/_local_ops.py +171 -0
  5. runapprentice-0.4.4/src/runapprentice/_mlx_eval.py +70 -0
  6. runapprentice-0.4.4/src/runapprentice/_mlx_lora.py +243 -0
  7. runapprentice-0.4.4/src/runapprentice/_train_output.py +197 -0
  8. runapprentice-0.4.4/src/runapprentice/cli.py +1049 -0
  9. {runapprentice-0.4.3 → runapprentice-0.4.4}/src/runapprentice/client.py +43 -8
  10. {runapprentice-0.4.3 → runapprentice-0.4.4}/src/runapprentice/langchain.py +3 -1
  11. runapprentice-0.4.4/src/runapprentice/local.py +1144 -0
  12. {runapprentice-0.4.3 → runapprentice-0.4.4}/src/runapprentice/models.py +41 -0
  13. runapprentice-0.4.4/tests/conftest.py +12 -0
  14. {runapprentice-0.4.3 → runapprentice-0.4.4}/tests/test_capture.py +17 -0
  15. {runapprentice-0.4.3 → runapprentice-0.4.4}/tests/test_cli.py +206 -0
  16. {runapprentice-0.4.3 → runapprentice-0.4.4}/tests/test_client.py +14 -0
  17. runapprentice-0.4.4/tests/test_device_facts.py +60 -0
  18. {runapprentice-0.4.3 → runapprentice-0.4.4}/tests/test_local.py +329 -23
  19. runapprentice-0.4.4/tests/test_local_ops.py +497 -0
  20. runapprentice-0.4.4/tests/test_local_progress.py +228 -0
  21. runapprentice-0.4.4/tests/test_managed_run_readers.py +115 -0
  22. runapprentice-0.4.4/tests/test_train_output.py +157 -0
  23. runapprentice-0.4.4/tests/test_training_audit.py +275 -0
  24. runapprentice-0.4.3/src/runapprentice/cli.py +0 -618
  25. runapprentice-0.4.3/src/runapprentice/local.py +0 -685
  26. {runapprentice-0.4.3 → runapprentice-0.4.4}/LICENSE +0 -0
  27. {runapprentice-0.4.3 → runapprentice-0.4.4}/README.md +0 -0
  28. {runapprentice-0.4.3 → runapprentice-0.4.4}/src/runapprentice/__init__.py +0 -0
  29. {runapprentice-0.4.3 → runapprentice-0.4.4}/src/runapprentice/_capture.py +0 -0
  30. {runapprentice-0.4.3 → runapprentice-0.4.4}/src/runapprentice/openai.py +0 -0
  31. {runapprentice-0.4.3 → runapprentice-0.4.4}/src/runapprentice/py.typed +0 -0
  32. {runapprentice-0.4.3 → runapprentice-0.4.4}/tests/test_dispatcher.py +0 -0
  33. {runapprentice-0.4.3 → runapprentice-0.4.4}/tests/test_failopen.py +0 -0
  34. {runapprentice-0.4.3 → runapprentice-0.4.4}/tests/test_langchain_prompts.py +0 -0
  35. {runapprentice-0.4.3 → runapprentice-0.4.4}/tests/test_openai.py +0 -0
@@ -53,3 +53,8 @@ docs/personal_learning.md
53
53
  spike/Capture-*.png
54
54
  apprentice-ui/ClaudeDesign/screenshots/localhost-*.png
55
55
  supabase-magic-link-template.html
56
+
57
+ # Remotion scratch: frame exports for review, retired captures, render output.
58
+ # 30MB of JPEG/PNG working files — never source.
59
+ apprentice-video/tmp/
60
+ apprentice-video/out/
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: runapprentice
3
- Version: 0.4.3
3
+ Version: 0.4.4
4
4
  Summary: Cut LLM cost without losing quality: capture traffic, build golden datasets, and optimize prompts.
5
5
  Project-URL: Homepage, https://runapprentice.com
6
6
  Project-URL: Documentation, https://docs.runapprentice.com
@@ -22,7 +22,7 @@ Requires-Dist: pydantic>=2.0
22
22
  Provides-Extra: langchain
23
23
  Requires-Dist: langchain-core>=0.3; extra == 'langchain'
24
24
  Provides-Extra: local
25
- Requires-Dist: mlx-lm<0.32,>=0.31.3; extra == 'local'
25
+ Requires-Dist: mlx-lm<0.33,>=0.32.2; extra == 'local'
26
26
  Provides-Extra: optimize
27
27
  Requires-Dist: dspy<4,>=3.2.1; extra == 'optimize'
28
28
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "runapprentice"
3
- version = "0.4.3"
3
+ version = "0.4.4"
4
4
  description = "Cut LLM cost without losing quality: capture traffic, build golden datasets, and optimize prompts."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -32,7 +32,7 @@ Documentation = "https://docs.runapprentice.com"
32
32
 
33
33
  [project.optional-dependencies]
34
34
  langchain = ["langchain-core>=0.3"]
35
- local = ["mlx-lm>=0.31.3,<0.32"]
35
+ local = ["mlx-lm>=0.32.2,<0.33"]
36
36
  optimize = ["dspy>=3.2.1,<4"]
37
37
 
38
38
  [dependency-groups]
@@ -57,6 +57,9 @@ only-include = ["src/runapprentice", "tests", "README.md", "LICENSE", "pyproject
57
57
  line-length = 100
58
58
  src = ["src", "tests"]
59
59
 
60
+ [tool.ruff.lint]
61
+ select = ["E4", "E7", "E9", "F"]
62
+
60
63
  [tool.mypy]
61
64
  strict = true
62
65
  mypy_path = "src"
@@ -0,0 +1,171 @@
1
+ """Process ownership and durable local training status. No MLX imports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as dt
6
+ import fcntl
7
+ import json
8
+ import os
9
+ import signal
10
+ import tempfile
11
+ import threading
12
+ import time
13
+ import uuid
14
+ from contextlib import contextmanager
15
+ from pathlib import Path
16
+ from typing import Any, Iterator
17
+
18
+ from runapprentice._train_output import status_command, watch_command
19
+ from runapprentice.client import ApprenticeError
20
+
21
+ _owner: dict[str, Any] | None = None
22
+ _thread: int | None = None
23
+ _fd: int | None = None
24
+ json_progress = False
25
+
26
+
27
+ def lock_fd() -> int | None:
28
+ return _fd
29
+
30
+
31
+ # ponytail: the lock lives in this user's home, so it serializes runs per macOS account. Two
32
+ # accounts training at once can still compete for the GPU; a shared lock would need a
33
+ # world-writable path and its permission problems. Docs say "per user account".
34
+ def _root() -> Path:
35
+ return Path.home() / ".apprentice" / "local-runs"
36
+
37
+
38
+ def _read(path: Path) -> dict[str, Any]:
39
+ try:
40
+ value = json.loads(path.read_text(encoding="utf-8"))
41
+ return value if isinstance(value, dict) else {}
42
+ except FileNotFoundError:
43
+ return {}
44
+
45
+
46
+ def _write(path: Path, value: dict[str, Any]) -> None:
47
+ name: str | None = None
48
+ try:
49
+ with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=path.parent,
50
+ delete=False) as out:
51
+ name = out.name
52
+ json.dump(value, out)
53
+ out.write("\n")
54
+ os.replace(name, path)
55
+ finally:
56
+ if name is not None:
57
+ Path(name).unlink(missing_ok=True)
58
+
59
+
60
+ def update(**values: Any) -> None:
61
+ if _owner is not None:
62
+ if values.get("state") in {"failed", "completed"}:
63
+ values.setdefault("finished", dt.datetime.now(dt.UTC).isoformat())
64
+ _owner.update(values)
65
+ _write(Path(_owner["status_path"]), _owner)
66
+
67
+
68
+ def snapshot() -> dict[str, Any]:
69
+ return dict(_owner) if _owner is not None else {}
70
+
71
+
72
+ def status(task: str) -> dict[str, Any]:
73
+ from runapprentice.local import _local_run_dir
74
+
75
+ path = _local_run_dir(task) / "status.json"
76
+ value = _read(path)
77
+ if not value:
78
+ return {"task": task, "state": "none"}
79
+ if value.get("state") == "running":
80
+ try:
81
+ with (_root() / ".lock").open("r+") as lock:
82
+ try:
83
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
84
+ value["state"] = "stopped"
85
+ except BlockingIOError:
86
+ holder = _read(_root() / "holder.json")
87
+ if holder.get("run_id") != value.get("run_id"):
88
+ value["state"] = "stopped"
89
+ except FileNotFoundError:
90
+ value["state"] = "stopped"
91
+ return value
92
+
93
+
94
+ def _interrupt(signum: int, frame: Any) -> None:
95
+ reason = ("the terminal closed" if signum == signal.SIGHUP
96
+ else "the process was told to quit, often by a command timeout")
97
+ raise KeyboardInterrupt(f"Stopped because {reason}.")
98
+
99
+
100
+ def _acquire(lock: Any) -> None:
101
+ # status() probes this lock for an instant, so one refusal may only mean a poll was
102
+ # looking. A real run holds it for much longer than these retries.
103
+ for attempt in range(3):
104
+ try:
105
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
106
+ return
107
+ except BlockingIOError:
108
+ if attempt == 2:
109
+ raise
110
+ time.sleep(0.05)
111
+
112
+
113
+ @contextmanager
114
+ def training(task: str) -> Iterator[None]:
115
+ global _owner, _thread, _fd
116
+ from runapprentice.local import _local_run_dir
117
+
118
+ folder = _local_run_dir(task)
119
+ if _owner is not None and _thread == threading.get_ident():
120
+ if _owner["task"] != task:
121
+ raise ApprenticeError("Another local task is already running.", code="busy")
122
+ yield
123
+ return
124
+ _root().mkdir(parents=True, exist_ok=True)
125
+ with (_root() / ".lock").open("a+") as lock:
126
+ try:
127
+ _acquire(lock)
128
+ except BlockingIOError as exc:
129
+ holder = _read(_root() / "holder.json")
130
+ command = status_command(str(holder.get("task", task)))
131
+ raise ApprenticeError(
132
+ f"Local training is busy: task {holder.get('task', 'unknown')}, "
133
+ f"pid {holder.get('pid', 'unknown')}. Check: {command}", code="busy",
134
+ remediation=command,
135
+ ) from exc
136
+ previous = {}
137
+ try:
138
+ folder.mkdir(parents=True, exist_ok=True)
139
+ _fd = lock.fileno()
140
+ _thread = threading.get_ident()
141
+ _owner = {"task": task, "pid": os.getpid(), "run_id": uuid.uuid4().hex,
142
+ "state": "running", "phase": "preflight", "step": None,
143
+ "total": None, "percent": None, "time_left": None,
144
+ "train_loss": None, "val_loss": None, "val_step": None,
145
+ "train_points": [], "val_points": [], "memory_points": [],
146
+ "speed_points": [], "tokens_per_second": None,
147
+ "peak_memory": None,
148
+ "effort": None, "started": dt.datetime.now(dt.UTC).isoformat(),
149
+ "finished": None, "status_path": str(folder / "status.json")}
150
+ _owner.update(log_path=str(folder / "train.log"),
151
+ status_command=status_command(task), watch_command=watch_command(task))
152
+ _write(_root() / "holder.json", _owner)
153
+ update()
154
+ with (folder / "train.log").open("w", encoding="utf-8") as log:
155
+ log.write(f"Training {task!r}, pid {os.getpid()}, started {_owner['started']}\n")
156
+ if threading.current_thread() is threading.main_thread():
157
+ for sig in (signal.SIGTERM, signal.SIGHUP):
158
+ previous[sig] = signal.signal(sig, _interrupt)
159
+ yield
160
+ except BaseException as exc:
161
+ try:
162
+ update(state="failed", message=str(exc) or "Local training was interrupted.",
163
+ finished=dt.datetime.now(dt.UTC).isoformat())
164
+ except OSError:
165
+ # ponytail: preserve the original failure if the disk cannot save status.
166
+ pass
167
+ raise
168
+ finally:
169
+ for sig, handler in previous.items():
170
+ signal.signal(sig, handler)
171
+ _owner, _thread, _fd = None, None, None
@@ -0,0 +1,70 @@
1
+ """Generate held-out answers with one model load: `python -m runapprentice._mlx_eval`.
2
+
3
+ Reads one JSON string (the prompt) per line from --prompts and writes one JSON
4
+ string (the answer) per line to --out. `mlx_lm generate` per row reloaded the
5
+ model every time. The memory guard from _mlx_lora covers this step too.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import os
13
+ from pathlib import Path
14
+ from typing import Any, cast
15
+
16
+ import mlx.core as mx
17
+
18
+ from runapprentice._mlx_lora import memory_ceiling_gb
19
+ from runapprentice.local import MEMORY_GUARD_EXIT
20
+
21
+ # Generation stops at the end of the answer; this only caps a model that never stops.
22
+ # mlx_lm generate's default of 100 tokens cut off real JSON answers mid-object.
23
+ MAX_TOKENS = 1024
24
+
25
+
26
+ def main() -> None:
27
+ from mlx_lm import generate, load
28
+
29
+ parser = argparse.ArgumentParser()
30
+ parser.add_argument("--model", required=True)
31
+ parser.add_argument("--adapter-path", required=True)
32
+ parser.add_argument("--prompts", type=Path, required=True)
33
+ parser.add_argument("--out", type=Path, required=True)
34
+ args = parser.parse_args()
35
+
36
+ ceiling = memory_ceiling_gb()
37
+ loaded = load(args.model, adapter_path=args.adapter_path)
38
+ # Any: mlx-lm's tokenizer wrapper forwards apply_chat_template untyped.
39
+ model, tokenizer = loaded[0], cast(Any, loaded[1])
40
+ with args.prompts.open(encoding="utf-8") as src, args.out.open("w", encoding="utf-8") as out:
41
+ for line in src:
42
+ prompt = tokenizer.apply_chat_template(
43
+ [{"role": "user", "content": json.loads(line)}],
44
+ tokenize=False,
45
+ add_generation_prompt=True,
46
+ # Qwen3.5 reasons in free text by default; the scorer needs the answer.
47
+ enable_thinking=False,
48
+ )
49
+ answer = generate(
50
+ model,
51
+ tokenizer,
52
+ tokenizer.encode(prompt, add_special_tokens=False),
53
+ max_tokens=MAX_TOKENS,
54
+ )
55
+ out.write(json.dumps(answer, ensure_ascii=False) + "\n")
56
+ out.flush()
57
+ if os.environ.get("APPRENTICE_MLX_PROGRESS"):
58
+ print('APPRENTICE_PROGRESS {"row": 1}', flush=True)
59
+ peak = mx.get_peak_memory() / 1e9
60
+ if peak > ceiling:
61
+ print(
62
+ f"[apprentice] memory guard: eval peaked at {peak:.1f} GB, over the "
63
+ f"{ceiling:.1f} GB safe limit for this Mac's GPU. Stopping.",
64
+ flush=True,
65
+ )
66
+ raise SystemExit(MEMORY_GUARD_EXIT)
67
+
68
+
69
+ if __name__ == "__main__":
70
+ main()
@@ -0,0 +1,243 @@
1
+ """`python -m mlx_lm lora` with two changes for long-row Qwen3.5 training.
2
+
3
+ 1. Speed and memory. In training mode mlx-lm runs Qwen3.5's linear-attention
4
+ recurrence (`gated_delta_ops`) one token at a time, keeping a 2 MB float32
5
+ state per token per layer for backprop. An 800-token row took 26-125 s per
6
+ step and ~23 GB. `gated_delta_chunked` computes the same recurrence 64
7
+ tokens at a time with matmuls (the chunkwise "WY" form of the delta rule),
8
+ matching the reference to float32 rounding. Model quality is unchanged.
9
+ 2. A memory guard. MLX's own memory limit is a guideline, not a cap, so a run
10
+ that outgrows the GPU working set pushes the Mac into swap. The guard stops
11
+ training with exit code MEMORY_GUARD_EXIT as soon as a step's peak passes
12
+ the ceiling, before the next, possibly longer, step can go further.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import math
19
+ import os
20
+ import sys
21
+ from pathlib import Path
22
+ from typing import Any, Callable
23
+
24
+ import mlx.core as mx
25
+
26
+ from runapprentice.local import MEMORY_GUARD_EXIT, _check_token_lengths
27
+ from runapprentice.client import ApprenticeError
28
+ # Fraction of Metal's recommended working set a step may peak at. The rest is
29
+ # headroom for a longer row than the one that set the peak.
30
+ MEMORY_GUARD_FRACTION = 0.85
31
+ CHUNK = 64
32
+
33
+
34
+ def _unit_lower_inverse(n: mx.array) -> mx.array:
35
+ # Forward substitution for (I + N)^-1, N strictly lower triangular. Row i is
36
+ # e_i - N[i, :i] @ rows[:i]. A power series ((I - N)(I + N^2)...) is shorter
37
+ # but cancels catastrophically in float32 when keys are correlated, as in text.
38
+ c = n.shape[-1]
39
+ eye = mx.eye(c, dtype=n.dtype)
40
+ rows = [mx.broadcast_to(eye[0], n.shape[:-2] + (c,))]
41
+ for i in range(1, c):
42
+ prev = mx.stack(rows, axis=-2) # [..., i, C]
43
+ rows.append(eye[i] - (n[..., i : i + 1, :i] @ prev)[..., 0, :])
44
+ return mx.stack(rows, axis=-2)
45
+
46
+
47
+ def gated_delta_chunked(
48
+ q: mx.array,
49
+ k: mx.array,
50
+ v: mx.array,
51
+ g: mx.array,
52
+ beta: mx.array,
53
+ state: mx.array,
54
+ chunk: int = CHUNK,
55
+ ) -> tuple[mx.array, mx.array]:
56
+ """Same result as mlx_lm `gated_delta_ops` for a scalar gate and no mask.
57
+
58
+ q, k: [B, T, Hk, Dk]; v: [B, T, Hv, Dv]; g, beta: [B, T, Hv]; state: [B, Hv, Dv, Dk].
59
+ Per token the reference does S = g*S; d = beta*(v - S k); S += d k^T; y = S q.
60
+ """
61
+ B, T, Hk, _ = q.shape
62
+ Hv = v.shape[-2]
63
+ if (repeat := Hv // Hk) > 1:
64
+ q = mx.repeat(q, repeat, -2)
65
+ k = mx.repeat(k, repeat, -2)
66
+ in_dtype = q.dtype
67
+ q, k, v = (x.astype(mx.float32).transpose(0, 2, 1, 3) for x in (q, k, v))
68
+ # Clamp before log: log's backward divides by its input, even with zero cotangent.
69
+ log_g = mx.log(mx.maximum(g.astype(mx.float32), 1e-30)).transpose(0, 2, 1)
70
+ beta = beta.astype(mx.float32).transpose(0, 2, 1)
71
+ # Padding tokens have k = 0, beta = 0 and g = 1, so they leave the state untouched.
72
+ pad = (-T) % chunk
73
+ if pad:
74
+ q, k, v = (mx.pad(x, [(0, 0), (0, 0), (0, pad), (0, 0)]) for x in (q, k, v))
75
+ log_g = mx.pad(log_g, [(0, 0), (0, 0), (0, pad)])
76
+ beta = mx.pad(beta, [(0, 0), (0, 0), (0, pad)])
77
+ n = (T + pad) // chunk
78
+ q, k, v = (x.reshape(B, Hv, n, chunk, x.shape[-1]) for x in (q, k, v))
79
+ log_g = log_g.reshape(B, Hv, n, chunk)
80
+ beta = beta.reshape(B, Hv, n, chunk)
81
+
82
+ cum = mx.cumsum(log_g, axis=-1) # log of the decay from chunk start to token i
83
+ idx = mx.arange(chunk)
84
+ strict = idx[:, None] > idx[None, :]
85
+ inclusive = idx[:, None] >= idx[None, :]
86
+ diff = cum[..., :, None] - cum[..., None, :]
87
+ decay_strict = mx.where(strict, mx.exp(mx.where(strict, diff, 0.0)), 0.0)
88
+ decay_inclusive = mx.where(inclusive, mx.exp(mx.where(inclusive, diff, 0.0)), 0.0)
89
+ # Within a chunk the deltas solve (I + A) D = beta * (V - Gamma K S0^T).
90
+ a = beta[..., None] * decay_strict * (k @ k.swapaxes(-1, -2))
91
+ a_inv = _unit_lower_inverse(a)
92
+ gamma = mx.exp(cum)[..., None]
93
+ w = a_inv @ (beta[..., None] * gamma * k)
94
+ u = a_inv @ (beta[..., None] * v)
95
+ attn = decay_inclusive * (q @ k.swapaxes(-1, -2))
96
+ to_end = mx.exp(cum[..., -1:] - cum)[..., None]
97
+
98
+ ys = []
99
+ for c in range(n):
100
+ state_t = state.swapaxes(-1, -2)
101
+ delta = u[:, :, c] - w[:, :, c] @ state_t
102
+ ys.append(gamma[:, :, c] * (q[:, :, c] @ state_t) + attn[:, :, c] @ delta)
103
+ state = mx.exp(cum[:, :, c, -1])[..., None, None] * state + (
104
+ delta.swapaxes(-1, -2) @ (to_end[:, :, c] * k[:, :, c])
105
+ )
106
+ y = mx.concatenate(ys, axis=2)[:, :, :T].transpose(0, 2, 1, 3)
107
+ return y.astype(in_dtype), state
108
+
109
+
110
+ def _install_chunked_recurrence() -> None:
111
+ from mlx_lm.models import gated_delta
112
+
113
+ reference: Callable[..., tuple[mx.array, mx.array]] = gated_delta.gated_delta_ops
114
+
115
+ def gated_delta_ops(
116
+ q: mx.array,
117
+ k: mx.array,
118
+ v: mx.array,
119
+ g: mx.array,
120
+ beta: mx.array,
121
+ state: mx.array | None = None,
122
+ mask: mx.array | None = None,
123
+ ) -> tuple[mx.array, mx.array]:
124
+ # Training passes no mask and a per-head scalar gate; anything else keeps the reference.
125
+ if mask is not None or g.ndim != 3:
126
+ return reference(q, k, v, g, beta, state, mask)
127
+ if state is None:
128
+ B, _, _, Dk = q.shape
129
+ Hv, Dv = v.shape[-2:]
130
+ state = mx.zeros((B, Hv, Dv, Dk), dtype=mx.float32)
131
+ return gated_delta_chunked(q, k, v, g, beta, state)
132
+
133
+ # gated_delta_update looks this name up in its module at call time.
134
+ gated_delta.gated_delta_ops = gated_delta_ops
135
+
136
+
137
+ def memory_ceiling_gb() -> float:
138
+ return float(mx.device_info()["max_recommended_working_set_size"]) * MEMORY_GUARD_FRACTION / 1e9
139
+
140
+
141
+ def _install_memory_guard() -> None:
142
+ from mlx_lm import lora
143
+ from mlx_lm.tuner.callbacks import get_reporting_callbacks as original
144
+
145
+ ceiling = memory_ceiling_gb()
146
+ # local.py reads the last report to estimate the full run's time.
147
+ stats_path = os.environ.get("APPRENTICE_MLX_STATS")
148
+
149
+ # Duck-typed: the trainer only calls these two methods.
150
+ class MemoryGuard:
151
+ def __init__(self, inner: Any) -> None:
152
+ self.inner = inner
153
+
154
+ def on_train_loss_report(self, train_info: dict[str, Any]) -> None:
155
+ if not math.isfinite(float(train_info["train_loss"])):
156
+ print("Training stopped because the training loss is NaN or infinite.", flush=True)
157
+ raise SystemExit(87)
158
+ if os.environ.get("APPRENTICE_MLX_PROGRESS"):
159
+ progress = {
160
+ key: value for key, value in train_info.items()
161
+ if key == "iteration" or (
162
+ key in {"train_loss", "peak_memory", "tokens_per_second"}
163
+ and math.isfinite(float(value))
164
+ )
165
+ }
166
+ print(
167
+ "APPRENTICE_PROGRESS "
168
+ + json.dumps(progress, default=float, allow_nan=False),
169
+ flush=True,
170
+ )
171
+ if self.inner is not None:
172
+ self.inner.on_train_loss_report(train_info)
173
+ if stats_path:
174
+ Path(stats_path).write_text(json.dumps(train_info, default=float), encoding="utf-8")
175
+ # local.py requests a report every step, before another batch runs.
176
+ peak = float(train_info["peak_memory"]) # GB, 1e9 bytes, as mlx-lm reports it
177
+ if peak > ceiling:
178
+ print(
179
+ f"[apprentice] memory guard: training peaked at {peak:.1f} GB, over the "
180
+ f"{ceiling:.1f} GB safe limit for this Mac's GPU. Stopping.",
181
+ flush=True,
182
+ )
183
+ raise SystemExit(MEMORY_GUARD_EXIT)
184
+
185
+ def on_val_loss_report(self, val_info: dict[str, Any]) -> None:
186
+ if (
187
+ os.environ.get("APPRENTICE_MLX_PROGRESS")
188
+ and math.isfinite(float(val_info["val_loss"]))
189
+ ):
190
+ print(
191
+ "APPRENTICE_PROGRESS "
192
+ + json.dumps(
193
+ {key: val_info[key] for key in ("iteration", "val_loss")},
194
+ default=float,
195
+ allow_nan=False,
196
+ ),
197
+ flush=True,
198
+ )
199
+ if self.inner is not None:
200
+ self.inner.on_val_loss_report(val_info)
201
+
202
+ def get_reporting_callbacks(*args: Any, **kwargs: Any) -> MemoryGuard:
203
+ return MemoryGuard(original(*args, **kwargs))
204
+
205
+ # lora.run looks this name up in its own module at call time.
206
+ setattr(lora, "get_reporting_callbacks", get_reporting_callbacks)
207
+
208
+
209
+ def main() -> None:
210
+ _install_chunked_recurrence()
211
+ _install_memory_guard()
212
+ from mlx_lm import lora
213
+
214
+ from mlx_lm.tuner.datasets import create_dataset
215
+
216
+ original_load = getattr(lora, "load_dataset")
217
+
218
+ def load_largest(args: Any, tokenizer: Any) -> Any:
219
+ train, valid, test = original_load(args, tokenizer)
220
+ for name, dataset in (("training", train), ("held-out", valid)):
221
+ try:
222
+ _check_token_lengths(dataset, args.max_seq_length, name)
223
+ except ApprenticeError as exc:
224
+ print(str(exc), flush=True)
225
+ raise SystemExit(86)
226
+ if not os.environ.get("APPRENTICE_MLX_STATS"):
227
+ return train, valid, test
228
+ # Reuse the training tokenizer and chat template, without loading another model.
229
+ indices = sorted(
230
+ range(len(train)),
231
+ key=lambda i: min(len(train.process(train[i])[0]), args.max_seq_length),
232
+ )[-args.batch_size :]
233
+ largest = create_dataset([train[i] for i in indices], tokenizer, args)
234
+ return largest, largest, test
235
+
236
+ setattr(lora, "load_dataset", load_largest)
237
+
238
+ sys.argv[0] = "mlx_lm.lora"
239
+ getattr(lora, "main")() # untyped in mlx-lm
240
+
241
+
242
+ if __name__ == "__main__":
243
+ main()