Source code for melody_features.feature_registry

"""Discovery helpers for decorated melodic feature callables."""

from __future__ import annotations

import inspect
from dataclasses import dataclass
from typing import Any, Callable, Dict, Iterator, List, Optional, Union


def _is_canonical_function_binding(name: str, obj: Any) -> bool:
    """Return whether `name` is the callable's defining name."""
    return not (inspect.isfunction(obj) and obj.__name__ != name)


def _add_unique_feature(
    features: Dict[str, Callable],
    seen_ids: set[int],
    name: str,
    obj: Callable,
) -> None:
    """Add a feature once, preserving the first canonical binding found."""
    feature_id = id(obj)
    if feature_id in seen_ids:
        return
    seen_ids.add(feature_id)
    features[name] = obj


def get_features_by_type(module: Any, feature_type: str) -> Dict[str, Callable]:
    """Get all canonical feature functions in `module` for a feature type."""
    features: Dict[str, Callable] = {}
    seen_ids: set[int] = set()

    for name, obj in inspect.getmembers(module):
        if name.startswith("_"):
            continue
        if not inspect.isfunction(obj):
            continue
        if not _is_canonical_function_binding(name, obj):
            continue
        if not hasattr(obj, "_feature_types"):
            continue
        if feature_type not in obj._feature_types:
            continue
        _add_unique_feature(features, seen_ids, name, obj)

    return features


def get_features_by_domain(module: Any, domain: str) -> Dict[str, Callable]:
    """Get all canonical feature functions in `module` for a feature domain."""
    features: Dict[str, Callable] = {}
    seen_ids: set[int] = set()

    for name, obj in inspect.getmembers(module):
        if name.startswith("_"):
            continue
        if not inspect.isfunction(obj):
            continue
        if not _is_canonical_function_binding(name, obj):
            continue
        if not hasattr(obj, "_feature_domain"):
            continue
        if obj._feature_domain != domain:
            continue
        _add_unique_feature(features, seen_ids, name, obj)

    return features


def get_features_by_domain_and_types(
    module: Any,
    domain: str,
    allowed_types: list[str],
) -> Dict[str, Callable]:
    """Get all canonical features for a domain matching any allowed type."""
    features: Dict[str, Callable] = {}
    seen_ids: set[int] = set()

    for name, obj in inspect.getmembers(module):
        if name.startswith("_"):
            continue
        if not inspect.isfunction(obj):
            continue
        if not _is_canonical_function_binding(name, obj):
            continue
        if not hasattr(obj, "_feature_domain") or obj._feature_domain != domain:
            continue
        if not hasattr(obj, "_feature_types"):
            continue
        if not any(feature_type in allowed_types for feature_type in obj._feature_types):
            continue
        _add_unique_feature(features, seen_ids, name, obj)

    return features


def _is_source_candidate(obj: Any) -> bool:
    return (
        inspect.isfunction(obj)
        or inspect.isclass(obj)
        or (hasattr(obj, "__call__") and hasattr(obj, "__name__"))
    )


def get_features_by_source(module: Any, source: str) -> Dict[str, Callable]:
    """Get decorated functions/classes in `module` for a source label."""
    source_features: Dict[str, Callable] = {}

    for name, obj in inspect.getmembers(module):
        if not _is_source_candidate(obj):
            continue
        if hasattr(obj, "_feature_sources") and source in obj._feature_sources:
            source_features[name] = obj
        elif hasattr(obj, "_feature_source") and obj._feature_source == source:
            source_features[name] = obj

    return source_features


_DESCRIPTOR_EXCLUDED_PROPERTIES = frozenset({"count_values", "freq_spec", "total_tokens"})

_TYPE_CATEGORY_LABELS = {
    "absolute": "Absolute Pitch",
    "pitch_class": "Pitch Class",
    "interval": "Pitch Interval",
    "contour": "Contour",
    "timing": "Timing",
    "tonality": "Tonality",
    "metre": "Metre",
    "expectation": "Expectation",
    "complexity": "Complexity",
    "lexical_diversity": "Lexical Diversity",
    "corpus": "Corpus",
}


@dataclass(frozen=True)
class FeatureInfo:
    """Metadata for one individual feature within the package."""

    name: str
    category: str
    domain: Optional[str]
    types: tuple[str, ...]
    sources: tuple[str, ...]
    summary: str
    kind: str  # "function" or "descriptor"


def _metadata_target(obj: Any, *, is_property: bool) -> Any:
    if is_property and hasattr(obj, "fget") and obj.fget is not None:
        return obj.fget
    return obj


def _category_label(types: list[str], domain: Optional[str], name: str) -> str:
    if name in {
        "yules_k",
        "simpsons_d",
        "sichels_s",
        "honores_h",
        "mean_entropy",
        "mean_productivity",
    }:
        return "Lexical Diversity"

    labels: list[str] = []
    for feature_type in types:
        label = _TYPE_CATEGORY_LABELS.get(feature_type)
        if feature_type == "interval" and domain == "rhythm" and "ioi" in name.lower():
            label = "Inter-Onset Interval"
        if label and label not in labels:
            labels.append(label)

    if labels:
        return ", ".join(labels)
    if types:
        return types[0].replace("_", " ").title()
    return "Other"


def _first_docline(obj: Any, *, is_property: bool) -> str:
    doc = inspect.getdoc(_metadata_target(obj, is_property=is_property)) or ""
    line = doc.strip().split("\n")[0] if doc else ""
    return line.strip()


def _iter_descriptor_features() -> Iterator[tuple[str, property]]:
    from .contour import (
        HuronContour,
        InterpolationContour,
        PolynomialContour,
        StepContour,
    )
    from .ngram_counter import NGramCounter

    for cls in (
        StepContour,
        InterpolationContour,
        PolynomialContour,
        HuronContour,
        NGramCounter,
    ):
        class_name = cls.__name__
        for prop_name, prop_obj in inspect.getmembers(cls):
            if not isinstance(prop_obj, property):
                continue
            if prop_name in _DESCRIPTOR_EXCLUDED_PROPERTIES:
                continue
            yield f"{class_name}.{prop_name}", prop_obj


def discover_atomic_features(
    module: Any,
    *,
    include_descriptors: bool = True,
) -> List[FeatureInfo]:
    """Collect metadata for atomic features exported from `module`.

    Aggregator helpers (names starting with `get_`) and alias bindings are
    excluded, matching the feature summary table conventions.
    """
    seen_function_ids: set[int] = set()
    entries: list[FeatureInfo] = []

    candidates: list[tuple[str, Any]] = [
        (name, obj)
        for name, obj in inspect.getmembers(module)
        if inspect.isfunction(obj)
    ]
    if include_descriptors:
        candidates.extend(_iter_descriptor_features())

    for name, obj in candidates:
        if name.startswith("get_") or name.startswith("_"):
            continue
        if name == "InverseEntropyWeighting":
            continue

        is_property = isinstance(obj, property)
        target = _metadata_target(obj, is_property=is_property)
        feature_types = getattr(target, "_feature_types", None)
        if not feature_types and not is_property:
            continue

        if not is_property:
            if not _is_canonical_function_binding(name, obj):
                continue
            try:
                function_id = id(inspect.unwrap(obj))
            except (AttributeError, ValueError):
                function_id = id(obj)
            if function_id in seen_function_ids:
                continue
            seen_function_ids.add(function_id)

        domain = getattr(target, "_feature_domain", None)
        types = tuple(feature_types or ())
        sources = tuple(getattr(target, "_feature_sources", []) or [])
        feature_name = name.split(".", 1)[-1] if "." in name else name

        entries.append(
            FeatureInfo(
                name=name,
                category=_category_label(list(types), domain, feature_name),
                domain=domain,
                types=types,
                sources=sources,
                summary=_first_docline(obj, is_property=is_property),
                kind="descriptor" if is_property else "function",
            )
        )

    return sorted(entries, key=lambda entry: entry.name.lower())


[docs] def list_available_features( *, domain: Optional[str] = None, feature_type: Optional[str] = None, source: Optional[str] = None, detailed: bool = False, module: Any = None, ) -> Union[List[str], List[dict]]: """List atomic features available in the package. Parameters ---------- domain : str, optional Filter by feature domain (`pitch`, `rhythm`, or `both`) feature_type : str, optional Filter by decorator type (e.g. `absolute`, `interval`, `contour`) source : str, optional Filter by implementation source (e.g. `fantastic`, `jsymbolic`) detailed : bool, optional If True, return metadata dictionaries instead of names only module : module, optional Module to scan (defaults to :mod:`melody_features.features`) Returns ------- list[str] or list[dict] Feature names, or metadata records when `detailed=True` """ if module is None: import melody_features.features as features_module module = features_module entries = discover_atomic_features(module) if domain is not None: entries = [entry for entry in entries if entry.domain == domain] if feature_type is not None: entries = [entry for entry in entries if feature_type in entry.types] if source is not None: entries = [entry for entry in entries if source in entry.sources] if detailed: return [ { "name": entry.name, "category": entry.category, "domain": entry.domain, "types": list(entry.types), "sources": list(entry.sources), "summary": entry.summary, "kind": entry.kind, } for entry in entries ] return [entry.name for entry in entries]