Skip to content
This repository was archived by the owner on Mar 12, 2026. It is now read-only.

Commit 526e7ed

Browse files
authored
Opaque type arithmetic: clamp, monthly, eliminate .toDouble (#133)
* Add clamp to opaque types, replace Math.max/min wrapping patterns - PLN, Rate, Ratio gain .clamp(lo, hi) extension method - ~20 sites converted from Type(Math.max(lo, Math.min(hi, x.toDouble))) to opaque-type arithmetic: .max(), .min(), .clamp() - Fix mistyped skillDecayRate/scarringRate: Rate → Ratio (monthly increments to Ratio fields, not interest rates) - FirmEntry.drawDigitalReadiness return type Double → Ratio * Eliminate unnecessary .toDouble in opaque type arithmetic - Add Rate.monthly (annual → monthly rate conversion) - Replace PLN(x.toDouble + y.toDouble) with x + y - Replace PLN(x.toDouble * rate.toDouble / 12.0) with x * rate.monthly - Replace Rate(a.toDouble + b.toDouble + c.toDouble) with a + b + c - Replace Ratio(...toDouble * smoothing + ...toDouble * smoothing) with opaque Ratio/Rate * scalar arithmetic - GvcTrade.kahanSumBy left as-is (Kahan precision requires Double) * Use PLN arithmetic in processMortgageFlows, apply Rate.monthly * Use opaque types in processMortgageFlows defaultRate/defaultLoss
1 parent a70edc6 commit 526e7ed

15 files changed

Lines changed: 93 additions & 88 deletions

src/main/scala/sfc/agents/Banking.scala

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,7 @@ object Banking:
283283
* zero.
284284
*/
285285
def hhDepositRate(refRate: Rate)(using p: SimParams): Rate =
286-
Rate(Math.max(0.0, refRate.toDouble - p.household.depositSpread.toDouble))
286+
(refRate - p.household.depositSpread).max(Rate.Zero)
287287

288288
/** Lending rate for a bank: refRate + baseSpread + bankSpread + nplSpread +
289289
* carPenalty. Failed banks get flat refRate + FailedBankSpread.
@@ -296,7 +296,7 @@ object Banking:
296296
if bank.car.toDouble < p.banking.minCar.toDouble * CarPenaltyThreshMult then
297297
Math.max(0.0, (p.banking.minCar.toDouble * CarPenaltyThreshMult - bank.car.toDouble) * CarPenaltyScale)
298298
else 0.0
299-
Rate(refRate.toDouble + p.banking.baseSpread.toDouble + cfg.lendingSpread.toDouble + nplSpread + carPenalty)
299+
refRate + p.banking.baseSpread + cfg.lendingSpread + Rate(nplSpread + carPenalty)
300300

301301
/** Interbank rate (WIBOR proxy): deposit rate + stress × (lombard − deposit).
302302
* stress = aggNplRate / stressThreshold, clipped to [0,1].
@@ -448,7 +448,7 @@ object Banking:
448448
if b.id == absorberId then
449449
b.copy(
450450
deposits = b.deposits + PLN(addDep),
451-
loans = b.loans + PLN(Math.max(0, addLoans)),
451+
loans = b.loans + PLN(addLoans).max(PLN.Zero),
452452
govBondHoldings = b.govBondHoldings + PLN(addBonds),
453453
corpBondHoldings = b.corpBondHoldings + PLN(addCorpB),
454454
consumerLoans = b.consumerLoans + PLN(addCC),
@@ -602,7 +602,7 @@ object Banking:
602602
val perBank = banks.map: b =>
603603
if b.failed then PLN.Zero
604604
else if b.reservesAtNbp > PLN.Zero then b.reservesAtNbp * depositRate / 12.0
605-
else if b.interbankNet < PLN.Zero then PLN(b.interbankNet.toDouble.abs * lombardRate.toDouble / 12.0 * -1.0)
605+
else if b.interbankNet < PLN.Zero then -(b.interbankNet.abs * lombardRate.monthly)
606606
else PLN.Zero
607607
PerBankAmounts(perBank, PLN(perBank.map(_.toDouble).kahanSum))
608608

src/main/scala/sfc/agents/Firm.scala

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -384,7 +384,7 @@ object Firm:
384384
rng: Random,
385385
)(using p: SimParams): Decision =
386386
val pnl = computePnL(firm, w.hhAgg.marketWage, w.flows.sectorDemandMult(firm.sector.toInt), w.priceLevel, lendRate, w.month)
387-
val ready2 = Ratio(Math.min(1.0, firm.digitalReadiness.toDouble + HybridMonthlyDrDrift))
387+
val ready2 = (firm.digitalReadiness + Ratio(HybridMonthlyDrDrift)).min(Ratio.One)
388388

389389
val upCapex = computeAiCapex(firm) * HybridToFullCapexMul
390390
val upLoan = upCapex * FullAiLoanShare
@@ -553,7 +553,7 @@ object Firm:
553553
diminishing * (0.5 + competitive)
554554
if canAfford && rng.nextDouble() < digiProb then
555555
val boost = p.firm.digiInvestBoost.toDouble * diminishing
556-
val newDR = Ratio(Math.min(1.0, firm.digitalReadiness.toDouble + boost))
556+
val newDR = (firm.digitalReadiness + Ratio(boost)).min(Ratio.One)
557557
Decision.DigiInvest(pnl, digiCost, newDR)
558558
else if nc < PLN.Zero then attemptDownsize(firm, pnl, nc, workers, TechState.Traditional(_), w.hhAgg.marketWage, BankruptReason.LaborCostInsolvency)
559559
else Decision.Survive(pnl, nc)
@@ -669,7 +669,7 @@ object Firm:
669669
if p.firm.digiDrift <= Ratio.Zero then return r
670670
val f = r.firm
671671
if !isAlive(f) then return r
672-
val newDR = Ratio(Math.min(1.0, f.digitalReadiness.toDouble + p.firm.digiDrift.toDouble))
672+
val newDR = (f.digitalReadiness + p.firm.digiDrift).min(Ratio.One)
673673
r.copy(firm = f.copy(digitalReadiness = newDR))
674674

675675
/** Apply physical capital investment after firm decision. Depreciation,
@@ -813,7 +813,7 @@ object Firm:
813813
* clamped to [0, 1].
814814
*/
815815
private def effectiveShadowShare(sector: SectorIdx, cyclicalAdj: Double)(using p: SimParams): Ratio =
816-
Ratio(Math.min(1.0, p.informal.sectorShares.map(_.toDouble)(sector.toInt) + cyclicalAdj))
816+
(p.informal.sectorShares(sector.toInt) + Ratio(cyclicalAdj)).min(Ratio.One)
817817

818818
/** CIT evasion fraction for a sector — shadow share × CIT evasion rate. */
819819
private def citEvasionFrac(sector: SectorIdx, cyclicalAdj: Double)(using p: SimParams): Ratio =

src/main/scala/sfc/agents/Household.scala

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ object Household:
207207
monthlyRent = rent,
208208
skill = Ratio(skill),
209209
healthPenalty = Ratio.Zero,
210-
mpc = Ratio(Math.max(MpcFloor, Math.min(MpcCeiling, mpc))),
210+
mpc = Ratio(mpc).clamp(Ratio(MpcFloor), Ratio(MpcCeiling)),
211211
status = HhStatus.Employed(firm.id, sectorIdx, wage),
212212
socialNeighbors =
213213
if hhId < socialNetwork.length then socialNetwork(hhId).map(HhId(_)) else Array.empty[HhId],
@@ -337,7 +337,7 @@ object Household:
337337
else
338338
p.fiscal.pitBracket1Annual * p.fiscal.pitRate1.toDouble +
339339
(annualized - p.fiscal.pitBracket1Annual) * p.fiscal.pitRate2.toDouble
340-
PLN(Math.max(0.0, grossTax.toDouble - p.fiscal.pitTaxCreditAnnual.toDouble) / 12.0)
340+
(grossTax - p.fiscal.pitTaxCreditAnnual).max(PLN.Zero) / 12.0
341341

342342
/** Compute 800+ social transfer (PIT-exempt, lump-sum per child ≤ 18). */
343343
def computeSocialTransfer(numChildren: Int)(using p: SimParams): PLN =
@@ -705,14 +705,14 @@ object Household:
705705
private def applySkillDecay(hh: State, status: HhStatus)(using p: SimParams): Ratio =
706706
status match
707707
case HhStatus.Unemployed(months) if months >= p.household.scarringOnset =>
708-
hh.skill * (1.0 - p.household.skillDecayRate.toDouble)
708+
hh.skill * (Ratio.One - p.household.skillDecayRate)
709709
case _ => hh.skill
710710

711711
/** Apply health scarring for long-term unemployed (cumulative, capped). */
712712
private def applyHealthScarring(hh: State, status: HhStatus)(using p: SimParams): Ratio =
713713
status match
714714
case HhStatus.Unemployed(months) if months >= p.household.scarringOnset =>
715-
Ratio(Math.min(p.household.scarringCap.toDouble, hh.healthPenalty.toDouble + p.household.scarringRate.toDouble))
715+
(hh.healthPenalty + p.household.scarringRate).min(p.household.scarringCap)
716716
case _ => hh.healthPenalty
717717

718718
/** Fraction of social neighbors in distress (BitSet, O(k) per HH). */

src/main/scala/sfc/agents/Nbfi.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ object Nbfi:
8484

8585
/** Bank credit tightness signal: 0 at NPL ≤ 3%, rises linearly, 1.0 at 6%. */
8686
def bankTightness(bankNplRatio: Ratio): Ratio =
87-
Ratio(Math.max(0.0, Math.min(1.0, (bankNplRatio.toDouble - NplTightnessFloor) / NplTightnessRange)))
87+
Ratio((bankNplRatio.toDouble - NplTightnessFloor) / NplTightnessRange).clamp(Ratio.Zero, Ratio.One)
8888

8989
/** TFI net inflow: proportional to wage bill, modulated by excess returns. */
9090
def tfiInflow(employed: Int, wage: PLN, equityReturn: Rate, govBondYield: Rate, depositRate: Rate)(using

src/main/scala/sfc/agents/Nbp.scala

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ object Nbp:
9191

9292
/** Floor/ceiling clamp to [rateFloor, rateCeiling]. */
9393
private def clampRate(rate: Double)(using p: SimParams): Rate =
94-
Rate(Math.max(p.monetary.rateFloor.toDouble, Math.min(p.monetary.rateCeiling.toDouble, rate)))
94+
Rate(rate).clamp(p.monetary.rateFloor, p.monetary.rateCeiling)
9595

9696
/** Update NBP reference rate via Taylor rule. Symmetric (dual mandate) or
9797
* asymmetric (inflation-only) depending on flags.nbpSymmetric.
@@ -126,7 +126,7 @@ object Nbp:
126126
val fiscalRisk = Math.min(FiscalRiskCap, p.fiscal.govFiscalRiskBeta * Math.max(0.0, debtToGdp - DebtThreshold))
127127
val qeCompress = QeCompressionCoeff * nbpBondGdpShare
128128
val foreignDemand = if nfa > PLN.Zero then ForeignDemandDiscount else 0.0
129-
Rate(Math.max(0.0, refRate.toDouble + termPremium + fiscalRisk - qeCompress - foreignDemand + credibilityPremium))
129+
(refRate + Rate(termPremium + fiscalRisk - qeCompress - foreignDemand + credibilityPremium)).max(Rate.Zero)
130130

131131
// ---------------------------------------------------------------------------
132132
// QE
@@ -183,4 +183,4 @@ object Nbp:
183183
val newReserves = reserves + eurTraded
184184
val gdpEffect = if gdp > 0 then Math.abs(eurTraded) * p.forex.baseExRate / gdp else 0.0
185185
val erEffect = direction * gdpEffect * p.monetary.fxStrength.toDouble
186-
FxInterventionResult(erEffect, PLN(eurTraded), PLN(Math.max(0.0, newReserves)))
186+
FxInterventionResult(erEffect, PLN(eurTraded), PLN(newReserves).max(PLN.Zero))

src/main/scala/sfc/config/HouseholdConfig.scala

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,8 @@ case class HouseholdConfig(
110110
mpcAlpha: Double = 8.2,
111111
mpcBeta: Double = 1.8,
112112
// Skill decay & scarring
113-
skillDecayRate: Rate = Rate(0.02),
114-
scarringRate: Rate = Rate(0.02),
113+
skillDecayRate: Ratio = Ratio(0.02),
114+
scarringRate: Ratio = Ratio(0.02),
115115
scarringCap: Ratio = Ratio(0.50),
116116
scarringOnset: Int = 3,
117117
// Retraining

src/main/scala/sfc/engine/markets/CorporateBondMarket.scala

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ object CorporateBondMarket:
6363
def computeYield(govBondYield: Rate, nplRatio: Ratio)(using p: SimParams): Rate =
6464
val cyclicalSpread = p.corpBond.spread.toDouble * (1.0 + nplRatio.toDouble * NplSensitivity)
6565
val spread = Math.min(MaxSpread, cyclicalSpread)
66-
Rate(Math.max(MinYield, govBondYield.toDouble + spread))
66+
(govBondYield + Rate(spread)).max(Rate(MinYield))
6767

6868
/** @param total
6969
* total monthly coupon across all holders
@@ -132,7 +132,7 @@ object CorporateBondMarket:
132132
if aggBankCar <= minCar then MinAbsorption
133133
else if aggBankCar.toDouble >= minCar.toDouble + CarBufferZone then 1.0
134134
else MinAbsorption + (1.0 - MinAbsorption) * (aggBankCar.toDouble - minCar.toDouble) / CarBufferZone
135-
Ratio(Math.max(MinAbsorption, Math.min(1.0, spreadAbsorption * carAbsorption)))
135+
Ratio(spreadAbsorption * carAbsorption).clamp(Ratio(MinAbsorption), Ratio.One)
136136

137137
/** Process new issuance: allocate to holders proportionally. */
138138
def processIssuance(state: State, issuance: PLN)(using p: SimParams): State =
@@ -171,7 +171,7 @@ object CorporateBondMarket:
171171
ppkHoldings = (in.prev.ppkHoldings - in.prev.ppkHoldings * reductionFrac).max(PLN.Zero),
172172
otherHoldings = (in.prev.otherHoldings - in.prev.otherHoldings * reductionFrac).max(PLN.Zero),
173173
corpBondYield = newYield,
174-
creditSpread = Rate(Math.max(0.0, newYield.toDouble - in.govBondYield.toDouble)),
174+
creditSpread = (newYield - in.govBondYield).max(Rate.Zero),
175175
lastAmortization = amort,
176176
lastDefaultAmount = defaults.grossDefault,
177177
lastDefaultLoss = defaults.lossAfterRecovery,

src/main/scala/sfc/engine/markets/EquityMarket.scala

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,11 +108,11 @@ object EquityMarket:
108108

109109
// Dividend yield: payout ratio x earnings yield (mean-reverting to calibrated)
110110
val impliedDivYield = newEarningsYield.toDouble * PayoutRatio
111-
val newDivYield = Rate(in.prev.dividendYield.toDouble * (1.0 - DivYieldSmoothing) + impliedDivYield * DivYieldSmoothing)
111+
val newDivYield = in.prev.dividendYield * (1.0 - DivYieldSmoothing) + Rate(impliedDivYield * DivYieldSmoothing)
112112

113113
// Foreign ownership: slow-moving, mean-reverting to calibrated share
114114
val newForeignOwnership =
115-
Ratio(in.prev.foreignOwnership.toDouble * (1.0 - ForeignReversionSpeed) + p.equity.foreignShare.toDouble * ForeignReversionSpeed)
115+
in.prev.foreignOwnership * (1.0 - ForeignReversionSpeed) + p.equity.foreignShare * ForeignReversionSpeed
116116

117117
val mReturn = if in.prev.index > 0 then newIndex / in.prev.index - 1.0 else 0.0
118118

src/main/scala/sfc/engine/markets/HousingMarket.scala

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -286,13 +286,13 @@ object HousingMarket:
286286
if !p.flags.re || prev.mortgageStock <= PLN.Zero
287287
then MortgageFlows(PLN.Zero, PLN.Zero, PLN.Zero, PLN.Zero)
288288
else
289-
val stock = prev.mortgageStock.toDouble
290-
val interest = PLN(stock * Math.max(0.0, mortgageRate.toDouble) / 12.0)
291-
val principal = PLN(stock / p.housing.mortgageMaturity.toDouble)
292-
val defaultRate = p.housing.defaultBase.toDouble +
293-
p.housing.defaultUnempSens * Math.max(0.0, unemploymentRate.toDouble - 0.05)
294-
val defaultAmount = PLN(stock * defaultRate)
295-
val defaultLoss = defaultAmount * (1.0 - p.housing.mortgageRecovery.toDouble)
289+
val stock = prev.mortgageStock
290+
val interest = stock * mortgageRate.max(Rate.Zero).monthly
291+
val principal = stock / p.housing.mortgageMaturity.toDouble
292+
val defaultRate = p.housing.defaultBase +
293+
Ratio(p.housing.defaultUnempSens * (unemploymentRate - Ratio(0.05)).max(Ratio.Zero).toDouble)
294+
val defaultAmount = stock * defaultRate
295+
val defaultLoss = defaultAmount * (Ratio.One - p.housing.mortgageRecovery)
296296
MortgageFlows(interest, principal, defaultAmount, defaultLoss)
297297

298298
// --- Apply flows ---

src/main/scala/sfc/engine/mechanisms/FirmEntry.scala

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ object FirmEntry:
109109
tech = tech,
110110
riskProfile = Ratio(rng.between(0.1, 0.9)),
111111
innovationCostFactor = rng.between(0.8, 1.5),
112-
digitalReadiness = Ratio(dr),
112+
digitalReadiness = dr,
113113
sector = SectorIdx(newSector),
114114
neighbors = newNeighbors,
115115
bankId = newBankId,
@@ -134,10 +134,11 @@ object FirmEntry:
134134
* conventional entrants draw from sector baseline with Gaussian noise,
135135
* clamped to the feasible range for non-digital firms.
136136
*/
137-
private def drawDigitalReadiness(isAiNative: Boolean, sector: Int, rng: Random)(using p: SimParams): Double =
138-
if isAiNative then rng.between(AiNativeMinDr, AiNativeMaxDr)
137+
private def drawDigitalReadiness(isAiNative: Boolean, sector: Int, rng: Random)(using p: SimParams): Ratio =
138+
if isAiNative then Ratio(rng.between(AiNativeMinDr, AiNativeMaxDr))
139139
else
140-
Math.max(ConventionalDrFloor, Math.min(ConventionalDrCap, p.sectorDefs(sector).baseDigitalReadiness.toDouble + rng.nextGaussian() * ConventionalDrNoise))
140+
Ratio(p.sectorDefs(sector).baseDigitalReadiness.toDouble + rng.nextGaussian() * ConventionalDrNoise)
141+
.clamp(Ratio(ConventionalDrFloor), Ratio(ConventionalDrCap))
141142

142143
/** Assign network neighbors from the living firm population. */
143144
private def assignNeighbors(livingIds: Vector[Int], rng: Random): Vector[FirmId] =

0 commit comments

Comments
 (0)