dotandru commited on
Commit
9e5ea2d
·
1 Parent(s): 3517437

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 CHANGED
@@ -1,25 +1,20 @@
1
  # backend/tests/test_pipeline.py
2
- # BuddyMath V2.0 — Acceptance Tests
3
  #
4
- # Acceptance criteria (from spec):
5
- # Task 2: 18/20 text problems produce valid JSON; null returned on unclear OCR fields.
6
- # Task 3: Verifier returns valid object even on insufficient data.
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
- """Verifier returns valid schema even with all-null geometry."""
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, ["insufficient_data", "partial", "contradiction"])
 
51
  self.assertIsInstance(result.warnings, list)
52
 
53
- def test_circle_full_data(self):
54
- """The key test: A(0,1), D(0,9) → center must be (0,5), radius=4."""
 
 
 
 
 
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
- # Task 4: Pin block contains all required sections
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- # RULES (from spec):
5
- # 1. Verify ONLY what can be verified algebraically. No solving.
6
- # 2. If data is insufficient (null fields block verification) status = insufficient_data.
7
- # 3. If a contradiction is found → status = contradiction + log it as warning.
8
- # 4. Always return a valid MathProblemSchema, even on failure. Never raise to the caller.
 
9
  # ─────────────────────────────────────────────────────────────────────────
10
 
11
  from __future__ import annotations
12
 
13
  import logging
14
- from typing import Any, Callable, Dict, Optional
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
- # ── Helper: safe sympy point ─────────────────────────────────────────────
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 verification. type=%s/%s | find=%s",
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 present: %s", null_keys)
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
- """Verify circle facts from given points/radius."""
 
 
 
113
  import re as _re
114
 
115
- # Collect known (non-null) points
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- # Find diameter pair from constraints
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
- # Fallback: constraint says diameter but labels didn't resolve → use first 2 points
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] Fallback: using points %s and %s as diameter",
145
  keys[0], keys[1],
146
  )
147
 
148
- if not diameter_pairs and len(points) < 2:
149
- schema.warnings.append(
150
- "Geometry/circle: not enough known points to verify; marking insufficient_data"
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
- p2 = points[p2_label]
159
- center = sp.Point2D(
160
- (p1.x + p2.x) / 2,
161
- (p1.y + p2.y) / 2,
162
- )
163
  computed_radius = p1.distance(p2) / 2
164
 
165
- verified["center"] = (float(center.x), float(center.y))
 
166
  verified["radius"] = float(computed_radius)
 
 
167
 
168
  logger.info(
169
- "📐 [VERIFIER/circle] Diameter %s%s → center=%s, radius=%s",
170
  p1_label, p2_label, center, computed_radius,
171
  )
172
 
173
- # If radius was given, cross-check
174
- if radius is not None:
175
- if abs(computed_radius - radius) > sp.Rational(1, 1000):
176
- msg = (
177
- f"CONTRADICTION: given radius={radius_raw} but "
178
- f"computed from diameter {p1_label}{p2_label}="
179
- f"{float(computed_radius):.4f}"
180
- )
181
- schema.warnings.append(msg)
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
- if circle.encloses_point(pt) or pt.distance(center) == computed_radius:
 
 
192
  verified[f"{label}_on_circle"] = True
193
- logger.info("✅ [VERIFIER/circle] %s lies on circle", label)
194
  else:
195
- dist = float(pt.distance(center))
196
- msg = (
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
- # Mark verification status
204
- null_keys = schema.null_fields()
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
- """Verify linear geometry: slopes, intersections, distances."""
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 — cannot verify")
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
- lhs_str, rhs_str = str(equation_str).split("=", 1)
233
  x = sp.Symbol("x")
234
- lhs = sp.sympify(lhs_str.strip())
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
- msg = f"CONTRADICTION: given solution={given_solution} not in computed {solutions}"
251
- schema.warnings.append(msg)
252
- logger.error("🚨 [VERIFIER/algebra] %s", msg)
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 — cannot verify sum-to-1")
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
- msg = f"CONTRADICTION: probabilities sum to {float(total):.4f}, not 1.0"
283
- schema.warnings.append(msg)
284
- logger.error("🚨 [VERIFIER/probability] %s", msg)
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 — cannot compute mean/stddev")
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)) # population std
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
- diff = abs(float(given_val) - verified[key])
319
- if diff > 1e-6:
320
- msg = f"CONTRADICTION: given {key}={given_val} but computed={verified[key]:.6f}"
321
- schema.warnings.append(msg)
322
- logger.error("🚨 [VERIFIER/statistics] %s", msg)
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 — cannot verify")
342
  schema.verification_status = "insufficient_data"
343
  return
344
-
345
  try:
346
  theta = sp.Symbol("theta")
347
- lhs_str, rhs_str = str(identity).split("=", 1)
348
- lhs = sp.sympify(lhs_str.strip().replace("sin", "sp.sin").replace("cos", "sp.cos"))
349
- rhs = sp.sympify(rhs_str.strip().replace("sin", "sp.sin").replace("cos", "sp.cos"))
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
- # Try numeric check at endpoint angle if provided
358
- if angle_deg is not None:
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 — cannot verify derivative")
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
- logger.info("📐 [VERIFIER/calculus] f'(%s) = %s", point_raw, val)
399
-
400
- given_deriv_val = g.get("derivative_at_point")
401
- if given_deriv_val is not None:
402
- diff_val = abs(float(given_deriv_val) - val)
403
- if diff_val > 1e-9:
404
- msg = (
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)