Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Inside SPy: OpSpec, MetaArg, and the blue/red dispatch model

SPy is a Python variant designed to be both easily interpreted and statically compiled to native code or WebAssembly. Its central trick is a two-color evaluation model: expressions whose value is known at compile time are blue, and expressions that must be evaluated at runtime are red. The process of eliminating all blue computation from the AST is called redshifting — the resulting tree is “less blue”, so the average color shifts toward the red end of the spectrum.

This post digs into three closely related concepts — OpSpec, MetaArg, and @blue.metafunc — that together implement SPy’s operator dispatch system.


The Blue/Red Model in a Nutshell

When you write x + y in SPy, the compiler doesn’t just emit an add instruction. It first runs a blue lookup to decide which add function to call based on the static types of x and y. The lookup is discarded; only the resulting direct call survives into the redshifted AST. This is how SPy gets zero-cost operator dispatch without sacrificing Python-style expressiveness.


OpSpec — The Result of a Blue Operator Lookup

OpSpec is what a blue dispatch function returns to say: “here is the concrete runtime function you should call for this operation.”

Internally, a W_OpSpec holds:

There are four variants:

VariantConditionMeaning
null_w_func is None, _w_const is NoneNo implementation found — will raise a type error
simple_w_func set, _args_wam is NoneCall _w_func with the caller’s arguments as-is
complexBoth _w_func and _args_wam setCall _w_func with the explicit argument list
const_w_const setThe result is already a known constant

Simple vs Complex OpSpec: What the Arg List Does

The argument list is a compile-time argument rewrite rule. The key is in typecheck_opspec:

if w_opspec.is_simple():
    out_args_wam = in_args_wam        # pass the caller's args through
else:
    out_args_wam = w_opspec._args_wam # use the OpSpec's own arg list

Each entry in the list is a W_MetaArg that is either:

This makes OpSpec(func, [args...]) a form of partial application at blue time: constants decided during dispatch are burned directly into the W_OpImpl, with no runtime overhead.


MetaArg — A Statically-Typed Argument Wrapper

W_MetaArg is how values are passed around inside the blue evaluation machinery. Instead of a bare W_Object, a MetaArg bundles:

In SPy user code, you construct a MetaArg explicitly when building a complex OpSpec:

@blue
def foo() -> OpSpec:
    arg = MetaArg('blue', i32, 42)  # type i32, value 42, known at compile time
    return OpSpec(bar, [arg])

Here 42 will be baked into the W_OpImpl as a ArgSpec.Const — it never flows through the runtime call.


@blue.metafunc — The Idiomatic Dispatch Decorator

@blue.metafunc is syntactic sugar for writing type-based dispatch functions. The decorated function receives its arguments already wrapped as MetaArg objects, and returns an OpSpec selecting the implementation:

@blue.metafunc
def myprint(m_x):
    if m_x.static_type == int:
        def myprint_int(x: int) -> None:
            print(x)
        return OpSpec(myprint_int)

    if m_x.static_type == str:
        def myprint_str(x: str) -> None:
            print(x)
        return OpSpec(myprint_str)

    raise TypeError("don't know how to print this")

The function runs entirely at blue time. The raise TypeError becomes a compile-time error — calling myprint with an unsupported type is a compilation failure, not a runtime crash. After redshifting, only the chosen concrete function remains in the AST.

This is the SPy equivalent of C++ template specialization, but written in ordinary Python with if statements, and fully debuggable with breakpoint().


Putting It Together: OpSpec, W_ASTFunc, and ast.FuncDef

A natural question is: can you construct an OpSpec from a raw ast.FuncDef? The short answer is no, not directly — but you’re only one step away.

OpSpec stores a W_Func internally. W_ASTFunc (which wraps an ast.FuncDef) is a subclass of W_Func, so the chain is:

ast.FuncDef  →  W_ASTFunc  →  W_OpSpec

A bare ast.FuncDef is just an AST node — it has no resolved types, no closure, no FQN. The W_ASTFunc wrapper is what gives it all of that. In practice, by the time any @blue function body executes, every def statement in scope has already been turned into a W_ASTFunc by exec_stmt_FuncDef. So when you write:

@blue.metafunc
def myop(m_x):
    def impl(x: i32) -> i32:
        return x * 2
    return OpSpec(impl)   # impl is already W_ASTFunc here

The inner def impl is evaluated during redshifting, producing a fully formed W_ASTFunc that goes directly into the OpSpec. That’s the intended idiom.


Implicit Type Conversions: __convert_to__ and __convert_from__

The conversion system is a clean illustration of how the dispatch machinery extends to user-defined types. Consider:

@struct
class CursedStr:
    value: str

    @blue.metafunc
    def __convert_to__(m_expT, m_gotT, m_x):
        if m_expT.blueval == int:
            def conv(x: CursedStr) -> int:
                return int(x.value)
            return OpSpec(conv, [m_x])
        return OpSpec.NULL

    @blue.metafunc
    def __convert_from__(m_expT, m_gotT, m_x):
        if m_gotT.blueval == int:
            def conv(x: int) -> CursedStr:
                return CursedStr(str(x))
            return OpSpec(conv, [m_x])
        return OpSpec.NULL

Why three arguments — m_expT, m_gotT, m_x?

Both hooks plug into the operator.CONVERT(expT, gotT, x) operator, which has a fixed 3-argument blue signature:

Both methods receive all three unchanged, because the lookup in convop.py forwards them directly:

elif w_conv_to := w_gotT.lookup_func("__convert_to__"):
    w_opspec = vm.fast_metacall(w_conv_to, [wam_expT, wam_gotT, wam_x])
elif w_conv_from := w_expT.lookup_func("__convert_from__"):
    w_opspec = vm.fast_metacall(w_conv_from, [wam_expT, wam_gotT, wam_x])

Why no m_self?

These are not instance methods. They are class-level metafuncs looked up on the type object. Conversion is a purely static/type-level decision. The actual value to convert is m_x, not self.

Which method is called?

The lookup order in get_opspec is:

  1. Check the built-in multimethod table (numeric conversions)

  2. Try __convert_to__ on the source type (gotT)

  3. Try __convert_from__ on the destination type (expT)

SituationMethod calledReasoning
inc(s) — source is CursedStr, dest is int__convert_to__ on CursedStrCursedStr knows how to become int
s2: CursedStr = 123 — source is int, dest is CursedStr__convert_from__ on CursedStrCursedStr knows how to be built from int

Why pass [m_x] explicitly?

conv takes one argument, but CONVERT was called with three (expT, gotT, x). Without the explicit arg list, a simple OpSpec(conv) would try to pass all three arguments to conv and fail. [m_x] says: take only the value argument from the call site. The m_expT and m_gotT are blue constants used only for the dispatch decision — they are fully eliminated by redshifting.


When Does All This Happen? The Two-Pass Model

Operator dispatch and conversion resolution happen once — in the ASTFrame/DopplerFrame AST walker — but this walker is entered twice in the pipeline:

source → pyparse → parse → symtable ─┬─ [interp: ASTFrame]
                                     └─ [redshift: DopplerFrame] → C → compile

Pass 1: Interpreter (ASTFrame, redshifting=False)

For every expression node, the interpreter:

  1. Recursively evaluates operands into W_MetaArg objects

  2. Calls the appropriate OPERATOR (blue call → produces W_OpImpl)

  3. Executes the W_OpImpl immediately to get the runtime value

  4. On return from eval_expr, checks the declared type annotation (varname) → calls CONVERT_maybe → executes any needed conversion immediately

Everything runs eagerly. The dispatch decisions are made on static types, even in interpreter mode.

Pass 2: Redshifter (DopplerFrame, redshifting=True)

DopplerFrame inherits from ASTFrame and reuses all the same eval_expr_* methods. The dispatch logic is identical. The difference is what happens with the results:

Similarly for conversions: instead of executing them, DopplerFrame.eval_expr injects them as explicit ast.Call nodes in the redshifted AST.

So after redshifting, inc(s) (where s: CursedStr is implicitly converted to int) becomes something like:

# redshifted:
print(inc(CursedStr_to_int(s)))   # conversion is now an explicit direct call

All the blue dispatch logic is gone.

InterpreterRedshifter
Dispatch resolutioncall_OP + CONVERT_maybeSame, inherited
After resolutionExecute W_OpImpl immediatelySerialize into ast.Call node
ConversionsExecute immediatelyInject as explicit ast.Call in new AST
ResultRuntime valueRedshifted AST with only direct calls

Summary

SPy’s operator dispatch system is built around three interlocking concepts:

Together they implement a dispatch model where all type-checking, overload resolution, and implicit conversion logic runs at compile time (blue time) and is completely absent from the compiled output — leaving only direct function calls in the redshifted AST.