oc_chat
Loading...
Searching...
No Matches
combine.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# bench/argv_policy/combine.py
4
5"""
6Compose two committed result files into one: the first decides, the second is
7consulted only where the first is unsure.
8
9Purpose
10-------
11Every leg of this bench so far has measured one model alone. The escalation
12column says what a model would hand to a human below a confidence threshold,
13and plan 293 showed that Jev, asked to flag below 0.9, catches every one of its
14own permission-granting errors. The fine-tuned encoders beat Jev on agreement
15at half the latency and no per-call cost, and at 0.9 they hand 42 to 51 of 300
16hard-set decisions upwards. Nobody has measured what happens when those
17escalated decisions go to Jev instead of to a human.
18
19Every piece of that measurement is already on disk, so this is a join rather
20than a run: the encoder result files and the Jev result files carry the same
21commands in the same order, and a combination is a pure function of two rows
22and a threshold. Nothing here opens a socket, loads a checkpoint or touches a
23GPU; the two inputs are read-only and the output is a result file in the bench
24schema that :mod:`score` reads unchanged.
25
26A row is UNSURE when the first model's top probability is strictly below the
27threshold or the row has no usable ``probs``. At or above the threshold every
28rule keeps the first model's verdict and ``probs`` untouched, so the four rules
29differ only in what they do with the unsure rows:
30
31``escalate``
32 The unsure row becomes ``confirm`` with no ``probs`` and nothing is
33 consulted. This is the CONTROL: the encoder alone with a human behind it,
34 which is the operating point plans 293 and 300 already recommend.
35``replace``
36 The unsure row takes the second model's verdict and ``probs``.
37``stricter``
38 The unsure row takes whichever of the two verdicts grants LESS permission,
39 with the ``probs`` of the model whose verdict was taken.
40``disagree``
41 The unsure row keeps the first's verdict and ``probs`` where the two agree,
42 and becomes ``confirm`` with no ``probs`` where they differ.
43
44``--sweep`` writes ``results/combined_sweep.md``: the same compositions at six
45thresholds, and the movement across the repeated Jev runs, scored in memory so
46that a threshold curve costs no committed files. It carries no generation date
47precisely so that a second run reproduces it byte for byte.
48
49Run it under the project venv from the subproject root::
50
51 python bench/argv_policy/combine.py --first results/encoder_small-7-c299_hard.json \\
52 --second results/jev_direct_hard.json --rule replace --threshold 0.9 \\
53 --out results/combined_encoder_small-7-c299_replace_t90_hard.json
54
55 python bench/argv_policy/combine.py --sweep
56
57Responsibilities
58----------------
59- :func:`rule_escalate` / :func:`rule_replace` / :func:`rule_stricter` /
60 :func:`rule_disagree` -- the four rules, each a pure function of one first
61 row, one second row and the threshold.
62- :func:`join_rows` -- the command-keyed join, and the refusal when the two
63 files do not carry the same commands.
64- :func:`build_payload` -- two payloads to one result payload with provenance.
65- :func:`render_sweep` -- the generated markdown of the threshold sweep and the
66 repeated-run movement.
67- :func:`main` -- the CLI.
68
69Diagnostics
70-----------
71Domain: OC_CHAT.MAIN
72Levels:
73 L2 - combination lifecycle (files read, file written, sweep rendered)
74 L3 - per-combination counts (consulted, disagreeing)
75 L4 - module load
76
77Contracts
78---------
79- Read-only in both inputs: a combination never rewrites a result file, and the
80 only paths written are the ``--out`` target and the sweep document.
81- No network, no torch, no key: the Jev files ARE the Jev runs.
82- The permission ordering is :data:`score.PERMISSIVENESS` itself, imported
83 rather than restated, so ``stricter`` can never disagree with the direction
84 columns the scorer reports.
85- A threshold comparison is STRICT: a top probability exactly equal to the
86 threshold is sure, which is the same reading ``score.py`` uses for its
87 escalation count.
88- Provenance stores each input path RELATIVE to the bench directory when it
89 lies inside it, so a committed combination is reproducible from any checkout
90 rather than naming the worktree it was made in.
91- Console output is ASCII only; commands are printed through :func:`ascii`.
92
93Dependencies
94------------
95- oc_diagnostics (structured debug logging via _dbg)
96- score (the bench scorer, for PERMISSIVENESS and for the sweep's arithmetic)
97"""
98from __future__ import annotations
99
100import argparse
101import datetime
102import hashlib
103import json
104import sys
105from dataclasses import dataclass
106from pathlib import Path
107from typing import Any, Callable
108
109from oc_diagnostics import _dbg
110
111from score import PERMISSIVENESS, Score, score_payload
112
113_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
114
115#: One row of a result file, and one whole result file. Both are JSON read off
116#: disk, so their value types are genuinely unknown to this module: the schema
117#: lives in the runners that wrote them and in ``score.py`` that reads them.
118Row = dict[str, Any]
119Payload = dict[str, Any]
120
121#: The four combination rules, in the order the tables report them.
122RULES: tuple[str, ...] = ("escalate", "replace", "stricter", "disagree")
123
124#: The rules that read the second model at all. ``escalate`` is the control and
125#: consults nothing, so a second file is optional for it.
126CONSULTING_RULES: tuple[str, ...] = ("replace", "stricter", "disagree")
127
128#: The verdict an unsure row is turned into when a rule sends it to a human.
129ESCALATION_VERDICT = "confirm"
130
131#: The rank given to a verdict outside the three labels. It is MORE permissive
132#: than ``allow`` so that ``stricter`` never prefers an unrecognised verdict,
133#: which is the same direction ``score.error_direction`` rounds in.
134UNKNOWN_RANK: int = max(PERMISSIVENESS.values()) + 1
135
136#: Default confidence below which the first model is treated as unsure. The
137#: same figure as ``score.DEFAULT_THRESHOLD``, and the plan's main tables.
138DEFAULT_THRESHOLD = 0.9
139
140#: The three firsts the sweep reads, by result-file stem, hard set then hand.
141SWEEP_FIRSTS: tuple[str, ...] = (
142 "encoder_small-7-c297",
143 "encoder_small-7-c299",
144 "encoder_xsmall-7-c297-sc4-dw1",
145)
146
147#: The repeated Jev runs on the hard set, first run first.
148SWEEP_SECONDS_HARD: tuple[str, ...] = (
149 "jev_direct_hard",
150 "jev_direct_hard_rep1",
151 "jev_direct_hard_rep2",
152)
153
154#: The repeated Jev runs on the hand set, first run first.
155SWEEP_SECONDS_HAND: tuple[str, ...] = ("jev_direct_hand", "jev_direct_hand_ref")
156
157#: The thresholds the sweep walks.
158SWEEP_THRESHOLDS: tuple[float, ...] = (0.5, 0.7, 0.8, 0.9, 0.95, 0.99)
159
160
161class CombineError(Exception):
162 """A combination that cannot be made: a bad join, or a missing input."""
163
164
165@dataclass(frozen=True)
167 """What one rule did with one pair of rows."""
168
169 pred: str
170 probs: dict[str, float] | None
171 consulted: bool
172
173
174def top_probability(row: Row) -> float | None:
175 """The row's top forecast probability, or ``None`` when it has none.
176
177 The same reading ``score.py`` takes of the same field, and the tests assert
178 that equality on a battery of rows rather than trusting this comment: a row
179 whose ``probs`` is missing, empty or not a mapping has no forecast at all,
180 and is not a zero-confidence one.
181 """
182
183 probs = row.get("probs")
184 if not isinstance(probs, dict) or not probs:
185 return None
186 values = [float(value) for value in probs.values()]
187 return max(values) if values else None
188
189
190def is_unsure(row: Row, threshold: float) -> bool:
191 """Whether the first model is unsure enough on this row to be combined.
192
193 Strictly below the threshold, so a top probability exactly equal to it is
194 SURE and the row passes through untouched. A row with no usable forecast is
195 unsure: a model that produced no distribution has expressed no confidence,
196 and reading that as certainty is the one direction this must not round in.
197 """
198
199 top = top_probability(row)
200 return top is None or top < threshold
201
202
203def _usable_probs(row: Row) -> dict[str, float] | None:
204 """A copy of the row's ``probs``, or ``None`` when it has none worth carrying."""
205
206 probs = row.get("probs")
207 if not isinstance(probs, dict) or not probs:
208 return None
209 return dict(probs)
210
211
212def _rank(prediction: str) -> int:
213 """How much permission one verdict grants, under the scorer's own ordering."""
214
215 return PERMISSIVENESS.get(prediction, UNKNOWN_RANK)
216
217
218def _keep_first(first: Row) -> Decision:
219 """The sure case, which every rule shares: the first model, untouched."""
220
221 return Decision(str(first["pred"]), _usable_probs(first), False)
222
223
224def rule_escalate(first: Row, second: Row | None,
225 threshold: float) -> Decision:
226 """The control: an unsure row goes to a human, and nothing is consulted.
227
228 ``second`` is accepted and deliberately unread. Every rule shares one
229 signature so that the sweep can run all four over the same joined rows, and
230 the control is the rule that has a second opinion available and declines to
231 ask for it -- which is exactly the operating point it controls for.
232 """
233
234 if not is_unsure(first, threshold):
235 return _keep_first(first)
236 return Decision(ESCALATION_VERDICT, None, False)
237
238
239def rule_replace(first: Row, second: Row | None,
240 threshold: float) -> Decision:
241 """An unsure row takes the second model's verdict and its ``probs``."""
242
243 if not is_unsure(first, threshold):
244 return _keep_first(first)
245 if second is None:
246 raise CombineError("rule 'replace' needs a second result file")
247 return Decision(str(second["pred"]), _usable_probs(second), True)
248
249
250def rule_stricter(first: Row, second: Row | None,
251 threshold: float) -> Decision:
252 """An unsure row takes whichever verdict grants less permission.
253
254 The ``probs`` travel with the verdict that was taken, so the forecast a row
255 carries is always the forecast of the model that decided it. Where the two
256 agree -- including where they agree on a verdict neither label set knows --
257 the first model's forecast is kept, because it is the model that would have
258 answered anyway.
259 """
260
261 if not is_unsure(first, threshold):
262 return _keep_first(first)
263 if second is None:
264 raise CombineError("rule 'stricter' needs a second result file")
265 first_pred, second_pred = str(first["pred"]), str(second["pred"])
266 if _rank(second_pred) < _rank(first_pred):
267 return Decision(second_pred, _usable_probs(second), True)
268 return Decision(first_pred, _usable_probs(first), True)
269
270
271def rule_disagree(first: Row, second: Row | None,
272 threshold: float) -> Decision:
273 """An unsure row survives agreement and goes to a human on disagreement."""
274
275 if not is_unsure(first, threshold):
276 return _keep_first(first)
277 if second is None:
278 raise CombineError("rule 'disagree' needs a second result file")
279 if str(second["pred"]) == str(first["pred"]):
280 return Decision(str(first["pred"]), _usable_probs(first), True)
281 return Decision(ESCALATION_VERDICT, None, True)
282
283
284#: The rules by name. Each is a pure function of (first row, second row,
285#: threshold) and holds no state between rows, which is what lets the sweep run
286#: the same four rules over six thresholds without re-reading a file.
287RULE_FUNCTIONS: dict[str, Callable[..., Decision]] = {
288 "escalate": rule_escalate,
289 "replace": rule_replace,
290 "stricter": rule_stricter,
291 "disagree": rule_disagree,
292}
293
294
295def _index_by_command(rows: list[Row], label: str) -> dict[str, Row]:
296 """Index one file's rows by command, refusing a duplicate.
297
298 A repeated command would make the join ambiguous -- two truths, two
299 forecasts, and no rule for which one wins -- so it is refused here rather
300 than resolved silently. No case file on this bench carries one.
301 """
302
303 index: dict[str, Row] = {}
304 for row in rows:
305 command = str(row["command"])
306 if command in index:
307 raise CombineError(f"{label}: command appears twice: {ascii(command)}")
308 index[command] = row
309 return index
310
311
312def join_rows(first_rows: list[Row],
313 second_rows: list[Row] | None,
314 first_label: str, second_label: str) -> list[Row | None]:
315 """Line the second file's rows up with the first file's, in the first's order.
316
317 Returns one entry per first row: the matching second row, or ``None`` for
318 every row when there is no second file. Refuses when the two files do not
319 carry exactly the same commands, or when they disagree about the truth of
320 one -- both of those mean the two runs are not the same measurement, and
321 every figure downstream would be a join artefact rather than a reading.
322 """
323
324 if second_rows is None:
325 return [None] * len(first_rows)
326 first_index = _index_by_command(first_rows, first_label)
327 second_index = _index_by_command(second_rows, second_label)
328 missing = sorted(set(first_index) - set(second_index))
329 extra = sorted(set(second_index) - set(first_index))
330 if missing or extra:
331 raise CombineError(
332 f"{first_label} and {second_label} do not carry the same commands: "
333 f"{len(missing)} only in the first, {len(extra)} only in the second"
334 + (f"; first missing: {ascii(missing[0])}" if missing else "")
335 + (f"; first extra: {ascii(extra[0])}" if extra else "")
336 )
337 for command, row in first_index.items():
338 other = second_index[command]
339 if str(row.get("truth")) != str(other.get("truth")):
340 raise CombineError(
341 f"{first_label} and {second_label} disagree on the truth of "
342 f"{ascii(command)}: {row.get('truth')!r} against {other.get('truth')!r}"
343 )
344 return [second_index[str(row["command"])] for row in first_rows]
345
346
347def _effective_ms(first: Row, second: Row | None,
348 consulted: bool) -> float | None:
349 """The latency of the combined decision: the first's, plus the second's when asked."""
350
351 values = [float(first["ms"])] if first.get("ms") is not None else []
352 if consulted and second is not None and second.get("ms") is not None:
353 values.append(float(second["ms"]))
354 return sum(values) if values else None
355
356
357def _threshold_tag(threshold: float) -> str:
358 """The threshold as the two digits the file names and model names carry."""
359
360 return f"t{round(threshold * 100):02d}"
361
362
363def _digest(path: Path) -> str:
364 """The SHA-256 of one input file, which is what pins a combination to its inputs."""
365
366 return hashlib.sha256(Path(path).read_bytes()).hexdigest()
367
368
369def _relative_path(path: Path, bench_dir: Path) -> str:
370 """One input path, relative to the bench directory when it lies inside it.
371
372 A committed combination names ``results/jev_direct_hard.json`` rather than
373 the worktree it happened to be made in, so anyone can rerun it from their
374 own checkout and compare digests instead of paths.
375 """
376
377 resolved = Path(path).resolve()
378 try:
379 return resolved.relative_to(Path(bench_dir).resolve()).as_posix()
380 except ValueError:
381 return resolved.as_posix()
382
383
384def combine_rows(first_rows: list[Row],
385 seconds: list[Row | None],
386 rule: str, threshold: float) -> list[Row]:
387 """Apply one rule down the two joined row lists, in the first file's order."""
388
389 apply_rule = RULE_FUNCTIONS[rule]
390 rows: list[Row] = []
391 for first, second in zip(first_rows, seconds):
392 decision = apply_rule(first, second, threshold)
393 row: Row = {
394 "command": str(first["command"]),
395 "truth": str(first["truth"]),
396 "slice": str(first.get("slice", "")),
397 "pred": decision.pred,
398 }
399 if decision.probs is not None:
400 row["probs"] = decision.probs
401 row["ms"] = _effective_ms(first, second, decision.consulted)
402 row["consulted"] = decision.consulted
403 row["first_pred"] = str(first["pred"])
404 row["second_pred"] = None if second is None else str(second["pred"])
405 rows.append(row)
406 return rows
407
408
409def build_payload(first_payload: Payload,
410 second_payload: Payload | None,
411 *, rule: str, threshold: float,
412 first_path: Path, second_path: Path | None,
413 bench_dir: Path, date: str) -> Payload:
414 """Two loaded result payloads to one combined result payload.
415
416 The output is a bench result file and nothing more exotic: ``score.py``
417 reads it with no change, and the three fields it does not read --
418 ``consulted``, ``first_pred``, ``second_pred`` -- are there so a reader can
419 take any row apart without rerunning the combination.
420 """
421
422 func = "build_payload"
423 first_rows = list(first_payload.get("results", []))
424 second_rows = list(second_payload.get("results", [])) if second_payload else None
425 first_model = str(first_payload.get("model", "?"))
426 second_model = str(second_payload.get("model", "?")) if second_payload else None
427 seconds = join_rows(first_rows, second_rows, first_model,
428 second_model or "(no second file)")
429 rows = combine_rows(first_rows, seconds, rule, threshold)
430
431 consulted = sum(1 for row in rows if row["consulted"])
432 disagreeing = sum(1 for row in rows
433 if row["consulted"] and row["first_pred"] != row["second_pred"])
434 cost_usd: float | None = None
435 if second_payload is not None and second_payload.get("cost_usd") is not None and second_rows:
436 cost_usd = consulted * float(second_payload["cost_usd"]) / len(second_rows)
437 payload: Payload = {
438 "model": f"combined_{first_model}_{rule}_{_threshold_tag(threshold)}",
439 "condition": second_model or "none",
440 "results": rows,
441 "cost_usd": cost_usd,
442 "provenance": {
443 "plan": "302",
444 "date": date,
445 "rule": rule,
446 "threshold": threshold,
447 "first_file": _relative_path(first_path, bench_dir),
448 "first_model": first_model,
449 "first_condition": str(first_payload.get("condition", "-")),
450 "first_sha256": _digest(first_path),
451 "second_file": None if second_path is None else _relative_path(second_path, bench_dir),
452 "second_model": second_model,
453 "second_condition": (str(second_payload.get("condition", "-"))
454 if second_payload else None),
455 "second_sha256": None if second_path is None else _digest(second_path),
456 "consulted": consulted,
457 "disagreeing": disagreeing,
458 "confirm_issued": sum(1 for row in rows if row["pred"] == ESCALATION_VERDICT),
459 "confirm_required": sum(1 for row in rows if row["truth"] == ESCALATION_VERDICT),
460 },
461 }
462 _dbg("OC_CHAT.MAIN", func,
463 f"{payload['model']}: consulted {consulted}, disagreeing {disagreeing}", 3)
464 return payload
465
466
467def load_payload(path: Path) -> Payload:
468 """Read one result file, refusing a path that is not there."""
469
470 target = Path(path)
471 if not target.is_file():
472 raise CombineError(f"result file not found: {target}")
473 return json.loads(target.read_text(encoding="utf-8"))
474
475
476# --------------------------------------------------------------------------
477# The sweep document
478# --------------------------------------------------------------------------
479
480
481@dataclass(frozen=True)
483 """One scored combination, as the sweep tables print it."""
484
485 first: str
486 rule: str
487 threshold: float
488 second: str
489 score: Score
490 consulted: int
491
492 @property
493 def cost_usd(self) -> float | None:
494 """What the consulted decisions would have cost at the second's own rate."""
495
496 return self.score.cost_usd
497
498
500 """Result files read once and reused across the sweep's 130-odd combinations."""
501
502 def __init__(self, results_dir: Path) -> None:
503 self._dir = Path(results_dir)
504 self._payloads: dict[str, Payload] = {}
505 self._digests: dict[str, str] = {}
506
507 def path(self, stem: str) -> Path:
508 """The result file for one stem."""
509
510 return self._dir / f"{stem}.json"
511
512 def payload(self, stem: str) -> Payload:
513 """One result payload, loaded at most once."""
514
515 if stem not in self._payloads:
516 self._payloads[stem] = load_payload(self.path(stem))
517 self._digests[stem] = _digest(self.path(stem))
518 return self._payloads[stem]
519
520 def digest(self, stem: str) -> str:
521 """The SHA-256 of one result file, computed at most once."""
522
523 self.payload(stem)
524 return self._digests[stem]
525
526
527def _sweep_row(cache: _PayloadCache, first: str, second: str, rule: str,
528 threshold: float, bench_dir: Path) -> SweepRow:
529 """Compose and score one combination in memory, writing nothing."""
530
531 payload = build_payload(
532 cache.payload(first), cache.payload(second), rule=rule, threshold=threshold,
533 first_path=cache.path(first), second_path=cache.path(second),
534 bench_dir=bench_dir, date="",
535 )
536 provenance = payload["provenance"]
537 assert isinstance(provenance, dict)
538 return SweepRow(first=first, rule=rule, threshold=threshold, second=second,
539 score=score_payload(payload, f"{first}|{rule}", threshold),
540 consulted=int(provenance["consulted"]))
541
542
543def _ms_cell(value: float | None) -> str:
544 """A latency in milliseconds, to one decimal, or a dash."""
545
546 return "-" if value is None else f"{value:.1f}"
547
548
549def _cost_cell(value: float | None) -> str:
550 """A cost in US dollars, to four decimals, or a dash."""
551
552 return "-" if value is None else f"{value:.4f}"
553
554
555def _threshold_table(rows: list[SweepRow]) -> str:
556 """The threshold sweep: one line per first, rule and threshold."""
557
558 lines = ["| first | rule | threshold | agreement | granted more | granted less "
559 "| consulted | confirm issued | median ms | cost USD |",
560 "|---|---|---:|---:|---:|---:|---:|---:|---:|---:|"]
561 for row in rows:
562 score = row.score
563 lines.append(
564 f"| `{row.first}` | `{row.rule}` | {row.threshold:.2f} | {score.agreement} "
565 f"| {score.granted_more} | {score.granted_less} | {row.consulted} "
566 f"| {_confirm_issued(score)} | {_ms_cell(score.median_ms)} "
567 f"| {_cost_cell(row.cost_usd)} |"
568 )
569 return "\n".join(lines) + "\n"
570
571
572def _confirm_issued(score: Score) -> int:
573 """How many ``confirm`` verdicts one scored combination issued."""
574
575 return int(score.provenance["confirm_issued"]) if score.provenance else 0
576
577
578def _grouped(rows: list[SweepRow],
579 key: Callable[[SweepRow], tuple[object, ...]]
580 ) -> list[tuple[tuple[object, ...], list[SweepRow]]]:
581 """Group scored rows by a key, in the order the keys first appear.
582
583 Grouping on the row's OWN fields rather than on the module constants is
584 what keeps a range table honest: a key spelled by hand can miss every row
585 and print an empty table, which is exactly what a reader would read as "no
586 movement between runs".
587 """
588
589 groups: dict[tuple[object, ...], list[SweepRow]] = {}
590 for row in rows:
591 groups.setdefault(key(row), []).append(row)
592 return list(groups.items())
593
594
595def _repeat_rows_table(rows: list[SweepRow]) -> str:
596 """Every (first, rule, run) reading at one threshold, one line each."""
597
598 lines = ["| first | rule | second | agreement | granted more | granted less "
599 "| consulted | confirm issued |", "|---|---|---|---:|---:|---:|---:|---:|"]
600 for row in rows:
601 score = row.score
602 lines.append(
603 f"| `{row.first}` | `{row.rule}` | `{row.second}` | {score.agreement} "
604 f"| {score.granted_more} | {score.granted_less} | {row.consulted} "
605 f"| {_confirm_issued(score)} |"
606 )
607 return "\n".join(lines)
608
609
610def _repeat_range_table(rows: list[SweepRow]) -> str:
611 """The range each (first, rule) covers across the repeated runs at one threshold."""
612
613 lines = ["| first | rule | agreement | granted more | granted less | consulted |",
614 "|---|---|---|---|---|---:|"]
615 for (first, rule), group in _grouped(rows, lambda row: (row.first, row.rule)):
616 lines.append(
617 f"| `{first}` | `{rule}` "
618 f"| {_range_cell([item.score.correct for item in group])}"
619 f" of {group[0].score.total} "
620 f"| {_range_cell([item.score.granted_more for item in group])} "
621 f"| {_range_cell([item.score.granted_less for item in group])} "
622 f"| {group[0].consulted} |"
623 )
624 return "\n".join(lines)
625
626
627def _threshold_range_table(rows: list[SweepRow]) -> str:
628 """The same range, at every swept threshold, for reading an operating point.
629
630 A recommendation made at one threshold needs its noise band at THAT
631 threshold. The pinned table above is at 0.90 because that is where the
632 plan's main tables sit; this one is what says whether a point chosen
633 elsewhere on the curve survives a second run of the second model.
634 """
635
636 lines = ["| first | rule | threshold | agreement | granted more | granted less "
637 "| consulted |", "|---|---|---:|---|---|---|---:|"]
638 for (first, rule, threshold), group in _grouped(
639 rows, lambda row: (row.first, row.rule, row.threshold)):
640 assert isinstance(threshold, float)
641 lines.append(
642 f"| `{first}` | `{rule}` | {threshold:.2f} "
643 f"| {_range_cell([item.score.correct for item in group])}"
644 f" of {group[0].score.total} "
645 f"| {_range_cell([item.score.granted_more for item in group])} "
646 f"| {_range_cell([item.score.granted_less for item in group])} "
647 f"| {group[0].consulted} |"
648 )
649 return "\n".join(lines)
650
651
652def _range_cell(values: list[int]) -> str:
653 """``n`` when every run agrees, ``lo-hi`` when they do not."""
654
655 low, high = min(values), max(values)
656 return str(low) if low == high else f"{low}-{high}"
657
658
659def render_sweep(results_dir: Path, bench_dir: Path) -> str:
660 """The whole generated sweep document.
661
662 Deliberately DATELESS. The document is a pure function of the committed
663 result files it names, and it carries their digests instead of a timestamp,
664 so a second run reproduces it byte for byte and a diff means an input
665 moved.
666 """
667
668 func = "render_sweep"
669 cache = _PayloadCache(results_dir)
670 hard_first = SWEEP_SECONDS_HARD[0]
671
672 hand_first = SWEEP_SECONDS_HAND[0]
673 sweep_rows = [_sweep_row(cache, f"{first}_hard", hard_first, rule, threshold, bench_dir)
674 for first in SWEEP_FIRSTS for rule in RULES
675 for threshold in SWEEP_THRESHOLDS]
676 hand_sweep_rows = [_sweep_row(cache, f"{first}_hand", hand_first, rule, threshold,
677 bench_dir)
678 for first in SWEEP_FIRSTS for rule in RULES
679 for threshold in SWEEP_THRESHOLDS]
680 hard_rows = [_sweep_row(cache, f"{first}_hard", second, rule, DEFAULT_THRESHOLD, bench_dir)
681 for first in SWEEP_FIRSTS for rule in RULES for second in SWEEP_SECONDS_HARD]
682 hand_rows = [_sweep_row(cache, f"{first}_hand", second, rule, DEFAULT_THRESHOLD, bench_dir)
683 for first in SWEEP_FIRSTS for rule in RULES for second in SWEEP_SECONDS_HAND]
684 hard_curve = [_sweep_row(cache, f"{first}_hard", second, rule, threshold, bench_dir)
685 for first in SWEEP_FIRSTS for rule in RULES
686 for threshold in SWEEP_THRESHOLDS for second in SWEEP_SECONDS_HARD]
687
688 inputs = sorted({row.first for row in sweep_rows + hard_rows + hand_rows}
689 | set(SWEEP_SECONDS_HARD) | set(SWEEP_SECONDS_HAND))
690 parts = [
691 "# Combining two opinions: the threshold sweep and the repeated runs",
692 "",
693 "Generated by `python bench/argv_policy/combine.py --sweep` (plan 302). Do not edit"
694 " by hand: every figure below is composed in memory from the committed result files"
695 " named at the foot of this document and scored by `score.py`, and the file carries"
696 " no generation date so that a second run reproduces it byte for byte.",
697 "",
698 "The first model decides. A row is unsure when its top probability is strictly below"
699 " the threshold, and only then is the second model consulted. `escalate` consults"
700 " nothing and is the control: it is the encoder alone with a human behind it.",
701 "",
702 "## The threshold sweep, hard set, first Jev run",
703 "",
704 f"Second: `{hard_first}`. Thresholds: "
705 + ", ".join(f"{value:.2f}" for value in SWEEP_THRESHOLDS) + ".",
706 "",
707 _threshold_table(sweep_rows).rstrip("\n"),
708 "",
709 "## The threshold sweep, hand set, first Jev run",
710 "",
711 "The 104 hand-written commands are too few for a threshold curve to be read on its"
712 " own -- one command is almost a percentage point -- so this table is here to say"
713 " whether an operating point chosen on the hard set above survives on the other case"
714 f" set, not to choose one. Second: `{hand_first}`.",
715 "",
716 _threshold_table(hand_sweep_rows).rstrip("\n"),
717 "",
718 "## The movement between repeated runs of the second model",
719 "",
720 "Plan 293 measured two identical Jev requests moving probabilities on 60 of 104 rows"
721 " by up to 0.13, so a combination that consults Jev inherits that slack. These are"
722 " the same compositions at "
723 f"{DEFAULT_THRESHOLD:.2f} against each repeated run. `escalate` consults nothing, so"
724 " its range is zero by construction and is the control for the reading: where an"
725 " `escalate` range is not zero the fault is in this document, not in the model.",
726 "",
727 f"### Hard set, 300 commands, every run at {DEFAULT_THRESHOLD:.2f}",
728 "",
729 _repeat_rows_table(hard_rows),
730 "",
731 f"### Hard set, 300 commands, the range across "
732 f"{len(SWEEP_SECONDS_HARD)} runs at {DEFAULT_THRESHOLD:.2f}",
733 "",
734 _repeat_range_table(hard_rows),
735 "",
736 f"### Hand set, 104 commands, every run at {DEFAULT_THRESHOLD:.2f}",
737 "",
738 _repeat_rows_table(hand_rows),
739 "",
740 f"### Hand set, 104 commands, the range across "
741 f"{len(SWEEP_SECONDS_HAND)} runs at {DEFAULT_THRESHOLD:.2f}",
742 "",
743 _repeat_range_table(hand_rows),
744 "",
745 "## The same range at every swept threshold, hard set",
746 "",
747 "An operating point read off the sweep above needs its noise band at the threshold"
748 " it was read at, not only at "
749 f"{DEFAULT_THRESHOLD:.2f}. This is the range each combination covers across the"
750 f" {len(SWEEP_SECONDS_HARD)} hard-set Jev runs, at each of the six thresholds.",
751 "",
752 _threshold_range_table(hard_curve),
753 "",
754 "## The inputs",
755 "",
756 "| result file | SHA-256 |",
757 "|---|---|",
758 ]
759 parts += [f"| `results/{stem}.json` | `{cache.digest(stem)}` |" for stem in inputs]
760 parts.append("")
761 _dbg("OC_CHAT.MAIN", func,
762 f"rendered {len(sweep_rows)} sweep rows and "
763 f"{len(hard_rows) + len(hand_rows) + len(hard_curve)} repeat rows", 2)
764 return "\n".join(parts)
765
766
767def _build_parser() -> argparse.ArgumentParser:
768 """Construct the CLI argument parser."""
769
770 parser = argparse.ArgumentParser(
771 prog="python bench/argv_policy/combine.py",
772 description="Combine two argv-policy result files: the first decides, the "
773 "second is consulted where the first is unsure.",
774 )
775 parser.add_argument("--first", default=None,
776 help="Result file of the model that DECIDES.")
777 parser.add_argument("--second", default=None,
778 help="Result file of the model CONSULTED where the first is "
779 "unsure. Optional for --rule escalate, which consults "
780 "nothing and only records which pairing it controls for.")
781 parser.add_argument("--rule", choices=RULES, default=None,
782 help="How an unsure row is decided.")
783 parser.add_argument("--threshold", type=float, default=DEFAULT_THRESHOLD,
784 help=f"Confidence below which the first model is unsure "
785 f"(default: {DEFAULT_THRESHOLD}). The comparison is strict.")
786 parser.add_argument("--out", default=None,
787 help="Where to write the combined result file, or the sweep "
788 "document under --sweep.")
789 parser.add_argument("--date", default=None,
790 help="The date recorded in provenance (default: today). Pin it "
791 "to reproduce a committed file byte for byte.")
792 parser.add_argument("--sweep", action="store_true",
793 help="Render the threshold sweep and the repeated-run movement "
794 "to results/combined_sweep.md instead of combining once.")
795 parser.add_argument("--results-dir", default=None,
796 help="Directory the sweep reads (default: results/ beside "
797 "this script).")
798 return parser
799
800
801def _run_sweep(args: argparse.Namespace, bench_dir: Path) -> int:
802 """The ``--sweep`` path: render the document and write it."""
803
804 func = "_run_sweep"
805 results_dir = Path(args.results_dir) if args.results_dir else bench_dir / "results"
806 if not results_dir.is_dir():
807 print(f"combine: results directory not found: {results_dir}", file=sys.stderr)
808 return 2
809 out = Path(args.out) if args.out else results_dir / "combined_sweep.md"
810 out.write_text(render_sweep(results_dir, bench_dir), encoding="utf-8")
811 print(f"combine: wrote {out}")
812 _dbg("OC_CHAT.MAIN", func, f"wrote {out}", 2)
813 return 0
814
815
816def _run_combination(args: argparse.Namespace, bench_dir: Path) -> int:
817 """The single-combination path: compose two files and write the result."""
818
819 func = "_run_combination"
820 if not args.first or not args.rule or not args.out:
821 print("combine: --first, --rule and --out are required without --sweep",
822 file=sys.stderr)
823 return 2
824 if args.second is None and args.rule in CONSULTING_RULES:
825 print(f"combine: --rule {args.rule} consults a second model, so --second "
826 "is required", file=sys.stderr)
827 return 2
828 first_path = Path(args.first)
829 second_path = Path(args.second) if args.second else None
830 first_payload = load_payload(first_path)
831 second_payload = load_payload(second_path) if second_path else None
832 date = args.date or datetime.date.today().isoformat()
833 payload = build_payload(first_payload, second_payload, rule=args.rule,
834 threshold=args.threshold, first_path=first_path,
835 second_path=second_path, bench_dir=bench_dir, date=date)
836 Path(args.out).write_text(json.dumps(payload, indent=1, sort_keys=True),
837 encoding="utf-8")
838 provenance = payload["provenance"]
839 assert isinstance(provenance, dict)
840 print(f"combine: wrote {args.out}")
841 print(f"combine: {payload['model']} against {payload['condition']}, "
842 f"{provenance['consulted']} of {len(payload['results'])} consulted, "
843 f"{provenance['disagreeing']} disagreeing")
844 _dbg("OC_CHAT.MAIN", func, f"wrote {args.out}", 2)
845 return 0
846
847
848def main(argv: list[str] | None = None) -> int:
849 """CLI entry point: one combination, or the whole sweep document."""
850
851 args = _build_parser().parse_args(argv)
852 bench_dir = Path(__file__).resolve().parent
853 try:
854 if args.sweep:
855 return _run_sweep(args, bench_dir)
856 return _run_combination(args, bench_dir)
857 except CombineError as error:
858 print(f"combine: {error}", file=sys.stderr)
859 return 2
860
861
862if __name__ == "__main__":
863 sys.exit(main())
str digest(self, str stem)
Definition combine.py:520
Path path(self, str stem)
Definition combine.py:507
Payload payload(self, str stem)
Definition combine.py:512
None __init__(self, Path results_dir)
Definition combine.py:502
float|None cost_usd(self)
Definition combine.py:493
argparse.ArgumentParser _build_parser()
Definition combine.py:767
Decision rule_disagree(Row first, Row|None second, float threshold)
Definition combine.py:272
Payload build_payload(Payload first_payload, Payload|None second_payload, *, str rule, float threshold, Path first_path, Path|None second_path, Path bench_dir, str date)
Definition combine.py:413
int _rank(str prediction)
Definition combine.py:212
str _threshold_tag(float threshold)
Definition combine.py:357
str _cost_cell(float|None value)
Definition combine.py:549
str render_sweep(Path results_dir, Path bench_dir)
Definition combine.py:659
list[tuple[tuple[object,...], list[SweepRow]]] _grouped(list[SweepRow] rows, Callable[[SweepRow], tuple[object,...]] key)
Definition combine.py:580
Decision _keep_first(Row first)
Definition combine.py:218
Decision rule_stricter(Row first, Row|None second, float threshold)
Definition combine.py:251
int _confirm_issued(Score score)
Definition combine.py:572
dict[str, float]|None _usable_probs(Row row)
Definition combine.py:203
str _relative_path(Path path, Path bench_dir)
Definition combine.py:369
list[Row] combine_rows(list[Row] first_rows, list[Row|None] seconds, str rule, float threshold)
Definition combine.py:386
Payload load_payload(Path path)
Definition combine.py:467
Decision rule_replace(Row first, Row|None second, float threshold)
Definition combine.py:240
SweepRow _sweep_row(_PayloadCache cache, str first, str second, str rule, float threshold, Path bench_dir)
Definition combine.py:528
str _ms_cell(float|None value)
Definition combine.py:543
float|None top_probability(Row row)
Definition combine.py:174
str _digest(Path path)
Definition combine.py:363
bool is_unsure(Row row, float threshold)
Definition combine.py:190
float|None _effective_ms(Row first, Row|None second, bool consulted)
Definition combine.py:348
str _range_cell(list[int] values)
Definition combine.py:652
str _repeat_range_table(list[SweepRow] rows)
Definition combine.py:610
Decision rule_escalate(Row first, Row|None second, float threshold)
Definition combine.py:225
dict[str, Row] _index_by_command(list[Row] rows, str label)
Definition combine.py:295
str _threshold_range_table(list[SweepRow] rows)
Definition combine.py:627
int _run_combination(argparse.Namespace args, Path bench_dir)
Definition combine.py:816
str _repeat_rows_table(list[SweepRow] rows)
Definition combine.py:595
list[Row|None] join_rows(list[Row] first_rows, list[Row]|None second_rows, str first_label, str second_label)
Definition combine.py:314
int _run_sweep(argparse.Namespace args, Path bench_dir)
Definition combine.py:801
int main(list[str]|None argv=None)
Definition combine.py:848
str _threshold_table(list[SweepRow] rows)
Definition combine.py:555