"""Prescriptive reasoning: define and solve decision problems.

Provides Problem for defining and solving decision problems — optimization,
constraint satisfaction, and feasibility — with variables, objectives, and
constraints. Problems are serialized and sent to an external solver service;
results are imported back after the solve completes.
"""

from __future__ import annotations

from dataclasses import dataclass
from functools import cached_property
from typing import Any, Literal, Optional
from weakref import WeakKeyDictionary
from decimal import Decimal
import json
import logging
import math
import uuid
import warnings

from relationalai.semantics.frontend import base as b
from relationalai.semantics import std, String, Float, Integer, Any as CoreAny
from relationalai.semantics.std.floats import parse_float as _parse_float
from relationalai.semantics.frontend.core import Hash, TypeVar, cast as core_cast
from relationalai.util.schema import get_provider
from relationalai.client import connect_sync
from v0.relationalai.clients.resources.snowflake import Resources as SnowflakeResources
from v0.relationalai.errors import RAIAbortedTransactionError, RAIException

from .wire_format import (
    _library,
    _VAR_CODES,
    _EXPR_CODES,
    _EXPR_CONCEPT_NAMES,
)
from .rewriter import ExpressionCompiler, SymbolicNode, _expr_dict_key
from .display import format_variable_table, format_expression_table
from ....util.docutils import include_in_docs

# include this module in documentation
__include_in_docs__ = True

logger = logging.getLogger(__name__)

# Tracks which variable relationships have populate=True per Model, to warn on
# duplicate populate rules (FD violations). WeakKeyDictionary so entries are
# cleaned up when the Model is garbage-collected.
_populated_relationships: WeakKeyDictionary[b.Model, set[int]] = WeakKeyDictionary()

# =============================================================================
# Constants
# =============================================================================
REASONER_TYPE = "PRESCRIPTIVE"


# =============================================================================
# Utilities
# =============================================================================


def _check_name_arg(arg: Any) -> None:
    """Raise if *arg* is a bare Concept (not a scalar DSL expression)."""
    if isinstance(arg, b.Concept):
        raise TypeError(
            f"Cannot use Concept '{arg._name}' as a name component. "
            f"Use a property that returns a scalar value instead "
            f'(for example, name=["x", {arg._name}.id]).'
        )


def _make_name(*args: Any, sep: Optional[str] = "_") -> Any:
    """Construct a name by concatenating arguments.

    Parameters
    ----------
    *args : Any
        Arguments to concatenate (strings, numbers, or lists).
    sep : str or None, optional
        Separator between arguments. Defaults to ``"_"``.

    Returns
    -------
    str or Expression
        The concatenated name.

    Raises
    ------
    ValueError
        If no arguments are provided.
    """
    if not args:
        raise ValueError("No arguments provided to `_make_name`")

    if len(args) == 1:
        arg = args[0]
        if isinstance(arg, str):
            return arg
        elif isinstance(arg, list):
            return _make_name(*arg, sep=sep)
        else:
            _check_name_arg(arg)
            return std.strings.string(arg)

    for a in args:
        if not isinstance(a, str):
            _check_name_arg(a)

    if sep:
        str_args: list[Any] = []
        for a in args:
            str_args.append(std.strings.string(a))
            str_args.append(sep)
        str_args.pop()  # Remove trailing separator.
    else:
        str_args = [std.strings.string(a) for a in args]

    return std.strings.concat(*str_args)


# =============================================================================
# Constraint Constructors
# =============================================================================

_implies = _library.Relation("implies", fields=[b.Field.input("left", CoreAny), b.Field.input("right", CoreAny)])


@include_in_docs
def implies(left: Any, right: Any) -> b.Expression:
    """Return an implication constraint (``left`` => ``right``).

    Use this inside :meth:`~relationalai.semantics.frontend.base.Model.require` and
    pass the resulting fragment to :meth:`~relationalai.semantics.reasoners.prescriptive.problem.Problem.satisfy`.
    When passed to :meth:`~relationalai.semantics.reasoners.prescriptive.problem.Problem.satisfy`,
    both sides must reference at least one decision variable declared via
    :meth:`~relationalai.semantics.reasoners.prescriptive.problem.Problem.solve_for`.

    Parameters
    ----------
    left : Any
        Antecedent condition (a comparison or constraint).
    right : Any
        Consequent condition (a comparison or constraint).

    Returns
    -------
    Expression
        A solver expression representing the implication constraint.

    Examples
    --------
    Constrain two binary variables:

    >>> from relationalai.semantics import Float, Model
    >>> from relationalai.semantics.reasoners.prescriptive import Problem, implies
    >>> m = Model("implies_demo")
    >>> x = m.Relationship(f"{Float:x}")
    >>> y = m.Relationship(f"{Float:y}")
    >>> p = Problem(m, Float)
    >>> p.solve_for(x, name="x", type="bin")
    >>> p.solve_for(y, name="y", type="bin")
    >>> p.satisfy(m.require(implies(x == 1, y == 1)))
    """
    return _implies(left, right)


_all_different = _library.Relation("all_different", fields=[b.Field.input("over", TypeVar)])


@include_in_docs
def all_different(*args: b.Value) -> b.Aggregate:
    """Return an all-different constraint.

    Use this inside :meth:`~relationalai.semantics.frontend.base.Model.require` and
    pass the resulting fragment to :meth:`~relationalai.semantics.reasoners.prescriptive.problem.Problem.satisfy`.
    Arguments must reference decision variables declared via
    :meth:`~relationalai.semantics.reasoners.prescriptive.problem.Problem.solve_for`.
    For grouped constraints (for example, "all values are distinct per row"),
    scope it with :meth:`~relationalai.semantics.frontend.base.Aggregate.per`.

    Parameters
    ----------
    *args : Value
        One or more decision-variable expressions that must take distinct values.

    Returns
    -------
    Aggregate
        A solver aggregate representing the all-different constraint.

    Examples
    --------
    Constrain values to be distinct per row:

    >>> from relationalai.semantics import Integer, Model
    >>> from relationalai.semantics.reasoners.prescriptive import Problem, all_different
    >>> m = Model("all_different_demo")
    >>> Cell = m.Concept("Cell", identify_by={"row": Integer, "col": Integer})
    >>> Cell.val = m.Property(f"{Cell} has {Integer:val}")
    >>> m.define(
    ...     Cell.new(row=0, col=0),
    ...     Cell.new(row=0, col=1),
    ...     Cell.new(row=1, col=0),
    ...     Cell.new(row=1, col=1),
    ... )
    >>> p = Problem(m, Integer)
    >>> p.solve_for(Cell.val, name=["x", Cell.row, Cell.col], lower=1, upper=4)
    >>> p.satisfy(m.require(all_different(Cell.val).per(Cell.row)).where(Cell))
    """
    return b.Aggregate(_all_different, *args)


_sos1 = _library.Relation(
    "special_ordered_set_type_1", fields=[b.Field.input("index", Integer), b.Field.input("variables", CoreAny)]
)


@include_in_docs
def special_ordered_set_type_1(index: Any, variables: Any) -> b.Aggregate:
    """Return an SOS1 constraint (at most one variable is non-zero).

    Use this inside :meth:`~relationalai.semantics.frontend.base.Model.require` and
    pass the resulting fragment to :meth:`~relationalai.semantics.reasoners.prescriptive.problem.Problem.satisfy`.
    Arguments must reference decision variables declared via
    :meth:`~relationalai.semantics.reasoners.prescriptive.problem.Problem.solve_for`.
    Commonly used for piecewise-linear formulations.

    Parameters
    ----------
    index : Any
        Ordering index used to define the set.
    variables : Any
        Decision-variable expression(s) in the SOS1 set.

    Returns
    -------
    Aggregate
        A solver aggregate representing the SOS1 constraint.

    Examples
    --------
    Create nonnegative weights and constrain them with SOS1:

    >>> from relationalai.semantics import Float, Integer, Model, std
    >>> from relationalai.semantics.reasoners.prescriptive import Problem, special_ordered_set_type_1
    >>> m = Model("sos1_demo")
    >>> Point = m.Concept("Point", identify_by={"i": Integer})
    >>> Point.w = m.Property(f"{Point} has {Float:w}")
    >>> m.define(Point.new(i=std.common.range(3)))
    >>> p = Problem(m, Float)
    >>> p.solve_for(Point.w, name=["w", Point.i], lower=0)
    >>> p.satisfy(m.require(special_ordered_set_type_1(Point.i, Point.w)))
    """
    return b.Aggregate(_sos1, index, variables)


_sos2 = _library.Relation(
    "special_ordered_set_type_2", fields=[b.Field.input("index", Integer), b.Field.input("variables", CoreAny)]
)


@include_in_docs
def special_ordered_set_type_2(index: Any, variables: Any) -> b.Aggregate:
    """Return an SOS2 constraint (at most two consecutive variables are non-zero).

    Use this inside :meth:`~relationalai.semantics.frontend.base.Model.require` and
    pass the resulting fragment to :meth:`~relationalai.semantics.reasoners.prescriptive.problem.Problem.satisfy`.
    Arguments must reference decision variables declared via
    :meth:`~relationalai.semantics.reasoners.prescriptive.problem.Problem.solve_for`.
    The ordering in the set is defined by ``index`` (often a rank or position),
    and the constraint is commonly used for piecewise-linear formulations.
    For the SOS1 variant, see :func:`~relationalai.semantics.reasoners.prescriptive.problem.special_ordered_set_type_1`.

    Parameters
    ----------
    index : Any
        Ordering index that defines adjacency in the set.
    variables : Any
        Decision-variable expression(s) in the SOS2 set.

    Returns
    -------
    Aggregate
        A solver aggregate representing the SOS2 constraint.
    """
    return b.Aggregate(_sos2, index, variables)


# =============================================================================
# Result Import Utilities
# =============================================================================


def _load_data_with_retry(
    model: Any,
    resources: Any,
    app_name: str,
    table_fqns: list[str],
    engine_name: str,
    sources: dict[str, str] | None = None,
    max_retries: int = 6,
    initial_delay: float = 2.0,
) -> None:
    """Load result tables via batched ``experimental.load_data`` with retries.

    This sends all result views in a single payload, so table registration and
    loading happen in one call.

    If the logic engine transaction aborted we retry with exponential backoff + jitter.
    """
    import random
    import time

    if not table_fqns:
        return

    def target_relation_name(fqn: str) -> str:
        # experimental.load_data registers data under this name in the logic engine.
        return fqn.lower().replace(".", "_")

    payload = {
        "tables": [
            {
                "table_reference": sources.get(fqn, fqn) if sources else fqn,
                "target_relation": target_relation_name(fqn),
            }
            for fqn in table_fqns
        ]
    }
    payload_json = json.dumps(payload)

    total_backoff = 0.0
    for attempt in range(max_retries):
        try:
            resources._exec(
                f"call {app_name}.experimental.load_data(?, ?, parse_json(?))",
                [model.name, engine_name, payload_json],
                skip_engine_db_error_retry=True,  # type: ignore[reportCallIssue]
            )
            if attempt > 0:
                logger.warning(
                    "load_data succeeded after %d retries (%.1fs total backoff)",
                    attempt,
                    total_backoff,
                )
            return
        # RuntimeError: resources._exec() wraps some Snowflake errors this way.
        except (RAIException, RuntimeError) as e:
            is_aborted = isinstance(e, RAIAbortedTransactionError) or "aborted" in str(e).lower()
            if is_aborted and attempt < max_retries - 1:
                delay = initial_delay * (2**attempt) + random.uniform(0, 2)
                total_backoff += delay
                logger.warning(
                    "load_data ABORTED (attempt %d/%d), retrying in %.1fs: %s", attempt + 1, max_retries, delay, e
                )
                time.sleep(delay)
                continue
            raise


# =============================================================================
# Main Problem Class
# =============================================================================


@dataclass(frozen=True)
class SolveInfoData:
    """Concrete snapshot of solver result metadata.

    Returned by :meth:`Problem.solve_info`. Built from one query on the
    internal ``_solve_info`` key-value relationship (which includes
    ``objective_value``). Cached until the next :meth:`~Problem.solve`.
    """

    termination_status: str | None
    objective_value: float | int | None
    solve_time_sec: float | None
    num_points: int | None
    solver_version: str | None
    error: tuple[str, ...]
    printed_model: str | None

    def __repr__(self) -> str:
        parts: list[str] = []
        if self.termination_status is not None:
            parts.append(f"status: {self.termination_status}")
        if self.objective_value is not None:
            parts.append(f"objective: {self.objective_value}")
        if self.solve_time_sec is not None:
            parts.append(f"solve time: {self.solve_time_sec:.2f}s")
        if self.num_points is not None:
            parts.append(f"num_points: {self.num_points}")
        if self.solver_version is not None:
            parts.append(f"solver: {self.solver_version}")
        if self.error:
            parts.extend(f"error: {e}" for e in self.error)
        if self.printed_model is not None:
            parts.append(f"printed model: {len(self.printed_model)} chars")
        lines = ["Solve result:"] + [f"\u2022 {p}" for p in parts]
        return "\n".join(lines)

    def display(self) -> str:
        """Print and return a formatted summary."""
        output = repr(self)
        print(output, end="\n\n")
        return output


@include_in_docs
class Problem:
    """Define and solve a decision problem on a model.

    Use :meth:`solve_for` to declare decision variables, :meth:`minimize`/
    :meth:`maximize` to add objectives, and :meth:`satisfy` to add constraints.
    Then call :meth:`solve` and read results via :meth:`variable_values` or
    :meth:`load_point`.

    Parameters
    ----------
    model : Model
        The :class:`~relationalai.semantics.frontend.base.Model` to attach solver
        constructs (variables, objectives, constraints, and results) to.
    numeric_type : Concept
        Numeric type used for bounds, solution values, and numeric literals.
        Use :data:`~relationalai.semantics.Float` for HiGHS/Gurobi/Ipopt (even
        for MIPs), and :data:`~relationalai.semantics.Integer` for MiniZinc.

    Attributes
    ----------
    Variable : Concept
        Concept for the declared solver variables (useful for inspection via
        :meth:`display`).
    Expression : Concept
        Concept for objectives and constraints (useful for inspection via
        :meth:`display`).

    Examples
    --------
    Declare a variable and objective:

    >>> from relationalai.semantics import Float, Model
    >>> from relationalai.semantics.reasoners.prescriptive import Problem
    >>> m = Model("demo")
    >>> x = m.Relationship(f"{Float:x}")
    >>> p = Problem(m, Float)
    >>> p.solve_for(x, name="x", lower=0)
    >>> p.minimize(x)

    Notes
    -----
    Calling :meth:`solve` invalidates the Python-side ``solve_info()`` cache.
    Result accessors like ``termination_status()`` return engine-side
    Relationships that reflect the most recent successful solve.
    """

    # Removed attributes — __getattr__ catches access and raises a clear
    # migration error. Only fires for names NOT defined as methods on Problem
    # (i.e. fully removed names, not property→method renames).
    # TODO: remove once prescriptive_assistant migration is complete.
    _REMOVED_ATTRS: dict[str, str] = {
        "result_count": "Use 'p.num_points()' (Relationship) or 'p.solve_info().num_points' (int).",
        "display_solve_info": "Use 'p.solve_info().display()'.",
    }

    def __getattr__(self, name: str) -> Any:
        if name in self._REMOVED_ATTRS:
            raise AttributeError(f"'{name}' has been removed. {self._REMOVED_ATTRS[name]}")
        raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")

    def __init__(self, model: b.Model, numeric_type: b.Concept) -> None:
        if not (numeric_type is Float or numeric_type is Integer):
            raise ValueError(f"Invalid numeric type '{numeric_type}'. Must be Float or Integer.")
        self._model = model
        self._numeric_type = numeric_type
        self._model_id = str(uuid.uuid4()).upper().replace("-", "_")
        self._load_point_counter = 0
        self._result_rules_defined = False
        self._result_tables: dict[str, Any] = {}
        self._solve_info_cache: SolveInfoData | None = None
        # Maps relationships to their corresponding variable concepts.
        self._variable_relationships: dict[int, b.Concept] = {}

        Concept = model.Concept
        Property = model.Property

        # Variable concept and its properties.
        Variable = self.Variable = Concept("Variable")
        Variable.name = Property(f"{Variable} has {String:name}")
        Variable.type = Property(f"{Variable} has {Integer:type}")
        Variable.lower = Property(f"{Variable} has {numeric_type:lower}")
        Variable.upper = Property(f"{Variable} has {numeric_type:upper}")
        Variable.start = Property(f"{Variable} has {numeric_type:start}")

        # Expression concept and its properties.
        Expression = self.Expression = Concept("Expression")
        Expression.name = Property(f"{Expression} has {String:name}")
        Expression.type = Property(f"{Expression} has {Integer:type}")
        Expression.root = Property(f"{Expression} has {Hash:root}")

        self._expr_counter = 0
        self._expr_subconcepts: list[b.Concept] = []

        # AST structure properties for expression nodes.
        self._operator = Property(f"{Hash:node} has {Integer:op}")
        self._ordered_args_hash = Property(f"{Hash:node} arg {Integer:i} is {Hash:arg}")
        self._ordered_args_data = Property(f"{Hash:node} arg {Integer:i} is {numeric_type:arg}")
        self._unordered_args_hash = Property(f"{Hash:node} arg {Hash:i} is {Hash:arg}")

        # Reachability: transitive closure from Expression roots through AST args.
        # Filters out orphaned AST nodes (from expressions where one arm is empty).
        n0, n1, i_, h_ = Hash.ref(), Hash.ref(), Integer.ref(), Hash.ref()
        self._reachable = model.Relationship(f"{Hash}")
        model.define(self._reachable(Expression.root))
        ctx = model.where(self._reachable(n0))
        ctx.define(self._reachable(n1)).where(self._ordered_args_hash(n0, i_, n1))
        ctx.define(self._reachable(n1)).where(self._unordered_args_hash(n0, h_, n1))

        # Solve output relations.
        self._solve_info = model.Relationship(f"{String:key} has {String:val}")
        self._points = Property(f"{Integer:sol_index} has {Hash:var_hash} with {numeric_type:value}")
        self._point = Property(f"{Hash:var_hash} has {numeric_type:value}")
        # Trace of load_point updates; max aggregation selects the latest.
        self._point_updates = Property(f"at {Integer:update} we load {Integer:point}")
        self._objective_values = Property(f"{Integer:sol_index} has {numeric_type:obj_value}")

        update_ref = Integer.ref()
        point_index = Integer.ref()
        var_hash = Hash.ref()
        point_val = self._numeric_type.ref()
        model.where(
            self._point_updates(update_ref, point_index),
            update_ref == std.aggregates.max(update_ref),
            self._points(point_index, var_hash, point_val),
        ).define(self._point(var_hash, point_val))

        # Per-point objective values (public, for multi-solution access in rules).
        self.point_objective_values = self._objective_values

    # =========================================================================
    # Subconcept Creation
    # =========================================================================

    def _create_variable_subconcept(
        self,
        relationship: b.Relationship,
        var_type: str,
        name: Optional[str] = None,
    ) -> b.Concept:
        """Create a Variable subconcept with standing type rule (and name if scalar)."""
        Var = self._model.Concept(f"Variable_{relationship._id}", extends=[self.Variable])
        ref = Var.ref()
        standing = [ref.type(_VAR_CODES[var_type])]
        if name is not None:
            standing.append(ref.name(name))
        self._model.define(*standing).where(ref)
        return Var

    def _create_expression_subconcept(
        self,
        expr_type: str,
        name: Optional[str] = None,
    ) -> b.Concept:
        """Create an Expression subconcept with standing type rule (and name if scalar)."""
        self._expr_counter += 1
        sub = self._model.Concept(
            f"{_EXPR_CONCEPT_NAMES[expr_type]}_{self._expr_counter}",
            extends=[self.Expression],
        )
        self._expr_subconcepts.append(sub)
        ref = sub.ref()
        standing = [ref.type(_EXPR_CODES[expr_type])]
        if name is not None:
            standing.append(ref.name(name))
        self._model.define(*standing).where(ref)
        return sub

    # =========================================================================
    # Variable Handling
    # =========================================================================

    @include_in_docs
    def solve_for(
        self,
        expr: b.Relationship | b.Chain | b.Expression,
        where: Optional[list[Any]] = None,
        populate: bool = True,
        name: Optional[Any | list[Any]] = None,
        type: Optional[str] = None,
        lower: Optional[std.NumberValue] = None,
        upper: Optional[std.NumberValue] = None,
        start: Optional[std.NumberValue] = None,
    ) -> b.Concept:
        """Declare decision variables for the problem.

        Call this before adding objectives or constraints. The returned concept
        can be passed to :meth:`display` to inspect the generated variables.

        Parameters
        ----------
        expr : Relationship or Chain or Expression
            Expression describing the variable(s) to create (for example,
            a scalar relationship like ``x`` or an indexed property like
            ``Item.cost``).
        where : list[Any], optional
            Optional conditions restricting which variable instances are created.
        populate : bool, optional
            If True (default), write solved values back to the original
            relationship/property after :meth:`solve`. Set to False when you
            create multiple :class:`Problem` instances that solve for the same
            relationship on the same model.
        name : Any or list[Any], optional
            Display name for variables. Use a string for scalars or a list
            pattern for indexed variables (for example, ``["x", Item.i]``).
        type : str, optional
            Variable type: ``"cont"`` (default for ``Float``), ``"int"``
            (default for ``Integer``), or ``"bin"`` (binary 0/1).
        lower, upper, start : Variable or float or int or Decimal, optional
            Lower/upper bounds and an optional initial value hint.

        Returns
        -------
        Concept
            A ``Variable`` subconcept representing the declared decision variables.

        Raises
        ------
        ValueError
            If variables are already defined for this relationship, or if an
            argument has an invalid value (for example, an unknown ``type``).
        TypeError
            If an argument has an invalid type.
        """
        relationship, args = self._extract_relationship(expr)
        key = _expr_dict_key(relationship)

        if key in self._variable_relationships:
            raise ValueError(
                "Variables already defined for this relationship. "
                "Each relationship can only be used once in solve_for()."
            )

        # Resolve name: scalar names go in the standing rule, DSL names go per-entity.
        name_val = _make_name(name if name is not None else "_")
        scalar_name = None if isinstance(name_val, b.DSLBase) else name_val

        resolved_type = self._resolve_var_type(type)
        Var = self._create_variable_subconcept(relationship, resolved_type, name=scalar_name)
        self._variable_relationships[key] = Var

        # Entity creation + DSL-valued properties (need where context for field bindings).
        fields = {f.name: args[i] for i, f in enumerate(relationship._fields[:-1])}
        var = Var.new(var_id=relationship._id, **fields)
        var_defs: list[Any] = [var]
        if isinstance(name_val, b.DSLBase):
            var_defs.append(var.name(name_val))

        # Scalar-valued optional properties: uses ref() for cleaner IR.
        ref = Var.ref()
        ref_defs: list[Any] = []
        for attr, val in [("lower", lower), ("upper", upper), ("start", start)]:
            if val is not None:
                val = self._validate_numeric_bound(attr, val)
                if isinstance(val, b.DSLBase):
                    var_defs.append(getattr(var, attr)(val))
                else:
                    ref_defs.append(getattr(ref, attr)(val))

        # Warn if binary variable has non-standard bounds (after validation).
        if resolved_type == "bin":
            for attr, val in [("lower", lower), ("upper", upper)]:
                if val is not None and not isinstance(val, b.Variable) and val not in (0, 1):
                    warnings.warn(
                        f"Binary variable has {attr}={val}. Binary variables are "
                        f"restricted to 0/1; non-standard bounds may be ignored by the solver.",
                        UserWarning,
                        stacklevel=2,
                    )

        self._model.define(*var_defs).where(*(where or []))
        if ref_defs:
            self._model.define(*ref_defs).where(ref)

        if populate:
            populated = _populated_relationships.setdefault(self._model, set())
            if key in populated:
                warnings.warn(
                    "Multiple Problems populating the same relationship will cause "
                    "a functional dependency violation. Use populate=False on all but one, "
                    "or use variable_values() to read results instead.",
                    UserWarning,
                    stacklevel=2,
                )
            populated.add(key)

            val_ref = self._numeric_type.ref()
            pop_var = Var.ref()
            var_hash = core_cast(b.MetaRef(Hash), pop_var)
            field_args = [getattr(pop_var, fn) for fn in fields.keys()]
            self._model.where(self._point(var_hash, val_ref), *(where or [])).define(relationship(*field_args, val_ref))

        return Var

    @staticmethod
    def _check_not_reading(rel: b.Relationship, context: str) -> None:
        """Raise if rel is a Reading (.alt()). Readings cannot be used in solve_for()."""
        if isinstance(rel, b.Reading):
            parent_name = rel._relationship._short_name or rel._relationship._name
            raise TypeError(
                f"Cannot use alternative reading (`.alt()`) in {context}. "
                f"Use the original relationship '{parent_name}' instead."
            )

    def _extract_relationship(self, expr: b.Relationship | b.Chain | b.Expression) -> tuple[b.Relationship, list[Any]]:
        """Extract relationship and args from variable expression."""
        if isinstance(expr, b.Relationship):
            self._check_not_reading(expr, "solve_for()")
            rel, args = expr, [f.type for f in expr._fields]
        elif isinstance(expr, b.Chain):
            if not isinstance(expr._start, b.Concept) or not isinstance(expr._next, b.Relationship):
                raise TypeError("Chain must have Concept start and Relationship next. Example: solve_for(Entity.value)")
            self._check_not_reading(expr._next, "solve_for()")
            rel, args = expr._next, [f.type for f in expr._next._fields]
        elif isinstance(expr, b.Expression):
            rel = expr._op
            if not isinstance(rel, b.Relationship):
                raise TypeError(
                    f"Expression operator must be Relationship, got {type(rel).__name__}. "
                    f"Example: solve_for(my_relation(x, y))"
                )
            self._check_not_reading(rel, "solve_for()")
            if len(expr._args) != len(rel._fields):
                field_names = [f.name for f in rel._fields]
                raise ValueError(
                    f"Argument count mismatch: {len(expr._args)} arguments provided, "
                    f"but relationship '{rel._name}' expects {len(rel._fields)} fields: {', '.join(field_names)}. "
                    f"Example: solve_for({rel._name}({', '.join('...' for _ in rel._fields)}))"
                )
            rel, args = rel, expr._args
        else:
            raise TypeError(
                f"Invalid expression type: {type(expr).__name__}. "
                f"Expected Relationship, Chain, or Expression. "
                f"Example: solve_for(my_relation) or solve_for(Entity.value)"
            )

        # The last field is the value — it must be numeric.
        value_type = rel._fields[-1].type
        if value_type is not Float and value_type is not Integer:
            raise TypeError(
                f"solve_for() requires a numeric (Float or Integer) property, "
                f"but '{rel._short_name}' has value type {value_type._name}. "
                f"Create a separate numeric property for decision variables."
            )
        return rel, args

    def _resolve_var_type(self, var_type: Optional[str]) -> str:
        """Resolve variable type, defaulting based on model's numeric type."""
        if var_type is None:
            return "cont" if self._numeric_type is Float else "int"
        if not isinstance(var_type, str):
            raise TypeError(
                f"Variable type must be a string, got {type(var_type).__name__}. Example: solve_for(x, type='int')"
            )
        if var_type not in _VAR_CODES:
            valid_types = ", ".join(f"'{t}'" for t in _VAR_CODES.keys())
            raise ValueError(
                f"Invalid variable type '{var_type}'. Valid types are: {valid_types}. "
                f"Example: solve_for(x, type='cont', lower=0, upper=10)"
            )
        return var_type

    def _validate_numeric_bound(self, name: str, val: Any) -> Any:
        """Validate and coerce a bound value to a DSL-compatible numeric type.

        Returns the (possibly coerced) value. Numpy float/int subclasses are
        converted to plain Python types since the DSL uses ``type(v) is float``
        identity checks.
        """
        if isinstance(val, bool):
            raise TypeError(
                f"Parameter '{name}' must be numeric (Variable, float, int, or Decimal), "
                f"got bool. Use 0/1 instead of False/True. "
                f"Example: solve_for(x, {name}=0)"
            )
        if not isinstance(val, (b.Variable, float, int, Decimal)):
            raise TypeError(
                f"Parameter '{name}' must be numeric (Variable, float, int, or Decimal), "
                f"got {type(val).__name__}. "
                f"Example: solve_for(x, {name}=0.0)"
            )
        if isinstance(val, float) and not math.isfinite(val):
            raise ValueError(
                f"Parameter '{name}' must be a finite number, got {val}. Example: solve_for(x, {name}=0.0)"
            )
        if isinstance(val, Decimal) and not val.is_finite():
            raise ValueError(
                f"Parameter '{name}' must be a finite number, got {val}. Example: solve_for(x, {name}=0.0)"
            )
        # Coerce numpy subclasses to plain Python types (DSL requires exact types).
        if isinstance(val, float) and type(val) is not float:
            return float(val)
        if isinstance(val, int) and type(val) is not int:
            return int(val)
        return val

    # =========================================================================
    # Expression Handling
    # =========================================================================

    @include_in_docs
    def minimize(
        self,
        expr: b.Variable | float | int | b.Fragment,
        name: Optional[Any | list[Any]] = None,
    ) -> b.Concept:
        """Add a minimization objective.

        The expression must reference at least one decision variable declared via
        :meth:`solve_for`.

        Parameters
        ----------
        expr : Variable or float or int or Fragment
            Objective expression to minimize.
        name : Any or list[Any], optional
            Optional objective name (string for scalar, or a list pattern for
            indexed objectives).

        Returns
        -------
        Concept
            An ``Expression`` subconcept representing the objective. Pass it to
            :meth:`display` to inspect.

        Raises
        ------
        ValueError
            If the objective does not reference any declared decision variables.
        """
        return self._add_objective("min_objective", expr, name)

    @include_in_docs
    def maximize(
        self,
        expr: b.Variable | float | int | b.Fragment,
        name: Optional[Any | list[Any]] = None,
    ) -> b.Concept:
        """Add a maximization objective.

        The expression must reference at least one decision variable declared via
        :meth:`solve_for`.

        Parameters
        ----------
        expr : Variable or float or int or Fragment
            Objective expression to maximize.
        name : Any or list[Any], optional
            Optional objective name (string for scalar, or a list pattern for
            indexed objectives).

        Returns
        -------
        Concept
            An ``Expression`` subconcept representing the objective. Pass it to
            :meth:`display` to inspect.

        Raises
        ------
        ValueError
            If the objective does not reference any declared decision variables.
        """
        return self._add_objective("max_objective", expr, name)

    def _add_objective(
        self,
        expr_type: Literal["min_objective", "max_objective"],
        expr: b.Variable | float | int | b.Fragment,
        name: Optional[Any | list[Any]],
    ) -> b.Concept:
        """Add an objective (minimize or maximize)."""
        scalar_name, dsl_name = self._extract_scalar_name(name)
        sub = self._create_expression_subconcept(expr_type, name=scalar_name)
        self._add_expression(sub, expr, dsl_name)
        return sub

    @include_in_docs
    def satisfy(
        self,
        expr: b.Fragment,
        name: Optional[Any | list[Any]] = None,
    ) -> b.Concept:
        """Add constraints from a ``model.require(...)`` fragment.

        Use this to turn a require-clause fragment into solver constraints.
        The returned concept can be passed to :meth:`display` to inspect.

        To check whether the solver's solution satisfies this constraint
        after :meth:`solve`, call :meth:`verify` — it temporarily installs
        the fragment as an IC, evaluates it, and removes it (one-shot).

        .. note::

            LP and MIP solvers return floating-point solutions that satisfy
            constraints within solver tolerance (e.g. ``1e-8``), but engine
            ICs check exact inequality.  For continuous-variable constraints,
            use a tolerant ``model.require()`` post-solve instead
            (e.g. ``model.require(x <= bound + 1e-6)``).

        Parameters
        ----------
        expr : Fragment
            A fragment created by :meth:`~relationalai.semantics.frontend.base.Model.require`
            (optionally scoped with :meth:`~relationalai.semantics.frontend.base.Model.where`).
        name : Any or list[Any], optional
            Optional constraint name (string for scalar, or a list pattern for
            indexed constraints).

        Returns
        -------
        Concept
            An ``Expression`` subconcept representing the added constraints.

        Raises
        ------
        TypeError
            If ``expr`` is not a fragment.
        ValueError
            If the fragment has no require clause, or if it includes select/define
            clauses.
        """
        if not isinstance(expr, b.Fragment):
            raise TypeError(
                f"satisfy() expects a Fragment from model.require(...), "
                f"got {type(expr).__name__}. "
                f"Example: s.satisfy(model.require(x >= 5))"
            )
        if not expr._require:
            raise ValueError("Fragment must have a require clause. Example: s.satisfy(model.require(x >= 5))")
        if expr._select or expr._define:
            raise ValueError("Fragment must not have select or define clauses.")

        # Remove constraint from model roots to prevent pre-solve firing.
        if expr in self._model.requires:
            self._model._remove_rule(expr)

        scalar_name, dsl_name = self._extract_scalar_name(name)
        sub = self._create_expression_subconcept("constraint", name=scalar_name)
        for req in expr._require:
            self._add_expression(sub, req, dsl_name, expr._where)
        return sub

    @include_in_docs
    def verify(self, *fragments: b.Fragment) -> None:
        """One-shot constraint verification against the current solution.

        Temporarily installs each fragment as an integrity constraint,
        triggers a model query to evaluate them, then removes them.
        A :class:`~v0.relationalai.errors.ModelWarning` is raised if
        any constraint is violated.

        Emits a :class:`UserWarning` and returns without checking if the
        most recent solve did not produce a successful solution (i.e.
        ``termination_status`` is not ``OPTIMAL``, ``LOCALLY_SOLVED``,
        or ``SOLUTION_LIMIT``).

        .. note::

            LP and MIP solvers return floating-point solutions that satisfy
            constraints within solver tolerance (e.g. ``1e-8``), but engine
            ICs check exact inequality.  For continuous-variable constraints,
            use a tolerant ``model.require()`` post-solve instead
            (e.g. ``model.require(x <= bound + 1e-6)``).

        Parameters
        ----------
        *fragments : Fragment
            One or more fragments previously passed to :meth:`satisfy`.
        """
        if not fragments:
            return
        # Check termination status first — skip IC check for non-successful solves.
        _VERIFY_OK = {"OPTIMAL", "LOCALLY_SOLVED", "SOLUTION_LIMIT"}
        info = self.solve_info()  # uses cache if available (no extra query after solve)
        status = (info.termination_status or "").upper()
        if not status:
            warnings.warn(
                "verify(): no termination_status available (solve() may not have been called).",
                UserWarning,
                stacklevel=2,
            )
            return
        if status not in _VERIFY_OK:
            warnings.warn(
                f"verify(): termination_status is '{status}' — "
                f"constraint verification is only meaningful for {' or '.join(sorted(_VERIFY_OK))}.",
                UserWarning,
                stacklevel=2,
            )
            return

        # One-shot: install ICs, trigger a query to evaluate them, remove ICs.
        # Installation is inside try so partial failures still clean up.
        installed: list[b.Fragment] = []
        try:
            for frag in fragments:
                if frag not in self._model.requires:
                    self._model._add_rule(frag)
                    installed.append(frag)
            # _add_rule bumps model._version, so solve_info() forces a
            # model re-install which evaluates the active ICs.
            self._solve_info_cache = None
            self.solve_info()
        finally:
            for frag in installed:
                if frag in self._model.requires:
                    self._model._remove_rule(frag)

    @staticmethod
    def _extract_scalar_name(name: Optional[Any | list[Any]]) -> tuple[Optional[str], Optional[Any]]:
        """Split a name into scalar and DSL components.

        Returns (scalar_name, dsl_name): one is set, the other is None.
        Scalar names go in standing rules; DSL names go per-entity.
        """
        if name is None:
            return None, None
        val = _make_name(name)
        if isinstance(val, b.DSLBase):
            return None, name
        return val, None

    def _add_expression(
        self,
        subconcept: Any,
        expr: Any,
        name: Optional[Any | list[Any]],
        where: Optional[list[Any]] = None,
    ) -> None:
        """Add an objective or constraint expression to the model."""
        compiler = ExpressionCompiler(self)
        if where:
            compiler.wheres.extend(compiler.rewrite_where(*where))

        if expr is None:
            raise TypeError("Expression must not be None.")
        result = compiler.rewrite(expr)
        if not isinstance(result, SymbolicNode):
            raise ValueError(
                "Expression does not reference any decision variables. "
                "Ensure it contains at least one variable declared via solve_for(). "
                "Constant objectives and constraints are not supported."
            )

        expr_node = subconcept.new(root=result.expr)
        compiler.defines.append(expr_node)
        if name is not None:
            compiler.defines.append(expr_node.name(_make_name(name)))

        self._emit_defines(compiler)

    def _emit_defines(self, compiler: ExpressionCompiler) -> None:
        """Recursively emit model.define calls from the compiler's output."""
        self._model.define(*compiler.defines).where(*compiler.wheres)
        for child in compiler.children:
            self._emit_defines(child)

    # =========================================================================
    # Model Inspection and Display
    # =========================================================================

    # =========================================================================
    # Problem & Solve Metadata (Graph-style Relationships)
    # =========================================================================
    #
    # These methods return Relationships, not plain values — they are usable
    # in model rules, ICs, and queries (e.g. model.require(p.num_variables() == 5)).
    # This is a deliberate API design: the prescriptive library is pre-release
    # and has no external users.  The previous @property accessors returned
    # Python scalars and could not be used in rules.
    #
    # For Python-side scalar access (e.g. printing results), use solve_info():
    #   info = p.solve_info()
    #   print(info.termination_status, info.objective_value)
    #
    # Lazily defined via cached_property — the derivation rule is installed once.
    #
    # Structural (pre-solve):
    #   p.num_variables(), p.num_constraints(),
    #   p.num_min_objectives(), p.num_max_objectives()
    #
    # Solve results (post-solve):
    #   p.termination_status(), p.objective_value(), p.solve_time_sec(),
    #   p.num_points(), p.solver_version(), p.printed_model(), p.error()

    def _ancillary_string_rel(self, key: str) -> Any:
        """Create a String Relationship derived from the _solve_info ancillary key-value store."""
        rel = self._model.Relationship(f"Problem {key} is {String:val}")
        val = String.ref()
        self._model.where(self._solve_info(key, val)).define(rel(val))
        return rel

    @include_in_docs
    def num_variables(self) -> b.Relationship:
        """Number of declared decision variables. Usable in rules and ICs."""
        return self._num_variables

    @cached_property
    def _num_variables(self) -> Any:
        rel = self._model.Relationship(f"Problem has {Integer:num_variables} variables")
        self._model.define(rel(std.aggregates.count(self.Variable) | 0))
        return rel

    @include_in_docs
    def num_constraints(self) -> b.Relationship:
        """Number of declared constraints. Usable in rules and ICs."""
        return self._num_constraints

    @cached_property
    def _num_constraints(self) -> Any:
        rel = self._model.Relationship(f"Problem has {Integer:num_constraints} constraints")
        self._model.define(
            rel(std.aggregates.count(self.Expression).where(self.Expression.type == _EXPR_CODES["constraint"]) | 0)
        )
        return rel

    @include_in_docs
    def num_min_objectives(self) -> b.Relationship:
        """Number of minimization objectives. Usable in rules and ICs."""
        return self._num_min_objectives

    @cached_property
    def _num_min_objectives(self) -> Any:
        rel = self._model.Relationship(f"Problem has {Integer:num_min_objectives} min objectives")
        self._model.define(
            rel(std.aggregates.count(self.Expression).where(self.Expression.type == _EXPR_CODES["min_objective"]) | 0)
        )
        return rel

    @include_in_docs
    def num_max_objectives(self) -> b.Relationship:
        """Number of maximization objectives. Usable in rules and ICs."""
        return self._num_max_objectives

    @cached_property
    def _num_max_objectives(self) -> Any:
        rel = self._model.Relationship(f"Problem has {Integer:num_max_objectives} max objectives")
        self._model.define(
            rel(std.aggregates.count(self.Expression).where(self.Expression.type == _EXPR_CODES["max_objective"]) | 0)
        )
        return rel

    @include_in_docs
    def termination_status(self) -> b.Relationship:
        """Solver termination status (e.g. ``"OPTIMAL"``). Usable in rules and ICs."""
        return self._termination_status

    @cached_property
    def _termination_status(self) -> Any:
        return self._ancillary_string_rel("termination_status")

    @include_in_docs
    def objective_value(self) -> b.Relationship:
        """Objective value from the first solution. Usable in rules and ICs.

        Hardwired to solution index 1 (the primary/best solution).
        :meth:`load_point` changes which solution's *variable values* are
        active, but does not change the reported objective — solvers report
        a single objective for the overall solve, not per-point objectives.
        """
        return self._objective_value

    @cached_property
    def _objective_value(self) -> Any:
        rel = self._model.Relationship(f"Problem objective value is {self._numeric_type:val}")
        val = self._numeric_type.ref()
        # Derive from solution 1 (the primary/best solution).  Multi-solution
        # solves (MiniZinc CP) report the same objective for all solutions.
        self._model.where(self._objective_values(1, val)).define(rel(val))
        return rel

    @include_in_docs
    def solve_time_sec(self) -> b.Relationship:
        """Solve time in seconds (Float Relationship). Usable in rules and ICs."""
        return self._solve_time_sec

    @cached_property
    def _solve_time_sec(self) -> Any:
        rel = self._model.Relationship(f"Problem solve time is {Float:sec}")
        val = String.ref()
        sec = Float.ref()
        self._model.where(
            self._solve_info("solve_time_sec", val),
            _parse_float(val) == sec,
        ).define(rel(sec))
        return rel

    @include_in_docs
    def num_points(self) -> b.Relationship:
        """Number of solution points. Usable in rules and ICs."""
        return self._num_points

    @cached_property
    def _num_points(self) -> Any:
        rel = self._model.Relationship(f"Problem has {Integer:num_points} solution points")
        sol_idx = Integer.ref()
        var_hash = Hash.ref()
        val = self._numeric_type.ref()
        n = Integer.ref()
        # Project _points to distinct sol_index (dedup across var_hash × value).
        _sol = self._model.Relationship(f"Problem has solution {Integer:sol_idx}")
        self._model.where(self._points(sol_idx, var_hash, val)).define(_sol(sol_idx))
        self._model.where(std.aggregates.count(sol_idx, _sol(sol_idx)) == n).define(rel(n))
        return rel

    @include_in_docs
    def solver_version(self) -> b.Relationship:
        """Solver version string. Usable in rules and ICs."""
        return self._solver_version

    @cached_property
    def _solver_version(self) -> Any:
        return self._ancillary_string_rel("solver_version")

    @include_in_docs
    def printed_model(self) -> b.Relationship:
        """Solver-provided text representation of the problem. Usable in rules and ICs."""
        return self._printed_model

    @cached_property
    def _printed_model(self) -> Any:
        return self._ancillary_string_rel("printed_model")

    @include_in_docs
    def error(self) -> b.Relationship:
        """Solver error message(s). Usable in rules and ICs."""
        return self._error

    @cached_property
    def _error(self) -> Any:
        return self._ancillary_string_rel("error")

    @include_in_docs
    def display(self, part: Optional[b.Concept] = None) -> str:
        """Print and return a human-readable summary of the problem.

        With no arguments, this shows all declared variables, objectives, and
        constraints. Pass a subconcept returned by :meth:`solve_for`,
        :meth:`minimize`, :meth:`maximize`, or :meth:`satisfy` to display only
        that part.

        Parameters
        ----------
        part : Concept, optional
            Specific variable/objective/constraint subconcept to display.

        Returns
        -------
        str
            The formatted summary (also printed).

        Raises
        ------
        ValueError
            If ``part`` is not a subconcept returned by :meth:`solve_for`,
            :meth:`minimize`, :meth:`maximize`, or :meth:`satisfy`.

        Examples
        --------
        Display the full problem:

        >>> from relationalai.semantics import Float, Model
        >>> from relationalai.semantics.reasoners.prescriptive import Problem
        >>> m = Model("demo")
        >>> x = m.Relationship(f"{Float:x}")
        >>> p = Problem(m, Float)
        >>> x_vars = p.solve_for(x, name="x", lower=0)
        >>> p.minimize(x)
        >>> p.display()

        Display a single part (a subconcept returned by ``solve_for``/``minimize``/``satisfy``):

        >>> p.display(x_vars)

        Notes
        -----
        This method queries the model.
        """
        if part is None:
            lines = self._display_full()
        else:
            lines = self._display_parts(part)
        output = "\n".join(lines)
        print(output, end="\n\n")
        return output

    def _display_full(self) -> list[str]:
        """Build display lines for the full model."""
        from pandas import Series

        model = self._model
        Var, Expr = self.Variable, self.Expression

        # TODO(coey): In future, issue multiple queries at the same time to reduce overhead.
        # Currently causes failures when running the pytest suite or the run_all.py script.
        # See https://relationalai.slack.com/archives/C08ATF88BC6/p1770146491179649?thread_ts=1768955416.245459&cid=C08ATF88BC6

        var_df = model.select(Var.type, Var, Var.name, Var.lower, Var.upper).to_df()
        var_types = var_df["type"].astype(int) if not var_df.empty else Series(dtype=int)

        expr_df = model.select(Expr.type, Expr.name, Expr.root).to_df()
        expr_types = expr_df["type"].astype(int) if not expr_df.empty else Series(dtype=int)

        ast_dfs = self._query_ast(self._reachable)

        # Build summary header.
        var_counts = var_types.value_counts()
        expr_counts = expr_types.value_counts()

        def get(counts: Any, codes: dict[str, int], key: str) -> int:
            return int(counts.get(codes[key], 0))

        n_cont = get(var_counts, _VAR_CODES, "cont")
        n_int = get(var_counts, _VAR_CODES, "int")
        n_bin = get(var_counts, _VAR_CODES, "bin")
        n_min = get(expr_counts, _EXPR_CODES, "min_objective")
        n_max = get(expr_counts, _EXPR_CODES, "max_objective")
        n_con = get(expr_counts, _EXPR_CODES, "constraint")

        lines: list[str] = []
        lines.append("Problem has:")
        lines.append(f"• {len(var_df)} variables: {n_cont} continuous, {n_int} integer, {n_bin} binary")
        lines.append(f"• {n_min} minimization objectives, {n_max} maximization objectives, {n_con} constraints")

        lines.extend(format_variable_table(var_df, var_types))
        lines.extend(format_expression_table(expr_df, expr_types, var_df, ast_dfs))
        return lines

    def _is_var_subconcept(self, concept: b.Concept) -> bool:
        """Check if concept is a Variable subconcept (identity comparison)."""
        return any(concept is v for v in self._variable_relationships.values())

    def _is_expr_subconcept(self, concept: b.Concept) -> bool:
        """Check if concept is an Expression subconcept (identity comparison)."""
        return any(concept is e for e in self._expr_subconcepts)

    def _display_parts(self, part: b.Concept) -> list[str]:
        """Build display lines for a single variable or expression subconcept."""
        from pandas import Series

        model = self._model

        if self._is_var_subconcept(part):
            var_df = model.select(part.type, part, part.name, part.lower, part.upper).to_df()
            var_types = var_df["type"].astype(int) if not var_df.empty else Series(dtype=int)
            return format_variable_table(var_df, var_types)

        if self._is_expr_subconcept(part):
            # Query all variables (needed for formatting variable references in expressions).
            Var = self.Variable
            var_df = model.select(Var.type, Var, Var.name, Var.lower, Var.upper).to_df()

            # Reachability scoped to this subconcept's roots. These define()
            # calls add rules to the model that persist (PyRel defines are additive).
            # Transitive closure requires recursive rules, so there's no pure-query
            # alternative. The rules are cheap (anonymous Hash relationships) but each
            # call bumps the model version. For frequent partial display calls, prefer
            # display() with no argument (uses the standing _reachable rule).
            n0, n1, i_, h_ = Hash.ref(), Hash.ref(), Integer.ref(), Hash.ref()
            reachable = model.Relationship(f"{Hash}")
            sub_ref = part.ref()
            model.define(reachable(sub_ref.root)).where(sub_ref)
            ctx = model.where(reachable(n0))
            ctx.define(reachable(n1)).where(self._ordered_args_hash(n0, i_, n1))
            ctx.define(reachable(n1)).where(self._unordered_args_hash(n0, h_, n1))

            expr_df = model.select(part.type, part.name, part.root).to_df()
            expr_types = expr_df["type"].astype(int) if not expr_df.empty else Series(dtype=int)
            ast_dfs = self._query_ast(reachable)
            return format_expression_table(expr_df, expr_types, var_df, ast_dfs)

        raise ValueError(
            f"Unrecognized display part: {part!r}. "
            f"Pass a subconcept returned by solve_for(), minimize(), maximize(), or satisfy()."
        )

    def _query_ast(self, reachable: Any) -> tuple[Any, Any, Any, Any]:
        """Query AST nodes filtered by a reachability relation."""
        model = self._model
        n0 = Hash.ref().alias("node")
        n1 = Hash.ref().alias("arg")
        i = Integer.ref().alias("index")
        h = Hash.ref().alias("hash")
        op = Integer.ref().alias("op")
        v = self._numeric_type.ref().alias("value")

        operators_df = model.select(n0, op).where(self._operator(n0, op), reachable(n0)).to_df()
        ordered_hash_df = model.select(n0, i, n1).where(self._ordered_args_hash(n0, i, n1), reachable(n0)).to_df()
        ordered_data_df = model.select(n0, i, v).where(self._ordered_args_data(n0, i, v), reachable(n0)).to_df()
        unordered_df = model.select(n0, n1).where(self._unordered_args_hash(n0, h, n1), reachable(n0)).to_df()
        return operators_df, ordered_hash_df, ordered_data_df, unordered_df

    # =========================================================================
    # Solve
    # =========================================================================

    def _export_model_to_csv(self) -> dict[str, str]:
        """Export model relations to CSV files for solver service.

        AST nodes are filtered through the standing _reachable relation to
        exclude orphaned nodes (from expressions where one arm is empty).
        """
        from relationalai.shims.executor import export_to_csv

        model = self._model
        Var, Expr = self.Variable, self.Expression
        reachable = self._reachable

        # Create fresh ref variables per fragment so that batching into a single
        # transaction does not create unintended cross-fragment joins.
        n0_op, op_ = Hash.ref().alias("NODE"), Integer.ref().alias("OP")
        n0_oh, i_oh, n1_oh = (
            Hash.ref().alias("NODE"),
            Integer.ref().alias("INDEX"),
            Hash.ref().alias("ARG"),
        )
        n0_od, i_od, v_od = (
            Hash.ref().alias("NODE"),
            Integer.ref().alias("INDEX"),
            self._numeric_type.ref().alias("VALUE"),
        )
        n0_uh, h_uh, n1_uh = (
            Hash.ref().alias("NODE"),
            Hash.ref().alias("HASH"),
            Hash.ref().alias("ARG"),
        )

        keys = [
            "variables",
            "expressions",
            "operators",
            "ordered_args_hash",
            "ordered_args_data",
            "unordered_args_hash",
        ]
        fragments = [
            model.select(
                Var.alias("VARIABLE"),
                Var.name.alias("NAME"),
                Var.type.alias("TYPE"),
                Var.lower.alias("LOWER"),
                Var.upper.alias("UPPER"),
                Var.start.alias("START"),
            ),
            model.select(Expr.root.alias("NODE"), Expr.name.alias("NAME"), Expr.type.alias("TYPE")),
            model.select(n0_op, op_).where(self._operator(n0_op, op_), reachable(n0_op)),
            model.select(n0_oh, i_oh, n1_oh).where(self._ordered_args_hash(n0_oh, i_oh, n1_oh), reachable(n0_oh)),
            model.select(n0_od, i_od, v_od).where(self._ordered_args_data(n0_od, i_od, v_od), reachable(n0_od)),
            model.select(n0_uh, n1_uh).where(self._unordered_args_hash(n0_uh, h_uh, n1_uh), reachable(n0_uh)),
        ]
        paths = export_to_csv(fragments)
        return dict(zip(keys, paths))

    def _import_solver_results(self, result_views: dict[str, str] | None = None) -> None:
        """Import solver results into model relations.

        All solver result views (ancillary, points, objective_values) are loaded
        in one batched payload and mapped to target relations in a single call.
        Data stays in Snowflake and is consumed directly by the logic engine.

        ``experimental.load_data`` has replace semantics, so each solve refreshes
        the same target relations and this model can reuse one set of derivation
        rules for all solves. ``result_views`` may override the source view for
        each result table while keeping stable target relations.
        """
        model = self._model
        self._solve_info_cache = None

        resources = get_provider(config=model.config).resources
        if not isinstance(resources, SnowflakeResources):
            raise TypeError(f"Importing solver results requires Snowflake resources, got {type(resources).__name__}.")
        app_name = resources.get_app_name()
        logic_engine_name = resources.get_default_engine_name()
        num_type = Integer if self._numeric_type is Integer else Float

        schemas: dict[str, dict[str, Any]] = {
            "ancillary": {"NAME": String, "VALUE": String},
            "points": {"SOL_INDEX": Integer, "VAR_HASH": String, "VALUE": num_type},
            "objective_values": {"SOL_INDEX": Integer, "VALUE": num_type},
        }

        if result_views is not None:
            expected = set(schemas.keys())
            actual = set(result_views.keys())
            if actual != expected:
                raise ValueError(
                    f"result_views must contain exactly {sorted(expected)}, got {sorted(actual)}"
                )

        if not self._result_tables:
            for name, schema in schemas.items():
                fqn = f"{app_name}.RESULTS.SOLVERS_{self._model_id}_{name.upper()}"
                table = model.Table(fqn, schema)
                # Skip CDC tracking — result tables use replace semantics,
                # so incremental change capture is unnecessary overhead.
                table._skip_cdc = True  # type: ignore[attr-defined]
                self._result_tables[name] = table

        table_fqns = [table._name for table in self._result_tables.values()]
        sources: dict[str, str] | None = None
        if result_views is not None:
            sources = {
                self._result_tables[name]._name: result_views[name]
                for name in schemas.keys()
            }
        _load_data_with_retry(
            model,
            resources,
            app_name,
            table_fqns,
            logic_engine_name,
            sources=sources,
        )

        # Rules are defined once; subsequent solves reuse them because
        # experimental.load_data replaces the underlying Table data in place.
        if not self._result_rules_defined:
            ancillary_tbl = self._result_tables["ancillary"]
            points_tbl = self._result_tables["points"]
            obj_vals_tbl = self._result_tables["objective_values"]

            model.define(self._solve_info(ancillary_tbl["NAME"], ancillary_tbl["VALUE"]))
            model.define(self._objective_values(obj_vals_tbl["SOL_INDEX"], obj_vals_tbl["VALUE"]))

            # Derive objective_value from objective_values table into ancillary
            # so solve_info() works without solver service ancillary changes.
            obj_val = self._numeric_type.ref()
            model.where(self._objective_values(1, obj_val)).define(
                self._solve_info("objective_value", std.strings.string(obj_val))
            )

            sol_idx = Integer.ref()
            var_hash_str = String.ref()
            val_ref = self._numeric_type.ref()
            model.where(
                points_tbl["SOL_INDEX"] == sol_idx,
                points_tbl["VAR_HASH"] == var_hash_str,
                points_tbl["VALUE"] == val_ref,
            ).define(self._points(sol_idx, std.common.parse_uuid(var_hash_str), val_ref))

            self._result_rules_defined = True

        # By default we load the solution with index `1` into `point`.
        # Reset load_point to solution 1 so re-solves after load_point() don't
        # query a stale solution index from the previous solve's data.
        self._load_point_counter += 1
        self._model.define(self._point_updates(self._load_point_counter, 1))

    def _wait_for_job(
        self,
        client: Any,
        job_id: str,
        log_to_console: bool = False,
        timeout_s: float = 900,
        poll_interval_s: float = 0.5,
    ) -> Any:
        """Poll job status until it reaches a terminal state, returning the job.

        Streams LogMessage events to stdout when *log_to_console* is True,
        using continuation tokens to avoid replaying events. Event streaming
        is best-effort — failures are silently ignored so they don't kill the
        solve. Performs a final event drain after the job reaches a terminal
        state (COMPLETED, FAILED, CANCELED, or ABORTED).

        Raises TimeoutError if the job has not reached a terminal state
        within *timeout_s* seconds.
        """
        import time as _time
        from relationalai.services.jobs.constants import JOB_TERMINAL_STATES

        continuation_token = ""
        deadline = _time.monotonic() + timeout_s

        def _drain_events() -> None:
            """Print LogMessage events, best-effort (errors silently ignored)."""
            nonlocal continuation_token
            try:
                events = client.jobs.get_events(
                    REASONER_TYPE,
                    job_id,
                    continuation_token=continuation_token,
                )
                for event in events.events:
                    msg = event.get("event", {}).get("message")
                    if event.get("type") == "LogMessage" and msg is not None:
                        print(msg)
                if events.continuation_token:
                    continuation_token = events.continuation_token
            except Exception:
                logger.debug("Failed to drain solver events", exc_info=True)

        while True:
            if log_to_console:
                _drain_events()

            job = client.jobs.get(REASONER_TYPE, job_id)
            if job and job.state and job.state.upper() in JOB_TERMINAL_STATES:
                if log_to_console:
                    _drain_events()
                return job

            if _time.monotonic() >= deadline:
                last_state = job.state if job and job.state else "not found"
                raise TimeoutError(f"Solver job {job_id} timed out after {timeout_s}s (last state: {last_state})")

            _time.sleep(poll_interval_s)

    @include_in_docs
    def solve(
        self,
        solver: str,
        *,
        time_limit_sec: float | None = None,
        silent: bool | None = None,
        solution_limit: int | None = None,
        relative_gap_tolerance: float | None = None,
        absolute_gap_tolerance: float | None = None,
        log_to_console: bool = False,
        print_only: bool = False,
        print_format: str | None = None,
        **solver_params: int | float | str | bool,
    ) -> None:
        """Solve the decision problem using a solver backend.

        Declare decision variables first with :meth:`solve_for`. After solving,
        inspect results via :meth:`variable_values` and result accessors such as
        :meth:`termination_status` and :meth:`objective_value`, or use
        :meth:`solve_info` for a Python-side snapshot.

        Parameters
        ----------
        solver : str
            Solver name (for example ``"highs"``, ``"minizinc"``, ``"ipopt"``).
        time_limit_sec : float, optional
            Maximum solve time in seconds. The solver service defaults to 300s
            if not provided.
        silent : bool, optional
            Whether to suppress solver output.
        solution_limit : int, optional
            Maximum number of solutions to return (when supported).
        relative_gap_tolerance : float, optional
            Relative optimality gap tolerance in ``[0, 1]``.
        absolute_gap_tolerance : float, optional
            Absolute optimality gap tolerance (>= 0).
        log_to_console : bool, default False
            Whether to stream solver logs to stdout while the job runs.
        print_only : bool, default False
            If True, request a text representation without solving. Results
            such as :meth:`printed_model` are still accessible afterward.
        print_format : str, optional
            Text format for the printed model. Supported formats: ``"moi"``
            (MOI text), ``"latex"``, ``"mof"`` (MOI JSON), ``"lp"``, ``"mps"``,
            ``"nl"`` (AMPL).
        **solver_params : int or float or str or bool
            Raw solver-specific parameters passed through to the solver service.

        Raises
        ------
        ValueError
            If no decision variables have been declared via :meth:`solve_for`.
        TypeError
            If any solver-specific parameter value is not an ``int``, ``float``,
            ``str``, or ``bool``.
        RuntimeError
            If the solver job fails.
        TimeoutError
            If the solver job does not reach a terminal state in time.

        Notes
        -----
        Passing ``**solver_params`` emits a warning because options may not be
        portable across solvers.
        """
        if not self._variable_relationships:
            raise ValueError("Cannot solve: no decision variables declared. Call solve_for() first.")
        if not isinstance(solver, str):
            raise TypeError(f"solver must be a string, got {type(solver).__name__}.")
        if "_server_side_import" in solver_params:
            raise TypeError(
                "The '_server_side_import' parameter has been removed. "
                "Simply remove it from your solve() call — result import is now handled automatically."
            )

        # Invalidate previous results before starting (see docstring above).
        self._solve_info_cache = None

        options: dict[str, Any] = {}
        if time_limit_sec is not None:
            options["time_limit_sec"] = float(time_limit_sec)
        if silent is not None:
            options["silent"] = bool(silent)
        if solution_limit is not None:
            options["solution_limit"] = int(solution_limit)
        if relative_gap_tolerance is not None:
            options["relative_gap_tolerance"] = float(relative_gap_tolerance)
        if absolute_gap_tolerance is not None:
            options["absolute_gap_tolerance"] = float(absolute_gap_tolerance)
        if print_only:
            options["print_only"] = True
        if print_format is not None:
            options["print_format"] = str(print_format)

        if solver_params:
            for value in solver_params.values():
                if not isinstance(value, (int, float, str, bool)):
                    raise TypeError(
                        f"Solver parameter values must be int, float, str, or bool, got {type(value).__name__}."
                    )
            warnings.warn(
                f"Solver-specific parameters {set(solver_params)} may not be portable across solvers.",
                UserWarning,
                stacklevel=2,
            )
            options.update(solver_params)

        import time

        client = connect_sync(config=self._model.config)
        ensure_op = client.reasoners.ensure(REASONER_TYPE)

        t0 = time.perf_counter()
        inputs = self._export_model_to_csv()
        logger.info("Exported CSV (%.2fs)", time.perf_counter() - t0)

        payload: dict[str, Any] = {
            "solver": solver.lower(),
            "version": 1,
            "options": options,
            "inputs": inputs,
            "model_id": self._model_id,
        }

        t1 = time.perf_counter()
        engine = ensure_op.wait()
        logger.info(
            "Solve: solver=%s, engine=%s, model_id=%s, options=%s",
            solver,
            engine.name,
            self._model_id,
            {k: v for k, v in options.items() if k != "inputs"},
        )
        op = client.jobs.create(REASONER_TYPE, engine.name, payload)
        # Add a 60s buffer on top of the solver's time limit so the client
        # doesn't time out before the solver does.
        wait_kwargs: dict[str, float] = {}
        if time_limit_sec is not None:
            wait_kwargs["timeout_s"] = float(time_limit_sec) + 60
        job = self._wait_for_job(client, op.id, log_to_console=log_to_console, **wait_kwargs)
        if (job.state or "").upper() != "COMPLETED":
            state = (job.state or "UNKNOWN").lower()
            reason = job.abort_reason or "no details available"
            raise RuntimeError(f"Solver job {job.id} {state}: {reason}")
        logger.info("Solver job %s completed (%.2fs)", job.id, time.perf_counter() - t1)
        t2 = time.perf_counter()
        self._import_solver_results()
        logger.info("Imported results (%.2fs)", time.perf_counter() - t2)

        return None

    # =========================================================================
    # Result Handling
    # =========================================================================

    @include_in_docs
    def variable_values(self, multiple: bool = False) -> b.Fragment:
        """Return decision variable values from the active solution.

        Use this after :meth:`solve` (and optionally :meth:`load_point`) to read
        variable values as a fragment you can materialize with ``.to_df()`` or
        print with ``.inspect()``.

        Parameters
        ----------
        multiple : bool, default False
            If True, return values for all solutions from the most recent
            :meth:`solve` call and include a 0-based ``sol_index`` column.

        Returns
        -------
        Fragment
            A fragment with columns ``name`` and ``value`` (and ``sol_index`` if
            *multiple* is True). Returns an empty DataFrame if :meth:`solve`
            has not been called.
        """
        Var = self.Variable
        val_ref = self._numeric_type.ref()
        var_hash = core_cast(b.MetaRef(Hash), Var)
        raw_index = Integer.ref()
        if multiple:
            return self._model.select(
                (raw_index - 1).alias("sol_index"),
                Var.name.alias("name"),
                val_ref.alias("value"),
            ).where(self._points(raw_index, var_hash, val_ref))

        return self._model.select(
            Var.name.alias("name"),
            val_ref.alias("value"),
        ).where(self._point(var_hash, val_ref))

    @include_in_docs
    def load_point(self, point_index: int) -> None:
        """Make a specific solution point the active solution.

        After calling this, :meth:`variable_values` and populated properties
        reflect the selected solution.

        Parameters
        ----------
        point_index : int
            0-based solution index (0 selects the first solution).

        Raises
        ------
        ValueError
            If *point_index* is not a non-negative integer.

        Notes
        -----
        No range validation is performed (it would require an engine query).
        If *point_index* exceeds available solutions, :meth:`variable_values`
        returns an empty DataFrame. Use ``solve_info().num_points`` to check
        bounds beforehand.

        Each call records a new selection via ``model.define(...)``. The next
        query may be slower because the model needs to re-evaluate.
        """
        if isinstance(point_index, bool) or not isinstance(point_index, int) or point_index < 0:
            raise ValueError(f"Point index must be a non-negative integer, got {point_index!r}")
        # No range validation — checking bounds would require an additional
        # engine query for every load_point call.  If the index doesn't exist,
        # variable_values will simply return an empty DataFrame for that point.
        self._load_point_counter += 1
        self._model.define(self._point_updates(self._load_point_counter, point_index + 1))

    # =========================================================================
    # Solve Info (Python Access)
    # =========================================================================

    @include_in_docs
    def solve_info(self) -> SolveInfoData:
        """Return solver result metadata as a cached :class:`SolveInfoData`.

        Fetches all result metadata in a single query. Subsequent calls
        return the cached snapshot until the next :meth:`solve`.

        Returns
        -------
        SolveInfoData
            Frozen dataclass with typed fields. Use ``print(si)`` or
            ``si.display()`` for a formatted summary. If :meth:`solve`
            has not been called, all fields are None (``error`` is ``()``).
        """
        if self._solve_info_cache is not None:
            return self._solve_info_cache

        # One query: batch-fetch all _solve_info key-value pairs.
        key_ref, val_ref = String.ref(), String.ref()
        df = self._model.select(key_ref, val_ref).where(self._solve_info(key_ref, val_ref)).to_df()
        info: dict[str, list[str]] = {}
        for _, row in df.iterrows():
            k, v = str(row.iloc[0]), str(row.iloc[1])
            info.setdefault(k, []).append(v)

        def _first(key: str) -> str | None:
            vals = info.get(key)
            return vals[0] if vals else None

        def _parse_num(s: str | None, as_int: bool = False) -> float | int | None:
            if s is None:
                return None
            try:
                return int(s) if as_int else float(s)
            except (ValueError, OverflowError):
                logger.warning("Could not parse ancillary value %r as %s", s, "int" if as_int else "float")
                return None

        stv = _first("solve_time_sec")
        obj_str = _first("objective_value")
        obj_value = _parse_num(obj_str, as_int=(self._numeric_type is Integer))

        # num_points from the Relationship (counts distinct sol_index in _points).
        # Falls back to ancillary string if Relationship returns empty (e.g. set_fake_results).
        np_df = self._model.select(self.num_points()).to_df()
        if not np_df.empty:
            np_value = int(np_df.iloc[0, 0])  # type: ignore[arg-type]
        else:
            np_value = _parse_num(_first("num_points"), as_int=True)

        self._solve_info_cache = SolveInfoData(
            termination_status=_first("termination_status"),
            objective_value=obj_value,
            solve_time_sec=_parse_num(stv),
            num_points=int(np_value) if np_value is not None else None,
            solver_version=_first("solver_version"),
            error=tuple(info.get("error", ())),
            printed_model=_first("printed_model"),
        )
        return self._solve_info_cache
