From d2a6fb45be4bc73df776d46e880e3ae51ce91e34 Mon Sep 17 00:00:00 2001 From: Stanislas Laurent Date: Tue, 7 Jul 2026 21:33:31 +0200 Subject: [PATCH 1/5] refactor(transparency): always run visualiser semantic gate; drop shap/captum name sniff (refs #318) --- src/raitap/transparency/factory.py | 18 ------------------ src/raitap/transparency/phase.py | 1 - 2 files changed, 19 deletions(-) diff --git a/src/raitap/transparency/factory.py b/src/raitap/transparency/factory.py index 303841af..7ac8872f 100644 --- a/src/raitap/transparency/factory.py +++ b/src/raitap/transparency/factory.py @@ -21,8 +21,6 @@ explainer_output_kind, ) from .exceptions import PayloadVisualiserIncompatibilityError, VisualiserIncompatibilityError -from .explainers.captum_explainer import CaptumExplainer -from .explainers.shap_explainer import ShapExplainer from .results import ConfiguredVisualiser from .semantics import explainer_capability @@ -111,14 +109,10 @@ def check_explainer_visualiser_payload_compat( def check_explainer_visualiser_semantic_compat( explainer: object, - explainer_target: str, visualisers: list[ConfiguredVisualiser], *, task_kind: TaskKind | None = None, ) -> None: - if not _requires_registry_semantics(explainer, explainer_target): - return - capability = explainer_capability(explainer, task_kind=task_kind) for configured in visualisers: @@ -198,18 +192,6 @@ def check_explainer_visualiser_compat( ) -def _requires_registry_semantics(explainer: object, explainer_target: str) -> bool: - target = explainer_target.lower() - class_name = type(explainer).__name__.lower() - if "shap" in target or "captum" in target or "shap" in class_name or "captum" in class_name: - return True - algorithm = str(getattr(explainer, "algorithm", "")) - return ( - algorithm in ShapExplainer.algorithm_registry - or algorithm in CaptumExplainer.algorithm_registry - ) - - def _enum_frozenset(value: object, enum_type: type[Any]) -> frozenset[Any]: if value is None: return frozenset() diff --git a/src/raitap/transparency/phase.py b/src/raitap/transparency/phase.py index f7e9a143..c52dfa1f 100644 --- a/src/raitap/transparency/phase.py +++ b/src/raitap/transparency/phase.py @@ -190,7 +190,6 @@ def prepare_explainer( check_explainer_visualiser_payload_compat(explainer, explainer_target, viz_list) check_explainer_visualiser_semantic_compat( explainer, - explainer_target, viz_list, task_kind=backend.task_kind, ) From 2e61a7ac0964d323795beb978b68fcc4dc513a73 Mon Sep 17 00:00:00 2001 From: Stanislas Laurent Date: Tue, 7 Jul 2026 21:34:21 +0200 Subject: [PATCH 2/5] refactor(transparency): resolve method families via own algorithm_registry only (refs #318) --- src/raitap/transparency/semantics.py | 152 ++---------------- .../transparency/tests/test_semantics.py | 79 +-------- 2 files changed, 25 insertions(+), 206 deletions(-) diff --git a/src/raitap/transparency/semantics.py b/src/raitap/transparency/semantics.py index e3688da5..42d45d87 100644 --- a/src/raitap/transparency/semantics.py +++ b/src/raitap/transparency/semantics.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Mapping, Sequence -from collections.abc import Set as AbstractSet from typing import Any from raitap.reproducibility import ( @@ -31,44 +30,21 @@ def method_families_for_explainer(explainer: object) -> frozenset[MethodFamily]: """Return the explicit method-family mapping for a configured explainer. - Resolution order: - - 1. ``type(explainer).algorithm_registry`` — required class-body ClassVar - on every transparency adapter (validated at decoration time by - ``@adapters.transparency``'s required kwarg). - 2. Framework label on the explainer (``framework="shap"`` / - ``"captum"``) → matching adapter class's registry. - 3. Cross-framework algorithm-name lookup (used by minimal test stubs - and config-only entry points where only the algorithm string is - known). + Reads the adapter's own ``algorithm_registry`` ClassVar — a required + class-body attribute on every transparency adapter, validated at decoration + time by ``@adapters.transparency``. Mirrors ``robustness.hints_for_assessor``. """ - algorithm = _explainer_algorithm(explainer) - - cls_registry = getattr(type(explainer), "algorithm_registry", None) - if isinstance(cls_registry, Mapping) and algorithm in cls_registry: - return cls_registry[algorithm].families - - framework = _explainer_framework(explainer) - if framework is not None: - adapter_cls = _adapter_class_for_framework(framework) - if adapter_cls is not None: - registry = adapter_cls.algorithm_registry - if algorithm in registry: - return registry[algorithm].families - raise _method_family_error(framework, algorithm) - - # No framework hint: try unique cross-framework match via algorithm name. - try: - return _method_families_for_algorithm(algorithm) - except ValueError: - raise _method_family_error(type(explainer).__name__, algorithm) from None + registry = getattr(type(explainer), "algorithm_registry", None) + if isinstance(registry, Mapping) and algorithm in registry: + return registry[algorithm].families + raise _method_family_error(type(explainer).__name__, algorithm) def explainer_seeding(explainer: object) -> Seeding: """Resolve the configured explainer's RNG classification (issue #339). - Same 3-tier lookup as :func:`method_families_for_explainer`; never raises. + Reads the adapter's own ``algorithm_registry``; never raises. Any unresolved case (stubs, unknown algorithm) defaults to ``deterministic`` — a missing declaration must never over-claim stochasticity. """ @@ -82,59 +58,9 @@ def _hints_for_explainer(explainer: object) -> ExplainerAlgorithmSpec | None: algorithm = _explainer_algorithm(explainer) except RaitapError: return None - - cls_registry = getattr(type(explainer), "algorithm_registry", None) - if isinstance(cls_registry, Mapping) and algorithm in cls_registry: - return cls_registry[algorithm] - - framework = _explainer_framework(explainer) - if framework is not None: - adapter_cls = _adapter_class_for_framework(framework) - if adapter_cls is not None and algorithm in adapter_cls.algorithm_registry: - return adapter_cls.algorithm_registry[algorithm] - - matches = [] - from raitap.transparency.explainers.captum_explainer import CaptumExplainer - from raitap.transparency.explainers.shap_explainer import ShapExplainer - - for adapter_cls in (ShapExplainer, CaptumExplainer): - registry = adapter_cls.algorithm_registry - if algorithm in registry: - matches.append(registry[algorithm]) - return matches[0] if len(matches) == 1 else None - - -def _adapter_class_for_framework(framework: str) -> type | None: - """Map a framework label (``"SHAP"`` / ``"Captum"``) to its adapter class. - - Imported lazily to avoid a circular ``transparency.semantics`` ↔ explainer - module dependency at import time. - """ - label = _normalise_framework(framework) - # Absolute imports so the ``test_semantics`` shadow-package loader - # (which uses a ``_raitap_transparency_semantics_under_test`` package - # alias) still finds the canonical adapter classes. - if label == "SHAP": - from raitap.transparency.explainers.shap_explainer import ShapExplainer - - return ShapExplainer - if label == "Captum": - from raitap.transparency.explainers.captum_explainer import CaptumExplainer - - return CaptumExplainer - return None - - -def _explainer_framework(explainer: object) -> str | None: - raw = getattr(explainer, "framework", None) or getattr(explainer, "explainer_framework", None) - if raw is not None: - return _normalise_framework(str(raw)) - class_name = type(explainer).__name__ - module = type(explainer).__module__.lower() - if class_name == "ShapExplainer" or "shap" in module: - return "SHAP" - if class_name == "CaptumExplainer" or "captum" in module: - return "Captum" + registry = getattr(type(explainer), "algorithm_registry", None) + if isinstance(registry, Mapping) and algorithm in registry: + return registry[algorithm] return None @@ -200,7 +126,6 @@ def infer_output_space( input_spec: InputSpec, attributions: object | None = None, explainer: object | None = None, - algorithm: str | None = None, method_families: frozenset[MethodFamily] | None = None, layer_path: str | None = None, feature_names: Sequence[str] | None = None, @@ -228,7 +153,6 @@ def infer_output_space( resolved_method_families = _resolve_method_families( method_families=method_families, explainer=explainer, - algorithm=algorithm, ) shape = _shape_tuple(getattr(attributions, "shape", None)) @@ -321,67 +245,25 @@ def _resolve_method_families( *, method_families: frozenset[MethodFamily] | None, explainer: object | None, - algorithm: str | None, ) -> frozenset[MethodFamily]: if method_families is not None: return method_families - if algorithm is not None: - return _method_families_for_algorithm(algorithm) if explainer is not None: return method_families_for_explainer(explainer) return frozenset() -def _method_families_for_algorithm(algorithm: str) -> frozenset[MethodFamily]: - """Cross-framework lookup: scan all known adapter classes for ``algorithm``. - - Used when callers pass an algorithm string without an explainer instance. - Ambiguity (algorithm name registered by >1 adapter) is rejected so the - caller has to disambiguate via an explicit ``explainer=`` argument. - """ - from raitap.transparency.explainers.captum_explainer import CaptumExplainer - from raitap.transparency.explainers.shap_explainer import ShapExplainer - - matches: list[tuple[str, AbstractSet[MethodFamily]]] = [] - for label, adapter_cls in (("SHAP", ShapExplainer), ("Captum", CaptumExplainer)): - registry = adapter_cls.algorithm_registry - if algorithm in registry: - matches.append((label, registry[algorithm].families)) - - if len(matches) == 1: - return frozenset(matches[0][1]) - if not matches: - raise _method_family_error("", algorithm) - - frameworks = ", ".join(framework for framework, _families in matches) - raise ValueError( - "method-family inference is ambiguous for algorithm " - f"{algorithm}; matched frameworks {frameworks}." - ) - - -def _normalise_framework(framework: str) -> str: - lowered = framework.strip().lower() - if lowered == "shap": - return "SHAP" - if lowered == "captum": - return "Captum" - return framework - - -def _method_family_error(framework: str, algorithm: str) -> RaitapError: +def _method_family_error(explainer_name: str, algorithm: str) -> RaitapError: if algorithm == "": message = ( - f"Explainer {framework} has no `algorithm` set. The bundled " - f"`transparency/{framework.lower().replace('explainer', '')}.yaml` " - "preset only sets `_target_`. Add `transparency..algorithm: ...` " - "(e.g. `GradientExplainer` for SHAP, `IntegratedGradients` for Captum) " - "to your config or pass it as a CLI override." + f"Explainer {explainer_name} has no `algorithm` set. Add " + "`transparency..algorithm: ...` to your config (e.g. " + "`GradientExplainer` for SHAP, `IntegratedGradients` for Captum)." ) else: message = ( - f"method-family inference is not implemented for framework " - f"{framework} and algorithm {algorithm}." + f"Unknown algorithm {algorithm!r} for explainer {explainer_name}: " + "not in its algorithm_registry." ) return RaitapError( message, diff --git a/src/raitap/transparency/tests/test_semantics.py b/src/raitap/transparency/tests/test_semantics.py index c1b2b038..75610c36 100644 --- a/src/raitap/transparency/tests/test_semantics.py +++ b/src/raitap/transparency/tests/test_semantics.py @@ -3,7 +3,7 @@ import importlib.util import sys from pathlib import Path -from types import ModuleType, SimpleNamespace +from types import ModuleType from typing import Any import pytest @@ -45,7 +45,6 @@ def _load_module(module_name: str, module_path: Path) -> Any: InputKind = contracts.InputKind InputSpec = contracts.InputSpec MethodFamily = contracts.MethodFamily -ExplainerAlgorithmSpec = contracts.ExplainerAlgorithmSpec ScopeDefinitionStep = contracts.ScopeDefinitionStep TensorLayout = contracts.TensorLayout from raitap.transparency.explainers.captum_explainer import CaptumExplainer # noqa: E402 @@ -60,7 +59,8 @@ def _load_module(module_name: str, module_path: Path) -> Any: def _explainer(framework: str, algorithm: str) -> object: - return SimpleNamespace(framework=framework, algorithm=algorithm) + cls = {"shap": ShapExplainer, "captum": CaptumExplainer}[framework] + return cls(algorithm=algorithm) def test_shap_method_family_registry_accepts_adr_v1_list() -> None: @@ -134,9 +134,7 @@ def test_captum_method_family_registry_exactly_matches_adr_v1_list() -> None: def test_bare_captum_grad_cam_is_rejected() -> None: with pytest.raises( Exception, - match=( - "method-family inference is not implemented for framework Captum and algorithm GradCam" - ), + match="Unknown algorithm 'GradCam' for explainer CaptumExplainer", ): method_families_for_explainer(_explainer("captum", "GradCam")) @@ -158,7 +156,7 @@ def test_explainer_capability_uses_local_explainer_output_contract() -> None: def test_explainer_capability_uses_explainer_declared_output_scope() -> None: class GlobalScopeExplainer: - framework = "captum" + algorithm_registry = CaptumExplainer.algorithm_registry algorithm = "LayerGradCam" output_scope = ExplanationScope.GLOBAL @@ -251,7 +249,9 @@ def test_infer_output_space_layout_alone_disambiguates( # (no ``kind``) to pick an output space — the contract the message # advertises. spec = InputSpec(kind=None, shape=shape, layout=layout) - output_space = infer_output_space(input_spec=spec, algorithm="IntegratedGradients") + output_space = infer_output_space( + input_spec=spec, method_families=frozenset({MethodFamily.GRADIENT}) + ) assert output_space.space is expected_space @@ -266,66 +266,3 @@ def test_infer_output_space_uses_cam_method_family_for_image_spatial_maps() -> N assert output_space.layout is TensorLayout.BATCH_CHANNEL_HEIGHT_WIDTH assert output_space.layer_path == "features.0" assert output_space.requires_interpolation is True - - -def test_infer_output_space_uses_algorithm_only_cam_signal() -> None: - output_space = infer_output_space( - input_spec=InputSpec(kind="image", shape=(2, 3, 8, 8), layout="NCHW"), - algorithm="LayerGradCam", - layer_path="features.0", - ) - - assert output_space.space is ExplanationOutputSpace.IMAGE_SPATIAL_MAP - assert output_space.requires_interpolation is True - - -def test_infer_output_space_explicit_algorithm_takes_precedence_over_explainer() -> None: - output_space = infer_output_space( - input_spec=InputSpec(kind="image", shape=(2, 3, 8, 8), layout="NCHW"), - explainer=_explainer("captum", "LayerGradCam"), - algorithm="IntegratedGradients", - ) - - assert output_space.space is ExplanationOutputSpace.INPUT_FEATURES - assert output_space.requires_interpolation is False - - -def test_infer_output_space_rejects_unknown_algorithm_only_signal() -> None: - with pytest.raises( - Exception, - match=( - "method-family inference is not implemented for framework " - " and algorithm MadeUpExplainer" - ), - ): - infer_output_space( - input_spec=InputSpec(kind="image", shape=(2, 3, 8, 8), layout="NCHW"), - algorithm="MadeUpExplainer", - ) - - -def test_infer_output_space_rejects_ambiguous_algorithm_only_signal( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setitem( - SHAP_METHOD_FAMILIES, - "SharedAlgorithm", - ExplainerAlgorithmSpec(frozenset({MethodFamily.SHAPLEY})), - ) - monkeypatch.setitem( - CAPTUM_METHOD_FAMILIES, - "SharedAlgorithm", - ExplainerAlgorithmSpec(frozenset({MethodFamily.GRADIENT})), - ) - - with pytest.raises( - Exception, - match=( - "method-family inference is ambiguous for algorithm " - "SharedAlgorithm; matched frameworks SHAP, Captum" - ), - ): - infer_output_space( - input_spec=InputSpec(kind="image", shape=(2, 3, 8, 8), layout="NCHW"), - algorithm="SharedAlgorithm", - ) From e6d32295be24ba10630aa29dd16c59f14883e727 Mon Sep 17 00:00:00 2001 From: Stanislas Laurent Date: Tue, 7 Jul 2026 21:37:43 +0200 Subject: [PATCH 3/5] test(transparency): give _IdentityExplainer a real algorithm_registry after tier collapse (refs #318) --- src/raitap/transparency/tests/test_results.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/raitap/transparency/tests/test_results.py b/src/raitap/transparency/tests/test_results.py index 4092f785..d33432a2 100644 --- a/src/raitap/transparency/tests/test_results.py +++ b/src/raitap/transparency/tests/test_results.py @@ -25,6 +25,7 @@ ScopeDefinitionStep, ) from raitap.transparency.explainers.base_explainer import AttributionOnlyExplainer +from raitap.transparency.explainers.captum_explainer import CaptumExplainer from raitap.transparency.results import ( ConfiguredVisualiser, ExplanationResult, @@ -87,7 +88,7 @@ def _make_explanation(run_dir: Path, *, explainer_name: str | None = "exp") -> E class _IdentityExplainer(AttributionOnlyExplainer): algorithm = "Saliency" - explainer_framework = "Captum" + algorithm_registry = CaptumExplainer.algorithm_registry def compute_attributions( self, From 350ec79cb3de92f317ef7204a232fef0daafc6cc Mon Sep 17 00:00:00 2001 From: Stanislas Laurent Date: Tue, 7 Jul 2026 22:10:05 +0200 Subject: [PATCH 4/5] fix(transparency): compute visualiser semantic capability lazily; give registry-less test stubs a registry (refs #318) --- .../explainers/tests/test_base_explainer.py | 15 +++++++++++-- src/raitap/transparency/factory.py | 22 ++++++++++++++----- src/raitap/transparency/tests/test_factory.py | 10 +++++++++ 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/src/raitap/transparency/explainers/tests/test_base_explainer.py b/src/raitap/transparency/explainers/tests/test_base_explainer.py index 39639010..d69d57c8 100644 --- a/src/raitap/transparency/explainers/tests/test_base_explainer.py +++ b/src/raitap/transparency/explainers/tests/test_base_explainer.py @@ -26,9 +26,19 @@ ) from raitap.transparency.explainers.base_explainer import AttributionOnlyExplainer +# Method-family resolution reads each adapter's own ``algorithm_registry`` +# ClassVar (issue #318). Bare test stubs declare this shared minimal registry +# so ``method_families_for_explainer`` resolves them the same way a real +# decorated adapter is resolved. +_BASE_REGISTRY: Mapping[str, ExplainerAlgorithmSpec] = { + "Saliency": ExplainerAlgorithmSpec({MethodFamily.GRADIENT}), + "IntegratedGradients": ExplainerAlgorithmSpec({MethodFamily.GRADIENT}), +} + class _StrictExplainer(AttributionOnlyExplainer): algorithm = "Saliency" + algorithm_registry = _BASE_REGISTRY def __init__(self) -> None: super().__init__() @@ -70,6 +80,7 @@ def compute_attributions( class _BatchRecordingExplainer(AttributionOnlyExplainer): algorithm = "IntegratedGradients" + algorithm_registry = _BASE_REGISTRY def __init__(self) -> None: super().__init__() @@ -120,6 +131,7 @@ def compute_attributions( class _GradTrackingExplainer(AttributionOnlyExplainer): algorithm = "Saliency" + algorithm_registry = _BASE_REGISTRY def compute_attributions( self, @@ -201,8 +213,7 @@ def test_explain_rejects_unknown_method_family_before_compute() -> None: with pytest.raises( Exception, match=( - "method-family inference is not implemented for framework " - " and algorithm UnregisteredAlgorithm" + "Unknown algorithm 'UnregisteredAlgorithm' for explainer _UnknownAlgorithmExplainer" ), ): explainer.explain( diff --git a/src/raitap/transparency/factory.py b/src/raitap/transparency/factory.py index 7ac8872f..56c5506c 100644 --- a/src/raitap/transparency/factory.py +++ b/src/raitap/transparency/factory.py @@ -16,6 +16,7 @@ from .algorithm_allowlist import ensure_algorithm_in_allowlist from .contracts import ( ExplainerAdapter, + ExplainerCapability, ExplanationOutputSpace, MethodFamily, explainer_output_kind, @@ -113,7 +114,12 @@ def check_explainer_visualiser_semantic_compat( *, task_kind: TaskKind | None = None, ) -> None: - capability = explainer_capability(explainer, task_kind=task_kind) + # Resolve the explainer's capability lazily: only a visualiser that + # actually declares supported method families / output spaces gives us + # something to check. With nothing to check (e.g. no visualisers), we must + # not force method-family resolution (reading the explainer's + # ``algorithm_registry``) for no reason. + capability: ExplainerCapability | None = None for configured in visualisers: visualiser = configured.visualiser @@ -121,6 +127,16 @@ def check_explainer_visualiser_semantic_compat( getattr(type(visualiser), "supported_method_families", frozenset()), MethodFamily, ) + supported_output_spaces = _enum_frozenset( + getattr(type(visualiser), "supported_output_spaces", frozenset()), + ExplanationOutputSpace, + ) + if not supported_method_families and not supported_output_spaces: + continue + + if capability is None: + capability = explainer_capability(explainer, task_kind=task_kind) + if supported_method_families and not ( capability.method_families & supported_method_families ): @@ -131,10 +147,6 @@ def check_explainer_visualiser_semantic_compat( f"{sorted(f.value for f in supported_method_families)}." ) - supported_output_spaces = _enum_frozenset( - getattr(type(visualiser), "supported_output_spaces", frozenset()), - ExplanationOutputSpace, - ) if not supported_output_spaces: continue if capability.candidate_output_spaces & supported_output_spaces: diff --git a/src/raitap/transparency/tests/test_factory.py b/src/raitap/transparency/tests/test_factory.py index d2d01e74..5f30c47f 100644 --- a/src/raitap/transparency/tests/test_factory.py +++ b/src/raitap/transparency/tests/test_factory.py @@ -17,6 +17,7 @@ VisualiserIncompatibilityError, ) from raitap.transparency.contracts import ( + ExplainerAlgorithmSpec, ExplanationOutputSpace, ExplanationPayloadKind, InputSpec, @@ -519,6 +520,9 @@ def test_explanation_rejects_method_family_mismatch_before_compute( ) -> None: class GradientExplainer: algorithm = "Saliency" + algorithm_registry: ClassVar[dict[str, ExplainerAlgorithmSpec]] = { + "Saliency": ExplainerAlgorithmSpec({MethodFamily.GRADIENT}) + } output_payload_kind: ClassVar[ExplanationPayloadKind] = ExplanationPayloadKind.ATTRIBUTIONS def __init__(self) -> None: @@ -591,6 +595,9 @@ def test_explanation_rejects_candidate_output_space_mismatch_before_compute( ) -> None: class GradientExplainer: algorithm = "Saliency" + algorithm_registry: ClassVar[dict[str, ExplainerAlgorithmSpec]] = { + "Saliency": ExplainerAlgorithmSpec({MethodFamily.GRADIENT}) + } output_payload_kind: ClassVar[ExplanationPayloadKind] = ExplanationPayloadKind.ATTRIBUTIONS def __init__(self) -> None: @@ -661,6 +668,9 @@ def test_explanation_allows_any_supported_candidate_output_space_before_compute( ) -> None: class GradientExplainer: algorithm = "Saliency" + algorithm_registry: ClassVar[dict[str, ExplainerAlgorithmSpec]] = { + "Saliency": ExplainerAlgorithmSpec({MethodFamily.GRADIENT}) + } output_payload_kind: ClassVar[ExplanationPayloadKind] = ExplanationPayloadKind.ATTRIBUTIONS def __init__(self) -> None: From 9cc2a6aee6c9dc76473775e91b89f5b28a4a2ee9 Mon Sep 17 00:00:00 2001 From: Stanislas Laurent Date: Wed, 8 Jul 2026 00:13:35 +0200 Subject: [PATCH 5/5] refactor(misc): de-hardcode diagnostics provider tuple and ci_method literal (refs #318) --- src/raitap/robustness/_ci_router.py | 10 +++++++--- src/raitap/utils/diagnostics.py | 24 ++++++++---------------- 2 files changed, 15 insertions(+), 19 deletions(-) diff --git a/src/raitap/robustness/_ci_router.py b/src/raitap/robustness/_ci_router.py index 141320ba..e66d2147 100644 --- a/src/raitap/robustness/_ci_router.py +++ b/src/raitap/robustness/_ci_router.py @@ -2,16 +2,20 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, get_args if TYPE_CHECKING: from .assessors._ci import CIMethod def resolve_ci_settings(raitap_kwargs: dict[str, Any]) -> tuple[CIMethod, float]: + from .assessors._ci import CIMethod + method = raitap_kwargs.pop("ci_method", "wilson") - if method not in ("wilson", "clopper_pearson"): - raise ValueError(f"raitap.ci_method must be 'wilson' or 'clopper_pearson', got {method!r}.") + valid = get_args(CIMethod) + if method not in valid: + allowed = " or ".join(repr(m) for m in valid) + raise ValueError(f"raitap.ci_method must be {allowed}, got {method!r}.") level = raitap_kwargs.pop("ci_level", 0.95) if not isinstance(level, (int, float)) or not 0.0 < float(level) < 1.0: raise ValueError(f"raitap.ci_level must be in (0, 1), got {level!r}.") diff --git a/src/raitap/utils/diagnostics.py b/src/raitap/utils/diagnostics.py index 93479dcb..e6fdaa59 100644 --- a/src/raitap/utils/diagnostics.py +++ b/src/raitap/utils/diagnostics.py @@ -25,7 +25,6 @@ from __future__ import annotations -import importlib import re import sys from dataclasses import dataclass @@ -36,14 +35,6 @@ _SUBSYSTEM_RE = re.compile(r"raitap[\\/](?!src[\\/])(?P\w+)[\\/]") -# Module packages that wrap third-party libraries and expose a -# ``THIRD_PARTY_LIBS`` constant. Aggregated lazily by :func:`_third_party_libs` -# so ``utils`` stays free of an upward dependency on these packages. -_THIRD_PARTY_LIB_PROVIDERS: Final[tuple[str, ...]] = ( - "raitap.transparency", - "raitap.robustness", -) - _DOCS_BASE: Final[str] = "https://caiivs.github.io/raitap" @@ -104,14 +95,15 @@ def _third_party_libs() -> frozenset[str]: Reads :data:`raitap._adapters.THIRD_PARTY_LIBS`, which :func:`raitap._adapters._register_core` populates from each - ``@register_*_adapter(..., library="...")`` decoration. Adapter modules - are imported once here so the decorators get a chance to fire. + ``@register_*_adapter(..., library="...")`` decoration. + :func:`raitap.configs.register_configs` is invoked first (idempotent) so + every in-tree family is imported and third-party plugins are discovered — + firing those decorations without ``utils`` hardcoding which packages exist. """ - for module_name in _THIRD_PARTY_LIB_PROVIDERS: - try: - importlib.import_module(module_name) - except Exception: - continue + from raitap.configs import register_configs + + register_configs() + from raitap._adapters import THIRD_PARTY_LIBS as _LIBS_BY_GROUP aggregated: set[str] = set()