webmcp-gauge 0.1.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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +121 -0
  3. package/action.yml +162 -0
  4. package/bin/webmcp-gauge.mjs +544 -0
  5. package/bin/webmcp-gauge.test.mjs +354 -0
  6. package/browser/launch.mjs +188 -0
  7. package/browser/serve.mjs +78 -0
  8. package/browser/session.mjs +210 -0
  9. package/browser/webmcp.mjs +432 -0
  10. package/browser/webmcp.test.mjs +299 -0
  11. package/core/args.mjs +93 -0
  12. package/core/args.test.mjs +85 -0
  13. package/core/capture-seam.test.mjs +86 -0
  14. package/core/cohort.mjs +432 -0
  15. package/core/cohort.test.mjs +370 -0
  16. package/core/gallery.mjs +145 -0
  17. package/core/gallery.test.mjs +128 -0
  18. package/core/gate.mjs +164 -0
  19. package/core/gate.test.mjs +213 -0
  20. package/core/lint.mjs +381 -0
  21. package/core/lint.test.mjs +346 -0
  22. package/core/orchestrate.mjs +128 -0
  23. package/core/orchestrate.test.mjs +191 -0
  24. package/core/stats.mjs +172 -0
  25. package/core/stats.test.mjs +156 -0
  26. package/core/sweep.mjs +274 -0
  27. package/core/sweep.test.mjs +162 -0
  28. package/core/taxonomy.mjs +175 -0
  29. package/core/taxonomy.test.mjs +198 -0
  30. package/core/trial.mjs +248 -0
  31. package/core/visibility.mjs +163 -0
  32. package/core/visibility.test.mjs +164 -0
  33. package/docs/concept.md +468 -0
  34. package/docs/explainer.md +161 -0
  35. package/docs/getting-started.md +331 -0
  36. package/fixtures/README.md +42 -0
  37. package/fixtures/airlock.utterances.json +284 -0
  38. package/fixtures/broken/compose.mjs +52 -0
  39. package/fixtures/broken/compose.test.mjs +270 -0
  40. package/fixtures/broken/sample-expenses.csv +966 -0
  41. package/fixtures/broken/tools.json +1311 -0
  42. package/fixtures/broken/twin.html +482 -0
  43. package/fixtures/broken/widget.html +62 -0
  44. package/fixtures/gallery/gallery.html +56 -0
  45. package/judges/openai-compatible.mjs +145 -0
  46. package/package.json +53 -0
  47. package/report/badge.mjs +110 -0
  48. package/report/badge.test.mjs +97 -0
  49. package/report/emit.mjs +282 -0
  50. package/report/published-runs.test.mjs +77 -0
  51. package/report/scorecard.mjs +157 -0
  52. package/report/scorecard.test.mjs +130 -0
@@ -0,0 +1,1311 @@
1
+ {
2
+ "version": "1.6.0",
3
+ "frozen": true,
4
+ "frozenOn": "2026-08-30",
5
+ "purpose": "Tool metadata for the degraded twin of the reference page. Several variants over one identical implementation and one identical dataset, so the only difference between them is what the manifest says about the tools. That is what makes a rate difference attributable to description and schema quality rather than to the app.",
6
+ "revisions": [
7
+ {
8
+ "version": "1.0.0",
9
+ "date": "2026-08-30",
10
+ "change": "First frozen version: the 'clean' and 'degraded' variants, plus the defect register with a prediction per injected defect.",
11
+ "results": "reports/discrimination-2026-08-30.md - clean 100.0% (140/140), degraded 80.0% (112/140), one session per arm."
12
+ },
13
+ {
14
+ "version": "1.1.0",
15
+ "date": "2026-08-30",
16
+ "change": "Added four ablation variants, each the clean manifest plus exactly one defect family: ablate-near-duplicate, ablate-duplicate-tool, ablate-thin, ablate-schema. The 'clean' and 'degraded' variants are byte-identical to 1.0.0, so the 1.0.0 numbers remain comparable.",
17
+ "reason": "The first sweep bundled two defects on sum_by_category - a near-duplicate description and an identically-described competitor tool - and the competitor absorbed 10 of its 20 trials, so the near-duplicate's own contribution was unmeasured. The ablations exist to separate them, and to test the two predictions the first run falsified: that a near-duplicate description alone hurts, and that a one-word description alone hurts."
18
+ },
19
+ {
20
+ "version": "1.2.0",
21
+ "date": "2026-09-05",
22
+ "change": "Added one interaction variant, ablate-pair: the degraded manifest's own entries for sum_by_category, filter_rows and summarise_by_category lifted onto the clean page, and nothing else. It is the first variant that is not one defect family, and the first that is declared as a strict subset of another variant ('subsetOf'), with a test asserting byte-identity against it and that it cannot lint worse than it. Every earlier variant is byte-identical to 1.1.0, so every published number remains comparable.",
23
+ "reason": "A cold read of the public report on 2026-09-05 found that the published compounding table's 'both together' row is the whole degraded manifest rather than a two-defect arm, so the -35.0 bundles the pair's interaction with a degraded neighbourhood. Composing this arm then found something sharper, recorded in the variant's 'composedFirstAs' field: the published 'competitor alone' arm gives the competitor the *reference* description while the degraded manifest gives it the *vague* one, and the degraded arm also thins sum_by_category's argument description. The three rows of that table do not nest, so 'the parts sum to -8.3' was never a parts-versus-whole comparison. This variant makes one true comparison possible: same tool entries as degraded, clean neighbourhood."
24
+ },
25
+ {
26
+ "version": "1.3.0",
27
+ "date": "2026-09-05",
28
+ "change": "Added the two halves of ablate-pair as their own variants, so the pair can be decomposed against the same tool entries for the first time: ablate-desc-degraded (degraded's sum_by_category and filter_rows, no competitor) and ablate-competitor-vague (degraded's competitor added, both descriptions left at reference quality). Both are strict subsets of 'degraded' and of ablate-pair, and every earlier variant is byte-identical to 1.2.0.",
29
+ "reason": "ablate-pair measured -46.7 on sum_by_category and closed the neighbourhood question, but it left the pair as a joint effect: the published single-defect arms cannot serve as its terms, because ablate-near-duplicate leaves the argument description intact and ablate-duplicate-tool's competitor carries the reference description rather than the vague one. Decomposing a whole into parts measured on different manifests is the mistake 1.2.0 was written to record; these two arms are the parts measured on the same manifest. The competitor arm also probes a suspected rule gap: the linter reports nothing in the description family for a page that adds a vaguely-described competitor beside a well-described tool."
30
+ },
31
+ {
32
+ "version": "1.4.0",
33
+ "date": "2026-09-05",
34
+ "change": "Added ablate-pair-paraphrased: ablate-pair with exactly one field changed, the competitor's description, from a byte-identical copy of the vague text to a plain paraphrase of it. First variant declared with 'variantOf' plus 'differsBy' rather than 'subsetOf', because it is not a subset of anything - it is a one-field contrast against another arm, and compose.test.mjs asserts that it differs from its parent in precisely the declared fields and nothing else. Every earlier variant is byte-identical to 1.3.0.",
35
+ "reason": "The decomposition established that neither defect costs anything alone and the pair costs -46.7, and the mechanism recorded for it is that the judge, unable to tell two identical descriptions apart, decided on the tool name. That mechanism does not obviously need the descriptions to be *identical* - only to be equally uninformative. This arm separates the two readings, and it is aimed squarely at a claim this project published the same day: that the linter's warning-versus-error grading was vindicated. By the linter's own similarity measure the paraphrase scores 0.529 against a 0.70 threshold, so the linter says nothing whatsoever about the competitor here. If the rate still collapses, that grading claim was too strong and both description rules are missing the harm."
36
+ },
37
+ {
38
+ "version": "1.5.0",
39
+ "date": "2026-09-05",
40
+ "change": "Added three more rungs of the same one-field contrast, so the competitor's description sweeps a similarity range instead of sitting at two points: ablate-pair-sim086 (0.857, above the linter's threshold), ablate-pair-sim013 (0.130) and ablate-pair-sim004 (0.043, effectively disjoint vocabulary). With ablate-pair (1.000) and ablate-pair-paraphrased (0.529) that is a five-rung ladder. Each rung carries a `ladder` block naming its measured similarity, and every rung is byte-identical to ablate-pair except the one declared field. Every earlier variant is byte-identical to 1.4.0.",
41
+ "reason": "Item 22 asks what the linter should do about a harm it cannot see, and option (a) is to lower `nearDuplicateThreshold`. That option is only worth its false positives if description similarity separates the harmful case from the harmless one at *some* value. The ladder tests exactly that, and rung 5 decides it: if two descriptions sharing almost no vocabulary still cost 45 points, no threshold on word overlap can work and (a) is dead on the evidence rather than on taste. Every rung is written to be as uninformative as the original - none of them says which tool serves the request - because the variable under test is lexical distance, not informativeness. The honest limit of that design is recorded with the result: the ladder cannot hold similarity-to-the-*utterance* perfectly fixed while it varies similarity-to-the-sibling."
42
+ },
43
+ {
44
+ "version": "1.6.0",
45
+ "date": "2026-09-05",
46
+ "change": "No variant changed. Every manifest is byte-identical to 1.5.0, so every published number stays comparable. What changed is the declared family list on the five interaction arms: core/lint.mjs gained description/indistinguishable-pair, the arms now trip it, and compose.test.mjs asserts that an arm declares exactly the rules it fires.",
47
+ "reason": "The rule was adopted by the maintainer on 2026-09-05 after probes/name-proxy-rule.mjs measured it against all thirteen manifests here - it fires on all five collapsed rungs and stays silent on every manifest that cost nothing, and the live reference page still lints 0/0. A declaration that did not catch up would have made the isolation test fail for the right reason, which is how this edit was found."
48
+ }
49
+ ],
50
+ "authoring": {
51
+ "writtenBy": "Assistant draft inside a Qwen Code session, 2026-08-30.",
52
+ "note": "The 'clean' variant copies the reference page's descriptions and schemas verbatim from airlock/src/tools.ts (surveyed 2026-08-30). The 'degraded' variant is written to embody documented WebMCP failure modes, not to hit a target number, and every injected defect is registered below with the prediction it was written to test - recorded before the sweep ran.",
53
+ "correction": "2026-09-05: 'verbatim' in the note above overstates it, and the note is left as written rather than edited. reports/ablation-2026-08-31.md is the accurate statement - the clean variant's descriptions are the reference ones *minus their privacy sentences*, so the clean twin is a close correspondence to the live page rather than an identity. Anything comparing a twin rate against a live-page rate has to carry that."
54
+ },
55
+ "dataset": {
56
+ "file": "sample-expenses.csv",
57
+ "sha256": "b737acfa7f3b815ee3451d1dde1210e5053f1f0a992643c9fb2f1cba10a11c09",
58
+ "provenance": "Byte-identical copy of airlock/public/sample-expenses.csv, taken 2026-08-30. Same 965 rows, same 12 categories, same 2025-09-01..2026-08-31 range, so fixtures/airlock.utterances.json 1.3.0 remains answerable without a single edit."
59
+ },
60
+ "defects": [
61
+ {
62
+ "tool": "describe_dataset",
63
+ "injected": "none - description and schema copied verbatim from the reference page",
64
+ "rules": [],
65
+ "prediction": "Stays at the ceiling. This is the in-page control: if it drops, the twin's implementation or the serving environment is the cause, not the metadata."
66
+ },
67
+ {
68
+ "tool": "monthly_trend",
69
+ "injected": "none - description and schema copied verbatim from the reference page",
70
+ "rules": [],
71
+ "prediction": "Stays at the ceiling. Second in-page control."
72
+ },
73
+ {
74
+ "tool": "sum_by_category",
75
+ "injected": "Description degraded to a near-duplicate of filter_rows', and an extra tool (summarise_by_category) registered with a byte-identical description",
76
+ "rules": [
77
+ "description/near-duplicate",
78
+ "description/duplicate"
79
+ ],
80
+ "prediction": "Materially lower, with the loss landing in wrong_tool - split between filter_rows and summarise_by_category - rather than not_selected."
81
+ },
82
+ {
83
+ "tool": "filter_rows",
84
+ "injected": "Description degraded to a near-duplicate of sum_by_category's; schema left untouched so argument expectations stay satisfiable",
85
+ "rules": [
86
+ "description/near-duplicate"
87
+ ],
88
+ "prediction": "Materially lower, loss in wrong_tool towards sum_by_category."
89
+ },
90
+ {
91
+ "tool": "find_anomalies",
92
+ "injected": "Description replaced with a generic one that names no operation",
93
+ "rules": [
94
+ "description/thin"
95
+ ],
96
+ "prediction": "Lower, mostly wrong_tool; oblique phrasings should suffer most because nothing in the description reaches the intent."
97
+ },
98
+ {
99
+ "tool": "top_expenses",
100
+ "injected": "Schema over-parameterised to nine properties, three of them required and undocumented (mode, format, strategy), and the handler throws when any of the three is missing. Description kept informative so the defect isolates the schema",
101
+ "rules": [
102
+ "schema/over-parameterised",
103
+ "schema/required-without-description"
104
+ ],
105
+ "prediction": "Selected correctly but executed wrongly: exec_error where the judge passes only what the utterance justifies, or bad_args where it invents values the utterance forbids. If it instead invents the three values and scores ok, that is a blind spot in the metric and the finding is about the harness, not the page."
106
+ },
107
+ {
108
+ "tool": "clear_highlights",
109
+ "injected": "Description replaced with a single word",
110
+ "rules": [
111
+ "description/thin"
112
+ ],
113
+ "prediction": "Lower. Its utterances are already the most oblique in the set ('clear that'), so a description carrying no information should hurt it badly."
114
+ },
115
+ {
116
+ "tool": "summarise_by_category",
117
+ "injected": "Extra tool: byte-identical description to sum_by_category, one untyped and undocumented property",
118
+ "rules": [
119
+ "description/duplicate",
120
+ "schema/missing-type",
121
+ "schema/undocumented-property"
122
+ ],
123
+ "prediction": "Absorbs some sum_by_category selections. Never correct for any utterance in the set, so every selection of it is scored wrong_tool."
124
+ },
125
+ {
126
+ "tool": "Clear Highlights",
127
+ "injected": "Extra tool: a name containing spaces, and an empty description",
128
+ "rules": [
129
+ "name/invalid-characters",
130
+ "description/missing"
131
+ ],
132
+ "prediction": "Spec issue #145 reports that a tool with a space in its name silently does nothing. Whether Chrome 152 registers it at all is a compatibility measurement this fixture takes: if getTools() omits it, the linter cannot see it from a live manifest and the rule earns its keep only on static input."
133
+ },
134
+ {
135
+ "tool": "top.expenses.v2",
136
+ "injected": "Extra tool: dotted name, a deprecation notice for a description, and an inputSchema that is not an object schema",
137
+ "rules": [
138
+ "name/invalid-characters",
139
+ "schema/not-object"
140
+ ],
141
+ "prediction": "Leftover-deprecated-tool shape. Should not be selected often; any selection is scored wrong_tool."
142
+ },
143
+ {
144
+ "scope": "page",
145
+ "injected": "?flood=N registers N filler tools alongside the real ones",
146
+ "rules": [
147
+ "budget/headroom"
148
+ ],
149
+ "prediction": "Linter-only. Never used for the measured sweep, because a tool-count effect would confound the description effect the sweep exists to isolate."
150
+ }
151
+ ],
152
+ "ablations": {
153
+ "note": "Each ablation is the clean manifest plus exactly one defect family, so a rate can be attributed to that family alone. It is composed at load time from 'clean' plus the patch in 'tools' by compose.mjs, which the page and the tests share - writing each variant out in full would make 'nothing else moved' a promise; composing it makes divergence impossible. 'changes' and 'adds' declare the intended difference and a test checks the composition against them. 'measure' is the --tools list worth spending trials on; the untouched tools already sit at the ceiling in both 1.0.0 arms.",
154
+ "ablate-near-duplicate": {
155
+ "family": "description/near-duplicate",
156
+ "basedOn": "clean",
157
+ "changes": [
158
+ "sum_by_category",
159
+ "filter_rows"
160
+ ],
161
+ "adds": [],
162
+ "measure": [
163
+ "sum_by_category",
164
+ "filter_rows"
165
+ ],
166
+ "question": "Does a near-duplicate description hurt on its own, with no competitor tool to absorb the request?",
167
+ "prediction": "Falsified once already: filter_rows held at 100% in the 1.0.0 degraded arm with this exact description. Expect both tools to stay at or near the ceiling, which would mean vocabulary overlap alone is not a defect the metric can see - and that the linter's near-duplicate rule is advisory for a reason.",
168
+ "tools": [
169
+ {
170
+ "name": "sum_by_category",
171
+ "impl": "sumByCategory",
172
+ "description": "Works with the rows in the table and returns totals for what it finds.",
173
+ "inputSchema": {
174
+ "type": "object",
175
+ "properties": {
176
+ "highlight": {
177
+ "type": "string",
178
+ "description": "Exact category name to highlight in the table. Must match a category returned by describe_dataset."
179
+ }
180
+ }
181
+ },
182
+ "annotations": {
183
+ "readOnlyHint": true
184
+ }
185
+ },
186
+ {
187
+ "name": "filter_rows",
188
+ "impl": "filterRows",
189
+ "description": "Works with the rows in the table and returns counts for what it finds.",
190
+ "inputSchema": {
191
+ "type": "object",
192
+ "properties": {
193
+ "from": {
194
+ "type": "string",
195
+ "description": "Earliest date to include, as YYYY-MM-DD."
196
+ },
197
+ "to": {
198
+ "type": "string",
199
+ "description": "Latest date to include, as YYYY-MM-DD."
200
+ },
201
+ "category": {
202
+ "type": "string",
203
+ "description": "Restrict to one category name."
204
+ },
205
+ "min_amount": {
206
+ "type": "number",
207
+ "description": "Smallest amount to include."
208
+ },
209
+ "max_amount": {
210
+ "type": "number",
211
+ "description": "Largest amount to include."
212
+ },
213
+ "limit": {
214
+ "type": "number",
215
+ "description": "How many matching rows to preview. Defaults to 10, capped at 25."
216
+ }
217
+ }
218
+ },
219
+ "annotations": {
220
+ "readOnlyHint": true
221
+ }
222
+ }
223
+ ]
224
+ },
225
+ "ablate-duplicate-tool": {
226
+ "family": "description/duplicate",
227
+ "basedOn": "clean",
228
+ "changes": [],
229
+ "adds": [
230
+ "summarise_by_category"
231
+ ],
232
+ "measure": [
233
+ "sum_by_category"
234
+ ],
235
+ "question": "How much of sum_by_category's collapse to 35% was the competitor rather than the vague description?",
236
+ "prediction": "Most of it. Expect sum_by_category well below the ceiling even with its reference description intact, because an identically-described tool sits beside it.",
237
+ "tools": [
238
+ {
239
+ "name": "summarise_by_category",
240
+ "impl": "sumByCategory",
241
+ "description": "Total the loaded spending by category, largest first. Optionally pass a category name to visually highlight its rows in the table for the human reading the page. Returns aggregates only.",
242
+ "inputSchema": {
243
+ "type": "object",
244
+ "properties": {
245
+ "highlight": {
246
+ "type": "string",
247
+ "description": "Exact category name to highlight in the table. Must match a category returned by describe_dataset."
248
+ }
249
+ }
250
+ },
251
+ "annotations": {
252
+ "readOnlyHint": true
253
+ }
254
+ }
255
+ ]
256
+ },
257
+ "ablate-thin": {
258
+ "family": "description/thin",
259
+ "basedOn": "clean",
260
+ "changes": [
261
+ "find_anomalies",
262
+ "clear_highlights"
263
+ ],
264
+ "adds": [],
265
+ "measure": [
266
+ "find_anomalies",
267
+ "clear_highlights"
268
+ ],
269
+ "question": "Does a description carrying no information hurt when nothing else can serve the request?",
270
+ "prediction": "find_anomalies lower (it competes with describe_dataset for 'what stands out'), clear_highlights at the ceiling (nothing else clears highlighting). If that holds, a thin description is only a defect where a competitor exists, and the same rule should read differently for a tool with no substitute.",
271
+ "tools": [
272
+ {
273
+ "name": "find_anomalies",
274
+ "impl": "findAnomalies",
275
+ "description": "Processes the table and gives numbers back.",
276
+ "inputSchema": {
277
+ "type": "object",
278
+ "properties": {
279
+ "threshold": {
280
+ "type": "number",
281
+ "description": "How many standard deviations above the category mean counts as unusual. Defaults to 2.5; raise it to be stricter, or lower it to surface milder variation."
282
+ }
283
+ }
284
+ },
285
+ "annotations": {
286
+ "readOnlyHint": true
287
+ }
288
+ },
289
+ {
290
+ "name": "clear_highlights",
291
+ "impl": "clearHighlights",
292
+ "description": "Utility.",
293
+ "inputSchema": {
294
+ "type": "object",
295
+ "properties": {}
296
+ },
297
+ "annotations": {
298
+ "readOnlyHint": false
299
+ }
300
+ }
301
+ ]
302
+ },
303
+ "ablate-schema": {
304
+ "family": "schema/over-parameterised",
305
+ "basedOn": "clean",
306
+ "changes": [
307
+ "top_expenses"
308
+ ],
309
+ "adds": [],
310
+ "measure": [
311
+ "top_expenses"
312
+ ],
313
+ "question": "Is the schema alone enough to produce the avoid/throw/decline spread seen at 1.0.0, with the description untouched?",
314
+ "prediction": "Yes - the description was already the reference one in the degraded arm, so this should reproduce 35% with exec_error present. This ablation is the control on the other three: it should change nothing.",
315
+ "tools": [
316
+ {
317
+ "name": "top_expenses",
318
+ "impl": "topExpenses",
319
+ "throwWhenMissing": [
320
+ "mode",
321
+ "format",
322
+ "strategy"
323
+ ],
324
+ "description": "Find the largest individual rows by amount and highlight them in the table.",
325
+ "inputSchema": {
326
+ "type": "object",
327
+ "required": [
328
+ "mode",
329
+ "format",
330
+ "strategy"
331
+ ],
332
+ "properties": {
333
+ "limit": {
334
+ "type": "number",
335
+ "description": "How many rows to consider. Defaults to 5, capped at 25."
336
+ },
337
+ "mode": {
338
+ "type": "string"
339
+ },
340
+ "format": {
341
+ "type": "string"
342
+ },
343
+ "strategy": {
344
+ "type": "string"
345
+ },
346
+ "locale": {
347
+ "type": "string"
348
+ },
349
+ "page": {
350
+ "type": "number"
351
+ },
352
+ "page_size": {
353
+ "type": "number"
354
+ },
355
+ "include_totals": {
356
+ "type": "boolean"
357
+ },
358
+ "verbose": {
359
+ "type": "boolean"
360
+ }
361
+ }
362
+ },
363
+ "annotations": {
364
+ "readOnlyHint": true
365
+ }
366
+ }
367
+ ]
368
+ },
369
+ "ablate-pair": {
370
+ "families": [
371
+ "description/near-duplicate",
372
+ "description/duplicate",
373
+ "schema/missing-type",
374
+ "schema/undocumented-property",
375
+ "description/indistinguishable-pair"
376
+ ],
377
+ "subsetOf": "degraded",
378
+ "subsetTools": [
379
+ "sum_by_category",
380
+ "filter_rows",
381
+ "summarise_by_category"
382
+ ],
383
+ "basedOn": "clean",
384
+ "changes": [
385
+ "sum_by_category",
386
+ "filter_rows"
387
+ ],
388
+ "adds": [
389
+ "summarise_by_category"
390
+ ],
391
+ "measure": [
392
+ "sum_by_category",
393
+ "filter_rows"
394
+ ],
395
+ "question": "Does the -35.0 compounding result need a degraded neighbourhood? 'Both together' in the published table is not a two-defect arm - it is the whole degraded manifest, where every other tool carries its own defect too. This arm lifts the degraded manifest's own entries for sum_by_category, filter_rows and summarise_by_category onto an otherwise clean page and changes nothing else, so it is a strict subset of the degraded variant and the neighbourhood is the only difference between them.",
396
+ "composedFirstAs": "The first attempt composed this as the union of ablate-near-duplicate's patch and ablate-duplicate-tool's patch, and the linter refused it: description/duplicate did not fire. That is a finding, not a wiring problem. The published 'competitor alone' arm gives summarise_by_category the *reference* description, so it duplicates a good one; the degraded manifest gives it the *vague* description, so it duplicates a bad one. The degraded arm also thins sum_by_category's argument description, which no ablation did. The three rows of the published compounding table therefore do not nest, and 'the parts sum to -8.3' compares arms that are not subsets of the whole. Recorded 2026-09-05 before this arm ran.",
397
+ "prediction": "sum_by_category lands near the degraded arm's 60.0% - call it 55 to 70 - and nowhere near the single-defect arms' 90.0% and 91.7%, so the interaction is real and does not need the neighbourhood. This is not a hunch: the degraded arm's own per-trial records say where its 24 sum_by_category failures went, and 20 of them chose summarise_by_category while only 4 chose describe_dataset. The competitor is inside this arm; the magnet is a clean, untouched tool that is inside it too. So at most about 4 trials in 60 are attributable to anything this arm removes. Two byte-identical descriptions cannot be told apart, which is why roughly half of the affected utterances should go to the wrong twin. Called wrong if it lands above 80%, which would mean the collapse needed the thin descriptions, the over-parameterised schema and the rejected names after all - and then both the report and ablation-2026-08-31 have to say so. filter_rows should hold near its near-duplicate-arm 95.0% and its degraded-arm 98.3%; a drop below that would be a neighbourhood effect from a neighbourhood of one added tool.",
398
+ "registered": "2026-09-05, before the arm ran. Item 21 in PROJECT-LOG.md, which asked for this arm, landed in commit fc5a62f; this entry and its prediction are committed on their own before any trial was measured, so the ordering is checkable in history rather than asserted.",
399
+ "tools": [
400
+ {
401
+ "name": "sum_by_category",
402
+ "impl": "sumByCategory",
403
+ "description": "Works with the rows in the table and returns totals for what it finds.",
404
+ "inputSchema": {
405
+ "type": "object",
406
+ "properties": {
407
+ "highlight": {
408
+ "type": "string",
409
+ "description": "A category name."
410
+ }
411
+ }
412
+ },
413
+ "annotations": {
414
+ "readOnlyHint": true
415
+ }
416
+ },
417
+ {
418
+ "name": "filter_rows",
419
+ "impl": "filterRows",
420
+ "description": "Works with the rows in the table and returns counts for what it finds.",
421
+ "inputSchema": {
422
+ "type": "object",
423
+ "properties": {
424
+ "from": {
425
+ "type": "string",
426
+ "description": "Earliest date to include, as YYYY-MM-DD."
427
+ },
428
+ "to": {
429
+ "type": "string",
430
+ "description": "Latest date to include, as YYYY-MM-DD."
431
+ },
432
+ "category": {
433
+ "type": "string",
434
+ "description": "Restrict to one category name."
435
+ },
436
+ "min_amount": {
437
+ "type": "number",
438
+ "description": "Smallest amount to include."
439
+ },
440
+ "max_amount": {
441
+ "type": "number",
442
+ "description": "Largest amount to include."
443
+ },
444
+ "limit": {
445
+ "type": "number",
446
+ "description": "How many matching rows to preview. Defaults to 10, capped at 25."
447
+ }
448
+ }
449
+ },
450
+ "annotations": {
451
+ "readOnlyHint": true
452
+ }
453
+ },
454
+ {
455
+ "name": "summarise_by_category",
456
+ "impl": "sumByCategory",
457
+ "description": "Works with the rows in the table and returns totals for what it finds.",
458
+ "inputSchema": {
459
+ "type": "object",
460
+ "properties": {
461
+ "cat": {}
462
+ }
463
+ },
464
+ "annotations": {
465
+ "readOnlyHint": true
466
+ }
467
+ }
468
+ ]
469
+ },
470
+ "ablate-desc-degraded": {
471
+ "families": [
472
+ "description/near-duplicate"
473
+ ],
474
+ "subsetOf": "degraded",
475
+ "subsetTools": [
476
+ "sum_by_category",
477
+ "filter_rows"
478
+ ],
479
+ "basedOn": "clean",
480
+ "changes": [
481
+ "sum_by_category",
482
+ "filter_rows"
483
+ ],
484
+ "adds": [],
485
+ "measure": [
486
+ "sum_by_category",
487
+ "filter_rows"
488
+ ],
489
+ "question": "Half of the pair, against the same competitor definition: what do the degraded descriptions cost with no competitor tool present? ablate-near-duplicate already asked this, but with its own hand-written entries - it left sum_by_category's argument description intact, where the degraded manifest thins it to 'A category name.'. Using 90.0% as the 'descriptions alone' term inside a decomposition of ablate-pair would be the exact mistake this file already recorded once, so the term is measured against degraded's own entries instead.",
490
+ "prediction": "sum_by_category near ablate-near-duplicate's 90.0% - call it 82 to 95 - because the one difference from that arm is a thinner *argument* description, and argument quality has never moved selection here: bad_args has been observed once in 4,320 trials. Falsified below 70, which would mean an argument description the model never has to fill in changes which tool it picks. filter_rows near its 95.0% in that arm. This is the arm that should be boring; it exists so the decomposition has a legitimate first term.",
491
+ "registered": "2026-09-05, before the arm ran, committed with ablate-competitor-vague and before either measured a trial.",
492
+ "tools": [
493
+ {
494
+ "name": "sum_by_category",
495
+ "impl": "sumByCategory",
496
+ "description": "Works with the rows in the table and returns totals for what it finds.",
497
+ "inputSchema": {
498
+ "type": "object",
499
+ "properties": {
500
+ "highlight": {
501
+ "type": "string",
502
+ "description": "A category name."
503
+ }
504
+ }
505
+ },
506
+ "annotations": {
507
+ "readOnlyHint": true
508
+ }
509
+ },
510
+ {
511
+ "name": "filter_rows",
512
+ "impl": "filterRows",
513
+ "description": "Works with the rows in the table and returns counts for what it finds.",
514
+ "inputSchema": {
515
+ "type": "object",
516
+ "properties": {
517
+ "from": {
518
+ "type": "string",
519
+ "description": "Earliest date to include, as YYYY-MM-DD."
520
+ },
521
+ "to": {
522
+ "type": "string",
523
+ "description": "Latest date to include, as YYYY-MM-DD."
524
+ },
525
+ "category": {
526
+ "type": "string",
527
+ "description": "Restrict to one category name."
528
+ },
529
+ "min_amount": {
530
+ "type": "number",
531
+ "description": "Smallest amount to include."
532
+ },
533
+ "max_amount": {
534
+ "type": "number",
535
+ "description": "Largest amount to include."
536
+ },
537
+ "limit": {
538
+ "type": "number",
539
+ "description": "How many matching rows to preview. Defaults to 10, capped at 25."
540
+ }
541
+ }
542
+ },
543
+ "annotations": {
544
+ "readOnlyHint": true
545
+ }
546
+ }
547
+ ]
548
+ },
549
+ "ablate-competitor-vague": {
550
+ "families": [
551
+ "schema/undocumented-property",
552
+ "schema/missing-type"
553
+ ],
554
+ "subsetOf": "degraded",
555
+ "subsetTools": [
556
+ "summarise_by_category"
557
+ ],
558
+ "basedOn": "clean",
559
+ "changes": [],
560
+ "adds": [
561
+ "summarise_by_category"
562
+ ],
563
+ "measure": [
564
+ "sum_by_category"
565
+ ],
566
+ "question": "The other half: what does the degraded manifest's competitor cost on its own, while sum_by_category keeps its reference description? ablate-duplicate-tool answered a different question - its competitor carries the *reference* description, so the two tools are exact twins. Here the competitor is the vaguer of the two, so the correct tool is the better-described match and should win on the text alone. Note what the linter says about this manifest: **nothing in the description family**. It reports only an undocumented, untyped property on the new tool. If this arm costs real accuracy, that is a rule gap, and the gap is that no rule fires when a page adds a vaguely-described tool that competes with a well-described one.",
567
+ "prediction": "sum_by_category at or near the ceiling - call it 90 to 100, so at least as good as ablate-duplicate-tool's 91.7% where the competitor was an exact twin - because its own description still says what it does and the competitor's does not. The mechanism that argues against this is the one the pair arm's smoke trial recorded: with descriptions indistinguishable the judge chose on the *name*, and 'summarise_by_category' is arguably the better name for 'break my spending down by category'. So if this arm lands below 85, the damage is coming from the name rather than from the description, which would be a finding about names and not about descriptions, and the linter has no rule for it either way. Falsified below 85.",
568
+ "registered": "2026-09-05, before the arm ran, committed with ablate-desc-degraded and before either measured a trial.",
569
+ "tools": [
570
+ {
571
+ "name": "summarise_by_category",
572
+ "impl": "sumByCategory",
573
+ "description": "Works with the rows in the table and returns totals for what it finds.",
574
+ "inputSchema": {
575
+ "type": "object",
576
+ "properties": {
577
+ "cat": {}
578
+ }
579
+ },
580
+ "annotations": {
581
+ "readOnlyHint": true
582
+ }
583
+ }
584
+ ]
585
+ },
586
+ "ablate-pair-paraphrased": {
587
+ "families": [
588
+ "description/near-duplicate",
589
+ "schema/undocumented-property",
590
+ "schema/missing-type",
591
+ "description/indistinguishable-pair"
592
+ ],
593
+ "variantOf": "ablate-pair",
594
+ "differsBy": [
595
+ "summarise_by_category.description"
596
+ ],
597
+ "basedOn": "clean",
598
+ "changes": [
599
+ "sum_by_category",
600
+ "filter_rows"
601
+ ],
602
+ "adds": [
603
+ "summarise_by_category"
604
+ ],
605
+ "measure": [
606
+ "sum_by_category"
607
+ ],
608
+ "question": "Does the -46.7 interaction need the two descriptions to be byte-identical, or is being about the same thing enough? This arm is ablate-pair with exactly one field changed: the competitor's description becomes a plain paraphrase of the vague text rather than a copy of it. Same meaning, ordinary wording, no attempt to sit near a threshold - and by the linter's own measure it scores **0.529** against a near-duplicate threshold of 0.70, so the linter now says **nothing at all** about the competitor. No description/duplicate, no description/near-duplicate against sum_by_category; the one near-duplicate finding that remains is the pre-existing sum_by_category-versus-filter_rows pair, which ablate-pair has too.",
609
+ "prediction": "It still collapses - near ablate-pair's 48.3%, call it 45 to 70 - so byte-identity is NOT the trigger. The reason is what the judge actually did in the pair arm's smoke trial: it never compared the two strings. It read both descriptions, found neither one told it which tool to use, and fell back on the tool *name*. A paraphrase of an uninformative description is still uninformative, so the name tiebreaker should dominate exactly as before. Falsified above 85%, which would mean the harm really is about copied text and the error-graded duplicate rule is aimed correctly. **Consequence registered in advance, because it costs this project a claim it published hours ago:** if this arm collapses while the linter is silent, then `description/duplicate` as an *error* and the 0.70 near-duplicate threshold both miss the harm, the 2026-09-05 statement that the severities were vindicated is too strong and must be narrowed to byte-identical cases only, and the rule actually needed is a semantic one this linter cannot express without a model. Either result is publishable; the one that costs us something is the one to say loudest.",
610
+ "registered": "2026-09-05, before the arm ran, committed on its own before any trial was measured.",
611
+ "tools": [
612
+ {
613
+ "name": "sum_by_category",
614
+ "impl": "sumByCategory",
615
+ "description": "Works with the rows in the table and returns totals for what it finds.",
616
+ "inputSchema": {
617
+ "type": "object",
618
+ "properties": {
619
+ "highlight": {
620
+ "type": "string",
621
+ "description": "A category name."
622
+ }
623
+ }
624
+ },
625
+ "annotations": {
626
+ "readOnlyHint": true
627
+ }
628
+ },
629
+ {
630
+ "name": "filter_rows",
631
+ "impl": "filterRows",
632
+ "description": "Works with the rows in the table and returns counts for what it finds.",
633
+ "inputSchema": {
634
+ "type": "object",
635
+ "properties": {
636
+ "from": {
637
+ "type": "string",
638
+ "description": "Earliest date to include, as YYYY-MM-DD."
639
+ },
640
+ "to": {
641
+ "type": "string",
642
+ "description": "Latest date to include, as YYYY-MM-DD."
643
+ },
644
+ "category": {
645
+ "type": "string",
646
+ "description": "Restrict to one category name."
647
+ },
648
+ "min_amount": {
649
+ "type": "number",
650
+ "description": "Smallest amount to include."
651
+ },
652
+ "max_amount": {
653
+ "type": "number",
654
+ "description": "Largest amount to include."
655
+ },
656
+ "limit": {
657
+ "type": "number",
658
+ "description": "How many matching rows to preview. Defaults to 10, capped at 25."
659
+ }
660
+ }
661
+ },
662
+ "annotations": {
663
+ "readOnlyHint": true
664
+ }
665
+ },
666
+ {
667
+ "name": "summarise_by_category",
668
+ "impl": "sumByCategory",
669
+ "description": "Handles the rows in the table and gives back totals for what it discovers.",
670
+ "inputSchema": {
671
+ "type": "object",
672
+ "properties": {
673
+ "cat": {}
674
+ }
675
+ },
676
+ "annotations": {
677
+ "readOnlyHint": true
678
+ }
679
+ }
680
+ ]
681
+ },
682
+ "ablate-pair-sim086": {
683
+ "families": [
684
+ "description/near-duplicate",
685
+ "schema/undocumented-property",
686
+ "schema/missing-type",
687
+ "description/indistinguishable-pair"
688
+ ],
689
+ "variantOf": "ablate-pair",
690
+ "differsBy": [
691
+ "summarise_by_category.description"
692
+ ],
693
+ "ladder": {
694
+ "similarity": 0.857,
695
+ "rung": 2,
696
+ "of": "description-similarity ladder, 2026-09-05"
697
+ },
698
+ "basedOn": "clean",
699
+ "changes": [
700
+ "sum_by_category",
701
+ "filter_rows"
702
+ ],
703
+ "adds": [
704
+ "summarise_by_category"
705
+ ],
706
+ "measure": [
707
+ "sum_by_category"
708
+ ],
709
+ "question": "Rung 2 of the similarity ladder. Is there any description-similarity threshold that separates the harmful case from the harmless one? This rung sits **above** the linter's 0.70 line at 0.857, so `description/near-duplicate` fires (three times) but `description/duplicate` does not - the one rung where the linter warns without erroring.",
710
+ "prediction": "It collapses like every other rung - 45 to 60% - because the mechanism is the tool name and not the text. If a warning-level rung costs the same as an error-level one, the severity split carries no information about magnitude, only about detectability.",
711
+ "registered": "2026-09-05, before the ladder ran, committed with the other two rungs and before any of the three measured a trial.",
712
+ "tools": [
713
+ {
714
+ "name": "sum_by_category",
715
+ "impl": "sumByCategory",
716
+ "description": "Works with the rows in the table and returns totals for what it finds.",
717
+ "inputSchema": {
718
+ "type": "object",
719
+ "properties": {
720
+ "highlight": {
721
+ "type": "string",
722
+ "description": "A category name."
723
+ }
724
+ }
725
+ },
726
+ "annotations": {
727
+ "readOnlyHint": true
728
+ }
729
+ },
730
+ {
731
+ "name": "filter_rows",
732
+ "impl": "filterRows",
733
+ "description": "Works with the rows in the table and returns counts for what it finds.",
734
+ "inputSchema": {
735
+ "type": "object",
736
+ "properties": {
737
+ "from": {
738
+ "type": "string",
739
+ "description": "Earliest date to include, as YYYY-MM-DD."
740
+ },
741
+ "to": {
742
+ "type": "string",
743
+ "description": "Latest date to include, as YYYY-MM-DD."
744
+ },
745
+ "category": {
746
+ "type": "string",
747
+ "description": "Restrict to one category name."
748
+ },
749
+ "min_amount": {
750
+ "type": "number",
751
+ "description": "Smallest amount to include."
752
+ },
753
+ "max_amount": {
754
+ "type": "number",
755
+ "description": "Largest amount to include."
756
+ },
757
+ "limit": {
758
+ "type": "number",
759
+ "description": "How many matching rows to preview. Defaults to 10, capped at 25."
760
+ }
761
+ }
762
+ },
763
+ "annotations": {
764
+ "readOnlyHint": true
765
+ }
766
+ },
767
+ {
768
+ "name": "summarise_by_category",
769
+ "impl": "sumByCategory",
770
+ "description": "Works with the rows in the table and returns totals for what it locates.",
771
+ "inputSchema": {
772
+ "type": "object",
773
+ "properties": {
774
+ "cat": {}
775
+ }
776
+ },
777
+ "annotations": {
778
+ "readOnlyHint": true
779
+ }
780
+ }
781
+ ]
782
+ },
783
+ "ablate-pair-sim013": {
784
+ "families": [
785
+ "description/near-duplicate",
786
+ "schema/undocumented-property",
787
+ "schema/missing-type",
788
+ "description/indistinguishable-pair"
789
+ ],
790
+ "variantOf": "ablate-pair",
791
+ "differsBy": [
792
+ "summarise_by_category.description"
793
+ ],
794
+ "ladder": {
795
+ "similarity": 0.13,
796
+ "rung": 4,
797
+ "of": "description-similarity ladder, 2026-09-05"
798
+ },
799
+ "basedOn": "clean",
800
+ "changes": [
801
+ "sum_by_category",
802
+ "filter_rows"
803
+ ],
804
+ "adds": [
805
+ "summarise_by_category"
806
+ ],
807
+ "measure": [
808
+ "sum_by_category"
809
+ ],
810
+ "question": "Rung 4. Almost no shared vocabulary - 0.130 - while saying just as little about which tool to use. If the collapse survives this, lexical distance is not the variable and no similarity threshold can ever separate the two cases.",
811
+ "prediction": "It collapses - 45 to 60%. The competitor's description shares nearly no words with the original and still fails to distinguish the two tools, which is the only property that has ever mattered in these arms.",
812
+ "registered": "2026-09-05, before the ladder ran, committed with the other two rungs and before any of the three measured a trial.",
813
+ "tools": [
814
+ {
815
+ "name": "sum_by_category",
816
+ "impl": "sumByCategory",
817
+ "description": "Works with the rows in the table and returns totals for what it finds.",
818
+ "inputSchema": {
819
+ "type": "object",
820
+ "properties": {
821
+ "highlight": {
822
+ "type": "string",
823
+ "description": "A category name."
824
+ }
825
+ }
826
+ },
827
+ "annotations": {
828
+ "readOnlyHint": true
829
+ }
830
+ },
831
+ {
832
+ "name": "filter_rows",
833
+ "impl": "filterRows",
834
+ "description": "Works with the rows in the table and returns counts for what it finds.",
835
+ "inputSchema": {
836
+ "type": "object",
837
+ "properties": {
838
+ "from": {
839
+ "type": "string",
840
+ "description": "Earliest date to include, as YYYY-MM-DD."
841
+ },
842
+ "to": {
843
+ "type": "string",
844
+ "description": "Latest date to include, as YYYY-MM-DD."
845
+ },
846
+ "category": {
847
+ "type": "string",
848
+ "description": "Restrict to one category name."
849
+ },
850
+ "min_amount": {
851
+ "type": "number",
852
+ "description": "Smallest amount to include."
853
+ },
854
+ "max_amount": {
855
+ "type": "number",
856
+ "description": "Largest amount to include."
857
+ },
858
+ "limit": {
859
+ "type": "number",
860
+ "description": "How many matching rows to preview. Defaults to 10, capped at 25."
861
+ }
862
+ }
863
+ },
864
+ "annotations": {
865
+ "readOnlyHint": true
866
+ }
867
+ },
868
+ {
869
+ "name": "summarise_by_category",
870
+ "impl": "sumByCategory",
871
+ "description": "Handles the listed entries and hands back combined figures for whatever turns up.",
872
+ "inputSchema": {
873
+ "type": "object",
874
+ "properties": {
875
+ "cat": {}
876
+ }
877
+ },
878
+ "annotations": {
879
+ "readOnlyHint": true
880
+ }
881
+ }
882
+ ]
883
+ },
884
+ "ablate-pair-sim004": {
885
+ "families": [
886
+ "description/near-duplicate",
887
+ "schema/undocumented-property",
888
+ "schema/missing-type",
889
+ "description/indistinguishable-pair"
890
+ ],
891
+ "variantOf": "ablate-pair",
892
+ "differsBy": [
893
+ "summarise_by_category.description"
894
+ ],
895
+ "ladder": {
896
+ "similarity": 0.043,
897
+ "rung": 5,
898
+ "of": "description-similarity ladder, 2026-09-05"
899
+ },
900
+ "basedOn": "clean",
901
+ "changes": [
902
+ "sum_by_category",
903
+ "filter_rows"
904
+ ],
905
+ "adds": [
906
+ "summarise_by_category"
907
+ ],
908
+ "measure": [
909
+ "sum_by_category"
910
+ ],
911
+ "question": "Rung 5, the decisive one. Effectively disjoint vocabulary - 0.043, sharing only 'the' and 'to' - and still uninformative about which tool serves the request. This is the rung that decides whether option (a) of item 22, lowering the similarity threshold, can work at any value at all.",
912
+ "prediction": "It collapses - 45 to 60% - and that kills option (a). Two tool descriptions can share almost no words and still leave a model with no way to choose between them, and no threshold on word overlap can catch that. Falsified at 85% or above, which would mean lexical distance is the variable after all and a low threshold would be worth its false positives.",
913
+ "registered": "2026-09-05, before the ladder ran, committed with the other two rungs and before any of the three measured a trial.",
914
+ "tools": [
915
+ {
916
+ "name": "sum_by_category",
917
+ "impl": "sumByCategory",
918
+ "description": "Works with the rows in the table and returns totals for what it finds.",
919
+ "inputSchema": {
920
+ "type": "object",
921
+ "properties": {
922
+ "highlight": {
923
+ "type": "string",
924
+ "description": "A category name."
925
+ }
926
+ }
927
+ },
928
+ "annotations": {
929
+ "readOnlyHint": true
930
+ }
931
+ },
932
+ {
933
+ "name": "filter_rows",
934
+ "impl": "filterRows",
935
+ "description": "Works with the rows in the table and returns counts for what it finds.",
936
+ "inputSchema": {
937
+ "type": "object",
938
+ "properties": {
939
+ "from": {
940
+ "type": "string",
941
+ "description": "Earliest date to include, as YYYY-MM-DD."
942
+ },
943
+ "to": {
944
+ "type": "string",
945
+ "description": "Latest date to include, as YYYY-MM-DD."
946
+ },
947
+ "category": {
948
+ "type": "string",
949
+ "description": "Restrict to one category name."
950
+ },
951
+ "min_amount": {
952
+ "type": "number",
953
+ "description": "Smallest amount to include."
954
+ },
955
+ "max_amount": {
956
+ "type": "number",
957
+ "description": "Largest amount to include."
958
+ },
959
+ "limit": {
960
+ "type": "number",
961
+ "description": "How many matching rows to preview. Defaults to 10, capped at 25."
962
+ }
963
+ }
964
+ },
965
+ "annotations": {
966
+ "readOnlyHint": true
967
+ }
968
+ },
969
+ {
970
+ "name": "summarise_by_category",
971
+ "impl": "sumByCategory",
972
+ "description": "Covers arithmetic duties over displayed material, as appropriate to the request.",
973
+ "inputSchema": {
974
+ "type": "object",
975
+ "properties": {
976
+ "cat": {}
977
+ }
978
+ },
979
+ "annotations": {
980
+ "readOnlyHint": true
981
+ }
982
+ }
983
+ ]
984
+ }
985
+ },
986
+ "variants": {
987
+ "clean": [
988
+ {
989
+ "name": "describe_dataset",
990
+ "impl": "describe",
991
+ "description": "Describe the spreadsheet currently loaded in the page: row count, column names, category list, date range and total amount. Call this first to learn what data is available. Individual rows are never returned.",
992
+ "inputSchema": {
993
+ "type": "object",
994
+ "properties": {}
995
+ },
996
+ "annotations": {
997
+ "readOnlyHint": true
998
+ }
999
+ },
1000
+ {
1001
+ "name": "sum_by_category",
1002
+ "impl": "sumByCategory",
1003
+ "description": "Total the loaded spending by category, largest first. Optionally pass a category name to visually highlight its rows in the table for the human reading the page. Returns aggregates only.",
1004
+ "inputSchema": {
1005
+ "type": "object",
1006
+ "properties": {
1007
+ "highlight": {
1008
+ "type": "string",
1009
+ "description": "Exact category name to highlight in the table. Must match a category returned by describe_dataset."
1010
+ }
1011
+ }
1012
+ },
1013
+ "annotations": {
1014
+ "readOnlyHint": true
1015
+ }
1016
+ },
1017
+ {
1018
+ "name": "filter_rows",
1019
+ "impl": "filterRows",
1020
+ "description": "Find rows matching any combination of date range, category and amount bounds. Returns how many matched and their total, and highlights every match in the table.",
1021
+ "inputSchema": {
1022
+ "type": "object",
1023
+ "properties": {
1024
+ "from": {
1025
+ "type": "string",
1026
+ "description": "Earliest date to include, as YYYY-MM-DD."
1027
+ },
1028
+ "to": {
1029
+ "type": "string",
1030
+ "description": "Latest date to include, as YYYY-MM-DD."
1031
+ },
1032
+ "category": {
1033
+ "type": "string",
1034
+ "description": "Restrict to one category name."
1035
+ },
1036
+ "min_amount": {
1037
+ "type": "number",
1038
+ "description": "Smallest amount to include."
1039
+ },
1040
+ "max_amount": {
1041
+ "type": "number",
1042
+ "description": "Largest amount to include."
1043
+ },
1044
+ "limit": {
1045
+ "type": "number",
1046
+ "description": "How many matching rows to preview. Defaults to 10, capped at 25."
1047
+ }
1048
+ }
1049
+ },
1050
+ "annotations": {
1051
+ "readOnlyHint": true
1052
+ }
1053
+ },
1054
+ {
1055
+ "name": "monthly_trend",
1056
+ "impl": "monthlyTrend",
1057
+ "description": "Total the loaded spending per calendar month, oldest first, so month-on-month changes are visible. Answers questions a single glance at the table cannot. Returns aggregates only.",
1058
+ "inputSchema": {
1059
+ "type": "object",
1060
+ "properties": {}
1061
+ },
1062
+ "annotations": {
1063
+ "readOnlyHint": true
1064
+ }
1065
+ },
1066
+ {
1067
+ "name": "find_anomalies",
1068
+ "impl": "findAnomalies",
1069
+ "description": "Find rows that are unusually large compared with the rest of their own category, using a z-score within each category. Categories with fewer than three rows are skipped. Highlights every outlier in the table.",
1070
+ "inputSchema": {
1071
+ "type": "object",
1072
+ "properties": {
1073
+ "threshold": {
1074
+ "type": "number",
1075
+ "description": "How many standard deviations above the category mean counts as unusual. Defaults to 2.5; raise it to be stricter, or lower it to surface milder variation."
1076
+ }
1077
+ }
1078
+ },
1079
+ "annotations": {
1080
+ "readOnlyHint": true
1081
+ }
1082
+ },
1083
+ {
1084
+ "name": "top_expenses",
1085
+ "impl": "topExpenses",
1086
+ "description": "Find the largest individual rows by amount and highlight them in the table.",
1087
+ "inputSchema": {
1088
+ "type": "object",
1089
+ "properties": {
1090
+ "limit": {
1091
+ "type": "number",
1092
+ "description": "How many rows to consider. Defaults to 5, capped at 25."
1093
+ }
1094
+ }
1095
+ },
1096
+ "annotations": {
1097
+ "readOnlyHint": true
1098
+ }
1099
+ },
1100
+ {
1101
+ "name": "clear_highlights",
1102
+ "impl": "clearHighlights",
1103
+ "description": "Remove any highlighting from the table so every row is legible again. Call this when moving on to an unrelated question.",
1104
+ "inputSchema": {
1105
+ "type": "object",
1106
+ "properties": {}
1107
+ },
1108
+ "annotations": {
1109
+ "readOnlyHint": false
1110
+ }
1111
+ }
1112
+ ],
1113
+ "degraded": [
1114
+ {
1115
+ "name": "describe_dataset",
1116
+ "impl": "describe",
1117
+ "description": "Describe the spreadsheet currently loaded in the page: row count, column names, category list, date range and total amount. Call this first to learn what data is available. Individual rows are never returned.",
1118
+ "inputSchema": {
1119
+ "type": "object",
1120
+ "properties": {}
1121
+ },
1122
+ "annotations": {
1123
+ "readOnlyHint": true
1124
+ }
1125
+ },
1126
+ {
1127
+ "name": "sum_by_category",
1128
+ "impl": "sumByCategory",
1129
+ "description": "Works with the rows in the table and returns totals for what it finds.",
1130
+ "inputSchema": {
1131
+ "type": "object",
1132
+ "properties": {
1133
+ "highlight": {
1134
+ "type": "string",
1135
+ "description": "A category name."
1136
+ }
1137
+ }
1138
+ },
1139
+ "annotations": {
1140
+ "readOnlyHint": true
1141
+ }
1142
+ },
1143
+ {
1144
+ "name": "filter_rows",
1145
+ "impl": "filterRows",
1146
+ "description": "Works with the rows in the table and returns counts for what it finds.",
1147
+ "inputSchema": {
1148
+ "type": "object",
1149
+ "properties": {
1150
+ "from": {
1151
+ "type": "string",
1152
+ "description": "Earliest date to include, as YYYY-MM-DD."
1153
+ },
1154
+ "to": {
1155
+ "type": "string",
1156
+ "description": "Latest date to include, as YYYY-MM-DD."
1157
+ },
1158
+ "category": {
1159
+ "type": "string",
1160
+ "description": "Restrict to one category name."
1161
+ },
1162
+ "min_amount": {
1163
+ "type": "number",
1164
+ "description": "Smallest amount to include."
1165
+ },
1166
+ "max_amount": {
1167
+ "type": "number",
1168
+ "description": "Largest amount to include."
1169
+ },
1170
+ "limit": {
1171
+ "type": "number",
1172
+ "description": "How many matching rows to preview. Defaults to 10, capped at 25."
1173
+ }
1174
+ }
1175
+ },
1176
+ "annotations": {
1177
+ "readOnlyHint": true
1178
+ }
1179
+ },
1180
+ {
1181
+ "name": "monthly_trend",
1182
+ "impl": "monthlyTrend",
1183
+ "description": "Total the loaded spending per calendar month, oldest first, so month-on-month changes are visible. Answers questions a single glance at the table cannot. Returns aggregates only.",
1184
+ "inputSchema": {
1185
+ "type": "object",
1186
+ "properties": {}
1187
+ },
1188
+ "annotations": {
1189
+ "readOnlyHint": true
1190
+ }
1191
+ },
1192
+ {
1193
+ "name": "find_anomalies",
1194
+ "impl": "findAnomalies",
1195
+ "description": "Processes the table and gives numbers back.",
1196
+ "inputSchema": {
1197
+ "type": "object",
1198
+ "properties": {
1199
+ "threshold": {
1200
+ "type": "number",
1201
+ "description": "A number."
1202
+ }
1203
+ }
1204
+ },
1205
+ "annotations": {
1206
+ "readOnlyHint": true
1207
+ }
1208
+ },
1209
+ {
1210
+ "name": "top_expenses",
1211
+ "impl": "topExpenses",
1212
+ "throwWhenMissing": [
1213
+ "mode",
1214
+ "format",
1215
+ "strategy"
1216
+ ],
1217
+ "description": "Find the largest individual rows by amount and highlight them in the table.",
1218
+ "inputSchema": {
1219
+ "type": "object",
1220
+ "required": [
1221
+ "mode",
1222
+ "format",
1223
+ "strategy"
1224
+ ],
1225
+ "properties": {
1226
+ "limit": {
1227
+ "type": "number",
1228
+ "description": "How many rows to consider. Defaults to 5, capped at 25."
1229
+ },
1230
+ "mode": {
1231
+ "type": "string"
1232
+ },
1233
+ "format": {
1234
+ "type": "string"
1235
+ },
1236
+ "strategy": {
1237
+ "type": "string"
1238
+ },
1239
+ "locale": {
1240
+ "type": "string"
1241
+ },
1242
+ "page": {
1243
+ "type": "number"
1244
+ },
1245
+ "page_size": {
1246
+ "type": "number"
1247
+ },
1248
+ "include_totals": {
1249
+ "type": "boolean"
1250
+ },
1251
+ "verbose": {
1252
+ "type": "boolean"
1253
+ }
1254
+ }
1255
+ },
1256
+ "annotations": {
1257
+ "readOnlyHint": true
1258
+ }
1259
+ },
1260
+ {
1261
+ "name": "clear_highlights",
1262
+ "impl": "clearHighlights",
1263
+ "description": "Utility.",
1264
+ "inputSchema": {
1265
+ "type": "object",
1266
+ "properties": {}
1267
+ },
1268
+ "annotations": {
1269
+ "readOnlyHint": false
1270
+ }
1271
+ },
1272
+ {
1273
+ "name": "summarise_by_category",
1274
+ "impl": "sumByCategory",
1275
+ "description": "Works with the rows in the table and returns totals for what it finds.",
1276
+ "inputSchema": {
1277
+ "type": "object",
1278
+ "properties": {
1279
+ "cat": {}
1280
+ }
1281
+ },
1282
+ "annotations": {
1283
+ "readOnlyHint": true
1284
+ }
1285
+ },
1286
+ {
1287
+ "name": "Clear Highlights",
1288
+ "impl": "clearHighlights",
1289
+ "description": "",
1290
+ "inputSchema": {
1291
+ "type": "object",
1292
+ "properties": {}
1293
+ },
1294
+ "annotations": {
1295
+ "readOnlyHint": false
1296
+ }
1297
+ },
1298
+ {
1299
+ "name": "top.expenses.v2",
1300
+ "impl": "topExpenses",
1301
+ "description": "Old version. Do not use.",
1302
+ "inputSchema": {
1303
+ "type": "string"
1304
+ },
1305
+ "annotations": {
1306
+ "readOnlyHint": true
1307
+ }
1308
+ }
1309
+ ]
1310
+ }
1311
+ }