@morscherlab/mint-sdk 1.3.0 → 1.3.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 (67) hide show
  1. package/dist/__tests__/components/AutoGroupModal.anatomy.test.d.ts +1 -0
  2. package/dist/__tests__/components/AutoGroupModal.wiring.test.d.ts +1 -0
  3. package/dist/__tests__/composables/autoGroup/fieldEdits.test.d.ts +1 -0
  4. package/dist/__tests__/composables/autoGroup/golden.test.d.ts +1 -0
  5. package/dist/components/AutoGroupModal.adapter.d.ts +27 -4
  6. package/dist/components/SmartGroup.types.d.ts +22 -0
  7. package/dist/components/SmartGroupFieldRecipe.groups.d.ts +10 -2
  8. package/dist/components/SmartGroupFieldRecipe.vue.d.ts +3 -0
  9. package/dist/components/index.js +1 -1
  10. package/dist/{components-vHfxV2PB.js → components-bbanLd1u.js} +499 -263
  11. package/dist/components-bbanLd1u.js.map +1 -0
  12. package/dist/composables/autoGroup/classify.d.ts +7 -1
  13. package/dist/composables/autoGroup/columns.d.ts +16 -1
  14. package/dist/composables/autoGroup/index.d.ts +5 -7
  15. package/dist/composables/autoGroup/tokenize.d.ts +36 -1
  16. package/dist/composables/index.js +2 -2
  17. package/dist/composables/useAutoGroup.d.ts +13 -2
  18. package/dist/{composables-D6uqp0UF.js → composables-BbHozlA7.js} +2 -2
  19. package/dist/{composables-D6uqp0UF.js.map → composables-BbHozlA7.js.map} +1 -1
  20. package/dist/index.js +3 -3
  21. package/dist/install.js +1 -1
  22. package/dist/permissions.js +2 -2
  23. package/dist/permissions.js.map +1 -1
  24. package/dist/styles.css +71 -82
  25. package/dist/types/auto-group.d.ts +1 -1
  26. package/dist/{validation-Dmp9Q5W0.js → validation-CukfLjy-.js} +323 -187
  27. package/dist/validation-CukfLjy-.js.map +1 -0
  28. package/package.json +2 -2
  29. package/src/__tests__/components/AutoGroupModal.anatomy.test.ts +86 -0
  30. package/src/__tests__/components/AutoGroupModal.wiring.test.ts +53 -0
  31. package/src/__tests__/components/SampleSelector.test.ts +14 -16
  32. package/src/__tests__/components/SmartGroupFieldRecipe.groups.test.ts +23 -0
  33. package/src/__tests__/components/SmartGroupFieldRecipe.test.ts +176 -0
  34. package/src/__tests__/composables/autoGroup/columns.test.ts +100 -11
  35. package/src/__tests__/composables/autoGroup/fieldEdits.test.ts +74 -0
  36. package/src/__tests__/composables/autoGroup/fingerprint.test.ts +9 -0
  37. package/src/__tests__/composables/autoGroup/golden.test.ts +187 -0
  38. package/src/__tests__/composables/autoGroup/groupByOrder.test.ts +7 -7
  39. package/src/__tests__/composables/autoGroup/tokenLength.test.ts +7 -0
  40. package/src/__tests__/composables/autoGroup/tokenize.test.ts +78 -1
  41. package/src/__tests__/composables/useAutoGroup.test.ts +2 -1
  42. package/src/__tests__/fixtures/auto-group/share-exp021.txt +103 -0
  43. package/src/__tests__/fixtures/auto-group/share-exp031.txt +39 -0
  44. package/src/__tests__/fixtures/auto-group/share-exp114.txt +123 -0
  45. package/src/__tests__/fixtures/auto-group/share-exp214.txt +63 -0
  46. package/src/__tests__/utils/permissions.test.ts +15 -0
  47. package/src/components/AutoGroupModal.adapter.ts +80 -9
  48. package/src/components/AutoGroupModal.vue +41 -8
  49. package/src/components/SmartGroup.types.ts +23 -0
  50. package/src/components/SmartGroupFieldRecipe.groups.ts +14 -2
  51. package/src/components/SmartGroupFieldRecipe.vue +289 -169
  52. package/src/composables/autoGroup/classify.ts +26 -7
  53. package/src/composables/autoGroup/columns.ts +144 -36
  54. package/src/composables/autoGroup/compose.ts +8 -10
  55. package/src/composables/autoGroup/fingerprint.ts +1 -1
  56. package/src/composables/autoGroup/index.ts +5 -12
  57. package/src/composables/autoGroup/tokenLength.ts +8 -0
  58. package/src/composables/autoGroup/tokenize.ts +148 -1
  59. package/src/composables/autoGroup/vocab.json +5 -11
  60. package/src/composables/useAutoGroup.ts +92 -79
  61. package/src/permissions.ts +3 -2
  62. package/src/styles/components/smart-group.css +71 -82
  63. package/src/types/auto-group.ts +1 -0
  64. package/dist/components-vHfxV2PB.js.map +0 -1
  65. package/dist/composables/autoGroup/replicatePreGroup.d.ts +0 -36
  66. package/dist/validation-Dmp9Q5W0.js.map +0 -1
  67. package/src/composables/autoGroup/replicatePreGroup.ts +0 -124
@@ -56,6 +56,22 @@ export interface DetectClassOptions {
56
56
  vocab?: Partial<ClassVocab>
57
57
  /** Per-sample explicit `sample_type` hint (e.g. from experiment design data). */
58
58
  sampleTypeHints?: (string | undefined)[]
59
+ /** Number of leading tokens that all samples share (`TokenizedBatch.sharedPrefix`). */
60
+ sharedPrefix?: number
61
+ }
62
+
63
+ /** A token that names a QC-type class (blank, QC, standard, spike-in, stock). */
64
+ export function isClassMarker(token: string, vocab: ClassVocab = classVocab): boolean {
65
+ return (
66
+ vocab.blankPattern.test(token) || vocab.iqcPattern.test(token) || vocab.eqcPattern.test(token)
67
+ || vocab.standardPattern.test(token) || vocab.standardAllow.has(token)
68
+ || vocab.spikeInPattern.test(token) || vocab.stockPattern.test(token)
69
+ )
70
+ }
71
+
72
+ /** Kinds that form experimental groups; all other kinds are QC-type overlay classes. */
73
+ export function isExperimentalKind(kind: SampleClassKind): boolean {
74
+ return kind === 'biological' || kind === 'unknown'
59
75
  }
60
76
 
61
77
  const DEFAULT_DISPOSITION: Record<SampleClassKind, ClassDisposition> = {
@@ -72,26 +88,29 @@ const DEFAULT_DISPOSITION: Record<SampleClassKind, ClassDisposition> = {
72
88
  function classifyTokens(
73
89
  tokens: string[],
74
90
  vocab: ClassVocab,
91
+ qcFrom: number,
75
92
  ): { kind: SampleClassKind; subKind?: string; tagPositions: number[] } {
76
93
  // First-match-wins ordering: Blank → IQC → EQC → Standard → SpikeIn → Stock → Biological → Unknown.
77
- for (let i = 0; i < tokens.length; i++) {
94
+ // QC kinds are read from `qcFrom`: a token that all names share (the
95
+ // experiment title) cannot mark one sample as QC.
96
+ for (let i = qcFrom; i < tokens.length; i++) {
78
97
  if (vocab.blankPattern.test(tokens[i])) return { kind: 'blank', tagPositions: [i] }
79
98
  }
80
- for (let i = 0; i < tokens.length; i++) {
99
+ for (let i = qcFrom; i < tokens.length; i++) {
81
100
  if (vocab.iqcPattern.test(tokens[i])) return { kind: 'iqc', tagPositions: [i] }
82
101
  }
83
- for (let i = 0; i < tokens.length; i++) {
102
+ for (let i = qcFrom; i < tokens.length; i++) {
84
103
  if (vocab.eqcPattern.test(tokens[i])) return { kind: 'eqc', tagPositions: [i] }
85
104
  }
86
- for (let i = 0; i < tokens.length; i++) {
105
+ for (let i = qcFrom; i < tokens.length; i++) {
87
106
  if (vocab.standardPattern.test(tokens[i]) || vocab.standardAllow.has(tokens[i])) {
88
107
  return { kind: 'standard', tagPositions: [i] }
89
108
  }
90
109
  }
91
- for (let i = 0; i < tokens.length; i++) {
110
+ for (let i = qcFrom; i < tokens.length; i++) {
92
111
  if (vocab.spikeInPattern.test(tokens[i])) return { kind: 'spike-in', tagPositions: [i] }
93
112
  }
94
- for (let i = 0; i < tokens.length; i++) {
113
+ for (let i = qcFrom; i < tokens.length; i++) {
95
114
  if (vocab.stockPattern.test(tokens[i])) return { kind: 'stock', tagPositions: [i] }
96
115
  }
97
116
  for (let i = 0; i < tokens.length; i++) {
@@ -168,7 +187,7 @@ export function detectClass(
168
187
  kind = 'blank'
169
188
  tagPositions = []
170
189
  } else {
171
- const r = classifyTokens(tokenizedSamples[i], vocab)
190
+ const r = classifyTokens(tokenizedSamples[i], vocab, options.sharedPrefix ?? 0)
172
191
  kind = r.kind
173
192
  subKind = r.subKind
174
193
  tagPositions = r.tagPositions
@@ -5,15 +5,40 @@ import type {
5
5
  NumericParse,
6
6
  SampleClassKind,
7
7
  } from '../../types/auto-group'
8
+ import vocabData from './vocab.json'
8
9
 
9
- const NUMERIC_VALUE_UNIT = /^(\d+(?:dot\d+)?)([A-Za-z%/μ]+)$/
10
+ const NUMERIC_VALUE_UNIT = /^(\d+(?:(?:dot|\.)\d+)?)([A-Za-z%/μ]+)$/
10
11
  const RATIO = /^(\d+)to(\d+)$/
11
12
  const RUN_ORDER_PATTERN = /^\d{2,4}[A-Z]?$/
12
- // Explicit replicate-token vocabulary: rep1 / Rep2 / REP3 / replicate1 / R1.
13
+ // Explicit replicate-token vocabulary: rep1 / Rep2 / replicate1 / R1 / T1.
13
14
  // These tokens almost always identify a within-group replicate dimension that
14
15
  // users do NOT want in the default group key — e.g. ctrl/rep1, ctrl/rep2
15
16
  // should land in one Ctrl group with two replicates, not two singleton groups.
16
- const REPLICATE_TOKEN_PATTERN = /^(?:rep(?:licate)?|r)\d+$/i
17
+ const REPLICATE_TOKEN_PATTERN = new RegExp(vocabData.replicateToken, 'i')
18
+ /**
19
+ * A replicate counter is a dense run of small numbers or letters: `1, 2, 3`,
20
+ * `01, 02, 03` or `A, B, C`. A quarter of the run can be missing (a failed
21
+ * sample). `1, 5, 10` is a dose and `1, 2, 6, 7` an animal number: too sparse.
22
+ */
23
+ function isCounterRun(values: string[]): boolean {
24
+ if (!values.every(value => /^(\d{1,2}|[A-Za-z])$/.test(value))) return false
25
+ // One batch can count some replicates 1, 2, 3 and others a, b, c.
26
+ const ranks = [...new Set(values.map(value => (/\d/.test(value) ? Number(value) : value.toUpperCase().charCodeAt(0) - 64)))]
27
+ const allowedGaps = Math.max(1, Math.floor(ranks.length / 4))
28
+ return Math.min(...ranks) >= 1 && Math.max(...ranks) <= ranks.length + allowedGaps
29
+ }
30
+
31
+ // A stem with a counter attached: C1, C2, H1.
32
+ const STEM_COUNTER_PATTERN = /^([A-Za-z]+)(\d{1,2})$/
33
+ // Share of a column that must agree before the column is split or read as
34
+ // one-value-for-each-sample. The rest allows for odd names and re-injections.
35
+ const COLUMN_AGREEMENT = 0.9
36
+ const ID_MIN_CLASS_SIZE = 4
37
+
38
+ /** Roles the Field Recipe offers as grouping fields. `replicate` and `id` are off by default. */
39
+ export const GROUPING_FIELD_ROLES: ReadonlySet<ColumnRole | undefined> = new Set<ColumnRole>([
40
+ 'factor', 'numeric', 'replicate', 'id',
41
+ ])
17
42
 
18
43
  // Real-world LC-MS batches often have 30+ distinct conditions (subject IDs,
19
44
  // calibration levels, treatment combinations) in a single class. The earlier
@@ -48,16 +73,27 @@ export interface InferRoleInput {
48
73
  position: number
49
74
  totalPositions: number
50
75
  classSize: number
76
+ /** The column ends a name or a split token, where replicate counters sit. Defaults to the last position. */
77
+ tail?: boolean
51
78
  }
52
79
 
53
80
  export function inferRole(input: InferRoleInput): ColumnRole {
54
81
  const { values, position, totalPositions, classSize } = input
82
+ const tail = input.tail ?? position === totalPositions - 1
55
83
  const unique = new Set(values)
56
84
  const cardinality = unique.size
57
85
 
58
86
  if (cardinality === 1) return 'constant'
59
87
 
60
- // run-order: trailing position, all values match \d{2,4}[A-Z]? and cardinality is high.
88
+ // replicate: explicit names (rep1, R1, T1), or a small counter that repeats
89
+ // at the end of a name (1, 2, 3, 1, 2, 3). Empty cells come from names that
90
+ // a column split did not match.
91
+ const present = values.filter(Boolean)
92
+ if (present.every(v => REPLICATE_TOKEN_PATTERN.test(v))) return 'replicate'
93
+ if (tail && cardinality * 2 <= values.length && isCounterRun(present)) return 'replicate'
94
+
95
+ // run-order: trailing position, injection-like values and high cardinality.
96
+ // Numbers repeat when one batch holds two experiments, so half is enough.
61
97
  if (position === totalPositions - 1) {
62
98
  const allRunLike = values.every(v => RUN_ORDER_PATTERN.test(v))
63
99
  if (allRunLike && cardinality >= classSize * 0.5) return 'run-order'
@@ -67,31 +103,90 @@ export function inferRole(input: InferRoleInput): ColumnRole {
67
103
  const allNumeric = values.every(v => parseNumeric(v) !== null)
68
104
  if (allNumeric) {
69
105
  // A column like 5min / 10min reads as a factor in practice — users want to
70
- // group on the timepoint, not bin it. Numeric binning UI is opt-in via the
71
- // column popover for columns the user explicitly wants binned.
106
+ // group on the timepoint, not bin it.
72
107
  if (cardinality <= LOW_CARDINALITY_NUMERIC_AS_FACTOR_MAX) return 'factor'
73
108
  return 'numeric'
74
109
  }
75
110
 
76
- // replicate by explicit name (rep1, Rep2, replicate3, R1) — regardless of
77
- // cardinality. The cardinality-based ID_PATTERN check that used to live
78
- // here (allId && cardinality >= classSize * 0.5) was removed: with the
79
- // replicate pre-grouping pass running upstream, real replicates have
80
- // already been collapsed by name, so a column like C1/C2/M1/M2 reaching
81
- // role inference is almost certainly a distinct-condition factor, not a
82
- // replicate. Users who want a residual replicate column can still mark
83
- // it via the Workspace popover.
84
- const allReplicateNamed = values.every(v => REPLICATE_TOKEN_PATTERN.test(v))
85
- if (allReplicateNamed) return 'replicate'
111
+ // id: one value for (almost) each sample, (almost) all with a digit — an
112
+ // animal or sample number (212, M10, C01). Grouping by it gives groups of
113
+ // one, so it is off by default. Unique words (Ctrl, DrugA, DrugB) are
114
+ // conditions of a design with no replicates and stay a factor.
115
+ if (
116
+ values.length >= ID_MIN_CLASS_SIZE
117
+ && cardinality >= values.length * COLUMN_AGREEMENT
118
+ && values.filter(v => /\d/.test(v)).length >= values.length * COLUMN_AGREEMENT
119
+ ) return 'id'
86
120
 
87
121
  if (cardinality <= DEFAULT_FACTOR_CARDINALITY_MAX) return 'factor'
88
122
 
89
123
  return 'ignore'
90
124
  }
91
125
 
126
+ /**
127
+ * How a column splits when most of its values agree: on `-` into equal parts
128
+ * (`2-1` → `2`, `1`), or into stem + counter (`C1` → `C`, `1`) when two or more
129
+ * stems repeat with different counters. `D1`/`D3` (one stem) and `B13`/`S13`
130
+ * (one counter for each stem) stay whole.
131
+ */
132
+ function columnSplitter(values: string[]): ((value: string) => string[]) | undefined {
133
+ const agree = (test: (value: string) => boolean): boolean =>
134
+ values.filter(test).length >= values.length * COLUMN_AGREEMENT
135
+
136
+ const partCounts = new Map<number, number>()
137
+ for (const value of values) {
138
+ const parts = value.split('-').length
139
+ partCounts.set(parts, (partCounts.get(parts) ?? 0) + 1)
140
+ }
141
+ const parts = [...partCounts].sort((a, b) => b[1] - a[1])[0]?.[0] ?? 1
142
+ if (parts > 1 && agree(value => value.split('-').length === parts)) {
143
+ return value => {
144
+ const pieces = value.split('-')
145
+ return pieces.length === parts ? pieces : [value, ...Array<string>(parts - 1).fill('')]
146
+ }
147
+ }
148
+
149
+ const countersByStem = new Map<string, Set<string>>()
150
+ for (const value of values) {
151
+ const match = STEM_COUNTER_PATTERN.exec(value)
152
+ if (match) countersByStem.set(match[1], (countersByStem.get(match[1]) ?? new Set()).add(match[2]))
153
+ }
154
+ const repeated = [...countersByStem.values()].filter(counters => counters.size > 1).length
155
+ if (countersByStem.size > 1 && repeated * 2 >= countersByStem.size && agree(value => STEM_COUNTER_PATTERN.test(value))) {
156
+ return value => STEM_COUNTER_PATTERN.exec(value)?.slice(1) ?? [value, '']
157
+ }
158
+ return undefined
159
+ }
160
+
161
+ export interface ColumnSplit {
162
+ rows: string[][]
163
+ /** `origin[newIndex]` = index of the source column before the split. */
164
+ origin: number[]
165
+ /** New indices that end a split token. */
166
+ tail: number[]
167
+ }
168
+
169
+ /** Split the columns of one class (rows of equal length) that hold two fields in one token. */
170
+ export function splitColumns(rows: string[][]): ColumnSplit {
171
+ const width = rows[0]?.length ?? 0
172
+ const splitters = Array.from({ length: width }, (_, col) => columnSplitter(rows.map(row => row[col] ?? '')))
173
+ const origin: number[] = []
174
+ const tail: number[] = []
175
+ splitters.forEach((splitter, col) => {
176
+ const parts = splitter ? splitter(rows[0][col]).length : 1
177
+ for (let part = 0; part < parts; part++) origin.push(col)
178
+ if (parts > 1) tail.push(origin.length - 1)
179
+ })
180
+ return {
181
+ rows: rows.map(row => row.flatMap((token, col) => splitters[col]?.(token) ?? [token])),
182
+ origin,
183
+ tail,
184
+ }
185
+ }
186
+
92
187
  /**
93
188
  * Auto-detect a friendlier column name from the column's values + role.
94
- * Returns null when nothing matches, so callers can fall back to "Token N".
189
+ * Returns null when nothing matches, so callers can fall back to "Field N".
95
190
  *
96
191
  * Heuristics are content-derived and deterministic. They cover the common
97
192
  * LC-MS / proteomics naming conventions; users can override anything via
@@ -149,12 +244,13 @@ function inferColumnName(
149
244
  // Experiment / study identifiers (exp268, study42, expt7).
150
245
  if (values.every(v => /^(exp|expt|study)\d+$/i.test(v))) return 'Experiment'
151
246
 
152
- // Operator / Username (2-3 uppercase letters per value, low cardinality).
153
- // Constant or small-set 2-3-letter all-caps columns are almost always
154
- // operator initials (LCK, JD, AB). Larger 2-4-letter columns could be
155
- // project codes — disambiguation happens after the loop via the
156
- // `Username → Project` post-pass.
157
- if (uniqueValues.length <= 4 && values.every(v => /^[A-Z]{2,3}$/.test(v))) return 'Username'
247
+ // Operator / Username: a constant column of 2-3 uppercase letters is almost
248
+ // always operator initials (LCK, JD, AB). A column with two or more values
249
+ // (WT / KO) is a factor and gets no name here.
250
+ if (uniqueValues.length === 1 && /^[A-Z]{2,3}$/.test(uniqueValues[0])) return 'Username'
251
+
252
+ if (role === 'replicate') return 'Replicate'
253
+ if (role === 'id') return 'Sample ID'
158
254
 
159
255
  return null
160
256
  }
@@ -164,14 +260,20 @@ export function buildClassSchema(
164
260
  classKind: SampleClassKind,
165
261
  subKind: string | undefined,
166
262
  classTagPositions: number[],
263
+ /** Positions that end a split token (from `splitColumns`). */
264
+ tailPositions: number[] = [],
167
265
  ): ClassSchema {
168
266
  const totalPositions = tokenizedMembers[0]?.length ?? 0
169
267
  const classSize = tokenizedMembers.length
170
268
 
171
269
  const columns: ColumnInfo[] = []
172
270
  const tagSet = new Set(classTagPositions)
271
+ // Replicate counters sit at the end of a name: the last position, or the one
272
+ // before the injection number.
273
+ const tailSet = new Set([...tailPositions, totalPositions - 1])
173
274
 
174
- for (let pos = 0; pos < totalPositions; pos++) {
275
+ // Right to left, so the injection column is known before its neighbour.
276
+ for (let pos = totalPositions - 1; pos >= 0; pos--) {
175
277
  const values = tokenizedMembers.map(t => t[pos] ?? '')
176
278
  const uniqueValues = [...new Set(values)]
177
279
 
@@ -182,8 +284,12 @@ export function buildClassSchema(
182
284
  // used as factor columns for grouping.
183
285
  role = 'class-tag'
184
286
  } else {
185
- role = inferRole({ values, position: pos, totalPositions, classSize })
287
+ role = inferRole({ values, position: pos, totalPositions, classSize, tail: tailSet.has(pos) })
186
288
  }
289
+ // Injection numbers with no zero padding (7, 18, 20) read as an id column
290
+ // at the last position; a counter can sit before either form.
291
+ const lastPosition = pos === totalPositions - 1
292
+ if (role === 'run-order' || (lastPosition && role === 'id')) tailSet.add(pos - 1)
187
293
 
188
294
  const numeric =
189
295
  role === 'numeric'
@@ -197,9 +303,9 @@ export function buildClassSchema(
197
303
  : undefined
198
304
 
199
305
  const inferredName = inferColumnName(values, uniqueValues, role)
200
- columns.push({
306
+ columns.unshift({
201
307
  index: pos,
202
- name: inferredName ?? `Token ${pos + 1}`,
308
+ name: inferredName ?? `Field ${pos + 1}`,
203
309
  sourceIndices: [pos],
204
310
  uniqueValues,
205
311
  cardinality: uniqueValues.length,
@@ -210,17 +316,19 @@ export function buildClassSchema(
210
316
  })
211
317
  }
212
318
 
213
- // Disambiguate Username when there are multiple constant 2-4 letter columns:
214
- // first hit stays "Username", subsequent ones become "Project".
215
- let usernameAssigned = false
216
- for (const c of columns) {
217
- if (c.name === 'Username') {
218
- if (usernameAssigned) c.name = 'Project'
219
- else usernameAssigned = true
220
- }
319
+ // The name is the field's identity across classes, so it must be unique in
320
+ // a schema: a second `Date` or `Replicate` becomes `Date 2`. Grouping fields
321
+ // take their names first, so a card keeps `Matrix` and the constant class
322
+ // tag beside it becomes `Matrix 2`.
323
+ const nameCounts = new Map<string, number>()
324
+ const isField = (c: ColumnInfo): boolean => GROUPING_FIELD_ROLES.has(c.role)
325
+ for (const c of [...columns.filter(isField), ...columns.filter(c => !isField(c))]) {
326
+ const count = (nameCounts.get(c.name) ?? 0) + 1
327
+ nameCounts.set(c.name, count)
328
+ if (count > 1) c.name = `${c.name} ${count}`
221
329
  }
222
330
 
223
- const groupBy = columns.filter(c => c.role === 'factor').map(c => c.index)
331
+ const groupBy = columns.filter(c => c.role === 'factor' || c.role === 'numeric').map(c => c.index)
224
332
 
225
333
  return { classKind, subKind, columns, groupBy }
226
334
  }
@@ -154,22 +154,20 @@ export function composeGroups(input: ComposeInput): AutoGroupResult {
154
154
  // above) so group names from different classes don't collide when they
155
155
  // share a factor value (e.g. two tissue classes both emitting "5min").
156
156
  //
157
- // Only fires for disposition === 'group' classes with actual factor
158
- // keyParts: overlay/exclude classes already use cls.label AS their
159
- // single key part, so prefixing again would produce "IQC / IQC". The
160
- // Unknown class also opts out — its label ("Unknown") adds no signal.
157
+ // Only fires for disposition === 'group' classes (`classPrefix` holds no
158
+ // other class): overlay/exclude classes already use cls.label AS their
159
+ // single key part, so prefixing again would produce "IQC / IQC". A class
160
+ // with one condition has no factor keyParts and is named by the prefix
161
+ // alone ("Brain" next to "Liver / Ctrl"). The Unknown class opts out —
162
+ // its label ("Unknown") adds no signal.
161
163
  const hasMissingGroupPart =
162
164
  input.missingGroupName &&
163
165
  cls.disposition === 'group' &&
164
166
  keyParts.some(part => part.trim().length === 0)
165
- const prefix = cls.kind !== 'unknown' && keyParts.length > 0 ? classPrefix.get(cls) : undefined
167
+ const prefix = cls.kind !== 'unknown' ? classPrefix.get(cls) : undefined
166
168
  const groupKey = hasMissingGroupPart
167
169
  ? input.missingGroupName!
168
- : prefix
169
- ? `${prefix} / ${keyParts.join(' / ')}`
170
- : keyParts.length > 0
171
- ? keyParts.join(' / ')
172
- : cls.label
170
+ : [...(prefix ? [prefix] : []), ...keyParts].join(' / ') || cls.label
173
171
 
174
172
  const bucket = groupMap.get(groupKey)
175
173
  if (bucket) {
@@ -34,7 +34,7 @@ export function restoreFingerprint(
34
34
  c =>
35
35
  c.classKind === snap.kind &&
36
36
  c.subKind === snap.subKind &&
37
- (snap.tokenLength == null || c.tokenLength === snap.tokenLength),
37
+ (snap.tokenLength == null || c.tokenLength == null || c.tokenLength === snap.tokenLength),
38
38
  )
39
39
  const target =
40
40
  candidates.find(c => c.columns.length === snap.columns.length) ?? candidates[0]
@@ -1,12 +1,12 @@
1
1
  // Internal engine for `useAutoGroup` / `AutoGroupModal`. Deprecated: scheduled
2
2
  // for removal in MINT 1.4 together with `useAutoGroup`; no SDK replacement.
3
3
 
4
- export { splitMulti, pickPrimaryDelimiter } from './tokenize'
5
- export type { Delimiter } from './tokenize'
6
- export { detectClass, classVocab } from './classify'
4
+ export { splitMulti, pickPrimaryDelimiter, tokenizeBatch } from './tokenize'
5
+ export type { Delimiter, ExperimentHeader, TokenizedBatch } from './tokenize'
6
+ export { detectClass, classVocab, isExperimentalKind } from './classify'
7
7
  export type { ClassVocab, DetectClassOptions } from './classify'
8
- export { inferRole, parseNumeric, buildClassSchema } from './columns'
9
- export type { InferRoleInput } from './columns'
8
+ export { inferRole, parseNumeric, buildClassSchema, splitColumns, GROUPING_FIELD_ROLES } from './columns'
9
+ export type { InferRoleInput, ColumnSplit } from './columns'
10
10
  export { findMerges } from './cooccurrence'
11
11
  export { composeGroups } from './compose'
12
12
  export type { ComposeInput } from './compose'
@@ -15,11 +15,4 @@ export { composeTemplate } from './template'
15
15
  export type { TemplateOptions } from './template'
16
16
  export { DEFAULT_COLORS, QC_OVERLAY_COLOR } from './colors'
17
17
  export { classKey } from './classKey'
18
- export {
19
- stripReplicateTokens,
20
- preGroupReplicates,
21
- expandGroupsWithReplicates,
22
- expandTreeWithReplicates,
23
- } from './replicatePreGroup'
24
- export type { ReplicatePreGrouping } from './replicatePreGroup'
25
18
  export { splitByTokenLength } from './tokenLength'
@@ -1,4 +1,5 @@
1
1
  import type { SampleClass } from '../../types/auto-group'
2
+ import { isExperimentalKind } from './classify'
2
3
 
3
4
  /**
4
5
  * Token-length pre-grouping.
@@ -22,6 +23,13 @@ export function splitByTokenLength(
22
23
  const out: SampleClass[] = []
23
24
 
24
25
  for (const cls of classes) {
26
+ // QC-kind classes (QC, blank, standard) are one group named by the class
27
+ // label and never group by column, so they need no rectangular matrix.
28
+ if (!isExperimentalKind(cls.kind)) {
29
+ out.push(cls)
30
+ continue
31
+ }
32
+
25
33
  // Bucket members by token count, preserving first-seen order of lengths.
26
34
  const byLength = new Map<number, number[]>()
27
35
  const lengthOrder: number[] = []
@@ -1,4 +1,6 @@
1
- const DELIMITER_CANDIDATES = ['_', '-', '.'] as const
1
+ import { isClassMarker } from './classify'
2
+
3
+ const DELIMITER_CANDIDATES = ['_', '-', ' ', '.'] as const
2
4
  export type Delimiter = (typeof DELIMITER_CANDIDATES)[number]
3
5
 
4
6
  /** Pick the delimiter whose split produces the most consistent field count across `lines`. */
@@ -39,3 +41,148 @@ export function pickPrimaryDelimiter(lines: string[]): Delimiter {
39
41
  export function splitMulti(name: string, delimiter: Delimiter): string[] {
40
42
  return name.split(delimiter)
41
43
  }
44
+
45
+ /** The `exp<NNN>_<date>_<operator>` fields at the start of a sample name. */
46
+ export interface ExperimentHeader {
47
+ code?: string
48
+ /** ISO date. Names carry it as YYMMDD, YYYYMMDD or DDMMYYYY. */
49
+ date?: string
50
+ operator?: string
51
+ }
52
+
53
+ export interface TokenizedBatch {
54
+ delimiter: Delimiter
55
+ /** One row of tokens for each input name, in input order. */
56
+ rows: string[][]
57
+ /** Header fields with one value in the batch. They are not in `rows`. */
58
+ experiment: ExperimentHeader
59
+ /**
60
+ * Number of leading tokens that all rows share (normally the experiment
61
+ * title). They cannot tell a QC sample from a study sample.
62
+ */
63
+ sharedPrefix: number
64
+ }
65
+
66
+ const HEADER_FIELDS = ['code', 'date', 'operator'] as const
67
+ // Share of the names with a header that must carry the same operator before it
68
+ // leaves the rows. The rest allows for stray names such as `exp157_test_blank_1`.
69
+ const OPERATOR_AGREEMENT = 0.9
70
+ // Outside this window a 6-digit token is read day-first: `091123` is 2023-11-09, not 2009.
71
+ const FIRST_PLAUSIBLE_YEAR = 2015
72
+
73
+ function isoDate(year: number, month: number, day: number): string | undefined {
74
+ const lastYear = new Date().getFullYear() + 1
75
+ if (year < FIRST_PLAUSIBLE_YEAR || year > lastYear) return undefined
76
+ // `Date` rolls an impossible day over to the next month; reject that.
77
+ const date = new Date(Date.UTC(year, month - 1, day))
78
+ if (date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) return undefined
79
+ return date.toISOString().slice(0, 10)
80
+ }
81
+
82
+ /** Read a 6- or 8-digit date token; year-first is tried before day-first. */
83
+ export function parseDateToken(token: string): string | undefined {
84
+ const n = (from: number, to: number): number => Number(token.slice(from, to))
85
+ if (/^\d{8}$/.test(token)) return isoDate(n(0, 4), n(4, 6), n(6, 8)) ?? isoDate(n(4, 8), n(2, 4), n(0, 2))
86
+ if (/^\d{6}$/.test(token)) return isoDate(2000 + n(0, 2), n(2, 4), n(4, 6)) ?? isoDate(2000 + n(4, 6), n(2, 4), n(0, 2))
87
+ return undefined
88
+ }
89
+
90
+ type HeaderField = (typeof HEADER_FIELDS)[number]
91
+
92
+ /**
93
+ * Read the experiment code, date and operator from the left of a name, in any
94
+ * order. `position` holds the token index of each field. A name with no code
95
+ * and no date has no header, so `WT_Ctrl_1` keeps `WT` as a sample token.
96
+ */
97
+ function readHeader(tokens: string[]): { fields: ExperimentHeader; position: Partial<Record<HeaderField, number>> } {
98
+ const fields: ExperimentHeader = {}
99
+ const position: Partial<Record<HeaderField, number>> = {}
100
+ let i = 0
101
+ if (tokens.length > 1 && /^exp\d+$/i.test(tokens[0])) {
102
+ fields.code = tokens[0].toLowerCase()
103
+ position.code = i++
104
+ }
105
+ for (; i < tokens.length - 1; i++) {
106
+ const date = fields.date ? undefined : parseDateToken(tokens[i])
107
+ if (date) {
108
+ fields.date = date
109
+ position.date = i
110
+ } else if (!fields.operator && /^[A-Z]{2,3}$/.test(tokens[i]) && !isClassMarker(tokens[i])) {
111
+ fields.operator = tokens[i]
112
+ position.operator = i
113
+ } else {
114
+ break
115
+ }
116
+ }
117
+ if (!fields.code && !fields.date) return { fields: {}, position: {} }
118
+ return { fields, position }
119
+ }
120
+
121
+ const MS_FILE_EXTENSION = /\.(raw|mzml|mzxml|mzdata|wiff2?|d|mgf|csv|tsv|txt)$/i
122
+
123
+ /** Drop a mass-spec file extension from each name, and any other extension that all names share. */
124
+ function stripExtensions(names: string[]): string[] {
125
+ const bare = names.map(name => name.replace(MS_FILE_EXTENSION, ''))
126
+ const ext = /\.[A-Za-z][A-Za-z0-9]{0,4}$/.exec(bare[0] ?? '')?.[0]
127
+ return ext && bare.every(name => name.endsWith(ext)) ? bare.map(name => name.slice(0, -ext.length)) : bare
128
+ }
129
+
130
+ /**
131
+ * Sample names → token rows.
132
+ *
133
+ * 1. Trim; drop a file extension that all names share.
134
+ * 2. Split on runs of the primary delimiter.
135
+ * 3. Drop a trailing 14-digit acquisition stamp.
136
+ * 4. Read the experiment header. A field moves to `experiment` when the batch
137
+ * has one value for it. Any other field stays in the rows as written, so
138
+ * `QC` or `WT` after the date in some names is not taken for operator
139
+ * initials.
140
+ * 5. Count the tokens that all names share after the header (the experiment
141
+ * title), so a title word such as `std` cannot classify the batch as QC.
142
+ */
143
+ export function tokenizeBatch(names: string[]): TokenizedBatch {
144
+ const cleaned = stripExtensions(names.map(name => name.trim()))
145
+ const delimiter = pickPrimaryDelimiter(cleaned)
146
+ const parsed = cleaned.map(name => {
147
+ const split = name.split(delimiter).filter(Boolean)
148
+ if (split.length > 1 && /^\d{14}$/.test(split[split.length - 1])) split.pop()
149
+ const tokens = split.length > 0 ? split : [name]
150
+ return { tokens, ...readHeader(tokens) }
151
+ })
152
+
153
+ const experiment: ExperimentHeader = {}
154
+ for (const field of ['code', 'date'] as const) {
155
+ const values = new Set(parsed.map(({ fields }) => fields[field]).filter(Boolean))
156
+ if (values.size === 1) experiment[field] = [...values][0]
157
+ }
158
+ // `exp<NNN>` and a date are clear by their form. Operator initials are not:
159
+ // `QC`, `WT` or `KO` look the same. The operator is the caps token that
160
+ // (almost) all complete headers carry; any other caps token stays in its row.
161
+ const dated = parsed.filter(({ fields }) => fields.date)
162
+ const complete = dated.length > 0 ? dated : parsed.filter(({ fields }) => fields.code)
163
+ const operatorCounts = new Map<string, number>()
164
+ for (const { fields } of complete) {
165
+ if (fields.operator) operatorCounts.set(fields.operator, (operatorCounts.get(fields.operator) ?? 0) + 1)
166
+ }
167
+ const [operator, count] = [...operatorCounts].sort((a, b) => b[1] - a[1])[0] ?? []
168
+ if (operator && count >= complete.length * OPERATOR_AGREEMENT) experiment.operator = operator
169
+
170
+ const rows = parsed.map(({ tokens, fields, position }) => {
171
+ const moved = new Set(
172
+ HEADER_FIELDS.filter(field => experiment[field] && fields[field] === experiment[field]).map(field => position[field]),
173
+ )
174
+ return tokens.filter((_, index) => !moved.has(index))
175
+ })
176
+
177
+ // A title exists only after a header; without one a shared first token can
178
+ // be the QC marker of a QC-only paste.
179
+ let sharedPrefix = 0
180
+ if (rows.length > 1 && Object.keys(experiment).length > 0) {
181
+ while (rows.every(row => row.length > sharedPrefix + 1 && row[sharedPrefix] === rows[0][sharedPrefix])) sharedPrefix++
182
+ // When only numbers follow, the shared tokens are the sample description
183
+ // (a QC-only batch: `QC_1`, `QC_2`), not a title.
184
+ if (rows.every(row => row.slice(sharedPrefix).every(token => /^\d+[A-Za-z]?$/.test(token)))) sharedPrefix = 0
185
+ }
186
+
187
+ return { delimiter, experiment, rows, sharedPrefix }
188
+ }
@@ -7,14 +7,14 @@
7
7
  "matrixVocab": "Lowercase token → canonical display name. Tokens are normalised to lowercase before lookup, so adding 'tissues' handles 'Tissues' / 'TISSUES' / 'tissues' uniformly. Multiple aliases map to one canonical (e.g. cell + cells → Cells).",
8
8
  "ionizationModes": "Lowercase polarity tokens (pos / positive / neg / negative). Suffixed to the subKind so POS and NEG acquisitions get independent schemas.",
9
9
  "tissueParentTokens": "Tokens that flag the immediately-following token as the organ subKind (so `tissues / kidney` becomes Biological/Tissues, tagging both positions).",
10
- "replicateStripPatterns": "Regex sources stripped from each sample name during the replicate pre-grouping pass. Samples whose stripped (base) names match are treated as replicates of one another and grouped together before tokenisation. Default set covers _T<n>, _B<n>, and _Rep<n> markers. The trailing injection / run-order number is intentionally NOT stripped here — it survives tokenisation and is surfaced as a 'run-order' column (named 'Injection #'), then excluded from the default group key so replicates still collapse."
10
+ "replicateToken": "Regex source for a token that names a replicate: rep1, Rep2, replicate3, R1, T1 (technical replicate). A column whose values all match is a replicate column: it stays a visible field and is left out of the default group key. B<n> is not in the pattern: on the lab share B13 / S13 are label tokens."
11
11
  },
12
12
 
13
13
  "patterns": {
14
14
  "blank": "^(blank|blk|solv?blank|matrix.?blank)$",
15
- "iqc": "^iqc$",
15
+ "iqc": "^(iqc|qc|pqc|pooled.?qc)$",
16
16
  "eqc": "^eqc$",
17
- "standard": "^(standard|std\\d*|cal(?:ibrator)?\\d*|.+standard)$",
17
+ "standard": "^(standards?|stds?\\d*|istd|cal(?:ibrator)?\\d*|.+standard)$",
18
18
  "spikeIn": "spike.?in",
19
19
  "stock": "^stock$"
20
20
  },
@@ -22,9 +22,7 @@
22
22
  "standardAllow": [
23
23
  "ExtractionS",
24
24
  "IS",
25
- "internal.standard",
26
- "PQC",
27
- "pooledQC"
25
+ "internal.standard"
28
26
  ],
29
27
 
30
28
  "matrixVocab": {
@@ -58,9 +56,5 @@
58
56
 
59
57
  "tissueParentTokens": ["tissue", "tissues"],
60
58
 
61
- "replicateStripPatterns": [
62
- "[_-]T\\d+(?=[_-]|$)",
63
- "[_-]B\\d+(?=[_-]|$)",
64
- "[_-](?:rep(?:licate)?)\\d+(?=[_-]|$)"
65
- ]
59
+ "replicateToken": "^(?:rep(?:licate)?|r|t)\\d+$"
66
60
  }