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 question: why two types?
Section titled “The question: why two types?”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 nameforwardis ambiguous even when the arities differ, because Rust resolves the name before the arguments. Anderror[E0119]— a type that implementsFiltercan no longer implementNodeby 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 aBox<dyn Node>. The typestate variant dies sooner: theCatalogis a heterogeneous map that erases the type parameter, and it does not cross into Python at all.
Decisions taken
Section titled “Decisions taken”- One trait, one method,
forwardin both languages. Without the second trait there is no name ambiguity, so there is no need to call itadvancein Rust — and no reason for Python to keep two doors either.Filter,Step,g.step(),kind_ofandGraph’s two overrides all go. inputtravels apart from the context. A node that never looks atctxshould not have to cross a struct to reach the only thing it cares about.Ctxis 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, andctx.devicereads as it does in Rust.- One adapter, not two.
PyFilterNodeandPyStepNodemerge intoPyNode.
What disappeared
Section titled “What disappeared”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.
The conclusion outlived its own mechanism
Section titled “The conclusion outlived its own mechanism”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.