@platforma-sdk/model 1.80.2 → 1.80.10

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 (34) hide show
  1. package/dist/columns/column.cjs.map +1 -1
  2. package/dist/columns/column.js.map +1 -1
  3. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV3.cjs +1 -1
  4. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV3.cjs.map +1 -1
  5. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV3.js +1 -1
  6. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV3.js.map +1 -1
  7. package/dist/components/PlDataTable/createPlDataTable/discoverColumns.cjs +4 -4
  8. package/dist/components/PlDataTable/createPlDataTable/discoverColumns.cjs.map +1 -1
  9. package/dist/components/PlDataTable/createPlDataTable/discoverColumns.d.ts +1 -1
  10. package/dist/components/PlDataTable/createPlDataTable/discoverColumns.d.ts.map +1 -1
  11. package/dist/components/PlDataTable/createPlDataTable/discoverColumns.js +5 -5
  12. package/dist/components/PlDataTable/createPlDataTable/discoverColumns.js.map +1 -1
  13. package/dist/labels/derive_distinct_labels.cjs +80 -57
  14. package/dist/labels/derive_distinct_labels.cjs.map +1 -1
  15. package/dist/labels/derive_distinct_labels.d.ts +16 -9
  16. package/dist/labels/derive_distinct_labels.d.ts.map +1 -1
  17. package/dist/labels/derive_distinct_labels.js +80 -57
  18. package/dist/labels/derive_distinct_labels.js.map +1 -1
  19. package/dist/labels/linked_column_postfix.cjs +189 -0
  20. package/dist/labels/linked_column_postfix.cjs.map +1 -0
  21. package/dist/labels/linked_column_postfix.d.ts +27 -0
  22. package/dist/labels/linked_column_postfix.d.ts.map +1 -0
  23. package/dist/labels/linked_column_postfix.js +188 -0
  24. package/dist/labels/linked_column_postfix.js.map +1 -0
  25. package/dist/package.cjs +1 -1
  26. package/dist/package.js +1 -1
  27. package/package.json +10 -10
  28. package/src/columns/column.ts +6 -6
  29. package/src/components/PlDataTable/createPlDataTable/createPlDataTableV3.ts +2 -2
  30. package/src/components/PlDataTable/createPlDataTable/discoverColumns.ts +14 -18
  31. package/src/labels/derive_distinct_labels.test.ts +117 -72
  32. package/src/labels/derive_distinct_labels.ts +141 -112
  33. package/src/labels/linked_column_postfix.test.ts +112 -0
  34. package/src/labels/linked_column_postfix.ts +312 -0
@@ -4,6 +4,7 @@ import {
4
4
  readAnnotation,
5
5
  type AxisQualification,
6
6
  type MatchQualifications,
7
+ type PColumnSpec,
7
8
  type PObjectId,
8
9
  type PObjectSpec,
9
10
  type StringifiedJson,
@@ -11,14 +12,13 @@ import {
11
12
  } from "@milaboratories/pl-model-common";
12
13
  import { throwError } from "@milaboratories/helpers";
13
14
  import { isFunction, isNil } from "es-toolkit";
15
+ import { derivePostfixes, type LinkerFormatter } from "./linked_column_postfix";
14
16
 
15
17
  export type { Trace, TraceEntry } from "@milaboratories/pl-model-common";
16
18
 
17
19
  const DISTANCE_PENALTY = 0.001;
18
20
  const LABEL_TYPE = "__LABEL__";
19
21
  const LABEL_TYPE_FULL = "__LABEL__@1";
20
- const LINKER_TYPE = "__LINKER__";
21
- const LINKER_TYPE_FULL = "__LINKER__@1";
22
22
  const HIT_QUAL_TYPE = "__HIT_QUAL__";
23
23
  const ANCHOR_QUAL_TYPE_PREFIX = "__ANCHOR_QUAL__:";
24
24
 
@@ -27,7 +27,7 @@ function isAnchorQualType(t: string): boolean {
27
27
  }
28
28
 
29
29
  function isSyntheticType(t: string): boolean {
30
- return t === LINKER_TYPE || t === HIT_QUAL_TYPE || isAnchorQualType(t);
30
+ return t === HIT_QUAL_TYPE || isAnchorQualType(t);
31
31
  }
32
32
 
33
33
  /** SDK-internal trace shape — adds fields used by this algorithm only, not part of the on-disk contract. */
@@ -37,7 +37,9 @@ type ExtendedTraceEntry = Trace[number] & {
37
37
  };
38
38
 
39
39
  export type LinkerStep = {
40
- spec: PObjectSpec;
40
+ /** Linker column spec — its `axesSpec` yields the source axis (root); its `LinkLabel`/`Label` names it. */
41
+ spec: PColumnSpec;
42
+ /** Axis qualifications applied on this hop. Not yet consumed by the postfix (deferred). */
41
43
  qualifications?: AxisQualification[];
42
44
  };
43
45
 
@@ -47,7 +49,8 @@ export type Entry =
47
49
  spec: PObjectSpec;
48
50
  /** Extra trace entries merged with the base trace from annotations. */
49
51
  extraTrace?: ExtendedTraceEntry[];
50
- /** Linker steps traversed to discover this column; rendered as "via …" only when needed for uniqueness. */
52
+ /** Linker steps (`[0]` source-most) traversed to reach this column; rendered as a "via …"
53
+ * postfix only when needed for uniqueness — see {@link derivePostfixes}. */
51
54
  linkerPath?: LinkerStep[];
52
55
  /** Axis qualifications applied to the hit column / already-bound anchors; rendered as "[…]" suffixes. */
53
56
  qualifications?: MatchQualifications;
@@ -60,16 +63,9 @@ export type Entry =
60
63
  export type DeriveLabelsFormatters = {
61
64
  /** Native column label. Default: identity. `undefined` → label entry not added (treated as if spec had no label). */
62
65
  native?: (label: string, spec: PObjectSpec, index: number) => string | undefined;
63
- /** Linker zone (whole "via …" piece). Receives step labels with step-quals already inlined.
64
- * Default: `via ${steps.join(" > ")}`. */
65
- linker?: (linkerLabels: string[], spec: PObjectSpec, index: number) => string | undefined;
66
- /** Per-step linker qualifications inlined into the step base label.
67
- * Default: `[${formatQualifications(qs)}]`. `undefined` → step rendered without quals. */
68
- linkerStepQualification?: (
69
- qualifications: AxisQualification[],
70
- stepIndex: number,
71
- stepSpec: PObjectSpec,
72
- ) => string | undefined;
66
+ /** Linker/source postfix zone (phase 2). Receives the distinguishing tokens (`{ root, linkers }`)
67
+ * and returns the "via …" text, or `undefined` to suppress. */
68
+ linker?: LinkerFormatter;
73
69
  /** Hit-axis qualifications block. Default: `[${formatQualifications(qs)}]`. */
74
70
  hitQualification?: (
75
71
  qualifications: AxisQualification[],
@@ -98,7 +94,31 @@ export type DeriveLabelsOptions = {
98
94
  formatters?: DeriveLabelsFormatters;
99
95
  };
100
96
 
97
+ /**
98
+ * Distinct labels for a set of columns. Two phases:
99
+ * 1. {@link deriveStems} — treats each column as a single entity (native label + trace + hit/anchor
100
+ * qualifications) and produces the minimal distinguishing "stem".
101
+ * 2. {@link derivePostfixes} — for columns still colliding on their stem, appends a "via …" postfix
102
+ * describing the difference between their linker sources (root axis, then linker chain).
103
+ */
101
104
  export function deriveDistinctLabels(values: Entry[], options: DeriveLabelsOptions = {}): string[] {
105
+ const stems = deriveStems(values, options);
106
+ return derivePostfixes(
107
+ values.map((v, i) => {
108
+ const { spec, linkerPath } = extractEntryParts(v);
109
+ return {
110
+ stem: stems[i],
111
+ // Hit columns are PColumns; the postfix reads their axesSpec to orient linkers.
112
+ hit: spec as PColumnSpec,
113
+ linkers: (linkerPath ?? []).map((s) => s.spec),
114
+ };
115
+ }),
116
+ options.formatters?.linker,
117
+ );
118
+ }
119
+
120
+ /** Phase 1: minimal per-column stem — native label + trace + hit/anchor qualifications, no linkers. */
121
+ function deriveStems(values: Entry[], options: DeriveLabelsOptions): string[] {
102
122
  const forceTraceElements =
103
123
  options.forceTraceElements !== undefined && options.forceTraceElements.length > 0
104
124
  ? new Set(options.forceTraceElements)
@@ -112,13 +132,9 @@ export function deriveDistinctLabels(values: Entry[], options: DeriveLabelsOptio
112
132
  const labelForced =
113
133
  (options.includeNativeLabel === true || hasAnySynthetic) &&
114
134
  stats.countByType.has(LABEL_TYPE_FULL);
115
- // Tied to labeled-step presence, not path presence: entries with a non-empty linkerPath
116
- // but no labeled steps contribute no LINKER_TYPE trace entry, so they do not count here.
117
- const linkerForced = stats.countByType.get(LINKER_TYPE_FULL) === values.length;
118
135
 
119
136
  const forcedSet = new Set<string>();
120
137
  if (labelForced) forcedSet.add(LABEL_TYPE_FULL);
121
- if (linkerForced) forcedSet.add(LINKER_TYPE_FULL);
122
138
 
123
139
  const { mainTypes, secondaryTypes } = classifyTypes(stats, values.length);
124
140
 
@@ -152,15 +168,15 @@ export function deriveDistinctLabels(values: Entry[], options: DeriveLabelsOptio
152
168
  forcedSet,
153
169
  separator,
154
170
  );
155
- const minimizedLabels =
156
- build(minimized, false) ?? throwError("Failed to derive unique labels");
157
- return dropRedundantLinkerSuffix(
158
- records,
171
+ const rendered = build(minimized, false) ?? throwError("Failed to derive unique labels");
172
+ return repairBareByPresence(
173
+ rendered,
159
174
  minimized,
160
- forceTraceElements,
175
+ records,
176
+ stats,
161
177
  forcedSet,
178
+ forceTraceElements,
162
179
  separator,
163
- minimizedLabels,
164
180
  );
165
181
  }
166
182
 
@@ -180,14 +196,15 @@ export function deriveDistinctLabels(values: Entry[], options: DeriveLabelsOptio
180
196
  forcedSet,
181
197
  separator,
182
198
  );
183
- const minimizedLabels = build(minimized, true) ?? throwError("Failed to derive unique labels");
184
- return dropRedundantLinkerSuffix(
185
- records,
199
+ const rendered = build(minimized, true) ?? throwError("Failed to derive unique labels");
200
+ return repairBareByPresence(
201
+ rendered,
186
202
  minimized,
187
- forceTraceElements,
203
+ records,
204
+ stats,
188
205
  forcedSet,
206
+ forceTraceElements,
189
207
  separator,
190
- minimizedLabels,
191
208
  );
192
209
  }
193
210
 
@@ -233,22 +250,6 @@ function formatQualifications(qs: AxisQualification[]): string {
233
250
  return qs.map(formatQualification).join("; ");
234
251
  }
235
252
 
236
- function computeStepLabel(
237
- step: LinkerStep,
238
- stepIndex: number,
239
- formatters: DeriveLabelsFormatters | undefined,
240
- ): string | undefined {
241
- const base = (
242
- readAnnotation(step.spec, Annotation.LinkLabel) ?? readAnnotation(step.spec, Annotation.Label)
243
- )?.trim();
244
- if (isNil(base) || base.length === 0) return undefined;
245
- if (step.qualifications === undefined || step.qualifications.length === 0) return base;
246
- const qualText = isFunction(formatters?.linkerStepQualification)
247
- ? formatters.linkerStepQualification(step.qualifications, stepIndex, step.spec)
248
- : `[${formatQualifications(step.qualifications)}]`;
249
- return isNil(qualText) ? base : `${base} ${qualText}`;
250
- }
251
-
252
253
  function buildFullTrace(trace: ExtendedTraceEntry[]): FullTraceEntry[] {
253
254
  const result: FullTraceEntry[] = [];
254
255
  const occurrences = new Map<string, number>();
@@ -269,7 +270,7 @@ function buildFullTrace(trace: ExtendedTraceEntry[]): FullTraceEntry[] {
269
270
  }
270
271
 
271
272
  function enrichRecord(value: Entry, index: number, options: DeriveLabelsOptions): EnrichedRecord {
272
- const { spec, extraTrace, linkerPath, qualifications } = extractEntryParts(value);
273
+ const { spec, extraTrace, qualifications } = extractEntryParts(value);
273
274
  const formatters = options.formatters;
274
275
 
275
276
  const rawLabel = readAnnotation(spec, Annotation.Label);
@@ -292,20 +293,6 @@ function enrichRecord(value: Entry, index: number, options: DeriveLabelsOptions)
292
293
  }
293
294
  }
294
295
 
295
- if (linkerPath !== undefined && linkerPath.length > 0) {
296
- const stepLabels = linkerPath
297
- .map((step, i) => computeStepLabel(step, i, formatters))
298
- .filter((s): s is string => !isNil(s));
299
- if (stepLabels.length > 0) {
300
- const linkerText = isFunction(formatters?.linker)
301
- ? formatters.linker(stepLabels, spec, index)
302
- : `via ${stepLabels.join(" > ")}`;
303
- if (!isNil(linkerText)) {
304
- trace.push({ type: LINKER_TYPE, label: linkerText, importance: -10 });
305
- }
306
- }
307
- }
308
-
309
296
  if (qualifications !== undefined && qualifications.forQueries !== undefined) {
310
297
  for (const [anchorId, qs] of Object.entries(qualifications.forQueries)) {
311
298
  if (qs.length === 0) continue;
@@ -384,22 +371,16 @@ function renderRecordLabel(
384
371
  ): string | undefined {
385
372
  const traceParts: string[] = [];
386
373
  const anchorParts: string[] = [];
387
- let linkerLabel: string | undefined;
388
374
  let hitLabel: string | undefined;
389
375
 
390
376
  for (const ft of record.fullTrace) {
391
377
  if (!(includedTypes.has(ft.fullType) || forceTraceElements?.has(ft.type))) continue;
392
- if (ft.type === LINKER_TYPE) linkerLabel = ft.label;
393
- else if (ft.type === HIT_QUAL_TYPE) hitLabel = ft.label;
378
+ if (ft.type === HIT_QUAL_TYPE) hitLabel = ft.label;
394
379
  else if (isAnchorQualType(ft.type)) anchorParts.push(ft.label);
395
380
  else traceParts.push(ft.label);
396
381
  }
397
382
 
398
- const isEmpty =
399
- traceParts.length === 0 &&
400
- anchorParts.length === 0 &&
401
- linkerLabel === undefined &&
402
- hitLabel === undefined;
383
+ const isEmpty = traceParts.length === 0 && anchorParts.length === 0 && hitLabel === undefined;
403
384
 
404
385
  if (isEmpty) return undefined;
405
386
 
@@ -407,7 +388,6 @@ function renderRecordLabel(
407
388
  const append = (part: string) => {
408
389
  label = label.length === 0 ? part : `${label} ${part}`;
409
390
  };
410
- if (linkerLabel !== undefined) append(linkerLabel);
411
391
  for (const a of anchorParts) append(a);
412
392
  if (hitLabel !== undefined) append(hitLabel);
413
393
 
@@ -436,48 +416,6 @@ function buildLabels(
436
416
  return result;
437
417
  }
438
418
 
439
- /**
440
- * Drop the "via …" linker suffix from records whose label is already unique without it.
441
- *
442
- * Global minimization may include `LINKER_TYPE_FULL` solely to resolve a collision between a
443
- * subset of records — but `buildLabels` then renders the suffix on every record that carries a
444
- * linker trace entry, including ones whose stem is already unique. We strip the suffix where it
445
- * isn't load-bearing while keeping the symmetric rendering required by `linkerForced` /
446
- * `forceTraceElements`.
447
- *
448
- * Rule: a record's linker suffix is redundant iff its stem (label rendered without LINKER) does
449
- * not appear anywhere else in the set.
450
- */
451
- function dropRedundantLinkerSuffix(
452
- records: EnrichedRecord[],
453
- globalTypeSet: Set<string>,
454
- forceTraceElements: Set<string> | undefined,
455
- forcedSet: Set<string>,
456
- separator: string,
457
- labels: string[],
458
- ): string[] {
459
- if (!globalTypeSet.has(LINKER_TYPE_FULL)) return labels;
460
- if (forcedSet.has(LINKER_TYPE_FULL) || forceTraceElements?.has(LINKER_TYPE)) return labels;
461
-
462
- const setWithoutLinker = new Set(globalTypeSet);
463
- setWithoutLinker.delete(LINKER_TYPE_FULL);
464
-
465
- const stems = records.map((r) =>
466
- renderRecordLabel(r, setWithoutLinker, forceTraceElements, separator),
467
- );
468
-
469
- const stemOccurrences = new Map<string, number>();
470
- for (const s of stems) {
471
- if (s !== undefined) stemOccurrences.set(s, (stemOccurrences.get(s) ?? 0) + 1);
472
- }
473
-
474
- return labels.map((label, i) => {
475
- const stem = stems[i];
476
- if (stem === undefined) return label;
477
- return stemOccurrences.get(stem) === 1 ? stem : label;
478
- });
479
- }
480
-
481
419
  function countUniqueLabels(result: string[] | undefined): number {
482
420
  if (result === undefined) return 0;
483
421
  return new Set(result).size;
@@ -512,3 +450,94 @@ function minimizeTypeSet(
512
450
 
513
451
  return result;
514
452
  }
453
+
454
+ const ABSENT_VALUE = "absent"; // sentinel value for "row has no entry of this type"
455
+
456
+ /** Whether `fullType`'s rendered value differs across the group (absence counts as a value). */
457
+ function typeDistinguishes(records: EnrichedRecord[], group: number[], fullType: string): boolean {
458
+ const values = new Set<string>();
459
+ for (const i of group) {
460
+ const ft = records[i].fullTrace.find((e) => e.fullType === fullType);
461
+ values.add(ft?.label ?? ABSENT_VALUE);
462
+ if (values.size > 1) return true;
463
+ }
464
+ return false;
465
+ }
466
+
467
+ /** The highest-importance trace type this row HAS (outside `typeSet`) that sets it apart from its
468
+ * group peers. `undefined` when the row carries nothing distinguishing of its own. */
469
+ function bestDistinguishingType(
470
+ records: EnrichedRecord[],
471
+ group: number[],
472
+ row: number,
473
+ typeSet: Set<string>,
474
+ forcedSet: Set<string>,
475
+ forceTraceElements: Set<string> | undefined,
476
+ stats: TypeStats,
477
+ ): string | undefined {
478
+ return records[row].fullTrace.reduce<{ type: string; imp: number } | undefined>((best, ft) => {
479
+ if (typeSet.has(ft.fullType) || forcedSet.has(ft.fullType) || forceTraceElements?.has(ft.type))
480
+ return best;
481
+ if (!typeDistinguishes(records, group, ft.fullType)) return best;
482
+ const imp = stats.importances.get(ft.fullType) ?? 0;
483
+ return best === undefined || imp > best.imp ? { type: ft.fullType, imp } : best;
484
+ }, undefined)?.type;
485
+ }
486
+
487
+ /**
488
+ * Un-bare columns distinguished only "by absence". After minimization a column can end up with a
489
+ * bare trace zone (only the forced native label) while a peer sharing that same base renders extra
490
+ * tokens — the column reads as unique purely because it LACKS what the peer has. For each such bare
491
+ * column this re-renders JUST that column's label with the highest-importance trace type it actually
492
+ * carries that tells it apart from its peers, so every colliding column is distinguished by a token
493
+ * it HAS rather than by omission.
494
+ *
495
+ * Patches individual labels rather than the shared type set on purpose: the set is global, so adding
496
+ * a type there would also decorate unrelated columns in other groups that happen to carry it. Only
497
+ * bare columns' labels grow (a superset of their previous value), so uniqueness is preserved. Groups
498
+ * are keyed by the base = the label rendered from the forced types alone.
499
+ */
500
+ function repairBareByPresence(
501
+ labels: string[],
502
+ minimized: Set<string>,
503
+ records: EnrichedRecord[],
504
+ stats: TypeStats,
505
+ forcedSet: Set<string>,
506
+ forceTraceElements: Set<string> | undefined,
507
+ separator: string,
508
+ ): string[] {
509
+ const base = records.map((r) => renderRecordLabel(r, forcedSet, forceTraceElements, separator));
510
+ const isBare = records.map(
511
+ (r, i) => renderRecordLabel(r, minimized, forceTraceElements, separator) === base[i],
512
+ );
513
+
514
+ const groups = records.reduce<Map<string, number[]>>(
515
+ (acc, _, i) => acc.set(base[i] ?? "", [...(acc.get(base[i] ?? "") ?? []), i]),
516
+ new Map(),
517
+ );
518
+
519
+ const patched = [...labels];
520
+ for (const group of groups.values()) {
521
+ // Only asymmetric groups need repair: a bare column beside a richer peer. When every column is
522
+ // equally bare there is nothing to un-hide (they carry no distinguishing token of their own).
523
+ if (!group.some((i) => !isBare[i])) continue;
524
+ for (const i of group) {
525
+ if (!isBare[i]) continue;
526
+ const chosen = bestDistinguishingType(
527
+ records,
528
+ group,
529
+ i,
530
+ minimized,
531
+ forcedSet,
532
+ forceTraceElements,
533
+ stats,
534
+ );
535
+ if (chosen === undefined) continue;
536
+ const withToken = new Set([...minimized, chosen]);
537
+ patched[i] =
538
+ renderRecordLabel(records[i], withToken, forceTraceElements, separator) ?? patched[i];
539
+ }
540
+ }
541
+
542
+ return patched;
543
+ }
@@ -0,0 +1,112 @@
1
+ import { Annotation, type AxisSpec, type PColumnSpec } from "@milaboratories/pl-model-common";
2
+ import { describe, expect, test } from "vitest";
3
+ import { derivePostfixes } from "./linked_column_postfix";
4
+
5
+ // Shared target axis: it's on the hit column, and every linker's hit-facing side carries it — this
6
+ // is what lets `extractRoots` tell the source side (the "рут") from the target side.
7
+ const TARGET: AxisSpec = { type: "String", name: "hitAxis" };
8
+
9
+ const hit: PColumnSpec = {
10
+ kind: "PColumn",
11
+ name: "counts",
12
+ valueType: "Int",
13
+ axesSpec: [TARGET],
14
+ annotations: { [Annotation.Label]: "Counts" },
15
+ } as PColumnSpec;
16
+
17
+ /** A source-side axis, lives INSIDE a linker's axesSpec (never standalone). */
18
+ function sourceAxis(name: string, label?: string, domain?: Record<string, string>): AxisSpec {
19
+ return {
20
+ type: "String",
21
+ name,
22
+ ...(domain ? { domain } : {}),
23
+ ...(label ? { annotations: { [Annotation.Label]: label } } : {}),
24
+ } as AxisSpec;
25
+ }
26
+
27
+ /** Linker column: bridges a source axis → the shared target axis; carries a LinkLabel. */
28
+ function linker(linkLabel: string, src: AxisSpec, name = linkLabel): PColumnSpec {
29
+ return {
30
+ kind: "PColumn",
31
+ name,
32
+ valueType: "Int",
33
+ axesSpec: [src, TARGET],
34
+ annotations: { [Annotation.LinkLabel]: linkLabel },
35
+ } as PColumnSpec;
36
+ }
37
+
38
+ describe("prototype — structural postfix (difference of sources)", () => {
39
+ const axSample = sourceAxis("sampleId", "Sample");
40
+ const axClone = sourceAxis("cloneId", "Clone");
41
+
42
+ test("case 1 — same linker (label), different roots → postfix = root", () => {
43
+ const labels = derivePostfixes([
44
+ { stem: "Counts", hit, linkers: [linker("MapperA", axSample)] },
45
+ { stem: "Counts", hit, linkers: [linker("MapperA", axClone)] },
46
+ ]);
47
+ expect(labels).toEqual(["Counts via Sample", "Counts via Clone"]);
48
+ });
49
+
50
+ test("case 2 — different linkers, same root → postfix = linker", () => {
51
+ const labels = derivePostfixes([
52
+ { stem: "Counts", hit, linkers: [linker("MapperA", axSample)] },
53
+ { stem: "Counts", hit, linkers: [linker("MapperB", axSample)] },
54
+ ]);
55
+ expect(labels).toEqual(["Counts via MapperA", "Counts via MapperB"]);
56
+ });
57
+
58
+ test("case 3 — different linkers, different roots → root suffices (linker not added)", () => {
59
+ const labels = derivePostfixes([
60
+ { stem: "Counts", hit, linkers: [linker("MapperA", axSample)] },
61
+ { stem: "Counts", hit, linkers: [linker("MapperB", axClone)] },
62
+ ]);
63
+ expect(labels).toEqual(["Counts via Sample", "Counts via Clone"]);
64
+ });
65
+
66
+ test("roots share a Label but differ by domain → render only the differing domain key", () => {
67
+ const donorA = sourceAxis("donor", "Donor", { batch: "A" });
68
+ const donorB = sourceAxis("donor", "Donor", { batch: "B" });
69
+ const labels = derivePostfixes([
70
+ { stem: "Counts", hit, linkers: [linker("MapperA", donorA)] },
71
+ { stem: "Counts", hit, linkers: [linker("MapperA", donorB)] },
72
+ ]);
73
+ expect(labels).toEqual(["Counts via Donor[batch=A]", "Counts via Donor[batch=B]"]);
74
+ });
75
+
76
+ test("no collision → no postfix", () => {
77
+ const labels = derivePostfixes([
78
+ { stem: "Read counts" },
79
+ { stem: "Coverage", hit, linkers: [linker("MapperA", axSample)] },
80
+ ]);
81
+ expect(labels).toEqual(["Read counts", "Coverage"]);
82
+ });
83
+
84
+ test("collision only on a subset → bare row stays bare (direct column has no path)", () => {
85
+ const labels = derivePostfixes([
86
+ { stem: "Counts" }, // direct column, no path
87
+ { stem: "Counts", hit, linkers: [linker("MapperA", axSample)] },
88
+ ]);
89
+ expect(labels).toEqual(["Counts", "Counts via Sample"]);
90
+ });
91
+
92
+ test("mixed group — root+linker both needed globally; symmetric render, all unique", () => {
93
+ // A: MapperA+Sample, B: MapperA+Clone, C: MapperB+Clone
94
+ // root separates A from {B,C}; B vs C share root(Clone) → linker needed too.
95
+ const labels = derivePostfixes([
96
+ { stem: "Counts", hit, linkers: [linker("MapperA", axSample)] },
97
+ { stem: "Counts", hit, linkers: [linker("MapperA", axClone)] },
98
+ { stem: "Counts", hit, linkers: [linker("MapperB", axClone)] },
99
+ ]);
100
+ expect(labels).toEqual([
101
+ "Counts via Sample MapperA",
102
+ "Counts via Clone MapperA",
103
+ "Counts via Clone MapperB",
104
+ ]);
105
+ });
106
+
107
+ // Refinement (deferred): per-row minimal trim — row A only needs the root, so its ideal label is
108
+ // "Counts via Sample" without the redundant "MapperA". Requires an occurrence-count pass.
109
+ test.todo(
110
+ "per-row minimal trim: A should drop the non-load-bearing linker → 'Counts via Sample'",
111
+ );
112
+ });