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.
- {crc_framework-0.2.1 → crc_framework-0.2.5}/.github/workflows/release.yaml +2 -5
- crc_framework-0.2.5/.python-version +1 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/Cargo.lock +12 -52
- {crc_framework-0.2.1 → crc_framework-0.2.5}/Cargo.toml +2 -2
- {crc_framework-0.2.1 → crc_framework-0.2.5}/PKG-INFO +4 -1
- {crc_framework-0.2.1 → crc_framework-0.2.5}/crates/core/Cargo.toml +1 -1
- {crc_framework-0.2.1 → crc_framework-0.2.5}/crates/core/src/reference/curve_fitting.rs +62 -11
- crc_framework-0.2.5/crates/core/tests/quantile_fit_golden.rs +320 -0
- crc_framework-0.2.5/crates/core/tests/quantile_fit_golden.txt +56 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/pyproject.toml +5 -0
- crc_framework-0.2.5/uv.lock +755 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/.gitignore +0 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/LICENSE +0 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/README.md +0 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/THIRD_PARTY_NOTICES.md +0 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/crates/core/src/constants.rs +0 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/crates/core/src/distribution.rs +0 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/crates/core/src/error.rs +0 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/crates/core/src/lib.rs +0 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/crates/core/src/metrics.rs +0 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/crates/core/src/reference/climate/ag_params.rs +0 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/crates/core/src/reference/climate/energy_params.rs +0 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/crates/core/src/reference/climate/flood_params.rs +0 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/crates/core/src/reference/climate/mod.rs +0 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/crates/core/src/reference/climate/precipitation_thresholds.rs +0 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/crates/core/src/reference/climate/spei_params.rs +0 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/crates/core/src/reference/climate/wind_params.rs +0 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/crates/core/src/reference/mod.rs +0 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/crates/core/src/reference/spatial/continent_r4.rs +0 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/crates/core/src/reference/spatial/geography_r5.rs +0 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/crates/core/src/reference/spatial/ipcc.rs +0 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/crates/core/src/reference/spatial/mod.rs +0 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/crates/core/src/risk.rs +0 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/crates/core/src/spatial.rs +0 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/crates/core/src/transform.rs +0 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/python/crc_framework/__init__.py +0 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/python/crc_framework/_core.pyi +0 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/python/crc_framework/constants.py +0 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/python/crc_framework/distributions/__init__.py +0 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/python/crc_framework/distributions/base.py +0 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/python/crc_framework/distributions/empirical.py +0 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/python/crc_framework/distributions/fitted.py +0 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/python/crc_framework/distributions/fitting.py +0 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/python/crc_framework/distributions/hurdle.py +0 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/python/crc_framework/distributions/point_mass.py +0 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/python/crc_framework/distributions/tabulated.py +0 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/python/crc_framework/metrics/__init__.py +0 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/python/crc_framework/metrics/distribution.py +0 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/python/crc_framework/metrics/microscores.py +0 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/python/crc_framework/metrics/risk.py +0 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/python/crc_framework/metrics/spanning.py +0 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/python/crc_framework/models.py +0 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/python/crc_framework/py.typed +0 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/python/crc_framework/spatial.py +0 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/python/crc_framework/transforms/__init__.py +0 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/python/crc_framework/transforms/base.py +0 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/python/crc_framework/transforms/builtin.py +0 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/python/crc_framework/transforms/climate.py +0 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/src/lib.rs +0 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/tests/test_impacts.py +0 -0
- {crc_framework-0.2.1 → crc_framework-0.2.5}/tests/test_sdk.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
221
|
+
version = "0.29.0"
|
|
246
222
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
247
|
-
checksum = "
|
|
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.
|
|
235
|
+
version = "0.29.0"
|
|
263
236
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
264
|
-
checksum = "
|
|
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.
|
|
244
|
+
version = "0.29.0"
|
|
272
245
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
273
|
-
checksum = "
|
|
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.
|
|
254
|
+
version = "0.29.0"
|
|
282
255
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
283
|
-
checksum = "
|
|
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.
|
|
266
|
+
version = "0.29.0"
|
|
294
267
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
295
|
-
checksum = "
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
|
|
435
|
-
(
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
+
}
|