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 | })(); |