crc-framework 0.2.1__tar.gz → 0.2.5__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.
Files changed (62) hide show
  1. {crc_framework-0.2.1 → crc_framework-0.2.5}/.github/workflows/release.yaml +2 -5
  2. crc_framework-0.2.5/.python-version +1 -0
  3. {crc_framework-0.2.1 → crc_framework-0.2.5}/Cargo.lock +12 -52
  4. {crc_framework-0.2.1 → crc_framework-0.2.5}/Cargo.toml +2 -2
  5. {crc_framework-0.2.1 → crc_framework-0.2.5}/PKG-INFO +4 -1
  6. {crc_framework-0.2.1 → crc_framework-0.2.5}/crates/core/Cargo.toml +1 -1
  7. {crc_framework-0.2.1 → crc_framework-0.2.5}/crates/core/src/reference/curve_fitting.rs +62 -11
  8. crc_framework-0.2.5/crates/core/tests/quantile_fit_golden.rs +320 -0
  9. crc_framework-0.2.5/crates/core/tests/quantile_fit_golden.txt +56 -0
  10. {crc_framework-0.2.1 → crc_framework-0.2.5}/pyproject.toml +5 -0
  11. crc_framework-0.2.5/uv.lock +755 -0
  12. {crc_framework-0.2.1 → crc_framework-0.2.5}/.gitignore +0 -0
  13. {crc_framework-0.2.1 → crc_framework-0.2.5}/LICENSE +0 -0
  14. {crc_framework-0.2.1 → crc_framework-0.2.5}/README.md +0 -0
  15. {crc_framework-0.2.1 → crc_framework-0.2.5}/THIRD_PARTY_NOTICES.md +0 -0
  16. {crc_framework-0.2.1 → crc_framework-0.2.5}/crates/core/src/constants.rs +0 -0
  17. {crc_framework-0.2.1 → crc_framework-0.2.5}/crates/core/src/distribution.rs +0 -0
  18. {crc_framework-0.2.1 → crc_framework-0.2.5}/crates/core/src/error.rs +0 -0
  19. {crc_framework-0.2.1 → crc_framework-0.2.5}/crates/core/src/lib.rs +0 -0
  20. {crc_framework-0.2.1 → crc_framework-0.2.5}/crates/core/src/metrics.rs +0 -0
  21. {crc_framework-0.2.1 → crc_framework-0.2.5}/crates/core/src/reference/climate/ag_params.rs +0 -0
  22. {crc_framework-0.2.1 → crc_framework-0.2.5}/crates/core/src/reference/climate/energy_params.rs +0 -0
  23. {crc_framework-0.2.1 → crc_framework-0.2.5}/crates/core/src/reference/climate/flood_params.rs +0 -0
  24. {crc_framework-0.2.1 → crc_framework-0.2.5}/crates/core/src/reference/climate/mod.rs +0 -0
  25. {crc_framework-0.2.1 → crc_framework-0.2.5}/crates/core/src/reference/climate/precipitation_thresholds.rs +0 -0
  26. {crc_framework-0.2.1 → crc_framework-0.2.5}/crates/core/src/reference/climate/spei_params.rs +0 -0
  27. {crc_framework-0.2.1 → crc_framework-0.2.5}/crates/core/src/reference/climate/wind_params.rs +0 -0
  28. {crc_framework-0.2.1 → crc_framework-0.2.5}/crates/core/src/reference/mod.rs +0 -0
  29. {crc_framework-0.2.1 → crc_framework-0.2.5}/crates/core/src/reference/spatial/continent_r4.rs +0 -0
  30. {crc_framework-0.2.1 → crc_framework-0.2.5}/crates/core/src/reference/spatial/geography_r5.rs +0 -0
  31. {crc_framework-0.2.1 → crc_framework-0.2.5}/crates/core/src/reference/spatial/ipcc.rs +0 -0
  32. {crc_framework-0.2.1 → crc_framework-0.2.5}/crates/core/src/reference/spatial/mod.rs +0 -0
  33. {crc_framework-0.2.1 → crc_framework-0.2.5}/crates/core/src/risk.rs +0 -0
  34. {crc_framework-0.2.1 → crc_framework-0.2.5}/crates/core/src/spatial.rs +0 -0
  35. {crc_framework-0.2.1 → crc_framework-0.2.5}/crates/core/src/transform.rs +0 -0
  36. {crc_framework-0.2.1 → crc_framework-0.2.5}/python/crc_framework/__init__.py +0 -0
  37. {crc_framework-0.2.1 → crc_framework-0.2.5}/python/crc_framework/_core.pyi +0 -0
  38. {crc_framework-0.2.1 → crc_framework-0.2.5}/python/crc_framework/constants.py +0 -0
  39. {crc_framework-0.2.1 → crc_framework-0.2.5}/python/crc_framework/distributions/__init__.py +0 -0
  40. {crc_framework-0.2.1 → crc_framework-0.2.5}/python/crc_framework/distributions/base.py +0 -0
  41. {crc_framework-0.2.1 → crc_framework-0.2.5}/python/crc_framework/distributions/empirical.py +0 -0
  42. {crc_framework-0.2.1 → crc_framework-0.2.5}/python/crc_framework/distributions/fitted.py +0 -0
  43. {crc_framework-0.2.1 → crc_framework-0.2.5}/python/crc_framework/distributions/fitting.py +0 -0
  44. {crc_framework-0.2.1 → crc_framework-0.2.5}/python/crc_framework/distributions/hurdle.py +0 -0
  45. {crc_framework-0.2.1 → crc_framework-0.2.5}/python/crc_framework/distributions/point_mass.py +0 -0
  46. {crc_framework-0.2.1 → crc_framework-0.2.5}/python/crc_framework/distributions/tabulated.py +0 -0
  47. {crc_framework-0.2.1 → crc_framework-0.2.5}/python/crc_framework/metrics/__init__.py +0 -0
  48. {crc_framework-0.2.1 → crc_framework-0.2.5}/python/crc_framework/metrics/distribution.py +0 -0
  49. {crc_framework-0.2.1 → crc_framework-0.2.5}/python/crc_framework/metrics/microscores.py +0 -0
  50. {crc_framework-0.2.1 → crc_framework-0.2.5}/python/crc_framework/metrics/risk.py +0 -0
  51. {crc_framework-0.2.1 → crc_framework-0.2.5}/python/crc_framework/metrics/spanning.py +0 -0
  52. {crc_framework-0.2.1 → crc_framework-0.2.5}/python/crc_framework/models.py +0 -0
  53. {crc_framework-0.2.1 → crc_framework-0.2.5}/python/crc_framework/py.typed +0 -0
  54. {crc_framework-0.2.1 → crc_framework-0.2.5}/python/crc_framework/spatial.py +0 -0
  55. {crc_framework-0.2.1 → crc_framework-0.2.5}/python/crc_framework/transforms/__init__.py +0 -0
  56. {crc_framework-0.2.1 → crc_framework-0.2.5}/python/crc_framework/transforms/base.py +0 -0
  57. {crc_framework-0.2.1 → crc_framework-0.2.5}/python/crc_framework/transforms/builtin.py +0 -0
  58. {crc_framework-0.2.1 → crc_framework-0.2.5}/python/crc_framework/transforms/climate.py +0 -0
  59. {crc_framework-0.2.1 → crc_framework-0.2.5}/src/lib.rs +0 -0
  60. {crc_framework-0.2.1 → crc_framework-0.2.5}/tests/test_impacts.py +0 -0
  61. {crc_framework-0.2.1 → crc_framework-0.2.5}/tests/test_sdk.py +0 -0
  62. {crc_framework-0.2.1 → crc_framework-0.2.5}/tests/typing_smoke.py +0 -0
@@ -69,7 +69,6 @@ jobs:
69
69
  3.11
70
70
  3.12
71
71
  3.13
72
- 3.13t
73
72
  3.14
74
73
  3.14t
75
74
  - name: Build wheels
@@ -79,12 +78,11 @@ jobs:
79
78
  with:
80
79
  target: ${{ matrix.target }}
81
80
  args: >-
82
- --release
83
- --out dist
81
+ --release
82
+ --out dist
84
83
  --find-interpreter
85
84
  sccache: "true"
86
85
  container: "off"
87
- rust-toolchain: nightly-2026-01-27
88
86
  - name: Upload wheels
89
87
  uses: actions/upload-artifact@v4
90
88
  with:
@@ -102,7 +100,6 @@ jobs:
102
100
  with:
103
101
  command: sdist
104
102
  args: --out dist
105
- rust-toolchain: nightly-2026-01-27
106
103
  - name: Upload sdist
107
104
  uses: actions/upload-artifact@v4
108
105
  with:
@@ -0,0 +1 @@
1
+ 3.14
@@ -15,12 +15,6 @@ dependencies = [
15
15
  "zerocopy",
16
16
  ]
17
17
 
18
- [[package]]
19
- name = "autocfg"
20
- version = "1.5.1"
21
- source = "registry+https://github.com/rust-lang/crates.io-index"
22
- checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
23
-
24
18
  [[package]]
25
19
  name = "cfg-if"
26
20
  version = "1.0.4"
@@ -49,7 +43,7 @@ dependencies = [
49
43
 
50
44
  [[package]]
51
45
  name = "crc-framework-core"
52
- version = "0.2.1"
46
+ version = "0.2.5"
53
47
  dependencies = [
54
48
  "h3o",
55
49
  "phf",
@@ -58,7 +52,7 @@ dependencies = [
58
52
 
59
53
  [[package]]
60
54
  name = "crc-framework-rs"
61
- version = "0.2.1"
55
+ version = "0.2.5"
62
56
  dependencies = [
63
57
  "crc-framework-core",
64
58
  "h3o",
@@ -137,15 +131,6 @@ version = "0.5.0"
137
131
  source = "registry+https://github.com/rust-lang/crates.io-index"
138
132
  checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
139
133
 
140
- [[package]]
141
- name = "indoc"
142
- version = "2.0.7"
143
- source = "registry+https://github.com/rust-lang/crates.io-index"
144
- checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
145
- dependencies = [
146
- "rustversion",
147
- ]
148
-
149
134
  [[package]]
150
135
  name = "libc"
151
136
  version = "0.2.188"
@@ -158,15 +143,6 @@ version = "0.2.16"
158
143
  source = "registry+https://github.com/rust-lang/crates.io-index"
159
144
  checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
160
145
 
161
- [[package]]
162
- name = "memoffset"
163
- version = "0.9.1"
164
- source = "registry+https://github.com/rust-lang/crates.io-index"
165
- checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
166
- dependencies = [
167
- "autocfg",
168
- ]
169
-
170
146
  [[package]]
171
147
  name = "once_cell"
172
148
  version = "1.21.4"
@@ -242,35 +218,32 @@ dependencies = [
242
218
 
243
219
  [[package]]
244
220
  name = "pyo3"
245
- version = "0.27.2"
221
+ version = "0.29.0"
246
222
  source = "registry+https://github.com/rust-lang/crates.io-index"
247
- checksum = "ab53c047fcd1a1d2a8820fe84f05d6be69e9526be40cb03b73f86b6b03e6d87d"
223
+ checksum = "cd274650b21d4bfc26a0a47587962c1edb425f69287324355cd040c3ea66071c"
248
224
  dependencies = [
249
- "indoc",
250
225
  "libc",
251
- "memoffset",
252
226
  "once_cell",
253
227
  "portable-atomic",
254
228
  "pyo3-build-config",
255
229
  "pyo3-ffi",
256
230
  "pyo3-macros",
257
- "unindent",
258
231
  ]
259
232
 
260
233
  [[package]]
261
234
  name = "pyo3-build-config"
262
- version = "0.27.2"
235
+ version = "0.29.0"
263
236
  source = "registry+https://github.com/rust-lang/crates.io-index"
264
- checksum = "b455933107de8642b4487ed26d912c2d899dec6114884214a0b3bb3be9261ea6"
237
+ checksum = "c5e2a7d2f0d013342f295c048ad19237add5154a55b1c5a254c0ec93d4109078"
265
238
  dependencies = [
266
239
  "target-lexicon",
267
240
  ]
268
241
 
269
242
  [[package]]
270
243
  name = "pyo3-ffi"
271
- version = "0.27.2"
244
+ version = "0.29.0"
272
245
  source = "registry+https://github.com/rust-lang/crates.io-index"
273
- checksum = "1c85c9cbfaddf651b1221594209aed57e9e5cff63c4d11d1feead529b872a089"
246
+ checksum = "ca85c467da1bbc8d866eea5deff9cf29ea5f7785054a17da36e65bda9c05845b"
274
247
  dependencies = [
275
248
  "libc",
276
249
  "pyo3-build-config",
@@ -278,9 +251,9 @@ dependencies = [
278
251
 
279
252
  [[package]]
280
253
  name = "pyo3-macros"
281
- version = "0.27.2"
254
+ version = "0.29.0"
282
255
  source = "registry+https://github.com/rust-lang/crates.io-index"
283
- checksum = "0a5b10c9bf9888125d917fb4d2ca2d25c8df94c7ab5a52e13313a07e050a3b02"
256
+ checksum = "9ac53762fd065daa3194dd09337a38bd793a188100fd1a9304c4ab312d901771"
284
257
  dependencies = [
285
258
  "proc-macro2",
286
259
  "pyo3-macros-backend",
@@ -290,13 +263,12 @@ dependencies = [
290
263
 
291
264
  [[package]]
292
265
  name = "pyo3-macros-backend"
293
- version = "0.27.2"
266
+ version = "0.29.0"
294
267
  source = "registry+https://github.com/rust-lang/crates.io-index"
295
- checksum = "03b51720d314836e53327f5871d4c0cfb4fb37cc2c4a11cc71907a86342c40f9"
268
+ checksum = "4ca3a1557399783172dc5bf39cfca835157732532cba56b71d2292161e53b362"
296
269
  dependencies = [
297
270
  "heck",
298
271
  "proc-macro2",
299
- "pyo3-build-config",
300
272
  "quote",
301
273
  "syn 2.0.119",
302
274
  ]
@@ -345,12 +317,6 @@ dependencies = [
345
317
  "getrandom 0.3.4",
346
318
  ]
347
319
 
348
- [[package]]
349
- name = "rustversion"
350
- version = "1.0.23"
351
- source = "registry+https://github.com/rust-lang/crates.io-index"
352
- checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
353
-
354
320
  [[package]]
355
321
  name = "serde"
356
322
  version = "1.0.229"
@@ -429,12 +395,6 @@ version = "1.0.24"
429
395
  source = "registry+https://github.com/rust-lang/crates.io-index"
430
396
  checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
431
397
 
432
- [[package]]
433
- name = "unindent"
434
- version = "0.2.4"
435
- source = "registry+https://github.com/rust-lang/crates.io-index"
436
- checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3"
437
-
438
398
  [[package]]
439
399
  name = "version_check"
440
400
  version = "0.9.5"
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "crc-framework-rs"
3
- version = "0.2.1"
3
+ version = "0.2.5"
4
4
  edition = "2024"
5
5
  rust-version = "1.85"
6
6
  description = "Rust-powered Python bindings for the Climate Risk Commons Framework"
@@ -18,4 +18,4 @@ crate-type = ["cdylib"]
18
18
  [dependencies]
19
19
  crc-framework-core = { path = "crates/core" }
20
20
  h3o = "=0.9.3"
21
- pyo3 = { version = "0.27", features = ["extension-module"] }
21
+ pyo3 = { version = "0.29", features = ["extension-module"] }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crc-framework
3
- Version: 0.2.1
3
+ Version: 0.2.5
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)
6
6
  Classifier: Operating System :: OS Independent
@@ -20,6 +20,9 @@ Author: Riskthinking.AI
20
20
  License-Expression: AGPL-3.0-or-later
21
21
  Requires-Python: >=3.9
22
22
  Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
23
+ Project-URL: Homepage, https://github.com/RiskThinking/crc-framework-rs
24
+ Project-URL: Issues, https://github.com/RiskThinking/crc-framework-rs/issues
25
+ Project-URL: Repository, https://github.com/RiskThinking/crc-framework-rs
23
26
 
24
27
  # Climate Risk Commons Framework
25
28
 
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "crc-framework-core"
3
- version = "0.2.1"
3
+ version = "0.2.5"
4
4
  edition = "2024"
5
5
  rust-version = "1.85"
6
6
  description = "Core distribution and climate-risk algorithms for CRC Framework"
@@ -240,6 +240,9 @@ pub(crate) fn fit_quantile_points(
240
240
  };
241
241
  let value_range = values.iter().copied().fold(f64::NEG_INFINITY, f64::max)
242
242
  - values.iter().copied().fold(f64::INFINITY, f64::min);
243
+ // Loop-invariant across every start, iteration and objective evaluation of
244
+ // this fit; summed here in the same order the objective used to sum it.
245
+ let weight_sum = weights.iter().sum::<f64>();
243
246
  let mut best: Option<SimplexResult> = None;
244
247
 
245
248
  for start in starts {
@@ -268,6 +271,7 @@ pub(crate) fn fit_quantile_points(
268
271
  probabilities,
269
272
  values,
270
273
  weights,
274
+ weight_sum,
271
275
  value_scale,
272
276
  truncation_atom,
273
277
  )
@@ -403,12 +407,16 @@ fn constrain_quantile_parameters(kind: DistributionKind, parameters: &mut [f64])
403
407
  }
404
408
  }
405
409
 
410
+ /// `weight_sum` is `weights.iter().sum()`. It is a property of the fit, not of
411
+ /// the trial parameters, so the caller computes it once instead of having every
412
+ /// objective evaluation re-sum the weight vector.
406
413
  fn quantile_objective(
407
414
  kind: DistributionKind,
408
415
  parameters: &[f64],
409
416
  probabilities: &[f64],
410
417
  values: &[f64],
411
418
  weights: &[f64],
419
+ weight_sum: f64,
412
420
  value_scale: f64,
413
421
  truncation_atom: Option<f64>,
414
422
  ) -> f64 {
@@ -425,20 +433,36 @@ fn quantile_objective(
425
433
  if tail_start.is_some_and(|cdf| !cdf.is_finite() || 1.0 - cdf <= 1.0e-12) {
426
434
  return f64::INFINITY;
427
435
  }
428
- let weight_sum = weights.iter().sum::<f64>();
436
+ // The truncation branch is loop-invariant, so it is resolved once here
437
+ // rather than per knot. Both arms compute exactly what the single combined
438
+ // loop computed, in the same order.
429
439
  let mut loss = 0.0;
430
- for ((&probability, &value), &weight) in probabilities.iter().zip(values).zip(weights) {
431
- if weight == 0.0 {
432
- continue;
440
+ match tail_start {
441
+ None => {
442
+ for ((&probability, &value), &weight) in probabilities.iter().zip(values).zip(weights) {
443
+ if weight == 0.0 {
444
+ continue;
445
+ }
446
+ let fitted = distribution.ppf(probability);
447
+ if !fitted.is_finite() {
448
+ return f64::INFINITY;
449
+ }
450
+ loss += weight * ((fitted - value) / value_scale).powi(2);
451
+ }
433
452
  }
434
- let base_probability = tail_start.map_or(probability, |cdf| {
435
- (cdf + probability * (1.0 - cdf)).min(1.0 - 1.0e-15)
436
- });
437
- let fitted = distribution.ppf(base_probability);
438
- if !fitted.is_finite() {
439
- return f64::INFINITY;
453
+ Some(cdf) => {
454
+ for ((&probability, &value), &weight) in probabilities.iter().zip(values).zip(weights) {
455
+ if weight == 0.0 {
456
+ continue;
457
+ }
458
+ let base_probability = (cdf + probability * (1.0 - cdf)).min(1.0 - 1.0e-15);
459
+ let fitted = distribution.ppf(base_probability);
460
+ if !fitted.is_finite() {
461
+ return f64::INFINITY;
462
+ }
463
+ loss += weight * ((fitted - value) / value_scale).powi(2);
464
+ }
440
465
  }
441
- loss += weight * ((fitted - value) / value_scale).powi(2);
442
466
  }
443
467
  loss / weight_sum
444
468
  }
@@ -469,6 +493,17 @@ where
469
493
  let mut values: Vec<f64> = simplex.iter().map(|point| objective(point)).collect();
470
494
  let mut evaluations = values.len();
471
495
 
496
+ // Multi-start callers (e.g. GenExtreme's 5 shape starts) run this to
497
+ // completion for every start even when a start is clearly not
498
+ // converging. Nelder-Mead's improvement curve is front-loaded, so a
499
+ // start that hasn't beaten its best objective in STALL_LIMIT iterations
500
+ // is treated the same as one that exhausted max_iterations: it returns
501
+ // early as not-converged, which the caller already ranks below any
502
+ // converged candidate.
503
+ const STALL_LIMIT: usize = 300;
504
+ let mut best_seen = f64::INFINITY;
505
+ let mut stall_count = 0usize;
506
+
472
507
  for iteration in 0..max_iterations {
473
508
  let mut order: Vec<usize> = (0..simplex.len()).collect();
474
509
  order.sort_by(|&left, &right| values[left].total_cmp(&values[right]));
@@ -498,6 +533,22 @@ where
498
533
  };
499
534
  }
500
535
 
536
+ if values[best] < best_seen - objective_tolerance {
537
+ best_seen = values[best];
538
+ stall_count = 0;
539
+ } else {
540
+ stall_count += 1;
541
+ if stall_count >= STALL_LIMIT {
542
+ return SimplexResult {
543
+ parameters: simplex[best].clone(),
544
+ objective: values[best],
545
+ converged: false,
546
+ iterations: iteration,
547
+ evaluations,
548
+ };
549
+ }
550
+ }
551
+
501
552
  let mut centroid = vec![0.0; dimensions];
502
553
  for &index in &order[..dimensions] {
503
554
  for (dimension, value) in centroid.iter_mut().enumerate() {
@@ -0,0 +1,320 @@
1
+ //! Bit-exact golden tests for the quantile Nelder-Mead fitting path.
2
+ //!
3
+ //! `fit_quantiles` drives `quantile_nelder_mead`, the simplex optimizer used to
4
+ //! produce production risk-model parameters. These tests pin the exact IEEE-754
5
+ //! bit patterns of the fitted parameters together with the simplex iteration and
6
+ //! evaluation counts, so any change to the optimizer that is meant to be purely
7
+ //! mechanical (allocation strategy, code motion) can be verified as producing
8
+ //! identical output rather than merely similar output.
9
+ //!
10
+ //! The objective calls `exp`, `ln` and `pow` from the platform's libm, so the
11
+ //! table is pinned to the libm that produced it (it was generated on macOS
12
+ //! 15 / aarch64 and verified identical in both debug and release builds). Treat
13
+ //! a mismatch on a different platform as a portability finding to investigate,
14
+ //! not as an automatic regression, and regenerate the table only for an
15
+ //! intentional numerical change:
16
+ //!
17
+ //! ```text
18
+ //! CRC_GOLDEN_REGEN=1 cargo test -p crc-framework-core --test quantile_fit_golden -- --nocapture
19
+ //! ```
20
+
21
+ use crc_framework_core::{DistributionFamily, fit_hurdle_quantiles, fit_quantiles};
22
+
23
+ /// Deterministic value generator: SplitMix64, so the cases are identical on
24
+ /// every platform and every run without pulling in an RNG dependency.
25
+ struct SplitMix64(u64);
26
+
27
+ impl SplitMix64 {
28
+ fn next_u64(&mut self) -> u64 {
29
+ self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
30
+ let mut z = self.0;
31
+ z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
32
+ z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
33
+ z ^ (z >> 31)
34
+ }
35
+
36
+ /// Uniform in [0, 1) built from the top 53 bits, i.e. exactly representable.
37
+ fn next_unit(&mut self) -> f64 {
38
+ (self.next_u64() >> 11) as f64 * (1.0 / (1u64 << 53) as f64)
39
+ }
40
+ }
41
+
42
+ const PROBABILITIES_WIDE: [f64; 13] = [
43
+ 0.01, 0.05, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 0.95, 0.99,
44
+ ];
45
+ const PROBABILITIES_NARROW: [f64; 5] = [0.1, 0.25, 0.5, 0.75, 0.9];
46
+
47
+ /// A single fit problem: strictly increasing probabilities and non-decreasing
48
+ /// values, which is what `fit_quantiles` requires.
49
+ struct Case {
50
+ label: &'static str,
51
+ probabilities: Vec<f64>,
52
+ values: Vec<f64>,
53
+ weights: Option<Vec<f64>>,
54
+ }
55
+
56
+ /// Builds a monotone value curve by accumulating positive jitter onto a base
57
+ /// curve, so the resulting shapes are irregular enough to exercise multi-start
58
+ /// selection, expansion, contraction and shrink branches of the simplex.
59
+ fn monotone_values(seed: u64, count: usize, base: f64, slope: f64, jitter: f64) -> Vec<f64> {
60
+ let mut rng = SplitMix64(seed);
61
+ let mut value = base;
62
+ let mut out = Vec::with_capacity(count);
63
+ for step in 0..count {
64
+ value += slope * (1.0 + step as f64 * 0.1) + jitter * rng.next_unit();
65
+ out.push(value);
66
+ }
67
+ out
68
+ }
69
+
70
+ fn cases() -> Vec<Case> {
71
+ let wide = PROBABILITIES_WIDE.to_vec();
72
+ let narrow = PROBABILITIES_NARROW.to_vec();
73
+ vec![
74
+ Case {
75
+ label: "wide-gentle",
76
+ probabilities: wide.clone(),
77
+ values: monotone_values(0x1234_5678, wide.len(), 0.0, 1.0, 0.25),
78
+ weights: None,
79
+ },
80
+ Case {
81
+ label: "wide-steep-tail",
82
+ probabilities: wide.clone(),
83
+ values: monotone_values(0xDEAD_BEEF, wide.len(), 2.0, 4.0, 3.0),
84
+ weights: None,
85
+ },
86
+ Case {
87
+ label: "wide-large-scale",
88
+ probabilities: wide.clone(),
89
+ values: monotone_values(0x0BAD_C0DE, wide.len(), 1000.0, 250.0, 90.0),
90
+ weights: None,
91
+ },
92
+ Case {
93
+ label: "wide-tiny-scale",
94
+ probabilities: wide.clone(),
95
+ values: monotone_values(0x00C0_FFEE, wide.len(), 0.001, 0.0004, 0.0002),
96
+ weights: None,
97
+ },
98
+ Case {
99
+ label: "wide-weighted",
100
+ probabilities: wide.clone(),
101
+ values: monotone_values(0xFEED_FACE, wide.len(), 5.0, 2.0, 1.5),
102
+ weights: Some(vec![
103
+ 3.0, 2.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 2.0, 2.5, 4.0,
104
+ ]),
105
+ },
106
+ Case {
107
+ label: "wide-flat-then-jump",
108
+ probabilities: wide.clone(),
109
+ values: vec![
110
+ 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 2.0, 3.0, 5.0, 9.0, 14.0, 30.0,
111
+ ],
112
+ weights: None,
113
+ },
114
+ Case {
115
+ label: "narrow-gentle",
116
+ probabilities: narrow.clone(),
117
+ values: monotone_values(0xA5A5_5A5A, narrow.len(), 10.0, 3.0, 1.0),
118
+ weights: None,
119
+ },
120
+ Case {
121
+ label: "narrow-negative-support",
122
+ probabilities: narrow.clone(),
123
+ values: monotone_values(0x5150_5150, narrow.len(), -40.0, 6.0, 2.0),
124
+ weights: None,
125
+ },
126
+ ]
127
+ }
128
+
129
+ /// One line per (family, case) with the exact bit patterns of everything the
130
+ /// optimizer produces. Bits, not decimals: this is the whole point of the test.
131
+ fn fingerprint() -> Vec<String> {
132
+ let mut lines = Vec::new();
133
+ for family in DistributionFamily::ALL {
134
+ for case in cases() {
135
+ let outcome = fit_quantiles(
136
+ &case.probabilities,
137
+ &case.values,
138
+ case.weights.as_deref(),
139
+ family,
140
+ );
141
+ let rendered = match outcome {
142
+ Err(error) => format!("err({error})"),
143
+ Ok(result) => {
144
+ let shape = result.distribution.shape;
145
+ let loc = result.distribution.location;
146
+ let scale = result.distribution.scale;
147
+ let diagnostics = result.diagnostics;
148
+ format!(
149
+ "shape={} loc={:016x} scale={:016x} converged={} iterations={} \
150
+ evaluations={} points={} rmse={:016x} nrmse={:016x} r2={:016x} \
151
+ maxres={:016x}",
152
+ shape
153
+ .map_or_else(|| "none".to_owned(), |v| format!("{:016x}", v.to_bits())),
154
+ loc.to_bits(),
155
+ scale.to_bits(),
156
+ diagnostics.converged,
157
+ diagnostics.iterations,
158
+ diagnostics.evaluations,
159
+ diagnostics.point_count,
160
+ diagnostics.rmse.to_bits(),
161
+ diagnostics.normalized_rmse.to_bits(),
162
+ diagnostics.weighted_r_squared.to_bits(),
163
+ diagnostics.maximum_absolute_residual.to_bits(),
164
+ )
165
+ }
166
+ };
167
+ lines.push(format!("{}/{} {rendered}", family.name(), case.label));
168
+ }
169
+ }
170
+ lines
171
+ }
172
+
173
+ #[test]
174
+ fn quantile_fits_are_bit_identical_to_golden() {
175
+ let actual = fingerprint();
176
+
177
+ if std::env::var_os("CRC_GOLDEN_REGEN").is_some() {
178
+ println!("---BEGIN GOLDEN---");
179
+ for line in &actual {
180
+ println!("{line}");
181
+ }
182
+ println!("---END GOLDEN---");
183
+ return;
184
+ }
185
+
186
+ let expected: Vec<&str> = GOLDEN.lines().filter(|line| !line.is_empty()).collect();
187
+ assert_eq!(
188
+ actual.len(),
189
+ expected.len(),
190
+ "golden table is stale: case count changed"
191
+ );
192
+ let mut mismatches = Vec::new();
193
+ for (actual_line, expected_line) in actual.iter().zip(&expected) {
194
+ if actual_line != expected_line {
195
+ mismatches.push(format!(
196
+ " expected: {expected_line}\n actual: {actual_line}"
197
+ ));
198
+ }
199
+ }
200
+ assert!(
201
+ mismatches.is_empty(),
202
+ "{} of {} quantile fits changed:\n{}",
203
+ mismatches.len(),
204
+ expected.len(),
205
+ mismatches.join("\n")
206
+ );
207
+ }
208
+
209
+ const GOLDEN: &str = include_str!("quantile_fit_golden.txt");
210
+
211
+ /// Wall-clock micro-benchmark for the fitting hot path, at the knot widths the
212
+ /// curve_fit_cdf pipeline actually uses (999 knots per curve) plus a narrower
213
+ /// width for contrast. Both objective paths are measured, because that pipeline
214
+ /// overwhelmingly takes the truncated (hurdle) one.
215
+ ///
216
+ /// Ignored by default because it is a timing measurement, not an assertion:
217
+ ///
218
+ /// ```text
219
+ /// cargo test --release -p crc-framework-core --test quantile_fit_golden -- \
220
+ /// --ignored --nocapture bench_wide_quantile_fits
221
+ /// ```
222
+ #[test]
223
+ #[ignore = "timing benchmark, run explicitly with --ignored"]
224
+ fn bench_wide_quantile_fits() {
225
+ const WARMUP_ROUNDS: usize = 2;
226
+ const MEASURED_ROUNDS: usize = 7;
227
+ const PROBLEMS: usize = 4;
228
+
229
+ /// A curve with `plateau` leading knots pinned at zero (the hurdle atom)
230
+ /// and a strictly increasing tail, which is the shape the pipeline feeds in.
231
+ fn wide_problem(knots: usize, plateau: usize, seed: u64) -> (Vec<f64>, Vec<f64>) {
232
+ let mut rng = SplitMix64(seed);
233
+ let probabilities: Vec<f64> = (1..=knots)
234
+ .map(|index| index as f64 / (knots as f64 + 1.0))
235
+ .collect();
236
+ let mut value = 0.0;
237
+ let mut values = Vec::with_capacity(knots);
238
+ for step in 0..knots {
239
+ if step >= plateau {
240
+ value += 0.05 + rng.next_unit() * (1.0 + step as f64 * 0.01);
241
+ }
242
+ values.push(value);
243
+ }
244
+ (probabilities, values)
245
+ }
246
+
247
+ for knots in [999usize, 200] {
248
+ let plateau = knots / 4;
249
+ let problems: Vec<(Vec<f64>, Vec<f64>)> = (0..PROBLEMS)
250
+ .map(|seed| wide_problem(knots, plateau, 0x5EED + seed as u64))
251
+ .collect();
252
+ for family in [
253
+ DistributionFamily::GumbelRight,
254
+ DistributionFamily::GenExtreme,
255
+ ] {
256
+ for truncated in [false, true] {
257
+ let mut failures = 0usize;
258
+ let mut round = || {
259
+ let start = std::time::Instant::now();
260
+ let mut checksum = 0u64;
261
+ for (probabilities, values) in &problems {
262
+ let fitted = if truncated {
263
+ fit_hurdle_quantiles(
264
+ probabilities,
265
+ values,
266
+ None,
267
+ family,
268
+ values[0],
269
+ probabilities[plateau - 1],
270
+ )
271
+ .map(|result| {
272
+ (
273
+ result.distribution.base().location,
274
+ result.diagnostics.tail.iterations,
275
+ )
276
+ })
277
+ } else {
278
+ fit_quantiles(probabilities, values, None, family).map(|result| {
279
+ (result.distribution.location, result.diagnostics.iterations)
280
+ })
281
+ };
282
+ match fitted {
283
+ // Consume the result so nothing is optimized away.
284
+ Ok((location, iterations)) => {
285
+ checksum = checksum
286
+ .wrapping_mul(0x0100_0000_01b3)
287
+ .wrapping_add(location.to_bits())
288
+ .wrapping_add(iterations as u64);
289
+ }
290
+ Err(_) => failures += 1,
291
+ }
292
+ }
293
+ (start.elapsed().as_secs_f64(), checksum)
294
+ };
295
+
296
+ for _ in 0..WARMUP_ROUNDS {
297
+ round();
298
+ }
299
+ let mut timings = Vec::with_capacity(MEASURED_ROUNDS);
300
+ let mut checksum = 0u64;
301
+ for _ in 0..MEASURED_ROUNDS {
302
+ let (elapsed, round_checksum) = round();
303
+ checksum = round_checksum;
304
+ timings.push(elapsed);
305
+ }
306
+ timings.sort_by(f64::total_cmp);
307
+ // A benchmark that measured only error returns would be silently
308
+ // meaningless, so the fit failures are reported alongside.
309
+ assert_eq!(failures, 0, "benchmark inputs must produce real fits");
310
+ println!(
311
+ "knots={knots:<4} {:<11} {:<7} best {:>10.2} us/fit median {:>10.2} us/fit checksum {checksum:016x}",
312
+ family.name(),
313
+ if truncated { "hurdle" } else { "plain" },
314
+ timings[0] / PROBLEMS as f64 * 1.0e6,
315
+ timings[timings.len() / 2] / PROBLEMS as f64 * 1.0e6,
316
+ );
317
+ }
318
+ }
319
+ }
320
+ }