@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/src/link-traversal.js
CHANGED
|
@@ -1,144 +1,167 @@
|
|
|
1
|
-
// Pure causal-link traversal shared by the UI filter pipeline and app API.
|
|
2
|
-
|
|
3
|
-
export function normalizeTraversalSteps(value, maxSteps = 8) {
|
|
4
|
-
const n = Number(value)
|
|
5
|
-
if (!Number.isFinite(n)) return 0
|
|
6
|
-
return Math.max(0, Math.min(maxSteps, Math.trunc(n)))
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export function getLinkSourceIdsForTracing(link) {
|
|
10
|
-
if (!link || typeof link !== 'object') return []
|
|
11
|
-
const assessedSourceIds = link.assessed === true && Array.isArray(link.metadata?.assessment_source_ids)
|
|
12
|
-
? link.metadata.assessment_source_ids
|
|
13
|
-
.map(sourceId => String(sourceId || '').trim())
|
|
14
|
-
.filter(Boolean)
|
|
15
|
-
: []
|
|
16
|
-
if (assessedSourceIds.length > 0) return Array.from(new Set(assessedSourceIds))
|
|
17
|
-
|
|
18
|
-
const sourceId = String(link.source_id || '').trim()
|
|
19
|
-
return sourceId ? [sourceId] : []
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
if (!
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
if (!
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
}
|
|
1
|
+
// Pure causal-link traversal shared by the UI filter pipeline and app API.
|
|
2
|
+
|
|
3
|
+
export function normalizeTraversalSteps(value, maxSteps = 8) {
|
|
4
|
+
const n = Number(value)
|
|
5
|
+
if (!Number.isFinite(n)) return 0
|
|
6
|
+
return Math.max(0, Math.min(maxSteps, Math.trunc(n)))
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function getLinkSourceIdsForTracing(link) {
|
|
10
|
+
if (!link || typeof link !== 'object') return []
|
|
11
|
+
const assessedSourceIds = link.assessed === true && Array.isArray(link.metadata?.assessment_source_ids)
|
|
12
|
+
? link.metadata.assessment_source_ids
|
|
13
|
+
.map(sourceId => String(sourceId || '').trim())
|
|
14
|
+
.filter(Boolean)
|
|
15
|
+
: []
|
|
16
|
+
if (assessedSourceIds.length > 0) return Array.from(new Set(assessedSourceIds))
|
|
17
|
+
|
|
18
|
+
const sourceId = String(link.source_id || '').trim()
|
|
19
|
+
return sourceId ? [sourceId] : []
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// A link's quote position as { start, end } (half-open char range), or null if it has no usable
|
|
23
|
+
// position yet. Degenerate "never set" offsets count as no position: missing/non-numeric start, a
|
|
24
|
+
// zero-length span, or the classic 0/0 default. When the end is absent but the start is real, the
|
|
25
|
+
// quote's text length supplies the end. Single source of truth for the Pathways filter, the offset
|
|
26
|
+
// recalc, and the auto-heal trigger.
|
|
27
|
+
export function quoteOffsetSpan(link) {
|
|
28
|
+
const start = Number(link?.text_start_offset)
|
|
29
|
+
if (!Number.isFinite(start)) return null
|
|
30
|
+
const rawEnd = Number(link?.text_end_offset)
|
|
31
|
+
const end = (Number.isFinite(rawEnd) && rawEnd > start) ? rawEnd : start + String(link?.selected_text ?? '').length
|
|
32
|
+
if (end <= start) return null
|
|
33
|
+
// 0/0 (or 0 with no real end) is the unset default, not a quote genuinely at character 0.
|
|
34
|
+
if (start === 0 && !(Number.isFinite(rawEnd) && rawEnd > 0)) return null
|
|
35
|
+
return { start, end }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// True when a link has a quote and source that COULD be positioned but currently has no usable offset.
|
|
39
|
+
export function linkNeedsQuoteOffset(link) {
|
|
40
|
+
if (!String(link?.selected_text ?? '').trim()) return false
|
|
41
|
+
if (!String(link?.source_id ?? '').trim()) return false
|
|
42
|
+
return quoteOffsetSpan(link) === null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function findLinksWithinSteps(links, targetFactors, stepsUp, stepsDown) {
|
|
46
|
+
const validLinks = new Set()
|
|
47
|
+
const linksByEffect = new Map()
|
|
48
|
+
const linksByCause = new Map()
|
|
49
|
+
|
|
50
|
+
for (const link of Array.isArray(links) ? links : []) {
|
|
51
|
+
const cause = String(link?.cause || '').trim()
|
|
52
|
+
const effect = String(link?.effect || '').trim()
|
|
53
|
+
if (!cause || !effect) continue
|
|
54
|
+
|
|
55
|
+
if (!linksByEffect.has(effect)) linksByEffect.set(effect, [])
|
|
56
|
+
linksByEffect.get(effect).push(link)
|
|
57
|
+
|
|
58
|
+
if (!linksByCause.has(cause)) linksByCause.set(cause, [])
|
|
59
|
+
linksByCause.get(cause).push(link)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const up = normalizeTraversalSteps(stepsUp)
|
|
63
|
+
const down = normalizeTraversalSteps(stepsDown)
|
|
64
|
+
|
|
65
|
+
// Upstream means causes of the target factor.
|
|
66
|
+
if (up > 0) {
|
|
67
|
+
let currentFactors = new Set(targetFactors || [])
|
|
68
|
+
for (let step = 0; step < up; step += 1) {
|
|
69
|
+
const nextFactors = new Set()
|
|
70
|
+
for (const factor of currentFactors) {
|
|
71
|
+
for (const link of linksByEffect.get(factor) || []) {
|
|
72
|
+
validLinks.add(link)
|
|
73
|
+
const next = String(link?.cause || '').trim()
|
|
74
|
+
if (next) nextFactors.add(next)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
currentFactors = nextFactors
|
|
78
|
+
if (!currentFactors.size) break
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Downstream means effects of the target factor.
|
|
83
|
+
if (down > 0) {
|
|
84
|
+
let currentFactors = new Set(targetFactors || [])
|
|
85
|
+
for (let step = 0; step < down; step += 1) {
|
|
86
|
+
const nextFactors = new Set()
|
|
87
|
+
for (const factor of currentFactors) {
|
|
88
|
+
for (const link of linksByCause.get(factor) || []) {
|
|
89
|
+
validLinks.add(link)
|
|
90
|
+
const next = String(link?.effect || '').trim()
|
|
91
|
+
if (next) nextFactors.add(next)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
currentFactors = nextFactors
|
|
95
|
+
if (!currentFactors.size) break
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return Array.from(validLinks)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function findLinksWithinStepsWithThreads(links, targetFactors, stepsUp, stepsDown, getSourceIds = getLinkSourceIdsForTracing) {
|
|
103
|
+
const validLinks = new Set()
|
|
104
|
+
const linksByEffectAndSource = new Map()
|
|
105
|
+
const linksByCauseAndSource = new Map()
|
|
106
|
+
|
|
107
|
+
for (const link of Array.isArray(links) ? links : []) {
|
|
108
|
+
const cause = String(link?.cause || '').trim()
|
|
109
|
+
const effect = String(link?.effect || '').trim()
|
|
110
|
+
const sourceIds = getSourceIds(link)
|
|
111
|
+
if (!cause || !effect || sourceIds.length === 0) continue
|
|
112
|
+
|
|
113
|
+
for (const sourceId of sourceIds) {
|
|
114
|
+
if (!linksByEffectAndSource.has(effect)) linksByEffectAndSource.set(effect, new Map())
|
|
115
|
+
if (!linksByEffectAndSource.get(effect).has(sourceId)) linksByEffectAndSource.get(effect).set(sourceId, [])
|
|
116
|
+
linksByEffectAndSource.get(effect).get(sourceId).push(link)
|
|
117
|
+
|
|
118
|
+
if (!linksByCauseAndSource.has(cause)) linksByCauseAndSource.set(cause, new Map())
|
|
119
|
+
if (!linksByCauseAndSource.get(cause).has(sourceId)) linksByCauseAndSource.get(cause).set(sourceId, [])
|
|
120
|
+
linksByCauseAndSource.get(cause).get(sourceId).push(link)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const up = normalizeTraversalSteps(stepsUp)
|
|
125
|
+
const down = normalizeTraversalSteps(stepsDown)
|
|
126
|
+
const targets = new Set(targetFactors || [])
|
|
127
|
+
|
|
128
|
+
if (up > 0) {
|
|
129
|
+
for (const targetFactor of targets) {
|
|
130
|
+
const sourceMap = linksByEffectAndSource.get(targetFactor)
|
|
131
|
+
if (!sourceMap) continue
|
|
132
|
+
for (const sourceId of sourceMap.keys()) {
|
|
133
|
+
traverseSameSource(linksByEffectAndSource, targetFactor, sourceId, up, 'cause', validLinks)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (down > 0) {
|
|
139
|
+
for (const targetFactor of targets) {
|
|
140
|
+
const sourceMap = linksByCauseAndSource.get(targetFactor)
|
|
141
|
+
if (!sourceMap) continue
|
|
142
|
+
for (const sourceId of sourceMap.keys()) {
|
|
143
|
+
traverseSameSource(linksByCauseAndSource, targetFactor, sourceId, down, 'effect', validLinks)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return Array.from(validLinks)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function traverseSameSource(index, startFactor, sourceId, steps, nextField, validLinks) {
|
|
152
|
+
let currentFactors = new Set([startFactor])
|
|
153
|
+
for (let step = 0; step < steps; step += 1) {
|
|
154
|
+
const nextFactors = new Set()
|
|
155
|
+
for (const factor of currentFactors) {
|
|
156
|
+
const factorSourceMap = index.get(factor)
|
|
157
|
+
const stepLinks = factorSourceMap?.get(sourceId) || []
|
|
158
|
+
for (const link of stepLinks) {
|
|
159
|
+
validLinks.add(link)
|
|
160
|
+
const next = String(link?.[nextField] || '').trim()
|
|
161
|
+
if (next) nextFactors.add(next)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
currentFactors = nextFactors
|
|
165
|
+
if (!currentFactors.size) break
|
|
166
|
+
}
|
|
167
|
+
}
|