j-js commited on
Commit
9bed299
·
verified ·
1 Parent(s): d1c397a

Update solver_overlapping_sets.py

Browse files
Files changed (1) hide show
  1. solver_overlapping_sets.py +549 -41
solver_overlapping_sets.py CHANGED
@@ -1,63 +1,571 @@
1
  from __future__ import annotations
2
 
3
  import re
4
- from typing import Optional, List
5
 
6
  from models import SolverResult
7
 
8
 
9
- def _nums(text: str) -> List[int]:
10
- return [int(x) for x in re.findall(r"-?\d+", text)]
 
11
 
 
 
 
 
 
 
 
 
12
 
13
- def solve_overlapping_sets(text: str) -> Optional[SolverResult]:
14
- lower = (text or "").lower()
15
 
16
- if not any(w in lower for w in [
17
- "both", "either", "neither", "union", "intersection",
18
- "overlap", "venn"
19
- ]):
20
- return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
- nums = _nums(lower)
 
 
 
 
 
 
 
23
 
24
- # Basic inclusion-exclusion:
25
- # "30 study math, 20 study science, 8 study both"
26
- if ("both" in lower or "overlap" in lower) and len(nums) >= 3:
27
- a = nums[0]
28
- b = nums[1]
29
- both = nums[2]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  union = a + b - both
31
- return SolverResult(
32
- domain="quant",
33
- solved=True,
34
- topic="overlapping_sets",
35
- answer_value=str(union),
36
- internal_answer=str(union),
37
  steps=[
38
- "Use inclusion–exclusion.",
39
- "Total in either set = set A + set B − overlap.",
40
  ],
41
  )
42
 
43
- # "out of 50, 30 like tea, 20 like coffee, 8 like both, how many neither?"
44
- if "neither" in lower and len(nums) >= 4:
45
- total = nums[0]
46
- a = nums[1]
47
- b = nums[2]
48
- both = nums[3]
49
- union = a + b - both
50
- neither = total - union
51
- return SolverResult(
52
- domain="quant",
53
- solved=True,
54
- topic="overlapping_sets",
55
- answer_value=str(neither),
56
- internal_answer=str(neither),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  steps=[
58
- "Use inclusion–exclusion to find how many are in at least one set.",
59
- "Subtract that from the total to get neither.",
60
  ],
61
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
 
63
  return None
 
1
  from __future__ import annotations
2
 
3
  import re
4
+ from typing import Optional, List, Dict, Tuple
5
 
6
  from models import SolverResult
7
 
8
 
9
+ # ----------------------------
10
+ # Helpers
11
+ # ----------------------------
12
 
13
+ def _normalize(text: str) -> str:
14
+ t = (text or "").lower()
15
+ t = t.replace("∩", " intersection ")
16
+ t = t.replace("∪", " union ")
17
+ t = t.replace("&", " and ")
18
+ t = t.replace("%", " percent ")
19
+ t = re.sub(r"\s+", " ", t).strip()
20
+ return t
21
 
 
 
22
 
23
+ def _nums(text: str) -> List[float]:
24
+ vals: List[float] = []
25
+ for x in re.findall(r"-?\d+(?:\.\d+)?", text):
26
+ try:
27
+ vals.append(float(x))
28
+ except Exception:
29
+ pass
30
+ return vals
31
+
32
+
33
+ def _fmt_num(x: float) -> str:
34
+ if abs(x - round(x)) < 1e-9:
35
+ return str(int(round(x)))
36
+ return f"{x:.2f}".rstrip("0").rstrip(".")
37
+
38
+
39
+ def _safe_result(
40
+ internal_answer: float,
41
+ steps: List[str],
42
+ interpretation: Optional[str] = None,
43
+ ) -> SolverResult:
44
+ """
45
+ Keep the answer internally available for the system, while the steps remain
46
+ method-oriented and do not reveal the final numeric result.
47
+ """
48
+ answer_str = _fmt_num(internal_answer)
49
+ final_steps = []
50
+ if interpretation:
51
+ final_steps.append(interpretation)
52
+ final_steps.extend(steps)
53
+ return SolverResult(
54
+ domain="quant",
55
+ solved=True,
56
+ topic="overlapping_sets",
57
+ answer_value=answer_str,
58
+ internal_answer=answer_str,
59
+ steps=final_steps,
60
+ )
61
+
62
+
63
+ def _contains_any(text: str, phrases: List[str]) -> bool:
64
+ return any(p in text for p in phrases)
65
+
66
+
67
+ def _is_overlapping_sets_context(lower: str) -> bool:
68
+ keywords = [
69
+ "both", "either", "neither", "union", "intersection", "overlap",
70
+ "venn", "at least one", "at least 1", "none", "exactly two",
71
+ "exactly 2", "all three", "all 3", "combined roster", "in common",
72
+ "only one", "only 1", "only two", "only 2", "took", "club",
73
+ "clubs", "course", "courses", "class", "classes", "students",
74
+ "survey", "surveys", "like", "liked", "roster", "pet", "pets",
75
+ "team", "teams", "belong", "belongs", "belonged", "sign up",
76
+ "signed up", "play", "played", "members", "none of these",
77
+ ]
78
+ return any(k in lower for k in keywords)
79
+
80
+
81
+ def _question_target(lower: str) -> str:
82
+ """
83
+ Infer what the question is asking for.
84
+ """
85
+ if _contains_any(lower, ["how many neither", "how many none", "belong to none", "taken none", "none of the", "do not play any", "liked none"]):
86
+ return "neither"
87
+ if _contains_any(lower, ["at least one", "at least 1", "either set", "either group", "combined roster", "no student's name listed more than once", "no duplicate names", "total in either", "total in at least one"]):
88
+ return "union"
89
+ if _contains_any(lower, ["exactly two", "exactly 2", "only two", "only 2"]):
90
+ return "exactly_two"
91
+ if _contains_any(lower, ["all three", "all 3", "all the three"]):
92
+ return "all_three"
93
+ if _contains_any(lower, ["only one", "only 1", "exactly one", "exactly 1"]):
94
+ return "exactly_one"
95
+ if _contains_any(lower, ["both", "overlap", "intersection", "in common"]):
96
+ return "intersection"
97
+ return "unknown"
98
+
99
+
100
+ def _extract_total(lower: str) -> Optional[float]:
101
+ patterns = [
102
+ r"out of (\d+(?:\.\d+)?)",
103
+ r"of (\d+(?:\.\d+)?) (?:students|people|adults|employees|members|workers)",
104
+ r"there are (\d+(?:\.\d+)?)",
105
+ r"in a class of (\d+(?:\.\d+)?)",
106
+ r"each of the (\d+(?:\.\d+)?) members",
107
+ r"each of the (\d+(?:\.\d+)?) students",
108
+ r"this semester[, ]*each of the (\d+(?:\.\d+)?) students",
109
+ r"(\d+(?:\.\d+)?) people own",
110
+ r"(\d+(?:\.\d+)?)% of those surveyed",
111
+ r"total(?: is| =)? (\d+(?:\.\d+)?)",
112
+ ]
113
+ for pat in patterns:
114
+ m = re.search(pat, lower)
115
+ if m:
116
+ return float(m.group(1))
117
+ return None
118
+
119
+
120
+ def _extract_neither(lower: str) -> Optional[float]:
121
+ patterns = [
122
+ r"(\d+(?:\.\d+)?) .*do not play any",
123
+ r"(\d+(?:\.\d+)?) .*do not play any of",
124
+ r"(\d+(?:\.\d+)?) .*none of",
125
+ r"(\d+(?:\.\d+)?) .*belong to none",
126
+ r"(\d+(?:\.\d+)?) .*taken none",
127
+ r"(\d+(?:\.\d+)?) .*liked none",
128
+ r"none(?: =| is)? (\d+(?:\.\d+)?)",
129
+ r"neither(?: =| is)? (\d+(?:\.\d+)?)",
130
+ ]
131
+ for pat in patterns:
132
+ m = re.search(pat, lower)
133
+ if m:
134
+ return float(m.group(1))
135
+
136
+ # "85% liked at least one" -> none = 15
137
+ m = re.search(r"(\d+(?:\.\d+)?)\s*percent .*at least one", lower)
138
+ if m:
139
+ return 100.0 - float(m.group(1))
140
+
141
+ return None
142
+
143
+
144
+ def _extract_exactly_two(lower: str) -> Optional[float]:
145
+ patterns = [
146
+ r"(\d+(?:\.\d+)?) .*exactly two",
147
+ r"(\d+(?:\.\d+)?) .*exactly 2",
148
+ r"(\d+(?:\.\d+)?) .*only two",
149
+ r"(\d+(?:\.\d+)?) .*only 2",
150
+ ]
151
+ for pat in patterns:
152
+ m = re.search(pat, lower)
153
+ if m:
154
+ return float(m.group(1))
155
+ return None
156
+
157
+
158
+ def _extract_all_three(lower: str) -> Optional[float]:
159
+ patterns = [
160
+ r"(\d+(?:\.\d+)?) .*all three",
161
+ r"(\d+(?:\.\d+)?) .*all 3",
162
+ r"(\d+(?:\.\d+)?) .*all the three",
163
+ r"(\d+(?:\.\d+)?) .*on all 3",
164
+ ]
165
+ for pat in patterns:
166
+ m = re.search(pat, lower)
167
+ if m:
168
+ return float(m.group(1))
169
+ return None
170
+
171
 
172
+ def _extract_pairwise_including_triple(lower: str) -> List[float]:
173
+ """
174
+ Extract values like:
175
+ - 7 play both Hockey and Cricket
176
+ - E and M had 9 names in common
177
+ These are pairwise intersections that INCLUDE anyone in all three.
178
+ """
179
+ vals: List[float] = []
180
 
181
+ patterns = [
182
+ r"(\d+(?:\.\d+)?) .*both [a-z0-9 ]+ and [a-z0-9 ]+",
183
+ r"(\d+(?:\.\d+)?) .*in common",
184
+ r"([a-z]) and ([a-z]) had (\d+(?:\.\d+)?) names in common",
185
+ ]
186
+
187
+ for pat in patterns[:2]:
188
+ for m in re.finditer(pat, lower):
189
+ try:
190
+ vals.append(float(m.group(1)))
191
+ except Exception:
192
+ pass
193
+
194
+ for m in re.finditer(patterns[2], lower):
195
+ try:
196
+ vals.append(float(m.group(3)))
197
+ except Exception:
198
+ pass
199
+
200
+ # Deduplicate while preserving order
201
+ out = []
202
+ for v in vals:
203
+ if v not in out:
204
+ out.append(v)
205
+ return out[:3]
206
+
207
+
208
+ def _extract_single_set_counts(lower: str) -> List[float]:
209
+ """
210
+ Tries to capture the main three single-set totals from common GMAT wording.
211
+ """
212
+ vals: List[float] = []
213
+
214
+ patterns = [
215
+ r"(\d+(?:\.\d+)?) .*sign up for the [a-z ]+ club",
216
+ r"(\d+(?:\.\d+)?) .*took [a-z]",
217
+ r"(\d+(?:\.\d+)?) .*owned [a-z]+",
218
+ r"(\d+(?:\.\d+)?) .*play [a-z]+",
219
+ r"(\d+(?:\.\d+)?) .*liked product [123a-z]",
220
+ r"(\d+(?:\.\d+)?) .*are on the [a-z ]+ team",
221
+ r"(\d+(?:\.\d+)?) .*roster",
222
+ r"(\d+(?:\.\d+)?) belong to [abc]",
223
+ r"(\d+(?:\.\d+)?) have taken [a-z ]+ course",
224
+ r"(\d+(?:\.\d+)?) students took [abc]",
225
+ r"(\d+(?:\.\d+)?) members .* poetry club",
226
+ r"(\d+(?:\.\d+)?) students .* history club",
227
+ r"(\d+(?:\.\d+)?) students .* writing club",
228
+ ]
229
+
230
+ for pat in patterns:
231
+ for m in re.finditer(pat, lower):
232
+ try:
233
+ vals.append(float(m.group(1)))
234
+ except Exception:
235
+ pass
236
+
237
+ # fallback: collect leading list from phrasing like "20 play hockey, 15 play cricket and 11 play football"
238
+ for m in re.finditer(r"(\d+(?:\.\d+)?) [a-z ]+, (\d+(?:\.\d+)?) [a-z ]+ and (\d+(?:\.\d+)?) [a-z ]+", lower):
239
+ try:
240
+ triple = [float(m.group(1)), float(m.group(2)), float(m.group(3))]
241
+ for v in triple:
242
+ vals.append(v)
243
+ except Exception:
244
+ pass
245
+
246
+ out = []
247
+ for v in vals:
248
+ if v not in out:
249
+ out.append(v)
250
+ return out[:3]
251
+
252
+
253
+ def _extract_generic_numbers(lower: str) -> List[float]:
254
+ return _nums(lower)
255
+
256
+
257
+ # ----------------------------
258
+ # 2-set solvers
259
+ # ----------------------------
260
+
261
+ def _solve_two_set_basic(lower: str) -> Optional[SolverResult]:
262
+ nums = _extract_generic_numbers(lower)
263
+ target = _question_target(lower)
264
+
265
+ # Pattern: "30 study math, 20 study science, 8 study both"
266
+ if len(nums) >= 3 and _contains_any(lower, ["both", "overlap", "intersection", "in common"]):
267
+ a, b, both = nums[0], nums[1], nums[2]
268
+
269
+ if target in ["union", "unknown", "intersection"]:
270
+ if target == "intersection":
271
+ return _safe_result(
272
+ internal_answer=both,
273
+ interpretation="This is a 2-set overlap question asking for the intersection.",
274
+ steps=[
275
+ "Identify the overlap as the group counted in both sets.",
276
+ "Use the given overlap directly if it is already stated.",
277
+ ],
278
+ )
279
+
280
+ union = a + b - both
281
+ return _safe_result(
282
+ internal_answer=union,
283
+ interpretation="This is a 2-set inclusion–exclusion setup.",
284
+ steps=[
285
+ "Add the two set totals.",
286
+ "Subtract the overlap once because it was counted twice.",
287
+ "That gives the number in at least one of the two sets.",
288
+ ],
289
+ )
290
+
291
+ # Pattern with total and neither
292
+ if target == "neither" and len(nums) >= 4:
293
+ total, a, b, both = nums[0], nums[1], nums[2], nums[3]
294
  union = a + b - both
295
+ neither = total - union
296
+ return _safe_result(
297
+ internal_answer=neither,
298
+ interpretation="This is a 2-set total-minus-union question.",
 
 
299
  steps=[
300
+ "First find how many are in at least one set using inclusion–exclusion.",
301
+ "Then subtract that union from the total.",
302
  ],
303
  )
304
 
305
+ # Exactly one / only one
306
+ if target == "exactly_one" and len(nums) >= 3:
307
+ a, b, both = nums[0], nums[1], nums[2]
308
+ exactly_one = (a - both) + (b - both)
309
+ return _safe_result(
310
+ internal_answer=exactly_one,
311
+ interpretation="This is a 2-set exactly-one question.",
312
+ steps=[
313
+ "Remove the overlap from each set to get the set-only parts.",
314
+ "Add the two non-overlapping parts.",
315
+ ],
316
+ )
317
+
318
+ return None
319
+
320
+
321
+ # ----------------------------
322
+ # 3-set solvers
323
+ # ----------------------------
324
+
325
+ def _solve_three_set_from_pairwise_and_triple(lower: str) -> Optional[SolverResult]:
326
+ """
327
+ Handles the classic formula:
328
+ Union = A + B + C - (AB + AC + BC) + ABC
329
+ Also:
330
+ Neither = Total - Union
331
+ Exactly-two = (AB + AC + BC) - 3*ABC
332
+ """
333
+ singles = _extract_single_set_counts(lower)
334
+ pairwise = _extract_pairwise_including_triple(lower)
335
+ triple = _extract_all_three(lower)
336
+ total = _extract_total(lower)
337
+ neither = _extract_neither(lower)
338
+ target = _question_target(lower)
339
+
340
+ if len(singles) == 3 and len(pairwise) == 3 and triple is not None:
341
+ a, b, c = singles
342
+ ab, ac, bc = pairwise
343
+ abc = triple
344
+
345
+ union = a + b + c - ab - ac - bc + abc
346
+ exactly_two = (ab + ac + bc) - 3 * abc
347
+
348
+ if target in ["union", "unknown"]:
349
+ return _safe_result(
350
+ internal_answer=union,
351
+ interpretation="This is a 3-set inclusion–exclusion question with pairwise overlaps and a triple overlap.",
352
+ steps=[
353
+ "Add the three set totals.",
354
+ "Subtract each pairwise overlap once because those people/items were double-counted.",
355
+ "Add the all-three overlap back once because it was subtracted too many times.",
356
+ ],
357
+ )
358
+
359
+ if target == "neither" and total is not None:
360
+ ans = total - union
361
+ return _safe_result(
362
+ internal_answer=ans,
363
+ interpretation="This is a total-minus-3-set-union question.",
364
+ steps=[
365
+ "Use 3-set inclusion–exclusion to find how many are in at least one set.",
366
+ "Subtract that result from the total.",
367
+ ],
368
+ )
369
+
370
+ if target == "exactly_two":
371
+ return _safe_result(
372
+ internal_answer=exactly_two,
373
+ interpretation="This is a 3-set exactly-two question derived from pairwise overlaps that include the triple-overlap region.",
374
+ steps=[
375
+ "Each pairwise count includes the all-three region.",
376
+ "Subtract the all-three group once from each pairwise overlap to convert pairwise totals into exactly-two regions.",
377
+ "Then add those exactly-two regions together.",
378
+ ],
379
+ )
380
+
381
+ if target == "all_three" and total is not None and neither is not None:
382
+ # Reverse solve:
383
+ # Total = Union + Neither
384
+ # Total - Neither = A+B+C - (AB+AC+BC) + ABC
385
+ # ABC = (Total-Neither) - (A+B+C) + (AB+AC+BC)
386
+ ans = (total - neither) - (a + b + c) + (ab + ac + bc)
387
+ return _safe_result(
388
+ internal_answer=ans,
389
+ interpretation="This is a reverse 3-set inclusion–exclusion question solving for the all-three overlap.",
390
+ steps=[
391
+ "Convert the problem into a union count by removing the neither group from the total, if needed.",
392
+ "Set up the 3-set inclusion–exclusion equation.",
393
+ "Rearrange the equation so the all-three overlap is isolated.",
394
+ ],
395
+ )
396
+
397
+ return None
398
+
399
+
400
+ def _solve_three_set_from_exactly_two_and_triple(lower: str) -> Optional[SolverResult]:
401
+ """
402
+ Uses:
403
+ Total = A + B + C - exactly_two - 2*all_three + neither
404
+ or equivalently
405
+ Union = A + B + C - exactly_two - 2*all_three
406
+ """
407
+ singles = _extract_single_set_counts(lower)
408
+ exact2 = _extract_exactly_two(lower)
409
+ triple = _extract_all_three(lower)
410
+ total = _extract_total(lower)
411
+ neither = _extract_neither(lower)
412
+ target = _question_target(lower)
413
+
414
+ if len(singles) == 3 and exact2 is not None:
415
+ a, b, c = singles
416
+
417
+ # Find all-three
418
+ if target == "all_three" and total is not None:
419
+ n = neither if neither is not None else 0.0
420
+ # total = a+b+c - exact2 - 2*triple + n
421
+ ans = (a + b + c - exact2 + n - total) / 2.0
422
+ return _safe_result(
423
+ internal_answer=ans,
424
+ interpretation="This is the exactly-two / all-three 3-set formula.",
425
+ steps=[
426
+ "Use the version of inclusion–exclusion written in terms of exactly-two and all-three.",
427
+ "Treat the union as total minus neither when necessary.",
428
+ "Rearrange the equation so the all-three region is isolated.",
429
+ ],
430
+ )
431
+
432
+ # Find neither
433
+ if target == "neither" and total is not None and triple is not None:
434
+ ans = total - (a + b + c - exact2 - 2 * triple)
435
+ return _safe_result(
436
+ internal_answer=ans,
437
+ interpretation="This is a 3-set total-versus-union question using exactly-two and all-three.",
438
+ steps=[
439
+ "Compute the union from the three set totals, the exactly-two count, and the all-three count.",
440
+ "Subtract the union from the total to get neither.",
441
+ ],
442
+ )
443
+
444
+ # Find exactly-two
445
+ if target == "exactly_two" and total is not None and triple is not None:
446
+ n = neither if neither is not None else 0.0
447
+ ans = a + b + c + n - total - 2 * triple
448
+ return _safe_result(
449
+ internal_answer=ans,
450
+ interpretation="This is a reverse solve for the exactly-two total in a 3-set problem.",
451
+ steps=[
452
+ "Use the total/union form of the 3-set formula written with exactly-two and all-three.",
453
+ "Substitute the known values.",
454
+ "Rearrange to isolate the exactly-two count.",
455
+ ],
456
+ )
457
+
458
+ # Find union / at least one
459
+ if target in ["union", "unknown"] and triple is not None:
460
+ ans = a + b + c - exact2 - 2 * triple
461
+ return _safe_result(
462
+ internal_answer=ans,
463
+ interpretation="This is a 3-set union problem using exactly-two and all-three.",
464
+ steps=[
465
+ "Start with the sum of the three set totals.",
466
+ "Subtract the exactly-two contribution.",
467
+ "Subtract the extra double-counting caused by the all-three region.",
468
+ ],
469
+ )
470
+
471
+ return None
472
+
473
+
474
+ def _solve_three_set_from_total_and_triple(lower: str) -> Optional[SolverResult]:
475
+ """
476
+ Example:
477
+ Total known, neither implied 0, singles known, all-three known -> find exactly-two
478
+ Formula:
479
+ Total = A + B + C - exactly_two - 2*all_three + neither
480
+ """
481
+ singles = _extract_single_set_counts(lower)
482
+ total = _extract_total(lower)
483
+ triple = _extract_all_three(lower)
484
+ neither = _extract_neither(lower)
485
+ target = _question_target(lower)
486
+
487
+ if len(singles) == 3 and total is not None and triple is not None and target == "exactly_two":
488
+ a, b, c = singles
489
+ n = neither if neither is not None else 0.0
490
+ ans = a + b + c + n - total - 2 * triple
491
+ return _safe_result(
492
+ internal_answer=ans,
493
+ interpretation="This is a 3-set exactly-two question using total, singles, and all-three.",
494
+ steps=[
495
+ "Use the 3-set formula written in terms of exactly-two and all-three.",
496
+ "If the problem says everyone is in at least one set, take neither as zero.",
497
+ "Rearrange to isolate the exactly-two count.",
498
+ ],
499
+ )
500
+
501
+ return None
502
+
503
+
504
+ def _solve_percent_variant(lower: str) -> Optional[SolverResult]:
505
+ """
506
+ Supports survey-style percentage overlapping sets.
507
+ """
508
+ if "percent" not in lower:
509
+ return None
510
+
511
+ res = _solve_three_set_from_exactly_two_and_triple(lower)
512
+ if res is not None:
513
+ return res
514
+
515
+ res = _solve_three_set_from_pairwise_and_triple(lower)
516
+ if res is not None:
517
+ return res
518
+
519
+ return None
520
+
521
+
522
+ def _solve_subset_bound_variant(lower: str) -> Optional[SolverResult]:
523
+ """
524
+ Handles disguised overlap-style minimum union questions such as
525
+ 'ranges are 17, 28, 35; what is the minimum possible total range?'
526
+ where the minimum union is the largest set if all smaller sets fit inside it.
527
+ """
528
+ if not _contains_any(lower, ["minimum possible", "minimum", "largest", "smallest", "range"]):
529
+ return None
530
+
531
+ nums = _extract_generic_numbers(lower)
532
+ if len(nums) >= 3:
533
+ # Heuristic: for disguised minimum-union overlap questions, the minimum
534
+ # possible union is max(set sizes).
535
+ vals = nums[:3]
536
+ ans = max(vals)
537
+ return _safe_result(
538
+ internal_answer=ans,
539
+ interpretation="This is a disguised minimum-union overlapping-sets idea.",
540
  steps=[
541
+ "To minimize the total covered range/group, make the smaller sets lie entirely inside the largest set whenever possible.",
542
+ "So the minimum possible overall coverage cannot be smaller than the largest individual set.",
543
  ],
544
  )
545
+ return None
546
+
547
+
548
+ # ----------------------------
549
+ # Main router
550
+ # ----------------------------
551
+
552
+ def solve_overlapping_sets(text: str) -> Optional[SolverResult]:
553
+ lower = _normalize(text)
554
+
555
+ if not _is_overlapping_sets_context(lower):
556
+ return None
557
+
558
+ # Strongest / most specific first
559
+ for solver in [
560
+ _solve_percent_variant,
561
+ _solve_three_set_from_pairwise_and_triple,
562
+ _solve_three_set_from_exactly_two_and_triple,
563
+ _solve_three_set_from_total_and_triple,
564
+ _solve_subset_bound_variant,
565
+ _solve_two_set_basic,
566
+ ]:
567
+ result = solver(lower)
568
+ if result is not None:
569
+ return result
570
 
571
  return None