Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fraction Reduction rule #24

Open
wants to merge 17 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,407 changes: 2 additions & 1,405 deletions README.md

Large diffs are not rendered by default.

68 changes: 55 additions & 13 deletions mathy_core/expressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -576,28 +576,34 @@ def operate(self, one: NumberType, two: NumberType) -> NumberType:
return one * two

def __str__(self) -> str:
"""Multiplication special cases constant*variable to output `4x` instead of
`4 * x`"""
"""Special cases:
1. constant*variable -> 4x
2. fraction*variable -> 1/2x
3. constant*variable^power -> 4x^2
4. fraction*variable^power -> (1/2)x^2
"""
left, right = self._check()

# Handle fraction * variable or fraction * power cases
if isinstance(left, DivideExpression):
# Handle both direct variables and variables raised to a power
if isinstance(right, VariableExpression):
return self.with_color(f"({left}){right}")
elif isinstance(right, PowerExpression) and isinstance(
right.left, VariableExpression
):
return self.with_color(f"({left}){right}")

# Handle existing constant * variable cases
if isinstance(left, ConstantExpression):
# const * var
one = isinstance(right, VariableExpression)
# const * var^power
two = isinstance(right, PowerExpression) and isinstance(
right.left, VariableExpression
)
if one or two:
return self.with_color(f"{left}{right}")
return super().__str__()

def to_math_ml_fragment(self) -> str:
left, right = self._check()
right_ml = right.to_math_ml_fragment()
left_ml = left.to_math_ml_fragment()
if isinstance(left, ConstantExpression):
if isinstance(right, (VariableExpression, PowerExpression)):
return f"{left_ml}{right_ml}"
return super().to_math_ml_fragment()
return super().__str__()


class DivideExpression(BinaryExpression):
Expand Down Expand Up @@ -626,6 +632,42 @@ def operate(self, one: NumberType, two: NumberType) -> NumberType:
else:
return one / two

def __str__(self) -> str:
left, right = self._check()

# Check if we're being used as a coefficient in multiplication with a variable
is_coefficient = (
isinstance(self.parent, MultiplyExpression)
and self.parent.left is self # We're the left side of the multiplication
and isinstance(self.parent.right, (VariableExpression, PowerExpression))
)

def needs_parens(expr: MathExpression) -> bool:
if isinstance(expr, MultiplyExpression):
# Only need parens for multiplication if:
# 1. It involves a power term (like 3x^2)
# 2. It's a complex multiplication (more than coefficient * variable)
return any(
isinstance(child, PowerExpression)
for child in (expr.left, expr.right)
) or not (
isinstance(expr.left, ConstantExpression)
and isinstance(expr.right, VariableExpression)
)
return False

left_str = f"({left})" if needs_parens(left) else str(left)
right_str = f"({right})" if needs_parens(right) else str(right)

if is_coefficient:
# No spaces when coefficient
out = f"{left_str}{self.with_color(self.name)}{right_str}"
else:
# Keep spaces normally
out = f"{left_str} {self.with_color(self.name)} {right_str}"

return f"({out})" if self.self_parens() else out


class PowerExpression(BinaryExpression):
"""Raise one to the power of two"""
Expand Down
113 changes: 112 additions & 1 deletion mathy_core/layout.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from typing import Optional
from typing import Any, Optional

from .expressions import BinaryExpression, MathExpression
from .tree import BinaryTreeNode


Expand Down Expand Up @@ -239,4 +240,114 @@ def transform(
return measure


def render_tree_to_text(expression: MathExpression) -> str:
"""Render a tree structure as ASCII text for terminal display.

Args:
input_text: The math expression text to parse and render

Returns:
A string containing the ASCII representation of the tree
"""
layout = TreeLayout()
measure: TreeMeasurement = layout.layout(expression, 6, 2) # type: ignore

# Calculate canvas dimensions
padding = 4
min_x = int(measure.minX - padding)
max_x = int(measure.maxX + padding)
min_y = int(measure.minY - padding)
max_y = int(measure.maxY + padding)
width = max_x - min_x + 1
height = max_y - min_y + 1

# Create empty canvas
canvas = [[" " for _ in range(width)] for _ in range(height)]

def draw_line(x1: int, y1: int, x2: int, y2: int) -> None:
"""Draw a line between two points using ASCII characters."""
# Adjust coordinates to canvas space
x1 = int(x1 - min_x)
x2 = int(x2 - min_x)
y1 = int(y1 - min_y)
y2 = int(y2 - min_y)

if x1 == x2: # Vertical line
for y in range(min(y1, y2), max(y1, y2) + 1):
canvas[y][x1] = "│"
elif y1 == y2: # Horizontal line
for x in range(min(x1, x2), max(x1, x2) + 1):
canvas[y1][x] = "─"
else: # Diagonal lines
# Calculate middle point for drawing corners
mid_x = (x1 + x2) // 2
mid_y = (y1 + y2) // 2

# Draw the diagonal connection
if abs(x2 - x1) > 1: # Only if there's enough space
if y1 < y2:
canvas[mid_y][mid_x] = "╱" if x1 > x2 else "╲"
else:
canvas[mid_y][mid_x] = "╲" if x1 > x2 else "╱"

def node_visit(node: MathExpression, depth: int, data: Any) -> None:
"""Visit each node and draw it on the canvas."""
# Adjust coordinates to canvas space
x = int(node.x - min_x) # type: ignore
y = int(node.y - min_y) # type: ignore

# Draw connection to parent
if node.parent:
int(node.parent.x - min_x) # type: ignore
int(node.parent.y - min_y) # type: ignore
draw_line(node.x, node.y, node.parent.x, node.parent.y) # type: ignore

# Get node value
value = str(node)
if isinstance(node, BinaryExpression):
value = node.name

# Draw node
node_width = len(value) + 2 # Add space for brackets
start_x = x - node_width // 2

# Draw node value
if start_x >= 0 and start_x + node_width < width and y >= 0 and y < height:
for i, char in enumerate(value):
if start_x + i < width:
canvas[y][start_x + i] = char

# Visit all nodes
expression.visit_postorder(node_visit)

# Trim empty rows and columns while preserving structure
# Find bounds of actual content
min_row = 0
max_row = height - 1
min_col = 0
max_col = width - 1

# Find first and last non-empty rows
while min_row < height and all(c == " " for c in canvas[min_row]):
min_row += 1
while max_row > 0 and all(c == " " for c in canvas[max_row]):
max_row -= 1

# Find first and last non-empty columns
while min_col < width and all(row[min_col] == " " for row in canvas):
min_col += 1
while max_col > 0 and all(row[max_col] == " " for row in canvas):
max_col -= 1

# Extract the trimmed canvas
trimmed_canvas = [
# fmt: off
row[min_col:max_col + 1] for row in canvas[min_row:max_row + 1]
# fmt: on
]

# Convert canvas to string
return "\n" + "\n".join("".join(row) for row in trimmed_canvas)


__all__ = ("TidierExtreme", "TreeMeasurement", "TreeLayout")
17 changes: 9 additions & 8 deletions mathy_core/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,7 @@ def parse_factors(self) -> MathExpression:
if len(factors) == 0:
raise InvalidExpression("No factors")

# Handle power expressions for the last factor
exp: Optional[MathExpression] = None
if self.check(_IS_EXP):
opType = self.current_token.type
Expand All @@ -345,18 +346,18 @@ def parse_factors(self) -> MathExpression:
raise InvalidSyntax("Expected an expression after ^ operator")

right = self.parse_unary()
exp = PowerExpression(factors[-1], right)
# Create power expression from the last factor
factors[-1] = PowerExpression(factors[-1], right)

# Combine all factors with multiplication
if len(factors) == 1:
return exp or factors[0]
return factors[0]

while len(factors) > 0:
if exp is None:
exp = factors.pop(0)
# Build expression from left to right
exp = factors[0]
for i in range(1, len(factors)):
exp = MultiplyExpression(exp, factors[i])

exp = MultiplyExpression(exp, factors.pop(0))

assert exp is not None
return exp

def parse_function(self) -> MathExpression:
Expand Down
12 changes: 9 additions & 3 deletions mathy_core/problems.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,10 @@ def get_rand_term_templates(
)
variable = rand_var(common_variables)
exponent: Optional[NumberType] = cast(
Union[int, None], maybe_number(exponent_probability * 100, None)
Union[int, None],
maybe_power(
exponent_probability * 100, or_else=None, include_exponent=False
),
)
# Don't generate x^1
if exponent == 1:
Expand Down Expand Up @@ -127,9 +130,13 @@ def maybe_power(
percent_chance: NumberType = 80,
max_power: int = 4,
or_else: DefaultType = "", # type:ignore
include_exponent: bool = True,
) -> Union[str, DefaultType]:
if rand_bool(percent_chance):
return "^{}".format(random.randint(2, max_power))
if include_exponent:
return "^{}".format(random.randint(2, max_power))
else:
return str(random.randint(2, max_power))
else:
return or_else

Expand Down Expand Up @@ -320,7 +327,6 @@ def gen_simplify_multiple_terms(
num_terms: int,
optional_var: bool = False,
op: Optional[Union[List[str], str]] = None,
common_variables: bool = True,
inner_terms_scaling: float = 0.3,
powers_probability: float = 0.33,
optional_var_probability: float = 0.8,
Expand Down
6 changes: 6 additions & 0 deletions mathy_core/rule.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@
class BaseRule:
"""Basic rule class that visits a tree with a specified visit order."""

@property
def maintains_variables(self) -> bool:
"""Whether this rule maintains the same variables in the expression. Rules
that change variables should return False."""
return True

@property
def name(self) -> str:
"""Readable rule name used for debug rendering and description outputs"""
Expand Down
2 changes: 2 additions & 0 deletions mathy_core/rules/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from .constants_simplify import ConstantsSimplifyRule # noqa
from .distributive_factor_out import DistributiveFactorOutRule # noqa
from .distributive_multiply_across import DistributiveMultiplyRule # noqa
from .fraction_reduction import FractionReductionRule # noqa
from .multiplicative_inverse import MultiplicativeInverseRule # noqa
from .restate_subtraction import RestateSubtractionRule # noqa
from .variable_multiply import VariableMultiplyRule # noqa
Expand All @@ -15,6 +16,7 @@
"ConstantsSimplifyRule",
"DistributiveFactorOutRule",
"DistributiveMultiplyRule",
"FractionReductionRule",
"MultiplicativeInverseRule",
"RestateSubtractionRule",
"VariableMultiplyRule",
Expand Down
21 changes: 15 additions & 6 deletions mathy_core/rules/commutative_swap.test.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
{
"valid": [
{
"input": "(6/7)k^3",
"output": "k^3 * 6 / 7",
"args": {
"preferred": true
},
"why": "commuting fractional coefficient with variable raised to a power"
},
{
"input": "(5 + 12) * a",
"target": "(5 + 12) * a",
"output": "a * (5 + 12)"
},
{
"input": "2x = 6x - 8",
"target": "2x = 6x - 8",
Expand Down Expand Up @@ -76,11 +89,7 @@
"output": "2530z + 3.5x + 1m + 2z + 8.9c",
"why": "swapping middle children shouldn't introduce parenthesis nesting"
},
{
"input": "(5 + 12) * a",
"target": "(5 + 12) * a",
"output": "a * (5 + 12)"
},

{
"input": "2b^4 * 3x",
"target": "2b^4",
Expand Down Expand Up @@ -138,4 +147,4 @@
"input": "7 / x"
}
]
}
}
10 changes: 10 additions & 0 deletions mathy_core/rules/constants_simplify.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
AddExpression,
BinaryExpression,
ConstantExpression,
DivideExpression,
MathExpression,
MultiplyExpression,
NegateExpression,
Expand All @@ -25,6 +26,12 @@ class ConstantsSimplifyRule(BaseRule):
"""Given a binary operation on two constants, simplify to the resulting
constant expression"""

evaluate_fractions: bool

def __init__(self, evaluate_fractions: bool = False):
# If false, terms that are in preferred order will not commute
self.evaluate_fractions = evaluate_fractions

@property
def name(self) -> str:
return "Constant Arithmetic"
Expand Down Expand Up @@ -72,6 +79,9 @@ def get_type(
and isinstance(node.left, ConstantExpression)
and isinstance(node.right, ConstantExpression)
):
# If the parent is a division and we're not evaluating fractions, skip
if not self.evaluate_fractions and isinstance(node, DivideExpression):
return None
return _POS_SIMPLE, node.left, node.right

# Check for const * var * const
Expand Down
Loading