@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,177 @@
1
+ // Path tracing: keep the links lying on a directed path from a "from" factor to a "to" factor
2
+ // within maxSteps. Pure (no window / DataService / DOM). The whole idea, including source tracing:
3
+ //
4
+ // - from only: keep everything reachable FORWARD from the "from" factors within maxSteps.
5
+ // - to only: keep everything that can reach the "to" factors within maxSteps (backward).
6
+ // - from + to: keep edges u->v where dist(from..u) + 1 + dist(v..to) <= maxSteps.
7
+ // - onlyIndirect: drop the direct 1-step from->to links.
8
+ // - traceThreads: the whole chain must stay inside one source; run the core per source and union.
9
+ //
10
+ // Replaces the old findPathFactors/findPathFactorsWithThreads/findPathEdges/bfs* cluster.
11
+ import { getLinkSourceIdsForTracing } from './link-traversal.js'
12
+
13
+ function checkTextMatch(text, term, mode) {
14
+ const t = String(text ?? '').toLowerCase()
15
+ const s = String(term ?? '').toLowerCase()
16
+ if (!s) return false
17
+ if (mode === 'anywhere') return t.includes(s)
18
+ if (mode === 'exact') return t === s
19
+ return t.startsWith(s) // 'start' and the webapp default
20
+ }
21
+
22
+ const asLabels = (v) => (Array.isArray(v) ? v : v ? [v] : [])
23
+ const hasText = (labels) => labels.some((l) => l && typeof l === 'string' && l.trim())
24
+ const cause = (l) => String(l?.cause ?? '').trim()
25
+ const effect = (l) => String(l?.effect ?? '').trim()
26
+
27
+ // Min steps from any seed following adjacency `adj` (Map factor -> Set(next)), capped at maxSteps.
28
+ function bfsDist(adj, seeds, maxSteps) {
29
+ const dist = new Map()
30
+ let frontier = []
31
+ for (const s of seeds) if (!dist.has(s)) { dist.set(s, 0); frontier.push(s) }
32
+ for (let d = 0; d < maxSteps && frontier.length; d++) {
33
+ const next = []
34
+ for (const node of frontier) {
35
+ for (const nb of adj.get(node) || []) {
36
+ if (!dist.has(nb)) { dist.set(nb, d + 1); next.push(nb) }
37
+ }
38
+ }
39
+ frontier = next
40
+ }
41
+ return dist
42
+ }
43
+
44
+ // Allowed "cause|||effect" edge keys over a given edge list (one source's edges, or all edges).
45
+ function allowedEdgeKeys(links, fromFactors, toFactors, maxSteps, onlyIndirect) {
46
+ const forward = new Map()
47
+ const backward = new Map()
48
+ for (const l of links) {
49
+ const c = cause(l), e = effect(l)
50
+ if (!c || !e) continue
51
+ if (!forward.has(c)) forward.set(c, new Set())
52
+ forward.get(c).add(e)
53
+ if (!backward.has(e)) backward.set(e, new Set())
54
+ backward.get(e).add(c)
55
+ }
56
+ const hasFrom = fromFactors.size > 0
57
+ const hasTo = toFactors.size > 0
58
+ const keep = new Set()
59
+
60
+ if (hasFrom && hasTo) {
61
+ // Bounded simple-path search: from each "from" factor, walk forward without revisiting a factor
62
+ // (no cycles within a path), and whenever a "to" factor is reached record every edge on that
63
+ // path. Matches the current behaviour on feedback loops and self-loops (walks that revisit a
64
+ // factor are not counted as paths).
65
+ const onPath = new Set()
66
+ const walk = (node, depth, pathEdges) => {
67
+ if (toFactors.has(node) && pathEdges.length > 0) {
68
+ if (!(onlyIndirect && pathEdges.length === 1)) for (const ek of pathEdges) keep.add(ek)
69
+ }
70
+ if (depth >= maxSteps) return
71
+ for (const nb of forward.get(node) || []) {
72
+ if (onPath.has(nb)) continue
73
+ onPath.add(nb)
74
+ pathEdges.push(`${node}|||${nb}`)
75
+ walk(nb, depth + 1, pathEdges)
76
+ pathEdges.pop()
77
+ onPath.delete(nb)
78
+ }
79
+ }
80
+ for (const f of fromFactors) {
81
+ onPath.clear()
82
+ onPath.add(f)
83
+ walk(f, 0, [])
84
+ }
85
+ } else if (hasFrom) {
86
+ const distFrom = bfsDist(forward, fromFactors, maxSteps)
87
+ for (const l of links) {
88
+ const c = cause(l), e = effect(l)
89
+ if (c && e && distFrom.get(c) !== undefined && distFrom.get(c) < maxSteps) keep.add(`${c}|||${e}`)
90
+ }
91
+ } else if (hasTo) {
92
+ const distTo = bfsDist(backward, toFactors, maxSteps)
93
+ for (const l of links) {
94
+ const c = cause(l), e = effect(l)
95
+ if (c && e && distTo.get(e) !== undefined && distTo.get(e) < maxSteps) keep.add(`${c}|||${e}`)
96
+ }
97
+ }
98
+ return keep
99
+ }
100
+
101
+ // Match the filter's from/to labels against the links to get the from/to factor sets.
102
+ // Exposed so the webapp can reuse the exact same matching for its highlight outputs.
103
+ export function matchPathFactors(links, filter) {
104
+ const fromLabels = asLabels(filter.fromLabels)
105
+ const toLabels = asLabels(filter.toLabels)
106
+ const hasFromLabels = hasText(fromLabels)
107
+ const hasToLabels = hasText(toLabels)
108
+ const mode = filter.matchMode
109
+ const fromFactors = new Set()
110
+ const toFactors = new Set()
111
+ for (const l of links) {
112
+ const c = l?.cause || ''
113
+ const e = l?.effect || ''
114
+ for (const label of fromLabels) {
115
+ if (!label || typeof label !== 'string' || !label.trim()) continue
116
+ if (checkTextMatch(c, label, mode)) fromFactors.add(c)
117
+ if (checkTextMatch(e, label, mode)) fromFactors.add(e)
118
+ }
119
+ for (const label of toLabels) {
120
+ if (!label || typeof label !== 'string' || !label.trim()) continue
121
+ if (checkTextMatch(c, label, mode)) toFactors.add(c)
122
+ if (checkTextMatch(e, label, mode)) toFactors.add(e)
123
+ }
124
+ }
125
+ return { fromFactors, toFactors, hasFromLabels, hasToLabels }
126
+ }
127
+
128
+ export function applyPathTracingFilter(links, filter, getSourceIds = getLinkSourceIdsForTracing) {
129
+ if (!filter) return links
130
+ const { fromFactors, toFactors, hasFromLabels, hasToLabels } = matchPathFactors(links, filter)
131
+ if (!hasFromLabels && !hasToLabels) return links // inactive
132
+ // Labels given but nothing matched: no paths from/to nothing.
133
+ if (hasFromLabels && fromFactors.size === 0) return []
134
+ if (hasToLabels && toFactors.size === 0) return []
135
+
136
+ return tracePaths(links, fromFactors, toFactors, {
137
+ maxSteps: filter.maxSteps,
138
+ onlyIndirect: filter.onlyIndirect,
139
+ traceThreads: filter.traceThreads,
140
+ getSourceIds,
141
+ })
142
+ }
143
+
144
+ /**
145
+ * Core path tracing over already-matched factor sets. Pure. The webapp passes its own matched
146
+ * fromFactors/toFactors (so it keeps its own highlight outputs) and uses this for the link result.
147
+ * @param {object} opts maxSteps, onlyIndirect, traceThreads, getSourceIds
148
+ */
149
+ export function tracePaths(links, fromFactors, toFactors, opts = {}) {
150
+ const maxStepsRaw = Number(opts.maxSteps)
151
+ const steps = Number.isFinite(maxStepsRaw) && maxStepsRaw > 0 ? maxStepsRaw : 3
152
+ const onlyIndirect = opts.onlyIndirect === true
153
+ const getSourceIds = opts.getSourceIds || getLinkSourceIdsForTracing
154
+
155
+ if (opts.traceThreads) {
156
+ // Same-source only: run the core on each source's sub-graph; keep a link if any of its
157
+ // source ids yields an allowed edge in that source's sub-graph.
158
+ const linksBySource = new Map()
159
+ for (const l of links) {
160
+ for (const sid of getSourceIds(l)) {
161
+ if (!linksBySource.has(sid)) linksBySource.set(sid, [])
162
+ linksBySource.get(sid).push(l)
163
+ }
164
+ }
165
+ const allowedBySource = new Map()
166
+ for (const [sid, sourceLinks] of linksBySource) {
167
+ allowedBySource.set(sid, allowedEdgeKeys(sourceLinks, fromFactors, toFactors, steps, onlyIndirect))
168
+ }
169
+ return links.filter((l) => {
170
+ const key = `${cause(l)}|||${effect(l)}`
171
+ return getSourceIds(l).some((sid) => allowedBySource.get(sid)?.has(key))
172
+ })
173
+ }
174
+
175
+ const allowed = allowedEdgeKeys(links, fromFactors, toFactors, steps, onlyIndirect)
176
+ return links.filter((l) => allowed.has(`${cause(l)}|||${effect(l)}`))
177
+ }