@platforma-sdk/model 1.80.2 → 1.80.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/columns/column.cjs.map +1 -1
  2. package/dist/columns/column.js.map +1 -1
  3. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV3.cjs +1 -1
  4. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV3.cjs.map +1 -1
  5. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV3.js +1 -1
  6. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV3.js.map +1 -1
  7. package/dist/components/PlDataTable/createPlDataTable/discoverColumns.cjs +4 -4
  8. package/dist/components/PlDataTable/createPlDataTable/discoverColumns.cjs.map +1 -1
  9. package/dist/components/PlDataTable/createPlDataTable/discoverColumns.d.ts +1 -1
  10. package/dist/components/PlDataTable/createPlDataTable/discoverColumns.d.ts.map +1 -1
  11. package/dist/components/PlDataTable/createPlDataTable/discoverColumns.js +5 -5
  12. package/dist/components/PlDataTable/createPlDataTable/discoverColumns.js.map +1 -1
  13. package/dist/labels/derive_distinct_labels.cjs +80 -57
  14. package/dist/labels/derive_distinct_labels.cjs.map +1 -1
  15. package/dist/labels/derive_distinct_labels.d.ts +16 -9
  16. package/dist/labels/derive_distinct_labels.d.ts.map +1 -1
  17. package/dist/labels/derive_distinct_labels.js +80 -57
  18. package/dist/labels/derive_distinct_labels.js.map +1 -1
  19. package/dist/labels/linked_column_postfix.cjs +189 -0
  20. package/dist/labels/linked_column_postfix.cjs.map +1 -0
  21. package/dist/labels/linked_column_postfix.d.ts +27 -0
  22. package/dist/labels/linked_column_postfix.d.ts.map +1 -0
  23. package/dist/labels/linked_column_postfix.js +188 -0
  24. package/dist/labels/linked_column_postfix.js.map +1 -0
  25. package/dist/package.cjs +1 -1
  26. package/dist/package.js +1 -1
  27. package/package.json +10 -10
  28. package/src/columns/column.ts +6 -6
  29. package/src/components/PlDataTable/createPlDataTable/createPlDataTableV3.ts +2 -2
  30. package/src/components/PlDataTable/createPlDataTable/discoverColumns.ts +14 -18
  31. package/src/labels/derive_distinct_labels.test.ts +117 -72
  32. package/src/labels/derive_distinct_labels.ts +141 -112
  33. package/src/labels/linked_column_postfix.test.ts +112 -0
  34. package/src/labels/linked_column_postfix.ts +312 -0
@@ -0,0 +1,188 @@
1
+ import { Annotation, canonicalizeJson, getAxisId, readAnnotation } from "@milaboratories/pl-model-common";
2
+ import { isNil } from "es-toolkit";
3
+ //#region src/labels/linked_column_postfix.ts
4
+ /**
5
+ * Structural postfix derivation for linked columns (phase 2 of `deriveDistinctLabels`).
6
+ *
7
+ * A linked column is
8
+ * reached by a CHAIN of linkers, and we distinguish two such columns by the same ideology the main
9
+ * algorithm uses — "find the minimal difference, escalate until unique" — over two dimensions:
10
+ *
11
+ * - root: the single source axis of the chain, derived from `linkers[0]` (one of its axesSpec).
12
+ * This is "the source" — it's what we prefer to show ("difference of sources").
13
+ * - linkers: the linker chain itself (by `LinkLabel`), a per-step fallback used only where roots
14
+ * coincide.
15
+ *
16
+ * Nothing is stored redundantly: the caller passes the linker path (`linkers`) and the hit spec;
17
+ * root and every token are computed on the fly. The caller also supplies the `stem` (label+trace+
18
+ * quals from the existing single-entity machinery); this module only adds the path postfix, and
19
+ * only where stems collide.
20
+ */
21
+ function defaultLinkerFormatter(parts) {
22
+ const chain = parts.linkers.map((l) => l.text).join(" > ");
23
+ const pieces = [parts.root?.text, chain || void 0].filter((p) => !isNil(p));
24
+ return pieces.length === 0 ? "" : `via ${pieces.join(" ")}`;
25
+ }
26
+ function extractAxisIdentity(axis) {
27
+ const id = getAxisId(axis);
28
+ return {
29
+ label: readAnnotation(axis, Annotation.Label)?.trim() || void 0,
30
+ name: id.name,
31
+ domain: id.domain,
32
+ contextDomain: id.contextDomain
33
+ };
34
+ }
35
+ /**
36
+ * A linker is named ONLY by its human label (`LinkLabel`/`Label`) — never by its technical column
37
+ * name. Returns `undefined` for an unlabeled linker, which callers treat as "not renderable": such a
38
+ * step is skipped, and disambiguation falls to another step or the root.
39
+ */
40
+ function extractLinkerIdentity(linker) {
41
+ const label = (readAnnotation(linker, Annotation.LinkLabel) ?? readAnnotation(linker, Annotation.Label))?.trim();
42
+ return label ? {
43
+ label,
44
+ name: label
45
+ } : void 0;
46
+ }
47
+ /** Domain keys where `mine` differs from at least one competitor. */
48
+ function differingKeys(mine, others) {
49
+ const my = mine ?? {};
50
+ const diffKeys = /* @__PURE__ */ new Set();
51
+ for (const o of others) {
52
+ const od = o ?? {};
53
+ for (const k of Object.keys(my)) if (my[k] !== od[k]) diffKeys.add(k);
54
+ for (const k of Object.keys(od)) if (my[k] !== od[k]) diffKeys.add(k);
55
+ }
56
+ return [...diffKeys];
57
+ }
58
+ function renderWithDomain(base, keys, domain) {
59
+ if (keys.length === 0) return base;
60
+ const d = domain ?? {};
61
+ return `${base}[${keys.map((k) => `${k}=${d[k] ?? "∅"}`).join(", ")}]`;
62
+ }
63
+ /**
64
+ * Minimal token distinguishing `mine` from `others`: label → name → only the differing domain keys.
65
+ * Falls back to the bare label/name when indistinguishable at this dimension (another one covers it).
66
+ */
67
+ function deriveToken(mine, others) {
68
+ const base = mine.label ?? mine.name;
69
+ if (mine.label !== void 0 && others.every((o) => o.label !== mine.label)) return mine.label;
70
+ if (others.every((o) => o.name !== mine.name)) return base;
71
+ const dk = differingKeys(mine.domain, others.map((o) => o.domain));
72
+ if (dk.length > 0) return renderWithDomain(base, dk, mine.domain);
73
+ const ck = differingKeys(mine.contextDomain, others.map((o) => o.contextDomain));
74
+ if (ck.length > 0) return renderWithDomain(base, ck, mine.contextDomain);
75
+ return base;
76
+ }
77
+ /**
78
+ * The chain's single root: the source-side axis of `linkers[0]`. A linker bridges two axis groups;
79
+ * the side facing the next hop (`linkers[1]`, or the hit for a single-linker chain) is the target,
80
+ * the other side is the source → its axis is the root. `undefined` if `linkers[0]` is not a
81
+ * well-formed two-group linker.
82
+ */
83
+ function extractRoot(hit, linkers) {
84
+ const first = linkers[0];
85
+ if (first === void 0) return void 0;
86
+ const axes = first.axesSpec;
87
+ if (axes.length !== 2) return void 0;
88
+ const second = linkers[1] ?? hit;
89
+ return axes.find((a) => !second.axesSpec.some((b) => b.name === a.name));
90
+ }
91
+ const ABSENT = "__ABSENT__";
92
+ function deriveSlotKey(group, slot, row) {
93
+ if (slot.kind === "root") {
94
+ const r = group.roots[row];
95
+ return r === void 0 ? ABSENT : canonicalizeJson(getAxisId(r));
96
+ } else {
97
+ const l = group.entries[row].linkers?.[slot.i];
98
+ const id = l && extractLinkerIdentity(l);
99
+ return isNil(id) ? ABSENT : canonicalizeJson(id);
100
+ }
101
+ }
102
+ function deriveSlotToken(group, slot, row) {
103
+ if (slot.kind === "root") {
104
+ const r = group.roots[row];
105
+ if (r === void 0) return void 0;
106
+ const comp = group.roots.filter((o, j) => j !== row && !isNil(o)).map((o) => extractAxisIdentity(o));
107
+ return deriveToken(extractAxisIdentity(r), comp);
108
+ }
109
+ const l = group.entries[row].linkers?.[slot.i];
110
+ const id = l && extractLinkerIdentity(l);
111
+ if (isNil(id)) return void 0;
112
+ return deriveToken(id, group.entries.filter((_, j) => j !== row).map((e) => e.linkers?.[slot.i]).map((x) => x ? extractLinkerIdentity(x) : void 0).filter((x) => !isNil(x)));
113
+ }
114
+ function getDiscriminates(group, slot) {
115
+ return new Set(group.entries.map((_, r) => deriveSlotKey(group, slot, r))).size > 1;
116
+ }
117
+ /** Render one row against a chosen slot set: the distinguishing root + linker pieces, formatted. */
118
+ function renderRow(group, slots, row) {
119
+ const rootSpec = group.roots[row];
120
+ const rootText = slots.some((s) => s.kind === "root") ? deriveSlotToken(group, { kind: "root" }, row) : void 0;
121
+ const root = rootSpec !== void 0 && rootText !== void 0 ? {
122
+ spec: rootSpec,
123
+ text: rootText
124
+ } : void 0;
125
+ const linkers = slots.filter((s) => s.kind === "linker").sort((a, b) => a.i - b.i).map((s) => {
126
+ const spec = group.entries[row].linkers?.[s.i];
127
+ const text = deriveSlotToken(group, s, row);
128
+ return spec !== void 0 && text !== void 0 ? {
129
+ spec,
130
+ text
131
+ } : void 0;
132
+ }).filter((l) => !isNil(l));
133
+ if (root === void 0 && linkers.length === 0) return "";
134
+ return group.format({
135
+ root,
136
+ linkers
137
+ }, group.entries[row].hit, group.indices[row]) || "";
138
+ }
139
+ function renderAll(group, slots) {
140
+ return group.entries.map((_, r) => renderRow(group, slots, r));
141
+ }
142
+ function allUnique(rendered) {
143
+ return new Set(rendered).size === rendered.length;
144
+ }
145
+ /**
146
+ * Minimal slot set that makes the group unique. Escalate by priority (root, then linkers by step),
147
+ * then drop any redundant slot; render every row symmetrically against the result.
148
+ *
149
+ * KNOWN LIMITATION (review point): symmetric render can over-decorate a row in a mixed group (e.g.
150
+ * `via Sample MapperA` where `via Sample` alone is already unique for that row). Per-row trimming is
151
+ * a generalized `dropRedundantLinkerSuffix`; naive greedy trimming is unstable, so it's deferred.
152
+ */
153
+ function resolveGroup(entries, indices, format) {
154
+ const group = {
155
+ entries,
156
+ roots: entries.map((e) => e.hit && e.linkers?.length ? extractRoot(e.hit, e.linkers) : void 0),
157
+ indices,
158
+ format
159
+ };
160
+ const maxLen = Math.max(0, ...entries.map((e) => e.linkers?.length ?? 0));
161
+ const escalated = [{ kind: "root" }, ...Array.from({ length: maxLen }, (_, i) => ({
162
+ kind: "linker",
163
+ i
164
+ }))].reduce((acc, slot) => allUnique(renderAll(group, acc)) || !getDiscriminates(group, slot) ? acc : (acc.push(slot), acc), []);
165
+ return renderAll(group, escalated.reduce((acc, slot) => {
166
+ const trial = acc.filter((s) => s !== slot);
167
+ return allUnique(renderAll(group, trial)) ? trial : acc;
168
+ }, escalated));
169
+ }
170
+ /**
171
+ * Full label per entry: `stem` plus, where stems collide, a minimal postfix distinguishing the
172
+ * linked columns by the difference between their sources.
173
+ */
174
+ function derivePostfixes(entries, format = defaultLinkerFormatter) {
175
+ const postfix = [...entries.reduce((acc, e, idx) => acc.set(e.stem, [...acc.get(e.stem) ?? [], idx]), /* @__PURE__ */ new Map()).values()].reduce((acc, idxs) => {
176
+ if (idxs.length < 2) return acc;
177
+ const resolved = resolveGroup(idxs.map((i) => entries[i]), idxs, format);
178
+ return idxs.reduce((m, i, k) => m.set(i, resolved[k]), acc);
179
+ }, /* @__PURE__ */ new Map());
180
+ return entries.map((e, i) => {
181
+ const p = postfix.get(i);
182
+ return p ? `${e.stem} ${p}` : e.stem;
183
+ });
184
+ }
185
+ //#endregion
186
+ export { derivePostfixes, extractRoot };
187
+
188
+ //# sourceMappingURL=linked_column_postfix.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"linked_column_postfix.js","names":[],"sources":["../../src/labels/linked_column_postfix.ts"],"sourcesContent":["/**\n * Structural postfix derivation for linked columns (phase 2 of `deriveDistinctLabels`).\n *\n * A linked column is\n * reached by a CHAIN of linkers, and we distinguish two such columns by the same ideology the main\n * algorithm uses — \"find the minimal difference, escalate until unique\" — over two dimensions:\n *\n * - root: the single source axis of the chain, derived from `linkers[0]` (one of its axesSpec).\n * This is \"the source\" — it's what we prefer to show (\"difference of sources\").\n * - linkers: the linker chain itself (by `LinkLabel`), a per-step fallback used only where roots\n * coincide.\n *\n * Nothing is stored redundantly: the caller passes the linker path (`linkers`) and the hit spec;\n * root and every token are computed on the fly. The caller also supplies the `stem` (label+trace+\n * quals from the existing single-entity machinery); this module only adds the path postfix, and\n * only where stems collide.\n */\nimport {\n Annotation,\n canonicalizeJson,\n getAxisId,\n readAnnotation,\n type AxisSpec,\n type PColumnSpec,\n} from \"@milaboratories/pl-model-common\";\nimport { isNil } from \"es-toolkit\";\n\n/**\n * One entry to label: its stem plus the linker path that reached it (`linkers[0]` source-most) and\n * the hit spec used to orient the chain. Plain columns pass neither.\n */\nexport type PostfixEntry = { stem: string; hit?: PColumnSpec; linkers?: PColumnSpec[] };\n\n/**\n * One distinguishing piece of the postfix: the full source spec (axis or linker column — no info\n * lost) plus `text`, the minimal text that sets it apart from the siblings (a label, or only the\n * differing domain keys). Custom formatters may use `spec` freely; `text` is the default rendering.\n */\nexport type LinkerPart<Spec> = { spec: Spec; text: string };\n\n/** The distinguishing source of one column: its root axis (when it differs) + the linker chain. */\nexport type LinkerParts = {\n root?: LinkerPart<AxisSpec>;\n linkers: LinkerPart<PColumnSpec>[];\n};\n\n/**\n * Renders the postfix zone from the distinguishing sources. Default:\n * `via ${[root, linkers.join(\" > \")].filter(Boolean).join(\" \")}` (using each piece's `text`).\n * Returning `undefined` — or the empty string `\"\"` — suppresses the postfix entirely (the column\n * keeps just its stem); the two are treated identically.\n */\nexport type LinkerFormatter = (\n parts: LinkerParts,\n hit: PColumnSpec | undefined,\n index: number,\n) => string | undefined;\n\nfunction defaultLinkerFormatter(parts: LinkerParts): string {\n const chain = parts.linkers.map((l) => l.text).join(\" > \");\n const pieces = [parts.root?.text, chain || undefined].filter((p): p is string => !isNil(p));\n return pieces.length === 0 ? \"\" : `via ${pieces.join(\" \")}`;\n}\n\ntype AxisIdentity = { label?: string } & Pick<AxisSpec, \"name\" | \"domain\" | \"contextDomain\">;\n\nfunction extractAxisIdentity(axis: AxisSpec): AxisIdentity {\n const id = getAxisId(axis);\n return {\n label: readAnnotation(axis, Annotation.Label)?.trim() || undefined,\n name: id.name,\n domain: id.domain,\n contextDomain: id.contextDomain,\n };\n}\n\n/**\n * A linker is named ONLY by its human label (`LinkLabel`/`Label`) — never by its technical column\n * name. Returns `undefined` for an unlabeled linker, which callers treat as \"not renderable\": such a\n * step is skipped, and disambiguation falls to another step or the root.\n */\nfunction extractLinkerIdentity(linker: PColumnSpec): AxisIdentity | undefined {\n const label = (\n readAnnotation(linker, Annotation.LinkLabel) ?? readAnnotation(linker, Annotation.Label)\n )?.trim();\n return label ? { label, name: label } : undefined;\n}\n\n/** Domain keys where `mine` differs from at least one competitor. */\nfunction differingKeys(\n mine: Record<string, string> | undefined,\n others: (Record<string, string> | undefined)[],\n): string[] {\n const my = mine ?? {};\n const diffKeys = new Set<string>();\n for (const o of others) {\n const od = o ?? {};\n for (const k of Object.keys(my)) {\n if (my[k] !== od[k]) {\n diffKeys.add(k);\n }\n }\n for (const k of Object.keys(od)) {\n if (my[k] !== od[k]) {\n diffKeys.add(k);\n }\n }\n }\n return [...diffKeys];\n}\n\nfunction renderWithDomain(\n base: string,\n keys: string[],\n domain: Record<string, string> | undefined,\n): string {\n if (keys.length === 0) return base;\n const d = domain ?? {};\n const pairs = keys.map((k) => `${k}=${d[k] ?? \"∅\"}`).join(\", \");\n return `${base}[${pairs}]`;\n}\n\n/**\n * Minimal token distinguishing `mine` from `others`: label → name → only the differing domain keys.\n * Falls back to the bare label/name when indistinguishable at this dimension (another one covers it).\n */\nfunction deriveToken(mine: AxisIdentity, others: AxisIdentity[]): string {\n const base = mine.label ?? mine.name;\n if (mine.label !== undefined && others.every((o) => o.label !== mine.label)) return mine.label;\n if (others.every((o) => o.name !== mine.name)) return base;\n const dk = differingKeys(\n mine.domain,\n others.map((o) => o.domain),\n );\n if (dk.length > 0) return renderWithDomain(base, dk, mine.domain);\n const ck = differingKeys(\n mine.contextDomain,\n others.map((o) => o.contextDomain),\n );\n if (ck.length > 0) return renderWithDomain(base, ck, mine.contextDomain);\n return base;\n}\n\n/**\n * The chain's single root: the source-side axis of `linkers[0]`. A linker bridges two axis groups;\n * the side facing the next hop (`linkers[1]`, or the hit for a single-linker chain) is the target,\n * the other side is the source → its axis is the root. `undefined` if `linkers[0]` is not a\n * well-formed two-group linker.\n */\nexport function extractRoot(hit: PColumnSpec, linkers: PColumnSpec[]): AxisSpec | undefined {\n const first = linkers[0];\n if (first === undefined) return undefined;\n const axes = first.axesSpec;\n if (axes.length !== 2) return undefined;\n const second = linkers[1] ?? hit;\n return axes.find((a) => !second.axesSpec.some((b) => b.name === a.name));\n}\n\ntype Slot = { kind: \"root\" } | { kind: \"linker\"; i: number };\n\nconst ABSENT = \"__ABSENT__\"; // sentinel for \"this row has no value at this slot\"\n\n/** A colliding group: entries + their derived roots + original indices + the postfix formatter. */\ntype Group = {\n entries: PostfixEntry[];\n roots: (AxisSpec | undefined)[];\n indices: number[];\n format: LinkerFormatter;\n};\n\nfunction deriveSlotKey(group: Group, slot: Slot, row: number): string {\n if (slot.kind === \"root\") {\n const r = group.roots[row];\n return r === undefined ? ABSENT : canonicalizeJson(getAxisId(r));\n } else {\n const l = group.entries[row].linkers?.[slot.i];\n const id = l && extractLinkerIdentity(l);\n return isNil(id) ? ABSENT : canonicalizeJson(id);\n }\n}\n\nfunction deriveSlotToken(group: Group, slot: Slot, row: number): string | undefined {\n if (slot.kind === \"root\") {\n const r = group.roots[row];\n if (r === undefined) return undefined;\n const comp = group.roots\n .filter((o, j) => j !== row && !isNil(o))\n .map((o) => extractAxisIdentity(o!));\n return deriveToken(extractAxisIdentity(r), comp);\n }\n const l = group.entries[row].linkers?.[slot.i];\n const id = l && extractLinkerIdentity(l);\n if (isNil(id)) return undefined; // unlabeled / absent linker → not renderable, skip\n const comp = group.entries\n .filter((_, j) => j !== row)\n .map((e) => e.linkers?.[slot.i])\n .map((x) => (x ? extractLinkerIdentity(x) : undefined))\n .filter((x): x is AxisIdentity => !isNil(x));\n return deriveToken(id, comp);\n}\n\nfunction getDiscriminates(group: Group, slot: Slot): boolean {\n return new Set(group.entries.map((_, r) => deriveSlotKey(group, slot, r))).size > 1;\n}\n\n/** Render one row against a chosen slot set: the distinguishing root + linker pieces, formatted. */\nfunction renderRow(group: Group, slots: Slot[], row: number): string {\n const rootSpec = group.roots[row];\n const rootText = slots.some((s) => s.kind === \"root\")\n ? deriveSlotToken(group, { kind: \"root\" }, row)\n : undefined;\n const root: LinkerPart<AxisSpec> | undefined =\n rootSpec !== undefined && rootText !== undefined\n ? { spec: rootSpec, text: rootText }\n : undefined;\n\n const linkers = slots\n .filter((s): s is { kind: \"linker\"; i: number } => s.kind === \"linker\")\n .sort((a, b) => a.i - b.i)\n .map((s) => {\n const spec = group.entries[row].linkers?.[s.i];\n const text = deriveSlotToken(group, s, row);\n return spec !== undefined && text !== undefined ? { spec, text } : undefined;\n })\n .filter((l): l is LinkerPart<PColumnSpec> => !isNil(l));\n\n // No distinguishing pieces for this row → no postfix (don't invoke the formatter with empties).\n if (root === undefined && linkers.length === 0) return \"\";\n // A formatter may suppress the postfix by returning `undefined` or `\"\"`; `|| \"\"` collapses both\n // to the same empty value so neither counts as a distinguishing token during slot escalation.\n return group.format({ root, linkers }, group.entries[row].hit, group.indices[row]) || \"\";\n}\n\nfunction renderAll(group: Group, slots: Slot[]): string[] {\n return group.entries.map((_, r) => renderRow(group, slots, r));\n}\n\nfunction allUnique(rendered: string[]): boolean {\n return new Set(rendered).size === rendered.length;\n}\n\n/**\n * Minimal slot set that makes the group unique. Escalate by priority (root, then linkers by step),\n * then drop any redundant slot; render every row symmetrically against the result.\n *\n * KNOWN LIMITATION (review point): symmetric render can over-decorate a row in a mixed group (e.g.\n * `via Sample MapperA` where `via Sample` alone is already unique for that row). Per-row trimming is\n * a generalized `dropRedundantLinkerSuffix`; naive greedy trimming is unstable, so it's deferred.\n */\nfunction resolveGroup(\n entries: PostfixEntry[],\n indices: number[],\n format: LinkerFormatter,\n): string[] {\n const group: Group = {\n entries,\n roots: entries.map((e) =>\n e.hit && e.linkers?.length ? extractRoot(e.hit, e.linkers) : undefined,\n ),\n indices,\n format,\n };\n const maxLen = Math.max(0, ...entries.map((e) => e.linkers?.length ?? 0));\n\n const slots: Slot[] = [\n { kind: \"root\" },\n ...Array.from({ length: maxLen }, (_, i): Slot => ({ kind: \"linker\", i })),\n ];\n\n const escalated = slots.reduce<Slot[]>(\n (acc, slot) =>\n allUnique(renderAll(group, acc)) || !getDiscriminates(group, slot)\n ? acc\n : (acc.push(slot), acc),\n [],\n );\n const chosen = escalated.reduce<Slot[]>((acc, slot) => {\n const trial = acc.filter((s) => s !== slot);\n return allUnique(renderAll(group, trial)) ? trial : acc;\n }, escalated);\n\n return renderAll(group, chosen);\n}\n\n/**\n * Full label per entry: `stem` plus, where stems collide, a minimal postfix distinguishing the\n * linked columns by the difference between their sources.\n */\nexport function derivePostfixes(\n entries: PostfixEntry[],\n format: LinkerFormatter = defaultLinkerFormatter,\n): string[] {\n const groups = entries.reduce<Map<string, number[]>>(\n (acc, e, idx) => acc.set(e.stem, [...(acc.get(e.stem) ?? []), idx]),\n new Map(),\n );\n\n const postfix = [...groups.values()].reduce<Map<number, string>>((acc, idxs) => {\n if (idxs.length < 2) return acc; // stem already unique — no postfix\n const resolved = resolveGroup(\n idxs.map((i) => entries[i]),\n idxs,\n format,\n );\n return idxs.reduce((m, i, k) => m.set(i, resolved[k]), acc);\n }, new Map());\n\n return entries.map((e, i) => {\n const p = postfix.get(i);\n return p ? `${e.stem} ${p}` : e.stem;\n });\n}\n"],"mappings":"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"}
package/dist/package.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "1.80.2";
2
+ var version = "1.80.10";
3
3
  //#endregion
4
4
  Object.defineProperty(exports, "version", {
5
5
  enumerable: true,
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "1.80.2";
2
+ var version = "1.80.10";
3
3
  //#endregion
4
4
  export { version };
5
5
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platforma-sdk/model",
3
- "version": "1.80.2",
3
+ "version": "1.80.10",
4
4
  "description": "Platforma.bio SDK / Block Model",
5
5
  "files": [
6
6
  "./dist/**/*",
@@ -31,23 +31,23 @@
31
31
  "lru-cache": "^11.2.2",
32
32
  "utility-types": "^3.11.0",
33
33
  "zod": "~3.25.76",
34
- "@milaboratories/helpers": "1.14.4",
34
+ "@milaboratories/helpers": "1.14.5",
35
+ "@milaboratories/pl-model-middle-layer": "1.30.15",
35
36
  "@milaboratories/pl-error-like": "1.12.10",
36
- "@milaboratories/pl-model-common": "1.47.1",
37
- "@milaboratories/ptabler-expression-js": "1.2.35",
38
- "@milaboratories/pl-model-middle-layer": "1.30.13"
37
+ "@milaboratories/ptabler-expression-js": "1.2.37",
38
+ "@milaboratories/pl-model-common": "1.47.3"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@vitest/coverage-istanbul": "^4.1.3",
42
42
  "fast-json-patch": "^3.1.1",
43
43
  "typescript": "~5.9.3",
44
44
  "vitest": "^4.1.3",
45
- "@milaboratories/columns-collection-driver": "0.2.1",
45
+ "@milaboratories/columns-collection-driver": "0.2.3",
46
46
  "@milaboratories/build-configs": "2.0.0",
47
- "@milaboratories/ts-builder": "1.6.0",
48
- "@milaboratories/pf-spec-driver": "1.4.21",
49
- "@milaboratories/ts-configs": "1.3.0",
50
- "@milaboratories/pf-driver": "1.8.2"
47
+ "@milaboratories/pf-driver": "1.8.5",
48
+ "@milaboratories/pf-spec-driver": "1.4.24",
49
+ "@milaboratories/ts-builder": "1.6.1",
50
+ "@milaboratories/ts-configs": "1.3.1"
51
51
  },
52
52
  "scripts": {
53
53
  "build": "ts-builder build --target node",
@@ -1,9 +1,9 @@
1
- import type {
2
- ColumnUniversalId,
3
- LeafEntry,
4
- PColumn,
5
- PlRef,
6
- PObjectId,
1
+ import {
2
+ type ColumnUniversalId,
3
+ type LeafEntry,
4
+ type PColumn,
5
+ type PlRef,
6
+ type PObjectId,
7
7
  } from "@milaboratories/pl-model-common";
8
8
  import type { GlobalCfgRenderCtx } from "../render/internal";
9
9
  import type { TreeNodeAccessor } from "../render";
@@ -227,11 +227,11 @@ type ResolvedColumns = {
227
227
  function resolveInputColumns<A, U>(
228
228
  ctx: RenderCtxBase<A, U>,
229
229
  options: createPlDataTableOptionsV3,
230
- ): ResolvedColumns | undefined {
230
+ ): undefined | ResolvedColumns {
231
231
  if ("primaryColumns" in options) {
232
232
  const primary = options.primaryColumns;
233
233
  const secondary = options.columns ?? [];
234
- const labels = discoverLabelColumns(ctx, primary, [...primary, ...secondary]);
234
+ const labels = discoverLabelColumns(ctx, primary);
235
235
  // Exclude from secondary anything already present in primary: a label
236
236
  // column the block hands in as primary can be re-discovered here as a
237
237
  // label for that same axis, landing in the table twice with the same id.
@@ -7,7 +7,7 @@ import type {
7
7
  import { PColumnName } from "@milaboratories/pl-model-common";
8
8
  import type { RenderCtxBase } from "../../../render";
9
9
  import type { ColumnRecipe, ColumnsSource } from "../../../columns";
10
- import { ColumnsCollection, collectLinkerIds, isLeafColumn } from "../../../columns";
10
+ import { ColumnsCollection, isLeafColumn } from "../../../columns";
11
11
  import type { ColumnsSelectorConfig } from "./createPlDataTableV3";
12
12
 
13
13
  export type DiscoverTableColumnOptions = {
@@ -30,7 +30,7 @@ export type DiscoveredTableColumns = {
30
30
  export function discoverTableColumns(
31
31
  ctx: RenderCtxBase,
32
32
  options: DiscoverTableColumnOptions,
33
- ): undefined | DiscoveredTableColumns {
33
+ ): DiscoveredTableColumns {
34
34
  const discoveredColumns = ColumnsCollection(options.sources, { ctx: ctx.ctx })
35
35
  .discover({ ...options.selector, anchors: options.anchors })
36
36
  .getColumns();
@@ -54,22 +54,18 @@ export function discoverTableColumns(
54
54
  export function discoverLabelColumns<A, U>(
55
55
  ctx: RenderCtxBase<A, U>,
56
56
  primary: ColumnRecipe[],
57
- columns: ColumnRecipe[],
58
57
  ): ColumnRecipe[] {
59
- if (columns.length === 0) return [];
58
+ if (primary.length === 0) return [];
60
59
 
61
- const axes = columns.flatMap((col) => col.getSpec().axesSpec);
62
- return (
63
- ColumnsCollection(undefined, { ctx: ctx.ctx })
64
- // .addSource({ columns, isFinal: true })
65
- .discover({
66
- include: axes.map((a) => ({
67
- name: { type: "exact", value: PColumnName.Label },
68
- axes: [{ name: { type: "exact", value: a.name } }],
69
- })),
70
- anchors: Object.fromEntries(primary.map((col, i) => [`anchor_${i}`, col.getSpec()])),
71
- maxHops: columns.reduce((acc, c) => Math.max(acc, collectLinkerIds(c).length), 0),
72
- })
73
- .getColumns()
74
- );
60
+ const axes = primary.flatMap((col) => col.getSpec().axesSpec);
61
+ return ColumnsCollection(undefined, { ctx: ctx.ctx })
62
+ .discover({
63
+ include: axes.map((a) => ({
64
+ name: { type: "exact", value: PColumnName.Label },
65
+ axes: [{ name: { type: "exact", value: a.name } }],
66
+ })),
67
+ anchors: Object.fromEntries(primary.map((col, i) => [`anchor_${i}`, col.getSpec()])),
68
+ maxHops: 0,
69
+ })
70
+ .getColumns();
75
71
  }
@@ -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