dualdict 0.1.0a1__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.
- dualdict-0.1.0a1/LICENSE +21 -0
- dualdict-0.1.0a1/MANIFEST.in +12 -0
- dualdict-0.1.0a1/PKG-INFO +145 -0
- dualdict-0.1.0a1/README.md +170 -0
- dualdict-0.1.0a1/benchmarks/README.md +142 -0
- dualdict-0.1.0a1/benchmarks/api_v33.py +70 -0
- dualdict-0.1.0a1/benchmarks/bidict_v331.py +282 -0
- dualdict-0.1.0a1/benchmarks/compare.py +105 -0
- dualdict-0.1.0a1/benchmarks/compare_versions.py +119 -0
- dualdict-0.1.0a1/benchmarks/run.py +153 -0
- dualdict-0.1.0a1/benchmarks/run_bidict.py +123 -0
- dualdict-0.1.0a1/benchmarks/storage.py +17 -0
- dualdict-0.1.0a1/benchmarks/workloads.py +18 -0
- dualdict-0.1.0a1/cpp/CMakeLists.txt +53 -0
- dualdict-0.1.0a1/cpp/HARDENING.md +44 -0
- dualdict-0.1.0a1/cpp/LICENSE +21 -0
- dualdict-0.1.0a1/cpp/PYPI.md +50 -0
- dualdict-0.1.0a1/cpp/README.md +69 -0
- dualdict-0.1.0a1/cpp/THIRD_PARTY_NOTICES.txt +60 -0
- dualdict-0.1.0a1/cpp/V31.md +78 -0
- dualdict-0.1.0a1/cpp/V32.md +24 -0
- dualdict-0.1.0a1/cpp/V33.md +34 -0
- dualdict-0.1.0a1/cpp/V331.md +133 -0
- dualdict-0.1.0a1/cpp/build.py +40 -0
- dualdict-0.1.0a1/cpp/pyproject.toml +22 -0
- dualdict-0.1.0a1/cpp/src/module.cpp +203 -0
- dualdict-0.1.0a1/cpp/src/module_v31.cpp +302 -0
- dualdict-0.1.0a1/cpp/src/module_v331.cpp +356 -0
- dualdict-0.1.0a1/docs/PYPI.md +108 -0
- dualdict-0.1.0a1/docs/packaging.md +113 -0
- dualdict-0.1.0a1/docs/release-0.1.0a1.md +74 -0
- dualdict-0.1.0a1/dualdict/__init__.py +179 -0
- dualdict-0.1.0a1/dualdict/backends.py +26 -0
- dualdict-0.1.0a1/dualdict/v2.py +6 -0
- dualdict-0.1.0a1/dualdict/v2_python.py +177 -0
- dualdict-0.1.0a1/dualdict/v31_cpp.py +5 -0
- dualdict-0.1.0a1/dualdict/v32_cpp.py +3 -0
- dualdict-0.1.0a1/dualdict/v331_cpp.py +2 -0
- dualdict-0.1.0a1/dualdict/v33_cpp.py +3 -0
- dualdict-0.1.0a1/dualdict/v3_cpp.py +5 -0
- dualdict-0.1.0a1/dualdict.egg-info/PKG-INFO +145 -0
- dualdict-0.1.0a1/dualdict.egg-info/SOURCES.txt +53 -0
- dualdict-0.1.0a1/dualdict.egg-info/dependency_links.txt +1 -0
- dualdict-0.1.0a1/dualdict.egg-info/requires.txt +3 -0
- dualdict-0.1.0a1/dualdict.egg-info/top_level.txt +1 -0
- dualdict-0.1.0a1/examples/employee_directory.py +47 -0
- dualdict-0.1.0a1/pyproject.toml +23 -0
- dualdict-0.1.0a1/setup.cfg +4 -0
- dualdict-0.1.0a1/tests/data/dualdict_cases.py +98 -0
- dualdict-0.1.0a1/tests/data/supported_types.py +76 -0
- dualdict-0.1.0a1/tests/test_dualdict.py +356 -0
- dualdict-0.1.0a1/tests/test_v33.py +51 -0
- dualdict-0.1.0a1/tests/test_v331.py +98 -0
- dualdict-0.1.0a1/tests/test_v331_stress.py +199 -0
- dualdict-0.1.0a1/tests/v331_native_probe.py +351 -0
dualdict-0.1.0a1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Anjani
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
include README.md
|
|
2
|
+
include LICENSE
|
|
3
|
+
recursive-include docs *.md
|
|
4
|
+
recursive-include examples *.py
|
|
5
|
+
recursive-include tests *.py
|
|
6
|
+
recursive-include benchmarks *.py *.md
|
|
7
|
+
prune benchmarks/versions
|
|
8
|
+
prune benchmarks/comparisons
|
|
9
|
+
recursive-include cpp *.md *.toml *.txt *.py *.cpp
|
|
10
|
+
include cpp/LICENSE
|
|
11
|
+
prune cpp/dist
|
|
12
|
+
prune cpp/build
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dualdict
|
|
3
|
+
Version: 0.1.0a1
|
|
4
|
+
Summary: Bidirectional mapping with live tuple field proxies
|
|
5
|
+
License: MIT License
|
|
6
|
+
|
|
7
|
+
Copyright (c) 2026 Anjani
|
|
8
|
+
|
|
9
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
10
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
11
|
+
in the Software without restriction, including without limitation the rights
|
|
12
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
13
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
14
|
+
furnished to do so, subject to the following conditions:
|
|
15
|
+
|
|
16
|
+
The above copyright notice and this permission notice shall be included in all
|
|
17
|
+
copies or substantial portions of the Software.
|
|
18
|
+
|
|
19
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
20
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
21
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
22
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
23
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
24
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
25
|
+
SOFTWARE.
|
|
26
|
+
|
|
27
|
+
Keywords: bidirectional,mapping,tuple,live-proxy
|
|
28
|
+
Classifier: Development Status :: 3 - Alpha
|
|
29
|
+
Classifier: Programming Language :: Python :: 3
|
|
30
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
31
|
+
Requires-Python: >=3.10
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
License-File: LICENSE
|
|
34
|
+
Provides-Extra: benchmark
|
|
35
|
+
Requires-Dist: bidict==0.24.1; extra == "benchmark"
|
|
36
|
+
Dynamic: license-file
|
|
37
|
+
|
|
38
|
+
# DualDict
|
|
39
|
+
|
|
40
|
+
Experimental bidirectional mapping with unique keys and values. Live handles follow
|
|
41
|
+
a record through endpoint replacement and tuple-field edits. Deleting a record
|
|
42
|
+
permanently invalidates its handles, even if its old key is inserted again.
|
|
43
|
+
|
|
44
|
+
This is an alpha personal project under the MIT License. APIs may change.
|
|
45
|
+
Development is intermittent; no support or release schedule is promised.
|
|
46
|
+
|
|
47
|
+
## Installation
|
|
48
|
+
|
|
49
|
+
Once this version is published on PyPI:
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
python -m pip install dualdict==0.1.0a1
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The default implementation is pure Python, requires Python 3.10+, and needs no
|
|
56
|
+
compiler. Source-build CI exercises Python 3.10, 3.12 and 3.14 on Windows, Linux and
|
|
57
|
+
macOS; this is not a claim that every intervening version or platform was tested.
|
|
58
|
+
|
|
59
|
+
The optional native distribution is installed separately:
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
python -m pip install dualdict-cpp==0.3.3a1
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The initial native wheel targets **Windows x64, GIL-enabled CPython 3.12**. Other
|
|
66
|
+
configurations require a source build with a C++17 toolchain and Python development
|
|
67
|
+
headers (MSVC Build Tools on Windows). Isolated builds obtain CMake, nanobind and
|
|
68
|
+
other declared build dependencies. Source CI covers Windows, Linux and macOS but
|
|
69
|
+
does not provide a complete wheel matrix. Native code supports the main interpreter
|
|
70
|
+
only; free-threaded Python, PyPy and subinterpreters are unsupported.
|
|
71
|
+
|
|
72
|
+
## Python example
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from dualdict import DualDict
|
|
76
|
+
|
|
77
|
+
d = DualDict([(101, ("Alice", "Engineering"))])
|
|
78
|
+
employee = d.by_key[101] # live handle
|
|
79
|
+
employee[1] = "Research"
|
|
80
|
+
assert employee.snapshot() == ("Alice", "Research")
|
|
81
|
+
assert d.by_value[("Alice", "Research")].snapshot() == 101
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Optional native API
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from dualdict.v331_cpp import DualDict
|
|
88
|
+
|
|
89
|
+
d = DualDict([(101, ("Alice", "Engineering"))])
|
|
90
|
+
before = d.by_key[101] # raw endpoint
|
|
91
|
+
employee = d.by_key.live(101)
|
|
92
|
+
d.by_key.set_field(101, 1, "Research")
|
|
93
|
+
assert before == ("Alice", "Engineering")
|
|
94
|
+
assert employee.snapshot() == ("Alice", "Research")
|
|
95
|
+
assert d.by_value[("Alice", "Research")] == 101
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Backend selection is explicit. Installing the native package does not change
|
|
99
|
+
`from dualdict import DualDict`. Python indexed reads return live proxies; native
|
|
100
|
+
V3.3.1 indexed reads return raw endpoints. `view.snapshot(key)` returns a raw
|
|
101
|
+
endpoint in both APIs. Snapshots do not follow replacements and do not copy objects.
|
|
102
|
+
|
|
103
|
+
## Semantics and limitations
|
|
104
|
+
|
|
105
|
+
- Both endpoints must be hashable with stable, non-raising, non-reentrant hash and
|
|
106
|
+
equality behavior for their entire stored lifetime, including tuple members.
|
|
107
|
+
Do not externally mutate fields used by hash/equality. Equal objects collide.
|
|
108
|
+
- `add` rejects duplicate keys or values, including identical pairs. Assignment
|
|
109
|
+
inserts or replaces an endpoint but rejects another record's endpoint.
|
|
110
|
+
- Tuple-field edits require a plain tuple; nested edits, slices and deletion of
|
|
111
|
+
individual fields are excluded. Equal replacement retains the original object.
|
|
112
|
+
- Deletion and clear invalidate handles. Handles retain their mapping owner.
|
|
113
|
+
- `CollisionError`, `KeyError`, `TypeError` and `DeletedRecordError` report
|
|
114
|
+
collisions, missing lookups, invalid inputs and stale handles respectively.
|
|
115
|
+
- Bulk operations commit one entry at a time. Multiple calls are not transactions
|
|
116
|
+
and require application synchronization when shared. Hostile callbacks and
|
|
117
|
+
asynchronous interruption do not have transaction-level guarantees. A native
|
|
118
|
+
owner that cannot restore indexes after rollback failure disables itself and
|
|
119
|
+
must be discarded.
|
|
120
|
+
- Average lookup is O(1), excluding hash/equality cost. Tuple edits reconstruct
|
|
121
|
+
and hash an O(width) tuple. Benchmarks are workload-specific; no universal
|
|
122
|
+
speedup, memory saving or production-grade security assurance is claimed.
|
|
123
|
+
|
|
124
|
+
## Documentation, examples and reproducible benchmarks
|
|
125
|
+
|
|
126
|
+
The Python **source archive** includes `README.md`, `docs/`, `examples/`, `tests/`
|
|
127
|
+
and `benchmarks/`, including benchmark methodology in `benchmarks/README.md`.
|
|
128
|
+
These are not installed as runtime modules by the wheel. No public GitHub checkout
|
|
129
|
+
is required: download and extract the source archive from this version's package
|
|
130
|
+
files, then run these commands from the extracted directory:
|
|
131
|
+
|
|
132
|
+
```sh
|
|
133
|
+
python -m pip install ".[benchmark]"
|
|
134
|
+
python -m pip install dualdict-cpp==0.3.3a1
|
|
135
|
+
python -m examples.employee_directory
|
|
136
|
+
python -m examples.employee_directory --backend v331
|
|
137
|
+
python -m benchmarks.bidict_v331 --access random --seed 331
|
|
138
|
+
python -m benchmarks.bidict_v331 --access skewed --seed 331
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Native comparisons require the optional native package and a production build
|
|
142
|
+
without allocation-failure hooks. The benchmark extra pins `bidict==0.24.1`.
|
|
143
|
+
Reports record environment, configuration, exact timing statements and raw samples.
|
|
144
|
+
Random and skewed runs measure different access patterns; results do not establish
|
|
145
|
+
application-wide performance. Consult the included methodology before comparing.
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# DualDict
|
|
2
|
+
|
|
3
|
+
DualDict is an experimental bidirectional mapping with unique keys and values.
|
|
4
|
+
Its live handles follow a record through endpoint replacements and tuple-field
|
|
5
|
+
edits. Deleting a record permanently invalidates its handles, even if its old key
|
|
6
|
+
is inserted again.
|
|
7
|
+
|
|
8
|
+
This is an experimental personal project. Development is intermittent; no support
|
|
9
|
+
or release schedule is promised. APIs may change before a stable release.
|
|
10
|
+
|
|
11
|
+
Licensed under the [MIT License](LICENSE).
|
|
12
|
+
|
|
13
|
+
## Experimental package release
|
|
14
|
+
|
|
15
|
+
The alpha release uses `dualdict==0.1.0a1` and optional
|
|
16
|
+
`dualdict-cpp==0.3.3a1` (backend V3.3.1). Once uploaded to PyPI:
|
|
17
|
+
|
|
18
|
+
```powershell
|
|
19
|
+
python -m pip install dualdict==0.1.0a1
|
|
20
|
+
python -m pip install dualdict-cpp==0.3.3a1
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The default Python package requires Python 3.10+ and no compiler. The initial
|
|
24
|
+
native wheel targets **Windows x64 / GIL-enabled CPython 3.12**. Other
|
|
25
|
+
configurations require a C++17 compiler and Python development headers; source CI
|
|
26
|
+
exercises CPython 3.10, 3.12 and 3.14 on Windows, Linux and macOS, without providing
|
|
27
|
+
a complete wheel matrix. Free-threaded Python, PyPy and subinterpreters are
|
|
28
|
+
unsupported by the native backend.
|
|
29
|
+
|
|
30
|
+
The GitHub repository remains private. The published source archives include
|
|
31
|
+
documentation, examples and benchmark sources without exposing Git history.
|
|
32
|
+
See [release notes](docs/release-0.1.0a1.md) and the
|
|
33
|
+
[TestPyPI rehearsal](docs/packaging.md#testpypi-rehearsal-and-pypi-publication).
|
|
34
|
+
|
|
35
|
+
## Install from a checkout
|
|
36
|
+
|
|
37
|
+
The default Python implementation needs Python 3.10+ and no compiler:
|
|
38
|
+
|
|
39
|
+
```powershell
|
|
40
|
+
python -m pip install -e .
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
For the optional native backends, use GIL-enabled CPython and a C++17 toolchain
|
|
44
|
+
(MSVC Build Tools on Windows). Install both distributions from this checkout:
|
|
45
|
+
|
|
46
|
+
```powershell
|
|
47
|
+
python -m pip install -e .
|
|
48
|
+
python -m pip install ./cpp
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The native prototype targets the main interpreter. Free-threaded Python, PyPy and
|
|
52
|
+
subinterpreters are not supported by the native backend. See
|
|
53
|
+
[package build and installation checks](docs/packaging.md).
|
|
54
|
+
|
|
55
|
+
## Choose an API explicitly
|
|
56
|
+
|
|
57
|
+
| Import | Backend | Indexed read | Live handle |
|
|
58
|
+
|---|---|---|---|
|
|
59
|
+
| `from dualdict import DualDict` | V1, default Python | `view[key]` returns a proxy | `view[key]` |
|
|
60
|
+
| `from dualdict.v2_python import DualDict` | V2 Python | `view[key]` returns a proxy | `view[key]` |
|
|
61
|
+
| `from dualdict.v331_cpp import DualDict` | V3.3.1 native | `view[key]` returns the raw endpoint | `view.live(key)` |
|
|
62
|
+
|
|
63
|
+
`view.snapshot(key)` returns a raw endpoint in all three backends. Switching to
|
|
64
|
+
the native API is explicit; installing the extension does not change the default
|
|
65
|
+
import. Older native experiments remain available for comparisons:
|
|
66
|
+
[V3](cpp/README.md), [V3.1](cpp/V31.md), [V3.3](cpp/V33.md).
|
|
67
|
+
|
|
68
|
+
## V3.3.1 quick start
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from dualdict.v331_cpp import DualDict
|
|
72
|
+
|
|
73
|
+
d = DualDict([(101, ("Alice", "Engineering"))])
|
|
74
|
+
assert d.by_key[101] == ("Alice", "Engineering")
|
|
75
|
+
assert d.by_value[("Alice", "Engineering")] == 101
|
|
76
|
+
|
|
77
|
+
before = d.by_key[101] # raw endpoint, not a live handle
|
|
78
|
+
employee = d.by_key.live(101)
|
|
79
|
+
d.by_key.set_field(101, 1, "Research")
|
|
80
|
+
assert employee.snapshot() == ("Alice", "Research")
|
|
81
|
+
assert before == ("Alice", "Engineering")
|
|
82
|
+
assert d.by_value[("Alice", "Research")] == 101
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Without the extension, use the default API:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from dualdict import DualDict
|
|
89
|
+
|
|
90
|
+
d = DualDict([(101, ("Alice", "Engineering"))])
|
|
91
|
+
employee = d.by_key[101]
|
|
92
|
+
employee[1] = "Research"
|
|
93
|
+
assert employee.snapshot() == ("Alice", "Research")
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Run the complete example, including reverse replacement, collision rejection and
|
|
97
|
+
stale-handle behavior:
|
|
98
|
+
|
|
99
|
+
```powershell
|
|
100
|
+
python -m examples.employee_directory
|
|
101
|
+
python -m examples.employee_directory --backend v331
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Operation semantics
|
|
105
|
+
|
|
106
|
+
| Operation | Behavior |
|
|
107
|
+
|---|---|
|
|
108
|
+
| `d.add(key, value)` | Rejects duplicate keys or values, including an identical pair |
|
|
109
|
+
| `d.by_key[key] = value` | Inserts or replaces the value; rejects another record's value |
|
|
110
|
+
| `d.by_value[value] = key` | Symmetric insertion/replacement of the key |
|
|
111
|
+
| `del d.by_key[key]` | Deletes the record and invalidates its live handles |
|
|
112
|
+
| `d.clear()` | Deletes all records and invalidates all live handles |
|
|
113
|
+
| `d.items()` | Iterates raw `(key, value)` pairs |
|
|
114
|
+
| `view.snapshot(lookup)` | Returns the current raw endpoint without copying |
|
|
115
|
+
| `view.field(lookup, index)` | V3.3.1 direct endpoint indexing |
|
|
116
|
+
| `view.set_field(lookup, index, value)` | V3.3.1 positional field update of an exact tuple |
|
|
117
|
+
|
|
118
|
+
Both views support length, iteration, membership and deletion. Proxies are
|
|
119
|
+
unhashable and support snapshots, equality, indexing, iteration and tuple-field
|
|
120
|
+
assignment; they do not transparently emulate every operation of their endpoint.
|
|
121
|
+
Snapshots neither copy nor freeze custom objects, and do not follow replacements.
|
|
122
|
+
|
|
123
|
+
Field assignment requires a plain tuple; nested edits, slices and field deletion
|
|
124
|
+
are excluded. Tuple subclasses may be stored and replaced as whole endpoints.
|
|
125
|
+
Equal replacement is a no-op that retains the original endpoint object.
|
|
126
|
+
`CollisionError` reports uniqueness violations, `KeyError` missing lookups,
|
|
127
|
+
`TypeError` invalid hashability/types, and `DeletedRecordError` stale handles.
|
|
128
|
+
|
|
129
|
+
## Endpoint and safety contract
|
|
130
|
+
|
|
131
|
+
Both endpoints must have stable, non-raising, non-reentrant hash/equality behavior
|
|
132
|
+
for their entire stored lifetime. This includes objects inside tuples/frozensets.
|
|
133
|
+
Equal objects, including `1` and `True`, collide. Do not externally mutate fields
|
|
134
|
+
used by an endpoint's hash/equality. Hashability alone does not prove immutability.
|
|
135
|
+
Use immutable identifiers or replace endpoints through the mapping.
|
|
136
|
+
|
|
137
|
+
Validation failures preserve mappings under that contract. Arbitrary hostile
|
|
138
|
+
callbacks and asynchronous interruption do not have transaction-level guarantees.
|
|
139
|
+
If V3.3.1 cannot restore its indexes after a callback-induced rollback failure,
|
|
140
|
+
it disables the owner: public access and retained handles raise `RuntimeError`.
|
|
141
|
+
Discard that instance; no repair API is provided. Bulk operations commit one entry
|
|
142
|
+
at a time. Multi-call sequences require application synchronization; GIL-enabled
|
|
143
|
+
execution does not make them transactions. Handles retain their owner.
|
|
144
|
+
|
|
145
|
+
Average lookup is O(1), excluding endpoint hashing/equality. Tuple edits reconstruct
|
|
146
|
+
and hash an O(width) tuple. The native slot/generation architecture is described in
|
|
147
|
+
[V3.3.1 safety and validation notes](cpp/V331.md).
|
|
148
|
+
|
|
149
|
+
## Tests and benchmarks
|
|
150
|
+
|
|
151
|
+
```powershell
|
|
152
|
+
python -m unittest discover -s tests -v
|
|
153
|
+
$env:DUALDICT_BACKEND = "v3.3.1-cpp"
|
|
154
|
+
python -X dev -X faulthandler -m unittest discover -s tests -v
|
|
155
|
+
Remove-Item Env:DUALDICT_BACKEND
|
|
156
|
+
python -m pip install -e ".[benchmark]"
|
|
157
|
+
python -m benchmarks.bidict_v331 --access random
|
|
158
|
+
python -m benchmarks.bidict_v331 --access skewed
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
See [benchmark methodology and commands](benchmarks/README.md). Timings compare
|
|
162
|
+
both directions, tuple widths, writes, and mixed read/write workloads. Same-record,
|
|
163
|
+
shuffled full-dataset and 80/20 access patterns answer different questions.
|
|
164
|
+
Randomized lookup tests have favored bidict; write-heavy tests have favored
|
|
165
|
+
DualDict in the measured configurations. Results are workload/machine-specific,
|
|
166
|
+
not a universal speedup claim. These runners do not measure memory or production
|
|
167
|
+
latency. Native allocator/sanitizer checks are described in the safety guide.
|
|
168
|
+
|
|
169
|
+
The native backend remains experimental. Passing tests and CI add evidence;
|
|
170
|
+
they do not establish production-grade security assurance.
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# Benchmark workloads
|
|
2
|
+
|
|
3
|
+
`forward_lookup_snapshot`/`reverse_lookup_snapshot` measure proxy allocation
|
|
4
|
+
followed by proxy `.snapshot()` in DualDict. The new `*_lookup_raw` metrics use
|
|
5
|
+
view `.snapshot(lookup)` without allocating a proxy. bidict uses direct lookup
|
|
6
|
+
for both metric families, timed independently. Differences include method calls,
|
|
7
|
+
index selection and endpoint access, not just allocation. Regenerate both reports
|
|
8
|
+
to include the new raw metrics.
|
|
9
|
+
|
|
10
|
+
Run both implementations and generate a fresh comparison with one command:
|
|
11
|
+
|
|
12
|
+
```powershell
|
|
13
|
+
python -m benchmarks.compare
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
It accepts `--sizes`, `--widths`, `--operations`, `--repeats`, and `--output-dir`.
|
|
17
|
+
Each run creates a unique UTC-stamped directory under `benchmarks/comparisons`
|
|
18
|
+
with `dualdict.json`, `bidict.json`, `comparison.json`, and `comparison.md`.
|
|
19
|
+
Ratios are DualDict divided by bidict: below 1 means less time or memory.
|
|
20
|
+
Runs are sequential in separate processes using the same Python interpreter.
|
|
21
|
+
Existing results are preserved. A missing dependency stops the run before benchmarking.
|
|
22
|
+
|
|
23
|
+
Run `python -m benchmarks.run > benchmarks/results.json` from the project root.
|
|
24
|
+
Configure `--sizes 1000 10000 100000 --widths 2 8 32 --operations 10000 --repeats 5`.
|
|
25
|
+
Generation and setup are outside timed regions, except construction itself.
|
|
26
|
+
Seeded probes include repeated random hits. Mutations use fresh mappings each repeat.
|
|
27
|
+
Results include raw samples, median/minimum nanoseconds per operation and environment.
|
|
28
|
+
Throughput is `1e9 / median_ns_per_op`. Construction is normalized per entry.
|
|
29
|
+
|
|
30
|
+
Memory uses tracemalloc with pre-created inputs: it measures mapping overhead,
|
|
31
|
+
excluding endpoint objects allocated before tracing. It is not process RSS.
|
|
32
|
+
Tuple edits use a retained proxy; lookup includes proxy creation and snapshot.
|
|
33
|
+
Whole replacement uses a two-element tuple for both input families.
|
|
34
|
+
This is a local microbenchmark, not a production latency test.
|
|
35
|
+
Run on an idle machine and compare identical configurations and Python versions.
|
|
36
|
+
|
|
37
|
+
## bidict comparison
|
|
38
|
+
|
|
39
|
+
Install the optional dependency with `python -m pip install bidict`. Alternatively,
|
|
40
|
+
install locally with `python -m pip install --target .benchmark_deps bidict` and
|
|
41
|
+
set `$env:PYTHONPATH = "$PWD/.benchmark_deps"` in PowerShell before running.
|
|
42
|
+
|
|
43
|
+
```powershell
|
|
44
|
+
python -m benchmarks.run > benchmarks/results_dualdict.json
|
|
45
|
+
python -m benchmarks.run_bidict > benchmarks/results_bidict.json
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Both runners accept identical arguments and share the same seeded input generator.
|
|
49
|
+
The bidict output records its installed version. Lookup includes a proxy and snapshot
|
|
50
|
+
for DualDict, and direct retrieval for bidict. `manual_tuple_edit` uses lookup,
|
|
51
|
+
list reconstruction and assignment in bidict; compare it with DualDict's
|
|
52
|
+
`retained_proxy_tuple_edit`, which uses a retained proxy. These achieve the same
|
|
53
|
+
field update but do not provide equivalent retained-reference semantics.
|
|
54
|
+
|
|
55
|
+
Duplicate rejection now attempts a new key with an existing value in both runners.
|
|
56
|
+
The earlier identical-pair workload was incompatible with bidict's no-op behavior;
|
|
57
|
+
regenerate previous DualDict results before comparing. Memory excludes input
|
|
58
|
+
objects for both runners. No live-proxy survival comparison is implied.
|
|
59
|
+
|
|
60
|
+
## Same-record V3.3.1 versus bidict
|
|
61
|
+
|
|
62
|
+
```powershell
|
|
63
|
+
Remove-Item Env:PYTHONMALLOC -ErrorAction SilentlyContinue
|
|
64
|
+
python -m benchmarks.bidict_v331 --sizes 10000 100000 --widths 2 8 32 --operations 200000 --repeats 7
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Requires the normal V3.3.1 extension (allocation test hooks disabled) and bidict.
|
|
68
|
+
The runner checks validation and duplicate rejection before timing, then verifies
|
|
69
|
+
both indexes and identical final mappings after every sample. Both directions
|
|
70
|
+
use cached views and identical tuple records. Timings include native direct field
|
|
71
|
+
reads, bidict raw lookup plus indexing, full endpoint replacement, and field edits
|
|
72
|
+
with exact-tuple/index/hashability/uniqueness validation. Bidict implements field
|
|
73
|
+
edits through a Python helper; DualDict performs that work in its native API.
|
|
74
|
+
|
|
75
|
+
Mixed workloads use seeded, precomputed schedules with 90%/50% raw reads and
|
|
76
|
+
10%/50% field updates on one hot record (integer rounding for tiny runs). They
|
|
77
|
+
are not random-record workloads. Insert/delete use distinct-record batches,
|
|
78
|
+
limited to min(size, operations). Insert begins with size records; delete begins
|
|
79
|
+
with size plus batch records. These measure assignment of absent endpoints and
|
|
80
|
+
deletion, not strict duplicate-key insertion or a create/delete cycle.
|
|
81
|
+
|
|
82
|
+
Every sample starts fresh; one warmup is discarded and backend order alternates.
|
|
83
|
+
Generation, construction and correctness checks are outside timing; GC is disabled
|
|
84
|
+
inside timing. Reports under benchmarks/versions/*-bidict retain environment,
|
|
85
|
+
source timing statements, operation counts, raw samples, median/min/max, and a
|
|
86
|
+
side-by-side Markdown table. Ratio is bidict time / DualDict time: above 1 favors
|
|
87
|
+
DualDict. This direction differs from the older runners described above.
|
|
88
|
+
|
|
89
|
+
No proxy equivalence, memory savings, callback safety or application-wide speedup
|
|
90
|
+
is implied. Measure memory separately; this runner measures operation timings.
|
|
91
|
+
|
|
92
|
+
## Randomized full-dataset access
|
|
93
|
+
|
|
94
|
+
```powershell
|
|
95
|
+
python -m benchmarks.bidict_v331 --access random --seed 331 --sizes 10000 100000 --widths 2 8 32 --operations 200000 --repeats 7
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`--access hot` remains the default for comparison with the earlier experiment.
|
|
99
|
+
Random mode precomputes seeded shuffled passes over record indexes. Every record
|
|
100
|
+
is visited once per pass before any repeats, so the default 200K operations cover
|
|
101
|
+
the entire 10K/100K dataset. A shorter operation count gives partial coverage;
|
|
102
|
+
JSON records `records_visited` per row. This is randomized order without
|
|
103
|
+
replacement within a pass, not independent uniform sampling with replacement.
|
|
104
|
+
|
|
105
|
+
Each direction has its own fresh mapping. Writes change the opposite endpoint,
|
|
106
|
+
so lookup keys remain stable. Replacements preserve a unique identity field and
|
|
107
|
+
field updates change field one. Mixed schedules retain the same read/write ratios;
|
|
108
|
+
all operations visit the dataset, while only the write subset changes records.
|
|
109
|
+
Both backends reuse the same original key objects, rather than freshly recreated
|
|
110
|
+
equal keys. Lookup iterator advancement is included equally in timings. Insert
|
|
111
|
+
and delete shuffle their distinct-record batches; insertion targets new records,
|
|
112
|
+
not existing records in the dataset. Setup, RNG and validation remain untimed.
|
|
113
|
+
|
|
114
|
+
Reports have distinct `-bidict-hot`/`-bidict-random` directories, with access mode,
|
|
115
|
+
seed, record coverage, exact statements and raw timing samples in results.json.
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
## Skewed application-style access
|
|
119
|
+
|
|
120
|
+
```powershell
|
|
121
|
+
python -m benchmarks.bidict_v331 --access skewed --seed 331 --sizes 100000 --widths 8 --operations 50000 --repeats 3
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Exactly 80% of requests (rounded down) sample with replacement from a seeded
|
|
125
|
+
20% hot set (at least one record). Remaining requests sample the cold set.
|
|
126
|
+
For a one-record dataset every request is hot. This models access concentration;
|
|
127
|
+
it is an 80/20 distribution, not Zipf and not a claim about every application.
|
|
128
|
+
Mixed 90/10 and 50/50 workloads use separately generated read/write schedules.
|
|
129
|
+
JSON reports actual distinct-record coverage. Insert/delete still shuffle their
|
|
130
|
+
separate distinct-record batches and do **not** model an 80/20 distribution.
|
|
131
|
+
Both distributions are checked against a simple dictionary oracle before timing.
|
|
132
|
+
|
|
133
|
+
For a scaling experiment, start with a bounded run and increase the operation
|
|
134
|
+
count to suit available time and memory:
|
|
135
|
+
|
|
136
|
+
```powershell
|
|
137
|
+
python -m benchmarks.bidict_v331 --access random --sizes 1000000 --widths 2 --operations 1000000 --repeats 3
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
This command is a suggested experiment, not a recorded 1M-record result.
|
|
141
|
+
All runners allocate setup data and validate complete mappings outside timing,
|
|
142
|
+
so process memory and elapsed wall time exceed what the per-operation figures show.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Complete raw/live API workloads, in both directions; source timing statements are saved."""
|
|
2
|
+
import argparse
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import platform
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import timeit
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from statistics import median
|
|
12
|
+
from .workloads import make_pairs
|
|
13
|
+
from dualdict.backends import load_backend
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main():
|
|
17
|
+
parser = argparse.ArgumentParser()
|
|
18
|
+
parser.add_argument("--operations", type=int, default=200000)
|
|
19
|
+
parser.add_argument("--repeats", type=int, default=7)
|
|
20
|
+
a = parser.parse_args()
|
|
21
|
+
if min(a.operations, a.repeats) < 1:
|
|
22
|
+
parser.error("positive arguments required")
|
|
23
|
+
out = Path("benchmarks/versions") / (datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ") + "-api")
|
|
24
|
+
out.mkdir(parents=True)
|
|
25
|
+
rows = []
|
|
26
|
+
for backend in ("v3.3-cpp", "v3.3.1-cpp"):
|
|
27
|
+
test = subprocess.run([sys.executable, "-X", "faulthandler", "-m", "unittest", "discover", "-s", "tests", "-v"],
|
|
28
|
+
env=dict(os.environ, DUALDICT_BACKEND=backend), capture_output=True, text=True)
|
|
29
|
+
(out / (backend + "-tests.txt")).write_text(test.stdout + test.stderr)
|
|
30
|
+
test.check_returncode()
|
|
31
|
+
cls = load_backend(backend)
|
|
32
|
+
print("Benchmarking " + backend, flush=True)
|
|
33
|
+
for size in (10000, 100000):
|
|
34
|
+
for kind, width in (("scalar", 1), ("tuple", 2), ("tuple", 8), ("tuple", 32)):
|
|
35
|
+
pairs = make_pairs(size, width, kind)
|
|
36
|
+
for reverse in (False, True):
|
|
37
|
+
statements = {"raw_read": "view[key]", "snapshot": "view.snapshot(key)",
|
|
38
|
+
"live_creation": "view.live(key)"}
|
|
39
|
+
if kind == "tuple":
|
|
40
|
+
statements.update(field_read="view.field(key, 1)", snapshot_field="view.snapshot(key)[1]",
|
|
41
|
+
live_field="view.live(key)[1]", retained_read="proxy[1]",
|
|
42
|
+
set_field="view.set_field(key, 1, next(changes))",
|
|
43
|
+
retained_update="proxy[1] = next(changes)")
|
|
44
|
+
for name, statement in statements.items():
|
|
45
|
+
samples = []
|
|
46
|
+
for _ in range(a.repeats):
|
|
47
|
+
d = cls(pairs)
|
|
48
|
+
view = d.by_value if reverse else d.by_key
|
|
49
|
+
key = pairs[0][1 if reverse else 0]
|
|
50
|
+
scope = dict(view=view, key=key, proxy=view.live(key), changes=iter(range(100, a.operations + 100)))
|
|
51
|
+
seconds = timeit.Timer(statement, globals=scope).timeit(number=a.operations)
|
|
52
|
+
samples.append(seconds * 1e9 / a.operations)
|
|
53
|
+
if name in ("set_field", "retained_update"):
|
|
54
|
+
assert view[key][1] == a.operations + 99
|
|
55
|
+
rows.append(dict(backend=backend, size=size, kind=kind, width=width,
|
|
56
|
+
direction="reverse" if reverse else "forward", metric=name,
|
|
57
|
+
statement=statement, median_ns=median(samples), samples_ns=samples))
|
|
58
|
+
(out / "results.json").write_text(json.dumps(dict(python=platform.python_version(), platform=platform.platform(),
|
|
59
|
+
operations=a.operations, repeats=a.repeats, rows=rows), indent=2))
|
|
60
|
+
lines = ["# Complete V3.3 API comparison", "", "Median ns/op, lower is faster. Same-record workloads with cached view and lookup; setup outside timing.",
|
|
61
|
+
"Fresh mapping each sample. Updates include next(changes) overhead. Scalar field operations excluded.",
|
|
62
|
+
"timeit disables cyclic GC during each timed sample. Endpoint hashing, field indexing and proxy lifetime remain included.", "",
|
|
63
|
+
"| Backend | Entries | Kind | Width | Direction | Operation | ns/op |", "|---|---:|---|---:|---|---|---:|"]
|
|
64
|
+
lines += [f"| {r['backend']} | {r['size']} | {r['kind']} | {r['width']} | {r['direction']} | {r['metric']} | {r['median_ns']:.1f} |" for r in rows]
|
|
65
|
+
(out / "comparison.md").write_text("\n".join(lines))
|
|
66
|
+
print((out / "comparison.md").resolve())
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
if __name__ == "__main__":
|
|
70
|
+
main()
|