{
  "id": "P175",
  "slug": "loyalty-points",
  "key": "P175-loyalty-points",
  "title": "Loyalty points",
  "summary": "A cafe's points rule changed three times: today's pipeline rebuilding the points table calls 241 of 360 receipts ledger errors where 3 are, and a write-time store rule leaves the 19 old gaps in place.",
  "entry": "main.eml",
  "ui": "terminal",
  "readme": "# P175 - Loyalty points\n\nA cafe's points rule, changed three times between 2025 and 2027, and a\npoints table rebuilt with today's rule.\n\n- `main.eml` - the menu, the rules, the receipts, the backfill, the store\n  rule, and a receipt of your own\n- `loyalty.eml` - the calendar of 2025 to 2027, the rules and the day each\n  began, the points each gives, and 360 receipts drawn from a fixed seed\n\nHow each part works:\n\n- v1 gave a point for each whole 1.00; v2 doubled it at weekends; v3\n  added 50 on a receipt of 30.00 or over; v4, today's rule, gives a point\n  for each whole 2.00 and 50 on 40.00 or over. The ledger holds the points\n  each receipt was given on its day, three of them wrongly.\n- As in the corpus case `the-backfill-used-todays-code-and-the-rows-were-old`,\n  the points table rebuilt by today's pipeline differs from the ledger on\n  241 of the 360 receipts: every one before 2027, and one after. The\n  reconciliation puts the recomputed value under expected and calls all\n  241 ledger errors. Recomputed with the rule in force on each receipt's\n  day, 3 differ: the three really given the wrong points.\n- As in `the-rule-was-enforced-at-write-time-and-the-data-predated-it`,\n  since 2026-04-01 no receipt can be written without a store. The 19 old\n  receipts without one stay: 12.7% of the table when the rule began, 5.3%\n  at the end of 2027. The rule stopped the problem growing and did not\n  make it smaller.\n\nWhat is checked: menu choices 1 to 6; a real date written YYYY-MM-DD from\n2025-01-01 to 2027-12-31; an amount with two decimals from 0.01 to 999.99.\nAn empty answer cancels.\n\nSessions: `sessions/basic.in` gives:\n- the rules, the receipts, the backfill and the store rule;\n- receipts of your own: 23.40 on a Saturday under v2, 45.00 under v4 (the\n  backfill leaves it), and 1.99 under v1.\n\n`sessions/bad-input.in` gives:\n- menu choices 0 and x;\n- dates: a cancel, 2024-12-31, 2028-01-01, 2026-02-29, 2026-13-01,\n  26-01-01 and 2026/01/01; amounts: a cancel, 0.00, 1000.00, 12.5 and x.yz;\n- 30.00 on the last day of v2 and on the first day of v3.\n\nBuilt on the verified corpus cases `the-backfill-used-todays-code-and-the-rows-were-old`\n(three years of events reprocessed through the current pipeline) and\n`the-rule-was-enforced-at-write-time-and-the-data-predated-it` (a validation\nadded to the write path, and the rows written before it).\n",
  "modules": [
    {
      "name": "main.eml",
      "eml": "# P175 loyalty points: a cafe's points rules over three years - the rules,\n# the receipts, the points table rebuilt with today's rule, the store rule\n# checked at write time, and a receipt of your own.\nimport loyalty\n\ndef plain(s):\n    0 => i\n    len(s) => j\n    while i < j and s[i] == \" \":\n        i + 1 => i\n    while j > i and s[j - 1] == \" \":\n        j - 1 => j\n    return s[i:j]\n\ndef pad(s, n):\n    s => out\n    while len(out) < n:\n        out + \" \" => out\n    return out\n\ndef lpad(s, n):\n    s => out\n    while len(out) < n:\n        \" \" + out => out\n    return out\n\ndef digits(t):\n    for c in t:\n        if c < \"0\" or c > \"9\":\n            return False\n    return len(t) > 0\n\ndef two(n):\n    if n < 10:\n        return \"0\" + str(n)\n    return str(n)\n\ndef date(n):\n    loyalty.date_of(n) => d\n    return str(d[0]) + \"-\" + two(d[1]) + \"-\" + two(d[2])\n\ndef weekday(n):\n    return [\"Wednesday\", \"Thursday\", \"Friday\", \"Saturday\", \"Sunday\", \"Monday\", \"Tuesday\"][n % 7]\n\ndef money(c):\n    c % 100 => r\n    return str(int((c - r) / 100)) + \".\" + two(r)\n\ndef pct(a, b):\n    a * 2000 + b => y\n    int((y - y % (2 * b)) / (2 * b)) => t\n    return str(int((t - t % 10) / 10)) + \".\" + str(t % 10) + \"%\"\n\ndef show_rules():\n    \"The points rule, as it has changed:\" ^0\n    for r in loyalty.rules():\n        (\"  \" + r[0] + \"  from \" + date(r[1]) + \"  \" + r[2]) ^0\n    \"Today's rule is v4; the points pipeline implements it, and nothing else.\" ^0\n\ndef show_receipts():\n    loyalty.receipts() => rs\n    \"360 receipts, 2025 to 2027; the first eight:\" ^0\n    (\"  \" + pad(\"date\", 12) + lpad(\"amount\", 7) + \"  \" + pad(\"store\", 7) + pad(\"rule then\", 11) + \"points given\") ^0\n    0 => i\n    while i < 8:\n        rs[i] => r\n        \"-\" => st\n        if r[2] != \"\":\n            r[2] => st\n        (\"  \" + pad(date(r[0]), 12) + lpad(money(r[1]), 7) + \"  \" + pad(st, 7) + pad(loyalty.rules()[loyalty.rule_on(r[0])][0], 11) + str(r[3])) ^0\n        i + 1 => i\n\ndef show_backfill():\n    loyalty.receipts() => rs\n    \"The points table rebuilt from the receipts by today's pipeline, rule v4:\" ^0\n    (\"  \" + pad(\"rule then\", 11) + lpad(\"receipts\", 9) + lpad(\"differ from the ledger\", 24)) ^0\n    [0, 0, 0, 0] => n\n    [0, 0, 0, 0] => differ\n    0 => dated\n    for r in rs:\n        loyalty.rule_on(r[0]) => k\n        n[k] + 1 => n[k]\n        if loyalty.points(3, r[1], r[0]) != r[3]:\n            differ[k] + 1 => differ[k]\n        if loyalty.points(k, r[1], r[0]) != r[3]:\n            dated + 1 => dated\n    0 => total\n    0 => k\n    while k < 4:\n        total + differ[k] => total\n        (\"  \" + pad(loyalty.rules()[k][0], 11) + lpad(str(n[k]), 9) + lpad(str(differ[k]), 24)) ^0\n        k + 1 => k\n    (\"With today's rule \" + str(total) + \" of 360 receipts differ from the points the ledger recorded. The reconciliation puts the recomputed value under expected, and reports all \" + str(total) + \" as ledger errors.\") ^0\n    (\"Recomputed with the rule in force on each receipt's day, \" + str(dated) + \" differ: the receipts that were given the wrong points at the time.\") ^0\n\ndef show_store():\n    loyalty.receipts() => rs\n    (\"Since \" + date(loyalty.store_rule_day()) + \" a receipt cannot be written without a store. Receipts with no store, quarter by quarter:\") ^0\n    (\"  \" + pad(\"quarter end\", 13) + lpad(\"receipts\", 9) + lpad(\"no store\", 10) + lpad(\"share\", 8)) ^0\n    [89, 180, 272, 364, 454, 545, 637, 729, 819, 910, 1002, 1094] => ends\n    0 => last\n    for e in ends:\n        0 => n\n        0 => bad\n        for r in rs:\n            if r[0] <= e:\n                n + 1 => n\n                if r[2] == \"\":\n                    bad + 1 => bad\n        (\"  \" + pad(date(e), 13) + lpad(str(n), 9) + lpad(str(bad), 10) + lpad(pct(bad, n), 8)) ^0\n        bad => last\n    (\"The count stops at \" + str(last) + \" when the rule starts and never moves again; the share falls only as the table grows. The rule stopped the problem growing, and did not make it smaller.\") ^0\n\ndef own():\n    plain(input(\"date (YYYY-MM-DD, 2025 to 2027)> \")) => t\n    if t == \"\":\n        \"Cancelled.\" ^0\n        return\n    0 - 1 => n\n    if len(t) == 10 and t[4] == \"-\" and t[7] == \"-\" and digits(t[0:4]) and digits(t[5:7]) and digits(t[8:10]):\n        int(t[0:4]) => y\n        int(t[5:7]) => m\n        int(t[8:10]) => d\n        if y >= 2025 and y <= 2027 and m >= 1 and m <= 12 and d >= 1 and d <= loyalty.month_days()[m - 1]:\n            loyalty.day_number(y, m, d) => n\n    if n < 0:\n        \"Type a real date from 2025-01-01 to 2027-12-31, such as 2026-05-09.\" ^0\n        return\n    plain(input(\"amount (0.01 to 999.99)> \")) => a\n    if a == \"\":\n        \"Cancelled.\" ^0\n        return\n    0 - 1 => c\n    if len(a) >= 4 and len(a) <= 6 and a[len(a) - 3] == \".\" and digits(a[0:len(a) - 3]) and digits(a[len(a) - 2:]):\n        int(a[0:len(a) - 3]) * 100 + int(a[len(a) - 2:]) => c\n    if c < 1:\n        \"Type an amount with two decimals, such as 23.40, from 0.01 to 999.99.\" ^0\n        return\n    loyalty.rule_on(n) => k\n    loyalty.points(k, c, n) => then\n    loyalty.points(3, c, n) => now\n    \" points\" => word\n    if then == 1:\n        \" point\" => word\n    (\"On \" + t + \", a \" + weekday(n) + \", rule \" + loyalty.rules()[k][0] + \" gave \" + str(then) + word + \" for \" + money(c) + \"; today's rule v4 gives \" + str(now) + \".\") ^0\n    if then == now:\n        \"A backfill with today's rule leaves this receipt as it was.\" ^0\n    else:\n        \"A backfill with today's rule rewrites this receipt, and the reconciliation calls the ledger wrong.\" ^0\n\n\"== Loyalty points ==\" ^0\n\"A cafe's points rule, changed three times, and a table rebuilt with today's.\" ^0\nTrue => running\nwhile running:\n    \"\" ^0\n    \"1) the rules  2) the receipts  3) the backfill  4) the store rule  5) a receipt of your own  6) quit\" ^0\n    plain(input(\"choice> \")) => choice\n    if choice == \"1\":\n        show_rules()\n    elif choice == \"2\":\n        show_receipts()\n    elif choice == \"3\":\n        show_backfill()\n    elif choice == \"4\":\n        show_store()\n    elif choice == \"5\":\n        own()\n    elif choice == \"6\":\n        False => running\n    else:\n        \"Pick a number from 1 to 6.\" ^0\n\"Bye.\" ^0\n",
      "python": "import loyalty\n\ndef plain(s):\n    i = 0\n    j = len(s)\n    while i < j and s[i] == \" \":\n        i = i + 1\n    while j > i and s[j - 1] == \" \":\n        j = j - 1\n    return s[i:j]\n\ndef pad(s, n):\n    out = s\n    while len(out) < n:\n        out = out + \" \"\n    return out\n\ndef lpad(s, n):\n    out = s\n    while len(out) < n:\n        out = \" \" + out\n    return out\n\ndef digits(t):\n    for c in t:\n        if c < \"0\" or c > \"9\":\n            return False\n    return len(t) > 0\n\ndef two(n):\n    if n < 10:\n        return \"0\" + str(n)\n    return str(n)\n\ndef date(n):\n    d = loyalty.date_of(n)\n    return str(d[0]) + \"-\" + two(d[1]) + \"-\" + two(d[2])\n\ndef weekday(n):\n    return [\"Wednesday\", \"Thursday\", \"Friday\", \"Saturday\", \"Sunday\", \"Monday\", \"Tuesday\"][n % 7]\n\ndef money(c):\n    r = c % 100\n    return str(int((c - r) / 100)) + \".\" + two(r)\n\ndef pct(a, b):\n    y = a * 2000 + b\n    t = int((y - y % (2 * b)) / (2 * b))\n    return str(int((t - t % 10) / 10)) + \".\" + str(t % 10) + \"%\"\n\ndef show_rules():\n    print(\"The points rule, as it has changed:\")\n    for r in loyalty.rules():\n        print(\"  \" + r[0] + \"  from \" + date(r[1]) + \"  \" + r[2])\n    print(\"Today's rule is v4; the points pipeline implements it, and nothing else.\")\n\ndef show_receipts():\n    rs = loyalty.receipts()\n    print(\"360 receipts, 2025 to 2027; the first eight:\")\n    print(\"  \" + pad(\"date\", 12) + lpad(\"amount\", 7) + \"  \" + pad(\"store\", 7) + pad(\"rule then\", 11) + \"points given\")\n    i = 0\n    while i < 8:\n        r = rs[i]\n        st = \"-\"\n        if r[2] != \"\":\n            st = r[2]\n        print(\"  \" + pad(date(r[0]), 12) + lpad(money(r[1]), 7) + \"  \" + pad(st, 7) + pad(loyalty.rules()[loyalty.rule_on(r[0])][0], 11) + str(r[3]))\n        i = i + 1\n\ndef show_backfill():\n    rs = loyalty.receipts()\n    print(\"The points table rebuilt from the receipts by today's pipeline, rule v4:\")\n    print(\"  \" + pad(\"rule then\", 11) + lpad(\"receipts\", 9) + lpad(\"differ from the ledger\", 24))\n    n = [0, 0, 0, 0]\n    differ = [0, 0, 0, 0]\n    dated = 0\n    for r in rs:\n        k = loyalty.rule_on(r[0])\n        n[k] = n[k] + 1\n        if loyalty.points(3, r[1], r[0]) != r[3]:\n            differ[k] = differ[k] + 1\n        if loyalty.points(k, r[1], r[0]) != r[3]:\n            dated = dated + 1\n    total = 0\n    k = 0\n    while k < 4:\n        total = total + differ[k]\n        print(\"  \" + pad(loyalty.rules()[k][0], 11) + lpad(str(n[k]), 9) + lpad(str(differ[k]), 24))\n        k = k + 1\n    print(\"With today's rule \" + str(total) + \" of 360 receipts differ from the points the ledger recorded. The reconciliation puts the recomputed value under expected, and reports all \" + str(total) + \" as ledger errors.\")\n    print(\"Recomputed with the rule in force on each receipt's day, \" + str(dated) + \" differ: the receipts that were given the wrong points at the time.\")\n\ndef show_store():\n    rs = loyalty.receipts()\n    print(\"Since \" + date(loyalty.store_rule_day()) + \" a receipt cannot be written without a store. Receipts with no store, quarter by quarter:\")\n    print(\"  \" + pad(\"quarter end\", 13) + lpad(\"receipts\", 9) + lpad(\"no store\", 10) + lpad(\"share\", 8))\n    ends = [89, 180, 272, 364, 454, 545, 637, 729, 819, 910, 1002, 1094]\n    last = 0\n    for e in ends:\n        n = 0\n        bad = 0\n        for r in rs:\n            if r[0] <= e:\n                n = n + 1\n                if r[2] == \"\":\n                    bad = bad + 1\n        print(\"  \" + pad(date(e), 13) + lpad(str(n), 9) + lpad(str(bad), 10) + lpad(pct(bad, n), 8))\n        last = bad\n    print(\"The count stops at \" + str(last) + \" when the rule starts and never moves again; the share falls only as the table grows. The rule stopped the problem growing, and did not make it smaller.\")\n\ndef own():\n    t = plain(input(\"date (YYYY-MM-DD, 2025 to 2027)> \"))\n    if t == \"\":\n        print(\"Cancelled.\")\n        return\n    n = 0 - 1\n    if len(t) == 10 and t[4] == \"-\" and t[7] == \"-\" and digits(t[0:4]) and digits(t[5:7]) and digits(t[8:10]):\n        y = int(t[0:4])\n        m = int(t[5:7])\n        d = int(t[8:10])\n        if y >= 2025 and y <= 2027 and m >= 1 and m <= 12 and d >= 1 and d <= loyalty.month_days()[m - 1]:\n            n = loyalty.day_number(y, m, d)\n    if n < 0:\n        print(\"Type a real date from 2025-01-01 to 2027-12-31, such as 2026-05-09.\")\n        return\n    a = plain(input(\"amount (0.01 to 999.99)> \"))\n    if a == \"\":\n        print(\"Cancelled.\")\n        return\n    c = 0 - 1\n    if len(a) >= 4 and len(a) <= 6 and a[len(a) - 3] == \".\" and digits(a[0:len(a) - 3]) and digits(a[len(a) - 2:]):\n        c = int(a[0:len(a) - 3]) * 100 + int(a[len(a) - 2:])\n    if c < 1:\n        print(\"Type an amount with two decimals, such as 23.40, from 0.01 to 999.99.\")\n        return\n    k = loyalty.rule_on(n)\n    then = loyalty.points(k, c, n)\n    now = loyalty.points(3, c, n)\n    word = \" points\"\n    if then == 1:\n        word = \" point\"\n    print(\"On \" + t + \", a \" + weekday(n) + \", rule \" + loyalty.rules()[k][0] + \" gave \" + str(then) + word + \" for \" + money(c) + \"; today's rule v4 gives \" + str(now) + \".\")\n    if then == now:\n        print(\"A backfill with today's rule leaves this receipt as it was.\")\n    else:\n        print(\"A backfill with today's rule rewrites this receipt, and the reconciliation calls the ledger wrong.\")\n\nprint(\"== Loyalty points ==\")\nprint(\"A cafe's points rule, changed three times, and a table rebuilt with today's.\")\nrunning = True\nwhile running:\n    print(\"\")\n    print(\"1) the rules  2) the receipts  3) the backfill  4) the store rule  5) a receipt of your own  6) quit\")\n    choice = plain(input(\"choice> \"))\n    if choice == \"1\":\n        show_rules()\n    elif choice == \"2\":\n        show_receipts()\n    elif choice == \"3\":\n        show_backfill()\n    elif choice == \"4\":\n        show_store()\n    elif choice == \"5\":\n        own()\n    elif choice == \"6\":\n        running = False\n    else:\n        print(\"Pick a number from 1 to 6.\")\nprint(\"Bye.\")\n"
    },
    {
      "name": "loyalty.eml",
      "eml": "# P175 loyalty points - a cafe's points rule, changed three times between\n# 2025 and 2027, and 360 receipts. As in the corpus case\n# the-backfill-used-todays-code-and-the-rows-were-old, recomputing every\n# receipt with today's rule is correct for today and replaces what each old\n# receipt was actually given. And as in\n# the-rule-was-enforced-at-write-time-and-the-data-predated-it, a rule\n# checked when a receipt is written stops new violations and leaves every\n# old one where it was. Days are counted from 2025-01-01, a Wednesday;\n# none of 2025, 2026 and 2027 is a leap year.\n\ndef month_days():\n    return [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]\n\ndef day_number(y, m, d):\n    (y - 2025) * 365 => n\n    1 => k\n    while k < m:\n        n + month_days()[k - 1] => n\n        k + 1 => k\n    return n + d - 1\n\ndef date_of(n):\n    # [year, month, day] for day number n\n    2025 + int((n - n % 365) / 365) => y\n    n % 365 => r\n    1 => m\n    while r >= month_days()[m - 1]:\n        r - month_days()[m - 1] => r\n        m + 1 => m\n    return [y, m, r + 1]\n\ndef weekend(n):\n    # 2025-01-01 is a Wednesday: day 3 and 4 of each week are Saturday, Sunday\n    return n % 7 == 3 or n % 7 == 4\n\ndef rules():\n    # [name, first day, what it gives]\n    return [[\"v1\", 0, \"1 point for each whole 1.00\"],\n            [\"v2\", 243, \"2 points for each whole 1.00 at weekends, 1 on other days\"],\n            [\"v3\", 455, \"1 point for each whole 1.00, and 50 more on a receipt of 30.00 or over\"],\n            [\"v4\", 730, \"1 point for each whole 2.00, and 50 more on a receipt of 40.00 or over\"]]\n\ndef rule_on(n):\n    # the index of the rule in force on day n\n    0 => r\n    0 => i\n    for x in rules():\n        if n >= x[1]:\n            i => r\n        i + 1 => i\n    return r\n\ndef points(r, cents, n):\n    # what rule r gives for a receipt of cents on day n\n    int((cents - cents % 100) / 100) => whole\n    if r == 0:\n        return whole\n    if r == 1:\n        if weekend(n):\n            return 2 * whole\n        return whole\n    if r == 2:\n        if cents >= 3000:\n            return whole + 50\n        return whole\n    int((cents - cents % 200) / 200) => twos\n    if cents >= 4000:\n        return twos + 50\n    return twos\n\ndef step(x):\n    return (x * 48271) % 2147483647\n\ndef receipts():\n    # [day, cents, store, points the ledger recorded]; a store of \"\" was\n    # never filled in; three receipts were given the wrong points at the time\n    [0] * 360 => out\n    777 => x\n    0 => k\n    while k < 360:\n        step(x) => x\n        int(k * 1095 / 360) + x % 3 => day\n        step(x) => x\n        200 + 50 * (x % 117) => cents\n        step(x) => x\n        \"S\" + str(1 + x % 4) => store\n        step(x) => x\n        if day < 455 and x % 100 < 9:\n            \"\" => store\n        points(rule_on(day), cents, day) => given\n        if k % 113 == 40:\n            given + 5 => given\n        [day, cents, store, given] => out[k]\n        k + 1 => k\n    return out\n\ndef store_rule_day():\n    # the write path has required a store since 2026-04-01\n    return 455\n",
      "python": "def month_days():\n    return [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]\n\ndef day_number(y, m, d):\n    n = (y - 2025) * 365\n    k = 1\n    while k < m:\n        n = n + month_days()[k - 1]\n        k = k + 1\n    return n + d - 1\n\ndef date_of(n):\n    y = 2025 + int((n - n % 365) / 365)\n    r = n % 365\n    m = 1\n    while r >= month_days()[m - 1]:\n        r = r - month_days()[m - 1]\n        m = m + 1\n    return [y, m, r + 1]\n\ndef weekend(n):\n    return n % 7 == 3 or n % 7 == 4\n\ndef rules():\n    return [[\"v1\", 0, \"1 point for each whole 1.00\"], [\"v2\", 243, \"2 points for each whole 1.00 at weekends, 1 on other days\"], [\"v3\", 455, \"1 point for each whole 1.00, and 50 more on a receipt of 30.00 or over\"], [\"v4\", 730, \"1 point for each whole 2.00, and 50 more on a receipt of 40.00 or over\"]]\n\ndef rule_on(n):\n    r = 0\n    i = 0\n    for x in rules():\n        if n >= x[1]:\n            r = i\n        i = i + 1\n    return r\n\ndef points(r, cents, n):\n    whole = int((cents - cents % 100) / 100)\n    if r == 0:\n        return whole\n    if r == 1:\n        if weekend(n):\n            return 2 * whole\n        return whole\n    if r == 2:\n        if cents >= 3000:\n            return whole + 50\n        return whole\n    twos = int((cents - cents % 200) / 200)\n    if cents >= 4000:\n        return twos + 50\n    return twos\n\ndef step(x):\n    return x * 48271 % 2147483647\n\ndef receipts():\n    out = [0] * 360\n    x = 777\n    k = 0\n    while k < 360:\n        x = step(x)\n        day = int(k * 1095 / 360) + x % 3\n        x = step(x)\n        cents = 200 + 50 * (x % 117)\n        x = step(x)\n        store = \"S\" + str(1 + x % 4)\n        x = step(x)\n        if day < 455 and x % 100 < 9:\n            store = \"\"\n        given = points(rule_on(day), cents, day)\n        if k % 113 == 40:\n            given = given + 5\n        out[k] = [day, cents, store, given]\n        k = k + 1\n    return out\n\ndef store_rule_day():\n    return 455\n"
    }
  ],
  "sessions": [
    {
      "name": "bad-input",
      "input": "0\nx\n5\n\n5\n2024-12-31\n5\n2028-01-01\n5\n2026-02-29\n5\n2026-13-01\n5\n26-01-01\n5\n2026/01/01\n5\n2026-04-01\n\n5\n2026-04-01\n0.00\n5\n2026-04-01\n1000.00\n5\n2026-04-01\n12.5\n5\n2026-04-01\nx.yz\n5\n2026-03-31\n30.00\n5\n2026-04-01\n30.00\n6\n",
      "screen": "== Loyalty points ==\nA cafe's points rule, changed three times, and a table rebuilt with today's.\n\n1) the rules  2) the receipts  3) the backfill  4) the store rule  5) a receipt of your own  6) quit\nchoice> 0\nPick a number from 1 to 6.\n\n1) the rules  2) the receipts  3) the backfill  4) the store rule  5) a receipt of your own  6) quit\nchoice> x\nPick a number from 1 to 6.\n\n1) the rules  2) the receipts  3) the backfill  4) the store rule  5) a receipt of your own  6) quit\nchoice> 5\ndate (YYYY-MM-DD, 2025 to 2027)> \nCancelled.\n\n1) the rules  2) the receipts  3) the backfill  4) the store rule  5) a receipt of your own  6) quit\nchoice> 5\ndate (YYYY-MM-DD, 2025 to 2027)> 2024-12-31\nType a real date from 2025-01-01 to 2027-12-31, such as 2026-05-09.\n\n1) the rules  2) the receipts  3) the backfill  4) the store rule  5) a receipt of your own  6) quit\nchoice> 5\ndate (YYYY-MM-DD, 2025 to 2027)> 2028-01-01\nType a real date from 2025-01-01 to 2027-12-31, such as 2026-05-09.\n\n1) the rules  2) the receipts  3) the backfill  4) the store rule  5) a receipt of your own  6) quit\nchoice> 5\ndate (YYYY-MM-DD, 2025 to 2027)> 2026-02-29\nType a real date from 2025-01-01 to 2027-12-31, such as 2026-05-09.\n\n1) the rules  2) the receipts  3) the backfill  4) the store rule  5) a receipt of your own  6) quit\nchoice> 5\ndate (YYYY-MM-DD, 2025 to 2027)> 2026-13-01\nType a real date from 2025-01-01 to 2027-12-31, such as 2026-05-09.\n\n1) the rules  2) the receipts  3) the backfill  4) the store rule  5) a receipt of your own  6) quit\nchoice> 5\ndate (YYYY-MM-DD, 2025 to 2027)> 26-01-01\nType a real date from 2025-01-01 to 2027-12-31, such as 2026-05-09.\n\n1) the rules  2) the receipts  3) the backfill  4) the store rule  5) a receipt of your own  6) quit\nchoice> 5\ndate (YYYY-MM-DD, 2025 to 2027)> 2026/01/01\nType a real date from 2025-01-01 to 2027-12-31, such as 2026-05-09.\n\n1) the rules  2) the receipts  3) the backfill  4) the store rule  5) a receipt of your own  6) quit\nchoice> 5\ndate (YYYY-MM-DD, 2025 to 2027)> 2026-04-01\namount (0.01 to 999.99)> \nCancelled.\n\n1) the rules  2) the receipts  3) the backfill  4) the store rule  5) a receipt of your own  6) quit\nchoice> 5\ndate (YYYY-MM-DD, 2025 to 2027)> 2026-04-01\namount (0.01 to 999.99)> 0.00\nType an amount with two decimals, such as 23.40, from 0.01 to 999.99.\n\n1) the rules  2) the receipts  3) the backfill  4) the store rule  5) a receipt of your own  6) quit\nchoice> 5\ndate (YYYY-MM-DD, 2025 to 2027)> 2026-04-01\namount (0.01 to 999.99)> 1000.00\nType an amount with two decimals, such as 23.40, from 0.01 to 999.99.\n\n1) the rules  2) the receipts  3) the backfill  4) the store rule  5) a receipt of your own  6) quit\nchoice> 5\ndate (YYYY-MM-DD, 2025 to 2027)> 2026-04-01\namount (0.01 to 999.99)> 12.5\nType an amount with two decimals, such as 23.40, from 0.01 to 999.99.\n\n1) the rules  2) the receipts  3) the backfill  4) the store rule  5) a receipt of your own  6) quit\nchoice> 5\ndate (YYYY-MM-DD, 2025 to 2027)> 2026-04-01\namount (0.01 to 999.99)> x.yz\nType an amount with two decimals, such as 23.40, from 0.01 to 999.99.\n\n1) the rules  2) the receipts  3) the backfill  4) the store rule  5) a receipt of your own  6) quit\nchoice> 5\ndate (YYYY-MM-DD, 2025 to 2027)> 2026-03-31\namount (0.01 to 999.99)> 30.00\nOn 2026-03-31, a Tuesday, rule v2 gave 30 points for 30.00; today's rule v4 gives 15.\nA backfill with today's rule rewrites this receipt, and the reconciliation calls the ledger wrong.\n\n1) the rules  2) the receipts  3) the backfill  4) the store rule  5) a receipt of your own  6) quit\nchoice> 5\ndate (YYYY-MM-DD, 2025 to 2027)> 2026-04-01\namount (0.01 to 999.99)> 30.00\nOn 2026-04-01, a Wednesday, rule v3 gave 80 points for 30.00; today's rule v4 gives 15.\nA backfill with today's rule rewrites this receipt, and the reconciliation calls the ledger wrong.\n\n1) the rules  2) the receipts  3) the backfill  4) the store rule  5) a receipt of your own  6) quit\nchoice> 6\nBye.\n",
      "interpreter": "equal"
    },
    {
      "name": "basic",
      "input": "1\n2\n3\n4\n5\n2025-11-15\n23.40\n5\n2027-03-02\n45.00\n5\n2025-02-05\n1.99\n6\n",
      "screen": "== Loyalty points ==\nA cafe's points rule, changed three times, and a table rebuilt with today's.\n\n1) the rules  2) the receipts  3) the backfill  4) the store rule  5) a receipt of your own  6) quit\nchoice> 1\nThe points rule, as it has changed:\n  v1  from 2025-01-01  1 point for each whole 1.00\n  v2  from 2025-09-01  2 points for each whole 1.00 at weekends, 1 on other days\n  v3  from 2026-04-01  1 point for each whole 1.00, and 50 more on a receipt of 30.00 or over\n  v4  from 2027-01-01  1 point for each whole 2.00, and 50 more on a receipt of 40.00 or over\nToday's rule is v4; the points pipeline implements it, and nothing else.\n\n1) the rules  2) the receipts  3) the backfill  4) the store rule  5) a receipt of your own  6) quit\nchoice> 2\n360 receipts, 2025 to 2027; the first eight:\n  date         amount  store  rule then  points given\n  2025-01-01    32.00  S2     v1         32\n  2025-01-05    17.00  S2     v1         17\n  2025-01-07    54.00  S1     v1         54\n  2025-01-11    29.50  S1     v1         29\n  2025-01-13    55.00  S4     v1         55\n  2025-01-17    15.00  S3     v1         15\n  2025-01-21    17.00  S2     v1         17\n  2025-01-23     8.00  S1     v1         8\n\n1) the rules  2) the receipts  3) the backfill  4) the store rule  5) a receipt of your own  6) quit\nchoice> 3\nThe points table rebuilt from the receipts by today's pipeline, rule v4:\n  rule then   receipts  differ from the ledger\n  v1                80                      80\n  v2                70                      70\n  v3                90                      90\n  v4               120                       1\nWith today's rule 241 of 360 receipts differ from the points the ledger recorded. The reconciliation puts the recomputed value under expected, and reports all 241 as ledger errors.\nRecomputed with the rule in force on each receipt's day, 3 differ: the receipts that were given the wrong points at the time.\n\n1) the rules  2) the receipts  3) the backfill  4) the store rule  5) a receipt of your own  6) quit\nchoice> 4\nSince 2026-04-01 a receipt cannot be written without a store. Receipts with no store, quarter by quarter:\n  quarter end   receipts  no store   share\n  2025-03-31          30         2    6.7%\n  2025-06-30          60         7   11.7%\n  2025-09-30          90        14   15.6%\n  2025-12-31         120        17   14.2%\n  2026-03-31         150        19   12.7%\n  2026-06-30         180        19   10.6%\n  2026-09-30         210        19    9.0%\n  2026-12-31         240        19    7.9%\n  2027-03-31         270        19    7.0%\n  2027-06-30         300        19    6.3%\n  2027-09-30         330        19    5.8%\n  2027-12-31         360        19    5.3%\nThe count stops at 19 when the rule starts and never moves again; the share falls only as the table grows. The rule stopped the problem growing, and did not make it smaller.\n\n1) the rules  2) the receipts  3) the backfill  4) the store rule  5) a receipt of your own  6) quit\nchoice> 5\ndate (YYYY-MM-DD, 2025 to 2027)> 2025-11-15\namount (0.01 to 999.99)> 23.40\nOn 2025-11-15, a Saturday, rule v2 gave 46 points for 23.40; today's rule v4 gives 11.\nA backfill with today's rule rewrites this receipt, and the reconciliation calls the ledger wrong.\n\n1) the rules  2) the receipts  3) the backfill  4) the store rule  5) a receipt of your own  6) quit\nchoice> 5\ndate (YYYY-MM-DD, 2025 to 2027)> 2027-03-02\namount (0.01 to 999.99)> 45.00\nOn 2027-03-02, a Tuesday, rule v4 gave 72 points for 45.00; today's rule v4 gives 72.\nA backfill with today's rule leaves this receipt as it was.\n\n1) the rules  2) the receipts  3) the backfill  4) the store rule  5) a receipt of your own  6) quit\nchoice> 5\ndate (YYYY-MM-DD, 2025 to 2027)> 2025-02-05\namount (0.01 to 999.99)> 1.99\nOn 2025-02-05, a Wednesday, rule v1 gave 1 point for 1.99; today's rule v4 gives 0.\nA backfill with today's rule rewrites this receipt, and the reconciliation calls the ledger wrong.\n\n1) the rules  2) the receipts  3) the backfill  4) the store rule  5) a receipt of your own  6) quit\nchoice> 6\nBye.\n",
      "interpreter": "equal"
    }
  ],
  "builtOn": [
    {
      "slug": "the-backfill-used-todays-code-and-the-rows-were-old",
      "caseId": "627-the-backfill-used-todays-code-and-the-rows-were-old",
      "title": "The backfill used todays code and the rows were old"
    },
    {
      "slug": "the-rule-was-enforced-at-write-time-and-the-data-predated-it",
      "caseId": "578-the-rule-was-enforced-at-write-time-and-the-data-predated-it",
      "title": "The rule was enforced at write time and the data predated it"
    }
  ],
  "updated": "2026-10-19"
}
