6Embed the gate-labelled corpus once, then vote with the nearest neighbours.
10This is the cheapest shape on this bench that learns the argv policy from
11labels rather than from prose, and the only one that needs no training loop at
12all: embed every corpus command once, embed the query, and read the verdict off
13the nearest neighbours' labels. A decision costs one encoder forward pass and
14one matrix-vector product, nothing per call, and the whole model is rebuilt by
15re-embedding when the gate changes.
17It exists to bound plan 290 from below. An encoder that is fine-tuned on this
18corpus has to beat what the same encoder already achieves with no training at
19all, or the training was not worth its cost; and both have to beat plan 288's
20standard-library token floor, which decides in 0.07 ms.
22The vote is a softmax over PER-CLASS SIMILARITY SUMS across the top-k cosine
23neighbours, divided by a temperature. Two readings of that formula differ in
24what happens to a class no neighbour voted for. This module's default masks it
25out -- an empty sum is no forecast, not a zero one -- because the alternative
26gives a class with no votes ``exp(0)`` of the mass, which at k=1 makes the
27nearest-neighbour forecast 1/(1+2exp(-1/T)) rather than the exact 1.0 that a
281-NN forecast actually is. Both readings are implemented and both are swept by
29:func:`fit`, so the choice is measured on validation Brier rather than asserted
32Ties break toward ``deny``. A vote that cannot separate two classes grants the
33least permission, not the most, which matters because the query that produces
34an exactly tied vote is the degenerate one -- a command whose embedding is the
35zero vector, so every similarity is 0.0 and every present class sums to 0.0.
37Run the index build and the evaluation under the SCRATCH HARNESS venv (torch
38and sentence-transformers live there, and this bench adds no dependency to any
39``pyproject.toml``), from the oc_chat subproject root::
41 python bench/argv_policy/baselines/knn_classifier.py --stage all
43Run the tests under the PROJECT venv, where :class:`HashEmbedder` stands in for
44the real encoder so the witnesses need no model, no GPU and no network.
48- :class:`HashEmbedder` / :class:`SentenceTransformerEmbedder` /
49 :class:`LMStudioEmbedder` -- the three embedding routes.
50- :func:`write_npy` / :func:`read_npy` -- a standard-library ``.npy`` codec, so
51 an index written here is readable with or without numpy.
52- :func:`build_index` / :func:`load_index` -- the index on disk.
53- :func:`top_neighbours` / :func:`class_sums` / :func:`probabilities` /
54 :func:`verdict_of` / :func:`classify` -- one decision.
55- :func:`fit` -- k and the temperature from validation Brier alone.
56- :func:`evaluate` / :func:`write_result` -- a case set to a plan-288 result
58- :func:`main` -- the CLI: build, fit, evaluate.
64 L2 - index and evaluation lifecycle (build start and finish, files written)
65 L3 - per-batch, per-fit-cell and per-case-set detail
70- Deterministic given an embedder: the corpus, the pinned subset seed and the
71 (k, temperature) pair fix every number this module reports.
72- Read-only with respect to the corpus, the case sets and the scorer.
73- Index arrays are megabytes and rebuildable, so :func:`build_index` writes a
74 ``.gitignore`` of ``*`` into its own output directory. Nothing under it is
75 ever committed, and no model weights are either.
76- numpy is used when it is importable and a pure-Python path is used when it is
77 not; the on-disk format and every number are identical either way. The
78 fallback is for the witnesses, not for a 27000-row index.
79- Console output is ASCII only; commands are printed through :func:`ascii`.
80- Standard library only at import time. torch, sentence-transformers and numpy
81 are imported lazily, inside the routes that need them.
85- oc_diagnostics (structured debug logging via _dbg)
86- numpy (lazy; fast matrix maths and .npy loading when importable, with a pure-Python fallback)
87- sentence_transformers (lazy; the SentenceTransformer encoder behind the required embedding route)
88- torch (lazy; pins the encoder's CPU thread count)
90from __future__
import annotations
104from array
import array
105from dataclasses
import dataclass
106from pathlib
import Path
107from typing
import Any, Iterable, Sequence
109from oc_diagnostics
import _dbg
111_dbg(
"OC_CHAT.MAIN",
"module",
"loaded", 4)
114LABELS: tuple[str, ...] = (
"allow",
"confirm",
"deny")
122DEFAULT_K_GRID: tuple[int, ...] = (1, 5, 15, 51)
130DEFAULT_TEMPERATURE_GRID: tuple[float, ...] = (
131 0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0,
137DEFAULT_TEMPERATURE = 0.1
145JINA_MODEL_ID =
"jinaai/jina-embeddings-v2-base-code"
157LMSTUDIO_URL =
"http://127.0.0.1:1234/v1/embeddings"
158LMSTUDIO_MODEL =
"text-embedding-qwen3-embedding-0.6b"
159LMSTUDIO_TIMEOUT = 30.0
171NPY_MAGIC = b
"\x93NUMPY"
177HASH_NGRAM_SIZES: tuple[int, ...] = (1, 2, 3, 4)
181 """Return numpy when it is importable, else ``None``.
183 The witnesses run in the project venv, which has neither numpy nor torch;
184 the index builds and the bench evaluations run in the scratch harness venv,
185 which has both. One module serves both, so the import is a question rather
201 """Read a JSON-lines corpus split into a list of rows."""
203 text = Path(path).read_text(encoding=
"utf-8")
204 return [json.loads(line)
for line
in text.splitlines()
if line.strip()]
208 """Read one of the bench's hand-authored case sets."""
210 return json.loads(Path(path).read_text(encoding=
"utf-8"))
214 """A deterministic stand-in encoder: signed character n-grams, unit-norm.
216 Not a model and not a baseline -- it is what lets every witness in
217 ``tests/test_289_knn.py`` run in the project venv with no torch, no
218 download and no network, and what makes ``--embedder hash`` a smoke run of
219 the whole pipeline in under a second. CRC-32 rather than :func:`hash`,
220 which is salted per process: an index whose columns move between runs
221 cannot be built once and queried later.
228 def encode(self, texts: Sequence[str]) -> list[list[float]]:
229 """Project each text onto :attr:`dim` unit-norm dimensions."""
231 return [self.
_one(text)
for text
in texts]
233 def _one(self, text: str) -> list[float]:
234 """One text to one unit-norm vector."""
236 vector = [0.0] * self.
dim
237 padded = f
"\x02{text}\x03"
238 for size
in HASH_NGRAM_SIZES:
239 for start
in range(len(padded) - size + 1):
240 key = f
"{size}\x1f{padded[start:start + size]}".
encode(
"utf-8")
241 digest = zlib.crc32(key)
242 sign = 1.0
if digest & 1
else -1.0
243 vector[digest % self.
dim] += sign
244 norm = math.sqrt(sum(value * value
for value
in vector))
247 return [value / norm
for value
in vector]
251 """The required route (P2): a sentence-transformers encoder on CPU.
253 Imported lazily so this module stays importable in a venv with no torch.
254 ``HF_HUB_OFFLINE`` is set here rather than left to the caller's shell,
255 because a cache miss that silently reaches the network is the one failure
256 mode a reproducible bench cannot tolerate.
261 model_id: str = JINA_MODEL_ID,
262 max_seq_length: int = MAX_SEQ_LENGTH,
263 threads: int = THREADS,
264 name: str |
None =
None,
266 func =
"SentenceTransformerEmbedder.__init__"
267 os.environ.setdefault(
"HF_HUB_OFFLINE",
"1")
269 from sentence_transformers
import SentenceTransformer
271 torch.set_num_threads(threads)
272 started = time.perf_counter()
275 trust_remote_code=
True,
276 config_kwargs={
"max_position_embeddings": max_seq_length},
278 self.
model.max_seq_length = max_seq_length
283 self.
name = name
or model_id.rsplit(
"/", 1)[-1]
284 self.
dim = int(self.
model.get_sentence_embedding_dimension())
285 _dbg(
"OC_CHAT.MAIN", func,
286 f
"{self.name} dim {self.dim} in {self.load_seconds:.1f}s", 2)
288 def encode(self, texts: Sequence[str]) -> Any:
289 """Embed *texts* as a numpy float array, one row each."""
292 list(texts), batch_size=min(BUILD_BATCH, max(1, len(texts))),
293 show_progress_bar=
False,
298 """The optional route (P2): an OpenAI-shaped embeddings endpoint.
300 Needs no torch, so it runs in either venv. It is optional because it needs
301 the operator's LM Studio to be running: :meth:`probe` answers that in one
302 short call so a worker can skip the leg instead of waiting on it.
307 url: str = LMSTUDIO_URL,
308 model_id: str = LMSTUDIO_MODEL,
309 timeout: float = LMSTUDIO_TIMEOUT,
310 name: str |
None =
None,
312 func =
"LMStudioEmbedder.__init__"
316 self.
name = name
or f
"lmstudio_{model_id.rsplit('/', 1)[-1]}"
318 _dbg(
"OC_CHAT.MAIN", func, f
"{self.name} dim {self.dim}", 2)
321 def probe(url: str = LMSTUDIO_URL, timeout: float = 3.0) -> bool:
322 """Is the endpoint answering? One call, short timeout, never waits."""
324 models_url = url.rsplit(
"/", 1)[0] +
"/models"
326 with urllib.request.urlopen(models_url, timeout=timeout)
as response:
327 json.loads(response.read().decode(
"utf-8"))
328 except (urllib.error.URLError, OSError, ValueError):
332 def encode(self, texts: Sequence[str]) -> list[list[float]]:
333 """Embed *texts* through one POST, preserving the input order."""
335 body = json.dumps({
"model": self.
model_id,
"input": list(texts)})
336 request = urllib.request.Request(
337 self.
url, data=body.encode(
"utf-8"),
338 headers={
"Content-Type":
"application/json"}, method=
"POST",
340 with urllib.request.urlopen(request, timeout=self.
timeout)
as response:
341 payload = json.loads(response.read().decode(
"utf-8"))
342 rows = sorted(payload[
"data"], key=
lambda item: item.get(
"index", 0))
343 return [[float(value)
for value
in row[
"embedding"]]
for row
in rows]
347 """The padded version-1.0 header describing a C-order float32 matrix."""
349 body = f
"{{'descr': '<f4', 'fortran_order': False, 'shape': ({rows}, {dim}), }}"
350 padding = -(NPY_PREAMBLE + len(body) + 1) % NPY_ALIGNMENT
351 return body.encode(
"latin-1") + b
" " * padding + b
"\n"
355 """Flatten *matrix* to little-endian float32 bytes.
357 Two paths for one format: numpy when the caller handed over an array, and
358 :mod:`array` when it handed over lists. The bytes are identical.
361 if NUMPY
is not None and hasattr(matrix,
"astype"):
362 return NUMPY.ascontiguousarray(matrix, dtype=
"<f4").tobytes()
366 raise ValueError(f
"row of width {len(row)} in a {dim}-wide matrix")
367 flat.extend(float(value)
for value
in row)
368 if len(flat) != rows * dim:
369 raise ValueError(f
"{len(flat)} values for a {rows}x{dim} matrix")
370 if sys.byteorder !=
"little":
372 return flat.tobytes()
376 """Write *matrix* as a C-order float32 ``.npy`` file.
378 Hand-rolled because the project venv has no numpy and the witnesses must
379 still be able to round-trip an index; the harness venv reads the very same
380 bytes with :func:`numpy.load`.
384 dim = len(matrix[0])
if rows
else 0
387 with Path(path).open(
"wb")
as handle:
388 handle.write(NPY_MAGIC)
389 handle.write(bytes([1, 0]))
390 handle.write(struct.pack(
"<H", len(header)))
397 """Read a float32 ``.npy`` file into ``(rows, dim, flat values)``."""
399 with Path(path).open(
"rb")
as handle:
400 if handle.read(6) != NPY_MAGIC:
401 raise ValueError(f
"{path}: not a .npy file")
402 major = handle.read(2)[0]
403 width = 2
if major == 1
else 4
404 length = int.from_bytes(handle.read(width),
"little")
405 header = ast.literal_eval(handle.read(length).decode(
"latin-1").strip())
406 if header[
"descr"] !=
"<f4" or header[
"fortran_order"]:
407 raise ValueError(f
"{path}: expected C-order '<f4', got {header}")
408 rows, dim = (int(value)
for value
in header[
"shape"])
410 flat.fromfile(handle, rows * dim)
411 if sys.byteorder !=
"little":
413 return rows, dim, flat
416@dataclass(frozen=True)
418 """An embedded corpus on disk, loaded and ready to answer queries."""
432 """Total size of the index directory, the number P5 asks for."""
434 return sum(item.stat().st_size
for item
in self.path.iterdir()
439 """Scale every row to unit length, leaving an all-zero row alone.
441 Cosine similarity is a dot product only when both sides are unit-norm, so
442 this is done once at build time and never per query. A zero row cannot be
443 normalised and is kept as it is; it produces a similarity of 0.0 against
444 everything, which is the degenerate case the tie rule handles.
447 if NUMPY
is not None and hasattr(matrix,
"astype"):
448 array_2d = NUMPY.asarray(matrix, dtype=
"float32")
449 norms = NUMPY.linalg.norm(array_2d, axis=1, keepdims=
True)
450 return array_2d / NUMPY.where(norms == 0.0, 1.0, norms)
451 scaled: list[list[float]] = []
453 norm = math.sqrt(sum(float(value) * float(value)
for value
in row))
454 divisor = norm
if norm
else 1.0
455 scaled.append([float(value) / divisor
for value
in row])
463 subset: int |
None =
None,
464 seed: int = SUBSET_SEED,
466 """Embed a corpus split once and write the index to *index_dir*.
468 Three files: ``vectors.npy`` (row-normalised float32), ``index.json`` (the
469 commands, the labels and how the index was built) and a ``.gitignore`` of
470 ``*``, because the array is megabytes of rebuildable floats and must not be
471 committable by accident.
476 if subset
is not None and subset < len(rows):
477 rows = random.Random(seed).sample(rows, subset)
478 commands = [row[
"command"]
for row
in rows]
479 labels = [row[
"truth"]
for row
in rows]
480 _dbg(
"OC_CHAT.MAIN", func,
481 f
"embedding {len(commands)} commands with {embedder.name}", 2)
483 started = time.perf_counter()
484 batches: list[Any] = []
485 for start
in range(0, len(commands), BUILD_BATCH):
486 chunk = commands[start:start + BUILD_BATCH]
487 batches.append(embedder.encode(chunk))
488 _dbg(
"OC_CHAT.MAIN", func, f
"embedded {start + len(chunk)} of {len(commands)}", 3)
489 if NUMPY
is not None and batches
and hasattr(batches[0],
"astype"):
490 matrix = NUMPY.concatenate(batches, axis=0)
492 matrix = [row
for batch
in batches
for row
in batch]
494 build_seconds = time.perf_counter() - started
496 target = Path(index_dir)
497 target.mkdir(parents=
True, exist_ok=
True)
498 (target /
".gitignore").write_text(
499 "# Plan 289: an embedded corpus is megabytes of float32 that any\n"
500 "# checkout can rebuild from the committed corpus and the cached\n"
501 "# encoder, so nothing in this directory is ever committed.\n"
505 write_npy(target /
"vectors.npy", matrix)
506 (target /
"index.json").write_text(
508 "embedder": embedder.name,
509 "dim": int(embedder.dim),
510 "rows": len(commands),
511 "corpus_file": Path(corpus_path).name,
512 "subset_rows": subset,
513 "subset_seed": seed
if subset
is not None else None,
514 "build_seconds": build_seconds,
515 "commands": commands,
517 }, indent=1, sort_keys=
True) +
"\n",
520 _dbg(
"OC_CHAT.MAIN", func,
521 f
"wrote {target} in {build_seconds:.1f}s", 2)
525def load_index(index_dir: Path, embedder: Any =
None) -> Index:
526 """Load an index from disk, optionally with a live embedder for queries."""
529 target = Path(index_dir)
530 meta = json.loads((target /
"index.json").read_text(encoding=
"utf-8"))
531 if embedder
is not None and embedder.name != meta[
"embedder"]:
533 f
"{target}: index built with {meta['embedder']!r}, "
534 f
"queried with {embedder.name!r}"
536 if NUMPY
is not None:
537 vectors = NUMPY.load(target /
"vectors.npy")
538 rows, dim = int(vectors.shape[0]), int(vectors.shape[1])
540 rows, dim, vectors =
read_npy(target /
"vectors.npy")
541 if rows != meta[
"rows"]
or dim != meta[
"dim"]:
543 f
"{target}: array is {rows}x{dim}, metadata says "
544 f
"{meta['rows']}x{meta['dim']}"
546 _dbg(
"OC_CHAT.MAIN", func, f
"{target}: {rows}x{dim} {meta['embedder']}", 2)
548 path=target, embedder_name=str(meta[
"embedder"]), rows=rows, dim=dim,
549 commands=list(meta[
"commands"]), labels=list(meta[
"labels"]),
550 vectors=vectors, meta=meta, embedder=embedder,
554def index_row(index: Index, position: int) -> list[float]:
555 """One row of the index as a plain list, whichever backend holds it."""
557 if NUMPY
is not None and hasattr(index.vectors,
"shape"):
558 return [float(value)
for value
in index.vectors[position]]
559 start = position * index.dim
560 return list(index.vectors[start:start + index.dim])
564 """Cosine similarity of *query* against every indexed row.
566 A dot product, because both sides are unit-norm: the index rows were
567 normalised at build time and the query is normalised by :func:`classify`.
570 if NUMPY
is not None and hasattr(index.vectors,
"shape"):
571 return index.vectors @ NUMPY.asarray(query, dtype=
"float32")
572 out: list[float] = []
573 vectors = index.vectors
575 for position
in range(index.rows):
576 start = position * dim
577 out.append(sum(vectors[start + offset] * query[offset]
578 for offset
in range(dim)))
583 index: Index, query: Sequence[float], k: int
584) -> list[tuple[float, str]]:
585 """The *k* most similar indexed rows as ``(similarity, label)`` pairs."""
588 count = min(k, index.rows)
589 if NUMPY
is not None and hasattr(similarities,
"shape"):
590 if count < index.rows:
591 candidates = NUMPY.argpartition(-similarities, count - 1)[:count]
593 candidates = NUMPY.arange(index.rows)
594 order = candidates[NUMPY.argsort(-similarities[candidates], kind=
"stable")]
595 return [(float(similarities[position]), index.labels[int(position)])
596 for position
in order]
597 ranked = sorted(range(index.rows), key=
lambda position: -similarities[position])
598 return [(float(similarities[position]), index.labels[position])
599 for position
in ranked[:count]]
602def class_sums(neighbours: Iterable[tuple[float, str]]) -> dict[str, float]:
603 """Sum the similarities of the neighbours belonging to each class.
605 A class absent from the result is a class no neighbour voted for, which is
606 not the same thing as a class whose votes summed to zero. Membership, not
607 the sign of the sum, is what :func:`probabilities` masks on.
610 sums: dict[str, float] = {}
611 for similarity, label
in neighbours:
612 sums[label] = sums.get(label, 0.0) + similarity
617 sums: dict[str, float], temperature: float, mask_absent: bool =
True
618) -> dict[str, float]:
619 """Softmax the per-class similarity sums over a temperature.
621 With ``mask_absent`` (the default, and the reading in the module docstring)
622 a class no neighbour voted for receives no mass, so a 1-NN forecast is
623 exactly one-hot. Without it, every class enters the softmax with its sum --
624 zero when absent -- which is the other reading of P1's formula and the arm
625 :func:`fit` sweeps beside the first.
628 if temperature <= 0.0:
629 raise ValueError(f
"temperature must be positive, got {temperature}")
630 present = [label
for label
in LABELS
631 if not mask_absent
or label
in sums]
633 return {label: 0.0
for label
in LABELS}
634 scaled = {label: sums.get(label, 0.0) / temperature
for label
in present}
635 top = max(scaled.values())
636 exponentials = {label: math.exp(value - top)
for label, value
in scaled.items()}
637 total = sum(exponentials.values())
638 return {label: exponentials.get(label, 0.0) / total
for label
in LABELS}
642 """The verdict a forecast implies, and whether it came from a tie.
644 Ties break toward :data:`TIE_BREAK`. The caller gets the flag as well as
645 the verdict because plan 289 P3 requires the report to say how often the
646 rule fired: a tie rule nobody counts is a rule nobody can audit.
649 best = max(probs.get(label, 0.0)
for label
in LABELS)
650 tied = [label
for label
in LABELS
if probs.get(label, 0.0) == best]
652 return TIE_BREAK,
True
653 return tied[0],
False
657 """Embed one command with the index's own encoder, normalised."""
659 if index.embedder
is None:
660 raise ValueError(f
"{index.path}: loaded without an embedder, cannot query")
661 vector = index.embedder.encode([command])[0]
662 values = [float(value)
for value
in vector]
663 norm = math.sqrt(sum(value * value
for value
in values))
666 return [value / norm
for value
in values]
673 temperature: float = DEFAULT_TEMPERATURE,
674 mask_absent: bool =
True,
675) -> dict[str, float]:
676 """The forecast for one command: embed, search, vote.
678 This is the whole decision, and it is what the latency column measures:
679 one encoder forward pass at batch size one plus one matrix-vector product,
680 with no file read and no model load inside it.
687@dataclass(frozen=True)
689 """One ``(k, temperature, masking)`` cell of the validation sweep."""
701 """``correct/total`` with its percentage."""
703 share = 100.0 * self.correct / self.total
if self.total
else 0.0
704 return f
"{self.correct}/{self.total} ({share:.1f}%)"
707@dataclass(frozen=True)
709 """The chosen hyperparameters and the whole grid they were chosen from."""
724 index: Index, vectors: Any, k: int
725) -> list[list[tuple[float, str]]]:
726 """Top-*k* neighbours for a batch of query vectors.
728 With numpy this is one matmul per batch rather than one per query, which is
729 the difference between a fit that takes seconds and one that takes an hour.
732 if NUMPY
is not None and hasattr(index.vectors,
"shape"):
733 queries = NUMPY.asarray(vectors, dtype=
"float32")
734 similarities = queries @ index.vectors.T
735 count = min(k, index.rows)
736 out: list[list[tuple[float, str]]] = []
737 for position
in range(similarities.shape[0]):
738 row = similarities[position]
739 if count < index.rows:
740 candidates = NUMPY.argpartition(-row, count - 1)[:count]
742 candidates = NUMPY.arange(index.rows)
743 order = candidates[NUMPY.argsort(-row[candidates], kind=
"stable")]
744 out.append([(float(row[item]), index.labels[int(item)])
for item
in order])
750 """One vector scaled to unit length, or left alone when it is all zero."""
752 values = [float(value)
for value
in vector]
753 norm = math.sqrt(sum(value * value
for value
in values))
756 return [value / norm
for value
in values]
760 validation_path: Path,
762 k_grid: Sequence[int] = DEFAULT_K_GRID,
763 temperature_grid: Sequence[float] = DEFAULT_TEMPERATURE_GRID,
764 maskings: Sequence[bool] = (
True,
False),
766 """Choose ``k`` and the temperature on the validation split, by Brier.
768 P3: never on a case set. The neighbours are searched ONCE at the largest
769 ``k`` in the grid and every cell reads a prefix of that list, so the sweep
770 costs one pass over the validation split rather than one per cell.
775 truths = [row[
"truth"]
for row
in rows]
777 started = time.perf_counter()
778 neighbours: list[list[tuple[float, str]]] = []
779 for start
in range(0, len(rows), FIT_BATCH):
780 chunk = [row[
"command"]
for row
in rows[start:start + FIT_BATCH]]
781 vectors = [
_normalise_one(vector)
for vector
in index.embedder.encode(chunk)]
783 _dbg(
"OC_CHAT.MAIN", func, f
"searched {len(neighbours)} of {len(rows)}", 3)
785 grid: list[FitCell] = []
787 sums = [
class_sums(row[:k])
for row
in neighbours]
788 for mask_absent
in maskings:
789 for temperature
in temperature_grid:
793 for position, truth
in enumerate(truths):
794 probs =
probabilities(sums[position], temperature, mask_absent)
796 (probs.get(label, 0.0) - (1.0
if label == truth
else 0.0)) ** 2
800 correct += int(verdict == truth)
803 k=k, temperature=temperature, mask_absent=mask_absent,
804 brier=total_brier / len(rows), correct=correct,
805 total=len(rows), ties=ties,
808 _dbg(
"OC_CHAT.MAIN", func,
809 f
"k={k} T={temperature} mask={mask_absent}: "
810 f
"Brier {cell.brier:.4f} {cell.agreement}", 3)
812 best = min(grid, key=
lambda cell: (cell.brier, cell.k, cell.temperature))
813 seconds = time.perf_counter() - started
814 _dbg(
"OC_CHAT.MAIN", func,
815 f
"chose k={best.k} T={best.temperature} mask={best.mask_absent} "
816 f
"Brier {best.brier:.4f} in {seconds:.1f}s", 2)
818 k=best.k, temperature=best.temperature, mask_absent=best.mask_absent,
819 brier=best.brier, correct=best.correct, total=best.total, rows=len(rows),
820 source=Path(validation_path).name, grid=grid, seconds=seconds,
828 temperature: float = DEFAULT_TEMPERATURE,
829 mask_absent: bool =
True,
830 model_name: str =
"knn",
831 fitted: Fit |
None =
None,
833 """Decide every case in *cases_path* and build a plan-288 result payload.
835 P1 asks for a result FILE; producing the payload and writing it are split
836 so the witnesses can validate the schema without touching disk and so one
837 evaluation can be scored twice. :func:`write_result` is the other half.
839 ``condition`` is ``"corpus"`` for every file this module writes (P2): this
840 classifier is never given the policy text, so the policy/nopolicy axis the
841 prompted legs use does not exist for it. The case set therefore lives in
842 *model_name* and in the provenance block, not in the condition column.
847 rows: list[dict[str, Any]] = []
850 started = time.perf_counter()
851 probs =
classify(case[
"command"], index, k, temperature, mask_absent)
853 elapsed_ms = (time.perf_counter() - started) * 1000.0
856 "command": case[
"command"],
857 "truth": case[
"truth"],
858 "slice": case[
"slice"],
862 "reason": case[
"reason"],
865 hits = sum(row[
"pred"] == row[
"truth"]
for row
in rows)
866 _dbg(
"OC_CHAT.MAIN", func,
867 f
"{model_name}: {hits}/{len(rows)} agreement, {ties} ties", 3)
868 provenance: dict[str, Any] = {
869 "route": (f
"CPU, {index.embedder_name} embeddings, "
870 f
"cosine kNN over the gate-labelled corpus"),
871 "embedder": index.embedder_name,
872 "embedder_dim": index.dim,
873 "case_file": Path(cases_path).name,
874 "corpus_file": index.meta.get(
"corpus_file"),
875 "index_rows": index.rows,
876 "index_subset_rows": index.meta.get(
"subset_rows"),
877 "index_subset_seed": index.meta.get(
"subset_seed"),
878 "index_build_seconds": index.meta.get(
"build_seconds"),
879 "index_bytes": index.bytes_on_disk,
881 "temperature": temperature,
882 "mask_absent": mask_absent,
883 "tie_break": TIE_BREAK,
886 "max_seq_length": MAX_SEQ_LENGTH,
888 if fitted
is not None:
889 provenance[
"fit"] = {
890 "source": fitted.source,
892 "k_grid": list(DEFAULT_K_GRID),
893 "temperature_grid": list(DEFAULT_TEMPERATURE_GRID),
894 "validation_brier": fitted.brier,
895 "validation_agreement": f
"{fitted.correct}/{fitted.total}",
896 "seconds": fitted.seconds,
900 "condition":
"corpus",
902 "provenance": provenance,
907 """Write one result payload where :mod:`score` can find it."""
909 func =
"write_result"
910 target = Path(out_path)
911 target.parent.mkdir(parents=
True, exist_ok=
True)
913 json.dumps(payload, indent=1, sort_keys=
True) +
"\n", encoding=
"utf-8"
915 _dbg(
"OC_CHAT.MAIN", func, f
"wrote {target}", 2)
920 """The validation-Brier table W2 requires in the report, as markdown.
922 Pass *mask_absent* to render one arm of the sweep; omit it for the whole
923 grid. :func:`render_masking_table` is the compact comparison of the two.
927 "| k | temperature | masking | validation Brier | validation agreement | ties |",
928 "|---:|---:|---|---:|---:|---:|",
930 cells = [cell
for cell
in fitted.grid
931 if mask_absent
is None or cell.mask_absent == mask_absent]
932 for cell
in sorted(cells,
933 key=
lambda item: (item.k,
not item.mask_absent, item.temperature)):
934 masking =
"masked" if cell.mask_absent
else "unmasked"
936 f
"| {cell.k} | {cell.temperature} | {masking} | {cell.brier:.4f} "
937 f
"| {cell.agreement} | {cell.ties} |"
939 return "\n".join(lines) +
"\n"
943 """Best cell per ``k`` under each reading of P1's softmax, as markdown.
945 This is the table that settles the masking question empirically rather
946 than by argument: the same fit rule applied to both arms, side by side.
950 "| k | masked: best T | masked Brier | unmasked: best T | unmasked Brier |",
951 "|---:|---:|---:|---:|---:|",
953 for k
in sorted({cell.k
for cell
in fitted.grid}):
955 for mask_absent
in (
True,
False):
956 arm = [cell
for cell
in fitted.grid
957 if cell.k == k
and cell.mask_absent == mask_absent]
959 row.extend([
"-",
"-"])
961 best = min(arm, key=
lambda cell: (cell.brier, cell.temperature))
962 row.extend([str(best.temperature), f
"{best.brier:.4f}"])
963 lines.append(
"| " +
" | ".join(row) +
" |")
964 return "\n".join(lines) +
"\n"
968 """Build the embedder the CLI was asked for."""
974 if kind ==
"lmstudio":
976 raise ValueError(f
"unknown embedder {kind!r}")
980 """Construct the CLI argument parser."""
982 parser = argparse.ArgumentParser(
983 prog=
"python bench/argv_policy/baselines/knn_classifier.py",
984 description=
"Embed the gate-labelled corpus and vote with its neighbours.",
986 parser.add_argument(
"--stage", choices=[
"build",
"fit",
"evaluate",
"all"],
987 default=
"all", help=
"Which part to run (default: all).")
988 parser.add_argument(
"--embedder", choices=[
"jina",
"hash",
"lmstudio"],
989 default=
"jina", help=
"Embedding route (default: jina).")
990 parser.add_argument(
"--subset", type=int, default=
None,
991 help=f
"Index a pinned random subset of N rows "
992 f
"(P4 uses {SUBSET_ROWS}).")
993 parser.add_argument(
"--seed", type=int, default=SUBSET_SEED,
994 help=f
"Subset seed (default: {SUBSET_SEED}).")
995 parser.add_argument(
"--index-dir", default=
None,
997 help=
"Index directory (default: ../results/knn_index_<tag>).")
998 parser.add_argument(
"--out-dir", default=
None,
999 help=
"Where result files go (default: ../results).")
1000 parser.add_argument(
"--tag", default=
None,
1001 help=
"Name for this leg (default: <embedder>_<size>).")
1002 parser.add_argument(
"--k", type=int, default=
None,
1003 help=
"Neighbour count; omit to use the fitted value.")
1004 parser.add_argument(
"--temperature", type=float, default=
None,
1005 help=
"Softmax temperature; omit to use the fitted value.")
1006 parser.add_argument(
"--unmasked", action=
"store_true",
1007 help=
"Give absent classes exp(0) mass (the other reading of P1).")
1008 parser.add_argument(
"--fit-json", default=
None,
1009 help=
"Also write the whole fit grid as JSON to this path.")
1014 """The short name identifying one embedder-and-corpus-size leg."""
1017 return str(args.tag)
1018 embedder =
"jina" if args.embedder ==
"jina" else args.embedder
1019 size =
"full" if args.subset
is None else str(args.subset)
1020 return f
"{embedder}_{size}"
1023def main(argv: list[str] |
None =
None) -> int:
1024 """CLI entry point: build the index, fit on validation, score both sets."""
1028 bench_dir = Path(__file__).resolve().parent.parent
1030 index_dir = Path(args.index_dir)
if args.index_dir
else (
1031 bench_dir /
"results" / f
"knn_index_{tag}"
1033 out_dir = Path(args.out_dir)
if args.out_dir
else bench_dir /
"results"
1035 if args.stage
in (
"build",
"all"):
1036 if args.stage ==
"build" or not (index_dir /
"index.json").is_file():
1038 print(f
"knn: embedding corpus.jsonl with {embedder.name} "
1039 f
"(subset {args.subset}) -> {index_dir}")
1040 build_index(bench_dir /
"corpus.jsonl", embedder, index_dir,
1041 args.subset, args.seed)
1043 print(f
"knn: reusing the index at {index_dir}")
1044 if args.stage ==
"build":
1045 _dbg(
"OC_CHAT.MAIN", func,
"exit 0", 2)
1049 print(f
"knn: index {index.rows}x{index.dim} {index.embedder_name}, "
1050 f
"{index.bytes_on_disk / (1 << 20):.1f} MiB, "
1051 f
"built in {float(index.meta.get('build_seconds') or 0.0):.1f}s")
1053 fitted: Fit |
None =
None
1054 if args.k
is None or args.temperature
is None or args.stage ==
"fit":
1055 fitted =
fit(bench_dir /
"corpus_val.jsonl", index)
1056 print(f
"knn: fit on {fitted.source} ({fitted.rows} rows) in "
1057 f
"{fitted.seconds:.1f}s -> k={fitted.k} T={fitted.temperature} "
1058 f
"masked={fitted.mask_absent} Brier {fitted.brier:.4f} "
1059 f
"agreement {fitted.correct}/{fitted.total}")
1064 Path(args.fit_json).write_text(
1066 "leg": tag,
"source": fitted.source,
"rows": fitted.rows,
1067 "chosen": {
"k": fitted.k,
"temperature": fitted.temperature,
1068 "mask_absent": fitted.mask_absent,
1069 "brier": fitted.brier,
1070 "agreement": f
"{fitted.correct}/{fitted.total}"},
1071 "grid": [{
"k": cell.k,
"temperature": cell.temperature,
1072 "mask_absent": cell.mask_absent,
"brier": cell.brier,
1073 "correct": cell.correct,
"total": cell.total,
1074 "ties": cell.ties}
for cell
in fitted.grid],
1075 }, indent=1, sort_keys=
True) +
"\n",
1078 print(f
"knn: wrote {args.fit_json}")
1079 if args.stage ==
"fit":
1080 _dbg(
"OC_CHAT.MAIN", func,
"exit 0", 2)
1083 k = args.k
if args.k
is not None else (fitted.k
if fitted
else DEFAULT_K)
1084 temperature = args.temperature
if args.temperature
is not None else (
1085 fitted.temperature
if fitted
else DEFAULT_TEMPERATURE
1087 mask_absent =
False if args.unmasked
else (
1088 fitted.mask_absent
if fitted
else True
1090 for condition, case_file
in ((
"hand",
"cases.json"), (
"hard",
"cases_hard.json")):
1092 bench_dir / case_file, index, k, temperature, mask_absent,
1093 model_name=f
"knn_{tag}_{condition}", fitted=fitted,
1095 hits = sum(row[
"pred"] == row[
"truth"]
for row
in payload[
"results"])
1096 target =
write_result(payload, out_dir / f
"knn_{tag}_{condition}.json")
1097 print(f
" {condition:5} {hits}/{len(payload['results'])} agreement, "
1098 f
"{payload['provenance']['ties_broken']} ties -> {target.name}")
1099 _dbg(
"OC_CHAT.MAIN", func,
"exit 0", 2)
1103if __name__ ==
"__main__":
None __init__(self, int dim=64)
list[list[float]] encode(self, Sequence[str] texts)
list[float] _one(self, str text)
bool probe(str url=LMSTUDIO_URL, float timeout=3.0)
list[list[float]] encode(self, Sequence[str] texts)
None __init__(self, str url=LMSTUDIO_URL, str model_id=LMSTUDIO_MODEL, float timeout=LMSTUDIO_TIMEOUT, str|None name=None)
list[float] index_row(Index index, int position)
str render_fit_table(Fit fitted, bool|None mask_absent=None)
argparse.ArgumentParser _build_parser()
dict[str, Any] evaluate(Path cases_path, Index index, int k=DEFAULT_K, float temperature=DEFAULT_TEMPERATURE, bool mask_absent=True, str model_name="knn", Fit|None fitted=None)
str _leg_tag(argparse.Namespace args)
Any _similarities(Index index, Sequence[float] query)
Path write_result(dict[str, Any] payload, Path out_path)
Any _make_embedder(str kind)
Path build_index(Path corpus_path, Any embedder, Path index_dir, int|None subset=None, int seed=SUBSET_SEED)
Any _normalise_rows(Any matrix)
list[list[tuple[float, str]]] _neighbour_batches(Index index, Any vectors, int k)
bytes _npy_header(int rows, int dim)
tuple[str, bool] verdict_of(dict[str, float] probs)
str render_masking_table(Fit fitted)
Index load_index(Path index_dir, Any embedder=None)
list[dict[str, str]] read_cases(Path path)
Path write_npy(Path path, Any matrix)
dict[str, float] probabilities(dict[str, float] sums, float temperature, bool mask_absent=True)
int main(list[str]|None argv=None)
tuple[int, int, array] read_npy(Path path)
list[tuple[float, str]] top_neighbours(Index index, Sequence[float] query, int k)
dict[str, float] classify(str command, Index index, int k=DEFAULT_K, float temperature=DEFAULT_TEMPERATURE, bool mask_absent=True)
Fit fit(Path validation_path, Index index, Sequence[int] k_grid=DEFAULT_K_GRID, Sequence[float] temperature_grid=DEFAULT_TEMPERATURE_GRID, Sequence[bool] maskings=(True, False))
bytes _flat_float32(Any matrix, int rows, int dim)
list[dict[str, str]] read_jsonl(Path path)
list[float] _normalise_one(Sequence[float] vector)
list[float] embed_query(Index index, str command)
dict[str, float] class_sums(Iterable[tuple[float, str]] neighbours)