Source code for process_improve.experiments.strategy.engine

# (c) Kevin Dunn, 2010-2026. MIT License.

"""Deterministic rule engine for DOE strategy recommendation.

Implements ~50 decision rules from Montgomery, NIST, and Stat-Ease SCOR
to recommend multi-stage experimental strategies.  No LLM or randomness -
identical inputs always produce identical outputs.
"""

from __future__ import annotations

import hashlib
import json
import math
import re
import warnings
from typing import Any, Literal, cast

from process_improve.experiments.designs_response_surface import dsd_run_count
from process_improve.experiments.designs_screening import plackett_burman_runs
from process_improve.experiments.factor import Constraint, Factor, Response
from process_improve.experiments.strategy.budget import (
    allocate_budget,
    estimate_confirmation_runs,
    estimate_screening_runs,
)
from process_improve.experiments.strategy.domain_templates import get_domain_template
from process_improve.experiments.strategy.models import (
    DOEProblemSpec,
    DomainType,
    ExperimentalStage,
    ExperimentalStrategy,
    PriorKnowledge,
    TransitionRule,
)

# ---------------------------------------------------------------------------
# Prior knowledge parsing
# ---------------------------------------------------------------------------

_HIGH_KEYWORDS = re.compile(
    r"\b(confirmed|validated|published|well[-\s]established|proven|known\s+to\s+be\s+significant)\b",
    re.IGNORECASE,
)
_MEDIUM_KEYWORDS = re.compile(
    r"\b(literature\s+suggests?|preliminary\s+data|pilot\s+study|some\s+evidence|reported)\b",
    re.IGNORECASE,
)
_LOW_KEYWORDS = re.compile(
    r"\b(suspect|expected|based\s+on\s+theory|similar\s+process|assume)\b",
    re.IGNORECASE,
)
_NO_KEYWORDS = re.compile(
    r"\b(no\s+prior|first\s+time|unknown|exploratory|no\s+data|no\s+knowledge)\b",
    re.IGNORECASE,
)
_SIGNIFICANT_FACTOR_PATTERN = re.compile(
    # SEC-29 (#278): bounded the capture group so a multi-KB whitespace
    # payload can no longer trigger O(n^2) regex matching. ``\w+(?:\s\w+){0,4}``
    # is the longest realistic factor name we expect ("two-stage reactor
    # temperature") and matches in linear time.
    r"\b(\w+(?:\s\w+){0,4})\s+(?:is|are)\s+(?:known\s+to\s+be\s+)?(?:significant|important|key|critical)\b",
    re.IGNORECASE,
)

# SEC-29 (#278): cap the length of the free-text prior so a caller
# cannot send a multi-MB string that swamps the regex engine even with
# the bounded pattern above. 4 KiB comfortably accommodates any
# realistic prior-knowledge note and slams the door on payload DoS.
_PRIOR_KNOWLEDGE_MAX_CHARS = 4096


def _parse_prior_knowledge(
    text: str | None,
    factor_names: list[str],
) -> PriorKnowledge:
    """Map free-text prior knowledge to a structured confidence level.

    The strongest evidence cue in the text sets the confidence: confirmed, validated or
    published results (0.9) outrank reported or preliminary data (0.7), which outrank
    suspicion or theory (0.4), which outrank a statement of no prior knowledge (0.1). A
    weaker cue elsewhere in the text, such as one factor whose role is "unknown", does
    not lower the confidence that the strongest cue sets.
    """
    if not text or not text.strip():
        return PriorKnowledge(raw_text="", confidence=0.0)

    text = text.strip()
    if len(text) > _PRIOR_KNOWLEDGE_MAX_CHARS:
        raise ValueError(
            f"prior_knowledge text is {len(text)} characters; the maximum "
            f"is {_PRIOR_KNOWLEDGE_MAX_CHARS}. Trim the input to its "
            "relevant prose."
        )

    # Score based on keyword matching, strongest evidence first
    confidence = 0.0
    has_supporting_data = False

    if _HIGH_KEYWORDS.search(text):
        confidence = 0.9
        has_supporting_data = True
    elif _MEDIUM_KEYWORDS.search(text):
        confidence = 0.7
        has_supporting_data = "data" in text.lower() or "study" in text.lower()
    elif _LOW_KEYWORDS.search(text):
        confidence = 0.4
    elif _NO_KEYWORDS.search(text):
        confidence = 0.1
    else:
        # No clear keywords - assign moderate-low confidence
        confidence = 0.3

    # Extract factor names mentioned near significance keywords
    known_factors: list[str] = []
    for match in _SIGNIFICANT_FACTOR_PATTERN.finditer(text):
        candidate = match.group(1).strip()
        # Match against actual factor names (case-insensitive substring)
        for fn in factor_names:
            if (fn.lower() in candidate.lower() or candidate.lower() in fn.lower()) and fn not in known_factors:
                known_factors.append(fn)

    return PriorKnowledge(
        raw_text=text,
        confidence=confidence,
        known_significant_factors=known_factors,
        known_ranges_reliable=confidence >= 0.6,
        has_supporting_data=has_supporting_data,
    )


# ---------------------------------------------------------------------------
# Problem classification
# ---------------------------------------------------------------------------


def _classify_problem(spec: DOEProblemSpec) -> dict[str, Any]:
    """Classify the DOE problem into categories for rule matching."""
    n = spec.n_factors
    prior_conf = spec.prior_knowledge.confidence if spec.prior_knowledge else 0.0

    # Budget tightness
    budget_per_factor = spec.budget / n if spec.budget and n > 0 else float("inf")
    is_tight = budget_per_factor < 4
    is_very_tight = budget_per_factor < 2.5

    return {
        "n_factors": n,
        "n_continuous": spec.n_continuous,
        "n_categorical": spec.n_categorical,
        "n_mixture": spec.n_mixture,
        "has_mixture": spec.has_mixture,
        "has_hard_to_change": spec.has_hard_to_change,
        "has_constraints": spec.has_constraints,
        "prior_confidence": prior_conf,
        "budget": spec.budget,
        "budget_per_factor": budget_per_factor,
        "is_tight_budget": is_tight,
        "is_very_tight_budget": is_very_tight,
        "has_existing_data": spec.existing_data_summary is not None,
    }


# ---------------------------------------------------------------------------
# Run counts
# ---------------------------------------------------------------------------

#: Design types whose run count is their ``budget``; they are chosen by an optimality criterion.
_OPTIMAL_TYPES = frozenset({"d_optimal", "i_optimal", "a_optimal", "e_optimal"})

#: Runs beyond the model's coefficient count in the smallest optimal design the planner
#: proposes, so the fitted model keeps some residual degrees of freedom.
_MIN_RESIDUAL_RUNS = 3

#: Multiple of the model's coefficient count an optimal design is given when the budget allows.
_OPTIMAL_RUNS_PER_TERM = 1.5


def _n_parameters(factors: list[Factor], model_type: str) -> int:
    """Return the number of coefficients in ``model_type`` over ``factors``.

    The Scheffe models count the mixture blending terms; the others count as the
    optimal designs do (a categorical factor with ``L`` levels takes ``L - 1`` columns).
    """
    q = len(factors)
    if model_type == "scheffe_linear":
        return q
    if model_type == "scheffe_quadratic":
        return q * (q + 1) // 2
    if model_type == "scheffe_special_cubic":
        return q * (q + 1) // 2 + math.comb(q, 3)
    if model_type == "scheffe_cubic":
        return q * q + math.comb(q, 3)  # q + 2 C(q, 2) + C(q, 3)
    from process_improve.experiments.designs_optimal import _n_model_parameters  # noqa: PLC0415

    return _n_model_parameters(factors, model_type)


def _optimal_runs(factors: list[Factor], model_type: str) -> int:
    """Return the runs the planner gives an optimal design: 1.5 times the model's coefficients."""
    return math.ceil(_OPTIMAL_RUNS_PER_TERM * _n_parameters(factors, model_type))


def _on_full_simplex(factors: list[Factor], constraints: list[Any] | None) -> bool:
    """Whether mixture components span the whole simplex (no bound inside (0, 1), no constraint)."""
    bounded = any((f.low or 0.0) > 0.0 or (f.high if f.high is not None else 1.0) < 1.0 for f in factors)
    return not bounded and not constraints


def _built_runs(factors: list[Factor], design_type: str, params: dict[str, Any]) -> int:
    """Return the number of runs ``generate_design(factors, design_type=design_type, **params)`` gives.

    The ``budget`` sets the size of an optimal design (never fewer runs than its model
    has coefficients), of a supersaturated design and of a mixture design in a bounded or
    constrained region. The classical designs are built, which is quick, so their count
    is exactly the one ``generate_design`` gives.
    """
    if design_type in _OPTIMAL_TYPES:
        return max(int(params["budget"]), _n_parameters(factors, params.get("model_type", "interactions")))
    if design_type == "supersaturated" or (
        design_type == "mixture" and not _on_full_simplex(factors, params.get("constraints"))
    ):
        return int(params["budget"])
    from process_improve.experiments.designs import generate_design  # noqa: PLC0415

    # Only the size is wanted: a warning about a design the planner merely tries, such as a
    # smaller variant it then rejects, would only mislead the caller.
    with warnings.catch_warnings():
        warnings.simplefilter("ignore")
        return generate_design(factors, design_type=design_type, **params).n_runs


def _factors_named(spec: DOEProblemSpec, names: list[str]) -> list[Factor]:
    """Return the factors called ``names``, in the order the problem lists them."""
    return [f for f in spec.factors if f.name in names]


def _constraint_params(spec: DOEProblemSpec) -> dict[str, Any]:
    """Return the ``constraints`` keyword for a stage that honours them (empty without constraints)."""
    return {"constraints": [c.model_dump() for c in spec.constraints]} if spec.constraints else {}


# ---------------------------------------------------------------------------
# Screening design selection
# ---------------------------------------------------------------------------


def _mixture_screening_stage(spec: DOEProblemSpec, n: int) -> ExperimentalStage:
    """Screening stage when all factors are mixture components.

    A budget of ``max(q + 1, 6)`` blends gives the {q, 2} simplex lattice on the full
    simplex (the simplex centroid for two or three components, whose ``2^q - 1`` blends fit
    that budget), and a D-optimal choice of that many blends for the linear Scheffe model
    when the components have bounds or constraints.
    """
    params: dict[str, Any] = {"model_type": "scheffe_linear", "budget": max(n + 1, 6), **_constraint_params(spec)}
    return ExperimentalStage(
        stage_number=1,
        stage_name="Screening",
        design_type="mixture",
        design_params=params,
        factors=spec.factor_names,
        estimated_runs=_built_runs(spec.factors, "mixture", params),
        purpose="Screen mixture components to identify significant proportions.",
        success_criteria={"min_significant_factors": 1},
        transition_rules=_screening_transition_rules(),
    )


def _optimal_screening_stage(spec: DOEProblemSpec, n: int) -> ExperimentalStage:
    """Screening stage with a categorical factor of more than two levels: a D-optimal main-effects design.

    The two-level designs (factorials, Plackett-Burman, DSD) take two-level categorical
    factors only.
    """
    params = {"model_type": "main_effects", "budget": _optimal_runs(spec.factors, "main_effects")}
    return ExperimentalStage(
        stage_number=1,
        stage_name="Screening",
        design_type="d_optimal",
        design_params=params,
        factors=spec.factor_names,
        estimated_runs=_built_runs(spec.factors, "d_optimal", params),
        purpose=(
            f"Screen {n} factors with a D-optimal main-effects design, which places categorical factors "
            "at all their levels."
        ),
        success_criteria={"min_significant_factors": 1},
        transition_rules=_screening_transition_rules(),
    )


def _factorial_screening_stage(spec: DOEProblemSpec, n: int) -> ExperimentalStage:
    """Screening stage for 3-5 all-continuous factors (full or fractional factorial)."""
    design_type = "full_factorial" if n <= 4 else "fractional_factorial"
    params = {"n_center_points": 3, "resolution": 4 if n >= 5 else None}
    return ExperimentalStage(
        stage_number=1,
        stage_name="Screening",
        design_type=design_type,
        design_params=params,
        factors=spec.factor_names,
        estimated_runs=_built_runs(spec.factors, design_type, params),
        purpose=f"Screen {n} factors to identify significant main effects and interactions.",
        success_criteria={"min_significant_factors": 1},
        transition_rules=_screening_transition_rules(),
    )


def _supersaturated_runs(n: int, classification: dict[str, Any]) -> int | None:
    """Most runs within a budget below ``n + 1`` that give an unaliased supersaturated design, if any.

    Such a budget cannot estimate every main effect, so the screening design has to
    be supersaturated: the factors must all be continuous, run at two levels.
    """
    from process_improve.experiments.designs_supersaturated import supersaturated_runs  # noqa: PLC0415

    budget = classification["budget"]
    if budget is None or budget >= n + 1 or classification["n_continuous"] != n:
        return None
    return supersaturated_runs(n, budget)


def _large_factor_screening_choice(n: int, classification: dict[str, Any], template: dict[str, Any]) -> tuple[str, int]:
    """Choose (design_type, estimated_runs) when the factorial/mixture rules do not apply.

    A budget below ``n + 1`` gets a supersaturated design when one exists for it.
    Otherwise the ordered decision chain holds: definitive screening (very tight
    budget, curvature preference, moderate prior confidence, or continuous factors
    with room in the budget and no domain preference) takes precedence, then
    Plackett-Burman (explicit preference or 6+ factors with adequate budget), then an
    explicit fractional-factorial preference, with Plackett-Burman as the default.
    The returned key is a ``generate_design`` design type.

    A DSD is the default for continuous factors because its main effects are
    orthogonal to every two-factor interaction and square. In a 12-run Plackett-Burman
    design each interaction biases the main effects of the factors outside it by a
    third of its size, which can shrink a real factor to the size of the biased
    inactive ones, so the analysis misses it.
    """
    if (runs := _supersaturated_runs(n, classification)) is not None:
        return "supersaturated", runs
    prefer_curvature = template.get("prefer_curvature_detection", False)
    domain_pref = template.get("screening_preference")
    roomy_continuous = (
        domain_pref is None and classification["n_continuous"] == n and not classification["is_tight_budget"]
    )

    if (
        classification["is_very_tight_budget"]
        or prefer_curvature
        or domain_pref == "dsd"
        or classification["prior_confidence"] >= 0.6
        or roomy_continuous
    ):
        return "dsd", estimate_screening_runs(n, "dsd")
    if domain_pref == "plackett_burman" or (n >= 6 and not classification["is_tight_budget"]):
        return "plackett_burman", estimate_screening_runs(n, "plackett_burman")
    if domain_pref == "fractional_factorial":
        return "fractional_factorial", estimate_screening_runs(n, "fractional_factorial") + 3
    return "plackett_burman", estimate_screening_runs(n, "plackett_burman")


#: Degrees of freedom for error a planned DSD leaves :func:`analyze_omars`. With two, its
#: t-tests' critical value is 4.3 and a simulated 8-factor study missed an active factor 6%
#: of the time; with four it is 2.8, and it missed none.
_DSD_ERROR_DF = 4


def _dsd_params(n_factors: int) -> dict[str, Any]:
    """Return the ``generate_design`` keyword arguments for a DSD of ``n_factors`` continuous factors.

    The staged analysis (:func:`analyze_omars`) estimates the error from what the full
    second-order model cannot fit. The DSD of an even number of factors leaves it nothing,
    and of an odd number one degree of freedom less than its centre runs; each centre run
    added gives one more. Centre runs are added until :data:`_DSD_ERROR_DF` are left: five
    in all for an even number of factors, four for an odd number.
    """
    return {"n_center_points": _DSD_ERROR_DF + 1 if n_factors % 2 == 0 else _DSD_ERROR_DF}


def _dsd_error_df(n_factors: int, params: dict[str, Any]) -> int:
    """Return the degrees of freedom for error a DSD built with ``params`` leaves :func:`analyze_omars`."""
    centres = max(int(params.get("n_center_points") or 1), 1)
    return centres - 1 if n_factors % 2 == 0 else centres


def _screening_design_params(
    design_type: str, runs: int, *, all_continuous: bool = True, n_factors: int | None = None
) -> dict[str, Any]:
    """Build the ``generate_design`` keyword arguments for a large-factor screening stage.

    A definitive screening design takes the centre runs that leave its analysis four
    degrees of freedom for error (see :func:`_dsd_params`). A Plackett-Burman design gets three centre runs when
    every factor is continuous, for two reasons: they test for curvature, which is
    what decides whether the optimisation stage is needed at all, and the extra
    degrees of freedom let a stepwise search hold an interaction beside the main
    effects, which removes the bias that interaction puts on them. A categorical
    factor has no centre, so then it gets none.
    """
    if design_type == "fractional_factorial":
        return {"resolution": 4, "n_center_points": 3}
    if design_type == "plackett_burman":
        return {"n_center_points": 3 if all_continuous else 0}
    if design_type == "dsd" and n_factors is not None and all_continuous:
        return _dsd_params(n_factors)
    if design_type == "supersaturated":
        return {"budget": runs}
    return {}


def _select_screening_design(
    spec: DOEProblemSpec,
    classification: dict[str, Any],
    template: dict[str, Any],
) -> ExperimentalStage | None:
    """Select the appropriate screening design based on decision rules."""
    n = classification["n_factors"]

    # Rule: 2 or fewer factors - no screening needed.
    if n <= 2:
        return None
    # Rule: high prior confidence - skip screening.
    if classification["prior_confidence"] >= 0.8:
        return None
    # Rule: mixture factors only - use the mixture design path.
    if spec.has_mixture and spec.n_continuous == 0:
        return _mixture_screening_stage(spec, n)
    # Rule: a categorical factor with more than two levels - the two-level designs cannot place it.
    if any(f.type.value == "categorical" and len(f.levels or []) > 2 for f in spec.factors):
        return _optimal_screening_stage(spec, n)
    # Rule: 3-5 factors, all continuous - full/fractional factorial.
    if 3 <= n <= 5 and spec.n_continuous == n:
        return _factorial_screening_stage(spec, n)

    # Remaining cases (typically 6+ factors): Plackett-Burman, DSD, or fractional factorial.
    design_type, runs = _large_factor_screening_choice(n, classification, template)
    params = _screening_design_params(design_type, runs, all_continuous=spec.n_continuous == n, n_factors=n)
    return ExperimentalStage(
        stage_number=1,
        stage_name="Screening",
        design_type=design_type,
        design_params=params,
        factors=spec.factor_names,
        estimated_runs=_built_runs(spec.factors, design_type, params),
        purpose=f"Screen {n} candidate factors to identify the vital few.",
        success_criteria={"min_significant_factors": 1, "max_significant_factors": 5},
        transition_rules=_screening_transition_rules(),
    )


def _screening_transition_rules() -> list[TransitionRule]:
    """Return standard transition rules after a screening stage."""
    return [
        TransitionRule(
            condition="0-1 significant factors identified",
            action="broaden_factor_ranges",
            fallback="check_measurement_system",
        ),
        TransitionRule(
            condition="2-5 significant factors identified",
            action="proceed_to_optimization",
            fallback="proceed_to_optimization",
        ),
        TransitionRule(
            condition="6+ significant factors identified",
            action="sub_group_factors",
            fallback="run_additional_screening",
        ),
        TransitionRule(
            condition="Curvature detected via center points",
            action="augment_to_ccd",
            fallback="proceed_to_optimization",
        ),
    ]


# ---------------------------------------------------------------------------
# RSM design selection
# ---------------------------------------------------------------------------


def _mixture_optimization_stage(spec: DOEProblemSpec, stage_number: int) -> ExperimentalStage:
    """Quadratic Scheffe stage for an all-mixture problem.

    Components cannot be dropped after screening the way process factors can (the
    proportions must still sum to 1), and a CCD or Box-Behnken design in the components
    would not give proportions, so every component stays in a mixture design. On the full
    simplex that is the simplex centroid (``2^q - 1`` blends); in a bounded or constrained
    region, a D-optimal choice of 1.5 times the model's coefficients.
    """
    params: dict[str, Any] = {"model_type": "scheffe_quadratic", **_constraint_params(spec)}
    if not _on_full_simplex(spec.factors, spec.constraints):
        params["budget"] = _optimal_runs(spec.factors, "scheffe_quadratic")
    return ExperimentalStage(
        stage_number=stage_number,
        stage_name="Optimization",
        design_type="mixture",
        design_params=params,
        factors=spec.factor_names,
        estimated_runs=_built_runs(spec.factors, "mixture", params),
        purpose="Fit a quadratic Scheffe blending model over the mixture region, to locate the best blend.",
        success_criteria={"min_r_squared": 0.7, "adequate_precision": 4.0},
        transition_rules=[
            TransitionRule(
                condition="Model is adequate (R² > 0.7, adequate precision > 4)",
                action="proceed_to_confirmation",
                fallback="augment_design_or_transform_response",
            ),
        ],
    )


def _rsm_factor_names(spec: DOEProblemSpec, has_screening: bool) -> list[str]:
    """Factors the optimisation stage is planned over.

    Without screening these are the factors the prior knowledge names as significant when
    that knowledge is strong enough to skip screening, and otherwise every factor. After
    screening the stage needs the factors screening finds; until then it lists three
    placeholders, the factors the prior knowledge names first.
    """
    pk = spec.prior_knowledge
    known = [name for name in spec.factor_names if pk and name in pk.known_significant_factors]
    if not has_screening:
        return known if known and pk is not None and pk.confidence >= 0.8 else spec.factor_names
    ordered = known + [name for name in spec.factor_names if name not in known]
    return ordered[: min(spec.n_factors, 3)]


def _optimal_rsm_design(spec: DOEProblemSpec, factors: list[Factor]) -> tuple[str, dict[str, Any], str]:
    """D-optimal quadratic design: for constraints, categorical factors or a single factor."""
    params: dict[str, Any] = {"model_type": "quadratic", "budget": _optimal_runs(factors, "quadratic")}
    params.update(_constraint_params(spec))
    if spec.has_constraints:
        reason = "D-optimal RSM design over the constrained factor space."
    elif len(factors) == 1:
        reason = "A three-level D-optimal design (low, centre and high settings, replicated) fits the quadratic."
    else:
        reason = (
            "D-optimal RSM design: a CCD or Box-Behnken design needs continuous factors, so the categorical "
            "factors enter as main effects and interactions."
        )
    return "d_optimal", params, reason


def _rsm_design(
    spec: DOEProblemSpec,
    factors: list[Factor],
    template: dict[str, Any],
    has_screening: bool,
) -> tuple[str, dict[str, Any], str]:
    """Choose the optimisation design over ``factors``: (design_type, design_params, reason).

    A CCD is face-centred. The factors' low and high settings are all the planner knows
    of the region, which makes it a cube, and a rotatable CCD's axial runs leave that
    cube (by 1.68 times the half-range for three factors), at settings that may not
    be possible to run, such as a negative concentration.
    """
    n_rsm = len(factors)
    if spec.has_constraints or n_rsm == 1 or any(f.type.value == "categorical" for f in factors):
        return _optimal_rsm_design(spec, factors)

    domain_pref = template.get("rsm_preference")
    params: dict[str, Any] = {"n_center_points": template.get("min_center_points", 3)}
    # Box-Behnken designs exist for 3 to 7 factors.
    if has_screening and domain_pref in ("ccd", "ccd_face_centered", None):
        reason = (
            "A face-centred CCD adds axial and centre runs to a two-level base, with every run inside the factor "
            "ranges."
        )
    elif 3 <= n_rsm <= 7 and (domain_pref == "box_behnken" or not has_screening):
        return "box_behnken", params, "BBD for response surface modeling - fewer runs, avoids extreme corners."
    else:
        reason = "Face-centred CCD keeps every run within the factor ranges."
    params["alpha"] = "face_centered"
    if n_rsm >= 6:
        params["cube"] = "fractional"  # a resolution V half fraction, not the full 2^k cube
    return "ccd", params, reason


def _select_rsm_design(
    spec: DOEProblemSpec,
    classification: dict[str, Any],
    template: dict[str, Any],
    has_screening: bool,
) -> ExperimentalStage | None:
    """Select the RSM optimisation design."""
    if not spec.goal_includes_optimization and classification["n_factors"] > 5:
        return None
    stage_number = 2 if has_screening else 1
    if spec.has_mixture and spec.n_continuous == 0:
        return _mixture_optimization_stage(spec, stage_number=stage_number)

    names = _rsm_factor_names(spec, has_screening)
    factors = _factors_named(spec, names)
    design_type, params, reason = _rsm_design(spec, factors, template, has_screening)
    n_rsm = len(names)
    if has_screening:
        factor_label = (
            f"the {n_rsm} significant factors. The listed factors are placeholders: run this stage on "
            "the factors screening finds significant"
        )
    else:
        factor_label = f"all {n_rsm} factors" if n_rsm == spec.n_factors else f"the {n_rsm} known significant factors"

    return ExperimentalStage(
        stage_number=stage_number,
        stage_name="Optimization",
        design_type=design_type,
        design_params=params,
        factors=names,
        estimated_runs=_built_runs(factors, design_type, params),
        purpose=f"Fit quadratic response surface model for {factor_label}. {reason}",
        success_criteria={"min_r_squared": 0.7, "adequate_precision": 4.0},
        transition_rules=[
            TransitionRule(
                condition="Model is adequate (R² > 0.7, adequate precision > 4)",
                action="proceed_to_confirmation",
                fallback="augment_design_or_transform_response",
            ),
            TransitionRule(
                condition="Saddle point detected",
                action="perform_ridge_analysis",
                fallback="proceed_to_confirmation",
            ),
        ],
    )


# ---------------------------------------------------------------------------
# Confirmation stage
# ---------------------------------------------------------------------------


def _build_confirmation_stage(
    spec: DOEProblemSpec,
    stage_number: int,
    min_confirmation: int = 3,
) -> ExperimentalStage:
    """Build the confirmation stage (always included)."""
    n_runs = estimate_confirmation_runs(min_confirmation)
    return ExperimentalStage(
        stage_number=stage_number,
        stage_name="Confirmation",
        design_type="replicates_at_optimum",
        design_params={"n_replicates": n_runs},
        factors=spec.factor_names,
        estimated_runs=n_runs,
        purpose=(
            "Run replicates at the predicted optimum to verify the model predictions. "
            "Compare their mean with the prediction interval for the mean of that many runs (analyze_experiment "
            "with analysis_type='confirmation_test' reports the verdict as 'confirmed')."
        ),
        success_criteria={"observed_within_prediction_interval": True},
        transition_rules=[
            TransitionRule(
                condition="Observed values within prediction intervals",
                action="accept_optimum",
                fallback="investigate_discrepancy",
            ),
        ],
    )


# ---------------------------------------------------------------------------
# Conditional stages
# ---------------------------------------------------------------------------

#: Two-level screening designs whose centre runs, when they have some, test for curvature.
_TWO_LEVEL_SCREENING = frozenset({"full_factorial", "fractional_factorial", "plackett_burman"})


def _curvature_gate(screening: ExperimentalStage, spec: DOEProblemSpec) -> str:
    """Say what ``screening`` must show for the optimisation stage after it to be run; "" if it cannot show it.

    A DSD runs every factor at three levels, and a two-level design with centre runs
    compares its centre with its corners, so either can show whether a response
    surface is needed. Any other screening design cannot, and the optimisation stage
    after it is planned unconditionally.
    """
    if screening.design_type == "dsd":
        error_df = _dsd_error_df(spec.n_factors, screening.design_params)
        if error_df >= _DSD_ERROR_DF:
            analysis = "analyze_omars"
        elif error_df:
            analysis = (
                f"analyze_omars, whose {error_df} degree(s) of freedom for error can miss a smaller active effect"
            )
        else:
            analysis = (
                "analyze_experiment with analysis_type='model_selection', since without extra centre runs "
                "analyze_omars has no degrees of freedom for error"
            )
        return (
            f"the analysis of the definitive screening design ({analysis}) finds more than three active "
            "factors, or active second-order terms it cannot separate. A DSD already fits the full quadratic "
            "model in any three factors, so with three or fewer active factors the study goes straight to "
            "confirmation at that model's optimum."
        )
    has_centre = bool(screening.design_params.get("n_center_points"))
    if screening.design_type in _TWO_LEVEL_SCREENING and has_centre and spec.n_continuous == spec.n_factors:
        return (
            "the centre runs show curvature (analyze_experiment with analysis_type='curvature_test'). Without "
            "it, the screening model of main effects and interactions puts the best settings at a corner of "
            "the region, so the study goes straight to confirmation there."
        )
    return ""


def _mark_conditional_stages(stages: list[ExperimentalStage], spec: DOEProblemSpec) -> list[ExperimentalStage]:
    """Make an optimisation stage conditional when the screening stage before it can test for curvature.

    Run last, on the stages as the budget left them: a tight budget can take the
    centre runs out of a screening design, and then there is nothing to decide with.
    """
    marked = list(stages)
    for i in range(1, len(marked)):
        if marked[i - 1].stage_name != "Screening" or marked[i].stage_name != "Optimization":
            continue
        gate = _curvature_gate(marked[i - 1], spec)
        if gate:
            marked[i] = marked[i].model_copy(update={"conditional": True, "run_if": gate})
    return marked


# ---------------------------------------------------------------------------
# Hard-to-change factor wrapping
# ---------------------------------------------------------------------------


def _apply_split_plot(
    stages: list[ExperimentalStage],
    spec: DOEProblemSpec,
) -> list[ExperimentalStage]:
    """Give each stage with a hard-to-change factor a split-plot design.

    ``generate_design`` builds split-plot structure only in the optimal families, so such
    a stage becomes a D-optimal design of the same size, for main effects when screening
    and the quadratic model when optimising, with ``hard_to_change`` naming its
    whole-plot factors.
    """
    if not spec.has_hard_to_change:
        return stages

    htc = set(spec.hard_to_change_factors or [])
    updated: list[ExperimentalStage] = []
    for stage in stages:
        whole_plot = [name for name in stage.factors if name in htc]
        if stage.stage_name not in ("Screening", "Optimization") or not whole_plot:
            updated.append(stage)
            continue
        if stage.design_type in _OPTIMAL_TYPES:
            params = {**stage.design_params, "hard_to_change": whole_plot}
        else:
            model_type = "main_effects" if stage.stage_name == "Screening" else "quadratic"
            params = {
                "model_type": model_type,
                "budget": stage.estimated_runs,
                "hard_to_change": whole_plot,
                **_constraint_params(spec),
            }
        factors = _factors_named(spec, stage.factors)
        purpose = (
            f"{stage.purpose} Split-plot: {', '.join(whole_plot)} change only between whole plots, "
            "so the runs come from a D-optimal split-plot design."
        )
        updated.append(
            stage.model_copy(
                update={
                    "design_type": "d_optimal",
                    "design_params": params,
                    "estimated_runs": _built_runs(factors, "d_optimal", params),
                    "purpose": purpose,
                }
            )
        )

    return updated


# ---------------------------------------------------------------------------
# Prior knowledge adjustments
# ---------------------------------------------------------------------------


def _apply_prior_knowledge(
    stages: list[ExperimentalStage],
    spec: DOEProblemSpec,
) -> list[ExperimentalStage]:
    """Adjust stages based on prior knowledge confidence."""
    if not spec.prior_knowledge or spec.prior_knowledge.confidence < 0.8:
        return stages

    pk = spec.prior_knowledge

    # High confidence with supporting data → skip screening
    if pk.confidence >= 0.8 and pk.has_supporting_data:
        stages = [s for s in stages if s.stage_name != "Screening"]
        # Re-number remaining stages
        for i, stage in enumerate(stages):
            stages[i] = stage.model_copy(update={"stage_number": i + 1})

    return stages


# ---------------------------------------------------------------------------
# Budget adjustment
# ---------------------------------------------------------------------------

#: Stage name to the key ``allocate_budget`` uses for its share.
_STAGE_BUDGET_KEYS = {"Screening": "screening", "Optimization": "optimization", "Confirmation": "confirmation"}

#: Fewest factors for which a DSD fits the full quadratic model in any three of them
#: (Jones and Nachtsheim 2011).
_DSD_PROJECTS_TO_QUADRATIC = 6

#: Classical screening designs a tight budget may replace with a saturated Plackett-Burman design.
_SHRINKABLE_SCREENING = frozenset({"full_factorial", "fractional_factorial", "plackett_burman", "dsd"})


def _stage_variants(stage: ExperimentalStage, factors: list[Factor]) -> list[tuple[str, dict[str, Any]]]:
    """Versions of ``stage`` from its ideal design down to the smallest the planner accepts.

    Screening drops its centre points, then becomes a Plackett-Burman design. Optimisation
    keeps two centre points (one degree of freedom for pure error), then becomes a D-optimal
    quadratic design with three runs more than the model has coefficients, as does any
    optimal design. Confirmation drops to three replicates.
    """
    design_type, params = stage.design_type, stage.design_params
    variants: list[tuple[str, dict[str, Any]]] = [(design_type, params)]
    if stage.stage_name == "Confirmation":
        variants.append((design_type, {**params, "n_replicates": 3}))
    elif design_type in _OPTIMAL_TYPES or (design_type == "mixture" and "budget" in params):
        smallest = _n_parameters(factors, params.get("model_type", "interactions")) + _MIN_RESIDUAL_RUNS
        variants.append((design_type, {**params, "budget": smallest}))
    elif stage.stage_name == "Screening" and design_type in _SHRINKABLE_SCREENING:
        if params.get("n_center_points"):
            variants.append((design_type, {**params, "n_center_points": 0}))
        variants.append(("plackett_burman", {"n_center_points": 0}))
    elif stage.stage_name == "Optimization" and design_type in ("ccd", "box_behnken"):
        if params.get("n_center_points", 3) > 2:
            variants.append((design_type, {**params, "n_center_points": 2}))
        smallest = _n_parameters(factors, "quadratic") + _MIN_RESIDUAL_RUNS
        variants.append(("d_optimal", {"model_type": "quadratic", "budget": smallest}))
    return variants


def _variant_runs(stage: ExperimentalStage, spec: DOEProblemSpec) -> list[tuple[str, dict[str, Any], int]]:
    """Each variant of ``stage`` with its run count, keeping only those that save runs."""
    factors = _factors_named(spec, stage.factors)
    out: list[tuple[str, dict[str, Any], int]] = []
    for design_type, params in _stage_variants(stage, factors):
        if design_type == "replicates_at_optimum":
            runs = int(params["n_replicates"])
        else:
            runs = _built_runs(factors, design_type, params)
        if not out or runs < out[-1][2]:
            out.append((design_type, params, runs))
    return out


def _single_dsd_plan(
    stages: list[ExperimentalStage], spec: DOEProblemSpec, *, extra_centres: bool = True
) -> list[ExperimentalStage] | None:
    """One definitive screening design in place of separate screening and optimisation stages.

    The DSD has the centre runs that leave its analysis :data:`_DSD_ERROR_DF` degrees of
    freedom for error, or, with ``extra_centres=False``, only its own. Returns None when the
    plan has no such pair, or when a DSD cannot take the factors (fewer than three, any not
    continuous, or hard-to-change factors needing a split plot).
    """
    names = {s.stage_name for s in stages}
    if (
        not {"Screening", "Optimization"} <= names
        or spec.n_factors < 3
        or spec.n_continuous != spec.n_factors
        or spec.has_hard_to_change
    ):
        return None
    params = _dsd_params(spec.n_factors) if extra_centres else {}
    fits = (
        "it estimates the full quadratic model in up to three active factors"
        if spec.n_factors >= _DSD_PROJECTS_TO_QUADRATIC
        else "it estimates every main effect clear of the second-order terms"
    )
    dsd = ExperimentalStage(
        stage_number=1,
        stage_name="Screening and optimization",
        design_type="dsd",
        design_params=params,
        factors=spec.factor_names,
        estimated_runs=_built_runs(spec.factors, "dsd", params),
        purpose=f"Screen all {spec.n_factors} factors and detect curvature in one definitive screening design; {fits}.",
        success_criteria={"min_significant_factors": 1, "max_significant_factors": 3},
        transition_rules=_screening_transition_rules(),
    )
    confirmation = next(s for s in stages if s.stage_name == "Confirmation")
    smallest = confirmation.model_copy(update={"design_params": {"n_replicates": 3}, "estimated_runs": 3})
    return [dsd, smallest]


def _few_error_df_note(spec: DOEProblemSpec) -> str:
    """Say what a DSD without its extra centre runs leaves its analysis."""
    error_df = _dsd_error_df(spec.n_factors, {})
    return (
        f" To fit the budget it has no extra centre runs, so its analysis has {error_df} degree(s) of freedom for "
        "error and can miss a smaller active effect."
    )


def _lost_curvature_test(ideal: list[ExperimentalStage], fitted: list[ExperimentalStage], spec: DOEProblemSpec) -> bool:
    """Whether reducing the plan took away its screening design's test for curvature.

    Asked only for six or more factors, where the DSD that would replace the plan fits
    a full quadratic model in any three of them; a smaller DSD cannot.
    """
    if spec.n_factors < _DSD_PROJECTS_TO_QUADRATIC:
        return False
    screening = [
        (before, after)
        for before, after in zip(ideal, fitted, strict=True)
        if before.stage_name == "Screening" and any(s.stage_name == "Optimization" for s in ideal)
    ]
    return any(_curvature_gate(before, spec) and not _curvature_gate(after, spec) for before, after in screening)


def _shrink_to_budget(
    stages: list[ExperimentalStage], spec: DOEProblemSpec, budget_alloc: dict[str, Any], budget: int
) -> tuple[list[ExperimentalStage], list[str]]:
    """Take stages to smaller designs, the one furthest over its share first, until they fit or none can shrink.

    Returns the stages as reduced and a description of each change.
    """
    options = [_variant_runs(stage, spec) for stage in stages]
    level = [0] * len(stages)

    def runs(i: int) -> int:
        return options[i][level[i]][2]

    while sum(runs(i) for i in range(len(stages))) > budget:
        shrinkable = [i for i in range(len(stages)) if level[i] + 1 < len(options[i])]
        if not shrinkable:
            break
        share = [budget_alloc.get(_STAGE_BUDGET_KEYS.get(s.stage_name, ""), 0) for s in stages]
        level[max(shrinkable, key=lambda i: runs(i) - share[i])] += 1

    fitted: list[ExperimentalStage] = []
    changes: list[str] = []
    for i, stage in enumerate(stages):
        design_type, params, n_runs = options[i][level[i]]
        if level[i]:
            changes.append(
                f"{stage.stage_name}: {stage.design_type} ({stage.estimated_runs} runs) "
                f"became {design_type} ({n_runs} runs)"
            )
        fitted.append(
            stage.model_copy(update={"design_type": design_type, "design_params": params, "estimated_runs": n_runs})
        )
    return fitted, changes


def _apply_budget_constraints(
    stages: list[ExperimentalStage],
    spec: DOEProblemSpec,
    budget_alloc: dict[str, Any],
) -> tuple[list[ExperimentalStage], list[str]]:
    """Change the stages' designs until the plan fits the budget. Returns (stages, warnings).

    Each step takes the stage furthest over its share of the budget (from
    ``allocate_budget``) to its next smaller design, so ``estimated_runs`` is always what
    the stage's ``design_params`` build. One definitive screening design replaces the
    screening and optimisation stages, if it fits, in two cases: when every stage is as
    small as it goes and the plan still does not fit, and when the reduction took away
    the screening design's test for curvature, which would leave the optimisation stage
    to be run whatever the screening shows. Otherwise the warning says the budget is too
    small.
    """
    budget = spec.budget
    ideal_total = sum(s.estimated_runs for s in stages)
    if budget is None or ideal_total <= budget:
        return stages, []

    fitted, changes = _shrink_to_budget(stages, spec, budget_alloc, budget)
    smallest_total = sum(s.estimated_runs for s in fitted)
    single = _single_dsd_plan(stages, spec)
    single_total = sum(s.estimated_runs for s in single) if single is not None else smallest_total
    if single is not None and single_total <= budget and _lost_curvature_test(stages, fitted, spec):
        return single, [
            (
                f"Budget of {budget} runs is below the {ideal_total} runs of the ideal plan, and reducing it would "
                "leave a screening design that cannot test for curvature followed by an optimisation stage that "
                "must then always be run. One definitive screening design instead screens the factors and tests "
                "for curvature; it fits a full quadratic model if at most three factors are active."
            )
        ]
    if smallest_total <= budget:
        return fitted, [
            (
                f"Budget of {budget} runs is below the {ideal_total} runs of the ideal plan, so the designs were "
                f"reduced to fit: {'; '.join(changes)}."
            )
        ]

    bare = _single_dsd_plan(stages, spec, extra_centres=False)
    for plan, note in ((single, ""), (bare, _few_error_df_note(spec))):
        if plan is not None and sum(s.estimated_runs for s in plan) <= budget:
            return plan, [
                (
                    f"Budget of {budget} runs cannot hold separate screening and optimisation stages (at least "
                    f"{smallest_total} runs), so one definitive screening design screens the factors and detects "
                    f"curvature; it fits a full quadratic model only if at most three factors are active.{note}"
                )
            ]
    if bare is not None and sum(s.estimated_runs for s in bare) < smallest_total:
        fitted, smallest_total = bare, sum(s.estimated_runs for s in bare)
    return fitted, [
        (
            f"Budget of {budget} runs is below the smallest plan the rules give ({smallest_total} runs, with "
            "every stage reduced as far as it goes). Raise the budget or study fewer factors; "
            "total_estimated_runs reports the runs the stages need."
        )
    ]


# ---------------------------------------------------------------------------
# Strategy ID
# ---------------------------------------------------------------------------


def _compute_strategy_id(spec: DOEProblemSpec) -> str:
    """Compute a deterministic strategy ID from every input that can change the strategy.

    The factors are hashed in order with their types, ranges and levels, because the
    order picks the optimisation stage's placeholder factors.
    """
    canonical = json.dumps(
        {
            "factors": [f.model_dump(mode="json") for f in spec.factors],
            "responses": sorted(
                (r.model_dump(mode="json") for r in spec.responses), key=lambda r: json.dumps(r, sort_keys=True)
            ),
            "budget": spec.budget,
            "constraints": [c.model_dump(mode="json") for c in spec.constraints or []],
            "prior_knowledge": spec.prior_knowledge.raw_text if spec.prior_knowledge else "",
            "existing_data": spec.existing_data_summary,
            "domain": spec.domain.value,
            "detail_level": spec.detail_level,
            "htc": sorted(spec.hard_to_change_factors or []),
        },
        sort_keys=True,
        default=str,
    )
    return hashlib.sha256(canonical.encode()).hexdigest()[:12]


# ---------------------------------------------------------------------------
# Reasoning / assumptions / risks / alternatives
# ---------------------------------------------------------------------------


def _build_assumptions(spec: DOEProblemSpec, has_screening: bool) -> list[str]:
    """Build the list of assumptions for the strategy."""
    assumptions = [
        "Factor ranges are set wide enough to detect effects.",
        "Measurement system is adequate (repeatability << effect sizes).",
        "Runs are randomised to avoid confounding with lurking variables.",
    ]
    hard = [f.name for f in spec.factors if f.hard_limits]
    if hard:
        assumptions.append(
            f"The ranges of {', '.join(hard)} are hard limits: every stage keeps them inside, and a later round "
            "does not move or widen them."
        )
    if has_screening:
        assumptions.append("Screening will identify 2-4 significant factors for optimisation.")
        assumptions.append("Effect sparsity: only a few factors dominate the response.")
        assumptions.append("Effect heredity: interactions are only important if parent main effects are active.")
    if spec.prior_knowledge and spec.prior_knowledge.confidence >= 0.6:
        assumptions.append("Prior knowledge is reliable and applicable to the current experimental conditions.")
    return assumptions


def _build_risks(
    spec: DOEProblemSpec,
    classification: dict[str, Any],
    budget_warnings: list[str],
    template: dict[str, Any],
) -> list[str]:
    """Build the list of risks for the strategy, ending with the domain's special considerations."""
    risks = list(budget_warnings)
    if classification["is_tight_budget"]:
        risks.append("Tight budget may result in underpowered designs with low effect detection probability.")
    if spec.has_hard_to_change:
        risks.append(
            "Hard-to-change factors require split-plot analysis: standard ANOVA gives incorrect p-values. "
            "Use analyze_experiment(..., analysis_type='split_plot')."
        )
    if spec.has_mixture:
        risks.append("Mixture constraints require specialised designs and Scheffe polynomial models.")
    if classification["n_factors"] >= 8:
        risks.append("With 8+ factors, screening may miss important interactions (resolution III/IV limitation).")
    risks.extend(template.get("special_considerations", []))
    if not any(f.hard_limits for f in spec.factors):
        risks.extend(template.get("soft_range_considerations", []))
    if not risks:
        risks.append("Standard risks: ensure randomisation, verify measurement system, check for outliers.")
    return risks


def _build_alternatives(
    spec: DOEProblemSpec, classification: dict[str, Any], stages: list[ExperimentalStage]
) -> list[str]:
    """Suggest alternative strategies, other than the designs the plan already uses."""
    alternatives: list[str] = []
    n = classification["n_factors"]
    planned = {s.design_type for s in stages}

    if n >= 6 and "dsd" not in planned:
        alternatives.append(
            f"Definitive Screening Design ({dsd_run_count(n)} runs) to combine screening and curvature detection."
        )
    elif n >= 6 and spec.n_continuous == n:
        alternatives.append(
            f"Plackett-Burman design with 3 centre runs ({plackett_burman_runs(n) + 3} runs) if runs are scarcer "
            "than the DSD allows: each two-factor interaction then biases the main effects of the factors "
            "outside it, so analyse it with a stepwise search that can take interactions in."
        )
    if n <= 5:
        alternatives.append(f"Full factorial 2^{n} ({2**n} runs) if budget allows complete information.")
    if n >= 4:
        alternatives.append("I-optimal design for better prediction variance at the cost of simpler interpretation.")
    if spec.has_mixture:
        alternatives.append("D-optimal mixture design if simplex lattice is too restrictive.")

    return alternatives


def _build_reasoning(
    spec: DOEProblemSpec,
    classification: dict[str, Any],
    stages: list[ExperimentalStage],
    template: dict[str, Any],
) -> list[str]:
    """Build step-by-step reasoning for the strategy."""
    reasoning: list[str] = []
    n = classification["n_factors"]
    domain = spec.domain.value

    reasoning.append(f"Problem: {n} factors, {len(spec.responses)} response(s), domain={domain}.")

    if spec.budget:
        reasoning.append(f"Budget: {spec.budget} total runs ({spec.budget / n:.1f} runs per factor).")
    else:
        reasoning.append("No budget constraint - recommending ideal allocation.")

    if spec.prior_knowledge and spec.prior_knowledge.confidence > 0:
        reasoning.append(
            f"Prior knowledge confidence: {spec.prior_knowledge.confidence:.1f}. "
            + (
                "Skipping screening - going directly to RSM."
                if spec.prior_knowledge.confidence >= 0.8
                else "Using prior knowledge to inform design choices."
            )
        )

    reasoning.extend(
        f"Stage {stage.stage_number} ({stage.stage_name}): "
        f"{stage.design_type}, {stage.estimated_runs} runs. {stage.purpose}"
        + (f" Conditional: run this stage only if {stage.run_if}" if stage.conditional else "")
        for stage in stages
    )
    conditional_runs = sum(s.estimated_runs for s in stages if s.conditional)
    if conditional_runs:
        total = sum(s.estimated_runs for s in stages)
        reasoning.append(
            f"The plan needs {total - conditional_runs} runs when the screening shows no conditional stage is "
            f"called for, and {total} when it is."
        )

    domain_notes = template.get("notes", {}).get(spec.detail_level, "")
    if domain_notes:
        reasoning.append(f"Domain note ({domain}): {domain_notes}")
    extra_stages = template.get("extra_stages", [])
    if extra_stages:
        names = ", ".join(stage.replace("_", " ") for stage in extra_stages)
        reasoning.append(f"Studies in this domain usually add stages that are not scheduled here: {names}.")

    return reasoning


# ---------------------------------------------------------------------------
# Main entry point
# ---------------------------------------------------------------------------


def _refuse_unplannable(factors: list[Factor], hard_to_change_factors: list[str] | None) -> None:
    """Raise for inputs no stage can be built for, naming what to change.

    No staged plan is made for mixture components with process factors (``generate_design``
    builds one optimal design for them), a split plot cannot hold mixture components, and a
    hard-to-change name must be a factor.
    """
    names = [f.name for f in factors]
    is_mixture = [f.type.value == "mixture" for f in factors]
    if any(is_mixture) and not all(is_mixture):
        process = [name for name, mixture in zip(names, is_mixture, strict=True) if not mixture]
        raise ValueError(
            f"Mixture-process problems (mixture components together with process factors {process}) get no "
            "staged plan. generate_design builds one optimal design for them, for example "
            "generate_design(factors, design_type='d_optimal', model_type='interactions')."
        )
    unknown = [name for name in hard_to_change_factors or [] if name not in names]
    if unknown:
        raise ValueError(f"hard_to_change_factors {unknown} are not among the factors {names}.")
    if hard_to_change_factors and any(is_mixture):
        raise ValueError("Hard-to-change factors (a split-plot design) are not supported for mixture components.")


[docs] def recommend_strategy( # noqa: C901, PLR0913 *, factors: list[Factor], responses: list[Response] | None = None, budget: int | None = None, constraints: list[Constraint] | None = None, hard_to_change_factors: list[str] | None = None, prior_knowledge: str | None = None, existing_data: Any = None, # noqa: ANN401 - DataFrame or None domain: str | None = None, detail_level: str = "intermediate", ) -> dict[str, Any]: """Recommend a multi-stage experimental strategy. Given a DOE problem description, apply deterministic decision rules to recommend a staged experimental plan (screening → optimisation → confirmation). Parameters ---------- factors : list[Factor] All candidate experimental factors. responses : list[Response] or None Response variables with optimisation goals. budget : int or None Total run budget across all stages. ``None`` = no constraint. constraints : list[Constraint] or None Factor-space constraints (linear or nonlinear). hard_to_change_factors : list[str] or None Factor names that are expensive to reset between runs. prior_knowledge : str or None Free-text description of what the user already knows. existing_data : DataFrame or None Prior experimental data (summary extracted internally). domain : str or None Application domain (e.g. ``"fermentation"``). Defaults to ``"general"``. detail_level : str ``"novice"`` or ``"intermediate"`` (default). Returns ------- dict JSON-serialisable dictionary with the ``ExperimentalStrategy`` fields. Examples -------- >>> from process_improve.experiments.factor import Factor, Response >>> factors = [Factor(name=chr(65+i), low=0, high=100) for i in range(7)] >>> result = recommend_strategy(factors=factors, budget=40, domain="fermentation") >>> result["total_estimated_runs"] <= 40 True """ # --- Validate inputs --- if not factors: raise ValueError("At least one factor is required.") if budget is not None and budget <= 0: raise ValueError(f"Budget must be a positive integer, got {budget}.") if detail_level not in ("novice", "intermediate"): raise ValueError(f"detail_level must be 'novice' or 'intermediate', got {detail_level!r}.") _refuse_unplannable(factors, hard_to_change_factors) domain_enum = DomainType.general if domain: try: domain_enum = DomainType(domain) except ValueError: valid = [d.value for d in DomainType] raise ValueError(f"Unknown domain {domain!r}. Valid domains: {valid}") from None # --- Parse prior knowledge --- factor_names = [f.name for f in factors] pk = _parse_prior_knowledge(prior_knowledge, factor_names) # --- Summarise existing data --- data_summary = None if existing_data is not None: try: data_summary = { "n_rows": len(existing_data), "columns": list(existing_data.columns), } except (AttributeError, TypeError): data_summary = None # --- Build problem spec --- spec = DOEProblemSpec( factors=factors, responses=responses or [], budget=budget, constraints=constraints, hard_to_change_factors=hard_to_change_factors, prior_knowledge=pk, existing_data_summary=data_summary, domain=domain_enum, detail_level=cast("Literal['novice', 'intermediate']", detail_level), ) # --- Get domain template --- template = get_domain_template(domain_enum.value) # --- Classify problem --- classification = _classify_problem(spec) # --- Determine stages --- stages: list[ExperimentalStage] = [] # Screening stage screening = _select_screening_design(spec, classification, template) has_screening = screening is not None if screening: stages.append(screening) # RSM optimisation stage rsm = _select_rsm_design(spec, classification, template, has_screening) if rsm: stages.append(rsm) # Confirmation stage (always) min_conf = template.get("min_confirmation", 3) confirmation = _build_confirmation_stage(spec, len(stages) + 1, min_conf) stages.append(confirmation) # --- Apply modifiers --- stages = _apply_split_plot(stages, spec) stages = _apply_prior_knowledge(stages, spec) # --- Budget allocation --- needs_screening = any(s.stage_name == "Screening" for s in stages) needs_rsm = any(s.stage_name == "Optimization" for s in stages) screening_design = next((s.design_type for s in stages if s.stage_name == "Screening"), "plackett_burman") rsm_design = next((s.design_type for s in stages if s.stage_name == "Optimization"), "box_behnken") budget_alloc = allocate_budget( total_budget=budget, n_factors=spec.n_factors, needs_screening=needs_screening, needs_rsm=needs_rsm, screening_design=screening_design, rsm_design=rsm_design, domain_weights=template.get("budget_weights"), min_confirmation=min_conf, n_center_points=template.get("min_center_points", 3), ) stages, budget_warnings = _apply_budget_constraints(stages, spec, budget_alloc) # --- Re-number stages; mark the ones the screening decides on --- for i, stage in enumerate(stages): stages[i] = stage.model_copy(update={"stage_number": i + 1}) stages = _mark_conditional_stages(stages, spec) # --- Assemble strategy --- total_runs = sum(s.estimated_runs for s in stages) budget_dict = {s.stage_name: s.estimated_runs for s in stages} strategy = ExperimentalStrategy( strategy_id=_compute_strategy_id(spec), stages=stages, total_estimated_runs=total_runs, minimum_total_runs=sum(s.estimated_runs for s in stages if not s.conditional), budget_allocation=budget_dict, assumptions=_build_assumptions(spec, has_screening), risks=_build_risks(spec, classification, budget_warnings, template), alternative_strategies=_build_alternatives(spec, classification, stages), domain=domain_enum.value, detail_level=detail_level, reasoning=_build_reasoning(spec, classification, stages, template), ) return strategy.model_dump()