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

Update solver_probability.py

Browse files
Files changed (1) hide show
  1. solver_probability.py +908 -107
solver_probability.py CHANGED
@@ -1,155 +1,956 @@
1
  from __future__ import annotations
2
 
 
3
  import re
4
- from math import comb, perm
5
- from typing import Optional, List
 
6
 
7
  from models import SolverResult
8
 
9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  def _nums(text: str) -> List[int]:
11
  return [int(x) for x in re.findall(r"-?\d+", text)]
12
 
13
 
14
- def solve_probability(text: str) -> Optional[SolverResult]:
15
- lower = (text or "").lower()
 
 
 
 
 
 
16
 
17
- prob_words = [
18
- "probability", "chance", "likely", "dice", "die", "coin", "cards",
19
- "card", "deck", "random", "at random", "marble", "ball", "urn",
20
- "without replacement", "with replacement"
21
- ]
22
- if not any(w in lower for w in prob_words):
23
- return None
24
 
25
- nums = _nums(lower)
 
26
 
27
- # Pattern 1: simple favorable / total
28
- m = re.search(
29
- r"probability.*?(\d+).*?out of.*?(\d+)",
30
- lower,
 
 
 
 
 
 
 
 
 
 
31
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  if m:
33
  fav = int(m.group(1))
34
  total = int(m.group(2))
35
  if total == 0:
36
  return None
37
- result = fav / total
38
- return SolverResult(
39
- domain="quant",
40
- solved=True,
41
- topic="probability",
42
- answer_value=f"{result:g}",
43
- internal_answer=f"{result:g}",
44
  steps=[
45
- "Use probability = favorable outcomes ÷ total outcomes.",
 
 
46
  ],
47
  )
48
 
49
- # Pattern 2: one die
50
- if ("die" in lower or "dice" in lower) and "even" in lower:
51
- result = 3 / 6
52
- return SolverResult(
53
- domain="quant",
54
- solved=True,
55
- topic="probability",
56
- answer_value=f"{result:g}",
57
- internal_answer=f"{result:g}",
58
- steps=[
59
- "A fair die has 6 equally likely outcomes.",
60
- "Even outcomes are 2, 4, and 6, so there are 3 favorable outcomes.",
61
- "Probability = 3/6.",
62
- ],
63
- )
 
64
 
65
- if ("coin" in lower) and ("head" in lower or "heads" in lower):
66
- # one fair coin if not otherwise specified
67
- if "twice" not in lower and "two" not in lower:
68
- result = 1 / 2
69
- return SolverResult(
70
- domain="quant",
71
- solved=True,
72
- topic="probability",
73
- answer_value=f"{result:g}",
74
- internal_answer=f"{result:g}",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  steps=[
76
- "A fair coin has 2 equally likely outcomes.",
77
- "Heads is 1 favorable outcome out of 2.",
 
78
  ],
79
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
 
81
- # Pattern 3: at least one head in n fair tosses
82
- m = re.search(r"(?:coin.*?tossed|toss.*?coin).*?(\d+)\s+times", lower)
83
- if m and "at least one head" in lower:
84
- n = int(m.group(1))
85
- result = 1 - (1 / 2) ** n
86
- return SolverResult(
87
- domain="quant",
88
- solved=True,
89
- topic="probability",
90
- answer_value=f"{result:g}",
91
- internal_answer=f"{result:g}",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  steps=[
93
- "Use the complement: at least one head = 1 − P(no heads).",
94
- "For a fair coin, P(no heads in n tosses) = (1/2)^n.",
 
95
  ],
96
  )
97
 
98
- # Pattern 4: drawing one item from a set
99
- if any(w in lower for w in ["marble", "ball", "urn", "bag"]) and len(nums) >= 2:
100
- if any(w in lower for w in ["red", "blue", "green", "white", "black"]):
101
- total = sum(nums[:-1]) if "total" not in lower else nums[-1]
102
- # conservative: if exactly 2 nums, assume favorable then total
103
- if len(nums) == 2:
104
- favorable, total = nums[0], nums[1]
105
- else:
106
- favorable = nums[0]
107
- if total == 0:
108
- total = sum(nums)
109
- if total == 0:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  return None
111
- result = favorable / total
112
- return SolverResult(
113
- domain="quant",
114
- solved=True,
115
- topic="probability",
116
- answer_value=f"{result:g}",
117
- internal_answer=f"{result:g}",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  steps=[
119
- "Probability = number of favorable items ÷ total number of items.",
 
 
120
  ],
121
  )
122
 
123
- # Pattern 5: combinations-based probability
124
- # Example: choose 2 from 5 red and 3 blue, both red
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  m = re.search(
126
- r"(\d+)\s+red.*?(\d+)\s+blue.*?choose\s+(\d+).*?both red",
127
- lower,
128
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  if m:
130
  red = int(m.group(1))
131
  blue = int(m.group(2))
132
- choose_n = int(m.group(3))
133
  total = red + blue
134
- if choose_n != 2 or total < 2 or red < 2:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  return None
136
- result = comb(red, 2) / comb(total, 2)
137
- return SolverResult(
138
- domain="quant",
139
- solved=True,
140
- topic="probability",
141
- answer_value=f"{result:g}",
142
- internal_answer=f"{result:g}",
143
- steps=[
144
- "Count favorable selections.",
145
- "Count total possible selections.",
146
- "Probability = favorable ÷ total.",
147
- ],
148
- )
149
 
150
- # Pattern 6: at least / exactly from combinations
151
- if "exactly" in lower and "probability" in lower and len(nums) >= 4:
152
- # too ambiguous, skip unless explicit structure is added later
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  return None
154
 
155
- return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
+ import math
4
  import re
5
+ from fractions import Fraction
6
+ from math import comb
7
+ from typing import List, Optional, Tuple
8
 
9
  from models import SolverResult
10
 
11
 
12
+ # =========================================================
13
+ # basic helpers
14
+ # =========================================================
15
+
16
+ COLOR_WORDS = [
17
+ "red", "blue", "green", "white", "black", "yellow", "gray", "grey",
18
+ "orange", "purple", "pink", "brown"
19
+ ]
20
+
21
+ PROBABILITY_WORDS = [
22
+ "probability", "chance", "likely", "likelihood", "odds",
23
+ "random", "at random", "equally likely",
24
+ "coin", "coins", "head", "heads", "tail", "tails",
25
+ "die", "dice",
26
+ "card", "cards", "deck",
27
+ "marble", "marbles", "ball", "balls", "urn", "bag",
28
+ "without replacement", "with replacement",
29
+ "committee", "chosen", "select", "selected", "draw", "drawn",
30
+ "exactly", "at least", "at most", "no more than", "no fewer than",
31
+ "independent", "mutually exclusive", "or", "and",
32
+ "rain", "success", "failure"
33
+ ]
34
+
35
+
36
+ def _clean(text: str) -> str:
37
+ return re.sub(r"\s+", " ", (text or "").strip()).lower()
38
+
39
+
40
  def _nums(text: str) -> List[int]:
41
  return [int(x) for x in re.findall(r"-?\d+", text)]
42
 
43
 
44
+ def _fraction_str(x: float) -> str:
45
+ try:
46
+ f = Fraction(x).limit_denominator()
47
+ if f.denominator == 1:
48
+ return str(f.numerator)
49
+ return f"{f.numerator}/{f.denominator}"
50
+ except Exception:
51
+ return f"{x:.6g}"
52
 
 
 
 
 
 
 
 
53
 
54
+ def _safe_decimal_str(x: float) -> str:
55
+ return f"{x:.6g}"
56
 
57
+
58
+ def _make_result(
59
+ *,
60
+ internal_answer: Optional[str],
61
+ steps: List[str],
62
+ solved: bool = True,
63
+ ) -> SolverResult:
64
+ return SolverResult(
65
+ domain="quant",
66
+ solved=solved,
67
+ topic="probability",
68
+ answer_value=None, # do not expose final answer
69
+ internal_answer=internal_answer,
70
+ steps=steps,
71
  )
72
+
73
+
74
+ def _contains_probability_language(lower: str) -> bool:
75
+ return any(w in lower for w in PROBABILITY_WORDS)
76
+
77
+
78
+ def _contains_any(lower: str, words: List[str]) -> bool:
79
+ return any(w in lower for w in words)
80
+
81
+
82
+ def _has_without_replacement(lower: str) -> bool:
83
+ return "without replacement" in lower or "not replaced" in lower
84
+
85
+
86
+ def _has_with_replacement(lower: str) -> bool:
87
+ return "with replacement" in lower or "replaced" in lower
88
+
89
+
90
+ def _extract_percent_value(text: str) -> Optional[float]:
91
+ m = re.search(r"(\d+(?:\.\d+)?)\s*%", text)
92
+ if m:
93
+ return float(m.group(1)) / 100.0
94
+ return None
95
+
96
+
97
+ def _extract_probability_value(text: str) -> Optional[float]:
98
+ """
99
+ Tries to pull a direct probability from text:
100
+ - 40%
101
+ - 0.4
102
+ - 1/3
103
+ """
104
+ pct = _extract_percent_value(text)
105
+ if pct is not None:
106
+ return pct
107
+
108
+ frac = re.search(r"\b(\d+)\s*/\s*(\d+)\b", text)
109
+ if frac:
110
+ a = int(frac.group(1))
111
+ b = int(frac.group(2))
112
+ if b != 0:
113
+ return a / b
114
+
115
+ dec = re.search(r"\b0\.\d+\b", text)
116
+ if dec:
117
+ return float(dec.group(0))
118
+
119
+ return None
120
+
121
+
122
+ def _extract_named_counts(lower: str) -> List[Tuple[str, int]]:
123
+ """
124
+ Picks up structures like:
125
+ '10 green and 90 white marbles'
126
+ '1 gray, 2 white and 4 green balls'
127
+ '5 red, 3 blue'
128
+ """
129
+ pairs = []
130
+ for m in re.finditer(r"(\d+)\s+([a-z]+)", lower):
131
+ n = int(m.group(1))
132
+ word = m.group(2)
133
+ if word in COLOR_WORDS or word in {
134
+ "odd", "even", "prime", "composite",
135
+ "boys", "girls", "men", "women",
136
+ "married", "single"
137
+ }:
138
+ pairs.append((word, n))
139
+ return pairs
140
+
141
+
142
+ def _extract_color_counts(lower: str) -> List[Tuple[str, int]]:
143
+ return [(name, n) for name, n in _extract_named_counts(lower) if name in COLOR_WORDS]
144
+
145
+
146
+ def _extract_set_contents(lower: str) -> List[List[int]]:
147
+ """
148
+ Extracts {1,3,6,7,8} style sets.
149
+ """
150
+ sets = []
151
+ for m in re.finditer(r"\{([^{}]+)\}", lower):
152
+ raw = m.group(1)
153
+ vals = [int(x) for x in re.findall(r"-?\d+", raw)]
154
+ if vals:
155
+ sets.append(vals)
156
+ return sets
157
+
158
+
159
+ def _is_fair_coin(lower: str) -> bool:
160
+ return "coin" in lower or "coins" in lower
161
+
162
+
163
+ def _is_die_problem(lower: str) -> bool:
164
+ return "die" in lower or "dice" in lower
165
+
166
+
167
+ def _is_card_problem(lower: str) -> bool:
168
+ return "card" in lower or "cards" in lower or "deck" in lower
169
+
170
+
171
+ def _is_draw_problem(lower: str) -> bool:
172
+ return any(w in lower for w in ["marble", "marbles", "ball", "balls", "urn", "bag", "card", "cards", "deck"])
173
+
174
+
175
+ def _probability_of_card_event(lower: str) -> Optional[Tuple[float, List[str]]]:
176
+ """
177
+ Basic single-card deck facts.
178
+ """
179
+ if not _is_card_problem(lower):
180
+ return None
181
+
182
+ total = 52
183
+ event = None
184
+ count = None
185
+
186
+ if "ace" in lower:
187
+ event, count = "ace", 4
188
+ elif "king" in lower:
189
+ event, count = "king", 4
190
+ elif "queen" in lower:
191
+ event, count = "queen", 4
192
+ elif "jack" in lower:
193
+ event, count = "jack", 4
194
+ elif "heart" in lower:
195
+ event, count = "heart", 13
196
+ elif "spade" in lower:
197
+ event, count = "spade", 13
198
+ elif "club" in lower:
199
+ event, count = "club", 13
200
+ elif "diamond" in lower:
201
+ event, count = "diamond", 13
202
+ elif "face card" in lower or ("face" in lower and "card" in lower):
203
+ event, count = "face card", 12
204
+ elif "red card" in lower or ("red" in lower and "card" in lower):
205
+ event, count = "red card", 26
206
+ elif "black card" in lower or ("black" in lower and "card" in lower):
207
+ event, count = "black card", 26
208
+
209
+ if count is None:
210
+ return None
211
+
212
+ p = count / total
213
+ steps = [
214
+ "Treat a standard deck as 52 equally likely cards unless the question says otherwise.",
215
+ f"Count how many cards satisfy the requested property ({event}).",
216
+ "Use probability = favorable outcomes ÷ total outcomes.",
217
+ ]
218
+ return p, steps
219
+
220
+
221
+ def _extract_trial_counts(lower: str) -> Optional[Tuple[int, int]]:
222
+ """
223
+ Extract exactly k in n style language.
224
+ """
225
+ n = None
226
+ k = None
227
+
228
+ m_n = re.search(r"\b(?:in|over|during)\s+a?\s*(\d+)[- ](?:day|trial|toss|flip|roll|time|times|period)\b", lower)
229
+ if m_n:
230
+ n = int(m_n.group(1))
231
+
232
+ if n is None:
233
+ m_n = re.search(r"\b(\d+)\s+(?:times|trials|days|flips|tosses|rolls)\b", lower)
234
+ if m_n:
235
+ n = int(m_n.group(1))
236
+
237
+ m_k = re.search(r"\bexactly\s+(\d+)\b", lower)
238
+ if m_k:
239
+ k = int(m_k.group(1))
240
+
241
+ return (k, n) if k is not None and n is not None else None
242
+
243
+
244
+ def _is_sequence_ordered(lower: str) -> bool:
245
+ ordered_markers = [
246
+ "first", "second", "third",
247
+ "then", "followed by", "on the first day", "on the second day"
248
+ ]
249
+ return any(m in lower for m in ordered_markers)
250
+
251
+
252
+ # =========================================================
253
+ # core probability blocks
254
+ # =========================================================
255
+
256
+ def _solve_simple_favorable_total(lower: str) -> Optional[SolverResult]:
257
+ m = re.search(r"(\d+)\s+out of\s+(\d+)", lower)
258
  if m:
259
  fav = int(m.group(1))
260
  total = int(m.group(2))
261
  if total == 0:
262
  return None
263
+ p = fav / total
264
+ return _make_result(
265
+ internal_answer=_fraction_str(p),
 
 
 
 
266
  steps=[
267
+ "This is a direct favorable-over-total setup.",
268
+ "Count the outcomes that satisfy the condition.",
269
+ "Divide by the total number of equally likely outcomes.",
270
  ],
271
  )
272
 
273
+ m = re.search(r"probability.*?(\d+).*?(?:possible|total)", lower)
274
+ if m:
275
+ nums = _nums(lower)
276
+ if len(nums) >= 2 and nums[-1] != 0:
277
+ fav = nums[0]
278
+ total = nums[-1]
279
+ p = fav / total
280
+ return _make_result(
281
+ internal_answer=_fraction_str(p),
282
+ steps=[
283
+ "Use probability = favorable outcomes ÷ total equally likely outcomes.",
284
+ "Make sure the denominator is the full sample space.",
285
+ ],
286
+ )
287
+ return None
288
+
289
 
290
+ def _solve_single_coin_or_die(lower: str) -> Optional[SolverResult]:
291
+ if _is_fair_coin(lower):
292
+ if "head" in lower or "heads" in lower or "tail" in lower or "tails" in lower:
293
+ if not any(w in lower for w in ["twice", "two", "three", "4 times", "5 times", "6 times"]):
294
+ p = 1 / 2
295
+ return _make_result(
296
+ internal_answer=_fraction_str(p),
297
+ steps=[
298
+ "A fair coin has 2 equally likely outcomes.",
299
+ "Identify the one outcome that matches the event.",
300
+ "Use favorable ÷ total.",
301
+ ],
302
+ )
303
+
304
+ if _is_die_problem(lower):
305
+ if "even" in lower:
306
+ p = 3 / 6
307
+ return _make_result(
308
+ internal_answer=_fraction_str(p),
309
+ steps=[
310
+ "A fair die has 6 equally likely outcomes.",
311
+ "The even outcomes are 2, 4, and 6.",
312
+ "Use favorable ÷ total.",
313
+ ],
314
+ )
315
+ if "odd" in lower:
316
+ p = 3 / 6
317
+ return _make_result(
318
+ internal_answer=_fraction_str(p),
319
  steps=[
320
+ "A fair die has 6 equally likely outcomes.",
321
+ "The odd outcomes are 1, 3, and 5.",
322
+ "Use favorable ÷ total.",
323
  ],
324
  )
325
+ m = re.search(r"(?:at least|greater than or equal to)\s+(\d+)", lower)
326
+ if m:
327
+ k = int(m.group(1))
328
+ fav = len([x for x in range(1, 7) if x >= k])
329
+ if 0 <= fav <= 6:
330
+ p = fav / 6
331
+ return _make_result(
332
+ internal_answer=_fraction_str(p),
333
+ steps=[
334
+ "List the die outcomes that satisfy the condition.",
335
+ "Count how many are favorable.",
336
+ "Divide by 6.",
337
+ ],
338
+ )
339
+ m = re.search(r"(?:at most|less than or equal to)\s+(\d+)", lower)
340
+ if m:
341
+ k = int(m.group(1))
342
+ fav = len([x for x in range(1, 7) if x <= k])
343
+ if 0 <= fav <= 6:
344
+ p = fav / 6
345
+ return _make_result(
346
+ internal_answer=_fraction_str(p),
347
+ steps=[
348
+ "List the die outcomes that satisfy the condition.",
349
+ "Count how many are favorable.",
350
+ "Divide by 6.",
351
+ ],
352
+ )
353
+ return None
354
 
355
+
356
+ def _solve_single_card(lower: str) -> Optional[SolverResult]:
357
+ data = _probability_of_card_event(lower)
358
+ if data is None:
359
+ return None
360
+ p, steps = data
361
+ return _make_result(internal_answer=_fraction_str(p), steps=steps)
362
+
363
+
364
+ def _solve_basic_draw_ratio(lower: str) -> Optional[SolverResult]:
365
+ """
366
+ One draw from marbles/balls/cards with named categories.
367
+ """
368
+ if not _is_draw_problem(lower):
369
+ return None
370
+
371
+ color_counts = _extract_color_counts(lower)
372
+ if len(color_counts) >= 2 and not _is_sequence_ordered(lower):
373
+ total = sum(n for _, n in color_counts)
374
+ if total == 0:
375
+ return None
376
+
377
+ for color, count in color_counts:
378
+ if color in lower:
379
+ p = count / total
380
+ return _make_result(
381
+ internal_answer=_fraction_str(p),
382
+ steps=[
383
+ "This is a single-draw favorable-over-total problem.",
384
+ "Count how many objects have the requested property.",
385
+ "Divide by the total number of objects.",
386
+ ],
387
+ )
388
+ return None
389
+
390
+
391
+ def _solve_independent_ordered_events(lower: str) -> Optional[SolverResult]:
392
+ """
393
+ Handles ordered independent sequences like:
394
+ - heads and a 4
395
+ - rain first day but not second
396
+ """
397
+ if "heads and a \"4\"" in lower or ("head" in lower and "4" in lower and _is_die_problem(lower)):
398
+ p = (1 / 2) * (1 / 6)
399
+ return _make_result(
400
+ internal_answer=_fraction_str(p),
401
  steps=[
402
+ "Identify the events in order.",
403
+ "Because the events are independent, multiply their probabilities.",
404
+ "Use product rule for 'and' with independent events.",
405
  ],
406
  )
407
 
408
+ if "rain" in lower and _is_sequence_ordered(lower):
409
+ p_rain = _extract_probability_value(lower)
410
+ if p_rain is not None:
411
+ # catch 'rain on the first day but not on the second'
412
+ if ("first day" in lower and "second day" in lower) and (
413
+ "but not" in lower or "not on the second" in lower or "sunshine on the second" in lower
414
+ ):
415
+ p = p_rain * (1 - p_rain)
416
+ return _make_result(
417
+ internal_answer=_fraction_str(p),
418
+ steps=[
419
+ "Translate the wording into an ordered sequence of events.",
420
+ "Use the given probability for rain and its complement for no rain.",
421
+ "Because days are treated as independent here, multiply the stage probabilities.",
422
+ ],
423
+ )
424
+ return None
425
+
426
+
427
+ def _solve_complement_at_least_one(lower: str) -> Optional[SolverResult]:
428
+ """
429
+ At least one success in n independent trials.
430
+ """
431
+ if "at least one" not in lower:
432
+ return None
433
+
434
+ p = _extract_probability_value(lower)
435
+ n = None
436
+
437
+ m = re.search(r"\b(\d+)\s+(?:times|days|trials|flips|tosses|rolls)\b", lower)
438
+ if m:
439
+ n = int(m.group(1))
440
+
441
+ if n is None:
442
+ m = re.search(r"\bin a[n]?\s+(\d+)[- ](?:day|trial|flip|toss|roll|period)\b", lower)
443
+ if m:
444
+ n = int(m.group(1))
445
+
446
+ if p is None and _is_fair_coin(lower):
447
+ p = 1 / 2
448
+
449
+ if p is None or n is None:
450
+ return None
451
+
452
+ ans = 1 - (1 - p) ** n
453
+ return _make_result(
454
+ internal_answer=_fraction_str(ans),
455
+ steps=[
456
+ "For 'at least one', the complement is usually easiest.",
457
+ "Compute the probability of zero successes.",
458
+ "Subtract that from 1.",
459
+ ],
460
+ )
461
+
462
+
463
+ def _solve_exactly_k_in_n(lower: str) -> Optional[SolverResult]:
464
+ """
465
+ Binomial-type:
466
+ exactly k successes in n independent trials with probability p.
467
+ """
468
+ if "exactly" not in lower:
469
+ return None
470
+
471
+ kn = _extract_trial_counts(lower)
472
+ if not kn:
473
+ return None
474
+ k, n = kn
475
+
476
+ p = _extract_probability_value(lower)
477
+ if p is None and _is_fair_coin(lower):
478
+ p = 1 / 2
479
+
480
+ if p is None or n is None or k is None:
481
+ return None
482
+ if k < 0 or n < 0 or k > n:
483
+ return None
484
+
485
+ ans = comb(n, k) * (p ** k) * ((1 - p) ** (n - k))
486
+ return _make_result(
487
+ internal_answer=_safe_decimal_str(ans),
488
+ steps=[
489
+ "This is an 'exactly k successes in n independent trials' structure.",
490
+ "Count how many different arrangements produce k successes.",
491
+ "Multiply arrangements by the probability of one such arrangement.",
492
+ ],
493
+ )
494
+
495
+
496
+ def _solve_without_replacement_two_draws(lower: str) -> Optional[SolverResult]:
497
+ """
498
+ Two-draw color/object probability, with or without replacement.
499
+ Recognises:
500
+ - both red
501
+ - two red
502
+ - one of each
503
+ - at least one red
504
+ """
505
+ if not _is_draw_problem(lower):
506
+ return None
507
+
508
+ counts = _extract_color_counts(lower)
509
+ if len(counts) < 2:
510
+ return None
511
+
512
+ total = sum(n for _, n in counts)
513
+ if total <= 0:
514
+ return None
515
+
516
+ lookup = {name: n for name, n in counts}
517
+ replace = _has_with_replacement(lower) and not _has_without_replacement(lower)
518
+
519
+ target_color = None
520
+ for c in COLOR_WORDS:
521
+ if c in lower:
522
+ target_color = c
523
+ break
524
+
525
+ # both red / two red / both green / etc.
526
+ if target_color and any(phrase in lower for phrase in [f"both {target_color}", f"two {target_color}", f"{target_color} both"]):
527
+ if target_color not in lookup:
528
+ return None
529
+ good = lookup[target_color]
530
+
531
+ if replace:
532
+ ans = (good / total) ** 2
533
+ return _make_result(
534
+ internal_answer=_safe_decimal_str(ans),
535
+ steps=[
536
+ "This is a repeated-draw problem with replacement.",
537
+ "The probability stays the same from draw to draw.",
538
+ "For two required successes, multiply the stage probabilities.",
539
+ ],
540
+ )
541
+ else:
542
+ if good < 2 or total < 2:
543
  return None
544
+ ans = (good / total) * ((good - 1) / (total - 1))
545
+ return _make_result(
546
+ internal_answer=_safe_decimal_str(ans),
547
+ steps=[
548
+ "This is a repeated-draw problem without replacement.",
549
+ "After the first successful draw, both the favorable count and total count change.",
550
+ "Multiply the updated stage probabilities.",
551
+ ],
552
+ )
553
+
554
+ # one of each / one red and one blue
555
+ m = re.search(r"one\s+([a-z]+)\s+and\s+one\s+([a-z]+)", lower)
556
+ if m:
557
+ c1 = m.group(1)
558
+ c2 = m.group(2)
559
+ if c1 in lookup and c2 in lookup and c1 != c2:
560
+ a = lookup[c1]
561
+ b = lookup[c2]
562
+
563
+ if replace:
564
+ ans = 2 * (a / total) * (b / total)
565
+ else:
566
+ ans = (a / total) * (b / (total - 1)) + (b / total) * (a / (total - 1))
567
+
568
+ return _make_result(
569
+ internal_answer=_safe_decimal_str(ans),
570
  steps=[
571
+ "For 'one of each', consider both possible orders unless order is fixed.",
572
+ "Compute each valid order.",
573
+ "Add the mutually exclusive orders.",
574
  ],
575
  )
576
 
577
+ # at least one red
578
+ if target_color and f"at least one {target_color}" in lower and target_color in lookup:
579
+ good = lookup[target_color]
580
+ bad = total - good
581
+ if total < 2:
582
+ return None
583
+
584
+ if replace:
585
+ ans = 1 - (bad / total) ** 2
586
+ else:
587
+ if bad < 2:
588
+ ans = 1.0
589
+ else:
590
+ ans = 1 - (bad / total) * ((bad - 1) / (total - 1))
591
+
592
+ return _make_result(
593
+ internal_answer=_safe_decimal_str(ans),
594
+ steps=[
595
+ "For 'at least one', the complement is often easier.",
596
+ "First compute the probability of getting none of the target color.",
597
+ "Subtract from 1.",
598
+ ],
599
+ )
600
+
601
+ return None
602
+
603
+
604
+ def _solve_combination_probability(lower: str) -> Optional[SolverResult]:
605
+ """
606
+ Committee / selection style combinatorial probability.
607
+ """
608
+
609
+ # committee includes both Bob and Rachel
610
  m = re.search(
611
+ r"there are (\d+) .*? if (\d+) .*? randomly chosen .*? probability .*? includes both ([a-z]+) and ([a-z]+)",
612
+ lower
613
  )
614
+ if m:
615
+ total_people = int(m.group(1))
616
+ choose_n = int(m.group(2))
617
+ if choose_n == 2 and total_people >= 2:
618
+ ans = 1 / comb(total_people, 2)
619
+ return _make_result(
620
+ internal_answer=_fraction_str(ans),
621
+ steps=[
622
+ "This is a committee-selection probability problem.",
623
+ "Count all possible committees of the required size.",
624
+ "Count how many committees satisfy the condition, then divide favorable by total.",
625
+ ],
626
+ )
627
+
628
+ # explicit red/blue choose 2 both red
629
+ m = re.search(r"(\d+)\s+red.*?(\d+)\s+blue.*?choose\s+2.*?both red", lower)
630
  if m:
631
  red = int(m.group(1))
632
  blue = int(m.group(2))
 
633
  total = red + blue
634
+ if red >= 2 and total >= 2:
635
+ ans = comb(red, 2) / comb(total, 2)
636
+ return _make_result(
637
+ internal_answer=_fraction_str(ans),
638
+ steps=[
639
+ "Use combinations when order does not matter.",
640
+ "Count favorable selections.",
641
+ "Count total selections.",
642
+ ],
643
+ )
644
+
645
+ # married couples pattern: choose 3 from 10, none married to each other
646
+ m = re.search(
647
+ r"(\d+)\s+married couples.*?select .*?(\d+)\s+people.*?probability that none of them are married to each other",
648
+ lower
649
+ )
650
+ if m:
651
+ couples = int(m.group(1))
652
+ choose_n = int(m.group(2))
653
+ total_people = 2 * couples
654
+ if 0 <= choose_n <= couples:
655
+ favorable = comb(couples, choose_n) * (2 ** choose_n)
656
+ total = comb(total_people, choose_n)
657
+ ans = favorable / total
658
+ return _make_result(
659
+ internal_answer=_safe_decimal_str(ans),
660
+ steps=[
661
+ "This is a combinatorial selection problem with a restriction.",
662
+ "Choose which couples are represented.",
663
+ "Then choose one person from each selected couple.",
664
+ "Divide by the total number of unrestricted selections.",
665
+ ],
666
+ )
667
+
668
+ return None
669
+
670
+
671
+ def _solve_set_based_odd_even(lower: str) -> Optional[SolverResult]:
672
+ """
673
+ Example: choose one integer from each set, probability both odd.
674
+ """
675
+ sets = _extract_set_contents(lower)
676
+ if len(sets) >= 2 and ("odd" in lower or "even" in lower):
677
+ target = "odd" if "odd" in lower else "even"
678
+
679
+ probs = []
680
+ for s in sets[:2]:
681
+ if not s:
682
+ return None
683
+ if target == "odd":
684
+ good = sum(1 for x in s if x % 2 != 0)
685
+ else:
686
+ good = sum(1 for x in s if x % 2 == 0)
687
+ probs.append(good / len(s))
688
+
689
+ if "two odd integers" in lower or "both odd" in lower or "both even" in lower:
690
+ ans = probs[0] * probs[1]
691
+ return _make_result(
692
+ internal_answer=_safe_decimal_str(ans),
693
+ steps=[
694
+ "Treat each selection as its own favorable-over-total probability.",
695
+ "Then multiply because the selections come from separate sets.",
696
+ ],
697
+ )
698
+ return None
699
+
700
+
701
+ def _solve_or_probability(lower: str) -> Optional[SolverResult]:
702
+ """
703
+ Handles explicit P(A)=..., P(B)=..., mutually exclusive / overlap cases.
704
+ """
705
+ if " or " not in lower and "either" not in lower:
706
+ return None
707
+
708
+ probs = []
709
+ for m in re.finditer(r"p\([^)]+\)\s*=\s*(\d+/\d+|\d+%|0\.\d+)", lower):
710
+ probs.append(m.group(1))
711
+
712
+ def parse_prob(token: str) -> Optional[float]:
713
+ token = token.strip()
714
+ if token.endswith("%"):
715
+ return float(token[:-1]) / 100.0
716
+ if "/" in token:
717
+ a, b = token.split("/")
718
+ a, b = int(a), int(b)
719
+ if b == 0:
720
+ return None
721
+ return a / b
722
+ if token.startswith("0."):
723
+ return float(token)
724
+ return None
725
+
726
+ if len(probs) >= 2:
727
+ p_a = parse_prob(probs[0])
728
+ p_b = parse_prob(probs[1])
729
+ if p_a is None or p_b is None:
730
  return None
 
 
 
 
 
 
 
 
 
 
 
 
 
731
 
732
+ if "mutually exclusive" in lower or "cannot occur at the same time" in lower:
733
+ ans = p_a + p_b
734
+ return _make_result(
735
+ internal_answer=_safe_decimal_str(ans),
736
+ steps=[
737
+ "For mutually exclusive events, there is no overlap.",
738
+ "So the probability of 'A or B' is the sum of their probabilities.",
739
+ ],
740
+ )
741
+
742
+ m_overlap = re.search(r"p\(a and b\)\s*=\s*(\d+/\d+|\d+%|0\.\d+)", lower)
743
+ if m_overlap:
744
+ p_ab = parse_prob(m_overlap.group(1))
745
+ if p_ab is not None:
746
+ ans = p_a + p_b - p_ab
747
+ return _make_result(
748
+ internal_answer=_safe_decimal_str(ans),
749
+ steps=[
750
+ "For overlapping events, use the addition rule.",
751
+ "Add the two event probabilities.",
752
+ "Subtract the overlap once so it is not double-counted.",
753
+ ],
754
+ )
755
+
756
+ return None
757
+
758
+
759
+ def _solve_conditional_probability(lower: str) -> Optional[SolverResult]:
760
+ """
761
+ P(A|B) = P(A and B) / P(B)
762
+ """
763
+ if "given that" not in lower and "|" not in lower:
764
+ return None
765
+
766
+ tokens = []
767
+ for m in re.finditer(r"(\d+/\d+|\d+%|0\.\d+)", lower):
768
+ tokens.append(m.group(1))
769
+
770
+ def parse_prob(token: str) -> Optional[float]:
771
+ if token.endswith("%"):
772
+ return float(token[:-1]) / 100.0
773
+ if "/" in token:
774
+ a, b = token.split("/")
775
+ a, b = int(a), int(b)
776
+ if b == 0:
777
+ return None
778
+ return a / b
779
+ if token.startswith("0."):
780
+ return float(token)
781
+ return None
782
+
783
+ if len(tokens) >= 2:
784
+ p_ab = parse_prob(tokens[0])
785
+ p_b = parse_prob(tokens[1])
786
+ if p_ab is not None and p_b not in (None, 0):
787
+ ans = p_ab / p_b
788
+ return _make_result(
789
+ internal_answer=_safe_decimal_str(ans),
790
+ steps=[
791
+ "This is a conditional probability structure.",
792
+ "Restrict the sample space to the given condition.",
793
+ "Then divide the joint probability by the probability of the condition.",
794
+ ],
795
+ )
796
+ return None
797
+
798
+
799
+ def _solve_symmetry_probability(lower: str) -> Optional[SolverResult]:
800
+ """
801
+ Symmetry shortcuts like:
802
+ Bob left of Rachel -> 1/2
803
+ """
804
+ if "left of" in lower or "right of" in lower:
805
+ if "always left to" in lower or "left of" in lower:
806
+ ans = 1 / 2
807
+ return _make_result(
808
+ internal_answer=_fraction_str(ans),
809
+ steps=[
810
+ "This is a symmetry situation.",
811
+ "For every arrangement where one named person is left of the other, there is a mirrored arrangement where the order reverses.",
812
+ "So the desired probability is one half of all arrangements.",
813
+ ],
814
+ )
815
+ return None
816
+
817
+
818
+ def _solve_tree_style_two_stage(lower: str) -> Optional[SolverResult]:
819
+ """
820
+ Handles multi-branch two-stage without-replacement wording loosely.
821
+ This is intentionally conservative and only triggers when the structure is clear.
822
+ """
823
+ if "without replacement" not in lower:
824
+ return None
825
+
826
+ counts = _extract_color_counts(lower)
827
+ if len(counts) < 2:
828
+ return None
829
+
830
+ total = sum(n for _, n in counts)
831
+ lookup = {name: n for name, n in counts}
832
+ if total < 2:
833
  return None
834
 
835
+ # Example-style trigger:
836
+ # wins if first is green
837
+ # OR if first gray and second white
838
+ # OR if two white
839
+ if (
840
+ "wins if" in lower
841
+ and "first" in lower
842
+ and "second" in lower
843
+ and ("or if" in lower or "or" in lower)
844
+ ):
845
+ parts = []
846
+
847
+ # first green
848
+ for color in COLOR_WORDS:
849
+ if f"first is {color}" in lower or f"first {color}" in lower:
850
+ if color in lookup:
851
+ parts.append(lookup[color] / total)
852
+ break
853
+
854
+ # first gray and second white
855
+ for c1 in COLOR_WORDS:
856
+ for c2 in COLOR_WORDS:
857
+ phrase1 = f"first {c1} and second {c2}"
858
+ phrase2 = f"first ball is {c1} and the second ball is {c2}"
859
+ if phrase1 in lower or phrase2 in lower:
860
+ if c1 in lookup and c2 in lookup:
861
+ n1 = lookup[c1]
862
+ n2 = lookup[c2]
863
+ if c1 == c2:
864
+ if n1 >= 2:
865
+ parts.append((n1 / total) * ((n1 - 1) / (total - 1)))
866
+ else:
867
+ parts.append((n1 / total) * (n2 / (total - 1)))
868
+
869
+ # two white
870
+ for color in COLOR_WORDS:
871
+ if f"two {color}" in lower:
872
+ if color in lookup and lookup[color] >= 2:
873
+ n = lookup[color]
874
+ parts.append((n / total) * ((n - 1) / (total - 1)))
875
+ break
876
+
877
+ if parts:
878
+ ans = sum(parts)
879
+ return _make_result(
880
+ internal_answer=_safe_decimal_str(ans),
881
+ steps=[
882
+ "This is a multi-branch probability-tree style problem.",
883
+ "Break the win condition into separate valid paths.",
884
+ "Find the probability of each path.",
885
+ "Add the mutually exclusive winning paths.",
886
+ ],
887
+ )
888
+
889
+ return None
890
+
891
+
892
+ # =========================================================
893
+ # explanation fallback
894
+ # =========================================================
895
+
896
+ def _explanation_only_result(lower: str) -> Optional[SolverResult]:
897
+ if not _contains_probability_language(lower):
898
+ return None
899
+
900
+ steps = [
901
+ "Identify what counts as a successful outcome.",
902
+ "Decide whether the problem is favorable-over-total, multiplication ('and'), addition ('or'), complement, or counting-based.",
903
+ "Check whether order matters and whether draws are with replacement or without replacement.",
904
+ "If the wording says 'at least one', try the complement first.",
905
+ "If the wording says 'exactly k times in n trials', think binomial structure.",
906
+ ]
907
+
908
+ if _has_without_replacement(lower):
909
+ steps.append("Without replacement means the probabilities change after each draw.")
910
+ if "mutually exclusive" in lower:
911
+ steps.append("Mutually exclusive events are added because they cannot happen together.")
912
+ if "independent" in lower:
913
+ steps.append("Independent events are multiplied because one does not change the other.")
914
+
915
+ return _make_result(
916
+ internal_answer=None,
917
+ steps=steps,
918
+ solved=False,
919
+ )
920
+
921
+
922
+ # =========================================================
923
+ # main solver
924
+ # =========================================================
925
+
926
+ def solve_probability(text: str) -> Optional[SolverResult]:
927
+ lower = _clean(text)
928
+ if not _contains_probability_language(lower):
929
+ return None
930
+
931
+ solvers = [
932
+ _solve_simple_favorable_total,
933
+ _solve_single_coin_or_die,
934
+ _solve_single_card,
935
+ _solve_basic_draw_ratio,
936
+ _solve_independent_ordered_events,
937
+ _solve_complement_at_least_one,
938
+ _solve_exactly_k_in_n,
939
+ _solve_without_replacement_two_draws,
940
+ _solve_combination_probability,
941
+ _solve_set_based_odd_even,
942
+ _solve_or_probability,
943
+ _solve_conditional_probability,
944
+ _solve_symmetry_probability,
945
+ _solve_tree_style_two_stage,
946
+ ]
947
+
948
+ for solver in solvers:
949
+ try:
950
+ result = solver(lower)
951
+ if result is not None:
952
+ return result
953
+ except Exception:
954
+ continue
955
+
956
+ return _explanation_only_result(lower)