cc-transcript 8.0.0__tar.gz → 9.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/PKG-INFO +6 -2
  2. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/filterspec.py +4 -1
  3. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/judge/__init__.py +13 -0
  4. cc_transcript-9.0.0/cc_transcript/judge/similar.py +371 -0
  5. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/judge/verdicts.py +202 -47
  6. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/mining/__init__.py +8 -1
  7. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/mining/signals.py +161 -0
  8. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/mining/sourcekind.py +2 -1
  9. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/mining/spec.py +3 -1
  10. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/pyproject.toml +7 -2
  11. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/rust/src/mining.rs +288 -2
  12. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/rust/src/protocol.rs +2 -0
  13. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/Cargo.lock +0 -0
  14. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/Cargo.toml +0 -0
  15. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/LICENSE +0 -0
  16. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/README.md +0 -0
  17. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/__init__.py +0 -0
  18. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/__main__.py +0 -0
  19. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/_parser_rs.pyi +0 -0
  20. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/activity.py +0 -0
  21. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/backend.py +0 -0
  22. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/builders.py +0 -0
  23. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/cli.py +0 -0
  24. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/command.py +0 -0
  25. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/context.py +0 -0
  26. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/corrections.py +0 -0
  27. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/corrections_cli.py +0 -0
  28. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/cost.py +0 -0
  29. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/decisions.py +0 -0
  30. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/discovery.py +0 -0
  31. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/disktruth.py +0 -0
  32. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/evidence.py +0 -0
  33. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/extract/__init__.py +0 -0
  34. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/extract/correct.py +0 -0
  35. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/facts.py +0 -0
  36. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/ids.py +0 -0
  37. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/judge/llm.py +0 -0
  38. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/mining/candidates.py +0 -0
  39. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/mining/confidence.py +0 -0
  40. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/mining/engine.py +0 -0
  41. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/mining/filterspec.py +0 -0
  42. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/mining/formats.py +0 -0
  43. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/mining/store.py +0 -0
  44. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/models.py +0 -0
  45. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/parser.py +0 -0
  46. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/py.typed +0 -0
  47. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/query.py +0 -0
  48. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/render.py +0 -0
  49. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/rust.py +0 -0
  50. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/sentiment/__init__.py +0 -0
  51. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/sentiment/buckets.py +0 -0
  52. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/sentiment/engine.py +0 -0
  53. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/sentiment/lexicon.py +0 -0
  54. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/sentiment/scorespec.py +0 -0
  55. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/store.py +0 -0
  56. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/cc_transcript/tools.py +0 -0
  57. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/rust/Cargo.toml +0 -0
  58. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/rust/data/afinn-en-165.tsv +0 -0
  59. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/rust/data/domain_overrides.tsv +0 -0
  60. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/rust/src/command.rs +0 -0
  61. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/rust/src/event.rs +0 -0
  62. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/rust/src/filter.rs +0 -0
  63. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/rust/src/lexicon.rs +0 -0
  64. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/rust/src/lib.rs +0 -0
  65. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/rust/src/model.rs +0 -0
  66. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/rust/src/score.rs +0 -0
  67. {cc_transcript-8.0.0 → cc_transcript-9.0.0}/rust/src/value.rs +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cc-transcript
3
- Version: 8.0.0
3
+ Version: 9.0.0
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Environment :: Console
6
6
  Classifier: Intended Audience :: Developers
@@ -18,10 +18,14 @@ Requires-Dist: tree-sitter-bash>=0.25
18
18
  Requires-Dist: pytest>=8.0 ; extra == 'dev'
19
19
  Requires-Dist: ty>=0.0.44 ; extra == 'dev'
20
20
  Requires-Dist: ruff>=0.8 ; extra == 'dev'
21
- Requires-Dist: spawnllm>=0.5.1,<0.6 ; extra == 'llm'
21
+ Requires-Dist: sqlite-vec>=0.1.9 ; extra == 'judge'
22
+ Requires-Dist: model2vec>=0.8.2 ; extra == 'judge'
23
+ Requires-Dist: numpy>=1.26 ; extra == 'judge'
24
+ Requires-Dist: spawnllm>=0.5.4,<0.6 ; extra == 'llm'
22
25
  Requires-Dist: spacy>=3.8 ; extra == 'sentiment'
23
26
  Requires-Dist: afinn>=0.1 ; extra == 'sentiment'
24
27
  Provides-Extra: dev
28
+ Provides-Extra: judge
25
29
  Provides-Extra: llm
26
30
  Provides-Extra: sentiment
27
31
  License-File: LICENSE
@@ -70,10 +70,13 @@ INTERRUPT_MARKER_GROUPS: tuple[tuple[str, str], ...] = (("interrupt", r"^\s*\[Re
70
70
  STOP_HOOK_GROUPS: tuple[tuple[str, str], ...] = (("stop_hook", r"Stop hook feedback:"),)
71
71
 
72
72
  # Raw CC-injected protocol strings carried in tool-result content: the denial banner
73
- # and the markers that wrap the user's verbatim instruction in a rejected tool use.
73
+ # and the markers that wrap the user's verbatim instruction in a rejected tool use,
74
+ # and the banner pair that wraps an answered AskUserQuestion round.
74
75
  DENIAL_PREFIX = "The user doesn't want to proceed with this tool use. The tool use was rejected"
75
76
  USER_SAID_MARKER = "To tell you how to proceed, the user said:\n"
76
77
  USER_SAID_TRAILER = "Note: The user's next message"
78
+ ANSWERED_PREFIX = "Your questions have been answered: "
79
+ ANSWERED_TRAILER = ". You can now continue with these answers in mind."
77
80
 
78
81
  # Approve-and-advance directives: a user telling the agent to proceed/commit/push or
79
82
  # to resume killed work. They follow an assistant turn but advance it rather than
@@ -13,7 +13,18 @@ prompts, the verdict model, and any SQL views over the verdict table.
13
13
  from __future__ import annotations
14
14
 
15
15
  from cc_transcript.judge.llm import default_backend, resolved_model, structured_judge
16
+ from cc_transcript.judge.similar import (
17
+ Evidence,
18
+ KeyOverlap,
19
+ Suggestion,
20
+ default_embedder,
21
+ embed_evidence,
22
+ near_duplicate_keys,
23
+ record_evidence,
24
+ suggest_canonical_keys,
25
+ )
16
26
  from cc_transcript.judge.verdicts import (
27
+ SLUG_PATTERN,
17
28
  AuditEstimate,
18
29
  AuditSample,
19
30
  Disagreement,
@@ -25,7 +36,9 @@ from cc_transcript.judge.verdicts import (
25
36
  JudgeError,
26
37
  Metrics,
27
38
  VerdictLike,
39
+ VerdictSchemaError,
28
40
  VerdictStoreMixin,
41
+ canonical_slug,
29
42
  exact_upper_bound,
30
43
  flip_pairs,
31
44
  golden_result,
@@ -0,0 +1,371 @@
1
+ """Canonical-key retrieval over verdict evidence, via a sqlite-vec companion store.
2
+
3
+ When a verdict assigns a ``canonical_key``, :func:`record_evidence` embeds the
4
+ judged event's feedback with a static text model (``potion-retrieval-32M``) and
5
+ upserts the vector into a sqlite-vec table that lives in the verdict store's own
6
+ database — created on first use, never part of the base schema. Two read paths
7
+ sit on top: :func:`suggest_canonical_keys` ranks stored keys by evidence
8
+ similarity to a new correction, and :func:`near_duplicate_keys` flags distinct
9
+ keys whose evidence centroids nearly coincide (split detection; nothing merges).
10
+
11
+ The vector deps (``sqlite-vec``, ``model2vec``, ``numpy``) live behind the
12
+ ``cc-transcript[judge]`` extra and load lazily, so importing this module needs
13
+ none of them installed; an app that assigns canonical keys without the extra
14
+ fails loud with an :class:`ImportError` naming it.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from functools import cache
20
+ from importlib.util import find_spec
21
+ from typing import TYPE_CHECKING, NamedTuple
22
+ from weakref import WeakSet
23
+
24
+ import anyio.to_thread
25
+
26
+ if TYPE_CHECKING:
27
+ from collections.abc import Callable
28
+
29
+ import aiosqlite
30
+ import numpy as np
31
+
32
+ from cc_transcript.mining.candidates import DedupKey
33
+ from cc_transcript.mining.store import FeedbackStore
34
+ from cc_transcript.store import FileStateStore
35
+
36
+ EMBED_MODEL = "minishlab/potion-retrieval-32M"
37
+ EMBED_DIM = 512
38
+ JUDGE_EXTRA = "cc-transcript[judge]"
39
+ JUDGE_DEPS = ("model2vec", "numpy", "sqlite_vec")
40
+
41
+ VECTOR_SCHEMA = f"""
42
+ CREATE VIRTUAL TABLE IF NOT EXISTS verdict_vectors USING vec0(
43
+ vector_id TEXT PRIMARY KEY,
44
+ embedding float[{EMBED_DIM}] distance_metric=cosine
45
+ );
46
+ CREATE TABLE IF NOT EXISTS verdict_evidence (
47
+ vector_id TEXT PRIMARY KEY,
48
+ dedup_key TEXT NOT NULL,
49
+ role TEXT NOT NULL,
50
+ prompt_version INTEGER NOT NULL,
51
+ canonical_key TEXT NOT NULL,
52
+ evidence_text TEXT NOT NULL
53
+ );
54
+ """
55
+
56
+ type Embedder = Callable[[str], np.ndarray]
57
+
58
+ PREPARED_CONNECTIONS: WeakSet[aiosqlite.Connection] = WeakSet()
59
+
60
+
61
+ class Suggestion(NamedTuple):
62
+ """One canonical-key suggestion ranked by evidence similarity.
63
+
64
+ Attributes:
65
+ canonical_key: The suggested durable-rule key.
66
+ score: Cosine similarity of the query to the key's nearest evidence
67
+ vector, in ``[-1, 1]``; higher is closer.
68
+ sentences: Up to three evidence texts backing the key, most similar first.
69
+ """
70
+
71
+ canonical_key: str
72
+ score: float
73
+ sentences: tuple[str, ...]
74
+
75
+
76
+ class Evidence(NamedTuple):
77
+ """A judged event's embedded feedback, ready to upsert inside the verdict transaction.
78
+
79
+ Attributes:
80
+ vector: The serialized 512-dim embedding of the feedback plus the verdict summary.
81
+ text: The event's feedback text, stored verbatim as the evidence sentence.
82
+ canonical_key: The durable-rule key the verdict assigned.
83
+ """
84
+
85
+ vector: bytes
86
+ text: str
87
+ canonical_key: str
88
+
89
+
90
+ class KeyOverlap(NamedTuple):
91
+ """Two distinct canonical keys whose evidence centroids nearly coincide.
92
+
93
+ Attributes:
94
+ key_a: The lexicographically smaller key.
95
+ key_b: The lexicographically larger key.
96
+ similarity: Cosine similarity of the two evidence centroids.
97
+ """
98
+
99
+ key_a: str
100
+ key_b: str
101
+ similarity: float
102
+
103
+
104
+ def require_judge_extra() -> None:
105
+ """Raises a clear :class:`ImportError` naming the extra when a vector dep is missing."""
106
+ if any(find_spec(name) is None for name in JUDGE_DEPS):
107
+ raise ImportError(f"canonical-key retrieval needs the vector-store deps; install {JUDGE_EXTRA}")
108
+
109
+
110
+ @cache
111
+ def default_embedder() -> Embedder:
112
+ """The ``potion-retrieval-32M`` static embedder, loaded once and cached per process.
113
+
114
+ Returns a callable mapping one text to its L2-normalized 512-dim ``float32``
115
+ embedding. The model downloads from the Hugging Face hub on first use and is
116
+ cached there; inference is numpy-only. Tests inject a deterministic stand-in
117
+ by monkeypatching this loader.
118
+
119
+ Raises:
120
+ ImportError: When the ``cc-transcript[judge]`` extra is not installed.
121
+ """
122
+ require_judge_extra()
123
+ import numpy as np
124
+ from model2vec import StaticModel
125
+
126
+ model = StaticModel.from_pretrained(EMBED_MODEL)
127
+
128
+ def embed(text: str) -> np.ndarray:
129
+ vector = model.encode([text])[0].astype(np.float32)
130
+ return vector / np.linalg.norm(vector)
131
+
132
+ return embed
133
+
134
+
135
+ async def prepare_connection(store: FileStateStore) -> None:
136
+ """Loads the sqlite-vec extension and creates the companion tables, once per connection.
137
+
138
+ Holds ``store.lock`` around the check-and-prepare so the schema-creating
139
+ ``executescript`` — which implicitly commits the connection — can never fire
140
+ while another task holds an open :meth:`~cc_transcript.store.FileStateStore.transaction`,
141
+ and so the "already prepared" test-and-set stays atomic under concurrent
142
+ :meth:`~cc_transcript.judge.verdicts.VerdictStoreMixin.record_verdict` fan-out.
143
+ """
144
+ import sqlite_vec
145
+
146
+ async with store.lock:
147
+ if store.conn in PREPARED_CONNECTIONS:
148
+ return
149
+ await store.conn.enable_load_extension(True)
150
+ await store.conn.load_extension(sqlite_vec.loadable_path())
151
+ await store.conn.enable_load_extension(False)
152
+ await store.conn.executescript(VECTOR_SCHEMA)
153
+ PREPARED_CONNECTIONS.add(store.conn)
154
+
155
+
156
+ def serialize_vector(vector: np.ndarray) -> bytes:
157
+ import sqlite_vec
158
+
159
+ return sqlite_vec.serialize_float32(vector.tolist())
160
+
161
+
162
+ async def embed_evidence(store: FileStateStore, *, dedup_key: DedupKey, canonical_key: str, summary: str) -> Evidence:
163
+ """Prepares the vector store and embeds a judged event's feedback for upsert.
164
+
165
+ Fetches the event's feedback text from ``feedback_events`` and embeds it
166
+ together with ``summary``, off the event loop. Runs the schema-creating
167
+ :func:`prepare_connection` and the feedback read under ``store.lock``, both
168
+ outside the caller's transaction: ``prepare_connection``'s ``executescript``
169
+ implicitly commits an open transaction, so the caller calls this before
170
+ ``BEGIN`` and then upserts the returned :class:`Evidence` atomically via
171
+ :func:`record_evidence`. Called from
172
+ :meth:`~cc_transcript.judge.verdicts.VerdictStoreMixin.record_verdict`
173
+ whenever a verdict assigns a ``canonical_key``.
174
+
175
+ Args:
176
+ store: The verdict store; the vectors live in its database.
177
+ dedup_key: The judged event's dedup key.
178
+ canonical_key: The durable-rule key the verdict assigned.
179
+ summary: The verdict's one-sentence summary, embedded with the feedback.
180
+
181
+ Returns:
182
+ The serialized vector, evidence text, and canonical key, ready to upsert.
183
+
184
+ Raises:
185
+ ImportError: When the ``cc-transcript[judge]`` extra is not installed.
186
+ """
187
+ require_judge_extra()
188
+ await prepare_connection(store)
189
+ async with store.lock:
190
+ cursor = await store.conn.execute("SELECT text FROM feedback_events WHERE dedup_key = ?", (dedup_key,))
191
+ text = (await cursor.fetchone())["text"]
192
+ embedder = await anyio.to_thread.run_sync(default_embedder)
193
+ vector = await anyio.to_thread.run_sync(embedder, f"{text}\n{summary}")
194
+ return Evidence(serialize_vector(vector), text, canonical_key)
195
+
196
+
197
+ def evidence_vector_id(dedup_key: DedupKey, role: str, prompt_version: int) -> str:
198
+ return f"{dedup_key}::{role}::{prompt_version}"
199
+
200
+
201
+ async def clear_evidence(conn: aiosqlite.Connection, *, dedup_key: DedupKey, role: str, prompt_version: int) -> None:
202
+ """Deletes any evidence vector for one verdict identity, on the caller's transaction.
203
+
204
+ Both the delete pair :func:`record_evidence` runs before it re-inserts, and
205
+ the sole cleanup when a verdict is upgraded to name no ``canonical_key``: the
206
+ removal commits atomically with the verdict row that dropped the key, so a
207
+ dropped rule never leaves stale evidence for :func:`suggest_canonical_keys`
208
+ to surface.
209
+
210
+ Args:
211
+ conn: The verdict store's connection, inside the caller's write transaction.
212
+ dedup_key: The judged event's dedup key.
213
+ role: Who produced the verdict, e.g. ``judge`` or ``auditor``.
214
+ prompt_version: The prompt version that produced the verdict.
215
+ """
216
+ vector_id = evidence_vector_id(dedup_key, role, prompt_version)
217
+ await conn.execute("DELETE FROM verdict_vectors WHERE vector_id = ?", (vector_id,))
218
+ await conn.execute("DELETE FROM verdict_evidence WHERE vector_id = ?", (vector_id,))
219
+
220
+
221
+ async def record_evidence(
222
+ conn: aiosqlite.Connection, *, dedup_key: DedupKey, role: str, prompt_version: int, evidence: Evidence
223
+ ) -> None:
224
+ """Upserts a judged event's evidence vector inside the caller's open transaction.
225
+
226
+ Replaces any prior vector keyed ``(dedup_key, role, prompt_version)`` — one
227
+ vector per event per role per prompt version, mirroring the verdict's own
228
+ identity so a ``judge`` and an ``auditor`` verdict on the same event never
229
+ overwrite each other's evidence. The delete/insert pairs run on the caller's
230
+ already-begun transaction, so a failure rolls the verdict row back with them;
231
+ the failure-prone prepare and embed happen earlier in :func:`embed_evidence`.
232
+
233
+ Args:
234
+ conn: The verdict store's connection, inside the caller's write transaction.
235
+ dedup_key: The judged event's dedup key.
236
+ role: Who produced the verdict, e.g. ``judge`` or ``auditor``.
237
+ prompt_version: The prompt version that produced the verdict.
238
+ evidence: The embedded feedback from :func:`embed_evidence`.
239
+ """
240
+ await clear_evidence(conn, dedup_key=dedup_key, role=role, prompt_version=prompt_version)
241
+ vector_id = evidence_vector_id(dedup_key, role, prompt_version)
242
+ await conn.execute("INSERT INTO verdict_vectors(vector_id, embedding) VALUES (?, ?)", (vector_id, evidence.vector))
243
+ await conn.execute(
244
+ "INSERT INTO verdict_evidence(vector_id, dedup_key, role, prompt_version, canonical_key, evidence_text) "
245
+ "VALUES (?, ?, ?, ?, ?, ?)",
246
+ (vector_id, dedup_key, role, prompt_version, evidence.canonical_key, evidence.text),
247
+ )
248
+
249
+
250
+ async def prepare_evidence_removal(store: FileStateStore) -> bool:
251
+ """Readies the vec companion so an in-transaction :func:`clear_evidence` can reach it.
252
+
253
+ Returns False — skip the removal — whenever no evidence could exist: ``sqlite_vec``
254
+ is absent, or the companion tables were never created. The removal path embeds
255
+ nothing, so it gates on ``sqlite_vec`` alone — the ``model2vec``/``numpy``
256
+ embedder deps that :func:`require_judge_extra` guards belong to the insert side,
257
+ and a partial install (``sqlite_vec`` present, embedder absent) must still clear
258
+ stranded evidence. Otherwise loads the extension onto the connection (a no-op
259
+ once the connection is prepared) so a later :func:`clear_evidence` on the
260
+ caller's transaction can delete from the ``vec0`` virtual table, and returns
261
+ True. Called before the verdict transaction opens, mirroring
262
+ :func:`embed_evidence`, because :func:`prepare_connection`'s ``executescript``
263
+ commits.
264
+
265
+ Args:
266
+ store: The verdict store; the vectors live in its database.
267
+ """
268
+ if store.conn in PREPARED_CONNECTIONS:
269
+ return True
270
+ if find_spec("sqlite_vec") is None:
271
+ return False
272
+ async with store.lock:
273
+ cursor = await store.conn.execute(
274
+ "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'verdict_evidence'"
275
+ )
276
+ if await cursor.fetchone() is None:
277
+ return False
278
+ await prepare_connection(store)
279
+ return True
280
+
281
+
282
+ async def suggest_canonical_keys(
283
+ store: FeedbackStore, text: str, *, prompt_version: int, k: int = 5
284
+ ) -> list[Suggestion]:
285
+ """Ranks stored canonical keys by evidence similarity to ``text``.
286
+
287
+ Embeds ``text`` and scans every evidence vector recorded at ``prompt_version``,
288
+ ranking each key by its single closest evidence vector (cosine similarity).
289
+ Returns the top ``k`` keys, each with up to three backing evidence sentences
290
+ ordered most-similar first.
291
+
292
+ Args:
293
+ store: The verdict store whose database holds the evidence vectors.
294
+ text: The free text to find canonical keys for, e.g. a new correction.
295
+ prompt_version: The prompt version whose evidence to search.
296
+ k: The maximum number of distinct canonical keys to return.
297
+
298
+ Returns:
299
+ Up to ``k`` :class:`Suggestion`s, highest score first.
300
+
301
+ Raises:
302
+ ImportError: When the ``cc-transcript[judge]`` extra is not installed.
303
+ """
304
+ require_judge_extra()
305
+ conn = store.store.conn
306
+ await prepare_connection(store.store)
307
+ embedder = await anyio.to_thread.run_sync(default_embedder)
308
+ query = serialize_vector(await anyio.to_thread.run_sync(embedder, text))
309
+ cur = await conn.execute(
310
+ "SELECT e.canonical_key AS ck, e.evidence_text AS ev, vec_distance_cosine(v.embedding, ?) AS dist "
311
+ "FROM verdict_vectors v JOIN verdict_evidence e ON e.vector_id = v.vector_id "
312
+ "WHERE e.prompt_version = ? ORDER BY dist",
313
+ (query, prompt_version),
314
+ )
315
+ ranked: dict[str, list[tuple[float, str]]] = {}
316
+ async for row in cur:
317
+ ranked.setdefault(row["ck"], []).append((1.0 - row["dist"], row["ev"]))
318
+ return sorted(
319
+ (Suggestion(ck, hits[0][0], tuple(ev for _, ev in hits[:3])) for ck, hits in ranked.items()),
320
+ key=lambda suggestion: suggestion.score,
321
+ reverse=True,
322
+ )[:k]
323
+
324
+
325
+ async def near_duplicate_keys(store: FeedbackStore, *, prompt_version: int, threshold: float) -> list[KeyOverlap]:
326
+ """Finds distinct canonical keys whose evidence centroids nearly coincide.
327
+
328
+ Groups the evidence vectors recorded at ``prompt_version`` by canonical key,
329
+ reduces each group to a normalized centroid, and returns every pair of
330
+ distinct keys whose centroid cosine similarity exceeds ``threshold`` — a
331
+ split-detection signal that two keys may name the same rule. Nothing merges;
332
+ the caller decides.
333
+
334
+ Args:
335
+ store: The verdict store whose database holds the evidence vectors.
336
+ prompt_version: The prompt version whose evidence to compare.
337
+ threshold: The exclusive cosine-similarity floor a pair must clear.
338
+
339
+ Returns:
340
+ Overlapping key pairs, highest similarity first, each pair's keys ordered
341
+ lexicographically.
342
+
343
+ Raises:
344
+ ImportError: When the ``cc-transcript[judge]`` extra is not installed.
345
+ """
346
+ require_judge_extra()
347
+ import numpy as np
348
+
349
+ conn = store.store.conn
350
+ await prepare_connection(store.store)
351
+ cur = await conn.execute(
352
+ "SELECT e.canonical_key AS ck, v.embedding AS emb "
353
+ "FROM verdict_vectors v JOIN verdict_evidence e ON e.vector_id = v.vector_id "
354
+ "WHERE e.prompt_version = ?",
355
+ (prompt_version,),
356
+ )
357
+ groups: dict[str, list[np.ndarray]] = {}
358
+ async for row in cur:
359
+ groups.setdefault(row["ck"], []).append(np.frombuffer(row["emb"], dtype=np.float32))
360
+ centroids = {ck: (mean := np.mean(vectors, axis=0)) / np.linalg.norm(mean) for ck, vectors in groups.items()}
361
+ keys = sorted(centroids)
362
+ return sorted(
363
+ (
364
+ KeyOverlap(key_a, key_b, similarity)
365
+ for i, key_a in enumerate(keys)
366
+ for key_b in keys[i + 1 :]
367
+ if (similarity := float(np.dot(centroids[key_a], centroids[key_b]))) > threshold
368
+ ),
369
+ key=lambda overlap: overlap.similarity,
370
+ reverse=True,
371
+ )