j-js commited on
Commit
bedadb6
·
verified ·
1 Parent(s): c13d1bd

Update solver_ratio.py

Browse files
Files changed (1) hide show
  1. solver_ratio.py +675 -125
solver_ratio.py CHANGED
@@ -1,162 +1,712 @@
1
  from __future__ import annotations
2
 
3
  import re
4
- from math import gcd
5
- from typing import Optional, List
 
6
 
7
  from models import SolverResult
8
 
9
 
10
- def _nums(text: str) -> List[float]:
11
- return [float(x) for x in re.findall(r"-?\d+(?:\.\d+)?", text)]
 
12
 
13
 
14
- def _reduce_ratio(a: int, b: int) -> tuple[int, int]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  if a == 0 and b == 0:
16
- return (0, 0)
 
 
 
 
17
  g = gcd(abs(a), abs(b))
18
- return (a // g, b // g)
 
 
 
 
19
 
20
 
21
- def solve_ratio(text: str) -> Optional[SolverResult]:
22
- lower = (text or "").lower()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
- if "ratio" not in lower and ":" not in lower:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  return None
 
 
 
26
 
27
- # Pattern 1: "ratio of x to y"
28
- m = re.search(r"ratio\s+of\s+(-?\d+)\s+to\s+(-?\d+)", lower)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  if m:
30
- a = int(m.group(1))
31
- b = int(m.group(2))
32
- if b == 0 and a == 0:
33
- return None
34
- ra, rb = _reduce_ratio(a, b)
35
- return SolverResult(
36
- domain="quant",
37
- solved=True,
38
- topic="ratio",
39
- answer_value=f"{ra}:{rb}",
40
- internal_answer=f"{ra}:{rb}",
41
- steps=[
42
- "Write the ratio in order.",
43
- "Reduce both parts by their greatest common factor.",
44
- ],
45
- )
46
 
47
- # Pattern 2: "a:b = 2:3 and total is 40" / "there are 40 students in total"
 
48
  m = re.search(
49
- r"(\d+)\s*:\s*(\d+).*?(?:total|sum)\s*(?:is|of)?\s*(\d+(?:\.\d+)?)",
 
 
50
  lower,
51
  )
52
  if not m:
53
- m = re.search(
54
- r"(\d+)\s*:\s*(\d+).*?(\d+(?:\.\d+)?).*?(?:in total|total)",
55
- lower,
56
- )
57
- if m:
58
- a = float(m.group(1))
59
- b = float(m.group(2))
60
- total = float(m.group(3))
61
- parts = a + b
62
- if parts == 0:
63
- return None
64
- unit = total / parts
65
- first = a * unit
66
- second = b * unit
67
- return SolverResult(
68
- domain="quant",
69
- solved=True,
70
- topic="ratio_partition",
71
- answer_value=f"{first:g}, {second:g}",
72
- internal_answer=f"{first:g}, {second:g}",
73
- steps=[
74
- "Add the ratio parts.",
75
- "Divide the total by the total number of parts to get one unit.",
76
- "Multiply each ratio part by the unit value.",
77
- ],
78
- )
79
 
80
- # Pattern 3: "ratio is 2:3, one part is 10 more than the other"
 
 
 
 
 
 
 
 
 
81
  m = re.search(
82
- r"(\d+)\s*:\s*(\d+).*?(\d+(?:\.\d+)?)\s+more\s+than",
 
83
  lower,
84
  )
85
- if m:
86
- a = float(m.group(1))
87
- b = float(m.group(2))
88
- diff_val = float(m.group(3))
89
- diff_parts = abs(a - b)
90
- if diff_parts == 0:
91
- return None
92
- unit = diff_val / diff_parts
93
- first = a * unit
94
- second = b * unit
95
- return SolverResult(
96
- domain="quant",
97
- solved=True,
98
- topic="ratio_difference",
99
- answer_value=f"{first:g}, {second:g}",
100
- internal_answer=f"{first:g}, {second:g}",
101
- steps=[
102
- "Find the difference in ratio parts.",
103
- "Match that to the actual difference to get one unit.",
104
- "Multiply each ratio part by the unit value.",
105
- ],
106
- )
107
 
108
- # Pattern 4: "If x:y = 2:3 and y:z = 4:5, find x:z"
 
109
  m = re.search(
110
- r"(\w+)\s*:\s*(\w+)\s*=\s*(\d+)\s*:\s*(\d+).*?(\w+)\s*:\s*(\w+)\s*=\s*(\d+)\s*:\s*(\d+).*?find\s+(\w+)\s*:\s*(\w+)",
 
 
111
  lower,
112
  )
113
  if m:
114
- left1, right1, a, b = m.group(1), m.group(2), int(m.group(3)), int(m.group(4))
115
- left2, right2, c, d = m.group(5), m.group(6), int(m.group(7)), int(m.group(8))
116
- target1, target2 = m.group(9), m.group(10)
117
-
118
- if right1 == left2:
119
- lcm_base = b * c // gcd(b, c)
120
- mul1 = lcm_base // b
121
- mul2 = lcm_base // c
122
- first = a * mul1
123
- middle = lcm_base
124
- third = d * mul2
125
-
126
- mapping = {left1: first, right1: middle, right2: third}
127
- if target1 in mapping and target2 in mapping:
128
- x = mapping[target1]
129
- y = mapping[target2]
130
- rx, ry = _reduce_ratio(int(x), int(y))
131
- return SolverResult(
132
- domain="quant",
133
- solved=True,
134
- topic="ratio_chain",
135
- answer_value=f"{rx}:{ry}",
136
- internal_answer=f"{rx}:{ry}",
137
- steps=[
138
- "Match the common middle term across both ratios.",
139
- "Scale the ratios so the shared term is equal.",
140
- "Read off the requested ratio and simplify.",
141
- ],
142
- )
143
 
144
- # Pattern 5: direct colon simplification
145
- m = re.search(r"(\d+)\s*:\s*(\d+)", lower)
146
- if m and any(w in lower for w in ["simplify", "reduce", "ratio"]):
147
- a = int(m.group(1))
148
- b = int(m.group(2))
149
- ra, rb = _reduce_ratio(a, b)
150
- return SolverResult(
151
- domain="quant",
152
- solved=True,
 
 
153
  topic="ratio",
154
- answer_value=f"{ra}:{rb}",
155
- internal_answer=f"{ra}:{rb}",
156
  steps=[
157
- "Find the greatest common factor of both terms.",
158
- "Divide both sides of the ratio by that factor.",
 
159
  ],
160
  )
161
 
162
- return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
  import re
4
+ from fractions import Fraction
5
+ from math import gcd, isclose
6
+ from typing import Dict, List, Optional, Tuple
7
 
8
  from models import SolverResult
9
 
10
 
11
+ _NUMBER = r"-?\d+(?:\.\d+)?"
12
+ _INT = r"-?\d+"
13
+ _NAME = r"[a-zA-Z][a-zA-Z0-9_]*"
14
 
15
 
16
+ # =========================
17
+ # core helpers
18
+ # =========================
19
+
20
+ def _clean(text: str) -> str:
21
+ s = (text or "").strip()
22
+ s = s.replace("–", "-").replace("—", "-").replace("−", "-")
23
+ s = s.replace("∶", ":")
24
+ s = re.sub(r"\s+", " ", s)
25
+ return s
26
+
27
+
28
+ def _to_float(x: str) -> float:
29
+ return float(x)
30
+
31
+
32
+ def _to_int_if_whole(x: float):
33
+ if isclose(x, round(x), rel_tol=1e-9, abs_tol=1e-9):
34
+ return int(round(x))
35
+ return x
36
+
37
+
38
+ def _fmt_num(x: float) -> str:
39
+ xi = _to_int_if_whole(x)
40
+ if isinstance(xi, int):
41
+ return str(xi)
42
+ return f"{x:.6g}"
43
+
44
+
45
+ def _reduce_ratio(a: int, b: int) -> Tuple[int, int]:
46
  if a == 0 and b == 0:
47
+ return 0, 0
48
+ if a == 0:
49
+ return 0, 1 if b > 0 else -1
50
+ if b == 0:
51
+ return 1 if a > 0 else -1, 0
52
  g = gcd(abs(a), abs(b))
53
+ a //= g
54
+ b //= g
55
+ if b < 0:
56
+ a, b = -a, -b
57
+ return a, b
58
 
59
 
60
+ def _safe_ratio_answer(a: float, b: float) -> str:
61
+ if isclose(a, round(a), abs_tol=1e-9) and isclose(b, round(b), abs_tol=1e-9):
62
+ ra, rb = _reduce_ratio(int(round(a)), int(round(b)))
63
+ return f"{ra}:{rb}"
64
+ return f"{_fmt_num(a)}:{_fmt_num(b)}"
65
+
66
+
67
+ def _fraction_from_text(s: str) -> Fraction:
68
+ s = s.strip()
69
+ if "/" in s:
70
+ a, b = s.split("/", 1)
71
+ return Fraction(a.strip()) / Fraction(b.strip())
72
+ return Fraction(s)
73
+
74
+
75
+ def _make_result(
76
+ *,
77
+ topic: str,
78
+ internal_answer: Optional[str],
79
+ steps: List[str],
80
+ solved: bool = True,
81
+ answer_value: Optional[str] = None,
82
+ ) -> SolverResult:
83
+ return SolverResult(
84
+ domain="quant",
85
+ solved=solved,
86
+ topic=topic,
87
+ answer_value=answer_value if answer_value is not None else internal_answer,
88
+ internal_answer=internal_answer,
89
+ steps=steps,
90
+ )
91
+
92
+
93
+ # =========================
94
+ # detection helpers
95
+ # =========================
96
+
97
+ def _has_ratio_intent(lower: str) -> bool:
98
+ triggers = [
99
+ "ratio",
100
+ "proportion",
101
+ ":",
102
+ " to ",
103
+ "for every",
104
+ "out of",
105
+ "divide",
106
+ "split",
107
+ "share",
108
+ "distributed",
109
+ "mixture",
110
+ "combined",
111
+ "boys",
112
+ "girls",
113
+ "men",
114
+ "women",
115
+ "students",
116
+ "teachers",
117
+ ]
118
+ return any(t in lower for t in triggers)
119
+
120
+
121
+ def _extract_ratio_pair(lower: str) -> Optional[Tuple[float, float]]:
122
+ patterns = [
123
+ rf"ratio.*?is\s+({_NUMBER})\s*:\s*({_NUMBER})",
124
+ rf"ratio.*?is\s+({_NUMBER})\s+to\s+({_NUMBER})",
125
+ rf"in\s+the\s+ratio\s+({_NUMBER})\s*:\s*({_NUMBER})",
126
+ rf"in\s+the\s+ratio\s+({_NUMBER})\s+to\s+({_NUMBER})",
127
+ rf"({_NUMBER})\s*:\s*({_NUMBER})",
128
+ rf"({_NUMBER})\s+to\s+({_NUMBER})",
129
+ ]
130
+ for pat in patterns:
131
+ m = re.search(pat, lower)
132
+ if m:
133
+ return _to_float(m.group(1)), _to_float(m.group(2))
134
+ return None
135
+
136
+
137
+ def _extract_three_part_ratio(lower: str) -> Optional[Tuple[float, float, float]]:
138
+ patterns = [
139
+ rf"ratio.*?is\s+({_NUMBER})\s*:\s*({_NUMBER})\s*:\s*({_NUMBER})",
140
+ rf"in\s+the\s+ratio\s+({_NUMBER})\s*:\s*({_NUMBER})\s*:\s*({_NUMBER})",
141
+ rf"({_NUMBER})\s*:\s*({_NUMBER})\s*:\s*({_NUMBER})",
142
+ ]
143
+ for pat in patterns:
144
+ m = re.search(pat, lower)
145
+ if m:
146
+ return _to_float(m.group(1)), _to_float(m.group(2)), _to_float(m.group(3))
147
+ return None
148
+
149
 
150
+ def _extract_total(lower: str) -> Optional[float]:
151
+ patterns = [
152
+ rf"total(?:\s+number)?(?:\s+is|\s+of|\s*=)?\s*({_NUMBER})",
153
+ rf"sum(?:\s+is|\s+of|\s*=)?\s*({_NUMBER})",
154
+ rf"({_NUMBER})\s+(?:in total|altogether|total)",
155
+ rf"add up to\s+({_NUMBER})",
156
+ ]
157
+ for pat in patterns:
158
+ m = re.search(pat, lower)
159
+ if m:
160
+ return _to_float(m.group(1))
161
+ return None
162
+
163
+
164
+ def _extract_difference(lower: str) -> Optional[float]:
165
+ patterns = [
166
+ rf"difference(?:\s+is|\s+of|\s*=)?\s*({_NUMBER})",
167
+ rf"({_NUMBER})\s+more\s+than",
168
+ rf"({_NUMBER})\s+less\s+than",
169
+ rf"exceeds.*?by\s+({_NUMBER})",
170
+ ]
171
+ for pat in patterns:
172
+ m = re.search(pat, lower)
173
+ if m:
174
+ return _to_float(m.group(1))
175
+ return None
176
+
177
+
178
+ def _extract_out_of(lower: str) -> Optional[Tuple[float, float]]:
179
+ m = re.search(rf"({_NUMBER})\s+out\s+of\s+({_NUMBER})", lower)
180
+ if m:
181
+ return _to_float(m.group(1)), _to_float(m.group(2))
182
+ return None
183
+
184
+
185
+ def _extract_fraction_ratio(lower: str) -> Optional[Tuple[int, int]]:
186
+ m = re.search(rf"ratio.*?is\s+({_NUMBER}\s*/\s*{_NUMBER})", lower)
187
+ if not m:
188
  return None
189
+ frac = _fraction_from_text(m.group(1).replace(" ", ""))
190
+ return frac.numerator, frac.denominator
191
+
192
 
193
+ def _extract_missing_term_proportion(lower: str):
194
+ patterns = [
195
+ rf"(x|{_NUMBER})\s*:\s*(x|{_NUMBER})\s*=\s*(x|{_NUMBER})\s*:\s*(x|{_NUMBER})",
196
+ rf"(x|{_NUMBER})\s+to\s+(x|{_NUMBER})\s*=\s*(x|{_NUMBER})\s+to\s+(x|{_NUMBER})",
197
+ ]
198
+ for pat in patterns:
199
+ m = re.search(pat, lower)
200
+ if m:
201
+ vals = [m.group(i) for i in range(1, 5)]
202
+ if sum(v == "x" for v in vals) == 1:
203
+ return vals
204
+ return None
205
+
206
+
207
+ def _extract_named_initial_ratio(lower: str):
208
+ # e.g. ratio of boys to girls is 3:5
209
+ m = re.search(
210
+ rf"ratio\s+of\s+({_NAME})\s+to\s+({_NAME})\s+is\s+({_NUMBER})\s*(?::|to)\s*({_NUMBER})",
211
+ lower,
212
+ )
213
  if m:
214
+ return {
215
+ "name1": m.group(1),
216
+ "name2": m.group(2),
217
+ "a": _to_float(m.group(3)),
218
+ "b": _to_float(m.group(4)),
219
+ }
220
+ return None
221
+
 
 
 
 
 
 
 
 
222
 
223
+ def _extract_named_change(lower: str):
224
+ # e.g. after 6 girls join, the ratio becomes 2:3
225
  m = re.search(
226
+ rf"(?:after|when)\s+({_NUMBER})\s+(more\s+|additional\s+|extra\s+)?({_NAME})\s+"
227
+ rf"(join|are added|added|enter|left|leave|are removed|removed).*?"
228
+ rf"(?:ratio.*?becomes|ratio.*?changes?\s+to|is\s+now)\s+({_NUMBER})\s*(?::|to)\s*({_NUMBER})",
229
  lower,
230
  )
231
  if not m:
232
+ return None
233
+
234
+ delta = _to_float(m.group(1))
235
+ changed_name = m.group(3)
236
+ verb = m.group(4)
237
+ new_a = _to_float(m.group(5))
238
+ new_b = _to_float(m.group(6))
239
+
240
+ sign = -1 if verb in ["left", "leave", "are removed", "removed"] else 1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
 
242
+ return {
243
+ "delta": sign * delta,
244
+ "changed_name": changed_name,
245
+ "new_a": new_a,
246
+ "new_b": new_b,
247
+ }
248
+
249
+
250
+ def _extract_transfer_between_groups(lower: str):
251
+ # e.g. if 4 boys move to girls, ratio becomes ...
252
  m = re.search(
253
+ rf"(?:if|when|after)\s+({_NUMBER})\s+({_NAME})\s+(?:move|transfer|are transferred)\s+to\s+({_NAME}).*?"
254
+ rf"(?:ratio.*?becomes|ratio.*?changes?\s+to|is\s+now)\s+({_NUMBER})\s*(?::|to)\s*({_NUMBER})",
255
  lower,
256
  )
257
+ if not m:
258
+ return None
259
+ return {
260
+ "delta": _to_float(m.group(1)),
261
+ "from_name": m.group(2),
262
+ "to_name": m.group(3),
263
+ "new_a": _to_float(m.group(4)),
264
+ "new_b": _to_float(m.group(5)),
265
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
266
 
267
+
268
+ def _extract_chain_ratio(lower: str):
269
  m = re.search(
270
+ rf"({_NAME})\s*:\s*({_NAME})\s*=\s*({_NUMBER})\s*:\s*({_NUMBER}).*?"
271
+ rf"({_NAME})\s*:\s*({_NAME})\s*=\s*({_NUMBER})\s*:\s*({_NUMBER}).*?"
272
+ rf"find\s+({_NAME})\s*:\s*({_NAME})",
273
  lower,
274
  )
275
  if m:
276
+ return {
277
+ "l1": m.group(1), "r1": m.group(2), "a": _to_float(m.group(3)), "b": _to_float(m.group(4)),
278
+ "l2": m.group(5), "r2": m.group(6), "c": _to_float(m.group(7)), "d": _to_float(m.group(8)),
279
+ "t1": m.group(9), "t2": m.group(10),
280
+ }
281
+ return None
282
+
283
+
284
+ def _extract_part_of_total_question(lower: str) -> Optional[str]:
285
+ patterns = [
286
+ r"how many\s+([a-zA-Z][a-zA-Z0-9_]*)",
287
+ r"number of\s+([a-zA-Z][a-zA-Z0-9_]*)",
288
+ ]
289
+ for pat in patterns:
290
+ m = re.search(pat, lower)
291
+ if m:
292
+ return m.group(1)
293
+ return None
294
+
295
+
296
+ # =========================
297
+ # solver blocks
298
+ # =========================
 
 
 
 
 
 
299
 
300
+ def _solve_simplify_ratio(lower: str) -> Optional[SolverResult]:
301
+ if not any(k in lower for k in ["simplify", "reduce", "lowest terms", "ratio"]):
302
+ return None
303
+
304
+ pair = _extract_ratio_pair(lower)
305
+ if not pair:
306
+ return None
307
+
308
+ a, b = pair
309
+ if not isclose(a, round(a), abs_tol=1e-9) or not isclose(b, round(b), abs_tol=1e-9):
310
+ return _make_result(
311
  topic="ratio",
312
+ internal_answer=f"{_fmt_num(a)}:{_fmt_num(b)}",
 
313
  steps=[
314
+ "The question is asking for the comparison in the stated order.",
315
+ "Write the two quantities as a ratio.",
316
+ "Reduce only if both parts share a common factor cleanly.",
317
  ],
318
  )
319
 
320
+ ra, rb = _reduce_ratio(int(round(a)), int(round(b)))
321
+ return _make_result(
322
+ topic="ratio",
323
+ internal_answer=f"{ra}:{rb}",
324
+ steps=[
325
+ "The question is asking for the ratio in lowest terms.",
326
+ "Write the ratio in the correct order.",
327
+ "Divide both parts by their greatest common factor.",
328
+ ],
329
+ )
330
+
331
+
332
+ def _solve_out_of_ratio(lower: str) -> Optional[SolverResult]:
333
+ vals = _extract_out_of(lower)
334
+ if not vals:
335
+ return None
336
+ a, b = vals
337
+ return _make_result(
338
+ topic="ratio",
339
+ internal_answer=_safe_ratio_answer(a, b),
340
+ steps=[
341
+ "The phrase 'out of' means part-to-whole.",
342
+ "Write the first quantity over the total in the same order.",
343
+ "Then simplify the ratio if possible.",
344
+ ],
345
+ )
346
+
347
+
348
+ def _solve_fraction_ratio(lower: str) -> Optional[SolverResult]:
349
+ vals = _extract_fraction_ratio(lower)
350
+ if not vals:
351
+ return None
352
+ a, b = vals
353
+ return _make_result(
354
+ topic="ratio",
355
+ internal_answer=_safe_ratio_answer(a, b),
356
+ steps=[
357
+ "A ratio written as a fraction compares numerator to denominator.",
358
+ "Rewrite the fraction as a:b.",
359
+ "Then simplify if needed.",
360
+ ],
361
+ )
362
+
363
+
364
+ def _solve_partition_total_2(lower: str) -> Optional[SolverResult]:
365
+ pair = _extract_ratio_pair(lower)
366
+ total = _extract_total(lower)
367
+ if not pair or total is None:
368
+ return None
369
+
370
+ a, b = pair
371
+ unit = total / (a + b)
372
+ x1 = a * unit
373
+ x2 = b * unit
374
+
375
+ ask = _extract_part_of_total_question(lower)
376
+
377
+ if ask:
378
+ named = _extract_named_initial_ratio(lower)
379
+ if named:
380
+ if ask == named["name1"]:
381
+ ans = x1
382
+ elif ask == named["name2"]:
383
+ ans = x2
384
+ else:
385
+ ans = None
386
+ if ans is not None:
387
+ return _make_result(
388
+ topic="ratio_partition",
389
+ internal_answer=_fmt_num(ans),
390
+ steps=[
391
+ "The question is asking for one share from a total split in a given ratio.",
392
+ "Add the ratio parts to find the total number of equal units.",
393
+ "Divide the total by that number to get one unit.",
394
+ "Multiply by the requested part of the ratio.",
395
+ ],
396
+ )
397
+
398
+ return _make_result(
399
+ topic="ratio_partition",
400
+ internal_answer=f"{_fmt_num(x1)}, {_fmt_num(x2)}",
401
+ steps=[
402
+ "The question is asking you to split a total in a given ratio.",
403
+ "Add the ratio parts to get the number of equal units.",
404
+ "Find one unit by dividing the total by the total number of ratio parts.",
405
+ "Multiply each ratio part by that unit value.",
406
+ ],
407
+ )
408
+
409
+
410
+ def _solve_partition_difference(lower: str) -> Optional[SolverResult]:
411
+ pair = _extract_ratio_pair(lower)
412
+ diff = _extract_difference(lower)
413
+ if not pair or diff is None:
414
+ return None
415
+
416
+ a, b = pair
417
+ part_diff = abs(a - b)
418
+ if isclose(part_diff, 0.0):
419
+ return None
420
+
421
+ unit = diff / part_diff
422
+ x1 = a * unit
423
+ x2 = b * unit
424
+
425
+ return _make_result(
426
+ topic="ratio_difference",
427
+ internal_answer=f"{_fmt_num(x1)}, {_fmt_num(x2)}",
428
+ steps=[
429
+ "The question gives the ratio and the actual difference between the quantities.",
430
+ "Subtract the ratio parts to find the difference in ratio units.",
431
+ "Match the real difference to those units to find one unit.",
432
+ "Scale each ratio part by that unit value.",
433
+ ],
434
+ )
435
+
436
+
437
+ def _solve_missing_term_proportion(lower: str) -> Optional[SolverResult]:
438
+ vals = _extract_missing_term_proportion(lower)
439
+ if not vals:
440
+ return None
441
+
442
+ parsed: List[Optional[float]] = []
443
+ x_index = None
444
+ for i, v in enumerate(vals):
445
+ if v == "x":
446
+ parsed.append(None)
447
+ x_index = i
448
+ else:
449
+ parsed.append(_to_float(v))
450
+
451
+ a, b, c, d = parsed
452
+ x = None
453
+
454
+ if x_index == 0 and b not in (None, 0) and c is not None and d not in (None, 0):
455
+ x = (b * c) / d
456
+ elif x_index == 1 and a is not None and c is not None and c != 0 and d is not None:
457
+ x = (a * d) / c
458
+ elif x_index == 2 and a is not None and b not in (None, 0) and d is not None:
459
+ x = (a * d) / b
460
+ elif x_index == 3 and a not in (None, 0) and b is not None and c is not None:
461
+ x = (b * c) / a
462
+
463
+ if x is None:
464
+ return None
465
+
466
+ return _make_result(
467
+ topic="proportion",
468
+ internal_answer=_fmt_num(x),
469
+ steps=[
470
+ "The question is asking for a missing term in equivalent ratios.",
471
+ "Keep the positions of corresponding quantities aligned.",
472
+ "Use the fact that equal ratios represent the same multiplicative relationship.",
473
+ "Solve for the unknown term without changing the order of the ratio.",
474
+ ],
475
+ )
476
+
477
+
478
+ def _solve_chain_ratio(lower: str) -> Optional[SolverResult]:
479
+ info = _extract_chain_ratio(lower)
480
+ if not info:
481
+ return None
482
+
483
+ if info["r1"] != info["l2"]:
484
+ return None
485
+
486
+ a, b, c, d = info["a"], info["b"], info["c"], info["d"]
487
+ if not all(isclose(v, round(v), abs_tol=1e-9) for v in [a, b, c, d]):
488
+ return None
489
+
490
+ a, b, c, d = map(lambda z: int(round(z)), [a, b, c, d])
491
+
492
+ lcm_mid = b * c // gcd(b, c)
493
+ scale1 = lcm_mid // b
494
+ scale2 = lcm_mid // c
495
+
496
+ mapping = {
497
+ info["l1"]: a * scale1,
498
+ info["r1"]: lcm_mid,
499
+ info["r2"]: d * scale2,
500
+ }
501
+
502
+ if info["t1"] not in mapping or info["t2"] not in mapping:
503
+ return None
504
+
505
+ ans = _safe_ratio_answer(mapping[info["t1"]], mapping[info["t2"]])
506
+ return _make_result(
507
+ topic="ratio_chain",
508
+ internal_answer=ans,
509
+ steps=[
510
+ "The question is asking you to combine two linked ratios with a shared middle term.",
511
+ "Make the shared term equal in both ratios.",
512
+ "Then compare the two end terms and simplify.",
513
+ ],
514
+ )
515
+
516
+
517
+ def _solve_three_part_total(lower: str) -> Optional[SolverResult]:
518
+ triple = _extract_three_part_ratio(lower)
519
+ total = _extract_total(lower)
520
+ if not triple or total is None:
521
+ return None
522
+
523
+ a, b, c = triple
524
+ units = a + b + c
525
+ if isclose(units, 0.0):
526
+ return None
527
+
528
+ u = total / units
529
+ x1, x2, x3 = a * u, b * u, c * u
530
+
531
+ return _make_result(
532
+ topic="ratio_three_part",
533
+ internal_answer=f"{_fmt_num(x1)}, {_fmt_num(x2)}, {_fmt_num(x3)}",
534
+ steps=[
535
+ "The question is asking you to divide a total among three quantities in a given ratio.",
536
+ "Add all ratio parts to get the total number of equal units.",
537
+ "Divide the total by that number to find one unit.",
538
+ "Multiply each ratio part by the unit value.",
539
+ ],
540
+ )
541
+
542
+
543
+ def _solve_named_changed_ratio(lower: str) -> Optional[SolverResult]:
544
+ initial = _extract_named_initial_ratio(lower)
545
+ change = _extract_named_change(lower)
546
+ if not initial or not change:
547
+ return None
548
+
549
+ name1 = initial["name1"]
550
+ name2 = initial["name2"]
551
+ a = initial["a"]
552
+ b = initial["b"]
553
+
554
+ changed = change["changed_name"]
555
+ delta = change["delta"]
556
+ new_a = change["new_a"]
557
+ new_b = change["new_b"]
558
+
559
+ # original quantities = ax and bx
560
+ # if name1 changed: (ax + delta)/(bx) = new_a/new_b
561
+ # if name2 changed: (ax)/(bx + delta) = new_a/new_b
562
+ if changed == name1:
563
+ denom = new_b * a - new_a * b
564
+ if isclose(denom, 0.0):
565
+ return None
566
+ x = -(new_b * delta) / denom
567
+ elif changed == name2:
568
+ denom = new_b * a - new_a * b
569
+ if isclose(denom, 0.0):
570
+ return None
571
+ x = (new_a * delta) / denom
572
+ else:
573
+ return None
574
+
575
+ if x < 0:
576
+ return None
577
+
578
+ q1 = a * x
579
+ q2 = b * x
580
+
581
+ return _make_result(
582
+ topic="ratio_change",
583
+ internal_answer=f"{_fmt_num(q1)}, {_fmt_num(q2)}",
584
+ steps=[
585
+ "This is a ratio problem with an absolute change to one named group.",
586
+ "Represent the original groups as ratio parts multiplied by the same scale factor.",
587
+ "Apply the increase or decrease only to the group that changed.",
588
+ "Set the new expression equal to the new ratio and solve for the common scale factor.",
589
+ "Use that factor to recover the original quantities.",
590
+ ],
591
+ )
592
+
593
+
594
+ def _solve_transfer_between_groups(lower: str) -> Optional[SolverResult]:
595
+ initial = _extract_named_initial_ratio(lower)
596
+ transfer = _extract_transfer_between_groups(lower)
597
+ if not initial or not transfer:
598
+ return None
599
+
600
+ name1 = initial["name1"]
601
+ name2 = initial["name2"]
602
+ a = initial["a"]
603
+ b = initial["b"]
604
+
605
+ from_name = transfer["from_name"]
606
+ to_name = transfer["to_name"]
607
+ delta = transfer["delta"]
608
+ new_a = transfer["new_a"]
609
+ new_b = transfer["new_b"]
610
+
611
+ # original ax, bx
612
+ if from_name == name1 and to_name == name2:
613
+ # (ax - d)/(bx + d) = new_a/new_b
614
+ denom = new_b * a - new_a * b
615
+ if isclose(denom, 0.0):
616
+ return None
617
+ x = delta * (new_a + new_b) / denom
618
+ elif from_name == name2 and to_name == name1:
619
+ # (ax + d)/(bx - d) = new_a/new_b
620
+ denom = new_b * a - new_a * b
621
+ if isclose(denom, 0.0):
622
+ return None
623
+ x = -delta * (new_a + new_b) / denom
624
+ else:
625
+ return None
626
+
627
+ if x < 0:
628
+ return None
629
+
630
+ q1 = a * x
631
+ q2 = b * x
632
+
633
+ return _make_result(
634
+ topic="ratio_transfer",
635
+ internal_answer=f"{_fmt_num(q1)}, {_fmt_num(q2)}",
636
+ steps=[
637
+ "This is a transfer problem, so one group decreases while the other increases by the same amount.",
638
+ "Represent the original groups using a common scale factor based on the initial ratio.",
639
+ "Apply the transfer in opposite directions to the two groups.",
640
+ "Match the resulting quantities to the new ratio and solve for the scale factor.",
641
+ "Then recover the original group sizes.",
642
+ ],
643
+ )
644
+
645
+
646
+ def _solve_direct_proportion_text(lower: str) -> Optional[SolverResult]:
647
+ if not any(k in lower for k in ["proportion", "proportional", "same rate", "spread evenly", "uniformly"]):
648
+ return None
649
+
650
+ nums = [float(x) for x in re.findall(_NUMBER, lower)]
651
+ if len(nums) < 3:
652
+ return None
653
+
654
+ whole1, whole2, part1 = nums[0], nums[1], nums[2]
655
+ if isclose(whole1, 0.0):
656
+ return None
657
+
658
+ x = whole2 * part1 / whole1
659
+
660
+ return _make_result(
661
+ topic="proportion",
662
+ internal_answer=_fmt_num(x),
663
+ steps=[
664
+ "The question is asking for a missing amount under the same rate or same distribution pattern.",
665
+ "Set up equivalent ratios so whole-to-whole matches part-to-part.",
666
+ "Keep corresponding quantities aligned in the same order.",
667
+ "Then solve the proportion for the missing term.",
668
+ ],
669
+ )
670
+
671
+
672
+ def _solve_ratio(text: str) -> Optional[SolverResult]:
673
+ lower = _clean(text).lower()
674
+
675
+ if not _has_ratio_intent(lower):
676
+ return None
677
+
678
+ solvers = [
679
+ _solve_named_changed_ratio,
680
+ _solve_transfer_between_groups,
681
+ _solve_partition_total_2,
682
+ _solve_three_part_total,
683
+ _solve_partition_difference,
684
+ _solve_missing_term_proportion,
685
+ _solve_chain_ratio,
686
+ _solve_direct_proportion_text,
687
+ _solve_fraction_ratio,
688
+ _solve_out_of_ratio,
689
+ _solve_simplify_ratio,
690
+ ]
691
+
692
+ for solver in solvers:
693
+ result = solver(lower)
694
+ if result is not None:
695
+ return result
696
+
697
+ return _make_result(
698
+ topic="ratio",
699
+ internal_answer=None,
700
+ answer_value=None,
701
+ solved=False,
702
+ steps=[
703
+ "This looks like a ratio or proportion problem, but the exact structure is not fully clear yet.",
704
+ "First identify which quantities are being compared and in what order.",
705
+ "Then decide whether the problem is about simplifying, splitting a total, using a difference, handling a change, or setting up an equivalent proportion.",
706
+ "Once that structure is clear, the relationship can be translated into ratio units or algebra cleanly.",
707
+ ],
708
+ )
709
+
710
+
711
+ def solve_ratio(text: str) -> Optional[SolverResult]:
712
+ return _solve_ratio(text)