@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.
@@ -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
- export function findLinksWithinSteps(links, targetFactors, stepsUp, stepsDown) {
23
- const validLinks = new Set()
24
- const linksByEffect = new Map()
25
- const linksByCause = new Map()
26
-
27
- for (const link of Array.isArray(links) ? links : []) {
28
- const cause = String(link?.cause || '').trim()
29
- const effect = String(link?.effect || '').trim()
30
- if (!cause || !effect) continue
31
-
32
- if (!linksByEffect.has(effect)) linksByEffect.set(effect, [])
33
- linksByEffect.get(effect).push(link)
34
-
35
- if (!linksByCause.has(cause)) linksByCause.set(cause, [])
36
- linksByCause.get(cause).push(link)
37
- }
38
-
39
- const up = normalizeTraversalSteps(stepsUp)
40
- const down = normalizeTraversalSteps(stepsDown)
41
-
42
- // Upstream means causes of the target factor.
43
- if (up > 0) {
44
- let currentFactors = new Set(targetFactors || [])
45
- for (let step = 0; step < up; step += 1) {
46
- const nextFactors = new Set()
47
- for (const factor of currentFactors) {
48
- for (const link of linksByEffect.get(factor) || []) {
49
- validLinks.add(link)
50
- const next = String(link?.cause || '').trim()
51
- if (next) nextFactors.add(next)
52
- }
53
- }
54
- currentFactors = nextFactors
55
- if (!currentFactors.size) break
56
- }
57
- }
58
-
59
- // Downstream means effects of the target factor.
60
- if (down > 0) {
61
- let currentFactors = new Set(targetFactors || [])
62
- for (let step = 0; step < down; step += 1) {
63
- const nextFactors = new Set()
64
- for (const factor of currentFactors) {
65
- for (const link of linksByCause.get(factor) || []) {
66
- validLinks.add(link)
67
- const next = String(link?.effect || '').trim()
68
- if (next) nextFactors.add(next)
69
- }
70
- }
71
- currentFactors = nextFactors
72
- if (!currentFactors.size) break
73
- }
74
- }
75
-
76
- return Array.from(validLinks)
77
- }
78
-
79
- export function findLinksWithinStepsWithThreads(links, targetFactors, stepsUp, stepsDown, getSourceIds = getLinkSourceIdsForTracing) {
80
- const validLinks = new Set()
81
- const linksByEffectAndSource = new Map()
82
- const linksByCauseAndSource = new Map()
83
-
84
- for (const link of Array.isArray(links) ? links : []) {
85
- const cause = String(link?.cause || '').trim()
86
- const effect = String(link?.effect || '').trim()
87
- const sourceIds = getSourceIds(link)
88
- if (!cause || !effect || sourceIds.length === 0) continue
89
-
90
- for (const sourceId of sourceIds) {
91
- if (!linksByEffectAndSource.has(effect)) linksByEffectAndSource.set(effect, new Map())
92
- if (!linksByEffectAndSource.get(effect).has(sourceId)) linksByEffectAndSource.get(effect).set(sourceId, [])
93
- linksByEffectAndSource.get(effect).get(sourceId).push(link)
94
-
95
- if (!linksByCauseAndSource.has(cause)) linksByCauseAndSource.set(cause, new Map())
96
- if (!linksByCauseAndSource.get(cause).has(sourceId)) linksByCauseAndSource.get(cause).set(sourceId, [])
97
- linksByCauseAndSource.get(cause).get(sourceId).push(link)
98
- }
99
- }
100
-
101
- const up = normalizeTraversalSteps(stepsUp)
102
- const down = normalizeTraversalSteps(stepsDown)
103
- const targets = new Set(targetFactors || [])
104
-
105
- if (up > 0) {
106
- for (const targetFactor of targets) {
107
- const sourceMap = linksByEffectAndSource.get(targetFactor)
108
- if (!sourceMap) continue
109
- for (const sourceId of sourceMap.keys()) {
110
- traverseSameSource(linksByEffectAndSource, targetFactor, sourceId, up, 'cause', validLinks)
111
- }
112
- }
113
- }
114
-
115
- if (down > 0) {
116
- for (const targetFactor of targets) {
117
- const sourceMap = linksByCauseAndSource.get(targetFactor)
118
- if (!sourceMap) continue
119
- for (const sourceId of sourceMap.keys()) {
120
- traverseSameSource(linksByCauseAndSource, targetFactor, sourceId, down, 'effect', validLinks)
121
- }
122
- }
123
- }
124
-
125
- return Array.from(validLinks)
126
- }
127
-
128
- function traverseSameSource(index, startFactor, sourceId, steps, nextField, validLinks) {
129
- let currentFactors = new Set([startFactor])
130
- for (let step = 0; step < steps; step += 1) {
131
- const nextFactors = new Set()
132
- for (const factor of currentFactors) {
133
- const factorSourceMap = index.get(factor)
134
- const stepLinks = factorSourceMap?.get(sourceId) || []
135
- for (const link of stepLinks) {
136
- validLinks.add(link)
137
- const next = String(link?.[nextField] || '').trim()
138
- if (next) nextFactors.add(next)
139
- }
140
- }
141
- currentFactors = nextFactors
142
- if (!currentFactors.size) break
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
+ }