frutebox-node 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
frutebox/economics.py ADDED
@@ -0,0 +1,270 @@
1
+ """The reward engine — "mining, but for LLM inference".
2
+
3
+ Every settled request splits into four parts:
4
+
5
+ base_cost reimburses the provider's energy + basic maintenance
6
+ provider_margin the provider's small guaranteed profit
7
+ platform_fee the platform operator's (our) small fee
8
+ bonus a DECAYING bootstrap emission, like a Bitcoin block reward
9
+
10
+ The user only ever pays ``base_cost + provider_margin + platform_fee`` — which
11
+ is cheap and roughly flat. The ``bonus`` is paid to the provider *on top*, out
12
+ of a treasury/emission pool, and it halves every ``halving_epochs`` epochs. So
13
+ early contributors earn a lot, and over time earnings settle toward pure
14
+ cost-plus. That is what lets a company fund more hardware early, then coast on
15
+ sustainable margins later.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import time
22
+ from dataclasses import dataclass
23
+ from datetime import datetime, timezone
24
+ from pathlib import Path
25
+ from typing import Dict, List, Optional
26
+
27
+ from .config import Config
28
+ from .envvars import env as envvar
29
+
30
+ #: Seeded per-region rates. Flat hourly values until real tariff data is loaded — see the file
31
+ #: itself for why a plausible-looking invented curve would be worse than an honest flat number.
32
+ _RATES_FILE = Path(envvar("RATES_FILE", str(Path(__file__).resolve().parent.parent / "data" / "electricity_rates.json")))
33
+
34
+ _RATES_CACHE: Dict[str, dict] = {}
35
+
36
+
37
+ def _seed_rates() -> Dict[str, dict]:
38
+ global _RATES_CACHE
39
+ if _RATES_CACHE:
40
+ return _RATES_CACHE
41
+ try:
42
+ _RATES_CACHE = (json.loads(_RATES_FILE.read_text()) or {}).get("regions") or {}
43
+ except Exception:
44
+ # Never let a missing data file stop settlement — the defaults below are the same
45
+ # numbers the seed file carries.
46
+ _RATES_CACHE = {}
47
+ return _RATES_CACHE
48
+
49
+
50
+ #: Used only when the seed file is unreadable AND the region is unknown.
51
+ _DEFAULT_RATE = {"per_gb_eur": 0.01, "per_kwh_by_hour": [0.20] * 24}
52
+
53
+
54
+ def rates_for(region: str) -> dict:
55
+ """Bandwidth + hourly electricity rates for a region.
56
+
57
+ Prefers live/admin-edited rates cached from the web app (`gpuprices.region_rates()`), then
58
+ the seeded file, then a flat default. Falls back region -> "default" so an unrecognised
59
+ region still prices rather than raising mid-settlement.
60
+ """
61
+ key = (region or "default").strip().lower()
62
+ try: # local import: gpuprices imports config, not us
63
+ from . import gpuprices
64
+ live = gpuprices.region_rates() or {}
65
+ except Exception:
66
+ live = {}
67
+ for src in (live, _seed_rates()):
68
+ row = src.get(key) or src.get("default")
69
+ if isinstance(row, dict) and row.get("per_kwh_by_hour"):
70
+ return row
71
+ return _DEFAULT_RATE
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class JobMeter:
76
+ """What one served request actually consumed (file 01-D).
77
+
78
+ Settlement used to price on `(tokens, cost_per_1k)`, which is not what serving costs
79
+ anyone: an operator pays for **bandwidth** and **electricity**. Tokens break in both
80
+ directions — a long cheap generation overpays the node, a short prompt against a huge
81
+ context underpays it — and no token price can express that electricity costs more at 19:00
82
+ than at 03:00.
83
+
84
+ `executor_class` decides who, if anyone, gets paid:
85
+ * ``account_node`` a community operator's machine -> internet + electricity + reward
86
+ * ``backup_infra`` rented capacity we pay a vendor -> **nothing** here; the vendor bill
87
+ is recorded as vendor spend, and the user price is the rented cost
88
+ plus the thin rented margin
89
+ * ``owned`` our own hardware -> no payout, cost side only
90
+ * ``local`` the user's own device -> settlement is never called at all
91
+ """
92
+ bytes_in: int
93
+ bytes_out: int
94
+ t_dispatch: float
95
+ t_response: float
96
+ region: str = "default"
97
+ executor_class: str = "account_node"
98
+ #: 0 => not benchmarked; the tier wattage declaration is used instead.
99
+ node_power_w: float = 0.0
100
+ tier: str = "B"
101
+ #: §8A headroom input: what this job would have cost on the cheapest alternative, in EUR.
102
+ cheapest_alternative_eur: float = 0.0
103
+ tokens: int = 0
104
+
105
+ @property
106
+ def seconds(self) -> float:
107
+ # A clock that went backwards must not pay someone for negative time.
108
+ return max(0.0, float(self.t_response) - float(self.t_dispatch))
109
+
110
+
111
+ EXECUTOR_CLASSES = ("account_node", "backup_infra", "owned", "local")
112
+
113
+
114
+ class Economics:
115
+ def __init__(self, cfg: Config, genesis: Optional[float] = None):
116
+ self.cfg = cfg
117
+ # genesis is injected (not time.time() in a default arg) so tests /
118
+ # resumes are deterministic.
119
+ self.genesis = genesis if genesis is not None else time.time()
120
+ self.totals: Dict[str, float] = {
121
+ "user_revenue": 0.0,
122
+ "provider_payout": 0.0,
123
+ "platform_fee": 0.0,
124
+ "bonus_emitted": 0.0,
125
+ "requests": 0,
126
+ "tokens": 0,
127
+ }
128
+
129
+ # --- epoch / reward schedule -----------------------------------------
130
+ def current_epoch(self) -> int:
131
+ return int((time.time() - self.genesis) // self.cfg.epoch_seconds)
132
+
133
+ def seconds_into_epoch(self) -> float:
134
+ return (time.time() - self.genesis) % self.cfg.epoch_seconds
135
+
136
+ def reward_multiplier(self, epoch: Optional[int] = None) -> float:
137
+ """Bonus multiple of base cost at a given epoch (halving schedule)."""
138
+ if epoch is None:
139
+ epoch = self.current_epoch()
140
+ halvings = epoch // self.cfg.halving_epochs
141
+ return self.cfg.initial_bonus_rate * (0.5 ** halvings)
142
+
143
+ # --- settlement -------------------------------------------------------
144
+ def _tier_watts(self, tier: str) -> float:
145
+ return {
146
+ "C": self.cfg.tier_watts_c, "B": self.cfg.tier_watts_b,
147
+ "A": self.cfg.tier_watts_a, "S": self.cfg.tier_watts_s,
148
+ }.get((tier or "B").upper(), self.cfg.tier_watts_b)
149
+
150
+ def settle(self, job, cost_per_1k: Optional[float] = None) -> Dict[str, float]:
151
+ """Price one served request on bytes + time-of-use electricity + headroom reward.
152
+
153
+ Accepts a `JobMeter` (the real path). The legacy `(tokens, cost_per_1k)` signature
154
+ still works as a thin compat shim so callers not yet migrated keep settling instead of
155
+ crashing — it estimates 4 bytes/token, which is an estimate and is flagged as one on
156
+ the result (`estimated: True`).
157
+ """
158
+ if not isinstance(job, JobMeter):
159
+ tokens = int(job or 0)
160
+ rate = self.cfg.default_cost_per_1k if cost_per_1k is None else float(cost_per_1k)
161
+ # 4 bytes/token is the rough average for UTF-8 English; good enough to keep an
162
+ # unmigrated caller settling, never good enough to call measured.
163
+ approx = tokens * 4
164
+ job = JobMeter(bytes_in=approx // 2, bytes_out=approx - approx // 2,
165
+ t_dispatch=0.0, t_response=0.0, tokens=tokens,
166
+ cheapest_alternative_eur=(tokens / 1000.0) * rate)
167
+ return self._settle_meter(job, estimated=True)
168
+ return self._settle_meter(job, estimated=False)
169
+
170
+ def _settle_meter(self, job: JobMeter, *, estimated: bool) -> Dict[str, float]:
171
+ if job.executor_class == "local":
172
+ # On-device inference costs the network nothing and pays nobody. Reaching here
173
+ # means a local job was routed through network settlement, which would invent
174
+ # both a cost and a payout out of nothing — fail loudly instead.
175
+ raise ValueError("settle() must never be called for a local (on-device) job")
176
+ if job.executor_class not in EXECUTOR_CLASSES:
177
+ raise ValueError(f"unknown executor_class {job.executor_class!r}")
178
+
179
+ rates = rates_for(job.region)
180
+ per_gb = float(rates.get("per_gb_eur", _DEFAULT_RATE["per_gb_eur"]))
181
+ hourly = rates.get("per_kwh_by_hour") or _DEFAULT_RATE["per_kwh_by_hour"]
182
+
183
+ # Internet: what moved on the wire, both directions.
184
+ internet = (max(0, job.bytes_in) + max(0, job.bytes_out)) * per_gb / 1e9
185
+
186
+ # Electricity: seconds held x watts drawn x the tariff AT THE HOUR THE JOB STARTED.
187
+ hour = 0
188
+ if job.t_dispatch:
189
+ hour = datetime.fromtimestamp(job.t_dispatch, tz=timezone.utc).hour
190
+ per_kwh = float(hourly[hour % len(hourly)])
191
+ watts = job.node_power_w if job.node_power_w > 0 else self._tier_watts(job.tier)
192
+ electricity = (job.seconds / 3600.0) * (watts / 1000.0) * per_kwh
193
+
194
+ cost = internet + electricity
195
+ platform = cost * self.cfg.platform_rate
196
+
197
+ # Reward = §8A headroom: what we saved versus the cheapest alternative, after the
198
+ # operator's real costs and our fee. Never negative, and capped so a mispriced
199
+ # alternative cannot drain the treasury on a single job.
200
+ headroom = float(job.cheapest_alternative_eur) - cost - platform
201
+ reward = max(self.cfg.reward_floor_eur, min(max(0.0, headroom), self.cfg.reward_cap_eur))
202
+
203
+ if job.executor_class == "account_node":
204
+ payout = internet + electricity + reward
205
+ else:
206
+ # backup_infra: the vendor is paid on their own invoice, recorded as vendor spend.
207
+ # owned: our own hardware; the cost is ours and there is no counterparty to pay.
208
+ payout = 0.0
209
+ reward = 0.0
210
+
211
+ user_price = cost + platform + reward
212
+
213
+ return {
214
+ "tokens": job.tokens,
215
+ "executor_class": job.executor_class,
216
+ "region": (job.region or "default"),
217
+ "seconds": round(job.seconds, 4),
218
+ "watts": round(watts, 2),
219
+ "power_measured": job.node_power_w > 0,
220
+ "per_kwh_eur": round(per_kwh, 5),
221
+ "per_gb_eur": per_gb,
222
+ "internet_cost": round(internet, 8),
223
+ "electricity_cost": round(electricity, 8),
224
+ "base_cost": round(cost, 8),
225
+ "platform_fee": round(platform, 8),
226
+ "reward": round(reward, 8),
227
+ "user_price": round(user_price, 8),
228
+ "provider_payout": round(payout, 8),
229
+ "estimated": estimated,
230
+ "epoch": self.current_epoch(),
231
+ # Kept so existing dashboards/records that read these keys keep working. The
232
+ # halving bonus is now expressed through the reward headroom, not a multiple of a
233
+ # synthetic base cost.
234
+ "provider_margin": 0.0,
235
+ "bonus": round(reward, 8),
236
+ "multiplier": round(self.reward_multiplier(), 4),
237
+ }
238
+
239
+ def record(self, s: Dict[str, float]) -> None:
240
+ self.totals["user_revenue"] += s["user_price"]
241
+ self.totals["provider_payout"] += s.get("provider_payout", 0.0)
242
+ self.totals["platform_fee"] += s["platform_fee"]
243
+ self.totals["bonus_emitted"] += s.get("bonus", 0.0)
244
+ self.totals["requests"] += 1
245
+ self.totals["tokens"] += s.get("tokens", 0)
246
+
247
+ # --- dashboard snapshot ----------------------------------------------
248
+ def curve(self) -> List[Dict[str, float]]:
249
+ n = self.cfg.halving_epochs * self.cfg.curve_epochs
250
+ return [
251
+ {"epoch": e, "multiplier": round(self.reward_multiplier(e), 4)}
252
+ for e in range(0, n + 1)
253
+ ]
254
+
255
+ def snapshot(self) -> Dict:
256
+ return {
257
+ "epoch": self.current_epoch(),
258
+ "seconds_into_epoch": round(self.seconds_into_epoch(), 1),
259
+ "epoch_seconds": self.cfg.epoch_seconds,
260
+ "halving_epochs": self.cfg.halving_epochs,
261
+ "multiplier": round(self.reward_multiplier(), 4),
262
+ "rates": {
263
+ "margin": self.cfg.margin_rate,
264
+ "platform": self.cfg.platform_rate,
265
+ "initial_bonus": self.cfg.initial_bonus_rate,
266
+ },
267
+ "totals": {k: round(v, 4) if isinstance(v, float) else v
268
+ for k, v in self.totals.items()},
269
+ "curve": self.curve(),
270
+ }