Skip to content

CU6 & CU7 — A single contract, and the same one in both languages

pub trait Node: Send + Sync {
fn forward(&self, input: &Value, ctx: &Ctx<'_>) -> Result<Value, NodeError>;
}

Status: closed. 47 tests in Rust, 56 in Python. Two use cases and one argument: the core stopped having a Filter and a Step in CU6, and Python stopped having them in CU7.

The difference between a filter and a step was a single thing: whether it can finish on its own. But that was already said in the return value — a filter is a node that always answers Done. Having two traits duplicated in the type system a distinction that lived somewhere else, and with it propagated upwards the obligation to know which one each node was: catalog, plan, engine, errors, adapters, DSL. 35 places.

Two alternatives were tried before deciding, and both were rejected for concrete reasons worth keeping:

  • A sugar trait with a blanket impl (impl<T: Filter> Node for T). Compiled: error[E0034] — with two traits in scope the name forward is ambiguous even when the arities differ, because Rust resolves the name before the arguments. And error[E0119] — a type that implements Filter can no longer implement Node by hand, so a node could not evolve from always finishing to asking for a turn without being rewritten entirely.
  • State as a continuation (Pending { requests, resume }). Simpler on the surface, but it breaks deterministic replay, and resuming would require serializing a Box<dyn Node>. The typestate variant dies sooner: the Catalog is a heterogeneous map that erases the type parameter, and it does not cross into Python at all.
  1. One trait, one method, forward in both languages. Without the second trait there is no name ambiguity, so there is no need to call it advance in Rust — and no reason for Python to keep two doors either. Filter, Step, g.step(), kind_of and Graph’s two overrides all go.
  2. input travels apart from the context. A node that never looks at ctx should not have to cross a struct to reach the only thing it cares about.
  3. Ctx is a #[pyclass], not a dictionary. It is a core concept crossing the seam, so the adapter recognizes it by type instead of guessing from a dict’s keys, and ctx.device reads as it does in Rust.
  4. One adapter, not two. PyFilterNode and PyStepNode merge into PyNode.

trait Step, FilterError, StepError, StepCtx, NodeImpl, Plan::Step, RunError::{Filter, Step, WrongKind}, insert_filter/insert_step, run_filter/drive_step, PyStep, Pure. RunError goes from 7 variants to 5, Plan from 4 to 3, and compile no longer needs the catalog to know what kind each node is — only to check that there is one.

CU6’s argument was that the distinction lived in the return value and not in the type. After CU18 removed the return value’s variants too — and the conclusion did not need them: with one shape there is nothing left for the distinction to live in. The diagnosis was right and the mechanism it rested on was incidental, which is the most a design argument can hope for.