From 60ca69ee0c15c1cd775ad6ecfdcff8172e917611 Mon Sep 17 00:00:00 2001 From: Reed Date: Sun, 1 Nov 2026 14:37:46 +0100 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=94=B4=20Reed=20[accumulate-loss]=202?= =?UTF-8?q?026-11-01=20src/lib.rs=20=E2=80=94=20Measurement::accumulate=20?= =?UTF-8?q?drops=20the=20other=20side's=20loss=20on=20Failure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The arm `(Failure(e, l), _) | (_, Failure(e, l)) => Failure(e, l)` keeps only the failing side's loss. The spec (prism/docs/spec/imperfect.md, "What must never happen") forbids composition dropping loss and a failure's earlier cost being thrown away; accumulate's own docstring says "Loss combines." Tests, in self-then-other order (same as the Partial+Partial arm): - Partial(l1) + Failure(e, l2) → Failure(e, l1 ⊕ l2), and the mirror order - Failure(a, l1) + Failure(b, l2) → Failure(a, l1 ⊕ l2): first error wins, as the existing arm already does - total stays absorbing: Partial(total) + Failure(e, 3) currently gives 3 - law over u64, usize, Vec, String, ConvergenceLoss, (u64, Vec) Red against the unchanged implementation: 5 failing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G8bTSUPSTntzPWqYHKMepf --- src/lib.rs | 111 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 9394dcd..a0bf35b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1221,6 +1221,117 @@ mod tests { assert!(result.is_err()); } + // --- accumulate never drops loss across a Failure --- + // + // Order is `self` then `other`: `self_loss.combine(other_loss)`, the same + // order the Partial+Partial arm uses. Vec appends, so the order is + // visible in the result. + + fn vloss(xs: &[&str]) -> Vec { + xs.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn measurement_accumulate_partial_then_failure_keeps_both_losses() { + let a: Measurement> = Measurement::measured(vloss(&["l1"])); + let b: Measurement> = + Measurement::failed(MeasureError::Disconnected, vloss(&["l2"])); + assert_eq!( + a.accumulate(b), + Imperfect::Failure(MeasureError::Disconnected, vloss(&["l1", "l2"])) + ); + } + + #[test] + fn measurement_accumulate_failure_then_partial_keeps_both_losses() { + let a: Measurement> = + Measurement::failed(MeasureError::Disconnected, vloss(&["l2"])); + let b: Measurement> = Measurement::measured(vloss(&["l1"])); + assert_eq!( + a.accumulate(b), + Imperfect::Failure(MeasureError::Disconnected, vloss(&["l2", "l1"])) + ); + } + + #[test] + fn measurement_accumulate_failure_failure_first_error_wins_losses_combine() { + let a: Measurement<&str, Vec> = Measurement::failed("first", vloss(&["l1"])); + let b: Measurement<&str, Vec> = Measurement::failed("second", vloss(&["l2"])); + assert_eq!( + a.accumulate(b), + Imperfect::Failure("first", vloss(&["l1", "l2"])) + ); + } + + #[test] + fn measurement_accumulate_failure_settled_keeps_loss_either_order() { + let f = || -> Measurement<&str, Vec> { Measurement::failed("e", vloss(&["l"])) }; + let s = || -> Measurement<&str, Vec> { Measurement::settled() }; + assert_eq!(f().accumulate(s()), Imperfect::Failure("e", vloss(&["l"]))); + assert_eq!(s().accumulate(f()), Imperfect::Failure("e", vloss(&["l"]))); + } + + #[test] + fn measurement_accumulate_failure_total_stays_absorbing() { + let p: Measurement<&str, u64> = Measurement::measured(7); + let f: Measurement<&str, u64> = Measurement::failed("e", u64::total()); + assert_eq!( + p.clone().accumulate(f.clone()), + Imperfect::Failure("e", u64::MAX) + ); + assert_eq!(f.accumulate(p), Imperfect::Failure("e", u64::MAX)); + + let p: Measurement<&str, u64> = Measurement::measured(u64::total()); + let f: Measurement<&str, u64> = Measurement::failed("e", 3); + assert_eq!( + p.clone().accumulate(f.clone()), + Imperfect::Failure("e", u64::MAX) + ); + assert_eq!(f.accumulate(p), Imperfect::Failure("e", u64::MAX)); + } + + /// Property: for every pair of losses and every case pairing that + /// involves a Failure, the accumulated loss is `self.combine(other)`. + fn accumulate_keeps_loss_law(samples: &[L]) { + for l1 in samples { + for l2 in samples { + let p1: Measurement<&str, L> = Imperfect::Partial((), l1.clone()); + let f1: Measurement<&str, L> = Imperfect::Failure("a", l1.clone()); + let f2: Measurement<&str, L> = Imperfect::Failure("b", l2.clone()); + let p2: Measurement<&str, L> = Imperfect::Partial((), l2.clone()); + let both = l1.clone().combine(l2.clone()); + assert_eq!( + p1.accumulate(f2.clone()), + Imperfect::Failure("b", both.clone()) + ); + assert_eq!( + f1.clone().accumulate(p2), + Imperfect::Failure("a", both.clone()) + ); + assert_eq!(f1.accumulate(f2), Imperfect::Failure("a", both)); + } + } + } + + #[test] + fn measurement_accumulate_keeps_loss_across_loss_impls() { + accumulate_keeps_loss_law::(&[0, 1, 5, u64::total()]); + accumulate_keeps_loss_law::(&[0, 2, usize::total()]); + accumulate_keeps_loss_law::>(&[vec![], vloss(&["x"]), vloss(&["y", "z"])]); + accumulate_keeps_loss_law::(&[String::new(), "a".into(), "bc".into()]); + accumulate_keeps_loss_law::(&[ + ConvergenceLoss::zero(), + ConvergenceLoss::new(3), + ConvergenceLoss::new(9), + ConvergenceLoss::total(), + ]); + accumulate_keeps_loss_law::<(u64, Vec)>(&[ + (0, vec![]), + (1, vloss(&["x"])), + (u64::total(), vloss(&["y"])), + ]); + } + // --- Imperfect with ConvergenceLoss --- #[test] From 795ef2fb600b795242152bf38cb184c9790846b6 Mon Sep 17 00:00:00 2001 From: Reed Date: Sun, 1 Nov 2026 14:39:44 +0100 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=9F=A2=20Reed=20[accumulate-loss]=202?= =?UTF-8?q?026-11-01=20src/lib.rs=20=E2=80=94=20Measurement::accumulate=20?= =?UTF-8?q?carries=20both=20losses=20through=20a=20Failure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only the Failure arm changes. The error is unchanged (self's error if self failed, so the first wins when both fail); the loss is now self_loss.combine(other_loss), the same order as Partial+Partial. A Success side contributes zero, so Failure+Settled keeps its loss by the identity law. total stays absorbing: Partial(total) + Failure(e, 3) is now Failure(e, total). Mutants checked: restoring the drop fails 5 tests; swapping the combine order in the self-failed arm fails 3. fmt, clippy --all-targets -D warnings, cargo test --workspace (226 + 22 + 4 doc) clean. Line coverage 97.34 % (main 97.28 %, gate 99 % is older debt). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G8bTSUPSTntzPWqYHKMepf --- src/lib.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index a0bf35b..e403dde 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1128,7 +1128,9 @@ impl Measurement { } /// Accumulate another measurement. If either moved, the result moved. - /// Loss combines. + /// Loss combines, `self` then `other`, in every case, including Failure: + /// a Failure keeps its error (the first one, if both failed) and carries + /// the loss of both sides. pub fn accumulate(self, other: Self) -> Self { match (self, other) { (Imperfect::Success(_), Imperfect::Success(_)) => Imperfect::Success(()), @@ -1137,9 +1139,8 @@ impl Measurement { (Imperfect::Partial(_, l1), Imperfect::Partial(_, l2)) => { Imperfect::Partial((), l1.combine(l2)) } - (Imperfect::Failure(e, l), _) | (_, Imperfect::Failure(e, l)) => { - Imperfect::Failure(e, l) - } + (Imperfect::Failure(e, l1), other) => Imperfect::Failure(e, l1.combine(other.loss())), + (this, Imperfect::Failure(e, l2)) => Imperfect::Failure(e, this.loss().combine(l2)), } } }