Skip to content

Extending Recipe v2

For the complete path from numerical AudioOperation through a public Frame method, tests, exports, and documentation, start with the Frame and Operation extension guide. This page covers the additional contract required when that public operation must be portable as a Recipe.

A Recipe-capable operation has one @recipe_operation(...) declaration. The declaration owns its stable ID/version, accepted ordered bindings, parameter validation, and Frame-returning handler. Parameters use the shared canonical value grammar; an operation does not define a family-specific codec. The same declaration captures public semantic lineage and supplies the immutable registry entry, so those contracts cannot drift.

rename_channels() is Recipe intent rather than a display-only annotation. Its lineage node must run before any later operation that selects a channel by the new name, so authored and replayed selector meaning stays identical.

Extensions never mutate a process-wide registry. Start with an immutable registry and derive another value:

registry = default_recipe_registry().with_operation(my_operation_definition)
plan = RecipePlan.from_frame(result, registry=registry)
loaded = RecipePlan.from_dict(plan.to_dict(), registry=registry)
replayed = loaded.apply({"input_0": source}, registry=registry)

Only operations explicitly present in the supplied registry are portable. A runtime operation may remain undeclared, but extraction then fails at that node rather than cutting the graph or serializing a Python callable path.

Complete extension probe

Test the public path, not a descriptor or registry entry in isolation:

public Frame operation
  -> semantic lineage
  -> RecipePlan.from_frame
  -> RecipePlan.to_dict
  -> RecipePlan.from_dict
  -> RecipePlan.apply

Use that probe for a unary operation, a typed Frame transition, and a true multi-Frame operation. Adding any of them must not modify the central model, compiler, validator, executor, or serializer. If it does, improve the registration contract instead of adding a family branch.

Handler boundary

A handler receives ordered runtime inputs and decoded immutable parameters only. It does not receive a compiler, executor, registry, import path, or mutable context. Parameter validators are pure and run once during complete-plan validation. Handlers validate operation-specific runtime shape, sampling rate, class, and metadata at apply time. The common executor validates named inputs, graph kinds, and the authoritative semantic lineage returned by Frame operations.

The default capture and handler support one declared Frame binding and parameters that can be replayed by keyword. Multi-input operations declare both capture and handler; methods with positional-only or variadic positional arguments provide an explicit handler.

Use frame bindings for Frame operands and array for external NumPy/Dask operands. Do not embed arrays, wrap them in temporary Frames, serialize container kinds, or compute Dask values. Scalars and small JSON-like configuration belong in parameters. Persist invocation intent: omitted arguments stay omitted and input-dependent defaults are resolved by the handler when the plan is applied.

Add tests for metadata, source-time offsets, operand order, mutation isolation, Dask laziness, deterministic schema roundtrip, and unknown operation/version rejection.