File size: 16,050 Bytes
28c70af | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 | #!/usr/bin/env python3
# SPDX-License-Identifier: MIT OR Apache-2.0
"""The JSON-schema oracle (O6b): llama-server b11192's own answers under JSON schemas, token by token, for bankML to
equal. The grammar text itself is checked without a model by `testing/schema_oracle.py` (`oracle_schema_grammars`);
this checks the whole path β the grammar, its prefill, the redraw, the end set and the content rule β on real answers.
Record (once per model): launches llama-server from BANKML_GGML_LIB with Savante's flags (`-t 3 -c 2048 -np 1 --jinja
--reasoning off --verbose`) on a spare port and sends `/v1/chat/completions` requests carrying schemas the way mindX
and OpenAI clients send them β `response_format: {"type": "json_schema", "json_schema": {"name", "strict", "schema"}}`,
the top-level `json_schema` field, and `response_format: {"type": "json_object", "schema": β¦}` β over schemas with
required and optional fields, enums, integer ranges, nested `$defs`/`$ref`, `anyOf` with null, a pattern and a date
format, string lengths, a top-level array, a top-level enum and a top-level integer; greedy and seeded. Each request
is sent from an empty cache (`cache_prompt: false`) and recorded with its exact text (`request_text`, so a float
literal stays one), the tokens with the end token, the raw text, the content, the finish reason, the counts, the
grammar and the generation prompt the server used.
BANKML_GGML_LIB=<b11192 release dir> python3 testing/json_schema_oracle.py --record Bonsai-8B-Q1_0
BANKML_GGML_LIB=<b11192 release dir> python3 testing/json_schema_oracle.py --record Ternary-Bonsai-8B-Q2_0_g64
(0.3.5: and Bonsai-1.7B-Q1_0, SmolLM2-135M-Instruct-F16, mindx-gen39-F16 β every case and seed β and answers cut
by max_tokens inside an object, a top-level string and a top-level number)
β .models/oracle-json/schema-<model>.jsonl, replayed by `oracle_json_schema` / `oracle_json_schema_ternary` /
`oracle_json_schema_o4` (cargo test --release -- --ignored)
Live (`--bankml [STEM [NAME]]`): bankml serve --native with the model (default the 1-bit 8B) on spare ports; a subset of the records goes through
`/v1/chat/completions` (each request shape, one streamed) and through Ollama's `/api/chat` with `format: <schema>`,
each from an empty slot; every answer must equal the record (content, finish, prompt and completion counts)."""
import json, os, subprocess, sys, time, urllib.request
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from json_oracle import SAVANTE, SEEDED, wait # noqa: E402 (the same server flags, seeds and readiness wait)
root = Path(__file__).resolve().parents[1]
out = root / ".models" / "oracle-json"
CAT = {"type": "object", "properties": {"name": {"type": "string", "maxLength": 24}, "age": {"type": "integer", "minimum": 0, "maximum": 30},
"colour": {"enum": ["black", "white", "ginger", "grey", "tabby"]}, "indoor": {"type": "boolean"}},
"required": ["name", "age", "colour"], "additionalProperties": False}
VERDICT = {"type": "object", "properties": {"verdict": {"enum": ["verified", "unverified", "refuted", "unknown"]}, "claim": {"type": "string"},
"score": {"type": "integer", "minimum": 0, "maximum": 100}, "sources": {"type": "array", "items": {"type": "string"}, "maxItems": 2}},
"required": ["verdict", "claim", "score", "sources"], "additionalProperties": False}
LIBRARY = {"$defs": {"Book": {"type": "object", "properties": {"title": {"type": "string"}, "author": {"type": "string"},
"year": {"type": "integer", "minimum": 1000, "maximum": 2100}}, "required": ["title", "author", "year"]},
"Address": {"type": "object", "properties": {"street": {"type": "string"}, "city": {"type": "string"}}, "required": ["street", "city"]}},
"type": "object", "properties": {"name": {"type": "string"}, "address": {"$ref": "#/$defs/Address"},
"books": {"type": "array", "items": {"$ref": "#/$defs/Book"}, "minItems": 3, "maxItems": 3}},
"required": ["name", "address", "books"]}
PLAN = {"title": "Plan", "type": "object", "properties": {
"goal": {"title": "Goal", "type": "string"},
"steps": {"title": "Steps", "type": "array", "minItems": 1, "maxItems": 4, "items": {"type": "object", "properties": {
"action": {"enum": ["search", "write", "ask_user", "done"]}, "why": {"type": "string"}}, "required": ["action", "why"]}},
"priority": {"title": "Priority", "type": "integer", "minimum": 1, "maximum": 10, "default": 5},
"owner": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": None},
"kind": {"const": "plan"}},
"required": ["goal", "steps", "kind"]}
PLANETS = {"type": "array", "minItems": 3, "maxItems": 3, "items": {"type": "object", "properties": {"planet": {"type": "string"}, "fact": {"type": "string"}},
"required": ["planet", "fact"], "additionalProperties": False}}
YESNO = {"enum": ["yes", "no"]}
APOLLO = {"type": "object", "properties": {"date": {"type": "string", "format": "date"}, "code": {"type": "string", "pattern": "^[A-Z]{3}-[0-9]{2}$"},
"crew": {"type": "array", "items": {"type": "string", "minLength": 2}, "minItems": 1, "maxItems": 3}}, "required": ["date", "code", "crew"]}
NUMBER = {"type": "integer", "minimum": 1, "maximum": 100}
HAIKU = {"type": "object", "properties": {"lines": {"type": "array", "items": {"type": "string", "maxLength": 40}, "minItems": 3, "maxItems": 3},
"score": {"enum": [0.5, 1.0, 2]}}, "required": ["lines"]}
def openai_rf(schema, name):
return {"response_format": {"type": "json_schema", "json_schema": {"name": name, "strict": True, "schema": schema}}}
u = lambda t: [{"role": "user", "content": t}]
# (name, messages, max_tokens, how the schema is sent)
CASES = [
("cat", u("Give me a JSON object describing a cat: its name, age and colour."), 64, openai_rf(CAT, "cat")),
("verdict", [{"role": "system", "content": SAVANTE}, {"role": "user", "content": "Is the Great Wall of China visible from the Moon with the naked eye?"}],
96, {"json_schema": VERDICT}),
("library", u("Describe a small library as JSON: its name, its address (street and city), and a list of three books, each with a title, "
"an author and a year."), 200, openai_rf(LIBRARY, "library")),
("plan", u("Plan how to write a short report on bees."), 128, {"response_format": {"type": "json_object", "schema": PLAN}}),
("planets", u("List three planets and one fact about each."), 96, openai_rf(PLANETS, "planets")),
("yesno", u("Is the sun a star?"), 16, {"json_schema": YESNO}),
("apollo", u("When did Apollo 11 land on the Moon? Give the date, a mission code and the crew."), 80, openai_rf(APOLLO, "apollo")),
("number", u("Pick a number between 1 and 100."), 16, {"response_format": {"type": "json_object", "schema": NUMBER}}),
("haiku", u("Write a haiku about autumn leaves."), 64, {"json_schema": HAIKU}),
# 0.3.5: answers cut by max_tokens β inside an object, inside a top-level string, inside a top-level number β so
# the content rule is measured on open values, and a top-level string and number that end on their own
("library_cut", u("Describe a small library as JSON: its name, its address (street and city), and a list of three books, each with a "
"title, an author and a year."), 24, openai_rf(LIBRARY, "library")),
("motto", u("Write a long motto for a library."), 10, {"json_schema": {"type": "string"}}),
("motto_short", u("Give a three-word motto for a library."), 40, {"json_schema": {"type": "string", "maxLength": 30}}),
("pi", u("Write the number pi to thirty decimal places."), 8, {"response_format": {"type": "json_object", "schema": {"type": "number"}}}),
("count", u("How many legs does a spider have?"), 16, {"json_schema": {"type": "number"}}),
]
def requests(stem):
ternary = "Ternary" in stem
small = "8B" not in stem # 0.3.5: the O4 models (Bonsai-1.7B, SmolLM2-135M-Instruct, mindx-gen39) are cheap: every case, every seed
reqs = []
for i, (name, msgs, n, how) in enumerate(CASES):
if ternary and name in ("library", "plan", "apollo", "haiku", "motto_short"):
continue
reqs.append((f"{name}-greedy", {"messages": msgs, **how, "temperature": 0.0, "max_tokens": n}))
seeded = ("cat", "yesno") if ternary else ("cat", "verdict", "planets", "yesno", "haiku", "motto", "count")
if small or name in seeded:
for v in (SEEDED[:1] if ternary else SEEDED if small else SEEDED[:2]):
reqs.append((f"{name}-t{v['temperature']}-s{v['seed']}", {"messages": msgs, **how, "max_tokens": n, **v}))
return reqs
def post_text(base, path, text, timeout=7200):
req = urllib.request.Request(base + path, text.encode(), {"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.load(r)
def record(stem):
lib = Path(os.environ.get("BANKML_GGML_LIB", ""))
model = root / ".models" / f"{stem}.gguf"
if not (lib / "llama-server").exists() or not model.exists():
sys.exit("needs BANKML_GGML_LIB (b11192 release dir) and " + str(model))
base = "http://127.0.0.1:18303"
proc = subprocess.Popen([str(lib / "llama-server"), "-m", str(model), "--host", "127.0.0.1", "--port", "18303", "-t", "3", "-c", "2048",
"-np", "1", "--jinja", "--reasoning", "off", "--no-webui", "--verbose"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
wait(base, proc)
cases = []
for name, body in requests(stem):
t0 = time.time()
text = json.dumps(body)
r = post_text(base, "/v1/chat/completions", json.dumps({**body, "cache_prompt": False, "return_tokens": True}))
v = r["__verbose"]
g = v["generation_settings"]
cases.append({"name": name, "request": body, "request_text": text, "tokens": v["tokens"], "raw": v["content"],
"content": r["choices"][0]["message"]["content"], "finish_reason": r["choices"][0]["finish_reason"],
"prompt_tokens": r["usage"]["prompt_tokens"], "completion_tokens": r["usage"]["completion_tokens"],
"grammar": g["grammar"], "grammar_lazy": g["grammar_lazy"], "generation_prompt": g["generation_prompt"],
"seed": g["seed"], "top_k": g["top_k"], "top_p": g["top_p"], "min_p": g["min_p"], "temperature": g["temperature"]})
print(f"{name}: {len(v['tokens'])} tokens, {r['choices'][0]['finish_reason']}, {time.time() - t0:.0f} s: "
f"{r['choices'][0]['message']['content'][:70]!r}", flush=True)
finally:
proc.terminate()
proc.wait()
out.mkdir(parents=True, exist_ok=True)
dest = out / f"schema-{stem}.jsonl"
dest.write_text("".join(json.dumps(c) + "\n" for c in cases))
print(f"{len(cases)} schema-constrained answers from llama-server b11192 ({stem}) β {dest}")
def schema_of(body):
if "json_schema" in body:
return body["json_schema"]
rf = body["response_format"]
return rf["json_schema"]["schema"] if rf["type"] == "json_schema" else rf["schema"]
def bankml(stem="Bonsai-8B-Q1_0", name=None):
model = root / ".models" / f"{stem}.gguf"
forks = Path(os.environ.get("BANKML_FORKS", Path.home() / ".local/share/bankml/forks"))
fork = Path(os.environ.get("BANKML_FORK", forks / f"{stem}.gguf.FORK.json")) if stem == "Bonsai-8B-Q1_0" else forks / f"{stem}.gguf.FORK.json"
rec = out / f"schema-{stem}.jsonl"
binary = root / "target" / "release" / "bankml"
missing = [str(p) for p in (model, fork, rec, binary) if not p.exists()]
if missing:
print("json schema live oracle skipped: missing " + ", ".join(missing))
return 0
cases = {c["name"]: c for c in map(json.loads, rec.read_text().splitlines())}
# one per request shape, a seeded one, the scalar contents; the first is also streamed and sent through /api/chat
pick = [cases[n] for n in ("cat-greedy", "verdict-t0.7-s11", "plan-greedy", "yesno-greedy", "number-greedy", "haiku-t1.0-s22",
"motto-greedy", "pi-greedy", "library_cut-greedy") if n in cases]
base, name = "http://127.0.0.1:18199", name or stem.lower()
proc = subprocess.Popen([str(binary), "serve", str(model), "--fork", str(fork), "--native", "--listen", "127.0.0.1:18199",
"--upstream", "127.0.0.1:18200", "--ctx", "2048"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
t0, n, ok = time.time(), 0, 0
try:
wait(base, proc)
def fresh():
assert post_text(base, "/api/generate", json.dumps({"model": name, "keep_alive": 0}))["done_reason"] == "unload"
assert post_text(base, "/api/generate", json.dumps({"model": name, "keep_alive": -1, "stream": False}))["done_reason"] == "load"
for c in pick:
body, text = c["request"], c["request_text"]
paths = [("v1", False), ("v1", True), ("api", False)] if c is pick[0] else [("v1", False), ("api", False)]
for path, stream in paths:
fresh()
if path == "v1" and stream:
req = urllib.request.Request(base + "/v1/chat/completions", (text[:-1] + ', "stream": true}').encode(), {"Content-Type": "application/json"})
acc, fin, usage = "", None, None
with urllib.request.urlopen(req, timeout=7200) as r:
for line in r.read().decode().splitlines():
if line.startswith("data: {") and '"choices"' in line:
d = json.loads(line[6:])
acc += d["choices"][0]["delta"].get("content", "")
fin = d["choices"][0]["finish_reason"] or fin
usage = d.get("usage") or usage
got = (acc, fin, usage["prompt_tokens"], usage["completion_tokens"])
elif path == "v1":
r = post_text(base, "/v1/chat/completions", text)
got = (r["choices"][0]["message"]["content"], r["choices"][0]["finish_reason"], r["usage"]["prompt_tokens"], r["usage"]["completion_tokens"])
else:
o = {"model": name, "messages": body["messages"], "format": schema_of(body), "stream": False, "keep_alive": -1,
"options": {k: body[k] for k in ("temperature", "seed", "top_k") if k in body} | {"num_predict": body["max_tokens"]}}
r = post_text(base, "/api/chat", json.dumps(o))
got = (r["message"]["content"], r["done_reason"], r["prompt_eval_count"], r["eval_count"])
want = (c["content"], c["finish_reason"], c["prompt_tokens"], c["completion_tokens"])
n += 1
ok += got == want
if got != want:
print(f" {c['name']} via {path}{' (stream)' if stream else ''}: got {got!r:.160} want {want!r:.160}")
finally:
proc.terminate()
proc.wait()
print(f"json schema live oracle: {ok} of {n} answers through bankml serve --native (/v1 response_format json_schema, top-level "
f"json_schema and json_object with a schema, one streamed; /api/chat format <schema>) identical to llama-server b11192's record "
f"(content, finish, prompt/completion counts) β {time.time() - t0:.0f} s")
return 0 if ok == n else 1
if __name__ == "__main__":
if "--record" in sys.argv:
record(sys.argv[sys.argv.index("--record") + 1])
elif "--bankml" in sys.argv:
sys.exit(bankml(*sys.argv[sys.argv.index("--bankml") + 1:][:2]))
else:
sys.exit(__doc__)
|