From 757dc93dbd2e587cf8784b28c14598776507740e Mon Sep 17 00:00:00 2001 From: Justin DuJardin Date: Tue, 6 Feb 2024 11:24:14 -0800 Subject: [PATCH 01/17] chore: stash scaffold for fraction reduction rule - GPT4 generated the test cases, now to implement the rule and verify them one-by-one --- mathy_core/rules/__init__.py | 2 + mathy_core/rules/fraction_reduction.py | 40 +++++++++++ mathy_core/rules/fraction_reduction.test.json | 67 +++++++++++++++++++ tests/test_rules.py | 9 +++ 4 files changed, 118 insertions(+) create mode 100644 mathy_core/rules/fraction_reduction.py create mode 100644 mathy_core/rules/fraction_reduction.test.json diff --git a/mathy_core/rules/__init__.py b/mathy_core/rules/__init__.py index da949db..1d9982a 100644 --- a/mathy_core/rules/__init__.py +++ b/mathy_core/rules/__init__.py @@ -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 @@ -15,6 +16,7 @@ "ConstantsSimplifyRule", "DistributiveFactorOutRule", "DistributiveMultiplyRule", + "FractionReductionRule", "MultiplicativeInverseRule", "RestateSubtractionRule", "VariableMultiplyRule", diff --git a/mathy_core/rules/fraction_reduction.py b/mathy_core/rules/fraction_reduction.py new file mode 100644 index 0000000..a59a823 --- /dev/null +++ b/mathy_core/rules/fraction_reduction.py @@ -0,0 +1,40 @@ +from typing import Optional + +from ..expressions import ConstantExpression, MathExpression +from ..rule import BaseRule, ExpressionChangeRule + + +class FractionReductionRule(BaseRule): + """Reduce fractions by cancelling out common factors in the numerator and + denominator.""" + + @property + def name(self) -> str: + return "Fraction Reduction" + + @property + def code(self) -> str: + return "FR" + + def get_type(self, node: MathExpression) -> Optional[str]: + """Determine the configuration of the tree for this transformation. + + Support different types of tree configurations based on the division operation: + - + """ + return None + + def can_apply_to(self, node: MathExpression) -> bool: + tree_type = self.get_type(node) + return tree_type is not None + + def apply_to(self, node: MathExpression) -> ExpressionChangeRule: + change = super().apply_to(node) + tree_type = self.get_type(node) + assert tree_type is not None, "call can_apply_to before applying a rule" + change.save_parent() # connect result to node.parent + + result = ConstantExpression(1337) # implement it + + result.set_changed() # mark this node as changed for visualization + return change.done(result) diff --git a/mathy_core/rules/fraction_reduction.test.json b/mathy_core/rules/fraction_reduction.test.json new file mode 100644 index 0000000..b211b0a --- /dev/null +++ b/mathy_core/rules/fraction_reduction.test.json @@ -0,0 +1,67 @@ +{ + "valid": [ + { + "why": "Simple numeric fraction", + "input": "6x^2 / 3x", + "output": "2x" + }, + { + "why": "Cancel common variable factor", + "input": "4xy / 2y", + "output": "2x" + }, + { + "why": "Cancel and reduce constants with variables", + "input": "10x^2y / 5xy", + "output": "2x" + }, + { + "why": "Complex polynomial reduction", + "input": "(x^3 - x^2) / x^2", + "output": "x - 1" + }, + { + "why": "Fraction with common complex terms", + "input": "(3x^2 + 3) / (x^2 + 1)", + "output": "3" + }, + { + "why": "Higher degree polynomial cancellation", + "input": "x^4 / x^2", + "output": "x^2" + }, + { + "why": "Fraction where only part of the numerator cancels", + "input": "(x^2 + x) / x", + "output": "x + 1" + }, + { + "why": "Fraction with a negative common factor", + "input": "-4x^2 / -2x", + "output": "2x" + }, + { + "why": "Fraction with nested expression common factors", + "input": "(2x(x + 1)) / (x + 1)", + "output": "2x" + } + ], + "invalid": [ + { + "why": "Fraction with no common factors", + "input": "(x + 1) / (y + 1)" + }, + { + "why": "Zero numerator which should not change", + "input": "0 / (x^2 + 1)" + }, + { + "why": "Zero denominator which is undefined", + "input": "(x^2 + 1) / 0" + }, + { + "why": "Fraction with non-factorizable common terms", + "input": "(x + y) / (y + x^2)" + } + ] +} diff --git a/tests/test_rules.py b/tests/test_rules.py index 14b18df..2cd4351 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -6,10 +6,12 @@ ConstantsSimplifyRule, DistributiveFactorOutRule, DistributiveMultiplyRule, + FractionReductionRule, MultiplicativeInverseRule, RestateSubtractionRule, VariableMultiplyRule, ) + from mathy_core.testing import run_rule_tests @@ -55,6 +57,13 @@ def debug(ex): run_rule_tests("restate_subtraction", RestateSubtractionRule, debug) +def test_rules_fraction_reduction(): + def debug(ex): + pass + + run_rule_tests("fraction_reduction", FractionReductionRule, debug) + + def test_rules_multiplicative_inverse(): def debug(ex): pass From c3bcc4ec7848b3d0d2218cbf748c46f2d5465f79 Mon Sep 17 00:00:00 2001 From: Justin DuJardin Date: Sun, 23 Feb 2025 10:42:45 -0800 Subject: [PATCH 02/17] chore: stash tree to text util in test suite --- tests/test_parser.py | 112 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/tests/test_parser.py b/tests/test_parser.py index f149e2e..ca64b64 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -1,6 +1,8 @@ from typing import Any import pytest +from mathy_core.expressions import BinaryExpression, MathExpression +from mathy_core.layout import TreeLayout, TreeMeasurement from mathy_core.parser import ( ExpressionParser, InvalidExpression, @@ -10,6 +12,116 @@ ) +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 + ) # Adjusted units for better spacing + + # 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, data) -> None: + """Visit each node and draw it on the canvas.""" + # Adjust coordinates to canvas space + x = int(node.x - min_x) + y = int(node.y - min_y) + + # Draw connection to parent + if node.parent: + parent_x = int(node.parent.x - min_x) + parent_y = int(node.parent.y - min_y) + draw_line(node.x, node.y, node.parent.x, node.parent.y) + + # 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 = [ + row[min_col : max_col + 1] for row in canvas[min_row : max_row + 1] + ] + + # Convert canvas to string + return "\n".join("".join(row) for row in trimmed_canvas) + + @pytest.mark.parametrize( "expectation", [ From a4333c4abf400abb5b6f8a4b2f71f88bfe635cb5 Mon Sep 17 00:00:00 2001 From: Justin DuJardin Date: Sun, 23 Feb 2025 11:36:09 -0800 Subject: [PATCH 03/17] feat: add fraction reduction rule --- README.md | 1405 +---------------- mathy_core/expressions.py | 51 +- mathy_core/problems.py | 12 +- mathy_core/rules/fraction_reduction.py | 49 +- mathy_core/rules/fraction_reduction.test.json | 39 +- mathy_core/util.py | 198 ++- tests/test_parser.py | 5 +- 7 files changed, 260 insertions(+), 1499 deletions(-) diff --git a/README.md b/README.md index 8972901..300ee7f 100644 --- a/README.md +++ b/README.md @@ -1459,7 +1459,6 @@ gen_simplify_multiple_terms( num_terms: int, optional_var: bool = False, op: Optional[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, @@ -1539,1406 +1538,4 @@ a whole range of integers and floats. Using pretty numbers will restrict the numbers that are generated to integers between 1 and 12. When not using pretty numbers, floats and large integers will be included in the output from `rand_number` -# Tokenizer class - -```python (doc) -Tokenizer(self, exclude_padding: bool = True) -``` - -The Tokenizer produces a list of tokens from an input string. - -## eat_token method - -```python (doc) -Tokenizer.eat_token( - self, - context: mathy_core.tokenizer.TokenContext, - typeFn: Callable[[str], bool], -) -> str -``` - -Eat all of the tokens of a given type from the front of the stream -until a different type is hit, and return the text. - -## identify_alphas method - -```python (doc) -Tokenizer.identify_alphas( - self, - context: mathy_core.tokenizer.TokenContext, -) -> int -``` - -Identify and tokenize functions and variables. - -## identify_constants method - -```python (doc) -Tokenizer.identify_constants( - self, - context: mathy_core.tokenizer.TokenContext, -) -> int -``` - -Identify and tokenize a constant number. - -## identify_operators method - -```python (doc) -Tokenizer.identify_operators( - self, - context: mathy_core.tokenizer.TokenContext, -) -> bool -``` - -Identify and tokenize operators. - -## is_alpha method - -```python (doc) -Tokenizer.is_alpha(self, c: str) -> bool -``` - -Is this character a letter - -## is_number method - -```python (doc) -Tokenizer.is_number(self, c: str) -> bool -``` - -Is this character a number - -## tokenize method - -```python (doc) -Tokenizer.tokenize(self, buffer: str) -> List[mathy_core.tokenizer.Token] -``` - -Return an array of `Token`s from a given string input. -This throws an exception if an unknown token type is found in the input. - -# mathy_core.parser - -## ExpressionParser class - -```python (doc) -ExpressionParser(self) -> None -``` - -Parser for converting text into binary trees. Trees encode the order of -operations for an input, and allow evaluating it to detemrine the expression -value. - -### Grammar Rules - -Symbols: - -``` -( ) == Non-terminal -{ }* == 0 or more occurrences -{ }+ == 1 or more occurrences -{ }? == 0 or 1 occurrences -[ ] == Mandatory (1 must occur) -| == logical OR -" " == Terminal symbol (literal) -``` - -Non-terminals defined/parsed by Tokenizer: - -``` -(Constant) = anything that can be parsed by `float(in)` -(Variable) = any string containing only letters (a-z and A-Z) -``` - -Rules: - -``` -(Function) = [ functionName ] "(" (AddExp) ")" -(Factor) = { (Variable) | (Function) | "(" (AddExp) ")" }+ { { "^" }? (UnaryExp) }? -(FactorPrefix) = [ (Constant) { (Factor) }? | (Factor) ] -(UnaryExp) = { "-" }? (FactorPrefix) -(ExpExp) = (UnaryExp) { { "^" }? (UnaryExp) }? -(MultExp) = (ExpExp) { { "*" | "/" }? (ExpExp) }* -(AddExp) = (MultExp) { { "+" | "-" }? (MultExp) }* -(EqualExp) = (AddExp) { { "=" }? (AddExp) }* -(start) = (EqualExp) -``` - -### check method - -```python (doc) -ExpressionParser.check( - self, - tokens: mathy_core.parser.TokenSet, - do_assert: bool = False, -) -> bool -``` - -Check if the `self.current_token` is a member of a set Token types - -Args: - `tokens` The set of Token types to check against - -`Returns` True if the `current_token`'s type is in the set else False - -### eat method - -```python (doc) -ExpressionParser.eat(self, type: int) -> bool -``` - -Assign the next token in the queue to current_token if its type -matches that of the specified parameter. If the type does not match, -raise a syntax exception. - -Args: - `type` The type that your syntax expects @current_token to be - -### next method - -```python (doc) -ExpressionParser.next(self) -> bool -``` - -Assign the next token in the queue to `self.current_token`. - -Return True if there are still more tokens in the queue, or False if there -are no more tokens to look at. - -### parse method - -```python (doc) -ExpressionParser.parse( - self, - input_text: str, -) -> mathy_core.expressions.MathExpression -``` - -Parse a string representation of an expression into a tree -that can be later evaluated. - -Returns : The evaluatable expression tree. - -## TokenSet class - -```python (doc) -TokenSet(self, source: int) -``` - -TokenSet objects are bitmask combinations for checking to see -if a token is part of a valid set. - -### add method - -```python (doc) -TokenSet.add(self, addTokens: int) -> 'TokenSet' -``` - -Add tokens to self set and return a TokenSet representing -their combination of flags. Value can be an integer or an instance -of `TokenSet` - -### contains method - -```python (doc) -TokenSet.contains(self, type: int) -> bool -``` - -Returns true if the given type is part of this set - -# mathy_core.tree - -## BinaryTreeNode class - -```python (doc) -BinaryTreeNode( - self: ~NodeType, - left: Optional[~NodeType] = None, - right: Optional[~NodeType] = None, - parent: Optional[~NodeType] = None, - id: Optional[str] = None, -) -``` - -The binary tree node is the base node for all of our trees, and provides a -rich set of methods for constructing, inspecting, and modifying them. -The node itself defines the structure of the binary tree, having left and right -children, and a parent. - -### clone method - -```python (doc) -BinaryTreeNode.clone(self: ~NodeType) -> ~NodeType -``` - -Create a clone of this tree - -### get_children method - -```python (doc) -BinaryTreeNode.get_children(self: ~NodeType) -> List[~NodeType] -``` - -Get children as an array. If there are two children, the first object will -always represent the left child, and the second will represent the right. - -### get_root method - -```python (doc) -BinaryTreeNode.get_root(self: ~NodeType) -> ~NodeType -``` - -Return the root element of this tree - -### get_root_side method - -```python (doc) -BinaryTreeNode.get_root_side(self: ~NodeType) -> Literal['left', 'right'] -``` - -Return the side of the tree that this node lives on - -### get_sibling method - -```python (doc) -BinaryTreeNode.get_sibling(self: ~NodeType) -> Optional[~NodeType] -``` - -Get the sibling node of this node. If there is no parent, or the node -has no sibling, the return value will be None. - -### get_side method - -```python (doc) -BinaryTreeNode.get_side( - self, - child: Optional[~NodeType], -) -> Literal['left', 'right'] -``` - -Determine whether the given `child` is the left or right child of this -node - -### is_leaf method - -```python (doc) -BinaryTreeNode.is_leaf(self) -> bool -``` - -Is this node a leaf? A node is a leaf if it has no children. - -### rotate method - -```python (doc) -BinaryTreeNode.rotate(self: ~NodeType) -> ~NodeType -``` - -Rotate a node, changing the structure of the tree, without modifying -the order of the nodes in the tree. - -### set_left method - -```python (doc) -BinaryTreeNode.set_left( - self: ~NodeType, - child: Optional[~NodeType] = None, - clear_old_child_parent: bool = False, -) -> ~NodeType -``` - -Set the left node to the passed `child` - -### set_right method - -```python (doc) -BinaryTreeNode.set_right( - self: ~NodeType, - child: Optional[~NodeType] = None, - clear_old_child_parent: bool = False, -) -> ~NodeType -``` - -Set the right node to the passed `child` - -### set_side method - -```python (doc) -BinaryTreeNode.set_side( - self, - child: ~NodeType, - side: Literal['left', 'right'], -) -> ~NodeType -``` - -Set a new `child` on the given `side` - -### visit_inorder method - -```python (doc) -BinaryTreeNode.visit_inorder( - self, - visit_fn: Callable[[Any, int, Optional[Any]], Optional[Literal['stop']]], - depth: int = 0, - data: Optional[Any] = None, -) -> Optional[Literal['stop']] -``` - -Visit the tree inorder, which visits the left child, then the current node, -and then its right child. - -_Left -> Visit -> Right_ - -This method accepts a function that will be invoked for each node in the -tree. The callback function is passed three arguments: the node being -visited, the current depth in the tree, and a user specified data parameter. - -!!! info - - Traversals may be canceled by returning `STOP` from any visit function. - -### visit_postorder method - -```python (doc) -BinaryTreeNode.visit_postorder( - self, - visit_fn: Callable[[Any, int, Optional[Any]], Optional[Literal['stop']]], - depth: int = 0, - data: Optional[Any] = None, -) -> Optional[Literal['stop']] -``` - -Visit the tree postorder, which visits its left child, then its right child, -and finally the current node. - -_Left -> Right -> Visit_ - -This method accepts a function that will be invoked for each node in the -tree. The callback function is passed three arguments: the node being -visited, the current depth in the tree, and a user specified data parameter. - -!!! info - - Traversals may be canceled by returning `STOP` from any visit function. - -### visit_preorder method - -```python (doc) -BinaryTreeNode.visit_preorder( - self, - visit_fn: Callable[[Any, int, Optional[Any]], Optional[Literal['stop']]], - depth: int = 0, - data: Optional[Any] = None, -) -> Optional[Literal['stop']] -``` - -Visit the tree preorder, which visits the current node, then its left -child, and then its right child. - -_Visit -> Left -> Right_ - -This method accepts a function that will be invoked for each node in the -tree. The callback function is passed three arguments: the node being -visited, the current depth in the tree, and a user specified data parameter. - -!!! info - - Traversals may be canceled by returning `STOP` from any visit function. - -## VisitDataType - -Template type of user data passed to visit functions. - -# mathy_core.expressions - -## AbsExpression class - -```python (doc) -AbsExpression( - self, - child: Optional[mathy_core.expressions.MathExpression] = None, - child_on_left: bool = False, -) -``` - -Evaluates the absolute value of an expression. - -## AddExpression class - -```python (doc) -AddExpression( - self, - left: Optional[mathy_core.expressions.MathExpression] = None, - right: Optional[mathy_core.expressions.MathExpression] = None, -) -``` - -Add one and two - -## BinaryExpression class - -```python (doc) -BinaryExpression( - self, - left: Optional[mathy_core.expressions.MathExpression] = None, - right: Optional[mathy_core.expressions.MathExpression] = None, -) -``` - -An expression that operates on two sub-expressions - -### get_priority method - -```python (doc) -BinaryExpression.get_priority(self) -> int -``` - -Return a number representing the order of operations priority -of this node. This can be used to check if a node is `locked` -with respect to another node, i.e. the other node must be resolved -first during evaluation because of it's priority. - -### to_math_ml_fragment method - -```python (doc) -BinaryExpression.to_math_ml_fragment(self) -> str -``` - -Render this node as a MathML element fragment - -## ConstantExpression class - -```python (doc) -ConstantExpression(self, value: Optional[float, int] = None) -``` - -A Constant value node, where the value is accessible as `node.value` - -## DivideExpression class - -```python (doc) -DivideExpression( - self, - left: Optional[mathy_core.expressions.MathExpression] = None, - right: Optional[mathy_core.expressions.MathExpression] = None, -) -``` - -Divide one by two - -## EqualExpression class - -```python (doc) -EqualExpression( - self, - left: Optional[mathy_core.expressions.MathExpression] = None, - right: Optional[mathy_core.expressions.MathExpression] = None, -) -``` - -Evaluate equality of two expressions - -### operate method - -```python (doc) -EqualExpression.operate( - self, - one: Union[float, int], - two: Union[float, int], -) -> Union[float, int] -``` - -Return the value of the equation if one == two. - -Raise ValueError if both sides of the equation don't agree. - -## FactorialExpression class - -```python (doc) -FactorialExpression( - self, - child: Optional[mathy_core.expressions.MathExpression] = None, - child_on_left: bool = False, -) -``` - -Factorial of a constant, e.g. `5` evaluates to `120` - -## FunctionExpression class - -```python (doc) -FunctionExpression( - self, - child: Optional[mathy_core.expressions.MathExpression] = None, - child_on_left: bool = False, -) -``` - -A Specialized UnaryExpression that is used for functions. The function name in -text (used by the parser and tokenizer) is derived from the name() method on the -class. - -## MathExpression class - -```python (doc) -MathExpression( - self, - id: Optional[str] = None, - left: Optional[MathExpression] = None, - right: Optional[MathExpression] = None, - parent: Optional[MathExpression] = None, -) -``` - -Math tree node with helpers for manipulating expressions. - -`mathy:x+y=z` - -### add_class method - -```python (doc) -MathExpression.add_class( - self, - classes: Union[List[str], str], -) -> 'MathExpression' -``` - -Associate a class name with an expression. This class name will be -attached to nodes when the expression is converted to a capable output -format. - -See `MathExpression.to_math_ml_fragment` - -### all_changed method - -```python (doc) -MathExpression.all_changed(self) -> None -``` - -Mark this node and all of its children as changed - -### clear_classes method - -```python (doc) -MathExpression.clear_classes(self) -> None -``` - -Clear all the classes currently set on the nodes in this expression. - -### clone method - -```python (doc) -MathExpression.clone(self) -> 'MathExpression' -``` - -A specialization of the clone method that can track and report a cloned -subtree node. - -See `MathExpression.clone_from_root` for more details. - -### clone_from_root method - -```python (doc) -MathExpression.clone_from_root( - self, - node: Optional[MathExpression] = None, -) -> 'MathExpression' -``` - -Clone this node including the entire parent hierarchy that it has. This -is useful when you want to clone a subtree and still maintain the overall -hierarchy. - -**Arguments** - -- **node (MathExpression)**: The node to clone. - -**Returns** - -`(MathExpression)`: The cloned node. - -### color - -Color to use for this node when rendering it as changed with -`.terminal_text` - -### evaluate method - -```python (doc) -MathExpression.evaluate( - self, - context: Optional[Dict[str, Union[float, int]]] = None, -) -> Union[float, int] -``` - -Evaluate the expression, resolving all variables to constant values - -### find_id method - -```python (doc) -MathExpression.find_id(self, id: str) -> Optional[MathExpression] -``` - -Find an expression by its unique ID. - -Returns: The found `MathExpression` or `None` - -### find_type method - -```python (doc) -MathExpression.find_type(self, instanceType: Type[~NodeType]) -> List[~NodeType] -``` - -Find an expression in this tree by type. - -- instanceType: The type to check for instances of - -Returns the found `MathExpression` objects of the given type. - -### make_ml_tag method - -```python (doc) -MathExpression.make_ml_tag( - self, - tag: str, - content: str, - classes: List[str] = [], -) -> str -``` - -Make a MathML tag for the given content while respecting the node's given -classes. - -**Arguments** - -- **tag (str)**: The ML tag name to create. -- **content (str)**: The ML content to place inside of the tag. - classes (List[str]) An array of classes to attach to this tag. - -**Returns** - -`(str)`: A MathML element with the given tag, content, and classes - -### path_to_root method - -```python (doc) -MathExpression.path_to_root(self) -> str -``` - -Generate a namespaced path key to from the current node to the root. -This key can be used to identify a node inside of a tree. - -### raw - -raw text representation of the expression. - -### set_changed method - -```python (doc) -MathExpression.set_changed(self) -> None -``` - -Mark this node as having been changed by the application of a Rule - -### terminal_text - -Text output of this node that includes terminal color codes that -highlight which nodes have been changed in this tree as a result of -a transformation. - -### to_list method - -```python (doc) -MathExpression.to_list( - self, - visit: str = 'preorder', -) -> List[MathExpression] -``` - -Convert this node hierarchy into a list. - -### to_math_ml method - -```python (doc) -MathExpression.to_math_ml(self) -> str -``` - -Convert this expression into a MathML container. - -### to_math_ml_fragment method - -```python (doc) -MathExpression.to_math_ml_fragment(self) -> str -``` - -Convert this single node into MathML. - -### with_color method - -```python (doc) -MathExpression.with_color(self, text: str, style: str = 'bright') -> str -``` - -Render a string that is colored if something has changed - -## MultiplyExpression class - -```python (doc) -MultiplyExpression( - self, - left: Optional[mathy_core.expressions.MathExpression] = None, - right: Optional[mathy_core.expressions.MathExpression] = None, -) -``` - -Multiply one and two - -## NegateExpression class - -```python (doc) -NegateExpression( - self, - child: Optional[mathy_core.expressions.MathExpression] = None, - child_on_left: bool = False, -) -``` - -Negate an expression, e.g. `4` becomes `-4` - -### to_math_ml_fragment method - -```python (doc) -NegateExpression.to_math_ml_fragment(self) -> str -``` - -Convert this single node into MathML. - -## PowerExpression class - -```python (doc) -PowerExpression( - self, - left: Optional[mathy_core.expressions.MathExpression] = None, - right: Optional[mathy_core.expressions.MathExpression] = None, -) -``` - -Raise one to the power of two - -## SgnExpression class - -```python (doc) -SgnExpression( - self, - child: Optional[mathy_core.expressions.MathExpression] = None, - child_on_left: bool = False, -) -``` - -### operate method - -```python (doc) -SgnExpression.operate(self, value: Union[float, int]) -> Union[float, int] -``` - -Determine the sign of an value. - -**Returns** - -`(int)`: -1 if negative, 1 if positive, 0 if 0 - -## SubtractExpression class - -```python (doc) -SubtractExpression( - self, - left: Optional[mathy_core.expressions.MathExpression] = None, - right: Optional[mathy_core.expressions.MathExpression] = None, -) -``` - -Subtract one from two - -## UnaryExpression class - -```python (doc) -UnaryExpression( - self, - child: Optional[mathy_core.expressions.MathExpression] = None, - child_on_left: bool = False, -) -``` - -An expression that operates on one sub-expression - -# mathy_core.rules.associative_swap - -## AssociativeSwapRule class - -```python (doc) -AssociativeSwapRule(self, args, kwargs) -``` - -Associative Property -Addition: `(a + b) + c = a + (b + c)` - - (y) + + (x) - / \ / \ - / \ / \ - (x) + c -> a + (y) - / \ / \ - / \ / \ - a b b c - -Multiplication: `(ab)c = a(bc)` - - (x) * * (y) - / \ / \ - / \ / \ - (y) * c <- a * (x) - / \ / \ - / \ / \ - a b b c - -# mathy_core.rules.balanced_move - -## BalancedMoveRule class - -```python (doc) -BalancedMoveRule(self, args, kwargs) -``` - -Balanced rewrite rule moves nodes from one side of an equation -to the other by performing the same operation on both sides. - -Addition: `a + 2 = 3` -> `a + 2 - 2 = 3 - 2` -Multiplication: `3a = 3` -> `3a / 3 = 3 / 3` - -### get_type method - -```python (doc) -BalancedMoveRule.get_type( - self, - node: mathy_core.expressions.MathExpression, -) -> Optional[str] -``` - -Determine the configuration of the tree for this transformation. - -Supports the following configurations: - -- Addition is a term connected by an addition to the side of an equation - or inequality. It generates two subtractions to move from one side to the - other. -- Multiply is a coefficient of a term that must be divided on both sides of - the equation or inequality. - -# mathy_core.rules.commutative_swap - -## CommutativeSwapRule class - -```python (doc) -CommutativeSwapRule(self, preferred: bool = True) -``` - -Commutative Property -For Addition: `a + b = b + a` - - + + - / \ / \ - / \ -> / \ - / \ / \ - a b b a - -For Multiplication: `a * b = b * a` - - * * - / \ / \ - / \ -> / \ - / \ / \ - a b b a - -# mathy_core.rules.constants_simplify - -## ConstantsSimplifyRule class - -```python (doc) -ConstantsSimplifyRule(self, args, kwargs) -``` - -Given a binary operation on two constants, simplify to the resulting -constant expression - -### get_type method - -```python (doc) -ConstantsSimplifyRule.get_type( - self, - node: mathy_core.expressions.MathExpression, -) -> Optional[Tuple[str, mathy_core.expressions.ConstantExpression, mathy_core.expressions.ConstantExpression]] -``` - -Determine the configuration of the tree for this transformation. - -Support the three types of tree configurations: - -- Simple is where the node's left and right children are exactly - constants linked by an add operation. -- Chained Right is where the node's left child is a constant, but the right - child is another binary operation of the same type. In this case the left - child of the next binary node is the target. - -Structure: - -- Simple - - node(add),node.left(const),node.right(const) -- Chained Right - - node(add),node.left(const),node.right(add),node.right.left(const) -- Chained Right Deep - - node(add),node.left(const),node.right(add),node.right.left(const) - -# mathy_core.rules.distributive_factor_out - -## DistributiveFactorOutRule class - -```python (doc) -DistributiveFactorOutRule(self, constants: bool = False) -``` - -Distributive Property -`ab + ac = a(b + c)` - -The distributive property can be used to expand out expressions -to allow for simplification, as well as to factor out common properties -of terms. - -**Factor out a common term** - -This handles the `ab + ac` conversion of the distributive property, which -factors out a common term from the given two addition operands. - - + * - / \ / \ - / \ / \ - / \ -> / \ - * * a + - / \ / \ / \ - a b a c b c - -### get_type method - -```python (doc) -DistributiveFactorOutRule.get_type( - self, - node: mathy_core.expressions.MathExpression, -) -> Optional[Tuple[str, mathy_core.util.TermEx, mathy_core.util.TermEx]] -``` - -Determine the configuration of the tree for this transformation. - -Support the three types of tree configurations: - -- Simple is where the node's left and right children are exactly - terms linked by an add operation. -- Chained Left is where the node's left child is a term, but the right - child is another add operation. In this case the left child - of the next add node is the target. -- Chained Right is where the node's right child is a term, but the left - child is another add operation. In this case the right child - of the child add node is the target. - -Structure: - -- Simple - - node(add),node.left(term),node.right(term) -- Chained Left - - node(add),node.left(term),node.right(add),node.right.left(term) -- Chained Right - - node(add),node.right(term),node.left(add),node.left.right(term) - -# mathy_core.rules.distributive_multiply_across - -## DistributiveMultiplyRule class - -```python (doc) -DistributiveMultiplyRule(self, args, kwargs) -``` - -Distributive Property -`a(b + c) = ab + ac` - -The distributive property can be used to expand out expressions -to allow for simplification, as well as to factor out common properties of terms. - -**Distribute across a group** - -This handles the `a(b + c)` conversion of the distributive property, which -distributes `a` across both `b` and `c`. - -_note: this is useful because it takes a complex Multiply expression and -replaces it with two simpler ones. This can expose terms that can be -combined for further expression simplification._ - - + - * / \ - / \ / \ - / \ / \ - a + -> * * - / \ / \ / \ - / \ / \ / \ - b c a b a c - -# mathy_core.rules.variable_multiply - -## VariableMultiplyRule class - -```python (doc) -VariableMultiplyRule(self, args, kwargs) -``` - -This restates `x^b * x^d` as `x^(b + d)` which has the effect of isolating -the exponents attached to the variables, so they can be combined. - - 1. When there are two terms with the same base being multiplied together, their - exponents are added together. "x * x^3" = "x^4" because "x = x^1" so - "x^1 * x^3 = x^(1 + 3) = x^4" - - TODO: 2. When there is a power raised to another power, they can be combined by - multiplying the exponents together. "x^(2^2) = x^4" - -The rule identifies terms with explicit and implicit powers, so the following -transformations are all valid: - -Explicit powers: x^b \* x^d = x^(b+d) - - * - / \ - / \ ^ - / \ = / \ - ^ ^ x + - / \ / \ / \ - x b x d b d - -Implicit powers: x \* x^d = x^(1 + d) - - * - / \ - / \ ^ - / \ = / \ - x ^ x + - / \ / \ - x d 1 d - -### get_type method - -```python (doc) -VariableMultiplyRule.get_type( - self, - node: mathy_core.expressions.MathExpression, -) -> Optional[Tuple[str, mathy_core.util.TermEx, mathy_core.util.TermEx]] -``` - -Determine the configuration of the tree for this transformation. - -Support two types of tree configurations: - -- Simple is where the node's left and right children are exactly - terms that can be multiplied together. -- Chained is where the node's left child is a term, but the right - child is a continuation of a more complex term, as indicated by - the presence of another Multiply node. In this case the left child - of the next multiply node is the target. - -Structure: - -- Simple node(mult),node.left(term),node.right(term) -- Chained node(mult),node.left(term),node.right(mult),node.right.left(term) - -# mathy_core.layout - -## TreeLayout class - -```python (doc) -TreeLayout(self, args, kwargs) -``` - -Calculate a visual layout for input trees. - -### layout method - -```python (doc) -TreeLayout.layout( - self, - node: mathy_core.tree.BinaryTreeNode, - unit_x_multiplier: float = 1.0, - unit_y_multiplier: float = 1.0, -) -> 'TreeMeasurement' -``` - -Assign x/y values to all nodes in the tree, and return an object containing -the measurements of the tree. - -Returns a TreeMeasurement object that describes the bounds of the tree - -### transform method - -```python (doc) -TreeLayout.transform( - self, - node: Optional[mathy_core.tree.BinaryTreeNode] = None, - x: float = 0, - unit_x_multiplier: float = 1, - unit_y_multiplier: float = 1, - measure: Optional[TreeMeasurement] = None, -) -> 'TreeMeasurement' -``` - -Transform relative to absolute coordinates, and measure the bounds of -the tree. - -Return a measurement of the tree in output units. - -## TreeMeasurement class - -```python (doc) -TreeMeasurement(self) -> None -``` - -Summary of the rendered tree - -# mathy_core.problems - -## Problem Generation - -Utility functions for helping generate input problems. - -## DefaultType - -Template type for a default return value - -## gen_binomial_times_binomial function - -```python (doc) -gen_binomial_times_binomial( - op: str = '+', - min_vars: int = 1, - max_vars: int = 2, - simple_variables: bool = True, - powers_probability: float = 0.33, - like_variables_probability: float = 1.0, -) -> Tuple[str, int] -``` - -Generate a binomial multiplied by another binomial. - -**Example** - -``` -(2e + 12p)(16 + 7e) -``` - -`mathy:(2e + 12p)(16 + 7e)` - -## gen_binomial_times_monomial function - -```python (doc) -gen_binomial_times_monomial( - op: str = '+', - min_vars: int = 1, - max_vars: int = 2, - simple_variables: bool = True, - powers_probability: float = 0.33, - like_variables_probability: float = 1.0, -) -> Tuple[str, int] -``` - -Generate a binomial multiplied by a monomial. - -**Example** - -``` -(4x^3 + y) * 2x -``` - -`mathy:(4x^3 + y) * 2x` - -## gen_combine_terms_in_place function - -```python (doc) -gen_combine_terms_in_place( - min_terms: int = 16, - max_terms: int = 26, - easy: bool = True, - powers: bool = False, -) -> Tuple[str, int] -``` - -Generate a problem that puts one pair of like terms next to each other -somewhere inside a large tree of unlike terms. - -The problem is intended to be solved in a very small number of moves, making -training across many episodes relatively quick, and reducing the combinatorial -explosion of branches that need to be searched to solve the task. - -The hope is that by focusing the agent on selecting the right moves inside of a -ridiculously large expression it will learn to select actions to combine like terms -invariant of the sequence length. - -**Example** - -``` -4y + 12j + 73q + 19k + 13z + 56l + (24x + 12x) + 43n + 17j -``` - -`mathy:4y + 12j + 73q + 19k + 13z + 56l + (24x + 12x) + 43n + 17j` - -## gen_commute_haystack function - -```python (doc) -gen_commute_haystack( - min_terms: int = 5, - max_terms: int = 8, - commute_blockers: int = 1, - easy: bool = True, - powers: bool = False, -) -> Tuple[str, int] -``` - -A problem with a bunch of terms that have no matches, and a single -set of two terms that do match, but are separated by one other term. -The challenge is to commute the terms to each other in one move. - -**Example** - -``` -4y + 12j + 73q + 19k + 13z + 24x + 56l + 12x + 43n + 17j" - ^-----------^ -``` - -`mathy:4y + 12j + 73q + 19k + 13z + 24x + 56l + 12x + 43n + 17j` - -## gen_move_around_blockers_one function - -```python (doc) -gen_move_around_blockers_one( - number_blockers: int, - powers_probability: float = 0.5, -) -> Tuple[str, int] -``` - -Two like terms separated by (n) blocker terms. - -**Example** - -``` -4x + (y + f) + x -``` - -`mathy:4x + (y + f) + x` - -## gen_move_around_blockers_two function - -```python (doc) -gen_move_around_blockers_two( - number_blockers: int, - powers_probability: float = 0.5, -) -> Tuple[str, int] -``` - -Two like terms with three blockers. - -**Example** - -``` -7a + 4x + (2f + j) + x + 3d -``` - -`mathy:7a + 4x + (2f + j) + x + 3d` - -## gen_simplify_multiple_terms function - -```python (doc) -gen_simplify_multiple_terms( - num_terms: int, - optional_var: bool = False, - op: Optional[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, - noise_probability: float = 0.8, - shuffle_probability: float = 0.66, - share_var_probability: float = 0.5, - grouping_noise_probability: float = 0.66, - noise_terms: Optional[int] = None, -) -> Tuple[str, int] -``` - -Generate a polynomial problem with like terms that need to be combined and -simplified. - -**Example** - -``` -2a + 3j - 7b + 17.2a + j -``` - -`mathy:2a + 3j - 7b + 17.2a + j` - -## get_blocker function - -```python (doc) -get_blocker( - num_blockers: int = 1, - exclude_vars: Optional[List[str]] = None, -) -> str -``` - -Get a string of terms to place between target simplification terms -in order to challenge the agent's ability to use commutative/associative -rules to move terms around. - -## get_rand_vars function - -```python (doc) -get_rand_vars( - num_vars: int, - exclude_vars: Optional[List[str]] = None, - common_variables: bool = False, -) -> List[str] -``` - -Get a list of random variables, excluding the given list of hold-out variables - -## MathyTermTemplate dataclass - -```python (doc) -MathyTermTemplate( - self, - variable: Optional[str] = None, - exponent: Optional[float, int] = None, -) -> None -``` - -MathyTermTemplate(variable: Optional[str] = None, exponent: Union[float, int, NoneType] = None) - -## split_in_two_random function - -```python (doc) -split_in_two_random(value: int) -> Tuple[int, int] -``` - -Split a given number into two smaller numbers that sum to it. -Returns: a tuple of (lower, higher) numbers that sum to the input - -## use_pretty_numbers function - -```python (doc) -use_pretty_numbers(enabled: bool = True) -> None -``` - -Determine if problems should include only pretty numbers or -a whole range of integers and floats. Using pretty numbers will -restrict the numbers that are generated to integers between 1 and 12. When not using pretty numbers, floats and large integers will -be included in the output from `rand_number` - - - \ No newline at end of file + diff --git a/mathy_core/expressions.py b/mathy_core/expressions.py index bb6309a..68459b3 100644 --- a/mathy_core/expressions.py +++ b/mathy_core/expressions.py @@ -576,18 +576,27 @@ 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 + """ left, right = self._check() + + # Handle fraction * variable cases + if isinstance(left, DivideExpression): + if isinstance(right, (VariableExpression, PowerExpression)): + 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: @@ -626,6 +635,40 @@ 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: + out = f"{left_str}{self.with_color(self.name)}{right_str}" # No spaces when coefficient + else: + out = f"{left_str} {self.with_color(self.name)} {right_str}" # Keep spaces normally + + return f"({out})" if self.self_parens() else out + class PowerExpression(BinaryExpression): """Raise one to the power of two""" diff --git a/mathy_core/problems.py b/mathy_core/problems.py index 0033cf6..39299ea 100644 --- a/mathy_core/problems.py +++ b/mathy_core/problems.py @@ -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: @@ -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 random.randint(2, max_power) else: return or_else @@ -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, diff --git a/mathy_core/rules/fraction_reduction.py b/mathy_core/rules/fraction_reduction.py index a59a823..5443f40 100644 --- a/mathy_core/rules/fraction_reduction.py +++ b/mathy_core/rules/fraction_reduction.py @@ -1,7 +1,13 @@ from typing import Optional -from ..expressions import ConstantExpression, MathExpression +from ..expressions import DivideExpression, MathExpression from ..rule import BaseRule, ExpressionChangeRule +from ..util import ( + FractionReductionResult, + factor_fraction_terms_ex, + get_term_ex, + make_term_fractional, +) class FractionReductionRule(BaseRule): @@ -16,25 +22,38 @@ def name(self) -> str: def code(self) -> str: return "FR" - def get_type(self, node: MathExpression) -> Optional[str]: - """Determine the configuration of the tree for this transformation. - - Support different types of tree configurations based on the division operation: - - - """ - return None + def get_reduction_term( + self, node: MathExpression + ) -> Optional[FractionReductionResult]: + is_division = isinstance(node, DivideExpression) + if not is_division: + return None + left_term = get_term_ex(node.left) + right_term = get_term_ex(node.right) + if left_term is None or right_term is None: + return None + f = factor_fraction_terms_ex(left_term, right_term) + if not f: + return None + return f def can_apply_to(self, node: MathExpression) -> bool: - tree_type = self.get_type(node) - return tree_type is not None + reduced_fraction_result = self.get_reduction_term(node) + return reduced_fraction_result is not None def apply_to(self, node: MathExpression) -> ExpressionChangeRule: change = super().apply_to(node) - tree_type = self.get_type(node) - assert tree_type is not None, "call can_apply_to before applying a rule" - change.save_parent() # connect result to node.parent - - result = ConstantExpression(1337) # implement it + reduced_fraction_result = self.get_reduction_term(node) + assert ( + reduced_fraction_result is not None + ), "call can_apply_to before applying a rule" + change.save_parent() # connect result to node.parent + result = make_term_fractional( + numerator=reduced_fraction_result.numerator, + denominator=reduced_fraction_result.denominator, + variable=reduced_fraction_result.reduced_variable, + exponent=reduced_fraction_result.reduced_exponent, + ) result.set_changed() # mark this node as changed for visualization return change.done(result) diff --git a/mathy_core/rules/fraction_reduction.test.json b/mathy_core/rules/fraction_reduction.test.json index b211b0a..1ee550e 100644 --- a/mathy_core/rules/fraction_reduction.test.json +++ b/mathy_core/rules/fraction_reduction.test.json @@ -2,12 +2,25 @@ "valid": [ { "why": "Simple numeric fraction", - "input": "6x^2 / 3x", - "output": "2x" + "input": "(3x^2) / (6x)", + "output": "1/2x", + "debug": true + }, + { + "why": "Simple numeric fraction", + "input": "(6x^2) / (3x)", + "output": "2x", + "debug": true + }, + { + "why": "Simple numeric fraction", + "input": "(6x) / (3x)", + "output": "2", + "debug": true }, { "why": "Cancel common variable factor", - "input": "4xy / 2y", + "input": "(4xy) / (2y)", "output": "2x" }, { @@ -20,30 +33,10 @@ "input": "(x^3 - x^2) / x^2", "output": "x - 1" }, - { - "why": "Fraction with common complex terms", - "input": "(3x^2 + 3) / (x^2 + 1)", - "output": "3" - }, { "why": "Higher degree polynomial cancellation", "input": "x^4 / x^2", "output": "x^2" - }, - { - "why": "Fraction where only part of the numerator cancels", - "input": "(x^2 + x) / x", - "output": "x + 1" - }, - { - "why": "Fraction with a negative common factor", - "input": "-4x^2 / -2x", - "output": "2x" - }, - { - "why": "Fraction with nested expression common factors", - "input": "(2x(x + 1)) / (x + 1)", - "output": "2x" } ], "invalid": [ diff --git a/mathy_core/util.py b/mathy_core/util.py index d3e4851..69041ef 100644 --- a/mathy_core/util.py +++ b/mathy_core/util.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass, field import math import random from typing import Any, Dict, List, NamedTuple, Optional, Set, Tuple, Union, cast @@ -28,13 +29,16 @@ def is_debug_mode() -> bool: def compare_expression_string_values( - from_expression: str, to_expression: str, history: Optional[List[Any]] = None + from_expression: str, + to_expression: str, + history: Optional[List[Any]] = None, + env_name: Optional[str] = None, ) -> None: """Compare and evaluate two expressions strings to verify they have the same value""" parser = ExpressionParser() return compare_expression_values( - parser.parse(from_expression), parser.parse(to_expression), history + parser.parse(from_expression), parser.parse(to_expression), history, env_name ) @@ -58,6 +62,7 @@ def compare_expression_values( from_expression: MathExpression, to_expression: MathExpression, history: Optional[List[Any]] = None, + env_name: Optional[str] = None, ) -> None: """Compare and evaluate two expressions to verify they have the same value""" vars_from: Set[str] = set( @@ -70,15 +75,6 @@ def compare_expression_values( for v in to_expression.find_type(VariableExpression) if v.identifier ) - # If there are not the same unique vars in the two expressions, something - # bad happened, and the two expressions can only coincidentally be equal - # in value. - if len(vars_from) != len(vars_to): - raise_with_history( - "Number of variables changed", - f"{list(vars_from)} != {list(vars_to)}", - history, - ) sorted_from = list(vars_from) sorted_from.sort() @@ -101,15 +97,17 @@ def compare_expression_values( value_to = to_expression.evaluate(eval_context) # Print out the problem steps leading up to error result. - if not math.isclose(value_from, value_to, rel_tol=1e-9, abs_tol=0.0): + if not math.isclose(value_from, value_to, rel_tol=1e-6, abs_tol=0.0): changed = f""" - {from_expression} = {value_from} + IN: {from_expression} = {value_from} - {to_expression} = {value_to} + OUT: {to_expression} = {value_to} - {value_from} != {value_to} + ERROR: {value_from} != {value_to} """ - raise_with_history("Expression value changed", changed, history) + raise_with_history( + f"{env_name or 'unknown env'}: Expression value changed", changed, history + ) def compare_equation_values( @@ -381,58 +379,123 @@ def has_like_terms(expression: MathExpression) -> bool: return False -class FactorResult: - best: NumberType - left: NumberType - right: NumberType - all_left: Dict[NumberType, NumberType] - all_right: Dict[NumberType, NumberType] - variable: Optional[str] - exponent: Optional[NumberType] - leftExponent: Optional[NumberType] - rightExponent: Optional[NumberType] - leftVariable: Optional[str] - rightVariable: Optional[str] +@dataclass +class FractionReductionResult: + numerator: NumberType = 1 + denominator: NumberType = 1 + reduced_variable: Optional[str] = None + reduced_exponent: Optional[NumberType] = None + common_variable: Optional[str] = None + common_exponent: Optional[NumberType] = None - def __init__(self) -> None: - self.best = -1 - self.left = -1 - self.right = -1 - self.all_left = {} - self.all_right = {} - self.variable = None - self.exponent = None - self.leftExponent = None - self.rightExponent = None - self.leftVariable = None - self.rightVariable = None + +@dataclass +class FactorResult: + best: NumberType = -1 + left: NumberType = -1 + right: NumberType = -1 + all_left: Dict[NumberType, NumberType] = field(default_factory=dict) + all_right: Dict[NumberType, NumberType] = field(default_factory=dict) + variable: Optional[str] = None + exponent: Optional[NumberType] = None + leftExponent: Optional[NumberType] = None + rightExponent: Optional[NumberType] = None + leftVariable: Optional[str] = None + rightVariable: Optional[str] = None # Create a term node hierarchy from a given set of # term parameters. This takes into account removing # implicit coefficients of 1 where possible. +def reduce_fraction(num: NumberType, den: NumberType) -> tuple[NumberType, NumberType]: + """Reduces a fraction to its simplest form""" + + def gcd(a: int, b: int) -> int: + while b: + a, b = b, a % b + return a + + if isinstance(num, int) and isinstance(den, int): + divisor = gcd(abs(num), abs(den)) + return (num // divisor, den // divisor) + return (num, den) + + def make_term( coefficient: NumberType = 1, variable: Optional[str] = None, exponent: Optional[NumberType] = None, ) -> MathExpression: - constExp = ConstantExpression(coefficient) + """Create a term node hierarchy from given parameters""" + # Handle pure constant case if variable is None and exponent is None: - return constExp + return ConstantExpression(coefficient) - varExp = VariableExpression(variable) + # Just a variable if coefficient == 1 and exponent is None: - return varExp + return VariableExpression(variable) - multExp = MultiplyExpression(constExp, varExp) + # Variable with coefficient if exponent is None: - return multExp + if coefficient == 1: + return VariableExpression(variable) + return MultiplyExpression( + ConstantExpression(coefficient), VariableExpression(variable) + ) - expConstExp = ConstantExpression(exponent) + # Variable with exponent + var_exp = PowerExpression( + VariableExpression(variable), ConstantExpression(exponent) + ) if coefficient == 1: - return PowerExpression(varExp, expConstExp) + return var_exp + return MultiplyExpression(ConstantExpression(coefficient), var_exp) + - return PowerExpression(multExp, expConstExp) +def make_term_fractional( + numerator: NumberType = 1, + denominator: NumberType = 1, + variable: Optional[str] = None, + exponent: Optional[NumberType] = None, +) -> MathExpression: + # First reduce the fraction + num, den = reduce_fraction(numerator, denominator) + + # If denominator is 1, we can use the simpler form + if den == 1: + if num == 1 and variable is not None: + if exponent is None: + return VariableExpression(variable) + return PowerExpression( + VariableExpression(variable), ConstantExpression(exponent) + ) + + base = MultiplyExpression( + ConstantExpression(num), + ( + VariableExpression(variable) + if variable is not None + else ConstantExpression(1) + ), + ) + + if exponent is None: + return base + return PowerExpression(base, ConstantExpression(exponent)) + + # For actual fractions, construct the term + coef = DivideExpression(ConstantExpression(num), ConstantExpression(den)) + + if variable is None: + return coef + + var_term = ( + PowerExpression(VariableExpression(variable), ConstantExpression(exponent)) + if exponent is not None + else VariableExpression(variable) + ) + + return MultiplyExpression(coef, var_term) class TermResult: @@ -637,6 +700,43 @@ def get_term_ex(node: Optional[MathExpression]) -> Optional[TermEx]: return None +def factor_fraction_terms_ex( + left_term: TermEx, right_term: TermEx +) -> Union[FractionReductionResult, Literal[False]]: + if not left_term or not right_term: + raise ValueError("invalid terms for factoring") + + has_left: bool = left_term.variable is not None + has_right: bool = right_term.variable is not None + + result = FractionReductionResult() + + # Handle coefficients while preserving fractions + left_coef = left_term.coefficient if left_term.coefficient is not None else 1 + right_coef = right_term.coefficient if right_term.coefficient is not None else 1 + + if right_coef == 0: + return False + + result.numerator = left_coef + result.denominator = right_coef + + # Handle variables and exponents + if has_left and has_right and left_term.variable == right_term.variable: + result.common_variable = left_term.variable + result.reduced_variable = left_term.variable + + left_exp = left_term.exponent if left_term.exponent is not None else 1 + right_exp = right_term.exponent if right_term.exponent is not None else 1 + + result.reduced_exponent = left_exp - right_exp + result.common_exponent = min(left_exp, right_exp) + elif not (result.numerator != result.denominator): + return False + + return result + + def factor_add_terms_ex( left_term: TermEx, right_term: TermEx ) -> Union[FactorResult, Literal[False]]: diff --git a/tests/test_parser.py b/tests/test_parser.py index ca64b64..30d4f2e 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -119,12 +119,13 @@ def node_visit(node: MathExpression, depth, data) -> None: ] # Convert canvas to string - return "\n".join("".join(row) for row in trimmed_canvas) + return "\n" + "\n".join("".join(row) for row in trimmed_canvas) @pytest.mark.parametrize( "expectation", [ + {"input": "(3x^2) / (6x)", "output": "(3x^2) / 6x"}, {"input": "4x * p^(1 + 3) * 12x^2", "output": "4x * p^(1 + 3) * 12x^2"}, { "input": "(-2.257893300159429e+16h^2 * v) * j^4", @@ -143,7 +144,9 @@ def node_visit(node: MathExpression, depth, data) -> None: def test_parser_to_string(expectation: dict[str, str]) -> None: parser = ExpressionParser() expression = parser.parse(expectation["input"]) + print(render_tree_to_text(expression)) out_str = str(expression) + print(render_tree_to_text(parser.parse(out_str))) assert out_str == expectation["output"] From 050c621135da9adc9fd0cb3e89ed6e3f99608b2e Mon Sep 17 00:00:00 2001 From: Justin DuJardin Date: Sun, 23 Feb 2025 17:47:10 -0800 Subject: [PATCH 04/17] feat(util): add render_tree_to_text function for monospace ascii tree outputs --- mathy_core/layout.py | 111 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/mathy_core/layout.py b/mathy_core/layout.py index af82d1b..4d762fe 100644 --- a/mathy_core/layout.py +++ b/mathy_core/layout.py @@ -1,5 +1,6 @@ from typing import Optional +from .expressions import BinaryExpression, MathExpression from .tree import BinaryTreeNode @@ -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 + ) # Adjusted units for better spacing + + # 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, data) -> None: + """Visit each node and draw it on the canvas.""" + # Adjust coordinates to canvas space + x = int(node.x - min_x) + y = int(node.y - min_y) + + # Draw connection to parent + if node.parent: + int(node.parent.x - min_x) + int(node.parent.y - min_y) + draw_line(node.x, node.y, node.parent.x, node.parent.y) + + # 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 = [ + row[min_col : max_col + 1] for row in canvas[min_row : max_row + 1] + ] + + # Convert canvas to string + return "\n" + "\n".join("".join(row) for row in trimmed_canvas) + + __all__ = ("TidierExtreme", "TreeMeasurement", "TreeLayout") From 1b462749178727112527222b3c2f9be976330e8d Mon Sep 17 00:00:00 2001 From: Justin DuJardin Date: Sun, 23 Feb 2025 17:47:39 -0800 Subject: [PATCH 05/17] chore: fix fraction * variable parens --- mathy_core/expressions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mathy_core/expressions.py b/mathy_core/expressions.py index 68459b3..b3ebdea 100644 --- a/mathy_core/expressions.py +++ b/mathy_core/expressions.py @@ -586,7 +586,7 @@ def __str__(self) -> str: # Handle fraction * variable cases if isinstance(left, DivideExpression): if isinstance(right, (VariableExpression, PowerExpression)): - return self.with_color(f"{left}{right}") + return self.with_color(f"({left}){right}") # Handle existing constant * variable cases if isinstance(left, ConstantExpression): From 3526b0715ce6e5169627c40a5f36d4171f111fb6 Mon Sep 17 00:00:00 2001 From: Justin DuJardin Date: Sun, 23 Feb 2025 17:48:25 -0800 Subject: [PATCH 06/17] chore: fix test suite for a "working" fraction reduction rule - some failures in corner cases, noted in invalid test cases with proper self-shaming commentary --- mathy_core/rules/fraction_reduction.test.json | 38 +++--- mathy_core/util.py | 45 ++++--- tests/test_parser.py | 117 +----------------- tests/test_rules.py | 1 - 4 files changed, 52 insertions(+), 149 deletions(-) diff --git a/mathy_core/rules/fraction_reduction.test.json b/mathy_core/rules/fraction_reduction.test.json index 1ee550e..816da4b 100644 --- a/mathy_core/rules/fraction_reduction.test.json +++ b/mathy_core/rules/fraction_reduction.test.json @@ -2,8 +2,8 @@ "valid": [ { "why": "Simple numeric fraction", - "input": "(3x^2) / (6x)", - "output": "1/2x", + "input": "(6x) / (3x)", + "output": "2", "debug": true }, { @@ -14,25 +14,10 @@ }, { "why": "Simple numeric fraction", - "input": "(6x) / (3x)", - "output": "2", + "input": "(3x^2) / (6x)", + "output": "(1/2)x", "debug": true }, - { - "why": "Cancel common variable factor", - "input": "(4xy) / (2y)", - "output": "2x" - }, - { - "why": "Cancel and reduce constants with variables", - "input": "10x^2y / 5xy", - "output": "2x" - }, - { - "why": "Complex polynomial reduction", - "input": "(x^3 - x^2) / x^2", - "output": "x - 1" - }, { "why": "Higher degree polynomial cancellation", "input": "x^4 / x^2", @@ -40,6 +25,21 @@ } ], "invalid": [ + { + "why": "Complex polynomial reduction -- rule sucking part 3", + "input": "(x^3 - x^2) / x^2", + "output": "x - 1" + }, { + "why": "Cancel and reduce constants with variables -- my rule sucks x2. plz corner case jesus, save me", + "input": "(10x^2y) / (5xy)", + "output": "2x" + }, + { + "why": "Cancel common variable factor -- my rule sucks? needs more corner case.", + "input": "(4xy) / (2y)", + "output": "2x" + }, + { "why": "Fraction with no common factors", "input": "(x + 1) / (y + 1)" diff --git a/mathy_core/util.py b/mathy_core/util.py index 69041ef..0534309 100644 --- a/mathy_core/util.py +++ b/mathy_core/util.py @@ -1,10 +1,10 @@ -from dataclasses import dataclass, field import math import random +from dataclasses import dataclass, field from typing import Any, Dict, List, NamedTuple, Optional, Set, Tuple, Union, cast import numpy as np -from wasabi import TracebackPrinter # type: ignore +from wasabi import TracebackPrinter from .expressions import ( AddExpression, @@ -18,6 +18,7 @@ SubtractExpression, VariableExpression, ) +from .layout import render_tree_to_text # type: ignore from .parser import ExpressionParser from .tree import LEFT, VisitStop from .types import Literal, NumberType @@ -98,11 +99,17 @@ def compare_expression_values( # Print out the problem steps leading up to error result. if not math.isclose(value_from, value_to, rel_tol=1e-6, abs_tol=0.0): + in_tree_visualize = render_tree_to_text(from_expression) + out_tree_visualize = render_tree_to_text(to_expression) changed = f""" IN: {from_expression} = {value_from} + {in_tree_visualize} + OUT: {to_expression} = {value_to} + {out_tree_visualize} + ERROR: {value_from} != {value_to} """ raise_with_history( @@ -464,26 +471,25 @@ def make_term_fractional( # If denominator is 1, we can use the simpler form if den == 1: if num == 1 and variable is not None: - if exponent is None: + if exponent is None or exponent == 1: return VariableExpression(variable) return PowerExpression( VariableExpression(variable), ConstantExpression(exponent) ) - base = MultiplyExpression( - ConstantExpression(num), - ( - VariableExpression(variable) - if variable is not None - else ConstantExpression(1) - ), - ) + if variable is not None: + base = MultiplyExpression( + ConstantExpression(num), + VariableExpression(variable), + ) + else: + base = ConstantExpression(num) - if exponent is None: + if exponent is None or exponent == 1: return base return PowerExpression(base, ConstantExpression(exponent)) - # For actual fractions, construct the term + # For fractions, FIRST create the fractional coefficient coef = DivideExpression(ConstantExpression(num), ConstantExpression(den)) if variable is None: @@ -491,10 +497,11 @@ def make_term_fractional( var_term = ( PowerExpression(VariableExpression(variable), ConstantExpression(exponent)) - if exponent is not None + if exponent is not None and exponent != 1 else VariableExpression(variable) ) + # Wrap the fraction in parentheses before multiplying with variable return MultiplyExpression(coef, var_term) @@ -724,12 +731,18 @@ def factor_fraction_terms_ex( # Handle variables and exponents if has_left and has_right and left_term.variable == right_term.variable: result.common_variable = left_term.variable - result.reduced_variable = left_term.variable left_exp = left_term.exponent if left_term.exponent is not None else 1 right_exp = right_term.exponent if right_term.exponent is not None else 1 - result.reduced_exponent = left_exp - right_exp + reduced_exp = left_exp - right_exp + if reduced_exp == 0: + result.reduced_variable = None + result.reduced_exponent = None + else: + result.reduced_variable = left_term.variable + result.reduced_exponent = reduced_exp + result.common_exponent = min(left_exp, right_exp) elif not (result.numerator != result.denominator): return False diff --git a/tests/test_parser.py b/tests/test_parser.py index 30d4f2e..877890f 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -1,8 +1,8 @@ from typing import Any + import pytest -from mathy_core.expressions import BinaryExpression, MathExpression -from mathy_core.layout import TreeLayout, TreeMeasurement +from mathy_core.layout import render_tree_to_text from mathy_core.parser import ( ExpressionParser, InvalidExpression, @@ -12,119 +12,10 @@ ) -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 - ) # Adjusted units for better spacing - - # 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, data) -> None: - """Visit each node and draw it on the canvas.""" - # Adjust coordinates to canvas space - x = int(node.x - min_x) - y = int(node.y - min_y) - - # Draw connection to parent - if node.parent: - parent_x = int(node.parent.x - min_x) - parent_y = int(node.parent.y - min_y) - draw_line(node.x, node.y, node.parent.x, node.parent.y) - - # 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 = [ - row[min_col : max_col + 1] for row in canvas[min_row : max_row + 1] - ] - - # Convert canvas to string - return "\n" + "\n".join("".join(row) for row in trimmed_canvas) - - @pytest.mark.parametrize( "expectation", [ + {"input": "(1 / 2)x", "output": "(1/2)x"}, {"input": "(3x^2) / (6x)", "output": "(3x^2) / 6x"}, {"input": "4x * p^(1 + 3) * 12x^2", "output": "4x * p^(1 + 3) * 12x^2"}, { @@ -146,7 +37,6 @@ def test_parser_to_string(expectation: dict[str, str]) -> None: expression = parser.parse(expectation["input"]) print(render_tree_to_text(expression)) out_str = str(expression) - print(render_tree_to_text(parser.parse(out_str))) assert out_str == expectation["output"] @@ -160,6 +50,7 @@ def test_parser_factorials() -> None: def test_parser_operator_precedence() -> None: expects: list[dict[str, float | int | str]] = [ + {"input": "(1 / 2) * 4^2", "output": 8}, {"input": "9 / 8 * 9", "output": 10.125}, {"input": "4 + 9 / 8 * 9", "output": 14.125}, ] diff --git a/tests/test_rules.py b/tests/test_rules.py index 2cd4351..2cf7c5e 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -11,7 +11,6 @@ RestateSubtractionRule, VariableMultiplyRule, ) - from mathy_core.testing import run_rule_tests From eb7840328ab842b1c44a95f843f44c3a02c0e813 Mon Sep 17 00:00:00 2001 From: Justin DuJardin Date: Sun, 23 Feb 2025 17:56:41 -0800 Subject: [PATCH 07/17] chore: fix lint --- mathy_core/expressions.py | 6 ++++-- mathy_core/layout.py | 22 +++++++++++----------- mathy_core/problems.py | 2 +- mathy_core/util.py | 3 ++- 4 files changed, 18 insertions(+), 15 deletions(-) diff --git a/mathy_core/expressions.py b/mathy_core/expressions.py index b3ebdea..aa06ada 100644 --- a/mathy_core/expressions.py +++ b/mathy_core/expressions.py @@ -663,9 +663,11 @@ def needs_parens(expr: MathExpression) -> bool: right_str = f"({right})" if needs_parens(right) else str(right) if is_coefficient: - out = f"{left_str}{self.with_color(self.name)}{right_str}" # No spaces when coefficient + # No spaces when coefficient + out = f"{left_str}{self.with_color(self.name)}{right_str}" else: - out = f"{left_str} {self.with_color(self.name)} {right_str}" # Keep spaces normally + # Keep spaces normally + out = f"{left_str} {self.with_color(self.name)} {right_str}" return f"({out})" if self.self_parens() else out diff --git a/mathy_core/layout.py b/mathy_core/layout.py index 4d762fe..50f6c13 100644 --- a/mathy_core/layout.py +++ b/mathy_core/layout.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Any, Optional from .expressions import BinaryExpression, MathExpression from .tree import BinaryTreeNode @@ -250,9 +250,7 @@ def render_tree_to_text(expression: MathExpression) -> str: A string containing the ASCII representation of the tree """ layout = TreeLayout() - measure: TreeMeasurement = layout.layout( - expression, 6, 2 - ) # Adjusted units for better spacing + measure: TreeMeasurement = layout.layout(expression, 6, 2) # type: ignore # Calculate canvas dimensions padding = 4 @@ -292,17 +290,17 @@ def draw_line(x1: int, y1: int, x2: int, y2: int) -> None: else: canvas[mid_y][mid_x] = "╲" if x1 > x2 else "╱" - def node_visit(node: MathExpression, depth, data) -> None: + 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) - y = int(node.y - min_y) + 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) - int(node.parent.y - min_y) - draw_line(node.x, node.y, node.parent.x, node.parent.y) + 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) @@ -343,7 +341,9 @@ def node_visit(node: MathExpression, depth, data) -> None: # Extract the trimmed canvas trimmed_canvas = [ - row[min_col : max_col + 1] for row in canvas[min_row : max_row + 1] + # fmt: off + row[min_col:max_col + 1] for row in canvas[min_row:max_row + 1] + # fmt: on ] # Convert canvas to string diff --git a/mathy_core/problems.py b/mathy_core/problems.py index 39299ea..b605aa2 100644 --- a/mathy_core/problems.py +++ b/mathy_core/problems.py @@ -136,7 +136,7 @@ def maybe_power( if include_exponent: return "^{}".format(random.randint(2, max_power)) else: - return random.randint(2, max_power) + return str(random.randint(2, max_power)) else: return or_else diff --git a/mathy_core/util.py b/mathy_core/util.py index 0534309..f8b2f61 100644 --- a/mathy_core/util.py +++ b/mathy_core/util.py @@ -79,7 +79,7 @@ def compare_expression_values( sorted_from = list(vars_from) sorted_from.sort() - sorted_to = list(vars_from) + sorted_to = list(vars_to) sorted_to.sort() if sorted_from != sorted_to: @@ -477,6 +477,7 @@ def make_term_fractional( VariableExpression(variable), ConstantExpression(exponent) ) + base: MathExpression if variable is not None: base = MultiplyExpression( ConstantExpression(num), From e4143a2fe22abaeda4637d6711fd744485674339 Mon Sep 17 00:00:00 2001 From: Justin DuJardin Date: Sun, 23 Feb 2025 18:30:28 -0800 Subject: [PATCH 08/17] feat(rule): add maintains_variables property default to True - for rules that might potentially remove variables set this to true to avoid the test suite asserting when it happens. --- mathy_core/rule.py | 6 ++++++ mathy_core/rules/fraction_reduction.py | 5 +++++ mathy_core/testing.py | 8 ++++++-- mathy_core/util.py | 11 +++++++++-- 4 files changed, 26 insertions(+), 4 deletions(-) diff --git a/mathy_core/rule.py b/mathy_core/rule.py index 7b7052a..d0428e5 100644 --- a/mathy_core/rule.py +++ b/mathy_core/rule.py @@ -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""" diff --git a/mathy_core/rules/fraction_reduction.py b/mathy_core/rules/fraction_reduction.py index 5443f40..9363c22 100644 --- a/mathy_core/rules/fraction_reduction.py +++ b/mathy_core/rules/fraction_reduction.py @@ -14,6 +14,11 @@ class FractionReductionRule(BaseRule): """Reduce fractions by cancelling out common factors in the numerator and denominator.""" + @property + def maintains_variables(self) -> bool: + # e.g. 3x / 6x -> 1/2 + return False + @property def name(self) -> str: return "Fraction Reduction" diff --git a/mathy_core/testing.py b/mathy_core/testing.py index 4007c3b..7ed0b49 100644 --- a/mathy_core/testing.py +++ b/mathy_core/testing.py @@ -103,9 +103,13 @@ def run_rule_tests( compare_equation_values(before, after, eval_context=eval_context) else: # Compare the values of the in-memory expressions output from the rule - compare_expression_values(before, after) + compare_expression_values( + before, after, enforce_unique_vars=rule.maintains_variables + ) # Parse the output strings to new expressions, and compare the values - compare_expression_string_values(str(before), str(after)) + compare_expression_string_values( + str(before), str(after), enforce_unique_vars=rule.maintains_variables + ) actual = str(after).strip() expected = ex["output"] assert actual == expected, f"Expected '{actual}' to be '{expected}'" diff --git a/mathy_core/util.py b/mathy_core/util.py index f8b2f61..16efede 100644 --- a/mathy_core/util.py +++ b/mathy_core/util.py @@ -34,12 +34,17 @@ def compare_expression_string_values( to_expression: str, history: Optional[List[Any]] = None, env_name: Optional[str] = None, + enforce_unique_vars: bool = True, ) -> None: """Compare and evaluate two expressions strings to verify they have the same value""" parser = ExpressionParser() return compare_expression_values( - parser.parse(from_expression), parser.parse(to_expression), history, env_name + parser.parse(from_expression), + parser.parse(to_expression), + history, + env_name, + enforce_unique_vars, ) @@ -64,6 +69,7 @@ def compare_expression_values( to_expression: MathExpression, history: Optional[List[Any]] = None, env_name: Optional[str] = None, + enforce_unique_vars: bool = True, ) -> None: """Compare and evaluate two expressions to verify they have the same value""" vars_from: Set[str] = set( @@ -82,7 +88,8 @@ def compare_expression_values( sorted_to = list(vars_to) sorted_to.sort() - if sorted_from != sorted_to: + # Certain rules like fraction reduction can change the unique variables + if sorted_from != sorted_to and enforce_unique_vars: raise_with_history( "Unique variables changed", f"{sorted_from} != {sorted_to}", From 43e216f80b1d7c1a4c8b9183c8cacce864d9d4dc Mon Sep 17 00:00:00 2001 From: Justin DuJardin Date: Sun, 23 Feb 2025 18:33:29 -0800 Subject: [PATCH 09/17] chore: fix remove type ignore --- mathy_core/util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mathy_core/util.py b/mathy_core/util.py index 16efede..2afdc06 100644 --- a/mathy_core/util.py +++ b/mathy_core/util.py @@ -4,7 +4,7 @@ from typing import Any, Dict, List, NamedTuple, Optional, Set, Tuple, Union, cast import numpy as np -from wasabi import TracebackPrinter +from wasabi import TracebackPrinter # type: ignore from .expressions import ( AddExpression, From ccb575a7c5057d27e51f8eda90372a69861c2acf Mon Sep 17 00:00:00 2001 From: Justin DuJardin Date: Mon, 24 Feb 2025 09:47:10 -0800 Subject: [PATCH 10/17] chore: more tests --- mathy_core/rules/fraction_reduction.test.json | 7 ++++++- tests/test_util.py | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/mathy_core/rules/fraction_reduction.test.json b/mathy_core/rules/fraction_reduction.test.json index 816da4b..f8e7bbc 100644 --- a/mathy_core/rules/fraction_reduction.test.json +++ b/mathy_core/rules/fraction_reduction.test.json @@ -25,11 +25,16 @@ } ], "invalid": [ + { + "why": "Code coverage - when factoring the fraction terms fails (divide 0 coefficient)", + "input": "4 / 0" + }, { "why": "Complex polynomial reduction -- rule sucking part 3", "input": "(x^3 - x^2) / x^2", "output": "x - 1" - }, { + }, + { "why": "Cancel and reduce constants with variables -- my rule sucks x2. plz corner case jesus, save me", "input": "(10x^2y) / (5xy)", "output": "2x" diff --git a/tests/test_util.py b/tests/test_util.py index fd8d50c..d38b8a5 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,3 +1,6 @@ +import pytest + +from mathy_core.layout import render_tree_to_text from mathy_core.parser import ExpressionParser from mathy_core.util import ( TermEx, @@ -83,6 +86,22 @@ def test_util_has_like_terms(): assert input == input and has_like_terms(expr) == expected +@pytest.mark.parametrize( + "text", + [ + "7 + 4x - 2", + "1/4 + 1/2", + "3x^7 - 4x^(3+1) + 2!", + "g + -x^3 + 4x^3 + 19p^4 + -1y", + "1f + 98i + 3f * 14t - (1/2x)", + ], +) +def test_util_render_tree_to_text(text: str) -> None: + parser = ExpressionParser() + expr = parser.parse(text) + assert render_tree_to_text(expr) is not None + + def test_util_terms_are_like(): parser = ExpressionParser() expr = parser.parse("10 + (7x + 6x)") From 17b84e19f5b0a772ab105515291d90154f827268 Mon Sep 17 00:00:00 2001 From: Justin DuJardin Date: Mon, 24 Feb 2025 09:54:06 -0800 Subject: [PATCH 11/17] docs: add fraction reduction rule docs --- mathy_core/rules/fraction_reduction.md | 48 ++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 mathy_core/rules/fraction_reduction.md diff --git a/mathy_core/rules/fraction_reduction.md b/mathy_core/rules/fraction_reduction.md new file mode 100644 index 0000000..76db7f5 --- /dev/null +++ b/mathy_core/rules/fraction_reduction.md @@ -0,0 +1,48 @@ +# Fraction Reduction + +The `Fraction Reduction` rule simplifies fractions by canceling out common factors in the numerator and denominator. This transformation reduces the complexity of expressions and produces equivalent fractions in their simplest form. + +This rule implements the mathematical principle that when a factor appears in both the numerator and denominator of a fraction, it can be canceled out: `(a × c) / (b × c) = a / b` + +## Operations + +**Numeric Fraction Reduction** + +This handles cases where the numerator and denominator contain common numeric factors. For example, `6/3` simplifies to `2`. + +**Variable Cancellation** + +When the same variable appears in both the numerator and denominator, the rule cancels them out: + +- If the powers are equal, the variable is removed completely +- If the powers differ, the variable remains with the difference of the exponents + +For example: + +- `(6x) / (3x)` simplifies to `2` +- `(6x^2) / (3x)` simplifies to `2x` + +**Coefficient Reduction** + +The rule also handles coefficient reduction in conjunction with variable cancellation. It first factors out common terms, then reduces the numerical coefficient to its simplest form. + +For example, `(3x^2) / (6x)` simplifies to `(1/2)x`. + +**Exponent Simplification** + +When variables with exponents appear in both the numerator and denominator, the rule simplifies by subtracting the exponents. + +For example, `x^4 / x^2` simplifies to `x^2`. + +## Implementation Details + +The rule creates a reduced fraction by: + +1. Identifying common factors in the numerator and denominator +2. Reducing the numeric coefficients to their simplest form using GCD +3. Handling variable terms by adjusting their exponents appropriately +4. Constructing a new expression with the simplified terms + +### Examples + +`rule_tests:fraction_reduction` From 96f207a182d7b5c4ad3b95babd951506f324c1a5 Mon Sep 17 00:00:00 2001 From: Justin DuJardin Date: Mon, 24 Feb 2025 09:56:32 -0800 Subject: [PATCH 12/17] chore: don't use tuple since netlify uses older python version --- mathy_core/util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mathy_core/util.py b/mathy_core/util.py index 2afdc06..1ff2570 100644 --- a/mathy_core/util.py +++ b/mathy_core/util.py @@ -421,7 +421,7 @@ class FactorResult: # Create a term node hierarchy from a given set of # term parameters. This takes into account removing # implicit coefficients of 1 where possible. -def reduce_fraction(num: NumberType, den: NumberType) -> tuple[NumberType, NumberType]: +def reduce_fraction(num: NumberType, den: NumberType) -> Tuple[NumberType, NumberType]: """Reduces a fraction to its simplest form""" def gcd(a: int, b: int) -> int: From 8b3344711afbfd342660650a26cdb08c4a625a9c Mon Sep 17 00:00:00 2001 From: Justin DuJardin Date: Mon, 24 Feb 2025 10:09:23 -0800 Subject: [PATCH 13/17] docs: add Fraction Reduction rule to navigation --- website/mkdocs.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/website/mkdocs.yml b/website/mkdocs.yml index 2724ed5..90bba29 100644 --- a/website/mkdocs.yml +++ b/website/mkdocs.yml @@ -34,6 +34,7 @@ nav: - Constants Simplify: api/rules/constants_simplify.md - Distributive Factor Out: api/rules/distributive_factor_out.md - Distributive Multiply Across: api/rules/distributive_multiply_across.md + - Fraction Reduction: api/rules/fraction_reduction.md - Multiplicative Inverse: api/rules/multiplicative_inverse.md - Restate Subtraction: api/rules/restate_subtraction.md - Variable Multiply: api/rules/variable_multiply.md From 996dce14fb8071f89544d9175aa6f355ddb9d5fd Mon Sep 17 00:00:00 2001 From: Justin DuJardin Date: Mon, 24 Feb 2025 10:19:26 -0800 Subject: [PATCH 14/17] docs: run website/tools/docs.sh to generate updated api docs - i don't remember having to do this. maybe automate it away in the future. --- website/docs/api/problems.md | 1 - website/docs/api/rule.md | 5 +- website/docs/api/rules/fraction_reduction.md | 63 ++++++++++ website/docs/api/util.md | 119 +++++++++++++++++++ 4 files changed, 186 insertions(+), 2 deletions(-) create mode 100644 website/docs/api/rules/fraction_reduction.md diff --git a/website/docs/api/problems.md b/website/docs/api/problems.md index f6fe0fd..d0043c9 100644 --- a/website/docs/api/problems.md +++ b/website/docs/api/problems.md @@ -147,7 +147,6 @@ gen_simplify_multiple_terms( num_terms: int, optional_var: bool = False, op: Optional[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, diff --git a/website/docs/api/rule.md b/website/docs/api/rule.md index b6a29ea..2b03cfa 100644 --- a/website/docs/api/rule.md +++ b/website/docs/api/rule.md @@ -51,6 +51,9 @@ Find all nodes in an expression that can have this rule applied to them. Each node is marked with it's token index in the expression, according to the visit strategy, and stored as `node.r_index` starting with index 0 +### maintains_variables +Whether this rule maintains the same variables in the expression. Rules +that change variables should return False. ### name Readable rule name used for debug rendering and description outputs ## ExpressionChangeRule @@ -80,4 +83,4 @@ ExpressionChangeRule.save_parent( ) -> 'ExpressionChangeRule' ``` Note the parent of the node being modified, and set it as the parent of the -rule output automatically. \ No newline at end of file +rule output automatically. diff --git a/website/docs/api/rules/fraction_reduction.md b/website/docs/api/rules/fraction_reduction.md new file mode 100644 index 0000000..581e0b1 --- /dev/null +++ b/website/docs/api/rules/fraction_reduction.md @@ -0,0 +1,63 @@ +```python + +import mathy_core.rules.fraction_reduction +``` +# Fraction Reduction + +The `Fraction Reduction` rule simplifies fractions by canceling out common factors in the numerator and denominator. This transformation reduces the complexity of expressions and produces equivalent fractions in their simplest form. + +This rule implements the mathematical principle that when a factor appears in both the numerator and denominator of a fraction, it can be canceled out: `(a × c) / (b × c) = a / b` + +## Operations + +**Numeric Fraction Reduction** + +This handles cases where the numerator and denominator contain common numeric factors. For example, `6/3` simplifies to `2`. + +**Variable Cancellation** + +When the same variable appears in both the numerator and denominator, the rule cancels them out: + +- If the powers are equal, the variable is removed completely +- If the powers differ, the variable remains with the difference of the exponents + +For example: + +- `(6x) / (3x)` simplifies to `2` +- `(6x^2) / (3x)` simplifies to `2x` + +**Coefficient Reduction** + +The rule also handles coefficient reduction in conjunction with variable cancellation. It first factors out common terms, then reduces the numerical coefficient to its simplest form. + +For example, `(3x^2) / (6x)` simplifies to `(1/2)x`. + +**Exponent Simplification** + +When variables with exponents appear in both the numerator and denominator, the rule simplifies by subtracting the exponents. + +For example, `x^4 / x^2` simplifies to `x^2`. + +## Implementation Details + +The rule creates a reduced fraction by: + +1. Identifying common factors in the numerator and denominator +2. Reducing the numeric coefficients to their simplest form using GCD +3. Handling variable terms by adjusting their exponents appropriately +4. Constructing a new expression with the simplified terms + +### Examples + +`rule_tests:fraction_reduction` + + +## API + + +## FractionReductionRule +```python +FractionReductionRule(self, args, kwargs) +``` +Reduce fractions by cancelling out common factors in the numerator and +denominator. diff --git a/website/docs/api/util.md b/website/docs/api/util.md index e87841f..c7ca5e5 100644 --- a/website/docs/api/util.md +++ b/website/docs/api/util.md @@ -21,6 +21,8 @@ compare_expression_string_values( from_expression: str, to_expression: str, history: Optional[List[Any]] = None, + env_name: Optional[str] = None, + enforce_unique_vars: bool = True, ) -> None ``` Compare and evaluate two expressions strings to verify they have the @@ -31,6 +33,8 @@ compare_expression_values( from_expression: mathy_core.expressions.MathExpression, to_expression: mathy_core.expressions.MathExpression, history: Optional[List[Any]] = None, + env_name: Optional[str] = None, + enforce_unique_vars: bool = True, ) -> None ``` Compare and evaluate two expressions to verify they have the same value @@ -48,6 +52,112 @@ accessible by key. That is, factoring 2 would return 2 : 1 } +## FactorResult +```python +FactorResult( + self, + best: Union[float, int] = -1, + left: Union[float, int] = -1, + right: Union[float, int] = -1, + all_left: Dict[Union[float, int], Union[float, int]] = , + all_right: Dict[Union[float, int], Union[float, int]] = , + variable: Optional[str] = None, + exponent: Optional[float, int] = None, + leftExponent: Optional[float, int] = None, + rightExponent: Optional[float, int] = None, + leftVariable: Optional[str] = None, + rightVariable: Optional[str] = None, +) -> None +``` +FactorResult(best: Union[float, int] = -1, left: Union[float, int] = -1, right: Union[float, int] = -1, all_left: Dict[Union[float, int], Union[float, int]] = , all_right: Dict[Union[float, int], Union[float, int]] = , variable: Optional[str] = None, exponent: Union[float, int, NoneType] = None, leftExponent: Union[float, int, NoneType] = None, rightExponent: Union[float, int, NoneType] = None, leftVariable: Optional[str] = None, rightVariable: Optional[str] = None) +### best +int([x]) -> integer +int(x, base=10) -> integer + +Convert a number or string to an integer, or return 0 if no arguments +are given. If x is a number, return x.__int__(). For floating point +numbers, this truncates towards zero. + +If x is not a number or if base is given, then x must be a string, +bytes, or bytearray instance representing an integer literal in the +given base. The literal can be preceded by '+' or '-' and be surrounded +by whitespace. The base defaults to 10. Valid bases are 0 and 2-36. +Base 0 means to interpret the base from the string as an integer literal. +>>> int('0b100', base=0) +4 +### left +int([x]) -> integer +int(x, base=10) -> integer + +Convert a number or string to an integer, or return 0 if no arguments +are given. If x is a number, return x.__int__(). For floating point +numbers, this truncates towards zero. + +If x is not a number or if base is given, then x must be a string, +bytes, or bytearray instance representing an integer literal in the +given base. The literal can be preceded by '+' or '-' and be surrounded +by whitespace. The base defaults to 10. Valid bases are 0 and 2-36. +Base 0 means to interpret the base from the string as an integer literal. +>>> int('0b100', base=0) +4 +### right +int([x]) -> integer +int(x, base=10) -> integer + +Convert a number or string to an integer, or return 0 if no arguments +are given. If x is a number, return x.__int__(). For floating point +numbers, this truncates towards zero. + +If x is not a number or if base is given, then x must be a string, +bytes, or bytearray instance representing an integer literal in the +given base. The literal can be preceded by '+' or '-' and be surrounded +by whitespace. The base defaults to 10. Valid bases are 0 and 2-36. +Base 0 means to interpret the base from the string as an integer literal. +>>> int('0b100', base=0) +4 +## FractionReductionResult +```python +FractionReductionResult( + self, + numerator: Union[float, int] = 1, + denominator: Union[float, int] = 1, + reduced_variable: Optional[str] = None, + reduced_exponent: Optional[float, int] = None, + common_variable: Optional[str] = None, + common_exponent: Optional[float, int] = None, +) -> None +``` +FractionReductionResult(numerator: Union[float, int] = 1, denominator: Union[float, int] = 1, reduced_variable: Optional[str] = None, reduced_exponent: Union[float, int, NoneType] = None, common_variable: Optional[str] = None, common_exponent: Union[float, int, NoneType] = None) +### denominator +int([x]) -> integer +int(x, base=10) -> integer + +Convert a number or string to an integer, or return 0 if no arguments +are given. If x is a number, return x.__int__(). For floating point +numbers, this truncates towards zero. + +If x is not a number or if base is given, then x must be a string, +bytes, or bytearray instance representing an integer literal in the +given base. The literal can be preceded by '+' or '-' and be surrounded +by whitespace. The base defaults to 10. Valid bases are 0 and 2-36. +Base 0 means to interpret the base from the string as an integer literal. +>>> int('0b100', base=0) +4 +### numerator +int([x]) -> integer +int(x, base=10) -> integer + +Convert a number or string to an integer, or return 0 if no arguments +are given. If x is a number, return x.__int__(). For floating point +numbers, this truncates towards zero. + +If x is not a number or if base is given, then x must be a string, +bytes, or bytearray instance representing an integer literal in the +given base. The literal can be preceded by '+' or '-' and be surrounded +by whitespace. The base defaults to 10. Valid bases are 0 and 2-36. +Base 0 means to interpret the base from the string as an integer literal. +>>> int('0b100', base=0) +4 ## get_term_ex ```python get_term_ex( @@ -140,6 +250,15 @@ __Examples__ - Simple = x^2 * 4 - Complex = 2 * 2x^2 +## make_term +```python +make_term( + coefficient: Union[float, int] = 1, + variable: Optional[str] = None, + exponent: Optional[float, int] = None, +) -> mathy_core.expressions.MathExpression +``` +Create a term node hierarchy from given parameters ## pad_array ```python pad_array(in_list: List[Any], max_length: int, value: Any = 0) -> List[Any] From 8407cd685d0f36ba5a7a398449e33a53ef083530 Mon Sep 17 00:00:00 2001 From: Justin DuJardin Date: Mon, 24 Feb 2025 10:30:06 -0800 Subject: [PATCH 15/17] chore: update copyright year --- website/mkdocs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/mkdocs.yml b/website/mkdocs.yml index 90bba29..ae41347 100644 --- a/website/mkdocs.yml +++ b/website/mkdocs.yml @@ -5,7 +5,7 @@ site_author: Justin DuJardin site_url: https://core.mathy.ai # Copyright -copyright: Copyright © 2011 - 2024 Justin DuJardin +copyright: Copyright © 2011 - 2025 Justin DuJardin repo_name: mathy/mathy_core repo_url: https://github.com/mathy/mathy_core From 5bc1419cbc2013b42d5317a31c3bc389a4d4b460 Mon Sep 17 00:00:00 2001 From: Justin DuJardin Date: Mon, 24 Feb 2025 12:37:51 -0800 Subject: [PATCH 16/17] feat(rule): ConstantsSimplify rule does not evaluate fractions by default BREAKING CHANGE: prior to this ConstantsSimplify would by default evaluate any fractions it found, yielding a single constant value. --- README.md | 2 +- mathy_core/rules/commutative_swap.test.json | 21 +++++++++++++------ mathy_core/rules/constants_simplify.py | 10 +++++++++ mathy_core/rules/constants_simplify.test.json | 9 +++++++- tests/test_parser.py | 2 ++ 5 files changed, 36 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 300ee7f..e429f88 100644 --- a/README.md +++ b/README.md @@ -1060,7 +1060,7 @@ For Multiplication: `a * b = b * a` ## ConstantsSimplifyRule class ```python (doc) -ConstantsSimplifyRule(self, args, kwargs) +ConstantsSimplifyRule(self, evaluate_fractions: bool = False) ``` Given a binary operation on two constants, simplify to the resulting diff --git a/mathy_core/rules/commutative_swap.test.json b/mathy_core/rules/commutative_swap.test.json index ff45ddf..109f6dc 100644 --- a/mathy_core/rules/commutative_swap.test.json +++ b/mathy_core/rules/commutative_swap.test.json @@ -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", @@ -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", @@ -138,4 +147,4 @@ "input": "7 / x" } ] -} \ No newline at end of file +} diff --git a/mathy_core/rules/constants_simplify.py b/mathy_core/rules/constants_simplify.py index b67d7c3..470de7b 100644 --- a/mathy_core/rules/constants_simplify.py +++ b/mathy_core/rules/constants_simplify.py @@ -4,6 +4,7 @@ AddExpression, BinaryExpression, ConstantExpression, + DivideExpression, MathExpression, MultiplyExpression, NegateExpression, @@ -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" @@ -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 diff --git a/mathy_core/rules/constants_simplify.test.json b/mathy_core/rules/constants_simplify.test.json index d70ba1d..b9e543f 100644 --- a/mathy_core/rules/constants_simplify.test.json +++ b/mathy_core/rules/constants_simplify.test.json @@ -80,7 +80,10 @@ }, { "input": "4 / 2", - "output": "2" + "output": "2", + "args": { + "evaluate_fractions": true + } }, { "input": "5 * 5", @@ -92,6 +95,10 @@ } ], "invalid": [ + { + "input": "(15/6)h", + "why": "doesn't simplify fractions by default" + }, { "input": "13f^4 * (5f^4 + 7)", "why": "can't simplify nested child because the nested group has a different priority" diff --git a/tests/test_parser.py b/tests/test_parser.py index 877890f..9311d73 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -15,6 +15,8 @@ @pytest.mark.parametrize( "expectation", [ + {"input": "a * (5 + 12)", "output": "a * (5 + 12)"}, + {"input": "(6 / 7)k^3", "output": "(6/7)k^3"}, {"input": "(1 / 2)x", "output": "(1/2)x"}, {"input": "(3x^2) / (6x)", "output": "(3x^2) / 6x"}, {"input": "4x * p^(1 + 3) * 12x^2", "output": "4x * p^(1 + 3) * 12x^2"}, From 6080631603b78e8b792d6b86a50622a6fcbf8531 Mon Sep 17 00:00:00 2001 From: Justin DuJardin Date: Mon, 24 Feb 2025 12:57:37 -0800 Subject: [PATCH 17/17] chore: fix issue dropping variables only in the numerator --- mathy_core/expressions.py | 19 ++++++++----------- mathy_core/parser.py | 17 +++++++++-------- mathy_core/rules/fraction_reduction.test.json | 12 ++++++++++++ mathy_core/util.py | 15 ++++++++++++++- 4 files changed, 43 insertions(+), 20 deletions(-) diff --git a/mathy_core/expressions.py b/mathy_core/expressions.py index aa06ada..960050e 100644 --- a/mathy_core/expressions.py +++ b/mathy_core/expressions.py @@ -580,12 +580,18 @@ def __str__(self) -> str: 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 cases + # Handle fraction * variable or fraction * power cases if isinstance(left, DivideExpression): - if isinstance(right, (VariableExpression, PowerExpression)): + # 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 @@ -599,15 +605,6 @@ def __str__(self) -> str: 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() - class DivideExpression(BinaryExpression): """Divide one by two""" diff --git a/mathy_core/parser.py b/mathy_core/parser.py index 89347fe..36c942f 100644 --- a/mathy_core/parser.py +++ b/mathy_core/parser.py @@ -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 @@ -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: diff --git a/mathy_core/rules/fraction_reduction.test.json b/mathy_core/rules/fraction_reduction.test.json index f8e7bbc..c8b2875 100644 --- a/mathy_core/rules/fraction_reduction.test.json +++ b/mathy_core/rules/fraction_reduction.test.json @@ -1,5 +1,17 @@ { "valid": [ + { + "why": "Training corner case regression", + "input": "9n + (8/10)n", + "output": "9n + (4/5)n", + "debug": true + }, + { + "why": "Training corner case regression", + "input": "9n + 8n / 10", + "output": "9n + (4/5)n", + "debug": true + }, { "why": "Simple numeric fraction", "input": "(6x) / (3x)", diff --git a/mathy_core/util.py b/mathy_core/util.py index 1ff2570..73facf5 100644 --- a/mathy_core/util.py +++ b/mathy_core/util.py @@ -57,7 +57,7 @@ def raise_with_history( history_text: List[str] = [] if history is not None: - history_text = [f" - {h.raw}" for h in history] + history_text = [f"[{h.action}] - {h.raw}" for h in history] history_text.insert(0, description) tb = TracebackPrinter() error = tb(title, "\n".join(history_text), tb=traceback.extract_stack()) @@ -738,6 +738,7 @@ def factor_fraction_terms_ex( # Handle variables and exponents if has_left and has_right and left_term.variable == right_term.variable: + # Both terms have the same variable result.common_variable = left_term.variable left_exp = left_term.exponent if left_term.exponent is not None else 1 @@ -752,6 +753,18 @@ def factor_fraction_terms_ex( result.reduced_exponent = reduced_exp result.common_exponent = min(left_exp, right_exp) + elif has_left and not has_right: + # Only numerator has a variable - preserve it + result.reduced_variable = left_term.variable + result.reduced_exponent = ( + left_term.exponent if left_term.exponent is not None else 1 + ) + elif not has_left and has_right: + # Only denominator has a variable + result.reduced_variable = right_term.variable + result.reduced_exponent = -( + right_term.exponent if right_term.exponent is not None else 1 + ) elif not (result.numerator != result.denominator): return False