Omar Solano Claude Opus 4.8 (1M context) commited on
Commit
dc474cb
·
unverified ·
1 Parent(s): b151505

wip: eval harness + public docs bundle; relocate eval docs into evals/ (#10)

Browse files

Work-in-progress checkpoint of the current eval cleanup:
- relocate the 6 evals_*.md docs into evals/ (drop the evals_ prefix);
rewrite every reference (evals.md, AGENTS.md, pyproject.toml,
data/eval/README.md, evals/grade.py, upload_dbs_to_hf.py, cross-links)
- evals/ harness: grade/judge/report/run_battery changes + new
grading_prep.py, grading_merge.py, grade_workflow.js
- data/scraping_scripts: build_public_docs_bundle.py + upload/gitignore wiring
- app/ (api, config, kb_manifest) and tests tweaks
- gitignore local-only working dirs (code_review/, workshop_AIE/)


Claude-Session: https://claude.ai/code/session_01BDhiM239toao3TAmoJ1joD

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

.gitignore CHANGED
@@ -168,6 +168,8 @@ data/chroma-db**/
168
  evaluation_data/chroma-db**/
169
  # Build artifact; downloaded from HF on first run.
170
  data/kb/
 
 
171
  data/*_md_files/
172
  data/full_stack_ai_engineering/
173
  data/beginner_python_for_ai_engineering/
@@ -234,3 +236,8 @@ data/eval/review_log_v1.md
234
  # so non-.jsonl files (review_log_v1.md, review_batches/*.json) can't slip in.
235
  eval/
236
  deepseek_eval_results.md
 
 
 
 
 
 
168
  evaluation_data/chroma-db**/
169
  # Build artifact; downloaded from HF on first run.
170
  data/kb/
171
+ # Staging dir for the public docs-only bundle (build_public_docs_bundle.py).
172
+ data/public_docs_bundle/
173
  data/*_md_files/
174
  data/full_stack_ai_engineering/
175
  data/beginner_python_for_ai_engineering/
 
236
  # so non-.jsonl files (review_log_v1.md, review_batches/*.json) can't slip in.
237
  eval/
238
  deepseek_eval_results.md
239
+
240
+ # Local-only working dirs (internal review notes, workshop slide decks).
241
+ # Never meant for the public repo; keep them from an accidental `git add -A`.
242
+ code_review/
243
+ workshop_AIE/
AGENTS.md CHANGED
@@ -12,7 +12,7 @@ ChromaDB for vectors; Cohere for embeddings/rerank; chat model is provider-confi
12
 
13
  - [GitHub repo](https://github.com/towardsai/ai-tutor-app)
14
  - [Live demo — prod Space](https://huggingface.co/spaces/towardsai-tutors/ai-tutor-chatbot) · [Dev Space (private)](https://huggingface.co/spaces/towardsai-tutors/ai-tutor)
15
- - [Vector DB + KB bundle](https://huggingface.co/datasets/towardsai-tutors/ai-tutor-vector-db) · [Private raw JSONL data](https://huggingface.co/datasets/towardsai-tutors/ai-tutor-data)
16
 
17
  ## Where things live
18
 
@@ -91,11 +91,11 @@ uv run -m data.scraping_scripts.retire_source_workflow --sources KEY [--dry-run
91
 
92
  ## Evaluation
93
 
94
- **`evals.md` is the entry point** (what we evaluate, the datasets, results, remaining work); `evals/` is the harness (`run_battery` → `grade` → `report`, plus `check_triggers` and the blinded `handgrade_workbook`). Eval datasets and run results contain **real student text**: they are gitignored and ship via the private `ai-tutor-data` HF dataset (`eval/`, `eval_runs/`) — download snippet in `evals.md`. Runs cost real API money (a 4-preset bake-off ≈ $73); grading and reporting re-run offline from saved bundles for free. **Running or contributing an experiment? Follow `evals_contributing.md`** (run → upload data to HF → record an `F<N>` finding → merge; with a definition-of-done gate).
95
 
96
  ## Environment variables
97
 
98
- Chat runtime: `COHERE_API_KEY` (retrieval), one chat-model provider key (`GEMINI_API_KEY`/`GOOGLE_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or — for open-model eval runs — `OPENROUTER_API_KEY` / `DEEPSEEK_API_KEY`), `HF_TOKEN` (first-start download). Optional: `LANGSMITH_*` (tracing), `AI_TUTOR_API_PORT`/`HOST`/`CORS_ALLOW_ORIGINS`, `AI_TUTOR_KB_DIR`, `AI_TUTOR_MEMORY_PRESET` (default memory preset; see `app/memory_presets.py`), `NEXT_PUBLIC_AI_TUTOR_API_BASE_URL`. Data workflows also need `GITHUB_TOKEN` and `GEMINI_API_KEY`/`GOOGLE_API_KEY` (context generation). See `.env.example`.
99
 
100
  ## Deployment
101
 
 
12
 
13
  - [GitHub repo](https://github.com/towardsai/ai-tutor-app)
14
  - [Live demo — prod Space](https://huggingface.co/spaces/towardsai-tutors/ai-tutor-chatbot) · [Dev Space (private)](https://huggingface.co/spaces/towardsai-tutors/ai-tutor)
15
+ - [Vector DB + KB bundle](https://huggingface.co/datasets/towardsai-tutors/ai-tutor-vector-db) (private; full corpus incl. courses) · [Public docs-only bundle](https://huggingface.co/datasets/towardsai-tutors/ai-tutor-vector-db-public) (no token needed; cold-start fallback) · [Private raw JSONL data](https://huggingface.co/datasets/towardsai-tutors/ai-tutor-data)
16
 
17
  ## Where things live
18
 
 
91
 
92
  ## Evaluation
93
 
94
+ **`evals.md` is the entry point** (what we evaluate, the datasets, results, remaining work); `evals/` is the harness (`run_battery` → `grade` → `report`, plus `check_triggers` and the blinded `handgrade_workbook`). Eval datasets and run results contain **real student text**: they are gitignored and ship via the private `ai-tutor-data` HF dataset (`eval/`, `eval_runs/`) — download snippet in `evals.md`. Runs cost real API money (a 4-preset bake-off ≈ $323; the full eval program ≈ $590 across all Gemini 3.5 Flash runs); grading and reporting re-run offline from saved bundles for free. **Running or contributing an experiment? Follow `evals/contributing.md`** (run → upload data to HF → record an `F<N>` finding → merge; with a definition-of-done gate).
95
 
96
  ## Environment variables
97
 
98
+ Chat runtime: `COHERE_API_KEY` (retrieval), one chat-model provider key (`GEMINI_API_KEY`/`GOOGLE_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or — for open-model eval runs — `OPENROUTER_API_KEY` / `DEEPSEEK_API_KEY`), `HF_TOKEN` (first-start download of the full private bundle; without it, cold start falls back to the public docs-only bundle — documentation sources only, course content hidden). Optional: `LANGSMITH_*` (tracing), `AI_TUTOR_API_PORT`/`HOST`/`CORS_ALLOW_ORIGINS`, `AI_TUTOR_KB_DIR`, `AI_TUTOR_MEMORY_PRESET` (default memory preset; see `app/memory_presets.py`), `NEXT_PUBLIC_AI_TUTOR_API_BASE_URL`. Data workflows also need `GITHUB_TOKEN` and `GEMINI_API_KEY`/`GOOGLE_API_KEY` (context generation). See `.env.example`.
99
 
100
  ## Deployment
101
 
app/api.py CHANGED
@@ -24,7 +24,7 @@ from .chat_service import (
24
  warm_up_retriever,
25
  )
26
  from .chat_types import ChatEvent, ChatRequest, ChatTurn
27
- from .kb_manifest import load_manifest_entries
28
  from .memory_presets import MEMORY_PRESETS
29
  from .config import (
30
  AVAILABLE_MODELS,
@@ -615,9 +615,16 @@ _SOURCE_VERSIONS: dict[str, dict[str, Any]] = _load_source_versions()
615
 
616
  def _source_entries() -> list[dict[str, Any]]:
617
  defaults = set(DEFAULT_SELECTED_SOURCES_UI)
 
 
 
 
 
618
  entries: list[dict[str, Any]] = []
619
  for label in AVAILABLE_SOURCES_UI:
620
  key = SOURCE_UI_TO_KEY[label]
 
 
621
  group = "courses" if key in COURSE_SOURCE_KEYS else "docs"
622
  display = SOURCE_DISPLAY_INFO.get(key, {})
623
  entry: dict[str, Any] = {
 
24
  warm_up_retriever,
25
  )
26
  from .chat_types import ChatEvent, ChatRequest, ChatTurn
27
+ from .kb_manifest import available_source_keys, load_manifest_entries
28
  from .memory_presets import MEMORY_PRESETS
29
  from .config import (
30
  AVAILABLE_MODELS,
 
615
 
616
  def _source_entries() -> list[dict[str, Any]]:
617
  defaults = set(DEFAULT_SELECTED_SOURCES_UI)
618
+ # On the public docs-only bundle the course sources are absent, so hide
619
+ # them from the picker instead of offering sources that return nothing.
620
+ # `None` means the bundle is not loaded yet (advertise everything, the
621
+ # prod default); otherwise only advertise sources present in the bundle.
622
+ available = available_source_keys()
623
  entries: list[dict[str, Any]] = []
624
  for label in AVAILABLE_SOURCES_UI:
625
  key = SOURCE_UI_TO_KEY[label]
626
+ if available is not None and key not in available:
627
+ continue
628
  group = "courses" if key in COURSE_SOURCE_KEYS else "docs"
629
  display = SOURCE_DISPLAY_INFO.get(key, {})
630
  entry: dict[str, Any] = {
app/config.py CHANGED
@@ -47,6 +47,14 @@ VECTOR_COLLECTION_NAME = "chroma-db-all_sources"
47
  DOCUMENT_DICT_PATH = f"{VECTOR_DB_DIR}/document_dict_all_sources.pkl"
48
  BM25_INDEX_PATH = f"{VECTOR_DB_DIR}/bm25_index_all_sources.pkl"
49
  CHROMA_SQLITE_PATH = f"{VECTOR_DB_DIR}/chroma.sqlite3"
 
 
 
 
 
 
 
 
50
  KB_DIR = "data/kb"
51
  KB_MANIFEST_PATH = f"{KB_DIR}/generated/corpus_manifest.jsonl"
52
  KB_INDEX_PATH = f"{KB_DIR}/wiki/index.md"
@@ -101,6 +109,85 @@ def ensure_kb_agents_md() -> None:
101
  os.replace(tmp_path, target)
102
 
103
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  def ensure_local_vector_db() -> None:
105
  """Make sure the vector-db/KB bundle and AGENTS.md exist locally.
106
 
@@ -119,24 +206,7 @@ def ensure_local_vector_db() -> None:
119
  "Vector database missing or incomplete locally, downloading "
120
  "from Hugging Face"
121
  )
122
- from huggingface_hub import snapshot_download
123
-
124
- # Mute httpx's per-file flood during the cold-start download only.
125
- httpx_logger = logging.getLogger("httpx")
126
- previous_level = httpx_logger.level
127
- httpx_logger.setLevel(logging.WARNING)
128
- try:
129
- snapshot_download(
130
- repo_id="towardsai-tutors/ai-tutor-vector-db",
131
- local_dir="data",
132
- repo_type="dataset",
133
- # The GraphRAG experiment index (~150 MB) lives in the same
134
- # repo for reproducibility but prod does not use it; skip it
135
- # on cold start. Pull it explicitly to run that eval.
136
- ignore_patterns=["graphrag/**"],
137
- )
138
- finally:
139
- httpx_logger.setLevel(previous_level)
140
  ensure_kb_agents_md()
141
  _BUNDLE_READY = _bundle_complete() and os.path.exists(KB_AGENTS_PATH)
142
 
 
47
  DOCUMENT_DICT_PATH = f"{VECTOR_DB_DIR}/document_dict_all_sources.pkl"
48
  BM25_INDEX_PATH = f"{VECTOR_DB_DIR}/bm25_index_all_sources.pkl"
49
  CHROMA_SQLITE_PATH = f"{VECTOR_DB_DIR}/chroma.sqlite3"
50
+ # The full bundle (all sources, incl. gated course content) lives in the
51
+ # private repo; cold-starting from it needs an HF_TOKEN with read access. When
52
+ # that token is missing or lacks access, we fall back to the public docs-only
53
+ # bundle (the 9 documentation sources, no courses) built by
54
+ # data/scraping_scripts/build_public_docs_bundle.py. Both repos share the same
55
+ # tree layout, so only the download source changes here.
56
+ VECTOR_DB_REPO_ID = "towardsai-tutors/ai-tutor-vector-db"
57
+ PUBLIC_VECTOR_DB_REPO_ID = "towardsai-tutors/ai-tutor-vector-db-public"
58
  KB_DIR = "data/kb"
59
  KB_MANIFEST_PATH = f"{KB_DIR}/generated/corpus_manifest.jsonl"
60
  KB_INDEX_PATH = f"{KB_DIR}/wiki/index.md"
 
109
  os.replace(tmp_path, target)
110
 
111
 
112
+ def _snapshot_bundle(repo_id: str, *, token: str | None) -> None:
113
+ """Download a vector-db/KB bundle snapshot into ``data/``.
114
+
115
+ Mutes httpx's per-file flood during the cold-start download only. The
116
+ GraphRAG experiment index (~150 MB) lives in the private repo for
117
+ reproducibility but prod does not use it; skip it on cold start. Pull it
118
+ explicitly to run that eval. (The public bundle has no graphrag/ tree, so
119
+ the pattern is simply a no-op there.)
120
+ """
121
+ from huggingface_hub import snapshot_download
122
+
123
+ httpx_logger = logging.getLogger("httpx")
124
+ previous_level = httpx_logger.level
125
+ httpx_logger.setLevel(logging.WARNING)
126
+ try:
127
+ snapshot_download(
128
+ repo_id=repo_id,
129
+ local_dir="data",
130
+ repo_type="dataset",
131
+ token=token,
132
+ ignore_patterns=["graphrag/**"],
133
+ )
134
+ finally:
135
+ httpx_logger.setLevel(previous_level)
136
+
137
+
138
+ def _download_bundle() -> None:
139
+ """Download the bundle, falling back to the public docs-only one.
140
+
141
+ With a usable ``HF_TOKEN`` we pull the full private bundle (all sources,
142
+ incl. gated course content). When the token is absent, or present but
143
+ without access to the private repo, we fall back to the public docs-only
144
+ bundle so the app can still cold-start (documentation sources only). Any
145
+ non-access error (e.g. a network failure) propagates instead of silently
146
+ degrading to the smaller bundle.
147
+ """
148
+ try:
149
+ from huggingface_hub.errors import (
150
+ GatedRepoError,
151
+ RepositoryNotFoundError,
152
+ )
153
+ except ImportError: # older huggingface_hub
154
+ from huggingface_hub.utils import (
155
+ GatedRepoError,
156
+ RepositoryNotFoundError,
157
+ )
158
+
159
+ # Resolve the token the way huggingface_hub itself does: the HF_TOKEN env
160
+ # var first, then a cached `huggingface-cli login`. Keying off os.getenv
161
+ # alone would skip a cached login that the old implicit-token download honored.
162
+ try:
163
+ from huggingface_hub import get_token
164
+
165
+ token = get_token()
166
+ except Exception: # pragma: no cover - very old hub without get_token
167
+ token = os.getenv("HF_TOKEN") or None
168
+
169
+ if not token:
170
+ logger.warning(
171
+ "No Hugging Face token found; downloading the public docs-only "
172
+ "bundle from %s (documentation sources only, no course content).",
173
+ PUBLIC_VECTOR_DB_REPO_ID,
174
+ )
175
+ _snapshot_bundle(PUBLIC_VECTOR_DB_REPO_ID, token=None)
176
+ return
177
+
178
+ try:
179
+ _snapshot_bundle(VECTOR_DB_REPO_ID, token=token)
180
+ except (GatedRepoError, RepositoryNotFoundError) as exc:
181
+ logger.warning(
182
+ "HF_TOKEN cannot access the private bundle %s (%s); falling back to "
183
+ "the public docs-only bundle %s.",
184
+ VECTOR_DB_REPO_ID,
185
+ type(exc).__name__,
186
+ PUBLIC_VECTOR_DB_REPO_ID,
187
+ )
188
+ _snapshot_bundle(PUBLIC_VECTOR_DB_REPO_ID, token=None)
189
+
190
+
191
  def ensure_local_vector_db() -> None:
192
  """Make sure the vector-db/KB bundle and AGENTS.md exist locally.
193
 
 
206
  "Vector database missing or incomplete locally, downloading "
207
  "from Hugging Face"
208
  )
209
+ _download_bundle()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
  ensure_kb_agents_md()
211
  _BUNDLE_READY = _bundle_complete() and os.path.exists(KB_AGENTS_PATH)
212
 
app/kb_manifest.py CHANGED
@@ -117,6 +117,21 @@ def load_manifest_entries(kb_dir: str = KB_DIR) -> tuple[KbManifestEntry, ...]:
117
  return result
118
 
119
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  def manifest_indexes(
121
  kb_dir: str = KB_DIR,
122
  ) -> tuple[
 
117
  return result
118
 
119
 
120
+ def available_source_keys(kb_dir: str = KB_DIR) -> frozenset[str] | None:
121
+ """Source keys actually present in the downloaded KB bundle.
122
+
123
+ Returns ``None`` when the manifest is not available yet (the bundle may
124
+ still be downloading on first start), signalling callers to not filter.
125
+ Otherwise it is the distinct set of sources shipped in the bundle. On the
126
+ public docs-only bundle this excludes the course sources, so the UI source
127
+ picker can hide sources that have no data to query.
128
+ """
129
+ entries = load_manifest_entries(kb_dir)
130
+ if not entries:
131
+ return None
132
+ return frozenset(entry.source for entry in entries if entry.source)
133
+
134
+
135
  def manifest_indexes(
136
  kb_dir: str = KB_DIR,
137
  ) -> tuple[
data/eval/README.md CHANGED
@@ -41,7 +41,7 @@ Built from `data/academy_discussion_eval.jsonl` — real student posts from the
41
  - **requires_notebook**: fully answering needs the Colab notebook contents (cells/outputs/files), which our KB does not contain — verified against lesson markdown with file/line evidence (`notebook_evidence`), not guessed. All 60 included cases are `false`; the one true case in review was excluded. If retrieval misses on a flagged-adjacent case, check `notebook_evidence` before blaming the retriever.
42
  - **requires_media**: the post referenced a screenshot we don't have. The 2 included cases remain gradeable without it.
43
  - **planted facts**: personal facts (environment, goal, level, weak topic, preference/constraint) a session's student states in turn 0, which later probes test. The session-level analog of a profile.
44
- - **Probe types** (`probe_type`): `fact_recall` means the answer must use a fact the student gave many turns ago. `preference_compliance` means the tutor must honor a stated preference, such as OS, tooling, language, or style. `anaphora` means the user asks about "that thing from earlier," so the tutor must resolve the reference. `anaphora_consistency` means the answer must stay consistent with the tutor's own earlier recommendation. `fact_update` means the student changed a fact mid-session; using the old value fails. `behavior_routing` means a support/platform issue appears inside a long chat, and the tutor must still redirect appropriately. Battery v2 (planned, not built) will add `contradiction` (a fact changed early and probed only after it would be evicted), `longhorizon_recall`, and `entity_isolation` (two entities in one session, no fact bleed). `cross_session_recall` was an earlier idea for `sleeptime_consolidation` and is deferred with the multi-thread battery; the v1 types here are frozen. See `evals_part_c_plan.md` → "Battery v2" for the build plan.
45
  - **expected_facts / check_note**: per probe, the specific facts a correct answer must use, and a one-line pass/fail rule for the grader. Grade probes binary → **memory probe accuracy**, reported overall and per probe type per memory preset.
46
  - **profile_seed / facts**: the persona's stored profile. Seed it with `app.chat_service.set_student_profile(student_id, profile_seed)` before running that persona's questions; `facts` is the same content structured, for slicing results by fact type.
47
  - **Self-grading checks** (`checks` / `anti_patterns`): persona questions grade themselves because we authored the ground truth. `{"type": "regex_any", "patterns": [...]}` = case-insensitive regex over the answer; any match passes that check. `{"type": "llm", "instruction": ...}` = a judge call with that instruction (use a strong Anthropic model; binary verdict). `anti_patterns` = case-insensitive regexes whose match FAILS the question (e.g. bash `export` advice for a Windows persona). A question passes when every check passes and no anti-pattern matches.
 
41
  - **requires_notebook**: fully answering needs the Colab notebook contents (cells/outputs/files), which our KB does not contain — verified against lesson markdown with file/line evidence (`notebook_evidence`), not guessed. All 60 included cases are `false`; the one true case in review was excluded. If retrieval misses on a flagged-adjacent case, check `notebook_evidence` before blaming the retriever.
42
  - **requires_media**: the post referenced a screenshot we don't have. The 2 included cases remain gradeable without it.
43
  - **planted facts**: personal facts (environment, goal, level, weak topic, preference/constraint) a session's student states in turn 0, which later probes test. The session-level analog of a profile.
44
+ - **Probe types** (`probe_type`): `fact_recall` means the answer must use a fact the student gave many turns ago. `preference_compliance` means the tutor must honor a stated preference, such as OS, tooling, language, or style. `anaphora` means the user asks about "that thing from earlier," so the tutor must resolve the reference. `anaphora_consistency` means the answer must stay consistent with the tutor's own earlier recommendation. `fact_update` means the student changed a fact mid-session; using the old value fails. `behavior_routing` means a support/platform issue appears inside a long chat, and the tutor must still redirect appropriately. Battery v2 (planned, not built) will add `contradiction` (a fact changed early and probed only after it would be evicted), `longhorizon_recall`, and `entity_isolation` (two entities in one session, no fact bleed). `cross_session_recall` was an earlier idea for `sleeptime_consolidation` and is deferred with the multi-thread battery; the v1 types here are frozen. See `evals/part_c_plan.md` → "Battery v2" for the build plan.
45
  - **expected_facts / check_note**: per probe, the specific facts a correct answer must use, and a one-line pass/fail rule for the grader. Grade probes binary → **memory probe accuracy**, reported overall and per probe type per memory preset.
46
  - **profile_seed / facts**: the persona's stored profile. Seed it with `app.chat_service.set_student_profile(student_id, profile_seed)` before running that persona's questions; `facts` is the same content structured, for slicing results by fact type.
47
  - **Self-grading checks** (`checks` / `anti_patterns`): persona questions grade themselves because we authored the ground truth. `{"type": "regex_any", "patterns": [...]}` = case-insensitive regex over the answer; any match passes that check. `{"type": "llm", "instruction": ...}` = a judge call with that instruction (use a strong Anthropic model; binary verdict). `anti_patterns` = case-insensitive regexes whose match FAILS the question (e.g. bash `export` advice for a Windows persona). A question passes when every check passes and no anti-pattern matches.
data/scraping_scripts/README.md CHANGED
@@ -270,6 +270,37 @@ uv run -m data.scraping_scripts.retire_source_workflow --sources 8-hour_primer -
270
  Use `--keep-source-registry` only if you want to remove existing chunks while
271
  leaving the source configured as active for a future rebuild.
272
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
  ## Tips for New Team Members
274
 
275
  1. To update the AI Tutor with new content:
 
270
  Use `--keep-source-registry` only if you want to remove existing chunks while
271
  leaving the source configured as active for a future rebuild.
272
 
273
+ ## Publishing the public docs-only bundle
274
+
275
+ The production bundle in `towardsai-tutors/ai-tutor-vector-db` is **private** and
276
+ holds every source, including gated course content, so cold-starting the app
277
+ needs an `HF_TOKEN` with read access. To let anyone run the chatbot without that
278
+ token, we publish a **public, docs-only** bundle to
279
+ `towardsai-tutors/ai-tutor-vector-db-public` (the 9 documentation/reference
280
+ sources, no courses). The runtime (`app.config.ensure_local_vector_db`) downloads
281
+ the full private bundle when a usable `HF_TOKEN` is present and falls back to the
282
+ public one otherwise; the picker hides course sources when running on it.
283
+
284
+ This is a **standalone, manual** step — it is **not** wired into the docs/course
285
+ workflows. Refresh the public bundle whenever the docs sources change materially:
286
+
287
+ ```bash
288
+ # Refresh the prod bundle first (so data/chroma-db-all_sources, data/kb,
289
+ # data/all_sources_data.jsonl are current), then:
290
+ uv run -m data.scraping_scripts.build_public_docs_bundle # build + upload
291
+ uv run -m data.scraping_scripts.build_public_docs_bundle --skip-upload # build only, inspect first
292
+ ```
293
+
294
+ It **derives** the docs-only bundle from the already-built prod artifacts (no
295
+ re-embedding, $0 Cohere): it copies the Chroma collection and deletes the course
296
+ chunks, rebuilds the BM25 / document-dict pkls from a docs-only view of
297
+ `all_sources_data.jsonl`, and copies `data/kb` while pruning `raw/courses`,
298
+ `wiki/courses`, and the course rows from the generated indexes. The staged tree
299
+ lives in `data/public_docs_bundle/` (gitignored). Uploading needs an `HF_TOKEN`
300
+ with write access to the `towardsai-tutors` org; the script creates the public
301
+ repo on first run. Pass `--include-contextual` to also ship a docs-only
302
+ `all_sources_contextual_nodes.pkl` (off by default to keep the download small).
303
+
304
  ## Tips for New Team Members
305
 
306
  1. To update the AI Tutor with new content:
data/scraping_scripts/build_public_docs_bundle.py ADDED
@@ -0,0 +1,380 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Build (and optionally upload) the PUBLIC, docs-only vector-db/KB bundle.
2
+
3
+ The production bundle lives in the **private** dataset
4
+ ``towardsai-tutors/ai-tutor-vector-db`` and contains every source, including
5
+ the course content (real student-facing material we keep gated). Anyone
6
+ without an ``HF_TOKEN`` that can read that repo cannot cold-start the app.
7
+
8
+ This script derives a **public** docs-only bundle from the already-built
9
+ production artifacts and pushes it to ``towardsai-tutors/ai-tutor-vector-db-public``
10
+ (public). The runtime (``app.config.ensure_local_vector_db``) falls back to it
11
+ when no usable ``HF_TOKEN`` is present. It contains only the 9 documentation /
12
+ reference sources (``DOC_SOURCE_KEYS``); the 5 course sources are stripped.
13
+
14
+ Why "derive", not "rebuild": the docs-only bundle is exactly what prod would
15
+ produce if the course sources had never existed, so we build each artifact the
16
+ same way prod does, minus the course rows:
17
+
18
+ * **Dense (Chroma)** - copy the prod collection and ``delete`` the course
19
+ chunks. This reuses the existing Cohere embeddings, so the build costs **$0**
20
+ and is identical to prod for every docs chunk.
21
+ * **BM25 + document dict** - rebuilt from a docs-only view of
22
+ ``all_sources_data.jsonl`` with the same helpers prod uses
23
+ (``build_document_dict`` / ``BM25Index.build``). Pure, no network.
24
+ * **KB** - copy ``data/kb`` and prune: drop ``raw/courses`` and ``wiki/courses``
25
+ and filter the course rows out of the generated indexes. The rest of the
26
+ docs wiki is kept verbatim.
27
+
28
+ Usage::
29
+
30
+ # build into data/public_docs_bundle/ and upload to the public repo
31
+ uv run -m data.scraping_scripts.build_public_docs_bundle
32
+
33
+ # build only (inspect locally first), skip the upload
34
+ uv run -m data.scraping_scripts.build_public_docs_bundle --skip-upload
35
+
36
+ Prerequisites: a complete local prod bundle (``data/chroma-db-all_sources/``,
37
+ ``data/all_sources_data.jsonl``, ``data/kb/``). Build/refresh it with the docs
38
+ workflow first. Uploading additionally needs an ``HF_TOKEN`` with write access
39
+ to the ``towardsai-tutors`` org.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import argparse
45
+ import csv
46
+ import gc
47
+ import json
48
+ import logging
49
+ import shutil
50
+ import sqlite3
51
+ from pathlib import Path
52
+
53
+ import chromadb
54
+
55
+ try:
56
+ from data.scraping_scripts.source_registry import (
57
+ ALL_SOURCES_JSONL,
58
+ CONTEXTUAL_NODES_PKL,
59
+ COURSE_SOURCE_KEYS,
60
+ DOC_SOURCE_KEYS,
61
+ )
62
+ from app.chroma_rag import (
63
+ BM25Index,
64
+ build_chunk_records,
65
+ build_document_dict,
66
+ get_chunk_record_source,
67
+ load_jsonl_documents,
68
+ save_bm25_index,
69
+ save_document_dict,
70
+ )
71
+ except ModuleNotFoundError:
72
+ import sys
73
+
74
+ project_root = Path(__file__).resolve().parents[2]
75
+ if str(project_root) not in sys.path:
76
+ sys.path.insert(0, str(project_root))
77
+ from data.scraping_scripts.source_registry import (
78
+ ALL_SOURCES_JSONL,
79
+ CONTEXTUAL_NODES_PKL,
80
+ COURSE_SOURCE_KEYS,
81
+ DOC_SOURCE_KEYS,
82
+ )
83
+ from app.chroma_rag import (
84
+ BM25Index,
85
+ build_chunk_records,
86
+ build_document_dict,
87
+ get_chunk_record_source,
88
+ load_jsonl_documents,
89
+ save_bm25_index,
90
+ save_document_dict,
91
+ )
92
+
93
+ logging.basicConfig(level=logging.INFO)
94
+ logger = logging.getLogger(__name__)
95
+
96
+ # Layout mirrors the prod bundle so the runtime needs no path changes: the
97
+ # public repo has the identical tree (chroma-db-all_sources/, kb/), just with
98
+ # the course rows removed. Keep these names in sync with app/config.py.
99
+ VECTOR_DB_DIR = "chroma-db-all_sources"
100
+ VECTOR_COLLECTION_NAME = "chroma-db-all_sources"
101
+ DOCUMENT_DICT_FILE = "document_dict_all_sources.pkl"
102
+ BM25_INDEX_FILE = "bm25_index_all_sources.pkl"
103
+ KB_DIR_NAME = "kb"
104
+
105
+ PUBLIC_REPO_ID = "towardsai-tutors/ai-tutor-vector-db-public"
106
+ DEFAULT_SOURCE_DIR = Path("data")
107
+ DEFAULT_STAGE_DIR = Path("data/public_docs_bundle")
108
+
109
+ _COURSE_KEYS = frozenset(COURSE_SOURCE_KEYS)
110
+
111
+
112
+ def _is_course_row(row: dict) -> bool:
113
+ return str(row.get("source") or "") in _COURSE_KEYS
114
+
115
+
116
+ def stage_chroma(source_dir: Path, stage_dir: Path, *, vacuum: bool = True) -> dict:
117
+ """Copy the prod Chroma collection and delete the course chunks.
118
+
119
+ Reuses the existing docs embeddings (no Cohere call). Returns counts for
120
+ the build summary.
121
+ """
122
+ src = source_dir / VECTOR_DB_DIR
123
+ dst = stage_dir / VECTOR_DB_DIR
124
+ if not src.is_dir():
125
+ raise SystemExit(f"Missing prod Chroma collection: {src}")
126
+
127
+ logger.info("Copying Chroma collection %s -> %s", src, dst)
128
+ shutil.copytree(src, dst)
129
+
130
+ client = chromadb.PersistentClient(path=str(dst))
131
+ collection = client.get_collection(name=VECTOR_COLLECTION_NAME)
132
+ before = collection.count()
133
+ course_keys = sorted(_COURSE_KEYS)
134
+ if course_keys:
135
+ collection.delete(where={"source": {"$in": course_keys}})
136
+ after = collection.count()
137
+ logger.info(
138
+ "Chroma: kept %s docs chunks, removed %s course chunks",
139
+ after,
140
+ before - after,
141
+ )
142
+
143
+ # Release the client so the sqlite file is unlocked before VACUUM.
144
+ del collection
145
+ del client
146
+ try:
147
+ chromadb.api.client.SharedSystemClient.clear_system_cache()
148
+ except Exception: # API location varies across chromadb versions
149
+ pass
150
+ gc.collect()
151
+
152
+ if vacuum:
153
+ sqlite_path = dst / "chroma.sqlite3"
154
+ try:
155
+ con = sqlite3.connect(sqlite_path)
156
+ con.execute("VACUUM")
157
+ con.close()
158
+ logger.info("Vacuumed %s to reclaim space from deleted rows", sqlite_path)
159
+ except sqlite3.Error as exc: # best-effort; a larger file is harmless
160
+ logger.warning("VACUUM skipped (%s); bundle will be larger", exc)
161
+
162
+ return {"dense_kept": after, "dense_removed": before - after}
163
+
164
+
165
+ def rebuild_retrieval_pkls(source_dir: Path, stage_dir: Path) -> dict:
166
+ """Rebuild the BM25 index and document dict from a docs-only JSONL.
167
+
168
+ Mirrors ``create_vector_stores.write_retrieval_artifacts`` so the public
169
+ pkls are byte-for-byte what prod would write without the course rows.
170
+ """
171
+ jsonl_path = source_dir / Path(ALL_SOURCES_JSONL).name
172
+ if not jsonl_path.exists():
173
+ raise SystemExit(f"Missing aggregate corpus: {jsonl_path}")
174
+
175
+ all_rows = load_jsonl_documents(str(jsonl_path))
176
+ docs_rows = [row for row in all_rows if not _is_course_row(row)]
177
+ logger.info(
178
+ "Corpus: %s docs documents (dropped %s course documents)",
179
+ len(docs_rows),
180
+ len(all_rows) - len(docs_rows),
181
+ )
182
+
183
+ dst = stage_dir / VECTOR_DB_DIR
184
+ save_document_dict(build_document_dict(docs_rows), str(dst / DOCUMENT_DICT_FILE))
185
+ bm25_records = build_chunk_records(docs_rows)
186
+ save_bm25_index(BM25Index.build(bm25_records), str(dst / BM25_INDEX_FILE))
187
+ logger.info(
188
+ "Rebuilt document dict (%s docs) and BM25 index (%s chunks)",
189
+ len(docs_rows),
190
+ len(bm25_records),
191
+ )
192
+ return {"documents": len(docs_rows), "bm25_chunks": len(bm25_records)}
193
+
194
+
195
+ def stage_contextual_nodes(source_dir: Path, stage_dir: Path) -> int:
196
+ """Write a docs-only copy of the contextual-nodes pickle (opt-in).
197
+
198
+ Not needed at runtime; included only when ``--include-contextual`` is
199
+ passed so others can rebuild the docs embeddings from scratch.
200
+ """
201
+ import pickle
202
+
203
+ src = source_dir / Path(CONTEXTUAL_NODES_PKL).name
204
+ if not src.exists():
205
+ logger.warning("No contextual nodes pickle at %s; skipping", src)
206
+ return 0
207
+ with src.open("rb") as handle:
208
+ records = pickle.load(handle)
209
+ kept = []
210
+ for record in records:
211
+ try:
212
+ record_source = get_chunk_record_source(record)
213
+ except Exception:
214
+ kept.append(record) # keep records we cannot classify
215
+ continue
216
+ if record_source not in _COURSE_KEYS:
217
+ kept.append(record)
218
+ dst = stage_dir / Path(CONTEXTUAL_NODES_PKL).name
219
+ with dst.open("wb") as handle:
220
+ pickle.dump(kept, handle)
221
+ logger.info("Contextual nodes: kept %s docs chunks", len(kept))
222
+ return len(kept)
223
+
224
+
225
+ def _filter_jsonl(path: Path) -> int:
226
+ """Drop course rows from a generated *.jsonl index in place."""
227
+ if not path.exists():
228
+ return 0
229
+ kept = []
230
+ with path.open("r", encoding="utf-8") as handle:
231
+ for line in handle:
232
+ if not line.strip():
233
+ continue
234
+ row = json.loads(line)
235
+ if str(row.get("source") or "") not in _COURSE_KEYS:
236
+ kept.append(line if line.endswith("\n") else line + "\n")
237
+ with path.open("w", encoding="utf-8") as handle:
238
+ handle.writelines(kept)
239
+ return len(kept)
240
+
241
+
242
+ def _filter_tsv(path: Path) -> int:
243
+ """Drop course rows from symbols.tsv in place (keeps the header)."""
244
+ if not path.exists():
245
+ return 0
246
+ with path.open("r", encoding="utf-8", newline="") as handle:
247
+ reader = csv.DictReader(handle, delimiter="\t")
248
+ fieldnames = reader.fieldnames or []
249
+ kept = [
250
+ row for row in reader if str(row.get("source") or "") not in _COURSE_KEYS
251
+ ]
252
+ with path.open("w", encoding="utf-8", newline="") as handle:
253
+ writer = csv.DictWriter(handle, fieldnames=fieldnames, delimiter="\t")
254
+ writer.writeheader()
255
+ writer.writerows(kept)
256
+ return len(kept)
257
+
258
+
259
+ def stage_kb(source_dir: Path, stage_dir: Path) -> dict:
260
+ """Copy data/kb and prune course content (raw, wiki, generated indexes)."""
261
+ src = source_dir / KB_DIR_NAME
262
+ dst = stage_dir / KB_DIR_NAME
263
+ if not src.is_dir():
264
+ raise SystemExit(f"Missing KB directory: {src}")
265
+
266
+ logger.info("Copying KB %s -> %s", src, dst)
267
+ shutil.copytree(src, dst)
268
+
269
+ for relative in ("raw/courses", "wiki/courses"):
270
+ target = dst / relative
271
+ if target.exists():
272
+ shutil.rmtree(target)
273
+ logger.info("Removed %s", target)
274
+
275
+ generated = dst / "generated"
276
+ manifest_rows = _filter_jsonl(generated / "corpus_manifest.jsonl")
277
+ heading_rows = _filter_jsonl(generated / "headings.jsonl")
278
+ symbol_rows = _filter_tsv(generated / "symbols.tsv")
279
+ logger.info(
280
+ "KB generated indexes: %s manifest rows, %s heading rows, %s symbol rows",
281
+ manifest_rows,
282
+ heading_rows,
283
+ symbol_rows,
284
+ )
285
+ return {"manifest_rows": manifest_rows}
286
+
287
+
288
+ def build_bundle(
289
+ *,
290
+ source_dir: Path = DEFAULT_SOURCE_DIR,
291
+ stage_dir: Path = DEFAULT_STAGE_DIR,
292
+ include_contextual: bool = False,
293
+ vacuum: bool = True,
294
+ ) -> dict:
295
+ if stage_dir.exists():
296
+ logger.info("Clearing existing staging dir %s", stage_dir)
297
+ shutil.rmtree(stage_dir)
298
+ stage_dir.mkdir(parents=True, exist_ok=True)
299
+
300
+ summary: dict = {"sources": sorted(DOC_SOURCE_KEYS)}
301
+ summary.update(stage_chroma(source_dir, stage_dir, vacuum=vacuum))
302
+ summary.update(rebuild_retrieval_pkls(source_dir, stage_dir))
303
+ summary.update(stage_kb(source_dir, stage_dir))
304
+ if include_contextual:
305
+ summary["contextual_chunks"] = stage_contextual_nodes(source_dir, stage_dir)
306
+ return summary
307
+
308
+
309
+ def public_allow_patterns(*, include_contextual: bool) -> list[str]:
310
+ patterns = [f"{VECTOR_DB_DIR}/**", f"{KB_DIR_NAME}/**"]
311
+ if include_contextual:
312
+ patterns.append(Path(CONTEXTUAL_NODES_PKL).name)
313
+ return patterns
314
+
315
+
316
+ def main() -> None:
317
+ parser = argparse.ArgumentParser(
318
+ description="Build and upload the public docs-only vector-db/KB bundle."
319
+ )
320
+ parser.add_argument("--source-dir", default=str(DEFAULT_SOURCE_DIR))
321
+ parser.add_argument("--stage-dir", default=str(DEFAULT_STAGE_DIR))
322
+ parser.add_argument("--repo", default=PUBLIC_REPO_ID)
323
+ parser.add_argument(
324
+ "--skip-upload",
325
+ action="store_true",
326
+ help="Build the bundle locally but do not push it to Hugging Face.",
327
+ )
328
+ parser.add_argument(
329
+ "--include-contextual",
330
+ action="store_true",
331
+ help="Also ship a docs-only all_sources_contextual_nodes.pkl so others "
332
+ "can rebuild the docs embeddings (larger download; off by default).",
333
+ )
334
+ parser.add_argument(
335
+ "--no-vacuum",
336
+ action="store_true",
337
+ help="Skip the sqlite VACUUM that reclaims space from deleted rows.",
338
+ )
339
+ args = parser.parse_args()
340
+
341
+ stage_dir = Path(args.stage_dir)
342
+ summary = build_bundle(
343
+ source_dir=Path(args.source_dir),
344
+ stage_dir=stage_dir,
345
+ include_contextual=args.include_contextual,
346
+ vacuum=not args.no_vacuum,
347
+ )
348
+
349
+ print("\nBuilt public docs-only bundle:")
350
+ print(f" sources: {', '.join(summary['sources'])}")
351
+ print(f" documents: {summary['documents']}")
352
+ print(
353
+ f" dense chunks: {summary['dense_kept']} (removed {summary['dense_removed']})"
354
+ )
355
+ print(f" bm25 chunks: {summary['bm25_chunks']}")
356
+ print(f" KB manifest: {summary['manifest_rows']} rows")
357
+ if "contextual_chunks" in summary:
358
+ print(f" contextual: {summary['contextual_chunks']} chunks")
359
+ print(f" staged at: {stage_dir}")
360
+
361
+ if args.skip_upload:
362
+ print("\n--skip-upload set; not pushing to Hugging Face.")
363
+ return
364
+
365
+ from data.scraping_scripts.upload_dbs_to_hf import upload_bundle
366
+
367
+ print(f"\nUploading {stage_dir} -> {args.repo} (public) ...")
368
+ upload_bundle(
369
+ args.repo,
370
+ folder_path=str(stage_dir),
371
+ allow_patterns=public_allow_patterns(
372
+ include_contextual=args.include_contextual
373
+ ),
374
+ create_public=True,
375
+ )
376
+ print("Done.")
377
+
378
+
379
+ if __name__ == "__main__":
380
+ main()
data/scraping_scripts/upload_dbs_to_hf.py CHANGED
@@ -1,6 +1,7 @@
1
  """Upload the Chroma vector database to a Hugging Face dataset repository."""
2
 
3
  import argparse
 
4
  from pathlib import Path
5
 
6
  from dotenv import load_dotenv
@@ -82,21 +83,45 @@ def _prune_stale_remote_files(
82
  )
83
 
84
 
85
- def upload_vector_db(repo_id: str = DEFAULT_REPO_ID) -> None:
86
- try:
87
- api = validate_hf_access(repo_id=repo_id)
88
- except HuggingFaceAuthError as exc:
89
- print(exc)
90
- raise SystemExit(1) from exc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
 
92
  # Step 1: prune. Restore the auto-cleanup we lost when moving off
93
  # `upload_folder(..., delete_patterns=["*"])`. See helper docstring.
94
  _prune_stale_remote_files(
95
  api,
96
  repo_id,
97
- folder_path=FOLDER_PATH,
98
- allow_patterns=ALLOW_PATTERNS,
99
- ignore_patterns=IGNORE_PATTERNS,
100
  )
101
 
102
  # Step 2: upload. `upload_large_folder` is the recommended path once the
@@ -105,14 +130,18 @@ def upload_vector_db(repo_id: str = DEFAULT_REPO_ID) -> None:
105
  # `upload_folder` tends to hit at this size. It is resumable: re-running
106
  # after a failure skips already-uploaded blobs.
107
  api.upload_large_folder(
108
- folder_path=FOLDER_PATH,
109
  repo_id=repo_id,
110
  repo_type="dataset",
111
- allow_patterns=ALLOW_PATTERNS,
112
- ignore_patterns=IGNORE_PATTERNS,
113
  )
114
 
115
 
 
 
 
 
116
  GRAPHRAG_LOCAL_DIR = "data/graphrag/output"
117
  GRAPHRAG_REPO_PATH = "graphrag/output"
118
 
@@ -126,7 +155,7 @@ def upload_graphrag_index(repo_id: str = DEFAULT_REPO_ID) -> None:
126
  it must never delete the production bundle. The runtime cold-start download
127
  (`config.ensure_local_vector_db`) ignores `graphrag/**`, so prod Spaces do
128
  not pull this ~150 MB experiment artifact; pull it explicitly to run the eval
129
- (see evals_graphrag.md).
130
  """
131
  try:
132
  api = validate_hf_access(repo_id=repo_id)
 
1
  """Upload the Chroma vector database to a Hugging Face dataset repository."""
2
 
3
  import argparse
4
+ import os
5
  from pathlib import Path
6
 
7
  from dotenv import load_dotenv
 
83
  )
84
 
85
 
86
+ def upload_bundle(
87
+ repo_id: str = DEFAULT_REPO_ID,
88
+ *,
89
+ folder_path: str = FOLDER_PATH,
90
+ allow_patterns: list[str] | None = None,
91
+ ignore_patterns: list[str] | None = None,
92
+ create_public: bool = False,
93
+ ) -> None:
94
+ """Prune stale remote files, then upload ``folder_path`` to ``repo_id``.
95
+
96
+ The default arguments reproduce the production upload (the private
97
+ all-sources bundle). ``build_public_docs_bundle`` reuses this with a
98
+ different folder/repo/patterns and ``create_public=True`` to publish the
99
+ docs-only bundle to a public dataset repo it may need to create first.
100
+ """
101
+ allow = allow_patterns if allow_patterns is not None else ALLOW_PATTERNS
102
+ ignore = ignore_patterns if ignore_patterns is not None else IGNORE_PATTERNS
103
+
104
+ if create_public:
105
+ # The public repo may not exist yet, so we cannot validate access to it
106
+ # up front (validate_hf_access requires the repo to exist). Create it as
107
+ # a public dataset (idempotent), which also proves the token can write.
108
+ api = HfApi(token=os.getenv("HF_TOKEN"))
109
+ api.create_repo(repo_id, repo_type="dataset", private=False, exist_ok=True)
110
+ else:
111
+ try:
112
+ api = validate_hf_access(repo_id=repo_id)
113
+ except HuggingFaceAuthError as exc:
114
+ print(exc)
115
+ raise SystemExit(1) from exc
116
 
117
  # Step 1: prune. Restore the auto-cleanup we lost when moving off
118
  # `upload_folder(..., delete_patterns=["*"])`. See helper docstring.
119
  _prune_stale_remote_files(
120
  api,
121
  repo_id,
122
+ folder_path=folder_path,
123
+ allow_patterns=allow,
124
+ ignore_patterns=ignore,
125
  )
126
 
127
  # Step 2: upload. `upload_large_folder` is the recommended path once the
 
130
  # `upload_folder` tends to hit at this size. It is resumable: re-running
131
  # after a failure skips already-uploaded blobs.
132
  api.upload_large_folder(
133
+ folder_path=folder_path,
134
  repo_id=repo_id,
135
  repo_type="dataset",
136
+ allow_patterns=allow,
137
+ ignore_patterns=ignore,
138
  )
139
 
140
 
141
+ def upload_vector_db(repo_id: str = DEFAULT_REPO_ID) -> None:
142
+ upload_bundle(repo_id)
143
+
144
+
145
  GRAPHRAG_LOCAL_DIR = "data/graphrag/output"
146
  GRAPHRAG_REPO_PATH = "graphrag/output"
147
 
 
155
  it must never delete the production bundle. The runtime cold-start download
156
  (`config.ensure_local_vector_db`) ignores `graphrag/**`, so prod Spaces do
157
  not pull this ~150 MB experiment artifact; pull it explicitly to run the eval
158
+ (see evals/graphrag.md).
159
  """
160
  try:
161
  api = validate_hf_access(repo_id=repo_id)
evals.md CHANGED
@@ -2,9 +2,9 @@
2
 
3
  This document explains how we test the AI tutor with repeatable conversations and measured results. The main question is: when the tutor has a long chat history, which memory strategy gives the best answers without wasting too many tokens, dollars, or seconds?
4
 
5
- The eval setup compares answer quality, memory retention, retrieval accuracy, tokens, cost, and latency for the June 2026 workshop. The same harness also becomes the ongoing quality program afterwards. Research sources and design rationale live in `evals_background.md`.
6
 
7
- **Running a new experiment?** See `evals_contributing.md` for the step-by-step (run → upload data to HF → record an `F<N>` finding here → merge) and the definition-of-done checklist, so the next person inherits both your result and the data to reproduce it.
8
 
9
  Plain-English map:
10
  - A **battery** is a dataset of test conversations or test questions.
@@ -61,7 +61,7 @@ The system under test is the production tutor (`app/`): the same agent, retrieva
61
  | Tool behavior | Which tools the tutor called, how often, and whether it re-searched for information it had already seen. | Tool-call events saved in each trace bundle. |
62
  | Retrieval | Whether the retrieved chunks included the correct course or lesson. `recall@shown` means "was the right source among the results shown to the model?" `MRR` rewards putting the right lesson earlier in the shown results. | Ground truth stored on each real discussion case: `source_key` and `lesson_url`. |
63
  | Response behavior | Whether the tutor did the correct kind of thing: answer from course content, answer generally, redirect a platform/support issue, or acknowledge feedback. | Cheap code checks for early signals; final behavior accuracy comes from human grades. |
64
- | Answer quality | Whether the answer covered the required key points and whether graded memory probes passed. | Human pass/fail grading in a blinded workbook. LLM judges are used only after they are validated against human labels. |
65
  | Memory | Whether the tutor remembered or updated facts from earlier in the session, followed preferences, resolved references like "that thing from earlier," and used stored student profiles. | Session probes and persona checks. |
66
 
67
  Cost is reported both as raw tokens and as estimated dollars after cached-token discounts. These can rank presets differently (finding F2).
@@ -98,7 +98,7 @@ Command roles:
98
  - `report` builds side-by-side tables and token curves from graded runs.
99
  - `handgrade_workbook` builds and merges a blinded grading workbook, so the grader does not know which preset produced an answer.
100
 
101
- Every turn persists a JSON trace bundle, so grading and reporting can re-run offline without touching the API. Runs are resume-safe: re-run the same command after an interruption and completed units are skipped. Each turn has a 10-minute timeout, and LangSmith is off by default. Running the tutor needs `GEMINI_API_KEY` + `COHERE_API_KEY`; the full 4-preset comparison run below cost **≈$330** at corrected pricing (the pre-2026-06-13 price table under-priced `gemini-3.5-flash` ~4.4×; the often-quoted "$73" is a pre-correction figure — see Harness corrections).
102
 
103
  **Setup for collaborators.** Code and docs are in git; the datasets and run results contain real student text and live only in the private HF dataset (`towardsai-tutors/ai-tutor-data` — git is force-pushed to the public prod Space on deploys, so student data never enters git). With an `HF_TOKEN` that can read it:
104
 
@@ -107,7 +107,7 @@ export HF_TOKEN=hf_... # token with READ access; the inline cmd does NOT load
107
  mkdir -p data/eval runs # runs/ is gitignored, so a fresh clone has no runs/ dir yet
108
  uv run python -c "from huggingface_hub import snapshot_download as d; d(repo_id='towardsai-tutors/ai-tutor-data', repo_type='dataset', allow_patterns=['eval/**','eval_runs/**'], ignore_patterns=['eval/README.md'], local_dir='.')"
109
  mv eval/* data/eval/ && mv eval_runs/part_*/* runs/ # restore working paths (part_b, part_c, ...)
110
- mv eval_runs/slm_compaction/axis_a/* runs/ 2>/dev/null # SLM Axis-A runs -> runs/axisa_* (F31); Axis-B (F30) stays under eval_runs/slm_compaction/axis_b/, see evals_slm_compaction.md
111
  ```
112
 
113
  ## Results — Part B comparison run, 2026-06-12
@@ -153,7 +153,7 @@ Numbered findings; each states what it was tested on. Convention: entries are ne
153
 
154
  ### Part C screen (2026-06-15)
155
 
156
- 11 arms (prod + full_history anchors + 9 variants), each on a fixed subset (24 stratified single-turn + 3 sessions s01/s02/s08), 1 trial, ~660 turns, 0 errors, ≈$166 at correct pricing. Graded by the validated subagent judge. Full table: `runs/c_report/report.md`. Probe-accuracy n=12/arm (1 trial) — treat percentages as coarse rankings, not precise rates; promotion (×3 trials, full batteries) would firm them up.
157
 
158
  - **F14 — The LLM judge is validated; Part C quality columns are reportable.** A blind subagent re-grade of the 96 human-confirmed probes reproduced them at 98% agreement / TPR 100% / TNR 96% (clears the >90% gate; this measures grader *reproducibility*, since the labels were sign-offs, not blind-from-scratch). The validated grader is the subagent workflow (same per-item-type rubric as `evals/judge.py`), run on subscription at no API cost; all Part C arms are graded that way. Supersedes F13's "judge not yet validated" caveat. (`runs/b_report/judge_val/`.)
159
  - **F15 — The screen reproduces F9/F10 on independent arms.** `full_history` is cheapest on sessions ($0.10/turn) AND best memory (100% probe); every compaction arm is both pricier and weaker. `prompt_compression` also reaches 100% memory — it shrinks message text but drops no facts — yet saves little (highest tokens), so "don't drop content" preserves memory but is not a cost win. (Screen; n thin.)
@@ -177,14 +177,18 @@ Numbered findings; each states what it was tested on. Convention: entries are ne
177
 
178
  - **F27 (2026-06-20) — On single-turn, `full_history`'s latency edge is a *tail* effect from `prod` summarizing mid-turn, not a median win; and `total − ttft` cannot de-confound latency.** Re-read of the Part B/C single-turn bundles (no re-run). Paired per question, the `prod` − `full_history` median **TTFT** delta is **+0.43s** (Part B, n=120, 2 trials) / +3.5s (Part C, n=24, 1 trial) and `prod` is slower in only **62/120** cases — a near-tie at the median, *not* the sessions gap (F9's 17s-vs-21–43s is multi-turn and cannot apply with no prior history). The gap lives in the **tail**: `SummarizationMiddleware` fires on **29/120** single-turn `prod` turns (24%) because the agent's *own* retrieval/browse output crosses the 30k trigger **within one turn**; those turns run **52s TTFT / 16 LLM calls** vs **29s / 8** when it doesn't, and on the *same* questions `prod` does 2–3× `full_history`'s calls (e.g. 9→27, 12→32) — the F9/F4 re-work pathology (summary drops raw evidence → re-browse) inside a single turn. `full_history` never summarizes, so it skips the tail; that lifts its *mean* (Part C 35s vs 51s) while the *median* stays tied. **Methodological corollary (extends the time-of-run caveat below):** `total − ttft` does **not** strip API-load noise — it is the answer-streaming tail (**~6.0s for both presets**; +1.5s even when summarization fires), because the entire agentic loop incl. the summarization tax lands in `ttft`, so subtracting it deletes the signal. Latency ≈ work × time-per-work, and the confound smears across ~20 calls, so no algebra on one sequential run removes it; the **unconfounded** read is the work metrics — **LLM calls/turn and tokens/turn** (run-time-independent like cost/recall, already in every bundle) — which are also the upstream cause. Live LangSmith spot-check (3 interleaved pairs, one browse-heavy single-turn Q) reproduced **both** directions: `prod` faster once (summary collapsed 268k→23k ctx, 65s) and slower twice (re-work: 23/32 calls, 116s/124s, $2.27/$1.04 vs `full_history` $0.74/$0.33), and LangSmith's independent latency matched telemetry to the ms (`prod` 65.07s vs 65.16s; `full_history` 103.64s vs 104.28s), trees confirming `full_history` carries no `SummarizationMiddleware`. Caveats: thin n and time-of-run confounded for absolute seconds; the live pairs are n=3 on one non-representative question. Same class as F24 (single-turn *clearing* backfire) on the other compaction half.
179
 
180
- - **F28 (2026-06-19) — GraphRAG does not beat classical hybrid RAG on single-turn course Q&A: it ties on grounding accuracy and costs +44% $/turn, empirically confirming the earlier decision to drop it.** A scoped, fair head-to-head where the *only* variable is the retrieval backend behind `retrieve_tutor_context`: production hybrid `LocalChromaRetriever` (dense Cohere `embed-v4` + BM25 → RRF → Cohere rerank → token budget) vs a true Microsoft GraphRAG index (`app/graph_rag.py` `GraphRAGRetriever` — local-search-style context assembly over entities/text-units/community-reports, mapped back to real `source`/`url` via `corpus_manifest`, then the **same Cohere rerank + token budget** as classical; context-provider only, so the agent's **Gemini 3.5 Flash is the sole generation model in both arms**). 41 `full_stack_ai_engineering` single-turn cases (27 `answer_from_corpus`), Gemini 3.5 Flash, `--disable-kb` so the retriever is the only variable, `--scope-sources`, 1 trial, **0 errors/arm**. Both arms **surface and cite the right source 100%** of the time and tie on lesson recall (**76%**) and cited-correct lesson (**85%**); classical is slightly *better* at ranking the right lesson (**MRR 0.70 vs 0.65**). GraphRAG pulls community-report context, so it spends **+61% input tokens (178k vs 110k) and +44% $/turn ($0.212 vs $0.147)** and is a touch slower, with no accuracy payoff (the +3pt behavior proxy is a coarse n=37 code check, not a quality signal). So GraphRAG's community structure does not help typical single-hop course Q&A — this **empirically supports** the prior "dropped on faith" decision (see "Deliberately not pursued" below) rather than asserting it. **Reusable artifact:** the scoped index (90 docs → 15,059 entities / 32,748 relationships / 3,371 community reports, built with Gemini 2.5 Flash for **$44.96**) is published to the private HF dataset (`graphrag/output/`, pulled on demand, *not* by prod cold-start — `ensure_local_vector_db` ignores `graphrag/**`), so the eval re-runs without the ~$45 rebuild. Full methodology, build/run commands, and the indexing gotchas (route Gemini 2.5 via its OpenAI-compatible endpoint; `run_graphrag_index.sh` loops `graphrag index` until `entities.parquet` exists since one exhausted retry aborts the stage) live in **`evals_graphrag.md`**. Connects to F17/F23 (the `kb_off` grounding discussion — classical RAG already grounds well). Caveats: scoped to one source, single-turn, n=41 (27 corpus-answer), 1 trial, auto metrics only (key-point/behavior human grades pending); `recall@source` is saturated by `--scope-sources`, so lesson recall / MRR / cited-correct-lesson are the discriminating numbers. GraphRAG's theoretical edge is **multi-hop / cross-document synthesis**, which this single-hop battery barely exercises — so this says it doesn't help on single-hop course Q&A, *not* that it never helps; a fair test of that strength needs a multi-hop `_v2` probe set (out of scope here). (Branch `experiment/graphrag-vs-rag`, PR #2.)
181
 
182
- - **F29 (2026-06-20) — Keeping a large *static document* in context is a different regime from conversational `full_history`, and it is one boundary where "keep everything" stops winning: for a single long lesson queried repeatedly, retrieving the relevant slice matches keep-it-all at ~1/13 the cost. (Boundary on F9; standalone Gemini-2.5-Flash fleet — absolute numbers not cross-comparable.)** A keep-vs-compact-vs-retrieve study on the corpus's largest lesson (~37.5k tok) loaded once in turn 0, then **15 question-turns with no tools** (the only context is what each method keeps or builds), each answer judged against the full lesson; 11 arms, Gemini 2.5 Flash, 1 trial. `rag` (fetch the relevant chunk per question) is **cheapest *and* tied-best** (60% / 3.2k tok / $0.020); `full_history` (keep the whole lesson in the prompt) is the **priciest in-context arm and not the best** (53% / 43k tok / $0.134); the summarization family cuts ~35% of tokens for a quality drop (40–53%); `incontext_history_retrieval` ties `rag` on quality but at ~13× the tokens; **`hierarchical_summarization` is the worst trade** — priciest ($0.605) and slowest (41.8s p50) for the lowest quality (33%). `graphrag` (53%, $0.045) again does not beat plain `rag` (consistent with **F28**). **Read it as a lesson, not a head-to-head:** this is *not* "RAG beats the `full_history` memory strategy" — here `full_history` only means "the whole lesson sits in the prompt," not its multi-turn-memory role. It says **F9's "keep everything wins" is about *conversation history*; for a big *static document* you could retrieve from, hoarding it in context is the priciest option and buys nothing**, and in the production tutor retrieval (Axis B) and a memory policy (Axis A) **compose**, they don't compete. Companion boundary: F30 (small context window). New reusable presets landed in `app/memory_presets.py`: `delta_summarization`, `hierarchical_summarization`. Full matrix, the F-C1–F-C6 detail, and reproduce steps in **`evals_compaction.md`**. Caveats: n=15, 1 session, 1 trial (coarse rankings, not precise rates); judge is Gemini 2.5 Flash (same family, reads the full lesson as ground truth); **separate fleet from the Part B/C 3.5-Flash runs — quality is not cross-comparable across models**. (Branch `experiment/context-compaction`, PR #5.)
183
 
184
- - **F30 (2026-06-20) — A small context window is a second boundary where "keep everything" stops winning, and a harder one than F29: when the document physically does not fit, "shove it all" (`full_context`) is forced into truncation and is strictly *dominated*, so retrieving the relevant slice (`rag`) is not just cheaper but necessary. (Small-window companion to F29, Axis B; local-SLM fleet — absolute numbers not cross-comparable.)** "Fit one ~37.7k-token lesson into context to answer 15 questions," 7 methods × 3 Ollama SLMs (llama3.1:8b, qwen2.5:7b-instruct, qwen3:8b no-think) on an M1 Pro/16 GB at a 32k window (`--num-ctx 32768`), no prompt caching; judge stays on Gemini 2.5 Flash (reads the full lesson as ground truth — the SLM cannot hold it, and a model must never grade itself), 1 trial. `full_context` (the whole *document* placed in the prompt, `ctx['full_context'] = lesson`, stateless per question — this is **document-stuffing**, *not* the conversational `full_history` memory policy) is **truncated on 15/15 turns** (37.7k → 32,767) and **dominated**: equal-or-worse quality (67–80%) at **~12–15× the latency** (266–346s vs ~20s) — silent information loss *plus* a huge latency tax (e.g. qwen2.5 `full_context` answered "no model was mentioned" for a fact the truncated intro had named). `rag` (chunk + Cohere-embed + retrieve top-k per question) is **top or tied-top on all three** (qwen2.5 100%, qwen3 100%, llama 80%) at ~2.9k context tokens and ~20–26s. `graphrag` ties `rag` on quality but at ~2.8× tokens and ~2.5× latency — no payoff, **consistent with F28/F29**. Summarization is the floor (llama `summary` **0%**); `trim`/`selective` land mid (47–73%). This marks where F9/F15's precondition — a window big enough to hold everything, cheaply, under caching — simply fails on the hardware a workshop attendee runs. **Distinct from the conversational-memory inversion (F31):** `full_context` here is *document-stuffing* (Axis B), not `full_history`'s multi-turn role, exactly the distinction F29 drew. **Methodology fix:** the first pass scored ~64% of judge verdicts as fails because a long JSON `reason` hit `max_tokens` and truncated the verdict; hardened the judge (25-word reason cap + leading-boolean recovery) and re-graded the saved answers offline (`evals/rejudge_compaction.py`, **0 unparseable** after) — the directly-measured axes (tokens, latency, overflow) were never affected. Caveats: n=15/method, 1 trial, one lesson, one domain — read as a ranking, not rates; **separate fleet (local Ollama SLMs, no caching, Gemini-2.5-Flash judge) — quality/cost not cross-comparable** with the Part B/C 3.5-Flash, the F29 2.5-Flash, or the DeepSeek runs. Full writeup: **`evals_slm_compaction.md`** (Experiment 1). (Branch `experiment/slm-compaction`, PR #3.)
185
- - **F31 (2026-06-20) — On an SLM, compacting a *growing conversation history* has no universal best method, and "keep everything" (`full_history`) never wins — and model capability dominates the choice of method. (SLM counterpart to F9/F15 on Axis A; same local fleet — absolute numbers not cross-comparable.)** Same lesson loaded turn 0 of a session, 15 question-turns, **retrieval off**, run through the real app middlewares (the production memory presets) on the 3 SLMs at `num_ctx=32768`, judged on Gemini against the full lesson, 1 trial. The best preset is **model-dependent** — `prompt_compression` on qwen2.5 (60%), `hierarchical_summarization` on llama3.1 (40%), `summarization_only`/`delta_summarization` on qwen3 (73%) — so there is **no single "best compaction" to recommend**. `full_history` (the genuine conversational memory policy — keep *every prior turn*, no summarization or context-editing; `summarization=False, context_editing=False`) **tops no model** (27% / 7% / 67%) and is near-bottom on the two weaker ones; `sliding_window` is reliably among the worst (13% / 13% / 40%). **Model capability dominates the method:** qwen3 (40–73% across *every* preset) >> qwen2.5 (13–60%) >> llama3.1 (0–40%) — choosing a stronger small model buys more than choosing the best strategy. **Mechanism (why keep-all can't win here):** on an SLM "keep everything" (`full_history`) can't even be truncated-and-kept — Ollama's server-side context fitting **evicts the whole oversized turn-0 lesson** from turn 1 on (the checkpoint still holds it; the model receives only ~0.3–1.4k tok of accumulated Q&A), so a method that *summarizes the lesson into a small injected message* is the only way to retain its gist; on Gemini's large window this never happens (F9's `full_history` held ~43k/turn). `hierarchical_summarization` is by far the priciest (≈30-call map-reduce, p50 233–333s, ~25–29k retained tok) for no quality lead except on the weakest model. This **inverts F9/F15's conversational-memory result** (keep-all cheapest *and* best) in the regime where the runtime physically cannot keep it. Caveats: n=15, 1 trial, LLM-judge variance moves individual cells ±1–3/15 (read only the big gaps); the `full_history` numbers are **Ollama-eviction-specific** (a different serving layer could keep a truncated lesson instead); **same separate-fleet comparability caveat as F30**. Writeup: **`evals_slm_compaction.md`** (Experiment 2). (Branch `experiment/slm-compaction`, PR #3.)
186
  - **F32 (2026-06-22) — Synthesis: "keep everything in context" wins only under a specific precondition — a large window that holds the whole history cheaply under prompt caching — and we have now mapped three boundaries where that precondition breaks and keep-all stops winning.** **Two distinct "keep-all" arms — never conflate them:** `full_history` (Axis A — F9/F15/F31) is the *memory policy* that keeps every prior **conversation turn** with no compaction (`summarization=False, context_editing=False` in `app/memory_presets.py`); `full_context` (Axis B — F30) is **document-stuffing** — the whole *source document* placed in the prompt (`ctx['full_context'] = lesson`). Same slogan ("keep everything"), different mechanisms. **F29 is the trap to watch:** its keep-all arm ran the `full_history` *preset* but over a single static lesson parked in turn 0 (`turns = [lesson] + questions`), so what it actually measured is the `full_context` idea (a document held in the prompt), **not** `full_history`'s multi-turn-memory role — which is why F29 sits on the *document* boundary (1) below, alongside F30, and **not** the conversational boundary (3). F9/F15 established the default (raw `full_history` is cheapest *and* best memory on conversational sessions), and F2/F25/F26 explain *why* it holds and even strengthens with horizon (the big **cached** prefix is cheaper than any compaction's cache-breaking rewrite; DeepSeek's ~50× cache pushes the crossover past 36 turns). Remove a piece of that precondition and keep-all loses, along three boundaries: **(1) a large static document you could retrieve from** (F29, Axis B, large cached window) — retrieving the slice *matches* keep-all at ~1/13 the cost, so hoarding the doc is the priciest option and buys nothing; **(2) a context window too small to hold the document** (F30, Axis B, SLM) — keep-all (`full_context`) is forced into truncation and is strictly dominated, so retrieval is *necessary*, not merely cheaper; **(3) a growing history on a small window** (F31, Axis A, SLM) — the runtime *evicts* the oversized turn, so keep-all (`full_history`) never tops the table and some compaction is mandatory, though no single method wins. Two cross-cutting lessons fall out: **model capability dominates the compaction method** on weak local models (F30/F31 — a better small model beats a better strategy), and the **axes compose, they don't compete** (F29) — retrieval (Axis B, *how to fit a document*) and a memory policy (Axis A, *how to compact history*) are orthogonal, so "RAG vs `full_history`" is a category error. **Net guidance:** keep-everything stays the right default in the production regime (large cached cloud window + conversational history; F9/F15/F25/F26); reach for retrieval or compaction exactly when one precondition breaks — a big static doc, a small window, or no caching. **Comparability:** this ties the *lessons* across fleets (3.5-Flash screen, 2.5-Flash F29, DeepSeek F25/F26, local SLMs F30/F31), **not** their absolute numbers, which are not cross-comparable.
187
 
 
 
 
 
188
  **Screen winners → promotion candidates:** `incontext_history_retrieval` (needs the long-session test), `retrieval_budget_30k` and `clear_retrieval_kb` (cheap Axis-B wins; clear_retrieval_kb is cheaper than prod with similar memory + better recall/key-points), anchored by `full_history`. **Drop:** `context_reset`, `observation_truncation`, `selective_retention`.
189
 
190
  **Part C — what remains (beyond the workshop).** The screen answered the core question; everything below *hardens* or *extends* it and is **gated on a spend or data decision from Omar** — none is required for the workshop.
@@ -192,7 +196,7 @@ Numbered findings; each states what it was tested on. Convention: entries are ne
192
  1. **Promotion (rigor, not new findings).** Re-run the 2-3 winners (+ 1-2 combos) on the full batteries (60 single-turn + 32 sessions + 30 replay) × 3 trials, with paired statistical tests and per-variant failure-taxonomy diffs, to turn the screen's coarse n=12 rankings into confident numbers; optionally an Anthropic (Haiku) re-run of the prefix-rewriting arms (`prompt_compression`, `context_reset`, `aggressive`) to test whether explicit caching flips F2's cost ranking. **~$1,300-1,800 at corrected pricing** (the old "$300-400" was at pre-correction prices; × the 4.4 fix — estimate precisely before running, since the per-turn cost is now ~$0.25).
193
  2. **Finish the v2 contradiction tier — the highest-value new dataset (already built and partially run; see the V2 execution note below).** Moderate sessions: plant A, update to A′, then probe after prod compaction has evicted A from the kept-recent window. This is the one correctness regime where `full_history` itself might lose (it sees both A and A′ and must choose the current one) — yet the partial run so far shows the opposite (`full_history` contradiction **2/2** vs `prod` **0/3**), so that regime has not actually appeared. What remains is finishing Tier 1 (the missing `full_history` session, then Tier-1 `profile_memory` and optionally `incontext_history_retrieval`) and adding trials, not building the dataset. **(2026-06-19 — now run in full on DeepSeek-V4-Flash rather than Gemini: F26 confirms `full_history` 3/3 contradiction vs `prod` 0/3, so the regime did not appear on a second model either; the Gemini Tier-1 finish is now optional cross-model confirmation.)**
194
  3. **The `incontext_history_retrieval` long-session cost test — principled but less product-critical.** The screen showed `incontext` works (83%, F16) but couldn't show a cost benefit on ≤13-turn sessions. Whether retrieve-old-turns beats `full_history` *once sessions get genuinely long* needs a token-calibrated long-session v2 tier; useful for the workshop argument, but gated by whether that regime matters in real tutor telemetry. **(2026-06-19 — run on DeepSeek (F25) at 36 turns: `full_history` stays cheapest and `incontext` works but does not beat it on cost under first-party caching; whether the answer flips on a 10× cache provider like Gemini/Anthropic is still open.)**
195
- 4. **Conditional builds.** Build `temporal_graph_memory` only if `full_history` fails contradictions (F26: it did **not** on DeepSeek — 3/3 — so this stays unbuilt), `delta_summarization` only if the long-horizon cost tier shows room to beat full history (F25: `full_history` still cheapest at 36t on DeepSeek, so no room there — though a 10× cache provider is untested), and `entity_memory` only if multi-project probes show fact bleed. `collection_memory` is a separate cheap v1/product follow-up for verbatim fact storage (F22 tested the current profile write-back, not that). `hierarchical_summarization` and `sleeptime_consolidation` are deferred. The step-by-step v2 plan is in `evals_part_c_plan.md` → "Battery v2".
196
 
197
  **Deliberately not pursued.** *Stretch (heavier, likely later):* sub-agent isolation, reframed as a tool-output-token play — keep noisy `run_kb_command` output out of the main context (ties to F1); temporal-graph memory is already the conditional build in item 4 above. *Dropped as low-information given the findings:* the skills / lazy-prompt-loading family (only a ~458-token win — see the talk-outline correction below), procedural memory, GraphRAG (now **empirically tested** rather than dropped on faith — **F28**: ties classical RAG on grounding, costs +44% $/turn, no payoff on single-hop Q&A), and multi-agent / parallel-research agents. Context for the `kb_off` arm (F17/F23): the KB is the dominant grounding tool — ≈89% of Part B turns, ~7.7 KB vs ~0.9 retrieval calls/turn over 1,088 turns — so disabling it forces the 100k-token retrieval fallback, which is why it raised cost/latency.
198
 
@@ -200,14 +204,16 @@ Numbered findings; each states what it was tested on. Convention: entries are ne
200
 
201
  Harness corrections (bugs in our measurement, not findings): the overnight 06-12 stall was machine sleep hanging API streams (all four pipelines stopped the same minute; fixed with the per-turn timeout); battery lesson-URLs carried a `/discussions/` suffix that silently zeroed recall@lesson until normalized (caught by the A3 smoke, re-graded from bundles without re-running).
202
 
203
- - **Pricing correction (2026-06-13).** `MODEL_PRICING` under-priced `gemini-3.5-flash` before this date (~$0.30/$2.50 per MTok vs the correct **$1.50 input / $9.00 output / $0.15 cache-read**, verified against Google's price sheet). Every dollar figure generated earlier — the Part B table and the inline costs in findings **F2/F8/F9/F12** — is **~4.4× too low**; token counts are unaffected. Part B bundles were re-costed from their saved token counts (2026-06-15) and `runs/b_report/report.md` regenerated at correct pricing: **relative rankings are unchanged** (F9 still has `full_history` cheapest, $0.11/turn sessions), only absolute dollars move. Real Part A+B spend ≈ **$338**, not ~$73. Use the regenerated report for absolute costs; finding dollars above are pre-correction.
204
  - **Stable `sheet_row_id` (2026-06-15).** `evals.grade` built `sheet_row_id` with builtin `hash()`, which is salted per process, so regenerating a `handgrade_sheet.csv` produced ids that no longer matched the frozen workbook keymap — silently emptying `handgrade_workbook merge`. Switched to a `hashlib.md5` hash; the Part B re-merge was rebuilt via the deterministic `run_id` to recover the human grades.
205
  - **`cleared_tool_outputs` is blind to library clearing (2026-06-19).** `ContextEditingMiddleware` edits a per-call `deepcopy` and never persists the placeholder to the checkpoint that `context_window_stats` reads, so the signal is 0 for all presets whether or not `ClearToolUsesEdit` fired (full detail + token evidence in **F24**). Detect clearing via input-token deltas; the custom Part C view-mechanisms avoid this by reporting through the `app.telemetry` turn-signal registry.
 
 
206
  - **Report mislabeled judge grades as human (2026-06-19, fixed).** `evals.report` hardcoded "(human grade)" on the behavior / key-point / probe-accuracy rows, but Part C runs were filled by the LLM judge (`judge_filled.csv`), not a person — so `runs/c_report/report.md` asserted a human label over judge numbers. Fix: `evals.grade` now records a `grade_source` per merged grade (the judge prefixes its note `[judge…]`; humans do not), and `evals.report` tags each column `[judge]`/`[human]`/`[mixed]` and renames the rows to "(graded)". Numbers are unchanged (and judge≈human per F14); only the provenance label is now honest. Same label-vs-measurement class as F23/F24. All Part C arms were re-graded and `c_report` regenerated.
207
  - **`input tok/turn` is billed-across-calls, not context size (2026-06-19, clarified).** Telemetry sums `usage_metadata` over every internal model call in a turn, so a high-loop arm (`observation_truncation`, 22.8 calls) bills lots of input from small contexts while a big-payload arm (`kb_off`) bills similar totals from few large ones. Correct for *cost*, but it is not "how big is the context window." The report now shows both: **input tok/turn (billed, all calls)** and a new **context tokens/turn (window size)** row (from `context_tokens_approx`, always computed but previously unsurfaced). On single-turn the two even rank arms differently (e.g. `kb_off` has the *largest* window at 73k from its 100k retrieval payloads, but mid-pack billed input). Read F1/F6's "~200k input" as billed volume, not window size.
208
  - **Latency rows are confounded by time-of-run (caveat, not yet fixed).** `run_part_c_screen.sh` runs arms sequentially, and TTFT/total-ms depend on API load at that moment, so cross-arm latency deltas (e.g. F19's 430s p95, F23's 19s-vs-38s) partly reflect *when* each arm ran. Token/cost/recall are run-time-independent and unaffected. Any latency claim that drives a decision should come from arms run interleaved (or repeated trials), not a single sequential screen. **`total − ttft` does *not* remove this** — it is only the answer-streaming tail (~6s, preset-insensitive); the agentic latency incl. the summarization tax is all in `ttft`, so subtracting it deletes the signal. Report the unconfounded work metrics (LLM calls/turn, tokens/turn) as the headline and treat seconds as supporting. See **F27**.
209
  - **Latency de-confounding tooling (2026-06-22).** Acting on F27: (1) `runs/run_part_c_screen_interleaved.sh` runs the screen question-outer / arm-inner (writes `runs/ci_*`, not `runs/c_*`) so every question's arms share one API-load window — same total runs as the sequential screen, so interleaving is free. (2) `context_stats` now also emits `time_to_first_token_ms` (first reasoning/tool/text token, always ≤ `ttft_ms`), separating raw model responsiveness from the tool-call loop that `ttft_ms` includes; it flows through `evals/grade.py` to the report and shows `—` on runs recorded before it existed. (3) `evals/report.py` now leads with the run-time-independent work metrics (LLM/tool calls/turn, tokens/turn) and tags every latency row `[confounded]`, with a runtime note. All `runs/*report*` were regenerated (layout/labels only; numbers unchanged). The scripts live in gitignored `runs/` like the rest of the run tooling.
210
- - **Pattern worth naming — most of our measurement bugs are telemetry/label blind-spots, not wrong math.** Four now share one shape: a number or label asserted a property the instrumentation could not observe for that config — **F21** (mechanism dormant: `profile_memory` never engaged on sessions), **F23** (recall blind to KB browsing), **F24** (clearing invisible to the checkpoint signal), and the judge-as-human label above. `check_triggers` enforces "did the mechanism fire?" for *compaction* only; nothing yet does for retrieval/profile/KB engagement or grade provenance. Standing guard for any new arm: before trusting a per-arm number, confirm the signal/label reflects what the config actually did.
211
 
212
  Talk-outline correction: the planned "skills / just-in-time KB instructions" change (Change 4) was never built, and its premise is off — the KB instructions block measures **458 tokens** (not ~4k) inside a 1,655-token system prompt; max saving ~2% of a turn, mostly cache-discounted. Recommended reframe: demo progressive disclosure on tool *outputs* (F1), where our numbers actually are.
213
 
@@ -224,22 +230,22 @@ Talk-outline correction: the planned "skills / just-in-time KB instructions" cha
224
  - [ ] Next.js meter component over the `data-context-stats` SSE part, if the live demo needs it.
225
  - [ ] Skills section: reframe per the 458-token measurement above.
226
 
227
- **Beyond the workshop.** Part C itself is done and reported above (findings F14–F24); what remains only *hardens* or *extends* it and is consolidated in "Part C — what remains" above — promotion, finishing the v2 contradiction tier, the `incontext_history_retrieval` long-session test, the conditional subsystem builds, and the deliberately-not-pursued list. The full execution detail (variant wiring, telemetry signals, the subset run matrix, the cost model, and the v2 battery plan) lives in `evals_part_c_plan.md`.
228
 
229
  **Product quality track (post-workshop)**
230
- Golden cases as a CI gate (5–10 critical-path cases with deterministic assertions, including the F7 non-empty check) → error-analysis cycles (an expert reads 50–100 traces in a small viewer, records pass/fail plus the first failure reason, and stops when new failure types stop appearing) → failure taxonomy → code assertions for recurring modes → LLM judges built from the hand-grade critiques and validated to >90% true-positive and true-negative rates on held-out human labels before any judge-graded number is reported → nightly battery + weekly trace sampling. Full methodology behind each step: `evals_background.md`.
231
 
232
  ## Files
233
 
234
  - `evals.md` — this file: the what, the data, the results, the queue.
235
- - `evals_contributing.md` — contributor playbook: how to run an experiment and land it (run → upload data to HF → record an `F<N>` finding → merge), with a definition-of-done gate.
236
- - `evals_part_c_plan.md` — Part C execution plan: orchestration (workflow vs direct), the variant catalog with exact wiring + telemetry signals, the subset run matrix, and cost.
237
- - `evals_background.md` — research sources (Hamel, howtoeval, OpenAI macro-evals) and design rationale.
238
- - `evals_graphrag.md` — GraphRAG vs classical RAG experiment (branch `experiment/graphrag-vs-rag`): a scoped, true-GraphRAG head-to-head on the single-turn battery. Revisits the dropped GraphRAG idea with a fair test.
239
- - `evals_compaction.md` — compaction-vs-keep-everything study (branch `experiment/context-compaction`): on the largest lesson, keep-all vs every compaction method vs retrieve-per-question. **Standalone fleet on Gemini 2.5 Flash — not comparable to the 3.5-Flash results above.**
240
- - `evals_slm_compaction.md` — knowledge-compaction on small local models (branch `experiment/slm-compaction`): two experiments on 3 Ollama SLMs (llama3.1:8b, qwen2.5:7b, qwen3:8b) at a 32k window where the lesson overflows. **Axis B** (fit a document: RAG vs stuff vs summarize, F30) and **Axis A** (compact growing history via the real memory presets, F31). The regime where compaction is *forced*; RAG wins Axis B, no universal winner on Axis A, and "keep everything" never wins on an SLM (synthesis in F32).
241
  - `data/eval/README.md` — battery schemas + glossary of terms; `review_log_v1.md` — dataset audit trail.
242
  - `evals/` — the harness code; `app/memory_presets.py`, `app/telemetry.py` — the app-side hooks.
243
  - `runs/b_report/`, `runs/c_report/` — generated tables, token curves, blinded grading workbook (Part B and the Part C screen). Re-grade / follow-up reports: `runs/kbfair_report/` (F23 KB-fair recall), `runs/d_report_profile_memory_active/` (F22 profile rerun), `runs/e2_v2_partial_report/` (v2 partial).
244
  - `runs/ds_v2_t1_report/`, `runs/ds_v2_t2_report/` — **DeepSeek-V4-Flash** v2 matrix (F25/F26: cost + contradiction + long-horizon), graded via the subagent path (`runs/ds_judge/`). Shared on HF as `eval_runs/part_f_deepseek_v2/` (the standard `mv eval_runs/part_*/* runs/` download restores them). Provider added in `app/chat_service.py` (`deepseek` / `openrouter`), pricing in `app/telemetry.py`.
245
- - `runs/axisa_*_report/`, `data/compaction_slm_*/` — **SLM compaction** study (F30/F31/F32, branch `experiment/slm-compaction`): Axis B fit-a-document and Axis A compact-history on 3 Ollama SLMs at a 32k window. Shared on HF under `eval_runs/slm_compaction/` (`axis_b/<model>/`, `axis_a/axisa_<model>_<preset>/`) + batteries `eval/compaction/`; the snippet above restores Axis-A to `runs/axisa_*`, Axis-B stays under `eval_runs/slm_compaction/axis_b/` — full restore command in `evals_slm_compaction.md`. Provider `ollama` added in `app/chat_service.py`.
 
2
 
3
  This document explains how we test the AI tutor with repeatable conversations and measured results. The main question is: when the tutor has a long chat history, which memory strategy gives the best answers without wasting too many tokens, dollars, or seconds?
4
 
5
+ The eval setup compares answer quality, memory retention, retrieval accuracy, tokens, cost, and latency for the June 2026 workshop. The same harness also becomes the ongoing quality program afterwards. Research sources and design rationale live in `evals/background.md`.
6
 
7
+ **Running a new experiment?** See `evals/contributing.md` for the step-by-step (run → upload data to HF → record an `F<N>` finding here → merge) and the definition-of-done checklist, so the next person inherits both your result and the data to reproduce it.
8
 
9
  Plain-English map:
10
  - A **battery** is a dataset of test conversations or test questions.
 
61
  | Tool behavior | Which tools the tutor called, how often, and whether it re-searched for information it had already seen. | Tool-call events saved in each trace bundle. |
62
  | Retrieval | Whether the retrieved chunks included the correct course or lesson. `recall@shown` means "was the right source among the results shown to the model?" `MRR` rewards putting the right lesson earlier in the shown results. | Ground truth stored on each real discussion case: `source_key` and `lesson_url`. |
63
  | Response behavior | Whether the tutor did the correct kind of thing: answer from course content, answer generally, redirect a platform/support issue, or acknowledge feedback. | Cheap code checks for early signals; final behavior accuracy comes from human grades. |
64
+ | Answer quality | Whether the answer covered the required key points, passed graded memory probes, and (holistic) whether course staff would approve sending it as a genuinely helpful, learning-oriented reply. A separate **faithfulness** check (every claim grounded in retrieved evidence) is built but parked — see the cap correction below. | Human pass/fail grading in a blinded workbook, or the validated blind subagent judge (same per-item rubric). LLM judges are used only after they are validated against human labels. Holistic and probe quality are complementary — holistic is graded blind to conversation history, so it cannot see a memory failure (F34); read them together. |
65
  | Memory | Whether the tutor remembered or updated facts from earlier in the session, followed preferences, resolved references like "that thing from earlier," and used stored student profiles. | Session probes and persona checks. |
66
 
67
  Cost is reported both as raw tokens and as estimated dollars after cached-token discounts. These can rank presets differently (finding F2).
 
98
  - `report` builds side-by-side tables and token curves from graded runs.
99
  - `handgrade_workbook` builds and merges a blinded grading workbook, so the grader does not know which preset produced an answer.
100
 
101
+ Every turn persists a JSON trace bundle, so grading and reporting can re-run offline without touching the API. Runs are resume-safe: re-run the same command after an interruption and completed units are skipped. Each turn has a 10-minute timeout, and LangSmith is off by default. Running the tutor needs `GEMINI_API_KEY` + `COHERE_API_KEY`; the full 4-preset comparison run below cost **≈$323** at corrected pricing (the pre-2026-06-13 price table under-priced `gemini-3.5-flash` ~4.4×; the often-quoted "$73" is a pre-correction figure — see Harness corrections).
102
 
103
  **Setup for collaborators.** Code and docs are in git; the datasets and run results contain real student text and live only in the private HF dataset (`towardsai-tutors/ai-tutor-data` — git is force-pushed to the public prod Space on deploys, so student data never enters git). With an `HF_TOKEN` that can read it:
104
 
 
107
  mkdir -p data/eval runs # runs/ is gitignored, so a fresh clone has no runs/ dir yet
108
  uv run python -c "from huggingface_hub import snapshot_download as d; d(repo_id='towardsai-tutors/ai-tutor-data', repo_type='dataset', allow_patterns=['eval/**','eval_runs/**'], ignore_patterns=['eval/README.md'], local_dir='.')"
109
  mv eval/* data/eval/ && mv eval_runs/part_*/* runs/ # restore working paths (part_b, part_c, ...)
110
+ mv eval_runs/slm_compaction/axis_a/* runs/ 2>/dev/null # SLM Axis-A runs -> runs/axisa_* (F31); Axis-B (F30) stays under eval_runs/slm_compaction/axis_b/, see evals/slm_compaction.md
111
  ```
112
 
113
  ## Results — Part B comparison run, 2026-06-12
 
153
 
154
  ### Part C screen (2026-06-15)
155
 
156
+ 11 arms (prod + full_history anchors + 9 variants), each on a fixed subset (24 stratified single-turn + 3 sessions s01/s02/s08), 1 trial, ~660 turns, 0 errors, ≈$163 at correct pricing. Graded by the validated subagent judge. Full table: `runs/c_report/report.md`. Probe-accuracy n=12/arm (1 trial) — treat percentages as coarse rankings, not precise rates; promotion (×3 trials, full batteries) would firm them up.
157
 
158
  - **F14 — The LLM judge is validated; Part C quality columns are reportable.** A blind subagent re-grade of the 96 human-confirmed probes reproduced them at 98% agreement / TPR 100% / TNR 96% (clears the >90% gate; this measures grader *reproducibility*, since the labels were sign-offs, not blind-from-scratch). The validated grader is the subagent workflow (same per-item-type rubric as `evals/judge.py`), run on subscription at no API cost; all Part C arms are graded that way. Supersedes F13's "judge not yet validated" caveat. (`runs/b_report/judge_val/`.)
159
  - **F15 — The screen reproduces F9/F10 on independent arms.** `full_history` is cheapest on sessions ($0.10/turn) AND best memory (100% probe); every compaction arm is both pricier and weaker. `prompt_compression` also reaches 100% memory — it shrinks message text but drops no facts — yet saves little (highest tokens), so "don't drop content" preserves memory but is not a cost win. (Screen; n thin.)
 
177
 
178
  - **F27 (2026-06-20) — On single-turn, `full_history`'s latency edge is a *tail* effect from `prod` summarizing mid-turn, not a median win; and `total − ttft` cannot de-confound latency.** Re-read of the Part B/C single-turn bundles (no re-run). Paired per question, the `prod` − `full_history` median **TTFT** delta is **+0.43s** (Part B, n=120, 2 trials) / +3.5s (Part C, n=24, 1 trial) and `prod` is slower in only **62/120** cases — a near-tie at the median, *not* the sessions gap (F9's 17s-vs-21–43s is multi-turn and cannot apply with no prior history). The gap lives in the **tail**: `SummarizationMiddleware` fires on **29/120** single-turn `prod` turns (24%) because the agent's *own* retrieval/browse output crosses the 30k trigger **within one turn**; those turns run **52s TTFT / 16 LLM calls** vs **29s / 8** when it doesn't, and on the *same* questions `prod` does 2–3× `full_history`'s calls (e.g. 9→27, 12→32) — the F9/F4 re-work pathology (summary drops raw evidence → re-browse) inside a single turn. `full_history` never summarizes, so it skips the tail; that lifts its *mean* (Part C 35s vs 51s) while the *median* stays tied. **Methodological corollary (extends the time-of-run caveat below):** `total − ttft` does **not** strip API-load noise — it is the answer-streaming tail (**~6.0s for both presets**; +1.5s even when summarization fires), because the entire agentic loop incl. the summarization tax lands in `ttft`, so subtracting it deletes the signal. Latency ≈ work × time-per-work, and the confound smears across ~20 calls, so no algebra on one sequential run removes it; the **unconfounded** read is the work metrics — **LLM calls/turn and tokens/turn** (run-time-independent like cost/recall, already in every bundle) — which are also the upstream cause. Live LangSmith spot-check (3 interleaved pairs, one browse-heavy single-turn Q) reproduced **both** directions: `prod` faster once (summary collapsed 268k→23k ctx, 65s) and slower twice (re-work: 23/32 calls, 116s/124s, $2.27/$1.04 vs `full_history` $0.74/$0.33), and LangSmith's independent latency matched telemetry to the ms (`prod` 65.07s vs 65.16s; `full_history` 103.64s vs 104.28s), trees confirming `full_history` carries no `SummarizationMiddleware`. Caveats: thin n and time-of-run confounded for absolute seconds; the live pairs are n=3 on one non-representative question. Same class as F24 (single-turn *clearing* backfire) on the other compaction half.
179
 
180
+ - **F28 (2026-06-19) — GraphRAG does not beat classical hybrid RAG on single-turn course Q&A: it ties on grounding accuracy and costs +44% $/turn, empirically confirming the earlier decision to drop it.** A scoped, fair head-to-head where the *only* variable is the retrieval backend behind `retrieve_tutor_context`: production hybrid `LocalChromaRetriever` (dense Cohere `embed-v4` + BM25 → RRF → Cohere rerank → token budget) vs a true Microsoft GraphRAG index (`app/graph_rag.py` `GraphRAGRetriever` — local-search-style context assembly over entities/text-units/community-reports, mapped back to real `source`/`url` via `corpus_manifest`, then the **same Cohere rerank + token budget** as classical; context-provider only, so the agent's **Gemini 3.5 Flash is the sole generation model in both arms**). 41 `full_stack_ai_engineering` single-turn cases (27 `answer_from_corpus`), Gemini 3.5 Flash, `--disable-kb` so the retriever is the only variable, `--scope-sources`, 1 trial, **0 errors/arm**. Both arms **surface and cite the right source 100%** of the time and tie on lesson recall (**76%**) and cited-correct lesson (**85%**); classical is slightly *better* at ranking the right lesson (**MRR 0.70 vs 0.65**). GraphRAG pulls community-report context, so it spends **+61% input tokens (178k vs 110k) and +44% $/turn ($0.212 vs $0.147)** and is a touch slower, with no accuracy payoff (the +3pt behavior proxy is a coarse n=37 code check, not a quality signal). So GraphRAG's community structure does not help typical single-hop course Q&A — this **empirically supports** the prior "dropped on faith" decision (see "Deliberately not pursued" below) rather than asserting it. **Reusable artifact:** the scoped index (90 docs → 15,059 entities / 32,748 relationships / 3,371 community reports, built with Gemini 2.5 Flash for **$44.96**) is published to the private HF dataset (`graphrag/output/`, pulled on demand, *not* by prod cold-start — `ensure_local_vector_db` ignores `graphrag/**`), so the eval re-runs without the ~$45 rebuild. Full methodology, build/run commands, and the indexing gotchas (route Gemini 2.5 via its OpenAI-compatible endpoint; `run_graphrag_index.sh` loops `graphrag index` until `entities.parquet` exists since one exhausted retry aborts the stage) live in **`evals/graphrag.md`**. Connects to F17/F23 (the `kb_off` grounding discussion — classical RAG already grounds well). Caveats: scoped to one source, single-turn, n=41 (27 corpus-answer), 1 trial, auto metrics only (key-point/behavior human grades pending); `recall@source` is saturated by `--scope-sources`, so lesson recall / MRR / cited-correct-lesson are the discriminating numbers. GraphRAG's theoretical edge is **multi-hop / cross-document synthesis**, which this single-hop battery barely exercises — so this says it doesn't help on single-hop course Q&A, *not* that it never helps; a fair test of that strength needs a multi-hop `_v2` probe set (out of scope here). (Branch `experiment/graphrag-vs-rag`, PR #2.)
181
 
182
+ - **F29 (2026-06-20) — Keeping a large *static document* in context is a different regime from conversational `full_history`, and it is one boundary where "keep everything" stops winning: for a single long lesson queried repeatedly, retrieving the relevant slice matches keep-it-all at ~1/13 the cost. (Boundary on F9; standalone Gemini-2.5-Flash fleet — absolute numbers not cross-comparable.)** A keep-vs-compact-vs-retrieve study on the corpus's largest lesson (~37.5k tok) loaded once in turn 0, then **15 question-turns with no tools** (the only context is what each method keeps or builds), each answer judged against the full lesson; 11 arms, Gemini 2.5 Flash, 1 trial. `rag` (fetch the relevant chunk per question) is **cheapest *and* tied-best** (60% / 3.2k tok / $0.020); `full_history` (keep the whole lesson in the prompt) is the **priciest in-context arm and not the best** (53% / 43k tok / $0.134); the summarization family cuts ~35% of tokens for a quality drop (40–53%); `incontext_history_retrieval` ties `rag` on quality but at ~13× the tokens; **`hierarchical_summarization` is the worst trade** — priciest ($0.605) and slowest (41.8s p50) for the lowest quality (33%). `graphrag` (53%, $0.045) again does not beat plain `rag` (consistent with **F28**). **Read it as a lesson, not a head-to-head:** this is *not* "RAG beats the `full_history` memory strategy" — here `full_history` only means "the whole lesson sits in the prompt," not its multi-turn-memory role. It says **F9's "keep everything wins" is about *conversation history*; for a big *static document* you could retrieve from, hoarding it in context is the priciest option and buys nothing**, and in the production tutor retrieval (Axis B) and a memory policy (Axis A) **compose**, they don't compete. Companion boundary: F30 (small context window). New reusable presets landed in `app/memory_presets.py`: `delta_summarization`, `hierarchical_summarization`. Full matrix, the F-C1–F-C6 detail, and reproduce steps in **`evals/compaction.md`**. Caveats: n=15, 1 session, 1 trial (coarse rankings, not precise rates); judge is Gemini 2.5 Flash (same family, reads the full lesson as ground truth); **separate fleet from the Part B/C 3.5-Flash runs — quality is not cross-comparable across models**. (Branch `experiment/context-compaction`, PR #5.)
183
 
184
+ - **F30 (2026-06-20) — A small context window is a second boundary where "keep everything" stops winning, and a harder one than F29: when the document physically does not fit, "shove it all" (`full_context`) is forced into truncation and is strictly *dominated*, so retrieving the relevant slice (`rag`) is not just cheaper but necessary. (Small-window companion to F29, Axis B; local-SLM fleet — absolute numbers not cross-comparable.)** "Fit one ~37.7k-token lesson into context to answer 15 questions," 7 methods × 3 Ollama SLMs (llama3.1:8b, qwen2.5:7b-instruct, qwen3:8b no-think) on an M1 Pro/16 GB at a 32k window (`--num-ctx 32768`), no prompt caching; judge stays on Gemini 2.5 Flash (reads the full lesson as ground truth — the SLM cannot hold it, and a model must never grade itself), 1 trial. `full_context` (the whole *document* placed in the prompt, `ctx['full_context'] = lesson`, stateless per question — this is **document-stuffing**, *not* the conversational `full_history` memory policy) is **truncated on 15/15 turns** (37.7k → 32,767) and **dominated**: equal-or-worse quality (67–80%) at **~12–15× the latency** (266–346s vs ~20s) — silent information loss *plus* a huge latency tax (e.g. qwen2.5 `full_context` answered "no model was mentioned" for a fact the truncated intro had named). `rag` (chunk + Cohere-embed + retrieve top-k per question) is **top or tied-top on all three** (qwen2.5 100%, qwen3 100%, llama 80%) at ~2.9k context tokens and ~20–26s. `graphrag` ties `rag` on quality but at ~2.8× tokens and ~2.5× latency — no payoff, **consistent with F28/F29**. Summarization is the floor (llama `summary` **0%**); `trim`/`selective` land mid (47–73%). This marks where F9/F15's precondition — a window big enough to hold everything, cheaply, under caching — simply fails on the hardware a workshop attendee runs. **Distinct from the conversational-memory inversion (F31):** `full_context` here is *document-stuffing* (Axis B), not `full_history`'s multi-turn role, exactly the distinction F29 drew. **Methodology fix:** the first pass scored ~64% of judge verdicts as fails because a long JSON `reason` hit `max_tokens` and truncated the verdict; hardened the judge (25-word reason cap + leading-boolean recovery) and re-graded the saved answers offline (`evals/rejudge_compaction.py`, **0 unparseable** after) — the directly-measured axes (tokens, latency, overflow) were never affected. Caveats: n=15/method, 1 trial, one lesson, one domain — read as a ranking, not rates; **separate fleet (local Ollama SLMs, no caching, Gemini-2.5-Flash judge) — quality/cost not cross-comparable** with the Part B/C 3.5-Flash, the F29 2.5-Flash, or the DeepSeek runs. Full writeup: **`evals/slm_compaction.md`** (Experiment 1). (Branch `experiment/slm-compaction`, PR #3.)
185
+ - **F31 (2026-06-20) — On an SLM, compacting a *growing conversation history* has no universal best method, and "keep everything" (`full_history`) never wins — and model capability dominates the choice of method. (SLM counterpart to F9/F15 on Axis A; same local fleet — absolute numbers not cross-comparable.)** Same lesson loaded turn 0 of a session, 15 question-turns, **retrieval off**, run through the real app middlewares (the production memory presets) on the 3 SLMs at `num_ctx=32768`, judged on Gemini against the full lesson, 1 trial. The best preset is **model-dependent** — `prompt_compression` on qwen2.5 (60%), `hierarchical_summarization` on llama3.1 (40%), `summarization_only`/`delta_summarization` on qwen3 (73%) — so there is **no single "best compaction" to recommend**. `full_history` (the genuine conversational memory policy — keep *every prior turn*, no summarization or context-editing; `summarization=False, context_editing=False`) **tops no model** (27% / 7% / 67%) and is near-bottom on the two weaker ones; `sliding_window` is reliably among the worst (13% / 13% / 40%). **Model capability dominates the method:** qwen3 (40–73% across *every* preset) >> qwen2.5 (13–60%) >> llama3.1 (0–40%) — choosing a stronger small model buys more than choosing the best strategy. **Mechanism (why keep-all can't win here):** on an SLM "keep everything" (`full_history`) can't even be truncated-and-kept — Ollama's server-side context fitting **evicts the whole oversized turn-0 lesson** from turn 1 on (the checkpoint still holds it; the model receives only ~0.3–1.4k tok of accumulated Q&A), so a method that *summarizes the lesson into a small injected message* is the only way to retain its gist; on Gemini's large window this never happens (F9's `full_history` held ~43k/turn). `hierarchical_summarization` is by far the priciest (≈30-call map-reduce, p50 233–333s, ~25–29k retained tok) for no quality lead except on the weakest model. This **inverts F9/F15's conversational-memory result** (keep-all cheapest *and* best) in the regime where the runtime physically cannot keep it. Caveats: n=15, 1 trial, LLM-judge variance moves individual cells ±1–3/15 (read only the big gaps); the `full_history` numbers are **Ollama-eviction-specific** (a different serving layer could keep a truncated lesson instead); **same separate-fleet comparability caveat as F30**. Writeup: **`evals/slm_compaction.md`** (Experiment 2). (Branch `experiment/slm-compaction`, PR #3.)
186
  - **F32 (2026-06-22) — Synthesis: "keep everything in context" wins only under a specific precondition — a large window that holds the whole history cheaply under prompt caching — and we have now mapped three boundaries where that precondition breaks and keep-all stops winning.** **Two distinct "keep-all" arms — never conflate them:** `full_history` (Axis A — F9/F15/F31) is the *memory policy* that keeps every prior **conversation turn** with no compaction (`summarization=False, context_editing=False` in `app/memory_presets.py`); `full_context` (Axis B — F30) is **document-stuffing** — the whole *source document* placed in the prompt (`ctx['full_context'] = lesson`). Same slogan ("keep everything"), different mechanisms. **F29 is the trap to watch:** its keep-all arm ran the `full_history` *preset* but over a single static lesson parked in turn 0 (`turns = [lesson] + questions`), so what it actually measured is the `full_context` idea (a document held in the prompt), **not** `full_history`'s multi-turn-memory role — which is why F29 sits on the *document* boundary (1) below, alongside F30, and **not** the conversational boundary (3). F9/F15 established the default (raw `full_history` is cheapest *and* best memory on conversational sessions), and F2/F25/F26 explain *why* it holds and even strengthens with horizon (the big **cached** prefix is cheaper than any compaction's cache-breaking rewrite; DeepSeek's ~50× cache pushes the crossover past 36 turns). Remove a piece of that precondition and keep-all loses, along three boundaries: **(1) a large static document you could retrieve from** (F29, Axis B, large cached window) — retrieving the slice *matches* keep-all at ~1/13 the cost, so hoarding the doc is the priciest option and buys nothing; **(2) a context window too small to hold the document** (F30, Axis B, SLM) — keep-all (`full_context`) is forced into truncation and is strictly dominated, so retrieval is *necessary*, not merely cheaper; **(3) a growing history on a small window** (F31, Axis A, SLM) — the runtime *evicts* the oversized turn, so keep-all (`full_history`) never tops the table and some compaction is mandatory, though no single method wins. Two cross-cutting lessons fall out: **model capability dominates the compaction method** on weak local models (F30/F31 — a better small model beats a better strategy), and the **axes compose, they don't compete** (F29) — retrieval (Axis B, *how to fit a document*) and a memory policy (Axis A, *how to compact history*) are orthogonal, so "RAG vs `full_history`" is a category error. **Net guidance:** keep-everything stays the right default in the production regime (large cached cloud window + conversational history; F9/F15/F25/F26); reach for retrieval or compaction exactly when one precondition breaks — a big static doc, a small window, or no caching. **Comparability:** this ties the *lessons* across fleets (3.5-Flash screen, 2.5-Flash F29, DeepSeek F25/F26, local SLMs F30/F31), **not** their absolute numbers, which are not cross-comparable.
187
 
188
+ - **F33 (2026-06-23) — New quality axis (holistic "would staff send this?"): on single-turn, compaction degrades *answer quality itself*, and the degradation dose-responds to how often summarization fires *within a single turn*. (Independent quality-axis confirmation of F4/F19/F27.)** Added two judge rubrics in `evals/judge.py`: **holistic** (whole-answer staff-approval / does-it-help-the-student-learn gate, emitted for every answered turn) and **faithfulness** (groundedness vs retrieved evidence — *parked*, see the correction below). Graded blind by the validated subagent path (F14) across all single-turn runs; holistic rows added to `evals/report.py`, tagged `[judge]`. The single-turn premise is that memory presets should barely differ ("Memory presets should score about the same here; big differences suggest a bug", §The dataset) — yet holistic spans **57%→100%**, and it is *not* a bug: it tracks within-turn summarization. Dose-response: arms that never summarize mid-turn score 92–100% (`full_history` 92% Part C / 92% Part B n=120; `kb_off` **100%** at just 3.6 LLM calls; `sliding_window`/`incontext_history_retrieval`/`selective_retention`/`retrieval_budget_30k` 96%), while arms whose *own* retrieval/KB output trips the trigger mid-turn sink in trigger order — `observation_truncation` **71%** (22.8 LLM calls/turn, 12/24 turns summarized), `context_reset` **62%** (18/24), `aggressive` **57%** (Part B n=120, **110/120** turns summarized mid-turn). So the F4/F19/F27 within-turn re-work pathology is not merely slower/pricier — it makes the *answers worse*, at a product-relevant magnitude (`aggressive`: ~half its single-turn answers would not be sent). Caveats: Part C n=24 (directional); the firm cell is Part B `aggressive` 57% vs `full_history` 92% at n=120; judge reproducibility-validated on probes (F14), **not** independently on holistic.
189
+
190
+ - **F34 (2026-06-23) — Across a session, standard compaction's damage is *surgical*: it collapses memory while answer quality stays ~100% — but that "quality" is partly an artifact of a history-blind judge, so "looks fine" ≠ "served the student". Only the most aggressive rewrite (`context_reset`) degrades the answers themselves, progressively over the session. (Refines F10/F12 on the holistic axis.)** Holistic graded on every answered turn of `b_se`+`c_se` (683 turns, 15 arms, blind subagent path). **The decoupling:** `prod` probe **58%**(c)/**38%**(b) yet holistic **97/99%**; `kb_off` probe 33% / holistic 100%; `selective_retention` probe 25% / holistic 100%; `profile_memory` probe 38% / holistic 100% — memory and answer-quality are *independent failure axes*. The lone exception is `context_reset` (probe 17%, holistic **69%**, holistic dropping **−24% early→late turn**; `sliding_window` a milder −16% late): broad, progressive quality degradation appears only under the most destructive history rewriting, otherwise quality holds flat (`Δ late−early` ≈ 0 for all standard arms). **But "high holistic" is misleading on exactly the turns that matter** — holistic is graded *blind to the session history* (sessions carry no per-turn gold answer, and the judge sees only question+answer), so on the **memory-failed probe turns** it still rated the (generic, context-ignoring) answer "good" **100% of the time** for every standard arm (`c_se_prod` 5/5, `b_se_prod` 15/15, `kb_off` 8/8, `selective_retention` 9/9; `forgot+looks-bad` = **0**) — only `context_reset` forgot so badly the blind judge caught it (6/10 forgotten-fact turns also looked bad). The failure mode is therefore *confidently generic*: a well-formed answer that silently ignores what the student stated earlier, which only the targeted probe detects. **Lesson:** holistic (context-free quality) and probe (context-aware memory) measure different things and must be read together — "answer quality looks fine" does **not** mean the student was served. This refines F10 (memory dies but general answer quality survives) and F12 (only the *aggressive* rewrites *also* degrade quality). Caveats: `c_se` is 3 sessions × 1 trial (~18 turns/early-late bucket), `b_se` ×2 trials; session holistic graded without a staff reference (the within-arm early/late comparison controls for that); judge reproducibility-validated on probes (F14), not on holistic — the blind-spot above is itself why a held-out human validation of holistic is the next gate.
191
+
192
  **Screen winners → promotion candidates:** `incontext_history_retrieval` (needs the long-session test), `retrieval_budget_30k` and `clear_retrieval_kb` (cheap Axis-B wins; clear_retrieval_kb is cheaper than prod with similar memory + better recall/key-points), anchored by `full_history`. **Drop:** `context_reset`, `observation_truncation`, `selective_retention`.
193
 
194
  **Part C — what remains (beyond the workshop).** The screen answered the core question; everything below *hardens* or *extends* it and is **gated on a spend or data decision from Omar** — none is required for the workshop.
 
196
  1. **Promotion (rigor, not new findings).** Re-run the 2-3 winners (+ 1-2 combos) on the full batteries (60 single-turn + 32 sessions + 30 replay) × 3 trials, with paired statistical tests and per-variant failure-taxonomy diffs, to turn the screen's coarse n=12 rankings into confident numbers; optionally an Anthropic (Haiku) re-run of the prefix-rewriting arms (`prompt_compression`, `context_reset`, `aggressive`) to test whether explicit caching flips F2's cost ranking. **~$1,300-1,800 at corrected pricing** (the old "$300-400" was at pre-correction prices; × the 4.4 fix — estimate precisely before running, since the per-turn cost is now ~$0.25).
197
  2. **Finish the v2 contradiction tier — the highest-value new dataset (already built and partially run; see the V2 execution note below).** Moderate sessions: plant A, update to A′, then probe after prod compaction has evicted A from the kept-recent window. This is the one correctness regime where `full_history` itself might lose (it sees both A and A′ and must choose the current one) — yet the partial run so far shows the opposite (`full_history` contradiction **2/2** vs `prod` **0/3**), so that regime has not actually appeared. What remains is finishing Tier 1 (the missing `full_history` session, then Tier-1 `profile_memory` and optionally `incontext_history_retrieval`) and adding trials, not building the dataset. **(2026-06-19 — now run in full on DeepSeek-V4-Flash rather than Gemini: F26 confirms `full_history` 3/3 contradiction vs `prod` 0/3, so the regime did not appear on a second model either; the Gemini Tier-1 finish is now optional cross-model confirmation.)**
198
  3. **The `incontext_history_retrieval` long-session cost test — principled but less product-critical.** The screen showed `incontext` works (83%, F16) but couldn't show a cost benefit on ≤13-turn sessions. Whether retrieve-old-turns beats `full_history` *once sessions get genuinely long* needs a token-calibrated long-session v2 tier; useful for the workshop argument, but gated by whether that regime matters in real tutor telemetry. **(2026-06-19 — run on DeepSeek (F25) at 36 turns: `full_history` stays cheapest and `incontext` works but does not beat it on cost under first-party caching; whether the answer flips on a 10× cache provider like Gemini/Anthropic is still open.)**
199
+ 4. **Conditional builds.** Build `temporal_graph_memory` only if `full_history` fails contradictions (F26: it did **not** on DeepSeek — 3/3 — so this stays unbuilt), `delta_summarization` only if the long-horizon cost tier shows room to beat full history (F25: `full_history` still cheapest at 36t on DeepSeek, so no room there — though a 10× cache provider is untested), and `entity_memory` only if multi-project probes show fact bleed. `collection_memory` is a separate cheap v1/product follow-up for verbatim fact storage (F22 tested the current profile write-back, not that). `hierarchical_summarization` and `sleeptime_consolidation` are deferred. The step-by-step v2 plan is in `evals/part_c_plan.md` → "Battery v2".
200
 
201
  **Deliberately not pursued.** *Stretch (heavier, likely later):* sub-agent isolation, reframed as a tool-output-token play — keep noisy `run_kb_command` output out of the main context (ties to F1); temporal-graph memory is already the conditional build in item 4 above. *Dropped as low-information given the findings:* the skills / lazy-prompt-loading family (only a ~458-token win — see the talk-outline correction below), procedural memory, GraphRAG (now **empirically tested** rather than dropped on faith — **F28**: ties classical RAG on grounding, costs +44% $/turn, no payoff on single-hop Q&A), and multi-agent / parallel-research agents. Context for the `kb_off` arm (F17/F23): the KB is the dominant grounding tool — ≈89% of Part B turns, ~7.7 KB vs ~0.9 retrieval calls/turn over 1,088 turns — so disabling it forces the 100k-token retrieval fallback, which is why it raised cost/latency.
202
 
 
204
 
205
  Harness corrections (bugs in our measurement, not findings): the overnight 06-12 stall was machine sleep hanging API streams (all four pipelines stopped the same minute; fixed with the per-turn timeout); battery lesson-URLs carried a `/discussions/` suffix that silently zeroed recall@lesson until normalized (caught by the A3 smoke, re-graded from bundles without re-running).
206
 
207
+ - **Pricing correction (2026-06-13).** `MODEL_PRICING` under-priced `gemini-3.5-flash` before this date (~$0.30/$2.50 per MTok vs the correct **$1.50 input / $9.00 output / $0.15 cache-read**, verified against Google's price sheet). Every dollar figure generated earlier — the Part B table and the inline costs in findings **F2/F8/F9/F12** — is **~4.4× too low**; token counts are unaffected. Part B bundles were re-costed from their saved token counts (2026-06-15) and `runs/b_report/report.md` regenerated at correct pricing: **relative rankings are unchanged** (F9 still has `full_history` cheapest, $0.11/turn sessions), only absolute dollars move. Real Part A+B spend ≈ **$338**, not ~$73; the comprehensive program ≈ **$590** across all Gemini 3.5 Flash runs (Part B ~$323 · Part C ~$163 · rest ~$103). Use the regenerated report for absolute costs; finding dollars above are pre-correction.
208
  - **Stable `sheet_row_id` (2026-06-15).** `evals.grade` built `sheet_row_id` with builtin `hash()`, which is salted per process, so regenerating a `handgrade_sheet.csv` produced ids that no longer matched the frozen workbook keymap — silently emptying `handgrade_workbook merge`. Switched to a `hashlib.md5` hash; the Part B re-merge was rebuilt via the deterministic `run_id` to recover the human grades.
209
  - **`cleared_tool_outputs` is blind to library clearing (2026-06-19).** `ContextEditingMiddleware` edits a per-call `deepcopy` and never persists the placeholder to the checkpoint that `context_window_stats` reads, so the signal is 0 for all presets whether or not `ClearToolUsesEdit` fired (full detail + token evidence in **F24**). Detect clearing via input-token deltas; the custom Part C view-mechanisms avoid this by reporting through the `app.telemetry` turn-signal registry.
210
+
211
+ - **Faithfulness parked; bundle tool-output cap raised 6k→40k (2026-06-23).** The new **faithfulness** rubric (groundedness vs retrieved evidence, F33) is *not reportable on existing runs*: `evals/run_battery.py` capped each saved tool output at `TOOL_OUTPUT_MAX_CHARS = 6_000` while the KB shell returns up to **40k** (`app/kb_shell.py` `DEFAULT_MAX_OUTPUT_CHARS`), so on KB-heavy arms the judge saw a fraction of what the agent actually grounded on and scored *capture-completeness, not grounding* — faithfulness tracked the KB:retrieval ratio almost monotonically (`b_st_prod` kb:ret 10.3 → **4%**; `c_st_prod` 7.7 → 25%; `kb_off`/`graphrag` kb:ret 0 → 50–76%), the same blind-signal class as **F23/F24**. Raised the cap to **40k** (captures what the agent saw; `output_chars` still records the true length so any overflow stays visible) so *future* re-records make faithfulness measurable; existing bundles cannot be backfilled (content past 6k is gone — needs a re-run). Until then `evals/grade.py` emits faithfulness **only when evidence was captured in full** (`evidence_is_complete`, self-re-enabling after the cap raise), and `evals/report.py` + `evals/grading_merge.py` drop the column (`DROP_ITEM_TYPES`). Holistic (F33/F34) is unaffected — it grades the answer, not the evidence.
212
  - **Report mislabeled judge grades as human (2026-06-19, fixed).** `evals.report` hardcoded "(human grade)" on the behavior / key-point / probe-accuracy rows, but Part C runs were filled by the LLM judge (`judge_filled.csv`), not a person — so `runs/c_report/report.md` asserted a human label over judge numbers. Fix: `evals.grade` now records a `grade_source` per merged grade (the judge prefixes its note `[judge…]`; humans do not), and `evals.report` tags each column `[judge]`/`[human]`/`[mixed]` and renames the rows to "(graded)". Numbers are unchanged (and judge≈human per F14); only the provenance label is now honest. Same label-vs-measurement class as F23/F24. All Part C arms were re-graded and `c_report` regenerated.
213
  - **`input tok/turn` is billed-across-calls, not context size (2026-06-19, clarified).** Telemetry sums `usage_metadata` over every internal model call in a turn, so a high-loop arm (`observation_truncation`, 22.8 calls) bills lots of input from small contexts while a big-payload arm (`kb_off`) bills similar totals from few large ones. Correct for *cost*, but it is not "how big is the context window." The report now shows both: **input tok/turn (billed, all calls)** and a new **context tokens/turn (window size)** row (from `context_tokens_approx`, always computed but previously unsurfaced). On single-turn the two even rank arms differently (e.g. `kb_off` has the *largest* window at 73k from its 100k retrieval payloads, but mid-pack billed input). Read F1/F6's "~200k input" as billed volume, not window size.
214
  - **Latency rows are confounded by time-of-run (caveat, not yet fixed).** `run_part_c_screen.sh` runs arms sequentially, and TTFT/total-ms depend on API load at that moment, so cross-arm latency deltas (e.g. F19's 430s p95, F23's 19s-vs-38s) partly reflect *when* each arm ran. Token/cost/recall are run-time-independent and unaffected. Any latency claim that drives a decision should come from arms run interleaved (or repeated trials), not a single sequential screen. **`total − ttft` does *not* remove this** — it is only the answer-streaming tail (~6s, preset-insensitive); the agentic latency incl. the summarization tax is all in `ttft`, so subtracting it deletes the signal. Report the unconfounded work metrics (LLM calls/turn, tokens/turn) as the headline and treat seconds as supporting. See **F27**.
215
  - **Latency de-confounding tooling (2026-06-22).** Acting on F27: (1) `runs/run_part_c_screen_interleaved.sh` runs the screen question-outer / arm-inner (writes `runs/ci_*`, not `runs/c_*`) so every question's arms share one API-load window — same total runs as the sequential screen, so interleaving is free. (2) `context_stats` now also emits `time_to_first_token_ms` (first reasoning/tool/text token, always ≤ `ttft_ms`), separating raw model responsiveness from the tool-call loop that `ttft_ms` includes; it flows through `evals/grade.py` to the report and shows `—` on runs recorded before it existed. (3) `evals/report.py` now leads with the run-time-independent work metrics (LLM/tool calls/turn, tokens/turn) and tags every latency row `[confounded]`, with a runtime note. All `runs/*report*` were regenerated (layout/labels only; numbers unchanged). The scripts live in gitignored `runs/` like the rest of the run tooling.
216
+ - **Pattern worth naming — most of our measurement bugs are telemetry/label blind-spots, not wrong math.** Six now share one shape: a number or label asserted a property the instrumentation could not observe for that config — **F21** (mechanism dormant: `profile_memory` never engaged on sessions), **F23** (recall blind to KB browsing), **F24** (clearing invisible to the checkpoint signal), the judge-as-human label above, **faithfulness graded on truncated evidence** (the 6k cap above — the judge scored what it could *see*, not what the agent *saw*), and **F34's holistic-on-session blind spot** (the quality judge sees no conversation history, so it credits answers that silently ignored the student's earlier-stated facts — "looks good" on 100% of memory-failed turns). `check_triggers` enforces "did the mechanism fire?" for *compaction* only; nothing yet does for retrieval/profile/KB engagement, grade provenance, or whether the *grader* saw what it needed. Standing guard for any new arm or metric: before trusting a per-arm number, confirm the signal/label/grader reflects what the config actually did.
217
 
218
  Talk-outline correction: the planned "skills / just-in-time KB instructions" change (Change 4) was never built, and its premise is off — the KB instructions block measures **458 tokens** (not ~4k) inside a 1,655-token system prompt; max saving ~2% of a turn, mostly cache-discounted. Recommended reframe: demo progressive disclosure on tool *outputs* (F1), where our numbers actually are.
219
 
 
230
  - [ ] Next.js meter component over the `data-context-stats` SSE part, if the live demo needs it.
231
  - [ ] Skills section: reframe per the 458-token measurement above.
232
 
233
+ **Beyond the workshop.** Part C itself is done and reported above (findings F14–F24); what remains only *hardens* or *extends* it and is consolidated in "Part C — what remains" above — promotion, finishing the v2 contradiction tier, the `incontext_history_retrieval` long-session test, the conditional subsystem builds, and the deliberately-not-pursued list. The full execution detail (variant wiring, telemetry signals, the subset run matrix, the cost model, and the v2 battery plan) lives in `evals/part_c_plan.md`.
234
 
235
  **Product quality track (post-workshop)**
236
+ Golden cases as a CI gate (5–10 critical-path cases with deterministic assertions, including the F7 non-empty check) → error-analysis cycles (an expert reads 50–100 traces in a small viewer, records pass/fail plus the first failure reason, and stops when new failure types stop appearing) → failure taxonomy → code assertions for recurring modes → LLM judges built from the hand-grade critiques and validated to >90% true-positive and true-negative rates on held-out human labels before any judge-graded number is reported → nightly battery + weekly trace sampling. Full methodology behind each step: `evals/background.md`.
237
 
238
  ## Files
239
 
240
  - `evals.md` — this file: the what, the data, the results, the queue.
241
+ - `evals/contributing.md` — contributor playbook: how to run an experiment and land it (run → upload data to HF → record an `F<N>` finding → merge), with a definition-of-done gate.
242
+ - `evals/part_c_plan.md` — Part C execution plan: orchestration (workflow vs direct), the variant catalog with exact wiring + telemetry signals, the subset run matrix, and cost.
243
+ - `evals/background.md` — research sources (Hamel, howtoeval, OpenAI macro-evals) and design rationale.
244
+ - `evals/graphrag.md` — GraphRAG vs classical RAG experiment (branch `experiment/graphrag-vs-rag`): a scoped, true-GraphRAG head-to-head on the single-turn battery. Revisits the dropped GraphRAG idea with a fair test.
245
+ - `evals/compaction.md` — compaction-vs-keep-everything study (branch `experiment/context-compaction`): on the largest lesson, keep-all vs every compaction method vs retrieve-per-question. **Standalone fleet on Gemini 2.5 Flash — not comparable to the 3.5-Flash results above.**
246
+ - `evals/slm_compaction.md` — knowledge-compaction on small local models (branch `experiment/slm-compaction`): two experiments on 3 Ollama SLMs (llama3.1:8b, qwen2.5:7b, qwen3:8b) at a 32k window where the lesson overflows. **Axis B** (fit a document: RAG vs stuff vs summarize, F30) and **Axis A** (compact growing history via the real memory presets, F31). The regime where compaction is *forced*; RAG wins Axis B, no universal winner on Axis A, and "keep everything" never wins on an SLM (synthesis in F32).
247
  - `data/eval/README.md` — battery schemas + glossary of terms; `review_log_v1.md` — dataset audit trail.
248
  - `evals/` — the harness code; `app/memory_presets.py`, `app/telemetry.py` — the app-side hooks.
249
  - `runs/b_report/`, `runs/c_report/` — generated tables, token curves, blinded grading workbook (Part B and the Part C screen). Re-grade / follow-up reports: `runs/kbfair_report/` (F23 KB-fair recall), `runs/d_report_profile_memory_active/` (F22 profile rerun), `runs/e2_v2_partial_report/` (v2 partial).
250
  - `runs/ds_v2_t1_report/`, `runs/ds_v2_t2_report/` — **DeepSeek-V4-Flash** v2 matrix (F25/F26: cost + contradiction + long-horizon), graded via the subagent path (`runs/ds_judge/`). Shared on HF as `eval_runs/part_f_deepseek_v2/` (the standard `mv eval_runs/part_*/* runs/` download restores them). Provider added in `app/chat_service.py` (`deepseek` / `openrouter`), pricing in `app/telemetry.py`.
251
+ - `runs/axisa_*_report/`, `data/compaction_slm_*/` — **SLM compaction** study (F30/F31/F32, branch `experiment/slm-compaction`): Axis B fit-a-document and Axis A compact-history on 3 Ollama SLMs at a 32k window. Shared on HF under `eval_runs/slm_compaction/` (`axis_b/<model>/`, `axis_a/axisa_<model>_<preset>/`) + batteries `eval/compaction/`; the snippet above restores Axis-A to `runs/axisa_*`, Axis-B stays under `eval_runs/slm_compaction/axis_b/` — full restore command in `evals/slm_compaction.md`. Provider `ollama` added in `app/chat_service.py`.
evals_background.md → evals/background.md RENAMED
File without changes
evals_compaction.md → evals/compaction.md RENAMED
File without changes
evals_contributing.md → evals/contributing.md RENAMED
File without changes
evals/grade.py CHANGED
@@ -50,6 +50,80 @@ RETRIEVAL_TOOLS = {"retrieve_tutor_context", "run_kb_command"}
50
  # in the command or its output reveal which corpus source the agent touched.
51
  KB_RAW_SOURCE_RE = re.compile(r"raw/(?:docs|courses)/([A-Za-z0-9_.\-]+)/")
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
 
54
  def index_battery(battery: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
55
  index: dict[str, dict[str, Any]] = {}
@@ -207,6 +281,7 @@ def sheet_row(
207
  item_type: str,
208
  criterion: str,
209
  reference: str = "",
 
210
  ) -> dict[str, str]:
211
  return {
212
  # Stable hash (NOT builtin hash(), which is salted per process — that
@@ -221,12 +296,49 @@ def sheet_row(
221
  "question": bundle["query"][:600],
222
  "answer": (bundle.get("answer") or "")[:4000],
223
  "criterion": criterion,
224
- "reference": reference[:2000],
225
  "grade": "",
226
  "note": "",
227
  }
228
 
229
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
  def grade_run(run_dir: Path) -> tuple[list[dict[str, Any]], list[dict[str, str]]]:
231
  bundles = load_jsonl(run_dir / "bundles.jsonl")
232
  if not bundles:
@@ -306,6 +418,11 @@ def grade_run(run_dir: Path) -> tuple[list[dict[str, Any]], list[dict[str, str]]
306
  reference=record.get("reference_answer") or "",
307
  )
308
  )
 
 
 
 
 
309
 
310
  elif bundle["battery_type"] == "sessions":
311
  probe = next(
@@ -332,6 +449,11 @@ def grade_run(run_dir: Path) -> tuple[list[dict[str, Any]], list[dict[str, str]]
332
  f"Rule: {probe['check_note']}",
333
  )
334
  )
 
 
 
 
 
335
 
336
  elif bundle["battery_type"] == "personas":
337
  result = grade_persona_question(record, bundle.get("answer") or "")
@@ -356,6 +478,11 @@ def grade_run(run_dir: Path) -> tuple[list[dict[str, Any]], list[dict[str, str]]
356
  criterion=" | ".join(llm_checks),
357
  )
358
  )
 
 
 
 
 
359
 
360
  elif bundle["battery_type"] == "replay":
361
  sheet.append(
@@ -391,22 +518,47 @@ def merge_handgrades(
391
  for r in rows
392
  }
393
  grade["grade_source"] = sources.pop() if len(sources) == 1 else "mixed"
 
 
 
 
 
 
 
 
 
 
 
394
  key_points = [r for r in rows if r["item_type"] == "key_point"]
395
  if key_points:
396
  passed = sum(1 for r in key_points if r["grade"].lower() == "pass")
397
  grade["key_points_passed"] = passed
398
  grade["key_points_total"] = len(key_points)
 
 
 
 
399
  for row in rows:
400
  verdict = row["grade"].strip().lower() == "pass"
401
  if row["item_type"] == "behavior":
402
  grade["behavior_pass"] = verdict
 
403
  elif row["item_type"].startswith("probe:"):
404
  grade["probe_pass"] = verdict
 
405
  elif row["item_type"] == "persona_llm_check":
406
  # Combines with the regex auto result (both must pass).
407
  grade["auto_pass"] = verdict and grade.get("auto_pass") is not False
 
408
  elif row["item_type"] == "replay_reply":
409
  grade["replay_pass"] = verdict
 
 
 
 
 
 
 
410
  return grades
411
 
412
 
 
50
  # in the command or its output reveal which corpus source the agent touched.
51
  KB_RAW_SOURCE_RE = re.compile(r"raw/(?:docs|courses)/([A-Za-z0-9_.\-]+)/")
52
 
53
+ # Per-bundle evidence cap for faithfulness rows. Tool outputs in a bundle are each
54
+ # capped at ~6000 chars; a few calls per turn, so ~12k keeps the gist of every
55
+ # call without bloating the grading prompt.
56
+ FAITHFULNESS_EVIDENCE_MAX = 12_000
57
+
58
+ # Holistic = the "would staff approve sending this?" gate (evals/background.md
59
+ # Layer 4). Emitted for every single-turn answer; the full rubric lives in
60
+ # evals/judge.py RUBRICS["holistic"], this is the at-a-glance criterion.
61
+ HOLISTIC_CRITERION = (
62
+ "Would an experienced course tutor approve sending this answer to the "
63
+ "student? Pass only if it is correct, grounded in the right material, and "
64
+ "genuinely helps them learn (builds understanding), with appropriate scope "
65
+ "and tone. (see README / judge rubric)"
66
+ )
67
+ # Faithfulness = is the answer grounded in the evidence the tutor actually
68
+ # retrieved (catches hallucination on corpus questions). Emitted only when the
69
+ # turn produced retrieval/KB evidence; rubric in judge.py RUBRICS["faithfulness"].
70
+ FAITHFULNESS_CRITERION = (
71
+ "Are the answer's substantive factual/technical claims supported by the "
72
+ "retrieved evidence shown below? Fail on fabricated APIs, parameters, "
73
+ "versions, citations, or course/lesson specifics not in the evidence."
74
+ )
75
+
76
+
77
+ def faithfulness_evidence(
78
+ bundle: dict[str, Any], max_chars: int = FAITHFULNESS_EVIDENCE_MAX
79
+ ) -> str:
80
+ """Concatenate the retrieval/KB evidence the tutor saw, for grounding grades.
81
+
82
+ Joins each retrieval/KB tool call's command + output (and, for
83
+ `retrieve_tutor_context`, the ranked source titles/URLs) so the judge can
84
+ check the answer's claims against exactly what the tools returned. Empty when
85
+ the turn used no retrieval/KB tool (e.g. answer_general), which is the signal
86
+ to skip the faithfulness row for that case.
87
+ """
88
+ parts: list[str] = []
89
+ for call in bundle.get("tool_calls") or []:
90
+ name = call.get("tool_name")
91
+ if name not in RETRIEVAL_TOOLS:
92
+ continue
93
+ header = f"[{name}] {(call.get('args_text') or '').strip()}".strip()
94
+ body = (call.get("output_text") or "").strip()
95
+ matches = call.get("matches") or []
96
+ if matches:
97
+ cites = "; ".join(
98
+ f"{m.get('title', '')} <{m.get('url', '')}>" for m in matches[:10]
99
+ )
100
+ body = f"{body}\nSOURCES: {cites}".strip()
101
+ if header or body:
102
+ parts.append(f"{header}\n{body}".strip())
103
+ return "\n\n".join(parts)[:max_chars]
104
+
105
+
106
+ def evidence_is_complete(bundle: dict[str, Any]) -> bool:
107
+ """True if every retrieval/KB tool output was captured in full.
108
+
109
+ Bundles cap each tool output at ``run_battery.TOOL_OUTPUT_MAX_CHARS`` but
110
+ preserve the true length in ``output_chars``. When a KB-browse output was
111
+ truncated, the judge sees only a slice of what the agent actually read, so a
112
+ faithfulness grade would measure capture-completeness, not grounding (the
113
+ same blind-signal class as evals.md F23/F24). Faithfulness is therefore
114
+ emitted ONLY when nothing the agent grounded on was truncated; after the
115
+ bundle cap is raised and a run is re-recorded, this returns True and the
116
+ column populates automatically.
117
+ """
118
+ for call in bundle.get("tool_calls") or []:
119
+ if call.get("tool_name") not in RETRIEVAL_TOOLS:
120
+ continue
121
+ full = call.get("output_chars")
122
+ kept = len(call.get("output_text") or "")
123
+ if isinstance(full, int) and full > kept:
124
+ return False
125
+ return True
126
+
127
 
128
  def index_battery(battery: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
129
  index: dict[str, dict[str, Any]] = {}
 
281
  item_type: str,
282
  criterion: str,
283
  reference: str = "",
284
+ reference_max: int = 2000,
285
  ) -> dict[str, str]:
286
  return {
287
  # Stable hash (NOT builtin hash(), which is salted per process — that
 
296
  "question": bundle["query"][:600],
297
  "answer": (bundle.get("answer") or "")[:4000],
298
  "criterion": criterion,
299
+ "reference": reference[:reference_max],
300
  "grade": "",
301
  "note": "",
302
  }
303
 
304
 
305
+ def quality_sheet_rows(
306
+ bundle: dict[str, Any], reference: str = ""
307
+ ) -> list[dict[str, str]]:
308
+ """Holistic + faithfulness sheet rows for one answered turn.
309
+
310
+ Shared by single-turn and session turns: holistic (would staff approve this
311
+ answer?) is emitted for every answered turn; faithfulness only when the turn
312
+ produced retrieval/KB evidence to ground against. ``reference`` is the staff
313
+ answer for single-turn cases (context for holistic) and empty for session
314
+ turns, which have no per-turn gold answer.
315
+ """
316
+ rows = [
317
+ sheet_row(
318
+ bundle=bundle,
319
+ item_type="holistic",
320
+ criterion=HOLISTIC_CRITERION,
321
+ reference=reference,
322
+ )
323
+ ]
324
+ # Faithfulness only when the agent's grounding was captured in full -- on
325
+ # bundles recorded under the old 6k tool-output cap, KB-browse evidence is
326
+ # truncated, so a faithfulness grade would score capture-completeness, not
327
+ # grounding (evals.md F23/F24 class). Re-record with the raised cap to enable.
328
+ evidence = faithfulness_evidence(bundle)
329
+ if evidence and evidence_is_complete(bundle):
330
+ rows.append(
331
+ sheet_row(
332
+ bundle=bundle,
333
+ item_type="faithfulness",
334
+ criterion=FAITHFULNESS_CRITERION,
335
+ reference=evidence,
336
+ reference_max=FAITHFULNESS_EVIDENCE_MAX,
337
+ )
338
+ )
339
+ return rows
340
+
341
+
342
  def grade_run(run_dir: Path) -> tuple[list[dict[str, Any]], list[dict[str, str]]]:
343
  bundles = load_jsonl(run_dir / "bundles.jsonl")
344
  if not bundles:
 
418
  reference=record.get("reference_answer") or "",
419
  )
420
  )
421
+ # Holistic (whole-answer staff-approval) + faithfulness (grounded in
422
+ # retrieved evidence); staff answer is context for holistic.
423
+ sheet.extend(
424
+ quality_sheet_rows(bundle, record.get("reference_answer") or "")
425
+ )
426
 
427
  elif bundle["battery_type"] == "sessions":
428
  probe = next(
 
449
  f"Rule: {probe['check_note']}",
450
  )
451
  )
452
+ # Holistic + faithfulness on every answered session turn (no per-turn
453
+ # gold answer, so no reference for holistic). Lets us see whether
454
+ # answer quality degrades on late turns under compaction.
455
+ if bundle.get("answer"):
456
+ sheet.extend(quality_sheet_rows(bundle))
457
 
458
  elif bundle["battery_type"] == "personas":
459
  result = grade_persona_question(record, bundle.get("answer") or "")
 
478
  criterion=" | ".join(llm_checks),
479
  )
480
  )
481
+ # Holistic + faithfulness on persona answers too (full quality
482
+ # coverage across batteries); the persona profile is the implicit
483
+ # reference, so no staff reference text here.
484
+ if bundle.get("answer"):
485
+ sheet.extend(quality_sheet_rows(bundle))
486
 
487
  elif bundle["battery_type"] == "replay":
488
  sheet.append(
 
518
  for r in rows
519
  }
520
  grade["grade_source"] = sources.pop() if len(sources) == 1 else "mixed"
521
+
522
+ def _src(row: dict[str, str]) -> str:
523
+ # Per-metric provenance so a run with human key-points but judge-filled
524
+ # holistic/faithfulness still reports each row's true grader (the
525
+ # overall grade_source above goes "mixed" in that case).
526
+ return (
527
+ "judge"
528
+ if (row.get("note") or "").lstrip().startswith("[judge")
529
+ else "human"
530
+ )
531
+
532
  key_points = [r for r in rows if r["item_type"] == "key_point"]
533
  if key_points:
534
  passed = sum(1 for r in key_points if r["grade"].lower() == "pass")
535
  grade["key_points_passed"] = passed
536
  grade["key_points_total"] = len(key_points)
537
+ kp_sources = {_src(r) for r in key_points}
538
+ grade["key_points_source"] = (
539
+ kp_sources.pop() if len(kp_sources) == 1 else "mixed"
540
+ )
541
  for row in rows:
542
  verdict = row["grade"].strip().lower() == "pass"
543
  if row["item_type"] == "behavior":
544
  grade["behavior_pass"] = verdict
545
+ grade["behavior_source"] = _src(row)
546
  elif row["item_type"].startswith("probe:"):
547
  grade["probe_pass"] = verdict
548
+ grade["probe_source"] = _src(row)
549
  elif row["item_type"] == "persona_llm_check":
550
  # Combines with the regex auto result (both must pass).
551
  grade["auto_pass"] = verdict and grade.get("auto_pass") is not False
552
+ grade["persona_source"] = _src(row)
553
  elif row["item_type"] == "replay_reply":
554
  grade["replay_pass"] = verdict
555
+ grade["replay_source"] = _src(row)
556
+ elif row["item_type"] == "holistic":
557
+ grade["holistic_pass"] = verdict
558
+ grade["holistic_source"] = _src(row)
559
+ elif row["item_type"] == "faithfulness":
560
+ grade["faithfulness_pass"] = verdict
561
+ grade["faithfulness_source"] = _src(row)
562
  return grades
563
 
564
 
evals/grade_workflow.js ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const meta = {
2
+ name: 'subagent-judge-grading',
3
+ description: 'Blinded subagent judge grades holistic/faithfulness/key-point/behavior chunks',
4
+ phases: [{ title: 'Grade', detail: 'one blinded grader agent per chunk' }],
5
+ }
6
+
7
+ // args: { battery, chunks: <int> } OR { battery, indices: [<int>...] }
8
+ // indices lets us re-run only the missing chunks after a partial run.
9
+ let A = args
10
+ if (typeof A === 'string') {
11
+ try { A = JSON.parse(A) } catch { A = {} }
12
+ }
13
+ const battery = (A && A.battery) || 'singleturn'
14
+ const explicit = A && Array.isArray(A.indices) ? A.indices : null
15
+ const nChunks = (A && A.chunks) || 0
16
+ if (!explicit && !nChunks) throw new Error('pass args.chunks (count) or args.indices (array)')
17
+
18
+ const dir = `runs/_grading/${battery}`
19
+
20
+ const SUMMARY_SCHEMA = {
21
+ type: 'object',
22
+ additionalProperties: false,
23
+ properties: {
24
+ chunk: { type: 'integer' },
25
+ graded: { type: 'integer', description: 'rows graded and written' },
26
+ low_confidence: { type: 'integer', description: 'count of confidence=low verdicts' },
27
+ wrote: { type: 'string', description: 'path of the verdicts json written' },
28
+ },
29
+ required: ['chunk', 'graded', 'low_confidence', 'wrote'],
30
+ }
31
+
32
+ function prompt(i) {
33
+ const pad = String(i).padStart(3, '0')
34
+ const chunkPath = `${dir}/chunk_${pad}.csv`
35
+ const outPath = `${dir}/verdicts_${pad}.json`
36
+ return `You are a BLINDED grader for an AI tutor on a course platform (applied AI, LLMs, RAG, Python).
37
+
38
+ INPUT: parse the CSV at ${chunkPath} with Python (use csv with field_size_limit(10_000_000); fields contain newlines). Columns: sheet_row_id, item_type, question, criterion, reference, answer.
39
+
40
+ RUBRICS: read the dict RUBRICS and the string PREAMBLE in evals/judge.py and follow them EXACTLY, selecting the rubric by each row's item_type (for "probe:*" use the "probe" rubric; for "faithfulness" the 'reference' field IS the retrieved evidence to grade grounding against; for unknown types use "key_point"). These are the same rubrics the project's validated judge uses.
41
+
42
+ RULES:
43
+ - Grade EVERY row in the chunk. You see only content; you do NOT know which system/preset produced each answer and must not infer or use it.
44
+ - An EMPTY answer = fail (confidence high).
45
+ - grade is "pass" or "fail"; always decide. confidence is "high" or "low" — use "low" ONLY for genuinely borderline calls a reasonable grader could see either way. reason = one short sentence.
46
+
47
+ OUTPUT: write a JSON file to ${outPath} that is a list of objects, one per row, each: {"sheet_row_id": <str>, "item_type": <str>, "grade": "pass"|"fail", "confidence": "high"|"low", "reason": <str>}. Write it with Python json.dump. Every input sheet_row_id must appear exactly once.
48
+
49
+ Then return the summary (chunk index ${i}, how many you graded, how many were low confidence, and the output path). Your returned text is parsed as data, not shown to a human.`
50
+ }
51
+
52
+ phase('Grade')
53
+ const indices = explicit || Array.from({ length: nChunks }, (_, i) => i)
54
+ const results = await parallel(
55
+ indices.map((i) => () =>
56
+ agent(prompt(i), { schema: SUMMARY_SCHEMA, label: `${battery}:chunk_${i}`, phase: 'Grade' })
57
+ )
58
+ )
59
+
60
+ const ok = results.filter(Boolean)
61
+ const graded = ok.reduce((a, r) => a + (r.graded || 0), 0)
62
+ const low = ok.reduce((a, r) => a + (r.low_confidence || 0), 0)
63
+ const failedChunks = indices.filter((_, k) => !results[k])
64
+ log(`${battery}: ${ok.length}/${indices.length} chunks done, ${graded} rows graded, ${low} low-confidence`)
65
+ if (failedChunks.length) log(`FAILED chunks (rerun): ${failedChunks.join(', ')}`)
66
+ return { battery, chunks_done: ok.length, chunks_total: indices.length, graded, low_confidence: low, failed_chunks: failedChunks }
evals/grading_merge.py ADDED
@@ -0,0 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Merge subagent-judge verdicts back into runs, and collect uncertain calls.
2
+
3
+ Companion to grading_prep.py / grade_workflow.js. For one battery:
4
+ 1. load every runs/_grading/<battery>/verdicts_*.json,
5
+ 2. per run, write combined_filled.csv = kept existing grades + new judge verdicts,
6
+ then run `evals.grade --handgrades` to regenerate grades_merged.jsonl,
7
+ 3. flag any expected rows that never got a verdict (chunk agent failed), and
8
+ 4. write uncertain.md + uncertain.csv = every confidence=low verdict with its
9
+ question/answer/criterion (the human-review queue the user asked for).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import collections
15
+ import csv
16
+ import hashlib
17
+ import json
18
+ import subprocess
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ csv.field_size_limit(10_000_000)
23
+
24
+
25
+ def _ah(answer: str) -> str:
26
+ """Short hash of the graded answer text, to disambiguate sheet_row_id.
27
+
28
+ sheet_row_id = run_id|item_type|md5(criterion) is NOT globally unique: run_id
29
+ is per-case, so the same question graded under two presets (different run
30
+ dirs) shares an id while having different answers. Keying verdicts by
31
+ (sheet_row_id, answer-hash) restores uniqueness; identical answers collapse
32
+ correctly (same grade).
33
+ """
34
+ return hashlib.md5((answer or "").encode("utf-8")).hexdigest()[:12]
35
+
36
+
37
+ COMBINED_COLS = [
38
+ "sheet_row_id",
39
+ "run_id",
40
+ "battery_type",
41
+ "preset",
42
+ "item_type",
43
+ "criterion",
44
+ "grade",
45
+ "note",
46
+ ]
47
+
48
+ # Item types dropped from the merge + review queue. Faithfulness is parked: on
49
+ # bundles recorded under the old 6k tool-output cap the judge can't see the
50
+ # truncated KB evidence, so it scores capture-completeness not grounding
51
+ # (evals.md F23/F24 class). Re-enable after re-recording runs with the raised cap.
52
+ DROP_ITEM_TYPES = {"faithfulness"}
53
+
54
+
55
+ def load_verdicts(
56
+ gdir: Path,
57
+ ) -> tuple[dict[tuple[str, str], dict], dict[tuple[str, str], dict]]:
58
+ """Return (verdicts, content) keyed by (sheet_row_id, answer-hash).
59
+
60
+ Recovers the verdict<->row association by POSITION within each chunk (chunk
61
+ row i corresponds to verdict i; order verified identical for all chunks), so
62
+ the per-run answer the verdict actually graded is known and used as the key.
63
+ `content` carries the chunk row (question/criterion/answer) for the report.
64
+ """
65
+ verdicts: dict[tuple[str, str], dict] = {}
66
+ content: dict[tuple[str, str], dict] = {}
67
+ for cf in sorted(gdir.glob("chunk_*.csv")):
68
+ crows = list(csv.DictReader(cf.open()))
69
+ vf = gdir / cf.name.replace("chunk_", "verdicts_").replace(".csv", ".json")
70
+ if not vf.exists():
71
+ continue
72
+ vrows = json.loads(vf.read_text())
73
+ # Match by sheet_row_id with per-id FIFO queues: robust to a short chunk
74
+ # (agent skipped rows), reordering, or within-chunk duplicate ids (a chunk
75
+ # spanning two runs that share a session/turn). A chunk row with no
76
+ # remaining verdict is left unmatched -> surfaces as "missing" in main().
77
+ queues: dict[str, collections.deque] = collections.defaultdict(
78
+ collections.deque
79
+ )
80
+ for v in vrows:
81
+ queues[v["sheet_row_id"]].append(v)
82
+ for r in crows:
83
+ q = queues.get(r["sheet_row_id"])
84
+ if not q:
85
+ continue
86
+ v = q.popleft()
87
+ key = (r["sheet_row_id"], _ah(r.get("answer") or ""))
88
+ verdicts[key] = v
89
+ content[key] = r
90
+ return verdicts, content
91
+
92
+
93
+ def expected_keys(gdir: Path) -> set[tuple[str, str]]:
94
+ keys: set[tuple[str, str]] = set()
95
+ for ch in sorted(gdir.glob("chunk_*.csv")):
96
+ for r in csv.DictReader(ch.open()):
97
+ keys.add((r["sheet_row_id"], _ah(r.get("answer") or "")))
98
+ return keys
99
+
100
+
101
+ def note_for(v: dict) -> str:
102
+ return f"[judge:{v.get('confidence', 'high')}] {v.get('reason', '')}".strip()
103
+
104
+
105
+ def main() -> None:
106
+ battery = sys.argv[1] if len(sys.argv) > 1 else "singleturn"
107
+ gdir = Path("runs/_grading") / battery
108
+ manifest = json.loads((gdir / "manifest.json").read_text())
109
+ verdicts, content = load_verdicts(gdir)
110
+ expected = expected_keys(gdir)
111
+ missing = sorted(expected - set(verdicts))
112
+ print(f"{battery}: {len(verdicts)} verdicts, {len(missing)} expected rows missing")
113
+
114
+ merged_runs = 0
115
+ key_to_run: dict[tuple[str, str], str] = {}
116
+ for name in manifest["runs"]:
117
+ run_dir = Path("runs") / name
118
+ sheet = list(csv.DictReader((run_dir / "handgrade_sheet.csv").open()))
119
+ combined: list[dict] = []
120
+ # kept existing grades (columns may differ across old files; normalize)
121
+ keep_path = gdir / "keep" / f"{name}.csv"
122
+ if keep_path.exists():
123
+ for r in csv.DictReader(keep_path.open()):
124
+ combined.append({c: r.get(c, "") for c in COMBINED_COLS})
125
+ # new judge verdicts for this run's sheet rows (content key disambiguates
126
+ # the cross-run sheet_row_id collision)
127
+ for r in sheet:
128
+ if r["item_type"] in DROP_ITEM_TYPES:
129
+ continue
130
+ key = (r["sheet_row_id"], _ah(r.get("answer") or ""))
131
+ key_to_run[key] = name
132
+ v = verdicts.get(key)
133
+ if not v:
134
+ continue
135
+ combined.append(
136
+ {
137
+ "sheet_row_id": r["sheet_row_id"],
138
+ "run_id": r.get("run_id", ""),
139
+ "battery_type": r.get("battery_type", battery),
140
+ "preset": r.get("preset", ""),
141
+ "item_type": r["item_type"],
142
+ "criterion": r.get("criterion", ""),
143
+ "grade": v["grade"],
144
+ "note": note_for(v),
145
+ }
146
+ )
147
+ cf = run_dir / "combined_filled.csv"
148
+ with cf.open("w", newline="") as f:
149
+ w = csv.DictWriter(f, fieldnames=COMBINED_COLS, extrasaction="ignore")
150
+ w.writeheader()
151
+ w.writerows(combined)
152
+ res = subprocess.run(
153
+ [
154
+ "uv",
155
+ "run",
156
+ "-m",
157
+ "evals.grade",
158
+ "--run",
159
+ str(run_dir),
160
+ "--handgrades",
161
+ str(cf),
162
+ ],
163
+ capture_output=True,
164
+ text=True,
165
+ )
166
+ if res.returncode != 0:
167
+ print(f" MERGE FAILED {name}: {res.stderr.strip()[-300:]}")
168
+ else:
169
+ merged_runs += 1
170
+ print(f"merged {merged_runs}/{len(manifest['runs'])} runs")
171
+
172
+ # ---- uncertain queue (the deliverable) --------------------------------
173
+ low = [
174
+ (k, v)
175
+ for k, v in verdicts.items()
176
+ if v.get("confidence") == "low"
177
+ and v.get("item_type", "").split(":")[0] not in DROP_ITEM_TYPES
178
+ ]
179
+
180
+ def arm_of(key: tuple[str, str]) -> str:
181
+ return key_to_run.get(key, "?")
182
+
183
+ low.sort(key=lambda kv: (kv[1]["item_type"], arm_of(kv[0])))
184
+ with (gdir / "uncertain.csv").open("w", newline="") as f:
185
+ w = csv.DictWriter(
186
+ f,
187
+ fieldnames=[
188
+ "sheet_row_id",
189
+ "run",
190
+ "item_type",
191
+ "judge_grade",
192
+ "judge_reason",
193
+ "question",
194
+ "criterion",
195
+ "answer",
196
+ "your_grade",
197
+ ],
198
+ )
199
+ w.writeheader()
200
+ for key, v in low:
201
+ c = content.get(key, {})
202
+ w.writerow(
203
+ {
204
+ "sheet_row_id": key[0],
205
+ "run": arm_of(key),
206
+ "item_type": v["item_type"],
207
+ "judge_grade": v["grade"],
208
+ "judge_reason": v.get("reason", ""),
209
+ "question": (c.get("question") or "")[:300],
210
+ "criterion": (c.get("criterion") or "")[:300],
211
+ "answer": (c.get("answer") or "")[:1200],
212
+ "your_grade": "",
213
+ }
214
+ )
215
+
216
+ lines = [f"# Uncertain judge calls — {battery}", ""]
217
+ lines.append(
218
+ f"{len(low)} low-confidence verdicts of {len(verdicts)} graded. "
219
+ "Fill `your_grade` in uncertain.csv to override.\n"
220
+ )
221
+ by_type = collections.Counter(v["item_type"].split(":")[0] for _, v in low)
222
+ by_run = collections.Counter(arm_of(k) for k, _ in low)
223
+ lines.append(
224
+ "By item type: " + ", ".join(f"{k}={n}" for k, n in sorted(by_type.items()))
225
+ )
226
+ lines.append(
227
+ "By run: " + ", ".join(f"{k}={n}" for k, n in sorted(by_run.items())) + "\n"
228
+ )
229
+ for key, v in low:
230
+ c = content.get(key, {})
231
+ lines.append(
232
+ f"## [{v['item_type']}] judge={v['grade'].upper()} — run `{arm_of(key)}`"
233
+ )
234
+ lines.append(f"- **id**: `{key[0]}`")
235
+ lines.append(f"- **judge reason**: {v.get('reason', '')}")
236
+ lines.append(f"- **Q**: {(c.get('question') or '')[:300]}")
237
+ lines.append(f"- **criterion**: {(c.get('criterion') or '')[:300]}")
238
+ lines.append(f"- **answer**: {(c.get('answer') or '')[:700]}")
239
+ lines.append("")
240
+ (gdir / "uncertain.md").write_text("\n".join(lines) + "\n")
241
+ print(f"uncertain: {len(low)} low-confidence -> {gdir}/uncertain.md (+ .csv)")
242
+ if missing:
243
+ print("MISSING keys (rerun their chunks):", len(missing))
244
+
245
+
246
+ if __name__ == "__main__":
247
+ main()
evals/grading_prep.py ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Prep subagent-judge grading inputs from regenerated handgrade sheets.
2
+
3
+ Not part of the shipped harness; a one-off orchestration helper for the
4
+ holistic/faithfulness backfill (kept in-repo so the run is reproducible).
5
+
6
+ Per run, per item_type the column is treated as all-or-nothing:
7
+ - already fully graded (existing judge_filled/handgrade_filled covers every sheet
8
+ row of that type) -> KEEP those grades, do not re-grade;
9
+ - otherwise -> ALL sheet rows of that type go to the judge (a single grader per
10
+ column per run, so no human+judge double-counting and clean provenance).
11
+
12
+ Outputs under runs/_grading/<battery>/:
13
+ - chunk_NNN.csv blinded rows for one grader agent (sheet_row_id + content)
14
+ - keep/<run>.csv existing grades to carry into the final merge
15
+ - manifest.json battery -> {n_chunks, n_rows, runs}
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import csv
21
+ import json
22
+ import sys
23
+ from collections import Counter, defaultdict
24
+ from pathlib import Path
25
+
26
+ from .grading_merge import DROP_ITEM_TYPES
27
+
28
+ csv.field_size_limit(10_000_000)
29
+
30
+ SKIP = {
31
+ "a4_s03_aggressive",
32
+ "a4_s03_fullhist",
33
+ "a4_sessions_prod",
34
+ "smoke_personas",
35
+ "smoke_session_prod",
36
+ "smoke_st_fullhist",
37
+ "smoke_st_prod",
38
+ "ds_smoke_st",
39
+ "e_v2_prod",
40
+ }
41
+ MAX_ROWS = 40
42
+ MAX_CHARS = 300_000
43
+ AGENT_COLS = [
44
+ "sheet_row_id",
45
+ "item_type",
46
+ "question",
47
+ "criterion",
48
+ "reference",
49
+ "answer",
50
+ ]
51
+
52
+
53
+ def battery_type(run_dir: Path) -> str:
54
+ first = json.loads((run_dir / "bundles.jsonl").open().readline())
55
+ return first.get("battery_type")
56
+
57
+
58
+ def existing_filled(run_dir: Path) -> list[dict[str, str]]:
59
+ out: list[dict[str, str]] = []
60
+ for fn in ("judge_filled.csv", "handgrade_filled.csv"):
61
+ p = run_dir / fn
62
+ if p.exists():
63
+ out += [
64
+ r for r in csv.DictReader(p.open()) if (r.get("grade") or "").strip()
65
+ ]
66
+ return out
67
+
68
+
69
+ def plan_run(run_dir: Path) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
70
+ """Return (rows_to_grade, rows_to_keep) for one run."""
71
+ sheet = list(csv.DictReader((run_dir / "handgrade_sheet.csv").open()))
72
+ if not sheet:
73
+ return [], []
74
+ sheet_by_type: dict[str, list] = defaultdict(list)
75
+ for r in sheet:
76
+ sheet_by_type[r["item_type"]].append(r)
77
+ filled = existing_filled(run_dir)
78
+ filled_by_type: dict[str, list] = defaultdict(list)
79
+ for r in filled:
80
+ filled_by_type[r["item_type"]].append(r)
81
+
82
+ to_grade: list[dict[str, str]] = []
83
+ to_keep: list[dict[str, str]] = []
84
+ for itype, rows in sheet_by_type.items():
85
+ graded = filled_by_type.get(itype, [])
86
+ if len(graded) >= len(rows): # column already fully graded -> keep
87
+ to_keep.extend(graded)
88
+ else: # grade the whole column
89
+ to_grade.extend(rows)
90
+ return to_grade, to_keep
91
+
92
+
93
+ def chunk(rows: list[dict[str, str]]) -> list[list[dict[str, str]]]:
94
+ chunks: list[list[dict[str, str]]] = []
95
+ cur: list[dict[str, str]] = []
96
+ cur_chars = 0
97
+ for r in rows:
98
+ rchars = len(r.get("reference") or "") + len(r.get("answer") or "")
99
+ if cur and (len(cur) >= MAX_ROWS or cur_chars + rchars > MAX_CHARS):
100
+ chunks.append(cur)
101
+ cur, cur_chars = [], 0
102
+ cur.append(r)
103
+ cur_chars += rchars
104
+ if cur:
105
+ chunks.append(cur)
106
+ return chunks
107
+
108
+
109
+ def main() -> None:
110
+ want = sys.argv[1] if len(sys.argv) > 1 else "singleturn"
111
+ # Optional 2nd arg: comma-separated run-name prefixes to include (focus a run
112
+ # on a subset of arms, e.g. "b_se,c_se" for the sessions quality test).
113
+ prefixes = tuple(
114
+ p for p in (sys.argv[2].split(",") if len(sys.argv) > 2 else []) if p
115
+ )
116
+ base = Path("runs")
117
+ out = base / "_grading" / want
118
+ (out / "keep").mkdir(parents=True, exist_ok=True)
119
+ # answers come from bundles (sheet 'answer' is truncated to 4000; fine for grading)
120
+ all_to_grade: list[dict[str, str]] = []
121
+ runs_used: list[str] = []
122
+ by_type = Counter()
123
+ for run_dir in sorted(base.glob("*/")):
124
+ name = run_dir.name
125
+ if name in SKIP or name.startswith("axisa"):
126
+ continue
127
+ if prefixes and not name.startswith(prefixes):
128
+ continue
129
+ if not (run_dir / "handgrade_sheet.csv").exists():
130
+ continue
131
+ if battery_type(run_dir) != want:
132
+ continue
133
+ to_grade, to_keep = plan_run(run_dir)
134
+ # Faithfulness is parked (see grading_merge.DROP_ITEM_TYPES): don't spend
135
+ # grading budget on it while the report/merge drop it.
136
+ to_grade = [r for r in to_grade if r["item_type"] not in DROP_ITEM_TYPES]
137
+ if to_keep:
138
+ with (out / "keep" / f"{name}.csv").open("w", newline="") as f:
139
+ w = csv.DictWriter(f, fieldnames=to_keep[0].keys())
140
+ w.writeheader()
141
+ w.writerows(to_keep)
142
+ if to_grade:
143
+ runs_used.append(name)
144
+ all_to_grade.extend(to_grade)
145
+ for r in to_grade:
146
+ by_type[r["item_type"].split(":")[0]] += 1
147
+
148
+ chunks = chunk(all_to_grade)
149
+ for i, ch in enumerate(chunks):
150
+ with (out / f"chunk_{i:03d}.csv").open("w", newline="") as f:
151
+ w = csv.DictWriter(f, fieldnames=AGENT_COLS, extrasaction="ignore")
152
+ w.writeheader()
153
+ w.writerows(ch)
154
+ (out / "manifest.json").write_text(
155
+ json.dumps(
156
+ {
157
+ "battery": want,
158
+ "n_chunks": len(chunks),
159
+ "n_rows": len(all_to_grade),
160
+ "by_item_type": dict(by_type),
161
+ "runs": runs_used,
162
+ },
163
+ indent=2,
164
+ )
165
+ )
166
+ print(f"{want}: {len(all_to_grade)} rows -> {len(chunks)} chunks {dict(by_type)}")
167
+ print(f" runs: {len(runs_used)} out: {out}")
168
+
169
+
170
+ if __name__ == "__main__":
171
+ main()
evals_graphrag.md → evals/graphrag.md RENAMED
File without changes
evals/judge.py CHANGED
@@ -94,6 +94,37 @@ RUBRICS: dict[str, str] = {
94
  "The 'reference' shows the gold staff behavior. Pass if the BEHAVIOR "
95
  "matches the expected kind even if details differ."
96
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  "replay_reply": (
98
  "ITEM TYPE = replay. The criterion asks whether this reply is about as "
99
  "helpful and correct as the real staff reply (shown as 'reference'). Pass "
@@ -127,9 +158,16 @@ def build_prompt(row: dict[str, str]) -> str:
127
  f"\nCRITERION:\n{row.get('criterion', '')}",
128
  ]
129
  if (row.get("reference") or "").strip():
130
- parts.append(
131
- f"\nSTAFF REFERENCE (context only, never shown to the tutor):\n{row['reference']}"
 
 
 
 
 
 
132
  )
 
133
  parts.append(f"\nANSWER TO GRADE:\n{row.get('answer') or '(EMPTY)'}")
134
  return "\n".join(parts)
135
 
 
94
  "The 'reference' shows the gold staff behavior. Pass if the BEHAVIOR "
95
  "matches the expected kind even if details differ."
96
  ),
97
+ "holistic": (
98
+ "ITEM TYPE = holistic tutor-answer quality (the staff-approval gate). "
99
+ "Decide as the course's expert tutor: would you approve sending this "
100
+ "answer to the student as-is? PASS only if ALL hold: (1) correct and not "
101
+ "misleading; (2) grounded in the right course/library material rather than "
102
+ "vague generic filler, for a course-content question; (3) it actually "
103
+ "helps the student LEARN -- explains or builds understanding at an "
104
+ "appropriate level, not just a bare dump, a wall of unexplained code, or "
105
+ "an unhelpful deflection; (4) appropriate scope and tone -- answers what "
106
+ "was asked, redirects platform/billing issues instead of inventing fixes, "
107
+ "acknowledges feedback without overpromising. FAIL if it is wrong, "
108
+ "ungrounded where grounding was needed, unhelpful for learning, or "
109
+ "off-scope. Judge the WHOLE answer, not a single claim; minor stylistic "
110
+ "nits alone are not a fail. Use confidence=low when it is a genuine "
111
+ "borderline 'I could see staff going either way'."
112
+ ),
113
+ "faithfulness": (
114
+ "ITEM TYPE = faithfulness / groundedness. The reference block is the "
115
+ "RETRIEVED EVIDENCE the tutor's own tools returned (corpus retrieval + KB "
116
+ "browse). Grade ONLY whether the answer's substantive factual and "
117
+ "technical claims are supported by that evidence. PASS if every "
118
+ "substantive claim is supported by, or a reasonable paraphrase/synthesis "
119
+ "of, the evidence. FAIL if the answer asserts specifics that are NOT in "
120
+ "the evidence -- fabricated API/function/parameter names, version numbers, "
121
+ "citations or URLs, course/lesson facts, or numeric claims (i.e. "
122
+ "hallucination beyond what the evidence supports). Widely-known general "
123
+ "programming facts an expert would accept need not appear in the evidence; "
124
+ "course-specific and library-specific specifics MUST. If the evidence is "
125
+ "empty or clearly insufficient to support the answer's specific claims, "
126
+ "FAIL (confidence=low if it is borderline)."
127
+ ),
128
  "replay_reply": (
129
  "ITEM TYPE = replay. The criterion asks whether this reply is about as "
130
  "helpful and correct as the real staff reply (shown as 'reference'). Pass "
 
158
  f"\nCRITERION:\n{row.get('criterion', '')}",
159
  ]
160
  if (row.get("reference") or "").strip():
161
+ # For faithfulness the reference block IS the retrieved evidence to grade
162
+ # the answer's grounding against; for every other item type it is the
163
+ # staff answer, shown as context only.
164
+ label = (
165
+ "RETRIEVED EVIDENCE the tutor's tools returned (grade the answer's "
166
+ "grounding against THIS)"
167
+ if row["item_type"] == "faithfulness"
168
+ else "STAFF REFERENCE (context only, never shown to the tutor)"
169
  )
170
+ parts.append(f"\n{label}:\n{row['reference']}")
171
  parts.append(f"\nANSWER TO GRADE:\n{row.get('answer') or '(EMPTY)'}")
172
  return "\n".join(parts)
173
 
evals_part_c_plan.md → evals/part_c_plan.md RENAMED
File without changes
evals/report.py CHANGED
@@ -97,6 +97,23 @@ def metric_rows(battery_type: str, runs: list[dict[str, Any]]) -> list[list[str]
97
  def add(name: str, fn) -> None:
98
  rows.append([name] + [fn(run["grades"]) for run in runs])
99
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  add("cases run", lambda g: str(len({x["unit_id"] for x in g})))
101
  add("errors", lambda g: str(sum(1 for x in g if x.get("error"))))
102
  # --- Work & cost: run-time-INDEPENDENT, the unconfounded headline ----------
@@ -212,8 +229,24 @@ def metric_rows(battery_type: str, runs: list[dict[str, Any]]) -> list[list[str]
212
  "behavior proxy from code checks",
213
  lambda g: rate(col(g, "behavior_heuristic")),
214
  )
215
- add("behavior (graded)", lambda g: rate(col(g, "behavior_pass")))
216
- add("key-point coverage (graded)", lambda g: _kp_coverage(g))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
 
218
  if battery_type == "sessions":
219
  add("cumulative input tok (last turn, mean)", _final_cumulative)
@@ -224,9 +257,15 @@ def metric_rows(battery_type: str, runs: list[dict[str, Any]]) -> list[list[str]
224
  ),
225
  )
226
  add(
227
- "probe accuracy (graded)",
228
  lambda g: rate([x.get("probe_pass") for x in g if x.get("is_probe")]),
229
  )
 
 
 
 
 
 
230
  for probe_type in sorted(
231
  {
232
  x.get("probe_type")
@@ -255,9 +294,16 @@ def metric_rows(battery_type: str, runs: list[dict[str, Any]]) -> list[list[str]
255
  "anti-pattern failures",
256
  lambda g: str(sum(1 for x in g if x.get("anti_pattern_hits"))),
257
  )
 
 
 
 
258
 
259
  if battery_type == "replay":
260
- add("replay reply pass (human grade)", lambda g: rate(col(g, "replay_pass")))
 
 
 
261
  return rows
262
 
263
 
 
97
  def add(name: str, fn) -> None:
98
  rows.append([name] + [fn(run["grades"]) for run in runs])
99
 
100
+ def src_tag(source_key: str) -> str:
101
+ """Per-metric provenance tag across the shown runs: [judge]/[human]/[mixed].
102
+
103
+ Lets a single report mix human-graded rows (e.g. Part B key-points,
104
+ session probes) with judge-graded rows (the new holistic/faithfulness)
105
+ and label each honestly, even when a run's overall grade_source is mixed.
106
+ """
107
+ seen = {
108
+ g.get(source_key)
109
+ for run in runs
110
+ for g in run["grades"]
111
+ if g.get(source_key)
112
+ }
113
+ if not seen:
114
+ return ""
115
+ return f" [{seen.pop()}]" if len(seen) == 1 else " [mixed]"
116
+
117
  add("cases run", lambda g: str(len({x["unit_id"] for x in g})))
118
  add("errors", lambda g: str(sum(1 for x in g if x.get("error"))))
119
  # --- Work & cost: run-time-INDEPENDENT, the unconfounded headline ----------
 
229
  "behavior proxy from code checks",
230
  lambda g: rate(col(g, "behavior_heuristic")),
231
  )
232
+ add(
233
+ f"behavior (graded){src_tag('behavior_source')}",
234
+ lambda g: rate(col(g, "behavior_pass")),
235
+ )
236
+ add(
237
+ f"key-point coverage (graded){src_tag('key_points_source')}",
238
+ lambda g: _kp_coverage(g),
239
+ )
240
+ add(
241
+ f"holistic: staff-approval (graded){src_tag('holistic_source')}",
242
+ lambda g: rate(col(g, "holistic_pass")),
243
+ )
244
+ # NOTE: faithfulness-to-evidence is intentionally not reported. On bundles
245
+ # recorded under the old 6k tool-output cap, KB-browse evidence is
246
+ # truncated, so the judge can't see what the agent grounded on and the
247
+ # score tracks capture-completeness, not grounding (evals.md F23/F24
248
+ # class). grade.py only emits it once evidence is captured in full; re-add
249
+ # the row after raising TOOL_OUTPUT_MAX_CHARS and re-recording a run.
250
 
251
  if battery_type == "sessions":
252
  add("cumulative input tok (last turn, mean)", _final_cumulative)
 
257
  ),
258
  )
259
  add(
260
+ f"probe accuracy (graded){src_tag('probe_source')}",
261
  lambda g: rate([x.get("probe_pass") for x in g if x.get("is_probe")]),
262
  )
263
+ # Answer quality across all session turns (not just probes): does holistic
264
+ # quality / faithfulness degrade under compaction over a long session?
265
+ add(
266
+ f"holistic: staff-approval (graded){src_tag('holistic_source')}",
267
+ lambda g: rate(col(g, "holistic_pass")),
268
+ )
269
  for probe_type in sorted(
270
  {
271
  x.get("probe_type")
 
294
  "anti-pattern failures",
295
  lambda g: str(sum(1 for x in g if x.get("anti_pattern_hits"))),
296
  )
297
+ add(
298
+ f"holistic: staff-approval (graded){src_tag('holistic_source')}",
299
+ lambda g: rate(col(g, "holistic_pass")),
300
+ )
301
 
302
  if battery_type == "replay":
303
+ add(
304
+ f"replay reply pass (graded){src_tag('replay_source')}",
305
+ lambda g: rate(col(g, "replay_pass")),
306
+ )
307
  return rows
308
 
309
 
evals/run_battery.py CHANGED
@@ -38,9 +38,15 @@ from .common import append_jsonl, detect_battery_type, load_jsonl, write_jsonl
38
 
39
  logger = logging.getLogger("evals.run_battery")
40
 
41
- # Full tool outputs can be 40k chars each; keep bundles browsable. The full
42
- # size is preserved in output_chars so truncation is visible.
43
- TOOL_OUTPUT_MAX_CHARS = 6_000
 
 
 
 
 
 
44
  # Slowest observed turn is ~2.5 min; anything past this is a wedged stream
45
  # (e.g. laptop sleep killed the connection mid-turn). The turn records a
46
  # TimeoutError and the unit re-runs on resume instead of hanging forever.
 
38
 
39
  logger = logging.getLogger("evals.run_battery")
40
 
41
+ # Capture the full tool output the agent saw: the KB shell returns up to
42
+ # DEFAULT_MAX_OUTPUT_CHARS = 40k chars/call (app/kb_shell.py), so 40k preserves
43
+ # everything the model grounded on. This matters for offline faithfulness
44
+ # grading -- under the old 6k cap, KB-browse evidence was truncated, so a
45
+ # faithfulness judge scored capture-completeness, not grounding (evals.md
46
+ # F23/F24 class). output_chars still records the true length, so any rare
47
+ # overflow stays visible. Bundles are bigger (KB-heavy turns ~40k x several
48
+ # calls), which is an acceptable cost for local-only runs.
49
+ TOOL_OUTPUT_MAX_CHARS = 40_000
50
  # Slowest observed turn is ~2.5 min; anything past this is a wedged stream
51
  # (e.g. laptop sleep killed the connection mid-turn). The turn records a
52
  # TimeoutError and the unit re-runs on resume instead of hanging forever.
evals_slm_compaction.md → evals/slm_compaction.md RENAMED
@@ -65,7 +65,7 @@ window is itself truncated at build time.
65
 
66
  **Naming caveat:** `full_context` here is *document-stuffing* (the whole lesson in
67
  the prompt), which is **not** `full_history`'s conversational-memory role. The
68
- sibling Gemini study (`evals_compaction.md`, F29) tests the same document-in-prompt
69
  idea but names that arm `full_history`, because it parks the lesson in turn 0 of a
70
  session — so don't read F29's `full_history` as the Axis-A memory policy.
71
 
@@ -137,7 +137,7 @@ Run on a **MacBook Pro, Apple M1 Pro, 16 GB**, one model at a time via
137
  3. **GraphRAG never wins outright — same verdict as the cloud experiment.** It ties
138
  `rag` on the qwens (100%) and edges it on llama (87% vs 80%), but at **~2.8× the
139
  context tokens (8.2k vs 2.9k) and ~2.5× the latency (~50-78s vs ~20-26s)**.
140
- No quality gain justifies the cost (cf. `evals_graphrag.md`).
141
 
142
  4. **Summarization compaction degrades hard, worst on the smallest model.**
143
  `summary` and `hierarchical_summary` are the bottom methods (llama `summary`
 
65
 
66
  **Naming caveat:** `full_context` here is *document-stuffing* (the whole lesson in
67
  the prompt), which is **not** `full_history`'s conversational-memory role. The
68
+ sibling Gemini study (`evals/compaction.md`, F29) tests the same document-in-prompt
69
  idea but names that arm `full_history`, because it parks the lesson in turn 0 of a
70
  session — so don't read F29's `full_history` as the Axis-A memory policy.
71
 
 
137
  3. **GraphRAG never wins outright — same verdict as the cloud experiment.** It ties
138
  `rag` on the qwens (100%) and edges it on llama (87% vs 80%), but at **~2.8× the
139
  context tokens (8.2k vs 2.9k) and ~2.5× the latency (~50-78s vs ~20-26s)**.
140
+ No quality gain justifies the cost (cf. `evals/graphrag.md`).
141
 
142
  4. **Summarization compaction degrades hard, worst on the smallest model.**
143
  `summary` and `hierarchical_summary` are the bottom methods (llama `summary`
pyproject.toml CHANGED
@@ -31,7 +31,7 @@ dependencies = [
31
  # Eval-only: the GraphRAG-vs-RAG experiment (F28). Pulls graphrag + lancedb/litellm/
32
  # pandas, all imported lazily and only by the opt-in `graphrag` retriever — never on
33
  # the production path. Kept out of the default install so the Spaces image stays lean.
34
- # Reproduce the experiment with: uv sync --extra graphrag (see evals_graphrag.md).
35
  graphrag = ["graphrag>=3.1.0"]
36
 
37
  [dependency-groups]
 
31
  # Eval-only: the GraphRAG-vs-RAG experiment (F28). Pulls graphrag + lancedb/litellm/
32
  # pandas, all imported lazily and only by the opt-in `graphrag` retriever — never on
33
  # the production path. Kept out of the default install so the Spaces image stays lean.
34
+ # Reproduce the experiment with: uv sync --extra graphrag (see evals/graphrag.md).
35
  graphrag = ["graphrag>=3.1.0"]
36
 
37
  [dependency-groups]
tests/test_build_public_docs_bundle.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import pickle
5
+ from pathlib import Path
6
+
7
+ from app import kb_manifest
8
+ from data.scraping_scripts import build_public_docs_bundle as builder
9
+
10
+ # Use real registry keys so the COURSE/_DOC split matches production.
11
+ COURSE_KEY = "master_ai_for_work"
12
+ DOC_KEY = "transformers"
13
+
14
+
15
+ def _write_jsonl(path: Path, rows: list[dict]) -> None:
16
+ path.parent.mkdir(parents=True, exist_ok=True)
17
+ with path.open("w", encoding="utf-8") as handle:
18
+ for row in rows:
19
+ handle.write(json.dumps(row) + "\n")
20
+
21
+
22
+ def test_filter_jsonl_drops_course_rows(tmp_path: Path) -> None:
23
+ path = tmp_path / "headings.jsonl"
24
+ _write_jsonl(
25
+ path,
26
+ [
27
+ {"source": DOC_KEY, "heading": "Install"},
28
+ {"source": COURSE_KEY, "heading": "Lesson 1"},
29
+ {"source": DOC_KEY, "heading": "Usage"},
30
+ ],
31
+ )
32
+ kept = builder._filter_jsonl(path)
33
+ assert kept == 2
34
+ rows = [json.loads(line) for line in path.read_text().splitlines()]
35
+ assert {row["source"] for row in rows} == {DOC_KEY}
36
+
37
+
38
+ def test_filter_tsv_drops_course_rows_keeps_header(tmp_path: Path) -> None:
39
+ path = tmp_path / "symbols.tsv"
40
+ path.write_text(
41
+ "symbol\tsource\ttitle\tpath\theading\tdoc_id\n"
42
+ f"AutoModel\t{DOC_KEY}\tT\tp\th\td1\n"
43
+ f"Lesson\t{COURSE_KEY}\tT\tp\th\td2\n",
44
+ encoding="utf-8",
45
+ )
46
+ kept = builder._filter_tsv(path)
47
+ assert kept == 1
48
+ lines = path.read_text().splitlines()
49
+ assert lines[0].startswith("symbol\tsource") # header preserved
50
+ assert COURSE_KEY not in path.read_text()
51
+ assert DOC_KEY in path.read_text()
52
+
53
+
54
+ def test_stage_kb_prunes_course_content(tmp_path: Path) -> None:
55
+ source_dir = tmp_path / "data"
56
+ kb = source_dir / "kb"
57
+ (kb / "raw" / "docs" / DOC_KEY).mkdir(parents=True)
58
+ (kb / "raw" / "docs" / DOC_KEY / "a.md").write_text("# A", encoding="utf-8")
59
+ (kb / "raw" / "courses" / COURSE_KEY).mkdir(parents=True)
60
+ (kb / "raw" / "courses" / COURSE_KEY / "l1.md").write_text("# L1", encoding="utf-8")
61
+ (kb / "wiki" / "courses").mkdir(parents=True)
62
+ (kb / "wiki" / "courses" / "c.md").write_text("course", encoding="utf-8")
63
+ (kb / "wiki").joinpath("index.md").write_text("# Index", encoding="utf-8")
64
+
65
+ generated = kb / "generated"
66
+ generated.mkdir(parents=True)
67
+ _write_jsonl(
68
+ generated / "corpus_manifest.jsonl",
69
+ [
70
+ {"source": DOC_KEY, "source_group": "docs", "path": "data/kb/raw/docs/x"},
71
+ {"source": COURSE_KEY, "source_group": "courses", "path": "y"},
72
+ ],
73
+ )
74
+ _write_jsonl(
75
+ generated / "headings.jsonl",
76
+ [{"source": DOC_KEY}, {"source": COURSE_KEY}],
77
+ )
78
+ (generated / "symbols.tsv").write_text(
79
+ "symbol\tsource\ttitle\tpath\theading\tdoc_id\n"
80
+ f"X\t{DOC_KEY}\tT\tp\th\td1\n"
81
+ f"Y\t{COURSE_KEY}\tT\tp\th\td2\n",
82
+ encoding="utf-8",
83
+ )
84
+
85
+ stage_dir = tmp_path / "stage"
86
+ stage_dir.mkdir()
87
+ summary = builder.stage_kb(source_dir, stage_dir)
88
+
89
+ staged_kb = stage_dir / "kb"
90
+ assert not (staged_kb / "raw" / "courses").exists()
91
+ assert (staged_kb / "raw" / "docs" / DOC_KEY / "a.md").exists()
92
+ assert not (staged_kb / "wiki" / "courses").exists()
93
+ assert (staged_kb / "wiki" / "index.md").exists() # docs wiki kept
94
+
95
+ manifest = [
96
+ json.loads(line)
97
+ for line in (staged_kb / "generated" / "corpus_manifest.jsonl")
98
+ .read_text()
99
+ .splitlines()
100
+ ]
101
+ assert {row["source"] for row in manifest} == {DOC_KEY}
102
+ assert summary["manifest_rows"] == 1
103
+ assert COURSE_KEY not in (staged_kb / "generated" / "symbols.tsv").read_text()
104
+
105
+
106
+ def test_rebuild_retrieval_pkls_drops_course_documents(tmp_path: Path) -> None:
107
+ source_dir = tmp_path / "data"
108
+ rows = [
109
+ {
110
+ "doc_id": "doc-transformers",
111
+ "name": "Transformers",
112
+ "url": "https://example.com/t",
113
+ "source": DOC_KEY,
114
+ "content": "# Heading\n\nUse AutoModel to load weights.",
115
+ "retrieve_doc": True,
116
+ "tokens": 9,
117
+ },
118
+ {
119
+ "doc_id": "doc-course",
120
+ "name": "Course Lesson",
121
+ "url": "https://example.com/c",
122
+ "source": COURSE_KEY,
123
+ "content": "# Lesson\n\nPrivate course material.",
124
+ "retrieve_doc": True,
125
+ "tokens": 7,
126
+ },
127
+ ]
128
+ _write_jsonl(source_dir / "all_sources_data.jsonl", rows)
129
+ (tmp_path / "stage" / builder.VECTOR_DB_DIR).mkdir(parents=True)
130
+
131
+ summary = builder.rebuild_retrieval_pkls(source_dir, tmp_path / "stage")
132
+ assert summary["documents"] == 1
133
+
134
+ dict_path = tmp_path / "stage" / builder.VECTOR_DB_DIR / builder.DOCUMENT_DICT_FILE
135
+ with dict_path.open("rb") as handle:
136
+ document_dict = pickle.load(handle)
137
+ assert set(document_dict) == {"doc-transformers"}
138
+
139
+
140
+ def test_public_allow_patterns_toggles_contextual() -> None:
141
+ base = builder.public_allow_patterns(include_contextual=False)
142
+ assert base == ["chroma-db-all_sources/**", "kb/**"]
143
+ with_ctx = builder.public_allow_patterns(include_contextual=True)
144
+ assert "all_sources_contextual_nodes.pkl" in with_ctx
145
+
146
+
147
+ def test_available_source_keys_excludes_absent_sources(tmp_path: Path) -> None:
148
+ kb_dir = tmp_path / "kb"
149
+ generated = kb_dir / "generated"
150
+ generated.mkdir(parents=True)
151
+ # A docs-only manifest (the public bundle): courses are simply not present.
152
+ _write_jsonl(
153
+ generated / "corpus_manifest.jsonl",
154
+ [
155
+ {"doc_id": "1", "source": DOC_KEY, "source_group": "docs"},
156
+ {"doc_id": "2", "source": "langchain", "source_group": "docs"},
157
+ ],
158
+ )
159
+ kb_manifest._MANIFEST_CACHE.pop(str(kb_dir), None)
160
+ keys = kb_manifest.available_source_keys(str(kb_dir))
161
+ assert keys == frozenset({DOC_KEY, "langchain"})
162
+ assert COURSE_KEY not in keys
163
+
164
+
165
+ def test_available_source_keys_none_when_manifest_missing(tmp_path: Path) -> None:
166
+ missing = tmp_path / "no_kb"
167
+ kb_manifest._MANIFEST_CACHE.pop(str(missing), None)
168
+ assert kb_manifest.available_source_keys(str(missing)) is None
tests/test_config.py CHANGED
@@ -77,6 +77,50 @@ def test_complete_bundle_skips_download_and_caches_readiness(tmp_path: Path) ->
77
  assert snapshot_download.call_count == 0
78
 
79
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  def test_ensure_kb_agents_md_writes_once_and_atomically(tmp_path: Path) -> None:
81
  _write_bundle_files(tmp_path)
82
 
 
77
  assert snapshot_download.call_count == 0
78
 
79
 
80
+ def _repo_not_found_error():
81
+ try:
82
+ from huggingface_hub.errors import RepositoryNotFoundError
83
+ except ImportError: # older huggingface_hub
84
+ from huggingface_hub.utils import RepositoryNotFoundError
85
+ return RepositoryNotFoundError
86
+
87
+
88
+ def test_download_bundle_uses_public_when_no_token() -> None:
89
+ with patch("huggingface_hub.get_token", return_value=None):
90
+ with patch("app.config._snapshot_bundle") as snapshot:
91
+ config._download_bundle()
92
+ snapshot.assert_called_once()
93
+ assert snapshot.call_args.args[0] == config.PUBLIC_VECTOR_DB_REPO_ID
94
+
95
+
96
+ def test_download_bundle_uses_private_when_token_present() -> None:
97
+ with patch("huggingface_hub.get_token", return_value="tok"):
98
+ with patch("app.config._snapshot_bundle") as snapshot:
99
+ config._download_bundle()
100
+ snapshot.assert_called_once()
101
+ assert snapshot.call_args.args[0] == config.VECTOR_DB_REPO_ID
102
+
103
+
104
+ def test_download_bundle_falls_back_to_public_on_no_access() -> None:
105
+ import requests
106
+
107
+ repo_error = _repo_not_found_error()
108
+ response = requests.Response()
109
+ response.status_code = 401
110
+ calls: list[str] = []
111
+
112
+ def side_effect(repo_id: str, *, token) -> None:
113
+ calls.append(repo_id)
114
+ if repo_id == config.VECTOR_DB_REPO_ID:
115
+ raise repo_error("no access", response=response)
116
+
117
+ with patch("huggingface_hub.get_token", return_value="tok"):
118
+ with patch("app.config._snapshot_bundle", side_effect=side_effect):
119
+ config._download_bundle()
120
+
121
+ assert calls == [config.VECTOR_DB_REPO_ID, config.PUBLIC_VECTOR_DB_REPO_ID]
122
+
123
+
124
  def test_ensure_kb_agents_md_writes_once_and_atomically(tmp_path: Path) -> None:
125
  _write_bundle_files(tmp_path)
126
 
tests/test_evals_grade.py CHANGED
@@ -1,13 +1,21 @@
1
  from __future__ import annotations
2
 
 
 
3
  import unittest
 
4
 
5
  from evals.common import detect_battery_type, normalize_url, percentile
6
  from evals.grade import (
7
  behavior_heuristic,
 
 
8
  grade_persona_question,
 
 
9
  retrieval_metrics,
10
  )
 
11
 
12
 
13
  class CommonTests(unittest.TestCase):
@@ -147,5 +155,202 @@ class PersonaGradingTests(unittest.TestCase):
147
  self.assertTrue(result["needs_judgment"])
148
 
149
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  if __name__ == "__main__":
151
  unittest.main()
 
1
  from __future__ import annotations
2
 
3
+ import csv
4
+ import tempfile
5
  import unittest
6
+ from pathlib import Path
7
 
8
  from evals.common import detect_battery_type, normalize_url, percentile
9
  from evals.grade import (
10
  behavior_heuristic,
11
+ evidence_is_complete,
12
+ faithfulness_evidence,
13
  grade_persona_question,
14
+ merge_handgrades,
15
+ quality_sheet_rows,
16
  retrieval_metrics,
17
  )
18
+ from evals.judge import RUBRICS, build_prompt, rubric_for
19
 
20
 
21
  class CommonTests(unittest.TestCase):
 
155
  self.assertTrue(result["needs_judgment"])
156
 
157
 
158
+ class FaithfulnessEvidenceTests(unittest.TestCase):
159
+ def test_collects_retrieval_and_kb_evidence(self) -> None:
160
+ bundle = {
161
+ "tool_calls": [
162
+ {
163
+ "tool_name": "retrieve_tutor_context",
164
+ "args_text": "context engineering",
165
+ "output_text": "Lesson 3 covers context engineering.",
166
+ "matches": [{"title": "Lesson 3", "url": "https://x/l3"}],
167
+ },
168
+ {
169
+ "tool_name": "run_kb_command",
170
+ "args_text": "rg foo",
171
+ "output_text": "raw/courses/agent/lesson.md: foo bar",
172
+ },
173
+ ]
174
+ }
175
+ evidence = faithfulness_evidence(bundle)
176
+ self.assertIn("context engineering", evidence)
177
+ self.assertIn("SOURCES: Lesson 3 <https://x/l3>", evidence)
178
+ self.assertIn("raw/courses/agent/lesson.md", evidence)
179
+
180
+ def test_empty_when_no_retrieval_tool(self) -> None:
181
+ bundle = {"tool_calls": [{"tool_name": "some_other_tool", "output_text": "x"}]}
182
+ self.assertEqual(faithfulness_evidence(bundle), "")
183
+ self.assertEqual(faithfulness_evidence({}), "")
184
+
185
+ def test_respects_max_chars(self) -> None:
186
+ bundle = {
187
+ "tool_calls": [{"tool_name": "run_kb_command", "output_text": "z" * 5000}]
188
+ }
189
+ self.assertEqual(len(faithfulness_evidence(bundle, max_chars=100)), 100)
190
+
191
+
192
+ class EvidenceCompletenessTests(unittest.TestCase):
193
+ def test_complete_when_no_truncation(self) -> None:
194
+ bundle = {
195
+ "tool_calls": [
196
+ {"tool_name": "run_kb_command", "output_text": "abc", "output_chars": 3}
197
+ ]
198
+ }
199
+ self.assertTrue(evidence_is_complete(bundle))
200
+
201
+ def test_incomplete_when_kb_output_truncated(self) -> None:
202
+ # output_chars (true length) exceeds captured output_text => truncated.
203
+ bundle = {
204
+ "tool_calls": [
205
+ {
206
+ "tool_name": "run_kb_command",
207
+ "output_text": "x" * 6000,
208
+ "output_chars": 40000,
209
+ }
210
+ ]
211
+ }
212
+ self.assertFalse(evidence_is_complete(bundle))
213
+
214
+ def test_non_retrieval_truncation_is_ignored(self) -> None:
215
+ bundle = {
216
+ "tool_calls": [
217
+ {"tool_name": "some_tool", "output_text": "x", "output_chars": 99999}
218
+ ]
219
+ }
220
+ self.assertTrue(evidence_is_complete(bundle))
221
+
222
+ def test_faithfulness_row_gated_on_complete_evidence(self) -> None:
223
+ truncated = {
224
+ "run_id": "r",
225
+ "battery_type": "singleturn",
226
+ "preset": "prod",
227
+ "query": "q",
228
+ "answer": "a",
229
+ "tool_calls": [
230
+ {
231
+ "tool_name": "run_kb_command",
232
+ "args_text": "rg foo",
233
+ "output_text": "y" * 6000,
234
+ "output_chars": 40000,
235
+ }
236
+ ],
237
+ }
238
+ types = {r["item_type"] for r in quality_sheet_rows(truncated)}
239
+ self.assertIn("holistic", types)
240
+ self.assertNotIn("faithfulness", types) # truncated -> no faithfulness
241
+
242
+ full = dict(truncated)
243
+ full["tool_calls"] = [
244
+ {
245
+ "tool_name": "run_kb_command",
246
+ "args_text": "rg foo",
247
+ "output_text": "y" * 100,
248
+ "output_chars": 100,
249
+ }
250
+ ]
251
+ types_full = {r["item_type"] for r in quality_sheet_rows(full)}
252
+ self.assertIn("faithfulness", types_full) # full evidence -> emitted
253
+
254
+
255
+ class JudgeRubricTests(unittest.TestCase):
256
+ def test_holistic_and_faithfulness_rubrics_exist(self) -> None:
257
+ self.assertIs(rubric_for("holistic"), RUBRICS["holistic"])
258
+ self.assertIs(rubric_for("faithfulness"), RUBRICS["faithfulness"])
259
+
260
+ def test_unknown_item_type_falls_back_to_key_point(self) -> None:
261
+ self.assertIs(rubric_for("mystery"), RUBRICS["key_point"])
262
+
263
+ def test_faithfulness_reference_labeled_as_evidence(self) -> None:
264
+ row = {
265
+ "item_type": "faithfulness",
266
+ "question": "q",
267
+ "criterion": "grounded?",
268
+ "reference": "RETRIEVED_TEXT_X",
269
+ "answer": "a",
270
+ }
271
+ prompt = build_prompt(row)
272
+ self.assertIn("RETRIEVED EVIDENCE", prompt)
273
+ self.assertNotIn("STAFF REFERENCE", prompt)
274
+
275
+ def test_other_item_types_keep_staff_reference_label(self) -> None:
276
+ row = {
277
+ "item_type": "key_point",
278
+ "question": "q",
279
+ "criterion": "claim",
280
+ "reference": "STAFF_TEXT",
281
+ "answer": "a",
282
+ }
283
+ self.assertIn("STAFF REFERENCE", build_prompt(row))
284
+
285
+
286
+ class MergeHandgradesTests(unittest.TestCase):
287
+ FIELDS = [
288
+ "sheet_row_id",
289
+ "run_id",
290
+ "item_type",
291
+ "criterion",
292
+ "grade",
293
+ "note",
294
+ ]
295
+
296
+ def _merge(self, grades, filled_rows):
297
+ with tempfile.TemporaryDirectory() as tmp:
298
+ path = Path(tmp) / "filled.csv"
299
+ with open(path, "w", encoding="utf-8", newline="") as f:
300
+ writer = csv.DictWriter(f, fieldnames=self.FIELDS)
301
+ writer.writeheader()
302
+ writer.writerows(filled_rows)
303
+ return merge_handgrades(grades, path)
304
+
305
+ def test_new_item_types_and_per_metric_source(self) -> None:
306
+ grades = [{"run_id": "r1"}]
307
+ filled = [
308
+ # human-graded key points
309
+ {
310
+ "sheet_row_id": "r1|key_point|a",
311
+ "run_id": "r1",
312
+ "item_type": "key_point",
313
+ "criterion": "k1",
314
+ "grade": "pass",
315
+ "note": "",
316
+ },
317
+ {
318
+ "sheet_row_id": "r1|key_point|b",
319
+ "run_id": "r1",
320
+ "item_type": "key_point",
321
+ "criterion": "k2",
322
+ "grade": "fail",
323
+ "note": "",
324
+ },
325
+ # judge-graded new dimensions
326
+ {
327
+ "sheet_row_id": "r1|holistic|c",
328
+ "run_id": "r1",
329
+ "item_type": "holistic",
330
+ "criterion": "h",
331
+ "grade": "pass",
332
+ "note": "[judge:high] good answer",
333
+ },
334
+ {
335
+ "sheet_row_id": "r1|faithfulness|d",
336
+ "run_id": "r1",
337
+ "item_type": "faithfulness",
338
+ "criterion": "f",
339
+ "grade": "fail",
340
+ "note": "[judge:low] fabricated param",
341
+ },
342
+ ]
343
+ merged = self._merge(grades, filled)[0]
344
+ self.assertEqual(merged["key_points_passed"], 1)
345
+ self.assertEqual(merged["key_points_total"], 2)
346
+ self.assertEqual(merged["key_points_source"], "human")
347
+ self.assertTrue(merged["holistic_pass"])
348
+ self.assertEqual(merged["holistic_source"], "judge")
349
+ self.assertFalse(merged["faithfulness_pass"])
350
+ self.assertEqual(merged["faithfulness_source"], "judge")
351
+ # human key-points + judge new dims => overall mixed
352
+ self.assertEqual(merged["grade_source"], "mixed")
353
+
354
+
355
  if __name__ == "__main__":
356
  unittest.main()