@datagrok/proteomics 1.0.1 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/CHANGELOG.md +52 -3
  2. package/CLAUDE.md +178 -0
  3. package/README.md +195 -3
  4. package/detectors.js +152 -9
  5. package/dist/package-test.js +1 -1744
  6. package/dist/package-test.js.map +1 -1
  7. package/dist/package.js +1 -146
  8. package/dist/package.js.map +1 -1
  9. package/docs/personas-and-capabilities.md +165 -0
  10. package/files/demo/README.md +264 -0
  11. package/files/demo/cptac-spike-in.txt +1571 -0
  12. package/files/demo/enrichment-demo.csv +120 -0
  13. package/files/demo/fragpipe-smoke-test.tsv +11 -0
  14. package/files/demo/proteinGroups.txt +28 -0
  15. package/files/demo/spectronaut-hye-candidates.tsv +94 -0
  16. package/files/demo/spectronaut-hye-demo.tsv +8761 -0
  17. package/files/demo/spectronaut-hye-mix.tsv +8761 -0
  18. package/files/demo/spectronaut-hye-precursor-golden.json +938 -0
  19. package/files/demo/spectronaut-hye-precursor-golden.tsv +235 -0
  20. package/files/demo/spectronaut-hye-precursor.tsv +493 -0
  21. package/images/enrichment-crosslink.png +0 -0
  22. package/images/enrichment-term-selected.png +0 -0
  23. package/images/hero.png +0 -0
  24. package/images/pipeline.svg +80 -0
  25. package/package.json +88 -63
  26. package/scripts/deqms_de.R +60 -0
  27. package/scripts/limma_de.R +42 -0
  28. package/scripts/vsn_normalize.R +19 -0
  29. package/src/analysis/differential-expression.ts +450 -0
  30. package/src/analysis/enrichment-export.ts +101 -0
  31. package/src/analysis/enrichment.ts +602 -0
  32. package/src/analysis/experiment-setup.ts +199 -0
  33. package/src/analysis/imputation.ts +407 -0
  34. package/src/analysis/log2-scale.ts +139 -0
  35. package/src/analysis/normalization.ts +255 -0
  36. package/src/analysis/pca.ts +254 -0
  37. package/src/analysis/spc-storage.ts +515 -0
  38. package/src/analysis/spc.ts +544 -0
  39. package/src/analysis/subcellular-location.ts +431 -0
  40. package/src/demo/enrichment-demo.ts +94 -0
  41. package/src/demo/proteomics-demo.ts +123 -0
  42. package/src/global.d.ts +15 -0
  43. package/src/menu.ts +133 -0
  44. package/src/package-api.ts +136 -14
  45. package/src/package-test.ts +45 -20
  46. package/src/package.g.ts +161 -0
  47. package/src/package.ts +1029 -17
  48. package/src/panels/protein-focus.ts +63 -0
  49. package/src/panels/published-analysis-panel.ts +151 -0
  50. package/src/panels/uniprot-panel.ts +349 -0
  51. package/src/parsers/fragpipe-parser.ts +200 -0
  52. package/src/parsers/generic-parser.ts +197 -0
  53. package/src/parsers/maxquant-parser.ts +162 -0
  54. package/src/parsers/shared-utils.ts +163 -0
  55. package/src/parsers/spectronaut-candidates-parser.ts +307 -0
  56. package/src/parsers/spectronaut-parser.ts +604 -0
  57. package/src/publishing/assert-published-shape.ts +260 -0
  58. package/src/publishing/post-open-recovery.ts +104 -0
  59. package/src/publishing/publish-project.ts +515 -0
  60. package/src/publishing/publish-settings.ts +59 -0
  61. package/src/publishing/publish-state.ts +316 -0
  62. package/src/publishing/share-dialog.ts +171 -0
  63. package/src/publishing/trim-dataframe.ts +247 -0
  64. package/src/tests/analysis.ts +658 -0
  65. package/src/tests/enrichment-export.ts +61 -0
  66. package/src/tests/enrichment-visualization.ts +340 -0
  67. package/src/tests/enrichment.ts +224 -0
  68. package/src/tests/fragpipe-e2e.ts +74 -0
  69. package/src/tests/fragpipe-parser.ts +147 -0
  70. package/src/tests/gene-label-resolver.ts +387 -0
  71. package/src/tests/generic-parser.ts +152 -0
  72. package/src/tests/group-mean-correlation.ts +139 -0
  73. package/src/tests/log2-scale.ts +93 -0
  74. package/src/tests/organisms.ts +56 -0
  75. package/src/tests/parsers.ts +182 -0
  76. package/src/tests/publish-roundtrip.ts +584 -0
  77. package/src/tests/publish-spike.ts +377 -0
  78. package/src/tests/qc-dashboard.ts +210 -0
  79. package/src/tests/smart-pathway-filter.ts +193 -0
  80. package/src/tests/spc-formula-lines-spike.ts +129 -0
  81. package/src/tests/spc.ts +640 -0
  82. package/src/tests/spectronaut-candidates-e2e.ts +140 -0
  83. package/src/tests/spectronaut-candidates-parser.ts +398 -0
  84. package/src/tests/spectronaut-parser.ts +668 -0
  85. package/src/tests/subcellular-location.ts +361 -0
  86. package/src/tests/uniprot-panel.ts +202 -0
  87. package/src/tests/volcano.ts +603 -0
  88. package/src/utils/column-detection.ts +28 -0
  89. package/src/utils/gene-label-resolver.ts +443 -0
  90. package/src/utils/organisms.ts +82 -0
  91. package/src/utils/proteomics-types.ts +30 -0
  92. package/src/viewers/enrichment-viewers.ts +274 -0
  93. package/src/viewers/group-mean-correlation.ts +218 -0
  94. package/src/viewers/heatmap.ts +168 -0
  95. package/src/viewers/pca-plot.ts +169 -0
  96. package/src/viewers/qc-computations.ts +266 -0
  97. package/src/viewers/qc-dashboard.ts +176 -0
  98. package/src/viewers/spc-dashboard.ts +755 -0
  99. package/src/viewers/volcano.ts +690 -0
  100. package/test-console-output-1.log +2073 -0
  101. package/test-record-1.mp4 +0 -0
  102. package/tools/derive-precursor-golden-sidecar.mjs +81 -0
  103. package/tools/generate-enrichment-fixture.sh +160 -0
  104. package/tools/generate-spectronaut-candidates-fixture.mjs +212 -0
  105. package/tools/generate-spectronaut-precursor-fixture.mjs +128 -0
  106. package/tools/spectronaut-aggregate.sh +46 -0
  107. package/tools/spectronaut-aggregate.sql +80 -0
  108. package/tsconfig.json +18 -71
  109. package/webpack.config.js +86 -45
  110. package/.eslintignore +0 -1
  111. package/.eslintrc.json +0 -56
  112. package/LICENSE +0 -674
  113. package/package.png +0 -0
  114. package/scripts/number_antibody.py +0 -190
  115. package/scripts/number_antibody_abnumber.py +0 -177
  116. package/scripts/number_antibody_anarci.py +0 -200
@@ -0,0 +1,668 @@
1
+ import * as DG from 'datagrok-api/dg';
2
+ import * as grok from 'datagrok-api/grok';
3
+ import {category, test, expect} from '@datagrok-libraries/test/src/test';
4
+ import {parseSpectronautText, parseSpectronautStream} from '../parsers/spectronaut-parser';
5
+ import {sniffIsPrecursor} from '../package';
6
+ import {SEMTYPE} from '../utils/proteomics-types';
7
+ import {getGroups} from '../analysis/experiment-setup';
8
+ import {_package} from '../package-test';
9
+
10
+ // Webpack `asset/source` (see webpack.config.js) inlines the committed
11
+ // tools/spectronaut-aggregate.{sql,sh} as raw strings into the test bundle
12
+ // (module types in src/global.d.ts). The tools/ dir is NOT deployed under
13
+ // files/, so it is unreachable via _package.files at runtime — this static
14
+ // import is the R5 drift guard (a deleted/renamed/changed fallback fails the
15
+ // build + grok test).
16
+ // eslint-disable-next-line import/no-unresolved
17
+ import aggregateSql from '../../tools/spectronaut-aggregate.sql';
18
+ // eslint-disable-next-line import/no-unresolved
19
+ import aggregateSh from '../../tools/spectronaut-aggregate.sh';
20
+
21
+ /** Builds a synthetic Spectronaut long-format TSV string for testing.
22
+ *
23
+ * Each protein gets ≥2 distinct precursor/fragment rows per (protein × condition
24
+ * × replicate). The quantity value is held CONSTANT within each group so duckdb's
25
+ * `max()` equals the parser's first-encountered value (RESEARCH Assumption A1);
26
+ * PG.Organisms / R.FileName are likewise constant per group so duckdb
27
+ * `any_value()` equals first-non-null (RESEARCH Pitfall 5).
28
+ *
29
+ * The emitted header carries the D-01 precursor signature
30
+ * (`EG.ModifiedPeptide`, `FG.Charge`, `FG.Id`, `PEP.StrippedSequence`). These are
31
+ * EXTRA columns — they add no protein groups, so the existing suite observes the
32
+ * same protein/row counts as before. NO `PG.Genes`/`PG.ProteinAccessions`
33
+ * columns are emitted (the real hye-mix demo lacks them; the committed
34
+ * tools/spectronaut-aggregate.sql drops the matching any_value SELECT terms for
35
+ * exactly this reason). Default conditions stay `CondA`/`CondB` so the committed
36
+ * SQL's DMD↔WT flip is a structural no-op.
37
+ *
38
+ * `extraRows` injects deliberate filter-branch rows (decoy/contaminant prefixes,
39
+ * sub-threshold / non-numeric / empty-string q-values) so a single call can
40
+ * exercise every R2 branch without perturbing the default behaviour the existing
41
+ * 19 tests depend on. */
42
+ function makeLongFormatTsv(opts: {
43
+ proteins: {id: string; organism?: string}[];
44
+ conditions: string[];
45
+ replicates: number[];
46
+ qValues?: Map<string, number | string>; // protein -> qValue override
47
+ ibaqValues?: Map<string, number>; // protein -> iBAQ override
48
+ fileNameTemplate?: string;
49
+ quantityColumn?: string; // defaults to 'PG.IBAQ'; pass 'PG.Quantity' to exercise the alias
50
+ /** Deliberate filter-branch rows appended after the regular protein rows.
51
+ * Each entry emits one precursor row with the given protein id, q-value, and
52
+ * (optional) explicit quantity, across every condition × replicate. Used to
53
+ * exercise CON__/REV__, sub-threshold-numeric, non-numeric, and empty-string
54
+ * q-value branches in one fixture. Defaults/existing tests never set this, so
55
+ * their protein/row counts are unaffected. */
56
+ extraRows?: {id: string; qValue: number | string; quantity?: number | string; organism?: string}[];
57
+ }): string {
58
+ const quantityCol = opts.quantityColumn ?? 'PG.IBAQ';
59
+ const headers = [
60
+ 'R.FileName', 'R.Condition', 'R.Replicate', 'PG.ProteinGroups',
61
+ 'PG.Organisms', quantityCol, 'EG.Qvalue', 'PEP.StrippedSequence',
62
+ 'EG.ModifiedPeptide', 'FG.Charge', 'FG.Id',
63
+ ];
64
+ const rows: string[] = [headers.join('\t')];
65
+
66
+ // Two distinct precursors per (protein × condition × replicate). Quantity is
67
+ // held constant per group; only the precursor identity (modified peptide /
68
+ // charge / fragment id / stripped sequence) varies, so duckdb max() collapses
69
+ // to the same value the parser's first-encountered-wins logic produces.
70
+ const precursors = [
71
+ {strip: 'PEPTIDER', mod: '_PEPTIDER_', charge: '2', fg: 'FG1'},
72
+ {strip: 'ANOTHERPEP', mod: '_ANOTHERPEP_', charge: '3', fg: 'FG2'},
73
+ ];
74
+
75
+ const emit = (
76
+ fileName: string, cond: string, rep: number, id: string,
77
+ organism: string, quantity: string, qVal: string,
78
+ ): void => {
79
+ for (const p of precursors) {
80
+ rows.push([fileName, cond, String(rep), id, organism,
81
+ quantity, qVal, p.strip, p.mod, p.charge, p.fg].join('\t'));
82
+ }
83
+ };
84
+
85
+ let ibaqCounter = 100;
86
+ for (const protein of opts.proteins) {
87
+ const ibaq = opts.ibaqValues?.get(protein.id) ?? ibaqCounter++;
88
+ for (const cond of opts.conditions) {
89
+ for (const rep of opts.replicates) {
90
+ const fileName = (opts.fileNameTemplate ?? 'file') + `_${cond}_${rep}`;
91
+ const qVal = opts.qValues?.get(protein.id) ?? 0.001;
92
+ const organism = protein.organism ?? 'Homo sapiens';
93
+ emit(fileName, cond, rep, protein.id, organism, String(ibaq), String(qVal));
94
+ }
95
+ }
96
+ }
97
+
98
+ // Deliberate filter-branch rows (decoy/contaminant + edge q-values).
99
+ if (opts.extraRows) {
100
+ let extraQuant = 500;
101
+ for (const er of opts.extraRows) {
102
+ const q = er.quantity ?? extraQuant++;
103
+ for (const cond of opts.conditions) {
104
+ for (const rep of opts.replicates) {
105
+ const fileName = (opts.fileNameTemplate ?? 'file') + `_${cond}_${rep}`;
106
+ const organism = er.organism ?? 'Homo sapiens';
107
+ emit(fileName, cond, rep, er.id, organism, String(q), String(er.qValue));
108
+ }
109
+ }
110
+ }
111
+ }
112
+
113
+ return rows.join('\n');
114
+ }
115
+
116
+ category('Spectronaut', () => {
117
+ const baseProteins = [
118
+ {id: 'P12345'}, {id: 'Q67890'}, {id: 'O15439'},
119
+ ];
120
+ const baseOpts = {
121
+ proteins: baseProteins,
122
+ conditions: ['CondA', 'CondB'],
123
+ replicates: [1, 2],
124
+ };
125
+
126
+ test('pivot produces correct protein count', async () => {
127
+ const tsv = makeLongFormatTsv(baseOpts);
128
+ const df = await parseSpectronautText(tsv);
129
+ expect(df.rowCount, 3);
130
+ });
131
+
132
+ test('pivot produces correct sample column names', async () => {
133
+ const tsv = makeLongFormatTsv(baseOpts);
134
+ const df = await parseSpectronautText(tsv);
135
+ expect(df.col('CondA_1') !== null, true);
136
+ expect(df.col('CondA_2') !== null, true);
137
+ expect(df.col('CondB_1') !== null, true);
138
+ expect(df.col('CondB_2') !== null, true);
139
+ });
140
+
141
+ test('CON__ and REV__ prefixed proteins are excluded', async () => {
142
+ const tsv = makeLongFormatTsv({
143
+ ...baseOpts,
144
+ proteins: [
145
+ {id: 'P12345'}, {id: 'CON__P99999'}, {id: 'REV__Q11111'},
146
+ ],
147
+ });
148
+ const df = await parseSpectronautText(tsv);
149
+ expect(df.rowCount, 1);
150
+ expect(df.col('PG.ProteinGroups')!.get(0), 'P12345');
151
+ });
152
+
153
+ test('rows with numeric EG.Qvalue > threshold are excluded', async () => {
154
+ const qValues = new Map<string, number | string>();
155
+ qValues.set('P12345', 0.001);
156
+ qValues.set('Q67890', 0.05); // above default 0.01 threshold
157
+ qValues.set('O15439', 0.005);
158
+ const tsv = makeLongFormatTsv({
159
+ ...baseOpts,
160
+ proteins: baseProteins,
161
+ qValues,
162
+ });
163
+ const df = await parseSpectronautText(tsv);
164
+ expect(df.rowCount, 2);
165
+ });
166
+
167
+ test('non-numeric EG.Qvalue rows treated as passing', async () => {
168
+ const qValues = new Map<string, number | string>();
169
+ qValues.set('P12345', 0.001);
170
+ qValues.set('Q67890', 'Profiled');
171
+ qValues.set('O15439', 'NaN');
172
+ const tsv = makeLongFormatTsv({
173
+ ...baseOpts,
174
+ proteins: baseProteins,
175
+ qValues,
176
+ });
177
+ const df = await parseSpectronautText(tsv);
178
+ expect(df.rowCount, 3);
179
+ });
180
+
181
+ test('PG.ProteinGroups has PROTEIN_ID semantic type', async () => {
182
+ const tsv = makeLongFormatTsv(baseOpts);
183
+ const df = await parseSpectronautText(tsv);
184
+ expect(df.col('PG.ProteinGroups')?.semType, SEMTYPE.PROTEIN_ID);
185
+ });
186
+
187
+ test('intensity columns have INTENSITY semantic type', async () => {
188
+ const tsv = makeLongFormatTsv(baseOpts);
189
+ const df = await parseSpectronautText(tsv);
190
+ expect(df.col('CondA_1')?.semType, SEMTYPE.INTENSITY);
191
+ expect(df.col('CondB_2')?.semType, SEMTYPE.INTENSITY);
192
+ });
193
+
194
+ test('R.FileName stored as spectronaut.fileName tag on intensity columns', async () => {
195
+ const tsv = makeLongFormatTsv({...baseOpts, fileNameTemplate: 'run'});
196
+ const df = await parseSpectronautText(tsv);
197
+ const col = df.col('CondA_1');
198
+ expect(col !== null, true);
199
+ if (col) {
200
+ const tag = col.getTag('spectronaut.fileName');
201
+ expect(tag !== null && tag !== '', true);
202
+ expect(tag!.includes('run'), true);
203
+ }
204
+ });
205
+
206
+ test('groups auto-populated via setGroups when exactly 2 conditions', async () => {
207
+ const tsv = makeLongFormatTsv(baseOpts);
208
+ const df = await parseSpectronautText(tsv);
209
+ const groups = getGroups(df);
210
+ expect(groups !== null, true);
211
+ if (groups) {
212
+ expect(groups.group1.columns.length, 2);
213
+ expect(groups.group2.columns.length, 2);
214
+ }
215
+ });
216
+
217
+ test('groups not set when more than 2 conditions', async () => {
218
+ const tsv = makeLongFormatTsv({
219
+ ...baseOpts,
220
+ conditions: ['CondA', 'CondB', 'CondC'],
221
+ });
222
+ const df = await parseSpectronautText(tsv);
223
+ const groups = getGroups(df);
224
+ expect(groups, null);
225
+ });
226
+
227
+ test('pre-normalized tag set for raw (non-log2) intensity data', async () => {
228
+ // Use default baseOpts which produce raw-range IBAQ values (100+)
229
+ const tsv = makeLongFormatTsv(baseOpts);
230
+ const df = await parseSpectronautText(tsv);
231
+ expect(df.getTag('proteomics.preNormalized'), 'true');
232
+ });
233
+
234
+ test('pre-normalized tag set when detectLog2Status detects log2-range values', async () => {
235
+ // Create data with values in log2 range (0-30)
236
+ const ibaqValues = new Map<string, number>();
237
+ ibaqValues.set('P12345', 15.2);
238
+ ibaqValues.set('Q67890', 22.7);
239
+ ibaqValues.set('O15439', 18.1);
240
+ const tsv = makeLongFormatTsv({...baseOpts, ibaqValues});
241
+ const df = await parseSpectronautText(tsv);
242
+ expect(df.getTag('proteomics.preNormalized'), 'true');
243
+ });
244
+
245
+ test('both raw and log2 intensity columns present in output', async () => {
246
+ const tsv = makeLongFormatTsv(baseOpts);
247
+ const df = await parseSpectronautText(tsv);
248
+ // Raw columns
249
+ expect(df.col('CondA_1') !== null, true);
250
+ // Log2 columns
251
+ expect(df.col('log2(CondA_1)') !== null, true);
252
+ expect(df.col('log2(CondB_2)') !== null, true);
253
+ });
254
+
255
+ test('proteomics.source tag set to spectronaut', async () => {
256
+ const tsv = makeLongFormatTsv(baseOpts);
257
+ const df = await parseSpectronautText(tsv);
258
+ expect(df.getTag('proteomics.source'), 'spectronaut');
259
+ });
260
+
261
+ test('PG.Quantity accepted as alternative to PG.IBAQ', async () => {
262
+ const tsv = makeLongFormatTsv({...baseOpts, quantityColumn: 'PG.Quantity'});
263
+ const df = await parseSpectronautText(tsv);
264
+ expect(df.rowCount, 3);
265
+ expect(df.col('CondA_1') !== null, true);
266
+ expect(df.col('CondB_2') !== null, true);
267
+ expect(df.col('CondA_1')?.semType, SEMTYPE.INTENSITY);
268
+ });
269
+
270
+ test('throws when neither PG.IBAQ nor PG.Quantity is present', async () => {
271
+ // makeLongFormatTsv with a bogus quantity-column name produces a frame
272
+ // that has neither known quantity column.
273
+ const tsv = makeLongFormatTsv({...baseOpts, quantityColumn: 'PG.Bogus'});
274
+ let threw = false;
275
+ try {
276
+ await parseSpectronautText(tsv);
277
+ } catch (e) {
278
+ threw = true;
279
+ expect((e as Error).message.includes('PG.IBAQ'), true);
280
+ expect((e as Error).message.includes('PG.Quantity'), true);
281
+ }
282
+ expect(threw, true);
283
+ });
284
+
285
+ test('demo file produces correct dimensions', async () => {
286
+ // This test will use the actual demo file when run in Datagrok
287
+ // For build validation, we use synthetic data with known dimensions
288
+ const proteins = [];
289
+ for (let i = 0; i < 93; i++)
290
+ proteins.push({id: `P${String(i).padStart(5, '0')}`});
291
+ const tsv = makeLongFormatTsv({
292
+ proteins,
293
+ conditions: ['HYE mix A', 'HYE mix B'],
294
+ replicates: [1, 2, 3, 4],
295
+ });
296
+ const df = await parseSpectronautText(tsv);
297
+ expect(df.rowCount, 93);
298
+ // 8 sample columns + protein ID + organisms + log2 columns
299
+ const intensityCols = df.columns.toList().filter((c) =>
300
+ c.semType === SEMTYPE.INTENSITY && !c.name.startsWith('log2('));
301
+ expect(intensityCols.length, 8);
302
+ });
303
+
304
+ // ---------------------------------------------------------------------------
305
+ // Plan 12-03 streaming-path coverage (RESEARCH Open Q1: the 19 tests above
306
+ // stay locked on parseSpectronautText; everything below pins the streaming
307
+ // path, the D-01 routing, and their equivalence to the text path / the
308
+ // committed duckdb golden — without touching makeLongFormatTsv or the 19).
309
+ // ---------------------------------------------------------------------------
310
+
311
+ /** Wraps a TSV string in a File and streams it through the precursor
312
+ * aggregator — the exact path importSpectronaut routes precursor reports to. */
313
+ async function streamTsv(tsv: string): Promise<DG.DataFrame> {
314
+ return await parseSpectronautStream(
315
+ new File([tsv], 'fixture.tsv', {type: 'text/tab-separated-values'}));
316
+ }
317
+
318
+ /** Reads a committed demo file using the package files convention already used
319
+ * by spectronaut-candidates-e2e / fragpipe-e2e (`_package.files` maps the
320
+ * deployed `files/` directory; `demo/<name>` -> `files/demo/<name>`). */
321
+ async function readDemoFile(name: string): Promise<string> {
322
+ return _package.files.readAsText(`demo/${name}`);
323
+ }
324
+
325
+ /** Non-log2 INTENSITY sample column names, sorted (the raw pre-log2 columns). */
326
+ function rawSampleCols(df: DG.DataFrame): string[] {
327
+ return df.columns.toList()
328
+ .filter((c) => c.semType === SEMTYPE.INTENSITY && !c.name.startsWith('log2('))
329
+ .map((c) => c.name)
330
+ .sort();
331
+ }
332
+
333
+ /** protein-id value -> row index, keyed on the primary protein column the
334
+ * shared finalize tail derives (falls back to PG.ProteinGroups). */
335
+ function proteinRowIndex(df: DG.DataFrame): {col: DG.Column; map: Map<string, number>} {
336
+ const col = df.col('Primary Protein ID') ?? df.col('PG.ProteinGroups')!;
337
+ const map = new Map<string, number>();
338
+ for (let i = 0; i < df.rowCount; i++)
339
+ map.set(String(col.get(i)), i);
340
+ return {col, map};
341
+ }
342
+
343
+ test('streams precursor fixture', async () => {
344
+ // R1 smoke test — the exact name in 12-VALIDATION.md's R1 row. Streams the
345
+ // committed precursor fixture (D-01 signature, edge rows, no PG.Genes).
346
+ const text = await readDemoFile('spectronaut-hye-precursor.tsv');
347
+ const df = await streamTsv(text);
348
+ expect(df.rowCount > 0, true);
349
+ const sampleCol = df.columns.toList().find((c) =>
350
+ c.semType === SEMTYPE.INTENSITY && !c.name.startsWith('log2(') &&
351
+ (c.name.startsWith('CondA_') || c.name.startsWith('CondB_')));
352
+ expect(sampleCol !== undefined, true);
353
+ expect(df.col('PG.ProteinGroups')?.semType, SEMTYPE.PROTEIN_ID);
354
+ });
355
+
356
+ test('sniffIsPrecursor routes by header', async () => {
357
+ // Directly verifies the D-01 routing branch (12-02 Task 2) in isolation.
358
+ const precHeader = [
359
+ 'R.FileName', 'R.Condition', 'R.Replicate', 'PG.ProteinGroups',
360
+ 'PG.Organisms', 'PG.Quantity', 'EG.Qvalue', 'EG.ModifiedPeptide',
361
+ 'FG.Charge', 'PEP.StrippedSequence',
362
+ ].join('\t');
363
+ const precRow = [
364
+ 'run_CondA_1', 'CondA', '1', 'P1', 'Homo sapiens', '10', '0.001',
365
+ '_PEP_', '2', 'PEP',
366
+ ].join('\t');
367
+ // PG-level header: required cols + PG.IBAQ but NONE of the three signature cols.
368
+ const pgHeader = [
369
+ 'R.FileName', 'R.Condition', 'R.Replicate', 'PG.ProteinGroups',
370
+ 'PG.Organisms', 'PG.IBAQ',
371
+ ].join('\t');
372
+ const pgRow = ['run_CondA_1', 'CondA', '1', 'P1', 'Homo sapiens', '10'].join('\t');
373
+
374
+ expect(await sniffIsPrecursor(
375
+ new File([precHeader + '\n' + precRow], 'p.tsv')), true);
376
+ expect(await sniffIsPrecursor(
377
+ new File([pgHeader + '\n' + pgRow], 'pg.tsv')), false);
378
+ });
379
+
380
+ test('stream path matches text path', async () => {
381
+ // Structural equivalence + per-(protein × raw sample column) numeric equality
382
+ // within 1e-3 — catches the documented first-encountered-vs-max divergence
383
+ // (RESEARCH ~L326/L428-429): a structural-only check would miss it.
384
+ const tsv = makeLongFormatTsv(baseOpts);
385
+ const a = await parseSpectronautText(tsv);
386
+ const b = await streamTsv(tsv);
387
+
388
+ expect(b.rowCount, a.rowCount);
389
+
390
+ const aCols = rawSampleCols(a);
391
+ const bCols = rawSampleCols(b);
392
+ expect(bCols.join(','), aCols.join(','));
393
+
394
+ for (const name of aCols)
395
+ expect(b.col(`log2(${name})`) !== null, a.col(`log2(${name})`) !== null);
396
+
397
+ expect(b.getTag('proteomics.source'), a.getTag('proteomics.source'));
398
+ expect(b.getTag('proteomics.preNormalized'), a.getTag('proteomics.preNormalized'));
399
+
400
+ const ga = getGroups(a);
401
+ const gb = getGroups(b);
402
+ expect(gb !== null, ga !== null);
403
+ if (ga && gb) {
404
+ expect(gb.group1.columns.length, ga.group1.columns.length);
405
+ expect(gb.group2.columns.length, ga.group2.columns.length);
406
+ }
407
+
408
+ // Per-cell numeric equality (Blocker 2): match rows by protein id, then
409
+ // compare every raw sample cell within 1e-3 (both-null treated as equal).
410
+ const ai = proteinRowIndex(a);
411
+ const bi = proteinRowIndex(b);
412
+ expect(bi.map.size, ai.map.size);
413
+ for (const [protein, ar] of ai.map) {
414
+ const br = bi.map.get(protein);
415
+ expect(br !== undefined, true);
416
+ for (const name of aCols) {
417
+ const ac = a.col(name)!;
418
+ const bc = b.col(name)!;
419
+ const aNull = ac.isNone(ar);
420
+ const bNull = bc.isNone(br!);
421
+ expect(bNull, aNull);
422
+ if (!aNull && !bNull) {
423
+ const av = ac.get(ar) as number;
424
+ const bv = bc.get(br!) as number;
425
+ expect(Math.abs(av - bv) <= 1e-3, true);
426
+ }
427
+ }
428
+ }
429
+ });
430
+
431
+ test('streaming filter parity', async () => {
432
+ // Every R2 edge branch in one fixture: CON__/REV__ excluded, an all-q>0.01
433
+ // protein excluded, non-numeric ('Profiled') and empty-string q-value
434
+ // proteins INCLUDED (duckdb null/non-numeric-pass parity).
435
+ const tsv = makeLongFormatTsv({
436
+ ...baseOpts,
437
+ proteins: [{id: 'KEEP1'}, {id: 'KEEP2'}],
438
+ extraRows: [
439
+ {id: 'CON__C1', qValue: 0.001},
440
+ {id: 'REV__R1', qValue: 0.001},
441
+ {id: 'HIGHQ', qValue: 0.05}, // all precursors numeric q > 0.01
442
+ {id: 'PROF', qValue: 'Profiled'}, // non-numeric -> passes
443
+ {id: 'EMPTYQ', qValue: ''}, // empty-string -> passes
444
+ ],
445
+ });
446
+ const df = await streamTsv(tsv);
447
+ const protCol = df.col('PG.ProteinGroups')!;
448
+ const ids = new Set<string>();
449
+ for (let i = 0; i < df.rowCount; i++)
450
+ ids.add(String(protCol.get(i)));
451
+
452
+ expect(ids.has('CON__C1'), false);
453
+ expect(ids.has('REV__R1'), false);
454
+ expect(ids.has('HIGHQ'), false);
455
+ expect(ids.has('PROF'), true);
456
+ expect(ids.has('EMPTYQ'), true);
457
+ // KEEP1, KEEP2, PROF, EMPTYQ survive.
458
+ expect(df.rowCount, 4);
459
+ });
460
+
461
+ /** Streams `tsv` while recording every `grok.shell.info` string, then restores
462
+ * the original `grok.shell.info` in a `finally` (a thrown assertion must NOT
463
+ * leak the stub into later tests — T-12-18). Returns the captured messages so
464
+ * the caller asserts the user-facing malformed-message contract directly (the
465
+ * per-line counters are function-local to parseSpectronautStream and not
466
+ * returned; the completion `grok.shell.info` is the observable the gap is
467
+ * about). */
468
+ async function streamCapturingInfo(
469
+ tsv: string,
470
+ ): Promise<{df: DG.DataFrame; infos: string[]}> {
471
+ const infos: string[] = [];
472
+ const original = grok.shell.info.bind(grok.shell);
473
+ (grok.shell as unknown as {info: (m: unknown) => void}).info = (m: unknown) => {
474
+ infos.push(String(m));
475
+ };
476
+ try {
477
+ const df = await streamTsv(tsv);
478
+ return {df, infos};
479
+ } finally {
480
+ (grok.shell as unknown as {info: typeof original}).info = original;
481
+ }
482
+ }
483
+
484
+ test('by-design-filtered rows are not counted malformed', async () => {
485
+ // A fixture whose ONLY dropped rows are by-design-filtered: a CON__ decoy,
486
+ // a REV__ decoy, and a protein whose every precursor has numeric q > 0.01.
487
+ // Every emitted line has the full 11-field header width — NO truncation.
488
+ // The gap's exact false signal ("skipped N malformed line(s)") must NOT
489
+ // fire for purely-filtered input; observe it via the grok.shell.info spy.
490
+ const tsv = makeLongFormatTsv({
491
+ ...baseOpts,
492
+ proteins: [{id: 'KEEP1'}, {id: 'KEEP2'}],
493
+ extraRows: [
494
+ {id: 'CON__C1', qValue: 0.001}, // contaminant -> filtered (silent)
495
+ {id: 'REV__R1', qValue: 0.001}, // decoy -> filtered (silent)
496
+ {id: 'HIGHQ', qValue: 0.05}, // numeric q > 0.01 -> filtered (silent)
497
+ ],
498
+ });
499
+ const {df, infos} = await streamCapturingInfo(tsv);
500
+
501
+ // Correct wide DataFrame: the two kept proteins survive; the
502
+ // decoy/contaminant/high-q proteins are absent.
503
+ const protCol = df.col('PG.ProteinGroups')!;
504
+ const ids = new Set<string>();
505
+ for (let i = 0; i < df.rowCount; i++)
506
+ ids.add(String(protCol.get(i)));
507
+ expect(ids.has('KEEP1'), true);
508
+ expect(ids.has('KEEP2'), true);
509
+ expect(ids.has('CON__C1'), false);
510
+ expect(ids.has('REV__R1'), false);
511
+ expect(ids.has('HIGHQ'), false);
512
+ expect(df.rowCount, 2);
513
+
514
+ // The crux: NO emitted info message labels the by-design-filtered rows
515
+ // "malformed" (this is the exact false corruption signal the gap reports).
516
+ const malformedMsgs = infos.filter((s) => /malformed/i.test(s));
517
+ expect(malformedMsgs.length, 0);
518
+ });
519
+
520
+ test('truncated line is counted malformed', async () => {
521
+ // A valid precursor fixture plus one deliberately truncated data line
522
+ // (fewer tab-separated fields than the 11-field header). The genuine
523
+ // malformed category MUST still surface — the fix narrows the message, it
524
+ // does not suppress it.
525
+ const validTsv = makeLongFormatTsv({
526
+ ...baseOpts,
527
+ proteins: [{id: 'KEEP1'}, {id: 'KEEP2'}],
528
+ });
529
+ const truncated = validTsv + '\n' + 'file\tCondA\t1';
530
+ const {df, infos} = await streamCapturingInfo(truncated);
531
+
532
+ // The valid proteins still produce a correct DataFrame.
533
+ const protCol = df.col('PG.ProteinGroups')!;
534
+ const ids = new Set<string>();
535
+ for (let i = 0; i < df.rowCount; i++)
536
+ ids.add(String(protCol.get(i)));
537
+ expect(ids.has('KEEP1'), true);
538
+ expect(ids.has('KEEP2'), true);
539
+ expect(df.rowCount, 2);
540
+
541
+ // The genuine-malformed completion message WAS emitted for the truncated row.
542
+ const surfaced = infos.some((s) => /skipped \d+ malformed line\(s\)/.test(s));
543
+ expect(surfaced, true);
544
+ });
545
+
546
+ test('empty-protein rows are filtered silently (streaming↔text parity)', async () => {
547
+ // Spectronaut routinely emits unassigned-precursor rows: a structurally
548
+ // complete line with PG.ProteinGroups blank. The text path (pivotSpectronaut)
549
+ // drops these silently — the streaming path must do the same, NOT count them
550
+ // toward the user-facing "skipped N malformed line(s)" message.
551
+ const tsv = makeLongFormatTsv({
552
+ ...baseOpts,
553
+ proteins: [{id: 'KEEP1'}, {id: 'KEEP2'}],
554
+ extraRows: [
555
+ {id: '', qValue: 0.001},
556
+ {id: '', qValue: 0.002},
557
+ ],
558
+ });
559
+ const {df, infos} = await streamCapturingInfo(tsv);
560
+
561
+ const protCol = df.col('PG.ProteinGroups')!;
562
+ const ids = new Set<string>();
563
+ for (let i = 0; i < df.rowCount; i++)
564
+ ids.add(String(protCol.get(i)));
565
+ expect(ids.has('KEEP1'), true);
566
+ expect(ids.has('KEEP2'), true);
567
+ expect(ids.has(''), false);
568
+ expect(df.rowCount, 2);
569
+
570
+ const malformedMsgs = infos.filter((s) => /malformed/i.test(s));
571
+ expect(malformedMsgs.length, 0);
572
+ });
573
+
574
+ test('both-numeric-casts-null rows are filtered silently (streaming↔text parity)', async () => {
575
+ // A precursor row where both PG.Quantity and EG.Qvalue cast to null is
576
+ // unusable for aggregation. The text path likewise has no contribution from
577
+ // such a row (ibaqCol.isNone gates the protein-map write, q-null-passes-as-
578
+ // non-numeric); categorizing it 'malformed' on the streaming path was a
579
+ // false-positive user message inherited from Phase 12. It must now be silent.
580
+ const tsv = makeLongFormatTsv({
581
+ ...baseOpts,
582
+ proteins: [{id: 'KEEP1'}, {id: 'KEEP2'}],
583
+ extraRows: [
584
+ {id: 'NULLPAIR', quantity: 'NaN', qValue: 'NA'},
585
+ ],
586
+ });
587
+ const {df, infos} = await streamCapturingInfo(tsv);
588
+
589
+ expect(df.rowCount, 2);
590
+ const malformedMsgs = infos.filter((s) => /malformed/i.test(s));
591
+ expect(malformedMsgs.length, 0);
592
+ });
593
+
594
+ test('streaming tag set and groups', async () => {
595
+ const df = await streamTsv(makeLongFormatTsv(baseOpts));
596
+ expect(df.getTag('proteomics.source'), 'spectronaut');
597
+ expect(df.getTag('proteomics.preNormalized'), 'true');
598
+ const g = getGroups(df);
599
+ expect(g !== null, true);
600
+ if (g) {
601
+ expect(g.group1.columns.length, 2);
602
+ expect(g.group2.columns.length, 2);
603
+ }
604
+ });
605
+
606
+ test('streaming output equals duckdb golden', async () => {
607
+ // The JSON sidecar is AUTHORITATIVE: Plan 01 derived it verbatim from the
608
+ // real duckdb golden .tsv (D-04) via a pure transcriber (no in-test
609
+ // re-aggregation). This test NEVER re-aggregates and has NO conditional
610
+ // fallback — if the fixture or sidecar cannot be read it FAILS loudly.
611
+ const golden = JSON.parse(
612
+ await readDemoFile('spectronaut-hye-precursor-golden.json')) as
613
+ Record<string, {quantity: number; qvalue: number}>;
614
+ const text = await readDemoFile('spectronaut-hye-precursor.tsv');
615
+ const df = await streamTsv(text);
616
+
617
+ const {map: protRow} = proteinRowIndex(df);
618
+ const sampleCols = rawSampleCols(df); // CondA_1.. / CondB_1.. raw columns
619
+
620
+ // Per-key (protein × condition_replicate) quantity equivalence within 1e-3.
621
+ const goldenKeys = Object.keys(golden);
622
+ expect(goldenKeys.length > 0, true);
623
+ for (const key of goldenKeys) {
624
+ // key = `${protein}${condition}_${replicate}` — split on the trailing
625
+ // `<Cond...>_<rep>` so multi-segment protein ids stay intact.
626
+ const m = key.match(/^(.*?)((?:Cond[A-Za-z]+)_\d+)$/);
627
+ expect(m !== null, true);
628
+ const protein = m![1];
629
+ const sampleKey = m![2];
630
+ const ri = protRow.get(protein);
631
+ expect(ri !== undefined, true);
632
+ const col = df.col(sampleKey);
633
+ expect(col !== null, true);
634
+ expect(col!.isNone(ri!), false);
635
+ const streamed = col!.get(ri!) as number;
636
+ expect(Math.abs(streamed - golden[key].quantity) <= 1e-3, true);
637
+ }
638
+
639
+ // The streamed (protein × sample) non-null key set must EQUAL the sidecar's
640
+ // exactly — no extra, no missing (catches a ported condition flip / filter
641
+ // mismatch: such a drift changes keys, not just values).
642
+ const streamedKeys = new Set<string>();
643
+ for (const [protein, ri] of protRow) {
644
+ for (const sc of sampleCols) {
645
+ const col = df.col(sc)!;
646
+ if (!col.isNone(ri))
647
+ streamedKeys.add(`${protein}${sc}`);
648
+ }
649
+ }
650
+ const goldenSet = new Set(goldenKeys);
651
+ expect(streamedKeys.size, goldenSet.size);
652
+ for (const k of streamedKeys)
653
+ expect(goldenSet.has(k), true);
654
+ for (const k of goldenSet)
655
+ expect(streamedKeys.has(k), true);
656
+ });
657
+
658
+ test('duckdb fallback tooling is committed', async () => {
659
+ // Lightweight R5 regression: the committed duckdb fallback was not deleted
660
+ // or renamed. aggregateSql / aggregateSh are the verbatim committed
661
+ // tools/spectronaut-aggregate.{sql,sh}, inlined by webpack asset/source
662
+ // (tools/ is not runtime-readable via _package.files).
663
+ expect(typeof aggregateSql === 'string' && aggregateSql.length > 0, true);
664
+ expect(typeof aggregateSh === 'string' && aggregateSh.length > 0, true);
665
+ expect(aggregateSql.includes('max(TRY_CAST'), true);
666
+ expect(aggregateSh.includes('spectronaut-aggregate.sql'), true);
667
+ });
668
+ });