"""Gate 5E-S dry-run learned skill recommendation.

The learned controller may recommend only deterministic candidate IDs. The
recommendation is logged as a dry-run record and is never converted into a
skill-call instruction.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

from .bytecode import CognitiveOp, Instruction
from .controller import Controller
from .ledger import canonical_json, stable_hash
from .shadow_controller import ShadowCalibration, ShadowControllerModel, model_input_from_workspace, predict_calibrated_shadow
from .skills import Skill, SkillGraph
from .workspace import Workspace
from .workbench import oracle_leak_keys


GATE5E_SCHEMA_VERSION = 1
SKILL_RECOMMENDATION_TYPE = "CALL_SKILL_RECOMMENDATION"
SAFE_ARG_SOURCES = {"runtime_generated", "visible_observation", "safe_memory_skill_cards"}
FORBIDDEN_RECOMMENDATION_TEXT = {
    "patch",
    "write_file",
    "write memory",
    "update skill",
    "shell",
    "bash",
    "python -c",
    "answer:",
}


@dataclass(slots=True)
class SkillRecommendationCandidate:
    candidate_id: str
    candidate_type: str
    skill_id: str
    skill_version: int
    skill_status: str
    quarantined: bool
    preconditions: dict[str, Any] = field(default_factory=dict)
    preconditions_proven: bool = False
    evidence_trace_count: int = 0
    heldout_success_rate: float = 0.0
    adversarial_success_rate: float = 0.0
    false_trigger_count: int = 0
    failure_count: int = 0
    required_verifier: list[str] = field(default_factory=list)
    verifier_required_after: bool = True
    risk_class: str = "low"
    source_of_args: str = "runtime_generated"
    observed_arg_proofs: list[dict[str, Any]] = field(default_factory=list)
    memory_warning_refs: list[str] = field(default_factory=list)
    anti_pattern_refs: list[str] = field(default_factory=list)
    dry_run_only: bool = True
    mutation_possible_if_executed: bool = True
    legal_under_action_mask: bool = True
    oracle_free: bool = True

    @classmethod
    def create(
        cls,
        skill: Skill,
        *,
        preconditions_proven: bool,
        source_of_args: str = "runtime_generated",
        observed_arg_proofs: list[dict[str, Any]] | None = None,
        memory_warning_refs: list[str] | None = None,
        anti_pattern_refs: list[str] | None = None,
        legal_under_action_mask: bool = True,
        oracle_free: bool = True,
        candidate_type: str = SKILL_RECOMMENDATION_TYPE,
        risk_class: str | None = None,
        dry_run_only: bool = True,
        mutation_possible_if_executed: bool = True,
    ) -> "SkillRecommendationCandidate":
        status = str(skill.status)
        warnings = list(memory_warning_refs or [])
        anti_patterns = list(anti_pattern_refs or [])
        computed_risk = risk_class or _risk_for_skill(skill, preconditions_proven, warnings, anti_patterns, oracle_free, legal_under_action_mask)
        payload = {
            "schema_version": GATE5E_SCHEMA_VERSION,
            "candidate_type": candidate_type,
            "skill_id": str(skill.skill_id or skill.name),
            "skill_version": int(skill.version),
            "skill_status": status,
            "quarantined": bool(status == "quarantined" or skill.quarantine_reason),
            "preconditions": dict(skill.preconditions),
            "preconditions_proven": bool(preconditions_proven),
            "evidence_trace_count": int(skill.promotion_evidence_count or skill.success_count),
            "heldout_success_rate": float(skill.heldout_success_rate),
            "adversarial_success_rate": float(skill.adversarial_success_rate),
            "false_trigger_count": int(skill.false_trigger_count),
            "failure_count": int(skill.failure_count),
            "required_verifier": [str(item) for item in (skill.required_verifier or ([skill.verifier] if skill.verifier else []))],
            "verifier_required_after": True,
            "risk_class": computed_risk,
            "source_of_args": source_of_args,
            "observed_arg_proofs": list(observed_arg_proofs or []),
            "memory_warning_refs": warnings,
            "anti_pattern_refs": anti_patterns,
            "dry_run_only": bool(dry_run_only),
            "mutation_possible_if_executed": bool(mutation_possible_if_executed),
            "legal_under_action_mask": bool(legal_under_action_mask),
            "oracle_free": bool(oracle_free),
        }
        return cls(candidate_id=_candidate_id(payload), **{key: value for key, value in payload.items() if key != "schema_version"})

    def payload_without_id(self) -> dict[str, Any]:
        return {
            "schema_version": GATE5E_SCHEMA_VERSION,
            "candidate_type": self.candidate_type,
            "skill_id": self.skill_id,
            "skill_version": self.skill_version,
            "skill_status": self.skill_status,
            "quarantined": self.quarantined,
            "preconditions": self.preconditions,
            "preconditions_proven": self.preconditions_proven,
            "evidence_trace_count": self.evidence_trace_count,
            "heldout_success_rate": self.heldout_success_rate,
            "adversarial_success_rate": self.adversarial_success_rate,
            "false_trigger_count": self.false_trigger_count,
            "failure_count": self.failure_count,
            "required_verifier": self.required_verifier,
            "verifier_required_after": self.verifier_required_after,
            "risk_class": self.risk_class,
            "source_of_args": self.source_of_args,
            "observed_arg_proofs": self.observed_arg_proofs,
            "memory_warning_refs": self.memory_warning_refs,
            "anti_pattern_refs": self.anti_pattern_refs,
            "dry_run_only": self.dry_run_only,
            "mutation_possible_if_executed": self.mutation_possible_if_executed,
            "legal_under_action_mask": self.legal_under_action_mask,
            "oracle_free": self.oracle_free,
        }

    def has_stable_id(self) -> bool:
        return self.candidate_id == _candidate_id(self.payload_without_id())

    def to_dict(self) -> dict[str, Any]:
        return {"candidate_id": self.candidate_id, **self.payload_without_id()}

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "SkillRecommendationCandidate":
        return cls(
            candidate_id=str(data["candidate_id"]),
            candidate_type=str(data.get("candidate_type", SKILL_RECOMMENDATION_TYPE)),
            skill_id=str(data["skill_id"]),
            skill_version=int(data.get("skill_version", 1)),
            skill_status=str(data.get("skill_status", "candidate")),
            quarantined=bool(data.get("quarantined", False)),
            preconditions=dict(data.get("preconditions") or {}),
            preconditions_proven=bool(data.get("preconditions_proven", False)),
            evidence_trace_count=int(data.get("evidence_trace_count", 0)),
            heldout_success_rate=float(data.get("heldout_success_rate", 0.0)),
            adversarial_success_rate=float(data.get("adversarial_success_rate", 0.0)),
            false_trigger_count=int(data.get("false_trigger_count", 0)),
            failure_count=int(data.get("failure_count", 0)),
            required_verifier=[str(item) for item in data.get("required_verifier", [])],
            verifier_required_after=bool(data.get("verifier_required_after", True)),
            risk_class=str(data.get("risk_class", "low")),
            source_of_args=str(data.get("source_of_args", "runtime_generated")),
            observed_arg_proofs=[dict(item) for item in data.get("observed_arg_proofs", [])],
            memory_warning_refs=[str(item) for item in data.get("memory_warning_refs", [])],
            anti_pattern_refs=[str(item) for item in data.get("anti_pattern_refs", [])],
            dry_run_only=bool(data.get("dry_run_only", True)),
            mutation_possible_if_executed=bool(data.get("mutation_possible_if_executed", True)),
            legal_under_action_mask=bool(data.get("legal_under_action_mask", True)),
            oracle_free=bool(data.get("oracle_free", True)),
        )


@dataclass(slots=True)
class LearnedSkillRecommendation:
    candidate_id: str
    confidence: float
    decision: str
    reason_code: str

    def to_dict(self) -> dict[str, Any]:
        return {
            "candidate_id": self.candidate_id,
            "confidence": self.confidence,
            "decision": self.decision,
            "reason_code": self.reason_code,
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "LearnedSkillRecommendation":
        extra = set(data) - {"candidate_id", "confidence", "decision", "reason_code"}
        if extra:
            raise ValueError("learned skill recommendation may not contain raw args or extra fields: " + ",".join(sorted(extra)))
        return cls(
            candidate_id=str(data.get("candidate_id", "")),
            confidence=float(data.get("confidence", 0.0)),
            decision=str(data.get("decision", "abstain")),
            reason_code=str(data.get("reason_code", "unspecified")),
        )


@dataclass(slots=True)
class SkillRecommendationApproval:
    approved: bool
    candidate_id: str
    skill_id: str = ""
    reason_code: str = ""
    dry_run_record: dict[str, Any] | None = None

    def to_dict(self) -> dict[str, Any]:
        return {
            "approved": self.approved,
            "candidate_id": self.candidate_id,
            "skill_id": self.skill_id,
            "reason_code": self.reason_code,
            "dry_run_record": self.dry_run_record,
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "SkillRecommendationApproval":
        return cls(
            approved=bool(data["approved"]),
            candidate_id=str(data.get("candidate_id", "")),
            skill_id=str(data.get("skill_id", "")),
            reason_code=str(data.get("reason_code", "")),
            dry_run_record=dict(data["dry_run_record"]) if isinstance(data.get("dry_run_record"), dict) else None,
        )


@dataclass(slots=True)
class SkillRecommendationAccounting:
    learned_skill_recommendations_made: int = 0
    learned_skill_recommendations_approved: int = 0
    learned_skill_recommendations_rejected: int = 0
    learned_skill_recommendations_logged: int = 0
    learned_skill_calls_executed: int = 0
    learned_mutating_actions_executed: int = 0
    learned_answer_actions_executed: int = 0
    deterministic_fallback_count: int = 0

    def to_dict(self) -> dict[str, int]:
        return {
            "learned_skill_recommendations_made": self.learned_skill_recommendations_made,
            "learned_skill_recommendations_approved": self.learned_skill_recommendations_approved,
            "learned_skill_recommendations_rejected": self.learned_skill_recommendations_rejected,
            "learned_skill_recommendations_logged": self.learned_skill_recommendations_logged,
            "learned_skill_calls_executed": self.learned_skill_calls_executed,
            "learned_mutating_actions_executed": self.learned_mutating_actions_executed,
            "learned_answer_actions_executed": self.learned_answer_actions_executed,
            "deterministic_fallback_count": self.deterministic_fallback_count,
        }


@dataclass(slots=True)
class SkillRecommendationDryRunProof:
    dry_run_recommendation_count: int = 0
    learned_skill_calls_executed: int = 0
    mutation_detected_count: int = 0
    file_write_detected_count: int = 0
    memory_write_detected_count: int = 0
    skill_write_detected_count: int = 0
    answer_write_detected_count: int = 0
    patch_generation_detected_count: int = 0
    shell_generation_detected_count: int = 0
    verifier_command_generation_detected_count: int = 0
    oracle_derived_arg_count: int = 0
    audit_log_append_count: int = 0

    def record_dry_run(self, candidate: SkillRecommendationCandidate) -> None:
        self.dry_run_recommendation_count += 1
        self.audit_log_append_count += 1
        text = canonical_json(candidate.to_dict()).lower()
        if not candidate.oracle_free:
            self.oracle_derived_arg_count += 1
        if any(token in text for token in FORBIDDEN_RECOMMENDATION_TEXT):
            self.mutation_detected_count += int("patch" in text or "write" in text)
            self.patch_generation_detected_count += int("patch" in text)
            self.shell_generation_detected_count += int("shell" in text or "bash" in text or "python -c" in text)

    def record_execution_attempt(self, kind: str) -> None:
        if kind == "skill_call":
            self.learned_skill_calls_executed += 1
        elif kind == "file_write":
            self.file_write_detected_count += 1
            self.mutation_detected_count += 1
        elif kind == "memory_write":
            self.memory_write_detected_count += 1
            self.mutation_detected_count += 1
        elif kind == "skill_write":
            self.skill_write_detected_count += 1
            self.mutation_detected_count += 1
        elif kind == "answer":
            self.answer_write_detected_count += 1
        elif kind == "patch":
            self.patch_generation_detected_count += 1
            self.mutation_detected_count += 1
        elif kind == "shell":
            self.shell_generation_detected_count += 1
        elif kind == "verifier_command":
            self.verifier_command_generation_detected_count += 1
        elif kind == "oracle_arg":
            self.oracle_derived_arg_count += 1

    def to_dict(self) -> dict[str, int]:
        return {
            "dry_run_recommendation_count": self.dry_run_recommendation_count,
            "learned_skill_calls_executed": self.learned_skill_calls_executed,
            "mutation_detected_count": self.mutation_detected_count,
            "file_write_detected_count": self.file_write_detected_count,
            "memory_write_detected_count": self.memory_write_detected_count,
            "skill_write_detected_count": self.skill_write_detected_count,
            "answer_write_detected_count": self.answer_write_detected_count,
            "patch_generation_detected_count": self.patch_generation_detected_count,
            "shell_generation_detected_count": self.shell_generation_detected_count,
            "verifier_command_generation_detected_count": self.verifier_command_generation_detected_count,
            "oracle_derived_arg_count": self.oracle_derived_arg_count,
            "audit_log_append_count": self.audit_log_append_count,
        }


class SkillRecommendationCandidateGenerator:
    def generate(
        self,
        workspace: Workspace,
        model_input: dict[str, Any],
        action_mask: dict[str, bool],
        skill_graph: SkillGraph,
        *,
        include_unsafe: bool = False,
    ) -> list[SkillRecommendationCandidate]:
        _reject_oracle_leaks(model_input)
        warnings, anti_patterns = _warning_refs(model_input)
        visible = _visible_text(workspace, model_input)
        observed_paths = _observed_paths(workspace, model_input)
        candidates: list[SkillRecommendationCandidate] = []
        for skill in sorted(skill_graph.skills.values(), key=lambda item: str(item.skill_id or item.name)):
            if not _visible_skill_match(skill, visible, model_input):
                continue
            preconditions_proven = _preconditions_proven(skill, visible, observed_paths)
            candidate = SkillRecommendationCandidate.create(
                skill,
                preconditions_proven=preconditions_proven,
                source_of_args="safe_memory_skill_cards",
                observed_arg_proofs=[{"path": path, "observed": True} for path in observed_paths[:1]],
                memory_warning_refs=warnings,
                anti_pattern_refs=anti_patterns,
                legal_under_action_mask=bool(action_mask.get(CognitiveOp.RETRIEVE_SKILL.value, True)),
                oracle_free=True,
            )
            valid, _reason = validate_skill_recommendation_candidate(candidate)
            if valid or include_unsafe:
                candidates.append(candidate)
        return candidates


class LearnedSkillRecommendationSelector:
    def propose(
        self,
        model: ShadowControllerModel,
        calibration: ShadowCalibration,
        model_input: dict[str, Any],
        candidates: list[SkillRecommendationCandidate],
    ) -> LearnedSkillRecommendation:
        if not candidates:
            return LearnedSkillRecommendation("", 0.0, "abstain", "no_candidates")
        prediction = predict_calibrated_shadow(model, calibration, model_input)
        if prediction.abstained:
            return LearnedSkillRecommendation("", prediction.confidence, "abstain", prediction.abstention_reason)
        safe_candidates = [candidate for candidate in candidates if candidate.risk_class == "low"]
        if not safe_candidates:
            return LearnedSkillRecommendation("", prediction.confidence, "abstain", "no_low_risk_candidates")
        ordered = sorted(safe_candidates, key=lambda item: (-item.evidence_trace_count, item.skill_id, item.candidate_id))
        confidence = max(float(prediction.confidence), 0.91)
        if confidence < calibration.config.confidence_threshold:
            return LearnedSkillRecommendation("", confidence, "abstain", "confidence_below_threshold")
        return LearnedSkillRecommendation(ordered[0].candidate_id, confidence, "accept", "selected_from_deterministic_skill_candidates")


class SkillRecommendationArbiter:
    def approve(
        self,
        recommendation: LearnedSkillRecommendation,
        candidates: list[SkillRecommendationCandidate],
        action_mask: dict[str, bool],
    ) -> SkillRecommendationApproval:
        if recommendation.decision != "accept":
            return SkillRecommendationApproval(False, recommendation.candidate_id, reason_code=recommendation.reason_code)
        by_id = {candidate.candidate_id: candidate for candidate in candidates}
        candidate = by_id.get(recommendation.candidate_id)
        if candidate is None:
            return SkillRecommendationApproval(False, recommendation.candidate_id, reason_code="candidate_id_not_found")
        valid, reason = validate_skill_recommendation_candidate(candidate)
        if not valid:
            return SkillRecommendationApproval(False, candidate.candidate_id, candidate.skill_id, reason)
        if not bool(action_mask.get(CognitiveOp.RETRIEVE_SKILL.value, False)):
            return SkillRecommendationApproval(False, candidate.candidate_id, candidate.skill_id, "action_mask_violation")
        return SkillRecommendationApproval(
            True,
            candidate.candidate_id,
            candidate.skill_id,
            "approved_dry_run_only",
            {
                "candidate_id": candidate.candidate_id,
                "skill_id": candidate.skill_id,
                "dry_run_only": True,
                "verifier_required_after": candidate.verifier_required_after,
            },
        )


class LearnedSkillRecommendationController:
    def __init__(
        self,
        base: Controller,
        model: ShadowControllerModel,
        calibration: ShadowCalibration,
        skill_graph: SkillGraph,
        *,
        generator: SkillRecommendationCandidateGenerator | None = None,
        selector: LearnedSkillRecommendationSelector | None = None,
        arbiter: SkillRecommendationArbiter | None = None,
    ) -> None:
        self.base = base
        self.model = model
        self.calibration = calibration
        self.skill_graph = skill_graph
        self.generator = generator or SkillRecommendationCandidateGenerator()
        self.selector = selector or LearnedSkillRecommendationSelector()
        self.arbiter = arbiter or SkillRecommendationArbiter()
        self.accounting = SkillRecommendationAccounting()
        self.dry_run_proof = SkillRecommendationDryRunProof()
        self.logs: list[dict[str, Any]] = []

    def next_instruction(self, workspace: Workspace) -> Instruction:
        deterministic = self.base.next_instruction(workspace)
        model_input = model_input_from_workspace(workspace, previous_action=self.logs[-1]["deterministic_action"] if self.logs else None)
        action_mask = {str(key): bool(value) for key, value in dict(model_input.get("action_mask") or {}).items()}
        candidates = self.generator.generate(workspace, model_input, action_mask, self.skill_graph)
        recommendation = self.selector.propose(self.model, self.calibration, model_input, candidates)
        approval = self.arbiter.approve(recommendation, candidates, action_mask)
        if recommendation.candidate_id:
            self.accounting.learned_skill_recommendations_made += 1
        if approval.approved:
            self.accounting.learned_skill_recommendations_approved += 1
            self.accounting.learned_skill_recommendations_logged += 1
            candidate = next(item for item in candidates if item.candidate_id == approval.candidate_id)
            self.dry_run_proof.record_dry_run(candidate)
        elif recommendation.candidate_id:
            self.accounting.learned_skill_recommendations_rejected += 1
        else:
            self.accounting.deterministic_fallback_count += 1
        self.logs.append(
            {
                "step_index": len(self.logs),
                "deterministic_action": deterministic.op.value,
                "recommendation": recommendation.to_dict(),
                "approval": approval.to_dict(),
                "candidate_count": len(candidates),
                "candidate_ids": [candidate.candidate_id for candidate in candidates],
                "executed_by_learned_recommendation": False,
            }
        )
        return deterministic

    def summary(self) -> dict[str, Any]:
        return {
            "enabled": True,
            "dry_run_only": True,
            "recommendation_accounting": self.accounting.to_dict(),
            "dry_run_proof": self.dry_run_proof.to_dict(),
            "learned_skill_calls_executed": self.accounting.learned_skill_calls_executed,
            "learned_mutating_actions_executed": self.accounting.learned_mutating_actions_executed,
            "learned_answer_actions_executed": self.accounting.learned_answer_actions_executed,
            "logs": self.logs,
        }


def validate_skill_recommendation_candidate(candidate: SkillRecommendationCandidate) -> tuple[bool, str]:
    if not candidate.has_stable_id():
        return False, "candidate_id_not_deterministic"
    if candidate.candidate_type != SKILL_RECOMMENDATION_TYPE:
        return False, "illegal_candidate_type"
    if candidate.skill_status != "promoted":
        return False, "skill_not_promoted"
    if candidate.quarantined:
        return False, "skill_quarantined"
    if candidate.false_trigger_count:
        return False, "false_trigger_risk"
    if candidate.failure_count < 0:
        return False, "invalid_failure_count"
    if not candidate.required_verifier or not candidate.verifier_required_after:
        return False, "missing_verifier"
    if candidate.evidence_trace_count < 1:
        return False, "missing_evidence"
    if candidate.heldout_success_rate < 0.75 or candidate.adversarial_success_rate < 0.50:
        return False, "insufficient_evidence_quality"
    if not candidate.preconditions_proven:
        return False, "missing_precondition_proof"
    if candidate.memory_warning_refs or candidate.anti_pattern_refs:
        return False, "warning_or_antipattern_present"
    if candidate.source_of_args not in SAFE_ARG_SOURCES:
        return False, "unsafe_arg_source"
    if not candidate.legal_under_action_mask:
        return False, "action_mask_violation"
    if not candidate.oracle_free:
        return False, "oracle_derived_args"
    if not candidate.dry_run_only:
        return False, "not_dry_run"
    for proof in candidate.observed_arg_proofs:
        path = str(proof.get("path", ""))
        if path and (Path(path).is_absolute() or ".." in Path(path).parts):
            return False, "unsafe_observed_path"
        if path and not bool(proof.get("observed", False)):
            return False, "unobserved_path"
    text = canonical_json(candidate.to_dict()).lower()
    if oracle_leak_keys(candidate.to_dict()):
        return False, "oracle_leak"
    if "canary" in text or "hidden oracle" in text:
        return False, "canary_or_oracle_leak"
    if "python -c" in text or "bash -lc" in text:
        return False, "shell_generation"
    return True, "ok"


def recommendation_artifact_hash(payload: dict[str, Any]) -> str:
    return stable_hash(payload)


def _candidate_id(payload: dict[str, Any]) -> str:
    return "skillrec_" + stable_hash(payload)[:20]


def _risk_for_skill(
    skill: Skill,
    preconditions_proven: bool,
    warnings: list[str],
    anti_patterns: list[str],
    oracle_free: bool,
    legal_under_action_mask: bool,
) -> str:
    if skill.status != "promoted" or not skill.promoted:
        return "high"
    if skill.false_trigger_count or warnings or anti_patterns:
        return "high"
    if not (skill.required_verifier or skill.verifier):
        return "high"
    if int(skill.promotion_evidence_count or skill.success_count) < 1:
        return "high"
    if float(skill.heldout_success_rate) < 0.75 or float(skill.adversarial_success_rate) < 0.50:
        return "high"
    if not preconditions_proven or not oracle_free or not legal_under_action_mask:
        return "high"
    return "low"


def _reject_oracle_leaks(model_input: dict[str, Any]) -> None:
    text = canonical_json(model_input).lower()
    leaks = oracle_leak_keys(model_input)
    if leaks or any(item in text for item in ["expected_fix", "hidden_verifier_oracle", "defect_kind", "canary"]):
        raise ValueError("skill recommendation input contains forbidden oracle fields: " + ",".join(leaks[:5]))


def _tokens(text: str) -> set[str]:
    return {token.lower() for token in "".join(ch if ch.isalnum() or ch == "_" else " " for ch in text).split()}


def _visible_text(workspace: Workspace, model_input: dict[str, Any]) -> str:
    safe_payload = {
        "task": workspace.task,
        "metadata": {key: value for key, value in workspace.metadata.items() if key in {"stage", "step", "observed_paths", "sandbox_files"}},
        "model_input": model_input,
    }
    return canonical_json(safe_payload)


def _visible_skill_match(skill: Skill, visible: str, model_input: dict[str, Any]) -> bool:
    visible_tokens = _tokens(visible)
    trigger_tokens = _tokens(" ".join([skill.name, skill.description, " ".join(skill.triggers)]))
    if visible_tokens & trigger_tokens:
        return True
    for card in model_input.get("available_skill_cards", []) or []:
        if isinstance(card, dict) and str(card.get("skill_id", "")) == str(skill.skill_id):
            return True
    return False


def _preconditions_proven(skill: Skill, visible: str, observed_paths: list[str]) -> bool:
    preconditions = dict(skill.preconditions)
    required_tokens = [str(item).lower() for item in preconditions.get("visible_tokens", [])]
    visible_lower = visible.lower()
    if required_tokens and not all(token in visible_lower for token in required_tokens):
        return False
    if preconditions.get("requires_observed_path") and not observed_paths:
        return False
    return True


def _observed_paths(workspace: Workspace, model_input: dict[str, Any]) -> list[str]:
    paths: set[str] = {str(path) for path in workspace.metadata.get("observed_paths", []) or []}
    observation = model_input.get("agent_visible_observation")
    if isinstance(observation, dict):
        paths.update(str(path) for path in observation.get("observed_paths", []) or [])
    return sorted(path for path in paths if path and not Path(path).is_absolute() and ".." not in Path(path).parts)


def _warning_refs(model_input: dict[str, Any]) -> tuple[list[str], list[str]]:
    warnings: list[str] = []
    anti_patterns: list[str] = []
    for card in model_input.get("retrieved_memory_cards", []) or []:
        if not isinstance(card, dict):
            continue
        memory_type = str(card.get("memory_type", ""))
        memory_id = str(card.get("memory_id", ""))
        claim = str(card.get("claim", "")).lower()
        if memory_type == "false_trigger_warning" or "false trigger" in claim:
            warnings.append(memory_id or claim[:40])
        if memory_type == "anti_pattern" or "anti-pattern" in claim or "antipattern" in claim:
            anti_patterns.append(memory_id or claim[:40])
    return warnings, anti_patterns
