diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a107f03..46e48a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,3 +50,4 @@ jobs: - run: uv tool install --force --reinstall --refresh . - run: phil ':version' - run: phil 'd(sin(x))/dx' + - run: phil "linalg det A=[[1,2],[3,4]]" diff --git a/.gitignore b/.gitignore index bcc8fa7..45862f7 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ __pycache__/ .hypothesis/ dist/ .DS_Store +AGENT_STATE.md diff --git a/README.md b/README.md index 4151474..fa4a830 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,10 @@ phil "ode y' = y" phil "ode y' = y, y(0)=1" phil "linalg solve A=[[2,1],[1,3]] b=[1,2]" phil "linalg rref A=[[1,2],[2,4]]" +phil "linalg det A=[[1,2],[3,4]]" +phil "linalg inv A=[[1,2],[3,4]]" +phil "linalg eig A=[[1,2],[3,4]]" +phil "linalg nullspace A=[[1,2],[2,4]]" phil :examples phil :tutorial phil :ode @@ -229,13 +233,13 @@ $ phil --format pretty 'Matrix([[1,2],[3,4]])' | Integer GCD/LCM | `gcd(a, b)`, `lcm(a, b)` | | Primality / factorization | `isprime(n)`, `factorint(n)` | | Rational parts | `num(expr)`, `den(expr)` | -| Matrix determinant | `det(Matrix([[...]]))` | -| Matrix inverse | `inv(Matrix([[...]]))` | -| Matrix rank | `rank(Matrix([[...]]))` | -| Matrix eigenvalues | `eigvals(Matrix([[...]]))` | -| Matrix RREF | `rref(Matrix([[...]]))` | -| Matrix nullspace | `nullspace(Matrix([[...]]))` | -| Solve linear system (Ax=b) | `msolve(Matrix([[...]]), Matrix([...]))` | +| Matrix determinant | `det(Matrix([[...]]))` or `linalg det A=[[...]]` | +| Matrix inverse | `inv(Matrix([[...]]))` or `linalg inv A=[[...]]` | +| Matrix rank | `rank(Matrix([[...]]))` or `linalg rank A=[[...]]` | +| Matrix eigenvalues | `eigvals(Matrix([[...]]))` or `linalg eig A=[[...]]` | +| Matrix RREF | `rref(Matrix([[...]]))` or `linalg rref A=[[...]]` | +| Matrix nullspace | `nullspace(Matrix([[...]]))` or `linalg nullspace A=[[...]]` | +| Solve linear system (Ax=b) | `msolve(Matrix([[...]]), Matrix([...]))` or `linalg solve A=[[...]] b=[...]` | | Symbolic linear solve | `linsolve((Eq(...), Eq(...)), (x, y))` | ### Common symbols diff --git a/src/calc/cli.py b/src/calc/cli.py index aad0a21..7bf68c8 100644 --- a/src/calc/cli.py +++ b/src/calc/cli.py @@ -10,7 +10,7 @@ from urllib.parse import quote_plus from urllib.request import urlopen -from sympy import Eq +from sympy import Eq, zoo from sympy.matrices.matrixbase import MatrixBase from .core import evaluate, normalize_expression @@ -98,7 +98,7 @@ def _calc_version() -> str: " calculus: d(expr, var), int(expr, var)\n" " equations: solve(expr, var), Eq(lhs, rhs)\n" " exact helpers: gcd, lcm, isprime, factorint, num, den\n" - " linear algebra: linalg solve A=[[...]] b=[...], linalg rref A=[[...]]\n" + " linear algebra: linalg solve A=[[...]] b=[...], linalg rref/det/inv/rank/nullspace/eig A=[[...]]\n" " ODE shortcut: ode y' = y, y(0)=1\n" " runnable patterns: :examples\n" " guided onboarding: :tutorial or :t" @@ -195,12 +195,17 @@ def _calc_version() -> str: ) LINALG_TEXT = ( "linear algebra quick reference:\n" - " quick start:\n" + " solve Ax=b:\n" " linalg solve A=[[2,1],[1,3]] b=[1,2]\n" + " row reduction:\n" " linalg rref A=[[1,2],[2,4]]\n" - " msolve(Matrix([[2,1],[1,3]]), Matrix([1,2]))\n" - " rref(Matrix([[1,2],[2,4]]))\n" - " nullspace(Matrix([[1,2],[2,4]]))\n" + " determinant / inverse / rank:\n" + " linalg det A=[[1,2],[3,4]]\n" + " linalg inv A=[[1,2],[3,4]]\n" + " linalg rank A=[[1,2],[2,4]]\n" + " eigenvalues / nullspace:\n" + " linalg eig A=[[1,2],[3,4]]\n" + " linalg nullspace A=[[1,2],[2,4]]\n" "\n" "symbolic systems:\n" " linsolve((Eq(2*x + y, 1), Eq(x + 3*y, 2)), (x, y))\n" @@ -492,7 +497,35 @@ def _evaluate_linalg_alias( parsed_expr = f"rref(Matrix({matrix_text}))" return result, parsed_expr - raise ValueError("unknown linalg subcommand; use 'solve' or 'rref'") + if subcommand in ("det", "inv", "rank", "nullspace", "eig", "eigvals"): + params = _parse_linalg_keyed_literals(rest, {"A"}) + matrix_text = params["A"] + matrix_value = evaluate( + f"Matrix({matrix_text})", + relaxed=relaxed, + session_locals=session_locals, + simplify_output=simplify_output, + ) + if not isinstance(matrix_value, MatrixBase): + raise ValueError(f"linalg {subcommand} expects a matrix literal for A") + if subcommand == "det": + result = matrix_value.det() + parsed_expr = f"det(Matrix({matrix_text}))" + elif subcommand == "inv": + result = matrix_value.inv() + parsed_expr = f"inv(Matrix({matrix_text}))" + elif subcommand == "rank": + result = matrix_value.rank() + parsed_expr = f"rank(Matrix({matrix_text}))" + elif subcommand == "nullspace": + result = matrix_value.nullspace() + parsed_expr = f"nullspace(Matrix({matrix_text}))" + else: # eig / eigvals + result = matrix_value.eigenvals() + parsed_expr = f"eigvals(Matrix({matrix_text}))" + return result, parsed_expr + + raise ValueError("unknown linalg subcommand; use: solve, rref, det, inv, rank, nullspace, eig") def _latest_pypi_version() -> str | None: @@ -684,6 +717,11 @@ def _execute_expression( ) rendered = _render_value(value, format_mode=format_mode, expr=expr, relaxed=relaxed) print(rendered) + if value is zoo: + print( + _style("hint: zoo = complex infinity; the expression is undefined (e.g. division by zero)", color="yellow", stream=sys.stderr, color_mode=color_mode), + file=sys.stderr, + ) if always_wa or _is_complex_expression(expr): _print_wolfram_hint(expr, copy_link=copy_wa, color_mode=color_mode) diff --git a/src/calc/core.py b/src/calc/core.py index 2146bdd..66d5bc0 100644 --- a/src/calc/core.py +++ b/src/calc/core.py @@ -432,7 +432,7 @@ def normalize_expression(expression: str, relaxed: bool = False) -> str: def _evaluate_parsed(parsed, simplify_output: bool): if isinstance(parsed, (list, tuple, dict)): return parsed - if simplify_output: + if simplify_output and isinstance(parsed, _sympy.Basic): return simplify(parsed) return parsed diff --git a/src/calc/diagnostics.py b/src/calc/diagnostics.py index 7f00345..8305381 100644 --- a/src/calc/diagnostics.py +++ b/src/calc/diagnostics.py @@ -114,7 +114,7 @@ def hint_for_error(message: str, expr: str | None = None, session_locals: dict | return "den syntax: den(expr) (for example den(3/14))" if text.startswith("linalg ") or text.startswith("unknown linalg "): - return "linalg syntax: 'linalg solve A=[[...]] b=[...]' or 'linalg rref A=[[...]]'; use :linalg" + return "linalg syntax: 'linalg solve A=[[...]] b=[...]', 'linalg rref A=[[...]]', 'linalg det/inv/rank/eig/nullspace A=[[...]]'; use :linalg" if "unexpected eof" in text: if expr and ("/d" in expr or expr.strip().startswith("d(")): return "derivative syntax: d(expr, var) or d(sin(x))/dx or df(t)/dt" diff --git a/tests/test_cli.py b/tests/test_cli.py index 7d9aafe..d2cc0bc 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -440,7 +440,9 @@ def test_cli_linalg_shortcut(): proc = run_cli(":linalg") assert proc.returncode == 0 assert "linear algebra quick reference:" in proc.stdout - assert "msolve(Matrix([[2,1],[1,3]]), Matrix([1,2]))" in proc.stdout + assert "linalg solve A=[[2,1],[1,3]] b=[1,2]" in proc.stdout + assert "linalg eig A=[[1,2],[3,4]]" in proc.stdout + assert "linalg nullspace A=[[1,2],[2,4]]" in proc.stdout proc = run_cli(":la") assert proc.returncode == 0 @@ -508,6 +510,66 @@ def test_cli_linalg_alias_rejects_malformed_comma_separators(): assert "must use '='" in proc.stderr +def test_cli_linalg_alias_det(): + proc = run_cli("linalg det A=[[1,2],[3,4]]") + assert proc.returncode == 0 + assert "-2" in proc.stdout + + +def test_cli_linalg_alias_inv(): + proc = run_cli("linalg inv A=[[1,2],[3,4]]") + assert proc.returncode == 0 + assert "Matrix" in proc.stdout + assert "-2" in proc.stdout + + +def test_cli_linalg_alias_rank(): + proc = run_cli("linalg rank A=[[1,2],[2,4]]") + assert proc.returncode == 0 + assert proc.stdout.strip() == "1" + + +def test_cli_linalg_alias_eig(): + proc = run_cli("linalg eig A=[[1,0],[0,2]]") + assert proc.returncode == 0 + assert "1" in proc.stdout + assert "2" in proc.stdout + + +def test_cli_linalg_alias_nullspace(): + proc = run_cli("linalg nullspace A=[[1,2],[2,4]]") + assert proc.returncode == 0 + assert "Matrix" in proc.stdout + + +def test_cli_linalg_alias_unknown_subcommand(): + proc = run_cli("linalg foo A=[[1,2],[3,4]]") + assert proc.returncode == 1 + assert "unknown linalg subcommand" in proc.stderr + + +def test_cli_simplify_non_basic_no_crash(): + # Regression: typing a bare helper name (like S) used to crash with + # Atom._eval_simplify() missing argument. It should now return gracefully. + proc = run_cli("S") + assert proc.returncode == 0 + + +def test_cli_zoo_hint(): + proc = run_cli("1/0") + assert proc.returncode == 0 + assert "zoo" in proc.stdout + assert "complex infinity" in proc.stderr + + +def test_cli_zoo_hint_decimal_division(): + # Decimal inputs are rationalized, so 0.1+0.2-0.3 == 0 exactly. + proc = run_cli("1/(0.1 + 0.2 - 0.3)") + assert proc.returncode == 0 + assert "zoo" in proc.stdout + assert "complex infinity" in proc.stderr + + def test_repl_help_and_quit(): proc = subprocess.run( [sys.executable, "-m", "calc"], diff --git a/tests/test_cli_unit.py b/tests/test_cli_unit.py index 06bb60e..4ba04d4 100644 --- a/tests/test_cli_unit.py +++ b/tests/test_cli_unit.py @@ -259,6 +259,63 @@ def test_evaluate_linalg_alias_errors(): cli._evaluate_linalg_alias("linalg solve A,=[[1,0],[0,1]] b=[1,2]", relaxed=True, simplify_output=True, session_locals={}) +def test_evaluate_linalg_alias_det(): + value, parsed = cli._evaluate_linalg_alias( + "linalg det A=[[1,2],[3,4]]", relaxed=True, simplify_output=True, session_locals={} + ) + assert str(value) == "-2" + assert parsed == "det(Matrix([[1,2],[3,4]]))" + + +def test_evaluate_linalg_alias_inv(): + value, parsed = cli._evaluate_linalg_alias( + "linalg inv A=[[1,2],[3,4]]", relaxed=True, simplify_output=True, session_locals={} + ) + assert "Matrix" in str(value) + assert parsed == "inv(Matrix([[1,2],[3,4]]))" + + +def test_evaluate_linalg_alias_rank(): + value, parsed = cli._evaluate_linalg_alias( + "linalg rank A=[[1,2],[2,4]]", relaxed=True, simplify_output=True, session_locals={} + ) + assert value == 1 + assert parsed == "rank(Matrix([[1,2],[2,4]]))" + + +def test_evaluate_linalg_alias_nullspace(): + value, parsed = cli._evaluate_linalg_alias( + "linalg nullspace A=[[1,2],[2,4]]", relaxed=True, simplify_output=True, session_locals={} + ) + assert isinstance(value, list) + assert parsed == "nullspace(Matrix([[1,2],[2,4]]))" + + +def test_evaluate_linalg_alias_eig(): + value, parsed = cli._evaluate_linalg_alias( + "linalg eig A=[[1,0],[0,2]]", relaxed=True, simplify_output=True, session_locals={} + ) + assert isinstance(value, dict) + assert parsed == "eigvals(Matrix([[1,0],[0,2]]))" + + +def test_execute_expression_zoo_hint(capsys): + cli._execute_expression( + "1/0", + format_mode="plain", + relaxed=True, + simplify_output=True, + explain_parse=False, + always_wa=False, + copy_wa=False, + color_mode="never", + session_locals={}, + ) + captured = capsys.readouterr() + assert "zoo" in captured.out + assert "complex infinity" in captured.err + + def test_evaluate_linalg_alias_rref_rejects_non_matrix(monkeypatch): monkeypatch.setattr(cli, "evaluate", lambda *a, **k: 123) with pytest.raises(ValueError, match="matrix literal for A"):