plm-core 0.1.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.
plm/__init__.py ADDED
File without changes
plm/__main__.py ADDED
@@ -0,0 +1,148 @@
1
+ """Command line: paste a task, get an answer plus a report of what ran.
2
+
3
+ .venv/bin/python -m plm "A 9 V supply feeds a divider of 10 kilohm over 4.7 kilohm. Output voltage in volts?"
4
+ .venv/bin/python -m plm 'Write a function `is_leap(year: int) -> bool` ...' # software pack
5
+ .venv/bin/python -m plm < task.txt # multi-line tasks, e.g. with assert/>>> example lines
6
+ .venv/bin/python -m plm # interactive: one task per line
7
+ .venv/bin/python -m plm --pack software --level intensive --show-work "..."
8
+ .venv/bin/python -m plm --thorough "$(cat task.txt)" # software: add model-written tests (~4x compute)
9
+ .venv/bin/python -m plm --sql-db shop.sqlite "Which customers ordered twice?" # sql pack, checked on the data
10
+ .venv/bin/python -m plm serve # OpenAI-compatible server, see plm/server.py
11
+ .venv/bin/python -m plm packs list # installed packs; also install / remove / doctor, see plm/manage.py
12
+
13
+ The pack is picked from the task text unless --pack is given. Every run is
14
+ appended to runs.jsonl in plm.paths.log_dir() (./logs in a checkout; override with PLM_LOG).
15
+ """
16
+
17
+ import argparse
18
+ import json
19
+ import os
20
+ import sys
21
+ import urllib.error
22
+ from pathlib import Path
23
+
24
+ from plm.paths import log_dir
25
+ from plm.report import answer_text, final_step, short
26
+
27
+ LOG = Path(os.environ.get("PLM_LOG") or log_dir() / "runs.jsonl")
28
+
29
+
30
+ def how(step):
31
+ if step.get("strategy"):
32
+ return {"tool": "calculator", "units": "units calc", "bare": "direct"}[step["strategy"]]
33
+ return "code" if "code" in step else "chat"
34
+
35
+
36
+ def report(result, show_work):
37
+ print("\n" + answer_text(result))
38
+ print(f"\npack {result['pack']}")
39
+ print(f"level {result['level']} ({result['reason']})")
40
+ chosen = final_step(result)
41
+ for i, s in enumerate(result["steps"], 1):
42
+ status = "ok" if not s["issues"] else "flagged: " + "; ".join(s["issues"])
43
+ if s["issues"] and i < len(result["steps"]):
44
+ status += " -> escalated"
45
+ if s is chosen and i < len(result["steps"]):
46
+ status += f" [answer used: {result.get('chosen_note') or 'passed the examples and the most generated tests'}]"
47
+ if not result["checked"] and not s["issues"]:
48
+ status = "unchecked"
49
+ print(f"step {i} {short(s['model']):<22} {how(s):<10} {s['tokens']:>5} tok {s['seconds']:>6.1f} s {status}")
50
+ if show_work:
51
+ work = s.get("program") or s.get("code") or s["model_text"]
52
+ print(" " + work.strip().replace("\n", "\n "))
53
+ writer = result.get("test_writer")
54
+ if writer:
55
+ print(f"tests {short(writer['model']):<22} {'test writer':<10} {writer['tokens']:>5} tok {writer['seconds']:>6.1f} s"
56
+ f" {len(writer['tests'])} tests")
57
+ if show_work:
58
+ print(" " + "\n ".join(writer["tests"]))
59
+ calls = len(result["steps"]) + bool(writer)
60
+ print(f"total {result['tokens']} tokens, {result['seconds']:.1f} s, {calls} model call{'s' if calls > 1 else ''}")
61
+ if result["pack"] == "general":
62
+ print(f"note {result['unchecked_reason']}")
63
+
64
+
65
+ def run_one(task, args):
66
+ from plm.packs import PACKS, detect
67
+
68
+ if not task.strip():
69
+ # An empty prompt still gets a confident answer from the models, so never send one.
70
+ sys.exit("plm: empty task (if piping a file, check that stdin actually reaches plm)")
71
+ pack = args.pack if args.pack != "auto" else "sql" if args.sql_db else detect(task)
72
+ extra = {}
73
+ if args.sql_db:
74
+ if pack == "sql":
75
+ extra["db"] = args.sql_db
76
+ else:
77
+ print(f"plm: --sql-db only applies to the sql pack; ignoring it for {pack}", file=sys.stderr)
78
+ if args.thorough:
79
+ if pack == "software":
80
+ extra["thorough"] = True
81
+ else:
82
+ print("plm: --thorough only applies to the software pack; ignoring it", file=sys.stderr)
83
+ try:
84
+ result = PACKS[pack].solve(task, level=args.level, timeout=args.timeout, **extra)
85
+ except urllib.error.HTTPError as e:
86
+ detail = e.read().decode(errors="replace")[:200]
87
+ sys.exit(f"Ollama error {e.code}: {detail}\nModels in use: {PACKS[pack].models} "
88
+ "(set PLM_SMALL_MODEL / PLM_LARGE_MODEL / PLM_CODER_MODEL)")
89
+ except urllib.error.URLError as e:
90
+ sys.exit(f"Can't reach Ollama ({e.reason}). Is it running? Address comes from OLLAMA_HOST.")
91
+ except ValueError as e: # a --sql-db / PLM_SQL_DB file that can't be opened
92
+ sys.exit(f"plm: {e}")
93
+ LOG.parent.mkdir(parents=True, exist_ok=True)
94
+ with LOG.open("a") as f:
95
+ f.write(json.dumps(result) + "\n")
96
+ if args.json:
97
+ print(json.dumps(result, indent=2))
98
+ else:
99
+ report(result, args.show_work)
100
+
101
+
102
+ def main():
103
+ if sys.argv[1:2] == ["packs"]:
104
+ # before importing plm.packs, which refuses to start with a broken pack installed
105
+ from plm.manage import main as packs
106
+ return packs(sys.argv[2:])
107
+ try: # a broken pack is a one-line message (it names `plm packs remove`), not a traceback
108
+ if sys.argv[1:2] == ["serve"]:
109
+ from plm.server import main as serve
110
+ else:
111
+ from plm.packs import PACKS
112
+ except RuntimeError as e:
113
+ sys.exit(f"plm: {e}")
114
+ if sys.argv[1:2] == ["serve"]:
115
+ return serve(sys.argv[2:])
116
+
117
+ ap = argparse.ArgumentParser(prog="plm", description="Adaptive multi-model orchestrator "
118
+ "('plm serve' for the server, 'plm packs' to manage packs)")
119
+ ap.add_argument("task", nargs="*", help="the task; omit for interactive mode")
120
+ ap.add_argument("--pack", choices=["auto", *PACKS], default="auto", help="domain pack (default: detect from the task)")
121
+ ap.add_argument("--level", choices=["calm", "alert", "intensive"], help="force an adrenaline level instead of classifying")
122
+ ap.add_argument("--thorough", action="store_true",
123
+ help="software: also check code against tests a second model writes (~4x compute)")
124
+ ap.add_argument("--sql-db", metavar="FILE", help="sql: a SQLite database to read the schema from and check the query "
125
+ "on, read-only (implies --pack sql; the server reads PLM_SQL_DB)")
126
+ ap.add_argument("--show-work", action="store_true", help="print each model's program or reasoning")
127
+ ap.add_argument("--json", action="store_true", help="print the full result as JSON")
128
+ ap.add_argument("--timeout", type=int, default=600, help="seconds per model call")
129
+ args = ap.parse_args()
130
+
131
+ if args.task:
132
+ run_one(" ".join(args.task), args)
133
+ elif not sys.stdin.isatty():
134
+ run_one(sys.stdin.read().strip(), args)
135
+ else:
136
+ print("plm: one task per line, empty line to quit")
137
+ while True:
138
+ try:
139
+ task = input("\ntask> ").strip()
140
+ except EOFError:
141
+ break
142
+ if not task:
143
+ break
144
+ run_one(task, args)
145
+
146
+
147
+ if __name__ == "__main__":
148
+ main()
@@ -0,0 +1 @@
1
+ """General pack: anything no other pack claims, answered by the large model, unchecked."""
@@ -0,0 +1,64 @@
1
+ """General pack: anything no other pack claims goes to the large model, unchecked.
2
+
3
+ There is no check for open-ended answers, so nothing is escalated and nothing is
4
+ routed down to a smaller model: without a check, a cheaper model's mistakes
5
+ would go unnoticed. The whole conversation is passed through (packs only see
6
+ the last user message), with the caller's sampling options.
7
+
8
+ Model name can be overridden with PLM_LARGE_MODEL.
9
+ """
10
+
11
+ import os
12
+ import time
13
+
14
+ from plm.ollama import chat, chat_stream
15
+
16
+ MODELS = {"large": os.environ.get("PLM_LARGE_MODEL", "qwen3:8b")}
17
+ NUM_PREDICT = 2048 # default reply budget when the caller sets none
18
+ LEVELS = {lvl: {"num_predict": NUM_PREDICT, "chain": ["large"]} for lvl in ("calm", "alert", "intensive")}
19
+ UNCHECKED = "no pack covers this task, so the answer wasn't checked"
20
+
21
+
22
+ def _messages(task, messages):
23
+ return messages or [{"role": "user", "content": task}]
24
+
25
+
26
+ def _result(task, level, step, start):
27
+ return {
28
+ "pack": "general",
29
+ "task": task,
30
+ "level": level or "alert",
31
+ "reason": "no pack matched; sent to the large model",
32
+ "answer": {"text": step["model_text"]},
33
+ "checked": False,
34
+ "unchecked_reason": UNCHECKED,
35
+ "trusted": False,
36
+ "steps": [step],
37
+ "tokens": step["tokens"],
38
+ "seconds": time.time() - start,
39
+ }
40
+
41
+
42
+ def _step(model, r):
43
+ return {"model": model, "tokens": r["eval_tokens"], "prompt_tokens": r["prompt_tokens"],
44
+ "model_text": r["response"], "capped": r["done_reason"] == "length", "issues": [],
45
+ "seconds": r["wall_s"]}
46
+
47
+
48
+ def solve(task, level=None, timeout=600, messages=None, options=None, max_tokens=None):
49
+ """Answer with the large model. Returns the same result shape as every pack."""
50
+ start = time.time()
51
+ model = MODELS["large"]
52
+ r = chat(model, _messages(task, messages), max_tokens or NUM_PREDICT, timeout, options)
53
+ return _result(task, level, _step(model, r), start)
54
+
55
+
56
+ def solve_stream(task, level=None, timeout=600, messages=None, options=None, max_tokens=None):
57
+ """Like solve(), but yields ("delta", text) as the reply streams, then ("result", result)."""
58
+ start = time.time()
59
+ model = MODELS["large"]
60
+ for kind, value in chat_stream(model, _messages(task, messages), max_tokens or NUM_PREDICT, timeout, options):
61
+ if kind == "delta":
62
+ yield "delta", value
63
+ else:
64
+ yield "result", _result(task, level, _step(model, value), start)
@@ -0,0 +1,8 @@
1
+ """The general pack's manifest: what the router needs to know about it."""
2
+
3
+ from plm.general import controller
4
+ from plm.pack import Pack
5
+
6
+ # Built into the core and always present: claims nothing, answers whatever no other pack claims.
7
+ PACK = Pack(name="general", version="0.1.0", claims=lambda text: 0, priority=0, solve=controller.solve,
8
+ levels=controller.LEVELS, models=controller.MODELS, solve_stream=controller.solve_stream)
plm/latex.py ADDED
@@ -0,0 +1,50 @@
1
+ """LaTeX quantities to plain text, so routing, levels and unit checks read them as they read plain text.
2
+
3
+ Textbooks and benchmarks (SciBench) write "$8.4 \\mathrm{~km}$" and "$\\mathrm{~m} / \\mathrm{s}^2$";
4
+ the claim rules and unit words expect "8.4 km" and "m/s^2". Only what the router and checks read
5
+ is converted: the model still gets the original text. Text with no "$" and no backslash is
6
+ returned unchanged, so no decision on plain text can move (LATEX_RULE in evals/test_detect.py).
7
+ """
8
+
9
+ import re
10
+
11
+ SYMBOLS = {"\\mu": "µ", "\\Omega": "Ω", "\\omega": "omega", "\\theta": "theta", "\\rho": "rho", "\\times": "×",
12
+ "\\cdot": "*", "\\circ": "°", "\\%": "%", "\\Delta": "Delta", "\\pi": "pi", "\\lambda": "lambda"}
13
+ WRAPPERS = re.compile(r"\\(?:mathrm|text|mathbf|textrm|operatorname|mbox)\s*\{([^{}]*)\}")
14
+
15
+
16
+ def plain(text):
17
+ """The text with LaTeX markup turned into the plain spelling the router and checks read."""
18
+ if "$" not in text and "\\" not in text:
19
+ return text
20
+ t = text
21
+ for _ in range(3): # nested wrappers: \mathrm{\mu C}
22
+ t = WRAPPERS.sub(r"\1", t)
23
+ t = re.sub(r"\^\s*\{?\s*\\circ\s*\}?", "°", t) # 20^{\circ} -> 20°
24
+ for k, v in SYMBOLS.items():
25
+ t = t.replace(k, v)
26
+ t = re.sub(r"\^\{([^{}]*)\}", r"^\1", t) # s^{-1} -> s^-1
27
+ t = re.sub(r"_\{([^{}]*)\}", r"_\1", t)
28
+ t = t.replace("$", " ").replace("~", " ").replace("{", "").replace("}", "")
29
+ t = re.sub(r"\\[,;:! ]", " ", t) # LaTeX spacing commands
30
+ t = re.sub(r"\\([A-Za-z]+)", r"\1", t) # any other command: its name
31
+ t = re.sub(r"\s*/\s*", "/", t) # m / s -> m/s
32
+ t = re.sub(r"(\d)\s+°", r"\1°", t) # 20 ° C -> 20°C
33
+ t = re.sub(r"°\s+([CFK])\b", r"°\1", t)
34
+ t = re.sub(r"(µ)\s+([A-Za-z])", r"\1\2", t) # µ C -> µC
35
+ return re.sub(r"[ \t]+", " ", t).strip()
36
+
37
+
38
+ if __name__ == "__main__": # self-test
39
+ cases = {
40
+ "$8.4 \\mathrm{~km}$ at $70 \\mathrm{~km} / \\mathrm{h}$": "8.4 km at 70 km/h",
41
+ "$g=9.8 \\mathrm{~m} / \\mathrm{s}^2$": "g=9.8 m/s^2",
42
+ "$20^{\\circ} \\mathrm{C}$": "20°C",
43
+ "$q_1=3.0 \\mu \\mathrm{C}$": "q_1=3.0 µC",
44
+ "$R = 220 \\Omega$": "R = 220 Ω",
45
+ "plain 3 m/s text": "plain 3 m/s text",
46
+ }
47
+ bad = {k: (plain(k), v) for k, v in cases.items() if plain(k) != v}
48
+ for k, (got, want) in bad.items():
49
+ print(f"FAIL {k!r}: {got!r}, want {want!r}")
50
+ print(f"latex self-test: {len(cases) - len(bad)}/{len(cases)} passed")
plm/manage.py ADDED
@@ -0,0 +1,220 @@
1
+ """`plm packs`: list, install, remove and check domain packs.
2
+
3
+ plm packs list [--json] installed packs, versions, and whether their models are pulled
4
+ plm packs install <name> [--source S]
5
+ plm packs remove <name>
6
+ plm packs doctor every pack loads, and every model it needs is pulled and loads
7
+
8
+ Packs aren't on PyPI yet. `install` takes plm-<name> from --source (a directory
9
+ holding plm-<name>/, the pack's own directory, or any pip requirement such as a
10
+ git URL), else from PLM_PACKS_SOURCE, else from the packages/ directory of the
11
+ checkout this core runs from. A directory is installed editable, so the code
12
+ stays where it is. Installing uses uv when it's on PATH, else pip, always into
13
+ the environment of the Python running plm. A pack is code that runs on this
14
+ machine: install only packs you trust.
15
+
16
+ These commands load packs one by one, so a pack that fails to load (which stops
17
+ the router) can still be listed and removed.
18
+ """
19
+
20
+ import argparse
21
+ import json
22
+ import os
23
+ import shlex
24
+ import shutil
25
+ import subprocess
26
+ import sys
27
+ import urllib.error
28
+ from pathlib import Path
29
+
30
+ import plm
31
+ from plm import ollama
32
+ from plm.general.manifest import PACK as GENERAL
33
+ from plm.pack import Pack, discover
34
+
35
+
36
+ def installed():
37
+ """One row per pack: name, version, distribution, models, and the error if it failed to load."""
38
+ rows = [{"name": GENERAL.name, "version": GENERAL.version, "dist": "plm-core (built in)",
39
+ "models": dict(GENERAL.models), "error": None}]
40
+ for ep, pack in discover():
41
+ dist = ep.dist.name if ep.dist else "?"
42
+ if isinstance(pack, Pack):
43
+ rows.append({"name": pack.name, "version": pack.version, "dist": dist, "models": dict(pack.models),
44
+ "error": None})
45
+ else:
46
+ rows.append({"name": ep.name, "version": "?", "dist": dist, "models": {}, "error": repr(pack)})
47
+ return rows
48
+
49
+
50
+ def pulled_models():
51
+ """Full names of the models Ollama has, or None if Ollama can't be reached."""
52
+ try:
53
+ return ollama.pulled()
54
+ except (urllib.error.URLError, OSError):
55
+ return None
56
+
57
+
58
+ def _is_pulled(model, pulled):
59
+ return None if pulled is None else ollama.full_name(model) in pulled
60
+
61
+
62
+ def _installer(verb):
63
+ uv = shutil.which("uv")
64
+ if uv:
65
+ return [uv, "pip", verb, "--python", sys.executable]
66
+ if subprocess.run([sys.executable, "-m", "pip", "--version"], capture_output=True).returncode != 0:
67
+ sys.exit("plm packs: neither uv nor pip is available to install with")
68
+ return [sys.executable, "-m", "pip", verb] + (["-y"] if verb == "uninstall" else [])
69
+
70
+
71
+ REPO = "https://github.com/ro-rbl/plm-orchestrator"
72
+
73
+
74
+ def _run(cmd):
75
+ print("$", shlex.join(cmd), flush=True)
76
+ return subprocess.run(cmd).returncode
77
+
78
+
79
+ def _source(name, source):
80
+ """pip arguments that install plm-<name> from --source, PLM_PACKS_SOURCE, this checkout, or the GitHub repo."""
81
+ dist = f"plm-{name}"
82
+ source = source or os.environ.get("PLM_PACKS_SOURCE")
83
+ if source is None:
84
+ checkout = Path(plm.__file__).resolve().parents[3] # packages/plm-core/src/plm -> packages
85
+ if (checkout / dist / "pyproject.toml").exists():
86
+ return ["-e", str(checkout / dist)]
87
+ if not (checkout / "plm-core" / "pyproject.toml").exists():
88
+ # an installed plm (uv tool install, pip) has no checkout around it: the project's repo
89
+ return [f"{dist} @ git+{REPO}#subdirectory=packages/{dist}"]
90
+ sys.exit(f"plm packs: no source for {dist}, and packs aren't on PyPI yet. Pass --source: a directory "
91
+ f"holding {dist}/, or a pip requirement such as "
92
+ f"'{dist} @ git+https://<repo>#subdirectory=packages/{dist}'")
93
+ path = Path(source).expanduser()
94
+ if (path / dist / "pyproject.toml").exists():
95
+ return ["-e", str(path / dist)]
96
+ if (path / "pyproject.toml").exists():
97
+ return ["-e", str(path)]
98
+ if path.exists():
99
+ sys.exit(f"plm packs: {path} has neither {dist}/pyproject.toml nor pyproject.toml")
100
+ return [source]
101
+
102
+
103
+ def _pull(model):
104
+ last = None
105
+ try:
106
+ for msg in ollama.pull(model):
107
+ if msg.get("status") != last: # progress repeats a status with new byte counts
108
+ last = msg.get("status")
109
+ print(f" {model}: {last}", flush=True)
110
+ except urllib.error.HTTPError as e:
111
+ raise RuntimeError(json.loads(e.read() or b"{}").get("error", str(e))) from e
112
+
113
+
114
+ def cmd_list(args):
115
+ rows, pulled = installed(), pulled_models()
116
+ if args.json:
117
+ for r in rows:
118
+ r["models"] = {tier: {"model": m, "pulled": _is_pulled(m, pulled)} for tier, m in r["models"].items()}
119
+ print(json.dumps(rows, indent=2))
120
+ return 0
121
+ for r in rows:
122
+ print(f"{r['name']:12} {r['version']:8} {r['dist']}")
123
+ if r["error"]:
124
+ print(f" FAILED TO LOAD: {r['error']}")
125
+ for tier, m in r["models"].items():
126
+ state = {True: "pulled", False: "MISSING", None: "unknown (Ollama unreachable)"}[_is_pulled(m, pulled)]
127
+ print(f" {tier:6} {m} {state}")
128
+ return 0
129
+
130
+
131
+ def cmd_install(args):
132
+ if args.name == GENERAL.name:
133
+ sys.exit("plm packs: the general pack is built into the core")
134
+ if _run(_installer("install") + _source(args.name, args.source)) != 0:
135
+ sys.exit("plm packs: install failed")
136
+ # A fresh process sees the newly installed pack (and its env-overridden models).
137
+ out = subprocess.run([sys.executable, "-m", "plm", "packs", "list", "--json"], capture_output=True, text=True)
138
+ if out.returncode != 0:
139
+ sys.exit(f"plm packs: installed, but listing packs failed:\n{out.stderr}")
140
+ row = next((r for r in json.loads(out.stdout) if r["name"] == args.name), None)
141
+ if row is None:
142
+ sys.exit(f"plm packs: installed plm-{args.name}, but it registers no pack named {args.name!r}")
143
+ if row["error"]:
144
+ sys.exit(f"plm packs: installed, but the pack fails to load: {row['error']}; "
145
+ f"remove it with `plm packs remove {args.name}`")
146
+ missing = [v["model"] for v in row["models"].values() if v["pulled"] is not True]
147
+ if any(v["pulled"] is None for v in row["models"].values()):
148
+ sys.exit(f"plm packs: installed {args.name}, but Ollama can't be reached to check its models; "
149
+ "start Ollama, then run `plm packs doctor`")
150
+ for model in dict.fromkeys(missing):
151
+ print(f"pulling {model}", flush=True)
152
+ try:
153
+ _pull(model)
154
+ except (RuntimeError, urllib.error.URLError, OSError) as e:
155
+ sys.exit(f"plm packs: installed {args.name}, but pulling {model} failed: {e}")
156
+ print(f"installed {args.name} {row['version']}; models: {', '.join(row['models'][t]['model'] for t in row['models'])}")
157
+ return 0
158
+
159
+
160
+ def cmd_remove(args):
161
+ if args.name == GENERAL.name:
162
+ sys.exit("plm packs: the general pack is built into the core and can't be removed")
163
+ row = next((r for r in installed() if r["name"] == args.name), None)
164
+ if row is None:
165
+ sys.exit(f"plm packs: no pack named {args.name!r} is installed")
166
+ if _run(_installer("uninstall") + [row["dist"]]) != 0:
167
+ sys.exit("plm packs: uninstall failed")
168
+ if row["models"]:
169
+ print(f"removed {args.name}; its models stay in Ollama, other packs may use them: "
170
+ f"{', '.join(dict.fromkeys(row['models'].values()))}")
171
+ return 0
172
+
173
+
174
+ def cmd_doctor(args):
175
+ rows, pulled = installed(), pulled_models()
176
+ problems = 0
177
+ names = [r["name"] for r in rows]
178
+ for name in {n for n in names if names.count(n) > 1}:
179
+ print(f"FAIL two installed packs are named {name!r}")
180
+ problems += 1
181
+ for r in rows:
182
+ if r["error"]:
183
+ print(f"FAIL {r['name']}: fails to load: {r['error']}")
184
+ problems += 1
185
+ continue
186
+ for model in dict.fromkeys(r["models"].values()):
187
+ state = _is_pulled(model, pulled)
188
+ if state is None:
189
+ print(f"FAIL {r['name']}: {model}: Ollama can't be reached")
190
+ elif not state:
191
+ print(f"FAIL {r['name']}: {model} is not pulled (`ollama pull {model}`)")
192
+ else:
193
+ try:
194
+ ollama.load(model)
195
+ print(f"ok {r['name']}: {model} loads")
196
+ continue
197
+ except (RuntimeError, urllib.error.URLError, OSError) as e:
198
+ print(f"FAIL {r['name']}: {model} doesn't load: {e}")
199
+ problems += 1
200
+ print("all packs healthy" if not problems else f"{problems} problem(s)")
201
+ return 1 if problems else 0
202
+
203
+
204
+ def main(argv):
205
+ ap = argparse.ArgumentParser(prog="plm packs", description="List, install, remove and check domain packs")
206
+ sub = ap.add_subparsers(dest="command", required=True)
207
+ p = sub.add_parser("list", help="installed packs and whether their models are pulled")
208
+ p.add_argument("--json", action="store_true")
209
+ p.set_defaults(run=cmd_list)
210
+ p = sub.add_parser("install", help="install a pack, then pull the models it needs")
211
+ p.add_argument("name")
212
+ p.add_argument("--source", help="a directory holding plm-<name>/, the pack's directory, or a pip requirement")
213
+ p.set_defaults(run=cmd_install)
214
+ p = sub.add_parser("remove", help="uninstall a pack (its models stay in Ollama)")
215
+ p.add_argument("name")
216
+ p.set_defaults(run=cmd_remove)
217
+ p = sub.add_parser("doctor", help="check every pack loads and every model it needs loads")
218
+ p.set_defaults(run=cmd_doctor)
219
+ args = ap.parse_args(argv)
220
+ sys.exit(args.run(args))
@@ -0,0 +1,2 @@
1
+ """Shared machinery for packs whose answers are a number with a unit: the answer
2
+ format and parser, the calculator tool, the unit/sanity checks and the controller."""
plm/numeric/answers.py ADDED
@@ -0,0 +1,78 @@
1
+ """The "ANSWER: <number> <unit>" contract: the prompt that asks for it and the parser that reads it.
2
+
3
+ Shared by the grader (evals/grade.py), the numeric controller (plm/numeric/controller.py) and
4
+ its answer checks (plm/numeric/checks.py), so all of them read model output the same way.
5
+ """
6
+
7
+ import re
8
+
9
+ import pint
10
+
11
+ # v1 showed "ANSWER: <number> <unit>" and small models copied "<unit>" literally.
12
+ PROMPT_VERSION = 2
13
+ ANSWER_FORMAT = (
14
+ "Solve the problem. Show brief working. Your last line must be the final answer "
15
+ "as a number followed by its unit, written like this example:\n"
16
+ "ANSWER: 3.3 kN\n"
17
+ "Use the unit the question asks for. For unitless answers, give only the number."
18
+ )
19
+
20
+ ureg = pint.UnitRegistry()
21
+
22
+ # Units pint can't (or shouldn't) convert; matched by alias instead.
23
+ LITERAL_UNITS = {
24
+ "": {"", "unitless", "dimensionless"},
25
+ "dB": {"db", "decibel", "decibels"},
26
+ "USD": {"usd", "$", "dollar", "dollars", "us dollars"},
27
+ "count": {"", "count", "cell", "cells"},
28
+ }
29
+
30
+ ANSWER_RE = re.compile(r"ANSWER\s*:\s*(.+)", re.IGNORECASE)
31
+ SUPERSCRIPT_DIGITS = "⁰¹²³⁴⁵⁶⁷⁸⁹"
32
+ SUPERSCRIPT_SIGNS = "⁻⁺"
33
+ SUPERSCRIPTS = str.maketrans(SUPERSCRIPT_DIGITS + SUPERSCRIPT_SIGNS, "0123456789-+")
34
+ NUMBER_RE = re.compile(r"[-+]?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?", re.IGNORECASE)
35
+
36
+
37
+ def normalize(text):
38
+ text = text.strip().replace("\u2212", "-").replace("\u2013", "-")
39
+ # LaTeX and Markdown from the models: \text{V}, \, , 10^{-19}, **3 A**
40
+ text = re.sub(r"\\(?:text|mathrm|mathit|rm)\s*\{([^}]*)\}", r"\1", text)
41
+ text = re.sub(r"\\[,;:! ]", " ", text)
42
+ text = text.replace("\\times", "×").replace("\\cdot", "*").replace("\\Omega", "ohm").replace("\\mu", "u")
43
+ text = re.sub(r"\^\{([^}]*)\}", r"^\1", text)
44
+ text = re.sub(f"10([{SUPERSCRIPT_SIGNS}]?[{SUPERSCRIPT_DIGITS}]+)",
45
+ lambda m: "10^" + m.group(1).translate(SUPERSCRIPTS), text) # 10⁻¹⁹
46
+ text = re.sub(r"\\[()\[\]]|[{}`]|\*\*", " ", text)
47
+ text = text.replace("$", " $ ")
48
+ # 1.6 × 10^-19 / 1.6x10^-19 / 1.6 * 10^-19 -> 1.6e-19
49
+ text = re.sub(r"\s*(?:×|x|\*)\s*10\s*\^\s*\(?\s*([-+]?\d+)\s*\)?", r"e\1", text)
50
+ text = re.sub(r"(?<=\d),(?=\d{3}\b)", "", text) # 14,722 -> 14722
51
+ return text
52
+
53
+
54
+ def normalize_unit(unit):
55
+ unit = unit.strip().rstrip(".;,").strip()
56
+ unit = unit.replace("Ω", "ohm").replace("Ω", "ohm")
57
+ unit = unit.replace("µ", "u").replace("μ", "u").replace("·", "*")
58
+ unit = re.sub(r"\bohms\b", "ohm", unit, flags=re.IGNORECASE)
59
+ unit = re.sub(r"\s*\(?\brms\b\)?", "", unit, flags=re.IGNORECASE).strip().strip("*").strip()
60
+ unit = re.sub(r"(?<=[A-Za-z])-(?=[A-Za-z])", "*", unit) # ohm-meter, kilowatt-hours
61
+ if re.fullmatch(r"deg[CcFf]", unit): # pint's Celsius and Fahrenheit, which the next rule would break
62
+ return "deg" + unit[3].upper()
63
+ if re.fullmatch(r"[A-Za-z]{4,}", unit): # spelled-out words only; keep mS vs MS
64
+ unit = unit.lower()
65
+ return unit
66
+
67
+
68
+ def parse_answer(response):
69
+ matches = ANSWER_RE.findall(response or "")
70
+ if not matches:
71
+ return None
72
+ # "**ANSWER:** ANSWER: 56.9 W" -> keep what follows the last label on the line
73
+ text = normalize(re.split(r"ANSWER\s*:", matches[-1], flags=re.IGNORECASE)[-1])
74
+ num = NUMBER_RE.search(text)
75
+ if not num:
76
+ return None
77
+ unit = text[: num.start()] + text[num.end():] # "$152.53" puts the unit first
78
+ return float(num.group()), normalize_unit(unit)