| from __future__ import annotations |
|
|
| import re |
| from typing import Optional, List, Tuple |
|
|
| from models import SolverResult |
|
|
|
|
| NUMBER = r"-?\d+(?:\.\d+)?" |
|
|
|
|
| def _nums(text: str) -> List[float]: |
| return [float(x) for x in re.findall(NUMBER, text)] |
|
|
|
|
| def _safe_float(x: str) -> Optional[float]: |
| try: |
| return float(x) |
| except Exception: |
| return None |
|
|
|
|
| def _clean(text: str) -> str: |
| t = text.lower() |
| t = t.replace("per cent", "%") |
| t = t.replace("percent", "%") |
| t = t.replace("percentage", "%") |
| t = t.replace("pct", "%") |
| t = t.replace("mean", "average") |
| t = t.replace("overall mean", "overall average") |
| t = t.replace("combined mean", "combined average") |
| t = t.replace("weighted mean", "weighted average") |
| t = re.sub(r"\s+", " ", t).strip() |
| return t |
|
|
|
|
| def _has_any(text: str, words: List[str]) -> bool: |
| return any(w in text for w in words) |
|
|
|
|
| def _fmt(x: float) -> str: |
| if abs(x - round(x)) < 1e-9: |
| return str(int(round(x))) |
| return f"{x:.10g}" |
|
|
|
|
| def _result( |
| topic: str, |
| answer: float, |
| method: str, |
| what_is_asked: str, |
| hints: List[str], |
| steps: List[str], |
| ) -> SolverResult: |
| |
| |
| return SolverResult( |
| domain="quant", |
| solved=True, |
| topic=topic, |
| answer_value=_fmt(answer), |
| internal_answer=_fmt(answer), |
| steps=[ |
| f"What the question is asking: {what_is_asked}", |
| f"Method: {method}", |
| *[f"Hint: {h}" for h in hints], |
| *steps, |
| ], |
| ) |
|
|
|
|
| def _weighted_average(a: float, wa: float, b: float, wb: float) -> float: |
| return (a * wa + b * wb) / (a + b) |
|
|
|
|
| def _parse_percent_number_pairs(text: str) -> List[Tuple[float, float]]: |
| """ |
| Attempts to parse patterns like: |
| - 10 liters of 20% |
| - 30% solution and 50% solution in amounts 4 and 6 |
| - 5 pounds of alloy containing 60% |
| Returns [(amount, percent_as_decimal), ...] |
| """ |
| pairs: List[Tuple[float, float]] = [] |
|
|
| patterns = [ |
| rf"({NUMBER})\s*(?:liters?|l|gallons?|ounces?|oz|ml|milliliters?|kg|kilograms?|grams?|g|pounds?|lb|units?)?\s*(?:of\s+)?(?:a\s+)?({NUMBER})\s*%", |
| rf"({NUMBER})\s*%\s*(?:solution|mixture|alloy|acid|alcohol|salt|metal|ingredient)?(?:[^0-9]{{0,30}}?)({NUMBER})\s*(?:liters?|l|gallons?|ounces?|oz|ml|milliliters?|kg|kilograms?|grams?|g|pounds?|lb|units?)", |
| ] |
|
|
| for pat in patterns: |
| for m in re.finditer(pat, text): |
| a = _safe_float(m.group(1)) |
| b = _safe_float(m.group(2)) |
| if a is None or b is None: |
| continue |
|
|
| |
| |
| if "%" in m.group(0).split(str(m.group(1)), 1)[1]: |
| amount = a |
| pct = b / 100.0 |
| else: |
| amount = b |
| pct = a / 100.0 |
|
|
| if amount > 0: |
| pairs.append((amount, pct)) |
|
|
| return pairs |
|
|
|
|
| def _extract_percent_values(text: str) -> List[float]: |
| return [float(x) / 100.0 for x in re.findall(rf"({NUMBER})\s*%", text)] |
|
|
|
|
| def _extract_overall_average(text: str) -> Optional[float]: |
| pats = [ |
| rf"overall average[^0-9\-]*({NUMBER})", |
| rf"combined average[^0-9\-]*({NUMBER})", |
| rf"average for all[^0-9\-]*({NUMBER})", |
| rf"average of all[^0-9\-]*({NUMBER})", |
| rf"entire group[^0-9\-]*average[^0-9\-]*({NUMBER})", |
| rf"the average[^0-9\-]*({NUMBER})", |
| ] |
| for pat in pats: |
| m = re.search(pat, text) |
| if m: |
| return _safe_float(m.group(1)) |
| return None |
|
|
|
|
| def _extract_counts_near_average(text: str) -> List[Tuple[float, float]]: |
| """ |
| Tries to capture: |
| - 12 students have an average of 80 |
| - a group of 5 with average 17 |
| Returns [(count, avg), ...] |
| """ |
| out: List[Tuple[float, float]] = [] |
| pats = [ |
| rf"({NUMBER})\s*(?:students?|items?|numbers?|employees?|workers?|people|values|scores|tests?|boxes|bags|cars|men|women|children|members|observations?)\s*(?:have|has|with)?[^.]*?average[^0-9\-]*({NUMBER})", |
| rf"group of\s*({NUMBER})[^.]*?average[^0-9\-]*({NUMBER})", |
| rf"({NUMBER})[^.]*?average[^0-9\-]*({NUMBER})", |
| ] |
| for pat in pats: |
| for m in re.finditer(pat, text): |
| c = _safe_float(m.group(1)) |
| a = _safe_float(m.group(2)) |
| if c is not None and a is not None and c > 0: |
| out.append((c, a)) |
| return out |
|
|
|
|
| def _is_mixture_language(text: str) -> bool: |
| return _has_any( |
| text, |
| [ |
| "mixture", |
| "solution", |
| "concentration", |
| "alloy", |
| "acid", |
| "alcohol", |
| "salt", |
| "pure", |
| "dilute", |
| "dilution", |
| "contains", |
| "%", |
| "blend", |
| ], |
| ) |
|
|
|
|
| def _is_average_language(text: str) -> bool: |
| return _has_any( |
| text, |
| [ |
| "average", |
| "weighted average", |
| "combined average", |
| "overall average", |
| "mean", |
| ], |
| ) |
|
|
|
|
| def _solve_two_group_combined_average(text: str) -> Optional[SolverResult]: |
| """ |
| Example: |
| - Group A has 10 students with average 80, group B has 15 students with average 70. |
| What is the combined average? |
| """ |
| pairs = _extract_counts_near_average(text) |
| if len(pairs) < 2: |
| return None |
|
|
| a, wa = pairs[0] |
| b, wb = pairs[1] |
| combined = _weighted_average(a, wa, b, wb) |
|
|
| return _result( |
| topic="weighted_average", |
| answer=combined, |
| method="Use total sum / total count. A combined average is a weighted average, not a simple average of the two averages unless the group sizes are equal.", |
| what_is_asked="Find the average of two groups after weighting each group by how many items it has.", |
| hints=[ |
| "Turn each group average into a group sum: count × average.", |
| "Add the group sums.", |
| "Divide by the total number of items.", |
| ], |
| steps=[ |
| "Compute the total contribution from the first group.", |
| "Compute the total contribution from the second group.", |
| "Add the contributions and divide by the total count.", |
| ], |
| ) |
|
|
|
|
| def _solve_remaining_group_average(text: str) -> Optional[SolverResult]: |
| """ |
| Example: |
| - 20 students have an overall average of 75. |
| - 8 of them have an average of 82. |
| - What is the average of the remaining students? |
| """ |
| if not _has_any(text, ["remaining", "rest", "others", "other"]): |
| return None |
|
|
| overall = _extract_overall_average(text) |
| if overall is None: |
| return None |
|
|
| pairs = _extract_counts_near_average(text) |
| nums = _nums(text) |
|
|
| if len(pairs) >= 1: |
| known_count, known_avg = pairs[0] |
| total_count = None |
|
|
| |
| total_patterns = [ |
| rf"({NUMBER})\s*(?:students?|items?|numbers?|employees?|workers?|people|values|scores|tests?|boxes|bags|cars|men|women|children|members|observations?)\s*(?:in all|total|altogether)", |
| rf"total of\s*({NUMBER})", |
| rf"({NUMBER})\s*(?:in total|altogether)", |
| ] |
| for pat in total_patterns: |
| m = re.search(pat, text) |
| if m: |
| total_count = _safe_float(m.group(1)) |
| break |
|
|
| if total_count is None and nums: |
| |
| total_count = max(nums) |
|
|
| if total_count is None or total_count <= known_count: |
| return None |
|
|
| total_sum = total_count * overall |
| known_sum = known_count * known_avg |
| remaining_count = total_count - known_count |
| remaining_avg = (total_sum - known_sum) / remaining_count |
|
|
| return _result( |
| topic="weighted_average", |
| answer=remaining_avg, |
| method="Use total sum = total count × overall average, subtract the known subgroup sum, then divide by the remaining count.", |
| what_is_asked="Find the average of the unknown subgroup by removing the known subgroup from the total.", |
| hints=[ |
| "Convert the overall average into a total sum.", |
| "Convert the known subgroup average into its subgroup sum.", |
| "Subtract, then divide by the remaining number of items.", |
| ], |
| steps=[ |
| "Write the total sum for the entire set.", |
| "Write the sum contributed by the known subgroup.", |
| "Subtract to get the remaining subgroup sum.", |
| "Divide by the remaining count.", |
| ], |
| ) |
|
|
| return None |
|
|
|
|
| def _solve_added_value_average(text: str) -> Optional[SolverResult]: |
| """ |
| Example: |
| - The average of 8 numbers is 12. If one number 20 is added, what is the new average? |
| """ |
| if not _has_any(text, ["added", "add", "inserted", "included", "joined"]): |
| return None |
| if _has_any(text, ["removed", "replace", "replaced"]): |
| return None |
|
|
| m = re.search( |
| rf"average of\s*({NUMBER})\s*(?:numbers?|values?|items?)\s*(?:is|was)\s*({NUMBER})", |
| text, |
| ) |
| if not m: |
| m = re.search( |
| rf"({NUMBER})\s*(?:numbers?|values?|items?)\s*(?:have|has)\s*(?:an\s*)?average of\s*({NUMBER})", |
| text, |
| ) |
| if not m: |
| return None |
|
|
| n = _safe_float(m.group(1)) |
| avg = _safe_float(m.group(2)) |
| if n is None or avg is None: |
| return None |
|
|
| added_matches = re.findall(rf"(?:add|added|including|included)\s*({NUMBER})", text) |
| if not added_matches: |
| |
| added_matches = re.findall(rf"number[^0-9\-]*({NUMBER})[^.]*?(?:added|included)", text) |
|
|
| if not added_matches: |
| return None |
|
|
| x = _safe_float(added_matches[-1]) |
| if x is None: |
| return None |
|
|
| new_avg = (n * avg + x) / (n + 1) |
|
|
| return _result( |
| topic="weighted_average", |
| answer=new_avg, |
| method="Convert average to total sum, add the new value, then divide by the new count.", |
| what_is_asked="Find the new average after one extra value is added.", |
| hints=[ |
| "Original sum = original count × original average.", |
| "Add the new value to the original sum.", |
| "Increase the count by 1.", |
| ], |
| steps=[ |
| "Compute the original total.", |
| "Update the total by adding the new value.", |
| "Update the count.", |
| "Divide the new total by the new count.", |
| ], |
| ) |
|
|
|
|
| def _solve_removed_value_average(text: str) -> Optional[SolverResult]: |
| """ |
| Example: |
| - The average of 10 numbers is 14. If one number 20 is removed, what is the new average? |
| """ |
| if not _has_any(text, ["removed", "remove", "deleted", "taken out"]): |
| return None |
| if _has_any(text, ["replaced", "replace"]): |
| return None |
|
|
| m = re.search( |
| rf"average of\s*({NUMBER})\s*(?:numbers?|values?|items?)\s*(?:is|was)\s*({NUMBER})", |
| text, |
| ) |
| if not m: |
| m = re.search( |
| rf"({NUMBER})\s*(?:numbers?|values?|items?)\s*(?:have|has)\s*(?:an\s*)?average of\s*({NUMBER})", |
| text, |
| ) |
| if not m: |
| return None |
|
|
| n = _safe_float(m.group(1)) |
| avg = _safe_float(m.group(2)) |
| if n is None or avg is None or n <= 1: |
| return None |
|
|
| removed_matches = re.findall(rf"(?:remove|removed|deleted|taken out)\s*({NUMBER})", text) |
| if not removed_matches: |
| removed_matches = re.findall(rf"number[^0-9\-]*({NUMBER})[^.]*?(?:removed|taken out)", text) |
| if not removed_matches: |
| return None |
|
|
| x = _safe_float(removed_matches[-1]) |
| if x is None: |
| return None |
|
|
| new_avg = (n * avg - x) / (n - 1) |
|
|
| return _result( |
| topic="weighted_average", |
| answer=new_avg, |
| method="Convert average to total sum, subtract the removed value, then divide by the new count.", |
| what_is_asked="Find the new average after one value is removed.", |
| hints=[ |
| "Original sum = count × average.", |
| "Subtract the removed value from the total sum.", |
| "Decrease the count by 1.", |
| ], |
| steps=[ |
| "Compute the original total.", |
| "Update the total by removing the specified value.", |
| "Update the count.", |
| "Divide the new total by the new count.", |
| ], |
| ) |
|
|
|
|
| def _solve_replaced_value_average(text: str) -> Optional[SolverResult]: |
| """ |
| Example: |
| - The average of 12 numbers is 9. If one value 5 is replaced by 17, what is the new average? |
| """ |
| if not _has_any(text, ["replace", "replaced", "substituted"]): |
| return None |
|
|
| m = re.search( |
| rf"average of\s*({NUMBER})\s*(?:numbers?|values?|items?)\s*(?:is|was)\s*({NUMBER})", |
| text, |
| ) |
| if not m: |
| m = re.search( |
| rf"({NUMBER})\s*(?:numbers?|values?|items?)\s*(?:have|has)\s*(?:an\s*)?average of\s*({NUMBER})", |
| text, |
| ) |
| if not m: |
| return None |
|
|
| n = _safe_float(m.group(1)) |
| avg = _safe_float(m.group(2)) |
| if n is None or avg is None: |
| return None |
|
|
| repl = re.search(rf"({NUMBER})\s*(?:is\s*)?(?:replaced|substituted)\s*(?:by|with)\s*({NUMBER})", text) |
| if not repl: |
| repl = re.search(rf"replace\s*({NUMBER})\s*(?:by|with)\s*({NUMBER})", text) |
| if not repl: |
| return None |
|
|
| old_val = _safe_float(repl.group(1)) |
| new_val = _safe_float(repl.group(2)) |
| if old_val is None or new_val is None: |
| return None |
|
|
| new_avg = (n * avg - old_val + new_val) / n |
|
|
| return _result( |
| topic="weighted_average", |
| answer=new_avg, |
| method="A replacement changes the total sum but not the count.", |
| what_is_asked="Find the new average after one value is swapped for another.", |
| hints=[ |
| "Original total = count × average.", |
| "Subtract the old value and add the new value.", |
| "Keep the count the same.", |
| ], |
| steps=[ |
| "Compute the original total.", |
| "Adjust the total for the replacement.", |
| "Divide by the unchanged count.", |
| ], |
| ) |
|
|
|
|
| def _solve_two_solution_target_mix(text: str) -> Optional[SolverResult]: |
| """ |
| Example: |
| - How many liters of 20% solution should be mixed with 30 liters of 50% solution to obtain 40% solution? |
| """ |
| if not _is_mixture_language(text): |
| return None |
|
|
| pcts = _extract_percent_values(text) |
| if len(pcts) < 3: |
| return None |
|
|
| target = None |
| target_patterns = [ |
| rf"(?:obtain|get|make|produce|yield|result in|to form)\s*({NUMBER})\s*%", |
| rf"target(?: concentration)?[^0-9\-]*({NUMBER})\s*%", |
| rf"final(?: concentration)?[^0-9\-]*({NUMBER})\s*%", |
| ] |
| for pat in target_patterns: |
| m = re.search(pat, text) |
| if m: |
| target = _safe_float(m.group(1)) |
| if target is not None: |
| target /= 100.0 |
| break |
|
|
| if target is None: |
| |
| target = pcts[-1] |
|
|
| pairs = _parse_percent_number_pairs(text) |
| if len(pairs) < 1: |
| return None |
|
|
| known_amount, known_pct = pairs[0] |
|
|
| |
| candidates = [] |
| for p in pcts: |
| if abs(p - target) > 1e-9 and abs(p - known_pct) > 1e-9: |
| candidates.append(p) |
| if not candidates: |
| return None |
| unknown_pct = candidates[0] |
|
|
| denom = unknown_pct - target |
| if abs(denom) < 1e-12: |
| return None |
|
|
| x = known_amount * (target - known_pct) / denom |
| if x <= 0: |
| return None |
|
|
| return _result( |
| topic="mixture_weighted_average", |
| answer=x, |
| method="Use a weighted-average concentration equation: amount × concentration, then divide by total amount.", |
| what_is_asked="Find the unknown quantity of one solution needed so that the final concentration hits the target.", |
| hints=[ |
| "Think of concentration as the average value being weighted by quantity.", |
| "Amount of pure substance = quantity × concentration.", |
| "Set pure substance before mixing equal to pure substance in the final mix.", |
| ], |
| steps=[ |
| "Write the pure-content contribution from each ingredient.", |
| "Write the final total quantity and final target concentration.", |
| "Set up one concentration equation in the unknown amount.", |
| "Solve for the unknown quantity.", |
| ], |
| ) |
|
|
|
|
| def _solve_mixture_resulting_concentration(text: str) -> Optional[SolverResult]: |
| """ |
| Example: |
| - 10 liters of 20% solution are mixed with 30 liters of 50% solution. |
| What is the resulting concentration? |
| """ |
| if not _is_mixture_language(text): |
| return None |
|
|
| pairs = _parse_percent_number_pairs(text) |
| if len(pairs) < 2: |
| return None |
|
|
| (a1, p1), (a2, p2) = pairs[0], pairs[1] |
| result = _weighted_average(a1, p1, a2, p2) |
|
|
| return _result( |
| topic="mixture_weighted_average", |
| answer=result * 100.0, |
| method="Resulting concentration is a weighted average of the two concentrations, weighted by quantity.", |
| what_is_asked="Find the final concentration after two solutions are mixed.", |
| hints=[ |
| "Pure substance from each part is quantity × concentration.", |
| "Add the pure amounts.", |
| "Divide by the total quantity, then convert back to percent if needed.", |
| ], |
| steps=[ |
| "Compute the pure-content amount in each starting solution.", |
| "Add those pure-content amounts.", |
| "Divide by the total mixture quantity.", |
| ], |
| ) |
|
|
|
|
| def _solve_alligation_ratio(text: str) -> Optional[SolverResult]: |
| """ |
| Example: |
| - In what ratio should 20% and 50% solutions be mixed to get 30%? |
| """ |
| if not _is_mixture_language(text): |
| return None |
| if not _has_any(text, ["ratio", "in what ratio", "what ratio", "proportion"]): |
| return None |
|
|
| pcts = _extract_percent_values(text) |
| if len(pcts) < 3: |
| return None |
|
|
| p1, p2, target = pcts[0], pcts[1], pcts[2] |
|
|
| if abs(p1 - target) < 1e-12 or abs(p2 - target) < 1e-12 or abs(p1 - p2) < 1e-12: |
| return None |
|
|
| |
| |
| a = p2 - target |
| b = target - p1 |
|
|
| if a <= 0 or b <= 0: |
| |
| a = target - p2 |
| b = p1 - target |
| if a <= 0 or b <= 0: |
| return None |
|
|
| ratio_value = a / b |
|
|
| return _result( |
| topic="mixture_weighted_average", |
| answer=ratio_value, |
| method="Use the alligation / balance idea: each quantity is weighted by its distance from the target concentration.", |
| what_is_asked="Find the mixing ratio that will produce the target concentration.", |
| hints=[ |
| "The higher concentration must be paired against the gap from the target to the lower concentration.", |
| "The lower concentration must be paired against the gap from the higher concentration to the target.", |
| "That gives the ratio of quantities.", |
| ], |
| steps=[ |
| "Find each concentration’s distance from the target concentration.", |
| "Cross-pair the distances.", |
| "Use those paired distances as the mixing ratio.", |
| ], |
| ) |
|
|
|
|
| def _solve_dilution_add_water(text: str) -> Optional[SolverResult]: |
| """ |
| Example: |
| - 40 liters of 25% solution. How much water must be added to make it 10%? |
| """ |
| if not _is_mixture_language(text): |
| return None |
| if not _has_any(text, ["water", "dilute", "dilution", "added", "add"]): |
| return None |
|
|
| |
| pairs = _parse_percent_number_pairs(text) |
| pcts = _extract_percent_values(text) |
| if len(pairs) < 1 or len(pcts) < 2: |
| return None |
|
|
| start_amount, start_pct = pairs[0] |
|
|
| target = None |
| for p in pcts: |
| if abs(p - start_pct) > 1e-9: |
| target = p |
| break |
| if target is None or target >= start_pct: |
| return None |
|
|
| pure_amount = start_amount * start_pct |
| final_total = pure_amount / target |
| water_to_add = final_total - start_amount |
| if water_to_add < 0: |
| return None |
|
|
| return _result( |
| topic="mixture_weighted_average", |
| answer=water_to_add, |
| method="When only water is added, the amount of solute stays constant while the total volume increases.", |
| what_is_asked="Find how much pure diluting liquid must be added to reduce the concentration to the target level.", |
| hints=[ |
| "Pure solute amount does not change during dilution.", |
| "Use: pure solute = final concentration × final total amount.", |
| "Then compare final total with starting amount.", |
| ], |
| steps=[ |
| "Compute the initial pure-solute amount.", |
| "Set that equal to target concentration × final total.", |
| "Solve for the final total amount.", |
| "Subtract the starting amount to get the amount added.", |
| ], |
| ) |
|
|
|
|
| def _solve_add_pure_component(text: str) -> Optional[SolverResult]: |
| """ |
| Example: |
| - 50 liters of 20% acid solution. How much pure acid must be added to make it 35%? |
| """ |
| if not _is_mixture_language(text): |
| return None |
| if not _has_any(text, ["pure", "add", "added"]): |
| return None |
|
|
| pairs = _parse_percent_number_pairs(text) |
| pcts = _extract_percent_values(text) |
| if len(pairs) < 1 or len(pcts) < 2: |
| return None |
|
|
| start_amount, start_pct = pairs[0] |
|
|
| target = None |
| for p in pcts: |
| if abs(p - start_pct) > 1e-9: |
| target = p |
| break |
| if target is None: |
| return None |
|
|
| if target <= start_pct or target >= 1: |
| return None |
|
|
| |
| |
| denom = 1 - target |
| if abs(denom) < 1e-12: |
| return None |
|
|
| x = start_amount * (target - start_pct) / denom |
| if x < 0: |
| return None |
|
|
| return _result( |
| topic="mixture_weighted_average", |
| answer=x, |
| method="When pure solute is added, both the pure-solute amount and the total amount increase.", |
| what_is_asked="Find how much pure active ingredient must be added to raise the concentration to the target level.", |
| hints=[ |
| "Initial pure amount = starting quantity × starting concentration.", |
| "Pure additive contributes its full amount to the solute total.", |
| "Set final pure amount equal to target concentration × final total amount.", |
| ], |
| steps=[ |
| "Write the starting pure-solute amount.", |
| "Add the unknown pure amount to get final solute.", |
| "Write the final total quantity.", |
| "Set up the target-concentration equation and solve.", |
| ], |
| ) |
|
|
|
|
| def _solve_remove_and_replace(text: str) -> Optional[SolverResult]: |
| """ |
| Example: |
| - A container has 40 liters of 30% salt solution. |
| x liters are removed and replaced with water. |
| Final concentration becomes 24%. |
| """ |
| if not _is_mixture_language(text): |
| return None |
| if not _has_any(text, ["removed", "remove", "replaced", "replace"]): |
| return None |
|
|
| pairs = _parse_percent_number_pairs(text) |
| pcts = _extract_percent_values(text) |
| if len(pairs) < 1 or len(pcts) < 2: |
| return None |
|
|
| total, start_pct = pairs[0] |
|
|
| target = None |
| for p in pcts: |
| if abs(p - start_pct) > 1e-9: |
| target = p |
| break |
| if target is None or target >= start_pct: |
| return None |
|
|
| |
| |
| |
| |
| |
| x = total - (total * target / start_pct) |
| if x < 0 or x > total: |
| return None |
|
|
| return _result( |
| topic="mixture_weighted_average", |
| answer=x, |
| method="Removing some mixture removes solute and solvent in the same proportion; replacing with water restores total volume but not lost solute.", |
| what_is_asked="Find how much mixture must be removed and replaced with pure diluting liquid to reach the target concentration.", |
| hints=[ |
| "The removed portion has the same concentration as the original mixture.", |
| "After replacement with water, total volume returns to the original amount.", |
| "Track the solute amount after removal, not just the concentration.", |
| ], |
| steps=[ |
| "Write the initial amount of solute.", |
| "Express the solute remaining after removing x units of the original mixture.", |
| "Use the target concentration and original total volume to write the final solute amount.", |
| "Set those equal and solve for x.", |
| ], |
| ) |
|
|
|
|
| def _solve_price_weighted_average(text: str) -> Optional[SolverResult]: |
| """ |
| Covers weighted-average phrasing disguised as cost per unit / average price. |
| """ |
| if not _has_any(text, ["average price", "average cost", "per pound", "per unit", "per kg", "per kilogram", "per item"]): |
| return None |
|
|
| nums = _nums(text) |
| if len(nums) < 4: |
| return None |
|
|
| |
| |
| pairs: List[Tuple[float, float]] = [] |
| for m in re.finditer(rf"({NUMBER})\s*(?:units?|items?|pounds?|lb|kg|kilograms?|grams?|g)?\s*(?:at|costing|priced at)\s*\$?\s*({NUMBER})", text): |
| q = _safe_float(m.group(1)) |
| p = _safe_float(m.group(2)) |
| if q is not None and p is not None: |
| pairs.append((q, p)) |
|
|
| if len(pairs) >= 2: |
| (q1, c1), (q2, c2) = pairs[0], pairs[1] |
| avg_cost = _weighted_average(q1, c1, q2, c2) |
| return _result( |
| topic="weighted_average", |
| answer=avg_cost, |
| method="Average price per unit is a weighted average of the unit prices, weighted by quantity bought.", |
| what_is_asked="Find the combined cost per unit after quantities bought at different rates are combined.", |
| hints=[ |
| "Total cost = quantity × unit price for each part.", |
| "Add the total costs.", |
| "Divide by the total quantity.", |
| ], |
| steps=[ |
| "Compute the cost contribution of each purchase block.", |
| "Add those costs.", |
| "Divide by the total quantity purchased.", |
| ], |
| ) |
|
|
| return None |
|
|
|
|
| def solve_mixture_weighted_average(text: str) -> Optional[SolverResult]: |
| """ |
| Unified solver for: |
| - weighted averages |
| - combined averages |
| - subgroup / remaining-group averages |
| - add/remove/replace average changes |
| - mixture / concentration / solution / alloy problems |
| - target concentration |
| - result concentration |
| - dilution and remove-replace patterns |
| - average cost / per-unit weighted average patterns |
| """ |
| if not text or not text.strip(): |
| return None |
|
|
| lower = _clean(text) |
|
|
| |
| if not (_is_average_language(lower) or _is_mixture_language(lower) or _has_any(lower, ["per pound", "per unit", "average cost", "average price"])): |
| return None |
|
|
| |
| solvers = [ |
| _solve_remove_and_replace, |
| _solve_dilution_add_water, |
| _solve_add_pure_component, |
| _solve_two_solution_target_mix, |
| _solve_alligation_ratio, |
| _solve_mixture_resulting_concentration, |
| _solve_remaining_group_average, |
| _solve_replaced_value_average, |
| _solve_added_value_average, |
| _solve_removed_value_average, |
| _solve_two_group_combined_average, |
| _solve_price_weighted_average, |
| ] |
|
|
| for solver in solvers: |
| try: |
| result = solver(lower) |
| if result is not None: |
| return result |
| except Exception: |
| continue |
|
|
| return None |
|
|
|
|
| def solve_weighted_average_and_mixture(text: str) -> Optional[SolverResult]: |
| return solve_mixture_weighted_average(text) |