core/generate/construction_affordances.py

653 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Diagnostic construction-affordance catalog.
Formalizes the constructional-affordance pattern proved by PR #835 so future
diagnostic families do not become scattered local parser patches.
This module contains declarations and proposal factories only. It does NOT:
- claim CGA/substrate geometric retrieval (no manifold calls here);
- label local regex matching as substrate cognition;
- add any acquisition/transaction or transfer/loss/rate/comparison families;
- affect serving (all entries have ``serving_allowed=False``).
Every entry in this catalog is ``diagnostic_only=True``. The catalog expresses
what a construction *means*, what roles it requires, what hazards it carries, and
what target semantics close it. The assessment functions that check actual
ProblemFrame evidence live in ``generate/problem_frame_contracts.py``.
Design doctrine (from ADR-0223):
Words and chunks are probes into the semantic substrate.
They surface candidate affordances and relation families.
Semantic closeness proposes.
Exact span bindings ground.
Organ-specific contracts determine.
Unsupported or ambiguous cases refuse.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, ClassVar, Literal
if TYPE_CHECKING:
from generate.kernel_facts import SourceSpan
# ---------------------------------------------------------------------------
# Frozen typed structures
# ---------------------------------------------------------------------------
@dataclass(frozen=True, slots=True)
class RoleObligation:
"""A single role obligation for a construction.
Args:
role: Role name that must be bound (e.g. ``"base_quantity"``).
required: If True, absence blocks contract closure. If False, the
role is advisory only.
description: Human-readable description of what this role represents.
"""
role: str
required: bool
description: str
@dataclass(frozen=True, slots=True)
class ConstructionHazard:
"""An ambiguity or confuser hazard associated with a construction family.
Args:
hazard_category: Matches ``KernelHazard.category`` in the frame.
blocking: If True, the presence of this hazard blocks closure.
description: Explanation of what the hazard represents.
"""
hazard_category: str
blocking: bool
description: str
@dataclass(frozen=True, slots=True)
class ConstructionSignature:
"""The relational signature of a construction: its type, organ, and roles.
Args:
relation_type: The ``BoundRelation.relation_type`` value that carries
this construction (e.g. ``"decrease_to_fraction"``).
candidate_organ: The assessment organ identifier produced by the
corresponding contract (e.g. ``"fraction_decrease"``).
required_roles: Roles that must be bound for the contract to be
declared runnable.
optional_roles: Roles that improve precision but are not blocking.
"""
relation_type: str
candidate_organ: str
required_roles: tuple[RoleObligation, ...]
optional_roles: tuple[RoleObligation, ...]
@dataclass(frozen=True, slots=True)
class ConstructionFamily:
"""A reviewed construction-affordance family declaration.
Args:
family_id: Stable dotted identifier, e.g.
``"proportional_change.decrease_to_fraction"``.
display_name: Short human-readable label.
signature: Relational signature (roles, organ, type).
hazards: Known hazards and confusers for this family.
target_semantics: Required (operator, state, direction) values for the
bound question target (free-form strings matching
``BoundQuestionTarget`` fields).
contract_labels: Stable blocker code strings used in
``ContractAssessment.missing_bindings``.
diagnostic_only: Always True in this PR; the construction may produce
diagnostic assessments but must not change serving.
serving_allowed: Always False in this PR. No construction may be
promoted to serving without a separate reviewed PR.
"""
family_id: str
display_name: str
signature: ConstructionSignature
hazards: tuple[ConstructionHazard, ...]
target_semantics: tuple[str, ...]
contract_labels: tuple[str, ...]
diagnostic_only: bool
serving_allowed: bool
def __post_init__(self) -> None:
if self.serving_allowed:
raise ValueError(
f"ConstructionFamily {self.family_id!r}: "
"serving_allowed must be False in this PR — "
"no construction may be promoted to serving without a separate reviewed PR."
)
if not self.diagnostic_only:
raise ValueError(
f"ConstructionFamily {self.family_id!r}: "
"diagnostic_only must be True — "
"catalog constructions do not authorize serving."
)
@dataclass(frozen=True, slots=True)
class ConstructionContract:
"""Registry entry binding a ConstructionFamily to its assessment function.
Args:
family: The construction family declaration.
assess_fn_name: Name of the assessment function in
``generate.problem_frame_contracts`` (for introspection
only; the function is not called through this struct).
"""
family: ConstructionFamily
assess_fn_name: str
@dataclass(frozen=True, slots=True)
class ConstructionProposal:
"""Lightweight diagnostic-only trace of a construction proposal.
Represents the evidence chain::
chunk/surface evidence
→ proposed construction family
→ proposed relation type
→ role obligations
→ hazards
→ status
A proposal is created from exact surface evidence before contract
assessment. It does not affect serving. Its initial ``status`` is
``"proposed"``; assessment remains a separate, downstream judgment.
Args:
family_id: Catalog family identifier, e.g.
``"proportional_change.decrease_to_fraction"``.
relation_type: The ``BoundRelation.relation_type`` that was searched.
candidate_organ: The organ this proposal targets.
evidence_spans: Source spans that motivated the proposal.
status: Always ``"proposed"``. Runnable/refused authority
belongs exclusively to ``ContractAssessment``.
role_obligations: Catalog-declared roles that downstream binding and
assessment must ground or explicitly refuse.
diagnostic_only: True for every proposal in the current catalog.
serving_allowed: False for every proposal in the current catalog.
"""
family_id: str
relation_type: str
candidate_organ: str
evidence_spans: tuple[SourceSpan, ...]
status: Literal["proposed"]
role_obligations: tuple[RoleObligation, ...] = ()
diagnostic_only: bool = True
serving_allowed: bool = False
_VALID_STATUS: ClassVar[Literal["proposed"]] = "proposed"
def __post_init__(self) -> None:
if self.status != self._VALID_STATUS:
raise ValueError(
"ConstructionProposal.status must remain 'proposed'; "
"ContractAssessment is the sole runnable/refused authority"
)
if not self.diagnostic_only or self.serving_allowed:
raise ValueError(
"ConstructionProposal must remain diagnostic-only and serving-disallowed"
)
@property
def missing_roles(self) -> tuple[str, ...]:
"""Compatibility view; assessment blockers are not proposal state."""
return ()
@property
def active_hazards(self) -> tuple[str, ...]:
"""Compatibility view; assessment hazards are not proposal state."""
return ()
# ---------------------------------------------------------------------------
# Catalog entries
# ---------------------------------------------------------------------------
_DECREASE_TO_FRACTION_FAMILY = ConstructionFamily(
family_id="proportional_change.decrease_to_fraction",
display_name="Proportional decrease to fraction",
signature=ConstructionSignature(
relation_type="decrease_to_fraction",
candidate_organ="fraction_decrease",
required_roles=(
RoleObligation(
"base_quantity",
required=True,
description="The quantity whose value will decrease (the current/initial state).",
),
RoleObligation(
"scale",
required=True,
description=(
"Fraction p/q in (0, 1) exclusive: the new value is scale × base_quantity. "
"Values of 0, 1, or > 1 are rejected."
),
),
RoleObligation(
"state_entity",
required=True,
description=(
"The entity (object or actor) whose state undergoes the decrease. "
"Must match the question-target entity for state_entity_continuity."
),
),
RoleObligation(
"transition",
required=True,
description='Span anchor for the "decrease to" trigger phrase.',
),
),
optional_roles=(
RoleObligation(
"unit",
required=False,
description=(
"Unit of the base quantity. When present, unit_continuity is checked: "
"the unit binding on base_quantity must match this role."
),
),
),
),
hazards=(
ConstructionHazard(
hazard_category="unbound_base_quantity",
blocking=True,
description=(
"The base quantity cannot be uniquely identified. "
"Multiple candidates or no 'current/now' sentence context."
),
),
ConstructionHazard(
hazard_category="percent_change_vs_percent_of",
blocking=False,
description=(
"Percent-change surfaces may co-occur; they do not trigger this construction "
"because this family only recognises exact fraction (p/q) scale triggers."
),
),
),
target_semantics=(
"operator:difference",
"state:delta",
"direction:decrease",
),
contract_labels=(
"decrease_relation_ambiguous",
"base_quantity_unbound",
"scale_unbound",
"state_entity_unbound",
"base_quantity_provenance_missing",
"scale_provenance_missing",
"unit_continuity_unproven",
"delta_decrease_target_unbound",
"delta_decrease_target_required",
"state_entity_continuity_unproven",
"scale_out_of_range",
),
diagnostic_only=True,
serving_allowed=False,
)
_PERCENT_PARTITION_FAMILY = ConstructionFamily(
family_id="partition.percent_partition",
display_name="Percent-based partition of a whole",
signature=ConstructionSignature(
relation_type="percent_of", # primary relation type checked in contracts
candidate_organ="percent_partition",
required_roles=(
RoleObligation(
"whole",
required=True,
description=(
"The original numeric whole that the partition subdivides. "
"Must appear before the partition event in text."
),
),
RoleObligation(
"part",
required=True,
description=(
"A distinct subgroup of the whole. At least two distinct "
"subgroup-part IDs are required to prove complementary coverage."
),
),
RoleObligation(
"scale",
required=True,
description=(
"Percentage or fraction linking part to whole via percent_of relation. "
"Each distinct part must have its own linked scale."
),
),
),
optional_roles=(),
),
hazards=(
ConstructionHazard(
hazard_category="unbound_base_quantity",
blocking=True,
description=(
"No numeric whole quantity is bound before the partition event. "
"Without a provenance-bearing original whole, closure is refused."
),
),
ConstructionHazard(
hazard_category="percent_change_vs_percent_of",
blocking=True,
description=(
"Percent-change surface ambiguity: 'percent' could indicate a change "
"rather than a partition fraction. Topology proof resolves this."
),
),
),
target_semantics=(
"operator:count",
"state:aggregate",
"direction:forward",
),
contract_labels=(
"grounded_partition_subgroup",
"grounded_whole_entity",
"original_whole_unbound",
"multiple_original_whole_candidates",
"partition_subgroups_not_distinct",
"percent_subgroup_links_incomplete",
"grounded_question_target",
"forward_aggregate_target_required",
"inverse_topology_unlicensed",
),
diagnostic_only=True,
serving_allowed=False,
)
_QUANTITY_ENTITY_FAMILY = ConstructionFamily(
family_id="binding.quantity_entity",
display_name="Local quantity-entity binding",
signature=ConstructionSignature(
relation_type="quantity_entity",
candidate_organ="quantity_entity_binding",
required_roles=(
RoleObligation(
"quantity",
required=True,
description="Exactly one source-grounded scalar mention.",
),
RoleObligation(
"entity",
required=True,
description="Exactly one local source-grounded entity mention.",
),
RoleObligation(
"quantity_kind",
required=True,
description="A positively grounded count or measurement disposition.",
),
RoleObligation(
"provenance_span",
required=True,
description="Exact non-synthetic quantity, entity, and optional unit spans.",
),
RoleObligation(
"local_binding_relation",
required=True,
description="The unique local quantity_entity MentionBinding edge.",
),
),
optional_roles=(
RoleObligation(
"unit",
required=False,
description=(
"An exact grounded unit bound to the same quantity when the "
"quantity kind is measurement."
),
),
),
),
hazards=(
ConstructionHazard(
hazard_category="quantity_entity_ambiguous",
blocking=True,
description="More than one local scalar, entity, or binding remains plausible.",
),
ConstructionHazard(
hazard_category="quantity_kind_unresolved",
blocking=True,
description="Count or measurement kind is not positively grounded.",
),
ConstructionHazard(
hazard_category="quantity_entity_nonlocal",
blocking=True,
description="The binding requires pronoun repair or a cross-sentence leap.",
),
),
target_semantics=(
"locality:single_sentence",
"authority:diagnostic_only",
),
contract_labels=(
"quantity_entity_proposal_required",
"quantity_unbound",
"entity_unbound",
"quantity_ambiguous",
"entity_ambiguous",
"local_binding_relation_unbound",
"local_binding_relation_ambiguous",
"quantity_kind_unresolved",
"unit_kind_conflict",
"provenance_span_inexact",
"quantity_entity_nonlocal",
"competing_family_context",
"percent_change_vs_percent_of",
),
diagnostic_only=True,
serving_allowed=False,
)
_UNARY_DELTA_FAMILY = ConstructionFamily(
family_id="state_change.unary_delta",
display_name="Unary gained/lost delta",
signature=ConstructionSignature(
relation_type="unary_delta",
candidate_organ="unary_delta_transition",
required_roles=(
RoleObligation(
"action_cue",
required=True,
description="Exact local gained/lost cue span from the original text.",
),
RoleObligation(
"delta_quantity",
required=True,
description="Exactly one grounded scalar quantity attached to the event.",
),
RoleObligation(
"changed_object",
required=True,
description="Exactly one grounded local object whose quantity changed.",
),
RoleObligation(
"direction",
required=True,
description="Increase for gained and decrease for lost.",
),
),
optional_roles=(),
),
hazards=(
ConstructionHazard(
hazard_category="quantity_entity_nonlocal",
blocking=True,
description=(
"Pronoun repair or cross-sentence binding would be required to "
"decide the changed object."
),
),
ConstructionHazard(
hazard_category="percent_change_vs_percent_of",
blocking=True,
description=(
"Percent/rate/comparison phrasing would widen this slice beyond "
"a local unary gained/lost event."
),
),
),
target_semantics=(
"authority:diagnostic_only",
"cue_inventory:gained|lost",
"locality:single_clause",
),
contract_labels=(
"unary_delta_proposal_required",
"unary_delta_relation_ambiguous",
"action_cue_unbound",
"delta_quantity_unbound",
"delta_quantity_ambiguous",
"changed_object_unbound",
"changed_object_ambiguous",
"local_binding_relation_unbound",
"direction_unbound",
"quantity_kind_unresolved",
"unit_object_conflict",
"provenance_span_inexact",
"quantity_entity_nonlocal",
"pronoun_antecedent_unresolved",
"event_assertion_unlicensed",
"passive_voice_unsupported",
"multiple_actor_surface",
),
diagnostic_only=True,
serving_allowed=False,
)
# The catalog. Keys are family_id strings. Sorted for deterministic iteration.
_CATALOG: dict[str, ConstructionFamily] = {
_DECREASE_TO_FRACTION_FAMILY.family_id: _DECREASE_TO_FRACTION_FAMILY,
_PERCENT_PARTITION_FAMILY.family_id: _PERCENT_PARTITION_FAMILY,
_QUANTITY_ENTITY_FAMILY.family_id: _QUANTITY_ENTITY_FAMILY,
_UNARY_DELTA_FAMILY.family_id: _UNARY_DELTA_FAMILY,
}
# Secondary indices for O(1) lookup by organ or relation type.
_BY_ORGAN: dict[str, ConstructionFamily] = {
family.signature.candidate_organ: family
for family in _CATALOG.values()
}
_BY_RELATION_TYPE: dict[str, ConstructionFamily] = {
family.signature.relation_type: family
for family in _CATALOG.values()
}
_PROPOSAL_FIRST_FAMILIES: frozenset[str] = frozenset({
"binding.quantity_entity",
"proportional_change.decrease_to_fraction",
"partition.percent_partition",
"state_change.unary_delta",
})
# ---------------------------------------------------------------------------
# Public accessors
# ---------------------------------------------------------------------------
def lookup_family(family_id: str) -> ConstructionFamily | None:
"""Return the ConstructionFamily for *family_id*, or None if not registered."""
return _CATALOG.get(family_id)
def lookup_by_organ(candidate_organ: str) -> ConstructionFamily | None:
"""Return the ConstructionFamily whose signature.candidate_organ matches."""
return _BY_ORGAN.get(candidate_organ)
def lookup_by_relation_type(relation_type: str) -> ConstructionFamily | None:
"""Return the ConstructionFamily whose signature.relation_type matches.
Note: ``percent_partition`` uses two relation types (``percent_of`` and
``subgroup_partition``) in its assessment logic, but the catalog entry
declares the primary relation type (``percent_of``) as the lookup key.
"""
return _BY_RELATION_TYPE.get(relation_type)
def all_diagnostic_families() -> tuple[ConstructionFamily, ...]:
"""Return all registered diagnostic families in deterministic (family_id) order."""
return tuple(_CATALOG[key] for key in sorted(_CATALOG))
def propose_construction(
family_id: str,
evidence_spans: tuple[SourceSpan, ...],
) -> ConstructionProposal:
"""Create a catalog-backed proposal from pre-assessment surface evidence.
This factory deliberately has no assessment inputs. It records the
construction hypothesis and its catalog obligations; bound relations and
``ContractAssessment`` remain responsible for grounding and determination.
Raises:
KeyError: If *family_id* is not registered in the catalog.
"""
family = _CATALOG[family_id]
return ConstructionProposal(
family_id=family.family_id,
relation_type=family.signature.relation_type,
candidate_organ=family.signature.candidate_organ,
evidence_spans=evidence_spans,
status="proposed",
role_obligations=(
*family.signature.required_roles,
*family.signature.optional_roles,
),
diagnostic_only=family.diagnostic_only,
serving_allowed=family.serving_allowed,
)
def make_proposal(
family_id: str,
evidence_spans: tuple[SourceSpan, ...],
assessment_runnable: bool,
missing_roles: tuple[str, ...],
active_hazards: tuple[str, ...],
) -> ConstructionProposal:
"""Reject assessment-backed proposal synthesis.
Every catalog family is proposal-first. The assessment-shaped signature
remains temporarily for callers that need a loud migration failure, but no
``ConstructionProposal`` may encode assessment output.
Args:
family_id: Catalog family identifier.
evidence_spans: Source spans from the contract assessment.
assessment_runnable: Whether the corresponding ContractAssessment.runnable
was True.
missing_roles: ContractAssessment.missing_bindings contents.
active_hazards: ContractAssessment.unresolved_hazards contents.
Raises:
KeyError: If *family_id* is not registered in the catalog.
ValueError: For every catalog family; use ``propose_construction``
before assessment instead.
"""
_ = (evidence_spans, assessment_runnable, missing_roles, active_hazards)
if family_id not in _CATALOG:
raise KeyError(family_id)
raise ValueError(
f"{family_id} is proposal-first; use propose_construction before assessment"
)