b2445fed97
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
310 lines
9.2 KiB
Rust
310 lines
9.2 KiB
Rust
//! `Circuit::begin`/`rollback`/`commit` (ADR 009 clause 11): a failed step is undone, and
|
|
//! the circuit then behaves as if the step never ran. Evidence: `context/test-evidence/zset.md`.
|
|
|
|
use proptest::prelude::*;
|
|
use std::panic::{AssertUnwindSafe, catch_unwind};
|
|
use zset::{Circuit, Input, ZSet};
|
|
|
|
type Z<K> = ZSet<K>;
|
|
type P = (u8, u8);
|
|
|
|
/// A circuit using every stateful operator, and two closures: every stream's
|
|
/// `current()`, and every sink drained.
|
|
struct Rig {
|
|
c: Circuit,
|
|
a: Input<P>,
|
|
b: Input<P>,
|
|
view: Box<dyn Fn() -> String>,
|
|
drain: Box<dyn Fn() -> String>,
|
|
}
|
|
|
|
fn rig() -> Rig {
|
|
let mut c = Circuit::new();
|
|
let (a, sa) = c.input::<P>();
|
|
let (b, sb) = c.input::<P>();
|
|
let ia = c.integrate(&sa);
|
|
let da = c.delay(&sa);
|
|
let df = c.differentiate(&sa);
|
|
let ds = c.distinct(&sa);
|
|
let j = c.join_indexed(&sa, &sb, |k, x, y| (*k, *x, *y));
|
|
let ij = c.integrate(&j);
|
|
let ag = c.aggregate(&sa, |_, vals| {
|
|
Some(vals.iter().fold(0i64, |s, (v, w)| {
|
|
s.wrapping_add(i64::from(*v).wrapping_mul(*w))
|
|
}))
|
|
});
|
|
let pl = c.plus(&ia, &sb);
|
|
let sinks = (
|
|
c.output(&ia),
|
|
c.output(&da),
|
|
c.output(&df),
|
|
c.output(&ds),
|
|
c.output(&j),
|
|
c.output(&ij),
|
|
c.output(&ag),
|
|
c.output(&pl),
|
|
);
|
|
let (s1, s2) = (sa.clone(), sb.clone());
|
|
let view = move || {
|
|
format!(
|
|
"{:?} {:?} {:?} {:?} {:?} {:?} {:?} {:?} {:?} {:?} {:?}",
|
|
s1.current(),
|
|
s2.current(),
|
|
ia.current(),
|
|
da.current(),
|
|
df.current(),
|
|
ds.current(),
|
|
j.current(),
|
|
ij.current(),
|
|
ag.current(),
|
|
pl.current(),
|
|
s1.current().len(),
|
|
)
|
|
};
|
|
let drain = move || {
|
|
format!(
|
|
"{:?} {:?} {:?} {:?} {:?} {:?} {:?} {:?}",
|
|
sinks.0.take(),
|
|
sinks.1.take(),
|
|
sinks.2.take(),
|
|
sinks.3.take(),
|
|
sinks.4.take(),
|
|
sinks.5.take(),
|
|
sinks.6.take(),
|
|
sinks.7.take(),
|
|
)
|
|
};
|
|
Rig {
|
|
c,
|
|
a,
|
|
b,
|
|
view: Box::new(view),
|
|
drain: Box::new(drain),
|
|
}
|
|
}
|
|
|
|
impl Rig {
|
|
fn tick(&mut self, a: &Z<P>, b: &Z<P>) {
|
|
self.a.push(a.clone());
|
|
self.b.push(b.clone());
|
|
self.c.step();
|
|
}
|
|
}
|
|
|
|
fn delta() -> impl Strategy<Value = Z<P>> {
|
|
prop::collection::vec(((0u8..4, 0u8..3), -3i64..=3), 0..6).prop_map(|p| p.into_iter().collect())
|
|
}
|
|
|
|
fn ticks(n: std::ops::Range<usize>) -> impl Strategy<Value = Vec<(Z<P>, Z<P>)>> {
|
|
prop::collection::vec((delta(), delta()), n)
|
|
}
|
|
|
|
/// The key that sits near `i64::MAX` once preloaded, so a later `+5` overflows it.
|
|
const BIG: P = (9, 9);
|
|
|
|
fn poison() -> Z<P> {
|
|
// `(0, 0)` sorts first, so operators have applied it before `BIG` overflows.
|
|
[((0, 0), 1), (BIG, 5)].into_iter().collect()
|
|
}
|
|
|
|
proptest! {
|
|
/// A step that fails half way, then a rollback, leaves the circuit as it was; the ticks
|
|
/// after run as if the failed step had never been attempted.
|
|
#[test]
|
|
fn a_rolled_back_step_leaves_the_circuit_as_it_was_and_the_ticks_after_unchanged(
|
|
pre in ticks(0..4),
|
|
post in ticks(1..4),
|
|
carry in delta(),
|
|
bad in (delta(), delta()),
|
|
fail in any::<bool>(),
|
|
) {
|
|
let (mut x, mut y) = (rig(), rig());
|
|
let preload: Z<P> = [(BIG, i64::MAX - 1)].into_iter().collect();
|
|
for (a, b) in pre.iter().chain([&(preload, Z::new())]) {
|
|
x.tick(a, b);
|
|
y.tick(a, b);
|
|
prop_assert_eq!((x.view)(), (y.view)(), "[pre] state");
|
|
prop_assert_eq!((x.drain)(), (y.drain)(), "[pre] output");
|
|
}
|
|
x.a.push(carry.clone());
|
|
y.a.push(carry);
|
|
let (view_before, drain_before) = ((x.view)(), (x.drain)());
|
|
|
|
x.c.begin();
|
|
let mut a = bad.0.clone();
|
|
if fail {
|
|
a += poison();
|
|
}
|
|
x.a.push(a);
|
|
x.b.push(bad.1.clone());
|
|
let outcome = catch_unwind(AssertUnwindSafe(|| x.c.step()));
|
|
prop_assert_eq!(outcome.is_err(), fail, "[panic] the poison step panics, the others do not");
|
|
x.c.rollback();
|
|
|
|
prop_assert_eq!((x.view)(), view_before, "[rollback] every stream's current()");
|
|
prop_assert_eq!((x.drain)(), drain_before, "[rollback] every sink");
|
|
for (i, (a, b)) in post.iter().enumerate() {
|
|
x.tick(a, b);
|
|
y.tick(a, b);
|
|
prop_assert_eq!((x.view)(), (y.view)(), "[post] state, tick {}", i);
|
|
prop_assert_eq!((x.drain)(), (y.drain)(), "[post] output, tick {}", i);
|
|
}
|
|
}
|
|
|
|
/// `commit` keeps what the steps did; a transaction that commits is not observable.
|
|
#[test]
|
|
fn a_committed_transaction_equals_the_same_steps_outside_one(
|
|
steps in ticks(1..6),
|
|
split in 0usize..6,
|
|
) {
|
|
let (mut x, mut y) = (rig(), rig());
|
|
for (i, (a, b)) in steps.iter().enumerate() {
|
|
let open = i >= split;
|
|
if open {
|
|
x.c.begin();
|
|
}
|
|
x.tick(a, b);
|
|
if open {
|
|
x.c.commit();
|
|
}
|
|
y.tick(a, b);
|
|
prop_assert_eq!((x.view)(), (y.view)(), "[commit] state, tick {}", i);
|
|
prop_assert_eq!((x.drain)(), (y.drain)(), "[commit] output, tick {}", i);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn an_operator_built_inside_a_transaction_goes_back_to_empty() {
|
|
let mut c = Circuit::new();
|
|
let (input, s) = c.input::<u8>();
|
|
c.begin();
|
|
let i = c.integrate(&s);
|
|
input.push([(1u8, 1)].into_iter().collect());
|
|
c.step();
|
|
c.rollback();
|
|
input.push([(2u8, 1)].into_iter().collect());
|
|
c.step();
|
|
let want: Z<u8> = [(2u8, 1)].into_iter().collect();
|
|
assert_eq!(
|
|
i.current(),
|
|
want,
|
|
"[built] the integral forgot the rolled-back tick"
|
|
);
|
|
}
|
|
|
|
/// One key at `MAX` that a transaction lowers by one and raises again. Undone oldest first,
|
|
/// the first undo would take it to `MAX + 1`; newest first, nothing leaves `i64`.
|
|
#[test]
|
|
fn rollback_undoes_newest_first_so_no_undo_leaves_i64() {
|
|
let mut c = Circuit::new();
|
|
let (input, s) = c.input::<P>();
|
|
let (i, d) = (c.integrate(&s), c.distinct(&s));
|
|
let g = c.aggregate(&s, |_, vals| Some(vals.len() as i64));
|
|
let one = |w: i64| -> Z<P> { [((1, 1), w)].into_iter().collect() };
|
|
input.push(one(i64::MAX));
|
|
c.step();
|
|
let before = (i.current(), d.current(), g.current());
|
|
c.begin();
|
|
for w in [-1, 1] {
|
|
input.push(one(w));
|
|
c.step();
|
|
}
|
|
c.rollback();
|
|
assert_eq!(
|
|
(i.current(), d.current(), g.current()),
|
|
before,
|
|
"[order] streams"
|
|
);
|
|
input.push(one(-1));
|
|
c.step();
|
|
assert_eq!(
|
|
i.current(),
|
|
one(i64::MAX - 1),
|
|
"[order] the integral is back at MAX first"
|
|
);
|
|
}
|
|
|
|
/// `i64::MIN` has no negation: a trace undoes it in two applications.
|
|
#[test]
|
|
fn a_minimum_weight_applied_to_every_kind_of_state_is_taken_back() {
|
|
let mut x = rig();
|
|
let mut y = rig();
|
|
let min: Z<P> = [((1, 1), i64::MIN)].into_iter().collect();
|
|
x.c.begin();
|
|
x.tick(&min, &Z::new());
|
|
x.c.rollback();
|
|
let right: Z<P> = [((1, 2), 1)].into_iter().collect();
|
|
x.tick(&Z::new(), &right);
|
|
y.tick(&Z::new(), &right);
|
|
assert_eq!((x.view)(), (y.view)(), "[min] state");
|
|
assert_eq!(
|
|
(x.drain)(),
|
|
(y.drain)(),
|
|
"[min] output: the join saw no left side"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rollback_undoes_the_clock() {
|
|
let mut c = Circuit::new();
|
|
let (input, _s) = c.input::<u8>();
|
|
c.step();
|
|
c.begin();
|
|
input.push([(1u8, 1)].into_iter().collect());
|
|
c.step();
|
|
assert_eq!(c.ticks(), 2);
|
|
c.rollback();
|
|
assert_eq!(c.ticks(), 1, "[ticks] the rolled-back step is not counted");
|
|
}
|
|
|
|
#[test]
|
|
#[should_panic(expected = "already open")]
|
|
fn a_second_begin_is_refused_loudly() {
|
|
let mut c = Circuit::new();
|
|
c.begin();
|
|
c.begin();
|
|
}
|
|
|
|
#[test]
|
|
#[should_panic(expected = "no transaction is open")]
|
|
fn a_rollback_with_nothing_open_is_refused_loudly() {
|
|
Circuit::new().rollback();
|
|
}
|
|
|
|
#[test]
|
|
#[should_panic(expected = "no transaction is open")]
|
|
fn a_commit_with_nothing_open_is_refused_loudly() {
|
|
Circuit::new().commit();
|
|
}
|
|
|
|
/// A tick in two pieces, rolled back: the edge's whole value comes back, not the last piece.
|
|
#[test]
|
|
fn a_rollback_across_a_split_tick_restores_the_whole_value() {
|
|
let (mut x, mut y) = (rig(), rig());
|
|
let first: Z<P> = [((1, 1), 2), ((2, 1), 1)].into_iter().collect();
|
|
x.tick(&first, &Z::new());
|
|
y.tick(&first, &Z::new());
|
|
// The tick is already split when the transaction opens, so every edge holds a `whole`.
|
|
for r in [&mut x, &mut y] {
|
|
r.a.push([((3, 1), 1)].into_iter().collect());
|
|
r.c.step_continue();
|
|
}
|
|
x.c.begin();
|
|
for k in [4u8, 5] {
|
|
x.a.push([((k, 1), 1)].into_iter().collect());
|
|
x.c.step_continue();
|
|
}
|
|
x.c.rollback();
|
|
assert_eq!((x.view)(), (y.view)(), "[split] state after the rollback");
|
|
assert_eq!(
|
|
(x.drain)(),
|
|
(y.drain)(),
|
|
"[split] output after the rollback"
|
|
);
|
|
let next: Z<P> = [((5, 1), 1)].into_iter().collect();
|
|
x.tick(&next, &Z::new());
|
|
y.tick(&next, &Z::new());
|
|
assert_eq!((x.view)(), (y.view)(), "[split] the tick after");
|
|
}
|