import type { ColumnInfo, ColumnRole, ClassSchema, NumericParse, SampleClassKind, } from '../../types/auto-group' import vocabData from './vocab.json' const NUMERIC_VALUE_UNIT = /^(\d+(?:(?:dot|\.)\d+)?)([A-Za-z%/μ]+)$/ const RATIO = /^(\d+)to(\d+)$/ const RUN_ORDER_PATTERN = /^\d{2,4}[A-Z]?$/ // Explicit replicate-token vocabulary: rep1 / Rep2 / replicate1 / R1 / T1. // These tokens almost always identify a within-group replicate dimension that // users do NOT want in the default group key — e.g. ctrl/rep1, ctrl/rep2 // should land in one Ctrl group with two replicates, not two singleton groups. const REPLICATE_TOKEN_PATTERN = new RegExp(vocabData.replicateToken, 'i') /** * A replicate counter is a dense run of small numbers or letters: `1, 2, 3`, * `01, 02, 03` or `A, B, C`. A quarter of the run can be missing (a failed * sample). `1, 5, 10` is a dose and `1, 2, 6, 7` an animal number: too sparse. */ function isCounterRun(values: string[]): boolean { if (!values.every(value => /^(\d{1,2}|[A-Za-z])$/.test(value))) return false // One batch can count some replicates 1, 2, 3 and others a, b, c. const ranks = [...new Set(values.map(value => (/\d/.test(value) ? Number(value) : value.toUpperCase().charCodeAt(0) - 64)))] const allowedGaps = Math.max(1, Math.floor(ranks.length / 4)) return Math.min(...ranks) >= 1 && Math.max(...ranks) <= ranks.length + allowedGaps } // A stem with a counter attached: C1, C2, H1. const STEM_COUNTER_PATTERN = /^([A-Za-z]+)(\d{1,2})$/ // Share of a column that must agree before the column is split or read as // one-value-for-each-sample. The rest allows for odd names and re-injections. const COLUMN_AGREEMENT = 0.9 const ID_MIN_CLASS_SIZE = 4 /** Roles the Field Recipe offers as grouping fields. `replicate` and `id` are off by default. */ export const GROUPING_FIELD_ROLES: ReadonlySet = new Set([ 'factor', 'numeric', 'replicate', 'id', ]) // Real-world LC-MS batches often have 30+ distinct conditions (subject IDs, // calibration levels, treatment combinations) in a single class. The earlier // cutoff of 20 routed everything above it to `ignore`, leaving such columns // out of the default groupBy — meaning a single giant group with no useful // split. 50 covers the common cases without making singleton-heavy columns // (sample IDs, timestamps) into factors. const DEFAULT_FACTOR_CARDINALITY_MAX = 50 // Numerics with only a handful of distinct levels (e.g. `5min` / `10min`, // `1nM` / `5nM` / `25nM`) read as factor levels by default. Users opt into // numeric binning explicitly via the column popover when they want it. const LOW_CARDINALITY_NUMERIC_AS_FACTOR_MAX = 5 export function parseNumeric(token: string): NumericParse | null { const ratio = RATIO.exec(token) if (ratio) { const num = Number(ratio[1]) const den = Number(ratio[2]) if (den === 0) return null return { value: num / den, unit: 'ratio', raw: token } } const m = NUMERIC_VALUE_UNIT.exec(token) if (!m) return null const valueStr = m[1].replace(/dot/g, '.') const value = Number(valueStr) if (Number.isNaN(value)) return null return { value, unit: m[2], raw: token } } export interface InferRoleInput { values: string[] position: number totalPositions: number classSize: number /** The column ends a name or a split token, where replicate counters sit. Defaults to the last position. */ tail?: boolean } export function inferRole(input: InferRoleInput): ColumnRole { const { values, position, totalPositions, classSize } = input const tail = input.tail ?? position === totalPositions - 1 const unique = new Set(values) const cardinality = unique.size if (cardinality === 1) return 'constant' // replicate: explicit names (rep1, R1, T1), or a small counter that repeats // at the end of a name (1, 2, 3, 1, 2, 3). Empty cells come from names that // a column split did not match. const present = values.filter(Boolean) if (present.every(v => REPLICATE_TOKEN_PATTERN.test(v))) return 'replicate' if (tail && cardinality * 2 <= values.length && isCounterRun(present)) return 'replicate' // run-order: trailing position, injection-like values and high cardinality. // Numbers repeat when one batch holds two experiments, so half is enough. if (position === totalPositions - 1) { const allRunLike = values.every(v => RUN_ORDER_PATTERN.test(v)) if (allRunLike && cardinality >= classSize * 0.5) return 'run-order' } // numeric: every value parses as value+unit (or ratio). const allNumeric = values.every(v => parseNumeric(v) !== null) if (allNumeric) { // A column like 5min / 10min reads as a factor in practice — users want to // group on the timepoint, not bin it. if (cardinality <= LOW_CARDINALITY_NUMERIC_AS_FACTOR_MAX) return 'factor' return 'numeric' } // id: one value for (almost) each sample, (almost) all with a digit — an // animal or sample number (212, M10, C01). Grouping by it gives groups of // one, so it is off by default. Unique words (Ctrl, DrugA, DrugB) are // conditions of a design with no replicates and stay a factor. if ( values.length >= ID_MIN_CLASS_SIZE && cardinality >= values.length * COLUMN_AGREEMENT && values.filter(v => /\d/.test(v)).length >= values.length * COLUMN_AGREEMENT ) return 'id' if (cardinality <= DEFAULT_FACTOR_CARDINALITY_MAX) return 'factor' return 'ignore' } /** * How a column splits when most of its values agree: on `-` into equal parts * (`2-1` → `2`, `1`), or into stem + counter (`C1` → `C`, `1`) when two or more * stems repeat with different counters. `D1`/`D3` (one stem) and `B13`/`S13` * (one counter for each stem) stay whole. */ function columnSplitter(values: string[]): ((value: string) => string[]) | undefined { const agree = (test: (value: string) => boolean): boolean => values.filter(test).length >= values.length * COLUMN_AGREEMENT const partCounts = new Map() for (const value of values) { const parts = value.split('-').length partCounts.set(parts, (partCounts.get(parts) ?? 0) + 1) } const parts = [...partCounts].sort((a, b) => b[1] - a[1])[0]?.[0] ?? 1 if (parts > 1 && agree(value => value.split('-').length === parts)) { return value => { const pieces = value.split('-') return pieces.length === parts ? pieces : [value, ...Array(parts - 1).fill('')] } } const countersByStem = new Map>() for (const value of values) { const match = STEM_COUNTER_PATTERN.exec(value) if (match) countersByStem.set(match[1], (countersByStem.get(match[1]) ?? new Set()).add(match[2])) } const repeated = [...countersByStem.values()].filter(counters => counters.size > 1).length if (countersByStem.size > 1 && repeated * 2 >= countersByStem.size && agree(value => STEM_COUNTER_PATTERN.test(value))) { return value => STEM_COUNTER_PATTERN.exec(value)?.slice(1) ?? [value, ''] } return undefined } export interface ColumnSplit { rows: string[][] /** `origin[newIndex]` = index of the source column before the split. */ origin: number[] /** New indices that end a split token. */ tail: number[] } /** Split the columns of one class (rows of equal length) that hold two fields in one token. */ export function splitColumns(rows: string[][]): ColumnSplit { const width = rows[0]?.length ?? 0 const splitters = Array.from({ length: width }, (_, col) => columnSplitter(rows.map(row => row[col] ?? ''))) const origin: number[] = [] const tail: number[] = [] splitters.forEach((splitter, col) => { const parts = splitter ? splitter(rows[0][col]).length : 1 for (let part = 0; part < parts; part++) origin.push(col) if (parts > 1) tail.push(origin.length - 1) }) return { rows: rows.map(row => row.flatMap((token, col) => splitters[col]?.(token) ?? [token])), origin, tail, } } /** * Auto-detect a friendlier column name from the column's values + role. * Returns null when nothing matches, so callers can fall back to "Field N". * * Heuristics are content-derived and deterministic. They cover the common * LC-MS / proteomics naming conventions; users can override anything via * the column popover (rename input). */ function inferColumnName( values: string[], uniqueValues: string[], role: ColumnRole, ): string | null { if (uniqueValues.length === 0) return null // Numeric+unit columns name themselves by the dominant unit category. // We tally all values so a mixed-unit column (`1uL`, `1nM`) picks the // majority rather than the first sample's unit. const allNumeric = (role === 'numeric' || role === 'factor') && values.every(v => parseNumeric(v) !== null) if (allNumeric) { const categoryFor = (u: string): string | null => { const ul = u.toLowerCase() if (['min', 'sec', 's', 'hr', 'h', 'd', 'day', 'days', 'wk', 'week', 'hour', 'hours'].includes(ul)) return 'Timepoint' if (['nm', 'um', 'mm', 'm', 'molar', 'mol'].includes(ul)) return 'Concentration' if (['mg', 'g', 'kg', 'ng', 'ug', 'pg'].includes(ul)) return 'Mass' if (['ml', 'l', 'ul', 'nl', 'pl'].includes(ul)) return 'Volume' if (ul === '%') return 'Percentage' if (['c', 'k', 'celsius', 'kelvin'].includes(ul)) return 'Temperature' return null } const tally = new Map() for (const v of values) { const p = parseNumeric(v) const cat = p ? categoryFor(p.unit) : null if (cat) tally.set(cat, (tally.get(cat) ?? 0) + 1) } let best: { cat: string; count: number } | null = null for (const [cat, count] of tally) { if (!best || count > best.count) best = { cat, count } } if (best && best.count >= values.length / 2) return best.cat } // Date-like all-digits tokens (YYMMDD or YYYYMMDD). if (values.every(v => /^\d{6}$/.test(v) || /^\d{8}$/.test(v))) return 'Date' // Run-order column = the trailing injection / acquisition sequence number. if (role === 'run-order') return 'Injection #' // Polarity tokens (POS/NEG/positive/negative). if (values.every(v => /^(pos|neg|positive|negative)$/i.test(v))) return 'Polarity' // Common biological matrices — kept in sync with classify.ts matrixVocab. if (values.every(v => /^(plasma|serum|tissue|tissues|kidney|liver|tumor|urine|brain|muscle|adipose|csf|feces|faeces|cells?|media|medium|extract)$/i.test(v))) { return 'Matrix' } // Experiment / study identifiers (exp268, study42, expt7). if (values.every(v => /^(exp|expt|study)\d+$/i.test(v))) return 'Experiment' // Operator / Username: a constant column of 2-3 uppercase letters is almost // always operator initials (LCK, JD, AB). A column with two or more values // (WT / KO) is a factor and gets no name here. if (uniqueValues.length === 1 && /^[A-Z]{2,3}$/.test(uniqueValues[0])) return 'Username' if (role === 'replicate') return 'Replicate' if (role === 'id') return 'Sample ID' return null } export function buildClassSchema( tokenizedMembers: string[][], classKind: SampleClassKind, subKind: string | undefined, classTagPositions: number[], /** Positions that end a split token (from `splitColumns`). */ tailPositions: number[] = [], ): ClassSchema { const totalPositions = tokenizedMembers[0]?.length ?? 0 const classSize = tokenizedMembers.length const columns: ColumnInfo[] = [] const tagSet = new Set(classTagPositions) // Replicate counters sit at the end of a name: the last position, or the one // before the injection number. const tailSet = new Set([...tailPositions, totalPositions - 1]) // Right to left, so the injection column is known before its neighbour. for (let pos = totalPositions - 1; pos >= 0; pos--) { const values = tokenizedMembers.map(t => t[pos] ?? '') const uniqueValues = [...new Set(values)] let role: ColumnRole if (tagSet.has(pos) && uniqueValues.length === 1) { // Only constant tag positions become class-tag; multi-valued positions (e.g. organ // type within a Tissues class) fall through to normal role inference so they can be // used as factor columns for grouping. role = 'class-tag' } else { role = inferRole({ values, position: pos, totalPositions, classSize, tail: tailSet.has(pos) }) } // Injection numbers with no zero padding (7, 18, 20) read as an id column // at the last position; a counter can sit before either form. const lastPosition = pos === totalPositions - 1 if (role === 'run-order' || (lastPosition && role === 'id')) tailSet.add(pos - 1) const numeric = role === 'numeric' ? { byValue: Object.fromEntries( uniqueValues .map(v => [v, parseNumeric(v)] as const) .filter((e): e is readonly [string, NumericParse] => e[1] !== null), ), } : undefined const inferredName = inferColumnName(values, uniqueValues, role) columns.unshift({ index: pos, name: inferredName ?? `Field ${pos + 1}`, sourceIndices: [pos], uniqueValues, cardinality: uniqueValues.length, role, numeric, // Legacy field type: pos === 0 ? 'prefix' : 'suffix', }) } // The name is the field's identity across classes, so it must be unique in // a schema: a second `Date` or `Replicate` becomes `Date 2`. Grouping fields // take their names first, so a card keeps `Matrix` and the constant class // tag beside it becomes `Matrix 2`. const nameCounts = new Map() const isField = (c: ColumnInfo): boolean => GROUPING_FIELD_ROLES.has(c.role) for (const c of [...columns.filter(isField), ...columns.filter(c => !isField(c))]) { const count = (nameCounts.get(c.name) ?? 0) + 1 nameCounts.set(c.name, count) if (count > 1) c.name = `${c.name} ${count}` } const groupBy = columns.filter(c => c.role === 'factor' || c.role === 'numeric').map(c => c.index) return { classKind, subKind, columns, groupBy } }