Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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]]"
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ __pycache__/
.hypothesis/
dist/
.DS_Store
AGENT_STATE.md
18 changes: 11 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
52 changes: 45 additions & 7 deletions src/calc/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion src/calc/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion src/calc/diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
64 changes: 63 additions & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"],
Expand Down
57 changes: 57 additions & 0 deletions tests/test_cli_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down
Loading