Source code for deeptab.models._mixins.observability

"""Lifecycle event dispatch for all DeepTab estimators.

All estimators emit named events at key points in the fit / predict /
serialise lifecycle via ``_emit_event``.  This module provides the default
no-op implementation so the call sites work without any configuration.

To receive events, pass an ``ObservabilityConfig`` at construction time::

    from deeptab.core.observability import ObservabilityConfig

    obs = ObservabilityConfig(structured_logging=True)
    clf = MLPClassifier(observability_config=obs)
    clf.fit(X, y)   # fit_started, model_built, … are now logged

Or configure after construction::

    clf.configure_observability(obs)

The full event inventory is documented in the architecture plan:
``dev/documentation/deeptab-modules/architecture_improvement_v0.md``.
"""

from __future__ import annotations

from typing import TYPE_CHECKING, Any, Protocol

if TYPE_CHECKING:
    from deeptab.core.observability import ObservabilityConfig


class _SupportsInfo(Protocol):
    """Structural type for any logger that accepts named lifecycle events.

    Any object with an ``info(event: str, **kwargs) -> None`` method satisfies
    this Protocol — ``structlog`` bound-loggers, ``logging.Logger`` adapters,
    or simple test doubles all qualify.
    """

    def info(self, event: str, **kwargs: Any) -> None: ...


class _NoOpEventLogger:
    """Logger that silently discards every event.

    Used as the default when no real logger has been attached to an
    estimator.  Its interface mirrors the ``structlog`` bound-logger API
    so that swapping in a real backend requires no changes at the call
    site.
    """

    def info(self, event: str, **kwargs: Any) -> None:
        pass


class _ObservabilityMixin:
    """Provide lifecycle event dispatch to all DeepTab estimators.

    Use ``configure_observability`` to attach a backend::

        from deeptab.core.observability import ObservabilityConfig
        clf.configure_observability(ObservabilityConfig(structured_logging=True))

    When ``_event_logger`` is ``None`` (the default) all events are
    silently discarded via ``_NoOpEventLogger`` semantics.
    """

    _event_logger: _SupportsInfo | None = None
    _run_id: str | None = None  # set per fit() call; auto-injected into every event
    _run_dir: str | None = None  # per-run output directory (set at fit start)
    _fit_start_ms: float = 0.0  # monotonic timestamp at fit() start

    def configure_observability(self, config: ObservabilityConfig) -> None:
        """Wire up logging backends described by *config*.

        Can be called at any point — before or after ``fit()``.  Changes take
        effect on the next lifecycle event emitted (i.e. the next ``fit()``
        or ``predict()`` call).

        Parameters
        ----------
        config : ObservabilityConfig
            Observability settings.  Imports optional dependencies lazily;
            raises ``ImportError`` with install hints if they are absent.
        """
        from deeptab.core.observability import build_structlog_logger

        # Always store the config so fit() can access it for run-dir creation,
        # Lightning loggers, and MLflow metadata logging.
        self._observability_config = config  # type: ignore[attr-defined]

        if config.structured_logging:
            self._event_logger = build_structlog_logger(config)

    def _emit_event(self, event: str, **kwargs: Any) -> None:
        """Dispatch a named lifecycle event to the attached logger.

        Automatically prepends ``run_id`` from the current fit run when
        one is active, so call sites never need to pass it explicitly.

        Parameters
        ----------
        event : str
            Dot-namespaced event name, e.g. ``"fit.started"``, ``"train.completed"``.
        **kwargs
            Arbitrary key-value context attached to the event.
        """
        if self._event_logger is not None:
            run_id = getattr(self, "_run_id", None)
            if run_id is not None and "run_id" not in kwargs:
                self._event_logger.info(event, run_id=run_id, **kwargs)
            else:
                self._event_logger.info(event, **kwargs)