fix(zset): a chunked rebuild is one tick, empty groups have no row, a last chunk is Done (improvise-bw4p.1)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
This commit is contained in:
Claude (dbsp-circuit)
2026-10-10 12:06:46 -07:00
committed by Edward Langley
parent ba17f92bc9
commit 0849981ae1
6 changed files with 281 additions and 27 deletions
+80 -17
View File
@@ -47,7 +47,8 @@ impl<K: Ord> Output<K> {
}
trait Node {
fn eval(&mut self);
/// `cont` is true for a later piece of a tick that has already begun.
fn eval(&mut self, cont: bool);
}
struct Unary<I, O, F> {
@@ -57,7 +58,7 @@ struct Unary<I, O, F> {
}
impl<I, O, F: FnMut(&I) -> O> Node for Unary<I, O, F> {
fn eval(&mut self) {
fn eval(&mut self, _cont: bool) {
let v = (self.f)(&self.src.0.borrow());
*self.out.0.borrow_mut() = v;
}
@@ -71,12 +72,47 @@ struct Binary<A, B, O, F> {
}
impl<A, B, O, F: FnMut(&A, &B) -> O> Node for Binary<A, B, O, F> {
fn eval(&mut self) {
fn eval(&mut self, _cont: bool) {
let v = (self.f)(&self.a.0.borrow(), &self.b.0.borrow());
*self.out.0.borrow_mut() = v;
}
}
/// out(t) = in(t - 1). A continued piece adds to what is held and emits nothing.
struct Delay<K> {
src: Stream<ZSet<K>>,
out: Stream<ZSet<K>>,
held: ZSet<K>,
}
impl<K: Ord + Clone> Node for Delay<K> {
fn eval(&mut self, cont: bool) {
let d = self.src.0.borrow();
let v = if cont {
self.held += &*d;
ZSet::new()
} else {
std::mem::replace(&mut self.held, d.clone())
};
*self.out.0.borrow_mut() = v;
}
}
/// out(t) = in(0) + .. + in(t). A continued piece emits only its own input.
struct Integrate<K> {
src: Stream<ZSet<K>>,
out: Stream<ZSet<K>>,
acc: ZSet<K>,
}
impl<K: Ord + Clone> Node for Integrate<K> {
fn eval(&mut self, cont: bool) {
let d = self.src.0.borrow();
self.acc += &*d;
*self.out.0.borrow_mut() = if cont { d.clone() } else { self.acc.clone() };
}
}
/// Moves what was pushed into the circuit's stream.
struct Source<K> {
pending: Rc<RefCell<ZSet<K>>>,
@@ -84,7 +120,7 @@ struct Source<K> {
}
impl<K: Ord> Node for Source<K> {
fn eval(&mut self) {
fn eval(&mut self, _cont: bool) {
*self.out.0.borrow_mut() = std::mem::take(&mut *self.pending.borrow_mut());
}
}
@@ -105,8 +141,18 @@ impl Circuit {
/// Runs one clock tick: every node, once, in order.
pub fn step(&mut self) {
self.run(false);
}
/// Runs another piece of the tick the last `step` began; the clock does not advance.
/// Pieces sum to one `step` of their combined input (`context/code/zset.md`).
pub fn step_continue(&mut self) {
self.run(true);
}
fn run(&mut self, cont: bool) {
for n in &mut self.nodes {
n.eval();
n.eval(cont);
}
}
@@ -161,17 +207,24 @@ impl Circuit {
/// out(t) = in(t - 1); out(0) is zero.
pub fn delay<K: Ord + Clone + 'static>(&mut self, s: &Stream<Z<K>>) -> Stream<Z<K>> {
let mut held = ZSet::new();
self.unary(s, move |d: &Z<K>| std::mem::replace(&mut held, d.clone()))
let out = Stream(Rc::default());
self.nodes.push(Box::new(Delay {
src: s.clone(),
out: out.clone(),
held: ZSet::new(),
}));
out
}
/// out(t) = in(0) + .. + in(t).
pub fn integrate<K: Ord + Clone + 'static>(&mut self, s: &Stream<Z<K>>) -> Stream<Z<K>> {
let mut acc = ZSet::new();
self.unary(s, move |d: &Z<K>| {
acc += d;
acc.clone()
})
let out = Stream(Rc::default());
self.nodes.push(Box::new(Integrate {
src: s.clone(),
out: out.clone(),
acc: ZSet::new(),
}));
out
}
/// out(t) = in(t) - in(t - 1).
@@ -306,12 +359,13 @@ pub enum Progress {
Done,
}
/// Building from a snapshot as a sliced job: the snapshot enters an input a chunk at a
/// time, one circuit step per chunk. Outputs sum to what one big delta would give.
/// A snapshot load as a sliced job: one logical tick, a `step` then `step_continue`s.
/// Outputs sum to one big delta's, temporal operators included (`context/code/zset.md`).
pub struct Rebuild<K> {
input: Input<K>,
rest: std::collections::btree_map::IntoIter<K, i64>,
chunk: usize,
started: bool,
}
impl<K: Ord> Rebuild<K> {
@@ -321,19 +375,28 @@ impl<K: Ord> Rebuild<K> {
input,
rest: snapshot.into_iter(),
chunk: chunk.max(1),
started: false,
}
}
/// Runs chunks until done or `expired()`, at least one per call. A predicate, not an
/// `Instant`, which panics on wasm32 (`context/code/zset.md`).
/// `Instant` (wasm32). An empty snapshot takes its one tick; a finished job none.
pub fn step(&mut self, circuit: &mut Circuit, mut expired: impl FnMut() -> bool) -> Progress {
loop {
let chunk: ZSet<K> = self.rest.by_ref().take(self.chunk).collect();
if chunk.is_zero() {
if chunk.is_zero() && self.started {
return Progress::Done;
}
self.input.push(chunk);
circuit.step();
if self.started {
circuit.step_continue();
} else {
circuit.step();
self.started = true;
}
if self.rest.len() == 0 {
return Progress::Done;
}
if expired() {
return Progress::Pending;
}
+8 -3
View File
@@ -76,7 +76,7 @@ impl<TA, TB> JoinState<TA, TB> {
}
/// Incremental group-by: `f` folds one key's integrated values to at most one result.
/// Only the touched keys are re-folded: out = -(k, f(old)) + (k, f(new)).
/// An empty group has no row, and `f` is not asked about it. Only touched keys are re-folded: out = -(k, f(old)) + (k, f(new)).
#[derive(Clone, Debug, Default)]
pub struct AggregateState<T> {
groups: T,
@@ -104,13 +104,18 @@ impl<T> AggregateState<T> {
}
let mut out = ZSet::new();
for (k, changes) in touched {
if let Some(r) = f(k, &self.groups.lookup(k)) {
let before = self.groups.lookup(k);
if let Some(r) = Some(&before)
.filter(|g| !g.is_empty())
.and_then(|g| f(k, g))
{
out.insert((k.clone(), r), -1);
}
for (v, w) in changes {
self.groups.apply(k, v, w);
}
if let Some(r) = f(k, &self.groups.lookup(k)) {
let after = self.groups.lookup(k);
if let Some(r) = Some(&after).filter(|g| !g.is_empty()).and_then(|g| f(k, g)) {
out.insert((k.clone(), r), 1);
}
}
@@ -0,0 +1,7 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc da6d39e3f2d54dc796842f642b0fc17d09a1491a9937c3d3f95d453d2d9487ed # shrinks to prior = [(3, -1)], snapshot = [(3, 2), (5, -2), (0, 1), (1, 1)], chunk = 0, budget = 1
+127
View File
@@ -377,4 +377,131 @@ fn aggregate_state_refolds_only_the_touched_keys() {
let out = st.step(&Z::singleton((7, 1), 1), f);
assert_eq!(folds.get(), 2, "one key, folded before and after");
assert_eq!(out, Z::from_iter([((7, 1), -1), ((7, 2), 1)]));
folds.set(0);
let out = st.step(&Z::singleton((7, 1), -1), f);
assert_eq!(folds.get(), 2, "the second step also folds only its key");
assert_eq!(out, Z::from_iter([((7, 2), -1), ((7, 1), 1)]));
}
fn temporal(c: &mut Circuit, s: &Stream<Z<u8>>) -> Stream<Z<u8>> {
let d = c.delay(s);
let i = c.integrate(s);
let df = c.differentiate(s);
let a = c.plus(&d, &i);
let b = c.plus(&a, &df);
c.distinct(&b)
}
/// The sums of three sinks over a snapshot loaded after one prior tick.
fn loaded(snapshot: &Z<u8>, chunk: usize, budget: usize) -> (Z<u8>, Z<u8>, Z<u8>) {
let mut c = Circuit::new();
let (input, s) = c.input::<u8>();
let (d, i, df) = (c.delay(&s), c.integrate(&s), c.differentiate(&s));
let sinks = (c.output(&d), c.output(&i), c.output(&df));
input.push(Z::singleton(9, 1));
c.step();
let _ = (sinks.0.take(), sinks.1.take(), sinks.2.take());
let mut job = Rebuild::new(input, snapshot.clone(), chunk);
for _ in 0..50 {
let left = Cell::new(budget);
let p = job.step(&mut c, || {
left.set(left.get() - 1);
left.get() == 0
});
if p == Progress::Done {
break;
}
}
(sinks.0.take(), sinks.1.take(), sinks.2.take())
}
#[test]
fn a_chunked_rebuild_is_one_logical_step_for_delay_integrate_and_differentiate() {
let snap: Z<u8> = [(1, 1), (2, 1)].into_iter().collect();
let want = loaded(&snap, 5, 5);
assert_eq!(
want.0,
Z::singleton(9, 1),
"delay shows the previous tick once"
);
assert_eq!(loaded(&snap, 1, 1), want, "[temporal-chunk]");
}
#[test]
fn an_empty_snapshot_is_still_one_tick() {
let mut c = Circuit::new();
let (input, s) = c.input::<u8>();
let d = c.delay(&s);
input.push(Z::singleton(1, 1));
c.step();
let mut job = Rebuild::new(input, Z::new(), 3);
assert_eq!(job.step(&mut c, || false), Progress::Done);
assert_eq!(d.current().len(), 1, "the empty load ticked the clock once");
assert_eq!(job.step(&mut c, || false), Progress::Done);
assert_eq!(d.current().len(), 1, "a finished job does not tick again");
}
#[test]
fn the_last_chunk_is_done_even_when_the_deadline_has_expired() {
let mut c = Circuit::new();
let (input, _s) = c.input::<u8>();
let mut job = Rebuild::new(input, Z::singleton(1, 1), 4);
assert_eq!(job.step(&mut c, || true), Progress::Done);
}
proptest! {
#[test]
fn a_chunked_temporal_rebuild_equals_one_step(
prior in pairs(), snapshot in pairs(), chunk in 0usize..4, budget in 1usize..4,
) {
let (prior, snapshot): (Z<u8>, Z<u8>) = (prior.into_iter().collect(), snapshot.into_iter().collect());
let run = |chunk: usize, budget: usize| {
let mut c = Circuit::new();
let (input, s) = c.input::<u8>();
let out = temporal(&mut c, &s);
let sink = c.output(&out);
input.push(prior.clone());
c.step();
let _ = sink.take();
let mut job = Rebuild::new(input, snapshot.clone(), chunk);
for _ in 0..50 {
let left = Cell::new(budget);
if job.step(&mut c, || { left.set(left.get() - 1); left.get() == 0 }) == Progress::Done { break; }
}
sink.take()
};
prop_assert_eq!(run(chunk, budget), run(100, 100), "[temporal-rebuild]");
}
}
#[test]
fn a_group_that_empties_leaves_no_phantom_row_for_a_count() {
let mut st = AggregateState::new(MemTrace::<u8, u8>::new());
let count = |_: &u8, vals: &[(u8, i64)]| Some(vals.len() as i64);
assert_eq!(
st.step(&Z::singleton((7, 1), 1), count),
Z::singleton((7, 1), 1)
);
assert_eq!(
st.step(&Z::singleton((7, 1), -1), count),
Z::singleton((7, 1), -1)
);
assert_eq!(
st.step(&Z::singleton((7, 2), 1), count),
Z::singleton((7, 1), 1)
);
}
#[test]
fn an_aggregate_that_returns_some_on_empty_input_matches_recompute() {
let steps = vec![
Z::singleton((7u8, 1u8), 1),
Z::singleton((7, 1), -1),
Z::singleton((7, 2), 1),
];
matches_recompute(
&steps,
|c, s| c.aggregate(s, |_, vals| Some(vals.len() as i64)),
|z| group(z, |vals| Some(vals.len() as i64)),
);
}