← back to the talk

One sentence. 8 models. The whole experiment.

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.

The apps it built

Same brief, 8 designs. Click any to open it live. Badge = does it actually compute when you use it.

Fable 5 app screenshot✓ works
Fable 5$73.00
security clean · perf 4.9x · 1 heal
GPT-5.5 app screenshot✓ works
GPT-5.5$40.24
security hardened · perf 105.9x · 4 heal
Gemini 3.1 Pro app screenshot✓ works
Gemini 3.1 Pro$0.93
security hardened · perf 1.4x · 3 heal
MiMo v2.5 Pro app screenshot✓ works
MiMo v2.5 Pro$0.52
perf 1.6x · 2 heal
MiniMax M3 app screenshot✓ works
MiniMax M3$1.52
perf 73.6x · 3 heal
GLM-5.1 app screenshot✗ broken
GLM-5.1$2.12
security clean · perf 127.3x · 5 heal
DeepSeek V4-Pro app screenshot✗ broken
DeepSeek V4-Pro$5.50
security clean · perf 61.5x · 11 heal
Kimi K2.6 app screenshot✗ DNF
Kimi K2.6$2.52
 

…and the same models in their native agent

The 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.

GLM-5.1 app screenshot✓ works
GLM-5.1native agent
Claude Code / MiMoCode build
DeepSeek V4-Pro app screenshot✓ works
DeepSeek V4-Pronative agent
Claude Code / MiMoCode build
MiniMax M3 app screenshot✓ works
MiniMax M3native agent
Claude Code / MiMoCode build
Kimi K2.6 app screenshot✓ works
Kimi K2.6native agent
Claude Code / MiMoCode build
MiMo (Max Mode) app screenshot✓ works
MiMo (Max Mode)native agent
Claude Code / MiMoCode build

Thin harness vs thick — the same model, built two ways

Each 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.

ModelThin · API pipelineThick · native agent
GLM-5.1brokenworks ↳ fixed
Kimi K2.6DNFworks ↳ fixed
DeepSeek V4-Probrokenworks ↳ fixed
MiniMax M3worksworks
MiMo (Max Mode)worksworks
DeepSeek and GLM shipped broken apps through the thin pipeline — green tests, dead UI — yet both work perfectly in their native agent. Same model; the scaffolding made the difference.

By the numbers

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).

ModelThinkingMax out$/M in·outCostOut toktok/s*HealsBugs/falseSecurityPerfApp
Fable 5AnthropicAdaptive (always-on)128K$14.20/$71.00$73.00260K11711/4clean4.9xworks
GPT-5.5OpenAIReasoning: xhigh128K$7.10/$42.60$40.24859K10843/2hardened105.9xworks
Gemini 3.1 ProGoogleReasoning: high64K$2.84/$17.04$0.9333K1835/0hardened1.4xworks
GLM-5.1Z.AI · free on planThinking: on128K$1.99/$6.25$2.12302K7853/2clean127.3xbroken
DeepSeek V4-ProDeepSeekThink-Max384K$0.62/$1.24$5.501012K54114/1clean61.5xbroken
Kimi K2.6MoonshotThinking: 64K budget256K$1.35/$5.68$2.52DNF — thrashed in the thin pipeline (201m at PR-1, never converged); ships clean in its native agent (24/24)DNF
MiMo v2.5 ProXiaomiReasoning: high128K$0.62/$1.24$0.52340K21121.6xworks
MiniMax M3MiniMaxAdaptive512K$0.43/$1.70$1.52626K237373.6xworks

Does the premium model actually code better?

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.

Cleanest coder → Fable $73
The only engine bug-free across every test; 1 self-heal round, leanest code, and it alone named the real design risk (the weekly/fortnightly rate convention, not float drift). But all 8 nailed the core maths — the premium buys edge-case discipline, not the ability to do the job.
Sharpest reviewer → Gemini 93¢
Caught more real bugs than the $73 model — zero false alarms, twice — while Fable cried wolf (1 real / 4 false). Paying 78× more bought a cleaner draft, not better judgement.
Most tests → MiMo 52¢
The cheapest model wrote the most tests (32) and architecture reasoning in Fable's league (the non-linearity insight) — at ~1/140th the cost.
Over-thinker → GPT $40
xhigh reasoning → a 1,745-line engine, 7× everyone, ~30% defensive bloat: nine aliases for one object, a 485-line function, zero comments. Deepest reasoning, heaviest output.
Price did not predict quality. The $5.50 model ranked last on code; the 93¢ model ranked best on review. The premium buys a clean first draft and restraint — pay up where a wrong answer is genuinely costly, not by default. (One honest caveat: "real vs false" bugs were the model judging its own reviews — decisiveness, not verified accuracy.)

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.

DimensionBest →middle→ Worst
Code (the engine)FableGPTGemini 93¢MiniMax · GLM · Kimi · MiMoDeepSeek $5.50
Design & architectureFableGPTGLMMiMo · Gemini · DeepSeekMiniMax
Review judgementGemini 93¢DeepSeekGPTGLM · MiMoFable $73
Tests (QA + security)GPT $40FableMiniMaxDeepSeek · MiMo · GeminiGLM · Kimi
Requirements (BA)FableMiniMaxGemini · MiMoDeepSeek · GLMGPT $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.

Which model for which job — route by stakes

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.

Bulk & scaffolding · low stakes
MiMo 52¢ · Gemini 93¢ — boilerplate, migrations, first drafts, high-volume summarisation. A wrong answer is cheap to catch and fix.
Everyday review & analysis
Gemini 93¢ — the sharpest reviewer in the field at 1/78th the frontier price. The value default for day-to-day work.
Test & security rigour
GPT $40 — worker-thread isolation, exact-dollar anchors. Reach for it where coverage and adversarial testing matter.
Regulatory-critical · high stakes
Fable $73 — the cleanest first draft, the only bug-free engine. Pay the premium where a wrong answer is genuinely expensive: money, safety, compliance.
Scale the maths before you commit. A$0.52–A$73 is for this ~1,200-line app; cost scales roughly with codebase size and multiplies by volume. At 10,000 runs/month a 143× model choice is the gap between a rounding error and a six-figure line item — the same call applies to every AI workflow you run.

Real correctness bugs the cross-check surfaced — all 8 nailed the core maths ($3,220.93/mo); these are edge cases:

totalMonths returns periods, not months
DeepSeek, MiMo — a weekly loan reports "1560 months". Breaks any cross-frequency comparison.
Lump sum applied at t0, no timing
GLM — its lower interest is a missing feature, not a win.
repayment field includes the extra
MiniMax — misreports the contracted repayment ($4,097 vs $3,597).
Payment rounded down → 361-month overshoot
GLM, MiMo — a 360-month loan reports 361. Fable's documented cent-rounding avoids it.

Eight personalities

Same brief, same harness — but each model has a character. What's distinctive about each.

Fable 5
The disciplined senior. The only engine bug-free across every test — cleanest first draft (1 self-heal round), leanest code, and it alone named the real design risk (the weekly/fortnightly rate convention, not float drift everyone else flagged). The $73 buys discipline and judgement, not raw capability.
GPT-5.5
The over-thinker. xhigh reasoning produced a 1,745-line engine — 7× everyone else — uniquely feature-complete (interest-only re-amortisation, dated offsets) but ~30% defensive bloat: nine aliases for one object, a 485-line function, zero comments. Deepest reasoning, heaviest output, $40, and the slowest.
Gemini 3.1 Pro
The sharpest reviewer — and the value champion. Caught more real bugs than the $73 model, with zero false alarms, twice; Fable-grade maths on every path. A working app for 93¢.
GLM-5.1
The free workhorse. Free on the Z.AI plan and genuinely capable — wrote the most buildable design in the field (exact opacities, motion timings). Converges, just slowly (5 self-heal rounds).
DeepSeek V4-Pro
The maximalist. Think-Max emits enormous reasoning chains — 1M output tokens, 11 self-heal rounds — and caught the subtlest review bug of anyone (a monkey-patched Array.reduce). But messiest code, and last on price-to-quality at $5.50.
Kimi K2.6 · DNF (thin pipeline)
The thrasher. Shipped a clean app in its native agent (Claude Code, 24/24) yet never converged in the thin pipeline — looped at PR-1 for 201 minutes and DNF'd. The clearest case of harness over model.
MiMo v2.5 Pro
The bargain prodigy. 52¢, the cheapest — yet wrote the most tests (32) and architecture reasoning in Fable's league (the non-linearity insight), at ~1/140th the cost. Weak only on its design doc.
MiniMax M3
The middle. Solid maths (uniquely reports percent-saved) but the most generic design in the field and a slow linear-scan recommender. Competent, unremarkable.

By role — what the AI did for your job

Every role in the room, done by 8 models. Pick yours and see how each approached it.

The Architect designs the API & system.

▸ INPUT · the prompt this role received (identical for every model)
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:

Fable 5
**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.
GPT-5.5
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.
Gemini 3.1 Pro
**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.
GLM-5.1
**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).
DeepSeek V4-Pro
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.
MiMo v2.5 Pro
## `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.

MiniMax M3
```
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.

▸ INPUT · the prompt this role received (identical for every model)
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:

Fable 5
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.
GPT-5.5
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.
Gemini 3.1 Pro
**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.
GLM-5.1
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.
DeepSeek V4-Pro
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.
MiMo v2.5 Pro
**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.
MiniMax M3
**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.

▸ INPUT · the prompt this role received (identical for every model)
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:

Fable 5
**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.
GPT-5.5
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.
Gemini 3.1 Pro
**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.
GLM-5.1
**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.
DeepSeek V4-Pro
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.
MiMo v2.5 Pro
**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.
MiniMax M3
**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.

▸ INPUT · the prompt this role received (identical for every model)
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:

Fable 5
**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.
GPT-5.5
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.
Gemini 3.1 Pro
**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.
GLM-5.1
**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".
DeepSeek V4-Pro
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.
MiMo v2.5 Pro
**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
MiniMax M3
**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.

▸ INPUT · the prompt this role received (identical for every model)
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:

Fable 5
// 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 ||
GPT-5.5
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);
 
Gemini 3.1 Pro
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(
GLM-5.1
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
DeepSeek V4-Pro
// 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
MiMo v2.5 Pro
// 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))
MiniMax M3
// 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;
  } else

The QA writes the acceptance tests.

▸ INPUT · the prompt this role received (identical for every model)
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:

Fable 5
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 ${r
GPT-5.5
import 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 require
Gemini 3.1 Pro
import 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.rep
GLM-5.1
import 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 < 
DeepSeek V4-Pro
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.t
MiMo v2.5 Pro
import 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.
MiniMax M3
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.

▸ INPUT · the prompt this role received (identical for every model)
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:

Fable 5
// 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 },
    { inter
GPT-5.5
import 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);
  
Gemini 3.1 Pro
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)}`);
    }
GLM-5.1
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 (c
DeepSeek V4-Pro
import 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 fin
MiMo v2.5 Pro
import 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), 'tota
MiniMax M3
import 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: 3

The DevOps sets up CI.

▸ INPUT · the prompt this role received (identical for every model)
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:

Fable 5
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
GPT-5.5
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
Gemini 3.1 Pro
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
GLM-5.1
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
DeepSeek V4-Pro
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
MiMo v2.5 Pro
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
MiniMax M3
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.

▸ INPUT · the prompt this role received (identical for every model)
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:

Fable 5
# 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
GPT-5.5
# 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
Gemini 3.1 Pro
# 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
GLM-5.1
# 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
DeepSeek V4-Pro
# 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
MiMo v2.5 Pro
# 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
MiniMax M3
# 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 regressions

7 models, one system — the architectures

Each architect's own diagram, pulled straight from its architecture doc.

Fable 5
+----------------------+  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  +----------------------+
GPT-5.5
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.  
GLM-5.1
`+--[ UI (Sliders/HTML) ]--+   +--[ loan.mjs ]--+   +--[ SVG Chart ]--+`
`| input.oninput -> state  | -> | quote(l, s)    | -> | render(schedule)|`
`+-------------------------+   +----------------+   +-----------------+`
DeepSeek V4-Pro
+-------------+     +-------------+
|    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.
Kimi K2.6
`┌─────────────┐     ┌──────────────┐     ┌─────────────┐`  
`│  loan.mjs   │────▶│  Web Worker  │────▶│  SVG Chart  │`  
`│  (pure)     │     │ (daily calc) │     │  (renderer) │`  
`└─────────────┘     └──────────────┘     └─────────────┘`  
MiMo v2.5 Pro
┌──────────────────────────────────────────────┐
│  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  │
└──────────────────────────────────────────────┘
MiniMax M3
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).
Built from the real run artifacts · 7 of 8 models shipped green, 1 DNF (Kimi) · 5 apps verified working in a browser