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.
- fixpoints-0.4.0.post85.dev0/.gitignore +241 -0
- fixpoints-0.4.0.post85.dev0/PKG-INFO +25 -0
- fixpoints-0.4.0.post85.dev0/README.md +11 -0
- fixpoints-0.4.0.post85.dev0/pyproject.toml +37 -0
- fixpoints-0.4.0.post85.dev0/src/fixpoints/__init__.py +8 -0
- fixpoints-0.4.0.post85.dev0/src/fixpoints/_core.py +370 -0
- fixpoints-0.4.0.post85.dev0/tests/test_fixpoint.py +271 -0
|
@@ -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)
|