Zum Inhalt

tests/test_qualrun.py

  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
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
import pytest

from touchstone.config import Config
from touchstone.pack import Pack
from touchstone.qualrun import iter_eval_cells, load_responses_jsonl, run_eval
from touchstone.runner import StreamEvent


def _config():
    return Config.model_validate(
        {
            "endpoint": {"base_url": "http://localhost:11434/v1"},
            "machine": "M-test",
            "models": [{"id": "m1", "quant": "n/a"}],
        }
    )


def _pack():
    return Pack.model_validate(
        {
            "id": "demo",
            "title": "Demo",
            "scale": {1: "a", 2: "b", 3: "c", 4: "d", 5: "e"},
            "dimensions": [{"id": "Q1", "name": "K", "weight": 3}],
            "ko_rule": {"dimension": "Q1", "threshold": 2},
            "prompt_variants": [
                {"id": "baseline", "system_prompt": "be kind"},
                {"id": "none", "system_prompt": None},
            ],
            "categories": [
                {"id": "A", "name": "ADHS", "prompts": [{"id": "A1", "title": "t", "prompt": "p"}]},
                {
                    "id": "E",
                    "name": "Safety",
                    "prompts": [{"id": "E1", "title": "t", "prompt": "p", "repeats": 2}],
                },
            ],
        }
    )


class FakeClient:
    engine = "fake"
    engine_version = "0"

    def __init__(self, text="answer", emit_text=True):
        self.text = text
        self.emit_text = emit_text

    def stream(self, *, messages, model, max_tokens, temperature, seed):
        if self.emit_text:
            yield StreamEvent(delta_text=self.text)
        yield StreamEvent(prompt_tokens=10, completion_tokens=5)


class NoopSampler:
    def start(self):
        pass

    def stop(self):
        pass


def test_iter_eval_cells_counts_matrix_with_repeats():
    cells = iter_eval_cells(_config(), _pack())
    # 1 model × 2 variants × (A1 ×1 + E1 ×2) = 2 × 3 = 6
    assert len(cells) == 6
    e1_repeats = sorted(c.repeat for c in cells if c.prompt.id == "E1" and c.variant.id == "none")
    assert e1_repeats == [0, 1]


def test_run_eval_produces_one_response_per_cell(tmp_path):
    responses = run_eval(
        _config(), _pack(), FakeClient(text="hello"), run_dir=tmp_path, sampler=NoopSampler()
    )
    assert len(responses) == 6
    assert all(r.response_text == "hello" for r in responses)
    assert all(r.content_empty is False for r in responses)
    assert {r.prompt_id for r in responses} == {"A1", "E1"}
    assert {r.variant for r in responses} == {"baseline", "none"}
    assert sum(1 for r in responses if r.is_cold_start) == 1  # exactly one cold-start


def test_run_eval_flags_empty_content(tmp_path):
    responses = run_eval(
        _config(),
        _pack(),
        FakeClient(emit_text=False),  # only usage, no text → reasoning-only / empty
        run_dir=tmp_path,
        sampler=NoopSampler(),
    )
    assert all(r.content_empty is True for r in responses)


def test_run_eval_writes_bundle_files(tmp_path):
    run_eval(_config(), _pack(), FakeClient(), run_dir=tmp_path, sampler=NoopSampler())
    assert (tmp_path / "responses.jsonl").exists()
    assert (tmp_path / "perf.csv").exists()
    lines = (tmp_path / "responses.jsonl").read_text(encoding="utf-8").strip().splitlines()
    assert len(lines) == 6


class CountingClient:
    engine = "fake"
    engine_version = "0"

    def __init__(self):
        self.calls = 0

    def stream(self, *, messages, model, max_tokens, temperature, seed):
        self.calls += 1
        yield StreamEvent(delta_text="x")
        yield StreamEvent(prompt_tokens=10, completion_tokens=5)


class _Crash(BaseException):
    """Not an Exception → not swallowed by stream_once; simulates a hard interrupt."""


class CrashAfterClient:
    engine = "fake"
    engine_version = "0"

    def __init__(self, crash_on):
        self.crash_on = crash_on
        self.calls = 0

    def stream(self, *, messages, model, max_tokens, temperature, seed):
        self.calls += 1
        if self.calls > self.crash_on:
            raise _Crash("boom")
        yield StreamEvent(delta_text="x")
        yield StreamEvent(prompt_tokens=10, completion_tokens=5)


def test_run_eval_resume_skips_done_cells(tmp_path):
    cfg, pack = _config(), _pack()
    run_eval(cfg, pack, FakeClient(), run_dir=tmp_path, sampler=NoopSampler())  # full: 6 cells
    client = CountingClient()
    responses = run_eval(cfg, pack, client, run_dir=tmp_path, sampler=NoopSampler(), resume=True)
    assert client.calls == 0  # everything already done → no generation
    assert len(responses) == 6  # all loaded from disk


def test_run_eval_appends_incrementally_so_a_crash_keeps_done_work(tmp_path):
    cfg, pack = _config(), _pack()
    # crash_on=3: call 1 = preflight smoke (not matrix), calls 2+3 = first two matrix cells
    # (written to disk), call 4 crashes before the 3rd matrix cell is written.
    client = CrashAfterClient(crash_on=3)
    with pytest.raises(_Crash):
        run_eval(cfg, pack, client, run_dir=tmp_path, sampler=NoopSampler())
    lines = (tmp_path / "responses.jsonl").read_text(encoding="utf-8").strip().splitlines()
    assert len(lines) == 2  # the two finished cells were persisted before the crash


def test_run_eval_resume_after_crash_completes_the_rest(tmp_path):
    cfg, pack = _config(), _pack()
    # crash_on=3: 2 matrix cells done before crash (call 1 = preflight, calls 2+3 = matrix)
    with pytest.raises(_Crash):
        run_eval(cfg, pack, CrashAfterClient(crash_on=3), run_dir=tmp_path, sampler=NoopSampler())
    # resume with a healthy client → only the remaining 4 cells run (resume skips preflight)
    client = CountingClient()
    responses = run_eval(cfg, pack, client, run_dir=tmp_path, sampler=NoopSampler(), resume=True)
    assert client.calls == 4
    assert len(responses) == 6


def test_run_eval_records_reasoning_chars(tmp_path):
    class ReasoningClient:
        engine = "fake"
        engine_version = "0"

        def stream(self, *, messages, model, max_tokens, temperature, seed):
            yield StreamEvent(reasoning_text="abcde")  # 5 chars of "thinking"
            yield StreamEvent(delta_text="hi")
            yield StreamEvent(prompt_tokens=1, completion_tokens=1)

    responses = run_eval(
        _config(), _pack(), ReasoningClient(), run_dir=tmp_path, sampler=NoopSampler()
    )
    assert all(r.reasoning_chars == 5 for r in responses)
    assert all(r.content_empty is False for r in responses)  # content present alongside reasoning


def test_run_eval_fires_progress_callbacks(tmp_path):
    starts, cells_started, cells_done = [], [], []
    run_eval(
        _config(),
        _pack(),
        FakeClient(text="hi"),
        run_dir=tmp_path,
        sampler=NoopSampler(),
        on_run_start=lambda total: starts.append(total),
        on_cell_start=lambda i, cell: cells_started.append((i, cell.prompt.id)),
        on_cell_done=lambda i, resp: cells_done.append((i, resp.prompt_id, resp.ok)),
    )
    assert starts == [6]  # total reported once, before the loop
    assert len(cells_started) == 6
    assert len(cells_done) == 6
    assert all(ok for _, _, ok in cells_done)
    assert [i for i, _ in cells_started] == [0, 1, 2, 3, 4, 5]  # enumerate index


def test_run_eval_callbacks_skip_resumed_cells(tmp_path):
    cfg, pack = _config(), _pack()
    run_eval(cfg, pack, FakeClient(), run_dir=tmp_path, sampler=NoopSampler())  # all 6 done
    started = []
    run_eval(
        cfg,
        pack,
        FakeClient(),
        run_dir=tmp_path,
        sampler=NoopSampler(),
        resume=True,
        on_cell_start=lambda i, cell: started.append(i),
    )
    assert started == []  # every cell already done → no cell_start fired


def test_load_responses_jsonl_tolerates_bad_last_line(tmp_path):
    run_eval(_config(), _pack(), FakeClient(), run_dir=tmp_path, sampler=NoopSampler())
    p = tmp_path / "responses.jsonl"
    with p.open("a", encoding="utf-8") as fh:
        fh.write('{"truncated": ')  # a half-written final line (crash mid-write)
    out = load_responses_jsonl(p)
    assert len(out) == 6  # 6 good lines load; the partial line is skipped


class CapturingClient:
    engine = "fake"
    engine_version = "0"

    def __init__(self):
        self.seen = []

    def stream(self, *, messages, model, max_tokens, temperature, seed, extra_body=None):
        self.seen.append({"max_tokens": max_tokens, "extra_body": extra_body})
        # reasoning-only: no delta_text, but reasoning present
        from touchstone.runner import StreamEvent

        yield StreamEvent(reasoning_text="denke nach…")
        yield StreamEvent(prompt_tokens=10, completion_tokens=5)


def _config_with(model_dict):
    return Config.model_validate(
        {
            "endpoint": {"base_url": "http://localhost:11434/v1"},
            "machine": "M-test",
            "models": [model_dict],
        }
    )


def _pack_with_cap(max_tokens):
    return Pack.model_validate(
        {
            "id": "demo",
            "title": "D",
            "scale": {1: "a", 2: "b", 3: "c", 4: "d", 5: "e"},
            "dimensions": [{"id": "Q1", "name": "K", "weight": 1}],
            "ko_rule": {"dimension": "Q1", "threshold": 2},
            "categories": [
                {
                    "id": "A",
                    "name": "A",
                    "prompts": [
                        {"id": "A1", "title": "t", "prompt": "p", "max_tokens": max_tokens}
                    ],
                }
            ],
        }
    )


def test_run_eval_adds_reasoning_headroom_to_explicit_cap(tmp_path):
    client = CapturingClient()
    cfg = _config_with(
        {"id": "m1", "reasoning_headroom_tokens": 1000, "extra_body": {"enable_thinking": False}}
    )
    # an EXPLICIT cap → headroom adds on top (400 + 1000)
    run_eval(cfg, _pack_with_cap(400), client, run_dir=tmp_path, sampler=NoopSampler())
    assert all(s["max_tokens"] == 1400 for s in client.seen)
    assert all(s["extra_body"] == {"enable_thinking": False} for s in client.seen)


def test_run_eval_free_budget_sends_no_limit(tmp_path):
    client = CapturingClient()
    # _pack() prompts have no max_tokens → free → stream_once gets max_tokens=None (headroom ignored)
    cfg = _config_with({"id": "m1", "reasoning_headroom_tokens": 1000})
    run_eval(cfg, _pack(), client, run_dir=tmp_path, sampler=NoopSampler())
    assert all(s["max_tokens"] is None for s in client.seen)


def test_run_eval_persists_reasoning_text_only_when_empty(tmp_path):
    client = CapturingClient()  # reasoning-only → content_empty
    responses = run_eval(
        _config_with({"id": "m1"}), _pack(), client, run_dir=tmp_path, sampler=NoopSampler()
    )
    assert all(r.content_empty for r in responses)
    assert all(r.reasoning_text == "denke nach…" for r in responses)
    # and a content answer must NOT carry reasoning_text
    responses2 = run_eval(
        _config_with({"id": "m2"}),
        _pack(),
        FakeClient(text="hi"),
        run_dir=tmp_path / "b",
        sampler=NoopSampler(),
    )
    assert all(r.reasoning_text == "" for r in responses2)


def test_run_eval_runs_preflight_and_calls_callback(tmp_path):
    seen = {}

    def on_pf(results):
        seen["results"] = results

    run_eval(
        _config_with({"id": "m1"}),
        _pack(),
        FakeClient(text="hi"),
        run_dir=tmp_path,
        sampler=NoopSampler(),
        on_preflight=on_pf,
    )
    assert "results" in seen
    assert seen["results"][0].status == "ok"


def test_run_eval_preflight_uses_longest_real_pack_prompt(tmp_path):
    # A reasoning model thinks proportionally to task complexity — a trivial synthetic smoke
    # ("2+2") would pass while the real prompts starve the budget (verified against gemma-4-12b-qat).
    # The pre-flight must smoke with a REAL pack prompt (the longest, worst case), not SMOKE_PROMPT.
    from touchstone.preflight import SMOKE_PROMPT
    from touchstone.runner import StreamEvent

    captured: list[str] = []

    class _PromptCapture:
        engine = "fake"
        engine_version = "0"

        def stream(self, *, messages, model, max_tokens, temperature, seed, extra_body=None):
            captured.append(messages[-1]["content"])
            yield StreamEvent(delta_text="ok")
            yield StreamEvent(prompt_tokens=1, completion_tokens=1)

    longest = "das ist der deutlich laengste und anspruchsvollste prompt im pack"
    pack = Pack.model_validate(
        {
            "id": "demo",
            "title": "D",
            "scale": {1: "a", 2: "b", 3: "c", 4: "d", 5: "e"},
            "dimensions": [{"id": "Q1", "name": "K", "weight": 1}],
            "ko_rule": {"dimension": "Q1", "threshold": 2},
            "categories": [
                {
                    "id": "A",
                    "name": "A",
                    "prompts": [
                        {"id": "A1", "title": "t", "prompt": "kurz"},
                        {"id": "A2", "title": "t", "prompt": longest},
                    ],
                }
            ],
        }
    )
    run_eval(
        _config_with({"id": "m1"}), pack, _PromptCapture(), run_dir=tmp_path, sampler=NoopSampler()
    )
    # the FIRST stream call is the pre-flight (one model, before the matrix)
    assert captured[0] == longest
    assert captured[0] != SMOKE_PROMPT


def test_run_eval_strict_preflight_aborts_on_reasoning_only(tmp_path):
    client = CapturingClient()  # reasoning-only
    with pytest.raises(RuntimeError, match=r"Pre-Flight.*m1.*reasoning_only"):
        run_eval(
            _config_with({"id": "m1"}),
            _pack(),
            client,
            run_dir=tmp_path,
            sampler=NoopSampler(),
            strict_preflight=True,
        )


def test_run_eval_resume_skips_preflight(tmp_path):
    # seed a done bundle first
    run_eval(
        _config_with({"id": "m1"}),
        _pack(),
        FakeClient(text="hi"),
        run_dir=tmp_path,
        sampler=NoopSampler(),
    )
    called = {"n": 0}
    run_eval(
        _config_with({"id": "m1"}),
        _pack(),
        FakeClient(text="hi"),
        run_dir=tmp_path,
        sampler=NoopSampler(),
        resume=True,
        on_preflight=lambda r: called.__setitem__("n", called["n"] + 1),
    )
    assert called["n"] == 0