diff --git a/generate/run_attempt_binding.py b/generate/run_attempt_binding.py new file mode 100644 index 00000000..a4a5669a --- /dev/null +++ b/generate/run_attempt_binding.py @@ -0,0 +1,620 @@ +"""Inert CandidateAttempt run-binding shell per ADR-0232.""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass +from enum import Enum, unique + +from generate.candidate_operator import CandidateOperatorResult +from generate.geometric_search_run import ( + BudgetCharge, + CandidateAttempt, + CandidateReplayStatus, + GeometricSearchRun, +) +from generate.kernel_facts import SourceSpan + +CANDIDATE_ATTEMPT_RUN_BINDING_POLICY_VERSION = "candidate_attempt_run_binding.v1" + + +@unique +class RunAttemptBindingRefusalReason(str, Enum): + INVALID_BINDING_INPUT = "invalid_binding_input" + UNSUPPORTED_BINDING_POLICY = "unsupported_binding_policy" + RUN_RESULT_MISMATCH = "run_result_mismatch" + ATTEMPT_RESULT_MISMATCH = "attempt_result_mismatch" + RECONSTRUCTION_RESULT_MISMATCH = "reconstruction_result_mismatch" + REPLAY_STATUS_NOT_PENDING = "replay_status_not_pending" + REPLAY_BLOCKERS_PRESENT = "replay_blockers_present" + DUPLICATE_ATTEMPT_INDEX = "duplicate_attempt_index" + DUPLICATE_ATTEMPT_ID = "duplicate_attempt_id" + DUPLICATE_CANDIDATE_DIGEST = "duplicate_candidate_digest" + BUDGET_CHARGE_EXCEEDS_REMAINING = "budget_charge_exceeds_remaining" + OPERATOR_SET_MISMATCH = "operator_set_mismatch" + RUN_IDENTITY_MISMATCH = "run_identity_mismatch" + MALFORMED_EVIDENCE_SPAN = "malformed_evidence_span" + UNSUPPORTED_SCHEMA_VERSION = "unsupported_schema_version" + + +@dataclass(frozen=True, slots=True) +class CandidateAttemptRunBindingInput: + input_digest: str + binding_policy_version: str + original_run_id: str + original_run_policy_version: str + original_run_input_digest: str + operator_result_id: str + operator_policy_version: str + candidate_attempt_id: str + attempt_index: int + candidate_digest: str + candidate_reconstruction_digest: str + operator_set_id: str + operator_set_version: str + budget_id: str + gate_decision_id: str + residual_ids: tuple[str, ...] + problem_frame_digest: str + original_contract_assessment_id: str + schema_versions: tuple[tuple[str, str], ...] + policy_versions: tuple[tuple[str, str], ...] + + +@dataclass(frozen=True, slots=True) +class CandidateAttemptRunBinding: + binding_id: str + binding_policy_version: str + input_digest: str + original_run_id: str + operator_result_id: str + candidate_attempt_id: str + attempt_index: int + candidate_digest: str + candidate_reconstruction_digest: str + candidate_attempt_ref: str + budget_charge: BudgetCharge + depth: int + step_index: int + run_attempt_membership: str + evidence_spans: tuple[SourceSpan, ...] + reason_codes: tuple[str, ...] + explanation: str + + +@dataclass(frozen=True, slots=True) +class CandidateAttemptRunBindingRefusal: + binding_refusal_id: str + binding_policy_version: str + input_digest: str | None + original_run_id: str | None + operator_result_id: str | None + candidate_attempt_id: str | None + reason_codes: tuple[str, ...] + explanation: str + + +CandidateAttemptRunBindingOutcome = ( + CandidateAttemptRunBinding | CandidateAttemptRunBindingRefusal +) + + +def _canonical_digest(payload: dict[str, object]) -> str: + encoded = json.dumps( + payload, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def _span_payload(span: SourceSpan) -> dict[str, object]: + return { + "text": span.text, + "start": span.start, + "end": span.end, + "sentence_index": span.sentence_index, + } + + +def _budget_charge_payload(charge: BudgetCharge) -> dict[str, int]: + return {"candidates": charge.candidates, "steps": charge.steps} + + +def _candidate_attempt_payload(attempt: CandidateAttempt) -> dict[str, object]: + return { + "attempt_id": attempt.attempt_id, + "attempt_index": attempt.attempt_index, + "parent_attempt_id": attempt.parent_attempt_id, + "operator_id": attempt.operator_id, + "operator_version": attempt.operator_version, + "input_digest": attempt.input_digest, + "candidate_digest": attempt.candidate_digest, + "budget_charge": _budget_charge_payload(attempt.budget_charge), + "depth": attempt.depth, + "step_index": attempt.step_index, + "replay_status": attempt.replay_status.value, + "replay_blockers": list(attempt.replay_blockers), + "evidence_spans": [_span_payload(span) for span in attempt.evidence_spans], + } + + +def _version_pairs_payload(versions: tuple[tuple[str, str], ...]) -> list[list[str]]: + return [[name, version] for name, version in versions] + + +def _valid_version_pairs(value: object) -> bool: + if not isinstance(value, tuple): + return False + names: list[str] = [] + for entry in value: + if not isinstance(entry, tuple) or len(entry) != 2: + return False + name, version = entry + if not isinstance(name, str) or not name: + return False + if not isinstance(version, str) or not version: + return False + names.append(name) + return len(names) == len(set(names)) and names == sorted(names) + + +def _valid_span(span: object) -> bool: + return ( + isinstance(span, SourceSpan) + and isinstance(span.text, str) + and type(span.start) is int + and type(span.end) is int + and span.start >= 0 + and span.end >= span.start + and (span.sentence_index is None or type(span.sentence_index) is int) + ) + + +def _valid_spans(spans: object) -> bool: + return isinstance(spans, tuple) and all(_valid_span(span) for span in spans) + + +def _binding_input_payload( + *, + original_run: GeometricSearchRun, + candidate_operator_result: CandidateOperatorResult, + schema_versions: tuple[tuple[str, str], ...], + policy_versions: tuple[tuple[str, str], ...], +) -> dict[str, object]: + attempt = candidate_operator_result.candidate_attempt + reconstruction = candidate_operator_result.candidate_reconstruction + return { + "binding_policy_version": CANDIDATE_ATTEMPT_RUN_BINDING_POLICY_VERSION, + "original_run_id": original_run.run_id, + "original_run_policy_version": original_run.run_policy_version, + "original_run_input_digest": original_run.input_digest, + "operator_result_id": candidate_operator_result.operator_result_id, + "operator_policy_version": candidate_operator_result.operator_policy_version, + "candidate_attempt_id": attempt.attempt_id, + "attempt_index": attempt.attempt_index, + "candidate_digest": attempt.candidate_digest, + "candidate_reconstruction_digest": reconstruction.candidate_reconstruction_digest, + "operator_set_id": original_run.operator_set_id, + "operator_set_version": original_run.operator_set_version, + "budget_id": original_run.budget_id, + "gate_decision_id": original_run.gate_decision_id, + "residual_ids": list(original_run.residual_ids), + "problem_frame_digest": original_run.problem_frame_digest, + "original_contract_assessment_id": original_run.contract_assessment_id, + "schema_versions": _version_pairs_payload(schema_versions), + "policy_versions": _version_pairs_payload(policy_versions), + } + + +def _refusal_id_payload( + *, + input_digest: str | None, + original_run_id: str | None, + operator_result_id: str | None, + candidate_attempt_id: str | None, + reason_codes: tuple[str, ...], +) -> dict[str, object]: + return { + "binding_refusal_id": "", + "binding_policy_version": CANDIDATE_ATTEMPT_RUN_BINDING_POLICY_VERSION, + "input_digest": input_digest, + "original_run_id": original_run_id, + "operator_result_id": operator_result_id, + "candidate_attempt_id": candidate_attempt_id, + "reason_codes": list(reason_codes), + } + + +def _refusal( + *, + reason: RunAttemptBindingRefusalReason, + input_digest: str | None = None, + original_run_id: str | None = None, + operator_result_id: str | None = None, + candidate_attempt_id: str | None = None, +) -> CandidateAttemptRunBindingRefusal: + reason_codes = (reason.value,) + binding_refusal_id = _canonical_digest( + _refusal_id_payload( + input_digest=input_digest, + original_run_id=original_run_id, + operator_result_id=operator_result_id, + candidate_attempt_id=candidate_attempt_id, + reason_codes=reason_codes, + ) + ) + return CandidateAttemptRunBindingRefusal( + binding_refusal_id=binding_refusal_id, + binding_policy_version=CANDIDATE_ATTEMPT_RUN_BINDING_POLICY_VERSION, + input_digest=input_digest, + original_run_id=original_run_id, + operator_result_id=operator_result_id, + candidate_attempt_id=candidate_attempt_id, + reason_codes=reason_codes, + explanation="CandidateAttempt run binding refused: " + reason.value + ".", + ) + + +def _binding_id_payload( + *, + input_digest: str, + original_run_id: str, + operator_result_id: str, + candidate_attempt_id: str, + attempt_index: int, + candidate_digest: str, + candidate_reconstruction_digest: str, + candidate_attempt_ref: str, + budget_charge: BudgetCharge, + depth: int, + step_index: int, + evidence_spans: tuple[SourceSpan, ...], +) -> dict[str, object]: + return { + "binding_id": "", + "binding_policy_version": CANDIDATE_ATTEMPT_RUN_BINDING_POLICY_VERSION, + "input_digest": input_digest, + "original_run_id": original_run_id, + "operator_result_id": operator_result_id, + "candidate_attempt_id": candidate_attempt_id, + "attempt_index": attempt_index, + "candidate_digest": candidate_digest, + "candidate_reconstruction_digest": candidate_reconstruction_digest, + "candidate_attempt_ref": candidate_attempt_ref, + "budget_charge": _budget_charge_payload(budget_charge), + "depth": depth, + "step_index": step_index, + "run_attempt_membership": "structurally_bound", + "evidence_spans": [_span_payload(span) for span in evidence_spans], + "reason_codes": [], + } + + +def _operator_provenance_has( + provenance: tuple[tuple[str, str], ...], key: str, value: str +) -> bool: + return (key, value) in provenance + + +def bind_candidate_attempt_to_run( + *, + original_run: GeometricSearchRun, + candidate_operator_result: CandidateOperatorResult, + schema_versions: tuple[tuple[str, str], ...] = (), + policy_versions: tuple[tuple[str, str], ...] = (), + explanation: str = "", +) -> CandidateAttemptRunBindingOutcome: + """Bind an existing candidate operator result to one immutable run episode.""" + + if not isinstance(original_run, GeometricSearchRun): + return _refusal(reason=RunAttemptBindingRefusalReason.INVALID_BINDING_INPUT) + if not isinstance(candidate_operator_result, CandidateOperatorResult): + return _refusal( + reason=RunAttemptBindingRefusalReason.INVALID_BINDING_INPUT, + original_run_id=original_run.run_id, + ) + + attempt = candidate_operator_result.candidate_attempt + reconstruction = candidate_operator_result.candidate_reconstruction + + if not isinstance(attempt, CandidateAttempt): + return _refusal( + reason=RunAttemptBindingRefusalReason.INVALID_BINDING_INPUT, + original_run_id=original_run.run_id, + operator_result_id=candidate_operator_result.operator_result_id, + ) + + if not _valid_version_pairs(schema_versions) or not _valid_version_pairs( + policy_versions + ): + return _refusal( + reason=RunAttemptBindingRefusalReason.UNSUPPORTED_SCHEMA_VERSION, + original_run_id=original_run.run_id, + operator_result_id=candidate_operator_result.operator_result_id, + candidate_attempt_id=attempt.attempt_id, + ) + + if candidate_operator_result.geometric_search_run_id != original_run.run_id: + return _refusal( + reason=RunAttemptBindingRefusalReason.RUN_RESULT_MISMATCH, + original_run_id=original_run.run_id, + operator_result_id=candidate_operator_result.operator_result_id, + candidate_attempt_id=attempt.attempt_id, + ) + + if candidate_operator_result.attempt_id != attempt.attempt_id: + return _refusal( + reason=RunAttemptBindingRefusalReason.ATTEMPT_RESULT_MISMATCH, + original_run_id=original_run.run_id, + operator_result_id=candidate_operator_result.operator_result_id, + candidate_attempt_id=attempt.attempt_id, + ) + if candidate_operator_result.attempt_index != attempt.attempt_index: + return _refusal( + reason=RunAttemptBindingRefusalReason.ATTEMPT_RESULT_MISMATCH, + original_run_id=original_run.run_id, + operator_result_id=candidate_operator_result.operator_result_id, + candidate_attempt_id=attempt.attempt_id, + ) + if candidate_operator_result.candidate_digest != attempt.candidate_digest: + return _refusal( + reason=RunAttemptBindingRefusalReason.ATTEMPT_RESULT_MISMATCH, + original_run_id=original_run.run_id, + operator_result_id=candidate_operator_result.operator_result_id, + candidate_attempt_id=attempt.attempt_id, + ) + if candidate_operator_result.input_digest != attempt.input_digest: + return _refusal( + reason=RunAttemptBindingRefusalReason.ATTEMPT_RESULT_MISMATCH, + original_run_id=original_run.run_id, + operator_result_id=candidate_operator_result.operator_result_id, + candidate_attempt_id=attempt.attempt_id, + ) + if candidate_operator_result.operator_name != attempt.operator_id: + return _refusal( + reason=RunAttemptBindingRefusalReason.ATTEMPT_RESULT_MISMATCH, + original_run_id=original_run.run_id, + operator_result_id=candidate_operator_result.operator_result_id, + candidate_attempt_id=attempt.attempt_id, + ) + if candidate_operator_result.operator_version != attempt.operator_version: + return _refusal( + reason=RunAttemptBindingRefusalReason.ATTEMPT_RESULT_MISMATCH, + original_run_id=original_run.run_id, + operator_result_id=candidate_operator_result.operator_result_id, + candidate_attempt_id=attempt.attempt_id, + ) + + if reconstruction.candidate_digest != candidate_operator_result.candidate_digest: + return _refusal( + reason=RunAttemptBindingRefusalReason.RECONSTRUCTION_RESULT_MISMATCH, + original_run_id=original_run.run_id, + operator_result_id=candidate_operator_result.operator_result_id, + candidate_attempt_id=attempt.attempt_id, + ) + if ( + reconstruction.candidate_reconstruction_digest + != candidate_operator_result.candidate_reconstruction_digest + ): + return _refusal( + reason=RunAttemptBindingRefusalReason.RECONSTRUCTION_RESULT_MISMATCH, + original_run_id=original_run.run_id, + operator_result_id=candidate_operator_result.operator_result_id, + candidate_attempt_id=attempt.attempt_id, + ) + + if attempt.replay_status is not CandidateReplayStatus.REPLAY_PENDING: + return _refusal( + reason=RunAttemptBindingRefusalReason.REPLAY_STATUS_NOT_PENDING, + original_run_id=original_run.run_id, + operator_result_id=candidate_operator_result.operator_result_id, + candidate_attempt_id=attempt.attempt_id, + ) + if attempt.replay_blockers != (): + return _refusal( + reason=RunAttemptBindingRefusalReason.REPLAY_BLOCKERS_PRESENT, + original_run_id=original_run.run_id, + operator_result_id=candidate_operator_result.operator_result_id, + candidate_attempt_id=attempt.attempt_id, + ) + + if reconstruction.source_residual_id not in original_run.residual_ids: + return _refusal( + reason=RunAttemptBindingRefusalReason.RUN_IDENTITY_MISMATCH, + original_run_id=original_run.run_id, + operator_result_id=candidate_operator_result.operator_result_id, + candidate_attempt_id=attempt.attempt_id, + ) + if reconstruction.problem_frame_digest != original_run.problem_frame_digest: + return _refusal( + reason=RunAttemptBindingRefusalReason.RUN_IDENTITY_MISMATCH, + original_run_id=original_run.run_id, + operator_result_id=candidate_operator_result.operator_result_id, + candidate_attempt_id=attempt.attempt_id, + ) + if ( + reconstruction.original_contract_assessment_id + != original_run.contract_assessment_id + ): + return _refusal( + reason=RunAttemptBindingRefusalReason.RUN_IDENTITY_MISMATCH, + original_run_id=original_run.run_id, + operator_result_id=candidate_operator_result.operator_result_id, + candidate_attempt_id=attempt.attempt_id, + ) + + if not _operator_provenance_has( + reconstruction.operator_provenance, + "operator_set_id", + original_run.operator_set_id, + ) or not _operator_provenance_has( + reconstruction.operator_provenance, + "operator_set_version", + original_run.operator_set_version, + ): + return _refusal( + reason=RunAttemptBindingRefusalReason.OPERATOR_SET_MISMATCH, + original_run_id=original_run.run_id, + operator_result_id=candidate_operator_result.operator_result_id, + candidate_attempt_id=attempt.attempt_id, + ) + + if not _valid_spans(attempt.evidence_spans): + return _refusal( + reason=RunAttemptBindingRefusalReason.MALFORMED_EVIDENCE_SPAN, + original_run_id=original_run.run_id, + operator_result_id=candidate_operator_result.operator_result_id, + candidate_attempt_id=attempt.attempt_id, + ) + if candidate_operator_result.evidence_spans != attempt.evidence_spans: + return _refusal( + reason=RunAttemptBindingRefusalReason.MALFORMED_EVIDENCE_SPAN, + original_run_id=original_run.run_id, + operator_result_id=candidate_operator_result.operator_result_id, + candidate_attempt_id=attempt.attempt_id, + ) + if reconstruction.evidence_spans != attempt.evidence_spans: + return _refusal( + reason=RunAttemptBindingRefusalReason.MALFORMED_EVIDENCE_SPAN, + original_run_id=original_run.run_id, + operator_result_id=candidate_operator_result.operator_result_id, + candidate_attempt_id=attempt.attempt_id, + ) + + for existing in original_run.candidate_attempts: + if existing.attempt_index == attempt.attempt_index: + return _refusal( + reason=RunAttemptBindingRefusalReason.DUPLICATE_ATTEMPT_INDEX, + original_run_id=original_run.run_id, + operator_result_id=candidate_operator_result.operator_result_id, + candidate_attempt_id=attempt.attempt_id, + ) + if existing.attempt_id == attempt.attempt_id: + return _refusal( + reason=RunAttemptBindingRefusalReason.DUPLICATE_ATTEMPT_ID, + original_run_id=original_run.run_id, + operator_result_id=candidate_operator_result.operator_result_id, + candidate_attempt_id=attempt.attempt_id, + ) + if existing.candidate_digest == attempt.candidate_digest: + return _refusal( + reason=RunAttemptBindingRefusalReason.DUPLICATE_CANDIDATE_DIGEST, + original_run_id=original_run.run_id, + operator_result_id=candidate_operator_result.operator_result_id, + candidate_attempt_id=attempt.attempt_id, + ) + + consumed = original_run.budget_consumed + charge = attempt.budget_charge + if charge.candidates < 0 or charge.steps < 0 or attempt.depth < 0: + return _refusal( + reason=RunAttemptBindingRefusalReason.BUDGET_CHARGE_EXCEEDS_REMAINING, + original_run_id=original_run.run_id, + operator_result_id=candidate_operator_result.operator_result_id, + candidate_attempt_id=attempt.attempt_id, + ) + if attempt.step_index < 0: + return _refusal( + reason=RunAttemptBindingRefusalReason.BUDGET_CHARGE_EXCEEDS_REMAINING, + original_run_id=original_run.run_id, + operator_result_id=candidate_operator_result.operator_result_id, + candidate_attempt_id=attempt.attempt_id, + ) + if consumed.candidates_considered + charge.candidates > consumed.max_candidates: + return _refusal( + reason=RunAttemptBindingRefusalReason.BUDGET_CHARGE_EXCEEDS_REMAINING, + original_run_id=original_run.run_id, + operator_result_id=candidate_operator_result.operator_result_id, + candidate_attempt_id=attempt.attempt_id, + ) + if consumed.steps_used + charge.steps > consumed.max_steps: + return _refusal( + reason=RunAttemptBindingRefusalReason.BUDGET_CHARGE_EXCEEDS_REMAINING, + original_run_id=original_run.run_id, + operator_result_id=candidate_operator_result.operator_result_id, + candidate_attempt_id=attempt.attempt_id, + ) + if max(consumed.depth_reached, attempt.depth) > consumed.max_depth: + return _refusal( + reason=RunAttemptBindingRefusalReason.BUDGET_CHARGE_EXCEEDS_REMAINING, + original_run_id=original_run.run_id, + operator_result_id=candidate_operator_result.operator_result_id, + candidate_attempt_id=attempt.attempt_id, + ) + if consumed.parallelism_used > consumed.max_parallelism: + return _refusal( + reason=RunAttemptBindingRefusalReason.BUDGET_CHARGE_EXCEEDS_REMAINING, + original_run_id=original_run.run_id, + operator_result_id=candidate_operator_result.operator_result_id, + candidate_attempt_id=attempt.attempt_id, + ) + + input_payload = _binding_input_payload( + original_run=original_run, + candidate_operator_result=candidate_operator_result, + schema_versions=schema_versions, + policy_versions=policy_versions, + ) + input_digest = _canonical_digest(input_payload) + binding_input = CandidateAttemptRunBindingInput( + input_digest=input_digest, + residual_ids=original_run.residual_ids, + schema_versions=schema_versions, + policy_versions=policy_versions, + **{ + key: value + for key, value in input_payload.items() + if key not in {"residual_ids", "schema_versions", "policy_versions"} + }, + ) + candidate_attempt_ref = _canonical_digest(_candidate_attempt_payload(attempt)) + binding_id = _canonical_digest( + _binding_id_payload( + input_digest=binding_input.input_digest, + original_run_id=original_run.run_id, + operator_result_id=candidate_operator_result.operator_result_id, + candidate_attempt_id=attempt.attempt_id, + attempt_index=attempt.attempt_index, + candidate_digest=attempt.candidate_digest, + candidate_reconstruction_digest=reconstruction.candidate_reconstruction_digest, + candidate_attempt_ref=candidate_attempt_ref, + budget_charge=charge, + depth=attempt.depth, + step_index=attempt.step_index, + evidence_spans=attempt.evidence_spans, + ) + ) + return CandidateAttemptRunBinding( + binding_id=binding_id, + binding_policy_version=CANDIDATE_ATTEMPT_RUN_BINDING_POLICY_VERSION, + input_digest=binding_input.input_digest, + original_run_id=original_run.run_id, + operator_result_id=candidate_operator_result.operator_result_id, + candidate_attempt_id=attempt.attempt_id, + attempt_index=attempt.attempt_index, + candidate_digest=attempt.candidate_digest, + candidate_reconstruction_digest=reconstruction.candidate_reconstruction_digest, + candidate_attempt_ref=candidate_attempt_ref, + budget_charge=charge, + depth=attempt.depth, + step_index=attempt.step_index, + run_attempt_membership="structurally_bound", + evidence_spans=attempt.evidence_spans, + reason_codes=(), + explanation=explanation + or "CandidateAttempt structurally bound to GeometricSearchRun.", + ) + + +__all__ = [ + "CANDIDATE_ATTEMPT_RUN_BINDING_POLICY_VERSION", + "RunAttemptBindingRefusalReason", + "CandidateAttemptRunBindingInput", + "CandidateAttemptRunBinding", + "CandidateAttemptRunBindingRefusal", + "CandidateAttemptRunBindingOutcome", + "bind_candidate_attempt_to_run", +]