decdn_node/serve_economics.rs
1//! Buy-side profitability gate for cache-miss relay legs (ADR 041).
2//!
3//! Node-local, config-selectable. Sibling to the ADR-040 cache policies, not part
4//! of them: cache policy decides what a node keeps; this decides what a node buys
5//! to serve. The policy returns a per-MB buy ceiling; the `node_origin` buy loop does
6//! the candidate scan and the refusal.
7
8use std::sync::Arc;
9
10use decdn_common::config::resolved::ResolvedServeEconomics;
11
12const BPS_DENOMINATOR: u64 = 10_000;
13
14/// Inputs to the buy ceiling, gathered per cache-miss.
15#[derive(Debug, Clone, Copy)]
16pub struct ServeEconomicsCtx {
17 /// The node's own sell rate, per MB, already raised to the on-chain floor.
18 pub sell_rate_per_mb: u64,
19 /// Operator basis points from `FeeRouter.getShares()[0]`; `(1 - f)` numerator.
20 pub operator_bps: u16,
21 /// ADR-040 frequency estimate for the hash; 0 = cold.
22 pub heat_estimate: u32,
23 /// The source node still has warming allowance: buy at market to warm.
24 /// False once the source's allowance is spent: buy only at the amortized floor.
25 pub warming_available: bool,
26}
27
28/// A node-local decision on the most the node pays upstream, per MB.
29pub trait ServeEconomicsPolicy: Send + Sync + std::fmt::Debug {
30 /// `None` = no economic ceiling (only the static ceiling applies).
31 /// `Some(v)` = a real ceiling; `v` may be 0, refusing every paid candidate.
32 fn max_buy_per_mb(&self, ctx: &ServeEconomicsCtx) -> Option<u64>;
33}
34
35/// Disables the economic gate; only `cache.max_rate_per_mb` bounds the buy.
36#[derive(Debug)]
37pub struct OffPolicy;
38
39impl ServeEconomicsPolicy for OffPolicy {
40 fn max_buy_per_mb(&self, _ctx: &ServeEconomicsCtx) -> Option<u64> {
41 None
42 }
43}
44
45/// Two-regime ceiling. `amortized = (operator_bps/10_000) · n_hat · sell` with
46/// `n_hat = clamp(round(discount · heat), 1, n_max)`. While the source has warming
47/// allowance, buy at the market price `max(sell, amortized)` (warming); once spent,
48/// buy only at `amortized` (grief-proof). See ADR 041 § The buy ceiling.
49#[derive(Debug)]
50pub struct MarginPolicy {
51 discount_bps: u32,
52 n_max: u32,
53}
54
55impl MarginPolicy {
56 /// `discount_bps` scales heat into the expected re-serve count `n_hat`,
57 /// which `n_max` caps.
58 #[must_use]
59 pub const fn new(discount_bps: u32, n_max: u32) -> Self {
60 Self {
61 discount_bps,
62 n_max,
63 }
64 }
65
66 fn n_hat(&self, heat_estimate: u32) -> u64 {
67 // round(discount · heat) with integer math: (discount_bps·heat + 5000) / 10_000
68 let scaled = u64::from(self.discount_bps)
69 .saturating_mul(u64::from(heat_estimate))
70 .saturating_add(BPS_DENOMINATOR / 2)
71 / BPS_DENOMINATOR;
72 scaled.clamp(1, u64::from(self.n_max))
73 }
74}
75
76impl ServeEconomicsPolicy for MarginPolicy {
77 fn max_buy_per_mb(&self, ctx: &ServeEconomicsCtx) -> Option<u64> {
78 let n_hat = self.n_hat(ctx.heat_estimate);
79 let amortized = u64::from(ctx.operator_bps)
80 .saturating_mul(n_hat)
81 .saturating_mul(ctx.sell_rate_per_mb)
82 / BPS_DENOMINATOR;
83 // Warm at market while the source has allowance; else the amortized floor.
84 Some(if ctx.warming_available {
85 ctx.sell_rate_per_mb.max(amortized)
86 } else {
87 amortized
88 })
89 }
90}
91
92/// Build the configured policy. Unknown names are impossible: config validation
93/// restricts `policy` to `"off"` | `"margin"` at load; anything else here
94/// falls back to `OffPolicy` (safe: only the static ceiling applies).
95#[must_use]
96pub fn select_policy(cfg: &ResolvedServeEconomics) -> Arc<dyn ServeEconomicsPolicy> {
97 match cfg.policy.as_str() {
98 "margin" => Arc::new(MarginPolicy::new(cfg.discount_bps, cfg.n_max)),
99 _ => Arc::new(OffPolicy),
100 }
101}
102
103#[cfg(test)]
104mod tests;