undetermined 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.
- undetermined-0.1.0/LICENSE +21 -0
- undetermined-0.1.0/PKG-INFO +203 -0
- undetermined-0.1.0/README.md +187 -0
- undetermined-0.1.0/pyproject.toml +32 -0
- undetermined-0.1.0/python/undetermined/__init__.py +26 -0
- undetermined-0.1.0/python/undetermined/budget.py +114 -0
- undetermined-0.1.0/python/undetermined/core.py +209 -0
- undetermined-0.1.0/python/undetermined.egg-info/PKG-INFO +203 -0
- undetermined-0.1.0/python/undetermined.egg-info/SOURCES.txt +10 -0
- undetermined-0.1.0/python/undetermined.egg-info/dependency_links.txt +1 -0
- undetermined-0.1.0/python/undetermined.egg-info/top_level.txt +1 -0
- undetermined-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Seth Wheeler
|
|
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,203 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: undetermined
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Point it at a program, get back what can and cannot be determined: a constant with an error bar, and an explicit undetermined list with reasons.
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: empirical,constant,measurement,plateau,algorithm-analysis,benchmark,estimation,reproducibility,uncertainty,minimum-detectable-effect,zero-dependency
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: Intended Audience :: Science/Research
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# undetermined
|
|
18
|
+
|
|
19
|
+
**Point it at a program; get back what can and cannot be determined about it.**
|
|
20
|
+
|
|
21
|
+
Plenty of libraries fit a curve to measurements and hand you back a number. This one
|
|
22
|
+
hands back a number *with the error bar it was decided by*, plus an explicit
|
|
23
|
+
`undetermined` list with a reason on each entry — and it will put an observable on that
|
|
24
|
+
list rather than fit a plateau to a drift.
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
heads: 1.9978 +/- 0.0032 4 rungs from truth=8 agree within 2.0 sigma
|
|
28
|
+
flat: UNDETERMINED no run of 3 rungs agrees; the constant is still moving
|
|
29
|
+
at the top of the ladder
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The second line is the point. A tool that always produced the first line would be
|
|
33
|
+
useless and would still pass every test that checks it produces one.
|
|
34
|
+
|
|
35
|
+
Ships as `undetermined` on **PyPI** and on **npm**, from one tree, at one version. No
|
|
36
|
+
dependencies in either half.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install undetermined
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
npm install undetermined
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Use
|
|
51
|
+
|
|
52
|
+
You supply an **adapter**: an object that knows how to run the thing you are measuring
|
|
53
|
+
at a controllable input size, and nothing else. This library never knows what program it
|
|
54
|
+
is looking at.
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
import random
|
|
58
|
+
from undetermined import characterize
|
|
59
|
+
|
|
60
|
+
class Coin:
|
|
61
|
+
def truths(self):
|
|
62
|
+
# The controllable input values, known by construction. A ladder, not a point:
|
|
63
|
+
# a constant that is only measured at one size cannot be shown to have settled.
|
|
64
|
+
return [8, 32, 128, 512]
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def observables(self):
|
|
68
|
+
def heads(truth, seed):
|
|
69
|
+
r = random.Random(seed)
|
|
70
|
+
return sum(1 for _ in range(truth) if r.random() < 0.5)
|
|
71
|
+
|
|
72
|
+
def flat(truth, seed):
|
|
73
|
+
r = random.Random(seed)
|
|
74
|
+
return sum(1 for _ in range(12) if r.random() < 0.5) + 0.5
|
|
75
|
+
|
|
76
|
+
return {"heads": heads, "flat": flat}
|
|
77
|
+
|
|
78
|
+
report = characterize(Coin(), trials=2500)
|
|
79
|
+
|
|
80
|
+
report["per_observable"]["heads"]["constant"] # 1.9978...
|
|
81
|
+
report["per_observable"]["heads"]["constant_se"] # 0.0032...
|
|
82
|
+
report["undetermined"] # ['flat']
|
|
83
|
+
report["notes"] # why each one is on the list
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
```js
|
|
87
|
+
import { characterize } from "undetermined";
|
|
88
|
+
|
|
89
|
+
const report = characterize(adapter, { trials: 2500 });
|
|
90
|
+
report.per_observable.heads.constant; // 1.9978...
|
|
91
|
+
report.undetermined; // ['flat']
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### The adapter protocol
|
|
95
|
+
|
|
96
|
+
| member | required | meaning |
|
|
97
|
+
| --- | --- | --- |
|
|
98
|
+
| `truths()` | yes | the controllable input values, known by construction |
|
|
99
|
+
| `observables` | yes | `{name: (truth, seed) -> number}`, at least two |
|
|
100
|
+
| `instances()` | no | sibling instances of the same family |
|
|
101
|
+
| `knobs` | no | `{name: [values]}` — parameters of the program itself |
|
|
102
|
+
| `perturbed(knob, v)` | with `knobs` | the observables with that knob set to that value |
|
|
103
|
+
|
|
104
|
+
At least two observables, always. With one there is no choice to make, so the library
|
|
105
|
+
cannot be shown to make one — it raises rather than reporting a confident single answer.
|
|
106
|
+
|
|
107
|
+
### Deriving the trial count instead of guessing it
|
|
108
|
+
|
|
109
|
+
`characterize` takes a `trials` count. If you would rather state the precision you need
|
|
110
|
+
and have the budget derived:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from undetermined import to_tolerance
|
|
114
|
+
report = to_tolerance(MyAdapter(), tolerance=0.01) # 1% on the constant
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
It doubles the trial count until every determined constant is inside the tolerance or the
|
|
118
|
+
cap is reached, and reports which ones never got there. `tolerance=0` raises: a tolerance
|
|
119
|
+
is a decision about your problem, and this library will not choose it for you.
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## What it refuses, and why
|
|
124
|
+
|
|
125
|
+
Three refusals, and each is the answer to a way of being confidently wrong.
|
|
126
|
+
|
|
127
|
+
**An observable that ignores its seed** raises immediately. `fit` averages an observable
|
|
128
|
+
over `trials` *different* seeds, so an observable that reads the clock or an unseeded RNG
|
|
129
|
+
produces a mean over noise. A mean over noise still has a standard error, still forms a
|
|
130
|
+
ladder, and can still plateau — every guard downstream compares against that error, so a
|
|
131
|
+
broken adapter does not produce a wrong-looking answer, it produces a **confident** one.
|
|
132
|
+
Each observable is called twice with the same `(truth, seed)` at the first and last rung;
|
|
133
|
+
a disagreement is a wiring error, not a finding, so it throws.
|
|
134
|
+
|
|
135
|
+
**A constant that never settles** is reported as `UNDETERMINED` with the reason. A run of
|
|
136
|
+
three consecutive rungs must agree within two combined standard errors before any
|
|
137
|
+
plateau is reported; the value is then the inverse-variance-weighted mean over that run,
|
|
138
|
+
and the report says which rung it started from.
|
|
139
|
+
|
|
140
|
+
**A choice between observables that is not earned** is reported as `UNDETERMINED`. With
|
|
141
|
+
`instances()`, an observable is only called informative if its constant varies at least
|
|
142
|
+
3x its own measurement error across instances, and it is only *chosen* over the runner-up
|
|
143
|
+
if it beats it by 3x. Two observables that both vary a lot are not a choice.
|
|
144
|
+
|
|
145
|
+
### The rule underneath all of it
|
|
146
|
+
|
|
147
|
+
> Compare against the **noise**, never against the **size**.
|
|
148
|
+
|
|
149
|
+
A spread only means something in units of the error on the thing that spread. Dividing by
|
|
150
|
+
the magnitude instead is how a large number gets mistaken for a real one, and it is the
|
|
151
|
+
single mistake this library is shaped around not making.
|
|
152
|
+
|
|
153
|
+
### The thresholds
|
|
154
|
+
|
|
155
|
+
They are the contract, and `python/tests/test_parity.py` asserts both halves hold the
|
|
156
|
+
same ones and produce the same output on the same numbers — including the same
|
|
157
|
+
explanatory strings.
|
|
158
|
+
|
|
159
|
+
| constant | value | what it gates |
|
|
160
|
+
| --- | --- | --- |
|
|
161
|
+
| `PLATEAU_K` | 2.0 | sigma within which rungs must agree |
|
|
162
|
+
| `PLATEAU_RUN` | 3 | consecutive agreeing rungs required |
|
|
163
|
+
| `MIN_RATIO` | 3.0 | error-multiples an observable must vary by to be informative |
|
|
164
|
+
| `MIN_MARGIN` | 3.0 | factor by which the winner must beat the runner-up |
|
|
165
|
+
| `SIGMAS` | 3.0 | standard errors in a minimum detectable effect |
|
|
166
|
+
| `FLOOR_TRIALS` / `CAP_TRIALS` | 400 / 40000 | the budget search bounds |
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## This is a library, not a CLI
|
|
171
|
+
|
|
172
|
+
An adapter is a code object with closures in it. There is nothing to pass on a command
|
|
173
|
+
line that would not amount to naming a Python or JavaScript symbol and importing it, so
|
|
174
|
+
the package ships an import and no console script.
|
|
175
|
+
|
|
176
|
+
## Where it sits
|
|
177
|
+
|
|
178
|
+
**Layer 0**: no dependencies inside or outside this network of packages, by design.
|
|
179
|
+
|
|
180
|
+
The `nondet` edge was considered and **rejected**. `nondet` addresses a function as
|
|
181
|
+
`FILE::NAME` so it can re-run it in fresh processes; this library's observables are
|
|
182
|
+
closures inside an adapter object and have no such address, so `nondet` cannot probe
|
|
183
|
+
them. Wiring it in would have meant either a fake file path or a check that never ran —
|
|
184
|
+
a dependency that looks like a guarantee and is not. The reproducibility precondition is
|
|
185
|
+
implemented natively in both halves instead, and it is checked in the same call that
|
|
186
|
+
would have needed the guarantee. `nondet` remains the right tool for the *functions your
|
|
187
|
+
adapter calls*, which do have addresses; running it on those is a good idea and is not
|
|
188
|
+
something this package can do on your behalf.
|
|
189
|
+
|
|
190
|
+
## Development
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
python3 -m unittest discover -s python/tests -v # PYTHONPATH=python
|
|
194
|
+
node --test js/test/*.test.js
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
The parity suite skips when `node` is not on PATH, so a Python-only contributor can still
|
|
198
|
+
run everything else. CI asserts it was **not** skipped — a skipped test and a passing one
|
|
199
|
+
look identical in a tally.
|
|
200
|
+
|
|
201
|
+
## License
|
|
202
|
+
|
|
203
|
+
MIT
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# undetermined
|
|
2
|
+
|
|
3
|
+
**Point it at a program; get back what can and cannot be determined about it.**
|
|
4
|
+
|
|
5
|
+
Plenty of libraries fit a curve to measurements and hand you back a number. This one
|
|
6
|
+
hands back a number *with the error bar it was decided by*, plus an explicit
|
|
7
|
+
`undetermined` list with a reason on each entry — and it will put an observable on that
|
|
8
|
+
list rather than fit a plateau to a drift.
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
heads: 1.9978 +/- 0.0032 4 rungs from truth=8 agree within 2.0 sigma
|
|
12
|
+
flat: UNDETERMINED no run of 3 rungs agrees; the constant is still moving
|
|
13
|
+
at the top of the ladder
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The second line is the point. A tool that always produced the first line would be
|
|
17
|
+
useless and would still pass every test that checks it produces one.
|
|
18
|
+
|
|
19
|
+
Ships as `undetermined` on **PyPI** and on **npm**, from one tree, at one version. No
|
|
20
|
+
dependencies in either half.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install undetermined
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npm install undetermined
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Use
|
|
35
|
+
|
|
36
|
+
You supply an **adapter**: an object that knows how to run the thing you are measuring
|
|
37
|
+
at a controllable input size, and nothing else. This library never knows what program it
|
|
38
|
+
is looking at.
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
import random
|
|
42
|
+
from undetermined import characterize
|
|
43
|
+
|
|
44
|
+
class Coin:
|
|
45
|
+
def truths(self):
|
|
46
|
+
# The controllable input values, known by construction. A ladder, not a point:
|
|
47
|
+
# a constant that is only measured at one size cannot be shown to have settled.
|
|
48
|
+
return [8, 32, 128, 512]
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def observables(self):
|
|
52
|
+
def heads(truth, seed):
|
|
53
|
+
r = random.Random(seed)
|
|
54
|
+
return sum(1 for _ in range(truth) if r.random() < 0.5)
|
|
55
|
+
|
|
56
|
+
def flat(truth, seed):
|
|
57
|
+
r = random.Random(seed)
|
|
58
|
+
return sum(1 for _ in range(12) if r.random() < 0.5) + 0.5
|
|
59
|
+
|
|
60
|
+
return {"heads": heads, "flat": flat}
|
|
61
|
+
|
|
62
|
+
report = characterize(Coin(), trials=2500)
|
|
63
|
+
|
|
64
|
+
report["per_observable"]["heads"]["constant"] # 1.9978...
|
|
65
|
+
report["per_observable"]["heads"]["constant_se"] # 0.0032...
|
|
66
|
+
report["undetermined"] # ['flat']
|
|
67
|
+
report["notes"] # why each one is on the list
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
```js
|
|
71
|
+
import { characterize } from "undetermined";
|
|
72
|
+
|
|
73
|
+
const report = characterize(adapter, { trials: 2500 });
|
|
74
|
+
report.per_observable.heads.constant; // 1.9978...
|
|
75
|
+
report.undetermined; // ['flat']
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### The adapter protocol
|
|
79
|
+
|
|
80
|
+
| member | required | meaning |
|
|
81
|
+
| --- | --- | --- |
|
|
82
|
+
| `truths()` | yes | the controllable input values, known by construction |
|
|
83
|
+
| `observables` | yes | `{name: (truth, seed) -> number}`, at least two |
|
|
84
|
+
| `instances()` | no | sibling instances of the same family |
|
|
85
|
+
| `knobs` | no | `{name: [values]}` — parameters of the program itself |
|
|
86
|
+
| `perturbed(knob, v)` | with `knobs` | the observables with that knob set to that value |
|
|
87
|
+
|
|
88
|
+
At least two observables, always. With one there is no choice to make, so the library
|
|
89
|
+
cannot be shown to make one — it raises rather than reporting a confident single answer.
|
|
90
|
+
|
|
91
|
+
### Deriving the trial count instead of guessing it
|
|
92
|
+
|
|
93
|
+
`characterize` takes a `trials` count. If you would rather state the precision you need
|
|
94
|
+
and have the budget derived:
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from undetermined import to_tolerance
|
|
98
|
+
report = to_tolerance(MyAdapter(), tolerance=0.01) # 1% on the constant
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
It doubles the trial count until every determined constant is inside the tolerance or the
|
|
102
|
+
cap is reached, and reports which ones never got there. `tolerance=0` raises: a tolerance
|
|
103
|
+
is a decision about your problem, and this library will not choose it for you.
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## What it refuses, and why
|
|
108
|
+
|
|
109
|
+
Three refusals, and each is the answer to a way of being confidently wrong.
|
|
110
|
+
|
|
111
|
+
**An observable that ignores its seed** raises immediately. `fit` averages an observable
|
|
112
|
+
over `trials` *different* seeds, so an observable that reads the clock or an unseeded RNG
|
|
113
|
+
produces a mean over noise. A mean over noise still has a standard error, still forms a
|
|
114
|
+
ladder, and can still plateau — every guard downstream compares against that error, so a
|
|
115
|
+
broken adapter does not produce a wrong-looking answer, it produces a **confident** one.
|
|
116
|
+
Each observable is called twice with the same `(truth, seed)` at the first and last rung;
|
|
117
|
+
a disagreement is a wiring error, not a finding, so it throws.
|
|
118
|
+
|
|
119
|
+
**A constant that never settles** is reported as `UNDETERMINED` with the reason. A run of
|
|
120
|
+
three consecutive rungs must agree within two combined standard errors before any
|
|
121
|
+
plateau is reported; the value is then the inverse-variance-weighted mean over that run,
|
|
122
|
+
and the report says which rung it started from.
|
|
123
|
+
|
|
124
|
+
**A choice between observables that is not earned** is reported as `UNDETERMINED`. With
|
|
125
|
+
`instances()`, an observable is only called informative if its constant varies at least
|
|
126
|
+
3x its own measurement error across instances, and it is only *chosen* over the runner-up
|
|
127
|
+
if it beats it by 3x. Two observables that both vary a lot are not a choice.
|
|
128
|
+
|
|
129
|
+
### The rule underneath all of it
|
|
130
|
+
|
|
131
|
+
> Compare against the **noise**, never against the **size**.
|
|
132
|
+
|
|
133
|
+
A spread only means something in units of the error on the thing that spread. Dividing by
|
|
134
|
+
the magnitude instead is how a large number gets mistaken for a real one, and it is the
|
|
135
|
+
single mistake this library is shaped around not making.
|
|
136
|
+
|
|
137
|
+
### The thresholds
|
|
138
|
+
|
|
139
|
+
They are the contract, and `python/tests/test_parity.py` asserts both halves hold the
|
|
140
|
+
same ones and produce the same output on the same numbers — including the same
|
|
141
|
+
explanatory strings.
|
|
142
|
+
|
|
143
|
+
| constant | value | what it gates |
|
|
144
|
+
| --- | --- | --- |
|
|
145
|
+
| `PLATEAU_K` | 2.0 | sigma within which rungs must agree |
|
|
146
|
+
| `PLATEAU_RUN` | 3 | consecutive agreeing rungs required |
|
|
147
|
+
| `MIN_RATIO` | 3.0 | error-multiples an observable must vary by to be informative |
|
|
148
|
+
| `MIN_MARGIN` | 3.0 | factor by which the winner must beat the runner-up |
|
|
149
|
+
| `SIGMAS` | 3.0 | standard errors in a minimum detectable effect |
|
|
150
|
+
| `FLOOR_TRIALS` / `CAP_TRIALS` | 400 / 40000 | the budget search bounds |
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## This is a library, not a CLI
|
|
155
|
+
|
|
156
|
+
An adapter is a code object with closures in it. There is nothing to pass on a command
|
|
157
|
+
line that would not amount to naming a Python or JavaScript symbol and importing it, so
|
|
158
|
+
the package ships an import and no console script.
|
|
159
|
+
|
|
160
|
+
## Where it sits
|
|
161
|
+
|
|
162
|
+
**Layer 0**: no dependencies inside or outside this network of packages, by design.
|
|
163
|
+
|
|
164
|
+
The `nondet` edge was considered and **rejected**. `nondet` addresses a function as
|
|
165
|
+
`FILE::NAME` so it can re-run it in fresh processes; this library's observables are
|
|
166
|
+
closures inside an adapter object and have no such address, so `nondet` cannot probe
|
|
167
|
+
them. Wiring it in would have meant either a fake file path or a check that never ran —
|
|
168
|
+
a dependency that looks like a guarantee and is not. The reproducibility precondition is
|
|
169
|
+
implemented natively in both halves instead, and it is checked in the same call that
|
|
170
|
+
would have needed the guarantee. `nondet` remains the right tool for the *functions your
|
|
171
|
+
adapter calls*, which do have addresses; running it on those is a good idea and is not
|
|
172
|
+
something this package can do on your behalf.
|
|
173
|
+
|
|
174
|
+
## Development
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
python3 -m unittest discover -s python/tests -v # PYTHONPATH=python
|
|
178
|
+
node --test js/test/*.test.js
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
The parity suite skips when `node` is not on PATH, so a Python-only contributor can still
|
|
182
|
+
run everything else. CI asserts it was **not** skipped — a skipped test and a passing one
|
|
183
|
+
look identical in a tally.
|
|
184
|
+
|
|
185
|
+
## License
|
|
186
|
+
|
|
187
|
+
MIT
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "undetermined"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Point it at a program, get back what can and cannot be determined: a constant with an error bar, and an explicit undetermined list with reasons."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
keywords = [
|
|
13
|
+
"empirical", "constant", "measurement", "plateau", "algorithm-analysis",
|
|
14
|
+
"benchmark", "estimation", "reproducibility", "uncertainty",
|
|
15
|
+
"minimum-detectable-effect", "zero-dependency",
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 3 - Alpha",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"Intended Audience :: Science/Research",
|
|
21
|
+
"License :: OSI Approved :: MIT License",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
]
|
|
24
|
+
# LAYER 0. No in-network dependencies. The `nondet` edge was considered and rejected:
|
|
25
|
+
# nondet addresses a function as FILE::NAME, and this package's observables are closures
|
|
26
|
+
# inside an adapter object, so it cannot probe them. The reproducibility precondition is
|
|
27
|
+
# implemented natively in both halves instead. See the README.
|
|
28
|
+
dependencies = []
|
|
29
|
+
|
|
30
|
+
[tool.setuptools]
|
|
31
|
+
package-dir = { "" = "python" }
|
|
32
|
+
packages = ["undetermined"]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""undetermined — point it at a program, get back what can and cannot be determined.
|
|
2
|
+
|
|
3
|
+
from undetermined import characterize, to_tolerance
|
|
4
|
+
|
|
5
|
+
report = characterize(MyAdapter(), trials=2500)
|
|
6
|
+
report = to_tolerance(MyAdapter(), tolerance=0.01) # derive the trial count instead
|
|
7
|
+
|
|
8
|
+
The name is the differentiator. Plenty of things fit a curve to measurements and hand
|
|
9
|
+
back a number; this one has an `undetermined` list with reasons, and will put an
|
|
10
|
+
observable on it rather than fit a plateau to a drift.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from .budget import mde, to_tolerance, trials_for
|
|
14
|
+
from .core import (
|
|
15
|
+
UNDETERMINED,
|
|
16
|
+
characterize,
|
|
17
|
+
fit,
|
|
18
|
+
heterogeneity,
|
|
19
|
+
ladder_for,
|
|
20
|
+
plateau,
|
|
21
|
+
reproducible,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = ["characterize", "to_tolerance", "reproducible", "fit", "plateau",
|
|
25
|
+
"heterogeneity", "ladder_for", "mde", "trials_for", "UNDETERMINED"]
|
|
26
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Spend trials until the answer is supportable, and refuse to report one that is not.
|
|
2
|
+
|
|
3
|
+
`characterize(adapter, trials=2500)` and `discover(adapter, trials=1200)` both take a trial
|
|
4
|
+
count the caller has to guess. Exp 310 measured what guessing costs, on twelve sealed
|
|
5
|
+
capabilities with the same rule and only the precision differing:
|
|
6
|
+
|
|
7
|
+
a coarse guess 2 of 12 correct
|
|
8
|
+
a good guess 11 of 12
|
|
9
|
+
a fine guess 12 of 12
|
|
10
|
+
DERIVED 11 of 12 -- and you cannot guess wrong
|
|
11
|
+
|
|
12
|
+
So the derived budget is not more accurate than a good guess. What it buys is that there is
|
|
13
|
+
no guess: the trial count is computed from the size of effect you said you cared about and
|
|
14
|
+
the variance actually observed, and it varied 100x across those twelve capabilities (400 to
|
|
15
|
+
40,000) with nothing in the rule knowing which capability was which.
|
|
16
|
+
|
|
17
|
+
THE SECOND HALF MATTERS MORE. Exp 309 published twelve verdicts and five of them were
|
|
18
|
+
unresolved -- right, and reached by measurements that could not have detected the effect that
|
|
19
|
+
decides the question. It reported a minimum detectable effect only on the branch where it
|
|
20
|
+
abstained. So here EVERY constant carries its MDE, and one whose MDE exceeds the tolerance is
|
|
21
|
+
reported `supported: False` with what it would cost, instead of being reported as a number.
|
|
22
|
+
|
|
23
|
+
from budget import to_tolerance
|
|
24
|
+
report = to_tolerance(MyAdapter(), tolerance=0.01) # "1% in the constant matters to me"
|
|
25
|
+
|
|
26
|
+
`tolerance` is a statement of what difference matters -- like a significance level, it is part
|
|
27
|
+
of the question and not a nuisance parameter. It is the one number this module will not choose
|
|
28
|
+
for you, and exp 310's TARGETS said so before that round ran.
|
|
29
|
+
"""
|
|
30
|
+
from . import core as CH
|
|
31
|
+
|
|
32
|
+
SIGMAS = 3.0 # exp 309's RESOLVABLE_SIGMAS; an MDE is this many standard errors
|
|
33
|
+
FLOOR_TRIALS = 400 # exp 310's floor
|
|
34
|
+
CAP_TRIALS = 40000 # exp 310's cap
|
|
35
|
+
GROWTH = 2.0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def mde(se, value):
|
|
39
|
+
"""The smallest RELATIVE effect a measurement of this precision could have detected."""
|
|
40
|
+
if se is None or value in (None, 0):
|
|
41
|
+
return None
|
|
42
|
+
return SIGMAS * abs(se / value)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def trials_for(current_mde, tolerance, spent):
|
|
46
|
+
"""How many trials would bring the MDE down to the tolerance.
|
|
47
|
+
|
|
48
|
+
se falls as 1/sqrt(N), so being k times too coarse costs k^2 the trials. Returns None
|
|
49
|
+
when the measurement already supports the claim.
|
|
50
|
+
"""
|
|
51
|
+
if current_mde is None or spent <= 0 or tolerance <= 0:
|
|
52
|
+
return None
|
|
53
|
+
if current_mde <= tolerance:
|
|
54
|
+
return None
|
|
55
|
+
return int(spent * (current_mde / tolerance) ** 2) + 1
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _annotate(report, tolerance):
|
|
59
|
+
"""Attach an MDE and a support verdict to every constant the report carries."""
|
|
60
|
+
worst = None
|
|
61
|
+
for name, row in report["per_observable"].items():
|
|
62
|
+
m = mde(row.get("constant_se"), row.get("constant"))
|
|
63
|
+
row["mde"] = m
|
|
64
|
+
row["tolerance"] = tolerance
|
|
65
|
+
row["supported"] = m is not None and m <= tolerance
|
|
66
|
+
if not row["supported"]:
|
|
67
|
+
row["why_unsupported"] = (
|
|
68
|
+
"no constant was determined" if m is None else
|
|
69
|
+
"the constant is %.6g but this measurement could not have detected a "
|
|
70
|
+
"%.3g relative effect (MDE %.3g), so it does not support a claim at that "
|
|
71
|
+
"tolerance" % (row["constant"], tolerance, m))
|
|
72
|
+
if m is not None and (worst is None or m > worst):
|
|
73
|
+
worst = m
|
|
74
|
+
return worst
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def to_tolerance(adapter, tolerance, seed0=17, floor=FLOOR_TRIALS, cap=CAP_TRIALS,
|
|
78
|
+
on_step=None):
|
|
79
|
+
"""Run `characterize`, raising the trial count until every constant it determined is
|
|
80
|
+
supported at `tolerance`, or the budget is spent.
|
|
81
|
+
|
|
82
|
+
Returns characterize's report with three things added per observable -- `mde`,
|
|
83
|
+
`tolerance`, `supported` -- plus a top-level `budget` block recording what was spent,
|
|
84
|
+
whether every determined constant is supported, and what the shortfall would cost.
|
|
85
|
+
|
|
86
|
+
An observable that comes back UNDETERMINED is left alone: no amount of precision turns a
|
|
87
|
+
ladder that never flattens into a constant, and pretending otherwise is what exp 310's
|
|
88
|
+
leg 0 found exp 309 doing on five of twelve.
|
|
89
|
+
"""
|
|
90
|
+
if tolerance <= 0:
|
|
91
|
+
raise ValueError("tolerance must be positive: it is the size of effect you care "
|
|
92
|
+
"about, and this module will not choose it for you")
|
|
93
|
+
trials = floor
|
|
94
|
+
history = []
|
|
95
|
+
while True:
|
|
96
|
+
report = CH.characterize(adapter, trials=trials, seed0=seed0)
|
|
97
|
+
worst = _annotate(report, tolerance)
|
|
98
|
+
determined = [n for n, r in report["per_observable"].items()
|
|
99
|
+
if r["constant"] is not CH.UNDETERMINED]
|
|
100
|
+
unsupported = [n for n in determined if not report["per_observable"][n]["supported"]]
|
|
101
|
+
history.append({"trials": trials, "worst_mde": worst,
|
|
102
|
+
"unsupported": sorted(unsupported)})
|
|
103
|
+
if on_step:
|
|
104
|
+
on_step(history[-1])
|
|
105
|
+
want = trials_for(worst, tolerance, trials) if unsupported else None
|
|
106
|
+
if not unsupported or want is None or trials >= cap:
|
|
107
|
+
report["budget"] = {
|
|
108
|
+
"tolerance": tolerance, "trials": trials, "floor": floor, "cap": cap,
|
|
109
|
+
"all_supported": not unsupported, "unsupported": sorted(unsupported),
|
|
110
|
+
"worst_mde": worst, "history": history,
|
|
111
|
+
"would_need": None if want is None else min(want, 10 ** 12),
|
|
112
|
+
"shortfall": None if want is None else round(want / float(cap), 2)}
|
|
113
|
+
return report
|
|
114
|
+
trials = min(cap, max(int(trials * GROWTH), want))
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Point it at a program; get back what can and cannot be determined about it.
|
|
2
|
+
|
|
3
|
+
One interface, no capability-specific logic.
|
|
4
|
+
|
|
5
|
+
An adapter exposes only:
|
|
6
|
+
truths() -> the controllable input values, known by construction
|
|
7
|
+
observables -> {name: f(truth, seed) -> float}
|
|
8
|
+
knobs -> {name: [values]} (optional)
|
|
9
|
+
perturbed(knob, v) -> {name: f(truth, seed)} (optional, required if knobs given)
|
|
10
|
+
|
|
11
|
+
Everything below is assembled from instruments earlier rounds paid for:
|
|
12
|
+
* fit c = truth / E[raw] (exp 297)
|
|
13
|
+
* plateau across a ladder (exp 298)
|
|
14
|
+
* heterogeneity = spread / own error (exp 302)
|
|
15
|
+
* report what is UNDETERMINED, never guess (exps 298, 300, 303)
|
|
16
|
+
|
|
17
|
+
The rule that survived every round: compare against the NOISE, never against the SIZE.
|
|
18
|
+
"""
|
|
19
|
+
import math
|
|
20
|
+
|
|
21
|
+
UNDETERMINED = None
|
|
22
|
+
MIN_RATIO = 3.0
|
|
23
|
+
MIN_MARGIN = 3.0
|
|
24
|
+
PLATEAU_K = 2.0
|
|
25
|
+
PLATEAU_RUN = 3
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------- primitives
|
|
29
|
+
|
|
30
|
+
def fit(sample, truth, trials, seed0=0):
|
|
31
|
+
"""c such that c * E[raw] == truth. Returns (c, standard_error)."""
|
|
32
|
+
raws = [sample(truth, seed0 + i) for i in range(trials)]
|
|
33
|
+
mean = sum(raws) / len(raws)
|
|
34
|
+
if mean == 0.0:
|
|
35
|
+
return None, None
|
|
36
|
+
c = truth / mean
|
|
37
|
+
var = sum((r - mean) ** 2 for r in raws) / (len(raws) - 1) if len(raws) > 1 else 0.0
|
|
38
|
+
cv = math.sqrt(var) / abs(mean)
|
|
39
|
+
return c, abs(c) * cv / math.sqrt(len(raws))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _sd(xs):
|
|
43
|
+
if len(xs) < 2:
|
|
44
|
+
return None
|
|
45
|
+
m = sum(xs) / len(xs)
|
|
46
|
+
return math.sqrt(sum((x - m) ** 2 for x in xs) / (len(xs) - 1))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def heterogeneity(values, errors):
|
|
50
|
+
"""Spread across cases in units of the measurement error. Never divided by magnitude."""
|
|
51
|
+
sd = _sd(values)
|
|
52
|
+
if sd is None:
|
|
53
|
+
return None
|
|
54
|
+
mean_e = sum(errors) / len(errors)
|
|
55
|
+
return None if mean_e <= 0 else sd / mean_e
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def plateau(ladder, k=PLATEAU_K, need=PLATEAU_RUN):
|
|
59
|
+
"""Earliest rung from which every later rung agrees within k combined errors."""
|
|
60
|
+
rungs = sorted(ladder, key=lambda r: r["truth"])
|
|
61
|
+
if len(rungs) < need:
|
|
62
|
+
return {"value": UNDETERMINED, "se": None, "from_truth": None,
|
|
63
|
+
"why": "ladder shorter than the required run of %d" % need}
|
|
64
|
+
for i in range(len(rungs) - need + 1):
|
|
65
|
+
tail = rungs[i:]
|
|
66
|
+
if all(abs(tail[0]["c"] - r["c"]) <=
|
|
67
|
+
k * math.sqrt(tail[0]["se"] ** 2 + r["se"] ** 2) for r in tail[1:]):
|
|
68
|
+
w = sum(1.0 / r["se"] ** 2 for r in tail)
|
|
69
|
+
return {"value": sum(r["c"] / r["se"] ** 2 for r in tail) / w,
|
|
70
|
+
"se": (1.0 / w) ** 0.5, "from_truth": tail[0]["truth"],
|
|
71
|
+
"why": "%d rungs from truth=%g agree within %.1f sigma"
|
|
72
|
+
% (len(tail), tail[0]["truth"], k)}
|
|
73
|
+
return {"value": UNDETERMINED, "se": None, "from_truth": None,
|
|
74
|
+
"why": "no run of %d rungs agrees; the constant is still moving at the top of "
|
|
75
|
+
"the ladder" % need}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---------------------------------------------------------------- the report
|
|
79
|
+
|
|
80
|
+
def ladder_for(sample, truths, trials, seed0=0):
|
|
81
|
+
out = []
|
|
82
|
+
for t in truths:
|
|
83
|
+
c, se = fit(sample, t, trials, seed0)
|
|
84
|
+
if c is not None and se:
|
|
85
|
+
out.append({"truth": t, "c": c, "se": se})
|
|
86
|
+
return out
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def reproducible(obs, truths, seed0=17):
|
|
90
|
+
"""Every observable must answer the same for the same (truth, seed), or refuse.
|
|
91
|
+
|
|
92
|
+
THE PRECONDITION NOTHING ELSE HERE CAN SUBSTITUTE FOR. `fit` averages an observable
|
|
93
|
+
over `trials` DIFFERENT seeds, so an observable that ignores its seed and reads the
|
|
94
|
+
clock, the environment or an unseeded RNG produces a mean over noise -- and a mean
|
|
95
|
+
over noise still has a standard error, still forms a ladder, and can still plateau.
|
|
96
|
+
Every guard downstream compares against that error, so a broken adapter does not
|
|
97
|
+
produce a wrong-looking answer: it produces a confident one.
|
|
98
|
+
|
|
99
|
+
Checked at the cheapest place it can fail -- the first and last rung, twice each --
|
|
100
|
+
because an observable that is stable at one size and not another is stable at
|
|
101
|
+
neither, and four calls is a price nobody notices.
|
|
102
|
+
|
|
103
|
+
A raise, not a `look`. The other refusals in this module describe what the program
|
|
104
|
+
would not reveal; this one says the instrument was wired up wrong, and continuing
|
|
105
|
+
would report a number about nothing.
|
|
106
|
+
"""
|
|
107
|
+
for name, f in sorted(obs.items()):
|
|
108
|
+
for truth in ({truths[0], truths[-1]} if truths else ()):
|
|
109
|
+
first, second = f(truth, seed0), f(truth, seed0)
|
|
110
|
+
if first != second:
|
|
111
|
+
raise ValueError(
|
|
112
|
+
"observable %r is not reproducible: at truth=%r and seed=%r it "
|
|
113
|
+
"returned %r and then %r. Every constant below is fitted from a mean "
|
|
114
|
+
"over seeds, so an observable that ignores its seed yields a mean "
|
|
115
|
+
"over noise -- which still plateaus, and would be reported as an "
|
|
116
|
+
"answer." % (name, truth, seed0, first, second))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def characterize(adapter, trials=2500, seed0=17):
|
|
120
|
+
"""The whole report. Nothing here knows what program it is looking at."""
|
|
121
|
+
truths = list(adapter.truths())
|
|
122
|
+
obs = adapter.observables
|
|
123
|
+
if len(obs) < 2:
|
|
124
|
+
raise ValueError("an adapter must expose at least two observables, or there is no "
|
|
125
|
+
"choice to make and the tool cannot be shown to make one")
|
|
126
|
+
reproducible(obs, truths, seed0)
|
|
127
|
+
per = {}
|
|
128
|
+
for name, f in obs.items():
|
|
129
|
+
lad = ladder_for(f, truths, trials, seed0)
|
|
130
|
+
p = plateau(lad)
|
|
131
|
+
per[name] = {"ladder": lad, "plateau": p,
|
|
132
|
+
"constant": p["value"], "constant_se": p["se"],
|
|
133
|
+
"regime_from": p["from_truth"],
|
|
134
|
+
"top_rung": lad[-1]["c"] if lad else None,
|
|
135
|
+
"top_rung_se": lad[-1]["se"] if lad else None}
|
|
136
|
+
|
|
137
|
+
# which observable carries information about THIS instance? Only answerable with more
|
|
138
|
+
# than one instance; with one, say so rather than guessing (exp 303's family C).
|
|
139
|
+
inst = getattr(adapter, "instances", None)
|
|
140
|
+
informative = {"choice": UNDETERMINED,
|
|
141
|
+
"why": "only one instance was supplied, so no constant can be shown to "
|
|
142
|
+
"vary across instances; supply >=2 to decide"}
|
|
143
|
+
if inst:
|
|
144
|
+
vals, errs = {}, {}
|
|
145
|
+
for name in obs:
|
|
146
|
+
vs, es = [], []
|
|
147
|
+
for sub in inst():
|
|
148
|
+
c, se = fit(sub.observables[name], truths[-1], trials, seed0)
|
|
149
|
+
if c is not None and se:
|
|
150
|
+
vs.append(c); es.append(se)
|
|
151
|
+
vals[name], errs[name] = vs, es
|
|
152
|
+
ratios = {n: heterogeneity(vals[n], errs[n]) for n in obs}
|
|
153
|
+
informative = _pick(ratios)
|
|
154
|
+
informative["ratios"] = ratios
|
|
155
|
+
|
|
156
|
+
# does anything respond to perturbing the program itself?
|
|
157
|
+
knobs = getattr(adapter, "knobs", None)
|
|
158
|
+
response = {}
|
|
159
|
+
if knobs and hasattr(adapter, "perturbed"):
|
|
160
|
+
for name in obs:
|
|
161
|
+
response[name] = {}
|
|
162
|
+
for knob, values in knobs.items():
|
|
163
|
+
pts = []
|
|
164
|
+
for v in values:
|
|
165
|
+
c, _ = fit(adapter.perturbed(knob, v)[name], truths[-1], max(400, trials // 4), seed0)
|
|
166
|
+
if c and c > 0:
|
|
167
|
+
pts.append((math.log(v), math.log(c)))
|
|
168
|
+
response[name][knob] = _slope(pts)
|
|
169
|
+
|
|
170
|
+
undetermined = [n for n in obs if per[n]["constant"] is UNDETERMINED]
|
|
171
|
+
return {"observables": sorted(obs), "per_observable": per,
|
|
172
|
+
"informative": informative, "perturbation_response": response,
|
|
173
|
+
"undetermined": undetermined,
|
|
174
|
+
"notes": _notes(per, informative, undetermined)}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _slope(pts):
|
|
178
|
+
if len(pts) < 2:
|
|
179
|
+
return None
|
|
180
|
+
mx = sum(p[0] for p in pts) / len(pts)
|
|
181
|
+
my = sum(p[1] for p in pts) / len(pts)
|
|
182
|
+
den = sum((p[0] - mx) ** 2 for p in pts)
|
|
183
|
+
return None if den <= 0 else sum((p[0]-mx)*(p[1]-my) for p in pts) / den
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _pick(ratios):
|
|
187
|
+
usable = {k: v for k, v in ratios.items() if v is not None}
|
|
188
|
+
live = {k: v for k, v in usable.items() if v >= MIN_RATIO}
|
|
189
|
+
if not live:
|
|
190
|
+
return {"choice": UNDETERMINED,
|
|
191
|
+
"why": "no constant varies beyond %.0fx its own error across instances"
|
|
192
|
+
% MIN_RATIO}
|
|
193
|
+
rank = sorted(live.items(), key=lambda kv: -kv[1])
|
|
194
|
+
if len(rank) > 1 and rank[0][1] < MIN_MARGIN * rank[1][1]:
|
|
195
|
+
return {"choice": UNDETERMINED,
|
|
196
|
+
"why": "%s (%.1f) does not beat %s (%.1f) by %.0fx"
|
|
197
|
+
% (rank[0][0], rank[0][1], rank[1][0], rank[1][1], MIN_MARGIN)}
|
|
198
|
+
return {"choice": rank[0][0],
|
|
199
|
+
"why": "%s varies %.1fx its own error" % (rank[0][0], rank[0][1])}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _notes(per, informative, undetermined):
|
|
203
|
+
out = []
|
|
204
|
+
for n in sorted(undetermined):
|
|
205
|
+
out.append("`%s`: no plateau -- %s" % (n, per[n]["plateau"]["why"]))
|
|
206
|
+
if informative["choice"] is UNDETERMINED:
|
|
207
|
+
out.append("which observable characterises the program: UNDETERMINED -- %s"
|
|
208
|
+
% informative["why"])
|
|
209
|
+
return out
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: undetermined
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Point it at a program, get back what can and cannot be determined: a constant with an error bar, and an explicit undetermined list with reasons.
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: empirical,constant,measurement,plateau,algorithm-analysis,benchmark,estimation,reproducibility,uncertainty,minimum-detectable-effect,zero-dependency
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: Intended Audience :: Science/Research
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# undetermined
|
|
18
|
+
|
|
19
|
+
**Point it at a program; get back what can and cannot be determined about it.**
|
|
20
|
+
|
|
21
|
+
Plenty of libraries fit a curve to measurements and hand you back a number. This one
|
|
22
|
+
hands back a number *with the error bar it was decided by*, plus an explicit
|
|
23
|
+
`undetermined` list with a reason on each entry — and it will put an observable on that
|
|
24
|
+
list rather than fit a plateau to a drift.
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
heads: 1.9978 +/- 0.0032 4 rungs from truth=8 agree within 2.0 sigma
|
|
28
|
+
flat: UNDETERMINED no run of 3 rungs agrees; the constant is still moving
|
|
29
|
+
at the top of the ladder
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The second line is the point. A tool that always produced the first line would be
|
|
33
|
+
useless and would still pass every test that checks it produces one.
|
|
34
|
+
|
|
35
|
+
Ships as `undetermined` on **PyPI** and on **npm**, from one tree, at one version. No
|
|
36
|
+
dependencies in either half.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install undetermined
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
npm install undetermined
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Use
|
|
51
|
+
|
|
52
|
+
You supply an **adapter**: an object that knows how to run the thing you are measuring
|
|
53
|
+
at a controllable input size, and nothing else. This library never knows what program it
|
|
54
|
+
is looking at.
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
import random
|
|
58
|
+
from undetermined import characterize
|
|
59
|
+
|
|
60
|
+
class Coin:
|
|
61
|
+
def truths(self):
|
|
62
|
+
# The controllable input values, known by construction. A ladder, not a point:
|
|
63
|
+
# a constant that is only measured at one size cannot be shown to have settled.
|
|
64
|
+
return [8, 32, 128, 512]
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def observables(self):
|
|
68
|
+
def heads(truth, seed):
|
|
69
|
+
r = random.Random(seed)
|
|
70
|
+
return sum(1 for _ in range(truth) if r.random() < 0.5)
|
|
71
|
+
|
|
72
|
+
def flat(truth, seed):
|
|
73
|
+
r = random.Random(seed)
|
|
74
|
+
return sum(1 for _ in range(12) if r.random() < 0.5) + 0.5
|
|
75
|
+
|
|
76
|
+
return {"heads": heads, "flat": flat}
|
|
77
|
+
|
|
78
|
+
report = characterize(Coin(), trials=2500)
|
|
79
|
+
|
|
80
|
+
report["per_observable"]["heads"]["constant"] # 1.9978...
|
|
81
|
+
report["per_observable"]["heads"]["constant_se"] # 0.0032...
|
|
82
|
+
report["undetermined"] # ['flat']
|
|
83
|
+
report["notes"] # why each one is on the list
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
```js
|
|
87
|
+
import { characterize } from "undetermined";
|
|
88
|
+
|
|
89
|
+
const report = characterize(adapter, { trials: 2500 });
|
|
90
|
+
report.per_observable.heads.constant; // 1.9978...
|
|
91
|
+
report.undetermined; // ['flat']
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### The adapter protocol
|
|
95
|
+
|
|
96
|
+
| member | required | meaning |
|
|
97
|
+
| --- | --- | --- |
|
|
98
|
+
| `truths()` | yes | the controllable input values, known by construction |
|
|
99
|
+
| `observables` | yes | `{name: (truth, seed) -> number}`, at least two |
|
|
100
|
+
| `instances()` | no | sibling instances of the same family |
|
|
101
|
+
| `knobs` | no | `{name: [values]}` — parameters of the program itself |
|
|
102
|
+
| `perturbed(knob, v)` | with `knobs` | the observables with that knob set to that value |
|
|
103
|
+
|
|
104
|
+
At least two observables, always. With one there is no choice to make, so the library
|
|
105
|
+
cannot be shown to make one — it raises rather than reporting a confident single answer.
|
|
106
|
+
|
|
107
|
+
### Deriving the trial count instead of guessing it
|
|
108
|
+
|
|
109
|
+
`characterize` takes a `trials` count. If you would rather state the precision you need
|
|
110
|
+
and have the budget derived:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from undetermined import to_tolerance
|
|
114
|
+
report = to_tolerance(MyAdapter(), tolerance=0.01) # 1% on the constant
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
It doubles the trial count until every determined constant is inside the tolerance or the
|
|
118
|
+
cap is reached, and reports which ones never got there. `tolerance=0` raises: a tolerance
|
|
119
|
+
is a decision about your problem, and this library will not choose it for you.
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## What it refuses, and why
|
|
124
|
+
|
|
125
|
+
Three refusals, and each is the answer to a way of being confidently wrong.
|
|
126
|
+
|
|
127
|
+
**An observable that ignores its seed** raises immediately. `fit` averages an observable
|
|
128
|
+
over `trials` *different* seeds, so an observable that reads the clock or an unseeded RNG
|
|
129
|
+
produces a mean over noise. A mean over noise still has a standard error, still forms a
|
|
130
|
+
ladder, and can still plateau — every guard downstream compares against that error, so a
|
|
131
|
+
broken adapter does not produce a wrong-looking answer, it produces a **confident** one.
|
|
132
|
+
Each observable is called twice with the same `(truth, seed)` at the first and last rung;
|
|
133
|
+
a disagreement is a wiring error, not a finding, so it throws.
|
|
134
|
+
|
|
135
|
+
**A constant that never settles** is reported as `UNDETERMINED` with the reason. A run of
|
|
136
|
+
three consecutive rungs must agree within two combined standard errors before any
|
|
137
|
+
plateau is reported; the value is then the inverse-variance-weighted mean over that run,
|
|
138
|
+
and the report says which rung it started from.
|
|
139
|
+
|
|
140
|
+
**A choice between observables that is not earned** is reported as `UNDETERMINED`. With
|
|
141
|
+
`instances()`, an observable is only called informative if its constant varies at least
|
|
142
|
+
3x its own measurement error across instances, and it is only *chosen* over the runner-up
|
|
143
|
+
if it beats it by 3x. Two observables that both vary a lot are not a choice.
|
|
144
|
+
|
|
145
|
+
### The rule underneath all of it
|
|
146
|
+
|
|
147
|
+
> Compare against the **noise**, never against the **size**.
|
|
148
|
+
|
|
149
|
+
A spread only means something in units of the error on the thing that spread. Dividing by
|
|
150
|
+
the magnitude instead is how a large number gets mistaken for a real one, and it is the
|
|
151
|
+
single mistake this library is shaped around not making.
|
|
152
|
+
|
|
153
|
+
### The thresholds
|
|
154
|
+
|
|
155
|
+
They are the contract, and `python/tests/test_parity.py` asserts both halves hold the
|
|
156
|
+
same ones and produce the same output on the same numbers — including the same
|
|
157
|
+
explanatory strings.
|
|
158
|
+
|
|
159
|
+
| constant | value | what it gates |
|
|
160
|
+
| --- | --- | --- |
|
|
161
|
+
| `PLATEAU_K` | 2.0 | sigma within which rungs must agree |
|
|
162
|
+
| `PLATEAU_RUN` | 3 | consecutive agreeing rungs required |
|
|
163
|
+
| `MIN_RATIO` | 3.0 | error-multiples an observable must vary by to be informative |
|
|
164
|
+
| `MIN_MARGIN` | 3.0 | factor by which the winner must beat the runner-up |
|
|
165
|
+
| `SIGMAS` | 3.0 | standard errors in a minimum detectable effect |
|
|
166
|
+
| `FLOOR_TRIALS` / `CAP_TRIALS` | 400 / 40000 | the budget search bounds |
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## This is a library, not a CLI
|
|
171
|
+
|
|
172
|
+
An adapter is a code object with closures in it. There is nothing to pass on a command
|
|
173
|
+
line that would not amount to naming a Python or JavaScript symbol and importing it, so
|
|
174
|
+
the package ships an import and no console script.
|
|
175
|
+
|
|
176
|
+
## Where it sits
|
|
177
|
+
|
|
178
|
+
**Layer 0**: no dependencies inside or outside this network of packages, by design.
|
|
179
|
+
|
|
180
|
+
The `nondet` edge was considered and **rejected**. `nondet` addresses a function as
|
|
181
|
+
`FILE::NAME` so it can re-run it in fresh processes; this library's observables are
|
|
182
|
+
closures inside an adapter object and have no such address, so `nondet` cannot probe
|
|
183
|
+
them. Wiring it in would have meant either a fake file path or a check that never ran —
|
|
184
|
+
a dependency that looks like a guarantee and is not. The reproducibility precondition is
|
|
185
|
+
implemented natively in both halves instead, and it is checked in the same call that
|
|
186
|
+
would have needed the guarantee. `nondet` remains the right tool for the *functions your
|
|
187
|
+
adapter calls*, which do have addresses; running it on those is a good idea and is not
|
|
188
|
+
something this package can do on your behalf.
|
|
189
|
+
|
|
190
|
+
## Development
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
python3 -m unittest discover -s python/tests -v # PYTHONPATH=python
|
|
194
|
+
node --test js/test/*.test.js
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
The parity suite skips when `node` is not on PATH, so a Python-only contributor can still
|
|
198
|
+
run everything else. CI asserts it was **not** skipped — a skipped test and a passing one
|
|
199
|
+
look identical in a tally.
|
|
200
|
+
|
|
201
|
+
## License
|
|
202
|
+
|
|
203
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
python/undetermined/__init__.py
|
|
5
|
+
python/undetermined/budget.py
|
|
6
|
+
python/undetermined/core.py
|
|
7
|
+
python/undetermined.egg-info/PKG-INFO
|
|
8
|
+
python/undetermined.egg-info/SOURCES.txt
|
|
9
|
+
python/undetermined.egg-info/dependency_links.txt
|
|
10
|
+
python/undetermined.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
undetermined
|