@platforma-sdk/model 1.80.0 → 1.80.8

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 +110 -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
@@ -213,6 +213,96 @@ test.each<{ name: string; traces: Trace[]; labels: string[] }>([
213
213
  expect(deriveDistinctLabels(tracesToSpecs(traces))).toEqual(labels);
214
214
  });
215
215
 
216
+ // Preset distilled from a real PlDataTable "all columns" dump: three columns whose native label is
217
+ // "Cluster Id", produced by two different clustering analyses (foldseek 3D-structure clustering and
218
+ // mmseqs2 clonotype clustering). The two foldseek columns carry a byte-identical trace, so phase 1
219
+ // cannot tell them apart (a linker "via …" postfix does in the real pipeline); the mmseqs2 column
220
+ // differs by its last trace step. Regression guard for "distinguish by absence": minimization used
221
+ // to keep only ONE clustering type, leaving the other column a bare "Cluster Id" — unique only
222
+ // because it LACKED the peer's clustering step. Each column must instead surface its OWN step.
223
+ test("cluster-id preset: colliding columns are distinguished by a token they have, not by absence", () => {
224
+ const clusterId = (trace: Trace): PColumnSpec => ({
225
+ kind: "PColumn",
226
+ name: "name",
227
+ valueType: "String",
228
+ annotations: { [Annotation.Trace]: JSON.stringify(trace), [Annotation.Label]: "Cluster Id" },
229
+ axesSpec: [],
230
+ });
231
+
232
+ const provenance: Trace = [
233
+ { type: "milaboratories.samples-and-data", importance: 10, label: "Samples & Data" },
234
+ {
235
+ type: "milaboratories.samples-and-data/dataset",
236
+ importance: 100,
237
+ label: "MB135 + Podocytes",
238
+ },
239
+ {
240
+ type: "milaboratories.mixcr-amplicon-alignment",
241
+ importance: 20,
242
+ label: "MiXCR generic amplicon",
243
+ },
244
+ { type: "milaboratories.redefine-clonotypes", importance: 30, label: "Imputed VDJRegion aa" },
245
+ ];
246
+ const foldseek: Trace = [
247
+ ...provenance,
248
+ { type: "milaboratories.antibody-tcr-lead-selection", importance: 30, label: "Selected Leads" },
249
+ {
250
+ type: "milaboratories.3d-structure-prediction",
251
+ importance: 20,
252
+ label: "Camelid (VHH/nanobody) NBB2, CDRH3 ≤ 2.5 Å",
253
+ },
254
+ {
255
+ type: "milaboratories.3d-structure-clustering.clustering",
256
+ importance: 30,
257
+ label: "Full Structure+AA, TM≥0.95, cov≥0.95",
258
+ },
259
+ ];
260
+ const mmseqs2: Trace = [
261
+ ...provenance,
262
+ {
263
+ type: "milaboratories.clonotype-clustering.clustering",
264
+ importance: 30,
265
+ label: "Imputed VDJRegion aa, BLOSUM62, ident:0.95, cov:0.95",
266
+ },
267
+ ];
268
+
269
+ const entries: Entry[] = [clusterId(foldseek), clusterId(mmseqs2), clusterId(foldseek)];
270
+
271
+ expect(deriveDistinctLabels(entries, { includeNativeLabel: true })).toEqual([
272
+ "Cluster Id / Full Structure+AA, TM≥0.95, cov≥0.95",
273
+ "Cluster Id / Imputed VDJRegion aa, BLOSUM62, ident:0.95, cov:0.95",
274
+ "Cluster Id / Full Structure+AA, TM≥0.95, cov≥0.95",
275
+ ]);
276
+ });
277
+
278
+ // The by-presence repair must patch ONLY the bare column's own label — never the shared global type
279
+ // set. Otherwise the token it adds for one group ("X2" needs "t") leaks onto every column that
280
+ // carries that type, including "Y" in an unrelated group, which should stay a bare "Y".
281
+ test("by-presence repair does not leak its token into other groups sharing that type", () => {
282
+ const spec = (label: string, trace: Trace): PColumnSpec => ({
283
+ kind: "PColumn",
284
+ name: "name",
285
+ valueType: "Int",
286
+ annotations: { [Annotation.Trace]: JSON.stringify(trace), [Annotation.Label]: label },
287
+ axesSpec: [],
288
+ });
289
+
290
+ const entries: Entry[] = [
291
+ // Group X: minimization separates the two by the higher-importance "a" (X2 ends up bare),
292
+ // then repair un-bares X2 with the token it actually has — "t".
293
+ spec("X", [{ type: "a", importance: 10, label: "A1" }]),
294
+ spec("X", [{ type: "t", importance: 1, label: "TX" }]),
295
+ // Group Y: sole "Y" column, also carries "t". Must NOT inherit X2's repair token.
296
+ spec("Y", [{ type: "t", importance: 1, label: "TY" }]),
297
+ ];
298
+
299
+ expect(deriveDistinctLabels(entries, { includeNativeLabel: true })).toEqual([
300
+ "X / A1",
301
+ "X / TX",
302
+ "Y",
303
+ ]);
304
+ });
305
+
216
306
  test.each<{ name: string; traces: Trace[]; labels: string[]; forceTraceElements: string[] }>([
217
307
  {
218
308
  name: "force one element",
@@ -353,7 +443,9 @@ test("linkerPath with multiple steps joins with ' > '", () => {
353
443
  },
354
444
  ];
355
445
  const labels = deriveDistinctLabels(entries);
356
- expect(labels).toEqual(["Col", "Col via L1 > L2"]);
446
+ // Minimal difference: the source-most hop L1 alone distinguishes the linked column from the bare
447
+ // one, so the redundant L2 is dropped.
448
+ expect(labels).toEqual(["Col", "Col via L1"]);
357
449
  });
358
450
 
359
451
  test("linkerPath skips steps without labels", () => {
@@ -377,7 +469,7 @@ test("linkerPath skips steps without labels", () => {
377
469
  expect(labels).toEqual(["Col", "Col via L2"]);
378
470
  });
379
471
 
380
- test("linkerPath with custom linkerLabelFormatter", () => {
472
+ test("formatters.linker customizes the postfix zone", () => {
381
473
  const entries: Entry[] = [
382
474
  {
383
475
  spec: createSpec({
@@ -392,29 +484,32 @@ test("linkerPath with custom linkerLabelFormatter", () => {
392
484
  },
393
485
  ];
394
486
  const labels = deriveDistinctLabels(entries, {
395
- formatters: { linker: (linkerLabels) => `[${linkerLabels.join(", ")}]` },
487
+ formatters: {
488
+ linker: ({ root, linkers }) =>
489
+ `[${[root?.text, ...linkers.map((l) => l.text)].filter(Boolean).join(", ")}]`,
490
+ },
396
491
  });
397
492
  expect(labels).toEqual(["Col", "Col [L1]"]);
398
493
  });
399
494
 
400
- test("linkerPath with linkerLabelFormatter returning undefined suppresses suffix", () => {
495
+ test("formatters.linker returning undefined suppresses the postfix", () => {
401
496
  const entries: Entry[] = [
402
497
  {
403
498
  spec: createSpec({
404
- annotations: { [Annotation.Trace]: JSON.stringify([{ type: "t1", label: "Col1" }]) },
499
+ annotations: { [Annotation.Trace]: JSON.stringify([{ type: "t1", label: "Col" }]) },
405
500
  }),
406
501
  linkerPath: [{ spec: createSpec({ annotations: { [Annotation.LinkLabel]: "L1" } }) }],
407
502
  },
408
503
  {
409
504
  spec: createSpec({
410
- annotations: { [Annotation.Trace]: JSON.stringify([{ type: "t1", label: "Col2" }]) },
505
+ annotations: { [Annotation.Trace]: JSON.stringify([{ type: "t1", label: "Col" }]) },
411
506
  }),
507
+ linkerPath: [{ spec: createSpec({ annotations: { [Annotation.LinkLabel]: "L2" } }) }],
412
508
  },
413
509
  ];
414
- const labels = deriveDistinctLabels(entries, {
415
- formatters: { linker: () => undefined },
416
- });
417
- expect(labels).toEqual(["Col1", "Col2"]);
510
+ const labels = deriveDistinctLabels(entries, { formatters: { linker: () => undefined } });
511
+ // Suppressed → both keep the bare stem (collision left unresolved by the caller's choice).
512
+ expect(labels).toEqual(["Col", "Col"]);
418
513
  });
419
514
 
420
515
  test("linkerPath falls back to Label when LinkLabel is absent", () => {
@@ -522,39 +617,9 @@ test("formatters.anchorQualification receives anchorId", () => {
522
617
  expect(labels).toEqual(["Counts (A=X)", "Counts (A=Y)"]);
523
618
  });
524
619
 
525
- test("formatters.linkerStepQualification controls inline step quals", () => {
526
- const s = {
527
- kind: "PColumn",
528
- name: "n",
529
- valueType: "Int",
530
- axesSpec: [],
531
- annotations: { [Annotation.Label]: "Counts" },
532
- } as PColumnSpec;
533
- const entries: Entry[] = [
534
- {
535
- spec: s,
536
- linkerPath: [
537
- {
538
- spec: createSpec({ annotations: { [Annotation.LinkLabel]: "Mapper" } }),
539
- qualifications: [{ axis: { name: "sample" }, contextDomain: { batch: "X" } }],
540
- },
541
- ],
542
- },
543
- {
544
- spec: s,
545
- linkerPath: [
546
- {
547
- spec: createSpec({ annotations: { [Annotation.LinkLabel]: "Mapper" } }),
548
- qualifications: [{ axis: { name: "sample" }, contextDomain: { batch: "Y" } }],
549
- },
550
- ],
551
- },
552
- ];
553
- const labels = deriveDistinctLabels(entries, {
554
- formatters: { linkerStepQualification: (qs) => `(${qs[0].contextDomain.batch})` },
555
- });
556
- expect(labels).toEqual(["Counts via Mapper (X)", "Counts via Mapper (Y)"]);
557
- });
620
+ // Deferred: linker-step qualifications are not yet consumed by phase 2 — to be restored with
621
+ // qualification support (see linked_column_postfix).
622
+ test.todo("linker-step qualifications control inline step quals");
558
623
 
559
624
  test("addLabelAsSuffix places native label at the end", () => {
560
625
  const specs = tracesToSpecs([[{ type: "t1", label: "L1" }], [{ type: "t1", label: "L2" }]]);
@@ -737,16 +802,14 @@ describe("deriveDistinctLabels v2 — linker path & qualifications", () => {
737
802
  expect(deriveDistinctLabels(entries)).toEqual(["Counts via Path A", "Counts via Path B"]);
738
803
  });
739
804
 
740
- test("multi-step paths joined with ' > '", () => {
805
+ test("multi-step paths render only the differing hop (shared hub dropped)", () => {
741
806
  const s = labeledSpec("Counts");
742
807
  const entries: Entry[] = [
743
808
  { spec: s, linkerPath: [{ spec: linkerSpec("Hub") }, { spec: linkerSpec("Tail X") }] },
744
809
  { spec: s, linkerPath: [{ spec: linkerSpec("Hub") }, { spec: linkerSpec("Tail Y") }] },
745
810
  ];
746
- expect(deriveDistinctLabels(entries)).toEqual([
747
- "Counts via Hub > Tail X",
748
- "Counts via Hub > Tail Y",
749
- ]);
811
+ // Minimal difference: the common "Hub" hop carries no distinction and is dropped.
812
+ expect(deriveDistinctLabels(entries)).toEqual(["Counts via Tail X", "Counts via Tail Y"]);
750
813
  });
751
814
 
752
815
  test("hit qualifications used when nothing else differs", () => {
@@ -779,27 +842,8 @@ describe("deriveDistinctLabels v2 — linker path & qualifications", () => {
779
842
  ]);
780
843
  });
781
844
 
782
- test("linker-step qualifications used to disambiguate identical linker labels", () => {
783
- const s = labeledSpec("Counts");
784
- const entries: Entry[] = [
785
- {
786
- spec: s,
787
- linkerPath: [
788
- { spec: linkerSpec("Mapper"), qualifications: [qual("sample", { batch: "X" })] },
789
- ],
790
- },
791
- {
792
- spec: s,
793
- linkerPath: [
794
- { spec: linkerSpec("Mapper"), qualifications: [qual("sample", { batch: "Y" })] },
795
- ],
796
- },
797
- ];
798
- expect(deriveDistinctLabels(entries)).toEqual([
799
- "Counts via Mapper [sample batch=X]",
800
- "Counts via Mapper [sample batch=Y]",
801
- ]);
802
- });
845
+ // Deferred with qualification support in phase 2 (see linked_column_postfix).
846
+ test.todo("linker-step qualifications used to disambiguate identical linker labels");
803
847
 
804
848
  test("layers compose only as far as needed; no over-decoration", () => {
805
849
  const entries: Entry[] = [
@@ -898,10 +942,11 @@ describe("deriveDistinctLabels v2 — linker path & qualifications", () => {
898
942
  },
899
943
  { spec: sB, linkerPath: [{ spec: linkerSpec("Mapper") }] },
900
944
  ];
945
+ // Stems already differ by trace/anchor-qual, so the shared "Mapper" linker is never needed.
901
946
  expect(deriveDistinctLabels(entries)).toEqual([
902
- "Counts / RNAseq via Mapper",
903
- "Counts / RNAseq via Mapper [anchor-main: sample batch=X]",
904
- "Counts / ATACseq via Mapper",
947
+ "Counts / RNAseq",
948
+ "Counts / RNAseq [anchor-main: sample batch=X]",
949
+ "Counts / ATACseq",
905
950
  ]);
906
951
  });
907
952
 
@@ -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
+ }