Follow-up to #879 / #800. pairwise() landed in the Rust catalog and reaches all three engines, but has no Python binding. Step 7 of /new-op treats the binding as part of adding an op, and both of this op's nearest mirrors already have one: difference (graph.rs:449, python.rs:205, plus a README table row) and skip (graph.rs:431).
What: stream.pairwise() on the Python side, emitting a 2-tuple of the previous and current values.
Why #[pyop] cannot do this one
Everything Python-composable rides one erased edge type, PyElement. Pairwise's Out is (T, T), so the erased form would be (PyElement, PyElement) — not a PyElement, and the proc macro has no way to wrap it. So this is a hand-written PyStream method, not an attribute on the Op impl.
That is fine, and the shape already exists in the file: PyStream::split / item (crates/wingfoil-python/src/graph.rs:803) treat values as indexable Python objects, and split_decomposes_tuples pins that contract. Emitting a PyTuple as the element means stream.pairwise().split() composes for free.
Shape
Stateful single input, so it wants the stateful sibling of wire_stateless (see how drop_small_change at graph.rs:372 carries state) — Option<PyElement> for the previous value, Tick::Quiet until it is filled:
Ok(Tick::Value(PyElement::new(
PyTuple::new(py, [prev, cur])?.unbind().into(),
)))
Acceptance
Getting started
/bind-adapter is for adapters; this one follows step 7 of .claude/commands/new-op.md. Background: docs/python-interop.md. Build and test with cd crates/wingfoil-python && maturin develop && pytest, plus cargo test -p wingfoil-python for the seam tests. Branch from main, PR base main.
Follow-up to #879 / #800.
pairwise()landed in the Rust catalog and reaches all three engines, but has no Python binding. Step 7 of/new-optreats the binding as part of adding an op, and both of this op's nearest mirrors already have one:difference(graph.rs:449,python.rs:205, plus a README table row) andskip(graph.rs:431).What:
stream.pairwise()on the Python side, emitting a 2-tuple of the previous and current values.Why
#[pyop]cannot do this oneEverything Python-composable rides one erased edge type,
PyElement.Pairwise'sOutis(T, T), so the erased form would be(PyElement, PyElement)— not aPyElement, and the proc macro has no way to wrap it. So this is a hand-writtenPyStreammethod, not an attribute on theOpimpl.That is fine, and the shape already exists in the file:
PyStream::split/item(crates/wingfoil-python/src/graph.rs:803) treat values as indexable Python objects, andsplit_decomposes_tuplespins that contract. Emitting aPyTupleas the element meansstream.pairwise().split()composes for free.Shape
Stateful single input, so it wants the stateful sibling of
wire_stateless(see howdrop_small_changeatgraph.rs:372carries state) —Option<PyElement>for the previous value,Tick::Quietuntil it is filled:Acceptance
PyStream::pairwiseincrates/wingfoil-python/src/graph.rs, next todifferenceStream::pairwiseforwarder incrates/wingfoil-python/src/python.rsdifference_of_counter_is_one(graph.rs:1409), covering the quiet first tick and a non-arithmetic element type — that is the capabilitydifferencecannot offer and the reason the op existscrates/wingfoil-python/tests/, includingpairwise().split()round-tripping into two streamscrates/wingfoil-python/README.md(next to.difference())Getting started
/bind-adapteris for adapters; this one follows step 7 of.claude/commands/new-op.md. Background:docs/python-interop.md. Build and test withcd crates/wingfoil-python && maturin develop && pytest, pluscargo test -p wingfoil-pythonfor the seam tests. Branch frommain, PR basemain.