Increment 2 for ADR-0252 Track B. Extends Increment 1's structure-mapping slice with pure-family canonicals S2–S4, overlapping-waves selector, and a structure-mapping-owned pure-S1 text extract that recovers four real holdout cases the serving reader misses (0148, 0228, 0234, 0441). Bar results (command-backed via scripts/measure_trackb_inc2.py): - generalization ratio S1 = 9.0 (9 holdout cases / 1 template) - coverage gain organ 5 → trackb 9, wrong=0 - selector routes S1/S2/S3; refuses empty; surface≠structure Off-serving: no organ retirement, serving reader untouched. S2–S4 holdout ratios are 0 (parser frontier). S3/S4 emit refuses at multi-register scope without weakening the three-gate wrong=0 path. [Verification]: Smoke suite passed locally (~132s, 176 passed); trackb unit tests 35 passed; measure_trackb_inc2 all modes green.
450 lines
16 KiB
Python
450 lines
16 KiB
Python
"""Symbolic relational structure-mapper — S1–S4 pure-family gates (Track B).
|
||
|
||
Aligns a problem's role-predicate graph to a canonical by **relational role
|
||
matching**. Surface attributes become the binding; they do not drive the match.
|
||
|
||
Blind: never reads gold structure labels. Refuse is a first-class outcome.
|
||
Each family has pure-family gates (exact role/part-set matching; refuse
|
||
supersets and stray predicates) so none can rebuild a wrong subproblem.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
from typing import Mapping
|
||
|
||
from generate.structure_mapping.canonicals import (
|
||
S1_CANONICAL,
|
||
S2_CANONICAL,
|
||
S3_CANONICAL,
|
||
S4_CANONICAL,
|
||
)
|
||
from generate.structure_mapping.role_predicate import RoleGraph
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class StructureMapResult:
|
||
"""Successful map onto one canonical with variable binding."""
|
||
|
||
structure_id: str
|
||
"""Matched canonical id (library key from the match, not a gold label)."""
|
||
|
||
binding: Mapping[str, object]
|
||
matched_predicates: tuple[str, ...]
|
||
"""Predicate kinds that participated (for right-reason audits)."""
|
||
|
||
# Back-compat with Increment 1 callers
|
||
@property
|
||
def maps_to_s1(self) -> bool:
|
||
return self.structure_id == "S1"
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class StructureMapRefuse:
|
||
"""No admissible alignment for the requested family (or all families)."""
|
||
|
||
reason: str
|
||
structure_id: str | None = None
|
||
|
||
|
||
def map_to_s1(role_graph: RoleGraph) -> StructureMapResult | StructureMapRefuse:
|
||
"""Map ``role_graph`` to the S1 canonical or refuse.
|
||
|
||
Pure-S1 gate:
|
||
1. Exactly one multiplicative ``compare``; no transfer/rate/compare_add.
|
||
2. Exactly one ``total`` whose part-set equals ``{a, b}``.
|
||
3. Seed ``contain`` on ``a`` **or** on ``b`` (not both, not a third).
|
||
If only ``b`` is seeded, ``a_value = b_value / k`` (inverted seed).
|
||
4. No extra contains.
|
||
"""
|
||
if not isinstance(role_graph, RoleGraph):
|
||
raise TypeError(
|
||
f"map_to_s1 expects RoleGraph, got {type(role_graph).__name__}"
|
||
)
|
||
_ = S1_CANONICAL.structure_id
|
||
|
||
compares = role_graph.of_kind("compare")
|
||
totals = role_graph.of_kind("total")
|
||
contains = role_graph.of_kind("contain")
|
||
transfers = role_graph.of_kind("transfer")
|
||
rates = role_graph.of_kind("rate")
|
||
compare_adds = role_graph.of_kind("compare_add")
|
||
|
||
if transfers:
|
||
return StructureMapRefuse(reason="non_s1_has_transfer", structure_id="S1")
|
||
if rates:
|
||
return StructureMapRefuse(reason="non_s1_has_rate", structure_id="S1")
|
||
if compare_adds:
|
||
return StructureMapRefuse(reason="non_s1_has_compare_add", structure_id="S1")
|
||
if len(compares) != 1:
|
||
return StructureMapRefuse(
|
||
reason=f"compare_count_not_one:{len(compares)}", structure_id="S1"
|
||
)
|
||
if not totals:
|
||
return StructureMapRefuse(reason="missing_total_query", structure_id="S1")
|
||
|
||
compare = compares[0]
|
||
b_term, a_term, k_term = compare.args
|
||
if a_term.kind != "entity" or b_term.kind != "entity":
|
||
return StructureMapRefuse(reason="compare_args_not_entities", structure_id="S1")
|
||
if k_term.kind != "quantity" or k_term.value is None:
|
||
return StructureMapRefuse(reason="compare_factor_missing", structure_id="S1")
|
||
if k_term.value <= 0:
|
||
return StructureMapRefuse(reason="compare_factor_non_positive", structure_id="S1")
|
||
|
||
a_name = a_term.name
|
||
b_name = b_term.name
|
||
if a_name == b_name:
|
||
return StructureMapRefuse(reason="compare_self_reference", structure_id="S1")
|
||
|
||
k = float(k_term.value)
|
||
expected_parts = frozenset({a_name, b_name})
|
||
|
||
exact_total = False
|
||
for total in totals:
|
||
part_names = frozenset(
|
||
t.name for t in total.args[:-1] if t.kind == "entity"
|
||
)
|
||
if part_names == expected_parts:
|
||
exact_total = True
|
||
break
|
||
if not exact_total:
|
||
any_cover = any(
|
||
a_name in (t.name for t in total.args[:-1] if t.kind == "entity")
|
||
and b_name in (t.name for t in total.args[:-1] if t.kind == "entity")
|
||
for total in totals
|
||
)
|
||
if any_cover:
|
||
return StructureMapRefuse(
|
||
reason="total_parts_not_exactly_a_b", structure_id="S1"
|
||
)
|
||
return StructureMapRefuse(
|
||
reason="total_does_not_cover_compare_roles", structure_id="S1"
|
||
)
|
||
|
||
a_value: float | None = None
|
||
b_value: float | None = None
|
||
unit: str | None = None
|
||
for c in contains:
|
||
ent, qty = c.args
|
||
if ent.kind != "entity" or qty.kind != "quantity":
|
||
continue
|
||
if qty.value is None:
|
||
continue
|
||
if ent.name == a_name:
|
||
a_value = float(qty.value)
|
||
unit = qty.unit
|
||
continue
|
||
if ent.name == b_name:
|
||
b_value = float(qty.value)
|
||
if unit is None:
|
||
unit = qty.unit
|
||
continue
|
||
return StructureMapRefuse(
|
||
reason=f"extra_contain_not_pure_s1:{ent.name}", structure_id="S1"
|
||
)
|
||
|
||
if a_value is not None and b_value is not None:
|
||
# Both seeded: only admissible if consistent with b = k×a.
|
||
expected_b = a_value * k
|
||
if abs(expected_b - b_value) > 1e-6 * max(1.0, abs(expected_b)):
|
||
return StructureMapRefuse(
|
||
reason="dual_seed_inconsistent_with_compare", structure_id="S1"
|
||
)
|
||
# Prefer a-seeded pure form.
|
||
b_value = None
|
||
|
||
if a_value is None and b_value is None:
|
||
return StructureMapRefuse(
|
||
reason="missing_contain_on_reference_a", structure_id="S1"
|
||
)
|
||
|
||
if a_value is None:
|
||
# Inverted seed: b known, a = b/k.
|
||
assert b_value is not None
|
||
if abs(k) < 1e-12:
|
||
return StructureMapRefuse(reason="compare_factor_near_zero", structure_id="S1")
|
||
a_value = float(b_value) / k
|
||
seed_mode = "b_seeded"
|
||
else:
|
||
seed_mode = "a_seeded"
|
||
|
||
binding: dict[str, object] = {
|
||
"a": a_name,
|
||
"b": b_name,
|
||
"k": k,
|
||
"a_value": a_value,
|
||
"unit": unit or "",
|
||
"canonical_id": S1_CANONICAL.structure_id,
|
||
"seed_mode": seed_mode,
|
||
}
|
||
return StructureMapResult(
|
||
structure_id="S1",
|
||
binding=binding,
|
||
matched_predicates=("contain", "compare", "total"),
|
||
)
|
||
|
||
|
||
def map_to_s2(role_graph: RoleGraph) -> StructureMapResult | StructureMapRefuse:
|
||
"""Pure S2: exactly one transfer, contains on src+dst only, query one endpoint.
|
||
|
||
No compare / compare_add / rate / total (total would be multi-entity sum;
|
||
S2 queries a single entity after transfer).
|
||
"""
|
||
if not isinstance(role_graph, RoleGraph):
|
||
raise TypeError(
|
||
f"map_to_s2 expects RoleGraph, got {type(role_graph).__name__}"
|
||
)
|
||
_ = S2_CANONICAL.structure_id
|
||
|
||
transfers = role_graph.of_kind("transfer")
|
||
contains = role_graph.of_kind("contain")
|
||
compares = role_graph.of_kind("compare")
|
||
compare_adds = role_graph.of_kind("compare_add")
|
||
rates = role_graph.of_kind("rate")
|
||
totals = role_graph.of_kind("total")
|
||
|
||
if compares:
|
||
return StructureMapRefuse(reason="non_s2_has_compare", structure_id="S2")
|
||
if compare_adds:
|
||
return StructureMapRefuse(reason="non_s2_has_compare_add", structure_id="S2")
|
||
if rates:
|
||
return StructureMapRefuse(reason="non_s2_has_rate", structure_id="S2")
|
||
if totals:
|
||
return StructureMapRefuse(reason="non_s2_has_total", structure_id="S2")
|
||
if len(transfers) != 1:
|
||
return StructureMapRefuse(
|
||
reason=f"transfer_count_not_one:{len(transfers)}", structure_id="S2"
|
||
)
|
||
|
||
xfer = transfers[0]
|
||
src_t, dst_t, qty_t = xfer.args
|
||
if src_t.kind != "entity" or dst_t.kind != "entity":
|
||
return StructureMapRefuse(reason="transfer_endpoints_not_entities", structure_id="S2")
|
||
if qty_t.kind != "quantity" or qty_t.value is None:
|
||
return StructureMapRefuse(reason="transfer_qty_missing", structure_id="S2")
|
||
if qty_t.value < 0:
|
||
return StructureMapRefuse(reason="transfer_qty_negative", structure_id="S2")
|
||
|
||
src, dst = src_t.name, dst_t.name
|
||
if src == dst:
|
||
return StructureMapRefuse(reason="transfer_self", structure_id="S2")
|
||
|
||
seeds: dict[str, tuple[float, str | None]] = {}
|
||
for c in contains:
|
||
ent, qty = c.args
|
||
if ent.kind != "entity" or qty.kind != "quantity" or qty.value is None:
|
||
continue
|
||
if ent.name not in (src, dst):
|
||
return StructureMapRefuse(
|
||
reason=f"extra_contain_not_pure_s2:{ent.name}", structure_id="S2"
|
||
)
|
||
seeds[ent.name] = (float(qty.value), qty.unit)
|
||
|
||
if src not in seeds or dst not in seeds:
|
||
return StructureMapRefuse(reason="missing_src_or_dst_seed", structure_id="S2")
|
||
|
||
# Query entity: inferred from a single unknown-less graph later; for pure
|
||
# role match we require the binding to name both post-transfer values.
|
||
# Default query = dst (most common "how many does Y have?" after receive).
|
||
# Callers that know the unknown entity may override via binding later.
|
||
src_val, unit = seeds[src]
|
||
dst_val, unit_d = seeds[dst]
|
||
unit = unit or unit_d or ""
|
||
xfer_qty = float(qty_t.value)
|
||
|
||
binding: dict[str, object] = {
|
||
"src": src,
|
||
"dst": dst,
|
||
"xfer_qty": xfer_qty,
|
||
"src_value": src_val,
|
||
"dst_value": dst_val,
|
||
"unit": unit,
|
||
"query_entity": dst, # default; solve may override from original graph
|
||
"canonical_id": S2_CANONICAL.structure_id,
|
||
}
|
||
return StructureMapResult(
|
||
structure_id="S2",
|
||
binding=binding,
|
||
matched_predicates=("contain", "contain", "transfer"),
|
||
)
|
||
|
||
|
||
def map_to_s3(role_graph: RoleGraph) -> StructureMapResult | StructureMapRefuse:
|
||
"""Pure S3: exactly one rate predicate; no transfer/compare/total pollution."""
|
||
if not isinstance(role_graph, RoleGraph):
|
||
raise TypeError(
|
||
f"map_to_s3 expects RoleGraph, got {type(role_graph).__name__}"
|
||
)
|
||
_ = S3_CANONICAL.structure_id
|
||
|
||
rates = role_graph.of_kind("rate")
|
||
if len(rates) != 1:
|
||
return StructureMapRefuse(
|
||
reason=f"rate_count_not_one:{len(rates)}", structure_id="S3"
|
||
)
|
||
if role_graph.of_kind("transfer"):
|
||
return StructureMapRefuse(reason="non_s3_has_transfer", structure_id="S3")
|
||
if role_graph.of_kind("compare"):
|
||
return StructureMapRefuse(reason="non_s3_has_compare", structure_id="S3")
|
||
if role_graph.of_kind("compare_add"):
|
||
return StructureMapRefuse(reason="non_s3_has_compare_add", structure_id="S3")
|
||
if role_graph.of_kind("total"):
|
||
return StructureMapRefuse(reason="non_s3_has_total", structure_id="S3")
|
||
|
||
rate = rates[0]
|
||
per, count, total_slot = rate.args
|
||
if per.kind != "quantity" or per.value is None:
|
||
return StructureMapRefuse(reason="rate_value_missing", structure_id="S3")
|
||
# count may be entity or quantity depending on converter
|
||
count_value: float | None = None
|
||
count_name = count.name
|
||
if count.kind == "quantity" and count.value is not None:
|
||
count_value = float(count.value)
|
||
# Look for contain on count entity for the duration/count seed
|
||
if count_value is None and count.kind == "entity":
|
||
for c in role_graph.of_kind("contain"):
|
||
ent, qty = c.args
|
||
if ent.kind == "entity" and ent.name == count.name and qty.value is not None:
|
||
count_value = float(qty.value)
|
||
break
|
||
if count_value is None:
|
||
return StructureMapRefuse(reason="rate_count_missing", structure_id="S3")
|
||
if per.value <= 0 or count_value <= 0:
|
||
return StructureMapRefuse(reason="rate_non_positive", structure_id="S3")
|
||
|
||
binding: dict[str, object] = {
|
||
"rate_value": float(per.value),
|
||
"count": count_value,
|
||
"count_name": count_name,
|
||
"unit": per.unit or (total_slot.unit if total_slot.kind == "quantity" else "") or "",
|
||
"canonical_id": S3_CANONICAL.structure_id,
|
||
}
|
||
return StructureMapResult(
|
||
structure_id="S3",
|
||
binding=binding,
|
||
matched_predicates=("rate",),
|
||
)
|
||
|
||
|
||
def map_to_s4(role_graph: RoleGraph) -> StructureMapResult | StructureMapRefuse:
|
||
"""Pure S4: one compare_add, total over {a,b}, seed contain on a (or b)."""
|
||
if not isinstance(role_graph, RoleGraph):
|
||
raise TypeError(
|
||
f"map_to_s4 expects RoleGraph, got {type(role_graph).__name__}"
|
||
)
|
||
_ = S4_CANONICAL.structure_id
|
||
|
||
if role_graph.of_kind("transfer"):
|
||
return StructureMapRefuse(reason="non_s4_has_transfer", structure_id="S4")
|
||
if role_graph.of_kind("rate"):
|
||
return StructureMapRefuse(reason="non_s4_has_rate", structure_id="S4")
|
||
if role_graph.of_kind("compare"):
|
||
return StructureMapRefuse(reason="non_s4_has_multiplicative_compare", structure_id="S4")
|
||
|
||
adds = role_graph.of_kind("compare_add")
|
||
totals = role_graph.of_kind("total")
|
||
contains = role_graph.of_kind("contain")
|
||
if len(adds) != 1:
|
||
return StructureMapRefuse(
|
||
reason=f"compare_add_count_not_one:{len(adds)}", structure_id="S4"
|
||
)
|
||
if not totals:
|
||
return StructureMapRefuse(reason="missing_total_query", structure_id="S4")
|
||
|
||
cad = adds[0]
|
||
b_term, a_term, d_term = cad.args
|
||
if a_term.kind != "entity" or b_term.kind != "entity":
|
||
return StructureMapRefuse(reason="compare_add_args_not_entities", structure_id="S4")
|
||
if d_term.kind != "quantity" or d_term.value is None:
|
||
return StructureMapRefuse(reason="compare_add_delta_missing", structure_id="S4")
|
||
|
||
a_name, b_name = a_term.name, b_term.name
|
||
if a_name == b_name:
|
||
return StructureMapRefuse(reason="compare_add_self_reference", structure_id="S4")
|
||
delta = float(d_term.value)
|
||
expected_parts = frozenset({a_name, b_name})
|
||
|
||
if not any(
|
||
frozenset(t.name for t in total.args[:-1] if t.kind == "entity") == expected_parts
|
||
for total in totals
|
||
):
|
||
return StructureMapRefuse(reason="total_parts_not_exactly_a_b", structure_id="S4")
|
||
|
||
a_value: float | None = None
|
||
b_value: float | None = None
|
||
unit: str | None = None
|
||
for c in contains:
|
||
ent, qty = c.args
|
||
if ent.kind != "entity" or qty.kind != "quantity" or qty.value is None:
|
||
continue
|
||
if ent.name == a_name:
|
||
a_value = float(qty.value)
|
||
unit = qty.unit
|
||
elif ent.name == b_name:
|
||
b_value = float(qty.value)
|
||
if unit is None:
|
||
unit = qty.unit
|
||
else:
|
||
return StructureMapRefuse(
|
||
reason=f"extra_contain_not_pure_s4:{ent.name}", structure_id="S4"
|
||
)
|
||
|
||
if a_value is None and b_value is None:
|
||
return StructureMapRefuse(reason="missing_seed_for_s4", structure_id="S4")
|
||
if a_value is None:
|
||
assert b_value is not None
|
||
a_value = b_value - delta
|
||
seed_mode = "b_seeded"
|
||
elif b_value is None:
|
||
seed_mode = "a_seeded"
|
||
else:
|
||
if abs((a_value + delta) - b_value) > 1e-6 * max(1.0, abs(b_value)):
|
||
return StructureMapRefuse(
|
||
reason="dual_seed_inconsistent_with_compare_add", structure_id="S4"
|
||
)
|
||
seed_mode = "a_seeded"
|
||
|
||
binding: dict[str, object] = {
|
||
"a": a_name,
|
||
"b": b_name,
|
||
"delta": delta,
|
||
"a_value": a_value,
|
||
"unit": unit or "",
|
||
"canonical_id": S4_CANONICAL.structure_id,
|
||
"seed_mode": seed_mode,
|
||
}
|
||
return StructureMapResult(
|
||
structure_id="S4",
|
||
binding=binding,
|
||
matched_predicates=("contain", "compare_add", "total"),
|
||
)
|
||
|
||
|
||
def binding_roles_only(binding: Mapping[str, object]) -> dict[str, object]:
|
||
"""Return the solve-relevant subset of a binding (no label fields)."""
|
||
keys = (
|
||
"a",
|
||
"b",
|
||
"k",
|
||
"a_value",
|
||
"unit",
|
||
"src",
|
||
"dst",
|
||
"xfer_qty",
|
||
"src_value",
|
||
"dst_value",
|
||
"query_entity",
|
||
"rate_value",
|
||
"count",
|
||
"delta",
|
||
)
|
||
return {k: binding[k] for k in keys if k in binding}
|
||
|
||
|
||
MAPPERS = {
|
||
"S1": map_to_s1,
|
||
"S2": map_to_s2,
|
||
"S3": map_to_s3,
|
||
"S4": map_to_s4,
|
||
}
|