Source code for spindoctor.nav_technique.confidence

"""Shared sigmoid-of-linear-combination confidence formula.

Every NavTechnique converts its ``NavTechniqueDiagnostics`` into a
calibrated [0, 1] confidence using the same shape:

    confidence = sigmoid(alpha0 + sum_i alpha_i * normalize_i(x_i))

where ``normalize_i`` applies the spec's ``offset`` -> ``divisor`` -> ``cap_at``
transformation.  Hard-zero gates (e.g. ``at_edge=True``) force confidence
to 0; an optional ``hard_cap`` clamps the result.

The math lives here so techniques' YAML formula specs can be evaluated
uniformly and a config-load validation pass can verify every spec at
startup.
"""

import math
from dataclasses import dataclass, field
from typing import Any

__all__ = [
    'ConfidenceBreakdown',
    'ConfidenceSpec',
    'ConfidenceTerm',
    'ConfidenceTermContribution',
    'evaluate_sigmoid_combination',
]

_NumberType = (int, float)


[docs] @dataclass(frozen=True) class ConfidenceTerm: """One term in the sigmoid-of-linear-combination formula. Parameters: feature: Name of the diagnostic-dataclass attribute supplying the raw value. alpha: Linear-combination coefficient. offset: Subtracted from the raw value before scaling. divisor: Raw value is divided by this after offset (default 1). Must be non-zero. cap_at: Optional upper bound on the post-scale value (clamped to ``[0, cap_at]`` if set). When set, must lie in ``[0, 1]``. Raises: TypeError: if ``alpha``, ``offset``, ``divisor``, or ``cap_at`` is not numeric. ValueError: if ``divisor`` is zero or ``cap_at`` is outside ``[0, 1]``. """ feature: str alpha: float offset: float = 0.0 divisor: float = 1.0 cap_at: float | None = None
[docs] def __post_init__(self) -> None: """Validate numeric types and divisor / cap_at ranges.""" if not isinstance(self.feature, str): raise TypeError( f'ConfidenceTerm.feature must be str; got {type(self.feature).__name__}' ) if not self.feature.strip(): raise ValueError('ConfidenceTerm.feature must be a non-empty string') for name in ('alpha', 'offset', 'divisor'): value = getattr(self, name) if isinstance(value, bool) or not isinstance(value, _NumberType): raise TypeError( f'ConfidenceTerm.{name} must be numeric; got {type(value).__name__}' ) if not math.isfinite(value): raise ValueError(f'ConfidenceTerm.{name} must be finite; got {value!r}') if self.divisor == 0.0: raise ValueError('ConfidenceTerm.divisor must be non-zero') if self.cap_at is not None: if isinstance(self.cap_at, bool) or not isinstance(self.cap_at, _NumberType): raise TypeError( f'ConfidenceTerm.cap_at must be numeric or None; got ' f'{type(self.cap_at).__name__}' ) if not math.isfinite(self.cap_at): raise ValueError(f'ConfidenceTerm.cap_at must be finite; got {self.cap_at!r}') if not 0.0 <= self.cap_at <= 1.0: raise ValueError(f'ConfidenceTerm.cap_at must lie in [0, 1]; got {self.cap_at!r}')
[docs] @dataclass(frozen=True) class ConfidenceSpec: """Full confidence formula spec for one NavTechnique. Parameters: alpha0: Constant term in the sigmoid argument. terms: Tuple of ``ConfidenceTerm`` linear contributions. hard_zero_if: Mapping of diagnostic-attribute names (str) to expected boolean values; if any condition holds, confidence = 0. hard_cap: Optional upper-bound clamp applied after the sigmoid. When set, must lie in ``[0, 1]``. Raises: TypeError: if ``alpha0`` is not numeric, ``terms`` is not a tuple of ``ConfidenceTerm``, or ``hard_zero_if`` is not a mapping of ``str`` to ``bool``. ValueError: if ``hard_cap`` is outside ``[0, 1]``. """ alpha0: float terms: tuple[ConfidenceTerm, ...] = () hard_zero_if: dict[str, bool] = field(default_factory=dict) hard_cap: float | None = None
[docs] def __post_init__(self) -> None: """Validate types and ranges of every field.""" if isinstance(self.alpha0, bool) or not isinstance(self.alpha0, _NumberType): raise TypeError( f'ConfidenceSpec.alpha0 must be numeric; got {type(self.alpha0).__name__}' ) if not isinstance(self.terms, tuple): raise TypeError( f'ConfidenceSpec.terms must be a tuple; got {type(self.terms).__name__}' ) for term in self.terms: if not isinstance(term, ConfidenceTerm): raise TypeError( 'ConfidenceSpec.terms entries must be ConfidenceTerm; ' f'got {type(term).__name__}' ) if not isinstance(self.hard_zero_if, dict): raise TypeError( 'ConfidenceSpec.hard_zero_if must be a dict; ' f'got {type(self.hard_zero_if).__name__}' ) for key, value in self.hard_zero_if.items(): if not isinstance(key, str): raise TypeError( f'ConfidenceSpec.hard_zero_if keys must be str; got {type(key).__name__}' ) if not isinstance(value, bool): raise TypeError( f'ConfidenceSpec.hard_zero_if[{key!r}] must be bool; got {type(value).__name__}' ) # Defensive shallow copy so the caller cannot mutate the # frozen dataclass's hard_zero_if dict after construction. object.__setattr__(self, 'hard_zero_if', dict(self.hard_zero_if)) if self.hard_cap is not None: if isinstance(self.hard_cap, bool) or not isinstance(self.hard_cap, _NumberType): raise TypeError( f'ConfidenceSpec.hard_cap must be numeric or None; got ' f'{type(self.hard_cap).__name__}' ) if not 0.0 <= self.hard_cap <= 1.0: raise ValueError( f'ConfidenceSpec.hard_cap must lie in [0, 1]; got {self.hard_cap!r}' )
def _sigmoid(x: float) -> float: """Numerically-stable logistic sigmoid.""" if x >= 0: z = math.exp(-x) return 1.0 / (1.0 + z) z = math.exp(x) return z / (1.0 + z) def _normalize(value: float, term: ConfidenceTerm) -> float: """Apply offset / divisor / cap_at to a raw diagnostic value.""" scaled = (value - term.offset) / term.divisor if term.cap_at is not None: if scaled < 0.0: scaled = 0.0 elif scaled > term.cap_at: scaled = term.cap_at return scaled
[docs] @dataclass(frozen=True) class ConfidenceTermContribution: """One term's contribution to the sigmoid argument. Parameters: feature: The diagnostic-attribute name. raw: The raw value read off the diagnostics object. normalized: ``raw`` after offset / divisor / cap_at. alpha: Linear coefficient applied. contribution: ``alpha * normalized`` — the term's signed contribution to the sigmoid argument. """ feature: str raw: float normalized: float alpha: float contribution: float
[docs] @dataclass(frozen=True) class ConfidenceBreakdown: """Per-step trace of a sigmoid-combination evaluation. Parameters: confidence: Final ``[0, 1]`` confidence after sigmoid + hard_cap. sigmoid_arg: The argument fed into the sigmoid (sum of alpha0 + term contributions). alpha0: The constant baseline term. terms: Per-term contributions. hard_zero: Name of the ``hard_zero_if`` attribute that fired, or ``None`` when no hard-zero gate applied. hard_cap_applied: True if the final sigmoid value was clamped by ``spec.hard_cap``. """ confidence: float sigmoid_arg: float alpha0: float terms: tuple[ConfidenceTermContribution, ...] hard_zero: str | None hard_cap_applied: bool
[docs] def evaluate_sigmoid_combination( spec: ConfidenceSpec, diagnostics: Any, *, technique_name: str = '', return_breakdown: bool = False, ) -> Any: """Evaluate the spec's sigmoid formula against a diagnostics object. Parameters: spec: The technique's confidence formula spec. diagnostics: Diagnostics dataclass instance for the technique. technique_name: Optional human-readable identifier used in error messages. return_breakdown: When True, return a tuple ``(confidence, ConfidenceBreakdown)`` so callers can log a per-term explanation of low / zero confidence values. Returns: Calibrated confidence in ``[0, 1]``, or ``(confidence, ConfidenceBreakdown)`` when ``return_breakdown=True``. Raises: ValueError: if a term references an attribute that does not exist on the diagnostics object. The message names the missing attribute and the technique. """ contributions: list[ConfidenceTermContribution] = [] # Hard-zero gates first: short-circuit if any condition holds. for attr_name, required in spec.hard_zero_if.items(): if not hasattr(diagnostics, attr_name): raise ValueError( f'confidence spec {technique_name!r}: hard_zero_if attribute ' f'{attr_name!r} not found on diagnostics ' f'{type(diagnostics).__name__}' ) actual = getattr(diagnostics, attr_name) if bool(actual) == bool(required): if not return_breakdown: return 0.0 breakdown = ConfidenceBreakdown( confidence=0.0, sigmoid_arg=float('nan'), alpha0=spec.alpha0, terms=tuple(contributions), hard_zero=attr_name, hard_cap_applied=False, ) return 0.0, breakdown # Linear-combination of normalized terms. arg = spec.alpha0 for term in spec.terms: if not hasattr(diagnostics, term.feature): raise ValueError( f'confidence spec {technique_name!r}: feature attribute ' f'{term.feature!r} not found on diagnostics ' f'{type(diagnostics).__name__}' ) raw = float(getattr(diagnostics, term.feature)) normalized = _normalize(raw, term) contribution = term.alpha * normalized arg += contribution contributions.append( ConfidenceTermContribution( feature=term.feature, raw=raw, normalized=normalized, alpha=term.alpha, contribution=contribution, ) ) confidence = _sigmoid(arg) hard_cap_applied = False if spec.hard_cap is not None and confidence > spec.hard_cap: confidence = spec.hard_cap hard_cap_applied = True if not return_breakdown: return confidence breakdown = ConfidenceBreakdown( confidence=confidence, sigmoid_arg=arg, alpha0=spec.alpha0, terms=tuple(contributions), hard_zero=None, hard_cap_applied=hard_cap_applied, ) return confidence, breakdown