dexterm-cli 0.2.0 → 0.2.1

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.
Files changed (138) hide show
  1. package/dist/packages/agents/src/spectator.js +28 -2
  2. package/dist/packages/agents/src/tiers.js +3 -0
  3. package/dist/packages/agents/src/tools.js +3 -0
  4. package/dist/packages/autotrade/src/backtest.js +6 -2
  5. package/dist/packages/autotrade/src/fleet.js +2 -2
  6. package/dist/packages/autotrade/src/index.js +2 -0
  7. package/dist/packages/autotrade/src/rugproof-probe.js +415 -0
  8. package/dist/packages/autotrade/src/rugproof.js +231 -0
  9. package/dist/packages/autotrade/src/runner.js +132 -17
  10. package/dist/packages/autotrade/src/service-install.js +6 -4
  11. package/dist/packages/autotrade/src/sessions.js +16 -7
  12. package/dist/packages/autotrade/src/strategy-stats.js +344 -0
  13. package/dist/packages/autotrade/src/strategy.js +12 -1
  14. package/dist/packages/classifier/src/calibrate.js +138 -0
  15. package/dist/packages/classifier/src/classify.js +107 -0
  16. package/dist/packages/classifier/src/config.js +220 -0
  17. package/dist/packages/classifier/src/dossier.js +95 -0
  18. package/dist/packages/classifier/src/hardware.js +63 -0
  19. package/dist/packages/classifier/src/index.js +18 -0
  20. package/dist/packages/classifier/src/models.js +111 -0
  21. package/dist/packages/classifier/src/providers/heuristic.js +77 -0
  22. package/dist/packages/classifier/src/providers/http.js +63 -0
  23. package/dist/packages/classifier/src/providers/jev-open.js +86 -0
  24. package/dist/packages/classifier/src/providers/llm.js +89 -0
  25. package/dist/packages/classifier/src/providers/systemone-http.js +95 -0
  26. package/dist/packages/classifier/src/questions.js +97 -0
  27. package/dist/packages/classifier/src/registry.js +42 -0
  28. package/dist/packages/classifier/src/snapshot.js +31 -0
  29. package/dist/packages/classifier/src/systemone.js +88 -0
  30. package/dist/packages/classifier/src/types.js +18 -0
  31. package/dist/packages/cli/assets/dash/dash.css +139 -0
  32. package/dist/packages/cli/assets/dash/dash.js +200 -0
  33. package/dist/packages/cli/assets/dash/icon.svg +1 -0
  34. package/dist/packages/cli/assets/dash/index.html +36 -0
  35. package/dist/packages/cli/src/app-help-gen.js +12 -0
  36. package/dist/packages/cli/src/autotrade-cmd/fleet.js +9 -2
  37. package/dist/packages/cli/src/autotrade-cmd/run.js +11 -1
  38. package/dist/packages/cli/src/autotrade-cmd/signal.js +2 -2
  39. package/dist/packages/cli/src/classifier-cmd.js +353 -0
  40. package/dist/packages/cli/src/classifier-live.js +81 -0
  41. package/dist/packages/cli/src/dash/data.js +217 -0
  42. package/dist/packages/cli/src/dash/server.js +92 -0
  43. package/dist/packages/cli/src/dash-cmd.js +113 -0
  44. package/dist/packages/cli/src/doctor.js +34 -0
  45. package/dist/packages/cli/src/live/screens/token-live.js +4 -0
  46. package/dist/packages/cli/src/live/signals.js +8 -0
  47. package/dist/packages/cli/src/main.js +98 -6
  48. package/dist/packages/cli/src/mcp-follow.js +21 -1
  49. package/dist/packages/cli/src/narratives-cmd.js +147 -0
  50. package/dist/packages/cli/src/narratives.js +114 -0
  51. package/dist/packages/cli/src/overlay/model.js +147 -0
  52. package/dist/packages/cli/src/overlay/page.js +146 -0
  53. package/dist/packages/cli/src/overlay/pnl.js +75 -0
  54. package/dist/packages/cli/src/overlay/server.js +170 -0
  55. package/dist/packages/cli/src/overlay-cmd.js +367 -0
  56. package/dist/packages/cli/src/record-cmd.js +82 -11
  57. package/dist/packages/cli/src/rules-cmd.js +2 -1
  58. package/dist/packages/cli/src/rules.js +24 -4
  59. package/dist/packages/cli/src/screens/narrative.js +154 -2
  60. package/dist/packages/cli/src/state.js +1 -0
  61. package/dist/packages/cli/src/version.js +1 -1
  62. package/dist/packages/custom/src/rules.js +5 -1
  63. package/dist/packages/mcp/assets/kb/app-help/classification-models.md +41 -0
  64. package/dist/packages/mcp/assets/kb/app-help/narratives-tracker.md +45 -0
  65. package/dist/packages/mcp/assets/kb/app-help/strategy-dashboard.md +38 -0
  66. package/dist/packages/mcp/assets/kb/app-help/streamer-overlay.md +36 -0
  67. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-rug-proof-entries-experimental.md +52 -0
  68. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-the-always-on-service.md +3 -1
  69. package/dist/packages/mcp/assets/kb/docs/docs-autotrade-the-strategy-dashboard-dexterm-dash.md +37 -0
  70. package/dist/packages/mcp/assets/kb/docs/docs-autotrade.md +3 -1
  71. package/dist/packages/mcp/assets/kb/docs/docs-beginners-before-any-experimental-trading.md +2 -2
  72. package/dist/packages/mcp/assets/kb/docs/docs-beginners-copy-and-paste-without-surprises.md +0 -1
  73. package/dist/packages/mcp/assets/kb/docs/docs-beginners-if-dexterm-says-command-not-found.md +2 -2
  74. package/dist/packages/mcp/assets/kb/docs/docs-beginners-reopen-dexterm-later.md +1 -1
  75. package/dist/packages/mcp/assets/kb/docs/docs-beginners-terminal-shell-prompt-command-output-and-path.md +2 -2
  76. package/dist/packages/mcp/assets/kb/docs/docs-beginners-windows-expectations.md +1 -1
  77. package/dist/packages/mcp/assets/kb/docs/docs-beginners-windows.md +1 -1
  78. package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat-the-ladder.md +2 -0
  79. package/dist/packages/mcp/assets/kb/docs/docs-build-a-strategy-by-chat.md +2 -0
  80. package/dist/packages/mcp/assets/kb/docs/docs-classifier-hosted-jev-with-your-own-key.md +34 -0
  81. package/dist/packages/mcp/assets/kb/docs/docs-classifier-how-they-measured-on-dexterm-s-own-history.md +52 -0
  82. package/dist/packages/mcp/assets/kb/docs/docs-classifier-set-up-kev-on-your-own-machine.md +39 -0
  83. package/dist/packages/mcp/assets/kb/docs/docs-classifier-settings.md +35 -0
  84. package/dist/packages/mcp/assets/kb/docs/docs-classifier-the-choices.md +36 -0
  85. package/dist/packages/mcp/assets/kb/docs/docs-classifier-where-it-plugs-in.md +29 -0
  86. package/dist/packages/mcp/assets/kb/docs/docs-classifier-which-model-for-which-machine.md +42 -0
  87. package/dist/packages/mcp/assets/kb/docs/docs-classifier.md +32 -0
  88. package/dist/packages/mcp/assets/kb/docs/docs-cli-invocations.md +1 -1
  89. package/dist/packages/mcp/assets/kb/docs/docs-configuration-headless-recorder-dexterm-record.md +3 -0
  90. package/dist/packages/mcp/assets/kb/docs/docs-copy-trading.md +1 -1
  91. package/dist/packages/mcp/assets/kb/docs/docs-data-sources-recorded-history-and-retention.md +1 -0
  92. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-install.md +3 -3
  93. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-requirements.md +2 -2
  94. package/dist/packages/mcp/assets/kb/docs/docs-getting-started-supported-platforms.md +2 -11
  95. package/dist/packages/mcp/assets/kb/docs/docs-getting-started.md +2 -2
  96. package/dist/packages/mcp/assets/kb/docs/docs-larp-measured-accuracy.md +2 -2
  97. package/dist/packages/mcp/assets/kb/docs/docs-larp-paste-a-coin-on-the-web.md +1 -1
  98. package/dist/packages/mcp/assets/kb/docs/docs-larp-the-larp-score.md +1 -1
  99. package/dist/packages/mcp/assets/kb/docs/docs-narratives-how-coins-are-grouped.md +39 -0
  100. package/dist/packages/mcp/assets/kb/docs/docs-narratives-limits.md +29 -0
  101. package/dist/packages/mcp/assets/kb/docs/docs-narratives-what-is-measured.md +44 -0
  102. package/dist/packages/mcp/assets/kb/docs/docs-narratives-what-testing-showed.md +43 -0
  103. package/dist/packages/mcp/assets/kb/docs/docs-narratives-where-to-find-it.md +37 -0
  104. package/dist/packages/mcp/assets/kb/docs/docs-narratives.md +29 -0
  105. package/dist/packages/mcp/assets/kb/docs/docs-overlay-before-you-start.md +33 -0
  106. package/dist/packages/mcp/assets/kb/docs/docs-overlay-limits.md +30 -0
  107. package/dist/packages/mcp/assets/kb/docs/docs-overlay-options.md +36 -0
  108. package/dist/packages/mcp/assets/kb/docs/docs-overlay-security-model.md +36 -0
  109. package/dist/packages/mcp/assets/kb/docs/docs-overlay-set-it-up-in-obs-step-by-step.md +49 -0
  110. package/dist/packages/mcp/assets/kb/docs/docs-overlay-the-pnl-scene-paper-and-live.md +34 -0
  111. package/dist/packages/mcp/assets/kb/docs/docs-overlay-the-three-scenes.md +35 -0
  112. package/dist/packages/mcp/assets/kb/docs/docs-overlay-what-is-redacted.md +31 -0
  113. package/dist/packages/mcp/assets/kb/docs/docs-overlay.md +25 -0
  114. package/dist/packages/mcp/assets/kb/docs/docs-paper-trading.md +1 -1
  115. package/dist/packages/mcp/assets/kb/docs/docs-real-mode-what-it-is.md +2 -0
  116. package/dist/packages/mcp/assets/kb/docs/docs-screens-bubble-map.md +1 -1
  117. package/dist/packages/mcp/assets/kb/docs/docs-spectator-streaming-it.md +1 -0
  118. package/dist/packages/mcp/assets/kb/docs/docs-telegram-what-it-is.md +1 -1
  119. package/dist/packages/mcp/assets/kb/docs/docs-voice-linux-and-windows.md +2 -2
  120. package/dist/packages/mcp/assets/kb/entries/narratives-tracker.md +45 -0
  121. package/dist/packages/mcp/src/server.js +104 -0
  122. package/dist/packages/mcp/src/spectator-sink.js +27 -0
  123. package/dist/packages/narratives/src/cluster.js +193 -0
  124. package/dist/packages/narratives/src/embed.js +184 -0
  125. package/dist/packages/narratives/src/export.js +60 -0
  126. package/dist/packages/narratives/src/index.js +10 -0
  127. package/dist/packages/narratives/src/label.js +48 -0
  128. package/dist/packages/narratives/src/normalize.js +179 -0
  129. package/dist/packages/narratives/src/recorder.js +93 -0
  130. package/dist/packages/narratives/src/tracker.js +328 -0
  131. package/dist/packages/recorder/src/exit-probes.js +215 -0
  132. package/dist/packages/recorder/src/index.js +1 -0
  133. package/dist/packages/recorder/src/recorder.js +11 -1
  134. package/dist/packages/recorder/src/retention.js +4 -0
  135. package/dist/packages/recorder/src/schema.js +20 -1
  136. package/dist/packages/tui/src/keymap.js +5 -0
  137. package/package.json +3 -1
  138. package/dist/packages/mcp/assets/kb/docs/docs-beginners-windows-powershell-experimental.md +0 -39
@@ -0,0 +1,344 @@
1
+ // dexterm autotrade — STRATEGY STATS for the performance dashboard (task-1749). Read-only over the run ledgers
2
+ // ($DEXTERM_HOME/autotrade/runs/*.jsonl): no network, no model, no key, never writes.
3
+ //
4
+ // The definitions are the challenge control's (launch/send-out/challenge-100x/control/report.mjs + mine.mjs, task-1697 /
5
+ // task-1733), so the dashboard and the control report count the same things the same way:
6
+ // entries each position once across restarts (its first fill or adoption), keyed per run FILE + engine id
7
+ // closed `trade_closed` (deduped) + WRITE-OFFS: a position the engine marked `stuck` (no pool to sell into) with no
8
+ // sell for >= 30 min, or dropped stuck at a clean run end, closes at its REALIZABLE value: the sells it made
9
+ // are kept, the unsold remainder counts 0. Derived here; the engine's ledgers are never edited.
10
+ // abandoned still open when a run ended cleanly and not stuck: the engine does not carry it, its outcome never enters
11
+ // the closed sample
12
+ // rugs a closed position at <= -80% on cost (a write-off is a rug only at <= -80%)
13
+ // mean mean % on cost with a 95% seeded percentile bootstrap (4000 resamples, seed 7); a difference of two
14
+ // strategies is a bootstrap of independent groups (seed 11); rates use the Wilson 95% interval
15
+ // sample under 20 closed is EXPLORATORY; the ETA to 20 closed = (20 - closed) / entries per day since the first event
16
+ // One difference from report.mjs (a fix, test/strategy-stats-1749.test.ts): the engine's crash recovery names an adopted
17
+ // position `<runId>/pN` while that run's own fills said `pN`; report.mjs keys `pN` and so counted an adopted position
18
+ // two or three times. Here `<runId>/pN` maps onto the position `pN` of the file `<runId>.jsonl`.
19
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+ export const RUG_PCT = -80;
22
+ export const MIN_CLOSED = 20;
23
+ export const STUCK_WRITE_OFF_MIN = 30;
24
+ const DAY = 86_400_000;
25
+ /** mulberry32: the seeded generator of control/mine.mjs (same seed, same intervals). */
26
+ export function rng(seed = 7) {
27
+ let a = seed >>> 0;
28
+ return () => { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
29
+ }
30
+ const mean = (xs) => xs.reduce((s, x) => s + x, 0) / xs.length;
31
+ const pctile = (xs, p) => { const s = [...xs].sort((a, b) => a - b); return s[Math.min(s.length - 1, Math.max(0, Math.floor(p * s.length)))]; };
32
+ /** Mean with a 95% percentile-bootstrap interval (control/mine.mjs meanCi). */
33
+ export function meanCi(xs, { B = 4000, seed = 7 } = {}) {
34
+ if (!xs.length)
35
+ return { n: 0, mean: null, lo: null, hi: null };
36
+ const r = rng(seed);
37
+ const ms = [];
38
+ for (let b = 0; b < B; b++) {
39
+ let s = 0;
40
+ for (let i = 0; i < xs.length; i++)
41
+ s += xs[Math.floor(r() * xs.length)];
42
+ ms.push(s / xs.length);
43
+ }
44
+ return { n: xs.length, mean: mean(xs), lo: pctile(ms, 0.025), hi: pctile(ms, 0.975) };
45
+ }
46
+ /** Difference of means (a - b), independent groups, bootstrap 95% (control/mine.mjs diffCi). */
47
+ export function diffCi(a, b, { B = 4000, seed = 11 } = {}) {
48
+ if (!a.length || !b.length)
49
+ return { mean: null, lo: null, hi: null };
50
+ const r = rng(seed);
51
+ const ds = [];
52
+ for (let k = 0; k < B; k++) {
53
+ let sa = 0, sb = 0;
54
+ for (let i = 0; i < a.length; i++)
55
+ sa += a[Math.floor(r() * a.length)];
56
+ for (let i = 0; i < b.length; i++)
57
+ sb += b[Math.floor(r() * b.length)];
58
+ ds.push(sa / a.length - sb / b.length);
59
+ }
60
+ return { mean: mean(a) - mean(b), lo: pctile(ds, 0.025), hi: pctile(ds, 0.975) };
61
+ }
62
+ /** Wilson 95% interval for k of n, as fractions (control/mine.mjs wilson). */
63
+ export function wilsonCi(k, n) {
64
+ if (!n)
65
+ return [null, null];
66
+ const z = 1.96, p = k / n, d = 1 + z * z / n, c = p + z * z / (2 * n), h = z * Math.sqrt(p * (1 - p) / n + z * z / (4 * n * n));
67
+ return [Math.max(0, (c - h) / d), Math.min(1, (c + h) / d)];
68
+ }
69
+ /**
70
+ * Every strategy (fleet member) in one runs directory, its positions tracked across run files (control/report.mjs
71
+ * readMembers, rules in the header). `now` decides which stuck positions are already written off.
72
+ */
73
+ export function readStrategyLedgers(runsDir, { now = Date.now(), writeOffMin = STUCK_WRITE_OFF_MIN } = {}) {
74
+ const M = {};
75
+ if (!existsSync(runsDir))
76
+ return M;
77
+ const symbols = new Map(); // mint -> symbol, from the candidate lines (fills carry no symbol)
78
+ const entryTick = new Map(); // mint -> the candidate tick that preceded the entry (pool age, liquidity, mcap)
79
+ const state = {};
80
+ const fin = (x) => typeof x === 'number' && Number.isFinite(x);
81
+ for (const f of readdirSync(runsDir).filter((x) => x.endsWith('.jsonl') && !/-fleet-/.test(x)).sort()) {
82
+ let member = null;
83
+ let text;
84
+ try {
85
+ text = readFileSync(join(runsDir, f), 'utf8');
86
+ }
87
+ catch {
88
+ continue;
89
+ }
90
+ for (const line of text.split('\n')) {
91
+ if (!line)
92
+ continue;
93
+ let e;
94
+ try {
95
+ e = JSON.parse(line);
96
+ }
97
+ catch {
98
+ continue;
99
+ }
100
+ if (e.kind === 'fleet_member' || e.kind === 'run_start') {
101
+ member ??= e.member ?? e.strategy ?? null;
102
+ if (member && !M[member]) {
103
+ M[member] = { files: 0, entries: 0, closed: [], open: 0, stuckOpen: [], openPositions: [], abandoned: [], writtenOff: [], adopted: 0, lastClampT: null, firstT: null, lastT: null, candidates: 0, entryTimes: [], startUsd: null, mode: null, basedOn: null };
104
+ state[member] = { pos: new Map(), openNow: new Set(), closedKeys: new Set(), local: new Map(), file: null, carry: new Set() };
105
+ }
106
+ if (member) {
107
+ const m = M[member];
108
+ if (fin(e.bankroll?.usd) && m.startUsd === null)
109
+ m.startUsd = e.bankroll.usd;
110
+ m.mode ??= e.mode ?? null;
111
+ if (e.basedOn)
112
+ m.basedOn = e.basedOn;
113
+ if (state[member].file !== f) {
114
+ state[member].file = f;
115
+ state[member].local = new Map();
116
+ state[member].carry = new Set(state[member].openNow);
117
+ m.files++;
118
+ }
119
+ }
120
+ continue;
121
+ }
122
+ if (!member)
123
+ continue;
124
+ const m = M[member];
125
+ const s = state[member];
126
+ if (typeof e.t === 'number') {
127
+ m.firstT = m.firstT === null ? e.t : Math.min(m.firstT, e.t);
128
+ m.lastT = m.lastT === null ? e.t : Math.max(m.lastT, e.t);
129
+ }
130
+ // a restart adopts what it carries at the head of its file; a position of the previous (crashed) file it did not
131
+ // adopt is gone from the engine's book: "abandoned", not open forever
132
+ if (s.carry.size && e.kind !== 'position_adopted') {
133
+ for (const key of s.carry) {
134
+ const p = s.pos.get(key);
135
+ s.openNow.delete(key);
136
+ m.abandoned.push({ positionId: p.positionId, mint: p.mint, openedT: p.firstT, stoppedT: e.t, notCarried: true, _p: p });
137
+ }
138
+ s.carry.clear();
139
+ }
140
+ if (e.kind === 'clamp' && typeof e.t === 'number')
141
+ m.lastClampT = Math.max(m.lastClampT ?? 0, e.t);
142
+ if (e.kind === 'candidate') {
143
+ m.candidates++;
144
+ if (e.mint && e.symbol)
145
+ symbols.set(e.mint, e.symbol);
146
+ if (e.mint && e.tick)
147
+ entryTick.set(e.mint, e.tick);
148
+ continue;
149
+ }
150
+ // the open position of an earlier file this id + mint carries (an adoption), newest first; `<runId>/pN` names the
151
+ // position pN of the file <runId>.jsonl exactly
152
+ const carried = (id, mint) => {
153
+ const slash = id.lastIndexOf('/');
154
+ if (slash > 0) {
155
+ const k = `${id.slice(0, slash)}.jsonl#${id.slice(slash + 1)}`;
156
+ if (s.pos.has(k) && !s.closedKeys.has(k))
157
+ return k;
158
+ }
159
+ return [...s.openNow].reverse().find((k) => { const p = s.pos.get(k); return p.positionId === id && (!mint || !p.mint || p.mint === mint); }) ?? null;
160
+ };
161
+ const open = (id, mint, t, profile = null) => {
162
+ const key = `${f}#${id}`;
163
+ s.pos.set(key, { key, positionId: id, mint: mint ?? null, symbol: null, firstT: t, costUsd: 0, proceedsUsd: 0, stuckSince: null, stuckWhy: null, lastSellT: null, profile });
164
+ s.openNow.add(key);
165
+ s.local.set(id, key);
166
+ m.entries++;
167
+ if (t !== null)
168
+ m.entryTimes.push(t);
169
+ return key;
170
+ };
171
+ const keyOf = (id, mint, t, { create = true, profile = null } = {}) => {
172
+ if (s.local.has(id))
173
+ return s.local.get(id);
174
+ const c = carried(id, mint);
175
+ if (c) {
176
+ s.local.set(id, c);
177
+ return c;
178
+ }
179
+ return create ? open(id, mint, t, profile) : null;
180
+ };
181
+ if (e.kind === 'position_adopted' && e.positionId) {
182
+ const c = carried(e.positionId, e.mint);
183
+ const key = c ?? open(e.positionId, e.mint, e.position?.openedAt ?? e.t); // its fills were in a file that is gone: it counts here
184
+ s.carry.delete(key);
185
+ s.local.set(e.positionId, key);
186
+ m.adopted++;
187
+ const p = s.pos.get(key);
188
+ const snap = e.position ?? {};
189
+ if (!c && fin(snap.costUsd))
190
+ p.costUsd = snap.costUsd;
191
+ if (snap.stuck && p.stuckSince === null)
192
+ p.stuckSince = e.t;
193
+ p.symbol ??= e.symbol ?? null;
194
+ continue;
195
+ }
196
+ if (e.kind === 'fill' && e.positionId) {
197
+ const key = keyOf(e.positionId, e.mint, e.t, { profile: e.profile ?? null });
198
+ if (s.closedKeys.has(key))
199
+ continue;
200
+ const p = s.pos.get(key);
201
+ p.profile ??= e.profile ?? null;
202
+ if (e.side === 'sell') {
203
+ p.proceedsUsd += fin(e.usd) ? e.usd : 0;
204
+ p.lastSellT = e.t;
205
+ p.stuckSince = null;
206
+ p.stuckWhy = null;
207
+ } // a sale ends a stuck spell
208
+ else
209
+ p.costUsd += fin(e.usd) ? e.usd : 0;
210
+ continue;
211
+ }
212
+ if (e.kind === 'stuck' && e.positionId) {
213
+ const key = keyOf(e.positionId, e.mint, e.t, { create: false });
214
+ const p = key && s.pos.get(key);
215
+ if (p && p.stuckSince === null) {
216
+ p.stuckSince = e.t;
217
+ p.stuckWhy = `${e.reason ?? 'stuck'} (${e.why ?? 'cannot sell'})`;
218
+ }
219
+ continue;
220
+ }
221
+ if (e.kind === 'wake' && e.positionId && e.event === 'mechanical_exit') { // the engine's own words for the stuck exit
222
+ const key = s.local.get(e.positionId);
223
+ const p = key && s.pos.get(key);
224
+ if (p && p.stuckSince === e.t && e.detail)
225
+ p.stuckWhy = e.detail;
226
+ continue;
227
+ }
228
+ if (e.kind === 'trade_closed' && e.trade) {
229
+ const key = keyOf(e.trade.id, e.trade.mint, e.trade.openedAt ?? e.t);
230
+ if (s.closedKeys.has(key))
231
+ continue;
232
+ s.closedKeys.add(key);
233
+ s.openNow.delete(key);
234
+ const p = s.pos.get(key);
235
+ const tick = e.trade.mint ? entryTick.get(e.trade.mint) : undefined;
236
+ m.closed.push({ t: e.trade.closedAt ?? e.t, openedAt: e.trade.openedAt ?? null, mint: e.trade.mint ?? null, symbol: e.trade.symbol ?? null, pnlPct: e.trade.pnlPct, pnlUsd: e.trade.pnlUsd ?? null,
237
+ costUsd: fin(e.trade.costUsd) ? e.trade.costUsd : p?.costUsd ?? null, exitReason: e.trade.exitReason ?? null, stuck: !!e.trade.stuck, profile: e.trade.profileId ?? p?.profile ?? null,
238
+ entryAgeMin: fin(tick?.ageMin) ? tick.ageMin : null, entryLiqUsd: fin(tick?.liq) ? tick.liq : null, entryMcap: fin(tick?.mcap) ? tick.mcap : null });
239
+ continue;
240
+ }
241
+ if (e.kind === 'run_end') {
242
+ for (const key of s.openNow) {
243
+ const p = s.pos.get(key);
244
+ m.abandoned.push({ positionId: p.positionId, mint: p.mint, openedT: p.firstT, stoppedT: e.t, _p: p });
245
+ }
246
+ s.openNow.clear();
247
+ }
248
+ }
249
+ }
250
+ const writeOff = (m, p, endT, how) => {
251
+ const pnlUsd = Math.round((p.proceedsUsd - p.costUsd) * 1e4) / 1e4;
252
+ const pnlPct = p.costUsd > 0 ? Math.round((pnlUsd / p.costUsd) * 1e4) / 100 : null;
253
+ const tick = p.mint ? entryTick.get(p.mint) : undefined;
254
+ const w = { t: p.stuckSince, openedAt: p.firstT, mint: p.mint, symbol: p.symbol ?? (p.mint ? symbols.get(p.mint) ?? null : null), pnlPct, pnlUsd, costUsd: Math.round(p.costUsd * 1e4) / 1e4, realizedUsd: Math.round(p.proceedsUsd * 1e4) / 1e4,
255
+ exitReason: 'written-off', stuck: true, writtenOff: true, held: how === 'still held', profile: p.profile,
256
+ reason: `${how}: stuck ${Math.round((endT - p.stuckSince) / 60_000)} min with no sell (${p.stuckWhy ?? 'illiquid'}); unsold remainder valued 0`, stuckSince: p.stuckSince,
257
+ entryAgeMin: typeof tick?.ageMin === 'number' ? tick.ageMin : null, entryLiqUsd: typeof tick?.liq === 'number' ? tick.liq : null, entryMcap: typeof tick?.mcap === 'number' ? tick.mcap : null };
258
+ m.writtenOff.push(w);
259
+ if (pnlPct !== null)
260
+ m.closed.push(w);
261
+ };
262
+ for (const [id, m] of Object.entries(M)) {
263
+ const s = state[id];
264
+ m.abandoned = m.abandoned.filter((a) => { const p = a._p; delete a._p; if (p.stuckSince === null)
265
+ return true; writeOff(m, p, a.stoppedT ?? now, a.notCarried ? 'dropped stuck (not carried over by the restart)' : 'dropped stuck at a clean run end'); return false; });
266
+ for (const key of s.openNow) {
267
+ const p = s.pos.get(key);
268
+ if (p.stuckSince !== null && now - p.stuckSince >= writeOffMin * 60_000)
269
+ writeOff(m, p, now, 'still held');
270
+ else {
271
+ m.open++;
272
+ m.openPositions.push({ positionId: p.positionId, mint: p.mint, symbol: p.symbol ?? (p.mint ? symbols.get(p.mint) ?? null : null), openedAt: p.firstT, costUsd: Math.round(p.costUsd * 1e4) / 1e4, realizedUsd: Math.round(p.proceedsUsd * 1e4) / 1e4, stuckSince: p.stuckSince, profile: p.profile });
273
+ if (p.stuckSince !== null)
274
+ m.stuckOpen.push({ positionId: p.positionId, mint: p.mint, stuckSince: p.stuckSince });
275
+ }
276
+ }
277
+ for (const c of m.closed)
278
+ c.symbol ??= c.mint ? symbols.get(c.mint) ?? null : null;
279
+ m.closed.sort((a, b) => a.t - b.t);
280
+ }
281
+ return M;
282
+ }
283
+ /**
284
+ * REALIZABLE value of an open position (agent-v4/scoreboard.mjs): the engine's mark sold into the pool with the
285
+ * engine's cost model (constant-product impact on half the liquidity, fee + slippage bps, network fee). null = the
286
+ * pool's liquidity is unknown.
287
+ */
288
+ export function realizableUsd(markUsd, liqUsd, ex = { feeBps: 30, slippageBps: 200, networkFeeUsd: 0.02 }) {
289
+ if (!Number.isFinite(markUsd) || typeof liqUsd !== 'number' || !Number.isFinite(liqUsd) || liqUsd <= 0)
290
+ return null;
291
+ const side = liqUsd / 2;
292
+ const impact = markUsd / (side + markUsd);
293
+ return Math.max(0, markUsd * (1 - impact) * (1 - (ex.feeBps + ex.slippageBps) / 10_000) - ex.networkFeeUsd);
294
+ }
295
+ /** The realized equity curve over the closed sample (start + cumulative P/L in close order) and its drawdown from the running peak. */
296
+ export function equityCurve(closed, startUsd) {
297
+ const start = startUsd ?? 100;
298
+ let eq = start;
299
+ let peak = start;
300
+ let maxDd = 0;
301
+ const points = [];
302
+ for (const c of [...closed].sort((a, b) => a.t - b.t)) {
303
+ eq += typeof c.pnlUsd === 'number' && Number.isFinite(c.pnlUsd) ? c.pnlUsd : 0;
304
+ peak = Math.max(peak, eq);
305
+ const dd = peak > 0 ? (1 - eq / peak) * 100 : 0;
306
+ maxDd = Math.max(maxDd, dd);
307
+ points.push({ t: c.t, usd: Math.round(eq * 100) / 100, drawdownPct: Math.round(dd * 10) / 10 });
308
+ }
309
+ return { points, maxDrawdownPct: Math.round(maxDd * 10) / 10, start, end: Math.round(eq * 100) / 100 };
310
+ }
311
+ /** One strategy's row (control/report.mjs summarize: rugs, means, ETA), plus the exits breakdown and the sample badge. */
312
+ export function strategyRow(id, m, { now = Date.now(), minClosed = MIN_CLOSED } = {}) {
313
+ const xs = m.closed.map((c) => c.pnlPct).filter((x) => typeof x === 'number' && Number.isFinite(x));
314
+ const rugs = m.closed.filter((c) => (c.pnlPct ?? 0) <= RUG_PCT || (c.stuck && !c.writtenOff)).length;
315
+ const days = m.firstT === null ? 0 : (now - m.firstT) / DAY;
316
+ const perDay = days > 0 ? m.entries / days : null;
317
+ const ci = meanCi(xs);
318
+ const by = new Map();
319
+ for (const c of m.closed) {
320
+ const r = c.writtenOff ? 'written-off' : c.exitReason ?? 'unknown';
321
+ if (!by.has(r))
322
+ by.set(r, []);
323
+ if (typeof c.pnlPct === 'number')
324
+ by.get(r).push(c.pnlPct);
325
+ }
326
+ const exits = [...by].map(([reason, v]) => ({ reason, n: v.length, meanPct: v.length ? Math.round(mean(v) * 10) / 10 : null, wins: v.filter((x) => x > 0).length })).sort((a, b) => b.n - a.n);
327
+ const sample = xs.length === 0 ? 'none' : xs.length < minClosed ? 'exploratory' : 'minimum';
328
+ return {
329
+ id, entries: m.entries, closed: xs.length, open: m.open, writtenOff: m.writtenOff.length, stuckOpen: m.stuckOpen.length, abandoned: m.abandoned.length, adopted: m.adopted,
330
+ wins: xs.filter((x) => x > 0).length, losses: xs.filter((x) => x <= 0).length, rugs, rugRate: xs.length ? rugs / xs.length : null, rugCi: wilsonCi(rugs, xs.length),
331
+ meanPct: ci.mean, lo: ci.lo, hi: ci.hi, days, entriesPerDay: perDay, etaDays: perDay ? Math.max(0, (minClosed - xs.length) / perDay) : null,
332
+ sample, sampleText: sample === 'none' ? 'no closed trades yet' : sample === 'exploratory' ? `exploratory: ${xs.length} closed, < ${minClosed}` : `${xs.length} closed (>= ${minClosed})`,
333
+ realizedUsd: Math.round(m.closed.reduce((s, c) => s + (typeof c.pnlUsd === 'number' ? c.pnlUsd : 0), 0) * 100) / 100, exits,
334
+ };
335
+ }
336
+ /** The decision rule the control applies to a variant (control/report.mjs DECISION_RULE, baseline named). */
337
+ export const decisionRule = (base, minClosed = MIN_CLOSED) => `A filter or exit change is adopted only if it beats ${base} over >= ${minClosed} closed entries in BOTH strategies with the 95% interval of the difference in mean % per entry clear of zero (above 0). Until then nothing changes.`;
338
+ /** A variant against the baseline (control/report.mjs verdicts). */
339
+ export function compareToBase(id, a, base, b, minClosed = MIN_CLOSED) {
340
+ const d = diffCi(a, b);
341
+ const enough = a.length >= minClosed && b.length >= minClosed;
342
+ const verdict = !enough ? `not yet: ${a.length} / ${b.length} closed (needs ${minClosed} each)` : (d.lo ?? 0) > 0 ? 'passes the rule: beats the baseline' : (d.hi ?? 0) < 0 ? 'worse than the baseline' : 'no clear difference: keep running';
343
+ return { id, base, n: a.length, baseN: b.length, diff: d, enough, verdict };
344
+ }
@@ -72,6 +72,13 @@ export const SCHEMAS = {
72
72
  youngVertical: nullable(obj({ maxAgeHours: num(0, 168), maxH1Pct: num(0, 100000) })),
73
73
  unknownHolders: { type: 'string', enum: ['reject', 'allow'] },
74
74
  larp: nullable(obj({ maxScore: num(0, 100), unknown: { type: 'string', enum: ['reject', 'allow'] } })),
75
+ rugproof: nullable(obj({
76
+ minPoolAgeMin: num(0, 1440), requireSafePool: { type: 'boolean' }, maxHolderLpPct: num(0, 100), maxWithdraws: num(0, 100, true), withdrawScanTx: num(1, 100, true),
77
+ liquidityRecheck: nullable(obj({ afterSec: num(5, 600), floorX: num(1, 100), maxDropPct: num(0, 90) })),
78
+ routeX: nullable(num(1, 20)),
79
+ youngPool: nullable(obj({ underMin: num(1, 1440), maxSizePct: num(1, 100) })),
80
+ drainGuard: nullable(obj({ pollSec: num(2, 10), liqDropPct: num(5, 90), exitOnNoRoute: { type: 'boolean' }, minReserveSol: num(0, 1000) })),
81
+ })),
75
82
  }, ['maxTop10Pct', 'maxDevPct', 'requireNoMintAuthority', 'requireNoFreezeAuthority', 'maxMcapSplitX', 'youngVertical', 'unknownHolders']),
76
83
  triggers: { type: 'array', max: 8, items: 'trigger' },
77
84
  triggerMode: { type: 'string', enum: ['any', 'all'] },
@@ -103,7 +110,7 @@ export const SCHEMAS = {
103
110
  onTimeout: { type: 'string', enum: ['skip'] },
104
111
  respawnOnWake: { type: 'boolean' },
105
112
  }, ['model', 'maxSessions', 'budgetUsdPerSession', 'budgetUsdPerDay', 'maxTurns', 'deltaEverySec', 'decideWithinSec', 'sessionMaxMin', 'onTimeout']),
106
- execution: obj({ slippageBps: num(0, 5000), feeBps: num(0, 1000), networkFeeUsd: num(0, 10), latencyMs: num(0, 120000), maxPriceImpactPct: num(0.1, 50), minExitLiquidityUsd: num(0, 1e7) }),
113
+ execution: obj({ slippageBps: num(0, 5000), feeBps: num(0, 1000), networkFeeUsd: num(0, 10), latencyMs: num(0, 120000), maxPriceImpactPct: num(0.1, 50), minExitLiquidityUsd: num(0, 1e7), stuckWriteOffMin: nullable(num(1, 10080)) }, ['slippageBps', 'feeBps', 'networkFeeUsd', 'latencyMs', 'maxPriceImpactPct', 'minExitLiquidityUsd']),
107
114
  notes: { type: 'array', max: 8, items: str(300) },
108
115
  }, ['kind', 'schema', 'id', 'name', 'version', 'description', 'mode', 'bankroll', 'entry', 'exits', 'sizing', 'risk', 'agent', 'execution']),
109
116
  };
@@ -200,6 +207,8 @@ export function validateStrategy(doc, library = builtinProfiles()) {
200
207
  errors.push({ path: '/risk/drawdown', msg: 'sizeDownAtPct must not exceed haltAtPct' });
201
208
  if (r.drawdown.recoverBelowPct >= r.drawdown.sizeDownAtPct)
202
209
  errors.push({ path: '/risk/drawdown/recoverBelowPct', msg: 'recoverBelowPct must be below sizeDownAtPct' });
210
+ if (s.execution.stuckWriteOffMin != null && s.mode !== 'paper')
211
+ errors.push({ path: '/execution/stuckWriteOffMin', msg: `the stuck write-off is paper only: in mode ${s.mode} the bag is still in your wallet and keeps its exit signals (remove it or set it to null)` });
203
212
  if (s.agent.budgetUsdPerSession > s.agent.budgetUsdPerDay)
204
213
  errors.push({ path: '/agent/budgetUsdPerSession', msg: 'a session budget above the daily budget' });
205
214
  if (s.entry.ageHours.max !== null && s.entry.ageHours.max < s.entry.ageHours.min)
@@ -211,6 +220,8 @@ export function validateStrategy(doc, library = builtinProfiles()) {
211
220
  warnings.push({ path: `/entry/triggers/${i}`, msg: `${t.kind} triggers need the live ${t.kind} feed; replays and backtests treat them as not firing` });
212
221
  if (s.bankroll.source === 'desk')
213
222
  warnings.push({ path: '/bankroll/source', msg: 'desk reads the paper desk book during a run; backtests use startUsd' });
223
+ if (s.entry.safety.rugproof)
224
+ warnings.push({ path: '/entry/safety/rugproof', msg: 'the rug-proof chain and route gates (pool structure, LP, withdraw history, sell route) and the drain poller need live data; a replay evaluates only the tick gates and the pump.fun-mint proxy' });
214
225
  if (s.entry.safety.larp)
215
226
  warnings.push({ path: '/entry/safety/larp', msg: 'the LARP gate needs the live web (live data runs only); replays and backtests do not evaluate it' });
216
227
  if (s.mode === 'real')
@@ -0,0 +1,138 @@
1
+ // Calibration + scoring metrics (task-1822). A decision model's own temperature is fitted on ITS training
2
+ // distribution, not on memecoins, so dexterm can apply a local Platt map (p' = sigmoid(a * logit(p) + b)) fitted on
3
+ // labelled rows of the user's / our own data. The fit is plain logistic regression on one feature (Newton steps,
4
+ // a small ridge so a separable sample cannot run away). Metrics: AUROC (ties half), Brier, log-loss, ECE with
5
+ // equal-width bins, reliability bins, precision at recall.
6
+ const clamp = (p) => Math.min(1 - 1e-6, Math.max(1e-6, p));
7
+ export const logit = (p) => { const q = clamp(p); return Math.log(q / (1 - q)); };
8
+ export const sigmoid = (z) => 1 / (1 + Math.exp(-z));
9
+ /** Fit p' = sigmoid(a * logit(p) + b) to binary labels. */
10
+ export function fitPlatt(ps, ys, fittedOn, ridge = 1e-3) {
11
+ if (ps.length !== ys.length || ps.length < 2)
12
+ throw new Error('fitPlatt: need >= 2 paired rows');
13
+ let a = 1, b = 0;
14
+ const xs = ps.map(logit);
15
+ // Penalised negative log-likelihood (the ridge pulls a toward 1, i.e. toward "keep the model's own scale").
16
+ const nll = (a, b) => {
17
+ let s = (ridge / 2) * (a - 1) ** 2;
18
+ for (let i = 0; i < xs.length; i++) {
19
+ const z = a * xs[i] + b;
20
+ s += ys[i] ? Math.log1p(Math.exp(-z)) : Math.log1p(Math.exp(z));
21
+ }
22
+ return s;
23
+ };
24
+ let cur = nll(a, b);
25
+ for (let it = 0; it < 100; it++) {
26
+ let ga = ridge * (a - 1), gb = 0, haa = ridge, hab = 0, hbb = 1e-9;
27
+ for (let i = 0; i < xs.length; i++) {
28
+ const q = sigmoid(a * xs[i] + b), y = ys[i] ? 1 : 0, w = q * (1 - q);
29
+ ga += (q - y) * xs[i];
30
+ gb += q - y;
31
+ haa += w * xs[i] * xs[i];
32
+ hab += w * xs[i];
33
+ hbb += w;
34
+ }
35
+ const det = haa * hbb - hab * hab;
36
+ if (!(Math.abs(det) > 1e-12))
37
+ break;
38
+ const da = (hbb * ga - hab * gb) / det, db = (haa * gb - hab * ga) / det;
39
+ // Pure Newton can overshoot and diverge on logistic losses: halve the step until the loss goes down.
40
+ let t = 1, next = nll(a - da, b - db);
41
+ while (!(next <= cur) && t > 1e-6) {
42
+ t /= 2;
43
+ next = nll(a - t * da, b - t * db);
44
+ }
45
+ if (!(next <= cur))
46
+ break;
47
+ a -= t * da;
48
+ b -= t * db;
49
+ const gain = cur - next;
50
+ cur = next;
51
+ if (gain < 1e-12 || Math.abs(t * da) + Math.abs(t * db) < 1e-10)
52
+ break;
53
+ }
54
+ return { a, b, n: ps.length, fitted_on: fittedOn };
55
+ }
56
+ export function applyPlatt(p, m) { return sigmoid(m.a * logit(p) + m.b); }
57
+ export function auroc(scores, ys) {
58
+ const idx = scores.map((s, i) => i).sort((i, j) => scores[i] - scores[j]);
59
+ let pos = 0, neg = 0, rankSum = 0;
60
+ // Average ranks over ties (Mann-Whitney U).
61
+ for (let i = 0; i < idx.length;) {
62
+ let j = i;
63
+ while (j + 1 < idx.length && scores[idx[j + 1]] === scores[idx[i]])
64
+ j++;
65
+ const r = (i + j) / 2 + 1;
66
+ for (let k = i; k <= j; k++)
67
+ if (ys[idx[k]])
68
+ rankSum += r;
69
+ i = j + 1;
70
+ }
71
+ for (const y of ys) {
72
+ if (y)
73
+ pos++;
74
+ else
75
+ neg++;
76
+ }
77
+ if (!pos || !neg)
78
+ return null;
79
+ return (rankSum - (pos * (pos + 1)) / 2) / (pos * neg);
80
+ }
81
+ export function brier(ps, ys) {
82
+ return ps.reduce((s, p, i) => s + (p - (ys[i] ? 1 : 0)) ** 2, 0) / ps.length;
83
+ }
84
+ export function logLoss(ps, ys) {
85
+ return -ps.reduce((s, p, i) => s + Math.log(ys[i] ? clamp(p) : 1 - clamp(p)), 0) / ps.length;
86
+ }
87
+ export function reliability(ps, ys, bins = 10) {
88
+ const out = [];
89
+ for (let b = 0; b < bins; b++) {
90
+ const lo = b / bins, hi = (b + 1) / bins;
91
+ let n = 0, sp = 0, sy = 0;
92
+ ps.forEach((p, i) => { if (p >= lo && (p < hi || (b === bins - 1 && p <= hi))) {
93
+ n++;
94
+ sp += p;
95
+ sy += ys[i] ? 1 : 0;
96
+ } });
97
+ out.push({ lo, hi, n, mean_p: n ? sp / n : NaN, rate: n ? sy / n : NaN });
98
+ }
99
+ return out;
100
+ }
101
+ export function ece(ps, ys, bins = 10) {
102
+ const N = ps.length;
103
+ return reliability(ps, ys, bins).reduce((s, b) => s + (b.n ? (b.n / N) * Math.abs(b.mean_p - b.rate) : 0), 0);
104
+ }
105
+ /** Best precision reachable at each recall target (threshold sweep over the scores). */
106
+ export function precisionAtRecall(scores, ys, targets = [0.25, 0.5, 0.75, 0.9]) {
107
+ const order = scores.map((s, i) => i).sort((i, j) => scores[j] - scores[i]);
108
+ const P = ys.filter(Boolean).length;
109
+ const curve = [];
110
+ let tp = 0;
111
+ for (let k = 0; k < order.length; k++) {
112
+ if (ys[order[k]])
113
+ tp++;
114
+ if (k + 1 < order.length && scores[order[k + 1]] === scores[order[k]])
115
+ continue;
116
+ curve.push({ r: P ? tp / P : 0, p: tp / (k + 1), t: scores[order[k]] });
117
+ }
118
+ return targets.map((target) => {
119
+ const ok = curve.filter((c) => c.r >= target);
120
+ if (!ok.length)
121
+ return { recall: target, precision: null, threshold: null };
122
+ const best = ok.reduce((m, c) => (c.p > m.p ? c : m));
123
+ return { recall: target, precision: best.p, threshold: best.t };
124
+ });
125
+ }
126
+ /** F1 for one class given predicted labels. */
127
+ export function f1(pred, truth, label) {
128
+ let tp = 0, fp = 0, fn = 0;
129
+ pred.forEach((p, i) => { if (p === label && truth[i] === label)
130
+ tp++;
131
+ else if (p === label)
132
+ fp++;
133
+ else if (truth[i] === label)
134
+ fn++; });
135
+ const precision = tp + fp ? tp / (tp + fp) : null, recall = tp + fn ? tp / (tp + fn) : null;
136
+ const f = precision !== null && recall !== null && precision + recall > 0 ? (2 * precision * recall) / (precision + recall) : null;
137
+ return { precision, recall, f1: f, n: tp + fn };
138
+ }
@@ -0,0 +1,107 @@
1
+ // The one entry point the app calls: firewall the dossier, ask the configured provider, apply a local calibration
2
+ // map when one is configured for (model, question), fall back to the heuristic on failure, label the result.
3
+ import { applyPlatt } from "./calibrate.js";
4
+ import { prepareState } from "./dossier.js";
5
+ import { heuristicProvider } from "./providers/heuristic.js";
6
+ import { validateQuestionSet } from "./questions.js";
7
+ import { finishAnswer } from "./systemone.js";
8
+ import { ClassifierError } from "./types.js";
9
+ export function calibrateAnswer(a, m) {
10
+ const mapped = {};
11
+ for (const [k, p] of Object.entries(a.probs))
12
+ mapped[k] = a.type === 'noul' && k === 'no' ? NaN : applyPlatt(p, m);
13
+ if (a.type === 'noul')
14
+ mapped.no = 1 - mapped.yes;
15
+ const out = finishAnswer(a.type, mapped);
16
+ return { ...out, raw_probs: a.probs };
17
+ }
18
+ export async function classify(provider, set, dossier, o = {}) {
19
+ validateQuestionSet(set);
20
+ const now = o.now ?? (() => performance.now());
21
+ const prepared = prepareState(dossier);
22
+ const t0 = now();
23
+ let used = provider;
24
+ let fallbackFrom;
25
+ let r;
26
+ try {
27
+ r = await provider.classify(set, prepared, { signal: o.signal });
28
+ }
29
+ catch (e) {
30
+ const fb = o.fallback === undefined ? heuristicProvider() : o.fallback;
31
+ if (!fb || fb.id === provider.id)
32
+ throw e;
33
+ fallbackFrom = { provider: provider.id, error: e instanceof ClassifierError ? `${e.code}: ${e.message}` : String(e.message ?? e) };
34
+ used = fb;
35
+ r = await fb.classify(set, prepared, { signal: o.signal });
36
+ }
37
+ const latency = now() - t0;
38
+ let calibration = r.calibration;
39
+ const answers = {};
40
+ for (const [qid, a] of Object.entries(r.answers)) {
41
+ const m = o.calibration?.[`${r.model}|${qid}`];
42
+ answers[qid] = m ? calibrateAnswer(a, m) : a;
43
+ if (m)
44
+ calibration = { kind: 'platt', note: `local Platt map fitted on ${m.fitted_on} (n ${m.n}); ${r.calibration.note}` };
45
+ }
46
+ return {
47
+ label: 'model-estimate',
48
+ provider: used.id,
49
+ model: r.model,
50
+ model_version: r.model_version,
51
+ question_set: set.id,
52
+ subject: dossier.subject,
53
+ answers,
54
+ latency_ms: Math.round(latency * 10) / 10,
55
+ usage: { input_tokens: r.input_tokens, cost_usd: r.cost_usd, cost_note: r.cost_note },
56
+ calibration,
57
+ injection_suspected: prepared.injection_suspected,
58
+ ...(fallbackFrom ? { fallback_from: fallbackFrom } : {}),
59
+ };
60
+ }
61
+ export async function classifyBatch(provider, set, dossiers, o = {}) {
62
+ const t0 = performance.now();
63
+ const results = new Array(dossiers.length);
64
+ let next = 0;
65
+ const worker = async () => {
66
+ while (next < dossiers.length) {
67
+ const i = next++;
68
+ try {
69
+ results[i] = await classify(provider, set, dossiers[i], o);
70
+ o.onResult?.(results[i], i);
71
+ }
72
+ catch (e) {
73
+ results[i] = { subject: dossiers[i].subject, error: e.message };
74
+ }
75
+ }
76
+ };
77
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(o.concurrency ?? 1, 16)) }, worker));
78
+ const out = { results, ms: Math.round(performance.now() - t0) };
79
+ if (o.triage) {
80
+ const t = { flag: [], pass: [], escalate: [] };
81
+ for (const r of results) {
82
+ if ('error' in r) {
83
+ t.escalate.push(r.subject);
84
+ continue;
85
+ }
86
+ const p = r.answers[o.triage.question]?.probs.yes;
87
+ if (p === undefined)
88
+ t.escalate.push(r.subject);
89
+ else if (p >= o.triage.hi)
90
+ t.flag.push(r.subject);
91
+ else if (p <= o.triage.lo)
92
+ t.pass.push(r.subject);
93
+ else
94
+ t.escalate.push(r.subject);
95
+ }
96
+ out.triage = t;
97
+ }
98
+ return out;
99
+ }
100
+ /** One-line human summary, always labelled an estimate with the model id. Never advice. */
101
+ export function describeResult(r, question = 'drain_6h') {
102
+ const a = r.answers[question];
103
+ if (!a)
104
+ return `${r.model}: no answer for ${question}`;
105
+ const v = a.type === 'noul' ? `p(yes) ${a.probs.yes.toFixed(2)}` : `${a.top} ${a.probs[a.top].toFixed(2)}`;
106
+ return `model estimate (${r.provider} ${r.model}, ${r.calibration.kind === 'platt' ? 'locally calibrated' : 'not calibrated on memecoins'}): ${question} ${v}${r.fallback_from ? ` [fallback from ${r.fallback_from.provider}]` : ''}`;
107
+ }