Upload 11 files
Browse files- .gitignore +7 -0
- LICENSE +21 -0
- README.md +118 -0
- eval/ab2_bonsai_vs_merge.py +94 -0
- eval/ab_bonsai_vs_merge.py +96 -0
- eval/problems_easy.jsonl +12 -0
- eval/problems_hard.jsonl +10 -0
- merge.py +126 -0
- race.py +148 -0
- requirements.txt +3 -0
- server.py +159 -0
.gitignore
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
.venv/
|
| 4 |
+
*.jsonl
|
| 5 |
+
!.gitignore
|
| 6 |
+
!eval/problems_easy.jsonl
|
| 7 |
+
!eval/problems_hard.jsonl
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 Jacob Thomas Cuthbertson
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: mit
|
| 3 |
+
tags:
|
| 4 |
+
- metacog
|
| 5 |
+
- ensemble
|
| 6 |
+
- system-one
|
| 7 |
+
- text-generation
|
| 8 |
+
base_model:
|
| 9 |
+
- prism-ml/Ternary-Bonsai-2-27B-gguf
|
| 10 |
+
- Mapika/decider-4b
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
# Heuristic 1
|
| 14 |
+
|
| 15 |
+
One model out of two: **Bonsai 2 27B (ternary)** thinks, **Decider-4B** judges,
|
| 16 |
+
[MetaCog](https://github.com/ItIsCuthNotCup/MetaCog) wires them into a single
|
| 17 |
+
OpenAI-compatible endpoint.
|
| 18 |
+
|
| 19 |
+
> **This is not a weight merge.** No tensors were averaged. It is an
|
| 20 |
+
> inference-time pipeline merge: the thinker writes candidate answers, an
|
| 21 |
+
> independent System-One judge (Decider-4B) scores them, and the winner is
|
| 22 |
+
> returned. One endpoint, one model ID, two models inside.
|
| 23 |
+
|
| 24 |
+
```
|
| 25 |
+
user prompt ──▶ bonsai-27B ──▶ greedy anchor judged FIRST
|
| 26 |
+
│ ≥ 0.80 and finished? ──▶ answer immediately
|
| 27 |
+
▼
|
| 28 |
+
7 sampled paths IN PARALLEL,
|
| 29 |
+
each judged the moment it finishes;
|
| 30 |
+
first finished path ≥ 0.80 wins on the spot,
|
| 31 |
+
the rest are abandoned
|
| 32 |
+
│
|
| 33 |
+
(nothing clears the bar? best judged
|
| 34 |
+
finished path wins)
|
| 35 |
+
```
|
| 36 |
+
|
| 37 |
+
The judge is *not* the thinker — that separation is the whole point.
|
| 38 |
+
|
| 39 |
+
`race.py` implements this loop (parallel generation, judge-as-they-finish,
|
| 40 |
+
early stop). The old sequential MetaCog `best_of_n` is still available via
|
| 41 |
+
`NO_RACE=1` (server) or `--no-race` (batch) for A/B comparison. The
|
| 42 |
+
early-stop bar is `STOP_CONFIDENCE` / `--stop-confidence` (default 0.80).
|
| 43 |
+
|
| 44 |
+
> **On the 0.80 bar, honestly:** the MetaCog repo measured its 0.95 cascade
|
| 45 |
+
> threshold on real Jev (a noul ≥ 0.95 was correct 226/229 times). 0.80 did
|
| 46 |
+
> not win anywhere — it is the operator's directive and is untested on
|
| 47 |
+
> decider-4b, whose confidence scale is not calibrated (values above 1.0
|
| 48 |
+
> have been observed). Measure before trusting it.
|
| 49 |
+
|
| 50 |
+
## Measured
|
| 51 |
+
|
| 52 |
+
| eval | bonsai alone | merge (Heuristic 1) | note |
|
| 53 |
+
|---|---|---|---|
|
| 54 |
+
| HumanEval, n=164, true pass@1 (prompt+completion graded as one program, `<think>` stripped) | 108/164 (65.9%) | **135/164 (82.3%)** | judge fixed 27, broke 0; McNemar p < 1e-6 |
|
| 55 |
+
| 12 easy Q&A (verified) | 12/12 | 12/12 | tie; nothing to fix |
|
| 56 |
+
| 10 hard Q&A (verified, tricks + big arithmetic) | 8/10 | **10/10** | judge fixed 2, broke 0 |
|
| 57 |
+
|
| 58 |
+
Cost per problem (HumanEval averages): merge uses ~5,600 thinker tokens
|
| 59 |
+
over 2.0 thinker calls + 8.5 judge calls; bonsai alone uses ~700 tokens in
|
| 60 |
+
1 call. Roughly 8× the thinker tokens for +16 points of accuracy, $0 on
|
| 61 |
+
local hardware.
|
| 62 |
+
|
| 63 |
+
Latency: when the judge is confident in the greedy answer it returns
|
| 64 |
+
immediately (~same as bonsai alone — e.g. 2.5s on a trivial prompt); the
|
| 65 |
+
full vote only runs when uncertain. Measured on 3 HumanEval problems where
|
| 66 |
+
the full vote ran (DGX Spark, sequential paths, old loop): heuristic-1 took
|
| 67 |
+
210s / 406s / 440s vs bonsai alone at 17s / 18s / 18s — roughly 12–24× slower
|
| 68 |
+
wall-clock on hard problems. The race loop (`race.py`) parallelizes the
|
| 69 |
+
sampled paths and cuts off the moment any finished path clears 0.80, which
|
| 70 |
+
removes most of that gap; re-measure on the Spark to confirm. Every response
|
| 71 |
+
carries `merge.judge_confidence`.
|
| 72 |
+
|
| 73 |
+
## Run it
|
| 74 |
+
|
| 75 |
+
```bash
|
| 76 |
+
pip install -r requirements.txt # metacog + httpx + pydantic
|
| 77 |
+
|
| 78 |
+
# one model on :8200
|
| 79 |
+
python server.py
|
| 80 |
+
# POST /v1/chat/completions {"messages": [...], "model": "bonsai-decider4b-merge"}
|
| 81 |
+
|
| 82 |
+
# batch runner
|
| 83 |
+
python merge.py --problem "What is 17*23?"
|
| 84 |
+
python merge.py --problems problems.jsonl --out results.jsonl
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
Env knobs: `THINKER_URL` (default `http://localhost:8010`),
|
| 88 |
+
`THINKER_MODEL` (default `bonsai`), `JUDGE_URL` (default
|
| 89 |
+
`http://localhost:8008`), `PORT` (8200), `N_PATHS` (8), `NO_CASCADE`,
|
| 90 |
+
`MAX_TOKENS` (2048), `TEMPERATURE` (0.8).
|
| 91 |
+
|
| 92 |
+
You need both backends up:
|
| 93 |
+
|
| 94 |
+
- **Thinker** — any OpenAI-compatible chat endpoint serving Bonsai 2 27B.
|
| 95 |
+
Reference setup: bjev's PrismML llama.cpp fork on `:8010`
|
| 96 |
+
(`Ternary-Bonsai-2-27B-PQ2_0.gguf`, 6.66 GiB).
|
| 97 |
+
- **Judge** — a `/v1/systemone` endpoint serving Decider-4B.
|
| 98 |
+
Reference: `runs/judge_server.py` (a `/v1/systemone` shim over the
|
| 99 |
+
`Mapika/decider-4b` checkpoint) on `:8008`.
|
| 100 |
+
|
| 101 |
+
On a DGX Spark this fits alongside both models in ~20 GB and runs at $0.
|
| 102 |
+
|
| 103 |
+
## Files
|
| 104 |
+
|
| 105 |
+
- `server.py` — the one-model OpenAI-compatible server (`:8200`)
|
| 106 |
+
- `merge.py` — batch runner (problems in, answers + confidence + timings out)
|
| 107 |
+
- `eval/ab_bonsai_vs_merge.py` — A/B harness: bonsai alone vs the merge
|
| 108 |
+
- `eval/problems_easy.jsonl`, `eval/problems_hard.jsonl` — the measured sets
|
| 109 |
+
|
| 110 |
+
## License
|
| 111 |
+
|
| 112 |
+
MIT. This repo ships pipeline code only — no weights. Parent model licenses
|
| 113 |
+
(checked 2026-09-27):
|
| 114 |
+
- Bonsai 2 27B ternary (`prism-ml/Ternary-Bonsai-2-27B-gguf`) — Apache-2.0
|
| 115 |
+
- Decider-4B (`Mapika/decider-4b`) — Apache-2.0
|
| 116 |
+
- MetaCog — MIT; llama.cpp — MIT
|
| 117 |
+
Fetch the weights yourself under their Apache-2.0 terms; nothing here
|
| 118 |
+
relicenses them. Not affiliated with PrismML, Mapika, or Alibaba.
|
eval/ab2_bonsai_vs_merge.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""A/B: bonsai alone (greedy, temp 0) vs the merge (bonsai + decider-4b judge).
|
| 3 |
+
|
| 4 |
+
Both served on the Spark: bonsai :8010, merge :8200.
|
| 5 |
+
Usage: .venv/bin/python runs/ab_bonsai_vs_merge.py
|
| 6 |
+
"""
|
| 7 |
+
import json
|
| 8 |
+
import re
|
| 9 |
+
import time
|
| 10 |
+
import urllib.request
|
| 11 |
+
|
| 12 |
+
PROBLEMS = [
|
| 13 |
+
("What is 1234 * 5678? Reply with just the number.", "7006652"),
|
| 14 |
+
("A bat and ball cost $1.10. The bat costs $1 more than the ball. How much does the ball cost in cents? Reply with just the number.", "5"),
|
| 15 |
+
("What is the 16th letter of the alphabet? Reply with just the letter.", "p"),
|
| 16 |
+
("If you have 3 apples and take away 2, how many do you have? Reply with just the number.", "2"),
|
| 17 |
+
("What year did the Berlin Wall fall? Reply with just the year.", "1989"),
|
| 18 |
+
("How many sides does a heptagon have? Reply with just the number.", "7"),
|
| 19 |
+
("What is 1/3 + 1/6? Reply as a fraction in lowest terms.", "1/2"),
|
| 20 |
+
("Which planet has the most moons as of 2024, Saturn or Jupiter? Reply with just the name.", "saturn"),
|
| 21 |
+
("A farmer has 17 sheep, all but 9 die. How many are left? Reply with just the number.", "9"),
|
| 22 |
+
("What is the square root of 144? Reply with just the number.", "12"),
|
| 23 |
+
]
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def chat(url, model, problem, temperature):
|
| 27 |
+
body = json.dumps({
|
| 28 |
+
"model": model,
|
| 29 |
+
"messages": [{"role": "user", "content": problem}],
|
| 30 |
+
"max_tokens": 256,
|
| 31 |
+
"temperature": temperature,
|
| 32 |
+
}).encode()
|
| 33 |
+
req = urllib.request.Request(url, data=body,
|
| 34 |
+
headers={"Content-Type": "application/json"})
|
| 35 |
+
with urllib.request.urlopen(req, timeout=300) as r:
|
| 36 |
+
return json.load(r)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def final_text(answer):
|
| 40 |
+
# strip <think>...</think>, take the last non-empty line
|
| 41 |
+
t = re.sub(r"<think>.*?</think>", "", answer, flags=re.S | re.I).strip()
|
| 42 |
+
lines = [l.strip() for l in t.splitlines() if l.strip()]
|
| 43 |
+
return lines[-1] if lines else t
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def correct(final, expect):
|
| 47 |
+
return expect.lower() in final.lower()
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def main():
|
| 51 |
+
rows = []
|
| 52 |
+
for i, (problem, expect) in enumerate(PROBLEMS):
|
| 53 |
+
# bonsai alone
|
| 54 |
+
t0 = time.time()
|
| 55 |
+
rb = chat("http://localhost:8010/v1/chat/completions", "bonsai",
|
| 56 |
+
problem, 0.0)
|
| 57 |
+
tb = time.time() - t0
|
| 58 |
+
ab = rb["choices"][0]["message"]["content"]
|
| 59 |
+
fb, cb = final_text(ab), correct(final_text(ab), expect)
|
| 60 |
+
|
| 61 |
+
# merge
|
| 62 |
+
t0 = time.time()
|
| 63 |
+
rm = chat("http://localhost:8200/v1/chat/completions",
|
| 64 |
+
"heuristic-1", problem, 0.0)
|
| 65 |
+
tm = time.time() - t0
|
| 66 |
+
am = rm["choices"][0]["message"]["content"]
|
| 67 |
+
fm, cm = final_text(am), correct(final_text(am), expect)
|
| 68 |
+
conf = rm.get("merge", {}).get("judge_confidence", 0)
|
| 69 |
+
|
| 70 |
+
rows.append({"i": i, "problem": problem, "expect": expect,
|
| 71 |
+
"bonsai": fb[:120], "bonsai_ok": cb, "bonsai_s": round(tb, 1),
|
| 72 |
+
"merge": fm[:120], "merge_ok": cm, "merge_s": round(tm, 1),
|
| 73 |
+
"conf": conf})
|
| 74 |
+
print(f"[{i+1}/{len(PROBLEMS)}] expect={expect!r} "
|
| 75 |
+
f"bonsai={'OK ' if cb else 'MISS'} ({tb:.0f}s) "
|
| 76 |
+
f"merge={'OK ' if cm else 'MISS'} ({tm:.0f}s, conf={conf:.2f})",
|
| 77 |
+
flush=True)
|
| 78 |
+
|
| 79 |
+
b_ok = sum(r["bonsai_ok"] for r in rows)
|
| 80 |
+
m_ok = sum(r["merge_ok"] for r in rows)
|
| 81 |
+
flips = [r for r in rows if r["merge_ok"] and not r["bonsai_ok"]]
|
| 82 |
+
losses = [r for r in rows if r["bonsai_ok"] and not r["merge_ok"]]
|
| 83 |
+
print(f"\n=== bonsai alone: {b_ok}/{len(rows)} | merge: {m_ok}/{len(rows)} ===")
|
| 84 |
+
print(f"judge fixed: {len(flips)} | judge broke: {len(losses)}")
|
| 85 |
+
for r in flips:
|
| 86 |
+
print(f" FIXED: {r['problem'][:60]}... bonsai={r['bonsai'][:40]!r} merge={r['merge'][:40]!r}")
|
| 87 |
+
for r in losses:
|
| 88 |
+
print(f" BROKE: {r['problem'][:60]}... bonsai={r['bonsai'][:40]!r} merge={r['merge'][:40]!r}")
|
| 89 |
+
json.dump(rows, open("runs/ab2_results.json", "w"), indent=1)
|
| 90 |
+
print("wrote runs/ab2_results.json")
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
if __name__ == "__main__":
|
| 94 |
+
main()
|
eval/ab_bonsai_vs_merge.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""A/B: bonsai alone (greedy, temp 0) vs the merge (bonsai + decider-4b judge).
|
| 3 |
+
|
| 4 |
+
Both served on the Spark: bonsai :8010, merge :8200.
|
| 5 |
+
Usage: .venv/bin/python runs/ab_bonsai_vs_merge.py
|
| 6 |
+
"""
|
| 7 |
+
import json
|
| 8 |
+
import re
|
| 9 |
+
import time
|
| 10 |
+
import urllib.request
|
| 11 |
+
|
| 12 |
+
PROBLEMS = [
|
| 13 |
+
("What is 17*23? Reply with just the number.", "391"),
|
| 14 |
+
("A train travels 120 km in 1.5 hours. What is its average speed in km/h? Reply with just the number.", "80"),
|
| 15 |
+
("What is the 5th prime number? Reply with just the number.", "11"),
|
| 16 |
+
("If all bloops are razzies and all razzies are lazzies, are all bloops lazzies? Reply yes or no.", "yes"),
|
| 17 |
+
("Name the planet closest to the sun. Reply with just the name.", "mercury"),
|
| 18 |
+
("What is 15% of 200? Reply with just the number.", "30"),
|
| 19 |
+
("A rectangle has length 9 and width 4. What is its area? Reply with just the number.", "36"),
|
| 20 |
+
("Which is larger: 3/4 or 5/7? Reply with just the fraction.", "3/4"),
|
| 21 |
+
("What is the capital of Australia? Reply with just the city name.", "canberra"),
|
| 22 |
+
("If you flip a fair coin twice, what is the probability both are heads? Reply as a fraction.", "1/4"),
|
| 23 |
+
("What is 7^3? Reply with just the number.", "343"),
|
| 24 |
+
("A book costs $12 after a 20% discount. What was the original price in dollars? Reply with just the number.", "15"),
|
| 25 |
+
]
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def chat(url, model, problem, temperature):
|
| 29 |
+
body = json.dumps({
|
| 30 |
+
"model": model,
|
| 31 |
+
"messages": [{"role": "user", "content": problem}],
|
| 32 |
+
"max_tokens": 256,
|
| 33 |
+
"temperature": temperature,
|
| 34 |
+
}).encode()
|
| 35 |
+
req = urllib.request.Request(url, data=body,
|
| 36 |
+
headers={"Content-Type": "application/json"})
|
| 37 |
+
with urllib.request.urlopen(req, timeout=300) as r:
|
| 38 |
+
return json.load(r)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def final_text(answer):
|
| 42 |
+
# strip <think>...</think>, take the last non-empty line
|
| 43 |
+
t = re.sub(r"<think>.*?</think>", "", answer, flags=re.S | re.I).strip()
|
| 44 |
+
lines = [l.strip() for l in t.splitlines() if l.strip()]
|
| 45 |
+
return lines[-1] if lines else t
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def correct(final, expect):
|
| 49 |
+
return expect.lower() in final.lower()
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def main():
|
| 53 |
+
rows = []
|
| 54 |
+
for i, (problem, expect) in enumerate(PROBLEMS):
|
| 55 |
+
# bonsai alone
|
| 56 |
+
t0 = time.time()
|
| 57 |
+
rb = chat("http://localhost:8010/v1/chat/completions", "bonsai",
|
| 58 |
+
problem, 0.0)
|
| 59 |
+
tb = time.time() - t0
|
| 60 |
+
ab = rb["choices"][0]["message"]["content"]
|
| 61 |
+
fb, cb = final_text(ab), correct(final_text(ab), expect)
|
| 62 |
+
|
| 63 |
+
# merge
|
| 64 |
+
t0 = time.time()
|
| 65 |
+
rm = chat("http://localhost:8200/v1/chat/completions",
|
| 66 |
+
"heuristic-1", problem, 0.0)
|
| 67 |
+
tm = time.time() - t0
|
| 68 |
+
am = rm["choices"][0]["message"]["content"]
|
| 69 |
+
fm, cm = final_text(am), correct(final_text(am), expect)
|
| 70 |
+
conf = rm.get("merge", {}).get("judge_confidence", 0)
|
| 71 |
+
|
| 72 |
+
rows.append({"i": i, "problem": problem, "expect": expect,
|
| 73 |
+
"bonsai": fb[:120], "bonsai_ok": cb, "bonsai_s": round(tb, 1),
|
| 74 |
+
"merge": fm[:120], "merge_ok": cm, "merge_s": round(tm, 1),
|
| 75 |
+
"conf": conf})
|
| 76 |
+
print(f"[{i+1}/{len(PROBLEMS)}] expect={expect!r} "
|
| 77 |
+
f"bonsai={'OK ' if cb else 'MISS'} ({tb:.0f}s) "
|
| 78 |
+
f"merge={'OK ' if cm else 'MISS'} ({tm:.0f}s, conf={conf:.2f})",
|
| 79 |
+
flush=True)
|
| 80 |
+
|
| 81 |
+
b_ok = sum(r["bonsai_ok"] for r in rows)
|
| 82 |
+
m_ok = sum(r["merge_ok"] for r in rows)
|
| 83 |
+
flips = [r for r in rows if r["merge_ok"] and not r["bonsai_ok"]]
|
| 84 |
+
losses = [r for r in rows if r["bonsai_ok"] and not r["merge_ok"]]
|
| 85 |
+
print(f"\n=== bonsai alone: {b_ok}/{len(rows)} | merge: {m_ok}/{len(rows)} ===")
|
| 86 |
+
print(f"judge fixed: {len(flips)} | judge broke: {len(losses)}")
|
| 87 |
+
for r in flips:
|
| 88 |
+
print(f" FIXED: {r['problem'][:60]}... bonsai={r['bonsai'][:40]!r} merge={r['merge'][:40]!r}")
|
| 89 |
+
for r in losses:
|
| 90 |
+
print(f" BROKE: {r['problem'][:60]}... bonsai={r['bonsai'][:40]!r} merge={r['merge'][:40]!r}")
|
| 91 |
+
json.dump(rows, open("runs/ab_results.json", "w"), indent=1)
|
| 92 |
+
print("wrote runs/ab_results.json")
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
if __name__ == "__main__":
|
| 96 |
+
main()
|
eval/problems_easy.jsonl
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{"problem": "What is 17*23? Reply with just the number.", "expect": "391"}
|
| 2 |
+
{"problem": "A train travels 120 km in 1.5 hours. What is its average speed in km/h? Reply with just the number.", "expect": "80"}
|
| 3 |
+
{"problem": "What is the 5th prime number? Reply with just the number.", "expect": "11"}
|
| 4 |
+
{"problem": "If all bloops are razzies and all razzies are lazzies, are all bloops lazzies? Reply yes or no.", "expect": "yes"}
|
| 5 |
+
{"problem": "Name the planet closest to the sun. Reply with just the name.", "expect": "mercury"}
|
| 6 |
+
{"problem": "What is 15% of 200? Reply with just the number.", "expect": "30"}
|
| 7 |
+
{"problem": "A rectangle has length 9 and width 4. What is its area? Reply with just the number.", "expect": "36"}
|
| 8 |
+
{"problem": "Which is larger: 3/4 or 5/7? Reply with just the fraction.", "expect": "3/4"}
|
| 9 |
+
{"problem": "What is the capital of Australia? Reply with just the city name.", "expect": "canberra"}
|
| 10 |
+
{"problem": "If you flip a fair coin twice, what is the probability both are heads? Reply as a fraction.", "expect": "1/4"}
|
| 11 |
+
{"problem": "What is 7^3? Reply with just the number.", "expect": "343"}
|
| 12 |
+
{"problem": "A book costs $12 after a 20% discount. What was the original price in dollars? Reply with just the number.", "expect": "15"}
|
eval/problems_hard.jsonl
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{"problem": "What is 1234 * 5678? Reply with just the number.", "expect": "7006652"}
|
| 2 |
+
{"problem": "A bat and ball cost $1.10. The bat costs $1 more than the ball. How much does the ball cost in cents? Reply with just the number.", "expect": "5"}
|
| 3 |
+
{"problem": "What is the 16th letter of the alphabet? Reply with just the letter.", "expect": "p"}
|
| 4 |
+
{"problem": "If you have 3 apples and take away 2, how many do you have? Reply with just the number.", "expect": "2"}
|
| 5 |
+
{"problem": "What year did the Berlin Wall fall? Reply with just the year.", "expect": "1989"}
|
| 6 |
+
{"problem": "How many sides does a heptagon have? Reply with just the number.", "expect": "7"}
|
| 7 |
+
{"problem": "What is 1/3 + 1/6? Reply as a fraction in lowest terms.", "expect": "1/2"}
|
| 8 |
+
{"problem": "Which planet has the most moons as of 2024, Saturn or Jupiter? Reply with just the name.", "expect": "saturn"}
|
| 9 |
+
{"problem": "A farmer has 17 sheep, all but 9 die. How many are left? Reply with just the number.", "expect": "9"}
|
| 10 |
+
{"problem": "What is the square root of 144? Reply with just the number.", "expect": "12"}
|
merge.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""The merge: bonsai thinks, decider-4b judges, MetaCog wires them into one model.
|
| 3 |
+
|
| 4 |
+
thinker: bonsai (bjev llama.cpp, OpenAI chat) .... http://localhost:8010
|
| 5 |
+
judge: decider-4b (/v1/systemone shim) ......... http://localhost:8008
|
| 6 |
+
|
| 7 |
+
Usage:
|
| 8 |
+
.venv/bin/python merge.py --problem "What is 17*23?"
|
| 9 |
+
.venv/bin/python merge.py --problems problems.jsonl --out results.jsonl
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import argparse
|
| 15 |
+
import json
|
| 16 |
+
import sys
|
| 17 |
+
import time
|
| 18 |
+
|
| 19 |
+
import re
|
| 20 |
+
|
| 21 |
+
def clean_answer(text: str) -> str:
|
| 22 |
+
"""Strip <think>...</think> reasoning blocks the thinker may leak."""
|
| 23 |
+
return re.sub(r"<think>.*?</think>\s*", "", text, flags=re.S).strip()
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
from metacog import Config, MetaCog, OpenAICompatThinker, SystemOneJudge
|
| 27 |
+
|
| 28 |
+
import os
|
| 29 |
+
|
| 30 |
+
THINKER_URL = os.environ.get("THINKER_URL", "http://localhost:8010")
|
| 31 |
+
THINKER_MODEL = os.environ.get("THINKER_MODEL", "bonsai")
|
| 32 |
+
JUDGE_URL = os.environ.get("JUDGE_URL", "http://localhost:8008")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def build_thinker_judge():
|
| 36 |
+
thinker = OpenAICompatThinker(THINKER_URL, model=THINKER_MODEL)
|
| 37 |
+
# non-TypeSafe base_url -> no auth header; the local shim is open
|
| 38 |
+
judge = SystemOneJudge(base_url=JUDGE_URL)
|
| 39 |
+
return thinker, judge
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def build(n_paths: int = 8, no_cascade: bool = False) -> MetaCog:
|
| 43 |
+
thinker, judge = build_thinker_judge()
|
| 44 |
+
config = Config(
|
| 45 |
+
mode="best_of_n",
|
| 46 |
+
strategy="noul",
|
| 47 |
+
n_paths=n_paths,
|
| 48 |
+
greedy_anchor=True,
|
| 49 |
+
cascade_confidence=None if no_cascade else 0.95,
|
| 50 |
+
max_tokens=2048,
|
| 51 |
+
temperature=0.8,
|
| 52 |
+
)
|
| 53 |
+
return MetaCog(thinker, judge, config)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def main() -> int:
|
| 57 |
+
ap = argparse.ArgumentParser(description="bonsai + decider-4b MetaCog merge")
|
| 58 |
+
src = ap.add_mutually_exclusive_group(required=True)
|
| 59 |
+
src.add_argument("--problem")
|
| 60 |
+
src.add_argument("--problems")
|
| 61 |
+
ap.add_argument("--n-paths", type=int, default=8)
|
| 62 |
+
ap.add_argument("--no-cascade", action="store_true")
|
| 63 |
+
ap.add_argument("--stop-confidence", type=float, default=0.80,
|
| 64 |
+
help="race loop early-stop bar (default 0.80)")
|
| 65 |
+
ap.add_argument("--no-race", action="store_true",
|
| 66 |
+
help="use the old sequential MetaCog best_of_n instead of the race loop")
|
| 67 |
+
ap.add_argument("--out", default="runs/merge_results.jsonl")
|
| 68 |
+
args = ap.parse_args()
|
| 69 |
+
|
| 70 |
+
if args.problem:
|
| 71 |
+
problems = [args.problem]
|
| 72 |
+
else:
|
| 73 |
+
problems = [json.loads(l)["problem"] for l in open(args.problems)
|
| 74 |
+
if l.strip()]
|
| 75 |
+
if not problems:
|
| 76 |
+
print("no problems", file=sys.stderr)
|
| 77 |
+
return 1
|
| 78 |
+
|
| 79 |
+
mc = build(args.n_paths, args.no_cascade)
|
| 80 |
+
use_race = not args.no_race
|
| 81 |
+
thinker = judge = None
|
| 82 |
+
if use_race:
|
| 83 |
+
from race import race
|
| 84 |
+
thinker, judge = build_thinker_judge()
|
| 85 |
+
t0 = time.time()
|
| 86 |
+
with open(args.out, "w") as out:
|
| 87 |
+
for i, problem in enumerate(problems):
|
| 88 |
+
t1 = time.time()
|
| 89 |
+
if use_race:
|
| 90 |
+
r = race(problem, thinker, judge, n_paths=args.n_paths,
|
| 91 |
+
stop_confidence=args.stop_confidence)
|
| 92 |
+
answer, finished = clean_answer(r.answer), r.finished
|
| 93 |
+
conf, toks, jcalls, dt = (r.confidence, r.thinker_tokens,
|
| 94 |
+
r.judge_calls, r.seconds)
|
| 95 |
+
extra = {"early_stopped": r.early_stopped,
|
| 96 |
+
"winner_source": r.winner_source,
|
| 97 |
+
"paths_judged": r.paths_judged}
|
| 98 |
+
else:
|
| 99 |
+
result = mc.run(problem)
|
| 100 |
+
dt = time.time() - t1
|
| 101 |
+
rnd = result.trace.rounds[-1] if result.trace.rounds else None
|
| 102 |
+
answer, finished = clean_answer(result.answer), result.finished
|
| 103 |
+
conf = round(rnd.verdict.confidence, 4) if rnd else 0.0
|
| 104 |
+
toks, jcalls = result.trace.thinker_tokens, result.trace.judge_calls
|
| 105 |
+
extra = {}
|
| 106 |
+
row = {
|
| 107 |
+
"i": i,
|
| 108 |
+
"problem": problem,
|
| 109 |
+
"answer": answer,
|
| 110 |
+
"finished": finished,
|
| 111 |
+
"judge_confidence": round(conf, 4),
|
| 112 |
+
"thinker_tokens": toks,
|
| 113 |
+
"judge_calls": jcalls,
|
| 114 |
+
"seconds": round(dt, 2),
|
| 115 |
+
**extra,
|
| 116 |
+
}
|
| 117 |
+
out.write(json.dumps(row) + "\n")
|
| 118 |
+
out.flush()
|
| 119 |
+
print(f"[{i+1}/{len(problems)}] conf={conf:.2f} "
|
| 120 |
+
f"{dt:.1f}s: {answer[:120]}", flush=True)
|
| 121 |
+
print(f"done: {len(problems)} problems in {time.time()-t0:.0f}s -> {args.out}")
|
| 122 |
+
return 0
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
if __name__ == "__main__":
|
| 126 |
+
raise SystemExit(main())
|
race.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Heuristic 1.1 race loop: parallel thought paths, judged as they finish.
|
| 3 |
+
|
| 4 |
+
Replaces the sequential best_of_n + judge-everything-at-the-end loop:
|
| 5 |
+
|
| 6 |
+
1. The greedy anchor (temperature 0) is generated and judged FIRST. If the
|
| 7 |
+
judge scores it >= stop_confidence and it finished, it wins immediately --
|
| 8 |
+
no sampled paths are ever generated.
|
| 9 |
+
2. Otherwise n_paths-1 sampled paths are generated IN PARALLEL
|
| 10 |
+
(ThreadPoolExecutor over the thinker's HTTP endpoint).
|
| 11 |
+
3. Each path is judged the moment it completes. The first FINISHED path to
|
| 12 |
+
score >= stop_confidence wins on the spot; the race is cut off at that
|
| 13 |
+
instant -- queued paths are cancelled and race() returns immediately.
|
| 14 |
+
(Honest cutoff semantics: threads already running a blocking HTTP
|
| 15 |
+
generation can't be killed, so they finish silently in the background
|
| 16 |
+
and their results are discarded. Nothing waits for them.)
|
| 17 |
+
4. If nothing clears the bar, the best judged finished path wins. Finished
|
| 18 |
+
paths always outrank truncated ones, whatever the score.
|
| 19 |
+
|
| 20 |
+
Only the thinker's .generate() and the judge's .score() from the metacog
|
| 21 |
+
package are used -- the MetaCog controller is not touched.
|
| 22 |
+
|
| 23 |
+
Caveat: decider-4b's confidence scale is not calibrated (values above 1.0
|
| 24 |
+
have been observed), so stop_confidence is a knob to measure, not a true
|
| 25 |
+
probability. The 0.95 default in the MetaCog repo was measured on real Jev
|
| 26 |
+
(226/229 correct at >= 0.95); 0.80 here is the operator's directive and is
|
| 27 |
+
untested on decider-4b.
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
from __future__ import annotations
|
| 31 |
+
|
| 32 |
+
import threading
|
| 33 |
+
import time
|
| 34 |
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 35 |
+
from dataclasses import dataclass
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@dataclass
|
| 39 |
+
class RaceResult:
|
| 40 |
+
answer: str
|
| 41 |
+
finished: bool
|
| 42 |
+
confidence: float
|
| 43 |
+
thinker_tokens: int = 0
|
| 44 |
+
judge_calls: int = 0
|
| 45 |
+
paths_submitted: int = 0
|
| 46 |
+
paths_judged: int = 0
|
| 47 |
+
early_stopped: bool = False
|
| 48 |
+
winner_source: str = "sample" # "greedy" | "sample"
|
| 49 |
+
seconds: float = 0.0
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _judge_one(judge, problem: str, text: str) -> float:
|
| 53 |
+
"""Judge's P(correct) for a single path; 0.0 if the judge call fails."""
|
| 54 |
+
try:
|
| 55 |
+
return float(judge.score(problem, [text]).confidence)
|
| 56 |
+
except Exception:
|
| 57 |
+
return 0.0
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def race(
|
| 61 |
+
problem: str,
|
| 62 |
+
thinker,
|
| 63 |
+
judge,
|
| 64 |
+
n_paths: int = 8,
|
| 65 |
+
stop_confidence: float = 0.80,
|
| 66 |
+
max_tokens: int = 2048,
|
| 67 |
+
temperature: float = 0.8,
|
| 68 |
+
max_workers: int = 8,
|
| 69 |
+
) -> RaceResult:
|
| 70 |
+
t0 = time.time()
|
| 71 |
+
lock = threading.Lock()
|
| 72 |
+
thinker_tokens = 0
|
| 73 |
+
judge_calls = 0
|
| 74 |
+
|
| 75 |
+
def gen(**kw):
|
| 76 |
+
nonlocal thinker_tokens
|
| 77 |
+
gens = thinker.generate(problem, "", max_tokens=max_tokens, **kw)
|
| 78 |
+
with lock:
|
| 79 |
+
for g in gens:
|
| 80 |
+
thinker_tokens += g.tokens or 0
|
| 81 |
+
return gens
|
| 82 |
+
|
| 83 |
+
def judge_of(text: str) -> float:
|
| 84 |
+
nonlocal judge_calls
|
| 85 |
+
with lock:
|
| 86 |
+
judge_calls += 1
|
| 87 |
+
return _judge_one(judge, problem, text)
|
| 88 |
+
|
| 89 |
+
# --- 1. greedy anchor first -------------------------------------------
|
| 90 |
+
greedy = gen(n=1, temperature=0.0)[0]
|
| 91 |
+
g_conf = judge_of(greedy.text)
|
| 92 |
+
pool = [(greedy.text, greedy.finished, g_conf, "greedy")]
|
| 93 |
+
if greedy.finished and g_conf >= stop_confidence:
|
| 94 |
+
return RaceResult(
|
| 95 |
+
answer=greedy.text, finished=True, confidence=g_conf,
|
| 96 |
+
thinker_tokens=thinker_tokens, judge_calls=judge_calls,
|
| 97 |
+
paths_submitted=1, paths_judged=1,
|
| 98 |
+
early_stopped=True, winner_source="greedy",
|
| 99 |
+
seconds=time.time() - t0,
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
# --- 2. sampled paths in parallel, judged as they finish ---------------
|
| 103 |
+
n_sampled = max(n_paths - 1, 0)
|
| 104 |
+
early = False
|
| 105 |
+
winner = None
|
| 106 |
+
# NOTE: not a `with` block on purpose. Exiting a ThreadPoolExecutor
|
| 107 |
+
# context manager WAITS for every worker, which would defeat the early
|
| 108 |
+
# stop. Instead we shut down without waiting: queued futures are
|
| 109 |
+
# cancelled, race() returns at once, and any thread already inside a
|
| 110 |
+
# blocking thinker.generate() finishes on its own in the background
|
| 111 |
+
# (its result is discarded). The thinker's generate() is MetaCog's
|
| 112 |
+
# library code and is deliberately not modified, so true GPU-side
|
| 113 |
+
# abort of an in-flight generation isn't available -- this is a
|
| 114 |
+
# stop-waiting cutoff, which is what cuts user-facing latency.
|
| 115 |
+
ex = ThreadPoolExecutor(max_workers=max(1, min(max_workers, n_sampled or 1)))
|
| 116 |
+
try:
|
| 117 |
+
futs = [ex.submit(gen, n=1, temperature=temperature)
|
| 118 |
+
for _ in range(n_sampled)]
|
| 119 |
+
for fut in as_completed(futs):
|
| 120 |
+
try:
|
| 121 |
+
g = fut.result()[0]
|
| 122 |
+
except Exception:
|
| 123 |
+
continue # a dead path just never gets judged
|
| 124 |
+
conf = judge_of(g.text)
|
| 125 |
+
pool.append((g.text, g.finished, conf, "sample"))
|
| 126 |
+
if g.finished and conf >= stop_confidence:
|
| 127 |
+
winner = (g.text, True, conf, "sample")
|
| 128 |
+
early = True
|
| 129 |
+
break
|
| 130 |
+
finally:
|
| 131 |
+
ex.shutdown(wait=False, cancel_futures=True)
|
| 132 |
+
|
| 133 |
+
# --- 3. no early stop: best judged finished path wins -------------------
|
| 134 |
+
if winner is None:
|
| 135 |
+
finished = [p for p in pool if p[1]]
|
| 136 |
+
text, fin, conf, src = max(finished or pool, key=lambda p: p[2])
|
| 137 |
+
winner = (text, fin, conf, src)
|
| 138 |
+
|
| 139 |
+
text, fin, conf, src = winner
|
| 140 |
+
with lock:
|
| 141 |
+
toks, calls = thinker_tokens, judge_calls
|
| 142 |
+
return RaceResult(
|
| 143 |
+
answer=text, finished=fin, confidence=conf,
|
| 144 |
+
thinker_tokens=toks, judge_calls=calls,
|
| 145 |
+
paths_submitted=1 + n_sampled, paths_judged=len(pool),
|
| 146 |
+
early_stopped=early, winner_source=src,
|
| 147 |
+
seconds=time.time() - t0,
|
| 148 |
+
)
|
requirements.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
metacog @ git+https://github.com/ItIsCuthNotCup/MetaCog.git
|
| 2 |
+
httpx>=0.27
|
| 3 |
+
pydantic>=2.0
|
server.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Serve the bonsai + decider-4b MetaCog merge as ONE OpenAI-compatible model.
|
| 3 |
+
|
| 4 |
+
thinker: bonsai 27B (ternary) ............ THINKER_URL (default http://localhost:8010)
|
| 5 |
+
judge: decider-4b (/v1/systemone) ..... JUDGE_URL (default http://localhost:8008)
|
| 6 |
+
|
| 7 |
+
POST /v1/chat/completions {"messages": [...]} -> judge-picked answer.
|
| 8 |
+
|
| 9 |
+
Env: PORT (8200), THINKER_URL, THINKER_MODEL (bonsai), JUDGE_URL,
|
| 10 |
+
N_PATHS (8), NO_CASCADE, MAX_TOKENS (2048), TEMPERATURE (0.8),
|
| 11 |
+
STOP_CONFIDENCE (0.80), NO_RACE (unset).
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import json
|
| 16 |
+
import os
|
| 17 |
+
import time
|
| 18 |
+
import uuid
|
| 19 |
+
|
| 20 |
+
import re
|
| 21 |
+
|
| 22 |
+
def clean_answer(text: str) -> str:
|
| 23 |
+
"""Strip <think>...</think> reasoning blocks the thinker may leak."""
|
| 24 |
+
return re.sub(r"<think>.*?</think>\s*", "", text, flags=re.S).strip()
|
| 25 |
+
|
| 26 |
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
| 27 |
+
|
| 28 |
+
from metacog import Config, MetaCog, OpenAICompatThinker, SystemOneJudge
|
| 29 |
+
from race import race
|
| 30 |
+
|
| 31 |
+
MODEL_ID = "heuristic-1"
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def env(name: str, default: str) -> str:
|
| 35 |
+
return os.environ.get(name, default)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
thinker = OpenAICompatThinker(
|
| 39 |
+
env("THINKER_URL", "http://localhost:8010"),
|
| 40 |
+
model=env("THINKER_MODEL", "bonsai"),
|
| 41 |
+
)
|
| 42 |
+
judge = SystemOneJudge(base_url=env("JUDGE_URL", "http://localhost:8008"))
|
| 43 |
+
USE_RACE = env("NO_RACE", "") != "1"
|
| 44 |
+
STOP_CONFIDENCE = float(env("STOP_CONFIDENCE", "0.80"))
|
| 45 |
+
N_PATHS = int(env("N_PATHS", "8"))
|
| 46 |
+
MAX_TOKENS = int(env("MAX_TOKENS", "2048"))
|
| 47 |
+
TEMPERATURE = float(env("TEMPERATURE", "0.8"))
|
| 48 |
+
MC = MetaCog(
|
| 49 |
+
thinker,
|
| 50 |
+
judge,
|
| 51 |
+
Config(
|
| 52 |
+
mode="best_of_n",
|
| 53 |
+
strategy="noul",
|
| 54 |
+
n_paths=N_PATHS,
|
| 55 |
+
greedy_anchor=True,
|
| 56 |
+
cascade_confidence=None if env("NO_CASCADE", "") == "1" else 0.95,
|
| 57 |
+
max_tokens=MAX_TOKENS,
|
| 58 |
+
temperature=TEMPERATURE,
|
| 59 |
+
),
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def problem_from(messages):
|
| 64 |
+
for m in reversed(messages):
|
| 65 |
+
if m.get("role") == "user":
|
| 66 |
+
c = m.get("content", "")
|
| 67 |
+
return c if isinstance(c, str) else json.dumps(c)
|
| 68 |
+
return "\n".join(f"{m.get('role')}: {m.get('content')}" for m in messages)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class Handler(BaseHTTPRequestHandler):
|
| 72 |
+
server_version = "MergeServer/1.0"
|
| 73 |
+
|
| 74 |
+
def _send(self, code, obj):
|
| 75 |
+
body = json.dumps(obj).encode()
|
| 76 |
+
self.send_response(code)
|
| 77 |
+
self.send_header("Content-Type", "application/json")
|
| 78 |
+
self.send_header("Content-Length", str(len(body)))
|
| 79 |
+
self.end_headers()
|
| 80 |
+
self.wfile.write(body)
|
| 81 |
+
|
| 82 |
+
def log_message(self, fmt, *args):
|
| 83 |
+
print(f"[{time.strftime('%H:%M:%S')}] {self.command} {self.path}", flush=True)
|
| 84 |
+
|
| 85 |
+
def do_GET(self):
|
| 86 |
+
path = self.path.rstrip("/")
|
| 87 |
+
if path in ("/health", "/v1/health", "/v1/models"):
|
| 88 |
+
self._send(200, {"status": "ok", "model": MODEL_ID})
|
| 89 |
+
else:
|
| 90 |
+
self._send(404, {"error": "not found"})
|
| 91 |
+
|
| 92 |
+
def do_POST(self):
|
| 93 |
+
path = self.path.rstrip("/")
|
| 94 |
+
if path not in ("/v1/chat/completions", "/chat/completions"):
|
| 95 |
+
return self._send(404, {"error": "not found"})
|
| 96 |
+
try:
|
| 97 |
+
raw = self.rfile.read(int(self.headers.get("Content-Length", 0))) or b"{}"
|
| 98 |
+
body = json.loads(raw)
|
| 99 |
+
except Exception as e:
|
| 100 |
+
return self._send(400, {"error": f"bad JSON: {e}"})
|
| 101 |
+
messages = body.get("messages", [])
|
| 102 |
+
if not messages:
|
| 103 |
+
return self._send(400, {"error": "messages required"})
|
| 104 |
+
t0 = time.time()
|
| 105 |
+
try:
|
| 106 |
+
if USE_RACE:
|
| 107 |
+
r = race(problem_from(messages), thinker, judge,
|
| 108 |
+
n_paths=N_PATHS, stop_confidence=STOP_CONFIDENCE,
|
| 109 |
+
max_tokens=MAX_TOKENS, temperature=TEMPERATURE)
|
| 110 |
+
answer, finished = clean_answer(r.answer), r.finished
|
| 111 |
+
conf, toks, dt = r.confidence, r.thinker_tokens, r.seconds
|
| 112 |
+
else:
|
| 113 |
+
result = MC.run(problem_from(messages))
|
| 114 |
+
dt = time.time() - t0
|
| 115 |
+
rounds = result.trace.rounds
|
| 116 |
+
conf = float(rounds[-1].verdict.confidence) if rounds else 0.0
|
| 117 |
+
answer, finished, toks = (clean_answer(result.answer),
|
| 118 |
+
result.finished,
|
| 119 |
+
result.trace.thinker_tokens)
|
| 120 |
+
except Exception as e:
|
| 121 |
+
return self._send(502, {"error": f"pipeline failed: {e}"})
|
| 122 |
+
print(f"[{time.strftime('%H:%M:%S')}] conf={conf:.2f} {dt:.1f}s", flush=True)
|
| 123 |
+
self._send(
|
| 124 |
+
200,
|
| 125 |
+
{
|
| 126 |
+
"id": f"merge-{uuid.uuid4().hex[:12]}",
|
| 127 |
+
"object": "chat.completion",
|
| 128 |
+
"created": int(time.time()),
|
| 129 |
+
"model": body.get("model", MODEL_ID),
|
| 130 |
+
"choices": [
|
| 131 |
+
{
|
| 132 |
+
"index": 0,
|
| 133 |
+
"message": {"role": "assistant", "content": answer},
|
| 134 |
+
"finish_reason": "stop" if finished else "length",
|
| 135 |
+
}
|
| 136 |
+
],
|
| 137 |
+
"usage": {
|
| 138 |
+
"prompt_tokens": 0,
|
| 139 |
+
"completion_tokens": toks,
|
| 140 |
+
"total_tokens": toks,
|
| 141 |
+
},
|
| 142 |
+
"merge": {
|
| 143 |
+
"thinker": "bonsai",
|
| 144 |
+
"judge": "decider-4b",
|
| 145 |
+
"judge_confidence": round(conf, 4),
|
| 146 |
+
"seconds": round(dt, 2),
|
| 147 |
+
},
|
| 148 |
+
},
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def main() -> None:
|
| 153 |
+
port = int(env("PORT", "8200"))
|
| 154 |
+
print(f"{MODEL_ID} on :{port}", flush=True)
|
| 155 |
+
ThreadingHTTPServer(("0.0.0.0", port), Handler).serve_forever()
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
if __name__ == "__main__":
|
| 159 |
+
main()
|