funcprops 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.
Files changed (56) hide show
  1. funcprops/__init__.py +234 -0
  2. funcprops/_semialg.py +246 -0
  3. funcprops/api.py +70 -0
  4. funcprops/branchcuts/__init__.py +141 -0
  5. funcprops/branchcuts/computation.py +664 -0
  6. funcprops/branchcuts/core.py +1204 -0
  7. funcprops/branchcuts/post_simplify.py +461 -0
  8. funcprops/conditions.py +124 -0
  9. funcprops/data/catalogue.json +127449 -0
  10. funcprops/data/dlmf_audit.json +3382 -0
  11. funcprops/dlmf.py +29 -0
  12. funcprops/engine.py +324 -0
  13. funcprops/exceptions.py +29 -0
  14. funcprops/proof_cache.py +373 -0
  15. funcprops/real_analysis/__init__.py +52 -0
  16. funcprops/real_analysis/advanced_convexity.py +217 -0
  17. funcprops/real_analysis/boundedness.py +168 -0
  18. funcprops/real_analysis/common.py +244 -0
  19. funcprops/real_analysis/convexity.py +698 -0
  20. funcprops/real_analysis/differentiability.py +112 -0
  21. funcprops/real_analysis/domain.py +188 -0
  22. funcprops/real_analysis/mapping.py +374 -0
  23. funcprops/real_analysis/monotonic_certificates.py +867 -0
  24. funcprops/real_analysis/monotonicity.py +673 -0
  25. funcprops/real_analysis/oscillation.py +151 -0
  26. funcprops/real_analysis/piecewise.py +174 -0
  27. funcprops/real_analysis/range.py +552 -0
  28. funcprops/real_analysis/sign.py +561 -0
  29. funcprops/real_analysis/smoothness.py +268 -0
  30. funcprops/real_analysis/special_functions.py +515 -0
  31. funcprops/real_analysis/structural_properties.py +558 -0
  32. funcprops/real_analysis/zeros.py +327 -0
  33. funcprops/registry.py +274 -0
  34. funcprops/results.py +239 -0
  35. funcprops/singularities.py +1767 -0
  36. funcprops/stratification.py +494 -0
  37. funcprops/sympy_compare.py +63 -0
  38. funcprops/transcendental/__init__.py +140 -0
  39. funcprops/transcendental/algebraize.py +201 -0
  40. funcprops/transcendental/analytic.py +261 -0
  41. funcprops/transcendental/complex_roots.py +542 -0
  42. funcprops/transcendental/exp_fourier.py +221 -0
  43. funcprops/transcendental/extrema.py +821 -0
  44. funcprops/transcendental/periodic.py +222 -0
  45. funcprops/transcendental/proof.py +104 -0
  46. funcprops/transcendental/real_interval.py +128 -0
  47. funcprops/transcendental/real_sign.py +117 -0
  48. funcprops/transcendental/reduce.py +288 -0
  49. funcprops/transcendental/roots.py +1380 -0
  50. funcprops/transcendental/zero_geometry.py +1197 -0
  51. funcprops/verification.py +410 -0
  52. funcprops-0.1.0.dist-info/METADATA +208 -0
  53. funcprops-0.1.0.dist-info/RECORD +56 -0
  54. funcprops-0.1.0.dist-info/WHEEL +5 -0
  55. funcprops-0.1.0.dist-info/licenses/LICENSE +674 -0
  56. funcprops-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,327 @@
1
+ from __future__ import annotations
2
+
3
+ import sympy as sp
4
+ from sympy import S
5
+
6
+ from ..exceptions import SYMBOLIC_QUERY_ERRORS
7
+ from ..results import PropertyResult
8
+ from ..stratification import evaluate_parameter_strata
9
+ from ..transcendental.roots import (
10
+ RootCertificate,
11
+ RootIsolationResult,
12
+ transcendental_roots,
13
+ )
14
+ from .common import condition_to_set
15
+ from .domain import function_domain
16
+
17
+
18
+ def _merge_root_results(results, domain_set):
19
+ """Merge per-cell zero results without discarding certification metadata."""
20
+ roots = []
21
+ zero_intervals = []
22
+ isolated = []
23
+ certificates = []
24
+ evidence = []
25
+ families = []
26
+ geometry = []
27
+ complete = True
28
+ methods = []
29
+ periodic = None
30
+ for result in results:
31
+ roots.extend(result.roots)
32
+ zero_intervals.extend(result.zero_intervals)
33
+ isolated.extend(result.isolated_intervals)
34
+ certificates.extend(result.certificates)
35
+ evidence.extend(result.evidence)
36
+ families.extend(result.parametric_families)
37
+ geometry.extend(result.geometry)
38
+ complete = complete and result.complete
39
+ methods.append(result.method)
40
+ if result.periodic is not None:
41
+ # A periodic representation cannot in general be combined losslessly
42
+ # with unrelated domain cells. Preserve it only for the single-cell case.
43
+ if periodic is None and len(results) == 1:
44
+ periodic = result.periodic
45
+ else:
46
+ complete = False
47
+ roots = tuple(sorted(set(roots), key=sp.default_sort_key))
48
+ zero_intervals = tuple(sorted(set(zero_intervals), key=sp.default_sort_key))
49
+ method = "domain_cell_isolation:" + "+".join(dict.fromkeys(methods))
50
+ return RootIsolationResult(
51
+ roots,
52
+ zero_intervals,
53
+ tuple(isolated),
54
+ periodic,
55
+ complete,
56
+ method,
57
+ (*tuple(evidence), ("domain_cells", domain_set)),
58
+ tuple(certificates),
59
+ tuple(families),
60
+ tuple(geometry),
61
+ )
62
+
63
+
64
+ def _domain_cells(domain_set):
65
+ """Return explicit real interval/point cells, or ``None`` if unresolved."""
66
+ domain_set = sp.simplify(domain_set)
67
+ if domain_set is S.EmptySet:
68
+ return ()
69
+ if isinstance(domain_set, (sp.Interval, sp.FiniteSet)):
70
+ return (domain_set,)
71
+ if isinstance(domain_set, sp.Union):
72
+ cells = []
73
+ for arg in domain_set.args:
74
+ sub = _domain_cells(arg)
75
+ if sub is None:
76
+ return None
77
+ cells.extend(sub)
78
+ return tuple(cells)
79
+ return None
80
+
81
+
82
+ def _finite_real_solutions(equation, var):
83
+ try:
84
+ sol = sp.solveset(equation, var, domain=S.Reals)
85
+ except SYMBOLIC_QUERY_ERRORS:
86
+ return ()
87
+ if not isinstance(sol, sp.FiniteSet):
88
+ return ()
89
+ return tuple(q for q in sol if q.is_real is not False)
90
+
91
+
92
+ def _structural_exceptional_points(expr, var, domain_set):
93
+ """Exact points at which a real-expression regime or branch can change."""
94
+ equations = []
95
+ try:
96
+ den = sp.denom(sp.together(expr))
97
+ if den != 1:
98
+ equations.append(sp.Eq(den, 0))
99
+ except SYMBOLIC_QUERY_ERRORS:
100
+ pass
101
+ for node in sp.preorder_traversal(expr):
102
+ if node.func is sp.log and node.args:
103
+ equations.append(sp.Eq(node.args[0], 0))
104
+ elif isinstance(node, sp.Pow):
105
+ base, exponent = node.args
106
+ if exponent.is_Rational and not exponent.is_Integer:
107
+ equations.append(sp.Eq(base, 0))
108
+ elif isinstance(node, sp.Piecewise):
109
+ for _value, cond in node.args:
110
+ if cond is True or cond is S.true:
111
+ continue
112
+ from sympy.core.relational import Relational
113
+
114
+ for rel in cond.atoms(Relational):
115
+ equations.append(sp.Eq(rel.lhs - rel.rhs, 0))
116
+ points = []
117
+ for eq in equations:
118
+ for point in _finite_real_solutions(eq, var):
119
+ try:
120
+ contained = domain_set.contains(point)
121
+ except SYMBOLIC_QUERY_ERRORS:
122
+ continue
123
+ if contained is S.true and point not in points:
124
+ points.append(point)
125
+ return tuple(sorted(points, key=sp.default_sort_key))
126
+
127
+
128
+ def _split_interval_at_points(interval, points):
129
+ if not isinstance(interval, sp.Interval):
130
+ return (interval,)
131
+ interior = []
132
+ for point in points:
133
+ try:
134
+ if interval.contains(point) is S.true and point not in (
135
+ interval.start,
136
+ interval.end,
137
+ ):
138
+ interior.append(point)
139
+ except SYMBOLIC_QUERY_ERRORS:
140
+ pass
141
+ interior = tuple(sorted(set(interior), key=sp.default_sort_key))
142
+ if not interior:
143
+ return (interval,)
144
+ cells = []
145
+ left = interval.start
146
+ left_open = interval.left_open
147
+ for point in interior:
148
+ cells.append(sp.Interval(left, point, left_open=left_open, right_open=True))
149
+ cells.append(sp.FiniteSet(point))
150
+ left = point
151
+ left_open = True
152
+ cells.append(
153
+ sp.Interval(
154
+ left, interval.end, left_open=left_open, right_open=interval.right_open
155
+ )
156
+ )
157
+ return tuple(c for c in cells if c is not S.EmptySet)
158
+
159
+
160
+ def _zeros_on_set(expr, var, domain_set, *, return_result, allow_numerical):
161
+ """Evaluate zero geometry on one parameter-free real set.
162
+
163
+ Disconnected real domains are analyzed cell by cell so no certificate spans a
164
+ pole, branch boundary, logarithmic exclusion, or other point where the input is
165
+ undefined.
166
+ """
167
+ cells = _domain_cells(domain_set)
168
+ if cells is not None:
169
+ exceptional = _structural_exceptional_points(expr, var, domain_set)
170
+ split_cells = []
171
+ for cell in cells:
172
+ split_cells.extend(_split_interval_at_points(cell, exceptional))
173
+ cells = tuple(split_cells)
174
+ if cells is not None and not (
175
+ len(cells) == 1 and isinstance(cells[0], sp.Interval) and cells[0] == domain_set
176
+ ):
177
+ results = []
178
+ for cell in cells:
179
+ if isinstance(cell, sp.FiniteSet):
180
+ roots = []
181
+ certs = []
182
+ cell_complete = True
183
+ for point in cell:
184
+ try:
185
+ value = sp.simplify(expr.subs(var, point))
186
+ except SYMBOLIC_QUERY_ERRORS:
187
+ cell_complete = False
188
+ continue
189
+ if value == 0 or value.is_zero is True:
190
+ roots.append(point)
191
+ certs.append(
192
+ RootCertificate(
193
+ sp.FiniteSet(point),
194
+ 1,
195
+ "exact_domain_point_root",
196
+ (("value", value),),
197
+ True,
198
+ )
199
+ )
200
+ elif value.is_zero is not False:
201
+ cell_complete = False
202
+ results.append(
203
+ RootIsolationResult(
204
+ tuple(roots),
205
+ complete=cell_complete,
206
+ method="domain_point_evaluation",
207
+ certificates=tuple(certs),
208
+ )
209
+ )
210
+ else:
211
+ results.append(
212
+ transcendental_roots(
213
+ expr, var, cell, allow_numerical=allow_numerical
214
+ )
215
+ )
216
+ rr = _merge_root_results(results, domain_set)
217
+ else:
218
+ rr = transcendental_roots(
219
+ expr, var, domain_set, allow_numerical=allow_numerical
220
+ )
221
+ if return_result:
222
+ return rr
223
+ if rr.complete:
224
+ if rr.periodic is not None:
225
+ return rr.periodic
226
+ if (
227
+ rr.method == "dlmf_indexed_zeros"
228
+ and rr.evidence
229
+ and isinstance(rr.evidence[0], sp.Set)
230
+ ):
231
+ return rr.evidence[0]
232
+ if rr.isolated_intervals or rr.parametric_families:
233
+ return rr
234
+ parts = []
235
+ if rr.roots:
236
+ parts.append(sp.FiniteSet(*rr.roots))
237
+ parts.extend(rr.zero_intervals)
238
+ if not parts:
239
+ return S.EmptySet
240
+ return sp.Union(*parts) if len(parts) > 1 else parts[0]
241
+ return rr
242
+
243
+
244
+ def function_zeros(
245
+ expr,
246
+ var,
247
+ *,
248
+ domain=True,
249
+ return_result=False,
250
+ allow_numerical=False,
251
+ _domain_ready=False,
252
+ ):
253
+ """Return the certified real zero set of ``expr``.
254
+
255
+ Exact finite and periodic families are returned directly. Parameter-dependent
256
+ domain conditions are evaluated stratum by stratum. If a root is only
257
+ certified by an isolating interval, ``RootIsolationResult`` is returned so
258
+ the certificate is not discarded.
259
+ """
260
+ expr = sp.sympify(expr)
261
+ var = sp.sympify(var)
262
+ natural = function_domain(expr, var)
263
+ if isinstance(natural, PropertyResult):
264
+ # Unknown natural-domain information must not erase an explicit user
265
+ # restriction. Fall back to the requested set/condition only.
266
+ if domain is True:
267
+ domain_set = S.Reals
268
+ elif isinstance(domain, sp.Set):
269
+ domain_set = domain
270
+ else:
271
+ domain_set = condition_to_set(sp.sympify(domain), var)
272
+ if domain_set is None:
273
+ return RootIsolationResult(
274
+ (), complete=False, method="unsupported-domain"
275
+ )
276
+ return _zeros_on_set(
277
+ expr,
278
+ var,
279
+ domain_set,
280
+ return_result=return_result,
281
+ allow_numerical=allow_numerical,
282
+ )
283
+
284
+ if domain is True:
285
+ condition = natural
286
+ elif isinstance(domain, sp.Set):
287
+ natural_set = condition_to_set(natural, var)
288
+ domain_set = sp.Intersection(
289
+ natural_set if natural_set is not None else S.Reals, domain
290
+ )
291
+ return _zeros_on_set(
292
+ expr,
293
+ var,
294
+ domain_set,
295
+ return_result=return_result,
296
+ allow_numerical=allow_numerical,
297
+ )
298
+ else:
299
+ condition = sp.And(natural, sp.sympify(domain))
300
+
301
+ if not _domain_ready:
302
+ stratified = evaluate_parameter_strata(
303
+ condition,
304
+ var,
305
+ lambda branch: function_zeros(
306
+ expr,
307
+ var,
308
+ domain=branch,
309
+ return_result=return_result,
310
+ allow_numerical=allow_numerical,
311
+ _domain_ready=True,
312
+ ),
313
+ source="zero-parameter-stratification",
314
+ )
315
+ if stratified is not None:
316
+ return stratified
317
+
318
+ domain_set = condition_to_set(condition, var)
319
+ if domain_set is None:
320
+ return RootIsolationResult((), complete=False, method="unsupported-domain")
321
+ return _zeros_on_set(
322
+ expr,
323
+ var,
324
+ domain_set,
325
+ return_result=return_result,
326
+ allow_numerical=allow_numerical,
327
+ )
funcprops/registry.py ADDED
@@ -0,0 +1,274 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from collections.abc import Iterable
6
+ from dataclasses import dataclass
7
+ from functools import lru_cache
8
+ from importlib.resources import files
9
+ from typing import Any
10
+
11
+ import sympy as sp
12
+ from sympy import Symbol
13
+
14
+ from .conditions import parse_catalogue_expression
15
+
16
+
17
+ def _balanced_content(text: str, start: int, open_char="[", close_char="]"):
18
+ depth = 0
19
+ for i in range(start, len(text)):
20
+ ch = text[i]
21
+ if ch == open_char:
22
+ depth += 1
23
+ elif ch == close_char:
24
+ depth -= 1
25
+ if depth == 0:
26
+ return text[start + 1 : i]
27
+ return None
28
+
29
+
30
+ def _split_top_level(text: str):
31
+ out = []
32
+ start = 0
33
+ depth = 0
34
+ pairs = {"[": "]", "(": ")", "{": "}"}
35
+ closes = set(pairs.values())
36
+ stack = []
37
+ for i, ch in enumerate(text):
38
+ if ch in pairs:
39
+ stack.append(pairs[ch])
40
+ depth += 1
41
+ elif ch in closes and stack and ch == stack[-1]:
42
+ stack.pop()
43
+ depth -= 1
44
+ elif ch == "," and depth == 0:
45
+ out.append(text[start:i].strip())
46
+ start = i + 1
47
+ out.append(text[start:].strip())
48
+ return out
49
+
50
+
51
+ def _source_signature(lhs: str | None):
52
+ """Extract the represented function pattern and property mode/variable."""
53
+ if not lhs:
54
+ return None, (), None, None
55
+ lhs = lhs.split("/;", 1)[0].strip()
56
+ first = lhs.find("[")
57
+ if first < 0:
58
+ return None, (), None, None
59
+ outer = _balanced_content(lhs, first)
60
+ if outer is None:
61
+ return None, (), None, None
62
+ outer_args = _split_top_level(outer)
63
+ if not outer_args:
64
+ return None, (), None, None
65
+ fpat = outer_args[0].strip()
66
+ if fpat.startswith("HoldPattern["):
67
+ i = fpat.find("[")
68
+ inner = _balanced_content(fpat, i)
69
+ if inner is not None:
70
+ fpat = inner.strip()
71
+ hidx = fpat.find("[")
72
+ if hidx < 0:
73
+ return fpat, (), None, outer_args[1] if len(outer_args) > 1 else None
74
+ head = fpat[:hidx].strip()
75
+ inner = _balanced_content(fpat, hidx)
76
+ args = tuple(_split_top_level(inner or "")) if inner is not None else ()
77
+ prop_head = lhs[:first].strip()
78
+ mode = None
79
+ var_token = None
80
+ if "SingularityProperties" in prop_head and len(outer_args) > 1:
81
+ token = outer_args[1].strip()
82
+ if token == "True":
83
+ mode = "real"
84
+ elif token == "False":
85
+ mode = "complex"
86
+ else:
87
+ mode = "any"
88
+ if "RealUnivariateProperties" in prop_head and len(outer_args) > 1:
89
+ var_token = outer_args[1].strip()
90
+ return head, args, mode, var_token
91
+
92
+
93
+ def _pattern_name(token: str):
94
+ token = token.strip()
95
+ m = re.match(r"([A-Za-z$][A-Za-z0-9$]*)_", token)
96
+ if m:
97
+ return m.group(1)
98
+ m = re.match(r"([A-Za-z$][A-Za-z0-9$]*):", token)
99
+ if m:
100
+ return m.group(1)
101
+ return None
102
+
103
+
104
+ def _literal_pattern_matches(token: str, actual) -> bool:
105
+ token = token.strip()
106
+ # Ordinary named patterns are handled by guards/assumptions.
107
+ if "_" in token and re.match(r"^[A-Za-z$][A-Za-z0-9$]*_", token):
108
+ return True
109
+ # a:(2|3), a:3 style restricted alternatives/defaults.
110
+ if ":" in token and re.match(r"^[A-Za-z$][A-Za-z0-9$]*:", token):
111
+ rhs = token.split(":", 1)[1].strip()
112
+ if rhs.startswith("(") and rhs.endswith(")") and "|" in rhs:
113
+ vals = rhs[1:-1].split("|")
114
+ return any(_literal_pattern_matches(v, actual) for v in vals)
115
+ if rhs.startswith("_"):
116
+ return True
117
+ return _literal_pattern_matches(rhs, actual)
118
+ # List/vector patterns and Blank/Repeated constructs are too rich to match
119
+ # syntactically here; leave them to their explicit registry guard.
120
+ if any(x in token for x in ("_", "Repeated[", "Vectors[", "Matrices[")):
121
+ return True
122
+ try:
123
+ literal = parse_catalogue_expression(token)
124
+ if isinstance(actual, sp.Basic) and isinstance(literal, sp.Basic):
125
+ return sp.simplify(actual - literal) == 0
126
+ return actual == literal
127
+ except (TypeError, ValueError):
128
+ return False
129
+
130
+
131
+ @dataclass(frozen=True)
132
+ class RuleCase:
133
+ value_source: str
134
+ condition_source: str
135
+ source_property_type: str | None = None
136
+ source_lhs: str | None = None
137
+
138
+ @property
139
+ def condition(self):
140
+ return parse_catalogue_expression(self.condition_source or "True")
141
+
142
+ @property
143
+ def value(self):
144
+ return parse_catalogue_expression(self.value_source)
145
+
146
+ @property
147
+ def signature(self):
148
+ return _source_signature(self.source_lhs)
149
+
150
+ def substitutions(self, expr):
151
+ _, patterns, _, _ = self.signature
152
+ repl = {}
153
+ for token, actual in zip(patterns, getattr(expr, "args", ()), strict=False):
154
+ name = _pattern_name(token)
155
+ if name:
156
+ repl[Symbol(name)] = actual
157
+ return repl
158
+
159
+ def instantiate(self, expr):
160
+ repl = self.substitutions(expr)
161
+ value = self.value
162
+ condition = self.condition
163
+ if isinstance(value, sp.Basic):
164
+ value = value.xreplace(repl)
165
+ if isinstance(condition, sp.Basic):
166
+ condition = condition.xreplace(repl)
167
+ return value, condition
168
+
169
+ def matches(self, expr, *, var=None, mode=None) -> bool:
170
+ _, patterns, case_mode, var_token = self.signature
171
+ args = getattr(expr, "args", ())
172
+ if patterns and len(patterns) != len(args):
173
+ return False
174
+ for token, actual in zip(patterns, args, strict=False):
175
+ if not _literal_pattern_matches(token, actual):
176
+ return False
177
+ if mode in ("real", "complex") and case_mode not in (None, "any", mode):
178
+ return False
179
+ if var is not None and var_token is not None:
180
+ name = _pattern_name(var_token)
181
+ if name:
182
+ mapped = self.substitutions(expr).get(Symbol(name))
183
+ if mapped is not None and mapped != var:
184
+ return False
185
+ return True
186
+
187
+ def specificity(self, *, mode=None) -> int:
188
+ _, patterns, case_mode, _ = self.signature
189
+ score = 0
190
+ for token in patterns:
191
+ t = token.strip()
192
+ if ":" in t:
193
+ score += 3
194
+ elif re.search(r"_(Integer|Real|Complex|Positive|NonNegative)", t):
195
+ score += 2
196
+ elif "_" not in t:
197
+ score += 4
198
+ if mode in ("real", "complex") and case_mode == mode:
199
+ score += 2
200
+ return score
201
+
202
+
203
+ @dataclass(frozen=True)
204
+ class Family:
205
+ raw: dict[str, Any]
206
+
207
+ @property
208
+ def id(self):
209
+ return self.raw["family_id"]
210
+
211
+ @property
212
+ def sympy_names(self):
213
+ return tuple(self.raw.get("sympy_names", ()))
214
+
215
+ @property
216
+ def mpmath_names(self):
217
+ return tuple(self.raw.get("mpmath_names", ()))
218
+
219
+ @property
220
+ def catalogue_names(self):
221
+ return tuple(self.raw.get("catalogue_names", ()))
222
+
223
+ def cases(self, prop: str) -> tuple[RuleCase, ...]:
224
+ p = self.raw.get("properties", {}).get(prop, {})
225
+ return tuple(
226
+ RuleCase(
227
+ **{
228
+ k: c.get(k)
229
+ for k in (
230
+ "value_source",
231
+ "condition_source",
232
+ "source_property_type",
233
+ "source_lhs",
234
+ )
235
+ }
236
+ )
237
+ for c in p.get("cases", ())
238
+ )
239
+
240
+
241
+ class FunctionRegistry:
242
+ def __init__(self, records: Iterable[dict[str, Any]]):
243
+ self._families = tuple(Family(r) for r in records)
244
+ self._by_name = {}
245
+ for fam in self._families:
246
+ for n in (
247
+ fam.id,
248
+ *fam.sympy_names,
249
+ *fam.mpmath_names,
250
+ *fam.catalogue_names,
251
+ ):
252
+ self._by_name.setdefault(n, fam)
253
+
254
+ def __len__(self):
255
+ return len(self._families)
256
+
257
+ def __iter__(self):
258
+ return iter(self._families)
259
+
260
+ def get(self, name: str) -> Family | None:
261
+ return self._by_name.get(name)
262
+
263
+ def require(self, name: str) -> Family:
264
+ f = self.get(name)
265
+ if f is None:
266
+ raise KeyError(name)
267
+ return f
268
+
269
+
270
+ @lru_cache(maxsize=1)
271
+ def registry() -> FunctionRegistry:
272
+ p = files("funcprops").joinpath("data/catalogue.json")
273
+ payload = json.loads(p.read_text())
274
+ return FunctionRegistry(payload["families"])