Files

682 lines
23 KiB
Python

"""
F52: Parameter Lab backend service.
Handles bounded parameter sweep planning and experiment state persistence.
"""
from __future__ import annotations
import itertools
import json
import logging
import time
import uuid
from dataclasses import asdict, dataclass, field, replace
from pathlib import Path
from typing import Any, Dict, List, Optional
try:
from aiohttp import web
except ImportError:
web = None # type: ignore
if __package__ and "." in __package__:
from ..services.access_control import require_admin_token
from ..services.rate_limit import build_rate_limit_response, check_rate_limit
else: # pragma: no cover (test-only import mode)
from services.access_control import require_admin_token # type: ignore
from services.rate_limit import ( # type: ignore
build_rate_limit_response,
check_rate_limit,
)
if __package__ and "." in __package__:
from ..services import parameter_lab_policy as _parameter_lab_policy
from ..services.parameter_lab_policy import (
ParameterLabValidationError,
serialize_plan_payload,
validate_compare_input,
validate_sweep_dimensions,
validate_workflow,
)
from ..services.safe_io import safe_write_text
else: # pragma: no cover (test-only import mode)
from services import parameter_lab_policy as _parameter_lab_policy
from services.parameter_lab_policy import (
ParameterLabValidationError,
serialize_plan_payload,
validate_compare_input,
validate_sweep_dimensions,
validate_workflow,
)
from services.safe_io import safe_write_text
PARAMETER_LAB_POLICY_VERSION = _parameter_lab_policy.PARAMETER_LAB_POLICY_VERSION
PARAMETER_LAB_POLICY = _parameter_lab_policy.PARAMETER_LAB_POLICY
MAX_PARAMETER_LAB_REQUEST_BYTES = _parameter_lab_policy.MAX_PARAMETER_LAB_REQUEST_BYTES
MAX_PARAMETER_LAB_WORKFLOW_UTF8_BYTES = (
_parameter_lab_policy.MAX_PARAMETER_LAB_WORKFLOW_UTF8_BYTES
)
MAX_SWEEP_DIMENSIONS = _parameter_lab_policy.MAX_SWEEP_DIMENSIONS
MAX_VALUES_PER_DIMENSION = _parameter_lab_policy.MAX_VALUES_PER_DIMENSION
MAX_NODE_ID_UTF8_BYTES = _parameter_lab_policy.MAX_NODE_ID_UTF8_BYTES
MAX_WIDGET_NAME_UTF8_BYTES = _parameter_lab_policy.MAX_WIDGET_NAME_UTF8_BYTES
MAX_SCALAR_STRING_UTF8_BYTES = _parameter_lab_policy.MAX_SCALAR_STRING_UTF8_BYTES
MAX_PARAMETER_LAB_PLAN_UTF8_BYTES = (
_parameter_lab_policy.MAX_PARAMETER_LAB_PLAN_UTF8_BYTES
)
MAX_SWEEP_COMBINATIONS = _parameter_lab_policy.MAX_SWEEP_COMBINATIONS
MAX_COMPARE_ITEMS = _parameter_lab_policy.MAX_COMPARE_ITEMS
# R98: Endpoint Metadata
if __package__ and "." in __package__:
from ..services.endpoint_manifest import (
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
else:
from services.endpoint_manifest import (
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
logger = logging.getLogger("ComfyUI-OpenClaw.services.parameter_lab")
# Configuration
EXPERIMENT_RETENTION_COUNT = 20
@dataclass
class SweepDimension:
node_id: str
widget_name: str
values: List[Any] = field(default_factory=list)
strategy: str = "grid" # "grid" or "random"
count: int = 0 # Reserved for random strategy
@dataclass
class SweepPlan:
experiment_id: str
workflow_json: str
dimensions: List[SweepDimension]
runs: List[Dict[str, Any]]
created_at: float = field(default_factory=time.time)
# F52: Data Model v1
schema_version: str = "1.0"
combination_cap: int = MAX_SWEEP_COMBINATIONS
budget_cap: int = MAX_SWEEP_COMBINATIONS # Currently same as combo cap
replay_metadata: Dict[str, Any] = field(default_factory=dict)
class SweepPlanner:
"""Generates bounded sweep plans."""
def generate(self, workflow: Any, params: List[Dict[str, Any]]) -> SweepPlan:
normalized_workflow = validate_workflow(workflow)
normalized_params = validate_sweep_dimensions(params)
dimensions: List[SweepDimension] = [
SweepDimension(
node_id=dimension["node_id"],
widget_name=dimension["widget_name"],
values=dimension["values"],
strategy=dimension["strategy"],
count=dimension["count"],
)
for dimension in normalized_params
]
overrides_list = self._generate_combinations(dimensions)
# IMPORTANT: validate a same-length placeholder before allocating any experiment ID.
candidate = SweepPlan(
experiment_id="exp_00000000",
workflow_json=normalized_workflow,
dimensions=dimensions,
runs=overrides_list,
schema_version="1.0",
combination_cap=MAX_SWEEP_COMBINATIONS,
budget_cap=MAX_SWEEP_COMBINATIONS,
replay_metadata={
"replay_input_version": "1.0",
"compat_state": "supported",
"lock_reason": "f52_closeout",
},
)
serialize_plan_payload(asdict(candidate))
return replace(candidate, experiment_id=f"exp_{uuid.uuid4().hex[:8]}")
def _generate_combinations(
self, dimensions: List[SweepDimension]
) -> List[Dict[str, Any]]:
value_lists: List[List[Any]] = []
keys: List[str] = []
for dim in dimensions:
vals = dim.values
if not vals:
continue
key = f"{dim.node_id}.{dim.widget_name}"
value_lists.append(vals)
keys.append(key)
# F50: Deterministic sort of keys?
# Actually, dimensions order matters for the user (UI order).
# We should respect input order but ensure the algorithm is stable.
# Reference implementation uses input order.
# "Deterministic" here means: same input -> same output.
# Python dicts preserve insertion order (3.7+).
# We'll rely on input list order stability.
if not value_lists:
return []
runs: List[Dict[str, Any]] = []
for combo in itertools.product(*value_lists):
override = {}
for idx, val in enumerate(combo):
override[keys[idx]] = val
runs.append(override)
return runs
class ComparePlanner:
"""
F50: Generates bounded multi-model comparison plans.
Enforces stricter fan-out and timeout policies than generic sweeps.
"""
def generate(
self, workflow: Any, items: List[Any], node_id: Any, widget_name: str
) -> SweepPlan:
normalized_workflow = validate_workflow(workflow)
validated_compare = validate_compare_input(items, node_id, widget_name)
normalized_items: List[Any] = validated_compare[0]
normalized_node_id = validated_compare[1]
normalized_widget_name = validated_compare[2]
# Create a single dimension for the model/item
dim = SweepDimension(
node_id=normalized_node_id,
widget_name=normalized_widget_name,
values=normalized_items,
strategy="compare",
)
# Generate runs (1 per item)
runs = []
for val in normalized_items:
runs.append({f"{normalized_node_id}.{normalized_widget_name}": val})
candidate = SweepPlan(
experiment_id="cmp_00000000",
workflow_json=normalized_workflow,
dimensions=[dim],
runs=runs,
schema_version="1.0",
combination_cap=MAX_COMPARE_ITEMS,
budget_cap=MAX_COMPARE_ITEMS,
replay_metadata={
"replay_input_version": "1.0",
"compat_state": "supported",
"lock_reason": "f50_closeout",
},
)
serialize_plan_payload(asdict(candidate))
return replace(candidate, experiment_id=f"cmp_{uuid.uuid4().hex[:8]}")
_compare_planner = ComparePlanner()
class ExperimentStore:
"""Persists experiment metadata."""
def __init__(self, state_dir: Path):
self.store_dir = state_dir / "experiments"
self.store_dir.mkdir(parents=True, exist_ok=True)
@staticmethod
def _is_experiment_file(path: Path) -> bool:
return path.name.startswith("exp_") or path.name.startswith("cmp_")
def _enforce_retention(self) -> None:
"""Delete oldest experiments if count exceeds limit."""
try:
# R78/F50: Include both exp_* (sweeps) and cmp_* (compares).
files = [
(file_path, file_path.stat().st_mtime)
for file_path in self.store_dir.glob("*.json")
if self._is_experiment_file(file_path)
]
files.sort(key=lambda item: item[1], reverse=True)
for file_path, _ in files[EXPERIMENT_RETENTION_COUNT:]:
try:
file_path.unlink()
logger.info("Pruned old experiment: %s", file_path.name)
except Exception as exc:
logger.warning("Failed to prune %s: %s", file_path.name, exc)
except Exception as exc:
logger.warning("Retention check failed: %s", exc)
def save_plan(self, plan: SweepPlan) -> None:
serialized = serialize_plan_payload(asdict(plan))
# IMPORTANT: keep validation before file creation and retention mutation.
safe_write_text(
str(self.store_dir),
f"{plan.experiment_id}.json",
serialized,
atomic=True,
)
self._enforce_retention()
def get_plan(self, exp_id: str) -> Optional[Dict[str, Any]]:
path = self.store_dir / f"{exp_id}.json"
if not path.exists():
return None
try:
with open(path, "r", encoding="utf-8") as handle:
data = json.load(handle)
# F52: Legacy compatibility guard
if "schema_version" not in data:
data["schema_version"] = "0.9" # Mark as pre-F52
data["replay_metadata"] = {
"compat_state": "legacy",
"replay_input_version": "0.9",
"note": "Legacy experiment; full replay guarantees not active",
}
return data # type: ignore[no-any-return]
except Exception:
return None
def list_experiments(self) -> List[Dict[str, Any]]:
results: List[Dict[str, Any]] = []
# R78/F50: Include both exp_* and cmp_*.
files = sorted(
[
file_path
for file_path in self.store_dir.glob("*.json")
if self._is_experiment_file(file_path)
],
key=lambda item: item.stat().st_mtime,
reverse=True,
)
for file_path in files:
try:
with open(file_path, "r", encoding="utf-8") as handle:
data = json.load(handle)
results.append(
{
"id": data["experiment_id"],
"created_at": data.get("created_at"),
"run_count": len(data.get("runs", [])),
"completed_count": len(
[
r
for r in data.get("results", {}).values()
if r.get("status") == "completed"
]
),
}
)
except Exception:
continue
return results
def update_experiment(
self, exp_id: str, run_id: str, output: Any = None, status: Optional[str] = None
) -> bool:
path = self.store_dir / f"{exp_id}.json"
if not path.exists():
return False
try:
with open(path, "r", encoding="utf-8") as handle:
data = json.load(handle)
if "results" not in data:
data["results"] = {}
if run_id not in data["results"]:
data["results"][run_id] = {}
if output is not None:
data["results"][run_id]["output"] = output
if status is not None:
data["results"][run_id]["status"] = status
data["updated_at"] = time.time()
with open(path, "w", encoding="utf-8") as handle:
json.dump(data, handle, indent=2)
return True
except Exception as exc:
logger.error("Failed to update experiment %s: %s", exp_id, exc)
return False
_planner = SweepPlanner()
_store: Optional[ExperimentStore] = None
def get_store() -> ExperimentStore:
global _store
if _store is None:
try:
from ..config import OPENCLAW_STATE_DIR
state_dir = Path(OPENCLAW_STATE_DIR)
except ImportError:
state_dir = Path("./openclaw_state")
_store = ExperimentStore(state_dir)
return _store
def _require_admin(request: web.Request) -> Optional[web.Response]:
"""
CRITICAL: All /lab routes are admin-grade mutating surfaces and must keep
auth + rate limit gates to avoid remote abuse and queue-flood vectors.
"""
if not check_rate_limit(request, "admin"):
return build_rate_limit_response(
request,
"admin",
web_module=web,
error="rate_limit_exceeded",
include_ok=True,
)
allowed, err = require_admin_token(request)
if not allowed:
return web.json_response(
{"ok": False, "error": err or "unauthorized"}, status=403
)
return None
async def _read_creation_payload(request: web.Request) -> dict[str, Any]:
content_length = request.content_length
if content_length is not None and content_length > MAX_PARAMETER_LAB_REQUEST_BYTES:
raise ParameterLabValidationError("payload_too_large", status=413)
raw_body = bytearray()
while True:
remaining = MAX_PARAMETER_LAB_REQUEST_BYTES + 1 - len(raw_body)
if remaining <= 0:
raise ParameterLabValidationError("payload_too_large", status=413)
chunk = await request.content.read(min(64 * 1024, remaining))
if not chunk:
break
raw_body.extend(chunk)
if len(raw_body) > MAX_PARAMETER_LAB_REQUEST_BYTES:
raise ParameterLabValidationError("payload_too_large", status=413)
try:
data = json.loads(raw_body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ParameterLabValidationError("invalid_json") from exc
if not isinstance(data, dict):
raise ParameterLabValidationError("invalid_payload")
return data
def _validation_response(exc: ParameterLabValidationError) -> web.Response:
return web.json_response({"ok": False, "error": exc.code}, status=exc.status)
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.MEDIUM,
summary="Create comparison",
description="Create a bounded multi-model comparison plan.",
audit="lab.compare.create",
plane=RoutePlane.ADMIN,
)
async def create_compare_handler(request: web.Request) -> web.Response:
if web is None:
raise RuntimeError("aiohttp not available")
deny = _require_admin(request)
if deny:
return deny
try:
data = await _read_creation_payload(request)
except ParameterLabValidationError as exc:
return _validation_response(exc)
workflow = data.get("workflow_json")
items = data.get("items", []) # List of comparison values.
node_id = data.get("node_id")
widget_name = data.get("widget_name")
if not isinstance(items, list):
return web.json_response(
{"ok": False, "error": "items_must_be_list"}, status=400
)
if node_id is None:
return web.json_response({"ok": False, "error": "node_id_required"}, status=400)
if not isinstance(widget_name, str) or not widget_name.strip():
return web.json_response(
{"ok": False, "error": "widget_name_required"}, status=400
)
try:
plan = _compare_planner.generate(workflow, items, node_id, widget_name)
get_store().save_plan(plan)
return web.json_response({"ok": True, "plan": asdict(plan)})
except ParameterLabValidationError as exc:
return _validation_response(exc)
except Exception as exc:
logger.error("Compare creation failed (%s)", type(exc).__name__)
return web.json_response({"ok": False, "error": "internal_error"}, status=500)
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.MEDIUM,
summary="Create sweep",
description="Create a bounded parameter sweep plan.",
audit="lab.sweep.create",
plane=RoutePlane.ADMIN,
)
async def create_sweep_handler(request: web.Request) -> web.Response:
if web is None:
raise RuntimeError("aiohttp not available")
deny = _require_admin(request)
if deny:
return deny
try:
data = await _read_creation_payload(request)
except ParameterLabValidationError as exc:
return _validation_response(exc)
workflow = data.get("workflow_json")
params = data.get("params", [])
try:
plan = _planner.generate(workflow, params)
get_store().save_plan(plan)
return web.json_response({"ok": True, "plan": asdict(plan)})
except ParameterLabValidationError as exc:
return _validation_response(exc)
except Exception as exc:
logger.error("Sweep creation failed (%s)", type(exc).__name__)
return web.json_response({"ok": False, "error": "internal_error"}, status=500)
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.LOW,
summary="List experiments",
description="List persistent experiments.",
audit="lab.list",
plane=RoutePlane.ADMIN,
)
async def list_experiments_handler(request: web.Request) -> web.Response:
if web is None:
raise RuntimeError("aiohttp not available")
deny = _require_admin(request)
if deny:
return deny
experiments = get_store().list_experiments()
return web.json_response({"ok": True, "experiments": experiments})
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.LOW,
summary="Get experiment",
description="Retrieve experiment details.",
audit="lab.get",
plane=RoutePlane.ADMIN,
)
async def get_experiment_handler(request: web.Request) -> web.Response:
if web is None:
raise RuntimeError("aiohttp not available")
deny = _require_admin(request)
if deny:
return deny
exp_id = request.match_info.get("exp_id")
if not exp_id:
return web.json_response({"ok": False, "error": "missing_id"}, status=400)
plan = get_store().get_plan(exp_id)
if not plan:
return web.json_response({"ok": False, "error": "not_found"}, status=404)
return web.json_response({"ok": True, "experiment": plan})
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.MEDIUM,
summary="Update experiment",
description="Update experiment state (e.g. run results).",
audit="lab.update",
plane=RoutePlane.ADMIN,
)
async def update_experiment_handler(request: web.Request) -> web.Response:
if web is None:
raise RuntimeError("aiohttp not available")
deny = _require_admin(request)
if deny:
return deny
exp_id = request.match_info.get("exp_id")
run_id = request.match_info.get("run_id")
if not exp_id or not run_id:
return web.json_response({"ok": False, "error": "missing_id"}, status=400)
try:
data = await request.json()
except Exception:
return web.json_response({"ok": False, "error": "invalid_json"}, status=400)
if not isinstance(data, dict):
return web.json_response({"ok": False, "error": "invalid_payload"}, status=400)
success = get_store().update_experiment(
exp_id, run_id, output=data.get("output"), status=data.get("status")
)
if success:
return web.json_response({"ok": True})
return web.json_response({"ok": False, "error": "update_failed"}, status=500)
@endpoint_metadata(
auth=AuthTier.ADMIN,
risk=RiskTier.MEDIUM,
summary="Select winner",
description="Select experiment winner and return params.",
audit="lab.winner",
plane=RoutePlane.ADMIN,
)
async def select_apply_winner_handler(request: web.Request) -> web.Response:
"""
F50: Winner-Handoff Safety Gate.
Validates selection and returns canonical params for "apply" action.
"""
if web is None:
raise RuntimeError("aiohttp not available")
deny = _require_admin(request)
if deny:
return deny
exp_id = request.match_info.get("exp_id")
if not exp_id:
return web.json_response({"ok": False, "error": "missing_id"}, status=400)
try:
data = await request.json()
except Exception:
return web.json_response({"ok": False, "error": "invalid_json"}, status=400)
if not isinstance(data, dict):
return web.json_response({"ok": False, "error": "invalid_payload"}, status=400)
run_id = data.get("run_id")
if not run_id:
return web.json_response({"ok": False, "error": "run_id_required"}, status=400)
run_id = str(run_id)
store = get_store()
plan = store.get_plan(exp_id)
if not plan:
return web.json_response(
{"ok": False, "error": "experiment_not_found"}, status=404
)
runs = plan.get("runs", [])
if not isinstance(runs, list):
return web.json_response(
{"ok": False, "error": "invalid_plan_runs"}, status=500
)
# CRITICAL: winner selection is index-based against canonical persisted `runs`.
# Do not infer/accept ad-hoc client payload as winner params.
try:
run_index = int(str(run_id))
except (TypeError, ValueError):
return web.json_response(
{"ok": False, "error": "invalid_run_id_format"}, status=400
)
if run_index < 0 or run_index >= len(runs):
return web.json_response({"ok": False, "error": "run_not_found"}, status=404)
results = plan.get("results", {})
if run_id not in results:
return web.json_response(
{"ok": False, "error": "run_result_not_found_or_incomplete"}, status=404
)
run_result = results[run_id]
if run_result.get("status") != "completed":
return web.json_response(
{"ok": False, "error": "run_not_completed"}, status=400
)
# Perform the "Handoff" -> Mark as winner.
updated = store.update_experiment(exp_id, run_id, status="winner")
if not updated:
return web.json_response({"ok": False, "error": "update_failed"}, status=500)
params = runs[run_index]
if not isinstance(params, dict):
return web.json_response(
{"ok": False, "error": "winner_params_lookup_failed"}, status=500
)
return web.json_response({"ok": True, "winner": params})