Every model received the identical one-line brief and ran the same 11-role pipeline — discovery, two sprints, security, a performance pass, reviewed PRs, to a live app. 7 of 8 shipped green; cost ran from $0.52 to $73.00 AUD — a 143× spread, and price didn't predict quality. Below: every app (thin-pipeline and native-agent build), full telemetry, and a code/design/review quality read.
Same brief, 8 designs. Click any to open it live. Badge = does it actually compute when you use it.
✓ works
✓ works
✓ works
✓ works
✓ works
✗ broken
✗ broken
✗ DNFThe thick-harness builds — each model's own coding agent (Claude Code / MiMoCode), run at max. Click to open live. DeepSeek & GLM were broken in the thin pipeline — they work here.
✓ works
✓ works
✓ works
✓ works
✓ worksEach model's app from my thin API pipeline vs its own native agent (Claude Code / MiMoCode, at max). Click a badge to open that app live.
| Model | Thin · API pipeline | Thick · native agent |
|---|---|---|
| GLM-5.1 | broken | works ↳ fixed |
| Kimi K2.6 | DNF | works ↳ fixed |
| DeepSeek V4-Pro | broken | works ↳ fixed |
| MiniMax M3 | works | works |
| MiMo (Max Mode) | works | works |
Config (how each was tuned) · cost + output tokens · and the quality scorecard from the run. *All costs in AUD (USD list prices × 1.42, Jun 2026). GLM is free on the Z.AI plan; $/M shown is list price. tok/s = output ÷ total wall-time (effective throughput, includes self-heal rounds).
| Model | Thinking | Max out | $/M in·out | Cost | Out tok | tok/s* | Heals | Bugs/false | Security | Perf | App |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Fable 5Anthropic | Adaptive (always-on) | 128K | $14.20/$71.00 | $73.00 | 260K | 117 | 1 | 1/4 | clean | 4.9x | works |
| GPT-5.5OpenAI | Reasoning: xhigh | 128K | $7.10/$42.60 | $40.24 | 859K | 108 | 4 | 3/2 | hardened | 105.9x | works |
| Gemini 3.1 ProGoogle | Reasoning: high | 64K | $2.84/$17.04 | $0.93 | 33K | 18 | 3 | 5/0 | hardened | 1.4x | works |
| GLM-5.1Z.AI · free on plan | Thinking: on | 128K | $1.99/$6.25 | $2.12 | 302K | 78 | 5 | 3/2 | clean | 127.3x | broken |
| DeepSeek V4-ProDeepSeek | Think-Max | 384K | $0.62/$1.24 | $5.50 | 1012K | 54 | 11 | 4/1 | clean | 61.5x | broken |
| Kimi K2.6Moonshot | Thinking: 64K budget | 256K | $1.35/$5.68 | $2.52 | ✗ DNF — thrashed in the thin pipeline (201m at PR-1, never converged); ships clean in its native agent (24/24) | DNF | |||||
| MiMo v2.5 ProXiaomi | Reasoning: high | 128K | $0.62/$1.24 | $0.52 | 340K | 211 | 2 | — | 1.6x | works | |
| MiniMax M3MiniMax | Adaptive | 512K | $0.43/$1.70 | $1.52 | 626K | 237 | 3 | — | 73.6x | works | |
Beyond price and speed — code, design and review quality, read straight from the artifacts (every model's engine, architecture doc and PR reviews). Short answer: a little — and not in proportion to price.
Rankings, read from the artifacts — every model's output graded on five axes by reading the actual code, designs, reviews, tests and requirements. No model wins more than two, and rank doesn't track price.
| Dimension | Best → | middle | → Worst | ||
|---|---|---|---|---|---|
| Code (the engine) | Fable | GPT | Gemini 93¢ | MiniMax · GLM · Kimi · MiMo | DeepSeek $5.50 |
| Design & architecture | Fable | GPT | GLM | MiMo · Gemini · DeepSeek | MiniMax |
| Review judgement | Gemini 93¢ | DeepSeek | GPT | GLM · MiMo | Fable $73 |
| Tests (QA + security) | GPT $40 | Fable | MiniMax | DeepSeek · MiMo · Gemini | GLM · Kimi |
| Requirements (BA) | Fable | MiniMax | Gemini · MiMo | DeepSeek · GLM | GPT $40 |
The sharpest finding: GPT ($40) wrote the best tests but the worst requirements — worker-thread timeout isolation + exact-dollar anchors, yet vaguer acceptance criteria than a 52¢ model. Money buys test rigour, not requirements clarity. And MiMo (52¢) never ranked last on any of the five axes.
The rankings as a procurement rule: default to cheap; pay up only where a wrong answer is genuinely expensive. The same call a bank makes on any control — match the spend to the risk.
Real correctness bugs the cross-check surfaced — all 8 nailed the core maths ($3,220.93/mo); these are edge cases:
Same brief, same harness — but each model has a character. What's distinctive about each.
Every role in the room, done by 8 models. Pick yours and see how each approached it.
The Architect designs the API & system.
You are the SOFTWARE ARCHITECT for a pure client-side static web app (no backend; all financial logic in one tested JS module loan.mjs — pure functions; a thin HTML/JS UI with sliders + an SVG chart). The engine must handle amortisation, extra repayments, an offset account, a lump sum, scenario comparison, a quote(loan, strategy) summary, and a recommendStrategy() that finds the smallest extra repayment hitting a target payoff. In ≤18 lines: the approach, the module API (export names + what each does), the data-model fields, a small ASCII box diagram (≤72 chars), the 2 key trade-offs and the single biggest technical risk. «BA requirements» «brief»
▸ OUTPUT · what each model produced:
**ARCHITECTURE — Home Loan Optimiser (≤18 lines)**
1. **Approach:** Every feature is one period-by-period simulation fold in `loan.mjs` — interest accrues each period on `max(balance − offset, 0)`, rounded to the cent; extra/offset/lump are just strategy inputs to that single loop. UI is a dumb shell: any input change re-calls the engine and redraws (no maths in UI).
2. **API (ESM, object args):**
3. `repayment(loan)` → per-period annuity payment (FR2 golden anchor, e.g. $2,997.75).
4. `simulate(loan, strategy)` → `{rows[], totalInterest, totalMonths, payoffDate}` — handles all features combined.
5. `quote(loan, strategy)` → `{repayment, totalInterest, totalMonths}` (thin wrapper over simulate).
6. `compare(loan, strategies[])` → per-strategy quote + `interestSaved`/`monthsSaved` vs baseline (drives FR7 cards).
7. `recommendStrategy(loan, targetMonths)` → linear scan of candidate extras (coarse $25 steps, refine $1) for the smallest hitting target — payoff is monotone in extra, so the scan is trivially correct; bisection is a later optimisation if ever needed.
8. **Model (plain JSON):** loan `{principal, annualRate, termYears, frequency}` · strategy `{extra=0, offset=0, offsetGrowth=0, lump:{amount, month}|null}`
9. row `{period, interest, principal, balance}` · all money rounded to cents at exactly one place (interest accrual) to prevent drift.
```
+----------------------+ quote/compare/ +----------------------+
| index.html + ui.js | recommendStrategy | loan.mjs (pure ESM) |
| sliders, SVG chart, |------------------->| simulate() core fold |
| cards — zero maths |<-------------------| quote/compare/rec |
+----------------------+ plain JS objects +----------------------+
```
**Trade-off 1:** Simulation over closed-form formulas — closed form can't express offset+lump+extra combined; ~360 iterations is far inside the 50 ms NFR, so we buy exactness for negligible perf cost.
**Trade-off 2:** Recompute-everything on input over incremental/memoised updates — stateless engine keeps UI trivially correct under FR6's <100 ms budget; we waste cycles but eliminate cache-invalidation bugs.
**Biggest risk:** Per-period rate convention for weekly/fortnightly (annualRate/52 vs /26 vs compounding-equivalent) plus the rounding policy — the one thing likely to break to-the-cent golden tests (FR2/FR3). Lock it with reference fixtures *before* any UI work.Approach: Static app: `loan.mjs` is the only finance engine (pure/tested); responsive WCAG UI maps forms/sliders to data, renders cards/SVG, and fires one analytics event after first valid compare.
Amortise period-by-period using canonical frequencies/dates; offset interest base is `max(balance-offset,0)`, and dated events apply at the first period date `>=` selected date.
API exports: `validateLoan(input)` -> canonical loan or errors.
`repaymentFor(loan)` -> scheduled repayment when amount omitted/term-driven.
`amortise(loan,strategy={})` -> rows applying recurring extras, offsets/changes, lump sums.
`quote(loan,strategy)` -> `{repayment,totalInterest,totalMonths}` summary.
`compareScenarios(loan,namedStrategies)` -> baseline vs each `{totalInterest,interestSaved,timeSaved,payoffDate}`.
`chartSeries(schedule)` -> `{balance[],cumInterest[],payoff[]}` for SVG.
`recommendStrategy(loan,targetMonths,candidates)` -> linear scan extra amounts, return smallest meeting target.
Loan model: `{balance,annualRate,termYears,repaymentFreq,repaymentAmount?,startDate}`.
Strategy model: `{extras:[{amount,freq,start,end}],offset:{balance,changes:[{date,balance}]},lumps:[{amount,date}]}`.
Result rows: `{date,opening,interest,scheduled,extra,lump,offset,principal,closing,cumInterest}`; result adds `payoffDate`.
`+-------------+ +------------------+ +--------------+`
`| HTML UI |--->| loan.mjs pure |--->| Cards + SVG |`
`| WCAG forms |<---| tests/fixtures | | analytics |`
`+-------------+ +------------------+ +--------------+`
Trade-offs: full recompute vs caching (simpler/testable, still <1s for 40y weekly x3); fixed amortisation rules vs bank-specific quirks (explainable but not every lender).
Biggest risk: ambiguous date/payment/interest ordering causing $/period mismatches—freeze rules in fixtures and surface assumptions in UI.**Approach:** A reactive architecture where vanilla JS UI listeners feed state to pure functions in `loan.mjs`, driving instant DOM and raw SVG updates to guarantee <200ms latency.
**Module API (`loan.mjs`):**
- `amortize(loan, strategy)`: Computes `[{month, balance, interest}]` array.
- `quote(loan, strategy)`: Returns aggregated `{repayment, totalInterest, totalMonths}`.
- `compare(base, opt)`: Returns delta `{interestSaved, monthsReduced}`.
- `recommendStrategy(loan, targetMonths)`: Binary searches smallest extra repayment.
**Data Models:**
- `Loan`: `{ principal, annualRate, termMonths }`
- `Strategy`: `{ extraRepayment, offsetBalance, lumpSum, lumpSumMonth }`
- `Result`: `{ repayment, totalInterest, totalMonths, schedule }`
**Architecture Diagram:**
`[Vanilla JS/HTML UI] --(Loan & Strategy)--> [loan.mjs Pure Math Engine]`
`[Raw SVG Chart & DOM] <---(Result Models)---| `
**Key Design Trade-offs:**
1. *Native SVG generation vs. Chart libraries:* Prioritizes zero-dependency bundle size and instant render performance (NFR1) over development convenience.
2. *Binary search vs. Algebraic formulas:* Iterative search in `recommendStrategy` handles offset/lump-sum edge cases cleanly, trading minor compute cycles for logic simplicity.
**Biggest Technical Risk:**
JavaScript IEEE 754 floating-point inaccuracies compounding over 360 monthly iterations; necessitates strict rounding to cents at every amortization step to ensure financial accuracy.**Approach:** Unidirectional UI binds slider inputs directly to pure functions in `loan.mjs`, instantly mapping returned state to an SVG path renderer to ensure frictionless, live-updating charts.
**Data Model:** `Loan`={balance, rate, termMonths}; `Strategy`={extraRepay, lumpSum, offset}; `Result`={repayment, totalInterest, totalMonths, schedule[]}.
**Module API (`loan.mjs`):**
* `quote(loan, strategy)`: Core engine computing the amortization schedule and returning the `Result` summary.
* `recommendStrategy(loan, targetMonths)`: Scans candidate extra repayments to find the smallest one hitting the target payoff.
* `compare(baseResult, optResult)`: Generates scenario comparison returning `{interestSaved, monthsSaved}`.
**Architecture:**
`+--[ UI (Sliders/HTML) ]--+ +--[ loan.mjs ]--+ +--[ SVG Chart ]--+`
`| input.oninput -> state | -> | quote(l, s) | -> | render(schedule)|`
`+-------------------------+ +----------------+ +-----------------+`
**Trade-offs:**
1. Recalculating the entire schedule on every slider tick (simpler pure stateless UI) over implementing complex partial-caching (better CPU usage).
2. Strict monthly compounding over complex exact-daily accrual (guarantees NFR1 <200ms response time, slightly sacrificing day-level precision).
**Biggest Technical Risk:** Cumulative floating-point precision errors in JS compounding over a 360-month schedule, causing penny-level drift from strict banking standards (AC2).Approach: Pure client-side app with `loan.mjs` pure functions; UI sliders + SVG chart; localStorage scenarios.
API: quote(loan,strat)->{repayment,totalInterest,months}; recommendStrategy(loan,target)->{extra,quote}; generateSchedule(loan,strat); compare(q1,q2); isValidLoan(l).
Models: Loan{principal,rate,term,freq}; Strategy{extra,offset,lumps[]}; Quote{repay,totalInt,months,sched}.
Diagram:
+-------------+ +-------------+
| UI |<--->| loan.mjs |
+-------------+ +-------------+
| localStorage (scenarios) |
+---------------------------------+
Trade-offs: 1) cent-integer rounding vs BigDecimal for <500ms chart updates and ±$0.01 precision. 2) constant offset & one lump sum vs full txn history to keep engine simple and testable.
Risk: floating-point drift causing non‑zero terminal balance; forced zero & cent‑integer arithmetic eliminate it.## `loan.mjs` — Architecture (≤18 lines)
**Approach:** Model the loan as a month-by-month balance array; each pure function applies one strategy modifier per iteration, yielding an immutable `Result` object. A thin UI wires sliders to recompute on `input` and repaints an SVG path via `requestAnimationFrame`.
**API:**
```js
// Core
amortise(loan) → Result // baseline schedule
applyExtra(loan, extra) → Result // +regular extra $/m
applyOffset(loan, balance) → Result // interest on (bal - offset)
applyLump(loan, month, amount) → Result// one-off at month N
combine(loan, ...modifiers) → Result // compose multiple strategies
// Utilities
quote(loan, strategy) → { repayment, totalInterest, totalMonths }
compare(baseline, optimized) → { interestSaved, monthsSaved, percentSaved }
recommendStrategy(loan, target) → Strategy // binary-scan smallest extra hitting targetMonth
// UI helpers (thin layer, not in engine)
renderChart(resultA, resultB) → SVGElement
bindInputs(formEl) → ReactiveLoan
```
**Data model:**
```ts
Loan = { principal, annualRate, termMonths }
Result = { schedule: {month, payment, interest, principal, balance}[],
totalInterest, totalMonths, repayment }
Strategy = { extra?: number, offset?: number, lump?: {month,amount} }
```
**Box diagram (≤72 chars):**
```
┌──────────────────────────────────────────────┐
│ HTML/JS UI (sliders, chart, compare view) │
│ bindInputs() ──► reactive Loan object │
├──────────────────────────────────────────────┤
│ loan.mjs (pure functions, zero DOM) │
│ quote() compare() recommendStrategy() │
│ amortise │ applyExtra │ applyOffset │ lump │
├──────────────────────────────────────────────┤
│ Storage: none — recomputed each interaction │
└──────────────────────────────────────────────┘
```
**2 Key trade-offs:**
1. **Full schedule array vs. running totals** — storing every month costs memory (up to 360 rows) but makes chart rendering O(1), comparison trivial, and debugging by inspection; recomputing totals-only would save bytes but break AC4.1's clickable data points.
2. **Binary-scan recommendStrategy vs. closed-form** — scanning extra-payment values with `quote()` is O(log N × term) which stays under 1s for any realistic range; an algebraic solver would be faster but wrong once offset/lump modifiers introduce non-linearity into the balance curve.
```
APPROACH Pure daily-interest engine in one ES module (loan.mjs); thin HTML/SVG UI debounces input and encodes full state in URL hash, with localStorage for named scenarios.
API (loan.mjs, all pure)
amortise(loan, strategy) -> schedule[] // month rows
quote(loan, strategy) -> {repayment,totalInterest,totalMonths} // summary for tiles/URL
recommendStrategy(loan, target) -> {extraMonthly,lumpSum,monthsSaved} // $1-step linear scan: smallest extra hitting targetMonths (simplest correct, exploits monotonicity)
compareScenarios(loan, strats[]) -> quote[] // drives 2-3 side-by-side tiles
applyOffset(principal, offset) -> effectivePrincipal // test seam
DATA
loan : {principal, ratePct, termYears, freq:'W'|'F'|'M', startDate}
strategy : {extraMonthly=0, offsetBalance=0, lumpSum=0, lumpMonth=0} // 0 = feature off
row : {month,date,opening,interest,repayment,principal,closing}
result : {repayment,totalInterest,totalMonths} // quote() shape
DIAGRAM
[UI+URL#s=] -50ms-> [loan.mjs] -schedule/quote-> [SVG x3 + tiles]
^ |
+------------ localStorage scenarios <---------------+
TRADE-OFFS T1 Daily correctness vs cost: precompute a day-indexed effective-principal array (P - offset - extras-so-far), then aggregate into months — keeps amortise() O(months) yet cent-exact.
T2 URL-shareable state vs payload bloat: JSON in #s= base64 + CompressionStream at save time — zero-backend portability in exchange for a one-time encode cost.
RISK Off-by-one-cent drift in daily offset/lump-sum application breaks the "matches CBA/ANZ/NAB/Westpac to the cent" AC. Mitigation: golden-test fixtures against published tables plus property tests (closing balance strictly non-increasing; Σrow.interest == result.totalInterest).
```The PO frames the vision & success metric.
You are the PRODUCT OWNER. In ≤4 lines total: (1) a one-sentence product vision and the primary user; (2) ONE measurable success metric; (3) the top 3 priorities (what matters most). Product: «the one-line brief»
▸ OUTPUT · what each model produced:
1) **Vision:** A live, interactive Home Loan Optimiser that shows everyday borrowers — primary user: an Australian homeowner with a variable-rate mortgage — exactly how extra repayments, an offset account, or a lump sum cut total interest and years off their loan, with charts that update as they type. 2) **Success metric:** ≥80% of first-time users reach an "insight moment" (view a strategy comparison showing interest saved + time cut) within 60 seconds of entering their loan details. 3) **Priorities:** (a) Correct amortisation maths — results must match bank calculators to the cent; (b) Instant what-if interactivity — sliders/inputs re-render charts live with zero page reloads; (c) Clear side-by-side strategy comparison (baseline vs extra repayments vs offset vs lump sum) showing $ interest saved and payoff date.
Vision: Help mortgage holders optimise repayments by instantly comparing extra repayments, offset accounts and lump sums to save interest and repay sooner. Success metric: ≥60% of users complete at least one scenario comparison in their first session. Top 3 priorities: accurate loan calculations; simple scenario inputs; clear live charts showing interest saved and payoff date.
**Vision:** To empower everyday homeowners to achieve financial freedom faster by instantly visualizing how strategic payments slash their loan lifespan and interest. **Success Metric:** Achieve a 45% completion rate of users entering their baseline loan details and actively manipulating the live scenario charts. **Top 3 Priorities:** 1) Flawless, instantaneous financial calculation engine; 2) Engaging, real-time interactive data visualization; 3) Frictionless UX that makes entering complex financial details effortless.
1) **Vision & User:** Empower existing homeowners to rapidly eliminate mortgage debt by visualizing the real-time financial impact of extra repayments, lump sums, and offsets. 2) **Success Metric:** The percentage of users who input their baseline mortgage data and generate at least one customized, interactive savings projection. 3) **Top 3 Priorities:** 1) An accurate, lightning-fast calculation engine; 2) highly responsive, intuitive live charts; 3) a frictionless, simple UI for data entry.
Empower homeowners to cut mortgage interest and time via live simulations of extra repayments, offset accounts, and lump sums. Success metric: monthly active users completing at least one full what-if scenario. Top priorities: 1) calculation accuracy & trust, 2) real-time chart interactivity, 3) flexible what-if modelling.
**Vision:** Empower homeowners to reduce debt faster by simulating repayment strategies, targeting mortgage holders. **Success Metric:** Monthly interactions (simulations run & charts viewed). **Top 3 Priorities:** 1. Core repayment scenarios (extra payments, offset, lump sum). 2. Live, intuitive charts showing interest saved and time reduction. 3. Mobile-friendly interface for quick calculations.
**1) Vision & User:** Empower Australian homeowners and first-home buyers to make their mortgage disappear faster by instantly modelling how extra repayments, offset accounts, and lump sums reshape their loan — all through live, interactive charts. **2) Success Metric:** 40% of visitors who enter a loan scenario share, save, or return to compare ≥2 strategies within 7 days (proves real decision-making value, not just curiosity clicks). **3) Top Priorities:** • **Accurate math engine first** — 3 repayment strategies (extra, offset, lump sum) with month-by-month recalculation matching major bank formulas; trust dies instantly if the numbers are wrong. • **Live, responsive charts** — interest saved, time shaved off the loan, and balance curve all update in <200ms as the user types; this is the "aha" moment and the product's reason to exist. • **Scenario comparison view** — side-by-side comparison of 2–3 strategies so users can confidently choose the best path before talking to their lender. *Deferred: bank API sync, export-to-PDF, mobile native app — all nice, none are the wedge.*
The BA writes the requirements & acceptance criteria.
You are the BUSINESS ANALYST. The PO has defined the product vision «PO output». Write structured requirements in ≤12 lines: (1) functional requirements — what the system must DO; (2) testable, measurable acceptance criteria for each; (3) non-functional requirements. Product: «brief»
▸ OUTPUT · what each model produced:
**REQUIREMENTS — Home Loan Optimiser (BA, v1)** 1. **FR1 Loan input:** Capture principal, annual rate, term (years), repayment frequency (weekly/fortnightly/monthly). *AC:* invalid/out-of-range values show inline errors; charts never render NaN. 2. **FR2 Amortisation engine:** Compute repayment, per-period interest/principal split, total interest, payoff date. *AC:* matches reference values to the cent (e.g. $500,000 @ 6.00% p.a., 30yr monthly → $2,997.75/mo) via golden tests for ≥5 known scenarios. 3. **FR3 Extra repayments:** User sets recurring extra amount. *AC:* schedule recalcs; interest saved ($) and time cut (yrs+months) shown, exact to the cent vs baseline. 4. **FR4 Offset account:** User sets offset balance (and optional monthly growth). *AC:* interest accrues on (principal − offset) each period, never negative; savings reported vs baseline. 5. **FR5 Lump sum:** User sets amount + month applied. *AC:* balance drops at that month; remaining schedule recalcs; payoff date moves correctly. 6. **FR6 Live interactivity:** Sliders/inputs re-render all charts and figures on change. *AC:* update <100 ms, zero page reloads, no flicker. 7. **FR7 Strategy comparison:** Side-by-side view of baseline vs FR3/FR4/FR5 (combinable). *AC:* each card shows $ interest saved + new payoff date; reachable within 2 interactions of entering loan details (supports 60-sec insight metric). 8. **NFR:** Single-page client-side app, no backend or external calls; all maths in one pure engine module (UI must call it, never reimplement); recompute full 30yr schedule <50 ms; works offline in modern browsers; WCAG AA contrast. Handing to ARCHITECT: the pure-engine/UI split in NFR is the load-bearing constraint — design around it.
1. **FR1 Loan setup:** User can enter loan balance, interest rate, term, repayment frequency/amount and start date; **AC:** valid inputs generate a baseline showing repayment amount, total interest and payoff date. 2. **FR2 Extra repayment scenario:** User can add recurring extra repayments with amount, frequency and start/end dates; **AC:** system shows interest saved and payoff-date reduction versus baseline. 3. **FR3 Offset account scenario:** User can enter offset balance and optional balance changes; **AC:** interest is calculated on loan balance minus offset balance, never below zero. 4. **FR4 Lump sum scenario:** User can add a one-off repayment amount and date; **AC:** system applies it on the selected date and recalculates interest saved and payoff date. 5. **FR5 Scenario comparison:** User can compare baseline against extra repayment, offset and lump sum scenarios; **AC:** comparison displays total interest, interest saved, time saved and final payoff date for each. 6. **FR6 Live charts:** System displays charts for loan balance, cumulative interest and payoff timeline; **AC:** charts update automatically after input changes within 1 second. 7. **FR7 Completion tracking:** System records when a user completes at least one valid scenario comparison; **AC:** analytics can report first-session comparison completion rate. 8. **NFR Accuracy:** Calculations must match defined amortisation rules within $1 and 1 repayment period for standard test cases. 9. **NFR Usability:** Scenario inputs must be simple enough to complete a comparison in under 2 minutes for first-time users. 10. **NFR Performance:** Results and charts must update in ≤1 second for loans up to 40 years with at least 3 scenarios. 11. **NFR Accessibility/Responsive:** Interface must work on mobile and desktop and meet WCAG 2.1 AA for key form and chart interactions.
**Functional Requirements & Acceptance Criteria:** **FR1 Baseline Entry:** The system must capture initial mortgage details (principal, interest rate, term) via a frictionless UI. *AC1: Users can complete baseline data entry in ≤3 steps, with inputs auto-formatting as currency/percentages instantly.* **FR2 Scenario Modeler:** The system must allow users to apply extra regular repayments, offset account balances, and lump sums. *AC2: Adjusting any scenario slider or input field successfully calculates the new loan lifespan and total interest.* **FR3 Live Visualization:** The system must render interactive charts comparing the baseline loan trajectory versus the optimized scenario. *AC3: Charts dynamically update to explicitly display "Total Interest Saved ($)" and "Years Reduced" upon any variable change.* **Non-Functional Requirements:** **NFR1 Performance:** The financial calculation engine and chart rendering must execute updates in <200ms to feel instantaneous (Priority 1). **NFR2 Usability:** The interface must be mobile-responsive and require zero page reloads to minimize friction (Priority 3). **NFR3 Analytics:** The system must track "baseline complete" and "chart manipulated" events to measure the 45% completion success metric.
**1) Functional Requirements:** * FR1: System must capture baseline mortgage data (balance, rate, term) and optimization variables (extra repayments, lump sums, offset balance). * FR2: System shall calculate and output a comparison of baseline vs. optimized amortization schedules (total interest saved, time eliminated). * FR3: System must generate interactive, live-updating charts visualizing the principal vs. interest breakdown and loan term reduction. **2) Acceptance Criteria:** * AC1 (Data Entry): Users can input all baseline data and generate an initial customized projection in ≤ 3 primary actions/clicks. * AC2 (Accuracy): Calculated outputs must match standard financial amortization math exactly (to the dollar and day). * AC3 (Live Charts): Visual charts must automatically reflect any input adjustments without requiring a manual "refresh" button. **3) Non-Functional Requirements:** * NFR1 (Performance): The calculation engine must process and render projection updates in under 200 milliseconds. * NFR2 (Usability): The data entry interface must be frictionless, using intuitive sliders/fields that allow completion in under 60 seconds.
Functional Requirements: 1) Loan input: principal, rate, term, frequency. 2) Extra repayment: monthly extra. 3) Offset account: balance. 4) Lump sum: amount & date. 5) Live amortisation chart. 6) Scenario manager: save, name, compare. Acceptance Criteria: 1) Input: valid 1k-10M, 0.1-30%, 1-40yr; inline errors & defaults. 2) Extra: interest saved ±$0.01; chart updates <1s. 3) Offset: net balance interest; savings ±$1; chart reflects offset. 4) Lump sum: principal reduces on date; recalc matches manual. 5) Chart: <500ms update on change; hover tooltip. 6) Scenarios: persist across sessions; side-by-side delta view. Non-Functional: Performance: chart <500ms, load <3s. Accuracy: cent‑level vs standard amortisation. Reliability: 99.9% uptime. Usability: >80% first‑session scenario completion.
**Home Loan Optimiser – Structured Requirements** **Functional Requirements (FR):** 1. **FR1:** Input mortgage details (principal, rate, term). 2. **FR2:** Simulate extra regular payments, offset account balance, and lump sum scenarios. 3. **FR3:** Calculate and display original vs. optimized total interest, time saved, and total savings. 4. **FR4:** Generate live, interactive charts (e.g., amortization curve, interest comparison). 5. **FR5:** Interface must be responsive for mobile devices. **Acceptance Criteria (AC):** 1. **AC1.1:** System accepts principal ($100k–$2M), rate (1–15%), and term (1–30 years). 2. **AC2.1:** Extra payment input alters repayment schedule correctly; savings increase. 3. **AC2.2:** Offset balance reduces calculated interest; chart updates instantly. 4. **AC3.1:** Output shows >0% interest reduction for any valid extra payment scenario. 5. **AC4.1:** Charts reflect current inputs without page reload; data points are clickable/tappable. 6. **AC5.1:** Layout adapts without horizontal scroll on screens <768px wide. **Non-Functional Requirements (NFR):** 1. **NFR1:** Calculation engine returns results within 1 second. 2. **NFR2:** WCAG 2.1 AA compliant for core inputs/outputs. 3. **NFR3:** Data remains local to the device; no external storage required.
**Requirements — Home Loan Optimiser** 1. **Loan Input Capture** — Principal ($), interest rate (% p.a.), remaining term, repayment frequency (W/F/M), start date. *AC: Validates positive numerics within realistic ranges (rate 0–25%, term 1–40yr); state URL-encoded so any scenario is shareable.* 2. **Strategy Configuration** — User enables/configures independently: (a) extra recurring repayment, (b) offset account balance, (c) one-off lump sum at month N. *AC: Baseline (no strategy) always rendered; savings view requires ≥1 strategy enabled; all values editable after run.* 3. **Amortisation Engine** — Daily-interest, reducing-balance calc; offset reduces effective principal each day; lump sum applied at chosen month; extras reduce principal immediately. *AC: Output matches published CBA/ANZ/NAB/Westpac amortisation tables to the cent on reference cases; full month-by-month schedule generated.* 4. **Live Recalculation** — Full schedule recompute + chart redraw on every input keystroke. *AC: p95 latency <200ms on mid-range laptop; calc runs off main thread for terms >30yr; no input lag, no jank.* 5. **Visualisations** — Three live charts: (i) balance curve (baseline vs strategy overlay), (ii) cumulative interest saved, (iii) time shaved off loan. *AC: Re-render synchronously with input; ARIA labels + keyboard-accessible tooltips; colour-blind safe palette; show exact $ and months.* 6. **Scenario Compare** — Save named scenarios; load 2–3 side-by-side in comparison view. *AC: Each tile displays interest paid, total term, monthly repayment, interest saved, time saved; persisted in localStorage; deletable.* 7. **Share / Save / Return** — Copy shareable URL, email snapshot, or save to local library; returning visitors see prior scenarios. *AC: Share event fires analytics; URL round-trips full state; 7-day re-engagement supported to satisfy success metric.* 8. **NFR – Performance:** First contentful paint <2s on 4G; JS bundle <300KB gz; charts via SVG/Canvas. 9. **NFR – Trust:** In-app "How we calculate" panel discloses formula, rate assumptions, RBA source; no hidden rounding. 10. **NFR – Privacy:** Loan data stays client-side until user explicitly saves/shares; no third-party trackers pre-consent. 11. **NFR – Compatibility:** Latest 2 versions of Chrome, Safari, Edge, Firefox; responsive 360px–1920px. 12. **NFR – Accessibility:** WCAG
The UX sets the visual direction.
You are the UX/UI DESIGNER for a Home Loan Optimiser. In ≤10 lines define the visual direction: a named style, the colour palette (hex), a type pairing, the layout (hero figure, control panel, balance chart), and the 3 signature components. Dark, modern, high-contrast fintech with confident hierarchy. Product: «brief»
▸ OUTPUT · what each model produced:
**Style: "Midnight Ledger"** — dark luxury fintech, editorial hierarchy, glassmorphic depth on a near-black canvas.
**Palette:** `#0A0E1A` canvas, `#131A2E` raised surfaces, `#F4F6FB` primary text, `#8B93A7` muted; accent `#3DDC97` (savings/gain), `#5B8CFF` (baseline/interactive), `#FF6B6B` (interest/cost). Colour is semantic only — green is always money saved.
**Type:** Space Grotesk (display/numerals, tabular figures for dollar amounts) + Inter (UI/body).
**Layout:** Full-bleed hero figure — the headline *is* the number ("$184,302 saved · 6.4 yrs sooner") at `clamp(3rem,7vw,8rem)`, live-updating. Below, asymmetric split: left third is a sticky control panel (loan inputs, extra-repayment slider, offset toggle, lump-sum field), right two-thirds the balance chart — baseline vs optimised curves with shaded interest-saved delta, crossover annotated.
**Signature components:** (1) **Delta Hero** — the giant animated savings counter that reflows on every input; (2) **Strategy Cards** — stacked glass toggles (Extra / Offset / Lump-sum), each wearing its own live mini-stat; (3) **Payoff Timeline** — horizontal year-rail with draggable lump-sum marker and a "loan-free" flag that slides left as you optimise.1. **Style:** “Midnight Equity” — dark, modern, high-contrast fintech with confident hierarchy and crisp data storytelling. 2. **Palette:** Ink #070B12, Charcoal #111827, Electric Cyan #22D3EE, Profit Green #22C55E, Alert Amber #F59E0B, Soft Slate #94A3B8, White #F8FAFC. 3. **Type pairing:** Space Grotesk for bold figures/headlines + Inter for controls, labels, and explanatory text. 4. **Layout:** Top hero figure showing “Interest saved / Years cut” → left control panel for mortgage, offset, extra repayments, lump sum → right live balance chart. 5. **Signature component 1:** Scenario Toggle Cards — Base Loan, Extra Repayments, Offset Account, Lump Sum. 6. **Signature component 2:** Savings Impact Hero — oversized animated dollar saving, payoff date, and time shaved off. 7. **Signature component 3:** Dual-Line Balance Chart — original loan vs optimised loan with payoff milestone markers.
**Style:** "Luminous Fintech Noir" — a dark, high-contrast aesthetic with a confident, data-driven hierarchy.
**Palette:** Deep Void (#0B0D14), Surface (#151A24), Primary Text (#FFFFFF), Profit Green (#00E599), and Offset Purple (#8B5CF6).
**Typography:** *Space Grotesk* (bold, geometric hero numbers) paired with *Inter* (crisp, highly legible UI and chart data).
**Layout:** An asymmetrical split-screen interface maximizing real estate for live, reactive financial modeling.
**Control Panel:** A sticky left-hand sidebar housing sleek inputs, toggles, and sliders for seamless scenario testing.
**Hero Figure:** Top right, a massive, glowing metric ("Total Interest Saved") instantly anchoring the user's ultimate goal.
**Balance Chart:** Directly beneath the hero, a prominent, dynamic chart comparing the base loan against the optimized timeline.
**Signature Component 1:** Haptic-style dark sliders with vivid #00E599 neon track fills for dialing in extra monthly repayments.
**Signature Component 2:** A dual-layered, gradient Area Chart with glowing trajectory lines that animate instantly upon any input change.
**Signature Component 3:** Luminous "Milestone Tags" — interactive floating tooltips on the chart pinpointing exactly when the loan is cleared.**Style:** "Midnight Ledger" — a dark, high-contrast fintech aesthetic emphasizing confident hierarchy and financial clarity. **Palette:** Base `#0B0F19` (Void), Surface `#161B26`, Text `#F8FAFC` (White), Accent `#00F0A0` (Neon Mint), Alert `#FF4757` (Debt). **Typography:** *Space Grotesk* (headings/hero data) paired with *Inter* (body/UI) for a highly legible, modern geometric look. **Layout:** A responsive 2-column dashboard: a left "Control Panel" input sidebar, and a right Main View housing the "Hero Figure" and "Balance Chart". **Hero Figure:** A massive, dynamically calculating "Total Interest Saved" metric pinned at the top right, glowing neon mint upon value changes. **Control Panel:** A sleek, dark-matte card with segmented toggles (Extra Repayments, Offset, Lump Sum) and responsive, futuristic sliders. **Balance Chart:** An interactive, gradient-filled area chart contrasting the slow original amortization curve with the fast optimized burn-down schedule. **Component 1:** "Glowing Slider Knobs" — custom sliders with haptic-feel UI that emit a subtle neon mint shadow as users drag them. **Component 2:** "Timeline Milestones" — interactive pin-drops on the chart's X-axis highlighting the exact month/year the loan is paid off. **Component 3:** "Savings Summary Pill" — a floating, sticky bottom bar displaying live net savings, updated loan term, and CTA to "Lock in Strategy".
Visual direction: **Midnight Ledger** — dark, high-contrast fintech with a sharp numerical focus. **Colour palette:** `#0B0E14` (bg), `#151A24` (surface), `#00E5A1` (primary/mint), `#FF5C5C` (alert), `#FFD166` (highlight), `#F1F5F9` / `#8B9DC4` (text). **Type pairing:** *Space Grotesk* (headings) + *Inter* (UI) + *Fira Code* (data/tables). **Layout:** Split dashboard — left control panel (sliders, inputs); right hero figure (3D house with dynamic “savings halo”) above a live balance comparison chart. **3 signature components:** 1. **Scenario Slider Stack** — tactile extra repayment, offset & lump-sum sliders with real‑time calculations. 2. **Dual Balance River Chart** — animated filled area showing original vs. optimised balance over time. 3. **Break-Even Countdown Card** — radial progress ring spotlighting months saved and total interest avoided.
**Style:** Nexus Dark **Palette:** Deep Navy (#0A0F1C), Electric Blue (#4D8EF7), Clean White (#FFFFFF) **Type:** Inter (UI), Space Grotesk (Numerical) **Layout:** Hero figure (key savings metric), left control panel (input fields), right balance chart (live visualization) **Signature Components:** 1. Interactive Amortization Slider 2. Balance & Interest Toggle Chart 3. Savings Highlight Callout
**Style name:** **"Vault"** — a dark, monolithic fintech surface where one luminous accent carries the entire interface. **1. Palette (hex):** bg `#060914` · surface `#0F1424` · surface-2 `#161D33` · border `#1F2740` · primary cyan `#00D4FF` · success green `#00E58A` · interest coral `#FF5470` · text `#F4F6FB` / muted `#8A93A6`. **2. Type pairing:** **Sora** (display, 600/700, tight tracking, 72–96px for hero numbers) + **Inter** (UI/body, 400/500, tabular-nums for every figure, 14–15px base). **3. Layout — 3-zone split:** left **Hero Figure** (oversized $ saved + "X years sooner" stat), center **Control Panel** (mortgage inputs, strategy toggles, sliders), right **Balance Chart** (twin-curve loan balance over time, 12-col grid, 8px spacing scale). **4. Signature ① — Savings Halo:** the hero saved-money figure wrapped in a soft cyan→green radial glow, ticking up live (300ms ease-out) as you adjust controls. **5. Signature ② — Strategy Chips:** three pill toggles — *Extra Repayments %* · *Offset $* · *Lump Sum $* — with 1.5px-stroke mono icons; active state fills with a success-green border + inner glow and pins a mini-delta above the chart. **6. Signature ③ — Twin-Curve Balance Chart:** baseline loan balance in muted `#8A93A6` area, optimised in `#00D4FF` stroke, with a glowing `#00E58A` delta band at 15% opacity between them; hover crosshair reveals interest delta + months shaved. **Tone:** confident, data-led, calm — hairline borders, no skeuomorphism, generous negative space, one high-contrast accent doing all the heavy lifting.
The Engineer implements the engine.
ARCHITECT'S DESIGN: «design». REQUIREMENTS: «requirements». It MUST pass these tests: «the acceptance test suite». Respond with ONLY the complete loan.mjs as one js code block — every function the tests import must be exported. (Then a second pass: build the UI to the designer's direction — a single self-contained index.html driven entirely by the engine it imports; no external libraries, fonts or CDN; must render fully offline.)
▸ OUTPUT · what each model produced:
// loan.mjs — pure amortisation engine for the Home Loan Optimiser.
// Convention locked by golden tests (FR2/FR3): per-period rate = annualRate/100/periodsPerYear
// (simple division, no compounding conversion), and money is rounded to cents at exactly
// one place — interest accrual — so cent-level drift cannot accumulate. The scheduled
// payment is rounded UP to the next cent so it always retires the loan within the
// contracted term (rounding down leaves a residual that adds a phantom extra period).
export const FREQUENCIES = Object.freeze({ monthly: 12, fortnightly: 26, weekly: 52 });
// validation ceiling: longest real-world term with comfortable headroom; anything past
// this is treated as a bad input rather than a multi-billion-iteration simulation
const MAX_YEARS = 100;
const MAX_MONTHS = MAX_YEARS * 12;
const round2 = (n) => Math.round(n * 100) / 100;
// epsilon absorbs float noise (e.g. 1798.65 * 100 === 179865.00000000003) so exact-cent
// values are not bumped a cent higher; noise is ~1e-8 at loan magnitudes, well under 1e-6
const ceil2 = (n) => Math.ceil(n * 100 - 1e-6) / 100;
const isFiniteNumber = (n) => typeof n === 'number' && Number.isFinite(n);
function validateLoan(loan) {
if (loan === null || typeof loan !== 'object') throw new TypeError('loan must be an object');
const { principal, annualRate, years, frequency } = loan;
if (!isFiniteNumber(principal) || principal <= 0) {
throw new RangeError(`loan.principal must be a positive number, got ${principal}`);
}
if (!isFiniteNumber(annualRate) || annualRate < 0) {
throw new RangeError(`loan.annualRate must be a non-negative percent, got ${annualRate}`);
}
if (!isFiniteNumber(years) || years <= 0 || years > MAX_YEARS) {
throw new RangeError(`loan.years must be a positive number of at most ${MAX_YEARS}, got ${years}`);
}
if (!Object.hasOwn(FREQUENCIES, frequency)) {
throw new RangeError(
`loan.frequency must be one of ${Object.keys(FREQUENCIES).join(', ')}, got ${frequency}`
);
}
// years like 0.01 round to zero periods, which would divide by zero in the annuity
if (Math.round(years * FREQUENCIES[frequency]) < 1) {
throw new RangeError(
`loan.years is too small: ${years} years at ${frequency} frequency yields no repayment periods`
);
}
return loan;
}
function normaliseStrategy(strategy = {}) {
if (strategy === null ||export const repaymentFrequencies = Object.freeze({
monthly: 12,
fortnightly: 26,
weekly: 52,
});
export const repaymentFrequencyMap = repaymentFrequencies;
export const REPAYMENT_FREQUENCIES = repaymentFrequencies;
export const REPAYMENT_FREQUENCY_MAP = repaymentFrequencies;
export const REPAYMENT_FREQS = repaymentFrequencies;
export const frequencyMap = repaymentFrequencies;
export const FREQUENCY_MAP = repaymentFrequencies;
export const FREQUENCIES = repaymentFrequencies;
export const PAYMENTS_PER_YEAR = repaymentFrequencies;
export const paymentsPerYear = repaymentFrequencies;
const DAY_MS = 24 * 60 * 60 * 1000;
const FINAL_PAYMENT_TOLERANCE = 1;
const DEFAULT_START_DATE = '2024-01-01';
const MAX_SAFE_CENT_AMOUNT = Number.MAX_SAFE_INTEGER / 100;
const MAX_LOAN_BALANCE = 10_000_000_000_000;
const MAX_PAYMENT_AMOUNT = 20_000_000_000;
const MAX_STRATEGY_MONEY = MAX_PAYMENT_AMOUNT;
const MAX_ANNUAL_RATE = 1000;
const MAX_TERM_YEARS = 100;
const MAX_TERM_PERIODS = MAX_TERM_YEARS * repaymentFrequencies.weekly;
const MAX_INTEREST_ONLY_MONTHS = MAX_TERM_YEARS * 12;
const MAX_AMORTISATION_PERIODS = 10000;
const frequencyAliases = Object.freeze({
12: 'monthly',
m: 'monthly',
month: 'monthly',
months: 'monthly',
monthly: 'monthly',
26: 'fortnightly',
f: 'fortnightly',
fortnight: 'fortnightly',
fortnights: 'fortnightly',
fortnightly: 'fortnightly',
biweekly: 'fortnightly',
'bi-weekly': 'fortnightly',
52: 'weekly',
w: 'weekly',
week: 'weekly',
weeks: 'weekly',
weekly: 'weekly',
});
function hasOwn(object, key) {
return Object.prototype.hasOwnProperty.call(object, key);
}
function firstDefined(object, keys) {
if (!object) return undefined;
for (const key of keys) {
if (object[key] !== undefined && object[key] !== null) return object[key];
}
return undefined;
}
function numberValue(value) {
if (typeof value === 'string') {
const cleaned = value.trim().replace(/[$,\s]/g, '').replace(/%$/, '');
if (cleaned === '') return NaN;
return Number(cleaned);
}
try {
return Number(value);
} catch {
return NaN;
}
}
function roundCents(value) {
let number;
try {
number = Number(value);
} catch {
return NaN;
}
if (!Number.isFinite(number)) return NaN;
if (Math.abs(number) > MAX_SAFE_CENT_AMOUNT) return NaN;
const adjusted = number + (number >= 0 ? 1e-9 : -1e-9);
export const FREQUENCIES = {
monthly: 12,
fortnightly: 26,
weekly: 52,
MONTHLY: 12,
FORTNIGHTLY: 26,
WEEKLY: 52
};
export function validate(loan) {
if (!loan || typeof loan.principal !== 'number' || !(loan.principal > 0) || !Number.isFinite(loan.principal)) {
throw new Error('Invalid principal: must be > 0');
}
const annualRate = loan.annualRate !== undefined ? loan.annualRate : (loan.rate !== undefined ? loan.rate * 100 : undefined);
if (typeof annualRate !== 'number' || !(annualRate >= 0) || !Number.isFinite(annualRate)) {
throw new Error('Invalid rate: negative');
}
if (loan.frequency !== undefined) {
if (typeof loan.frequency === 'string') {
const lowerFreq = loan.frequency.toLowerCase();
if (!FREQUENCIES[lowerFreq] && !FREQUENCIES[loan.frequency]) {
throw new Error('Invalid frequency: unknown');
}
} else if (typeof loan.frequency !== 'number' || loan.frequency <= 0) {
throw new Error('Invalid frequency');
}
}
const termMonths = loan.termMonths !== undefined ? loan.termMonths : (loan.years !== undefined ? loan.years * 12 : loan.periods);
if (typeof termMonths !== 'number' || !(termMonths > 0) || !Number.isFinite(termMonths) || termMonths > 12000) {
throw new Error('Invalid loan term');
}
}
export function amortize(loan, strategy = {}) {
validate(loan);
let periodsPerYear = 12;
if (loan.frequency !== undefined) {
if (typeof loan.frequency === 'number') {
periodsPerYear = loan.frequency;
} else if (typeof loan.frequency === 'string') {
periodsPerYear = FREQUENCIES[loan.frequency.toLowerCase()] || FREQUENCIES[loan.frequency] || 12;
}
}
const annualRate = loan.annualRate !== undefined ? loan.annualRate : (loan.rate !== undefined ? loan.rate * 100 : 0);
const r = (annualRate / 100) / periodsPerYear;
const termMonths = loan.termMonths !== undefined ? loan.termMonths : (loan.years !== undefined ? loan.years * 12 : loan.periods);
const n = Math.round((termMonths / 12) * periodsPerYear);
let ioPeriods = 0;
if (strategy && strategy.interestOnlyMonths !== undefined) {
ioPeriods = Math.round(strategy.interestOnlyMonths * periodsPerYear / 12);
} else if (loan.interestOnlyPeriods !== undefined) {
ioPeriods = loan.interestOnlyPeriods;
}
if (!Number.isFinite(ioPeriods) || ioPeriods < 0) throw new Error(export const FREQUENCY_MAP = {
monthly: 12,
fortnightly: 26,
weekly: 52,
};
const toCents = (n) => Math.round(n * 100) / 100;
function normalizeLoan(loan) {
const result = { ...loan };
if (result.annualRate === undefined && result.rate !== undefined) {
result.annualRate = result.rate * 100;
}
if (result.years === undefined && result.term !== undefined) {
result.years = result.term / 12;
}
if (!result.frequency) {
result.frequency = 'monthly';
}
return result;
}
function validateLoan(loan) {
if (!loan || typeof loan !== 'object') {
throw new Error('Invalid loan');
}
const { principal, annualRate, years, frequency } = loan;
if (typeof principal !== 'number' || !Number.isFinite(principal) || principal <= 0) {
throw new Error('Invalid principal');
}
if (typeof annualRate !== 'number' || !Number.isFinite(annualRate) || annualRate < 0) {
throw new Error('Invalid annualRate');
}
if (typeof years !== 'number' || !Number.isFinite(years) || years <= 0) {
throw new Error('Invalid years');
}
if (frequency !== undefined && !FREQUENCY_MAP[frequency]) {
throw new Error('Invalid frequency');
}
}
function computeScheduledRepayment(principal, periodicRate, totalPeriods) {
if (totalPeriods <= 0) return principal;
if (periodicRate > 0) {
const factor = Math.pow(1 + periodicRate, totalPeriods);
return (principal * periodicRate * factor) / (factor - 1);
}
return principal / totalPeriods;
}
export function quote(rawLoan, strategy = {}) {
const loan = normalizeLoan(rawLoan);
validateLoan(loan);
const { principal, annualRate, years } = loan;
const frequency = loan.frequency || 'monthly';
const periodsPerYear = FREQUENCY_MAP[frequency];
const totalTermPeriods = Math.max(1, Math.round(years * periodsPerYear));
const periodicRate = annualRate / 100 / periodsPerYear;
const rawExtra = strategy.extraPerPeriod ?? strategy.extraRepay ?? 0;
if (typeof rawExtra !== 'number' || !Number.isFinite(rawExtra) || rawExtra < 0) {
throw new Error('Invalid extraPerPeriod');
}
const extraPerPeriod = toCents(rawExtra);
const rawOffset = strategy.offset ?? 0;
if (typeof rawOffset !== 'number' || !Number.isFinite(rawOffset) || rawOffset < 0) {
throw new Error('Invalid offset');
}
const rawLumpSum = strategy.lumpSum ?? 0;
if (typeof rawLumpSum !== 'number' || !Number.i// loan.mjs - Australian mortgage calculation engine
// Pure functional logic, no I/O, no dependencies.
const FREQUENCIES = {
monthly: 12,
fortnightly: 26,
weekly: 52
};
function roundCents(n) {
return Math.round(n * 100) / 100;
}
// --- Normalization helpers ---
function normalizeLoan(loan) {
if (!loan || typeof loan !== 'object') return { principal: undefined, annualRate: undefined, years: undefined, frequency: undefined };
const norm = { ...loan };
// Detect alternative shape: has 'amount' but no 'principal'
if (norm.principal === undefined && norm.amount !== undefined) {
norm.principal = norm.amount;
if (norm.termMonths !== undefined) {
norm.years = norm.termMonths / 12;
}
}
// Only default frequency when the original loan did not contain the property at all
if (!('frequency' in loan)) {
norm.frequency = 'monthly';
}
return norm;
}
function extractStrategyFromLoan(loan) {
const strat = {};
if (!loan || typeof loan !== 'object') return strat;
if (loan.extra !== undefined) strat.extra = loan.extra;
if (loan.extraPerPeriod !== undefined) strat.extraPerPeriod = loan.extraPerPeriod;
if (loan.offset !== undefined) strat.offset = loan.offset;
if (loan.lumps !== undefined) strat.lumps = loan.lumps;
if (loan.lumpSum !== undefined) strat.lumpSum = loan.lumpSum;
if (loan.interestOnlyPeriods !== undefined) strat.interestOnlyMonths = loan.interestOnlyPeriods;
if (loan.interestOnlyMonths !== undefined) strat.interestOnlyMonths = loan.interestOnlyMonths;
return strat;
}
function validateLoan(normLoan) {
const { principal, annualRate, years, frequency } = normLoan;
if (typeof principal !== 'number' || principal <= 0 || !Number.isFinite(principal)) return false;
if (typeof annualRate !== 'number' || annualRate < 0 || !Number.isFinite(annualRate)) return false;
if (typeof years !== 'number' || years <= 0 || !Number.isFinite(years)) return false;
if (!['monthly', 'fortnightly', 'weekly'].includes(frequency)) return false;
// Prevent numeric overflow in amortization formula
const ppy = FREQUENCIES[frequency];
const totalPeriods = Math.round(years * ppy);
const rate = annualRate / 100 / ppy;
if (!Number.isFinite(Math.pow(1 + rate, totalPeriods))) {
return false;
}
return true;
}
// --- Safe payment finder (cached) ---
const safePaymentCache = new Map();
function findSafePa// loan.mjs — pure-logic ES module for Australian mortgage calculations
// No I/O, no dependencies. All money rounded to cents.
export const FREQUENCY_MAP = Object.freeze({
monthly: 12,
fortnightly: 26,
weekly: 52,
});
function roundCents(n) {
return Math.round(n * 100) / 100;
}
// ── Validation ───────────────────────────────────────────────────────────────
function resolveTermMonths(loan) {
if (Number.isFinite(loan.termMonths) && loan.termMonths > 0 && Number.isInteger(loan.termMonths)) {
return loan.termMonths;
}
if (Number.isFinite(loan.years) && loan.years > 0) {
return Math.round(loan.years * 12);
}
return null;
}
function validate(loan) {
const principal = loan.principal ?? loan.amount;
if (!Number.isFinite(principal) || principal <= 0)
throw new RangeError('principal must be a positive finite number');
if (!Number.isFinite(loan.annualRate) || loan.annualRate <= 0)
throw new RangeError('annualRate must be positive');
if (!FREQUENCY_MAP[loan.frequency])
throw new RangeError('unknown frequency');
if (resolveTermMonths(loan) == null)
throw new RangeError('termMonths must be a positive finite integer');
const io = loan.interestOnlyMonths ?? loan.interestOnlyPeriods ?? 0;
if (io != null && io !== 0) {
if (!Number.isFinite(io) || io < 0 || !Number.isInteger(io))
throw new RangeError('interestOnlyMonths must be a non-negative finite integer');
}
}
function validateStrategy(strat) {
if (strat == null) return;
if (strat.extraPerPeriod != null) {
if (!Number.isFinite(strat.extraPerPeriod) || strat.extraPerPeriod < 0)
throw new RangeError('extraPerPeriod must be a non-negative finite number');
}
if (strat.offset != null) {
if (!Number.isFinite(strat.offset) || strat.offset < 0)
throw new RangeError('offset must be a non-negative finite number');
}
if (strat.interestOnlyMonths != null) {
if (!Number.isFinite(strat.interestOnlyMonths) || strat.interestOnlyMonths < 0 || !Number.isInteger(strat.interestOnlyMonths))
throw new RangeError('interestOnlyMonths must be a non-negative finite integer');
}
}
// ── Core simulation ──────────────────────────────────────────────────────────
function calcPMT(principal, r, n) {
if (n <= 0) return 0;
if (r === 0) return roundCents(principal / n);
return roundCents((principal * r) / (1 - Math.pow(1 + r, -n))// loan.mjs — Pure-logic ES module for Australian mortgage calculations.
// No I/O, no dependencies. All money rounded to cents.
export const FREQ_MAP = {
M: 12, monthly: 12,
F: 26, fortnightly: 26,
W: 52, weekly: 52,
};
const NAME_TO_CODE = {
monthly: 'M',
fortnightly: 'F',
weekly: 'W',
};
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
function round2(n) {
return Math.round(n * 100) / 100;
}
function readNum(obj, key, defaultValue, min) {
if (obj[key] === undefined) return defaultValue;
const v = obj[key];
if (typeof v !== 'number' || !Number.isFinite(v)) {
throw new Error(`${key} must be a finite number`);
}
if (v < min) {
throw new Error(
`${key} must be ${min === 0 ? 'non-negative' : '>= ' + min}`
);
}
return v;
}
function requireNonNegativeInteger(name, value) {
if (!Number.isInteger(value) || value < 0) {
throw new Error(`${name} must be a non-negative integer`);
}
}
function advancePeriod(date, freq) {
const d = new Date(date);
if (freq === 'M') d.setMonth(d.getMonth() + 1);
else if (freq === 'F') d.setDate(d.getDate() + 14);
else d.setDate(d.getDate() + 7);
return d;
}
function normalizeLoan(loan) {
if (!loan || typeof loan !== 'object') {
throw new Error('loan must be an object');
}
let principal, ratePct, termYears, freq, startDate;
let isPublic = false;
if ('annualRate' in loan) {
// Public API schema
isPublic = true;
principal = loan.principal;
ratePct = loan.annualRate;
if (typeof ratePct === 'number' && Number.isFinite(ratePct) && ratePct < 0.1) {
ratePct = ratePct * 100;
}
if ('termMonths' in loan && loan.termMonths !== undefined) {
const tm = loan.termMonths;
if (typeof tm !== 'number' || !Number.isFinite(tm) || tm <= 0) {
throw new Error('termMonths must be a positive finite number');
}
termYears = tm / 12;
} else {
termYears = loan.years;
}
if (typeof loan.frequency !== 'string' ||
!Object.prototype.hasOwnProperty.call(NAME_TO_CODE, loan.frequency)) {
throw new Error('frequency must be monthly, fortnightly, or weekly');
}
freq = NAME_TO_CODE[loan.frequency];
startDate = loan.startDate;
} elseThe QA writes the acceptance tests.
You are a QA ENGINEER turning acceptance criteria into executable node:test tests importing loan.mjs. Verify with real numerical assertions: the frequency map (monthly=12, fortnightly=26, weekly=52); the scheduled repayment fully amortises (final balance ≈ 0); the accounting identity totalPaid = principal + totalInterest (within $1); higher rate → higher repayment; extra / offset / lump each cut interest; scenario comparison reports correct savings; invalid inputs rejected; recommendStrategy hits the target and a shorter target needs a larger extra repayment. «architect's API»
▸ OUTPUT · what each model produced:
import test from 'node:test';
import assert from 'node:assert/strict';
import {
FREQUENCIES,
repayment,
simulate,
quote,
compare,
recommendStrategy,
} from './loan.mjs';
const L = (over = {}) => ({ principal: 600000, annualRate: 5, years: 30, frequency: 'monthly', ...over });
const near = (a, b, t = 1) => Math.abs(a - b) <= t;
const toCents = (n) => Math.round(n * 100) / 100;
test('1. frequency map exists with monthly=12, fortnightly=26, weekly=52', () => {
assert.equal(FREQUENCIES.monthly, 12);
assert.equal(FREQUENCIES.fortnightly, 26);
assert.equal(FREQUENCIES.weekly, 52);
});
test('2a. golden anchor: $500k @ 6% / 30y monthly repays $2,997.75', () => {
const p = toCents(repayment(L({ principal: 500000, annualRate: 6 })));
assert.ok(near(p, 2997.75, 0.05), `expected ~2997.75, got ${p}`);
});
test('2b. default loan: $600k @ 5% / 30y monthly repays ≈ $3,220.93', () => {
const p = repayment(L());
assert.ok(near(p, 3220.93, 0.5), `expected ~3220.93, got ${p}`);
});
test('2c. scheduled repayment fully amortises the loan', () => {
const sim = simulate(L(), {});
const last = sim.rows[sim.rows.length - 1];
assert.ok(near(last.balance, 0, 0.01), `final balance ${last.balance} should be ~0`);
assert.equal(sim.totalMonths, 360);
assert.equal(sim.rows.length, 360);
});
test('2d. per-period payment shrinks as frequency increases', () => {
const m = repayment(L());
const f = repayment(L({ frequency: 'fortnightly' }));
const w = repayment(L({ frequency: 'weekly' }));
assert.ok(w < f && f < m, `expected weekly(${w}) < fortnightly(${f}) < monthly(${m})`);
});
test('3. accounting identity: totalPaid = principal + totalInterest (±$1)', () => {
const sim = simulate(L(), {});
const totalPaid = sim.rows.reduce((s, r) => s + r.interest + r.principal, 0);
assert.ok(
near(totalPaid, 600000 + sim.totalInterest, 1),
`paid ${toCents(totalPaid)} vs principal+interest ${toCents(600000 + sim.totalInterest)}`
);
const principalPaid = sim.rows.reduce((s, r) => s + r.principal, 0);
assert.ok(near(principalPaid, 600000, 1), `principal repaid ${toCents(principalPaid)} should be ~600000`);
});
test('4. higher rate → higher repayment', () => {
const r3 = repayment(L({ annualRate: 3 }));
const r5 = repayment(L({ annualRate: 5 }));
const r6 = repayment(L({ annualRate: 6 }));
assert.ok(r6 > r5 && r5 > r3, `expected ${rimport test from 'node:test';
import assert from 'node:assert/strict';
import * as engine from './loan.mjs';
const L = (over = {}) => ({ principal: 600000, annualRate: 5, years: 30, frequency: 'monthly', ...over });
const near = (a, b, t = 1) => Math.abs(a - b) <= t;
const toCents = (n) => Math.round(n * 100) / 100;
const {
validateLoan,
repaymentFor,
amortise,
quote,
compareScenarios,
recommendStrategy,
} = engine;
const hasOwn = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
const loanInput = (over = {}) => {
const legacyOverrides = {};
if (hasOwn(over, 'principal')) legacyOverrides.principal = over.principal;
if (hasOwn(over, 'balance')) legacyOverrides.principal = over.balance;
if (hasOwn(over, 'years')) legacyOverrides.years = over.years;
if (hasOwn(over, 'termYears')) legacyOverrides.years = over.termYears;
if (hasOwn(over, 'frequency')) legacyOverrides.frequency = over.frequency;
if (hasOwn(over, 'repaymentFreq')) legacyOverrides.frequency = over.repaymentFreq;
if (hasOwn(over, 'annualRate')) legacyOverrides.annualRate = over.annualRate;
const legacy = L(legacyOverrides);
const apiOverrides = { ...over };
delete apiOverrides.principal;
delete apiOverrides.years;
delete apiOverrides.frequency;
return {
balance: legacy.principal,
annualRate: legacy.annualRate,
termYears: legacy.years,
repaymentFreq: legacy.frequency,
startDate: '2024-01-01',
...apiOverrides,
};
};
const validationHasErrors = (result) => {
if (result == null || result === false) return true;
if (result instanceof Error) return true;
if (typeof result === 'string') return true;
if (Array.isArray(result)) return result.length > 0;
if (typeof result !== 'object') return false;
if (result.ok === false || result.valid === false) return true;
if (result.error) return true;
if (Array.isArray(result.errors)) return result.errors.length > 0;
if (result.errors && typeof result.errors === 'object') return Object.keys(result.errors).length > 0;
if (Array.isArray(result.issues)) return result.issues.length > 0;
if (result.issues && typeof result.issues === 'object') return Object.keys(result.issues).length > 0;
return false;
};
const validationValue = (result) =>
result?.loan ?? result?.value ?? result?.data ?? result?.canonical ?? result?.canonicalLoan ?? result;
const requireimport test from 'node:test';
import assert from 'node:assert/strict';
import { amortize, quote, compare, recommendStrategy, FREQUENCIES } from './loan.mjs';
const L = (over = {}) => ({ principal: 600000, annualRate: 5, years: 30, frequency: 'monthly', ...over });
const near = (a, b, t = 1) => Math.abs(a - b) <= t;
const toCents = (n) => Math.round(n * 100) / 100;
// Bridge the given `L()` helper to the Architect's `Loan` Data Model
const makeLoan = (over = {}) => {
const l = L(over);
return {
principal: l.principal,
annualRate: l.annualRate,
termMonths: l.years * 12,
frequency: l.frequency,
...over
};
};
test('1. Repayment frequency map exists with monthly=12, fortnightly=26, weekly=52', () => {
assert.ok(FREQUENCIES, 'FREQUENCIES export should exist');
assert.equal(FREQUENCIES.monthly, 12);
assert.equal(FREQUENCIES.fortnightly, 26);
assert.equal(FREQUENCIES.weekly, 52);
});
test('2. Scheduled repayment fully amortises the loan (final balance ≈ 0)', () => {
const loan = makeLoan();
const schedule = amortize(loan, {});
assert.ok(Array.isArray(schedule), 'Schedule should be an array');
assert.ok(schedule.length > 0, 'Schedule should not be empty');
const finalMonth = schedule[schedule.length - 1];
assert.ok(near(finalMonth.balance, 0, 0.01), `Final balance should be ~0, got ${finalMonth.balance}`);
});
test('3. Accounting identity: totalPaid = principal + totalInterest (within $1)', () => {
const loan = makeLoan();
const res = quote(loan, {});
const schedule = amortize(loan, {});
let totalPaid = 0;
let prevBalance = loan.principal;
// Calculate true total paid based strictly on the schedule changes
for (const row of schedule) {
const principalPaid = prevBalance - row.balance;
totalPaid += row.interest + principalPaid;
prevBalance = row.balance;
}
const expectedTotal = loan.principal + res.totalInterest;
assert.ok(
near(toCents(totalPaid), toCents(expectedTotal), 100),
`Accounting identity failed: totalPaid (${totalPaid}) != principal + totalInterest (${expectedTotal})`
);
});
test('4. Higher rate → higher repayment', () => {
const baseQuote = quote(makeLoan({ annualRate: 5 }), {});
const highQuote = quote(makeLoan({ annualRate: 8 }), {});
assert.ok(
highQuote.repayment > baseQuote.repayment,
`Expected repayment for 8% (${highQuote.repimport test from 'node:test';
import assert from 'node:assert/strict';
import { quote, recommendStrategy, compare, FREQUENCY_MAP } from './loan.mjs';
const L = (over = {}) => ({ principal: 600000, annualRate: 5, years: 30, frequency: 'monthly', ...over });
const near = (a, b, t = 1) => Math.abs(a - b) <= t;
const toCents = (n) => Math.round(n * 100) / 100;
test('1. Repayment frequency map exists with monthly=12, fortnightly=26, weekly=52', () => {
assert.ok(FREQUENCY_MAP);
assert.strictEqual(FREQUENCY_MAP.monthly, 12);
assert.strictEqual(FREQUENCY_MAP.fortnightly, 26);
assert.strictEqual(FREQUENCY_MAP.weekly, 52);
});
test('2. Scheduled repayment fully amortises the loan (final balance ≈ 0)', () => {
const res = quote(L(), {});
const finalBalance = res.schedule[res.schedule.length - 1].balance;
assert.ok(near(toCents(finalBalance), 0, 1), `Final balance should be ≈ 0, got ${finalBalance}`);
});
test('3. Accounting identity: totalPaid = principal + totalInterest (within $1)', () => {
const res = quote(L(), {});
const totalPaid = res.schedule.reduce((sum, p) => sum + p.payment, 0);
const expectedTotal = L().principal + res.totalInterest;
assert.ok(
near(toCents(totalPaid), toCents(expectedTotal), 1),
`Total paid (${toCents(totalPaid)}) should equal principal + totalInterest (${toCents(expectedTotal)}) within $1`
);
});
test('4. Higher rate → higher repayment', () => {
const res1 = quote(L({ annualRate: 5 }), {});
const res2 = quote(L({ annualRate: 6 }), {});
assert.ok(res2.repayment > res1.repayment);
});
test('5. Extra repayments cut both the term and the total interest', () => {
const base = quote(L(), {});
const extra = quote(L(), { extraRepay: 500 });
assert.ok(extra.totalMonths < base.totalMonths, 'Term should be shorter');
assert.ok(extra.totalInterest < base.totalInterest, 'Total interest should be lower');
});
test('6. An offset balance reduces total interest', () => {
const base = quote(L(), {});
const off = quote(L(), { offset: 100000 });
assert.ok(off.totalInterest < base.totalInterest, 'Offset should reduce total interest');
});
test('7. A lump sum reduces both interest and term', () => {
const base = quote(L(), {});
const lump = quote(L(), { lumpSum: 50000 });
assert.ok(lump.totalInterest < base.totalInterest, 'Lump sum should reduce total interest');
assert.ok(lump.totalMonths < import test from 'node:test';
import assert from 'node:assert/strict';
import { quote, generateSchedule, recommendStrategy, compare, isValidLoan } from './loan.mjs';
const L = (over = {}) => ({ principal: 600000, annualRate: 5, years: 30, frequency: 'monthly', ...over });
const near = (a, b, t = 1) => Math.abs(a - b) <= t;
const toCents = (n) => Math.round(n * 100) / 100;
test('Loan module', async (t) => {
// 1. Repayment frequency map
await t.test('1. Repayment frequency map', () => {
const qM = quote(L({ years: 1, frequency: 'monthly' }), {});
assert.strictEqual(qM.months, 12);
const qF = quote(L({ years: 1, frequency: 'fortnightly' }), {});
assert.strictEqual(qF.months, 26);
const qW = quote(L({ years: 1, frequency: 'weekly' }), {});
assert.strictEqual(qW.months, 52);
});
// 2. Scheduled repayment fully amortises the loan
await t.test('2. Scheduled repayment fully amortises the loan', () => {
const loan = L();
const schedule = generateSchedule(loan, {});
assert.ok(schedule.length > 0, 'Schedule should not be empty');
const last = schedule[schedule.length - 1];
assert.ok(near(last.balance, 0, 0.01), `Final balance should be ~0, got ${last.balance}`);
});
// 3. Accounting identity: totalPaid = principal + totalInterest
await t.test('3. Accounting identity', () => {
const loan = L();
const q = quote(loan, {});
const totalPaid = toCents(q.repayment * q.months);
const expected = toCents(loan.principal + q.totalInterest);
assert.ok(near(totalPaid, expected, 1), `totalPaid (${totalPaid}) ≈ expected (${expected})`);
});
// 4. Higher rate → higher repayment
await t.test('4. Higher rate → higher repayment', () => {
const q1 = quote(L({ annualRate: 5 }), {});
const q2 = quote(L({ annualRate: 6 }), {});
assert.ok(q2.repayment > q1.repayment, `6% repayment (${q2.repayment}) > 5% repayment (${q1.repayment})`);
});
// 5. Extra repayments cut both term and total interest
await t.test('5. Extra repayments cut term and total interest', () => {
const loan = L();
const base = quote(loan, {});
const extra = quote(loan, { extra: 200 });
assert.ok(extra.months < base.months, `Months with extra (${extra.months}) < base (${base.months})`);
assert.ok(extra.totalInterest < base.totalInterest, `Interest with extra (${extra.totalInterest}) < base (${base.timport test from 'node:test';
import assert from 'node:assert/strict';
import {
amortise,
applyExtra,
applyOffset,
applyLump,
combine,
quote,
compare,
recommendStrategy,
FREQUENCY_MAP,
} from './loan.mjs';
const L = (over = {}) => ({
principal: 600000,
annualRate: 5,
years: 30,
frequency: 'monthly',
...over,
});
const near = (a, b, t = 1) => Math.abs(a - b) <= t;
const toCents = (n) => Math.round(n * 100) / 100;
/* ── 1 Repayment frequency map ─────────────────────────────── */
test('FREQUENCY_MAP maps monthly→12, fortnightly→26, weekly→52', () => {
assert.equal(FREQUENCY_MAP.monthly, 12);
assert.equal(FREQUENCY_MAP.fortnightly, 26);
assert.equal(FREQUENCY_MAP.weekly, 52);
assert.equal(Object.keys(FREQUENCY_MAP).length, 3);
});
/* ── 2 Scheduled repayment fully amortises the loan ────────── */
test('final balance in schedule is ≈ 0 (fully amortised)', () => {
const result = amortise(L());
const last = result.schedule[result.schedule.length - 1];
assert.ok(near(last.balance, 0, 1), `final balance ${last.balance} should be ≈ 0`);
assert.ok(result.schedule.length > 0, 'schedule should not be empty');
assert.ok(result.totalMonths > 0);
assert.ok(result.totalInterest > 0);
});
/* ── 3 Accounting identity: totalPaid = principal + interest ── */
test('sum of payments ≈ principal + totalInterest (within $1)', () => {
const result = amortise(L());
const totalPaid = toCents(result.schedule.reduce((s, row) => s + row.payment, 0));
const expected = toCents(L().principal + result.totalInterest);
assert.ok(
near(totalPaid, expected, 1),
`totalPaid ${totalPaid} should ≈ principal + interest ${expected}`,
);
});
/* ── 4 Higher rate → higher repayment ──────────────────────── */
test('higher annual rate produces a higher repayment amount', () => {
const r3 = amortise(L({ annualRate: 3 }));
const r5 = amortise(L({ annualRate: 5 }));
const r7 = amortise(L({ annualRate: 7 }));
assert.ok(r3.repayment < r5.repayment);
assert.ok(r5.repayment < r7.repayment);
});
/* ── 5 Extra repayments cut term and total interest ─────────── */
test('regular extra repayment reduces both term and total interest', () => {
const base = amortise(L());
const boosted = applyExtra(L(), 500);
assert.ok(boosted.totalMonths < base.totalMonths,
`term ${boosted.totalMonths} < ${base.totalMonths}`);
assert.import test from 'node:test';
import assert from 'node:assert/strict';
import {
amortise,
quote,
recommendStrategy,
compareScenarios,
applyOffset,
FREQ_MAP,
} from './loan.mjs';
// Helpers — adapted to the architect's loan schema (ratePct, termYears, freq)
const L = (over = {}) => ({
principal: 600000,
ratePct: 5,
termYears: 30,
freq: 'M',
startDate: new Date('2025-01-01'),
...over,
});
const toCents = (n) => Math.round(n * 100) / 100;
const near = (a, b, t = 1) => Math.abs(a - b) <= t;
// ---------------------------------------------------------------------------
// 1. Repayment frequency map
// ---------------------------------------------------------------------------
test('FREQ_MAP defines monthly=12, fortnightly=26, weekly=52', () => {
assert.equal(FREQ_MAP.M, 12, 'monthly should be 12 periods/year');
assert.equal(FREQ_MAP.F, 26, 'fortnightly should be 26 periods/year');
assert.equal(FREQ_MAP.W, 52, 'weekly should be 52 periods/year');
});
// ---------------------------------------------------------------------------
// 2. Scheduled repayment fully amortises the loan
// ---------------------------------------------------------------------------
test('scheduled repayment fully amortises the loan (final balance ≈ 0)', () => {
const sched = amortise(L(), {});
assert.ok(sched.length > 0, 'schedule should not be empty');
const last = sched[sched.length - 1];
assert.ok(
near(toCents(last.closing), 0, 0.05),
`final closing ${last.closing} should round to $0.00`
);
// Every row before the last must have a positive closing balance
for (let i = 0; i < sched.length - 1; i++) {
assert.ok(sched[i].closing > 0, `row ${i} should still owe money`);
}
});
// ---------------------------------------------------------------------------
// 3. Accounting identity: totalPaid = principal + totalInterest (within $1)
// ---------------------------------------------------------------------------
test('accounting identity: totalPaid ≈ principal + totalInterest', () => {
const r = quote(L(), {});
const totalPaid = r.repayment * r.totalMonths;
const rhs = 600000 + r.totalInterest;
assert.ok(
near(totalPaid, rhs, 1),
`totalPaid ${totalPaid.toFixed(2)} vs principal+interest ${rhs.toFixed(2)}`
);
});
// ---------------------------------------------------------------------------
// 4. Higher rate → higher The Security writes adversarial input tests.
You are a SECURITY ENGINEER hardening a pure-logic financial module. Write security.test.mjs with 3–4 ADVERSARIAL tests: NaN / Infinity / negative / zero principal, rate or term MUST be rejected (throw, or a documented error) — never silently yield NaN/Infinity; a colossal principal (1e12) must stay finite; the recommender must terminate on an impossible target, not loop forever. «the module»
▸ OUTPUT · what each model produced:
// security.test.mjs — adversarial hardening suite for loan.mjs
// Attack surface: every export that accepts user-controlled numbers.
// Contract under test: hostile input throws (TypeError/RangeError) — it never
// silently propagates NaN/Infinity into money figures, and nothing loops forever.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
FREQUENCIES,
repayment,
simulate,
quote,
compare,
recommendStrategy,
} from './loan.mjs';
const BASE = Object.freeze({ principal: 500_000, annualRate: 5.5, years: 30, frequency: 'monthly' });
const throwsLoudly = (fn, label) =>
assert.throws(
fn,
(err) => err instanceof RangeError || err instanceof TypeError,
`${label} was accepted — hostile input must throw, not leak NaN/Infinity`
);
test('hostile loan fields (NaN/Infinity/negative/zero/strings/proto-keys) are rejected on every surface', () => {
assert.ok(Object.isFrozen(FREQUENCIES), 'FREQUENCIES must be frozen against runtime tampering');
const hostile = {
principal: [NaN, Infinity, -Infinity, -1, 0, -0, '500000', null],
annualRate: [NaN, Infinity, -Infinity, -0.01, '5.5', null],
years: [NaN, Infinity, -Infinity, -1, 0, 0.001, '30'],
frequency: ['daily', '', null, undefined, 12, 'constructor', '__proto__', 'toString'],
};
const surfaces = {
repayment: (l) => repayment(l),
simulate: (l) => simulate(l),
quote: (l) => quote(l),
compare: (l) => compare(l, [{}]),
recommendStrategy: (l) => recommendStrategy(l, { targetYears: 5 }),
};
for (const [field, values] of Object.entries(hostile)) {
for (const value of values) {
const loan = { ...BASE, [field]: value };
for (const [name, fn] of Object.entries(surfaces)) {
throwsLoudly(() => fn(loan), `${name}({...,${field}:${String(value)}})`);
}
}
}
throwsLoudly(() => repayment(null), 'repayment(null)');
throwsLoudly(() => repayment(undefined), 'repayment(undefined)');
throwsLoudly(() => repayment(1e6), 'repayment(non-object)');
throwsLoudly(() => compare(BASE, 'not-an-array'), 'compare(non-array strategies)');
});
test('hostile strategy fields cannot poison the simulation', () => {
const attacks = [
{ extra: NaN }, { extra: -1 }, { extra: Infinity }, { extra: '100' },
{ extraPerPeriod: NaN },
{ offset: NaN }, { offset: -0.01 }, { offset: Infinity },
{ interimport test from 'node:test';
import assert from 'node:assert/strict';
import { Worker } from 'node:worker_threads';
import {
validateLoan,
repaymentFor,
amortise,
amortize,
quote,
compareScenarios,
chartSeries,
recommendStrategy,
} from './loan.mjs';
const VALID_LOAN = Object.freeze({
principal: 350_000,
annualRate: 5.75,
termYears: 30,
repaymentFreq: 'monthly',
startDate: '2024-01-01',
});
const HOSTILE_NUMBERS = Object.freeze([
['NaN', Number.NaN],
['Infinity', Number.POSITIVE_INFINITY],
['-Infinity', Number.NEGATIVE_INFINITY],
['negative', -1],
['zero', 0],
]);
const loanFacingOperations = Object.freeze([
['validateLoan', (loan) => validateLoan(loan)],
['repaymentFor', (loan) => repaymentFor(loan)],
['amortise', (loan) => amortise(loan)],
['amortize', (loan) => amortize(loan)],
['quote', (loan) => quote(loan)],
['compareScenarios', (loan) => compareScenarios(loan, {})],
[
'recommendStrategy',
(loan) => recommendStrategy(loan, { targetMonths: 360, candidates: [0] }),
],
]);
function isDocumentedError(value) {
if (!value || typeof value !== 'object') return false;
const hasFailureFlag =
value.ok === false || value.valid === false || value.success === false;
const hasError =
(Array.isArray(value.errors) && value.errors.length > 0) ||
(typeof value.error === 'string' && value.error.length > 0) ||
value.error instanceof Error;
return hasFailureFlag && hasError;
}
function assertNoNonFiniteNumbers(value, label, seen = new Set(), path = '$') {
if (typeof value === 'number') {
assert.ok(
Number.isFinite(value),
`${label} returned a non-finite number at ${path}`,
);
return;
}
if (!value || typeof value !== 'object') return;
if (seen.has(value)) return;
seen.add(value);
if (Array.isArray(value)) {
for (let index = 0; index < value.length; index += 1) {
assertNoNonFiniteNumbers(value[index], label, seen, `${path}[${index}]`);
}
return;
}
for (const [key, child] of Object.entries(value)) {
assertNoNonFiniteNumbers(child, label, seen, `${path}.${key}`);
}
}
function assertRejected(operation, label) {
let returned;
try {
returned = operation();
} catch (error) {
assert.ok(error instanceof Error, `${label} threw a non-Error value`);
return;
}
assertNoNonFiniteNumbers(returned, label);
import test from 'node:test';
import assert from 'node:assert/strict';
import { FREQUENCIES, validate, amortize, quote, compare, recommendStrategy } from './loan.mjs';
test('Adversarial: Rejects hostile logic-breaking inputs (NaN, Infinity, negative, zero)', () => {
const badPrincipals = [NaN, Infinity, -1000, 0];
const badRates = [NaN, Infinity, -1];
const badTerms = [NaN, Infinity, -10, 0];
// Testing via validate() prevents potential infinite loops in amortize() while ensuring strict API boundaries
for (const val of badPrincipals) {
assert.throws(
() => validate({ principal: val, annualRate: 5, years: 30 }),
Error,
`Should strictly reject hostile principal: ${val}`
);
}
for (const val of badRates) {
assert.throws(
() => validate({ principal: 100000, annualRate: val, years: 30 }),
Error,
`Should strictly reject hostile rate: ${val}`
);
}
for (const val of badTerms) {
assert.throws(
() => validate({ principal: 100000, annualRate: 5, years: val }),
Error,
`Should strictly reject hostile term: ${val}`
);
}
});
test('Adversarial: Malicious strategy properties never silently yield NaN/Infinity', () => {
const loan = { principal: 100000, annualRate: 5, years: 30 };
// Attack payloads for the optional strategy configuration
const hostileStrategies = [
{ extraRepayment: Infinity },
{ offsetBalance: NaN },
{ lumpSum: Infinity, lumpSumMonth: -1 },
{ extraPerPeriod: -Infinity, lumpSum: NaN }
];
for (const strategy of hostileStrategies) {
let res;
try {
res = quote(loan, strategy);
} catch (err) {
continue; // Rejection via throw is a secure handling mechanism
}
// If it returns, the values MUST be strictly finite numbers
assert.ok(Number.isFinite(res.repayment), `Repayment silently yielded NaN/Infinity for strategy: ${JSON.stringify(strategy)}`);
assert.ok(Number.isFinite(res.totalInterest), `Total interest silently yielded NaN/Infinity for strategy: ${JSON.stringify(strategy)}`);
assert.ok(Number.isFinite(res.totalMonths), `Total months silently yielded NaN/Infinity for strategy: ${JSON.stringify(strategy)}`);
}
import test from 'node:test';
import assert from 'node:assert/strict';
import { FREQUENCY_MAP, quote, compare, recommendStrategy } from './loan.mjs';
test('must reject NaN, Infinity, negative, and zero principal, rate, or term with explicit errors', (t) => {
const invalidLoans = [
{ desc: 'NaN principal', loan: { principal: NaN, annualRate: 5, years: 30 } },
{ desc: 'Infinity principal', loan: { principal: Infinity, annualRate: 5, years: 30 } },
{ desc: 'Negative principal', loan: { principal: -1000, annualRate: 5, years: 30 } },
{ desc: 'Zero principal', loan: { principal: 0, annualRate: 5, years: 30 } },
{ desc: 'NaN rate', loan: { principal: 1000, annualRate: NaN, years: 30 } },
{ desc: 'Infinity rate', loan: { principal: 1000, annualRate: Infinity, years: 30 } },
{ desc: 'Negative rate', loan: { principal: 1000, annualRate: -5, years: 30 } },
{ desc: 'NaN term', loan: { principal: 1000, annualRate: 5, years: NaN } },
{ desc: 'Infinity term', loan: { principal: 1000, annualRate: 5, years: Infinity } },
{ desc: 'Negative term', loan: { principal: 1000, annualRate: 5, years: -30 } },
{ desc: 'Zero term', loan: { principal: 1000, annualRate: 5, years: 0 } }
];
for (const { desc, loan } of invalidLoans) {
assert.throws(
() => quote(loan),
{ message: /Invalid|Zero-rate/ },
`Expected rejection for ${desc}`
);
}
// Adversarial targets for recommendStrategy
assert.throws(
() => recommendStrategy({ principal: 1000, annualRate: 5, years: 30 }, { targetYears: 0 }),
{ message: 'Invalid targetYears' }
);
assert.throws(
() => recommendStrategy({ principal: 1000, annualRate: 5, years: 30 }, { targetYears: Infinity }),
{ message: 'Invalid targetYears' }
);
});
test('colossal principal (1e12) must stay finite and never silently yield NaN or Infinity', () => {
const colossalLoan = { principal: 1e12, annualRate: 5, years: 30 };
const res = quote(colossalLoan);
// Ensure output remains strictly finite and does not degrade into NaN/Infinity
assert.ok(Number.isFinite(res.repayment), 'repayment should be finite');
assert.ok(Number.isFinite(res.totalInterest), 'totalInterest should be finite');
assert.ok(Number.isFinite(res.totalMonths), 'totalMonths should be finite');
assert.ok(res.schedule.length > 0, 'schedule should populate');
for (cimport test from 'node:test';
import assert from 'node:assert/strict';
import { isValidLoan, quote, generateSchedule, recommendStrategy } from './loan.mjs';
const baseLoan = { principal: 500000, annualRate: 5, years: 30, frequency: 'monthly' };
test('Hostile loan inputs (NaN, Infinity, negative, zero principal/rate/term) are rejected', () => {
const invalidLoans = [
{ ...baseLoan, principal: NaN },
{ ...baseLoan, principal: Infinity },
{ ...baseLoan, principal: -100 },
{ ...baseLoan, principal: 0 },
{ ...baseLoan, principal: '500000' },
{ ...baseLoan, annualRate: NaN },
{ ...baseLoan, annualRate: Infinity },
{ ...baseLoan, annualRate: -1 },
{ ...baseLoan, annualRate: '5' },
{ ...baseLoan, years: NaN },
{ ...baseLoan, years: Infinity },
{ ...baseLoan, years: -5 },
{ ...baseLoan, years: 0 },
{ ...baseLoan, years: '30' },
{ ...baseLoan, frequency: 'daily' },
{ ...baseLoan, frequency: undefined },
{ ...baseLoan, frequency: 123 },
];
for (const loan of invalidLoans) {
assert.strictEqual(isValidLoan(loan), false, `isValidLoan should reject: ${JSON.stringify(loan)}`);
assert.throws(() => quote(loan), { message: 'Invalid loan' }, `quote should throw for: ${JSON.stringify(loan)}`);
assert.throws(() => generateSchedule(loan), { message: 'Invalid loan' }, `generateSchedule should throw for: ${JSON.stringify(loan)}`);
}
});
test('Colossal principal (1e12) stays finite', () => {
const loan = { principal: 1e12, annualRate: 5, years: 30, frequency: 'monthly' };
assert.ok(isValidLoan(loan), 'Loan with colossal principal should be valid');
const q = quote(loan);
assert.ok(Number.isFinite(q.repayment), 'repayment must be finite');
assert.ok(Number.isFinite(q.totalInterest), 'totalInterest must be finite');
assert.ok(Number.isFinite(q.totalMonths), 'totalMonths must be finite');
assert.ok(q.repayment > 0);
assert.ok(q.totalInterest >= 0);
assert.ok(q.totalMonths > 0);
const schedule = generateSchedule(loan);
assert.ok(Array.isArray(schedule));
for (const entry of schedule) {
assert.ok(Number.isFinite(entry.payment), `period ${entry.period} payment must be finite`);
assert.ok(Number.isFinite(entry.interest), `period ${entry.period} interest must be finite`);
assert.ok(Number.isFinite(entry.principal), `period ${entry.period} principal must be finimport test from 'node:test';
import assert from 'node:assert/strict';
import {
FREQUENCY_MAP,
amortise,
applyExtra,
applyOffset,
applyLump,
combine,
quote,
compare,
recommendStrategy,
} from './loan.mjs';
// ── helpers ──────────────────────────────────────────────────────────────────
const good = { principal: 500_000, annualRate: 6, years: 30, frequency: 'monthly' };
const SCALAR_POISON = [NaN, Infinity, -Infinity, 0, -1, -0.001];
// ── 1. Hostile numeric inputs on principal / rate / term / frequency ─────────
test('hostile scalar inputs must throw RangeError — never silently yield NaN/Infinity', () => {
for (const v of SCALAR_POISON) {
const tag = String(v);
assert.throws(
() => amortise({ ...good, principal: v }),
{ constructor: RangeError },
`principal=${tag}`,
);
assert.throws(
() => amortise({ ...good, annualRate: v }),
{ constructor: RangeError },
`annualRate=${tag}`,
);
assert.throws(
() => amortise({ ...good, years: v }),
{ constructor: RangeError },
`years=${tag}`,
);
}
// bad frequency
assert.throws(() => amortise({ ...good, frequency: 'yearly' }), RangeError);
assert.throws(() => amortise({ ...good, frequency: '' }), RangeError);
assert.throws(() => amortise({ ...good, frequency: undefined }), RangeError);
// every exported wrapper must also reject the same way
assert.throws(() => applyExtra({ ...good, principal: NaN }, 100), RangeError);
assert.throws(() => applyOffset({ ...good, annualRate: 0 }, 50_000), RangeError);
assert.throws(() => applyLump({ ...good, years: -5 }, 1, 10_000), RangeError);
assert.throws(() => quote({ ...good, principal: Infinity }), RangeError);
assert.throws(() => combine({ ...good, annualRate: NaN }), RangeError);
assert.throws(() => recommendStrategy({ ...good, principal: -1 }, { targetYears: 5 }), RangeError);
});
// ── 2. Colossal principal (1e12) — every field must stay finite ──────────────
test('colossal principal 1e12 produces only finite, non-negative values', () => {
const giant = { principal: 1e12, annualRate: 6.5, years: 30, frequency: 'monthly' };
const result = amortise(giant);
assert.ok(Number.isFinite(result.repayment), 'repayment finite');
assert.ok(Number.isFinite(result.totalInterest), 'totalInterest finite');
assert.ok(Number.isFinite(result.totalMonths), 'totaimport test from 'node:test';
import assert from 'node:assert/strict';
import { amortise, recommendStrategy } from './loan.mjs';
// ADVERSARIAL TEST 1: NaN / Infinity / negative / zero for principal,
// annualRate, or years must be rejected (throw) — never silently yield
// NaN or Infinity further down the schedule.
test('rejects NaN, Infinity, negative, and zero for principal, annualRate, and years', () => {
const base = { principal: 100000, annualRate: 5, years: 30, frequency: 'monthly' };
// Hostile values that must each throw, exercised against all three fields.
const hostile = [NaN, Infinity, -Infinity, 0, -1, '100', null, {}, []];
for (const bad of hostile) {
assert.throws(
() => amortise({ ...base, principal: bad }),
/principal/,
`principal=${String(bad)} should throw`
);
assert.throws(
() => amortise({ ...base, annualRate: bad }),
/rate/,
`annualRate=${String(bad)} should throw`
);
assert.throws(
() => amortise({ ...base, years: bad }),
/term/,
`years=${String(bad)} should throw`
);
}
});
// ADVERSARIAL TEST 2: a colossal principal (1e12) must still produce a
// fully finite schedule that amortises cleanly to zero. The math engine
// must not blow up to Infinity or silently produce NaN rows.
test('colossal principal (1e12) yields a finite schedule that closes at zero', () => {
const sched = amortise({
principal: 1e12,
annualRate: 5.5,
years: 30,
frequency: 'monthly',
});
assert.ok(sched.length > 0, 'schedule should not be empty');
for (const [i, row] of sched.entries()) {
for (const k of ['opening', 'interest', 'repayment', 'principal', 'closing']) {
assert.ok(
Number.isFinite(row[k]),
`row ${i} field ${k} not finite: ${row[k]}`
);
}
}
const last = sched[sched.length - 1];
assert.ok(
Math.abs(last.closing) < 0.01,
`final closing should be ~0, got ${last.closing}`
);
});
// ADVERSARIAL TEST 3: recommendStrategy must TERMINATE (throw) on an
// impossible target rather than spinning forever. The internal linear
// scan has a fixed cap (100000), so control always returns to the
// caller — empirically verified by a wall-clock bound.
test('recommendStrategy throws (terminates) on an impossible target, never loops forever', () => {
const loan = { principal: 500000, annualRate: 6, years: 3The DevOps sets up CI.
You are a DEVOPS ENGINEER. Write a minimal GitHub Actions workflow (.github/workflows/ci.yml) that, on every push and pull_request, checks out, sets up Node 20, and runs node --test. Output ONLY the YAML.
▸ OUTPUT · what each model produced:
name: CI
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: node --test
name: CI
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: node --test
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: node --test
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: node --test
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: node --test
name: CI
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: node --test
name: CI
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: node --test
The Tech Lead reviews the PR — bugs found & false alarms dismissed.
You are the TECH LEAD doing a substantive PR review. «PR diff» «the module» «CI: N/N passing» «automated review: confirmed findings + false alarms caught». In ≤5 lines: (1) comment on the architecture/approach — is it sound?; (2) comment on the review findings — were the fixes adequate?; (3) note any remaining concern or give confidence; (4) end with: APPROVE — «one reason».
▸ OUTPUT · what each model produced:
# Loan engine, UI & tests **Branch:** `feat/loan-engine` → `main` **Author:** AI Engineer **Reviewer:** 5 automated reviewers (adversarially verified) ## CI Results - Tests: **25 passed**, 0 failed (62ms) - Build: **green** ## Review Findings — Confirmed & Fixed - [MEDIUM] **Tiny years yields zero periods, Infinity**: validateLoan accepts years like 0.01, so Math.round(years*periodsPerYear) becomes 0 and annuity divides by zero, silently returning Infinity from repayment()/quote() instead of throwing. ## Review Findings — Rejected (false alarms caught) - 4 false alarms identified and dismissed by adversarial verification ## Checklist - [x] All tests passing - [x] Code review completed - [x] Review findings addressed or noted - [x] No regressions
# Loan engine, UI & tests **Branch:** `feat/loan-engine` → `main` **Author:** AI Engineer **Reviewer:** 5 automated reviewers (adversarially verified) ## CI Results - Tests: **20 passed**, 0 failed (298ms) - Build: **green** ## Review Findings — Confirmed & Fixed - [MEDIUM] **Missing startDate accepted**: validateLoan silently defaults an absent startDate to 2024-01-01 even though startDate is required by the loan model, so date-sensitive outputs can be fabricated instead of rejected. - [MEDIUM] **Candidates not respected**: recommendStrategy rounds supplied candidate amounts and can continue generating new amounts when none match, so it may return an extra repayment outside the provided candidates. - [HIGH] **Unpaid loans report finite terms**: If a supplied repayment never clears the balance, amortise stops at maxPeriods with payoffDate:null but quote/compare/recommend still treat that guard length as totalMonths, yielding misleading payoff times, savings and recommendations. ## Review Findings — Rejected (false alarms caught) - 2 false alarms identified and dismissed by adversarial verification ## Checklist - [x] All tests passing - [x] Code review completed - [x] Review findings addressed or noted - [x] No regressions
# Loan engine, UI & tests **Branch:** `feat/loan-engine` → `main` **Author:** AI Engineer **Reviewer:** 5 automated reviewers (adversarially verified) ## CI Results - Tests: **20 passed**, 0 failed (67ms) - Build: **green** ## Review Findings — Confirmed & Fixed - [HIGH] **Undocumented frequency requirement breaks API**: The validate function throws an error if loan.frequency is missing, but the documented Loan contract completely omits this field. - [HIGH] **Linear search violates binary search contract**: recommendStrategy uses an unbounded O(n) linear search instead of the architecturally mandated binary search, threatening the <200ms latency requirement. - [HIGH] **Infinite loop on invalid target months**: Missing bounds and NaN guards on targetMonths/targetYears causes the while(true) loop to run infinitely if inputs are negative or undefined. - [HIGH] **Missing validation for loan term duration**: Failing to validate termMonths or years allows undefined values, resulting in NaN period calculations and silent failures instead of throwing. - [HIGH] **recommendStrategy uses linear instead of binary search**: Incrementing extra repayment by 1 in a while(true) loop violates the architectural requirement for binary search and will easily breach the <200ms latency limit for large adjustments. ## Checklist - [x] All tests passing - [x] Code review completed - [x] Review findings addressed or noted - [x] No regressions
# Loan engine, UI & tests
**Branch:** `feat/loan-engine` → `main`
**Author:** AI Engineer
**Reviewer:** 5 automated reviewers (adversarially verified)
## CI Results
- Tests: **19 passed**, 0 failed (115ms)
- Build: **green**
## Review Findings — Confirmed & Fixed
- [HIGH] **Loan model fields mismatch contract**: Contract defines Loan={balance, rate, termMonths} but code expects {principal, annualRate, years, frequency}, so any UI binding to the documented shape throws validation errors.
- [HIGH] **recommendStrategy signature diverges from contract**: Contract specifies recommendStrategy(loan, targetMonths) with a numeric second argument, but implementation destructures {targetYears} from an object, causing a silent undefined or error on documented call sites.
- [MEDIUM] **Strategy params lack negative-value guards**: Negative lumpSum silently increases the balance, negative offset inflates interest-bearing principal, and negative extraPerPeriod reduces payments below the scheduled amount — all producing wrong results without any error.
## Review Findings — Rejected (false alarms caught)
- 2 false alarms identified and dismissed by adversarial verification
## Checklist
- [x] All tests passing
- [x] Code review completed
- [x] Review findings addressed or noted
- [x] No regressions# Loan engine, UI & tests **Branch:** `feat/loan-engine` → `main` **Author:** AI Engineer **Reviewer:** 5 automated reviewers (adversarially verified) ## CI Results - Tests: **21 passed**, 0 failed (149ms) - Build: **green** ## Review Findings — Confirmed & Fixed - [HIGH] **Unvalidated strategy fields**: Negative or non-numeric extra, offset, lumps, or interestOnlyMonths can produce invalid repayment schedules or runtime errors. - [MEDIUM] **recommendStrategy lacks targetYears validation**: Missing check for non-negative numeric targetYears leads to incorrect logic or wasteful iterations. - [HIGH] **Simulation ignores loan term for interest-only loans**: Interest-only full-term loans generate schedule far beyond the term with accumulating unpaid interest and incorrect totalMonths. - [MEDIUM] **Floating-point arithmetic causes schedule length drift**: Using unrounded exact payments introduces floating errors, resulting in extra periods or non-zero final balances. ## Review Findings — Rejected (false alarms caught) - 1 false alarms identified and dismissed by adversarial verification ## Checklist - [x] All tests passing - [x] Code review completed - [x] Review findings addressed or noted - [x] No regressions
# Loan engine, UI & tests
**Branch:** `feat/loan-engine` → `main`
**Author:** AI Engineer
**Reviewer:** 5 automated reviewers (adversarially verified)
## CI Results
- Tests: **28 passed**, 0 failed (137ms)
- Build: **green**
## Review Findings — Confirmed & Fixed
- [MEDIUM] **Loan model diverges from the contract**: The API contract defines `Loan = { principal, annualRate, termMonths }` but the implementation uses `years` and `frequency` (with a `FREQUENCY_MAP`), meaning the module cannot be consumed as the architect specified without a translation layer or contract update.
- [HIGH] **Loan shape mismatches documented type completely**: Contract defines Loan as {principal, annualRate, termMonths} but implementation requires {principal, annualRate, years, frequency}, so every exported function will throw or compute incorrectly when callers supply the documented shape.
- [HIGH] **NaN silently bypasses every validate() guard**: All three checks use `<= 0` which returns false for NaN (since NaN <= 0 is false), so a NaN principal, annualRate, or years passes validation and propagates NaN through the entire schedule without throwing.
- [MEDIUM] **applyLump month and amount completely unguarded**: A negative or NaN amount silently reduces the payment (growing the balance), and an out-of-range month is never matched so the lump is silently ignored with no error.
## Review Findings — Rejected (false alarms caught)
- 1 false alarms identified and dismissed by adversarial verification
## Checklist
- [x] All tests passing
- [x] Code review completed
- [x] Review findings addressed or noted
- [x] No regressions# Loan engine, UI & tests
**Branch:** `feat/loan-engine` → `main`
**Author:** AI Engineer
**Reviewer:** 5 automated reviewers (adversarially verified)
## CI Results
- Tests: **24 passed**, 0 failed (357ms)
- Build: **green**
## Review Findings — Confirmed & Fixed
- [HIGH] **recommendStrategy shape/signature drift**: Spec defines recommendStrategy(loan, target) -> {extraMonthly, lumpSum, monthsSaved}, but code takes (loan, opts), returns {extraPerPeriod, extraMonthly, payoffYears, totalInterest, monthsSaved}, and never considers lumpSum.
- [HIGH] **Schedule rows missing date field**: Architect's DATA section defines row as {month, date, opening, interest, repayment, principal, closing}, but amortise() emits rows without a date property, breaking the contract for any consumer expecting calendar dates.
- [HIGH] **NaN/Infinity slip through normalizeStrategy**: Unlike normalizeLoan, normalizeStrategy does not Number.isFinite-check its numeric inputs (extraPerPeriod, extraMonthly, offset, lumpSum, lumpMonth, interestOnlyMonths), so NaN/Infinity from callers corrupt every schedule row silently instead of throwing a clear error.
## Review Findings — Rejected (false alarms caught)
- 2 false alarms identified and dismissed by adversarial verification
## Checklist
- [x] All tests passing
- [x] Code review completed
- [x] Review findings addressed or noted
- [x] No regressionsEach architect's own diagram, pulled straight from its architecture doc.
+----------------------+ quote/compare/ +----------------------+ | index.html + ui.js | recommendStrategy | loan.mjs (pure ESM) | | sliders, SVG chart, |------------------->| simulate() core fold | | cards — zero maths |<-------------------| quote/compare/rec | +----------------------+ plain JS objects +----------------------+
Amortise period-by-period using canonical frequencies/dates; offset interest base is `max(balance-offset,0)`, and dated events apply at the first period date `>=` selected date.
API exports: `validateLoan(input)` -> canonical loan or errors.
`repaymentFor(loan)` -> scheduled repayment when amount omitted/term-driven.
`amortise(loan,strategy={})` -> rows applying recurring extras, offsets/changes, lump sums.
`quote(loan,strategy)` -> `{repayment,totalInterest,totalMonths}` summary.
`compareScenarios(loan,namedStrategies)` -> baseline vs each `{totalInterest,interestSaved,timeSaved,payoffDate}`.
`chartSeries(schedule)` -> `{balance[],cumInterest[],payoff[]}` for SVG.
`recommendStrategy(loan,targetMonths,candidates)` -> linear scan extra amounts, return smallest meeting target. `+--[ UI (Sliders/HTML) ]--+ +--[ loan.mjs ]--+ +--[ SVG Chart ]--+` `| input.oninput -> state | -> | quote(l, s) | -> | render(schedule)|` `+-------------------------+ +----------------+ +-----------------+`
+-------------+ +-------------+ | UI |<--->| loan.mjs | +-------------+ +-------------+ | localStorage (scenarios) | +---------------------------------+ Trade-offs: 1) cent-integer rounding vs BigDecimal for <500ms chart updates and ±$0.01 precision. 2) constant offset & one lump sum vs full txn history to keep engine simple and testable.
`┌─────────────┐ ┌──────────────┐ ┌─────────────┐` `│ loan.mjs │────▶│ Web Worker │────▶│ SVG Chart │` `│ (pure) │ │ (daily calc) │ │ (renderer) │` `└─────────────┘ └──────────────┘ └─────────────┘`
┌──────────────────────────────────────────────┐ │ HTML/JS UI (sliders, chart, compare view) │ │ bindInputs() ──► reactive Loan object │ ├──────────────────────────────────────────────┤ │ loan.mjs (pure functions, zero DOM) │ │ quote() compare() recommendStrategy() │ │ amortise │ applyExtra │ applyOffset │ lump │ ├──────────────────────────────────────────────┤ │ Storage: none — recomputed each interaction │ └──────────────────────────────────────────────┘
APPROACH Pure daily-interest engine in one ES module (loan.mjs); thin HTML/SVG UI debounces input and encodes full state in URL hash, with localStorage for named scenarios.
API (loan.mjs, all pure)
amortise(loan, strategy) -> schedule[] // month rows
quote(loan, strategy) -> {repayment,totalInterest,totalMonths} // summary for tiles/URL
recommendStrategy(loan, target) -> {extraMonthly,lumpSum,monthsSaved} // $1-step linear scan: smallest extra hitting targetMonths (simplest correct, exploits monotonicity)
compareScenarios(loan, strats[]) -> quote[] // drives 2-3 side-by-side tiles
applyOffset(principal, offset) -> effectivePrincipal // test seam
DATA
loan : {principal, ratePct, termYears, freq:'W'|'F'|'M', startDate}
strategy : {extraMonthly=0, offsetBalance=0, lumpSum=0, lumpMonth=0} // 0 = feature off
row : {month,date,opening,interest,repayment,principal,closing}
result : {repayment,totalInterest,totalMonths} // quote() shape
DIAGRAM
[UI+URL#s=] -50ms-> [loan.mjs] -schedule/quote-> [SVG x3 + tiles]
^ |
+------------ localStorage scenarios <---------------+
TRADE-OFFS T1 Daily correctness vs cost: precompute a day-indexed effective-principal array (P - offset - extras-so-far), then aggregate into months — keeps amortise() O(months) yet cent-exact.
T2 URL-shareable state vs payload bloat: JSON in #s= base64 + CompressionStream at save time — zero-backend portability in exchange for a one-time encode cost.
RISK Off-by-one-cent drift in daily offset/lump-sum application breaks the "matches CBA/ANZ/NAB/Westpac to the cent" AC. Mitigation: golden-test fixtures against published tables plus property tests (closing balance strictly non-increasing; Σrow.interest == result.totalInterest).