You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
824 lines
28 KiB
Python
824 lines
28 KiB
Python
"""Experiment management: create, search, tree, diff, migrate, validate, index."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import subprocess
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from libs.backtest.snapshots import resolve_snapshot
|
|
|
|
_EXPERIMENTS_DIR = Path("configs/experiments")
|
|
_INDEX_FILE = ".index.json"
|
|
|
|
_VALID_STATUSES = {"draft", "active", "promoted", "retired"}
|
|
_LEGACY_STATUS_ALIASES = {
|
|
"archived": "retired",
|
|
}
|
|
|
|
_META_FIELDS = {
|
|
"id", "parent", "created_at", "created_by", "status", "generation",
|
|
"version_family", "changelog", "aliases", "performance_summary",
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Internal helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _read_json(path: Path) -> dict[str, Any]:
|
|
return json.loads(path.read_text())
|
|
|
|
|
|
def _write_json(path: Path, data: dict[str, Any]) -> None:
|
|
path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
|
|
|
|
|
|
def _exp_path(name: str, configs_dir: Path) -> Path:
|
|
return configs_dir / f"{name}.json"
|
|
|
|
|
|
def normalize_experiment_status(status: str | None) -> str:
|
|
raw = str(status or "active").strip().lower()
|
|
return _LEGACY_STATUS_ALIASES.get(raw, raw)
|
|
|
|
|
|
def _infer_version_family(name: str) -> str | None:
|
|
"""Extract version family from experiment name, e.g. 'v6new', 'v8', 'v1'."""
|
|
m = re.search(r"_(v\d+[a-z]*)\.?\d*$", name)
|
|
if m:
|
|
return m.group(1)
|
|
# Try generic name formats like conviction_v6new.307
|
|
m = re.search(r"_(v\d+[a-z]*)\.", name)
|
|
if m:
|
|
return m.group(1)
|
|
return None
|
|
|
|
|
|
def _infer_parent_from_description(
|
|
description: str,
|
|
experiment_name: str,
|
|
all_names: set[str],
|
|
family: str | None,
|
|
) -> str | None:
|
|
"""Try to infer parent experiment name from description text.
|
|
|
|
Handles patterns:
|
|
[v312+] ... -> parent is family.312
|
|
[v312] ... -> parent is family.312
|
|
v288 + ... -> parent is family.288
|
|
v6new.30 + ... -> parent is return_max_long_v6new.30
|
|
Derivative of NAME -> parent is NAME (exact name)
|
|
"""
|
|
if not description:
|
|
return None
|
|
|
|
desc = description.strip()
|
|
|
|
# Pattern 1: explicit full name reference "Derivative of return_max_long_v1.274"
|
|
m = re.search(r"Derivative of ([\w.]+)", desc, re.IGNORECASE)
|
|
if m:
|
|
candidate = m.group(1)
|
|
if candidate in all_names:
|
|
return candidate
|
|
# Try adding common prefixes
|
|
for prefix in ("return_max_long_",):
|
|
full = prefix + candidate
|
|
if full in all_names:
|
|
return full
|
|
|
|
# Pattern 2: bracket notation [v312+] or [v312]
|
|
m = re.match(r"\[v(\d+)\+?\]", desc)
|
|
if m and family:
|
|
version = m.group(1)
|
|
# Build candidate name by replacing version number in own name
|
|
base_prefix = re.sub(r"\d+$", "", experiment_name.rsplit(".", 1)[0])
|
|
candidate = f"{base_prefix}{version}"
|
|
if candidate in all_names:
|
|
return candidate
|
|
# Try common prefixes
|
|
candidate2 = f"return_max_long_{family}.{version}"
|
|
if candidate2 in all_names:
|
|
return candidate2
|
|
|
|
# Pattern 3: explicit family.version reference "v6new.30 + ..." or "v6new.312 with ..." or "v6new.30 but ..."
|
|
m = re.match(r"(v\d+[a-z]*)\.(\d+)\s*(?:\+|with\b|but\b)", desc)
|
|
if m:
|
|
ref_family = m.group(1)
|
|
ref_version = m.group(2)
|
|
candidate = f"return_max_long_{ref_family}.{ref_version}"
|
|
if candidate in all_names:
|
|
return candidate
|
|
|
|
# Pattern 4: short version reference "v288 + ..." or "v288 but ..." or "v288 with ..."
|
|
m = re.match(r"v(\d+)\s*(?:\+|with\b|but\b)", desc)
|
|
if m and family:
|
|
version = m.group(1)
|
|
candidate = f"return_max_long_{family}.{version}"
|
|
if candidate in all_names:
|
|
return candidate
|
|
|
|
return None
|
|
|
|
|
|
def _get_git_creation_time(path: Path) -> str | None:
|
|
"""Get ISO-8601 timestamp of the file's first git commit."""
|
|
try:
|
|
result = subprocess.run(
|
|
["git", "log", "--follow", "--format=%aI", "--", str(path)],
|
|
capture_output=True, text=True, cwd=path.parent.parent.parent,
|
|
)
|
|
lines = result.stdout.strip().splitlines()
|
|
if lines:
|
|
return lines[-1].strip() # oldest commit date
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def _compute_generation(
|
|
parent_name: str | None,
|
|
all_data: dict[str, dict[str, Any]],
|
|
depth: int = 0,
|
|
) -> int:
|
|
if depth > 50 or parent_name is None:
|
|
return depth
|
|
parent = all_data.get(parent_name, {})
|
|
parent_parent = parent.get("parent")
|
|
if parent_parent is None or parent_parent == parent_name:
|
|
return depth + 1
|
|
return _compute_generation(parent_parent, all_data, depth + 1)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Index management
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _index_path(configs_dir: Path) -> Path:
|
|
return configs_dir / _INDEX_FILE
|
|
|
|
|
|
def _is_index_stale(configs_dir: Path) -> bool:
|
|
idx = _index_path(configs_dir)
|
|
if not idx.exists():
|
|
return True
|
|
idx_mtime = idx.stat().st_mtime
|
|
for p in _iter_experiment_files(configs_dir):
|
|
if p.stat().st_mtime > idx_mtime:
|
|
return True
|
|
return False
|
|
|
|
|
|
def resolve_experiment_name(
|
|
id_or_name: str,
|
|
configs_dir: Path = _EXPERIMENTS_DIR,
|
|
) -> str:
|
|
"""Resolve an experiment ID (e.g. '228') or name to the canonical experiment name.
|
|
|
|
Raises KeyError if the ID is not found.
|
|
"""
|
|
# If it looks like a plain integer, look it up in the index
|
|
if id_or_name.isdigit():
|
|
target_id = int(id_or_name)
|
|
idx_path = _index_path(configs_dir)
|
|
if idx_path.exists():
|
|
try:
|
|
index = _read_json(idx_path)
|
|
for name, meta in index.get("experiments", {}).items():
|
|
if meta.get("id") == target_id:
|
|
return name
|
|
except Exception:
|
|
pass
|
|
# Fallback: scan files
|
|
for p in _iter_experiment_files(configs_dir):
|
|
try:
|
|
data = _read_json(p)
|
|
if data.get("id") == target_id:
|
|
return data.get("experiment_name") or p.stem
|
|
except Exception:
|
|
pass
|
|
raise KeyError(f"No experiment found with ID {target_id}")
|
|
return id_or_name
|
|
|
|
|
|
def next_experiment_id(configs_dir: Path = _EXPERIMENTS_DIR) -> int:
|
|
"""Return the next available sequential experiment ID (max existing + 1)."""
|
|
max_id = 0
|
|
for p in _iter_experiment_files(configs_dir):
|
|
try:
|
|
eid = json.loads(p.read_text()).get("id")
|
|
if isinstance(eid, int) and eid > max_id:
|
|
max_id = eid
|
|
except Exception:
|
|
pass
|
|
return max_id + 1
|
|
|
|
|
|
def _iter_experiment_files(configs_dir: Path):
|
|
"""Yield experiment JSON files, excluding the index file."""
|
|
for p in sorted(configs_dir.glob("*.json")):
|
|
if p.name.startswith("."):
|
|
continue
|
|
yield p
|
|
|
|
|
|
def rebuild_experiment_index(
|
|
configs_dir: Path = _EXPERIMENTS_DIR,
|
|
journal_path: Path | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Scan all experiment JSONs and build .index.json metadata cache."""
|
|
experiments: dict[str, Any] = {}
|
|
|
|
# Optionally cross-reference journal for SQS scores
|
|
journal_sqs: dict[str, float | None] = {}
|
|
journal_exists: set[str] = set()
|
|
if journal_path and journal_path.exists():
|
|
for line in journal_path.read_text().splitlines():
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
entry = json.loads(line)
|
|
name = entry.get("experiment_name", "")
|
|
journal_exists.add(name)
|
|
score = entry.get("sqs_score")
|
|
if score is not None:
|
|
journal_sqs[name] = score
|
|
except Exception:
|
|
pass
|
|
|
|
for p in _iter_experiment_files(configs_dir):
|
|
try:
|
|
data = _read_json(p)
|
|
except Exception:
|
|
continue
|
|
name = data.get("experiment_name") or p.stem
|
|
experiments[name] = {
|
|
"id": data.get("id"),
|
|
"parent": data.get("parent"),
|
|
"version_family": data.get("version_family") or _infer_version_family(name),
|
|
"generation": data.get("generation"),
|
|
"status": normalize_experiment_status(data.get("status", "active")),
|
|
"created_at": data.get("created_at"),
|
|
"created_by": data.get("created_by"),
|
|
"tags": data.get("tags", []),
|
|
"aliases": data.get("aliases", []),
|
|
"description": data.get("description"),
|
|
"changelog": data.get("changelog"),
|
|
"has_journal_entry": name in journal_exists,
|
|
"sqs_score": journal_sqs.get(name),
|
|
}
|
|
|
|
index = {
|
|
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
"experiments": experiments,
|
|
}
|
|
_write_json(_index_path(configs_dir), index)
|
|
return index
|
|
|
|
|
|
def _load_index(
|
|
configs_dir: Path = _EXPERIMENTS_DIR,
|
|
journal_path: Path | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Load index, rebuilding if stale or corrupted."""
|
|
if _is_index_stale(configs_dir):
|
|
rebuild_experiment_index(configs_dir, journal_path)
|
|
return _read_json(_index_path(configs_dir))
|
|
try:
|
|
return _read_json(_index_path(configs_dir))
|
|
except (json.JSONDecodeError, ValueError):
|
|
# Index is corrupted — rebuild it
|
|
rebuild_experiment_index(configs_dir, journal_path)
|
|
return _read_json(_index_path(configs_dir))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Core API
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def create_experiment(
|
|
parent_name: str,
|
|
new_name: str,
|
|
changelog: str | None = None,
|
|
created_by: str = "ai_agent",
|
|
configs_dir: Path = _EXPERIMENTS_DIR,
|
|
) -> Path:
|
|
"""Create a new experiment config by copying from a parent experiment.
|
|
|
|
Sets parent, created_at, generation, version_family, status=draft, changelog.
|
|
Returns the path of the created file.
|
|
"""
|
|
parent_path = _exp_path(parent_name, configs_dir)
|
|
if not parent_path.exists():
|
|
raise FileNotFoundError(f"Parent experiment not found: {parent_path}")
|
|
|
|
new_path = _exp_path(new_name, configs_dir)
|
|
if new_path.exists():
|
|
raise FileExistsError(f"Experiment already exists: {new_path}")
|
|
|
|
data = _read_json(parent_path)
|
|
|
|
# Update identity fields
|
|
data["experiment_name"] = new_name
|
|
|
|
# Assign next sequential ID
|
|
data["id"] = next_experiment_id(configs_dir)
|
|
|
|
# Set metadata
|
|
data["parent"] = parent_name
|
|
data["created_at"] = datetime.now(timezone.utc).isoformat()
|
|
data["created_by"] = created_by
|
|
data["status"] = "draft"
|
|
data["changelog"] = changelog
|
|
data["version_family"] = _infer_version_family(new_name) or data.get("version_family")
|
|
|
|
# Compute generation from parent
|
|
parent_gen = data.get("generation")
|
|
data["generation"] = (parent_gen + 1) if isinstance(parent_gen, int) else None
|
|
|
|
# Clear runtime cache
|
|
data["performance_summary"] = None
|
|
|
|
# Phase-1 canonical snapshot migration: keep old manifests untouched, but
|
|
# newly created experiments should point at the canonical snapshot id.
|
|
requested_snapshot_id = data.get("dataset_snapshot_id")
|
|
if isinstance(requested_snapshot_id, str) and requested_snapshot_id:
|
|
try:
|
|
data["dataset_snapshot_id"] = resolve_snapshot(requested_snapshot_id).canonical_snapshot_id
|
|
except Exception:
|
|
pass
|
|
|
|
_write_json(new_path, data)
|
|
|
|
# Incrementally update index if it exists
|
|
idx_path = _index_path(configs_dir)
|
|
if idx_path.exists():
|
|
try:
|
|
index = _read_json(idx_path)
|
|
index["experiments"][new_name] = {
|
|
"id": data["id"],
|
|
"parent": parent_name,
|
|
"version_family": data.get("version_family"),
|
|
"generation": data.get("generation"),
|
|
"status": "draft",
|
|
"created_at": data["created_at"],
|
|
"created_by": created_by,
|
|
"tags": data.get("tags", []),
|
|
"aliases": data.get("aliases", []),
|
|
"description": data.get("description"),
|
|
"changelog": changelog,
|
|
"has_journal_entry": False,
|
|
"sqs_score": None,
|
|
}
|
|
index["updated_at"] = datetime.now(timezone.utc).isoformat()
|
|
_write_json(idx_path, index)
|
|
except Exception:
|
|
pass
|
|
|
|
return new_path
|
|
|
|
|
|
def search_experiments(
|
|
configs_dir: Path = _EXPERIMENTS_DIR,
|
|
journal_path: Path | None = None,
|
|
*,
|
|
tag: str | None = None,
|
|
status: str | None = None,
|
|
include_retired: bool = True,
|
|
version_family: str | None = None,
|
|
parent: str | None = None,
|
|
name_pattern: str | None = None,
|
|
has_journal_entry: bool | None = None,
|
|
) -> list[dict[str, Any]]:
|
|
"""Search experiments by metadata criteria. Returns list of index entries."""
|
|
index = _load_index(configs_dir, journal_path)
|
|
results = []
|
|
normalized_status = normalize_experiment_status(status) if status else None
|
|
for name, meta in index["experiments"].items():
|
|
meta_status = normalize_experiment_status(meta.get("status", "active"))
|
|
if tag and tag not in meta.get("tags", []):
|
|
continue
|
|
if normalized_status and meta_status != normalized_status:
|
|
continue
|
|
if not include_retired and not normalized_status and meta_status == "retired":
|
|
continue
|
|
if version_family and meta.get("version_family") != version_family:
|
|
continue
|
|
if parent and meta.get("parent") != parent:
|
|
continue
|
|
if name_pattern and not re.search(name_pattern, name):
|
|
continue
|
|
if has_journal_entry is not None and meta.get("has_journal_entry") != has_journal_entry:
|
|
continue
|
|
results.append({"name": name, **meta, "status": meta_status})
|
|
return results
|
|
|
|
|
|
def build_lineage_tree(
|
|
ancestor_name: str,
|
|
configs_dir: Path = _EXPERIMENTS_DIR,
|
|
journal_path: Path | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Build a lineage tree rooted at ancestor_name.
|
|
|
|
Returns a nested dict: {"name": ..., "meta": ..., "children": [...]}
|
|
"""
|
|
index = _load_index(configs_dir, journal_path)
|
|
experiments = index["experiments"]
|
|
|
|
# Build parent->children map
|
|
children_map: dict[str, list[str]] = {}
|
|
for name, meta in experiments.items():
|
|
p = meta.get("parent")
|
|
if p:
|
|
children_map.setdefault(p, []).append(name)
|
|
|
|
def _build_node(name: str, depth: int = 0) -> dict[str, Any]:
|
|
meta = experiments.get(name, {})
|
|
children = sorted(children_map.get(name, []))
|
|
return {
|
|
"name": name,
|
|
"meta": meta,
|
|
"depth": depth,
|
|
"children": [_build_node(c, depth + 1) for c in children],
|
|
}
|
|
|
|
if ancestor_name not in experiments:
|
|
raise KeyError(f"Experiment not found in index: {ancestor_name}")
|
|
|
|
return _build_node(ancestor_name)
|
|
|
|
|
|
def format_tree(node: dict[str, Any], prefix: str = "", is_last: bool = True) -> str:
|
|
"""Render a lineage tree as an indented text string."""
|
|
lines = []
|
|
connector = "└── " if is_last else "├── "
|
|
meta = node.get("meta", {})
|
|
status = normalize_experiment_status(meta.get("status", "active"))
|
|
sqs = meta.get("sqs_score")
|
|
sqs_str = f" SQS={sqs:.1f}" if sqs is not None else ""
|
|
aliases = meta.get("aliases", [])
|
|
alias_str = f" [{', '.join(aliases)}]" if aliases else ""
|
|
line = f"{prefix}{connector}{node['name']} [{status}]{sqs_str}{alias_str}"
|
|
if node.get("depth") == 0:
|
|
line = f"{node['name']} [{status}]{sqs_str}{alias_str}"
|
|
lines.append(line)
|
|
children = node.get("children", [])
|
|
for i, child in enumerate(children):
|
|
ext = " " if is_last else "│ "
|
|
child_prefix = "" if node.get("depth") == 0 else prefix + ext
|
|
lines.append(format_tree(child, child_prefix, i == len(children) - 1))
|
|
return "\n".join(lines)
|
|
|
|
|
|
def diff_experiments(
|
|
name_a: str,
|
|
name_b: str,
|
|
configs_dir: Path = _EXPERIMENTS_DIR,
|
|
) -> dict[str, tuple[Any, Any]]:
|
|
"""Compare two experiment configs. Returns dict of {path: (value_a, value_b)}.
|
|
|
|
Compares overrides, strategy_engines (by engine_id), tags, dataset_snapshot_id.
|
|
Metadata fields (parent, created_at, etc.) are excluded from the diff.
|
|
"""
|
|
path_a = _exp_path(name_a, configs_dir)
|
|
path_b = _exp_path(name_b, configs_dir)
|
|
if not path_a.exists():
|
|
raise FileNotFoundError(f"Experiment not found: {name_a}")
|
|
if not path_b.exists():
|
|
raise FileNotFoundError(f"Experiment not found: {name_b}")
|
|
|
|
data_a = _read_json(path_a)
|
|
data_b = _read_json(path_b)
|
|
|
|
diffs: dict[str, tuple[Any, Any]] = {}
|
|
|
|
# Compare non-metadata top-level fields
|
|
compare_keys = {"dataset_snapshot_id", "base_config", "overrides", "tags", "notes", "description"}
|
|
for key in compare_keys:
|
|
va = data_a.get(key)
|
|
vb = data_b.get(key)
|
|
if key == "overrides":
|
|
_deep_diff(va or {}, vb or {}, f"overrides", diffs)
|
|
elif va != vb:
|
|
diffs[key] = (va, vb)
|
|
|
|
# Compare strategy_engines by engine_id
|
|
engines_a = {e.get("engine_id", i): e for i, e in enumerate(data_a.get("strategy_engines", []))}
|
|
engines_b = {e.get("engine_id", i): e for i, e in enumerate(data_b.get("strategy_engines", []))}
|
|
all_ids = sorted(set(engines_a) | set(engines_b))
|
|
for eid in all_ids:
|
|
ea = engines_a.get(eid)
|
|
eb = engines_b.get(eid)
|
|
if ea is None:
|
|
diffs[f"engines.{eid}"] = (None, eb)
|
|
elif eb is None:
|
|
diffs[f"engines.{eid}"] = (ea, None)
|
|
else:
|
|
_deep_diff(ea, eb, f"engines.{eid}", diffs)
|
|
|
|
return diffs
|
|
|
|
|
|
def _deep_diff(
|
|
a: dict[str, Any],
|
|
b: dict[str, Any],
|
|
path: str,
|
|
result: dict[str, tuple[Any, Any]],
|
|
) -> None:
|
|
all_keys = set(a) | set(b)
|
|
for k in sorted(all_keys):
|
|
va = a.get(k)
|
|
vb = b.get(k)
|
|
full_path = f"{path}.{k}"
|
|
if isinstance(va, dict) and isinstance(vb, dict):
|
|
_deep_diff(va, vb, full_path, result)
|
|
elif va != vb:
|
|
result[full_path] = (va, vb)
|
|
|
|
|
|
def set_experiment_status(
|
|
experiment_name: str,
|
|
new_status: str,
|
|
configs_dir: Path = _EXPERIMENTS_DIR,
|
|
) -> None:
|
|
"""Update the status field of an experiment JSON in place."""
|
|
new_status = normalize_experiment_status(new_status)
|
|
if new_status not in _VALID_STATUSES:
|
|
raise ValueError(f"Invalid status: {new_status!r}. Must be one of {_VALID_STATUSES}")
|
|
path = _exp_path(experiment_name, configs_dir)
|
|
if not path.exists():
|
|
raise FileNotFoundError(f"Experiment not found: {path}")
|
|
data = _read_json(path)
|
|
data["status"] = new_status
|
|
_write_json(path, data)
|
|
|
|
# Update index if it exists
|
|
idx_path = _index_path(configs_dir)
|
|
if idx_path.exists():
|
|
try:
|
|
index = _read_json(idx_path)
|
|
if experiment_name in index.get("experiments", {}):
|
|
index["experiments"][experiment_name]["status"] = new_status
|
|
index["updated_at"] = datetime.now(timezone.utc).isoformat()
|
|
_write_json(idx_path, index)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def validate_all_experiments(
|
|
configs_dir: Path = _EXPERIMENTS_DIR,
|
|
) -> list[dict[str, str]]:
|
|
"""Validate all experiment JSON files. Returns list of {file, issue} dicts."""
|
|
from libs.backtest.domain import ExperimentManifest
|
|
from pydantic import ValidationError
|
|
|
|
issues: list[dict[str, str]] = []
|
|
all_names: set[str] = {p.stem for p in _iter_experiment_files(configs_dir)}
|
|
|
|
for p in _iter_experiment_files(configs_dir):
|
|
try:
|
|
raw = _read_json(p)
|
|
except Exception as e:
|
|
issues.append({"file": p.name, "issue": f"JSON parse error: {e}"})
|
|
continue
|
|
|
|
# Schema validation
|
|
try:
|
|
ExperimentManifest.model_validate(raw)
|
|
except ValidationError as e:
|
|
issues.append({"file": p.name, "issue": f"Schema validation failed: {e}"})
|
|
continue
|
|
|
|
name = raw.get("experiment_name", "")
|
|
|
|
# Name/filename match
|
|
if name and name != p.stem:
|
|
issues.append({"file": p.name, "issue": f"experiment_name '{name}' doesn't match filename '{p.stem}'"})
|
|
|
|
# base_config exists
|
|
base = raw.get("base_config", "")
|
|
if base and not Path(base).exists():
|
|
issues.append({"file": p.name, "issue": f"base_config path not found: {base}"})
|
|
|
|
# parent reference is valid
|
|
parent = raw.get("parent")
|
|
if parent and parent not in all_names:
|
|
issues.append({"file": p.name, "issue": f"parent '{parent}' does not exist"})
|
|
|
|
# status value
|
|
status = normalize_experiment_status(raw.get("status", "active"))
|
|
if status not in _VALID_STATUSES:
|
|
issues.append({"file": p.name, "issue": f"invalid status: {status!r}"})
|
|
|
|
return issues
|
|
|
|
|
|
def migrate_experiments(
|
|
configs_dir: Path = _EXPERIMENTS_DIR,
|
|
dry_run: bool = False,
|
|
) -> list[dict[str, Any]]:
|
|
"""Backfill metadata fields for existing experiment JSON files.
|
|
|
|
For each file missing metadata:
|
|
- version_family: inferred from filename
|
|
- parent: inferred from description patterns
|
|
- created_at: from git log (first commit) or file mtime
|
|
- generation: computed from parent chain
|
|
- changelog: extracted from description after parent ref
|
|
- status: defaulted to "active" if missing
|
|
- created_by: defaulted to "human"
|
|
|
|
Returns list of migration actions (what was inferred/set).
|
|
"""
|
|
all_paths = list(_iter_experiment_files(configs_dir))
|
|
all_names: set[str] = {p.stem for p in all_paths}
|
|
|
|
# First pass: load all data
|
|
all_data: dict[str, dict[str, Any]] = {}
|
|
for p in all_paths:
|
|
try:
|
|
all_data[p.stem] = _read_json(p)
|
|
except Exception:
|
|
pass
|
|
|
|
actions: list[dict[str, Any]] = []
|
|
|
|
# Second pass: infer metadata for each file
|
|
inferred_parents: dict[str, str | None] = {}
|
|
for name, data in all_data.items():
|
|
family = data.get("version_family") or _infer_version_family(name)
|
|
desc = data.get("description") or ""
|
|
inferred_parent = data.get("parent") or _infer_parent_from_description(
|
|
desc, name, all_names, family
|
|
)
|
|
inferred_parents[name] = inferred_parent
|
|
|
|
# Third pass: compute generations
|
|
def _get_gen(name: str, visited: set[str] | None = None) -> int:
|
|
if visited is None:
|
|
visited = set()
|
|
if name in visited:
|
|
return 0 # cycle protection
|
|
visited.add(name)
|
|
p = inferred_parents.get(name)
|
|
if p is None or p not in all_data:
|
|
return 0
|
|
return _get_gen(p, visited) + 1
|
|
|
|
# Compute ID assignments for files missing them (sorted by filename for determinism)
|
|
existing_ids: set[int] = {
|
|
d["id"] for d in all_data.values() if isinstance(d.get("id"), int)
|
|
}
|
|
next_id = max(existing_ids, default=0) + 1
|
|
id_assignments: dict[str, int] = {}
|
|
for name in sorted(all_data):
|
|
if all_data[name].get("id") is None:
|
|
id_assignments[name] = next_id
|
|
next_id += 1
|
|
|
|
# Fourth pass: write updates
|
|
for name, data in all_data.items():
|
|
p = _exp_path(name, configs_dir)
|
|
changes: dict[str, Any] = {}
|
|
|
|
if data.get("id") is None:
|
|
changes["id"] = id_assignments[name]
|
|
|
|
family = _infer_version_family(name)
|
|
if not data.get("version_family") and family:
|
|
changes["version_family"] = family
|
|
|
|
inferred_parent = inferred_parents.get(name)
|
|
if not data.get("parent") and inferred_parent:
|
|
changes["parent"] = inferred_parent
|
|
|
|
if not data.get("created_at"):
|
|
ts = _get_git_creation_time(p)
|
|
if ts:
|
|
changes["created_at"] = ts
|
|
else:
|
|
# Fall back to file mtime
|
|
mtime = datetime.fromtimestamp(p.stat().st_mtime, tz=timezone.utc)
|
|
changes["created_at"] = mtime.isoformat()
|
|
|
|
if not data.get("created_by"):
|
|
changes["created_by"] = "human"
|
|
|
|
if data.get("status") is None:
|
|
changes["status"] = "active"
|
|
elif normalize_experiment_status(data.get("status")) != data.get("status"):
|
|
changes["status"] = normalize_experiment_status(data.get("status"))
|
|
|
|
computed_gen = _get_gen(name)
|
|
if data.get("generation") != computed_gen:
|
|
changes["generation"] = computed_gen
|
|
|
|
if not data.get("changelog"):
|
|
desc = data.get("description") or ""
|
|
cl = _extract_changelog(desc)
|
|
if cl:
|
|
changes["changelog"] = cl
|
|
|
|
if changes:
|
|
actions.append({"name": name, "changes": changes})
|
|
if not dry_run:
|
|
data.update(changes)
|
|
_write_json(p, data)
|
|
|
|
# Rebuild index after migration
|
|
if not dry_run:
|
|
rebuild_experiment_index(configs_dir)
|
|
|
|
return actions
|
|
|
|
|
|
def _extract_changelog(description: str) -> str | None:
|
|
"""Extract the delta text from a description after the parent reference."""
|
|
if not description:
|
|
return None
|
|
desc = description.strip()
|
|
|
|
# Remove bracket prefix [v312+]
|
|
desc = re.sub(r"^\[v\d+\+?\]\s*", "", desc)
|
|
# Remove "vNNN + " prefix
|
|
desc = re.sub(r"^v\d+\s*\+\s*", "", desc)
|
|
# Remove "v6new.30 + " prefix
|
|
desc = re.sub(r"^v\d+[a-z]*\.\d+\s*\+\s*", "", desc)
|
|
# Remove "Derivative of NAME" prefix
|
|
desc = re.sub(r"^Derivative of [\w.]+\s*(?:with)?\s*", "", desc, flags=re.IGNORECASE)
|
|
|
|
desc = desc.strip()
|
|
return desc if desc else None
|
|
|
|
|
|
def get_ancestor_chain(
|
|
experiment_name: str,
|
|
configs_dir: Path = _EXPERIMENTS_DIR,
|
|
) -> list[dict[str, Any]]:
|
|
"""Return the full ancestor chain from root down to experiment_name (inclusive).
|
|
|
|
Each entry: {"name": str, "id": int|None, "description": str|None, "changelog": str|None}
|
|
"""
|
|
chain: list[dict[str, Any]] = []
|
|
visited: set[str] = set()
|
|
current = experiment_name
|
|
|
|
while current and current not in visited:
|
|
visited.add(current)
|
|
p = _exp_path(current, configs_dir)
|
|
if not p.exists():
|
|
break
|
|
try:
|
|
data = _read_json(p)
|
|
except Exception:
|
|
break
|
|
chain.append({
|
|
"name": current,
|
|
"id": data.get("id"),
|
|
"description": data.get("description"),
|
|
"changelog": data.get("changelog"),
|
|
"status": normalize_experiment_status(data.get("status", "active")),
|
|
})
|
|
current = data.get("parent") # type: ignore[assignment]
|
|
|
|
chain.reverse() # root first
|
|
return chain
|
|
|
|
|
|
def update_performance_summary(
|
|
experiment_name: str,
|
|
summary: dict[str, Any],
|
|
configs_dir: Path = _EXPERIMENTS_DIR,
|
|
activate_if_draft: bool = True,
|
|
) -> None:
|
|
"""Cache performance metrics in the experiment JSON and optionally activate draft experiments."""
|
|
path = _exp_path(experiment_name, configs_dir)
|
|
if not path.exists():
|
|
return
|
|
data = _read_json(path)
|
|
data["performance_summary"] = summary
|
|
if activate_if_draft and data.get("status") == "draft":
|
|
data["status"] = "active"
|
|
_write_json(path, data)
|
|
|
|
# Update index if it exists
|
|
idx_path = _index_path(configs_dir)
|
|
if idx_path.exists():
|
|
try:
|
|
index = _read_json(idx_path)
|
|
entry = index.get("experiments", {}).get(experiment_name)
|
|
if entry is not None:
|
|
entry["sqs_score"] = summary.get("sqs_score")
|
|
if activate_if_draft and entry.get("status") == "draft":
|
|
entry["status"] = "active"
|
|
index["updated_at"] = datetime.now(timezone.utc).isoformat()
|
|
_write_json(idx_path, index)
|
|
except Exception:
|
|
pass
|