@morscherlab/mint-sdk 1.0.28 → 1.0.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morscherlab/mint-sdk",
3
- "version": "1.0.28",
3
+ "version": "1.0.29",
4
4
  "description": "MINT Platform SDK — Vue 3 components, composables, and types for plugin development. MINT = Mass-spec INtegrated Toolkit.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -215,6 +215,74 @@ describe('extractSamplesFromDesignData', () => {
215
215
  })
216
216
  })
217
217
 
218
+ describe('useAutoGroup CSV structured hierarchy', () => {
219
+ it('uses CSV metadata columns as hierarchy without biological or QC class groups', () => {
220
+ const auto = useAutoGroup()
221
+ auto.inputMode.value = 'csv'
222
+ auto.csvData.value = parseCSV([
223
+ 'Sample,Tissue,Treatment,sample_type',
224
+ 'S1,Kidney,Drug,sample',
225
+ 'S2,Liver,Drug,sample',
226
+ 'QC_pool,,QC,qc',
227
+ ].join('\n'))
228
+ auto.parseInput()
229
+
230
+ expect(auto.classes.value).toHaveLength(1)
231
+ expect(auto.classes.value[0].kind).toBe('unknown')
232
+ expect(auto.qcGroups.value).toEqual([])
233
+ expect(auto.groups.value.map(g => g.name).sort()).toEqual([
234
+ 'Kidney / Drug',
235
+ 'Liver / Drug',
236
+ 'Outlier',
237
+ ])
238
+ expect(auto.groups.value.find(g => g.name === 'Outlier')?.samples).toEqual(['QC_pool'])
239
+ expect(auto.groups.value.some(g => g.name.includes('Biological'))).toBe(false)
240
+ expect(auto.groups.value.some(g => g.name === 'QC')).toBe(false)
241
+ })
242
+
243
+ it('defaults every CSV metadata column into the group key', () => {
244
+ const auto = useAutoGroup()
245
+ auto.inputMode.value = 'csv'
246
+ auto.csvData.value = parseCSV([
247
+ 'Sample,Batch,Tissue,Treatment,Timepoint',
248
+ 'S1,B1,Kidney,Drug,5min',
249
+ 'S2,B1,Kidney,Drug,10min',
250
+ ].join('\n'))
251
+ auto.parseInput()
252
+
253
+ expect(auto.activeSchema.value?.columns.map(c => c.name)).toEqual([
254
+ 'Batch',
255
+ 'Tissue',
256
+ 'Treatment',
257
+ 'Timepoint',
258
+ ])
259
+ expect(auto.activeSchema.value?.groupBy).toEqual([0, 1, 2, 3])
260
+ expect(auto.groups.value.map(g => g.name).sort()).toEqual([
261
+ 'B1 / Kidney / Drug / 10min',
262
+ 'B1 / Kidney / Drug / 5min',
263
+ ])
264
+ })
265
+
266
+ it('limits experiment design-data grouping to provided sample names', () => {
267
+ const auto = useAutoGroup()
268
+ const loaded = auto.loadExperimentData({
269
+ samples: [
270
+ { sample_name: 'S1', sample_type: 'sample', conditions: { Tissue: 'Kidney', Treatment: 'Control' } },
271
+ { sample_name: 'S2', sample_type: 'sample', conditions: { Tissue: 'Liver', Treatment: 'Drug' } },
272
+ { sample_name: 'S3', sample_type: 'sample', conditions: { Tissue: 'Brain', Treatment: 'Drug' } },
273
+ ],
274
+ }, { samples: ['S2', 'MissingFromDesign'] })
275
+
276
+ expect(loaded).toBe(true)
277
+ expect(auto.samples.value).toEqual(['S2', 'MissingFromDesign'])
278
+ expect(auto.groups.value.map(g => g.name).sort()).toEqual([
279
+ 'Liver / Drug',
280
+ 'Outlier',
281
+ ])
282
+ expect(auto.groups.value.find(g => g.name === 'Outlier')?.samples).toEqual(['MissingFromDesign'])
283
+ })
284
+ })
285
+
218
286
  describe('useAutoGroup auto-disable degenerate columns', () => {
219
287
  it('collapses unique-per-row injection numbers via replicate pre-grouping', () => {
220
288
  // The replicate pre-grouping pass strips the trailing 3-digit run-order
@@ -71,6 +71,26 @@ describe('useAutoGroupInputSources', () => {
71
71
  expect(sources.experimentLoading.value).toBe(false)
72
72
  })
73
73
 
74
+ it('filters loaded experiment metadata to the provided sample subset', async () => {
75
+ const { autoGroup, sources } = makeSources({
76
+ samples: ['S2', 'MissingFromDesign'],
77
+ designData: {
78
+ samples: [
79
+ { sample_name: 'S1', sample_type: 'sample', conditions: { Tissue: 'Kidney', Treatment: 'Control' } },
80
+ { sample_name: 'S2', sample_type: 'sample', conditions: { Tissue: 'Liver', Treatment: 'Drug' } },
81
+ ],
82
+ },
83
+ })
84
+
85
+ await sources.initializeInputSources()
86
+
87
+ expect(autoGroup.samples.value).toEqual(['S2', 'MissingFromDesign'])
88
+ expect(autoGroup.groups.value.map(g => g.name).sort()).toEqual([
89
+ 'Liver / Drug',
90
+ 'Outlier',
91
+ ])
92
+ })
93
+
74
94
  it('parses uploaded CSV files and can clear them back to paste mode', async () => {
75
95
  const { autoGroup, sources } = makeSources()
76
96
  const file = {
@@ -529,7 +529,7 @@ async function fetchExperimentData() {
529
529
  return
530
530
  }
531
531
 
532
- const success = autoGroup.loadExperimentData(pluginData)
532
+ const success = autoGroup.loadExperimentData(pluginData, { samples: props.samples })
533
533
  if (!success) {
534
534
  experimentError.value = 'No sample metadata found in this experiment'
535
535
  return
@@ -15,6 +15,8 @@ export interface ComposeInput {
15
15
  sampleNames: string[]
16
16
  schemas: Record<string, ClassSchema>
17
17
  classes: SampleClass[]
18
+ prefixClassLabels?: boolean
19
+ missingGroupName?: string
18
20
  }
19
21
 
20
22
  /** Apply the operation pipeline to a single raw token value:
@@ -116,13 +118,22 @@ export function composeGroups(input: ComposeInput): AutoGroupResult {
116
118
  // keyParts: overlay/exclude classes already use cls.label AS their
117
119
  // single key part, so prefixing again would produce "IQC / IQC". The
118
120
  // Unknown class also opts out — its label ("Unknown") adds no signal.
121
+ const hasMissingGroupPart =
122
+ input.missingGroupName &&
123
+ cls.disposition === 'group' &&
124
+ keyParts.some(part => part.trim().length === 0)
119
125
  const useClassPrefix =
120
- cls.disposition === 'group' && cls.kind !== 'unknown' && keyParts.length > 0
121
- const groupKey = useClassPrefix
122
- ? `${cls.label} / ${keyParts.join(' / ')}`
123
- : keyParts.length > 0
124
- ? keyParts.join(' / ')
125
- : cls.label
126
+ input.prefixClassLabels !== false &&
127
+ cls.disposition === 'group' &&
128
+ cls.kind !== 'unknown' &&
129
+ keyParts.length > 0
130
+ const groupKey = hasMissingGroupPart
131
+ ? input.missingGroupName!
132
+ : useClassPrefix
133
+ ? `${cls.label} / ${keyParts.join(' / ')}`
134
+ : keyParts.length > 0
135
+ ? keyParts.join(' / ')
136
+ : cls.label
126
137
 
127
138
  const bucket = groupMap.get(groupKey)
128
139
  if (bucket) {
@@ -35,6 +35,14 @@ import { unwrapExperimentDesignData } from './experimentDesignData'
35
35
 
36
36
  export { DEFAULT_COLORS } from './autoGroup'
37
37
 
38
+ const STRUCTURED_MISSING_GROUP = 'Outlier'
39
+
40
+ type ParsedExperimentCsvData = ParsedCsvData & { sampleTypeHints: (string | undefined)[] }
41
+
42
+ interface LoadExperimentDataOptions {
43
+ samples?: readonly string[]
44
+ }
45
+
38
46
  let deprecationWarned = false
39
47
  let outlierDeprecationWarned = false
40
48
  function warnDeprecated(api: string) {
@@ -104,30 +112,26 @@ export function useAutoGroup() {
104
112
  }
105
113
  const tokens = csv.rows.map(r => nonSampleCols.map(col => r[col] ?? ''))
106
114
  tokenized.value = tokens
107
- // Class detection: use explicit sample_type hint from csv.rows[i].sample_type
108
- const hints = csv.rows.map(r => {
109
- const t = String(r['sample_type'] ?? '').toLowerCase()
110
- if (t === 'qc') return 'qc'
111
- if (t === 'blank') return 'blank'
112
- return undefined
113
- })
114
- const detected = detectClass(tokens, { sampleTypeHints: hints })
115
- classes.value = detected
116
- // Build schema per class with CSV header names
117
- const newSchemas: Record<string, ClassSchema> = {}
118
- for (const cls of detected) {
119
- const memberTokens = cls.members.map(i => tokens[i])
120
- const schema = buildClassSchema(memberTokens, cls.kind, cls.subKind, cls.classTagPositions)
121
- // Override default Token N names with actual CSV header names
122
- schema.columns = schema.columns.map((c, i) => ({
123
- ...c,
124
- name: nonSampleCols[i] ?? c.name,
125
- originalName: nonSampleCols[i],
126
- }))
127
- newSchemas[classKey(cls)] = schema
115
+ const structuredClass: SampleClass = {
116
+ kind: 'unknown',
117
+ label: 'Unknown',
118
+ members: csv.rows.map((_, i) => i),
119
+ classTagPositions: [],
120
+ disposition: 'group',
128
121
  }
129
- schemas.value = newSchemas
130
- if (detected.length > 0) activeClassKey.value = classKey(detected[0])
122
+ classes.value = [structuredClass]
123
+
124
+ const schema = buildClassSchema(tokens, 'unknown', undefined, [])
125
+ schema.columns = schema.columns.map((c, i) => ({
126
+ ...c,
127
+ name: nonSampleCols[i] ?? c.name,
128
+ originalName: nonSampleCols[i],
129
+ }))
130
+ // CSV headers are already the user-authored hierarchy. Default to every
131
+ // metadata column so N CSV columns produce N visible grouping levels.
132
+ schema.groupBy = schema.columns.map(c => c.index)
133
+ schemas.value = { unknown: schema }
134
+ activeClassKey.value = 'unknown'
131
135
  }
132
136
 
133
137
  function parseInputFromPaste() {
@@ -193,6 +197,10 @@ export function useAutoGroup() {
193
197
  sampleNames: pre ? pre.baseNames : samples.value,
194
198
  schemas: schemas.value,
195
199
  classes: classes.value,
200
+ prefixClassLabels: !(csvData.value && (inputMode.value === 'csv' || inputMode.value === 'experiment')),
201
+ missingGroupName: csvData.value && (inputMode.value === 'csv' || inputMode.value === 'experiment')
202
+ ? STRUCTURED_MISSING_GROUP
203
+ : undefined,
196
204
  })
197
205
  if (!pre) return composed
198
206
  // Expand each group's `samples` from base names back to the original
@@ -416,8 +424,11 @@ export function useAutoGroup() {
416
424
  if (activeClassKey.value) setColumnName(activeClassKey.value, index, name)
417
425
  }
418
426
 
419
- function loadExperimentData(rawData: Record<string, unknown>): boolean {
420
- const parsed = extractSamplesFromDesignData(rawData)
427
+ function loadExperimentData(
428
+ rawData: Record<string, unknown>,
429
+ options: LoadExperimentDataOptions = {},
430
+ ): boolean {
431
+ const parsed = extractSamplesFromDesignData(rawData, options)
421
432
  if (!parsed) return false
422
433
  inputMode.value = 'experiment'
423
434
  csvData.value = parsed
@@ -460,7 +471,8 @@ export function useAutoGroup() {
460
471
 
461
472
  export function extractSamplesFromDesignData(
462
473
  rawData: Record<string, unknown>,
463
- ): (ParsedCsvData & { sampleTypeHints: (string | undefined)[] }) | null {
474
+ options: LoadExperimentDataOptions = {},
475
+ ): ParsedExperimentCsvData | null {
464
476
  const designData = unwrapExperimentDesignData(rawData)
465
477
  if (!designData) return null
466
478
  const samples = designData.samples
@@ -492,5 +504,51 @@ export function extractSamplesFromDesignData(
492
504
  for (const key of allConditionKeys) row[key] = conditions[key] ?? ''
493
505
  return row
494
506
  })
495
- return { columns, rows, sampleColumn: 'sample_name', delimiter: ',', sampleTypeHints }
507
+ return constrainParsedSamplesToInput(
508
+ { columns, rows, sampleColumn: 'sample_name', delimiter: ',', sampleTypeHints },
509
+ options.samples,
510
+ )
511
+ }
512
+
513
+ function constrainParsedSamplesToInput(
514
+ parsed: ParsedExperimentCsvData,
515
+ samples: readonly string[] | undefined,
516
+ ): ParsedExperimentCsvData {
517
+ const requested = samples
518
+ ?.map(sample => sample.trim())
519
+ .filter(sample => sample.length > 0)
520
+ if (!requested || requested.length === 0) return parsed
521
+
522
+ const rowBySample = new Map<string, { row: Record<string, string>; hint: string | undefined }>()
523
+ parsed.rows.forEach((row, index) => {
524
+ const sample = row[parsed.sampleColumn]
525
+ if (sample && !rowBySample.has(sample)) {
526
+ rowBySample.set(sample, { row, hint: parsed.sampleTypeHints[index] })
527
+ }
528
+ })
529
+
530
+ const metadataColumns = parsed.columns.filter(
531
+ column => column !== parsed.sampleColumn && column !== 'sample_type',
532
+ )
533
+ const rows: Record<string, string>[] = []
534
+ const sampleTypeHints: (string | undefined)[] = []
535
+
536
+ for (const sample of requested) {
537
+ const match = rowBySample.get(sample)
538
+ if (match) {
539
+ rows.push(match.row)
540
+ sampleTypeHints.push(match.hint)
541
+ continue
542
+ }
543
+
544
+ const outlierRow: Record<string, string> = {
545
+ [parsed.sampleColumn]: sample,
546
+ sample_type: 'sample',
547
+ }
548
+ for (const column of metadataColumns) outlierRow[column] = ''
549
+ rows.push(outlierRow)
550
+ sampleTypeHints.push(undefined)
551
+ }
552
+
553
+ return { ...parsed, rows, sampleTypeHints }
496
554
  }
@@ -64,7 +64,7 @@ export function useAutoGroupInputSources(options: UseAutoGroupInputSourcesOption
64
64
  return
65
65
  }
66
66
 
67
- const success = options.autoGroup.loadExperimentData(pluginData)
67
+ const success = options.autoGroup.loadExperimentData(pluginData, { samples: options.samples.value })
68
68
  if (!success) {
69
69
  experimentError.value = 'No sample metadata found in this experiment'
70
70
  return