Skip to content

Instantly share code, notes, and snippets.

@basperheim
Last active May 12, 2026 16:51
Show Gist options
  • Select an option

  • Save basperheim/64264f9c3e1298aae4e23f41c000e8a0 to your computer and use it in GitHub Desktop.

Select an option

Save basperheim/64264f9c3e1298aae4e23f41c000e8a0 to your computer and use it in GitHub Desktop.
Pre-Calculus Tutoring Python Script
#!/usr/bin/env python3
from __future__ import annotations
import random
import sys
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
GREEN = "\033[92m"
RED = "\033[91m"
CYAN = "\033[96m"
YELLOW = "\033[93m"
MAGENTA = "\033[95m"
RESET = "\033[0m"
DECIMAL_PLACES = Decimal("0.01")
@dataclass(frozen=True)
class Problem:
topic: str
prompt: str
expected_answer: Decimal
hints: list[str]
explanation: str
@dataclass
class SessionStats:
attempted: int = 0
solved_without_hints: int = 0
solved_with_hints: int = 0
revealed: int = 0
skipped: int = 0
def quantize_2(value: Decimal) -> Decimal:
return value.quantize(DECIMAL_PLACES, rounding=ROUND_HALF_UP)
def parse_decimal_input(raw: str) -> Decimal:
return quantize_2(Decimal(raw.strip()))
def decimal_str(value: Decimal) -> str:
value = quantize_2(value)
if value == value.to_integral():
return str(value.to_integral())
return format(value, "f").rstrip("0").rstrip(".")
def difficulty_from_argv(argv: list[str]) -> int:
if len(argv) < 2:
return 3
try:
value = int(argv[1])
except ValueError:
print(f"{YELLOW}Invalid difficulty. Using default difficulty 3.{RESET}")
return 3
return max(1, min(5, value))
def choose_topic() -> str:
print(f"{CYAN}Choose a topic:{RESET}")
print(" 1) Solving linear equations")
print(" 2) Function evaluation")
print(" 3) Function composition")
print(" 4) Mixed")
print()
while True:
choice = input("Enter 1, 2, 3, or 4: ").strip()
if choice == "1":
return "linear_equations"
if choice == "2":
return "function_evaluation"
if choice == "3":
return "function_composition"
if choice == "4":
return "mixed"
print(f"{YELLOW}Please enter 1, 2, 3, or 4.{RESET}")
def choose_problem_count() -> int:
while True:
raw = input("How many problems do you want this session? [default: 5] ").strip()
if raw == "":
return 5
try:
value = int(raw)
except ValueError:
print(f"{YELLOW}Please enter a whole number.{RESET}")
continue
if value < 1:
print(f"{YELLOW}Please enter at least 1.{RESET}")
continue
return value
def intro_text() -> None:
print(f"{MAGENTA}Algebra & Precalculus Foundations Tutor v2{RESET}")
print("This is a learning tool, not a speed test.")
print("You can answer normally, or type:")
print(" hint -> show the next hint")
print(" explain -> show the full worked solution")
print(" skip -> skip the problem")
print(" quit -> end the session")
print()
def random_nonzero_int(min_value: int, max_value: int) -> int:
while True:
value = random.randint(min_value, max_value)
if value != 0:
return value
def maybe_half_step(difficulty: int, min_value: int, max_value: int) -> Decimal:
if difficulty < 5:
return Decimal(random.randint(min_value, max_value))
whole_part = random.randint(min_value, max_value)
value = Decimal(str(random.choice([whole_part, whole_part + 0.5])))
return quantize_2(value)
def format_signed_number(value: int | Decimal) -> str:
number = Decimal(str(value))
if number >= 0:
return f"+ {decimal_str(number)}"
return f"- {decimal_str(abs(number))}"
def format_linear_expression(a: int | Decimal, b: int | Decimal) -> str:
a_dec = Decimal(str(a))
b_dec = Decimal(str(b))
if a_dec == 1:
first = "x"
elif a_dec == -1:
first = "-x"
else:
first = f"{decimal_str(a_dec)}x"
if b_dec == 0:
return first
return f"{first} {format_signed_number(b_dec)}"
def format_quadratic_term(a: int | Decimal) -> str:
a_dec = Decimal(str(a))
if a_dec == 1:
return "x^2"
if a_dec == -1:
return "-x^2"
return f"{decimal_str(a_dec)}x^2"
def topic_for_round(selected_topic: str) -> str:
if selected_topic != "mixed":
return selected_topic
return random.choice([
"linear_equations",
"function_evaluation",
"function_composition",
])
def build_linear_equation_problem(difficulty: int) -> Problem:
if difficulty == 1:
a = random_nonzero_int(1, 5)
x_value = Decimal(random.randint(-5, 10))
b = random.randint(-10, 10)
elif difficulty == 2:
a = random_nonzero_int(-6, 6)
x_value = Decimal(random.randint(-8, 12))
b = random.randint(-12, 12)
elif difficulty == 3:
a = random_nonzero_int(-9, 9)
x_value = Decimal(random.randint(-12, 15))
b = random.randint(-20, 20)
elif difficulty == 4:
a = random_nonzero_int(-12, 12)
x_value = Decimal(random.randint(-15, 20))
b = random.randint(-25, 25)
else:
a = random_nonzero_int(-12, 12)
x_value = maybe_half_step(difficulty, -10, 15)
b = random.randint(-25, 25)
a_dec = Decimal(str(a))
b_dec = Decimal(str(b))
c_dec = quantize_2(a_dec * x_value + b_dec)
left_side = format_linear_expression(a, b)
prompt = (
"Solve for x:\n"
f" {left_side} = {decimal_str(c_dec)}\n"
"Enter only the value of x."
)
isolated_right = quantize_2(c_dec - b_dec)
hints = [
(
"Hint 1:\n"
"Your goal is to isolate x.\n"
"First undo the constant term attached to the x-term."
),
(
"Hint 2:\n"
f"Start with {left_side} = {decimal_str(c_dec)}\n"
f"Undo {b:+d} by doing the opposite operation on both sides.\n"
f"That gives: {decimal_str(a_dec)}x = {decimal_str(isolated_right)}"
),
(
"Hint 3:\n"
f"Now divide both sides by {decimal_str(a_dec)}.\n"
f"So x = {decimal_str(quantize_2(isolated_right / a_dec))}"
),
]
explanation = (
"Worked solution:\n"
f"1) Start with {left_side} = {decimal_str(c_dec)}\n"
f"2) Undo the constant term: {decimal_str(a_dec)}x = {decimal_str(isolated_right)}\n"
f"3) Divide both sides by {decimal_str(a_dec)}\n"
f"4) x = {decimal_str(x_value)}"
)
return Problem(
topic="Solving linear equations",
prompt=prompt,
expected_answer=quantize_2(x_value),
hints=hints,
explanation=explanation,
)
def build_function_evaluation_problem(difficulty: int) -> Problem:
function_name = random.choice(["f", "g", "h"])
if difficulty <= 2:
form = random.choice(["linear", "square"])
elif difficulty == 3:
form = random.choice(["linear", "square", "two_term"])
else:
form = random.choice(["linear", "square", "two_term", "shifted_square"])
if difficulty < 5:
x_value = Decimal(random.randint(-6, 8))
else:
x_value = maybe_half_step(difficulty, -6, 8)
if form == "linear":
a = random_nonzero_int(-6, 6)
b = random.randint(-10, 10)
a_dec = Decimal(str(a))
b_dec = Decimal(str(b))
expr = format_linear_expression(a, b)
answer = quantize_2(a_dec * x_value + b_dec)
prompt = (
"Evaluate the function:\n"
f" {function_name}(x) = {expr}\n"
f"Find {function_name}({decimal_str(x_value)})\n"
"Enter only the final number."
)
hints = [
(
"Hint 1:\n"
f"Substitute {decimal_str(x_value)} everywhere you see x.\n"
"Treat the entire input as the value of x."
),
(
"Hint 2:\n"
f"After substitution, you get:\n"
f" {decimal_str(a_dec)}({decimal_str(x_value)}) "
f"{format_signed_number(b_dec)}"
),
(
"Hint 3:\n"
f"Multiply first: {decimal_str(a_dec)} × {decimal_str(x_value)}"
f" = {decimal_str(quantize_2(a_dec * x_value))}\n"
"Then finish the addition or subtraction."
),
]
explanation = (
"Worked solution:\n"
f"1) Substitute {decimal_str(x_value)} for x\n"
f"2) {function_name}({decimal_str(x_value)}) = {decimal_str(a_dec)}({decimal_str(x_value)}) "
f"{format_signed_number(b_dec)}\n"
f"3) Multiply: {decimal_str(a_dec)}({decimal_str(x_value)}) = "
f"{decimal_str(quantize_2(a_dec * x_value))}\n"
f"4) Finish the arithmetic\n"
f"5) {function_name}({decimal_str(x_value)}) = {decimal_str(answer)}"
)
elif form == "square":
a = random_nonzero_int(-4, 4)
b = random.randint(-10, 10)
a_dec = Decimal(str(a))
b_dec = Decimal(str(b))
squared = quantize_2(x_value * x_value)
answer = quantize_2(a_dec * squared + b_dec)
expr = format_quadratic_term(a)
if b != 0:
expr = f"{expr} {format_signed_number(b_dec)}"
prompt = (
"Evaluate the function:\n"
f" {function_name}(x) = {expr}\n"
f"Find {function_name}({decimal_str(x_value)})\n"
"Enter only the final number."
)
hints = [
(
"Hint 1:\n"
"Substitute carefully, especially if the input is negative.\n"
"Square the input before applying the outer coefficient."
),
(
"Hint 2:\n"
f"First compute ({decimal_str(x_value)})^2 = {decimal_str(squared)}"
),
(
"Hint 3:\n"
f"Now multiply by {decimal_str(a_dec)} and then apply "
f"{format_signed_number(b_dec)}."
),
]
explanation = (
"Worked solution:\n"
f"1) Substitute {decimal_str(x_value)} for x\n"
f"2) Compute the square: ({decimal_str(x_value)})^2 = {decimal_str(squared)}\n"
f"3) Multiply by {decimal_str(a_dec)} to get {decimal_str(quantize_2(a_dec * squared))}\n"
f"4) Apply the constant term\n"
f"5) {function_name}({decimal_str(x_value)}) = {decimal_str(answer)}"
)
elif form == "two_term":
a = random_nonzero_int(-4, 4)
b = random_nonzero_int(-6, 6)
a_dec = Decimal(str(a))
b_dec = Decimal(str(b))
squared = quantize_2(x_value * x_value)
first_term = quantize_2(a_dec * squared)
second_term = quantize_2(b_dec * x_value)
answer = quantize_2(first_term + second_term)
expr = f"{format_quadratic_term(a)} {format_signed_number(b_dec)}x"
prompt = (
"Evaluate the function:\n"
f" {function_name}(x) = {expr}\n"
f"Find {function_name}({decimal_str(x_value)})\n"
"Enter only the final number."
)
hints = [
(
"Hint 1:\n"
"Substitute the input for x in both terms.\n"
"Then compute each term separately before combining them."
),
(
"Hint 2:\n"
f"First term: {decimal_str(a_dec)}({decimal_str(x_value)})^2\n"
f"Second term: {decimal_str(b_dec)}({decimal_str(x_value)})"
),
(
"Hint 3:\n"
f"({decimal_str(x_value)})^2 = {decimal_str(squared)}\n"
f"First term = {decimal_str(first_term)}\n"
f"Second term = {decimal_str(second_term)}"
),
]
explanation = (
"Worked solution:\n"
f"1) Substitute {decimal_str(x_value)} for x in both terms\n"
f"2) Compute x^2: ({decimal_str(x_value)})^2 = {decimal_str(squared)}\n"
f"3) First term = {decimal_str(first_term)}\n"
f"4) Second term = {decimal_str(second_term)}\n"
f"5) Add them together\n"
f"6) {function_name}({decimal_str(x_value)}) = {decimal_str(answer)}"
)
else:
shift = random_nonzero_int(-6, 6)
shift_dec = Decimal(str(shift))
inside = quantize_2(x_value + shift_dec)
answer = quantize_2(inside * inside)
if shift > 0:
expr = f"(x + {shift})^2"
else:
expr = f"(x - {abs(shift)})^2"
prompt = (
"Evaluate the function:\n"
f" {function_name}(x) = {expr}\n"
f"Find {function_name}({decimal_str(x_value)})\n"
"Enter only the final number."
)
hints = [
(
"Hint 1:\n"
"Do the parentheses first.\n"
"This is not the same thing as x^2 plus or minus something."
),
(
"Hint 2:\n"
f"Inside the parentheses:\n"
f" {decimal_str(x_value)} {format_signed_number(shift_dec)} = {decimal_str(inside)}"
),
(
"Hint 3:\n"
f"Now square the result:\n"
f" ({decimal_str(inside)})^2"
),
]
explanation = (
"Worked solution:\n"
f"1) Evaluate the parentheses first\n"
f"2) {decimal_str(x_value)} {format_signed_number(shift_dec)} = {decimal_str(inside)}\n"
f"3) Square that result\n"
f"4) {function_name}({decimal_str(x_value)}) = {decimal_str(answer)}"
)
return Problem(
topic="Function evaluation",
prompt=prompt,
expected_answer=answer,
hints=hints,
explanation=explanation,
)
def build_function_composition_problem(difficulty: int) -> Problem:
f_name, g_name = random.sample(["f", "g", "h"], 2)
x_value = Decimal(random.randint(-5, 6))
f_a = random_nonzero_int(-5, 5)
f_b = random.randint(-8, 8)
g_a = random_nonzero_int(-5, 5)
g_b = random.randint(-8, 8)
f_a_dec = Decimal(str(f_a))
f_b_dec = Decimal(str(f_b))
g_a_dec = Decimal(str(g_a))
g_b_dec = Decimal(str(g_b))
f_expr = format_linear_expression(f_a, f_b)
g_expr = format_linear_expression(g_a, g_b)
if difficulty <= 2:
outer_name = f_name
inner_name = g_name
outer_a = f_a_dec
outer_b = f_b_dec
inner_a = g_a_dec
inner_b = g_b_dec
outer_expr = f_expr
inner_expr = g_expr
else:
# Sometimes swap order to keep it from feeling too canned.
if random.random() < 0.5:
outer_name = f_name
inner_name = g_name
outer_a = f_a_dec
outer_b = f_b_dec
inner_a = g_a_dec
inner_b = g_b_dec
outer_expr = f_expr
inner_expr = g_expr
else:
outer_name = g_name
inner_name = f_name
outer_a = g_a_dec
outer_b = g_b_dec
inner_a = f_a_dec
inner_b = f_b_dec
outer_expr = g_expr
inner_expr = f_expr
inner_value = quantize_2(inner_a * x_value + inner_b)
answer = quantize_2(outer_a * inner_value + outer_b)
prompt = (
"Evaluate the composition:\n"
f" {f_name}(x) = {f_expr}\n"
f" {g_name}(x) = {g_expr}\n"
f"Find {outer_name}({inner_name}({decimal_str(x_value)}))\n"
"Enter only the final number."
)
hints = [
(
"Hint 1:\n"
"Work from the inside out.\n"
f"First compute {inner_name}({decimal_str(x_value)}), then plug that result into {outer_name}."
),
(
"Hint 2:\n"
f"{inner_name}(x) = {inner_expr}\n"
f"So start with {inner_name}({decimal_str(x_value)})."
),
(
"Hint 3:\n"
f"{inner_name}({decimal_str(x_value)}) = {decimal_str(inner_value)}\n"
f"Now compute {outer_name}({decimal_str(inner_value)}) using {outer_name}(x) = {outer_expr}."
),
]
explanation = (
"Worked solution:\n"
f"1) Compute the inside function first:\n"
f" {inner_name}({decimal_str(x_value)}) = {decimal_str(inner_value)}\n"
f"2) Now plug that into the outside function:\n"
f" {outer_name}({decimal_str(inner_value)})\n"
f"3) Evaluate it\n"
f"4) {outer_name}({inner_name}({decimal_str(x_value)})) = {decimal_str(answer)}"
)
return Problem(
topic="Function composition",
prompt=prompt,
expected_answer=answer,
hints=hints,
explanation=explanation,
)
def generate_problem(selected_topic: str, difficulty: int) -> Problem:
actual_topic = topic_for_round(selected_topic)
if actual_topic == "linear_equations":
return build_linear_equation_problem(difficulty)
if actual_topic == "function_evaluation":
return build_function_evaluation_problem(difficulty)
return build_function_composition_problem(difficulty)
def run_problem(problem: Problem, stats: SessionStats) -> bool:
stats.attempted += 1
shown_hint_count = 0
used_hint = False
print(f"{CYAN}{problem.topic}{RESET}")
print(problem.prompt)
print()
while True:
raw = input("> ").strip()
if raw.lower() == "quit":
return False
if raw.lower() == "skip":
stats.skipped += 1
print(f"{YELLOW}Skipped.{RESET}")
print(f"The correct answer was {decimal_str(problem.expected_answer)}.")
print(problem.explanation)
print()
return True
if raw.lower() == "hint":
if shown_hint_count < len(problem.hints):
print(problem.hints[shown_hint_count])
shown_hint_count += 1
used_hint = True
else:
print(f"{YELLOW}No more hints left for this problem.{RESET}")
print()
continue
if raw.lower() == "explain":
stats.revealed += 1
print(problem.explanation)
print(f"The correct answer was {decimal_str(problem.expected_answer)}.")
print()
return True
try:
user_answer = parse_decimal_input(raw)
except InvalidOperation:
print(
f"{YELLOW}Please enter a valid number, or type hint / explain / skip / quit.{RESET}"
)
continue
if user_answer == problem.expected_answer:
if used_hint:
stats.solved_with_hints += 1
print(f"{GREEN}Correct.{RESET} You got it with help.")
else:
stats.solved_without_hints += 1
print(f"{GREEN}Correct!{RESET}")
print(problem.explanation)
print()
return True
print(f"{RED}Not quite.{RESET}")
if shown_hint_count == 0:
print("Type 'hint' for guidance, or try again.")
else:
print("Use the hint and try the next step carefully.")
print()
def print_summary(stats: SessionStats) -> None:
solved_total = stats.solved_without_hints + stats.solved_with_hints
denominator = stats.attempted if stats.attempted > 0 else 1
print(f"{MAGENTA}Session Summary{RESET}")
print(f"Problems attempted: {stats.attempted}")
print(f"Solved without hints: {stats.solved_without_hints}")
print(f"Solved with hints: {stats.solved_with_hints}")
print(f"Revealed explanations: {stats.revealed}")
print(f"Skipped: {stats.skipped}")
print(f"Total solved: {solved_total}/{denominator}")
def main() -> None:
random.seed()
difficulty = difficulty_from_argv(sys.argv)
intro_text()
print(f"Difficulty: {difficulty} (1 = easier, 5 = harder)")
topic = choose_topic()
count = choose_problem_count()
print()
stats = SessionStats()
for index in range(1, count + 1):
print(f"{MAGENTA}Problem {index}/{count}{RESET}")
problem = generate_problem(topic, difficulty)
should_continue = run_problem(problem, stats)
if not should_continue:
break
print_summary(stats)
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment