"""Top-level study models and validation helpers."""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field, replace
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast
from .conditions import Constraint, Factor, FactorKind, Level
from .hypotheses import (
AnalysisPlan,
Hypothesis,
OutcomeSpec,
coerce_analysis_plan,
coerce_hypothesis,
coerce_outcome,
validate_hypothesis_bindings,
)
from .io import json_io, yaml_io
from .schemas import (
SCHEMA_VERSION,
ProvenanceMetadata,
RunBudget,
RunStatus,
SeedPolicy,
ValidationError,
to_jsonable,
)
if TYPE_CHECKING:
from .designs import DesignSpec
STUDY_SCHEMA_VERSION = SCHEMA_VERSION
def _default_design_spec() -> DesignSpec:
"""Return the default typed design without creating an import cycle."""
from .designs import DesignSpec
return DesignSpec()
[docs]
@dataclass(slots=True)
class Block:
"""Blocking structure for design-of-experiments materialization."""
name: str
levels: tuple[Any, ...]
metadata: dict[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
"""Validate block content."""
if not self.name.strip():
raise ValidationError("Block.name must be non-empty.")
if not self.levels:
raise ValidationError(f"Block '{self.name}' must contain at least one level.")
[docs]
@dataclass(slots=True)
class RunSpec:
"""One executable run specification."""
run_id: str
study_id: str
condition_id: str
problem_id: str
replicate: int
seed: int
agent_spec_ref: Any
problem_spec_ref: Any
execution_metadata: dict[str, Any] = field(default_factory=dict)
[docs]
@dataclass(slots=True)
class RunResult:
"""Normalized result bundle for one run."""
run_id: str
status: RunStatus
outputs: dict[str, Any] = field(default_factory=dict)
metrics: dict[str, Any] = field(default_factory=dict)
evaluator_outputs: list[dict[str, Any]] = field(default_factory=list)
cost: float = 0.0
latency: float = 0.0
trace_refs: list[str] = field(default_factory=list)
artifact_refs: list[str] = field(default_factory=list)
error_info: str | None = None
provenance_info: dict[str, Any] = field(default_factory=dict)
observations: list[Any] = field(default_factory=list)
run_spec: RunSpec | None = None
started_at: str | None = None
ended_at: str | None = None
[docs]
@dataclass(slots=True)
class Study:
"""Top-level experiment definition."""
study_id: str
title: str
description: str
authors: tuple[str, ...] = ()
rationale: str = ""
tags: tuple[str, ...] = ()
hypotheses: tuple[Hypothesis, ...] = ()
factors: tuple[Factor, ...] = ()
blocks: tuple[Block, ...] = ()
constraints: tuple[Constraint, ...] = ()
design_spec: DesignSpec = field(default_factory=_default_design_spec)
outcomes: tuple[OutcomeSpec, ...] = ()
analysis_plans: tuple[AnalysisPlan, ...] = ()
run_budget: RunBudget = field(default_factory=RunBudget)
seed_policy: SeedPolicy = field(default_factory=SeedPolicy)
output_dir: Path | None = None
provenance_metadata: dict[str, Any] = field(default_factory=dict)
notes: str = ""
problem_ids: tuple[str, ...] = ()
agent_specs: tuple[str, ...] = ()
def __post_init__(self) -> None:
"""Validate basic study fields and normalize output directory."""
if not self.study_id.strip():
raise ValidationError("Study.study_id must be non-empty.")
if not self.title.strip():
raise ValidationError("Study.title must be non-empty.")
if not _is_design_spec(self.design_spec):
from .designs import coerce_design_spec
self.design_spec = coerce_design_spec(cast(Mapping[str, Any], self.design_spec))
if self.output_dir is None:
self.output_dir = Path("artifacts") / self.study_id
@property
def primary_outcomes(self) -> tuple[str, ...]:
"""Return outcome names marked as primary on their definitions."""
return tuple(outcome.name for outcome in self.outcomes if outcome.primary)
@property
def secondary_outcomes(self) -> tuple[str, ...]:
"""Return outcome names not marked as primary."""
return tuple(outcome.name for outcome in self.outcomes if not outcome.primary)
[docs]
def to_dict(self) -> dict[str, Any]:
"""Serialize the study to a stable JSON/YAML-friendly mapping."""
payload = cast(dict[str, Any], to_jsonable(self))
return {"schema_version": STUDY_SCHEMA_VERSION, **payload}
[docs]
def to_yaml(self, path: str | Path) -> Path:
"""Write the study definition to YAML."""
return yaml_io.write_yaml(Path(path), self.to_dict())
[docs]
def to_json(self, path: str | Path) -> Path:
"""Write the study definition to JSON."""
return json_io.write_json(Path(path), self.to_dict())
[docs]
@classmethod
def from_dict(cls, payload: Mapping[str, Any]) -> Study:
"""Construct a study from a loose mapping payload."""
factors = tuple(
_coerce_factor(factor) for factor in cast(Sequence[Any], payload.get("factors", ()))
)
blocks = tuple(
_coerce_block(block) for block in cast(Sequence[Any], payload.get("blocks", ()))
)
constraints = tuple(
_coerce_constraint(constraint)
for constraint in cast(Sequence[Any], payload.get("constraints", ()))
)
hypotheses = tuple(
coerce_hypothesis(hypothesis)
for hypothesis in cast(Sequence[Any], payload.get("hypotheses", ()))
)
outcomes = tuple(
coerce_outcome(outcome) for outcome in cast(Sequence[Any], payload.get("outcomes", ()))
)
analysis_plans = tuple(
coerce_analysis_plan(plan)
for plan in cast(Sequence[Any], payload.get("analysis_plans", ()))
)
outcomes = _apply_legacy_outcome_roles(payload, outcomes)
_validate_legacy_hypothesis_links(payload, analysis_plans)
from .designs import coerce_design_spec
run_budget_payload = payload.get("run_budget")
run_budget = (
run_budget_payload
if isinstance(run_budget_payload, RunBudget)
else RunBudget(**cast(dict[str, Any], run_budget_payload or {}))
)
seed_policy_payload = payload.get("seed_policy")
seed_policy = (
seed_policy_payload
if isinstance(seed_policy_payload, SeedPolicy)
else SeedPolicy(**cast(dict[str, Any], seed_policy_payload or {}))
)
output_dir_payload = payload.get("output_dir")
output_dir = Path(str(output_dir_payload)) if output_dir_payload else None
return cls(
study_id=str(payload["study_id"]),
title=str(payload.get("title", "")),
description=str(payload.get("description", "")),
authors=tuple(cast(Sequence[str], payload.get("authors", ()))),
rationale=str(payload.get("rationale", "")),
tags=tuple(cast(Sequence[str], payload.get("tags", ()))),
hypotheses=hypotheses,
factors=factors,
blocks=blocks,
constraints=constraints,
design_spec=coerce_design_spec(cast(Mapping[str, Any], payload.get("design_spec", {}))),
outcomes=outcomes,
analysis_plans=analysis_plans,
run_budget=run_budget,
seed_policy=seed_policy,
output_dir=output_dir,
provenance_metadata=dict(
cast(Mapping[str, Any], payload.get("provenance_metadata", {}))
),
notes=str(payload.get("notes", "")),
problem_ids=tuple(cast(Sequence[str], payload.get("problem_ids", ()))),
agent_specs=tuple(cast(Sequence[str], payload.get("agent_specs", ()))),
)
[docs]
@classmethod
def from_yaml(cls, path: str | Path) -> Study:
"""Load a study from YAML."""
return cls.from_dict(yaml_io.read_yaml(Path(path)))
[docs]
@classmethod
def from_json(cls, path: str | Path) -> Study:
"""Load a study from JSON."""
return cls.from_dict(json_io.read_json(Path(path)))
[docs]
def validate_study(study: Study) -> list[str]:
"""Validate cross-object references and study consistency."""
errors: list[str] = []
factor_names = [factor.name for factor in study.factors]
block_names = [block.name for block in study.blocks]
outcome_names = [outcome.name for outcome in study.outcomes]
analysis_plan_ids = [plan.analysis_plan_id for plan in study.analysis_plans]
hypothesis_ids = [hypothesis.hypothesis_id for hypothesis in study.hypotheses]
errors.extend(_duplicate_errors("factor", factor_names))
errors.extend(_duplicate_errors("block", block_names))
errors.extend(_duplicate_errors("outcome", outcome_names))
errors.extend(_duplicate_errors("analysis plan", analysis_plan_ids))
errors.extend(_duplicate_errors("hypothesis", hypothesis_ids))
outcome_name_set = set(outcome_names)
errors.extend(
validate_hypothesis_bindings(
study.hypotheses,
factor_names=factor_names,
outcome_names=outcome_names,
)
)
for analysis_plan in study.analysis_plans:
for hypothesis_id in analysis_plan.hypothesis_ids:
if hypothesis_id not in set(hypothesis_ids):
errors.append(
"Analysis plan "
f"'{analysis_plan.analysis_plan_id}' references unknown hypothesis "
f"'{hypothesis_id}'."
)
for outcome_name in analysis_plan.outcomes:
if outcome_name not in outcome_name_set:
errors.append(
f"Analysis plan '{analysis_plan.analysis_plan_id}' references unknown outcome "
f"'{outcome_name}'."
)
problem_binding_factor = _binding_factor(
study.factors,
names=("problem_id", "problem"),
)
agent_binding_factor = _binding_factor(
study.factors,
names=("agent_id", "agent", "agent_spec"),
)
if study.run_budget.max_runs is not None:
requested_runs = (
_binding_count(study.problem_ids, problem_binding_factor)
* _binding_count(study.agent_specs, agent_binding_factor)
* study.run_budget.replicates
)
if requested_runs > study.run_budget.max_runs:
errors.append(
"Run budget max_runs is below the configured problem/agent/replicate plan."
)
return errors
def _is_design_spec(value: object) -> bool:
"""Return whether a value is already a typed design specification."""
from .designs import DesignSpec
return isinstance(value, DesignSpec)
def _apply_legacy_outcome_roles(
payload: Mapping[str, Any], outcomes: tuple[OutcomeSpec, ...]
) -> tuple[OutcomeSpec, ...]:
"""Migrate legacy study-level outcome roles into outcome definitions."""
if "primary_outcomes" not in payload and "secondary_outcomes" not in payload:
return outcomes
primary_names = set(cast(Sequence[str], payload.get("primary_outcomes", ())))
secondary_names = set(cast(Sequence[str], payload.get("secondary_outcomes", ())))
outcome_names = {outcome.name for outcome in outcomes}
unknown_names = (primary_names | secondary_names) - outcome_names
if unknown_names:
unknown = ", ".join(sorted(unknown_names))
raise ValidationError(f"Legacy outcome roles reference unknown outcomes: {unknown}.")
if primary_names & secondary_names:
overlap = ", ".join(sorted(primary_names & secondary_names))
raise ValidationError(f"Outcomes cannot be both primary and secondary: {overlap}.")
declared_primary = {outcome.name for outcome in outcomes if outcome.primary}
declared_secondary = outcome_names - declared_primary
raw_outcomes = cast(Sequence[Any], payload.get("outcomes", ()))
roles_declared = any(
isinstance(raw_outcome, Mapping) and "primary" in raw_outcome
for raw_outcome in raw_outcomes
)
if roles_declared:
if "primary_outcomes" in payload and declared_primary != primary_names:
raise ValidationError(
"OutcomeSpec.primary conflicts with legacy Study.primary_outcomes metadata."
)
if "secondary_outcomes" in payload and declared_secondary != secondary_names:
raise ValidationError(
"OutcomeSpec.primary conflicts with legacy Study.secondary_outcomes metadata."
)
return outcomes
if not primary_names:
return outcomes
return tuple(replace(outcome, primary=outcome.name in primary_names) for outcome in outcomes)
def _validate_legacy_hypothesis_links(
payload: Mapping[str, Any], analysis_plans: tuple[AnalysisPlan, ...]
) -> None:
"""Reject contradictory legacy hypothesis-to-analysis-plan links."""
plan_hypotheses = {plan.analysis_plan_id: set(plan.hypothesis_ids) for plan in analysis_plans}
for raw_hypothesis in cast(Sequence[Any], payload.get("hypotheses", ())):
if not isinstance(raw_hypothesis, Mapping):
continue
linked_plan_id = raw_hypothesis.get("linked_analysis_plan_id")
if linked_plan_id in (None, ""):
continue
hypothesis_id = str(raw_hypothesis.get("hypothesis_id", ""))
linked_plan_hypotheses = plan_hypotheses.get(str(linked_plan_id))
if linked_plan_hypotheses is None or hypothesis_id not in linked_plan_hypotheses:
raise ValidationError(
f"Hypothesis '{hypothesis_id}' conflicts with analysis plan "
f"'{linked_plan_id}'. AnalysisPlan.hypothesis_ids is authoritative."
)
def load_study(path: str | Path) -> Study:
"""Load a study from YAML or JSON based on file extension."""
resolved = Path(path)
suffix = resolved.suffix.lower()
if suffix in {".yaml", ".yml"}:
return Study.from_yaml(resolved)
if suffix == ".json":
return Study.from_json(resolved)
raise ValidationError("Study file must end with .yaml/.yml or .json.")
def _duplicate_errors(label: str, names: Sequence[str]) -> list[str]:
"""Return duplicate-name errors for one label class."""
seen: set[str] = set()
errors: list[str] = []
for name in names:
if name in seen:
errors.append(f"Duplicate {label} name detected: '{name}'.")
seen.add(name)
return errors
def _binding_factor(factors: Sequence[Factor], *, names: Sequence[str]) -> Factor | None:
"""Return the first factor that binds a runtime agent or problem identifier."""
name_set = set(names)
for factor in factors:
if factor.name in name_set:
return factor
return None
def _binding_count(values: Sequence[str], factor: Factor | None) -> int:
"""Resolve the effective binding count for max-run validation."""
if values:
return len(values)
if factor is not None:
return max(1, len(factor.levels))
return 1
def _coerce_factor(value: Factor | Mapping[str, Any]) -> Factor:
"""Coerce a mapping payload into a `Factor` instance."""
if isinstance(value, Factor):
return value
levels = tuple(
level
if isinstance(level, Level)
else Level(
name=str(cast(Mapping[str, Any], level)["name"]),
value=cast(Mapping[str, Any], level).get("value"),
label=cast(str | None, cast(Mapping[str, Any], level).get("label")),
metadata=dict(
cast(Mapping[str, Any], cast(Mapping[str, Any], level).get("metadata", {}))
),
)
for level in cast(Sequence[Any], value.get("levels", ()))
)
return Factor(
name=str(value["name"]),
description=str(value.get("description", "")),
kind=FactorKind(str(value.get("kind", FactorKind.MANIPULATED.value))),
levels=levels,
dtype=cast(str | None, value.get("dtype")),
default=value.get("default"),
metadata=dict(cast(Mapping[str, Any], value.get("metadata", {}))),
)
def _coerce_block(value: Block | Mapping[str, Any]) -> Block:
"""Coerce a mapping payload into a `Block` instance."""
if isinstance(value, Block):
return value
return Block(
name=str(value["name"]),
levels=tuple(cast(Sequence[Any], value.get("levels", ()))),
metadata=dict(cast(Mapping[str, Any], value.get("metadata", {}))),
)
def _coerce_constraint(value: Constraint | Mapping[str, Any]) -> Constraint:
"""Coerce a mapping payload into a `Constraint` instance."""
if isinstance(value, Constraint):
return value
return Constraint(
constraint_id=str(value["constraint_id"]),
description=str(value.get("description", "")),
expression=cast(str | None, value.get("expression")),
callable_ref=cast(str | None, value.get("callable_ref")),
severity=value.get("severity", "error"),
)
def build_default_provenance() -> dict[str, Any]:
"""Capture a baseline provenance payload for study manifests."""
return cast(
dict[str, Any],
to_jsonable(
ProvenanceMetadata.capture(
package_names=(
"design-research-experiments",
"design-research-agents",
"design-research-problems",
"design-research-analysis",
)
)
),
)