"""Diagnostic organ-contract readiness derived only from ProblemFrame evidence.

Contract dispatch is deliberately narrow:
- ``assess_contracts()`` routes to three diagnostic assessment functions.
- The ``_CONTRACT_REGISTRY`` provides catalog metadata for introspection and
  proposal-trace generation; it does not replace the structural logic inside
  each assessment function.
- All registered contracts have ``serving_allowed=False``; this module must
  never be imported from serving dispatch paths.
"""

from __future__ import annotations

import re
from dataclasses import dataclass

from generate.construction_affordances import (
    ConstructionContract,
    _DECREASE_TO_FRACTION_FAMILY,
    _PERCENT_PARTITION_FAMILY,
    _QUANTITY_ENTITY_FAMILY,
    _UNARY_DELTA_FAMILY,
)
from generate.kernel_facts import BoundRelation, GroundedMention, SourceSpan
from generate.problem_frame import ProblemFrame


# ---------------------------------------------------------------------------
# Contract registry
#
# Maps candidate_organ -> ConstructionContract.  This registry provides
# metadata for proposal-trace generation and external introspection.  It does
# not replace the per-assessment dispatch logic in assess_contracts(); the
# structural proof obligations for each family live inside the dedicated
# assess_* functions below.
#
# Why not route dispatch through the registry?
# assess_fraction_decrease and assess_percent_partition each contain
# construction-specific structural logic (role iteration, topology proofs,
# hazard escalation) that cannot be generalised behind a single callable
# without introducing a forced abstraction boundary.  The registry expresses
# *what* a construction is; the assessment functions express *how* its
# obligations are checked.  Both layers are needed and should remain separate.
# ---------------------------------------------------------------------------

_CONTRACT_REGISTRY: dict[str, ConstructionContract] = {
    "fraction_decrease": ConstructionContract(
        family=_DECREASE_TO_FRACTION_FAMILY,
        assess_fn_name="assess_fraction_decrease",
    ),
    "percent_partition": ConstructionContract(
        family=_PERCENT_PARTITION_FAMILY,
        assess_fn_name="assess_percent_partition",
    ),
    "quantity_entity_binding": ConstructionContract(
        family=_QUANTITY_ENTITY_FAMILY,
        assess_fn_name="assess_quantity_entity",
    ),
    "unary_delta_transition": ConstructionContract(
        family=_UNARY_DELTA_FAMILY,
        assess_fn_name="assess_unary_delta",
    ),
}


def get_contract_family_id(candidate_organ: str) -> str | None:
    """Return the catalog family ID for a candidate organ, if registered."""
    contract = _CONTRACT_REGISTRY.get(candidate_organ)
    return contract.family.family_id if contract else None


@dataclass(frozen=True, slots=True)
class ContractAssessment:
    candidate_organ: str
    missing_bindings: tuple[str, ...]
    unresolved_hazards: tuple[str, ...]
    runnable: bool
    explanation: str
    evidence_spans: tuple[SourceSpan, ...]


def _roles(frame: ProblemFrame, relation_type: str) -> set[str]:
    return {
        role.role
        for relation in frame.bound_relations
        if relation.relation_type == relation_type
        for role in relation.roles
    }


def _evidence(frame: ProblemFrame, relation_type: str) -> tuple[SourceSpan, ...]:
    spans = {
        (span.start, span.end, span.text): span
        for relation in frame.bound_relations
        if relation.relation_type == relation_type
        for span in relation.evidence_spans
    }
    if frame.bound_question_target is not None:
        for span in frame.bound_question_target.evidence_spans:
            spans[(span.start, span.end, span.text)] = span
    return tuple(spans[key] for key in sorted(spans))


def _role_target(relation: BoundRelation, role_name: str) -> str | None:
    return next(
        (role.target_id for role in relation.roles if role.role == role_name), None
    )


def _role_spans(relation: BoundRelation, role_name: str) -> tuple[SourceSpan, ...]:
    role = next((item for item in relation.roles if item.role == role_name), None)
    return () if role is None else role.evidence_spans


def _mention_map(frame: ProblemFrame) -> dict[str, object]:
    return {mention.mention_id: mention for mention in frame.mentions}


def _quantity_value_by_mention_id(frame: ProblemFrame) -> dict[str, object]:
    quantities = {quantity.fact_id: quantity for quantity in frame.quantities}
    return {
        mention.mention_id: quantities[mention.fact_id]
        for mention in frame.mentions
        if mention.fact_id is not None and mention.fact_id in quantities
    }


def _quantity_entity_bindings(frame: ProblemFrame) -> tuple[tuple[str, str], ...]:
    return tuple(
        (binding.source_mention_id, binding.target_mention_id)
        for binding in frame.bindings
        if binding.binding_type == "quantity_entity"
    )


def _quantity_unit_bindings(frame: ProblemFrame) -> dict[str, str]:
    return {
        binding.source_mention_id: binding.target_mention_id
        for binding in frame.bindings
        if binding.binding_type == "quantity_unit"
    }


_UNRESOLVED_ENTITY_SURFACES: frozenset[str] = frozenset(
    {
        "he",
        "her",
        "hers",
        "him",
        "his",
        "it",
        "its",
        "one",
        "ones",
        "she",
        "their",
        "theirs",
        "them",
        "these",
        "they",
        "this",
        "those",
    }
)


def _span_is_exact(frame: ProblemFrame, span: SourceSpan) -> bool:
    return (
        bool(frame.problem_text)
        and 0 <= span.start <= span.end <= len(frame.problem_text)
        and bool(span.text)
        and frame.problem_text[span.start : span.end] == span.text
    )


def _spans_are_local(
    problem_text: str,
    first: SourceSpan,
    second: SourceSpan,
) -> bool:
    left, right = sorted((first, second), key=lambda span: span.start)
    if left.end > right.start:
        return False
    return not any(marker in problem_text[left.end : right.start] for marker in ".!?")


def _unique_evidence(spans: tuple[SourceSpan, ...]) -> tuple[SourceSpan, ...]:
    unique = {(span.start, span.end, span.text): span for span in spans}
    return tuple(unique[key] for key in sorted(unique))


def _entity_mentions(frame: ProblemFrame) -> tuple[GroundedMention, ...]:
    return tuple(
        mention
        for mention in frame.mentions
        if mention.kind in {"entity", "object", "actor"}
    )


def assess_quantity_entity(frame: ProblemFrame) -> ContractAssessment:
    """Assess one proposal-backed local quantity/entity edge.

    This contract is deliberately stricter than generic mention extraction: it
    closes only one exact scalar, one exact entity, one exact local edge, and a
    positively grounded count/measurement disposition.  It derives neither an
    answer nor serving authority.
    """

    proposals = tuple(
        proposal
        for proposal in frame.proposals
        if proposal.family_id == _QUANTITY_ENTITY_FAMILY.family_id
    )
    bindings = tuple(
        binding
        for binding in frame.bindings
        if binding.binding_type == "quantity_entity"
    )
    mentions = {mention.mention_id: mention for mention in frame.mentions}
    quantity_facts = {quantity.fact_id: quantity for quantity in frame.quantities}

    missing: list[str] = []
    unresolved: set[str] = set()
    if len(proposals) != 1:
        missing.append("quantity_entity_proposal_required")
    proposal = proposals[0] if len(proposals) == 1 else None

    if not bindings:
        missing.append("local_binding_relation_unbound")
    elif len(bindings) != 1:
        missing.append("local_binding_relation_ambiguous")
    binding = bindings[0] if len(bindings) == 1 else None

    quantity = mentions.get(binding.source_mention_id) if binding is not None else None
    entity = mentions.get(binding.target_mention_id) if binding is not None else None
    if quantity is None or quantity.kind != "quantity":
        missing.append("quantity_unbound")
    elif quantity.fact_id is None or quantity.fact_id not in quantity_facts:
        missing.append("quantity_unbound")
    if len(frame.quantities) != 1:
        missing.append("quantity_ambiguous")

    if entity is None or entity.kind not in {"entity", "object"}:
        missing.append("entity_unbound")
    elif entity.surface.lower() in _UNRESOLVED_ENTITY_SURFACES:
        missing.append("entity_unbound")
        unresolved.add("quantity_entity_nonlocal")

    if quantity is not None and entity is not None:
        competing_entities = tuple(
            mention
            for mention in _entity_mentions(frame)
            if mention.mention_id != entity.mention_id
            and _spans_are_local(frame.problem_text, entity.span, mention.span)
        )
        if competing_entities:
            missing.append("entity_ambiguous")
        if not _spans_are_local(frame.problem_text, quantity.span, entity.span):
            missing.append("quantity_entity_nonlocal")

    if proposal is not None and quantity is not None and entity is not None:
        cue_contains_binding = any(
            cue.start <= quantity.span.start and entity.span.end <= cue.end
            for cue in proposal.evidence_spans
        )
        if not cue_contains_binding:
            missing.append("local_binding_relation_unbound")

    dispositions = tuple(
        disposition
        for disposition in frame.quantity_kind_dispositions
        if quantity is not None
        and entity is not None
        and disposition.quantity_mention_id == quantity.mention_id
        and disposition.entity_mention_id == entity.mention_id
    )
    if len(dispositions) != 1:
        missing.append("quantity_kind_unresolved")
    disposition = dispositions[0] if len(dispositions) == 1 else None

    unit_bindings = tuple(
        binding
        for binding in frame.bindings
        if quantity is not None
        and binding.binding_type == "quantity_unit"
        and binding.source_mention_id == quantity.mention_id
    )
    if len(unit_bindings) > 1:
        missing.append("unit_kind_conflict")
    unit_binding = unit_bindings[0] if len(unit_bindings) == 1 else None
    unit = (
        mentions.get(unit_binding.target_mention_id)
        if unit_binding is not None
        else None
    )
    if disposition is not None:
        if disposition.quantity_kind == "count" and unit_binding is not None:
            missing.append("unit_kind_conflict")
        elif disposition.quantity_kind == "measurement":
            if (
                unit_binding is None
                or disposition.unit_mention_id != unit_binding.target_mention_id
                or unit is None
                or unit.kind != "unit"
            ):
                missing.append("unit_kind_conflict")
    elif unit_binding is not None:
        missing.append("unit_kind_conflict")

    if unit is not None and entity is not None and unit.span == entity.span:
        missing.append("unit_kind_conflict")

    evidence = _unique_evidence(
        tuple(
            span
            for group in (
                (() if proposal is None else proposal.evidence_spans),
                (() if binding is None else binding.evidence_spans),
                (() if disposition is None else disposition.evidence_spans),
                (() if unit_binding is None else unit_binding.evidence_spans),
            )
            for span in group
        )
    )
    exact_evidence = evidence
    if quantity is not None:
        exact_evidence = _unique_evidence((*exact_evidence, quantity.span))
        quantity_fact = (
            quantity_facts.get(quantity.fact_id)
            if quantity.fact_id is not None
            else None
        )
        if (
            quantity_fact is None
            or quantity.span not in quantity_fact.provenance.source_spans
        ):
            missing.append("provenance_span_inexact")
    if entity is not None:
        exact_evidence = _unique_evidence((*exact_evidence, entity.span))
    if unit is not None:
        exact_evidence = _unique_evidence((*exact_evidence, unit.span))
        unit_fact = next(
            (grounded for grounded in frame.units if unit.fact_id == grounded.fact_id),
            None,
        )
        if unit_fact is None or unit.span not in unit_fact.provenance.source_spans:
            missing.append("provenance_span_inexact")

    if binding is not None and quantity is not None and entity is not None:
        if binding.evidence_spans != (quantity.span, entity.span):
            missing.append("provenance_span_inexact")
    if unit_binding is not None and quantity is not None and unit is not None:
        if unit_binding.evidence_spans != (quantity.span, unit.span):
            missing.append("provenance_span_inexact")
    if not exact_evidence or not all(
        _span_is_exact(frame, span) for span in exact_evidence
    ):
        missing.append("provenance_span_inexact")

    competing_families = {candidate.name for candidate in frame.process_frames}
    if competing_families:
        missing.append("competing_family_context")
    categories = {hazard.category for hazard in frame.hazards}
    if "percent_change_vs_percent_of" in categories:
        unresolved.add("percent_change_vs_percent_of")

    missing_bindings = tuple(dict.fromkeys(missing))
    unresolved_hazards = tuple(sorted(unresolved))
    runnable = not missing_bindings and not unresolved_hazards
    return ContractAssessment(
        candidate_organ="quantity_entity_binding",
        missing_bindings=missing_bindings,
        unresolved_hazards=unresolved_hazards,
        runnable=runnable,
        explanation=(
            "one exact local quantity/entity binding is grounded diagnostically"
            if runnable
            else "diagnostic candidate is not runnable: "
            + ", ".join((*missing_bindings, *unresolved_hazards))
        ),
        evidence_spans=exact_evidence,
    )


def assess_fraction_decrease(frame: ProblemFrame) -> ContractAssessment:
    mentions = _mention_map(frame)
    quantities = _quantity_value_by_mention_id(frame)
    quantity_units = _quantity_unit_bindings(frame)
    relations = [
        relation
        for relation in frame.bound_relations
        if relation.relation_type == "decrease_to_fraction"
    ]
    question_target = frame.bound_question_target

    missing: list[str] = []
    unresolved: set[str] = set()
    if len(relations) != 1:
        missing.append("decrease_relation_ambiguous")
    relation = relations[0] if len(relations) == 1 else None

    base_id = _role_target(relation, "base_quantity") if relation is not None else None
    scale_id = _role_target(relation, "scale") if relation is not None else None
    state_id = _role_target(relation, "state_entity") if relation is not None else None
    unit_id = _role_target(relation, "unit") if relation is not None else None

    if base_id is None:
        missing.append("base_quantity_unbound")
    if scale_id is None:
        missing.append("scale_unbound")
    if state_id is None:
        missing.append("state_entity_unbound")
    if base_id is not None and base_id not in quantities:
        missing.append("base_quantity_provenance_missing")
    if scale_id is not None and scale_id not in quantities:
        missing.append("scale_provenance_missing")
    if (
        unit_id is not None
        and base_id is not None
        and quantity_units.get(base_id) != unit_id
    ):
        missing.append("unit_continuity_unproven")

    if question_target is None or not question_target.grounded:
        missing.append("delta_decrease_target_unbound")
    else:
        if not (
            question_target.target_operator == "difference"
            and question_target.target_state == "delta"
            and question_target.target_direction == "decrease"
        ):
            missing.append("delta_decrease_target_required")
        if (
            state_id is not None
            and state_id in mentions
            and question_target.target_mention_id in mentions
        ):
            relation_state = mentions[state_id]
            target_state = mentions[question_target.target_mention_id]
            if relation_state.surface.lower() != target_state.surface.lower():
                missing.append("state_entity_continuity_unproven")

    scale = quantities.get(scale_id) if scale_id is not None else None
    if scale is not None and not (0 < scale.value < 1):
        missing.append("scale_out_of_range")

    categories = {hazard.category for hazard in frame.hazards}
    if (
        any(item.startswith("base_quantity") for item in missing)
        and "unbound_base_quantity" in categories
    ):
        unresolved.add("unbound_base_quantity")

    evidence_spans = (
        _evidence(frame, "decrease_to_fraction")
        if relation is None
        else tuple(
            sorted(
                {
                    (span.start, span.end, span.text): span
                    for span in (
                        *relation.evidence_spans,
                        *(question_target.evidence_spans if question_target else ()),
                    )
                }.values(),
                key=lambda span: (span.start, span.end, span.text),
            )
        )
    )
    runnable = not missing and not unresolved
    return ContractAssessment(
        candidate_organ="fraction_decrease",
        missing_bindings=tuple(dict.fromkeys(missing)),
        unresolved_hazards=tuple(sorted(unresolved)),
        runnable=runnable,
        explanation=(
            "all fraction-decrease roles and delta target obligations are grounded"
            if runnable
            else "diagnostic candidate is not runnable: "
            + ", ".join((*dict.fromkeys(missing), *sorted(unresolved)))
        ),
        evidence_spans=evidence_spans,
    )


def assess_unary_delta(frame: ProblemFrame) -> ContractAssessment:
    proposals = tuple(
        proposal
        for proposal in frame.proposals
        if proposal.family_id == _UNARY_DELTA_FAMILY.family_id
    )
    relations = tuple(
        relation
        for relation in frame.bound_relations
        if relation.relation_type == "unary_delta"
    )
    mentions = _mention_map(frame)

    missing: list[str] = []
    unresolved: set[str] = set()

    # 1. require exactly one unary-delta proposal
    if len(proposals) != 1:
        missing.append("unary_delta_proposal_required")
    proposal = proposals[0] if len(proposals) == 1 else None

    # 2. require exactly one typed cue in frame.unary_delta_cues
    if len(frame.unary_delta_cues) != 1:
        missing.append("action_cue_unbound")
    cue = frame.unary_delta_cues[0] if len(frame.unary_delta_cues) == 1 else None

    # 3. require exact proposal cue span == typed cue span
    if proposal is not None and cue is not None:
        if proposal.evidence_spans != (cue.span,):
            missing.append("provenance_span_inexact")

    relation = relations[0] if len(relations) == 1 else None

    # Check if multiple relations exist
    if len(relations) > 1:
        missing.append("unary_delta_relation_ambiguous")

    # Roles / targets
    cue_id_role = None
    quantity_id = None
    object_id = None
    direction_role = None

    if relation is not None:
        cue_id_role = _role_target(relation, "action_cue")
        quantity_id = _role_target(relation, "delta_quantity")
        object_id = _role_target(relation, "changed_object")
        direction_role = _role_target(relation, "direction")

        # 4. require relation action_cue target to equal cue.cue_id
        if cue is not None and cue_id_role != cue.cue_id:
            missing.append("action_cue_unbound")

        # 5. derive expected direction from the typed cue and check it
        expected_direction = cue.direction if cue is not None else None
        if direction_role is None or direction_role != expected_direction:
            missing.append("direction_unbound")
    else:
        # If relation is absent, all roles are unbound
        missing.append("action_cue_unbound")
        missing.append("direction_unbound")

    # Get mentions if they are referenced by the relation
    quantity = mentions.get(quantity_id) if quantity_id is not None else None
    changed_object = mentions.get(object_id) if object_id is not None else None

    # 6. require quantity and changed_object roles only when a relation is present
    if relation is not None:
        if quantity is None or quantity.kind != "quantity":
            missing.append("delta_quantity_unbound")
        if changed_object is None or changed_object.kind != "object":
            missing.append("changed_object_unbound")
    else:
        # Refuse missing quantity/object with stable missing labels from frame evidence
        quantities_in_frame = [m for m in frame.mentions if m.kind == "quantity"]
        objects_in_frame = [m for m in frame.mentions if m.kind == "object"]

        if not quantities_in_frame:
            missing.append("delta_quantity_unbound")
        elif len(quantities_in_frame) > 1:
            missing.append("delta_quantity_ambiguous")
        else:
            quantity = quantities_in_frame[0]

        if not objects_in_frame:
            missing.append("changed_object_unbound")
        elif len(objects_in_frame) > 1:
            missing.append("changed_object_ambiguous")
        else:
            changed_object = objects_in_frame[0]

        # Check local binding relation if both exist but relation is absent
        if quantity is not None and changed_object is not None:
            has_binding = any(
                b.binding_type == "quantity_entity"
                and b.source_mention_id == quantity.mention_id
                and b.target_mention_id == changed_object.mention_id
                for b in frame.bindings
            )
            if not has_binding:
                missing.append("local_binding_relation_unbound")

    # 7. refuse unit/object conflict such as `Tom gained 3 degrees.`
    if quantity is not None:
        has_unit = any(
            b.binding_type == "quantity_unit"
            and b.source_mention_id == quantity.mention_id
            for b in frame.bindings
        )
        if has_unit:
            missing.append("quantity_kind_unresolved")
            missing.append("unit_object_conflict")

    # 8. use exact spans only
    exact_evidence = _unique_evidence(
        tuple(
            span
            for group in (
                (() if proposal is None else proposal.evidence_spans),
                (() if cue is None else (cue.span,)),
                (() if quantity is None else (quantity.span,)),
                (() if changed_object is None else (changed_object.span,)),
            )
            for span in group
        )
    )

    # Span check for proposal cue vs cue
    if (
        proposal is not None
        and cue is not None
        and proposal.evidence_spans != (cue.span,)
    ):
        missing.append("provenance_span_inexact")

    # Span check for relation evidence
    if (
        relation is not None
        and quantity is not None
        and changed_object is not None
        and cue is not None
    ):
        if relation.evidence_spans != (cue.span, quantity.span, changed_object.span):
            missing.append("provenance_span_inexact")

    if not exact_evidence or not all(
        _span_is_exact(frame, span) for span in exact_evidence
    ):
        missing.append("provenance_span_inexact")

    # Order/containment check
    if (
        cue is not None
        and changed_object is not None
        and not _spans_are_local(
            frame.problem_text,
            cue.span,
            changed_object.span,
        )
    ):
        missing.append("quantity_entity_nonlocal")
    if (
        cue is not None
        and quantity is not None
        and not _spans_are_local(
            frame.problem_text,
            cue.span,
            quantity.span,
        )
    ):
        missing.append("quantity_entity_nonlocal")

    # Check for pronoun antecedent unresolved
    pronouns = {
        "he",
        "her",
        "hers",
        "him",
        "his",
        "it",
        "its",
        "one",
        "ones",
        "she",
        "their",
        "theirs",
        "them",
        "these",
        "they",
        "this",
        "those",
    }
    if any(re.search(rf"\b{p}\b", frame.problem_text.lower()) for p in pronouns):
        unresolved.add("pronoun_antecedent_unresolved")

    # Check negation / modality
    negation_modality = {
        "not",
        "never",
        "may",
        "might",
        "could",
        "would",
        "should",
        "can",
    }
    if any(
        re.search(rf"\b{word}\b", frame.problem_text.lower())
        for word in negation_modality
    ):
        unresolved.add("event_assertion_unlicensed")

    # Check passive voice
    if cue is not None:
        passive_pattern = rf"\b(was|were|been|be)\s+{cue.surface}\b"
        by_pattern = rf"\b{cue.surface}\s+by\b"
        if re.search(passive_pattern, frame.problem_text.lower()) or re.search(
            by_pattern, frame.problem_text.lower()
        ):
            unresolved.add("passive_voice_unsupported")

    # Check for multiple actors
    if len(frame.actors) > 1:
        unresolved.add("multiple_actor_surface")

    missing_bindings = tuple(dict.fromkeys(missing))
    unresolved_hazards = tuple(sorted(unresolved))
    runnable = not missing_bindings and not unresolved_hazards
    return ContractAssessment(
        candidate_organ="unary_delta_transition",
        missing_bindings=missing_bindings,
        unresolved_hazards=unresolved_hazards,
        runnable=runnable,
        explanation=(
            "one exact local unary gained/lost delta is grounded diagnostically"
            if runnable
            else "diagnostic candidate is not runnable: "
            + ", ".join((*missing_bindings, *unresolved_hazards))
        ),
        evidence_spans=exact_evidence,
    )


def assess_percent_partition(frame: ProblemFrame) -> ContractAssessment:
    mentions = {mention.mention_id: mention for mention in frame.mentions}
    quantities = _quantity_value_by_mention_id(frame)
    quantity_entity = _quantity_entity_bindings(frame)
    subgroups = [
        relation
        for relation in frame.bound_relations
        if relation.relation_type == "subgroup_partition"
    ]
    percentages = [
        relation
        for relation in frame.bound_relations
        if relation.relation_type == "percent_of"
    ]
    linked_pairs: list[tuple[BoundRelation, BoundRelation]] = []
    subgroup_part_ids: set[str] = set()
    shared_whole_ids: set[str] = set()
    original_whole_quantities: set[str] = set()

    for subgroup in subgroups:
        subgroup_part = _role_target(subgroup, "part")
        subgroup_whole = _role_target(subgroup, "whole")
        if subgroup_part is None or subgroup_whole is None:
            continue
        subgroup_part_ids.add(subgroup_part)
        shared_whole_ids.add(subgroup_whole)
        relation_start = min(span.start for span in subgroup.evidence_spans)
        original_whole_quantities.update(
            quantity_id
            for quantity_id, entity_id in quantity_entity
            if entity_id == subgroup_whole
            and quantity_id in mentions
            and quantity_id in quantities
            and mentions[quantity_id].span.start < relation_start
        )
        for percent in percentages:
            percent_part = _role_target(percent, "part")
            percent_whole = _role_target(percent, "whole")
            if percent_part == subgroup_part and percent_whole == subgroup_whole:
                linked_pairs.append((subgroup, percent))

    missing: list[str] = []
    if not subgroups:
        missing.append("grounded_partition_subgroup")
    if not shared_whole_ids:
        missing.append("grounded_whole_entity")
    if not original_whole_quantities:
        missing.append("original_whole_unbound")
    elif len(original_whole_quantities) != 1:
        missing.append("multiple_original_whole_candidates")
    if len(subgroup_part_ids) < 2:
        missing.append("partition_subgroups_not_distinct")
    if len(linked_pairs) < 2:
        missing.append("percent_subgroup_links_incomplete")
    question_target = frame.bound_question_target
    if question_target is None or not question_target.grounded:
        missing.append("grounded_question_target")
    elif not (
        question_target.target_operator == "count"
        and question_target.target_state == "aggregate"
        and question_target.target_direction == "forward"
    ):
        if (
            question_target.target_state == "initial"
            and question_target.target_direction == "inverse"
        ):
            missing.extend(
                ("inverse_topology_unlicensed", "forward_aggregate_target_required")
            )
        else:
            missing.append("forward_aggregate_target_required")

    unresolved: set[str] = set()
    categories = {hazard.category for hazard in frame.hazards}
    if (
        any(
            item in missing
            for item in ("grounded_whole_entity", "original_whole_unbound")
        )
        and "unbound_base_quantity" in categories
    ):
        unresolved.add("unbound_base_quantity")
    if (
        any(
            item in missing
            for item in (
                "grounded_partition_subgroup",
                "percent_subgroup_links_incomplete",
            )
        )
        and "percent_change_vs_percent_of" in categories
    ):
        unresolved.add("percent_change_vs_percent_of")
    runnable = not missing and not unresolved
    return ContractAssessment(
        candidate_organ="percent_partition",
        missing_bindings=tuple(dict.fromkeys(missing)),
        unresolved_hazards=tuple(sorted(unresolved)),
        runnable=runnable,
        explanation=(
            "all percent-partition roles and the question target are grounded"
            if runnable
            else "diagnostic candidate is not runnable: "
            + ", ".join((*dict.fromkeys(missing), *sorted(unresolved)))
        ),
        evidence_spans=tuple(
            sorted(
                {
                    (span.start, span.end, span.text): span
                    for pair in linked_pairs
                    for relation in pair
                    for span in relation.evidence_spans
                }.values(),
                key=lambda span: (span.start, span.end, span.text),
            )
        )
        + (() if question_target is None else question_target.evidence_spans),
    )


def assess_contracts(frame: ProblemFrame) -> tuple[ContractAssessment, ...]:
    """Return deterministic diagnostic assessments; never admits serving.

    Dispatch order:
    1. ``quantity_entity`` — triggered by its proposal-first foundational
       family in ``frame.proposals``.  Routes to
       ``assess_quantity_entity``, which closes only exact local evidence.
       Registry key: ``_CONTRACT_REGISTRY["quantity_entity_binding"]``.
    2. ``decrease_to_fraction`` — triggered by its proposal-first catalog
       family in ``frame.proposals``.  Routes to ``assess_fraction_decrease``,
       which still determines closure from bound frame evidence.
       Registry key: ``_CONTRACT_REGISTRY["fraction_decrease"]``.
    3. ``unary_delta`` — triggered by its proposal-first catalog family in
       ``frame.proposals``. Routes to ``assess_unary_delta``, which closes
       only exact local gained/lost cue, quantity, and object evidence.
       Registry key: ``_CONTRACT_REGISTRY["unary_delta"]``.
    4. ``percent_partition`` — triggered by its proposal-first catalog family
       in ``frame.proposals``.  Routes to ``assess_percent_partition``, which
       still determines closure from bound frame evidence.
       Registry key: ``_CONTRACT_REGISTRY["percent_partition"]``.
    5. ``container_packing`` / ``labor_rate`` — inline skeleton assessments;
       not yet in the catalog registry (added to registry when obligations are
       fully specified).

    The registry provides catalog metadata for proposal traces; it does not
    replace the structural logic inside each assess_* function.  See module
    docstring for rationale.
    """
    frame_names = {candidate.name for candidate in frame.process_frames}
    results: list[ContractAssessment] = []

    # Registry-backed diagnostic families
    proposed_family_ids = {proposal.family_id for proposal in frame.proposals}
    if _QUANTITY_ENTITY_FAMILY.family_id in proposed_family_ids:
        # Catalog: _CONTRACT_REGISTRY["quantity_entity_binding"]
        results.append(assess_quantity_entity(frame))
    if _DECREASE_TO_FRACTION_FAMILY.family_id in proposed_family_ids:
        # Catalog: _CONTRACT_REGISTRY["fraction_decrease"]
        results.append(assess_fraction_decrease(frame))
    if _UNARY_DELTA_FAMILY.family_id in proposed_family_ids:
        # Catalog: _CONTRACT_REGISTRY["unary_delta_transition"]
        results.append(assess_unary_delta(frame))
    if _PERCENT_PARTITION_FAMILY.family_id in proposed_family_ids:
        # Catalog: _CONTRACT_REGISTRY["percent_partition"]
        results.append(assess_percent_partition(frame))

    # Skeleton families not yet in the catalog registry
    if "container_packing" in frame_names and frame.bound_question_target is not None:
        roles = _roles(frame, "container_packing")
        missing = tuple(
            name for name in ("container", "content", "count_per") if name not in roles
        )
        results.append(
            ContractAssessment(
                "nested_fraction_remainder_total",
                missing,
                (),
                not missing,
                "container contract grounded"
                if not missing
                else "missing container bindings: " + ", ".join(missing),
                _evidence(frame, "container_packing"),
            )
        )
    if "labor_rate" in frame_names:
        roles = _roles(frame, "labor_rate")
        missing = tuple(
            name for name in ("worker", "rate", "duration") if name not in roles
        )
        results.append(
            ContractAssessment(
                "temporal_tariff",
                missing,
                (),
                not missing,
                "temporal tariff contract grounded"
                if not missing
                else "missing tariff bindings: " + ", ".join(missing),
                _evidence(frame, "labor_rate"),
            )
        )
    return tuple(sorted(results, key=lambda item: item.candidate_organ))


def recommended_migration_target(assessments: tuple[ContractAssessment, ...]) -> str:
    runnable = [item.candidate_organ for item in assessments if item.runnable]
    if runnable:
        return sorted(runnable)[0]
    if assessments:
        best = min(
            assessments,
            key=lambda item: (
                len(item.missing_bindings) + len(item.unresolved_hazards),
                item.candidate_organ,
            ),
        )
        return f"substrate:contract_gap:{best.candidate_organ}"
    return "substrate:problem_frame_builder"
