Spaces:
Runtime error
Runtime error
V9.0.1: SymPy Pipeline Hardening (Aggressive Sanitizer, Hebrew & Arrow Stripping)
Browse files- domain/math_validator.py +8 -10
- orchestrator.py +4 -6
- smart_solver.py +20 -4
- utils/math_utils.py +35 -0
- visuals.py +7 -5
domain/math_validator.py
CHANGED
|
@@ -7,6 +7,7 @@ import asyncio
|
|
| 7 |
from typing import Tuple, List, Optional
|
| 8 |
import sympy
|
| 9 |
from sympy.parsing.sympy_parser import parse_expr
|
|
|
|
| 10 |
|
| 11 |
logger = logging.getLogger(__name__)
|
| 12 |
|
|
@@ -205,18 +206,13 @@ class MathPolygraph:
|
|
| 205 |
@staticmethod
|
| 206 |
async def _check_segment(raw_segment: str, step_id) -> Tuple[bool, str]:
|
| 207 |
"""Internal helper to validate a single extracted math segment."""
|
| 208 |
-
#
|
| 209 |
-
|
| 210 |
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
# Split by all equalities and check each segment (e.g. x=y=5 -> check x, y, 5)
|
| 214 |
-
# This bypasses SymPy's inability to parse "=" and prevents split() unpacking errors.
|
| 215 |
-
parts_to_check = [p.strip() for p in raw_segment.split('=') if p.strip()]
|
| 216 |
-
else:
|
| 217 |
-
parts_to_check = [raw_segment]
|
| 218 |
|
| 219 |
-
for part in
|
| 220 |
sympy_str = _latex_to_sympy_str(part)
|
| 221 |
if not sympy_str or sympy_str in ('', '-', '()', '( )'):
|
| 222 |
continue
|
|
@@ -225,6 +221,8 @@ class MathPolygraph:
|
|
| 225 |
# Run with timeout to prevent ReDoS or complex simplification hangs
|
| 226 |
status = await asyncio.to_thread(MathPolygraph._sympify_with_timeout, sympy_str)
|
| 227 |
if status is False:
|
|
|
|
|
|
|
| 228 |
return False, f"SYMPY_PARSE_ERROR:step_{step_id}"
|
| 229 |
elif status is None:
|
| 230 |
# Timeout is treated as a soft warning for now
|
|
|
|
| 7 |
from typing import Tuple, List, Optional
|
| 8 |
import sympy
|
| 9 |
from sympy.parsing.sympy_parser import parse_expr
|
| 10 |
+
from utils.math_utils import aggressive_sympy_sanitizer
|
| 11 |
|
| 12 |
logger = logging.getLogger(__name__)
|
| 13 |
|
|
|
|
| 206 |
@staticmethod
|
| 207 |
async def _check_segment(raw_segment: str, step_id) -> Tuple[bool, str]:
|
| 208 |
"""Internal helper to validate a single extracted math segment."""
|
| 209 |
+
# V9.0.1: Use Aggressive Sanitizer to handle Hebrew, Arrows, and Comma-splitting
|
| 210 |
+
sanitized_parts = aggressive_sympy_sanitizer(raw_segment)
|
| 211 |
|
| 212 |
+
if not sanitized_parts:
|
| 213 |
+
return True, ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
|
| 215 |
+
for part in sanitized_parts:
|
| 216 |
sympy_str = _latex_to_sympy_str(part)
|
| 217 |
if not sympy_str or sympy_str in ('', '-', '()', '( )'):
|
| 218 |
continue
|
|
|
|
| 221 |
# Run with timeout to prevent ReDoS or complex simplification hangs
|
| 222 |
status = await asyncio.to_thread(MathPolygraph._sympify_with_timeout, sympy_str)
|
| 223 |
if status is False:
|
| 224 |
+
# Provide clearer error context
|
| 225 |
+
logger.warning(f"[V9.0.1] SymPy Parse Error on part '{part}' (from segment '{raw_segment}')")
|
| 226 |
return False, f"SYMPY_PARSE_ERROR:step_{step_id}"
|
| 227 |
elif status is None:
|
| 228 |
# Timeout is treated as a soft warning for now
|
orchestrator.py
CHANGED
|
@@ -9,7 +9,7 @@ from domain.math_validator import MathPolygraph # V1.0: SymPy Polygraph
|
|
| 9 |
from domain.processing_strategy import ProcessingStrategy
|
| 10 |
from domain.ontology import get_allowed_concepts, get_pedagogical_tag
|
| 11 |
from domain.math_normalizer import MathCanonicalizer
|
| 12 |
-
from utils.math_utils import sanitize_latex_for_sympy
|
| 13 |
from domain.curriculum_classifier import CurriculumClassifier
|
| 14 |
from domain.proposal_engine import ProposalEngine
|
| 15 |
from domain.risk_engine import CognitiveRiskEngine
|
|
@@ -58,12 +58,10 @@ def build_ast_metadata(math_input: str, category: str) -> dict:
|
|
| 58 |
estimated_complexity = 0.5
|
| 59 |
|
| 60 |
try:
|
| 61 |
-
parts =
|
| 62 |
free_syms = set()
|
| 63 |
-
for
|
| 64 |
try:
|
| 65 |
-
# V317.8: Clean LaTeX (colors/text) before validation
|
| 66 |
-
clean_part = sanitize_latex_for_sympy(part)
|
| 67 |
expr = sp.sympify(clean_part.replace('=', '-'), evaluate=False)
|
| 68 |
free_syms.update(expr.free_symbols)
|
| 69 |
except Exception as e:
|
|
@@ -80,7 +78,7 @@ def build_ast_metadata(math_input: str, category: str) -> dict:
|
|
| 80 |
# Build node registry for Planner (IDs → expressions)
|
| 81 |
ast_registry = {}
|
| 82 |
try:
|
| 83 |
-
parts =
|
| 84 |
for i, part in enumerate(parts):
|
| 85 |
ast_registry[f"ast_node_{i}"] = part
|
| 86 |
except Exception as e:
|
|
|
|
| 9 |
from domain.processing_strategy import ProcessingStrategy
|
| 10 |
from domain.ontology import get_allowed_concepts, get_pedagogical_tag
|
| 11 |
from domain.math_normalizer import MathCanonicalizer
|
| 12 |
+
from utils.math_utils import sanitize_latex_for_sympy, aggressive_sympy_sanitizer
|
| 13 |
from domain.curriculum_classifier import CurriculumClassifier
|
| 14 |
from domain.proposal_engine import ProposalEngine
|
| 15 |
from domain.risk_engine import CognitiveRiskEngine
|
|
|
|
| 58 |
estimated_complexity = 0.5
|
| 59 |
|
| 60 |
try:
|
| 61 |
+
parts = aggressive_sympy_sanitizer(math_input)
|
| 62 |
free_syms = set()
|
| 63 |
+
for clean_part in parts:
|
| 64 |
try:
|
|
|
|
|
|
|
| 65 |
expr = sp.sympify(clean_part.replace('=', '-'), evaluate=False)
|
| 66 |
free_syms.update(expr.free_symbols)
|
| 67 |
except Exception as e:
|
|
|
|
| 78 |
# Build node registry for Planner (IDs → expressions)
|
| 79 |
ast_registry = {}
|
| 80 |
try:
|
| 81 |
+
parts = aggressive_sympy_sanitizer(math_input)
|
| 82 |
for i, part in enumerate(parts):
|
| 83 |
ast_registry[f"ast_node_{i}"] = part
|
| 84 |
except Exception as e:
|
smart_solver.py
CHANGED
|
@@ -11,6 +11,8 @@ from dataclasses import dataclass
|
|
| 11 |
import copy
|
| 12 |
from domain.step_types import StepType, SignedStep
|
| 13 |
|
|
|
|
|
|
|
| 14 |
logger = logging.getLogger(__name__)
|
| 15 |
|
| 16 |
# ==================== V7.3: TYPED ACTION CONTRACT ====================
|
|
@@ -449,8 +451,19 @@ class StepValidator:
|
|
| 449 |
Deterministically verifies the algebraic equivalence between two steps.
|
| 450 |
"""
|
| 451 |
try:
|
| 452 |
-
|
| 453 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 454 |
|
| 455 |
# Use simplify to check equivalence
|
| 456 |
diff = simplify(expr_n - expr_n_plus_1)
|
|
@@ -481,12 +494,15 @@ class StepValidator:
|
|
| 481 |
and no hallucinations occurred via injected fake constants/variables.
|
| 482 |
"""
|
| 483 |
try:
|
| 484 |
-
|
|
|
|
|
|
|
| 485 |
initial_vars = set()
|
| 486 |
for ex in initial_exprs:
|
| 487 |
initial_vars.update(ex.free_symbols)
|
| 488 |
|
| 489 |
-
|
|
|
|
| 490 |
final_vars = set()
|
| 491 |
for ex in final_exprs:
|
| 492 |
final_vars.update(ex.free_symbols)
|
|
|
|
| 11 |
import copy
|
| 12 |
from domain.step_types import StepType, SignedStep
|
| 13 |
|
| 14 |
+
from utils.math_utils import aggressive_sympy_sanitizer
|
| 15 |
+
|
| 16 |
logger = logging.getLogger(__name__)
|
| 17 |
|
| 18 |
# ==================== V7.3: TYPED ACTION CONTRACT ====================
|
|
|
|
| 451 |
Deterministically verifies the algebraic equivalence between two steps.
|
| 452 |
"""
|
| 453 |
try:
|
| 454 |
+
# V9.0.1: Aggressive sanitization before comparison
|
| 455 |
+
# We take the first expression from the sanitizer if multiple are returned
|
| 456 |
+
parts_n = aggressive_sympy_sanitizer(str(step_n))
|
| 457 |
+
parts_n_plus_1 = aggressive_sympy_sanitizer(str(step_n_plus_1))
|
| 458 |
+
|
| 459 |
+
if not parts_n or not parts_n_plus_1:
|
| 460 |
+
return False
|
| 461 |
+
|
| 462 |
+
clean_n = parts_n[0].replace('=', '-')
|
| 463 |
+
clean_n_plus_1 = parts_n_plus_1[0].replace('=', '-')
|
| 464 |
+
|
| 465 |
+
expr_n = sympify(clean_n, evaluate=False)
|
| 466 |
+
expr_n_plus_1 = sympify(clean_n_plus_1, evaluate=False)
|
| 467 |
|
| 468 |
# Use simplify to check equivalence
|
| 469 |
diff = simplify(expr_n - expr_n_plus_1)
|
|
|
|
| 494 |
and no hallucinations occurred via injected fake constants/variables.
|
| 495 |
"""
|
| 496 |
try:
|
| 497 |
+
# V9.0.1: Aggressive sanitization
|
| 498 |
+
initial_parts = aggressive_sympy_sanitizer(str(initial_math))
|
| 499 |
+
initial_exprs = [sympify(str(p).replace('=', '-'), evaluate=False) for p in initial_parts]
|
| 500 |
initial_vars = set()
|
| 501 |
for ex in initial_exprs:
|
| 502 |
initial_vars.update(ex.free_symbols)
|
| 503 |
|
| 504 |
+
final_parts = aggressive_sympy_sanitizer(str(final_step))
|
| 505 |
+
final_exprs = [sympify(str(p).replace('=', '-'), evaluate=False) for p in final_parts]
|
| 506 |
final_vars = set()
|
| 507 |
for ex in final_exprs:
|
| 508 |
final_vars.update(ex.free_symbols)
|
utils/math_utils.py
CHANGED
|
@@ -21,3 +21,38 @@ def sanitize_latex_for_sympy(latex_str: str) -> str:
|
|
| 21 |
cleaned = cleaned.replace(r'\left', '').replace(r'\right', '')
|
| 22 |
|
| 23 |
return cleaned
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
cleaned = cleaned.replace(r'\left', '').replace(r'\right', '')
|
| 22 |
|
| 23 |
return cleaned
|
| 24 |
+
|
| 25 |
+
from typing import List
|
| 26 |
+
|
| 27 |
+
def aggressive_sympy_sanitizer(latex_str: str) -> List[str]:
|
| 28 |
+
"""
|
| 29 |
+
V9.0.1: Cleans dirty LaTeX generated by the LLM before feeding it to SymPy.
|
| 30 |
+
Splits comma-separated equations into a list of pure math strings.
|
| 31 |
+
"""
|
| 32 |
+
if not latex_str:
|
| 33 |
+
return []
|
| 34 |
+
|
| 35 |
+
s = latex_str
|
| 36 |
+
|
| 37 |
+
# 1. Strip Hebrew Content including surrounding parentheses (e.g. (לכן C))
|
| 38 |
+
# This prevents leaving behind empty shells like "( C)" which crash SymPy.
|
| 39 |
+
s = re.sub(r'\([^)]*[\u0590-\u05FF]+[^)]*\)', '', s)
|
| 40 |
+
s = re.sub(r'[\u0590-\u05FF]+', '', s)
|
| 41 |
+
|
| 42 |
+
# 2. Strip formatting tags (\text{} and \color{})
|
| 43 |
+
s = re.sub(r'\\text\{.*?\}', '', s)
|
| 44 |
+
s = re.sub(r'\\color\{.*?\}', '', s)
|
| 45 |
+
|
| 46 |
+
# 3. Replace Logical Arrows with Equals (to maintain equation structure)
|
| 47 |
+
arrows = [r'\Rightarrow', r'\implies', r'\iff', r'\rightarrow']
|
| 48 |
+
for arrow in arrows:
|
| 49 |
+
s = s.replace(arrow, '=')
|
| 50 |
+
|
| 51 |
+
# 4. Split multiple equations
|
| 52 |
+
# SymPy cannot parse "x=0, y=9" as a single AST node.
|
| 53 |
+
# We must split by comma and return a list of clean equations.
|
| 54 |
+
# We also run the existing sanitize_latex_for_sympy on each part.
|
| 55 |
+
expressions = [sanitize_latex_for_sympy(expr.strip()) for expr in s.split(',')]
|
| 56 |
+
|
| 57 |
+
# Filter out empty strings that might have resulted from the split/regex
|
| 58 |
+
return [e for e in expressions if e]
|
visuals.py
CHANGED
|
@@ -13,12 +13,14 @@ def clean_latex_for_sympy(latex_str):
|
|
| 13 |
cleaned = cleaned.replace(r'\ln', 'log')
|
| 14 |
return cleaned
|
| 15 |
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
expr_str = clean_latex_for_sympy(expr_str)
|
| 19 |
-
|
| 20 |
-
# 1. Strip Hebrew
|
| 21 |
expr_str = re.sub(r'[\u0590-\u05FF]', '', expr_str)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
# 2. Cut multiple equations
|
| 24 |
if r'\\' in expr_str:
|
|
|
|
| 13 |
cleaned = cleaned.replace(r'\ln', 'log')
|
| 14 |
return cleaned
|
| 15 |
|
| 16 |
+
# 1. Strip Hebrew Characters and parenthetical Hebrew explanations (V9.0.1)
|
| 17 |
+
expr_str = re.sub(r'\([^)]*[\u0590-\u05FF]+[^)]*\)', '', expr_str)
|
|
|
|
|
|
|
|
|
|
| 18 |
expr_str = re.sub(r'[\u0590-\u05FF]', '', expr_str)
|
| 19 |
+
|
| 20 |
+
# 1.1 Replace Logical Arrows with Equals (V9.0.1)
|
| 21 |
+
arrows = [r'\Rightarrow', r'\implies', r'\iff', r'\rightarrow']
|
| 22 |
+
for arrow in arrows:
|
| 23 |
+
expr_str = expr_str.replace(arrow, '=')
|
| 24 |
|
| 25 |
# 2. Cut multiple equations
|
| 26 |
if r'\\' in expr_str:
|