@causalmap/filter-engine 0.1.0 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/index.js +4 -0
- package/package.json +2 -1
- package/src/apply-filters.js +117 -34
- package/src/link-display-transforms.js +459 -430
- package/src/link-traversal.js +167 -144
package/README.md
CHANGED
|
@@ -113,6 +113,14 @@ A few filters need a value the host application resolves and supplies:
|
|
|
113
113
|
`applyLinkFrequencyFilter(links, filter, { separateFlippedBundles })` when driving it directly. It
|
|
114
114
|
defaults to `false`, so a consumer without combine-opposites needs nothing.
|
|
115
115
|
|
|
116
|
+
## Documentation
|
|
117
|
+
|
|
118
|
+
These filters are the engine behind the Causal Map app. To see what they do in context:
|
|
119
|
+
|
|
120
|
+
- [Answering questions: individual questions](https://garden.causalmap.app/questions-introduction/): the analysis questions these filters answer (main outcomes, comparing groups, path tracing, and more).
|
|
121
|
+
- [How to, in the Causal Map app](https://garden.causalmap.app/howto-intro/): step-by-step guides for using the filters in the app.
|
|
122
|
+
- [The Causal Map app](https://garden.causalmap.app/app/): the product the engine powers.
|
|
123
|
+
|
|
116
124
|
## Provenance
|
|
117
125
|
|
|
118
126
|
Canonical source: `webapp/js/filter-engine` in the Causal Map app. `src/` here is generated by
|
package/index.js
CHANGED
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
// Primary entry: ordered structural link filtering, data in and data out.
|
|
9
9
|
export { applyOrderedLinkFilters } from './src/apply-filters.js'
|
|
10
10
|
|
|
11
|
+
// The filter types applyOrderedLinkFilters understands, and the helpers that resolve a
|
|
12
|
+
// source-groups filter's column and values against a project's sources.
|
|
13
|
+
export { LINK_FILTER_TYPES, sourceGroupColumns, canonicalSourceGroupValues } from './src/apply-filters.js'
|
|
14
|
+
|
|
11
15
|
// Per-filter functions, for consumers that drive filters individually.
|
|
12
16
|
export {
|
|
13
17
|
applyLabelFilter,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@causalmap/filter-engine",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Pure, dependency-free link-filter engine for causal maps: ordered structural filters (tags, label, path-tracing, frequency, combine-opposites, zoom/collapse and more), data in and data out.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "FSL-1.1-MIT",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
],
|
|
19
19
|
"scripts": {
|
|
20
20
|
"sync": "node ./sync-from-webapp.mjs",
|
|
21
|
+
"drift": "node ./sync-from-webapp.mjs --drift",
|
|
21
22
|
"test": "node --test tests/filters/traversal.test.mjs tests/filters/ordered-filters.test.mjs && node tests/filters/_filtering-diff.mjs && node tests/filters/_transform-diff.mjs && node tests/filters/_combine-diff.mjs && node tests/filters/_frequency-diff.mjs && node tests/filters/_path-diff.mjs",
|
|
22
23
|
"prepack": "node ./sync-from-webapp.mjs"
|
|
23
24
|
},
|
package/src/apply-filters.js
CHANGED
|
@@ -39,29 +39,72 @@ function linkTagsList(link) {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/** Match DataService.applySourceJoinInPlace: expose source custom_columns/metadata on each row for label-by-group + map bundles. */
|
|
42
|
+
// A source's metadata as the link columns the engine filters on, [column, name, value]: each custom column as
|
|
43
|
+
// s_<name with spaces and slashes as underscores>, each other scalar field as source_<name>. One function, so what the
|
|
44
|
+
// engine filters on and what a source-groups filter is resolved against cannot drift apart.
|
|
45
|
+
function sourceMetadataColumns(source) {
|
|
46
|
+
const md = source?.metadata && typeof source.metadata === 'object' ? source.metadata : {}
|
|
47
|
+
const cc = md.custom_columns && typeof md.custom_columns === 'object' ? md.custom_columns : {}
|
|
48
|
+
const out = []
|
|
49
|
+
for (const key of Object.keys(cc)) out.push([`s_${String(key).replace(/[\s/]/g, '_')}`, key, cc[key]])
|
|
50
|
+
for (const key of Object.keys(md)) {
|
|
51
|
+
if (key === 'custom_columns') continue
|
|
52
|
+
const value = md[key]
|
|
53
|
+
if (typeof value !== 'object' || value === null) out.push([`source_${key}`, key, value])
|
|
54
|
+
}
|
|
55
|
+
return out
|
|
56
|
+
}
|
|
57
|
+
|
|
42
58
|
function mergeSourceMetadataOntoLinks(links, sourceById) {
|
|
43
59
|
return links.map((link) => {
|
|
44
60
|
const sid = String(link?.source_id ?? '').trim()
|
|
45
61
|
const source = sourceById.get(sid)
|
|
46
62
|
if (!source || typeof link !== 'object') return link
|
|
47
63
|
const out = { ...link }
|
|
48
|
-
const
|
|
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
|
-
}
|
|
64
|
+
for (const [column, , value] of sourceMetadataColumns(source)) out[column] = value
|
|
61
65
|
return out
|
|
62
66
|
})
|
|
63
67
|
}
|
|
64
68
|
|
|
69
|
+
/** Every source column a source-groups filter can name, under the key the engine filters on, with its name and the
|
|
70
|
+
* values the sources hold in it (trimmed, list values counted one by one). */
|
|
71
|
+
export function sourceGroupColumns(sources) {
|
|
72
|
+
const columns = new Map()
|
|
73
|
+
for (const source of Array.isArray(sources) ? sources : []) {
|
|
74
|
+
for (const [column, name, value] of sourceMetadataColumns(source)) {
|
|
75
|
+
if (!columns.has(column)) columns.set(column, { name, values: new Set() })
|
|
76
|
+
for (const v of Array.isArray(value) ? value : [value]) {
|
|
77
|
+
if (v !== null && v !== undefined && String(v).trim() !== '') columns.get(column).values.add(String(v).trim())
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return columns
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const compactKey = (value) => String(value ?? '').trim().toLowerCase().replace(/[^a-z0-9]/g, '')
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Requested source-group values as the column spells them: exact, then ignoring case, then ignoring everything but
|
|
88
|
+
* letters and digits, so "mnx-4" is "MNX4". A value with no match is kept as asked and named in `absent`, since the
|
|
89
|
+
* filter's exact match would drop every link for it.
|
|
90
|
+
*/
|
|
91
|
+
export function canonicalSourceGroupValues(requested, available) {
|
|
92
|
+
const values = [...(available || [])].map((v) => String(v ?? '').trim()).filter(Boolean)
|
|
93
|
+
const exact = new Set(values)
|
|
94
|
+
const byLower = new Map(values.map((v) => [v.toLowerCase(), v]))
|
|
95
|
+
const byCompact = new Map(values.map((v) => [compactKey(v), v]))
|
|
96
|
+
const out = []
|
|
97
|
+
const absent = []
|
|
98
|
+
for (const raw of Array.isArray(requested) ? requested : []) {
|
|
99
|
+
const r = String(raw ?? '').trim()
|
|
100
|
+
if (!r) continue
|
|
101
|
+
const hit = exact.has(r) ? r : (byLower.get(r.toLowerCase()) || byCompact.get(compactKey(r)))
|
|
102
|
+
if (!hit) absent.push(r)
|
|
103
|
+
out.push(hit || r)
|
|
104
|
+
}
|
|
105
|
+
return { values: [...new Set(out)], absent }
|
|
106
|
+
}
|
|
107
|
+
|
|
65
108
|
// Factors whose cause/effect matches the filter's selected labels. Exposed so the webapp can reuse
|
|
66
109
|
// the exact same matching for its highlight outputs.
|
|
67
110
|
export function matchLabelFactors(links, filter) {
|
|
@@ -254,8 +297,36 @@ export function applyFactorFrequencyFilter(links, filter) {
|
|
|
254
297
|
return links.filter((link) => allowedFactors.has((link.cause || '').trim()) && allowedFactors.has((link.effect || '').trim()))
|
|
255
298
|
}
|
|
256
299
|
|
|
300
|
+
const normalizeSourceFieldSuffix = (suffix) => String(suffix || '').trim().replace(/[\s/]/g, '_').toLowerCase()
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The flattened link column (s_* or source_*, as mergeSourceMetadataOntoLinks writes them) that a source-group field
|
|
304
|
+
* name means. `available` is the column names, or link rows to read them from. A name that is already a column is
|
|
305
|
+
* kept; a legacy custom_<name> alias or a plain source column name (as a person or a model writes it, such as
|
|
306
|
+
* "#Sex of the respondent") becomes its s_* column; anything else is returned unchanged.
|
|
307
|
+
*/
|
|
308
|
+
export function resolveSourceGroupsField(field, available) {
|
|
309
|
+
const requested = String(field || '').trim()
|
|
310
|
+
if (!requested) return requested
|
|
311
|
+
const names = new Set()
|
|
312
|
+
for (const item of available instanceof Set || Array.isArray(available) ? available : []) {
|
|
313
|
+
if (typeof item === 'string') names.add(item.trim())
|
|
314
|
+
else if (item && typeof item === 'object') Object.keys(item).forEach((k) => names.add(k))
|
|
315
|
+
}
|
|
316
|
+
if (names.size === 0 || names.has(requested)) return requested
|
|
317
|
+
const plain = requested.startsWith('custom_') ? requested.substring(7) : requested
|
|
318
|
+
const flattened = `s_${plain.replace(/[\s/]/g, '_')}`
|
|
319
|
+
if (names.has(flattened)) return flattened
|
|
320
|
+
if (names.has(`source_${plain}`)) return `source_${plain}`
|
|
321
|
+
const wanted = normalizeSourceFieldSuffix(plain)
|
|
322
|
+
for (const name of names) {
|
|
323
|
+
if (name.startsWith('s_') && normalizeSourceFieldSuffix(name.substring(2)) === wanted) return name
|
|
324
|
+
}
|
|
325
|
+
return requested
|
|
326
|
+
}
|
|
327
|
+
|
|
257
328
|
// 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.
|
|
329
|
+
// The caller resolves filter.selectedField to that key first (resolveSourceGroupsField). Exact value match, trimmed.
|
|
259
330
|
export function applySourceGroupsFilter(links, filter) {
|
|
260
331
|
const field = filter.selectedField
|
|
261
332
|
const values = Array.isArray(filter.selectedValues) ? filter.selectedValues : []
|
|
@@ -268,8 +339,13 @@ export function applySourceGroupsFilter(links, filter) {
|
|
|
268
339
|
})
|
|
269
340
|
}
|
|
270
341
|
|
|
342
|
+
// The one test of whether a link runs from a factor to itself; recode's loop refusal uses it too.
|
|
343
|
+
export function isSelfLoop(cause, effect) {
|
|
344
|
+
return String(cause ?? '').trim() === String(effect ?? '').trim()
|
|
345
|
+
}
|
|
346
|
+
|
|
271
347
|
export function applyExcludeSelfLoops(links) {
|
|
272
|
-
return links.filter((link) => (link.cause
|
|
348
|
+
return links.filter((link) => !isSelfLoop(link.cause, link.effect))
|
|
273
349
|
}
|
|
274
350
|
|
|
275
351
|
// Canonical per-label transforms (match the webapp's applyZoomToLabel / applyCollapseToLabel /
|
|
@@ -513,6 +589,31 @@ export function applyCombineOppositesFilter(links, filter) {
|
|
|
513
589
|
})
|
|
514
590
|
}
|
|
515
591
|
|
|
592
|
+
// Every link filter type the engine applies, and how. This registry is the vocabulary: applyOrderedLinkFilters reads
|
|
593
|
+
// it, and anything that describes or checks a chain (the MCP server's schema and filter reference) lists its keys, so a
|
|
594
|
+
// new type is one entry here.
|
|
595
|
+
const LINK_FILTERS = {
|
|
596
|
+
sources: applySourcesFilter,
|
|
597
|
+
everything: applyEverythingFilter,
|
|
598
|
+
label: applyLabelFilter,
|
|
599
|
+
'exclude-label': applyExcludeLabelFilter,
|
|
600
|
+
'path-tracing': applyPathTracingFilter,
|
|
601
|
+
tags: applyTagsFilter,
|
|
602
|
+
'exclude-tags': applyExcludeTagsFilter,
|
|
603
|
+
'source-groups': (rows, filter) => applySourceGroupsFilter(rows, { ...filter, selectedField: resolveSourceGroupsField(filter.selectedField, rows) }),
|
|
604
|
+
'link-frequency': applyLinkFrequencyFilter,
|
|
605
|
+
'factor-frequency': applyFactorFrequencyFilter,
|
|
606
|
+
'custom-links-label': applyLabelByGroupToFactors,
|
|
607
|
+
// Reserved map columns are attached after ordered row filters have run.
|
|
608
|
+
'map-custom-columns': (rows) => rows,
|
|
609
|
+
'exclude-self-loops': (rows) => applyExcludeSelfLoops(rows),
|
|
610
|
+
zoom: applyZoomFilter,
|
|
611
|
+
collapse: (rows, filter) => applyCollapseFilter(rows, filter).rows,
|
|
612
|
+
'replace-brackets': applyReplaceBracketsFilter,
|
|
613
|
+
'combine-opposites': applyCombineOppositesFilter,
|
|
614
|
+
}
|
|
615
|
+
export const LINK_FILTER_TYPES = Object.freeze(Object.keys(LINK_FILTERS))
|
|
616
|
+
|
|
516
617
|
/**
|
|
517
618
|
* Apply an ordered list of link filters. Pure: data in, data out.
|
|
518
619
|
* @param {Array} links Enriched link rows.
|
|
@@ -530,25 +631,7 @@ export function applyOrderedLinkFilters(links, filters, sources = []) {
|
|
|
530
631
|
const filter = raw && typeof raw === 'object' ? raw : null
|
|
531
632
|
if (!filter || filter.enabled === false) continue
|
|
532
633
|
const type = String(filter.type || '').trim()
|
|
533
|
-
if (type
|
|
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)
|
|
634
|
+
if (Object.prototype.hasOwnProperty.call(LINK_FILTERS, type)) rows = LINK_FILTERS[type](rows, filter)
|
|
552
635
|
else unsupported.push(type || '(missing type)')
|
|
553
636
|
}
|
|
554
637
|
|