fixpoints 0.4.0.post85.dev0__tar.gz → 0.6.0__tar.gz
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.
- {fixpoints-0.4.0.post85.dev0 → fixpoints-0.6.0}/.gitignore +8 -2
- {fixpoints-0.4.0.post85.dev0 → fixpoints-0.6.0}/PKG-INFO +2 -2
- {fixpoints-0.4.0.post85.dev0 → fixpoints-0.6.0}/pyproject.toml +1 -1
- {fixpoints-0.4.0.post85.dev0 → fixpoints-0.6.0}/src/fixpoints/_core.py +164 -44
- {fixpoints-0.4.0.post85.dev0 → fixpoints-0.6.0}/README.md +0 -0
- {fixpoints-0.4.0.post85.dev0 → fixpoints-0.6.0}/src/fixpoints/__init__.py +0 -0
- {fixpoints-0.4.0.post85.dev0 → fixpoints-0.6.0}/tests/test_fixpoint.py +0 -0
|
@@ -211,6 +211,12 @@ __marimo__/
|
|
|
211
211
|
.devenv/
|
|
212
212
|
result
|
|
213
213
|
|
|
214
|
+
# devenv/nixago scaffolding regenerated by running `nix develop` in a paper subdirectory
|
|
215
|
+
# (the canonical copies live at the repository root)
|
|
216
|
+
/papers/co-lambda/.envrc
|
|
217
|
+
/papers/co-lambda/.gitattributes
|
|
218
|
+
/papers/co-lambda/.vscode/
|
|
219
|
+
|
|
214
220
|
# LaTeX
|
|
215
221
|
*.pdf
|
|
216
222
|
*.aux
|
|
@@ -236,6 +242,6 @@ experiment_results.db
|
|
|
236
242
|
# nixago: ignore-linked-files
|
|
237
243
|
/.vscode/extensions.json
|
|
238
244
|
|
|
239
|
-
|
|
245
|
+
arxiv-submission.tar.gz
|
|
240
246
|
node_modules/
|
|
241
|
-
|
|
247
|
+
comment.cut
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fixpoints
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Least-fixpoint cached-property infrastructure for mutual recursion
|
|
5
5
|
Project-URL: Repository, https://github.com/Atry/MIXINv2
|
|
6
6
|
Author-email: "Yang, Bo" <yang-bo@yang-bo.com>
|
|
@@ -9,7 +9,7 @@ Classifier: Development Status :: 3 - Alpha
|
|
|
9
9
|
Classifier: License :: OSI Approved :: MIT License
|
|
10
10
|
Classifier: Programming Language :: Python :: 3.13
|
|
11
11
|
Classifier: Programming Language :: Python :: 3.14
|
|
12
|
-
Requires-Python: >=3.
|
|
12
|
+
Requires-Python: >=3.11
|
|
13
13
|
Description-Content-Type: text/markdown
|
|
14
14
|
|
|
15
15
|
# fixpoints
|
|
@@ -18,7 +18,7 @@ dynamic = ["version"]
|
|
|
18
18
|
description = "Least-fixpoint cached-property infrastructure for mutual recursion"
|
|
19
19
|
readme = "README.md"
|
|
20
20
|
license = "MIT"
|
|
21
|
-
requires-python = ">=3.
|
|
21
|
+
requires-python = ">=3.11"
|
|
22
22
|
authors = [{ name = "Yang, Bo", email = "yang-bo@yang-bo.com" }]
|
|
23
23
|
classifiers = [
|
|
24
24
|
"Development Status :: 3 - Alpha",
|
|
@@ -52,10 +52,9 @@ class _FixpointContext:
|
|
|
52
52
|
when they encounter reentry.
|
|
53
53
|
"""
|
|
54
54
|
for instance in self.participant_refs:
|
|
55
|
-
instance_dict = instance.__dict__
|
|
56
55
|
instance_id = id(instance)
|
|
57
56
|
for attr_name in self._clearable_attr_names:
|
|
58
|
-
value =
|
|
57
|
+
value = _clear_fixpoint_attr(instance, attr_name)
|
|
59
58
|
if value is not None:
|
|
60
59
|
self.approximations[(instance_id, attr_name)] = value
|
|
61
60
|
|
|
@@ -82,6 +81,110 @@ class FixpointRecursionError(RecursionError):
|
|
|
82
81
|
|
|
83
82
|
_FIXPOINT_SENTINEL = object()
|
|
84
83
|
|
|
84
|
+
_CACHE_SLOT_PREFIX = "_fpc_"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _make_slot_accessors(slot_name: str) -> "tuple[Callable, Callable, Callable]":
|
|
88
|
+
"""Generate compiled per-property slot accessors via AST compilation.
|
|
89
|
+
|
|
90
|
+
Returns ``(cache_get, cache_set, cache_pop)`` with the slot name baked into bytecode:
|
|
91
|
+
direct slot access, no dict indirection.
|
|
92
|
+
"""
|
|
93
|
+
ns: dict = {"_sentinel": _FIXPOINT_SENTINEL}
|
|
94
|
+
exec(compile(
|
|
95
|
+
f"def _cache_get(instance, _sentinel=_sentinel):\n"
|
|
96
|
+
f" try:\n"
|
|
97
|
+
f" return instance.{slot_name}\n"
|
|
98
|
+
f" except AttributeError:\n"
|
|
99
|
+
f" return _sentinel\n"
|
|
100
|
+
f"def _cache_set(instance, value):\n"
|
|
101
|
+
f" instance.{slot_name} = value\n"
|
|
102
|
+
f"def _cache_pop(instance):\n"
|
|
103
|
+
f" try:\n"
|
|
104
|
+
f" value = instance.{slot_name}\n"
|
|
105
|
+
f" except AttributeError:\n"
|
|
106
|
+
f" return None\n"
|
|
107
|
+
f" del instance.{slot_name}\n"
|
|
108
|
+
f" return value\n",
|
|
109
|
+
f"<fixpoint-slot-{slot_name}>", "exec",
|
|
110
|
+
), ns)
|
|
111
|
+
return ns["_cache_get"], ns["_cache_set"], ns["_cache_pop"]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _make_dict_accessors(attr_name: str) -> "tuple[Callable, Callable, Callable]":
|
|
115
|
+
"""Generate compiled per-property dict accessors via AST compilation.
|
|
116
|
+
|
|
117
|
+
Returns ``(cache_get, cache_set, cache_pop)`` with the attribute name baked into bytecode.
|
|
118
|
+
"""
|
|
119
|
+
ns: dict = {"_sentinel": _FIXPOINT_SENTINEL}
|
|
120
|
+
exec(compile(
|
|
121
|
+
f"def _cache_get(instance, _sentinel=_sentinel):\n"
|
|
122
|
+
f" return instance.__dict__.get({attr_name!r}, _sentinel)\n"
|
|
123
|
+
f"def _cache_set(instance, value):\n"
|
|
124
|
+
f" instance.__dict__[{attr_name!r}] = value\n"
|
|
125
|
+
f"def _cache_pop(instance):\n"
|
|
126
|
+
f" return instance.__dict__.pop({attr_name!r}, None)\n",
|
|
127
|
+
f"<fixpoint-dict-{attr_name}>", "exec",
|
|
128
|
+
), ns)
|
|
129
|
+
return ns["_cache_get"], ns["_cache_set"], ns["_cache_pop"]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def fixpoint_slotted(cls: type) -> type:
|
|
133
|
+
"""Class decorator: add ``_fpc_*`` cache slots for each ``fixpoint_cached_property`` on ``cls``.
|
|
134
|
+
|
|
135
|
+
Scans ``cls`` for ``fixpoint_cached_property`` descriptors, adds a dedicated cache slot
|
|
136
|
+
for each one (named ``_fpc_{property_name}``), and rebuilds the class with augmented
|
|
137
|
+
``__slots__``. This removes the need to manually declare cache slots.
|
|
138
|
+
|
|
139
|
+
Usage::
|
|
140
|
+
|
|
141
|
+
@fixpoint_slotted
|
|
142
|
+
class Thunk:
|
|
143
|
+
__slots__ = ("callee", "argument")
|
|
144
|
+
|
|
145
|
+
@fixpoint_cached_property(bottom=lambda: _BOTTOM)
|
|
146
|
+
def weak_head_normal_form(self) -> object: ...
|
|
147
|
+
"""
|
|
148
|
+
extra_slots = []
|
|
149
|
+
for name, value in vars(cls).items():
|
|
150
|
+
if isinstance(value, (fixpoint_cached_property, _fixpoint_dependent_property)):
|
|
151
|
+
extra_slots.append(_CACHE_SLOT_PREFIX + name)
|
|
152
|
+
if not extra_slots:
|
|
153
|
+
return cls
|
|
154
|
+
existing_slots = getattr(cls, "__slots__", ())
|
|
155
|
+
new_slots = tuple(existing_slots) + tuple(extra_slots)
|
|
156
|
+
member_descriptor_type = type(type.__dict__["__module__"]) if "__module__" not in existing_slots else None
|
|
157
|
+
ns = {}
|
|
158
|
+
for key, value in vars(cls).items():
|
|
159
|
+
if key in ("__slots__", "__dict__", "__weakref__"):
|
|
160
|
+
continue
|
|
161
|
+
if isinstance(value, type) and issubclass(value, type):
|
|
162
|
+
continue
|
|
163
|
+
if member_descriptor_type is not None and type(value).__name__ == "member_descriptor":
|
|
164
|
+
continue
|
|
165
|
+
ns[key] = value
|
|
166
|
+
ns["__slots__"] = new_slots
|
|
167
|
+
ns["__qualname__"] = cls.__qualname__
|
|
168
|
+
new_cls = type(cls)(cls.__name__, cls.__bases__, ns)
|
|
169
|
+
for name, value in vars(new_cls).items():
|
|
170
|
+
if isinstance(value, (fixpoint_cached_property, _fixpoint_dependent_property)):
|
|
171
|
+
value._bind_accessors(new_cls)
|
|
172
|
+
return new_cls
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _clear_fixpoint_attr(instance: object, attr_name: str) -> object | None:
|
|
176
|
+
"""Pop a cached fixpoint value from an instance, handling both dict and slot modes."""
|
|
177
|
+
if type(instance).__dictoffset__ != 0:
|
|
178
|
+
return instance.__dict__.pop(attr_name, None)
|
|
179
|
+
slot_name = _CACHE_SLOT_PREFIX + attr_name
|
|
180
|
+
try:
|
|
181
|
+
value = getattr(instance, slot_name)
|
|
182
|
+
except AttributeError:
|
|
183
|
+
return None
|
|
184
|
+
delattr(instance, slot_name)
|
|
185
|
+
return value
|
|
186
|
+
|
|
187
|
+
|
|
85
188
|
# Registry of attribute names that need clearing during fixpoint digest cycles.
|
|
86
189
|
# Populated by fixpoint_cached_property and fixpoint_dependent decorators.
|
|
87
190
|
_fixpoint_clearable_attrs: set[str] = set()
|
|
@@ -144,10 +247,15 @@ class fixpoint_cached_property:
|
|
|
144
247
|
*,
|
|
145
248
|
bottom: Callable[[], object],
|
|
146
249
|
accumulate: Callable[[object, object], bool] | None = None,
|
|
250
|
+
merge: Callable[[object, object], object] | None = None,
|
|
147
251
|
) -> None:
|
|
148
252
|
# Support both @fixpoint_cached_property(bottom=...) and direct call
|
|
253
|
+
if accumulate is not None and merge is not None:
|
|
254
|
+
raise ValueError("fixpoint_cached_property: pass at most one of accumulate / merge")
|
|
149
255
|
self._bottom = bottom
|
|
150
256
|
self._accumulate = accumulate
|
|
257
|
+
self._merge = merge
|
|
258
|
+
self._cache_get = self._cache_set = self._cache_pop = None
|
|
151
259
|
if func is not None:
|
|
152
260
|
self.func: Callable = func
|
|
153
261
|
self.attrname: str = func.__name__
|
|
@@ -166,6 +274,21 @@ class fixpoint_cached_property:
|
|
|
166
274
|
if not hasattr(self, "attrname"):
|
|
167
275
|
self.attrname = name
|
|
168
276
|
_fixpoint_clearable_attrs.add(self.attrname)
|
|
277
|
+
self._bind_accessors(owner)
|
|
278
|
+
|
|
279
|
+
def _bind_accessors(self, owner: type) -> None:
|
|
280
|
+
"""Generate compiled cache accessors, dispatching by slot vs dict."""
|
|
281
|
+
slot_name = _CACHE_SLOT_PREFIX + self.attrname
|
|
282
|
+
if slot_name in getattr(owner, "__slots__", ()):
|
|
283
|
+
self._cache_get, self._cache_set, self._cache_pop = _make_slot_accessors(slot_name)
|
|
284
|
+
else:
|
|
285
|
+
self._cache_get, self._cache_set, self._cache_pop = _make_dict_accessors(self.attrname)
|
|
286
|
+
|
|
287
|
+
def _ensure_accessors(self, instance: object) -> None:
|
|
288
|
+
"""Lazily bind accessors when __set_name__ was not called (e.g. manually defined classes)."""
|
|
289
|
+
if self._cache_get is not None:
|
|
290
|
+
return
|
|
291
|
+
self._bind_accessors(type(instance))
|
|
169
292
|
|
|
170
293
|
@classmethod
|
|
171
294
|
def _get_max_iterations(cls) -> int | float:
|
|
@@ -178,16 +301,16 @@ class fixpoint_cached_property:
|
|
|
178
301
|
if instance is None:
|
|
179
302
|
return self
|
|
180
303
|
|
|
304
|
+
self._ensure_accessors(instance)
|
|
305
|
+
cache_get = self._cache_get
|
|
306
|
+
cache_set = self._cache_set
|
|
307
|
+
|
|
181
308
|
# Fast path: already cached
|
|
182
|
-
|
|
183
|
-
value = cache.get(self.attrname, _FIXPOINT_SENTINEL)
|
|
309
|
+
value = cache_get(instance)
|
|
184
310
|
if value is not _FIXPOINT_SENTINEL:
|
|
185
311
|
max_iterations = self._get_max_iterations()
|
|
186
312
|
if max_iterations == 0:
|
|
187
313
|
return value
|
|
188
|
-
# Detect reentry: if this key is currently being computed
|
|
189
|
-
# (on the call stack), accessing its cached approximation
|
|
190
|
-
# means the fixpoint has not converged yet.
|
|
191
314
|
context = _fixpoint_context_var.get()
|
|
192
315
|
if context is not None:
|
|
193
316
|
key = (id(instance), self.attrname)
|
|
@@ -202,18 +325,15 @@ class fixpoint_cached_property:
|
|
|
202
325
|
key = (instance_id, self.attrname)
|
|
203
326
|
|
|
204
327
|
if context is None:
|
|
205
|
-
# I am the driver — start a digest loop (or single-pass for max_iterations=0)
|
|
206
328
|
context = _FixpointContext(
|
|
207
329
|
clearable_attr_names=frozenset(_fixpoint_clearable_attrs)
|
|
208
330
|
)
|
|
209
331
|
token = _fixpoint_context_var.set(context)
|
|
210
332
|
try:
|
|
211
333
|
if max_iterations == 0:
|
|
212
|
-
# Zero-iteration mode: compute once with reentry detection.
|
|
213
|
-
# Reentry raises FixpointRecursionError instead of infinite recursion.
|
|
214
334
|
context.computing.add(key)
|
|
215
335
|
result = self.func(instance)
|
|
216
|
-
|
|
336
|
+
cache_set(instance, result)
|
|
217
337
|
return result
|
|
218
338
|
|
|
219
339
|
approximation = self._bottom()
|
|
@@ -225,35 +345,31 @@ class fixpoint_cached_property:
|
|
|
225
345
|
result = self.func(instance)
|
|
226
346
|
|
|
227
347
|
if not context.reentrant:
|
|
228
|
-
|
|
229
|
-
cache[self.attrname] = result
|
|
348
|
+
cache_set(instance, result)
|
|
230
349
|
return result
|
|
231
350
|
|
|
232
351
|
if self._accumulate is not None:
|
|
233
|
-
# Monotonic accumulation: merge each iteration's
|
|
234
|
-
# result into an accumulator that only grows.
|
|
235
|
-
# This prevents oscillation when intermediate
|
|
236
|
-
# computations encounter cycles in varying order.
|
|
237
352
|
changed = self._accumulate(accumulator, result)
|
|
238
353
|
if not changed and iteration > 0:
|
|
239
|
-
|
|
354
|
+
cache_set(instance, accumulator)
|
|
240
355
|
return accumulator
|
|
241
|
-
# Use the accumulator as next round's approximation
|
|
242
356
|
approximation = accumulator
|
|
357
|
+
elif self._merge is not None:
|
|
358
|
+
merged = self._merge(approximation, result)
|
|
359
|
+
if merged == approximation and iteration > 0:
|
|
360
|
+
cache_set(instance, merged)
|
|
361
|
+
return merged
|
|
362
|
+
approximation = merged
|
|
243
363
|
else:
|
|
244
|
-
# Exact equality convergence (original behavior)
|
|
245
364
|
if result == previous_result:
|
|
246
|
-
|
|
365
|
+
cache_set(instance, result)
|
|
247
366
|
return result
|
|
248
367
|
previous_result = result
|
|
249
368
|
approximation = result
|
|
250
369
|
|
|
251
|
-
|
|
252
|
-
# caches, and re-run
|
|
253
|
-
cache[self.attrname] = approximation
|
|
370
|
+
cache_set(instance, approximation)
|
|
254
371
|
context.clear_participant_caches()
|
|
255
|
-
|
|
256
|
-
cache[self.attrname] = approximation
|
|
372
|
+
cache_set(instance, approximation)
|
|
257
373
|
context.computing.clear()
|
|
258
374
|
context.reentrant = False
|
|
259
375
|
|
|
@@ -266,7 +382,6 @@ class fixpoint_cached_property:
|
|
|
266
382
|
finally:
|
|
267
383
|
_fixpoint_context_var.reset(token)
|
|
268
384
|
elif key in context.computing:
|
|
269
|
-
# Reentry detected — return previous approximation or bottom.
|
|
270
385
|
context.reentrant = True
|
|
271
386
|
context.add_participant(instance)
|
|
272
387
|
if max_iterations == 0:
|
|
@@ -275,9 +390,7 @@ class fixpoint_cached_property:
|
|
|
275
390
|
f"reentry detected with max_fixpoint_iterations=0",
|
|
276
391
|
incomplete_result=self._bottom(),
|
|
277
392
|
)
|
|
278
|
-
|
|
279
|
-
# approximations from the previous iteration.
|
|
280
|
-
approximation = cache.get(self.attrname, _FIXPOINT_SENTINEL)
|
|
393
|
+
approximation = cache_get(instance)
|
|
281
394
|
if approximation is not _FIXPOINT_SENTINEL:
|
|
282
395
|
return approximation
|
|
283
396
|
saved = context.approximations.get(key, _FIXPOINT_SENTINEL)
|
|
@@ -285,22 +398,17 @@ class fixpoint_cached_property:
|
|
|
285
398
|
return saved
|
|
286
399
|
return self._bottom()
|
|
287
400
|
else:
|
|
288
|
-
# Inside a fixpoint context but this is a fresh (instance, attr)
|
|
289
|
-
# pair — compute normally. Keep the key in ``computing`` only
|
|
290
|
-
# while ``self.func`` runs so that cycles through this key are
|
|
291
|
-
# detected by the ``elif`` branch above. Once computation
|
|
292
|
-
# finishes, remove the key so the fast-path cache check does
|
|
293
|
-
# not misidentify a later read of this cached value as reentry.
|
|
294
401
|
context.computing.add(key)
|
|
295
402
|
context.add_participant(instance)
|
|
296
403
|
result = self.func(instance)
|
|
297
404
|
context.computing.discard(key)
|
|
298
|
-
|
|
405
|
+
cache_set(instance, result)
|
|
299
406
|
return result
|
|
300
407
|
|
|
301
408
|
def __set__(self, instance: object, value: object) -> None:
|
|
302
409
|
"""Data descriptor setter to ensure __get__ is always called."""
|
|
303
|
-
|
|
410
|
+
self._ensure_accessors(instance)
|
|
411
|
+
self._cache_set(instance, value)
|
|
304
412
|
|
|
305
413
|
|
|
306
414
|
class _fixpoint_dependent_property:
|
|
@@ -317,31 +425,43 @@ class _fixpoint_dependent_property:
|
|
|
317
425
|
self.func = func
|
|
318
426
|
self.attrname = func.__name__
|
|
319
427
|
self.__doc__ = func.__doc__
|
|
428
|
+
self._cache_get = self._cache_set = self._cache_pop = None
|
|
320
429
|
_fixpoint_clearable_attrs.add(self.attrname)
|
|
321
430
|
|
|
322
431
|
def __set_name__(self, owner: type, name: str) -> None:
|
|
323
432
|
if not hasattr(self, "attrname"):
|
|
324
433
|
self.attrname = name
|
|
325
434
|
_fixpoint_clearable_attrs.add(self.attrname)
|
|
435
|
+
self._bind_accessors(owner)
|
|
436
|
+
|
|
437
|
+
def _bind_accessors(self, owner: type) -> None:
|
|
438
|
+
slot_name = _CACHE_SLOT_PREFIX + self.attrname
|
|
439
|
+
if slot_name in getattr(owner, "__slots__", ()):
|
|
440
|
+
self._cache_get, self._cache_set, self._cache_pop = _make_slot_accessors(slot_name)
|
|
441
|
+
else:
|
|
442
|
+
self._cache_get, self._cache_set, self._cache_pop = _make_dict_accessors(self.attrname)
|
|
443
|
+
|
|
444
|
+
def _ensure_accessors(self, instance: object) -> None:
|
|
445
|
+
if self._cache_get is not None:
|
|
446
|
+
return
|
|
447
|
+
self._bind_accessors(type(instance))
|
|
326
448
|
|
|
327
449
|
def __get__(self, instance: object, owner: type = None) -> object:
|
|
328
450
|
if instance is None:
|
|
329
451
|
return self
|
|
330
452
|
|
|
331
|
-
|
|
332
|
-
value =
|
|
333
|
-
if value is not
|
|
453
|
+
self._ensure_accessors(instance)
|
|
454
|
+
value = self._cache_get(instance)
|
|
455
|
+
if value is not _FIXPOINT_SENTINEL:
|
|
334
456
|
return value
|
|
335
457
|
|
|
336
458
|
if fixpoint_cached_property._get_max_iterations() > 0:
|
|
337
|
-
# Register as participant so clear_participant_caches can
|
|
338
|
-
# invalidate this cached value between fixpoint iterations.
|
|
339
459
|
context = _fixpoint_context_var.get()
|
|
340
460
|
if context is not None:
|
|
341
461
|
context.add_participant(instance)
|
|
342
462
|
|
|
343
463
|
value = self.func(instance)
|
|
344
|
-
|
|
464
|
+
self._cache_set(instance, value)
|
|
345
465
|
return value
|
|
346
466
|
|
|
347
467
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|