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.
@@ -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
+ undetermined
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+