oc_chat
Loading...
Searching...
No Matches
knn_classifier.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# bench/argv_policy/baselines/knn_classifier.py
4
5"""
6Embed the gate-labelled corpus once, then vote with the nearest neighbours.
7
8Purpose
9-------
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.
16
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.
21
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
30(see ``mask_absent``).
31
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.
36
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::
40
41 python bench/argv_policy/baselines/knn_classifier.py --stage all
42
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.
45
46Responsibilities
47----------------
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
57 file.
58- :func:`main` -- the CLI: build, fit, evaluate.
59
60Diagnostics
61-----------
62Domain: OC_CHAT.MAIN
63Levels:
64 L2 - index and evaluation lifecycle (build start and finish, files written)
65 L3 - per-batch, per-fit-cell and per-case-set detail
66 L4 - module load
67
68Contracts
69---------
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.
82
83Dependencies
84------------
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)
89"""
90from __future__ import annotations
91
92import argparse
93import ast
94import json
95import math
96import os
97import random
98import struct
99import sys
100import time
101import urllib.error
102import urllib.request
103import zlib
104from array import array
105from dataclasses import dataclass
106from pathlib import Path
107from typing import Any, Iterable, Sequence
108
109from oc_diagnostics import _dbg
110
111_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
112
113#: The three verdicts, which are also the three forecast classes.
114LABELS: tuple[str, ...] = ("allow", "confirm", "deny")
115
116#: The verdict an undecided vote resolves to. Fail closed: the least
117#: permission, never the most.
118TIE_BREAK = "deny"
119
120#: Plan 289 P3's neighbour counts. Chosen on the validation split, never on a
121#: case set. 51 is odd so a two-class vote cannot tie on count alone.
122DEFAULT_K_GRID: tuple[int, ...] = (1, 5, 15, 51)
123
124#: The temperature grid swept beside ``k``. P3 pins the ``k`` values and leaves
125#: the temperature to the implementation, so it is pinned here: eight values
126#: over more than two orders of magnitude, from near-one-hot to nearly uniform.
127#: The top two exist because a grid whose minimum sits on its own boundary is a
128#: sweep that was not allowed to finish -- the first run of this module put the
129#: minimum at 1.0 for all four k, which is a truncated answer, not an answer.
130DEFAULT_TEMPERATURE_GRID: tuple[float, ...] = (
131 0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0,
132)
133
134#: The neighbour count and temperature a caller gets when it does not fit. Only
135#: the witnesses use these; every reported number comes from :func:`fit`.
136DEFAULT_K = 15
137DEFAULT_TEMPERATURE = 0.1
138
139#: Plan 289 P4's small-corpus condition: a pinned random subset of the training
140#: split, so the report can say how much labelled material the shape needs.
141SUBSET_ROWS = 1000
142SUBSET_SEED = 289
143
144#: The required embedder (P2), from the harness cache, offline.
145JINA_MODEL_ID = "jinaai/jina-embeddings-v2-base-code"
146
147#: jina-v2's remote code allocates its ALiBi bias for the CONFIGURED maximum
148#: length regardless of the input, which is 3.2 GB at its default 8192 and
149#: crashed the 2026-09-17 session. Commands are short; cap it.
150MAX_SEQ_LENGTH = 512
151
152#: Threads for the encoder forward pass. The September run at cpu_count minus
153#: two oversubscribed the host, so this is pinned rather than derived.
154THREADS = 8
155
156#: The optional embedder (P2), only when the operator has LM Studio running.
157LMSTUDIO_URL = "http://127.0.0.1:1234/v1/embeddings"
158LMSTUDIO_MODEL = "text-embedding-qwen3-embedding-0.6b"
159LMSTUDIO_TIMEOUT = 30.0
160
161#: Rows per encoder call when embedding a corpus.
162BUILD_BATCH = 128
163
164#: Query rows per similarity matmul when fitting. 256 keeps the similarity
165#: block near 27 MB at corpus scale instead of the 324 MB a single call needs.
166FIT_BATCH = 256
167
168#: ``.npy`` version 1.0: magic, version, then a uint16 header length. The total
169#: of those and the header must be a multiple of this, and the header ends in a
170#: newline.
171NPY_MAGIC = b"\x93NUMPY"
172NPY_ALIGNMENT = 64
173NPY_PREAMBLE = 10
174
175#: Character n-gram widths :class:`HashEmbedder` projects. The same range the
176#: token floor uses, so the stand-in is a recognisable shape rather than noise.
177HASH_NGRAM_SIZES: tuple[int, ...] = (1, 2, 3, 4)
178
179
180def _numpy() -> Any:
181 """Return numpy when it is importable, else ``None``.
182
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
186 than a requirement.
187 """
188
189 try:
190 import numpy
191 except ImportError: # pragma: no cover - exercised by the project venv
192 return None
193 return numpy
194
195
196#: numpy when this interpreter has it, else ``None``. See :func:`_numpy`.
197NUMPY: Any = _numpy()
198
199
200def read_jsonl(path: Path) -> list[dict[str, str]]:
201 """Read a JSON-lines corpus split into a list of rows."""
202
203 text = Path(path).read_text(encoding="utf-8")
204 return [json.loads(line) for line in text.splitlines() if line.strip()]
205
206
207def read_cases(path: Path) -> list[dict[str, str]]:
208 """Read one of the bench's hand-authored case sets."""
209
210 return json.loads(Path(path).read_text(encoding="utf-8"))
211
212
214 """A deterministic stand-in encoder: signed character n-grams, unit-norm.
215
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.
222 """
223
224 def __init__(self, dim: int = 64) -> None:
225 self.dim = dim
226 self.name = "hash"
227
228 def encode(self, texts: Sequence[str]) -> list[list[float]]:
229 """Project each text onto :attr:`dim` unit-norm dimensions."""
230
231 return [self._one(text) for text in texts]
232
233 def _one(self, text: str) -> list[float]:
234 """One text to one unit-norm vector."""
235
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))
245 if norm == 0.0:
246 return vector
247 return [value / norm for value in vector]
248
249
251 """The required route (P2): a sentence-transformers encoder on CPU.
252
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.
257 """
258
260 self,
261 model_id: str = JINA_MODEL_ID,
262 max_seq_length: int = MAX_SEQ_LENGTH,
263 threads: int = THREADS,
264 name: str | None = None,
265 ) -> None:
266 func = "SentenceTransformerEmbedder.__init__"
267 os.environ.setdefault("HF_HUB_OFFLINE", "1")
268 import torch
269 from sentence_transformers import SentenceTransformer
270
271 torch.set_num_threads(threads)
272 started = time.perf_counter()
273 self.model = SentenceTransformer(
274 model_id,
275 trust_remote_code=True,
276 config_kwargs={"max_position_embeddings": max_seq_length},
277 )
278 self.model.max_seq_length = max_seq_length
279 self.load_seconds = time.perf_counter() - started
280 self.model_id = model_id
281 self.threads = threads
282 self.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)
287
288 def encode(self, texts: Sequence[str]) -> Any:
289 """Embed *texts* as a numpy float array, one row each."""
290
291 return self.model.encode(
292 list(texts), batch_size=min(BUILD_BATCH, max(1, len(texts))),
293 show_progress_bar=False,
294 )
295
296
298 """The optional route (P2): an OpenAI-shaped embeddings endpoint.
299
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.
303 """
304
306 self,
307 url: str = LMSTUDIO_URL,
308 model_id: str = LMSTUDIO_MODEL,
309 timeout: float = LMSTUDIO_TIMEOUT,
310 name: str | None = None,
311 ) -> None:
312 func = "LMStudioEmbedder.__init__"
313 self.url = url
314 self.model_id = model_id
315 self.timeout = timeout
316 self.name = name or f"lmstudio_{model_id.rsplit('/', 1)[-1]}"
317 self.dim = len(self.encode(["pip list"])[0])
318 _dbg("OC_CHAT.MAIN", func, f"{self.name} dim {self.dim}", 2)
319
320 @staticmethod
321 def probe(url: str = LMSTUDIO_URL, timeout: float = 3.0) -> bool:
322 """Is the endpoint answering? One call, short timeout, never waits."""
323
324 models_url = url.rsplit("/", 1)[0] + "/models"
325 try:
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):
329 return False
330 return True
331
332 def encode(self, texts: Sequence[str]) -> list[list[float]]:
333 """Embed *texts* through one POST, preserving the input order."""
334
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",
339 )
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]
344
345
346def _npy_header(rows: int, dim: int) -> bytes:
347 """The padded version-1.0 header describing a C-order float32 matrix."""
348
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"
352
353
354def _flat_float32(matrix: Any, rows: int, dim: int) -> bytes:
355 """Flatten *matrix* to little-endian float32 bytes.
356
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.
359 """
360
361 if NUMPY is not None and hasattr(matrix, "astype"):
362 return NUMPY.ascontiguousarray(matrix, dtype="<f4").tobytes()
363 flat = array("f")
364 for row in matrix:
365 if len(row) != dim:
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": # pragma: no cover - x86/ARM are little-endian
371 flat.byteswap()
372 return flat.tobytes()
373
374
375def write_npy(path: Path, matrix: Any) -> Path:
376 """Write *matrix* as a C-order float32 ``.npy`` file.
377
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`.
381 """
382
383 rows = len(matrix)
384 dim = len(matrix[0]) if rows else 0
385 data = _flat_float32(matrix, rows, dim)
386 header = _npy_header(rows, dim)
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)))
391 handle.write(header)
392 handle.write(data)
393 return Path(path)
394
395
396def read_npy(path: Path) -> tuple[int, int, array]:
397 """Read a float32 ``.npy`` file into ``(rows, dim, flat values)``."""
398
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"])
409 flat = array("f")
410 flat.fromfile(handle, rows * dim)
411 if sys.byteorder != "little": # pragma: no cover - x86/ARM are little-endian
412 flat.byteswap()
413 return rows, dim, flat
414
415
416@dataclass(frozen=True)
417class Index:
418 """An embedded corpus on disk, loaded and ready to answer queries."""
419
420 path: Path
421 embedder_name: str
422 rows: int
423 dim: int
424 commands: list[str]
425 labels: list[str]
426 vectors: Any
427 meta: dict[str, Any]
428 embedder: Any = None
429
430 @property
431 def bytes_on_disk(self) -> int:
432 """Total size of the index directory, the number P5 asks for."""
433
434 return sum(item.stat().st_size for item in self.path.iterdir()
435 if item.is_file())
436
437
438def _normalise_rows(matrix: Any) -> Any:
439 """Scale every row to unit length, leaving an all-zero row alone.
440
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.
445 """
446
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]] = []
452 for row in matrix:
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])
456 return scaled
457
458
460 corpus_path: Path,
461 embedder: Any,
462 index_dir: Path,
463 subset: int | None = None,
464 seed: int = SUBSET_SEED,
465) -> Path:
466 """Embed a corpus split once and write the index to *index_dir*.
467
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.
472 """
473
474 func = "build_index"
475 rows = read_jsonl(corpus_path)
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)
482
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)
491 else:
492 matrix = [row for batch in batches for row in batch]
493 matrix = _normalise_rows(matrix)
494 build_seconds = time.perf_counter() - started
495
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"
502 "*\n",
503 encoding="utf-8",
504 )
505 write_npy(target / "vectors.npy", matrix)
506 (target / "index.json").write_text(
507 json.dumps({
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,
516 "labels": labels,
517 }, indent=1, sort_keys=True) + "\n",
518 encoding="utf-8",
519 )
520 _dbg("OC_CHAT.MAIN", func,
521 f"wrote {target} in {build_seconds:.1f}s", 2)
522 return target
523
524
525def load_index(index_dir: Path, embedder: Any = None) -> Index:
526 """Load an index from disk, optionally with a live embedder for queries."""
527
528 func = "load_index"
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"]:
532 raise ValueError(
533 f"{target}: index built with {meta['embedder']!r}, "
534 f"queried with {embedder.name!r}"
535 )
536 if NUMPY is not None:
537 vectors = NUMPY.load(target / "vectors.npy")
538 rows, dim = int(vectors.shape[0]), int(vectors.shape[1])
539 else:
540 rows, dim, vectors = read_npy(target / "vectors.npy")
541 if rows != meta["rows"] or dim != meta["dim"]:
542 raise ValueError(
543 f"{target}: array is {rows}x{dim}, metadata says "
544 f"{meta['rows']}x{meta['dim']}"
545 )
546 _dbg("OC_CHAT.MAIN", func, f"{target}: {rows}x{dim} {meta['embedder']}", 2)
547 return Index(
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,
551 )
552
553
554def index_row(index: Index, position: int) -> list[float]:
555 """One row of the index as a plain list, whichever backend holds it."""
556
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])
561
562
563def _similarities(index: Index, query: Sequence[float]) -> Any:
564 """Cosine similarity of *query* against every indexed row.
565
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`.
568 """
569
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
574 dim = index.dim
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)))
579 return out
580
581
583 index: Index, query: Sequence[float], k: int
584) -> list[tuple[float, str]]:
585 """The *k* most similar indexed rows as ``(similarity, label)`` pairs."""
586
587 similarities = _similarities(index, query)
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]
592 else:
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]]
600
601
602def class_sums(neighbours: Iterable[tuple[float, str]]) -> dict[str, float]:
603 """Sum the similarities of the neighbours belonging to each class.
604
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.
608 """
609
610 sums: dict[str, float] = {}
611 for similarity, label in neighbours:
612 sums[label] = sums.get(label, 0.0) + similarity
613 return sums
614
615
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.
620
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.
626 """
627
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]
632 if not present: # pragma: no cover - k >= 1 always votes
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}
639
640
641def verdict_of(probs: dict[str, float]) -> tuple[str, bool]:
642 """The verdict a forecast implies, and whether it came from a tie.
643
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.
647 """
648
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]
651 if len(tied) > 1:
652 return TIE_BREAK, True
653 return tied[0], False
654
655
656def embed_query(index: Index, command: str) -> list[float]:
657 """Embed one command with the index's own encoder, normalised."""
658
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))
664 if norm == 0.0:
665 return values
666 return [value / norm for value in values]
667
668
670 command: str,
671 index: Index,
672 k: int = DEFAULT_K,
673 temperature: float = DEFAULT_TEMPERATURE,
674 mask_absent: bool = True,
675) -> dict[str, float]:
676 """The forecast for one command: embed, search, vote.
677
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.
681 """
682
683 neighbours = top_neighbours(index, embed_query(index, command), k)
684 return probabilities(class_sums(neighbours), temperature, mask_absent)
685
686
687@dataclass(frozen=True)
689 """One ``(k, temperature, masking)`` cell of the validation sweep."""
690
691 k: int
692 temperature: float
693 mask_absent: bool
694 brier: float
695 correct: int
696 total: int
697 ties: int
698
699 @property
700 def agreement(self) -> str:
701 """``correct/total`` with its percentage."""
702
703 share = 100.0 * self.correct / self.total if self.total else 0.0
704 return f"{self.correct}/{self.total} ({share:.1f}%)"
705
706
707@dataclass(frozen=True)
708class Fit:
709 """The chosen hyperparameters and the whole grid they were chosen from."""
710
711 k: int
712 temperature: float
713 mask_absent: bool
714 brier: float
715 correct: int
716 total: int
717 rows: int
718 source: str
719 grid: list[FitCell]
720 seconds: float
721
722
724 index: Index, vectors: Any, k: int
725) -> list[list[tuple[float, str]]]:
726 """Top-*k* neighbours for a batch of query vectors.
727
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.
730 """
731
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]
741 else:
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])
745 return out
746 return [top_neighbours(index, vector, k) for vector in vectors]
747
748
749def _normalise_one(vector: Sequence[float]) -> list[float]:
750 """One vector scaled to unit length, or left alone when it is all zero."""
751
752 values = [float(value) for value in vector]
753 norm = math.sqrt(sum(value * value for value in values))
754 if norm == 0.0:
755 return values
756 return [value / norm for value in values]
757
758
759def fit(
760 validation_path: Path,
761 index: Index,
762 k_grid: Sequence[int] = DEFAULT_K_GRID,
763 temperature_grid: Sequence[float] = DEFAULT_TEMPERATURE_GRID,
764 maskings: Sequence[bool] = (True, False),
765) -> Fit:
766 """Choose ``k`` and the temperature on the validation split, by Brier.
767
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.
771 """
772
773 func = "fit"
774 rows = read_jsonl(validation_path)
775 truths = [row["truth"] for row in rows]
776 widest = max(k_grid)
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)]
782 neighbours.extend(_neighbour_batches(index, vectors, widest))
783 _dbg("OC_CHAT.MAIN", func, f"searched {len(neighbours)} of {len(rows)}", 3)
784
785 grid: list[FitCell] = []
786 for k in k_grid:
787 sums = [class_sums(row[:k]) for row in neighbours]
788 for mask_absent in maskings:
789 for temperature in temperature_grid:
790 total_brier = 0.0
791 correct = 0
792 ties = 0
793 for position, truth in enumerate(truths):
794 probs = probabilities(sums[position], temperature, mask_absent)
795 total_brier += sum(
796 (probs.get(label, 0.0) - (1.0 if label == truth else 0.0)) ** 2
797 for label in LABELS
798 )
799 verdict, tied = verdict_of(probs)
800 correct += int(verdict == truth)
801 ties += int(tied)
802 cell = FitCell(
803 k=k, temperature=temperature, mask_absent=mask_absent,
804 brier=total_brier / len(rows), correct=correct,
805 total=len(rows), ties=ties,
806 )
807 grid.append(cell)
808 _dbg("OC_CHAT.MAIN", func,
809 f"k={k} T={temperature} mask={mask_absent}: "
810 f"Brier {cell.brier:.4f} {cell.agreement}", 3)
811
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)
817 return Fit(
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,
821 )
822
823
825 cases_path: Path,
826 index: Index,
827 k: int = DEFAULT_K,
828 temperature: float = DEFAULT_TEMPERATURE,
829 mask_absent: bool = True,
830 model_name: str = "knn",
831 fitted: Fit | None = None,
832) -> dict[str, Any]:
833 """Decide every case in *cases_path* and build a plan-288 result payload.
834
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.
838
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.
843 """
844
845 func = "evaluate"
846 cases = read_cases(cases_path)
847 rows: list[dict[str, Any]] = []
848 ties = 0
849 for case in cases:
850 started = time.perf_counter()
851 probs = classify(case["command"], index, k, temperature, mask_absent)
852 verdict, tied = verdict_of(probs)
853 elapsed_ms = (time.perf_counter() - started) * 1000.0
854 ties += int(tied)
855 rows.append({
856 "command": case["command"],
857 "truth": case["truth"],
858 "slice": case["slice"],
859 "pred": verdict,
860 "probs": probs,
861 "ms": elapsed_ms,
862 "reason": case["reason"],
863 "tie": tied,
864 })
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,
880 "k": k,
881 "temperature": temperature,
882 "mask_absent": mask_absent,
883 "tie_break": TIE_BREAK,
884 "ties_broken": ties,
885 "threads": THREADS,
886 "max_seq_length": MAX_SEQ_LENGTH,
887 }
888 if fitted is not None:
889 provenance["fit"] = {
890 "source": fitted.source,
891 "rows": fitted.rows,
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,
897 }
898 return {
899 "model": model_name,
900 "condition": "corpus",
901 "results": rows,
902 "provenance": provenance,
903 }
904
905
906def write_result(payload: dict[str, Any], out_path: Path) -> Path:
907 """Write one result payload where :mod:`score` can find it."""
908
909 func = "write_result"
910 target = Path(out_path)
911 target.parent.mkdir(parents=True, exist_ok=True)
912 target.write_text(
913 json.dumps(payload, indent=1, sort_keys=True) + "\n", encoding="utf-8"
914 )
915 _dbg("OC_CHAT.MAIN", func, f"wrote {target}", 2)
916 return target
917
918
919def render_fit_table(fitted: Fit, mask_absent: bool | None = None) -> str:
920 """The validation-Brier table W2 requires in the report, as markdown.
921
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.
924 """
925
926 lines = [
927 "| k | temperature | masking | validation Brier | validation agreement | ties |",
928 "|---:|---:|---|---:|---:|---:|",
929 ]
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"
935 lines.append(
936 f"| {cell.k} | {cell.temperature} | {masking} | {cell.brier:.4f} "
937 f"| {cell.agreement} | {cell.ties} |"
938 )
939 return "\n".join(lines) + "\n"
940
941
942def render_masking_table(fitted: Fit) -> str:
943 """Best cell per ``k`` under each reading of P1's softmax, as markdown.
944
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.
947 """
948
949 lines = [
950 "| k | masked: best T | masked Brier | unmasked: best T | unmasked Brier |",
951 "|---:|---:|---:|---:|---:|",
952 ]
953 for k in sorted({cell.k for cell in fitted.grid}):
954 row = [str(k)]
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]
958 if not arm:
959 row.extend(["-", "-"])
960 continue
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"
965
966
967def _make_embedder(kind: str) -> Any:
968 """Build the embedder the CLI was asked for."""
969
970 if kind == "hash":
971 return HashEmbedder()
972 if kind == "jina":
974 if kind == "lmstudio":
975 return LMStudioEmbedder()
976 raise ValueError(f"unknown embedder {kind!r}")
977
978
979def _build_parser() -> argparse.ArgumentParser:
980 """Construct the CLI argument parser."""
981
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.",
985 )
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,
996 # OCT-LINT: disable=no_hardcoded_secrets reason="plan 289: argparse help text naming the default index path; no credential (operator ruling 2026-09-17)"
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.")
1010 return parser
1011
1012
1013def _leg_tag(args: argparse.Namespace) -> str:
1014 """The short name identifying one embedder-and-corpus-size leg."""
1015
1016 if args.tag:
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}"
1021
1022
1023def main(argv: list[str] | None = None) -> int:
1024 """CLI entry point: build the index, fit on validation, score both sets."""
1025
1026 func = "main"
1027 args = _build_parser().parse_args(argv)
1028 bench_dir = Path(__file__).resolve().parent.parent
1029 tag = _leg_tag(args)
1030 index_dir = Path(args.index_dir) if args.index_dir else (
1031 bench_dir / "results" / f"knn_index_{tag}"
1032 )
1033 out_dir = Path(args.out_dir) if args.out_dir else bench_dir / "results"
1034
1035 if args.stage in ("build", "all"):
1036 if args.stage == "build" or not (index_dir / "index.json").is_file():
1037 embedder = _make_embedder(args.embedder)
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)
1042 else:
1043 print(f"knn: reusing the index at {index_dir}")
1044 if args.stage == "build":
1045 _dbg("OC_CHAT.MAIN", func, "exit 0", 2)
1046 return 0
1047
1048 index = load_index(index_dir, _make_embedder(args.embedder))
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")
1052
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}")
1060 print()
1061 print(render_fit_table(fitted, mask_absent=True))
1062 print(render_masking_table(fitted))
1063 if args.fit_json:
1064 Path(args.fit_json).write_text(
1065 json.dumps({
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",
1076 encoding="utf-8",
1077 )
1078 print(f"knn: wrote {args.fit_json}")
1079 if args.stage == "fit":
1080 _dbg("OC_CHAT.MAIN", func, "exit 0", 2)
1081 return 0
1082
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
1086 )
1087 mask_absent = False if args.unmasked else (
1088 fitted.mask_absent if fitted else True
1089 )
1090 for condition, case_file in (("hand", "cases.json"), ("hard", "cases_hard.json")):
1091 payload = evaluate(
1092 bench_dir / case_file, index, k, temperature, mask_absent,
1093 model_name=f"knn_{tag}_{condition}", fitted=fitted,
1094 )
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)
1100 return 0
1101
1102
1103if __name__ == "__main__":
1104 sys.exit(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)
Any encode(self, Sequence[str] texts)
None __init__(self, str model_id=JINA_MODEL_ID, int max_seq_length=MAX_SEQ_LENGTH, int threads=THREADS, 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)