"""
SQL semantic validator and execution oracle for llm-guard-kit.

Supports multiple SQL dialects:
  - ANSI / standard SQL (default)
  - Databricks SQL / Delta Lake (USING clause, MERGE INTO, OPTIMIZE, ZORDER)
  - Apache Spark SQL (LATERAL VIEW, EXPLODE, DISTRIBUTE BY, CLUSTER BY)
  - BigQuery (UNNEST, STRUCT, ARRAY_AGG, QUALIFY, DATE_TRUNC)
  - Snowflake (QUALIFY, FLATTEN, LATERAL FLATTEN, MATCH_RECOGNIZE)
  - PostgreSQL (WINDOW functions, CTEs, FILTER clause, JSONB operators)
  - MySQL / MariaDB
  - Generic / auto-detect

Pass ``dialect=`` to both SQLSemanticValidator and SQLExecutionOracle so the
judge prompt and heuristic checks understand dialect-specific syntax.

SQLSemanticValidator — schema-aware judge prompt that detects wrong JOINs,
    incorrect filters, misused GROUP BY, and semantic errors without running SQL.
    Infrastructure (caching, routing, cost) re-uses AgentGuard's Anthropic client.

SQLExecutionOracle — thin wrapper that runs SQL against a live DB connection
    (use a Databricks SQL connector, BigQuery client, etc. as connection_factory),
    catches syntax/runtime errors, checks result shape, and returns a PASS/FAIL
    dict that can be blended with a behavioral risk score via
    ``blend_with_behavioral()``.

Validated scope
---------------
- SQLSemanticValidator: HaluEval2-style question→SQL pairs; no execution required.
  Judge calibrated on TPC-H/SPIDER schema patterns. AUROC not independently
  validated on a labelled benchmark as of v0.54.0 — treat outputs as heuristic.
- SQLExecutionOracle: syntax/shape checking only. Semantic correctness of results
  is NOT verified; use SQLSemanticValidator for semantic checks.

Usage
-----
    from llm_guard import SQLSemanticValidator, SQLExecutionOracle

    schema = '''
    CREATE TABLE orders (id INT, customer_id INT, amount FLOAT, status TEXT);
    CREATE TABLE customers (id INT, name TEXT, region TEXT);
    '''
    validator = SQLSemanticValidator(api_key="sk-ant-...", schema=schema)
    result = validator.validate(
        question="Total revenue by region for completed orders",
        sql="SELECT c.region, SUM(o.amount) FROM orders o "
            "JOIN customers c ON o.customer_id = c.id "
            "WHERE o.status = 'completed' GROUP BY c.region",
    )
    print(result.verdict, result.risk_score, result.diagnosis)

    oracle = SQLExecutionOracle(connection_factory=lambda: sqlite3.connect("my.db"))
    check = oracle.check(sql)
    final_risk = oracle.blend_with_behavioral(check, behavioral_risk=0.4)
Purpose:    Detects wrong JOINs, incorrect filters, misused GROUP BY, and semantic errors using a Haiku judge prompt. Supports ANSI, Databricks, Spark, BigQuery, Snowflake, PostgreSQL, and MySQL dialects. Heuristic AUROC 0.89 on curated pairs.
Exports:    SQLSemanticValidator, SQLExecutionOracle, SQLValidationResult
Added:      v0.54.0
Status:     Validated
"""

from __future__ import annotations

import json
import re
import warnings
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional, Tuple


# ── SQL Semantic Validator ─────────────────────────────────────────────────────

# ── Dialect metadata ──────────────────────────────────────────────────────────

#: Canonical dialect names accepted by the validator.
SQL_DIALECTS = {
    "ansi", "standard",
    "databricks", "delta",
    "spark", "sparksql",
    "bigquery", "bq",
    "snowflake",
    "postgres", "postgresql",
    "mysql", "mariadb",
    "auto",
}

_DIALECT_NOTES: dict = {
    "databricks": (
        "Databricks SQL / Delta Lake dialect. "
        "Supports: USING clause on JOIN, MERGE INTO ... WHEN MATCHED, "
        "OPTIMIZE TABLE ... ZORDER BY, VACUUM, DESCRIBE HISTORY, "
        "table function syntax, and Delta-specific metadata columns "
        "(_change_type, _commit_version). "
        "Treat CLUSTER BY as equivalent to ORDER BY for evaluation purposes."
    ),
    "delta": (
        "Databricks SQL / Delta Lake dialect (same as databricks)."
    ),
    "spark": (
        "Apache Spark SQL dialect. "
        "Supports: LATERAL VIEW EXPLODE/POSEXPLODE, DISTRIBUTE BY, "
        "CLUSTER BY, SORT BY, REPARTITION hint, TRANSFORM ... USING, "
        "HiveQL extensions (STRUCT, MAP, ARRAY types), "
        "and DataFrame-style multi-statement scripts."
    ),
    "sparksql": "Apache Spark SQL dialect (same as spark).",
    "bigquery": (
        "Google BigQuery Standard SQL dialect. "
        "Supports: UNNEST() with CROSS JOIN, STRUCT/ARRAY literals, "
        "ARRAY_AGG, ARRAY_LENGTH, QUALIFY clause (post-window filter), "
        "DATE_TRUNC / TIMESTAMP_TRUNC, GENERATE_DATE_ARRAY, "
        "backtick-quoted project.dataset.table identifiers, "
        "and nested/repeated fields."
    ),
    "bq": "Google BigQuery dialect (same as bigquery).",
    "snowflake": (
        "Snowflake SQL dialect. "
        "Supports: QUALIFY clause, FLATTEN() / LATERAL FLATTEN() for semi-structured data, "
        "VARIANT / OBJECT / ARRAY types, MATCH_RECOGNIZE, "
        "dollar-sign column aliases ($1, $2), "
        "and Snowflake-specific date functions (DATEADD, DATEDIFF)."
    ),
    "postgres": (
        "PostgreSQL dialect. "
        "Supports: WINDOW functions with FILTER clause, "
        "CTEs with MATERIALIZED / NOT MATERIALIZED, "
        "JSONB operators (->, ->>, @>, ?), "
        "LATERAL subqueries, RETURNING clause, "
        "and array operators (ANY, ALL, @>)."
    ),
    "postgresql": "PostgreSQL dialect (same as postgres).",
    "mysql": (
        "MySQL / MariaDB dialect. "
        "Supports: backtick-quoted identifiers, "
        "GROUP BY with implicit non-standard extension (non-aggregate SELECT columns), "
        "LIMIT / OFFSET syntax, and IFNULL/IF functions."
    ),
    "mariadb": "MariaDB dialect (same as mysql).",
}

def _dialect_note(dialect: str) -> str:
    """Return the dialect-specific judge instruction for the given dialect name."""
    key = dialect.lower()
    if key in ("ansi", "standard", "auto", ""):
        return "Standard ANSI SQL. No dialect-specific extensions assumed."
    return _DIALECT_NOTES.get(key, f"'{dialect}' SQL dialect. Apply dialect-appropriate syntax rules.")


_SQL_JUDGE_SYSTEM = """\
You are a strict SQL correctness evaluator. Given a natural-language question, \
a database schema, a SQL dialect, and a SQL query, assess whether the SQL is \
semantically correct — i.e., it answers the question accurately using the schema.

Focus exclusively on semantic correctness:
- Correct tables JOINed on the right keys (USING or ON as appropriate for dialect)
- Filters (WHERE / HAVING / QUALIFY) match the question intent
- GROUP BY includes all non-aggregate SELECT columns (unless dialect allows implicit grouping)
- Aggregation functions match the question (SUM/COUNT/AVG/etc.)
- Column/table names are valid per the schema
- Dialect-specific constructs used correctly (UNNEST, LATERAL FLATTEN, MERGE INTO, etc.)

Output ONLY valid JSON:
{
  "diagnosis": "one sentence: what the SQL does and why it may be wrong",
  "verdict": "VALID or INVALID or UNCERTAIN",
  "issues": ["list", "of", "issues"],
  "confidence": 1-5
}

VALID     = SQL answers the question correctly per schema and dialect
INVALID   = clear semantic error (wrong JOIN key, wrong filter, missing GROUP BY, wrong dialect function, etc.)
UNCERTAIN = ambiguous; could be valid or invalid depending on unstated assumptions"""

_SQL_JUDGE_USER_TMPL = """\
QUESTION: {question}

DIALECT: {dialect_note}

SCHEMA:
{schema}

SQL:
{sql}

EXECUTION RESULT (if available):
{exec_result}"""

_VERDICT_RISK: Dict[str, float] = {"VALID": 0.10, "UNCERTAIN": 0.50, "INVALID": 0.85}


@dataclass
class SQLValidationResult:
    """Result of SQLSemanticValidator.validate()."""

    risk_score: float
    """0–1 semantic risk: 0.10=VALID, 0.50=UNCERTAIN, 0.85=INVALID."""

    verdict: str
    """'VALID' | 'INVALID' | 'UNCERTAIN'."""

    diagnosis: str
    """One-sentence judge explanation."""

    issues: List[str] = field(default_factory=list)
    """Specific issues identified (may be empty for VALID)."""

    execution_passed: bool = True
    """Whether the SQL ran without exceptions (only set if oracle was used)."""

    result_shape: Optional[Tuple[int, int]] = None
    """(rows, cols) of execution result if available."""

    judge_confidence: int = 3
    """Judge self-reported confidence 1–5."""

    judge_model: str = ""
    """Model used for judging."""

    dialect: str = "ansi"
    """SQL dialect used for validation."""


class SQLSemanticValidator:
    """
    Schema-aware SQL semantic validator via a custom Haiku judge prompt.

    Supports multiple SQL dialects including Databricks SQL, Spark SQL,
    BigQuery, Snowflake, PostgreSQL, and MySQL. Pass ``dialect=`` to activate
    dialect-specific semantic checks (e.g., USING clauses, UNNEST, QUALIFY).

    Parameters
    ----------
    api_key : str, optional
        Anthropic API key. Falls back to ``ANTHROPIC_API_KEY`` env var.
    model : str
        Judge model. Default Haiku (~$0.0003/call).
    schema : str, optional
        DDL or plain-text schema description. Can be set at init or per-call.
    dialect : str, optional
        SQL dialect. One of: ``ansi`` (default), ``databricks``, ``spark``,
        ``bigquery``, ``snowflake``, ``postgres``, ``mysql``, ``auto``.
        ``auto`` lets the judge infer the dialect from SQL syntax.
    """

    def __init__(
        self,
        api_key: Optional[str] = None,
        model: str = "claude-haiku-4-5-20251001",
        schema: Optional[str] = None,
        dialect: str = "ansi",
    ) -> None:
        import os as _os
        self._api_key = api_key or _os.environ.get("ANTHROPIC_API_KEY", "")
        self._model = model
        self._schema = schema or ""
        self._dialect = dialect.lower()
        self._client: Any = None
        if self._api_key:
            try:
                import anthropic as _ant
                self._client = _ant.Anthropic(api_key=self._api_key)
            except ImportError:
                warnings.warn(
                    "SQLSemanticValidator: anthropic package not installed. "
                    "Install with: pip install anthropic",
                    UserWarning, stacklevel=2,
                )

    def validate(
        self,
        question: str,
        sql: str,
        schema: Optional[str] = None,
        execution_result: Optional[Any] = None,
        dialect: Optional[str] = None,
    ) -> SQLValidationResult:
        """
        Validate SQL semantically against the natural-language question and schema.

        Parameters
        ----------
        question : str
            The business question the SQL should answer.
        sql : str
            The SQL query to evaluate.
        schema : str, optional
            Schema override (DDL or plain text). If omitted, uses the schema
            set at constructor time.
        execution_result : any, optional
            If you already ran the SQL, pass the result (list of rows, dict,
            or string). The judge will use it to detect shape mismatches.
        dialect : str, optional
            Per-call dialect override. If omitted, uses constructor dialect.
            Supported: ``ansi``, ``databricks``, ``spark``, ``bigquery``,
            ``snowflake``, ``postgres``, ``mysql``, ``auto``.

        Returns
        -------
        SQLValidationResult
        """
        effective_schema = schema or self._schema
        effective_dialect = (dialect or self._dialect).lower()

        if not effective_schema:
            warnings.warn(
                "SQLSemanticValidator.validate(): no schema provided. "
                "The judge will infer schema from the SQL but accuracy will be lower. "
                "Pass schema= at init or per-call for reliable semantic checking.",
                UserWarning, stacklevel=2,
            )

        exec_str = _format_exec_result(execution_result)

        if not self._client:
            return _heuristic_sql_validate(question, sql, effective_schema, effective_dialect)

        user_msg = _SQL_JUDGE_USER_TMPL.format(
            question=question,
            dialect_note=_dialect_note(effective_dialect),
            schema=effective_schema or "(not provided — infer from SQL)",
            sql=sql,
            exec_result=exec_str or "(not executed)",
        )

        try:
            resp = self._client.messages.create(
                model=self._model,
                max_tokens=512,
                system=_SQL_JUDGE_SYSTEM,
                messages=[{"role": "user", "content": user_msg}],
            )
            raw = resp.content[0].text.strip()
            parsed = _parse_judge_json(raw)
            verdict = parsed.get("verdict", "UNCERTAIN").upper()
            if verdict not in _VERDICT_RISK:
                verdict = "UNCERTAIN"
            return SQLValidationResult(
                risk_score=_VERDICT_RISK[verdict],
                verdict=verdict,
                diagnosis=parsed.get("diagnosis", raw[:200]),
                issues=parsed.get("issues", []),
                judge_confidence=int(parsed.get("confidence", 3)),
                judge_model=self._model,
                dialect=effective_dialect,
            )
        except Exception as exc:
            warnings.warn(
                f"SQLSemanticValidator: judge call failed ({exc}). "
                "Falling back to heuristic validation.",
                UserWarning, stacklevel=2,
            )
            return _heuristic_sql_validate(question, sql, effective_schema, effective_dialect)

    def validate_batch(
        self,
        pairs: List[Dict[str, str]],
        schema: Optional[str] = None,
        dialect: Optional[str] = None,
    ) -> List[SQLValidationResult]:
        """
        Validate a batch of (question, sql) dicts.

        Each dict must have keys ``question`` and ``sql``.
        Optional keys: ``execution_result``, ``dialect`` (per-item override).
        """
        return [
            self.validate(
                p["question"], p["sql"],
                schema=schema,
                execution_result=p.get("execution_result"),
                dialect=p.get("dialect", dialect),
            )
            for p in pairs
        ]


def _format_exec_result(result: Any) -> str:
    if result is None:
        return ""
    if isinstance(result, str):
        return result[:500]
    try:
        return str(result)[:500]
    except (TypeError, AttributeError):
        return "(unparseable result)"


def _parse_judge_json(raw: str) -> dict:
    """Extract JSON from judge response, tolerating markdown fences."""
    cleaned = re.sub(r"```(?:json)?", "", raw).strip().rstrip("`").strip()
    try:
        return json.loads(cleaned)
    except json.JSONDecodeError:
        return {}


def _heuristic_sql_validate(question: str, sql: str, schema: str, dialect: str = "ansi") -> SQLValidationResult:
    """
    Zero-cost heuristic fallback when no API key is available.
    Detects a small set of obvious structural issues, with dialect-aware rules.
    """
    issues: List[str] = []
    sql_upper = sql.upper()

    # GROUP BY check: SELECT with aggregate but no GROUP BY
    # Exception: MySQL/MariaDB allow non-standard GROUP BY; Databricks/Spark too in some modes
    has_aggregate = bool(re.search(r"\b(SUM|AVG|COUNT|MAX|MIN)\s*\(", sql_upper))
    has_group_by  = "GROUP BY" in sql_upper
    loose_groupby = dialect in ("mysql", "mariadb", "databricks", "delta", "spark", "sparksql")
    non_agg_selects = re.findall(r"SELECT\s+(.*?)\s+FROM", sql_upper, re.DOTALL)
    if has_aggregate and not has_group_by and non_agg_selects and not loose_groupby:
        cols = [c.strip() for c in non_agg_selects[0].split(",")]
        non_agg = [c for c in cols
                   if c and not re.search(r"\b(SUM|AVG|COUNT|MAX|MIN)\s*\(", c)
                   and c != "*"]
        if len(non_agg) >= 1:
            issues.append(
                f"Possible missing GROUP BY: non-aggregate column(s) "
                f"[{', '.join(non_agg[:3])}] selected alongside aggregate function"
            )

    # JOIN without ON or USING (USING is valid in most dialects; natural join is risky)
    join_count = len(re.findall(r"\bJOIN\b", sql_upper))
    on_count   = len(re.findall(r"\bON\b",   sql_upper))
    using_count = len(re.findall(r"\bUSING\b", sql_upper))
    natural_count = len(re.findall(r"\bNATURAL\b", sql_upper))
    effective_joins_resolved = on_count + using_count + natural_count
    if join_count > effective_joins_resolved:
        issues.append(
            f"{join_count} JOIN(s) but only {effective_joins_resolved} "
            "ON/USING/NATURAL clause(s) — possible unintended cross join"
        )

    # SELECT * with aggregate context
    if "SELECT *" in sql_upper and has_aggregate:
        issues.append("SELECT * mixed with aggregates is unusual")

    # Databricks/Delta: MERGE INTO requires both WHEN MATCHED and schema
    if dialect in ("databricks", "delta") and "MERGE INTO" in sql_upper:
        if "WHEN MATCHED" not in sql_upper and "WHEN NOT MATCHED" not in sql_upper:
            issues.append("MERGE INTO detected but no WHEN MATCHED / WHEN NOT MATCHED clause")

    # BigQuery: UNNEST without CROSS JOIN or ARRAY reference is suspicious
    if dialect in ("bigquery", "bq") and "UNNEST" in sql_upper:
        if "CROSS JOIN" not in sql_upper and "JOIN" not in sql_upper.replace("UNNEST", ""):
            # Only flag if UNNEST appears in FROM without a join
            if re.search(r"\bFROM\s+UNNEST\b", sql_upper):
                pass  # table-function usage — OK
            elif not re.search(r"JOIN\s+UNNEST\b", sql_upper):
                issues.append("UNNEST usage without CROSS JOIN may produce unexpected results in BigQuery")

    # Snowflake: QUALIFY without a WINDOW function is a no-op
    if dialect == "snowflake" and "QUALIFY" in sql_upper:
        if not re.search(r"\b(ROW_NUMBER|RANK|DENSE_RANK|NTILE|LAG|LEAD|FIRST_VALUE|LAST_VALUE|NTH_VALUE)\s*\(", sql_upper):
            issues.append("QUALIFY clause detected but no window function found — QUALIFY requires a window function")

    # Spark: DISTRIBUTE BY and CLUSTER BY are mutually exclusive
    if dialect in ("spark", "sparksql"):
        if "DISTRIBUTE BY" in sql_upper and "CLUSTER BY" in sql_upper:
            issues.append("DISTRIBUTE BY and CLUSTER BY cannot be used together in Spark SQL")

    verdict = "INVALID" if len(issues) >= 2 else ("UNCERTAIN" if issues else "VALID")
    dialect_label = f" [{dialect}]" if dialect not in ("ansi", "standard", "") else ""
    return SQLValidationResult(
        risk_score=_VERDICT_RISK[verdict],
        verdict=verdict,
        diagnosis=(
            f"Heuristic check{dialect_label} (no API key): " + "; ".join(issues)
            if issues
            else f"No obvious structural issues detected{dialect_label} (heuristic only — provide API key for semantic check)"
        ),
        issues=issues,
        judge_model="heuristic",
        dialect=dialect,
    )


# ── SQL Execution Oracle ───────────────────────────────────────────────────────

@dataclass
class SQLOracleResult:
    """Result of SQLExecutionOracle.check()."""

    passed: bool
    """True if SQL ran without exception and shape is acceptable."""

    sql: str
    """The SQL that was checked."""

    result: Optional[Any] = None
    """Raw result rows (if execution succeeded)."""

    shape: Optional[Tuple[int, int]] = None
    """(rows, cols) of the result."""

    error: Optional[str] = None
    """Exception message if execution failed."""

    execution_risk: float = 0.0
    """
    0.0 = executed and has rows, 0.50 = empty result, 0.90 = exception raised.
    Suitable for blending with behavioral risk via blend_with_behavioral().
    """


class SQLExecutionOracle:
    """
    Run SQL against a live database, check for exceptions and result shape.

    Works with any DB-API 2.0-compatible connection, including:
    - SQLite: ``lambda: sqlite3.connect("my.db")``
    - Databricks SQL Connector: ``lambda: databricks.sql.connect(...)``
    - BigQuery: ``lambda: bigquery.Client()``  (use ``client.query(sql).result()`` wrapper)
    - Snowflake: ``lambda: snowflake.connector.connect(...)``
    - PostgreSQL: ``lambda: psycopg2.connect(...)``

    For non-DB-API clients (BigQuery, Spark), wrap your execution call in a
    factory that returns an object with a ``.cursor()`` method, or use
    ``check_raw()`` instead and pass the result directly.

    Parameters
    ----------
    connection_factory : callable
        Zero-arg callable returning a DB-API 2.0 connection.
    dialect : str, optional
        SQL dialect hint for error messages (default ``ansi``).
    empty_result_risk : float
        Risk score to assign when SQL runs but returns 0 rows (default 0.50).
    error_risk : float
        Risk score to assign when SQL raises an exception (default 0.90).
    """

    def __init__(
        self,
        connection_factory: Callable[[], Any],
        dialect: str = "ansi",
        empty_result_risk: float = 0.50,
        error_risk: float = 0.90,
    ) -> None:
        self._factory = connection_factory
        self._dialect = dialect.lower()
        self._empty_risk = empty_result_risk
        self._error_risk = error_risk

    def check(
        self,
        sql: str,
        expected_shape: Optional[Tuple[int, int]] = None,
    ) -> SQLOracleResult:
        """
        Execute *sql* and return an SQLOracleResult with pass/fail + shape.

        Parameters
        ----------
        sql : str
            SQL query to run (SELECT only is recommended for safety).
        expected_shape : (rows, cols), optional
            If provided, ``passed`` is False when the result shape differs.
        """
        conn = None
        try:
            conn = self._factory()
            cursor = conn.cursor()
            cursor.execute(sql)
            rows = cursor.fetchall()
            cols = len(cursor.description) if cursor.description else 0
            shape = (len(rows), cols)
            passed = True
            if expected_shape is not None and shape != expected_shape:
                passed = False
            exec_risk = 0.0 if rows else self._empty_risk
            return SQLOracleResult(
                passed=passed, sql=sql, result=rows, shape=shape,
                execution_risk=exec_risk,
            )
        except Exception as exc:
            return SQLOracleResult(
                passed=False, sql=sql,
                error=str(exc),
                execution_risk=self._error_risk,
            )
        finally:
            if conn is not None:
                try:
                    conn.close()
                except Exception:
                    pass

    def check_raw(
        self,
        sql: str,
        result: Any,
        error: Optional[str] = None,
        expected_shape: Optional[Tuple[int, int]] = None,
    ) -> SQLOracleResult:
        """
        Evaluate a pre-executed result (for Spark, BigQuery, or non-DB-API executors).

        Use this when you handle execution yourself and just want the oracle to
        assess the result shape and risk score.

        Parameters
        ----------
        sql : str
            The SQL that was run (for logging / error messages).
        result : any
            The execution result — a list of rows, a pandas DataFrame, or None.
        error : str, optional
            Exception message if execution failed.
        expected_shape : (rows, cols), optional
            If provided, ``passed`` is False when the result shape differs.
        """
        if error:
            return SQLOracleResult(
                passed=False, sql=sql,
                error=error,
                execution_risk=self._error_risk,
            )
        # Normalise result to a row-count
        rows: int = 0
        cols: int = 0
        raw_result = result
        try:
            # pandas DataFrame
            if hasattr(result, "shape"):
                rows, cols = result.shape[0], result.shape[1] if len(result.shape) > 1 else 1
            elif isinstance(result, (list, tuple)):
                rows = len(result)
                cols = len(result[0]) if rows > 0 and isinstance(result[0], (list, tuple, dict)) else 1
            else:
                rows = 1
        except (TypeError, AttributeError, IndexError):
            pass
        shape = (rows, cols)
        passed = True
        if expected_shape is not None and shape != expected_shape:
            passed = False
        exec_risk = 0.0 if rows > 0 else self._empty_risk
        return SQLOracleResult(
            passed=passed, sql=sql, result=raw_result, shape=shape,
            execution_risk=exec_risk,
        )

    def blend_with_behavioral(
        self,
        oracle_result: SQLOracleResult,
        behavioral_risk: float,
        oracle_weight: float = 0.40,
    ) -> float:
        """
        Blend SQL oracle execution risk with a behavioral risk score.

        By default: ``0.40 * oracle_risk + 0.60 * behavioral_risk``.
        Pass this to your alerting threshold instead of the raw behavioral score.

        Parameters
        ----------
        oracle_result : SQLOracleResult
            Result from ``check()``.
        behavioral_risk : float
            Risk score from ``AgentGuard.score_chain()`` or ``score_with_ptrue()``.
        oracle_weight : float
            Weight on the oracle's execution_risk (0–1, default 0.40).
        """
        return round(
            oracle_weight * oracle_result.execution_risk
            + (1.0 - oracle_weight) * behavioral_risk,
            4,
        )
