Skip to main content

max / makenotwork

Pin what a discount round-trip loses, as an equation Phase 2 of wiki testing-posture, the metamorphic relation. The relation asked for was "apply a discount, remove it, get the original price back". It holds for Fixed and it does not hold for Percentage, because integer cents rounding is not invertible, which the task expected. What replaces it is not a tolerance. apply_discount computes price - (price * pct) / 100 in integers, so writing price * pct = 100q + r gives discounted * 100 = price * (100 - pct) + r exactly, and r is the whole of the loss. The tests state that identity and assert r is the remainder integer division dropped, which bounds the round-trip error by 100 regardless of price: a $10,000 sale is no less recoverable than a $1 one. Removal is therefore exact exactly when 100 divides price * pct, which is the half of the original relation that survives and is its own property. Fixed inverts wherever it does not clamp; at or above the price every price collapses to 0, so no inverse exists, and that clamp is what stops a generous coupon paying the buyer. A full percentage discount is the one case that cannot hold however the price is chosen, so it is a named test rather than a skipped assumption: free is a configuration, not an edge.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-06 15:32 UTC
Signed with PGP, not checked
Commit: 5c7ebea43779330e15fe00fe690c886eb014fa34
Parent: 1241db5
1 file changed, +98 insertions, -0 deletions
@@ -1217,6 +1217,104 @@
1217 1217 }
1218 1218 }
1219 1219
1220 + // ── Metamorphic: applying a discount and removing it again ───────────────
1221 + //
1222 + // Wiki `testing-posture`, phase 2. A metamorphic relation states how two
1223 + // runs relate rather than what either returns, so it needs no table of
1224 + // expected values and nothing has to be recomputed by hand when a price
1225 + // changes. See Chen et al. 1998.
1226 + //
1227 + // The relation asked for was "apply then remove is the identity". It is,
1228 + // for Fixed, and it is not for Percentage, because integer cents rounding
1229 + // is not invertible. What replaces it is not a tolerance: the loss is an
1230 + // exact quantity and the tests below pin it as one.
1231 +
1232 + proptest::proptest! {
1233 + /// Fixed is invertible wherever it does not clamp: the discount is a
1234 + /// subtraction, and adding it back is the inverse of subtracting it.
1235 + #[test]
1236 + fn prop_removing_a_fixed_discount_restores_the_price_exactly(
1237 + price in 0..=1_000_000i32,
1238 + discount in 0..=1_000_000i32,
1239 + ) {
1240 + proptest::prop_assume!(discount <= price);
1241 + let discounted = apply_discount(price, DiscountType::Fixed, discount);
1242 + proptest::prop_assert_eq!(discounted + discount, price);
1243 + }
1244 +
1245 + /// Above the price it is not invertible, and that is the intended
1246 + /// behaviour rather than a gap: the clamp to zero is what stops a
1247 + /// generous coupon paying the buyer. Every price at or below the
1248 + /// discount collapses to the same 0, so no inverse can tell them apart.
1249 + #[test]
1250 + fn prop_a_fixed_discount_over_the_price_destroys_it(
1251 + price in 0..=1_000_000i32,
1252 + excess in 0..=1_000_000i32,
1253 + ) {
1254 + let discount = price.saturating_add(excess);
1255 + proptest::prop_assert_eq!(apply_discount(price, DiscountType::Fixed, discount), 0);
1256 + }
1257 +
1258 + /// What a percentage discount loses, stated exactly.
1259 + ///
1260 + /// `apply_discount` computes `price - (price * pct) / 100` with integer
1261 + /// division, so writing `price * pct = 100q + r` with `0 <= r < 100`
1262 + /// gives `discounted * 100 = price * (100 - pct) + r`. The remainder `r`
1263 + /// is the whole of the round-trip loss and it is bounded by 100
1264 + /// regardless of how large the price is.
1265 + ///
1266 + /// That identity is the tolerance the task asked to have pinned, and it
1267 + /// is worth having as an equation rather than an epsilon: the error does
1268 + /// not grow with the price, so a $10,000 sale is no less recoverable
1269 + /// than a $1 one.
1270 + #[test]
1271 + fn prop_a_percentage_discount_loses_exactly_the_rounding_remainder(
1272 + price in 0..=1_000_000i32,
1273 + pct in 0..=100i32,
1274 + ) {
1275 + let discounted = apply_discount(price, DiscountType::Percentage, pct);
1276 + let remainder = i64::from(discounted) * 100 - i64::from(price) * i64::from(100 - pct);
1277 + proptest::prop_assert!(
1278 + (0..100).contains(&remainder),
1279 + "price={} pct={} discounted={} left remainder {}, outside [0, 100)",
1280 + price, pct, discounted, remainder,
1281 + );
1282 + proptest::prop_assert_eq!(
1283 + remainder,
1284 + (i64::from(price) * i64::from(pct)) % 100,
1285 + "the remainder is not the one integer division dropped",
1286 + );
1287 + }
1288 +
1289 + /// So removal is exact exactly when nothing was dropped, which is when
1290 + /// 100 divides `price * pct`. Constructing such a price is the point:
1291 + /// this is the half of the original relation that does survive.
1292 + #[test]
1293 + fn prop_removing_a_percentage_discount_is_exact_when_it_divides_evenly(
1294 + hundreds in 0..=10_000i32,
1295 + pct in 0..=99i32,
1296 + ) {
1297 + let price = hundreds * 100;
1298 + let discounted = apply_discount(price, DiscountType::Percentage, pct);
1299 + // No remainder, so the inverse is the plain rational one.
1300 + proptest::prop_assert_eq!(
1301 + i64::from(discounted) * 100 / i64::from(100 - pct),
1302 + i64::from(price),
1303 + );
1304 + }
1305 + }
1306 +
1307 + /// The one case where the relation cannot hold however the price is chosen.
1308 + /// A full discount maps every price to 0, so removal has nothing to work
1309 + /// from. Worth a named test rather than an `prop_assume!` that quietly skips
1310 + /// it, since "free" is a real configuration and not an edge.
1311 + #[test]
1312 + fn removing_a_full_discount_is_impossible_by_construction() {
1313 + for price in [0, 1, 99, 100, 101, 999, 1_000_000] {
1314 + assert_eq!(apply_discount(price, DiscountType::Percentage, 100), 0);
1315 + }
1316 + }
1317 +
1220 1318 // Cart promo semantics: one redemption = one use (ultra-fuzz Run 10 Pay S1)
1221 1319
1222 1320 /// Build a percentage-discount promo with no scope/min-price gating.