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.
Files changed (165) hide show
  1. flux_engine/__init__.py +22 -0
  2. flux_engine/_links.py +55 -0
  3. flux_engine/_vendor/__init__.py +5 -0
  4. flux_engine/_vendor/flux_spec/NOTICE +19 -0
  5. flux_engine/_vendor/flux_spec/VENDOR-NOTES.md +104 -0
  6. flux_engine/_vendor/flux_spec/__init__.py +9 -0
  7. flux_engine/_vendor/flux_spec/bundle.py +410 -0
  8. flux_engine/_vendor/flux_spec/enforce.py +316 -0
  9. flux_engine/_vendor/flux_spec/schema/flux-enforcement-0.5.0.json +121 -0
  10. flux_engine/_vendor/flux_spec/schema/flux-manifest-0.5.0.json +153 -0
  11. flux_engine/_vendor/flux_spec/schema/flux-schema-0.2.0.json +465 -0
  12. flux_engine/_vendor/flux_spec/schema/flux-schema-0.3.0.json +876 -0
  13. flux_engine/_vendor/flux_spec/schema/flux-schema-0.4.0.json +1601 -0
  14. flux_engine/_vendor/flux_spec/schema/flux-schema-0.4.1.json +1668 -0
  15. flux_engine/_vendor/flux_spec/schema/flux-schema-0.5.0.json +1669 -0
  16. flux_engine/_vendor/flux_spec/schema/flux-schema-latest.json +1669 -0
  17. flux_engine/_vendor/flux_spec/schema/flux-ui-hints-0.4.0.json +795 -0
  18. flux_engine/_vendor/flux_spec/schema/flux-ui-hints-0.4.1.json +852 -0
  19. flux_engine/_vendor/flux_spec/schema/flux-ui-hints-0.5.0.json +852 -0
  20. flux_engine/_vendor/flux_spec/validate.py +537 -0
  21. flux_engine/_vendor/flux_spec/vectors/enforcement-vectors.json +935 -0
  22. flux_engine/_vendor/flux_spec/vendor/fluid/fluid-schema-0.7.3.json +5234 -0
  23. flux_engine/_vendor/flux_spec/vendor/fluid/fluid-schema-0.7.4.json +5437 -0
  24. flux_engine/_vendor/flux_spec/vendor/fluid/fluid-schema-0.7.5.json +5498 -0
  25. flux_engine/_vocabulary.py +141 -0
  26. flux_engine/accuracy/__init__.py +5 -0
  27. flux_engine/accuracy/_scoring/__init__.py +24 -0
  28. flux_engine/accuracy/accuracy_record.py +96 -0
  29. flux_engine/accuracy/policy.py +158 -0
  30. flux_engine/accuracy/records.py +264 -0
  31. flux_engine/accuracy/store.py +169 -0
  32. flux_engine/api/__init__.py +163 -0
  33. flux_engine/api/accuracy.py +209 -0
  34. flux_engine/api/agentic.py +134 -0
  35. flux_engine/api/calibration.py +125 -0
  36. flux_engine/api/claims.py +93 -0
  37. flux_engine/api/digests.py +73 -0
  38. flux_engine/api/emit.py +78 -0
  39. flux_engine/api/expressions.py +72 -0
  40. flux_engine/api/fidelity.py +110 -0
  41. flux_engine/api/lineage.py +78 -0
  42. flux_engine/api/stages.py +242 -0
  43. flux_engine/api/verdict.py +108 -0
  44. flux_engine/calibrate/__init__.py +5 -0
  45. flux_engine/calibrate/declared.py +253 -0
  46. flux_engine/calibrate/provenance.py +113 -0
  47. flux_engine/cli/__init__.py +2146 -0
  48. flux_engine/cli/__main__.py +17 -0
  49. flux_engine/cli/_documents.py +226 -0
  50. flux_engine/cli/_hints.py +118 -0
  51. flux_engine/cli/_init.py +152 -0
  52. flux_engine/cli/_progress.py +124 -0
  53. flux_engine/concordance/__init__.py +127 -0
  54. flux_engine/concordance/boundary/__init__.py +78 -0
  55. flux_engine/concordance/boundary/merkle.py +287 -0
  56. flux_engine/concordance/boundary/slice.py +429 -0
  57. flux_engine/concordance/boundary/statistic.py +561 -0
  58. flux_engine/concordance/boundary/store.py +334 -0
  59. flux_engine/concordance/boundary/vault.py +357 -0
  60. flux_engine/concordance/compiler.py +542 -0
  61. flux_engine/concordance/errors.py +189 -0
  62. flux_engine/concordance/gate.py +525 -0
  63. flux_engine/concordance/predicate.py +1217 -0
  64. flux_engine/concordance/record.py +554 -0
  65. flux_engine/concordance/verifier.py +583 -0
  66. flux_engine/core/__init__.py +5 -0
  67. flux_engine/core/artifact.py +99 -0
  68. flux_engine/core/canonical.py +369 -0
  69. flux_engine/core/clock.py +437 -0
  70. flux_engine/core/digest.py +288 -0
  71. flux_engine/core/errors.py +277 -0
  72. flux_engine/core/fixedpoint.py +929 -0
  73. flux_engine/core/lrm.py +158 -0
  74. flux_engine/core/rng.py +736 -0
  75. flux_engine/core/seedtree.py +891 -0
  76. flux_engine/core/trace.py +423 -0
  77. flux_engine/describe.py +207 -0
  78. flux_engine/engine.py +257 -0
  79. flux_engine/evidence/__init__.py +5 -0
  80. flux_engine/evidence/pack.py +411 -0
  81. flux_engine/evidence/report.py +243 -0
  82. flux_engine/expr/__init__.py +133 -0
  83. flux_engine/expr/ast.py +312 -0
  84. flux_engine/expr/attrs.py +386 -0
  85. flux_engine/expr/check.py +278 -0
  86. flux_engine/expr/compile.py +322 -0
  87. flux_engine/expr/errors.py +256 -0
  88. flux_engine/expr/lexer.py +297 -0
  89. flux_engine/expr/parser.py +313 -0
  90. flux_engine/expr/types.py +177 -0
  91. flux_engine/generator/__init__.py +97 -0
  92. flux_engine/generator/families.py +325 -0
  93. flux_engine/generator/paths.py +227 -0
  94. flux_engine/generator/rows.py +918 -0
  95. flux_engine/generator/statistics.py +524 -0
  96. flux_engine/governance/__init__.py +87 -0
  97. flux_engine/governance/audit.py +307 -0
  98. flux_engine/governance/gate.py +334 -0
  99. flux_engine/governance/skills.py +220 -0
  100. flux_engine/governance/sovereignty.py +251 -0
  101. flux_engine/lineage/__init__.py +5 -0
  102. flux_engine/lineage/emitter.py +398 -0
  103. flux_engine/py.typed +0 -0
  104. flux_engine/runtime/__init__.py +153 -0
  105. flux_engine/runtime/campaign.py +407 -0
  106. flux_engine/runtime/cast.py +283 -0
  107. flux_engine/runtime/detector.py +222 -0
  108. flux_engine/runtime/errors.py +58 -0
  109. flux_engine/runtime/experiment.py +311 -0
  110. flux_engine/runtime/journey.py +383 -0
  111. flux_engine/runtime/jurisdiction.py +72 -0
  112. flux_engine/runtime/payloads.py +124 -0
  113. flux_engine/runtime/signals.py +334 -0
  114. flux_engine/runtime/temperature.py +156 -0
  115. flux_engine/seam/__init__.py +5 -0
  116. flux_engine/seam/attest.py +290 -0
  117. flux_engine/seam/conform.py +448 -0
  118. flux_engine/seam/contract.py +605 -0
  119. flux_engine/seam/emit.py +488 -0
  120. flux_engine/seam/mapping.py +299 -0
  121. flux_engine/seam/resolve.py +1087 -0
  122. flux_engine/seam/sinks.py +157 -0
  123. flux_engine/spec/__init__.py +134 -0
  124. flux_engine/spec/bundle.py +25 -0
  125. flux_engine/spec/enforce.py +31 -0
  126. flux_engine/spec/validate.py +28 -0
  127. flux_engine/stages/__init__.py +30 -0
  128. flux_engine/stages/apply.py +447 -0
  129. flux_engine/stages/errors.py +315 -0
  130. flux_engine/stages/layout.py +292 -0
  131. flux_engine/stages/pipeline.py +600 -0
  132. flux_engine/stages/plan.py +241 -0
  133. flux_engine/stages/replay.py +83 -0
  134. flux_engine/stages/run.py +208 -0
  135. flux_engine/stages/ship.py +934 -0
  136. flux_engine/stages/validate.py +93 -0
  137. flux_engine/stages/verify.py +1129 -0
  138. flux_engine/starter/__init__.py +161 -0
  139. flux_engine/starter/bundle/detector.flux.yml +15 -0
  140. flux_engine/starter/bundle/journey.flux.yml +23 -0
  141. flux_engine/starter/bundle/module.flux.yml +16 -0
  142. flux_engine/starter/bundle/payment-recovery.fluid.yml +30 -0
  143. flux_engine/starter/bundle/persona.flux.yml +26 -0
  144. flux_engine/starter/bundle/signal.flux.yml +13 -0
  145. flux_engine/starter/bundle/simulation.flux.yml +29 -0
  146. flux_engine/starter/bundle/taxonomy.flux.yml +17 -0
  147. flux_engine/starter/bundle/treatment.flux.yml +13 -0
  148. flux_engine/starter/bundle/world.flux.yml +20 -0
  149. flux_engine/verticals/__init__.py +85 -0
  150. flux_engine/verticals/telco.py +89 -0
  151. flux_engine/world/__init__.py +89 -0
  152. flux_engine/world/derived.py +195 -0
  153. flux_engine/world/errors.py +41 -0
  154. flux_engine/world/families.py +260 -0
  155. flux_engine/world/population.py +602 -0
  156. flux_engine/world/tracing.py +66 -0
  157. flux_engine/world/values.py +121 -0
  158. flux_sim_engine-0.2.0.dist-info/METADATA +108 -0
  159. flux_sim_engine-0.2.0.dist-info/RECORD +165 -0
  160. flux_sim_engine-0.2.0.dist-info/WHEEL +5 -0
  161. flux_sim_engine-0.2.0.dist-info/entry_points.txt +2 -0
  162. flux_sim_engine-0.2.0.dist-info/licenses/LICENSE +202 -0
  163. flux_sim_engine-0.2.0.dist-info/licenses/NOTICE +10 -0
  164. flux_sim_engine-0.2.0.dist-info/licenses/flux_engine/_vendor/flux_spec/NOTICE +19 -0
  165. flux_sim_engine-0.2.0.dist-info/top_level.txt +1 -0
@@ -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