Implements a diagnostic-only SearchGateDecision adapter over ContractResidual records.
Preserves the authority boundary:
ContractAssessment -> ContractResidual -> SearchGateDecision.
SearchGateDecision is not proof, repair, budget, search, serving, candidate generation, or mutation authority.
Validation reported on head 1d68292c7f:
- tests/test_search_gate.py: 23 passed
- tests/test_contract_residuals.py: 75 passed
- adjacent contract/proposal tests: 48 passed
- ruff: passed
- compileall: passed
- smoke: 108 passed
- git diff --check: clean
308 lines
8.7 KiB
Python
308 lines
8.7 KiB
Python
"""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",
|
|
]
|