Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 37 additions & 17 deletions docs/docs/in_depth/mloda-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,34 +97,19 @@ from mloda.steward import (

##### resolve_feature

Resolve a feature name to its matching FeatureGroup class. This is useful for debugging feature resolution or understanding which FeatureGroup handles a specific feature.
Resolve a single feature name (or a `Feature`) to its matching FeatureGroup without running the request, reporting failures in `result.error` instead of raising. It takes `feature` (`str | Feature`) positionally plus keyword-only `options`, `plugin_collector`, `feature_group`, `links`, `data_access_collection`, and `compute_frameworks`, and returns a `ResolvedFeature` (8 fields, including `candidates`, `error`, `supported_compute_frameworks`, `subtype`).

``` python
from mloda.steward import resolve_feature

# Successful resolution
result = resolve_feature("my_feature_name")
if result.feature_group:
print(f"Resolved to: {result.feature_group.__name__}")
else:
print(f"Error: {result.error}")

# Access all matching candidates (before subclass filtering)
print(f"Candidates: {[fg.__name__ for fg in result.candidates]}")
```

**Parameters:**

- **feature_name** (`str`): The name of the feature to resolve.
- **options** (`Options | None`, keyword-only): Options used for matching and for the compute framework capability split. Defaults to empty `Options`. Required to resolve FeatureGroups that gate matching on an option.
- **plugin_collector** (`PluginCollector | None`, keyword-only): Restricts the FeatureGroups considered, and threads its `allow_redefinition` flag into deduplication.

**Returns:** `ResolvedFeature` dataclass with fields:

- **feature_name** (`str`): The input feature name.
- **feature_group** (`Type[FeatureGroup] | None`): The resolved FeatureGroup class, or None if resolution failed.
- **candidates** (`List[Type[FeatureGroup]]`): All FeatureGroups that matched before subclass filtering.
- **error** (`str | None`): Error message if resolution failed (no match or multiple conflicts).
See [Discover Plugins](discover-plugins.md#resolving-feature-names) for the full signature, every `ResolvedFeature` field, and worked examples (options-gated groups, scoping, broken framework declarations). `resolve_feature` is exported from `mloda.provider`, `mloda.user`, and `mloda.steward`; the `ResolvedFeature` return type is exported from `mloda.steward`.

##### explain and resolved_plan

Expand Down Expand Up @@ -180,6 +165,41 @@ for step in mloda.explain(["sales__mean_aggr"], compute_frameworks=["PandasDataF
print(step.requested_feature_names, step.injected_feature_names)
```

##### diagnose and resolution_report

`diagnose` and `resolution_report()` are the non-raising counterparts to `explain` and `resolved_plan()`: where the plan-based pair returns `PlanStep` records, these return the resolution facts a failing request would otherwise raise.

`mlodaAPI.diagnose(features, ...)` runs the whole-request preflight without raising and returns a single `ResolutionDiagnosis`. It takes the same arguments as `explain` (every parameter after `features` is keyword-only). On success its `records` equal `session.resolution_report()` and `complete` is `True`; on a resolution failure it carries the records resolved before the failing feature plus `feature_name`, `failed_result`, and `message`; on an environment or config failure (redefinition conflict, framework-declaration error, compute-framework pin) it returns `records=[]`, `complete=False`, and only `message`.

`session.resolution_report()` returns the `list[ResolutionRecord]` captured while `prepare()` planned the request, one per feature, available before or after `run()`.

``` python
from mloda.user import mlodaAPI

diagnosis = mlodaAPI.diagnose(["sales__mean_aggr"], compute_frameworks=["PandasDataFrame"])
if diagnosis.complete:
for record in diagnosis.records:
print(record.feature_name, record.requested)
else:
print(diagnosis.feature_name, diagnosis.message)
```

When the same request is run rather than diagnosed, the failure raises `FeatureResolutionError` (below).

##### Resolution result types

Import the typed resolution surfaces from `mloda.provider` (also re-exported from `mloda.user` and `mloda.steward`):

``` python
from mloda.provider import FeatureResolutionError, ResolutionDiagnosis, ResolutionRecord
```

- **`FeatureResolutionError`** (a `ValueError` subclass): raised during planning (`mlodaAPI(...)` / `prepare()` / `run_all()`) when a feature does not resolve to exactly one FeatureGroup. Attributes: `feature_name` (`str`), `result` (`EvaluationResult`, the captured per-candidate elimination facts), `partial_records` (`tuple[ResolutionRecord, ...]`, features resolved before the failure, capped at the last 1000). Because it subclasses `ValueError`, existing `except ValueError` handlers keep working; catch `FeatureResolutionError` to read the attributes. See [Feature Group Resolution Errors](troubleshooting/feature-group-resolution-errors.md).
- **`ResolutionDiagnosis`** (frozen dataclass): the return value of `diagnose`, never raised. Fields: `records` (`list[ResolutionRecord]`), `complete` (`bool`), `feature_name` (`str | None`), `failed_result` (`EvaluationResult | None`), `message` (`str | None`).
- **`ResolutionRecord`** (frozen dataclass): one per feature, returned inside `resolution_report()`, `ResolutionDiagnosis.records`, and `FeatureResolutionError.partial_records`; never raised. Fields: `feature_name` (`str`), `requested` (`bool`), `result` (`EvaluationResult`).

`EvaluationResult` is the captured matcher outcome carried by the fields above; it lives in `mloda.core.prepare.identify_feature_group` and is not part of the public `__init__` exports.

##### get_feature_group_docs

Get documentation for feature groups with optional filtering.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,45 @@ Pass a `Feature`, `options`, `plugin_collector`, `links`,
frameworks needs this to mirror) to reproduce the run you are debugging. See
[Discover Plugins](../discover-plugins.md) for the full signature.

## Catching resolution failures

Inside a run, a feature that does not resolve to exactly one FeatureGroup raises
`FeatureResolutionError` during planning (the `mloda.run_all(...)` /
`mloda.prepare(...)` call, before any compute runs). It is a `ValueError`
subclass, so existing `except ValueError` handlers keep working, but you can now
catch it specifically and inspect why it failed:

``` python
from mloda.provider import FeatureResolutionError
from mloda.user import mloda

try:
mloda.run_all(["my_feature"])
except FeatureResolutionError as err:
print(err.feature_name) # the feature that failed to resolve
print(err) # the human-readable message a run prints
for record in err.partial_records:
print(record.feature_name, record.requested) # features resolved before the failure
# err.result carries the per-candidate elimination facts (an EvaluationResult)
```

To inspect a request without catching an exception, use the non-raising
surfaces: `resolve_feature` for a single name (above), or `mlodaAPI.diagnose(...)`,
which returns a `ResolutionDiagnosis` for the whole request. See
[mlodaAPI](../mloda-api.md#diagnose-and-resolution_report) for both, plus the
fields on `FeatureResolutionError`, `ResolutionDiagnosis`, and `ResolutionRecord`.

## Multiple Feature Groups Error

### The Problem

```
ValueError: Multiple feature groups found for feature '<feature_name>':
FeatureResolutionError: Multiple feature groups found for feature '<feature_name>':
- FeatureGroupA (...) [domain: ...]
- FeatureGroupB (...) [domain: ...]
```

This error occurs when multiple distinct feature groups claim they can handle the same feature. Each feature must resolve to exactly one feature group to prevent conflicts.
This error occurs when multiple distinct feature groups claim they can handle the same feature. Each feature must resolve to exactly one feature group to prevent conflicts. It is raised as `FeatureResolutionError` (a `ValueError` subclass) during planning; see [Catching resolution failures](#catching-resolution-failures) to inspect it.

If you previously hit this in a notebook because of a redefined feature group (re-running a cell that defines `class MyFG(FeatureGroup): ...`), that case is now auto-deduplicated. If this error still appears, it points to a real conflict between two distinct classes (different `(module, qualname)`).

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,21 +187,6 @@ def parse_feature_name(
return None, None
return cls._legacy_operation_config(parsed), parsed.source_feature

@classmethod
def _match_pattern_based_feature(
cls,
feature_name: str | FeatureName,
prefix_patterns: list[Any],
pattern: str = CHAIN_SEPARATOR,
) -> bool:
"""Internal method for matching pattern-based features - used by match_configuration_feature_chain_parser."""
_feature_name: FeatureName = FeatureName(feature_name) if isinstance(feature_name, str) else feature_name

has_prefix_configuration, source_feature = cls.parse_feature_name(_feature_name, prefix_patterns, pattern)
if has_prefix_configuration is None or source_feature is None:
return False
return True

@classmethod
def _can_skip_required_check(cls, spec: PropertySpec) -> bool:
"""Check if the base parser should treat this property as optional.
Expand Down Expand Up @@ -498,8 +483,9 @@ def name_path_presence_rejection_reason(
) -> str | None:
"""The reason a name-path candidate was rejected for missing presence (#769); None when nothing is missing.

Diagnostic-only: mirrors _check_name_path_required_presence so the resolution-failure
report explains the same non-match the matcher produced.
Supported diagnostic seam, paired with ``_strict_validation_rejection_reason``:
mirrors _check_name_path_required_presence so the resolution-failure report explains the
same non-match the matcher produced.
"""
missing = cls._name_path_missing_required_keys(effective_options, property_mapping)
if not missing:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -313,8 +313,9 @@ def _strict_validation_rejection_reason(cls, feature_name: str | FeatureName, op
"""Return the rejection message that match_feature_group_criteria discards, if any.

The engine no longer calls this: it renders the reasons the first match pass recorded via
``record_match_rejection``. This stays a compatibility facade for direct diagnostic calls
and must keep producing the same messages the match pass records.
``record_match_rejection``. This is a supported diagnostic seam: a stable,
overridable hook for reproducing a single group's value-rejection reason outside a run. It
must keep producing the same messages the match pass records.

Reports two kinds of value rejection, both gated on the feature group OTHERWISE matching
the feature:
Expand Down
9 changes: 0 additions & 9 deletions mloda/core/abstract_plugins/components/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,15 +151,6 @@ def __init__(
OptionsValidator.validate_no_duplicate_keys(self.group, self.context)
OptionsValidator.validate_propagate_keys_in_context(self.propagate_context_keys, self.context)

def add(self, key: str, value: Any) -> None:
"""
Legacy method for backward compatibility.
Adds to group to maintain existing behavior during migration.

Possibility that we keep this as default method for adding options in the future.
"""
self.add_to_group(key, value)

def add_to_group(self, key: str, value: Any) -> None:
"""Add parameter to group (affects Feature Group resolution/splitting)."""
OptionsValidator.validate_can_add_to_group(key, value, self.group, self.context)
Expand Down
4 changes: 1 addition & 3 deletions mloda/core/abstract_plugins/components/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,11 @@ def as_str(value: Any) -> str:
return value


def get_all_subclasses(cls: Any, log_n_subclasses: int = 0) -> set[type[Any]]:
def get_all_subclasses(cls: Any) -> set[type[Any]]:
all_subclasses = set()

for subclass in cls.__subclasses__():
all_subclasses.add(subclass)
all_subclasses.update(get_all_subclasses(subclass))

if log_n_subclasses > 0:
logger.debug(f"Abstractclass: {type(cls)}. Subclasses: {list(all_subclasses)[log_n_subclasses]}.")
return all_subclasses
17 changes: 3 additions & 14 deletions mloda/core/abstract_plugins/feature_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import inspect
import logging
from collections.abc import Collection, Mapping
from typing import Any, ClassVar, Callable, Iterable, Optional, final
from typing import Any, ClassVar, Callable, Optional, final
from abc import ABC

from mloda.core.abstract_plugins.components.base_artifact import BaseArtifact
Expand All @@ -14,6 +14,7 @@
from mloda.core.abstract_plugins.components.base_feature_group_version import BaseFeatureGroupVersion
from mloda.core.abstract_plugins.components.feature_chainer.feature_chain_parser import (
CHAIN_SEPARATOR,
COLUMN_SEPARATOR,
FeatureChainParser,
option_key_is_present,
)
Expand Down Expand Up @@ -469,7 +470,7 @@ def get_column_base_feature(column_name: str) -> str:

Returns the original name if no ~N suffix exists.
"""
return column_name.split("~")[0]
return column_name.split(COLUMN_SEPARATOR)[0]

@staticmethod
def expand_feature_columns(feature_name: str, num_columns: int) -> list[str]:
Expand Down Expand Up @@ -841,15 +842,3 @@ class MyMatchData(FeatureGroup, MatchData):
def format_feature_group_class(fg_class: type[FeatureGroup]) -> str:
"""Format a single FeatureGroup class for error messages."""
return f"{fg_class.__name__} ({fg_class.__module__})"


def format_feature_group_classes(feature_groups: Iterable[type[FeatureGroup]], include_domain: bool = False) -> str:
"""Format FeatureGroup classes for error messages."""
lines = []
for fg_class in feature_groups:
line = f" - {fg_class.__name__} ({fg_class.__module__})"
if include_domain:
domain = fg_class.get_domain()
line += f" [domain: {domain.name}]"
lines.append(line)
return "\n".join(lines)
2 changes: 2 additions & 0 deletions mloda/core/api/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -530,11 +530,13 @@ def _setup_engine_runner(
return runner

def get_result(self) -> list[Any]:
"""Return the computed run results; raises if no run function has executed yet."""
if self.runner is None:
raise ValueError("You need to run any run function beforehand.")
return self.runner.get_result()

def get_artifacts(self) -> dict[str, Any]:
"""Return the artifacts produced by the run; raises if no run function has executed yet."""
if self.runner is None:
raise ValueError("You need to run any run function beforehand.")
return self.runner.get_artifacts()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""Tests for the format_feature_group_class (singular) helper, which stays live in
production (engine.py, execution_plan.py, feature_group_step.py) after the plural
format_feature_group_classes was removed."""

from typing import Optional

from mloda.core.abstract_plugins.components.domain import Domain
from mloda.core.abstract_plugins.components.feature_name import FeatureName
from mloda.core.abstract_plugins.feature_group import FeatureGroup, format_feature_group_class
from mloda.user import Feature, Options


class SampleFeatureGroupAlpha(FeatureGroup):
"""A test feature group for formatting tests."""

def input_features(self, options: Options, feature_name: FeatureName) -> Optional[set[Feature]]:
return None


class SampleFeatureGroupWithDomain(FeatureGroup):
"""A test feature group with a custom domain."""

@classmethod
def get_domain(cls) -> Domain:
return Domain("sales")

def input_features(self, options: Options, feature_name: FeatureName) -> Optional[set[Feature]]:
return None


class TestFormatFeatureGroupClass:
"""Direct unit tests for the format_feature_group_class (singular) helper."""

def test_returns_class_name_and_module(self) -> None:
"""format_feature_group_class returns the ClassName (module.path) format."""
result = format_feature_group_class(SampleFeatureGroupAlpha)

assert "SampleFeatureGroupAlpha" in result
assert SampleFeatureGroupAlpha.__module__ in result

def test_output_format_structure(self) -> None:
"""The exact structure is ClassName (module.path)."""
result = format_feature_group_class(SampleFeatureGroupAlpha)

assert result.count("(") == 1
assert result.count(")") == 1
assert result.startswith("SampleFeatureGroupAlpha")
assert result.endswith(")")

def test_with_feature_group_with_domain(self) -> None:
"""Formatting works for a FeatureGroup that declares a custom domain."""
result = format_feature_group_class(SampleFeatureGroupWithDomain)

assert "SampleFeatureGroupWithDomain" in result
assert SampleFeatureGroupWithDomain.__module__ in result
Loading
Loading