@platforma-sdk/model 1.76.4 → 1.77.0

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 (62) hide show
  1. package/dist/columns/column_collection_builder.cjs +6 -3
  2. package/dist/columns/column_collection_builder.cjs.map +1 -1
  3. package/dist/columns/column_collection_builder.js +6 -3
  4. package/dist/columns/column_collection_builder.js.map +1 -1
  5. package/dist/components/PlDataTable/createPlDataTable/createPTableDefV2.cjs.map +1 -1
  6. package/dist/components/PlDataTable/createPlDataTable/createPTableDefV2.js.map +1 -1
  7. package/dist/components/PlDataTable/createPlDataTable/createPTableDefV3.cjs.map +1 -1
  8. package/dist/components/PlDataTable/createPlDataTable/createPTableDefV3.js.map +1 -1
  9. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV2.cjs.map +1 -1
  10. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV2.d.ts +1 -1
  11. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV2.js.map +1 -1
  12. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV3.cjs.map +1 -1
  13. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV3.d.ts +1 -1
  14. package/dist/components/PlDataTable/createPlDataTable/createPlDataTableV3.js.map +1 -1
  15. package/dist/components/PlDataTable/createPlDataTable/index.cjs.map +1 -1
  16. package/dist/components/PlDataTable/createPlDataTable/index.js.map +1 -1
  17. package/dist/components/PlDataTable/createPlDataTableSheet.cjs.map +1 -1
  18. package/dist/components/PlDataTable/createPlDataTableSheet.d.ts +1 -1
  19. package/dist/components/PlDataTable/createPlDataTableSheet.js.map +1 -1
  20. package/dist/components/PlDataTable/index.d.ts +1 -1
  21. package/dist/components/PlDataTable/state-migration.cjs +32 -1
  22. package/dist/components/PlDataTable/state-migration.cjs.map +1 -1
  23. package/dist/components/PlDataTable/state-migration.d.ts +4 -3
  24. package/dist/components/PlDataTable/state-migration.d.ts.map +1 -1
  25. package/dist/components/PlDataTable/state-migration.js +33 -2
  26. package/dist/components/PlDataTable/state-migration.js.map +1 -1
  27. package/dist/components/PlDataTable/typesV6.d.ts +26 -84
  28. package/dist/components/PlDataTable/typesV6.d.ts.map +1 -1
  29. package/dist/components/PlDataTable/typesV7.d.ts +98 -0
  30. package/dist/components/PlDataTable/typesV7.d.ts.map +1 -0
  31. package/dist/components/PlDatasetSelector/build_dataset_options.cjs +5 -1
  32. package/dist/components/PlDatasetSelector/build_dataset_options.cjs.map +1 -1
  33. package/dist/components/PlDatasetSelector/build_dataset_options.d.ts +6 -1
  34. package/dist/components/PlDatasetSelector/build_dataset_options.d.ts.map +1 -1
  35. package/dist/components/PlDatasetSelector/build_dataset_options.js +5 -1
  36. package/dist/components/PlDatasetSelector/build_dataset_options.js.map +1 -1
  37. package/dist/components/PlDatasetSelector/filter_discovery.cjs +21 -30
  38. package/dist/components/PlDatasetSelector/filter_discovery.cjs.map +1 -1
  39. package/dist/components/PlDatasetSelector/filter_discovery.d.ts +20 -20
  40. package/dist/components/PlDatasetSelector/filter_discovery.d.ts.map +1 -1
  41. package/dist/components/PlDatasetSelector/filter_discovery.js +21 -30
  42. package/dist/components/PlDatasetSelector/filter_discovery.js.map +1 -1
  43. package/dist/components/PlDatasetSelector/index.d.ts +1 -1
  44. package/dist/components/index.d.ts +2 -2
  45. package/dist/index.d.ts +3 -3
  46. package/dist/package.cjs +1 -1
  47. package/dist/package.js +1 -1
  48. package/package.json +5 -5
  49. package/src/columns/column_collection_builder.ts +12 -3
  50. package/src/components/PlDataTable/createPlDataTable/createPTableDefV2.ts +1 -1
  51. package/src/components/PlDataTable/createPlDataTable/createPTableDefV3.ts +1 -1
  52. package/src/components/PlDataTable/createPlDataTable/createPlDataTableV2.ts +1 -1
  53. package/src/components/PlDataTable/createPlDataTable/createPlDataTableV3.ts +1 -1
  54. package/src/components/PlDataTable/createPlDataTable/index.ts +1 -1
  55. package/src/components/PlDataTable/createPlDataTableSheet.ts +1 -1
  56. package/src/components/PlDataTable/index.ts +1 -1
  57. package/src/components/PlDataTable/state-migration.ts +71 -13
  58. package/src/components/PlDataTable/typesV6.ts +16 -138
  59. package/src/components/PlDataTable/typesV7.ts +151 -0
  60. package/src/components/PlDatasetSelector/build_dataset_options.ts +10 -2
  61. package/src/components/PlDatasetSelector/filter_discovery.test.ts +116 -19
  62. package/src/components/PlDatasetSelector/filter_discovery.ts +46 -39
@@ -1,152 +1,30 @@
1
- import type {
2
- AxisId,
3
- AxisSpec,
4
- CanonicalizedJson,
5
- ListOptionBase,
6
- PTableColumnSpec,
7
- PTableSorting,
8
- PColumnIdAndSpec,
9
- PTableHandle,
10
- RootFilterSpec,
11
- PTableColumnId,
12
- PFrameHandle,
13
- } from "@milaboratories/pl-model-common";
14
- import type { FilterSpecLeaf } from "../../filters";
15
- import { Nil } from "@milaboratories/helpers";
1
+ import type { CanonicalizedJson, PTableColumnSpec } from "@milaboratories/pl-model-common";
2
+ import type { PlDataTableFiltersWithMeta, PlDataTableSheetState, PTableParamsV2 } from "./typesV7";
16
3
 
17
- export type PlTableColumnIdJson = CanonicalizedJson<PTableColumnSpec>;
4
+ /**
5
+ * v6 colId scheme: bare canonicalized `PTableColumnSpec` (full spec including
6
+ * all annotations and `pl7.app/trace`). v7 dropped the spec body and now uses
7
+ * `CanonicalizedJson<PTableColumnId>` — orders of magnitude smaller.
8
+ */
9
+ export type PlDataTableV6ColIdJson = CanonicalizedJson<PTableColumnSpec>;
18
10
 
19
- export type PlDataTableGridStateCore = {
20
- /** Includes column ordering */
21
- columnOrder?: {
22
- /** All colIds in order */
23
- orderedColIds: PlTableColumnIdJson[];
24
- };
25
- /** Includes current sort columns and direction */
26
- sort?: {
27
- /** Sorted columns and directions in order */
28
- sortModel: {
29
- /** Column Id to apply the sort to. */
30
- colId: PlTableColumnIdJson;
31
- /** Sort direction */
32
- sort: "asc" | "desc";
33
- }[];
34
- };
35
- /** Includes column visibility */
36
- columnVisibility?: {
37
- /** All colIds which were hidden */
38
- hiddenColIds: PlTableColumnIdJson[];
39
- };
11
+ export type PlDataTableGridStateV6 = {
12
+ columnOrder?: { orderedColIds: PlDataTableV6ColIdJson[] };
13
+ sort?: { sortModel: { colId: PlDataTableV6ColIdJson; sort: "asc" | "desc" }[] };
14
+ columnVisibility?: { hiddenColIds: PlDataTableV6ColIdJson[] };
40
15
  };
41
16
 
42
- export type PlDataTableSheet = {
43
- /** spec of the axis to use */
44
- axis: AxisSpec;
45
- /** options to show in the filter dropdown */
46
- options: ListOptionBase<string | number>[];
47
- /** default (selected) value */
48
- defaultValue?: string | number;
49
- };
50
-
51
- export type PlDataTableSheetState = {
52
- /** id of the axis */
53
- axisId: AxisId;
54
- /** selected value */
55
- value: string | number;
56
- };
57
-
58
- /** Tree-based filter state compatible with PlAdvancedFilter's RootFilter */
59
- export type PlDataTableFilterMeta = {
60
- id: number;
61
- source?: "table-filter" | "table-search";
62
- isExpanded?: boolean;
63
- isSuppressed?: boolean;
64
- };
65
- export type PlDataTableFilterSpecLeaf = FilterSpecLeaf<CanonicalizedJson<PTableColumnId>>;
66
- export type PlDataTableFilters = RootFilterSpec<PlDataTableFilterSpecLeaf>;
67
- export type PlDataTableFiltersWithMeta = RootFilterSpec<
68
- PlDataTableFilterSpecLeaf,
69
- PlDataTableFilterMeta
70
- >;
71
-
72
- export type PlDataTableStateV2CacheEntry = {
73
- /** DataSource identifier for state management */
17
+ export type PlDataTableStateV2V6CacheEntry = {
74
18
  sourceId: string;
75
- /** Internal ag-grid state */
76
- gridState: PlDataTableGridStateCore;
77
- /** Sheets state */
19
+ gridState: PlDataTableGridStateV6;
78
20
  sheetsState: PlDataTableSheetState[];
79
- /** User filters state (tree-based, compatible with PlAdvancedFilter) */
80
21
  filtersState: null | PlDataTableFiltersWithMeta;
81
- /** Default filters state from model (snapshot of defaults) */
82
22
  defaultFiltersState: null | PlDataTableFiltersWithMeta;
83
- /** Fast search string */
84
23
  searchString?: string;
85
24
  };
86
25
 
87
- export type PTableParamsV2 =
88
- | {
89
- sourceId: null;
90
- hiddenColIds: null;
91
- sorting: [];
92
- filters: null;
93
- defaultFilters: null;
94
- }
95
- | {
96
- sourceId: string;
97
- hiddenColIds: null | PTableColumnId[];
98
- sorting: PTableSorting[];
99
- filters: null | PlDataTableFilters;
100
- defaultFilters: null | PlDataTableFilters;
101
- };
102
-
103
- export type PlDataTableStateV2Normalized = {
104
- /** Version for upgrades */
26
+ export type PlDataTableStateV2V6 = {
105
27
  version: 6;
106
- /** Internal states, LRU cache for 5 sourceId-s */
107
- stateCache: PlDataTableStateV2CacheEntry[];
108
- /** PTable params derived from the cache state for the current sourceId */
28
+ stateCache: PlDataTableStateV2V6CacheEntry[];
109
29
  pTableParams: PTableParamsV2;
110
30
  };
111
-
112
- /** PlAgDataTable model */
113
- export type PlDataTableModel = {
114
- /** DataSource identifier for state management */
115
- sourceId: null | string;
116
- /** p-table including all columns, used to show the full specification of the table */
117
- fullTableHandle?: PTableHandle;
118
- /** p-frame handle */
119
- fullPframeHandle?: PFrameHandle;
120
- /** p-table including only visible columns, used to get the data */
121
- visibleTableHandle?: PTableHandle;
122
- /** Default filters from model options, surfaced for UI display */
123
- defaultFilters?: Nil | PlDataTableFilters;
124
- };
125
-
126
- export type CreatePlDataTableOps = {
127
- /** Filters for columns and non-partitioned axes */
128
- filters?: PlDataTableFilters;
129
-
130
- /** Sorting to columns hidden from user */
131
- sorting?: PTableSorting[];
132
-
133
- /**
134
- * Selects columns for which will be inner-joined to the table.
135
- *
136
- * Default behaviour: all columns are considered to be core
137
- */
138
- coreColumnPredicate?: (spec: PColumnIdAndSpec) => boolean;
139
-
140
- /**
141
- * Determines how core columns should be joined together:
142
- * inner - so user will only see records present in all core columns
143
- * full - so user will only see records present in any of the core columns
144
- *
145
- * All non-core columns will be left joined to the table produced by the core
146
- * columns, in other words records form the pool of non-core columns will only
147
- * make their way into the final table if core table contains corresponding key.
148
- *
149
- * Default: 'full'
150
- */
151
- coreJoinType?: "inner" | "full";
152
- };
@@ -0,0 +1,151 @@
1
+ import type {
2
+ AxisId,
3
+ AxisSpec,
4
+ CanonicalizedJson,
5
+ ListOptionBase,
6
+ PTableSorting,
7
+ PColumnIdAndSpec,
8
+ PTableHandle,
9
+ RootFilterSpec,
10
+ PTableColumnId,
11
+ PFrameHandle,
12
+ } from "@milaboratories/pl-model-common";
13
+ import type { FilterSpecLeaf } from "../../filters";
14
+ import { Nil } from "@milaboratories/helpers";
15
+
16
+ export type PlTableColumnIdJson = CanonicalizedJson<PTableColumnId>;
17
+
18
+ export type PlDataTableGridStateCore = {
19
+ /** Includes column ordering */
20
+ columnOrder?: {
21
+ /** All colIds in order */
22
+ orderedColIds: PlTableColumnIdJson[];
23
+ };
24
+ /** Includes current sort columns and direction */
25
+ sort?: {
26
+ /** Sorted columns and directions in order */
27
+ sortModel: {
28
+ /** Column Id to apply the sort to. */
29
+ colId: PlTableColumnIdJson;
30
+ /** Sort direction */
31
+ sort: "asc" | "desc";
32
+ }[];
33
+ };
34
+ /** Includes column visibility */
35
+ columnVisibility?: {
36
+ /** All colIds which were hidden */
37
+ hiddenColIds: PlTableColumnIdJson[];
38
+ };
39
+ };
40
+
41
+ export type PlDataTableSheet = {
42
+ /** spec of the axis to use */
43
+ axis: AxisSpec;
44
+ /** options to show in the filter dropdown */
45
+ options: ListOptionBase<string | number>[];
46
+ /** default (selected) value */
47
+ defaultValue?: string | number;
48
+ };
49
+
50
+ export type PlDataTableSheetState = {
51
+ /** id of the axis */
52
+ axisId: AxisId;
53
+ /** selected value */
54
+ value: string | number;
55
+ };
56
+
57
+ /** Tree-based filter state compatible with PlAdvancedFilter's RootFilter */
58
+ export type PlDataTableFilterMeta = {
59
+ id: number;
60
+ source?: "table-filter" | "table-search";
61
+ isExpanded?: boolean;
62
+ isSuppressed?: boolean;
63
+ };
64
+ export type PlDataTableFilterSpecLeaf = FilterSpecLeaf<CanonicalizedJson<PTableColumnId>>;
65
+ export type PlDataTableFilters = RootFilterSpec<PlDataTableFilterSpecLeaf>;
66
+ export type PlDataTableFiltersWithMeta = RootFilterSpec<
67
+ PlDataTableFilterSpecLeaf,
68
+ PlDataTableFilterMeta
69
+ >;
70
+
71
+ export type PlDataTableStateV2CacheEntry = {
72
+ /** DataSource identifier for state management */
73
+ sourceId: string;
74
+ /** Internal ag-grid state */
75
+ gridState: PlDataTableGridStateCore;
76
+ /** Sheets state */
77
+ sheetsState: PlDataTableSheetState[];
78
+ /** User filters state (tree-based, compatible with PlAdvancedFilter) */
79
+ filtersState: null | PlDataTableFiltersWithMeta;
80
+ /** Default filters state from model (snapshot of defaults) */
81
+ defaultFiltersState: null | PlDataTableFiltersWithMeta;
82
+ /** Fast search string */
83
+ searchString?: string;
84
+ };
85
+
86
+ export type PTableParamsV2 =
87
+ | {
88
+ sourceId: null;
89
+ hiddenColIds: null;
90
+ sorting: [];
91
+ filters: null;
92
+ defaultFilters: null;
93
+ }
94
+ | {
95
+ sourceId: string;
96
+ hiddenColIds: null | PTableColumnId[];
97
+ sorting: PTableSorting[];
98
+ filters: null | PlDataTableFilters;
99
+ defaultFilters: null | PlDataTableFilters;
100
+ };
101
+
102
+ export type PlDataTableStateV2Normalized = {
103
+ /** Version for upgrades */
104
+ version: 7;
105
+ /** Internal states, LRU cache for 5 sourceId-s */
106
+ stateCache: PlDataTableStateV2CacheEntry[];
107
+ /** PTable params derived from the cache state for the current sourceId */
108
+ pTableParams: PTableParamsV2;
109
+ };
110
+
111
+ /** PlAgDataTable model */
112
+ export type PlDataTableModel = {
113
+ /** DataSource identifier for state management */
114
+ sourceId: null | string;
115
+ /** p-table including all columns, used to show the full specification of the table */
116
+ fullTableHandle?: PTableHandle;
117
+ /** p-frame handle */
118
+ fullPframeHandle?: PFrameHandle;
119
+ /** p-table including only visible columns, used to get the data */
120
+ visibleTableHandle?: PTableHandle;
121
+ /** Default filters from model options, surfaced for UI display */
122
+ defaultFilters?: Nil | PlDataTableFilters;
123
+ };
124
+
125
+ export type CreatePlDataTableOps = {
126
+ /** Filters for columns and non-partitioned axes */
127
+ filters?: PlDataTableFilters;
128
+
129
+ /** Sorting to columns hidden from user */
130
+ sorting?: PTableSorting[];
131
+
132
+ /**
133
+ * Selects columns for which will be inner-joined to the table.
134
+ *
135
+ * Default behaviour: all columns are considered to be core
136
+ */
137
+ coreColumnPredicate?: (spec: PColumnIdAndSpec) => boolean;
138
+
139
+ /**
140
+ * Determines how core columns should be joined together:
141
+ * inner - so user will only see records present in all core columns
142
+ * full - so user will only see records present in any of the core columns
143
+ *
144
+ * All non-core columns will be left joined to the table produced by the core
145
+ * columns, in other words records form the pool of non-core columns will only
146
+ * make their way into the final table if core table contains corresponding key.
147
+ *
148
+ * Default: 'full'
149
+ */
150
+ coreJoinType?: "inner" | "full";
151
+ };
@@ -31,7 +31,11 @@ export type BuildDatasetOptions = {
31
31
  * accept-all.
32
32
  */
33
33
  filter?: SpecPredicateOption;
34
- /** Formatting options for filter labels. */
34
+ /**
35
+ * Formatting options forwarded to label derivation for both filter and
36
+ * enrichment rows. `formatters.native` on the filter path is overridden
37
+ * — see `FilterMatchOptions.labelOptions`.
38
+ */
35
39
  labelOptions?: DeriveLabelsOptions;
36
40
  /**
37
41
  * Enables enrichment discovery and filters hits attached to
@@ -96,7 +100,11 @@ export function buildDatasetOptions(
96
100
  const filters =
97
101
  filterMatches.length === 0
98
102
  ? undefined
99
- : filterMatchesToOptions(filterMatches, refMap, opts?.labelOptions);
103
+ : filterMatchesToOptions(filterMatches, {
104
+ refsByObjectId: refMap,
105
+ datasetSpec,
106
+ labelOptions: opts?.labelOptions,
107
+ });
100
108
 
101
109
  let enrichments;
102
110
  if (enrichmentCollection && withEnrichments) {
@@ -1,5 +1,5 @@
1
1
  import { Annotation, createPlRef } from "@milaboratories/pl-model-common";
2
- import type { AxisSpec, PColumnSpec, PlRef, PObjectId } from "@milaboratories/pl-model-common";
2
+ import type { AxisSpec, PColumnSpec, PObjectId } from "@milaboratories/pl-model-common";
3
3
  import { SpecDriver } from "@milaboratories/pf-spec-driver";
4
4
  import canonicalize from "canonicalize";
5
5
  import { afterEach, describe, expect, test } from "vitest";
@@ -85,10 +85,7 @@ describe("buildRefMap", () => {
85
85
  test("maps canonicalized PlRef to original ref", () => {
86
86
  const ref1 = createPlRef("b1", "out1");
87
87
  const ref2 = createPlRef("b2", "out2", true);
88
- const entries = [{ ref: ref1 }, { ref: ref2 }];
89
-
90
- const map = buildRefMap(entries);
91
-
88
+ const map = buildRefMap([{ ref: ref1 }, { ref: ref2 }]);
92
89
  expect(map.get(canonicalize(ref1)! as PObjectId)).toBe(ref1);
93
90
  expect(map.get(canonicalize(ref2)! as PObjectId)).toBe(ref2);
94
91
  expect(map.size).toBe(2);
@@ -104,18 +101,10 @@ describe("filterMatchesToOptions", () => {
104
101
  const filterRef1 = createPlRef("b1", "filter-top1000");
105
102
  const filterRef2 = createPlRef("b1", "filter-highconf");
106
103
 
107
- // Build ref map from entries (simulating result pool)
108
- const refMap = buildRefMap([
109
- { ref: anchorSnap.id as unknown as PlRef }, // anchor — won't be looked up
110
- { ref: filterRef1 },
111
- { ref: filterRef2 },
112
- ]);
113
-
114
- // Build filter specs with isSubset annotation
104
+ const refMap = buildRefMap([{ ref: filterRef1 }, { ref: filterRef2 }]);
115
105
  const filterSpec1 = spec("filter1", [axis("sample")], { [Annotation.IsSubset]: "true" });
116
106
  const filterSpec2 = spec("filter2", [axis("sample")], { [Annotation.IsSubset]: "true" });
117
107
 
118
- // Use the canonical PlRef as the PObjectId (matches how result pool works)
119
108
  const f1Snap = snap(canonicalize(filterRef1)! as string, filterSpec1);
120
109
  const f2Snap = snap(canonicalize(filterRef2)! as string, filterSpec2);
121
110
 
@@ -126,7 +115,10 @@ describe("filterMatchesToOptions", () => {
126
115
  const matches = findFilterColumns(collection);
127
116
  expect(matches.length).toBe(2);
128
117
 
129
- const options = filterMatchesToOptions(matches, refMap);
118
+ const options = filterMatchesToOptions(matches, {
119
+ refsByObjectId: refMap,
120
+ datasetSpec: anchorSpec,
121
+ });
130
122
  expect(options).toHaveLength(2);
131
123
  // Each option has a ref and label
132
124
  for (const opt of options) {
@@ -136,11 +128,114 @@ describe("filterMatchesToOptions", () => {
136
128
  }
137
129
  });
138
130
 
131
+ test("single filter: dataset name prefixes the discriminating trace step", () => {
132
+ // Regression: without the dataset in the input, deriveDistinctLabels
133
+ // picks the highest-importance step it sees — the dataset's own
134
+ // `samples-and-data/dataset` step (importance 100) — and the filter
135
+ // shows "Bulk" instead of "Top 10". Appending the dataset forces the
136
+ // algorithm to include the lower-importance lead-selection step too.
137
+ const datasetSpec = spec("dataset", [axis("sample"), axis("gene")], {
138
+ [Annotation.Label]: "Bulk",
139
+ [Annotation.Trace]: JSON.stringify([
140
+ { type: "milaboratories.samples-and-data/dataset", label: "Bulk", importance: 100 },
141
+ { type: "milaboratories.mixcr-clonotyping", label: "MiXCR", importance: 20 },
142
+ ]),
143
+ });
144
+
145
+ const filterRef = createPlRef("b1", "filter");
146
+ const refMap = buildRefMap([{ ref: filterRef }]);
147
+ const filterSpec = spec("filter", [axis("sample")], {
148
+ [Annotation.IsSubset]: "true",
149
+ [Annotation.Label]: "Selected Leads",
150
+ [Annotation.Trace]: JSON.stringify([
151
+ { type: "milaboratories.samples-and-data/dataset", label: "Bulk", importance: 100 },
152
+ { type: "milaboratories.mixcr-clonotyping", label: "MiXCR", importance: 20 },
153
+ { type: "milaboratories.antibody-tcr-lead-selection", label: "Top 10", importance: 30 },
154
+ ]),
155
+ });
156
+ const fSnap = snap(canonicalize(filterRef)! as string, filterSpec);
157
+
158
+ const builder = new ColumnCollectionBuilder(createSpecFrameCtx());
159
+ builder.addSource([fSnap, anchorSnap]);
160
+ const collection = builder.build({ anchors: { main: anchorSpec } })!;
161
+
162
+ const matches = findFilterColumns(collection);
163
+ expect(matches).toHaveLength(1);
164
+
165
+ const options = filterMatchesToOptions(matches, { refsByObjectId: refMap, datasetSpec });
166
+ expect(options).toHaveLength(1);
167
+ expect(options[0].label).toBe("Bulk / Top 10");
168
+
169
+ // Caller's `formatters.native` must NOT override the internal
170
+ // suppression — otherwise the algorithm short-circuits on the
171
+ // inherited "Selected Leads" label.
172
+ const withCustomNative = filterMatchesToOptions(matches, {
173
+ refsByObjectId: refMap,
174
+ datasetSpec,
175
+ labelOptions: { formatters: { native: (l) => `<<${l}>>` } },
176
+ });
177
+ expect(withCustomNative[0].label).toBe("Bulk / Top 10");
178
+
179
+ // Non-native label options (here `separator`) flow through.
180
+ const withSeparator = filterMatchesToOptions(matches, {
181
+ refsByObjectId: refMap,
182
+ datasetSpec,
183
+ labelOptions: { separator: " :: " },
184
+ });
185
+ expect(withSeparator[0].label).toBe("Bulk :: Top 10");
186
+ });
187
+
188
+ test("multiple filters with shared dataset trace disambiguate by filter-specific steps", () => {
189
+ const datasetSpec = spec("dataset", [axis("sample"), axis("gene")], {
190
+ [Annotation.Label]: "Bulk",
191
+ [Annotation.Trace]: JSON.stringify([
192
+ { type: "milaboratories.samples-and-data/dataset", label: "Bulk", importance: 100 },
193
+ { type: "milaboratories.mixcr-clonotyping", label: "MiXCR", importance: 20 },
194
+ ]),
195
+ });
196
+
197
+ const ref1 = createPlRef("b1", "f1");
198
+ const ref2 = createPlRef("b2", "f2");
199
+ const refMap = buildRefMap([{ ref: ref1 }, { ref: ref2 }]);
200
+ const filterSpec1 = spec("filter1", [axis("sample")], {
201
+ [Annotation.IsSubset]: "true",
202
+ [Annotation.Label]: "Selected Leads",
203
+ [Annotation.Trace]: JSON.stringify([
204
+ { type: "milaboratories.samples-and-data/dataset", label: "Bulk", importance: 100 },
205
+ { type: "milaboratories.mixcr-clonotyping", label: "MiXCR", importance: 20 },
206
+ { type: "milaboratories.antibody-tcr-lead-selection", label: "Top 10", importance: 30 },
207
+ ]),
208
+ });
209
+ const filterSpec2 = spec("filter2", [axis("sample")], {
210
+ [Annotation.IsSubset]: "true",
211
+ [Annotation.Label]: "Selected Leads",
212
+ [Annotation.Trace]: JSON.stringify([
213
+ { type: "milaboratories.samples-and-data/dataset", label: "Bulk", importance: 100 },
214
+ { type: "milaboratories.mixcr-clonotyping", label: "MiXCR", importance: 20 },
215
+ { type: "milaboratories.antibody-tcr-lead-selection", label: "Top 11", importance: 30 },
216
+ ]),
217
+ });
218
+ const f1Snap = snap(canonicalize(ref1)! as string, filterSpec1);
219
+ const f2Snap = snap(canonicalize(ref2)! as string, filterSpec2);
220
+
221
+ const builder = new ColumnCollectionBuilder(createSpecFrameCtx());
222
+ builder.addSource([f1Snap, f2Snap, anchorSnap]);
223
+ const collection = builder.build({ anchors: { main: anchorSpec } })!;
224
+
225
+ const matches = findFilterColumns(collection);
226
+ expect(matches).toHaveLength(2);
227
+
228
+ const options = filterMatchesToOptions(matches, { refsByObjectId: refMap, datasetSpec });
229
+ expect(options.map((o) => o.label).sort()).toEqual(["Bulk / Top 10", "Bulk / Top 11"]);
230
+ });
231
+
139
232
  test("returns empty array for empty matches", () => {
140
- expect(filterMatchesToOptions([], new Map())).toEqual([]);
233
+ expect(
234
+ filterMatchesToOptions([], { refsByObjectId: new Map(), datasetSpec: anchorSpec }),
235
+ ).toEqual([]);
141
236
  });
142
237
 
143
- test("skips entries whose ref is not found in map", () => {
238
+ test("skips entries whose id is not in refsByObjectId", () => {
144
239
  const knownRef = createPlRef("b1", "known");
145
240
  const knownSpec = spec("known", [axis("sample")], { [Annotation.IsSubset]: "true" });
146
241
  const orphanSpec = spec("orphan", [axis("sample")], { [Annotation.IsSubset]: "true" });
@@ -153,9 +248,11 @@ describe("filterMatchesToOptions", () => {
153
248
 
154
249
  const matches = findFilterColumns(collection);
155
250
  expect(matches.length).toBe(2);
156
-
157
251
  const refMap = buildRefMap([{ ref: knownRef }]);
158
- const options = filterMatchesToOptions(matches, refMap);
252
+ const options = filterMatchesToOptions(matches, {
253
+ refsByObjectId: refMap,
254
+ datasetSpec: anchorSpec,
255
+ });
159
256
  expect(options).toHaveLength(1);
160
257
  expect(options[0].ref).toBe(knownRef);
161
258
  });
@@ -1,5 +1,5 @@
1
1
  import { Annotation } from "@milaboratories/pl-model-common";
2
- import type { Option, PlRef, PObjectId } from "@milaboratories/pl-model-common";
2
+ import type { Option, PlRef, PObjectId, PObjectSpec } from "@milaboratories/pl-model-common";
3
3
  import canonicalize from "canonicalize";
4
4
  import type {
5
5
  AnchoredColumnCollection,
@@ -12,12 +12,8 @@ import {
12
12
  } from "../../labels/derive_distinct_labels";
13
13
 
14
14
  /**
15
- * Matches columns annotated `pl7.app/isSubset: "true"` whose axes ⊆ anchor axes.
16
- *
17
- * The axes-subset constraint is enforced by `mode: "enrichment"`, which sets
18
- * `allowFloatingHitAxes: false` — every axis of the matched column must be
19
- * present in the anchor's axes. See `matchingModeToConstraints()` in
20
- * `column_collection_builder.ts`.
15
+ * Columns annotated `pl7.app/isSubset: "true"` whose axes ⊆ anchor axes.
16
+ * The axes-subset constraint comes from `mode: "enrichment"`.
21
17
  */
22
18
  export function findFilterColumns(collection: AnchoredColumnCollection): ColumnMatch[] {
23
19
  return collection.findColumns({
@@ -28,51 +24,62 @@ export function findFilterColumns(collection: AnchoredColumnCollection): ColumnM
28
24
  });
29
25
  }
30
26
 
27
+ export type FilterMatchOptions = {
28
+ /** Maps result-pool column id back to its source PlRef (see {@link buildRefMap}). */
29
+ refsByObjectId: ReadonlyMap<PObjectId, PlRef>;
30
+ /** Spec of the dataset the filters are subsets of. */
31
+ datasetSpec: PObjectSpec;
32
+ /**
33
+ * Forwarded to `deriveDistinctLabels`. Any `formatters.native` caller
34
+ * sets is silently overridden — the function relies on a no-op native
35
+ * formatter to keep the algorithm from short-circuiting on filters'
36
+ * inherited `pl7.app/label`.
37
+ */
38
+ labelOptions?: DeriveLabelsOptions;
39
+ };
40
+
31
41
  /**
32
- * Derive labeled options from filter column matches, for use in DatasetOption.filters.
33
- *
34
- * Entries whose column id has no PlRef in `refsByObjectId` are silently
35
- * skipped — they cannot be exposed as user-selectable options.
36
- *
37
- * @param matches - from findFilterColumns()
38
- * @param refsByObjectId - from {@link buildRefMap}
39
- * @param labelOptions - forwarded to deriveDistinctLabels()
42
+ * Derive labels for filter column matches (for `DatasetOption.filters`).
43
+ * Matches whose column id is missing from `refsByObjectId` are silently
44
+ * dropped they cannot be exposed as selectable options.
40
45
  */
41
46
  export function filterMatchesToOptions(
42
47
  matches: ColumnMatch[],
43
- refsByObjectId: ReadonlyMap<PObjectId, PlRef>,
44
- labelOptions?: DeriveLabelsOptions,
48
+ options: FilterMatchOptions,
45
49
  ): Option[] {
46
50
  if (matches.length === 0) return [];
47
51
 
48
- // Each ColumnMatch can be reached via multiple variants (different linker
49
- // paths / qualifications). We emit one Option per variant so the user can
50
- // pick a specific path `deriveDistinctLabels` disambiguates labels by
51
- // path. All variants of a match share a column id, so the ref lookup
52
- // happens once per match.
53
- const flattened = matches.flatMap((match) => {
52
+ const { refsByObjectId, datasetSpec, labelOptions } = options;
53
+
54
+ // One entry per match-variant (different paths to the same column are
55
+ // exposed as separate Options). The `ref` field rides along on the
56
+ // Entry-shaped objects via structural typing; `deriveDistinctLabels`
57
+ // ignores extra fields.
58
+ const entries = matches.flatMap((match): (Entry & { ref: PlRef })[] => {
54
59
  const ref = refsByObjectId.get(match.column.id);
55
60
  if (ref === undefined) return [];
56
- return match.variants.map((variant) => ({ match, variant, ref }));
61
+ return match.variants.map((variant) => ({
62
+ ref,
63
+ spec: match.column.spec,
64
+ linkerPath: variant.path.map((p) => ({ spec: p.linker.spec })),
65
+ }));
57
66
  });
58
67
 
59
- const entries: Entry[] = flattened.map(({ match, variant }) => ({
60
- spec: match.column.spec,
61
- linkerPath: variant.path.map((p) => ({ spec: p.linker.spec })),
62
- }));
63
-
64
- const labels = deriveDistinctLabels(entries, labelOptions);
68
+ // Appending the dataset forces a discriminating trace step into every
69
+ // filter label (yielding e.g. "Bulk / Top 10"); the dataset's own label
70
+ // is dropped because we only zip `entries`. Native label is force-
71
+ // suppressed (the override sits after caller's spread) — filters
72
+ // inherit the dataset's `pl7.app/label` and would otherwise satisfy
73
+ // uniqueness before any trace step is consulted.
74
+ const labels = deriveDistinctLabels([...entries, { spec: datasetSpec }], {
75
+ ...labelOptions,
76
+ formatters: { ...labelOptions?.formatters, native: () => undefined },
77
+ });
65
78
 
66
- return flattened.map(({ ref }, i) => ({ ref, label: labels[i] }));
79
+ return entries.map(({ ref }, i) => ({ ref, label: labels[i] }));
67
80
  }
68
81
 
69
- /**
70
- * Usage: `buildRefMap(ctx.resultPool.getSpecs().entries)`
71
- */
82
+ /** Build the `refsByObjectId` map from `ctx.resultPool.getSpecs().entries`. */
72
83
  export function buildRefMap(entries: readonly { readonly ref: PlRef }[]): Map<PObjectId, PlRef> {
73
- const map = new Map<PObjectId, PlRef>();
74
- for (const entry of entries) {
75
- map.set(canonicalize(entry.ref)! as PObjectId, entry.ref);
76
- }
77
- return map;
84
+ return new Map(entries.map((e) => [canonicalize(e.ref)! as PObjectId, e.ref]));
78
85
  }