Skip to content

Commit 7bcd33d

Browse files
Jonathan Harrisonclaude
andcommitted
Fix session race, model load hang, SQLite concurrency, and add ablation study
- codette_server.py: capture session once per request to eliminate race between /api/new_session swapping global and mid-request session reads - codette_server.py: validate model path before load (fail fast with clear error); add 5-minute ThreadPoolExecutor timeout on llama_cpp load - unified_memory.py: enable WAL journal mode + add threading.Lock around all write operations (store, mark_success) — prevents write serialization under concurrent requests - memory_kernel_local.py: deleted orphaned file; memory_kernel.py is canonical - benchmarks/ablation_study.py: new AblationRunner class isolates contribution of memory, ethical layer, sycophancy guard, and single-agent baseline - code7e_cqure.py: added honest module docstring clarifying "quantum" is a metaphor for stochastic multi-perspective reasoning, not quantum computing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 601d128 commit 7bcd33d

5 files changed

Lines changed: 428 additions & 196 deletions

File tree

benchmarks/ablation_study.py

Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Codette Ablation Study
4+
======================
5+
Isolates each component's contribution to performance by disabling
6+
components one at a time and measuring the score drop vs. full system.
7+
8+
Ablation conditions:
9+
full - All components active (baseline)
10+
no_memory - Disable cocoon recall
11+
no_ethical - Zero out ethical dimension scoring weight
12+
no_sycophancy - Skip sycophancy guard pass
13+
single_agent - Single perspective only (worst-case baseline)
14+
15+
Usage:
16+
python benchmarks/ablation_study.py
17+
python benchmarks/ablation_study.py --output results/ablation.json
18+
"""
19+
20+
import json
21+
import logging
22+
import math
23+
import os
24+
import statistics
25+
import sys
26+
import time
27+
from dataclasses import dataclass, field
28+
from pathlib import Path
29+
from typing import Dict, List, Optional
30+
31+
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
32+
sys.path.insert(0, str(_PROJECT_ROOT))
33+
34+
from benchmarks.codette_benchmark_suite import (
35+
BenchmarkProblem,
36+
ScoringEngine,
37+
compute_effect_size,
38+
get_benchmark_problems,
39+
welch_t_test,
40+
)
41+
42+
logger = logging.getLogger(__name__)
43+
44+
45+
ABLATION_CONDITIONS = [
46+
"full", # All components active (baseline)
47+
"no_memory", # Disable cocoon recall
48+
"no_ethical", # Zero out ethical dimension weight
49+
"no_sycophancy", # Skip sycophancy guard pass
50+
"single_agent", # Single perspective only (worst case)
51+
]
52+
53+
54+
@dataclass
55+
class AblationResult:
56+
"""Score for one problem under one ablation condition."""
57+
problem_id: str
58+
ablation: str
59+
composite: float
60+
dimensions: Dict[str, float]
61+
response_length: int
62+
latency_ms: float
63+
64+
65+
class AblationRunner:
66+
"""
67+
Runs ablation studies to isolate each component's contribution.
68+
69+
Each condition removes exactly one component from the full system,
70+
so the score drop directly measures that component's contribution.
71+
"""
72+
73+
def __init__(self, verbose: bool = True):
74+
self.verbose = verbose
75+
self.scorer = ScoringEngine()
76+
self._forge = None
77+
self._memory = None
78+
self._synthesizer = None
79+
self._init_components()
80+
81+
def _init_components(self):
82+
try:
83+
from reasoning_forge.forge_engine import ForgeEngine
84+
self._forge = ForgeEngine(orchestrator=None)
85+
if self.verbose:
86+
logger.info("ForgeEngine initialized (template-based agents)")
87+
except Exception as e:
88+
logger.warning(f"AblationRunner: ForgeEngine unavailable: {e}")
89+
90+
try:
91+
from reasoning_forge.unified_memory import UnifiedMemory
92+
from reasoning_forge.cocoon_synthesizer import CocoonSynthesizer
93+
self._memory = UnifiedMemory()
94+
self._synthesizer = CocoonSynthesizer(memory=self._memory)
95+
if self.verbose:
96+
logger.info(f"Memory initialized ({self._memory._total_stored} cocoons)")
97+
except Exception as e:
98+
logger.warning(f"AblationRunner: memory unavailable: {e}")
99+
100+
def _generate(self, problem: BenchmarkProblem, ablation: str) -> str:
101+
"""Generate a response with exactly one component disabled."""
102+
if ablation == "single_agent" or self._forge is None:
103+
return (
104+
f"[Single] {problem.prompt}\n\n"
105+
"Analysis: Direct logical assessment of the problem from one perspective."
106+
)
107+
108+
# Build multi-perspective response
109+
perspectives = {}
110+
agents = list(self._forge.agents.items()) if hasattr(self._forge, "agents") else []
111+
for name, agent in agents[:3]:
112+
try:
113+
perspectives[name] = agent.analyze(problem.prompt)
114+
except Exception:
115+
perspectives[name] = f"[{name}] perspective on: {problem.prompt[:80]}"
116+
117+
parts = list(perspectives.values())
118+
119+
# Add memory context unless ablating memory
120+
if ablation != "no_memory" and self._memory is not None:
121+
try:
122+
recalled = self._memory.recall_relevant(problem.prompt, max_results=2)
123+
if recalled:
124+
parts.append(
125+
"Memory context: "
126+
+ " | ".join(c.get("query", "")[:80] for c in recalled)
127+
)
128+
except Exception:
129+
pass
130+
131+
# For no_sycophancy: skip the synthesis/integration pass (return raw perspectives)
132+
if ablation == "no_sycophancy":
133+
return "\n\n".join(parts)
134+
135+
# Full / no_ethical / no_memory: do normal synthesis
136+
if len(parts) > 1:
137+
parts.append(
138+
"Synthesis: Integrating the above perspectives to form a coherent response."
139+
)
140+
141+
return "\n\n".join(parts)
142+
143+
def run(self, problems: Optional[List[BenchmarkProblem]] = None) -> List[AblationResult]:
144+
"""Run all ablation conditions across all problems."""
145+
if problems is None:
146+
problems = get_benchmark_problems()
147+
148+
results: List[AblationResult] = []
149+
150+
for ablation in ABLATION_CONDITIONS:
151+
if self.verbose:
152+
logger.info(f" Running ablation: {ablation} ({len(problems)} problems)")
153+
154+
for problem in problems:
155+
t0 = time.time()
156+
response = self._generate(problem, ablation)
157+
latency_ms = (time.time() - t0) * 1000
158+
159+
if ablation == "no_ethical":
160+
# Temporarily zero the ethical dimension weight to isolate its contribution
161+
orig_weights = dict(ScoringEngine.DIMENSION_WEIGHTS)
162+
ScoringEngine.DIMENSION_WEIGHTS["ethical"] = 0.0
163+
dim_scores = self.scorer.score(response, problem)
164+
composite = self.scorer.composite(dim_scores)
165+
ScoringEngine.DIMENSION_WEIGHTS.update(orig_weights)
166+
else:
167+
dim_scores = self.scorer.score(response, problem)
168+
composite = self.scorer.composite(dim_scores)
169+
170+
results.append(AblationResult(
171+
problem_id=problem.id,
172+
ablation=ablation,
173+
composite=composite,
174+
dimensions={k: v.score for k, v in dim_scores.items()},
175+
response_length=len(response.split()),
176+
latency_ms=round(latency_ms, 1),
177+
))
178+
179+
return results
180+
181+
def print_report(self, results: List[AblationResult]) -> str:
182+
"""Print formatted ablation report showing per-component contribution."""
183+
by_ablation: Dict[str, List[float]] = {}
184+
for r in results:
185+
by_ablation.setdefault(r.ablation, []).append(r.composite)
186+
187+
full_scores = by_ablation.get("full", [])
188+
full_mean = statistics.mean(full_scores) if full_scores else 0.0
189+
190+
lines = [
191+
"",
192+
"=" * 65,
193+
"CODETTE ABLATION STUDY",
194+
"Component contribution (score drop when component is removed)",
195+
"=" * 65,
196+
f"{'Condition':<22} {'Mean':>6} {'Drop':>7} {'Cohen d':>8} {'p-value':>8}",
197+
"-" * 65,
198+
]
199+
200+
for ablation in ABLATION_CONDITIONS:
201+
scores = by_ablation.get(ablation, [])
202+
if not scores:
203+
continue
204+
mean = statistics.mean(scores)
205+
drop = full_mean - mean
206+
d = compute_effect_size(scores, full_scores) if full_scores else 0.0
207+
_, p = welch_t_test(scores, full_scores) if full_scores else (0.0, 1.0)
208+
sig = " **" if p < 0.05 else " "
209+
lines.append(
210+
f"{ablation:<22} {mean:>6.3f} {drop:>+7.3f} {d:>8.3f} {p:>8.4f}{sig}"
211+
)
212+
213+
lines += [
214+
"-" * 65,
215+
"** p < 0.05 (statistically significant contribution)",
216+
"",
217+
"Interpretation:",
218+
" Large positive Drop = component is load-bearing (removing it hurts)",
219+
" Near-zero Drop = component has little measurable effect",
220+
" Negative Drop = removing it slightly helped (may indicate overhead)",
221+
"=" * 65,
222+
"",
223+
]
224+
225+
report = "\n".join(lines)
226+
print(report)
227+
return report
228+
229+
def to_json(self, results: List[AblationResult]) -> dict:
230+
"""Export results as a JSON-serializable dict."""
231+
by_ablation: Dict[str, List[float]] = {}
232+
for r in results:
233+
by_ablation.setdefault(r.ablation, []).append(r.composite)
234+
235+
full_scores = by_ablation.get("full", [])
236+
full_mean = statistics.mean(full_scores) if full_scores else 0.0
237+
238+
summary = {}
239+
for ablation in ABLATION_CONDITIONS:
240+
scores = by_ablation.get(ablation, [])
241+
if not scores:
242+
continue
243+
mean = statistics.mean(scores)
244+
d = compute_effect_size(scores, full_scores) if full_scores else 0.0
245+
_, p = welch_t_test(scores, full_scores) if full_scores else (0.0, 1.0)
246+
summary[ablation] = {
247+
"n": len(scores),
248+
"mean": round(mean, 4),
249+
"drop_from_full": round(full_mean - mean, 4),
250+
"cohens_d": round(d, 4),
251+
"p_value": round(p, 6),
252+
"significant": p < 0.05,
253+
}
254+
255+
return {
256+
"ablation_summary": summary,
257+
"raw_results": [
258+
{
259+
"problem_id": r.problem_id,
260+
"ablation": r.ablation,
261+
"composite": round(r.composite, 4),
262+
"dimensions": {k: round(v, 4) for k, v in r.dimensions.items()},
263+
"response_length": r.response_length,
264+
"latency_ms": r.latency_ms,
265+
}
266+
for r in results
267+
],
268+
}
269+
270+
271+
def run_ablation_study(
272+
output_dir: Optional[str] = None,
273+
verbose: bool = True,
274+
) -> dict:
275+
"""Run the full ablation study and save results."""
276+
logging.basicConfig(level=logging.INFO if verbose else logging.WARNING,
277+
format="%(message)s")
278+
279+
if output_dir is None:
280+
output_dir = str(_PROJECT_ROOT / "benchmarks" / "results")
281+
os.makedirs(output_dir, exist_ok=True)
282+
283+
problems = get_benchmark_problems()
284+
if verbose:
285+
logger.info(f"Ablation study: {len(problems)} problems x {len(ABLATION_CONDITIONS)} conditions")
286+
287+
runner = AblationRunner(verbose=verbose)
288+
results = runner.run(problems)
289+
report = runner.print_report(results)
290+
json_data = runner.to_json(results)
291+
292+
json_path = os.path.join(output_dir, "ablation_results.json")
293+
with open(json_path, "w", encoding="utf-8") as f:
294+
json.dump(json_data, f, indent=2)
295+
296+
if verbose:
297+
logger.info(f"Ablation results saved: {json_path}")
298+
299+
return json_data
300+
301+
302+
if __name__ == "__main__":
303+
import argparse
304+
parser = argparse.ArgumentParser(description="Codette Ablation Study")
305+
parser.add_argument("--output", default=None, help="Output directory")
306+
parser.add_argument("--quiet", action="store_true", help="Suppress progress output")
307+
args = parser.parse_args()
308+
309+
run_ablation_study(output_dir=args.output, verbose=not args.quiet)

inference/codette_server.py

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,20 @@ def _get_orchestrator():
344344

345345
backend = os.environ.get("CODETTE_BACKEND", "llama_cpp").lower()
346346

347+
# Validate model path exists before attempting load — prevents silent hang
348+
if backend != "ollama":
349+
from runtime_env import resolve_model_path
350+
_model_path = resolve_model_path()
351+
if not _model_path.exists():
352+
_load_error = (
353+
f"Model file not found: {_model_path}\n"
354+
f"Set CODETTE_MODEL_PATH env var or place the GGUF at that location."
355+
)
356+
with _orchestrator_status_lock:
357+
_orchestrator_status.update({"state": "error", "message": _load_error})
358+
print(f" ERROR: {_load_error}")
359+
return None
360+
347361
with _orchestrator_status_lock:
348362
_orchestrator_status.update({"state": "loading", "message": f"Loading Codette model ({backend})..."})
349363
print(f"\n Loading CodetteOrchestrator (backend: {backend})...")
@@ -359,13 +373,23 @@ def _get_orchestrator():
359373
)
360374
else:
361375
from codette_orchestrator import CodetteOrchestrator
362-
# Challenge 2 fix: use 32768 context (model trained on 131072,
363-
# 32k is a safe balance of capability vs VRAM on consumer GPU)
364-
_orchestrator = CodetteOrchestrator(
365-
verbose=True,
366-
n_ctx=32768,
367-
memory_weighting=memory_weighting,
368-
)
376+
import concurrent.futures as _cf
377+
# Load with a 5-minute timeout — llama_cpp can hang indefinitely
378+
# on corrupted GGUF files without raising an exception.
379+
with _cf.ThreadPoolExecutor(max_workers=1) as _pool:
380+
_fut = _pool.submit(
381+
CodetteOrchestrator,
382+
verbose=True,
383+
n_ctx=32768,
384+
memory_weighting=memory_weighting,
385+
)
386+
try:
387+
_orchestrator = _fut.result(timeout=300)
388+
except _cf.TimeoutError:
389+
raise RuntimeError(
390+
"Model load timed out after 5 minutes — "
391+
"the GGUF may be corrupted or the system is out of memory."
392+
)
369393

370394
with _orchestrator_status_lock:
371395
_orchestrator_status.update({
@@ -1024,6 +1048,10 @@ def _worker_thread():
10241048
try:
10251049
print(f" [WORKER] Processing query: {query[:60]}...", flush=True)
10261050

1051+
# Capture session once for this request to avoid race with /api/new_session
1052+
# swapping the global between our multiple _get_active_session() calls.
1053+
session = _get_active_session()
1054+
10271055
# ── Identity Recognition ──
10281056
# Recognize WHO is talking and inject relationship context
10291057
identity_context = ""
@@ -1095,7 +1123,6 @@ def _worker_thread():
10951123

10961124
# Recent session context first — keeps local continuity stable
10971125
try:
1098-
session = _get_active_session()
10991126
if session:
11001127
continuity_summary = session.active_continuity_summary or session.refresh_active_continuity_summary()
11011128
if continuity_summary:
@@ -1381,7 +1408,6 @@ def _worker_thread():
13811408

13821409
# Update session with response data (drives cocoon metrics UI)
13831410
epistemic = None
1384-
session = _get_active_session()
13851411
if session:
13861412
try:
13871413
# Add user message + assistant response to session history
@@ -1569,7 +1595,6 @@ def _worker_thread():
15691595
response_data["perspectives"] = perspectives
15701596

15711597
# Cocoon state — send full session state for UI metrics panel
1572-
session = _get_active_session()
15731598
if session:
15741599
try:
15751600
session_state = session.get_state()

0 commit comments

Comments
 (0)