fix(kernel): harden quantity-entity proposal seam (#853)

This commit is contained in:
Shay 2026-06-20 21:49:26 -07:00 committed by GitHub
parent b5c4a39b56
commit ca83d3846c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 76 additions and 63 deletions

View file

@ -24,8 +24,8 @@ Design doctrine (from ADR-0223):
"""
from __future__ import annotations
from dataclasses import dataclass, replace
from typing import TYPE_CHECKING
from dataclasses import dataclass
from typing import TYPE_CHECKING, ClassVar, Literal
if TYPE_CHECKING:
from generate.kernel_facts import SourceSpan
@ -170,11 +170,8 @@ class ConstructionProposal:
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.
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.
@ -185,28 +182,36 @@ class ConstructionProposal:
relation_type: str
candidate_organ: str
evidence_spans: tuple[SourceSpan, ...]
status: str
missing_roles: tuple[str, ...]
active_hazards: tuple[str, ...]
status: Literal["proposed"]
role_obligations: tuple[RoleObligation, ...] = ()
diagnostic_only: bool = True
serving_allowed: bool = False
_VALID_STATUSES: frozenset[str] = frozenset({
"proposed", "partial", "closed", "refused"
})
_VALID_STATUS: ClassVar[Literal["proposed"]] = "proposed"
def __post_init__(self) -> None:
if self.status not in self._VALID_STATUSES:
if self.status != self._VALID_STATUS:
raise ValueError(
f"ConstructionProposal.status must be one of "
f"{sorted(self._VALID_STATUSES)}, got {self.status!r}"
"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
@ -527,8 +532,6 @@ def propose_construction(
candidate_organ=family.signature.candidate_organ,
evidence_spans=evidence_spans,
status="proposed",
missing_roles=(),
active_hazards=(),
role_obligations=(
*family.signature.required_roles,
*family.signature.optional_roles,
@ -545,11 +548,11 @@ def make_proposal(
missing_roles: tuple[str, ...],
active_hazards: tuple[str, ...],
) -> ConstructionProposal:
"""Map assessment evidence onto a proposal for legacy catalog paths.
"""Reject assessment-backed proposal synthesis.
Migrated proposal-first families must enter through
:func:`propose_construction`. This adapter remains only for explicitly
unmigrated catalog paths that still synthesize proposals from assessments.
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.
@ -559,35 +562,14 @@ def make_proposal(
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.
ValueError: If *family_id* has already migrated to the proposal-first seam.
ValueError: For every catalog family; use ``propose_construction``
before assessment instead.
"""
if family_id in _PROPOSAL_FIRST_FAMILIES:
raise ValueError(
f"{family_id} is proposal-first; use propose_construction before assessment"
)
proposal = propose_construction(family_id, evidence_spans)
if assessment_runnable:
status = "closed"
elif active_hazards:
status = "refused"
elif missing_roles:
status = "partial"
else:
status = "proposed"
return replace(
proposal,
status=status,
missing_roles=missing_roles,
active_hazards=active_hazards,
_ = (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"
)

View file

@ -562,8 +562,18 @@ def _extract_bindings(
def _quantity_kind_dispositions(
mentions: tuple[GroundedMention, ...],
bindings: tuple[MentionBinding, ...],
proposals: tuple[ConstructionProposal, ...],
) -> tuple[QuantityKindDisposition, ...]:
"""Close count/measurement kind only from existing local bindings."""
"""Close kind only for the exact proposal-backed local binding."""
quantity_entity_proposals = tuple(
proposal
for proposal in proposals
if proposal.family_id == "binding.quantity_entity"
)
if len(quantity_entity_proposals) != 1:
return ()
proposal = quantity_entity_proposals[0]
mentions_by_id = {mention.mention_id: mention for mention in mentions}
unit_bindings: dict[str, list[MentionBinding]] = {}
@ -579,6 +589,11 @@ def _quantity_kind_dispositions(
entity = mentions_by_id.get(binding.target_mention_id)
if quantity is None or entity is None or quantity.fact_id is None:
continue
if not any(
cue.start <= quantity.span.start and entity.span.end <= cue.end
for cue in proposal.evidence_spans
):
continue
bound_units = unit_bindings.get(quantity.mention_id, [])
if not bound_units:
@ -917,11 +932,12 @@ def build_problem_frame(problem_text: str) -> ProblemFrame:
builder.add_proposal(proposal)
for proposal in _percent_partition_proposals(problem_text, frames):
builder.add_proposal(proposal)
for proposal in _quantity_entity_proposals(
quantity_entity_proposals = _quantity_entity_proposals(
problem_text,
tuple(grounded_quantities),
frames,
):
)
for proposal in quantity_entity_proposals:
builder.add_proposal(proposal)
mentions = _extract_mentions(problem_text, tuple(grounded_quantities), units)
@ -934,7 +950,11 @@ def build_problem_frame(problem_text: str) -> ProblemFrame:
builder.add_object(mention.surface)
for binding in bindings:
builder.add_binding(binding)
for disposition in _quantity_kind_dispositions(mentions, bindings):
for disposition in _quantity_kind_dispositions(
mentions,
bindings,
quantity_entity_proposals,
):
builder.add_quantity_kind_disposition(disposition)
for relation in _bound_relations(problem_text, mentions, bindings):
builder.add_bound_relation(relation)

View file

@ -138,25 +138,35 @@ def test_make_proposal_still_rejects_unknown_family_ids() -> None:
propose_construction("invalid_family_id", (span,))
def test_construction_proposal_status_validation() -> None:
def test_construction_proposal_cannot_carry_assessment_authority() -> None:
span = SourceSpan("test text", 0, 9)
ConstructionProposal(
family_id="proportional_change.decrease_to_fraction",
relation_type="decrease_to_fraction",
candidate_organ="fraction_decrease",
evidence_spans=(span,),
status="closed",
missing_roles=(),
active_hazards=(),
status="proposed",
)
with pytest.raises(ValueError, match="status must be one of"):
for status in ("partial", "closed", "refused"):
with pytest.raises(ValueError, match="must remain 'proposed'"):
ConstructionProposal(
family_id="proportional_change.decrease_to_fraction",
relation_type="decrease_to_fraction",
candidate_organ="fraction_decrease",
evidence_spans=(span,),
status=status, # type: ignore[arg-type]
)
assert "missing_roles" not in ConstructionProposal.__dataclass_fields__
assert "active_hazards" not in ConstructionProposal.__dataclass_fields__
with pytest.raises(TypeError, match="unexpected keyword argument 'missing_roles'"):
ConstructionProposal(
family_id="proportional_change.decrease_to_fraction",
relation_type="decrease_to_fraction",
candidate_organ="fraction_decrease",
evidence_spans=(span,),
status="invalid_status",
missing_roles=(),
active_hazards=(),
status="proposed",
missing_roles=("base_quantity_unbound",),
)

View file

@ -120,6 +120,7 @@ def test_confusers_do_not_dispatch_quantity_entity(problem_text: str) -> None:
frame = build_problem_frame(problem_text)
assert FAMILY_ID not in {proposal.family_id for proposal in frame.proposals}
assert frame.quantity_kind_dispositions == ()
assert CANDIDATE_ORGAN not in {
assessment.candidate_organ
for assessment in assess_contracts(frame)
@ -209,4 +210,4 @@ def test_existing_proposal_first_families_do_not_gain_quantity_entity_dispatch()
assert CANDIDATE_ORGAN not in {
assessment.candidate_organ
for assessment in assess_contracts(frame)
}
}