frame_plugins/rewards.rs
1// SPDX-License-Identifier: MPL-2.0
2//
3// Part of Auguth Labs open-source softwares.
4// Built for the Substrate framework.
5//
6// This Source Code Form is subject to the terms of the Mozilla Public
7// License, v. 2.0. If a copy of the MPL was not distributed with this
8// file, You can obtain one at https://mozilla.org/MPL/2.0/.
9//
10// Copyright (c) 2026 Auguth Labs (OPC) Pvt Ltd, India
11
12// ===============================================================================
13// ```````````````````````````````` REWARD PLUGINS ```````````````````````````````
14// ===============================================================================
15
16//! Defines **pluggable reward models** for computing and distributing value across
17//! participants.
18//!
19//! Rewards are abstracted into two main models:
20//!
21//! ## Payout (`payout`)
22//!
23//! - Computes the **total reward value** to be distributed.
24//! - Produces a single payout amount that acts as the **source value**
25//! for downstream distribution.
26//!
27//! In this model:
28//! - Input is a scalar representing a measurable quantity (e.g., stake, era, score).
29//! - Output is a **total payout value**.
30//!
31//! Useful for scenarios where:
32//! - The system must determine **how much value is available** for distribution.
33//! - Reward generation follows configurable economic or logical rules.
34//!
35//!
36//! ## Payee (`payee`)
37//!
38//! - Distributes the computed payout among a set of participants.
39//! - Consumes the payout value and allocates it across entities.
40//!
41//! In this model:
42//! - Input is `(Payout, [(Id, Share)])`.
43//! - Output is `[(Id, Payout)]` allocations.
44//!
45//! Useful for scenarios where:
46//! - The total reward must be **split among multiple participants**.
47//! - Allocation depends on contribution, weight, or equal participation.
48//!
49//!
50//! ## Purpose
51//!
52//! Separating reward computation into `payout` and `payee` provides flexibility:
53//!
54//! - **Payout** determines *how much total value* is available.
55//! - **Payee** determines *how that value is distributed*.
56//!
57//! This separation enables:
58//! - Independent evolution of reward generation and distribution strategies.
59//! - Composable reward pipelines.
60//! - Extensibility without modifying existing models.
61
62// ===============================================================================
63// ``````````````````````````````` PAYOUT PLUGINS ````````````````````````````````
64// ===============================================================================
65
66pub use payout::*;
67
68/// Defines **pluggable payout models** for computing the total reward value
69/// from an input signal.
70///
71/// Payouts are abstracted as transformation models that convert an input
72/// quantity into a **single distributable value**.
73///
74/// ## Concept
75///
76/// - A payout model determines **how much total value** should be generated.
77/// - The computed payout acts as the **source value** for downstream distribution.
78///
79/// ## In this model:
80///
81/// - Input is a scalar representing a measurable quantity.
82/// - Output is a **single payout value**.
83///
84/// ## Purpose
85///
86/// Payout models provide flexibility in defining reward generation:
87///
88/// - Control how total rewards are computed from inputs.
89/// - Enable configurable reward policies.
90/// - Serve as the first stage in reward distribution pipelines.
91pub mod payout {
92
93 // ===============================================================================
94 // ``````````````````````````````````` IMPORTS ```````````````````````````````````
95 // ===============================================================================
96
97 // --- Core / Std ---
98 use core::ops::{Shr};
99
100 // --- FRAME Suite ---
101 use frame_suite::{
102 fixedpoint::{FixedForInteger, FixedOp, IntegerToFixed, FixedSignedCast},
103 plugin_model,
104 };
105
106 // --- Substrate primitives ---
107 use sp_runtime::{
108 traits::{Zero, One, CheckedDiv, Bounded},
109 Saturating, FixedPointNumber, Vec, FixedI128
110 };
111
112 // ===============================================================================
113 // ````````````````````````````````` ZERO-PAYOUT `````````````````````````````````
114 // ===============================================================================
115
116 plugin_model!(
117 /// A payout model that always returns zero.
118 ///
119 /// ## Use Cases
120 ///
121 /// - Disabling rewards or payouts
122 /// - Testing and benchmarking
123 /// - Placeholder model in non-monetary systems
124 name: pub ZeroPayout,
125 input: Asset,
126 bounds : [Asset: Zero],
127 compute: |_input, _context| {
128 Asset::zero()
129 }
130 );
131
132 // ===============================================================================
133 // ``````````````````````````````` CONSTANT-PAYOUT ```````````````````````````````
134 // ===============================================================================
135
136 /// Defines the configuration for the [`ConstantPayout`] model.
137 ///
138 /// This struct provides a **fixed reward value** that will be returned
139 /// for every computation performed by the model.
140 ///
141 /// **Concept**: **Constant Reward Emission**
142 ///
143 /// Unlike dynamic payout models that depend on input values or contextual
144 /// parameters, this configuration enforces a **static reward policy**.
145
146 pub struct ConstantPayoutConfig<T> {
147 /// The constant reward value returned by the model.
148 pub payout: T,
149 }
150
151 plugin_model!(
152 /// The **ConstantPayout** model returns a **fixed reward value**
153 /// regardless of the provided input.
154 ///
155 /// **Concept**: **Static Reward Model**
156 ///
157 /// This model ignores all input signals and instead produces a
158 /// deterministic output defined entirely by its configuration.
159 ///
160 /// ## Characteristics:
161 /// - **Input-agnostic**: The input value has no effect on the output.
162 /// - **Deterministic**: Always returns the same reward for a given configuration.
163 /// - **Context-driven**: Relies solely on [`ConstantPayoutConfig`] for output.
164 /// - **Zero-complexity**: No computation or aggregation involved.
165 ///
166 ///
167 /// ## Applications:
168 /// - Fixed payout systems (e.g., base rewards, participation rewards)
169 /// - Genesis or bootstrap reward distribution
170 /// - Testing pipelines where predictable output is required
171 ///
172 /// ## Use Cases
173 ///
174 /// - Bootstrap phases where all participants receive equal rewards.
175 /// - Fixed incentive systems with no dependency on performance or input.
176 /// - Testing and benchmarking deterministic payout behavior.
177 ///
178 /// ## Example:
179 /// ```ignore
180 /// let config = ConstantPayoutConfig { init_reward: 100 };
181 /// let output = ConstantPayout::compute((), Some(config));
182 /// assert_eq!(output, 100);
183 /// ```
184 name: pub ConstantPayout,
185 input: Asset,
186 context: ConstantPayoutConfig<Asset>,
187 bounds: [Asset: Copy],
188 compute: |_input, context| {
189 context.payout
190 }
191 );
192
193 // ===============================================================================
194 // `````````````````````````````` INFLATION-PAYOUT ```````````````````````````````
195 // ===============================================================================
196
197 /// Defines the configuration for the [`InflationPayout`] model.
198 ///
199 /// This struct specifies the **inflation rate** used to compute rewards
200 /// as a fraction of the input asset.
201 ///
202 /// **Concept**: **Proportional Inflation-Based Reward**
203 ///
204 /// Rewards are derived by applying a fixed fractional rate to the input,
205 /// enabling linear scaling based on the magnitude of the asset.
206 pub struct InflationPayoutConfig<F>
207 where
208 F: FixedPointNumber,
209 {
210 /// A fixed-point fraction representing the reward rate.
211 ///
212 /// Example: `0.01` represents a 1% reward.
213 pub inflation_rate: F, // fraction, e.g. 0.01 for 1%
214 }
215
216 plugin_model!(
217 /// The **InflationPayout** model computes rewards as a **fixed proportion
218 /// of the input asset**, based on a configured inflation rate.
219 ///
220 /// **Concept**: **Linear Inflation Scaling**
221 ///
222 /// The model converts the input asset into a fixed-point representation,
223 /// applies the inflation rate, and converts the result back into the
224 /// original asset type.
225 ///
226 /// ## Formula
227 ///
228 /// ```text
229 /// reward = input * inflation_rate
230 /// ```
231 ///
232 /// ## Characteristics:
233 /// - **Proportional**: Rewards scale linearly with the input value.
234 /// - **Deterministic**: Same input and rate always produce the same output.
235 /// - **Fixed-point safe**: Uses fixed-point arithmetic for precision.
236 /// - **Context-driven**: Controlled via [`InflationPayoutConfig`].
237 ///
238 /// ## Applications:
239 /// - Staking reward systems
240 /// - Inflationary token supply models
241 /// - Proportional incentive distribution
242 ///
243 /// ## Use Cases
244 ///
245 /// - Token inflation mechanisms
246 /// - Staking rewards proportional to stake
247 /// - Emission schedules with fixed percentage growth
248 ///
249 /// ## Example:
250 /// ```ignore
251 /// let config = InflationPayoutConfig {
252 /// inflation_rate: FixedU128::from_rational(1, 100)
253 /// }; // 1%
254 /// let reward = InflationPayout::compute(1_000u128, Some(config));
255 /// assert_eq!(reward, 10);
256 /// ```
257 name: pub InflationPayout,
258 input: Asset,
259 others: [FixedPoint],
260 context: InflationPayoutConfig<FixedPoint>,
261 bounds: [
262 Asset: IntegerToFixed + FixedForInteger<FixedPoint = FixedPoint>,
263 FixedPoint: FixedPointNumber,
264 ],
265 compute: |input, context| {
266 let x = input.to_fixed();
267 let inflation = context.inflation_rate;
268 let reward_fixed = inflation.saturating_mul(x);
269 Asset::from_fixed(&reward_fixed)
270 }
271 );
272
273 // ===============================================================================
274 // ```````````````````````````````` LINEAR-PAYOUT ````````````````````````````````
275 // ===============================================================================
276
277 /// Defines the configuration for the [`LinearPayout`] model.
278 ///
279 /// This struct specifies the parameters of a **linear reward function**.
280 ///
281 /// The payout is computed as a linear transformation of the input:
282 /// a scaled component plus a constant offset.
283 pub struct LinearPayoutConfig<F>
284 where
285 F: FixedPointNumber,
286 {
287 /// The scaling factor applied to the input.
288 pub slope: F,
289 /// The constant offset added to the result.
290 pub base_reward: F,
291 }
292
293 plugin_model!(
294 /// The **LinearPayout** model computes rewards using a
295 /// **linear function** of the input asset.
296 ///
297 /// **Concept**: **Linear Transformation**
298 ///
299 /// ## Formula
300 ///
301 /// ```text
302 /// reward = (slope * input) + base_reward
303 /// ```
304 ///
305 /// ## Characteristics:
306 /// - **Linear scaling**: Reward grows proportionally with input.
307 /// - **Base offset**: Ensures a minimum reward via `base_reward`.
308 /// - **Deterministic**: Same inputs and parameters yield identical results.
309 /// - **Fixed-point safe**: Uses fixed-point arithmetic for precision.
310 /// - **Context-driven**: Controlled via [`LinearPayoutConfig`].
311 ///
312 /// ## Applications:
313 /// - Staking systems with base + proportional rewards
314 /// - Incentive models with guaranteed minimum payout
315 /// - Linear emission schedules
316 /// - Reward shaping for participation-based systems
317 ///
318 /// ## Use Cases
319 ///
320 /// - Reward systems with a base incentive plus proportional scaling
321 /// - Gradual incentive curves
322 /// - Configurable emission policies
323 ///
324 /// ## Example:
325 /// ```ignore
326 /// let config = LinearPayoutConfig {
327 /// slope: FixedU128::from_rational(1, 10), // 0.1x
328 /// base_reward: FixedU128::from_integer(5),
329 /// };
330 ///
331 /// let reward = LinearPayout::compute(100u128, Some(config));
332 /// // reward = (0.1 * 100) + 5 = 15
333 /// assert_eq!(reward, 15);
334 /// ```
335 name: pub LinearPayout,
336 input: Asset,
337 others: [FixedPoint],
338 context: LinearPayoutConfig<FixedPoint>,
339 bounds: [
340 Asset: IntegerToFixed + FixedForInteger<FixedPoint = FixedPoint>,
341 FixedPoint: FixedPointNumber,
342 ],
343 compute: |input, context| {
344 let slope = context.slope;
345 let base = context.base_reward;
346 let x = input.to_fixed();
347 let reward_fixed = x.saturating_mul(slope).saturating_add(base);
348 Asset::from_fixed(&reward_fixed)
349 }
350 );
351
352 // ===============================================================================
353 // ``````````````````````````````` QUADRATIC-PAYOUT ``````````````````````````````
354 // ===============================================================================
355
356 /// Defines the configuration for the [`QuadraticPayout`] model.
357 ///
358 /// This struct specifies the coefficients of a **quadratic reward function**,
359 /// enabling non-linear reward shaping.
360 ///
361 /// **Concept**: **Quadratic Reward Curve**
362 ///
363 /// Rewards are computed using a second-degree polynomial:
364 ///
365 /// ```text
366 /// reward = (a * x^2) + (b * x) + c
367 /// ```
368 ///
369 /// This allows flexible modeling of reward behavior:
370 ///
371 /// - **Convex curve (a > 0):** Rewards accelerate as input increases
372 /// -> Encourages high participation or large stake
373 ///
374 /// - **Concave curve (a < 0):** Rewards grow sublinearly
375 /// -> Penalizes concentration, discourages dominance
376 ///
377 /// - **Linear case (a = 0):** Reduces to a linear function
378 pub struct QuadraticPayoutConfig<F>
379 where
380 F: FixedPointNumber,
381 {
382 /// Controls curvature (growth acceleration/decay)
383 pub quadratic_coeff: F,
384 /// Controls proportional scaling
385 pub linear_coeff: F,
386 /// Base reward offset
387 pub constant_term: F,
388 }
389
390 plugin_model!(
391 /// The **QuadraticPayout** model computes rewards using a **quadratic
392 /// function** of the input asset.
393 ///
394 /// **Concept**: **Second-Order Reward Transformation**
395 ///
396 /// ## Formula
397 ///
398 /// ```text
399 /// reward = (a * x^2) + (b * x) + c
400 /// ```
401 ///
402 /// ## Characteristics:
403 /// - **Non-linear scaling**: Captures accelerating or diminishing returns
404 /// - **Flexible shaping**: Controlled via three coefficients
405 /// - **Deterministic**: Same input and parameters yield identical results
406 /// - **Fixed-point safe**: Uses fixed-point arithmetic for precision
407 /// - **Context-driven**: Controlled via [`QuadraticPayoutConfig`]
408 ///
409 /// ## Applications:
410 /// - Advanced staking reward curves
411 /// - Anti-centralization incentive models
412 /// - Economic simulations and experimentation
413 /// - Reward shaping in governance systems
414 ///
415 /// ## Use Cases
416 ///
417 /// - Anti-whale reward shaping (concave curves)
418 /// - Incentivizing large contributions (convex curves)
419 /// - Flexible economic modeling beyond linear systems
420 /// - Approximation of more complex reward curves
421 ///
422 /// ## Example
423 ///
424 /// ```ignore
425 /// let config = QuadraticPayoutConfig {
426 /// quadratic_coeff: FixedU128::from_rational(1, 100), // 0.01
427 /// linear_coeff: FixedU128::from_integer(2), // 2x
428 /// constant_term: FixedU128::from_integer(10), // base reward
429 /// };
430 ///
431 /// let reward = QuadraticPayout::compute(100u128, Some(config));
432 /// // reward = (0.01 * 100^2) + (2 * 100) + 10 = 100 + 200 + 10 = 310
433 /// ```
434 name: pub QuadraticPayout,
435 input: Asset,
436 others: [FixedPoint],
437 context: QuadraticPayoutConfig<FixedPoint>,
438 bounds: [
439 Asset: IntegerToFixed + FixedForInteger<FixedPoint = FixedPoint>,
440 FixedPoint: FixedPointNumber,
441 ],
442 compute: |input, context| {
443 let a = context.quadratic_coeff;
444 let b = context.linear_coeff;
445 let c = context.constant_term;
446
447 let x = input.to_fixed();
448
449 // Compute a * x^2
450 let x_sq = x.saturating_mul(x);
451 let term_quadratic = a.saturating_mul(x_sq);
452
453 // Compute b * x
454 let term_linear = b.saturating_mul(x);
455
456 // Constant term
457 let term_constant = c;
458
459 // Combine all terms
460 let reward_fixed = term_quadratic
461 .saturating_add(term_linear)
462 .saturating_add(term_constant);
463
464 Asset::from_fixed(&reward_fixed)
465 }
466 );
467
468 // ===============================================================================
469 // ``````````````````````````````` HALVING-PAYOUT ````````````````````````````````
470 // ===============================================================================
471
472 /// Defines the configuration for the [`HalvingPayout`] model.
473 ///
474 /// This struct specifies the parameters for a **halving-based reward schedule**,
475 /// where rewards decrease exponentially over time.
476 ///
477 /// **Concept**: **Exponential Decay via Halving**
478 ///
479 /// Rewards follow a discrete exponential decay pattern:
480 ///
481 /// ```text
482 /// reward = R0 / 2^n
483 /// ```
484 ///
485 /// where:
486 /// - `R0` = initial reward
487 /// - `n` = number of halving intervals (e.g., era, epoch, or block index)
488 ///
489 /// This model is widely used in monetary systems to:
490 /// - Control long-term inflation
491 /// - Gradually reduce issuance
492 /// - Introduce scarcity over time
493 pub struct HalvingPayoutConfig<T> {
494 /// Initial reward (R0): payout when n = 0
495 pub initial_reward: T,
496 }
497
498 plugin_model!(
499 /// The **HalvingPayout** model computes rewards using a **binary
500 /// exponential decay** based on the input interval.
501 ///
502 /// **Concept**: **Discrete Halving Function**
503 ///
504 /// ## Formula
505 ///
506 /// ```text
507 /// reward = R0 / 2^n
508 /// ```
509 ///
510 /// ## Characteristics:
511 /// - **Exponential decay**: Reward halves with each increment of input
512 /// - **Deterministic**: Same input and configuration yield identical output
513 /// - **Efficient**: Uses bit shifting instead of division
514 /// - **Discrete**: Stepwise reduction per interval
515 /// - **Context-driven**: Controlled via [`HalvingPayoutConfig`]
516 ///
517 /// ## Applications:
518 /// - Blockchain issuance schedules
519 /// - Mining or staking reward decay
520 /// - Long-term economic stabilization
521 /// - Scarcity-driven incentive design
522 ///
523 /// ## Behavior
524 ///
525 /// - At `n = 0`: reward = `R0`
526 /// - At `n = 1`: reward = `R0 / 2`
527 /// - At `n = 2`: reward = `R0 / 4`
528 /// - ...
529 ///
530 /// ## Use Cases
531 ///
532 /// - Bitcoin-style emission schedules
533 /// - Deflationary tokenomics
534 /// - Long-term reward tapering
535 /// - Controlled supply issuance
536 ///
537 /// ## Example
538 ///
539 /// ```ignore
540 /// let config = HalvingPayoutConfig {
541 /// initial_reward: 100,
542 /// };
543 ///
544 /// assert_eq!(HalvingPayout::compute(0, Some(config)), 100); // 100 / 2^0
545 /// assert_eq!(HalvingPayout::compute(1, Some(config)), 50); // 100 / 2^1
546 /// assert_eq!(HalvingPayout::compute(2, Some(config)), 25); // 100 / 2^2
547 /// ```
548 name: pub HalvingPayout,
549 input: Asset, // input: halving index (n), output: reward
550 context: HalvingPayoutConfig<Asset>,
551 bounds: [
552 Asset: Copy + Shr<Output = Asset> + Zero
553 ],
554 compute: |n, context| {
555 // Special case: n = 0 -> return initial reward
556 if n.is_zero() {
557 return context.initial_reward
558 }
559
560 // Compute: R0 >> n == R0 / 2^n
561 context.initial_reward >> n
562 }
563 );
564
565 // ===============================================================================
566 // ``````````````````````````````` EXP-DECAY-PAYOUT ``````````````````````````````
567 // ===============================================================================
568
569 /// Defines the configuration for the [`ExpDecayPayout`] model.
570 ///
571 /// This struct specifies the parameters for an **exponential decay reward function**,
572 /// where rewards decrease continuously over time.
573 ///
574 /// **Concept**: **Continuous Exponential Decay**
575 ///
576 /// Rewards follow a smooth exponential decay curve:
577 ///
578 /// ```text
579 /// reward = r0 * e^(-a * x)
580 /// ```
581 ///
582 /// where:
583 /// - `r0` = initial reward
584 /// - `a` = decay constant (rate of decay, a > 0)
585 /// - `x` = input variable (e.g., time, era, or block index)
586 ///
587 /// This model enables:
588 /// - Smooth reward reduction over time
589 /// - More natural decay compared to discrete halving
590 /// - Fine-grained control over emission rate
591 pub struct ExpDecayPayoutConfig<T, F>
592 where
593 F: FixedPointNumber,
594 {
595 /// Initial reward (r0): reward at x = 0
596 pub initial_reward: T,
597
598 /// Decay constant (a): controls how fast rewards decrease
599 pub decay_constant: F,
600 }
601
602 plugin_model!(
603 /// The **ExpDecayPayout** model computes rewards using a **continuous
604 /// exponential decay** based on the input variable.
605 ///
606 /// **Concept**: **Smooth Decay Function**
607 ///
608 /// ## Formula
609 ///
610 /// ```text
611 /// reward = r0 * e^(-a * x)
612 /// ```
613 ///
614 /// ## Signed Arithmetic
615 ///
616 /// The exponent `-a * x` is always non-positive for `a >= 0` and `x >= 0`.
617 /// Unsigned fixed-point types cannot represent negative numbers, so the
618 /// negation is performed inside a concrete `FixedI128` workspace via
619 /// [`FixedSignedCast`], then projected back. This makes the model correct
620 /// for both signed and unsigned `Asset` and `FixedPoint` types.
621 ///
622 /// ## Characteristics:
623 /// - **Continuous decay**: Smooth reduction instead of stepwise halving.
624 /// - **Non-linear scaling**: Faster decay as `a` increases.
625 /// - **Deterministic**: Same input and parameters yield identical results.
626 /// - **Fixed-point safe**: Signed intermediate arithmetic via `FixedSignedCast`.
627 /// - **Works with unsigned types**: No `Neg` bound; negation in `FixedI128`.
628 /// - **Context-driven**: Controlled via [`ExpDecayPayoutConfig`].
629 ///
630 /// ## Applications:
631 /// - Emission schedules with smooth decay.
632 /// - Staking reward tapering.
633 /// - Time-based incentive reduction.
634 /// - Economic stabilization mechanisms.
635 ///
636 /// ## Use Cases
637 /// - Replacing halving with a smoother continuous decay.
638 /// - Gradual reward reduction without abrupt drops.
639 /// - Fine-tuned monetary policy control.
640 ///
641 /// ## Example
642 ///
643 /// ```ignore
644 /// let config = ExpDecayPayoutConfig {
645 /// initial_reward: 1000u128,
646 /// decay_constant: FixedU128::saturating_from_rational(1, 10), // a = 0.1
647 /// };
648 /// // x = 10: reward = 1000 * e^(-1.0) ~= 367
649 /// assert_eq!(ExpDecayPayout::compute(10u128, Some(config)), 367);
650 /// ```
651 name: pub ExpDecayPayout,
652 input: Asset, // x: time / era / block index
653 others: [FixedPoint],
654 context: ExpDecayPayoutConfig<Asset, FixedPoint>,
655 bounds: [
656 Asset: Copy + IntegerToFixed + FixedForInteger<FixedPoint = FixedPoint>,
657 FixedPoint: FixedPointNumber + FixedSignedCast<Signed = FixedI128>,
658 ],
659 compute: |x, context| {
660 // Lift input and decay constant into the signed workspace.
661 // saturated_into is infallible for FixedU64 (u64 always fits in i128)
662 // and clamps at i128::MAX for large FixedU128 values. For signed
663 // FixedPoint types it is a zero-cost identity.
664 let x_fixed: FixedPoint = x.to_fixed();
665 let x_s: FixedI128 = FixedSignedCast::saturated_into(x_fixed);
666 let a_s: FixedI128 = FixedSignedCast::saturated_into(context.decay_constant);
667
668 // a * x in signed space - always >= 0 when a >= 0 and x >= 0.
669 let ax_s: FixedI128 = a_s.saturating_mul(x_s);
670
671 // Negate to produce the exponent -a * x.
672 // saturating_sub from zero avoids any dependency on Neg being
673 // implemented for FixedPoint, which unsigned types do not satisfy.
674 let neg_ax: FixedI128 = FixedI128::zero().saturating_sub(ax_s);
675
676 // e^(-a * x), result is in (0, 1] for non-negative a and x.
677 // Concrete FixedI128::fixed_exp - no generic FixedOp bound needed.
678 // unwrap_or(zero) is a safe sentinel, overflow is not reachable here
679 // since the exponent is <= 0.
680 let exp_s: FixedI128 = FixedI128::fixed_exp(&neg_ax)
681 .unwrap_or(FixedI128::zero());
682
683 // Project the exp result back to FixedPoint.
684 // exp_s is always in (0, 1] so it is non-negative and representable
685 // in both signed and unsigned FixedPoint types.
686 let exp_fp: FixedPoint = FixedSignedCast::saturated_from(exp_s);
687
688 // reward = r0 * e^(-a * x)
689 let r0_fixed: FixedPoint = context.initial_reward.to_fixed();
690 let reward_fixed: FixedPoint = r0_fixed.saturating_mul(exp_fp);
691
692 Asset::from_fixed(&reward_fixed)
693 }
694 );
695
696
697 // ===============================================================================
698 // ``````````````````````````````` SIGMOID-PAYOUT ````````````````````````````````
699 // ===============================================================================
700
701 /// Defines the configuration for the [`SigmoidPayout`] model.
702 ///
703 /// This struct specifies the parameters for a **sigmoid (S-curve) reward function**,
704 /// enabling smooth growth and saturation behavior.
705 ///
706 /// **Concept**: **Logistic Growth Curve**
707 ///
708 /// Rewards follow a sigmoid function:
709 ///
710 /// ```text
711 /// reward = L / (1 + e^(-k(x - x0)))
712 /// ```
713 ///
714 /// where:
715 /// - `L` = maximum reward (upper bound)
716 /// - `k` = growth rate (steepness of the curve)
717 /// - `x0` = midpoint (inflection point)
718 /// - `x` = input variable (e.g., time, era, or score)
719 ///
720 /// Instead of directly specifying `k` and `x0`, this model derives them from:
721 /// - `growth_start` (a): lower percentile of growth (0 < a < 1)
722 /// - `growth_end` (b): upper percentile of growth (0 < b < 1)
723 ///
724 /// This allows intuitive configuration of the curve shape.
725 pub struct SigmoidPayoutConfig<T, F>
726 where
727 F: FixedPointNumber,
728 {
729 /// Maximum reward (L): asymptotic upper bound
730 pub max_reward: T,
731
732 /// Lower growth bound (a): fraction where growth begins (0 < a < 1)
733 pub growth_start: F,
734
735 /// Upper growth bound (b): fraction where growth saturates (0 < b < 1)
736 pub growth_end: F,
737 }
738
739 plugin_model!(
740 /// The **SigmoidPayout** model computes rewards using a **logistic
741 /// (S-shaped) function**, producing slow start, rapid growth, and
742 /// eventual saturation.
743 ///
744 /// **Concept**: **S-Curve Reward Transformation**
745 ///
746 /// ## Formula
747 ///
748 /// ```text
749 /// f(x) = L / (1 + e^(-k*(x - x0)))
750 /// ```
751 ///
752 /// where `k` and `x0` are derived from `growth_start` (a) and `growth_end` (b):
753 ///
754 /// ```text
755 /// k = logit(b) - logit(a) (always > 0 when b > a, both in (0, 1))
756 /// x0 = -logit(a) / k (midpoint; > 0 when a < 0.5)
757 /// ```
758 ///
759 /// ## Signed Arithmetic
760 ///
761 /// Even when `Asset` and `FixedPoint` are unsigned types, several
762 /// intermediate values are inherently signed:
763 ///
764 /// - `logit(a) < 0` for any `a < 0.5`.
765 /// - `x - x0 < 0` for any `x < x0` (the entire left half of the curve).
766 /// - `-k*(x - x0) < 0` for any `x > x0` (the entire right half).
767 ///
768 /// All of these are computed in a concrete `FixedI128` workspace via
769 /// [`FixedSignedCast`], then the final result (always >= 0) is projected
770 /// back. This makes the model correct for both signed and unsigned types
771 /// with no `Neg` bound on `FixedPoint`.
772 ///
773 /// ## Guard Conditions (returns zero)
774 ///
775 /// - `growth_start <= 0` or `growth_start >= 1`.
776 /// - `growth_end <= 0` or `growth_end >= 1`.
777 /// - `k == 0` (degenerate; only when `growth_start == growth_end`).
778 ///
779 /// ## Precision Note
780 ///
781 /// Fixed-point arithmetic accumulates small rounding errors across the
782 /// `ln -> k -> x0 -> exp` chain. In practice the output may be one integer
783 /// unit below the analytically exact value at certain inputs. This is
784 /// expected and inconsequential for integer rewards.
785 ///
786 /// ## Characteristics:
787 /// - **Bounded**: Reward never exceeds `max_reward`.
788 /// - **Smooth growth**: Gradual ramp-up instead of abrupt changes.
789 /// - **Works with unsigned types**: No `Neg` bound; negation in `FixedI128`.
790 /// - **Deterministic**: Same input and parameters yield identical results.
791 /// - **Context-driven**: Controlled via [`SigmoidPayoutConfig`].
792 ///
793 /// ## Applications:
794 /// - Adoption-based reward curves.
795 /// - Incentive ramp-up systems.
796 /// - Gradual onboarding rewards.
797 /// - Supply emission with saturation.
798 ///
799 /// ## Example
800 ///
801 /// ```ignore
802 /// let config = SigmoidPayoutConfig {
803 /// max_reward: 100u128,
804 /// growth_start: FixedU128::saturating_from_rational(1, 10), // 0.1
805 /// growth_end: FixedU128::saturating_from_rational(9, 10), // 0.9
806 /// };
807 /// // k ~= 4.394, x0 ~= 0.5
808 /// // f(0) = 10 (= growth_start * L, lower tail)
809 /// // f(1) = 89 (= growth_end * L, upper tail, rounded down)
810 /// assert_eq!(SigmoidPayout::compute(0u128, Some(config)), 10);
811 /// assert_eq!(SigmoidPayout::compute(1u128, Some(config)), 89);
812 /// ```
813 name: pub SigmoidPayout,
814 input: Asset,
815 others: [FixedPoint],
816 context: SigmoidPayoutConfig<Asset, FixedPoint>,
817 bounds: [
818 Asset: Copy + IntegerToFixed + FixedForInteger<FixedPoint = FixedPoint> + Zero,
819 FixedPoint: FixedPointNumber + FixedSignedCast<Signed = FixedI128>,
820 ],
821 compute: |x, context| {
822 let zero_fp = FixedPoint::zero();
823 let one_fp = FixedPoint::one();
824 let zero_s = FixedI128::zero();
825
826 let a = context.growth_start;
827 let b = context.growth_end;
828
829 // Guard: a and b must be strictly in (0, 1).
830 if a <= zero_fp || a >= one_fp || b <= zero_fp || b >= one_fp {
831 return Asset::zero();
832 }
833
834 // logit(p) = ln(p / (1 - p))
835 //
836 // The ratio p/(1-p) is always > 0 and is computed in FixedPoint space.
837 // It is promoted to FixedI128 before calling fixed_ln because the result
838 // can be negative when p < 0.5 (ratio < 1, ln < 0).
839 // Concrete FixedI128::fixed_ln - no generic FixedOp bound needed.
840 let logit = |p: FixedPoint| -> Option<FixedI128> {
841 let denom = one_fp.saturating_sub(p); // 1 - p > 0 since p < 1
842 let ratio = p.checked_div(&denom)?; // p/(1-p) > 0
843 let ratio_s: FixedI128 = FixedSignedCast::saturated_into(ratio);
844 FixedI128::fixed_ln(&ratio_s) // may be negative
845 };
846
847 let logit_a: FixedI128 = match logit(a) {
848 Some(v) => v,
849 None => return Asset::zero(),
850 };
851 let logit_b: FixedI128 = match logit(b) {
852 Some(v) => v,
853 None => return Asset::zero(),
854 };
855
856 // k = logit(b) - logit(a), always > 0 when b > a.
857 let k: FixedI128 = logit_b.saturating_sub(logit_a);
858 if k == zero_s {
859 return Asset::zero();
860 }
861
862 // x0 = -logit(a) / k
863 //
864 // The negation is required by the derivation: solving f(0) = a * L gives
865 // k * x0 = -logit(a), so x0 = -logit(a) / k. For a < 0.5, logit(a) < 0,
866 // so -logit(a) > 0 and x0 > 0. The original code omitted the negation,
867 // placing x0 at a negative value and collapsing the curve to its
868 // saturation tail at x = 0.
869 let neg_logit_a: FixedI128 = zero_s.saturating_sub(logit_a);
870 let x0: FixedI128 = match neg_logit_a.checked_div(&k) {
871 Some(v) => v,
872 None => return Asset::zero(),
873 };
874
875 // Evaluate f(x) = L / (1 + e^(-k*(x - x0))).
876 // All arithmetic stays in FixedI128 so that x - x0 can be negative
877 // when x < x0, and the negated product can be negative when x > x0.
878 let x_s: FixedI128 = FixedSignedCast::saturated_into(x.to_fixed());
879 let delta: FixedI128 = x_s.saturating_sub(x0);
880 let k_delta: FixedI128 = k.saturating_mul(delta);
881
882 // Negate via subtraction from zero
883 let neg_k_delta: FixedI128 = zero_s.saturating_sub(k_delta);
884
885 // Concrete FixedI128::fixed_exp - no generic bound needed.
886 // On extreme overflow uses max_value() so the denominator is large and
887 // the result rounds toward zero rather than producing garbage.
888 let exp_val: FixedI128 = FixedI128::fixed_exp(&neg_k_delta)
889 .unwrap_or(FixedI128::max_value());
890
891 let denom: FixedI128 = FixedI128::one().saturating_add(exp_val);
892
893 let l_s: FixedI128 = FixedSignedCast::saturated_into(context.max_reward.to_fixed());
894 let output_s: FixedI128 = l_s.checked_div(&denom).unwrap_or(zero_s);
895
896 // Project back to FixedPoint.
897 // output_s >= 0 because L >= 0 and denom >= 1; saturated_from clamps
898 // any accidental negative to zero rather than panicking.
899 let output_fp: FixedPoint = FixedSignedCast::saturated_from(output_s);
900 Asset::from_fixed(&output_fp)
901 }
902 );
903
904 // ===============================================================================
905 // ````````````````````````` INVERSE-PROPORTIONAL-PAYOUT `````````````````````````
906 // ===============================================================================
907
908 /// Defines the configuration for the [`InverseProportionalPayout`] model.
909 ///
910 /// This struct specifies the parameters for an **inverse proportional reward
911 /// function**, where rewards decrease as the input increases.
912 ///
913 /// **Concept**: **Inverse Scaling Function**
914 ///
915 /// Rewards follow an inverse relationship:
916 ///
917 /// ```text
918 /// reward = k / (x + eps)
919 /// ```
920 ///
921 /// where:
922 /// - `k` = proportionality constant (scaling factor)
923 /// - `x` = input variable (e.g., stake, time, or score)
924 /// - `eps` = small positive constant to prevent division by zero
925 ///
926 /// This model produces high rewards for small inputs and diminishing rewards
927 /// as input increases.
928 pub struct InverseProportionalConfig<F>
929 where
930 F: FixedPointNumber,
931 {
932 /// Proportionality constant (k): controls overall reward scale
933 pub k: F,
934
935 /// Small constant (eps): prevents division by zero and stabilizes
936 /// behavior near x = 0
937 pub epsilon: F,
938 }
939
940 plugin_model!(
941 /// The **InverseProportionalPayout** model computes rewards using an
942 /// **inverse function** of the input asset.
943 ///
944 /// **Concept**: **Diminishing Reward Curve**
945 ///
946 /// ## Formula
947 ///
948 /// ```text
949 /// reward = k / (x + eps)
950 /// ```
951 ///
952 /// ## Characteristics:
953 /// - **Inverse scaling**: Reward decreases as input increases
954 /// - **High initial rewards**: Small inputs yield larger rewards
955 /// - **Diminishing returns**: Larger inputs are progressively penalized
956 /// - **Stable near zero**: `eps` prevents singularities at x = 0
957 /// - **Deterministic**: Same input and parameters yield identical results
958 /// - **Fixed-point safe**: Uses fixed-point arithmetic for precision
959 /// - **Context-driven**: Controlled via [`InverseProportionalConfig`]
960 ///
961 /// ## Applications:
962 /// - Anti-whale incentive systems
963 /// - Rewarding early or small participants
964 /// - Load balancing and fairness mechanisms
965 /// - Resource pricing models
966 ///
967 /// ## Use Cases
968 ///
969 /// - Favoring smaller stakeholders over large ones
970 /// - Preventing concentration of rewards
971 /// - Incentivizing early-stage participation
972 ///
973 /// ## Example
974 ///
975 /// ```ignore
976 /// let config = InverseProportionalConfig {
977 /// k: FixedU128::from_integer(100),
978 /// epsilon: FixedU128::from_integer(1),
979 /// };
980 ///
981 /// let reward = InverseProportionalPayout::compute(9u128, Some(config));
982 /// // reward = 100 / (9 + 1) = 10
983 /// ```
984 name: pub InverseProportionalPayout,
985 input: Asset,
986 others: [FixedPoint],
987 context: InverseProportionalConfig<FixedPoint>,
988 bounds: [
989 Asset: Copy + IntegerToFixed + FixedForInteger<FixedPoint = FixedPoint>,
990 FixedPoint: Copy + FixedPointNumber,
991 ],
992 compute: |input, context| {
993 let x = input.to_fixed();
994
995 // Compute denominator: x + eps
996 let denom = x.saturating_add(context.epsilon);
997
998 // non-positive denominator produces nonsensical or undefined
999 // results; return zero as a safe fallback.
1000 if denom <= FixedPoint::zero() {
1001 return Asset::from_fixed(&FixedPoint::zero());
1002 }
1003
1004 // Compute reward: k / (x + eps)
1005 let reward = context
1006 .k
1007 .checked_div(&denom)
1008 .unwrap_or_else(|| FixedPoint::zero());
1009
1010 Asset::from_fixed(&reward)
1011 }
1012 );
1013
1014 // ===============================================================================
1015 // ````````````````````````````` LOGARITHMIC-PAYOUT ``````````````````````````````
1016 // ===============================================================================
1017
1018 /// Defines the configuration for the [`LogarithmicPayout`] model.
1019 ///
1020 /// This struct specifies the parameters for a **logarithmic reward function**,
1021 /// enabling diminishing growth as input increases.
1022 ///
1023 /// **Concept**: **Logarithmic Growth Curve**
1024 ///
1025 /// Rewards follow a logarithmic function:
1026 ///
1027 /// ```text
1028 /// reward = a * ln(b * x + c) + d
1029 /// ```
1030 ///
1031 /// where:
1032 /// - `a` = vertical scaling (controls steepness)
1033 /// - `b` = horizontal scaling (input stretch/compression)
1034 /// - `c` = horizontal shift (ensures argument > 0)
1035 /// - `d` = vertical shift (baseline reward)
1036 /// - `x` = input variable (e.g., stake, time, or score)
1037 ///
1038 /// This model produces rapid initial growth that slows over time,
1039 /// resulting in diminishing returns for larger inputs.
1040 pub struct LogarithmicConfig<F>
1041 where
1042 F: FixedPointNumber,
1043 {
1044 /// Vertical scaling (a): controls steepness of growth
1045 pub vertical_scale: F,
1046
1047 /// Horizontal scaling (b): stretches/compresses input
1048 pub horizontal_scale: F,
1049
1050 /// Horizontal shift (c): ensures ln argument remains positive
1051 pub horizontal_shift: F,
1052
1053 /// Vertical shift (d): baseline reward offset
1054 pub vertical_shift: F,
1055 }
1056
1057 plugin_model! (
1058 /// The **LogarithmicPayout** model computes rewards using a **logarithmic function**,
1059 /// producing fast initial growth followed by diminishing returns.
1060 ///
1061 /// **Concept**: **Diminishing Growth Function**
1062 ///
1063 /// ## Formula
1064 ///
1065 /// ```text
1066 /// reward = a * ln(b * x + c) + d
1067 /// ```
1068 ///
1069 /// ## Characteristics:
1070 /// - **Diminishing returns**: Growth slows as input increases
1071 /// - **High early rewards**: Strong incentives for small inputs
1072 /// - **Unbounded (slow growth)**: Continues increasing but at decreasing rate
1073 /// - **Deterministic**: Same input and parameters yield identical results
1074 /// - **Fixed-point safe**: Uses fixed-point logarithmic operations
1075 /// - **Context-driven**: Controlled via [`LogarithmicConfig`]
1076 ///
1077 /// ## Applications:
1078 /// - Rewarding early participation more than late participation
1079 /// - Anti-whale incentive systems
1080 /// - Pricing curves (e.g., bonding curves)
1081 /// - Resource allocation with diminishing utility
1082 ///
1083 /// ## Use Cases
1084 ///
1085 /// - Incentivizing early adopters
1086 /// - Preventing exponential reward growth
1087 /// - Modeling diminishing marginal returns
1088 ///
1089 /// ## Example
1090 ///
1091 /// ```ignore
1092 /// let config = LogarithmicConfig {
1093 /// vertical_scale: FixedU128::from_integer(10), // a
1094 /// horizontal_scale: FixedU128::from_integer(1), // b
1095 /// horizontal_shift: FixedU128::from_integer(1), // c
1096 /// vertical_shift: FixedU128::from_integer(0), // d
1097 /// };
1098 ///
1099 /// let reward = LogarithmicPayout::compute(9u128, Some(config));
1100 /// // reward = 10 * ln(9 + 1) ~= 10 * ln(10)
1101 /// ```
1102 name: pub LogarithmicPayout,
1103 input: Asset,
1104 others: [FixedPoint],
1105 context: LogarithmicConfig<FixedPoint>,
1106 bounds: [
1107 Asset: IntegerToFixed + FixedForInteger<FixedPoint = FixedPoint>,
1108 FixedPoint: FixedPointNumber + FixedOp
1109 ],
1110 compute: |input, context| {
1111 let x = input.to_fixed();
1112
1113 // Compute inner term: (b * x + c)
1114 let inner = context
1115 .horizontal_scale
1116 .saturating_mul(x)
1117 .saturating_add(context.horizontal_shift);
1118
1119 // Compute ln(bx + c)
1120 let ln_val = FixedOp::fixed_ln(&inner).unwrap_or(FixedPoint::zero());
1121
1122 // Compute final reward: a * ln(...) + d
1123 let reward = context
1124 .vertical_scale
1125 .saturating_mul(ln_val)
1126 .saturating_add(context.vertical_shift);
1127
1128 Asset::from_fixed(&reward)
1129 }
1130 );
1131
1132 // ===============================================================================
1133 // `````````````````````````````` FIXED-RATE-PAYOUT ``````````````````````````````
1134 // ===============================================================================
1135
1136 /// Defines the configuration for the [`FixedRatePayout`] model.
1137 ///
1138 /// This struct specifies the parameters for a **fixed-rate reward function**,
1139 /// where rewards scale linearly with the input.
1140 ///
1141 /// **Concept**: **Proportional Scaling Function**
1142 ///
1143 /// Rewards follow a simple proportional relationship:
1144 ///
1145 /// ```text
1146 /// reward = x * r
1147 /// ```
1148 ///
1149 /// where:
1150 /// - `x` = input variable (e.g., stake, balance, or score)
1151 /// - `r` = fixed rate (fraction per unit input)
1152 ///
1153 /// This model produces rewards directly proportional to the input value.
1154 pub struct FixedRateConfig<F>
1155 where
1156 F: FixedPointNumber,
1157 {
1158 /// Fixed rate (r): percentage applied to input
1159 ///
1160 /// Example: `0.01` represents 1% of input
1161 pub rate: F,
1162 }
1163
1164 plugin_model!(
1165 /// The **FixedRatePayout** model computes rewards using a **fixed
1166 /// proportional rate**, scaling linearly with the input asset.
1167 ///
1168 /// **Concept**: **Linear Proportional Function**
1169 ///
1170 /// ## Formula
1171 ///
1172 /// ```text
1173 /// reward = x * r
1174 /// ```
1175 ///
1176 /// ## Characteristics:
1177 /// - **Linear scaling**: Reward increases proportionally with input
1178 /// - **Simple and efficient**: Minimal computation required
1179 /// - **Deterministic**: Same input and rate yield identical results
1180 /// - **Fixed-point safe**: Uses fixed-point arithmetic for precision
1181 /// - **Context-driven**: Controlled via [`FixedRateConfig`]
1182 ///
1183 /// ## Applications:
1184 /// - Percentage-based rewards (e.g., staking yield)
1185 /// - Fee or commission calculations
1186 /// - Proportional incentive distribution
1187 /// - Basic inflation mechanisms
1188 ///
1189 /// ## Use Cases
1190 ///
1191 /// - Rewarding participants based on stake size
1192 /// - Applying consistent percentage returns
1193 /// - Baseline reward models for systems
1194 ///
1195 /// ## Example
1196 ///
1197 /// ```ignore
1198 /// let config = FixedRateConfig {
1199 /// rate: FixedU128::from_rational(1, 100), // 1%
1200 /// };
1201 ///
1202 /// let reward = FixedRatePayout::compute(1000u128, Some(config));
1203 /// // reward = 1000 * 0.01 = 10
1204 /// ```
1205 name: pub FixedRatePayout,
1206 input: Asset,
1207 others: [FixedPoint],
1208 context: FixedRateConfig<FixedPoint>,
1209 bounds: [
1210 Asset: IntegerToFixed + FixedForInteger<FixedPoint = FixedPoint>,
1211 FixedPoint: FixedPointNumber
1212 ],
1213 compute: |input, context| {
1214 let x = input.to_fixed();
1215 let rate = context.rate;
1216
1217 // reward = x * r (saturating to avoid overflow)
1218 let reward = x.saturating_mul(rate);
1219
1220 Asset::from_fixed(&reward)
1221 }
1222 );
1223
1224 // ===============================================================================
1225 // ````````````````````````````` FIXED-ANNUAL-PAYOUT `````````````````````````````
1226 // ===============================================================================
1227
1228 /// Defines the configuration for the [`FixedAnnualPayout`] model.
1229 ///
1230 /// This struct specifies the parameters for converting an **annual percentage
1231 /// rate (APR)** into a per-period reward.
1232 ///
1233 /// **Concept**: **Discrete Compounding Conversion**
1234 ///
1235 /// Rewards are derived by converting APR into an **effective per-period
1236 /// rate (EPR)**:
1237 ///
1238 /// ```text
1239 /// EPR = (1 + APR)^(1 / n) - 1
1240 /// reward = x * EPR
1241 /// ```
1242 ///
1243 /// where:
1244 /// - `APR` = annual percentage rate
1245 /// - `n` = number of reward intervals per year
1246 /// - `x` = input variable (e.g., stake or balance)
1247 ///
1248 /// This model ensures that rewards are distributed consistently across
1249 /// discrete time intervals while preserving the annual rate.
1250 pub struct FixedAnnualConfig<T, F>
1251 where
1252 F: FixedPointNumber,
1253 {
1254 /// Annual Percentage Rate (APR)
1255 pub apr: F,
1256
1257 /// Number of reward allocations per year (n)
1258 pub time_count: T,
1259 }
1260
1261 plugin_model!(
1262 /// The **FixedAnnualPayout** model computes rewards using a **per-period
1263 /// rate derived from an annual percentage rate (APR)**.
1264 ///
1265 /// **Concept**: **APR to Periodic Yield Conversion**
1266 ///
1267 /// ## Formula
1268 ///
1269 /// ```text
1270 /// EPR = (1 + APR)^(1 / n) - 1
1271 /// reward = x * EPR
1272 /// ```
1273 ///
1274 /// ## Characteristics:
1275 /// - **Time-aware scaling**: Converts annual rate into per-period rewards
1276 /// - **Compounding-consistent**: Preserves APR across discrete intervals
1277 /// - **Deterministic**: Same input and parameters yield identical results
1278 /// - **Fixed-point safe**: Uses fixed-point exponentiation
1279 /// - **Context-driven**: Controlled via [`FixedAnnualConfig`]
1280 ///
1281 /// ## Applications:
1282 /// - Staking reward systems with APR targets
1283 /// - Financial yield calculations
1284 /// - Periodic emission schedules
1285 /// - Interest-based incentive models
1286 ///
1287 /// ## Use Cases
1288 ///
1289 /// - Converting annual rewards into per-block or per-era payouts
1290 /// - Maintaining consistent yield across different time granularities
1291 /// - Financial modeling of compounding returns
1292 ///
1293 /// ## Example
1294 ///
1295 /// ```ignore
1296 /// let config = FixedAnnualConfig {
1297 /// apr: FixedU128::from_rational(12, 100), // 12% APR
1298 /// time_count: 12u128, // monthly payouts
1299 /// };
1300 ///
1301 /// let reward = FixedAnnualPayout::compute(1000u128, Some(config));
1302 /// // reward ~= 1000 * ((1.12)^(1/12) - 1)
1303 /// ```
1304 name: pub FixedAnnualPayout,
1305 input: Asset,
1306 others: [FixedPoint],
1307 context: FixedAnnualConfig<Asset, FixedPoint>,
1308 bounds: [
1309 Asset: Copy + IntegerToFixed + FixedForInteger<FixedPoint = FixedPoint>,
1310 FixedPoint: FixedPointNumber + FixedOp
1311 ],
1312 compute: |input, context| {
1313 let one = FixedPoint::one();
1314
1315 // guard against time_count == 0 (undefined period rate).
1316 let n = context.time_count.to_fixed();
1317 if n == FixedPoint::zero() {
1318 debug_assert!(false, "FixedAnnualPayout: time_count is zero - period rate undefined");
1319 return Asset::from_fixed(&FixedPoint::zero());
1320 }
1321
1322 // Compute base: (1 + APR)
1323 let base = one.saturating_add(context.apr);
1324
1325 // Compute exponent: 1 / n
1326 let exponent = one
1327 .checked_div(&n)
1328 .unwrap_or_else(|| FixedPoint::zero());
1329
1330 // Compute (1 + APR)^(1/n).
1331 // on overflow (None), saturate toward max rather than zero.
1332 let power = match FixedOp::fixed_pow(&base, &exponent) {
1333 Some(v) => v,
1334 None => {
1335 debug_assert!(false, "FixedAnnualPayout: fixed_pow overflowed - saturating to max");
1336 FixedPoint::max_value()
1337 }
1338 };
1339
1340 // EPR = (1 + APR)^(1/n) - 1
1341 let epr = power.saturating_sub(one);
1342
1343 // reward = x * EPR
1344 let x = input.to_fixed();
1345 let reward = x.saturating_mul(epr);
1346
1347 Asset::from_fixed(&reward)
1348 }
1349 );
1350
1351 // ===============================================================================
1352 // ``````````````````````````````` PIECEWISE-PAYOUT ``````````````````````````````
1353 // ===============================================================================
1354
1355 /// Defines parameters for a **logistic (sigmoid) curve** used in piecewise reward modeling.
1356 ///
1357 /// **Concept**: **Logistic Curve Parameterization**
1358 ///
1359 /// The curve follows:
1360 ///
1361 /// ```text
1362 /// f(x) = L / (1 + e^(-k*(x - x0)))
1363 /// ```
1364 ///
1365 /// where:
1366 /// - `L` = maximum value (upper asymptote)
1367 /// - `k` = steepness (growth rate; positive for a growing S-curve)
1368 /// - `x0` = midpoint / inflection point
1369 ///
1370 /// ## Type Requirements
1371 ///
1372 /// `FixedPoint` must implement [`FixedSignedCast<Signed = FixedI128>`].
1373 /// Both unsigned and signed fixed-point types satisfy this bound. Signed
1374 /// arithmetic (`x - x0` when x < x0, and the negated exponent) is performed
1375 /// in the concrete `FixedI128` workspace and projected back.
1376 pub struct CurveParams<F>
1377 where
1378 F: FixedPointNumber,
1379 {
1380 /// Maximum reward level (L): upper asymptotic bound of the curve
1381 pub l: F,
1382
1383 /// Steepness factor (k): controls how sharply the curve transitions.
1384 ///
1385 /// - k > 0: growing S-curve (output increases with x)
1386 /// - k < 0: decaying S-curve (output decreases with x)
1387 /// - k = 0: constant L/2 for all inputs
1388 pub k: F,
1389
1390 /// Inflection point (x0): input value where the curve is steepest.
1391 ///
1392 /// At x = x0: f(x0) = L / 2.
1393 pub x0: F,
1394 }
1395
1396 impl<FixedPoint> CurveParams<FixedPoint>
1397 where
1398 FixedPoint: FixedPointNumber + FixedSignedCast<Signed = FixedI128>,
1399 {
1400 /// Evaluates the logistic curve at input `x`.
1401 ///
1402 /// **Formula**
1403 ///
1404 /// ```text
1405 /// f(x) = L / (1 + e^(-k*(x - x0)))
1406 /// ```
1407 ///
1408 /// Signed intermediates (`x - x0`, `k*(x - x0)`, and its negation) are
1409 /// computed in `FixedI128` via [`FixedSignedCast`] so the method works
1410 /// correctly for both signed and unsigned `FixedPoint` types..
1411 pub fn evaluate(&self, x: FixedPoint) -> FixedPoint {
1412 let zero_s = FixedI128::zero();
1413
1414 // Lift all operands into the signed workspace.
1415 // saturated_into is infallible for FixedU64 and clamps at i128::MAX
1416 // for large FixedU128 values; for signed types it is a zero-cost identity.
1417 let x_s: FixedI128 = FixedSignedCast::saturated_into(x);
1418 let x0_s: FixedI128 = FixedSignedCast::saturated_into(self.x0);
1419 let k_s: FixedI128 = FixedSignedCast::saturated_into(self.k);
1420 let l_s: FixedI128 = FixedSignedCast::saturated_into(self.l);
1421
1422 // Compute x - x0; negative for any x left of the inflection point.
1423 // The old code used unsigned saturating_sub, which clamped to 0 here
1424 // and made every point left of x0 behave as if x == x0.
1425 let diff_s: FixedI128 = x_s.saturating_sub(x0_s);
1426
1427 // Compute -k*(x - x0).
1428 // k_s * diff_s is negative on the left half (diff_s < 0, k_s > 0)
1429 // and on the right half with a decaying curve (diff_s > 0, k_s < 0).
1430 // Negate via subtraction from zero - no .neg() on FixedPoint needed.
1431 let k_diff_s: FixedI128 = k_s.saturating_mul(diff_s);
1432 let neg_k_diff_s: FixedI128 = zero_s.saturating_sub(k_diff_s);
1433
1434 // Compute e^(-k*(x - x0)).
1435 // Concrete FixedI128::fixed_exp - handles negative arguments correctly.
1436 // On overflow use max_value() so the denominator is large and the
1437 // result approaches 0, which is the correct limit for a large exponent.
1438 let exp_s: FixedI128 = FixedI128::fixed_exp(&neg_k_diff_s)
1439 .unwrap_or(FixedI128::max_value());
1440
1441 // Compute denominator: 1 + e^(...)
1442 let denom_s: FixedI128 = FixedI128::one().saturating_add(exp_s);
1443
1444 // Final logistic value: L / denom
1445 let output_s: FixedI128 = l_s.checked_div(&denom_s).unwrap_or(zero_s);
1446
1447
1448 // Project back to FixedPoint.
1449 // output_s >= 0 always (L >= 0, denom >= 1); saturated_from clamps
1450 // any accidental negative to 0 rather than panicking.
1451 FixedSignedCast::saturated_from(output_s)
1452 }
1453 }
1454
1455 /// Defines a **piecewise segment** used in [`PiecewisePayout`].
1456 ///
1457 /// **Concept**: **Segmented Function Composition**
1458 ///
1459 /// Each segment defines a function over a specific input interval `[start_x, end_x]`.
1460 /// The overall payout function is constructed by combining multiple segments.
1461 pub enum Segment<F>
1462 where
1463 F: FixedPointNumber,
1464 {
1465 /// Linear segment using interpolation between two points.
1466 ///
1467 /// **Concept**: **Linear Interpolation**
1468 ///
1469 /// ```text
1470 /// f(x) = y1 + ((x - x1) / (x2 - x1)) * (y2 - y1)
1471 /// ```
1472 ///
1473 /// Both increasing (`y2 > y1`) and decreasing (`y2 < y1`) segments are
1474 /// supported for signed and unsigned types. The signed delta `y2 - y1`
1475 /// is computed in `FixedI128` via [`FixedSignedCast`].
1476 Linear {
1477 /// Starting input value (x1)
1478 start_x: F,
1479 /// Ending input value (x2)
1480 end_x: F,
1481 /// Output at start_x (y1)
1482 start_y: F,
1483 /// Output at end_x (y2)
1484 end_y: F,
1485 },
1486
1487 /// Curve-based segment using a parameterized logistic function.
1488 ///
1489 /// **Concept**: **Parameterized Curve Segment**
1490 ///
1491 /// Uses [`CurveParams`] to evaluate:
1492 ///
1493 /// ```text
1494 /// f(x) = L / (1 + e^(-k*(x - x0)))
1495 /// ```
1496 ///
1497 /// Works correctly for both signed and unsigned `FixedPoint` types.
1498 Curve {
1499 /// Starting input value for the segment
1500 start_x: F,
1501 /// Ending input value for the segment
1502 end_x: F,
1503 /// Curve parameters defining shape and behavior
1504 params: CurveParams<F>,
1505 },
1506 }
1507
1508 /// Defines the configuration for the [`PiecewisePayout`] model.
1509 ///
1510 /// **Concept**: **Piecewise Curve Composition**
1511 ///
1512 /// The reward function is:
1513 ///
1514 /// ```text
1515 /// reward(x) = f_i(x), if x is in [x_i_start, x_i_end]
1516 /// 0, if x matches no segment
1517 /// ```
1518 pub struct PiecewiseConfig<F>
1519 where
1520 F: FixedPointNumber,
1521 {
1522 /// Ordered list of segments defining the piecewise function.
1523 ///
1524 /// The first matching segment is used; segments may overlap without error.
1525 pub segments: Vec<Segment<F>>,
1526 }
1527
1528 plugin_model!(
1529 /// The **PiecewisePayout** model computes rewards using a **piecewise-defined function**,
1530 /// selecting different behaviors depending on the input range.
1531 ///
1532 /// **Concept**: **Segmented Reward Function**
1533 ///
1534 /// ## Formula
1535 ///
1536 /// ```text
1537 /// reward(x) = f_i(x), for the first segment i where x is in [start_x_i, end_x_i]
1538 /// 0, if no segment matches
1539 /// ```
1540 ///
1541 /// ## Segment Types
1542 ///
1543 /// - **Linear**: `y = start_y + t * (end_y - start_y)` where `t = (x - x1) / (x2 - x1)`.
1544 /// Supports both increasing and decreasing slopes for signed and unsigned types.
1545 /// - **Curve**: `y = L / (1 + e^(-k*(x - x0)))`.
1546 /// Works correctly for unsigned types; signed arithmetic handled via `FixedI128`.
1547 ///
1548 /// ## Signed Arithmetic
1549 ///
1550 /// Two sub-computations require signed intermediates that unsigned fixed-point
1551 /// cannot represent:
1552 ///
1553 /// - **Curve segments**: `x - x0` is negative for `x < x0` (the left sigmoid tail).
1554 /// The old code clamped this to 0 via unsigned `saturating_sub`, flattening the
1555 /// entire left half of every curve segment.
1556 /// - **Decreasing linear segments**: `end_y - start_y` is negative when `end_y < start_y`.
1557 /// The old code clamped to 0, turning every ramp-down into a flat line.
1558 ///
1559 /// Both are now computed in concrete `FixedI128` via [`FixedSignedCast`].
1560 ///
1561 /// ## Characteristics:
1562 /// - **Composable**: Combine Linear and Curve segments freely.
1563 /// - **Works with unsigned types**: No `Neg` bound, signed arithmetic in `FixedI128`.
1564 /// - **Deterministic**: Same input and configuration yield identical output.
1565 /// - **Context-driven**: Controlled via [`PiecewiseConfig`].
1566 ///
1567 /// ## Applications:
1568 /// - Multi-phase emission schedules.
1569 /// - Bootstrap, growth, and stabilization curves.
1570 /// - DeFi incentive design.
1571 /// - Tiered reward systems.
1572 ///
1573 /// ## Example
1574 ///
1575 /// ```ignore
1576 /// // Linear ramp: x in [0, 10] maps reward from 0 to 100.
1577 /// let config = PiecewiseConfig {
1578 /// segments: vec![
1579 /// Segment::Linear {
1580 /// start_x: FixedU128::zero(),
1581 /// end_x: FixedU128::saturating_from_integer(10),
1582 /// start_y: FixedU128::zero(),
1583 /// end_y: FixedU128::saturating_from_integer(100),
1584 /// }
1585 /// ],
1586 /// };
1587 /// assert_eq!(PiecewisePayout::compute(5u128, Some(config)), 50);
1588 /// ```
1589 name: pub PiecewisePayout,
1590 input: Asset,
1591 others: [FixedPoint],
1592 context: PiecewiseConfig<FixedPoint>,
1593 bounds: [
1594 Asset: Copy + IntegerToFixed + FixedForInteger<FixedPoint = FixedPoint> + Zero,
1595 FixedPoint: FixedPointNumber + FixedSignedCast<Signed = FixedI128>,
1596 ],
1597 compute: |input, context| {
1598 let zero_fp = FixedPoint::zero();
1599 let _zero_s = FixedI128::zero();
1600 let x: FixedPoint = input.to_fixed();
1601
1602 // Find the first segment whose range contains x.
1603 // Segment bounds are stored as FixedPoint values; for unsigned types
1604 // they are always >= 0, and x (from a non-negative Asset) is also >= 0,
1605 // so the comparisons are correct without any signed-space conversion.
1606 let seg = context.segments.iter().find(|seg| match seg {
1607 Segment::Linear { start_x, end_x, .. } => x >= *start_x && x <= *end_x,
1608 Segment::Curve { start_x, end_x, .. } => x >= *start_x && x <= *end_x,
1609 });
1610
1611 let Some(segment) = seg else {
1612 return Asset::zero();
1613 };
1614
1615 match segment {
1616 Segment::Linear { start_x, end_x, start_y, end_y } => {
1617 let width = end_x.saturating_sub(*start_x);
1618
1619 // Degenerate segment (zero width): return start_y unchanged.
1620 if width == zero_fp {
1621 return Asset::from_fixed(start_y);
1622 }
1623
1624 // Compute t = (x - start_x) / (end_x - start_x), always in [0, 1].
1625 // x >= start_x is guaranteed by the segment match above, so
1626 // saturating_sub is safe in unsigned space here.
1627 let t: FixedPoint = x
1628 .saturating_sub(*start_x)
1629 .checked_div(&width)
1630 .unwrap_or(zero_fp);
1631
1632 // Compute delta_y = end_y - start_y in signed space.
1633 // This value is negative for any decreasing segment (end_y < start_y).
1634 // The old code used end_y.saturating_sub(start_y), which clamped
1635 // to 0 for unsigned FixedPoint, turning every ramp-down into a
1636 // flat line at start_y.
1637 let start_y_s: FixedI128 = FixedSignedCast::saturated_into(*start_y);
1638 let end_y_s: FixedI128 = FixedSignedCast::saturated_into(*end_y);
1639 let delta_y_s: FixedI128 = end_y_s.saturating_sub(start_y_s); // may be < 0
1640
1641 // t is in [0, 1] and non-negative; promote for the multiply.
1642 let t_s: FixedI128 = FixedSignedCast::saturated_into(t);
1643 // Compute y = start_y + t * delta_y
1644 let y_s: FixedI128 = start_y_s.saturating_add(t_s.saturating_mul(delta_y_s));
1645
1646 // Project back to FixedPoint.
1647 // For unsigned types, a negative y (possible when delta_y < 0 and
1648 // t > 0) clamps to 0 via saturated_from, which is the correct
1649 // saturating semantic for rewards.
1650 let y_fp: FixedPoint = FixedSignedCast::saturated_from(y_s);
1651 Asset::from_fixed(&y_fp)
1652 }
1653
1654 Segment::Curve { params, .. } => {
1655 // Delegate to CurveParams::evaluate, which performs all signed
1656 // arithmetic in FixedI128 via FixedSignedCast.
1657 let y: FixedPoint = params.evaluate(x);
1658 Asset::from_fixed(&y)
1659 }
1660 }
1661 }
1662 );
1663
1664}
1665
1666pub use payee::*;
1667
1668/// Defines **pluggable payee models** for distributing a total payout
1669/// across a set of participants.
1670///
1671/// Payees are abstracted as allocation models that transform a total payout
1672/// and a set of participant weights into **individual reward assignments**.
1673///
1674/// ## Concept
1675///
1676/// - A payee model determines **how a total payout is distributed**.
1677/// - The distribution is computed over a set of `(Id, Share)` pairs.
1678/// - The output assigns a payout value to each participant.
1679///
1680/// ## Mathematical Form
1681///
1682/// Where:
1683/// - `P` = total payout
1684/// - `p_i` = payout assigned to participant `i`
1685/// - `n` = number of participants
1686///
1687/// ## In this model:
1688///
1689/// - Input is `(Payout, [(Id, Share)])`.
1690/// - Output is `[(Id, Payout)]`.
1691///
1692/// ## Properties
1693///
1694/// - **Conservative distribution**: Total assigned payout equals input payout.
1695/// - **Deterministic mapping**: Same inputs produce identical allocations.
1696/// - **Context-free or context-driven** depending on model.
1697/// - **Finite partitioning** of total value across participants.
1698///
1699/// ## Purpose
1700///
1701/// Payee models provide flexibility in defining reward distribution:
1702///
1703/// - Control how total rewards are allocated among participants.
1704/// - Support different allocation strategies based on weights or structure.
1705/// - Serve as the final stage in reward pipelines.
1706pub mod payee {
1707
1708 // ===============================================================================
1709 // ``````````````````````````````````` IMPORTS ```````````````````````````````````
1710 // ===============================================================================
1711
1712 // --- Core / Std ---
1713 use core::iter::once;
1714
1715 // --- FRAME Suite ---
1716 use frame_suite::{
1717 fixedpoint::{FixedForInteger, FixedOp, IntegerToFixed},
1718 plugin_model,
1719 };
1720
1721 // --- Substrate primitives ---
1722 use sp_runtime::{
1723 traits::{One, Saturating, Zero},
1724 FixedPointNumber, Vec,
1725 };
1726
1727 // ===============================================================================
1728 // ````````````````````````````````` SHARES PAYEE ````````````````````````````````
1729 // ===============================================================================
1730
1731 plugin_model!(
1732 /// The **SharesPay** model distributes a total payout proportionally
1733 /// based on participant shares.
1734 ///
1735 /// **Concept**: **Proportional Allocation with Remainder Correction**
1736 ///
1737 /// ## Mathematical Form
1738 ///
1739 /// `p_i = floor(P * s_i / sum(s_j))`
1740 ///
1741 /// Subject to:
1742 ///
1743 /// `sum(p_i) = P`
1744 ///
1745 /// Where:
1746 /// - `P` = total payout
1747 /// - `s_i` = share of participant `i`
1748 /// - `p_i` = payout assigned to participant `i`
1749 /// - `n` = number of participants
1750 ///
1751 /// ## Properties
1752 ///
1753 /// - **Proportional allocation**: Each payout is derived from relative share.
1754 /// - **Discrete rounding**: Values are floored to integer representation.
1755 /// - **Remainder redistribution**: Residual units are reassigned to preserve
1756 /// total sum.
1757 /// - **Conservative**: Total distributed payout equals input payout.
1758 /// - **Deterministic**: Same inputs produce identical allocation.
1759 ///
1760 /// ## Purpose
1761 ///
1762 /// - Enables share-based distribution of rewards.
1763 /// - Preserves proportional fairness under discrete constraints.
1764 /// - Ensures exact conservation of total payout.
1765 name: pub SharesPay,
1766 input: (Payout,PayoutFor),
1767 output: Payees,
1768 others: [Id, Share, FixedPoint],
1769 bounds: [
1770 Payout: IntegerToFixed + FixedForInteger<FixedPoint = FixedPoint> + Saturating + Copy + Zero + PartialOrd,
1771 PayoutFor: IntoIterator<Item = (Id, Share)> + Clone + FromIterator<(Id, Share)>,
1772 Share: Copy + IntegerToFixed + FixedForInteger<FixedPoint = FixedPoint> + Zero + Saturating,
1773 Payees: Extend<(Id, Payout)> + Default,
1774 Id: Clone,
1775 FixedPoint: FixedPointNumber + FixedOp
1776 ],
1777 compute: |input, _context| {
1778 let (payout, payout_for) = input;
1779 let payout_fixed = payout.to_fixed();
1780
1781 let (total_shares, len) = payout_for
1782 .clone()
1783 .into_iter()
1784 .fold((Share::zero(), 0usize), |(acc_share, acc_len), (_, share)| {
1785 (acc_share.saturating_add(share), acc_len.saturating_add(1usize))
1786 });
1787
1788 let mut payees = Payees::default();
1789
1790 if total_shares.is_zero() || len.is_zero() {
1791 return payees
1792 }
1793
1794 // Calculate each payee's raw payout (fixed-point), floor it, and track the remainder
1795 let mut payouts: Vec<(Id, Payout, FixedPoint)> = Vec::with_capacity(len);
1796 let mut total_distributed: Payout = Payout::zero();
1797 let mut remainders: Vec<(usize, FixedPoint)> = Vec::with_capacity(len);
1798
1799 for (idx, (id, share)) in payout_for.clone().into_iter().enumerate() {
1800 let ratio = share.to_fixed().checked_div(&total_shares.to_fixed()).unwrap_or_else(|| Share::zero().to_fixed());
1801 let pay_fp = payout_fixed.saturating_mul(ratio);
1802 let pay_int = Payout::from_fixed(&pay_fp);
1803 let pay_fp_int = pay_int.to_fixed();
1804 let remainder = pay_fp.saturating_sub(pay_fp_int);
1805 payouts.push((id.clone(), pay_int, remainder));
1806 total_distributed = total_distributed.saturating_add(pay_int);
1807 remainders.push((idx, remainder));
1808 }
1809
1810 // Calculate how much is left undistributed due to flooring
1811 let mut undistributed = payout.saturating_sub(total_distributed);
1812
1813 // Sort remainders descending, distribute +1 to top N
1814 remainders.sort_by(|a, b| b.1.cmp(&a.1));
1815 let mut i = 0;
1816 while undistributed > Payout::zero() && i < remainders.len() {
1817 let idx = remainders[i].0;
1818 payouts[idx].1 = payouts[idx].1.saturating_add(Payout::from_fixed(&FixedPoint::one()));
1819 undistributed = undistributed.saturating_sub(Payout::from_fixed(&FixedPoint::one()));
1820 i += 1;
1821 }
1822
1823 for (id, pay, _) in payouts {
1824 payees.extend(core::iter::once((id, pay)));
1825 }
1826
1827 payees
1828 }
1829
1830 );
1831
1832 // ===============================================================================
1833 // ````````````````````````````````` EQUAL PAYEE `````````````````````````````````
1834 // ===============================================================================
1835
1836 plugin_model!(
1837 /// The **EqualPay** model distributes a total payout equally
1838 /// among all participants.
1839 ///
1840 /// **Concept**: **Uniform Allocation**
1841 ///
1842 /// ## Mathematical Form
1843 ///
1844 /// `p_i = floor( P / n )`
1845 ///
1846 /// Subject to:
1847 ///
1848 /// `sum(p_i) <= P`
1849 ///
1850 /// Where:
1851 /// - `P` = total payout
1852 /// - `p_i` = payout assigned to participant `i`
1853 /// - `n` = number of participants
1854 ///
1855 /// ## Properties
1856 ///
1857 /// - **Uniform allocation**: Each participant receives the same payout.
1858 /// - **Discrete division**: Values are derived via integer division.
1859 /// - **Non-exhaustive**: Total distributed payout may be less than input due to rounding.
1860 /// - **Deterministic**: Same inputs produce identical allocation.
1861 ///
1862 /// ## Purpose
1863 ///
1864 /// - Enables equal distribution of rewards.
1865 /// - Provides simple and uniform allocation strategy.
1866 /// - Suitable when all participants are treated identically.
1867 name: pub EqualPay,
1868 input: (Payout,PayoutFor),
1869 output: Payees,
1870 others: [Id, Share, FixedPoint],
1871 bounds: [
1872 Payout: IntegerToFixed + FixedForInteger<FixedPoint = FixedPoint> + Saturating + Zero + One,
1873 PayoutFor: IntoIterator<Item = (Id, Share)> + Clone + FromIterator<(Id, Share)>,
1874 Share: Copy + IntegerToFixed + FixedForInteger<FixedPoint = FixedPoint>,
1875 Payees: Extend<(Id, Payout)> + Default,
1876 Id: Clone,
1877 FixedPoint: FixedPointNumber + FixedOp
1878 ],
1879 compute: |input, _context| {
1880 let (payout, payout_for) = input;
1881 let payout_fixed = payout.to_fixed();
1882 let mut payees = Payees::default();
1883
1884 // Count the number of payees
1885 let count: usize = payout_for.clone().into_iter().count();
1886
1887 if count == 0 {
1888 return payees
1889 }
1890
1891 // Build count as FixedPoint by adding One repeatedly
1892 let mut count_fixed = FixedPoint::one();
1893 for _ in 1..count {
1894 count_fixed = count_fixed.saturating_add(FixedPoint::one());
1895 }
1896
1897 // Divide payout equally among all payees
1898 let equal_pay = payout_fixed.checked_div(&count_fixed)
1899 .unwrap_or_else(|| FixedPoint::zero());
1900
1901 for (id, _) in payout_for.clone() {
1902 payees.extend(once((
1903 id.clone(),
1904 Payout::from_fixed(&equal_pay)
1905 )));
1906 }
1907 payees
1908 }
1909 );
1910}
1911
1912// ===============================================================================
1913// ````````````````````` PAYOUT & PAYEE MODELS PLUGIN TESTS ``````````````````````
1914// ===============================================================================
1915
1916#[cfg(test)]
1917mod tests {
1918
1919 // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1920 // ``````````````````````````````````` IMPORTS ```````````````````````````````````
1921 // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1922
1923 // --- Local crate imports ---
1924 use super::*;
1925 use crate::rewards::payout::{CurveParams, Segment};
1926
1927 // --- FRAME Suite ---
1928 use frame_suite::plugin_test;
1929
1930 // --- Substrate primitives ---
1931 use sp_runtime::{AccountId32, FixedI128, FixedPointNumber, FixedU128, traits::{One, Zero}};
1932
1933 // --- Substrate std (no_std helpers) ---
1934 use sp_std::vec;
1935
1936 // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1937 // `````````````````````````````````` CONSTANTS ``````````````````````````````````
1938 // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1939
1940 const fn account_frm_seed(seed: u8) -> AccountId32 {
1941 let mut data = [0u8; 32];
1942 data[31] = seed;
1943 AccountId32::new(data)
1944 }
1945
1946 const ALICE: AccountId32 = account_frm_seed(1);
1947 const BOB: AccountId32 = account_frm_seed(2);
1948 const MIKE: AccountId32 = account_frm_seed(5);
1949
1950 // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1951 // ```````````````````````````````` PAYOUT MODELS ````````````````````````````````
1952 // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1953
1954 // ---------------------------------- ZERO-PAYOUT ---------------------------------
1955
1956 plugin_test! {
1957 model: payout::ZeroPayout,
1958 input: u128,
1959 cases: {
1960 (zero_payout_returns_zero_for_zero_input, 0, 0),
1961 (zero_payout_returns_zero_for_positive_input, 1000, 0),
1962 (zero_payout_returns_zero_for_random_input, 15755, 0),
1963 (zero_payout_returns_zero_for_max_input, u128::MAX, 0),
1964 }
1965 }
1966
1967 // ---------------------------------- CONSTANT-PAYOUT ---------------------------------
1968
1969 plugin_test! {
1970 model: payout::ConstantPayout,
1971 input: u128,
1972 output: u128,
1973 context: payout::ConstantPayoutConfig<u128>,
1974 value: payout::ConstantPayoutConfig { payout: 500 },
1975 cases: {
1976 (constant_payout_always_returns_init_reward_for_zero, 0, 500),
1977 (constant_payout_always_returns_init_reward_for_positive, 1000, 500),
1978 (constant_payout_always_returns_init_reward_for_random, 45200, 500),
1979 (constant_payout_always_returns_init_reward_for_max, u128::MAX, 500),
1980 }
1981 }
1982
1983 // ---------------------------------- INFLATION-PAYOUT ---------------------------------
1984
1985 plugin_test! {
1986 model: payout::InflationPayout,
1987 input: u128,
1988 output: u128,
1989 context: payout::InflationPayoutConfig<FixedU128>,
1990 value: payout::InflationPayoutConfig {
1991 inflation_rate: FixedU128::saturating_from_rational(1, 10), // 10%
1992 },
1993 cases: {
1994 (inflation_payout_10_percent_of_1000, 1000, 100),
1995 (inflation_payout_10_percent_of_500, 500, 50),
1996 (inflation_payout_10_percent_of_10, 10, 1),
1997 }
1998 }
1999
2000 plugin_test! {
2001 model: payout::InflationPayout,
2002 input: i128,
2003 output: i128,
2004 context: payout::InflationPayoutConfig<FixedI128>,
2005 value: payout::InflationPayoutConfig {
2006 inflation_rate: FixedI128::saturating_from_rational(1, 10), // 10%
2007 },
2008 cases: {
2009 (inflation_payout_signed_10_percent_of_1000, -1000, -100),
2010 (inflation_payout_signed_10_percent_of_500, -500, -50),
2011 (inflation_payout_signed_10_percent_of_10, -10, -1),
2012 }
2013 }
2014
2015 // ---------------------------------- LINEAR-PAYOUT ---------------------------------
2016
2017 plugin_test! {
2018 model: payout::LinearPayout,
2019 input: u128,
2020 output: u128,
2021 context: payout::LinearPayoutConfig<FixedU128>,
2022 value: payout::LinearPayoutConfig {
2023 slope: FixedU128::saturating_from_integer(2),
2024 base_reward: FixedU128::saturating_from_integer(10),
2025 },
2026 cases: {
2027 (linear_payout_2x_plus_10_for_5, 5, 20), // 2*5 + 10
2028 (linear_payout_2x_plus_10_for_0, 0, 10),
2029 (linear_payout_2x_plus_10_for_150, 150, 310),
2030 (linear_payout_2x_plus_10_for_725, 725, 1460),
2031 }
2032 }
2033
2034 plugin_test! {
2035 model: payout::LinearPayout,
2036 input: i128,
2037 output: i128,
2038 context: payout::LinearPayoutConfig<FixedI128>,
2039 value: payout::LinearPayoutConfig {
2040 slope: FixedI128::saturating_from_integer(2),
2041 base_reward: FixedI128::saturating_from_integer(10),
2042 },
2043 cases: {
2044 (linear_payout_signed_case_1, -5, 0), // 2*-5 + 10
2045 (linear_payout_signed_case_2, -2, 6),
2046 (linear_payout_signed_case_3, -150, -290),
2047 (linear_payout_signed_case_4, -725, -1440),
2048 }
2049 }
2050
2051 // ---------------------------------- QUADRATIC-PAYOUT ---------------------------------
2052
2053 plugin_test! {
2054 model: payout::QuadraticPayout,
2055 input: u128,
2056 output: u128,
2057 context: payout::QuadraticPayoutConfig<FixedU128>,
2058 value: payout::QuadraticPayoutConfig {
2059 quadratic_coeff: FixedU128::saturating_from_integer(1),
2060 linear_coeff: FixedU128::saturating_from_integer(2),
2061 constant_term: FixedU128::saturating_from_integer(3),
2062 },
2063 cases: {
2064 (quadratic_payout_x2_plus_2x_plus_3_for_2, 2, 11), // 1*4 + 2*2 + 3
2065 (quadratic_payout_x2_plus_2x_plus_3_for_0, 0, 3),
2066 (quadratic_payout_x2_plus_2x_plus_3_for_125, 125, 15878),
2067 (quadratic_payout_x2_plus_2x_plus_3_for_2675, 2675, 7160978),
2068 }
2069 }
2070
2071 plugin_test! {
2072 model: payout::QuadraticPayout,
2073 input: i128,
2074 output: i128,
2075 context: payout::QuadraticPayoutConfig<FixedI128>,
2076 value: payout::QuadraticPayoutConfig {
2077 quadratic_coeff: FixedI128::saturating_from_integer(1),
2078 linear_coeff: FixedI128::saturating_from_integer(2),
2079 constant_term: FixedI128::saturating_from_integer(3),
2080 },
2081 cases: {
2082 (quadratic_payout_signed_case_1, -2, 3), // 1*4 + 2*-2 + 3
2083 (quadratic_payout_signed_case_2, -24, 531),
2084 (quadratic_payout_signed_case_3, -125, 15378),
2085 (quadratic_payout_signed_case_4, -2675, 7150278),
2086 }
2087 }
2088
2089 // ---------------------------------- HALVING-PAYOUT ---------------------------------
2090
2091 plugin_test! {
2092 model: payout::HalvingPayout,
2093 input: u128,
2094 output: u128,
2095 context: payout::HalvingPayoutConfig<u128>,
2096 value: payout::HalvingPayoutConfig { initial_reward: 1024 },
2097 cases: {
2098 (halving_payout_initial_reward_for_era_0, 0, 1024),
2099 (halving_payout_halved_for_era_1, 1, 512),
2100 (halving_payout_halved_for_era_2, 2, 256),
2101 (halving_payout_halved_for_era_8, 8, 4),
2102 (halving_payout_halved_for_era_10, 10, 1),
2103 (halving_payout_zero_for_era_11, 11, 0)
2104 }
2105 }
2106
2107 plugin_test! {
2108 model: payout::HalvingPayout,
2109 input: i128,
2110 output: i128,
2111 context: payout::HalvingPayoutConfig<i128>,
2112 value: payout::HalvingPayoutConfig { initial_reward: -1024 },
2113 cases: {
2114 (halving_payout_negative_initial_for_era_0, 0, -1024),
2115 (halving_payout_negative_halved_for_era_1, 1, -512),
2116 (halving_payout_negative_halved_for_era_2, 2, -256),
2117 (halving_payout_negative_halved_for_era_6, 6, -16),
2118 }
2119 }
2120
2121 // ---------------------------------- EXP-DECAY-PAYOUT ----------------------------------
2122
2123 // A -> UNSIGNED ASSET
2124
2125 // --- A1. Standard decay: a=0.1, r0=1000 ---
2126 plugin_test! {
2127 model: ExpDecayPayout,
2128 input: u128,
2129 output: u128,
2130 context: ExpDecayPayoutConfig<u128, FixedU128>,
2131 value: ExpDecayPayoutConfig {
2132 initial_reward: 1000u128,
2133 decay_constant: FixedU128::saturating_from_rational(1, 10), // a = 0.1
2134 },
2135 cases: {
2136 // x=0: e^0 = 1.0 -> reward = 1000
2137 (exp_decay_unsigned_x0, 0, 1000),
2138 // x=1: e^-0.1 ~= 0.9048 -> 904
2139 (exp_decay_unsigned_x1, 1, 904),
2140 // x=5: e^-0.5 ~= 0.6065 -> 606
2141 (exp_decay_unsigned_x5, 5, 606),
2142 // x=10: e^-1.0 ~= 0.3679 -> 367
2143 (exp_decay_unsigned_x10, 10, 367),
2144 // x=20: e^-2.0 ~= 0.1353 -> 135
2145 (exp_decay_unsigned_x20, 20, 135),
2146 // x=50: e^-5.0 ~= 0.0067 -> 6
2147 (exp_decay_unsigned_x50, 50, 6),
2148 // x=100: e^-10 ~= 0.000045 -> 0 (truncated)
2149 (exp_decay_unsigned_x100, 100, 0),
2150 }
2151 }
2152
2153 // --- A2. Zero decay constant (a=0): reward is constant at r0 for all x ---
2154 //
2155 // e^(-0 * x) = e^0 = 1, so reward = r0 regardless of x.
2156 plugin_test! {
2157 model: ExpDecayPayout,
2158 input: u128,
2159 output: u128,
2160 context: ExpDecayPayoutConfig<u128, FixedU128>,
2161 value: ExpDecayPayoutConfig {
2162 initial_reward: 500u128,
2163 decay_constant: FixedU128::zero(), // a = 0
2164 },
2165 cases: {
2166 (exp_decay_unsigned_zero_decay_x0, 0, 500),
2167 (exp_decay_unsigned_zero_decay_x10, 10, 500),
2168 (exp_decay_unsigned_zero_decay_x100, 100, 500),
2169 }
2170 }
2171
2172 // --- A3. Zero initial reward (r0=0): always 0 regardless of decay ---
2173 //
2174 // 0 * anything = 0.
2175 plugin_test! {
2176 model: ExpDecayPayout,
2177 input: u128,
2178 output: u128,
2179 context: ExpDecayPayoutConfig<u128, FixedU128>,
2180 value: ExpDecayPayoutConfig {
2181 initial_reward: 0u128,
2182 decay_constant: FixedU128::saturating_from_rational(1, 10),
2183 },
2184 cases: {
2185 (exp_decay_unsigned_zero_r0_x0, 0, 0),
2186 (exp_decay_unsigned_zero_r0_x10, 10, 0),
2187 }
2188 }
2189
2190 // --- A4. a=1 (fast decay), r0=100 ---
2191 //
2192 // x=0 -> 100, x=1 -> ~36, x=2 -> ~13, x=3 -> ~4
2193 plugin_test! {
2194 model: ExpDecayPayout,
2195 input: u128,
2196 output: u128,
2197 context: ExpDecayPayoutConfig<u128, FixedU128>,
2198 value: ExpDecayPayoutConfig {
2199 initial_reward: 100u128,
2200 decay_constant: FixedU128::one(), // a = 1
2201 },
2202 cases: {
2203 (exp_decay_unsigned_fast_x0, 0, 100),
2204 (exp_decay_unsigned_fast_x1, 1, 36),
2205 (exp_decay_unsigned_fast_x2, 2, 13),
2206 (exp_decay_unsigned_fast_x3, 3, 4),
2207 (exp_decay_unsigned_fast_x4, 4, 1),
2208 // x=5: e^-5 ~= 0.0067 -> 0 (truncated to integer)
2209 (exp_decay_unsigned_fast_x5, 5, 0),
2210 }
2211 }
2212
2213 // --- A5. a=ln(2)~=0.693, r0=1024: each unit step halves the reward ---
2214 //
2215 // This is the continuous analog of HalvingPayout. Because ln(2) is
2216 // irrational and we use a rational approximation (693/1000), results
2217 // track 512, 256, 128, ... with small rounding errors.
2218 plugin_test! {
2219 model: ExpDecayPayout,
2220 input: u128,
2221 output: u128,
2222 context: ExpDecayPayoutConfig<u128, FixedU128>,
2223 value: ExpDecayPayoutConfig {
2224 initial_reward: 1024u128,
2225 // ln(2) ~= 0.6931471... ~= 693/1000 (good enough for 4 halvings)
2226 decay_constant: FixedU128::saturating_from_rational(693, 1000),
2227 },
2228 cases: {
2229 // x=0: 1024 * e^0 = 1024
2230 (exp_decay_unsigned_halving_x0, 0, 1024),
2231 // x=1: 1024 * e^-ln2 = 512 (tiny rounding -> 512)
2232 (exp_decay_unsigned_halving_x1, 1, 512),
2233 // x=2: 1024 * e^-2ln2 = 256
2234 (exp_decay_unsigned_halving_x2, 2, 256),
2235 // x=3: 1024 * e^-3ln2 = 128
2236 (exp_decay_unsigned_halving_x3, 3, 128),
2237 }
2238 }
2239
2240 // --- A6. Large x causes exp to approach zero - should not panic ---
2241 plugin_test! {
2242 model: ExpDecayPayout,
2243 input: u128,
2244 output: u128,
2245 context: ExpDecayPayoutConfig<u128, FixedU128>,
2246 value: ExpDecayPayoutConfig {
2247 initial_reward: 1_000_000u128,
2248 decay_constant: FixedU128::one(), // a = 1
2249 },
2250 cases: {
2251 // e^-50 is astronomically small - truncates to 0
2252 (exp_decay_unsigned_large_x, 50, 0),
2253 }
2254 }
2255
2256 // B -> SIGNED ASSET
2257
2258 // --- B1. Standard decay: a=0.1, r0=1000 ---
2259 plugin_test! {
2260 model: ExpDecayPayout,
2261 input: i128,
2262 output: i128,
2263 context: ExpDecayPayoutConfig<i128, FixedI128>,
2264 value: ExpDecayPayoutConfig {
2265 initial_reward: 1000i128,
2266 decay_constant: FixedI128::saturating_from_rational(1, 10),
2267 },
2268 cases: {
2269 (exp_decay_signed_x0, 0, 1000),
2270 (exp_decay_signed_x1, 1, 904),
2271 (exp_decay_signed_x5, 5, 606),
2272 (exp_decay_signed_x10, 10, 367),
2273 (exp_decay_signed_x20, 20, 135),
2274 (exp_decay_signed_x50, 50, 6),
2275 (exp_decay_signed_x100, 100, 0),
2276 }
2277 }
2278
2279 // --- B2. Negative input (x < 0): -a*x becomes positive -> e^(positive) > 1 ---
2280 plugin_test! {
2281 model: ExpDecayPayout,
2282 input: i128,
2283 output: i128,
2284 context: ExpDecayPayoutConfig<i128, FixedI128>,
2285 value: ExpDecayPayoutConfig {
2286 initial_reward: 1000i128,
2287 decay_constant: FixedI128::saturating_from_rational(1, 10),
2288 },
2289 cases: {
2290 // x=-10: e^(+1.0) ~= 2.718 -> reward = 2718
2291 (exp_decay_signed_neg_x10, -10, 2718),
2292 // x=-5: e^(+0.5) ~= 1.6487 -> reward = 1648
2293 (exp_decay_signed_neg_x5, -5, 1648),
2294 // x=-1: e^(+0.1) ~= 1.1052 -> reward = 1105
2295 (exp_decay_signed_neg_x1, -1, 1105),
2296 }
2297 }
2298
2299 // --- B3. Zero decay, signed ---
2300 plugin_test! {
2301 model: ExpDecayPayout,
2302 input: i128,
2303 output: i128,
2304 context: ExpDecayPayoutConfig<i128, FixedI128>,
2305 value: ExpDecayPayoutConfig {
2306 initial_reward: 500i128,
2307 decay_constant: FixedI128::zero(),
2308 },
2309 cases: {
2310 (exp_decay_signed_zero_decay_x0, 0, 500),
2311 (exp_decay_signed_zero_decay_x10, 10, 500),
2312 }
2313 }
2314
2315 // --- B4. Zero initial reward, signed ---
2316 plugin_test! {
2317 model: ExpDecayPayout,
2318 input: i128,
2319 output: i128,
2320 context: ExpDecayPayoutConfig<i128, FixedI128>,
2321 value: ExpDecayPayoutConfig {
2322 initial_reward: 0i128,
2323 decay_constant: FixedI128::saturating_from_rational(1, 10),
2324 },
2325 cases: {
2326 (exp_decay_signed_zero_r0_x0, 0, 0),
2327 (exp_decay_signed_zero_r0_x10, 10, 0),
2328 }
2329 }
2330
2331 // --- B5. Fast decay (a=1), signed ---
2332 plugin_test! {
2333 model: ExpDecayPayout,
2334 input: i128,
2335 output: i128,
2336 context: ExpDecayPayoutConfig<i128, FixedI128>,
2337 value: ExpDecayPayoutConfig {
2338 initial_reward: 100i128,
2339 decay_constant: FixedI128::one(),
2340 },
2341 cases: {
2342 (exp_decay_signed_fast_x0, 0, 100),
2343 (exp_decay_signed_fast_x1, 1, 36),
2344 (exp_decay_signed_fast_x2, 2, 13),
2345 (exp_decay_signed_fast_x3, 3, 4),
2346 (exp_decay_signed_fast_x5, 5, 0),
2347 }
2348 }
2349
2350 // ----------------------------------- SIGMOID-PAYOUT -----------------------------------
2351
2352 // A -> UNSIGNED ASSET
2353
2354 // --- A1. Standard curve: a=0.1, b=0.9, L=100 ---
2355 plugin_test! {
2356 model: SigmoidPayout,
2357 input: u128,
2358 output: u128,
2359 context: SigmoidPayoutConfig<u128, FixedU128>,
2360 value: SigmoidPayoutConfig {
2361 max_reward: 100u128,
2362 growth_start: FixedU128::saturating_from_rational(1, 10), // 0.1
2363 growth_end: FixedU128::saturating_from_rational(9, 10), // 0.9
2364 },
2365 cases: {
2366 // x=0: lower tail, f(0) = a*L = 10 (exact by construction)
2367 (sigmoid_unsigned_x0_lower_tail, 0, 10),
2368 // x=1: upper tail, f(1) = b*L = 90 (rounds to 89 due to fixed-point rounding)
2369 (sigmoid_unsigned_x1_upper_tail, 1, 89),
2370 // x=5: deep saturation -> 100
2371 (sigmoid_unsigned_x5_saturation, 5, 99),
2372 // x=100: fully saturated -> 100
2373 (sigmoid_unsigned_x100_full_saturation, 100, 100),
2374 }
2375 }
2376
2377 // --- A2. Wide curve: a=0.2, b=0.8, L=1000 ---
2378 plugin_test! {
2379 model: SigmoidPayout,
2380 input: u128,
2381 output: u128,
2382 context: SigmoidPayoutConfig<u128, FixedU128>,
2383 value: SigmoidPayoutConfig {
2384 max_reward: 1000u128,
2385 growth_start: FixedU128::saturating_from_rational(2, 10), // 0.2
2386 growth_end: FixedU128::saturating_from_rational(8, 10), // 0.8
2387 },
2388 cases: {
2389 (sigmoid_unsigned_wide_x0, 0, 200),
2390 (sigmoid_unsigned_wide_x1, 1, 799),
2391 (sigmoid_unsigned_wide_x10, 10, 999),
2392 }
2393 }
2394
2395 // --- A3. Very steep curve: a=0.01, b=0.99, L=100 ---
2396 plugin_test! {
2397 model: SigmoidPayout,
2398 input: u128,
2399 output: u128,
2400 context: SigmoidPayoutConfig<u128, FixedU128>,
2401 value: SigmoidPayoutConfig {
2402 max_reward: 100u128,
2403 growth_start: FixedU128::saturating_from_rational(1, 100), // 0.01
2404 growth_end: FixedU128::saturating_from_rational(99, 100), // 0.99
2405 },
2406 cases: {
2407 // x=0: f(0) = 1*L/100 = 1
2408 (sigmoid_unsigned_steep_x0, 0, 1),
2409 // x=1: f(1) ~= 99 (rounding -> 98)
2410 (sigmoid_unsigned_steep_x1, 1, 98),
2411 // x=5: saturation
2412 (sigmoid_unsigned_steep_x5, 5, 100),
2413 }
2414 }
2415
2416 // --- A4. a = 0.5 (midpoint at x=0): symmetric about x=0 ---
2417 plugin_test! {
2418 model: SigmoidPayout,
2419 input: u128,
2420 output: u128,
2421 context: SigmoidPayoutConfig<u128, FixedU128>,
2422 value: SigmoidPayoutConfig {
2423 max_reward: 100u128,
2424 growth_start: FixedU128::saturating_from_rational(1, 2), // 0.5 -> logit=0 -> x0=0
2425 growth_end: FixedU128::saturating_from_rational(9, 10), // 0.9
2426 },
2427 cases: {
2428 // f(0) = L/2 = 50 (exact - x0=0 so x=0 is the midpoint)
2429 (sigmoid_unsigned_midpoint_at_x0, 0, 50),
2430 // f(1) ~= b*L = 90 -> rounding -> 89
2431 (sigmoid_unsigned_midpoint_x1, 1, 89),
2432 // f(10): saturation
2433 (sigmoid_unsigned_midpoint_x10, 10, 99),
2434 }
2435 }
2436
2437 // --- A5. Zero max_reward: always returns 0 ---
2438 plugin_test! {
2439 model: SigmoidPayout,
2440 input: u128,
2441 output: u128,
2442 context: SigmoidPayoutConfig<u128, FixedU128>,
2443 value: SigmoidPayoutConfig {
2444 max_reward: 0u128,
2445 growth_start: FixedU128::saturating_from_rational(1, 10),
2446 growth_end: FixedU128::saturating_from_rational(9, 10),
2447 },
2448 cases: {
2449 (sigmoid_unsigned_zero_max_x0, 0, 0),
2450 (sigmoid_unsigned_zero_max_x1, 1, 0),
2451 (sigmoid_unsigned_zero_max_x100, 100, 0),
2452 }
2453 }
2454
2455 // B -> SIGNED ASSET
2456
2457 // --- B1. Standard curve: a=0.1, b=0.9, L=100 (same config as A1) ---
2458 plugin_test! {
2459 model: SigmoidPayout,
2460 input: i128,
2461 output: i128,
2462 context: SigmoidPayoutConfig<i128, FixedI128>,
2463 value: SigmoidPayoutConfig {
2464 max_reward: 100i128,
2465 growth_start: FixedI128::saturating_from_rational(1, 10),
2466 growth_end: FixedI128::saturating_from_rational(9, 10),
2467 },
2468 cases: {
2469 (sigmoid_signed_x0_lower_tail, 0, 10),
2470 (sigmoid_signed_x1_upper_tail, 1, 89),
2471 (sigmoid_signed_x5_saturation, 5, 99),
2472 (sigmoid_signed_x100_full_saturation, 100, 100),
2473 }
2474 }
2475
2476 // --- B2. Negative input (x < 0), further into the lower tail ---
2477 plugin_test! {
2478 model: SigmoidPayout,
2479 input: i128,
2480 output: i128,
2481 context: SigmoidPayoutConfig<i128, FixedI128>,
2482 value: SigmoidPayoutConfig {
2483 max_reward: 100i128,
2484 growth_start: FixedI128::saturating_from_rational(1, 10),
2485 growth_end: FixedI128::saturating_from_rational(9, 10),
2486 },
2487 cases: {
2488 // x=-1: deep in lower tail -> 0
2489 (sigmoid_signed_neg_x1, -1, 0),
2490 // x=-5: even deeper -> 0
2491 (sigmoid_signed_neg_x5, -5, 0),
2492 }
2493 }
2494
2495 // --- B3. Wide curve, signed: a=0.2, b=0.8, L=1000 ---
2496 plugin_test! {
2497 model: SigmoidPayout,
2498 input: i128,
2499 output: i128,
2500 context: SigmoidPayoutConfig<i128, FixedI128>,
2501 value: SigmoidPayoutConfig {
2502 max_reward: 1000i128,
2503 growth_start: FixedI128::saturating_from_rational(2, 10),
2504 growth_end: FixedI128::saturating_from_rational(8, 10),
2505 },
2506 cases: {
2507 (sigmoid_signed_wide_x0, 0, 200),
2508 (sigmoid_signed_wide_x1, 1, 799),
2509 (sigmoid_signed_wide_x10, 10, 999),
2510 // x=-1: far into lower tail, exponent = k*(1+x0) ~= 2.773*1.5 ~= 4.16
2511 // e^4.16 ~= 64 -> f = 1000/65 ~= 15
2512 (sigmoid_signed_wide_neg_x1, -1, 15),
2513 }
2514 }
2515
2516 // --- B4. Midpoint at x=0 (a=0.5): negative input -> below L/2 ---
2517 plugin_test! {
2518 model: SigmoidPayout,
2519 input: i128,
2520 output: i128,
2521 context: SigmoidPayoutConfig<i128, FixedI128>,
2522 value: SigmoidPayoutConfig {
2523 max_reward: 100i128,
2524 growth_start: FixedI128::saturating_from_rational(1, 2),
2525 growth_end: FixedI128::saturating_from_rational(9, 10),
2526 },
2527 cases: {
2528 (sigmoid_signed_mid_x0, 0, 50),
2529 (sigmoid_signed_mid_x1, 1, 89),
2530 // x=-1: symmetric to x=1 around x0=0 -> f(-1) = L - f(1) ~= 100-90 = 10
2531 (sigmoid_signed_mid_neg_x1, -1, 10),
2532 // x=-2: f(-2) = L - f(2) ~= 100-98 = 2
2533 (sigmoid_signed_mid_neg_x2, -2, 1),
2534 }
2535 }
2536
2537 // C -> GAURD CONDITIONS
2538
2539 // --- C1. growth_start = 0 (invalid: not strictly in (0,1)) ---
2540 plugin_test! {
2541 model: SigmoidPayout,
2542 input: u128,
2543 output: u128,
2544 context: SigmoidPayoutConfig<u128, FixedU128>,
2545 value: SigmoidPayoutConfig {
2546 max_reward: 100u128,
2547 growth_start: FixedU128::zero(), // INVALID
2548 growth_end: FixedU128::saturating_from_rational(9, 10),
2549 },
2550 cases: {
2551 (sigmoid_guard_unsigned_start_zero, 50, 0),
2552 }
2553 }
2554
2555 // --- C2. growth_start = 1 (invalid: not strictly in (0,1)) ---
2556 plugin_test! {
2557 model: SigmoidPayout,
2558 input: u128,
2559 output: u128,
2560 context: SigmoidPayoutConfig<u128, FixedU128>,
2561 value: SigmoidPayoutConfig {
2562 max_reward: 100u128,
2563 growth_start: FixedU128::one(), // INVALID
2564 growth_end: FixedU128::saturating_from_rational(9, 10),
2565 },
2566 cases: {
2567 (sigmoid_guard_unsigned_start_one, 50, 0),
2568 }
2569 }
2570
2571 // --- C3. growth_end = 0 (invalid) ---
2572 plugin_test! {
2573 model: SigmoidPayout,
2574 input: u128,
2575 output: u128,
2576 context: SigmoidPayoutConfig<u128, FixedU128>,
2577 value: SigmoidPayoutConfig {
2578 max_reward: 100u128,
2579 growth_start: FixedU128::saturating_from_rational(1, 10),
2580 growth_end: FixedU128::zero(), // INVALID
2581 },
2582 cases: {
2583 (sigmoid_guard_unsigned_end_zero, 50, 0),
2584 }
2585 }
2586
2587 // --- C4. growth_end = 1 (invalid) ---
2588 plugin_test! {
2589 model: SigmoidPayout,
2590 input: u128,
2591 output: u128,
2592 context: SigmoidPayoutConfig<u128, FixedU128>,
2593 value: SigmoidPayoutConfig {
2594 max_reward: 100u128,
2595 growth_start: FixedU128::saturating_from_rational(1, 10),
2596 growth_end: FixedU128::one(), // INVALID
2597 },
2598 cases: {
2599 (sigmoid_guard_unsigned_end_one, 50, 0),
2600 }
2601 }
2602
2603 // --- C5. growth_start = growth_end -> k = 0, degenerate ---
2604 plugin_test! {
2605 model: SigmoidPayout,
2606 input: u128,
2607 output: u128,
2608 context: SigmoidPayoutConfig<u128, FixedU128>,
2609 value: SigmoidPayoutConfig {
2610 max_reward: 100u128,
2611 growth_start: FixedU128::saturating_from_rational(5, 10), // 0.5
2612 growth_end: FixedU128::saturating_from_rational(5, 10), // 0.5 - same -> k=0
2613 },
2614 cases: {
2615 (sigmoid_guard_unsigned_equal_fracs, 50, 0),
2616 }
2617 }
2618
2619 // --- C6. Same guards for signed type ---
2620 plugin_test! {
2621 model: SigmoidPayout,
2622 input: i128,
2623 output: i128,
2624 context: SigmoidPayoutConfig<i128, FixedI128>,
2625 value: SigmoidPayoutConfig {
2626 max_reward: 100i128,
2627 growth_start: FixedI128::zero(), // INVALID
2628 growth_end: FixedI128::saturating_from_rational(9, 10),
2629 },
2630 cases: {
2631 (sigmoid_guard_signed_start_zero, 50, 0),
2632 }
2633 }
2634
2635 plugin_test! {
2636 model: SigmoidPayout,
2637 input: i128,
2638 output: i128,
2639 context: SigmoidPayoutConfig<i128, FixedI128>,
2640 value: SigmoidPayoutConfig {
2641 max_reward: 100i128,
2642 growth_start: FixedI128::saturating_from_rational(1, 10),
2643 growth_end: FixedI128::one(), // INVALID
2644 },
2645 cases: {
2646 (sigmoid_guard_signed_end_one, 50, 0),
2647 }
2648 }
2649
2650 // --- C7. Inverted curve (growth_start > growth_end) ---
2651 //
2652 // Both parameters are individually valid (in (0,1)), but since
2653 // growth_start > growth_end, the derived slope k wil be negative.
2654 //
2655 // k < 0 inverts the sigmoid:
2656 // - The curve decays instead of grows
2657 // - x = 0 -> high value (~ b * L)
2658 // - x -> large -> low value (~ a * L)
2659 // This is valid behavior for a decreasing reward schedule.
2660 //
2661 // This behavior is not explicitly guarded in the model
2662 // (only k = 0 is guarded), so we document it here as
2663 // a valid "decreasing reward schedule".
2664 plugin_test! {
2665 model: SigmoidPayout,
2666 input: u128,
2667 output: u128,
2668 context: SigmoidPayoutConfig<u128, FixedU128>,
2669 value: SigmoidPayoutConfig {
2670 max_reward: 100u128,
2671 growth_start: FixedU128::saturating_from_rational(9, 10), // 0.9 - inverted
2672 growth_end: FixedU128::saturating_from_rational(1, 10), // 0.1
2673 },
2674 cases: {
2675 // Decreasing curve -> high at x=0, low at x=1
2676 (sigmoid_inverted_unsigned_x0, 0, 89),
2677 (sigmoid_inverted_unsigned_x1, 1, 10),
2678 // Deep past the curve -> very small
2679 (sigmoid_inverted_unsigned_x10, 10, 0),
2680 }
2681 }
2682
2683 // ----------------------------- INVERSE-PROPORTIONAL-PAYOUT ----------------------------
2684
2685 plugin_test! {
2686 model: payout::InverseProportionalPayout,
2687 input: u128,
2688 output: u128,
2689 context: payout::InverseProportionalConfig<FixedU128>,
2690 value: payout::InverseProportionalConfig {
2691 k: FixedU128::saturating_from_integer(100),
2692 epsilon: FixedU128::one(),
2693 },
2694 cases: {
2695 (inv_prop_u128_x0, 0, 100),
2696 (inv_prop_u128_x9, 9, 10),
2697 (inv_prop_u128_x99, 99, 1),
2698 (inv_prop_u128_x999, 999, 0),
2699 }
2700 }
2701
2702 // Signed: negative x -> denom = x + eps may be <= 0 -> return 0
2703 // With k = 100, eps = 1:
2704 // x = -1 -> denom = -1 + 1 = 0 -> return 0
2705 // x = -5 -> denom = -5 + 1 = -4 -> return 0
2706 plugin_test! {
2707 model: payout::InverseProportionalPayout,
2708 input: i128,
2709 output: i128,
2710 context: payout::InverseProportionalConfig<FixedI128>,
2711 value: payout::InverseProportionalConfig {
2712 k: FixedI128::saturating_from_integer(100),
2713 epsilon: FixedI128::one(),
2714 },
2715 cases: {
2716 (inv_prop_i128_positive_x9, 9, 10),
2717 (inv_prop_i128_positive_x99, 99, 1),
2718 // FIX 1: non-positive denom -> 0
2719 (inv_prop_i128_neg_x1, -1, 0),
2720 (inv_prop_i128_neg_x5, -5, 0),
2721 // x=0 -> denom = 0 + 1 = 1 -> 100 / 1 = 100
2722 (inv_prop_i128_zero, 0, 100),
2723 }
2724 }
2725
2726 // ---------------------------------- FIXED-RATE-PAYOUT ---------------------------------
2727
2728 plugin_test! {
2729 model: payout::FixedRatePayout,
2730 input: u128,
2731 output: u128,
2732 context: payout::FixedRateConfig<FixedU128>,
2733 value: payout::FixedRateConfig {
2734 rate: FixedU128::saturating_from_rational(5, 100), // 5%
2735 },
2736 cases: {
2737 (fixed_rate_payout_5_percent_of_1000, 1000, 50),
2738 (fixed_rate_payout_5_percent_of_200, 200, 10),
2739 (fixed_rate_payout_5_percent_of_16800, 16800, 840),
2740 (fixed_rate_payout_5_percent_of_0, 0, 0),
2741 }
2742 }
2743
2744 plugin_test! {
2745 model: payout::FixedRatePayout,
2746 input: i128,
2747 output: i128,
2748 context: payout::FixedRateConfig<FixedI128>,
2749 value: payout::FixedRateConfig {
2750 rate: FixedI128::saturating_from_rational(1, 10), // 10%
2751 },
2752 cases: {
2753 (fixed_rate_payout_signed_10_percent_of_370, -370, -37),
2754 (fixed_rate_payout_signed_10_percent_of_1000, -1000, -100),
2755 (fixed_rate_payout_signed_10_percent_of_max, i128::MIN, -17014118346046923173),
2756 }
2757 }
2758
2759 // --------------------------------- FIXED-ANNUAL-PAYOUT --------------------------------
2760
2761 plugin_test! {
2762 model: payout::FixedAnnualPayout,
2763 input: u128,
2764 output: u128,
2765 context: payout::FixedAnnualConfig<u128, FixedU128>,
2766 value: payout::FixedAnnualConfig {
2767 apr: FixedU128::saturating_from_rational(12, 100), // 12%
2768 time_count: 12u128, // monthly
2769 },
2770 cases: {
2771 // 1000 * (1.12^(1/12) - 1) ~= 9
2772 (fixed_annual_12_percent_monthly_of_1000, 1000, 9),
2773 // 10000 * same ~= 94
2774 (fixed_annual_12_percent_monthly_of_10000, 10000, 94),
2775 (fixed_annual_12_percent_monthly_of_0, 0, 0),
2776 }
2777 }
2778
2779 plugin_test! {
2780 model: payout::FixedAnnualPayout,
2781 input: u128,
2782 output: u128,
2783 context: payout::FixedAnnualConfig<u128, FixedU128>,
2784 value: payout::FixedAnnualConfig {
2785 apr: FixedU128::zero(), // 0% APR
2786 time_count: 12u128,
2787 },
2788 cases: {
2789 // EPR = (1+0)^(1/12) - 1 = 0 -> reward = 0
2790 (fixed_annual_zero_apr_any_input, 1000, 0),
2791 (fixed_annual_zero_apr_zero_input, 0, 0),
2792 }
2793 }
2794
2795 // APR = 100%, n = 1 -> EPR = 1.0 -> reward = x
2796 plugin_test! {
2797 model: payout::FixedAnnualPayout,
2798 input: u128,
2799 output: u128,
2800 context: payout::FixedAnnualConfig<u128, FixedU128>,
2801 value: payout::FixedAnnualConfig {
2802 apr: FixedU128::one(), // 100%
2803 time_count: 1u128,
2804 },
2805 cases: {
2806 (fixed_annual_100_percent_n1_of_500, 500, 500),
2807 (fixed_annual_100_percent_n1_of_1000, 1000, 1000),
2808 }
2809 }
2810
2811 // ---------------------------------- LOGARITHMIC-PAYOUT ---------------------------------
2812
2813 plugin_test! {
2814 model: payout::LogarithmicPayout,
2815 input: u128,
2816 output: u128,
2817 context: payout::LogarithmicConfig<FixedU128>,
2818 value: payout::LogarithmicConfig {
2819 vertical_scale: FixedU128::one(),
2820 horizontal_scale: FixedU128::one(),
2821 horizontal_shift: FixedU128::one(),
2822 vertical_shift: FixedU128::zero(),
2823 },
2824 cases: {
2825 (logarthmic_payout_unsigned_case_1, 1, 0),
2826 (logarthmic_payout_unsigned_case_2, 10, 2),
2827 (logarthmic_payout_unsigned_case_3, 112, 4),
2828 (logarthmic_payout_unsigned_case_4, 7025, 8)
2829 }
2830 }
2831
2832 plugin_test! {
2833 model: payout::LogarithmicPayout,
2834 input: i128,
2835 output: i128,
2836 context: payout::LogarithmicConfig<FixedI128>,
2837 value: payout::LogarithmicConfig {
2838 vertical_scale: FixedI128::one(),
2839 horizontal_scale: FixedI128::one(),
2840 horizontal_shift: FixedI128::one(),
2841 vertical_shift: FixedI128::zero(),
2842 },
2843 cases: {
2844 (logarthmic_payout_signed_case_1, -1, 0),
2845 (logarthmic_payout_signed_case_2, -10, 0),
2846 (logarthmic_payout_signed_case_3, -112, 0),
2847 (logarthmic_payout_signed_case_4, -7025, 0)
2848 }
2849 }
2850
2851 // ------------------------------- PIECEWISE-PAYOUT ------------------------------
2852
2853 // A -> LINEAR SEGMENT - UNSIGNED TYPES
2854
2855 // --- A1. Increasing linear [0, 10] -> [0, 100] ---
2856 plugin_test! {
2857 model: PiecewisePayout,
2858 input: u128,
2859 output: u128,
2860 context: PiecewiseConfig<FixedU128>,
2861 value: PiecewiseConfig {
2862 segments: vec![
2863 Segment::Linear {
2864 start_x: FixedU128::zero(),
2865 end_x: FixedU128::saturating_from_integer(10),
2866 start_y: FixedU128::zero(),
2867 end_y: FixedU128::saturating_from_integer(100),
2868 }
2869 ],
2870 },
2871 cases: {
2872 (linear_unsigned_increasing_x0, 0, 0),
2873 (linear_unsigned_increasing_x5, 5, 50),
2874 (linear_unsigned_increasing_x10, 10, 100),
2875 }
2876 }
2877
2878 // --- A2. Decreasing linear [0, 10] -> [100, 0] ---
2879 plugin_test! {
2880 model: PiecewisePayout,
2881 input: u128,
2882 output: u128,
2883 context: PiecewiseConfig<FixedU128>,
2884 value: PiecewiseConfig {
2885 segments: vec![
2886 Segment::Linear {
2887 start_x: FixedU128::zero(),
2888 end_x: FixedU128::saturating_from_integer(10),
2889 start_y: FixedU128::saturating_from_integer(100),
2890 end_y: FixedU128::zero(),
2891 }
2892 ],
2893 },
2894 cases: {
2895 // x=0: t=0 -> y = 100
2896 (linear_unsigned_decreasing_x0, 0, 100),
2897 // x=5: t=0.5 -> y = 50
2898 (linear_unsigned_decreasing_x5, 5, 50),
2899 // x=10: t=1.0 -> y = 0
2900 (linear_unsigned_decreasing_x10, 10, 0),
2901 }
2902 }
2903
2904 // --- A3. Decreasing linear [0, 100] -> [1000, 0], larger scale ---
2905 plugin_test! {
2906 model: PiecewisePayout,
2907 input: u128,
2908 output: u128,
2909 context: PiecewiseConfig<FixedU128>,
2910 value: PiecewiseConfig {
2911 segments: vec![
2912 Segment::Linear {
2913 start_x: FixedU128::zero(),
2914 end_x: FixedU128::saturating_from_integer(100),
2915 start_y: FixedU128::saturating_from_integer(1000),
2916 end_y: FixedU128::zero(),
2917 }
2918 ],
2919 },
2920 cases: {
2921 (linear_unsigned_large_decreasing_x0, 0, 1000),
2922 (linear_unsigned_large_decreasing_x25, 25, 750),
2923 (linear_unsigned_large_decreasing_x50, 50, 500),
2924 (linear_unsigned_large_decreasing_x75, 75, 250),
2925 (linear_unsigned_large_decreasing_x100, 100, 0),
2926 }
2927 }
2928
2929 // --- A4. Flat segment (start_y == end_y), always returns start_y ---
2930 plugin_test! {
2931 model: PiecewisePayout,
2932 input: u128,
2933 output: u128,
2934 context: PiecewiseConfig<FixedU128>,
2935 value: PiecewiseConfig {
2936 segments: vec![
2937 Segment::Linear {
2938 start_x: FixedU128::zero(),
2939 end_x: FixedU128::saturating_from_integer(50),
2940 start_y: FixedU128::saturating_from_integer(42),
2941 end_y: FixedU128::saturating_from_integer(42),
2942 }
2943 ],
2944 },
2945 cases: {
2946 (linear_unsigned_flat_x0, 0, 42),
2947 (linear_unsigned_flat_x25, 25, 42),
2948 (linear_unsigned_flat_x50, 50, 42),
2949 }
2950 }
2951
2952 // B -> LINEAR SEGMENT - SIGNED TYPES (regression)
2953
2954 // --- B1. Increasing linear ---
2955 plugin_test! {
2956 model: PiecewisePayout,
2957 input: i128,
2958 output: i128,
2959 context: PiecewiseConfig<FixedI128>,
2960 value: PiecewiseConfig {
2961 segments: vec![
2962 Segment::Linear {
2963 start_x: FixedI128::zero(),
2964 end_x: FixedI128::saturating_from_integer(10),
2965 start_y: FixedI128::zero(),
2966 end_y: FixedI128::saturating_from_integer(100),
2967 }
2968 ],
2969 },
2970 cases: {
2971 (linear_signed_increasing_x0, 0, 0),
2972 (linear_signed_increasing_x5, 5, 50),
2973 (linear_signed_increasing_x10, 10, 100),
2974 }
2975 }
2976
2977 // --- B2. Decreasing linear ---
2978 plugin_test! {
2979 model: PiecewisePayout,
2980 input: i128,
2981 output: i128,
2982 context: PiecewiseConfig<FixedI128>,
2983 value: PiecewiseConfig {
2984 segments: vec![
2985 Segment::Linear {
2986 start_x: FixedI128::zero(),
2987 end_x: FixedI128::saturating_from_integer(10),
2988 start_y: FixedI128::saturating_from_integer(100),
2989 end_y: FixedI128::zero(),
2990 }
2991 ],
2992 },
2993 cases: {
2994 (linear_signed_decreasing_x0, 0, 100),
2995 (linear_signed_decreasing_x5, 5, 50),
2996 (linear_signed_decreasing_x10, 10, 0),
2997 }
2998 }
2999
3000 // --- B3. Signed segment with negative y-values, ramp from 0 to -100 ---
3001 plugin_test! {
3002 model: PiecewisePayout,
3003 input: i128,
3004 output: i128,
3005 context: PiecewiseConfig<FixedI128>,
3006 value: PiecewiseConfig {
3007 segments: vec![
3008 Segment::Linear {
3009 start_x: FixedI128::zero(),
3010 end_x: FixedI128::saturating_from_integer(10),
3011 start_y: FixedI128::zero(),
3012 end_y: FixedI128::saturating_from_integer(-100),
3013 }
3014 ],
3015 },
3016 cases: {
3017 (linear_signed_neg_y_x0, 0, 0),
3018 (linear_signed_neg_y_x5, 5, -50),
3019 (linear_signed_neg_y_x10, 10, -100),
3020 }
3021 }
3022
3023 // C. -> CURVE SEGMENT - UNSIGNED TYPES
3024
3025 // --- C1. L=100, k=1, x0=5 ---
3026 //
3027 // f(x) = 100 / (1 + e^(-1*(x - 5)))
3028 //
3029 // x=0 (left tail): exp_arg = -(0-5) = +5 -> e^5 ~= 148.41 -> 100/149.41 ~= 0
3030 // x=2 (left tail): exp_arg = -(2-5) = +3 -> e^3 ~= 20.09 -> 100/21.09 ~= 4
3031 // x=4 (left tail): exp_arg = -(4-5) = +1 -> e^1 ~= 2.72 -> 100/3.72 ~= 26
3032 // x=5 (midpoint): exp_arg = 0 -> e^0 = 1 -> 100/2 = 50
3033 // x=6 (right tail): exp_arg = -(6-5) = -1 -> e^(-1) ~=0.368 -> 100/1.368 ~= 73
3034 // x=8 (right tail): exp_arg = -(8-5) = -3 -> e^(-3) ~=0.050 -> 100/1.050 ~= 95
3035 // x=100 (saturation): -> 100
3036 plugin_test! {
3037 model: PiecewisePayout,
3038 input: u128,
3039 output: u128,
3040 context: PiecewiseConfig<FixedU128>,
3041 value: PiecewiseConfig {
3042 segments: vec![
3043 Segment::Curve {
3044 start_x: FixedU128::zero(),
3045 end_x: FixedU128::saturating_from_integer(100),
3046 params: CurveParams {
3047 l: FixedU128::saturating_from_integer(100),
3048 k: FixedU128::one(),
3049 x0: FixedU128::saturating_from_integer(5),
3050 },
3051 }
3052 ],
3053 },
3054 cases: {
3055 // Left of x0=5
3056 (curve_unsigned_left_tail_x0, 0, 0),
3057 (curve_unsigned_left_tail_x2, 2, 4),
3058 (curve_unsigned_left_tail_x4, 4, 26),
3059 // Midpoint
3060 (curve_unsigned_midpoint_x5, 5, 50),
3061 // Right of x0=5
3062 (curve_unsigned_right_tail_x6, 6, 73),
3063 (curve_unsigned_right_tail_x8, 8, 95),
3064 // Saturation
3065 (curve_unsigned_saturation, 100, 100),
3066 }
3067 }
3068
3069 // --- C2. L=100, k=2, x0=3, unsigned: steeper curve, x0 not at 5 ---
3070 //
3071 // f(x) = 100 / (1 + e^(-2*(x - 3)))
3072 //
3073 // x=0: exp_arg = -2*(0-3) = +6 -> e^6 ~= 403.4 -> 100/404.4 ~= 0
3074 // x=1: exp_arg = -2*(1-3) = +4 -> e^4 ~= 54.6 -> 100/55.6 ~= 1
3075 // x=2: exp_arg = -2*(2-3) = +2 -> e^2 ~= 7.4 -> 100/8.4 ~= 11
3076 // x=3: exp_arg = 0 -> 100/2 = 50
3077 // x=4: exp_arg = -2*(4-3) = -2 -> e^(-2) ~= 0.135-> 100/1.135 ~= 88
3078 // x=5: exp_arg = -2*(5-3) = -4 -> e^(-4) ~= 0.018-> 100/1.018 ~= 98
3079 plugin_test! {
3080 model: PiecewisePayout,
3081 input: u128,
3082 output: u128,
3083 context: PiecewiseConfig<FixedU128>,
3084 value: PiecewiseConfig {
3085 segments: vec![
3086 Segment::Curve {
3087 start_x: FixedU128::zero(),
3088 end_x: FixedU128::saturating_from_integer(20),
3089 params: CurveParams {
3090 l: FixedU128::saturating_from_integer(100),
3091 k: FixedU128::saturating_from_integer(2),
3092 x0: FixedU128::saturating_from_integer(3),
3093 },
3094 }
3095 ],
3096 },
3097 cases: {
3098 (curve_unsigned_steep_x0, 0, 0),
3099 (curve_unsigned_steep_x1, 1, 1),
3100 (curve_unsigned_steep_x2, 2, 11),
3101 (curve_unsigned_steep_x3, 3, 50),
3102 (curve_unsigned_steep_x4, 4, 88),
3103 (curve_unsigned_steep_x5, 5, 98),
3104 }
3105 }
3106
3107 // --- C3. x0=0 (midpoint at the boundary), x=0 always gives L/2 ---
3108 plugin_test! {
3109 model: PiecewisePayout,
3110 input: u128,
3111 output: u128,
3112 context: PiecewiseConfig<FixedU128>,
3113 value: PiecewiseConfig {
3114 segments: vec![
3115 Segment::Curve {
3116 start_x: FixedU128::zero(),
3117 end_x: FixedU128::saturating_from_integer(20),
3118 params: CurveParams {
3119 l: FixedU128::saturating_from_integer(100),
3120 k: FixedU128::one(),
3121 x0: FixedU128::zero(), // midpoint at x=0
3122 },
3123 }
3124 ],
3125 },
3126 cases: {
3127 (curve_unsigned_x0_at_midpoint, 0, 50),
3128 (curve_unsigned_x0_right_x1, 1, 73),
3129 (curve_unsigned_x0_right_x5, 5, 99),
3130 }
3131 }
3132
3133 // D. CURVE SEGMENT - SIGNED TYPES (regression)
3134
3135 // --- D1. L=100, k=1, x0=5 ---
3136 plugin_test! {
3137 model: PiecewisePayout,
3138 input: i128,
3139 output: i128,
3140 context: PiecewiseConfig<FixedI128>,
3141 value: PiecewiseConfig {
3142 segments: vec![
3143 Segment::Curve {
3144 start_x: FixedI128::zero(),
3145 end_x: FixedI128::saturating_from_integer(100),
3146 params: CurveParams {
3147 l: FixedI128::saturating_from_integer(100),
3148 k: FixedI128::one(),
3149 x0: FixedI128::saturating_from_integer(5),
3150 },
3151 }
3152 ],
3153 },
3154 cases: {
3155 (curve_signed_at_midpoint, 5, 50),
3156 (curve_signed_saturation, 100, 100),
3157 (curve_signed_at_zero, 0, 0),
3158 (curve_signed_left_x2, 2, 4),
3159 (curve_signed_left_x4, 4, 26),
3160 (curve_signed_right_x6, 6, 73),
3161 (curve_signed_right_x8, 8, 95),
3162 }
3163 }
3164
3165 // --- D2. Signed input with negative x (x < 0), x0=5 ---
3166 //
3167 // x=-2: diff = -2 - 5 = -7 -> exp_arg = +7 -> e^7 ~= 1096 -> 100/1097 ~= 0
3168 // x=-5: diff = -10 -> exp_arg = +10 -> e^10 ~= 22026 -> ~= 0
3169 plugin_test! {
3170 model: PiecewisePayout,
3171 input: i128,
3172 output: i128,
3173 context: PiecewiseConfig<FixedI128>,
3174 value: PiecewiseConfig {
3175 segments: vec![
3176 Segment::Curve {
3177 start_x: FixedI128::saturating_from_integer(-10),
3178 end_x: FixedI128::saturating_from_integer(100),
3179 params: CurveParams {
3180 l: FixedI128::saturating_from_integer(100),
3181 k: FixedI128::one(),
3182 x0: FixedI128::saturating_from_integer(5),
3183 },
3184 }
3185 ],
3186 },
3187 cases: {
3188 (curve_signed_neg_x2, -2, 0),
3189 (curve_signed_neg_x5, -5, 0),
3190 (curve_signed_midpoint, 5, 50),
3191 }
3192 }
3193
3194 // E -> MULTI-SEGMENT COMPOSITIONS
3195
3196 // --- E1. Linear increase -> decreasing linear (triangle wave) ---
3197 //
3198 // Seg 1: [0, 5] -> [0, 100] (ramp up)
3199 // Seg 2: [5, 10] -> [100, 0] (ramp down)
3200 //
3201 // x=0: Seg 1, t=0 -> 0
3202 // x=2: Seg 1, t=0.4 -> 40
3203 // x=5: Seg 1 matches (x <= end_x=5), t=1.0 -> 100 (first-match wins)
3204 // x=7: Seg 2, t=0.4 -> 60
3205 // x=10: Seg 2, t=1.0 -> 0
3206 plugin_test! {
3207 model: PiecewisePayout,
3208 input: u128,
3209 output: u128,
3210 context: PiecewiseConfig<FixedU128>,
3211 value: PiecewiseConfig {
3212 segments: vec![
3213 Segment::Linear {
3214 start_x: FixedU128::zero(),
3215 end_x: FixedU128::saturating_from_integer(5),
3216 start_y: FixedU128::zero(),
3217 end_y: FixedU128::saturating_from_integer(100),
3218 },
3219 Segment::Linear {
3220 start_x: FixedU128::saturating_from_integer(5),
3221 end_x: FixedU128::saturating_from_integer(10),
3222 start_y: FixedU128::saturating_from_integer(100),
3223 end_y: FixedU128::zero(),
3224 },
3225 ],
3226 },
3227 cases: {
3228 (multi_triangle_x0, 0, 0),
3229 (multi_triangle_x2, 2, 40),
3230 (multi_triangle_x5, 5, 100), // first segment matches at x=5
3231 (multi_triangle_x7, 7, 60),
3232 (multi_triangle_x10, 10, 0),
3233 }
3234 }
3235
3236 // --- E2. Linear ramp -> Curve saturation (bootstrapping schedule) ---
3237 //
3238 // Seg 1: Linear [0, 10] -> [0, 50] (ramp up)
3239 // Seg 2: Curve [10, 100], L=100, k=1, x0=10
3240 //
3241 // x=5: Seg 1, t=0.5 -> 25
3242 // x=10: Seg 1 (first match), t=1.0 -> 50
3243 // x=15: Seg 2, diff=5, exp_arg=-5 -> e^(-5)~=0.0067 -> 100/1.0067~=99
3244 // x=100: Seg 2, saturation -> 100
3245 plugin_test! {
3246 model: PiecewisePayout,
3247 input: u128,
3248 output: u128,
3249 context: PiecewiseConfig<FixedU128>,
3250 value: PiecewiseConfig {
3251 segments: vec![
3252 Segment::Linear {
3253 start_x: FixedU128::zero(),
3254 end_x: FixedU128::saturating_from_integer(10),
3255 start_y: FixedU128::zero(),
3256 end_y: FixedU128::saturating_from_integer(50),
3257 },
3258 Segment::Curve {
3259 start_x: FixedU128::saturating_from_integer(10),
3260 end_x: FixedU128::saturating_from_integer(100),
3261 params: CurveParams {
3262 l: FixedU128::saturating_from_integer(100),
3263 k: FixedU128::one(),
3264 x0: FixedU128::saturating_from_integer(10),
3265 },
3266 },
3267 ],
3268 },
3269 cases: {
3270 (multi_ramp_curve_x5, 5, 25),
3271 (multi_ramp_curve_x10, 10, 50), // linear segment matches first
3272 (multi_ramp_curve_x15, 15, 99),
3273 (multi_ramp_curve_x100, 100, 100),
3274 }
3275 }
3276
3277 // --- E3. Gap between segments: x in the gap -> 0 ---
3278 //
3279 // Seg 1: [0, 5]
3280 // Seg 2: [20, 30]
3281 // x=10: no segment matches -> 0
3282 plugin_test! {
3283 model: PiecewisePayout,
3284 input: u128,
3285 output: u128,
3286 context: PiecewiseConfig<FixedU128>,
3287 value: PiecewiseConfig {
3288 segments: vec![
3289 Segment::Linear {
3290 start_x: FixedU128::zero(),
3291 end_x: FixedU128::saturating_from_integer(5),
3292 start_y: FixedU128::zero(),
3293 end_y: FixedU128::saturating_from_integer(50),
3294 },
3295 Segment::Linear {
3296 start_x: FixedU128::saturating_from_integer(20),
3297 end_x: FixedU128::saturating_from_integer(30),
3298 start_y: FixedU128::saturating_from_integer(80),
3299 end_y: FixedU128::saturating_from_integer(100),
3300 },
3301 ],
3302 },
3303 cases: {
3304 (multi_gap_in_range_seg1, 5, 50),
3305 (multi_gap_between, 10, 0),
3306 (multi_gap_in_range_seg2, 20, 80),
3307 }
3308 }
3309
3310 // F -> EDGE / GUARD CASES
3311
3312 // --- F1. Empty segment list -> 0 for any input ---
3313 plugin_test! {
3314 model: PiecewisePayout,
3315 input: u128,
3316 output: u128,
3317 context: PiecewiseConfig<FixedU128>,
3318 value: PiecewiseConfig {
3319 segments: vec![],
3320 },
3321 cases: {
3322 (empty_segments_x0, 0, 0),
3323 (empty_segments_x42, 42, 0),
3324 }
3325 }
3326
3327 // --- F2. Out-of-range input -> 0 ---
3328 plugin_test! {
3329 model: PiecewisePayout,
3330 input: u128,
3331 output: u128,
3332 context: PiecewiseConfig<FixedU128>,
3333 value: PiecewiseConfig {
3334 segments: vec![
3335 Segment::Linear {
3336 start_x: FixedU128::zero(),
3337 end_x: FixedU128::saturating_from_integer(10),
3338 start_y: FixedU128::zero(),
3339 end_y: FixedU128::saturating_from_integer(100),
3340 }
3341 ],
3342 },
3343 cases: {
3344 (out_of_range_above, 50, 0),
3345 }
3346 }
3347
3348 // --- F3. Degenerate linear segment (start_x == end_x) -> always start_y ---
3349 //
3350 // width = 0, so we return start_y unconditionally
3351 plugin_test! {
3352 model: PiecewisePayout,
3353 input: u128,
3354 output: u128,
3355 context: PiecewiseConfig<FixedU128>,
3356 value: PiecewiseConfig {
3357 segments: vec![
3358 Segment::Linear {
3359 start_x: FixedU128::saturating_from_integer(5),
3360 end_x: FixedU128::saturating_from_integer(5), // same as start
3361 start_y: FixedU128::saturating_from_integer(77),
3362 end_y: FixedU128::saturating_from_integer(99),
3363 }
3364 ],
3365 },
3366 cases: {
3367 (degenerate_linear_at_point, 5, 77),
3368 }
3369 }
3370
3371 // --- F4. Curve with k=0: e^0 = 1 always -> L/2 everywhere ---
3372 plugin_test! {
3373 model: PiecewisePayout,
3374 input: u128,
3375 output: u128,
3376 context: PiecewiseConfig<FixedU128>,
3377 value: PiecewiseConfig {
3378 segments: vec![
3379 Segment::Curve {
3380 start_x: FixedU128::zero(),
3381 end_x: FixedU128::saturating_from_integer(100),
3382 params: CurveParams {
3383 l: FixedU128::saturating_from_integer(100),
3384 k: FixedU128::zero(), // k=0 -> exp_arg=0 -> e^0=1 -> L/2
3385 x0: FixedU128::saturating_from_integer(5),
3386 },
3387 }
3388 ],
3389 },
3390 cases: {
3391 (curve_k_zero_x0, 0, 50),
3392 (curve_k_zero_x50, 50, 50),
3393 }
3394 }
3395
3396 // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
3397 // ````````````````````````````````` PAYEE MODELS ````````````````````````````````
3398 // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
3399
3400 // ---------------------------------- SHARES-PAY ---------------------------------
3401
3402 plugin_test! {
3403 model: payee::SharesPay,
3404 input: (u128, Vec<(AccountId32, u128)>),
3405 output: Vec<(AccountId32, u128)>,
3406 cases: {
3407 // 100 payout, shares: [50, 50] => each gets 50
3408 (shares_pay_equal_split, (100, vec![(ALICE, 50), (BOB, 50)]), vec![(ALICE, 50), (BOB, 50)]),
3409 // 90 payout, shares: [30, 60] => 30/90=1/3*90=30, 60/90=2/3*90=60
3410 (shares_pay_proportional_split, (90, vec![(ALICE, 30), (BOB, 60)]), vec![(ALICE, 30), (BOB, 60)]),
3411 // 0 payout, shares: [10, 20] => all get 0
3412 (shares_pay_zero_payout, (0, vec![(ALICE, 10), (BOB, 20)]), vec![(ALICE, 0), (BOB, 0)]),
3413 // payout with zero shares
3414 (shares_pay_all_zero_shares, (100, vec![(ALICE, 0), (BOB, 0)]), vec![]),
3415 // payout with empty payees
3416 (shares_pay_no_payees, (100, vec![]), vec![]),
3417 }
3418 }
3419
3420 // ---------------------------------- EQUAL-PAY ----------------------------------
3421
3422 plugin_test! {
3423 model: payee::EqualPay,
3424 input: (u128, Vec<(AccountId32, u128)>),
3425 output: Vec<(AccountId32, u128)>,
3426 cases: {
3427 // 100 payout, 2 payees: each gets 50
3428 (equal_pay_two_payees_even_split, (100, vec![(ALICE, 10), (BOB, 20)]), vec![(ALICE, 50), (BOB, 50)]),
3429 // 90 payout, 3 payees: each gets 30
3430 (equal_pay_three_payees_even_split, (90, vec![(ALICE, 1u128), (BOB, 1u128), (MIKE, 1u128)]), vec![(ALICE, 30), (BOB, 30), (MIKE, 30)]),
3431 (equal_pay_three_payees_with_remainder, (100, vec![(ALICE, 1u128), (BOB, 1u128), (MIKE, 1u128)]), vec![(ALICE, 33u128), (BOB, 33u128), (MIKE, 33u128)]),
3432 // 0 payout, 2 payees: each gets 0
3433 (equal_pay_zero_payout, (0, vec![(ALICE, 10), (BOB, 20)]), vec![(ALICE, 0), (BOB, 0)]),
3434 // payout with empty payees
3435 (equal_pay_no_payees, (100, vec![]), vec![]),
3436 }
3437 }
3438}