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__)