cint

cint university / 401 / ced, the cint editor

401-01: an exact ledger

This is lab 401-01 of CU-401.2, checked against cint 0.2.0.

You will keep a bank ledger in whole cents, pay a day of interest rounded by a stated rule, refuse an overdraft as an error and stop on an overflow. Then you will save a checked copy of the ledger's output, called a golden. The golden catches a mistake the tests miss, and the tests catch a mistake the golden misses.

Take it after 201-04. The walk-through explains the parts of the language the program uses that 201-00 to 201-04 do not cover.

the program

// ledger.ci: an exact bank ledger. Money is whole cents in I64.

error LedgerError { overdrawn, unknown_kind }

// The kinds of entry, as typed constants.
const U8 DEPOSIT = 0;
const U8 WITHDRAW = 1;
const U8 INTEREST = 2;

struct Entry {
    U8 kind;
    I64 cents;                  // the amount; for interest, the yearly rate in basis points
}

// One day of interest at a yearly rate in basis points, rounded half to even, to the cent.
I64 daily_interest(I64 balance, I64 basis_points) {
    return muldiv(I64, balance, basis_points, 10_000 * 365, half_even);
}

// Apply one entry. Overdrawing is an expected error; overflow is a fault.
LedgerError!I64 apply(I64 balance, Entry e) {
    switch (e.kind) {
        case DEPOSIT:
            return balance + e.cents;
        case WITHDRAW:
            if (e.cents > balance) {
                return LedgerError.overdrawn;
            }
            return balance - e.cents;
        case INTEREST:
            return balance + daily_interest(balance, e.cents);
        default:
            return LedgerError.unknown_kind;
    }
}

void print_kind(U8 kind) {
    switch (kind) {
        case DEPOSIT: "deposit ";
        case WITHDRAW: "withdraw";
        case INTEREST: "interest";
        default: "unknown ";
    }
}

void main() {
    Entry[5] book;
    book[0] = Entry(DEPOSIT, 125_000);
    book[1] = Entry(WITHDRAW, 40_050);
    book[2] = Entry(INTEREST, 450);     // 4.50 percent a year, for one day
    book[3] = Entry(WITHDRAW, 200_000); // more than the balance: rejected
    book[4] = Entry(DEPOSIT, 99);
    I64 balance = 0;
    for i in 0..5 {
        Entry e = book[i];
        I64 next = apply(balance, e) catch (err) {
            switch (err) {
                case .overdrawn: "{i}: rejected, overdrawn\n";
                case .unknown_kind: "{i}: rejected, unknown kind\n";
            }
            continue;
        };
        balance = next;
        "{i}: ";
        print_kind(e.kind);
        " {e.cents:>8} -> {balance:/100}\n";
    }
    "final balance: {balance:/100}\n";
}

test "interest ties round to even" {
    assert(daily_interest(1_825_000, 1) == 0);  // exactly half a cent: rounds to 0
    assert(daily_interest(5_475_000, 1) == 2);  // exactly 1.5 cents: rounds to 2
}

test "an overdraft is an error" {
    I64 after = apply(100, Entry(WITHDRAW, 101)) catch -1;
    assert(after == -1);
}

test "an overflow is a fault" expect_fault E_OVERFLOW {
    _ = apply(I64.max, Entry(DEPOSIT, 1));
}

Run this program in ced

run it

$ cint run ledger.ci
0: deposit    125000 -> 1250.00
1: withdraw    40050 -> 849.50
2: interest      450 -> 849.60
3: rejected, overdrawn
4: deposit        99 -> 850.59
final balance: 850.59

One day of interest on 849.50 (84950 cents) at 4.50 percent a year is 84950 × 450 / 3650000 cents, about 10.47 cents. half_even rounds it to 10, and the balance becomes 849.60. Entry 3 asks for 2000.00 from a balance of 849.60, so apply returns the error overdrawn. The loop prints a line and moves on, and the balance does not change.

test it

$ cint test ledger.ci
test ledger.ci:71 "interest ties round to even" ... ok
test ledger.ci:76 "an overdraft is an error" ... ok
test ledger.ci:81 "an overflow is a fault" ... ok
3 of 3 tests passed

The first test checks two ties, where a rounding rule matters: an exact half cent rounds to the even neighbor, 0, and an exact cent and a half rounds to 2. The third test passes because the deposit faults: expect_fault E_OVERFLOW says a fault with that code is the expected result.

errors and faults

The ledger has two different ways to fail.

  • An overdraft is an error. It is something a ledger expects: a customer asks for more than the balance. apply returns it as a value of type LedgerError!I64, and the caller decides what to do with it, here with catch.
  • An overflow is a fault. A balance past I64.max, about 92 quadrillion dollars, means the program is wrong. No caller can catch it. The program stops at the operation and prints a fault report.

keep a golden

A golden is a saved output that you have checked by hand and that later runs must match byte for byte. The output above is right: you checked the interest and the rejected entry. Save it:

$ cint run ledger.ci > golden.txt

Now make a mistake. Change the interest entry's rate from 450 to 45 and run again into a second file:

$ cint run ledger.ci > after.txt

Compare the two files. In VS Code, right-click golden.txt in the Explorer and choose Select for Compare, then right-click after.txt and choose Compare with Selected. In a terminal on macOS or Linux, use diff:

$ diff golden.txt after.txt
3c3
< 2: interest      450 -> 849.60
---
> 2: interest       45 -> 849.51
5,6c5,6
< 4: deposit        99 -> 850.59
< final balance: 850.59
---
> 4: deposit        99 -> 850.50
> final balance: 850.50

In PowerShell on Windows, fc.exe /b golden.txt after.txt says whether the two files match, and VS Code's compare shows the lines that differ. Type fc.exe in full, since fc alone means something else in PowerShell.

Read the first line that differs. Line 3 is the first difference, and it names entry 2. Every later difference follows from it: the balance after entry 2 is 9 cents short, so every balance after it is too. The first differing line points at the change; the lines after it are consequences.

The tests still pass with the typo, because none of them reads book. That is one thing a golden is for: it checks the whole run, including data no test looks at.

Put 450 back and check that the run matches the golden again. diff prints nothing when the files are the same.

a mistake only the tests catch

Now make a different change. Replace the body of daily_interest with a plain division that looks as if it does the same thing:

    return balance * basis_points / (10_000 * 365);

Run this program in ced

Run it and compare with the golden. There is no difference: 10.47 cents rounds down to 10, the same result as rounding to the nearest cent. The tests disagree:

$ cint test ledger.ci
test ledger.ci:71 "interest ties round to even" ... FAILED
fault[E_ASSERT]: assertion failed
  --> ledger.ci:73:12
   |
73 |     assert(daily_interest(5_475_000, 1) == 2);  // exactly 1.5 cents: rounds to 2
   |            ^ assert.checked.bool
   |
   = operation: assert.checked.bool
   = operand-count: 2
   = operand: I64 1
   = operand: I64 2
   ...
test ledger.ci:76 "an overdraft is an error" ... ok
test ledger.ci:81 "an overflow is a fault" ... ok
2 of 3 tests passed

The two operands are the two sides of the comparison: the function returned 1 where the test wanted 2, because / rounds down. A golden holds what one run printed; a test holds a case chosen because it is hard. A program you intend to keep needs both. Put muldiv back.

when it faults

Change the last entry to deposit the largest I64:

    book[4] = Entry(DEPOSIT, I64.max);

Run this program in ced

$ cint run ledger.ci
0: deposit    125000 -> 1250.00
1: withdraw    40050 -> 849.50
2: interest      450 -> 849.60
3: rejected, overdrawn
fault[E_OVERFLOW]: the exact result is outside the type's range
  --> ledger.ci:24:28
   |
24 |             return balance + e.cents;
   |                            ^ add.checked.i64
   |
   = operation: add.checked.i64
   = operand-count: 2
   = operand: I64 84960
   = operand: I64 9223372036854775807
   = exact:   9223372036854860767
   = limit:   I64 9223372036854775807
   ...
   = stack-depth: 1
   = called from: ledger.ci:56:20

The four lines before the fault are the program's output up to that point. The fault report goes to standard error, not standard output, so cint run ledger.ci > after.txt saves the four lines and the report still appears in the terminal. cint run exits with status 1.

Run it again. With the same cint release, the report is the same, byte for byte, every time and on every computer: the same operation, the same operands and the same line. That makes a fault report a golden too. Save it and fix the program. When a fault comes back after a later change, compare the new report with the saved one to see whether it is the same fault or a new one. 201-07 builds this into a method.

walk-through

const U8 DEPOSIT = 0; gives each kind of entry a name with a type. switch (e.kind) matches against the names, and default takes any other value, which apply returns as unknown_kind.

Entry[5] book; declares five entries, all zero, and the lines after it fill them in.

catch (err) { ... continue; } handles the error where apply is called. err holds which error it was, and the switch prints a line for each. continue goes on to the next entry, so the line I64 next = ... never completes for a rejected entry.

apply(100, Entry(WITHDRAW, 101)) catch -1 is the short form: if apply returns an error, the whole expression is -1.

{e.cents:>8} prints the amount right-aligned in 8 characters, and {balance:/100} prints cents as dollars with two decimal places.

A basis point is a hundredth of a percent. That makes 450 basis points 4.50 percent. Dividing by 10,000 turns basis points into a fraction, and by 365 a yearly rate into a daily one. muldiv multiplies before it divides, holds the product exactly and rounds once.

extend it

  1. Add a FEE kind that takes a fixed 2.50 from the balance and is refused as overdrawn when the balance is smaller. Save a new golden only after you have checked the new output by hand.
  2. Add a test for a withdrawal of exactly the balance. Should it be accepted?
  3. Pay 365 days of interest on 1000.00 at 4.50 percent, compounding daily, and print the balance at the end. Then round each day with floor instead of half_even. How much less does the account earn in a year?

reference

error sets, catch, structs, muldiv, rounding modes, tests, fault record, golden

Next: 401-02, hunting an overflow.