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.
Files changed (55) hide show
  1. dualdict-0.1.0a1/LICENSE +21 -0
  2. dualdict-0.1.0a1/MANIFEST.in +12 -0
  3. dualdict-0.1.0a1/PKG-INFO +145 -0
  4. dualdict-0.1.0a1/README.md +170 -0
  5. dualdict-0.1.0a1/benchmarks/README.md +142 -0
  6. dualdict-0.1.0a1/benchmarks/api_v33.py +70 -0
  7. dualdict-0.1.0a1/benchmarks/bidict_v331.py +282 -0
  8. dualdict-0.1.0a1/benchmarks/compare.py +105 -0
  9. dualdict-0.1.0a1/benchmarks/compare_versions.py +119 -0
  10. dualdict-0.1.0a1/benchmarks/run.py +153 -0
  11. dualdict-0.1.0a1/benchmarks/run_bidict.py +123 -0
  12. dualdict-0.1.0a1/benchmarks/storage.py +17 -0
  13. dualdict-0.1.0a1/benchmarks/workloads.py +18 -0
  14. dualdict-0.1.0a1/cpp/CMakeLists.txt +53 -0
  15. dualdict-0.1.0a1/cpp/HARDENING.md +44 -0
  16. dualdict-0.1.0a1/cpp/LICENSE +21 -0
  17. dualdict-0.1.0a1/cpp/PYPI.md +50 -0
  18. dualdict-0.1.0a1/cpp/README.md +69 -0
  19. dualdict-0.1.0a1/cpp/THIRD_PARTY_NOTICES.txt +60 -0
  20. dualdict-0.1.0a1/cpp/V31.md +78 -0
  21. dualdict-0.1.0a1/cpp/V32.md +24 -0
  22. dualdict-0.1.0a1/cpp/V33.md +34 -0
  23. dualdict-0.1.0a1/cpp/V331.md +133 -0
  24. dualdict-0.1.0a1/cpp/build.py +40 -0
  25. dualdict-0.1.0a1/cpp/pyproject.toml +22 -0
  26. dualdict-0.1.0a1/cpp/src/module.cpp +203 -0
  27. dualdict-0.1.0a1/cpp/src/module_v31.cpp +302 -0
  28. dualdict-0.1.0a1/cpp/src/module_v331.cpp +356 -0
  29. dualdict-0.1.0a1/docs/PYPI.md +108 -0
  30. dualdict-0.1.0a1/docs/packaging.md +113 -0
  31. dualdict-0.1.0a1/docs/release-0.1.0a1.md +74 -0
  32. dualdict-0.1.0a1/dualdict/__init__.py +179 -0
  33. dualdict-0.1.0a1/dualdict/backends.py +26 -0
  34. dualdict-0.1.0a1/dualdict/v2.py +6 -0
  35. dualdict-0.1.0a1/dualdict/v2_python.py +177 -0
  36. dualdict-0.1.0a1/dualdict/v31_cpp.py +5 -0
  37. dualdict-0.1.0a1/dualdict/v32_cpp.py +3 -0
  38. dualdict-0.1.0a1/dualdict/v331_cpp.py +2 -0
  39. dualdict-0.1.0a1/dualdict/v33_cpp.py +3 -0
  40. dualdict-0.1.0a1/dualdict/v3_cpp.py +5 -0
  41. dualdict-0.1.0a1/dualdict.egg-info/PKG-INFO +145 -0
  42. dualdict-0.1.0a1/dualdict.egg-info/SOURCES.txt +53 -0
  43. dualdict-0.1.0a1/dualdict.egg-info/dependency_links.txt +1 -0
  44. dualdict-0.1.0a1/dualdict.egg-info/requires.txt +3 -0
  45. dualdict-0.1.0a1/dualdict.egg-info/top_level.txt +1 -0
  46. dualdict-0.1.0a1/examples/employee_directory.py +47 -0
  47. dualdict-0.1.0a1/pyproject.toml +23 -0
  48. dualdict-0.1.0a1/setup.cfg +4 -0
  49. dualdict-0.1.0a1/tests/data/dualdict_cases.py +98 -0
  50. dualdict-0.1.0a1/tests/data/supported_types.py +76 -0
  51. dualdict-0.1.0a1/tests/test_dualdict.py +356 -0
  52. dualdict-0.1.0a1/tests/test_v33.py +51 -0
  53. dualdict-0.1.0a1/tests/test_v331.py +98 -0
  54. dualdict-0.1.0a1/tests/test_v331_stress.py +199 -0
  55. dualdict-0.1.0a1/tests/v331_native_probe.py +351 -0
@@ -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()