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.
201 lines
7.5 KiB
Python
201 lines
7.5 KiB
Python
"""Deterministic MathProblemGraph → role-predicate graph converter.
|
||
|
||
Pure function of the typed surface graph. No structure labels. No geometry.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
from typing import Iterable
|
||
|
||
from generate.math_problem_graph import (
|
||
Comparison,
|
||
MathProblemGraph,
|
||
Quantity,
|
||
Rate,
|
||
)
|
||
from generate.structure_mapping.role_predicate import (
|
||
RoleGraph,
|
||
RolePredicate,
|
||
RoleTerm,
|
||
)
|
||
|
||
|
||
def graph_to_role_graph(graph: MathProblemGraph) -> RoleGraph:
|
||
"""Convert a parsed :class:`MathProblemGraph` into a :class:`RoleGraph`.
|
||
|
||
Mapping rules (load-bearing):
|
||
- ``initial_state`` → ``contain(entity, qty)``
|
||
- ``compare_multiplicative`` → ``compare(actor, reference, factor)``
|
||
(actor is k× reference — S1 role order ``compare(b, a, k)``)
|
||
- ``compare_additive`` (direction more/less) → ``compare_add(actor, reference, delta)``
|
||
- ``transfer`` → ``transfer(src, dst, qty)``
|
||
- ``apply_rate`` → ``rate`` with rate value / denominator-as-count placeholder
|
||
- ``unknown.entity is None`` → ``total`` over entities that hold ``unknown.unit``
|
||
(or all entities when unit carriers are incomplete)
|
||
- other ops (add/subtract/multiply/…) emit no role predicates in this slice
|
||
(they are attribute-level or deferred families)
|
||
|
||
Returns a sorted, frozen role graph. Raises nothing on partial coverage —
|
||
empty predicate lists are valid (mapper will refuse).
|
||
"""
|
||
if not isinstance(graph, MathProblemGraph):
|
||
raise TypeError(
|
||
f"graph_to_role_graph expects MathProblemGraph, got {type(graph).__name__}"
|
||
)
|
||
|
||
preds: list[RolePredicate] = []
|
||
|
||
for poss in graph.initial_state:
|
||
preds.append(
|
||
RolePredicate(
|
||
kind="contain",
|
||
args=(
|
||
RoleTerm(kind="entity", name=poss.entity),
|
||
RoleTerm(
|
||
kind="quantity",
|
||
name=f"q:{poss.entity}:{poss.quantity.unit}",
|
||
value=float(poss.quantity.value),
|
||
unit=poss.quantity.unit,
|
||
),
|
||
),
|
||
)
|
||
)
|
||
|
||
for op in graph.operations:
|
||
if op.kind == "compare_multiplicative":
|
||
assert isinstance(op.operand, Comparison)
|
||
factor = float(op.operand.factor) if op.operand.factor is not None else None
|
||
if factor is None:
|
||
continue
|
||
preds.append(
|
||
RolePredicate(
|
||
kind="compare",
|
||
args=(
|
||
RoleTerm(kind="entity", name=op.actor), # b
|
||
RoleTerm(kind="entity", name=op.operand.reference_actor), # a
|
||
RoleTerm(
|
||
kind="quantity",
|
||
name="factor",
|
||
value=factor,
|
||
unit=None,
|
||
), # k
|
||
),
|
||
)
|
||
)
|
||
elif op.kind == "compare_additive":
|
||
assert isinstance(op.operand, Comparison)
|
||
if op.operand.delta is None:
|
||
continue
|
||
# delta is a Quantity on the Comparison (ADR-0123).
|
||
delta_qty = op.operand.delta
|
||
if not isinstance(delta_qty, Quantity):
|
||
continue
|
||
delta = float(delta_qty.value)
|
||
# direction "fewer" means actor is below reference → flip sign so
|
||
# compare_add always means b = a + delta with delta signed.
|
||
if op.operand.direction == "fewer":
|
||
delta = -delta
|
||
preds.append(
|
||
RolePredicate(
|
||
kind="compare_add",
|
||
args=(
|
||
RoleTerm(kind="entity", name=op.actor), # b
|
||
RoleTerm(kind="entity", name=op.operand.reference_actor), # a
|
||
RoleTerm(
|
||
kind="quantity",
|
||
name="delta",
|
||
value=delta,
|
||
unit=delta_qty.unit,
|
||
),
|
||
),
|
||
)
|
||
)
|
||
elif op.kind == "transfer":
|
||
if not isinstance(op.operand, Quantity) or op.target is None:
|
||
continue
|
||
preds.append(
|
||
RolePredicate(
|
||
kind="transfer",
|
||
args=(
|
||
RoleTerm(kind="entity", name=op.actor),
|
||
RoleTerm(kind="entity", name=op.target),
|
||
RoleTerm(
|
||
kind="quantity",
|
||
name=f"xfer:{op.actor}:{op.target}",
|
||
value=float(op.operand.value),
|
||
unit=op.operand.unit,
|
||
),
|
||
),
|
||
)
|
||
)
|
||
elif op.kind == "apply_rate":
|
||
if not isinstance(op.operand, Rate):
|
||
continue
|
||
preds.append(
|
||
RolePredicate(
|
||
kind="rate",
|
||
args=(
|
||
RoleTerm(
|
||
kind="quantity",
|
||
name="rate_value",
|
||
value=float(op.operand.value),
|
||
unit=op.operand.numerator_unit,
|
||
),
|
||
RoleTerm(kind="entity", name=op.actor),
|
||
RoleTerm(
|
||
kind="quantity",
|
||
name="rate_total_slot",
|
||
value=0.0,
|
||
unit=op.operand.numerator_unit,
|
||
),
|
||
),
|
||
)
|
||
)
|
||
|
||
# Total query: unknown over all entities (sum) — S1 family signature.
|
||
if graph.unknown.entity is None:
|
||
unit = graph.unknown.unit
|
||
holders = _entities_with_unit(graph, unit)
|
||
if not holders:
|
||
holders = list(graph.entities)
|
||
part_terms = tuple(RoleTerm(kind="entity", name=e) for e in holders)
|
||
sum_term = RoleTerm(
|
||
kind="quantity",
|
||
name="sum_query",
|
||
value=0.0, # unknown; placeholder attribute
|
||
unit=unit,
|
||
)
|
||
preds.append(RolePredicate(kind="total", args=part_terms + (sum_term,)))
|
||
|
||
ordered = tuple(_sort_predicates(preds))
|
||
digest = hashlib.sha256(graph.canonical_bytes()).hexdigest()
|
||
return RoleGraph(predicates=ordered, source_graph_hash=digest)
|
||
|
||
|
||
def _entities_with_unit(graph: MathProblemGraph, unit: str) -> list[str]:
|
||
seen: list[str] = []
|
||
for poss in graph.initial_state:
|
||
if poss.quantity.unit == unit and poss.entity not in seen:
|
||
seen.append(poss.entity)
|
||
for op in graph.operations:
|
||
if op.kind in ("compare_multiplicative", "compare_additive") and isinstance(
|
||
op.operand, Comparison
|
||
):
|
||
# compare-defined entity inherits the reference unit; include actor
|
||
if op.actor not in seen:
|
||
seen.append(op.actor)
|
||
if op.operand.reference_actor not in seen:
|
||
seen.append(op.operand.reference_actor)
|
||
return seen
|
||
|
||
|
||
def _sort_predicates(preds: Iterable[RolePredicate]) -> list[RolePredicate]:
|
||
def key(p: RolePredicate) -> tuple:
|
||
arg_key = tuple(
|
||
(a.kind, a.name, a.value if a.value is not None else 0.0, a.unit or "")
|
||
for a in p.args
|
||
)
|
||
return (p.kind, arg_key)
|
||
|
||
return sorted(preds, key=key)
|