@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.
- package/LICENSE.md +110 -0
- package/README.md +121 -0
- package/index.js +38 -0
- package/package.json +34 -0
- package/src/apply-filters.js +557 -0
- package/src/link-display-transforms.js +430 -0
- package/src/link-traversal.js +144 -0
- package/src/path-tracing.js +177 -0
|
@@ -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
|
+
}
|