From b052c1f3ee4df93f0480cf8654998c446d3afbf4 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Tue, 7 Jul 2026 12:36:36 -0400 Subject: [PATCH] =?UTF-8?q?feat(harness):=20store.py=20=E2=80=94=20Store?= =?UTF-8?q?=20=E7=89=88=E6=9C=AC=E6=93=8D=E4=BD=9C=20+=20Seed=20=E7=AE=A1?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 从 TRM4 core/workspace.py 拆出 Store + Seed 相关函数: - _parse_version / list_versions / next_version / advance_version - _write_meta / init_store - init_seed / list_seeds / read_seed - extract_run_db(保留原始 CREATE 语句重建主键约束) - promote_to_seed(强校验版本一致 + 非 NULL + finally 清理) 26 个测试全部通过,radon 复杂度 A (2.83)。 Co-Authored-By: Claude Opus 4.6 (1M context) --- app/harness/store.py | 378 +++++++++++++++++++++++++ tests/unit/test_harness_store.py | 460 +++++++++++++++++++++++++++++++ 2 files changed, 838 insertions(+) create mode 100644 app/harness/store.py create mode 100644 tests/unit/test_harness_store.py diff --git a/app/harness/store.py b/app/harness/store.py new file mode 100644 index 0000000..49509d9 --- /dev/null +++ b/app/harness/store.py @@ -0,0 +1,378 @@ +"""Store 版本操作 + Seed 管理。 + +Store 存储版本化资源(视频、题目、Skill、Prompt), +通过版本号(v1, v2, ...)管理资源的演化历史。 +Seed 是可复现的实验起点,包含权重快照 + baseline 数据库。 +""" + +from __future__ import annotations + +import json +import re +import shutil +import sqlite3 +from datetime import UTC, datetime +from typing import TYPE_CHECKING + +from loguru import logger + +if TYPE_CHECKING: + from pathlib import Path + + +def _now_iso() -> str: + """返回当前 UTC 时间的 ISO 格式字符串。""" + return datetime.now(UTC).isoformat() + + +def _parse_version(name: str) -> int: + """解析版本目录名 ``v\\d+`` 为整数。 + + 参数: + name: 版本目录名,如 ``"v1"``、``"v10"``。 + + 返回: + 版本号整数。 + + 异常: + ValueError: 版本目录名格式不合法(不匹配 ``v\\d+``)。 + """ + match = re.match(r"v(\d+)$", name) + if not match: + raise ValueError(f"无效版本号: {name}") + return int(match.group(1)) + + +def list_versions(store_dir: Path, resource_type: str) -> list[str]: + """列出 Store 中某类资源的所有版本号,按数字值排序。 + + 按数字排序保证 v10 排在 v2 后面(而非字典序 v10 < v2)。 + + 参数: + store_dir: Store 根目录。 + resource_type: 资源类型路径,如 ``"skills"``、``"questions/generated"``。 + + 返回: + 排序后的版本号列表,如 ``["v1", "v2", "v10"]``。 + """ + resource_dir = store_dir / resource_type + if not resource_dir.is_dir(): + return [] + versions = [] + for entry in resource_dir.iterdir(): + if entry.is_dir() and re.match(r"v\d+$", entry.name): + versions.append(entry.name) + return sorted(versions, key=_parse_version) + + +def next_version(store_dir: Path, resource_type: str) -> str: + """返回某类资源的下一个可用版本号。 + + 参数: + store_dir: Store 根目录。 + resource_type: 资源类型路径。 + + 返回: + 下一个版本号字符串,如 ``"v3"``。 + """ + versions = list_versions(store_dir, resource_type) + if not versions: + return "v1" + latest = _parse_version(versions[-1]) + return f"v{latest + 1}" + + +def _write_meta(target_dir: Path, version: str, source: str, **extra: str | None) -> None: + """写入版本元数据文件 ``meta.json``。 + + 参数: + target_dir: 版本目录。 + version: 版本号。 + source: 来源标识(``"manual"`` / ``"evolution"`` / ``"auto-gen"``)。 + **extra: 额外字段(parent, trigger_run, trigger_workspace, description)。 + """ + meta = { + "version": version, + "created_at": _now_iso(), + "parent": extra.get("parent"), + "source": source, + "trigger_run": extra.get("trigger_run"), + "trigger_workspace": extra.get("trigger_workspace"), + "description": extra.get("description", ""), + } + (target_dir / "meta.json").write_text(json.dumps(meta, ensure_ascii=False, indent=2)) + + +def advance_version( + store_dir: Path, + resource_type: str, + source_dir: Path, + meta: dict, +) -> str: + """将 source_dir 的内容写入 Store 的下一个版本目录,写入 meta.json。 + + 参数: + store_dir: Store 根目录。 + resource_type: 资源类型路径,如 ``"skills"``、``"questions/generated"``。 + source_dir: 包含新版本资源文件的源目录。 + meta: 元数据字典,至少包含 ``source`` 字段。 + + 返回: + 新版本号字符串,如 ``"v2"``。 + """ + version = next_version(store_dir, resource_type) + target = store_dir / resource_type / version + shutil.copytree(source_dir, target) + _write_meta( + target, + version, + meta.get("source", "manual"), + parent=meta.get("parent"), + trigger_run=meta.get("trigger_run"), + trigger_workspace=meta.get("trigger_workspace"), + description=meta.get("description", ""), + ) + logger.info("Store 版本推进: {}/{}", resource_type, version) + return version + + +def init_store( + store_dir: Path, + videos_source: Path, + skills_dir: Path, + prompts_dir: Path, +) -> None: + """初始化 Store:拷贝视频数据,创建 skills/v1、prompts/v1 和 questions 目录。 + + 参数: + store_dir: Store 目标路径(不得已存在)。 + videos_source: 视频数据源目录。 + skills_dir: 初始 Skill 文件目录。 + prompts_dir: 初始 Prompt 文件目录。 + + 异常: + FileExistsError: Store 目录已存在。 + """ + if store_dir.exists(): + raise FileExistsError(f"Store 已存在: {store_dir}") + store_dir.mkdir(parents=True) + shutil.copytree(videos_source, store_dir / "videos") + (store_dir / "questions" / "benchmarks").mkdir(parents=True) + (store_dir / "questions" / "generated").mkdir(parents=True) + shutil.copytree(skills_dir, store_dir / "skills" / "v1") + _write_meta( + store_dir / "skills" / "v1", + "v1", + "manual", + description="手工创建的初始版本", + ) + shutil.copytree(prompts_dir, store_dir / "prompts" / "v1") + _write_meta( + store_dir / "prompts" / "v1", + "v1", + "manual", + description="手工创建的初始版本", + ) + logger.info("Store 初始化完成: {}", store_dir) + + +# --------------------------------------------------------------------------- +# 种子库(Seed)函数 +# --------------------------------------------------------------------------- + + +def init_seed( + store_dir: Path, + name: str, + skills_dir: Path, + prompts_dir: Path, + baseline_db: Path, + baseline_run_id: str, + parent: str | None, + description: str, +) -> Path: + """在 store/seeds/ 写一个种子:权重 + baseline.db + seed.json。 + + 参数: + store_dir: Store 根目录。 + name: 种子名(如 ``'initial'``、``'from-evolve-v20'``)。 + skills_dir: 该版本 Skill 权重源目录。 + prompts_dir: 该版本 Prompt 权重源目录。 + baseline_db: 该版本全量记录 db(含 _runs + predictions 行)。 + baseline_run_id: 全量记录的 run_id,fresh 时注入 build_pools。 + parent: 来源(initial 为 None)。 + description: 人类可读说明。 + + 返回: + 种子目录路径。 + + 异常: + FileExistsError: 同名种子已存在(不覆盖)。 + """ + seed_dir = store_dir / "seeds" / name + if seed_dir.exists(): + raise FileExistsError(f"种子已存在,不覆盖: {seed_dir}") + seed_dir.mkdir(parents=True) + shutil.copytree(skills_dir, seed_dir / "skills") + shutil.copytree(prompts_dir, seed_dir / "prompts") + shutil.copy2(baseline_db, seed_dir / "baseline.db") + (seed_dir / "seed.json").write_text( + json.dumps( + { + "baseline_run_id": baseline_run_id, + "parent": parent, + "created_at": _now_iso(), + "description": description, + }, + ensure_ascii=False, + indent=2, + ) + ) + logger.info("种子创建完成: {}", seed_dir) + return seed_dir + + +def list_seeds(store_dir: Path) -> list[str]: + """列出 store/seeds 下所有种子名(按名排序)。 + + 参数: + store_dir: Store 根目录。 + + 返回: + 种子名列表(仅含 seed.json 存在的目录),按名排序。 + """ + seeds_root = store_dir / "seeds" + if not seeds_root.is_dir(): + return [] + return sorted(e.name for e in seeds_root.iterdir() if (e / "seed.json").exists()) + + +def read_seed(store_dir: Path, name: str) -> dict: + """读取种子 seed.json;不存在则报错。 + + 参数: + store_dir: Store 根目录。 + name: 种子名。 + + 返回: + seed.json 解析后的字典。 + + 异常: + FileNotFoundError: 该种子不存在。 + """ + seed_json = store_dir / "seeds" / name / "seed.json" + if not seed_json.exists(): + raise FileNotFoundError(f"种子不存在: {name}({seed_json})") + return json.loads(seed_json.read_text()) + + +def extract_run_db(src_db: Path, dst_db: Path, run_id: str) -> None: + """从 src_db 抽出某 run_id 的 _runs + predictions 行,写一个最小 db(种子 baseline.db)。 + + 用源表的**原始 CREATE 语句**重建目标表,保留主键/列类型/约束—— + ``_runs.run_id TEXT PRIMARY KEY`` 是 HarnessLog ``INSERT OR IGNORE`` 去重的依据, + 若 seed db 丢主键则续训/fresh-bootstrap 的去重失效。 + + 参数: + src_db: 源 harness.db。 + dst_db: 目标 db(不得已存在)。 + run_id: 要抽取的 run。 + + 异常: + RuntimeError: 源中无该表或无该 run 的行。 + """ + src = sqlite3.connect(src_db) + dst = sqlite3.connect(dst_db) + try: + for table in ("_runs", "predictions"): + create_sql = src.execute( + "SELECT sql FROM sqlite_master WHERE type='table' AND name=?", + (table,), + ).fetchone() + if create_sql is None or create_sql[0] is None: + raise RuntimeError(f"源 db 无表 {table}") + dst.execute(create_sql[0]) + cols = [r[1] for r in src.execute(f"PRAGMA table_info({table})")] + col_sql = ", ".join(cols) + rows = src.execute( + f"SELECT {col_sql} FROM {table} WHERE run_id=?", (run_id,) + ).fetchall() + if not rows: + raise RuntimeError(f"{table} 中无 run_id={run_id} 的行") + ph = ", ".join("?" * len(cols)) + dst.executemany(f"INSERT INTO {table} ({col_sql}) VALUES ({ph})", rows) + dst.commit() + finally: + dst.close() + src.close() + + +def promote_to_seed( + workspace_dir: Path, + store_dir: Path, + version: str, + eval_run_id: str, + name: str, + description: str, +) -> Path: + """把 workspace 的指定版本 + 配套 prompts + 指定 eval run 全量记录固化成新种子。 + + 强校验 eval_run_id 对应的 _runs 行中 skills_version 必须与 version 一致, + 且 skills_version/prompts_version 均不得为 NULL。 + + 参数: + workspace_dir: 来源 workspace。 + store_dir: Store 根目录。 + version: skills 版本号。 + eval_run_id: canonical eval run(其 _runs 行提供配套 prompts 版本与全量记录)。 + name: 新种子名(冲突报错不覆盖)。 + description: 说明。 + + 返回: + 新种子目录。 + + 异常: + ValueError: eval_run_id 不存在,或其 skills_version 与 version 不符,或版本为 NULL。 + FileExistsError: 同名种子已存在(由 init_seed 抛出)。 + """ + con = sqlite3.connect(workspace_dir / "harness.db") + con.row_factory = sqlite3.Row + try: + row = con.execute( + "SELECT skills_version, prompts_version FROM _runs WHERE run_id=?", + (eval_run_id,), + ).fetchone() + finally: + con.close() + + if row is None: + raise ValueError(f"eval run 不存在: {eval_run_id}") + + skills_v, prompts_v = row["skills_version"], row["prompts_version"] + + # 强校验——eval run 的版本必须与 --version 一致,且不得为 NULL + if skills_v is None or prompts_v is None: + raise ValueError(f"eval run {eval_run_id} 的 _runs 版本对为 NULL(未回填?),无法 promote") + if skills_v != version: + raise ValueError(f"eval run {eval_run_id} 的版本 {skills_v} 与 --version {version} 不符") + + tmp_db = workspace_dir / "_promote_tmp.db" + if tmp_db.exists(): + tmp_db.unlink() + extract_run_db(workspace_dir / "harness.db", tmp_db, eval_run_id) + try: + seed_dir = init_seed( + store_dir, + name, + workspace_dir / "skills" / skills_v, + workspace_dir / "prompts" / prompts_v, + tmp_db, + baseline_run_id=eval_run_id, + parent=f"{workspace_dir.name}:{version}", + description=description, + ) + finally: + tmp_db.unlink() + + logger.info("Promote 完成: {} -> {}", workspace_dir.name, seed_dir) + return seed_dir diff --git a/tests/unit/test_harness_store.py b/tests/unit/test_harness_store.py new file mode 100644 index 0000000..31ae713 --- /dev/null +++ b/tests/unit/test_harness_store.py @@ -0,0 +1,460 @@ +"""Store 版本操作 + Seed 管理的单元测试。""" + +from __future__ import annotations + +import json +import sqlite3 + +import pytest + +from app.harness.store import ( + _parse_version, + _write_meta, + advance_version, + extract_run_db, + init_seed, + init_store, + list_seeds, + list_versions, + next_version, + promote_to_seed, + read_seed, +) + + +# --------------------------------------------------------------------------- +# _parse_version +# --------------------------------------------------------------------------- + + +class TestParseVersion: + """_parse_version 解析 v\\d+ 格式版本号。""" + + def test_parse_version_normal(self) -> None: + assert _parse_version("v1") == 1 + assert _parse_version("v10") == 10 + assert _parse_version("v999") == 999 + + def test_parse_version_invalid(self) -> None: + with pytest.raises(ValueError, match="无效版本号"): + _parse_version("abc") + with pytest.raises(ValueError, match="无效版本号"): + _parse_version("v") + with pytest.raises(ValueError, match="无效版本号"): + _parse_version("v1.0") + + +# --------------------------------------------------------------------------- +# list_versions — 数字排序 +# --------------------------------------------------------------------------- + + +class TestListVersions: + """list_versions 按数字排序,v10 排在 v2 后。""" + + def test_list_versions_numeric_sort(self, tmp_path: "Path") -> None: + """v10 必须排在 v2 后面(非字典序)。""" + store = tmp_path / "store" + resource = store / "skills" + resource.mkdir(parents=True) + for v in ("v1", "v10", "v2", "v20", "v3"): + (resource / v).mkdir() + result = list_versions(store, "skills") + assert result == ["v1", "v2", "v3", "v10", "v20"] + + def test_list_versions_empty(self, tmp_path: "Path") -> None: + store = tmp_path / "store" + assert list_versions(store, "skills") == [] + + def test_list_versions_ignores_non_version_dirs(self, tmp_path: "Path") -> None: + """非 v\\d+ 格式的目录被忽略。""" + store = tmp_path / "store" + resource = store / "skills" + resource.mkdir(parents=True) + (resource / "v1").mkdir() + (resource / "backup").mkdir() + (resource / ".hidden").mkdir() + assert list_versions(store, "skills") == ["v1"] + + +# --------------------------------------------------------------------------- +# next_version +# --------------------------------------------------------------------------- + + +class TestNextVersion: + """next_version 返回下一个可用版本号。""" + + def test_next_version_empty(self, tmp_path: "Path") -> None: + store = tmp_path / "store" + assert next_version(store, "skills") == "v1" + + def test_next_version_after_existing(self, tmp_path: "Path") -> None: + store = tmp_path / "store" + resource = store / "skills" + resource.mkdir(parents=True) + (resource / "v1").mkdir() + (resource / "v2").mkdir() + assert next_version(store, "skills") == "v3" + + def test_next_version_with_gap(self, tmp_path: "Path") -> None: + """v1 和 v10 之间有 gap,next 应为 v11。""" + store = tmp_path / "store" + resource = store / "skills" + resource.mkdir(parents=True) + (resource / "v1").mkdir() + (resource / "v10").mkdir() + assert next_version(store, "skills") == "v11" + + +# --------------------------------------------------------------------------- +# advance_version +# --------------------------------------------------------------------------- + + +class TestAdvanceVersion: + """advance_version copytree + _write_meta。""" + + def test_advance_version(self, tmp_path: "Path") -> None: + store = tmp_path / "store" + resource = store / "skills" + resource.mkdir(parents=True) + (resource / "v1").mkdir() + + source = tmp_path / "new_skills" + source.mkdir() + (source / "skill_a.md").write_text("内容A") + + version = advance_version( + store, + "skills", + source, + {"source": "evolution", "description": "进化产出"}, + ) + assert version == "v2" + assert (resource / "v2" / "skill_a.md").read_text() == "内容A" + + meta = json.loads((resource / "v2" / "meta.json").read_text()) + assert meta["version"] == "v2" + assert meta["source"] == "evolution" + assert meta["description"] == "进化产出" + assert "created_at" in meta + + +# --------------------------------------------------------------------------- +# init_store +# --------------------------------------------------------------------------- + + +class TestInitStore: + """init_store 初始化 Store 目录结构。""" + + def test_init_store(self, tmp_path: "Path") -> None: + videos = tmp_path / "videos_src" + videos.mkdir() + (videos / "v001").mkdir() + (videos / "v001" / "tree.json").write_text("{}") + + skills = tmp_path / "skills_src" + skills.mkdir() + (skills / "search.md").write_text("skill") + + prompts = tmp_path / "prompts_src" + prompts.mkdir() + (prompts / "system.md").write_text("prompt") + + store = tmp_path / "store" + init_store(store, videos, skills, prompts) + + assert (store / "videos" / "v001" / "tree.json").exists() + assert (store / "questions" / "benchmarks").is_dir() + assert (store / "questions" / "generated").is_dir() + assert (store / "skills" / "v1" / "search.md").read_text() == "skill" + assert (store / "prompts" / "v1" / "system.md").read_text() == "prompt" + + skills_meta = json.loads( + (store / "skills" / "v1" / "meta.json").read_text() + ) + assert skills_meta["version"] == "v1" + assert skills_meta["source"] == "manual" + + def test_init_store_exists_raises(self, tmp_path: "Path") -> None: + store = tmp_path / "store" + store.mkdir() + with pytest.raises(FileExistsError, match="Store 已存在"): + init_store(store, tmp_path, tmp_path, tmp_path) + + +# --------------------------------------------------------------------------- +# Seed 相关 +# --------------------------------------------------------------------------- + + +def _make_seed_fixtures(tmp_path): + """创建 seed 测试所需的公共 fixture。""" + store = tmp_path / "store" + store.mkdir(parents=True) + + skills_dir = tmp_path / "sk" + skills_dir.mkdir() + (skills_dir / "search.md").write_text("skill") + + prompts_dir = tmp_path / "pr" + prompts_dir.mkdir() + (prompts_dir / "system.md").write_text("prompt") + + baseline_db = tmp_path / "base.db" + conn = sqlite3.connect(baseline_db) + conn.execute( + "CREATE TABLE _runs (run_id TEXT PRIMARY KEY, status TEXT)" + ) + conn.execute("INSERT INTO _runs VALUES ('r1', 'done')") + conn.execute( + "CREATE TABLE predictions (run_id TEXT, question_id TEXT, answer TEXT)" + ) + conn.execute("INSERT INTO predictions VALUES ('r1', 'q1', 'A')") + conn.commit() + conn.close() + + return store, skills_dir, prompts_dir, baseline_db + + +class TestInitSeed: + """init_seed 创建种子目录。""" + + def test_init_seed(self, tmp_path: "Path") -> None: + store, skills_dir, prompts_dir, baseline_db = _make_seed_fixtures(tmp_path) + seed_dir = init_seed( + store, + "initial", + skills_dir, + prompts_dir, + baseline_db, + "r1", + None, + "初始种子", + ) + assert seed_dir == store / "seeds" / "initial" + assert (seed_dir / "skills" / "search.md").exists() + assert (seed_dir / "prompts" / "system.md").exists() + assert (seed_dir / "baseline.db").exists() + + meta = json.loads((seed_dir / "seed.json").read_text()) + assert meta["baseline_run_id"] == "r1" + assert meta["parent"] is None + assert meta["description"] == "初始种子" + assert "created_at" in meta + + def test_init_seed_exists_raises(self, tmp_path: "Path") -> None: + store, skills_dir, prompts_dir, baseline_db = _make_seed_fixtures(tmp_path) + init_seed(store, "dup", skills_dir, prompts_dir, baseline_db, "r1", None, "first") + with pytest.raises(FileExistsError, match="种子已存在"): + init_seed( + store, "dup", skills_dir, prompts_dir, baseline_db, "r1", None, "second" + ) + + +class TestListSeeds: + """list_seeds 列出所有种子。""" + + def test_list_seeds(self, tmp_path: "Path") -> None: + store, skills_dir, prompts_dir, baseline_db = _make_seed_fixtures(tmp_path) + init_seed(store, "beta", skills_dir, prompts_dir, baseline_db, "r1", None, "b") + init_seed(store, "alpha", skills_dir, prompts_dir, baseline_db, "r1", None, "a") + assert list_seeds(store) == ["alpha", "beta"] + + def test_list_seeds_empty(self, tmp_path: "Path") -> None: + store = tmp_path / "store" + assert list_seeds(store) == [] + + +class TestReadSeed: + """read_seed 读取 seed.json。""" + + def test_read_seed(self, tmp_path: "Path") -> None: + store, skills_dir, prompts_dir, baseline_db = _make_seed_fixtures(tmp_path) + init_seed(store, "s1", skills_dir, prompts_dir, baseline_db, "r1", None, "desc") + meta = read_seed(store, "s1") + assert meta["baseline_run_id"] == "r1" + assert meta["description"] == "desc" + + def test_read_seed_not_found(self, tmp_path: "Path") -> None: + store = tmp_path / "store" + store.mkdir() + with pytest.raises(FileNotFoundError, match="种子不存在"): + read_seed(store, "no_such") + + +# --------------------------------------------------------------------------- +# extract_run_db +# --------------------------------------------------------------------------- + + +class TestExtractRunDb: + """extract_run_db 抽取指定 run 的行并保留 PK。""" + + def _make_src_db(self, path): + """创建带 _runs + predictions 表的源 db。""" + conn = sqlite3.connect(path) + conn.execute( + "CREATE TABLE _runs (run_id TEXT PRIMARY KEY, status TEXT)" + ) + conn.execute("INSERT INTO _runs VALUES ('r1', 'done')") + conn.execute("INSERT INTO _runs VALUES ('r2', 'done')") + conn.execute( + "CREATE TABLE predictions " + "(run_id TEXT, question_id TEXT, answer TEXT)" + ) + conn.execute("INSERT INTO predictions VALUES ('r1', 'q1', 'A')") + conn.execute("INSERT INTO predictions VALUES ('r1', 'q2', 'B')") + conn.execute("INSERT INTO predictions VALUES ('r2', 'q1', 'C')") + conn.commit() + conn.close() + + def test_extract_run_db_preserves_pk(self, tmp_path: "Path") -> None: + """原始 CREATE 保留主键约束。""" + src = tmp_path / "src.db" + dst = tmp_path / "dst.db" + self._make_src_db(src) + extract_run_db(src, dst, "r1") + + conn = sqlite3.connect(dst) + # 验证 _runs 表有 PK + create_sql = conn.execute( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='_runs'" + ).fetchone()[0] + assert "PRIMARY KEY" in create_sql + + # 验证只有 r1 的行 + runs = conn.execute("SELECT * FROM _runs").fetchall() + assert len(runs) == 1 + assert runs[0][0] == "r1" + + preds = conn.execute("SELECT * FROM predictions").fetchall() + assert len(preds) == 2 + conn.close() + + def test_extract_run_db_missing_table(self, tmp_path: "Path") -> None: + """源 db 无目标表时报错。""" + src = tmp_path / "src.db" + dst = tmp_path / "dst.db" + conn = sqlite3.connect(src) + conn.execute("CREATE TABLE other (id TEXT)") + conn.commit() + conn.close() + with pytest.raises(RuntimeError, match="源 db 无表"): + extract_run_db(src, dst, "r1") + + def test_extract_run_db_no_rows(self, tmp_path: "Path") -> None: + """目标 run_id 不存在时报错。""" + src = tmp_path / "src.db" + dst = tmp_path / "dst.db" + self._make_src_db(src) + with pytest.raises(RuntimeError, match="无 run_id="): + extract_run_db(src, dst, "nonexistent") + + +# --------------------------------------------------------------------------- +# promote_to_seed +# --------------------------------------------------------------------------- + + +def _make_promote_fixtures(tmp_path): + """创建 promote_to_seed 测试所需的 workspace + store。""" + ws = tmp_path / "ws" + ws.mkdir() + store = tmp_path / "store" + store.mkdir() + + # workspace 内的 skills/prompts 版本目录 + (ws / "skills" / "v2").mkdir(parents=True) + (ws / "skills" / "v2" / "skill.md").write_text("evolved") + (ws / "prompts" / "v2").mkdir(parents=True) + (ws / "prompts" / "v2" / "system.md").write_text("prompt v2") + + # workspace harness.db + db_path = ws / "harness.db" + conn = sqlite3.connect(db_path) + conn.execute(""" + CREATE TABLE _runs ( + run_id TEXT PRIMARY KEY, + skills_version TEXT, + prompts_version TEXT + ) + """) + conn.execute( + "INSERT INTO _runs VALUES ('eval_001', 'v2', 'v2')" + ) + conn.execute(""" + CREATE TABLE predictions ( + run_id TEXT, question_id TEXT, answer TEXT + ) + """) + conn.execute("INSERT INTO predictions VALUES ('eval_001', 'q1', 'A')") + conn.commit() + conn.close() + + return ws, store + + +class TestPromoteToSeed: + """promote_to_seed 固化 workspace 版本为种子。""" + + def test_promote_to_seed_success(self, tmp_path: "Path") -> None: + ws, store = _make_promote_fixtures(tmp_path) + seed_dir = promote_to_seed(ws, store, "v2", "eval_001", "evolved-seed", "good") + assert seed_dir == store / "seeds" / "evolved-seed" + assert (seed_dir / "skills" / "skill.md").exists() + assert (seed_dir / "prompts" / "system.md").exists() + assert (seed_dir / "baseline.db").exists() + meta = json.loads((seed_dir / "seed.json").read_text()) + assert meta["baseline_run_id"] == "eval_001" + assert meta["parent"] == "ws:v2" + + def test_promote_to_seed_version_mismatch(self, tmp_path: "Path") -> None: + """eval run 的 skills_version 与 --version 不符时报错。""" + ws, store = _make_promote_fixtures(tmp_path) + with pytest.raises(ValueError, match="版本.*不符"): + promote_to_seed(ws, store, "v3", "eval_001", "bad", "mismatch") + + def test_promote_to_seed_null_version(self, tmp_path: "Path") -> None: + """eval run 的版本为 NULL 时报错。""" + ws = tmp_path / "ws2" + ws.mkdir() + store = tmp_path / "store2" + store.mkdir() + db_path = ws / "harness.db" + conn = sqlite3.connect(db_path) + conn.execute(""" + CREATE TABLE _runs ( + run_id TEXT PRIMARY KEY, + skills_version TEXT, + prompts_version TEXT + ) + """) + conn.execute("INSERT INTO _runs VALUES ('eval_null', NULL, NULL)") + conn.commit() + conn.close() + with pytest.raises(ValueError, match="NULL"): + promote_to_seed(ws, store, "v1", "eval_null", "bad", "null ver") + + def test_promote_to_seed_run_not_found(self, tmp_path: "Path") -> None: + """eval run 不存在时报错。""" + ws, store = _make_promote_fixtures(tmp_path) + with pytest.raises(ValueError, match="eval run 不存在"): + promote_to_seed(ws, store, "v1", "nonexistent", "bad", "no run") + + def test_promote_cleanup_tmp_db(self, tmp_path: "Path") -> None: + """finally 清理临时 db 文件。""" + ws, store = _make_promote_fixtures(tmp_path) + promote_to_seed(ws, store, "v2", "eval_001", "clean-test", "cleanup") + # 临时 db 应已清理 + assert not (ws / "_promote_tmp.db").exists() + + def test_promote_cleanup_tmp_db_on_error(self, tmp_path: "Path") -> None: + """即使 init_seed 失败(同名种子),临时 db 也应被清理。""" + ws, store = _make_promote_fixtures(tmp_path) + promote_to_seed(ws, store, "v2", "eval_001", "first", "first time") + with pytest.raises(FileExistsError): + promote_to_seed(ws, store, "v2", "eval_001", "first", "second time") + assert not (ws / "_promote_tmp.db").exists()