fixpoints 0.4.0.post85.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.
@@ -0,0 +1,241 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ **/docs/_build/
73
+ **/docs/api/
74
+
75
+ # PyBuilder
76
+ .pybuilder/
77
+ target/
78
+
79
+ # Jupyter Notebook
80
+ .ipynb_checkpoints
81
+ .jupyter_ystore.db
82
+
83
+ # IPython
84
+ profile_default/
85
+ ipython_config.py
86
+
87
+ # pyenv
88
+ # For a library or package, you might want to ignore these files since the code is
89
+ # intended to run in multiple environments; otherwise, check them in:
90
+ # .python-version
91
+
92
+ # pipenv
93
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
94
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
95
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
96
+ # install all needed dependencies.
97
+ #Pipfile.lock
98
+
99
+ # UV
100
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
101
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
102
+ # commonly ignored for libraries.
103
+ #uv.lock
104
+
105
+ # poetry
106
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
107
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
108
+ # commonly ignored for libraries.
109
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
110
+ #poetry.lock
111
+ #poetry.toml
112
+
113
+ # pdm
114
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
115
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
116
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
117
+ #pdm.lock
118
+ #pdm.toml
119
+ .pdm-python
120
+ .pdm-build/
121
+
122
+ # pixi
123
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
124
+ #pixi.lock
125
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
126
+ # in the .venv directory. It is recommended not to include this directory in version control.
127
+ .pixi
128
+
129
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
130
+ __pypackages__/
131
+
132
+ # Celery stuff
133
+ celerybeat-schedule
134
+ celerybeat.pid
135
+
136
+ # SageMath parsed files
137
+ *.sage.py
138
+
139
+ # Environments
140
+ .env
141
+ .venv
142
+ env/
143
+ venv/
144
+ ENV/
145
+ env.bak/
146
+ venv.bak/
147
+
148
+ # Spyder project settings
149
+ .spyderproject
150
+ .spyproject
151
+
152
+ # Rope project settings
153
+ .ropeproject
154
+
155
+ # mkdocs documentation
156
+ /site
157
+
158
+ # mypy
159
+ .mypy_cache/
160
+ .dmypy.json
161
+ dmypy.json
162
+
163
+ # Pyre type checker
164
+ .pyre/
165
+
166
+ # pytype static type analyzer
167
+ .pytype/
168
+
169
+ # Cython debug symbols
170
+ cython_debug/
171
+
172
+ # PyCharm
173
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
174
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
175
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
176
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
177
+ #.idea/
178
+
179
+ # Abstra
180
+ # Abstra is an AI-powered process automation framework.
181
+ # Ignore directories containing user credentials, local state, and settings.
182
+ # Learn more at https://abstra.io/docs
183
+ .abstra/
184
+
185
+ # Visual Studio Code
186
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
187
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
188
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
189
+ # you could uncomment the following to ignore the entire vscode folder
190
+ # .vscode/
191
+
192
+ # Ruff stuff:
193
+ .ruff_cache/
194
+
195
+ # PyPI configuration file
196
+ .pypirc
197
+
198
+ # Cursor
199
+ # Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
200
+ # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
201
+ # refer to https://docs.cursor.com/context/ignore-files
202
+ .cursorignore
203
+ .cursorindexingignore
204
+
205
+ # Marimo
206
+ marimo/_static/
207
+ marimo/_lsp/
208
+ __marimo__/
209
+
210
+ .direnv/
211
+ .devenv/
212
+ result
213
+
214
+ # LaTeX
215
+ *.pdf
216
+ *.aux
217
+ *.fls
218
+ *.fdb_latexmk
219
+ *.synctex.gz
220
+ *.bbl
221
+ *.blg
222
+ *.out
223
+ *.dvi
224
+ *.xcp
225
+
226
+ # Local data
227
+ data/
228
+ trajectory/
229
+ experiment_results.db
230
+
231
+ .playwright-mcp/
232
+ *.local.*
233
+ .envrc.private
234
+ .pre-commit-config.yaml
235
+
236
+ # nixago: ignore-linked-files
237
+ /.vscode/extensions.json
238
+
239
+ /inheritance-calculus/arxiv-submission.tar.gz
240
+ node_modules/
241
+ inheritance-calculus/comment.cut
@@ -0,0 +1,25 @@
1
+ Metadata-Version: 2.4
2
+ Name: fixpoints
3
+ Version: 0.4.0.post85.dev0
4
+ Summary: Least-fixpoint cached-property infrastructure for mutual recursion
5
+ Project-URL: Repository, https://github.com/Atry/MIXINv2
6
+ Author-email: "Yang, Bo" <yang-bo@yang-bo.com>
7
+ License-Expression: MIT
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3.13
11
+ Classifier: Programming Language :: Python :: 3.14
12
+ Requires-Python: >=3.13
13
+ Description-Content-Type: text/markdown
14
+
15
+ # fixpoints
16
+
17
+ Least-fixpoint cached-property infrastructure for mutual recursion.
18
+
19
+ `fixpoints` provides `fixpoint_cached_property` and `fixpoint_dependent`, drop-in
20
+ replacements for `functools.cached_property` that resolve mutually recursive
21
+ computations by least-fixpoint iteration. When reentry (a cycle) is detected, the
22
+ outermost caller drives a digest loop that re-evaluates participants until their
23
+ values stabilize, starting from a configurable bottom value.
24
+
25
+ This package depends only on the Python standard library.
@@ -0,0 +1,11 @@
1
+ # fixpoints
2
+
3
+ Least-fixpoint cached-property infrastructure for mutual recursion.
4
+
5
+ `fixpoints` provides `fixpoint_cached_property` and `fixpoint_dependent`, drop-in
6
+ replacements for `functools.cached_property` that resolve mutually recursive
7
+ computations by least-fixpoint iteration. When reentry (a cycle) is detected, the
8
+ outermost caller drives a digest loop that re-evaluates participants until their
9
+ values stabilize, starting from a configurable bottom value.
10
+
11
+ This package depends only on the Python standard library.
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["hatchling", "uv-dynamic-versioning>=0.7.0", "editables"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [tool.hatch.version]
6
+ source = "uv-dynamic-versioning"
7
+
8
+ [tool.uv-dynamic-versioning]
9
+ vcs = "git"
10
+ style = "pep440"
11
+ bump = false
12
+ fallback-version = "0.0.0.dev0"
13
+ metadata = false
14
+
15
+ [project]
16
+ name = "fixpoints"
17
+ dynamic = ["version"]
18
+ description = "Least-fixpoint cached-property infrastructure for mutual recursion"
19
+ readme = "README.md"
20
+ license = "MIT"
21
+ requires-python = ">=3.13"
22
+ authors = [{ name = "Yang, Bo", email = "yang-bo@yang-bo.com" }]
23
+ classifiers = [
24
+ "Development Status :: 3 - Alpha",
25
+ "License :: OSI Approved :: MIT License",
26
+ "Programming Language :: Python :: 3.13",
27
+ "Programming Language :: Python :: 3.14",
28
+ ]
29
+ dependencies = []
30
+
31
+ [tool.hatch.build.targets.wheel]
32
+ packages = ["src/fixpoints"]
33
+ only-include = ["src/fixpoints"]
34
+ sources = ["src"]
35
+
36
+ [project.urls]
37
+ Repository = "https://github.com/Atry/MIXINv2"
@@ -0,0 +1,8 @@
1
+ """Least-fixpoint cached-property infrastructure for mutual recursion.
2
+
3
+ Import the public symbols directly from :mod:`fixpoints._core`::
4
+
5
+ from fixpoints._core import fixpoint_cached_property
6
+ from fixpoints._core import fixpoint_dependent
7
+ from fixpoints._core import FixpointRecursionError
8
+ """
@@ -0,0 +1,370 @@
1
+ """Least-fixpoint cached-property infrastructure for mutual recursion.
2
+
3
+ ``fixpoint_cached_property`` and ``fixpoint_dependent`` are drop-in
4
+ replacements for ``functools.cached_property`` that resolve mutually
5
+ recursive computations by least-fixpoint iteration. When reentry (a cycle)
6
+ is detected, the outermost caller drives a digest loop that re-evaluates
7
+ participants until their values stabilize, starting from a configurable
8
+ bottom value.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import itertools
14
+ import math
15
+ from collections import defaultdict
16
+ from contextvars import ContextVar
17
+ from enum import Enum
18
+ from functools import cached_property
19
+ from typing import Callable, ClassVar
20
+
21
+
22
+ class _FixpointContext:
23
+ """Tracks the state of a fixpoint iteration (digest cycle).
24
+
25
+ Stored in a ContextVar so that nested/concurrent fixpoint computations
26
+ are isolated per-thread/per-coroutine.
27
+ """
28
+
29
+ __slots__ = ("computing", "reentrant", "participant_ids", "participant_refs",
30
+ "_clearable_attr_names", "approximations")
31
+
32
+ def __init__(self, clearable_attr_names: frozenset[str]) -> None:
33
+ self.computing: set[tuple[int, str]] = set()
34
+ self.reentrant: bool = False
35
+ self.participant_ids: set[int] = set()
36
+ self.participant_refs: list[object] = []
37
+ self._clearable_attr_names = clearable_attr_names
38
+ self.approximations: dict[tuple[int, str], object] = {}
39
+
40
+ def add_participant(self, instance: object) -> None:
41
+ instance_id = id(instance)
42
+ if instance_id not in self.participant_ids:
43
+ self.participant_ids.add(instance_id)
44
+ self.participant_refs.append(instance)
45
+
46
+ def clear_participant_caches(self) -> None:
47
+ """Clear all fixpoint-related cached values on all participants.
48
+
49
+ Before clearing, save each value into ``approximations`` so that
50
+ intermediate fixpoint_cached_property computations can use their
51
+ previous iteration's result as an approximation instead of bottom
52
+ when they encounter reentry.
53
+ """
54
+ for instance in self.participant_refs:
55
+ instance_dict = instance.__dict__
56
+ instance_id = id(instance)
57
+ for attr_name in self._clearable_attr_names:
58
+ value = instance_dict.pop(attr_name, None)
59
+ if value is not None:
60
+ self.approximations[(instance_id, attr_name)] = value
61
+
62
+
63
+ _fixpoint_context_var: ContextVar[_FixpointContext | None] = ContextVar(
64
+ "_fixpoint_context_var", default=None
65
+ )
66
+
67
+
68
+ class FixpointRecursionError(RecursionError):
69
+ """Raised when fixpoint iteration is exhausted or reentry is detected with no iterations remaining.
70
+
71
+ Carries the best approximation computed so far in ``incomplete_result``.
72
+ As a ``RecursionError`` subclass, existing code that catches ``RecursionError``
73
+ will also catch ``FixpointRecursionError``.
74
+ """
75
+
76
+ incomplete_result: object
77
+
78
+ def __init__(self, message: str, *, incomplete_result: object) -> None:
79
+ super().__init__(message)
80
+ self.incomplete_result = incomplete_result
81
+
82
+
83
+ _FIXPOINT_SENTINEL = object()
84
+
85
+ # Registry of attribute names that need clearing during fixpoint digest cycles.
86
+ # Populated by fixpoint_cached_property and fixpoint_dependent decorators.
87
+ _fixpoint_clearable_attrs: set[str] = set()
88
+
89
+
90
+ def _accumulate_defaultdict_set(
91
+ accumulator: defaultdict[object, set[object]],
92
+ new_result: defaultdict[object, set[object]],
93
+ ) -> bool:
94
+ """Merge new_result into accumulator (pointwise set union).
95
+
96
+ Returns True if accumulator grew (new entries were added).
97
+ """
98
+ changed = False
99
+ for key, values in new_result.items():
100
+ existing = accumulator[key]
101
+ old_size = len(existing)
102
+ existing.update(values)
103
+ if len(existing) > old_size:
104
+ changed = True
105
+ return changed
106
+
107
+
108
+ class FixpointIterationSentinel(Enum):
109
+ UNLIMITED = math.inf
110
+
111
+
112
+ class fixpoint_cached_property:
113
+ """A cached_property that supports mutual-recursion via least fixpoint iteration.
114
+
115
+ API-compatible with functools.cached_property. When reentry is detected
116
+ (mutual recursion), returns the previous iteration's approximation
117
+ (or ``bottom()`` on the first iteration). The outermost caller drives
118
+ a digest loop until values stabilize (no reentry occurs in a round).
119
+
120
+ Usage::
121
+
122
+ @fixpoint_cached_property(bottom=lambda: defaultdict(set))
123
+ def qualified_this(self):
124
+ ...
125
+
126
+ The class-level ``max_fixpoint_iterations`` ContextVar controls the
127
+ maximum number of digest rounds. ``0`` disables fixpoint iteration
128
+ and raises ``FixpointRecursionError`` on reentry. Default
129
+ ``FixpointIterationSentinel.UNLIMITED`` iterates until convergence or
130
+ until Python's stack is exhausted::
131
+
132
+ fixpoint_cached_property.max_fixpoint_iterations.set(0) # single-pass
133
+ fixpoint_cached_property.max_fixpoint_iterations.set(100) # bounded multi-pass
134
+ fixpoint_cached_property.max_fixpoint_iterations.set(FixpointIterationSentinel.UNLIMITED) # unbounded (default)
135
+ """
136
+
137
+ max_fixpoint_iterations: ClassVar[ContextVar[int | FixpointIterationSentinel]] = ContextVar(
138
+ "fixpoint_cached_property.max_fixpoint_iterations", default=FixpointIterationSentinel.UNLIMITED
139
+ )
140
+
141
+ def __init__(
142
+ self,
143
+ func: Callable = None,
144
+ *,
145
+ bottom: Callable[[], object],
146
+ accumulate: Callable[[object, object], bool] | None = None,
147
+ ) -> None:
148
+ # Support both @fixpoint_cached_property(bottom=...) and direct call
149
+ self._bottom = bottom
150
+ self._accumulate = accumulate
151
+ if func is not None:
152
+ self.func: Callable = func
153
+ self.attrname: str = func.__name__
154
+ self.__doc__ = func.__doc__
155
+ _fixpoint_clearable_attrs.add(self.attrname)
156
+
157
+ def __call__(self, func: Callable) -> "fixpoint_cached_property":
158
+ """Support @fixpoint_cached_property(bottom=...) decorator syntax."""
159
+ self.func = func
160
+ self.attrname = func.__name__
161
+ self.__doc__ = func.__doc__
162
+ _fixpoint_clearable_attrs.add(self.attrname)
163
+ return self
164
+
165
+ def __set_name__(self, owner: type, name: str) -> None:
166
+ if not hasattr(self, "attrname"):
167
+ self.attrname = name
168
+ _fixpoint_clearable_attrs.add(self.attrname)
169
+
170
+ @classmethod
171
+ def _get_max_iterations(cls) -> int | float:
172
+ raw = cls.max_fixpoint_iterations.get()
173
+ if isinstance(raw, FixpointIterationSentinel):
174
+ return raw.value
175
+ return raw
176
+
177
+ def __get__(self, instance: object, owner: type = None) -> object:
178
+ if instance is None:
179
+ return self
180
+
181
+ # Fast path: already cached
182
+ cache = instance.__dict__
183
+ value = cache.get(self.attrname, _FIXPOINT_SENTINEL)
184
+ if value is not _FIXPOINT_SENTINEL:
185
+ max_iterations = self._get_max_iterations()
186
+ if max_iterations == 0:
187
+ 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
+ context = _fixpoint_context_var.get()
192
+ if context is not None:
193
+ key = (id(instance), self.attrname)
194
+ if key in context.computing:
195
+ context.reentrant = True
196
+ context.add_participant(instance)
197
+ return value
198
+
199
+ max_iterations = self._get_max_iterations()
200
+ context = _fixpoint_context_var.get()
201
+ instance_id = id(instance)
202
+ key = (instance_id, self.attrname)
203
+
204
+ if context is None:
205
+ # I am the driver — start a digest loop (or single-pass for max_iterations=0)
206
+ context = _FixpointContext(
207
+ clearable_attr_names=frozenset(_fixpoint_clearable_attrs)
208
+ )
209
+ token = _fixpoint_context_var.set(context)
210
+ try:
211
+ if max_iterations == 0:
212
+ # Zero-iteration mode: compute once with reentry detection.
213
+ # Reentry raises FixpointRecursionError instead of infinite recursion.
214
+ context.computing.add(key)
215
+ result = self.func(instance)
216
+ cache[self.attrname] = result
217
+ return result
218
+
219
+ approximation = self._bottom()
220
+ accumulator = self._bottom() if self._accumulate is not None else None
221
+ previous_result = _FIXPOINT_SENTINEL
222
+ for iteration in itertools.count():
223
+ context.computing.add(key)
224
+ context.add_participant(instance)
225
+ result = self.func(instance)
226
+
227
+ if not context.reentrant:
228
+ # No reentry this round — fixpoint reached
229
+ cache[self.attrname] = result
230
+ return result
231
+
232
+ 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
+ changed = self._accumulate(accumulator, result)
238
+ if not changed and iteration > 0:
239
+ cache[self.attrname] = accumulator
240
+ return accumulator
241
+ # Use the accumulator as next round's approximation
242
+ approximation = accumulator
243
+ else:
244
+ # Exact equality convergence (original behavior)
245
+ if result == previous_result:
246
+ cache[self.attrname] = result
247
+ return result
248
+ previous_result = result
249
+ approximation = result
250
+
251
+ # Cache current approximation, clear all intermediate
252
+ # caches, and re-run
253
+ cache[self.attrname] = approximation
254
+ context.clear_participant_caches()
255
+ # Restore driver's own approximation
256
+ cache[self.attrname] = approximation
257
+ context.computing.clear()
258
+ context.reentrant = False
259
+
260
+ if iteration + 1 >= max_iterations:
261
+ raise FixpointRecursionError(
262
+ f"fixpoint_cached_property '{self.attrname}' did not converge "
263
+ f"after {max_iterations} iterations",
264
+ incomplete_result=approximation,
265
+ )
266
+ finally:
267
+ _fixpoint_context_var.reset(token)
268
+ elif key in context.computing:
269
+ # Reentry detected — return previous approximation or bottom.
270
+ context.reentrant = True
271
+ context.add_participant(instance)
272
+ if max_iterations == 0:
273
+ raise FixpointRecursionError(
274
+ f"fixpoint_cached_property '{self.attrname}': "
275
+ f"reentry detected with max_fixpoint_iterations=0",
276
+ incomplete_result=self._bottom(),
277
+ )
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)
281
+ if approximation is not _FIXPOINT_SENTINEL:
282
+ return approximation
283
+ saved = context.approximations.get(key, _FIXPOINT_SENTINEL)
284
+ if saved is not _FIXPOINT_SENTINEL:
285
+ return saved
286
+ return self._bottom()
287
+ 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
+ context.computing.add(key)
295
+ context.add_participant(instance)
296
+ result = self.func(instance)
297
+ context.computing.discard(key)
298
+ cache[self.attrname] = result
299
+ return result
300
+
301
+ def __set__(self, instance: object, value: object) -> None:
302
+ """Data descriptor setter to ensure __get__ is always called."""
303
+ instance.__dict__[self.attrname] = value
304
+
305
+
306
+ class _fixpoint_dependent_property:
307
+ """A cached_property that registers its instance as a fixpoint participant.
308
+
309
+ Behaves like ``functools.cached_property`` but, when computed inside an
310
+ active fixpoint context, registers the instance so that
311
+ ``clear_participant_caches`` will clear the cached value between
312
+ iterations. Without this, stale values computed from an incomplete
313
+ fixpoint approximation survive across iterations.
314
+ """
315
+
316
+ def __init__(self, func: Callable) -> None:
317
+ self.func = func
318
+ self.attrname = func.__name__
319
+ self.__doc__ = func.__doc__
320
+ _fixpoint_clearable_attrs.add(self.attrname)
321
+
322
+ def __set_name__(self, owner: type, name: str) -> None:
323
+ if not hasattr(self, "attrname"):
324
+ self.attrname = name
325
+ _fixpoint_clearable_attrs.add(self.attrname)
326
+
327
+ def __get__(self, instance: object, owner: type = None) -> object:
328
+ if instance is None:
329
+ return self
330
+
331
+ cache = instance.__dict__
332
+ value = cache.get(self.attrname)
333
+ if value is not None:
334
+ return value
335
+
336
+ 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
+ context = _fixpoint_context_var.get()
340
+ if context is not None:
341
+ context.add_participant(instance)
342
+
343
+ value = self.func(instance)
344
+ cache[self.attrname] = value
345
+ return value
346
+
347
+
348
+ def fixpoint_dependent(func: Callable) -> _fixpoint_dependent_property:
349
+ """Mark a cached_property as dependent on fixpoint_cached_property values.
350
+
351
+ During fixpoint digest cycles, these caches are cleared between iterations
352
+ so they are recomputed with updated approximations.
353
+
354
+ Usage::
355
+
356
+ @fixpoint_dependent
357
+ @cached_property
358
+ def symbol_kind(self):
359
+ ...
360
+
361
+ Or equivalently::
362
+
363
+ @fixpoint_dependent
364
+ def symbol_kind(self):
365
+ ...
366
+ """
367
+ if isinstance(func, cached_property):
368
+ return _fixpoint_dependent_property(func.func)
369
+ else:
370
+ return _fixpoint_dependent_property(func)
@@ -0,0 +1,271 @@
1
+ """Tests for fixpoint_cached_property fixpoint-iteration and FixpointRecursionError behavior."""
2
+
3
+ from collections import defaultdict
4
+
5
+ import pytest
6
+
7
+ from fixpoints._core import (
8
+ FixpointIterationSentinel,
9
+ FixpointRecursionError,
10
+ _accumulate_defaultdict_set,
11
+ fixpoint_cached_property,
12
+ )
13
+
14
+
15
+ class TestDivergentConvergenceBehavior:
16
+ """Tests showing different convergence behavior with different max_fixpoint_iterations.
17
+
18
+ The inheritance-calculus paper (Section 7) defines a translation T from
19
+ the lazy λ-calculus to mixin trees. The mixin-tree equations for the
20
+ ``this`` function (qualified-this resolution) form a monotone system
21
+ whose least fixpoint is computed iteratively when max_fixpoint_iterations > 0.
22
+
23
+ With max_fixpoint_iterations=0, cyclic dependencies in the ``this``
24
+ function raise ``FixpointRecursionError`` because reentry is detected with no iterations
25
+ remaining to converge.
26
+
27
+ The cycle pattern arises from self-referential λ-terms such as the
28
+ self-application combinator Ω = (λx. x x)(λx. x x). The T
29
+ translation maps Ω to a mixin tree where the ``tailCall`` scope
30
+ inherits from ``↑1.argument`` (the enclosing lambda's argument slot).
31
+ After composition, this creates a cycle in the ``this`` function:
32
+ computing ``this(p, p_def)`` for one scope requires ``this`` for
33
+ another scope, which in turn requires the first.
34
+
35
+ The tests below use ``fixpoint_cached_property`` directly — the same
36
+ mechanism that implements ``qualified_this`` in the MixinSymbol —
37
+ to demonstrate the divergence/convergence difference.
38
+ """
39
+
40
+ def _make_transitive_closure_nodes(
41
+ self,
42
+ initial_a: dict[str, set[int]],
43
+ initial_b: dict[str, set[int]],
44
+ ) -> tuple[object, object]:
45
+ """Create two nodes with mutually recursive transitive closure.
46
+
47
+ Each node's ``reachable`` property is the union of its own values
48
+ and everything reachable from the other node. This is analogous
49
+ to the ``this(p, p_def)`` function: ``this(p) = own(p) ∪
50
+ ⋃{this(q) | q ∈ supers(p)}``, which forms a monotone system
51
+ over set-valued lattices.
52
+
53
+ The mutual dependence mirrors the cycle that arises in
54
+ ``qualified_this`` when a scope's overrides depend on the
55
+ qualified-this of another scope, which in turn depends on the
56
+ first scope's overrides.
57
+ """
58
+
59
+ class TransitiveClosureNode:
60
+ def __init__(self, initial_values: dict[str, set[int]]) -> None:
61
+ self.__dict__["_initial_values"] = initial_values
62
+ self.__dict__["_other"] = None
63
+
64
+ def set_other(self, other: "TransitiveClosureNode") -> None:
65
+ self.__dict__["_other"] = other
66
+
67
+ @fixpoint_cached_property(
68
+ bottom=lambda: defaultdict(set),
69
+ accumulate=_accumulate_defaultdict_set,
70
+ )
71
+ def reachable(self) -> defaultdict[str, set[int]]:
72
+ result: defaultdict[str, set[int]] = defaultdict(set)
73
+ for key, values in self._initial_values.items():
74
+ result[key].update(values)
75
+ if self._other is not None:
76
+ for key, values in self._other.reachable.items():
77
+ result[key].update(values)
78
+ return result
79
+
80
+ node_a = TransitiveClosureNode(initial_a)
81
+ node_b = TransitiveClosureNode(initial_b)
82
+ node_a.set_other(node_b)
83
+ node_b.set_other(node_a)
84
+ return node_a, node_b
85
+
86
+ def test_fixpoint_converges_on_mutual_recursion(self) -> None:
87
+ """max_fixpoint_iterations=100 resolves mutual recursion via iterative approximation.
88
+
89
+ Analogous to Datalog transitive closure or the ``this`` fixpoint:
90
+ the computation starts with ⊥ (empty set), and each iteration
91
+ discovers more reachable elements until convergence.
92
+ """
93
+ token = fixpoint_cached_property.max_fixpoint_iterations.set(100)
94
+ try:
95
+ node_a, node_b = self._make_transitive_closure_nodes(
96
+ initial_a={"x": {1, 2}},
97
+ initial_b={"y": {3, 4}},
98
+ )
99
+ reachable_a = dict(node_a.reachable)
100
+ reachable_b = dict(node_b.reachable)
101
+ finally:
102
+ fixpoint_cached_property.max_fixpoint_iterations.reset(token)
103
+
104
+ # Both nodes discover each other's values through fixpoint iteration
105
+ assert reachable_a["x"] == {1, 2}
106
+ assert reachable_a["y"] == {3, 4}
107
+ assert reachable_b["x"] == {1, 2}
108
+ assert reachable_b["y"] == {3, 4}
109
+
110
+ def test_zero_iterations_raises_bottom_on_mutual_recursion(self) -> None:
111
+ """max_fixpoint_iterations=0 raises FixpointRecursionError on mutual recursion.
112
+
113
+ With no fixpoint iterations allowed, the mutual dependency between
114
+ A and B triggers reentry detection. Unlike the old
115
+ INDEXED_HYLOMORPHISM (which had no reentry detection and caused
116
+ Python's natural stack overflow), max_fixpoint_iterations=0 detects
117
+ the reentry immediately and raises FixpointRecursionError with the incomplete result.
118
+ """
119
+ token = fixpoint_cached_property.max_fixpoint_iterations.set(0)
120
+ try:
121
+ node_a, _node_b = self._make_transitive_closure_nodes(
122
+ initial_a={"x": {1, 2}},
123
+ initial_b={"y": {3, 4}},
124
+ )
125
+ with pytest.raises(FixpointRecursionError) as exception_info:
126
+ node_a.reachable
127
+ assert isinstance(exception_info.value.incomplete_result, defaultdict)
128
+ finally:
129
+ fixpoint_cached_property.max_fixpoint_iterations.reset(token)
130
+
131
+ def test_fixpoint_converges_three_node_cycle(self) -> None:
132
+ """max_fixpoint_iterations=100 handles N-way cycles (A→B→C→A), not just 2-cycles.
133
+
134
+ This mirrors the 3-cycle in RelationalCycle.mixin.yaml (a→b→c→a),
135
+ where the transitive closure requires multiple fixpoint iterations
136
+ to discover all reachable pairs.
137
+ """
138
+
139
+ class TriCycleNode:
140
+ def __init__(self, initial_values: dict[str, set[int]]) -> None:
141
+ self.__dict__["_initial_values"] = initial_values
142
+ self.__dict__["_next"] = None
143
+
144
+ def set_next(self, other: "TriCycleNode") -> None:
145
+ self.__dict__["_next"] = other
146
+
147
+ @fixpoint_cached_property(
148
+ bottom=lambda: defaultdict(set),
149
+ accumulate=_accumulate_defaultdict_set,
150
+ )
151
+ def reachable(self) -> defaultdict[str, set[int]]:
152
+ result: defaultdict[str, set[int]] = defaultdict(set)
153
+ for key, values in self._initial_values.items():
154
+ result[key].update(values)
155
+ if self._next is not None:
156
+ for key, values in self._next.reachable.items():
157
+ result[key].update(values)
158
+ return result
159
+
160
+ token = fixpoint_cached_property.max_fixpoint_iterations.set(100)
161
+ try:
162
+ node_a = TriCycleNode({"a": {1}})
163
+ node_b = TriCycleNode({"b": {2}})
164
+ node_c = TriCycleNode({"c": {3}})
165
+ node_a.set_next(node_b)
166
+ node_b.set_next(node_c)
167
+ node_c.set_next(node_a)
168
+
169
+ reachable_a = dict(node_a.reachable)
170
+ finally:
171
+ fixpoint_cached_property.max_fixpoint_iterations.reset(token)
172
+
173
+ # All three values discovered through the cycle
174
+ assert reachable_a["a"] == {1}
175
+ assert reachable_a["b"] == {2}
176
+ assert reachable_a["c"] == {3}
177
+
178
+
179
+ class TestUnlimitedIterationsOmega:
180
+ """Tests that UNLIMITED iterations causes RecursionError (not FixpointRecursionError) for divergent computations."""
181
+
182
+ def test_omega_raises_recursion_error_not_bottom(self) -> None:
183
+ """With UNLIMITED, a divergent fixpoint hits Python's native RecursionError.
184
+
185
+ This simulates the Omega combinator: a computation that never converges.
186
+ With a finite limit, the fixpoint loop would raise FixpointRecursionError after exhausting
187
+ iterations. With UNLIMITED, the itertools.count() loop runs indefinitely,
188
+ and eventually Python's recursion limit is hit within a single iteration's
189
+ computation, raising a native RecursionError (not FixpointRecursionError).
190
+ """
191
+ iteration_count = 0
192
+
193
+ class OmegaNode:
194
+ def __init__(self) -> None:
195
+ self.__dict__["_other"] = None
196
+
197
+ def set_other(self, other: "OmegaNode") -> None:
198
+ self.__dict__["_other"] = other
199
+
200
+ @fixpoint_cached_property(bottom=lambda: 0)
201
+ def divergent(self) -> int:
202
+ nonlocal iteration_count
203
+ iteration_count += 1
204
+ if iteration_count > 200:
205
+ raise RecursionError("simulated stack overflow after 200 iterations")
206
+ # Return alternating values so it never converges
207
+ return self._other.divergent + 1
208
+
209
+ node_a = OmegaNode()
210
+ node_b = OmegaNode()
211
+ node_a.set_other(node_b)
212
+ node_b.set_other(node_a)
213
+
214
+ with pytest.raises(RecursionError) as exception_info:
215
+ node_a.divergent
216
+ # The error should be a native RecursionError, NOT a FixpointRecursionError
217
+ assert not isinstance(exception_info.value, FixpointRecursionError)
218
+ # Verify we actually ran past the old default of 100
219
+ assert iteration_count > 100
220
+
221
+
222
+ class TestFixpointRecursionErrorException:
223
+ """Tests for the FixpointRecursionError exception class."""
224
+
225
+ def test_bottom_is_recursion_error_subclass(self) -> None:
226
+ assert issubclass(FixpointRecursionError, RecursionError)
227
+
228
+ def test_negative_max_fixpoint_iterations_raises_bottom(self) -> None:
229
+ """Negative max_fixpoint_iterations is meaningless; ContextVar accepts any int."""
230
+ # ContextVar accepts any int value, but negative values are nonsensical.
231
+ # The fixpoint loop uses range(max_iterations), so negative values
232
+ # produce zero iterations and raise FixpointRecursionError on reentry (same as 0).
233
+ pass
234
+
235
+ def test_bottom_carries_incomplete_result(self) -> None:
236
+ """max_fixpoint_iterations=1 on a system needing 2+ iterations raises FixpointRecursionError with partial result."""
237
+ token = fixpoint_cached_property.max_fixpoint_iterations.set(1)
238
+ try:
239
+
240
+ class MutualNode:
241
+ def __init__(self, initial_values: dict[str, set[int]]) -> None:
242
+ self.__dict__["_initial_values"] = initial_values
243
+ self.__dict__["_other"] = None
244
+
245
+ def set_other(self, other: "MutualNode") -> None:
246
+ self.__dict__["_other"] = other
247
+
248
+ @fixpoint_cached_property(
249
+ bottom=lambda: defaultdict(set),
250
+ accumulate=_accumulate_defaultdict_set,
251
+ )
252
+ def reachable(self) -> defaultdict[str, set[int]]:
253
+ result: defaultdict[str, set[int]] = defaultdict(set)
254
+ for key, values in self._initial_values.items():
255
+ result[key].update(values)
256
+ if self._other is not None:
257
+ for key, values in self._other.reachable.items():
258
+ result[key].update(values)
259
+ return result
260
+
261
+ node_a = MutualNode({"x": {1}})
262
+ node_b = MutualNode({"y": {2}})
263
+ node_a.set_other(node_b)
264
+ node_b.set_other(node_a)
265
+
266
+ with pytest.raises(FixpointRecursionError) as exception_info:
267
+ node_a.reachable
268
+ # The incomplete result should be a defaultdict(set) with partial data
269
+ assert isinstance(exception_info.value.incomplete_result, defaultdict)
270
+ finally:
271
+ fixpoint_cached_property.max_fixpoint_iterations.reset(token)