core/generate/construction_affordances.py

458 lines
17 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.

"""Construction-affordance catalog skeleton.
Formalizes the constructional-affordance pattern proved by PR #835 so future
diagnostic families do not become scattered local parser patches.
This module is a catalog/registry skeleton 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
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 — "
"this PR registers catalog skeleton entries only."
)
@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 by ``make_proposal()`` from ProblemFrame evidence.
It does not affect serving. Its ``status`` reflects whether the
corresponding contract assessment declared the frame runnable.
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: One of ``"proposed"``, ``"partial"``, ``"closed"``,
``"refused"``. ``"closed"`` means the contract
assessment was runnable.
missing_roles: Role obligation names that were absent at assessment time.
active_hazards: Hazard categories that were unresolved at assessment time.
"""
family_id: str
relation_type: str
candidate_organ: str
evidence_spans: tuple[SourceSpan, ...]
status: str
missing_roles: tuple[str, ...]
active_hazards: tuple[str, ...]
_VALID_STATUSES: frozenset[str] = frozenset({
"proposed", "partial", "closed", "refused"
})
def __post_init__(self) -> None:
if self.status not in self._VALID_STATUSES:
raise ValueError(
f"ConstructionProposal.status must be one of "
f"{sorted(self._VALID_STATUSES)}, got {self.status!r}"
)
# ---------------------------------------------------------------------------
# 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,
)
# 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,
}
# 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()
}
# ---------------------------------------------------------------------------
# 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 make_proposal(
family_id: str,
evidence_spans: tuple[SourceSpan, ...],
assessment_runnable: bool,
missing_roles: tuple[str, ...],
active_hazards: tuple[str, ...],
) -> ConstructionProposal:
"""Create a lightweight diagnostic-only ConstructionProposal from frame evidence.
This is a thin factory. The caller supplies evidence already gathered by
``assess_contracts()``; this function only maps that evidence to the
standard proposal trace shape.
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.
Returns:
A ConstructionProposal with status derived from the assessment:
- ``"closed"`` if runnable and no missing roles/hazards;
- ``"partial"`` if some roles are bound but closure is incomplete;
- ``"refused"`` if active hazards block closure;
- ``"proposed"`` otherwise (construction proposed but not evaluated).
Raises:
KeyError: If *family_id* is not registered in the catalog.
"""
family = _CATALOG[family_id] # raises KeyError if absent — intended
if assessment_runnable:
status = "closed"
elif active_hazards:
status = "refused"
elif missing_roles:
status = "partial"
else:
status = "proposed"
return ConstructionProposal(
family_id=family_id,
relation_type=family.signature.relation_type,
candidate_organ=family.signature.candidate_organ,
evidence_spans=evidence_spans,
status=status,
missing_roles=missing_roles,
active_hazards=active_hazards,
)