From 8383b852d0af98e1869a5f6818ca4e80e7b72e12 Mon Sep 17 00:00:00 2001 From: Shay Date: Sat, 4 Jul 2026 15:11:50 -0700 Subject: [PATCH] Lane 3: Geometric evaluation for fraction_decrease --- chat/pack_resolver.py | 96 +++++++++++++++++++++ generate/derivation/fraction_decrease.py | 101 ++++++++++------------- generate/math_candidate_graph.py | 25 ++++-- generate/problem_frame_contracts.py | 5 +- packs/common/schema/sense.schema.json | 17 ++++ 5 files changed, 177 insertions(+), 67 deletions(-) diff --git a/chat/pack_resolver.py b/chat/pack_resolver.py index f862b808..288dccee 100644 --- a/chat/pack_resolver.py +++ b/chat/pack_resolver.py @@ -251,3 +251,99 @@ def clear_resolver_cache() -> None: """ _pack_lexicon_for.cache_clear() _pack_glosses_for.cache_clear() + _pack_lemma_to_entry_id.cache_clear() + _pack_senses_for.cache_clear() + + +@lru_cache(maxsize=16) +def _pack_lemma_to_entry_id(pack_id: str) -> dict[str, str]: + """Return ``{lemma_lower: entry_id}`` from the pack's lexicon.jsonl.""" + lexicon_path = _PACK_ROOT / pack_id / "lexicon.jsonl" + if not lexicon_path.exists(): + return {} + out: dict[str, str] = {} + for line in lexicon_path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + if not isinstance(entry, dict): + continue + lemma = entry.get("lemma") or entry.get("surface") + if not lemma or not isinstance(lemma, str): + continue + entry_id = entry.get("entry_id") + if isinstance(entry_id, str) and entry_id.strip(): + out[lemma.lower()] = entry_id.strip() + return out + + +@lru_cache(maxsize=16) +def _pack_senses_for(pack_id: str) -> dict[str, dict]: + """Return ``{lemma_id: geometric_signature}`` from the pack's optional + ``senses.jsonl`` file. Also maps ``lemma`` as a fallback key. + """ + path = _PACK_ROOT / pack_id / "senses.jsonl" + if not path.exists(): + return {} + out: dict[str, dict] = {} + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + if not isinstance(entry, dict): + continue + + geometric_signature = entry.get("geometric_signature") + if not isinstance(geometric_signature, dict): + continue + + lemma_id = entry.get("lemma_id") + if isinstance(lemma_id, str) and lemma_id.strip(): + out[lemma_id.strip()] = geometric_signature + + # Optional fallback for direct lemma matching if lemma is present + lemma = entry.get("lemma") + if isinstance(lemma, str) and lemma.strip(): + out[lemma.strip().lower()] = geometric_signature + + return out + + +def resolve_geometric_signature( + lemma: str, + pack_ids: tuple[str, ...] = DEFAULT_RESOLVABLE_PACK_IDS, +) -> tuple[str, dict] | None: + """Return ``(pack_id, geometric_signature)`` for the first pack in + *pack_ids* that BOTH (a) ratifies *lemma* in its ``lexicon.jsonl`` + AND (b) carries a geometric_signature for it in ``senses.jsonl``. + """ + if not lemma or not isinstance(lemma, str): + return None + key = lemma.strip().lower() + if not key: + return None + for pack_id in pack_ids: + lex = _pack_lexicon_for(pack_id) + if key not in lex: + continue + + lemma_ids = _pack_lemma_to_entry_id(pack_id) + entry_id = lemma_ids.get(key) + + senses = _pack_senses_for(pack_id) + + if entry_id and entry_id in senses: + return (pack_id, senses[entry_id]) + + if key in senses: + return (pack_id, senses[key]) + + return None diff --git a/generate/derivation/fraction_decrease.py b/generate/derivation/fraction_decrease.py index fbc21893..9128c37a 100644 --- a/generate/derivation/fraction_decrease.py +++ b/generate/derivation/fraction_decrease.py @@ -10,7 +10,7 @@ the **decrease by** delta (not the final value). Narrow organ — not a generic affine equation parser, not ``N/M more than`` affine surfaces (0010-class), not percent-decrease. Promotion requires: -- exactly one ``decrease to N/M of`` forecast (no ``more than`` fraction affine); +- exactly one ``decrease to N/M of forecast`` (no ``more than`` fraction affine); - question binds ``decrease`` + ``by`` (delta target, not final-value question); - exactly one explicit current/base quantity with a unit; - hazard refusal (percent, goal language, multiple decrease-to fractions). @@ -20,21 +20,20 @@ Deterministic; sealed module (no ``chat/`` import). from __future__ import annotations -import re from typing import Final +import numpy as np + +from algebra.cga import embed_point, read_scalar_e1 +from algebra.versor import versor_apply from generate.derivation.clauses import segment_clauses from generate.derivation.extract import extract_quantities from generate.derivation.model import GroundedDerivation, Quantity, Step from generate.derivation.target import _question_clause from generate.derivation.verify import Resolution, SelfVerification, select_self_verified +from generate.problem_frame_contracts import ContractAssessment from generate.math_roundtrip import _tokens -_DECREASE_TO_FRACTION_RE: Final[re.Pattern[str]] = re.compile( - r"decrease\s+to\s+(\d+)\s*/\s*(\d+)\s+of", - re.IGNORECASE, -) -_EXTRA_FRACTION_RE: Final[re.Pattern[str]] = re.compile(r"\d+\s*/\s*\d+") _GOAL_INTENT: Final[frozenset[str]] = frozenset( {"want", "wants", "wanted", "need", "needs", "hoping", "hopes", "plans", "aims", "goal"} ) @@ -55,21 +54,6 @@ def _asks_final_value(question_clause: str) -> bool: return bool(_FINAL_VALUE_CUES & tokens) or "temperature" in tokens -def _target_fraction(problem_text: str) -> tuple[int, int, str] | None: - matches = list(_DECREASE_TO_FRACTION_RE.finditer(problem_text)) - if len(matches) != 1: - return None - num_s, den_s = matches[0].group(1), matches[0].group(2) - try: - num = int(num_s) - den = int(den_s) - except ValueError: - return None - if num <= 0 or den <= 0 or num >= den: - return None - return num, den, f"{num_s}/{den_s}" - - def _has_hazard_surface(problem_text: str, question_clause: str) -> bool: text_tokens = _tokens(problem_text) question_tokens = _tokens(question_clause) @@ -81,18 +65,15 @@ def _has_hazard_surface(problem_text: str, question_clause: str) -> bool: return True if _asks_final_value(question_clause): return True - fractions = _EXTRA_FRACTION_RE.findall(problem_text) - target = _target_fraction(problem_text) - if target is None: - return True - _, _, token = target - extra = [f for f in fractions if f.replace(" ", "") != token.replace(" ", "")] - return len(extra) > 0 + return False -def _current_base_quantity(problem_text: str, question_clause: str) -> Quantity | None: +def _current_base_quantity( + problem_text: str, question_clause: str, contract: ContractAssessment +) -> Quantity | None: + source_text = contract.bindings[0].source_span.text for clause in segment_clauses(problem_text): - if _DECREASE_TO_FRACTION_RE.search(clause): + if source_text in clause: continue tokens = _tokens(clause) if "current" not in tokens and "now" not in tokens: @@ -103,7 +84,7 @@ def _current_base_quantity(problem_text: str, question_clause: str) -> Quantity for clause in segment_clauses(problem_text): if clause == question_clause: continue - if _DECREASE_TO_FRACTION_RE.search(clause): + if source_text in clause: continue quantities = [q for q in extract_quantities(clause) if q.unit] if len(quantities) == 1: @@ -111,33 +92,38 @@ def _current_base_quantity(problem_text: str, question_clause: str) -> Quantity return None -def build_fraction_decrease(problem_text: str) -> GroundedDerivation | None: - """Construct ``base × (1 − N/M)`` decrease delta, or ``None``.""" +def build_fraction_decrease( + problem_text: str, contract: ContractAssessment +) -> GroundedDerivation | None: + """Construct mathematical decrease delta, or ``None``.""" + if not contract.is_runnable or not contract.bindings: + return None + question_clause = _question_clause(problem_text) if not _asks_decrease_delta(question_clause): return None if _has_hazard_surface(problem_text, question_clause): return None - fraction = _target_fraction(problem_text) - base = _current_base_quantity(problem_text, question_clause) - if fraction is None or base is None: + base = _current_base_quantity(problem_text, question_clause, contract) + if base is None: return None - num, den, source_token = fraction - delta_factor = 1.0 - (num / den) - if delta_factor <= 0: - return None + D = contract.bindings[0].geometric_payload + v = base.value + Q = embed_point([v, 0, 0], dtype=np.float64) + Q_new = versor_apply(D, Q) + new_value = read_scalar_e1(Q_new) return GroundedDerivation( start=base, steps=( Step( - op="multiply", + op="subtract", operand=Quantity( - value=delta_factor, - unit="", - source_token=source_token, + value=new_value, + unit=base.unit, + source_token=contract.bindings[0].source_span.text, ), cue="decrease", ), @@ -146,34 +132,35 @@ def build_fraction_decrease(problem_text: str) -> GroundedDerivation | None: def _self_verifies_fraction_decrease( - derivation: GroundedDerivation, problem_text: str + derivation: GroundedDerivation, problem_text: str, contract: ContractAssessment ) -> SelfVerification: from generate.derivation.verify import _base_reasons tokens = _tokens(problem_text) reasons = list(_base_reasons(derivation, tokens)) - fraction = _target_fraction(problem_text) - if fraction is None: + if not contract.bindings: reasons.append("missing decrease-to fraction forecast") else: - _, _, token = fraction - if token not in problem_text.replace(" ", ""): + token = contract.bindings[0].source_span.text + if token not in problem_text: reasons.append(f"fraction token {token!r} not grounded in text") - base = _current_base_quantity(problem_text, _question_clause(problem_text)) + base = _current_base_quantity(problem_text, _question_clause(problem_text), contract) if base is None: reasons.append("missing explicit current/base quantity") return SelfVerification(verified=not reasons, reasons=tuple(reasons)) -def compose_fraction_decrease(problem_text: str) -> Resolution | None: +def compose_fraction_decrease( + problem_text: str, contract: ContractAssessment +) -> Resolution | None: """Gate the typed fraction-decrease chain through self-verification.""" - derivation = build_fraction_decrease(problem_text) + derivation = build_fraction_decrease(problem_text, contract) if derivation is None: return None - if not _self_verifies_fraction_decrease(derivation, problem_text).verified: + if not _self_verifies_fraction_decrease(derivation, problem_text, contract).verified: return None return Resolution( answer=derivation.answer, @@ -182,6 +169,8 @@ def compose_fraction_decrease(problem_text: str) -> Resolution | None: ) -def resolve_promotable_fraction_decrease(problem_text: str) -> Resolution | None: +def resolve_promotable_fraction_decrease( + problem_text: str, contract: ContractAssessment +) -> Resolution | None: """Serving promotion bridge (Gate A2k).""" - return compose_fraction_decrease(problem_text) + return compose_fraction_decrease(problem_text, contract) diff --git a/generate/math_candidate_graph.py b/generate/math_candidate_graph.py index 961a0ff9..a68158ac 100644 --- a/generate/math_candidate_graph.py +++ b/generate/math_candidate_graph.py @@ -793,16 +793,23 @@ def parse_and_solve(text: str, *, sealed: bool = False) -> CandidateGraphResult: from generate.derivation.fraction_decrease import ( resolve_promotable_fraction_decrease, ) + from generate.problem_frame_builder import build_problem_frame + from generate.problem_frame_contracts import assess_geometric_proposals - fraction_decrease_resolution = resolve_promotable_fraction_decrease(text) - if fraction_decrease_resolution is not None: - return CandidateGraphResult( - answer=fraction_decrease_resolution.answer, - selected_graph=None, - refusal_reason=None, - branches_enumerated=1, - branches_admissible=1, - ) + frame = build_problem_frame(text) + contracts = assess_geometric_proposals(frame) + contract = next((c for c in contracts if c.runnable and c.candidate_organ == "fraction_decrease"), None) + + if contract is not None: + fraction_decrease_resolution = resolve_promotable_fraction_decrease(text, contract) + if fraction_decrease_resolution is not None: + return CandidateGraphResult( + answer=fraction_decrease_resolution.answer, + selected_graph=None, + refusal_reason=None, + branches_enumerated=1, + branches_admissible=1, + ) # Gate A2l — equal half-split percent partition (Sprint 8 R6 lift). from generate.derivation.percent_partition import ( diff --git a/generate/problem_frame_contracts.py b/generate/problem_frame_contracts.py index 7e09cdfe..31b84108 100644 --- a/generate/problem_frame_contracts.py +++ b/generate/problem_frame_contracts.py @@ -25,6 +25,7 @@ from generate.construction_affordances import ( ) from generate.kernel_facts import BoundRelation, GroundedMention, SourceSpan from generate.problem_frame import ProblemFrame +from chat.pack_resolver import resolve_geometric_signature # --------------------------------------------------------------------------- @@ -888,7 +889,7 @@ def assess_geometric_proposals(frame: ProblemFrame) -> list[ContractAssessment]: continue bindings = [] - for span in prop.spans: + for span in prop.evidence_spans: signature = resolve_geometric_signature(span.text) if signature: _, geom = signature @@ -914,7 +915,7 @@ def assess_geometric_proposals(frame: ProblemFrame) -> list[ContractAssessment]: candidate_organ=candidate_organ, runnable=runnable, explanation="Assessed via geometric algebra.", - evidence_spans=tuple(prop.spans) + evidence_spans=prop.evidence_spans ) assessments.append(assessment) diff --git a/packs/common/schema/sense.schema.json b/packs/common/schema/sense.schema.json index e0d3647a..68ed733b 100644 --- a/packs/common/schema/sense.schema.json +++ b/packs/common/schema/sense.schema.json @@ -23,6 +23,23 @@ "rank": { "type": ["number", "integer"], "description": "Priority rank among senses for the same lemma. 1 is most primary." + }, + "geometric_signature": { + "type": "object", + "properties": { + "operator_type": { + "enum": ["static_blade", "parameterized_dilation", "parameterized_translation"] + }, + "blade_values": { + "type": "array", + "items": { "type": "number" } + }, + "generator_binding": { + "type": "string" + } + }, + "required": ["operator_type", "blade_values"], + "additionalProperties": false } }, "additionalProperties": false