@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.
- package/dist/columns/column.cjs.map +1 -1
- package/dist/columns/column.js.map +1 -1
- package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV3.cjs +1 -1
- package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV3.cjs.map +1 -1
- package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV3.js +1 -1
- package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV3.js.map +1 -1
- package/dist/components/PlDataTable/createPlDataTable/discoverColumns.cjs +4 -4
- package/dist/components/PlDataTable/createPlDataTable/discoverColumns.cjs.map +1 -1
- package/dist/components/PlDataTable/createPlDataTable/discoverColumns.d.ts +1 -1
- package/dist/components/PlDataTable/createPlDataTable/discoverColumns.d.ts.map +1 -1
- package/dist/components/PlDataTable/createPlDataTable/discoverColumns.js +5 -5
- package/dist/components/PlDataTable/createPlDataTable/discoverColumns.js.map +1 -1
- package/dist/labels/derive_distinct_labels.cjs +80 -57
- package/dist/labels/derive_distinct_labels.cjs.map +1 -1
- package/dist/labels/derive_distinct_labels.d.ts +16 -9
- package/dist/labels/derive_distinct_labels.d.ts.map +1 -1
- package/dist/labels/derive_distinct_labels.js +80 -57
- package/dist/labels/derive_distinct_labels.js.map +1 -1
- package/dist/labels/linked_column_postfix.cjs +189 -0
- package/dist/labels/linked_column_postfix.cjs.map +1 -0
- package/dist/labels/linked_column_postfix.d.ts +27 -0
- package/dist/labels/linked_column_postfix.d.ts.map +1 -0
- package/dist/labels/linked_column_postfix.js +188 -0
- package/dist/labels/linked_column_postfix.js.map +1 -0
- package/dist/package.cjs +1 -1
- package/dist/package.js +1 -1
- package/package.json +10 -10
- package/src/columns/column.ts +6 -6
- package/src/components/PlDataTable/createPlDataTable/createPlDataTableV3.ts +110 -2
- package/src/components/PlDataTable/createPlDataTable/discoverColumns.ts +14 -18
- package/src/labels/derive_distinct_labels.test.ts +117 -72
- package/src/labels/derive_distinct_labels.ts +141 -112
- package/src/labels/linked_column_postfix.test.ts +112 -0
- 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
|
-
|
|
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("
|
|
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: {
|
|
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("
|
|
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: "
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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
|
|
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
|
-
|
|
747
|
-
|
|
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
|
-
|
|
783
|
-
|
|
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
|
|
903
|
-
"Counts / RNAseq
|
|
904
|
-
"Counts / ATACseq
|
|
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 ===
|
|
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
|
|
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
|
|
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 (
|
|
64
|
-
*
|
|
65
|
-
linker?:
|
|
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
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
records,
|
|
171
|
+
const rendered = build(minimized, false) ?? throwError("Failed to derive unique labels");
|
|
172
|
+
return repairBareByPresence(
|
|
173
|
+
rendered,
|
|
159
174
|
minimized,
|
|
160
|
-
|
|
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
|
|
184
|
-
return
|
|
185
|
-
|
|
199
|
+
const rendered = build(minimized, true) ?? throwError("Failed to derive unique labels");
|
|
200
|
+
return repairBareByPresence(
|
|
201
|
+
rendered,
|
|
186
202
|
minimized,
|
|
187
|
-
|
|
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,
|
|
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 ===
|
|
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
|
+
}
|