Spaces:
Runtime error
Runtime error
fix(v2.0): correct circle center priority — diameter_lines intersection wins over chord midpoint [16/16 tests pass]
Browse files- backend/tests/test_pipeline.py +108 -44
- backend/universal_verifier.py +207 -163
backend/tests/test_pipeline.py
CHANGED
|
@@ -1,25 +1,20 @@
|
|
| 1 |
# backend/tests/test_pipeline.py
|
| 2 |
-
# BuddyMath V2.0 — Acceptance Tests
|
| 3 |
#
|
| 4 |
-
#
|
| 5 |
-
#
|
| 6 |
-
#
|
| 7 |
-
# Task 4: Pin block appears in log before LLM call; circle problem → M=(3,5).
|
| 8 |
-
# Task 5: All 4 pipeline stages logged per question.
|
| 9 |
#
|
| 10 |
# Run with: python -m pytest backend/tests/test_pipeline.py -v
|
| 11 |
# ─────────────────────────────────────────────────────────────────────────
|
| 12 |
|
| 13 |
from __future__ import annotations
|
| 14 |
|
| 15 |
-
import json
|
| 16 |
import logging
|
| 17 |
import sys
|
| 18 |
import os
|
| 19 |
import unittest
|
| 20 |
-
from unittest.mock import MagicMock, patch
|
| 21 |
|
| 22 |
-
# Add server root to path
|
| 23 |
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
|
| 24 |
|
| 25 |
from backend.math_schema import MathProblemSchema
|
|
@@ -33,12 +28,12 @@ pinner = VariablePinner()
|
|
| 33 |
|
| 34 |
|
| 35 |
# ============================================================
|
| 36 |
-
# Task 3: Verifier robustness — always returns valid schema
|
| 37 |
# ============================================================
|
| 38 |
class TestVerifierRobustness(unittest.TestCase):
|
| 39 |
|
| 40 |
def test_circle_insufficient_data(self):
|
| 41 |
-
"""
|
| 42 |
schema = MathProblemSchema(
|
| 43 |
problem_type="geometry",
|
| 44 |
sub_type="circle",
|
|
@@ -47,11 +42,17 @@ class TestVerifierRobustness(unittest.TestCase):
|
|
| 47 |
constraints=["AD is a diameter"],
|
| 48 |
)
|
| 49 |
result = verifier.verify(schema)
|
| 50 |
-
self.assertIn(result.verification_status,
|
|
|
|
| 51 |
self.assertIsInstance(result.warnings, list)
|
| 52 |
|
| 53 |
-
def
|
| 54 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
schema = MathProblemSchema(
|
| 56 |
problem_type="geometry",
|
| 57 |
sub_type="circle",
|
|
@@ -68,7 +69,6 @@ class TestVerifierRobustness(unittest.TestCase):
|
|
| 68 |
self.assertAlmostEqual(result.verified.get("radius"), 4.0, places=4)
|
| 69 |
|
| 70 |
def test_algebra_insufficient_data(self):
|
| 71 |
-
"""Verifier handles null equation gracefully."""
|
| 72 |
schema = MathProblemSchema(
|
| 73 |
problem_type="algebra",
|
| 74 |
sub_type="quadratic",
|
|
@@ -80,7 +80,6 @@ class TestVerifierRobustness(unittest.TestCase):
|
|
| 80 |
self.assertEqual(result.verification_status, "insufficient_data")
|
| 81 |
|
| 82 |
def test_algebra_quadratic(self):
|
| 83 |
-
"""x^2 - 5x + 6 = 0 → roots must be [2, 3]."""
|
| 84 |
schema = MathProblemSchema(
|
| 85 |
problem_type="algebra",
|
| 86 |
sub_type="quadratic",
|
|
@@ -95,7 +94,6 @@ class TestVerifierRobustness(unittest.TestCase):
|
|
| 95 |
self.assertIn("3", solutions)
|
| 96 |
|
| 97 |
def test_probability_sum_to_one(self):
|
| 98 |
-
"""Sum check: [0.2, 0.3, 0.5] = 1 → verified."""
|
| 99 |
schema = MathProblemSchema(
|
| 100 |
problem_type="probability",
|
| 101 |
sub_type="basic",
|
|
@@ -107,7 +105,6 @@ class TestVerifierRobustness(unittest.TestCase):
|
|
| 107 |
self.assertEqual(result.verification_status, "verified")
|
| 108 |
|
| 109 |
def test_probability_contradiction(self):
|
| 110 |
-
"""Sum check: [0.5, 0.6] = 1.1 → contradiction."""
|
| 111 |
schema = MathProblemSchema(
|
| 112 |
problem_type="probability",
|
| 113 |
sub_type="basic",
|
|
@@ -119,7 +116,6 @@ class TestVerifierRobustness(unittest.TestCase):
|
|
| 119 |
self.assertEqual(result.verification_status, "contradiction")
|
| 120 |
|
| 121 |
def test_statistics_verified(self):
|
| 122 |
-
"""Mean of [2, 4, 6] = 4.0."""
|
| 123 |
schema = MathProblemSchema(
|
| 124 |
problem_type="statistics",
|
| 125 |
sub_type="descriptive",
|
|
@@ -131,7 +127,6 @@ class TestVerifierRobustness(unittest.TestCase):
|
|
| 131 |
self.assertAlmostEqual(result.verified.get("mean"), 4.0, places=4)
|
| 132 |
|
| 133 |
def test_calculus_derivative(self):
|
| 134 |
-
"""f(x) = x^2 → f'(3) = 6."""
|
| 135 |
schema = MathProblemSchema(
|
| 136 |
problem_type="calculus",
|
| 137 |
sub_type="differentiation",
|
|
@@ -144,7 +139,99 @@ class TestVerifierRobustness(unittest.TestCase):
|
|
| 144 |
|
| 145 |
|
| 146 |
# ============================================================
|
| 147 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
# ============================================================
|
| 149 |
class TestVariablePinner(unittest.TestCase):
|
| 150 |
|
|
@@ -188,29 +275,6 @@ class TestVariablePinner(unittest.TestCase):
|
|
| 188 |
block = pinner.build_pin_block(schema)
|
| 189 |
self.assertIn("CONTRADICTION", block)
|
| 190 |
|
| 191 |
-
def test_circle_exercise_m_equals_3_5(self):
|
| 192 |
-
"""
|
| 193 |
-
Acceptance test (Task 4 spec):
|
| 194 |
-
A(0,1), D(0,9) — diagram shows M on x-axis at (3,5), but that's wrong.
|
| 195 |
-
Correct center from diameter: M = midpoint(A,D) = (0,5).
|
| 196 |
-
The pin block must show (0.0, 5.0), not (3,5).
|
| 197 |
-
"""
|
| 198 |
-
schema = MathProblemSchema(
|
| 199 |
-
problem_type="geometry",
|
| 200 |
-
sub_type="circle",
|
| 201 |
-
given={"A": [0, 1], "D": [0, 9]},
|
| 202 |
-
find=["center of circle"],
|
| 203 |
-
constraints=["AD is a diameter", "M is the center of the circle"],
|
| 204 |
-
)
|
| 205 |
-
schema = verifier.verify(schema)
|
| 206 |
-
block = pinner.build_pin_block(schema)
|
| 207 |
-
center = schema.verified.get("center")
|
| 208 |
-
# center must be (0,5), NOT (3,5) — the algebraic truth beats visual intuition
|
| 209 |
-
self.assertIsNotNone(center)
|
| 210 |
-
self.assertAlmostEqual(center[0], 0.0, places=3)
|
| 211 |
-
self.assertAlmostEqual(center[1], 5.0, places=3)
|
| 212 |
-
self.assertIn("GROUND TRUTH", block)
|
| 213 |
-
|
| 214 |
|
| 215 |
if __name__ == "__main__":
|
| 216 |
unittest.main(verbosity=2)
|
|
|
|
| 1 |
# backend/tests/test_pipeline.py
|
| 2 |
+
# BuddyMath V2.0 — Acceptance Tests (v2 — Post Priority Fix)
|
| 3 |
#
|
| 4 |
+
# CRITICAL RULE (from review):
|
| 5 |
+
# M=(0,5) via midpoint of chord AD is the HALLUCINATION we are preventing.
|
| 6 |
+
# M=(3,5) via intersection of diameter lines is the ONLY correct answer.
|
|
|
|
|
|
|
| 7 |
#
|
| 8 |
# Run with: python -m pytest backend/tests/test_pipeline.py -v
|
| 9 |
# ─────────────────────────────────────────────────────────────────────────
|
| 10 |
|
| 11 |
from __future__ import annotations
|
| 12 |
|
|
|
|
| 13 |
import logging
|
| 14 |
import sys
|
| 15 |
import os
|
| 16 |
import unittest
|
|
|
|
| 17 |
|
|
|
|
| 18 |
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
|
| 19 |
|
| 20 |
from backend.math_schema import MathProblemSchema
|
|
|
|
| 28 |
|
| 29 |
|
| 30 |
# ============================================================
|
| 31 |
+
# Task 2/3: Verifier robustness — always returns valid schema
|
| 32 |
# ============================================================
|
| 33 |
class TestVerifierRobustness(unittest.TestCase):
|
| 34 |
|
| 35 |
def test_circle_insufficient_data(self):
|
| 36 |
+
"""All-null geometry → insufficient_data, never crashes."""
|
| 37 |
schema = MathProblemSchema(
|
| 38 |
problem_type="geometry",
|
| 39 |
sub_type="circle",
|
|
|
|
| 42 |
constraints=["AD is a diameter"],
|
| 43 |
)
|
| 44 |
result = verifier.verify(schema)
|
| 45 |
+
self.assertIn(result.verification_status,
|
| 46 |
+
["insufficient_data", "partial", "contradiction"])
|
| 47 |
self.assertIsInstance(result.warnings, list)
|
| 48 |
|
| 49 |
+
def test_circle_chord_midpoint_fallback(self):
|
| 50 |
+
"""
|
| 51 |
+
FALLBACK ONLY test: A(0,1), D(0,9) named as diameter chord (no lines given).
|
| 52 |
+
Result must be M=(0.0, 5.0).
|
| 53 |
+
NOTE: This is the CORRECT result for this specific input.
|
| 54 |
+
It is WRONG to use midpoint when diameter lines are also available.
|
| 55 |
+
"""
|
| 56 |
schema = MathProblemSchema(
|
| 57 |
problem_type="geometry",
|
| 58 |
sub_type="circle",
|
|
|
|
| 69 |
self.assertAlmostEqual(result.verified.get("radius"), 4.0, places=4)
|
| 70 |
|
| 71 |
def test_algebra_insufficient_data(self):
|
|
|
|
| 72 |
schema = MathProblemSchema(
|
| 73 |
problem_type="algebra",
|
| 74 |
sub_type="quadratic",
|
|
|
|
| 80 |
self.assertEqual(result.verification_status, "insufficient_data")
|
| 81 |
|
| 82 |
def test_algebra_quadratic(self):
|
|
|
|
| 83 |
schema = MathProblemSchema(
|
| 84 |
problem_type="algebra",
|
| 85 |
sub_type="quadratic",
|
|
|
|
| 94 |
self.assertIn("3", solutions)
|
| 95 |
|
| 96 |
def test_probability_sum_to_one(self):
|
|
|
|
| 97 |
schema = MathProblemSchema(
|
| 98 |
problem_type="probability",
|
| 99 |
sub_type="basic",
|
|
|
|
| 105 |
self.assertEqual(result.verification_status, "verified")
|
| 106 |
|
| 107 |
def test_probability_contradiction(self):
|
|
|
|
| 108 |
schema = MathProblemSchema(
|
| 109 |
problem_type="probability",
|
| 110 |
sub_type="basic",
|
|
|
|
| 116 |
self.assertEqual(result.verification_status, "contradiction")
|
| 117 |
|
| 118 |
def test_statistics_verified(self):
|
|
|
|
| 119 |
schema = MathProblemSchema(
|
| 120 |
problem_type="statistics",
|
| 121 |
sub_type="descriptive",
|
|
|
|
| 127 |
self.assertAlmostEqual(result.verified.get("mean"), 4.0, places=4)
|
| 128 |
|
| 129 |
def test_calculus_derivative(self):
|
|
|
|
| 130 |
schema = MathProblemSchema(
|
| 131 |
problem_type="calculus",
|
| 132 |
sub_type="differentiation",
|
|
|
|
| 139 |
|
| 140 |
|
| 141 |
# ============================================================
|
| 142 |
+
# THE KEY TEST — Task 4 "M=3,5" Acceptance Criterion
|
| 143 |
+
# ============================================================
|
| 144 |
+
class TestDiameterLineIntersection(unittest.TestCase):
|
| 145 |
+
"""
|
| 146 |
+
This is THE acceptance test.
|
| 147 |
+
|
| 148 |
+
Problem: A(0,1), D(0,9)
|
| 149 |
+
Two diameters: y = 4/3*x + 1 and y = -4/3*x + 9
|
| 150 |
+
Algebraic intersection: x=3, y=5 → M=(3,5)
|
| 151 |
+
|
| 152 |
+
❌ WRONG (hallucination): M=(0,5) — midpoint of chord AD
|
| 153 |
+
✅ CORRECT (algebraic): M=(3,5) — intersection of diameter lines
|
| 154 |
+
"""
|
| 155 |
+
|
| 156 |
+
def _make_schema(self) -> MathProblemSchema:
|
| 157 |
+
return MathProblemSchema(
|
| 158 |
+
problem_type="geometry",
|
| 159 |
+
sub_type="circle",
|
| 160 |
+
given={
|
| 161 |
+
"A": [0, 1],
|
| 162 |
+
"D": [0, 9],
|
| 163 |
+
"diameter_lines": ["y=4/3*x+1", "y=-4/3*x+9"],
|
| 164 |
+
},
|
| 165 |
+
find=["center of circle M"],
|
| 166 |
+
constraints=["AC and DB are diameters", "M is the center"],
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
def test_center_is_3_5_not_0_5(self):
|
| 170 |
+
"""
|
| 171 |
+
ACCEPTANCE TEST: M must be (3,5) — intersection of diameters.
|
| 172 |
+
M=(0,5) means the verifier used midpoint of chord AD. This is the hallucination.
|
| 173 |
+
"""
|
| 174 |
+
schema = self._make_schema()
|
| 175 |
+
result = verifier.verify(schema)
|
| 176 |
+
|
| 177 |
+
M = result.verified.get("M")
|
| 178 |
+
self.assertIsNotNone(M, "M must be in verified dict")
|
| 179 |
+
self.assertAlmostEqual(M[0], 3.0, places=4,
|
| 180 |
+
msg=f"M.x should be 3.0 (got {M[0]}) — midpoint fallback not allowed here")
|
| 181 |
+
self.assertAlmostEqual(M[1], 5.0, places=4,
|
| 182 |
+
msg=f"M.y should be 5.0 (got {M[1]})")
|
| 183 |
+
|
| 184 |
+
# Explicitly assert the WRONG answer doesn't appear
|
| 185 |
+
self.assertFalse(
|
| 186 |
+
abs(M[0] - 0.0) < 0.01 and abs(M[1] - 5.0) < 0.01,
|
| 187 |
+
"CRITICAL FAILURE: M=(0,5) detected — this is the chord midpoint hallucination!"
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
def test_center_method_is_line_intersection(self):
|
| 191 |
+
"""Verify the method field is set correctly."""
|
| 192 |
+
schema = self._make_schema()
|
| 193 |
+
result = verifier.verify(schema)
|
| 194 |
+
method = result.verified.get("center_method")
|
| 195 |
+
self.assertEqual(method, "diameter_line_intersection")
|
| 196 |
+
|
| 197 |
+
def test_verification_status_is_verified(self):
|
| 198 |
+
schema = self._make_schema()
|
| 199 |
+
result = verifier.verify(schema)
|
| 200 |
+
self.assertIn(result.verification_status, ["verified", "partial"])
|
| 201 |
+
|
| 202 |
+
def test_pin_block_contains_correct_center(self):
|
| 203 |
+
"""The PIN block must show M=(3.0, 5.0), not (0.0, 5.0)."""
|
| 204 |
+
schema = self._make_schema()
|
| 205 |
+
result = verifier.verify(schema)
|
| 206 |
+
block = pinner.build_pin_block(result)
|
| 207 |
+
|
| 208 |
+
self.assertIn("GROUND TRUTH", block)
|
| 209 |
+
self.assertIn("3.0", block, "Pin block must contain x=3.0 for center M")
|
| 210 |
+
self.assertIn("5.0", block, "Pin block must contain y=5.0 for center M")
|
| 211 |
+
|
| 212 |
+
def test_pin_block_aggressive_on_contradiction(self):
|
| 213 |
+
"""Pin block flags CONTRADICTION when center_method = chord_midpoint with lines available."""
|
| 214 |
+
schema = MathProblemSchema(
|
| 215 |
+
problem_type="geometry",
|
| 216 |
+
sub_type="circle",
|
| 217 |
+
given={
|
| 218 |
+
"A": [0, 1],
|
| 219 |
+
"D": [0, 9],
|
| 220 |
+
"diameter_lines": ["y=4/3*x+1", "y=-4/3*x+9"],
|
| 221 |
+
},
|
| 222 |
+
find=["center"],
|
| 223 |
+
constraints=["AC and DB are diameters"],
|
| 224 |
+
# Simulate the hallucination being injected from outside
|
| 225 |
+
verified={"M": (0.0, 5.0), "center_method": "diameter_chord_midpoint"},
|
| 226 |
+
verification_status="contradiction",
|
| 227 |
+
warnings=["CRITICAL CONTRADICTION: LLM assumed M(0,5) from chord, correct is M(3,5)"],
|
| 228 |
+
)
|
| 229 |
+
block = pinner.build_pin_block(schema)
|
| 230 |
+
self.assertIn("CONTRADICTION", block)
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
# ============================================================
|
| 234 |
+
# Task 4: Pin block structure
|
| 235 |
# ============================================================
|
| 236 |
class TestVariablePinner(unittest.TestCase):
|
| 237 |
|
|
|
|
| 275 |
block = pinner.build_pin_block(schema)
|
| 276 |
self.assertIn("CONTRADICTION", block)
|
| 277 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
|
| 279 |
if __name__ == "__main__":
|
| 280 |
unittest.main(verbosity=2)
|
backend/universal_verifier.py
CHANGED
|
@@ -1,17 +1,18 @@
|
|
| 1 |
# backend/universal_verifier.py
|
| 2 |
# BuddyMath V2.0 — Stage 2: SymPy-based Universal Verifier
|
| 3 |
#
|
| 4 |
-
#
|
| 5 |
-
# 1.
|
| 6 |
-
# 2.
|
| 7 |
-
#
|
| 8 |
-
#
|
|
|
|
| 9 |
# ─────────────────────────────────────────────────────────────────────────
|
| 10 |
|
| 11 |
from __future__ import annotations
|
| 12 |
|
| 13 |
import logging
|
| 14 |
-
from typing import Any,
|
| 15 |
|
| 16 |
import sympy as sp
|
| 17 |
|
|
@@ -20,7 +21,7 @@ from backend.math_schema import MathProblemSchema
|
|
| 20 |
logger = logging.getLogger(__name__)
|
| 21 |
|
| 22 |
|
| 23 |
-
# ──
|
| 24 |
def _to_point(value: Any) -> Optional[sp.Point2D]:
|
| 25 |
"""Convert [x, y] list → sympy.Point2D, or None if invalid."""
|
| 26 |
if isinstance(value, (list, tuple)) and len(value) == 2:
|
|
@@ -45,7 +46,6 @@ def _to_rational(value: Any) -> Optional[sp.Rational]:
|
|
| 45 |
class UniversalVerifier:
|
| 46 |
"""
|
| 47 |
Routes a MathProblemSchema to the appropriate domain verifier.
|
| 48 |
-
Returns the schema with `verified`, `verification_status`, and `warnings` filled in.
|
| 49 |
Guaranteed to never raise an exception to the caller.
|
| 50 |
"""
|
| 51 |
|
|
@@ -61,13 +61,12 @@ class UniversalVerifier:
|
|
| 61 |
def verify(self, schema: MathProblemSchema) -> MathProblemSchema:
|
| 62 |
"""Entry point. Always returns a valid schema."""
|
| 63 |
logger.info(
|
| 64 |
-
"🔍 [VERIFIER] Starting
|
| 65 |
schema.problem_type, schema.sub_type, schema.find,
|
| 66 |
)
|
| 67 |
-
|
| 68 |
null_keys = schema.null_fields()
|
| 69 |
if null_keys:
|
| 70 |
-
logger.warning("⚠️ [VERIFIER] Null fields
|
| 71 |
|
| 72 |
try:
|
| 73 |
method_name = self._DISPATCH.get(schema.problem_type)
|
|
@@ -94,25 +93,63 @@ class UniversalVerifier:
|
|
| 94 |
g = schema.given
|
| 95 |
verified = {}
|
| 96 |
sub = schema.sub_type.lower()
|
| 97 |
-
|
| 98 |
-
# ── Circle sub-type ────────────────────────────────────────────
|
| 99 |
if "circle" in sub:
|
| 100 |
self._verify_circle(schema, g, verified)
|
| 101 |
-
# ── Line / linear sub-type ─────────────────────────────────────
|
| 102 |
elif any(k in sub for k in ("line", "linear", "segment")):
|
| 103 |
self._verify_linear(schema, g, verified)
|
| 104 |
else:
|
| 105 |
schema.warnings.append(f"No geometry handler for sub_type='{schema.sub_type}'")
|
| 106 |
schema.verification_status = "partial"
|
| 107 |
-
return
|
| 108 |
-
|
| 109 |
-
schema.verified.update(verified)
|
| 110 |
|
| 111 |
-
def _verify_circle(self, schema, g, verified):
|
| 112 |
-
"""
|
|
|
|
|
|
|
|
|
|
| 113 |
import re as _re
|
| 114 |
|
| 115 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
points = {}
|
| 117 |
for k, v in g.items():
|
| 118 |
if v is None:
|
|
@@ -124,164 +161,128 @@ class UniversalVerifier:
|
|
| 124 |
radius_raw = g.get("radius")
|
| 125 |
radius = _to_rational(radius_raw)
|
| 126 |
|
| 127 |
-
|
| 128 |
-
diameter_pairs = []
|
| 129 |
has_diameter_constraint = False
|
| 130 |
for c in schema.constraints:
|
| 131 |
if "diameter" in c.lower():
|
| 132 |
has_diameter_constraint = True
|
| 133 |
-
# Match single uppercase letters OR short labels like "AB"
|
| 134 |
labels = _re.findall(r"\b([A-Za-z]{1,2})\b", c)
|
| 135 |
matched = [lbl for lbl in labels if lbl in points]
|
| 136 |
if len(matched) >= 2:
|
| 137 |
diameter_pairs.append((matched[0], matched[1]))
|
| 138 |
|
| 139 |
-
#
|
| 140 |
if not diameter_pairs and has_diameter_constraint and len(points) >= 2:
|
| 141 |
keys = list(points.keys())
|
| 142 |
diameter_pairs.append((keys[0], keys[1]))
|
| 143 |
logger.info(
|
| 144 |
-
"📐 [VERIFIER/circle]
|
| 145 |
keys[0], keys[1],
|
| 146 |
)
|
| 147 |
|
| 148 |
-
if not diameter_pairs
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
)
|
| 152 |
schema.verification_status = "insufficient_data"
|
| 153 |
return
|
| 154 |
|
| 155 |
-
# For each diameter pair: compute center and verify radius
|
| 156 |
for p1_label, p2_label in diameter_pairs:
|
| 157 |
-
p1 = points[p1_label]
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
(p1.y + p2.y) / 2,
|
| 162 |
-
)
|
| 163 |
computed_radius = p1.distance(p2) / 2
|
| 164 |
|
| 165 |
-
verified["center"] = (float(
|
|
|
|
| 166 |
verified["radius"] = float(computed_radius)
|
|
|
|
|
|
|
| 167 |
|
| 168 |
logger.info(
|
| 169 |
-
"📐 [VERIFIER/circle]
|
| 170 |
p1_label, p2_label, center, computed_radius,
|
| 171 |
)
|
| 172 |
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
logger.error("🚨 [VERIFIER/circle] %s", msg)
|
| 183 |
-
schema.verification_status = "contradiction"
|
| 184 |
-
return
|
| 185 |
|
| 186 |
-
# Verify any other points lie on the circle
|
| 187 |
-
circle = sp.Circle(center, computed_radius)
|
| 188 |
for label, pt in points.items():
|
| 189 |
if label in (p1_label, p2_label):
|
| 190 |
continue
|
| 191 |
-
|
|
|
|
|
|
|
| 192 |
verified[f"{label}_on_circle"] = True
|
| 193 |
-
logger.info("✅ [VERIFIER/circle] %s lies on circle", label)
|
| 194 |
else:
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
f"Point {label}={tuple(pt)} is NOT on circle "
|
| 198 |
-
f"(distance to center={dist:.4f}, radius={float(computed_radius):.4f})"
|
| 199 |
)
|
| 200 |
-
schema.warnings.append(msg)
|
| 201 |
-
logger.warning("⚠️ [VERIFIER/circle] %s", msg)
|
| 202 |
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
if null_keys:
|
| 206 |
-
schema.verification_status = "partial"
|
| 207 |
-
else:
|
| 208 |
-
schema.verification_status = "verified"
|
| 209 |
|
| 210 |
-
def _verify_linear(self, schema, g, verified):
|
| 211 |
-
"
|
| 212 |
-
schema.warnings.append(
|
| 213 |
-
"Linear geometry verifier: basic implementation — extend as needed"
|
| 214 |
-
)
|
| 215 |
schema.verification_status = "partial"
|
| 216 |
|
| 217 |
# ── ALGEBRA ──────────────────────────────────────────────────────────
|
| 218 |
def _verify_algebra(self, schema: MathProblemSchema) -> None:
|
| 219 |
g = schema.given
|
| 220 |
verified = schema.verified
|
| 221 |
-
|
| 222 |
-
# We expect an "equation" field and optionally "solution"
|
| 223 |
equation_str = g.get("equation")
|
| 224 |
if equation_str is None:
|
| 225 |
-
schema.warnings.append("Algebra: 'equation' is null
|
| 226 |
schema.verification_status = "insufficient_data"
|
| 227 |
return
|
| 228 |
-
|
| 229 |
try:
|
| 230 |
-
# Parse equation: support "lhs = rhs" or just expression = 0
|
| 231 |
if "=" in str(equation_str):
|
| 232 |
-
|
| 233 |
x = sp.Symbol("x")
|
| 234 |
-
|
| 235 |
-
rhs = sp.sympify(rhs_str.strip())
|
| 236 |
-
expr = lhs - rhs
|
| 237 |
else:
|
| 238 |
x = sp.Symbol("x")
|
| 239 |
expr = sp.sympify(str(equation_str))
|
| 240 |
-
|
| 241 |
solutions = sp.solve(expr, x)
|
| 242 |
verified["solutions"] = [str(s) for s in solutions]
|
| 243 |
logger.info("📐 [VERIFIER/algebra] Solutions: %s", solutions)
|
| 244 |
-
|
| 245 |
-
# If the problem gave us expected solutions, cross-check
|
| 246 |
given_solution = g.get("solution")
|
| 247 |
if given_solution is not None:
|
| 248 |
given_val = sp.Rational(given_solution)
|
| 249 |
if given_val not in solutions:
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
schema.verification_status = "contradiction"
|
| 254 |
return
|
| 255 |
-
|
| 256 |
-
null_keys = schema.null_fields()
|
| 257 |
-
schema.verification_status = "partial" if null_keys else "verified"
|
| 258 |
-
|
| 259 |
except Exception as e:
|
| 260 |
schema.warnings.append(f"Algebra verifier error: {e}")
|
| 261 |
schema.verification_status = "partial"
|
| 262 |
-
logger.error("❌ [VERIFIER/algebra] %s", e)
|
| 263 |
|
| 264 |
# ── PROBABILITY ───────────────────────────────────────────────────────
|
| 265 |
def _verify_probability(self, schema: MathProblemSchema) -> None:
|
| 266 |
g = schema.given
|
| 267 |
verified = schema.verified
|
| 268 |
-
|
| 269 |
-
# Expect a "probabilities" key: list of numeric values
|
| 270 |
probs = g.get("probabilities")
|
| 271 |
if probs is None:
|
| 272 |
-
schema.warnings.append("Probability: 'probabilities' is null
|
| 273 |
schema.verification_status = "insufficient_data"
|
| 274 |
return
|
| 275 |
-
|
| 276 |
try:
|
| 277 |
total = sum(sp.Rational(str(p)) for p in probs)
|
| 278 |
verified["sum"] = float(total)
|
| 279 |
-
logger.info("📐 [VERIFIER/probability] Sum of probabilities = %s", total)
|
| 280 |
-
|
| 281 |
if total != 1:
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
schema.verification_status = "contradiction"
|
| 286 |
else:
|
| 287 |
schema.verification_status = "verified"
|
|
@@ -292,37 +293,25 @@ class UniversalVerifier:
|
|
| 292 |
# ── STATISTICS ────────────────────────────────────────────────────────
|
| 293 |
def _verify_statistics(self, schema: MathProblemSchema) -> None:
|
| 294 |
import numpy as np
|
| 295 |
-
|
| 296 |
g = schema.given
|
| 297 |
verified = schema.verified
|
| 298 |
-
|
| 299 |
data = g.get("data")
|
| 300 |
if data is None:
|
| 301 |
-
schema.warnings.append("Statistics: 'data' is null
|
| 302 |
schema.verification_status = "insufficient_data"
|
| 303 |
return
|
| 304 |
-
|
| 305 |
try:
|
| 306 |
arr = np.array([float(x) for x in data])
|
| 307 |
verified["mean"] = float(np.mean(arr))
|
| 308 |
-
verified["std"] = float(np.std(arr, ddof=0))
|
| 309 |
-
logger.info(
|
| 310 |
-
"📐 [VERIFIER/statistics] mean=%.4f, std=%.4f",
|
| 311 |
-
verified["mean"], verified["std"],
|
| 312 |
-
)
|
| 313 |
-
|
| 314 |
-
# Cross-check if given mean/std
|
| 315 |
for key in ("mean", "std"):
|
| 316 |
given_val = g.get(key)
|
| 317 |
-
if given_val is not None:
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
schema.verification_status = "contradiction"
|
| 324 |
-
return
|
| 325 |
-
|
| 326 |
schema.verification_status = "verified"
|
| 327 |
except Exception as e:
|
| 328 |
schema.warnings.append(f"Statistics verifier error: {e}")
|
|
@@ -332,41 +321,29 @@ class UniversalVerifier:
|
|
| 332 |
def _verify_trigonometry(self, schema: MathProblemSchema) -> None:
|
| 333 |
g = schema.given
|
| 334 |
verified = schema.verified
|
| 335 |
-
|
| 336 |
-
# Expect "identity" and "angle_deg" (endpoint check)
|
| 337 |
identity = g.get("identity")
|
| 338 |
angle_deg = g.get("angle_deg")
|
| 339 |
-
|
| 340 |
if identity is None:
|
| 341 |
-
schema.warnings.append("Trigonometry: 'identity' is null
|
| 342 |
schema.verification_status = "insufficient_data"
|
| 343 |
return
|
| 344 |
-
|
| 345 |
try:
|
| 346 |
theta = sp.Symbol("theta")
|
| 347 |
-
|
| 348 |
-
lhs = sp.sympify(
|
| 349 |
-
rhs = sp.sympify(
|
| 350 |
diff = sp.simplify(lhs - rhs)
|
| 351 |
-
|
| 352 |
if diff == 0:
|
| 353 |
verified["identity_valid"] = True
|
| 354 |
-
logger.info("✅ [VERIFIER/trig] Identity verified symbolically")
|
| 355 |
schema.verification_status = "verified"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 356 |
else:
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
angle_rad = sp.Rational(angle_deg) * sp.pi / 180
|
| 360 |
-
val = float(diff.subs(theta, angle_rad))
|
| 361 |
-
verified["identity_at_angle"] = (abs(val) < 1e-9)
|
| 362 |
-
if abs(val) < 1e-9:
|
| 363 |
-
schema.verification_status = "partial"
|
| 364 |
-
else:
|
| 365 |
-
schema.warnings.append(f"Trig identity fails at angle={angle_deg}°")
|
| 366 |
-
schema.verification_status = "contradiction"
|
| 367 |
-
else:
|
| 368 |
-
schema.warnings.append("Trig identity not simplified to zero — partial")
|
| 369 |
-
schema.verification_status = "partial"
|
| 370 |
except Exception as e:
|
| 371 |
schema.warnings.append(f"Trigonometry verifier error: {e}")
|
| 372 |
schema.verification_status = "partial"
|
|
@@ -375,41 +352,108 @@ class UniversalVerifier:
|
|
| 375 |
def _verify_calculus(self, schema: MathProblemSchema) -> None:
|
| 376 |
g = schema.given
|
| 377 |
verified = schema.verified
|
| 378 |
-
|
| 379 |
func_str = g.get("function")
|
| 380 |
point_raw = g.get("point")
|
| 381 |
-
|
| 382 |
if func_str is None:
|
| 383 |
-
schema.warnings.append("Calculus: 'function' is null
|
| 384 |
schema.verification_status = "insufficient_data"
|
| 385 |
return
|
| 386 |
-
|
| 387 |
try:
|
| 388 |
x = sp.Symbol("x")
|
| 389 |
f = sp.sympify(str(func_str))
|
| 390 |
deriv = sp.diff(f, x)
|
| 391 |
verified["derivative"] = str(deriv)
|
| 392 |
-
logger.info("📐 [VERIFIER/calculus] f'(x) = %s", deriv)
|
| 393 |
-
|
| 394 |
if point_raw is not None:
|
| 395 |
pt = _to_rational(point_raw)
|
| 396 |
val = float(deriv.subs(x, pt))
|
| 397 |
verified["derivative_at_point"] = val
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
f"CONTRADICTION: given f'({point_raw})={given_deriv_val} "
|
| 406 |
-
f"but computed={val:.6f}"
|
| 407 |
-
)
|
| 408 |
-
schema.warnings.append(msg)
|
| 409 |
-
schema.verification_status = "contradiction"
|
| 410 |
-
return
|
| 411 |
-
|
| 412 |
schema.verification_status = "verified"
|
| 413 |
except Exception as e:
|
| 414 |
schema.warnings.append(f"Calculus verifier error: {e}")
|
| 415 |
schema.verification_status = "partial"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
# backend/universal_verifier.py
|
| 2 |
# BuddyMath V2.0 — Stage 2: SymPy-based Universal Verifier
|
| 3 |
#
|
| 4 |
+
# PRIORITY ORDER FOR CIRCLE CENTER (non-negotiable):
|
| 5 |
+
# 1. diameter_lines in given → center = algebraic intersection (ALWAYS preferred)
|
| 6 |
+
# 2. Midpoint of named diameter chord → FALLBACK ONLY (no lines available)
|
| 7 |
+
#
|
| 8 |
+
# A chord midpoint is NOT a center unless it is proven to be a diameter.
|
| 9 |
+
# If diameter line equations exist, they override ALL midpoint calculations.
|
| 10 |
# ─────────────────────────────────────────────────────────────────────────
|
| 11 |
|
| 12 |
from __future__ import annotations
|
| 13 |
|
| 14 |
import logging
|
| 15 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 16 |
|
| 17 |
import sympy as sp
|
| 18 |
|
|
|
|
| 21 |
logger = logging.getLogger(__name__)
|
| 22 |
|
| 23 |
|
| 24 |
+
# ── Helpers ──────────────────────────────────────────────────────────────
|
| 25 |
def _to_point(value: Any) -> Optional[sp.Point2D]:
|
| 26 |
"""Convert [x, y] list → sympy.Point2D, or None if invalid."""
|
| 27 |
if isinstance(value, (list, tuple)) and len(value) == 2:
|
|
|
|
| 46 |
class UniversalVerifier:
|
| 47 |
"""
|
| 48 |
Routes a MathProblemSchema to the appropriate domain verifier.
|
|
|
|
| 49 |
Guaranteed to never raise an exception to the caller.
|
| 50 |
"""
|
| 51 |
|
|
|
|
| 61 |
def verify(self, schema: MathProblemSchema) -> MathProblemSchema:
|
| 62 |
"""Entry point. Always returns a valid schema."""
|
| 63 |
logger.info(
|
| 64 |
+
"🔍 [VERIFIER] Starting. type=%s/%s | find=%s",
|
| 65 |
schema.problem_type, schema.sub_type, schema.find,
|
| 66 |
)
|
|
|
|
| 67 |
null_keys = schema.null_fields()
|
| 68 |
if null_keys:
|
| 69 |
+
logger.warning("⚠️ [VERIFIER] Null fields: %s", null_keys)
|
| 70 |
|
| 71 |
try:
|
| 72 |
method_name = self._DISPATCH.get(schema.problem_type)
|
|
|
|
| 93 |
g = schema.given
|
| 94 |
verified = {}
|
| 95 |
sub = schema.sub_type.lower()
|
|
|
|
|
|
|
| 96 |
if "circle" in sub:
|
| 97 |
self._verify_circle(schema, g, verified)
|
|
|
|
| 98 |
elif any(k in sub for k in ("line", "linear", "segment")):
|
| 99 |
self._verify_linear(schema, g, verified)
|
| 100 |
else:
|
| 101 |
schema.warnings.append(f"No geometry handler for sub_type='{schema.sub_type}'")
|
| 102 |
schema.verification_status = "partial"
|
|
|
|
|
|
|
|
|
|
| 103 |
|
| 104 |
+
def _verify_circle(self, schema: MathProblemSchema, g: dict, verified: dict) -> None:
|
| 105 |
+
"""
|
| 106 |
+
PRIORITY 1: diameter_lines → center = algebraic intersection.
|
| 107 |
+
PRIORITY 2: diameter chord → midpoint (only if no lines given).
|
| 108 |
+
"""
|
| 109 |
import re as _re
|
| 110 |
|
| 111 |
+
# ═══════════════════════════════════════════════════════════════
|
| 112 |
+
# PRIORITY 1: Diameter line equations — MANDATORY if present
|
| 113 |
+
# ═══════════════════════════════════════════════════════════════
|
| 114 |
+
diameter_lines = g.get("diameter_lines")
|
| 115 |
+
if diameter_lines and len(diameter_lines) >= 2:
|
| 116 |
+
logger.info(
|
| 117 |
+
"🔑 [VERIFIER/circle] diameter_lines detected — using intersection method"
|
| 118 |
+
)
|
| 119 |
+
center, method = _find_center_from_diameter_lines(diameter_lines, schema)
|
| 120 |
+
if center is not None:
|
| 121 |
+
cx, cy = float(center.x), float(center.y)
|
| 122 |
+
verified["M"] = (cx, cy)
|
| 123 |
+
verified["center"] = (cx, cy)
|
| 124 |
+
verified["center_method"] = "diameter_line_intersection"
|
| 125 |
+
schema.verified.update(verified)
|
| 126 |
+
logger.info("✅ [VERIFIER/circle] M = (%s, %s) via %s", cx, cy, method)
|
| 127 |
+
|
| 128 |
+
# Verify radius from a known point
|
| 129 |
+
_compute_radius_from_center(g, center, verified, schema)
|
| 130 |
+
schema.verified.update(verified)
|
| 131 |
+
|
| 132 |
+
# Cross-check if radius also given
|
| 133 |
+
given_r = g.get("radius")
|
| 134 |
+
if given_r and "radius" in verified:
|
| 135 |
+
if abs(float(given_r) - verified["radius"]) > 1e-6:
|
| 136 |
+
schema.warnings.append(
|
| 137 |
+
f"CONTRADICTION: given radius={given_r} vs "
|
| 138 |
+
f"computed={verified['radius']:.4f}"
|
| 139 |
+
)
|
| 140 |
+
schema.verification_status = "contradiction"
|
| 141 |
+
return
|
| 142 |
+
|
| 143 |
+
schema.verification_status = "partial" if schema.null_fields() else "verified"
|
| 144 |
+
return
|
| 145 |
+
else:
|
| 146 |
+
schema.warnings.append(
|
| 147 |
+
f"diameter_lines parsing/solve failed: {method} — falling back to chord"
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
# ═══════════════════════════════════════════════════════════════
|
| 151 |
+
# PRIORITY 2: Midpoint of diameter chord (FALLBACK)
|
| 152 |
+
# ═══════════════════════════════════════════════════════════════
|
| 153 |
points = {}
|
| 154 |
for k, v in g.items():
|
| 155 |
if v is None:
|
|
|
|
| 161 |
radius_raw = g.get("radius")
|
| 162 |
radius = _to_rational(radius_raw)
|
| 163 |
|
| 164 |
+
diameter_pairs: List[Tuple[str, str]] = []
|
|
|
|
| 165 |
has_diameter_constraint = False
|
| 166 |
for c in schema.constraints:
|
| 167 |
if "diameter" in c.lower():
|
| 168 |
has_diameter_constraint = True
|
|
|
|
| 169 |
labels = _re.findall(r"\b([A-Za-z]{1,2})\b", c)
|
| 170 |
matched = [lbl for lbl in labels if lbl in points]
|
| 171 |
if len(matched) >= 2:
|
| 172 |
diameter_pairs.append((matched[0], matched[1]))
|
| 173 |
|
| 174 |
+
# Auto-pick first 2 points if labels ambiguous
|
| 175 |
if not diameter_pairs and has_diameter_constraint and len(points) >= 2:
|
| 176 |
keys = list(points.keys())
|
| 177 |
diameter_pairs.append((keys[0], keys[1]))
|
| 178 |
logger.info(
|
| 179 |
+
"📐 [VERIFIER/circle/fallback] Using points %s, %s as diameter chord",
|
| 180 |
keys[0], keys[1],
|
| 181 |
)
|
| 182 |
|
| 183 |
+
if not diameter_pairs:
|
| 184 |
+
msg = "No diameter_lines and no named diameter chord — insufficient data"
|
| 185 |
+
schema.warnings.append(msg)
|
|
|
|
| 186 |
schema.verification_status = "insufficient_data"
|
| 187 |
return
|
| 188 |
|
|
|
|
| 189 |
for p1_label, p2_label in diameter_pairs:
|
| 190 |
+
p1, p2 = points[p1_label], points[p2_label]
|
| 191 |
+
cx = (p1.x + p2.x) / 2
|
| 192 |
+
cy = (p1.y + p2.y) / 2
|
| 193 |
+
center = sp.Point2D(cx, cy)
|
|
|
|
|
|
|
| 194 |
computed_radius = p1.distance(p2) / 2
|
| 195 |
|
| 196 |
+
verified["center"] = (float(cx), float(cy))
|
| 197 |
+
verified["M"] = (float(cx), float(cy))
|
| 198 |
verified["radius"] = float(computed_radius)
|
| 199 |
+
verified["center_method"] = "diameter_chord_midpoint"
|
| 200 |
+
schema.verified.update(verified)
|
| 201 |
|
| 202 |
logger.info(
|
| 203 |
+
"📐 [VERIFIER/circle/fallback] midpoint(%s,%s) → center=%s radius=%s",
|
| 204 |
p1_label, p2_label, center, computed_radius,
|
| 205 |
)
|
| 206 |
|
| 207 |
+
if radius is not None and abs(float(computed_radius) - float(radius)) > 1e-6:
|
| 208 |
+
msg = (
|
| 209 |
+
f"CONTRADICTION: given radius={radius_raw} but "
|
| 210 |
+
f"computed from chord {p1_label}{p2_label}={float(computed_radius):.4f}"
|
| 211 |
+
)
|
| 212 |
+
schema.warnings.append(msg)
|
| 213 |
+
logger.error("🚨 [VERIFIER/circle] %s", msg)
|
| 214 |
+
schema.verification_status = "contradiction"
|
| 215 |
+
return
|
|
|
|
|
|
|
|
|
|
| 216 |
|
|
|
|
|
|
|
| 217 |
for label, pt in points.items():
|
| 218 |
if label in (p1_label, p2_label):
|
| 219 |
continue
|
| 220 |
+
dist = float(pt.distance(center))
|
| 221 |
+
r = float(computed_radius)
|
| 222 |
+
if abs(dist - r) < 1e-6:
|
| 223 |
verified[f"{label}_on_circle"] = True
|
|
|
|
| 224 |
else:
|
| 225 |
+
schema.warnings.append(
|
| 226 |
+
f"Point {label} NOT on circle (dist={dist:.4f}, r={r:.4f})"
|
|
|
|
|
|
|
| 227 |
)
|
|
|
|
|
|
|
| 228 |
|
| 229 |
+
schema.verified.update(verified)
|
| 230 |
+
schema.verification_status = "partial" if schema.null_fields() else "verified"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
|
| 232 |
+
def _verify_linear(self, schema: MathProblemSchema, g: dict, verified: dict) -> None:
|
| 233 |
+
schema.warnings.append("Linear geometry verifier: basic — extend as needed")
|
|
|
|
|
|
|
|
|
|
| 234 |
schema.verification_status = "partial"
|
| 235 |
|
| 236 |
# ── ALGEBRA ──────────────────────────────────────────────────────────
|
| 237 |
def _verify_algebra(self, schema: MathProblemSchema) -> None:
|
| 238 |
g = schema.given
|
| 239 |
verified = schema.verified
|
|
|
|
|
|
|
| 240 |
equation_str = g.get("equation")
|
| 241 |
if equation_str is None:
|
| 242 |
+
schema.warnings.append("Algebra: 'equation' is null")
|
| 243 |
schema.verification_status = "insufficient_data"
|
| 244 |
return
|
|
|
|
| 245 |
try:
|
|
|
|
| 246 |
if "=" in str(equation_str):
|
| 247 |
+
lhs_s, rhs_s = str(equation_str).split("=", 1)
|
| 248 |
x = sp.Symbol("x")
|
| 249 |
+
expr = sp.sympify(lhs_s.strip()) - sp.sympify(rhs_s.strip())
|
|
|
|
|
|
|
| 250 |
else:
|
| 251 |
x = sp.Symbol("x")
|
| 252 |
expr = sp.sympify(str(equation_str))
|
|
|
|
| 253 |
solutions = sp.solve(expr, x)
|
| 254 |
verified["solutions"] = [str(s) for s in solutions]
|
| 255 |
logger.info("📐 [VERIFIER/algebra] Solutions: %s", solutions)
|
|
|
|
|
|
|
| 256 |
given_solution = g.get("solution")
|
| 257 |
if given_solution is not None:
|
| 258 |
given_val = sp.Rational(given_solution)
|
| 259 |
if given_val not in solutions:
|
| 260 |
+
schema.warnings.append(
|
| 261 |
+
f"CONTRADICTION: given solution={given_solution} not in {solutions}"
|
| 262 |
+
)
|
| 263 |
schema.verification_status = "contradiction"
|
| 264 |
return
|
| 265 |
+
schema.verification_status = "partial" if schema.null_fields() else "verified"
|
|
|
|
|
|
|
|
|
|
| 266 |
except Exception as e:
|
| 267 |
schema.warnings.append(f"Algebra verifier error: {e}")
|
| 268 |
schema.verification_status = "partial"
|
|
|
|
| 269 |
|
| 270 |
# ── PROBABILITY ───────────────────────────────────────────────────────
|
| 271 |
def _verify_probability(self, schema: MathProblemSchema) -> None:
|
| 272 |
g = schema.given
|
| 273 |
verified = schema.verified
|
|
|
|
|
|
|
| 274 |
probs = g.get("probabilities")
|
| 275 |
if probs is None:
|
| 276 |
+
schema.warnings.append("Probability: 'probabilities' is null")
|
| 277 |
schema.verification_status = "insufficient_data"
|
| 278 |
return
|
|
|
|
| 279 |
try:
|
| 280 |
total = sum(sp.Rational(str(p)) for p in probs)
|
| 281 |
verified["sum"] = float(total)
|
|
|
|
|
|
|
| 282 |
if total != 1:
|
| 283 |
+
schema.warnings.append(
|
| 284 |
+
f"CONTRADICTION: probabilities sum to {float(total):.4f}, not 1.0"
|
| 285 |
+
)
|
| 286 |
schema.verification_status = "contradiction"
|
| 287 |
else:
|
| 288 |
schema.verification_status = "verified"
|
|
|
|
| 293 |
# ── STATISTICS ────────────────────────────────────────────────────────
|
| 294 |
def _verify_statistics(self, schema: MathProblemSchema) -> None:
|
| 295 |
import numpy as np
|
|
|
|
| 296 |
g = schema.given
|
| 297 |
verified = schema.verified
|
|
|
|
| 298 |
data = g.get("data")
|
| 299 |
if data is None:
|
| 300 |
+
schema.warnings.append("Statistics: 'data' is null")
|
| 301 |
schema.verification_status = "insufficient_data"
|
| 302 |
return
|
|
|
|
| 303 |
try:
|
| 304 |
arr = np.array([float(x) for x in data])
|
| 305 |
verified["mean"] = float(np.mean(arr))
|
| 306 |
+
verified["std"] = float(np.std(arr, ddof=0))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
for key in ("mean", "std"):
|
| 308 |
given_val = g.get(key)
|
| 309 |
+
if given_val is not None and abs(float(given_val) - verified[key]) > 1e-6:
|
| 310 |
+
schema.warnings.append(
|
| 311 |
+
f"CONTRADICTION: given {key}={given_val} vs computed={verified[key]:.6f}"
|
| 312 |
+
)
|
| 313 |
+
schema.verification_status = "contradiction"
|
| 314 |
+
return
|
|
|
|
|
|
|
|
|
|
| 315 |
schema.verification_status = "verified"
|
| 316 |
except Exception as e:
|
| 317 |
schema.warnings.append(f"Statistics verifier error: {e}")
|
|
|
|
| 321 |
def _verify_trigonometry(self, schema: MathProblemSchema) -> None:
|
| 322 |
g = schema.given
|
| 323 |
verified = schema.verified
|
|
|
|
|
|
|
| 324 |
identity = g.get("identity")
|
| 325 |
angle_deg = g.get("angle_deg")
|
|
|
|
| 326 |
if identity is None:
|
| 327 |
+
schema.warnings.append("Trigonometry: 'identity' is null")
|
| 328 |
schema.verification_status = "insufficient_data"
|
| 329 |
return
|
|
|
|
| 330 |
try:
|
| 331 |
theta = sp.Symbol("theta")
|
| 332 |
+
lhs_s, rhs_s = str(identity).split("=", 1)
|
| 333 |
+
lhs = sp.sympify(lhs_s.strip())
|
| 334 |
+
rhs = sp.sympify(rhs_s.strip())
|
| 335 |
diff = sp.simplify(lhs - rhs)
|
|
|
|
| 336 |
if diff == 0:
|
| 337 |
verified["identity_valid"] = True
|
|
|
|
| 338 |
schema.verification_status = "verified"
|
| 339 |
+
elif angle_deg is not None:
|
| 340 |
+
angle_rad = sp.Rational(angle_deg) * sp.pi / 180
|
| 341 |
+
val = float(diff.subs(theta, angle_rad))
|
| 342 |
+
verified["identity_at_angle"] = (abs(val) < 1e-9)
|
| 343 |
+
schema.verification_status = "partial" if abs(val) < 1e-9 else "contradiction"
|
| 344 |
else:
|
| 345 |
+
schema.warnings.append("Trig identity could not be simplified to zero")
|
| 346 |
+
schema.verification_status = "partial"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 347 |
except Exception as e:
|
| 348 |
schema.warnings.append(f"Trigonometry verifier error: {e}")
|
| 349 |
schema.verification_status = "partial"
|
|
|
|
| 352 |
def _verify_calculus(self, schema: MathProblemSchema) -> None:
|
| 353 |
g = schema.given
|
| 354 |
verified = schema.verified
|
|
|
|
| 355 |
func_str = g.get("function")
|
| 356 |
point_raw = g.get("point")
|
|
|
|
| 357 |
if func_str is None:
|
| 358 |
+
schema.warnings.append("Calculus: 'function' is null")
|
| 359 |
schema.verification_status = "insufficient_data"
|
| 360 |
return
|
|
|
|
| 361 |
try:
|
| 362 |
x = sp.Symbol("x")
|
| 363 |
f = sp.sympify(str(func_str))
|
| 364 |
deriv = sp.diff(f, x)
|
| 365 |
verified["derivative"] = str(deriv)
|
|
|
|
|
|
|
| 366 |
if point_raw is not None:
|
| 367 |
pt = _to_rational(point_raw)
|
| 368 |
val = float(deriv.subs(x, pt))
|
| 369 |
verified["derivative_at_point"] = val
|
| 370 |
+
given_dv = g.get("derivative_at_point")
|
| 371 |
+
if given_dv is not None and abs(float(given_dv) - val) > 1e-9:
|
| 372 |
+
schema.warnings.append(
|
| 373 |
+
f"CONTRADICTION: given f'({point_raw})={given_dv} vs computed={val:.6f}"
|
| 374 |
+
)
|
| 375 |
+
schema.verification_status = "contradiction"
|
| 376 |
+
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 377 |
schema.verification_status = "verified"
|
| 378 |
except Exception as e:
|
| 379 |
schema.warnings.append(f"Calculus verifier error: {e}")
|
| 380 |
schema.verification_status = "partial"
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
# ── Module-level helpers ──────────────────────────────────────────────────
|
| 384 |
+
def _find_center_from_diameter_lines(
|
| 385 |
+
diameter_lines: list,
|
| 386 |
+
schema: "MathProblemSchema",
|
| 387 |
+
) -> Tuple[Optional[sp.Point2D], str]:
|
| 388 |
+
"""
|
| 389 |
+
Solve the intersection of two diameter line equations.
|
| 390 |
+
Returns (sp.Point2D, description) or (None, reason).
|
| 391 |
+
"""
|
| 392 |
+
if len(diameter_lines) < 2:
|
| 393 |
+
return None, "need at least 2 diameter lines"
|
| 394 |
+
|
| 395 |
+
x, y = sp.symbols("x y")
|
| 396 |
+
|
| 397 |
+
def _parse_line(eq_str: str) -> Optional[sp.Eq]:
|
| 398 |
+
eq_str = str(eq_str).strip().replace(" ", "")
|
| 399 |
+
if "=" not in eq_str:
|
| 400 |
+
return None
|
| 401 |
+
lhs_s, rhs_s = eq_str.split("=", 1)
|
| 402 |
+
try:
|
| 403 |
+
return sp.Eq(sp.sympify(lhs_s), sp.sympify(rhs_s))
|
| 404 |
+
except Exception as e:
|
| 405 |
+
logger.error("❌ [VERIFIER/diameters] Cannot parse '%s': %s", eq_str, e)
|
| 406 |
+
return None
|
| 407 |
+
|
| 408 |
+
eq1 = _parse_line(diameter_lines[0])
|
| 409 |
+
eq2 = _parse_line(diameter_lines[1])
|
| 410 |
+
|
| 411 |
+
if eq1 is None or eq2 is None:
|
| 412 |
+
return None, "line parsing failed"
|
| 413 |
+
|
| 414 |
+
logger.info(
|
| 415 |
+
"📐 [VERIFIER/diameters] Solving:\n L1=%s\n L2=%s", eq1, eq2
|
| 416 |
+
)
|
| 417 |
+
|
| 418 |
+
try:
|
| 419 |
+
sol = sp.solve([eq1, eq2], [x, y])
|
| 420 |
+
if not sol:
|
| 421 |
+
schema.warnings.append("Diameter lines are parallel — no intersection")
|
| 422 |
+
return None, "parallel lines"
|
| 423 |
+
|
| 424 |
+
if isinstance(sol, dict):
|
| 425 |
+
cx, cy = sol[x], sol[y]
|
| 426 |
+
elif isinstance(sol, (list, tuple)) and sol:
|
| 427 |
+
first = sol[0]
|
| 428 |
+
cx, cy = first[0], first[1]
|
| 429 |
+
else:
|
| 430 |
+
return None, "unexpected solution format"
|
| 431 |
+
|
| 432 |
+
center = sp.Point2D(cx, cy)
|
| 433 |
+
logger.info("✅ [VERIFIER/diameters] Center = (%s, %s)", cx, cy)
|
| 434 |
+
return center, f"intersection of {diameter_lines[0]} ∩ {diameter_lines[1]}"
|
| 435 |
+
|
| 436 |
+
except Exception as e:
|
| 437 |
+
logger.error("❌ [VERIFIER/diameters] Solve error: %s", e)
|
| 438 |
+
return None, f"solve error: {e}"
|
| 439 |
+
|
| 440 |
+
|
| 441 |
+
def _compute_radius_from_center(
|
| 442 |
+
g: dict, center: sp.Point2D, verified: dict, schema: "MathProblemSchema"
|
| 443 |
+
) -> None:
|
| 444 |
+
"""Compute radius as distance from center to the first known point."""
|
| 445 |
+
for k, v in g.items():
|
| 446 |
+
if k in ("diameter_lines", "radius"):
|
| 447 |
+
continue
|
| 448 |
+
if v is None:
|
| 449 |
+
continue
|
| 450 |
+
p = _to_point(v)
|
| 451 |
+
if p:
|
| 452 |
+
r = float(p.distance(center))
|
| 453 |
+
verified["radius"] = r
|
| 454 |
+
logger.info("📐 [VERIFIER/circle] radius = dist(%s, center) = %.4f", k, r)
|
| 455 |
+
return
|
| 456 |
+
# Fallback: use given radius
|
| 457 |
+
given_r = g.get("radius")
|
| 458 |
+
if given_r is not None:
|
| 459 |
+
verified["radius"] = float(given_r)
|