j-js commited on
Commit
0e39b7f
·
verified ·
1 Parent(s): ae8227a

Update solver_combinatorics.py

Browse files
Files changed (1) hide show
  1. solver_combinatorics.py +731 -63
solver_combinatorics.py CHANGED
@@ -1,95 +1,763 @@
1
  from __future__ import annotations
2
 
 
3
  import re
4
- from math import comb, perm, factorial
5
  from typing import Optional
6
 
7
  from models import SolverResult
8
 
9
 
10
- def solve_combinatorics(text: str) -> Optional[SolverResult]:
11
- lower = (text or "").lower()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
- if not any(w in lower for w in [
14
- "combination", "permutation", "arrange", "arrangement",
15
- "choose", "select", "committee"
16
- ]):
17
  return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
- # "how many ways to choose r from n"
20
- m = re.search(r"choose\s+(\d+)\s+from\s+(\d+)", lower)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  if m:
22
- r = int(m.group(1))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  n = int(m.group(2))
24
- if r > n or r < 0:
25
- return None
26
- result = comb(n, r)
27
- return SolverResult(
28
- domain="quant",
29
- solved=True,
30
- topic="combinations",
31
- answer_value=str(result),
32
- internal_answer=str(result),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  steps=[
34
- "Use combinations when order does not matter.",
35
- "Compute nCr.",
 
 
36
  ],
37
  )
38
 
39
- # "committee of 3 from 8"
40
- m = re.search(r"committee\s+of\s+(\d+).*?from\s+(\d+)", lower)
41
- if m:
42
- r = int(m.group(1))
43
- n = int(m.group(2))
44
- if r > n or r < 0:
45
- return None
46
- result = comb(n, r)
47
- return SolverResult(
48
- domain="quant",
49
- solved=True,
50
- topic="combinations",
51
- answer_value=str(result),
52
- internal_answer=str(result),
53
  steps=[
54
- "A committee is a selection problem, so use combinations.",
55
- "Compute nCr.",
 
 
56
  ],
57
  )
58
 
59
- # "arrange 5 books" / "permutations of 5 items"
60
- m = re.search(r"(?:arrange|arrangement|permutation).*?(\d+)", lower)
61
- if m and "choose" not in lower and "committee" not in lower:
62
- n = int(m.group(1))
63
- result = factorial(n)
64
- return SolverResult(
65
- domain="quant",
66
- solved=True,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  topic="permutations",
68
- answer_value=str(result),
69
- internal_answer=str(result),
70
  steps=[
71
- "When arranging all distinct items, use n!.",
 
 
 
72
  ],
73
  )
74
 
75
- # "how many ways to arrange r from n"
76
- m = re.search(r"arrange\s+(\d+).*?from\s+(\d+)", lower)
77
- if m:
78
- r = int(m.group(1))
79
- n = int(m.group(2))
80
- if r > n or r < 0:
81
- return None
82
- result = perm(n, r)
83
- return SolverResult(
84
- domain="quant",
85
- solved=True,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  topic="permutations",
87
- answer_value=str(result),
88
- internal_answer=str(result),
89
  steps=[
90
- "Use permutations when order matters.",
91
- "Compute nPr.",
 
92
  ],
93
  )
94
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  return None
 
1
  from __future__ import annotations
2
 
3
+ import math
4
  import re
5
+ from math import comb, factorial, perm
6
  from typing import Optional
7
 
8
  from models import SolverResult
9
 
10
 
11
+ # ----------------------------
12
+ # Core helpers
13
+ # ----------------------------
14
+
15
+ def _clean(text: str) -> str:
16
+ t = (text or "").strip()
17
+ t = t.replace("×", "x")
18
+ t = t.replace("–", "-").replace("—", "-")
19
+ t = t.replace("“", '"').replace("”", '"')
20
+ t = t.replace("’", "'")
21
+ t = re.sub(r"\s+", " ", t)
22
+ return t
23
+
24
+
25
+ def _lower(text: str) -> str:
26
+ return _clean(text).lower()
27
+
28
+
29
+ def _safe_comb(n: int, r: int) -> Optional[int]:
30
+ if n < 0 or r < 0 or r > n:
31
+ return None
32
+ return comb(n, r)
33
+
34
 
35
+ def _safe_perm(n: int, r: int) -> Optional[int]:
36
+ if n < 0 or r < 0 or r > n:
 
 
37
  return None
38
+ return perm(n, r)
39
+
40
+
41
+ def _fact(n: int) -> Optional[int]:
42
+ if n < 0:
43
+ return None
44
+ return factorial(n)
45
+
46
+
47
+ def _make_result(topic: str, answer: int, steps: list[str]) -> SolverResult:
48
+ # Keep numeric answer internal / structured.
49
+ # The outer reply layer can decide whether to reveal it.
50
+ return SolverResult(
51
+ domain="quant",
52
+ solved=True,
53
+ topic=topic,
54
+ answer_value=str(answer),
55
+ internal_answer=str(answer),
56
+ steps=steps,
57
+ )
58
+
59
+
60
+ def _numbers(text: str) -> list[int]:
61
+ return [int(x) for x in re.findall(r"\d+", text)]
62
+
63
+
64
+ def _has_any(text: str, words: list[str]) -> bool:
65
+ return any(w in text for w in words)
66
+
67
+
68
+ def _word_frequency_from_quoted_or_caps(raw: str) -> Optional[dict[str, int]]:
69
+ """
70
+ Tries to detect repeated-letter arrangement prompts:
71
+ - letters of "BALLOON"
72
+ - word MISSISSIPPI
73
+ - arrange the letters in BOOKKEEPER
74
+ """
75
+ # quoted word
76
+ m = re.search(r'letters?\s+of\s+"([A-Za-z]+)"', raw, re.I)
77
+ if not m:
78
+ m = re.search(r"word\s+([A-Za-z]+)", raw, re.I)
79
+ if not m:
80
+ m = re.search(r'arrange\s+the\s+letters?\s+(?:in|of)\s+"?([A-Za-z]+)"?', raw, re.I)
81
+
82
+ if not m:
83
+ return None
84
+
85
+ word = m.group(1).strip()
86
+ if not word.isalpha() or len(word) < 2:
87
+ return None
88
+
89
+ freq: dict[str, int] = {}
90
+ for ch in word.upper():
91
+ freq[ch] = freq.get(ch, 0) + 1
92
+ return freq
93
+
94
+
95
+ def _multiset_permutations(freq: dict[str, int]) -> int:
96
+ total = sum(freq.values())
97
+ out = factorial(total)
98
+ for c in freq.values():
99
+ out //= factorial(c)
100
+ return out
101
+
102
+
103
+ def _extract_n_r_from_choose_style(lower: str) -> Optional[tuple[int, int]]:
104
+ patterns = [
105
+ r"(?:choose|select|pick|form|make)\s+(\d+)\s+(?:from|out of)\s+(\d+)",
106
+ r"(?:choose|select|pick)\s+(\d+)\s+of\s+(\d+)",
107
+ r"(?:committee|team|group|delegation|panel)\s+of\s+(\d+).*?(?:from|out of)\s+(\d+)",
108
+ r"(\d+)\s+(?:chosen|selected|picked)\s+from\s+(\d+)",
109
+ r"how many combinations.*?(\d+).*?(?:from|out of)\s+(\d+)",
110
+ r"n\s*=\s*(\d+)\s*,?\s*r\s*=\s*(\d+)", # only if combo cues present elsewhere
111
+ ]
112
+
113
+ for pat in patterns:
114
+ m = re.search(pat, lower)
115
+ if m:
116
+ a, b = int(m.group(1)), int(m.group(2))
117
+ # for n=,r= pattern we want n,r order
118
+ if "n=" in pat:
119
+ return a, b
120
+ return b, a
121
+ return None
122
+
123
+
124
+ def _extract_n_r_from_perm_style(lower: str) -> Optional[tuple[int, int]]:
125
+ patterns = [
126
+ r"(?:arrange|order|rank|assign|seat)\s+(\d+)\s+(?:from|out of)\s+(\d+)",
127
+ r"(?:choose|select|pick)\s+(\d+)\s+(?:from|out of)\s+(\d+)\s+(?:and|then)\s+(?:arrange|order|rank)",
128
+ r"permutations?\s+of\s+(\d+)\s+(?:from|out of)\s+(\d+)",
129
+ r"(?:how many ways|number of ways).*?(?:arrange|order|rank).*?(\d+).*?(?:from|out of)\s+(\d+)",
130
+ r"(\d+)\s+(?:positions|places|slots).*?(?:filled|chosen).*?(?:from|out of)\s+(\d+)",
131
+ ]
132
 
133
+ for pat in patterns:
134
+ m = re.search(pat, lower)
135
+ if m:
136
+ r, n = int(m.group(1)), int(m.group(2))
137
+ return n, r
138
+ return None
139
+
140
+
141
+ def _extract_total_distinct_arrangement_n(lower: str) -> Optional[int]:
142
+ patterns = [
143
+ r"(?:arrange|order|permute|seat)\s+(\d+)\s+(?:different|distinct)?\s*(?:objects|items|people|books|letters|marbles|students|digits)?",
144
+ r"permutations?\s+of\s+(\d+)\s+(?:different|distinct)?",
145
+ r"arrangements?\s+of\s+(\d+)\s+(?:different|distinct)?",
146
+ ]
147
+ for pat in patterns:
148
+ m = re.search(pat, lower)
149
+ if m:
150
+ return int(m.group(1))
151
+ return None
152
+
153
+
154
+ def _extract_circular_n(lower: str) -> Optional[int]:
155
+ patterns = [
156
+ r"(?:around|in)\s+a\s+circle.*?(\d+)",
157
+ r"circular arrangements?.*?(\d+)",
158
+ r"seat\s+(\d+).*?(?:around|in)\s+a\s+(?:round\s+)?table",
159
+ r"arrange\s+(\d+).*?(?:around|in)\s+a\s+circle",
160
+ ]
161
+ for pat in patterns:
162
+ m = re.search(pat, lower)
163
+ if m:
164
+ return int(m.group(1))
165
+ return None
166
+
167
+
168
+ def _extract_required_excluded_committee(lower: str) -> Optional[tuple[int, int, int, int]]:
169
+ """
170
+ Returns (n_total, r_choose, required_count, excluded_count)
171
+ for patterns like:
172
+ - committee of 4 from 10 if A must be included
173
+ - choose 3 from 8 if 2 specific people are excluded
174
+ """
175
+ base = _extract_n_r_from_choose_style(lower)
176
+ if not base:
177
+ return None
178
+
179
+ n, r = base
180
+ required = 0
181
+ excluded = 0
182
+
183
+ # specific people forced in
184
+ req_patterns = [
185
+ r"(?:must|has to|have to|should)\s+be\s+included",
186
+ r"including\s+\w+",
187
+ r"include\s+\w+",
188
+ r"with\s+\w+\s+included",
189
+ ]
190
+ exc_patterns = [
191
+ r"(?:must|has to|have to)\s+not\s+be\s+included",
192
+ r"excluding\s+\w+",
193
+ r"exclude\s+\w+",
194
+ r"without\s+\w+",
195
+ ]
196
+
197
+ if any(re.search(p, lower) for p in req_patterns):
198
+ required = 1
199
+ if any(re.search(p, lower) for p in exc_patterns):
200
+ excluded = 1
201
+
202
+ m = re.search(r"(\d+)\s+specific\s+(?:people|members|students|persons)\s+(?:must|have to)\s+be\s+included", lower)
203
+ if m:
204
+ required = int(m.group(1))
205
+
206
+ m = re.search(r"(\d+)\s+specific\s+(?:people|members|students|persons)\s+(?:must|have to)\s+be\s+excluded", lower)
207
+ if m:
208
+ excluded = int(m.group(1))
209
+
210
+ if required == 0 and excluded == 0:
211
+ return None
212
+
213
+ return n, r, required, excluded
214
+
215
+
216
+ def _extract_group_selection(lower: str) -> Optional[tuple[int, int, int, int, str]]:
217
+ """
218
+ Handles common grouped committee/team cases:
219
+ - 3 men and 2 women from 7 men and 5 women
220
+ - at least 2 women from 6 men and 4 women for a committee of 4
221
+ Returns:
222
+ (a_total, b_total, choose_total, threshold, mode)
223
+ mode in {"exact_a", "at_least_a"}
224
+ Here group A is first-mentioned category.
225
+ """
226
+ # exact split: x men and y women from M men and W women
227
+ m = re.search(
228
+ r"(\d+)\s+\w+\s+and\s+(\d+)\s+\w+.*?(?:from|out of)\s+(\d+)\s+\w+\s+and\s+(\d+)\s+\w+",
229
+ lower,
230
+ )
231
  if m:
232
+ a_choose = int(m.group(1))
233
+ b_choose = int(m.group(2))
234
+ a_total = int(m.group(3))
235
+ b_total = int(m.group(4))
236
+ # pack choose_total as first component sum, threshold as a_choose,
237
+ # mode exact_a means choose threshold from first group and remainder from second
238
+ return a_total, b_total, a_choose + b_choose, a_choose, "exact_a"
239
+
240
+ # at least k from first group
241
+ m = re.search(
242
+ r"at least\s+(\d+)\s+\w+.*?(?:committee|team|group|delegation|selection)\s+of\s+(\d+).*?(?:from|out of)\s+(\d+)\s+\w+\s+and\s+(\d+)\s+\w+",
243
+ lower,
244
+ )
245
+ if m:
246
+ threshold = int(m.group(1))
247
+ choose_total = int(m.group(2))
248
+ a_total = int(m.group(3))
249
+ b_total = int(m.group(4))
250
+ return a_total, b_total, choose_total, threshold, "at_least_a"
251
+
252
+ return None
253
+
254
+
255
+ def _extract_adjacent_block_n_k(lower: str) -> Optional[tuple[int, int]]:
256
+ """
257
+ Tries to detect:
258
+ - among n distinct objects, k specific objects must be together
259
+ - A and B must sit together among n people
260
+ Returns (n_total_distinct, block_size)
261
+ """
262
+ # count named items joined by and
263
+ m = re.search(
264
+ r"(\d+)\s+(?:distinct|different)?\s*(?:people|students|books|letters|objects|items|marbles).*?(?:must|have to)\s+be\s+together",
265
+ lower,
266
+ )
267
+ if m:
268
+ n = int(m.group(1))
269
+ # if text explicitly lists "a and b", treat as 2
270
+ pair = re.search(r"\b\w+\s+and\s+\w+\b.*?(?:must|have to)\s+be\s+together", lower)
271
+ if pair:
272
+ return n, 2
273
+
274
+ # more direct "2 specific people must sit together among 7"
275
+ m = re.search(
276
+ r"(\d+)\s+specific\s+(?:people|students|books|letters|objects|items).*?(?:must|have to)\s+be\s+together.*?(?:among|from|out of)\s+(\d+)",
277
+ lower,
278
+ )
279
+ if m:
280
+ k = int(m.group(1))
281
  n = int(m.group(2))
282
+ return n, k
283
+
284
+ # "A and B together among 6 people"
285
+ m = re.search(
286
+ r"\b\w+\s+and\s+\w+\b.*?(?:together|next to each other|adjacent).*?(?:among|out of|from)\s+(\d+)",
287
+ lower,
288
+ )
289
+ if m:
290
+ n = int(m.group(1))
291
+ return n, 2
292
+
293
+ # fallback: if exactly one overall n and pair-together cue exists
294
+ nums = _numbers(lower)
295
+ if len(nums) == 1 and _has_any(lower, ["together", "next to each other", "adjacent"]):
296
+ return nums[0], 2
297
+
298
+ return None
299
+
300
+
301
+ def _extract_not_together_pair(lower: str) -> Optional[int]:
302
+ if not _has_any(lower, ["not together", "not adjacent", "not next to each other", "cannot sit together"]):
303
+ return None
304
+ nums = _numbers(lower)
305
+ if nums:
306
+ return nums[-1] if len(nums) == 1 else max(nums)
307
+ return None
308
+
309
+
310
+ def _extract_relative_order_n(lower: str) -> Optional[int]:
311
+ """
312
+ Detect simple relative-order cases:
313
+ - A left of B
314
+ - A before B
315
+ - A ahead of B
316
+ among n distinct objects/people
317
+ """
318
+ if not _has_any(lower, ["left of", "before", "ahead of", "to the left of"]):
319
+ return None
320
+ nums = _numbers(lower)
321
+ if nums:
322
+ return nums[-1] if len(nums) == 1 else max(nums)
323
+ return None
324
+
325
+
326
+ def _extract_stars_bars(lower: str) -> Optional[tuple[int, int, bool]]:
327
+ """
328
+ Detect nonnegative / positive integer solution counts:
329
+ - number of nonnegative integer solutions to x+y+z=10
330
+ - positive integer solutions to a+b+c=12
331
+ Returns (n_sum, k_vars, positive_required)
332
+ """
333
+ m = re.search(
334
+ r"(nonnegative|positive)\s+integer\s+solutions?.*?to\s+([a-z](?:\s*\+\s*[a-z])+)\s*=\s*(\d+)",
335
+ lower,
336
+ )
337
+ if not m:
338
+ return None
339
+
340
+ positivity = m.group(1)
341
+ vars_expr = m.group(2)
342
+ total = int(m.group(3))
343
+ vars_count = len(re.findall(r"[a-z]", vars_expr))
344
+ return total, vars_count, positivity == "positive"
345
+
346
+
347
+ def _extract_digit_codes(lower: str) -> Optional[tuple[int, bool, bool]]:
348
+ """
349
+ Handles password/code/number formation style:
350
+ Returns (length, repetition_allowed, starts_with_zero_allowed_guess)
351
+ """
352
+ if not _has_any(lower, ["digit number", "digits", "code", "password", "license plate", "pin"]):
353
+ return None
354
+
355
+ m = re.search(r"(\d+)[-\s]digit", lower)
356
+ if not m:
357
+ m = re.search(r"code\s+of\s+length\s+(\d+)", lower)
358
+ if not m:
359
+ return None
360
+
361
+ length = int(m.group(1))
362
+ repetition_allowed = _has_any(lower, ["repetition allowed", "can repeat", "may repeat", "with repetition"])
363
+ starts_zero_allowed = not _has_any(lower, ["first digit cannot be 0", "leading zero not allowed", "cannot start with 0"])
364
+
365
+ return length, repetition_allowed, starts_zero_allowed
366
+
367
+
368
+ def _extract_available_digits(lower: str) -> Optional[int]:
369
+ m = re.search(r"from\s+(\d+)\s+(?:digits|numbers)", lower)
370
+ if m:
371
+ return int(m.group(1))
372
+ # decimal digit default
373
+ if _has_any(lower, ["digit number", "digits", "code", "password", "pin"]):
374
+ return 10
375
+ return None
376
+
377
+
378
+ # ----------------------------
379
+ # Main solver
380
+ # ----------------------------
381
+
382
+ def solve_combinatorics(text: str) -> Optional[SolverResult]:
383
+ raw = _clean(text or "")
384
+ lower = raw.lower()
385
+
386
+ if not raw:
387
+ return None
388
+
389
+ combinatorics_cues = [
390
+ "combination", "combinations", "permutation", "permutations",
391
+ "arrange", "arrangement", "arrangements", "order", "ordered",
392
+ "choose", "select", "pick", "committee", "team", "group",
393
+ "delegation", "panel", "seat", "seating", "circular", "circle",
394
+ "round table", "together", "adjacent", "left of", "before",
395
+ "anagram", "letters of", "word", "integer solutions", "password",
396
+ "license plate", "pin", "code", "nonnegative integer", "positive integer",
397
+ ]
398
+ if not _has_any(lower, combinatorics_cues):
399
+ return None
400
+
401
+ # ----------------------------------------
402
+ # 1) Repeated-letter arrangements / anagrams
403
+ # ----------------------------------------
404
+ freq = _word_frequency_from_quoted_or_caps(raw)
405
+ if freq and _has_any(lower, ["letters", "word", "arrange", "anagram", "distinct arrangements"]):
406
+ result = _multiset_permutations(freq)
407
+ return _make_result(
408
+ topic="combinatorics",
409
+ answer=result,
410
  steps=[
411
+ "Treat this as an arrangement of letters with repetition.",
412
+ "Start with the factorial of the total number of letters.",
413
+ "Then divide by factorials of each repeated-letter count so identical rearrangements are not overcounted.",
414
+ "Evaluate that expression at the end.",
415
  ],
416
  )
417
 
418
+ # ----------------------------------------
419
+ # 2) Circular arrangements
420
+ # ----------------------------------------
421
+ n_circle = _extract_circular_n(lower)
422
+ if n_circle is not None and n_circle >= 1:
423
+ result = 1 if n_circle == 1 else factorial(n_circle - 1)
424
+ return _make_result(
425
+ topic="combinatorics",
426
+ answer=result,
 
 
 
 
 
427
  steps=[
428
+ "For circular arrangements of distinct objects, rotations count as the same arrangement.",
429
+ "Fix one object to remove rotational duplicates.",
430
+ "Then arrange the remaining objects in the remaining positions.",
431
+ "Evaluate the factorial expression at the end.",
432
  ],
433
  )
434
 
435
+ # ----------------------------------------
436
+ # 3) Integer-solution counting (stars and bars)
437
+ # ----------------------------------------
438
+ stars = _extract_stars_bars(lower)
439
+ if stars:
440
+ total, vars_count, positive_required = stars
441
+ if positive_required:
442
+ adjusted = total - vars_count
443
+ if adjusted < 0:
444
+ return _make_result(
445
+ topic="combinatorics",
446
+ answer=0,
447
+ steps=[
448
+ "For positive integer solutions, first give each variable 1.",
449
+ "That reduces the remaining amount to distribute.",
450
+ "If the remaining amount is negative, no valid solutions exist.",
451
+ ],
452
+ )
453
+ result = comb(adjusted + vars_count - 1, vars_count - 1)
454
+ return _make_result(
455
+ topic="combinatorics",
456
+ answer=result,
457
+ steps=[
458
+ "This is a stars-and-bars counting problem with positive integers.",
459
+ "Give each variable 1 first so the positivity condition is satisfied.",
460
+ "Then count the nonnegative distributions of the remaining total among the variables.",
461
+ "Evaluate the resulting combination.",
462
+ ],
463
+ )
464
+ else:
465
+ result = comb(total + vars_count - 1, vars_count - 1)
466
+ return _make_result(
467
+ topic="combinatorics",
468
+ answer=result,
469
+ steps=[
470
+ "This is a stars-and-bars counting problem with nonnegative integers.",
471
+ "Interpret the total as stars and the separators between variables as bars.",
472
+ "Count the placements of the bars among the stars.",
473
+ "Evaluate the resulting combination.",
474
+ ],
475
+ )
476
+
477
+ # ----------------------------------------
478
+ # 4) Exact grouped selections
479
+ # ----------------------------------------
480
+ grouped = _extract_group_selection(lower)
481
+ if grouped:
482
+ a_total, b_total, choose_total, threshold, mode = grouped
483
+ if mode == "exact_a":
484
+ a_choose = threshold
485
+ b_choose = choose_total - a_choose
486
+ left = _safe_comb(a_total, a_choose)
487
+ right = _safe_comb(b_total, b_choose)
488
+ if left is not None and right is not None:
489
+ result = left * right
490
+ return _make_result(
491
+ topic="combinations",
492
+ answer=result,
493
+ steps=[
494
+ "Split the selection by category.",
495
+ "Choose the required number from the first group.",
496
+ "Choose the remaining number from the second group.",
497
+ "Multiply the independent counts.",
498
+ ],
499
+ )
500
+
501
+ if mode == "at_least_a":
502
+ total_count = 0
503
+ valid = False
504
+ for a_choose in range(threshold, choose_total + 1):
505
+ b_choose = choose_total - a_choose
506
+ left = _safe_comb(a_total, a_choose)
507
+ right = _safe_comb(b_total, b_choose)
508
+ if left is None or right is None:
509
+ continue
510
+ total_count += left * right
511
+ valid = True
512
+ if valid:
513
+ return _make_result(
514
+ topic="combinations",
515
+ answer=total_count,
516
+ steps=[
517
+ "Break the problem into valid cases based on how many can come from the first group.",
518
+ "For each valid case, choose from the first group and choose the remainder from the second group.",
519
+ "Add the case counts together.",
520
+ ],
521
+ )
522
+
523
+ # ----------------------------------------
524
+ # 5) Committee / selection with required or excluded members
525
+ # ----------------------------------------
526
+ req_exc = _extract_required_excluded_committee(lower)
527
+ if req_exc:
528
+ n, r, required, excluded = req_exc
529
+
530
+ # required members first
531
+ if required > 0 and excluded == 0:
532
+ remaining_n = n - required
533
+ remaining_r = r - required
534
+ result = _safe_comb(remaining_n, remaining_r)
535
+ if result is not None:
536
+ return _make_result(
537
+ topic="combinations",
538
+ answer=result,
539
+ steps=[
540
+ "Treat the required members as already chosen.",
541
+ "Reduce both the total pool and the number still to be selected.",
542
+ "Then count the remaining selection with combinations.",
543
+ ],
544
+ )
545
+
546
+ # excluded members first
547
+ if excluded > 0 and required == 0:
548
+ remaining_n = n - excluded
549
+ result = _safe_comb(remaining_n, r)
550
+ if result is not None:
551
+ return _make_result(
552
+ topic="combinations",
553
+ answer=result,
554
+ steps=[
555
+ "Remove the excluded members from the pool first.",
556
+ "Then choose the full committee from the reduced pool.",
557
+ ],
558
+ )
559
+
560
+ # both required and excluded
561
+ remaining_n = n - required - excluded
562
+ remaining_r = r - required
563
+ result = _safe_comb(remaining_n, remaining_r)
564
+ if result is not None:
565
+ return _make_result(
566
+ topic="combinations",
567
+ answer=result,
568
+ steps=[
569
+ "Force the required members into the selection.",
570
+ "Remove the excluded members from the pool.",
571
+ "Then choose the remaining spots from the remaining eligible people.",
572
+ ],
573
+ )
574
+
575
+ # ----------------------------------------
576
+ # 6) Pair not together / not adjacent
577
+ # ----------------------------------------
578
+ n_not_together = _extract_not_together_pair(lower)
579
+ if n_not_together is not None and n_not_together >= 2:
580
+ total = factorial(n_not_together)
581
+ together = 2 * factorial(n_not_together - 1)
582
+ result = total - together
583
+ return _make_result(
584
  topic="permutations",
585
+ answer=result,
 
586
  steps=[
587
+ "Count all unrestricted arrangements first.",
588
+ "Then count the arrangements where the two specified objects stay together by treating them as one block.",
589
+ "Because the two objects can switch places inside the block, include that internal ordering factor.",
590
+ "Subtract the together-case count from the total.",
591
  ],
592
  )
593
 
594
+ # ----------------------------------------
595
+ # 7) Adjacent/together block arrangements
596
+ # ----------------------------------------
597
+ block = _extract_adjacent_block_n_k(lower)
598
+ if block:
599
+ n, k = block
600
+ if n >= k >= 2:
601
+ result = factorial(n - k + 1) * factorial(k)
602
+ return _make_result(
603
+ topic="permutations",
604
+ answer=result,
605
+ steps=[
606
+ "Treat the required adjacent objects as one block.",
607
+ "Count the arrangements of that block together with the remaining distinct objects.",
608
+ "Then multiply by the internal arrangements of the objects inside the block.",
609
+ ],
610
+ )
611
+
612
+ # ----------------------------------------
613
+ # 8) Relative order: A before B / left of B
614
+ # ----------------------------------------
615
+ n_order = _extract_relative_order_n(lower)
616
+ if n_order is not None and n_order >= 2:
617
+ result = factorial(n_order) // 2
618
+ return _make_result(
619
  topic="permutations",
620
+ answer=result,
 
621
  steps=[
622
+ "Start from all arrangements of the distinct objects.",
623
+ "For any arrangement, swapping the two named objects reverses their relative order.",
624
+ "So exactly half of all arrangements satisfy the required order condition.",
625
  ],
626
  )
627
 
628
+ # ----------------------------------------
629
+ # 9) Exact combination selection nCr
630
+ # ----------------------------------------
631
+ choose_nr = _extract_n_r_from_choose_style(lower)
632
+ if choose_nr and _has_any(lower, ["choose", "select", "pick", "committee", "team", "group", "delegation", "panel"]):
633
+ n, r = choose_nr
634
+ result = _safe_comb(n, r)
635
+ if result is not None:
636
+ return _make_result(
637
+ topic="combinations",
638
+ answer=result,
639
+ steps=[
640
+ "This is a selection problem where order does not matter.",
641
+ "Use combinations: choose the required number from the total pool.",
642
+ "Set up the combination expression and evaluate it at the end.",
643
+ ],
644
+ )
645
+
646
+ # ----------------------------------------
647
+ # 10) Exact permutation / ordered selection nPr
648
+ # ----------------------------------------
649
+ perm_nr = _extract_n_r_from_perm_style(lower)
650
+ if perm_nr:
651
+ n, r = perm_nr
652
+ result = _safe_perm(n, r)
653
+ if result is not None:
654
+ return _make_result(
655
+ topic="permutations",
656
+ answer=result,
657
+ steps=[
658
+ "This is an ordered selection problem, so order matters.",
659
+ "Choose and arrange the required number of positions from the total available objects.",
660
+ "Set up the permutation expression and evaluate it at the end.",
661
+ ],
662
+ )
663
+
664
+ # ----------------------------------------
665
+ # 11) Full arrangement of n distinct objects
666
+ # ----------------------------------------
667
+ if _has_any(lower, ["arrange", "arrangement", "arrangements", "permutation", "permutations", "seat", "seating", "order"]):
668
+ n_all = _extract_total_distinct_arrangement_n(lower)
669
+ if n_all is not None:
670
+ result = _fact(n_all)
671
+ if result is not None:
672
+ return _make_result(
673
+ topic="permutations",
674
+ answer=result,
675
+ steps=[
676
+ "All distinct objects are being arranged.",
677
+ "Fill positions one by one: first position, second position, and so on.",
678
+ "That leads to the factorial count for all distinct arrangements.",
679
+ ],
680
+ )
681
+
682
+ # ----------------------------------------
683
+ # 12) Digits / codes / passwords
684
+ # ----------------------------------------
685
+ digit_case = _extract_digit_codes(lower)
686
+ if digit_case:
687
+ length, repetition_allowed, starts_zero_allowed = digit_case
688
+ available = _extract_available_digits(lower) or 10
689
+
690
+ if repetition_allowed:
691
+ if starts_zero_allowed:
692
+ result = available ** length
693
+ else:
694
+ result = (available - 1) * (available ** (length - 1))
695
+ return _make_result(
696
+ topic="combinatorics",
697
+ answer=result,
698
+ steps=[
699
+ "Treat each position independently.",
700
+ "Use the allowed number of choices for the first position, paying attention to any leading-zero restriction.",
701
+ "Then multiply by the allowed choices for each remaining position.",
702
+ ],
703
+ )
704
+
705
+ # no repetition allowed
706
+ if length > available:
707
+ return _make_result(
708
+ topic="combinatorics",
709
+ answer=0,
710
+ steps=[
711
+ "Without repetition, you cannot fill more positions than the number of available distinct digits.",
712
+ "So this setup has no valid arrangements.",
713
+ ],
714
+ )
715
+
716
+ if starts_zero_allowed:
717
+ result = perm(available, length)
718
+ else:
719
+ result = (available - 1) * perm(available - 1, length - 1)
720
+
721
+ return _make_result(
722
+ topic="combinatorics",
723
+ answer=result,
724
+ steps=[
725
+ "This is an ordered arrangement of digits.",
726
+ "Because repetition is not allowed, the number of choices decreases from position to position.",
727
+ "Handle the first digit separately if leading zero is not allowed.",
728
+ "Then multiply the sequential choices.",
729
+ ],
730
+ )
731
+
732
+ # ----------------------------------------
733
+ # 13) Direct nCr / nPr notation
734
+ # ----------------------------------------
735
+ m = re.search(r"(\d+)\s*c\s*(\d+)", lower)
736
+ if m:
737
+ n, r = int(m.group(1)), int(m.group(2))
738
+ result = _safe_comb(n, r)
739
+ if result is not None:
740
+ return _make_result(
741
+ topic="combinations",
742
+ answer=result,
743
+ steps=[
744
+ "Interpret the notation as a combination count.",
745
+ "Evaluate the combination expression carefully.",
746
+ ],
747
+ )
748
+
749
+ m = re.search(r"(\d+)\s*p\s*(\d+)", lower)
750
+ if m:
751
+ n, r = int(m.group(1)), int(m.group(2))
752
+ result = _safe_perm(n, r)
753
+ if result is not None:
754
+ return _make_result(
755
+ topic="permutations",
756
+ answer=result,
757
+ steps=[
758
+ "Interpret the notation as a permutation count.",
759
+ "Evaluate the permutation expression carefully.",
760
+ ],
761
+ )
762
+
763
  return None