mathema 0.6.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.
Files changed (100) hide show
  1. mathema/__init__.py +806 -0
  2. mathema/_brute_force.py +154 -0
  3. mathema/_deprecation.py +49 -0
  4. mathema/_indexed.py +145 -0
  5. mathema/_math_vocab.py +181 -0
  6. mathema/_providers.py +100 -0
  7. mathema/_render_mode.py +41 -0
  8. mathema/_sampling.py +182 -0
  9. mathema/_scan.py +134 -0
  10. mathema/_shrink.py +77 -0
  11. mathema/_target_resolvers.py +51 -0
  12. mathema/_tier_text.py +114 -0
  13. mathema/_timeout.py +155 -0
  14. mathema/acceptance.py +1282 -0
  15. mathema/analysis.py +1019 -0
  16. mathema/audit.py +1365 -0
  17. mathema/auth.py +334 -0
  18. mathema/authoring.py +727 -0
  19. mathema/badges.py +870 -0
  20. mathema/centrality.py +119 -0
  21. mathema/claim_families.py +2762 -0
  22. mathema/cli.py +3055 -0
  23. mathema/compendium/__init__.py +544 -0
  24. mathema/compendium/export.py +189 -0
  25. mathema/compiled.py +190 -0
  26. mathema/concepts.py +298 -0
  27. mathema/conjecture.py +5050 -0
  28. mathema/corroboration.py +185 -0
  29. mathema/diagnostics.py +592 -0
  30. mathema/dimensions.py +241 -0
  31. mathema/docstring.py +932 -0
  32. mathema/domain.py +1820 -0
  33. mathema/equivalence.py +827 -0
  34. mathema/f.py +59 -0
  35. mathema/families.py +287 -0
  36. mathema/fetch.py +91 -0
  37. mathema/finite_sets.py +123 -0
  38. mathema/forms.py +210 -0
  39. mathema/gates.py +826 -0
  40. mathema/grammar.py +3057 -0
  41. mathema/hazards.py +730 -0
  42. mathema/identity.py +166 -0
  43. mathema/impl_coverage.py +457 -0
  44. mathema/intent.py +162 -0
  45. mathema/interfaces/__init__.py +15 -0
  46. mathema/interfaces/extension.py +165 -0
  47. mathema/interfaces/mcp/__init__.py +18 -0
  48. mathema/interfaces/mcp/resources.py +222 -0
  49. mathema/interfaces/mcp/server.py +132 -0
  50. mathema/interfaces/mcp/tools.py +685 -0
  51. mathema/interfaces/runtime.py +147 -0
  52. mathema/inventory.py +999 -0
  53. mathema/lexicon.py +1085 -0
  54. mathema/linalg.py +233 -0
  55. mathema/locks.py +126 -0
  56. mathema/matrices.py +388 -0
  57. mathema/moved.py +137 -0
  58. mathema/partiality.py +43 -0
  59. mathema/probing.py +1211 -0
  60. mathema/py.typed +0 -0
  61. mathema/reason_codes.py +936 -0
  62. mathema/records.py +321 -0
  63. mathema/representations.py +102 -0
  64. mathema/review.py +195 -0
  65. mathema/routes.py +232 -0
  66. mathema/spec.py +2297 -0
  67. mathema/suggest.py +727 -0
  68. mathema/symbolic/__init__.py +47 -0
  69. mathema/symbolic/_base.py +1154 -0
  70. mathema/symbolic/_conditioned.py +1037 -0
  71. mathema/symbolic/_coupled.py +205 -0
  72. mathema/symbolic/_dot.py +188 -0
  73. mathema/symbolic/_extensive.py +769 -0
  74. mathema/symbolic/_fold.py +917 -0
  75. mathema/symbolic/_forms.py +250 -0
  76. mathema/symbolic/_guard_points.py +184 -0
  77. mathema/symbolic/_loop_shapes.py +108 -0
  78. mathema/symbolic/_matrix.py +400 -0
  79. mathema/symbolic/_normalize.py +422 -0
  80. mathema/symbolic/_partiality.py +591 -0
  81. mathema/symbolic/_proof_support.py +2823 -0
  82. mathema/symbolic/_prove.py +3402 -0
  83. mathema/symbolic/_recurrence.py +275 -0
  84. mathema/symbolic/_residues.py +908 -0
  85. mathema/symbolic/_seq_common.py +698 -0
  86. mathema/symbolic/_smt.py +309 -0
  87. mathema/symbolic/_strategies.py +345 -0
  88. mathema/symbolic/_sum.py +584 -0
  89. mathema/sync.py +444 -0
  90. mathema/targets.py +306 -0
  91. mathema/tiers.py +268 -0
  92. mathema/types.py +527 -0
  93. mathema/verify.py +1109 -0
  94. mathema-0.6.0.dist-info/METADATA +487 -0
  95. mathema-0.6.0.dist-info/RECORD +100 -0
  96. mathema-0.6.0.dist-info/WHEEL +5 -0
  97. mathema-0.6.0.dist-info/entry_points.txt +2 -0
  98. mathema-0.6.0.dist-info/licenses/LICENSE.md +108 -0
  99. mathema-0.6.0.dist-info/licenses/LICENSING.md +67 -0
  100. mathema-0.6.0.dist-info/top_level.txt +1 -0
mathema/__init__.py ADDED
@@ -0,0 +1,806 @@
1
+ # SPDX-License-Identifier: BUSL-1.1
2
+ # Copyright 2026 Tetrion Ltd
3
+ """mathema: Claim-Driven Development, turning software intent into
4
+ verifiable evidence.
5
+
6
+ import mathema
7
+ r = mathema.check(my_function, claims=["f(-x) == -f(x)"])
8
+ print(r)
9
+ mathema.write_spec(my_function, claims=[...]) # also writes the record
10
+
11
+ State a claim, verify it against the real function, keep the record. Every
12
+ claim is adjudicated on one of two evidence routes: **derive** lifts the
13
+ function's body to a sympy expression and decides the claim algebraically
14
+ (`proven` is exact, not sampled, see `symbolic.py`), or **probe** calls the
15
+ real function on sampled/seeded inputs and checks the claim numerically
16
+ (`holds (n=...)`/`falsified`, see `probing.py`). A function that can't be
17
+ lifted (a loop, a non-scalar parameter, ...) still gets probe-route
18
+ coverage; derive is strictly stronger evidence where it applies, not a
19
+ replacement for probe. Every claim binds to the function's identity hashes
20
+ so a later change is caught, not silently inherited.
21
+
22
+ A bare string claim (as above) takes the `best` route: a symbolic proof
23
+ is attempted first, and a claim the proof cannot decide falls through to
24
+ probing, so the returned entry's `route` names the mechanism that settled
25
+ it. To pin the route, build the claim with `route="derive"` or
26
+ `route="probe"` and pass it in the same `claims=` list; `mathema.check()`
27
+ accepts a pre-built claim object exactly as it accepts a string, so
28
+ nothing else about the call changes:
29
+
30
+ r = mathema.check(my_function,
31
+ claims=[mathema.claims.claim("f(-x) == -f(x)", route="derive")])
32
+
33
+ A `proven` verdict routed `derive` means the proof decided it, never
34
+ sampling. When the proof cannot decide, the claim still falls through to
35
+ probing, and `Probe.meta["mathema.derive_status"]` on the returned entry
36
+ ("unliftable" vs. "undecided") records why the proof stopped;
37
+ see authoring.md's own claim-grammar section for the full grammar
38
+ (`d(...)`/`lim(...)`/`integrate(...)`/`Sum(...)`, domain quantifiers,
39
+ `raises(...)`) `route="derive"` understands. `mathema.claims` is this same
40
+ `claim()`/`check_conjectures()` pair exposed directly, for calling the
41
+ derive route standalone without `check()`'s own built-in-law probing:
42
+
43
+ results = mathema.claims.check_conjectures(
44
+ my_function, [mathema.claims.claim("f(-x) == -f(x)", route="derive")])
45
+
46
+ This package implements the Claim-Driven Development spec: see the sibling
47
+ `claim-driven-development` repo's `v0.2.0/` for the workflow definition and
48
+ the record schema this package reads and writes (`SPEC_VERSION` below
49
+ states which version).
50
+ """
51
+ from __future__ import annotations
52
+
53
+ from dataclasses import dataclass, field
54
+
55
+ from .analysis import Facts, SourceUnavailable, _parse_notes, analyze_source
56
+ # aliased on import: the bare name `claims` is already the conjecture-module
57
+ # alias below (`mathema.claims.check(...)`, load-bearing in README/tests), so
58
+ # the claims()-decorator is exposed as `claims_decorator` at this level,
59
+ # still directly importable as `from mathema.authoring import claims`.
60
+ from .authoring import (DomainError, claims as claims_decorator,
61
+ declared_from_function, enforce_dimensions, enforce_domain,
62
+ enforce_structure,
63
+ materialize_declared, parse_docstring_claims,
64
+ reject_missing, resolve_declared, retrieve)
65
+ from .claim_families import _register_builtin_claim_families
66
+ from .conjecture import Conjecture, check_conjectures, claim
67
+ from .diagnostics import critical_points
68
+ from .compiled import CompiledForm, compile_form, numeric_check
69
+ from .forms import (Form, SubstitutedForm, Substitution, closed_forms,
70
+ executable_forms, register_substitution,
71
+ substituted_forms)
72
+ from .identity import form_hash
73
+ from .grammar import get_unicode_output, set_unicode_output
74
+ from .probing import Probe, probe
75
+ from .reason_codes import Category, ClaimReasonCode, ReasonCode, issue
76
+ from .spec import SPEC_VERSION
77
+ from .targets import Target, TargetError, resolve, resolve_function
78
+ from .verify import GateReport, VerifyResult, gate, verify_project
79
+ from .suggest import suggest_claims
80
+ # exposed for direct, notebook-style exploration of what a function
81
+ # lifts to; `mathema.lift_symbolic`, not bare `lift`, since `lift` is
82
+ # too generic a name to claim at this module's own top level. See
83
+ # docs/authoring.md's "Exploring a function symbolically" section.
84
+ from .symbolic import lift as lift_symbolic
85
+ from .symbolic._dot import _register_dot_family
86
+ from .symbolic._fold import _register_fold_family
87
+ from .symbolic._sum import _register_sum_family
88
+
89
+ from . import conjecture as claims
90
+ from . import spec as registry
91
+
92
+ import sys as _sys
93
+ for _alias, _mod in (("claims", claims), ("registry", registry)):
94
+ _sys.modules[f"{__name__}.{_alias}"] = _mod
95
+
96
+ # The built-in claim families register here, explicitly, once per
97
+ # process, not as an import side effect of the modules that define
98
+ # them. Any `mathema.*` import runs this package __init__ first, so the
99
+ # registry is always populated before check_conjectures() or try_prove()
100
+ # can look a family up.
101
+ _register_builtin_claim_families()
102
+ _register_dot_family()
103
+ _register_fold_family()
104
+ _register_sum_family()
105
+
106
+ __version__ = "0.6.0"
107
+ __all__ = ["claim", "check", "write_spec", "retrieve", "analyze",
108
+ "status", "track_claims",
109
+ "tagged", "Record", "claims", "registry", "SPEC_VERSION",
110
+ "__version__", "claims_decorator", "declared_from_function",
111
+ "resolve_declared", "materialize_declared",
112
+ "parse_docstring_claims", "enforce_domain", "enforce_dimensions", "enforce_structure", "reject_missing",
113
+ "DomainError", "docstring_report", "DocstringReport", "lift_symbolic",
114
+ "critical_points", "suggest_claims", "Conjecture",
115
+ "issue", "ReasonCode", "Category", "ClaimReasonCode",
116
+ "get_unicode_output", "set_unicode_output",
117
+ "Form", "SubstitutedForm", "Substitution", "closed_forms",
118
+ "substituted_forms", "register_substitution",
119
+ "resolve", "resolve_function", "Target", "TargetError",
120
+ "gate", "verify_project", "GateReport", "VerifyResult",
121
+ "executable_forms", "CompiledForm", "compile_form",
122
+ "numeric_check", "form_hash"]
123
+
124
+
125
+ @dataclass
126
+ class Record:
127
+ """The result of checking a function's claims: the facts read off it,
128
+ and one Probe per claim adjudicated (built-in laws plus any claims
129
+ passed in). See the claim-driven-development repo's
130
+ v0.2.0/record-schema.md for the YAML shape this turns into via
131
+ to_spec()/save_spec()."""
132
+ facts: Facts
133
+ probes: list[Probe]
134
+ spec_path: str | None = None
135
+ # `lifted` holds the derive route's own symbolic-lift result
136
+ # (symbolic.Lifted) when the body lifts to a closed form, None
137
+ # otherwise; every reader treats None as a valid value. `concepts`
138
+ # is reserved for a future understanding/Mathemata layer and is
139
+ # always empty.
140
+ lifted: object | None = field(default=None, repr=False)
141
+ concepts: list = field(default_factory=list, repr=False)
142
+ dependencies: list = field(default_factory=list, repr=False)
143
+ # one-deep callee records (inventory.function_dependencies): every
144
+ # sibling function/class/module this function references, with key,
145
+ # file, line, and (for functions) the form hash freshness checks
146
+ # compare against, written into the verified spec as its own
147
+ # section, annotated with per-dependency freshness at record time.
148
+ # namespaced extension data, the same role Probe.meta already plays
149
+ # for one claim; this is the function-level counterpart, for data
150
+ # that isn't a stable, always-computed field (diagnostics.
151
+ # diagnostic_report(), opted into per function, is the first real
152
+ # user: "mathema.diagnostic_report" -> its own dict). Never
153
+ # populated automatically by check()/write_spec(), a caller sets it
154
+ # explicitly when it wants the extra work done.
155
+ meta: dict = field(default_factory=dict, repr=False)
156
+
157
+ def __repr__(self) -> str:
158
+ from .analysis import tier_word
159
+ lines = [f"mathema.Record({self.facts.name}) · {tier_word(self.facts.tier)} "
160
+ f"· form {self.facts.form}"]
161
+ for p in self.probes:
162
+ mark = {"holds": "holds ", "falsified": "FALSIFY", "proven": "proven ",
163
+ "skipped": "skip ", "unknown": "unknown",
164
+ "invalidated": "INVALID"}.get(p.verdict.split(":", 1)[0], p.verdict)
165
+ if p.verdict == "proven":
166
+ # a proof has no trial count the way a probe does, the
167
+ # statement itself gets prettified into ordinary math
168
+ # notation, and the quantifier (who it holds for) stands
169
+ # in place of "(n=...)", on its own line since it's
170
+ # often longer than the statement it qualifies.
171
+ stmt = p.statement.replace("==", "=").replace("<=", "≤").replace(">=", "≥")
172
+ line = f" {mark} {p.name}: {stmt}"
173
+ if p.condition:
174
+ line += f"\n {p.condition}"
175
+ else:
176
+ line = f" {mark} {p.name}: {p.statement}"
177
+ if p.verdict == "holds" and p.n:
178
+ line += f" (n={p.n})"
179
+ if p.counterexample:
180
+ line += f"\n counterexample {p.counterexample}"
181
+ stratum = getattr(p, "stratum", None)
182
+ if stratum:
183
+ # which stratum the falsification indicts, one line:
184
+ # the verdict above is unchanged, this only classifies
185
+ parts = []
186
+ if stratum.get("mathematics"):
187
+ parts.append(f"mathematics {stratum['mathematics']}")
188
+ if stratum.get("cause"):
189
+ parts.append(stratum["cause"])
190
+ elif stratum.get("blame"):
191
+ parts.append(f"blame {stratum['blame']}")
192
+ if parts:
193
+ line += f"\n [{', '.join(parts)}]"
194
+ lines.append(line)
195
+ return "\n".join(lines)
196
+
197
+ def to_spec(self) -> dict:
198
+ from .spec import to_spec
199
+ return to_spec(self)
200
+
201
+ def save_spec(self, path: str) -> str:
202
+ """Write the decoupled spec: intent, identity, claims, references,
203
+ and the reasoning chain, as standalone YAML bound to the identity
204
+ hashes."""
205
+ from .spec import save_spec
206
+ return save_spec(self, path)
207
+
208
+
209
+ def analyze(fn) -> Facts:
210
+ """Machine-derived facts about a function: parameters, purity, guards,
211
+ structural shape. Pure `ast`, no probing, no claim verification.
212
+
213
+ Falls back to a documentation-only record (tier 0) for callables with no
214
+ retrievable Python source (builtins, C extensions): the docstring states
215
+ the intent, and probing still runs against the live callable."""
216
+ injected = getattr(fn, "__mathema_facts__", None)
217
+ if isinstance(injected, Facts):
218
+ # a resolver-built proxy carries the facts its frontend could
219
+ # honestly state (a namespaced form hash, real param kinds,
220
+ # tree=None); they are richer than the doc-only fallback the
221
+ # source-less path below would produce, so they win outright
222
+ return injected
223
+ try:
224
+ facts = analyze_source(fn)
225
+ except SourceUnavailable:
226
+ return _doc_only_facts(fn)
227
+ facts.tier = 3 if not facts.is_pure else 2
228
+ return facts
229
+
230
+
231
+ def _doc_only_facts(fn) -> Facts:
232
+ """A documentation-only record (tier 0) for callables without Python
233
+ source. Intent is the author's documented claim: a real but weak
234
+ evidence class, and the only one available for compiled code."""
235
+ import hashlib
236
+ import inspect
237
+
238
+ from .intent import parse_doc
239
+
240
+ doc = inspect.getdoc(fn) or ""
241
+ parsed = parse_doc(doc)
242
+ name = getattr(fn, "__name__", "callable")
243
+ try:
244
+ sig = inspect.signature(fn)
245
+ params = [n for n, p in sig.parameters.items()
246
+ if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)
247
+ and p.default is p.empty]
248
+ sig_str = str(sig)
249
+ except (ValueError, TypeError):
250
+ params, sig_str = [], "(…)"
251
+ for arity in (1, 2, 3):
252
+ try:
253
+ fn(*[0.5] * arity)
254
+ except TypeError:
255
+ continue
256
+ except Exception:
257
+ pass # domain error still confirms the arity
258
+ params = [f"x{i}" for i in range(arity)] if arity > 1 else ["x"]
259
+ sig_str = "(" + ", ".join(params) + ")"
260
+ break
261
+ return Facts(
262
+ name=name,
263
+ signature=sig_str,
264
+ params=params,
265
+ param_kinds={p: "unknown" for p in params},
266
+ returns_kind="unknown",
267
+ docstring=doc or None,
268
+ source="<no Python source; documentation-only record>",
269
+ effects=[],
270
+ # purity cannot be established without source: null, per the
271
+ # record schema (and the general rule: a tool never reports a
272
+ # stronger epistemic state than it has a basis for). Probes
273
+ # still check determinism empirically either way.
274
+ is_pure=None,
275
+ loops=[], comprehensions=[], recursion=False, branch_count=0,
276
+ call_groups={"math": [], "builtin": [], "external": []},
277
+ max_loop_depth=0, lines=0,
278
+ form="doc:" + hashlib.sha256((doc or name).encode()).hexdigest()[:8],
279
+ sigh=hashlib.sha256(sig_str.encode()).hexdigest()[:12],
280
+ tier=0, tree=None,
281
+ doc_intent=parsed.summary or None,
282
+ doc_refs=parsed.refs,
283
+ doc_hints=parsed.concept_hints,
284
+ doc_notes=_parse_notes(doc),
285
+ )
286
+
287
+
288
+ def _expand_claim_keywords(claims: list, fn, facts, parent_domain: dict,
289
+ extensive: bool) -> list:
290
+ """Intent:
291
+ Replace battery keywords inside a claims list with the hazard
292
+ claims they name: `defined` / `excluding` / `stable` /
293
+ `stateless` (and their long/spaced spellings) each expand to
294
+ the RELEVANT members, the same structural gates
295
+ suggest_claims applies decide relevance, so a keyword asks a
296
+ question, never forces an irrelevant claim. Everything else
297
+ (law strings, Conjectures) passes through untouched, and the
298
+ expanded claims carry source "user": a keyword is adoption,
299
+ so its claims gate like any hand-written one.
300
+
301
+ Notes:
302
+ A string that is neither a keyword nor parseable as a law,
303
+ but is closely spelled to a keyword, raises with the whole
304
+ keyword vocabulary and its meanings, a near-miss teaches
305
+ the battery rather than failing as a grammar error. Keyword
306
+ claims never carry a route: the safety members are examine
307
+ by construction.
308
+ """
309
+ from dataclasses import replace as _replace
310
+
311
+ from .conjecture import InvalidConjecture, claim
312
+ from .families import close_keyword, expand_keyword, keyword_help
313
+ from .suggest import suggest_claims
314
+
315
+ out: list = []
316
+ suggested = None
317
+ for item in claims:
318
+ if isinstance(item, dict):
319
+ # the claims-file row shape, accepted at the call site the
320
+ # same way a claims.yaml row is: one shared construction
321
+ # (spec._declared_conjecture), so the two spellings can
322
+ # never parse differently. A row with no `source` of its
323
+ # own is a call-site claim, exactly like a law string here
324
+ from .spec import _declared_conjecture
325
+ row = dict(item)
326
+ row.setdefault("source", "user")
327
+ out.append(_declared_conjecture(row))
328
+ continue
329
+ if not isinstance(item, str):
330
+ out.append(item)
331
+ continue
332
+ member_names = expand_keyword(item)
333
+ if member_names is None:
334
+ try:
335
+ claim(item)
336
+ except InvalidConjecture:
337
+ near = close_keyword(item)
338
+ if near is not None:
339
+ raise InvalidConjecture(
340
+ f"unknown claim {item!r}; did you mean the "
341
+ f"battery keyword {near!r}? keywords: "
342
+ f"{keyword_help()}") from None
343
+ raise
344
+ out.append(item)
345
+ continue
346
+ if suggested is None:
347
+ suggested = suggest_claims(fn, facts=facts,
348
+ extensive=extensive)
349
+ for cj in suggested:
350
+ if cj.name.split("[", 1)[0] in member_names:
351
+ out.append(_replace(cj, source="user"))
352
+ if "excluded_outside_domain" in member_names:
353
+ # never battery-suggested: generated here per parameter
354
+ # the function-level parent domain bounds (there is no
355
+ # outside otherwise); a claim's own quantifier is not a
356
+ # function-level domain and generates nothing here
357
+ for p in sorted(parent_domain):
358
+ if p in facts.params:
359
+ out.append(claim(f"excluded_outside_domain({p})"))
360
+ return out
361
+
362
+
363
+ def _refuse_name_collisions(entries: list) -> list:
364
+ """Intent:
365
+ The claims a check adjudicates, keyed by name: an exact
366
+ duplicate (same name, statement and domain) is one claim, and
367
+ two distinct claims under one name are refused.
368
+
369
+ Raises:
370
+ InvalidConjecture: when two distinct claims take the same name
371
+ (four domain variants of one law all auto-name `f_mi_ge_0`,
372
+ say), since a name keys the claim's pins, locks and
373
+ verified row and cannot stand for two claims.
374
+ """
375
+ import json
376
+
377
+ from .conjecture import InvalidConjecture
378
+
379
+ def _stmt(c):
380
+ return c.get("statement") or c.get("law")
381
+
382
+ def _identity(c):
383
+ # the full identity, statement and domain: a domain lives in its
384
+ # own field, not the (domain-stripped) statement
385
+ return (c.get("name"), _stmt(c),
386
+ json.dumps(c.get("domain") or {}, sort_keys=True,
387
+ default=str))
388
+
389
+ seen: dict = {}
390
+ for c in entries:
391
+ seen.setdefault(_identity(c), c)
392
+ by_name: dict = {}
393
+ for c in seen.values():
394
+ prior = by_name.setdefault(c.get("name"), c)
395
+ if prior is not c:
396
+ raise InvalidConjecture(
397
+ f"two claims take the name {c.get('name')!r} "
398
+ f"({_stmt(prior)!r} and {_stmt(c)!r}); give each an "
399
+ f"explicit name (claim(..., name=...), or `name:` in a "
400
+ f"claims file)")
401
+ return list(seen.values())
402
+
403
+
404
+ def _domains_from_claims(claims) -> dict:
405
+ """Intent:
406
+ The parameter domains declared by the `for` quantifiers of the
407
+ claims being checked, so the built-in battery samples inside the
408
+ region their author actually declared.
409
+
410
+ Notes:
411
+ A claim that will not parse is ignored here and reported by the
412
+ ordinary path instead, so this never changes which errors a
413
+ caller sees. Where two claims bound the same parameter, the
414
+ first stands: the battery only needs one legal region to
415
+ synthesise a call in, and each claim is still adjudicated
416
+ against its own domain regardless. The built-in battery is the
417
+ only reader: these regions never become a function-level domain
418
+ for any claim.
419
+ """
420
+ from .conjecture import claim as _claim
421
+
422
+ out: dict = {}
423
+ for c in claims or ():
424
+ try:
425
+ parsed = _claim(c) if isinstance(c, str) else c
426
+ except Exception:
427
+ continue
428
+ for name, bound in (getattr(parsed, "domain", None) or {}).items():
429
+ out.setdefault(name, bound)
430
+ return out
431
+
432
+
433
+ def check(fn, claims: list | None = None, domain: dict | None = None,
434
+ trials: int | None = None,
435
+ trials_scale: float = 1.0, extensive: bool = False,
436
+ declared: dict | None = None,
437
+ known_premises: dict | None = None) -> Record:
438
+ """Verify a function's claims, each adjudicated against the real
439
+ function.
440
+
441
+ mathema.check(ema) # + suggested claims
442
+ mathema.check(ema, claims=["f(x, 1.0) == x[-1]"]) # add a claim
443
+ mathema.check(ema, claims=[]) # declared surfaces only
444
+ mathema.check(ema, domain={"alpha": (0, 1)})
445
+ mathema.check(ema, declared=mathema.retrieve(ema)) # + file claims
446
+
447
+ check is IO-free: it reads only what travels with the function
448
+ object (decorator, docstring, type markers) and never touches the
449
+ filesystem. The file-declared layer, the highest-precedence
450
+ authoring surface, therefore reaches it only through `declared=`,
451
+ a retrieved entry from `mathema.retrieve(fn, root)`. Call-site
452
+ `claims=` still wins per claim name over everything retrieved.
453
+ `write_spec()`, the CLI, and the MCP tools do this join for you;
454
+ a bare `check(fn)` adjudicates the function-attached surfaces only.
455
+
456
+ `claims` omitted (`None`) defaults to `suggest_claims(fn)`'s
457
+ own proposals, monotonicity/affine-ness/convexity, symmetry,
458
+ idempotence, commutativity/associativity, raises(...) guards, each
459
+ adjudicated at the best evidence level actually available (a proof
460
+ when the function lifts, probing otherwise, see suggest_claims()'s
461
+ own docstring). `claims=[]` (an explicit empty list, not merely
462
+ omitted) means declared surfaces only: no suggested claims are
463
+ added, while the built-in structural probes and the function's own
464
+ decorator/docstring/type-marker claims still run, and so does a
465
+ `declared=` entry when one is passed. Any other explicit `claims=[...]` is
466
+ checked as given, unioned with whatever the function's own decorator
467
+ or docstring declares.
468
+
469
+ `domain` declares parameter limits: algebraic probes sample inside
470
+ the declared domain. Whether the code REJECTS out-of-domain input
471
+ is its own declared claim, `excluded_outside_domain(p)`, state it
472
+ explicitly, opt in with the `excluding` keyword, or get it
473
+ auto-declared by @enforce_domain (the decorator that makes it
474
+ true). There is no adjudication mode: a declared exclusion that
475
+ trials find unenforced is falsified with the accepted value as
476
+ witness; an undeclared one reports nothing. See
477
+ claim-driven-development's v0.2.0/claim-anatomy.md, "Domain:
478
+ declared vs enforced".
479
+
480
+ `known_premises` is caller-supplied context for `assuming <name>
481
+ holds` references that resolve nowhere in the batch: a mapping of
482
+ claim name to a human line (usually from
483
+ `compendium.external_premises`) that the missing-prerequisite note
484
+ then includes. Data only; it never satisfies a premise, and
485
+ check() itself stays IO-free.
486
+
487
+ `extensive` reaches every route this call touches: `probe()`'s own
488
+ critical-point sampling hints and `domain_safe[...]` probe, and a
489
+ `route="derive"` claim's case-split fallback. Default `False`
490
+ everywhere; real, opt-in cost when set.
491
+
492
+ A parameter or return type hinted with a mathema type marker
493
+ (`Annotated[float, Probability]`, `Annotated[list, Shape("m", "n")]`,
494
+ see types.py) contributes its own claims automatically: a domain
495
+ marker merges into `domain` (an explicitly passed bound for the same
496
+ parameter wins), and a `Shape` marker adds a structural probe. Purely
497
+ additive, a function with no markers behaves exactly as before.
498
+
499
+ Domains are per claim. The signature markers and `domain=` together
500
+ are the function-level parent domain; each claim is adjudicated over
501
+ that parent with its own `for` bindings overriding it per parameter,
502
+ and one claim's bindings never reach another claim. A claim's record
503
+ states its own bindings in `domain`/`condition` and whatever the
504
+ parent supplied in `meta["mathema.parent_domain"]`.
505
+
506
+ A `@claims_decorator(...)`-tagged function, or a docstring `Claims:`
507
+ block (see authoring.py), also contributes its claims automatically,
508
+ same as the type markers above; `claims=` passed here is unioned
509
+ with those, winning per claim name on a collision (an explicit call-
510
+ site claim is the most deliberate of the three; see
511
+ authoring.declared_from_function's own decorator-over-docstring rule
512
+ for the same reasoning one layer in).
513
+ """
514
+ from .probing import _RISK, _SPECIALS
515
+ from .spec import declare, entry_claims
516
+ from .types import _TYPE_PROBE_TRIALS, domain_from_signature, type_probes
517
+
518
+ facts = analyze(fn)
519
+ # the function-level parent domain: signature markers, then an
520
+ # explicit domain= winning per parameter. Each claim is adjudicated
521
+ # over this parent with its own `for` bindings overriding it per
522
+ # parameter; one claim's bindings never reach another claim.
523
+ parent_domain = {**domain_from_signature(fn), **(domain or {})}
524
+ # the built-in battery only needs somewhere the function can be
525
+ # called: a parameter the parent leaves open is synthesised inside
526
+ # a region some claim declared for it, so the battery never reports
527
+ # a raise outside every region the author stated
528
+ battery_domain = {**_domains_from_claims(claims), **parent_domain}
529
+
530
+ from .inventory import function_dependencies
531
+ deps = function_dependencies(fn, facts)
532
+
533
+ def _stamp_surface(ps, surface):
534
+ # the authoring-surface provenance output rows fold into their
535
+ # `source` field (records.row_source); claim-route probes carry
536
+ # theirs from the conjecture instead
537
+ for p in ps:
538
+ meta = dict(p.meta or {})
539
+ meta.setdefault("mathema.surface", surface)
540
+ p.meta = meta
541
+ return ps
542
+
543
+ probes = _stamp_surface(
544
+ probe(fn, facts, domain=battery_domain or None,
545
+ trials=trials, trials_scale=trials_scale, extensive=extensive),
546
+ "builtin")
547
+
548
+ type_trials = trials or _TYPE_PROBE_TRIALS
549
+ scale = min(1.0, trials_scale)
550
+ if scale < 1.0:
551
+ type_trials = max(_RISK.min_trials_floor_when_scaled, len(_SPECIALS),
552
+ round(type_trials * scale))
553
+ probes = probes + _stamp_surface(type_probes(fn, trials=type_trials),
554
+ "types")
555
+
556
+ # whether this list is the caller's or mathema's own matters for
557
+ # precedence below: a call-site claim is the MOST deliberate
558
+ # surface, a suggestion the least
559
+ suggested = claims is None
560
+ if suggested:
561
+ claims = suggest_claims(fn, facts=facts, extensive=extensive)
562
+ claims = _expand_claim_keywords(claims, fn, facts, parent_domain,
563
+ extensive)
564
+ built = [claim(c) if isinstance(c, str) else c for c in claims]
565
+ explicit = []
566
+ for cj in built:
567
+ entry = declare(cj)
568
+ # declare()'s serialized form keeps only importable dotted
569
+ # refs; the caller's actual callables ride beside it under an
570
+ # in-memory-only key (never written to any store), re-attached
571
+ # when the merged entry is rebuilt into claims
572
+ live = {k: v for k, v in (getattr(cj, "funcs", None) or {}).items()
573
+ if callable(v)}
574
+ if live:
575
+ entry["__live_funcs__"] = live
576
+ explicit.append(entry)
577
+ # distinct claims that name alike (four domain variants of one law)
578
+ # cannot share the name-keyed merge below
579
+ explicit = _refuse_name_collisions(explicit)
580
+ from .spec import merge_entries
581
+ # a retrieved entry already carries the function surfaces merged
582
+ # with the file store at the documented precedence; without one,
583
+ # the function's own surfaces stand in (read off the object, so
584
+ # check() stays IO-free)
585
+ authored = (dict(declared) if declared is not None
586
+ else {"claims": declared_from_function(fn)})
587
+ if suggested:
588
+ # a suggestion must never outrank a same-named authored claim.
589
+ # It used to: the suggestion won the merge, so a human's
590
+ # declared claim came back source "suggested", gates false,
591
+ # and its verdict silently stopped counting toward the gate.
592
+ merged_entry = merge_entries({"claims": explicit}, authored,
593
+ on_conflict="silent")
594
+ else:
595
+ # a call-site claim overlays, winning per name: documented
596
+ # precedence, the caller is expressing intent now
597
+ merged_entry = merge_entries(authored, {"claims": explicit},
598
+ on_conflict="silent")
599
+ all_claims = entry_claims(merged_entry)
600
+ if all_claims:
601
+ probes = probes + check_conjectures(fn, all_claims, domain=parent_domain or None,
602
+ trials=trials, trials_scale=trials_scale,
603
+ facts=facts, extensive=extensive,
604
+ known_premises=known_premises,
605
+ float_companions=True)
606
+ from .concepts import Concept, concepts_for, flat_union
607
+ sources = concepts_for(facts, probes)
608
+ dismissed = set(((declared or {}).get("meta") or {}).get(
609
+ "mathema.concepts_dismissed") or [])
610
+ if dismissed:
611
+ # a curated dismissal silences the suggestion, permanently;
612
+ # keyword hints are inferences and never re-offer a tag a
613
+ # person has already said no to
614
+ sources = {k: [c for c in v if c not in dismissed]
615
+ for k, v in sources.items()}
616
+ # keyword hints are inferences, not assertions: they ride the
617
+ # provenance split, clearly labeled, but never the interop union
618
+ # or the record's own concepts field
619
+ asserted = {k: v for k, v in sources.items()
620
+ if k in ("declared", "mechanism") and v}
621
+ concept_objs = [Concept(name, src)
622
+ for src, names in asserted.items() for name in names]
623
+ meta = {}
624
+ if asserted or sources.get("keyword"):
625
+ # the spec's own interop shape (a flat list under
626
+ # meta.concepts) plus the un-flattened provenance beside it
627
+ meta = {"mathema.concept_sources": {k: v for k, v in sources.items()
628
+ if v}}
629
+ if asserted:
630
+ meta["concepts"] = flat_union(asserted)
631
+ # the derive route's closed form, kept on the record so the math
632
+ # section can state it (a proof's subject, rendered and exact).
633
+ # Guarded: an unliftable body simply leaves the field None, which
634
+ # every reader already treats as a valid value.
635
+ try:
636
+ lifted = lift_symbolic(fn, facts)
637
+ except Exception:
638
+ lifted = None
639
+ return Record(facts=facts, probes=probes, dependencies=deps,
640
+ concepts=concept_objs, meta=meta, lifted=lifted)
641
+
642
+
643
+ def write_spec(fn, claims: list | None = None, root: str = ".",
644
+ key: str | None = None, **kwargs) -> Record:
645
+ """The one-call IO workflow: retrieve the declared layer from the
646
+ project store, check the function's claims against it, write the
647
+ record into the store (.mathema/verified/), and return the Record.
648
+
649
+ mathema.write_spec(my_fn, claims=[...])
650
+ mathema.write_spec(my_fn, domain={"a": (0, 1)}, strict=True)
651
+
652
+ This is `retrieve` + `check` + `spec.record` composed; `check`
653
+ itself stays IO-free.
654
+ """
655
+ from .authoring import retrieve
656
+ from .spec import declare, merge_entries, record
657
+
658
+ declared = retrieve(fn, root)
659
+ from .compendium import external_premises as _external_premises
660
+ from .compendium import install as _install_compendium
661
+ _install_compendium(root)
662
+ kwargs.setdefault("known_premises", _external_premises(root))
663
+ rec = check(fn, claims=claims, declared=declared, **kwargs)
664
+ if key is None:
665
+ from .authoring import _fn_key
666
+ key = _fn_key(fn)
667
+ # The written claims_fingerprint must reflect everything check() just
668
+ # evaluated, the retrieved file-declared claims and the function's
669
+ # own decorator/docstring claims, not only the explicit `claims=`
670
+ # argument here. A fingerprint computed from a subset of what was
671
+ # actually checked would make `mathema verify` see a false "form
672
+ # changed" on the very next run (the claim set it merges independently
673
+ # wouldn't match this record's stamped fingerprint).
674
+ explicit = [declare(claim(c) if isinstance(c, str) else c) for c in (claims or [])]
675
+ merged = merge_entries(dict(declared), {"claims": explicit},
676
+ on_conflict="silent")
677
+ rec.spec_path = record(rec, key=key, root=root, claims=merged["claims"])
678
+ return rec
679
+
680
+
681
+ def load_ipython_extension(ip):
682
+ """`%load_ext mathema` then:
683
+
684
+ %%mathema
685
+ def my_fn(x: list, alpha: float) -> float:
686
+ ...
687
+
688
+ runs the cell, then for every function it defines: writes its spec
689
+ (check + record to .mathema/verified/) and displays the result inline.
690
+ """
691
+ import ast as _ast
692
+
693
+ from IPython.display import display
694
+
695
+ def mathema_cell(line, cell):
696
+ ip.run_cell(cell)
697
+ for node in _ast.parse(cell).body:
698
+ if isinstance(node, _ast.FunctionDef):
699
+ fn = ip.user_ns.get(node.name)
700
+ if callable(fn):
701
+ display(write_spec(fn))
702
+
703
+ ip.register_magic_function(mathema_cell, magic_kind="cell", magic_name="mathema")
704
+
705
+
706
+ _REGISTRY: dict = {}
707
+
708
+
709
+ def track_claims(fn):
710
+ """Optional bare tag: track this function's claims over time.
711
+
712
+ Entirely optional. Without it, everything still works: check, write_spec,
713
+ claims.check, and the CLI operate on any function. What an untracked
714
+ function loses is visibility in mathema.status(), the fresh-versus-stale
715
+ sweep, because status can only report on functions it knows exist. Tag
716
+ the functions whose claims you want watched; leave the rest alone.
717
+
718
+ Mechanics, precisely:
719
+ - runs once, at decoration time; the function object is returned
720
+ unchanged (no wrapper, zero call overhead, stack traces untouched)
721
+ - stamps ``fn.__mathema__ = {"key": ...}`` so tools and readers can see
722
+ the tag by introspection
723
+ - registers the function under its dotted key (module.qualname) in a
724
+ weak registry, so `mathema.tagged()` and `mathema.status()` can find
725
+ it; redefining the function in a notebook simply re-registers the key
726
+ The tag carries no spec content: intent and claims live in the sidecar
727
+ spec store (see `mathema.spec`), keeping code untouched by design.
728
+ """
729
+ import weakref
730
+
731
+ from .authoring import _fn_key
732
+ key = _fn_key(fn)
733
+ fn.__mathema__ = {"key": key}
734
+ _REGISTRY[key] = weakref.ref(fn)
735
+ return fn
736
+
737
+
738
+ def tagged() -> dict:
739
+ """Currently-alive @track_claims-tagged functions, keyed by dotted name."""
740
+ out = {}
741
+ for key, ref in list(_REGISTRY.items()):
742
+ fn = ref()
743
+ if fn is None:
744
+ del _REGISTRY[key]
745
+ else:
746
+ out[key] = fn
747
+ return out
748
+
749
+
750
+ def status(root: str = ".") -> str:
751
+ """One line per tagged function: does a verified record exist, and is
752
+ it fresh (its recorded form hash matches the code as it is now)?
753
+ Freshness reads only the verified layer (.mathema/verified/), a declared
754
+ claims file never carries an identity hash, so it can't answer this."""
755
+ from .spec import load_verified
756
+
757
+ verified = load_verified(root)
758
+ lines = []
759
+ for key, fn in sorted(tagged().items()):
760
+ try:
761
+ current = analyze_source(fn).form
762
+ except SourceUnavailable:
763
+ current = None
764
+ rec = verified.get(key) or verified.get(fn.__name__)
765
+ if rec is None:
766
+ lines.append(f" ∅ {key}: no verified record")
767
+ continue
768
+ stored = (rec["entry"].get("identity") or {}).get("form")
769
+ state = ("fresh" if stored and stored == current
770
+ else "STALE (code changed since spec)" if stored else "no hash")
771
+ lines.append(f" {'✓' if state == 'fresh' else '✗'} {key}: {state} "
772
+ f"[{rec['source']}]")
773
+ return "mathema status\n" + ("\n".join(lines) if lines else
774
+ " (no @track_claims-tagged functions alive)")
775
+
776
+
777
+ @dataclass
778
+ class DocstringReport:
779
+ """The result of `docstring_report()`: the same loose best-practice
780
+ checklist `mathema audit`'s `docs` column already scores
781
+ (`inventory.docstring_quality()`), returned as a single object so it
782
+ can be inspected or printed on its own for one function, e.g. in a
783
+ notebook, without running the full `audit` CLI over a package."""
784
+ name: str
785
+ quality: dict
786
+
787
+ def __repr__(self) -> str:
788
+ from .inventory import docs_checklist
789
+
790
+ q = self.quality
791
+ header = (f"mathema.DocstringReport({self.name}) · "
792
+ f"{q['score']}/{q['applicable']} criteria met")
793
+ return "\n".join([header] + [f" {line}" for line in docs_checklist(q)])
794
+
795
+
796
+ def docstring_report(fn) -> DocstringReport:
797
+ """Run the same loose docstring best-practice checklist `mathema
798
+ audit`'s `docs` column scores, against just this one function,
799
+ interactive/notebook-friendly, no package import or CLI needed.
800
+
801
+ mathema.docstring_report(my_fn)
802
+ """
803
+ from .inventory import docstring_quality
804
+
805
+ return DocstringReport(name=getattr(fn, "__name__", "callable"),
806
+ quality=docstring_quality(fn))