ragaudit-cli 0.3.0__py3-none-any.whl

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 (71) hide show
  1. ragaudit/__init__.py +1 -0
  2. ragaudit/adapters/__init__.py +0 -0
  3. ragaudit/adapters/base.py +109 -0
  4. ragaudit/adapters/callable_adapter.py +15 -0
  5. ragaudit/cli.py +540 -0
  6. ragaudit/cli_easy.py +458 -0
  7. ragaudit/cli_v2.py +507 -0
  8. ragaudit/env.py +46 -0
  9. ragaudit/generate/__init__.py +0 -0
  10. ragaudit/generate/pipeline.py +486 -0
  11. ragaudit/generate/prompts.py +116 -0
  12. ragaudit/generate/testset_generator.py +146 -0
  13. ragaudit/generate/text.py +75 -0
  14. ragaudit/ingest/__init__.py +0 -0
  15. ragaudit/ingest/chunker.py +53 -0
  16. ragaudit/ingest/corpus.py +120 -0
  17. ragaudit/ingest/loader.py +49 -0
  18. ragaudit/ingest/versioning.py +92 -0
  19. ragaudit/llm/__init__.py +0 -0
  20. ragaudit/llm/client.py +86 -0
  21. ragaudit/llm/factory.py +70 -0
  22. ragaudit/llm/json_output.py +72 -0
  23. ragaudit/llm/mock.py +552 -0
  24. ragaudit/models/__init__.py +0 -0
  25. ragaudit/models/adapter.py +39 -0
  26. ragaudit/models/document.py +28 -0
  27. ragaudit/models/evaluation.py +70 -0
  28. ragaudit/models/question.py +152 -0
  29. ragaudit/models/run.py +44 -0
  30. ragaudit/models/scoring.py +82 -0
  31. ragaudit/models/testset.py +30 -0
  32. ragaudit/models/tune.py +56 -0
  33. ragaudit/report/__init__.py +0 -0
  34. ragaudit/report/generator.py +155 -0
  35. ragaudit/report/plain_summary.py +122 -0
  36. ragaudit/report/templates/_v2_tooltip.jinja +43 -0
  37. ragaudit/report/templates/generate_report.html.jinja +175 -0
  38. ragaudit/report/templates/report.html.jinja +252 -0
  39. ragaudit/report/templates/style.css +1095 -0
  40. ragaudit/report/templates/tune_report.html.jinja +158 -0
  41. ragaudit/report/templates/v2.css +286 -0
  42. ragaudit/report/v2.py +380 -0
  43. ragaudit/runner/__init__.py +0 -0
  44. ragaudit/runner/ablation.py +46 -0
  45. ragaudit/runner/evaluate.py +325 -0
  46. ragaudit/runner/executor.py +98 -0
  47. ragaudit/runner/locked.py +37 -0
  48. ragaudit/runner/summary.py +45 -0
  49. ragaudit/scaffold.py +252 -0
  50. ragaudit/scoring/__init__.py +0 -0
  51. ragaudit/scoring/correctness.py +94 -0
  52. ragaudit/scoring/groundedness.py +86 -0
  53. ragaudit/scoring/retrieval.py +42 -0
  54. ragaudit/scoring/text_match.py +51 -0
  55. ragaudit/scoring/verdict.py +14 -0
  56. ragaudit/storage/__init__.py +0 -0
  57. ragaudit/storage/db.py +81 -0
  58. ragaudit/storage/lock.py +36 -0
  59. ragaudit/storage/repository.py +216 -0
  60. ragaudit/storage/store.py +536 -0
  61. ragaudit/tune/__init__.py +7 -0
  62. ragaudit/tune/devset.py +16 -0
  63. ragaudit/tune/loop.py +208 -0
  64. ragaudit/tune/search_space.py +140 -0
  65. ragaudit/tune/strategies.py +197 -0
  66. ragaudit/yaml_io.py +199 -0
  67. ragaudit_cli-0.3.0.dist-info/METADATA +429 -0
  68. ragaudit_cli-0.3.0.dist-info/RECORD +71 -0
  69. ragaudit_cli-0.3.0.dist-info/WHEEL +4 -0
  70. ragaudit_cli-0.3.0.dist-info/entry_points.txt +2 -0
  71. ragaudit_cli-0.3.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,536 @@
1
+ """The v2 store: questions, corpus versions, cached eval results, and tuning logs.
2
+
3
+ A separate SQLite file from v1's `ragaudit.db` (default `.ragaudit/store.db`)
4
+ so v1 tables and behaviour are never touched. Uses the stdlib `sqlite3`
5
+ module only, with a one-row `schema_version` table so a future schema
6
+ change can tell old stores apart from new ones. Opening a schema-1 store
7
+ migrates it (see `_migrate_v1_to_v2`).
8
+
9
+ Every read that could return test-split questions or results goes through
10
+ a guard: unless the final evaluation path in `ragaudit.runner.locked` has
11
+ unlocked the test split, it raises LockedTestSetError. Rejected questions
12
+ are exempt, since they are never scored and so can't leak anything.
13
+ """
14
+
15
+ import json
16
+ import sqlite3
17
+ from collections import Counter
18
+ from collections.abc import Iterable
19
+ from datetime import datetime, timezone
20
+ from pathlib import Path
21
+
22
+ from ragaudit.models.evaluation import EvalSummary, QuestionResult
23
+ from ragaudit.models.question import Question, QuestionStatus, QuestionType, RejectReason, Split, split_for_chunks
24
+ from ragaudit.models.tune import Trial, TuneResult
25
+ from ragaudit.storage.lock import LockedTestSetError, is_test_split_unlocked
26
+
27
+ DEFAULT_STORE_PATH = Path(".ragaudit") / "store.db"
28
+ SCHEMA_VERSION = 2
29
+ """2: splits are per chunk, and `questions.split` is NULL for cross-split rejects."""
30
+
31
+ _QUESTIONS_TABLE = """
32
+ CREATE TABLE IF NOT EXISTS questions (
33
+ question_id TEXT PRIMARY KEY,
34
+ chunk_ids_json TEXT NOT NULL,
35
+ chunk_hashes_json TEXT NOT NULL,
36
+ fact TEXT NOT NULL,
37
+ question TEXT NOT NULL,
38
+ gold_answer TEXT NOT NULL,
39
+ question_type TEXT NOT NULL,
40
+ split TEXT,
41
+ status TEXT NOT NULL,
42
+ reject_reason TEXT,
43
+ created_at TEXT NOT NULL,
44
+ source_timestamp TEXT,
45
+ generator_model TEXT NOT NULL,
46
+ verifier_model TEXT,
47
+ lexical_overlap REAL,
48
+ metadata_json TEXT NOT NULL
49
+ );
50
+ """
51
+
52
+ _QUESTIONS_INDEX = "CREATE INDEX IF NOT EXISTS idx_questions_split_status ON questions (split, status)"
53
+
54
+ SCHEMA = f"""
55
+ CREATE TABLE IF NOT EXISTS schema_version (
56
+ version INTEGER NOT NULL
57
+ );
58
+
59
+ {_QUESTIONS_TABLE.strip()}
60
+ {_QUESTIONS_INDEX};
61
+
62
+ CREATE TABLE IF NOT EXISTS corpus_versions (
63
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
64
+ index_version TEXT NOT NULL,
65
+ created_at TEXT NOT NULL,
66
+ chunk_map_json TEXT NOT NULL
67
+ );
68
+
69
+ CREATE TABLE IF NOT EXISTS generation_attempts (
70
+ source_key TEXT PRIMARY KEY,
71
+ created_at TEXT NOT NULL,
72
+ outcome TEXT NOT NULL
73
+ );
74
+
75
+ CREATE TABLE IF NOT EXISTS generate_runs (
76
+ run_id TEXT PRIMARY KEY,
77
+ created_at TEXT NOT NULL,
78
+ summary_json TEXT NOT NULL
79
+ );
80
+
81
+ CREATE TABLE IF NOT EXISTS eval_results (
82
+ question_id TEXT NOT NULL,
83
+ config_hash TEXT NOT NULL,
84
+ index_version TEXT NOT NULL,
85
+ split TEXT NOT NULL,
86
+ created_at TEXT NOT NULL,
87
+ data_json TEXT NOT NULL,
88
+ PRIMARY KEY (question_id, config_hash, index_version)
89
+ );
90
+
91
+ CREATE TABLE IF NOT EXISTS eval_runs (
92
+ run_id TEXT PRIMARY KEY,
93
+ created_at TEXT NOT NULL,
94
+ config_hash TEXT NOT NULL,
95
+ config_json TEXT NOT NULL,
96
+ index_version TEXT NOT NULL,
97
+ split TEXT NOT NULL,
98
+ mode TEXT NOT NULL,
99
+ summary_json TEXT NOT NULL
100
+ );
101
+
102
+ CREATE TABLE IF NOT EXISTS tune_runs (
103
+ tune_id TEXT PRIMARY KEY,
104
+ created_at TEXT NOT NULL,
105
+ finished_at TEXT,
106
+ strategy TEXT NOT NULL,
107
+ search_space_json TEXT NOT NULL,
108
+ result_json TEXT
109
+ );
110
+
111
+ CREATE TABLE IF NOT EXISTS tune_trials (
112
+ tune_id TEXT NOT NULL,
113
+ trial_index INTEGER NOT NULL,
114
+ created_at TEXT NOT NULL,
115
+ config_hash TEXT NOT NULL,
116
+ data_json TEXT NOT NULL,
117
+ PRIMARY KEY (tune_id, trial_index)
118
+ );
119
+ """
120
+
121
+ _QUESTION_COLUMNS = (
122
+ "question_id, chunk_ids_json, chunk_hashes_json, fact, question, gold_answer, question_type, split, "
123
+ "status, reject_reason, created_at, source_timestamp, generator_model, verifier_model, lexical_overlap, "
124
+ "metadata_json"
125
+ )
126
+
127
+
128
+ class StoreSchemaError(Exception):
129
+ """Raised when the store was written by a newer, incompatible ragaudit."""
130
+
131
+
132
+ def _require_unlocked_for(splits: set[Split], statuses: set[QuestionStatus]) -> None:
133
+ if Split.TEST not in splits or statuses == {QuestionStatus.REJECTED}:
134
+ return
135
+ if not is_test_split_unlocked():
136
+ raise LockedTestSetError(
137
+ "test-split questions and results are locked: they are only readable through the final "
138
+ "evaluation path (ragaudit.runner.locked). Use the dev split for tuning."
139
+ )
140
+
141
+
142
+ def _question_to_row(q: Question) -> tuple:
143
+ return (
144
+ q.question_id,
145
+ json.dumps(q.chunk_ids),
146
+ json.dumps(q.chunk_hashes),
147
+ q.fact,
148
+ q.question,
149
+ q.gold_answer,
150
+ q.question_type.value,
151
+ q.split.value if q.split is not None else None,
152
+ q.status.value,
153
+ q.reject_reason,
154
+ q.created_at.isoformat(),
155
+ q.source_timestamp.isoformat() if q.source_timestamp else None,
156
+ q.generator_model,
157
+ q.verifier_model,
158
+ q.lexical_overlap,
159
+ json.dumps(q.metadata),
160
+ )
161
+
162
+
163
+ def _row_to_question(r: tuple) -> Question:
164
+ return Question(
165
+ question_id=r[0],
166
+ chunk_ids=json.loads(r[1]),
167
+ chunk_hashes=json.loads(r[2]),
168
+ fact=r[3],
169
+ question=r[4],
170
+ gold_answer=r[5],
171
+ question_type=QuestionType(r[6]),
172
+ split=Split(r[7]) if r[7] is not None else None,
173
+ status=QuestionStatus(r[8]),
174
+ reject_reason=r[9],
175
+ created_at=datetime.fromisoformat(r[10]),
176
+ source_timestamp=datetime.fromisoformat(r[11]) if r[11] else None,
177
+ generator_model=r[12],
178
+ verifier_model=r[13],
179
+ lexical_overlap=r[14],
180
+ metadata=json.loads(r[15]),
181
+ )
182
+
183
+
184
+ class Store:
185
+ """A v2 store backed by one SQLite file. Use as a context manager to close it."""
186
+
187
+ def __init__(self, path: str | Path = DEFAULT_STORE_PATH) -> None:
188
+ self.path = path
189
+ if str(path) != ":memory:":
190
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
191
+ self._conn = sqlite3.connect(str(path))
192
+ self._conn.executescript(SCHEMA)
193
+ self._check_schema_version()
194
+ self._conn.commit()
195
+
196
+ def _check_schema_version(self) -> None:
197
+ row = self._conn.execute("SELECT version FROM schema_version").fetchone()
198
+ if row is None:
199
+ self._conn.execute("INSERT INTO schema_version (version) VALUES (?)", (SCHEMA_VERSION,))
200
+ elif row[0] == 1:
201
+ self._migrate_v1_to_v2()
202
+ elif row[0] > SCHEMA_VERSION:
203
+ raise StoreSchemaError(
204
+ f"store {self.path} has schema version {row[0]}, but this ragaudit only understands "
205
+ f"up to {SCHEMA_VERSION}. Upgrade ragaudit."
206
+ )
207
+
208
+ def _migrate_v1_to_v2(self) -> None:
209
+ """Re-split a schema-1 store per chunk.
210
+
211
+ Schema 1 split per question, so one chunk's questions could sit in
212
+ both splits. This recomputes every question's split from its chunk
213
+ ids; a multi-hop question whose chunks now disagree becomes a
214
+ cross_split reject with no split. The questions table is rebuilt to
215
+ allow that NULL. Cached results of questions whose split changed are
216
+ dropped (they're recomputed on the next evaluate); past eval and tune
217
+ run summaries are kept as history.
218
+ """
219
+ conn = self._conn
220
+ conn.commit()
221
+ isolation_level = conn.isolation_level
222
+ conn.isolation_level = None # manage the transaction by hand so the table rebuild is all-or-nothing
223
+ try:
224
+ conn.execute("BEGIN")
225
+ rows = conn.execute(f"SELECT {_QUESTION_COLUMNS} FROM questions").fetchall()
226
+ conn.execute("DROP INDEX IF EXISTS idx_questions_split_status")
227
+ conn.execute("ALTER TABLE questions RENAME TO questions_v1")
228
+ conn.execute(_QUESTIONS_TABLE)
229
+ conn.execute(_QUESTIONS_INDEX)
230
+ moved = []
231
+ for row in rows:
232
+ row = list(row)
233
+ split = split_for_chunks(json.loads(row[1]))
234
+ if split is None:
235
+ row[8], row[9] = QuestionStatus.REJECTED.value, RejectReason.CROSS_SPLIT.value
236
+ new_split = split.value if split is not None else None
237
+ if new_split != row[7]:
238
+ moved.append(row[0])
239
+ row[7] = new_split
240
+ conn.execute(f"INSERT INTO questions ({_QUESTION_COLUMNS}) VALUES ({', '.join('?' * 16)})", row)
241
+ conn.execute("DROP TABLE questions_v1")
242
+ conn.executemany("DELETE FROM eval_results WHERE question_id = ?", [(qid,) for qid in moved])
243
+ conn.execute("UPDATE schema_version SET version = ?", (SCHEMA_VERSION,))
244
+ conn.execute("COMMIT")
245
+ except BaseException:
246
+ conn.execute("ROLLBACK")
247
+ raise
248
+ finally:
249
+ conn.isolation_level = isolation_level
250
+
251
+ @property
252
+ def schema_version(self) -> int:
253
+ return self._conn.execute("SELECT version FROM schema_version").fetchone()[0]
254
+
255
+ def close(self) -> None:
256
+ self._conn.close()
257
+
258
+ def __enter__(self) -> "Store":
259
+ return self
260
+
261
+ def __exit__(self, *exc_info) -> None:
262
+ self.close()
263
+
264
+ # --- questions ---------------------------------------------------------
265
+
266
+ def save_questions(self, questions: Iterable[Question]) -> int:
267
+ """Insert questions, keeping any existing row with the same id. Returns how many were new.
268
+
269
+ The id is a hash of the source content and fact, so an existing row
270
+ with the same id is the same question; the first write wins.
271
+ """
272
+ inserted = 0
273
+ for q in questions:
274
+ cursor = self._conn.execute(
275
+ f"INSERT OR IGNORE INTO questions ({_QUESTION_COLUMNS}) VALUES ({', '.join('?' * 16)})",
276
+ _question_to_row(q),
277
+ )
278
+ inserted += cursor.rowcount
279
+ self._conn.commit()
280
+ return inserted
281
+
282
+ def load_questions(
283
+ self,
284
+ split: Split | None = None,
285
+ statuses: Iterable[QuestionStatus] = (QuestionStatus.ACTIVE,),
286
+ ) -> list[Question]:
287
+ """Load questions by split and status, oldest first.
288
+
289
+ split=None means both splits. Any request that could include
290
+ non-rejected test-split questions raises LockedTestSetError unless
291
+ the final evaluation path has unlocked the test split.
292
+ """
293
+ status_set = set(statuses)
294
+ split_set = {split} if split is not None else set(Split)
295
+ _require_unlocked_for(split_set, status_set)
296
+
297
+ split_marks = ", ".join("?" * len(split_set))
298
+ status_marks = ", ".join("?" * len(status_set))
299
+ rows = self._conn.execute(
300
+ f"SELECT {_QUESTION_COLUMNS} FROM questions "
301
+ f"WHERE split IN ({split_marks}) AND status IN ({status_marks}) "
302
+ "ORDER BY created_at, question_id",
303
+ (*[s.value for s in split_set], *[s.value for s in status_set]),
304
+ ).fetchall()
305
+ return [_row_to_question(r) for r in rows]
306
+
307
+ def dev_questions(self) -> list[Question]:
308
+ """Active dev-split questions, with answers. The only question view `tune` is given."""
309
+ return self.load_questions(split=Split.DEV)
310
+
311
+ def question_ids(self, split: Split, status: QuestionStatus = QuestionStatus.ACTIVE) -> list[str]:
312
+ """Ids only (no text, no answers), so safe for either split."""
313
+ rows = self._conn.execute(
314
+ "SELECT question_id FROM questions WHERE split = ? AND status = ? ORDER BY created_at, question_id",
315
+ (split.value, status.value),
316
+ ).fetchall()
317
+ return [r[0] for r in rows]
318
+
319
+ def question_counts(self) -> dict:
320
+ """Counts by split/status and by reject reason. No question text, so safe for either split.
321
+
322
+ Split "none" holds cross_split rejects, which belong to neither split.
323
+ """
324
+ by_split_status: dict[str, dict[str, int]] = {
325
+ key: {st.value: 0 for st in QuestionStatus} for key in [s.value for s in Split] + ["none"]
326
+ }
327
+ for split, status, count in self._conn.execute(
328
+ "SELECT split, status, COUNT(*) FROM questions GROUP BY split, status"
329
+ ):
330
+ by_split_status[split or "none"][status] = count
331
+ reasons = Counter(
332
+ {
333
+ reason: count
334
+ for reason, count in self._conn.execute(
335
+ "SELECT reject_reason, COUNT(*) FROM questions WHERE status = ? GROUP BY reject_reason",
336
+ (QuestionStatus.REJECTED.value,),
337
+ )
338
+ }
339
+ )
340
+ return {"by_split_status": by_split_status, "reject_reasons": dict(reasons)}
341
+
342
+ def set_question_status(
343
+ self, question_ids: Iterable[str], status: QuestionStatus, reject_reason: str | None = None
344
+ ) -> int:
345
+ """Set status (and reject_reason) on the given questions. Returns how many rows changed."""
346
+ changed = 0
347
+ for question_id in question_ids:
348
+ cursor = self._conn.execute(
349
+ "UPDATE questions SET status = ?, reject_reason = ? WHERE question_id = ?",
350
+ (status.value, reject_reason, question_id),
351
+ )
352
+ changed += cursor.rowcount
353
+ self._conn.commit()
354
+ return changed
355
+
356
+ def active_source_keys(self) -> set[str]:
357
+ """Generation source keys ("chunk_id@hash" joined by "+") that already have active questions."""
358
+ keys = set()
359
+ for ids_json, hashes_json in self._conn.execute(
360
+ "SELECT chunk_ids_json, chunk_hashes_json FROM questions WHERE status = ?",
361
+ (QuestionStatus.ACTIVE.value,),
362
+ ):
363
+ pairs = zip(json.loads(ids_json), json.loads(hashes_json))
364
+ keys.add("+".join(sorted(f"{chunk_id}@{chunk_hash}" for chunk_id, chunk_hash in pairs)))
365
+ return keys
366
+
367
+ # --- generation bookkeeping ---------------------------------------------
368
+
369
+ def attempted_source_keys(self) -> set[str]:
370
+ return {r[0] for r in self._conn.execute("SELECT source_key FROM generation_attempts")}
371
+
372
+ def record_generation_attempt(self, source_key: str, outcome: str) -> None:
373
+ self._conn.execute(
374
+ "INSERT OR REPLACE INTO generation_attempts (source_key, created_at, outcome) VALUES (?, ?, ?)",
375
+ (source_key, datetime.now(timezone.utc).isoformat(), outcome),
376
+ )
377
+ self._conn.commit()
378
+
379
+ def save_generate_run(self, run_id: str, created_at: datetime, summary_json: str) -> None:
380
+ self._conn.execute(
381
+ "INSERT OR REPLACE INTO generate_runs (run_id, created_at, summary_json) VALUES (?, ?, ?)",
382
+ (run_id, created_at.isoformat(), summary_json),
383
+ )
384
+ self._conn.commit()
385
+
386
+ def load_generate_run(self, run_id: str | None = None) -> str | None:
387
+ """Summary JSON of a generate run (the latest if run_id is None), or None."""
388
+ if run_id is None:
389
+ row = self._conn.execute("SELECT summary_json FROM generate_runs ORDER BY created_at DESC LIMIT 1").fetchone()
390
+ else:
391
+ row = self._conn.execute("SELECT summary_json FROM generate_runs WHERE run_id = ?", (run_id,)).fetchone()
392
+ return row[0] if row else None
393
+
394
+ # --- corpus versions and staleness --------------------------------------
395
+
396
+ def latest_corpus_version(self) -> tuple[str, dict[str, str]] | None:
397
+ """(index_version, chunk_id -> chunk_hash) of the last recorded corpus, or None."""
398
+ row = self._conn.execute(
399
+ "SELECT index_version, chunk_map_json FROM corpus_versions ORDER BY seq DESC LIMIT 1"
400
+ ).fetchone()
401
+ return (row[0], json.loads(row[1])) if row else None
402
+
403
+ def record_corpus_version(self, index_version: str, chunk_map: dict[str, str]) -> None:
404
+ self._conn.execute(
405
+ "INSERT INTO corpus_versions (index_version, created_at, chunk_map_json) VALUES (?, ?, ?)",
406
+ (index_version, datetime.now(timezone.utc).isoformat(), json.dumps(chunk_map, sort_keys=True)),
407
+ )
408
+ self._conn.commit()
409
+
410
+ def corpus_version_count(self) -> int:
411
+ return self._conn.execute("SELECT COUNT(*) FROM corpus_versions").fetchone()[0]
412
+
413
+ def refresh_staleness(self, current_chunks: dict[str, str]) -> tuple[int, int]:
414
+ """Mark active questions stale if any source chunk changed or vanished; revive stale ones whose sources are back.
415
+
416
+ Reads only ids and hashes, never question text or answers, so it
417
+ runs over both splits. Returns (marked_stale, revived).
418
+ """
419
+ to_stale, to_revive = [], []
420
+ for question_id, ids_json, hashes_json, status in self._conn.execute(
421
+ "SELECT question_id, chunk_ids_json, chunk_hashes_json, status FROM questions WHERE status IN (?, ?)",
422
+ (QuestionStatus.ACTIVE.value, QuestionStatus.STALE.value),
423
+ ):
424
+ sources_present = all(
425
+ current_chunks.get(chunk_id) == chunk_hash
426
+ for chunk_id, chunk_hash in zip(json.loads(ids_json), json.loads(hashes_json))
427
+ )
428
+ if status == QuestionStatus.ACTIVE.value and not sources_present:
429
+ to_stale.append(question_id)
430
+ elif status == QuestionStatus.STALE.value and sources_present:
431
+ to_revive.append(question_id)
432
+ self.set_question_status(to_stale, QuestionStatus.STALE)
433
+ self.set_question_status(to_revive, QuestionStatus.ACTIVE)
434
+ return len(to_stale), len(to_revive)
435
+
436
+ # --- evaluation results (the cache) ------------------------------------
437
+
438
+ def save_result(self, result: QuestionResult, config_hash: str, index_version: str) -> None:
439
+ """Cache one result under (question_id, config_hash, index_version)."""
440
+ self._conn.execute(
441
+ "INSERT OR REPLACE INTO eval_results "
442
+ "(question_id, config_hash, index_version, split, created_at, data_json) VALUES (?, ?, ?, ?, ?, ?)",
443
+ (
444
+ result.question_id,
445
+ config_hash,
446
+ index_version,
447
+ result.split,
448
+ datetime.now(timezone.utc).isoformat(),
449
+ result.model_dump_json(),
450
+ ),
451
+ )
452
+ self._conn.commit()
453
+
454
+ def load_result(self, question_id: str, config_hash: str, index_version: str) -> QuestionResult | None:
455
+ """A cached result, or None. Test-split results are locked like test questions (judge rationales can echo answers)."""
456
+ row = self._conn.execute(
457
+ "SELECT split, data_json FROM eval_results WHERE question_id = ? AND config_hash = ? AND index_version = ?",
458
+ (question_id, config_hash, index_version),
459
+ ).fetchone()
460
+ if row is None:
461
+ return None
462
+ _require_unlocked_for({Split(row[0])}, {QuestionStatus.ACTIVE})
463
+ return QuestionResult.model_validate_json(row[1])
464
+
465
+ def result_question_ids(self, config_hash: str, split: Split) -> set[str]:
466
+ """Ids of questions with a cached result for this config at any index version. Ids only, so safe."""
467
+ rows = self._conn.execute(
468
+ "SELECT DISTINCT question_id FROM eval_results WHERE config_hash = ? AND split = ?",
469
+ (config_hash, split.value),
470
+ )
471
+ return {r[0] for r in rows}
472
+
473
+ def save_eval_run(self, summary: EvalSummary) -> None:
474
+ self._conn.execute(
475
+ "INSERT OR REPLACE INTO eval_runs "
476
+ "(run_id, created_at, config_hash, config_json, index_version, split, mode, summary_json) "
477
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
478
+ (
479
+ summary.run_id,
480
+ summary.created_at.isoformat(),
481
+ summary.config_hash,
482
+ json.dumps(summary.config, sort_keys=True),
483
+ summary.index_version,
484
+ summary.split,
485
+ summary.mode,
486
+ summary.model_dump_json(),
487
+ ),
488
+ )
489
+ self._conn.commit()
490
+
491
+ def last_eval_run(self, config_hash: str, split: Split) -> EvalSummary | None:
492
+ """The latest evaluation summary for this config and split (aggregates only, so safe for test)."""
493
+ row = self._conn.execute(
494
+ "SELECT summary_json FROM eval_runs WHERE config_hash = ? AND split = ? ORDER BY created_at DESC LIMIT 1",
495
+ (config_hash, split.value),
496
+ ).fetchone()
497
+ return EvalSummary.model_validate_json(row[0]) if row else None
498
+
499
+ # --- tuning logs -------------------------------------------------------
500
+
501
+ def save_tune_run(self, tune_id: str, created_at: datetime, strategy: str, search_space: dict) -> None:
502
+ self._conn.execute(
503
+ "INSERT OR REPLACE INTO tune_runs (tune_id, created_at, strategy, search_space_json) VALUES (?, ?, ?, ?)",
504
+ (tune_id, created_at.isoformat(), strategy, json.dumps(search_space)),
505
+ )
506
+ self._conn.commit()
507
+
508
+ def save_tune_trial(self, tune_id: str, trial: Trial) -> None:
509
+ self._conn.execute(
510
+ "INSERT OR REPLACE INTO tune_trials (tune_id, trial_index, created_at, config_hash, data_json) "
511
+ "VALUES (?, ?, ?, ?, ?)",
512
+ (tune_id, trial.index, trial.created_at.isoformat(), trial.config_hash, trial.model_dump_json()),
513
+ )
514
+ self._conn.commit()
515
+
516
+ def finish_tune_run(self, tune_id: str, finished_at: datetime, result_json: str) -> None:
517
+ self._conn.execute(
518
+ "UPDATE tune_runs SET finished_at = ?, result_json = ? WHERE tune_id = ?",
519
+ (finished_at.isoformat(), result_json, tune_id),
520
+ )
521
+ self._conn.commit()
522
+
523
+ def load_tune_result(self, tune_id: str | None = None) -> TuneResult | None:
524
+ """A finished tune run (the latest if tune_id is None), or None. Test scores in it are aggregates only."""
525
+ query = "SELECT result_json FROM tune_runs WHERE result_json IS NOT NULL"
526
+ if tune_id is None:
527
+ row = self._conn.execute(f"{query} ORDER BY created_at DESC LIMIT 1").fetchone()
528
+ else:
529
+ row = self._conn.execute(f"{query} AND tune_id = ?", (tune_id,)).fetchone()
530
+ return TuneResult.model_validate_json(row[0]) if row else None
531
+
532
+ def load_tune_trials(self, tune_id: str) -> list[Trial]:
533
+ rows = self._conn.execute(
534
+ "SELECT data_json FROM tune_trials WHERE tune_id = ? ORDER BY trial_index", (tune_id,)
535
+ ).fetchall()
536
+ return [Trial.model_validate_json(r[0]) for r in rows]
@@ -0,0 +1,7 @@
1
+ """`ragaudit tune`: closed-loop config search on the dev split, scored once on the locked test split.
2
+
3
+ Nothing in this package may import `ragaudit.storage.lock` or read the
4
+ store directly; it sees questions only through `DevSet` and test results
5
+ only as aggregate scores from `ragaudit.runner.locked`. A unit test
6
+ enforces this.
7
+ """
@@ -0,0 +1,16 @@
1
+ """The data-access layer `tune` uses: dev-split questions only."""
2
+
3
+ from ragaudit.models.question import Question, Split
4
+ from ragaudit.storage.store import Store
5
+
6
+
7
+ class DevSet:
8
+ """Active dev-split questions, with their gold answers. Never contains a test question."""
9
+
10
+ def __init__(self, store: Store) -> None:
11
+ self.questions: list[Question] = store.dev_questions()
12
+ if any(q.split != Split.DEV for q in self.questions):
13
+ raise AssertionError("DevSet received a non-dev question")
14
+
15
+ def __len__(self) -> int:
16
+ return len(self.questions)