@holoscript/std 2.1.0 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +197 -0
  2. package/dist/__tests__/EconomicPrimitives.test.d.ts.map +1 -0
  3. package/dist/__tests__/EconomicTraits.test.d.ts.map +1 -0
  4. package/dist/__tests__/SimulationLabPrimitives.test.d.ts.map +1 -0
  5. package/dist/__tests__/Sprint33.test.d.ts.map +1 -0
  6. package/dist/__tests__/Sprint56.test.d.ts.map +1 -0
  7. package/dist/__tests__/collections.test.d.ts.map +1 -0
  8. package/dist/__tests__/events.test.d.ts.map +1 -0
  9. package/dist/__tests__/materials.test.d.ts.map +1 -0
  10. package/dist/__tests__/math.test.d.ts.map +1 -0
  11. package/dist/__tests__/physics.test.d.ts.map +1 -0
  12. package/dist/__tests__/spatial.test.d.ts.map +1 -0
  13. package/dist/__tests__/string-decoupled.test.d.ts.map +1 -0
  14. package/dist/__tests__/string.test.d.ts.map +1 -0
  15. package/dist/__tests__/time.test.d.ts.map +1 -0
  16. package/dist/collections.d.ts.map +1 -1
  17. package/dist/events.d.ts.map +1 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +42 -9
  20. package/dist/materials.d.ts.map +1 -0
  21. package/dist/math.d.ts.map +1 -1
  22. package/dist/physics.d.ts.map +1 -0
  23. package/dist/spatial.d.ts.map +1 -0
  24. package/dist/string.d.ts.map +1 -1
  25. package/dist/time.d.ts.map +1 -1
  26. package/dist/traits/ARTraits.d.ts.map +1 -0
  27. package/dist/traits/EconomicPrimitives.d.ts.map +1 -0
  28. package/dist/traits/EconomicTraits.d.ts.map +1 -0
  29. package/dist/traits/IoTTraits.d.ts.map +1 -0
  30. package/dist/traits/SimulationLabPrimitives.d.ts.map +1 -0
  31. package/dist/traits/SimulationLabTraits.d.ts.map +1 -0
  32. package/dist/traits/VRRTraits.d.ts.map +1 -0
  33. package/dist/types.d.ts.map +1 -1
  34. package/package.json +17 -6
  35. package/src/__tests__/EconomicPrimitives.test.ts +690 -0
  36. package/src/__tests__/EconomicTraits.test.ts +425 -0
  37. package/src/__tests__/SimulationLabPrimitives.test.ts +415 -0
  38. package/src/__tests__/Sprint33.test.ts +1301 -0
  39. package/src/__tests__/Sprint56.test.ts +1070 -0
  40. package/src/__tests__/collections.test.ts +182 -0
  41. package/src/__tests__/events.test.ts +135 -0
  42. package/src/__tests__/materials.test.ts +84 -0
  43. package/src/__tests__/math.test.ts +246 -0
  44. package/src/__tests__/physics.test.ts +83 -0
  45. package/src/__tests__/spatial.test.ts +333 -0
  46. package/src/__tests__/string-decoupled.test.ts +16 -0
  47. package/src/__tests__/string.test.ts +164 -0
  48. package/src/__tests__/time.test.ts +110 -0
  49. package/src/collections.ts +8 -2
  50. package/src/events.ts +88 -0
  51. package/src/index.ts +161 -11
  52. package/src/materials.ts +109 -0
  53. package/src/math.ts +30 -14
  54. package/src/physics.ts +141 -0
  55. package/src/spatial.ts +320 -0
  56. package/src/string.basic.test.ts +14 -0
  57. package/src/string.test.ts +335 -0
  58. package/src/string.ts +19 -2
  59. package/src/time.ts +9 -10
  60. package/src/traits/ARTraits.ts +103 -0
  61. package/src/traits/EconomicPrimitives.ts +755 -0
  62. package/src/traits/EconomicTraits.ts +552 -0
  63. package/src/traits/IoTTraits.ts +102 -0
  64. package/src/traits/SimulationLabPrimitives.ts +650 -0
  65. package/src/traits/SimulationLabTraits.ts +191 -0
  66. package/src/traits/VRRTraits.ts +326 -0
  67. package/src/types.ts +47 -12
  68. package/vitest.config.ts +10 -0
  69. package/dist/collections.d.ts +0 -177
  70. package/dist/collections.js +0 -720
  71. package/dist/collections.js.map +0 -1
  72. package/dist/index.d.ts +0 -89
  73. package/dist/index.js.map +0 -1
  74. package/dist/math.d.ts +0 -162
  75. package/dist/math.js +0 -539
  76. package/dist/math.js.map +0 -1
  77. package/dist/string.d.ts +0 -208
  78. package/dist/string.js +0 -443
  79. package/dist/string.js.map +0 -1
  80. package/dist/time.d.ts +0 -215
  81. package/dist/time.js +0 -530
  82. package/dist/time.js.map +0 -1
  83. package/dist/types.d.ts +0 -193
  84. package/dist/types.js +0 -198
  85. package/dist/types.js.map +0 -1
@@ -0,0 +1,690 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ generateTxHash,
4
+ executeTrade,
5
+ calculateDepreciation,
6
+ depreciatedValue,
7
+ isDestroyed,
8
+ calculateRepairCost,
9
+ bondingCurvePrice,
10
+ bondingCurveBuyCost,
11
+ bondingCurveSellRefund,
12
+ spatialPrice,
13
+ calculateTaxRate,
14
+ calculateTaxAmount,
15
+ calculateRedistribution,
16
+ createPIDState,
17
+ updatePID,
18
+ createDualLoopPIDState,
19
+ updateDualLoopPID,
20
+ createFaucetSinkMetrics,
21
+ recordFaucet,
22
+ recordSink,
23
+ resetMetricsPeriod,
24
+ DEFAULT_DEPRECIATION,
25
+ DEFAULT_BONDING_CURVE,
26
+ DEFAULT_WEALTH_TAX,
27
+ DEFAULT_PID,
28
+ } from '../traits/EconomicPrimitives.js';
29
+ import type {
30
+ OwnershipRecord,
31
+ BondingCurveConfig,
32
+ WealthTaxConfig,
33
+ PIDConfig,
34
+ DualLoopPIDConfig,
35
+ } from '../traits/EconomicPrimitives.js';
36
+
37
+ // =============================================================================
38
+ // TRADEABLE PRIMITIVES
39
+ // =============================================================================
40
+
41
+ describe('Tradeable Primitives', () => {
42
+ describe('generateTxHash', () => {
43
+ it('should produce deterministic hash from inputs', () => {
44
+ const hash1 = generateTxHash('alice', 'bob', 100, 1000);
45
+ const hash2 = generateTxHash('alice', 'bob', 100, 1000);
46
+ expect(hash1).toBe(hash2);
47
+ });
48
+
49
+ it('should produce different hashes for different inputs', () => {
50
+ const hash1 = generateTxHash('alice', 'bob', 100, 1000);
51
+ const hash2 = generateTxHash('alice', 'carol', 100, 1000);
52
+ const hash3 = generateTxHash('alice', 'bob', 200, 1000);
53
+ expect(hash1).not.toBe(hash2);
54
+ expect(hash1).not.toBe(hash3);
55
+ });
56
+
57
+ it('should return an 8-character hex string', () => {
58
+ const hash = generateTxHash('alice', 'bob', 100, 1000);
59
+ expect(hash).toMatch(/^[0-9a-f]{8}$/);
60
+ });
61
+ });
62
+
63
+ describe('executeTrade', () => {
64
+ const baseRecord: OwnershipRecord = {
65
+ owner: 'alice',
66
+ history: [],
67
+ locked: false,
68
+ };
69
+
70
+ it('should transfer ownership to buyer', () => {
71
+ const result = executeTrade(baseRecord, 'bob', 100, Date.now());
72
+ expect(result.ok).toBe(true);
73
+ if (result.ok) {
74
+ expect(result.value.owner).toBe('bob');
75
+ }
76
+ });
77
+
78
+ it('should add transfer record to history', () => {
79
+ const result = executeTrade(baseRecord, 'bob', 100, 1000);
80
+ expect(result.ok).toBe(true);
81
+ if (result.ok) {
82
+ expect(result.value.history).toHaveLength(1);
83
+ expect(result.value.history[0].from).toBe('alice');
84
+ expect(result.value.history[0].to).toBe('bob');
85
+ expect(result.value.history[0].price).toBe(100);
86
+ expect(result.value.history[0].txHash).toBeDefined();
87
+ }
88
+ });
89
+
90
+ it('should fail if record is locked', () => {
91
+ const locked: OwnershipRecord = { ...baseRecord, locked: true };
92
+ const result = executeTrade(locked, 'bob', 100, Date.now());
93
+ expect(result.ok).toBe(false);
94
+ if (!result.ok) {
95
+ expect(result.error).toBe('TRANSFER_BLOCKED');
96
+ }
97
+ });
98
+
99
+ it('should fail for negative price', () => {
100
+ const result = executeTrade(baseRecord, 'bob', -10, Date.now());
101
+ expect(result.ok).toBe(false);
102
+ if (!result.ok) {
103
+ expect(result.error).toBe('INVALID_AMOUNT');
104
+ }
105
+ });
106
+
107
+ it('should cap history at 50 entries', () => {
108
+ const longHistory: OwnershipRecord = {
109
+ owner: 'alice',
110
+ history: Array.from({ length: 55 }, (_, i) => ({
111
+ from: `agent-${i}`,
112
+ to: `agent-${i + 1}`,
113
+ price: 10,
114
+ timestamp: i,
115
+ txHash: `hash-${i}`,
116
+ })),
117
+ locked: false,
118
+ };
119
+ const result = executeTrade(longHistory, 'bob', 100, Date.now());
120
+ expect(result.ok).toBe(true);
121
+ if (result.ok) {
122
+ expect(result.value.history.length).toBeLessThanOrEqual(50);
123
+ }
124
+ });
125
+
126
+ it('should allow zero-price trade (gift)', () => {
127
+ const result = executeTrade(baseRecord, 'bob', 0, Date.now());
128
+ expect(result.ok).toBe(true);
129
+ if (result.ok) {
130
+ expect(result.value.owner).toBe('bob');
131
+ expect(result.value.history[0].price).toBe(0);
132
+ }
133
+ });
134
+ });
135
+ });
136
+
137
+ // =============================================================================
138
+ // DEPRECIATION PRIMITIVES
139
+ // =============================================================================
140
+
141
+ describe('Depreciation Primitives', () => {
142
+ describe('calculateDepreciation', () => {
143
+ it('should return initial condition when no time has elapsed', () => {
144
+ const result = calculateDepreciation(1.0, 0.001, 0);
145
+ expect(result).toBe(1.0);
146
+ });
147
+
148
+ it('should decay over time with exponential curve', () => {
149
+ const result = calculateDepreciation(1.0, 0.001, 100);
150
+ expect(result).toBeLessThan(1.0);
151
+ expect(result).toBeGreaterThan(0.0);
152
+ // e^(-0.001 * 100) = e^(-0.1) ~ 0.9048
153
+ expect(result).toBeCloseTo(0.9048, 3);
154
+ });
155
+
156
+ it('should return initial condition for zero decay rate', () => {
157
+ const result = calculateDepreciation(1.0, 0, 1000);
158
+ expect(result).toBe(1.0);
159
+ });
160
+
161
+ it('should approach zero for large elapsed time', () => {
162
+ const result = calculateDepreciation(1.0, 0.01, 10000);
163
+ expect(result).toBeCloseTo(0, 5);
164
+ });
165
+
166
+ it('should handle negative elapsed time by returning initial', () => {
167
+ const result = calculateDepreciation(1.0, 0.001, -10);
168
+ expect(result).toBe(1.0);
169
+ });
170
+
171
+ it('should scale proportionally with initial condition', () => {
172
+ const full = calculateDepreciation(1.0, 0.001, 100);
173
+ const half = calculateDepreciation(0.5, 0.001, 100);
174
+ expect(half).toBeCloseTo(full * 0.5, 6);
175
+ });
176
+ });
177
+
178
+ describe('depreciatedValue', () => {
179
+ it('should return full value at condition 1.0', () => {
180
+ expect(depreciatedValue(1000, 1.0)).toBe(1000);
181
+ });
182
+
183
+ it('should return zero value at condition 0.0', () => {
184
+ expect(depreciatedValue(1000, 0.0)).toBe(0);
185
+ });
186
+
187
+ it('should return half value at condition 0.5', () => {
188
+ expect(depreciatedValue(1000, 0.5)).toBe(500);
189
+ });
190
+
191
+ it('should clamp condition to [0, 1]', () => {
192
+ expect(depreciatedValue(1000, 1.5)).toBe(1000);
193
+ expect(depreciatedValue(1000, -0.5)).toBe(0);
194
+ });
195
+ });
196
+
197
+ describe('isDestroyed', () => {
198
+ it('should return true when condition is at threshold', () => {
199
+ expect(isDestroyed(0.01, 0.01)).toBe(true);
200
+ });
201
+
202
+ it('should return true when condition is below threshold', () => {
203
+ expect(isDestroyed(0.005, 0.01)).toBe(true);
204
+ });
205
+
206
+ it('should return false when condition is above threshold', () => {
207
+ expect(isDestroyed(0.5, 0.01)).toBe(false);
208
+ });
209
+ });
210
+
211
+ describe('calculateRepairCost', () => {
212
+ it('should cost nothing to repair full condition item', () => {
213
+ expect(calculateRepairCost(1000, 1.0, 0.5)).toBe(0);
214
+ });
215
+
216
+ it('should cost half of value to fully repair from 0 at 0.5 multiplier', () => {
217
+ expect(calculateRepairCost(1000, 0.0, 0.5)).toBe(500);
218
+ });
219
+
220
+ it('should scale with condition deficit', () => {
221
+ const cost = calculateRepairCost(1000, 0.5, 0.5);
222
+ // deficit = 0.5, cost = 1000 * 0.5 * 0.5 = 250
223
+ expect(cost).toBe(250);
224
+ });
225
+ });
226
+
227
+ describe('DEFAULT_DEPRECIATION', () => {
228
+ it('should have reasonable default values', () => {
229
+ expect(DEFAULT_DEPRECIATION.decayRate).toBeGreaterThan(0);
230
+ expect(DEFAULT_DEPRECIATION.condition).toBe(1.0);
231
+ expect(DEFAULT_DEPRECIATION.destroyThreshold).toBeGreaterThan(0);
232
+ expect(DEFAULT_DEPRECIATION.destroyThreshold).toBeLessThan(1);
233
+ expect(DEFAULT_DEPRECIATION.repairable).toBe(true);
234
+ });
235
+ });
236
+ });
237
+
238
+ // =============================================================================
239
+ // BONDING CURVE PRIMITIVES
240
+ // =============================================================================
241
+
242
+ describe('Bonding Curve Primitives', () => {
243
+ describe('bondingCurvePrice', () => {
244
+ it('should return 0 for zero supply', () => {
245
+ expect(bondingCurvePrice(0, 1.0, 2.0, 'exponential')).toBe(0);
246
+ });
247
+
248
+ it('should return 0 for zero reserve ratio', () => {
249
+ expect(bondingCurvePrice(100, 0, 2.0, 'exponential')).toBe(0);
250
+ });
251
+
252
+ it('should increase price as supply increases (exponential)', () => {
253
+ const p1 = bondingCurvePrice(10, 1.0, 2.0, 'exponential');
254
+ const p2 = bondingCurvePrice(100, 1.0, 2.0, 'exponential');
255
+ expect(p2).toBeGreaterThan(p1);
256
+ });
257
+
258
+ it('should scale linearly for linear curve', () => {
259
+ const p1 = bondingCurvePrice(10, 1.0, 2.0, 'linear');
260
+ const p2 = bondingCurvePrice(20, 1.0, 2.0, 'linear');
261
+ expect(p2).toBeCloseTo(p1 * 2, 6);
262
+ });
263
+
264
+ it('should calculate exponential: P = R * S^(1/n)', () => {
265
+ // R=1, S=100, n=2 -> P = 1 * 100^(1/2) = 10
266
+ const price = bondingCurvePrice(100, 1.0, 2.0, 'exponential');
267
+ expect(price).toBeCloseTo(10, 6);
268
+ });
269
+
270
+ it('should calculate logarithmic: P = R * ln(S+1)', () => {
271
+ const price = bondingCurvePrice(100, 1.0, 2.0, 'logarithmic');
272
+ expect(price).toBeCloseTo(Math.log(101), 6);
273
+ });
274
+
275
+ it('should calculate sigmoid with plateau', () => {
276
+ const lowSupply = bondingCurvePrice(10, 1.0, 2.0, 'sigmoid', 100);
277
+ const highSupply = bondingCurvePrice(1000, 1.0, 2.0, 'sigmoid', 100);
278
+ // Sigmoid should plateau
279
+ expect(highSupply).toBeGreaterThan(lowSupply);
280
+ expect(highSupply).toBeLessThanOrEqual(1.0); // Bounded by R
281
+ });
282
+
283
+ it('should scale with reserve ratio', () => {
284
+ const p1 = bondingCurvePrice(100, 1.0, 2.0, 'exponential');
285
+ const p2 = bondingCurvePrice(100, 2.0, 2.0, 'exponential');
286
+ expect(p2).toBeCloseTo(p1 * 2, 6);
287
+ });
288
+ });
289
+
290
+ describe('bondingCurveBuyCost', () => {
291
+ it('should return 0 for buying 0 amount', () => {
292
+ expect(bondingCurveBuyCost(DEFAULT_BONDING_CURVE, 0)).toBe(0);
293
+ });
294
+
295
+ it('should increase with amount bought', () => {
296
+ const config: BondingCurveConfig = {
297
+ ...DEFAULT_BONDING_CURVE,
298
+ reserveRatio: 1.0,
299
+ currentSupply: 10,
300
+ };
301
+ const cost1 = bondingCurveBuyCost(config, 1);
302
+ const cost10 = bondingCurveBuyCost(config, 10);
303
+ expect(cost10).toBeGreaterThan(cost1);
304
+ });
305
+
306
+ it('should include transaction fee', () => {
307
+ const noFee: BondingCurveConfig = {
308
+ ...DEFAULT_BONDING_CURVE,
309
+ reserveRatio: 1.0,
310
+ currentSupply: 10,
311
+ transactionFee: 0,
312
+ };
313
+ const withFee: BondingCurveConfig = {
314
+ ...noFee,
315
+ transactionFee: 0.02,
316
+ };
317
+ const costNoFee = bondingCurveBuyCost(noFee, 5);
318
+ const costWithFee = bondingCurveBuyCost(withFee, 5);
319
+ expect(costWithFee).toBeGreaterThan(costNoFee);
320
+ expect(costWithFee).toBeCloseTo(costNoFee * 1.02, 1);
321
+ });
322
+ });
323
+
324
+ describe('bondingCurveSellRefund', () => {
325
+ it('should return 0 for selling 0 amount', () => {
326
+ expect(bondingCurveSellRefund(DEFAULT_BONDING_CURVE, 0)).toBe(0);
327
+ });
328
+
329
+ it('should return 0 when selling more than supply', () => {
330
+ const config: BondingCurveConfig = {
331
+ ...DEFAULT_BONDING_CURVE,
332
+ currentSupply: 5,
333
+ };
334
+ expect(bondingCurveSellRefund(config, 10)).toBe(0);
335
+ });
336
+
337
+ it('should return less than buy cost (spread from fees)', () => {
338
+ const config: BondingCurveConfig = {
339
+ ...DEFAULT_BONDING_CURVE,
340
+ reserveRatio: 1.0,
341
+ currentSupply: 100,
342
+ transactionFee: 0.02,
343
+ };
344
+ const sellRefund = bondingCurveSellRefund(config, 10);
345
+ // Same config but from supply=90 for buy comparison
346
+ const buyConfig = { ...config, currentSupply: 90 };
347
+ const buyCost = bondingCurveBuyCost(buyConfig, 10);
348
+ expect(sellRefund).toBeLessThan(buyCost);
349
+ });
350
+ });
351
+
352
+ describe('spatialPrice', () => {
353
+ it('should return base price when distance factor is 0', () => {
354
+ expect(spatialPrice(100, 50, 0)).toBe(100);
355
+ });
356
+
357
+ it('should return base price when distance is 0', () => {
358
+ expect(spatialPrice(100, 0, 0.01)).toBe(100);
359
+ });
360
+
361
+ it('should increase price with distance', () => {
362
+ const near = spatialPrice(100, 10, 0.01);
363
+ const far = spatialPrice(100, 100, 0.01);
364
+ expect(far).toBeGreaterThan(near);
365
+ });
366
+
367
+ it('should calculate: P_spatial = P_bonding * (1 + factor * distance)', () => {
368
+ const result = spatialPrice(100, 50, 0.01);
369
+ // 100 * (1 + 0.01 * 50) = 100 * 1.5 = 150
370
+ expect(result).toBeCloseTo(150, 6);
371
+ });
372
+ });
373
+ });
374
+
375
+ // =============================================================================
376
+ // TAXABLE WEALTH PRIMITIVES
377
+ // =============================================================================
378
+
379
+ describe('Taxable Wealth Primitives', () => {
380
+ describe('calculateTaxRate', () => {
381
+ it('should return 0 for wealth below threshold', () => {
382
+ expect(calculateTaxRate(5000, 10000, 0.01, 0.05)).toBe(0);
383
+ });
384
+
385
+ it('should return 0 for wealth equal to threshold', () => {
386
+ expect(calculateTaxRate(10000, 10000, 0.01, 0.05)).toBe(0);
387
+ });
388
+
389
+ it('should return positive rate for wealth above threshold', () => {
390
+ const rate = calculateTaxRate(20000, 10000, 0.01, 0.05);
391
+ // log(20000/10000) * 0.01 = log(2) * 0.01 ~ 0.00693
392
+ expect(rate).toBeGreaterThan(0);
393
+ expect(rate).toBeCloseTo(Math.log(2) * 0.01, 4);
394
+ });
395
+
396
+ it('should cap at max effective rate', () => {
397
+ const rate = calculateTaxRate(10000000, 100, 0.1, 0.05);
398
+ expect(rate).toBe(0.05);
399
+ });
400
+
401
+ it('should handle zero threshold', () => {
402
+ expect(calculateTaxRate(1000, 0, 0.01, 0.05)).toBe(0);
403
+ });
404
+
405
+ it('should increase with wealth (logarithmic, W.034)', () => {
406
+ const r1 = calculateTaxRate(20000, 10000, 0.01, 0.05);
407
+ const r2 = calculateTaxRate(100000, 10000, 0.01, 0.05);
408
+ expect(r2).toBeGreaterThan(r1);
409
+ });
410
+ });
411
+
412
+ describe('calculateTaxAmount', () => {
413
+ it('should return 0 for wealth below threshold', () => {
414
+ const amount = calculateTaxAmount(5000, DEFAULT_WEALTH_TAX);
415
+ expect(amount).toBe(0);
416
+ });
417
+
418
+ it('should return positive amount for wealth above threshold', () => {
419
+ const config: WealthTaxConfig = {
420
+ ...DEFAULT_WEALTH_TAX,
421
+ threshold: 10000,
422
+ baseRate: 0.01,
423
+ };
424
+ const amount = calculateTaxAmount(50000, config);
425
+ expect(amount).toBeGreaterThan(0);
426
+ });
427
+ });
428
+
429
+ describe('calculateRedistribution', () => {
430
+ it('should split tax between redistribution and burn', () => {
431
+ const result = calculateRedistribution(1000, 10, 0.7);
432
+ expect(result.perRecipient).toBeCloseTo(70, 6); // 700 / 10
433
+ expect(result.burned).toBeCloseTo(300, 6);
434
+ });
435
+
436
+ it('should burn everything when no recipients', () => {
437
+ const result = calculateRedistribution(1000, 0, 0.7);
438
+ expect(result.perRecipient).toBe(0);
439
+ expect(result.burned).toBe(1000);
440
+ });
441
+
442
+ it('should handle zero tax collected', () => {
443
+ const result = calculateRedistribution(0, 10, 0.7);
444
+ expect(result.perRecipient).toBe(0);
445
+ expect(result.burned).toBe(0);
446
+ });
447
+
448
+ it('should burn all when redistribution fraction is 0', () => {
449
+ const result = calculateRedistribution(1000, 10, 0);
450
+ expect(result.perRecipient).toBe(0);
451
+ expect(result.burned).toBe(1000);
452
+ });
453
+
454
+ it('should redistribute all when fraction is 1.0', () => {
455
+ const result = calculateRedistribution(1000, 10, 1.0);
456
+ expect(result.perRecipient).toBeCloseTo(100, 6);
457
+ expect(result.burned).toBeCloseTo(0, 6);
458
+ });
459
+ });
460
+ });
461
+
462
+ // =============================================================================
463
+ // PID CONTROLLER PRIMITIVES
464
+ // =============================================================================
465
+
466
+ describe('PID Controller Primitives', () => {
467
+ describe('createPIDState', () => {
468
+ it('should initialize with zero state', () => {
469
+ const state = createPIDState();
470
+ expect(state.previousError).toBe(0);
471
+ expect(state.integral).toBe(0);
472
+ expect(state.lastOutput).toBe(0);
473
+ expect(state.initialized).toBe(false);
474
+ });
475
+ });
476
+
477
+ describe('updatePID', () => {
478
+ const config: PIDConfig = {
479
+ kp: 1.0,
480
+ ki: 0.1,
481
+ kd: 0.01,
482
+ setpoint: 100,
483
+ outputMin: -1.0,
484
+ outputMax: 1.0,
485
+ integralLimit: 10.0,
486
+ };
487
+
488
+ it('should produce positive output when measurement is below setpoint', () => {
489
+ const state = createPIDState();
490
+ const output = updatePID(config, state, 50, 1.0);
491
+ expect(output).toBeGreaterThan(0);
492
+ });
493
+
494
+ it('should produce negative output when measurement is above setpoint', () => {
495
+ const state = createPIDState();
496
+ const output = updatePID(config, state, 150, 1.0);
497
+ expect(output).toBeLessThan(0);
498
+ });
499
+
500
+ it('should produce zero output when measurement equals setpoint', () => {
501
+ const state = createPIDState();
502
+ const output = updatePID(config, state, 100, 1.0);
503
+ expect(output).toBeCloseTo(0, 6);
504
+ });
505
+
506
+ it('should clamp output to [min, max]', () => {
507
+ const state = createPIDState();
508
+ const output = updatePID(config, state, 0, 1.0);
509
+ expect(output).toBeLessThanOrEqual(config.outputMax);
510
+ expect(output).toBeGreaterThanOrEqual(config.outputMin);
511
+ });
512
+
513
+ it('should return last output for dt <= 0', () => {
514
+ const state = createPIDState();
515
+ state.lastOutput = 0.5;
516
+ const output = updatePID(config, state, 50, 0);
517
+ expect(output).toBe(0.5);
518
+ });
519
+
520
+ it('should converge toward setpoint over iterations', () => {
521
+ const state = createPIDState();
522
+ let measurement = 50;
523
+ const outputs: number[] = [];
524
+
525
+ for (let i = 0; i < 20; i++) {
526
+ const output = updatePID(config, state, measurement, 0.1);
527
+ outputs.push(output);
528
+ // Simulate system responding to control output
529
+ measurement += output * 5;
530
+ }
531
+
532
+ // Measurement should approach setpoint
533
+ expect(measurement).toBeGreaterThan(50);
534
+ });
535
+
536
+ it('should apply anti-windup to integral term', () => {
537
+ const state = createPIDState();
538
+ // Drive integral high by persistent error
539
+ for (let i = 0; i < 1000; i++) {
540
+ updatePID(config, state, 0, 1.0);
541
+ }
542
+ expect(Math.abs(state.integral)).toBeLessThanOrEqual(config.integralLimit);
543
+ });
544
+ });
545
+
546
+ describe('Dual-Loop PID', () => {
547
+ it('should create initial state with multiplier 1.0', () => {
548
+ const state = createDualLoopPIDState();
549
+ expect(state.faucetMultiplier).toBe(1.0);
550
+ });
551
+
552
+ it('should decrease faucet multiplier when supply is above target', () => {
553
+ const config: DualLoopPIDConfig = {
554
+ innerLoop: { ...DEFAULT_PID },
555
+ outerLoop: { ...DEFAULT_PID, kp: 0.3, ki: 0.005, kd: 0.05 },
556
+ targetMoneySupply: 100000,
557
+ activePlayerCount: 100,
558
+ perCapitaTarget: 1000,
559
+ };
560
+ const state = createDualLoopPIDState();
561
+
562
+ // Supply is double the target
563
+ const multiplier = updateDualLoopPID(config, state, 200000, 100, 1.0);
564
+ // Should reduce faucet since there is too much money
565
+ expect(multiplier).toBeLessThan(1.0);
566
+ });
567
+
568
+ it('should increase faucet multiplier when supply is below target', () => {
569
+ const config: DualLoopPIDConfig = {
570
+ innerLoop: { ...DEFAULT_PID },
571
+ outerLoop: { ...DEFAULT_PID, kp: 0.3, ki: 0.005, kd: 0.05 },
572
+ targetMoneySupply: 100000,
573
+ activePlayerCount: 100,
574
+ perCapitaTarget: 1000,
575
+ };
576
+ const state = createDualLoopPIDState();
577
+
578
+ // Supply is half the target
579
+ const multiplier = updateDualLoopPID(config, state, 50000, 100, 1.0);
580
+ expect(multiplier).toBeGreaterThan(1.0);
581
+ });
582
+
583
+ it('should keep multiplier in [0, 2] range', () => {
584
+ const config: DualLoopPIDConfig = {
585
+ innerLoop: { ...DEFAULT_PID },
586
+ outerLoop: { ...DEFAULT_PID },
587
+ targetMoneySupply: 100000,
588
+ activePlayerCount: 100,
589
+ perCapitaTarget: 1000,
590
+ };
591
+ const state = createDualLoopPIDState();
592
+
593
+ for (let i = 0; i < 100; i++) {
594
+ const m = updateDualLoopPID(config, state, 0, 100, 1.0);
595
+ expect(m).toBeGreaterThanOrEqual(0);
596
+ expect(m).toBeLessThanOrEqual(2);
597
+ }
598
+ });
599
+ });
600
+ });
601
+
602
+ // =============================================================================
603
+ // FAUCET-SINK TRACKING
604
+ // =============================================================================
605
+
606
+ describe('Faucet-Sink Metrics', () => {
607
+ describe('createFaucetSinkMetrics', () => {
608
+ it('should initialize with ratio 1.0', () => {
609
+ const metrics = createFaucetSinkMetrics(1000);
610
+ expect(metrics.ratio).toBe(1.0);
611
+ expect(metrics.totalFaucet).toBe(0);
612
+ expect(metrics.totalSink).toBe(0);
613
+ });
614
+ });
615
+
616
+ describe('recordFaucet', () => {
617
+ it('should accumulate faucet amount', () => {
618
+ const metrics = createFaucetSinkMetrics(1000);
619
+ recordFaucet(metrics, 100);
620
+ expect(metrics.totalFaucet).toBe(100);
621
+ recordFaucet(metrics, 50);
622
+ expect(metrics.totalFaucet).toBe(150);
623
+ });
624
+
625
+ it('should update ratio when sinks exist', () => {
626
+ const metrics = createFaucetSinkMetrics(1000);
627
+ recordSink(metrics, 100);
628
+ recordFaucet(metrics, 200);
629
+ expect(metrics.ratio).toBeCloseTo(2.0, 6);
630
+ });
631
+
632
+ it('should set ratio to Infinity when no sinks', () => {
633
+ const metrics = createFaucetSinkMetrics(1000);
634
+ recordFaucet(metrics, 100);
635
+ expect(metrics.ratio).toBe(Infinity);
636
+ });
637
+ });
638
+
639
+ describe('recordSink', () => {
640
+ it('should accumulate sink amount', () => {
641
+ const metrics = createFaucetSinkMetrics(1000);
642
+ recordSink(metrics, 50);
643
+ expect(metrics.totalSink).toBe(50);
644
+ });
645
+
646
+ it('should update ratio correctly', () => {
647
+ const metrics = createFaucetSinkMetrics(1000);
648
+ recordFaucet(metrics, 100);
649
+ recordSink(metrics, 100);
650
+ expect(metrics.ratio).toBeCloseTo(1.0, 6);
651
+ });
652
+ });
653
+
654
+ describe('resetMetricsPeriod', () => {
655
+ it('should archive current ratio to history', () => {
656
+ const metrics = createFaucetSinkMetrics(1000);
657
+ recordFaucet(metrics, 200);
658
+ recordSink(metrics, 100);
659
+ resetMetricsPeriod(metrics, 2000);
660
+ expect(metrics.ratioHistory).toHaveLength(1);
661
+ expect(metrics.ratioHistory[0]).toBeCloseTo(2.0, 6);
662
+ });
663
+
664
+ it('should reset counters after period reset', () => {
665
+ const metrics = createFaucetSinkMetrics(1000);
666
+ recordFaucet(metrics, 200);
667
+ recordSink(metrics, 100);
668
+ resetMetricsPeriod(metrics, 2000);
669
+ expect(metrics.totalFaucet).toBe(0);
670
+ expect(metrics.totalSink).toBe(0);
671
+ expect(metrics.ratio).toBe(1.0);
672
+ });
673
+
674
+ it('should cap history at 100 entries', () => {
675
+ const metrics = createFaucetSinkMetrics(0);
676
+ for (let i = 0; i < 110; i++) {
677
+ recordFaucet(metrics, 100);
678
+ recordSink(metrics, 50);
679
+ resetMetricsPeriod(metrics, i + 1);
680
+ }
681
+ expect(metrics.ratioHistory.length).toBeLessThanOrEqual(100);
682
+ });
683
+
684
+ it('should not archive if no activity', () => {
685
+ const metrics = createFaucetSinkMetrics(1000);
686
+ resetMetricsPeriod(metrics, 2000);
687
+ expect(metrics.ratioHistory).toHaveLength(0);
688
+ });
689
+ });
690
+ });