Primitive Value Semantics 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: Reject primitives that are not frozen dataclasses at the point they enter tusk, so features deduplicate correctly and a pickled feature set equals the one it was saved from.
Architecture: One guard in resolve(), the single function every primitive passes through on its way into synthesis. It runs on the resolved instance, so it covers both a user-constructed instance and a registered name. No public signature changes; the failure moves from a misleading compile-time error to an immediate one naming the class.
Tech Stack: Python 3, dataclasses, pytest, ruff, ty, pre-commit.
Spec: docs/superpowers/specs/2026-08-29-primitive-value-semantics-design.md
Global Constraints¶
- The error type is
PrimitiveError(src/tusk/exceptions.py:14). Do not add a new exception type. - The predicate is
dataclasses.is_dataclass(cls)andcls.__dataclass_params__.frozen, read offtype(primitive). - No signature changes to
resolve,resolve_all,register, or any public entry point. - All 28 built-in primitives already comply. If a change makes one fail, the change is wrong — do not edit a built-in to suit it.
- Every commit must pass
uv run pytest -q,uv run ruff check .,uv run ruff format --check src tests, anduv run ty check. The pre-commit hooks run these pluspydoclintandinterrogate, which require Google-style docstrings withArgs:/Returns:/Raises:sections on every public function. ARaises:section must list only exceptions raised in that function's own body. - Do not use
git commit --no-verify.
Already covered — do not re-implement¶
Spec test 5 ("every registered built-in passes the check") already exists as
test_every_registered_primitive_is_a_frozen_dataclass in
tests/test_primitives_base.py:96, alongside
test_every_registered_primitive_round_trips_through_pickle at line 104. Both
iterate _REGISTRY. Leave them alone; Task 1 must not break them.
Consequence for test design: those tests assert that everything in
_REGISTRY is a frozen dataclass. Registering a deliberately-bad primitive at
module scope in any test file would make them fail. Task 1 therefore registers
its bad primitive inside a fixture that restores the registry afterwards.
Task 1: Reject non-frozen-dataclass primitives in resolve()¶
Files:
- Modify: src/tusk/primitives/registry.py:35-54 (the resolve function)
- Modify: docs/guide/custom-primitives.md:26-32
- Test: tests/test_primitives_base.py
Interfaces:
- Consumes: PrimitiveError from tusk.exceptions; _REGISTRY, register, resolve from tusk.primitives.registry.
- Produces: resolve(spec) keeps its signature (str | Primitive) -> Primitive and still returns the same object when given an instance (resolve(inst) is inst), which test_registry_resolves_names_and_passes_instances_through at line 78 depends on. Adds a private _require_value_semantics(primitive: Primitive) -> None in the same module.
- Step 1: Write the failing tests
Append to tests/test_primitives_base.py. Note the existing imports at the top
of that file already cover dataclass, is_dataclass, pytest,
PrimitiveError, TransformPrimitive, F, _REGISTRY, register and
resolve — add nothing to the import block.
@pytest.fixture
def restore_registry():
"""Undo any registration a test performs.
test_every_registered_primitive_is_a_frozen_dataclass asserts that every
entry in _REGISTRY complies, so a deliberately-bad primitive must not
outlive the test that needs it.
"""
before = dict(_REGISTRY)
yield
_REGISTRY.clear()
_REGISTRY.update(before)
class PlainClassPrimitive(TransformPrimitive):
"""A primitive written the ordinary way: no dataclass, identity equality."""
name = "plain_class_primitive"
input_dtypes = (F.NUMERIC,)
def __init__(self, k=2.0):
self.k = k
def build(self, expr):
return expr * self.k
@dataclass
class UnfrozenPrimitive(TransformPrimitive):
"""A dataclass that forgot frozen=True, so it is unhashable."""
name = "unfrozen_primitive"
input_dtypes = (F.NUMERIC,)
k: float = 2.0
def build(self, expr):
return expr * self.k
def test_a_plain_class_primitive_instance_is_rejected():
with pytest.raises(PrimitiveError, match="PlainClassPrimitive"):
resolve(PlainClassPrimitive(3.0))
def test_a_plain_class_primitive_is_rejected_by_registered_name(restore_registry):
register(PlainClassPrimitive)
with pytest.raises(PrimitiveError, match="PlainClassPrimitive"):
resolve("plain_class_primitive")
def test_an_unfrozen_dataclass_primitive_is_rejected():
# Without the check this reaches synthesis and dies with an unhelpful
# "unhashable type" much further downstream.
with pytest.raises(PrimitiveError, match="UnfrozenPrimitive"):
resolve(UnfrozenPrimitive(3.0))
def test_the_rejection_names_the_fix():
with pytest.raises(PrimitiveError, match="frozen dataclass"):
resolve(PlainClassPrimitive(3.0))
def test_a_compliant_primitive_still_resolves_unchanged():
instance = Pair(scale=3.0)
assert resolve(instance) is instance
assert resolve("doubled") == Doubled()
def test_the_rejection_reaches_a_user_through_the_public_entry_point(db):
with pytest.raises(PrimitiveError, match="PlainClassPrimitive"):
tusk.deep_feature_synthesis(
database=db,
target_table="customers",
agg_primitives=[],
trans_primitives=[PlainClassPrimitive(3.0)],
features_only=True,
)
The last test needs the db fixture from tests/conftest.py, which pytest
supplies automatically. It needs no new import: the existing
import tusk.primitives at the top of the file already binds the name tusk,
so tusk.deep_feature_synthesis resolves. Do not add import tusk — it is
redundant.
The unannotated build(self, expr) and __init__(self, k=2.0) above match
the primitives already defined in this file (Doubled, Spread, Pair at
lines 14-47). pyproject.toml:79-88 already scopes a ty override for
invalid-method-override over tests/**, so the fixed-arity build is
accepted.
- Step 2: Run the tests to verify they fail
Run: uv run pytest tests/test_primitives_base.py -q
Expected: the four rejection tests FAIL. test_a_plain_class_primitive_instance_is_rejected fails with DID NOT RAISE <class 'tusk.exceptions.PrimitiveError'>. test_the_rejection_reaches_a_user_through_the_public_entry_point fails the same way — it currently returns a FeatureList instead of raising. test_a_compliant_primitive_still_resolves_unchanged PASSES already; it is a regression guard, not a new behavior.
- Step 3: Write the implementation
In src/tusk/primitives/registry.py, add import dataclasses to the import
block (above from collections.abc import Iterable, so ruff's isort rule I
keeps stdlib grouped and alphabetical), then replace the body of resolve and
add the helper below it:
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, or the primitive is not
a frozen dataclass.
"""
if isinstance(spec, Primitive):
primitive = spec
else:
try:
primitive = _REGISTRY[spec]()
except KeyError:
known = ", ".join(sorted(_REGISTRY))
msg = f"unknown primitive {spec!r}; available: {known}"
raise PrimitiveError(msg) from None
_require_value_semantics(primitive)
return primitive
def _require_value_semantics(primitive: Primitive) -> None:
"""Reject a primitive that compares by identity rather than by value.
Features are frozen dataclasses holding a primitive, so a feature's
equality and hash are only as good as its primitive's. Deduplication
during synthesis and matching a saved feature set against a fresh one both
depend on that.
Args:
primitive: The resolved primitive to check.
Raises:
PrimitiveError: If it is not a frozen dataclass.
"""
cls = type(primitive)
params = getattr(cls, "__dataclass_params__", None)
if dataclasses.is_dataclass(cls) and params is not None and params.frozen:
return
raise PrimitiveError(
f"primitive {cls.__name__!r} must be a frozen dataclass. tusk compares "
"primitives by value to deduplicate features and to match a saved "
"feature set against a fresh one; a primitive that compares by "
"identity silently produces duplicate features. Decorate it with "
"@dataclass(frozen=True) -- see docs/guide/custom-primitives.md.",
)
Note resolve now assigns and returns at the end rather than returning early,
so the check covers both paths. Returning primitive (not a copy) preserves
resolve(inst) is inst.
- Step 4: Run the tests to verify they pass
Run: uv run pytest tests/test_primitives_base.py -q
Expected: PASS.
Then the whole suite, which must be unchanged apart from the new tests:
Run: uv run pytest -q
Expected: 358 passed, 42 deselected (352 before, plus the 6 new tests).
- Step 5: Update the custom-primitives guide
docs/guide/custom-primitives.md currently shows the frozen dataclass and says
parameters are ordinary dataclass fields. Make the requirement explicit.
Replace the paragraph at lines 26-32:
Then pass `"range"` or `Range()` to `deep_feature_synthesis()`. Parameters are
ordinary dataclass fields.
`@dataclass(frozen=True)` is required, not a style preference. tusk compares
primitives by value — to drop duplicate features during synthesis, and to
match a feature set loaded from disk against a freshly synthesized one. A
plain class compares by object identity, so two identically configured
instances would look different and both would survive. A primitive that is
not a frozen dataclass is rejected with `PrimitiveError` as soon as it reaches
`deep_feature_synthesis()`.
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 6: Verify the docs build
Run: uv run --group docs zensical build
Expected: No issues found.
- Step 7: Run every check, then commit
uv run ruff check .
uv run ruff format --check src tests
uv run ty check
uv run pytest -q
git add src/tusk/primitives/registry.py tests/test_primitives_base.py docs/guide/custom-primitives.md
git commit -m "Require primitives to be frozen dataclasses
A primitive that is a plain class compares by object identity, so two
identically configured instances are unequal. Features hold primitives and
are compared by value, so that broke deduplication during synthesis and
made a pickled feature set unequal to the one it was saved from.
resolve() now rejects one, on both the instance and the registered-name
path, naming the class and the fix. All 28 built-ins already complied."
Task 2: Pin the feature-set pickle guarantee¶
Files:
- Test: tests/test_feature_list.py
Interfaces:
- Consumes: resolve's new guarantee from Task 1; tusk.deep_feature_synthesis; the db fixture from tests/conftest.py; the features fixture already defined at the top of tests/test_feature_list.py.
- Produces: nothing other tasks consume. This is the end of the plan.
Why this is its own task: Task 1 protects a property of features, but every existing test of that property is about primitives (tests/test_primitives_base.py:104 pickles primitives, not feature sets). Nothing currently pins the thing the change exists for.
- Step 1: Write the failing test
Append to tests/test_feature_list.py. Add import pickle to its import
block, at the top of the stdlib group above from collections.abc import
Sequence.
def test_a_feature_list_survives_pickle_equal_and_hash_stable(features):
"""The property the frozen-dataclass rule on primitives exists to protect.
A loaded feature set has to equal the one it was saved from, or it will
not deduplicate against a freshly synthesized set and the combination
produces duplicate columns.
"""
restored = pickle.loads(pickle.dumps(features))
assert restored == features
assert hash(restored) == hash(features)
assert restored.target_table == features.target_table
assert restored.output_names == features.output_names
def test_a_restored_feature_list_still_computes(features, db):
restored = pickle.loads(pickle.dumps(features))
assert restored.apply(db).collect().equals(features.apply(db).collect())
def test_restored_features_deduplicate_against_a_fresh_run(features, db):
"""A loaded set combined with a fresh one must collapse, not double."""
restored = pickle.loads(pickle.dumps(features))
combined = tusk.FeatureList(dict.fromkeys([*restored, *features]))
assert len(combined) == len(features)
- Step 2: Run the tests
Run: uv run pytest tests/test_feature_list.py -q
Expected: PASS on all three. These are regression guards — the behavior is already correct for compliant primitives, and Task 1 is what keeps a non-compliant one from reaching this code path at all. If any fails, stop: either Task 1 broke something or the guarantee was never real.
- Step 3: Run every check, then commit
uv run ruff check .
uv run ruff format --check src tests
uv run ty check
uv run pytest -q
git add tests/test_feature_list.py
git commit -m "Pin the feature-set pickle guarantee
Nothing tested that a FeatureList round-trips equal and hash-stable, which
is the property the frozen-dataclass rule on primitives protects."
Verification¶
After both tasks:
uv run pytest -q # 361 passed, 42 deselected
uv run ruff check . # All checks passed!
uv run ruff format --check src tests # 45 files already formatted
uv run ty check # All checks passed!
uv run --group docs zensical build # No issues found
Counts: 352 before this plan, +6 in Task 1, +3 in Task 2.
Out of scope¶
- Guarding hand-built features (
TransformFeature(MyPrim(), ...)). The spec accepts this gap;Featureclasses are public and bypassresolve(). - Any change to
register(). It still accepts a non-compliant class;resolve()is what rejects it. Moving the check intoregister()would miss unregistered instances. - A JSON serialization format. Pickle is the chosen route.