@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
@@ -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
+ });
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Structural postfix derivation for linked columns (phase 2 of `deriveDistinctLabels`).
3
+ *
4
+ * A linked column is
5
+ * reached by a CHAIN of linkers, and we distinguish two such columns by the same ideology the main
6
+ * algorithm uses — "find the minimal difference, escalate until unique" — over two dimensions:
7
+ *
8
+ * - root: the single source axis of the chain, derived from `linkers[0]` (one of its axesSpec).
9
+ * This is "the source" — it's what we prefer to show ("difference of sources").
10
+ * - linkers: the linker chain itself (by `LinkLabel`), a per-step fallback used only where roots
11
+ * coincide.
12
+ *
13
+ * Nothing is stored redundantly: the caller passes the linker path (`linkers`) and the hit spec;
14
+ * root and every token are computed on the fly. The caller also supplies the `stem` (label+trace+
15
+ * quals from the existing single-entity machinery); this module only adds the path postfix, and
16
+ * only where stems collide.
17
+ */
18
+ import {
19
+ Annotation,
20
+ canonicalizeJson,
21
+ getAxisId,
22
+ readAnnotation,
23
+ type AxisSpec,
24
+ type PColumnSpec,
25
+ } from "@milaboratories/pl-model-common";
26
+ import { isNil } from "es-toolkit";
27
+
28
+ /**
29
+ * One entry to label: its stem plus the linker path that reached it (`linkers[0]` source-most) and
30
+ * the hit spec used to orient the chain. Plain columns pass neither.
31
+ */
32
+ export type PostfixEntry = { stem: string; hit?: PColumnSpec; linkers?: PColumnSpec[] };
33
+
34
+ /**
35
+ * One distinguishing piece of the postfix: the full source spec (axis or linker column — no info
36
+ * lost) plus `text`, the minimal text that sets it apart from the siblings (a label, or only the
37
+ * differing domain keys). Custom formatters may use `spec` freely; `text` is the default rendering.
38
+ */
39
+ export type LinkerPart<Spec> = { spec: Spec; text: string };
40
+
41
+ /** The distinguishing source of one column: its root axis (when it differs) + the linker chain. */
42
+ export type LinkerParts = {
43
+ root?: LinkerPart<AxisSpec>;
44
+ linkers: LinkerPart<PColumnSpec>[];
45
+ };
46
+
47
+ /**
48
+ * Renders the postfix zone from the distinguishing sources. Default:
49
+ * `via ${[root, linkers.join(" > ")].filter(Boolean).join(" ")}` (using each piece's `text`).
50
+ * Returning `undefined` — or the empty string `""` — suppresses the postfix entirely (the column
51
+ * keeps just its stem); the two are treated identically.
52
+ */
53
+ export type LinkerFormatter = (
54
+ parts: LinkerParts,
55
+ hit: PColumnSpec | undefined,
56
+ index: number,
57
+ ) => string | undefined;
58
+
59
+ function defaultLinkerFormatter(parts: LinkerParts): string {
60
+ const chain = parts.linkers.map((l) => l.text).join(" > ");
61
+ const pieces = [parts.root?.text, chain || undefined].filter((p): p is string => !isNil(p));
62
+ return pieces.length === 0 ? "" : `via ${pieces.join(" ")}`;
63
+ }
64
+
65
+ type AxisIdentity = { label?: string } & Pick<AxisSpec, "name" | "domain" | "contextDomain">;
66
+
67
+ function extractAxisIdentity(axis: AxisSpec): AxisIdentity {
68
+ const id = getAxisId(axis);
69
+ return {
70
+ label: readAnnotation(axis, Annotation.Label)?.trim() || undefined,
71
+ name: id.name,
72
+ domain: id.domain,
73
+ contextDomain: id.contextDomain,
74
+ };
75
+ }
76
+
77
+ /**
78
+ * A linker is named ONLY by its human label (`LinkLabel`/`Label`) — never by its technical column
79
+ * name. Returns `undefined` for an unlabeled linker, which callers treat as "not renderable": such a
80
+ * step is skipped, and disambiguation falls to another step or the root.
81
+ */
82
+ function extractLinkerIdentity(linker: PColumnSpec): AxisIdentity | undefined {
83
+ const label = (
84
+ readAnnotation(linker, Annotation.LinkLabel) ?? readAnnotation(linker, Annotation.Label)
85
+ )?.trim();
86
+ return label ? { label, name: label } : undefined;
87
+ }
88
+
89
+ /** Domain keys where `mine` differs from at least one competitor. */
90
+ function differingKeys(
91
+ mine: Record<string, string> | undefined,
92
+ others: (Record<string, string> | undefined)[],
93
+ ): string[] {
94
+ const my = mine ?? {};
95
+ const diffKeys = new Set<string>();
96
+ for (const o of others) {
97
+ const od = o ?? {};
98
+ for (const k of Object.keys(my)) {
99
+ if (my[k] !== od[k]) {
100
+ diffKeys.add(k);
101
+ }
102
+ }
103
+ for (const k of Object.keys(od)) {
104
+ if (my[k] !== od[k]) {
105
+ diffKeys.add(k);
106
+ }
107
+ }
108
+ }
109
+ return [...diffKeys];
110
+ }
111
+
112
+ function renderWithDomain(
113
+ base: string,
114
+ keys: string[],
115
+ domain: Record<string, string> | undefined,
116
+ ): string {
117
+ if (keys.length === 0) return base;
118
+ const d = domain ?? {};
119
+ const pairs = keys.map((k) => `${k}=${d[k] ?? "∅"}`).join(", ");
120
+ return `${base}[${pairs}]`;
121
+ }
122
+
123
+ /**
124
+ * Minimal token distinguishing `mine` from `others`: label → name → only the differing domain keys.
125
+ * Falls back to the bare label/name when indistinguishable at this dimension (another one covers it).
126
+ */
127
+ function deriveToken(mine: AxisIdentity, others: AxisIdentity[]): string {
128
+ const base = mine.label ?? mine.name;
129
+ if (mine.label !== undefined && others.every((o) => o.label !== mine.label)) return mine.label;
130
+ if (others.every((o) => o.name !== mine.name)) return base;
131
+ const dk = differingKeys(
132
+ mine.domain,
133
+ others.map((o) => o.domain),
134
+ );
135
+ if (dk.length > 0) return renderWithDomain(base, dk, mine.domain);
136
+ const ck = differingKeys(
137
+ mine.contextDomain,
138
+ others.map((o) => o.contextDomain),
139
+ );
140
+ if (ck.length > 0) return renderWithDomain(base, ck, mine.contextDomain);
141
+ return base;
142
+ }
143
+
144
+ /**
145
+ * The chain's single root: the source-side axis of `linkers[0]`. A linker bridges two axis groups;
146
+ * the side facing the next hop (`linkers[1]`, or the hit for a single-linker chain) is the target,
147
+ * the other side is the source → its axis is the root. `undefined` if `linkers[0]` is not a
148
+ * well-formed two-group linker.
149
+ */
150
+ export function extractRoot(hit: PColumnSpec, linkers: PColumnSpec[]): AxisSpec | undefined {
151
+ const first = linkers[0];
152
+ if (first === undefined) return undefined;
153
+ const axes = first.axesSpec;
154
+ if (axes.length !== 2) return undefined;
155
+ const second = linkers[1] ?? hit;
156
+ return axes.find((a) => !second.axesSpec.some((b) => b.name === a.name));
157
+ }
158
+
159
+ type Slot = { kind: "root" } | { kind: "linker"; i: number };
160
+
161
+ const ABSENT = "__ABSENT__"; // sentinel for "this row has no value at this slot"
162
+
163
+ /** A colliding group: entries + their derived roots + original indices + the postfix formatter. */
164
+ type Group = {
165
+ entries: PostfixEntry[];
166
+ roots: (AxisSpec | undefined)[];
167
+ indices: number[];
168
+ format: LinkerFormatter;
169
+ };
170
+
171
+ function deriveSlotKey(group: Group, slot: Slot, row: number): string {
172
+ if (slot.kind === "root") {
173
+ const r = group.roots[row];
174
+ return r === undefined ? ABSENT : canonicalizeJson(getAxisId(r));
175
+ } else {
176
+ const l = group.entries[row].linkers?.[slot.i];
177
+ const id = l && extractLinkerIdentity(l);
178
+ return isNil(id) ? ABSENT : canonicalizeJson(id);
179
+ }
180
+ }
181
+
182
+ function deriveSlotToken(group: Group, slot: Slot, row: number): string | undefined {
183
+ if (slot.kind === "root") {
184
+ const r = group.roots[row];
185
+ if (r === undefined) return undefined;
186
+ const comp = group.roots
187
+ .filter((o, j) => j !== row && !isNil(o))
188
+ .map((o) => extractAxisIdentity(o!));
189
+ return deriveToken(extractAxisIdentity(r), comp);
190
+ }
191
+ const l = group.entries[row].linkers?.[slot.i];
192
+ const id = l && extractLinkerIdentity(l);
193
+ if (isNil(id)) return undefined; // unlabeled / absent linker → not renderable, skip
194
+ const comp = group.entries
195
+ .filter((_, j) => j !== row)
196
+ .map((e) => e.linkers?.[slot.i])
197
+ .map((x) => (x ? extractLinkerIdentity(x) : undefined))
198
+ .filter((x): x is AxisIdentity => !isNil(x));
199
+ return deriveToken(id, comp);
200
+ }
201
+
202
+ function getDiscriminates(group: Group, slot: Slot): boolean {
203
+ return new Set(group.entries.map((_, r) => deriveSlotKey(group, slot, r))).size > 1;
204
+ }
205
+
206
+ /** Render one row against a chosen slot set: the distinguishing root + linker pieces, formatted. */
207
+ function renderRow(group: Group, slots: Slot[], row: number): string {
208
+ const rootSpec = group.roots[row];
209
+ const rootText = slots.some((s) => s.kind === "root")
210
+ ? deriveSlotToken(group, { kind: "root" }, row)
211
+ : undefined;
212
+ const root: LinkerPart<AxisSpec> | undefined =
213
+ rootSpec !== undefined && rootText !== undefined
214
+ ? { spec: rootSpec, text: rootText }
215
+ : undefined;
216
+
217
+ const linkers = slots
218
+ .filter((s): s is { kind: "linker"; i: number } => s.kind === "linker")
219
+ .sort((a, b) => a.i - b.i)
220
+ .map((s) => {
221
+ const spec = group.entries[row].linkers?.[s.i];
222
+ const text = deriveSlotToken(group, s, row);
223
+ return spec !== undefined && text !== undefined ? { spec, text } : undefined;
224
+ })
225
+ .filter((l): l is LinkerPart<PColumnSpec> => !isNil(l));
226
+
227
+ // No distinguishing pieces for this row → no postfix (don't invoke the formatter with empties).
228
+ if (root === undefined && linkers.length === 0) return "";
229
+ // A formatter may suppress the postfix by returning `undefined` or `""`; `|| ""` collapses both
230
+ // to the same empty value so neither counts as a distinguishing token during slot escalation.
231
+ return group.format({ root, linkers }, group.entries[row].hit, group.indices[row]) || "";
232
+ }
233
+
234
+ function renderAll(group: Group, slots: Slot[]): string[] {
235
+ return group.entries.map((_, r) => renderRow(group, slots, r));
236
+ }
237
+
238
+ function allUnique(rendered: string[]): boolean {
239
+ return new Set(rendered).size === rendered.length;
240
+ }
241
+
242
+ /**
243
+ * Minimal slot set that makes the group unique. Escalate by priority (root, then linkers by step),
244
+ * then drop any redundant slot; render every row symmetrically against the result.
245
+ *
246
+ * KNOWN LIMITATION (review point): symmetric render can over-decorate a row in a mixed group (e.g.
247
+ * `via Sample MapperA` where `via Sample` alone is already unique for that row). Per-row trimming is
248
+ * a generalized `dropRedundantLinkerSuffix`; naive greedy trimming is unstable, so it's deferred.
249
+ */
250
+ function resolveGroup(
251
+ entries: PostfixEntry[],
252
+ indices: number[],
253
+ format: LinkerFormatter,
254
+ ): string[] {
255
+ const group: Group = {
256
+ entries,
257
+ roots: entries.map((e) =>
258
+ e.hit && e.linkers?.length ? extractRoot(e.hit, e.linkers) : undefined,
259
+ ),
260
+ indices,
261
+ format,
262
+ };
263
+ const maxLen = Math.max(0, ...entries.map((e) => e.linkers?.length ?? 0));
264
+
265
+ const slots: Slot[] = [
266
+ { kind: "root" },
267
+ ...Array.from({ length: maxLen }, (_, i): Slot => ({ kind: "linker", i })),
268
+ ];
269
+
270
+ const escalated = slots.reduce<Slot[]>(
271
+ (acc, slot) =>
272
+ allUnique(renderAll(group, acc)) || !getDiscriminates(group, slot)
273
+ ? acc
274
+ : (acc.push(slot), acc),
275
+ [],
276
+ );
277
+ const chosen = escalated.reduce<Slot[]>((acc, slot) => {
278
+ const trial = acc.filter((s) => s !== slot);
279
+ return allUnique(renderAll(group, trial)) ? trial : acc;
280
+ }, escalated);
281
+
282
+ return renderAll(group, chosen);
283
+ }
284
+
285
+ /**
286
+ * Full label per entry: `stem` plus, where stems collide, a minimal postfix distinguishing the
287
+ * linked columns by the difference between their sources.
288
+ */
289
+ export function derivePostfixes(
290
+ entries: PostfixEntry[],
291
+ format: LinkerFormatter = defaultLinkerFormatter,
292
+ ): string[] {
293
+ const groups = entries.reduce<Map<string, number[]>>(
294
+ (acc, e, idx) => acc.set(e.stem, [...(acc.get(e.stem) ?? []), idx]),
295
+ new Map(),
296
+ );
297
+
298
+ const postfix = [...groups.values()].reduce<Map<number, string>>((acc, idxs) => {
299
+ if (idxs.length < 2) return acc; // stem already unique — no postfix
300
+ const resolved = resolveGroup(
301
+ idxs.map((i) => entries[i]),
302
+ idxs,
303
+ format,
304
+ );
305
+ return idxs.reduce((m, i, k) => m.set(i, resolved[k]), acc);
306
+ }, new Map());
307
+
308
+ return entries.map((e, i) => {
309
+ const p = postfix.get(i);
310
+ return p ? `${e.stem} ${p}` : e.stem;
311
+ });
312
+ }