File size: 10,925 Bytes
0072adb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Answer a request of the SystemOne-compatible request shape with the clef-flash LiteRT graphs of this folder, or check
this folder against the provider's float32 reference.

Run from the repository root, in a Python with host/requirements-host.txt installed (examples/README.md):
    python examples/run_example.py --check                                # the GPU at float32 precision (Metal on a Mac)
    python examples/run_example.py --check --accelerator cpu              # the CPU (XNNPACK, 8 threads)
    python examples/run_example.py --request examples/request.json --image fixtures/images/clock_3oclock_256x240.png --tile 256
    python examples/run_example.py --request my_request.json [--image photo.jpg] [--accelerator cpu]

A request is {"model": str, "state": text or any JSON value, "questions": {id: {"type": "noul" | "choice" | "score",
"instructions": str, "criteria": ...}}}: choice criteria {option id: description}, score criteria a list of levels
(lowest first), noul criteria optional {"true": ..., "false": ...}. --image adds one picture (or put a local path in
"images": [...]), read at a square tile of 448 px (196 picture tokens, the default) or 256 px (64). The response body
is printed as JSON on stdout. A request the host refuses prints `refused: <error>: <message>` on stderr: RequestTooLong
(more than 4,096 tokens, the largest row graph), UnsupportedMedia (more than one picture, videos, media_kwargs, a
picture that cannot be opened, a URL, a tile other than 256 / 448) and the provider's own checks (MissingModelOrState,
NoQuestions, BadQuestionType, EmptyCriteria, SchemaTooLong).

--check runs the four public fixture requests named in examples/run_example.expected.json (a SemIf record, a support
ticket with noul / choice / score questions, a JSON order, and a drawn picture at the 256 px tile; the row graphs of
lengths 256 and 512) and compares them with the provider's own code in float32 on the CPU: on every question whose two
most likely options are more than 0.02 apart the most likely option is the same, every probability is within 0.02, the
mean over the requests of each request's mean |difference| is at most 0.002 (the acceptance line of this conversion),
and the input tokens, row graph length and answer fields are equal; then it sends the refusal requests of REFUSALS and
compares each error's name (and message, where it names no path). It prints one line, PASS or FAIL.

Exit codes: 0 answered / PASS, 1 check FAIL, 2 bad arguments, 3 refused request, 4 a file of the folder is missing."""
from __future__ import annotations

import argparse
import copy
import json
import sys
import time
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "host"))

import clef_litert as C  # noqa: E402

EXPECTED = ROOT / "examples/run_example.expected.json"
PICTURE = "fixtures/images/shapes_224.png"


def _refusals() -> dict:
    """name -> (request, tile): one request for each refusal the host names (the --check part)."""
    base = {"model": "clef-flash", "state": "The kettle stopped heating after a week.",
            "questions": {"faulty": {"type": "noul", "instructions": "The product is faulty."}}}
    long_state = {"room": "cold-room-2", "unit": "celsius",
                  "readings": [{"minute": i, "value": round(3.0 + (i % 9) * 0.1, 1)} for i in range(400)]}
    many = {f"option_{i:04d}": f"Description number {i} of a very long list of options" for i in range(1500)}

    def edit(**kw):
        r = copy.deepcopy(base)
        for k, v in kw.items():
            if v is None:
                r.pop(k, None)
            else:
                r[k] = v
        return r

    pic = str(ROOT / PICTURE)
    return {
        "too_long": (edit(state=long_state), 448),
        "two_pictures": (edit(images=[pic, pic]), 448),
        "videos": (edit(videos=["clip.mp4"]), 448),
        "media_kwargs": (edit(media_kwargs={"max_pixels": 1000}), 448),
        "url_picture": (edit(images=["https://example.com/picture.png"]), 448),
        "unreadable_picture": (edit(images=[str(ROOT / "examples/request.json")]), 448),
        "tile_300": (edit(images=[pic]), 300),
        "no_model": (edit(model=None), 448),
        "no_questions": (edit(questions={}), 448),
        "bad_type": (edit(questions={"faulty": {"type": "boolean"}}), 448),
        "empty_criteria": (edit(questions={"team": {"type": "choice", "criteria": {}}}), 448),
        "schema_too_long": (edit(questions={"team": {"type": "choice", "criteria": many}}), 448),
    }


def load_folder(accelerator: str, threads: int) -> C.ClefLiteRT:
    missing = [p for p in (C.FOLDER["tokenizer"], C.FOLDER["embed_tokens"], C.FOLDER["lm_head"],
                           C.FOLDER["joint_head"], C.FOLDER["joint_head_config"]) if not (ROOT / p).is_file()]
    if missing:
        print(f"missing in {ROOT}: {', '.join(missing)}", file=sys.stderr)
        raise SystemExit(4)
    try:
        return C.ClefLiteRT.from_dir(ROOT, accelerator=accelerator, threads=threads)
    except ValueError as e:                      # not one row graph file in the folder
        print(f"{e} in {ROOT}", file=sys.stderr)
        raise SystemExit(4)


def answer(args) -> int:
    request = json.loads(Path(args.request).read_text())
    if args.image:
        request["images"] = list(request.get("images") or []) + [args.image]
    with load_folder(args.accelerator, args.threads) as clef:
        t = time.perf_counter()
        try:
            body = clef.decide(request, tile=args.tile)
        except C.RequestError as e:
            print(f"refused: {type(e).__name__}: {e}", file=sys.stderr)
            return 3
        seconds = time.perf_counter() - t
    print(json.dumps(body, indent=1, ensure_ascii=False))
    print(f"# {seconds:.1f} s on the {args.accelerator}, with the compile of the graphs this request needed",
          file=sys.stderr)
    return 0


def check(args) -> int:
    expected = json.loads(EXPECTED.read_text())
    records = {r["id"]: r for r in json.loads((ROOT / "fixtures/requests_public.json").read_text())["records"]}
    problems, worst, means = [], (0.0, ""), []
    with load_folder(args.accelerator, args.threads) as clef:
        kept = {}
        row_graph = clef.graphs.row_graph

        def keep_hidden(length, inputs):         # the hidden rows decide() reads, kept for the unrounded check
            kept["hidden"] = row_graph(length, inputs)
            return kept["hidden"]

        clef.graphs.row_graph = keep_hidden
        for item in sorted(expected["requests"], key=lambda x: x["length"]):
            name = item["record"]
            request = copy.deepcopy(records[name]["request"])
            if item.get("image"):
                request["images"] = [str(ROOT / item["image"])]
            body = clef.decide(request, tile=item["tile"])
            enc = clef.host.encode(request, tile=item["tile"])
            logits = clef.host.scores(enc, kept["hidden"])
            if clef.host.respond(request, enc, logits) != body:
                problems.append(f"{name}: the response is not the one of its own probabilities")
            if (enc.record.tokens, enc.length) != (item["input_tokens"], item["length"]):
                problems.append(f"{name}: {enc.record.tokens} tokens on L{enc.length}, expected {item['input_tokens']} "
                                f"on L{item['length']}")
            if body["usage"] != {"input_tokens": item["input_tokens"], "output_tokens": 0}:
                problems.append(f"{name}: usage {body['usage']}")
            diffs = []
            for q, z in zip(enc.record.questions, logits):
                e = item["questions"][q.question_id]
                got = dict(zip(q.option_ids, (float(x) for x in C.question_probs(z))))
                if list(got) != e["option_ids"]:
                    problems.append(f"{name}/{q.question_id}: options {list(got)}")
                    continue
                d = [abs(got[o] - r) for o, r in zip(e["option_ids"], e["probabilities"])]
                diffs += d
                if max(d) > worst[0]:
                    worst = (max(d), f"{name}/{q.question_id}")
                top = max(got, key=got.__getitem__)
                if not e["near_tie"] and top != e["argmax"]:
                    problems.append(f"{name}/{q.question_id}: most likely {top}, expected {e['argmax']}")
                if max(d) > expected["line"]["max_abs_dp"]:
                    problems.append(f"{name}/{q.question_id}: |dp| {max(d):.2e}")
                a = body["answers"][q.question_id]
                if [a["type"], sorted(a)] != [e["type"], e["answer_fields"]]:
                    problems.append(f"{name}/{q.question_id}: answer {a}")
            means.append(sum(diffs) / max(len(diffs), 1))
            print(f"# {name}: {enc.record.tokens} tokens, L{enc.length}" + (f", {enc.tile} px tile" if enc.tile else "")
                  + f", max |dp| {max(diffs):.2e}", file=sys.stderr)
        mean = sum(means) / len(means)
        if mean > expected["line"]["mean_of_request_means"]:
            problems.append(f"mean of the requests' mean |dp| {mean:.2e}")
        cases = _refusals()
        for name, want in expected["refusals"].items():
            request, tile = cases[name]
            try:
                clef.decide(request, tile=tile)
                problems.append(f"refusal {name}: answered")
            except C.RequestError as e:
                if type(e).__name__ != want["error"] or (want["message"] is not None and str(e) != want["message"]):
                    problems.append(f"refusal {name}: {type(e).__name__}: {e}")
    verdict = "PASS" if not problems else "FAIL"
    print(f"{verdict} ({args.accelerator}): {len(expected['requests'])} requests, max |dp| {worst[0]:.2e} ({worst[1]}), "
          f"mean of the requests' mean |dp| {mean:.2e}, {len(expected['refusals'])} refusals named"
          + ("" if not problems else "; " + "; ".join(problems)))
    return 0 if not problems else 1


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    ap.add_argument("--request", help="a request JSON file")
    ap.add_argument("--image", help="one picture for the request (a local file)")
    ap.add_argument("--tile", type=int, default=448, help="the picture's square tile: 448 (default) or 256")
    ap.add_argument("--accelerator", choices=["gpu", "cpu"], default="gpu",
                    help="gpu = float32 precision (Metal on a Mac, the default), cpu = XNNPACK")
    ap.add_argument("--threads", type=int, default=8, help="CPU threads (default 8)")
    ap.add_argument("--check", action="store_true", help="compare with examples/run_example.expected.json")
    a = ap.parse_args()
    if a.check == bool(a.request):
        ap.error("give --request or --check")
    return check(a) if a.check else answer(a)


if __name__ == "__main__":
    sys.exit(main())