j-js commited on
Commit
3795483
·
verified ·
1 Parent(s): 5ec6d54

Update solver_work_rate.py

Browse files
Files changed (1) hide show
  1. solver_work_rate.py +776 -21
solver_work_rate.py CHANGED
@@ -1,42 +1,797 @@
1
  from __future__ import annotations
2
 
 
3
  import re
4
- from typing import Optional
 
5
 
6
  from models import SolverResult
7
 
8
 
9
- def solve_work_rate(text: str) -> Optional[SolverResult]:
 
 
10
 
11
- lower = text.lower()
 
 
12
 
13
- if "together" not in lower and "work together" not in lower:
14
- return None
15
 
16
- times = [float(x) for x in re.findall(r"\d+(?:\.\d+)?", lower)]
 
 
 
 
 
 
17
 
18
- if len(times) < 2:
 
 
 
 
 
 
 
 
 
 
19
  return None
 
20
 
21
- t1 = times[0]
22
- t2 = times[1]
23
 
24
- rate = (1 / t1) + (1 / t2)
 
25
 
26
- if rate == 0:
 
 
 
 
27
  return None
 
 
 
 
28
 
29
- total_time = 1 / rate
 
 
 
 
 
 
 
 
 
 
 
30
 
 
31
  return SolverResult(
32
  domain="quant",
33
- solved=True,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  topic="work_rate",
35
- answer_value=f"{total_time:g}",
36
- internal_answer=f"{total_time:g}",
37
- steps=[
38
- "Convert each time to a work rate.",
39
- "Add the rates together.",
40
- "Take the reciprocal to get the total time."
41
- ]
42
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
+ import math
4
  import re
5
+ from fractions import Fraction
6
+ from typing import Callable, Iterable, Optional
7
 
8
  from models import SolverResult
9
 
10
 
11
+ # ============================================================
12
+ # Core helpers
13
+ # ============================================================
14
 
15
+ NUMBER = r"-?\d+(?:\.\d+)?"
16
+ FRACTION = r"\d+\s*/\s*\d+"
17
+ NUMTOKEN = rf"(?:{FRACTION}|{NUMBER})"
18
 
 
 
19
 
20
+ def _norm(text: str) -> str:
21
+ s = (text or "").strip()
22
+ s = s.replace("’", "'").replace("“", '"').replace("”", '"')
23
+ s = s.replace("–", "-").replace("—", "-")
24
+ s = s.replace("per cent", "percent")
25
+ s = re.sub(r"\s+", " ", s)
26
+ return s
27
 
28
+
29
+ def _to_float(token: str) -> float:
30
+ token = token.strip()
31
+ if "/" in token and re.fullmatch(r"\d+\s*/\s*\d+", token):
32
+ a, b = token.split("/")
33
+ return float(Fraction(int(a.strip()), int(b.strip())))
34
+ return float(token)
35
+
36
+
37
+ def _safe_div(a: float, b: float) -> Optional[float]:
38
+ if abs(b) < 1e-12:
39
  return None
40
+ return a / b
41
 
 
 
42
 
43
+ def _approx_int(x: float, tol: float = 1e-9) -> bool:
44
+ return abs(x - round(x)) <= tol
45
 
46
+
47
+ def _clean_num(x: Optional[float]) -> Optional[str]:
48
+ if x is None:
49
+ return None
50
+ if not math.isfinite(x):
51
  return None
52
+ if _approx_int(x):
53
+ return str(int(round(x)))
54
+ return f"{x:.6g}"
55
+
56
 
57
+ def _make_result(
58
+ topic: str,
59
+ internal_answer: Optional[float],
60
+ steps: list[str],
61
+ method: Optional[str] = None,
62
+ what_is_asked: Optional[str] = None,
63
+ ) -> SolverResult:
64
+ extra_steps = []
65
+ if what_is_asked:
66
+ extra_steps.append(f"What the question is asking: {what_is_asked}")
67
+ if method:
68
+ extra_steps.append(f"Method: {method}")
69
 
70
+ # Do not reveal the final value in visible fields.
71
  return SolverResult(
72
  domain="quant",
73
+ solved=internal_answer is not None,
74
+ topic=topic,
75
+ answer_value=None,
76
+ internal_answer=_clean_num(internal_answer),
77
+ steps=extra_steps + steps,
78
+ )
79
+
80
+
81
+ def _contains_any(lower: str, phrases: Iterable[str]) -> bool:
82
+ return any(p in lower for p in phrases)
83
+
84
+
85
+ def _extract_all_numbers(text: str) -> list[float]:
86
+ return [_to_float(m.group(0)) for m in re.finditer(NUMTOKEN, text)]
87
+
88
+
89
+ def _extract_time_units(lower: str) -> bool:
90
+ return bool(re.search(r"\b(hour|hours|hr|hrs|minute|minutes|min|day|days)\b", lower))
91
+
92
+
93
+ def _workish(lower: str) -> bool:
94
+ keywords = [
95
+ "work", "together", "alone", "finish", "complete", "completed",
96
+ "job", "task", "paint", "printer", "prints", "machine", "machines",
97
+ "crew", "worker", "workers", "pipe", "pipes", "tap", "faucet",
98
+ "fill", "filled", "drain", "drains", "empty", "empties",
99
+ "leak", "leaks", "produce", "produces", "manufacture", "manufactures",
100
+ "pages per minute", "sprockets per hour", "rate"
101
+ ]
102
+ return _contains_any(lower, keywords)
103
+
104
+
105
+ def _distanceish(lower: str) -> bool:
106
+ keywords = [
107
+ "distance", "mile", "miles", "km", "kilometer", "kilometers",
108
+ "speed", "mph", "kph", "miles per hour", "kilometers per hour",
109
+ "travel", "travels", "moving", "train", "car", "bike", "walk",
110
+ "upstream", "downstream", "current", "stream"
111
+ ]
112
+ return _contains_any(lower, keywords)
113
+
114
+
115
+ def _find_times_complete_job(text: str) -> list[float]:
116
+ """
117
+ Finds patterns like:
118
+ - A can do the job in 3 hours
119
+ - finishes a task in 24 minutes
120
+ - alone would finish in 60 minutes
121
+ """
122
+ pats = [
123
+ rf"\bin\s+({NUMTOKEN})\s*(?:hour|hours|hr|hrs|minute|minutes|min|day|days)\b",
124
+ rf"\btakes?\s+({NUMTOKEN})\s*(?:hour|hours|hr|hrs|minute|minutes|min|day|days)\b",
125
+ rf"\bfinish(?:es)?(?:\s+the\s+\w+)?\s+in\s+({NUMTOKEN})\s*(?:hour|hours|hr|hrs|minute|minutes|min|day|days)\b",
126
+ rf"\bcomplete(?:s|d)?(?:\s+the\s+\w+)?\s+in\s+({NUMTOKEN})\s*(?:hour|hours|hr|hrs|minute|minutes|min|day|days)\b",
127
+ ]
128
+ vals: list[float] = []
129
+ for pat in pats:
130
+ for m in re.finditer(pat, text, flags=re.I):
131
+ vals.append(_to_float(m.group(1)))
132
+ return vals
133
+
134
+
135
+ def _find_percent_more_less(lower: str) -> Optional[tuple[str, float]]:
136
+ """
137
+ Returns ("more"/"less", percent_as_decimal)
138
+ """
139
+ m = re.search(rf"({NUMTOKEN})\s*%\s*(more|less)", lower)
140
+ if m:
141
+ return m.group(2), _to_float(m.group(1)) / 100.0
142
+ return None
143
+
144
+
145
+ def _find_times_faster_slower(lower: str) -> Optional[tuple[str, float]]:
146
+ """
147
+ Examples:
148
+ - B is 2 times as fast as A
149
+ - Y's rate is 1/10 of X
150
+ """
151
+ m = re.search(rf"\b({NUMTOKEN})\s+times?\s+as\s+(fast|slow)\b", lower)
152
+ if m:
153
+ return m.group(2), _to_float(m.group(1))
154
+
155
+ m2 = re.search(rf"\brate\s+is\s+({NUMTOKEN})\s+of\b", lower)
156
+ if m2:
157
+ return "multiplier", _to_float(m2.group(1))
158
+ return None
159
+
160
+
161
+ # ============================================================
162
+ # Work-rate solving blocks
163
+ # ============================================================
164
+
165
+ def _solve_basic_together(text: str, lower: str) -> Optional[SolverResult]:
166
+ """
167
+ Basic:
168
+ A in t1, B in t2, how long together?
169
+ Also handles 3+ workers if multiple independent completion times appear.
170
+ """
171
+ if not _contains_any(lower, ["together", "work together", "working together"]):
172
+ return None
173
+
174
+ times = _find_times_complete_job(text)
175
+ if len(times) < 2:
176
+ # fallback: use first two numeric tokens only when wording is clearly work-like
177
+ nums = _extract_all_numbers(text)
178
+ if len(nums) >= 2 and _workish(lower) and _extract_time_units(lower):
179
+ times = nums[:2]
180
+
181
+ if len(times) < 2:
182
+ return None
183
+
184
+ rates = []
185
+ for t in times:
186
+ if t <= 0:
187
+ return None
188
+ rates.append(1.0 / t)
189
+
190
+ total_rate = sum(rates)
191
+ total_time = _safe_div(1.0, total_rate)
192
+ if total_time is None:
193
+ return None
194
+
195
+ return _make_result(
196
+ topic="work_rate",
197
+ internal_answer=total_time,
198
+ what_is_asked="how to combine individual completion times into one combined completion time",
199
+ method="Convert each worker's time into rate = 1/job per time unit, add the rates, then invert.",
200
+ steps=[
201
+ "Identify each worker/machine's individual time for one full job.",
202
+ "Convert each time into a per-unit work rate using rate = 1 ÷ time.",
203
+ "Add the rates because work done in the same unit of time combines.",
204
+ "Take the reciprocal of the combined rate to get total time.",
205
+ ],
206
+ )
207
+
208
+
209
+ def _solve_combined_and_one_alone(text: str, lower: str) -> Optional[SolverResult]:
210
+ """
211
+ Together time + one alone time => other alone time.
212
+ Example:
213
+ A and B together finish in 24 min. A alone in 60 min. How long for B alone?
214
+ """
215
+ if not _contains_any(lower, ["together", "alone"]):
216
+ return None
217
+
218
+ nums = _extract_all_numbers(text)
219
+ if len(nums) < 2:
220
+ return None
221
+
222
+ # Heuristic: first number = together time, second = one-alone time
223
+ t_together = nums[0]
224
+ t_one = nums[1]
225
+ if t_together <= 0 or t_one <= 0:
226
+ return None
227
+
228
+ rate_other = (1.0 / t_together) - (1.0 / t_one)
229
+ t_other = _safe_div(1.0, rate_other)
230
+ if t_other is None or t_other <= 0:
231
+ return None
232
+
233
+ ask_other_alone = _contains_any(
234
+ lower,
235
+ ["how long will", "how long would", "how much time", "alone", "by himself", "by herself", "by itself"]
236
+ )
237
+ if not ask_other_alone:
238
+ return None
239
+
240
+ return _make_result(
241
+ topic="work_rate",
242
+ internal_answer=t_other,
243
+ what_is_asked="the missing individual completion time when the combined time and one person's time are known",
244
+ method="Use combined rate minus known individual rate to isolate the unknown rate, then invert.",
245
+ steps=[
246
+ "Write the combined rate as 1 ÷ together-time.",
247
+ "Write the known person's rate as 1 ÷ known-alone-time.",
248
+ "Subtract to get the unknown person's rate.",
249
+ "Invert that unknown rate to get the missing alone time.",
250
+ ],
251
+ )
252
+
253
+
254
+ def _solve_fraction_done_by_x_then_y(text: str, lower: str) -> Optional[SolverResult]:
255
+ """
256
+ Example:
257
+ X takes 12 hours. He finishes 2/3 of the work.
258
+ Rest is finished by Y whose rate is 1/10 of X.
259
+ How much time does Y take for his portion?
260
+ """
261
+ if "rest of the work" not in lower and "remaining" not in lower and "finishes" not in lower:
262
+ return None
263
+
264
+ base_time_match = re.search(
265
+ rf"\btakes?\s+({NUMTOKEN})\s*(hour|hours|hr|hrs|minute|minutes|min|day|days)\b",
266
+ lower
267
+ )
268
+ frac_done_match = re.search(rf"\bfinishes?\s+({NUMTOKEN})\s+of\s+the\s+work\b", lower)
269
+ rate_ratio_match = re.search(rf"\brate\s+is\s+({NUMTOKEN})\s+of\b", lower)
270
+
271
+ if not (base_time_match and frac_done_match and rate_ratio_match):
272
+ return None
273
+
274
+ base_time = _to_float(base_time_match.group(1))
275
+ frac_done = _to_float(frac_done_match.group(1))
276
+ ratio = _to_float(rate_ratio_match.group(1))
277
+
278
+ if base_time <= 0 or ratio <= 0 or frac_done <= 0 or frac_done >= 1:
279
+ return None
280
+
281
+ remaining = 1.0 - frac_done
282
+ x_rate = 1.0 / base_time
283
+ y_rate = ratio * x_rate
284
+ y_time = _safe_div(remaining, y_rate)
285
+ if y_time is None or y_time <= 0:
286
+ return None
287
+
288
+ return _make_result(
289
+ topic="work_rate",
290
+ internal_answer=y_time,
291
+ what_is_asked="the time needed for a second worker to complete only the remaining fraction of the job",
292
+ method="Convert the first worker's time to rate, scale the second worker's rate from the ratio, then divide remaining work by that rate.",
293
+ steps=[
294
+ "Convert the known worker's full-job time into a unit rate.",
295
+ "Translate the completed fraction into remaining work: remaining = 1 - completed fraction.",
296
+ "Use the stated rate ratio to get the second worker's rate.",
297
+ "Use time = remaining work ÷ rate.",
298
+ ],
299
+ )
300
+
301
+
302
+ def _solve_work_after_some_time(text: str, lower: str) -> Optional[SolverResult]:
303
+ """
304
+ General remaining-work setup:
305
+ A can do in a hours, B in b hours. They work together for x hours.
306
+ How much remains / what fraction remains / how much completed?
307
+ """
308
+ if not _contains_any(lower, ["remain", "remains", "remaining", "left", "completed", "after"]):
309
+ return None
310
+
311
+ nums = _extract_all_numbers(text)
312
+ if len(nums) < 3:
313
+ return None
314
+
315
+ # common GMAT-style heuristic: first two are full-job times, third is elapsed time
316
+ a, b, x = nums[0], nums[1], nums[2]
317
+ if a <= 0 or b <= 0 or x < 0:
318
+ return None
319
+
320
+ combined = (1.0 / a) + (1.0 / b)
321
+ completed = combined * x
322
+ remaining = 1.0 - completed
323
+
324
+ if "remain" in lower or "left" in lower or "remaining" in lower:
325
+ internal = remaining
326
+ asked = "the fraction of work still left after some amount of joint work"
327
+ else:
328
+ internal = completed
329
+ asked = "the fraction of work completed after some amount of joint work"
330
+
331
+ return _make_result(
332
+ topic="work_rate",
333
+ internal_answer=internal,
334
+ what_is_asked=asked,
335
+ method="Find the combined rate, multiply by elapsed time to get completed work, then subtract from 1 if the question asks for what remains.",
336
+ steps=[
337
+ "Convert each worker's full-job time into a rate.",
338
+ "Add the rates if they are working simultaneously.",
339
+ "Multiply the combined rate by the elapsed time to get the completed fraction.",
340
+ "If the question asks what remains, subtract that completed fraction from 1.",
341
+ ],
342
+ )
343
+
344
+
345
+ def _solve_join_leave_case(text: str, lower: str) -> Optional[SolverResult]:
346
+ """
347
+ Handles:
348
+ - A starts alone, B joins later
349
+ - A and B start together, one leaves
350
+ Requires 3 times/numbers in common patterns.
351
+ """
352
+ join_words = ["joins", "join", "joined", "after", "left", "leaves", "leave"]
353
+ if not _contains_any(lower, join_words):
354
+ return None
355
+
356
+ nums = _extract_all_numbers(text)
357
+ if len(nums) < 3:
358
+ return None
359
+
360
+ a, b, x = nums[0], nums[1], nums[2]
361
+ if a <= 0 or b <= 0 or x < 0:
362
+ return None
363
+
364
+ a_rate = 1.0 / a
365
+ b_rate = 1.0 / b
366
+
367
+ if _contains_any(lower, ["joins", "join", "joined"]):
368
+ completed_before_join = a_rate * x
369
+ remaining = 1.0 - completed_before_join
370
+ together_rate = a_rate + b_rate
371
+ rest_time = _safe_div(remaining, together_rate)
372
+ total_time = None if rest_time is None else x + rest_time
373
+
374
+ if total_time is None or total_time <= 0:
375
+ return None
376
+
377
+ return _make_result(
378
+ topic="work_rate",
379
+ internal_answer=total_time,
380
+ what_is_asked="the total completion time when one worker starts first and another joins later",
381
+ method="Split the job into stages: first-stage work by one worker, then remaining work by the combined rate.",
382
+ steps=[
383
+ "Convert each person's full-job time into a rate.",
384
+ "Compute how much work the first worker finishes before the second joins.",
385
+ "Subtract from 1 to get the remaining work.",
386
+ "Use the combined rate for the remaining stage, then add the elapsed starting time.",
387
+ ],
388
+ )
389
+
390
+ if _contains_any(lower, ["left", "leaves", "leave"]):
391
+ completed_before_leave = (a_rate + b_rate) * x
392
+ remaining = 1.0 - completed_before_leave
393
+ # heuristic: after leaving, first worker continues
394
+ rest_time = _safe_div(remaining, a_rate)
395
+ total_time = None if rest_time is None else x + rest_time
396
+
397
+ if total_time is None or total_time <= 0:
398
+ return None
399
+
400
+ return _make_result(
401
+ topic="work_rate",
402
+ internal_answer=total_time,
403
+ what_is_asked="the total completion time when workers begin together and one stops after a known amount of time",
404
+ method="Split the timeline into stages: together first, then remaining work by the continuing worker.",
405
+ steps=[
406
+ "Convert each worker's time into a unit work rate.",
407
+ "Find the completed fraction during the stage when both are active.",
408
+ "Subtract from 1 to get the remaining fraction.",
409
+ "Finish the remainder using the continuing worker's rate and add stage times.",
410
+ ],
411
+ )
412
+
413
+ return None
414
+
415
+
416
+ def _solve_fill_drain(text: str, lower: str) -> Optional[SolverResult]:
417
+ """
418
+ Pipes/tanks:
419
+ fill in a hours, drain/empty in b hours => net rate = 1/a - 1/b
420
+ """
421
+ if not _contains_any(lower, ["pipe", "pipes", "fill", "filled", "drain", "drains", "empty", "empties", "leak", "leaks", "tank", "cistern"]):
422
+ return None
423
+
424
+ nums = _extract_all_numbers(text)
425
+ if len(nums) < 2:
426
+ return None
427
+
428
+ a, b = nums[0], nums[1]
429
+ if a <= 0 or b <= 0:
430
+ return None
431
+
432
+ fill_rate = 1.0 / a
433
+ drain_rate = 1.0 / b
434
+ net = fill_rate - drain_rate
435
+
436
+ if net <= 0:
437
+ return None
438
+
439
+ t = _safe_div(1.0, net)
440
+ if t is None:
441
+ return None
442
+
443
+ return _make_result(
444
+ topic="work_rate",
445
+ internal_answer=t,
446
+ what_is_asked="the net completion time when one process adds work and another removes it",
447
+ method="Treat filling as positive rate and draining/leaking as negative rate, then invert the net rate.",
448
+ steps=[
449
+ "Convert the filling time into a positive rate.",
450
+ "Convert the draining/leaking time into a negative rate.",
451
+ "Subtract the drain rate from the fill rate to get the net rate.",
452
+ "Take the reciprocal of the net rate to get total time.",
453
+ ],
454
+ )
455
+
456
+
457
+ def _solve_page_rate_difference(text: str, lower: str) -> Optional[SolverResult]:
458
+ """
459
+ Example:
460
+ Together finish in 24 min. A alone in 60 min.
461
+ B prints 5 pages/min more than A.
462
+ How many pages in task?
463
+ """
464
+ if "pages" not in lower and "pages a minute" not in lower and "prints" not in lower:
465
+ return None
466
+
467
+ nums = _extract_all_numbers(text)
468
+ if len(nums) < 3:
469
+ return None
470
+
471
+ together_time, a_time, diff_pages = nums[0], nums[1], nums[2]
472
+ if together_time <= 0 or a_time <= 0 or diff_pages <= 0:
473
+ return None
474
+
475
+ # difference in job/minute between B and A
476
+ b_job_rate = (1.0 / together_time) - (1.0 / a_time)
477
+ a_job_rate = 1.0 / a_time
478
+ job_rate_difference = abs(b_job_rate - a_job_rate)
479
+
480
+ if job_rate_difference <= 0:
481
+ return None
482
+
483
+ pages_total = _safe_div(diff_pages, job_rate_difference)
484
+ if pages_total is None or pages_total <= 0:
485
+ return None
486
+
487
+ return _make_result(
488
+ topic="work_rate",
489
+ internal_answer=pages_total,
490
+ what_is_asked="the total size of the job when job-rate differences correspond to a page-per-minute difference",
491
+ method="Translate both workers into job/minute, compare those job-rates, then scale that difference up to the stated page difference.",
492
+ steps=[
493
+ "Write the combined job rate using the together time.",
494
+ "Write the known worker's job rate using the alone time.",
495
+ "Subtract to get the other worker's job rate.",
496
+ "Find the difference in their job-rates per minute.",
497
+ "Use the fact that this difference corresponds to the stated pages-per-minute gap to scale up to the full job size.",
498
+ ],
499
+ )
500
+
501
+
502
+ def _solve_percent_more_output_fixed_total(text: str, lower: str) -> Optional[SolverResult]:
503
+ """
504
+ Example:
505
+ A takes 10 hours longer than B to produce 660 sprockets.
506
+ B produces 10% more per hour than A.
507
+ Find A's rate.
508
+ """
509
+ if not _contains_any(lower, ["more per hour", "less per hour", "produces", "produce", "manufacture", "sprockets", "pages"]):
510
+ return None
511
+
512
+ nums = _extract_all_numbers(text)
513
+ pm = _find_percent_more_less(lower)
514
+
515
+ if len(nums) < 3 or pm is None:
516
+ return None
517
+
518
+ total_units = nums[0]
519
+ time_difference = nums[1]
520
+ kind, pct = pm
521
+
522
+ if total_units <= 0 or time_difference <= 0 or pct <= 0:
523
+ return None
524
+
525
+ # Let A = r units/time. B = r(1+p) if "more", else r(1-p)
526
+ multiplier = (1.0 + pct) if kind == "more" else (1.0 - pct)
527
+ if multiplier <= 0:
528
+ return None
529
+
530
+ # total/r - total/(multiplier*r) = time_difference
531
+ # total * (1 - 1/multiplier) / r = time_difference
532
+ numerator = total_units * (1.0 - 1.0 / multiplier)
533
+ r = _safe_div(numerator, time_difference)
534
+ if r is None or r <= 0:
535
+ return None
536
+
537
+ return _make_result(
538
  topic="work_rate",
539
+ internal_answer=r,
540
+ what_is_asked="the base production rate when total output, time difference, and relative efficiency are given",
541
+ method="Let the slower rate be r, express the faster rate using the percent relationship, write time = total ÷ rate for each, and subtract the times.",
542
+ steps=[
543
+ "Assign a variable to the unknown base production rate.",
544
+ "Translate the percent-more/less statement into the other machine's rate.",
545
+ "Write each completion time as total output ÷ rate.",
546
+ "Use the stated time difference to form one equation and solve for the base rate.",
547
+ ],
548
+ )
549
+
550
+
551
+ def _solve_direct_rate_from_units_and_time(text: str, lower: str) -> Optional[SolverResult]:
552
+ """
553
+ Direct unit-rate:
554
+ 660 sprockets in 12 hours -> 55 per hour
555
+ distance/rate/time analogue included.
556
+ """
557
+ if not _contains_any(lower, ["per hour", "per minute", "per day", "rate", "speed", "mph", "kph"]):
558
+ return None
559
+
560
+ nums = _extract_all_numbers(text)
561
+ if len(nums) < 2:
562
+ return None
563
+
564
+ a, b = nums[0], nums[1]
565
+ if a <= 0 or b <= 0:
566
+ return None
567
+
568
+ # Heuristic: if asks "per hour" or "speed", compute units/time
569
+ if _contains_any(lower, ["per hour", "per minute", "per day", "speed", "mph", "kph"]):
570
+ rate = _safe_div(a, b)
571
+ if rate is None or rate <= 0:
572
+ return None
573
+
574
+ topic = "distance_rate" if _distanceish(lower) else "work_rate"
575
+ asked = (
576
+ "the unit rate or speed from a total amount and a time"
577
+ if topic == "work_rate"
578
+ else "the speed from distance and time"
579
+ )
580
+ method = (
581
+ "Use rate = total work ÷ time."
582
+ if topic == "work_rate"
583
+ else "Use speed = distance ÷ time."
584
+ )
585
+
586
+ return _make_result(
587
+ topic=topic,
588
+ internal_answer=rate,
589
+ what_is_asked=asked,
590
+ method=method,
591
+ steps=[
592
+ "Identify the total amount completed or traveled.",
593
+ "Identify the time taken.",
594
+ "Use rate = amount ÷ time.",
595
+ ],
596
+ )
597
+
598
+ return None
599
+
600
+
601
+ # ============================================================
602
+ # Distance/rate sub-blocks
603
+ # ============================================================
604
+
605
+ def _solve_distance_speed_time(text: str, lower: str) -> Optional[SolverResult]:
606
+ """
607
+ Basic distance = rate * time family.
608
+ Supports solving for one missing quantity in simple 2-number problems.
609
+ """
610
+ if not _distanceish(lower):
611
+ return None
612
+
613
+ nums = _extract_all_numbers(text)
614
+ if len(nums) < 2:
615
+ return None
616
+
617
+ a, b = nums[0], nums[1]
618
+ if a <= 0 or b <= 0:
619
+ return None
620
+
621
+ if "how far" in lower or "distance" in lower:
622
+ d = a * b
623
+ return _make_result(
624
+ topic="distance_rate",
625
+ internal_answer=d,
626
+ what_is_asked="the distance traveled from speed and time",
627
+ method="Use distance = rate × time.",
628
+ steps=[
629
+ "Identify the rate/speed.",
630
+ "Identify the travel time.",
631
+ "Multiply rate by time to get distance.",
632
+ ],
633
+ )
634
+
635
+ if "how long" in lower or "how much time" in lower:
636
+ t = _safe_div(a, b)
637
+ if t is None or t <= 0:
638
+ return None
639
+ return _make_result(
640
+ topic="distance_rate",
641
+ internal_answer=t,
642
+ what_is_asked="the travel time from distance and speed",
643
+ method="Use time = distance ÷ rate.",
644
+ steps=[
645
+ "Identify the total distance.",
646
+ "Identify the speed.",
647
+ "Divide distance by speed to get time.",
648
+ ],
649
+ )
650
+
651
+ if "what speed" in lower or "what is the speed" in lower or "mph" in lower or "kph" in lower:
652
+ s = _safe_div(a, b)
653
+ if s is None or s <= 0:
654
+ return None
655
+ return _make_result(
656
+ topic="distance_rate",
657
+ internal_answer=s,
658
+ what_is_asked="the speed from distance and time",
659
+ method="Use speed = distance ÷ time.",
660
+ steps=[
661
+ "Identify the distance.",
662
+ "Identify the time.",
663
+ "Divide distance by time to get speed.",
664
+ ],
665
+ )
666
+
667
+ return None
668
+
669
+
670
+ def _solve_relative_speed(text: str, lower: str) -> Optional[SolverResult]:
671
+ """
672
+ Relative speed:
673
+ - towards each other => add speeds
674
+ - same direction / catches up => subtract speeds
675
+ """
676
+ if not _distanceish(lower):
677
+ return None
678
+
679
+ if not _contains_any(lower, ["towards each other", "opposite directions", "same direction", "catch up", "catches up"]):
680
+ return None
681
+
682
+ nums = _extract_all_numbers(text)
683
+ if len(nums) < 2:
684
+ return None
685
+
686
+ v1, v2 = nums[0], nums[1]
687
+ if v1 <= 0 or v2 <= 0:
688
+ return None
689
+
690
+ if _contains_any(lower, ["towards each other", "opposite directions"]):
691
+ rel = v1 + v2
692
+ ask = "the relative speed when two objects move toward each other or in opposite directions"
693
+ meth = "Add the speeds because separation changes by both rates together."
694
+ else:
695
+ rel = abs(v1 - v2)
696
+ ask = "the relative speed when two objects move in the same direction"
697
+ meth = "Subtract the speeds because only the speed difference closes the gap."
698
+
699
+ return _make_result(
700
+ topic="distance_rate",
701
+ internal_answer=rel,
702
+ what_is_asked=ask,
703
+ method=meth,
704
+ steps=[
705
+ "Decide whether the motion uses addition or subtraction of speeds.",
706
+ "Use addition for opposite directions/toward each other.",
707
+ "Use subtraction for same-direction chase situations.",
708
+ ],
709
+ )
710
+
711
+
712
+ # ============================================================
713
+ # Question-explainer fallback
714
+ # ============================================================
715
+
716
+ def _explain_work_rate_without_solving(lower: str) -> Optional[SolverResult]:
717
+ explain_triggers = [
718
+ "what is this asking",
719
+ "what does this mean",
720
+ "how do i set this up",
721
+ "how should i think about this",
722
+ "explain the question",
723
+ "decode the question",
724
+ ]
725
+ if not _contains_any(lower, explain_triggers):
726
+ return None
727
+
728
+ if _workish(lower):
729
+ return _make_result(
730
+ topic="work_rate",
731
+ internal_answer=None,
732
+ what_is_asked="how to translate a work/rate word problem into rate equations",
733
+ method="Use a unit-job model: job = 1, rate = fraction of job per unit time, time = work ÷ rate.",
734
+ steps=[
735
+ "Treat the whole task as 1 complete job.",
736
+ "Convert each person's completion time into a rate using 1 ÷ time.",
737
+ "Add rates when people work together at the same time.",
738
+ "Subtract rates when one process undoes another, like draining or leaking.",
739
+ "For multi-stage problems, track completed work and remaining work separately.",
740
+ "For percentage or ratio statements, convert the wording into algebra before solving.",
741
+ ],
742
+ )
743
+
744
+ if _distanceish(lower):
745
+ return _make_result(
746
+ topic="distance_rate",
747
+ internal_answer=None,
748
+ what_is_asked="how to translate a distance/rate question into equations",
749
+ method="Use the distance-rate-time relationship and choose the form that matches the missing quantity.",
750
+ steps=[
751
+ "Identify which quantity is missing: distance, speed, or time.",
752
+ "Use distance = speed × time.",
753
+ "Rearrange to speed = distance ÷ time or time = distance ÷ speed when needed.",
754
+ "For relative motion, add speeds when moving toward each other and subtract when moving in the same direction.",
755
+ ],
756
+ )
757
+
758
+ return None
759
+
760
+
761
+ # ============================================================
762
+ # Main entry point
763
+ # ============================================================
764
+
765
+ def solve_work_rate(text: str) -> Optional[SolverResult]:
766
+ raw = _norm(text)
767
+ lower = raw.lower()
768
+
769
+ # Broad gateway: this solver now intentionally covers work/rate and
770
+ # a useful distance/rate subset because the user wanted wider rate coverage.
771
+ if not (_workish(lower) or _distanceish(lower) or "rate" in lower):
772
+ return None
773
+
774
+ solvers: list[Callable[[str, str], Optional[SolverResult]]] = [
775
+ _solve_fraction_done_by_x_then_y,
776
+ _solve_page_rate_difference,
777
+ _solve_percent_more_output_fixed_total,
778
+ _solve_join_leave_case,
779
+ _solve_fill_drain,
780
+ _solve_work_after_some_time,
781
+ _solve_combined_and_one_alone,
782
+ _solve_basic_together,
783
+ _solve_relative_speed,
784
+ _solve_distance_speed_time,
785
+ _solve_direct_rate_from_units_and_time,
786
+ _explain_work_rate_without_solving,
787
+ ]
788
+
789
+ for solver in solvers:
790
+ try:
791
+ result = solver(raw, lower)
792
+ if result is not None:
793
+ return result
794
+ except Exception:
795
+ continue
796
+
797
+ return None