robustmargin 0.1.1__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.
robustmargin/ratio.py ADDED
@@ -0,0 +1,941 @@
1
+ """The ratio-of-two-thresholds result and :func:`ratio_interval`, which computes one.
2
+
3
+ :func:`ratio_interval` solves the classic Fieller quadratic --
4
+ ``{rho : A*rho^2 - 2*B*rho + K <= 0}`` for ``A = theta_b^2 - z^2*Vb``,
5
+ ``B = theta_a*theta_b - z^2*C``, ``K = theta_a^2 - z^2*Va`` (``C = 0`` under
6
+ ``dependence="independent"``) -- on the *natural* severity scale, or the log-delta interval
7
+ (``log(ratio)``, variance ``Va + Vb`` on the log scale, exponentiated back around the estimate).
8
+ Both reuse the exact per-threshold delta-method variance :mod:`robustmargin.intervals` already
9
+ computes for a single threshold's own interval (``intervals._theta_variance``), rather than
10
+ reassembling the gradient a second time.
11
+
12
+ **A note on three different things all called ``theta``.** In the Fieller construction
13
+ ``theta`` is a threshold's value on the *original* severity scale (:attr:`Threshold.value`); in the
14
+ log-delta construction ``theta`` is ``log(x*)``; and :mod:`robustmargin.intervals` writes ``theta``
15
+ for yet a third thing, the axis it actually optimises over (``log(severity)`` on a log axis, raw
16
+ severity on a linear one). All three coincide only on a log axis. Fieller here always converts
17
+ ``intervals``' transformed-scale ``Var(theta)`` to the natural scale via the delta method's own
18
+ chain rule for ``value = exp(theta)`` on a log axis (``Var(value) = value^2 * Var(theta)``) or the
19
+ identity map on a linear axis (``value = theta``, so ``Var(value) = Var(theta)`` directly) -- see
20
+ ``_fieller_variance``'s docstring for why this is computed directly rather than through a
21
+ ``value^2 * sigma_log^2`` round trip (the two agree wherever ``value != 0``; the direct route has no
22
+ removable singularity at ``value == 0`` on a linear axis).
23
+
24
+ Several questions the ratio construction leaves open are settled as follows rather than guessed
25
+ at: a censored or failed input threshold propagates a status/censoring flag with no numbers,
26
+ direction-aware for which side is censored; ``level=None`` inherits from the two inputs, which
27
+ must agree, and an explicit mismatching ``level`` also raises; a Fieller lower root below zero is
28
+ intersected with the positive parameter space -- ``lo = 0.0``, ``shape`` stays ``BOUNDED``, the
29
+ unclipped root goes in ``warnings``; an ``EXCLUSIVE``-candidate set whose two roots are both
30
+ negative is exactly the whole positive axis, not a two-ray set to clip -- reported as
31
+ ``UNBOUNDED``; a ``log_delta`` interval too wide to exponentiate raises rather than inventing a
32
+ shape ``Ratio`` has no member for; and with ``K > 0`` the ``EXCLUSIVE``-candidate branch
33
+ (``A < 0``, an open region of the parameter space) yields a half-line, reported as
34
+ ``IntervalShape.HALF_OPEN``. The measure-zero boundary ``A == 0`` next to it yields the same
35
+ half-line and is reported the same way. The shared-``Fit`` check of the
36
+ ``dependence="independent"`` guard is made by :func:`ratio_interval`, not by :class:`Ratio`.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import math
42
+ from collections.abc import Mapping
43
+ from dataclasses import dataclass, field
44
+
45
+ from scipy.stats import norm
46
+
47
+ from robustmargin.intervals import _MAX_LOG_HALF_WIDTH, _theta_variance
48
+ from robustmargin.threshold import Threshold, _resolve_target
49
+ from robustmargin.types import Censoring, IntervalShape, JSONValue, Status, _check_finite_number
50
+
51
+ __all__ = ["Ratio", "ratio_interval"]
52
+
53
+ _VALID_METHODS = frozenset({"fieller", "log_delta"})
54
+ _VALID_DEPENDENCE = frozenset({"independent"})
55
+
56
+
57
+ def _check_finite(value: float | None, *, label: str) -> None:
58
+ if value is not None:
59
+ _check_finite_number(value, label=f"Ratio.{label}")
60
+
61
+
62
+ @dataclass(frozen=True, kw_only=True)
63
+ class Ratio:
64
+ """The ratio of two thresholds' severities, on the same axis, with a confidence interval.
65
+
66
+ A Fieller-type interval is not always a single bounded interval: ``shape`` records
67
+ which geometry ``lo``/``hi`` describe, and that geometry is never clipped to look bounded
68
+ when it isn't.
69
+
70
+ **A failed or censored ratio carries no numbers.** Whenever ``status`` is not ``OK`` or
71
+ ``censoring`` is not ``NONE``, ``shape``, ``estimate``, ``lo`` and ``hi`` are all ``None`` --
72
+ :func:`ratio_interval` propagates the input thresholds' status/censoring rather than
73
+ reporting a number, direction-aware for which side is censored. Only on
74
+ the ``OK`` + ``NONE`` path is ``shape`` required to be set, along with the numbers it
75
+ governs.
76
+
77
+ Attributes
78
+ ----------
79
+ threshold_a, threshold_b
80
+ The two :class:`~robustmargin.Threshold` results the ratio is between (``a / b``). Their
81
+ axes must match on ``name`` and ``unit``, and their ``definition`` must be equal
82
+ (a ratio between two different target-performance definitions is not one number); a
83
+ mismatch on either raises. With ``dependence="independent"``, the two fits must not be
84
+ the very same object (``threshold_a.fit is threshold_b.fit``, object identity only) and
85
+ must not share a cluster id. Both checks are **necessary, not sufficient**: together they
86
+ can only see object identity and the ``cluster_ids`` a :class:`~robustmargin.Fit` records,
87
+ not whether the two series were drawn from overlapping observations that carry no cluster
88
+ id. Equal fits **by value** are not rejected: two disjoint samples can produce identical
89
+ counts, and that ratio is valid -- only the identical *object* is refused.
90
+ ``ratio_interval`` checks only object identity and ``cluster_ids``, for exactly this reason:
91
+ a full shared-data check would need an observation id that nothing in the package records
92
+ yet (deferred to v0.2's paired dependence work).
93
+ estimate
94
+ The point estimate of the ratio. Required and equal to
95
+ ``threshold_a.value / threshold_b.value`` (``math.isclose``, ``rel_tol=1e-9``) when
96
+ ``status`` is ``OK`` and ``censoring`` is ``NONE``; ``None`` otherwise.
97
+ lo, hi
98
+ The interval endpoints, read according to ``shape``, only when ``status`` is ``OK`` and
99
+ ``censoring`` is ``NONE``:
100
+
101
+ - ``BOUNDED``: ``[lo, hi]``, both set, ``lo <= hi``, and ``lo <= estimate <= hi``.
102
+ ``method="log_delta"`` additionally requires ``lo > 0``. Also covers a Fieller set
103
+ bounded above but not below on its own, intersected to ``[0, hi]``; ``HALF_OPEN`` is
104
+ never used for that case.
105
+ - ``UNBOUNDED``: both ``None`` (the whole real line, or the positive parameter space
106
+ when intersected); it carries ``estimate``.
107
+ - ``EXCLUSIVE``: both set, ``lo < hi`` strictly, meaning
108
+ ``(-inf, lo] union [hi, inf)``, and ``estimate`` lies in that union (``estimate <=
109
+ lo`` or ``estimate >= hi``) -- the point estimate always lies inside the Fieller
110
+ confidence set, never in the excluded gap. **Unreachable via ``ratio_interval`` in
111
+ v0.1**: the invariant stays enforced for a directly-constructed
112
+ ``Ratio`` and for v0.2's ``dependence="paired"``.
113
+ - ``HALF_OPEN``: ``lo`` set, ``hi`` is ``None``, meaning ``[lo, inf)``, and
114
+ ``lo <= estimate``. ``method="log_delta"`` additionally requires ``lo > 0``, as for
115
+ ``BOUNDED``. This is what a two-ray Fieller set with ``K > 0`` is once intersected
116
+ with the positive parameter space: an open region of the parameter space, reached
117
+ whenever the denominator is poorly determined and the numerator is not, not a
118
+ measure-zero boundary.
119
+
120
+ Both ``None`` otherwise.
121
+ shape
122
+ The :class:`~robustmargin.IntervalShape` of ``(lo, hi)`` when ``status`` is ``OK`` and
123
+ ``censoring`` is ``NONE``; ``None`` otherwise (including when ``status``/``censoring``
124
+ make a shape meaningless).
125
+ method
126
+ ``"fieller"`` or ``"log_delta"``. ``"log_delta"`` additionally requires, on the
127
+ ``OK`` + ``NONE`` path, that ``threshold_a.axis.scale`` and ``threshold_b.axis.scale``
128
+ are both ``"log"`` and that ``threshold_a.value`` and ``threshold_b.value`` are both
129
+ ``> 0`` (the log-scale delta method is undefined off the log axis or through zero).
130
+ dependence
131
+ ``"independent"`` in v0.1 (v0.2 adds ``"paired"``). With ``"independent"``, the
132
+ two thresholds' fits must not share a cluster id.
133
+ level
134
+ The confidence level, strictly between 0 and 1.
135
+ censoring
136
+ The :class:`~robustmargin.Censoring` propagated from the input thresholds,
137
+ direction-aware: read against the ratio itself, not against either threshold's own
138
+ tested range.
139
+ status
140
+ The :class:`~robustmargin.Status` of this ratio.
141
+ warnings
142
+ Free-text notes surfaced alongside the ratio. Empty by default. :func:`ratio_interval`
143
+ carries both ``threshold_a.warnings`` and ``threshold_b.warnings`` here too -- in
144
+ particular a ``"NON_MONOTONE_DATA: "`` entry on either input threshold propagates
145
+ through, on every path (censored/failed and the ``OK``+``NONE`` path alike).
146
+ schema_version
147
+ The serialised-result schema version this object belongs to. Defaults to ``"1"``, the
148
+ only version ``from_dict`` reads; any other version is rejected.
149
+ provenance
150
+ An opaque mapping the caller may attach to record where the inputs came from.
151
+ robustmargin stores it and never interprets it. Empty by default.
152
+ """
153
+
154
+ threshold_a: Threshold
155
+ threshold_b: Threshold
156
+ estimate: float | None
157
+ lo: float | None
158
+ hi: float | None
159
+ shape: IntervalShape | None
160
+ method: str
161
+ dependence: str
162
+ level: float
163
+ censoring: Censoring
164
+ status: Status
165
+ warnings: tuple[str, ...] = ()
166
+ schema_version: str = "1"
167
+ provenance: Mapping[str, JSONValue] = field(default_factory=dict)
168
+
169
+ def __post_init__(self) -> None:
170
+ object.__setattr__(self, "warnings", tuple(self.warnings))
171
+
172
+ if self.threshold_a.axis.name != self.threshold_b.axis.name:
173
+ raise ValueError(
174
+ "Ratio.threshold_a and .threshold_b must share an axis name, got "
175
+ f"{self.threshold_a.axis.name!r} and {self.threshold_b.axis.name!r}"
176
+ )
177
+ if self.threshold_a.axis.unit != self.threshold_b.axis.unit:
178
+ raise ValueError(
179
+ "Ratio.threshold_a and .threshold_b must share an axis unit, got "
180
+ f"{self.threshold_a.axis.unit!r} and {self.threshold_b.axis.unit!r}"
181
+ )
182
+ if self.threshold_a.definition != self.threshold_b.definition:
183
+ raise ValueError(
184
+ "Ratio.threshold_a.definition must equal Ratio.threshold_b.definition, got "
185
+ f"{self.threshold_a.definition!r} and {self.threshold_b.definition!r}"
186
+ )
187
+
188
+ if self.method not in _VALID_METHODS:
189
+ raise ValueError(
190
+ f"Ratio.method must be one of {sorted(_VALID_METHODS)}, got {self.method!r}"
191
+ )
192
+ if self.dependence not in _VALID_DEPENDENCE:
193
+ raise ValueError(
194
+ f"Ratio.dependence must be one of {sorted(_VALID_DEPENDENCE)}, "
195
+ f"got {self.dependence!r}"
196
+ )
197
+ _check_finite_number(self.level, label="Ratio.level")
198
+ if not (0.0 < self.level < 1.0):
199
+ raise ValueError(f"Ratio.level must satisfy 0 < level < 1, got {self.level!r}")
200
+
201
+ # The constructor does not check `threshold_a.fit is threshold_b.fit`. A directly
202
+ # constructed `Ratio` over one `Fit` under `dependence="independent"` is accepted,
203
+ # although its interval is wrong (that ratio is identically 1 with zero variance);
204
+ # `ratio_interval` refuses it, and every real caller goes through that function. The
205
+ # check stays out of the constructor so that the public type's accepted inputs do not
206
+ # change. The cluster-id check below is enforced here.
207
+ if self.dependence == "independent":
208
+ ids_a = self.threshold_a.fit.cluster_ids
209
+ ids_b = self.threshold_b.fit.cluster_ids
210
+ if ids_a is not None and ids_b is not None:
211
+ overlap = set(ids_a) & set(ids_b)
212
+ if overlap:
213
+ raise ValueError(
214
+ "Ratio: threshold_a.fit and threshold_b.fit share cluster id(s) "
215
+ f"{sorted(overlap, key=str)!r}, which is incompatible with "
216
+ "dependence='independent'"
217
+ )
218
+
219
+ _check_finite(self.estimate, label="estimate")
220
+ _check_finite(self.lo, label="lo")
221
+ _check_finite(self.hi, label="hi")
222
+
223
+ is_ok_none = self.status is Status.OK and self.censoring is Censoring.NONE
224
+
225
+ if not is_ok_none:
226
+ if (
227
+ self.shape is not None
228
+ or self.estimate is not None
229
+ or self.lo is not None
230
+ or self.hi is not None
231
+ ):
232
+ raise ValueError(
233
+ "Ratio.shape, .estimate, .lo and .hi must all be None when status is not "
234
+ "OK or censoring is not NONE"
235
+ )
236
+ return
237
+
238
+ if self.shape is None:
239
+ raise ValueError("Ratio.shape must be set when status is OK and censoring is NONE")
240
+
241
+ if self.threshold_a.censoring is not Censoring.NONE:
242
+ raise ValueError(
243
+ "Ratio.threshold_a.censoring must be NONE when the ratio's own status is OK "
244
+ f"and censoring is NONE, got {self.threshold_a.censoring!r}"
245
+ )
246
+ if self.threshold_b.censoring is not Censoring.NONE:
247
+ raise ValueError(
248
+ "Ratio.threshold_b.censoring must be NONE when the ratio's own status is OK "
249
+ f"and censoring is NONE, got {self.threshold_b.censoring!r}"
250
+ )
251
+
252
+ if self.threshold_a.status is not Status.OK or self.threshold_a.value is None:
253
+ raise ValueError(
254
+ "Ratio.threshold_a must have status OK with value set when the ratio's own "
255
+ "status is OK and censoring is NONE"
256
+ )
257
+ if self.threshold_b.status is not Status.OK or self.threshold_b.value is None:
258
+ raise ValueError(
259
+ "Ratio.threshold_b must have status OK with value set when the ratio's own "
260
+ "status is OK and censoring is NONE"
261
+ )
262
+
263
+ if self.estimate is None:
264
+ raise ValueError("Ratio.estimate must be set when status is OK and censoring is NONE")
265
+ estimate = self.estimate
266
+
267
+ if not self.threshold_b.value > 0.0:
268
+ raise ValueError(
269
+ f"Ratio.threshold_b.value must be > 0 to divide by, got {self.threshold_b.value!r}"
270
+ )
271
+ expected = self.threshold_a.value / self.threshold_b.value
272
+ if not math.isclose(estimate, expected, rel_tol=1e-9):
273
+ raise ValueError(
274
+ "Ratio.estimate must equal threshold_a.value / threshold_b.value "
275
+ f"({expected!r}), got {estimate!r}"
276
+ )
277
+
278
+ if self.shape is IntervalShape.BOUNDED:
279
+ if self.lo is None or self.hi is None:
280
+ raise ValueError(
281
+ "Ratio.lo and .hi must both be set when shape is BOUNDED and censoring is NONE"
282
+ )
283
+ if not self.lo <= self.hi:
284
+ raise ValueError(
285
+ f"Ratio.lo must not exceed .hi when shape is BOUNDED, got lo={self.lo!r}, "
286
+ f"hi={self.hi!r}"
287
+ )
288
+ if not (self.lo <= estimate <= self.hi):
289
+ raise ValueError(
290
+ "Ratio.estimate must lie within [lo, hi] when shape is BOUNDED, got "
291
+ f"estimate={estimate!r}, lo={self.lo!r}, hi={self.hi!r}"
292
+ )
293
+ if self.method == "log_delta" and not self.lo > 0.0:
294
+ raise ValueError(
295
+ f"Ratio.lo must be > 0 when method='log_delta', got lo={self.lo!r}"
296
+ )
297
+ elif self.shape is IntervalShape.UNBOUNDED:
298
+ if self.lo is not None or self.hi is not None:
299
+ raise ValueError(
300
+ "Ratio.lo and .hi must both be None when shape is UNBOUNDED and censoring "
301
+ "is NONE"
302
+ )
303
+ elif self.shape is IntervalShape.EXCLUSIVE:
304
+ # Unreachable through `ratio_interval` while `dependence="independent"` fixes the
305
+ # covariance term at 0: every same-signed-root case that would
306
+ # produce this shape needs a nonzero covariance, which only v0.2's
307
+ # `dependence="paired"` can supply. The invariant stays enforced -- for a `Ratio`
308
+ # built directly (as `robustmargin.testing.fake_ratio` does) and for when `paired`
309
+ # lands -- rather than being deleted along with the now-dead production path.
310
+ if self.lo is None or self.hi is None:
311
+ raise ValueError(
312
+ "Ratio.lo and .hi must both be set when shape is EXCLUSIVE and censoring "
313
+ "is NONE"
314
+ )
315
+ if not self.lo < self.hi:
316
+ raise ValueError(
317
+ "Ratio.lo must be strictly less than .hi when shape is EXCLUSIVE, got "
318
+ f"lo={self.lo!r}, hi={self.hi!r}"
319
+ )
320
+ if not (estimate <= self.lo or estimate >= self.hi):
321
+ raise ValueError(
322
+ "Ratio.estimate must lie outside (lo, hi) when shape is EXCLUSIVE, got "
323
+ f"estimate={estimate!r}, lo={self.lo!r}, hi={self.hi!r}"
324
+ )
325
+ elif self.shape is IntervalShape.HALF_OPEN:
326
+ # `[lo, inf)`: always lower-bounded, never upper-bounded -- a set bounded only
327
+ # above is reported as `BOUNDED` `[0, hi]` instead.
328
+ if self.lo is None:
329
+ raise ValueError(
330
+ "Ratio.lo must be set when shape is HALF_OPEN and censoring is NONE"
331
+ )
332
+ if self.hi is not None:
333
+ raise ValueError(
334
+ "Ratio.hi must be None when shape is HALF_OPEN and censoring is NONE, got "
335
+ f"hi={self.hi!r}"
336
+ )
337
+ if not (self.lo <= estimate):
338
+ raise ValueError(
339
+ "Ratio.estimate must be >= lo when shape is HALF_OPEN, got "
340
+ f"estimate={estimate!r}, lo={self.lo!r}"
341
+ )
342
+ if self.method == "log_delta" and not self.lo > 0.0:
343
+ raise ValueError(
344
+ f"Ratio.lo must be > 0 when method='log_delta', got lo={self.lo!r}"
345
+ )
346
+
347
+ if self.method == "log_delta":
348
+ if self.threshold_a.axis.scale != "log" or self.threshold_b.axis.scale != "log":
349
+ raise ValueError(
350
+ "Ratio.method='log_delta' requires threshold_a.axis.scale and "
351
+ "threshold_b.axis.scale to both be 'log', got "
352
+ f"{self.threshold_a.axis.scale!r} and {self.threshold_b.axis.scale!r}"
353
+ )
354
+ if not (self.threshold_a.value > 0.0 and self.threshold_b.value > 0.0):
355
+ raise ValueError(
356
+ "Ratio.method='log_delta' requires threshold_a.value and "
357
+ f"threshold_b.value to both be > 0, got {self.threshold_a.value!r} and "
358
+ f"{self.threshold_b.value!r}"
359
+ )
360
+ if self.shape not in (IntervalShape.BOUNDED, IntervalShape.HALF_OPEN):
361
+ raise ValueError(
362
+ "Ratio.method='log_delta' requires shape=BOUNDED or HALF_OPEN, got "
363
+ f"shape={self.shape!r}"
364
+ )
365
+
366
+
367
+ # ------------------------------------------------------------------------------------------
368
+ # ratio_interval(): guards, per-threshold variance, Fieller/log-delta shape, censoring
369
+ # propagation.
370
+ # ------------------------------------------------------------------------------------------
371
+
372
+ # `Censoring` flip for the denominator: a directional flag on `threshold_b`
373
+ # is re-read against the ratio in the opposite direction from the same flag on `threshold_a`.
374
+ _CENSORING_FLIP: dict[Censoring, Censoring] = {
375
+ Censoring.RIGHT: Censoring.LEFT,
376
+ Censoring.LEFT: Censoring.RIGHT,
377
+ Censoring.OPEN_UPPER: Censoring.OPEN_LOWER,
378
+ Censoring.OPEN_LOWER: Censoring.OPEN_UPPER,
379
+ }
380
+
381
+
382
+ def _theta_variance_for(t: Threshold) -> float:
383
+ """``Var(theta)`` on :mod:`robustmargin.intervals`' own transformed axis, for threshold ``t`` --
384
+ delegates the delta-method gradient assembly to ``intervals._theta_variance`` (the same
385
+ function :func:`robustmargin.intervals.delta_interval` uses for its own cross-check interval)
386
+ rather than reconstructing it here, and never reconstructs a variance by inverting ``t.lo``/
387
+ ``t.hi`` -- that fails for a profile interval (whose ``lo``/``hi`` are not delta-method
388
+ endpoints at all) and for the open- or both-sides-open cases, where one or both are ``None``.
389
+ Clamped to ``>= 0`` against floating-point noise, mirroring
390
+ ``intervals.delta_interval``'s own ``max(var_theta, 0.0)``.
391
+ """
392
+ target, _ = _resolve_target(t.definition, fit=t.fit)
393
+ return max(_theta_variance(t.fit, definition=t.definition, target=target), 0.0)
394
+
395
+
396
+ def _fieller_variance(t: Threshold, *, value: float) -> float:
397
+ """``Va``/``Vb`` in the Fieller quadratic: the *natural*-severity-scale
398
+ variance of ``t``'s point estimate (module docstring's "three different thetas" note).
399
+
400
+ Converts ``_theta_variance_for``'s transformed-scale ``Var(theta)`` via the delta method's
401
+ own chain rule for ``value = exp(theta)`` on a log axis (``Var(value) = value^2 *
402
+ Var(theta)``), or the identity on a linear axis (``value = theta`` already, so
403
+ ``Var(value) = Var(theta)`` directly). This is algebraically the same
404
+ ``Va = value^2 * sigma_log^2`` identity (``sigma_log`` being ``intervals.delta_interval``'s own
405
+ log-scale standard error) wherever ``value != 0``, but computed without ever dividing by
406
+ ``value``, so a linear-axis threshold whose value is exactly 0 gets the exact answer instead of
407
+ a 0/0 division.
408
+ """
409
+ var_theta = _theta_variance_for(t)
410
+ return value * value * var_theta if t.axis.scale == "log" else var_theta
411
+
412
+
413
+ # How far below zero `disc` may fall, relative to the scale of the two
414
+ # terms it is the difference of, before a clamp to 0.0 is worth a warning rather than treated as
415
+ # ordinary floating-point rounding. Matches the `rel_tol=1e-9` convention `Covariance` already
416
+ # uses for its own symmetry check (`models.py`).
417
+ _DISC_RELATIVE_EPS = 1e-9
418
+
419
+
420
+ def _stable_quadratic_roots(
421
+ *, a_coef: float, b_coef: float, k_coef: float, disc: float
422
+ ) -> tuple[float, float]:
423
+ """The two roots of ``A*rho^2 - 2*B*rho + K = 0`` (note the factor of 2 on ``B``, matching
424
+ this module's Fieller coefficients), computed with the substitution that avoids catastrophic
425
+ cancellation in the naive ``(B +/- sqrt(disc)) / A`` when ``|B|`` and ``sqrt(disc)`` are
426
+ close -- exactly the case at the branch boundary this module cares about (measured relative
427
+ error ``1.35e-6`` at ``A=1e-10``, ``5.6e-5`` at ``A=1e-12`` from the naive formula):
428
+ ``q = B + copysign(sqrt(disc), B)``, ``r1 = q/A``, ``r2 = K/q``. Requires
429
+ ``disc >= 0`` and ``a_coef != 0`` -- both already guaranteed by every call site.
430
+ """
431
+ sq = math.sqrt(disc)
432
+ if b_coef == 0.0 and sq == 0.0:
433
+ # A fully degenerate double root at 0 (only reachable when B and K are both exactly 0
434
+ # too, given disc = B^2 - A*K and a_coef != 0) -- the general formula below would divide
435
+ # 0.0 by 0.0 for the second root.
436
+ return 0.0, 0.0
437
+ q = b_coef + math.copysign(sq, b_coef)
438
+ return q / a_coef, k_coef / q
439
+
440
+
441
+ def _fieller_shape(
442
+ *, theta_a: float, va: float, theta_b: float, vb: float, z: float
443
+ ) -> tuple[IntervalShape, float | None, float | None, tuple[str, ...]]:
444
+ """Solve ``{rho : A*rho^2 - 2*B*rho + K <= 0}`` (module docstring) for
445
+ ``A = theta_b^2 - z^2*Vb``, ``B = theta_a*theta_b`` (``C = 0`` under
446
+ ``dependence="independent"``), ``K = theta_a^2 - z^2*Va``, then intersects the result with
447
+ the positive parameter space a severity ratio must live in.
448
+
449
+ ``A > 0``: ``BOUNDED``. The discriminant is non-negative here: the quadratic at the point
450
+ estimate is ``q(theta_a/theta_b) = -z^2 * (Va + rho_hat^2 * Vb) <= 0`` and ``A > 0``, so two
451
+ real roots bracket the estimate. Under ``C = 0`` this holds unconditionally; the guard in
452
+ the code is kept for a future estimated covariance. A lower root below zero is intersected
453
+ with the positive parameter space -- ``lo = 0.0``, the unclipped root goes in ``warnings``,
454
+ ``shape`` stays ``BOUNDED`` (the upper-only geometry never becomes ``HALF_OPEN``).
455
+
456
+ ``A < 0`` with a positive discriminant: ``K > 0`` implies ``theta_a > 0`` and hence
457
+ ``B = theta_a*theta_b > 0`` under independence, so the roots' product (``K/A``) and sum
458
+ (``2*B/A``) pin down exactly two outcomes:
459
+
460
+ - ``K > 0``: the roots are opposite-signed (product ``< 0``), so intersected with the
461
+ positive axis the set is exactly ``[hi, inf)`` -- ``HALF_OPEN`` with ``lo`` set to the
462
+ positive root (the larger of the two), **not** ``EXCLUSIVE``.
463
+ - ``K <= 0``: both roots are negative (product ``>= 0``, sum ``< 0``), so the set is exactly
464
+ ``(0, inf)`` -- ``UNBOUNDED``, exact.
465
+
466
+ ``EXCLUSIVE`` (same-signed roots with ``A < 0``) needs ``B <= 0``, i.e. a nonzero covariance
467
+ term -- unreachable through this function while ``dependence="independent"`` fixes ``C = 0``.
468
+ :class:`Ratio`'s invariants for it stay enforced against v0.2's ``dependence="paired"``.
469
+
470
+ ``A < 0`` with a non-positive discriminant: ``UNBOUNDED`` (no real roots, confidence set is
471
+ the whole line).
472
+
473
+ ``A == 0`` exactly (``g == 1``): the quadratic degenerates to the linear
474
+ ``-2*B*rho + K <= 0``, i.e. ``rho >= K/(2*B)`` when ``B > 0``. ``K > 0`` puts that boundary
475
+ strictly above zero -- ``HALF_OPEN`` with ``lo = K/(2*B)``. ``A == 0`` is itself
476
+ measure-zero, but it borders the open ``A < 0, K > 0`` region, which gives the same
477
+ half-line, so the two are reported alike. ``K <= 0`` puts the boundary at or below zero, so
478
+ the intersected set is exactly ``(0, inf)`` -- ``UNBOUNDED``.
479
+
480
+ Raises
481
+ ------
482
+ ValueError
483
+ If ``va`` or ``vb`` is not finite: a NaN variance passes none
484
+ of the sign tests above and would otherwise fall through to the ``A == 0`` branch,
485
+ reporting "``A`` is exactly 0" -- a false statement about a quantity that was never
486
+ computed to a real number in the first place.
487
+ """
488
+ if not (math.isfinite(va) and math.isfinite(vb)):
489
+ raise ValueError(
490
+ "ratio_interval: method='fieller' needs finite per-threshold variances, got "
491
+ f"Va={va!r} and Vb={vb!r}. A non-finite delta-method variance means the interval "
492
+ "shape cannot be determined -- this is a distinct failure from the A == 0 boundary "
493
+ "and is not silently routed through it"
494
+ )
495
+
496
+ a_coef = theta_b * theta_b - z * z * vb
497
+ b_coef = theta_a * theta_b
498
+ k_coef = theta_a * theta_a - z * z * va
499
+ disc = b_coef * b_coef - a_coef * k_coef
500
+
501
+ if a_coef > 0.0:
502
+ collected_warnings: list[str] = []
503
+ scale = max(abs(b_coef * b_coef), abs(a_coef * k_coef), 1.0)
504
+ if disc < -_DISC_RELATIVE_EPS * scale:
505
+ collected_warnings.append(
506
+ f"ratio_interval: the Fieller discriminant is negative ({disc!r}) in the "
507
+ "A > 0 branch, where it is mathematically guaranteed to be >= 0 (the point "
508
+ "estimate lies inside the confidence set, so the roots are real); clamped to "
509
+ "0.0 rather than "
510
+ "silently -- this may indicate numerical trouble upstream, not just "
511
+ "floating-point rounding"
512
+ )
513
+ disc_clamped = max(disc, 0.0)
514
+ r1, r2 = _stable_quadratic_roots(
515
+ a_coef=a_coef, b_coef=b_coef, k_coef=k_coef, disc=disc_clamped
516
+ )
517
+ lo, hi = sorted([r1, r2])
518
+ if lo < 0.0:
519
+ # K <= 0 puts rho=0 in the confidence set, so the lower root is below zero --
520
+ # impossible for a severity ratio. Intersect with the positive parameter space
521
+ # rather than report a negative bound. The upper-only geometry this produces stays
522
+ # BOUNDED, never HALF_OPEN: HALF_OPEN is reserved for the lower-bounded-only shape
523
+ # below.
524
+ collected_warnings.append(
525
+ "ratio_interval: the Fieller lower root is below zero "
526
+ f"(unclipped lo={lo!r}); a severity ratio cannot be negative, so the reported "
527
+ "interval is intersected with the positive parameter space (lo=0.0) -- this "
528
+ "stays a conservative superset of the true confidence set"
529
+ )
530
+ lo = 0.0
531
+ return IntervalShape.BOUNDED, lo, hi, tuple(collected_warnings)
532
+
533
+ if a_coef < 0.0 and disc > 0.0:
534
+ r1, r2 = _stable_quadratic_roots(a_coef=a_coef, b_coef=b_coef, k_coef=k_coef, disc=disc)
535
+ lo, hi = sorted([r1, r2])
536
+ if k_coef <= 0.0:
537
+ # Under `dependence="independent"` (C=0), B = theta_a*theta_b > 0, so the roots'
538
+ # product K/A >= 0 (K <= 0, A < 0) and their sum 2*B/A < 0 -- both roots share a
539
+ # sign, and that sign is negative. The right ray [hi, inf) with hi < 0 already
540
+ # contains all of (0, inf), so intersected with the positive parameter space the
541
+ # confidence set is exactly (0, inf): UNBOUNDED, exact.
542
+ warning = (
543
+ "ratio_interval: the Fieller set is two rays both lying below zero "
544
+ f"(unclipped lo={lo!r}, hi={hi!r}); a severity ratio cannot be negative, so the "
545
+ "set intersected with the positive parameter space is unconstrained on (0, inf) "
546
+ "-- reporting shape=UNBOUNDED exactly, not as a conservative superset"
547
+ )
548
+ return IntervalShape.UNBOUNDED, None, None, (warning,)
549
+ # K > 0 with A < 0 forces opposite-signed roots (product K/A < 0), so
550
+ # under independence this branch is always a half-line intersected with the positive
551
+ # axis -- [hi, inf), reported as HALF_OPEN with lo=hi (the positive root), never
552
+ # EXCLUSIVE. EXCLUSIVE needs B <= 0 (a nonzero covariance term), unreachable while C=0.
553
+ return IntervalShape.HALF_OPEN, hi, None, ()
554
+
555
+ if a_coef < 0.0:
556
+ return IntervalShape.UNBOUNDED, None, None, ()
557
+
558
+ # `a_coef == 0.0` exactly: `g == 1`. The quadratic degenerates to the linear
559
+ # `-2*B*rho + K <= 0`. When `B > 0` (independence's usual case), the solution is
560
+ # `rho >= K/(2*B)`: a genuine half-line, split on K's sign.
561
+ if b_coef > 0.0:
562
+ boundary = k_coef / (2.0 * b_coef)
563
+ if k_coef > 0.0:
564
+ # A genuine finite half-line entirely on the positive side -- HALF_OPEN with
565
+ # lo = K/2B.
566
+ return IntervalShape.HALF_OPEN, boundary, None, ()
567
+ warning = (
568
+ "ratio_interval: the Fieller quadratic's leading coefficient A is exactly 0 (g == "
569
+ f"1); the degenerate half-line's boundary (rho >= {boundary!r}) is at or below "
570
+ "zero, so the confidence set intersected with the positive parameter space is "
571
+ "exactly (0, inf) -- reporting shape=UNBOUNDED, exact rather than a lossy superset "
572
+ "for this sub-case"
573
+ )
574
+ return IntervalShape.UNBOUNDED, None, None, (warning,)
575
+
576
+ # B <= 0 cannot arise under dependence="independent" (B = theta_a*theta_b, and both values
577
+ # are non-negative by Threshold's own invariant), so this is a defensive branch matching the
578
+ # general algebra rather than a reachable v0.1 case.
579
+ if b_coef == 0.0:
580
+ # A == 0 and B == 0 degenerate the inequality to `K <= 0`, so K > 0 is an EMPTY
581
+ # confidence set -- not a half-line, and emphatically not the whole axis. Reporting
582
+ # UNBOUNDED there would be the least informative possible answer to an input that is
583
+ # actually contradictory, so it raises rather than silently falling back. Unreachable
584
+ # while dependence="independent" keeps B = theta_a*theta_b >= 0 (B == 0 forces
585
+ # theta_a == 0, hence K = -z^2*Va <= 0), but v0.2's paired covariance can reach it.
586
+ if k_coef > 0.0:
587
+ raise ValueError(
588
+ "ratio_interval: the Fieller confidence set is empty (A == 0, B == 0, "
589
+ f"K == {k_coef!r} > 0), which no IntervalShape can represent and which means "
590
+ "the inputs are mutually contradictory rather than merely uninformative"
591
+ )
592
+ note = "no finite half-line endpoint either (B == 0 too)"
593
+ elif b_coef < 0.0:
594
+ note = f"rho <= {k_coef / (2.0 * b_coef)!r}"
595
+ else:
596
+ note = f"rho >= {k_coef / (2.0 * b_coef)!r}"
597
+ warning = (
598
+ "ratio_interval: the Fieller quadratic's leading coefficient A is exactly 0 (g == 1); "
599
+ f"the true confidence set is a half-line ({note}), which IntervalShape has no member "
600
+ "for in this direction -- reporting shape=UNBOUNDED, which is exact here rather than "
601
+ "clipping it to look bounded"
602
+ )
603
+ return IntervalShape.UNBOUNDED, None, None, (warning,)
604
+
605
+
606
+ def _log_delta_shape(
607
+ *, theta_a: float, var_log_a: float, theta_b: float, var_log_b: float, z: float
608
+ ) -> tuple[float, float]:
609
+ """``log(ratio) = log(theta_a) - log(theta_b)``, variance ``Va + Vb`` (``C = 0`` under
610
+ ``dependence="independent"``), exponentiated back around the point estimate. Always
611
+ ``BOUNDED`` -- :class:`Ratio`'s own invariant requires it for ``method="log_delta"`` -- so a
612
+ half-width too large for a finite ``float`` has no shape left to fall back to and raises
613
+ instead: no number is invented, and no shape ``Ratio`` cannot represent for this method is
614
+ reported.
615
+ """
616
+ sigma_log_ratio = math.sqrt(max(var_log_a + var_log_b, 0.0))
617
+ half_width = z * sigma_log_ratio
618
+ estimate = theta_a / theta_b
619
+ if not math.isfinite(half_width) or half_width > _MAX_LOG_HALF_WIDTH:
620
+ raise ValueError(
621
+ "ratio_interval: method='log_delta' has a log-scale standard error too large for a "
622
+ f"finite interval (sigma_log_ratio={sigma_log_ratio!r}); method='log_delta' must "
623
+ "report shape=BOUNDED (Ratio's own invariant) and there is no defensible bounded "
624
+ "interval for this input -- method='fieller' can report shape=UNBOUNDED explicitly "
625
+ "instead"
626
+ )
627
+ lo = estimate * math.exp(-half_width)
628
+ hi = estimate * math.exp(half_width)
629
+ # `lo > 0.0` as well as finiteness: a half-width under _MAX_LOG_HALF_WIDTH can still
630
+ # underflow `lo` to exactly 0.0 for a small enough estimate, and 0.0 is "finite". Without
631
+ # this the failure surfaces from Ratio.__post_init__ as the log_delta lo > 0 invariant
632
+ # rather than as an explanation of what actually went wrong.
633
+ if not (math.isfinite(lo) and math.isfinite(hi) and lo > 0.0):
634
+ raise ValueError(
635
+ "ratio_interval: method='log_delta' produced a non-finite interval endpoint for "
636
+ "this input; there is no defensible bounded interval to report"
637
+ )
638
+ return lo, hi
639
+
640
+
641
+ def _propagate_censoring(
642
+ t_a: Threshold, t_b: Threshold
643
+ ) -> tuple[Status, Censoring, tuple[str, ...]]:
644
+ """The ``(Status, Censoring)`` a :class:`Ratio` carries when at least one input threshold is
645
+ not ``(OK, NONE)``: a flag, never a number.
646
+
647
+ **Status.** ``Status.OK`` only when both inputs are ``OK`` (a ``(OK, RIGHT)`` threshold --
648
+ a converged fit whose bound is censored -- is an ``OK`` input). When exactly one input has a
649
+ failure status, the ratio carries it. When both do, and they **differ**, the ratio carries
650
+ ``threshold_a``'s and records both in ``warnings``; no severity ordering over ``Status`` is
651
+ invented to pick a "worse" one -- none exists anywhere in this package.
652
+
653
+ **Censoring.** Direction-aware: a numerator's directional flag
654
+ (``RIGHT``/``LEFT``/``OPEN_UPPER``/``OPEN_LOWER``) carries straight across; a denominator's
655
+ flips (a larger denominator pushes the ratio *down*, and vice versa). When both carry a
656
+ directional flag, there is no coherent direction; ``threshold_a``'s is reported, with a
657
+ warning naming the ambiguity. This is the same ``threshold_a``-wins tie-break as ``Status``
658
+ above, applied to the other field, so the two never disagree about which input won.
659
+ """
660
+ collected_warnings: list[str] = []
661
+
662
+ a_failed = t_a.status is not Status.OK
663
+ b_failed = t_b.status is not Status.OK
664
+ if a_failed and b_failed:
665
+ status = t_a.status
666
+ if t_a.status != t_b.status:
667
+ collected_warnings.append(
668
+ "ratio_interval: threshold_a and threshold_b have different failure statuses "
669
+ f"(threshold_a={t_a.status!s}, threshold_b={t_b.status!s}); threshold_a's "
670
+ "status is reported"
671
+ )
672
+ elif a_failed:
673
+ status = t_a.status
674
+ elif b_failed:
675
+ status = t_b.status
676
+ else:
677
+ status = Status.OK
678
+
679
+ a_dir = t_a.censoring
680
+ b_dir = t_b.censoring
681
+ a_has_direction = a_dir is not Censoring.NONE
682
+ b_has_direction = b_dir is not Censoring.NONE
683
+ if a_has_direction and b_has_direction:
684
+ censoring = a_dir
685
+ collected_warnings.append(
686
+ "ratio_interval: both threshold_a and threshold_b carry censoring "
687
+ f"(threshold_a={a_dir!s}, threshold_b={b_dir!s}); there is no coherent direction "
688
+ f"for the ratio's bound, so threshold_a's flag ({a_dir!s}) is reported"
689
+ )
690
+ elif a_has_direction:
691
+ censoring = a_dir
692
+ elif b_has_direction:
693
+ censoring = _CENSORING_FLIP[b_dir]
694
+ else:
695
+ censoring = Censoring.NONE
696
+
697
+ return status, censoring, tuple(collected_warnings)
698
+
699
+
700
+ def ratio_interval(
701
+ t_a: Threshold,
702
+ t_b: Threshold,
703
+ /,
704
+ *,
705
+ method: str,
706
+ dependence: str,
707
+ level: float | None = None,
708
+ ) -> Ratio:
709
+ """The ratio of two thresholds' severities, ``t_a / t_b``, on the same axis, with a
710
+ confidence interval.
711
+
712
+ ``method="fieller"`` solves the classic Fieller quadratic on the natural severity scale;
713
+ ``method="log_delta"`` uses the log-scale delta method and additionally requires, once both
714
+ inputs are known to be ``(OK, NONE)``, that both axes' ``scale`` are ``"log"`` and both
715
+ thresholds' ``value`` are ``> 0``. Both use the normal quantile ``z``, never a Student-t
716
+ quantile, matching the rest of robustmargin -- R ``drc``'s own
717
+ ``EDcomp(interval="fieller")`` uses ``qt`` instead, which is why its interval endpoints (not
718
+ its point estimates) do not match robustmargin's at the usual reference tolerance
719
+ (the reference comparison at
720
+ https://github.com/robustmargin/robustmargin/blob/main/tests/reference/test_ratio_drc_edcomp.py).
721
+
722
+ ``IntervalShape.EXCLUSIVE`` is **unreachable** through this function while
723
+ ``dependence="independent"``: a two-ray Fieller set turns out, once intersected with the
724
+ positive parameter space, to always be either the half-line ``[hi, inf)`` (reported as
725
+ ``IntervalShape.HALF_OPEN``; an open region of the parameter space, not a measure-zero
726
+ boundary -- how often it occurs is a property of the design's power) or the whole positive
727
+ axis (``UNBOUNDED``) -- never a genuine two-ray exclusion, which needs a nonzero covariance
728
+ term that only v0.2's ``dependence="paired"`` can supply. ``EXCLUSIVE``'s :class:`Ratio`
729
+ invariants stay enforced against that future, but no call through this function reaches them
730
+ today.
731
+
732
+ Parameters
733
+ ----------
734
+ t_a, t_b
735
+ The two thresholds, ``a / b``. Must share ``axis.name``, ``axis.unit`` and
736
+ ``definition``; under ``dependence="independent"``, their fits must not share a cluster
737
+ id. All three are :class:`Ratio`'s own ``__post_init__`` invariants and are not
738
+ duplicated here -- they fire when the resulting :class:`Ratio` is constructed, on every
739
+ path through this function.
740
+ method
741
+ ``"fieller"`` or ``"log_delta"``.
742
+ dependence
743
+ ``"independent"`` only, in this release. ``"paired"`` raises, naming that it is v0.2
744
+ work (mirrors :func:`robustmargin.threshold`'s own guard for the same request).
745
+ Under ``"independent"``, ``t_a.fit`` and ``t_b.fit`` must not be the same object and
746
+ must not share a cluster id -- both checks are necessary but **not sufficient** for
747
+ actual independence.
748
+
749
+ **This function does not verify independence; establishing it is the caller's
750
+ responsibility.** Nothing in robustmargin records an observation id (only
751
+ :attr:`~robustmargin.Fit.cluster_ids`, one entry per distinct cluster, not per row), so two
752
+ fits built from overlapping rows that carry no cluster ids are accepted here and produce
753
+ a ``Ratio`` whose stated coverage is wrong -- silently, because there is nothing left in
754
+ the package to check it against. This is a documented limitation, not a guarantee this
755
+ function makes.
756
+ level
757
+ ``None`` (the default) inherits the confidence level from ``t_a``/``t_b``, which must
758
+ agree; an explicit ``level`` that disagrees with either also raises -- there is no
759
+ silently-assumed default.
760
+
761
+ Returns
762
+ -------
763
+ Ratio
764
+ If either input is censored or has a failed :class:`~robustmargin.Status`, the returned
765
+ ``Ratio`` carries a propagated status/censoring flag and no numbers at all -- never a
766
+ one-sided bound built by mixing an exact censoring bound in one threshold with a
767
+ model-fitted value and its sampling error in the other. ``warnings`` always carries both
768
+ ``t_a.warnings`` and ``t_b.warnings``, whichever path is taken.
769
+
770
+ Raises
771
+ ------
772
+ ValueError
773
+ On any of: ``t_a.fit is t_b.fit`` under ``dependence="independent"`` (checked directly
774
+ by this function); mismatched axis name/unit, mismatched ``definition``, or shared cluster
775
+ ids under ``dependence="independent"`` (via :class:`Ratio`'s own validation, once this
776
+ function reaches the point of constructing one); ``method`` outside
777
+ ``{"fieller", "log_delta"}``; ``dependence`` anything other than ``"independent"``
778
+ (``"paired"`` gets its own message, naming it v0.2 work); ``level`` outside ``(0, 1)``, or a
779
+ ``level`` mismatch; ``threshold_b.value == 0`` (nothing to divide by); for
780
+ ``method="log_delta"`` once both inputs are ``(OK, NONE)``, either axis not ``scale="log"``
781
+ or either ``value`` not ``> 0``; for ``method="log_delta"``, a combined log-scale standard
782
+ error too large to exponentiate finitely; or, for ``method="fieller"``, a non-finite
783
+ per-threshold variance.
784
+ """
785
+ if method not in _VALID_METHODS:
786
+ raise ValueError(
787
+ f"ratio_interval: method must be one of {sorted(_VALID_METHODS)}, got {method!r}"
788
+ )
789
+ if dependence == "paired":
790
+ raise ValueError(
791
+ "ratio_interval: dependence='paired' is not implemented in this release; pass "
792
+ "dependence='independent' explicitly to acknowledge independence is being assumed "
793
+ "anyway, or wait for a future release that adds it"
794
+ )
795
+ if dependence not in _VALID_DEPENDENCE:
796
+ raise ValueError(
797
+ "ratio_interval: dependence must be 'independent' (v0.2 will add 'paired'), got "
798
+ f"{dependence!r}"
799
+ )
800
+
801
+ # The shared-`Fit`-object check lives here, in the function, rather than in
802
+ # `Ratio.__post_init__` where the cluster-id check below (delegated to `Ratio`'s own
803
+ # construction) lives. `Ratio`'s constructor does not check it, so that the public type's
804
+ # accepted inputs do not change; every real caller goes through this function (see the
805
+ # matching comment in `Ratio.__post_init__`).
806
+ if dependence == "independent" and t_a.fit is t_b.fit:
807
+ raise ValueError(
808
+ "ratio_interval: threshold_a.fit and threshold_b.fit are the very same Fit object, "
809
+ "which is incompatible with dependence='independent'. This is an object-identity "
810
+ "check only, not a value-equality one: two disjoint samples may legitimately "
811
+ "produce an equal Fit by value, and that ratio is valid."
812
+ )
813
+
814
+ if level is None:
815
+ if t_a.level != t_b.level:
816
+ raise ValueError(
817
+ "ratio_interval: level=None inherits the confidence level from threshold_a and "
818
+ f"threshold_b, which must agree; got threshold_a.level="
819
+ f"{t_a.level!r} and threshold_b.level={t_b.level!r}"
820
+ )
821
+ resolved_level = t_a.level
822
+ else:
823
+ _check_finite_number(level, label="ratio_interval level")
824
+ if not (0.0 < level < 1.0):
825
+ raise ValueError(f"ratio_interval: level must satisfy 0 < level < 1, got {level!r}")
826
+ if level != t_a.level or level != t_b.level:
827
+ raise ValueError(
828
+ f"ratio_interval: level={level!r} does not match threshold_a.level "
829
+ f"({t_a.level!r}) and/or threshold_b.level ({t_b.level!r})"
830
+ )
831
+ resolved_level = level
832
+
833
+ # Carry both inputs' own warnings (for example a "NON_MONOTONE_DATA: " token from either
834
+ # threshold) onto the ratio, on every path below -- the censored/failed early return and the
835
+ # OK+NONE path alike.
836
+ collected_warnings: list[str] = [*t_a.warnings, *t_b.warnings]
837
+
838
+ a_clean = t_a.status is Status.OK and t_a.censoring is Censoring.NONE
839
+ b_clean = t_b.status is Status.OK and t_b.censoring is Censoring.NONE
840
+ if not (a_clean and b_clean):
841
+ propagated_status, propagated_censoring, propagation_warnings = _propagate_censoring(
842
+ t_a, t_b
843
+ )
844
+ collected_warnings.extend(propagation_warnings)
845
+ return Ratio(
846
+ threshold_a=t_a,
847
+ threshold_b=t_b,
848
+ estimate=None,
849
+ lo=None,
850
+ hi=None,
851
+ shape=None,
852
+ method=method,
853
+ dependence=dependence,
854
+ level=resolved_level,
855
+ censoring=propagated_censoring,
856
+ status=propagated_status,
857
+ warnings=tuple(collected_warnings),
858
+ )
859
+
860
+ value_a, value_b = t_a.value, t_b.value
861
+ assert value_a is not None and value_b is not None # `a_clean`/`b_clean` guarantee this
862
+
863
+ # `(Status.OK, Censoring.NONE)` with `lo = hi = None` is a legal `Threshold` -- a converged
864
+ # fit whose profile crossed the target on neither side (`intervals.py`'s own documented
865
+ # "open on both sides" outcome). Nothing else about this function would ever surface that:
866
+ # the delta-method variance used below does not come from `t_a.lo`/`t_a.hi` at all, so a
867
+ # ratio built on such an input looks exactly like an ordinary one unless it is named
868
+ # explicitly here (no silent fallback). `t_a.warnings`/`t_b.warnings` are not repr'd into
869
+ # this message -- `collected_warnings` already starts with both inputs' own warnings in
870
+ # full, so embedding them again here would just duplicate them.
871
+ if t_a.lo is None and t_a.hi is None:
872
+ collected_warnings.append(
873
+ "ratio_interval: threshold_a has status=OK and censoring=NONE but reports no "
874
+ "interval at all (lo and hi are both None); this ratio's variance still comes from "
875
+ "threshold_a's own delta-method covariance"
876
+ )
877
+ if t_b.lo is None and t_b.hi is None:
878
+ collected_warnings.append(
879
+ "ratio_interval: threshold_b has status=OK and censoring=NONE but reports no "
880
+ "interval at all (lo and hi are both None); this ratio's variance still comes from "
881
+ "threshold_b's own delta-method covariance"
882
+ )
883
+
884
+ if method == "log_delta":
885
+ if t_a.axis.scale != "log" or t_b.axis.scale != "log":
886
+ raise ValueError(
887
+ "ratio_interval: method='log_delta' requires threshold_a.axis.scale and "
888
+ "threshold_b.axis.scale to both be 'log', got "
889
+ f"{t_a.axis.scale!r} and {t_b.axis.scale!r}"
890
+ )
891
+ # `axis.scale == "log"` already forces `value > 0` (`Threshold`'s own invariant), so this
892
+ # is redundant given the check above -- kept anyway, both to mirror `Ratio`'s own
893
+ # invariant exactly and to fail explicitly rather than rely on that invariant continuing
894
+ # to hold if it is ever loosened.
895
+ if not (value_a > 0.0 and value_b > 0.0):
896
+ raise ValueError(
897
+ "ratio_interval: method='log_delta' requires threshold_a.value and "
898
+ f"threshold_b.value to both be > 0, got {value_a!r} and {value_b!r}"
899
+ )
900
+ elif not value_b > 0.0:
901
+ # Fieller divides by threshold_b.value. A log axis already guarantees `> 0`; a linear
902
+ # axis only guarantees `>= 0` (`Threshold`'s own invariant), so this is the one case
903
+ # Fieller itself must refuse rather than crash on with a raw ZeroDivisionError.
904
+ raise ValueError(
905
+ f"ratio_interval: threshold_b.value must be > 0 to divide by, got {value_b!r}"
906
+ )
907
+
908
+ z = float(norm.ppf(0.5 + resolved_level / 2.0))
909
+ estimate = value_a / value_b
910
+
911
+ if method == "fieller":
912
+ va = _fieller_variance(t_a, value=value_a)
913
+ vb = _fieller_variance(t_b, value=value_b)
914
+ shape, lo, hi, shape_warnings = _fieller_shape(
915
+ theta_a=value_a, va=va, theta_b=value_b, vb=vb, z=z
916
+ )
917
+ collected_warnings.extend(shape_warnings)
918
+ else:
919
+ # log_delta: both axes are confirmed 'log' above, so `_theta_variance_for` already IS
920
+ # `Var(log(value))` -- no natural-scale detour needed (module docstring).
921
+ var_log_a = _theta_variance_for(t_a)
922
+ var_log_b = _theta_variance_for(t_b)
923
+ lo, hi = _log_delta_shape(
924
+ theta_a=value_a, var_log_a=var_log_a, theta_b=value_b, var_log_b=var_log_b, z=z
925
+ )
926
+ shape = IntervalShape.BOUNDED
927
+
928
+ return Ratio(
929
+ threshold_a=t_a,
930
+ threshold_b=t_b,
931
+ estimate=estimate,
932
+ lo=lo,
933
+ hi=hi,
934
+ shape=shape,
935
+ method=method,
936
+ dependence=dependence,
937
+ level=resolved_level,
938
+ censoring=Censoring.NONE,
939
+ status=Status.OK,
940
+ warnings=tuple(collected_warnings),
941
+ )