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 +0 -0
- plm/__main__.py +148 -0
- plm/general/__init__.py +1 -0
- plm/general/controller.py +64 -0
- plm/general/manifest.py +8 -0
- plm/latex.py +50 -0
- plm/manage.py +220 -0
- plm/numeric/__init__.py +2 -0
- plm/numeric/answers.py +78 -0
- plm/numeric/calc.py +200 -0
- plm/numeric/checks.py +123 -0
- plm/numeric/controller.py +86 -0
- plm/numeric/decompose.py +142 -0
- plm/numeric/levels.py +34 -0
- plm/ollama.py +191 -0
- plm/pack.py +55 -0
- plm/packs.py +36 -0
- plm/paths.py +17 -0
- plm/report.py +52 -0
- plm/server.py +360 -0
- plm_core-0.1.0.dist-info/METADATA +30 -0
- plm_core-0.1.0.dist-info/RECORD +25 -0
- plm_core-0.1.0.dist-info/WHEEL +4 -0
- plm_core-0.1.0.dist-info/entry_points.txt +2 -0
- plm_core-0.1.0.dist-info/licenses/LICENSE +21 -0
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()
|
plm/general/__init__.py
ADDED
|
@@ -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)
|
plm/general/manifest.py
ADDED
|
@@ -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))
|
plm/numeric/__init__.py
ADDED
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)
|