flux-sim-engine 0.2.0__py3-none-any.whl
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.
- flux_engine/__init__.py +22 -0
- flux_engine/_links.py +55 -0
- flux_engine/_vendor/__init__.py +5 -0
- flux_engine/_vendor/flux_spec/NOTICE +19 -0
- flux_engine/_vendor/flux_spec/VENDOR-NOTES.md +104 -0
- flux_engine/_vendor/flux_spec/__init__.py +9 -0
- flux_engine/_vendor/flux_spec/bundle.py +410 -0
- flux_engine/_vendor/flux_spec/enforce.py +316 -0
- flux_engine/_vendor/flux_spec/schema/flux-enforcement-0.5.0.json +121 -0
- flux_engine/_vendor/flux_spec/schema/flux-manifest-0.5.0.json +153 -0
- flux_engine/_vendor/flux_spec/schema/flux-schema-0.2.0.json +465 -0
- flux_engine/_vendor/flux_spec/schema/flux-schema-0.3.0.json +876 -0
- flux_engine/_vendor/flux_spec/schema/flux-schema-0.4.0.json +1601 -0
- flux_engine/_vendor/flux_spec/schema/flux-schema-0.4.1.json +1668 -0
- flux_engine/_vendor/flux_spec/schema/flux-schema-0.5.0.json +1669 -0
- flux_engine/_vendor/flux_spec/schema/flux-schema-latest.json +1669 -0
- flux_engine/_vendor/flux_spec/schema/flux-ui-hints-0.4.0.json +795 -0
- flux_engine/_vendor/flux_spec/schema/flux-ui-hints-0.4.1.json +852 -0
- flux_engine/_vendor/flux_spec/schema/flux-ui-hints-0.5.0.json +852 -0
- flux_engine/_vendor/flux_spec/validate.py +537 -0
- flux_engine/_vendor/flux_spec/vectors/enforcement-vectors.json +935 -0
- flux_engine/_vendor/flux_spec/vendor/fluid/fluid-schema-0.7.3.json +5234 -0
- flux_engine/_vendor/flux_spec/vendor/fluid/fluid-schema-0.7.4.json +5437 -0
- flux_engine/_vendor/flux_spec/vendor/fluid/fluid-schema-0.7.5.json +5498 -0
- flux_engine/_vocabulary.py +141 -0
- flux_engine/accuracy/__init__.py +5 -0
- flux_engine/accuracy/_scoring/__init__.py +24 -0
- flux_engine/accuracy/accuracy_record.py +96 -0
- flux_engine/accuracy/policy.py +158 -0
- flux_engine/accuracy/records.py +264 -0
- flux_engine/accuracy/store.py +169 -0
- flux_engine/api/__init__.py +163 -0
- flux_engine/api/accuracy.py +209 -0
- flux_engine/api/agentic.py +134 -0
- flux_engine/api/calibration.py +125 -0
- flux_engine/api/claims.py +93 -0
- flux_engine/api/digests.py +73 -0
- flux_engine/api/emit.py +78 -0
- flux_engine/api/expressions.py +72 -0
- flux_engine/api/fidelity.py +110 -0
- flux_engine/api/lineage.py +78 -0
- flux_engine/api/stages.py +242 -0
- flux_engine/api/verdict.py +108 -0
- flux_engine/calibrate/__init__.py +5 -0
- flux_engine/calibrate/declared.py +253 -0
- flux_engine/calibrate/provenance.py +113 -0
- flux_engine/cli/__init__.py +2146 -0
- flux_engine/cli/__main__.py +17 -0
- flux_engine/cli/_documents.py +226 -0
- flux_engine/cli/_hints.py +118 -0
- flux_engine/cli/_init.py +152 -0
- flux_engine/cli/_progress.py +124 -0
- flux_engine/concordance/__init__.py +127 -0
- flux_engine/concordance/boundary/__init__.py +78 -0
- flux_engine/concordance/boundary/merkle.py +287 -0
- flux_engine/concordance/boundary/slice.py +429 -0
- flux_engine/concordance/boundary/statistic.py +561 -0
- flux_engine/concordance/boundary/store.py +334 -0
- flux_engine/concordance/boundary/vault.py +357 -0
- flux_engine/concordance/compiler.py +542 -0
- flux_engine/concordance/errors.py +189 -0
- flux_engine/concordance/gate.py +525 -0
- flux_engine/concordance/predicate.py +1217 -0
- flux_engine/concordance/record.py +554 -0
- flux_engine/concordance/verifier.py +583 -0
- flux_engine/core/__init__.py +5 -0
- flux_engine/core/artifact.py +99 -0
- flux_engine/core/canonical.py +369 -0
- flux_engine/core/clock.py +437 -0
- flux_engine/core/digest.py +288 -0
- flux_engine/core/errors.py +277 -0
- flux_engine/core/fixedpoint.py +929 -0
- flux_engine/core/lrm.py +158 -0
- flux_engine/core/rng.py +736 -0
- flux_engine/core/seedtree.py +891 -0
- flux_engine/core/trace.py +423 -0
- flux_engine/describe.py +207 -0
- flux_engine/engine.py +257 -0
- flux_engine/evidence/__init__.py +5 -0
- flux_engine/evidence/pack.py +411 -0
- flux_engine/evidence/report.py +243 -0
- flux_engine/expr/__init__.py +133 -0
- flux_engine/expr/ast.py +312 -0
- flux_engine/expr/attrs.py +386 -0
- flux_engine/expr/check.py +278 -0
- flux_engine/expr/compile.py +322 -0
- flux_engine/expr/errors.py +256 -0
- flux_engine/expr/lexer.py +297 -0
- flux_engine/expr/parser.py +313 -0
- flux_engine/expr/types.py +177 -0
- flux_engine/generator/__init__.py +97 -0
- flux_engine/generator/families.py +325 -0
- flux_engine/generator/paths.py +227 -0
- flux_engine/generator/rows.py +918 -0
- flux_engine/generator/statistics.py +524 -0
- flux_engine/governance/__init__.py +87 -0
- flux_engine/governance/audit.py +307 -0
- flux_engine/governance/gate.py +334 -0
- flux_engine/governance/skills.py +220 -0
- flux_engine/governance/sovereignty.py +251 -0
- flux_engine/lineage/__init__.py +5 -0
- flux_engine/lineage/emitter.py +398 -0
- flux_engine/py.typed +0 -0
- flux_engine/runtime/__init__.py +153 -0
- flux_engine/runtime/campaign.py +407 -0
- flux_engine/runtime/cast.py +283 -0
- flux_engine/runtime/detector.py +222 -0
- flux_engine/runtime/errors.py +58 -0
- flux_engine/runtime/experiment.py +311 -0
- flux_engine/runtime/journey.py +383 -0
- flux_engine/runtime/jurisdiction.py +72 -0
- flux_engine/runtime/payloads.py +124 -0
- flux_engine/runtime/signals.py +334 -0
- flux_engine/runtime/temperature.py +156 -0
- flux_engine/seam/__init__.py +5 -0
- flux_engine/seam/attest.py +290 -0
- flux_engine/seam/conform.py +448 -0
- flux_engine/seam/contract.py +605 -0
- flux_engine/seam/emit.py +488 -0
- flux_engine/seam/mapping.py +299 -0
- flux_engine/seam/resolve.py +1087 -0
- flux_engine/seam/sinks.py +157 -0
- flux_engine/spec/__init__.py +134 -0
- flux_engine/spec/bundle.py +25 -0
- flux_engine/spec/enforce.py +31 -0
- flux_engine/spec/validate.py +28 -0
- flux_engine/stages/__init__.py +30 -0
- flux_engine/stages/apply.py +447 -0
- flux_engine/stages/errors.py +315 -0
- flux_engine/stages/layout.py +292 -0
- flux_engine/stages/pipeline.py +600 -0
- flux_engine/stages/plan.py +241 -0
- flux_engine/stages/replay.py +83 -0
- flux_engine/stages/run.py +208 -0
- flux_engine/stages/ship.py +934 -0
- flux_engine/stages/validate.py +93 -0
- flux_engine/stages/verify.py +1129 -0
- flux_engine/starter/__init__.py +161 -0
- flux_engine/starter/bundle/detector.flux.yml +15 -0
- flux_engine/starter/bundle/journey.flux.yml +23 -0
- flux_engine/starter/bundle/module.flux.yml +16 -0
- flux_engine/starter/bundle/payment-recovery.fluid.yml +30 -0
- flux_engine/starter/bundle/persona.flux.yml +26 -0
- flux_engine/starter/bundle/signal.flux.yml +13 -0
- flux_engine/starter/bundle/simulation.flux.yml +29 -0
- flux_engine/starter/bundle/taxonomy.flux.yml +17 -0
- flux_engine/starter/bundle/treatment.flux.yml +13 -0
- flux_engine/starter/bundle/world.flux.yml +20 -0
- flux_engine/verticals/__init__.py +85 -0
- flux_engine/verticals/telco.py +89 -0
- flux_engine/world/__init__.py +89 -0
- flux_engine/world/derived.py +195 -0
- flux_engine/world/errors.py +41 -0
- flux_engine/world/families.py +260 -0
- flux_engine/world/population.py +602 -0
- flux_engine/world/tracing.py +66 -0
- flux_engine/world/values.py +121 -0
- flux_sim_engine-0.2.0.dist-info/METADATA +108 -0
- flux_sim_engine-0.2.0.dist-info/RECORD +165 -0
- flux_sim_engine-0.2.0.dist-info/WHEEL +5 -0
- flux_sim_engine-0.2.0.dist-info/entry_points.txt +2 -0
- flux_sim_engine-0.2.0.dist-info/licenses/LICENSE +202 -0
- flux_sim_engine-0.2.0.dist-info/licenses/NOTICE +10 -0
- flux_sim_engine-0.2.0.dist-info/licenses/flux_engine/_vendor/flux_spec/NOTICE +19 -0
- flux_sim_engine-0.2.0.dist-info/top_level.txt +1 -0
flux_engine/core/rng.py
ADDED
|
@@ -0,0 +1,736 @@
|
|
|
1
|
+
# Copyright 2026 Agentics Transformation Limited
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
"""Kernel draws -- KERNEL-SPEC §4 ("Draws: fixed draw count, no rejection, no float branch").
|
|
6
|
+
|
|
7
|
+
Every sampler here obeys four normative rules from §4:
|
|
8
|
+
|
|
9
|
+
1. **The bit source is raw words only.** numpy is used *only* as the bit source
|
|
10
|
+
(``bit_generator.random_raw()``); no numpy distribution function is called on any
|
|
11
|
+
digest path, and ``PCG64`` is named explicitly (never ``default_rng``). The single
|
|
12
|
+
numpy touch point is :class:`DrawStream`, which converts each 64-bit word to a Python
|
|
13
|
+
``int`` at the boundary -- no numpy scalar, and in particular no numpy float, is ever
|
|
14
|
+
handed to a caller.
|
|
15
|
+
2. **Fixed draw count.** Each sampler consumes a number of raw words that is a function of
|
|
16
|
+
its *shape* arguments only, never of the values drawn -- see :data:`RAW_WORDS_PER_DRAW`
|
|
17
|
+
and :func:`permutation_word_cost`. There are no rejection loops: ``bounded_uint``
|
|
18
|
+
consumes its 64-word budget in full whatever it draws.
|
|
19
|
+
3. **No float anywhere.** Probabilities and continuous results are ``Decimal``; thresholds
|
|
20
|
+
and comparisons are exact integer arithmetic over ``Fraction``; the inverse-CDF work is
|
|
21
|
+
done in the ``decimal`` module, whose General Decimal Arithmetic operations are
|
|
22
|
+
correctly rounded and therefore give the same digits on every libm and architecture.
|
|
23
|
+
4. **Mappings iterate by sorted key.** ``categorical`` orders its levels by NFC key
|
|
24
|
+
(:func:`nfc_sorted_items`), never by insertion order.
|
|
25
|
+
|
|
26
|
+
Never used here, per §4's closing sentence: ``random``, a numpy ``Generator`` distribution
|
|
27
|
+
method, ``math.exp``/``log``/``erf`` on a digest path, or a rejection loop.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from decimal import (
|
|
33
|
+
ROUND_CEILING,
|
|
34
|
+
ROUND_FLOOR,
|
|
35
|
+
ROUND_HALF_EVEN,
|
|
36
|
+
Context,
|
|
37
|
+
Decimal,
|
|
38
|
+
DivisionByZero,
|
|
39
|
+
InvalidOperation,
|
|
40
|
+
Overflow,
|
|
41
|
+
getcontext,
|
|
42
|
+
localcontext,
|
|
43
|
+
)
|
|
44
|
+
from fractions import Fraction
|
|
45
|
+
from functools import lru_cache
|
|
46
|
+
from typing import Mapping, Tuple, TypeVar
|
|
47
|
+
|
|
48
|
+
from numpy.random import PCG64, Generator, SeedSequence
|
|
49
|
+
|
|
50
|
+
from flux_engine.core.canonical import nfc
|
|
51
|
+
from flux_engine.core.errors import FluxError, register
|
|
52
|
+
from flux_engine.core.lrm import FLX_5020, WEIGHT_SUM_TOLERANCE
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
"BOUNDED_UINT_DRAW_BUDGET",
|
|
56
|
+
"FLX_5021",
|
|
57
|
+
"KERNEL_DECIMAL_PRECISION",
|
|
58
|
+
"RAW_WORDS_PER_DRAW",
|
|
59
|
+
"UNIFORM53_DENOMINATOR",
|
|
60
|
+
"WORD_BITS",
|
|
61
|
+
"DrawStream",
|
|
62
|
+
"bernoulli",
|
|
63
|
+
"bounded_uint",
|
|
64
|
+
"categorical",
|
|
65
|
+
"exponential",
|
|
66
|
+
"kernel_context",
|
|
67
|
+
"nfc_sorted_items",
|
|
68
|
+
"normal_cdf",
|
|
69
|
+
"normal_ppf",
|
|
70
|
+
"permutation",
|
|
71
|
+
"permutation_word_cost",
|
|
72
|
+
"poisson",
|
|
73
|
+
"poisson_cap",
|
|
74
|
+
"truncated_normal",
|
|
75
|
+
"uniform53",
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
_T = TypeVar("_T")
|
|
79
|
+
|
|
80
|
+
FLX_5021 = register(
|
|
81
|
+
"FLX-5021",
|
|
82
|
+
"draw parameter outside its declared domain",
|
|
83
|
+
"A kernel sampler was asked for a draw it cannot form -- a probability outside "
|
|
84
|
+
"[0, 1], a non-positive rate, a bound below 1, an interval whose lower end is "
|
|
85
|
+
"above its upper end, or a scale on which no value of the declared interval is "
|
|
86
|
+
"representable. The kernel refuses rather than substituting a default, because "
|
|
87
|
+
"the substituted value would be hashed into the run digest and no reader could "
|
|
88
|
+
"tell it apart from an authored one (KERNEL-SPEC §4).",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
#: Width of one raw word from the bit generator. PCG64's ``random_raw`` is 64-bit.
|
|
92
|
+
WORD_BITS = 64
|
|
93
|
+
_WORD_MASK = (1 << WORD_BITS) - 1
|
|
94
|
+
|
|
95
|
+
#: ``uniform53`` returns an integer in ``[0, 2**53)``; this is the denominator that turns
|
|
96
|
+
#: it into an exact fraction of the unit interval.
|
|
97
|
+
UNIFORM53_DENOMINATOR = 1 << 53
|
|
98
|
+
|
|
99
|
+
#: §4: ``bounded_uint`` is "debiased by a fixed 64-draw budget ... the bound is consumed in
|
|
100
|
+
#: full (all 64 words are always drawn) so the stream position is data-independent".
|
|
101
|
+
BOUNDED_UINT_DRAW_BUDGET = 64
|
|
102
|
+
|
|
103
|
+
#: The declared precision of §4's decimal evaluation ("evaluated in the ``decimal`` module
|
|
104
|
+
#: at 28 digits"). :func:`normal_cdf` and :func:`normal_ppf` return values at this
|
|
105
|
+
#: precision, and every continuous draw is rounded onto it before quantisation.
|
|
106
|
+
KERNEL_DECIMAL_PRECISION = 28
|
|
107
|
+
|
|
108
|
+
#: Guard digits used *inside* the series and continued-fraction evaluations, so that the
|
|
109
|
+
#: cancellation in ``1 - erf(z)`` and in the Halley residual cannot eat into the declared
|
|
110
|
+
#: 28 digits. An implementation detail of the same correctly-rounded decimal arithmetic:
|
|
111
|
+
#: every intermediate is still exact-to-the-digit and identical on every platform.
|
|
112
|
+
_WORKING_PRECISION = 50
|
|
113
|
+
|
|
114
|
+
#: Argument at which Φ's evaluation switches from the positive-term error-function series
|
|
115
|
+
#: to the continued fraction for the complementary error function. Below it the series has
|
|
116
|
+
#: no cancellation at all; above it ``1 - erf`` starts losing digits faster than the guard
|
|
117
|
+
#: digits above can absorb.
|
|
118
|
+
_ERFC_SWITCH = Decimal(4)
|
|
119
|
+
|
|
120
|
+
#: Depth of the backward recurrence for the complementary-error-function continued
|
|
121
|
+
#: fraction. Fixed rather than convergence-tested, so the evaluation is straight-line: no
|
|
122
|
+
#: iteration count, and therefore no result, depends on how close two intermediate values
|
|
123
|
+
#: happened to land.
|
|
124
|
+
_ERFC_CF_DEPTH = 200
|
|
125
|
+
|
|
126
|
+
#: Defensive bound on the error-function series. Unreachable for any argument that reaches
|
|
127
|
+
#: it: the series is entered only for ``z <= 4``, where it settles in well under 200 terms.
|
|
128
|
+
_SERIES_MAX_TERMS = 10_000
|
|
129
|
+
|
|
130
|
+
#: Raw 64-bit words consumed by each sampler, keyed by function name. Pinned by the "fixed
|
|
131
|
+
#: draw count per sample" property test: the stream position must advance by exactly this
|
|
132
|
+
#: much, whatever the parameters and whatever the values drawn. ``permutation`` is
|
|
133
|
+
#: shape-dependent and has its own :func:`permutation_word_cost`.
|
|
134
|
+
RAW_WORDS_PER_DRAW: Mapping[str, int] = {
|
|
135
|
+
"uniform53": 1,
|
|
136
|
+
"bernoulli": 1,
|
|
137
|
+
"categorical": 1,
|
|
138
|
+
"bounded_uint": BOUNDED_UINT_DRAW_BUDGET,
|
|
139
|
+
"truncated_normal": 1,
|
|
140
|
+
"exponential": 1,
|
|
141
|
+
"poisson": 1,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def nfc_sorted_items(mapping: Mapping[str, _T]) -> Tuple[Tuple[str, _T], ...]:
|
|
146
|
+
"""Return *mapping*'s items ordered by NFC key.
|
|
147
|
+
|
|
148
|
+
KERNEL-SPEC §7: "iteration over any mapping is by sorted key"; §4's ``categorical``
|
|
149
|
+
names the same order for its levels. The raw key is the secondary sort term so that two
|
|
150
|
+
distinct keys sharing one NFC form still have a total, reproducible order rather than
|
|
151
|
+
one that depends on the mapping's insertion history.
|
|
152
|
+
"""
|
|
153
|
+
return tuple(sorted(mapping.items(), key=lambda item: (nfc(item[0]), item[0])))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
# The bit source
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class DrawStream:
|
|
162
|
+
"""One path-addressed stream of raw 64-bit words (KERNEL-SPEC §3, §4).
|
|
163
|
+
|
|
164
|
+
Wraps the numpy bit generator so that no numpy object -- and in particular no numpy
|
|
165
|
+
float -- escapes into the kernel: :meth:`next_raw` returns a Python ``int``.
|
|
166
|
+
:attr:`position` counts the words consumed so far, which is what the "fixed draw count"
|
|
167
|
+
property test asserts against :data:`RAW_WORDS_PER_DRAW`.
|
|
168
|
+
|
|
169
|
+
A stream is addressed by its seed-tree path (:mod:`flux_engine.core.seedtree`) and is
|
|
170
|
+
never spawned from a parent, so no stream's values depend on the order in which the
|
|
171
|
+
run happened to reach it.
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
__slots__ = ("_bit_generator", "_generator", "_position", "label")
|
|
175
|
+
|
|
176
|
+
def __init__(self, generator: Generator, *, label: str = "") -> None:
|
|
177
|
+
self._generator = generator
|
|
178
|
+
self._bit_generator = generator.bit_generator
|
|
179
|
+
self._position = 0
|
|
180
|
+
self.label = label
|
|
181
|
+
|
|
182
|
+
@classmethod
|
|
183
|
+
def from_entropy(cls, entropy: int, *, label: str = "") -> "DrawStream":
|
|
184
|
+
"""Build the stream for one seed-tree entropy value.
|
|
185
|
+
|
|
186
|
+
§3 names the construction exactly: ``Generator(PCG64(SeedSequence(<entropy>)))``.
|
|
187
|
+
``PCG64`` is named here rather than reached through ``default_rng``, so a change to
|
|
188
|
+
numpy's default generator can never move a digest.
|
|
189
|
+
"""
|
|
190
|
+
if entropy < 0:
|
|
191
|
+
raise FluxError(FLX_5021, f"stream entropy must be non-negative, got {entropy}")
|
|
192
|
+
return cls(Generator(PCG64(SeedSequence(entropy))), label=label)
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def position(self) -> int:
|
|
196
|
+
"""Number of raw words consumed from this stream so far."""
|
|
197
|
+
return self._position
|
|
198
|
+
|
|
199
|
+
def next_raw(self) -> int:
|
|
200
|
+
"""Consume and return one raw 64-bit word as a Python ``int``."""
|
|
201
|
+
raw = self._bit_generator.random_raw()
|
|
202
|
+
self._position += 1
|
|
203
|
+
return int(raw) & _WORD_MASK
|
|
204
|
+
|
|
205
|
+
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
|
206
|
+
return f"DrawStream(label={self.label!r}, position={self._position})"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# ---------------------------------------------------------------------------
|
|
210
|
+
# Integer draws
|
|
211
|
+
# ---------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def uniform53(stream: DrawStream) -> int:
|
|
215
|
+
"""Return an integer in ``[0, 2**53)`` -- §4: ``random_raw() >> 11``.
|
|
216
|
+
|
|
217
|
+
The unit interval is carried as this integer numerator over
|
|
218
|
+
:data:`UNIFORM53_DENOMINATOR` and is never converted to a binary float, so the value a
|
|
219
|
+
downstream comparison sees is exactly the value that was drawn.
|
|
220
|
+
"""
|
|
221
|
+
return stream.next_raw() >> (WORD_BITS - 53)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _probability_threshold(p: Decimal) -> int:
|
|
225
|
+
"""``floor(p · 2**53)`` on exact rationals -- the integer form of §4's comparison."""
|
|
226
|
+
return int(Fraction(p) * UNIFORM53_DENOMINATOR)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _require_probability(p: Decimal, what: str) -> None:
|
|
230
|
+
if not Decimal(0) <= p <= Decimal(1):
|
|
231
|
+
raise FluxError(FLX_5021, f"{what} must lie in [0, 1], got {p}")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def bernoulli(stream: DrawStream, p: Decimal) -> bool:
|
|
235
|
+
"""Return ``uniform53 < floor(p · 2**53)`` (§4). The comparison is integer.
|
|
236
|
+
|
|
237
|
+
``p`` is a ``Decimal`` converted to an exact ``Fraction``, so the threshold is the true
|
|
238
|
+
floor of ``p·2**53`` rather than the floor of a rounded binary approximation -- the
|
|
239
|
+
difference is one subject in about nine quadrillion, but it is the difference between a
|
|
240
|
+
reproducible cohort and a nearly reproducible one.
|
|
241
|
+
"""
|
|
242
|
+
_require_probability(p, "bernoulli p")
|
|
243
|
+
return uniform53(stream) < _probability_threshold(p)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def categorical(stream: DrawStream, levels: Mapping[str, Decimal]) -> str:
|
|
247
|
+
"""Draw one level -- §4: "cumulative integer thresholds over levels sorted by NFC key".
|
|
248
|
+
|
|
249
|
+
One raw word. The thresholds are exact integers derived from the exact rational
|
|
250
|
+
weights, so no level's share depends on the order in which the weights were
|
|
251
|
+
accumulated. The last level absorbs the rounding remainder, which makes the sampler
|
|
252
|
+
total: every value ``uniform53`` can return maps to a declared level.
|
|
253
|
+
"""
|
|
254
|
+
if not levels:
|
|
255
|
+
raise FluxError(FLX_5021, "categorical levels must not be empty")
|
|
256
|
+
ordered = nfc_sorted_items(levels)
|
|
257
|
+
total = Fraction(0)
|
|
258
|
+
for key, weight in ordered:
|
|
259
|
+
if weight < 0:
|
|
260
|
+
raise FluxError(FLX_5021, f"categorical weight for level {key!r} is negative")
|
|
261
|
+
total += Fraction(weight)
|
|
262
|
+
if abs(total - 1) > Fraction(WEIGHT_SUM_TOLERANCE):
|
|
263
|
+
raise FluxError(
|
|
264
|
+
FLX_5020,
|
|
265
|
+
f"categorical weights sum to {total.numerator}/{total.denominator}, "
|
|
266
|
+
f"which is further than {WEIGHT_SUM_TOLERANCE} from 1",
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
draw = uniform53(stream)
|
|
270
|
+
cumulative = Fraction(0)
|
|
271
|
+
for key, weight in ordered[:-1]:
|
|
272
|
+
cumulative += Fraction(weight)
|
|
273
|
+
if draw < int(cumulative * UNIFORM53_DENOMINATOR):
|
|
274
|
+
return key
|
|
275
|
+
return ordered[-1][0]
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def bounded_uint(stream: DrawStream, n: int) -> int:
|
|
279
|
+
"""Return an integer in ``[0, n)`` -- Lemire's nearly-divisionless method (§4).
|
|
280
|
+
|
|
281
|
+
Debiased by a **fixed 64-draw budget**: all :data:`BOUNDED_UINT_DRAW_BUDGET` words are
|
|
282
|
+
drawn every time, and the first one that passes Lemire's rejection test decides the
|
|
283
|
+
result. Consuming the budget in full is what makes the stream position
|
|
284
|
+
data-independent -- a replay of the same path lands on the same word whatever the
|
|
285
|
+
values happened to be. If every word in the budget is rejected (probability below
|
|
286
|
+
``64 · 2**-64``, effectively unreachable), the last word is taken modulo ``n``; that is
|
|
287
|
+
a documented, bounded bias, chosen over an unbounded rejection loop because an
|
|
288
|
+
unbounded loop would make the stream position depend on the data.
|
|
289
|
+
"""
|
|
290
|
+
if n < 1:
|
|
291
|
+
raise FluxError(FLX_5021, f"bounded_uint bound must be >= 1, got {n}")
|
|
292
|
+
|
|
293
|
+
words = [stream.next_raw() for _ in range(BOUNDED_UINT_DRAW_BUDGET)]
|
|
294
|
+
# Lemire's rejection threshold, 2**64 mod n. Written out rather than spelled with the
|
|
295
|
+
# C idiom ``(-n) % n``, which relies on unsigned wraparound and evaluates to 0 in
|
|
296
|
+
# Python's arbitrary-precision integers.
|
|
297
|
+
threshold = (1 << WORD_BITS) % n
|
|
298
|
+
for word in words:
|
|
299
|
+
product = word * n
|
|
300
|
+
if (product & _WORD_MASK) >= threshold:
|
|
301
|
+
return product >> WORD_BITS
|
|
302
|
+
return words[-1] % n
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def permutation_word_cost(n: int) -> int:
|
|
306
|
+
"""Raw words consumed by :func:`permutation` for a sequence of length *n*."""
|
|
307
|
+
if n < 0:
|
|
308
|
+
raise FluxError(FLX_5021, f"permutation length must be >= 0, got {n}")
|
|
309
|
+
return max(n - 1, 0) * BOUNDED_UINT_DRAW_BUDGET
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def permutation(stream: DrawStream, n: int) -> Tuple[int, ...]:
|
|
313
|
+
"""Return a permutation of ``range(n)`` -- §4: "Fisher-Yates over ``bounded_uint``".
|
|
314
|
+
|
|
315
|
+
The downward Fisher-Yates sweep makes exactly ``n - 1`` bounded draws, so the word cost
|
|
316
|
+
is :func:`permutation_word_cost` and depends on the length alone.
|
|
317
|
+
"""
|
|
318
|
+
if n < 0:
|
|
319
|
+
raise FluxError(FLX_5021, f"permutation length must be >= 0, got {n}")
|
|
320
|
+
items = list(range(n))
|
|
321
|
+
for i in range(n - 1, 0, -1):
|
|
322
|
+
j = bounded_uint(stream, i + 1)
|
|
323
|
+
items[i], items[j] = items[j], items[i]
|
|
324
|
+
return tuple(items)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
# ---------------------------------------------------------------------------
|
|
328
|
+
# Decimal Φ and Φ⁻¹
|
|
329
|
+
# ---------------------------------------------------------------------------
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def kernel_context(precision: int = KERNEL_DECIMAL_PRECISION) -> Context:
|
|
333
|
+
"""The decimal context every continuous draw is evaluated in.
|
|
334
|
+
|
|
335
|
+
Every field is pinned -- precision, rounding, exponent range and traps -- so a caller's
|
|
336
|
+
ambient ``decimal`` context cannot move a hashed value. The traps are the default set:
|
|
337
|
+
an invalid operation, a division by zero or an overflow raises, rather than producing
|
|
338
|
+
the NaN or infinity that would otherwise travel silently into a digest.
|
|
339
|
+
"""
|
|
340
|
+
return Context(
|
|
341
|
+
prec=precision,
|
|
342
|
+
rounding=ROUND_HALF_EVEN,
|
|
343
|
+
Emin=-999999,
|
|
344
|
+
Emax=999999,
|
|
345
|
+
capitals=1,
|
|
346
|
+
clamp=0,
|
|
347
|
+
flags=[],
|
|
348
|
+
traps=[InvalidOperation, DivisionByZero, Overflow],
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _current_precision() -> int:
|
|
353
|
+
"""The precision of the context the caller is currently evaluating in."""
|
|
354
|
+
return getcontext().prec
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@lru_cache(maxsize=None)
|
|
358
|
+
def _pi(precision: int) -> Decimal:
|
|
359
|
+
"""π to *precision* digits.
|
|
360
|
+
|
|
361
|
+
Borrowed, not derived: the ``pi()`` recipe published in CPython's ``decimal`` module
|
|
362
|
+
documentation, transcribed onto an explicit context instead of the ambient one so the
|
|
363
|
+
value cannot depend on a caller's settings.
|
|
364
|
+
"""
|
|
365
|
+
with localcontext(kernel_context(precision + 4)):
|
|
366
|
+
three = Decimal(3)
|
|
367
|
+
lasts, t, s, n, na, d, da = Decimal(0), three, Decimal(3), 1, 0, 0, 24
|
|
368
|
+
while s != lasts:
|
|
369
|
+
lasts = s
|
|
370
|
+
n, na = n + na, na + 8
|
|
371
|
+
d, da = d + da, da + 32
|
|
372
|
+
t = (t * n) / d
|
|
373
|
+
s += t
|
|
374
|
+
with localcontext(kernel_context(precision)):
|
|
375
|
+
return +s
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
@lru_cache(maxsize=None)
|
|
379
|
+
def _sqrt_pi(precision: int) -> Decimal:
|
|
380
|
+
with localcontext(kernel_context(precision + 4)):
|
|
381
|
+
value = _pi(precision + 4).sqrt()
|
|
382
|
+
with localcontext(kernel_context(precision)):
|
|
383
|
+
return +value
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
@lru_cache(maxsize=None)
|
|
387
|
+
def _sqrt_two(precision: int) -> Decimal:
|
|
388
|
+
with localcontext(kernel_context(precision + 4)):
|
|
389
|
+
value = Decimal(2).sqrt()
|
|
390
|
+
with localcontext(kernel_context(precision)):
|
|
391
|
+
return +value
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
@lru_cache(maxsize=None)
|
|
395
|
+
def _sqrt_two_pi(precision: int) -> Decimal:
|
|
396
|
+
with localcontext(kernel_context(precision + 4)):
|
|
397
|
+
value = (2 * _pi(precision + 4)).sqrt()
|
|
398
|
+
with localcontext(kernel_context(precision)):
|
|
399
|
+
return +value
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _erf_series(z: Decimal) -> Decimal:
|
|
403
|
+
"""``erf(z)`` for ``0 <= z <= 4`` by the all-positive-term confluent series.
|
|
404
|
+
|
|
405
|
+
``erf(z) = (2z/√π)·e^{-z²}·Σ (2z²)^n / (2n+1)!!``. Every term is positive, so unlike the
|
|
406
|
+
alternating Taylor series there is no cancellation and the accumulated sum keeps the
|
|
407
|
+
full working precision. Must be called inside a working-precision context.
|
|
408
|
+
"""
|
|
409
|
+
z_squared = z * z
|
|
410
|
+
two_z_squared = 2 * z_squared
|
|
411
|
+
term = Decimal(1)
|
|
412
|
+
total = Decimal(1)
|
|
413
|
+
for k in range(1, _SERIES_MAX_TERMS):
|
|
414
|
+
term = term * two_z_squared / (2 * k + 1)
|
|
415
|
+
updated = total + term
|
|
416
|
+
if updated == total:
|
|
417
|
+
break
|
|
418
|
+
total = updated
|
|
419
|
+
else: # pragma: no cover - unreachable for the z <= 4 this branch is entered for
|
|
420
|
+
raise FluxError(FLX_5021, "error-function series did not settle within its term bound")
|
|
421
|
+
return 2 * z * (-z_squared).exp() * total / _sqrt_pi(_current_precision())
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _erfc_continued_fraction(z: Decimal) -> Decimal:
|
|
425
|
+
"""``erfc(z)`` for ``z > 4`` by the backward recurrence of its continued fraction.
|
|
426
|
+
|
|
427
|
+
``erfc(z) = (e^{-z²}/√π) · 1/(z + (1/2)/(z + 1/(z + (3/2)/(z + ...))))``. The recurrence
|
|
428
|
+
runs a fixed :data:`_ERFC_CF_DEPTH` levels rather than stopping on a convergence test,
|
|
429
|
+
so the evaluation is straight-line. Must be called inside a working-precision context.
|
|
430
|
+
"""
|
|
431
|
+
tail = Decimal(0)
|
|
432
|
+
for k in range(_ERFC_CF_DEPTH, 0, -1):
|
|
433
|
+
tail = (Decimal(k) / 2) / (z + tail)
|
|
434
|
+
return (-z * z).exp() / _sqrt_pi(_current_precision()) / (z + tail)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _erfc_nonnegative(z: Decimal) -> Decimal:
|
|
438
|
+
"""``erfc(z)`` for ``z >= 0``. Must be called inside a working-precision context."""
|
|
439
|
+
if z <= _ERFC_SWITCH:
|
|
440
|
+
return 1 - _erf_series(z)
|
|
441
|
+
return _erfc_continued_fraction(z)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def normal_cdf(x: Decimal, *, precision: int = KERNEL_DECIMAL_PRECISION) -> Decimal:
|
|
445
|
+
"""Φ(x), the standard normal CDF, evaluated in ``decimal`` (KERNEL-SPEC §4).
|
|
446
|
+
|
|
447
|
+
Both branches route through the complementary error function of a **non-negative**
|
|
448
|
+
argument, so a lower-tail value keeps its significant digits: ``Φ(-8)`` comes out as a
|
|
449
|
+
number with full precision rather than as the difference of two values that both round
|
|
450
|
+
to 1.
|
|
451
|
+
"""
|
|
452
|
+
with localcontext(kernel_context(_WORKING_PRECISION)):
|
|
453
|
+
scaled = x / _sqrt_two(_WORKING_PRECISION)
|
|
454
|
+
if scaled <= 0:
|
|
455
|
+
value = _erfc_nonnegative(-scaled) / 2
|
|
456
|
+
else:
|
|
457
|
+
value = 1 - _erfc_nonnegative(scaled) / 2
|
|
458
|
+
with localcontext(kernel_context(precision)):
|
|
459
|
+
return +value
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
# Acklam's rational approximation to Φ⁻¹ (relative error below 1.15e-9 across the open unit
|
|
463
|
+
# interval). Borrowed, not derived: Peter Acklam's published coefficient set, transcribed as
|
|
464
|
+
# exact decimal literals so the approximation -- and therefore the starting point of the
|
|
465
|
+
# refinement below -- is identical on every platform.
|
|
466
|
+
_ACKLAM_A = (
|
|
467
|
+
Decimal("-3.969683028665376e+01"),
|
|
468
|
+
Decimal("2.209460984245205e+02"),
|
|
469
|
+
Decimal("-2.759285104469687e+02"),
|
|
470
|
+
Decimal("1.383577518672690e+02"),
|
|
471
|
+
Decimal("-3.066479806614716e+01"),
|
|
472
|
+
Decimal("2.506628277459239e+00"),
|
|
473
|
+
)
|
|
474
|
+
_ACKLAM_B = (
|
|
475
|
+
Decimal("-5.447609879822406e+01"),
|
|
476
|
+
Decimal("1.615858368580409e+02"),
|
|
477
|
+
Decimal("-1.556989798598866e+02"),
|
|
478
|
+
Decimal("6.680131188771972e+01"),
|
|
479
|
+
Decimal("-1.328068155288572e+01"),
|
|
480
|
+
)
|
|
481
|
+
_ACKLAM_C = (
|
|
482
|
+
Decimal("-7.784894002430293e-03"),
|
|
483
|
+
Decimal("-3.223964580411365e-01"),
|
|
484
|
+
Decimal("-2.400758277161838e+00"),
|
|
485
|
+
Decimal("-2.549732539343734e+00"),
|
|
486
|
+
Decimal("4.374664141464968e+00"),
|
|
487
|
+
Decimal("2.938163982698783e+00"),
|
|
488
|
+
)
|
|
489
|
+
_ACKLAM_D = (
|
|
490
|
+
Decimal("7.784695709041462e-03"),
|
|
491
|
+
Decimal("3.224671290700398e-01"),
|
|
492
|
+
Decimal("2.445134137142996e+00"),
|
|
493
|
+
Decimal("3.754408661907416e+00"),
|
|
494
|
+
)
|
|
495
|
+
_ACKLAM_P_LOW = Decimal("0.02425")
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _acklam_tail(q: Decimal) -> Decimal:
|
|
499
|
+
"""The shared tail branch of Acklam's approximation. Inside a working context."""
|
|
500
|
+
c0, c1, c2, c3, c4, c5 = _ACKLAM_C
|
|
501
|
+
d0, d1, d2, d3 = _ACKLAM_D
|
|
502
|
+
numerator = ((((c0 * q + c1) * q + c2) * q + c3) * q + c4) * q + c5
|
|
503
|
+
denominator = (((d0 * q + d1) * q + d2) * q + d3) * q + 1
|
|
504
|
+
return numerator / denominator
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _acklam(p: Decimal) -> Decimal:
|
|
508
|
+
"""Acklam's initial estimate of Φ⁻¹(p). Inside a working-precision context."""
|
|
509
|
+
if p < _ACKLAM_P_LOW:
|
|
510
|
+
return _acklam_tail((-2 * p.ln()).sqrt())
|
|
511
|
+
if p > 1 - _ACKLAM_P_LOW:
|
|
512
|
+
return -_acklam_tail((-2 * (1 - p).ln()).sqrt())
|
|
513
|
+
a0, a1, a2, a3, a4, a5 = _ACKLAM_A
|
|
514
|
+
b0, b1, b2, b3, b4 = _ACKLAM_B
|
|
515
|
+
q = p - Decimal("0.5")
|
|
516
|
+
r = q * q
|
|
517
|
+
numerator = (((((a0 * r + a1) * r + a2) * r + a3) * r + a4) * r + a5) * q
|
|
518
|
+
denominator = ((((b0 * r + b1) * r + b2) * r + b3) * r + b4) * r + 1
|
|
519
|
+
return numerator / denominator
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def normal_ppf(p: Decimal, *, precision: int = KERNEL_DECIMAL_PRECISION) -> Decimal:
|
|
523
|
+
"""Φ⁻¹(p) -- §4: "Acklam's rational approximation with one Halley refinement step".
|
|
524
|
+
|
|
525
|
+
The refinement is Halley's third-order step on the residual ``Φ(x) − p``::
|
|
526
|
+
|
|
527
|
+
e = Φ(x) − p ; u = e·√(2π)·e^{x²/2} ; x ← x − u/(1 + x·u/2)
|
|
528
|
+
|
|
529
|
+
One step suffices: Acklam's estimate is already accurate to about ``1.15e-9`` and a
|
|
530
|
+
third-order step cubes that error, landing far below the declared precision. The step
|
|
531
|
+
is unconditional -- there is no "iterate until converged" loop whose trip count could
|
|
532
|
+
differ between two evaluations of the same input.
|
|
533
|
+
"""
|
|
534
|
+
if not Decimal(0) < p < Decimal(1):
|
|
535
|
+
raise FluxError(FLX_5021, f"normal_ppf requires 0 < p < 1, got {p}")
|
|
536
|
+
with localcontext(kernel_context(_WORKING_PRECISION)):
|
|
537
|
+
x = _acklam(p)
|
|
538
|
+
residual = normal_cdf(x, precision=_WORKING_PRECISION) - p
|
|
539
|
+
u = residual * _sqrt_two_pi(_WORKING_PRECISION) * (x * x / 2).exp()
|
|
540
|
+
refined = x - u / (1 + x * u / 2)
|
|
541
|
+
with localcontext(kernel_context(precision)):
|
|
542
|
+
return +refined
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
# ---------------------------------------------------------------------------
|
|
546
|
+
# Continuous draws
|
|
547
|
+
# ---------------------------------------------------------------------------
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _unit_interval(draw: int) -> Decimal:
|
|
551
|
+
"""The ``uniform53`` draw as an exact ``Decimal`` in ``[0, 1)``.
|
|
552
|
+
|
|
553
|
+
``2**53`` is a power of two and its reciprocal has 53 significant decimal digits, which
|
|
554
|
+
is inside the working precision, so this quotient is exact: the conversion introduces
|
|
555
|
+
no rounding of its own.
|
|
556
|
+
"""
|
|
557
|
+
with localcontext(kernel_context(_WORKING_PRECISION)):
|
|
558
|
+
return Decimal(draw) / Decimal(UNIFORM53_DENOMINATOR)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _unrepresentable(scale: int) -> FluxError:
|
|
562
|
+
"""``FLX-5021`` for a value too large to hold at *scale* in the working precision.
|
|
563
|
+
|
|
564
|
+
``Decimal.quantize`` signals ``InvalidOperation`` when the quantised
|
|
565
|
+
coefficient would need more digits than the context's precision. The
|
|
566
|
+
working context's traps make that an exception, and letting it escape
|
|
567
|
+
bare would turn a declaration the kernel cannot honour into a crash
|
|
568
|
+
instead of the refusal ``FLX-5021`` names.
|
|
569
|
+
"""
|
|
570
|
+
return FluxError(
|
|
571
|
+
FLX_5021,
|
|
572
|
+
f"a value of this interval cannot be held at scale {scale} within the "
|
|
573
|
+
f"kernel's {_WORKING_PRECISION}-digit working precision; declare a "
|
|
574
|
+
"narrower interval or a coarser scale",
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _quantise(value: Decimal, scale: int) -> Decimal:
|
|
579
|
+
if scale < 0:
|
|
580
|
+
raise FluxError(FLX_5021, f"declared scale must be >= 0, got {scale}")
|
|
581
|
+
with localcontext(kernel_context(_WORKING_PRECISION)):
|
|
582
|
+
try:
|
|
583
|
+
return value.quantize(Decimal(1).scaleb(-scale))
|
|
584
|
+
except InvalidOperation:
|
|
585
|
+
raise _unrepresentable(scale) from None
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _inward_bounds(lo: Decimal, hi: Decimal, scale: int) -> Tuple[Decimal, Decimal]:
|
|
589
|
+
"""The declared bounds rounded *inward* onto the declared scale."""
|
|
590
|
+
if scale < 0:
|
|
591
|
+
raise FluxError(FLX_5021, f"declared scale must be >= 0, got {scale}")
|
|
592
|
+
with localcontext(kernel_context(_WORKING_PRECISION)):
|
|
593
|
+
step = Decimal(1).scaleb(-scale)
|
|
594
|
+
try:
|
|
595
|
+
lo_q = lo.quantize(step, rounding=ROUND_CEILING)
|
|
596
|
+
hi_q = hi.quantize(step, rounding=ROUND_FLOOR)
|
|
597
|
+
except InvalidOperation:
|
|
598
|
+
raise _unrepresentable(scale) from None
|
|
599
|
+
if lo_q > hi_q:
|
|
600
|
+
raise FluxError(
|
|
601
|
+
FLX_5021,
|
|
602
|
+
f"no value at scale {scale} lies inside [{lo}, {hi}]; widen the interval "
|
|
603
|
+
"or declare a finer scale",
|
|
604
|
+
)
|
|
605
|
+
return lo_q, hi_q
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _clamp(value: Decimal, lo: Decimal, hi: Decimal) -> Decimal:
|
|
609
|
+
if value < lo:
|
|
610
|
+
return lo
|
|
611
|
+
if value > hi:
|
|
612
|
+
return hi
|
|
613
|
+
return value
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def truncated_normal(
|
|
617
|
+
stream: DrawStream,
|
|
618
|
+
*,
|
|
619
|
+
mean: Decimal,
|
|
620
|
+
stddev: Decimal,
|
|
621
|
+
lo: Decimal,
|
|
622
|
+
hi: Decimal,
|
|
623
|
+
scale: int = 6,
|
|
624
|
+
) -> Decimal:
|
|
625
|
+
"""One draw from the normal truncated to ``[lo, hi]`` (KERNEL-SPEC §4).
|
|
626
|
+
|
|
627
|
+
**One** ``uniform53``, mapped through the inverse CDF on the standardised bounds:
|
|
628
|
+
``u' = Φ(a) + u·(Φ(b) − Φ(a))``, ``x = mean + stddev·Φ⁻¹(u')``. The result is quantised
|
|
629
|
+
to *scale* decimal places (§4: "the trait's declared scale (default 6 dp)").
|
|
630
|
+
|
|
631
|
+
Two decisions the spec leaves to the implementation, both taken for accuracy and both
|
|
632
|
+
order-preserving in ``u``:
|
|
633
|
+
|
|
634
|
+
* When the interval lies entirely above the mean it is **mirrored** about the mean, so
|
|
635
|
+
that Φ is always evaluated where it keeps its significant digits (the lower tail)
|
|
636
|
+
rather than where it rounds to 1. Mirroring reverses the draw's direction twice, so
|
|
637
|
+
the result is still monotone in ``u`` -- the property the "monotone in u" test pins.
|
|
638
|
+
* The declared bounds are rounded *inward* onto the declared scale and the quantised
|
|
639
|
+
result is clamped to them, so rounding can never push a value outside the interval
|
|
640
|
+
the caller declared. The ``[lo, hi]`` invariant therefore holds of the returned
|
|
641
|
+
value, not merely of the value before rounding.
|
|
642
|
+
"""
|
|
643
|
+
if stddev < 0:
|
|
644
|
+
raise FluxError(FLX_5021, f"truncated_normal stddev must be >= 0, got {stddev}")
|
|
645
|
+
if lo > hi:
|
|
646
|
+
raise FluxError(FLX_5021, f"truncated_normal requires lo <= hi, got [{lo}, {hi}]")
|
|
647
|
+
|
|
648
|
+
lo_q, hi_q = _inward_bounds(lo, hi, scale)
|
|
649
|
+
draw = uniform53(stream)
|
|
650
|
+
|
|
651
|
+
if stddev == 0:
|
|
652
|
+
return _clamp(_quantise(mean, scale), lo_q, hi_q)
|
|
653
|
+
|
|
654
|
+
with localcontext(kernel_context(_WORKING_PRECISION)):
|
|
655
|
+
u = _unit_interval(draw)
|
|
656
|
+
a = (lo - mean) / stddev
|
|
657
|
+
b = (hi - mean) / stddev
|
|
658
|
+
if a > 0:
|
|
659
|
+
z = -_standard_truncated(1 - u, -b, -a)
|
|
660
|
+
else:
|
|
661
|
+
z = _standard_truncated(u, a, b)
|
|
662
|
+
value = mean + stddev * z
|
|
663
|
+
return _clamp(_quantise(value, scale), lo_q, hi_q)
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _standard_truncated(u: Decimal, a: Decimal, b: Decimal) -> Decimal:
|
|
667
|
+
"""Φ⁻¹ of *u* mapped into ``[Φ(a), Φ(b)]``. Inside a working-precision context."""
|
|
668
|
+
cdf_a = normal_cdf(a, precision=_WORKING_PRECISION)
|
|
669
|
+
cdf_b = normal_cdf(b, precision=_WORKING_PRECISION)
|
|
670
|
+
mass = cdf_b - cdf_a
|
|
671
|
+
if mass <= 0:
|
|
672
|
+
# A degenerate interval: equal bounds, or one whose mass is below the working
|
|
673
|
+
# precision. The lower bound is the only defensible answer -- inventing an interior
|
|
674
|
+
# value would put a number into the run digest that the declared interval does not
|
|
675
|
+
# contain.
|
|
676
|
+
return a
|
|
677
|
+
target = cdf_a + u * mass
|
|
678
|
+
if target <= 0:
|
|
679
|
+
return a
|
|
680
|
+
if target >= 1:
|
|
681
|
+
return b
|
|
682
|
+
return normal_ppf(target, precision=_WORKING_PRECISION)
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def exponential(stream: DrawStream, *, rate: Decimal, scale: int = 6) -> Decimal:
|
|
686
|
+
"""One draw from the exponential distribution -- §4: "inverse CDF in ``decimal``".
|
|
687
|
+
|
|
688
|
+
``x = −ln(1 − u)/rate`` on one ``uniform53``. ``1 − u`` lies in ``(0, 1]`` because ``u``
|
|
689
|
+
is at most ``(2**53 − 1)/2**53``, so the logarithm's argument is never zero and the draw
|
|
690
|
+
needs no resampling.
|
|
691
|
+
"""
|
|
692
|
+
if rate <= 0:
|
|
693
|
+
raise FluxError(FLX_5021, f"exponential rate must be > 0, got {rate}")
|
|
694
|
+
draw = uniform53(stream)
|
|
695
|
+
with localcontext(kernel_context(_WORKING_PRECISION)):
|
|
696
|
+
value = -((1 - _unit_interval(draw)).ln()) / rate
|
|
697
|
+
return _quantise(value, scale)
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def poisson_cap(mean: Decimal) -> int:
|
|
701
|
+
"""§4's fixed cap on the Poisson search: ``floor(mean + 10·sqrt(mean) + 10)``.
|
|
702
|
+
|
|
703
|
+
Fixed and documented rather than adaptive: the bound is a function of the declared rate
|
|
704
|
+
alone, so two runs of the same declaration search the same distance.
|
|
705
|
+
"""
|
|
706
|
+
if mean < 0:
|
|
707
|
+
raise FluxError(FLX_5021, f"poisson mean must be >= 0, got {mean}")
|
|
708
|
+
with localcontext(kernel_context(_WORKING_PRECISION)):
|
|
709
|
+
bound = mean + 10 * mean.sqrt() + 10
|
|
710
|
+
return int(bound.to_integral_value(rounding=ROUND_FLOOR))
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def poisson(stream: DrawStream, *, mean: Decimal) -> int:
|
|
714
|
+
"""One draw from the Poisson distribution -- §4: inverse CDF, "one uniform per draw".
|
|
715
|
+
|
|
716
|
+
Sequential search over the CDF with a hard cap at :func:`poisson_cap`. The search
|
|
717
|
+
consumes no further randomness, so the word cost is 1 whatever the value returned; a
|
|
718
|
+
draw that reaches the cap returns the cap, which is a documented truncation of the far
|
|
719
|
+
tail rather than a resample.
|
|
720
|
+
"""
|
|
721
|
+
if mean < 0:
|
|
722
|
+
raise FluxError(FLX_5021, f"poisson mean must be >= 0, got {mean}")
|
|
723
|
+
draw = uniform53(stream)
|
|
724
|
+
if mean == 0:
|
|
725
|
+
return 0
|
|
726
|
+
cap = poisson_cap(mean)
|
|
727
|
+
with localcontext(kernel_context(_WORKING_PRECISION)):
|
|
728
|
+
u = _unit_interval(draw)
|
|
729
|
+
term = (-mean).exp()
|
|
730
|
+
cumulative = term
|
|
731
|
+
k = 0
|
|
732
|
+
while u >= cumulative and k < cap:
|
|
733
|
+
k += 1
|
|
734
|
+
term = term * mean / k
|
|
735
|
+
cumulative += term
|
|
736
|
+
return k
|