@coinrithm/mcp-trading 0.7.15 → 0.7.17

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 (76) hide show
  1. package/CHANGELOG.md +885 -692
  2. package/README.md +486 -400
  3. package/dist/agent/bench/bench.d.ts +59 -0
  4. package/dist/agent/bench/bench.js +319 -0
  5. package/dist/agent/bench/cassette.d.ts +97 -0
  6. package/dist/agent/bench/cassette.js +181 -0
  7. package/dist/agent/bench/costs.d.ts +28 -0
  8. package/dist/agent/bench/costs.js +60 -0
  9. package/dist/agent/bench/labelBuilder.d.ts +123 -0
  10. package/dist/agent/bench/labelBuilder.js +282 -0
  11. package/dist/agent/bench/labels.d.ts +75 -0
  12. package/dist/agent/bench/labels.js +259 -0
  13. package/dist/agent/bench/recordingClient.d.ts +51 -0
  14. package/dist/agent/bench/recordingClient.js +193 -0
  15. package/dist/agent/bench/replayClient.d.ts +41 -0
  16. package/dist/agent/bench/replayClient.js +262 -0
  17. package/dist/agent/bench/report.d.ts +115 -0
  18. package/dist/agent/bench/report.js +435 -0
  19. package/dist/agent/capitalSizing.d.ts +10 -1
  20. package/dist/agent/capitalSizing.js +28 -4
  21. package/dist/agent/cli.d.ts +25 -0
  22. package/dist/agent/cli.js +300 -2
  23. package/dist/agent/client.d.ts +14 -0
  24. package/dist/agent/client.js +23 -6
  25. package/dist/agent/configurationWarnings.js +11 -0
  26. package/dist/agent/decision.d.ts +24 -16
  27. package/dist/agent/decision.js +92 -3
  28. package/dist/agent/decisionValidator.d.ts +31 -0
  29. package/dist/agent/decisionValidator.js +148 -3
  30. package/dist/agent/engine.d.ts +5 -3
  31. package/dist/agent/engine.js +5 -3
  32. package/dist/agent/futuresCloseOutcome.d.ts +19 -0
  33. package/dist/agent/futuresCloseOutcome.js +54 -0
  34. package/dist/agent/futuresEligibility.d.ts +13 -0
  35. package/dist/agent/futuresEligibility.js +43 -5
  36. package/dist/agent/observe.d.ts +28 -2
  37. package/dist/agent/observe.js +530 -90
  38. package/dist/agent/pmPaperCandidates.d.ts +17 -0
  39. package/dist/agent/pmPaperCandidates.js +125 -0
  40. package/dist/agent/pmPaperCycle.d.ts +16 -0
  41. package/dist/agent/pmPaperCycle.js +372 -0
  42. package/dist/agent/pmPaperDecision.d.ts +187 -0
  43. package/dist/agent/pmPaperDecision.js +101 -0
  44. package/dist/agent/pmPaperPolicy.d.ts +71 -0
  45. package/dist/agent/pmPaperPolicy.js +46 -0
  46. package/dist/agent/prompt.d.ts +15 -1
  47. package/dist/agent/prompt.js +187 -9
  48. package/dist/agent/promptTables.d.ts +1 -0
  49. package/dist/agent/promptTables.js +33 -0
  50. package/dist/agent/providerCapabilities.d.ts +7 -0
  51. package/dist/agent/providerCapabilities.js +27 -1
  52. package/dist/agent/providers.d.ts +26 -7
  53. package/dist/agent/providers.js +193 -48
  54. package/dist/agent/resolve.js +3 -0
  55. package/dist/agent/runner.d.ts +17 -1
  56. package/dist/agent/runner.js +210 -38
  57. package/dist/agent/setups.d.ts +2 -1
  58. package/dist/agent/setups.js +17 -16
  59. package/dist/agent/signals.d.ts +33 -0
  60. package/dist/agent/signals.js +89 -0
  61. package/dist/agent/skill.js +46 -0
  62. package/dist/agent/skillValidator.js +56 -0
  63. package/dist/agent/strictLint.js +30 -0
  64. package/dist/agent/templates.js +30 -30
  65. package/dist/agent/types.d.ts +180 -12
  66. package/dist/agent/universe.d.ts +78 -0
  67. package/dist/agent/universe.js +215 -0
  68. package/dist/agent/version.d.ts +1 -1
  69. package/dist/agent/version.js +1 -1
  70. package/dist/client.d.ts +14 -0
  71. package/dist/client.js +31 -1
  72. package/dist/http.js +1 -1
  73. package/dist/pmPaperV2.d.ts +81 -0
  74. package/dist/pmPaperV2.js +53 -0
  75. package/dist/tools.js +84 -6
  76. package/package.json +100 -97
@@ -0,0 +1,59 @@
1
+ import { type ObserveOptions } from "../observe.js";
2
+ import { Provider } from "../providers.js";
3
+ import { AgentSpec, ProposedAction } from "../types.js";
4
+ import { Cassette } from "./cassette.js";
5
+ import type { BenchmarkStrategy } from "../mechanical.js";
6
+ import { LabelFile } from "./labels.js";
7
+ import { BenchReport, CycleRow } from "./report.js";
8
+ export declare const BASELINE_SKIP = "baseline:skip";
9
+ export declare const BASELINE_MARKET = "baseline:market";
10
+ export declare const BASELINE_BASE_RATE = "baseline:base-rate";
11
+ export declare const BASELINE_RANDOM = "baseline:random";
12
+ /**
13
+ * Mechanical PM baselines, one deterministic run per cassette each: the
14
+ * market's own probability, an uninformative 50, and a seeded random
15
+ * forecast (mechanical.ts). They need the recorded PM observation, so a
16
+ * futures/spot-only corpus has only baseline:skip.
17
+ */
18
+ export declare const MECHANICAL_BASELINES: ReadonlyArray<readonly [name: string, strategy: BenchmarkStrategy]>;
19
+ export declare const DEFAULT_REPEATS = 3;
20
+ export declare const MAX_REPEATS = 50;
21
+ export declare const DEFAULT_SEED = 20261007;
22
+ export declare const DEFAULT_BOOTSTRAP_RESAMPLES = 2000;
23
+ export interface BenchVariant {
24
+ name: string;
25
+ spec: AgentSpec;
26
+ mergedProse: string;
27
+ provider: Provider;
28
+ }
29
+ export interface RunBenchOptions {
30
+ cassettes: Cassette[];
31
+ variants: BenchVariant[];
32
+ repeats?: number;
33
+ labels?: Record<string, LabelFile>;
34
+ seed?: number;
35
+ bootstrapResamples?: number;
36
+ /** Include the skip and market-implied baselines (default true). */
37
+ baselines?: boolean;
38
+ log?: (line: string) => void;
39
+ }
40
+ /**
41
+ * Run `fn` with Date.now() reading `clockMs` plus the real time elapsed since
42
+ * the call. Restored afterwards even when `fn` throws. `new Date()` with no
43
+ * argument is NOT shifted (it does not call Date.now); in the runner it only
44
+ * stamps the whale-context fetchedAt and fallback asOf values.
45
+ */
46
+ export declare function withReplayClock<T>(clockMs: number, fn: () => Promise<T>): Promise<T>;
47
+ /**
48
+ * The identity of a move WITHOUT its size, for action-set overlap and repeat
49
+ * consistency: did the variant do the same thing, not the same amount.
50
+ */
51
+ export declare function actionKey(action: ProposedAction): string;
52
+ /** Replay one cassette for one variant once, through the real runCycle. */
53
+ export declare function replayCycle(cassette: Cassette, variant: string, repeat: number, spec: AgentSpec, mergedProse: string, provider: Provider, labels?: LabelFile, observeOptions?: ObserveOptions): Promise<CycleRow>;
54
+ /**
55
+ * Every modelling assumption behind the numbers, written into each report so
56
+ * a reader never has to guess what a "stop" or a "cost" means here.
57
+ */
58
+ export declare function benchAssumptions(): Record<string, unknown>;
59
+ export declare function runBench(opts: RunBenchOptions): Promise<BenchReport>;
@@ -0,0 +1,319 @@
1
+ // The bench: run variants of an agent on the SAME recorded inputs, K times
2
+ // each, next to built-in baselines, and report honest paired statistics.
3
+ //
4
+ // Every replayed cycle goes through the production runCycle in DRY-RUN with a
5
+ // ReplayClient, so prompt building, parsing, ref resolution, capital sizing,
6
+ // validation and every reject code are exactly the shipped code paths. There
7
+ // is no bench-only decision logic to drift from production.
8
+ //
9
+ // Each cycle starts from a fresh run state (the same state recording used),
10
+ // so daily counters, debounce and journal can never leak between cassettes or
11
+ // repeats. The consequence: replay shows how a variant decides on a first
12
+ // look at a recorded market, not how it behaves deep into a running streak.
13
+ //
14
+ // Clock: Date.now() reads the recording's clock plus the real time elapsed in
15
+ // this cycle, so freshness and entry-predicate age checks see the age the data
16
+ // had when recorded (plus the variant's own model latency, as in production).
17
+ import { buildAgentDefinitionSnapshot } from "../definitionSnapshot.js";
18
+ import { clearPmCalibrationCache } from "../observe.js";
19
+ import { runCycle } from "../runner.js";
20
+ import { newState } from "../state.js";
21
+ import { mechanicalBaselineSpec } from "./cassette.js";
22
+ import { FUTURES_SYNTHETIC_FEE_BPS, PM_SYNTHETIC_FEE_RATE_AT_MID, SPOT_SYNTHETIC_FEE_BPS, } from "./costs.js";
23
+ import { scoreAction } from "./labels.js";
24
+ import { BENCH_RUN_ID, SKIP_PROVIDER } from "./recordingClient.js";
25
+ import { ReplayClient } from "./replayClient.js";
26
+ import { buildReport, chronological, } from "./report.js";
27
+ export const BASELINE_SKIP = "baseline:skip";
28
+ export const BASELINE_MARKET = "baseline:market";
29
+ export const BASELINE_BASE_RATE = "baseline:base-rate";
30
+ export const BASELINE_RANDOM = "baseline:random";
31
+ /**
32
+ * Mechanical PM baselines, one deterministic run per cassette each: the
33
+ * market's own probability, an uninformative 50, and a seeded random
34
+ * forecast (mechanical.ts). They need the recorded PM observation, so a
35
+ * futures/spot-only corpus has only baseline:skip.
36
+ */
37
+ export const MECHANICAL_BASELINES = [
38
+ [BASELINE_MARKET, "market-implied"],
39
+ [BASELINE_BASE_RATE, "base-rate"],
40
+ [BASELINE_RANDOM, "random"],
41
+ ];
42
+ export const DEFAULT_REPEATS = 3;
43
+ export const MAX_REPEATS = 50;
44
+ export const DEFAULT_SEED = 20261007;
45
+ export const DEFAULT_BOOTSTRAP_RESAMPLES = 2000;
46
+ const VARIANT_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,39}$/;
47
+ /**
48
+ * Run `fn` with Date.now() reading `clockMs` plus the real time elapsed since
49
+ * the call. Restored afterwards even when `fn` throws. `new Date()` with no
50
+ * argument is NOT shifted (it does not call Date.now); in the runner it only
51
+ * stamps the whale-context fetchedAt and fallback asOf values.
52
+ */
53
+ export async function withReplayClock(clockMs, fn) {
54
+ const realNow = Date.now;
55
+ const offset = clockMs - realNow();
56
+ Date.now = () => realNow() + offset;
57
+ try {
58
+ return await fn();
59
+ }
60
+ finally {
61
+ Date.now = realNow;
62
+ }
63
+ }
64
+ /**
65
+ * The identity of a move WITHOUT its size, for action-set overlap and repeat
66
+ * consistency: did the variant do the same thing, not the same amount.
67
+ */
68
+ export function actionKey(action) {
69
+ const lower = (s) => (s ?? "").toLowerCase();
70
+ const upper = (s) => (s ?? "").toUpperCase();
71
+ switch (action.type) {
72
+ case "pm_open":
73
+ return `pm_open:${lower(action.source)}/${lower(action.slug)}/${action.outcomeExternalMarketId ?? action.ref ?? ""}`;
74
+ case "futures_open":
75
+ return `futures_open:${upper(action.symbol)}:${action.side}`;
76
+ case "futures_close":
77
+ return `futures_close:${action.positionId}`;
78
+ case "futures_set_sltp":
79
+ return `futures_set_sltp:${action.positionId}`;
80
+ case "spot_order":
81
+ return `spot_order:${upper(action.symbol)}:${action.side}:${action.orderType}`;
82
+ case "spot_cancel":
83
+ return `spot_cancel:${action.orderId}`;
84
+ }
85
+ }
86
+ function sizeOf(action) {
87
+ if (action.type === "pm_open")
88
+ return action.stakeMusd;
89
+ if (action.type === "futures_open")
90
+ return action.marginMusd;
91
+ return undefined;
92
+ }
93
+ function toRow(cassette, variant, repeat, result, runtimeError, client, labels) {
94
+ const actions = (result?.planned ?? []).map((p) => {
95
+ const forecast = p.action
96
+ .forecastProbability;
97
+ return {
98
+ key: actionKey(p.action),
99
+ type: p.action.type,
100
+ accepted: p.accepted,
101
+ ...(p.accepted ? {} : { code: p.code ?? "unknown" }),
102
+ ...(p.quote ? { quote: "synthesized" } : {}),
103
+ ...(typeof forecast === "number"
104
+ ? { forecastProbability: forecast }
105
+ : {}),
106
+ ...(p.quote?.entryProbability !== undefined
107
+ ? { entryProbability: p.quote.entryProbability }
108
+ : {}),
109
+ ...(sizeOf(p.action) !== undefined ? { sizeMusd: sizeOf(p.action) } : {}),
110
+ ...(p.accepted
111
+ ? {
112
+ score: scoreAction({
113
+ action: p.action,
114
+ quote: p.quote,
115
+ asOf: cassette.asOf,
116
+ labels,
117
+ }),
118
+ }
119
+ : {}),
120
+ };
121
+ });
122
+ const decisionType = runtimeError
123
+ ? "runtime_error"
124
+ : (result?.decisionType ?? (result?.decision === "act" ? "act" : "skip"));
125
+ return {
126
+ cassetteId: cassette.id,
127
+ asOf: cassette.asOf,
128
+ variant,
129
+ repeat,
130
+ decision: result?.decision ?? "skip",
131
+ decisionType,
132
+ modelFailed: result?.modelFailed === true,
133
+ llmCallMade: result?.llmCallMade === true,
134
+ ...(result?.routeAttempts
135
+ ? {
136
+ routeAttempts: result.routeAttempts.slice(0, 2).map((attempt) => ({
137
+ provider: attempt.provider,
138
+ model: attempt.model,
139
+ outcome: attempt.outcome,
140
+ ...(attempt.failureClass
141
+ ? { failureClass: attempt.failureClass }
142
+ : {}),
143
+ ...(attempt.status !== undefined ? { status: attempt.status } : {}),
144
+ ...(attempt.responseSource
145
+ ? { responseSource: attempt.responseSource }
146
+ : {}),
147
+ ...(attempt.actionsStringDiagnostic
148
+ ? { actionsStringDiagnostic: attempt.actionsStringDiagnostic }
149
+ : {}),
150
+ ...(attempt.admissionReasons
151
+ ? { admissionReasons: [...attempt.admissionReasons] }
152
+ : {}),
153
+ })),
154
+ ...(result.routeAttempts.length > 2
155
+ ? { routeAttemptsTruncated: true }
156
+ : {}),
157
+ }
158
+ : {}),
159
+ ...(result?.triggerCodes ? { triggerCodes: [...result.triggerCodes] } : {}),
160
+ ...(result?.skipReason
161
+ ? { skipReason: result.skipReason.slice(0, 200) }
162
+ : {}),
163
+ ...(runtimeError ? { runtimeError: runtimeError.slice(0, 200) } : {}),
164
+ actions,
165
+ missingInputs: [...new Set(client.stats.missing)].sort(),
166
+ synthesizedQuotes: client.stats.synthesizedQuotes.length,
167
+ refusedWrites: [...client.stats.refusedWrites],
168
+ };
169
+ }
170
+ /** Replay one cassette for one variant once, through the real runCycle. */
171
+ export async function replayCycle(cassette, variant, repeat, spec, mergedProse, provider, labels, observeOptions) {
172
+ clearPmCalibrationCache();
173
+ const client = new ReplayClient(cassette);
174
+ let result;
175
+ let runtimeError;
176
+ try {
177
+ result = await withReplayClock(cassette.clockMs, () => runCycle({
178
+ client,
179
+ provider,
180
+ spec,
181
+ mergedProse,
182
+ state: newState(BENCH_RUN_ID),
183
+ live: false,
184
+ ...(observeOptions ? { observeOptions } : {}),
185
+ }));
186
+ }
187
+ catch (error) {
188
+ // A crash is a result for this variant, never a reason to drop the cycle.
189
+ runtimeError = error instanceof Error ? error.message : String(error);
190
+ }
191
+ return toRow(cassette, variant, repeat, result, runtimeError, client, labels);
192
+ }
193
+ /** The always-skip baseline: no reads, no model, no action, by definition. */
194
+ function skipBaselineRow(cassette) {
195
+ return {
196
+ cassetteId: cassette.id,
197
+ asOf: cassette.asOf,
198
+ variant: BASELINE_SKIP,
199
+ repeat: 0,
200
+ decision: "skip",
201
+ decisionType: "skip",
202
+ modelFailed: false,
203
+ llmCallMade: false,
204
+ skipReason: "baseline: always skip",
205
+ actions: [],
206
+ missingInputs: [],
207
+ synthesizedQuotes: 0,
208
+ refusedWrites: [],
209
+ };
210
+ }
211
+ /**
212
+ * Every modelling assumption behind the numbers, written into each report so
213
+ * a reader never has to guess what a "stop" or a "cost" means here.
214
+ */
215
+ export function benchAssumptions() {
216
+ return {
217
+ quotes: "every quote is synthesized from recorded evidence (PM: the discover probability of the chosen outcome; futures/spot: the recorded market price); none comes from the paper engine",
218
+ entry: "entry at the recorded (synthesized) quote with the paper fee model below; entry latency is not modelled",
219
+ pmFee: {
220
+ constant: "PM_SYNTHETIC_FEE_RATE_AT_MID",
221
+ value: PM_SYNTHETIC_FEE_RATE_AT_MID,
222
+ model: "fee = rate x 4p(1-p) of the price paid (openapi Paper Execution Realism v1 shape: ~1.8% near 50%, ~0 at the extremes); ask spread and size slippage are not modelled, so PM cost is optimistic by an unmeasured amount",
223
+ },
224
+ futuresFee: {
225
+ constant: "FUTURES_SYNTHETIC_FEE_BPS",
226
+ value: FUTURES_SYNTHETIC_FEE_BPS,
227
+ model: "flat taker fee on notional at entry and at exit (the runner's conservative pre-quote estimate); spread and slippage not modelled",
228
+ },
229
+ spotFee: {
230
+ constant: "SPOT_SYNTHETIC_FEE_BPS",
231
+ value: SPOT_SYNTHETIC_FEE_BPS,
232
+ },
233
+ pmEntryFloor: "the synthesized quote does not apply the server floor; the runner validator rejects the same action as pm_entry_below_floor (production: quote_ineligible)",
234
+ pmOutcome: "settled 0/1 from the label file; cost = stake / net shares (fee-inclusive); Brier uses the forecast the agent sent",
235
+ futuresOutcome: "an OHLC walk MODEL over label bars that open after asOf; it is not exact fill or stop behaviour",
236
+ sameBarStopAndTarget: "a bar touching both stop and take-profit is resolved as the STOP (conservative) and counted in labelled.futuresSameBarStopFirst",
237
+ missingBars: "a missing bar before the exit (or before the horizon end) gives outcome unlabelled_gap: counted, never filled",
238
+ funding: "included only when the label file carries funding events for the symbol (labelled.futuresFundingIncluded); otherwise excluded",
239
+ liquidation: "approximated at entry x (1 -/+ 1/leverage); forfeits the margin with no extra fill cost",
240
+ state: "fresh run state per cycle: no journal, theses, debounce or daily counters carried between cassettes or repeats",
241
+ clock: "Date.now = recording clock + real elapsed time, so data ages as recorded plus model latency",
242
+ outcomeCutoff: "asOf is the end of all recording reads; it is not the earlier trades sync cursor",
243
+ pnlComparisons: "zero means a complete no-action cycle; cycles with missing inputs, runtime errors, or any unscored accepted action are excluded from paired PnL, with exclusion counts shown; all repeats of a cassette must qualify",
244
+ inference: "per-cassette bootstrap assumes independent observations; overlapping market windows can violate that assumption; repeated A/A calibration does not establish independence or future performance",
245
+ };
246
+ }
247
+ export async function runBench(opts) {
248
+ const repeats = opts.repeats ?? DEFAULT_REPEATS;
249
+ if (!Number.isInteger(repeats) || repeats < 1 || repeats > MAX_REPEATS)
250
+ throw new Error(`repeats must be an integer from 1 to ${MAX_REPEATS}`);
251
+ if (opts.variants.length === 0)
252
+ throw new Error("at least one variant is required");
253
+ const names = new Set();
254
+ for (const v of opts.variants) {
255
+ if (!VARIANT_NAME_RE.test(v.name))
256
+ throw new Error(`variant name "${v.name}" must be 1-40 letters, digits, "_", "." or "-"`);
257
+ if (names.has(v.name))
258
+ throw new Error(`duplicate variant "${v.name}"`);
259
+ names.add(v.name);
260
+ }
261
+ if (opts.cassettes.length === 0)
262
+ throw new Error("the corpus has no cassettes");
263
+ const seed = (opts.seed ?? DEFAULT_SEED) >>> 0;
264
+ const resamples = opts.bootstrapResamples ?? DEFAULT_BOOTSTRAP_RESAMPLES;
265
+ const baselines = opts.baselines !== false;
266
+ const log = opts.log ?? (() => { });
267
+ const cassettes = chronological(opts.cassettes);
268
+ const rows = [];
269
+ for (const cassette of cassettes) {
270
+ const labels = opts.labels?.[cassette.id];
271
+ for (const v of opts.variants) {
272
+ for (let r = 0; r < repeats; r++) {
273
+ const row = await replayCycle(cassette, v.name, r, v.spec, v.mergedProse, v.provider, labels);
274
+ rows.push(row);
275
+ log(`${cassette.id} ${v.name}#${r + 1}: ${row.decisionType}, ${row.actions.filter((a) => a.accepted).length}/${row.actions.length} accepted${row.missingInputs.length ? `, ${row.missingInputs.length} missing input(s)` : ""}`);
276
+ }
277
+ }
278
+ if (baselines) {
279
+ rows.push(skipBaselineRow(cassette));
280
+ // Deterministic, so one run per cassette each. Use the recorded agent's
281
+ // board policy, not the replacement mechanical provider's policy.
282
+ // Originally mechanical recordings keep their uncurated board.
283
+ if (cassette.marketBaselineRecorded)
284
+ for (const [name, strategy] of MECHANICAL_BASELINES)
285
+ rows.push(await replayCycle(cassette, name, 0, mechanicalBaselineSpec(cassette.spec, strategy), "", SKIP_PROVIDER, labels, {
286
+ curatedPmBoard: cassette.spec.model?.provider !== "mechanical",
287
+ }));
288
+ }
289
+ }
290
+ const variants = opts.variants.map((v) => ({
291
+ name: v.name,
292
+ kind: "variant",
293
+ definitionHash: buildAgentDefinitionSnapshot(v.spec, v.mergedProse)
294
+ .definitionHash,
295
+ }));
296
+ if (baselines) {
297
+ variants.push({ name: BASELINE_SKIP, kind: "baseline" });
298
+ if (cassettes.some((c) => c.marketBaselineRecorded))
299
+ for (const [name] of MECHANICAL_BASELINES)
300
+ variants.push({ name, kind: "baseline" });
301
+ }
302
+ const specHashes = [...new Set(cassettes.map((c) => c.agentSpecHash))].sort();
303
+ return buildReport({
304
+ cassettes,
305
+ rows,
306
+ variants,
307
+ repeats,
308
+ seed,
309
+ bootstrapResamples: resamples,
310
+ assumptions: benchAssumptions(),
311
+ fidelity: {
312
+ recordedAgentDefinitions: specHashes,
313
+ synthesizedQuotes: rows.reduce((s, r) => s + r.synthesizedQuotes, 0),
314
+ cyclesWithMissingInputs: rows.filter((r) => r.missingInputs.length > 0)
315
+ .length,
316
+ refusedWrites: rows.reduce((s, r) => s + r.refusedWrites.length, 0),
317
+ },
318
+ });
319
+ }
@@ -0,0 +1,97 @@
1
+ import { AgentSpec } from "../types.js";
2
+ import type { BenchmarkStrategy } from "../mechanical.js";
3
+ import { LabelFile } from "./labels.js";
4
+ export declare const CASSETTE_SCHEMA = "coinrithm.bench.cassette.v1";
5
+ export interface RecordedResponse {
6
+ /** Canonical request key: METHOD + path + sorted query. */
7
+ key: string;
8
+ method: string;
9
+ path: string;
10
+ query: Record<string, string>;
11
+ status: number;
12
+ ok: boolean;
13
+ /** The body as the client parses it: JSON when it parses, else the text. */
14
+ data: unknown;
15
+ /** The recorded request failed in transport (no HTTP response at all). */
16
+ transportError?: boolean;
17
+ }
18
+ export interface Cassette {
19
+ schema: typeof CASSETTE_SCHEMA;
20
+ id: string;
21
+ recordedAt: string;
22
+ /** Epoch ms the replay clock starts from (Date.now during replay). */
23
+ clockMs: number;
24
+ /** Cutoff after ALL recording reads, including baseline-only inputs. */
25
+ asOf: string;
26
+ /** definitionHash of the recorded spec + prose (see definitionSnapshot). */
27
+ agentSpecHash: string;
28
+ spec: AgentSpec;
29
+ /** PM observation available for baselines. Legacy recordings used this
30
+ * only to mark an attempted extra mechanical pass; replay still reports
31
+ * missing inputs instead of treating the marker as a coverage guarantee. */
32
+ marketBaselineRecorded: boolean;
33
+ recordCycle: {
34
+ decision: string;
35
+ decisionType?: string;
36
+ skipReason?: string;
37
+ };
38
+ /** Non-GET requests the recorder refused to forward (never sent). */
39
+ refusedRequests: string[];
40
+ responses: RecordedResponse[];
41
+ }
42
+ export interface CanonicalRequest {
43
+ key: string;
44
+ method: string;
45
+ path: string;
46
+ query: Record<string, string>;
47
+ }
48
+ /**
49
+ * Canonical request identity used for both recording and replay: upper-cased
50
+ * method, the path relative to the API base path, and the query parameters
51
+ * sorted by name. The host is deliberately not part of the key, so a cassette
52
+ * recorded against one base URL replays against the bench's fake one.
53
+ */
54
+ export declare function canonicalRequest(method: string, url: string, basePath?: string): CanonicalRequest;
55
+ /** The path prefix of an API base URL ("" for a host-root base). */
56
+ export declare function basePathOf(baseUrl: string): string;
57
+ export declare function sha256Hex(text: string): string;
58
+ /** Sorted-key JSON, the same canonicalization the scorecard hash relies on. */
59
+ export declare function canonicalJson(value: unknown): string;
60
+ /**
61
+ * The spec a recording pass runs with: the agent's own spec with its gate
62
+ * forced to fire, so the post-gate reads (whale context) are always recorded
63
+ * and a variant whose gate fires on replay finds them in the cassette.
64
+ */
65
+ export declare function recordingSpec(spec: AgentSpec): AgentSpec;
66
+ /**
67
+ * The market-implied baseline: the recorded agent's spec and caps with the
68
+ * deterministic mechanical "market-implied" strategy as its brain (mechanical
69
+ * .ts). runBench separately preserves the recorded agent's observation
70
+ * policy, so replacing the provider never changes the PM opportunity set.
71
+ */
72
+ export declare function marketBaselineSpec(spec: AgentSpec): AgentSpec;
73
+ /**
74
+ * The same recorded spec with one of the mechanical benchmark strategies as
75
+ * its brain. base-rate and random pick the SAME market from the SAME
76
+ * observation as market-implied (mechanical.ts); all use the original
77
+ * recording's PM board and never fetch a separate baseline universe.
78
+ */
79
+ export declare function mechanicalBaselineSpec(spec: AgentSpec, strategy: BenchmarkStrategy): AgentSpec;
80
+ /** Filesystem-safe, content-addressed cassette id: asOf stamp + body hash. */
81
+ export declare function cassetteId(asOf: string, contentHash: string): string;
82
+ export declare function writeCassette(dir: string, cassette: Cassette): string;
83
+ /** Validate a parsed cassette. Throws on a malformed one (fail closed). */
84
+ export declare function parseCassette(value: unknown, source: string): Cassette;
85
+ export interface Corpus {
86
+ cassettes: Cassette[];
87
+ labels: Record<string, LabelFile>;
88
+ /** Top-level .json files that are not cassettes (e.g. a saved report). */
89
+ ignored: string[];
90
+ }
91
+ /**
92
+ * Read every cassette at the top level of `dir` plus the optional
93
+ * `labels/<cassetteId>.json` files. A JSON file with the cassette schema that
94
+ * fails validation, or a malformed label file, stops the bench: a silently
95
+ * skipped input would bias the comparison.
96
+ */
97
+ export declare function readCorpus(dir: string): Corpus;
@@ -0,0 +1,181 @@
1
+ // Bench cassettes: the recorded READ inputs of one agent cycle.
2
+ //
3
+ // A cassette is the source of truth the bench replays. It holds every read
4
+ // response the real production runner made in one dry-run cycle (status, body,
5
+ // keyed by a canonical request key), the clock it was recorded at, and the
6
+ // recorded agent's spec (needed to replay the built-in baselines on the same
7
+ // inputs). It never holds request headers, so no API key reaches a cassette.
8
+ // It DOES hold the recording account's paper reads (wallet, positions), so
9
+ // keep a corpus as private as the account it was recorded from.
10
+ //
11
+ // Why not replay the stored `decision_input_record`: that record declares
12
+ // itself partial (prompts, news and journal excluded, byte budget) and carries
13
+ // no replay guarantee. Inputs are therefore recorded going forward, in full.
14
+ import { createHash } from "node:crypto";
15
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { DEFAULT_TRIGGER_POLICY } from "../types.js";
18
+ import { sortDeep } from "../util.js";
19
+ import { parseLabelFile } from "./labels.js";
20
+ export const CASSETTE_SCHEMA = "coinrithm.bench.cassette.v1";
21
+ /**
22
+ * Canonical request identity used for both recording and replay: upper-cased
23
+ * method, the path relative to the API base path, and the query parameters
24
+ * sorted by name. The host is deliberately not part of the key, so a cassette
25
+ * recorded against one base URL replays against the bench's fake one.
26
+ */
27
+ export function canonicalRequest(method, url, basePath = "") {
28
+ const u = new URL(url);
29
+ const base = basePath.replace(/\/+$/, "");
30
+ let path = u.pathname;
31
+ if (base && (path === base || path.startsWith(`${base}/`)))
32
+ path = path.slice(base.length) || "/";
33
+ const params = new URLSearchParams(u.search);
34
+ params.sort();
35
+ const query = {};
36
+ params.forEach((value, name) => {
37
+ query[name] = value;
38
+ });
39
+ const qs = params.toString();
40
+ const m = method.toUpperCase();
41
+ return { key: `${m} ${path}${qs ? `?${qs}` : ""}`, method: m, path, query };
42
+ }
43
+ /** The path prefix of an API base URL ("" for a host-root base). */
44
+ export function basePathOf(baseUrl) {
45
+ return new URL(baseUrl).pathname.replace(/\/+$/, "");
46
+ }
47
+ export function sha256Hex(text) {
48
+ return createHash("sha256").update(text).digest("hex");
49
+ }
50
+ /** Sorted-key JSON, the same canonicalization the scorecard hash relies on. */
51
+ export function canonicalJson(value) {
52
+ return JSON.stringify(sortDeep(value));
53
+ }
54
+ /**
55
+ * The spec a recording pass runs with: the agent's own spec with its gate
56
+ * forced to fire, so the post-gate reads (whale context) are always recorded
57
+ * and a variant whose gate fires on replay finds them in the cassette.
58
+ */
59
+ export function recordingSpec(spec) {
60
+ return {
61
+ ...spec,
62
+ triggerPolicy: {
63
+ ...(spec.triggerPolicy ?? DEFAULT_TRIGGER_POLICY),
64
+ mode: "always",
65
+ },
66
+ };
67
+ }
68
+ /**
69
+ * The market-implied baseline: the recorded agent's spec and caps with the
70
+ * deterministic mechanical "market-implied" strategy as its brain (mechanical
71
+ * .ts). runBench separately preserves the recorded agent's observation
72
+ * policy, so replacing the provider never changes the PM opportunity set.
73
+ */
74
+ export function marketBaselineSpec(spec) {
75
+ return mechanicalBaselineSpec(spec, "market-implied");
76
+ }
77
+ /**
78
+ * The same recorded spec with one of the mechanical benchmark strategies as
79
+ * its brain. base-rate and random pick the SAME market from the SAME
80
+ * observation as market-implied (mechanical.ts); all use the original
81
+ * recording's PM board and never fetch a separate baseline universe.
82
+ */
83
+ export function mechanicalBaselineSpec(spec, strategy) {
84
+ return {
85
+ ...recordingSpec(spec),
86
+ model: { provider: "mechanical", name: strategy },
87
+ };
88
+ }
89
+ function stampOf(asOf) {
90
+ const stamp = asOf
91
+ .replace(/[^0-9A-Za-z]+/g, "-")
92
+ .slice(0, 40)
93
+ .replace(/^-+|-+$/g, "");
94
+ return stamp || "cassette";
95
+ }
96
+ /** Filesystem-safe, content-addressed cassette id: asOf stamp + body hash. */
97
+ export function cassetteId(asOf, contentHash) {
98
+ return `${stampOf(asOf)}-${contentHash.slice(0, 12)}`;
99
+ }
100
+ export function writeCassette(dir, cassette) {
101
+ mkdirSync(dir, { recursive: true });
102
+ const file = join(dir, `${cassette.id}.json`);
103
+ writeFileSync(file, `${JSON.stringify(cassette, null, 2)}\n`, "utf8");
104
+ return file;
105
+ }
106
+ /** Validate a parsed cassette. Throws on a malformed one (fail closed). */
107
+ export function parseCassette(value, source) {
108
+ const c = (value ?? {});
109
+ const problems = [];
110
+ if (c.schema !== CASSETTE_SCHEMA)
111
+ problems.push("schema");
112
+ if (typeof c.id !== "string" || !/^[0-9A-Za-z-]{1,80}$/.test(c.id))
113
+ problems.push("id");
114
+ if (typeof c.asOf !== "string" || !Number.isFinite(Date.parse(c.asOf)))
115
+ problems.push("asOf");
116
+ if (typeof c.clockMs !== "number" || !Number.isFinite(c.clockMs))
117
+ problems.push("clockMs");
118
+ if (!c.spec || typeof c.spec !== "object" || !Array.isArray(c.spec.venues))
119
+ problems.push("spec");
120
+ if (!Array.isArray(c.responses) ||
121
+ c.responses.some((r) => !r ||
122
+ typeof r.key !== "string" ||
123
+ typeof r.path !== "string" ||
124
+ typeof r.status !== "number"))
125
+ problems.push("responses");
126
+ if (problems.length > 0)
127
+ throw new Error(`cassette ${source} is invalid (${problems.join(", ")}); re-record it`);
128
+ return {
129
+ ...c,
130
+ marketBaselineRecorded: c.marketBaselineRecorded === true,
131
+ refusedRequests: Array.isArray(c.refusedRequests) ? c.refusedRequests : [],
132
+ };
133
+ }
134
+ /**
135
+ * Read every cassette at the top level of `dir` plus the optional
136
+ * `labels/<cassetteId>.json` files. A JSON file with the cassette schema that
137
+ * fails validation, or a malformed label file, stops the bench: a silently
138
+ * skipped input would bias the comparison.
139
+ */
140
+ export function readCorpus(dir) {
141
+ const cassettes = [];
142
+ const ignored = [];
143
+ for (const name of readdirSync(dir).sort()) {
144
+ if (!name.endsWith(".json"))
145
+ continue;
146
+ const file = join(dir, name);
147
+ let parsed;
148
+ try {
149
+ parsed = JSON.parse(readFileSync(file, "utf8"));
150
+ }
151
+ catch {
152
+ throw new Error(`corpus file ${name} is not valid JSON; repair or explicitly remove it before comparing variants`);
153
+ }
154
+ if (parsed?.schema !== CASSETTE_SCHEMA) {
155
+ ignored.push(name);
156
+ continue;
157
+ }
158
+ cassettes.push(parseCassette(parsed, name));
159
+ }
160
+ const ids = new Set();
161
+ for (const c of cassettes) {
162
+ if (ids.has(c.id))
163
+ throw new Error(`duplicate cassette id ${c.id}`);
164
+ ids.add(c.id);
165
+ }
166
+ const labels = {};
167
+ for (const c of cassettes) {
168
+ const file = join(dir, "labels", `${c.id}.json`);
169
+ if (!existsSync(file))
170
+ continue;
171
+ let parsed;
172
+ try {
173
+ parsed = JSON.parse(readFileSync(file, "utf8"));
174
+ }
175
+ catch (e) {
176
+ throw new Error(`label file labels/${c.id}.json is not valid JSON: ${e.message}`);
177
+ }
178
+ labels[c.id] = parseLabelFile(parsed, `labels/${c.id}.json`);
179
+ }
180
+ return { cassettes, labels, ignored };
181
+ }