fixpoints 0.4.0.post85.dev0__tar.gz → 0.5.0.post1.dev0__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.
@@ -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
- /inheritance-calculus/arxiv-submission.tar.gz
245
+ arxiv-submission.tar.gz
240
246
  node_modules/
241
- inheritance-calculus/comment.cut
247
+ comment.cut
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fixpoints
3
- Version: 0.4.0.post85.dev0
3
+ Version: 0.5.0.post1.dev0
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.13
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.13"
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 = instance_dict.pop(attr_name, None)
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
- cache = instance.__dict__
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
- cache[self.attrname] = result
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
- # No reentry this round — fixpoint reached
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
- cache[self.attrname] = accumulator
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
- cache[self.attrname] = result
365
+ cache_set(instance, result)
247
366
  return result
248
367
  previous_result = result
249
368
  approximation = result
250
369
 
251
- # Cache current approximation, clear all intermediate
252
- # caches, and re-run
253
- cache[self.attrname] = approximation
370
+ cache_set(instance, approximation)
254
371
  context.clear_participant_caches()
255
- # Restore driver's own approximation
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
- # Check the instance cache first, then fall back to saved
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
- cache[self.attrname] = result
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
- instance.__dict__[self.attrname] = value
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
- cache = instance.__dict__
332
- value = cache.get(self.attrname)
333
- if value is not None:
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
- cache[self.attrname] = value
464
+ self._cache_set(instance, value)
345
465
  return value
346
466
 
347
467