@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 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.0",
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
  },
@@ -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 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
- }
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 || '').trim() !== (link.effect || '').trim())
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 === '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)
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