@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.
- 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 +2 -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
|
@@ -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
|
+
}
|
|
@@ -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
|
+
});
|