mobius-number 0.1.0__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.
- mobius_number-0.1.0/.gitignore +13 -0
- mobius_number-0.1.0/LICENSE +21 -0
- mobius_number-0.1.0/PKG-INFO +202 -0
- mobius_number-0.1.0/README.md +174 -0
- mobius_number-0.1.0/pyproject.toml +51 -0
- mobius_number-0.1.0/src/mobius_number/__init__.py +29 -0
- mobius_number-0.1.0/src/mobius_number/core.py +223 -0
- mobius_number-0.1.0/tests/test_mobius.py +293 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jay Carpenter
|
|
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,202 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mobius-number
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Complementary residue arithmetic — 0.1 + 0.2 = 0.3, exactly.
|
|
5
|
+
Project-URL: Homepage, https://github.com/JustNothingJay/mobius-number
|
|
6
|
+
Project-URL: Repository, https://github.com/JustNothingJay/mobius-number
|
|
7
|
+
Project-URL: Issues, https://github.com/JustNothingJay/mobius-number/issues
|
|
8
|
+
Author: Jay Carpenter
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: IEEE-754,arithmetic,complementary-residue,exact,floating-point,mobius,precision,rational
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
15
|
+
Classifier: Intended Audience :: Science/Research
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.9
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# MöbiusNumber
|
|
30
|
+
|
|
31
|
+
**0.1 + 0.2 = 0.3. Exactly.**
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from mobius_number import M
|
|
35
|
+
|
|
36
|
+
>>> 0.1 + 0.2 == 0.3
|
|
37
|
+
False
|
|
38
|
+
|
|
39
|
+
>>> M("0.1") + M("0.2") == M("0.3")
|
|
40
|
+
True
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
A number type that carries its own correction. No rounding error. No epsilon comparisons. No workarounds.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
pip install mobius-number
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## The Problem
|
|
52
|
+
|
|
53
|
+
Every computer in the world gets this wrong:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
>>> 0.1 + 0.2
|
|
57
|
+
0.30000000000000004
|
|
58
|
+
|
|
59
|
+
>>> 0.1 + 0.2 == 0.3
|
|
60
|
+
False
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
This has been true since 1985 when IEEE 754 was published. The reason: computers store numbers in base 2. The number 0.1 in binary is a repeating fraction — like 1/3 in decimal (0.333...). It gets rounded. Then 0.2 gets rounded. The rounding errors stack.
|
|
64
|
+
|
|
65
|
+
Every workaround — arbitrary precision, interval arithmetic, posit numbers — tries to fix the math layer while leaving the foundation untouched: the number is stored in the form the transistor speaks, and the human adapts.
|
|
66
|
+
|
|
67
|
+
**What if the representation served the number instead?**
|
|
68
|
+
|
|
69
|
+
## The Idea
|
|
70
|
+
|
|
71
|
+
DNA has two strands. Every base carries its complement — A pairs with T, G pairs with C. There is no excess. The complement consumes what the original doesn't cover.
|
|
72
|
+
|
|
73
|
+
A Möbius strip has one surface. Traversing the full loop covers both "sides" and returns to the origin.
|
|
74
|
+
|
|
75
|
+
A MöbiusNumber stores two strands:
|
|
76
|
+
|
|
77
|
+
- **The binary strand** — `float64`, hardware-fast, carries rounding error
|
|
78
|
+
- **The rational strand** — exact `Fraction`, no loss, no repeating
|
|
79
|
+
|
|
80
|
+
They are not two separate representations. They are one object. The binary strand is the shadow. The rational strand is the substance. **On collapse, the rational governs.**
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
CURRENT (IEEE 754):
|
|
84
|
+
Store 0.1 → 0.1000000000000000055511...
|
|
85
|
+
Residue: 0.0000000000000000055511... ← THROWN AWAY
|
|
86
|
+
|
|
87
|
+
MöbiusNumber:
|
|
88
|
+
Binary strand: 0.1000000000000000055511...
|
|
89
|
+
Rational strand: 1/10
|
|
90
|
+
Anti (residue): exact_value − binary_value
|
|
91
|
+
Collapse: the rational governs → 0.1 exactly
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Arithmetic propagates both strands. When you add two MöbiusNumbers, the rationals add exactly. The floats add approximately. **The error exists but is never consulted for truth.** The anti-strand is always present, always correct, and annihilates the rounding error on collapse.
|
|
95
|
+
|
|
96
|
+
## Proof
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
$ python -c "from mobius_number import M; print(M('0.1') + M('0.2') == M('0.3'))"
|
|
100
|
+
True
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### The Famous Failures — All Fixed
|
|
104
|
+
|
|
105
|
+
| Test | IEEE 754 | MöbiusNumber |
|
|
106
|
+
|------|----------|--------------|
|
|
107
|
+
| `0.1 + 0.2 == 0.3` | **False** | **True** |
|
|
108
|
+
| `(1/49) * 49 == 1` | **False** | **True** |
|
|
109
|
+
| `(1 + 1e-16) - 1` | **0.0** (total loss) | **1e-16** (exact) |
|
|
110
|
+
| `0.1 * 10 == 1.0` | True¹ | True |
|
|
111
|
+
| `$10 / 3 * 3 == $10` | True¹ | True |
|
|
112
|
+
| `0.01 * 100 == 1.0` | True¹ | True |
|
|
113
|
+
| `(1/7) * 7 == 1` | **False** | **True** |
|
|
114
|
+
| `0.001 added 1000×` | **False** | **True** |
|
|
115
|
+
|
|
116
|
+
¹ IEEE 754 gets these by luck — the rounding errors happen to cancel. The MöbiusNumber gets them by construction.
|
|
117
|
+
|
|
118
|
+
### Strand Anatomy
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
from mobius_number import M
|
|
122
|
+
|
|
123
|
+
n = M("0.1")
|
|
124
|
+
print(n.diagnose())
|
|
125
|
+
# {
|
|
126
|
+
# 'binary_strand': 0.1,
|
|
127
|
+
# 'rational_strand': '1/10',
|
|
128
|
+
# 'residue': '-1/180143985094819840',
|
|
129
|
+
# 'residue_float': -5.55e-18,
|
|
130
|
+
# 'collapsed': 0.1
|
|
131
|
+
# }
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
The residue is the anti-strand — the exact complement of the binary error. It exists. It is never discarded. On collapse, the Möbius strip closes.
|
|
135
|
+
|
|
136
|
+
## Usage
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
from mobius_number import M
|
|
140
|
+
|
|
141
|
+
# Basic arithmetic
|
|
142
|
+
a = M("0.1")
|
|
143
|
+
b = M("0.2")
|
|
144
|
+
c = a + b # M('3/10')
|
|
145
|
+
c.collapse() # 0.3
|
|
146
|
+
|
|
147
|
+
# Financial
|
|
148
|
+
price = M("19.99")
|
|
149
|
+
tax = price * M("0.0825")
|
|
150
|
+
total = price + tax # Exact
|
|
151
|
+
|
|
152
|
+
# Comparison — the rational governs
|
|
153
|
+
M("0.1") + M("0.2") == M("0.3") # True — always
|
|
154
|
+
|
|
155
|
+
# Interop with plain numbers
|
|
156
|
+
M("0.5") + 1 # M('3/2')
|
|
157
|
+
3 * M("0.1") # M('3/10')
|
|
158
|
+
|
|
159
|
+
# Inspect the strands
|
|
160
|
+
n = M("0.1")
|
|
161
|
+
n.approx # 0.1 (the float — fast, lossy)
|
|
162
|
+
n.exact # Fraction(1, 10) (the truth)
|
|
163
|
+
n.residue # Fraction(-1, 180143985094819840)
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Why Not Just Use `Fraction`?
|
|
167
|
+
|
|
168
|
+
You can. Python's `fractions.Fraction` gives exact rational arithmetic. But:
|
|
169
|
+
|
|
170
|
+
1. **Speed** — MöbiusNumber carries a float for fast approximate work. Use `.approx` in hot loops, `.collapse()` when you need truth.
|
|
171
|
+
2. **Drop-in intent** — `M("0.1")` reads like a number. `Fraction("0.1")` reads like a workaround.
|
|
172
|
+
3. **The conceptual point** — the number and its correction are one object. The anti-strand is not a separate operation. It is intrinsic. Like DNA. Like a Möbius strip.
|
|
173
|
+
|
|
174
|
+
## How It Works
|
|
175
|
+
|
|
176
|
+
Every MöbiusNumber is internally:
|
|
177
|
+
|
|
178
|
+
```
|
|
179
|
+
(_approx: float, _exact: Fraction)
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
- **Construction from string**: `M("0.1")` → `_exact = Fraction("0.1") = 1/10`; `_approx = float(1/10)`
|
|
183
|
+
- **Construction from float**: `M(0.1)` → recovers the rational intent via `limit_denominator`
|
|
184
|
+
- **Arithmetic**: both strands propagate independently through `+`, `-`, `*`, `/`, `**`
|
|
185
|
+
- **Comparison**: always uses `_exact` — the rational strand governs all equality and ordering
|
|
186
|
+
- **Collapse**: `float(_exact)` — the Möbius traversal returns the exact value
|
|
187
|
+
|
|
188
|
+
No external dependencies. Pure Python. Works on 3.9+.
|
|
189
|
+
|
|
190
|
+
## The Name
|
|
191
|
+
|
|
192
|
+
A Möbius strip is a surface with one side. If you trace a line along it, you cover both "sides" and return to the origin having traversed the whole thing. There is no front and back — only one continuous surface.
|
|
193
|
+
|
|
194
|
+
A MöbiusNumber is a number with one identity. The binary approximation and the exact rational are not two things — they are one object that, when fully traversed, resolves to the truth. The representation IS the correction.
|
|
195
|
+
|
|
196
|
+
## Author
|
|
197
|
+
|
|
198
|
+
Jay Carpenter — [SECS Research](https://github.com/JustNothingJay/SECS_Research)
|
|
199
|
+
|
|
200
|
+
## License
|
|
201
|
+
|
|
202
|
+
MIT
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# MöbiusNumber
|
|
2
|
+
|
|
3
|
+
**0.1 + 0.2 = 0.3. Exactly.**
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
from mobius_number import M
|
|
7
|
+
|
|
8
|
+
>>> 0.1 + 0.2 == 0.3
|
|
9
|
+
False
|
|
10
|
+
|
|
11
|
+
>>> M("0.1") + M("0.2") == M("0.3")
|
|
12
|
+
True
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
A number type that carries its own correction. No rounding error. No epsilon comparisons. No workarounds.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
pip install mobius-number
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## The Problem
|
|
24
|
+
|
|
25
|
+
Every computer in the world gets this wrong:
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
>>> 0.1 + 0.2
|
|
29
|
+
0.30000000000000004
|
|
30
|
+
|
|
31
|
+
>>> 0.1 + 0.2 == 0.3
|
|
32
|
+
False
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
This has been true since 1985 when IEEE 754 was published. The reason: computers store numbers in base 2. The number 0.1 in binary is a repeating fraction — like 1/3 in decimal (0.333...). It gets rounded. Then 0.2 gets rounded. The rounding errors stack.
|
|
36
|
+
|
|
37
|
+
Every workaround — arbitrary precision, interval arithmetic, posit numbers — tries to fix the math layer while leaving the foundation untouched: the number is stored in the form the transistor speaks, and the human adapts.
|
|
38
|
+
|
|
39
|
+
**What if the representation served the number instead?**
|
|
40
|
+
|
|
41
|
+
## The Idea
|
|
42
|
+
|
|
43
|
+
DNA has two strands. Every base carries its complement — A pairs with T, G pairs with C. There is no excess. The complement consumes what the original doesn't cover.
|
|
44
|
+
|
|
45
|
+
A Möbius strip has one surface. Traversing the full loop covers both "sides" and returns to the origin.
|
|
46
|
+
|
|
47
|
+
A MöbiusNumber stores two strands:
|
|
48
|
+
|
|
49
|
+
- **The binary strand** — `float64`, hardware-fast, carries rounding error
|
|
50
|
+
- **The rational strand** — exact `Fraction`, no loss, no repeating
|
|
51
|
+
|
|
52
|
+
They are not two separate representations. They are one object. The binary strand is the shadow. The rational strand is the substance. **On collapse, the rational governs.**
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
CURRENT (IEEE 754):
|
|
56
|
+
Store 0.1 → 0.1000000000000000055511...
|
|
57
|
+
Residue: 0.0000000000000000055511... ← THROWN AWAY
|
|
58
|
+
|
|
59
|
+
MöbiusNumber:
|
|
60
|
+
Binary strand: 0.1000000000000000055511...
|
|
61
|
+
Rational strand: 1/10
|
|
62
|
+
Anti (residue): exact_value − binary_value
|
|
63
|
+
Collapse: the rational governs → 0.1 exactly
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Arithmetic propagates both strands. When you add two MöbiusNumbers, the rationals add exactly. The floats add approximately. **The error exists but is never consulted for truth.** The anti-strand is always present, always correct, and annihilates the rounding error on collapse.
|
|
67
|
+
|
|
68
|
+
## Proof
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
$ python -c "from mobius_number import M; print(M('0.1') + M('0.2') == M('0.3'))"
|
|
72
|
+
True
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### The Famous Failures — All Fixed
|
|
76
|
+
|
|
77
|
+
| Test | IEEE 754 | MöbiusNumber |
|
|
78
|
+
|------|----------|--------------|
|
|
79
|
+
| `0.1 + 0.2 == 0.3` | **False** | **True** |
|
|
80
|
+
| `(1/49) * 49 == 1` | **False** | **True** |
|
|
81
|
+
| `(1 + 1e-16) - 1` | **0.0** (total loss) | **1e-16** (exact) |
|
|
82
|
+
| `0.1 * 10 == 1.0` | True¹ | True |
|
|
83
|
+
| `$10 / 3 * 3 == $10` | True¹ | True |
|
|
84
|
+
| `0.01 * 100 == 1.0` | True¹ | True |
|
|
85
|
+
| `(1/7) * 7 == 1` | **False** | **True** |
|
|
86
|
+
| `0.001 added 1000×` | **False** | **True** |
|
|
87
|
+
|
|
88
|
+
¹ IEEE 754 gets these by luck — the rounding errors happen to cancel. The MöbiusNumber gets them by construction.
|
|
89
|
+
|
|
90
|
+
### Strand Anatomy
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
from mobius_number import M
|
|
94
|
+
|
|
95
|
+
n = M("0.1")
|
|
96
|
+
print(n.diagnose())
|
|
97
|
+
# {
|
|
98
|
+
# 'binary_strand': 0.1,
|
|
99
|
+
# 'rational_strand': '1/10',
|
|
100
|
+
# 'residue': '-1/180143985094819840',
|
|
101
|
+
# 'residue_float': -5.55e-18,
|
|
102
|
+
# 'collapsed': 0.1
|
|
103
|
+
# }
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The residue is the anti-strand — the exact complement of the binary error. It exists. It is never discarded. On collapse, the Möbius strip closes.
|
|
107
|
+
|
|
108
|
+
## Usage
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from mobius_number import M
|
|
112
|
+
|
|
113
|
+
# Basic arithmetic
|
|
114
|
+
a = M("0.1")
|
|
115
|
+
b = M("0.2")
|
|
116
|
+
c = a + b # M('3/10')
|
|
117
|
+
c.collapse() # 0.3
|
|
118
|
+
|
|
119
|
+
# Financial
|
|
120
|
+
price = M("19.99")
|
|
121
|
+
tax = price * M("0.0825")
|
|
122
|
+
total = price + tax # Exact
|
|
123
|
+
|
|
124
|
+
# Comparison — the rational governs
|
|
125
|
+
M("0.1") + M("0.2") == M("0.3") # True — always
|
|
126
|
+
|
|
127
|
+
# Interop with plain numbers
|
|
128
|
+
M("0.5") + 1 # M('3/2')
|
|
129
|
+
3 * M("0.1") # M('3/10')
|
|
130
|
+
|
|
131
|
+
# Inspect the strands
|
|
132
|
+
n = M("0.1")
|
|
133
|
+
n.approx # 0.1 (the float — fast, lossy)
|
|
134
|
+
n.exact # Fraction(1, 10) (the truth)
|
|
135
|
+
n.residue # Fraction(-1, 180143985094819840)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Why Not Just Use `Fraction`?
|
|
139
|
+
|
|
140
|
+
You can. Python's `fractions.Fraction` gives exact rational arithmetic. But:
|
|
141
|
+
|
|
142
|
+
1. **Speed** — MöbiusNumber carries a float for fast approximate work. Use `.approx` in hot loops, `.collapse()` when you need truth.
|
|
143
|
+
2. **Drop-in intent** — `M("0.1")` reads like a number. `Fraction("0.1")` reads like a workaround.
|
|
144
|
+
3. **The conceptual point** — the number and its correction are one object. The anti-strand is not a separate operation. It is intrinsic. Like DNA. Like a Möbius strip.
|
|
145
|
+
|
|
146
|
+
## How It Works
|
|
147
|
+
|
|
148
|
+
Every MöbiusNumber is internally:
|
|
149
|
+
|
|
150
|
+
```
|
|
151
|
+
(_approx: float, _exact: Fraction)
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
- **Construction from string**: `M("0.1")` → `_exact = Fraction("0.1") = 1/10`; `_approx = float(1/10)`
|
|
155
|
+
- **Construction from float**: `M(0.1)` → recovers the rational intent via `limit_denominator`
|
|
156
|
+
- **Arithmetic**: both strands propagate independently through `+`, `-`, `*`, `/`, `**`
|
|
157
|
+
- **Comparison**: always uses `_exact` — the rational strand governs all equality and ordering
|
|
158
|
+
- **Collapse**: `float(_exact)` — the Möbius traversal returns the exact value
|
|
159
|
+
|
|
160
|
+
No external dependencies. Pure Python. Works on 3.9+.
|
|
161
|
+
|
|
162
|
+
## The Name
|
|
163
|
+
|
|
164
|
+
A Möbius strip is a surface with one side. If you trace a line along it, you cover both "sides" and return to the origin having traversed the whole thing. There is no front and back — only one continuous surface.
|
|
165
|
+
|
|
166
|
+
A MöbiusNumber is a number with one identity. The binary approximation and the exact rational are not two things — they are one object that, when fully traversed, resolves to the truth. The representation IS the correction.
|
|
167
|
+
|
|
168
|
+
## Author
|
|
169
|
+
|
|
170
|
+
Jay Carpenter — [SECS Research](https://github.com/JustNothingJay/SECS_Research)
|
|
171
|
+
|
|
172
|
+
## License
|
|
173
|
+
|
|
174
|
+
MIT
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mobius-number"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Complementary residue arithmetic — 0.1 + 0.2 = 0.3, exactly."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Jay Carpenter" },
|
|
14
|
+
]
|
|
15
|
+
keywords = [
|
|
16
|
+
"floating-point",
|
|
17
|
+
"arithmetic",
|
|
18
|
+
"precision",
|
|
19
|
+
"IEEE-754",
|
|
20
|
+
"exact",
|
|
21
|
+
"rational",
|
|
22
|
+
"mobius",
|
|
23
|
+
"complementary-residue",
|
|
24
|
+
]
|
|
25
|
+
classifiers = [
|
|
26
|
+
"Development Status :: 4 - Beta",
|
|
27
|
+
"Intended Audience :: Developers",
|
|
28
|
+
"Intended Audience :: Financial and Insurance Industry",
|
|
29
|
+
"Intended Audience :: Science/Research",
|
|
30
|
+
"License :: OSI Approved :: MIT License",
|
|
31
|
+
"Programming Language :: Python :: 3",
|
|
32
|
+
"Programming Language :: Python :: 3.9",
|
|
33
|
+
"Programming Language :: Python :: 3.10",
|
|
34
|
+
"Programming Language :: Python :: 3.11",
|
|
35
|
+
"Programming Language :: Python :: 3.12",
|
|
36
|
+
"Programming Language :: Python :: 3.13",
|
|
37
|
+
"Topic :: Scientific/Engineering :: Mathematics",
|
|
38
|
+
"Topic :: Software Development :: Libraries",
|
|
39
|
+
"Typing :: Typed",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[project.urls]
|
|
43
|
+
Homepage = "https://github.com/JustNothingJay/mobius-number"
|
|
44
|
+
Repository = "https://github.com/JustNothingJay/mobius-number"
|
|
45
|
+
Issues = "https://github.com/JustNothingJay/mobius-number/issues"
|
|
46
|
+
|
|
47
|
+
[tool.hatch.build.targets.wheel]
|
|
48
|
+
packages = ["src/mobius_number"]
|
|
49
|
+
|
|
50
|
+
[tool.pytest.ini_options]
|
|
51
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MöbiusNumber — Complementary Residue Arithmetic
|
|
3
|
+
|
|
4
|
+
Every number is stored as two strands:
|
|
5
|
+
- The binary approximation (float64, hardware-fast)
|
|
6
|
+
- The exact rational identity (numerator/denominator, no loss)
|
|
7
|
+
|
|
8
|
+
They are not two representations. They are one object — like DNA's
|
|
9
|
+
double helix, like a Möbius strip traversed fully. The approximation
|
|
10
|
+
and its anti coexist. On collapse, the rational governs.
|
|
11
|
+
|
|
12
|
+
The float gives you speed.
|
|
13
|
+
The rational gives you truth.
|
|
14
|
+
Collapse gives you both.
|
|
15
|
+
|
|
16
|
+
>>> from mobius_number import M
|
|
17
|
+
>>> M("0.1") + M("0.2") == M("0.3")
|
|
18
|
+
True
|
|
19
|
+
|
|
20
|
+
Jay Carpenter, 2026
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from mobius_number.core import MobiusNumber
|
|
24
|
+
|
|
25
|
+
# Convenience alias
|
|
26
|
+
M = MobiusNumber
|
|
27
|
+
|
|
28
|
+
__version__ = "0.1.0"
|
|
29
|
+
__all__ = ["MobiusNumber", "M"]
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MöbiusNumber core implementation.
|
|
3
|
+
|
|
4
|
+
A number that carries its own correction. Two strands — the binary
|
|
5
|
+
approximation and the exact rational identity — coexist as one object.
|
|
6
|
+
On collapse, the rational governs.
|
|
7
|
+
|
|
8
|
+
The structure mirrors DNA: every base carries its complement.
|
|
9
|
+
The topology is Möbius: traversing the full loop returns the exact value.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from fractions import Fraction
|
|
13
|
+
from typing import Union
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MobiusNumber:
|
|
17
|
+
"""
|
|
18
|
+
A number that carries its own correction.
|
|
19
|
+
|
|
20
|
+
Internally:
|
|
21
|
+
_approx : float — the binary strand (fast, lossy)
|
|
22
|
+
_exact : Fraction — the anti strand (exact, complete)
|
|
23
|
+
|
|
24
|
+
The float is the shadow. The Fraction is the substance.
|
|
25
|
+
Collapse = the Möbius traversal → exact output.
|
|
26
|
+
|
|
27
|
+
Construction:
|
|
28
|
+
MobiusNumber("0.1") — from string (purest: "0.1" means exactly 1/10)
|
|
29
|
+
MobiusNumber(0.1) — from float (recovers rational intent)
|
|
30
|
+
MobiusNumber(1) — from int (exact)
|
|
31
|
+
MobiusNumber(Fraction(1, 10)) — from Fraction (exact)
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
__slots__ = ('_approx', '_exact')
|
|
35
|
+
|
|
36
|
+
def __init__(self, value: Union[int, float, str, Fraction, 'MobiusNumber']):
|
|
37
|
+
if isinstance(value, MobiusNumber):
|
|
38
|
+
self._approx = value._approx
|
|
39
|
+
self._exact = value._exact
|
|
40
|
+
elif isinstance(value, Fraction):
|
|
41
|
+
self._exact = value
|
|
42
|
+
self._approx = float(value)
|
|
43
|
+
elif isinstance(value, int):
|
|
44
|
+
self._exact = Fraction(value)
|
|
45
|
+
self._approx = float(value)
|
|
46
|
+
elif isinstance(value, float):
|
|
47
|
+
self._approx = value
|
|
48
|
+
self._exact = Fraction(value).limit_denominator(10**15)
|
|
49
|
+
elif isinstance(value, str):
|
|
50
|
+
self._exact = Fraction(value)
|
|
51
|
+
self._approx = float(self._exact)
|
|
52
|
+
else:
|
|
53
|
+
raise TypeError(f"Cannot create MobiusNumber from {type(value)}")
|
|
54
|
+
|
|
55
|
+
# ------------------------------------------------------------------
|
|
56
|
+
# Strand access
|
|
57
|
+
# ------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def approx(self) -> float:
|
|
61
|
+
"""The binary strand — hardware-fast, carries rounding error."""
|
|
62
|
+
return self._approx
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def exact(self) -> Fraction:
|
|
66
|
+
"""The rational strand — exact, no loss."""
|
|
67
|
+
return self._exact
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def residue(self) -> Fraction:
|
|
71
|
+
"""The anti-strand: exact_value − binary_approximation."""
|
|
72
|
+
return self._exact - Fraction(self._approx)
|
|
73
|
+
|
|
74
|
+
# ------------------------------------------------------------------
|
|
75
|
+
# Collapse — the Möbius traversal
|
|
76
|
+
# ------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
def collapse(self) -> float:
|
|
79
|
+
"""
|
|
80
|
+
Traverse the full Möbius loop.
|
|
81
|
+
Binary approximation + anti-strand residue → exact value.
|
|
82
|
+
"""
|
|
83
|
+
return float(self._exact)
|
|
84
|
+
|
|
85
|
+
# ------------------------------------------------------------------
|
|
86
|
+
# Arithmetic — both strands propagate
|
|
87
|
+
# ------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
def __add__(self, other):
|
|
90
|
+
other = _coerce(other)
|
|
91
|
+
result = MobiusNumber.__new__(MobiusNumber)
|
|
92
|
+
result._exact = self._exact + other._exact
|
|
93
|
+
result._approx = self._approx + other._approx
|
|
94
|
+
return result
|
|
95
|
+
|
|
96
|
+
def __radd__(self, other):
|
|
97
|
+
return self.__add__(other)
|
|
98
|
+
|
|
99
|
+
def __sub__(self, other):
|
|
100
|
+
other = _coerce(other)
|
|
101
|
+
result = MobiusNumber.__new__(MobiusNumber)
|
|
102
|
+
result._exact = self._exact - other._exact
|
|
103
|
+
result._approx = self._approx - other._approx
|
|
104
|
+
return result
|
|
105
|
+
|
|
106
|
+
def __rsub__(self, other):
|
|
107
|
+
other = _coerce(other)
|
|
108
|
+
return other.__sub__(self)
|
|
109
|
+
|
|
110
|
+
def __mul__(self, other):
|
|
111
|
+
other = _coerce(other)
|
|
112
|
+
result = MobiusNumber.__new__(MobiusNumber)
|
|
113
|
+
result._exact = self._exact * other._exact
|
|
114
|
+
result._approx = self._approx * other._approx
|
|
115
|
+
return result
|
|
116
|
+
|
|
117
|
+
def __rmul__(self, other):
|
|
118
|
+
return self.__mul__(other)
|
|
119
|
+
|
|
120
|
+
def __truediv__(self, other):
|
|
121
|
+
other = _coerce(other)
|
|
122
|
+
if other._exact == 0:
|
|
123
|
+
raise ZeroDivisionError("MobiusNumber division by zero")
|
|
124
|
+
result = MobiusNumber.__new__(MobiusNumber)
|
|
125
|
+
result._exact = self._exact / other._exact
|
|
126
|
+
result._approx = self._approx / other._approx
|
|
127
|
+
return result
|
|
128
|
+
|
|
129
|
+
def __rtruediv__(self, other):
|
|
130
|
+
other = _coerce(other)
|
|
131
|
+
return other.__truediv__(self)
|
|
132
|
+
|
|
133
|
+
def __neg__(self):
|
|
134
|
+
result = MobiusNumber.__new__(MobiusNumber)
|
|
135
|
+
result._exact = -self._exact
|
|
136
|
+
result._approx = -self._approx
|
|
137
|
+
return result
|
|
138
|
+
|
|
139
|
+
def __abs__(self):
|
|
140
|
+
result = MobiusNumber.__new__(MobiusNumber)
|
|
141
|
+
result._exact = abs(self._exact)
|
|
142
|
+
result._approx = abs(self._approx)
|
|
143
|
+
return result
|
|
144
|
+
|
|
145
|
+
def __pow__(self, exp):
|
|
146
|
+
if isinstance(exp, int):
|
|
147
|
+
result = MobiusNumber.__new__(MobiusNumber)
|
|
148
|
+
result._exact = self._exact ** exp
|
|
149
|
+
result._approx = self._approx ** exp
|
|
150
|
+
return result
|
|
151
|
+
return NotImplemented
|
|
152
|
+
|
|
153
|
+
# ------------------------------------------------------------------
|
|
154
|
+
# Comparison — the rational governs, always
|
|
155
|
+
# ------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
def __eq__(self, other):
|
|
158
|
+
if not isinstance(other, (MobiusNumber, int, float, str, Fraction)):
|
|
159
|
+
return NotImplemented
|
|
160
|
+
other = _coerce(other)
|
|
161
|
+
return self._exact == other._exact
|
|
162
|
+
|
|
163
|
+
def __ne__(self, other):
|
|
164
|
+
if not isinstance(other, (MobiusNumber, int, float, str, Fraction)):
|
|
165
|
+
return NotImplemented
|
|
166
|
+
other = _coerce(other)
|
|
167
|
+
return self._exact != other._exact
|
|
168
|
+
|
|
169
|
+
def __lt__(self, other):
|
|
170
|
+
other = _coerce(other)
|
|
171
|
+
return self._exact < other._exact
|
|
172
|
+
|
|
173
|
+
def __le__(self, other):
|
|
174
|
+
other = _coerce(other)
|
|
175
|
+
return self._exact <= other._exact
|
|
176
|
+
|
|
177
|
+
def __gt__(self, other):
|
|
178
|
+
other = _coerce(other)
|
|
179
|
+
return self._exact > other._exact
|
|
180
|
+
|
|
181
|
+
def __ge__(self, other):
|
|
182
|
+
other = _coerce(other)
|
|
183
|
+
return self._exact >= other._exact
|
|
184
|
+
|
|
185
|
+
# ------------------------------------------------------------------
|
|
186
|
+
# Display
|
|
187
|
+
# ------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
def __repr__(self):
|
|
190
|
+
return f"M('{self._exact}')"
|
|
191
|
+
|
|
192
|
+
def __str__(self):
|
|
193
|
+
return str(float(self._exact))
|
|
194
|
+
|
|
195
|
+
def __float__(self):
|
|
196
|
+
return float(self._exact)
|
|
197
|
+
|
|
198
|
+
def __int__(self):
|
|
199
|
+
return int(self._exact)
|
|
200
|
+
|
|
201
|
+
def __hash__(self):
|
|
202
|
+
return hash(self._exact)
|
|
203
|
+
|
|
204
|
+
# ------------------------------------------------------------------
|
|
205
|
+
# Diagnostic
|
|
206
|
+
# ------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
def diagnose(self) -> dict:
|
|
209
|
+
"""Return both strands and the residue between them."""
|
|
210
|
+
return {
|
|
211
|
+
"binary_strand": self._approx,
|
|
212
|
+
"rational_strand": str(self._exact),
|
|
213
|
+
"residue": str(self.residue),
|
|
214
|
+
"residue_float": float(self.residue),
|
|
215
|
+
"collapsed": self.collapse(),
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _coerce(value) -> MobiusNumber:
|
|
220
|
+
"""Convert a raw value into a MobiusNumber."""
|
|
221
|
+
if isinstance(value, MobiusNumber):
|
|
222
|
+
return value
|
|
223
|
+
return MobiusNumber(value)
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Tests for MöbiusNumber — complementary residue arithmetic."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from fractions import Fraction
|
|
5
|
+
from mobius_number import MobiusNumber, M
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# =====================================================================
|
|
9
|
+
# THE PROOF: 0.1 + 0.2 = 0.3
|
|
10
|
+
# =====================================================================
|
|
11
|
+
|
|
12
|
+
class TestTheProof:
|
|
13
|
+
"""The problem that started it all."""
|
|
14
|
+
|
|
15
|
+
def test_ieee754_fails(self):
|
|
16
|
+
"""IEEE 754 gets this wrong. This is not a bug — it's the spec."""
|
|
17
|
+
assert 0.1 + 0.2 != 0.3 # The 41-year-old problem
|
|
18
|
+
|
|
19
|
+
def test_mobius_solves_it(self):
|
|
20
|
+
"""MöbiusNumber gets it right."""
|
|
21
|
+
assert M("0.1") + M("0.2") == M("0.3")
|
|
22
|
+
|
|
23
|
+
def test_collapse_is_exact(self):
|
|
24
|
+
result = M("0.1") + M("0.2")
|
|
25
|
+
assert result.collapse() == 0.3
|
|
26
|
+
|
|
27
|
+
def test_error_is_zero(self):
|
|
28
|
+
result = M("0.1") + M("0.2")
|
|
29
|
+
target = M("0.3")
|
|
30
|
+
assert result.exact - target.exact == Fraction(0)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# =====================================================================
|
|
34
|
+
# CONSTRUCTION
|
|
35
|
+
# =====================================================================
|
|
36
|
+
|
|
37
|
+
class TestConstruction:
|
|
38
|
+
"""Every input path must produce the correct rational strand."""
|
|
39
|
+
|
|
40
|
+
def test_from_string(self):
|
|
41
|
+
n = M("0.1")
|
|
42
|
+
assert n.exact == Fraction(1, 10)
|
|
43
|
+
|
|
44
|
+
def test_from_int(self):
|
|
45
|
+
n = M(42)
|
|
46
|
+
assert n.exact == Fraction(42)
|
|
47
|
+
assert n.approx == 42.0
|
|
48
|
+
|
|
49
|
+
def test_from_float(self):
|
|
50
|
+
n = M(0.5)
|
|
51
|
+
assert n.exact == Fraction(1, 2)
|
|
52
|
+
|
|
53
|
+
def test_from_fraction(self):
|
|
54
|
+
n = M(Fraction(1, 7))
|
|
55
|
+
assert n.exact == Fraction(1, 7)
|
|
56
|
+
|
|
57
|
+
def test_from_mobius(self):
|
|
58
|
+
a = M("3.14")
|
|
59
|
+
b = M(a)
|
|
60
|
+
assert b.exact == a.exact
|
|
61
|
+
|
|
62
|
+
def test_from_negative_string(self):
|
|
63
|
+
n = M("-0.1")
|
|
64
|
+
assert n.exact == Fraction(-1, 10)
|
|
65
|
+
|
|
66
|
+
def test_from_zero(self):
|
|
67
|
+
n = M(0)
|
|
68
|
+
assert n.exact == Fraction(0)
|
|
69
|
+
assert n.approx == 0.0
|
|
70
|
+
|
|
71
|
+
def test_invalid_type_raises(self):
|
|
72
|
+
with pytest.raises(TypeError):
|
|
73
|
+
M([1, 2, 3])
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# =====================================================================
|
|
77
|
+
# ARITHMETIC
|
|
78
|
+
# =====================================================================
|
|
79
|
+
|
|
80
|
+
class TestArithmetic:
|
|
81
|
+
"""Both strands must propagate through every operation."""
|
|
82
|
+
|
|
83
|
+
def test_addition(self):
|
|
84
|
+
assert (M("0.1") + M("0.2")).exact == Fraction(3, 10)
|
|
85
|
+
|
|
86
|
+
def test_subtraction(self):
|
|
87
|
+
assert (M("0.3") - M("0.1")).exact == Fraction(1, 5)
|
|
88
|
+
|
|
89
|
+
def test_multiplication(self):
|
|
90
|
+
assert (M("0.1") * M("10")).exact == Fraction(1)
|
|
91
|
+
|
|
92
|
+
def test_division(self):
|
|
93
|
+
assert (M("1") / M("3")).exact == Fraction(1, 3)
|
|
94
|
+
|
|
95
|
+
def test_negation(self):
|
|
96
|
+
assert (-M("0.5")).exact == Fraction(-1, 2)
|
|
97
|
+
|
|
98
|
+
def test_abs(self):
|
|
99
|
+
assert abs(M("-7")).exact == Fraction(7)
|
|
100
|
+
|
|
101
|
+
def test_power(self):
|
|
102
|
+
assert (M("3") ** 2).exact == Fraction(9)
|
|
103
|
+
|
|
104
|
+
def test_division_by_zero(self):
|
|
105
|
+
with pytest.raises(ZeroDivisionError):
|
|
106
|
+
M("1") / M("0")
|
|
107
|
+
|
|
108
|
+
def test_radd(self):
|
|
109
|
+
result = 1 + M("0.5")
|
|
110
|
+
assert result.exact == Fraction(3, 2)
|
|
111
|
+
|
|
112
|
+
def test_rsub(self):
|
|
113
|
+
result = 1 - M("0.3")
|
|
114
|
+
assert result.exact == Fraction(7, 10)
|
|
115
|
+
|
|
116
|
+
def test_rmul(self):
|
|
117
|
+
result = 3 * M("0.1")
|
|
118
|
+
assert result.exact == Fraction(3, 10)
|
|
119
|
+
|
|
120
|
+
def test_rtruediv(self):
|
|
121
|
+
result = 1 / M("3")
|
|
122
|
+
assert result.exact == Fraction(1, 3)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# =====================================================================
|
|
126
|
+
# THE FAMOUS FAILURES
|
|
127
|
+
# =====================================================================
|
|
128
|
+
|
|
129
|
+
class TestFamousFailures:
|
|
130
|
+
"""Every classic IEEE 754 failure, corrected."""
|
|
131
|
+
|
|
132
|
+
def test_sum_point_one_ten_times(self):
|
|
133
|
+
"""0.1 added 10 times should equal 1.0."""
|
|
134
|
+
total = M("0.0")
|
|
135
|
+
for _ in range(10):
|
|
136
|
+
total = total + M("0.1")
|
|
137
|
+
assert total == M("1.0")
|
|
138
|
+
|
|
139
|
+
def test_one_forty_ninth_times_forty_nine(self):
|
|
140
|
+
"""(1/49) * 49 should equal 1."""
|
|
141
|
+
assert (M("1") / M("49")) * M("49") == M("1")
|
|
142
|
+
|
|
143
|
+
def test_one_third_times_three(self):
|
|
144
|
+
"""(1/3) * 3 should equal 1."""
|
|
145
|
+
assert (M("1") / M("3")) * M("3") == M("1")
|
|
146
|
+
|
|
147
|
+
def test_ten_divided_by_three_times_three(self):
|
|
148
|
+
"""$10.00 / 3 * 3 should equal $10.00."""
|
|
149
|
+
assert (M("10") / M("3")) * M("3") == M("10")
|
|
150
|
+
|
|
151
|
+
def test_catastrophic_cancellation(self):
|
|
152
|
+
"""(1 + 1e-16) - 1 should equal 1e-16, not 0."""
|
|
153
|
+
result = (M("1") + M("0.0000000000000001")) - M("1")
|
|
154
|
+
assert result.exact == Fraction(1, 10**16)
|
|
155
|
+
assert result.collapse() == 1e-16
|
|
156
|
+
|
|
157
|
+
def test_ieee_catastrophic_cancellation_loses(self):
|
|
158
|
+
"""IEEE 754 loses this entirely — returns 0."""
|
|
159
|
+
assert (1.0 + 1e-16) - 1.0 == 0.0 # Total information loss
|
|
160
|
+
|
|
161
|
+
def test_one_seventh_round_trip(self):
|
|
162
|
+
"""1/7 * 7 = 1 exactly."""
|
|
163
|
+
assert (M("1") / M("7")) * M("7") == M("1")
|
|
164
|
+
|
|
165
|
+
def test_penny_accumulation(self):
|
|
166
|
+
"""Add $0.01 one hundred times = $1.00."""
|
|
167
|
+
total = M("0.0")
|
|
168
|
+
for _ in range(100):
|
|
169
|
+
total = total + M("0.01")
|
|
170
|
+
assert total == M("1.0")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# =====================================================================
|
|
174
|
+
# COMPARISON — THE RATIONAL GOVERNS
|
|
175
|
+
# =====================================================================
|
|
176
|
+
|
|
177
|
+
class TestComparison:
|
|
178
|
+
"""Equality and ordering use the rational strand, not the float."""
|
|
179
|
+
|
|
180
|
+
def test_equality(self):
|
|
181
|
+
assert M("0.1") == M("0.1")
|
|
182
|
+
|
|
183
|
+
def test_inequality(self):
|
|
184
|
+
assert M("0.1") != M("0.2")
|
|
185
|
+
|
|
186
|
+
def test_less_than(self):
|
|
187
|
+
assert M("0.1") < M("0.2")
|
|
188
|
+
|
|
189
|
+
def test_less_equal(self):
|
|
190
|
+
assert M("0.1") <= M("0.1")
|
|
191
|
+
assert M("0.1") <= M("0.2")
|
|
192
|
+
|
|
193
|
+
def test_greater_than(self):
|
|
194
|
+
assert M("0.2") > M("0.1")
|
|
195
|
+
|
|
196
|
+
def test_greater_equal(self):
|
|
197
|
+
assert M("0.2") >= M("0.2")
|
|
198
|
+
assert M("0.2") >= M("0.1")
|
|
199
|
+
|
|
200
|
+
def test_cross_type_equality(self):
|
|
201
|
+
assert M("5") == M(5)
|
|
202
|
+
|
|
203
|
+
def test_hash_consistency(self):
|
|
204
|
+
"""Equal MöbiusNumbers must have equal hashes."""
|
|
205
|
+
a = M("0.1") + M("0.2")
|
|
206
|
+
b = M("0.3")
|
|
207
|
+
assert hash(a) == hash(b)
|
|
208
|
+
|
|
209
|
+
def test_usable_as_dict_key(self):
|
|
210
|
+
d = {M("0.3"): "found"}
|
|
211
|
+
key = M("0.1") + M("0.2")
|
|
212
|
+
assert d[key] == "found"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# =====================================================================
|
|
216
|
+
# STRAND ANATOMY
|
|
217
|
+
# =====================================================================
|
|
218
|
+
|
|
219
|
+
class TestStrands:
|
|
220
|
+
"""The residue (anti-strand) must be the exact complement."""
|
|
221
|
+
|
|
222
|
+
def test_residue_of_point_one(self):
|
|
223
|
+
n = M("0.1")
|
|
224
|
+
# residue = exact - Fraction(approx)
|
|
225
|
+
# exact is 1/10, approx is the nearest float
|
|
226
|
+
assert n.residue == Fraction(1, 10) - Fraction(0.1)
|
|
227
|
+
|
|
228
|
+
def test_residue_of_integer_is_zero(self):
|
|
229
|
+
n = M(1)
|
|
230
|
+
assert n.residue == Fraction(0)
|
|
231
|
+
|
|
232
|
+
def test_residue_of_half_is_zero(self):
|
|
233
|
+
n = M("0.5")
|
|
234
|
+
assert n.residue == Fraction(0) # 0.5 is exact in binary
|
|
235
|
+
|
|
236
|
+
def test_diagnose_returns_dict(self):
|
|
237
|
+
d = M("0.1").diagnose()
|
|
238
|
+
assert "binary_strand" in d
|
|
239
|
+
assert "rational_strand" in d
|
|
240
|
+
assert "residue" in d
|
|
241
|
+
assert "collapsed" in d
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# =====================================================================
|
|
245
|
+
# DISPLAY
|
|
246
|
+
# =====================================================================
|
|
247
|
+
|
|
248
|
+
class TestDisplay:
|
|
249
|
+
"""String output must show the collapsed (exact) value."""
|
|
250
|
+
|
|
251
|
+
def test_str(self):
|
|
252
|
+
assert str(M("0.3")) == "0.3"
|
|
253
|
+
|
|
254
|
+
def test_repr(self):
|
|
255
|
+
assert repr(M("0.1")) == "M('1/10')"
|
|
256
|
+
|
|
257
|
+
def test_float_conversion(self):
|
|
258
|
+
assert float(M("0.5")) == 0.5
|
|
259
|
+
|
|
260
|
+
def test_int_conversion(self):
|
|
261
|
+
assert int(M("42")) == 42
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# =====================================================================
|
|
265
|
+
# ACCUMULATION STRESS
|
|
266
|
+
# =====================================================================
|
|
267
|
+
|
|
268
|
+
class TestAccumulation:
|
|
269
|
+
"""Errors must not compound across long chains."""
|
|
270
|
+
|
|
271
|
+
def test_thousand_additions(self):
|
|
272
|
+
"""0.001 * 1000 = 1.0 exactly."""
|
|
273
|
+
total = M("0.0")
|
|
274
|
+
for _ in range(1000):
|
|
275
|
+
total = total + M("0.001")
|
|
276
|
+
assert total == M("1.0")
|
|
277
|
+
|
|
278
|
+
def test_chained_division_multiplication(self):
|
|
279
|
+
"""(((1 / 3) / 7) / 11) * 11 * 7 * 3 = 1."""
|
|
280
|
+
n = M("1")
|
|
281
|
+
n = n / M("3")
|
|
282
|
+
n = n / M("7")
|
|
283
|
+
n = n / M("11")
|
|
284
|
+
n = n * M("11")
|
|
285
|
+
n = n * M("7")
|
|
286
|
+
n = n * M("3")
|
|
287
|
+
assert n == M("1")
|
|
288
|
+
|
|
289
|
+
def test_financial_chain(self):
|
|
290
|
+
"""$100 split 7 ways, then reassembled."""
|
|
291
|
+
share = M("100") / M("7")
|
|
292
|
+
total = share * M("7")
|
|
293
|
+
assert total == M("100")
|