test(kernel): harden unary-delta isolation after proposal seam
This commit is contained in:
parent
a5281df562
commit
032c05f806
1 changed files with 320 additions and 21 deletions
|
|
@ -20,9 +20,7 @@ CANDIDATE_ORGAN = "unary_delta"
|
||||||
|
|
||||||
def _proposal(frame):
|
def _proposal(frame):
|
||||||
proposals = tuple(
|
proposals = tuple(
|
||||||
proposal
|
proposal for proposal in frame.proposals if proposal.family_id == FAMILY_ID
|
||||||
for proposal in frame.proposals
|
|
||||||
if proposal.family_id == FAMILY_ID
|
|
||||||
)
|
)
|
||||||
assert len(proposals) == 1
|
assert len(proposals) == 1
|
||||||
return proposals[0]
|
return proposals[0]
|
||||||
|
|
@ -79,8 +77,12 @@ def test_exact_local_gained_lost_event_is_proposed_bound_and_assessed(
|
||||||
assert assessment.missing_bindings == ()
|
assert assessment.missing_bindings == ()
|
||||||
assert assessment.unresolved_hazards == ()
|
assert assessment.unresolved_hazards == ()
|
||||||
assert all(
|
assert all(
|
||||||
span.text == problem_text[span.start:span.end]
|
span.text == problem_text[span.start : span.end]
|
||||||
for span in (*proposal.evidence_spans, *relation.evidence_spans, *assessment.evidence_spans)
|
for span in (
|
||||||
|
*proposal.evidence_spans,
|
||||||
|
*relation.evidence_spans,
|
||||||
|
*assessment.evidence_spans,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -107,8 +109,7 @@ def test_proposal_free_frame_does_not_dispatch_unary_delta_contract() -> None:
|
||||||
proposal_free = dataclasses.replace(frame, proposals=())
|
proposal_free = dataclasses.replace(frame, proposals=())
|
||||||
|
|
||||||
assert CANDIDATE_ORGAN not in {
|
assert CANDIDATE_ORGAN not in {
|
||||||
assessment.candidate_organ
|
assessment.candidate_organ for assessment in assess_contracts(proposal_free)
|
||||||
for assessment in assess_contracts(proposal_free)
|
|
||||||
}
|
}
|
||||||
assessment = assess_unary_delta(proposal_free)
|
assessment = assess_unary_delta(proposal_free)
|
||||||
assert assessment.runnable is False
|
assert assessment.runnable is False
|
||||||
|
|
@ -145,12 +146,10 @@ def test_confusers_do_not_dispatch_unary_delta(problem_text: str) -> None:
|
||||||
|
|
||||||
assert FAMILY_ID not in {proposal.family_id for proposal in frame.proposals}
|
assert FAMILY_ID not in {proposal.family_id for proposal in frame.proposals}
|
||||||
assert not any(
|
assert not any(
|
||||||
relation.relation_type == "unary_delta"
|
relation.relation_type == "unary_delta" for relation in frame.bound_relations
|
||||||
for relation in frame.bound_relations
|
|
||||||
)
|
)
|
||||||
assert CANDIDATE_ORGAN not in {
|
assert CANDIDATE_ORGAN not in {
|
||||||
assessment.candidate_organ
|
assessment.candidate_organ for assessment in assess_contracts(frame)
|
||||||
for assessment in assess_contracts(frame)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -159,20 +158,22 @@ def test_missing_object_literal_surface_stays_unbound_and_non_runnable() -> None
|
||||||
|
|
||||||
assert FAMILY_ID not in {proposal.family_id for proposal in frame.proposals}
|
assert FAMILY_ID not in {proposal.family_id for proposal in frame.proposals}
|
||||||
assert not any(
|
assert not any(
|
||||||
relation.relation_type == "unary_delta"
|
relation.relation_type == "unary_delta" for relation in frame.bound_relations
|
||||||
for relation in frame.bound_relations
|
|
||||||
)
|
)
|
||||||
assert CANDIDATE_ORGAN not in {
|
assert CANDIDATE_ORGAN not in {
|
||||||
assessment.candidate_organ
|
assessment.candidate_organ for assessment in assess_contracts(frame)
|
||||||
for assessment in assess_contracts(frame)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_missing_quantity_blocks_runnable_unary_delta() -> None:
|
def test_missing_quantity_blocks_runnable_unary_delta() -> None:
|
||||||
frame = build_problem_frame("Tom gained 3 apples.")
|
frame = build_problem_frame("Tom gained 3 apples.")
|
||||||
relation = _relation(frame)
|
relation = _relation(frame)
|
||||||
stripped_roles = tuple(role for role in relation.roles if role.role != "delta_quantity")
|
stripped_roles = tuple(
|
||||||
broken = dataclasses.replace(frame, bound_relations=(dataclasses.replace(relation, roles=stripped_roles),))
|
role for role in relation.roles if role.role != "delta_quantity"
|
||||||
|
)
|
||||||
|
broken = dataclasses.replace(
|
||||||
|
frame, bound_relations=(dataclasses.replace(relation, roles=stripped_roles),)
|
||||||
|
)
|
||||||
|
|
||||||
assessment = assess_unary_delta(broken)
|
assessment = assess_unary_delta(broken)
|
||||||
|
|
||||||
|
|
@ -243,12 +244,303 @@ def test_existing_proposal_first_families_do_not_gain_unary_delta_dispatch() ->
|
||||||
frame = build_problem_frame(problem_text)
|
frame = build_problem_frame(problem_text)
|
||||||
assert FAMILY_ID not in {proposal.family_id for proposal in frame.proposals}
|
assert FAMILY_ID not in {proposal.family_id for proposal in frame.proposals}
|
||||||
assert CANDIDATE_ORGAN not in {
|
assert CANDIDATE_ORGAN not in {
|
||||||
assessment.candidate_organ
|
assessment.candidate_organ for assessment in assess_contracts(frame)
|
||||||
for assessment in assess_contracts(frame)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_unary_delta_path_does_not_import_legacy_semantic_state() -> None:
|
def test_missing_object_confuser_strict_isolation() -> None:
|
||||||
|
# Tom gained 3.
|
||||||
|
# Expected: no state_change.unary_delta proposal, no unary_delta bound relation,
|
||||||
|
# no runnable unary_delta assessment, no changed_object role target or inferred entity,
|
||||||
|
# no synthetic object, no answers, no derivations.
|
||||||
|
frame = build_problem_frame("Tom gained 3.")
|
||||||
|
assert FAMILY_ID not in {proposal.family_id for proposal in frame.proposals}
|
||||||
|
assert not any(r.relation_type == "unary_delta" for r in frame.bound_relations)
|
||||||
|
assert CANDIDATE_ORGAN not in {a.candidate_organ for a in assess_contracts(frame)}
|
||||||
|
# No changed_object inferred or borrowed
|
||||||
|
for relation in frame.bound_relations:
|
||||||
|
assert not any(role.role == "changed_object" for role in relation.roles)
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_quantity_confuser_strict_isolation() -> None:
|
||||||
|
# Tom gained apples.
|
||||||
|
# Expected: no runnable unary_delta, no unary_delta relation, no synthetic quantity
|
||||||
|
frame = build_problem_frame("Tom gained apples.")
|
||||||
|
assert not any(r.relation_type == "unary_delta" for r in frame.bound_relations)
|
||||||
|
assert not any(
|
||||||
|
a.candidate_organ == CANDIDATE_ORGAN and a.runnable
|
||||||
|
for a in assess_contracts(frame)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("problem_text", "cue", "direction", "qty_text", "obj_text"),
|
||||||
|
(
|
||||||
|
("Ana gained 3 marbles.", "gained", "increase", "3", "marbles"),
|
||||||
|
("The jar lost 2 cookies.", "lost", "decrease", "2", "cookies"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_positive_controls_strict_isolation(
|
||||||
|
problem_text: str,
|
||||||
|
cue: str,
|
||||||
|
direction: str,
|
||||||
|
qty_text: str,
|
||||||
|
obj_text: str,
|
||||||
|
) -> None:
|
||||||
|
frame = build_problem_frame(problem_text)
|
||||||
|
|
||||||
|
# Exactly one unary_delta proposal
|
||||||
|
unary_proposals = [p for p in frame.proposals if p.family_id == FAMILY_ID]
|
||||||
|
assert len(unary_proposals) == 1
|
||||||
|
proposal = unary_proposals[0]
|
||||||
|
|
||||||
|
# Proposal status exactly "proposed"
|
||||||
|
assert proposal.status == "proposed"
|
||||||
|
# Diagnostic-only / serving-disallowed posture preserved
|
||||||
|
assert proposal.diagnostic_only is True
|
||||||
|
assert proposal.serving_allowed is False
|
||||||
|
assert not hasattr(proposal, "runnable")
|
||||||
|
|
||||||
|
# Exactly one unary_delta relation
|
||||||
|
unary_relations = [
|
||||||
|
r for r in frame.bound_relations if r.relation_type == "unary_delta"
|
||||||
|
]
|
||||||
|
assert len(unary_relations) == 1
|
||||||
|
relation = unary_relations[0]
|
||||||
|
|
||||||
|
roles = {role.role: role for role in relation.roles}
|
||||||
|
assert set(roles) == {"action_cue", "delta_quantity", "changed_object", "direction"}
|
||||||
|
assert roles["direction"].target_id == direction
|
||||||
|
|
||||||
|
# Spans
|
||||||
|
cue_span = roles["action_cue"].evidence_spans[0]
|
||||||
|
qty_span = roles["delta_quantity"].evidence_spans[0]
|
||||||
|
obj_span = roles["changed_object"].evidence_spans[0]
|
||||||
|
|
||||||
|
assert cue_span.text == cue
|
||||||
|
assert qty_span.text == qty_text
|
||||||
|
assert obj_span.text == obj_text
|
||||||
|
|
||||||
|
# Exact span assertions
|
||||||
|
assert problem_text[cue_span.start : cue_span.end] == cue
|
||||||
|
assert problem_text[qty_span.start : qty_span.end] == qty_text
|
||||||
|
assert problem_text[obj_span.start : obj_span.end] == obj_text
|
||||||
|
|
||||||
|
# Runnable ContractAssessment only when role-complete
|
||||||
|
unary_assessments = [
|
||||||
|
a for a in assess_contracts(frame) if a.candidate_organ == CANDIDATE_ORGAN
|
||||||
|
]
|
||||||
|
assert len(unary_assessments) == 1
|
||||||
|
assessment = unary_assessments[0]
|
||||||
|
assert assessment.runnable is True
|
||||||
|
assert assessment.missing_bindings == ()
|
||||||
|
assert assessment.unresolved_hazards == ()
|
||||||
|
|
||||||
|
# No answer or derivation or serving path
|
||||||
|
assert not hasattr(frame, "answer")
|
||||||
|
assert not hasattr(frame, "derivation")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"problem_text",
|
||||||
|
(
|
||||||
|
"There are 12 apples.",
|
||||||
|
"Tom has 12 apples.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_static_quantity_possession_isolation(problem_text: str) -> None:
|
||||||
|
frame = build_problem_frame(problem_text)
|
||||||
|
|
||||||
|
# May exercise existing quantity_entity behavior if already authorized
|
||||||
|
# but must not emit unary_delta proposal, relation, or assessment
|
||||||
|
assert FAMILY_ID not in {p.family_id for p in frame.proposals}
|
||||||
|
assert not any(r.relation_type == "unary_delta" for r in frame.bound_relations)
|
||||||
|
assert CANDIDATE_ORGAN not in {a.candidate_organ for a in assess_contracts(frame)}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"problem_text",
|
||||||
|
(
|
||||||
|
"Tom gave Ana 3 apples.",
|
||||||
|
"Tom sent Ana 3 apples.",
|
||||||
|
"Tom received 3 apples.",
|
||||||
|
"Tom bought 3 apples.",
|
||||||
|
"Tom sold 3 apples.",
|
||||||
|
"Tom spent 3 dollars.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_transfer_isolation(problem_text: str) -> None:
|
||||||
|
frame = build_problem_frame(problem_text)
|
||||||
|
# no unary_delta proposal, no relation, no owner/source/target inference, no transfer semantics
|
||||||
|
assert FAMILY_ID not in {p.family_id for p in frame.proposals}
|
||||||
|
assert not any(r.relation_type == "unary_delta" for r in frame.bound_relations)
|
||||||
|
assert CANDIDATE_ORGAN not in {a.candidate_organ for a in assess_contracts(frame)}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"problem_text",
|
||||||
|
(
|
||||||
|
"Tom put 3 apples in the box.",
|
||||||
|
"Tom took 3 apples from the box.",
|
||||||
|
"Tom moved 3 apples into the basket.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_containment_movement_isolation(problem_text: str) -> None:
|
||||||
|
frame = build_problem_frame(problem_text)
|
||||||
|
# no unary_delta proposal, no relation, no container/source/target inference
|
||||||
|
assert FAMILY_ID not in {p.family_id for p in frame.proposals}
|
||||||
|
assert not any(r.relation_type == "unary_delta" for r in frame.bound_relations)
|
||||||
|
assert CANDIDATE_ORGAN not in {a.candidate_organ for a in assess_contracts(frame)}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"problem_text",
|
||||||
|
(
|
||||||
|
"Tom had 12 apples and now has 15 apples.",
|
||||||
|
"Tom had 12 apples and lost 3.",
|
||||||
|
"There are 12 apples. Tom lost 3.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_before_after_isolation(problem_text: str) -> None:
|
||||||
|
frame = build_problem_frame(problem_text)
|
||||||
|
# no broad before/after state inference, no cross-sentence repair,
|
||||||
|
# no object borrowed from prior sentence/clause, no arithmetic closure, no answer
|
||||||
|
assert FAMILY_ID not in {p.family_id for p in frame.proposals}
|
||||||
|
assert not any(r.relation_type == "unary_delta" for r in frame.bound_relations)
|
||||||
|
assert CANDIDATE_ORGAN not in {a.candidate_organ for a in assess_contracts(frame)}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"problem_text",
|
||||||
|
(
|
||||||
|
"Tom gained 3 more apples than Ana.",
|
||||||
|
"3 apples per child were gained.",
|
||||||
|
"20% of apples were gained.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_comparison_rate_percent_isolation(problem_text: str) -> None:
|
||||||
|
frame = build_problem_frame(problem_text)
|
||||||
|
# no unary_delta relation, no rate/percent/comparison collapse into unary_delta
|
||||||
|
assert FAMILY_ID not in {p.family_id for p in frame.proposals}
|
||||||
|
assert not any(r.relation_type == "unary_delta" for r in frame.bound_relations)
|
||||||
|
assert CANDIDATE_ORGAN not in {a.candidate_organ for a in assess_contracts(frame)}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"problem_text",
|
||||||
|
(
|
||||||
|
"Tom did not gain 3 apples.",
|
||||||
|
"Tom may have gained 3 apples.",
|
||||||
|
"3 apples were gained by Tom.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_negation_modality_passive_isolation(problem_text: str) -> None:
|
||||||
|
frame = build_problem_frame(problem_text)
|
||||||
|
# no runnable unary_delta, no relation, no answer
|
||||||
|
assert FAMILY_ID not in {p.family_id for p in frame.proposals}
|
||||||
|
assert not any(
|
||||||
|
a.candidate_organ == CANDIDATE_ORGAN and a.runnable
|
||||||
|
for a in assess_contracts(frame)
|
||||||
|
)
|
||||||
|
assert not any(r.relation_type == "unary_delta" for r in frame.bound_relations)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"problem_text",
|
||||||
|
(
|
||||||
|
"Tom gained 3 apples and lost 2 apples.",
|
||||||
|
"Tom gained 3 and 4 apples.",
|
||||||
|
"Tom gained 3 apples and oranges.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_multi_event_ambiguity_isolation(problem_text: str) -> None:
|
||||||
|
frame = build_problem_frame(problem_text)
|
||||||
|
# no single unary_delta closure, no arbitrary first-match success,
|
||||||
|
# no silent ambiguity normalization
|
||||||
|
assert FAMILY_ID not in {p.family_id for p in frame.proposals}
|
||||||
|
assert not any(
|
||||||
|
a.candidate_organ == CANDIDATE_ORGAN and a.runnable
|
||||||
|
for a in assess_contracts(frame)
|
||||||
|
)
|
||||||
|
# Ensure there isn't exactly one relation resolving both
|
||||||
|
unary_relations = [
|
||||||
|
r for r in frame.bound_relations if r.relation_type == "unary_delta"
|
||||||
|
]
|
||||||
|
assert len(unary_relations) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_unit_object_conflict_isolation() -> None:
|
||||||
|
# Tom gained 3 degrees.
|
||||||
|
# Expected: no runnable unary_delta (blocked by unit/quantity kind unresolved),
|
||||||
|
# no object widening, no answer
|
||||||
|
frame = build_problem_frame("Tom gained 3 degrees.")
|
||||||
|
assert not any(
|
||||||
|
a.candidate_organ == CANDIDATE_ORGAN and a.runnable
|
||||||
|
for a in assess_contracts(frame)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_exact_span_assertions() -> None:
|
||||||
|
frame = build_problem_frame("Ana gained 3 marbles.")
|
||||||
|
proposal = [p for p in frame.proposals if p.family_id == FAMILY_ID][0]
|
||||||
|
relation = [r for r in frame.bound_relations if r.relation_type == "unary_delta"][0]
|
||||||
|
assessment = [
|
||||||
|
a for a in assess_contracts(frame) if a.candidate_organ == CANDIDATE_ORGAN
|
||||||
|
][0]
|
||||||
|
|
||||||
|
# Positives: problem_text[span.start:span.end] == span.text
|
||||||
|
for span in (
|
||||||
|
*proposal.evidence_spans,
|
||||||
|
*relation.evidence_spans,
|
||||||
|
*assessment.evidence_spans,
|
||||||
|
):
|
||||||
|
assert frame.problem_text[span.start : span.end] == span.text
|
||||||
|
|
||||||
|
# Negatives: no synthetic span, no widened span, no normalized span
|
||||||
|
# We check that there are no spans that do not exactly slice the text or are empty/synthesized
|
||||||
|
for span in (*proposal.evidence_spans, *relation.evidence_spans):
|
||||||
|
assert span.start >= 0
|
||||||
|
assert span.end <= len(frame.problem_text)
|
||||||
|
assert span.end > span.start
|
||||||
|
assert span.text.strip() == span.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_authority_boundary_checks() -> None:
|
||||||
|
# proposals remain hypotheses
|
||||||
|
# proposal object does not carry runnable/refused authority
|
||||||
|
frame = build_problem_frame("Ana gained 3 marbles.")
|
||||||
|
proposal = [p for p in frame.proposals if p.family_id == FAMILY_ID][0]
|
||||||
|
|
||||||
|
assert not hasattr(proposal, "runnable")
|
||||||
|
assert not hasattr(proposal, "verdict")
|
||||||
|
assert not hasattr(proposal, "missing_bindings")
|
||||||
|
assert not hasattr(proposal, "unresolved_hazards")
|
||||||
|
|
||||||
|
# ContractAssessment is sole runnable/refused authority
|
||||||
|
assessment = [
|
||||||
|
a for a in assess_contracts(frame) if a.candidate_organ == CANDIDATE_ORGAN
|
||||||
|
][0]
|
||||||
|
assert hasattr(assessment, "runnable")
|
||||||
|
assert hasattr(assessment, "missing_bindings")
|
||||||
|
assert hasattr(assessment, "unresolved_hazards")
|
||||||
|
|
||||||
|
# proposal-free frames do not dispatch unary_delta
|
||||||
|
proposal_free = dataclasses.replace(frame, proposals=())
|
||||||
|
assert CANDIDATE_ORGAN not in {
|
||||||
|
a.candidate_organ for a in assess_contracts(proposal_free)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_forbidden_import_coupling_checks() -> None:
|
||||||
|
# Add guard coverage that unary_delta does not import/call:
|
||||||
|
# generate.derivation.*, generate.math_candidate_graph, legacy semantic-state ledger, runtime serving paths
|
||||||
|
forbidden = [
|
||||||
|
"generate.derivation",
|
||||||
|
"generate.math_candidate_graph",
|
||||||
|
"generate.derivation.state",
|
||||||
|
"chat.runtime",
|
||||||
|
"runtime.serving",
|
||||||
|
]
|
||||||
for module in (
|
for module in (
|
||||||
"generate.problem_frame_builder",
|
"generate.problem_frame_builder",
|
||||||
"generate.problem_frame_contracts",
|
"generate.problem_frame_contracts",
|
||||||
|
|
@ -260,4 +552,11 @@ def test_unary_delta_path_does_not_import_legacy_semantic_state() -> None:
|
||||||
for node in ast.walk(tree)
|
for node in ast.walk(tree)
|
||||||
if isinstance(node, ast.ImportFrom) and node.module is not None
|
if isinstance(node, ast.ImportFrom) and node.module is not None
|
||||||
}
|
}
|
||||||
assert "generate.derivation.state" not in imported
|
for name in ast.walk(tree):
|
||||||
|
if isinstance(name, ast.Import):
|
||||||
|
for alias in name.names:
|
||||||
|
imported.add(alias.name)
|
||||||
|
for forbid in forbidden:
|
||||||
|
assert forbid not in imported
|
||||||
|
for imp in imported:
|
||||||
|
assert not imp.startswith(forbid)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue