core/generate/structure_mapping/mapper.py
Shay e5643454d9 feat(trackb): S1–S4 symbolic SME, selector, pure-S1 coverage gain
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.
2026-07-19 19:49:07 -07:00

450 lines
16 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.

"""Symbolic relational structure-mapper — S1S4 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,
}