Skip to content

Commit f778aa6

Browse files
DeepfreezechillBrian KrafftCopilot
authored
fix: batch hydration, mypy, and final P3 fixes (#28)
- Rewrite _hydrate_records() from O(N) per-record to O(1) batched queries - Add TagSearch.get_tags_batch() for bulk tag retrieval - Add AnalysisStore.batch_load_recent_analyses() with ROW_NUMBER window fn - Refactor _to_analysis() to accept preloaded judgments (backward compat) - Fix 3 mypy errors: assert lastrowid, List[Any] params typing - Fix hydration regression: get_versions/get_ancestry/load_by_category - Add _hydrate_record()/_hydrate_records() facade helpers - Fix check_same_thread via _make_connection(), max_depth clamp to 50 - Fix docstring drift, MigrationManager doc references - 4 new hydration regression tests in test_facade_coverage.py /8eyes R3: SHIP 0.95 (Opus 4.6) — 5/5 outcomes pass, 4 attacks repelled /collab R3: SHIP 0.93 (GPT-5.4) — 5/5 checks pass, 4 attacks repelled Suite: 1,356 passed, 31 skipped, 44 warnings (all P4 file-size) Co-authored-by: Brian Krafft <bkrafft@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 12bfba3 commit f778aa6

6 files changed

Lines changed: 580 additions & 27 deletions

File tree

openspace/skill_engine/analysis_store.py

Lines changed: 79 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from datetime import datetime
2323
from functools import wraps
2424
from pathlib import Path
25-
from typing import Any, Dict, Generator, List, Optional
25+
from typing import Any, Callable, Dict, Generator, List, Optional
2626

2727
from openspace.utils.logging import Logger
2828

@@ -43,12 +43,12 @@ def _db_retry(
4343
max_retries: int = 5,
4444
initial_delay: float = 0.1,
4545
backoff: float = 2.0,
46-
):
46+
) -> Callable:
4747
"""Retry on transient SQLite errors with exponential backoff."""
4848

49-
def decorator(func):
49+
def decorator(func: Callable) -> Callable:
5050
@wraps(func)
51-
def wrapper(*args, **kwargs):
51+
def wrapper(*args: Any, **kwargs: Any) -> Any:
5252
delay = initial_delay
5353
for attempt in range(max_retries):
5454
try:
@@ -78,7 +78,7 @@ class AnalysisStore:
7878
Usage (standalone)::
7979
8080
store = AnalysisStore(db_path=Path("analyses.db"))
81-
store.record_execution_analysis(analysis)
81+
store.record_execution_analysis_sync(analysis)
8282
recent = store.load_analyses(skill_id="some_skill")
8383
store.close()
8484
@@ -314,9 +314,70 @@ def hydrate_recent_analyses(self, record: SkillRecord) -> SkillRecord:
314314
last_updated=record.last_updated,
315315
recent_analyses=recent_analyses, # This is what we're hydrating
316316
tool_dependencies=record.tool_dependencies,
317+
critical_tools=record.critical_tools, # Fix: preserve critical_tools
317318
tags=record.tags,
318319
)
319320

321+
def batch_load_recent_analyses(
322+
self, skill_ids: List[str], limit: int = 5
323+
) -> Dict[str, List[ExecutionAnalysis]]:
324+
"""Batch-load recent analyses for multiple skills.
325+
326+
Uses window functions to get top-N per skill in 2 queries total
327+
(analyses + judgments), avoiding O(N*M) fan-out.
328+
"""
329+
if not skill_ids:
330+
return {}
331+
placeholders = ",".join("?" * len(skill_ids))
332+
with self._reader() as conn:
333+
# 1 query: ranked analyses per skill (DISTINCT via window over unique ea.id per skill)
334+
rows = conn.execute(
335+
f"SELECT sub.* FROM ("
336+
f" SELECT ea.*, sj.skill_id AS source_skill_id, "
337+
f" ROW_NUMBER() OVER ("
338+
f" PARTITION BY sj.skill_id ORDER BY ea.timestamp DESC"
339+
f" ) AS rn "
340+
f" FROM execution_analyses ea "
341+
f" JOIN skill_judgments sj ON ea.id = sj.analysis_id "
342+
f" WHERE sj.skill_id IN ({placeholders})"
343+
f") sub WHERE sub.rn <= ?",
344+
[*skill_ids, limit],
345+
).fetchall()
346+
347+
# Collect analysis IDs and group by skill
348+
filtered: Dict[str, list] = {}
349+
analysis_ids: set = set()
350+
for row in rows:
351+
sid = row["source_skill_id"]
352+
filtered.setdefault(sid, []).append(row)
353+
analysis_ids.add(row["id"])
354+
355+
if not analysis_ids:
356+
return {}
357+
358+
# 1 query: all judgments for matched analyses
359+
aid_placeholders = ",".join("?" * len(analysis_ids))
360+
judgment_rows = conn.execute(
361+
f"SELECT analysis_id, skill_id, skill_applied, note "
362+
f"FROM skill_judgments WHERE analysis_id IN ({aid_placeholders})",
363+
list(analysis_ids),
364+
).fetchall()
365+
366+
judgments_by_analysis: Dict[str, list] = {}
367+
for jr in judgment_rows:
368+
judgments_by_analysis.setdefault(jr["analysis_id"], []).append(jr)
369+
370+
# Deserialize with preloaded judgments
371+
result: Dict[str, List[ExecutionAnalysis]] = {}
372+
for sid, ea_rows in filtered.items():
373+
result[sid] = [
374+
self._to_analysis(
375+
conn, row, judgments_by_analysis.get(row["id"], [])
376+
)
377+
for row in ea_rows
378+
]
379+
return result
380+
320381
# ── Bulk Operations ────────────────────────────────────────────────
321382

322383
@_db_retry()
@@ -495,6 +556,7 @@ def insert_analysis(self, a: ExecutionAnalysis) -> int:
495556
),
496557
)
497558
analysis_id = cur.lastrowid
559+
assert analysis_id is not None, "INSERT must set lastrowid"
498560

499561
for j in a.skill_judgments:
500562
self._conn.execute(
@@ -505,14 +567,21 @@ def insert_analysis(self, a: ExecutionAnalysis) -> int:
505567
return analysis_id
506568

507569
@staticmethod
508-
def _to_analysis(conn: sqlite3.Connection, row: sqlite3.Row) -> ExecutionAnalysis:
570+
def _to_analysis(
571+
conn: sqlite3.Connection,
572+
row: sqlite3.Row,
573+
preloaded_judgments: Optional[List[sqlite3.Row]] = None,
574+
) -> ExecutionAnalysis:
509575
"""Deserialize an execution_analyses row + judgments → ExecutionAnalysis."""
510576
analysis_id = row["id"]
511577

512-
judgment_rows = conn.execute(
513-
"SELECT skill_id, skill_applied, note FROM skill_judgments WHERE analysis_id=?",
514-
(analysis_id,),
515-
).fetchall()
578+
if preloaded_judgments is not None:
579+
judgment_rows = preloaded_judgments
580+
else:
581+
judgment_rows = conn.execute(
582+
"SELECT skill_id, skill_applied, note FROM skill_judgments WHERE analysis_id=?",
583+
(analysis_id,),
584+
).fetchall()
516585

517586
suggestions: list[EvolutionSuggestion] = []
518587
raw_suggestions = row["evolution_suggestions"]

openspace/skill_engine/lineage_tracker.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -116,10 +116,7 @@ def __init__(
116116
if db_path is None:
117117
raise ValueError("Either db_path or conn must be provided")
118118
self._db_path = Path(db_path)
119-
self._conn = sqlite3.connect(str(db_path), timeout=30)
120-
self._conn.row_factory = sqlite3.Row
121-
self._conn.execute("PRAGMA journal_mode=WAL")
122-
self._conn.execute("PRAGMA busy_timeout=30000")
119+
self._conn = self._make_connection(read_only=False)
123120
# Standalone mode: ensure schema exists
124121
mm = MigrationManager(conn=self._conn, lock=self._mu)
125122
mm.initialize_schema()
@@ -480,6 +477,7 @@ def get_ancestors(
480477
List of ancestor :class:`SkillRecord` objects, sorted by
481478
generation (nearest first).
482479
"""
480+
max_depth = min(max_depth, 50) # Safety clamp to prevent DoS
483481
with self._reader() as conn:
484482
visited: set[str] = {skill_id} # Fix 1: Seed with starting skill_id to prevent cycles
485483
ancestors: List[SkillRecord] = []
@@ -551,6 +549,7 @@ def get_lineage_tree(
551549
Returns:
552550
Nested dict representing the lineage tree.
553551
"""
552+
max_depth = min(max_depth, 50) # Safety clamp to prevent DoS
554553
with self._reader() as conn:
555554
return self._subtree(conn, skill_id, max_depth, set())
556555

openspace/skill_engine/migration_manager.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ class MigrationManager:
171171
172172
# SkillStore creates us internally:
173173
# store._migrations = MigrationManager(conn=store._conn, lock=store._mu)
174-
# SkillStore calls manager.initialize_schema() during __init__
174+
# SkillStore calls manager.ensure_current_schema() during __init__
175175
"""
176176

177177
def __init__(

openspace/skill_engine/store.py

Lines changed: 87 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from __future__ import annotations
3030

3131
import asyncio
32+
import dataclasses
3233
import json
3334
import sqlite3
3435
import threading
@@ -681,14 +682,15 @@ def get_versions(self, name: str) -> List[SkillRecord]:
681682
682683
Delegates to :class:`LineageTracker` (Epic 3.3).
683684
"""
684-
# Fix 5: Hydrate recent_analyses after delegation
685+
# Fix: Fully hydrate records after delegation (tags, tool_deps, critical_tools, recent_analyses)
685686
records = self._lineage.get_evolution_chain(name)
686-
return [self._hydrate_recent_analyses(record) for record in records]
687+
return self._hydrate_records(records)
687688

688689
@_db_retry()
689690
def load_by_category(self, category: SkillCategory, *, active_only: bool = True) -> List[SkillRecord]:
690691
"""Delegate to SkillRepository for category queries."""
691-
return self._repo.load_by_category(category, active_only=active_only)
692+
records = self._repo.load_by_category(category, active_only=active_only)
693+
return self._hydrate_records(records)
692694

693695
@_db_retry()
694696
def load_analyses(
@@ -826,9 +828,9 @@ def get_ancestry(self, skill_id: str, max_depth: int = 10) -> List[SkillRecord]:
826828
827829
Delegates to :class:`LineageTracker` (Epic 3.3).
828830
"""
829-
# Fix 5: Hydrate recent_analyses after delegation
831+
# Fix: Fully hydrate records after delegation (tags, tool_deps, critical_tools, recent_analyses)
830832
records = self._lineage.get_ancestors(skill_id, max_depth=max_depth)
831-
return [self._hydrate_recent_analyses(record) for record in records]
833+
return self._hydrate_records(records)
832834

833835
@_db_retry()
834836
def get_lineage_tree(self, skill_id: str, max_depth: int = 5) -> Dict[str, Any]:
@@ -992,6 +994,82 @@ def _hydrate_recent_analyses(self, record: SkillRecord) -> SkillRecord:
992994
"""Hydrate recent_analyses for a SkillRecord from AnalysisStore delegation."""
993995
return self._analyses.hydrate_recent_analyses(record)
994996

997+
def _hydrate_record(self, record: SkillRecord) -> SkillRecord:
998+
"""Fully hydrate a SkillRecord with tags, tool_deps, critical_tools, and recent_analyses.
999+
1000+
Used by facade methods that delegate to modules returning partially hydrated records.
1001+
"""
1002+
with self._reader() as conn:
1003+
# Hydrate tool dependencies and critical tools
1004+
dep_rows = conn.execute(
1005+
"SELECT tool_key, critical FROM skill_tool_deps WHERE skill_id=?",
1006+
(record.skill_id,),
1007+
).fetchall()
1008+
1009+
tool_dependencies = [r["tool_key"] for r in dep_rows]
1010+
critical_tools = [r["tool_key"] for r in dep_rows if r["critical"]]
1011+
1012+
# Hydrate tags using TagSearch
1013+
tags = self._tag_search.get_tags(record.skill_id)
1014+
1015+
# Hydrate recent_analyses using AnalysisStore
1016+
hydrated_record = self._analyses.hydrate_recent_analyses(record)
1017+
1018+
# Return a new record with all fields hydrated
1019+
return dataclasses.replace(
1020+
hydrated_record,
1021+
tags=tags,
1022+
tool_dependencies=tool_dependencies,
1023+
critical_tools=critical_tools,
1024+
)
1025+
1026+
def _hydrate_records(self, records: List[SkillRecord]) -> List[SkillRecord]:
1027+
"""Batch hydrate multiple SkillRecords — O(1) queries instead of O(N).
1028+
1029+
Batches tag, tool_dep, and analysis queries across all records.
1030+
"""
1031+
if not records:
1032+
return records
1033+
1034+
skill_ids = [r.skill_id for r in records]
1035+
placeholders = ",".join("?" * len(skill_ids))
1036+
1037+
with self._reader() as conn:
1038+
# Batch 1: tool_deps — 1 query
1039+
dep_rows = conn.execute(
1040+
f"SELECT skill_id, tool_key, critical FROM skill_tool_deps "
1041+
f"WHERE skill_id IN ({placeholders})",
1042+
skill_ids,
1043+
).fetchall()
1044+
1045+
deps_by_skill: Dict[str, List[str]] = {}
1046+
critical_by_skill: Dict[str, List[str]] = {}
1047+
for row in dep_rows:
1048+
sid = row["skill_id"]
1049+
deps_by_skill.setdefault(sid, []).append(row["tool_key"])
1050+
if row["critical"]:
1051+
critical_by_skill.setdefault(sid, []).append(row["tool_key"])
1052+
1053+
# Batch 2: tags — 1 query (via TagSearch)
1054+
tags_by_skill = self._tag_search.get_tags_batch(skill_ids)
1055+
1056+
# Batch 3: analyses — 2 queries (via AnalysisStore)
1057+
analyses_by_skill = self._analyses.batch_load_recent_analyses(
1058+
skill_ids, SkillRecord.MAX_RECENT
1059+
)
1060+
1061+
# Assemble
1062+
return [
1063+
dataclasses.replace(
1064+
record,
1065+
tags=tags_by_skill.get(record.skill_id, []),
1066+
tool_dependencies=deps_by_skill.get(record.skill_id, []),
1067+
critical_tools=critical_by_skill.get(record.skill_id, []),
1068+
recent_analyses=analyses_by_skill.get(record.skill_id, []),
1069+
)
1070+
for record in records
1071+
]
1072+
9951073
# Deserialization
9961074
def _to_record(self, conn: sqlite3.Connection, row: sqlite3.Row) -> SkillRecord:
9971075
"""Deserialize a skill_records row + related rows → SkillRecord."""
@@ -1101,6 +1179,10 @@ def get_tags(self, skill_id: str) -> List[str]:
11011179
"""Get all tags for a skill (facade to TagSearch)."""
11021180
return self._tag_search.get_tags(skill_id)
11031181

1182+
def get_tags_batch(self, skill_ids: List[str]) -> Dict[str, List[str]]:
1183+
"""Batch-get tags for multiple skills (facade to TagSearch)."""
1184+
return self._tag_search.get_tags_batch(skill_ids)
1185+
11041186
def get_all_tags(self) -> List[Dict[str, Any]]:
11051187
"""Get all tags with usage counts (facade to TagSearch)."""
11061188
return self._tag_search.get_all_tags()

openspace/skill_engine/tag_search.py

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from contextlib import contextmanager
1919
from functools import wraps
2020
from pathlib import Path
21-
from typing import Any, Dict, Generator, List, Optional
21+
from typing import Any, Callable, Dict, Generator, List, Optional
2222

2323
from openspace.utils.logging import Logger
2424

@@ -32,12 +32,12 @@ def _db_retry(
3232
max_retries: int = 5,
3333
initial_delay: float = 0.1,
3434
backoff: float = 2.0,
35-
):
35+
) -> Callable:
3636
"""Retry on transient SQLite errors with exponential backoff."""
3737

38-
def decorator(func):
38+
def decorator(func: Callable) -> Callable:
3939
@wraps(func)
40-
def wrapper(*args, **kwargs):
40+
def wrapper(*args: Any, **kwargs: Any) -> Any:
4141
delay = initial_delay
4242
for attempt in range(max_retries):
4343
try:
@@ -221,6 +221,23 @@ def get_tags(self, skill_id: str) -> List[str]:
221221
).fetchall()
222222
return [r["tag"] for r in rows]
223223

224+
@_db_retry()
225+
def get_tags_batch(self, skill_ids: List[str]) -> Dict[str, List[str]]:
226+
"""Batch-get tags for multiple skills. Returns {skill_id: [sorted tags]}."""
227+
if not skill_ids:
228+
return {}
229+
placeholders = ",".join("?" * len(skill_ids))
230+
with self._reader() as conn:
231+
rows = conn.execute(
232+
f"SELECT skill_id, tag FROM skill_tags "
233+
f"WHERE skill_id IN ({placeholders}) ORDER BY skill_id, tag",
234+
skill_ids,
235+
).fetchall()
236+
result: Dict[str, List[str]] = {}
237+
for row in rows:
238+
result.setdefault(row["skill_id"], []).append(row["tag"])
239+
return result
240+
224241
@_db_retry()
225242
def find_skills_by_tags(
226243
self,
@@ -448,8 +465,8 @@ def search_skills(
448465
List of skill records matching the criteria.
449466
"""
450467
with self._reader() as conn:
451-
conditions = []
452-
params = []
468+
conditions: List[str] = []
469+
params: List[Any] = []
453470

454471
# Active filter
455472
if active_only:

0 commit comments

Comments
 (0)