@causalmap/filter-engine 0.1.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.
@@ -0,0 +1,557 @@
1
+ // Shared, pure filter engine: links + sources tables in, filtered links out.
2
+ // No window / DataService / DOM. Imported by BOTH the browser webapp and the Deno edge function,
3
+ // so it must stay plain .js (no TypeScript, no build step) and import only pure helpers.
4
+ //
5
+ // This is the single source of truth for ordered link filtering. The webapp pipeline and the
6
+ // app-api edge function both call it; do not reintroduce a parallel copy.
7
+ import {
8
+ applyLabelByGroupToFactors,
9
+ attachReservedCmLinkFields,
10
+ } from './link-display-transforms.js'
11
+ import { findLinksWithinSteps, findLinksWithinStepsWithThreads, getLinkSourceIdsForTracing } from './link-traversal.js'
12
+ import { applyPathTracingFilter } from './path-tracing.js'
13
+
14
+ function norm(value) {
15
+ return String(value ?? '').trim().toLowerCase().replace(/\s+/g, ' ')
16
+ }
17
+
18
+ function arr(value) {
19
+ return Array.isArray(value)
20
+ ? value.map((v) => String(v ?? '').trim()).filter(Boolean)
21
+ : String(value ?? '').split(',').map((v) => v.trim()).filter(Boolean)
22
+ }
23
+
24
+ // Canonical (webapp) default is 'start' when no matchMode is given.
25
+ function checkTextMatch(value, term, mode) {
26
+ const text = String(value ?? '').toLowerCase()
27
+ const search = String(term ?? '').toLowerCase()
28
+ if (!search) return false
29
+ if (mode === 'anywhere') return text.includes(search)
30
+ if (mode === 'exact') return text === search
31
+ return text.startsWith(search) // 'start' and default
32
+ }
33
+
34
+ // Webapp tag list: array as-is, or comma-split a string (trim + drop empties).
35
+ function linkTagsList(link) {
36
+ return Array.isArray(link.tags)
37
+ ? link.tags
38
+ : (link.tags || '').split(',').map((t) => t.trim()).filter((t) => t)
39
+ }
40
+
41
+ /** Match DataService.applySourceJoinInPlace: expose source custom_columns/metadata on each row for label-by-group + map bundles. */
42
+ function mergeSourceMetadataOntoLinks(links, sourceById) {
43
+ return links.map((link) => {
44
+ const sid = String(link?.source_id ?? '').trim()
45
+ const source = sourceById.get(sid)
46
+ if (!source || typeof link !== 'object') return link
47
+ const out = { ...link }
48
+ const md = source.metadata && typeof source.metadata === 'object' ? source.metadata : {}
49
+ const cc = md.custom_columns && typeof md.custom_columns === 'object' ? md.custom_columns : {}
50
+ for (const key of Object.keys(cc)) {
51
+ const safeKey = String(key).replace(/[\s/]/g, '_')
52
+ out[`s_${safeKey}`] = cc[key]
53
+ }
54
+ for (const key of Object.keys(md)) {
55
+ if (key === 'custom_columns') continue
56
+ const value = md[key]
57
+ if (typeof value !== 'object' || value === null) {
58
+ out[`source_${key}`] = value
59
+ }
60
+ }
61
+ return out
62
+ })
63
+ }
64
+
65
+ // Factors whose cause/effect matches the filter's selected labels. Exposed so the webapp can reuse
66
+ // the exact same matching for its highlight outputs.
67
+ export function matchLabelFactors(links, filter) {
68
+ const selected = arr(filter.selectedLabels)
69
+ const mode = filter.matchMode
70
+ const targetFactors = new Set()
71
+ if (!selected.length) return targetFactors
72
+ for (const link of links) {
73
+ const cause = String(link?.cause || '')
74
+ const effect = String(link?.effect || '')
75
+ for (const label of selected) {
76
+ if (checkTextMatch(cause, label, mode)) targetFactors.add(cause)
77
+ if (checkTextMatch(effect, label, mode)) targetFactors.add(effect)
78
+ }
79
+ }
80
+ return targetFactors
81
+ }
82
+
83
+ export function applyLabelFilter(links, filter) {
84
+ const selected = arr(filter.selectedLabels)
85
+ if (!selected.length) return links
86
+ const targetFactors = matchLabelFactors(links, filter)
87
+ if (!targetFactors.size) return []
88
+ if (Number(filter.stepsUp) === 0 && Number(filter.stepsDown) === 0) return []
89
+ // Same-source thread tracing uses getLinkSourceIdsForTracing, which is what the webapp uses.
90
+ if (filter.traceThreads) {
91
+ return findLinksWithinStepsWithThreads(links, targetFactors, filter.stepsUp ?? 1, filter.stepsDown ?? 1)
92
+ }
93
+ return findLinksWithinSteps(links, targetFactors, filter.stepsUp ?? 1, filter.stepsDown ?? 1)
94
+ }
95
+
96
+ // Keep links whose tags match ANY selected tag (OR). Links with no tags are dropped.
97
+ export function applyTagsFilter(links, filter) {
98
+ const selected = Array.isArray(filter.selectedTags) ? filter.selectedTags : []
99
+ if (!selected.length) return links
100
+ return links.filter((link) => {
101
+ if (!link.tags) return false
102
+ const linkTags = linkTagsList(link)
103
+ return selected.some((selectedTag) =>
104
+ linkTags.some((linkTag) => checkTextMatch(linkTag, selectedTag, filter.matchMode)))
105
+ })
106
+ }
107
+
108
+ // Exclude links whose tags match the selected tags. Default ALL (every); excludeAny uses OR (some).
109
+ // Links with no tags are kept.
110
+ export function applyExcludeTagsFilter(links, filter) {
111
+ const selected = Array.isArray(filter.selectedTags) ? filter.selectedTags : []
112
+ if (!selected.length) return links
113
+ const excludeAny = filter.excludeAny === true
114
+ return links.filter((link) => {
115
+ if (!link.tags) return true
116
+ const linkTags = linkTagsList(link)
117
+ const tagsMatch = excludeAny
118
+ ? selected.some((et) => linkTags.some((lt) => checkTextMatch(lt, et, filter.matchMode)))
119
+ : selected.every((et) => linkTags.some((lt) => checkTextMatch(lt, et, filter.matchMode)))
120
+ return !tagsMatch
121
+ })
122
+ }
123
+
124
+ // Membership over a resolved link column (the caller resolves filter.selectedField first). No trim.
125
+ export function applyEverythingFilter(links, filter) {
126
+ const field = filter.selectedField
127
+ const values = Array.isArray(filter.selectedValues) ? filter.selectedValues : []
128
+ if (!field || !values.length) return links
129
+ return links.filter((link) => {
130
+ const fieldValue = link[field]
131
+ if (fieldValue !== null && fieldValue !== undefined && fieldValue !== '') {
132
+ if (Array.isArray(fieldValue)) return fieldValue.some((v) => values.includes(String(v)))
133
+ return values.includes(String(fieldValue))
134
+ }
135
+ return false
136
+ })
137
+ }
138
+
139
+ // Exclude links whose cause/effect match the selected labels. Default ALL (every); excludeAny uses OR (some).
140
+ export function applyExcludeLabelFilter(links, filter) {
141
+ const selected = Array.isArray(filter.selectedLabels) ? filter.selectedLabels : []
142
+ if (!selected.length) return links
143
+ const excludeAny = filter.excludeAny === true
144
+ return links.filter((link) => {
145
+ const cause = link.cause || ''
146
+ const effect = link.effect || ''
147
+ const labelsMatch = excludeAny
148
+ ? selected.some((el) => checkTextMatch(cause, el, filter.matchMode) || checkTextMatch(effect, el, filter.matchMode))
149
+ : selected.every((el) => checkTextMatch(cause, el, filter.matchMode) || checkTextMatch(effect, el, filter.matchMode))
150
+ return !labelsMatch
151
+ })
152
+ }
153
+
154
+ function applySourcesFilter(links, filter) {
155
+ const sourceIds = new Set(arr(filter.sourceIds).map((id) => norm(id)))
156
+ if (!sourceIds.size) return links
157
+ return links.filter((link) => sourceIds.has(norm(link?.source_id)))
158
+ }
159
+
160
+ // Threshold/mode selection shared by both frequency filters (faithful to the webapp).
161
+ // mode: 'minimum' keeps metrics >= threshold; anything else is 'top' (default) keeping the
162
+ // top-`threshold` by value, ties at the cutoff included. which: 'sources' counts unique sources,
163
+ // anything else counts appearances (default).
164
+ function frequencyAllowed(metrics, filter) {
165
+ const threshold = filter.threshold || 1
166
+ const allowed = new Set()
167
+ if (filter.mode === 'minimum') {
168
+ metrics.forEach((count, key) => {
169
+ if (count >= threshold) allowed.add(key)
170
+ })
171
+ } else {
172
+ const sorted = Array.from(metrics.entries()).sort((a, b) => b[1] - a[1])
173
+ if (sorted.length > 0) {
174
+ const idx = Math.min(threshold - 1, sorted.length - 1)
175
+ const cutoff = sorted[idx][1]
176
+ sorted.forEach(([key, cnt]) => {
177
+ if (cnt >= cutoff) allowed.add(key)
178
+ })
179
+ }
180
+ }
181
+ return allowed
182
+ }
183
+
184
+ /**
185
+ * Link frequency filter. Bundles links by cause|||effect, optionally separating flipped opposite
186
+ * pairs. `options.separateFlippedBundles` is the resolved combine-opposites bundle decision
187
+ * (strategy === 'separate'); the caller computes it from the active combine-opposites filter and
188
+ * passes it in, so the engine stays free of pipeline-state lookups. The per-link flip flags
189
+ * (flipped_cause / flipped_effect) are already on the link, set upstream by combine-opposites.
190
+ */
191
+ export function applyLinkFrequencyFilter(links, filter, options = {}) {
192
+ const separateFlippedBundles = options.separateFlippedBundles === true
193
+ const getBundleKey = (link) => {
194
+ const c = `${(link.cause || '').trim()}`
195
+ const e = `${(link.effect || '').trim()}`
196
+ const hasFlip = (typeof link.flipped_cause === 'boolean') || (typeof link.flipped_effect === 'boolean')
197
+ if (!hasFlip || !separateFlippedBundles) return `${c}|||${e}`
198
+ const fc = link.flipped_cause === true ? 'F' : '-'
199
+ const fe = link.flipped_effect === true ? 'F' : '-'
200
+ return `${c}|||${e}|||${fc}${fe}`
201
+ }
202
+
203
+ const linkMetrics = new Map()
204
+ if (filter.which === 'sources') {
205
+ const sourceSets = new Map()
206
+ links.forEach((link) => {
207
+ const key = getBundleKey(link)
208
+ if (!sourceSets.has(key)) sourceSets.set(key, new Set())
209
+ getLinkSourceIdsForTracing(link).forEach((sourceId) => sourceSets.get(key).add(sourceId))
210
+ })
211
+ sourceSets.forEach((set, key) => linkMetrics.set(key, set.size))
212
+ } else {
213
+ links.forEach((link) => {
214
+ const key = getBundleKey(link)
215
+ linkMetrics.set(key, (linkMetrics.get(key) || 0) + 1)
216
+ })
217
+ }
218
+
219
+ const allowedKeys = frequencyAllowed(linkMetrics, filter)
220
+ return links.filter((link) => allowedKeys.has(getBundleKey(link)))
221
+ }
222
+
223
+ // Factor frequency filter: count factors (cause/effect) and keep links where BOTH ends pass.
224
+ // No flip bundling (factors, not link pairs).
225
+ export function applyFactorFrequencyFilter(links, filter) {
226
+ const factorCounts = new Map()
227
+ if (filter.which === 'sources') {
228
+ const factorSourceSets = new Map()
229
+ links.forEach((link) => {
230
+ const sourceIds = getLinkSourceIdsForTracing(link)
231
+ if (sourceIds.length === 0) return
232
+ const cause = (link.cause || '').trim()
233
+ const effect = (link.effect || '').trim()
234
+ if (cause) {
235
+ if (!factorSourceSets.has(cause)) factorSourceSets.set(cause, new Set())
236
+ sourceIds.forEach((sourceId) => factorSourceSets.get(cause).add(sourceId))
237
+ }
238
+ if (effect) {
239
+ if (!factorSourceSets.has(effect)) factorSourceSets.set(effect, new Set())
240
+ sourceIds.forEach((sourceId) => factorSourceSets.get(effect).add(sourceId))
241
+ }
242
+ })
243
+ factorSourceSets.forEach((set, factor) => factorCounts.set(factor, set.size))
244
+ } else {
245
+ links.forEach((link) => {
246
+ const cause = (link.cause || '').trim()
247
+ const effect = (link.effect || '').trim()
248
+ if (cause) factorCounts.set(cause, (factorCounts.get(cause) || 0) + 1)
249
+ if (effect) factorCounts.set(effect, (factorCounts.get(effect) || 0) + 1)
250
+ })
251
+ }
252
+
253
+ const allowedFactors = frequencyAllowed(factorCounts, filter)
254
+ return links.filter((link) => allowedFactors.has((link.cause || '').trim()) && allowedFactors.has((link.effect || '').trim()))
255
+ }
256
+
257
+ // Membership over a resolved source-group column flattened onto each link (s_*/source_*).
258
+ // The caller resolves filter.selectedField to that key first. Exact value match, trimmed.
259
+ export function applySourceGroupsFilter(links, filter) {
260
+ const field = filter.selectedField
261
+ const values = Array.isArray(filter.selectedValues) ? filter.selectedValues : []
262
+ if (!field || !values.length) return links
263
+ return links.filter((link) => {
264
+ const fieldValue = link[field]
265
+ if (fieldValue === null || fieldValue === undefined || fieldValue === '') return false
266
+ if (Array.isArray(fieldValue)) return fieldValue.some((v) => values.includes(String(v).trim()))
267
+ return values.includes(String(fieldValue).trim())
268
+ })
269
+ }
270
+
271
+ export function applyExcludeSelfLoops(links) {
272
+ return links.filter((link) => (link.cause || '').trim() !== (link.effect || '').trim())
273
+ }
274
+
275
+ // Canonical per-label transforms (match the webapp's applyZoomToLabel / applyCollapseToLabel /
276
+ // applyBracketReplacement exactly).
277
+ export function applyZoomToLabel(label, level) {
278
+ if (!label || level === 0 || level === '0' || level === 'none') return label
279
+ const levelNum = parseInt(level)
280
+ if (isNaN(levelNum) || levelNum <= 0) return label
281
+ const parts = label.split(';').map((p) => p.trim())
282
+ if (parts.length <= levelNum) return label
283
+ return parts.slice(0, levelNum).join('; ')
284
+ }
285
+
286
+ export function applyCollapseToLabel(label, selectedLabels, separate, matchMode) {
287
+ if (!label || !selectedLabels || selectedLabels.length === 0) return label
288
+ let result = label
289
+ if (separate) {
290
+ // Replace each matching search term with itself (re-checked against the running result).
291
+ selectedLabels.forEach((searchTerm) => {
292
+ if (checkTextMatch(result, searchTerm, matchMode)) result = searchTerm
293
+ })
294
+ } else {
295
+ // Replace any matching label with the first search term.
296
+ if (selectedLabels.some((searchTerm) => checkTextMatch(result, searchTerm, matchMode))) {
297
+ result = selectedLabels[0]
298
+ }
299
+ }
300
+ return result
301
+ }
302
+
303
+ function applyBracketReplacement(label, replaceRound, replaceSquare) {
304
+ if (!label || (!replaceRound && !replaceSquare)) return label
305
+ let result = label
306
+ if (replaceRound) result = result.replace(/\([^)]*\)/g, '').trim()
307
+ if (replaceSquare) result = result.replace(/\[[^\]]*\]/g, '').trim()
308
+ return result
309
+ }
310
+
311
+ /**
312
+ * Shared recoding transform for the label-transform family (zoom / collapse / replace-brackets).
313
+ * Applies `transformLabel` to each cause/effect, tracks `_recoded` provenance (preserving the
314
+ * earliest original), and returns the set of changed (new) labels for highlights.
315
+ * @param {Array} links
316
+ * @param {(label:string)=>string} transformLabel
317
+ * @param {{ setFlags?: boolean }} options setFlags adds `_recoded_cause`/`_recoded_effect` (collapse,
318
+ * replace-brackets do; zoom does not).
319
+ * @returns {{ rows: Array, changedFactors: Set<string> }}
320
+ */
321
+ export function applyRecodingTransform(links, transformLabel, options = {}) {
322
+ const setFlags = options.setFlags === true
323
+ const changedFactors = new Set()
324
+ const rows = links.map((link) => {
325
+ const originalCause = link.cause
326
+ const originalEffect = link.effect
327
+ const newCause = transformLabel(originalCause)
328
+ const newEffect = transformLabel(originalEffect)
329
+ const recodedCause = newCause !== originalCause
330
+ const recodedEffect = newEffect !== originalEffect
331
+ if (recodedCause) changedFactors.add(newCause)
332
+ if (recodedEffect) changedFactors.add(newEffect)
333
+
334
+ // Preserve original labels in _recoded for downstream operations (e.g. rename).
335
+ const recodedMeta = link._recoded || {
336
+ cause: { original: originalCause, recoded: false },
337
+ effect: { original: originalEffect, recoded: false },
338
+ }
339
+ if (recodedCause) {
340
+ if (!recodedMeta.cause) recodedMeta.cause = { original: originalCause, recoded: true }
341
+ else if (!recodedMeta.cause.original) recodedMeta.cause.original = originalCause
342
+ recodedMeta.cause.recoded = true
343
+ }
344
+ if (recodedEffect) {
345
+ if (!recodedMeta.effect) recodedMeta.effect = { original: originalEffect, recoded: true }
346
+ else if (!recodedMeta.effect.original) recodedMeta.effect.original = originalEffect
347
+ recodedMeta.effect.recoded = true
348
+ }
349
+
350
+ const out = { ...link, cause: newCause, effect: newEffect, _recoded: recodedMeta }
351
+ if (setFlags) {
352
+ out._recoded_cause = recodedCause || link._recoded_cause === true
353
+ out._recoded_effect = recodedEffect || link._recoded_effect === true
354
+ }
355
+ return out
356
+ })
357
+ return { rows, changedFactors }
358
+ }
359
+
360
+ export function applyZoomFilter(links, filter) {
361
+ if (!(filter.level > 0)) return links
362
+ return applyRecodingTransform(links, (label) => applyZoomToLabel(label, filter.level), { setFlags: false }).rows
363
+ }
364
+
365
+ // Returns { rows, changedFactors }: changedFactors feeds the webapp's collapse highlights.
366
+ export function applyCollapseFilter(links, filter) {
367
+ const selected = filter.selectedLabels && filter.selectedLabels.length ? filter.selectedLabels : []
368
+ if (!selected.length) return { rows: links, changedFactors: new Set() }
369
+ return applyRecodingTransform(
370
+ links,
371
+ (label) => applyCollapseToLabel(label, selected, filter.separate, filter.matchMode),
372
+ { setFlags: true }
373
+ )
374
+ }
375
+
376
+ export function applyReplaceBracketsFilter(links, filter) {
377
+ const hasNew = (typeof filter?.replaceRound === 'boolean') || (typeof filter?.replaceSquare === 'boolean')
378
+ const replaceRound = hasNew ? !!filter.replaceRound : (filter?.bracketMode === 'round')
379
+ const replaceSquare = hasNew ? !!filter.replaceSquare : (filter?.bracketMode === 'square')
380
+ if (!replaceRound && !replaceSquare) return links
381
+ return applyRecodingTransform(
382
+ links,
383
+ (label) => applyBracketReplacement(label, replaceRound, replaceSquare),
384
+ { setFlags: true }
385
+ ).rows
386
+ }
387
+
388
+ // Webapp stripTags: remove bracketed [N] / [~N] tags, collapse whitespace.
389
+ function stripCombineTags(text) {
390
+ if (!text) return ''
391
+ return String(text).replace(/\[~\d+\]|\[\d+\]/g, '').replace(/\s+/g, ' ').trim()
392
+ }
393
+
394
+ function normalizeHierarchy(label) {
395
+ return String(label || '').split(';').map((p) => p.replace(/\s+/g, ' ').trim()).filter(Boolean).join('; ')
396
+ }
397
+
398
+ // Faithful port of the webapp's combine-opposites inline branch. Rewrites negative variants
399
+ // ([~N] / legacy bareword ~N, and ~prefixed hierarchy labels) to their positive label and sets
400
+ // flipped_cause/flipped_effect. Numeric opposites default ON; tilde opposites default OFF.
401
+ export function applyCombineOppositesFilter(links, filter) {
402
+ const useNumericOpposites = (filter.useNumericOpposites !== false)
403
+ const useTildeOpposites = (filter.useTildeOpposites === true)
404
+
405
+ const labelsSet = new Set()
406
+ for (const link of links) {
407
+ if (link.cause) labelsSet.add(link.cause)
408
+ if (link.effect) labelsSet.add(link.effect)
409
+ }
410
+
411
+ // numericMapping: tagNum -> { negativeLabel, positiveLabel }
412
+ const numericMapping = new Map()
413
+ if (useNumericOpposites) {
414
+ const tagPairs = new Map()
415
+ labelsSet.forEach((label) => {
416
+ const negMatch = label.match(/\[~(\d+)\]|~(\d+)/)
417
+ if (negMatch) {
418
+ const num = negMatch[1] || negMatch[2]
419
+ if (!tagPairs.has(num)) tagPairs.set(num, {})
420
+ tagPairs.get(num).negative = label
421
+ }
422
+ const bracketMatch = label.match(/\[(\d+)\]/)
423
+ if (bracketMatch) {
424
+ const matchIndex = label.indexOf(bracketMatch[0])
425
+ if (matchIndex === 0 || label[matchIndex - 1] !== '~') {
426
+ const num = bracketMatch[1]
427
+ if (!tagPairs.has(num)) tagPairs.set(num, {})
428
+ tagPairs.get(num).positive = label
429
+ }
430
+ } else {
431
+ const allNumbers = label.matchAll(/\d+/g)
432
+ for (const match of allNumbers) {
433
+ const num = match[0]
434
+ const matchIndex = match.index
435
+ if (matchIndex === 0 || label[matchIndex - 1] !== '~') {
436
+ if (!tagPairs.has(num)) tagPairs.set(num, {})
437
+ tagPairs.get(num).positive = label
438
+ break
439
+ }
440
+ }
441
+ }
442
+ })
443
+ tagPairs.forEach((pair, num) => {
444
+ if (pair.negative && pair.positive) {
445
+ numericMapping.set(num, { negativeLabel: pair.negative, positiveLabel: pair.positive })
446
+ }
447
+ })
448
+ }
449
+
450
+ // tildeMapping: normalized ~label -> preferred opposite (existing original spelling if present).
451
+ const tildeMapping = new Map()
452
+ if (useTildeOpposites) {
453
+ const toggleAllParts = (normalizedLabel) => normalizeHierarchy(normalizedLabel).split(';').map((part) => {
454
+ const s = part.replace(/\s+/g, ' ').trim()
455
+ return /^~\s*/.test(s) ? s.replace(/^~\s*/, '').trim() : `~${s}`
456
+ }).join('; ')
457
+ const normToOriginal = new Map()
458
+ labelsSet.forEach((label) => {
459
+ const orig = String(label || '')
460
+ const norm = normalizeHierarchy(orig)
461
+ if (!norm) return
462
+ if (!normToOriginal.has(norm)) normToOriginal.set(norm, orig)
463
+ })
464
+ labelsSet.forEach((label) => {
465
+ const orig = String(label || '').trim()
466
+ if (!/^\s*~/.test(orig)) return
467
+ const norm = normalizeHierarchy(orig)
468
+ const oppositeNorm = toggleAllParts(norm)
469
+ tildeMapping.set(norm, normToOriginal.get(oppositeNorm) || oppositeNorm)
470
+ })
471
+ }
472
+
473
+ return links.map((link) => {
474
+ let newCause = link.cause
475
+ let newEffect = link.effect
476
+ let flippedCause = false
477
+ let flippedEffect = false
478
+
479
+ if (useNumericOpposites) {
480
+ if (link.effect) {
481
+ const negTagMatch = link.effect.match(/\[~(\d+)\]|~(\d+)/)
482
+ if (negTagMatch) {
483
+ const mapping = numericMapping.get(negTagMatch[1] || negTagMatch[2])
484
+ if (mapping) { newEffect = mapping.positiveLabel; flippedEffect = true }
485
+ }
486
+ }
487
+ if (link.cause) {
488
+ const negTagMatch = link.cause.match(/\[~(\d+)\]|~(\d+)/)
489
+ if (negTagMatch) {
490
+ const mapping = numericMapping.get(negTagMatch[1] || negTagMatch[2])
491
+ if (mapping) { newCause = mapping.positiveLabel; flippedCause = true }
492
+ }
493
+ }
494
+ }
495
+
496
+ if (useTildeOpposites) {
497
+ if (typeof newEffect === 'string' && /^\s*~/.test(newEffect)) {
498
+ const mapped = tildeMapping.get(normalizeHierarchy(newEffect))
499
+ if (mapped) { newEffect = mapped; flippedEffect = true }
500
+ }
501
+ if (typeof newCause === 'string' && /^\s*~/.test(newCause)) {
502
+ const mapped = tildeMapping.get(normalizeHierarchy(newCause))
503
+ if (mapped) { newCause = mapped; flippedCause = true }
504
+ }
505
+ }
506
+
507
+ if (filter.stripTags !== false) {
508
+ newCause = stripCombineTags(newCause)
509
+ newEffect = stripCombineTags(newEffect)
510
+ }
511
+
512
+ return { ...link, cause: newCause, effect: newEffect, flipped_cause: flippedCause, flipped_effect: flippedEffect }
513
+ })
514
+ }
515
+
516
+ /**
517
+ * Apply an ordered list of link filters. Pure: data in, data out.
518
+ * @param {Array} links Enriched link rows.
519
+ * @param {Array} filters Ordered filter configs ({ id, type, enabled?, ...cfg }).
520
+ * @param {Array} sources Source rows (id, metadata.custom_columns); used by source-based filters.
521
+ * @returns {{ rows: Array, unsupported: string[] }}
522
+ */
523
+ export function applyOrderedLinkFilters(links, filters, sources = []) {
524
+ const sourceById = new Map(sources.map((source) => [String(source?.id ?? ''), source]))
525
+ let rows = Array.isArray(links) ? mergeSourceMetadataOntoLinks(links.slice(), sourceById) : []
526
+ const list = Array.isArray(filters) ? filters : []
527
+ const unsupported = []
528
+
529
+ for (const raw of list) {
530
+ const filter = raw && typeof raw === 'object' ? raw : null
531
+ if (!filter || filter.enabled === false) continue
532
+ const type = String(filter.type || '').trim()
533
+ if (type === 'sources') rows = applySourcesFilter(rows, filter)
534
+ else if (type === 'everything') rows = applyEverythingFilter(rows, filter)
535
+ else if (type === 'label') rows = applyLabelFilter(rows, filter)
536
+ else if (type === 'exclude-label') rows = applyExcludeLabelFilter(rows, filter)
537
+ else if (type === 'path-tracing') rows = applyPathTracingFilter(rows, filter)
538
+ else if (type === 'tags') rows = applyTagsFilter(rows, filter)
539
+ else if (type === 'exclude-tags') rows = applyExcludeTagsFilter(rows, filter)
540
+ else if (type === 'source-groups') rows = applySourceGroupsFilter(rows, filter)
541
+ else if (type === 'link-frequency') rows = applyLinkFrequencyFilter(rows, filter)
542
+ else if (type === 'factor-frequency') rows = applyFactorFrequencyFilter(rows, filter)
543
+ else if (type === 'custom-links-label') rows = applyLabelByGroupToFactors(rows, filter)
544
+ else if (type === 'map-custom-columns') {
545
+ // Reserved map columns are attached after ordered row filters have run.
546
+ }
547
+ else if (type === 'exclude-self-loops') rows = applyExcludeSelfLoops(rows)
548
+ else if (type === 'zoom') rows = applyZoomFilter(rows, filter)
549
+ else if (type === 'collapse') rows = applyCollapseFilter(rows, filter).rows
550
+ else if (type === 'replace-brackets') rows = applyReplaceBracketsFilter(rows, filter)
551
+ else if (type === 'combine-opposites') rows = applyCombineOppositesFilter(rows, filter)
552
+ else unsupported.push(type || '(missing type)')
553
+ }
554
+
555
+ rows = attachReservedCmLinkFields(rows, list.filter((filter) => filter && typeof filter === 'object'))
556
+ return { rows, unsupported }
557
+ }