"""Diagnostic-only SearchGateDecision adapter over ContractResidual records.""" from __future__ import annotations import hashlib import json from dataclasses import dataclass from enum import Enum, unique from generate.contract_residuals import ContractResidual, ResidualKind from generate.kernel_facts import SourceSpan SEARCH_GATE_POLICY_VERSION = "search_gate.v1" @unique class SearchGateStatus(str, Enum): ELIGIBLE = "eligible" INELIGIBLE = "ineligible" BLOCKED = "blocked" UNASSESSABLE = "unassessable" @dataclass(frozen=True, slots=True) class SearchGateDecision: decision_id: str policy_version: str input_digest: str residual_ids: tuple[str, ...] candidate_organ: str | None status: SearchGateStatus reason_code: str evidence_spans: tuple[SourceSpan, ...] explanation: str _KIND_MAP: dict[ResidualKind, tuple[SearchGateStatus, str]] = { ResidualKind.MISSING_PROPOSAL: ( SearchGateStatus.ELIGIBLE, "eligible_missing_proposal", ), ResidualKind.MISSING_RELATION: ( SearchGateStatus.ELIGIBLE, "eligible_missing_relation", ), ResidualKind.MISSING_ROLE: ( SearchGateStatus.ELIGIBLE, "eligible_missing_role", ), ResidualKind.TARGET_UNBOUND: ( SearchGateStatus.ELIGIBLE, "eligible_target_unbound", ), ResidualKind.AMBIGUOUS_RELATION: ( SearchGateStatus.BLOCKED, "blocked_ambiguous_relation", ), ResidualKind.AMBIGUOUS_ROLE: ( SearchGateStatus.BLOCKED, "blocked_ambiguous_role", ), ResidualKind.INEXACT_PROVENANCE: ( SearchGateStatus.BLOCKED, "blocked_inexact_provenance", ), ResidualKind.NONLOCAL_BINDING: ( SearchGateStatus.BLOCKED, "blocked_nonlocal_binding", ), ResidualKind.UNSUPPORTED_TOPOLOGY: ( SearchGateStatus.BLOCKED, "blocked_unsupported_topology", ), ResidualKind.UNIT_OBJECT_CONFLICT: ( SearchGateStatus.BLOCKED, "blocked_unit_object_conflict", ), ResidualKind.HAZARD_BLOCKED: ( SearchGateStatus.BLOCKED, "blocked_hazard", ), ResidualKind.CONTRACT_GAP_UNCLASSIFIED: ( SearchGateStatus.BLOCKED, "blocked_unclassified_gap", ), } _BLOCKED_PRIORITY: dict[str, int] = { "blocked_hazard": 1, "blocked_unit_object_conflict": 2, "blocked_unsupported_topology": 3, "blocked_nonlocal_binding": 4, "blocked_inexact_provenance": 5, "blocked_ambiguous_relation": 6, "blocked_ambiguous_role": 7, "blocked_unclassified_gap": 8, } _ELIGIBLE_PRIORITY: dict[str, int] = { "eligible_missing_proposal": 1, "eligible_missing_relation": 2, "eligible_missing_role": 3, "eligible_target_unbound": 4, } def _enum_value(value: object) -> object: return getattr(value, "value", value) def _map_residual(kind: object) -> tuple[SearchGateStatus, str]: return _KIND_MAP.get( kind, (SearchGateStatus.BLOCKED, "blocked_unclassified_gap") ) def _span_payload(span: SourceSpan) -> dict[str, object]: return { "text": span.text, "start": span.start, "end": span.end, "sentence_index": span.sentence_index, } def _ordered_residuals( residuals: tuple[ContractResidual, ...], ) -> tuple[ContractResidual, ...]: return tuple(sorted(residuals, key=lambda residual: residual.residual_id)) def _collect_evidence_spans( residuals: tuple[ContractResidual, ...], ) -> tuple[SourceSpan, ...]: return tuple( span for residual in residuals for span in residual.evidence_spans ) def _residual_payload(residual: ContractResidual) -> dict[str, object]: return { "residual_id": residual.residual_id, "candidate_organ": residual.candidate_organ, "family_id": residual.family_id, "residual_kind": _enum_value(residual.residual_kind), "residual_code": residual.residual_code, "source_axis": _enum_value(residual.source_axis), "evidence_spans": [ _span_payload(span) for span in residual.evidence_spans ], } def _sha256_json(payload: dict[str, object]) -> str: encoded = json.dumps( payload, ensure_ascii=False, separators=(",", ":"), sort_keys=True, ).encode("utf-8") return hashlib.sha256(encoded).hexdigest() def _input_digest(residuals: tuple[ContractResidual, ...]) -> str: return _sha256_json( {"residuals": [_residual_payload(residual) for residual in residuals]} ) def _decision_id( *, policy_version: str, input_digest: str, residual_ids: tuple[str, ...], candidate_organ: str | None, status: SearchGateStatus, reason_code: str, evidence_spans: tuple[SourceSpan, ...], ) -> str: return _sha256_json( { "policy_version": policy_version, "input_digest": input_digest, "residual_ids": list(residual_ids), "candidate_organ": candidate_organ, "status": status.value, "reason_code": reason_code, "evidence_spans": [_span_payload(span) for span in evidence_spans], } ) def _make_decision( *, ordered_residuals: tuple[ContractResidual, ...], candidate_organ: str | None, status: SearchGateStatus, reason_code: str, explanation: str, ) -> SearchGateDecision: residual_ids = tuple(residual.residual_id for residual in ordered_residuals) evidence_spans = _collect_evidence_spans(ordered_residuals) input_digest = _input_digest(ordered_residuals) decision_id = _decision_id( policy_version=SEARCH_GATE_POLICY_VERSION, input_digest=input_digest, residual_ids=residual_ids, candidate_organ=candidate_organ, status=status, reason_code=reason_code, evidence_spans=evidence_spans, ) return SearchGateDecision( decision_id=decision_id, policy_version=SEARCH_GATE_POLICY_VERSION, input_digest=input_digest, residual_ids=residual_ids, candidate_organ=candidate_organ, status=status, reason_code=reason_code, evidence_spans=evidence_spans, explanation=explanation, ) def decide_search_gate( residuals: tuple[ContractResidual, ...], ) -> tuple[SearchGateDecision, ...]: if not residuals: return ( _make_decision( ordered_residuals=(), candidate_organ=None, status=SearchGateStatus.UNASSESSABLE, reason_code="unassessable_empty_context", explanation="Empty residual context.", ), ) ordered_residuals = _ordered_residuals(residuals) organs = {residual.candidate_organ for residual in ordered_residuals} if len(organs) > 1: return ( _make_decision( ordered_residuals=ordered_residuals, candidate_organ=None, status=SearchGateStatus.UNASSESSABLE, reason_code="unassessable_mixed_candidate_organs", explanation="Mixed candidate organs in residual context.", ), ) candidate_organ = ordered_residuals[0].candidate_organ mapped = [ (_map_residual(residual.residual_kind), residual) for residual in ordered_residuals ] blocked_items = [ item for item in mapped if item[0][0] != SearchGateStatus.ELIGIBLE ] if blocked_items: if any( status == SearchGateStatus.BLOCKED for (status, _), _ in blocked_items ): overall_status = SearchGateStatus.BLOCKED else: overall_status = SearchGateStatus.INELIGIBLE def blocked_priority_key( item: tuple[tuple[SearchGateStatus, str], ContractResidual], ) -> int: return _BLOCKED_PRIORITY.get(item[0][1], 99) best_blocked = min(blocked_items, key=blocked_priority_key) reason_code = best_blocked[0][1] explanation = f"Search gate blocked: {best_blocked[1].explanation}" else: overall_status = SearchGateStatus.ELIGIBLE def eligible_priority_key( item: tuple[tuple[SearchGateStatus, str], ContractResidual], ) -> int: return _ELIGIBLE_PRIORITY.get(item[0][1], 99) best_eligible = min(mapped, key=eligible_priority_key) reason_code = best_eligible[0][1] explanation = f"Search gate eligible: {best_eligible[1].explanation}" return ( _make_decision( ordered_residuals=ordered_residuals, candidate_organ=candidate_organ, status=overall_status, reason_code=reason_code, explanation=explanation, ), ) __all__ = [ "SearchGateStatus", "SearchGateDecision", "decide_search_gate", ]