mathslate 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mathslate/__init__.py ADDED
@@ -0,0 +1,140 @@
1
+ """MathSlate — a mathematical workspace that grows with you.
2
+
3
+ from mathslate import *
4
+ plot(sin(x)/x)
5
+
6
+ Everything symbolic is SymPy's, unwrapped and re-exported. MathSlate adds
7
+ eight callables and one guarantee: the graph is right, and you can always ask
8
+ it what the plain Python was.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ # --- SymPy re-exports (zero MathSlate code) -------------------------------
14
+ # PRD 6.3: thin wrappers around identically named SymPy functions are not
15
+ # wrapped at all. What follows is a plain re-export.
16
+ import sympy as sympy
17
+ from sympy import ( # noqa: F401
18
+ E,
19
+ Abs,
20
+ Eq,
21
+ Float,
22
+ Function,
23
+ I,
24
+ Integer,
25
+ Matrix,
26
+ Piecewise,
27
+ Rational,
28
+ Sum,
29
+ Symbol,
30
+ acos,
31
+ apart,
32
+ asin,
33
+ atan,
34
+ binomial,
35
+ cancel,
36
+ cbrt,
37
+ ceiling,
38
+ cos,
39
+ cosh,
40
+ cot,
41
+ csc,
42
+ diff,
43
+ exp,
44
+ expand,
45
+ factor,
46
+ factorial,
47
+ floor,
48
+ gcd,
49
+ integrate,
50
+ lambdify,
51
+ limit,
52
+ log,
53
+ nsimplify,
54
+ nsolve,
55
+ oo,
56
+ pi,
57
+ root,
58
+ sec,
59
+ series,
60
+ sign,
61
+ simplify,
62
+ sin,
63
+ sinh,
64
+ solve,
65
+ solveset,
66
+ sqrt,
67
+ symbols,
68
+ tan,
69
+ tanh,
70
+ together,
71
+ trigsimp,
72
+ )
73
+
74
+ from . import errors as errors
75
+ from .api import (
76
+ analyze,
77
+ animate,
78
+ dataset,
79
+ frontend_report,
80
+ get_range_controls,
81
+ get_verbose,
82
+ plot,
83
+ polar,
84
+ set_range_controls,
85
+ set_verbose,
86
+ show_python,
87
+ slider,
88
+ table,
89
+ )
90
+ from .core.analysis import Analysis
91
+ from .core.data import Dataset
92
+ from .core.tables import Table
93
+ from .result import PlotResult
94
+
95
+ __version__: str = "0.1.0"
96
+
97
+ # --- predefined symbols (PRD 6.3) -----------------------------------------
98
+ # Open decision 3: `import *` is permitted so that these exist, but
99
+ # show_python() always emits the explicit `x = sp.symbols('x')`.
100
+ x, y, z, t, n, k = symbols("x y z t n k", real=True)
101
+ theta = Symbol("theta", real=True)
102
+
103
+ #: New public symbols introduced by MathSlate — the API-surface budget.
104
+ NEW_API: tuple[str, ...] = (
105
+ "plot",
106
+ "polar",
107
+ "slider",
108
+ "animate",
109
+ "table",
110
+ "analyze",
111
+ "show_python",
112
+ "dataset",
113
+ "set_verbose",
114
+ "get_verbose",
115
+ "set_range_controls",
116
+ "get_range_controls",
117
+ "frontend_report",
118
+ "PlotResult",
119
+ "Analysis",
120
+ "Table",
121
+ "Dataset",
122
+ )
123
+
124
+ __all__ = [
125
+ # MathSlate
126
+ *NEW_API,
127
+ "errors",
128
+ "sympy",
129
+ "__version__",
130
+ # predefined symbols
131
+ "x", "y", "z", "t", "n", "k", "theta",
132
+ # SymPy re-exports
133
+ "E", "Abs", "Eq", "Float", "Function", "I", "Integer", "Matrix", "Piecewise",
134
+ "Rational", "Sum", "Symbol", "acos", "apart", "asin", "atan", "binomial",
135
+ "cancel", "cbrt", "ceiling", "cos", "cosh", "cot", "csc", "diff", "exp",
136
+ "expand", "factor", "factorial", "floor", "gcd", "integrate", "lambdify",
137
+ "limit", "log", "nsimplify", "nsolve", "oo", "pi", "root", "sec", "series",
138
+ "sign", "simplify", "sin", "sinh", "solve", "solveset", "sqrt", "symbols",
139
+ "tan", "tanh", "together", "trigsimp",
140
+ ]
mathslate/_text.py ADDED
@@ -0,0 +1,64 @@
1
+ """Printing that survives a non-UTF-8 console.
2
+
3
+ MathSlate's inference report and π tick labels are nicer with real Unicode,
4
+ and notebooks render it happily. A Windows terminal on a legacy code page
5
+ (cp949, cp1252, …) raises ``UnicodeEncodeError`` instead — which would turn a
6
+ learner's very first ``plot()`` into a traceback. So: keep the Unicode, and
7
+ transliterate only when the destination cannot take it.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import sys
13
+ from typing import Final, TextIO
14
+
15
+ __all__ = ["to_ascii", "encodable", "safe_print"]
16
+
17
+ _REPLACEMENTS: Final[tuple[tuple[str, str], ...]] = (
18
+ ("∈", "in"), # ∈
19
+ ("·", "-"), # ·
20
+ ("—", "--"), # —
21
+ ("–", "-"), # –
22
+ ("→", "->"), # →
23
+ ("π", "pi"), # π
24
+ ("θ", "theta"), # θ
25
+ ("φ", "phi"), # φ
26
+ ("≤", "<="), # ≤
27
+ ("≥", ">="), # ≥
28
+ ("×", "x"), # ×
29
+ ("∞", "inf"), # ∞
30
+ )
31
+
32
+
33
+ def to_ascii(text: str) -> str:
34
+ """Replace MathSlate's decorative Unicode with plain ASCII equivalents."""
35
+ for source, target in _REPLACEMENTS:
36
+ text = text.replace(source, target)
37
+ return text.encode("ascii", "replace").decode("ascii")
38
+
39
+
40
+ def encodable(text: str, stream: TextIO) -> bool:
41
+ """Can ``stream`` represent ``text`` without loss?"""
42
+ encoding = getattr(stream, "encoding", None)
43
+ if not encoding:
44
+ return True
45
+ try:
46
+ text.encode(encoding)
47
+ except (UnicodeEncodeError, LookupError):
48
+ return False
49
+ return True
50
+
51
+
52
+ def safe_print(text: str, stream: TextIO | None = None) -> None:
53
+ """``print`` that transliterates rather than raising or printing mojibake.
54
+
55
+ Checking up front matters: a console configured with ``errors='replace'``
56
+ does not raise, it silently prints question marks.
57
+ """
58
+ target = stream if stream is not None else sys.stdout
59
+ if not encodable(text, target):
60
+ text = to_ascii(text)
61
+ try:
62
+ print(text, file=target)
63
+ except UnicodeEncodeError:
64
+ print(to_ascii(text), file=target)
@@ -0,0 +1,51 @@
1
+ """Natural language → MathSlate code (PRD 6.1, 7 v1.0).
2
+
3
+ from mathslate.ai import ask
4
+ print(ask("plot the tangent over one period").code)
5
+
6
+ Three rules, and the first two are absolute.
7
+
8
+ **The core never imports this package.** PRD §8 lists "AI dependency blocked on
9
+ school or corporate networks" as a risk whose mitigation is that the core must
10
+ work fully offline and the AI ships as extras. ``import mathslate`` does not
11
+ reach this module, and this module's provider libraries are imported lazily
12
+ inside the adapter that needs them, so nothing here can make a plain install
13
+ heavier or a school network a problem. ``tests/test_ai.py`` asserts it.
14
+
15
+ **It returns code; it does not run it.** A model writing Python and the library
16
+ executing it unseen is not something a learner can check, and checking is the
17
+ whole point of this project. :class:`Suggestion` holds the source and prints it.
18
+ An explicit :meth:`Suggestion.run` validates a small MathSlate-oriented AST,
19
+ uses restricted builtins, and runs under a wall-clock budget so an
20
+ allowlisted-but-runaway expression cannot hang the caller; unrestricted Python
21
+ requires ``unsafe=True``.
22
+
23
+ **No provider is bundled.** :func:`ask` resolves one at call time from what is
24
+ installed and configured, and says exactly what to install when it finds
25
+ nothing. Resolving PRD §9 open decision 4: the layer lives in the core package
26
+ as extras, not as a separate distribution — it is a few hundred lines and a
27
+ separate package would cost more in version skew than it saves.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from .providers import (
33
+ PROVIDERS,
34
+ Provider,
35
+ available_providers,
36
+ resolve_provider,
37
+ )
38
+ from .suggest import Suggestion, ask, configure, configured, forget, system_prompt
39
+
40
+ __all__ = [
41
+ "ask",
42
+ "configure",
43
+ "configured",
44
+ "forget",
45
+ "Suggestion",
46
+ "Provider",
47
+ "PROVIDERS",
48
+ "available_providers",
49
+ "resolve_provider",
50
+ "system_prompt",
51
+ ]
@@ -0,0 +1,231 @@
1
+ """The three backends, behind one small protocol.
2
+
3
+ Each adapter is the least code that turns "a system prompt and a question" into
4
+ "some text". Provider SDKs are imported **inside** the call, never at module
5
+ scope, so importing :mod:`mathslate.ai` costs nothing and a missing SDK is a
6
+ sentence rather than an ImportError from three frames down.
7
+
8
+ Model names are defaults, not decisions: every provider moves faster than a
9
+ release cycle, so ``ask(..., model=...)`` overrides and the default is only
10
+ what a first-time caller gets.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ from dataclasses import dataclass
17
+ from typing import Callable, Protocol
18
+
19
+ from ..errors import UnsupportedInputError
20
+
21
+ __all__ = [
22
+ "Provider",
23
+ "Backend",
24
+ "PROVIDERS",
25
+ "available_providers",
26
+ "resolve_provider",
27
+ ]
28
+
29
+ #: How many tokens a suggestion may take. MathSlate answers are short.
30
+ MAX_TOKENS: int = 1500
31
+
32
+
33
+ class Backend(Protocol):
34
+ """What a provider adapter has to be able to do."""
35
+
36
+ def complete(self, system: str, question: str, model: str) -> str:
37
+ """Return the model's reply to ``question`` under ``system``."""
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class Provider:
42
+ """One named backend: what to install, what to set, and what it defaults to."""
43
+
44
+ name: str
45
+ #: The import path, for detection.
46
+ package: str
47
+ #: What to type after `pip install` — not always the import path.
48
+ pip_name: str
49
+ env_var: str
50
+ default_model: str
51
+ build: Callable[[str | None], Backend]
52
+
53
+ def installed(self) -> bool:
54
+ import importlib.util
55
+
56
+ try:
57
+ return importlib.util.find_spec(self.package) is not None
58
+ except (ImportError, ModuleNotFoundError, ValueError):
59
+ # `find_spec("google.genai")` raises rather than returning None when
60
+ # the parent package is absent, which is the ordinary case here.
61
+ return False
62
+
63
+ def configured(self) -> bool:
64
+ return bool(os.environ.get(self.env_var))
65
+
66
+ def ready(self) -> bool:
67
+ return self.installed() and self.configured()
68
+
69
+ def describe(self) -> str:
70
+ if not self.installed():
71
+ return f"{self.name}: not installed (pip install {self.pip_name})"
72
+ if not self.configured():
73
+ return f"{self.name}: installed, but {self.env_var} is not set"
74
+ return f"{self.name}: ready"
75
+
76
+
77
+ # --------------------------------------------------------------------------
78
+ # adapters
79
+ # --------------------------------------------------------------------------
80
+
81
+
82
+ class _Anthropic:
83
+ def __init__(self, api_key: str | None = None) -> None:
84
+ self._api_key = api_key
85
+
86
+ def complete(self, system: str, question: str, model: str) -> str:
87
+ import anthropic
88
+
89
+ client = anthropic.Anthropic(api_key=self._api_key or None)
90
+ reply = client.messages.create(
91
+ model=model,
92
+ max_tokens=MAX_TOKENS,
93
+ system=system,
94
+ messages=[{"role": "user", "content": question}],
95
+ )
96
+ return "".join(
97
+ block.text for block in reply.content if getattr(block, "type", "") == "text"
98
+ )
99
+
100
+
101
+ class _OpenAI:
102
+ def __init__(self, api_key: str | None = None) -> None:
103
+ self._api_key = api_key
104
+
105
+ def complete(self, system: str, question: str, model: str) -> str:
106
+ import openai
107
+
108
+ client = openai.OpenAI(api_key=self._api_key or None)
109
+ reply = client.chat.completions.create(
110
+ model=model,
111
+ max_completion_tokens=MAX_TOKENS,
112
+ messages=[
113
+ {"role": "system", "content": system},
114
+ {"role": "user", "content": question},
115
+ ],
116
+ )
117
+ return reply.choices[0].message.content or ""
118
+
119
+
120
+ class _Gemini:
121
+ def __init__(self, api_key: str | None = None) -> None:
122
+ self._api_key = api_key
123
+
124
+ def complete(self, system: str, question: str, model: str) -> str:
125
+ from google import genai
126
+ from google.genai import types
127
+
128
+ client = genai.Client(api_key=self._api_key or None)
129
+ reply = client.models.generate_content(
130
+ model=model,
131
+ contents=question,
132
+ config=types.GenerateContentConfig(
133
+ system_instruction=system, max_output_tokens=MAX_TOKENS
134
+ ),
135
+ )
136
+ return reply.text or ""
137
+
138
+
139
+ #: In preference order. Claude first because MathSlate's own development is
140
+ #: done against it, so its behaviour on this prompt is the best understood.
141
+ PROVIDERS: tuple[Provider, ...] = (
142
+ Provider(
143
+ name="claude",
144
+ package="anthropic",
145
+ pip_name="anthropic",
146
+ env_var="ANTHROPIC_API_KEY",
147
+ default_model="claude-sonnet-5",
148
+ build=_Anthropic,
149
+ ),
150
+ Provider(
151
+ name="openai",
152
+ package="openai",
153
+ pip_name="openai",
154
+ env_var="OPENAI_API_KEY",
155
+ default_model="gpt-5",
156
+ build=_OpenAI,
157
+ ),
158
+ Provider(
159
+ name="gemini",
160
+ package="google.genai",
161
+ pip_name="google-genai",
162
+ env_var="GOOGLE_API_KEY",
163
+ default_model="gemini-2.5-pro",
164
+ build=_Gemini,
165
+ ),
166
+ )
167
+
168
+
169
+ def available_providers() -> tuple[Provider, ...]:
170
+ """Every provider that is both installed and configured."""
171
+ return tuple(provider for provider in PROVIDERS if provider.ready())
172
+
173
+
174
+ def resolve_provider(
175
+ name: str | None = None, *, api_key: str | None = None
176
+ ) -> Provider:
177
+ """Pick a provider, or explain precisely what is missing.
178
+
179
+ An explicitly supplied key is a complete credential. Requiring the same
180
+ key to also exist in an environment variable would make ``ask(api_key=...)``
181
+ and ``configure(api_key=...)`` unusable in notebooks.
182
+ """
183
+ if name is not None:
184
+ for provider in PROVIDERS:
185
+ if provider.name == name:
186
+ if not provider.installed():
187
+ raise UnsupportedInputError(
188
+ f"provider {name!r} needs its SDK: "
189
+ f"pip install {provider.pip_name}."
190
+ )
191
+ if not api_key and not provider.configured():
192
+ raise UnsupportedInputError(
193
+ f"provider {name!r} needs {provider.env_var} in the "
194
+ "environment or an explicit api_key=."
195
+ )
196
+ return provider
197
+ known = ", ".join(p.name for p in PROVIDERS)
198
+ raise UnsupportedInputError(f"unknown provider {name!r}; known: {known}.")
199
+
200
+ if api_key:
201
+ # A key is a credential for exactly one service, and nothing in the
202
+ # string reliably says which. Guessing would send the user's secret to
203
+ # a company it does not belong to, so an explicit key requires an
204
+ # explicit provider unless there is only one candidate.
205
+ candidates = tuple(p for p in PROVIDERS if p.installed())
206
+ if len(candidates) == 1:
207
+ return candidates[0]
208
+ if not candidates:
209
+ raise UnsupportedInputError(
210
+ "an api_key was given but no provider SDK is installed, so there "
211
+ "is nothing to send it to. Install one with "
212
+ "`pip install 'mathslate[ai]'`."
213
+ )
214
+ names = ", ".join(p.name for p in candidates)
215
+ raise UnsupportedInputError(
216
+ f"an explicit api_key needs an explicit provider: {names} are all "
217
+ "installed, and sending a key to the wrong one would hand your "
218
+ f"credential to the wrong company. Say which, e.g. "
219
+ f"provider='{candidates[0].name}'."
220
+ )
221
+
222
+ ready = available_providers()
223
+ if ready:
224
+ return ready[0]
225
+ status = "\n ".join(provider.describe() for provider in PROVIDERS)
226
+ raise UnsupportedInputError(
227
+ "no AI provider is ready. MathSlate itself needs none of this and works "
228
+ "offline; the assistant is an optional extra.\n"
229
+ f" {status}\n"
230
+ "Install one with `pip install 'mathslate[ai]'` and set its API key."
231
+ )