GameAI / solver_weighted_average_and_mixture.py
j-js's picture
Rename solver_weighted_average.py to solver_weighted_average_and_mixture.py
a97fec0 verified
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:
# Keeps solving internal. Explanatory steps never reveal the computed value.
# answer_value/internal_answer are still populated for backend use.
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
# For first pattern, first number is amount, second is percent.
# For second pattern, first is percent, second is amount.
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
# Prefer a count explicitly tied to "total/all/overall"
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:
# fallback: largest number in text often represents total count
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:
# fallback: "one more number, 25, is added"
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:
# fallback: last percent often target
target = pcts[-1]
pairs = _parse_percent_number_pairs(text)
if len(pairs) < 1:
return None
known_amount, known_pct = pairs[0]
# Need one other percent distinct from target and known_pct
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
# Ratio of amount at p1 : amount at p2
# = (p2 - target) : (target - p1)
a = p2 - target
b = target - p1
if a <= 0 or b <= 0:
# maybe order reversed
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
# Detect a starting amount and starting %
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
# Add pure component (100%)
# start_amount*start_pct + x = target*(start_amount + x)
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
# Remove x liters of the mixture, then replace with water.
# Solute left after removal = total*start_pct * (1 - x/total) = start_pct*(total-x)
# Final total returns to total, target solute = total*target
# => start_pct*(total-x) = total*target
# => x = total - total*target/start_pct
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
# Heuristic: often amount1, price1, amount2, price2
# Safer approach: if pairs near "at" exist, parse them.
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)
# Broad recognition gate.
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
# Order matters: more specific before more general.
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)