soma.cell

Loan amortization — principal never goes negative, never grows back

Two amortization bugs with real victims: servicers who kept drafting payments after payoff (principal goes negative = the borrower is owed money and usually never told), and the pre-2008 "option ARM" negative amortization, where a payment smaller than the accrued interest silently GREW the principal until the loan ballooned past the house's value. Here `principal >= 0` is a memory invariant — the overdraft write raises — and a payment must at least cover the month's interest, so the balance is monotonically non-increasing. paid_off is terminal and proven.

A Soma program from the escrow_finance part of the verified corpus: 100 lines, cells Loan, LoanTests. It passes soma check with soma 3.2.4.

How it is built

The cell Loan declares 4 signals in its face: originate(id: String, principal: Int, rate_bps: Int) -> Map, monthly_interest(id: String) -> Int, make_payment(id: String, amount: Int) -> Map and balance(id: String) -> Int; keeps 2 memory slots: principal (Map<String, Int>, persistent and consistent) under the invariant principal >= 0 and rate (Map<String, Int>, persistent and consistent) under the invariant rate >= 0 && rate <= 2500. Its state machine loan starts in applied and allows 2 transitions: applied -> active and active -> paid_off. paid_off is terminal. Handlers: originate (line 38), monthly_interest (line 46), make_payment (line 52) and balance (line 71).

The file also holds 1 test cell (LoanTests) with 12 assertions, run by soma test.

Generated from soma describe on this file.

The source

loan_amortization.cell

// ============================================================================
// LOAN AMORTIZATION — principal never goes negative, never grows back
// ============================================================================
//
// Two amortization bugs with real victims: servicers who kept drafting
// payments after payoff (principal goes negative = the borrower is owed
// money and usually never told), and the pre-2008 "option ARM" negative
// amortization, where a payment smaller than the accrued interest silently
// GREW the principal until the loan ballooned past the house's value.
// Here `principal >= 0` is a memory invariant — the overdraft write raises
// — and a payment must at least cover the month's interest, so the balance
// is monotonically non-increasing. paid_off is terminal and proven.
// ============================================================================

cell Loan {
    face {
        signal originate(id: String, principal: Int, rate_bps: Int) -> Map
        signal monthly_interest(id: String) -> Int
        signal make_payment(id: String, amount: Int) -> Map
        signal balance(id: String) -> Int
    }

    memory {
        // the wall: principal cannot be overdrafted below zero
        principal: Map<String, Int> [persistent, consistent]
        invariant principal >= 0
        rate: Map<String, Int> [persistent, consistent]
        invariant rate >= 0 && rate <= 2500
    }

    // paid_off is terminal: a settled loan cannot resume billing.
    state loan {
        initial: applied
        applied -> active
        active -> paid_off
    }

    on originate(id: String, principal_amt: Int, rate_bps: Int) {
        if principal_amt <= 0 { return map("ok", false, "reason", "bad principal") }
        transition(id, "active")
        rate.set(id, rate_bps)          // raises if rate is usurious (> 25%)
        principal.set(id, principal_amt)
        return map("ok", true, "state", get_status(id))
    }

    on monthly_interest(id: String) {
        let p = principal.get(id) ?? 0
        let r = rate.get(id) ?? 0
        return div_round(p * r, 120000) // bps annual -> one month, rounded HALF_UP (BigDecimal style)
    }

    on make_payment(id: String, amount: Int) {
        if get_status(id) != "active" {
            return map("ok", false, "reason", "loan is not active")
        }
        let interest = monthly_interest(id)
        if amount < interest {
            // NEGATIVE AMORTIZATION refused: a payment below interest
            // would grow the principal — that loan shape does not exist
            return map("ok", false, "reason",
                "payment {amount} below interest {interest}: would grow principal")
        }
        let p = principal.get(id) ?? 0
        let toward_principal = amount - interest
        principal.set(id, p - toward_principal)   // raises on overpayment
        if p - toward_principal == 0 { transition(id, "paid_off") }
        return map("ok", true, "interest", interest,
                   "principal_reduced", toward_principal, "remaining", p - toward_principal)
    }

    on balance(id: String) {
        return principal.get(id) ?? 0
    }
}

cell test LoanTests {
    rules {
        // 120,000 at 6.00% (600 bps): first month's interest is 600
        assert originate("L1", 120000, 600).ok == true
        assert monthly_interest("L1") == 600
        // NEGATIVE AMORTIZATION: paying less than interest is refused
        assert make_payment("L1", 599).ok == false
        assert balance("L1") == 120000
        // a real payment: 600 interest + 19400 principal
        assert make_payment("L1", 20000).remaining == 100600
        // OVERDRAFT: paying past payoff would drive principal negative —
        // the ledger write itself raises (100600 + 503 interest < 999999)
        assert_fails make_payment("L1", 999999)
        assert balance("L1") == 100600
        assert_fails principal.set("L1", -1)
        // exact payoff: balance + this month's interest, to the cent
        assert make_payment("L1", 100600 + 503).remaining == 0
        // paid_off is terminal: the servicer cannot keep drafting
        assert make_payment("L1", 100).ok == false
        assert_fails transition("L1", "active")
        // usurious rates are rejected at origination by the rate invariant
        assert_fails originate("L2", 5000, 9900)
    }
}

What the toolchain says

Run on a fresh copy of the program at build time; the outputs are pasted unedited.

$ soma check loan_amortization.cell
warning: parameter 2 of `originate`: the face calls it `principal`, the handler `principal_amt` — an HTTP JSON body is matched by NAME (the handler's), and `soma describe` publishes the face's: use one name
  --> loan_amortization.cell:17:9
   |
17 |         signal originate(id: String, principal: Int, rate_bps: Int) …
   |         ^
✓ 1 warning(s), no errors.
$ soma test loan_amortization.cell
test LoanTests ...
  ✓ assert originate("L1", 120000, 600).ok == true
  ✓ assert monthly_interest("L1") == 600
  ✓ assert make_payment("L1", 599).ok == false
  ✓ assert balance("L1") == 120000
  ✓ assert make_payment("L1", 20000).remaining == 100600
  ✓ assert_fails make_payment("L1", 999999) — raised memory invariant violated on 'principal': principal >= 0 — rejected write of -898896 (key "L1"); the slot is unchanged
  ✓ assert balance("L1") == 100600
  ✓ assert_fails principal.set("L1", -1) — raised memory invariant violated on 'principal': principal >= 0 — rejected write of -1 (key "L1"); the slot is unchanged
  ✓ assert make_payment("L1", 100600 + 503).remaining == 0
  ✓ assert make_payment("L1", 100).ok == false
  ✓ assert_fails transition("L1", "active") — raised invalid transition: paid_off → active. Current state: 'paid_off'. Valid targets: []
  ✓ assert_fails originate("L2", 5000, 9900) — raised memory invariant violated on 'rate': rate >= 0 && rate <= 2500 — rejected write of 9900 (key "L2"); the slot is unchanged

12 tests: 12 passed, 0 failed
$ soma verify loan_amortization.cell
Verifying loan_amortization.cell...

State machine 'loan': 3 states, initial 'applied'
  States: [active, applied, paid_off]

  ✓ 3 states, 2 transitions
  ✓ all states reachable from 'applied'
  ✓ terminal states: [paid_off]
  ✓ no deadlocks
  ✓ liveness: every state can eventually reach a terminal state
  ✓ think-isolated: CTL safety properties hold regardless of LLM output (2 handlers, 2 literal transitions, 0 dynamic)
  ✓ termination: all 4 handlers structurally terminate
  ✓ refinement: handler `originate` ⟶ {active}
  ✓ refinement: handler `make_payment` ⟶ {paid_off [if p - toward_principal == 0]}
  · transition sources: `originate`, `make_payment` call transition(id, target) without declaring the source — the runtime refuses a move the graph forbids, but the proof does not see where the handler starts; `transition(id, from, to)` names the edge (checked by `soma check`, refused at run time from any other state)

Loan/invariants:

  ✓ invariant principal >= 0 — writer 'originate' proven by induction (writes principal_amt)
  ⚠ invariant principal >= 0 — runtime-checked (computed values): make_payment → principal because `toward_principal` = amount - interest — not bounded
  ⚠ invariant rate >= 0 && rate <= 2500 — runtime-checked (computed values): originate → rate because `rate_bps` is a parameter (narrow it: `require rate_bps >= 0 && rate_bps <= 2500 else …`)

10 passed, 2 warnings, 0 failures

  Temporal properties for 'loan':
  ✓ deadlock_free — no deadlocks in any reachable state
Temporal: 1 passed, 0 failed
VERIFY OK

Builtins used

div_round, get_status, map, transition

Source file: /corpus/escrow_finance/loan_amortization.cell · examples/corpus/escrow_finance/loan_amortization.cell