canli-validation-mcp 0.12.0 → 0.13.0

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.
package/README.md CHANGED
@@ -34,14 +34,16 @@ other stdio client (Cursor, VS Code) run the same `npx` command; see "Claude Des
34
34
 
35
35
  ## What it does
36
36
 
37
- An MCP (Model Context Protocol) server over canlicapital.com's free, keyed validation API. It
38
- gives a coding agent fifteen tools: issue a free key, run the nine validators (deflated Sharpe,
39
- CSCV overfitting, data-snooping tests (Hansen's SPA, White's Reality Check and Romano-Wolf StepM),
40
- paper-evidence conformance, breadth ceiling, minimum track record length, minimum backtest length,
41
- haircut Sharpe ratio, luck-equivalent trials),
42
- audit one backtest with three of them in a single call, fetch a stored receipt, verify a
43
- receipt's signature offline, read service status, and read a company's reported financial history
44
- from SEC filings. Every validation result carries, beside the number, the sentences that say what
37
+ An MCP (Model Context Protocol) server over canlicapital.com's free, keyed validation API, with a
38
+ lab that computes on your machine. It gives a coding agent twenty tools: issue a free key, run the
39
+ nine validators (deflated Sharpe, CSCV overfitting, data-snooping tests (Hansen's SPA, White's
40
+ Reality Check and Romano-Wolf StepM), paper-evidence conformance, breadth ceiling, minimum track
41
+ record length, minimum backtest length, haircut Sharpe ratio, luck-equivalent trials), audit one
42
+ backtest with several of them in a single call, verify a receipt's signature offline, read service
43
+ status, read a company's reported financial history from SEC filings, and six lab tools: backtest
44
+ a rule over a parameter grid, summarize a long series, stress-test a strategy's returns, check a
45
+ plan against a broker's limits, check a signal for lookahead, and test a whole research pipeline
46
+ on placebo data. Every validation result carries, beside the number, the sentences that say what
45
47
  it does not establish and the receipt that records it, success or error, so the agent cannot see a
46
48
  number without its limits.
47
49
 
@@ -136,9 +138,9 @@ original SEC response and the record's own boundary sentence: these are accounti
136
138
  reported to the SEC, not market prices, returns or a recommendation. Companies and concepts
137
139
  outside the current release return an error with the available concepts listed.
138
140
 
139
- ## The lab: backtest, summarize, stress, check feasibility
141
+ ## The lab: backtest, summarize, stress, feasibility, lookahead, placebos
140
142
 
141
- Four tools compute in the server process itself, on your machine or on the hosted endpoint, with
143
+ Six tools compute in the server process itself, on your machine or on the hosted endpoint, with
142
144
  nothing sent to the API and no receipt stored. Each result names the SHA-256 of exactly the numbers
143
145
  it was computed from, and states what it does not establish.
144
146
 
@@ -174,6 +176,32 @@ it was computed from, and states what it does not establish.
174
176
  share of daily volume, square-root market impact (coefficient Y, default 1) with every copy of the
175
177
  same strategy counted, and the capital at which costs and impact eat the expected gross return.
176
178
  Every broker fact carries the date it was checked and its source.
179
+ - **`check_leakage`** finds lookahead in a signal without seeing its code. A causal signal computed
180
+ on the first n rows gives exactly the values the full run gives for those rows. `plan` picks the
181
+ cuts (seeded, between 40% and 95% of the series); you rerun your own code on the rows before each
182
+ cut, and `compare` checks every column: a value that changed when the later rows were removed
183
+ used them. On your machine, `columns_file` takes the path of a JSON file `{cuts, columns}` your
184
+ script wrote (Python's `NaN` reads as missing) instead of thousands of pasted numbers. The result names the pattern: future rows with their horizon (a `shift(-5)` reads as
185
+ five rows), the full sample (a normalization, rank, scaling or fit over the whole series), or
186
+ scattered rows (filling gaps backwards). On a fixed zoo of 22 pandas and statsmodels signals
187
+ (`config/research/leakage-zoo.json`, from `scripts/research/leakage/`), all 11 leaks are found
188
+ with the right pattern and none of the 11 honest indicators is flagged. A pass covers only the
189
+ cuts tested: survivorship, vendor revisions or a leak confined to the first rows leave no trace.
190
+ - **`placebo_test`** tests a whole research pipeline at once. `plan` writes your panel (`real.csv`)
191
+ and 19 placebos to a new folder in the system's temporary directory: the same returns with the
192
+ periods in a random order, the same order for every asset, so each asset keeps its returns and each
193
+ day its cross-section, but nothing can be predicted. Run the pipeline unchanged on each, every
194
+ search, filter and parameter choice included, and `compare` ranks its real result among the
195
+ placebo results. Every choice the pipeline makes is counted, because it makes them again on every
196
+ placebo. A pre-registered study chose the reordering (`scripts/research/placebo/v1/`,
197
+ `config/research/placebo-v1-evaluation.json`): in all 30 combinations of simulated market and
198
+ pipeline where nothing can be predicted (10,000 tests each; five return shapes, flat,
199
+ drifting and with assets that differ only in their constant means; SMA, time-series and
200
+ cross-sectional momentum grids), its false-positive rate at a nominal 5% was 4.6% to 5.5%.
201
+ On the same markets a t-test on the best rule found an edge 10.9% to 78.9% of the time,
202
+ and Bonferroni's correction 8.0% to 10.3% wherever the market drifted and 58.5% to
203
+ 59.7% where assets only differed in their means. With 19 placebos the smallest p is 0.05; 99
204
+ give a finer one.
177
205
 
178
206
  On your own machine, `prices_file`, `series_file` and `returns_file` take a CSV or JSON path instead
179
207
  of the numbers. Only numbers are read, plus an ISO date column when the file has one, so results can
@@ -214,10 +242,10 @@ took three audit questions from 4 of 9 to 8 of 9 answered correctly on gpt-5.4-m
214
242
 
215
243
  ## Prompts, resources and structured results
216
244
 
217
- Clients that show MCP prompts offer six guided workflows: `validate_backtest` (deflated Sharpe, then
245
+ Clients that show MCP prompts offer eight guided workflows: `validate_backtest` (deflated Sharpe, then
218
246
  overfitting, then the track record needed, reported with what each number does not establish),
219
- `track_record_needed`, `backtest_and_validate`, `stress_my_strategy`, `production_check` and
220
- `summarize_market_series`. Resources: `canli://limits`, the boundary sentences every result carries;
247
+ `track_record_needed`, `backtest_and_validate`, `stress_my_strategy`, `production_check`,
248
+ `summarize_market_series`, `check_signal_for_lookahead` and `test_pipeline_on_placebos`. Resources: `canli://limits`, the boundary sentences every result carries;
221
249
  `canli://sources`, the papers behind each validator and how each is checked against them;
222
250
  `canli://strategy-spec`, the JSON Schema of every strategy family and its parameters; and
223
251
  `canli://openapi`, the API's OpenAPI 3.1 document. Two resource templates are for writing code
@@ -420,25 +448,25 @@ no key is configured.
420
448
 
421
449
  | toolset | tools |
422
450
  |---|---|
423
- | `validate` | `get_key`, the eight validators, `audit_backtest` |
451
+ | `validate` | `get_key`, the nine validators, `audit_backtest` |
424
452
  | `receipts` | `verify_receipt` |
425
453
  | `company` | `company_financial_history` |
426
454
  | `status` | `service_status` |
427
- | `lab` | `backtest_strategy`, `summarize_series`, `stress_test`, `check_feasibility` |
455
+ | `lab` | `backtest_strategy`, `summarize_series`, `stress_test`, `check_feasibility`, `check_leakage`, `placebo_test` |
428
456
 
429
457
  Measured with `bench/tool_list_tokens.py` (tokenizer: tiktoken `o200k_base`; other models'
430
458
  tokenizers give different absolute counts), in the shape an OpenAI-style client sends the list:
431
459
 
432
460
  | CANLI_TOOLSETS | tools | tokens per turn | of all |
433
461
  |---|---|---|---|
434
- | (default) | 17 | 5,581 | 95% |
435
- | (default, with CANLI_KEY) | 16 | 5,445 | 93% |
436
- | `all` | 18 | 5,852 | 100% |
437
- | `validate` | 11 | 3,708 | 63% |
438
- | `receipts` | 1 | 195 | 3% |
439
- | `company` | 1 | 273 | 5% |
440
- | `status` | 1 | 89 | 2% |
441
- | `lab` | 4 | 1,595 | 27% |
462
+ | (default) | 19 | 5,970 | 96% |
463
+ | (default, with CANLI_KEY) | 18 | 5,834 | 93% |
464
+ | `all` | 20 | 6,241 | 100% |
465
+ | `validate` | 11 | 3,650 | 58% |
466
+ | `receipts` | 1 | 182 | 3% |
467
+ | `company` | 1 | 273 | 4% |
468
+ | `status` | 1 | 89 | 1% |
469
+ | `lab` | 6 | 2,055 | 33% |
442
470
 
443
471
  Providers cache a tool list that is identical from turn to turn and bill the cached part at a
444
472
  fraction of the price (`test/tool-list-stable.test.mjs` keeps each list byte-stable); a smaller list
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canli-validation-mcp",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Check whether a backtest is real: deflated Sharpe, CSCV probability of backtest overfitting, minimum track record and backtest length, haircut Sharpe and luck-equivalent trials, as an MCP server. Local mode, hosted endpoint, signed receipts.",
5
5
  "keywords": [
6
6
  "mcp",
@@ -1,22 +1,26 @@
1
1
  // mcp/src/lab-schemas.mjs
2
2
  //
3
3
  // Input and output schemas for the lab tools (src/lab.mjs): backtest_strategy, summarize_series,
4
- // stress_test and check_feasibility. Like the validators' descriptions, each tool description
5
- // carries one sentence of its own result's boundary language verbatim (test/lab.test.mjs checks
6
- // it against the sentences the computation attaches), so an agent reads what the result cannot be
7
- // used to claim before it ever calls the tool. Descriptions stay terse: the tool list is re-sent to
8
- // the model on every turn.
4
+ // stress_test, check_feasibility, check_leakage and placebo_test. Like the validators'
5
+ // descriptions, each tool description carries one sentence of its own result's boundary language
6
+ // verbatim (test/lab.test.mjs checks it against the sentences the computation attaches), so an
7
+ // agent reads what the result cannot be used to claim before it ever calls the tool. Descriptions
8
+ // stay terse: the tool list is re-sent to the model on every turn.
9
9
  import { z } from "zod";
10
10
  import { BACKTEST_LIMITS_TEXT, FAMILIES } from "./local/js/backtest-core.js";
11
11
  import { FEASIBILITY_LIMITS_TEXT } from "./local/js/feasibility-core.js";
12
12
  import { SUMMARY_LIMITS_TEXT } from "./local/js/series-summary-core.js";
13
13
  import { STRESS_LIMITS_TEXT } from "./local/js/stress-core.js";
14
+ import { LEAKAGE_LIMITS_TEXT } from "./local/js/leakage-core.js";
15
+ import { PLACEBO_LIMITS_TEXT, PLACEBO_METHODS } from "./local/js/placebo-core.js";
14
16
 
15
17
  export const LAB_BOUNDARY = Object.freeze({
16
18
  backtest_strategy: BACKTEST_LIMITS_TEXT[3],
17
19
  summarize_series: SUMMARY_LIMITS_TEXT[2],
18
20
  stress_test: STRESS_LIMITS_TEXT[2],
19
21
  check_feasibility: FEASIBILITY_LIMITS_TEXT[0],
22
+ check_leakage: LEAKAGE_LIMITS_TEXT[0],
23
+ placebo_test: PLACEBO_LIMITS_TEXT[0],
20
24
  });
21
25
 
22
26
  export const LAB_TOOL_DESCRIPTIONS = Object.freeze({
@@ -24,6 +28,8 @@ export const LAB_TOOL_DESCRIPTIONS = Object.freeze({
24
28
  summarize_series: `A long price or return series in about 100 words plus fields: growth, risk, dated drawdowns, trend, volatility regime, tails, jumps, stale data. Use instead of reading raw bars. ${LAB_BOUNDARY.summarize_series}`,
25
29
  stress_test: `Stress a strategy's returns: bootstrap histories (how often the drawdown limit breaks or Sharpe turns negative) and named crash, volatility, repeat and stuck-position scenarios, with a fragility share. ${LAB_BOUNDARY.stress_test}`,
26
30
  check_feasibility: `Check a trading plan before it trades: broker order-rate and minimum-order limits, 2026 US day-trading rules, square-root market impact with crowding, and the capital where costs eat the return. ${LAB_BOUNDARY.check_feasibility}`,
31
+ check_leakage: `Does a signal look ahead? plan gives cuts; rerun your code on the first cut rows for each, then compare. Rows that changed used later rows; the result names the pattern and horizon. No code is sent. ${LAB_BOUNDARY.check_leakage}`,
32
+ placebo_test: `Does your pipeline find edges in noise? plan writes placebo files (your data's returns in random orders, so nothing is predictable); run the whole pipeline on real.csv and on each, then compare for a p-value that counts every choice it makes. ${LAB_BOUNDARY.placebo_test}`,
27
33
  });
28
34
 
29
35
  const FAMILY_NAMES = Object.keys(FAMILIES);
@@ -102,6 +108,37 @@ export const feasibilityInput = z
102
108
  })
103
109
  .strict();
104
110
 
111
+ export const leakageInput = z
112
+ .object({
113
+ action: z.enum(["plan", "compare"]),
114
+ observations: z.number().int().optional().describe("plan: rows in the series."),
115
+ prefixes: z.number().int().optional().describe("plan: cuts, default 5."),
116
+ seed: z.number().int().optional(),
117
+ cuts: z.array(z.number().int()).optional().describe("compare: plan's cuts."),
118
+ // Shapes and lengths are checked by the computation (js/leakage-core.js) with messages that
119
+ // say what to send; the advertised schema stays small because it is re-sent every turn.
120
+ columns: z.record(z.string(), z.looseObject({})).optional().describe("compare: {name: {full: [all rows], prefixes: [[first cuts[i] rows], ...]}}, null for missing."),
121
+ columns_file: z.string().optional().describe("compare, on your machine: a JSON file {cuts, columns} instead."),
122
+ timestamps: z.array(z.string()).optional(),
123
+ tolerance: z.number().optional().describe("Default 1e-9."),
124
+ })
125
+ .strict();
126
+
127
+ export const placeboInput = z
128
+ .object({
129
+ action: z.enum(["plan", "compare"]),
130
+ data_file: z.string().optional().describe("plan, on your machine: CSV or JSON, a column per asset, dates optional."),
131
+ columns: z.record(z.string(), z.array(z.number())).optional().describe("plan: {asset: [values]} instead of data_file."),
132
+ kind: z.enum(["prices", "returns"]).optional().describe("plan: default prices."),
133
+ placebos: z.number().int().optional().describe("plan: default 19, up to 199."),
134
+ method: z.enum(PLACEBO_METHODS).optional(),
135
+ seed: z.number().int().optional(),
136
+ real: z.number().optional().describe("compare: the pipeline's result on the real data."),
137
+ placebo_results: z.array(z.number()).optional().describe("compare: its result on each placebo, in file order."),
138
+ lower_is_better: z.boolean().optional(),
139
+ })
140
+ .strict();
141
+
105
142
  const loose = z.looseObject({}).optional();
106
143
  const sentences = z.array(z.string()).optional();
107
144
 
@@ -117,6 +154,14 @@ export const stressOutput = z
117
154
  .looseObject({ baseline: loose, resampled: loose, scenarios: z.array(z.unknown()).optional(), fragility: loose, plain_reading: z.string().optional(), limits: sentences, source: loose })
118
155
  .describe("resampled holds bootstrap percentiles and breach frequencies; scenarios each named stress and its rule; fragility the share of histories breaking the limit or losing.");
119
156
 
157
+ export const leakageOutput = z
158
+ .looseObject({ verdict: z.string().optional(), cuts: z.array(z.number()).optional(), seed: z.number().optional(), columns: loose, flagged_columns: z.array(z.string()).optional(), plain_reading: z.string().optional(), limits: sentences, source: loose })
159
+ .describe("plan: cuts and seed. compare: verdict over all columns, flagged_columns, and per column its pattern, sentence and per-cut changes.");
160
+
161
+ export const placeboOutput = z
162
+ .looseObject({ verdict: z.string().optional(), p_value: z.number().optional(), dir: z.string().optional(), files: z.array(z.string()).optional(), placebo: loose, plain_reading: z.string().optional(), limits: sentences, source: loose })
163
+ .describe("plan: dir, real.csv and the placebo files, method and seed. compare: verdict, p_value, and what the pipeline found on the placebos.");
164
+
120
165
  export const feasibilityOutput = z
121
166
  .looseObject({ verdict: z.string().optional(), checks: z.array(z.unknown()).optional(), to_change: z.array(z.string()).optional(), sources: loose, plain_reading: z.string().optional(), limits: sentences })
122
167
  .describe("verdict summarizes the checks; each check has a status (ok, warning, blocking, unknown), its finding and numbers; to_change lists what to fix.");
package/src/lab.mjs CHANGED
@@ -13,17 +13,23 @@
13
13
  //
14
14
  // Also here: the code-generation resources (every tool's exact JSON Schemas, the strategy spec,
15
15
  // runnable client examples, the API's OpenAPI document) and the lab's guided prompts.
16
- import { createHash } from "node:crypto";
16
+ import { createHash, randomInt } from "node:crypto";
17
+ import { mkdtempSync } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
17
20
  import { ResourceTemplate } from "@modelcontextprotocol/server";
18
21
  import { z } from "zod";
19
22
  import { BACKTEST_LIMITS, BACKTEST_LIMITS_TEXT, FAMILIES, runBacktest } from "./local/js/backtest-core.js";
20
23
  import { FEASIBILITY_LIMITS_TEXT, checkFeasibility } from "./local/js/feasibility-core.js";
21
24
  import { SUMMARY_LIMITS_TEXT, summarizeSeries } from "./local/js/series-summary-core.js";
22
25
  import { STRESS_LIMITS_TEXT, stressTest } from "./local/js/stress-core.js";
23
- import { LAB_TOOL_DESCRIPTIONS, backtestInput, backtestOutput, feasibilityInput, feasibilityOutput, stressInput, stressOutput, summarizeInput, summaryOutput } from "./lab-schemas.mjs";
24
- import { readSeriesWithDates } from "./series-file.mjs";
26
+ import { LEAKAGE_LIMITS, LEAKAGE_LIMITS_TEXT, checkLeakage, planPrefixes } from "./local/js/leakage-core.js";
27
+ import { PLACEBO_DEFAULT_METHOD, PLACEBO_LIMITS, PLACEBO_LIMITS_TEXT, checkPanel, placeboPValue, placeboPanels } from "./local/js/placebo-core.js";
28
+ import { LAB_TOOL_DESCRIPTIONS, backtestInput, backtestOutput, feasibilityInput, feasibilityOutput, leakageInput, leakageOutput, placeboInput, placeboOutput, stressInput, stressOutput, summarizeInput, summaryOutput } from "./lab-schemas.mjs";
29
+ import { readColumnsFile, readPanelFile, readSeriesWithDates, writePanelFile } from "./series-file.mjs";
30
+ import { leanJsonSchema } from "./schemas.mjs";
25
31
 
26
- export const LAB_TOOLS = Object.freeze(["backtest_strategy", "summarize_series", "stress_test", "check_feasibility"]);
32
+ export const LAB_TOOLS = Object.freeze(["backtest_strategy", "summarize_series", "stress_test", "check_feasibility", "check_leakage", "placebo_test"]);
27
33
 
28
34
  // Same shape as every other tool's result: the object as text, and as structured content.
29
35
  const labText = (value) => ({ content: [{ type: "text", text: JSON.stringify(value) }], structuredContent: value });
@@ -83,16 +89,129 @@ export async function toolCheckFeasibility(session, args) {
83
89
  return labText({ ...checkFeasibility(input), limits: FEASIBILITY_LIMITS_TEXT });
84
90
  }
85
91
 
92
+ // check_leakage: plan picks the cut points (a fresh seed unless one is sent, so the caller does not
93
+ // choose them); compare reads the caller's outputs and reports, per column, what changed and why.
94
+ export async function toolCheckLeakage(session, args) {
95
+ const input = parse(leakageInput, args, "check_leakage");
96
+ if (input.action === "plan") {
97
+ if (input.observations === undefined) throw new Error("check_leakage: plan needs observations, the number of rows in the full series");
98
+ const seed = input.seed ?? randomInt(1, 2 ** 31);
99
+ const cuts = planPrefixes(input.observations, { prefixes: input.prefixes ?? LEAKAGE_LIMITS.default_prefixes, seed });
100
+ return labText({
101
+ schema: "canli.leakage-plan.v1", observations: input.observations, cuts, seed,
102
+ plain_reading: `Run your signal code ${cuts.length + 1} times: on all ${input.observations} rows, and on the first ${cuts.join(", ")} rows. Send each output column with action compare and these cuts; prefixes[i] holds the run on the first cuts[i] rows.`,
103
+ limits: LEAKAGE_LIMITS_TEXT,
104
+ });
105
+ }
106
+ let { cuts, columns, timestamps } = input;
107
+ if (input.columns_file !== undefined) {
108
+ if (columns !== undefined) throw new Error("check_leakage: send columns or columns_file, not both");
109
+ if (session.hosted) throw new Error("check_leakage: the hosted endpoint cannot read files on your machine; send columns as numbers, or run the server locally with npx -y canli-validation-mcp.");
110
+ const file = readColumnsFile(input.columns_file);
111
+ if (cuts !== undefined && file.cuts !== undefined && JSON.stringify(cuts) !== JSON.stringify(file.cuts)) throw new Error("check_leakage: the cuts sent differ from the cuts in columns_file");
112
+ cuts ??= file.cuts;
113
+ columns = file.columns;
114
+ timestamps ??= file.timestamps;
115
+ }
116
+ if (cuts === undefined || columns === undefined) throw new Error("check_leakage: compare needs cuts (from plan) and columns, sent or in a columns_file");
117
+ const result = checkLeakage({ cuts, columns, tolerance: input.tolerance, timestamps: labels(timestamps) });
118
+ // Per cut, only what a reader needs: where the changes start and how large they are.
119
+ const report = Object.fromEntries(Object.entries(result.columns).map(([name, c]) => [name, {
120
+ verdict: c.verdict, pattern: c.pattern, sentence: c.sentence,
121
+ ...(c.horizon_rows !== undefined ? { horizon_rows: c.horizon_rows } : {}),
122
+ ...(c.share_changed !== undefined ? { share_changed: c.share_changed } : {}),
123
+ ...(c.first_changed_at !== undefined ? { first_changed_at: c.first_changed_at } : {}),
124
+ per_cut: c.prefixes.map((p) => ({ cut: p.cut, changed: p.changed, first_changed: p.first_changed, max_abs_difference: p.max_abs_difference })),
125
+ }]));
126
+ const flagged = result.flagged_columns;
127
+ return labText({
128
+ schema: "canli.leakage.v1", verdict: result.verdict, flagged_columns: flagged, cuts: result.cuts, columns: report,
129
+ source: { ...(input.columns_file !== undefined ? { columns_file: input.columns_file } : {}), input_sha256: digest({ cuts, columns }) },
130
+ plain_reading: flagged.length
131
+ ? `Lookahead in ${flagged.length} of ${Object.keys(report).length} column${Object.keys(report).length === 1 ? "" : "s"} (${flagged.join(", ")}). ${flagged.map((n) => `${n}: ${report[n].sentence}`).join(" ")}`
132
+ : `No value changed in ${Object.keys(report).length} column${Object.keys(report).length === 1 ? "" : "s"} at ${result.cuts.length} cuts: these prefixes found no lookahead.`,
133
+ limits: LEAKAGE_LIMITS_TEXT,
134
+ });
135
+ }
136
+
137
+ // placebo_test: plan reorders the periods of the caller's panel, the same order for every column,
138
+ // and writes the real panel and each placebo as CSV to a new folder in the system's temporary
139
+ // directory (the hosted endpoint, which writes nothing, returns small placebos inline); compare
140
+ // ranks the pipeline's real result among its placebo results.
141
+ const PLACEBO_INLINE_MAX = 50000;
142
+ const short = (x) => Number(x.toPrecision(4));
143
+
144
+ export async function toolPlaceboTest(session, args) {
145
+ const input = parse(placeboInput, args, "placebo_test");
146
+ if (input.action === "compare") {
147
+ if (input.real === undefined || input.placebo_results === undefined) throw new Error("placebo_test: compare needs real and placebo_results, from the pipeline's runs on real.csv and the placebo files");
148
+ const r = placeboPValue(input.real, input.placebo_results, { higherIsBetter: !input.lower_is_better });
149
+ const beats = r.as_good_as_real === 0;
150
+ const luck = `On the placebos, where nothing can be predicted, it found a median of ${short(r.placebo.median)} and at best ${short(r.placebo.best)}.`;
151
+ return labText({
152
+ schema: "canli.placebo.v1", verdict: r.p_value <= 0.05 ? "beats_placebos" : "within_placebo_range", ...r,
153
+ source: { input_sha256: digest({ real: input.real, placebo_results: input.placebo_results }) },
154
+ plain_reading: beats
155
+ ? `The pipeline's real result, ${short(r.real)}, beat all ${r.placebos} placebos: p = ${short(r.p_value)}, the smallest ${r.placebos} placebos can give${r.placebos < 99 ? "; 99 placebos give a finer p" : ""}. ${luck}`
156
+ : `${r.as_good_as_real} of ${r.placebos} placebos gave a result at least as good as the real one, ${short(r.real)}: p = ${short(r.p_value)}. ${luck}`,
157
+ limits: PLACEBO_LIMITS_TEXT,
158
+ });
159
+ }
160
+ if ((input.data_file === undefined) === (input.columns === undefined)) throw new Error("placebo_test: plan needs exactly one of data_file or columns");
161
+ const kind = input.kind ?? "prices";
162
+ const method = input.method ?? PLACEBO_DEFAULT_METHOD;
163
+ const placebos = input.placebos ?? PLACEBO_LIMITS.default_placebos;
164
+ const seed = input.seed ?? randomInt(1, 2 ** 31);
165
+ let panel;
166
+ if (input.data_file !== undefined) {
167
+ if (session.hosted) throw new Error("placebo_test: the hosted endpoint cannot read files on your machine; send columns as numbers, or run the server locally with npx -y canli-validation-mcp.");
168
+ panel = readPanelFile(input.data_file);
169
+ } else {
170
+ const names = Object.keys(input.columns);
171
+ panel = { columns: names.map((n) => input.columns[n]), names, dates: null, date_name: null, skipped: [], format: "csv" };
172
+ }
173
+ checkPanel(panel.columns, kind);
174
+ const source = {
175
+ ...(input.data_file !== undefined ? { data_file: input.data_file, column_positions: panel.positions, ...(panel.date_column ? { date_column_position: panel.date_column } : {}), ...(panel.skipped.length ? { skipped_row_counter_columns: panel.skipped } : {}) } : {}),
176
+ columns: panel.columns.length, rows: panel.columns[0].length, input_sha256: digest(panel.columns),
177
+ };
178
+ const made = placeboPanels(panel.columns, { kind, method, placebos, seed });
179
+ const plan = { schema: "canli.placebo-plan.v1", kind, method, placebos, seed };
180
+ if (session.hosted) {
181
+ if (placebos * panel.columns.length * panel.columns[0].length > PLACEBO_INLINE_MAX) throw new Error(`placebo_test: the hosted endpoint returns at most ${PLACEBO_INLINE_MAX} placebo numbers; send fewer rows or columns, or run the server locally with npx -y canli-validation-mcp, which writes the placebos to files`);
182
+ const names = panel.names;
183
+ return labText({ ...plan, placebo_columns: [...made].map((p) => Object.fromEntries(names.map((n, i) => [n, p[i]]))), plain_reading: `Run the pipeline unchanged on the real columns and on each of the ${placebos} placebos, then send its results to compare in this order.`, limits: PLACEBO_LIMITS_TEXT, source });
184
+ }
185
+ const dir = mkdtempSync(join(tmpdir(), "canli-placebo-"));
186
+ const layout = { names: panel.names, dates: panel.dates, dateName: panel.date_name, format: panel.format };
187
+ const real = `real.${panel.format}`;
188
+ writePanelFile(join(dir, real), { ...layout, columns: panel.columns });
189
+ const files = [];
190
+ for (const columns of made) {
191
+ const name = `placebo_${String(files.length + 1).padStart(3, "0")}.${panel.format}`;
192
+ writePanelFile(join(dir, name), { ...layout, columns });
193
+ files.push(name);
194
+ }
195
+ return labText({
196
+ ...plan, dir, real, files,
197
+ plain_reading: `Run the pipeline unchanged, every search and choice included, on ${real} and on each of the ${placebos} placebo files in ${dir}, record the one number it reports (such as its best Sharpe), and send them to compare in file order.`,
198
+ limits: PLACEBO_LIMITS_TEXT, source,
199
+ });
200
+ }
201
+
86
202
  // The lab computes locally and changes nothing anywhere: read-only, closed world, idempotent.
87
203
  const LAB_ANNOTATIONS = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
88
204
 
89
205
  export function labToolSpecs(session) {
90
- const spec = (name, title, input, output, handler) => [name, { title, annotations: { title, ...LAB_ANNOTATIONS }, description: LAB_TOOL_DESCRIPTIONS[name], inputSchema: input, outputSchema: output }, (args) => handler(session, args)];
206
+ const spec = (name, title, input, output, handler, annotations = LAB_ANNOTATIONS) => [name, { title, annotations: { title, ...annotations }, description: LAB_TOOL_DESCRIPTIONS[name], inputSchema: input, outputSchema: output }, (args) => handler(session, args)];
91
207
  return [
92
208
  spec("backtest_strategy", "Backtest a rule over a parameter grid, validated", backtestInput, backtestOutput, toolBacktestStrategy),
93
209
  spec("summarize_series", "Summarize a price or return series", summarizeInput, summaryOutput, toolSummarizeSeries),
94
210
  spec("stress_test", "Stress-test a strategy's returns", stressInput, stressOutput, toolStressTest),
95
211
  spec("check_feasibility", "Check a plan against broker limits and impact", feasibilityInput, feasibilityOutput, toolCheckFeasibility),
212
+ spec("check_leakage", "Check a signal for lookahead", leakageInput, leakageOutput, toolCheckLeakage),
213
+ // plan writes files, each call to a new temporary folder: not read-only, not idempotent.
214
+ spec("placebo_test", "Test a research pipeline on placebo data", placeboInput, placeboOutput, toolPlaceboTest, { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }),
96
215
  ];
97
216
  }
98
217
 
@@ -121,6 +240,8 @@ export const EXAMPLE_ARGS = Object.freeze({
121
240
  summarize_series: { series_file: "prices.csv", series_kind: "prices", name: "My asset" },
122
241
  stress_test: { returns_file: "strategy_returns.csv", drawdown_limit: 0.2, paths: 1000, seed: 42 },
123
242
  check_feasibility: { broker: "alpaca", asset_class: "us_equity", account: "margin", capital_usd: 250000, orders_per_rebalance: 40, rebalances_per_year: 52, turnover_per_year: 8, adv_usd: 20000000, daily_volatility: 0.02, copies: 1, expected_gross_return: 0.12, spread_and_fees_bps: 3 },
243
+ check_leakage: { action: "plan", observations: 2520, prefixes: 5 },
244
+ placebo_test: { action: "plan", data_file: "prices.csv" },
124
245
  });
125
246
 
126
247
  const usesFile = (args) => Object.keys(args).some((k) => k.endsWith("_file"));
@@ -220,7 +341,7 @@ export function registerCodeResources(server, session, catalog) {
220
341
  (uri, { tool }) => {
221
342
  const entry = catalog[tool];
222
343
  if (!entry) throw new Error(`No tool ${tool}; tools are ${names.join(", ")}`);
223
- const body = { tool, description: entry.description, input_schema: z.toJSONSchema(entry.inputSchema, { io: "input" }), output_schema: z.toJSONSchema(entry.outputSchema, { io: "output" }) };
344
+ const body = { tool, description: entry.description, input_schema: leanJsonSchema(z.toJSONSchema(entry.inputSchema, { io: "input" })), output_schema: leanJsonSchema(z.toJSONSchema(entry.outputSchema, { io: "output" })) };
224
345
  return { contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(body, null, 2) }] };
225
346
  },
226
347
  );
@@ -305,4 +426,26 @@ export function registerLabPrompts(server) {
305
426
  "Point out anything in the data check (stale prices, jumps) that should be fixed before the series is backtested.",
306
427
  ],
307
428
  );
429
+ prompt(
430
+ "check_signal_for_lookahead",
431
+ "Does my signal look ahead?",
432
+ "Rerun your own signal code on prefixes the server picks, and find any value that depends on later rows.",
433
+ { rows: z.string().describe("Rows in the full series the signal is computed on") },
434
+ ({ rows }) => [
435
+ `Call check_leakage with action plan and observations ${rows}.`,
436
+ "Run your own signal and feature code once on all rows and once on the first cut rows for each cut it returns, changing nothing else, and send every output column with action compare. On this machine, write them to a JSON file {cuts, columns} and send its path as columns_file instead of pasting numbers.",
437
+ "For each flagged column, explain the pattern (future rows with their horizon, full-sample statistics, filled gaps), find the line of code that causes it, fix it, and run the check again. Say that a pass covers only these cuts.",
438
+ ],
439
+ );
440
+ prompt(
441
+ "test_pipeline_on_placebos",
442
+ "Does my pipeline find edges in noise?",
443
+ "Run your whole research pipeline on placebo data, where nothing can be predicted, and see whether its real result beats what it finds there.",
444
+ { data_file: z.string().describe("Path to the CSV or JSON the pipeline reads: a column per asset, dates optional") },
445
+ ({ data_file }) => [
446
+ `Call placebo_test with action plan and data_file ${data_file}.`,
447
+ "Run the research pipeline unchanged, every search, filter and parameter choice included, on real.csv and on each placebo file in the folder it returns, and record the one number the pipeline reports, such as its best Sharpe.",
448
+ "Call placebo_test with action compare, the real number as real and the placebo numbers in file order as placebo_results. Explain the p-value and what the pipeline finds on data where nothing can be predicted, and say that only this pipeline's search is counted, not pipelines tried before it.",
449
+ ],
450
+ );
308
451
  }
@@ -0,0 +1,182 @@
1
+ // =============================================================================
2
+ // leakage-core.js
3
+ // -----------------------------------------------------------------------------
4
+ // A lookahead check that needs no code. A causal signal's value at row t depends only on rows 0
5
+ // to t, so computing it on the first n rows gives exactly the values the full run gives for rows
6
+ // 0 to n - 1. The server picks the cut points; the caller reruns its own code on each prefix and
7
+ // sends every column back; a row whose value changed when the rows after the cut were removed
8
+ // used those rows.
9
+ //
10
+ // planPrefixes the cut points (prefix lengths) for a series, seeded so a plan reproduces
11
+ // compareColumn for each prefix: rows compared, rows that changed, the first and last changed
12
+ // row, the largest change, and how far before the cut the changes start
13
+ // diagnose what the pattern of changes says: future rows (a fixed horizon before every
14
+ // cut), the full sample (most of every prefix changed: a full-sample
15
+ // normalization, rank or fit), or sparse rows (typical of filling gaps backwards)
16
+ //
17
+ // A pass shows only that these prefixes found no lookahead: the caller supplies the columns, and
18
+ // survivorship, vendor revisions or a leak that only shows in rows never cut leave no trace here.
19
+ // =============================================================================
20
+
21
+ import { makeRandom } from "./selection-risk-core.js";
22
+
23
+ export const LEAKAGE_LIMITS = Object.freeze({
24
+ min_observations: 30,
25
+ max_observations: 100000,
26
+ min_prefixes: 2,
27
+ max_prefixes: 20,
28
+ default_prefixes: 5,
29
+ max_columns: 20,
30
+ default_tolerance: 1e-9,
31
+ });
32
+
33
+ export const LEAKAGE_LIMITS_TEXT = Object.freeze([
34
+ "A pass covers only these cuts: the columns are the caller's, and survivorship, vendor revisions or a leak no cut reaches leave no trace.",
35
+ "Cut points fall between 40% and 95% of the series, so a leak confined to its first rows is not tested.",
36
+ ]);
37
+
38
+ /**
39
+ * Prefix lengths for a series of `observations` rows: `prefixes` distinct cuts between 40% and 95%
40
+ * of the series, one in each equal slot of that range at a seeded position, sorted.
41
+ */
42
+ export function planPrefixes(observations, { prefixes = LEAKAGE_LIMITS.default_prefixes, seed = 1 } = {}) {
43
+ const n = Number(observations);
44
+ const k = Number(prefixes);
45
+ if (!Number.isInteger(n) || n < LEAKAGE_LIMITS.min_observations || n > LEAKAGE_LIMITS.max_observations) {
46
+ throw new RangeError(`observations must be an integer from ${LEAKAGE_LIMITS.min_observations} to ${LEAKAGE_LIMITS.max_observations}`);
47
+ }
48
+ if (!Number.isInteger(k) || k < LEAKAGE_LIMITS.min_prefixes || k > LEAKAGE_LIMITS.max_prefixes) {
49
+ throw new RangeError(`prefixes must be an integer from ${LEAKAGE_LIMITS.min_prefixes} to ${LEAKAGE_LIMITS.max_prefixes}`);
50
+ }
51
+ const lo = Math.ceil(0.4 * n);
52
+ const hi = Math.floor(0.95 * n);
53
+ if (hi - lo + 1 < k) throw new RangeError(`a series of ${n} rows has room for at most ${hi - lo + 1} cuts`);
54
+ const next = makeRandom(Number(seed) >>> 0 || 1);
55
+ const width = (hi - lo + 1) / k;
56
+ const cuts = new Set();
57
+ for (let i = 0; i < k; i++) {
58
+ const start = lo + Math.floor(i * width);
59
+ const end = Math.max(start, lo + Math.floor((i + 1) * width) - 1);
60
+ let cut = start + Math.floor(next() * (end - start + 1));
61
+ while (cuts.has(cut)) cut += 1;
62
+ cuts.add(cut);
63
+ }
64
+ return [...cuts].sort((a, b) => a - b);
65
+ }
66
+
67
+ const missing = (v) => v === null || v === undefined || (typeof v === "number" && Number.isNaN(v));
68
+
69
+ function readValue(v, where) {
70
+ if (missing(v)) return null;
71
+ const x = typeof v === "number" ? v : typeof v === "string" && v.trim() !== "" ? Number(v) : Number.NaN;
72
+ if (!Number.isFinite(x)) throw new RangeError(`${where} is ${JSON.stringify(v)}, not a number or null`);
73
+ return x;
74
+ }
75
+
76
+ /**
77
+ * Compares one column's full run with its prefix runs. `prefixes[i]` must hold exactly `cuts[i]`
78
+ * values: the caller's code run on rows 0 to cuts[i] - 1. Missing values (null) match only missing
79
+ * values; numbers match within `tolerance` relative to max(1, |a|, |b|).
80
+ */
81
+ export function compareColumn(full, prefixes, cuts, { tolerance = LEAKAGE_LIMITS.default_tolerance, name = "column" } = {}) {
82
+ if (!Array.isArray(full)) throw new RangeError(`${name}.full must be an array`);
83
+ if (!Array.isArray(prefixes) || prefixes.length !== cuts.length) throw new RangeError(`${name}.prefixes must hold ${cuts.length} runs, one per cut`);
84
+ const f = full.map((v, i) => readValue(v, `${name}.full[${i}]`));
85
+ return cuts.map((cut, p) => {
86
+ const run = prefixes[p];
87
+ if (!Array.isArray(run) || run.length !== cut) {
88
+ throw new RangeError(`${name}.prefixes[${p}] has ${Array.isArray(run) ? run.length : "no"} values; its cut is ${cut}: send your code's output on exactly the first ${cut} rows`);
89
+ }
90
+ if (cut > f.length) throw new RangeError(`${name}: cut ${cut} is beyond the ${f.length} rows of the full run`);
91
+ let changed = 0, first = -1, last = -1, maxDiff = 0;
92
+ for (let t = 0; t < cut; t++) {
93
+ const a = f[t];
94
+ const b = readValue(run[t], `${name}.prefixes[${p}][${t}]`);
95
+ let same;
96
+ if (a === null || b === null) same = a === b;
97
+ else {
98
+ const d = Math.abs(a - b);
99
+ same = d <= tolerance * Math.max(1, Math.abs(a), Math.abs(b));
100
+ if (!same && d > maxDiff) maxDiff = d;
101
+ }
102
+ if (!same) {
103
+ changed += 1;
104
+ if (first < 0) first = t;
105
+ last = t;
106
+ }
107
+ }
108
+ return {
109
+ cut,
110
+ compared: cut,
111
+ changed,
112
+ share_changed: changed / cut,
113
+ first_changed: first < 0 ? null : first,
114
+ last_changed: last < 0 ? null : last,
115
+ rows_before_cut: first < 0 ? null : cut - first,
116
+ max_abs_difference: changed ? maxDiff : 0,
117
+ };
118
+ });
119
+ }
120
+
121
+ /**
122
+ * What a column's changes say. future_rows: in every prefix the changed rows sit in a block that
123
+ * ends at the cut and is no wider than a tenth of the shortest prefix, so the value at row t uses
124
+ * rows up to t + horizon. full_sample: most rows of some prefix changed. sparse: anything else.
125
+ */
126
+ export function diagnose(results) {
127
+ const touched = results.filter((r) => r.changed > 0);
128
+ if (!touched.length) {
129
+ return { verdict: "no_lookahead_found", pattern: "none", sentence: `No value changed in ${results.length} prefixes: these cuts found no lookahead.` };
130
+ }
131
+ const shortest = Math.min(...results.map((r) => r.cut));
132
+ const horizon = Math.max(...touched.map((r) => r.rows_before_cut));
133
+ const endsAtCut = touched.every((r) => r.last_changed === r.cut - 1 && r.changed === r.rows_before_cut);
134
+ if (endsAtCut && horizon <= Math.max(1, Math.floor(shortest / 10))) {
135
+ return {
136
+ verdict: "lookahead_found",
137
+ pattern: "future_rows",
138
+ horizon_rows: horizon,
139
+ sentence: `Up to ${horizon} row${horizon === 1 ? "" : "s"} before each cut changed when the later rows were removed: the value at row t uses at least the next ${horizon} row${horizon === 1 ? "" : "s"}, as shift(-${horizon}), a centered window or a forward return would.`,
140
+ };
141
+ }
142
+ const widest = Math.max(...touched.map((r) => r.share_changed));
143
+ if (widest > 0.5) {
144
+ return {
145
+ verdict: "lookahead_found",
146
+ pattern: "full_sample",
147
+ share_changed: widest,
148
+ sentence: `Up to ${(widest * 100).toFixed(0)}% of a prefix's rows changed when later rows were removed: values depend on the whole sample, as a full-sample normalization, rank, scaling or model fit would.`,
149
+ };
150
+ }
151
+ const first = Math.min(...touched.map((r) => r.first_changed));
152
+ return {
153
+ verdict: "lookahead_found",
154
+ pattern: "sparse",
155
+ first_changed: first,
156
+ sentence: `Scattered rows changed when later rows were removed (the earliest at row ${first}): typical of filling gaps backwards (bfill) or a lookup that reaches forward.`,
157
+ };
158
+ }
159
+
160
+ /** The whole check: every column compared at every cut, and one verdict for the set. */
161
+ export function checkLeakage({ cuts, columns, tolerance = LEAKAGE_LIMITS.default_tolerance, timestamps }) {
162
+ if (!Array.isArray(cuts) || cuts.length < LEAKAGE_LIMITS.min_prefixes || cuts.length > LEAKAGE_LIMITS.max_prefixes) {
163
+ throw new RangeError(`cuts must list ${LEAKAGE_LIMITS.min_prefixes} to ${LEAKAGE_LIMITS.max_prefixes} prefix lengths, as check_leakage's plan returned them`);
164
+ }
165
+ const names = Object.keys(columns ?? {});
166
+ if (!names.length || names.length > LEAKAGE_LIMITS.max_columns) throw new RangeError(`send 1 to ${LEAKAGE_LIMITS.max_columns} columns`);
167
+ const out = {};
168
+ for (const name of names) {
169
+ const results = compareColumn(columns[name].full, columns[name].prefixes, cuts, { tolerance, name });
170
+ const d = diagnose(results);
171
+ const label = (i) => (timestamps && i !== null && i !== undefined && timestamps[i] !== undefined ? timestamps[i] : undefined);
172
+ out[name] = { ...d, ...(d.pattern === "sparse" && label(d.first_changed) ? { first_changed_at: label(d.first_changed) } : {}), prefixes: results };
173
+ }
174
+ const flagged = names.filter((n) => out[n].verdict === "lookahead_found");
175
+ return {
176
+ verdict: flagged.length ? "lookahead_found" : "no_lookahead_found",
177
+ flagged_columns: flagged,
178
+ cuts,
179
+ tolerance,
180
+ columns: out,
181
+ };
182
+ }
@@ -0,0 +1,164 @@
1
+ // =============================================================================
2
+ // placebo-core.js
3
+ // -----------------------------------------------------------------------------
4
+ // The pipeline placebo. A research pipeline searches data for a strategy and reports a result,
5
+ // usually the best Sharpe it found. How good a result does the same pipeline find in data where
6
+ // nothing can be predicted? A placebo dataset holds the real returns with their periods reordered,
7
+ // the same reordering for every column: each column keeps its returns (mean, volatility, fat
8
+ // tails) and each period keeps its cross-section, but no period's position says anything about
9
+ // the next. The caller runs its own pipeline on the real data and on every placebo; the rank of the
10
+ // real result among the placebo results is the p-value. The pipeline's code never leaves the
11
+ // caller, and every choice the pipeline makes is counted, because it makes them again on every
12
+ // placebo.
13
+ //
14
+ // placeboOrder one reordering of n periods: a permutation, a permutation of blocks, or a
15
+ // stationary bootstrap (Politis and Romano 1994)
16
+ // placeboPanel a panel of prices or returns, reordered: prices are rebuilt from the
17
+ // reordered returns, starting from the real first prices
18
+ // placeboPanels K placebos from one seed, one at a time
19
+ // placeboPValue the real result's rank among the placebo results, as a p-value
20
+ //
21
+ // When the periods are exchangeable (independent and identically distributed rows), the real data
22
+ // and each permutation of it are equally likely, so the real result's rank is uniform and the
23
+ // permutation p-value is exact. Volatility clustering breaks exchangeability; how far that moves
24
+ // the p-value was measured before a method was chosen (scripts/research/placebo/v1/).
25
+ // =============================================================================
26
+
27
+ import { makeRandom } from "./selection-risk-core.js";
28
+ import { defaultBlock, stationaryIndices } from "./snooping-core.js";
29
+
30
+ export const PLACEBO_METHODS = Object.freeze(["permute", "block_permute", "stationary_bootstrap"]);
31
+ // placebo_test's default: the method the pre-registered study selected
32
+ // (config/research/placebo-v1-evaluation.json; a test keeps the two equal).
33
+ export const PLACEBO_DEFAULT_METHOD = "permute";
34
+
35
+ export const PLACEBO_LIMITS = Object.freeze({
36
+ min_rows: 30,
37
+ max_rows: 100000,
38
+ max_columns: 500,
39
+ max_cells: 5000000,
40
+ min_placebos: 19,
41
+ max_placebos: 199,
42
+ default_placebos: 19,
43
+ });
44
+
45
+ export const PLACEBO_LIMITS_TEXT = Object.freeze([
46
+ "Beating the placebos shows the pipeline finds more in the real order of the returns than in random orders of the same returns; it does not show the edge survives costs, capacity or the future.",
47
+ "Only the search this pipeline runs is counted: pipelines tried and dropped before it are not.",
48
+ ]);
49
+
50
+ /** A uniform integer in [0, k) from a uniform generator in [0, 1). */
51
+ const below = (next, k) => Math.min(k - 1, Math.floor(next() * k));
52
+
53
+ /** Fisher-Yates over an Int32Array in place. */
54
+ function shuffle(values, next) {
55
+ for (let i = values.length - 1; i > 0; i--) {
56
+ const j = below(next, i + 1);
57
+ const v = values[i];
58
+ values[i] = values[j];
59
+ values[j] = v;
60
+ }
61
+ return values;
62
+ }
63
+
64
+ /**
65
+ * One reordering of n periods, as the index of the real period each placebo period takes.
66
+ * permute: every order equally likely. block_permute: the periods cut into consecutive blocks of
67
+ * `block` (the last may be shorter) and the blocks shuffled, so structure inside a block survives.
68
+ * stationary_bootstrap: blocks of random length with mean `block`, drawn with replacement.
69
+ */
70
+ export function placeboOrder(n, { method = PLACEBO_DEFAULT_METHOD, block, next }) {
71
+ if (!PLACEBO_METHODS.includes(method)) throw new RangeError(`method must be one of ${PLACEBO_METHODS.join(", ")}`);
72
+ if (method === "permute") return shuffle(Int32Array.from({ length: n }, (_, i) => i), next);
73
+ const b = block ?? defaultBlock(n);
74
+ if (!Number.isInteger(b) || b < 1 || b > n) throw new RangeError(`block must be a whole number from 1 to ${n}`);
75
+ if (method === "stationary_bootstrap") return stationaryIndices(n, b, next);
76
+ const starts = shuffle(Int32Array.from({ length: Math.ceil(n / b) }, (_, k) => k * b), next);
77
+ const out = new Int32Array(n);
78
+ let at = 0;
79
+ for (const start of starts) for (let t = start; t < Math.min(n, start + b); t++) out[at++] = t;
80
+ return out;
81
+ }
82
+
83
+ /**
84
+ * Checks a panel (columns of equal length) and returns the number of periods the placebo reorders:
85
+ * the rows of a returns panel, or the returns between rows of a prices panel.
86
+ */
87
+ export function checkPanel(columns, kind = "prices") {
88
+ if (kind !== "prices" && kind !== "returns") throw new RangeError('kind must be "prices" or "returns"');
89
+ if (!Array.isArray(columns) || !columns.length || columns.length > PLACEBO_LIMITS.max_columns) {
90
+ throw new RangeError(`send 1 to ${PLACEBO_LIMITS.max_columns} columns`);
91
+ }
92
+ const rows = columns[0]?.length;
93
+ if (!Number.isInteger(rows) || rows < PLACEBO_LIMITS.min_rows || rows > PLACEBO_LIMITS.max_rows) {
94
+ throw new RangeError(`each column needs ${PLACEBO_LIMITS.min_rows} to ${PLACEBO_LIMITS.max_rows} rows`);
95
+ }
96
+ if (rows * columns.length > PLACEBO_LIMITS.max_cells) throw new RangeError(`at most ${PLACEBO_LIMITS.max_cells} numbers in all`);
97
+ columns.forEach((column, c) => {
98
+ if (!Array.isArray(column) && !ArrayBuffer.isView(column)) throw new RangeError(`column ${c + 1} is not a list of numbers`);
99
+ if (column.length !== rows) throw new RangeError(`column ${c + 1} has ${column.length} rows; column 1 has ${rows}`);
100
+ for (let t = 0; t < rows; t++) {
101
+ const v = column[t];
102
+ if (typeof v !== "number" || !Number.isFinite(v)) throw new RangeError(`column ${c + 1}, row ${t + 1} is not a finite number: the placebo needs a complete panel`);
103
+ if (kind === "prices" && v <= 0) throw new RangeError(`column ${c + 1}, row ${t + 1} is not a positive price`);
104
+ }
105
+ });
106
+ return kind === "prices" ? rows - 1 : rows;
107
+ }
108
+
109
+ /**
110
+ * A panel reordered by `order`. Returns: placebo row t is real row order[t]. Prices: the placebo
111
+ * keeps the real first prices, and its return into row t + 1 is the real return into row
112
+ * order[t] + 1, so a permutation ends every column at its real last price.
113
+ */
114
+ export function placeboPanel(columns, { kind = "prices", order }) {
115
+ if (kind === "returns") return columns.map((column) => Array.from(order, (i) => column[i]));
116
+ return columns.map((column) => {
117
+ const out = new Array(column.length);
118
+ out[0] = column[0];
119
+ for (let t = 1; t < column.length; t++) {
120
+ const i = order[t - 1] + 1;
121
+ out[t] = out[t - 1] * (column[i] / column[i - 1]);
122
+ }
123
+ return out;
124
+ });
125
+ }
126
+
127
+ /** `placebos` placebo panels from one seed, in order, one at a time. */
128
+ export function* placeboPanels(columns, { kind = "prices", method = PLACEBO_DEFAULT_METHOD, block, placebos = PLACEBO_LIMITS.default_placebos, seed = 1 } = {}) {
129
+ const n = checkPanel(columns, kind);
130
+ if (!Number.isInteger(placebos) || placebos < PLACEBO_LIMITS.min_placebos || placebos > PLACEBO_LIMITS.max_placebos) {
131
+ throw new RangeError(`placebos must be a whole number from ${PLACEBO_LIMITS.min_placebos} to ${PLACEBO_LIMITS.max_placebos}`);
132
+ }
133
+ const next = makeRandom(Number(seed) >>> 0 || 1);
134
+ for (let k = 0; k < placebos; k++) yield placeboPanel(columns, { kind, order: placeboOrder(n, { method, block, next }) });
135
+ }
136
+
137
+ /** The value at nearest rank q (0 < q <= 1) of an ascending list. */
138
+ const nearestRank = (sorted, q) => sorted[Math.max(0, Math.ceil(q * sorted.length) - 1)];
139
+
140
+ /**
141
+ * The real result's rank among the placebo results. A placebo result as good as the real one counts
142
+ * against it, so ties make the p-value larger, never smaller. With K placebos the smallest p-value
143
+ * is 1 / (K + 1).
144
+ */
145
+ export function placeboPValue(real, results, { higherIsBetter = true } = {}) {
146
+ if (typeof real !== "number" || !Number.isFinite(real)) throw new RangeError("real must be a finite number: the pipeline's result on the real data");
147
+ if (!Array.isArray(results) || results.length < PLACEBO_LIMITS.min_placebos || results.length > PLACEBO_LIMITS.max_placebos) {
148
+ throw new RangeError(`send ${PLACEBO_LIMITS.min_placebos} to ${PLACEBO_LIMITS.max_placebos} placebo results, one per placebo`);
149
+ }
150
+ results.forEach((x, k) => {
151
+ if (typeof x !== "number" || !Number.isFinite(x)) throw new RangeError(`placebo_results[${k}] is not a finite number: rerun that placebo, or record what the pipeline reports when it finds nothing`);
152
+ });
153
+ const atLeast = results.filter((x) => (higherIsBetter ? x >= real : x <= real)).length;
154
+ const sorted = [...results].sort((a, b) => a - b);
155
+ const best = higherIsBetter ? sorted[sorted.length - 1] : sorted[0];
156
+ return {
157
+ p_value: (1 + atLeast) / (results.length + 1),
158
+ smallest_possible_p: 1 / (results.length + 1),
159
+ placebos: results.length,
160
+ as_good_as_real: atLeast,
161
+ real,
162
+ placebo: { min: sorted[0], median: nearestRank(sorted, 0.5), q90: nearestRank(sorted, 0.9), max: sorted[sorted.length - 1], best },
163
+ };
164
+ }
package/src/schemas.mjs CHANGED
@@ -394,3 +394,40 @@ export const statusOutput = z
394
394
  export const companyHistoryOutput = z
395
395
  .looseObject({ company: loose, claim_boundary: z.string().optional(), source: loose, histories: z.array(z.unknown()).optional(), history: loose, error: refusal })
396
396
  .describe("Without a concept, histories lists what is available; with one, history holds the observations newest first as columns and rows, each with its filing. Values are as reported to the SEC.");
397
+
398
+ // zod writes keywords that constrain nothing a caller sends, and the tool list carrying them is
399
+ // re-sent to the model every turn, so the schemas the server lists leave them out:
400
+ // - JavaScript's safe-integer range (minimum -9007199254740991, maximum 9007199254740991) on every
401
+ // .int() field, about nine tokens a field;
402
+ // - propertyNames {type: "string"}, which every JSON object key already is;
403
+ // - an empty additionalProperties ({}), which allows anything, as its absence does.
404
+ // A real bound stays, and zod still validates every call. Only subschemas are walked, so a field
405
+ // that happens to be named "minimum" or "additionalProperties" is never touched.
406
+ const SUBSCHEMA_MAPS = ["properties", "patternProperties", "$defs", "dependentSchemas"];
407
+ const SUBSCHEMA_LISTS = ["anyOf", "oneOf", "allOf", "prefixItems"];
408
+ const SUBSCHEMAS = ["items", "additionalProperties", "propertyNames", "contains", "not", "if", "then", "else", "unevaluatedItems", "unevaluatedProperties"];
409
+ const isPlainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
410
+
411
+ export function leanJsonSchema(schema) {
412
+ if (!isPlainObject(schema)) return schema;
413
+ const out = { ...schema };
414
+ for (const key of SUBSCHEMA_MAPS) {
415
+ if (isPlainObject(out[key])) out[key] = Object.fromEntries(Object.entries(out[key]).map(([name, sub]) => [name, leanJsonSchema(sub)]));
416
+ }
417
+ for (const key of SUBSCHEMA_LISTS) if (Array.isArray(out[key])) out[key] = out[key].map(leanJsonSchema);
418
+ for (const key of SUBSCHEMAS) if (key in out) out[key] = leanJsonSchema(out[key]);
419
+ const integer = out.type === "integer" || (Array.isArray(out.type) && out.type.includes("integer"));
420
+ if (integer && out.minimum === Number.MIN_SAFE_INTEGER) delete out.minimum;
421
+ if (integer && out.maximum === Number.MAX_SAFE_INTEGER) delete out.maximum;
422
+ if (isPlainObject(out.propertyNames) && Object.keys(out.propertyNames).length === 1 && out.propertyNames.type === "string") delete out.propertyNames;
423
+ if (isPlainObject(out.additionalProperties) && Object.keys(out.additionalProperties).length === 0) delete out.additionalProperties;
424
+ return out;
425
+ }
426
+
427
+ // The schema a tool is registered with: zod's own validation, and its JSON Schema made lean.
428
+ export function listedSchema(schema) {
429
+ if (!schema) return schema;
430
+ const std = schema["~standard"];
431
+ const lean = (io) => (options) => leanJsonSchema(std.jsonSchema[io](options));
432
+ return { "~standard": { ...std, jsonSchema: { input: lean("input"), output: lean("output") } } };
433
+ }
@@ -7,15 +7,17 @@
7
7
  // Only numbers leave this module. Error messages name rows and columns by position, never by a
8
8
  // cell's content or a header's text, so a path pointed at the wrong file cannot echo that file back
9
9
  // into the conversation. The hosted endpoint never reads files (see toolAuditBacktest).
10
- import { closeSync, fstatSync, openSync, readSync } from "node:fs";
10
+ import { closeSync, fstatSync, openSync, readSync, writeFileSync } from "node:fs";
11
11
  import { resolve } from "node:path";
12
12
 
13
13
  export const MAX_SERIES_FILE_BYTES = 5 * 1024 * 1024;
14
+ // check_leakage's columns and placebo_test's panels hold many series at once: a larger cap.
15
+ export const MAX_LARGE_FILE_BYTES = 50 * 1024 * 1024;
14
16
 
15
17
  // One open file descriptor for the check and the read, so the file checked is the file read: a
16
18
  // path swapped between a separate stat and read could otherwise pass the size and type checks as
17
19
  // one file and be read as another. The read stops one byte past the cap, whatever the file grows to.
18
- function readText(path) {
20
+ function readText(path, maxBytes = MAX_SERIES_FILE_BYTES) {
19
21
  let fd;
20
22
  try {
21
23
  fd = openSync(resolve(path), "r");
@@ -25,14 +27,14 @@ function readText(path) {
25
27
  try {
26
28
  const stat = fstatSync(fd);
27
29
  if (!stat.isFile()) throw new Error(`${path}: not a regular file`);
28
- if (stat.size > MAX_SERIES_FILE_BYTES) throw new Error(`${path}: larger than ${MAX_SERIES_FILE_BYTES} bytes`);
29
- const buffer = Buffer.alloc(MAX_SERIES_FILE_BYTES + 1);
30
+ if (stat.size > maxBytes) throw new Error(`${path}: larger than ${maxBytes} bytes`);
31
+ const buffer = Buffer.alloc(maxBytes + 1);
30
32
  let length = 0;
31
33
  for (;;) {
32
34
  const read = readSync(fd, buffer, length, buffer.length - length, null);
33
35
  if (read === 0) break;
34
36
  length += read;
35
- if (length > MAX_SERIES_FILE_BYTES) throw new Error(`${path}: larger than ${MAX_SERIES_FILE_BYTES} bytes`);
37
+ if (length > maxBytes) throw new Error(`${path}: larger than ${maxBytes} bytes`);
36
38
  }
37
39
  return buffer.toString("utf8", 0, length);
38
40
  } finally {
@@ -178,3 +180,82 @@ export function readMatrixFile(path) {
178
180
  if (columns.length < 2) throw new Error(`${path}: needs at least 2 numeric columns, one per variant`);
179
181
  return { matrix: table.rows.map((_, i) => columns.map((c) => c.values[i])), skipped };
180
182
  }
183
+
184
+ // check_leakage's columns as the caller's script wrote them: {"columns": {name: {"full": [...],
185
+ // "prefixes": [[...], ...]}}}, with "cuts" and "timestamps" if the script has them. Python's
186
+ // json.dump writes a missing value as NaN, which is not JSON, so NaN reads as null. Every problem
187
+ // is named by position; names leave this module only once every column has the right shape.
188
+ export function readColumnsFile(path) {
189
+ const text = readText(path, MAX_LARGE_FILE_BYTES).replace(/(?<=[[,:]\s*)NaN(?=\s*[,\]}])/g, "null");
190
+ let value;
191
+ try {
192
+ value = JSON.parse(text);
193
+ } catch {
194
+ throw new Error(`${path}: does not parse as JSON (numbers, null for a missing value)`);
195
+ }
196
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
197
+ if (!isObject(value) || !isObject(value.columns)) throw new Error(`${path}: must be a JSON object {"columns": {name: {"full": [...], "prefixes": [[...], ...]}}}`);
198
+ Object.entries(value.columns).forEach(([name, column], i) => {
199
+ if (name.length > 100) throw new Error(`${path}: column ${i + 1} has a name longer than 100 characters`);
200
+ if (!isObject(column) || !Array.isArray(column.full) || !Array.isArray(column.prefixes) || !column.prefixes.every(Array.isArray)) {
201
+ throw new Error(`${path}: column ${i + 1} is not {"full": [...], "prefixes": [[...], ...]}`);
202
+ }
203
+ [column.full, ...column.prefixes].forEach((run, r) => run.forEach((cell, t) => {
204
+ if (cell !== null && !(typeof cell === "number" && Number.isFinite(cell))) {
205
+ throw new Error(`${path}: column ${i + 1}, ${r === 0 ? "full" : `prefixes[${r - 1}]`}[${t}] is not a finite number or null`);
206
+ }
207
+ }));
208
+ });
209
+ if (value.cuts !== undefined && !(Array.isArray(value.cuts) && value.cuts.every(Number.isInteger))) throw new Error(`${path}: cuts must be a list of whole numbers`);
210
+ if (value.timestamps !== undefined && !(Array.isArray(value.timestamps) && value.timestamps.every((v) => typeof v === "string" || typeof v === "number"))) {
211
+ throw new Error(`${path}: timestamps must be a list of dates or numbers`);
212
+ }
213
+ return { columns: value.columns, cuts: value.cuts, timestamps: value.timestamps?.map(String) };
214
+ }
215
+
216
+ // A panel for placebo_test: every numeric column (one per asset) and, when the file has one, its
217
+ // ISO date column. A column with a gap is refused, naming its lines: the placebo reorders whole
218
+ // periods. Header names leave this module only into the placebo files written beside the data,
219
+ // never into an error message.
220
+ export function readPanelFile(path) {
221
+ const text = readText(path, MAX_LARGE_FILE_BYTES);
222
+ const table = parseTable(text, path);
223
+ if (!table.rows.length) throw new Error(`${path}: no data rows`);
224
+ const { columns, incomplete, skipped } = numericColumns(table);
225
+ if (incomplete.length) {
226
+ const c = incomplete[0];
227
+ throw new Error(`${path}: column ${c.index + 1} holds numbers but is empty or not a number on line${c.gaps.length > 1 ? "s" : ""} ${lineList(c.gaps)}; the placebo reorders whole periods, so fill or remove those rows`);
228
+ }
229
+ if (!columns.length) throw new Error(`${path}: no column holds only numbers`);
230
+ let dateColumn = null;
231
+ if (typeof table.rows[0][0] !== "number") {
232
+ for (let j = 0; j < table.rows[0].length && dateColumn === null; j += 1) {
233
+ if (table.rows.every((row) => ISO_DATE.test(String(row[j]).trim()))) dateColumn = j;
234
+ }
235
+ }
236
+ return {
237
+ columns: columns.map((c) => c.values),
238
+ positions: columns.map((c) => c.index + 1),
239
+ names: table.header ? columns.map((c) => table.header[c.index]) : null,
240
+ dates: dateColumn === null ? null : table.rows.map((row) => String(row[dateColumn]).trim()),
241
+ date_name: dateColumn !== null && table.header ? table.header[dateColumn] : null,
242
+ date_column: dateColumn === null ? null : dateColumn + 1,
243
+ skipped,
244
+ format: text.trim().startsWith("[") ? "json" : "csv",
245
+ };
246
+ }
247
+
248
+ // Writes a panel in the format it was read from. CSV: a header row when the source had one, then
249
+ // one row per period, its date first when there is one. JSON: a list of numbers for one column, a
250
+ // list of rows for several. Numbers keep every digit. An existing file is never overwritten.
251
+ export function writePanelFile(path, { columns, names, dates, dateName, format = "csv" }) {
252
+ if (format === "json") {
253
+ const body = columns.length === 1 ? columns[0] : columns[0].map((_, t) => columns.map((c) => c[t]));
254
+ writeFileSync(path, `${JSON.stringify(Array.from(body, (v) => (ArrayBuffer.isView(v) ? Array.from(v) : v)))}\n`, { flag: "wx" });
255
+ return;
256
+ }
257
+ const lines = [];
258
+ if (names) lines.push([...(dates ? [dateName ?? "date"] : []), ...names].join(","));
259
+ for (let t = 0; t < columns[0].length; t += 1) lines.push([...(dates ? [dates[t]] : []), ...columns.map((c) => String(c[t]))].join(","));
260
+ writeFileSync(path, `${lines.join("\n")}\n`, { flag: "wx" });
261
+ }
package/src/server.mjs CHANGED
@@ -22,14 +22,14 @@ import { bestOfTrialsProbability } from "./local/js/luck-core.js";
22
22
  import { minimumBacktestLength } from "./local/js/dsr-core.js";
23
23
  import { studentTUpper } from "./local/js/student-t.js";
24
24
  import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
25
- import { breadthInput, trackRecordInput, auditBacktestInput, verifyReceiptToolShape, backtestLengthInput, haircutSharpeInput, luckTrialsInput, auditBacktestToolShape, companyHistoryInput, companyHistoryToolShape, deflatedSharpeInput, deflatedSharpeToolShape, emptyInput, getKeyInput, getReceiptInput, overfittingInput, realityCheckInput, realityCheckToolShape, LIMITS_SENTENCES, paperEvidenceInput, paperEvidenceToolShape, TOOL_DESCRIPTIONS, validationOutput, auditOutput, keyOutput, receiptOutput, verifyReceiptOutput, statusOutput, companyHistoryOutput } from "./schemas.mjs";
25
+ import { breadthInput, trackRecordInput, auditBacktestInput, verifyReceiptToolShape, backtestLengthInput, haircutSharpeInput, luckTrialsInput, auditBacktestToolShape, companyHistoryInput, companyHistoryToolShape, deflatedSharpeInput, deflatedSharpeToolShape, emptyInput, getKeyInput, getReceiptInput, overfittingInput, realityCheckInput, realityCheckToolShape, LIMITS_SENTENCES, paperEvidenceInput, paperEvidenceToolShape, TOOL_DESCRIPTIONS, validationOutput, auditOutput, keyOutput, receiptOutput, verifyReceiptOutput, statusOutput, companyHistoryOutput, listedSchema } from "./schemas.mjs";
26
26
 
27
27
  export const DEFAULT_BASE = "https://canlicapital.com";
28
28
  export const SERVER_NAME = "canlicapital-validation-mcp";
29
29
  export const SERVER_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
30
30
  // Sent once in initialize; clients such as Claude Code put it in the system prompt, so the model
31
31
  // knows the first call to make even when tool definitions are deferred. Byte-stable across runs.
32
- export const SERVER_INSTRUCTIONS = "Checks whether a backtest's result is real. For one strategy's returns, call audit_backtest, pointing returns_file at the backtest's CSV instead of pasting long series. For a Sharpe found by a search, validate_deflated_sharpe needs how many independent variants were tried and how their Sharpes spread; with every variant's returns, validate_overfitting gives the probability of backtest overfitting. To test a rule on prices, backtest_strategy runs a whole parameter grid and validates the best with the count actually run; summarize_series reads a long series in a hundred words; stress_test and check_feasibility ask whether it survives bad markets and a real broker. Set CANLI_LOCAL=1 to compute on this machine with no network. Every result says what it does not establish; quote those limits with the number.";
32
+ export const SERVER_INSTRUCTIONS = "Checks whether a backtest's result is real. For one strategy's returns, call audit_backtest, pointing returns_file at the backtest's CSV instead of pasting long series. For a Sharpe found by a search, validate_deflated_sharpe needs how many independent variants were tried and how their Sharpes spread; with every variant's returns, validate_overfitting gives the probability of backtest overfitting. To test a rule on prices, backtest_strategy runs a whole parameter grid and validates the best with the count actually run; summarize_series reads a long series in a hundred words; stress_test and check_feasibility ask whether it survives bad markets and a real broker. Before trusting a research pipeline, check_leakage finds lookahead in its signals and placebo_test runs it on data where nothing can be predicted. Set CANLI_LOCAL=1 to compute on this machine with no network. Every result says what it does not establish; quote those limits with the number.";
33
33
 
34
34
  // How the server introduces itself in initialize: a readable title, the page that documents it
35
35
  // and its icon, so clients and directories that read serverInfo show more than a package name.
@@ -881,7 +881,11 @@ export function registerResources(server) {
881
881
 
882
882
  // Everything the server exposes: the npm package and the hosted endpoint both call this.
883
883
  export function registerAll(server, session) {
884
- const catalog = registerTools(server, session);
884
+ // The SDK lists the schemas it is given, so the server registers lean ones (listedSchema), while
885
+ // registerTools itself hands out the zod schemas: the website's MCP pages read those to check
886
+ // their example arguments.
887
+ const listing = { registerTool: (name, config, handler) => server.registerTool(name, { ...config, inputSchema: listedSchema(config.inputSchema), outputSchema: listedSchema(config.outputSchema) }, handler) };
888
+ const catalog = registerTools(listing, session);
885
889
  registerPrompts(server);
886
890
  registerLabPrompts(server);
887
891
  registerResources(server);