Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/www/js/governor.js

14.5 KiB, 1 run

created by r2519314175:1369, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1/* ============================================================
2 Daimond — spend governor (DaimondGovernor)
3 ------------------------------------------------------------
4 A quiet speed limit on money, not a fuel gauge on it.
5
6 Every existing spend control in Daimond, and in every rival,
7 watches a TOTAL: a monthly budget, a balance, a cap. A total
8 is the wrong thing to watch for the failure that actually
9 hurts — a fan-out of agents burning a week's credit in
10 seconds. That is a RATE, and by the time a total notices, the
11 money is gone.
12
13 So this module watches the rate. It does two things, and only
14 these two, so that in normal use it is silent and never in the
15 way:
16
17 1. A predictive gate on the conductor's fan-out. When a
18 Diamond dispatches N workers, the cost of that batch is
19 known BEFORE a single one runs (N times what a worker
20 typically costs). If that would push a burst past its
21 budget, the gate pauses and asks — once, at the one
22 moment it matters. A batch of a few normal agents sails
23 through untouched; a batch of fifty does not.
24
25 2. A learned sense of "faster than usual". The baseline is
26 the user's OWN recent spend, so a runaway is defined
27 relative to their normal rather than an absolute number
28 nobody ever sets. When the live rate runs well above that
29 baseline, a calm amber note appears. It informs; it does
30 not block. The only thing that ever blocks is (1).
31
32 The decision logic here is pure and separately testable; the
33 modal and the DOM live in daimond.js, which owns them. This
34 module holds state and answers questions.
35
36 Depends on `window.DaimondLedger` (loaded first) for the
37 baseline. Attaches a single global, `window.DaimondGovernor`.
38 Also exported for Node, so the pure core can be unit-tested
39 without a browser.
40 ============================================================ */
41(function () {
42 'use strict';
43
44 // ── Tunables ───────────────────────────────────────────────
45 // Deliberately generous. The cost of a false alarm is a user
46 // who learns to ignore the gate, so every threshold errs
47 // towards silence and only the genuinely surprising trips it.
48
49 var SETTINGS_KEY = 'daimond-governor'; // per-account (accounts.js namespaces daimond-*)
50
51 // What one worker costs when there is no history to say
52 // otherwise. A new account has no baseline, so the gate falls
53 // back to this — small enough that a handful of agents never
54 // trips, large enough that a big fan-out does.
55 var FALLBACK_WORKER_USD = 0.08;
56
57 // The auto-derived per-burst budget: the larger of this floor
58 // and a multiple of the user's typical turn, so it scales with
59 // how the person actually works.
60 var DEFAULT_BUDGET_USD = 1.00;
61 var BUDGET_TURN_MULTIPLE = 12;
62
63 // A gap longer than this ends a "burst": a stretch of activity
64 // with no real pause. Runaways happen inside one burst; a burst
65 // that has gone quiet resets the running total.
66 var BURST_GAP_MS = 45 * 1000;
67
68 // The window over which the live rate is measured.
69 var RATE_WINDOW_MS = 60 * 1000;
70
71 // Below this the rate is never called fast, however it compares
72 // to the baseline: nobody wants an amber note over pennies.
73 var MIN_RATE_FLOOR_USD_MIN = 0.50;
74
75 // The live rate must exceed the baseline by this factor to go
76 // amber.
77 var AMBER_MULTIPLE = 3;
78
79 // Minimum ledger samples before a learned baseline is trusted;
80 // below it, the fallbacks stand in.
81 var MIN_SAMPLES = 6;
82
83 // ── Pure core ──────────────────────────────────────────────
84 // No DOM, no storage, no clock. Everything a decision needs is
85 // passed in, so the same functions run under Node in the tests.
86
87 /// The median of a numeric array, or 0 for an empty one.
88 function median(xs) {
89 if (!xs || !xs.length) return 0;
90 var s = xs.slice().sort(function (a, b) { return a - b; });
91 var m = Math.floor(s.length / 2);
92 return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
93 }
94
95 /// A learned baseline from raw ledger samples `[{ t, u }]`.
96 ///
97 /// Returns `{ perTurnUsd, rateUsdMin, n, learned }`. When there
98 /// are too few priced turns to trust, `learned` is false and the
99 /// figures are the fallbacks — a caller can still use them, it
100 /// just knows they are assumed rather than measured.
101 function baselineFrom(samples) {
102 var costs = [];
103 for (var i = 0; samples && i < samples.length; i++) {
104 var u = samples[i] && samples[i].u;
105 if (typeof u === 'number' && u > 0) costs.push(u);
106 }
107 var learned = costs.length >= MIN_SAMPLES;
108 var perTurn = learned ? median(costs) : FALLBACK_WORKER_USD;
109 if (!(perTurn > 0)) perTurn = FALLBACK_WORKER_USD;
110 // A "normal fast" pace: about two typical turns a minute,
111 // never below the pennies floor. This is what the live rate
112 // is judged against, not an absolute the user must invent.
113 var rate = Math.max(MIN_RATE_FLOOR_USD_MIN, perTurn * 2);
114 return { perTurnUsd: perTurn, rateUsdMin: rate, n: costs.length, learned: learned };
115 }
116
117 /// The predicted cost of dispatching `n` workers, given a
118 /// baseline. Pure multiplication — the point is that it is known
119 /// before any worker runs.
120 function estimateBatch(n, baseline) {
121 var per = (baseline && baseline.perTurnUsd > 0) ? baseline.perTurnUsd : FALLBACK_WORKER_USD;
122 return Math.max(0, (n || 0)) * per;
123 }
124
125 /// The auto budget for one burst, from the baseline: a multiple
126 /// of a typical turn, floored so it is never trivially small.
127 function autoBudget(baseline) {
128 var per = (baseline && baseline.perTurnUsd > 0) ? baseline.perTurnUsd : FALLBACK_WORKER_USD;
129 return Math.max(DEFAULT_BUDGET_USD, per * BUDGET_TURN_MULTIPLE);
130 }
131
132 /// Decide whether a dispatch of `n` workers needs a look.
133 ///
134 /// `runSpent` is what the current burst has already cost;
135 /// `budget` is the burst's ceiling. The batch needs confirming
136 /// when what the burst has spent plus what this batch is
137 /// predicted to spend would cross the budget. That is the whole
138 /// rule: cheap batches, and batches inside a fresh budget, pass
139 /// silently; the one that would run the burst away does not.
140 ///
141 /// Returns `{ needsConfirm, predicted, perWorker, runSpent,
142 /// budget, projected }`.
143 function decideDispatch(n, baseline, runSpent, budget) {
144 var per = (baseline && baseline.perTurnUsd > 0) ? baseline.perTurnUsd : FALLBACK_WORKER_USD;
145 var predicted = estimateBatch(n, baseline);
146 var spent = Math.max(0, runSpent || 0);
147 var cap = (budget > 0) ? budget : autoBudget(baseline);
148 var projected = spent + predicted;
149 return {
150 needsConfirm: projected > cap,
151 predicted: predicted,
152 perWorker: per,
153 runSpent: spent,
154 budget: cap,
155 projected: projected,
156 n: Math.max(0, n || 0),
157 };
158 }
159
160 /// The live rate, in dollars per minute, from observations
161 /// `[{ t, u }]` and a `now`, over the rate window.
162 function velocityFrom(obs, now, windowMs) {
163 var w = windowMs || RATE_WINDOW_MS;
164 var since = now - w;
165 var usd = 0;
166 for (var i = 0; obs && i < obs.length; i++) {
167 if (obs[i] && obs[i].t >= since) usd += (obs[i].u || 0);
168 }
169 return usd / (w / 60000); // per minute
170 }
171
172 /// Classify the current state: 'green', 'amber' or 'tripped'.
173 ///
174 /// Amber is "faster than usual and above the pennies floor".
175 /// Tripped is reserved for the burst having already run past its
176 /// budget — a state the dispatch gate normally prevents, kept so
177 /// a caller can show it if a burst gets there another way.
178 function levelFor(rateUsdMin, baseline, runSpent, budget) {
179 var base = (baseline && baseline.rateUsdMin > 0) ? baseline.rateUsdMin : MIN_RATE_FLOOR_USD_MIN;
180 var cap = (budget > 0) ? budget : autoBudget(baseline);
181 if ((runSpent || 0) > cap) return 'tripped';
182 if (rateUsdMin > MIN_RATE_FLOOR_USD_MIN && rateUsdMin > base * AMBER_MULTIPLE) return 'amber';
183 return 'green';
184 }
185
186 // ── Stateful shell ─────────────────────────────────────────
187 // The browser side: reads the ledger for a baseline, keeps the
188 // recent observations and the current burst, and reads the
189 // clock. None of this runs under Node — the export at the foot
190 // hands out the pure core only.
191
192 var _obs = []; // recent observations {t,u}, pruned to the burst window
193 var _burstStart = 0; // epoch-ms the current burst began
194 var _burstSpent = 0; // USD spent since _burstStart
195 var _lastObs = 0; // epoch-ms of the last observation
196
197 function now() {
198 return (typeof Date !== 'undefined') ? Date.now() : 0;
199 }
200
201 function readSettings() {
202 try {
203 var raw = localStorage.getItem(SETTINGS_KEY);
204 var o = raw ? JSON.parse(raw) : {};
205 return (o && typeof o === 'object') ? o : {};
206 } catch (e) { return {}; }
207 }
208
209 function writeSettings(o) {
210 try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(o || {})); } catch (e) { /* quota */ }
211 }
212
213 /// The learned baseline from the live ledger, or the fallbacks
214 /// if the ledger is absent or thin.
215 function baseline() {
216 var samples = [];
217 try {
218 if (window.DaimondLedger && typeof DaimondLedger.samples === 'function') {
219 samples = DaimondLedger.samples();
220 }
221 } catch (e) { samples = []; }
222 return baselineFrom(samples);
223 }
224
225 /// The current burst's budget: the user's set figure if they
226 /// have one, else the auto figure from the baseline.
227 function budget() {
228 var s = readSettings();
229 if (typeof s.budgetUsd === 'number' && s.budgetUsd > 0) return s.budgetUsd;
230 return autoBudget(baseline());
231 }
232
233 /// Feed one completed turn to the governor. Accepts a ledger
234 /// entry (`{ t, u }`) or a plain `{ ts, usd }`; anything without
235 /// a usable cost is ignored. Rolls the burst forward, starting a
236 /// fresh one after a real pause.
237 function observe(entry) {
238 if (!entry) return;
239 var t = (typeof entry.t === 'number') ? entry.t : entry.ts;
240 var u = (typeof entry.u === 'number') ? entry.u : entry.usd;
241 if (typeof t !== 'number') t = now();
242 if (typeof u !== 'number' || !(u >= 0)) return;
243
244 if (_burstStart === 0 || (t - _lastObs) > BURST_GAP_MS) {
245 // A new burst: the last one has gone quiet.
246 _burstStart = t;
247 _burstSpent = 0;
248 _obs = [];
249 }
250 _lastObs = t;
251 _burstSpent += u;
252 _obs.push({ t: t, u: u });
253 // Keep only what the rate window and burst need.
254 var cutoff = t - Math.max(RATE_WINDOW_MS, BURST_GAP_MS);
255 _obs = _obs.filter(function (o) { return o.t >= cutoff; });
256 }
257
258 // ── The pause, answered through the same gate ──────────────
259 // A paused node comes back REFUSED rather than thrown, so the
260 // caller has one path and not two. The rate gate asks; a pause
261 // does not, and `refused` is what says which of the two this is.
262
263 /// What the app says. The table lives in i18n/en.js.
264 function tr(k, v) { return window.DaimondI18n ? DaimondI18n.t(k, v) : k; }
265
266 /// The words for a refusal: what was not done, and where the control
267 /// is. English until the key exists, so nothing shows a bare key.
268 function pauseWords(node) {
269 var s = tr('pause.refused.dispatch', { node: node });
270 if (s === 'pause.refused.dispatch') {
271 // A byte-for-byte copy of the catalogue entry, `{node}` filled in here.
272 // Assembling a second wording is how this drifted from `en.js` unnoticed.
273 s = ('{node} is paused. No agents dispatched, nothing spent. '
274 + 'Press play on it to resume.').replace(/\{node\}/g, node);
275 }
276 return s;
277 }
278
279 /// Assess a dispatch of `n` workers against the current burst.
280 /// Pure decision, live inputs. daimond.js turns a
281 /// `needsConfirm: true` into the app's own confirm modal.
282 ///
283 /// `node` is the pause-tree leaf dispatching — a Diamond's `self`, or
284 /// a triggered action. A paused one adds `refused: true`, the node
285 /// and the words; `needsConfirm` rides with it so a caller written
286 /// before the pause existed still stops rather than dispatching.
287 /// Only the pump's own leaf is left out of this: pausing the pump
288 /// QUEUES work rather than refusing it, which is what the hold in
289 /// daimond.js has always meant and what resuming it puts back.
290 function assessDispatch(n, node) {
291 // A dispatch that lands after a pause opens its own burst, so
292 // its budget is fresh rather than charged with an idle history.
293 var t = now();
294 var spent = (t - _lastObs > BURST_GAP_MS) ? 0 : _burstSpent;
295 var d = decideDispatch(n, baseline(), spent, budget());
296 d.refused = !!(node && window.DaimondPause && DaimondPause.isPaused(node));
297 if (d.refused) {
298 d.needsConfirm = true;
299 d.pauseNode = node;
300 d.refusal = pauseWords(node);
301 }
302 return d;
303 }
304
305 /// The current state for the quiet meter: level, live rate, and
306 /// what the burst has spent against its budget.
307 function status() {
308 var t = now();
309 var b = baseline();
310 var cap = budget();
311 var stale = (t - _lastObs) > BURST_GAP_MS;
312 var spent = stale ? 0 : _burstSpent;
313 var rate = stale ? 0 : velocityFrom(_obs, t, RATE_WINDOW_MS);
314 return {
315 level: stale ? 'green' : levelFor(rate, b, spent, cap),
316 rateUsdMin: rate,
317 burstSpent: spent,
318 budget: cap,
319 baseline: b,
320 };
321 }
322
323 /// Read/adjust the per-burst budget. `setBudget(null)` clears it
324 /// back to the auto figure.
325 function getBudget() { return budget(); }
326 function setBudget(usd) {
327 var s = readSettings();
328 if (usd == null || !(usd > 0)) delete s.budgetUsd;
329 else s.budgetUsd = usd;
330 writeSettings(s);
331 }
332
333 /// Forget the current burst (e.g. on unlock/account switch), so
334 /// one account's activity never colours another's.
335 function reset() { _obs = []; _burstStart = 0; _burstSpent = 0; _lastObs = 0; }
336
337 var api = {
338 // Live API used by daimond.js.
339 observe: observe,
340 assessDispatch: assessDispatch,
341 status: status,
342 baseline: baseline,
343 getBudget: getBudget,
344 setBudget: setBudget,
345 reset: reset,
346 // Pure core, exposed for tests and for reuse.
347 _core: {
348 median: median,
349 baselineFrom: baselineFrom,
350 estimateBatch: estimateBatch,
351 autoBudget: autoBudget,
352 decideDispatch: decideDispatch,
353 velocityFrom: velocityFrom,
354 levelFor: levelFor,
355 consts: {
356 FALLBACK_WORKER_USD: FALLBACK_WORKER_USD,
357 DEFAULT_BUDGET_USD: DEFAULT_BUDGET_USD,
358 BUDGET_TURN_MULTIPLE: BUDGET_TURN_MULTIPLE,
359 BURST_GAP_MS: BURST_GAP_MS,
360 RATE_WINDOW_MS: RATE_WINDOW_MS,
361 MIN_RATE_FLOOR_USD_MIN: MIN_RATE_FLOOR_USD_MIN,
362 AMBER_MULTIPLE: AMBER_MULTIPLE,
363 MIN_SAMPLES: MIN_SAMPLES,
364 },
365 },
366 };
367
368 if (typeof window !== 'undefined') window.DaimondGovernor = api;
369 if (typeof module !== 'undefined' && module.exports) module.exports = api;
370})();