Tusk DFS Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build tusk, a library implementing Deep Feature Synthesis over narwhals lazy frames with a featuretools-shaped EntitySet + dfs() interface.
Architecture: Two phases with a hard boundary. Phase 1 (synthesis.py) walks the relationship graph using only schemas and emits an immutable graph of Feature definitions — no dataframe is touched. Phase 2 (compiler.py) turns a feature list into narwhals operations, batching so that all aggregations from one child table collapse into a single group_by().agg() and a single join. synthesis.py must never import compiler.py.
Tech Stack: Python ≥3.10, narwhals (only runtime dependency), polars (test backend), pytest, uv, ruff, ty, interrogate, pydoclint.
Spec: docs/superpowers/specs/2026-08-17-deep-feature-synthesis-design.md
Global Constraints¶
- Runtime dependency is
narwhalsonly. No pandas, no polars, no woodwork in[project.dependencies]. - Tusk never calls
collect()except in the one documented eager round-trip path incompiler.py. - Narwhals API only. No
to_native()/ backend-specific calls in library logic except the single documented round-trip. - Python
>=3.10. Usefrom __future__ import annotationsin every module soX | Yannotations work on 3.10. - Google-style docstrings on every public symbol.
interrogate(fail-under 100) andpydoclint(style = "google") run in pre-commit. forbid-binarypre-commit hook — no parquet/feather fixtures may be committed. Test data is constructed in Python.- Order-dependent expressions (
cum_sum,cum_count,cum_min,cum_max,diff,shift,rank, rolling) must be followed by.over(..., order_by=...); narwhals requires this on lazy backends. mode()and other length-changing expressions are forbidden inside a lazygroup_by().agg()— narwhals rejects them.- Commit after every task. Never amend.
Verified API facts¶
Probed against narwhals 2.24.0 / polars 1.43.2. Rely on these; do not re-derive.
| Fact | Detail |
|---|---|
| Backend identity | nw_frame.implementation → e.g. polars; compare for the single-backend rule |
| Eager vs lazy | isinstance(x, nw.DataFrame) / nw.LazyFrame; df.lazy() converts |
| Schema | lf.collect_schema() → mapping of name → dtype |
| Dtype predicates | dtype.is_numeric(), dtype.is_temporal(), dtype == nw.String, dtype == nw.Boolean. Boolean is not numeric |
| Works in lazy agg | nw.len().cast(nw.Int64), col.n_unique().cast(nw.Int64), col.cast(nw.Int64).mean(), col.quantile(q, interpolation="linear"), std, median |
| Fails in lazy agg | col.mode() — "Length-changing expressions are not supported for use in LazyFrame" |
| Windows | nw.col("v").cum_sum().over("g", order_by="t"); order_by also works with no partition |
| Join | lf.join(other, left_on=..., right_on=..., how="left") drops the right key column |
| Lazy scan | pl.scan_parquet on a deleted file builds queries fine and raises FileNotFoundError only at collect() |
| Dtype hashing | narwhals dtypes are hashable, so frozen-dataclass features deduplicate in a set |
| Plan inspection | explain() prints both LEFT JOIN: and END LEFT JOIN, so count "LEFT JOIN:" for joins and "AGGREGATE" for group-bys. Counting bare "JOIN" double-counts |
Task 1: Project scaffolding¶
Files:
- Modify: pyproject.toml
- Create: src/tusk/__init__.py, src/tusk/exceptions.py
- Test: tests/test_packaging.py
Interfaces:
- Consumes: nothing
- Produces: tusk.exceptions.TuskError, SchemaError, PrimitiveError, MissingPrimaryKeyWarning; an installable tusk package; pytest markers differential and benchmark.
- Step 1: Write the failing test
# tests/test_packaging.py
"""Packaging and exception hierarchy smoke tests."""
import pytest
import tusk
from tusk.exceptions import (
MissingPrimaryKeyWarning,
PrimitiveError,
SchemaError,
TuskError,
)
def test_package_imports():
assert tusk.__version__
@pytest.mark.parametrize("exc", [SchemaError, PrimitiveError])
def test_errors_share_a_base(exc):
assert issubclass(exc, TuskError)
def test_missing_primary_key_warning_is_filterable():
assert issubclass(MissingPrimaryKeyWarning, UserWarning)
- Step 2: Run test to verify it fails
Run: uv run pytest tests/test_packaging.py -v
Expected: FAIL — ModuleNotFoundError: No module named 'tusk'
- Step 3: Add packaging config
Add to pyproject.toml (keep the existing [dependency-groups] dev entries and append to them):
[project]
name = "tusk"
version = "0.1.0"
description = "Deep feature synthesis for narwhals lazy dataframes"
readme = "README.md"
requires-python = ">=3.10"
dependencies = ["narwhals>=2.24"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/tusk"]
[dependency-groups]
dev = [
"interrogate>=1.7.0",
"pre-commit-uv>=4.3.0",
"pydoclint>=0.9.1",
"pytest>=8.0",
"polars>=1.43",
"ruff>=0.16.3",
"ty>=0.0.72",
]
validation = ["featuretools>=1.31", "pandas>=2.0"]
benchmark = ["relbench>=1.0"]
[tool.pytest.ini_options]
testpaths = ["tests"]
markers = [
"differential: cross-checks values against featuretools (opt-in)",
"benchmark: relbench performance runs (opt-in)",
]
addopts = "-m 'not differential and not benchmark'"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "D"]
[tool.ruff.lint.pydocstyle]
convention = "google"
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["D"]
[tool.interrogate]
fail-under = 100
ignore-init-module = true
ignore-private = true
ignore-magic = true
ignore-nested-functions = true
exclude = ["tests"]
[tool.pydoclint]
style = "google"
exclude = "tests"
arg-type-hints-in-docstring = false
check-return-types = false
- Step 4: Write the package modules
# src/tusk/exceptions.py
"""Exception and warning types raised by tusk."""
from __future__ import annotations
class TuskError(Exception):
"""Base class for all errors raised by tusk."""
class SchemaError(TuskError):
"""Raised when an entity set's schema is invalid or inconsistent."""
class PrimitiveError(TuskError):
"""Raised when a primitive is unknown or cannot be applied."""
class MissingPrimaryKeyWarning(UserWarning):
"""Warns that a table without a primary key has reduced capabilities."""
# src/tusk/__init__.py
"""Deep feature synthesis for narwhals lazy dataframes."""
from __future__ import annotations
__version__ = "0.1.0"
__all__ = ["__version__"]
- Step 5: Sync and run tests
Run: uv sync && uv run pytest tests/test_packaging.py -v
Expected: PASS (3 tests)
- Step 6: Commit
git add pyproject.toml uv.lock src/tusk/__init__.py src/tusk/exceptions.py tests/test_packaging.py
git commit -m "feat: scaffold tusk package with exception hierarchy"
Task 2: Dtype families¶
Files:
- Create: src/tusk/dtypes.py
- Test: tests/test_dtypes.py
Interfaces:
- Consumes: nothing
- Produces: tusk.dtypes.DtypeFamily (enum: NUMERIC, TEMPORAL, STRING, BOOLEAN, ANY) and tusk.dtypes.matches(dtype, family) -> bool.
- Step 1: Write the failing test
# tests/test_dtypes.py
import narwhals as nw
import pytest
from tusk.dtypes import DtypeFamily, matches
F = DtypeFamily
@pytest.mark.parametrize(
("dtype", "family", "expected"),
[
(nw.Int64(), F.NUMERIC, True),
(nw.Float64(), F.NUMERIC, True),
(nw.Boolean(), F.NUMERIC, False),
(nw.String(), F.NUMERIC, False),
(nw.Datetime(), F.TEMPORAL, True),
(nw.Date(), F.TEMPORAL, True),
(nw.Int64(), F.TEMPORAL, False),
(nw.String(), F.STRING, True),
(nw.Int64(), F.STRING, False),
(nw.Boolean(), F.BOOLEAN, True),
(nw.Int64(), F.BOOLEAN, False),
(nw.String(), F.ANY, True),
(nw.Datetime(), F.ANY, True),
],
)
def test_matches(dtype, family, expected):
assert matches(dtype, family) is expected
- Step 2: Run test to verify it fails
Run: uv run pytest tests/test_dtypes.py -v
Expected: FAIL — ModuleNotFoundError: No module named 'tusk.dtypes'
- Step 3: Implement
# src/tusk/dtypes.py
"""Dtype families used to decide which primitives apply to which columns.
Matching is done on narwhals dtypes alone. There are no logical types and no
semantic tags: the cost is losing the distinction between an integer that is a
quantity and one that is a category; the benefit is identical behaviour on
every backend with no extra dependencies.
"""
from __future__ import annotations
from enum import Enum
import narwhals as nw
class DtypeFamily(Enum):
"""A group of narwhals dtypes that a primitive can accept."""
NUMERIC = "numeric"
TEMPORAL = "temporal"
STRING = "string"
BOOLEAN = "boolean"
ANY = "any"
def matches(dtype: nw.dtypes.DType, family: DtypeFamily) -> bool:
"""Report whether a dtype belongs to a family.
Args:
dtype: The narwhals dtype to test.
family: The family to test membership of.
Returns:
True if the dtype belongs to the family.
"""
if family is DtypeFamily.ANY:
return True
if family is DtypeFamily.NUMERIC:
return bool(dtype.is_numeric())
if family is DtypeFamily.TEMPORAL:
return bool(dtype.is_temporal())
if family is DtypeFamily.STRING:
return dtype == nw.String
return dtype == nw.Boolean
- Step 4: Run tests
Run: uv run pytest tests/test_dtypes.py -v
Expected: PASS (13 tests)
- Step 5: Commit
Task 3: EntitySet schema model¶
Files:
- Create: src/tusk/entityset.py
- Test: tests/test_entityset.py, tests/conftest.py
Interfaces:
- Consumes: tusk.exceptions.SchemaError, MissingPrimaryKeyWarning
- Produces:
- TableSchema(name: str, primary_key: str | None, row_creation_time: str | None, dtypes: Mapping[str, DType])
- Relationship(parent: str, child: str, foreign_key: str)
- EntitySet(id: str) with .add_dataframe(name, dataframe, primary_key=None, row_creation_time=None) -> EntitySet, .add_relationship(parent, child, foreign_key) -> EntitySet, .schema(name) -> TableSchema, .frame(name) -> nw.LazyFrame, .children_of(name) -> list[Relationship], .parents_of(name) -> list[Relationship], .key_columns(name) -> frozenset[str], .is_eager -> bool, .table_names -> tuple[str, ...]
- Step 1: Write the shared fixture
# tests/conftest.py
"""Shared fixtures. The backend list is the portability knob: adding a backend
here is intended to be a one-line change."""
import datetime as dt
import polars as pl
import pytest
import tusk
BACKENDS = ["polars"]
@pytest.fixture(params=BACKENDS)
def backend(request):
"""Name of the dataframe backend under test."""
return request.param
def _frames():
"""Three related tables with hand-checkable values.
customers 1 and 2 have sessions; customer 3 has none (empty-group case).
session 30 has no transactions (nested empty-group case).
"""
customers = pl.LazyFrame(
{
"id": [1, 2, 3],
"age": [30, 40, 50],
"signed_up_at": [dt.datetime(2024, 1, 1)] * 3,
}
)
sessions = pl.LazyFrame(
{
"id": [10, 20, 30],
"customer_id": [1, 1, 2],
"started_at": [
dt.datetime(2024, 3, 4), # Monday
dt.datetime(2024, 3, 5),
dt.datetime(2024, 3, 6),
],
}
)
transactions = pl.LazyFrame(
{
"id": [100, 101, 102, 103],
"session_id": [10, 10, 20, 20],
"amount": [1.0, 3.0, 10.0, 20.0],
"occurred_at": [
dt.datetime(2024, 3, 4, 1),
dt.datetime(2024, 3, 4, 2),
dt.datetime(2024, 3, 5, 1),
dt.datetime(2024, 3, 5, 2),
],
}
)
return customers, sessions, transactions
@pytest.fixture
def es():
"""A three-table retail entity set."""
customers, sessions, transactions = _frames()
return (
tusk.EntitySet("retail")
.add_dataframe("customers", customers, primary_key="id",
row_creation_time="signed_up_at")
.add_dataframe("sessions", sessions, primary_key="id",
row_creation_time="started_at")
.add_dataframe("transactions", transactions, primary_key="id",
row_creation_time="occurred_at")
.add_relationship(parent="customers", child="sessions",
foreign_key="customer_id")
.add_relationship(parent="sessions", child="transactions",
foreign_key="session_id")
)
- Step 2: Write the failing test
# tests/test_entityset.py
import narwhals as nw
import polars as pl
import pytest
import tusk
from tusk.entityset import Relationship
from tusk.exceptions import MissingPrimaryKeyWarning, SchemaError
def test_schema_is_read_from_the_frame(es):
schema = es.schema("transactions")
assert schema.primary_key == "id"
assert schema.row_creation_time == "occurred_at"
assert schema.dtypes["amount"] == nw.Float64
def test_relationship_accessors(es):
assert es.children_of("customers") == [
Relationship(parent="customers", child="sessions", foreign_key="customer_id")
]
assert es.parents_of("transactions") == [
Relationship(parent="sessions", child="transactions", foreign_key="session_id")
]
assert es.children_of("transactions") == []
def test_key_columns_include_pk_fk_and_time(es):
assert es.key_columns("sessions") == frozenset({"id", "customer_id", "started_at"})
def test_missing_primary_key_warns():
with pytest.warns(MissingPrimaryKeyWarning, match="cannot be used as a relationship parent"):
tusk.EntitySet("x").add_dataframe("t", pl.LazyFrame({"a": [1]}))
def test_unknown_column_raises():
with pytest.raises(SchemaError, match="nope"):
tusk.EntitySet("x").add_dataframe("t", pl.LazyFrame({"a": [1]}), primary_key="nope")
def test_composite_key_raises():
with pytest.raises(SchemaError, match="[Cc]omposite"):
tusk.EntitySet("x").add_dataframe("t", pl.LazyFrame({"a": [1]}), primary_key=["a"])
def test_parent_without_primary_key_raises():
es = tusk.EntitySet("x").add_dataframe("p", pl.LazyFrame({"a": [1]}), primary_key="a")
with pytest.warns(MissingPrimaryKeyWarning):
es.add_dataframe("c", pl.LazyFrame({"a": [1], "p_a": [1]}))
with pytest.raises(SchemaError, match="primary_key"):
es.add_relationship(parent="c", child="p", foreign_key="a")
def test_unknown_foreign_key_raises(es):
with pytest.raises(SchemaError, match="missing"):
es.add_relationship(parent="customers", child="sessions", foreign_key="nope")
def test_eager_input_is_recorded_and_lazified():
es = tusk.EntitySet("x").add_dataframe("t", pl.DataFrame({"a": [1]}), primary_key="a")
assert es.is_eager is True
assert isinstance(es.frame("t"), nw.LazyFrame)
def test_self_reference_is_allowed():
es = tusk.EntitySet("x").add_dataframe(
"employees", pl.LazyFrame({"id": [1, 2], "manager_id": [None, 1]}), primary_key="id"
)
es.add_relationship(parent="employees", child="employees", foreign_key="manager_id")
assert es.children_of("employees")[0].foreign_key == "manager_id"
- Step 3: Run test to verify it fails
Run: uv run pytest tests/test_entityset.py -v
Expected: FAIL — AttributeError: module 'tusk' has no attribute 'EntitySet'
- Step 4: Implement
# src/tusk/entityset.py
"""The schema model: tables, relationships, and the entity set that holds them."""
from __future__ import annotations
import warnings
from dataclasses import dataclass
from typing import Any, Mapping
import narwhals as nw
from tusk.exceptions import MissingPrimaryKeyWarning, SchemaError
@dataclass(frozen=True)
class TableSchema:
"""Everything phase 1 knows about a table.
Attributes:
name: Table name within the entity set.
primary_key: Column uniquely identifying a row, if declared.
row_creation_time: Column recording when a row became knowable.
dtypes: Mapping of column name to narwhals dtype.
"""
name: str
primary_key: str | None
row_creation_time: str | None
dtypes: Mapping[str, Any]
@dataclass(frozen=True)
class Relationship:
"""A one-to-many link from a parent table to a child table.
The parent side is always the parent's ``primary_key``; ``foreign_key``
names the child's column.
Attributes:
parent: Name of the parent table.
child: Name of the child table.
foreign_key: Column on the child pointing at the parent's primary key.
"""
parent: str
child: str
foreign_key: str
class EntitySet:
"""A collection of related tables that DFS can synthesize features over."""
def __init__(self, id: str) -> None:
"""Create an empty entity set.
Args:
id: Human-readable identifier for this entity set.
"""
self.id = id
self._frames: dict[str, nw.LazyFrame] = {}
self._schemas: dict[str, TableSchema] = {}
self._relationships: list[Relationship] = []
self._backend: Any = None
self._is_eager: bool | None = None
@property
def is_eager(self) -> bool:
"""Whether the caller supplied eager frames."""
return bool(self._is_eager)
@property
def table_names(self) -> tuple[str, ...]:
"""Names of every table in the entity set."""
return tuple(self._schemas)
def add_dataframe(
self,
name: str,
dataframe: Any,
primary_key: str | None = None,
row_creation_time: str | None = None,
) -> EntitySet:
"""Add a table to the entity set.
Args:
name: Name to register the table under.
dataframe: A native frame or a narwhals frame.
primary_key: Column uniquely identifying a row. Required for a
table used as a relationship parent or as the DFS target.
row_creation_time: Column recording when a row became knowable.
Required for order-dependent primitives on this table.
Returns:
This entity set, to allow chaining.
Raises:
SchemaError: If the name is taken, a declared column is missing,
a key is composite, or the backend differs from earlier tables.
Warns:
MissingPrimaryKeyWarning: If ``primary_key`` is omitted.
"""
if name in self._schemas:
raise SchemaError(f"table {name!r} is already in this entity set")
_reject_composite(primary_key, "primary_key")
_reject_composite(row_creation_time, "row_creation_time")
frame = dataframe if isinstance(dataframe, (nw.DataFrame, nw.LazyFrame)) else nw.from_native(dataframe)
is_eager = isinstance(frame, nw.DataFrame)
lazy = frame.lazy() if is_eager else frame
if self._backend is None:
self._backend = lazy.implementation
self._is_eager = is_eager
elif lazy.implementation != self._backend:
raise SchemaError(
f"table {name!r} uses backend {lazy.implementation}, but this entity set "
f"uses {self._backend}; narwhals cannot join across backends"
)
dtypes = dict(lazy.collect_schema())
for column, label in ((primary_key, "primary_key"), (row_creation_time, "row_creation_time")):
if column is not None and column not in dtypes:
raise SchemaError(f"{label} {column!r} is not a column of {name!r}")
if primary_key is None:
warnings.warn(
f"{name!r} has no primary_key: it cannot be used as a relationship parent "
f"or as a DFS target, and order-dependent primitives on it will have "
f"non-deterministic tiebreaks",
MissingPrimaryKeyWarning,
stacklevel=2,
)
self._frames[name] = lazy
self._schemas[name] = TableSchema(name, primary_key, row_creation_time, dtypes)
return self
def add_relationship(self, parent: str, child: str, foreign_key: str) -> EntitySet:
"""Link a parent table to a child table.
Args:
parent: Name of the parent table. Must have a ``primary_key``.
child: Name of the child table.
foreign_key: The child's column pointing at the parent's primary key.
Returns:
This entity set, to allow chaining.
Raises:
SchemaError: If a table is unknown, the parent has no primary key,
the foreign key is composite, or the child lacks that column.
"""
_reject_composite(foreign_key, "foreign_key")
for table in (parent, child):
if table not in self._schemas:
raise SchemaError(f"unknown table {table!r}")
if self._schemas[parent].primary_key is None:
raise SchemaError(f"parent table {parent!r} needs a primary_key to be a relationship parent")
if foreign_key not in self._schemas[child].dtypes:
raise SchemaError(f"child table {child!r} is missing foreign_key column {foreign_key!r}")
self._relationships.append(Relationship(parent, child, foreign_key))
return self
def schema(self, name: str) -> TableSchema:
"""Return a table's schema.
Args:
name: Table name.
Returns:
The table's schema.
Raises:
SchemaError: If the table is unknown.
"""
try:
return self._schemas[name]
except KeyError:
raise SchemaError(f"unknown table {name!r}") from None
def frame(self, name: str) -> nw.LazyFrame:
"""Return a table's lazy frame.
Args:
name: Table name.
Returns:
The table's narwhals LazyFrame.
Raises:
SchemaError: If the table is unknown.
"""
try:
return self._frames[name]
except KeyError:
raise SchemaError(f"unknown table {name!r}") from None
def children_of(self, name: str) -> list[Relationship]:
"""Return relationships where this table is the parent.
Args:
name: Table name.
Returns:
Matching relationships, in insertion order.
"""
return [r for r in self._relationships if r.parent == name]
def parents_of(self, name: str) -> list[Relationship]:
"""Return relationships where this table is the child.
Args:
name: Table name.
Returns:
Matching relationships, in insertion order.
"""
return [r for r in self._relationships if r.child == name]
def key_columns(self, name: str) -> frozenset[str]:
"""Return columns excluded from use as primitive inputs.
Primary keys, foreign keys, and the row creation time are structural,
not measurements: ``MEAN(customer_id)`` is noise.
Args:
name: Table name.
Returns:
The table's structural column names.
"""
schema = self.schema(name)
keys = {schema.primary_key, schema.row_creation_time}
keys.update(r.foreign_key for r in self.parents_of(name))
keys.discard(None)
return frozenset(keys) # type: ignore[arg-type]
def _reject_composite(value: Any, label: str) -> None:
"""Raise if a key was given as a sequence.
Args:
value: The declared key.
label: Parameter name, used in the message.
Raises:
SchemaError: If the value is a list or tuple.
"""
if isinstance(value, (list, tuple)):
raise SchemaError(f"composite {label} is not supported; got {value!r}")
Add to src/tusk/__init__.py:
from tusk.entityset import EntitySet, Relationship, TableSchema
__all__ = ["__version__", "EntitySet", "Relationship", "TableSchema"]
- Step 5: Run tests
Run: uv run pytest tests/test_entityset.py -v
Expected: PASS (10 tests)
- Step 6: Commit
git add src/tusk/entityset.py src/tusk/__init__.py tests/test_entityset.py tests/conftest.py
git commit -m "feat: add EntitySet schema model"
Task 4: Primitive base classes and registry¶
Files:
- Create: src/tusk/primitives/__init__.py, src/tusk/primitives/base.py, src/tusk/primitives/registry.py
- Test: tests/test_primitives_base.py
Interfaces:
- Consumes: tusk.dtypes.DtypeFamily, tusk.exceptions.PrimitiveError
- Produces:
- Primitive with ClassVars name, input_dtypes: tuple[DtypeFamily, ...], output_dtype, commutative, stack_on_self, default_value; methods return_dtype(input_dtypes) -> DType, generate_name(arg_names) -> str, output_names(base_name) -> tuple[str, ...], outputs(*inputs) -> tuple[nw.Expr, ...], property number_of_outputs -> int, abstract build(*inputs) -> nw.Expr | Sequence[nw.Expr]
- AggregationPrimitive(Primitive), TransformPrimitive(Primitive) with ClassVar order_dependent: bool = False
- registry.register(cls), registry.resolve(spec) -> Primitive, registry.resolve_all(specs) -> tuple[Primitive, ...]
- Step 1: Write the failing test
# tests/test_primitives_base.py
import pickle
from dataclasses import FrozenInstanceError, dataclass, is_dataclass
import narwhals as nw
import polars as pl
import pytest
import tusk.primitives # noqa: F401 -- registers the built-in primitives
from tusk.dtypes import DtypeFamily as F
from tusk.exceptions import PrimitiveError
from tusk.primitives.base import AggregationPrimitive, TransformPrimitive
from tusk.primitives.registry import _REGISTRY, register, resolve, resolve_all
@register
@dataclass(frozen=True)
class Doubled(TransformPrimitive):
name = "doubled"
input_dtypes = (F.NUMERIC,)
def build(self, expr):
return expr * 2
@register
@dataclass(frozen=True)
class Spread(AggregationPrimitive):
name = "spread"
input_dtypes = (F.NUMERIC,)
output_dtype = nw.Float64
def build(self, expr):
return expr.max() - expr.min()
@dataclass(frozen=True)
class Pair(AggregationPrimitive):
name = "pair"
input_dtypes = (F.NUMERIC,)
output_dtype = nw.Float64
scale: float = 1.0
@property
def number_of_outputs(self):
return 2
def build(self, expr):
return [expr.min() * self.scale, expr.max() * self.scale]
def test_name_generation():
assert Doubled().generate_name(("amount",)) == "DOUBLED(amount)"
def test_single_output_names():
assert Spread().output_names("SPREAD(amount)") == ("SPREAD(amount)",)
def test_multi_output_names_are_indexed():
assert Pair().output_names("PAIR(amount)") == ("PAIR(amount)[0]", "PAIR(amount)[1]")
def test_outputs_is_always_a_tuple():
assert len(Spread().outputs(nw.col("a"))) == 1
assert len(Pair().outputs(nw.col("a"))) == 2
def test_return_dtype_preserves_input_by_default():
assert Doubled().return_dtype((nw.Int64(),)) == nw.Int64()
assert Spread().return_dtype((nw.Int64(),)) == nw.Float64
def test_parameters_are_dataclass_fields_and_affect_equality():
assert Pair(scale=2.0) != Pair(scale=1.0)
assert Pair(scale=2.0) == Pair(scale=2.0)
def test_registry_resolves_names_and_passes_instances_through():
assert resolve("doubled") == Doubled()
instance = Pair(scale=3.0)
assert resolve(instance) is instance
assert resolve_all(["doubled", "spread"]) == (Doubled(), Spread())
def test_unknown_primitive_lists_alternatives():
with pytest.raises(PrimitiveError, match="doubled"):
resolve("dubbled")
def test_expressions_actually_evaluate():
lf = nw.from_native(pl.LazyFrame({"g": [1, 1], "a": [2.0, 5.0]}))
got = lf.group_by("g").agg(Spread().outputs(nw.col("a"))[0].alias("s")).collect()
assert got.to_native()["s"][0] == 3.0
def test_every_registered_primitive_is_a_frozen_dataclass():
for name in _REGISTRY:
primitive = resolve(name)
assert is_dataclass(primitive), f"{name} is not a dataclass"
with pytest.raises(FrozenInstanceError):
setattr(primitive, "name", "mutated") # noqa: B010
def test_every_registered_primitive_round_trips_through_pickle():
# Primitives built by a metaprogramming helper rather than a class body
# land in the wrong module and silently break any process-parallel or
# cached use. Instantiating via the registry keeps this honest.
for name in _REGISTRY:
primitive = resolve(name)
assert pickle.loads(pickle.dumps(primitive)) == primitive
- Step 2: Run test to verify it fails
Run: uv run pytest tests/test_primitives_base.py -v
Expected: FAIL — ModuleNotFoundError: No module named 'tusk.primitives'
- Step 3: Implement the base classes
# src/tusk/primitives/base.py
"""Primitive protocol.
A primitive builds narwhals expressions and never sees a value. That is what
lets feature computation push down into the backend engine. Featuretools' model
-- a callable over a materialized pandas Series -- is deliberately not used.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any, ClassVar, Sequence
import narwhals as nw
from tusk.dtypes import DtypeFamily
class Primitive(ABC):
"""Base class for every primitive.
Attributes:
name: Registry key, also the upper-cased stem of generated names.
input_dtypes: One dtype family per input. Empty means the primitive
takes no column input, e.g. ``count``.
output_dtype: Fixed output dtype, or None to preserve the first input's.
commutative: Whether argument order is irrelevant, so that only one of
``f(a, b)`` and ``f(b, a)`` is generated.
stack_on_self: Whether this primitive may be applied to its own output.
default_value: Value substituted for empty groups after a left join.
"""
name: ClassVar[str]
input_dtypes: ClassVar[tuple[DtypeFamily, ...]] = ()
output_dtype: ClassVar[Any] = None
commutative: ClassVar[bool] = False
stack_on_self: ClassVar[bool] = True
default_value: ClassVar[Any] = None
@property
def number_of_outputs(self) -> int:
"""How many columns this primitive produces."""
return 1
def return_dtype(self, input_dtypes: tuple[Any, ...]) -> Any:
"""Compute the output dtype without touching data.
Args:
input_dtypes: Dtypes of the input features, in order.
Returns:
The dtype of this primitive's output.
"""
if self.output_dtype is not None:
return self.output_dtype
return input_dtypes[0]
def generate_name(self, arg_names: Sequence[str]) -> str:
"""Build the display name for an application of this primitive.
Args:
arg_names: Names of the inputs. For a zero-input aggregation this
is the child table's name, giving e.g. ``COUNT(transactions)``.
Returns:
The feature name.
"""
return f"{self.name.upper()}({', '.join(arg_names)})"
def output_names(self, base_name: str) -> tuple[str, ...]:
"""Expand a feature name into one name per output column.
Args:
base_name: The name from :meth:`generate_name`.
Returns:
One name per output column; indexed when there is more than one.
"""
if self.number_of_outputs == 1:
return (base_name,)
return tuple(f"{base_name}[{i}]" for i in range(self.number_of_outputs))
def outputs(self, *inputs: nw.Expr) -> tuple[nw.Expr, ...]:
"""Normalize :meth:`build` to a tuple of expressions.
Args:
*inputs: One expression per declared input.
Returns:
One expression per output column.
"""
built = self.build(*inputs)
if isinstance(built, (list, tuple)):
return tuple(built)
return (built,)
@abstractmethod
def build(self, *inputs: nw.Expr) -> nw.Expr | Sequence[nw.Expr]:
"""Build this primitive's narwhals expression.
Args:
*inputs: One expression per declared input.
Returns:
A single expression, or a sequence for multi-output primitives.
"""
class AggregationPrimitive(Primitive):
"""A primitive applied to a child table's rows, grouped by foreign key."""
class TransformPrimitive(Primitive):
"""A primitive applied row-wise within a single table.
Attributes:
order_dependent: Whether the expression needs an explicit ordering.
Narwhals requires ``.over(order_by=...)`` for these on lazy
backends, so tusk requires a ``row_creation_time`` on the table.
"""
order_dependent: ClassVar[bool] = False
- Step 4: Implement the registry
# src/tusk/primitives/registry.py
"""Name-to-primitive registry."""
from __future__ import annotations
from collections.abc import Iterable
from typing import TypeVar
from tusk.exceptions import PrimitiveError
from tusk.primitives.base import Primitive
_REGISTRY: dict[str, type[Primitive]] = {}
def register(cls: type[Primitive]) -> type[Primitive]:
"""Register a primitive class under its ``name``.
Args:
cls: The primitive class to register.
Returns:
The class unchanged, so this works as a decorator.
Raises:
PrimitiveError: If the name is already registered.
"""
if cls.name in _REGISTRY and _REGISTRY[cls.name] is not cls:
raise PrimitiveError(f"primitive name {cls.name!r} is already registered")
_REGISTRY[cls.name] = cls
return cls
def resolve(spec: str | Primitive) -> Primitive:
"""Turn a name or instance into a primitive instance.
Args:
spec: A registered primitive name, or an already-built instance.
Returns:
A primitive instance.
Raises:
PrimitiveError: If the name is not registered.
"""
if isinstance(spec, Primitive):
return spec
try:
return _REGISTRY[spec]()
except KeyError:
known = ", ".join(sorted(_REGISTRY))
raise PrimitiveError(f"unknown primitive {spec!r}; available: {known}") from None
def resolve_all(specs: Iterable[str | Primitive]) -> tuple[Primitive, ...]:
"""Resolve a collection of names or instances.
Args:
specs: Names or instances.
Returns:
Primitive instances in the given order.
"""
return tuple(resolve(spec) for spec in specs)
# src/tusk/primitives/__init__.py
"""Primitives: the expression builders DFS composes into features."""
from __future__ import annotations
from tusk.primitives.base import AggregationPrimitive, Primitive, TransformPrimitive
from tusk.primitives.registry import register, resolve, resolve_all
__all__ = [
"AggregationPrimitive",
"Primitive",
"TransformPrimitive",
"register",
"resolve",
"resolve_all",
]
- Step 5: Run tests
Run: uv run pytest tests/test_primitives_base.py -v
Expected: PASS (11 tests)
- Step 6: Commit
git add src/tusk/primitives tests/test_primitives_base.py
git commit -m "feat: add primitive protocol and registry"
Task 5: Built-in aggregation primitives¶
Files:
- Create: src/tusk/primitives/aggregation.py
- Modify: src/tusk/primitives/__init__.py
- Test: tests/test_primitives_aggregation.py
Interfaces:
- Consumes: AggregationPrimitive, register, DtypeFamily
- Produces: registered names count, sum, mean, min, max, std, median, n_unique, percent_true, quantiles; classes Count, Sum, Mean, Min, Max, Std, Median, NUnique, PercentTrue, Quantiles(qs=(0.25, 0.5, 0.75)); AGG_DEFAULTS: tuple[str, ...]
- Step 1: Write the failing test
# tests/test_primitives_aggregation.py
import narwhals as nw
import polars as pl
import pytest
from tusk.primitives.aggregation import AGG_DEFAULTS, Count, Quantiles
from tusk.primitives.registry import resolve
@pytest.fixture
def lf():
return nw.from_native(
pl.LazyFrame(
{
"g": [1, 1, 1, 2],
"v": [1.0, 2.0, 6.0, 4.0],
"b": [True, False, True, True],
"s": ["a", "a", "b", "c"],
}
)
)
def _agg(lf, primitive, column):
exprs = primitive.outputs(nw.col(column))
named = [e.alias(f"o{i}") for i, e in enumerate(exprs)]
got = lf.group_by("g").agg(*named).sort("g").collect().to_native()
return got
@pytest.mark.parametrize(
("name", "column", "expected"),
[
("sum", "v", 9.0),
("mean", "v", 3.0),
("min", "v", 1.0),
("max", "v", 6.0),
("median", "v", 2.0),
("n_unique", "s", 2),
("percent_true", "b", pytest.approx(2 / 3)),
],
)
def test_aggregations_over_group_one(lf, name, column, expected):
got = _agg(lf, resolve(name), column)
assert got["o0"][0] == expected
def test_count_takes_no_column_input(lf):
assert Count().input_dtypes == ()
got = lf.group_by("g").agg(Count().outputs()[0].alias("n")).sort("g").collect().to_native()
assert got["n"].to_list() == [3, 1]
def test_count_defaults_empty_groups_to_zero():
assert Count().default_value == 0
assert Count().stack_on_self is False
def test_count_and_n_unique_declare_int64(lf):
got = lf.group_by("g").agg(
Count().outputs()[0].alias("n"),
resolve("n_unique").outputs(nw.col("s"))[0].alias("u"),
).collect()
assert got.collect_schema()["n"] == nw.Int64
assert got.collect_schema()["u"] == nw.Int64
def test_quantiles_is_multi_output(lf):
q = Quantiles(qs=(0.0, 0.5, 1.0))
assert q.number_of_outputs == 3
assert q.output_names("QUANTILES(v)") == (
"QUANTILES(v)[0]",
"QUANTILES(v)[1]",
"QUANTILES(v)[2]",
)
got = _agg(lf, q, "v")
assert [got["o0"][0], got["o1"][0], got["o2"][0]] == [1.0, 2.0, 6.0]
def test_defaults_are_the_documented_set():
assert AGG_DEFAULTS == ("count", "sum", "mean", "min", "max", "std", "n_unique")
- Step 2: Run test to verify it fails
Run: uv run pytest tests/test_primitives_aggregation.py -v
Expected: FAIL — ModuleNotFoundError: No module named 'tusk.primitives.aggregation'
- Step 3: Implement
Note the casts: n_unique and len return unsigned types on polars, so cast to Int64 to make the declared output_dtype truthful on every backend. percent_true casts the boolean before averaging, because a boolean mean is not portable.
# src/tusk/primitives/aggregation.py
"""Built-in aggregation primitives.
Every expression here is legal inside a lazy ``group_by().agg()``. Length-changing
expressions such as ``mode()`` are not -- narwhals rejects them on lazy frames --
which is why ``quantiles`` rather than ``n_most_common`` is the multi-output
primitive.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Sequence
import narwhals as nw
from tusk.dtypes import DtypeFamily as F
from tusk.primitives.base import AggregationPrimitive
from tusk.primitives.registry import register
AGG_DEFAULTS: tuple[str, ...] = ("count", "sum", "mean", "min", "max", "std", "n_unique")
@register
@dataclass(frozen=True)
class Count(AggregationPrimitive):
"""Number of child rows in the group."""
name = "count"
input_dtypes = ()
output_dtype = nw.Int64
default_value = 0
stack_on_self = False
def build(self) -> nw.Expr:
"""Build the row-count expression.
Returns:
A narwhals expression counting rows.
"""
return nw.len().cast(nw.Int64)
@register
@dataclass(frozen=True)
class Sum(AggregationPrimitive):
"""Sum of a numeric column."""
name = "sum"
input_dtypes = (F.NUMERIC,)
def build(self, expr: nw.Expr) -> nw.Expr:
"""Build the sum expression.
Args:
expr: The column to sum.
Returns:
A narwhals expression.
"""
return expr.sum()
@register
@dataclass(frozen=True)
class Mean(AggregationPrimitive):
"""Arithmetic mean of a numeric column."""
name = "mean"
input_dtypes = (F.NUMERIC,)
output_dtype = nw.Float64
def build(self, expr: nw.Expr) -> nw.Expr:
"""Build the mean expression.
Args:
expr: The column to average.
Returns:
A narwhals expression.
"""
return expr.mean()
@register
@dataclass(frozen=True)
class Min(AggregationPrimitive):
"""Smallest value of a numeric column."""
name = "min"
input_dtypes = (F.NUMERIC,)
def build(self, expr: nw.Expr) -> nw.Expr:
"""Build the minimum expression.
Args:
expr: The column to reduce.
Returns:
A narwhals expression.
"""
return expr.min()
@register
@dataclass(frozen=True)
class Max(AggregationPrimitive):
"""Largest value of a numeric column."""
name = "max"
input_dtypes = (F.NUMERIC,)
def build(self, expr: nw.Expr) -> nw.Expr:
"""Build the maximum expression.
Args:
expr: The column to reduce.
Returns:
A narwhals expression.
"""
return expr.max()
@register
@dataclass(frozen=True)
class Std(AggregationPrimitive):
"""Sample standard deviation of a numeric column."""
name = "std"
input_dtypes = (F.NUMERIC,)
output_dtype = nw.Float64
def build(self, expr: nw.Expr) -> nw.Expr:
"""Build the standard-deviation expression.
Args:
expr: The column to reduce.
Returns:
A narwhals expression.
"""
return expr.std()
@register
@dataclass(frozen=True)
class Median(AggregationPrimitive):
"""Median of a numeric column."""
name = "median"
input_dtypes = (F.NUMERIC,)
output_dtype = nw.Float64
def build(self, expr: nw.Expr) -> nw.Expr:
"""Build the median expression.
Args:
expr: The column to reduce.
Returns:
A narwhals expression.
"""
return expr.median()
@register
@dataclass(frozen=True)
class NUnique(AggregationPrimitive):
"""Number of distinct values in a column."""
name = "n_unique"
input_dtypes = (F.ANY,)
output_dtype = nw.Int64
default_value = 0
stack_on_self = False
def build(self, expr: nw.Expr) -> nw.Expr:
"""Build the distinct-count expression.
Args:
expr: The column to count distinct values of.
Returns:
A narwhals expression.
"""
return expr.n_unique().cast(nw.Int64)
@register
@dataclass(frozen=True)
class PercentTrue(AggregationPrimitive):
"""Fraction of rows where a boolean column is true."""
name = "percent_true"
input_dtypes = (F.BOOLEAN,)
output_dtype = nw.Float64
def build(self, expr: nw.Expr) -> nw.Expr:
"""Build the true-fraction expression.
Args:
expr: The boolean column.
Returns:
A narwhals expression.
"""
return expr.cast(nw.Int64).mean()
@register
@dataclass(frozen=True)
class Quantiles(AggregationPrimitive):
"""Several quantiles of a numeric column, one output column per quantile.
Attributes:
qs: The quantiles to compute, each in [0, 1].
"""
name = "quantiles"
input_dtypes = (F.NUMERIC,)
output_dtype = nw.Float64
qs: tuple[float, ...] = field(default=(0.25, 0.5, 0.75))
@property
def number_of_outputs(self) -> int:
"""One output column per requested quantile."""
return len(self.qs)
def build(self, expr: nw.Expr) -> Sequence[nw.Expr]:
"""Build one expression per quantile.
Args:
expr: The column to reduce.
Returns:
One narwhals expression per quantile.
"""
return [expr.quantile(q, interpolation="linear") for q in self.qs]
Add to src/tusk/primitives/__init__.py:
from tusk.primitives.aggregation import (
AGG_DEFAULTS,
Count,
Max,
Mean,
Median,
Min,
NUnique,
PercentTrue,
Quantiles,
Std,
Sum,
)
and extend __all__ with "AGG_DEFAULTS", "Count", "Max", "Mean", "Median", "Min", "NUnique", "PercentTrue", "Quantiles", "Std", "Sum".
- Step 4: Run tests
Run: uv run pytest tests/test_primitives_aggregation.py -v
Expected: PASS (12 tests)
- Step 5: Commit
git add src/tusk/primitives/aggregation.py src/tusk/primitives/__init__.py tests/test_primitives_aggregation.py
git commit -m "feat: add built-in aggregation primitives"
Task 6: Built-in transform primitives¶
Files:
- Create: src/tusk/primitives/transform.py
- Modify: src/tusk/primitives/__init__.py
- Test: tests/test_primitives_transform.py
Interfaces:
- Consumes: TransformPrimitive, register, DtypeFamily
- Produces: registered names year, month, day, hour, weekday, is_weekend, absolute, natural_log, add_numeric, subtract_numeric, multiply_numeric, divide_numeric, cum_sum, cum_count, cum_min, cum_max, diff, time_since_previous; TRANS_DEFAULTS: tuple[str, ...]
- Step 1: Write the failing test
# tests/test_primitives_transform.py
import datetime as dt
import narwhals as nw
import polars as pl
import pytest
from tusk.primitives.registry import resolve
from tusk.primitives.transform import TRANS_DEFAULTS
@pytest.fixture
def lf():
return nw.from_native(
pl.LazyFrame(
{
"g": [1, 1, 1],
"t": [dt.datetime(2024, 3, 4, 5), dt.datetime(2024, 3, 9, 6), dt.datetime(2024, 3, 10, 7)],
"v": [-2.0, 3.0, 4.0],
"w": [1.0, 1.0, 2.0],
}
)
)
def _apply(lf, name, *columns):
primitive = resolve(name)
expr = primitive.outputs(*[nw.col(c) for c in columns])[0]
if primitive.order_dependent:
expr = expr.over(order_by="t")
return lf.with_columns(expr.alias("o")).collect().to_native()["o"].to_list()
@pytest.mark.parametrize(
("name", "columns", "expected"),
[
("year", ("t",), [2024, 2024, 2024]),
("month", ("t",), [3, 3, 3]),
("day", ("t",), [4, 9, 10]),
("hour", ("t",), [5, 6, 7]),
("weekday", ("t",), [1, 6, 7]),
("is_weekend", ("t",), [False, True, True]),
("absolute", ("v",), [2.0, 3.0, 4.0]),
("add_numeric", ("v", "w"), [-1.0, 4.0, 6.0]),
("subtract_numeric", ("v", "w"), [-3.0, 2.0, 2.0]),
("multiply_numeric", ("v", "w"), [-2.0, 3.0, 8.0]),
("divide_numeric", ("v", "w"), [-2.0, 3.0, 2.0]),
],
)
def test_row_wise_transforms(lf, name, columns, expected):
assert _apply(lf, name, *columns) == expected
@pytest.mark.parametrize(
("name", "expected"),
[
("cum_sum", [-2.0, 1.0, 5.0]),
("cum_max", [-2.0, 3.0, 4.0]),
("cum_min", [-2.0, -2.0, -2.0]),
("diff", [None, 5.0, 1.0]),
],
)
def test_order_dependent_transforms(lf, name, expected):
assert _apply(lf, name, "v") == expected
def test_order_dependent_primitives_are_flagged():
assert resolve("cum_sum").order_dependent is True
assert resolve("month").order_dependent is False
def test_time_since_previous_is_seconds(lf):
got = _apply(lf, "time_since_previous", "t")
assert got[0] is None
assert got[1] == pytest.approx(5 * 86400 + 3600)
def test_arithmetic_commutativity_flags():
assert resolve("add_numeric").commutative is True
assert resolve("subtract_numeric").commutative is False
def test_defaults_exclude_arithmetic():
assert TRANS_DEFAULTS == ("year", "month", "weekday")
- Step 2: Run test to verify it fails
Run: uv run pytest tests/test_primitives_transform.py -v
Expected: FAIL — ModuleNotFoundError: No module named 'tusk.primitives.transform'
- Step 3: Implement
# src/tusk/primitives/transform.py
"""Built-in transform primitives.
Primitives with ``order_dependent = True`` must be wrapped by the compiler in
``.over(..., order_by=...)``; narwhals requires this on lazy backends and will
raise otherwise.
"""
from __future__ import annotations
from dataclasses import dataclass
import narwhals as nw
from tusk.dtypes import DtypeFamily as F
from tusk.primitives.base import TransformPrimitive
from tusk.primitives.registry import register
TRANS_DEFAULTS: tuple[str, ...] = ("year", "month", "weekday")
@register
@dataclass(frozen=True)
class Year(TransformPrimitive):
"""Calendar year."""
name = "year"
input_dtypes = (F.TEMPORAL,)
output_dtype = nw.Int32
def build(self, expr: nw.Expr) -> nw.Expr:
"""Build the year expression."""
return expr.dt.year()
@register
@dataclass(frozen=True)
class Month(TransformPrimitive):
"""Calendar month, 1-12."""
name = "month"
input_dtypes = (F.TEMPORAL,)
output_dtype = nw.Int8
def build(self, expr: nw.Expr) -> nw.Expr:
"""Build the month expression."""
return expr.dt.month()
@register
@dataclass(frozen=True)
class Day(TransformPrimitive):
"""Day of month, 1-31."""
name = "day"
input_dtypes = (F.TEMPORAL,)
output_dtype = nw.Int8
def build(self, expr: nw.Expr) -> nw.Expr:
"""Build the day expression."""
return expr.dt.day()
@register
@dataclass(frozen=True)
class Hour(TransformPrimitive):
"""Hour of day, 0-23."""
name = "hour"
input_dtypes = (F.TEMPORAL,)
output_dtype = nw.Int8
def build(self, expr: nw.Expr) -> nw.Expr:
"""Build the hour expression."""
return expr.dt.hour()
@register
@dataclass(frozen=True)
class Weekday(TransformPrimitive):
"""ISO weekday, 1 (Monday) to 7 (Sunday)."""
name = "weekday"
input_dtypes = (F.TEMPORAL,)
output_dtype = nw.Int8
def build(self, expr: nw.Expr) -> nw.Expr:
"""Build the weekday expression."""
return expr.dt.weekday()
@register
@dataclass(frozen=True)
class IsWeekend(TransformPrimitive):
"""Whether the date falls on a Saturday or Sunday."""
name = "is_weekend"
input_dtypes = (F.TEMPORAL,)
output_dtype = nw.Boolean
def build(self, expr: nw.Expr) -> nw.Expr:
"""Build the is_weekend expression."""
return expr.dt.weekday() >= 6
@register
@dataclass(frozen=True)
class Absolute(TransformPrimitive):
"""Absolute value."""
name = "absolute"
input_dtypes = (F.NUMERIC,)
def build(self, expr: nw.Expr) -> nw.Expr:
"""Build the absolute expression."""
return expr.abs()
@register
@dataclass(frozen=True)
class NaturalLog(TransformPrimitive):
"""Natural logarithm. Non-positive inputs yield null or negative infinity."""
name = "natural_log"
input_dtypes = (F.NUMERIC,)
output_dtype = nw.Float64
def build(self, expr: nw.Expr) -> nw.Expr:
"""Build the natural_log expression."""
return expr.log()
@register
@dataclass(frozen=True)
class SubtractNumeric(TransformPrimitive):
"""Difference of two numeric columns."""
name = "subtract_numeric"
input_dtypes = (F.NUMERIC, F.NUMERIC)
def build(self, left: nw.Expr, right: nw.Expr) -> nw.Expr:
"""Build the subtract_numeric expression."""
return left - right
@register
@dataclass(frozen=True)
class DivideNumeric(TransformPrimitive):
"""Ratio of two numeric columns."""
name = "divide_numeric"
input_dtypes = (F.NUMERIC, F.NUMERIC)
output_dtype = nw.Float64
def build(self, left: nw.Expr, right: nw.Expr) -> nw.Expr:
"""Build the divide_numeric expression."""
return left / right
@register
@dataclass(frozen=True)
class AddNumeric(TransformPrimitive):
"""Sum of two numeric columns."""
name = "add_numeric"
input_dtypes = (F.NUMERIC, F.NUMERIC)
commutative = True
def build(self, left: nw.Expr, right: nw.Expr) -> nw.Expr:
"""Build the add_numeric expression."""
return left + right
@register
@dataclass(frozen=True)
class MultiplyNumeric(TransformPrimitive):
"""Product of two numeric columns."""
name = "multiply_numeric"
input_dtypes = (F.NUMERIC, F.NUMERIC)
commutative = True
def build(self, left: nw.Expr, right: nw.Expr) -> nw.Expr:
"""Build the multiply_numeric expression."""
return left * right
@register
@dataclass(frozen=True)
class CumSum(TransformPrimitive):
"""Running total in row-creation order."""
name = "cum_sum"
input_dtypes = (F.NUMERIC,)
order_dependent = True
def build(self, expr: nw.Expr) -> nw.Expr:
"""Build the cum_sum expression."""
return expr.cum_sum()
@register
@dataclass(frozen=True)
class CumCount(TransformPrimitive):
"""Running count of non-null values in row-creation order."""
name = "cum_count"
input_dtypes = (F.ANY,)
output_dtype = nw.Int64
order_dependent = True
def build(self, expr: nw.Expr) -> nw.Expr:
"""Build the cum_count expression."""
return expr.cum_count().cast(nw.Int64)
@register
@dataclass(frozen=True)
class CumMin(TransformPrimitive):
"""Running minimum in row-creation order."""
name = "cum_min"
input_dtypes = (F.NUMERIC,)
order_dependent = True
def build(self, expr: nw.Expr) -> nw.Expr:
"""Build the cum_min expression."""
return expr.cum_min()
@register
@dataclass(frozen=True)
class CumMax(TransformPrimitive):
"""Running maximum in row-creation order."""
name = "cum_max"
input_dtypes = (F.NUMERIC,)
order_dependent = True
def build(self, expr: nw.Expr) -> nw.Expr:
"""Build the cum_max expression."""
return expr.cum_max()
@register
@dataclass(frozen=True)
class Diff(TransformPrimitive):
"""Change from the previous row in row-creation order."""
name = "diff"
input_dtypes = (F.NUMERIC,)
order_dependent = True
def build(self, expr: nw.Expr) -> nw.Expr:
"""Build the diff expression."""
return expr.diff()
@register
@dataclass(frozen=True)
class TimeSincePrevious(TransformPrimitive):
"""Seconds elapsed since the previous row in row-creation order."""
name = "time_since_previous"
input_dtypes = (F.TEMPORAL,)
output_dtype = nw.Float64
order_dependent = True
def build(self, expr: nw.Expr) -> nw.Expr:
"""Build the time_since_previous expression."""
return expr.diff().dt.total_seconds().cast(nw.Float64)
Every primitive is a frozen dataclass declared with a class body — there is
no decorator shortcut for the zero-parameter case. A type()-built class
inherits the wrong __module__, which makes it unpicklable and invisible to
static analysis, and the shortcut could never cover parameterized primitives
like Quantiles anyway. Docstrings are abbreviated above; the shipped file
carries full Args/Returns sections.
Add to src/tusk/primitives/__init__.py:
from tusk.primitives.transform import (
TRANS_DEFAULTS,
Absolute,
AddNumeric,
CumCount,
CumMax,
CumMin,
CumSum,
Day,
Diff,
DivideNumeric,
Hour,
IsWeekend,
Month,
MultiplyNumeric,
NaturalLog,
SubtractNumeric,
TimeSincePrevious,
Weekday,
Year,
)
and extend __all__ with "TRANS_DEFAULTS" and every class above, matching how aggregation.py exports its own. Each is usable either by name through resolve() or as an imported class; the import is also what registers them.
- Step 4: Run tests
Run: uv run pytest tests/test_primitives_transform.py -v
Expected: PASS (20 tests). If expr.log() is unavailable on the installed narwhals, use expr.log(base=math.e); check uv run python -c "import narwhals as nw; print(nw.col('a').log)" first.
- Step 5: Commit
git add src/tusk/primitives/transform.py src/tusk/primitives/__init__.py tests/test_primitives_transform.py
git commit -m "feat: add built-in transform primitives"
Task 7: Feature definitions¶
Files:
- Create: src/tusk/features.py
- Test: tests/test_features.py
Interfaces:
- Consumes: Primitive, Relationship
- Produces frozen dataclasses, all exposing the properties .name, .dtype, .depth, .table, .base_features, .output_names. Field names differ from the property names, because .base_features is a uniform accessor over differently-shaped fields — construct positionally:
- IdentityFeature(table_name: str, column: str, column_dtype)
- TransformFeature(primitive, bases: tuple[Feature, ...])
- AggregationFeature(primitive, bases: tuple[Feature, ...], relationship) — bases is empty for zero-arity primitives such as count
- DirectFeature(base_feature: Feature, relationship)
- GroupByTransformFeature(primitive, bases: tuple[Feature, ...], relationship)
- Step 1: Write the failing test
# tests/test_features.py
import narwhals as nw
from tusk.entityset import Relationship
from tusk.features import (
AggregationFeature,
DirectFeature,
GroupByTransformFeature,
IdentityFeature,
TransformFeature,
)
from tusk.primitives.aggregation import Count, Mean, Quantiles
from tusk.primitives.registry import resolve
CUSTOMER_SESSION = Relationship("customers", "sessions", "customer_id")
SESSION_TX = Relationship("sessions", "transactions", "session_id")
amount = IdentityFeature("transactions", "amount", nw.Float64())
def test_identity_feature():
assert amount.name == "amount"
assert amount.depth == 0
assert amount.table == "transactions"
assert amount.output_names == ("amount",)
def test_aggregation_feature_names_and_depth():
feature = AggregationFeature(Mean(), (amount,), SESSION_TX)
assert feature.name == "MEAN(transactions.amount)"
assert feature.table == "sessions"
assert feature.depth == 1
assert feature.dtype == nw.Float64
def test_zero_arity_aggregation_names_the_table():
feature = AggregationFeature(Count(), (), SESSION_TX)
assert feature.name == "COUNT(transactions)"
assert feature.depth == 1
def test_stacked_aggregation_reaches_depth_two():
inner = AggregationFeature(Mean(), (amount,), SESSION_TX)
outer = AggregationFeature(resolve("sum"), (inner,), CUSTOMER_SESSION)
assert outer.name == "SUM(sessions.MEAN(transactions.amount))"
assert outer.depth == 2
assert outer.table == "customers"
def test_direct_feature():
age = IdentityFeature("customers", "age", nw.Int64())
feature = DirectFeature(age, CUSTOMER_SESSION)
assert feature.name == "customers.age"
assert feature.table == "sessions"
assert feature.depth == 1
assert feature.dtype == nw.Int64()
assert feature.base_features == (age,)
def test_transform_feature():
started = IdentityFeature("sessions", "started_at", nw.Datetime())
feature = TransformFeature(resolve("month"), (started,))
assert feature.name == "MONTH(started_at)"
assert feature.table == "sessions"
assert feature.depth == 1
def test_groupby_transform_feature_names_the_group():
feature = GroupByTransformFeature(resolve("cum_sum"), (amount,), SESSION_TX)
assert feature.name == "CUM_SUM(amount) by session_id"
assert feature.table == "transactions"
assert feature.depth == 1
def test_multi_output_feature_expands_names():
feature = AggregationFeature(Quantiles(qs=(0.5, 0.9)), (amount,), SESSION_TX)
assert feature.output_names == (
"QUANTILES(transactions.amount)[0]",
"QUANTILES(transactions.amount)[1]",
)
def test_features_deduplicate_by_structural_equality():
a = AggregationFeature(Mean(), (amount,), SESSION_TX)
b = AggregationFeature(Mean(), (amount,), SESSION_TX)
assert a == b
assert len({a, b}) == 1
- Step 2: Run test to verify it fails
Run: uv run pytest tests/test_features.py -v
Expected: FAIL — ModuleNotFoundError: No module named 'tusk.features'
- Step 3: Implement
# src/tusk/features.py
"""Feature definitions: the immutable output of phase 1.
Features are frozen dataclasses with structural equality, so a feature reached
by two different routes deduplicates in a set with no extra bookkeeping. Every
dtype here is derived from primitive metadata, never from data.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from tusk.entityset import Relationship
from tusk.primitives.base import Primitive
@dataclass(frozen=True)
class Feature:
"""Base class for every feature definition."""
@property
def name(self) -> str:
"""Display name, and the column name in the feature matrix."""
raise NotImplementedError
@property
def dtype(self) -> Any:
"""Output dtype, computed statically."""
raise NotImplementedError
@property
def depth(self) -> int:
"""Number of stacked primitive applications."""
raise NotImplementedError
@property
def table(self) -> str:
"""Table this feature is a column of."""
raise NotImplementedError
@property
def base_features(self) -> tuple[Feature, ...]:
"""Features this one is computed from."""
raise NotImplementedError
@property
def output_names(self) -> tuple[str, ...]:
"""One column name per output; more than one for multi-output primitives."""
return (self.name,)
@dataclass(frozen=True)
class IdentityFeature(Feature):
"""A raw column of a table.
Attributes:
table_name: Table the column belongs to.
column: Column name.
column_dtype: The column's narwhals dtype.
"""
table_name: str
column: str
column_dtype: Any
@property
def name(self) -> str:
"""The column's own name."""
return self.column
@property
def dtype(self) -> Any:
"""The column's dtype."""
return self.column_dtype
@property
def depth(self) -> int:
"""Identity features are depth zero."""
return 0
@property
def table(self) -> str:
"""Table the column belongs to."""
return self.table_name
@property
def base_features(self) -> tuple[Feature, ...]:
"""Identity features have no bases."""
return ()
@dataclass(frozen=True)
class TransformFeature(Feature):
"""A primitive applied row-wise to features of one table.
Attributes:
primitive: The transform primitive.
bases: Input features, all on the same table.
"""
primitive: Primitive
bases: tuple[Feature, ...]
@property
def name(self) -> str:
"""Generated name, e.g. ``MONTH(started_at)``."""
return self.primitive.generate_name([b.name for b in self.bases])
@property
def dtype(self) -> Any:
"""Dtype derived from the primitive and its inputs."""
return self.primitive.return_dtype(tuple(b.dtype for b in self.bases))
@property
def depth(self) -> int:
"""One deeper than the deepest input."""
return 1 + max(b.depth for b in self.bases)
@property
def table(self) -> str:
"""The table its inputs live on."""
return self.bases[0].table
@property
def base_features(self) -> tuple[Feature, ...]:
"""Its input features."""
return self.bases
@property
def output_names(self) -> tuple[str, ...]:
"""One name per output column."""
return self.primitive.output_names(self.name)
@dataclass(frozen=True)
class AggregationFeature(Feature):
"""A primitive applied to a child table's rows, grouped by foreign key.
Attributes:
primitive: The aggregation primitive.
bases: Input features on the child table. Empty for zero-arity
primitives such as ``count``.
relationship: The parent-child link being aggregated across.
"""
primitive: Primitive
bases: tuple[Feature, ...]
relationship: Relationship
@property
def name(self) -> str:
"""Generated name, e.g. ``MEAN(transactions.amount)``.
Zero-arity primitives name the child table instead of a column, giving
``COUNT(transactions)``.
"""
child = self.relationship.child
if not self.bases:
return self.primitive.generate_name([child])
return self.primitive.generate_name([f"{child}.{b.name}" for b in self.bases])
@property
def dtype(self) -> Any:
"""Dtype derived from the primitive and its inputs."""
return self.primitive.return_dtype(tuple(b.dtype for b in self.bases))
@property
def depth(self) -> int:
"""One deeper than the deepest input; 1 when there are none."""
return 1 + max((b.depth for b in self.bases), default=0)
@property
def table(self) -> str:
"""The parent table the aggregate lands on."""
return self.relationship.parent
@property
def base_features(self) -> tuple[Feature, ...]:
"""Its input features on the child table."""
return self.bases
@property
def output_names(self) -> tuple[str, ...]:
"""One name per output column."""
return self.primitive.output_names(self.name)
@dataclass(frozen=True)
class DirectFeature(Feature):
"""A parent's feature joined down onto the child.
Attributes:
base_feature: The feature on the parent table.
relationship: The parent-child link being traversed.
"""
base_feature: Feature
relationship: Relationship
@property
def name(self) -> str:
"""Generated name, e.g. ``customers.age``."""
return f"{self.relationship.parent}.{self.base_feature.name}"
@property
def dtype(self) -> Any:
"""The parent feature's dtype, unchanged."""
return self.base_feature.dtype
@property
def depth(self) -> int:
"""One deeper than the parent feature."""
return 1 + self.base_feature.depth
@property
def table(self) -> str:
"""The child table the value lands on."""
return self.relationship.child
@property
def base_features(self) -> tuple[Feature, ...]:
"""The single parent feature."""
return (self.base_feature,)
@dataclass(frozen=True)
class GroupByTransformFeature(Feature):
"""A transform applied within groups defined by a foreign key.
Attributes:
primitive: The transform primitive.
bases: Input features on the child table.
relationship: The link whose foreign key defines the groups.
"""
primitive: Primitive
bases: tuple[Feature, ...]
relationship: Relationship
@property
def name(self) -> str:
"""Generated name, e.g. ``CUM_SUM(amount) by session_id``."""
stem = self.primitive.generate_name([b.name for b in self.bases])
return f"{stem} by {self.relationship.foreign_key}"
@property
def dtype(self) -> Any:
"""Dtype derived from the primitive and its inputs."""
return self.primitive.return_dtype(tuple(b.dtype for b in self.bases))
@property
def depth(self) -> int:
"""One deeper than the deepest input."""
return 1 + max(b.depth for b in self.bases)
@property
def table(self) -> str:
"""The child table the values land on."""
return self.relationship.child
@property
def base_features(self) -> tuple[Feature, ...]:
"""Its input features."""
return self.bases
@property
def output_names(self) -> tuple[str, ...]:
"""One name per output column."""
return self.primitive.output_names(self.name)
- Step 4: Run tests
Run: uv run pytest tests/test_features.py -v
Expected: PASS (9 tests)
- Step 5: Commit
git add src/tusk/features.py tests/test_features.py
git commit -m "feat: add feature definition types"
Task 8: Phase 1 synthesis¶
Files:
- Create: src/tusk/synthesis.py
- Modify: src/tusk/exceptions.py (add CategoricalDtypeWarning)
- Test: tests/test_synthesis.py
Interfaces:
- Consumes: EntitySet, all feature types, Primitive, resolve_all, matches
- Produces: synthesize(entityset, target_dataframe_name, agg_primitives, trans_primitives, groupby_trans_primitives, max_depth) -> list[Feature]
Critical: this module must not import tusk.compiler, and must not touch any frame — only entityset.schema().
- Step 1: Write the failing test
# tests/test_synthesis.py
import polars as pl
import pytest
import tusk
from tusk.features import AggregationFeature, IdentityFeature
from tusk.synthesis import synthesize
def names(features):
return {f.name for f in features}
def test_depth_one_aggregations(es):
got = synthesize(es, "customers", agg_primitives=["count", "mean"],
trans_primitives=[], groupby_trans_primitives=[], max_depth=1)
assert names(got) == {"age", "COUNT(sessions)"}
def test_depth_two_stacks_through_two_relationships(es):
got = synthesize(es, "customers", agg_primitives=["count", "mean"],
trans_primitives=[], groupby_trans_primitives=[], max_depth=2)
assert "MEAN(sessions.MEAN(transactions.amount))" in names(got)
assert "MEAN(sessions.COUNT(transactions))" in names(got)
assert "COUNT(sessions)" in names(got)
def test_target_keys_are_not_emitted_as_features(es):
got = synthesize(es, "customers", agg_primitives=["count"],
trans_primitives=[], groupby_trans_primitives=[], max_depth=1)
assert "id" not in names(got)
assert "signed_up_at" not in names(got)
def test_never_traverses_back_so_target_columns_do_not_return(es):
got = synthesize(es, "customers", agg_primitives=["mean"],
trans_primitives=[], groupby_trans_primitives=[], max_depth=3)
assert not any("customers.age" in n for n in names(got))
def test_direct_features_come_from_parents(es):
got = synthesize(es, "sessions", agg_primitives=[],
trans_primitives=[], groupby_trans_primitives=[], max_depth=1)
assert "customers.age" in names(got)
def test_transforms_respect_dtype_families(es):
got = synthesize(es, "sessions", agg_primitives=[], trans_primitives=["month", "absolute"],
groupby_trans_primitives=[], max_depth=1)
# started_at is the row_creation_time, a key column, so it is not an input;
# there is no other temporal or numeric column on sessions.
assert not any(n.startswith("MONTH") for n in names(got))
def test_transform_stacks_on_aggregation(es):
got = synthesize(es, "customers", agg_primitives=["mean"], trans_primitives=["absolute"],
groupby_trans_primitives=[], max_depth=2)
assert "ABSOLUTE(MEAN(sessions.MEAN(transactions.amount)))" not in names(got)
assert "MEAN(sessions.MEAN(transactions.amount))" in names(got)
def test_groupby_transform_features(es):
got = synthesize(es, "transactions", agg_primitives=[], trans_primitives=[],
groupby_trans_primitives=["cum_sum"], max_depth=1)
assert "CUM_SUM(amount) by session_id" in names(got)
def test_stack_on_self_is_respected(es):
got = synthesize(es, "customers", agg_primitives=["count"],
trans_primitives=[], groupby_trans_primitives=[], max_depth=2)
assert "COUNT(sessions)" in names(got)
assert not any(n.startswith("COUNT(sessions.COUNT") for n in names(got))
def test_self_referential_schema_terminates():
es = (
tusk.EntitySet("hr")
.add_dataframe("employees",
pl.LazyFrame({"id": [1, 2], "manager_id": [None, 1], "salary": [1.0, 2.0]}),
primary_key="id")
.add_relationship(parent="employees", child="employees", foreign_key="manager_id")
)
got = synthesize(es, "employees", agg_primitives=["mean"], trans_primitives=[],
groupby_trans_primitives=[], max_depth=3)
assert "MEAN(employees.salary)" in names(got)
def test_diamond_schema_terminates():
es = (
tusk.EntitySet("d")
.add_dataframe("a", pl.LazyFrame({"id": [1], "v": [1.0]}), primary_key="id")
.add_dataframe("b", pl.LazyFrame({"id": [1], "a_id": [1], "v": [1.0]}), primary_key="id")
.add_dataframe("c", pl.LazyFrame({"id": [1], "a_id": [1], "v": [1.0]}), primary_key="id")
.add_dataframe("d", pl.LazyFrame({"id": [1], "b_id": [1], "c_id": [1], "v": [1.0]}), primary_key="id")
.add_relationship(parent="a", child="b", foreign_key="a_id")
.add_relationship(parent="a", child="c", foreign_key="a_id")
.add_relationship(parent="b", child="d", foreign_key="b_id")
.add_relationship(parent="c", child="d", foreign_key="c_id")
)
got = synthesize(es, "a", agg_primitives=["mean"], trans_primitives=[],
groupby_trans_primitives=[], max_depth=3)
assert "MEAN(b.MEAN(d.v))" in names(got)
def test_features_are_deduplicated(es):
got = synthesize(es, "customers", agg_primitives=["count", "mean"],
trans_primitives=[], groupby_trans_primitives=[], max_depth=2)
assert len(got) == len(set(got))
def test_categorical_column_skipped_by_string_primitive_warns():
"""A Categorical column skipped by a STRING primitive must say so."""
import narwhals as nw
import pyarrow # noqa: F401 (dev dep; polars Categorical is enough here)
from tusk.dtypes import DtypeFamily as F
from tusk.exceptions import CategoricalDtypeWarning
from tusk.primitives.base import TransformPrimitive
from tusk.primitives.registry import register
@register
@dataclass(frozen=True)
class Shout(TransformPrimitive):
"""Uppercase a string column."""
name = "shout"
input_dtypes = (F.STRING,)
def build(self, expr):
"""Build the uppercase expression.
Args:
expr: The column to uppercase.
Returns:
A narwhals expression.
"""
return expr.str.to_uppercase()
es = tusk.EntitySet("x").add_dataframe(
"t",
pl.LazyFrame(
{
"id": [1, 2],
"plain": ["a", "b"],
"cat": pl.Series(["a", "b"], dtype=pl.Categorical),
}
),
primary_key="id",
)
with pytest.warns(CategoricalDtypeWarning, match="cat"):
got = synthesize(es, "t", agg_primitives=[], trans_primitives=["shout"],
groupby_trans_primitives=[], max_depth=1)
# The String column is still used; only the Categorical one is skipped.
assert "SHOUT(plain)" in names(got)
assert "SHOUT(cat)" not in names(got)
def test_no_categorical_warning_when_no_string_primitive_requested(es, recwarn):
"""Default primitives require no STRING input, so nothing is skipped."""
from tusk.exceptions import CategoricalDtypeWarning
synthesize(es, "customers", agg_primitives=["count"], trans_primitives=[],
groupby_trans_primitives=[], max_depth=1)
assert not [w for w in recwarn if issubclass(w.category, CategoricalDtypeWarning)]
def test_order_dependent_transform_without_row_creation_time_fails_in_phase_one():
es = tusk.EntitySet("x").add_dataframe(
"t", pl.LazyFrame({"id": [1], "v": [1.0]}), primary_key="id"
)
with pytest.raises(tusk.exceptions.PrimitiveError, match="row_creation_time"):
synthesize(es, "t", agg_primitives=[], trans_primitives=["cum_sum"],
groupby_trans_primitives=[], max_depth=1)
def test_unknown_target_raises(es):
with pytest.raises(tusk.exceptions.SchemaError, match="nope"):
synthesize(es, "nope", agg_primitives=[], trans_primitives=[],
groupby_trans_primitives=[], max_depth=1)
def test_no_frames_are_touched(es, monkeypatch):
def explode(_name):
raise AssertionError("synthesis touched a frame")
monkeypatch.setattr(es, "frame", explode)
synthesize(es, "customers", agg_primitives=["count", "mean"],
trans_primitives=["month"], groupby_trans_primitives=[], max_depth=2)
- Step 2: Run test to verify it fails
Run: uv run pytest tests/test_synthesis.py -v
Expected: FAIL — ModuleNotFoundError: No module named 'tusk.synthesis'
- Step 3: Add the new warning class
Append to src/tusk/exceptions.py:
class CategoricalDtypeWarning(UserWarning):
"""Warns that a Categorical or Enum column was skipped by a string primitive."""
Its own class, like MissingPrimaryKeyWarning, so it can be filtered
independently.
- Step 4: Implement
# src/tusk/synthesis.py
"""Phase 1: build the feature graph from schemas alone.
Nothing here touches a dataframe, and this module must never import
:mod:`tusk.compiler`. That separation is what makes the algorithm testable
without any backend at all.
"""
from __future__ import annotations
import itertools
import warnings
from typing import Iterable, Sequence
import narwhals as nw
from tusk.dtypes import DtypeFamily, matches
from tusk.entityset import EntitySet, Relationship
from tusk.exceptions import CategoricalDtypeWarning, PrimitiveError
from tusk.features import (
AggregationFeature,
DirectFeature,
Feature,
GroupByTransformFeature,
IdentityFeature,
TransformFeature,
)
from tusk.primitives.base import Primitive
from tusk.primitives.registry import resolve_all
def synthesize(
entityset: EntitySet,
target_dataframe_name: str,
agg_primitives: Iterable[str | Primitive],
trans_primitives: Iterable[str | Primitive],
groupby_trans_primitives: Iterable[str | Primitive],
max_depth: int,
) -> list[Feature]:
"""Generate feature definitions for a target table.
Args:
entityset: The schema to walk.
target_dataframe_name: Table to build features for.
agg_primitives: Aggregation primitives, as names or instances.
trans_primitives: Transform primitives, as names or instances.
groupby_trans_primitives: Transform primitives applied within
foreign-key groups.
max_depth: Maximum number of stacked primitive applications.
Returns:
Feature definitions on the target table, deduplicated and excluding the
target's own key columns.
Raises:
SchemaError: If the target table is unknown.
"""
entityset.schema(target_dataframe_name)
context = _Context(
entityset=entityset,
agg=resolve_all(agg_primitives),
trans=resolve_all(trans_primitives),
groupby=resolve_all(groupby_trans_primitives),
)
features = context.build(target_dataframe_name, max_depth, ())
keys = entityset.key_columns(target_dataframe_name)
kept = [
f
for f in features
if not (isinstance(f, IdentityFeature) and f.column in keys)
]
return list(dict.fromkeys(kept))
class _Context:
"""Carries the entity set and resolved primitives through the recursion."""
def __init__(
self,
entityset: EntitySet,
agg: Sequence[Primitive],
trans: Sequence[Primitive],
groupby: Sequence[Primitive],
) -> None:
"""Store the walk's inputs.
Args:
entityset: The schema to walk.
agg: Resolved aggregation primitives.
trans: Resolved transform primitives.
groupby: Resolved groupby-transform primitives.
"""
self.entityset = entityset
self.agg = agg
self.trans = trans
self.groupby = groupby
self._categorical_warned: set[tuple[str, str, str]] = set()
def build(self, table: str, depth_limit: int, path: tuple[Relationship, ...]) -> list[Feature]:
"""Build every feature on ``table`` of depth at most ``depth_limit``.
Args:
table: Table to build features for.
depth_limit: Maximum depth of returned features.
path: Relationships already traversed, never traversed again.
Returns:
Feature definitions on the table, deduplicated.
"""
schema = self.entityset.schema(table)
features: list[Feature] = [
IdentityFeature(table, column, dtype) for column, dtype in schema.dtypes.items()
]
if depth_limit > 0:
features.extend(self._aggregations(table, depth_limit, path))
features.extend(self._directs(table, depth_limit, path))
features.extend(self._transforms(table, features, depth_limit))
features.extend(self._groupby_transforms(table, features, depth_limit, path))
return list(dict.fromkeys(features))
def _aggregations(
self, table: str, depth_limit: int, path: tuple[Relationship, ...]
) -> list[Feature]:
"""Aggregate each child table's features up into this table.
Args:
table: The parent table.
depth_limit: Maximum depth of returned features.
path: Relationships already traversed.
Returns:
Aggregation features on the table.
"""
out: list[Feature] = []
for rel in self.entityset.children_of(table):
if rel in path:
continue
child_features = self.build(rel.child, depth_limit - 1, path + (rel,))
usable = self._usable(rel.child, child_features)
for primitive in self.agg:
if not primitive.input_dtypes:
out.append(AggregationFeature(primitive, (), rel))
continue
for combo in self._combinations(primitive, usable):
out.append(AggregationFeature(primitive, combo, rel))
return out
def _directs(
self, table: str, depth_limit: int, path: tuple[Relationship, ...]
) -> list[Feature]:
"""Join each parent table's features down onto this table.
Args:
table: The child table.
depth_limit: Maximum depth of returned features.
path: Relationships already traversed.
Returns:
Direct features on the table.
"""
out: list[Feature] = []
for rel in self.entityset.parents_of(table):
if rel in path:
continue
for base in self._usable(
rel.parent, self.build(rel.parent, depth_limit - 1, path + (rel,))
):
out.append(DirectFeature(base, rel))
return out
def _transforms(
self, table: str, existing: Sequence[Feature], depth_limit: int
) -> list[Feature]:
"""Apply transform primitives to features already on this table.
Args:
table: The table being built.
existing: Features produced so far.
depth_limit: Maximum depth of returned features.
Returns:
Transform features on the table.
Raises:
PrimitiveError: If an order-dependent primitive is requested for a
table with no ``row_creation_time``.
"""
usable = self._usable(table, existing)
out: list[Feature] = []
for primitive in self.trans:
self._check_ordering(primitive, table)
for combo in self._combinations(primitive, usable):
feature = TransformFeature(primitive, combo)
if feature.depth <= depth_limit:
out.append(feature)
return out
def _groupby_transforms(
self,
table: str,
existing: Sequence[Feature],
depth_limit: int,
path: tuple[Relationship, ...],
) -> list[Feature]:
"""Apply transform primitives within each foreign-key group.
Args:
table: The table being built.
existing: Features produced so far.
depth_limit: Maximum depth of returned features.
path: Relationships already traversed.
Returns:
Groupby-transform features on the table.
Raises:
PrimitiveError: If an order-dependent primitive is requested for a
table with no ``row_creation_time``.
"""
if not self.groupby:
return []
usable = self._usable(table, existing)
out: list[Feature] = []
for rel in self.entityset.parents_of(table):
if rel in path:
continue
for primitive in self.groupby:
self._check_ordering(primitive, table)
for combo in self._combinations(primitive, usable):
feature = GroupByTransformFeature(primitive, combo, rel)
if feature.depth <= depth_limit:
out.append(feature)
return out
def _warn_categorical(
self, primitive: Primitive, candidates: Sequence[Feature]
) -> None:
"""Warn when a Categorical or Enum column is skipped by a STRING slot.
Casting a column to ``Categorical`` asserts that its values are labels
rather than text, so a string primitive skipping it is correct. Skipping
it *silently* is not: the user would get a feature matrix with columns
quietly missing and nothing to explain why.
Each (primitive, column) pair warns at most once per synthesis run.
Args:
primitive: The primitive whose inputs are being matched.
candidates: Features available as inputs.
"""
if DtypeFamily.STRING not in primitive.input_dtypes:
return
for feature in candidates:
if feature.dtype not in (nw.Categorical, nw.Enum):
continue
key = (primitive.name, feature.table, feature.name)
if key in self._categorical_warned:
continue
self._categorical_warned.add(key)
warnings.warn(
f"column {feature.name!r} on {feature.table!r} has dtype "
f"{feature.dtype}, so primitive {primitive.name!r} (which requires "
f"a string input) will not be applied to it. Cast the column to "
f"String if you want text primitives to use it.",
CategoricalDtypeWarning,
stacklevel=2,
)
def _check_ordering(self, primitive: Primitive, table: str) -> None:
"""Reject order-dependent primitives on tables that cannot be ordered.
Narwhals requires ``order_by`` for these expressions on lazy backends,
and the ordering column is the table's ``row_creation_time``. Checking
here keeps the failure in phase 1, before any query is built.
Args:
primitive: The primitive being applied.
table: The table it would be applied to.
Raises:
PrimitiveError: If the primitive is order-dependent and the table
has no ``row_creation_time``.
"""
if not getattr(primitive, "order_dependent", False):
return
if self.entityset.schema(table).row_creation_time is None:
raise PrimitiveError(
f"primitive {primitive.name!r} is order-dependent, so table "
f"{table!r} needs a row_creation_time"
)
def _usable(self, table: str, features: Sequence[Feature]) -> list[Feature]:
"""Drop key columns, which are structural rather than measurements.
Args:
table: The table the features belong to.
features: Candidate features.
Returns:
Features usable as primitive inputs.
"""
keys = self.entityset.key_columns(table)
return [
f for f in features if not (isinstance(f, IdentityFeature) and f.column in keys)
]
def _combinations(
self, primitive: Primitive, candidates: Sequence[Feature]
) -> list[tuple[Feature, ...]]:
"""Enumerate input tuples a primitive accepts.
Args:
primitive: The primitive to match inputs for.
candidates: Available features.
Returns:
One tuple per valid input combination.
"""
per_slot = [
[f for f in candidates if matches(f.dtype, family)]
for family in primitive.input_dtypes
]
self._warn_categorical(primitive, candidates)
if any(not slot for slot in per_slot):
return []
if len(per_slot) == 1:
combos = [(f,) for f in per_slot[0]]
else:
combos = [c for c in itertools.product(*per_slot) if len(set(c)) == len(c)]
if primitive.commutative:
seen: set[frozenset[Feature]] = set()
deduped = []
for combo in combos:
key = frozenset(combo)
if key not in seen:
seen.add(key)
deduped.append(combo)
combos = deduped
if not primitive.stack_on_self:
combos = [c for c in combos if not any(_uses(f, primitive) for f in c)]
return combos
def _uses(feature: Feature, primitive: Primitive) -> bool:
"""Report whether a feature was produced by a given primitive.
Args:
feature: The feature to inspect.
primitive: The primitive to look for.
Returns:
True if the feature's own primitive matches.
"""
return getattr(feature, "primitive", None) == primitive
- Step 5: Run tests
Run: uv run pytest tests/test_synthesis.py -v
Expected: PASS (17 tests)
- Step 6: Commit
git add src/tusk/exceptions.py src/tusk/synthesis.py tests/test_synthesis.py
git commit -m "feat: add phase 1 feature synthesis"
Task 9: Compiler — single table (identity, transforms, cutoff)¶
Files:
- Create: src/tusk/compiler.py
- Test: tests/test_compiler_single_table.py
Interfaces:
- Consumes: EntitySet, feature types
- Produces: compile_features(features, entityset, cutoff_time=None) -> nw.LazyFrame
- Step 1: Write the failing test
# tests/test_compiler_single_table.py
import datetime as dt
import narwhals as nw
from tusk.compiler import compile_features
from tusk.features import IdentityFeature, TransformFeature
from tusk.primitives.registry import resolve
def test_identity_features_round_trip(es):
age = IdentityFeature("customers", "age", nw.Int64())
got = compile_features([age], es).collect().to_native().sort("id")
assert got.columns == ["id", "age"]
assert got["age"].to_list() == [30, 40, 50]
def test_transform_feature_is_computed(es):
started = IdentityFeature("sessions", "started_at", nw.Datetime())
feature = TransformFeature(resolve("day"), (started,))
got = compile_features([feature], es).collect().to_native().sort("id")
assert got["DAY(started_at)"].to_list() == [4, 5, 6]
def test_stacked_transform_is_computed(es):
started = IdentityFeature("sessions", "started_at", nw.Datetime())
day = TransformFeature(resolve("day"), (started,))
doubled = TransformFeature(resolve("add_numeric"), (day, day))
got = compile_features([day, doubled], es).collect().to_native().sort("id")
assert got["ADD_NUMERIC(DAY(started_at), DAY(started_at))"].to_list() == [8, 10, 12]
def test_cutoff_filters_rows(es):
started = IdentityFeature("sessions", "started_at", nw.Datetime())
feature = TransformFeature(resolve("day"), (started,))
got = compile_features([feature], es, cutoff_time=dt.datetime(2024, 3, 5)).collect().to_native()
assert got["id"].to_list() == [10, 20]
def test_result_stays_lazy(es):
age = IdentityFeature("customers", "age", nw.Int64())
assert isinstance(compile_features([age], es), nw.LazyFrame)
- Step 2: Run test to verify it fails
Run: uv run pytest tests/test_compiler_single_table.py -v
Expected: FAIL — ModuleNotFoundError: No module named 'tusk.compiler'
- Step 3: Implement the single-table path
# src/tusk/compiler.py
"""Phase 2: turn feature definitions into a single lazy query plan.
The only ``collect()`` in tusk is the documented eager round-trip in
:func:`compile_features`' caller; nothing here materializes a frame.
"""
from __future__ import annotations
from typing import Any, Sequence
import narwhals as nw
from tusk.entityset import EntitySet
from tusk.exceptions import SchemaError
from tusk.features import (
AggregationFeature,
DirectFeature,
Feature,
GroupByTransformFeature,
IdentityFeature,
TransformFeature,
)
def compile_features(
features: Sequence[Feature],
entityset: EntitySet,
cutoff_time: Any = None,
) -> nw.LazyFrame:
"""Compile feature definitions into a lazy feature matrix.
Args:
features: Features to compute. All must be on the same table.
entityset: The entity set holding the frames.
cutoff_time: Only rows whose ``row_creation_time`` is at or before this
value are visible. None disables filtering.
Returns:
A lazy frame with the target's primary key plus one column per feature
output.
Raises:
SchemaError: If the features span tables, the list is empty, or the
target table has no primary key.
"""
if not features:
raise SchemaError("no features to compile")
tables = {f.table for f in features}
if len(tables) > 1:
raise SchemaError(f"features span multiple tables: {sorted(tables)}")
target = tables.pop()
primary_key = entityset.schema(target).primary_key
if primary_key is None:
raise SchemaError(
f"target table {target!r} needs a primary_key: the feature matrix is keyed by it"
)
frame = _table_frame(entityset, target, _closure(features), cutoff_time)
columns = [primary_key]
for feature in features:
columns.extend(feature.output_names)
return frame.select(*dict.fromkeys(columns))
def _closure(features: Sequence[Feature]) -> set[Feature]:
"""Expand features to include every feature they are computed from.
A requested feature's inputs must exist as columns before it can be
computed, so the compiler always works over the transitive closure rather
than the caller's list.
Args:
features: Starting features.
Returns:
The features plus all of their transitive bases.
"""
out: set[Feature] = set()
stack = list(features)
while stack:
feature = stack.pop()
if feature in out:
continue
out.add(feature)
stack.extend(feature.base_features)
return out
def _base_frame(entityset: EntitySet, table: str, cutoff_time: Any) -> nw.LazyFrame:
"""Return a table's frame with the cutoff filter applied.
Tables without a ``row_creation_time`` are timeless and pass through.
Args:
entityset: The entity set holding the frames.
table: Table name.
cutoff_time: The cutoff, or None.
Returns:
The filtered lazy frame.
"""
frame = entityset.frame(table)
row_creation_time = entityset.schema(table).row_creation_time
if cutoff_time is not None and row_creation_time is not None:
frame = frame.filter(nw.col(row_creation_time) <= cutoff_time)
return frame
def _table_frame(
entityset: EntitySet,
table: str,
needed: set[Feature],
cutoff_time: Any,
) -> nw.LazyFrame:
"""Build a frame for ``table`` carrying a column for every needed feature.
Args:
entityset: The entity set holding the frames.
table: Table to build.
needed: Features on this table that must appear as columns.
cutoff_time: The cutoff, or None.
Returns:
A lazy frame with the table's own columns plus the needed features.
"""
frame = _base_frame(entityset, table, cutoff_time)
derived = [f for f in needed if not isinstance(f, IdentityFeature)]
for feature in sorted(derived, key=lambda f: f.depth):
frame = _apply(frame, feature)
return frame
def _apply(frame: nw.LazyFrame, feature: Feature) -> nw.LazyFrame:
"""Add a derived feature's columns to a frame.
Args:
frame: The frame to extend.
feature: The feature to compute.
Returns:
The extended frame.
Raises:
SchemaError: If the feature type is not handled here.
"""
if isinstance(feature, TransformFeature):
inputs = [nw.col(b.name) for b in feature.base_features]
exprs = feature.primitive.outputs(*inputs)
named = [e.alias(n) for e, n in zip(exprs, feature.output_names)]
return frame.with_columns(*named)
raise SchemaError(f"cannot compile feature type {type(feature).__name__}")
- Step 4: Run tests
Run: uv run pytest tests/test_compiler_single_table.py -v
Expected: PASS (5 tests)
- Step 5: Commit
git add src/tusk/compiler.py tests/test_compiler_single_table.py
git commit -m "feat: compile identity and transform features"
Task 10: Compiler — aggregation features¶
Files:
- Modify: src/tusk/compiler.py
- Test: tests/test_compiler_aggregation.py
Interfaces:
- Consumes: everything from Task 9
- Produces: _table_frame handling AggregationFeature, with one group_by().agg() and one join per (child table, relationship) group, and default_value applied via fill_null.
- Step 1: Write the failing test
# tests/test_compiler_aggregation.py
import narwhals as nw
from tusk.compiler import compile_features
from tusk.entityset import Relationship
from tusk.features import AggregationFeature, IdentityFeature
from tusk.primitives.aggregation import Count, Mean, Quantiles, Sum
CUSTOMER_SESSION = Relationship("customers", "sessions", "customer_id")
SESSION_TX = Relationship("sessions", "transactions", "session_id")
AMOUNT = IdentityFeature("transactions", "amount", nw.Float64())
def collect(features, es):
return compile_features(features, es).collect().to_native().sort("id")
def test_count_of_children(es):
# customer 1 has sessions 10 and 20; customer 2 has session 30; customer 3 has none.
got = collect([AggregationFeature(Count(), (), CUSTOMER_SESSION)], es)
assert got["COUNT(sessions)"].to_list() == [2, 1, 0]
def test_empty_group_gets_count_default_of_zero(es):
got = collect([AggregationFeature(Count(), (), CUSTOMER_SESSION)], es)
assert got["COUNT(sessions)"][2] == 0
def test_empty_group_gets_null_for_mean(es):
sessions_mean = AggregationFeature(Mean(), (AMOUNT,), SESSION_TX)
got = collect([AggregationFeature(Mean(), (sessions_mean,), CUSTOMER_SESSION)], es)
assert got["MEAN(sessions.MEAN(transactions.amount))"][2] is None
def test_depth_two_aggregation_values(es):
# session 10 -> mean(1, 3) = 2; session 20 -> mean(10, 20) = 15; session 30 -> null.
# customer 1 -> mean(2, 15) = 8.5; customer 2 -> null; customer 3 -> null.
sessions_mean = AggregationFeature(Mean(), (AMOUNT,), SESSION_TX)
feature = AggregationFeature(Mean(), (sessions_mean,), CUSTOMER_SESSION)
got = collect([feature], es)
assert got[feature.name].to_list() == [8.5, None, None]
def test_multi_output_aggregation_produces_one_column_per_output(es):
feature = AggregationFeature(Quantiles(qs=(0.0, 1.0)), (AMOUNT,), SESSION_TX)
got = compile_features([feature], es).collect().to_native().sort("id")
assert got[feature.output_names[0]].to_list() == [1.0, 10.0, None]
assert got[feature.output_names[1]].to_list() == [3.0, 20.0, None]
def test_many_aggregations_from_one_child_produce_one_join(es):
features = [
AggregationFeature(Count(), (), SESSION_TX),
AggregationFeature(Mean(), (AMOUNT,), SESSION_TX),
AggregationFeature(Sum(), (AMOUNT,), SESSION_TX),
]
plan = compile_features(features, es).to_native().explain()
assert plan.count("LEFT JOIN:") == 1
assert plan.count("AGGREGATE") == 1
- Step 2: Run test to verify it fails
Run: uv run pytest tests/test_compiler_aggregation.py -v
Expected: FAIL — SchemaError: cannot compile feature type AggregationFeature
- Step 3: Implement
Replace _table_frame and add _add_aggregations in src/tusk/compiler.py:
def _table_frame(
entityset: EntitySet,
table: str,
needed: set[Feature],
cutoff_time: Any,
) -> nw.LazyFrame:
"""Build a frame for ``table`` carrying a column for every needed feature.
Aggregations are folded in first, batched by relationship so that feature
count does not drive join count; row-wise features are then applied in
depth order, so each one's inputs already exist as columns.
Args:
entityset: The entity set holding the frames.
table: Table to build.
needed: Features on this table that must appear as columns.
cutoff_time: The cutoff, or None.
Returns:
A lazy frame with the table's own columns plus the needed features.
"""
frame = _base_frame(entityset, table, cutoff_time)
aggregations = [f for f in needed if isinstance(f, AggregationFeature)]
for relationship in dict.fromkeys(f.relationship for f in aggregations):
batch = [f for f in aggregations if f.relationship == relationship]
frame = _add_aggregations(frame, entityset, table, relationship, batch, cutoff_time)
row_wise = [
f for f in needed if isinstance(f, (TransformFeature, GroupByTransformFeature))
]
for feature in sorted(row_wise, key=lambda f: f.depth):
frame = _apply(frame, feature)
return frame
def _add_aggregations(
frame: nw.LazyFrame,
entityset: EntitySet,
table: str,
relationship: Any,
batch: Sequence[AggregationFeature],
cutoff_time: Any,
) -> nw.LazyFrame:
"""Fold one child table's aggregations into the parent with a single join.
Args:
frame: The parent frame being built.
entityset: The entity set holding the frames.
table: The parent table's name.
relationship: The relationship being aggregated across.
batch: Every aggregation feature using that relationship.
cutoff_time: The cutoff, or None.
Returns:
The parent frame with the batch's columns joined on.
"""
child_needed: set[Feature] = set()
for feature in batch:
child_needed.update(_closure(feature.base_features))
child = _table_frame(entityset, relationship.child, child_needed, cutoff_time)
exprs = []
for feature in batch:
inputs = [nw.col(b.name) for b in feature.base_features]
built = feature.primitive.outputs(*inputs)
exprs.extend(e.alias(n) for e, n in zip(built, feature.output_names))
grouped = child.group_by(relationship.foreign_key).agg(*exprs)
frame = frame.join(
grouped,
left_on=entityset.schema(table).primary_key,
right_on=relationship.foreign_key,
how="left",
)
defaults = [
nw.col(name).fill_null(feature.primitive.default_value).alias(name)
for feature in batch
if feature.primitive.default_value is not None
for name in feature.output_names
]
return frame.with_columns(*defaults) if defaults else frame
_closure already exists from Task 9; reuse it rather than redefining it.
- Step 4: Run tests
Run: uv run pytest tests/test_compiler_aggregation.py tests/test_compiler_single_table.py -v
Expected: PASS (11 tests)
- Step 5: Commit
git add src/tusk/compiler.py tests/test_compiler_aggregation.py
git commit -m "feat: compile aggregation features with batched joins"
Task 11: Compiler — direct features¶
Files:
- Modify: src/tusk/compiler.py
- Test: tests/test_compiler_direct.py
Interfaces:
- Consumes: everything from Task 10
- Produces: _table_frame handling DirectFeature, batched by relationship into one select + one join.
- Step 1: Write the failing test
# tests/test_compiler_direct.py
import narwhals as nw
from tusk.compiler import compile_features
from tusk.entityset import Relationship
from tusk.features import AggregationFeature, DirectFeature, IdentityFeature
from tusk.primitives.aggregation import Count, Mean
CUSTOMER_SESSION = Relationship("customers", "sessions", "customer_id")
SESSION_TX = Relationship("sessions", "transactions", "session_id")
AGE = IdentityFeature("customers", "age", nw.Int64())
AMOUNT = IdentityFeature("transactions", "amount", nw.Float64())
def test_direct_feature_copies_parent_column_down(es):
feature = DirectFeature(AGE, CUSTOMER_SESSION)
got = compile_features([feature], es).collect().to_native().sort("id")
# sessions 10 and 20 belong to customer 1 (age 30); session 30 to customer 2 (age 40).
assert got["customers.age"].to_list() == [30, 30, 40]
def test_direct_feature_of_a_derived_parent_feature(es):
parent_count = AggregationFeature(Count(), (), CUSTOMER_SESSION)
feature = DirectFeature(parent_count, CUSTOMER_SESSION)
got = compile_features([feature], es).collect().to_native().sort("id")
assert got["customers.COUNT(sessions)"].to_list() == [2, 2, 1]
def test_direct_features_from_one_parent_share_a_join(es):
features = [
DirectFeature(AGE, CUSTOMER_SESSION),
DirectFeature(AggregationFeature(Count(), (), CUSTOMER_SESSION), CUSTOMER_SESSION),
]
plan = compile_features(features, es).to_native().explain()
# One join for the direct features, one inside the parent for its own aggregate.
assert plan.count("LEFT JOIN:") == 2
def test_transform_can_stack_on_a_direct_feature(es):
from tusk.features import TransformFeature
from tusk.primitives.registry import resolve
direct = DirectFeature(AGE, CUSTOMER_SESSION)
feature = TransformFeature(resolve("absolute"), (direct,))
got = compile_features([feature], es).collect().to_native().sort("id")
assert got["ABSOLUTE(customers.age)"].to_list() == [30, 30, 40]
- Step 2: Run test to verify it fails
Run: uv run pytest tests/test_compiler_direct.py -v
Expected: FAIL — SchemaError: cannot compile feature type DirectFeature
- Step 3: Implement
In _table_frame, after the aggregation loop and before the row-wise loop, insert:
directs = [f for f in needed if isinstance(f, DirectFeature)]
for relationship in dict.fromkeys(f.relationship for f in directs):
batch = [f for f in directs if f.relationship == relationship]
frame = _add_directs(frame, entityset, relationship, batch, cutoff_time)
And add the helper:
def _add_directs(
frame: nw.LazyFrame,
entityset: EntitySet,
relationship: Any,
batch: Sequence[DirectFeature],
cutoff_time: Any,
) -> nw.LazyFrame:
"""Join one parent table's features down onto the child with a single join.
Args:
frame: The child frame being built.
entityset: The entity set holding the frames.
relationship: The relationship being traversed.
batch: Every direct feature using that relationship.
cutoff_time: The cutoff, or None.
Returns:
The child frame with the batch's columns joined on.
"""
parent_key = entityset.schema(relationship.parent).primary_key
parent_needed: set[Feature] = set()
for feature in batch:
parent_needed.update(_closure(feature.base_features))
parent = _table_frame(entityset, relationship.parent, parent_needed, cutoff_time)
selected = [nw.col(parent_key)]
for feature in batch:
selected.append(nw.col(feature.base_feature.name).alias(feature.name))
return frame.join(
parent.select(*selected),
left_on=relationship.foreign_key,
right_on=parent_key,
how="left",
)
- Step 4: Run tests
Run: uv run pytest tests/test_compiler_direct.py -v
Expected: PASS (4 tests)
- Step 5: Commit
git add src/tusk/compiler.py tests/test_compiler_direct.py
git commit -m "feat: compile direct features"
Task 12: Compiler — groupby transforms and ordering¶
Files:
- Modify: src/tusk/compiler.py
- Test: tests/test_compiler_ordering.py
Interfaces:
- Consumes: everything from Task 11
- Produces: _apply handling GroupByTransformFeature and order-dependent TransformFeature, wrapping expressions in .over(...) with an order_by of (row_creation_time, primary_key).
- Step 1: Write the failing test
# tests/test_compiler_ordering.py
import narwhals as nw
import polars as pl
import pytest
import tusk
from tusk.compiler import compile_features
from tusk.entityset import Relationship
from tusk.exceptions import PrimitiveError
from tusk.features import GroupByTransformFeature, IdentityFeature, TransformFeature
from tusk.primitives.registry import resolve
SESSION_TX = Relationship("sessions", "transactions", "session_id")
AMOUNT = IdentityFeature("transactions", "amount", nw.Float64())
def test_groupby_cum_sum_restarts_per_group(es):
feature = GroupByTransformFeature(resolve("cum_sum"), (AMOUNT,), SESSION_TX)
got = compile_features([feature], es).collect().to_native().sort("id")
# session 10: 1, 1+3; session 20: 10, 10+20
assert got[feature.name].to_list() == [1.0, 4.0, 10.0, 30.0]
def test_ungrouped_order_dependent_transform(es):
feature = TransformFeature(resolve("cum_sum"), (AMOUNT,))
got = compile_features([feature], es).collect().to_native().sort("id")
assert got["CUM_SUM(amount)"].to_list() == [1.0, 4.0, 14.0, 34.0]
def test_ordering_uses_row_creation_time_not_frame_order():
frame = pl.LazyFrame(
{
"id": [1, 2, 3],
"g": [1, 1, 1],
"v": [100.0, 1.0, 10.0],
"t": [3, 1, 2], # deliberately not row order
}
)
parent = pl.LazyFrame({"id": [1]})
es = (
tusk.EntitySet("x")
.add_dataframe("p", parent, primary_key="id")
.add_dataframe("c", frame, primary_key="id", row_creation_time="t")
.add_relationship(parent="p", child="c", foreign_key="g")
)
feature = GroupByTransformFeature(
resolve("cum_sum"), (IdentityFeature("c", "v", nw.Float64()),),
Relationship("p", "c", "g"),
)
got = compile_features([feature], es).collect().to_native().sort("id")
# ordered by t: 1.0, then 10.0, then 100.0 -> cumulative 111.0, 1.0, 11.0 by id
assert got[feature.name].to_list() == [111.0, 1.0, 11.0]
def test_order_dependent_primitive_without_row_creation_time_raises():
es = tusk.EntitySet("x").add_dataframe(
"t", pl.LazyFrame({"id": [1], "v": [1.0]}), primary_key="id"
)
feature = TransformFeature(resolve("cum_sum"), (IdentityFeature("t", "v", nw.Float64()),))
with pytest.raises(PrimitiveError, match="row_creation_time"):
compile_features([feature], es)
- Step 2: Run test to verify it fails
Run: uv run pytest tests/test_compiler_ordering.py -v
Expected: FAIL — SchemaError: cannot compile feature type GroupByTransformFeature
- Step 3: Implement
_apply needs the entity set to find the ordering columns, so change its signature to _apply(frame, feature, entityset) and update the one call site in _table_frame. Replace _apply with:
def _apply(frame: nw.LazyFrame, feature: Feature, entityset: EntitySet) -> nw.LazyFrame:
"""Add a row-wise feature's columns to a frame.
Order-dependent primitives are wrapped in ``.over(..., order_by=...)``
rather than relying on a frame-level sort: on lazy backends a sort is not
guaranteed to survive later operations, and narwhals requires ``order_by``
for these expressions in any case.
Args:
frame: The frame to extend.
feature: The feature to compute.
entityset: The entity set, used to find ordering columns.
Returns:
The extended frame.
Raises:
SchemaError: If the feature type is not handled here.
PrimitiveError: If an order-dependent primitive is applied to a table
with no ``row_creation_time``.
"""
if not isinstance(feature, (TransformFeature, GroupByTransformFeature)):
raise SchemaError(f"cannot compile feature type {type(feature).__name__}")
inputs = [nw.col(b.name) for b in feature.base_features]
exprs = list(feature.primitive.outputs(*inputs))
partition = (
[feature.relationship.foreign_key]
if isinstance(feature, GroupByTransformFeature)
else []
)
if getattr(feature.primitive, "order_dependent", False):
order_by = _order_by(entityset, feature.table, feature.primitive.name)
exprs = [e.over(*partition, order_by=order_by) for e in exprs]
elif partition:
exprs = [e.over(*partition) for e in exprs]
named = [e.alias(n) for e, n in zip(exprs, feature.output_names)]
return frame.with_columns(*named)
def _order_by(entityset: EntitySet, table: str, primitive_name: str) -> tuple[str, ...]:
"""Build the ordering key for an order-dependent expression.
Args:
entityset: The entity set holding the schemas.
table: The table being ordered.
primitive_name: Used in the error message.
Returns:
The row creation time, followed by the primary key when one exists.
Raises:
PrimitiveError: If the table has no ``row_creation_time``.
"""
schema = entityset.schema(table)
if schema.row_creation_time is None:
raise PrimitiveError(
f"primitive {primitive_name!r} is order-dependent, so table {table!r} "
f"needs a row_creation_time"
)
if schema.primary_key is None:
return (schema.row_creation_time,)
return (schema.row_creation_time, schema.primary_key)
Add from tusk.exceptions import PrimitiveError, SchemaError to the imports.
- Step 4: Run the whole suite
Run: uv run pytest -v
Expected: PASS — all tests from Tasks 1-12
- Step 5: Commit
git add src/tusk/compiler.py tests/test_compiler_ordering.py
git commit -m "feat: compile order-dependent and grouped transforms"
Task 13: Public API, round-trip, and README¶
Files:
- Create: src/tusk/api.py, README.md
- Modify: src/tusk/__init__.py
- Test: tests/test_dfs.py
Interfaces:
- Consumes: synthesize, compile_features, EntitySet
- Produces:
- tusk.dfs(entityset, target_dataframe_name, agg_primitives=None, trans_primitives=None, groupby_trans_primitives=None, max_depth=2, cutoff_time=None, features_only=False) → (native_frame, list[Feature]), or list[Feature] when features_only=True
- tusk.calculate_feature_matrix(features, entityset, cutoff_time=None) → native frame
- Step 1: Write the failing test
# tests/test_dfs.py
import narwhals as nw
import polars as pl
import pytest
import tusk
def test_dfs_end_to_end(es):
matrix, features = tusk.dfs(entityset=es, target_dataframe_name="customers",
agg_primitives=["count", "mean"], trans_primitives=[],
max_depth=2)
assert isinstance(matrix, pl.LazyFrame)
got = matrix.collect().sort("id")
assert got["COUNT(sessions)"].to_list() == [2, 1, 0]
assert {f.name for f in features} <= set(got.columns)
def test_features_only_returns_definitions_alone(es):
features = tusk.dfs(entityset=es, target_dataframe_name="customers",
agg_primitives=["count"], trans_primitives=[],
max_depth=1, features_only=True)
assert isinstance(features, list)
assert {f.name for f in features} == {"age", "COUNT(sessions)"}
def test_defaults_are_applied(es):
features = tusk.dfs(entityset=es, target_dataframe_name="customers",
features_only=True)
names = {f.name for f in features}
assert "COUNT(sessions)" in names
assert not any(n.startswith("ADD_NUMERIC") for n in names)
def test_calculate_feature_matrix_reapplies_definitions(es):
features = tusk.dfs(entityset=es, target_dataframe_name="customers",
agg_primitives=["count"], trans_primitives=[],
max_depth=1, features_only=True)
matrix = tusk.calculate_feature_matrix(features, es)
assert "COUNT(sessions)" in matrix.collect().columns
def test_eager_input_round_trips_to_eager_output():
customers = pl.DataFrame({"id": [1, 2], "age": [30, 40]})
sessions = pl.DataFrame({"id": [10, 11], "customer_id": [1, 1]})
es = (
tusk.EntitySet("x")
.add_dataframe("customers", customers, primary_key="id")
.add_dataframe("sessions", sessions, primary_key="id")
.add_relationship(parent="customers", child="sessions", foreign_key="customer_id")
)
matrix, _ = tusk.dfs(entityset=es, target_dataframe_name="customers",
agg_primitives=["count"], trans_primitives=[], max_depth=1)
assert isinstance(matrix, pl.DataFrame)
assert matrix.sort("id")["COUNT(sessions)"].to_list() == [2, 0]
def test_dfs_never_materializes_for_lazy_input(tmp_path):
"""Scan a real file, delete it, then build features: only collect() may fail."""
path = tmp_path / "sessions.parquet"
pl.DataFrame({"id": [1, 2], "customer_id": [1, 1]}).write_parquet(path)
es = (
tusk.EntitySet("x")
.add_dataframe("customers", pl.LazyFrame({"id": [1]}), primary_key="id")
.add_dataframe("sessions", pl.scan_parquet(path), primary_key="id")
.add_relationship(parent="customers", child="sessions", foreign_key="customer_id")
)
path.unlink()
matrix, _ = tusk.dfs(entityset=es, target_dataframe_name="customers",
agg_primitives=["count"], trans_primitives=[], max_depth=1)
with pytest.raises(FileNotFoundError):
matrix.collect()
def test_unknown_primitive_fails_before_any_query(es):
with pytest.raises(tusk.exceptions.PrimitiveError, match="dubbled"):
tusk.dfs(entityset=es, target_dataframe_name="customers",
trans_primitives=["dubbled"], features_only=True)
- Step 2: Run test to verify it fails
Run: uv run pytest tests/test_dfs.py -v
Expected: FAIL — AttributeError: module 'tusk' has no attribute 'dfs'
- Step 3: Implement
# src/tusk/api.py
"""The public entry points: :func:`dfs` and :func:`calculate_feature_matrix`."""
from __future__ import annotations
from typing import Any, Iterable, Sequence
from tusk.compiler import compile_features
from tusk.entityset import EntitySet
from tusk.features import Feature
from tusk.primitives.aggregation import AGG_DEFAULTS
from tusk.primitives.base import Primitive
from tusk.primitives.transform import TRANS_DEFAULTS
from tusk.synthesis import synthesize
def dfs(
entityset: EntitySet,
target_dataframe_name: str,
agg_primitives: Iterable[str | Primitive] | None = None,
trans_primitives: Iterable[str | Primitive] | None = None,
groupby_trans_primitives: Iterable[str | Primitive] | None = None,
max_depth: int = 2,
cutoff_time: Any = None,
features_only: bool = False,
) -> Any:
"""Run deep feature synthesis over an entity set.
Args:
entityset: The tables and relationships to synthesize over.
target_dataframe_name: Table to build features for. One row of the
result corresponds to one row of this table.
agg_primitives: Aggregation primitives, as names or instances. None
selects the documented defaults.
trans_primitives: Transform primitives. None selects the defaults.
groupby_trans_primitives: Transforms applied within foreign-key groups.
None means none.
max_depth: Maximum number of stacked primitive applications.
cutoff_time: Only rows whose ``row_creation_time`` is at or before this
value are visible. None disables filtering.
features_only: Return the feature definitions without computing them.
Returns:
A ``(feature_matrix, features)`` tuple, where the matrix is in the
caller's native frame type; or just the feature list when
``features_only`` is true.
"""
features = synthesize(
entityset=entityset,
target_dataframe_name=target_dataframe_name,
agg_primitives=AGG_DEFAULTS if agg_primitives is None else agg_primitives,
trans_primitives=TRANS_DEFAULTS if trans_primitives is None else trans_primitives,
groupby_trans_primitives=groupby_trans_primitives or (),
max_depth=max_depth,
)
if features_only:
return features
return calculate_feature_matrix(features, entityset, cutoff_time), features
def calculate_feature_matrix(
features: Sequence[Feature],
entityset: EntitySet,
cutoff_time: Any = None,
) -> Any:
"""Compute a feature matrix from existing feature definitions.
Use this to apply a feature set fitted on training data to new data.
Args:
features: Feature definitions, all on the same target table.
entityset: The entity set to compute over.
cutoff_time: Only rows whose ``row_creation_time`` is at or before this
value are visible. None disables filtering.
Returns:
The feature matrix in the caller's native frame type.
"""
frame = compile_features(features, entityset, cutoff_time)
if entityset.is_eager:
return frame.collect().to_native()
return frame.to_native()
Update src/tusk/__init__.py:
"""Deep feature synthesis for narwhals lazy dataframes."""
from __future__ import annotations
from tusk import exceptions
from tusk.api import calculate_feature_matrix, dfs
from tusk.entityset import EntitySet, Relationship, TableSchema
__version__ = "0.1.0"
__all__ = [
"__version__",
"EntitySet",
"Relationship",
"TableSchema",
"calculate_feature_matrix",
"dfs",
"exceptions",
]
- Step 4: Write the README
# tusk
Deep feature synthesis for [narwhals](https://narwhals-dev.github.io/narwhals/)
lazy dataframes. Tusk generates features across related tables the way
[featuretools](https://featuretools.alteryx.com/) does, but it builds a single
lazy query plan instead of materializing intermediate frames — so synthesis
pushes down into whichever engine holds your data, and works the same on every
backend narwhals supports.
## Install
```bash
uv add tusk
```
## Usage
```python
from datetime import datetime
import tusk
from tusk.primitives import Quantiles
es = tusk.EntitySet("retail")
es.add_dataframe("customers", customers_lf, primary_key="id",
row_creation_time="signed_up_at")
es.add_dataframe("sessions", sessions_lf, primary_key="id",
row_creation_time="started_at")
es.add_dataframe("transactions", tx_lf, primary_key="id",
row_creation_time="occurred_at")
es.add_relationship(parent="customers", child="sessions", foreign_key="customer_id")
es.add_relationship(parent="sessions", child="transactions", foreign_key="session_id")
feature_matrix, features = tusk.dfs(
entityset=es,
target_dataframe_name="customers",
agg_primitives=["mean", "count", Quantiles(qs=(0.25, 0.5, 0.75))],
trans_primitives=["month", "weekday"],
max_depth=2,
cutoff_time=datetime(2026, 1, 1),
)
```
`feature_matrix` comes back in the frame type you put in — lazy in, lazy out —
so nothing is computed until you collect it. `features` is a list of inspectable
definitions you can re-apply to new data:
```python
matrix = tusk.calculate_feature_matrix(features, es_new)
```
## Differences from featuretools
- **Lazy throughout.** Tusk never collects; you decide when to compute.
- **Any narwhals backend**, not just pandas. One entity set uses one backend.
- **`primary_key` and `row_creation_time`** rather than `index` and
`time_index`. Narwhals has no index concept, and `row_creation_time` names
what the column means: when the row became knowable.
- **Three-argument relationships.** `add_relationship(parent=, child=,
foreign_key=)` — the parent side is always the parent's primary key.
- **One global `cutoff_time`**, not per-row cutoff times.
- **`primary_key` is optional**, but a table without one cannot be a
relationship parent or a DFS target, and order-dependent primitives on it
have non-deterministic tiebreaks. You get a `MissingPrimaryKeyWarning`.
## Primitives
Aggregation: `count`, `sum`, `mean`, `min`, `max`, `std`, `median`, `n_unique`,
`percent_true`, `quantiles`.
Transform: `year`, `month`, `day`, `hour`, `weekday`, `is_weekend`, `absolute`,
`natural_log`, `add_numeric`, `subtract_numeric`, `multiply_numeric`,
`divide_numeric`.
Order-dependent (require a `row_creation_time`): `cum_sum`, `cum_count`,
`cum_min`, `cum_max`, `diff`, `time_since_previous`.
Passing `agg_primitives=None` or `trans_primitives=None` selects a sensible
default subset. Arithmetic primitives are excluded from the defaults because
they generate hundreds of features on wide tables.
### Custom primitives
A primitive builds a narwhals expression and never sees a value, which is what
keeps it pushed down:
```python
from dataclasses import dataclass
import narwhals as nw
from tusk.dtypes import DtypeFamily as F
from tusk.primitives import AggregationPrimitive, register
@register
@dataclass(frozen=True)
class Range(AggregationPrimitive):
"""Difference between the largest and smallest value."""
name = "range"
input_dtypes = (F.NUMERIC,)
def build(self, expr: nw.Expr) -> nw.Expr:
return expr.max() - expr.min()
```
Then pass `"range"` or `Range()` to `dfs()`. Parameters are ordinary dataclass
fields.
There is no second, shorter way to declare one. Every built-in primitive is a
frozen dataclass written out like this, so `Year` and `Count` are the same kind
of object as `Range` — nothing in tusk can reach a definition path your own code
cannot.
- Step 5: Run the whole suite and the linters
Run: uv run pytest -v && uv run pre-commit run --all-files
Expected: all tests PASS; pre-commit hooks PASS
- Step 6: Commit
git add src/tusk/api.py src/tusk/__init__.py README.md tests/test_dfs.py
git commit -m "feat: add dfs and calculate_feature_matrix public API"
Task 14: Differential validation against featuretools¶
Files:
- Create: tests/differential/__init__.py, tests/differential/test_vs_featuretools.py
- Test: the same file
Interfaces:
- Consumes: the public API from Task 13
- Produces: an opt-in suite, marked differential, run with uv run --group validation pytest -m differential
This tier is scaffolding. Per the spec it is removed once the differential suite has passed unchanged across two consecutive releases that added primitives. Note that in the file's module docstring.
- Step 1: Write the test
# tests/differential/test_vs_featuretools.py
"""Cross-check tusk's values against featuretools on synthetic data.
Temporary scaffolding: this tier validates the algorithm during development and
is removed once the library settles. See the spec, section 11.
Run with: uv run --group validation pytest -m differential
"""
import numpy as np
import pandas as pd
import polars as pl
import pytest
import tusk
featuretools = pytest.importorskip("featuretools")
pytestmark = pytest.mark.differential
PRIMITIVES = {"count": "count", "sum": "sum", "mean": "mean", "min": "min", "max": "max"}
@pytest.fixture
def synthetic():
"""A two-table dataset with empty groups and nulls."""
rng = np.random.default_rng(0)
customers = pd.DataFrame({"id": np.arange(1, 21)})
sessions = pd.DataFrame(
{
"id": np.arange(1, 61),
"customer_id": rng.integers(1, 18, size=60), # customers 18-20 get none
"value": rng.normal(size=60),
}
)
return customers, sessions
def _featuretools_matrix(customers, sessions):
es = featuretools.EntitySet("s")
es = es.add_dataframe(dataframe_name="customers", dataframe=customers, index="id")
es = es.add_dataframe(dataframe_name="sessions", dataframe=sessions, index="id")
es = es.add_relationship("customers", "id", "sessions", "customer_id")
matrix, _ = featuretools.dfs(
entityset=es,
target_dataframe_name="customers",
agg_primitives=list(PRIMITIVES),
trans_primitives=[],
max_depth=1,
)
return matrix.sort_index()
def _tusk_matrix(customers, sessions):
es = (
tusk.EntitySet("s")
.add_dataframe("customers", pl.from_pandas(customers).lazy(), primary_key="id")
.add_dataframe("sessions", pl.from_pandas(sessions).lazy(), primary_key="id")
.add_relationship(parent="customers", child="sessions", foreign_key="customer_id")
)
matrix, _ = tusk.dfs(
entityset=es,
target_dataframe_name="customers",
agg_primitives=list(PRIMITIVES),
trans_primitives=[],
max_depth=1,
)
return matrix.collect().sort("id").to_pandas().set_index("id")
@pytest.mark.parametrize(
("tusk_name", "featuretools_name"),
[
("COUNT(sessions)", "COUNT(sessions)"),
("SUM(sessions.value)", "SUM(sessions.value)"),
("MEAN(sessions.value)", "MEAN(sessions.value)"),
("MIN(sessions.value)", "MIN(sessions.value)"),
("MAX(sessions.value)", "MAX(sessions.value)"),
],
)
def test_values_match_featuretools(synthetic, tusk_name, featuretools_name):
customers, sessions = synthetic
ours = _tusk_matrix(customers, sessions)[tusk_name]
theirs = _featuretools_matrix(customers, sessions)[featuretools_name]
pd.testing.assert_series_equal(
ours.reset_index(drop=True).astype(float),
theirs.reset_index(drop=True).astype(float),
check_names=False,
)
def test_empty_groups_agree_on_count(synthetic):
customers, sessions = synthetic
ours = _tusk_matrix(customers, sessions)["COUNT(sessions)"]
childless = set(customers["id"]) - set(sessions["customer_id"])
assert childless
assert (ours.loc[sorted(childless)] == 0).all()
- Step 2: Run it
Run: uv run --group validation pytest -m differential -v
Expected: PASS. If featuretools names a column differently, fix the mapping in the parametrize list — do not change tusk's naming to match, since the naming scheme is specified.
- Step 3: Verify it stays out of the default run
Run: uv run pytest --collect-only -q | tail -3
Expected: no differential tests collected
- Step 4: Commit
Task 15: Relbench performance benchmark¶
Files:
- Create: benchmarks/__init__.py, benchmarks/test_relbench.py
- Test: the same file
Interfaces:
- Consumes: the public API from Task 13
- Produces: an opt-in benchmark, marked benchmark, run with uv run --group benchmark pytest -m benchmark -s
- Step 1: Verify relbench's schema API
Run:
uv run --group benchmark python -c "
from relbench.datasets import get_dataset
db = get_dataset('rel-f1', download=True).get_db()
table = next(iter(db.table_dict.values()))
print(type(table), [a for a in dir(table) if not a.startswith('_')])
"
Table exposing df, pkey_col, fkey_col_to_pkey_table, time_col. If the attribute names differ in the installed version, use the real ones in Step 2 — the mapping is the point, not the exact spelling.
- Step 2: Write the benchmark
# benchmarks/test_relbench.py
"""Performance runs against a real relational dataset.
This is the only tier that puts evidence behind the scale-beyond-memory goal.
Kept until deliberately cut. See the spec, section 11.
Run with: uv run --group benchmark pytest -m benchmark -s
"""
import time
import polars as pl
import pytest
import tusk
pytestmark = pytest.mark.benchmark
relbench_datasets = pytest.importorskip("relbench.datasets")
def _entity_set(db):
"""Map a relbench Database onto a tusk EntitySet.
Args:
db: A relbench Database.
Returns:
A tusk EntitySet with the same tables and relationships.
"""
es = tusk.EntitySet("relbench")
for name, table in db.table_dict.items():
es.add_dataframe(
name,
pl.from_pandas(table.df).lazy(),
primary_key=table.pkey_col,
row_creation_time=table.time_col,
)
for name, table in db.table_dict.items():
for foreign_key, parent in table.fkey_col_to_pkey_table.items():
es.add_relationship(parent=parent, child=name, foreign_key=foreign_key)
return es
@pytest.fixture(scope="module")
def db():
"""The rel-f1 database."""
return relbench_datasets.get_dataset("rel-f1", download=True).get_db()
def test_dfs_on_relbench(db):
es = _entity_set(db)
target = max(db.table_dict, key=lambda n: len(db.table_dict[n].df))
start = time.perf_counter()
features = tusk.dfs(entityset=es, target_dataframe_name=target,
max_depth=2, features_only=True)
synthesis_seconds = time.perf_counter() - start
start = time.perf_counter()
matrix = tusk.calculate_feature_matrix(features, es).collect()
compute_seconds = time.perf_counter() - start
print(
f"\ntarget={target} rows={matrix.height} features={len(features)} "
f"synthesis={synthesis_seconds:.2f}s compute={compute_seconds:.2f}s"
)
assert matrix.height == len(db.table_dict[target].df)
assert len(features) > 0
- Step 3: Run it
Run: uv run --group benchmark pytest -m benchmark -s -v
Expected: PASS, printing row/feature counts and timings. A failure here on a real schema is a genuine finding — record it before changing the benchmark.
- Step 4: Verify it stays out of the default run
Run: uv run pytest --collect-only -q | tail -3
Expected: no benchmark tests collected
- Step 5: Commit