Brianferrell787 commited on
Commit
f8768df
·
0 Parent(s):

Publish-ready RANGE snapshot

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +2 -0
  2. CHANGELOG.md +56 -0
  3. CITATION.cff +15 -0
  4. Cargo.lock +1017 -0
  5. Cargo.toml +26 -0
  6. DEPLOYMENT.md +202 -0
  7. LICENSE +21 -0
  8. PACKAGE_README.md +350 -0
  9. README.md +154 -0
  10. SPACE_BUNDLE_MANIFEST.json +79 -0
  11. app.py +1172 -0
  12. docs/attention2alpha-compatibility-review.md +46 -0
  13. docs/performance.md +83 -0
  14. docs/readme-research.md +65 -0
  15. docs/release.md +81 -0
  16. docs/taxonomy.md +194 -0
  17. docs/technical-signoff.md +53 -0
  18. examples.py +55 -0
  19. examples/app.py +86 -0
  20. examples/benchmark_hf.py +195 -0
  21. examples/custom_taxonomy.py +43 -0
  22. examples/quickstart.py +26 -0
  23. pyproject.toml +84 -0
  24. requirements.txt +4 -0
  25. rust/core.rs +1372 -0
  26. rust/generated/python_whitespace.bin +3 -0
  27. rust/generated/unicode_index.bin +3 -0
  28. rust/generated/unicode_meta.txt +1 -0
  29. rust/generated/unicode_pool.bin +3 -0
  30. rust/lib.rs +321 -0
  31. src/range_news/__init__.py +64 -0
  32. src/range_news/_core/__init__.py +1 -0
  33. src/range_news/_core/accelerated/__init__.py +1 -0
  34. src/range_news/_core/accelerated/compact.py +583 -0
  35. src/range_news/_core/accelerated/indicator.py +384 -0
  36. src/range_news/_core/catalogs.py +470 -0
  37. src/range_news/_core/catalogs/entities.yml +186 -0
  38. src/range_news/_core/catalogs/lexical.yml +744 -0
  39. src/range_news/_core/config.py +293 -0
  40. src/range_news/_core/configs/default.yml +126 -0
  41. src/range_news/_core/indicator.py +1417 -0
  42. src/range_news/_core/models.py +418 -0
  43. src/range_news/_taxonomy_impl.py +629 -0
  44. src/range_news/api.py +371 -0
  45. src/range_news/backends.py +146 -0
  46. src/range_news/customization.py +73 -0
  47. src/range_news/models.py +52 -0
  48. src/range_news/py.typed +0 -0
  49. vendor/range_news/__init__.py +64 -0
  50. vendor/range_news/_core/__init__.py +1 -0
.gitattributes ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ *.bin filter=lfs diff=lfs merge=lfs -text
2
+ *.so filter=lfs diff=lfs merge=lfs -text
CHANGELOG.md ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Changelog
2
+
3
+ All notable package changes are recorded here. The package follows semantic
4
+ versioning and uses Python's pre-release version syntax.
5
+
6
+ ## 0.1.0a1 — release candidate, not published
7
+
8
+ ### Added
9
+
10
+ - The public `range_news` Python namespace and **RANGE** product name: Rapid Analysis
11
+ of News for the General Economy.
12
+ - An exact CPU/Rust path for rich predictions, compact list screening, and Arrow screening.
13
+ - A conservative, offline general-economy taxonomy covering monetary policy, inflation,
14
+ labor markets, growth, rates, credit, fiscal policy, trade, supply disruptions,
15
+ financial stability, regulation, sectors, company events, and broad market benchmarks.
16
+ - Deterministic evidence lineage, effective dates, taxonomy fingerprints, explanations,
17
+ ranking, and editable taxonomy copies.
18
+ - Wheel and source-distribution verification on Windows and Linux CI.
19
+ - Runnable README examples with visible output.
20
+ - A Space-ready Gradio demo under `app/` with highlighted evidence, evidence JSON,
21
+ batch screening, downloadable outputs, named API endpoints, a multi-stage Docker
22
+ build, and a deterministic offline export manifest.
23
+ - Separate deployment exports for local Docker and free Gradio/ZeroGPU hosting. The
24
+ hosted export vendors the runtime payload from an inspected CPython 3.10
25
+ manylinux2014 wheel while keeping prediction on the compiled CPU/Rust path.
26
+ - CI validation and a Linux container smoke test for the future Hugging Face Space.
27
+
28
+ ### Changed
29
+
30
+ - The project is now named **RANGE** throughout its distribution, Python namespace,
31
+ native module, documentation, deployment identifiers, and demo.
32
+ - The README now leads with the job-to-be-done, a working result, intended users,
33
+ an architecture overview, a mechanism-level speed explanation, API selection
34
+ guidance, and carefully qualified benchmark context.
35
+ - The Space demo now separates article relevance from supported exposure, distinguishes
36
+ unresolved evidence paths visually, and includes a verified private-deployment
37
+ runbook.
38
+ - The public package identity, examples, and documentation are no longer tied to the
39
+ Attention2Alpha project or a particular leveraged-ETF portfolio. This presentation
40
+ pass leaves the established default taxonomy semantics unchanged.
41
+ - The package version is `0.1.0a1`, explicitly identifying a research alpha.
42
+ - The hosted demo now rejects oversized or malformed requests before inference,
43
+ shares one bounded execution queue across its public endpoints, and hides internal
44
+ error details in production.
45
+ - Evidence downloads now omit screened article text, retain a SHA-256 identifier and text
46
+ lengths, live in Gradio's managed cache, and become eligible for best-effort cleanup
47
+ after five minutes.
48
+ - Hosted dependencies, Docker base images, and the Dockerfile frontend are pinned for
49
+ reproducible deployment builds. Managed networks can supply a trusted build root as
50
+ a temporary secret without disabling TLS verification.
51
+
52
+ ### Not included
53
+
54
+ - No GPU backend.
55
+ - No public or production hosted service, model download, live-data integration,
56
+ trading recommendation, release tag, or package-index publication.
CITATION.cff ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ cff-version: 1.2.0
2
+ message: "Please cite an archived release when this software supports published work."
3
+ title: "RANGE: Rapid Analysis of News for the General Economy"
4
+ type: software
5
+ version: "0.1.0a1"
6
+ authors:
7
+ - family-names: Ferrell
8
+ given-names: Brian
9
+ license: MIT
10
+ keywords:
11
+ - financial news
12
+ - economic news
13
+ - information retrieval
14
+ - taxonomy
15
+ - reproducible research
Cargo.lock ADDED
@@ -0,0 +1,1017 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # This file is automatically @generated by Cargo.
2
+ # It is not intended for manual editing.
3
+ version = 4
4
+
5
+ [[package]]
6
+ name = "ahash"
7
+ version = "0.8.12"
8
+ source = "registry+https://github.com/rust-lang/crates.io-index"
9
+ checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
10
+ dependencies = [
11
+ "cfg-if",
12
+ "const-random",
13
+ "getrandom 0.3.4",
14
+ "once_cell",
15
+ "version_check",
16
+ "zerocopy",
17
+ ]
18
+
19
+ [[package]]
20
+ name = "aho-corasick"
21
+ version = "1.1.5"
22
+ source = "registry+https://github.com/rust-lang/crates.io-index"
23
+ checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
24
+ dependencies = [
25
+ "memchr",
26
+ ]
27
+
28
+ [[package]]
29
+ name = "android_system_properties"
30
+ version = "0.1.6"
31
+ source = "registry+https://github.com/rust-lang/crates.io-index"
32
+ checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
33
+ dependencies = [
34
+ "libc",
35
+ ]
36
+
37
+ [[package]]
38
+ name = "arrow-array"
39
+ version = "59.3.0"
40
+ source = "registry+https://github.com/rust-lang/crates.io-index"
41
+ checksum = "1e5f6adeffdf587d7a31db5d2266189624b526730cd3627f9ff9fedae97ad584"
42
+ dependencies = [
43
+ "ahash",
44
+ "arrow-buffer",
45
+ "arrow-data",
46
+ "arrow-schema",
47
+ "chrono",
48
+ "chrono-tz",
49
+ "half",
50
+ "hashbrown",
51
+ "libc",
52
+ "num-complex",
53
+ "num-integer",
54
+ "num-traits",
55
+ ]
56
+
57
+ [[package]]
58
+ name = "arrow-buffer"
59
+ version = "59.3.0"
60
+ source = "registry+https://github.com/rust-lang/crates.io-index"
61
+ checksum = "097d193003ce7995d5d087089069ec2a6e0187faf5a6f8c9f38af2645d987182"
62
+ dependencies = [
63
+ "bytes",
64
+ "half",
65
+ "num-bigint",
66
+ "num-traits",
67
+ ]
68
+
69
+ [[package]]
70
+ name = "arrow-cast"
71
+ version = "59.3.0"
72
+ source = "registry+https://github.com/rust-lang/crates.io-index"
73
+ checksum = "635c9c635668ad26adf76cce8fb276c4be7cf06e63bd516de7da514f9680ee53"
74
+ dependencies = [
75
+ "arrow-array",
76
+ "arrow-buffer",
77
+ "arrow-data",
78
+ "arrow-ord",
79
+ "arrow-schema",
80
+ "arrow-select",
81
+ "atoi",
82
+ "base64",
83
+ "chrono",
84
+ "comfy-table",
85
+ "half",
86
+ "lexical-core",
87
+ "num-traits",
88
+ "ryu",
89
+ ]
90
+
91
+ [[package]]
92
+ name = "arrow-data"
93
+ version = "59.3.0"
94
+ source = "registry+https://github.com/rust-lang/crates.io-index"
95
+ checksum = "9ba2f832eaeca24b8f26143dba750e42ee4ab51cf7d65e701ca9607cfda9f358"
96
+ dependencies = [
97
+ "arrow-buffer",
98
+ "arrow-schema",
99
+ "half",
100
+ "num-integer",
101
+ "num-traits",
102
+ ]
103
+
104
+ [[package]]
105
+ name = "arrow-ord"
106
+ version = "59.3.0"
107
+ source = "registry+https://github.com/rust-lang/crates.io-index"
108
+ checksum = "2c900759f3bd8354fd4196bc4403eee846894dc2adf66b4225472006a0bf18c5"
109
+ dependencies = [
110
+ "arrow-array",
111
+ "arrow-buffer",
112
+ "arrow-data",
113
+ "arrow-schema",
114
+ "arrow-select",
115
+ ]
116
+
117
+ [[package]]
118
+ name = "arrow-schema"
119
+ version = "59.3.0"
120
+ source = "registry+https://github.com/rust-lang/crates.io-index"
121
+ checksum = "10fab8d4563491417ba801fab29d205104d20d4bdf37bda6cd1cf425cff598cd"
122
+ dependencies = [
123
+ "bitflags",
124
+ ]
125
+
126
+ [[package]]
127
+ name = "arrow-select"
128
+ version = "59.3.0"
129
+ source = "registry+https://github.com/rust-lang/crates.io-index"
130
+ checksum = "fc58569193c2525915f3cc6310edba3792f1200f65d6e9ed330aa33e691493b8"
131
+ dependencies = [
132
+ "ahash",
133
+ "arrow-array",
134
+ "arrow-buffer",
135
+ "arrow-data",
136
+ "arrow-schema",
137
+ "num-traits",
138
+ ]
139
+
140
+ [[package]]
141
+ name = "atoi"
142
+ version = "2.0.0"
143
+ source = "registry+https://github.com/rust-lang/crates.io-index"
144
+ checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528"
145
+ dependencies = [
146
+ "num-traits",
147
+ ]
148
+
149
+ [[package]]
150
+ name = "autocfg"
151
+ version = "1.5.1"
152
+ source = "registry+https://github.com/rust-lang/crates.io-index"
153
+ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
154
+
155
+ [[package]]
156
+ name = "base64"
157
+ version = "0.23.1"
158
+ source = "registry+https://github.com/rust-lang/crates.io-index"
159
+ checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
160
+
161
+ [[package]]
162
+ name = "bitflags"
163
+ version = "2.13.1"
164
+ source = "registry+https://github.com/rust-lang/crates.io-index"
165
+ checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
166
+
167
+ [[package]]
168
+ name = "bumpalo"
169
+ version = "3.20.3"
170
+ source = "registry+https://github.com/rust-lang/crates.io-index"
171
+ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
172
+
173
+ [[package]]
174
+ name = "bytes"
175
+ version = "1.12.1"
176
+ source = "registry+https://github.com/rust-lang/crates.io-index"
177
+ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
178
+
179
+ [[package]]
180
+ name = "cc"
181
+ version = "1.4.4"
182
+ source = "registry+https://github.com/rust-lang/crates.io-index"
183
+ checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273"
184
+ dependencies = [
185
+ "find-msvc-tools",
186
+ "shlex",
187
+ ]
188
+
189
+ [[package]]
190
+ name = "cfg-if"
191
+ version = "1.0.4"
192
+ source = "registry+https://github.com/rust-lang/crates.io-index"
193
+ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
194
+
195
+ [[package]]
196
+ name = "chrono"
197
+ version = "0.4.45"
198
+ source = "registry+https://github.com/rust-lang/crates.io-index"
199
+ checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
200
+ dependencies = [
201
+ "iana-time-zone",
202
+ "js-sys",
203
+ "num-traits",
204
+ "wasm-bindgen",
205
+ "windows-link",
206
+ ]
207
+
208
+ [[package]]
209
+ name = "chrono-tz"
210
+ version = "0.10.4"
211
+ source = "registry+https://github.com/rust-lang/crates.io-index"
212
+ checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
213
+ dependencies = [
214
+ "chrono",
215
+ "phf",
216
+ ]
217
+
218
+ [[package]]
219
+ name = "comfy-table"
220
+ version = "7.2.2"
221
+ source = "registry+https://github.com/rust-lang/crates.io-index"
222
+ checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47"
223
+ dependencies = [
224
+ "unicode-segmentation",
225
+ "unicode-width",
226
+ ]
227
+
228
+ [[package]]
229
+ name = "const-random"
230
+ version = "0.1.18"
231
+ source = "registry+https://github.com/rust-lang/crates.io-index"
232
+ checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
233
+ dependencies = [
234
+ "const-random-macro",
235
+ ]
236
+
237
+ [[package]]
238
+ name = "const-random-macro"
239
+ version = "0.1.16"
240
+ source = "registry+https://github.com/rust-lang/crates.io-index"
241
+ checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
242
+ dependencies = [
243
+ "getrandom 0.2.17",
244
+ "once_cell",
245
+ "tiny-keccak",
246
+ ]
247
+
248
+ [[package]]
249
+ name = "core-foundation-sys"
250
+ version = "0.8.7"
251
+ source = "registry+https://github.com/rust-lang/crates.io-index"
252
+ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
253
+
254
+ [[package]]
255
+ name = "crossbeam-deque"
256
+ version = "0.8.7"
257
+ source = "registry+https://github.com/rust-lang/crates.io-index"
258
+ checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb"
259
+ dependencies = [
260
+ "crossbeam-epoch",
261
+ "crossbeam-utils",
262
+ ]
263
+
264
+ [[package]]
265
+ name = "crossbeam-epoch"
266
+ version = "0.9.20"
267
+ source = "registry+https://github.com/rust-lang/crates.io-index"
268
+ checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
269
+ dependencies = [
270
+ "crossbeam-utils",
271
+ ]
272
+
273
+ [[package]]
274
+ name = "crossbeam-utils"
275
+ version = "0.8.22"
276
+ source = "registry+https://github.com/rust-lang/crates.io-index"
277
+ checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
278
+
279
+ [[package]]
280
+ name = "crunchy"
281
+ version = "0.2.4"
282
+ source = "registry+https://github.com/rust-lang/crates.io-index"
283
+ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
284
+
285
+ [[package]]
286
+ name = "either"
287
+ version = "1.18.0"
288
+ source = "registry+https://github.com/rust-lang/crates.io-index"
289
+ checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
290
+
291
+ [[package]]
292
+ name = "equivalent"
293
+ version = "1.0.2"
294
+ source = "registry+https://github.com/rust-lang/crates.io-index"
295
+ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
296
+
297
+ [[package]]
298
+ name = "find-msvc-tools"
299
+ version = "0.1.11"
300
+ source = "registry+https://github.com/rust-lang/crates.io-index"
301
+ checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
302
+
303
+ [[package]]
304
+ name = "futures-core"
305
+ version = "0.3.34"
306
+ source = "registry+https://github.com/rust-lang/crates.io-index"
307
+ checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
308
+
309
+ [[package]]
310
+ name = "futures-task"
311
+ version = "0.3.34"
312
+ source = "registry+https://github.com/rust-lang/crates.io-index"
313
+ checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
314
+
315
+ [[package]]
316
+ name = "futures-util"
317
+ version = "0.3.34"
318
+ source = "registry+https://github.com/rust-lang/crates.io-index"
319
+ checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
320
+ dependencies = [
321
+ "futures-core",
322
+ "futures-task",
323
+ "pin-project-lite",
324
+ "slab",
325
+ ]
326
+
327
+ [[package]]
328
+ name = "getrandom"
329
+ version = "0.2.17"
330
+ source = "registry+https://github.com/rust-lang/crates.io-index"
331
+ checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
332
+ dependencies = [
333
+ "cfg-if",
334
+ "libc",
335
+ "wasi",
336
+ ]
337
+
338
+ [[package]]
339
+ name = "getrandom"
340
+ version = "0.3.4"
341
+ source = "registry+https://github.com/rust-lang/crates.io-index"
342
+ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
343
+ dependencies = [
344
+ "cfg-if",
345
+ "libc",
346
+ "r-efi",
347
+ "wasip2",
348
+ ]
349
+
350
+ [[package]]
351
+ name = "half"
352
+ version = "2.7.1"
353
+ source = "registry+https://github.com/rust-lang/crates.io-index"
354
+ checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
355
+ dependencies = [
356
+ "cfg-if",
357
+ "crunchy",
358
+ "num-traits",
359
+ "zerocopy",
360
+ ]
361
+
362
+ [[package]]
363
+ name = "hashbrown"
364
+ version = "0.17.1"
365
+ source = "registry+https://github.com/rust-lang/crates.io-index"
366
+ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
367
+
368
+ [[package]]
369
+ name = "heck"
370
+ version = "0.5.0"
371
+ source = "registry+https://github.com/rust-lang/crates.io-index"
372
+ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
373
+
374
+ [[package]]
375
+ name = "iana-time-zone"
376
+ version = "0.1.65"
377
+ source = "registry+https://github.com/rust-lang/crates.io-index"
378
+ checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
379
+ dependencies = [
380
+ "android_system_properties",
381
+ "core-foundation-sys",
382
+ "iana-time-zone-haiku",
383
+ "js-sys",
384
+ "log",
385
+ "wasm-bindgen",
386
+ "windows-core",
387
+ ]
388
+
389
+ [[package]]
390
+ name = "iana-time-zone-haiku"
391
+ version = "0.1.2"
392
+ source = "registry+https://github.com/rust-lang/crates.io-index"
393
+ checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
394
+ dependencies = [
395
+ "cc",
396
+ ]
397
+
398
+ [[package]]
399
+ name = "indexmap"
400
+ version = "2.14.1"
401
+ source = "registry+https://github.com/rust-lang/crates.io-index"
402
+ checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb"
403
+ dependencies = [
404
+ "equivalent",
405
+ "hashbrown",
406
+ ]
407
+
408
+ [[package]]
409
+ name = "js-sys"
410
+ version = "0.3.104"
411
+ source = "registry+https://github.com/rust-lang/crates.io-index"
412
+ checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
413
+ dependencies = [
414
+ "cfg-if",
415
+ "futures-util",
416
+ "wasm-bindgen",
417
+ ]
418
+
419
+ [[package]]
420
+ name = "lexical-core"
421
+ version = "1.0.6"
422
+ source = "registry+https://github.com/rust-lang/crates.io-index"
423
+ checksum = "7d8d125a277f807e55a77304455eb7b1cb52f2b18c143b60e766c120bd64a594"
424
+ dependencies = [
425
+ "lexical-parse-float",
426
+ "lexical-parse-integer",
427
+ "lexical-util",
428
+ "lexical-write-float",
429
+ "lexical-write-integer",
430
+ ]
431
+
432
+ [[package]]
433
+ name = "lexical-parse-float"
434
+ version = "1.0.6"
435
+ source = "registry+https://github.com/rust-lang/crates.io-index"
436
+ checksum = "52a9f232fbd6f550bc0137dcb5f99ab674071ac2d690ac69704593cb4abbea56"
437
+ dependencies = [
438
+ "lexical-parse-integer",
439
+ "lexical-util",
440
+ ]
441
+
442
+ [[package]]
443
+ name = "lexical-parse-integer"
444
+ version = "1.0.6"
445
+ source = "registry+https://github.com/rust-lang/crates.io-index"
446
+ checksum = "9a7a039f8fb9c19c996cd7b2fcce303c1b2874fe1aca544edc85c4a5f8489b34"
447
+ dependencies = [
448
+ "lexical-util",
449
+ ]
450
+
451
+ [[package]]
452
+ name = "lexical-util"
453
+ version = "1.0.7"
454
+ source = "registry+https://github.com/rust-lang/crates.io-index"
455
+ checksum = "2604dd126bb14f13fb5d1bd6a66155079cb9fa655b37f875b3a742c705dbed17"
456
+
457
+ [[package]]
458
+ name = "lexical-write-float"
459
+ version = "1.0.6"
460
+ source = "registry+https://github.com/rust-lang/crates.io-index"
461
+ checksum = "50c438c87c013188d415fbabbb1dceb44249ab81664efbd31b14ae55dabb6361"
462
+ dependencies = [
463
+ "lexical-util",
464
+ "lexical-write-integer",
465
+ ]
466
+
467
+ [[package]]
468
+ name = "lexical-write-integer"
469
+ version = "1.0.6"
470
+ source = "registry+https://github.com/rust-lang/crates.io-index"
471
+ checksum = "409851a618475d2d5796377cad353802345cba92c867d9fbcde9cf4eac4e14df"
472
+ dependencies = [
473
+ "lexical-util",
474
+ ]
475
+
476
+ [[package]]
477
+ name = "libc"
478
+ version = "0.2.189"
479
+ source = "registry+https://github.com/rust-lang/crates.io-index"
480
+ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
481
+
482
+ [[package]]
483
+ name = "libm"
484
+ version = "0.2.16"
485
+ source = "registry+https://github.com/rust-lang/crates.io-index"
486
+ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
487
+
488
+ [[package]]
489
+ name = "log"
490
+ version = "0.4.34"
491
+ source = "registry+https://github.com/rust-lang/crates.io-index"
492
+ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
493
+
494
+ [[package]]
495
+ name = "matrixmultiply"
496
+ version = "0.3.11"
497
+ source = "registry+https://github.com/rust-lang/crates.io-index"
498
+ checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7"
499
+ dependencies = [
500
+ "autocfg",
501
+ "rawpointer",
502
+ ]
503
+
504
+ [[package]]
505
+ name = "memchr"
506
+ version = "2.8.3"
507
+ source = "registry+https://github.com/rust-lang/crates.io-index"
508
+ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
509
+
510
+ [[package]]
511
+ name = "ndarray"
512
+ version = "0.17.2"
513
+ source = "registry+https://github.com/rust-lang/crates.io-index"
514
+ checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d"
515
+ dependencies = [
516
+ "matrixmultiply",
517
+ "num-complex",
518
+ "num-integer",
519
+ "num-traits",
520
+ "portable-atomic",
521
+ "portable-atomic-util",
522
+ "rawpointer",
523
+ ]
524
+
525
+ [[package]]
526
+ name = "num-bigint"
527
+ version = "0.5.1"
528
+ source = "registry+https://github.com/rust-lang/crates.io-index"
529
+ checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0"
530
+ dependencies = [
531
+ "num-integer",
532
+ "num-traits",
533
+ ]
534
+
535
+ [[package]]
536
+ name = "num-complex"
537
+ version = "0.4.6"
538
+ source = "registry+https://github.com/rust-lang/crates.io-index"
539
+ checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
540
+ dependencies = [
541
+ "num-traits",
542
+ ]
543
+
544
+ [[package]]
545
+ name = "num-integer"
546
+ version = "0.1.47"
547
+ source = "registry+https://github.com/rust-lang/crates.io-index"
548
+ checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
549
+ dependencies = [
550
+ "num-traits",
551
+ ]
552
+
553
+ [[package]]
554
+ name = "num-traits"
555
+ version = "0.2.19"
556
+ source = "registry+https://github.com/rust-lang/crates.io-index"
557
+ checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
558
+ dependencies = [
559
+ "autocfg",
560
+ "libm",
561
+ ]
562
+
563
+ [[package]]
564
+ name = "numpy"
565
+ version = "0.29.0"
566
+ source = "registry+https://github.com/rust-lang/crates.io-index"
567
+ checksum = "6a5b15d63a5ff39e378daed0e1340d3a5964703ea9712eb09a0dc66fade996f4"
568
+ dependencies = [
569
+ "half",
570
+ "libc",
571
+ "ndarray",
572
+ "num-complex",
573
+ "num-integer",
574
+ "num-traits",
575
+ "pyo3",
576
+ "pyo3-build-config",
577
+ "rustc-hash",
578
+ ]
579
+
580
+ [[package]]
581
+ name = "once_cell"
582
+ version = "1.21.4"
583
+ source = "registry+https://github.com/rust-lang/crates.io-index"
584
+ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
585
+
586
+ [[package]]
587
+ name = "phf"
588
+ version = "0.12.1"
589
+ source = "registry+https://github.com/rust-lang/crates.io-index"
590
+ checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"
591
+ dependencies = [
592
+ "phf_shared",
593
+ ]
594
+
595
+ [[package]]
596
+ name = "phf_shared"
597
+ version = "0.12.1"
598
+ source = "registry+https://github.com/rust-lang/crates.io-index"
599
+ checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
600
+ dependencies = [
601
+ "siphasher",
602
+ ]
603
+
604
+ [[package]]
605
+ name = "pin-project-lite"
606
+ version = "0.2.17"
607
+ source = "registry+https://github.com/rust-lang/crates.io-index"
608
+ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
609
+
610
+ [[package]]
611
+ name = "portable-atomic"
612
+ version = "1.15.0"
613
+ source = "registry+https://github.com/rust-lang/crates.io-index"
614
+ checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
615
+
616
+ [[package]]
617
+ name = "portable-atomic-util"
618
+ version = "0.2.7"
619
+ source = "registry+https://github.com/rust-lang/crates.io-index"
620
+ checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
621
+ dependencies = [
622
+ "portable-atomic",
623
+ ]
624
+
625
+ [[package]]
626
+ name = "proc-macro2"
627
+ version = "1.0.107"
628
+ source = "registry+https://github.com/rust-lang/crates.io-index"
629
+ checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
630
+ dependencies = [
631
+ "unicode-ident",
632
+ ]
633
+
634
+ [[package]]
635
+ name = "pyo3"
636
+ version = "0.29.2"
637
+ source = "registry+https://github.com/rust-lang/crates.io-index"
638
+ checksum = "4688ddedf473e32662b9b067670129a8afb8c18e351482c70d62ba4a88171e8b"
639
+ dependencies = [
640
+ "chrono",
641
+ "chrono-tz",
642
+ "indexmap",
643
+ "libc",
644
+ "once_cell",
645
+ "portable-atomic",
646
+ "pyo3-build-config",
647
+ "pyo3-ffi",
648
+ "pyo3-macros",
649
+ ]
650
+
651
+ [[package]]
652
+ name = "pyo3-arrow"
653
+ version = "0.19.0"
654
+ source = "registry+https://github.com/rust-lang/crates.io-index"
655
+ checksum = "3d5ddf226a2dbf7607570d0657c2bf6fbe299208368b2914f3dd7e7ba0b57688"
656
+ dependencies = [
657
+ "arrow-array",
658
+ "arrow-buffer",
659
+ "arrow-cast",
660
+ "arrow-data",
661
+ "arrow-schema",
662
+ "arrow-select",
663
+ "chrono",
664
+ "chrono-tz",
665
+ "half",
666
+ "indexmap",
667
+ "numpy",
668
+ "pyo3",
669
+ "thiserror",
670
+ ]
671
+
672
+ [[package]]
673
+ name = "pyo3-build-config"
674
+ version = "0.29.2"
675
+ source = "registry+https://github.com/rust-lang/crates.io-index"
676
+ checksum = "f41027e41b4bd03f6e60f9f417fe24a6341a6bb744edd62b6f709f2a52ea30e9"
677
+ dependencies = [
678
+ "target-lexicon",
679
+ ]
680
+
681
+ [[package]]
682
+ name = "pyo3-ffi"
683
+ version = "0.29.2"
684
+ source = "registry+https://github.com/rust-lang/crates.io-index"
685
+ checksum = "e591a95526fead067432c3b3a33fc74770b87b1e04e73671090d9c2055a2b327"
686
+ dependencies = [
687
+ "libc",
688
+ "pyo3-build-config",
689
+ ]
690
+
691
+ [[package]]
692
+ name = "pyo3-macros"
693
+ version = "0.29.2"
694
+ source = "registry+https://github.com/rust-lang/crates.io-index"
695
+ checksum = "73225868fc1cd84eef2c3c230ddb91273bf1de46aeb8a4248da76d32a0924a1c"
696
+ dependencies = [
697
+ "proc-macro2",
698
+ "pyo3-macros-backend",
699
+ "quote",
700
+ "syn",
701
+ ]
702
+
703
+ [[package]]
704
+ name = "pyo3-macros-backend"
705
+ version = "0.29.2"
706
+ source = "registry+https://github.com/rust-lang/crates.io-index"
707
+ checksum = "571575aa3749fa6216757dd47d2a3e7ef360f329a40f0666a9fbd14889024952"
708
+ dependencies = [
709
+ "heck",
710
+ "proc-macro2",
711
+ "quote",
712
+ "syn",
713
+ ]
714
+
715
+ [[package]]
716
+ name = "quote"
717
+ version = "1.0.47"
718
+ source = "registry+https://github.com/rust-lang/crates.io-index"
719
+ checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
720
+ dependencies = [
721
+ "proc-macro2",
722
+ ]
723
+
724
+ [[package]]
725
+ name = "r-efi"
726
+ version = "5.3.0"
727
+ source = "registry+https://github.com/rust-lang/crates.io-index"
728
+ checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
729
+
730
+ [[package]]
731
+ name = "range_news_native"
732
+ version = "0.1.0"
733
+ dependencies = [
734
+ "aho-corasick",
735
+ "arrow-array",
736
+ "arrow-schema",
737
+ "pyo3",
738
+ "pyo3-arrow",
739
+ "rayon",
740
+ ]
741
+
742
+ [[package]]
743
+ name = "rawpointer"
744
+ version = "0.2.1"
745
+ source = "registry+https://github.com/rust-lang/crates.io-index"
746
+ checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
747
+
748
+ [[package]]
749
+ name = "rayon"
750
+ version = "1.12.0"
751
+ source = "registry+https://github.com/rust-lang/crates.io-index"
752
+ checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
753
+ dependencies = [
754
+ "either",
755
+ "rayon-core",
756
+ ]
757
+
758
+ [[package]]
759
+ name = "rayon-core"
760
+ version = "1.13.0"
761
+ source = "registry+https://github.com/rust-lang/crates.io-index"
762
+ checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
763
+ dependencies = [
764
+ "crossbeam-deque",
765
+ "crossbeam-utils",
766
+ ]
767
+
768
+ [[package]]
769
+ name = "rustc-hash"
770
+ version = "2.1.3"
771
+ source = "registry+https://github.com/rust-lang/crates.io-index"
772
+ checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
773
+
774
+ [[package]]
775
+ name = "rustversion"
776
+ version = "1.0.23"
777
+ source = "registry+https://github.com/rust-lang/crates.io-index"
778
+ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
779
+
780
+ [[package]]
781
+ name = "ryu"
782
+ version = "1.0.23"
783
+ source = "registry+https://github.com/rust-lang/crates.io-index"
784
+ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
785
+
786
+ [[package]]
787
+ name = "shlex"
788
+ version = "2.0.1"
789
+ source = "registry+https://github.com/rust-lang/crates.io-index"
790
+ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
791
+
792
+ [[package]]
793
+ name = "siphasher"
794
+ version = "1.0.3"
795
+ source = "registry+https://github.com/rust-lang/crates.io-index"
796
+ checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
797
+
798
+ [[package]]
799
+ name = "slab"
800
+ version = "0.4.12"
801
+ source = "registry+https://github.com/rust-lang/crates.io-index"
802
+ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
803
+
804
+ [[package]]
805
+ name = "syn"
806
+ version = "2.0.119"
807
+ source = "registry+https://github.com/rust-lang/crates.io-index"
808
+ checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
809
+ dependencies = [
810
+ "proc-macro2",
811
+ "quote",
812
+ "unicode-ident",
813
+ ]
814
+
815
+ [[package]]
816
+ name = "target-lexicon"
817
+ version = "0.13.5"
818
+ source = "registry+https://github.com/rust-lang/crates.io-index"
819
+ checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
820
+
821
+ [[package]]
822
+ name = "thiserror"
823
+ version = "1.0.69"
824
+ source = "registry+https://github.com/rust-lang/crates.io-index"
825
+ checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
826
+ dependencies = [
827
+ "thiserror-impl",
828
+ ]
829
+
830
+ [[package]]
831
+ name = "thiserror-impl"
832
+ version = "1.0.69"
833
+ source = "registry+https://github.com/rust-lang/crates.io-index"
834
+ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
835
+ dependencies = [
836
+ "proc-macro2",
837
+ "quote",
838
+ "syn",
839
+ ]
840
+
841
+ [[package]]
842
+ name = "tiny-keccak"
843
+ version = "2.0.2"
844
+ source = "registry+https://github.com/rust-lang/crates.io-index"
845
+ checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
846
+ dependencies = [
847
+ "crunchy",
848
+ ]
849
+
850
+ [[package]]
851
+ name = "unicode-ident"
852
+ version = "1.0.24"
853
+ source = "registry+https://github.com/rust-lang/crates.io-index"
854
+ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
855
+
856
+ [[package]]
857
+ name = "unicode-segmentation"
858
+ version = "1.13.3"
859
+ source = "registry+https://github.com/rust-lang/crates.io-index"
860
+ checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
861
+
862
+ [[package]]
863
+ name = "unicode-width"
864
+ version = "0.2.2"
865
+ source = "registry+https://github.com/rust-lang/crates.io-index"
866
+ checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
867
+
868
+ [[package]]
869
+ name = "version_check"
870
+ version = "0.9.5"
871
+ source = "registry+https://github.com/rust-lang/crates.io-index"
872
+ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
873
+
874
+ [[package]]
875
+ name = "wasi"
876
+ version = "0.11.1+wasi-snapshot-preview1"
877
+ source = "registry+https://github.com/rust-lang/crates.io-index"
878
+ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
879
+
880
+ [[package]]
881
+ name = "wasip2"
882
+ version = "1.0.4+wasi-0.2.12"
883
+ source = "registry+https://github.com/rust-lang/crates.io-index"
884
+ checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
885
+ dependencies = [
886
+ "wit-bindgen",
887
+ ]
888
+
889
+ [[package]]
890
+ name = "wasm-bindgen"
891
+ version = "0.2.127"
892
+ source = "registry+https://github.com/rust-lang/crates.io-index"
893
+ checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
894
+ dependencies = [
895
+ "cfg-if",
896
+ "once_cell",
897
+ "rustversion",
898
+ "wasm-bindgen-macro",
899
+ "wasm-bindgen-shared",
900
+ ]
901
+
902
+ [[package]]
903
+ name = "wasm-bindgen-macro"
904
+ version = "0.2.127"
905
+ source = "registry+https://github.com/rust-lang/crates.io-index"
906
+ checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
907
+ dependencies = [
908
+ "quote",
909
+ "wasm-bindgen-macro-support",
910
+ ]
911
+
912
+ [[package]]
913
+ name = "wasm-bindgen-macro-support"
914
+ version = "0.2.127"
915
+ source = "registry+https://github.com/rust-lang/crates.io-index"
916
+ checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
917
+ dependencies = [
918
+ "bumpalo",
919
+ "proc-macro2",
920
+ "quote",
921
+ "syn",
922
+ "wasm-bindgen-shared",
923
+ ]
924
+
925
+ [[package]]
926
+ name = "wasm-bindgen-shared"
927
+ version = "0.2.127"
928
+ source = "registry+https://github.com/rust-lang/crates.io-index"
929
+ checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
930
+ dependencies = [
931
+ "unicode-ident",
932
+ ]
933
+
934
+ [[package]]
935
+ name = "windows-core"
936
+ version = "0.62.2"
937
+ source = "registry+https://github.com/rust-lang/crates.io-index"
938
+ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
939
+ dependencies = [
940
+ "windows-implement",
941
+ "windows-interface",
942
+ "windows-link",
943
+ "windows-result",
944
+ "windows-strings",
945
+ ]
946
+
947
+ [[package]]
948
+ name = "windows-implement"
949
+ version = "0.60.2"
950
+ source = "registry+https://github.com/rust-lang/crates.io-index"
951
+ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
952
+ dependencies = [
953
+ "proc-macro2",
954
+ "quote",
955
+ "syn",
956
+ ]
957
+
958
+ [[package]]
959
+ name = "windows-interface"
960
+ version = "0.59.3"
961
+ source = "registry+https://github.com/rust-lang/crates.io-index"
962
+ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
963
+ dependencies = [
964
+ "proc-macro2",
965
+ "quote",
966
+ "syn",
967
+ ]
968
+
969
+ [[package]]
970
+ name = "windows-link"
971
+ version = "0.2.1"
972
+ source = "registry+https://github.com/rust-lang/crates.io-index"
973
+ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
974
+
975
+ [[package]]
976
+ name = "windows-result"
977
+ version = "0.4.1"
978
+ source = "registry+https://github.com/rust-lang/crates.io-index"
979
+ checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
980
+ dependencies = [
981
+ "windows-link",
982
+ ]
983
+
984
+ [[package]]
985
+ name = "windows-strings"
986
+ version = "0.5.1"
987
+ source = "registry+https://github.com/rust-lang/crates.io-index"
988
+ checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
989
+ dependencies = [
990
+ "windows-link",
991
+ ]
992
+
993
+ [[package]]
994
+ name = "wit-bindgen"
995
+ version = "0.57.1"
996
+ source = "registry+https://github.com/rust-lang/crates.io-index"
997
+ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
998
+
999
+ [[package]]
1000
+ name = "zerocopy"
1001
+ version = "0.8.56"
1002
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1003
+ checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
1004
+ dependencies = [
1005
+ "zerocopy-derive",
1006
+ ]
1007
+
1008
+ [[package]]
1009
+ name = "zerocopy-derive"
1010
+ version = "0.8.56"
1011
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1012
+ checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
1013
+ dependencies = [
1014
+ "proc-macro2",
1015
+ "quote",
1016
+ "syn",
1017
+ ]
Cargo.toml ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [package]
2
+ name = "range_news_native"
3
+ version = "0.1.0"
4
+ edition = "2021"
5
+ rust-version = "1.88"
6
+ publish = false
7
+ license = "MIT"
8
+ description = "Native engine for RANGE financial-news relevance screening"
9
+
10
+ [lib]
11
+ name = "_native"
12
+ path = "rust/lib.rs"
13
+ crate-type = ["cdylib"]
14
+
15
+ [dependencies]
16
+ aho-corasick = "1.1.5"
17
+ arrow-array = "59"
18
+ arrow-schema = "59"
19
+ pyo3 = { version = "0.29.2", features = ["extension-module"] }
20
+ pyo3-arrow = { version = "0.19.0", default-features = false }
21
+ rayon = "1.12"
22
+
23
+ [profile.release]
24
+ lto = "fat"
25
+ codegen-units = 1
26
+ strip = "symbols"
DEPLOYMENT.md ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Run RANGE locally or deploy it free on Hugging Face
2
+
3
+ RANGE supports two deployments from the same Gradio app:
4
+
5
+ | Route | Best for | Runtime |
6
+ | --- | --- | --- |
7
+ | Local Docker | Private local use and a portable container | Docker with the compiled CPU/Rust engine |
8
+ | Private Hugging Face Space | A shareable demo without a paid plan | Gradio on the free ZeroGPU runtime, with the same CPU/Rust engine |
9
+
10
+ The Hugging Face route does **not** turn RANGE into a GPU application. Its real
11
+ `/analyze` and `/batch` endpoints remain on CPU and never request a GPU allocation.
12
+ The app contains one unused `@spaces.GPU` function solely because the free ZeroGPU
13
+ runtime requires a decorated function.
14
+
15
+ Free ZeroGPU hosting is currently available to eligible personal accounts in good
16
+ standing, with a verified email, an account older than 30 days, and no more than two
17
+ hosted ZeroGPU Spaces. Hugging Face can change these limits. See the
18
+ [ZeroGPU documentation](https://huggingface.co/docs/hub/spaces-zerogpu) before a new
19
+ deployment.
20
+
21
+ ## Prepare the repository
22
+
23
+ Run these commands from `dataset/news_relevance/range_news` on the `codex` branch.
24
+ The existing project environment is sufficient for validation:
25
+
26
+ ```powershell
27
+ conda activate attention2alpha
28
+ python app/validate_space.py
29
+ python app/export_space.py `
30
+ --target docker `
31
+ --output build/huggingface-space `
32
+ --force
33
+ ```
34
+
35
+ The exporter writes only below `build/` and replaces a directory only when its
36
+ manifest proves that it is an earlier RANGE export.
37
+
38
+ ## Route A: run the Docker app locally
39
+
40
+ ```powershell
41
+ docker build `
42
+ --tag range-news-space:0.1.0a1 `
43
+ build/huggingface-space
44
+
45
+ docker run --rm --name range-news-space `
46
+ --publish 127.0.0.1:7860:7860 `
47
+ range-news-space:0.1.0a1
48
+ ```
49
+
50
+ Open `http://127.0.0.1:7860`. Stop the container with `Ctrl+C`.
51
+
52
+ The Docker bundle is self-contained source: it builds the Rust extension, installs
53
+ the package and Gradio, and runs as an unprivileged user. It does not need a Hugging
54
+ Face account or network access for inference.
55
+
56
+ If downloads fail with an `UnknownIssuer` certificate error on a managed network,
57
+ export that network's trusted root certificate as a PEM file and add
58
+ `--secret "id=build_ca,src=C:\path\to\trusted-root.pem"` to the Docker build command.
59
+ The certificate is available only while dependencies download and is not copied into
60
+ the image. Do not disable TLS verification or add a trusted-host exception.
61
+
62
+ ## Route B: build the free Hugging Face bundle
63
+
64
+ Hugging Face does not compile the Rust extension for this route. Export the portable
65
+ CPython 3.10 manylinux2014 wheel from the Docker build first:
66
+
67
+ ```powershell
68
+ docker build `
69
+ --platform linux/amd64 `
70
+ --target wheel-export `
71
+ --output "type=local,dest=build/manylinux2014-wheel" `
72
+ build/huggingface-space
73
+
74
+ $Wheels = @(Get-ChildItem `
75
+ -LiteralPath build/manylinux2014-wheel `
76
+ -Filter "range_news-0.1.0a1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl")
77
+
78
+ if ($Wheels.Count -ne 1) {
79
+ throw "Expected exactly one portable RANGE x86_64 wheel."
80
+ }
81
+ $Wheel = $Wheels[0]
82
+
83
+ python app/export_space.py `
84
+ --target zerogpu `
85
+ --wheel $Wheel.FullName `
86
+ --output build/huggingface-zerogpu-space `
87
+ --force
88
+ ```
89
+
90
+ The second export contains Gradio Space metadata, the app, package documentation and
91
+ source, and a runtime copy extracted from the validated wheel. Gradio Spaces install
92
+ `requirements.txt` before copying repository files, so a relative wheel reference
93
+ cannot work during their build. Vendoring the inspected wheel payload keeps the build
94
+ self-contained without publishing the package and makes the native extension available
95
+ after the source copy. The requirements file installs RANGE's small Python
96
+ dependencies; the platform supplies Gradio, `spaces`, and `huggingface_hub`.
97
+
98
+ Before uploading, inspect `build/huggingface-zerogpu-space`. Do not add credentials,
99
+ private dataset rows, licensed articles, caches, or machine-specific paths. A public
100
+ Space exposes this bundle as a public repository, including the app source, bundled
101
+ RANGE source and native runtime, package documentation, and default taxonomy. Review
102
+ the uploaded revision history as well as the latest files before changing visibility.
103
+
104
+ ## Create and upload the private Space
105
+
106
+ Use a small deployment environment to keep Hub tooling separate from the research
107
+ environment. Create it once; skip the first command when it already exists:
108
+
109
+ ```powershell
110
+ conda create --name range-news-hf-deploy --yes python=3.10
111
+ conda activate range-news-hf-deploy
112
+ python -m pip install `
113
+ "huggingface_hub>=1.16,<2" `
114
+ "gradio-client==2.6.1" `
115
+ "pip-system-certs>=5,<6"
116
+ hf auth login
117
+ hf auth whoami --format json
118
+ ```
119
+
120
+ `pip-system-certs` lets the Python CLI use the Windows certificate store, including
121
+ institution-managed certificate authorities.
122
+
123
+ Create the private Gradio Space on the free ZeroGPU hardware and upload the prepared
124
+ directory:
125
+
126
+ ```powershell
127
+ $SpaceId = "Brianferrell787/range-news"
128
+
129
+ hf repos create $SpaceId `
130
+ --type space `
131
+ --sdk gradio `
132
+ --flavor zero-a10g `
133
+ --private
134
+
135
+ hf upload $SpaceId build/huggingface-zerogpu-space . `
136
+ --repo-type space `
137
+ --commit-message "Deploy private RANGE 0.1.0a1 demo"
138
+ ```
139
+
140
+ Do not run the upload if creation fails. For an existing Space, first verify that its
141
+ repository is private, its SDK is Gradio, and its hardware is ZeroGPU.
142
+
143
+ ## Verify the live deployment
144
+
145
+ ```powershell
146
+ hf spaces wait $SpaceId --timeout 30m
147
+ hf spaces logs $SpaceId --build -n 200
148
+ hf spaces logs $SpaceId -n 200
149
+ hf spaces info $SpaceId --expand sdk,runtime --format json
150
+ ```
151
+
152
+ Open `https://huggingface.co/spaces/Brianferrell787/range-news` and test all three
153
+ single-article examples plus the batch example. The Space can sleep when idle, so the
154
+ first request may include a cold start.
155
+
156
+ Private API calls use the token already stored by `hf auth login`:
157
+
158
+ ```python
159
+ from gradio_client import Client
160
+ from huggingface_hub import get_token
161
+
162
+ client = Client("Brianferrell787/range-news", token=get_token())
163
+ client.view_api()
164
+ ```
165
+
166
+ The generated `agents.md` points coding agents to the API schema, which lists the
167
+ named `/analyze` and `/batch` endpoints.
168
+
169
+ ## Privacy and performance boundaries
170
+
171
+ - Do not add an HF token as a Space secret; RANGE needs no runtime credential.
172
+ - Text submitted to the hosted demo is processed on Hugging Face infrastructure. Do
173
+ not submit confidential or unlicensed material.
174
+ - The interface and named API endpoints reject more than 100 rows, more than 20,000
175
+ characters or 80,000 UTF-8 bytes in one article, and more than 200,000 characters
176
+ or 800,000 UTF-8 bytes across a batch. Use the package's compact or Arrow API for
177
+ large datasets. Batch rows must have exactly four columns. Publication timestamps
178
+ are capped at 128 characters, while article IDs and sources are capped at 256
179
+ characters each. Metadata across the batch is capped at 64,000 characters and
180
+ 256,000 UTF-8 bytes, and combined article and metadata cell content is capped at
181
+ 264,000 characters and 1,056,000 UTF-8 bytes. These caps do not count JSON framing
182
+ or Gradio's transport envelope. A separate 4 MiB server limit covers the complete
183
+ HTTP request body before JSON parsing.
184
+ - Downloaded evidence identifies the screened article by text length and SHA-256 hash
185
+ instead of copying the screened text. RANGE removes leading and trailing whitespace
186
+ before screening. Download files become eligible for best-effort
187
+ deletion after five minutes, cleanup runs once a minute, and a Space restart may
188
+ remove them sooner. They are not durable or private storage.
189
+ - Production responses hide internal error details. Use the Space logs to diagnose a
190
+ failure; do not expose a traceback or exception text in the browser.
191
+ - Package benchmark rates describe the native batch engine on the recorded hardware,
192
+ not the latency or throughput of this shared two-core hosted demo.
193
+ - A `relevant` result means the configured financial and economic policy accepted the
194
+ article. It is not sentiment, materiality, a return forecast, or investment advice.
195
+
196
+ ## Planned Python 3.12 migration
197
+
198
+ The current alpha bundle uses CPython 3.10. Python 3.12 support is a planned follow-up,
199
+ not part of this deployment. Because RANGE includes a compiled Rust extension, that
200
+ migration must update and test the package metadata, build image, native wheel, Space
201
+ runtime, exported bundle, and clean-install checks together. Do not change only the
202
+ Space metadata or try to load the existing `cp310` wheel in Python 3.12.
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Brian Ferrell
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
PACKAGE_README.md ADDED
@@ -0,0 +1,350 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # RANGE
2
+
3
+ **Rapid Analysis of News for the General Economy**
4
+
5
+ RANGE screens timestamped financial news for economic and broad-market relevance
6
+ at high throughput while preserving the evidence trail behind every result.
7
+
8
+ The Python distribution is `range-news` and the import namespace is `range_news`.
9
+
10
+ ![Status](https://img.shields.io/badge/status-research%20alpha-orange)
11
+ ![Python](https://img.shields.io/badge/python-3.10-blue)
12
+ ![Engine](https://img.shields.io/badge/engine-Rust-dea584)
13
+ ![License](https://img.shields.io/badge/license-MIT-green)
14
+
15
+ RANGE answers three practical questions for each article:
16
+
17
+ 1. **Does the article pass the configured financial and economic relevance screen?**
18
+ 2. **Which configured broad-market exposures does that evidence reach?**
19
+ 3. **What exact text, rule, identity, and relationship produced the result?**
20
+
21
+ It runs on CPU, makes no network request during inference, and does not depend on an embedding model, an external API, or an LLM. The `0.1.0a1` source and CI artifacts are prepared for review but are **not published to PyPI**.
22
+
23
+ ## See it in 30 seconds
24
+
25
+ Install from this checkout:
26
+
27
+ ```bash
28
+ cd dataset/news_relevance/range_news
29
+ python -m pip install .
30
+ ```
31
+
32
+ Then screen one article:
33
+
34
+ ```python
35
+ from range_news import RelevanceEngine
36
+
37
+ engine = RelevanceEngine(workers=1)
38
+
39
+ decision = engine.predict(
40
+ (
41
+ "CPI inflation cooled after the Federal Reserve cut the federal funds rate. "
42
+ "The S&P 500 and Nasdaq-100 advanced."
43
+ ),
44
+ timestamp="2025-01-15T10:30:00-05:00",
45
+ article_id="economy-example",
46
+ source="example",
47
+ )
48
+
49
+ print("status:", decision.status)
50
+ print("accepted:", decision.accepted)
51
+ print("markets:", ", ".join(decision.relevant_to))
52
+ ```
53
+
54
+ Output:
55
+
56
+ ```text
57
+ status: relevant
58
+ accepted: True
59
+ markets: S&P 500, Nasdaq-100, Broad market
60
+ ```
61
+
62
+ The concise summary is JSON-ready:
63
+
64
+ ```python
65
+ print(decision.summary())
66
+ ```
67
+
68
+ The full explanation is generated from the evidence already recorded — inference is not run twice:
69
+
70
+ ```python
71
+ print(engine.explain(decision, detail="audit"))
72
+ ```
73
+
74
+ [`examples/quickstart.py`](examples/quickstart.py) is the executable version checked by CI.
75
+
76
+ ## Why use RANGE?
77
+
78
+ | Need | What RANGE provides |
79
+ | --- | --- |
80
+ | Filter large news collections | Compact list and Arrow paths return only rows with findings. |
81
+ | Explain every result | Literal spans, offsets, rules, catalog IDs, context checks, and relationship lineage. |
82
+ | Reproduce a screen later | Timestamp-aware identities plus a deterministic taxonomy fingerprint. |
83
+ | Keep data local | CPU execution with no inference-time network call. |
84
+ | Separate relevance from prediction | No sentiment score, return forecast, or trade recommendation is invented. |
85
+
86
+ Typical users include quantitative researchers, financial-data engineers, news-intelligence teams, risk and surveillance analysts, and anyone building a traceable first-stage filter before a more expensive model or human review.
87
+
88
+ ## How it works
89
+
90
+ ```mermaid
91
+ flowchart LR
92
+ A[Timestamped article] --> B[Exact normalization]
93
+ B --> C[Compiled phrase and identifier matching]
94
+ C --> D[Context and effective-date checks]
95
+ D --> E[Identity and relationship resolution]
96
+ E --> F[Article relevance result]
97
+ E --> G[Exposure results]
98
+ F --> H[Exact matched text and rule path]
99
+ G --> H
100
+ ```
101
+
102
+ The default taxonomy is deliberately general rather than tied to a portfolio. It covers monetary policy, inflation, employment, growth, rates, credit, fiscal policy, trade, disruptions, financial stability, regulation, sectors, company events, and broad U.S. benchmarks. Application-specific security or portfolio mappings can be added downstream without changing the meaning of the core taxonomy.
103
+
104
+ ## Why it is fast
105
+
106
+ RANGE was optimized as a screening engine rather than a general language model:
107
+
108
+ - **Compiled multi-pattern matching.** Rust Aho-Corasick automata replace Python loops over individual phrases and identifiers.
109
+ - **Parallel CPU execution.** Rayon distributes independent articles across a bounded worker pool.
110
+ - **Reused worker memory.** Per-worker scratch buffers are cleared and reused instead of being rebuilt for every article.
111
+ - **A fast common path.** Wholly ASCII rows use a specialized normalization path; frozen Unicode lookup tables preserve the exact fallback behavior for other text.
112
+ - **Sparse results.** Compact screening returns only rows that differ from the canonical no-match result.
113
+ - **Arrow-native input.** `screen_arrow()` avoids constructing one Python object for every input row and keeps conversion back to Python outside the hot path.
114
+
115
+ The result is low-overhead, predictable CPU screening with a rich Python path available whenever full evidence objects are needed.
116
+
117
+ ### Research benchmark context
118
+
119
+ In our fastest recorded CPU test, RANGE screened **457,735 articles/second in a 500,000-article run** and **395,017 articles/second in a 10,000,000-article run**. We measured this with a specially optimized RANGE build on one Intel i7-10750H Windows laptop using 10 CPU workers. These are the fastest recorded results for the screening step alone. They do not include file loading or the rest of a data pipeline, and performance varies with hardware, article length, and screening rules.
120
+
121
+ ## Choose the right API
122
+
123
+ | API | Best for | Result shape |
124
+ | --- | --- | --- |
125
+ | `predict()` | One article that needs a complete explanation | Rich immutable decision |
126
+ | `predict_batch()` | A smaller batch where every row needs rich evidence | Tuple of rich decisions |
127
+ | `screen_batch()` | High-throughput Python sequences | Sparse compact batch |
128
+ | `screen_arrow()` | Columnar datasets and large pipelines | Sparse Arrow facts |
129
+ | `rank()` | Deterministic prioritization within an exposure and time bucket | Ranked rich decisions |
130
+
131
+ ## What a rich decision retains
132
+
133
+ A `predict()` result contains:
134
+
135
+ - article ID, source, immutable text, and interpreted timestamp;
136
+ - every literal matched span and its source offsets;
137
+ - detection method, rule ID, catalog entry, and catalog version;
138
+ - context acceptance or collision rejection;
139
+ - effective-dated identity resolutions;
140
+ - relationship lineage from an entity to an exposure;
141
+ - accepted, unresolved, and rejected evidence paths; and
142
+ - article-level and exposure-level decisions.
143
+
144
+ The compact summary stays small:
145
+
146
+ ```python
147
+ prediction = engine.predict(
148
+ "Initial jobless claims rose as Treasury yields fell.",
149
+ timestamp="2025-01-16T08:30:00-05:00",
150
+ article_id="macro-2",
151
+ )
152
+
153
+ print(prediction.summary())
154
+ print(prediction.why)
155
+ ```
156
+
157
+ Use Mermaid when a visual evidence flow is more useful:
158
+
159
+ ```python
160
+ mermaid_source = engine.explain(prediction, format="mermaid", detail="audit")
161
+ ```
162
+
163
+ ## Batch screening
164
+
165
+ Use the compact path when most rows are expected to have no configured finding:
166
+
167
+ ```python
168
+ screened = engine.screen_batch(
169
+ [
170
+ "Ordinary local weather with no financial terms.",
171
+ "The unemployment rate rose while retail sales weakened.",
172
+ ],
173
+ timestamps=[
174
+ "2025-01-15T10:00:00-05:00",
175
+ "2025-01-15T10:01:00-05:00",
176
+ ],
177
+ article_ids=["local-1", "economy-1"],
178
+ )
179
+
180
+ print("rows:", screened.count)
181
+ print("rows with findings:", screened.exception_count)
182
+ print("implicit no-match rows:", screened.implicit_zero_count)
183
+ ```
184
+
185
+ ```text
186
+ rows: 2
187
+ rows with findings: 1
188
+ implicit no-match rows: 1
189
+ ```
190
+
191
+ Here, an “exception” means a row that differs from the canonical no-match result; it is not a processing error.
192
+
193
+ ## Arrow screening
194
+
195
+ Install the Arrow extra:
196
+
197
+ ```bash
198
+ python -m pip install ".[arrow]"
199
+ ```
200
+
201
+ Then screen columnar data without materializing one Python input object per row:
202
+
203
+ ```python
204
+ from datetime import datetime, timezone
205
+
206
+ import pyarrow as pa
207
+ from range_news import RelevanceEngine
208
+
209
+ batch = pa.record_batch(
210
+ [
211
+ pa.array([
212
+ "Ordinary local weather.",
213
+ "The Federal Reserve announced a rate cut.",
214
+ ]),
215
+ pa.array(
216
+ [
217
+ datetime(2025, 1, 15, 15, 0, tzinfo=timezone.utc),
218
+ datetime(2025, 1, 15, 15, 1, tzinfo=timezone.utc),
219
+ ],
220
+ type=pa.timestamp("us", tz="UTC"),
221
+ ),
222
+ ],
223
+ names=["text", "timestamp"],
224
+ )
225
+
226
+ facts = RelevanceEngine().screen_arrow(batch)
227
+ print("rows:", facts.row_count)
228
+ print("rows with findings:", facts.exception_count)
229
+ sparse_record_batch = facts.to_pyarrow()
230
+ ```
231
+
232
+ ## Default taxonomy
233
+
234
+ | Area | Representative evidence |
235
+ | --- | --- |
236
+ | Monetary policy | central-bank decisions, policy rates, quantitative easing or tightening |
237
+ | Inflation | CPI, PCE, PPI, inflation expectations |
238
+ | Labor markets | payrolls, unemployment, claims, openings, participation, wages |
239
+ | Growth and demand | GDP, retail sales, production, surveys, confidence, housing, orders |
240
+ | Financial conditions | Treasury yields, yield curve, credit spreads, lending standards, dollar |
241
+ | Fiscal and trade policy | deficits, debt limit, shutdowns, tariffs, export controls |
242
+ | Disruptions and stability | supply chains, banking stress, systemic risk, sanctions, volatility |
243
+ | Regulation | broad securities rulemaking and banking-capital policy |
244
+ | Financial context | sectors and company events accepted at article level without implying a broad exposure |
245
+ | Broad benchmarks | S&P 500, Nasdaq-100, Russell 2000, plus SPY, QQQ, and IWM aliases |
246
+
247
+ General economic rules may support `Broad market`. Explicit benchmark evidence may support its configured broad exposure. Company-event or sector evidence can be accepted as financial context without being converted into an unsupported security-level or broad-market exposure; article relevance and supported exposure remain separate outputs.
248
+
249
+ ## Customize the taxonomy
250
+
251
+ Copy the bundled configuration and catalogs to editable YAML:
252
+
253
+ ```python
254
+ from range_news import copy_default_taxonomy
255
+
256
+ config_path = copy_default_taxonomy("my_taxonomy")
257
+ print(config_path)
258
+ ```
259
+
260
+ Or add an application-owned rule in Python:
261
+
262
+ ```python
263
+ from range_news import DefaultTaxonomy, RelevanceEngine
264
+
265
+
266
+ def add_regional_indicator(builder):
267
+ builder.add_lexical_rule(
268
+ rule_id="regional-manufacturing-survey",
269
+ entity_concept="macroeconomic_indicator",
270
+ tag_type="macroeconomic",
271
+ phrases=("regional manufacturing survey",),
272
+ market_wide_authorized=True,
273
+ source_id="my-research-project",
274
+ )
275
+
276
+
277
+ taxonomy = DefaultTaxonomy().extend(add_regional_indicator)
278
+ custom_engine = RelevanceEngine(taxonomy=taxonomy)
279
+ print(custom_engine.taxonomy.fingerprint)
280
+ ```
281
+
282
+ Each changed taxonomy receives a new deterministic fingerprint. See [the taxonomy guide](docs/taxonomy.md) for the complete extension model.
283
+
284
+ ## Space-ready demo
285
+
286
+ A polished Gradio demonstration is staged in [`app/`](README.md). It includes:
287
+
288
+ - single-article analysis with highlighted evidence;
289
+ - a structured evidence table and evidence JSON;
290
+ - Mermaid evidence-flow source for developer-owned rendering;
291
+ - a capped batch-screening tab with downloadable JSONL;
292
+ - named Gradio API endpoints for people and agents; and
293
+ - two supported deployment routes from the same app: local Docker and a free private
294
+ Gradio/ZeroGPU-compatible Space whose predictions still run on CPU.
295
+
296
+ The hosted demo applies the same size limits to browser and direct API requests.
297
+ Generated evidence downloads omit the screened article text, retain its length and
298
+ SHA-256 hash, and become eligible for best-effort deletion after five minutes. See the
299
+ deployment guide before making a Space public: public visibility exposes the app,
300
+ bundled package source and native runtime, documentation, and default taxonomy.
301
+
302
+ Run it locally:
303
+
304
+ ```bash
305
+ python -m pip install ".[demo]"
306
+ python app/app.py
307
+ ```
308
+
309
+ Validate and export a standalone Space bundle without uploading anything:
310
+
311
+ ```bash
312
+ python app/validate_space.py
313
+ python app/export_space.py --target docker --output build/huggingface-space --force
314
+ ```
315
+
316
+ See [`app/DEPLOYMENT.md`](DEPLOYMENT.md) for the local Docker workflow and the
317
+ complete free private Hugging Face workflow, including its portable Linux wheel.
318
+
319
+ ## Scope and limitations
320
+
321
+ RANGE is a traceable screening layer, not an open-ended semantic model. It intentionally does **not**:
322
+
323
+ - infer sentiment, causality, magnitude, or expected return;
324
+ - recommend a trade or decide whether news is bullish or bearish;
325
+ - fuzzy-correct misspellings or paraphrases outside the configured taxonomy;
326
+ - provide a complete security master or constituent history;
327
+ - update its taxonomy automatically; or
328
+ - guarantee that every economically important article contains a configured phrase.
329
+
330
+ Unmapped but plausible evidence should remain reviewable or be handled by an explicit custom taxonomy rather than silently becoming a security-level claim.
331
+
332
+ ## Package status
333
+
334
+ - Distribution: `range-news`
335
+ - Import: `range_news`
336
+ - Version: `0.1.0a1`
337
+ - Runtime: CPython 3.10
338
+ - Shipping backend: CPU/Rust
339
+ - License: MIT
340
+ - Publication: disabled in this pull request
341
+
342
+ A coordinated move to CPython 3.12 is planned after this alpha. Because the backend is
343
+ a native extension, the package metadata, build images, wheel, hosted runtime, and
344
+ clean-install tests must migrate together.
345
+
346
+ The dedicated package workflow builds and checks wheel and source artifacts on Windows and Linux, installs both into clean environments, runs the public contracts and README example outside the repository, verifies taxonomy and Unicode assets, validates the Space scaffold, and retains review artifacts. It contains no package-index credential or publication step.
347
+
348
+ See [the release-candidate contract](docs/release.md), [technical sign-off](docs/technical-signoff.md), and [changelog](CHANGELOG.md).
349
+
350
+ This software is for research and information-screening workflows. It does not provide investment advice.
README.md ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: RANGE
3
+ emoji: 🔎
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: gradio
7
+ sdk_version: "6.26.0"
8
+ app_file: app.py
9
+ python_version: "3.10.13"
10
+ pinned: false
11
+ license: mit
12
+ suggested_hardware: zero-a10g
13
+ short_description: Fast, explainable financial-news screening.
14
+ tags:
15
+ - financial-analysis
16
+ - text-analysis
17
+ - information-extraction
18
+ - explainable-ai
19
+ - rust
20
+ ---
21
+
22
+ # RANGE
23
+
24
+ **Rapid Analysis of News for the General Economy**
25
+
26
+ RANGE checks timestamped financial news, identifies whether it matches the current
27
+ financial and economic rules, shows any supported broad-market connection, and
28
+ explains exactly which words and rules produced the result.
29
+
30
+ The article screen and exposure map are separate results. A company event, for example,
31
+ can be accepted as financial context without supporting a bundled broad-market
32
+ exposure.
33
+
34
+ The engine runs inside this Space with no LLM, model download, API key, or outbound
35
+ inference service. In a hosted Space, submitted text is still processed on Hugging Face
36
+ infrastructure; do not submit material you are not permitted to share.
37
+
38
+ The repository can produce a local Docker bundle or a free Gradio/ZeroGPU-compatible
39
+ bundle. Both execute the same compiled CPU/Rust predictor; ZeroGPU is a hosting route,
40
+ not a RANGE GPU backend or a GPU benchmark.
41
+
42
+ > **Hosting note:** This free Gradio deployment uses the ZeroGPU runtime, but RANGE's real prediction endpoints stay on CPU and never request a GPU allocation.
43
+
44
+ ## What the demo returns
45
+
46
+ For one article, the app shows two related outputs:
47
+
48
+ - **Article relevance:** `relevant` or `not_relevant` in this deterministic demo, with
49
+ a plain-language result in the interface. The package contract reserves
50
+ `needs_review` for optional unresolved semantic evidence.
51
+ - **Supported exposure:** Broad market, S&P 500, Nasdaq-100, or Russell 2000 when an
52
+ accepted evidence path supports one; otherwise none.
53
+
54
+ It also returns:
55
+
56
+ - a concise reason suitable for an application;
57
+ - highlighted accepted, review, and rejected matches in the original text;
58
+ - offsets, matching method, rules, identities, and exposure lineage;
59
+ - a downloadable evidence record that identifies the submitted text by its length and
60
+ SHA-256 hash instead of repeating the full article; and
61
+ - Mermaid source for developer-owned evidence-flow rendering.
62
+
63
+ A second tab screens a small editable batch and exports JSONL. Requests are capped at
64
+ 100 rows, 20,000 characters and 80,000 UTF-8 bytes per article, and 200,000 characters
65
+ and 800,000 UTF-8 bytes per batch. The Python and Arrow APIs are intended for large
66
+ collections. The web interface and its named API endpoints enforce the same limits
67
+ before running the screening engine. Batch rows must have exactly the four displayed
68
+ columns, metadata fields have separate size limits, and nested values are rejected.
69
+ Combined article and metadata cell content cannot exceed 264,000 characters or
70
+ 1,056,000 UTF-8 bytes. These figures do not include JSON framing or Gradio's transport
71
+ envelope. A separate 4 MiB server limit covers the complete HTTP request body before
72
+ JSON parsing.
73
+
74
+ Download files are temporary conveniences, not storage. A generated file becomes
75
+ eligible for deletion after five minutes, and the app makes a best-effort cleanup pass
76
+ once a minute. The host may remove it sooner when the Space restarts. Save a file
77
+ promptly if you need it, and do not rely on the download link as an archive.
78
+
79
+ ## Why it is fast
80
+
81
+ RANGE is a compiled screening engine rather than an open-ended language model:
82
+
83
+ - Rust Aho-Corasick automata scan many configured phrases together.
84
+ - Rayon distributes independent articles across CPU workers.
85
+ - Worker-local buffers are reused instead of rebuilt for every row.
86
+ - Common ASCII news text uses a specialized normalization path, while frozen Unicode
87
+ tables keep fallback behavior deterministic.
88
+ - Compact and Arrow package APIs represent the common no-match case sparsely.
89
+
90
+ In our fastest recorded CPU test, RANGE screened **457,735 articles/second in a
91
+ 500,000-article run** and **395,017 articles/second in a 10,000,000-article run**.
92
+ We measured this with a specially optimized RANGE build on one Intel i7-10750H Windows
93
+ laptop using 10 CPU workers. These are the fastest recorded results for the screening
94
+ step alone. Loading data, running a complete pipeline, and using the hosted demo will
95
+ be slower. Speed varies with hardware, article length, and screening rules.
96
+
97
+ ## What the result means
98
+
99
+ A `relevant` result means the article passed the configured financial and economic
100
+ relevance policy. Evidence-path state and supported exposure are reported separately.
101
+ Neither result means the article is bullish, bearish, material, complete, or
102
+ predictive of returns.
103
+
104
+ The app does not perform sentiment analysis, investment recommendations, fuzzy
105
+ semantic search, or LLM inference. Each result remains traceable to literal
106
+ observations and explicit policy.
107
+
108
+ ## API and coding agents
109
+
110
+ The Gradio functions expose named endpoints:
111
+
112
+ - `/analyze` — one timestamped article with detailed evidence;
113
+ - `/batch` — up to 100 timestamped articles with JSONL summaries.
114
+
115
+ After this bundle is deployed to `<namespace>/<space>`, inspect the generated agent
116
+ instructions with:
117
+
118
+ ```bash
119
+ curl -H "Authorization: Bearer $HF_TOKEN" \
120
+ https://huggingface.co/spaces/<namespace>/<space>/agents.md
121
+ ```
122
+
123
+ That document tells coding agents how to discover and call the Space API. Private
124
+ Spaces require a Hugging Face bearer token for both `agents.md` and API requests. The
125
+ Space does not need a separate model endpoint.
126
+
127
+ ## Package
128
+
129
+ This app is built from **RANGE 0.1.0a1**, a deterministic CPU/Rust research alpha.
130
+ The Python distribution is `range-news` and the import namespace is `range_news`.
131
+ The standalone bundle includes
132
+ the default taxonomy used by the demo. Package users can copy and edit its YAML or
133
+ extend the taxonomy in Python without modifying the engine.
134
+
135
+ See `PACKAGE_README.md` in an exported bundle for the full Python API, taxonomy, benchmark context, and limitations.
136
+
137
+ ## Safety and scope
138
+
139
+ This software is for research and information-screening workflows. It does not provide
140
+ investment advice. A hosted production deployment returns a simple error to the user
141
+ when something unexpected fails; diagnostic details stay in the host logs.
142
+
143
+ Do not submit private, licensed, or confidential article text to a public deployment
144
+ unless you have permission to do so. The article text is still processed on the Space host
145
+ and can appear in the interactive page even though downloaded evidence records omit it.
146
+
147
+ Making the Space public also makes its repository public. That includes the app source,
148
+ bundled RANGE source and native runtime, package documentation, and default taxonomy.
149
+ Keep credentials, private datasets, licensed articles, local paths, and unpublished
150
+ rules out of every uploaded file and revision.
151
+
152
+ The current alpha uses CPython 3.10. Moving the native package and Space runtime to
153
+ CPython 3.12 is planned as a separate, tested migration; the existing CPython 3.10
154
+ wheel cannot be reused for that change.
SPACE_BUNDLE_MANIFEST.json ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "files": {
3
+ ".gitattributes": "30af955bc8b9589ff13954efae1020fb4fcbd3e2114ce2530c1e19a198331490",
4
+ "CHANGELOG.md": "04441fe7ba8cc83498bf014cda1b29bcb6f7c148cfbbe3452dc7a9dea9af85ef",
5
+ "CITATION.cff": "acf3bf2c922c2afd0f8cc336024f525d9334c4b94b83efaa96dc36a1df5c6198",
6
+ "Cargo.lock": "64eaf82038b21074bf9d6b30e00bf05536958b08463dbe4a4c9a88a86e28a2d8",
7
+ "Cargo.toml": "f58a8af353ccba4309f9ddddc3e138634093ab2545bfef5214b6a08815d37d59",
8
+ "DEPLOYMENT.md": "2d005b4059d06bc5e722d67acfe33b97a1b631ac9537e69721e0920b26ba2c0d",
9
+ "LICENSE": "98564786fde1cf443f2b761bc9c3b329dcac3528b3c383a66b25101ee9670585",
10
+ "PACKAGE_README.md": "e6fe56bcb6c0cf24a6c7b3859527ab043c341a7c40b5eb3f919a9bccab33382c",
11
+ "README.md": "423104b0ef72e4e605c4facf10681f0741d43eb8a70ea6932fc945e84d474048",
12
+ "app.py": "138adcabfb167a19a499d3c0f80bfa63b565c61c3fac1e1d31c04f12bcc5e622",
13
+ "docs/attention2alpha-compatibility-review.md": "c6e28e67b5528c5683efa6cb98603a9f46e42430c96bee6da4a3a5af9bf94c13",
14
+ "docs/performance.md": "9d4c99009993527e49f2ce3bb38d3156f1b7e1425723f0093c7873c544261b24",
15
+ "docs/readme-research.md": "f69255682495de999875c91a8c4dee2e59e1b185877920cf4c85f0ccd49053f3",
16
+ "docs/release.md": "ce56bb5c7f456a710c90bed63ce2dfe90632d1b0208cc16d06e0b37ad0470bbe",
17
+ "docs/taxonomy.md": "899b86c816ead8cd9bae9b58f96d0d21a1439cc778e6b61e7c17644a64dc1abf",
18
+ "docs/technical-signoff.md": "4c1efa828b2460ff5092f07df30319ca7775ec3118ccdfc573aaa5b2980928c5",
19
+ "examples.py": "94da2be9d8e523a54b6aae6fbd365c5e3456e836b120e4a4a0bd78acb5969d10",
20
+ "examples/app.py": "53c76cc3834e73435483db6fbf89d8125a0f7aa590269d880cffdaa8e40097f5",
21
+ "examples/benchmark_hf.py": "5df4e9d756a6447eb5f462ac7b72aaf2a8c1d92558a58620e5abb51e97e62bca",
22
+ "examples/custom_taxonomy.py": "9c98ed10f258a4503faa783068676f027328a2944b22493de49592057be3aa5d",
23
+ "examples/quickstart.py": "19bdd779ec202957c72c2b49e7bf778a13ea1b94d0a52f1bba5521810eb71626",
24
+ "pyproject.toml": "d40182524a367d9ad2d08892450b4bcecfb2133c92bf45867c4e8f4a05910300",
25
+ "requirements.txt": "94f5ee00edc14a89e5bf6f5fcb90261cde4a57dc3ebc20e0639e68ec504cc220",
26
+ "rust/core.rs": "9ec890063a3485ab90f9f517a0e8c17b4ba52d66d937f2b9f6cf521889590f1a",
27
+ "rust/generated/python_whitespace.bin": "6c314375b6c075589b515139e50068d72c0cbd7daf4246e555a9dcfc9748ecc9",
28
+ "rust/generated/unicode_index.bin": "fe152ac184aa77c40d0f040ba725140b17d845bae9fae255b2b3d3c43bb79c8f",
29
+ "rust/generated/unicode_meta.txt": "d7b7f5d807b712597a04e8cf1394c1734962c4e042057cc7fca85f5173919d38",
30
+ "rust/generated/unicode_pool.bin": "109a8da1563408ad1da82919a5dfa1ab9fa18870f0f4d22e2818857b5a4fc30f",
31
+ "rust/lib.rs": "f6a2205062c2272b13b925deb1883ff1fab10aa5e40a46695bfe96a5626b5753",
32
+ "src/range_news/__init__.py": "816ba0ee72545cc03d6e5183d46530acd026e67f4b38d6d972a1abd520eb899d",
33
+ "src/range_news/_core/__init__.py": "7e6c8615e347bca422f9b1e6e10160ac277c346956adf9e86a8a08419f879e96",
34
+ "src/range_news/_core/accelerated/__init__.py": "ed04df1ca0da8254d9558201c6172969626aced74864842c5a17068ce87a6999",
35
+ "src/range_news/_core/accelerated/compact.py": "97730675a2563d4e196c06c826e2e2099ff0d123ee027bd0b2888b2aba75825b",
36
+ "src/range_news/_core/accelerated/indicator.py": "e2ce4da9561752b1f3622b140fd7d798aaf7dd0eebc4828b928f80454de1c740",
37
+ "src/range_news/_core/catalogs.py": "324f63cb5c0653c11dd7ca626d17f919b06c148794b9988e52890816289ba66f",
38
+ "src/range_news/_core/catalogs/entities.yml": "5849fe67d3e642ed9a6f67b5f8e0ed83fd850bb570b8ea4cf8ba0cf13bc24548",
39
+ "src/range_news/_core/catalogs/lexical.yml": "d58e2b64b4f9fe3aba4b0bbc671b05eaeb343955255014cf2577d41935abc14a",
40
+ "src/range_news/_core/config.py": "6599b43888463e67255d588ab50f408a077a2a6e52361503b85b711b61b678b0",
41
+ "src/range_news/_core/configs/default.yml": "6e443b14b9e7c4524e8dbdd3035726627345d01d02cc21de7a53a133dea5802c",
42
+ "src/range_news/_core/indicator.py": "488178693d90e7a7f329b40309eb4583908d5a990cd0b293b272c5fbb192f3a4",
43
+ "src/range_news/_core/models.py": "e152077ecd18c31eed76b150e234934180a5ffad061d2bf26e35229160cf2733",
44
+ "src/range_news/_taxonomy_impl.py": "db6802ef00114e33bbb47b2d71886f037e151f397ae82258d1a6760f43bdfed1",
45
+ "src/range_news/api.py": "1d165a3211fad9a9de1c274320dc21e787303fcd1a37599e399ba82881d4886d",
46
+ "src/range_news/backends.py": "7a25879d21ed9992aeecc9dc5f4093de00ae68c117646b132140d663987829ca",
47
+ "src/range_news/customization.py": "6f5e4ae6ba6576cd0fb6a84cb1cfbe85b6da973ab6a411a9d2fdd243ef906355",
48
+ "src/range_news/models.py": "305c227238efa18be762fbcad7bdd6ee3fee1274d1e4c866854589319191ac24",
49
+ "src/range_news/py.typed": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
50
+ "vendor/range_news/__init__.py": "816ba0ee72545cc03d6e5183d46530acd026e67f4b38d6d972a1abd520eb899d",
51
+ "vendor/range_news/_core/__init__.py": "7e6c8615e347bca422f9b1e6e10160ac277c346956adf9e86a8a08419f879e96",
52
+ "vendor/range_news/_core/accelerated/__init__.py": "ed04df1ca0da8254d9558201c6172969626aced74864842c5a17068ce87a6999",
53
+ "vendor/range_news/_core/accelerated/compact.py": "97730675a2563d4e196c06c826e2e2099ff0d123ee027bd0b2888b2aba75825b",
54
+ "vendor/range_news/_core/accelerated/indicator.py": "e2ce4da9561752b1f3622b140fd7d798aaf7dd0eebc4828b928f80454de1c740",
55
+ "vendor/range_news/_core/catalogs.py": "324f63cb5c0653c11dd7ca626d17f919b06c148794b9988e52890816289ba66f",
56
+ "vendor/range_news/_core/catalogs/entities.yml": "5849fe67d3e642ed9a6f67b5f8e0ed83fd850bb570b8ea4cf8ba0cf13bc24548",
57
+ "vendor/range_news/_core/catalogs/lexical.yml": "d58e2b64b4f9fe3aba4b0bbc671b05eaeb343955255014cf2577d41935abc14a",
58
+ "vendor/range_news/_core/config.py": "6599b43888463e67255d588ab50f408a077a2a6e52361503b85b711b61b678b0",
59
+ "vendor/range_news/_core/configs/default.yml": "6e443b14b9e7c4524e8dbdd3035726627345d01d02cc21de7a53a133dea5802c",
60
+ "vendor/range_news/_core/indicator.py": "488178693d90e7a7f329b40309eb4583908d5a990cd0b293b272c5fbb192f3a4",
61
+ "vendor/range_news/_core/models.py": "e152077ecd18c31eed76b150e234934180a5ffad061d2bf26e35229160cf2733",
62
+ "vendor/range_news/_native.cpython-310-x86_64-linux-gnu.so": "45e05c00ab729ead671436ed8bd392c016dfa1e44ef42fdfade758902b1519d1",
63
+ "vendor/range_news/_taxonomy_impl.py": "db6802ef00114e33bbb47b2d71886f037e151f397ae82258d1a6760f43bdfed1",
64
+ "vendor/range_news/api.py": "1d165a3211fad9a9de1c274320dc21e787303fcd1a37599e399ba82881d4886d",
65
+ "vendor/range_news/backends.py": "7a25879d21ed9992aeecc9dc5f4093de00ae68c117646b132140d663987829ca",
66
+ "vendor/range_news/customization.py": "6f5e4ae6ba6576cd0fb6a84cb1cfbe85b6da973ab6a411a9d2fdd243ef906355",
67
+ "vendor/range_news/models.py": "305c227238efa18be762fbcad7bdd6ee3fee1274d1e4c866854589319191ac24",
68
+ "vendor/range_news/py.typed": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
69
+ },
70
+ "format": "range-news-hugging-face-space-bundle-v1",
71
+ "hardware": "zero-a10g",
72
+ "package": "range-news",
73
+ "runtime_package": "vendor/range_news",
74
+ "sdk": "gradio",
75
+ "source_wheel": "range_news-0.1.0a1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
76
+ "source_wheel_sha256": "c72a0f0d174e028f0a3db338c13e1dbf3e905ff2f67bacc0b3966a2386c2681b",
77
+ "target": "zerogpu",
78
+ "version": "0.1.0a1"
79
+ }
app.py ADDED
@@ -0,0 +1,1172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Space-ready Gradio demo for explainable financial-news relevance screening."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import hashlib
7
+ import html
8
+ import json
9
+ import os
10
+ import sys
11
+ import uuid
12
+ from collections.abc import Mapping, Sequence
13
+ from datetime import datetime
14
+ from enum import Enum
15
+ from functools import lru_cache
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ import gradio as gr
20
+ from starlette.middleware import Middleware
21
+ from starlette.middleware.body_limit import RequestBodyLimitMiddleware
22
+ from gradio.utils import get_upload_folder
23
+
24
+ VENDORED_PACKAGE_ROOT = Path(__file__).resolve().parent / "vendor"
25
+ if VENDORED_PACKAGE_ROOT.is_dir():
26
+ sys.path.insert(0, str(VENDORED_PACKAGE_ROOT))
27
+
28
+ try:
29
+ import spaces
30
+ except ModuleNotFoundError: # Local installs and the Docker image do not need it.
31
+ spaces = None
32
+
33
+ from range_news import RelevanceEngine, __version__
34
+
35
+ from examples import ARTICLE_EXAMPLES, BATCH_ROWS, SINGLE_EXAMPLES
36
+
37
+
38
+ if spaces is not None:
39
+
40
+ @spaces.GPU(duration=1)
41
+ def _zerogpu_hosting_marker() -> None:
42
+ """Register the free ZeroGPU runtime without moving CPU inference to a GPU."""
43
+
44
+ return None
45
+
46
+
47
+ GRADIO_MAJOR = int(gr.__version__.split(".", 1)[0])
48
+ THEME = gr.themes.Soft(primary_hue="teal", neutral_hue="slate")
49
+ MAX_BATCH_ROWS = 100
50
+ EXPECTED_BATCH_COLUMNS = 4
51
+ MAX_ARTICLE_CHARS = 20_000
52
+ MAX_ARTICLE_BYTES = 80_000
53
+ MAX_TIMESTAMP_CHARS = 128
54
+ MAX_TIMESTAMP_BYTES = 512
55
+ MAX_ARTICLE_ID_CHARS = 256
56
+ MAX_ARTICLE_ID_BYTES = 1_024
57
+ MAX_SOURCE_CHARS = 256
58
+ MAX_SOURCE_BYTES = 1_024
59
+ MAX_BATCH_CHARS = 200_000
60
+ MAX_BATCH_BYTES = 800_000
61
+ MAX_BATCH_METADATA_CHARS = 64_000
62
+ MAX_BATCH_METADATA_BYTES = 256_000
63
+ MAX_BATCH_CONTENT_CHARS = MAX_BATCH_CHARS + MAX_BATCH_METADATA_CHARS
64
+ MAX_BATCH_CONTENT_BYTES = MAX_BATCH_BYTES + MAX_BATCH_METADATA_BYTES
65
+ MAX_HTTP_REQUEST_BYTES = 4 * 1024 * 1024
66
+ EVIDENCE_HEADERS = [
67
+ "evidence path",
68
+ "matched text",
69
+ "offsets",
70
+ "type",
71
+ "method",
72
+ "rule or catalog",
73
+ "identity",
74
+ "exposure",
75
+ ]
76
+ BATCH_HEADERS = [
77
+ "article_id",
78
+ "status",
79
+ "accepted",
80
+ "relevant_to",
81
+ "why",
82
+ ]
83
+
84
+ CSS = """
85
+ html, body {width: 100%; margin: 0 !important; overflow-x: clip;}
86
+ .gradio-container {
87
+ width: 100% !important;
88
+ max-width: none !important;
89
+ margin: 0 !important;
90
+ padding: 0 0 2rem !important;
91
+ }
92
+ .hero-host {
93
+ width: 100vw !important;
94
+ max-width: none !important;
95
+ margin-inline: calc(50% - 50vw) !important;
96
+ padding: 0 !important;
97
+ border: 0 !important;
98
+ box-sizing: border-box !important;
99
+ }
100
+ .hero {
101
+ position: relative;
102
+ width: 100%;
103
+ margin: 0;
104
+ overflow: hidden;
105
+ box-sizing: border-box;
106
+ border-block: 1px solid var(--border-color-primary);
107
+ border-inline: 0;
108
+ background:
109
+ radial-gradient(circle at 92% 15%, rgba(13, 148, 136, 0.18), transparent 34%),
110
+ radial-gradient(circle at 8% 88%, rgba(37, 99, 235, 0.12), transparent 30%),
111
+ linear-gradient(145deg, var(--background-fill-primary), var(--background-fill-secondary));
112
+ box-shadow: 0 18px 48px rgba(15, 23, 42, 0.08);
113
+ }
114
+ .hero-inner {
115
+ width: min(calc(100% - 2rem), 1180px);
116
+ margin-inline: auto;
117
+ padding: clamp(2.2rem, 5vw, 4.5rem) clamp(1.25rem, 4vw, 3.2rem);
118
+ box-sizing: border-box;
119
+ text-align: center;
120
+ }
121
+ .hero-eyebrow,
122
+ .hero h1,
123
+ .hero-expansion,
124
+ .hero-copy,
125
+ .benchmark-note {
126
+ width: 100%;
127
+ text-align: center !important;
128
+ }
129
+ .hero-eyebrow {
130
+ margin: 0 0 0.8rem;
131
+ color: #0f766e;
132
+ font-size: 0.78rem;
133
+ font-weight: 750;
134
+ letter-spacing: 0.12em;
135
+ text-transform: uppercase;
136
+ }
137
+ .hero h1 {
138
+ max-width: 960px;
139
+ margin: 0 auto;
140
+ font-size: clamp(3.4rem, 10vw, 7rem);
141
+ line-height: 0.92;
142
+ letter-spacing: -0.065em;
143
+ }
144
+ .hero-expansion {
145
+ max-width: 960px;
146
+ margin: 1rem auto 0;
147
+ font-size: clamp(1.08rem, 2.5vw, 1.55rem);
148
+ font-weight: 720;
149
+ line-height: 1.35;
150
+ }
151
+ .hero-copy {
152
+ max-width: 900px;
153
+ margin: 1rem auto 0;
154
+ font-size: clamp(1rem, 1.5vw, 1.16rem);
155
+ line-height: 1.65;
156
+ opacity: 0.88;
157
+ }
158
+ .speed-grid {
159
+ display: grid;
160
+ grid-template-columns: repeat(3, minmax(0, 1fr));
161
+ gap: 0.75rem;
162
+ max-width: 900px;
163
+ margin: 1.6rem auto 0;
164
+ }
165
+ .speed-stat {
166
+ padding: 0.9rem 1rem;
167
+ border: 1px solid var(--border-color-primary);
168
+ border-radius: 14px;
169
+ background: var(--background-fill-primary);
170
+ text-align: center;
171
+ }
172
+ .speed-stat strong {display: block; font-size: clamp(1.2rem, 2.6vw, 1.75rem); line-height: 1.15;}
173
+ .speed-stat span {display: block; margin-top: 0.25rem; font-size: 0.8rem; line-height: 1.35; opacity: 0.72;}
174
+ .benchmark-note {max-width: 900px; margin: 0.75rem auto 0; font-size: 0.86rem; line-height: 1.5; opacity: 0.72;}
175
+ .trust-row {display: flex; justify-content: center; flex-wrap: wrap; gap: 0.55rem; margin-top: 1.25rem;}
176
+ .trust-pill {
177
+ padding: 0.36rem 0.68rem;
178
+ border: 1px solid var(--border-color-primary);
179
+ border-radius: 999px;
180
+ background: var(--background-fill-primary);
181
+ font-size: 0.82rem;
182
+ font-weight: 650;
183
+ }
184
+ .value-grid {
185
+ display: grid;
186
+ grid-template-columns: repeat(3, minmax(0, 1fr));
187
+ gap: 0.8rem;
188
+ width: min(calc(100% - 2rem), 1120px);
189
+ max-width: 1120px;
190
+ margin: 1.15rem auto 1.35rem;
191
+ }
192
+ .content-shell {
193
+ width: min(calc(100% - 2rem), 1120px) !important;
194
+ max-width: 1120px !important;
195
+ margin-inline: auto !important;
196
+ align-self: center !important;
197
+ }
198
+ .value-card {
199
+ padding: 1rem 1.05rem;
200
+ border: 1px solid var(--border-color-primary);
201
+ border-radius: 14px;
202
+ background: var(--background-fill-primary);
203
+ text-align: center;
204
+ }
205
+ .value-card strong {display: block; margin-bottom: 0.25rem; font-size: 0.95rem;}
206
+ .value-card span {font-size: 0.88rem; line-height: 1.45; opacity: 0.78;}
207
+ .status-card {
208
+ min-height: 255px;
209
+ padding: 1.15rem 1.25rem;
210
+ border: 1px solid var(--border-color-primary);
211
+ border-top: 5px solid #94a3b8;
212
+ border-radius: 16px;
213
+ background: var(--background-fill-primary);
214
+ box-shadow: 0 10px 26px rgba(15, 23, 42, 0.06);
215
+ }
216
+ .status-card.status-relevant {border-top-color: #0f766e;}
217
+ .status-card.status-review {border-top-color: #d97706;}
218
+ .status-card.status-no-match {border-top-color: #64748b;}
219
+ .status-card h3 {margin: 0.5rem 0 0.45rem; font-size: 1.32rem; line-height: 1.25;}
220
+ .status-label {font-size: 0.73rem; font-weight: 750; letter-spacing: 0.1em; opacity: 0.68; text-transform: uppercase;}
221
+ .status-badge {
222
+ display: inline-block;
223
+ margin-left: 0.45rem;
224
+ padding: 0.2rem 0.48rem;
225
+ border-radius: 999px;
226
+ background: var(--background-fill-secondary);
227
+ font-size: 0.72rem;
228
+ font-weight: 750;
229
+ letter-spacing: 0.04em;
230
+ }
231
+ .result-grid {display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.75rem; margin: 0.9rem 0;}
232
+ .result-cell {padding: 0.72rem 0.8rem; border-radius: 11px; background: var(--background-fill-secondary);}
233
+ .result-cell strong {display: block; margin-bottom: 0.18rem; font-size: 0.78rem; opacity: 0.72;}
234
+ .result-cell span {font-weight: 650; line-height: 1.4;}
235
+ .boundary-note {margin: 0.75rem 0 0; font-size: 0.86rem; line-height: 1.45; opacity: 0.76;}
236
+ .small-note {font-size: 0.78rem; opacity: 0.64;}
237
+ .evidence-legend {display: flex; flex-wrap: wrap; gap: 0.55rem 1rem; margin: 0 0 0.65rem; font-size: 0.83rem;}
238
+ .legend-key {display: inline-flex; align-items: center; gap: 0.35rem;}
239
+ .legend-swatch {width: 0.72rem; height: 0.72rem; border-radius: 3px; border: 1px solid rgba(15, 23, 42, 0.16);}
240
+ .swatch-accepted {background: #99f6e4;}
241
+ .swatch-review {background: #fde68a;}
242
+ .swatch-rejected {background: #e2e8f0;}
243
+ .swatch-observed {background: #bfdbfe;}
244
+ mark.evidence {padding: 0.08rem 0.2rem; border-radius: 0.25rem; color: #111827;}
245
+ mark.evidence:focus {outline: 2px solid #2563eb; outline-offset: 2px;}
246
+ mark.evidence-accepted {background: #99f6e4;}
247
+ mark.evidence-review {background: #fde68a;}
248
+ mark.evidence-rejected {background: #e2e8f0; text-decoration: line-through; text-decoration-thickness: 1px;}
249
+ mark.evidence-observed {background: #bfdbfe;}
250
+ .article-preview {
251
+ min-height: 5.5rem;
252
+ line-height: 1.8;
253
+ white-space: pre-wrap;
254
+ padding: 1rem 1.05rem;
255
+ border: 1px solid var(--border-color-primary);
256
+ border-radius: 14px;
257
+ background: var(--background-fill-primary);
258
+ }
259
+ .empty-preview {opacity: 0.68;}
260
+ .privacy-note {
261
+ margin-top: 1rem;
262
+ padding: 0.85rem 1rem;
263
+ border-left: 4px solid #64748b;
264
+ border-radius: 8px;
265
+ background: var(--background-fill-secondary);
266
+ font-size: 0.88rem;
267
+ line-height: 1.5;
268
+ }
269
+ @media (max-width: 760px) {
270
+ .speed-grid, .value-grid, .result-grid {grid-template-columns: 1fr;}
271
+ .hero-inner {padding: 1.8rem 1.25rem 2rem;}
272
+ .speed-stat {text-align: center;}
273
+ .status-card {min-height: auto;}
274
+ }
275
+ """
276
+
277
+ ABOUT = """
278
+ ## What RANGE does
279
+
280
+ RANGE is a **deterministic screening layer** for timestamped economic and financial
281
+ news. It answers two related questions separately:
282
+
283
+ 1. Did the article pass the configured financial and economic relevance screen?
284
+ 2. Did that evidence support a configured broad-market exposure?
285
+
286
+ It also preserves the exact text, offsets, rules, time-valid identities, and
287
+ relationships behind the result. The screening logic is deterministic and versioned.
288
+
289
+ ### From article to result
290
+
291
+ ```text
292
+ article + publication time
293
+ → literal phrase and identifier matches
294
+ → context, identity, and effective-date checks
295
+ → accepted / review / rejected evidence paths
296
+ → article relevance + supported broad-market exposure
297
+ ```
298
+
299
+ ### Why it is fast
300
+
301
+ - **Compile once, scan together.** Rust Aho-Corasick automata check many configured
302
+ phrases in one pass instead of running a Python loop for every rule.
303
+ - **Use the CPU cores.** Rayon processes independent articles in parallel.
304
+ - **Avoid repeated allocation.** Worker-local scratch buffers are reused across rows.
305
+ - **Optimize common news text.** ASCII input takes a specialized path while frozen
306
+ Unicode tables preserve deterministic fallback behavior.
307
+ - **Keep empty results cheap.** Compact list and Arrow interfaces return sparse facts
308
+ rather than building a rich Python object for every no-match row.
309
+
310
+ In our fastest recorded CPU test, RANGE screened **457,735 articles/second in a
311
+ 500,000-article run** and **395,017 articles/second in a 10,000,000-article run**.
312
+ We measured this with a specially optimized RANGE build on one Intel i7-10750H Windows
313
+ laptop using 10 CPU workers. These are the fastest recorded results for the screening
314
+ step alone. Loading data, running a complete pipeline, and using the hosted demo will
315
+ be slower. Speed varies with hardware, article length, and screening rules.
316
+
317
+ ### What it is not
318
+
319
+ This demo does not infer sentiment, forecast returns, recommend a position, or use an
320
+ LLM. A `relevant` result means the article passed the bundled financial and economic
321
+ relevance policy; evidence-path state and supported exposure are reported separately.
322
+ Neither result means the news is bullish, bearish, material, complete, or predictive
323
+ of returns.
324
+
325
+ The hosted demo is intended for short evaluation inputs. The downloadable package APIs
326
+ are the appropriate interface for large datasets.
327
+ """
328
+
329
+ HERO_HTML = """
330
+ <section class="hero">
331
+ <div class="hero-inner">
332
+ <p class="hero-eyebrow">Fast · consistent · shows its work</p>
333
+ <h1>RANGE</h1>
334
+ <p class="hero-expansion">Rapid Analysis of News for the General Economy</p>
335
+ <p class="hero-copy">
336
+ RANGE quickly checks financial news for economic and broad-market relevance.
337
+ It tells you whether an article matches, which broad market or index it relates to, and
338
+ the exact words and rule behind the result—without using an LLM or sending the
339
+ article to an external API.
340
+ </p>
341
+ <div class="speed-grid" aria-label="Recorded CPU benchmark highlights">
342
+ <div class="speed-stat"><strong>457,735</strong><span>articles screened per second in a 500,000-article test</span></div>
343
+ <div class="speed-stat"><strong>395,017</strong><span>articles screened per second in a 10-million-article test</span></div>
344
+ <div class="speed-stat"><strong>Clear reasons</strong><span>Every result includes the matched words and rule</span></div>
345
+ </div>
346
+ <p class="benchmark-note">Measured with a specially optimized RANGE build on one Intel i7-10750H Windows laptop using 10 CPU workers. These are the fastest recorded results for the screening step alone. Loading data, running a complete pipeline, and using the hosted demo will be slower. Speed varies with hardware, article length, and screening rules.</p>
347
+ <div class="trust-row" aria-label="Product characteristics">
348
+ <span class="trust-pill">Rust-accelerated on CPU</span>
349
+ <span class="trust-pill">Screening rules you can customize</span>
350
+ <span class="trust-pill">Exact matched text and rules</span>
351
+ </div>
352
+ </div>
353
+ </section>
354
+ <section class="value-grid" aria-label="What RANGE returns">
355
+ <div class="value-card"><strong>Article relevance</strong><span>Whether the article matches the financial and economic topics in the current rules.</span></div>
356
+ <div class="value-card"><strong>Related market</strong><span>Broad market, S&amp;P 500, Nasdaq-100, or Russell 2000—when the article supports the connection.</span></div>
357
+ <div class="value-card"><strong>Evidence trail</strong><span>Matched words → rule → name valid on the publication date → market connection.</span></div>
358
+ </section>
359
+ """
360
+
361
+ INITIAL_STATUS_HTML = """
362
+ <div class="status-card status-empty" role="status" aria-live="polite">
363
+ <span class="status-label">Screening result</span>
364
+ <h3>Your result will appear here</h3>
365
+ <p>RANGE reports article relevance and supported broad-market exposure as two
366
+ separate outputs.</p>
367
+ <div class="result-grid">
368
+ <div class="result-cell"><strong>Article relevance</strong><span>Relevant · No configured match</span></div>
369
+ <div class="result-cell"><strong>Related market</strong><span>Shown only when the article supports a connection</span></div>
370
+ </div>
371
+ <p class="boundary-note">This is a relevance screen—not sentiment, materiality, a
372
+ return forecast, or investment advice.</p>
373
+ </div>
374
+ """
375
+
376
+
377
+ def _text_size(value: str, *, label: str) -> tuple[int, int]:
378
+ """Return character and UTF-8 sizes with a public-safe Unicode error."""
379
+
380
+ characters = len(value)
381
+ try:
382
+ utf8_bytes = len(value.encode("utf-8"))
383
+ except UnicodeEncodeError as exc:
384
+ raise gr.Error(f"{label} contains invalid Unicode text.") from exc
385
+ return characters, utf8_bytes
386
+
387
+
388
+ def _validate_article_size(text: str, *, label: str = "Article") -> tuple[int, int]:
389
+ """Validate public-request bounds before the engine sees article text."""
390
+
391
+ characters, utf8_bytes = _text_size(text, label=label)
392
+ if characters > MAX_ARTICLE_CHARS:
393
+ raise gr.Error(f"{label} exceeds the {MAX_ARTICLE_CHARS:,}-character limit.")
394
+ if utf8_bytes > MAX_ARTICLE_BYTES:
395
+ raise gr.Error(f"{label} exceeds the {MAX_ARTICLE_BYTES:,}-byte UTF-8 limit.")
396
+ return characters, utf8_bytes
397
+
398
+
399
+ def _bounded_metadata(
400
+ value: Any,
401
+ *,
402
+ label: str,
403
+ max_characters: int,
404
+ max_bytes: int,
405
+ required: bool = False,
406
+ ) -> tuple[str, int, int]:
407
+ """Normalize one metadata value after enforcing its raw request bounds."""
408
+
409
+ if value is None:
410
+ raw_value = ""
411
+ elif isinstance(value, str):
412
+ raw_value = value
413
+ else:
414
+ raise gr.Error(f"{label} must be text.")
415
+
416
+ characters, utf8_bytes = _text_size(raw_value, label=label)
417
+ if characters > max_characters:
418
+ raise gr.Error(f"{label} exceeds the {max_characters:,}-character limit.")
419
+ if utf8_bytes > max_bytes:
420
+ raise gr.Error(f"{label} exceeds the {max_bytes:,}-byte UTF-8 limit.")
421
+
422
+ cleaned = raw_value.strip()
423
+ if required and not cleaned:
424
+ raise gr.Error(f"Enter {label.lower()}.")
425
+ return cleaned, characters, utf8_bytes
426
+
427
+
428
+ def _batch_cell_text(value: Any, *, label: str) -> str:
429
+ """Accept ordinary table scalars without stringifying nested objects."""
430
+
431
+ if value is None:
432
+ return ""
433
+ if isinstance(value, str):
434
+ return value
435
+ if isinstance(value, (bool, int, float)):
436
+ return str(value)
437
+ raise gr.Error(f"{label} must be plain text or a number.")
438
+
439
+
440
+ def _validate_batch_dimensions(row_count: int, column_count: int) -> None:
441
+ """Reject oversized or malformed tables before their cells are copied."""
442
+
443
+ if row_count > MAX_BATCH_ROWS:
444
+ raise gr.Error(
445
+ f"The interactive demo is capped at {MAX_BATCH_ROWS} rows per request."
446
+ )
447
+ if column_count != EXPECTED_BATCH_COLUMNS:
448
+ raise gr.Error(
449
+ "Batch input must have exactly four columns: "
450
+ "article_id, timestamp, source, and text."
451
+ )
452
+
453
+
454
+ def _local_error_details_enabled() -> bool:
455
+ """Allow detailed tracebacks only through an explicit non-Space opt-in."""
456
+
457
+ requested = os.environ.get("RANGE_LOCAL_SHOW_ERROR", "").strip().casefold()
458
+ if requested not in {"1", "true", "yes", "on"}:
459
+ return False
460
+ return not any(os.environ.get(name) for name in ("SPACE_ID", "SPACE_HOST"))
461
+
462
+
463
+ @lru_cache(maxsize=1)
464
+ def _engine() -> RelevanceEngine:
465
+ """Create and cache the immutable CPU engine used by this process."""
466
+
467
+ return RelevanceEngine()
468
+
469
+
470
+ def _jsonable(value: Any) -> Any:
471
+ """Convert immutable package contracts to ordinary JSON-compatible values."""
472
+
473
+ if dataclasses.is_dataclass(value):
474
+ return {
475
+ field.name: _jsonable(getattr(value, field.name))
476
+ for field in dataclasses.fields(value)
477
+ }
478
+ if isinstance(value, Enum):
479
+ return value.value
480
+ if isinstance(value, datetime):
481
+ return value.isoformat()
482
+ if isinstance(value, Mapping):
483
+ return {str(_jsonable(key)): _jsonable(item) for key, item in value.items()}
484
+ if isinstance(value, (tuple, list, set, frozenset)):
485
+ return [_jsonable(item) for item in value]
486
+ if isinstance(value, Path):
487
+ return str(value)
488
+ attributes = getattr(value, "__dict__", None)
489
+ if isinstance(attributes, dict):
490
+ return {
491
+ str(key): _jsonable(item)
492
+ for key, item in attributes.items()
493
+ if not str(key).startswith("_")
494
+ }
495
+ return value
496
+
497
+
498
+ def _download_file(payload: Any, *, suffix: str) -> str:
499
+ cache_root = Path(get_upload_folder())
500
+ cache_root.mkdir(parents=True, exist_ok=True)
501
+ path = cache_root / f"relevance-{uuid.uuid4().hex}{suffix}"
502
+ if suffix == ".jsonl":
503
+ lines = [json.dumps(row, ensure_ascii=False, sort_keys=True) for row in payload]
504
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
505
+ else:
506
+ path.write_text(
507
+ json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
508
+ encoding="utf-8",
509
+ )
510
+ return str(path)
511
+
512
+
513
+ def _evidence_sets(prediction) -> tuple[set[str], set[str], set[str]]:
514
+ accepted = {
515
+ observation_id
516
+ for path in prediction.article_decision.accepted_evidence
517
+ for observation_id in path.observation_ids
518
+ }
519
+ unresolved = {
520
+ observation_id
521
+ for path in prediction.article_decision.unresolved_evidence
522
+ for observation_id in path.observation_ids
523
+ }
524
+ rejected = {
525
+ observation_id
526
+ for path in prediction.article_decision.rejected_evidence
527
+ for observation_id in path.observation_ids
528
+ }
529
+ return accepted, unresolved, rejected
530
+
531
+
532
+ def _evidence_rows(prediction) -> list[list[str]]:
533
+ accepted, unresolved, rejected = _evidence_sets(prediction)
534
+ resolutions = {
535
+ resolution.observation_id: resolution for resolution in prediction.resolutions
536
+ }
537
+ paths = (
538
+ *prediction.article_decision.accepted_evidence,
539
+ *prediction.article_decision.unresolved_evidence,
540
+ *prediction.article_decision.rejected_evidence,
541
+ )
542
+ exposure_by_observation: dict[str, set[str]] = {}
543
+ for path in paths:
544
+ label = path.exposure_family.label if path.exposure_family is not None else "—"
545
+ for observation_id in path.observation_ids:
546
+ exposure_by_observation.setdefault(observation_id, set()).add(label)
547
+
548
+ rows: list[list[str]] = []
549
+ for observation in prediction.observations:
550
+ if observation.observation_id in accepted:
551
+ status = "accepted"
552
+ elif observation.observation_id in unresolved:
553
+ status = "unresolved path"
554
+ elif observation.observation_id in rejected:
555
+ status = "rejected"
556
+ else:
557
+ status = "observed"
558
+ resolution = resolutions.get(observation.observation_id)
559
+ identity = (
560
+ resolution.canonical_id
561
+ if resolution is not None and resolution.canonical_id
562
+ else (resolution.status.value if resolution is not None else "—")
563
+ )
564
+ lineage = observation.rule_id or observation.catalog_entry_id or "—"
565
+ rows.append(
566
+ [
567
+ status,
568
+ observation.matched_surface,
569
+ f"{observation.span_start}:{observation.span_end}",
570
+ observation.tag_type or observation.entity_concept,
571
+ observation.detection_method,
572
+ lineage,
573
+ identity,
574
+ ", ".join(
575
+ sorted(
576
+ exposure_by_observation.get(observation.observation_id, {"—"})
577
+ )
578
+ ),
579
+ ]
580
+ )
581
+ return rows
582
+
583
+
584
+ def _highlighted_article(prediction) -> str:
585
+ text = prediction.article.text
586
+ accepted, unresolved, rejected = _evidence_sets(prediction)
587
+
588
+ def evidence_status(observation_id: str) -> str:
589
+ if observation_id in accepted:
590
+ return "accepted"
591
+ if observation_id in unresolved:
592
+ return "review"
593
+ if observation_id in rejected:
594
+ return "rejected"
595
+ return "observed"
596
+
597
+ status_priority = {"accepted": 0, "review": 1, "rejected": 2, "observed": 3}
598
+ candidates = sorted(
599
+ prediction.observations,
600
+ key=lambda row: (
601
+ row.span_start,
602
+ status_priority[evidence_status(row.observation_id)],
603
+ -(row.span_end - row.span_start),
604
+ row.observation_id,
605
+ ),
606
+ )
607
+ selected = []
608
+ cursor = -1
609
+ for observation in candidates:
610
+ if observation.span_start < cursor:
611
+ continue
612
+ selected.append(observation)
613
+ cursor = observation.span_end
614
+
615
+ pieces: list[str] = []
616
+ cursor = 0
617
+ for observation in selected:
618
+ pieces.append(html.escape(text[cursor : observation.span_start]))
619
+ status = evidence_status(observation.observation_id)
620
+ status_label = {
621
+ "accepted": "Accepted evidence",
622
+ "review": "Unresolved path",
623
+ "rejected": "Rejected match",
624
+ "observed": "Observed match",
625
+ }[status]
626
+ title = html.escape(
627
+ f"{status_label} · {observation.tag_type or observation.entity_concept} · "
628
+ f"{observation.detection_method} · "
629
+ f"{observation.span_start}:{observation.span_end}"
630
+ )
631
+ surface = html.escape(text[observation.span_start : observation.span_end])
632
+ aria_label = html.escape(f"{status_label}: {observation.matched_surface}")
633
+ pieces.append(
634
+ f'<mark class="evidence evidence-{status}" title="{title}" '
635
+ f'tabindex="0" aria-label="{aria_label}">{surface}</mark>'
636
+ )
637
+ cursor = observation.span_end
638
+ pieces.append(html.escape(text[cursor:]))
639
+ legend = (
640
+ '<div class="evidence-legend" aria-label="Evidence highlight legend">'
641
+ '<span class="legend-key"><span class="legend-swatch swatch-accepted"></span>Accepted evidence</span>'
642
+ '<span class="legend-key"><span class="legend-swatch swatch-review"></span>Unresolved path</span>'
643
+ '<span class="legend-key"><span class="legend-swatch swatch-rejected"></span>Rejected match</span>'
644
+ '<span class="legend-key"><span class="legend-swatch swatch-observed"></span>Observed match</span>'
645
+ "</div>"
646
+ )
647
+ return legend + '<div class="article-preview">' + "".join(pieces) + "</div>"
648
+
649
+
650
+ def _status_card(prediction) -> str:
651
+ card_class, badge, heading = {
652
+ "relevant": (
653
+ "status-relevant",
654
+ "Relevant",
655
+ "Article passed the configured relevance screen",
656
+ ),
657
+ "needs_review": (
658
+ "status-review",
659
+ "Needs review",
660
+ "Possible evidence needs review",
661
+ ),
662
+ "not_relevant": (
663
+ "status-no-match",
664
+ "No configured match",
665
+ "No accepted evidence in the bundled taxonomy",
666
+ ),
667
+ }[prediction.status]
668
+ exposures = ", ".join(prediction.relevant_to)
669
+ exposure_value = exposures or "None"
670
+ has_unresolved_path = bool(prediction.article_decision.unresolved_evidence)
671
+ if exposures:
672
+ exposure_note = ""
673
+ elif prediction.status == "relevant" and has_unresolved_path:
674
+ exposure_note = (
675
+ '<p class="boundary-note">The article passed at the article level, but '
676
+ "its highlighted relationship evidence does not complete a configured "
677
+ "path to a broad-market exposure and is shown as unresolved.</p>"
678
+ )
679
+ elif prediction.status == "relevant":
680
+ exposure_note = (
681
+ '<p class="boundary-note">The article passed at the article level, but '
682
+ "no accepted evidence path established one of the bundled broad-market "
683
+ "exposures.</p>"
684
+ )
685
+ else:
686
+ exposure_note = (
687
+ '<p class="boundary-note">Article relevance and exposure mapping are '
688
+ "separate results.</p>"
689
+ )
690
+ return (
691
+ f'<div class="status-card {card_class}" role="status" aria-live="polite">'
692
+ '<span class="status-label">Screening result</span>'
693
+ f'<span class="status-badge">{html.escape(badge)}</span>'
694
+ f"<h3>{html.escape(heading)}</h3>"
695
+ '<div class="result-grid">'
696
+ '<div class="result-cell"><strong>Article relevance</strong>'
697
+ f"<span>{html.escape(badge)}</span></div>"
698
+ '<div class="result-cell"><strong>Related market</strong>'
699
+ f"<span>{html.escape(exposure_value)}</span></div>"
700
+ "</div>"
701
+ f"<p><strong>Why RANGE returned this:</strong> {html.escape(prediction.why)}</p>"
702
+ f"{exposure_note}"
703
+ '<p class="boundary-note">Relevant means the article passed the configured '
704
+ "screen. It does not mean bullish, bearish, material, or predictive.</p>"
705
+ f'<p class="small-note">RANGE {html.escape(__version__)} · '
706
+ f"taxonomy {html.escape(_engine().taxonomy.version)} · "
707
+ f"CPU workers {_engine().workers}</p>"
708
+ "</div>"
709
+ )
710
+
711
+
712
+ def _audit_payload(prediction) -> dict[str, Any]:
713
+ decision = _jsonable(prediction)
714
+ article = decision.get("article") if isinstance(decision, dict) else None
715
+ if not isinstance(article, dict):
716
+ raise RuntimeError("RANGE produced an invalid evidence record.")
717
+
718
+ article_text = prediction.article.text
719
+ encoded_text = article_text.encode("utf-8")
720
+ article.pop("text", None)
721
+ article.update(
722
+ {
723
+ "text_sha256": hashlib.sha256(encoded_text).hexdigest(),
724
+ "text_characters": len(article_text),
725
+ "text_utf8_bytes": len(encoded_text),
726
+ "text_normalization": "leading_and_trailing_whitespace_removed",
727
+ }
728
+ )
729
+ return {
730
+ "summary": prediction.summary(),
731
+ "package_version": __version__,
732
+ "backend": _jsonable(_engine().backend_info),
733
+ "taxonomy": _engine().taxonomy.describe(),
734
+ "decision": decision,
735
+ }
736
+
737
+
738
+ def analyze_article(
739
+ text: str,
740
+ timestamp: str,
741
+ article_id: str,
742
+ source: str,
743
+ ):
744
+ """Screen one timestamped article for configured financial-news relevance.
745
+
746
+ Returns a plain-language result, highlighted evidence, an application-ready
747
+ summary, rule path, detailed evidence data, Mermaid source, and a download.
748
+ """
749
+
750
+ if not isinstance(text, str):
751
+ raise gr.Error("Article text must be text.")
752
+ _validate_article_size(text)
753
+ cleaned_text = text.strip()
754
+ if not cleaned_text:
755
+ raise gr.Error("Enter non-empty article text.")
756
+ resolved_timestamp, _, _ = _bounded_metadata(
757
+ timestamp,
758
+ label="Publication timestamp",
759
+ max_characters=MAX_TIMESTAMP_CHARS,
760
+ max_bytes=MAX_TIMESTAMP_BYTES,
761
+ required=True,
762
+ )
763
+ resolved_id, _, _ = _bounded_metadata(
764
+ article_id,
765
+ label="Article ID",
766
+ max_characters=MAX_ARTICLE_ID_CHARS,
767
+ max_bytes=MAX_ARTICLE_ID_BYTES,
768
+ )
769
+ resolved_source, _, _ = _bounded_metadata(
770
+ source,
771
+ label="Source",
772
+ max_characters=MAX_SOURCE_CHARS,
773
+ max_bytes=MAX_SOURCE_BYTES,
774
+ )
775
+
776
+ try:
777
+ prediction = _engine().predict(
778
+ cleaned_text,
779
+ timestamp=resolved_timestamp,
780
+ article_id=resolved_id or None,
781
+ source=resolved_source or "demo",
782
+ )
783
+ except (TypeError, ValueError) as exc:
784
+ raise gr.Error(
785
+ "The article or publication timestamp is invalid. "
786
+ "Use an ISO 8601 timestamp such as 2025-01-15T09:30:00-05:00."
787
+ ) from exc
788
+
789
+ audit = _audit_payload(prediction)
790
+ explanation = _engine().explain(prediction, format="text", detail="audit")
791
+ diagram = _engine().explain(prediction, format="mermaid", detail="audit")
792
+ return (
793
+ _status_card(prediction),
794
+ _highlighted_article(prediction),
795
+ prediction.summary(),
796
+ _evidence_rows(prediction),
797
+ explanation,
798
+ json.dumps(audit, ensure_ascii=False, indent=2, sort_keys=True),
799
+ diagram,
800
+ _download_file(audit, suffix=".json"),
801
+ )
802
+
803
+
804
+ def _rows(value: Any) -> list[list[Any]]:
805
+ """Return a small four-column table after validating its shape up front."""
806
+
807
+ if value is None:
808
+ return []
809
+
810
+ shape = getattr(value, "shape", None)
811
+ if shape is not None:
812
+ try:
813
+ dimensions = tuple(int(part) for part in shape)
814
+ except (TypeError, ValueError) as exc:
815
+ raise gr.Error("Batch input must be a two-dimensional table.") from exc
816
+ if len(dimensions) != 2:
817
+ raise gr.Error("Batch input must be a two-dimensional table.")
818
+ _validate_batch_dimensions(dimensions[0], dimensions[1])
819
+ if hasattr(value, "values") and hasattr(value.values, "tolist"):
820
+ converted = value.values.tolist()
821
+ elif hasattr(value, "tolist"):
822
+ converted = value.tolist()
823
+ else:
824
+ raise gr.Error("Batch input must be a two-dimensional table.")
825
+ if not isinstance(converted, list):
826
+ raise gr.Error("Batch input must be a two-dimensional table.")
827
+ rows: Sequence[Any] = converted
828
+ else:
829
+ if not isinstance(value, Sequence) or isinstance(
830
+ value, (str, bytes, bytearray)
831
+ ):
832
+ raise gr.Error("Batch input must be a two-dimensional table.")
833
+ _validate_batch_dimensions(len(value), EXPECTED_BATCH_COLUMNS)
834
+ rows = value
835
+
836
+ for index, row in enumerate(rows):
837
+ if not isinstance(row, Sequence) or isinstance(row, (str, bytes, bytearray)):
838
+ raise gr.Error(f"Batch row {index + 1} must contain four cells.")
839
+ _validate_batch_dimensions(len(rows), len(row))
840
+ return [list(row) for row in rows]
841
+
842
+
843
+ def analyze_batch(data: Any):
844
+ """Screen up to 100 timestamped articles and return summaries plus JSONL.
845
+
846
+ This hosted-demo endpoint uses the rich prediction path. Use the package's compact
847
+ or Arrow APIs for high-throughput datasets.
848
+ """
849
+
850
+ normalized: list[tuple[str, str, str, str]] = []
851
+ total_text_characters = 0
852
+ total_text_bytes = 0
853
+ total_metadata_characters = 0
854
+ total_metadata_bytes = 0
855
+ for index, row in enumerate(_rows(data)):
856
+ raw_article_id, raw_timestamp, raw_source, raw_text = (
857
+ _batch_cell_text(cell, label=f"Batch row {index + 1}, column {offset + 1}")
858
+ for offset, cell in enumerate(row)
859
+ )
860
+ article_id, id_characters, id_bytes = _bounded_metadata(
861
+ raw_article_id,
862
+ label=f"Batch row {index + 1} article ID",
863
+ max_characters=MAX_ARTICLE_ID_CHARS,
864
+ max_bytes=MAX_ARTICLE_ID_BYTES,
865
+ )
866
+ timestamp, timestamp_characters, timestamp_bytes = _bounded_metadata(
867
+ raw_timestamp,
868
+ label=f"Batch row {index + 1} publication timestamp",
869
+ max_characters=MAX_TIMESTAMP_CHARS,
870
+ max_bytes=MAX_TIMESTAMP_BYTES,
871
+ )
872
+ source, source_characters, source_bytes = _bounded_metadata(
873
+ raw_source,
874
+ label=f"Batch row {index + 1} source",
875
+ max_characters=MAX_SOURCE_CHARS,
876
+ max_bytes=MAX_SOURCE_BYTES,
877
+ )
878
+ text_characters, text_bytes = _validate_article_size(
879
+ raw_text, label=f"Batch row {index + 1} article"
880
+ )
881
+ text = raw_text.strip()
882
+
883
+ metadata_characters = id_characters + timestamp_characters + source_characters
884
+ metadata_bytes = id_bytes + timestamp_bytes + source_bytes
885
+ total_metadata_characters += metadata_characters
886
+ total_metadata_bytes += metadata_bytes
887
+ total_text_characters += text_characters
888
+ total_text_bytes += text_bytes
889
+ total_content_characters = total_metadata_characters + total_text_characters
890
+ total_content_bytes = total_metadata_bytes + total_text_bytes
891
+
892
+ if total_metadata_characters > MAX_BATCH_METADATA_CHARS:
893
+ raise gr.Error(
894
+ "Batch metadata exceeds the "
895
+ f"{MAX_BATCH_METADATA_CHARS:,}-character aggregate limit."
896
+ )
897
+ if total_metadata_bytes > MAX_BATCH_METADATA_BYTES:
898
+ raise gr.Error(
899
+ "Batch metadata exceeds the "
900
+ f"{MAX_BATCH_METADATA_BYTES:,}-byte aggregate UTF-8 limit."
901
+ )
902
+ if total_text_characters > MAX_BATCH_CHARS:
903
+ raise gr.Error(
904
+ f"Batch article text exceeds the {MAX_BATCH_CHARS:,}-character "
905
+ "aggregate limit."
906
+ )
907
+ if total_text_bytes > MAX_BATCH_BYTES:
908
+ raise gr.Error(
909
+ f"Batch article text exceeds the {MAX_BATCH_BYTES:,}-byte "
910
+ "aggregate UTF-8 limit."
911
+ )
912
+ if total_content_characters > MAX_BATCH_CONTENT_CHARS:
913
+ raise gr.Error(
914
+ "Combined batch article and metadata content exceeds the "
915
+ f"{MAX_BATCH_CONTENT_CHARS:,}-character limit."
916
+ )
917
+ if total_content_bytes > MAX_BATCH_CONTENT_BYTES:
918
+ raise gr.Error(
919
+ "Combined batch article and metadata content exceeds the "
920
+ f"{MAX_BATCH_CONTENT_BYTES:,}-byte UTF-8 limit."
921
+ )
922
+
923
+ if not any((article_id, timestamp, source, text)):
924
+ continue
925
+ if not text:
926
+ raise gr.Error(f"Batch row {index + 1} has no article text.")
927
+ if not timestamp:
928
+ raise gr.Error(f"Batch row {index + 1} has no timestamp.")
929
+ normalized.append(
930
+ (article_id or f"row-{index}", timestamp, source or "demo", text)
931
+ )
932
+
933
+ if not normalized:
934
+ raise gr.Error("Add at least one complete batch row.")
935
+ try:
936
+ predictions = _engine().predict_batch(
937
+ [row[3] for row in normalized],
938
+ timestamps=[row[1] for row in normalized],
939
+ article_ids=[row[0] for row in normalized],
940
+ sources=[row[2] for row in normalized],
941
+ )
942
+ except (TypeError, ValueError) as exc:
943
+ raise gr.Error(
944
+ "A batch row is invalid. Check that every publication timestamp is "
945
+ "valid ISO 8601 text."
946
+ ) from exc
947
+
948
+ summaries = [prediction.summary() for prediction in predictions]
949
+ table = [
950
+ [
951
+ summary["article_id"],
952
+ summary["status"],
953
+ str(summary["accepted"]),
954
+ ", ".join(summary["relevant_to"]),
955
+ summary["why"],
956
+ ]
957
+ for summary in summaries
958
+ ]
959
+ relevant = sum(summary["status"] == "relevant" for summary in summaries)
960
+ review = sum(summary["status"] == "needs_review" for summary in summaries)
961
+ no_match = len(summaries) - relevant - review
962
+ metrics = {
963
+ "rows": len(summaries),
964
+ "relevant": relevant,
965
+ "needs_review": review,
966
+ "no_configured_match": no_match,
967
+ "taxonomy_fingerprint": _engine().taxonomy.fingerprint,
968
+ }
969
+ return table, metrics, _download_file(summaries, suffix=".jsonl")
970
+
971
+
972
+ def build_demo() -> gr.Blocks:
973
+ """Build the human-facing UI and named Gradio API endpoints."""
974
+
975
+ blocks_options: dict[str, Any] = {
976
+ "title": "RANGE",
977
+ "fill_width": True,
978
+ "delete_cache": (60, 300),
979
+ }
980
+ if GRADIO_MAJOR < 6:
981
+ blocks_options.update(css=CSS, theme=THEME)
982
+
983
+ def build_content() -> None:
984
+ with gr.Tab("Screen one article"):
985
+ gr.Markdown(
986
+ "### Check an article\n"
987
+ "Paste the text and add its publication time. Time is required so "
988
+ "RANGE can apply time-valid names and relationships; it is not "
989
+ "used to forecast prices."
990
+ )
991
+ with gr.Row():
992
+ with gr.Column(scale=5):
993
+ article = gr.Textbox(
994
+ label="Article text",
995
+ lines=13,
996
+ value=SINGLE_EXAMPLES[0][0],
997
+ placeholder="Paste a headline, excerpt, or full article...",
998
+ max_length=MAX_ARTICLE_CHARS,
999
+ )
1000
+ with gr.Row():
1001
+ timestamp = gr.Textbox(
1002
+ label="Publication timestamp",
1003
+ value=SINGLE_EXAMPLES[0][1],
1004
+ info="Required · ISO 8601; include an offset when known.",
1005
+ max_length=MAX_TIMESTAMP_CHARS,
1006
+ )
1007
+ with gr.Accordion("Optional article metadata", open=False):
1008
+ gr.Markdown(
1009
+ "Article ID and source make exported records easier to join "
1010
+ "back to your data. They do not change text matching."
1011
+ )
1012
+ with gr.Row():
1013
+ article_id = gr.Textbox(
1014
+ label="Article ID",
1015
+ value=SINGLE_EXAMPLES[0][2],
1016
+ max_length=MAX_ARTICLE_ID_CHARS,
1017
+ )
1018
+ source = gr.Textbox(
1019
+ label="Source",
1020
+ value=SINGLE_EXAMPLES[0][3],
1021
+ max_length=MAX_SOURCE_CHARS,
1022
+ )
1023
+ analyze = gr.Button("Check relevance", variant="primary")
1024
+ with gr.Column(scale=4):
1025
+ status = gr.HTML(INITIAL_STATUS_HTML)
1026
+ with gr.Accordion("Developer output and download", open=False):
1027
+ summary = gr.JSON(label="Application-ready summary")
1028
+ result_file = gr.File(
1029
+ label="Download evidence record (article text omitted)"
1030
+ )
1031
+
1032
+ example_options: dict[str, Any] = {
1033
+ "examples": SINGLE_EXAMPLES,
1034
+ "inputs": [article, timestamp, article_id, source],
1035
+ "label": "Try a representative scenario",
1036
+ "cache_examples": False,
1037
+ }
1038
+ if GRADIO_MAJOR >= 6:
1039
+ example_options["example_labels"] = [row[0] for row in ARTICLE_EXAMPLES]
1040
+ gr.Examples(**example_options)
1041
+
1042
+ gr.Markdown(
1043
+ "### Evidence in the article\n"
1044
+ "Highlights distinguish evidence that supported the result from "
1045
+ "unresolved evidence paths and matches rejected by context checks."
1046
+ )
1047
+ highlighted = gr.HTML(
1048
+ '<div class="article-preview empty-preview">Run the screen to see '
1049
+ "matched evidence in its original context.</div>"
1050
+ )
1051
+ evidence = gr.Dataframe(
1052
+ headers=EVIDENCE_HEADERS,
1053
+ datatype=["str"] * len(EVIDENCE_HEADERS),
1054
+ interactive=False,
1055
+ label="Evidence and rule path",
1056
+ )
1057
+ with gr.Accordion("Why RANGE returned this result", open=True):
1058
+ explanation = gr.Textbox(lines=16, label=None)
1059
+ with gr.Accordion("Evidence record (article text omitted)", open=False):
1060
+ audit_json = gr.Code(language="json", lines=24, label=None)
1061
+ with gr.Accordion("Mermaid evidence-flow source (developer)", open=False):
1062
+ diagram = gr.Code(language="markdown", lines=20, label=None)
1063
+
1064
+ analyze.click(
1065
+ fn=analyze_article,
1066
+ inputs=[article, timestamp, article_id, source],
1067
+ outputs=[
1068
+ status,
1069
+ highlighted,
1070
+ summary,
1071
+ evidence,
1072
+ explanation,
1073
+ audit_json,
1074
+ diagram,
1075
+ result_file,
1076
+ ],
1077
+ api_name="analyze",
1078
+ scroll_to_output=True,
1079
+ concurrency_limit=1,
1080
+ concurrency_id="range-cpu",
1081
+ )
1082
+
1083
+ with gr.Tab("Batch demo (up to 100)"):
1084
+ gr.Markdown(
1085
+ "This is the rich, explainable demo path—not the package's fastest "
1086
+ "compact benchmark path. Up to 100 rows; 20,000 characters / "
1087
+ "80,000 UTF-8 bytes per article; "
1088
+ "200,000 characters / 800,000 bytes per batch. Package APIs are designed "
1089
+ "for larger batches."
1090
+ )
1091
+ batch_input = gr.Dataframe(
1092
+ headers=["article_id", "timestamp", "source", "text"],
1093
+ datatype=["str", "str", "str", "str"],
1094
+ value=BATCH_ROWS,
1095
+ **(
1096
+ {
1097
+ "row_count": len(BATCH_ROWS),
1098
+ "column_count": 4,
1099
+ }
1100
+ if GRADIO_MAJOR >= 6
1101
+ else {
1102
+ "row_count": (len(BATCH_ROWS), "dynamic"),
1103
+ "col_count": (4, "fixed"),
1104
+ }
1105
+ ),
1106
+ type="array",
1107
+ interactive=True,
1108
+ label="Timestamped articles",
1109
+ )
1110
+ screen = gr.Button("Screen batch", variant="primary")
1111
+ with gr.Row():
1112
+ batch_results = gr.Dataframe(
1113
+ headers=BATCH_HEADERS,
1114
+ datatype=["str"] * len(BATCH_HEADERS),
1115
+ interactive=False,
1116
+ label="Screening results",
1117
+ )
1118
+ batch_metrics = gr.JSON(label="Batch summary")
1119
+ batch_file = gr.File(label="Download summaries as JSONL")
1120
+ screen.click(
1121
+ fn=analyze_batch,
1122
+ inputs=batch_input,
1123
+ outputs=[batch_results, batch_metrics, batch_file],
1124
+ api_name="batch",
1125
+ concurrency_limit=1,
1126
+ concurrency_id="range-cpu",
1127
+ )
1128
+
1129
+ with gr.Tab("How it works"):
1130
+ gr.Markdown(ABOUT)
1131
+ gr.Markdown(
1132
+ "The named API endpoints are `/analyze` and `/batch`. After deployment "
1133
+ "as a compatible Hugging Face Space, the Space also exposes its generated "
1134
+ "API documentation and `agents.md` instructions for coding agents."
1135
+ )
1136
+
1137
+ gr.Markdown(
1138
+ "---\n"
1139
+ '<div class="privacy-note"><strong>Research alpha · privacy notice.</strong> '
1140
+ "RANGE records configured evidence; it does not provide investment "
1141
+ "advice or a forecast. In a hosted Space, submitted text and generated "
1142
+ "downloads are processed temporarily on the Space host. Do not submit "
1143
+ "private, licensed, or confidential text unless you have permission.</div>"
1144
+ )
1145
+
1146
+ with gr.Blocks(**blocks_options) as demo:
1147
+ gr.HTML(HERO_HTML, elem_classes=["hero-host"])
1148
+ with gr.Column(elem_classes=["content-shell"]):
1149
+ build_content()
1150
+ return demo
1151
+
1152
+
1153
+ demo = build_demo()
1154
+
1155
+
1156
+ if __name__ == "__main__":
1157
+ launch_options: dict[str, Any] = {
1158
+ "server_name": os.environ.get("GRADIO_SERVER_NAME", "0.0.0.0"),
1159
+ "server_port": int(os.environ.get("GRADIO_SERVER_PORT", "7860")),
1160
+ "show_error": _local_error_details_enabled(),
1161
+ "app_kwargs": {
1162
+ "middleware": [
1163
+ Middleware(
1164
+ RequestBodyLimitMiddleware,
1165
+ max_body_size=MAX_HTTP_REQUEST_BYTES,
1166
+ )
1167
+ ]
1168
+ },
1169
+ }
1170
+ if GRADIO_MAJOR >= 6:
1171
+ launch_options.update(css=CSS, theme=THEME)
1172
+ demo.queue(api_open=False, max_size=32).launch(**launch_options)
docs/attention2alpha-compatibility-review.md ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Attention2Alpha compatibility-change safety review
2
+
3
+ Status: **safe to retain; no revert recommended**
4
+
5
+ Scope: non-package files changed in PR #157 while preparing the standalone RANGE
6
+ package and its validation gates.
7
+
8
+ This review distinguishes narrow compatibility and correctness repairs from changes to
9
+ the trading or learning objective. The reviewed edits do not alter the RANGE
10
+ taxonomy and do not redefine Attention2Alpha reward, order execution, portfolio
11
+ accounting, replay semantics, or the policy-learning objective.
12
+
13
+ ## File review
14
+
15
+ | file | change | safety assessment |
16
+ | --- | --- | --- |
17
+ | `attention2alpha/data_split.py` | preserves opaque string split keys instead of coercing them to timestamps | correct for fraction-mode and synthetic indexes; timestamp inputs retain existing handling |
18
+ | `attention2alpha/embeddings/embedding_base.py` | serializes optional configuration objects without importing full optional stacks; makes checkpoint-probe publication failure-safe; clarifies validation errors | reduces import coupling and prevents a stale probe from describing an incomplete artifact |
19
+ | `attention2alpha/embeddings/embedding_flair.py` | supports legacy and lightweight adapters and validates persisted round-trip probes on legacy loads | compatibility hardening; production `nn.Module` behavior remains supported |
20
+ | `attention2alpha/environments/leveraged_etf_portfolio_env.py` | permits validation with W&B disabled while re-raising failures when an active run exists | reporting-path resilience only; no trade or reward calculation changes |
21
+ | `attention2alpha/state_features.py` | uses `min(2, window)` for rolling standard deviation | fixes the invalid `window=1, min_periods=2` case while preserving behavior for windows of two or more |
22
+ | `attention2alpha/train_eval.py` | filters loader keyword arguments against legacy signatures, restores fallback metadata, and reuses the production order-matrix builder for simple adapters | compatibility and best-effort reporting improvements; no training-objective change |
23
+
24
+ ## Risk notes
25
+
26
+ The W&B guard catches a broad exception because W&B versions expose different
27
+ uninitialized-run failures. It re-raises when `wandb.run` exists, so failures during an
28
+ active tracked run are not silently hidden. A future cleanup may narrow the exception
29
+ after the supported W&B range is frozen, but the current behavior is safe for this PR.
30
+
31
+ The one-period rolling-volatility change is mathematically well-defined: sample
32
+ standard deviation remains `NaN` until enough observations exist, but Pandas no longer
33
+ rejects an impossible `min_periods` setting.
34
+
35
+ ## Evidence
36
+
37
+ The repository deterministic suite exercises checkpoint serialization, round-trip
38
+ validation, train/eval compatibility, state features, and environment guards. The
39
+ package workflow is separate and verifies that the RANGE wheel does not import the
40
+ Attention2Alpha package.
41
+
42
+ ## Decision
43
+
44
+ Keep the changes. When RANGE moves to a dedicated repository, copy only the package
45
+ and `app/` sources; these monorepo compatibility repairs remain part of Attention2Alpha
46
+ history rather than becoming dependencies of the standalone package.
docs/performance.md ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Performance and architecture
2
+
3
+ RANGE is optimized as an exact information-retrieval system. It is not a language
4
+ model and it does not use an external inference service.
5
+
6
+ ## Shipping path
7
+
8
+ ```text
9
+ Python API
10
+ -> immutable taxonomy and compiled matcher state
11
+ -> native normalization and Aho-Corasick scan
12
+ -> identity and effective-date resolution
13
+ -> evidence-path construction
14
+ -> rich, compact, or Arrow result
15
+ ```
16
+
17
+ The implementation combines:
18
+
19
+ 1. **Aho-Corasick multi-pattern matching in Rust.** Configured phrases are compiled
20
+ into automata so the article is not rescanned by a Python loop for every term.
21
+ 2. **Rayon parallelism.** Independent articles are partitioned across CPU workers.
22
+ 3. **Worker-local scratch state.** Temporary buffers are reused within worker loops.
23
+ 4. **ASCII specialization with frozen Unicode fallback.** Common news text avoids
24
+ unnecessary general-case work while non-ASCII behavior remains versioned.
25
+ 5. **Sparse compact outputs.** The common no-match case is represented implicitly.
26
+ 6. **Arrow integration.** Columnar text can remain columnar across the screening
27
+ boundary instead of becoming one Python input object per row.
28
+ 7. **Immutable reusable engine state.** Taxonomy parsing and matcher construction are
29
+ paid at engine initialization rather than repeated for every article.
30
+
31
+ ## Research path
32
+
33
+ The surrounding research repository records a staged optimization program covering:
34
+
35
+ - correctness-frozen Python baselines;
36
+ - native phrase matching;
37
+ - fused compact result construction;
38
+ - sparse outputs;
39
+ - Arrow input and output;
40
+ - multicore scale-out and saturation tests;
41
+ - distributed and dataframe integration;
42
+ - alternative matchers; and
43
+ - experimental GPU screening.
44
+
45
+ The public alpha deliberately ships only the exact CPU/Rust backend. A faster
46
+ experimental stage is not a package feature unless it preserves the frozen decision
47
+ contract and has a supportable build and runtime story.
48
+
49
+ ## What to time
50
+
51
+ Different measurements answer different questions.
52
+
53
+ | measurement | includes |
54
+ | --- | --- |
55
+ | classification core | normalization, matching, and compact decision construction |
56
+ | rich prediction | Python article objects, full evidence lineage, and explanation-ready results |
57
+ | Arrow screening | Arrow ingestion and sparse Arrow facts |
58
+ | end-to-end pipeline | data loading, preprocessing, screening, conversion, and downstream work |
59
+
60
+ Do not compare a classification-core number with an end-to-end application number.
61
+
62
+ For reproducible timing:
63
+
64
+ - construct and warm the engine before the measured loop;
65
+ - record package, taxonomy, Python, compiler, CPU, thread, and dataset revisions;
66
+ - separate data loading from classification unless the claim is end-to-end;
67
+ - keep conversion to Python objects outside an Arrow-core timing claim;
68
+ - report article counts and total text bytes, not only articles per second;
69
+ - use repeated, counterbalanced runs when comparing implementations; and
70
+ - retain raw results and correctness digests.
71
+
72
+ ## Claim boundary
73
+
74
+ Throughput depends on article length, taxonomy size, hit rate, worker count, CPU,
75
+ memory bandwidth, input representation, and whether rich Python results are requested.
76
+ Repository benchmarks are evidence for their recorded environment, not a guarantee for
77
+ every host or workload.
78
+
79
+ The strongest portable claim for `0.1.0a1` is architectural:
80
+
81
+ > Relevance replaces per-pattern Python work and neural inference with compiled
82
+ > multi-pattern CPU retrieval, parallel article processing, and sparse or columnar
83
+ > result paths while preserving a traceable evidence contract.
docs/readme-research.md ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # README and demo research notes
2
+
3
+ This note records the presentation research behind the `0.1.0a1` README and staged
4
+ Hugging Face Space. It is not a benchmark claim and it does not change the taxonomy or
5
+ runtime contract.
6
+
7
+ ## Sources reviewed
8
+
9
+ The review covered the GitHub monthly trending page and representative high-quality
10
+ project READMEs with adjacent presentation problems:
11
+
12
+ - [GitHub Trending, monthly](https://github.com/trending?since=monthly): current
13
+ repository presentation patterns.
14
+ - [Polars](https://github.com/pola-rs/polars): performance-oriented data tooling with
15
+ a concise value proposition and evidence-linked benchmarks.
16
+ - [DuckDB](https://github.com/duckdb/duckdb): immediate installation and runnable
17
+ examples before deeper architecture.
18
+ - [OpenBB](https://github.com/OpenBB-finance/OpenBB): finance-specific positioning,
19
+ clear audience, product surfaces, and documentation paths.
20
+ - [FinanceToolkit](https://github.com/JerBouma/FinanceToolkit): finance-oriented
21
+ examples, scope, and discoverable feature groups.
22
+ - [Hugging Face Docker Spaces](https://huggingface.co/docs/hub/spaces-sdks-docker)
23
+ and the [coding-agent setup document](https://huggingface.co/new-space/agents.md):
24
+ deployment metadata, Docker runtime expectations, and agent API discovery.
25
+
26
+ The review was conducted on 2026-09-02. Trending content changes over time; the goal
27
+ was to extract durable communication patterns rather than copy rankings or wording.
28
+
29
+ ## Patterns applied
30
+
31
+ | observed pattern | application in Relevance |
32
+ | --- | --- |
33
+ | one-sentence value proposition | “Fast financial-news screening that shows its work.” |
34
+ | show a useful result early | the README opens with real quickstart stdout |
35
+ | put runnable code before exhaustive reference | a complete one-article example appears near the top |
36
+ | explain performance mechanistically | Rust, automata, Rayon, sparse output, and Arrow are mapped to their practical effects |
37
+ | separate product surfaces by user goal | the README distinguishes rich, compact, Arrow, and ranking paths |
38
+ | link performance claims to evidence | claims are bounded to recorded research stages and environments |
39
+ | state boundaries plainly | relevance is separated from sentiment, materiality, prediction, and advice |
40
+ | make the demo self-explanatory | the Space has curated examples, evidence highlights, lineage, downloads, and named APIs |
41
+ | keep deployment reproducible | the Space exporter creates a deterministic source bundle and SHA-256 manifest |
42
+ | avoid decorative clutter | one Space-card emoji; no emoji-heavy package documentation |
43
+
44
+ ## Marketing standard
45
+
46
+ The README is intentionally specific rather than superlative. It explains why the
47
+ system can be fast and traceable, but it does not claim to be the fastest system in all
48
+ settings. It shows the output contract before implementation details and keeps
49
+ limitations close to the value proposition.
50
+
51
+ The intended audience is:
52
+
53
+ - financial-data and news-ingestion engineers;
54
+ - quantitative researchers building candidate-selection pipelines;
55
+ - analysts who need literal evidence and provenance;
56
+ - researchers comparing exact retrieval with model-based classifiers; and
57
+ - teams that need a deterministic first-stage filter before more expensive NLP.
58
+
59
+ ## Items intentionally omitted
60
+
61
+ - unqualified “blazing fast” or “state of the art” language;
62
+ - benchmark numbers without the host, stage, dataset, and repetition design;
63
+ - claims that rule-based relevance is semantically complete;
64
+ - claims that a relevant article predicts price direction;
65
+ - a published package badge or live Space badge before either artifact exists.
docs/release.md ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # RANGE 0.1.0a1 release-candidate contract
2
+
3
+ This directory is the authoritative source for the **RANGE** research alpha.
4
+
5
+ ## Identity
6
+
7
+ - Product: `RANGE`
8
+ - Distribution: `range-news`
9
+ - Import namespace: `range_news`
10
+ - Version: `0.1.0a1`
11
+ - Supported runtime: CPython 3.10
12
+ - Supported backend: CPU
13
+ - License: MIT
14
+ - Publication state: not published
15
+
16
+ The distribution name is descriptive while the product name remains RANGE.
17
+
18
+ ## Required artifacts
19
+
20
+ A candidate build must produce:
21
+
22
+ - a Linux wheel and a Windows wheel;
23
+ - one source distribution;
24
+ - bundled taxonomy YAML;
25
+ - bundled frozen Unicode tables;
26
+ - the native extension;
27
+ - `LICENSE`, `README.md`, `CHANGELOG.md`, and `CITATION.cff`; and
28
+ - the `app/` Space scaffold, Docker build, offline exporter, and validator in the
29
+ source distribution.
30
+
31
+ ## Required validation
32
+
33
+ CI is deliberately small and manually invoked. The `range-news-release-candidate`
34
+ workflow has only two jobs:
35
+
36
+ 1. **Linux package + Space smoke** validates source syntax, the Space scaffold, the
37
+ Rust dependency lock, wheel/sdist metadata and contents, clean installations,
38
+ public contracts, README output, the exported Docker context, the running Gradio
39
+ service, and the named `/analyze` and `/batch` endpoints.
40
+ 2. **Windows package smoke** validates the native Windows artifact, source distribution,
41
+ metadata and contents, clean installations, public contracts, PyArrow support, and
42
+ README output.
43
+
44
+ The broader Attention2Alpha and historical acceleration suites are not package-release
45
+ gates. They remain available through the separate manual/main-only `repository-tests`
46
+ workflow.
47
+
48
+ ## Why the workflow is manual
49
+
50
+ While RANGE remains inside a larger private monorepo, every feature-branch push can
51
+ otherwise produce duplicate `push` and `pull_request` runs. Manual invocation keeps the
52
+ PR readable and conserves hosted-runner minutes while preserving the checks that matter
53
+ before publication or final technical sign-off.
54
+
55
+ ## Explicit no-publish boundary
56
+
57
+ The repository contains no PyPI token, trusted-publisher deployment job, release tag,
58
+ Space creation step, or upload command for this package. CI artifacts are uploaded only
59
+ to the associated GitHub Actions run for inspection. The Space exporter writes only to
60
+ a local directory.
61
+
62
+ Publishing requires a separate, explicit human decision after:
63
+
64
+ - ownership and license review;
65
+ - inspection of both platform artifacts;
66
+ - confirmation of the final repository and documentation URLs;
67
+ - review of the public Space privacy notice and input limits;
68
+ - a clean release commit;
69
+ - a version and tag decision; and
70
+ - intentional package-index and/or Space deployment workflows added in a later change.
71
+
72
+ ## Alpha support boundary
73
+
74
+ `0.1.0a1` makes a narrow claim:
75
+
76
+ > An exact, CPU-first, research-alpha package for traceable financial-news relevance
77
+ > screening against a versioned general-economy taxonomy.
78
+
79
+ It does not claim complete semantic recall, investment usefulness, universal
80
+ throughput, Python-version portability beyond 3.10, or paper-grade performance
81
+ generalization.
docs/taxonomy.md ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # The bundled general-economy taxonomy
2
+
3
+ The default policy in RANGE is a conservative, offline taxonomy for economic and
4
+ financial-news screening. It is not a portfolio definition and it is not a sentiment
5
+ or trading model.
6
+
7
+ ## Decision layers
8
+
9
+ Each article is processed through four traceable layers:
10
+
11
+ 1. **Observation** — literal text spans are matched by effective-dated aliases,
12
+ normalized phrases, context-qualified acronyms, or conjunction rules.
13
+ 2. **Identity resolution** — catalog observations are accepted, rejected, or left
14
+ unresolved at the article timestamp.
15
+ 3. **Evidence construction** — accepted and unresolved observations become evidence
16
+ paths with reason codes and relationship lineage.
17
+ 4. **Decision** — the article receives an admissibility status and one decision for
18
+ each broad exposure family.
19
+
20
+ The taxonomy fingerprint hashes the loaded configuration and catalogs. A result is
21
+ reproducible only when the text, timestamp, package version, and taxonomy fingerprint
22
+ are all retained.
23
+
24
+ ## General-economy scope
25
+
26
+ The bundled lexical catalog covers these maintained areas:
27
+
28
+ - central banks and monetary policy;
29
+ - consumer, producer, and consumption-price inflation;
30
+ - payrolls, unemployment, claims, job openings, participation, and wages;
31
+ - GDP, consumption, production, business surveys, confidence, housing, and orders;
32
+ - Treasury yields, the yield curve, credit spreads, lending standards, and the dollar;
33
+ - energy and industrial commodity prices;
34
+ - fiscal policy, debt-limit events, shutdowns, tariffs, and trade controls;
35
+ - supply-chain disruptions and business-cycle language;
36
+ - financial stability, banking stress, volatility, sovereign risk, and sanctions;
37
+ - securities and banking regulation;
38
+ - named economic sectors; and
39
+ - common company-event phrases.
40
+
41
+ The broad categories are intentionally finite. Exact phrase presence does not prove
42
+ economic causality, and absence does not prove that an article is economically
43
+ unimportant.
44
+
45
+ ## Exposure model
46
+
47
+ The public alpha has four broad exposure families:
48
+
49
+ | family | interpretation |
50
+ | --- | --- |
51
+ | `EXPOSURE:SP500` | S&P 500 benchmark evidence |
52
+ | `EXPOSURE:NASDAQ100` | Nasdaq-100 benchmark evidence |
53
+ | `EXPOSURE:RUSSELL2000` | Russell 2000 benchmark evidence |
54
+ | `MARKET_WIDE` | economic or market-wide evidence not limited to one benchmark |
55
+
56
+ The entity catalog contains only the three named indexes and their common broad ETF
57
+ proxies, SPY, QQQ, and IWM. It does not bundle a leveraged-ETF universe, a complete
58
+ security master, or historical constituents.
59
+
60
+ A direct index or broad-ETF alias can complete a benchmark-specific relevance path.
61
+ A maintained macroeconomic or market-wide lexical rule can complete a `MARKET_WIDE`
62
+ path. Sector and company-event phrases are retained as financial context but do not
63
+ guess a security or broad benchmark when no explicit relationship exists.
64
+
65
+ ## Direction neutrality
66
+
67
+ RANGE answers whether evidence is connected to a maintained economic or market
68
+ concept. It does not infer whether the evidence is:
69
+
70
+ - bullish or bearish;
71
+ - temporary or persistent;
72
+ - already priced in;
73
+ - material enough to trade; or
74
+ - favorable for a long, short, leveraged, or inverse instrument.
75
+
76
+ A downstream application may map broad exposures into its own current holdings or
77
+ watchlist. Keeping that mapping outside the default taxonomy prevents the package from
78
+ silently becoming stale when a portfolio changes.
79
+
80
+ ## Exact matching contract
81
+
82
+ The normalizer applies the package's frozen per-code-point NFKC and casefold behavior,
83
+ normalizes supported dash and separator variants, standardizes apostrophes, collapses
84
+ Python whitespace, and checks token boundaries. It does not use stemming, edit
85
+ distance, fuzzy correction, embeddings, or a language model.
86
+
87
+ Selected short acronyms such as `CPI`, `GDP`, `PPI`, `PMI`, `SEC`, and `Fed` require
88
+ case or nearby context where collision risk is material. `SPY` also requires financial
89
+ context so ordinary uses of “spy” do not become ETF evidence.
90
+
91
+ ## Effective dates
92
+
93
+ Every article requires a timestamp. Entity aliases and relationships are evaluated at
94
+ that time using half-open validity intervals `[valid_from, valid_to)`.
95
+
96
+ The QQQ catalog demonstrates why this matters: the canonical fund remains continuous,
97
+ while historical and current product-name aliases have non-overlapping validity
98
+ periods. A ticker may remain valid while a product or issuer name changes.
99
+
100
+ ISO 8601 timestamps with an explicit UTC offset are recommended. Naive values use the
101
+ documented America/New_York convention.
102
+
103
+ ## Inspect the taxonomy
104
+
105
+ ```python
106
+ from range_news import GeneralEconomyTaxonomy
107
+
108
+ taxonomy = GeneralEconomyTaxonomy()
109
+
110
+ print(taxonomy.version)
111
+ print(taxonomy.fingerprint)
112
+ print(taxonomy.describe())
113
+ print(taxonomy.entity_ids)
114
+ print(taxonomy.lexical_rule_ids)
115
+ ```
116
+
117
+ ## Copy and edit the complete bundle
118
+
119
+ ```python
120
+ from range_news import RelevanceEngine, copy_default_taxonomy
121
+
122
+ path = copy_default_taxonomy("my_taxonomy")
123
+ # Edit my_taxonomy/taxonomy.yml and my_taxonomy/catalogs/*.yml.
124
+ engine = RelevanceEngine.from_yaml(path)
125
+ ```
126
+
127
+ The copied structure is:
128
+
129
+ ```text
130
+ my_taxonomy/
131
+ ├── taxonomy.yml
132
+ └── catalogs/
133
+ ├── entities.yml
134
+ └── lexical.yml
135
+ ```
136
+
137
+ Unknown fields fail closed. Existing files are not overwritten unless
138
+ `overwrite=True` is explicit.
139
+
140
+ ## Add rules in Python
141
+
142
+ `Taxonomy.extend()` and subclass configuration are add-only conveniences. They return
143
+ new immutable taxonomy objects and never mutate the bundled default.
144
+
145
+ ```python
146
+ from range_news import GeneralEconomyTaxonomy
147
+
148
+
149
+ def add_indicator(builder):
150
+ builder.add_lexical_rule(
151
+ rule_id="regional-manufacturing-survey",
152
+ entity_concept="macroeconomic_indicator",
153
+ tag_type="macroeconomic",
154
+ phrases=("regional manufacturing survey",),
155
+ target_id="MACRO:REGIONAL_MANUFACTURING",
156
+ market_wide_authorized=True,
157
+ source_id="my-project",
158
+ )
159
+
160
+
161
+ custom = GeneralEconomyTaxonomy().extend(add_indicator)
162
+ ```
163
+
164
+ Use an editable YAML copy when existing rules, aliases, dates, relationships, ranking
165
+ settings, or exposure membership must be removed or changed.
166
+
167
+ ## Provenance and review discipline
168
+
169
+ Each maintained row has a stable `rule_id` or `alias_id` and a `source_id`. The YAML
170
+ also records source URLs for human review, but source URLs are not fetched during
171
+ inference and are not treated as live guarantees.
172
+
173
+ Before changing the bundled taxonomy:
174
+
175
+ 1. state the intended concept and false-positive risk;
176
+ 2. add positive, negative, boundary, and effective-date tests;
177
+ 3. verify rich, compact-list, and Arrow agreement;
178
+ 4. record the new catalog version and source-as-of date; and
179
+ 5. treat the resulting fingerprint change as a new decision policy.
180
+
181
+ ## Limitations
182
+
183
+ The default taxonomy does not provide:
184
+
185
+ - exhaustive coverage of economic language;
186
+ - entity linking for arbitrary companies;
187
+ - licensed constituent histories;
188
+ - current holdings or portfolio mappings;
189
+ - automatic taxonomy maintenance;
190
+ - sentiment, causality, magnitude, or horizon estimates; or
191
+ - investment advice.
192
+
193
+ The alpha is designed to be inspectable and conservative. Broader recall should be
194
+ added explicitly rather than hidden behind approximate matching.
docs/technical-signoff.md ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # RANGE 0.1.0a1 technical sign-off
2
+
3
+ This document defines the bounded technical sign-off for the unpublished research
4
+ alpha. It does not authorize publication, tagging, release creation, Space deployment,
5
+ or merge.
6
+
7
+ ## Identity
8
+
9
+ - Product: **RANGE**
10
+ - Distribution: `range-news`
11
+ - Python namespace: `range_news`
12
+ - Native Rust crate: `range_news_native`
13
+ - Version: `0.1.0a1`
14
+ - Runtime: CPython 3.10
15
+ - Shipping backend: exact CPU/Rust
16
+
17
+ Shipping source, wheels, and source distributions must not contain retired package
18
+ identifiers. The repository URL may retain the parent repository's historical name;
19
+ that is not a package identity.
20
+
21
+ ## Required current-head evidence
22
+
23
+ Technical sign-off intentionally has only two required jobs. Manually run the
24
+ `range-news-release-candidate` workflow on the exact commit being reviewed and require:
25
+
26
+ 1. **Linux package + Space smoke** — source validation, Rust lock/format checks, wheel
27
+ and source-distribution construction, metadata and archive inspection, clean
28
+ installs, public package contracts, README output verification, deterministic Space
29
+ export, Docker build, Gradio health check, and `/analyze` and `/batch` endpoint
30
+ discovery.
31
+ 2. **Windows package smoke** — native Windows wheel and source-distribution build,
32
+ metadata/archive inspection, clean installs, package contracts, PyArrow coverage,
33
+ and README output verification.
34
+
35
+ The full Attention2Alpha deterministic suite and historical acceleration suite are not
36
+ RANGE package sign-off gates. They remain available in `repository-tests` for a
37
+ manual run and execute after integration to `main`.
38
+
39
+ A success recorded for an older commit is not sufficient.
40
+
41
+ ## Taxonomy non-change boundary
42
+
43
+ README, app, CI, and package-identity work must not change the bundled taxonomy
44
+ configuration, entity catalog, lexical catalog, effective dates, exposure mappings,
45
+ matching rules, or decision behavior. Package presentation and taxonomy design are
46
+ separate contracts.
47
+
48
+ ## No-publish boundary
49
+
50
+ Passing the manual gate leaves the pull request open and unmerged. It creates no PyPI
51
+ or TestPyPI upload, trusted-publisher relationship, registry credential, Git tag,
52
+ GitHub release, Hugging Face Space, deployment, or automatic merge. Publication
53
+ requires a separate explicit change and human decision.
examples.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Curated examples for the RANGE Hugging Face Space demo."""
2
+
3
+ from __future__ import annotations
4
+
5
+ ARTICLE_EXAMPLES: tuple[tuple[str, str, str, str], ...] = (
6
+ (
7
+ "Relevant · CPI, Federal Reserve, and broad benchmarks",
8
+ "CPI inflation cooled after the Federal Reserve cut the federal funds rate. "
9
+ "The S&P 500 and Nasdaq-100 advanced.",
10
+ "2025-01-15T10:30:00-05:00",
11
+ "macro-inflation-1",
12
+ ),
13
+ (
14
+ "Relevant · labor-market indicators",
15
+ "Initial jobless claims rose while nonfarm payroll growth slowed.",
16
+ "2025-01-16T08:30:00-05:00",
17
+ "macro-labor-1",
18
+ ),
19
+ (
20
+ "Relevant · rates and credit conditions",
21
+ "Treasury yields fell and credit spreads narrowed after the policy announcement.",
22
+ "2025-01-17T14:15:00-05:00",
23
+ "financial-conditions-1",
24
+ ),
25
+ (
26
+ "Relevant · Russell 2000 benchmark",
27
+ "The Russell 2000 gained as investors rotated toward smaller companies.",
28
+ "2025-01-21T11:05:00-05:00",
29
+ "benchmark-1",
30
+ ),
31
+ (
32
+ "Relevant context · no supported broad exposure",
33
+ "The software company announced quarterly earnings and a share repurchase.",
34
+ "2025-01-22T16:05:00-05:00",
35
+ "company-context-1",
36
+ ),
37
+ (
38
+ "No configured match · local-news control",
39
+ "The city opened a new public garden after a week of mild weather.",
40
+ "2025-01-23T09:00:00-05:00",
41
+ "control-1",
42
+ ),
43
+ )
44
+
45
+ SINGLE_EXAMPLES: list[list[str]] = [
46
+ [text, timestamp, article_id, "demo"]
47
+ for _label, text, timestamp, article_id in ARTICLE_EXAMPLES
48
+ ]
49
+
50
+ BATCH_ROWS: list[list[str]] = [
51
+ [article_id, timestamp, "demo", text]
52
+ for _label, text, timestamp, article_id in ARTICLE_EXAMPLES
53
+ ]
54
+
55
+ __all__ = ["ARTICLE_EXAMPLES", "BATCH_ROWS", "SINGLE_EXAMPLES"]
examples/app.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Local Gradio demo for one timestamped economy or financial-news article."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from functools import lru_cache
6
+
7
+ import gradio as gr
8
+
9
+ from range_news import RelevanceEngine
10
+
11
+
12
+ @lru_cache(maxsize=1)
13
+ def _engine() -> RelevanceEngine:
14
+ return RelevanceEngine()
15
+
16
+
17
+ def predict_article(text: str, timestamp: str):
18
+ """Return a compact decision, explanation, and Mermaid diagram source."""
19
+
20
+ if not text or not text.strip():
21
+ raise gr.Error("Enter article text before running a prediction.")
22
+ if not timestamp or not timestamp.strip():
23
+ raise gr.Error("Enter an ISO 8601 publication timestamp.")
24
+
25
+ try:
26
+ prediction = _engine().predict(
27
+ text,
28
+ timestamp=timestamp,
29
+ article_id="interactive-demo",
30
+ source="gradio-local",
31
+ )
32
+ except (TypeError, ValueError) as exc:
33
+ raise gr.Error(f"The article or timestamp is invalid: {exc}") from exc
34
+
35
+ explanation = _engine().explain(prediction, format="text", detail="summary")
36
+ diagram = _engine().explain(prediction, format="mermaid", detail="summary")
37
+ return prediction.summary(), explanation, diagram
38
+
39
+
40
+ def build_demo() -> gr.Blocks:
41
+ with gr.Blocks(title="RANGE") as demo:
42
+ gr.Markdown(
43
+ "# RANGE\n"
44
+ "**Rapid Analysis of News for the General Economy**\n\n"
45
+ "Screen timestamped economic and financial news against the bundled "
46
+ "general-economy taxonomy. Everything runs in this Python process."
47
+ )
48
+ with gr.Row():
49
+ with gr.Column(scale=3):
50
+ article = gr.Textbox(
51
+ label="Article text",
52
+ lines=12,
53
+ value=(
54
+ "CPI inflation cooled after the Federal Reserve cut the "
55
+ "federal funds rate. The S&P 500 advanced."
56
+ ),
57
+ )
58
+ timestamp = gr.Textbox(
59
+ label="Publication timestamp",
60
+ value="2025-01-15T10:30:00-05:00",
61
+ info="Use ISO 8601 with a UTC offset when possible.",
62
+ )
63
+ run = gr.Button("Predict relevance", variant="primary")
64
+ with gr.Column(scale=2):
65
+ decision = gr.JSON(label="Decision")
66
+ explanation = gr.Markdown(label="Evidence explanation")
67
+
68
+ diagram = gr.Code(
69
+ language="markdown",
70
+ label="Mermaid evidence diagram",
71
+ lines=16,
72
+ )
73
+ run.click(
74
+ fn=predict_article,
75
+ inputs=[article, timestamp],
76
+ outputs=[decision, explanation, diagram],
77
+ api_name="predict",
78
+ )
79
+ return demo
80
+
81
+
82
+ demo = build_demo()
83
+
84
+
85
+ if __name__ == "__main__":
86
+ demo.launch()
examples/benchmark_hf.py ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Reproducible CPU/Arrow benchmark on one pinned Hugging Face shard.
2
+
3
+ Install with ``python -m pip install ".[arrow,benchmark]"`` and accept the
4
+ dataset's access terms before running this script.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import hashlib
11
+ import json
12
+ import os
13
+ import statistics
14
+ import time
15
+ from datetime import datetime, timezone
16
+
17
+ import pyarrow as pa
18
+ from datasets import load_dataset
19
+ from huggingface_hub import HfApi, get_token, login
20
+
21
+ from range_news import DefaultTaxonomy, RelevanceEngine, __version__
22
+
23
+ DATASET = "Brianferrell787/financial-news-multisource"
24
+ REVISION = "c25780f336280adb57c64bda7aed605d065c672d"
25
+ SHARD = "data/sp500_daily_headlines/sp500_daily_headlines.000.parquet"
26
+
27
+
28
+ def utc_timestamp(value: str) -> datetime:
29
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
30
+ if parsed.tzinfo is None:
31
+ parsed = parsed.replace(tzinfo=timezone.utc)
32
+ return parsed.astimezone(timezone.utc)
33
+
34
+
35
+ def load_seed() -> tuple[pa.RecordBatch, str]:
36
+ if get_token() is None:
37
+ login()
38
+ info = HfApi().dataset_info(DATASET, revision=REVISION, token=True)
39
+ if info.sha != REVISION:
40
+ raise RuntimeError(f"dataset revision drifted: {info.sha}")
41
+
42
+ dataset = load_dataset(
43
+ DATASET,
44
+ name="data",
45
+ data_files={"train": SHARD},
46
+ split="train",
47
+ revision=REVISION,
48
+ token=True,
49
+ ).select_columns(["date", "text"])
50
+
51
+ texts = pa.array(dataset["text"], type=pa.string())
52
+ timestamps = pa.array(
53
+ [utc_timestamp(value) for value in dataset["date"]],
54
+ type=pa.timestamp("us", tz="UTC"),
55
+ )
56
+ seed = pa.record_batch([texts, timestamps], names=["text", "timestamp"])
57
+
58
+ digest = hashlib.sha256()
59
+ for date, text in zip(dataset["date"], dataset["text"], strict=True):
60
+ row = json.dumps(
61
+ [date, text],
62
+ ensure_ascii=False,
63
+ separators=(",", ":"),
64
+ ).encode("utf-8")
65
+ digest.update(len(row).to_bytes(8, "little"))
66
+ digest.update(row)
67
+ return seed, digest.hexdigest()
68
+
69
+
70
+ def repeated_batches(
71
+ seed: pa.RecordBatch,
72
+ total_rows: int,
73
+ *,
74
+ batch_rows: int = 100_000,
75
+ ) -> tuple[pa.RecordBatch, ...]:
76
+ output = []
77
+ emitted = 0
78
+ while emitted < total_rows:
79
+ start = emitted % seed.num_rows
80
+ take = min(batch_rows, total_rows - emitted, seed.num_rows - start)
81
+ output.append(seed.slice(start, take))
82
+ emitted += take
83
+ return tuple(output)
84
+
85
+
86
+ def reader(seed: pa.RecordBatch, batches: tuple[pa.RecordBatch, ...]):
87
+ return pa.RecordBatchReader.from_batches(seed.schema, batches)
88
+
89
+
90
+ def semantic_digest(facts, total_rows: int) -> str:
91
+ digest = hashlib.sha256(f"rows={total_rows}\n".encode())
92
+ for row in facts.to_pyarrow().to_pylist():
93
+ payload = json.dumps(
94
+ row,
95
+ sort_keys=True,
96
+ separators=(",", ":"),
97
+ default=str,
98
+ ).encode("utf-8")
99
+ digest.update(len(payload).to_bytes(8, "little"))
100
+ digest.update(payload)
101
+ return digest.hexdigest()
102
+
103
+
104
+ def measure(
105
+ engine: RelevanceEngine,
106
+ seed: pa.RecordBatch,
107
+ total_rows: int,
108
+ *,
109
+ workers: int,
110
+ repetitions: int,
111
+ ) -> dict[str, object]:
112
+ prepared = repeated_batches(seed, total_rows)
113
+ warmup_rows = min(total_rows, 10_000)
114
+ warmup = repeated_batches(seed, warmup_rows)
115
+ engine.screen_arrow(reader(seed, warmup), workers=workers, row_count=warmup_rows)
116
+
117
+ elapsed = []
118
+ digests = []
119
+ exceptions = []
120
+ for _ in range(repetitions):
121
+ started = time.perf_counter()
122
+ facts = engine.screen_arrow(
123
+ reader(seed, prepared),
124
+ workers=workers,
125
+ row_count=total_rows,
126
+ )
127
+ elapsed.append(time.perf_counter() - started)
128
+ digests.append(semantic_digest(facts, total_rows))
129
+ exceptions.append(facts.exception_count)
130
+
131
+ if len(set(digests)) != 1 or len(set(exceptions)) != 1:
132
+ raise RuntimeError("repetitions produced different semantic outputs")
133
+
134
+ median = statistics.median(elapsed)
135
+ return {
136
+ "backend": "cpu-rust-arrow",
137
+ "rows": total_rows,
138
+ "physical_rows": seed.num_rows,
139
+ "replication_factor": round(total_rows / seed.num_rows, 3),
140
+ "workers": workers,
141
+ "repetitions": repetitions,
142
+ "median_seconds": round(median, 6),
143
+ "rows_per_second": round(total_rows / median),
144
+ "exception_rows": exceptions[0],
145
+ "output_sha256": digests[0],
146
+ }
147
+
148
+
149
+ def main() -> None:
150
+ parser = argparse.ArgumentParser()
151
+ parser.add_argument(
152
+ "--rows",
153
+ type=int,
154
+ nargs="+",
155
+ default=[10_000, 100_000, 1_000_000],
156
+ )
157
+ parser.add_argument("--workers", type=int, default=min(10, os.cpu_count() or 1))
158
+ parser.add_argument("--repetitions", type=int, default=3)
159
+ args = parser.parse_args()
160
+
161
+ seed, seed_sha256 = load_seed()
162
+ taxonomy = DefaultTaxonomy()
163
+ engine = RelevanceEngine(taxonomy=taxonomy, workers=args.workers)
164
+
165
+ print(
166
+ json.dumps(
167
+ {
168
+ "package": __version__,
169
+ "taxonomy": taxonomy.fingerprint,
170
+ "dataset": DATASET,
171
+ "revision": REVISION,
172
+ "shard": SHARD,
173
+ "physical_rows": seed.num_rows,
174
+ "seed_sha256": seed_sha256,
175
+ },
176
+ indent=2,
177
+ )
178
+ )
179
+ for total_rows in args.rows:
180
+ print(
181
+ json.dumps(
182
+ measure(
183
+ engine,
184
+ seed,
185
+ total_rows,
186
+ workers=args.workers,
187
+ repetitions=args.repetitions,
188
+ ),
189
+ sort_keys=True,
190
+ )
191
+ )
192
+
193
+
194
+ if __name__ == "__main__":
195
+ main()
examples/custom_taxonomy.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Extend the bundled general-economy taxonomy without modifying it in place."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ from range_news import DefaultTaxonomy, RelevanceEngine
8
+
9
+
10
+ def add_regional_indicator(builder) -> None:
11
+ builder.add_lexical_rule(
12
+ rule_id="regional-manufacturing-survey",
13
+ entity_concept="macroeconomic_indicator",
14
+ tag_type="macroeconomic",
15
+ phrases=("regional manufacturing survey",),
16
+ target_id="MACRO:REGIONAL_MANUFACTURING",
17
+ market_wide_authorized=True,
18
+ source_id="custom-example",
19
+ )
20
+
21
+
22
+ def main() -> None:
23
+ base = DefaultTaxonomy()
24
+ custom = base.extend(add_regional_indicator)
25
+
26
+ assert base.fingerprint != custom.fingerprint
27
+ assert "regional-manufacturing-survey" not in base.lexical_rule_ids
28
+ assert "regional-manufacturing-survey" in custom.lexical_rule_ids
29
+
30
+ decision = RelevanceEngine(taxonomy=custom, workers=1).predict(
31
+ "The regional manufacturing survey weakened sharply.",
32
+ timestamp="2025-01-15T08:30:00-05:00",
33
+ article_id="custom-economy-1",
34
+ source="custom-example",
35
+ )
36
+
37
+ print(json.dumps(decision.summary(), indent=2))
38
+ print(f"\nBase taxonomy: {base.fingerprint}")
39
+ print(f"Custom taxonomy: {custom.fingerprint}")
40
+
41
+
42
+ if __name__ == "__main__":
43
+ main()
examples/quickstart.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Run the general-economy example shown in the README."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from range_news import RelevanceEngine
6
+
7
+
8
+ def main() -> None:
9
+ engine = RelevanceEngine(workers=1)
10
+ decision = engine.predict(
11
+ (
12
+ "CPI inflation cooled after the Federal Reserve cut the federal funds rate. "
13
+ "The S&P 500 and Nasdaq-100 advanced."
14
+ ),
15
+ timestamp="2025-01-15T10:30:00-05:00",
16
+ article_id="economy-example",
17
+ source="example",
18
+ )
19
+
20
+ print("status:", decision.status)
21
+ print("accepted:", decision.accepted)
22
+ print("markets:", ", ".join(decision.relevant_to))
23
+
24
+
25
+ if __name__ == "__main__":
26
+ main()
pyproject.toml ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["maturin>=1.13.3,<2"]
3
+ build-backend = "maturin"
4
+
5
+ [project]
6
+ name = "range-news"
7
+ version = "0.1.0a1"
8
+ description = "RANGE: Rapid Analysis of News for the General Economy."
9
+ readme = "PACKAGE_README.md"
10
+ requires-python = ">=3.10,<3.11"
11
+ license = { file = "LICENSE" }
12
+ authors = [
13
+ { name = "Brian Ferrell" },
14
+ ]
15
+ keywords = [
16
+ "financial news",
17
+ "economic news",
18
+ "information retrieval",
19
+ "high throughput",
20
+ "taxonomy",
21
+ "Arrow",
22
+ "Rust",
23
+ ]
24
+ dependencies = [
25
+ "PyYAML>=6.0.3,<7",
26
+ "ahocorasick-rs>=1.0.3,<2",
27
+ "tzdata>=2026.3; platform_system == 'Windows'",
28
+ ]
29
+ classifiers = [
30
+ "Development Status :: 3 - Alpha",
31
+ "Intended Audience :: Developers",
32
+ "Intended Audience :: Financial and Insurance Industry",
33
+ "Intended Audience :: Science/Research",
34
+ "License :: OSI Approved :: MIT License",
35
+ "Programming Language :: Python :: 3",
36
+ "Programming Language :: Python :: 3.10",
37
+ "Programming Language :: Rust",
38
+ "Topic :: Office/Business :: Financial",
39
+ "Typing :: Typed",
40
+ ]
41
+
42
+ [project.optional-dependencies]
43
+ arrow = ["pyarrow>=18"]
44
+ demo = ["gradio>=6.26,<7", "starlette>=1.6,<2"]
45
+ benchmark = [
46
+ "datasets>=3,<5",
47
+ "huggingface-hub>=0.27",
48
+ "pyarrow>=18",
49
+ ]
50
+ test = [
51
+ "pytest>=8",
52
+ "pyarrow>=18",
53
+ ]
54
+ release = [
55
+ "build>=1.2,<2",
56
+ "check-wheel-contents>=0.6,<1",
57
+ "twine>=6,<7",
58
+ ]
59
+
60
+ [tool.maturin]
61
+ bindings = "pyo3"
62
+ auditwheel = "repair"
63
+ python-source = "src"
64
+ python-packages = ["range_news"]
65
+ module-name = "range_news._native"
66
+ include = [
67
+ { path = "src/range_news/py.typed", format = "wheel" },
68
+ { path = "src/range_news/_core/configs/*.yml", format = "wheel" },
69
+ { path = "src/range_news/_core/catalogs/*.yml", format = "wheel" },
70
+ { path = "Cargo.lock", format = "sdist" },
71
+ { path = "rust/generated/*.bin", format = "sdist" },
72
+ { path = "rust/generated/unicode_meta.txt", format = "sdist" },
73
+ { path = "CHANGELOG.md", format = "sdist" },
74
+ { path = "CITATION.cff", format = "sdist" },
75
+ { path = "app/*.py", format = "sdist" },
76
+ { path = "app/*.md", format = "sdist" },
77
+ { path = "app/Dockerfile", format = "sdist" },
78
+ { path = "app/requirements.txt", format = "sdist" },
79
+ { path = "app/.dockerignore", format = "sdist" },
80
+ ]
81
+
82
+ [tool.pytest.ini_options]
83
+ testpaths = ["tests"]
84
+ addopts = "--strict-markers -ra"
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ starlette==1.6.0
2
+ PyYAML==6.0.3
3
+ ahocorasick-rs==1.0.3
4
+ tzdata==2026.3
rust/core.rs ADDED
@@ -0,0 +1,1372 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use aho_corasick::{AhoCorasick, AhoCorasickBuilder, MatchKind};
2
+ use pyo3::exceptions::PyValueError;
3
+ use pyo3::prelude::*;
4
+ use rayon::prelude::*;
5
+ use rayon::ThreadPoolBuilder;
6
+ use std::collections::{HashMap, HashSet};
7
+
8
+ static UNICODE_INDEX: &[u8] = include_bytes!("generated/unicode_index.bin");
9
+ static UNICODE_POOL: &[u8] = include_bytes!("generated/unicode_pool.bin");
10
+ static PYTHON_WHITESPACE: &[u8] = include_bytes!("generated/python_whitespace.bin");
11
+
12
+ const ALIAS: u8 = 0;
13
+ const LEXICAL: u8 = 1;
14
+ const CONJUNCTION: u8 = 2;
15
+
16
+ const FAMILY_NONE: i8 = -1;
17
+ const FAMILY_SP500: i8 = 0;
18
+ const FAMILY_NASDAQ100: i8 = 1;
19
+ const FAMILY_RUSSELL2000: i8 = 2;
20
+ const FAMILY_MARKET_WIDE: i8 = 3;
21
+
22
+ const ARTICLE_ACCEPTED: u8 = 0;
23
+ const ARTICLE_REJECTED: u8 = 2;
24
+ const EXP_SUPPORTED: u8 = 0;
25
+ const EXP_PLAUSIBLE: u8 = 1;
26
+ const EXP_NOT_SUPPORTED: u8 = 2;
27
+
28
+ #[derive(Clone)]
29
+ struct ConsumerSpec {
30
+ kind: u8,
31
+ a: usize,
32
+ b: usize,
33
+ c: usize,
34
+ }
35
+
36
+ #[derive(Clone, Copy, Eq, PartialEq)]
37
+ enum ExactMode {
38
+ CaseSensitive,
39
+ PythonIgnoreCaseAsciiLiteral,
40
+ }
41
+
42
+ #[derive(Clone)]
43
+ struct ExactSpec {
44
+ value: Vec<char>,
45
+ mode: ExactMode,
46
+ kind: u8,
47
+ index: usize,
48
+ }
49
+
50
+ struct ExactAutomaton {
51
+ automaton: AhoCorasick,
52
+ pattern_specs: Vec<Vec<usize>>,
53
+ }
54
+
55
+ struct ExactSpecs {
56
+ specs: Vec<ExactSpec>,
57
+ case_sensitive: Option<ExactAutomaton>,
58
+ ignorecase_ascii: Option<ExactAutomaton>,
59
+ }
60
+
61
+ #[derive(Clone, Copy)]
62
+ struct ExactByteMatch {
63
+ spec_index: usize,
64
+ start: usize,
65
+ end: usize,
66
+ }
67
+
68
+ #[derive(Clone)]
69
+ struct ContextRule {
70
+ window_chars: usize,
71
+ accept_clauses: Vec<Vec<Vec<u8>>>,
72
+ reject_terms: Vec<Vec<u8>>,
73
+ }
74
+
75
+ #[derive(Clone)]
76
+ struct AliasSpec {
77
+ canonical_group: usize,
78
+ context_rule: Option<usize>,
79
+ valid_from_epoch: i64,
80
+ valid_to_epoch: Option<i64>,
81
+ family: i8,
82
+ entity_kind: u8,
83
+ unresolved_tag: bool,
84
+ }
85
+
86
+ #[derive(Clone)]
87
+ struct LexicalSpec {
88
+ context_window_chars: usize,
89
+ context_terms: Vec<Vec<u8>>,
90
+ is_event: bool,
91
+ market_wide_authorized: bool,
92
+ unresolved_tag: bool,
93
+ has_event_family: bool,
94
+ }
95
+
96
+ #[derive(Clone)]
97
+ struct ConjunctionSpec {
98
+ actor_phrase_count: usize,
99
+ action_phrase_count: usize,
100
+ object_phrase_count: usize,
101
+ scope_phrase_count: usize,
102
+ }
103
+
104
+ #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
105
+ struct ObservationKey {
106
+ kind: u8,
107
+ index: usize,
108
+ start: usize,
109
+ end: usize,
110
+ }
111
+
112
+ // (row_index, article_status, sp500, nasdaq100, russell2000, market_wide,
113
+ // observation_count, native_candidate, exact_candidate)
114
+ type FactRow = (usize, u8, u8, u8, u8, u8, usize, bool, bool);
115
+
116
+ #[derive(Default)]
117
+ struct ScanScratch {
118
+ chars: Vec<char>,
119
+ normalized: Vec<u8>,
120
+ offsets: Vec<usize>,
121
+ window: Vec<u8>,
122
+ window_offsets: Vec<usize>,
123
+ exact_byte_matches: Vec<ExactByteMatch>,
124
+ exact_matches: Vec<(usize, usize, usize)>,
125
+ exact_boundaries: Vec<usize>,
126
+ alias_candidates: Vec<(usize, usize, usize)>,
127
+ lexical_candidates: Vec<(usize, usize, usize)>,
128
+ conjunction_hits: HashSet<(usize, usize, usize)>,
129
+ longest: HashMap<(usize, usize), usize>,
130
+ observations: HashSet<ObservationKey>,
131
+ }
132
+
133
+ impl ScanScratch {
134
+ fn reset(&mut self) {
135
+ self.chars.clear();
136
+ self.normalized.clear();
137
+ self.offsets.clear();
138
+ self.window.clear();
139
+ self.window_offsets.clear();
140
+ self.exact_byte_matches.clear();
141
+ self.exact_matches.clear();
142
+ self.exact_boundaries.clear();
143
+ self.alias_candidates.clear();
144
+ self.lexical_candidates.clear();
145
+ self.conjunction_hits.clear();
146
+ self.longest.clear();
147
+ self.observations.clear();
148
+ }
149
+ }
150
+
151
+ #[inline(always)]
152
+ fn is_python_ascii_space(byte: u8) -> bool {
153
+ matches!(
154
+ byte,
155
+ b' ' | b'\t' | b'\n' | 0x0b | 0x0c | b'\r' | 0x1c..=0x1f
156
+ )
157
+ }
158
+
159
+ #[inline(always)]
160
+ fn is_separator_ascii(byte: u8) -> bool {
161
+ matches!(
162
+ byte,
163
+ b'-' | b'.' | b',' | b':' | b';' | b'(' | b')' | b'[' | b']' | b'{' | b'}' | b'/'
164
+ )
165
+ }
166
+
167
+ #[inline(always)]
168
+ fn is_token_byte(byte: u8) -> bool {
169
+ byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_'
170
+ }
171
+
172
+ #[inline(always)]
173
+ fn is_exact_token_char(ch: char, mode: ExactMode) -> bool {
174
+ if ch.is_ascii_alphanumeric() || ch == '_' {
175
+ return true;
176
+ }
177
+ // Python's Unicode-aware re.IGNORECASE expands [A-Za-z] to these four
178
+ // additional code points. They are token characters only for the
179
+ // python_ignorecase_ascii_literal mode; applying this globally would change
180
+ // the frozen boundary behavior of case-sensitive exact aliases.
181
+ matches!(mode, ExactMode::PythonIgnoreCaseAsciiLiteral)
182
+ && matches!(ch, '\u{0130}' | '\u{0131}' | '\u{017f}' | '\u{212a}')
183
+ }
184
+
185
+ #[inline(always)]
186
+ fn is_python_space(ch: char) -> bool {
187
+ let cp = ch as usize;
188
+ let byte = PYTHON_WHITESPACE[cp >> 3];
189
+ (byte & (1 << (cp & 7))) != 0
190
+ }
191
+
192
+ #[inline(always)]
193
+ fn mapped_bytes(ch: char) -> Option<&'static [u8]> {
194
+ let cp = ch as usize;
195
+ let base = cp * 4;
196
+ let offset = u32::from_le_bytes([
197
+ UNICODE_INDEX[base],
198
+ UNICODE_INDEX[base + 1],
199
+ UNICODE_INDEX[base + 2],
200
+ UNICODE_INDEX[base + 3],
201
+ ]) as usize;
202
+ if offset == 0 {
203
+ return None;
204
+ }
205
+ let len = u16::from_le_bytes([UNICODE_POOL[offset], UNICODE_POOL[offset + 1]]) as usize;
206
+ Some(&UNICODE_POOL[offset + 2..offset + 2 + len])
207
+ }
208
+
209
+ #[inline(always)]
210
+ fn normalize_post_char(
211
+ ch: char,
212
+ source_index: usize,
213
+ normalized: &mut Vec<u8>,
214
+ offsets: &mut Vec<usize>,
215
+ pending_space: &mut bool,
216
+ ) {
217
+ let ch = match ch {
218
+ '-' | '‐' | '‑' | '‒' | '–' | '—' | '―' | '−' => ' ',
219
+ '.' | ',' | ':' | ';' | '(' | ')' | '[' | ']' | '{' | '}' | '/' | '/' => ' ',
220
+ '\'' | '’' | '‘' | '‛' | '`' | '´' => '\'',
221
+ other => other,
222
+ };
223
+ if is_python_space(ch) {
224
+ *pending_space = !normalized.is_empty();
225
+ return;
226
+ }
227
+ if *pending_space {
228
+ normalized.push(b' ');
229
+ offsets.push(source_index);
230
+ *pending_space = false;
231
+ }
232
+ let mut buffer = [0u8; 4];
233
+ let encoded = ch.encode_utf8(&mut buffer).as_bytes();
234
+ normalized.extend_from_slice(encoded);
235
+ offsets.extend(std::iter::repeat(source_index).take(encoded.len()));
236
+ }
237
+
238
+ fn normalize_source_char(
239
+ source_character: char,
240
+ source_index: usize,
241
+ normalized: &mut Vec<u8>,
242
+ offsets: &mut Vec<usize>,
243
+ pending_space: &mut bool,
244
+ ) {
245
+ if source_character.is_ascii() {
246
+ let mut byte = (source_character as u8).to_ascii_lowercase();
247
+ if is_separator_ascii(byte) {
248
+ byte = b' ';
249
+ } else if byte == b'`' {
250
+ byte = b'\'';
251
+ }
252
+ if is_python_ascii_space(byte) {
253
+ *pending_space = !normalized.is_empty();
254
+ return;
255
+ }
256
+ if *pending_space {
257
+ normalized.push(b' ');
258
+ offsets.push(source_index);
259
+ *pending_space = false;
260
+ }
261
+ normalized.push(byte);
262
+ offsets.push(source_index);
263
+ } else if let Some(mapped) = mapped_bytes(source_character) {
264
+ let mapped = std::str::from_utf8(mapped).expect("generated Unicode pool is valid UTF-8");
265
+ for character in mapped.chars() {
266
+ normalize_post_char(character, source_index, normalized, offsets, pending_space);
267
+ }
268
+ } else {
269
+ normalize_post_char(
270
+ source_character,
271
+ source_index,
272
+ normalized,
273
+ offsets,
274
+ pending_space,
275
+ );
276
+ }
277
+ }
278
+
279
+ fn normalize_chars_into(
280
+ chars: &[char],
281
+ source_base: usize,
282
+ normalized: &mut Vec<u8>,
283
+ offsets: &mut Vec<usize>,
284
+ ) {
285
+ normalized.clear();
286
+ offsets.clear();
287
+ normalized.reserve(chars.len());
288
+ offsets.reserve(chars.len());
289
+ let mut pending_space = false;
290
+ for (local_index, source_character) in chars.iter().copied().enumerate() {
291
+ let source_index = source_base + local_index;
292
+ normalize_source_char(
293
+ source_character,
294
+ source_index,
295
+ normalized,
296
+ offsets,
297
+ &mut pending_space,
298
+ );
299
+ }
300
+ }
301
+
302
+ #[inline(always)]
303
+ fn normalize_ascii_byte(mut byte: u8) -> u8 {
304
+ byte.make_ascii_lowercase();
305
+ if is_separator_ascii(byte) {
306
+ b' '
307
+ } else if byte == b'`' {
308
+ b'\''
309
+ } else {
310
+ byte
311
+ }
312
+ }
313
+
314
+ fn normalize_ascii_text_into(text: &str, normalized: &mut Vec<u8>, offsets: &mut Vec<usize>) {
315
+ debug_assert!(text.is_ascii());
316
+ let mut pending_space = false;
317
+ for (source_index, source_byte) in text.bytes().enumerate() {
318
+ let byte = normalize_ascii_byte(source_byte);
319
+ if is_python_ascii_space(byte) {
320
+ pending_space = !normalized.is_empty();
321
+ continue;
322
+ }
323
+ if pending_space {
324
+ normalized.push(b' ');
325
+ offsets.push(source_index);
326
+ pending_space = false;
327
+ }
328
+ normalized.push(byte);
329
+ offsets.push(source_index);
330
+ }
331
+ }
332
+
333
+ fn normalize_text_into(
334
+ text: &str,
335
+ normalized: &mut Vec<u8>,
336
+ offsets: &mut Vec<usize>,
337
+ ) -> (bool, bool) {
338
+ normalized.clear();
339
+ offsets.clear();
340
+ normalized.reserve(text.len());
341
+ offsets.reserve(text.len());
342
+ if text.is_ascii() {
343
+ normalize_ascii_text_into(text, normalized, offsets);
344
+ return (true, false);
345
+ }
346
+ let mut pending_space = false;
347
+ let mut all_ascii = true;
348
+ let mut exceptional_ignorecase = false;
349
+ for (source_index, source_character) in text.chars().enumerate() {
350
+ all_ascii &= source_character.is_ascii();
351
+ exceptional_ignorecase |= matches!(
352
+ source_character,
353
+ '\u{0130}' | '\u{0131}' | '\u{017f}' | '\u{212a}'
354
+ );
355
+ normalize_source_char(
356
+ source_character,
357
+ source_index,
358
+ normalized,
359
+ offsets,
360
+ &mut pending_space,
361
+ );
362
+ }
363
+ (all_ascii, exceptional_ignorecase)
364
+ }
365
+
366
+ #[inline]
367
+ fn normalized_contains(haystack: &[u8], needle: &[u8]) -> bool {
368
+ if needle.is_empty() || haystack.len() < needle.len() {
369
+ return false;
370
+ }
371
+ let mut cursor = 0usize;
372
+ while cursor + needle.len() <= haystack.len() {
373
+ if let Some(relative) = haystack[cursor..]
374
+ .windows(needle.len())
375
+ .position(|window| window == needle)
376
+ {
377
+ let start = cursor + relative;
378
+ let end = start + needle.len();
379
+ let left_ok = start == 0 || !is_token_byte(haystack[start - 1]);
380
+ let right_ok = end == haystack.len() || !is_token_byte(haystack[end]);
381
+ if left_ok && right_ok {
382
+ return true;
383
+ }
384
+ cursor = start + 1;
385
+ } else {
386
+ return false;
387
+ }
388
+ }
389
+ false
390
+ }
391
+
392
+ fn normalized_window_into(
393
+ chars: &[char],
394
+ start: usize,
395
+ end: usize,
396
+ width: usize,
397
+ normalized: &mut Vec<u8>,
398
+ offsets: &mut Vec<usize>,
399
+ ) {
400
+ let left = start.saturating_sub(width);
401
+ let right = (end + width).min(chars.len());
402
+ normalize_chars_into(&chars[left..right], left, normalized, offsets);
403
+ }
404
+
405
+ fn context_status(
406
+ chars: &[char],
407
+ start: usize,
408
+ end: usize,
409
+ rule: &ContextRule,
410
+ window: &mut Vec<u8>,
411
+ window_offsets: &mut Vec<usize>,
412
+ ) -> u8 {
413
+ // 0=satisfied, 1=explicit_collision_exclusion, 2=context_clause_not_satisfied
414
+ normalized_window_into(chars, start, end, rule.window_chars, window, window_offsets);
415
+ if rule
416
+ .reject_terms
417
+ .iter()
418
+ .any(|term| normalized_contains(window, term))
419
+ {
420
+ return 1;
421
+ }
422
+ if rule
423
+ .accept_clauses
424
+ .iter()
425
+ .any(|clause| clause.iter().all(|term| normalized_contains(window, term)))
426
+ {
427
+ 0
428
+ } else {
429
+ 2
430
+ }
431
+ }
432
+
433
+ #[inline(always)]
434
+ fn py_ignorecase_ascii_literal_eq(pattern: char, observed: char) -> bool {
435
+ let lower = pattern.to_ascii_lowercase();
436
+ if observed.is_ascii() {
437
+ return observed.to_ascii_lowercase() == lower;
438
+ }
439
+ match lower {
440
+ 'i' => matches!(observed, 'İ' | 'ı'),
441
+ 's' => observed == 'ſ',
442
+ 'k' => observed == 'K',
443
+ _ => false,
444
+ }
445
+ }
446
+
447
+ fn exact_matches(chars: &[char], spec: &ExactSpec) -> Vec<(usize, usize)> {
448
+ let width = spec.value.len();
449
+ if width == 0 || width > chars.len() {
450
+ return Vec::new();
451
+ }
452
+ let mut output = Vec::new();
453
+ for start in 0..=chars.len() - width {
454
+ let end = start + width;
455
+ if start > 0 && is_exact_token_char(chars[start - 1], spec.mode) {
456
+ continue;
457
+ }
458
+ if end < chars.len() && is_exact_token_char(chars[end], spec.mode) {
459
+ continue;
460
+ }
461
+ let matched = spec.value.iter().enumerate().all(|(offset, expected)| {
462
+ let observed = chars[start + offset];
463
+ match spec.mode {
464
+ ExactMode::CaseSensitive => observed == *expected,
465
+ ExactMode::PythonIgnoreCaseAsciiLiteral => {
466
+ py_ignorecase_ascii_literal_eq(*expected, observed)
467
+ }
468
+ }
469
+ });
470
+ if matched {
471
+ output.push((start, end));
472
+ }
473
+ }
474
+ output
475
+ }
476
+
477
+ #[inline(always)]
478
+ fn exact_boundaries_match(text: &str, start: usize, end: usize, mode: ExactMode) -> bool {
479
+ let left_ok = start == 0
480
+ || !is_exact_token_char(
481
+ text[..start]
482
+ .chars()
483
+ .next_back()
484
+ .expect("nonempty exact left boundary"),
485
+ mode,
486
+ );
487
+ let right_ok = end == text.len()
488
+ || !is_exact_token_char(
489
+ text[end..]
490
+ .chars()
491
+ .next()
492
+ .expect("nonempty exact right boundary"),
493
+ mode,
494
+ );
495
+ left_ok && right_ok
496
+ }
497
+
498
+ impl ExactAutomaton {
499
+ fn append_matches(&self, text: &str, mode: ExactMode, output: &mut Vec<ExactByteMatch>) {
500
+ for matched in self.automaton.find_overlapping_iter(text.as_bytes()) {
501
+ let start = matched.start();
502
+ let end = matched.end();
503
+ if !exact_boundaries_match(text, start, end, mode) {
504
+ continue;
505
+ }
506
+ for &spec_index in &self.pattern_specs[matched.pattern().as_usize()] {
507
+ output.push(ExactByteMatch {
508
+ spec_index,
509
+ start,
510
+ end,
511
+ });
512
+ }
513
+ }
514
+ }
515
+ }
516
+
517
+ fn build_exact_automaton(
518
+ specs: &[ExactSpec],
519
+ mode: ExactMode,
520
+ ascii_case_insensitive: bool,
521
+ ) -> PyResult<Option<ExactAutomaton>> {
522
+ let mut patterns = Vec::<String>::new();
523
+ let mut pattern_specs = Vec::<Vec<usize>>::new();
524
+ let mut pattern_ids = HashMap::<String, usize>::new();
525
+
526
+ for (spec_index, spec) in specs.iter().enumerate() {
527
+ if spec.mode != mode {
528
+ continue;
529
+ }
530
+ let value: String = spec.value.iter().collect();
531
+ let pattern_key = if ascii_case_insensitive {
532
+ value.to_ascii_lowercase()
533
+ } else {
534
+ value.clone()
535
+ };
536
+ if let Some(&pattern_id) = pattern_ids.get(&pattern_key) {
537
+ pattern_specs[pattern_id].push(spec_index);
538
+ } else {
539
+ let pattern_id = patterns.len();
540
+ pattern_ids.insert(pattern_key, pattern_id);
541
+ patterns.push(value);
542
+ pattern_specs.push(vec![spec_index]);
543
+ }
544
+ }
545
+
546
+ if patterns.is_empty() {
547
+ return Ok(None);
548
+ }
549
+ let automaton = AhoCorasickBuilder::new()
550
+ .match_kind(MatchKind::Standard)
551
+ .ascii_case_insensitive(ascii_case_insensitive)
552
+ .build(&patterns)
553
+ .map_err(|error| PyValueError::new_err(error.to_string()))?;
554
+ Ok(Some(ExactAutomaton {
555
+ automaton,
556
+ pattern_specs,
557
+ }))
558
+ }
559
+
560
+ impl ExactSpecs {
561
+ fn new(specs: Vec<ExactSpec>) -> PyResult<Self> {
562
+ let case_sensitive = build_exact_automaton(&specs, ExactMode::CaseSensitive, false)?;
563
+ let ignorecase_ascii =
564
+ build_exact_automaton(&specs, ExactMode::PythonIgnoreCaseAsciiLiteral, true)?;
565
+ Ok(Self {
566
+ specs,
567
+ case_sensitive,
568
+ ignorecase_ascii,
569
+ })
570
+ }
571
+
572
+ fn append_matching(
573
+ &self,
574
+ text: &str,
575
+ byte_matches: &mut Vec<ExactByteMatch>,
576
+ output: &mut Vec<(usize, usize, usize)>,
577
+ exceptional_chars: &mut Vec<char>,
578
+ boundaries: &mut Vec<usize>,
579
+ text_is_ascii: bool,
580
+ exceptional_ignorecase: bool,
581
+ ) {
582
+ byte_matches.clear();
583
+ output.clear();
584
+ exceptional_chars.clear();
585
+ boundaries.clear();
586
+ if let Some(automaton) = &self.case_sensitive {
587
+ automaton.append_matches(text, ExactMode::CaseSensitive, byte_matches);
588
+ }
589
+
590
+ if !exceptional_ignorecase {
591
+ if let Some(automaton) = &self.ignorecase_ascii {
592
+ automaton.append_matches(
593
+ text,
594
+ ExactMode::PythonIgnoreCaseAsciiLiteral,
595
+ byte_matches,
596
+ );
597
+ }
598
+ }
599
+
600
+ output.reserve(byte_matches.len());
601
+ if text_is_ascii {
602
+ output.extend(
603
+ byte_matches
604
+ .iter()
605
+ .copied()
606
+ .map(|matched| (matched.spec_index, matched.start, matched.end)),
607
+ );
608
+ } else if !byte_matches.is_empty() {
609
+ // Aho-Corasick reports UTF-8 byte offsets while the frozen Python
610
+ // surface reports code-point offsets. Build this map only for the
611
+ // uncommon non-ASCII row that actually produced an exact hit.
612
+ boundaries.extend(text.char_indices().map(|(offset, _)| offset));
613
+ boundaries.push(text.len());
614
+ output.extend(byte_matches.iter().copied().map(|matched| {
615
+ let start = boundaries
616
+ .binary_search(&matched.start)
617
+ .expect("exact match starts at a UTF-8 character boundary");
618
+ let end = boundaries
619
+ .binary_search(&matched.end)
620
+ .expect("exact match ends at a UTF-8 character boundary");
621
+ (matched.spec_index, start, end)
622
+ }));
623
+ }
624
+
625
+ if exceptional_ignorecase {
626
+ // Python re.IGNORECASE gives ASCII I/S/K four additional Unicode
627
+ // equivalents (İ, ı, ſ, K). The common ASCII automaton cannot change
628
+ // byte width while matching, so retain the exact scalar fallback
629
+ // only for rows that contain one of those exceptional code points.
630
+ exceptional_chars.extend(text.chars());
631
+ for (spec_index, spec) in self.specs.iter().enumerate() {
632
+ if spec.mode != ExactMode::PythonIgnoreCaseAsciiLiteral {
633
+ continue;
634
+ }
635
+ output.extend(
636
+ exact_matches(exceptional_chars, spec)
637
+ .into_iter()
638
+ .map(|(start, end)| (spec_index, start, end)),
639
+ );
640
+ }
641
+ }
642
+ }
643
+
644
+ #[cfg(test)]
645
+ fn matching(&self, text: &str) -> Vec<(usize, usize, usize)> {
646
+ let mut byte_matches = Vec::new();
647
+ let mut output = Vec::new();
648
+ let mut exceptional_chars = Vec::new();
649
+ let mut boundaries = Vec::new();
650
+ let mut normalized = Vec::new();
651
+ let mut offsets = Vec::new();
652
+ let (text_is_ascii, exceptional_ignorecase) =
653
+ normalize_text_into(text, &mut normalized, &mut offsets);
654
+ self.append_matching(
655
+ text,
656
+ &mut byte_matches,
657
+ &mut output,
658
+ &mut exceptional_chars,
659
+ &mut boundaries,
660
+ text_is_ascii,
661
+ exceptional_ignorecase,
662
+ );
663
+ output
664
+ }
665
+ }
666
+
667
+ fn build_automaton(patterns: &[String]) -> PyResult<AhoCorasick> {
668
+ if patterns.iter().any(|pattern| pattern.is_empty()) {
669
+ return Err(PyValueError::new_err("patterns must be non-empty"));
670
+ }
671
+ AhoCorasickBuilder::new()
672
+ .match_kind(MatchKind::Standard)
673
+ .build(patterns)
674
+ .map_err(|error| PyValueError::new_err(error.to_string()))
675
+ }
676
+
677
+ fn scan_one(
678
+ automaton: &AhoCorasick,
679
+ pattern_consumers: &[Vec<usize>],
680
+ consumers: &[ConsumerSpec],
681
+ exact_specs: &ExactSpecs,
682
+ aliases: &[AliasSpec],
683
+ lexical: &[LexicalSpec],
684
+ contexts: &[ContextRule],
685
+ conjunctions: &[ConjunctionSpec],
686
+ text: &str,
687
+ observed_epoch: i64,
688
+ row_index: usize,
689
+ scratch: &mut ScanScratch,
690
+ ) -> Option<FactRow> {
691
+ scratch.reset();
692
+ let (text_is_ascii, exceptional_ignorecase) =
693
+ normalize_text_into(text, &mut scratch.normalized, &mut scratch.offsets);
694
+ let mut native_candidate = false;
695
+ if !scratch.normalized.is_empty() {
696
+ for matched in automaton.find_overlapping_iter(&scratch.normalized) {
697
+ let start = matched.start();
698
+ let end = matched.end();
699
+ let left_ok = start == 0 || !is_token_byte(scratch.normalized[start - 1]);
700
+ let right_ok =
701
+ end == scratch.normalized.len() || !is_token_byte(scratch.normalized[end]);
702
+ if !left_ok || !right_ok {
703
+ continue;
704
+ }
705
+ native_candidate = true;
706
+ let original_start = scratch.offsets[start];
707
+ let original_end = scratch.offsets[end - 1] + 1;
708
+ for &consumer_id in &pattern_consumers[matched.pattern().as_usize()] {
709
+ let spec = &consumers[consumer_id];
710
+ match spec.kind {
711
+ ALIAS => {
712
+ scratch
713
+ .alias_candidates
714
+ .push((spec.a, original_start, original_end));
715
+ }
716
+ LEXICAL => {
717
+ scratch
718
+ .lexical_candidates
719
+ .push((spec.a, original_start, original_end));
720
+ }
721
+ CONJUNCTION => {
722
+ scratch.conjunction_hits.insert((spec.a, spec.b, spec.c));
723
+ }
724
+ _ => unreachable!("validated consumer kind"),
725
+ }
726
+ }
727
+ }
728
+ }
729
+
730
+ let mut exact_candidate = false;
731
+ exact_specs.append_matching(
732
+ text,
733
+ &mut scratch.exact_byte_matches,
734
+ &mut scratch.exact_matches,
735
+ &mut scratch.chars,
736
+ &mut scratch.exact_boundaries,
737
+ text_is_ascii,
738
+ exceptional_ignorecase,
739
+ );
740
+ for &(spec_index, start, end) in &scratch.exact_matches {
741
+ exact_candidate = true;
742
+ let spec = &exact_specs.specs[spec_index];
743
+ match spec.kind {
744
+ ALIAS => scratch.alias_candidates.push((spec.index, start, end)),
745
+ LEXICAL => scratch.lexical_candidates.push((spec.index, start, end)),
746
+ CONJUNCTION => {
747
+ scratch.conjunction_hits.insert((spec.index, 0, usize::MAX));
748
+ }
749
+ _ => unreachable!("validated exact kind"),
750
+ }
751
+ }
752
+
753
+ if scratch.alias_candidates.is_empty()
754
+ && scratch.lexical_candidates.is_empty()
755
+ && scratch.conjunction_hits.is_empty()
756
+ {
757
+ return None;
758
+ }
759
+
760
+ // Normalized scanning and the exact automata do not need a materialized
761
+ // Unicode scalar vector. Build it only after the row produced a consumer;
762
+ // the common no-fact path therefore performs one traversal and no char Vec
763
+ // allocation. Exceptional Python ignorecase rows may already have filled it.
764
+ if scratch.chars.is_empty() && !text.is_empty() {
765
+ scratch.chars.extend(text.chars());
766
+ }
767
+
768
+ // Longest alias per (canonical_id, start), preserving every alias that ties.
769
+ for &(alias_index, start, end) in &scratch.alias_candidates {
770
+ let key = (aliases[alias_index].canonical_group, start);
771
+ scratch
772
+ .longest
773
+ .entry(key)
774
+ .and_modify(|value| *value = (*value).max(end))
775
+ .or_insert(end);
776
+ }
777
+
778
+ let mut accepted_nonsemantic = false;
779
+ let mut accepted_evidence = false;
780
+ let mut unresolved_exposure = false;
781
+ let mut unresolved_event = false;
782
+ let mut supported_mask: u8 = 0;
783
+
784
+ for candidate_index in 0..scratch.alias_candidates.len() {
785
+ let (alias_index, start, end) = scratch.alias_candidates[candidate_index];
786
+ let spec = &aliases[alias_index];
787
+ if end < scratch.longest[&(spec.canonical_group, start)] {
788
+ continue;
789
+ }
790
+ let key = ObservationKey {
791
+ kind: ALIAS,
792
+ index: alias_index,
793
+ start,
794
+ end,
795
+ };
796
+ if !scratch.observations.insert(key) {
797
+ continue;
798
+ }
799
+
800
+ let active = observed_epoch >= spec.valid_from_epoch
801
+ && spec
802
+ .valid_to_epoch
803
+ .map(|until| observed_epoch < until)
804
+ .unwrap_or(true);
805
+ if !active {
806
+ continue;
807
+ }
808
+
809
+ let context_code = if let Some(rule_index) = spec.context_rule {
810
+ context_status(
811
+ &scratch.chars,
812
+ start,
813
+ end,
814
+ &contexts[rule_index],
815
+ &mut scratch.window,
816
+ &mut scratch.window_offsets,
817
+ )
818
+ } else {
819
+ 0
820
+ };
821
+ if context_code != 0 {
822
+ // Frozen exposure unresolved filter excludes explicit collision and
823
+ // context-clause failures. Missing context cannot occur because the
824
+ // validated config rejects unknown rules during construction.
825
+ continue;
826
+ }
827
+
828
+ accepted_nonsemantic = true;
829
+ if spec.family != FAMILY_NONE && (spec.entity_kind == 0 || spec.entity_kind == 1) {
830
+ accepted_evidence = true;
831
+ supported_mask |= 1u8 << (spec.family as u8);
832
+ } else if spec.unresolved_tag {
833
+ unresolved_exposure = true;
834
+ }
835
+ }
836
+
837
+ for candidate_index in 0..scratch.lexical_candidates.len() {
838
+ let (lexical_index, start, end) = scratch.lexical_candidates[candidate_index];
839
+ let spec = &lexical[lexical_index];
840
+ let key = ObservationKey {
841
+ kind: LEXICAL,
842
+ index: lexical_index,
843
+ start,
844
+ end,
845
+ };
846
+ if !scratch.observations.insert(key) {
847
+ continue;
848
+ }
849
+
850
+ let context_ok = if spec.context_terms.is_empty() {
851
+ true
852
+ } else {
853
+ normalized_window_into(
854
+ &scratch.chars,
855
+ start,
856
+ end,
857
+ spec.context_window_chars,
858
+ &mut scratch.window,
859
+ &mut scratch.window_offsets,
860
+ );
861
+ spec.context_terms
862
+ .iter()
863
+ .any(|term| normalized_contains(&scratch.window, term))
864
+ };
865
+
866
+ if spec.is_event {
867
+ if spec.market_wide_authorized {
868
+ accepted_evidence = true;
869
+ supported_mask |= 1u8 << FAMILY_MARKET_WIDE;
870
+ } else {
871
+ if spec.unresolved_tag {
872
+ unresolved_exposure = true;
873
+ }
874
+ if spec.has_event_family {
875
+ unresolved_event = true;
876
+ }
877
+ }
878
+ } else {
879
+ if context_ok {
880
+ accepted_nonsemantic = true;
881
+ }
882
+ if spec.market_wide_authorized && context_ok {
883
+ accepted_evidence = true;
884
+ supported_mask |= 1u8 << FAMILY_MARKET_WIDE;
885
+ } else if spec.unresolved_tag {
886
+ // Matches frozen generic lexical unresolved branch, including a
887
+ // lexical context failure, whose evidence reason is the generic
888
+ // identity-resolved-without-path code.
889
+ unresolved_exposure = true;
890
+ }
891
+ }
892
+ }
893
+
894
+ for (rule_index, rule) in conjunctions.iter().enumerate() {
895
+ let field_has = |field: usize, count: usize| -> bool {
896
+ (0..count).any(|phrase_index| {
897
+ scratch
898
+ .conjunction_hits
899
+ .contains(&(rule_index, field, phrase_index))
900
+ })
901
+ };
902
+ let actor_phrase = field_has(0, rule.actor_phrase_count);
903
+ let actor_exact = scratch
904
+ .conjunction_hits
905
+ .contains(&(rule_index, 0, usize::MAX));
906
+ let actor = actor_phrase || actor_exact;
907
+ let action = field_has(1, rule.action_phrase_count);
908
+ let object = field_has(2, rule.object_phrase_count);
909
+ let scope = field_has(3, rule.scope_phrase_count);
910
+ if actor && action && object && scope {
911
+ // Only one conjunction observation exists per configured rule.
912
+ scratch.observations.insert(ObservationKey {
913
+ kind: CONJUNCTION,
914
+ index: rule_index,
915
+ start: 0,
916
+ end: 1,
917
+ });
918
+ accepted_evidence = true;
919
+ supported_mask |= 1u8 << FAMILY_MARKET_WIDE;
920
+ }
921
+ }
922
+
923
+ if scratch.observations.is_empty() {
924
+ return None;
925
+ }
926
+ let article_status = if accepted_evidence || accepted_nonsemantic || unresolved_event {
927
+ ARTICLE_ACCEPTED
928
+ } else {
929
+ ARTICLE_REJECTED
930
+ };
931
+ let completed_elsewhere = supported_mask != 0;
932
+ let exposure_status = |family: i8| -> u8 {
933
+ if (supported_mask & (1u8 << (family as u8))) != 0 {
934
+ EXP_SUPPORTED
935
+ } else if family != FAMILY_MARKET_WIDE && !completed_elsewhere && unresolved_exposure {
936
+ EXP_PLAUSIBLE
937
+ } else {
938
+ EXP_NOT_SUPPORTED
939
+ }
940
+ };
941
+
942
+ Some((
943
+ row_index,
944
+ article_status,
945
+ exposure_status(FAMILY_SP500),
946
+ exposure_status(FAMILY_NASDAQ100),
947
+ exposure_status(FAMILY_RUSSELL2000),
948
+ exposure_status(FAMILY_MARKET_WIDE),
949
+ scratch.observations.len(),
950
+ native_candidate,
951
+ exact_candidate,
952
+ ))
953
+ }
954
+
955
+ #[pyclass(frozen, module = "range_news._native")]
956
+ struct NativeCompactFactScanner {
957
+ automaton: AhoCorasick,
958
+ pattern_consumers: Vec<Vec<usize>>,
959
+ consumers: Vec<ConsumerSpec>,
960
+ exact_specs: ExactSpecs,
961
+ aliases: Vec<AliasSpec>,
962
+ lexical: Vec<LexicalSpec>,
963
+ contexts: Vec<ContextRule>,
964
+ conjunctions: Vec<ConjunctionSpec>,
965
+ }
966
+
967
+ #[pymethods]
968
+ impl NativeCompactFactScanner {
969
+ #[new]
970
+ #[pyo3(signature = (
971
+ patterns,
972
+ pattern_consumers,
973
+ consumers,
974
+ exact_specs,
975
+ alias_specs,
976
+ lexical_specs,
977
+ context_rules,
978
+ conjunction_specs
979
+ ))]
980
+ fn new(
981
+ patterns: Vec<String>,
982
+ pattern_consumers: Vec<Vec<usize>>,
983
+ consumers: Vec<(u8, usize, usize, usize)>,
984
+ exact_specs: Vec<(String, String, u8, usize)>,
985
+ alias_specs: Vec<(usize, i64, Option<i64>, i8, u8, bool, Option<usize>)>,
986
+ lexical_specs: Vec<(usize, Vec<String>, bool, bool, bool, bool)>,
987
+ context_rules: Vec<(usize, Vec<Vec<String>>, Vec<String>)>,
988
+ conjunction_specs: Vec<(usize, usize, usize, usize)>,
989
+ ) -> PyResult<Self> {
990
+ if patterns.len() != pattern_consumers.len() {
991
+ return Err(PyValueError::new_err("pattern_consumers length mismatch"));
992
+ }
993
+ let automaton = build_automaton(&patterns)?;
994
+ let consumers = consumers
995
+ .into_iter()
996
+ .map(|(kind, a, b, c)| ConsumerSpec { kind, a, b, c })
997
+ .collect();
998
+ let mut parsed_exact = Vec::with_capacity(exact_specs.len());
999
+ for (value, mode, kind, index) in exact_specs {
1000
+ if value.is_empty() {
1001
+ return Err(PyValueError::new_err("exact value must be non-empty"));
1002
+ }
1003
+ let mode = match mode.as_str() {
1004
+ "case_sensitive" => ExactMode::CaseSensitive,
1005
+ "python_ignorecase_ascii_literal" => {
1006
+ if !value.is_ascii() {
1007
+ return Err(PyValueError::new_err("ignorecase literal must be ASCII"));
1008
+ }
1009
+ ExactMode::PythonIgnoreCaseAsciiLiteral
1010
+ }
1011
+ _ => return Err(PyValueError::new_err(format!("unknown exact mode: {mode}"))),
1012
+ };
1013
+ parsed_exact.push(ExactSpec {
1014
+ value: value.chars().collect(),
1015
+ mode,
1016
+ kind,
1017
+ index,
1018
+ });
1019
+ }
1020
+ let aliases = alias_specs
1021
+ .into_iter()
1022
+ .map(
1023
+ |(
1024
+ canonical_group,
1025
+ valid_from_epoch,
1026
+ valid_to_epoch,
1027
+ family,
1028
+ entity_kind,
1029
+ unresolved_tag,
1030
+ context_rule,
1031
+ )| AliasSpec {
1032
+ canonical_group,
1033
+ valid_from_epoch,
1034
+ valid_to_epoch,
1035
+ family,
1036
+ entity_kind,
1037
+ unresolved_tag,
1038
+ context_rule,
1039
+ },
1040
+ )
1041
+ .collect();
1042
+ let lexical = lexical_specs
1043
+ .into_iter()
1044
+ .map(
1045
+ |(
1046
+ context_window_chars,
1047
+ terms,
1048
+ is_event,
1049
+ market_wide_authorized,
1050
+ unresolved_tag,
1051
+ has_event_family,
1052
+ )| LexicalSpec {
1053
+ context_window_chars,
1054
+ context_terms: terms.into_iter().map(String::into_bytes).collect(),
1055
+ is_event,
1056
+ market_wide_authorized,
1057
+ unresolved_tag,
1058
+ has_event_family,
1059
+ },
1060
+ )
1061
+ .collect();
1062
+ let contexts = context_rules
1063
+ .into_iter()
1064
+ .map(|(window_chars, clauses, rejects)| ContextRule {
1065
+ window_chars,
1066
+ accept_clauses: clauses
1067
+ .into_iter()
1068
+ .map(|clause| clause.into_iter().map(String::into_bytes).collect())
1069
+ .collect(),
1070
+ reject_terms: rejects.into_iter().map(String::into_bytes).collect(),
1071
+ })
1072
+ .collect();
1073
+ let conjunctions = conjunction_specs
1074
+ .into_iter()
1075
+ .map(
1076
+ |(
1077
+ actor_phrase_count,
1078
+ action_phrase_count,
1079
+ object_phrase_count,
1080
+ scope_phrase_count,
1081
+ )| ConjunctionSpec {
1082
+ actor_phrase_count,
1083
+ action_phrase_count,
1084
+ object_phrase_count,
1085
+ scope_phrase_count,
1086
+ },
1087
+ )
1088
+ .collect();
1089
+ let exact_specs = ExactSpecs::new(parsed_exact)?;
1090
+ Ok(Self {
1091
+ automaton,
1092
+ pattern_consumers,
1093
+ consumers,
1094
+ exact_specs,
1095
+ aliases,
1096
+ lexical,
1097
+ contexts,
1098
+ conjunctions,
1099
+ })
1100
+ }
1101
+
1102
+ #[pyo3(signature = (texts, epochs, workers=4))]
1103
+ fn screen_facts_batch(
1104
+ &self,
1105
+ py: Python<'_>,
1106
+ texts: Vec<String>,
1107
+ epochs: Vec<i64>,
1108
+ workers: usize,
1109
+ ) -> PyResult<Vec<FactRow>> {
1110
+ if texts.len() != epochs.len() {
1111
+ return Err(PyValueError::new_err("texts/epochs length mismatch"));
1112
+ }
1113
+ if workers == 0 {
1114
+ return Err(PyValueError::new_err("workers must be >= 1"));
1115
+ }
1116
+ let pool = ThreadPoolBuilder::new()
1117
+ .num_threads(workers)
1118
+ .thread_name(|index| format!("news-relevance-worker-{index}"))
1119
+ .build()
1120
+ .map_err(|error| PyValueError::new_err(error.to_string()))?;
1121
+ let automaton = &self.automaton;
1122
+ let pattern_consumers = &self.pattern_consumers;
1123
+ let consumers = &self.consumers;
1124
+ let exact_specs = &self.exact_specs;
1125
+ let aliases = &self.aliases;
1126
+ let lexical = &self.lexical;
1127
+ let contexts = &self.contexts;
1128
+ let conjunctions = &self.conjunctions;
1129
+ Ok(py.detach(move || {
1130
+ pool.install(|| {
1131
+ texts
1132
+ .par_iter()
1133
+ .zip(epochs.par_iter())
1134
+ .enumerate()
1135
+ .map_init(ScanScratch::default, |scratch, (index, (text, epoch))| {
1136
+ scan_one(
1137
+ automaton,
1138
+ pattern_consumers,
1139
+ consumers,
1140
+ exact_specs,
1141
+ aliases,
1142
+ lexical,
1143
+ contexts,
1144
+ conjunctions,
1145
+ text,
1146
+ *epoch,
1147
+ index,
1148
+ scratch,
1149
+ )
1150
+ })
1151
+ .filter_map(|row| row)
1152
+ .collect()
1153
+ })
1154
+ }))
1155
+ }
1156
+ }
1157
+
1158
+ #[cfg(test)]
1159
+ mod exact_automata_tests {
1160
+ use super::*;
1161
+
1162
+ fn spec(value: &str, mode: ExactMode, index: usize) -> ExactSpec {
1163
+ ExactSpec {
1164
+ value: value.chars().collect(),
1165
+ mode,
1166
+ kind: ALIAS,
1167
+ index,
1168
+ }
1169
+ }
1170
+
1171
+ #[test]
1172
+ fn exact_automata_equal_scalar_reference_across_boundaries_and_unicode() {
1173
+ let specs = vec![
1174
+ spec("UPRO", ExactMode::CaseSensitive, 0),
1175
+ spec("QQQ", ExactMode::PythonIgnoreCaseAsciiLiteral, 1),
1176
+ spec("iShares", ExactMode::PythonIgnoreCaseAsciiLiteral, 2),
1177
+ spec("PowerShares", ExactMode::PythonIgnoreCaseAsciiLiteral, 3),
1178
+ // Duplicate literals must still fan out to every configured consumer.
1179
+ spec("SEC", ExactMode::CaseSensitive, 4),
1180
+ spec("SEC", ExactMode::CaseSensitive, 5),
1181
+ ];
1182
+ let matcher = ExactSpecs::new(specs).expect("valid exact automata");
1183
+ let texts = [
1184
+ "UPRO upro QQQ qqq xQQQ QQQx SEC",
1185
+ "é UPRO αQQQ αSEC",
1186
+ "İShares ıSHARES iShares Powerſhares",
1187
+ "İQQQ QQQİ ſQQQ QQQK",
1188
+ "no exact literals here",
1189
+ ];
1190
+
1191
+ for text in texts {
1192
+ let chars: Vec<char> = text.chars().collect();
1193
+ let mut expected = Vec::new();
1194
+ for (spec_index, spec) in matcher.specs.iter().enumerate() {
1195
+ expected.extend(
1196
+ exact_matches(&chars, spec)
1197
+ .into_iter()
1198
+ .map(|(start, end)| (spec_index, start, end)),
1199
+ );
1200
+ }
1201
+ expected.sort_unstable();
1202
+
1203
+ let mut observed = matcher.matching(text);
1204
+ observed.sort_unstable();
1205
+ assert_eq!(observed, expected, "text={text:?}");
1206
+ assert_eq!(!observed.is_empty(), !expected.is_empty());
1207
+ }
1208
+ }
1209
+
1210
+ #[test]
1211
+ fn python_ignorecase_exceptions_remain_token_boundaries() {
1212
+ let matcher = ExactSpecs::new(vec![spec(
1213
+ "QQQ",
1214
+ ExactMode::PythonIgnoreCaseAsciiLiteral,
1215
+ 0,
1216
+ )])
1217
+ .expect("valid exact automaton");
1218
+ for text in ["İQQQ", "QQQı", "ſQQQ", "QQQK"] {
1219
+ assert!(matcher.matching(text).is_empty(), "text={text:?}");
1220
+ }
1221
+ }
1222
+
1223
+ #[test]
1224
+ fn direct_text_normalization_matches_character_slice_reference() {
1225
+ let texts = [
1226
+ "ASCII -- punctuation\tand spaces",
1227
+ "Café — ‘quoted’ / fullwidth",
1228
+ "Straße İ ı ſ K fi",
1229
+ "\u{001c}leading\u{00a0}and\u{2029}trailing\n",
1230
+ "",
1231
+ ];
1232
+ for text in texts {
1233
+ let chars: Vec<char> = text.chars().collect();
1234
+ let mut expected = Vec::new();
1235
+ let mut expected_offsets = Vec::new();
1236
+ normalize_chars_into(&chars, 0, &mut expected, &mut expected_offsets);
1237
+
1238
+ let mut observed = Vec::new();
1239
+ let mut observed_offsets = Vec::new();
1240
+ let (all_ascii, exceptional_ignorecase) =
1241
+ normalize_text_into(text, &mut observed, &mut observed_offsets);
1242
+ assert_eq!(observed, expected, "text={text:?}");
1243
+ assert_eq!(observed_offsets, expected_offsets, "text={text:?}");
1244
+ assert_eq!(
1245
+ all_ascii,
1246
+ chars.iter().all(|character| character.is_ascii())
1247
+ );
1248
+ assert_eq!(
1249
+ exceptional_ignorecase,
1250
+ chars.iter().any(|character| matches!(
1251
+ character,
1252
+ '\u{0130}' | '\u{0131}' | '\u{017f}' | '\u{212a}'
1253
+ )),
1254
+ "text={text:?}"
1255
+ );
1256
+ }
1257
+ }
1258
+
1259
+ fn assert_ascii_normalization_matches_character_reference(bytes: &[u8]) {
1260
+ let text = std::str::from_utf8(bytes).expect("ASCII bytes are valid UTF-8");
1261
+ let chars: Vec<char> = text.chars().collect();
1262
+ let mut expected = Vec::new();
1263
+ let mut expected_offsets = Vec::new();
1264
+ normalize_chars_into(&chars, 0, &mut expected, &mut expected_offsets);
1265
+
1266
+ let mut observed = Vec::new();
1267
+ let mut observed_offsets = Vec::new();
1268
+ let flags = normalize_text_into(text, &mut observed, &mut observed_offsets);
1269
+ assert_eq!(flags, (true, false), "bytes={bytes:?}");
1270
+ assert_eq!(observed, expected, "bytes={bytes:?}");
1271
+ assert_eq!(observed_offsets, expected_offsets, "bytes={bytes:?}");
1272
+ }
1273
+
1274
+ #[test]
1275
+ fn ascii_byte_path_matches_character_reference_exhaustively() {
1276
+ assert_ascii_normalization_matches_character_reference(b"");
1277
+ for first in 0u8..=0x7f {
1278
+ assert_ascii_normalization_matches_character_reference(&[first]);
1279
+ assert_ascii_normalization_matches_character_reference(&[b'x', first, b'y']);
1280
+ for second in 0u8..=0x7f {
1281
+ assert_ascii_normalization_matches_character_reference(&[first, second]);
1282
+ assert_ascii_normalization_matches_character_reference(&[
1283
+ b'x', first, second, b'y',
1284
+ ]);
1285
+ }
1286
+ }
1287
+ }
1288
+
1289
+ #[test]
1290
+ fn ascii_byte_path_preserves_collapse_and_control_semantics() {
1291
+ let tricky = [
1292
+ b' ', b'-', b'\t', b'A', b'`', b'B', b'.', b'/', b' ', 0x00, b'\\', 0x7f, b' ', b'\n',
1293
+ ];
1294
+ assert_ascii_normalization_matches_character_reference(&tricky);
1295
+
1296
+ let mut normalized = Vec::new();
1297
+ let mut offsets = Vec::new();
1298
+ let text = std::str::from_utf8(&tricky).expect("ASCII bytes are valid UTF-8");
1299
+ assert_eq!(
1300
+ normalize_text_into(text, &mut normalized, &mut offsets),
1301
+ (true, false)
1302
+ );
1303
+ assert_eq!(normalized, b"a'b \0\\\x7f");
1304
+ assert_eq!(offsets, [3, 4, 5, 9, 9, 10, 11]);
1305
+
1306
+ let python_ascii_whitespace = [
1307
+ b' ', b'\t', b'\n', 0x0b, 0x0c, b'\r', 0x1c, 0x1d, 0x1e, 0x1f,
1308
+ ];
1309
+ let separators = [
1310
+ b'-', b'.', b',', b':', b';', b'(', b')', b'[', b']', b'{', b'}', b'/',
1311
+ ];
1312
+ let mut runs = vec![b'A'];
1313
+ runs.extend_from_slice(&python_ascii_whitespace);
1314
+ runs.extend_from_slice(&separators);
1315
+ runs.push(b'Z');
1316
+ assert_ascii_normalization_matches_character_reference(&runs);
1317
+ normalize_text_into(
1318
+ std::str::from_utf8(&runs).expect("ASCII bytes are valid UTF-8"),
1319
+ &mut normalized,
1320
+ &mut offsets,
1321
+ );
1322
+ assert_eq!(normalized, b"a z");
1323
+ assert_eq!(offsets, [0, runs.len() - 1, runs.len() - 1]);
1324
+ }
1325
+
1326
+ #[test]
1327
+ fn reused_scan_scratch_does_not_leak_candidates_between_rows() {
1328
+ let automaton = build_automaton(&["alpha".to_string()]).expect("valid automaton");
1329
+ let pattern_consumers = vec![vec![0]];
1330
+ let consumers = vec![ConsumerSpec {
1331
+ kind: ALIAS,
1332
+ a: 0,
1333
+ b: 0,
1334
+ c: 0,
1335
+ }];
1336
+ let exact_specs = ExactSpecs::new(Vec::new()).expect("empty exact specs are valid");
1337
+ let aliases = vec![AliasSpec {
1338
+ canonical_group: 0,
1339
+ context_rule: None,
1340
+ valid_from_epoch: 0,
1341
+ valid_to_epoch: None,
1342
+ family: FAMILY_SP500,
1343
+ entity_kind: 0,
1344
+ unresolved_tag: false,
1345
+ }];
1346
+ let mut scratch = ScanScratch::default();
1347
+ let scan = |text: &str, row_index: usize, scratch: &mut ScanScratch| {
1348
+ scan_one(
1349
+ &automaton,
1350
+ &pattern_consumers,
1351
+ &consumers,
1352
+ &exact_specs,
1353
+ &aliases,
1354
+ &[],
1355
+ &[],
1356
+ &[],
1357
+ text,
1358
+ 1,
1359
+ row_index,
1360
+ scratch,
1361
+ )
1362
+ };
1363
+
1364
+ let first = scan("Alpha rises", 0, &mut scratch).expect("first row matches");
1365
+ assert_eq!(first.0, 0);
1366
+ assert_eq!(first.6, 1);
1367
+ assert!(scan("nothing relevant", 1, &mut scratch).is_none());
1368
+ let third = scan("alpha again", 2, &mut scratch).expect("third row matches");
1369
+ assert_eq!(third.0, 2);
1370
+ assert_eq!(third.6, 1);
1371
+ }
1372
+ }
rust/generated/python_whitespace.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6c314375b6c075589b515139e50068d72c0cbd7daf4246e555a9dcfc9748ecc9
3
+ size 139264
rust/generated/unicode_index.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fe152ac184aa77c40d0f040ba725140b17d845bae9fae255b2b3d3c43bb79c8f
3
+ size 4456448
rust/generated/unicode_meta.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ {"index_bytes":4456448,"mapped_codepoints":6179,"normalization":"per-codepoint NFKC then casefold","pool_bytes":29973,"python":"3.10.18","sha256":"a85f34b8ec50dfe2c5cd7a38b7ed38e65df635a1b197cf7607fce8f5fb5c23ed","unicode":"13.0.0","unicode_limit":1114112,"whitespace_bytes":139264}
rust/generated/unicode_pool.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:109a8da1563408ad1da82919a5dfa1ab9fa18870f0f4d22e2818857b5a4fc30f
3
+ size 29973
rust/lib.rs ADDED
@@ -0,0 +1,321 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // The Python-list and Arrow entry points share one exact scanning core and ship
2
+ // in a single private extension module.
3
+ include!("core.rs");
4
+
5
+ use arrow_array::{
6
+ Array, ArrayRef, BooleanArray, LargeStringArray, RecordBatch, StringArray,
7
+ TimestampMicrosecondArray, TimestampMillisecondArray, TimestampNanosecondArray,
8
+ TimestampSecondArray, UInt32Array, UInt64Array, UInt8Array,
9
+ };
10
+ use arrow_schema::{DataType, Field, Schema, TimeUnit};
11
+ use pyo3_arrow::input::AnyRecordBatch;
12
+ use pyo3_arrow::PyRecordBatch;
13
+ use std::sync::{Arc, Mutex, OnceLock};
14
+
15
+ static ARROW_POOLS: OnceLock<Mutex<HashMap<usize, Arc<rayon::ThreadPool>>>> = OnceLock::new();
16
+
17
+ fn worker_pool(workers: usize) -> PyResult<Arc<rayon::ThreadPool>> {
18
+ if workers == 0 {
19
+ return Err(PyValueError::new_err("workers must be >= 1"));
20
+ }
21
+ let pools = ARROW_POOLS.get_or_init(|| Mutex::new(HashMap::new()));
22
+ if let Some(pool) = pools
23
+ .lock()
24
+ .map_err(|_| PyValueError::new_err("Arrow worker-pool cache poisoned"))?
25
+ .get(&workers)
26
+ .cloned()
27
+ {
28
+ return Ok(pool);
29
+ }
30
+
31
+ let pool = Arc::new(
32
+ ThreadPoolBuilder::new()
33
+ .num_threads(workers)
34
+ .thread_name(|index| format!("news-relevance-arrow-{index}"))
35
+ .build()
36
+ .map_err(|error| PyValueError::new_err(error.to_string()))?,
37
+ );
38
+ let mut guard = pools
39
+ .lock()
40
+ .map_err(|_| PyValueError::new_err("Arrow worker-pool cache poisoned"))?;
41
+ Ok(guard.entry(workers).or_insert_with(|| pool.clone()).clone())
42
+ }
43
+
44
+ enum TextColumn<'a> {
45
+ Utf8(&'a StringArray),
46
+ LargeUtf8(&'a LargeStringArray),
47
+ }
48
+
49
+ impl<'a> TextColumn<'a> {
50
+ #[inline(always)]
51
+ fn value(&self, index: usize) -> &'a str {
52
+ match self {
53
+ Self::Utf8(values) => values.value(index),
54
+ Self::LargeUtf8(values) => values.value(index),
55
+ }
56
+ }
57
+ }
58
+
59
+ enum TimestampColumn<'a> {
60
+ Second(&'a TimestampSecondArray),
61
+ Millisecond(&'a TimestampMillisecondArray),
62
+ Microsecond(&'a TimestampMicrosecondArray),
63
+ Nanosecond(&'a TimestampNanosecondArray),
64
+ }
65
+
66
+ impl<'a> TimestampColumn<'a> {
67
+ #[inline(always)]
68
+ fn value_microseconds(&self, index: usize) -> Result<i64, String> {
69
+ match self {
70
+ Self::Second(values) => values
71
+ .value(index)
72
+ .checked_mul(1_000_000)
73
+ .ok_or_else(|| "timestamp second value overflows microseconds".to_string()),
74
+ Self::Millisecond(values) => values
75
+ .value(index)
76
+ .checked_mul(1_000)
77
+ .ok_or_else(|| "timestamp millisecond value overflows microseconds".to_string()),
78
+ Self::Microsecond(values) => Ok(values.value(index)),
79
+ Self::Nanosecond(values) => Ok(values.value(index) / 1_000),
80
+ }
81
+ }
82
+ }
83
+
84
+ fn text_column(array: &dyn Array) -> Result<TextColumn<'_>, String> {
85
+ if array.null_count() != 0 {
86
+ return Err("Arrow text column must not contain nulls".to_string());
87
+ }
88
+ match array.data_type() {
89
+ DataType::Utf8 => Ok(TextColumn::Utf8(
90
+ array
91
+ .as_any()
92
+ .downcast_ref::<StringArray>()
93
+ .ok_or_else(|| "failed to downcast Utf8 text column".to_string())?,
94
+ )),
95
+ DataType::LargeUtf8 => Ok(TextColumn::LargeUtf8(
96
+ array
97
+ .as_any()
98
+ .downcast_ref::<LargeStringArray>()
99
+ .ok_or_else(|| "failed to downcast LargeUtf8 text column".to_string())?,
100
+ )),
101
+ other => Err(format!(
102
+ "Arrow text column must be Utf8 or LargeUtf8, got {other}"
103
+ )),
104
+ }
105
+ }
106
+
107
+ fn timestamp_column(array: &dyn Array) -> Result<TimestampColumn<'_>, String> {
108
+ if array.null_count() != 0 {
109
+ return Err("Arrow timestamp column must not contain nulls".to_string());
110
+ }
111
+ let (unit, timezone) = match array.data_type() {
112
+ DataType::Timestamp(unit, timezone) => (unit, timezone),
113
+ other => {
114
+ return Err(format!(
115
+ "Arrow timestamp column must be a timestamp type, got {other}"
116
+ ))
117
+ }
118
+ };
119
+ if timezone.is_none() {
120
+ return Err(
121
+ "Arrow-native fast path requires a timezone-aware timestamp column; \
122
+ use America/New_York (or another explicit timezone) to preserve \
123
+ exact effective-date semantics"
124
+ .to_string(),
125
+ );
126
+ }
127
+ match unit {
128
+ TimeUnit::Second => Ok(TimestampColumn::Second(
129
+ array
130
+ .as_any()
131
+ .downcast_ref::<TimestampSecondArray>()
132
+ .ok_or_else(|| "failed to downcast second timestamp column".to_string())?,
133
+ )),
134
+ TimeUnit::Millisecond => Ok(TimestampColumn::Millisecond(
135
+ array
136
+ .as_any()
137
+ .downcast_ref::<TimestampMillisecondArray>()
138
+ .ok_or_else(|| "failed to downcast millisecond timestamp column".to_string())?,
139
+ )),
140
+ TimeUnit::Microsecond => Ok(TimestampColumn::Microsecond(
141
+ array
142
+ .as_any()
143
+ .downcast_ref::<TimestampMicrosecondArray>()
144
+ .ok_or_else(|| "failed to downcast microsecond timestamp column".to_string())?,
145
+ )),
146
+ TimeUnit::Nanosecond => Ok(TimestampColumn::Nanosecond(
147
+ array
148
+ .as_any()
149
+ .downcast_ref::<TimestampNanosecondArray>()
150
+ .ok_or_else(|| "failed to downcast nanosecond timestamp column".to_string())?,
151
+ )),
152
+ }
153
+ }
154
+
155
+ fn facts_to_record_batch(rows: Vec<FactRow>) -> Result<RecordBatch, String> {
156
+ let mut row_index = Vec::with_capacity(rows.len());
157
+ let mut article_status = Vec::with_capacity(rows.len());
158
+ let mut sp500 = Vec::with_capacity(rows.len());
159
+ let mut nasdaq100 = Vec::with_capacity(rows.len());
160
+ let mut russell2000 = Vec::with_capacity(rows.len());
161
+ let mut market_wide = Vec::with_capacity(rows.len());
162
+ let mut observation_count = Vec::with_capacity(rows.len());
163
+ let mut native_candidate = Vec::with_capacity(rows.len());
164
+ let mut exact_candidate = Vec::with_capacity(rows.len());
165
+
166
+ for (index, article, sp, ndx, rut, market, observations, native, exact) in rows {
167
+ row_index.push(index as u64);
168
+ article_status.push(article);
169
+ sp500.push(sp);
170
+ nasdaq100.push(ndx);
171
+ russell2000.push(rut);
172
+ market_wide.push(market);
173
+ observation_count.push(observations as u32);
174
+ native_candidate.push(native);
175
+ exact_candidate.push(exact);
176
+ }
177
+
178
+ let fields = vec![
179
+ Field::new("row_index", DataType::UInt64, false),
180
+ Field::new("article_status", DataType::UInt8, false),
181
+ Field::new("sp500", DataType::UInt8, false),
182
+ Field::new("nasdaq100", DataType::UInt8, false),
183
+ Field::new("russell2000", DataType::UInt8, false),
184
+ Field::new("market_wide", DataType::UInt8, false),
185
+ Field::new("observation_count", DataType::UInt32, false),
186
+ Field::new("native_candidate", DataType::Boolean, false),
187
+ Field::new("exact_candidate", DataType::Boolean, false),
188
+ ];
189
+ let columns: Vec<ArrayRef> = vec![
190
+ Arc::new(UInt64Array::from(row_index)),
191
+ Arc::new(UInt8Array::from(article_status)),
192
+ Arc::new(UInt8Array::from(sp500)),
193
+ Arc::new(UInt8Array::from(nasdaq100)),
194
+ Arc::new(UInt8Array::from(russell2000)),
195
+ Arc::new(UInt8Array::from(market_wide)),
196
+ Arc::new(UInt32Array::from(observation_count)),
197
+ Arc::new(BooleanArray::from(native_candidate)),
198
+ Arc::new(BooleanArray::from(exact_candidate)),
199
+ ];
200
+ RecordBatch::try_new(Arc::new(Schema::new(fields)), columns).map_err(|error| error.to_string())
201
+ }
202
+
203
+ #[pyclass(frozen, module = "range_news._native")]
204
+ struct NativeArrowCompactFactScanner {
205
+ inner: NativeCompactFactScanner,
206
+ }
207
+
208
+ #[pymethods]
209
+ impl NativeArrowCompactFactScanner {
210
+ #[new]
211
+ #[pyo3(signature = (
212
+ patterns,
213
+ pattern_consumers,
214
+ consumers,
215
+ exact_specs,
216
+ alias_specs,
217
+ lexical_specs,
218
+ context_rules,
219
+ conjunction_specs
220
+ ))]
221
+ fn new(
222
+ patterns: Vec<String>,
223
+ pattern_consumers: Vec<Vec<usize>>,
224
+ consumers: Vec<(u8, usize, usize, usize)>,
225
+ exact_specs: Vec<(String, String, u8, usize)>,
226
+ alias_specs: Vec<(usize, i64, Option<i64>, i8, u8, bool, Option<usize>)>,
227
+ lexical_specs: Vec<(usize, Vec<String>, bool, bool, bool, bool)>,
228
+ context_rules: Vec<(usize, Vec<Vec<String>>, Vec<String>)>,
229
+ conjunction_specs: Vec<(usize, usize, usize, usize)>,
230
+ ) -> PyResult<Self> {
231
+ Ok(Self {
232
+ inner: NativeCompactFactScanner::new(
233
+ patterns,
234
+ pattern_consumers,
235
+ consumers,
236
+ exact_specs,
237
+ alias_specs,
238
+ lexical_specs,
239
+ context_rules,
240
+ conjunction_specs,
241
+ )?,
242
+ })
243
+ }
244
+
245
+ #[pyo3(signature = (data, text_column_name="text", timestamp_column_name="timestamp", workers=4))]
246
+ fn screen_arrow(
247
+ &self,
248
+ py: Python<'_>,
249
+ data: AnyRecordBatch,
250
+ text_column_name: &str,
251
+ timestamp_column_name: &str,
252
+ workers: usize,
253
+ ) -> PyResult<PyRecordBatch> {
254
+ if workers == 0 {
255
+ return Err(PyValueError::new_err("workers must be >= 1"));
256
+ }
257
+ let schema = data.schema()?;
258
+ let text_index = schema
259
+ .index_of(text_column_name)
260
+ .map_err(|error| PyValueError::new_err(error.to_string()))?;
261
+ let timestamp_index = schema
262
+ .index_of(timestamp_column_name)
263
+ .map_err(|error| PyValueError::new_err(error.to_string()))?;
264
+ let mut reader = data.into_reader()?;
265
+
266
+ let pool = worker_pool(workers)?;
267
+ let inner = &self.inner;
268
+
269
+ let result: Result<Vec<FactRow>, String> = py.detach(move || {
270
+ let mut output = Vec::new();
271
+ let mut row_base = 0usize;
272
+ for batch_result in &mut reader {
273
+ let batch = batch_result.map_err(|error| error.to_string())?;
274
+ let texts = text_column(batch.column(text_index).as_ref())?;
275
+ let timestamps = timestamp_column(batch.column(timestamp_index).as_ref())?;
276
+ let batch_rows: Result<Vec<FactRow>, String> = pool.install(|| {
277
+ (0..batch.num_rows())
278
+ .into_par_iter()
279
+ .map_init(ScanScratch::default, |scratch, index| {
280
+ let epoch = timestamps.value_microseconds(index)?;
281
+ Ok(scan_one(
282
+ &inner.automaton,
283
+ &inner.pattern_consumers,
284
+ &inner.consumers,
285
+ &inner.exact_specs,
286
+ &inner.aliases,
287
+ &inner.lexical,
288
+ &inner.contexts,
289
+ &inner.conjunctions,
290
+ texts.value(index),
291
+ epoch,
292
+ row_base + index,
293
+ scratch,
294
+ ))
295
+ })
296
+ .filter_map(|value: Result<Option<FactRow>, String>| match value {
297
+ Ok(Some(row)) => Some(Ok(row)),
298
+ Ok(None) => None,
299
+ Err(error) => Some(Err(error)),
300
+ })
301
+ .collect()
302
+ });
303
+ output.extend(batch_rows?);
304
+ row_base += batch.num_rows();
305
+ }
306
+ output.sort_unstable_by_key(|row| row.0);
307
+ Ok(output)
308
+ });
309
+
310
+ let record_batch = facts_to_record_batch(result.map_err(PyValueError::new_err)?)
311
+ .map_err(PyValueError::new_err)?;
312
+ Ok(PyRecordBatch::new(record_batch))
313
+ }
314
+ }
315
+
316
+ #[pymodule]
317
+ fn _native(module: &Bound<'_, PyModule>) -> PyResult<()> {
318
+ module.add_class::<NativeCompactFactScanner>()?;
319
+ module.add_class::<NativeArrowCompactFactScanner>()?;
320
+ Ok(())
321
+ }
src/range_news/__init__.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Exact, CPU-first, traceable financial-news relevance screening."""
2
+
3
+ from .api import (
4
+ RelevanceEngine,
5
+ available_backends,
6
+ predict,
7
+ predict_batch,
8
+ screen_arrow,
9
+ screen_batch,
10
+ )
11
+ from .customization import (
12
+ DefaultRanker,
13
+ DefaultTaxonomy,
14
+ GeneralEconomyTaxonomy,
15
+ Ranker,
16
+ Taxonomy,
17
+ TaxonomyBuilder,
18
+ copy_default_taxonomy,
19
+ )
20
+ from .models import (
21
+ ArrowScreeningBatch,
22
+ Article,
23
+ ArticleAdmissibilityStatus,
24
+ BackendInfo,
25
+ EvidencePath,
26
+ ExposureFamily,
27
+ ExposureRelevanceStatus,
28
+ Prediction,
29
+ PythonScreeningBatch,
30
+ RankedPrediction,
31
+ ScreeningBatch,
32
+ ScreeningResult,
33
+ )
34
+
35
+ __version__ = "0.1.0a1"
36
+
37
+ __all__ = [
38
+ "ArrowScreeningBatch",
39
+ "Article",
40
+ "ArticleAdmissibilityStatus",
41
+ "BackendInfo",
42
+ "DefaultRanker",
43
+ "DefaultTaxonomy",
44
+ "EvidencePath",
45
+ "ExposureFamily",
46
+ "ExposureRelevanceStatus",
47
+ "GeneralEconomyTaxonomy",
48
+ "Prediction",
49
+ "PythonScreeningBatch",
50
+ "RankedPrediction",
51
+ "Ranker",
52
+ "RelevanceEngine",
53
+ "ScreeningBatch",
54
+ "ScreeningResult",
55
+ "Taxonomy",
56
+ "TaxonomyBuilder",
57
+ "__version__",
58
+ "available_backends",
59
+ "copy_default_taxonomy",
60
+ "predict",
61
+ "predict_batch",
62
+ "screen_arrow",
63
+ "screen_batch",
64
+ ]
src/range_news/_core/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Private, deterministic financial-news relevance implementation."""
src/range_news/_core/accelerated/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Private high-throughput screening implementation."""
src/range_news/_core/accelerated/compact.py ADDED
@@ -0,0 +1,583 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Compact list and Arrow screening backed by the packaged Rust extension.
2
+
3
+ Rich predictions remain implemented by :class:`AcceleratedRelevanceIndicator`.
4
+ This module owns the deliberately smaller high-throughput projection: rejected
5
+ rows with no observations remain implicit, while only rows containing facts
6
+ cross the Rust/Python boundary.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Mapping, Sequence
12
+ from dataclasses import dataclass, field
13
+ from datetime import datetime, timezone
14
+ from types import MappingProxyType
15
+
16
+ from ..catalogs import normalize_financial_phrase
17
+ from ..models import (
18
+ Article,
19
+ ArticleAdmissibilityStatus,
20
+ ExposureFamily,
21
+ ExposureRelevanceStatus,
22
+ RelevanceDecision,
23
+ parse_article_time,
24
+ )
25
+ from .indicator import AcceleratedRelevanceIndicator
26
+
27
+ try:
28
+ from ... import _native
29
+ except ImportError:
30
+ _native = None
31
+
32
+ TimestampLike = datetime | str
33
+
34
+ _ALIAS = 0
35
+ _LEXICAL = 1
36
+ _CONJUNCTION = 2
37
+
38
+ _FAMILY_CODE = {
39
+ None: -1,
40
+ ExposureFamily.SP500: 0,
41
+ ExposureFamily.NASDAQ100: 1,
42
+ ExposureFamily.RUSSELL2000: 2,
43
+ ExposureFamily.MARKET_WIDE: 3,
44
+ }
45
+ _ARTICLE_STATUS = {
46
+ 0: ArticleAdmissibilityStatus.ACCEPTED,
47
+ 2: ArticleAdmissibilityStatus.REJECTED,
48
+ }
49
+ _EXPOSURE_STATUS = {
50
+ 0: ExposureRelevanceStatus.SUPPORTED,
51
+ 1: ExposureRelevanceStatus.PLAUSIBLE_UNRESOLVED,
52
+ 2: ExposureRelevanceStatus.NOT_SUPPORTED,
53
+ }
54
+ _UNRESOLVED_TAGS = {
55
+ "direct_security",
56
+ "constituent_or_issuer",
57
+ "sector_or_industry",
58
+ "company_event",
59
+ "semantic_financial_other",
60
+ }
61
+ _EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
62
+
63
+
64
+ def _epoch_microseconds(value: TimestampLike) -> int:
65
+ observed = parse_article_time(value).astimezone(timezone.utc)
66
+ delta = observed - _EPOCH
67
+ return (
68
+ delta.days * 86_400_000_000
69
+ + delta.seconds * 1_000_000
70
+ + delta.microseconds
71
+ )
72
+
73
+
74
+ def _length(name: str, value: Sequence[object] | None, expected: int) -> None:
75
+ if value is not None and len(value) != expected:
76
+ raise ValueError(f"{name} length {len(value)} does not match {expected}")
77
+
78
+
79
+ @dataclass(frozen=True, slots=True)
80
+ class ScreeningResult:
81
+ """One compact result from materialized list screening."""
82
+
83
+ article_id: str
84
+ content_id: str
85
+ timestamp: datetime
86
+ article_status: ArticleAdmissibilityStatus
87
+ exposures: Mapping[ExposureFamily, ExposureRelevanceStatus]
88
+ observation_count: int = 0
89
+ resolution_count: int = 0
90
+
91
+ def __post_init__(self) -> None:
92
+ object.__setattr__(self, "exposures", MappingProxyType(dict(self.exposures)))
93
+
94
+ @classmethod
95
+ def from_decision(cls, decision: RelevanceDecision) -> "ScreeningResult":
96
+ return cls(
97
+ article_id=decision.article.article_id,
98
+ content_id=decision.article.content_id or decision.article.article_id,
99
+ timestamp=decision.article.timestamp,
100
+ article_status=decision.article_decision.status,
101
+ exposures={
102
+ family: decision.exposure_decisions[family].status
103
+ for family in ExposureFamily
104
+ },
105
+ observation_count=len(decision.observations),
106
+ resolution_count=len(decision.resolutions),
107
+ )
108
+
109
+ def as_dict(self) -> dict[str, object]:
110
+ """Return a serialization-friendly row."""
111
+
112
+ return {
113
+ "article_id": self.article_id,
114
+ "content_id": self.content_id,
115
+ "timestamp": self.timestamp,
116
+ "article_status": self.article_status.value,
117
+ **{
118
+ family.value: self.exposures[family].value
119
+ for family in ExposureFamily
120
+ },
121
+ "observation_count": self.observation_count,
122
+ "resolution_count": self.resolution_count,
123
+ }
124
+
125
+
126
+ @dataclass(frozen=True, slots=True)
127
+ class ColumnArticleBatch:
128
+ """Storage-neutral article columns for compact list screening."""
129
+
130
+ article_ids: Sequence[str]
131
+ timestamps: Sequence[TimestampLike]
132
+ texts: Sequence[str]
133
+ sources: Sequence[str] | None = None
134
+ content_ids: Sequence[str | None] | None = None
135
+ decision_buckets: Sequence[str | None] | None = None
136
+
137
+ def __post_init__(self) -> None:
138
+ count = len(self.article_ids)
139
+ if len(self.timestamps) != count or len(self.texts) != count:
140
+ raise ValueError("article_ids, timestamps, and texts must have equal lengths")
141
+ _length("sources", self.sources, count)
142
+ _length("content_ids", self.content_ids, count)
143
+ _length("decision_buckets", self.decision_buckets, count)
144
+
145
+ @property
146
+ def count(self) -> int:
147
+ return len(self.article_ids)
148
+
149
+ def article(self, index: int) -> Article:
150
+ article_id = self.article_ids[index]
151
+ return Article(
152
+ article_id=article_id,
153
+ content_id=(
154
+ self.content_ids[index]
155
+ if self.content_ids is not None
156
+ else article_id
157
+ ),
158
+ timestamp=self.timestamps[index],
159
+ decision_bucket=(
160
+ self.decision_buckets[index]
161
+ if self.decision_buckets is not None
162
+ else None
163
+ ),
164
+ source=self.sources[index] if self.sources is not None else "unknown",
165
+ text=self.texts[index],
166
+ )
167
+
168
+ def zero_screening(self, index: int) -> ScreeningResult:
169
+ article_id = self.article_ids[index]
170
+ content_id = (
171
+ self.content_ids[index]
172
+ if self.content_ids is not None and self.content_ids[index] is not None
173
+ else article_id
174
+ )
175
+ return ScreeningResult(
176
+ article_id=article_id,
177
+ content_id=content_id,
178
+ timestamp=parse_article_time(self.timestamps[index]),
179
+ article_status=ArticleAdmissibilityStatus.REJECTED,
180
+ exposures={
181
+ family: ExposureRelevanceStatus.NOT_SUPPORTED
182
+ for family in ExposureFamily
183
+ },
184
+ )
185
+
186
+
187
+ @dataclass(frozen=True, slots=True)
188
+ class CompactColumnScreenBatch:
189
+ """Sparse list result retaining only non-default rows as Python objects."""
190
+
191
+ batch: ColumnArticleBatch
192
+ exception_rows: tuple[tuple[int, ScreeningResult], ...]
193
+ native_candidate_count: int = 0
194
+ exact_candidate_count: int = 0
195
+
196
+ @property
197
+ def count(self) -> int:
198
+ return self.batch.count
199
+
200
+ @property
201
+ def exception_count(self) -> int:
202
+ return len(self.exception_rows)
203
+
204
+ @property
205
+ def implicit_zero_count(self) -> int:
206
+ return self.count - self.exception_count
207
+
208
+ @property
209
+ def exception_indices(self) -> tuple[int, ...]:
210
+ return tuple(index for index, _row in self.exception_rows)
211
+
212
+ def materialize(self) -> tuple[ScreeningResult, ...]:
213
+ """Expand implicit default rows into a result for every input row."""
214
+
215
+ exceptions = dict(self.exception_rows)
216
+ return tuple(
217
+ exceptions.get(index) or self.batch.zero_screening(index)
218
+ for index in range(self.count)
219
+ )
220
+
221
+
222
+ @dataclass(slots=True)
223
+ class ArrowCompactFacts:
224
+ """Sparse native Arrow result keyed by the input row index."""
225
+
226
+ row_count: int | None
227
+ facts: object
228
+ _pyarrow_cache: object | None = field(default=None, init=False, repr=False)
229
+
230
+ def to_pyarrow(self):
231
+ """Import and cache the native result as a ``pyarrow.RecordBatch``."""
232
+
233
+ if self._pyarrow_cache is None:
234
+ try:
235
+ import pyarrow as pa
236
+ except ImportError as exc:
237
+ raise RuntimeError(
238
+ "PyArrow is required to materialize Arrow screening facts"
239
+ ) from exc
240
+
241
+ if hasattr(self.facts, "__arrow_c_array__"):
242
+ self._pyarrow_cache = pa.record_batch(self.facts)
243
+ else:
244
+ method = getattr(self.facts, "into_pyarrow", None)
245
+ if method is None:
246
+ method = getattr(self.facts, "to_pyarrow", None)
247
+ if method is None:
248
+ raise RuntimeError(
249
+ "The native Arrow result does not expose a supported "
250
+ "PyArrow conversion interface"
251
+ )
252
+ self._pyarrow_cache = method()
253
+ return self._pyarrow_cache
254
+
255
+ @property
256
+ def exception_count(self) -> int:
257
+ return int(self.to_pyarrow().num_rows)
258
+
259
+ @property
260
+ def implicit_zero_count(self) -> int:
261
+ if self.row_count is None:
262
+ raise RuntimeError("row_count is unknown for this Arrow stream")
263
+ return self.row_count - self.exception_count
264
+
265
+
266
+ def _build_native_specs(indicator: AcceleratedRelevanceIndicator) -> tuple[object, ...]:
267
+ """Translate the loaded taxonomy into the native scanner contract."""
268
+
269
+ patterns = list(indicator._scanner.patterns)
270
+ pattern_ids = {pattern: index for index, pattern in enumerate(patterns)}
271
+ pattern_consumers: list[list[int]] = [[] for _ in patterns]
272
+ consumers: list[tuple[int, int, int, int]] = []
273
+
274
+ def add(pattern: str, kind: int, a: int, b: int = 0, c: int = 0) -> None:
275
+ consumer_id = len(consumers)
276
+ consumers.append((kind, a, b, c))
277
+ pattern_consumers[pattern_ids[pattern]].append(consumer_id)
278
+
279
+ canonical_groups = {
280
+ canonical_id: index
281
+ for index, canonical_id in enumerate(sorted(indicator.catalogs.entities))
282
+ }
283
+ context_ids = {
284
+ rule_id: index
285
+ for index, rule_id in enumerate(indicator.config.context_rules)
286
+ }
287
+
288
+ aliases = tuple(indicator.catalogs.aliases)
289
+ alias_specs = []
290
+ exact_specs: list[tuple[str, str, int, int]] = []
291
+ for alias_index, alias in enumerate(aliases):
292
+ entity = indicator.catalogs.entities[alias.canonical_id]
293
+ family = indicator.catalogs.exposure_family_for(
294
+ alias.canonical_id,
295
+ indicator.config,
296
+ )
297
+ entity_kind = (
298
+ 0
299
+ if entity.entity_kind == "fund"
300
+ else 1
301
+ if entity.entity_kind == "index"
302
+ else 2
303
+ )
304
+ tag_type = (
305
+ "direct_security"
306
+ if entity.entity_kind == "fund"
307
+ else "benchmark_or_index"
308
+ if entity.entity_kind == "index"
309
+ else "constituent_or_issuer"
310
+ )
311
+ alias_specs.append(
312
+ (
313
+ canonical_groups[alias.canonical_id],
314
+ _epoch_microseconds(alias.valid_from),
315
+ (
316
+ _epoch_microseconds(alias.valid_to)
317
+ if alias.valid_to is not None
318
+ else None
319
+ ),
320
+ _FAMILY_CODE[family],
321
+ entity_kind,
322
+ tag_type in _UNRESOLVED_TAGS,
323
+ (
324
+ context_ids[alias.context_rule_id]
325
+ if alias.requires_context and alias.context_rule_id is not None
326
+ else None
327
+ ),
328
+ )
329
+ )
330
+ normalized = indicator._alias_norm.get(alias.alias_id)
331
+ if normalized is not None:
332
+ add(normalized, _ALIAS, alias_index)
333
+ else:
334
+ exact_specs.append(
335
+ (
336
+ alias.surface,
337
+ (
338
+ "python_ignorecase_ascii_literal"
339
+ if alias.match_mode == "exact_casefold"
340
+ else "case_sensitive"
341
+ ),
342
+ _ALIAS,
343
+ alias_index,
344
+ )
345
+ )
346
+
347
+ lexical_specs = []
348
+ for rule in indicator.catalogs.lexical_rules:
349
+ lexical_index = len(lexical_specs)
350
+ lexical_specs.append(
351
+ (
352
+ rule.context_window_chars,
353
+ [normalize_financial_phrase(term) for term in rule.context_any],
354
+ rule.detection_method == "event_rule",
355
+ rule.market_wide_authorized,
356
+ rule.tag_type in _UNRESOLVED_TAGS,
357
+ rule.event_family is not None,
358
+ )
359
+ )
360
+ for phrase in rule.phrases:
361
+ if rule.case_sensitive:
362
+ exact_specs.append(
363
+ (phrase, "case_sensitive", _LEXICAL, lexical_index)
364
+ )
365
+ else:
366
+ add(
367
+ indicator._lex_norm[(rule.rule_id, phrase)],
368
+ _LEXICAL,
369
+ lexical_index,
370
+ )
371
+
372
+ conjunction_specs = []
373
+ field_codes = {
374
+ "actor_phrases": 0,
375
+ "action_phrases": 1,
376
+ "object_phrases": 2,
377
+ "scope_phrases": 3,
378
+ }
379
+ conjunction_rules = tuple(indicator.config.market_wide_conjunctions.items())
380
+ for rule_index, (rule_id, rule) in enumerate(conjunction_rules):
381
+ conjunction_specs.append(
382
+ (
383
+ len(rule.actor_phrases),
384
+ len(rule.action_phrases),
385
+ len(rule.object_phrases),
386
+ len(rule.scope_phrases),
387
+ )
388
+ )
389
+ for field_name, field_code in field_codes.items():
390
+ for phrase_index, phrase in enumerate(getattr(rule, field_name)):
391
+ add(
392
+ indicator._conj_norm[(rule_id, field_name, phrase)],
393
+ _CONJUNCTION,
394
+ rule_index,
395
+ field_code,
396
+ phrase_index,
397
+ )
398
+ for identifier in rule.actor_identifiers:
399
+ exact_specs.append(
400
+ (identifier, "case_sensitive", _CONJUNCTION, rule_index)
401
+ )
402
+
403
+ context_rules = [
404
+ (
405
+ rule.window_chars,
406
+ [
407
+ [normalize_financial_phrase(term) for term in clause]
408
+ for clause in rule.accept_any_clauses
409
+ ],
410
+ [normalize_financial_phrase(term) for term in rule.reject_any_terms],
411
+ )
412
+ for rule in indicator.config.context_rules.values()
413
+ ]
414
+
415
+ return (
416
+ patterns,
417
+ pattern_consumers,
418
+ consumers,
419
+ exact_specs,
420
+ alias_specs,
421
+ lexical_specs,
422
+ context_rules,
423
+ conjunction_specs,
424
+ )
425
+
426
+
427
+ class CompactRelevanceIndicator(AcceleratedRelevanceIndicator):
428
+ """Rich exact prediction plus native compact list and Arrow projections."""
429
+
430
+ def __init__(self, config=None, catalogs=None) -> None:
431
+ super().__init__(config=config, catalogs=catalogs)
432
+ self._list_scanner = None
433
+ self._arrow_scanner = None
434
+ if _native is None:
435
+ return
436
+ has_list = hasattr(_native, "NativeCompactFactScanner")
437
+ has_arrow = hasattr(_native, "NativeArrowCompactFactScanner")
438
+ if not (has_list or has_arrow):
439
+ return
440
+ native_specs = _build_native_specs(self)
441
+ if has_list:
442
+ self._list_scanner = _native.NativeCompactFactScanner(*native_specs)
443
+ if has_arrow:
444
+ self._arrow_scanner = _native.NativeArrowCompactFactScanner(*native_specs)
445
+
446
+ @property
447
+ def list_available(self) -> bool:
448
+ return self._list_scanner is not None
449
+
450
+ @property
451
+ def arrow_available(self) -> bool:
452
+ return self.list_available and self._arrow_scanner is not None
453
+
454
+ @property
455
+ def matcher_backend(self) -> str:
456
+ if self.arrow_available:
457
+ return "rust_native_compact_arrow"
458
+ if self.list_available:
459
+ return "rust_native_compact"
460
+ return super().matcher_backend
461
+
462
+ def screen_column_batch(
463
+ self,
464
+ batch: ColumnArticleBatch,
465
+ *,
466
+ workers: int = 4,
467
+ ) -> CompactColumnScreenBatch:
468
+ """Classify list-backed columns while keeping default rows implicit."""
469
+
470
+ if isinstance(workers, bool) or not isinstance(workers, int) or workers <= 0:
471
+ raise ValueError("workers must be a positive integer")
472
+ if batch.count == 0:
473
+ return CompactColumnScreenBatch(batch=batch, exception_rows=())
474
+ if self._list_scanner is None:
475
+ raise RuntimeError(
476
+ "Compact screening requires the packaged Rust extension; "
477
+ "reinstall a wheel for this platform or rebuild the package"
478
+ )
479
+
480
+ rows = self._list_scanner.screen_facts_batch(
481
+ list(batch.texts),
482
+ [_epoch_microseconds(value) for value in batch.timestamps],
483
+ workers,
484
+ )
485
+ exceptions = []
486
+ native_candidates = 0
487
+ exact_candidates = 0
488
+ for (
489
+ index,
490
+ article_status,
491
+ sp500,
492
+ nasdaq100,
493
+ russell2000,
494
+ market_wide,
495
+ observation_count,
496
+ native_candidate,
497
+ exact_candidate,
498
+ ) in rows:
499
+ index = int(index)
500
+ native_candidates += bool(native_candidate)
501
+ exact_candidates += bool(exact_candidate)
502
+ article_id = batch.article_ids[index]
503
+ content_id = (
504
+ batch.content_ids[index]
505
+ if batch.content_ids is not None
506
+ and batch.content_ids[index] is not None
507
+ else article_id
508
+ )
509
+ exceptions.append(
510
+ (
511
+ index,
512
+ ScreeningResult(
513
+ article_id=article_id,
514
+ content_id=content_id,
515
+ timestamp=parse_article_time(batch.timestamps[index]),
516
+ article_status=_ARTICLE_STATUS[int(article_status)],
517
+ exposures={
518
+ ExposureFamily.SP500: _EXPOSURE_STATUS[int(sp500)],
519
+ ExposureFamily.NASDAQ100: _EXPOSURE_STATUS[
520
+ int(nasdaq100)
521
+ ],
522
+ ExposureFamily.RUSSELL2000: _EXPOSURE_STATUS[
523
+ int(russell2000)
524
+ ],
525
+ ExposureFamily.MARKET_WIDE: _EXPOSURE_STATUS[
526
+ int(market_wide)
527
+ ],
528
+ },
529
+ observation_count=int(observation_count),
530
+ resolution_count=int(observation_count),
531
+ ),
532
+ )
533
+ )
534
+
535
+ return CompactColumnScreenBatch(
536
+ batch=batch,
537
+ exception_rows=tuple(exceptions),
538
+ native_candidate_count=native_candidates,
539
+ exact_candidate_count=exact_candidates,
540
+ )
541
+
542
+ def screen_arrow(
543
+ self,
544
+ data,
545
+ *,
546
+ text_column: str = "text",
547
+ timestamp_column: str = "timestamp",
548
+ workers: int = 4,
549
+ row_count: int | None = None,
550
+ ) -> ArrowCompactFacts:
551
+ """Screen an Arrow batch or stream through its PyCapsule interface."""
552
+
553
+ if isinstance(workers, bool) or not isinstance(workers, int) or workers <= 0:
554
+ raise ValueError("workers must be a positive integer")
555
+ if self._arrow_scanner is None:
556
+ raise RuntimeError("The packaged Arrow screening extension is unavailable")
557
+ if row_count is None:
558
+ discovered = getattr(data, "num_rows", None)
559
+ if discovered is not None:
560
+ row_count = int(discovered)
561
+ elif (
562
+ isinstance(row_count, bool)
563
+ or not isinstance(row_count, int)
564
+ or row_count < 0
565
+ ):
566
+ raise ValueError("row_count must be a non-negative integer or None")
567
+
568
+ facts = self._arrow_scanner.screen_arrow(
569
+ data,
570
+ text_column,
571
+ timestamp_column,
572
+ workers,
573
+ )
574
+ return ArrowCompactFacts(row_count=row_count, facts=facts)
575
+
576
+
577
+ __all__ = [
578
+ "ArrowCompactFacts",
579
+ "ColumnArticleBatch",
580
+ "CompactColumnScreenBatch",
581
+ "CompactRelevanceIndicator",
582
+ "ScreeningResult",
583
+ ]
src/range_news/_core/accelerated/indicator.py ADDED
@@ -0,0 +1,384 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Isolated accelerated matcher for the frozen news-relevance implementation.
2
+
3
+ The public decision logic remains in ``RelevanceIndicator``. This subclass only
4
+ replaces ``observe()`` so normalized text is built once per article and all
5
+ normalized phrases share one multi-pattern scan. Exact/context semantics and
6
+ all downstream resolution/evidence/decision code stay in the existing package.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import re
12
+ from collections import defaultdict
13
+ from collections.abc import Iterable, Sequence
14
+ from concurrent.futures import ThreadPoolExecutor
15
+
16
+ from ..catalogs import (
17
+ CatalogAlias,
18
+ _TOKEN_CHARS,
19
+ _TOKEN_PATTERN_CHARS,
20
+ _normalize_with_offsets,
21
+ context_result,
22
+ find_normalized_phrase_spans,
23
+ normalize_financial_phrase,
24
+ )
25
+ from ..indicator import RelevanceIndicator, _method_for, _stable_id
26
+ from ..models import Article, Observation, ObservationBundle, RelevanceDecision
27
+
28
+ try:
29
+ from ahocorasick_rs import AhoCorasick, Implementation
30
+ except ImportError: # optional accelerator
31
+ AhoCorasick = None
32
+ Implementation = None
33
+
34
+ Span = tuple[int, int]
35
+
36
+
37
+ class _Scanner:
38
+ def __init__(self, patterns: Iterable[str]) -> None:
39
+ self.patterns = tuple(sorted({p for p in patterns if p}))
40
+ self.automaton = None
41
+ if AhoCorasick is not None and self.patterns:
42
+ self.automaton = AhoCorasick(
43
+ self.patterns,
44
+ implementation=Implementation.DFA,
45
+ store_patterns=False,
46
+ )
47
+
48
+ @property
49
+ def backend(self) -> str:
50
+ return "rust_aho_corasick_dfa" if self.automaton is not None else "python_find"
51
+
52
+ @staticmethod
53
+ def _boundary(text: str, start: int, end: int) -> bool:
54
+ return (
55
+ (start == 0 or text[start - 1] not in _TOKEN_CHARS)
56
+ and (end == len(text) or text[end] not in _TOKEN_CHARS)
57
+ )
58
+
59
+ def scan(self, text: str) -> dict[str, tuple[Span, ...]]:
60
+ normalized, offsets = _normalize_with_offsets(text)
61
+ if not normalized:
62
+ return {}
63
+ hits: dict[int, list[Span]] = defaultdict(list)
64
+ if self.automaton is not None:
65
+ matches = self.automaton.find_matches_as_indexes(normalized, overlapping=True)
66
+ for pid, start, end in matches:
67
+ if self._boundary(normalized, start, end):
68
+ hits[int(pid)].append((start, end))
69
+ else:
70
+ for pid, pattern in enumerate(self.patterns):
71
+ cursor = 0
72
+ while True:
73
+ start = normalized.find(pattern, cursor)
74
+ if start < 0:
75
+ break
76
+ end = start + len(pattern)
77
+ if self._boundary(normalized, start, end):
78
+ hits[pid].append((start, end))
79
+ cursor = start + 1
80
+ return {
81
+ self.patterns[pid]: tuple(
82
+ (offsets[s], offsets[e - 1] + 1) for s, e in spans
83
+ )
84
+ for pid, spans in hits.items()
85
+ }
86
+
87
+
88
+ class AcceleratedRelevanceIndicator(RelevanceIndicator):
89
+ """Drop-in indicator that preserves outputs while eliminating repeated normalization."""
90
+
91
+ def __init__(self, config=None, catalogs=None) -> None:
92
+ super().__init__(config=config, catalogs=catalogs)
93
+ patterns: set[str] = set()
94
+ self._alias_norm: dict[str, str] = {}
95
+ self._alias_regex: dict[str, re.Pattern[str]] = {}
96
+ for alias in self.catalogs.aliases:
97
+ if alias.match_mode == "normalized_phrase":
98
+ key = normalize_financial_phrase(alias.surface)
99
+ self._alias_norm[alias.alias_id] = key
100
+ patterns.add(key)
101
+ else:
102
+ flags = (
103
+ 0
104
+ if alias.match_mode in {"exact_identifier", "exact_case_sensitive"}
105
+ else re.IGNORECASE
106
+ )
107
+ self._alias_regex[alias.alias_id] = re.compile(
108
+ rf"(?<![{_TOKEN_PATTERN_CHARS}])"
109
+ rf"{re.escape(alias.surface)}"
110
+ rf"(?![{_TOKEN_PATTERN_CHARS}])",
111
+ flags,
112
+ )
113
+
114
+ self._lex_norm: dict[tuple[str, str], str] = {}
115
+ self._lex_regex: dict[tuple[str, str], re.Pattern[str]] = {}
116
+ for rule in self.catalogs.lexical_rules:
117
+ for phrase in rule.phrases:
118
+ key = (rule.rule_id, phrase)
119
+ if rule.case_sensitive:
120
+ self._lex_regex[key] = re.compile(
121
+ rf"(?<![{_TOKEN_PATTERN_CHARS}])"
122
+ rf"{re.escape(phrase)}"
123
+ rf"(?![{_TOKEN_PATTERN_CHARS}])"
124
+ )
125
+ else:
126
+ value = normalize_financial_phrase(phrase)
127
+ self._lex_norm[key] = value
128
+ patterns.add(value)
129
+
130
+ self._conj_norm: dict[tuple[str, str, str], str] = {}
131
+ self._conj_regex: dict[tuple[str, str], re.Pattern[str]] = {}
132
+ for rule_id, rule in self.config.market_wide_conjunctions.items():
133
+ for field in (
134
+ "actor_phrases",
135
+ "action_phrases",
136
+ "object_phrases",
137
+ "scope_phrases",
138
+ ):
139
+ for phrase in getattr(rule, field):
140
+ value = normalize_financial_phrase(phrase)
141
+ self._conj_norm[(rule_id, field, phrase)] = value
142
+ patterns.add(value)
143
+ for identifier in rule.actor_identifiers:
144
+ self._conj_regex[(rule_id, identifier)] = re.compile(
145
+ rf"(?<![A-Za-z0-9_]){re.escape(identifier)}(?![A-Za-z0-9_])"
146
+ )
147
+ self._scanner = _Scanner(patterns)
148
+
149
+ @property
150
+ def matcher_backend(self) -> str:
151
+ return self._scanner.backend
152
+
153
+ def _alias_spans(
154
+ self,
155
+ alias: CatalogAlias,
156
+ text: str,
157
+ hits: dict[str, tuple[Span, ...]],
158
+ ) -> tuple[Span, ...]:
159
+ normalized = self._alias_norm.get(alias.alias_id)
160
+ if normalized is not None:
161
+ return hits.get(normalized, ())
162
+ return tuple(
163
+ (m.start(), m.end())
164
+ for m in self._alias_regex[alias.alias_id].finditer(text)
165
+ )
166
+
167
+ def observe(self, article: Article) -> ObservationBundle:
168
+ text = article.text
169
+ hits = self._scanner.scan(text)
170
+ observations: list[Observation] = []
171
+ candidates: list[tuple[CatalogAlias, int, int]] = []
172
+ for alias in self.catalogs.aliases:
173
+ candidates.extend(
174
+ (alias, s, e)
175
+ for s, e in self._alias_spans(alias, text, hits)
176
+ )
177
+
178
+ longest: dict[tuple[str, int], int] = {}
179
+ for alias, start, end in candidates:
180
+ key = (alias.canonical_id, start)
181
+ longest[key] = max(longest.get(key, 0), end)
182
+
183
+ for alias, start, end in candidates:
184
+ if end < longest[(alias.canonical_id, start)]:
185
+ continue
186
+ entity = self.catalogs.entities[alias.canonical_id]
187
+ reason = "context_not_required"
188
+ if alias.requires_context:
189
+ _, reason = context_result(
190
+ text,
191
+ start=start,
192
+ end=end,
193
+ rule=self.config.context_rules.get(alias.context_rule_id or ""),
194
+ )
195
+ observations.append(
196
+ Observation(
197
+ observation_id=_stable_id(
198
+ "obs2",
199
+ article.article_id,
200
+ start,
201
+ end,
202
+ entity.entity_concept,
203
+ _method_for(alias),
204
+ alias.alias_id,
205
+ ),
206
+ article_id=article.article_id,
207
+ matched_surface=text[start:end],
208
+ span_start=start,
209
+ span_end=end,
210
+ entity_concept=entity.entity_concept,
211
+ detection_method=_method_for(alias),
212
+ confidence=1.0,
213
+ candidate_canonical_id=alias.canonical_id,
214
+ catalog_entry_id=alias.alias_id,
215
+ catalog_version=self.catalogs.entity_catalog_version,
216
+ tag_type=(
217
+ "direct_security"
218
+ if entity.entity_kind == "fund"
219
+ else (
220
+ "benchmark_or_index"
221
+ if entity.entity_kind == "index"
222
+ else "constituent_or_issuer"
223
+ )
224
+ ),
225
+ context_status=reason,
226
+ source_id=alias.source_id,
227
+ )
228
+ )
229
+
230
+ for rule in self.catalogs.lexical_rules:
231
+ for phrase in rule.phrases:
232
+ key = (rule.rule_id, phrase)
233
+ spans = (
234
+ tuple(
235
+ (m.start(), m.end())
236
+ for m in self._lex_regex[key].finditer(text)
237
+ )
238
+ if rule.case_sensitive
239
+ else hits.get(self._lex_norm[key], ())
240
+ )
241
+ for start, end in spans:
242
+ context_ok = True
243
+ if rule.context_any:
244
+ window = text[
245
+ max(0, start - rule.context_window_chars) : min(
246
+ len(text), end + rule.context_window_chars
247
+ )
248
+ ]
249
+ context_ok = any(
250
+ bool(find_normalized_phrase_spans(window, term))
251
+ for term in rule.context_any
252
+ )
253
+ method = rule.detection_method
254
+ stable = (
255
+ (article.article_id, start, end, rule.rule_id)
256
+ if method == "event_rule"
257
+ else (
258
+ article.article_id,
259
+ start,
260
+ end,
261
+ rule.entity_concept,
262
+ method,
263
+ self.catalogs.lexical_catalog_version,
264
+ )
265
+ )
266
+ observations.append(
267
+ Observation(
268
+ observation_id=_stable_id(
269
+ "signal2" if method == "event_rule" else "obs2",
270
+ *stable,
271
+ ),
272
+ article_id=article.article_id,
273
+ matched_surface=text[start:end],
274
+ span_start=start,
275
+ span_end=end,
276
+ entity_concept=rule.entity_concept,
277
+ detection_method=method,
278
+ confidence=1.0,
279
+ candidate_canonical_id=rule.target_id,
280
+ catalog_version=self.catalogs.lexical_catalog_version,
281
+ rule_id=rule.rule_id,
282
+ tag_type=rule.tag_type,
283
+ event_family=rule.event_family,
284
+ market_wide_authorized=rule.market_wide_authorized,
285
+ context_status=(
286
+ "lexical_context_satisfied"
287
+ if context_ok
288
+ else "lexical_context_not_satisfied"
289
+ ),
290
+ source_id=rule.source_id,
291
+ )
292
+ )
293
+
294
+ for rule_id, rule in self.config.market_wide_conjunctions.items():
295
+ def spans(field: str) -> list[Span]:
296
+ return [
297
+ span
298
+ for phrase in getattr(rule, field)
299
+ for span in hits.get(
300
+ self._conj_norm[(rule_id, field, phrase)], ()
301
+ )
302
+ ]
303
+
304
+ actor = spans("actor_phrases")
305
+ actor.extend(
306
+ (m.start(), m.end())
307
+ for identifier in rule.actor_identifiers
308
+ for m in self._conj_regex[(rule_id, identifier)].finditer(text)
309
+ )
310
+ action = spans("action_phrases")
311
+ obj = spans("object_phrases")
312
+ scope = spans("scope_phrases")
313
+ if not all((actor, action, obj, scope)):
314
+ continue
315
+ contributing = [actor[0], action[0], obj[0], scope[0]]
316
+ start = min(s for s, _ in contributing)
317
+ end = max(e for _, e in contributing)
318
+ observations.append(
319
+ Observation(
320
+ observation_id=_stable_id(
321
+ "signal2", article.article_id, start, end, rule_id
322
+ ),
323
+ article_id=article.article_id,
324
+ matched_surface=text[start:end],
325
+ span_start=start,
326
+ span_end=end,
327
+ entity_concept=rule.entity_concept,
328
+ detection_method="event_rule",
329
+ confidence=1.0,
330
+ candidate_canonical_id=rule.target_id,
331
+ catalog_version=self.config.version,
332
+ rule_id=rule_id,
333
+ tag_type=rule.tag_type,
334
+ event_family=rule.event_family,
335
+ market_wide_authorized=True,
336
+ context_status="market_wide_conjunction_satisfied",
337
+ source_id="sec_rules_regulations",
338
+ )
339
+ )
340
+
341
+ return ObservationBundle(
342
+ article=article,
343
+ observations=tuple(
344
+ sorted(set(observations), key=lambda row: row.observation_id)
345
+ ),
346
+ )
347
+
348
+ def predict_batch(
349
+ self,
350
+ articles: Sequence[Article],
351
+ *,
352
+ workers: int = 1,
353
+ ) -> tuple[RelevanceDecision, ...]:
354
+ """Predict an ordered batch. ``workers=1`` is the lowest-overhead default."""
355
+ if isinstance(workers, bool) or not isinstance(workers, int) or workers <= 0:
356
+ raise ValueError("workers must be a positive integer")
357
+ if workers == 1 or len(articles) <= 1:
358
+ return tuple(self.predict(article) for article in articles)
359
+ count = min(workers, len(articles), os.cpu_count() or 1)
360
+ with ThreadPoolExecutor(max_workers=count) as pool:
361
+ return tuple(pool.map(self.predict, articles))
362
+
363
+
364
+ def assert_decisions_equivalent(
365
+ baseline: Sequence[RelevanceDecision],
366
+ accelerated: Sequence[RelevanceDecision],
367
+ ) -> None:
368
+ """Require exact observations, resolutions, article decisions, and exposures."""
369
+ if len(baseline) != len(accelerated):
370
+ raise AssertionError(
371
+ f"decision count differs: {len(baseline)} != {len(accelerated)}"
372
+ )
373
+ for i, (left, right) in enumerate(zip(baseline, accelerated, strict=True)):
374
+ article_id = left.article.article_id
375
+ if left.article != right.article:
376
+ raise AssertionError(f"article differs at {i}: {article_id}")
377
+ if left.observations != right.observations:
378
+ raise AssertionError(f"observations differ at {i}: {article_id}")
379
+ if left.resolutions != right.resolutions:
380
+ raise AssertionError(f"resolutions differ at {i}: {article_id}")
381
+ if left.article_decision != right.article_decision:
382
+ raise AssertionError(f"article decision differs at {i}: {article_id}")
383
+ if dict(left.exposure_decisions) != dict(right.exposure_decisions):
384
+ raise AssertionError(f"exposure decisions differ at {i}: {article_id}")
src/range_news/_core/catalogs.py ADDED
@@ -0,0 +1,470 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Catalog loading, conservative matching, and effective-date utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import unicodedata
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+ from types import MappingProxyType
12
+ from typing import Any
13
+
14
+ import yaml
15
+
16
+ from .config import ContextRule, NewsRelevanceConfig
17
+ from .models import ExposureFamily, parse_article_time
18
+
19
+ _DASHES = {"-", "‐", "‑", "‒", "–", "—", "―", "−"}
20
+ _APOSTROPHES = {"'", "’", "‘", "‛", "`", "´"}
21
+ _SEPARATOR_PUNCTUATION = set(".,:;()[]{}//")
22
+ _TOKEN_CHARS = set("abcdefghijklmnopqrstuvwxyz0123456789_")
23
+ _TOKEN_PATTERN_CHARS = r"A-Za-z0-9_"
24
+
25
+
26
+ def _as_utc(value: datetime | str) -> datetime:
27
+ return parse_article_time(value).astimezone(timezone.utc)
28
+
29
+
30
+ def active_during(
31
+ timestamp: datetime | str,
32
+ *,
33
+ valid_from: datetime | str,
34
+ valid_to: datetime | str | None = None,
35
+ ) -> bool:
36
+ """Return whether a timestamp belongs to the interval ``[from, to)``."""
37
+
38
+ start = _as_utc(valid_from)
39
+ end = _as_utc(valid_to) if valid_to is not None else None
40
+ if end is not None and start >= end:
41
+ raise ValueError("valid_to must be later than valid_from")
42
+ observed = _as_utc(timestamp)
43
+ return start <= observed and (end is None or observed < end)
44
+
45
+
46
+ def _normalize_with_offsets(value: str) -> tuple[str, list[int]]:
47
+ characters: list[str] = []
48
+ offsets: list[int] = []
49
+ pending_space = False
50
+ for source_index, source_character in enumerate(value):
51
+ for character in unicodedata.normalize("NFKC", source_character).casefold():
52
+ if character in _DASHES or character in _SEPARATOR_PUNCTUATION:
53
+ character = " "
54
+ elif character in _APOSTROPHES:
55
+ character = "'"
56
+ if character.isspace():
57
+ pending_space = bool(characters)
58
+ continue
59
+ if pending_space:
60
+ characters.append(" ")
61
+ offsets.append(source_index)
62
+ pending_space = False
63
+ characters.append(character)
64
+ offsets.append(source_index)
65
+ return "".join(characters), offsets
66
+
67
+
68
+ def normalize_financial_phrase(value: str) -> str:
69
+ """Normalize without stemming, fuzzy edits, token deletion, or correction."""
70
+
71
+ return _normalize_with_offsets(value)[0]
72
+
73
+
74
+ def find_normalized_phrase_spans(text: str, phrase: str) -> list[tuple[int, int]]:
75
+ normalized_text, offsets = _normalize_with_offsets(text)
76
+ normalized_phrase = normalize_financial_phrase(phrase)
77
+ if not normalized_phrase or not offsets:
78
+ return []
79
+ spans: list[tuple[int, int]] = []
80
+ cursor = 0
81
+ while True:
82
+ start = normalized_text.find(normalized_phrase, cursor)
83
+ if start < 0:
84
+ break
85
+ end = start + len(normalized_phrase)
86
+ left_ok = start == 0 or normalized_text[start - 1] not in _TOKEN_CHARS
87
+ right_ok = (
88
+ end == len(normalized_text) or normalized_text[end] not in _TOKEN_CHARS
89
+ )
90
+ if left_ok and right_ok:
91
+ spans.append((offsets[start], offsets[end - 1] + 1))
92
+ cursor = start + 1
93
+ return spans
94
+
95
+
96
+ @dataclass(frozen=True, slots=True)
97
+ class CatalogEntity:
98
+ canonical_id: str
99
+ canonical_name: str
100
+ entity_concept: str
101
+ entity_kind: str
102
+ valid_from: str
103
+ valid_to: str | None
104
+
105
+
106
+ @dataclass(frozen=True, slots=True)
107
+ class CatalogAlias:
108
+ alias_id: str
109
+ canonical_id: str
110
+ surface: str
111
+ match_mode: str
112
+ requires_context: bool
113
+ context_rule_id: str | None
114
+ valid_from: str
115
+ valid_to: str | None
116
+ source_id: str
117
+
118
+
119
+ @dataclass(frozen=True, slots=True)
120
+ class CatalogRelationship:
121
+ relationship_id: str
122
+ relationship_type: str
123
+ subject_id: str
124
+ object_id: str
125
+ valid_from: str
126
+ valid_to: str | None
127
+ catalog_version: str
128
+
129
+
130
+ @dataclass(frozen=True, slots=True)
131
+ class LexicalRule:
132
+ rule_id: str
133
+ entity_concept: str
134
+ tag_type: str
135
+ target_id: str | None
136
+ phrases: tuple[str, ...]
137
+ detection_method: str
138
+ market_wide_authorized: bool
139
+ case_sensitive: bool
140
+ context_window_chars: int
141
+ context_any: tuple[str, ...]
142
+ event_family: str | None
143
+ source_id: str
144
+
145
+
146
+ @dataclass(frozen=True, slots=True)
147
+ class CatalogBundle:
148
+ entity_catalog_version: str
149
+ lexical_catalog_version: str
150
+ entities: Mapping[str, CatalogEntity]
151
+ aliases: tuple[CatalogAlias, ...]
152
+ relationships: Mapping[str, CatalogRelationship]
153
+ lexical_rules: tuple[LexicalRule, ...]
154
+
155
+ def exposure_family_for(
156
+ self, canonical_id: str, config: NewsRelevanceConfig
157
+ ) -> ExposureFamily | None:
158
+ families = [
159
+ family
160
+ for family, members in config.exposure_members.items()
161
+ if canonical_id in members
162
+ ]
163
+ if len(families) > 1:
164
+ raise ValueError(
165
+ f"canonical ID {canonical_id!r} belongs to multiple families"
166
+ )
167
+ return families[0] if families else None
168
+
169
+ def active_relationship_path(
170
+ self,
171
+ canonical_id: str,
172
+ timestamp: datetime | str,
173
+ config: NewsRelevanceConfig,
174
+ ) -> tuple[str, ...]:
175
+ path = config.relationship_paths.get(canonical_id, ())
176
+ current = canonical_id
177
+ for relationship_id in path:
178
+ relationship = self.relationships.get(relationship_id)
179
+ if relationship is None or relationship.subject_id != current:
180
+ return ()
181
+ if not active_during(
182
+ timestamp,
183
+ valid_from=relationship.valid_from,
184
+ valid_to=relationship.valid_to,
185
+ ):
186
+ return ()
187
+ current = relationship.object_id
188
+ family = self.exposure_family_for(canonical_id, config)
189
+ if (
190
+ path
191
+ and family is not None
192
+ and current not in config.exposure_members[family]
193
+ ):
194
+ return ()
195
+ return tuple(path)
196
+
197
+
198
+ def _read_yaml(path: Path) -> Mapping[str, Any]:
199
+ with path.open(encoding="utf-8") as handle:
200
+ value = yaml.safe_load(handle)
201
+ if not isinstance(value, Mapping):
202
+ raise TypeError(f"YAML root must be a mapping: {path}")
203
+ return value
204
+
205
+
206
+ def _exact_keys(value: Mapping[str, Any], allowed: set[str], name: str) -> None:
207
+ unknown = set(value) - allowed
208
+ if unknown:
209
+ raise ValueError(f"unknown {name} keys: {sorted(unknown)}")
210
+
211
+
212
+ def load_catalogs(config: NewsRelevanceConfig) -> CatalogBundle:
213
+ entity_root = _read_yaml(config.entity_catalog_path)
214
+ lexical_root = _read_yaml(config.lexical_catalog_path)
215
+ _exact_keys(
216
+ entity_root,
217
+ {
218
+ "catalog_version",
219
+ "source_as_of",
220
+ "constituent_evidence_enabled",
221
+ "entities",
222
+ "aliases",
223
+ "context_rules",
224
+ "relationships",
225
+ },
226
+ "entity catalog",
227
+ )
228
+ _exact_keys(
229
+ lexical_root,
230
+ {"catalog_version", "source_as_of", "normalization_version", "rules"},
231
+ "lexical catalog",
232
+ )
233
+ if entity_root.get("constituent_evidence_enabled") is not False:
234
+ raise ValueError("entity catalog must fail closed on constituent evidence")
235
+ entity_version = str(entity_root.get("catalog_version", ""))
236
+ lexical_version = str(lexical_root.get("catalog_version", ""))
237
+ if not entity_version or not lexical_version:
238
+ raise ValueError("catalog versions must be non-empty")
239
+
240
+ entities: dict[str, CatalogEntity] = {}
241
+ for raw in entity_root.get("entities", ()):
242
+ _exact_keys(
243
+ raw,
244
+ {
245
+ "canonical_id",
246
+ "canonical_name",
247
+ "entity_concept",
248
+ "entity_kind",
249
+ "valid_from",
250
+ "valid_to",
251
+ },
252
+ "entity record",
253
+ )
254
+ entity = CatalogEntity(
255
+ canonical_id=str(raw["canonical_id"]),
256
+ canonical_name=str(raw["canonical_name"]),
257
+ entity_concept=str(raw["entity_concept"]),
258
+ entity_kind=str(raw["entity_kind"]),
259
+ valid_from=str(raw["valid_from"]),
260
+ valid_to=str(raw["valid_to"]) if raw.get("valid_to") is not None else None,
261
+ )
262
+ if entity.canonical_id in entities:
263
+ raise ValueError(f"duplicate entity {entity.canonical_id}")
264
+ active_during(
265
+ entity.valid_from, valid_from=entity.valid_from, valid_to=entity.valid_to
266
+ )
267
+ entities[entity.canonical_id] = entity
268
+
269
+ aliases: list[CatalogAlias] = []
270
+ seen_aliases: set[str] = set()
271
+ for raw in entity_root.get("aliases", ()):
272
+ _exact_keys(
273
+ raw,
274
+ {
275
+ "alias_id",
276
+ "canonical_id",
277
+ "surface",
278
+ "match_mode",
279
+ "requires_context",
280
+ "context_rule_id",
281
+ "valid_from",
282
+ "valid_to",
283
+ "source_id",
284
+ "source_url",
285
+ },
286
+ "alias record",
287
+ )
288
+ canonical_id = str(raw["canonical_id"])
289
+ entity = entities.get(canonical_id)
290
+ if entity is None:
291
+ raise ValueError(f"alias references unknown entity {canonical_id}")
292
+ alias_id = str(raw["alias_id"])
293
+ if alias_id in seen_aliases:
294
+ raise ValueError(f"duplicate alias {alias_id}")
295
+ seen_aliases.add(alias_id)
296
+ override = config.alias_policy_overrides.get(alias_id, {})
297
+ requires_context = bool(
298
+ override.get("context_required", raw.get("requires_context", False))
299
+ )
300
+ context_rule_id = override.get("context_rule_id", raw.get("context_rule_id"))
301
+ if requires_context and str(context_rule_id) not in config.context_rules:
302
+ raise ValueError(f"alias {alias_id} references an unknown context rule")
303
+ alias = CatalogAlias(
304
+ alias_id=alias_id,
305
+ canonical_id=canonical_id,
306
+ surface=str(raw["surface"]),
307
+ match_mode=str(raw["match_mode"]),
308
+ requires_context=requires_context,
309
+ context_rule_id=str(context_rule_id) if context_rule_id else None,
310
+ valid_from=str(raw.get("valid_from", entity.valid_from)),
311
+ valid_to=(
312
+ str(raw["valid_to"])
313
+ if raw.get("valid_to") is not None
314
+ else entity.valid_to
315
+ ),
316
+ source_id=str(raw["source_id"]),
317
+ )
318
+ if alias.match_mode not in {
319
+ "exact_identifier",
320
+ "exact_case_sensitive",
321
+ "exact_casefold",
322
+ "normalized_phrase",
323
+ }:
324
+ raise ValueError(f"unsupported match mode {alias.match_mode}")
325
+ aliases.append(alias)
326
+
327
+ relationships: dict[str, CatalogRelationship] = {}
328
+ for raw in entity_root.get("relationships", ()):
329
+ _exact_keys(
330
+ raw,
331
+ {
332
+ "relationship_id",
333
+ "relationship_type",
334
+ "subject_id",
335
+ "object_id",
336
+ "valid_from",
337
+ "valid_to",
338
+ "source_id",
339
+ "source_url",
340
+ },
341
+ "relationship record",
342
+ )
343
+ relationship = CatalogRelationship(
344
+ relationship_id=str(raw["relationship_id"]),
345
+ relationship_type=str(raw["relationship_type"]),
346
+ subject_id=str(raw["subject_id"]),
347
+ object_id=str(raw["object_id"]),
348
+ valid_from=str(raw["valid_from"]),
349
+ valid_to=str(raw["valid_to"]) if raw.get("valid_to") is not None else None,
350
+ catalog_version=entity_version,
351
+ )
352
+ if relationship.relationship_id in relationships:
353
+ raise ValueError(f"duplicate relationship {relationship.relationship_id}")
354
+ if (
355
+ relationship.subject_id not in entities
356
+ or relationship.object_id not in entities
357
+ ):
358
+ raise ValueError("relationship endpoint is missing from the catalog")
359
+ active_during(
360
+ relationship.valid_from,
361
+ valid_from=relationship.valid_from,
362
+ valid_to=relationship.valid_to,
363
+ )
364
+ relationships[relationship.relationship_id] = relationship
365
+
366
+ for path in config.relationship_paths.values():
367
+ if any(relationship_id not in relationships for relationship_id in path):
368
+ raise ValueError("configured relationship path references an unknown row")
369
+
370
+ lexical_rules: list[LexicalRule] = []
371
+ seen_rules: set[str] = set()
372
+ for raw in lexical_root.get("rules", ()):
373
+ _exact_keys(
374
+ raw,
375
+ {
376
+ "rule_id",
377
+ "entity_concept",
378
+ "tag_type",
379
+ "target_id",
380
+ "market_wide_authorized",
381
+ "phrases",
382
+ "source_id",
383
+ "source_url",
384
+ "case_sensitive",
385
+ "context_window_chars",
386
+ "context_any",
387
+ "detection_method",
388
+ "event_family",
389
+ },
390
+ "lexical rule",
391
+ )
392
+ rule_id = str(raw["rule_id"])
393
+ if rule_id in seen_rules:
394
+ raise ValueError(f"duplicate lexical rule {rule_id}")
395
+ seen_rules.add(rule_id)
396
+ rule = LexicalRule(
397
+ rule_id=rule_id,
398
+ entity_concept=str(raw["entity_concept"]),
399
+ tag_type=str(raw["tag_type"]),
400
+ target_id=(
401
+ str(raw["target_id"]) if raw.get("target_id") is not None else None
402
+ ),
403
+ phrases=tuple(str(item) for item in raw["phrases"]),
404
+ detection_method=str(raw.get("detection_method", "lexical_phrase")),
405
+ market_wide_authorized=raw.get("market_wide_authorized") is True,
406
+ case_sensitive=raw.get("case_sensitive") is True,
407
+ context_window_chars=int(raw.get("context_window_chars", 0)),
408
+ context_any=tuple(str(item) for item in raw.get("context_any", ())),
409
+ event_family=(
410
+ str(raw["event_family"])
411
+ if raw.get("event_family") is not None
412
+ else None
413
+ ),
414
+ source_id=str(raw["source_id"]),
415
+ )
416
+ if rule.tag_type not in config.ranking_tiers:
417
+ raise ValueError(f"lexical tag type {rule.tag_type!r} has no ranking tier")
418
+ lexical_rules.append(rule)
419
+
420
+ return CatalogBundle(
421
+ entity_catalog_version=entity_version,
422
+ lexical_catalog_version=lexical_version,
423
+ entities=MappingProxyType(entities),
424
+ aliases=tuple(aliases),
425
+ relationships=MappingProxyType(relationships),
426
+ lexical_rules=tuple(lexical_rules),
427
+ )
428
+
429
+
430
+ def alias_spans(text: str, alias: CatalogAlias) -> list[tuple[int, int]]:
431
+ if alias.match_mode == "normalized_phrase":
432
+ return find_normalized_phrase_spans(text, alias.surface)
433
+ flags = (
434
+ 0
435
+ if alias.match_mode in {"exact_identifier", "exact_case_sensitive"}
436
+ else re.IGNORECASE
437
+ )
438
+ pattern = re.compile(
439
+ rf"(?<![{_TOKEN_PATTERN_CHARS}]){re.escape(alias.surface)}(?![{_TOKEN_PATTERN_CHARS}])",
440
+ flags,
441
+ )
442
+ return [(match.start(), match.end()) for match in pattern.finditer(text)]
443
+
444
+
445
+ def lexical_spans(text: str, rule: LexicalRule, phrase: str) -> list[tuple[int, int]]:
446
+ if rule.case_sensitive:
447
+ pattern = re.compile(
448
+ rf"(?<![{_TOKEN_PATTERN_CHARS}]){re.escape(phrase)}(?![{_TOKEN_PATTERN_CHARS}])"
449
+ )
450
+ return [(match.start(), match.end()) for match in pattern.finditer(text)]
451
+ return find_normalized_phrase_spans(text, phrase)
452
+
453
+
454
+ def context_result(
455
+ text: str, *, start: int, end: int, rule: ContextRule | None
456
+ ) -> tuple[bool, str]:
457
+ if rule is None:
458
+ return False, "missing_context_rule"
459
+ window = text[
460
+ max(0, start - rule.window_chars) : min(len(text), end + rule.window_chars)
461
+ ]
462
+ for term in rule.reject_any_terms:
463
+ if find_normalized_phrase_spans(window, term):
464
+ return False, "explicit_collision_exclusion"
465
+ for clause in rule.accept_any_clauses:
466
+ if clause and all(
467
+ find_normalized_phrase_spans(window, term) for term in clause
468
+ ):
469
+ return True, "context_clause_satisfied"
470
+ return False, "context_clause_not_satisfied"
src/range_news/_core/catalogs/entities.yml ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ catalog_version: "relevance-general-economy-entities-2026-09-01-v1"
2
+ source_as_of: "2026-09-01"
3
+ constituent_evidence_enabled: false
4
+
5
+ entities:
6
+ - canonical_id: SPY
7
+ canonical_name: "SPDR S&P 500 ETF Trust"
8
+ entity_concept: investment_fund
9
+ entity_kind: fund
10
+ valid_from: "1993-01-22 00:00:00-05:00"
11
+ valid_to: null
12
+ - canonical_id: QQQ
13
+ canonical_name: "Invesco QQQ"
14
+ entity_concept: investment_fund
15
+ entity_kind: fund
16
+ valid_from: "1999-03-10 00:00:00-05:00"
17
+ valid_to: null
18
+ - canonical_id: IWM
19
+ canonical_name: "iShares Russell 2000 ETF"
20
+ entity_concept: investment_fund
21
+ entity_kind: fund
22
+ valid_from: "2000-05-22 00:00:00-04:00"
23
+ valid_to: null
24
+ - canonical_id: "INDEX:SP500"
25
+ canonical_name: "S&P 500"
26
+ entity_concept: market_index
27
+ entity_kind: index
28
+ valid_from: "1957-03-04 00:00:00-05:00"
29
+ valid_to: null
30
+ - canonical_id: "INDEX:NASDAQ100"
31
+ canonical_name: "Nasdaq-100 Index"
32
+ entity_concept: market_index
33
+ entity_kind: index
34
+ valid_from: "1985-01-31 00:00:00-05:00"
35
+ valid_to: null
36
+ - canonical_id: "INDEX:RUSSELL2000"
37
+ canonical_name: "Russell 2000 Index"
38
+ entity_concept: market_index
39
+ entity_kind: index
40
+ valid_from: "1984-01-01 00:00:00-05:00"
41
+ valid_to: null
42
+
43
+ aliases:
44
+ - alias_id: ticker-SPY
45
+ canonical_id: SPY
46
+ surface: SPY
47
+ match_mode: exact_case_sensitive
48
+ requires_context: true
49
+ context_rule_id: ticker-collision-SPY-v1
50
+ source_id: ssga-spy
51
+ source_url: "https://www.ssga.com/us/en/intermediary/etfs/spdr-sp-500-etf-trust-spy"
52
+ - alias_id: name-SPY
53
+ canonical_id: SPY
54
+ surface: "SPDR S&P 500 ETF Trust"
55
+ match_mode: normalized_phrase
56
+ requires_context: false
57
+ source_id: ssga-spy
58
+ source_url: "https://www.ssga.com/us/en/intermediary/etfs/spdr-sp-500-etf-trust-spy"
59
+ - alias_id: name-SPY-short
60
+ canonical_id: SPY
61
+ surface: "SPDR S&P 500 ETF"
62
+ match_mode: normalized_phrase
63
+ requires_context: false
64
+ source_id: ssga-spy
65
+ source_url: "https://www.ssga.com/us/en/intermediary/etfs/spdr-sp-500-etf-trust-spy"
66
+
67
+ - alias_id: ticker-QQQ
68
+ canonical_id: QQQ
69
+ surface: QQQ
70
+ match_mode: exact_identifier
71
+ requires_context: false
72
+ source_id: invesco-qqq
73
+ source_url: "https://www.invesco.com/qqq-etf/en/home.html"
74
+ - alias_id: name-QQQ-current
75
+ canonical_id: QQQ
76
+ surface: "Invesco QQQ"
77
+ match_mode: normalized_phrase
78
+ requires_context: false
79
+ valid_from: "2018-06-04 00:00:00-04:00"
80
+ valid_to: null
81
+ source_id: invesco-qqq
82
+ source_url: "https://www.invesco.com/qqq-etf/en/home.html"
83
+ - alias_id: name-QQQ-current-etf
84
+ canonical_id: QQQ
85
+ surface: "Invesco QQQ ETF"
86
+ match_mode: normalized_phrase
87
+ requires_context: false
88
+ valid_from: "2018-06-04 00:00:00-04:00"
89
+ valid_to: null
90
+ source_id: invesco-qqq
91
+ source_url: "https://www.invesco.com/qqq-etf/en/home.html"
92
+ - alias_id: name-QQQ-historical
93
+ canonical_id: QQQ
94
+ surface: "PowerShares QQQ Trust, Series 1"
95
+ match_mode: normalized_phrase
96
+ requires_context: false
97
+ valid_from: "1999-03-10 00:00:00-05:00"
98
+ valid_to: "2018-06-04 00:00:00-04:00"
99
+ source_id: sec-qqq-historical
100
+ source_url: "https://www.sec.gov/Archives/edgar/data/1067839/000119312518023862/d423471d485bpos.htm"
101
+
102
+ - alias_id: ticker-IWM
103
+ canonical_id: IWM
104
+ surface: IWM
105
+ match_mode: exact_identifier
106
+ requires_context: false
107
+ source_id: ishares-iwm
108
+ source_url: "https://www.ishares.com/us/products/239710/ishares-russell-2000-etf"
109
+ - alias_id: name-IWM
110
+ canonical_id: IWM
111
+ surface: "iShares Russell 2000 ETF"
112
+ match_mode: normalized_phrase
113
+ requires_context: false
114
+ source_id: ishares-iwm
115
+ source_url: "https://www.ishares.com/us/products/239710/ishares-russell-2000-etf"
116
+
117
+ - alias_id: index-SP500
118
+ canonical_id: "INDEX:SP500"
119
+ surface: "S&P 500"
120
+ match_mode: normalized_phrase
121
+ requires_context: false
122
+ source_id: sp-global-sp500
123
+ source_url: "https://www.spglobal.com/spdji/en/indices/equity/sp-500/"
124
+ - alias_id: index-SP500-name
125
+ canonical_id: "INDEX:SP500"
126
+ surface: "S&P 500 Index"
127
+ match_mode: normalized_phrase
128
+ requires_context: false
129
+ source_id: sp-global-sp500
130
+ source_url: "https://www.spglobal.com/spdji/en/indices/equity/sp-500/"
131
+ - alias_id: index-NASDAQ100
132
+ canonical_id: "INDEX:NASDAQ100"
133
+ surface: "Nasdaq-100"
134
+ match_mode: normalized_phrase
135
+ requires_context: false
136
+ source_id: nasdaq-100
137
+ source_url: "https://indexes.nasdaqomx.com/Index/Overview/NDX"
138
+ - alias_id: index-NASDAQ100-space
139
+ canonical_id: "INDEX:NASDAQ100"
140
+ surface: "Nasdaq 100"
141
+ match_mode: normalized_phrase
142
+ requires_context: false
143
+ source_id: nasdaq-100
144
+ source_url: "https://indexes.nasdaqomx.com/Index/Overview/NDX"
145
+ - alias_id: index-RUSSELL2000
146
+ canonical_id: "INDEX:RUSSELL2000"
147
+ surface: "Russell 2000"
148
+ match_mode: normalized_phrase
149
+ requires_context: false
150
+ source_id: ftse-russell-2000
151
+ source_url: "https://www.lseg.com/en/ftse-russell/indices/russell-2000-index"
152
+ - alias_id: index-RUSSELL2000-name
153
+ canonical_id: "INDEX:RUSSELL2000"
154
+ surface: "Russell 2000 Index"
155
+ match_mode: normalized_phrase
156
+ requires_context: false
157
+ source_id: ftse-russell-2000
158
+ source_url: "https://www.lseg.com/en/ftse-russell/indices/russell-2000-index"
159
+
160
+ context_rules: {}
161
+
162
+ relationships:
163
+ - relationship_id: SPY-tracks-SP500
164
+ relationship_type: tracks_benchmark
165
+ subject_id: SPY
166
+ object_id: "INDEX:SP500"
167
+ valid_from: "1993-01-22 00:00:00-05:00"
168
+ valid_to: null
169
+ source_id: ssga-spy
170
+ source_url: "https://www.ssga.com/us/en/intermediary/etfs/spdr-sp-500-etf-trust-spy"
171
+ - relationship_id: QQQ-tracks-NASDAQ100
172
+ relationship_type: tracks_benchmark
173
+ subject_id: QQQ
174
+ object_id: "INDEX:NASDAQ100"
175
+ valid_from: "1999-03-10 00:00:00-05:00"
176
+ valid_to: null
177
+ source_id: invesco-qqq
178
+ source_url: "https://www.invesco.com/qqq-etf/en/home.html"
179
+ - relationship_id: IWM-tracks-RUSSELL2000
180
+ relationship_type: tracks_benchmark
181
+ subject_id: IWM
182
+ object_id: "INDEX:RUSSELL2000"
183
+ valid_from: "2000-05-22 00:00:00-04:00"
184
+ valid_to: null
185
+ source_id: ishares-iwm
186
+ source_url: "https://www.ishares.com/us/products/239710/ishares-russell-2000-etf"
src/range_news/_core/catalogs/lexical.yml ADDED
@@ -0,0 +1,744 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ catalog_version: "relevance-general-economy-lexical-2026-09-01-v1"
2
+ source_as_of: "2026-09-01"
3
+ normalization_version: "financial-phrase-nfkc-v2"
4
+
5
+ rules:
6
+ - rule_id: central-bank-federal-reserve
7
+ entity_concept: central_bank
8
+ tag_type: macroeconomic
9
+ target_id: "CENTRAL_BANK:FED"
10
+ market_wide_authorized: true
11
+ phrases:
12
+ - "Federal Reserve"
13
+ - "Federal Reserve Board"
14
+ - "Federal Open Market Committee"
15
+ - FOMC
16
+ source_id: federal-reserve
17
+ source_url: "https://www.federalreserve.gov/monetarypolicy.htm"
18
+ - rule_id: central-bank-global
19
+ entity_concept: central_bank
20
+ tag_type: macroeconomic
21
+ target_id: "CENTRAL_BANK:GLOBAL"
22
+ market_wide_authorized: true
23
+ phrases:
24
+ - "European Central Bank"
25
+ - "Bank of England"
26
+ - "Bank of Japan"
27
+ - "People's Bank of China"
28
+ source_id: central-banks
29
+ source_url: "https://www.bis.org/cbanks.htm"
30
+ - rule_id: monetary-policy-rates
31
+ entity_concept: monetary_policy
32
+ tag_type: macroeconomic
33
+ target_id: "MACRO:POLICY_RATE"
34
+ market_wide_authorized: true
35
+ phrases:
36
+ - "federal funds rate"
37
+ - "policy rate decision"
38
+ - "interest rate decision"
39
+ - "rate hike"
40
+ - "rate cut"
41
+ - "interest-rate hike"
42
+ - "interest-rate cut"
43
+ source_id: federal-reserve
44
+ source_url: "https://www.federalreserve.gov/monetarypolicy.htm"
45
+ - rule_id: monetary-policy-balance-sheet
46
+ entity_concept: monetary_policy
47
+ tag_type: macroeconomic
48
+ target_id: "MACRO:MONETARY_BALANCE_SHEET"
49
+ market_wide_authorized: true
50
+ phrases:
51
+ - "quantitative easing"
52
+ - "quantitative tightening"
53
+ - "balance sheet runoff"
54
+ - "central bank asset purchases"
55
+ - "bond purchase program"
56
+ source_id: federal-reserve
57
+ source_url: "https://www.federalreserve.gov/monetarypolicy.htm"
58
+ - rule_id: central-bank-fed-shorthand
59
+ entity_concept: central_bank
60
+ tag_type: macroeconomic
61
+ target_id: "CENTRAL_BANK:FED"
62
+ market_wide_authorized: true
63
+ phrases: [Fed]
64
+ case_sensitive: true
65
+ context_window_chars: 96
66
+ context_any: [rate, rates, policy, inflation, FOMC, official, chair]
67
+ source_id: federal-reserve
68
+ source_url: "https://www.federalreserve.gov/monetarypolicy.htm"
69
+
70
+ - rule_id: inflation-consumer-prices
71
+ entity_concept: macroeconomic_indicator
72
+ tag_type: macroeconomic
73
+ target_id: "MACRO:CPI"
74
+ market_wide_authorized: true
75
+ phrases:
76
+ - "Consumer Price Index"
77
+ - "CPI inflation"
78
+ - "core CPI"
79
+ - "headline CPI"
80
+ source_id: bls-cpi
81
+ source_url: "https://www.bls.gov/cpi/"
82
+ - rule_id: inflation-pce-prices
83
+ entity_concept: macroeconomic_indicator
84
+ tag_type: macroeconomic
85
+ target_id: "MACRO:PCE"
86
+ market_wide_authorized: true
87
+ phrases:
88
+ - "personal consumption expenditures price index"
89
+ - "PCE inflation"
90
+ - "core PCE"
91
+ - "PCE price index"
92
+ source_id: bea-pce
93
+ source_url: "https://www.bea.gov/data/personal-consumption-expenditures-price-index"
94
+ - rule_id: inflation-producer-prices
95
+ entity_concept: macroeconomic_indicator
96
+ tag_type: macroeconomic
97
+ target_id: "MACRO:PPI"
98
+ market_wide_authorized: true
99
+ phrases:
100
+ - "Producer Price Index"
101
+ - "PPI inflation"
102
+ - "core PPI"
103
+ source_id: bls-ppi
104
+ source_url: "https://www.bls.gov/ppi/"
105
+ - rule_id: inflation-expectations
106
+ entity_concept: macroeconomic_indicator
107
+ tag_type: macroeconomic
108
+ target_id: "MACRO:INFLATION_EXPECTATIONS"
109
+ market_wide_authorized: true
110
+ phrases:
111
+ - "inflation expectations"
112
+ - "breakeven inflation"
113
+ - "five-year inflation expectations"
114
+ - "long-term inflation expectations"
115
+ source_id: fred-inflation-expectations
116
+ source_url: "https://fred.stlouisfed.org/"
117
+ - rule_id: inflation-cpi-acronym
118
+ entity_concept: macroeconomic_indicator
119
+ tag_type: macroeconomic
120
+ target_id: "MACRO:CPI"
121
+ market_wide_authorized: true
122
+ phrases: [CPI]
123
+ case_sensitive: true
124
+ context_window_chars: 64
125
+ context_any: [inflation, price, prices, index, consumer]
126
+ source_id: bls-cpi
127
+ source_url: "https://www.bls.gov/cpi/"
128
+ - rule_id: inflation-pce-acronym
129
+ entity_concept: macroeconomic_indicator
130
+ tag_type: macroeconomic
131
+ target_id: "MACRO:PCE"
132
+ market_wide_authorized: true
133
+ phrases: [PCE]
134
+ case_sensitive: true
135
+ context_window_chars: 64
136
+ context_any: [inflation, price, prices, index, spending]
137
+ source_id: bea-pce
138
+ source_url: "https://www.bea.gov/data/personal-consumption-expenditures-price-index"
139
+ - rule_id: inflation-ppi-acronym
140
+ entity_concept: macroeconomic_indicator
141
+ tag_type: macroeconomic
142
+ target_id: "MACRO:PPI"
143
+ market_wide_authorized: true
144
+ phrases: [PPI]
145
+ case_sensitive: true
146
+ context_window_chars: 64
147
+ context_any: [inflation, price, prices, index, producer]
148
+ source_id: bls-ppi
149
+ source_url: "https://www.bls.gov/ppi/"
150
+
151
+ - rule_id: employment-payrolls
152
+ entity_concept: macroeconomic_indicator
153
+ tag_type: macroeconomic
154
+ target_id: "MACRO:PAYROLLS"
155
+ market_wide_authorized: true
156
+ phrases:
157
+ - "nonfarm payrolls"
158
+ - "nonfarm payroll employment"
159
+ - "payroll employment"
160
+ - "employment situation report"
161
+ source_id: bls-employment
162
+ source_url: "https://www.bls.gov/ces/"
163
+ - rule_id: employment-unemployment
164
+ entity_concept: macroeconomic_indicator
165
+ tag_type: macroeconomic
166
+ target_id: "MACRO:UNEMPLOYMENT"
167
+ market_wide_authorized: true
168
+ phrases:
169
+ - "unemployment rate"
170
+ - "labor force participation rate"
171
+ - "underemployment rate"
172
+ source_id: bls-employment
173
+ source_url: "https://www.bls.gov/ces/"
174
+ - rule_id: employment-claims
175
+ entity_concept: macroeconomic_indicator
176
+ tag_type: macroeconomic
177
+ target_id: "MACRO:JOBLESS_CLAIMS"
178
+ market_wide_authorized: true
179
+ phrases:
180
+ - "initial jobless claims"
181
+ - "continuing jobless claims"
182
+ - "unemployment insurance claims"
183
+ source_id: dol-unemployment-claims
184
+ source_url: "https://oui.doleta.gov/unemploy/claims.asp"
185
+ - rule_id: employment-job-openings
186
+ entity_concept: macroeconomic_indicator
187
+ tag_type: macroeconomic
188
+ target_id: "MACRO:JOLTS"
189
+ market_wide_authorized: true
190
+ phrases:
191
+ - "Job Openings and Labor Turnover Survey"
192
+ - "JOLTS report"
193
+ - "job openings data"
194
+ source_id: bls-jolts
195
+ source_url: "https://www.bls.gov/jlt/"
196
+ - rule_id: employment-wages
197
+ entity_concept: macroeconomic_indicator
198
+ tag_type: macroeconomic
199
+ target_id: "MACRO:WAGES"
200
+ market_wide_authorized: true
201
+ phrases:
202
+ - "average hourly earnings"
203
+ - "wage growth"
204
+ - "employment cost index"
205
+ - "unit labor costs"
206
+ source_id: bls-wages
207
+ source_url: "https://www.bls.gov/ces/"
208
+
209
+ - rule_id: growth-gdp
210
+ entity_concept: macroeconomic_indicator
211
+ tag_type: macroeconomic
212
+ target_id: "MACRO:GDP"
213
+ market_wide_authorized: true
214
+ phrases:
215
+ - "gross domestic product"
216
+ - "real GDP"
217
+ - "GDP growth"
218
+ - "GDP contraction"
219
+ source_id: bea-gdp
220
+ source_url: "https://www.bea.gov/data/gdp/gross-domestic-product"
221
+ - rule_id: consumption-retail-sales
222
+ entity_concept: macroeconomic_indicator
223
+ tag_type: macroeconomic
224
+ target_id: "MACRO:CONSUMPTION"
225
+ market_wide_authorized: true
226
+ phrases:
227
+ - "retail sales"
228
+ - "consumer spending"
229
+ - "personal consumption expenditures"
230
+ source_id: census-retail
231
+ source_url: "https://www.census.gov/retail/"
232
+ - rule_id: activity-industrial-production
233
+ entity_concept: macroeconomic_indicator
234
+ tag_type: macroeconomic
235
+ target_id: "MACRO:INDUSTRIAL_PRODUCTION"
236
+ market_wide_authorized: true
237
+ phrases:
238
+ - "industrial production"
239
+ - "capacity utilization"
240
+ - "manufacturing output"
241
+ source_id: federal-reserve-industrial-production
242
+ source_url: "https://www.federalreserve.gov/releases/g17/current/"
243
+ - rule_id: activity-business-surveys
244
+ entity_concept: macroeconomic_indicator
245
+ tag_type: macroeconomic
246
+ target_id: "MACRO:BUSINESS_SURVEYS"
247
+ market_wide_authorized: true
248
+ phrases:
249
+ - "Purchasing Managers' Index"
250
+ - "manufacturing PMI"
251
+ - "services PMI"
252
+ - "ISM manufacturing index"
253
+ - "ISM services index"
254
+ source_id: business-surveys
255
+ source_url: "https://fred.stlouisfed.org/"
256
+ - rule_id: activity-confidence
257
+ entity_concept: macroeconomic_indicator
258
+ tag_type: macroeconomic
259
+ target_id: "MACRO:CONFIDENCE"
260
+ market_wide_authorized: true
261
+ phrases:
262
+ - "consumer confidence"
263
+ - "consumer sentiment"
264
+ - "business confidence"
265
+ source_id: confidence-surveys
266
+ source_url: "https://fred.stlouisfed.org/"
267
+ - rule_id: activity-housing
268
+ entity_concept: macroeconomic_indicator
269
+ tag_type: macroeconomic
270
+ target_id: "MACRO:HOUSING"
271
+ market_wide_authorized: true
272
+ phrases:
273
+ - "housing starts"
274
+ - "building permits"
275
+ - "existing home sales"
276
+ - "new home sales"
277
+ - "pending home sales"
278
+ source_id: census-housing
279
+ source_url: "https://www.census.gov/construction/nrc/"
280
+ - rule_id: activity-durable-goods
281
+ entity_concept: macroeconomic_indicator
282
+ tag_type: macroeconomic
283
+ target_id: "MACRO:DURABLE_GOODS"
284
+ market_wide_authorized: true
285
+ phrases:
286
+ - "durable goods orders"
287
+ - "factory orders"
288
+ - "core capital goods orders"
289
+ source_id: census-manufacturing
290
+ source_url: "https://www.census.gov/manufacturing/m3/"
291
+ - rule_id: growth-gdp-acronym
292
+ entity_concept: macroeconomic_indicator
293
+ tag_type: macroeconomic
294
+ target_id: "MACRO:GDP"
295
+ market_wide_authorized: true
296
+ phrases: [GDP]
297
+ case_sensitive: true
298
+ context_window_chars: 64
299
+ context_any: [growth, economy, economic, output, contraction]
300
+ source_id: bea-gdp
301
+ source_url: "https://www.bea.gov/data/gdp/gross-domestic-product"
302
+ - rule_id: activity-pmi-acronym
303
+ entity_concept: macroeconomic_indicator
304
+ tag_type: macroeconomic
305
+ target_id: "MACRO:BUSINESS_SURVEYS"
306
+ market_wide_authorized: true
307
+ phrases: [PMI]
308
+ case_sensitive: true
309
+ context_window_chars: 64
310
+ context_any: [manufacturing, services, business, survey, index]
311
+ source_id: business-surveys
312
+ source_url: "https://fred.stlouisfed.org/"
313
+
314
+ - rule_id: rates-treasury-yields
315
+ entity_concept: market_rate
316
+ tag_type: macroeconomic
317
+ target_id: "MACRO:TREASURY_YIELDS"
318
+ market_wide_authorized: true
319
+ phrases:
320
+ - "Treasury yield"
321
+ - "Treasury yields"
322
+ - "10-year yield"
323
+ - "ten-year yield"
324
+ - "2-year yield"
325
+ - "two-year yield"
326
+ source_id: us-treasury-rates
327
+ source_url: "https://home.treasury.gov/resource-center/data-chart-center/interest-rates"
328
+ - rule_id: rates-yield-curve
329
+ entity_concept: market_rate
330
+ tag_type: macroeconomic
331
+ target_id: "MACRO:YIELD_CURVE"
332
+ market_wide_authorized: true
333
+ phrases:
334
+ - "yield curve"
335
+ - "yield curve inversion"
336
+ - "inverted yield curve"
337
+ - "term premium"
338
+ source_id: us-treasury-rates
339
+ source_url: "https://home.treasury.gov/resource-center/data-chart-center/interest-rates"
340
+ - rule_id: credit-conditions
341
+ entity_concept: financial_conditions
342
+ tag_type: macroeconomic
343
+ target_id: "MACRO:CREDIT_CONDITIONS"
344
+ market_wide_authorized: true
345
+ phrases:
346
+ - "credit spreads"
347
+ - "credit spread"
348
+ - "high-yield spread"
349
+ - "investment-grade spread"
350
+ - "credit conditions"
351
+ - "credit crunch"
352
+ - "lending standards"
353
+ source_id: financial-conditions
354
+ source_url: "https://fred.stlouisfed.org/"
355
+ - rule_id: currency-dollar
356
+ entity_concept: currency_market
357
+ tag_type: macroeconomic
358
+ target_id: "MACRO:US_DOLLAR"
359
+ market_wide_authorized: true
360
+ phrases:
361
+ - "U.S. dollar index"
362
+ - "US dollar index"
363
+ - "dollar index"
364
+ - "currency intervention"
365
+ - "foreign exchange intervention"
366
+ source_id: currency-markets
367
+ source_url: "https://home.treasury.gov/"
368
+ - rule_id: commodities-energy
369
+ entity_concept: commodity_market
370
+ tag_type: macroeconomic
371
+ target_id: "MACRO:ENERGY_PRICES"
372
+ market_wide_authorized: true
373
+ phrases:
374
+ - "crude oil prices"
375
+ - "oil prices"
376
+ - "natural gas prices"
377
+ - "OPEC production"
378
+ - "energy prices"
379
+ source_id: eia-energy
380
+ source_url: "https://www.eia.gov/petroleum/"
381
+ - rule_id: commodities-industrial-metals
382
+ entity_concept: commodity_market
383
+ tag_type: macroeconomic
384
+ target_id: "MACRO:COMMODITIES"
385
+ market_wide_authorized: true
386
+ phrases:
387
+ - "copper prices"
388
+ - "industrial metals prices"
389
+ - "commodity price shock"
390
+ source_id: commodity-markets
391
+ source_url: "https://fred.stlouisfed.org/"
392
+
393
+ - rule_id: fiscal-budget
394
+ entity_concept: fiscal_policy
395
+ tag_type: macroeconomic
396
+ target_id: "MACRO:FISCAL_POLICY"
397
+ market_wide_authorized: true
398
+ phrases:
399
+ - "federal budget deficit"
400
+ - "fiscal stimulus"
401
+ - "fiscal tightening"
402
+ - "government spending package"
403
+ - "tax legislation"
404
+ source_id: cbo-budget
405
+ source_url: "https://www.cbo.gov/topics/budget"
406
+ - rule_id: fiscal-debt-limit
407
+ entity_concept: fiscal_policy
408
+ tag_type: macroeconomic
409
+ target_id: "MACRO:DEBT_LIMIT"
410
+ market_wide_authorized: true
411
+ phrases:
412
+ - "debt ceiling"
413
+ - "debt limit"
414
+ - "Treasury cash balance"
415
+ - "extraordinary measures"
416
+ source_id: us-treasury
417
+ source_url: "https://home.treasury.gov/"
418
+ - rule_id: fiscal-government-shutdown
419
+ entity_concept: fiscal_policy
420
+ tag_type: macroeconomic
421
+ target_id: "MACRO:GOVERNMENT_SHUTDOWN"
422
+ market_wide_authorized: true
423
+ phrases:
424
+ - "government shutdown"
425
+ - "federal government shutdown"
426
+ - "shutdown deadline"
427
+ source_id: us-budget
428
+ source_url: "https://www.cbo.gov/topics/budget"
429
+ - rule_id: trade-tariffs
430
+ entity_concept: trade_policy
431
+ tag_type: macroeconomic
432
+ target_id: "MACRO:TARIFFS"
433
+ market_wide_authorized: true
434
+ phrases:
435
+ - "tariff increase"
436
+ - "tariff cuts"
437
+ - "new tariffs"
438
+ - "trade war"
439
+ - "import duties"
440
+ source_id: ustr-trade-enforcement
441
+ source_url: "https://ustr.gov/issue-areas/enforcement"
442
+ - rule_id: trade-controls
443
+ entity_concept: trade_policy
444
+ tag_type: macroeconomic
445
+ target_id: "MACRO:TRADE_CONTROLS"
446
+ market_wide_authorized: true
447
+ phrases:
448
+ - "export controls"
449
+ - "import restrictions"
450
+ - "trade restrictions"
451
+ - "trade embargo"
452
+ source_id: ustr-trade-enforcement
453
+ source_url: "https://ustr.gov/issue-areas/enforcement"
454
+ - rule_id: supply-chain
455
+ entity_concept: economic_disruption
456
+ tag_type: market_event
457
+ target_id: "MARKET:SUPPLY_CHAIN"
458
+ market_wide_authorized: true
459
+ detection_method: event_rule
460
+ event_family: supply_chain_disruption
461
+ phrases:
462
+ - "supply chain disruption"
463
+ - "shipping disruption"
464
+ - "port strike"
465
+ - "semiconductor shortage"
466
+ - "freight bottleneck"
467
+ source_id: general-economy-taxonomy
468
+ source_url: "https://www.nber.org/research/business-cycle-dating"
469
+ - rule_id: business-cycle
470
+ entity_concept: business_cycle
471
+ tag_type: macroeconomic
472
+ target_id: "MACRO:BUSINESS_CYCLE"
473
+ market_wide_authorized: true
474
+ phrases:
475
+ - recession
476
+ - "economic contraction"
477
+ - "soft landing"
478
+ - "hard landing"
479
+ - stagflation
480
+ source_id: nber-business-cycle
481
+ source_url: "https://www.nber.org/research/business-cycle-dating"
482
+
483
+ - rule_id: financial-stability
484
+ entity_concept: financial_stability
485
+ tag_type: market_event
486
+ target_id: "MARKET:FINANCIAL_STABILITY"
487
+ market_wide_authorized: true
488
+ detection_method: event_rule
489
+ event_family: financial_stability
490
+ phrases:
491
+ - "financial stability"
492
+ - "systemic risk"
493
+ - "liquidity facility"
494
+ - "emergency lending facility"
495
+ - "market functioning facility"
496
+ source_id: federal-reserve-financial-stability
497
+ source_url: "https://www.federalreserve.gov/publications/financial-stability-report.htm"
498
+ - rule_id: banking-stress
499
+ entity_concept: financial_stability
500
+ tag_type: market_event
501
+ target_id: "MARKET:BANKING_STRESS"
502
+ market_wide_authorized: true
503
+ detection_method: event_rule
504
+ event_family: banking_stress
505
+ phrases:
506
+ - "bank failure"
507
+ - "banking crisis"
508
+ - "bank run"
509
+ - "deposit flight"
510
+ - "emergency bank rescue"
511
+ source_id: fdic-bank-failures
512
+ source_url: "https://www.fdic.gov/resources/resolutions/bank-failures/failed-bank-list/"
513
+ - rule_id: market-volatility
514
+ entity_concept: market_condition
515
+ tag_type: market_event
516
+ target_id: "MARKET:VOLATILITY"
517
+ market_wide_authorized: true
518
+ detection_method: event_rule
519
+ event_family: market_volatility
520
+ phrases:
521
+ - "market volatility"
522
+ - "volatility spike"
523
+ - "stock market selloff"
524
+ - "equity market selloff"
525
+ - "broad market rally"
526
+ source_id: market-conditions
527
+ source_url: "https://fred.stlouisfed.org/"
528
+ - rule_id: sovereign-risk
529
+ entity_concept: sovereign_risk
530
+ tag_type: market_event
531
+ target_id: "MARKET:SOVEREIGN_RISK"
532
+ market_wide_authorized: true
533
+ detection_method: event_rule
534
+ event_family: sovereign_risk
535
+ phrases:
536
+ - "sovereign default"
537
+ - "sovereign debt crisis"
538
+ - "credit rating downgrade of the United States"
539
+ source_id: sovereign-risk
540
+ source_url: "https://home.treasury.gov/"
541
+ - rule_id: economic-sanctions
542
+ entity_concept: geopolitical_economy
543
+ tag_type: market_event
544
+ target_id: "MARKET:SANCTIONS"
545
+ market_wide_authorized: true
546
+ detection_method: event_rule
547
+ event_family: economic_sanctions
548
+ phrases:
549
+ - "economic sanctions"
550
+ - "financial sanctions"
551
+ - "asset freeze sanctions"
552
+ source_id: us-treasury-sanctions
553
+ source_url: "https://ofac.treasury.gov/"
554
+
555
+ - rule_id: regulator-sec-name
556
+ entity_concept: financial_regulator
557
+ tag_type: regulatory
558
+ target_id: "REGULATOR:SEC"
559
+ market_wide_authorized: false
560
+ phrases:
561
+ - "Securities and Exchange Commission"
562
+ - "U.S. Securities and Exchange Commission"
563
+ source_id: sec-rules
564
+ source_url: "https://www.sec.gov/rules-regulations"
565
+ - rule_id: regulator-sec-acronym
566
+ entity_concept: financial_regulator
567
+ tag_type: regulatory
568
+ target_id: "REGULATOR:SEC"
569
+ market_wide_authorized: false
570
+ phrases: [SEC]
571
+ case_sensitive: true
572
+ context_window_chars: 96
573
+ context_any: [commission, regulator, filing, disclosure, investigation, charges, securities, rule, "market structure"]
574
+ source_id: sec-rules
575
+ source_url: "https://www.sec.gov/rules-regulations"
576
+ - rule_id: regulation-bank-capital
577
+ entity_concept: financial_regulator
578
+ tag_type: regulatory
579
+ target_id: "REGULATION:BANKING"
580
+ market_wide_authorized: true
581
+ phrases:
582
+ - "bank capital requirements"
583
+ - "capital rule"
584
+ - "stress test results"
585
+ - "deposit insurance reform"
586
+ - "liquidity coverage ratio"
587
+ source_id: banking-regulation
588
+ source_url: "https://www.fdic.gov/"
589
+
590
+ - rule_id: sector-communication-services
591
+ entity_concept: industry_or_sector
592
+ tag_type: sector_or_industry
593
+ target_id: "SECTOR:COMMUNICATION_SERVICES"
594
+ market_wide_authorized: false
595
+ phrases: ["communication services sector"]
596
+ source_id: sp-global-gics
597
+ source_url: "https://www.spglobal.com/spdji/en/landing/topic/gics/"
598
+ - rule_id: sector-consumer-discretionary
599
+ entity_concept: industry_or_sector
600
+ tag_type: sector_or_industry
601
+ target_id: "SECTOR:CONSUMER_DISCRETIONARY"
602
+ market_wide_authorized: false
603
+ phrases: ["consumer discretionary sector"]
604
+ source_id: sp-global-gics
605
+ source_url: "https://www.spglobal.com/spdji/en/landing/topic/gics/"
606
+ - rule_id: sector-consumer-staples
607
+ entity_concept: industry_or_sector
608
+ tag_type: sector_or_industry
609
+ target_id: "SECTOR:CONSUMER_STAPLES"
610
+ market_wide_authorized: false
611
+ phrases: ["consumer staples sector"]
612
+ source_id: sp-global-gics
613
+ source_url: "https://www.spglobal.com/spdji/en/landing/topic/gics/"
614
+ - rule_id: sector-energy
615
+ entity_concept: industry_or_sector
616
+ tag_type: sector_or_industry
617
+ target_id: "SECTOR:ENERGY"
618
+ market_wide_authorized: false
619
+ phrases: ["energy sector"]
620
+ source_id: sp-global-gics
621
+ source_url: "https://www.spglobal.com/spdji/en/landing/topic/gics/"
622
+ - rule_id: sector-financials
623
+ entity_concept: industry_or_sector
624
+ tag_type: sector_or_industry
625
+ target_id: "SECTOR:FINANCIALS"
626
+ market_wide_authorized: false
627
+ phrases: ["financials sector"]
628
+ source_id: sp-global-gics
629
+ source_url: "https://www.spglobal.com/spdji/en/landing/topic/gics/"
630
+ - rule_id: sector-health-care
631
+ entity_concept: industry_or_sector
632
+ tag_type: sector_or_industry
633
+ target_id: "SECTOR:HEALTH_CARE"
634
+ market_wide_authorized: false
635
+ phrases: ["health care sector"]
636
+ source_id: sp-global-gics
637
+ source_url: "https://www.spglobal.com/spdji/en/landing/topic/gics/"
638
+ - rule_id: sector-industrials
639
+ entity_concept: industry_or_sector
640
+ tag_type: sector_or_industry
641
+ target_id: "SECTOR:INDUSTRIALS"
642
+ market_wide_authorized: false
643
+ phrases: ["industrials sector"]
644
+ source_id: sp-global-gics
645
+ source_url: "https://www.spglobal.com/spdji/en/landing/topic/gics/"
646
+ - rule_id: sector-information-technology
647
+ entity_concept: industry_or_sector
648
+ tag_type: sector_or_industry
649
+ target_id: "SECTOR:INFORMATION_TECHNOLOGY"
650
+ market_wide_authorized: false
651
+ phrases: ["information technology sector"]
652
+ source_id: sp-global-gics
653
+ source_url: "https://www.spglobal.com/spdji/en/landing/topic/gics/"
654
+ - rule_id: sector-materials
655
+ entity_concept: industry_or_sector
656
+ tag_type: sector_or_industry
657
+ target_id: "SECTOR:MATERIALS"
658
+ market_wide_authorized: false
659
+ phrases: ["materials sector"]
660
+ source_id: sp-global-gics
661
+ source_url: "https://www.spglobal.com/spdji/en/landing/topic/gics/"
662
+ - rule_id: sector-real-estate
663
+ entity_concept: industry_or_sector
664
+ tag_type: sector_or_industry
665
+ target_id: "SECTOR:REAL_ESTATE"
666
+ market_wide_authorized: false
667
+ phrases: ["real estate sector"]
668
+ source_id: sp-global-gics
669
+ source_url: "https://www.spglobal.com/spdji/en/landing/topic/gics/"
670
+ - rule_id: sector-utilities
671
+ entity_concept: industry_or_sector
672
+ tag_type: sector_or_industry
673
+ target_id: "SECTOR:UTILITIES"
674
+ market_wide_authorized: false
675
+ phrases: ["utilities sector"]
676
+ source_id: sp-global-gics
677
+ source_url: "https://www.spglobal.com/spdji/en/landing/topic/gics/"
678
+
679
+ - rule_id: company-event-earnings
680
+ entity_concept: financial_instrument
681
+ tag_type: company_event
682
+ target_id: null
683
+ market_wide_authorized: false
684
+ detection_method: event_rule
685
+ event_family: earnings_or_profit_loss
686
+ phrases:
687
+ - "quarterly earnings"
688
+ - "earnings per share"
689
+ - "profit warning"
690
+ source_id: maintained-company-events
691
+ source_url: "https://www.sec.gov/edgar/search-and-access"
692
+ - rule_id: company-event-merger
693
+ entity_concept: company
694
+ tag_type: company_event
695
+ target_id: null
696
+ market_wide_authorized: false
697
+ detection_method: event_rule
698
+ event_family: merger_or_acquisition
699
+ phrases:
700
+ - "merger agreement"
701
+ - "acquisition agreement"
702
+ - "takeover offer"
703
+ source_id: maintained-company-events
704
+ source_url: "https://www.sec.gov/edgar/search-and-access"
705
+ - rule_id: company-event-distress
706
+ entity_concept: company
707
+ tag_type: company_event
708
+ target_id: null
709
+ market_wide_authorized: false
710
+ detection_method: event_rule
711
+ event_family: bankruptcy_or_distress
712
+ phrases:
713
+ - "filed for bankruptcy"
714
+ - "files for bankruptcy"
715
+ - "bankruptcy protection"
716
+ source_id: maintained-company-events
717
+ source_url: "https://www.sec.gov/edgar/search-and-access"
718
+ - rule_id: company-event-capital-return
719
+ entity_concept: financial_instrument
720
+ tag_type: company_event
721
+ target_id: null
722
+ market_wide_authorized: false
723
+ detection_method: event_rule
724
+ event_family: dividend_or_capital_return
725
+ phrases:
726
+ - "share repurchase"
727
+ - "stock buyback"
728
+ - "special dividend"
729
+ source_id: maintained-company-events
730
+ source_url: "https://www.sec.gov/edgar/search-and-access"
731
+ - rule_id: company-event-rating
732
+ entity_concept: financial_instrument
733
+ tag_type: company_event
734
+ target_id: null
735
+ market_wide_authorized: false
736
+ detection_method: event_rule
737
+ event_family: analyst_rating
738
+ phrases:
739
+ - "analyst upgrade"
740
+ - "analyst downgrade"
741
+ - "price target raised"
742
+ - "price target cut"
743
+ source_id: maintained-company-events
744
+ source_url: "https://www.sec.gov/edgar/search-and-access"
src/range_news/_core/config.py ADDED
@@ -0,0 +1,293 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Strict configuration loader for the public news-relevance API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from types import MappingProxyType
9
+ from typing import Any
10
+
11
+ import yaml
12
+
13
+ from .models import ExposureFamily
14
+
15
+ _PACKAGE_ROOT = Path(__file__).resolve().parent
16
+ DEFAULT_CONFIG_PATH = _PACKAGE_ROOT / "configs" / "default.yml"
17
+ _TOP_LEVEL_KEYS = {
18
+ "version",
19
+ "mode",
20
+ "catalogs",
21
+ "normalization",
22
+ "alias_policy_overrides",
23
+ "context_rules",
24
+ "market_wide_conjunctions",
25
+ "exposure_families",
26
+ "relationship_paths",
27
+ "constituent_evidence_enabled",
28
+ "licensed_adapters",
29
+ "precedence",
30
+ "relevant_k",
31
+ "ranked_k",
32
+ }
33
+ _TIE_BREAKERS = (
34
+ "accepted_tag_details_desc",
35
+ "independent_detection_methods_desc",
36
+ "article_timestamp_desc",
37
+ "article_id_asc",
38
+ )
39
+
40
+
41
+ def _mapping(value: object, name: str) -> Mapping[str, Any]:
42
+ if not isinstance(value, Mapping):
43
+ raise TypeError(f"{name} must be a mapping")
44
+ return value
45
+
46
+
47
+ def _exact_keys(value: Mapping[str, Any], allowed: set[str], name: str) -> None:
48
+ unknown = set(value) - allowed
49
+ if unknown:
50
+ raise ValueError(f"unknown {name} keys: {sorted(unknown)}")
51
+
52
+
53
+ def _catalog_path(value: object, name: str, *, base_dir: Path) -> Path:
54
+ if not isinstance(value, str) or not value.strip():
55
+ raise ValueError(f"{name} must be a non-empty path")
56
+ resolved = (base_dir / value).resolve()
57
+ if not resolved.is_file():
58
+ raise ValueError(f"{name} does not exist: {resolved}")
59
+ return resolved
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class ContextRule:
64
+ window_chars: int
65
+ accept_any_clauses: tuple[tuple[str, ...], ...]
66
+ reject_any_terms: tuple[str, ...]
67
+
68
+
69
+ @dataclass(frozen=True, slots=True)
70
+ class MarketWideConjunctionRule:
71
+ actor_phrases: tuple[str, ...]
72
+ actor_identifiers: tuple[str, ...]
73
+ action_phrases: tuple[str, ...]
74
+ object_phrases: tuple[str, ...]
75
+ scope_phrases: tuple[str, ...]
76
+ entity_concept: str
77
+ tag_type: str
78
+ target_id: str
79
+ event_family: str
80
+
81
+
82
+ @dataclass(frozen=True, slots=True)
83
+ class NewsRelevanceConfig:
84
+ version: str
85
+ mode: str
86
+ entity_catalog_path: Path
87
+ lexical_catalog_path: Path
88
+ alias_policy_overrides: Mapping[str, Mapping[str, Any]]
89
+ context_rules: Mapping[str, ContextRule]
90
+ market_wide_conjunctions: Mapping[str, MarketWideConjunctionRule]
91
+ exposure_members: Mapping[ExposureFamily, frozenset[str]]
92
+ relationship_paths: Mapping[str, tuple[str, ...]]
93
+ precedence: tuple[str, ...]
94
+ accepted_link_statuses: frozenset[str]
95
+ ranking_tiers: Mapping[str, int]
96
+ ranking_budget: int
97
+ constituent_evidence_enabled: bool
98
+
99
+
100
+ def load_config(path: str | Path | None = None) -> NewsRelevanceConfig:
101
+ """Load a fail-closed configuration; unknown fields are rejected."""
102
+
103
+ config_path = Path(path).resolve() if path is not None else DEFAULT_CONFIG_PATH
104
+ catalog_root = config_path.parent if path is not None else _PACKAGE_ROOT
105
+ with config_path.open(encoding="utf-8") as handle:
106
+ root = yaml.safe_load(handle)
107
+ root = _mapping(root, "config")
108
+ _exact_keys(root, _TOP_LEVEL_KEYS, "top-level")
109
+ if root.get("mode") != "catalog_lite":
110
+ raise ValueError("only mode=catalog_lite is currently implemented")
111
+ if root.get("constituent_evidence_enabled") is not False:
112
+ raise ValueError("catalog_lite must fail closed on constituent evidence")
113
+ adapters = _mapping(root.get("licensed_adapters"), "licensed_adapters")
114
+ _exact_keys(adapters, {"crsp", "compustat"}, "licensed_adapters")
115
+ if any(value is not False for value in adapters.values()):
116
+ raise ValueError("licensed adapters are unavailable in the public core")
117
+
118
+ catalogs = _mapping(root.get("catalogs"), "catalogs")
119
+ _exact_keys(catalogs, {"entities", "lexical"}, "catalogs")
120
+ normalization = _mapping(root.get("normalization"), "normalization")
121
+ _exact_keys(normalization, {"version", "fuzzy_edits"}, "normalization")
122
+ if normalization != {
123
+ "version": "financial-phrase-nfkc-v2",
124
+ "fuzzy_edits": False,
125
+ }:
126
+ raise ValueError("the conservative v2 normalization contract is required")
127
+
128
+ overrides = _mapping(root.get("alias_policy_overrides"), "alias_policy_overrides")
129
+ override_values: dict[str, Mapping[str, Any]] = {}
130
+ for alias_id, raw in overrides.items():
131
+ value = _mapping(raw, f"alias_policy_overrides.{alias_id}")
132
+ _exact_keys(
133
+ value, {"context_required", "context_rule_id"}, f"alias override {alias_id}"
134
+ )
135
+ if not isinstance(value.get("context_required"), bool):
136
+ raise TypeError(f"alias override {alias_id} needs boolean context_required")
137
+ override_values[str(alias_id)] = MappingProxyType(dict(value))
138
+
139
+ rules = _mapping(root.get("context_rules"), "context_rules")
140
+ context_rules: dict[str, ContextRule] = {}
141
+ for rule_id, raw in rules.items():
142
+ value = _mapping(raw, f"context_rules.{rule_id}")
143
+ _exact_keys(
144
+ value,
145
+ {"window_chars", "accept_any_clauses", "reject_any_terms"},
146
+ f"context rule {rule_id}",
147
+ )
148
+ width = value.get("window_chars")
149
+ if isinstance(width, bool) or not isinstance(width, int) or width <= 0:
150
+ raise ValueError(f"context rule {rule_id} needs positive window_chars")
151
+ context_rules[str(rule_id)] = ContextRule(
152
+ window_chars=width,
153
+ accept_any_clauses=tuple(
154
+ tuple(str(term) for term in clause)
155
+ for clause in value.get("accept_any_clauses", ())
156
+ ),
157
+ reject_any_terms=tuple(
158
+ str(term) for term in value.get("reject_any_terms", ())
159
+ ),
160
+ )
161
+
162
+ raw_conjunctions = _mapping(
163
+ root.get("market_wide_conjunctions"), "market_wide_conjunctions"
164
+ )
165
+ market_wide_conjunctions: dict[str, MarketWideConjunctionRule] = {}
166
+ conjunction_keys = {
167
+ "actor_phrases",
168
+ "actor_identifiers",
169
+ "action_phrases",
170
+ "object_phrases",
171
+ "scope_phrases",
172
+ "entity_concept",
173
+ "tag_type",
174
+ "target_id",
175
+ "event_family",
176
+ }
177
+ for rule_id, raw in raw_conjunctions.items():
178
+ value = _mapping(raw, f"market_wide_conjunctions.{rule_id}")
179
+ _exact_keys(value, conjunction_keys, f"market-wide conjunction {rule_id}")
180
+ sequence_fields = (
181
+ "actor_phrases",
182
+ "actor_identifiers",
183
+ "action_phrases",
184
+ "object_phrases",
185
+ "scope_phrases",
186
+ )
187
+ sequences: dict[str, tuple[str, ...]] = {}
188
+ for field in sequence_fields:
189
+ raw_values = value.get(field)
190
+ if not isinstance(raw_values, list) or not raw_values:
191
+ raise TypeError(
192
+ f"market-wide conjunction {rule_id}.{field} must be a non-empty list"
193
+ )
194
+ sequences[field] = tuple(str(item) for item in raw_values)
195
+ scalar_values = {}
196
+ for field in ("entity_concept", "tag_type", "target_id", "event_family"):
197
+ raw_value = value.get(field)
198
+ if not isinstance(raw_value, str) or not raw_value.strip():
199
+ raise TypeError(
200
+ f"market-wide conjunction {rule_id}.{field} must be non-empty"
201
+ )
202
+ scalar_values[field] = raw_value
203
+ if scalar_values["target_id"] != ExposureFamily.MARKET_WIDE.value:
204
+ raise ValueError("market-wide conjunction target must be MARKET_WIDE")
205
+ market_wide_conjunctions[str(rule_id)] = MarketWideConjunctionRule(
206
+ **sequences,
207
+ **scalar_values,
208
+ )
209
+
210
+ raw_families = _mapping(root.get("exposure_families"), "exposure_families")
211
+ exposure_members: dict[ExposureFamily, frozenset[str]] = {}
212
+ seen_members: set[str] = set()
213
+ for family_name, raw in raw_families.items():
214
+ family = ExposureFamily(str(family_name))
215
+ value = _mapping(raw, f"exposure_families.{family_name}")
216
+ _exact_keys(value, {"canonical_members"}, f"exposure family {family_name}")
217
+ members = frozenset(
218
+ str(member) for member in value.get("canonical_members", ())
219
+ )
220
+ if not members or seen_members & members:
221
+ raise ValueError("exposure family members must be non-empty and disjoint")
222
+ seen_members.update(members)
223
+ exposure_members[family] = members
224
+
225
+ raw_paths = _mapping(root.get("relationship_paths"), "relationship_paths")
226
+ relationship_paths = {
227
+ str(canonical_id): tuple(str(item) for item in path_value)
228
+ for canonical_id, path_value in raw_paths.items()
229
+ }
230
+ if any(not path_value for path_value in relationship_paths.values()):
231
+ raise ValueError("relationship paths cannot be empty")
232
+
233
+ precedence = _mapping(root.get("precedence"), "precedence")
234
+ _exact_keys(precedence, {"detection_methods"}, "precedence")
235
+ methods = tuple(str(item) for item in precedence.get("detection_methods", ()))
236
+ if not methods or len(methods) != len(set(methods)):
237
+ raise ValueError("precedence detection methods must be unique and non-empty")
238
+
239
+ relevant = _mapping(root.get("relevant_k"), "relevant_k")
240
+ _exact_keys(relevant, {"accepted_link_statuses"}, "relevant_k")
241
+ link_statuses = frozenset(
242
+ str(item) for item in relevant.get("accepted_link_statuses", ())
243
+ )
244
+ if not link_statuses:
245
+ raise ValueError("Relevant-K requires accepted link statuses")
246
+
247
+ ranked = _mapping(root.get("ranked_k"), "ranked_k")
248
+ _exact_keys(ranked, {"strategy", "budget", "tiers", "tie_breakers"}, "ranked_k")
249
+ if ranked.get("strategy") != "per_bucket_exposure_lexicographic_v2":
250
+ raise ValueError("unsupported Ranked-K strategy")
251
+ if tuple(ranked.get("tie_breakers", ())) != _TIE_BREAKERS:
252
+ raise ValueError("Ranked-K tie-breakers differ from the frozen contract")
253
+ budget = ranked.get("budget")
254
+ if isinstance(budget, bool) or not isinstance(budget, int) or budget <= 0:
255
+ raise ValueError("ranked_k.budget must be a positive integer")
256
+ tiers_raw = _mapping(ranked.get("tiers"), "ranked_k.tiers")
257
+ tiers = {str(name): int(value) for name, value in tiers_raw.items()}
258
+ if any(value < 0 for value in tiers.values()):
259
+ raise ValueError("ranking tiers must be non-negative")
260
+ missing_conjunction_tiers = {
261
+ rule.tag_type
262
+ for rule in market_wide_conjunctions.values()
263
+ if rule.tag_type not in tiers
264
+ }
265
+ if missing_conjunction_tiers:
266
+ raise ValueError(
267
+ "market-wide conjunction tag types need ranking tiers: "
268
+ f"{sorted(missing_conjunction_tiers)}"
269
+ )
270
+
271
+ version = root.get("version")
272
+ if not isinstance(version, str) or not version.strip():
273
+ raise ValueError("version must be non-empty")
274
+ return NewsRelevanceConfig(
275
+ version=version,
276
+ mode="catalog_lite",
277
+ entity_catalog_path=_catalog_path(
278
+ catalogs.get("entities"), "catalogs.entities", base_dir=catalog_root
279
+ ),
280
+ lexical_catalog_path=_catalog_path(
281
+ catalogs.get("lexical"), "catalogs.lexical", base_dir=catalog_root
282
+ ),
283
+ alias_policy_overrides=MappingProxyType(override_values),
284
+ context_rules=MappingProxyType(context_rules),
285
+ market_wide_conjunctions=MappingProxyType(market_wide_conjunctions),
286
+ exposure_members=MappingProxyType(exposure_members),
287
+ relationship_paths=MappingProxyType(relationship_paths),
288
+ precedence=methods,
289
+ accepted_link_statuses=link_statuses,
290
+ ranking_tiers=MappingProxyType(tiers),
291
+ ranking_budget=budget,
292
+ constituent_evidence_enabled=False,
293
+ )
src/range_news/_core/configs/default.yml ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: "relevance-general-economy-v1"
2
+ mode: "catalog_lite"
3
+
4
+ catalogs:
5
+ entities: "catalogs/entities.yml"
6
+ lexical: "catalogs/lexical.yml"
7
+
8
+ normalization:
9
+ version: "financial-phrase-nfkc-v2"
10
+ fuzzy_edits: false
11
+
12
+ alias_policy_overrides:
13
+ ticker-SPY:
14
+ context_required: true
15
+ context_rule_id: "ticker-collision-SPY-v1"
16
+
17
+ context_rules:
18
+ ticker-collision-SPY-v1:
19
+ window_chars: 128
20
+ accept_any_clauses:
21
+ - [SPDR]
22
+ - [ETF]
23
+ - [fund, "S&P 500"]
24
+ - [shares, "S&P 500"]
25
+ - [ticker, market]
26
+ - [NYSE, Arca]
27
+ reject_any_terms:
28
+ - espionage
29
+ - "spy agency"
30
+ - "spy plane"
31
+ - "spy novel"
32
+ - "spy satellite"
33
+ - "Russian spy"
34
+ - spies
35
+
36
+ market_wide_conjunctions:
37
+ sec-broad-rulemaking:
38
+ actor_phrases:
39
+ - "Securities and Exchange Commission"
40
+ - "U.S. Securities and Exchange Commission"
41
+ actor_identifiers: [SEC]
42
+ action_phrases:
43
+ - adopts
44
+ - adopted
45
+ - approves
46
+ - approved
47
+ - proposes
48
+ - proposed
49
+ - finalizes
50
+ - finalized
51
+ - rescinds
52
+ - rescinded
53
+ object_phrases:
54
+ - rule
55
+ - regulation
56
+ - "disclosure requirement"
57
+ - "market structure"
58
+ scope_phrases:
59
+ - "securities market"
60
+ - "equity market"
61
+ - "public companies"
62
+ - "broker-dealers"
63
+ - "stock exchanges"
64
+ - "clearing agencies"
65
+ - "all exchanges"
66
+ entity_concept: financial_regulator
67
+ tag_type: regulatory
68
+ target_id: MARKET_WIDE
69
+ event_family: broad_rulemaking
70
+
71
+ exposure_families:
72
+ "EXPOSURE:SP500":
73
+ canonical_members: [SPY, "INDEX:SP500"]
74
+ "EXPOSURE:NASDAQ100":
75
+ canonical_members: [QQQ, "INDEX:NASDAQ100"]
76
+ "EXPOSURE:RUSSELL2000":
77
+ canonical_members: [IWM, "INDEX:RUSSELL2000"]
78
+
79
+ relationship_paths:
80
+ SPY: [SPY-tracks-SP500]
81
+ QQQ: [QQQ-tracks-NASDAQ100]
82
+ IWM: [IWM-tracks-RUSSELL2000]
83
+
84
+ constituent_evidence_enabled: false
85
+ licensed_adapters:
86
+ crsp: false
87
+ compustat: false
88
+
89
+ precedence:
90
+ detection_methods:
91
+ - exact_identifier
92
+ - exact_catalog_alias
93
+ - normalized_catalog_alias
94
+ - lexical_phrase
95
+ - regex_pattern
96
+ - event_rule
97
+ - frozen_gliner
98
+ - semantic
99
+ - source_metadata
100
+
101
+ relevant_k:
102
+ accepted_link_statuses:
103
+ - direct_asset
104
+ - direct_benchmark
105
+ - resolved_exposure
106
+ - market_wide
107
+
108
+ ranked_k:
109
+ strategy: "per_bucket_exposure_lexicographic_v2"
110
+ budget: 16
111
+ tiers:
112
+ direct_security: 0
113
+ benchmark_or_index: 1
114
+ constituent_or_issuer: 2
115
+ company_event: 2
116
+ sector_or_industry: 3
117
+ macroeconomic: 4
118
+ regulatory: 4
119
+ market_event: 4
120
+ market_wide: 4
121
+ semantic_financial_other: 5
122
+ tie_breakers:
123
+ - accepted_tag_details_desc
124
+ - independent_detection_methods_desc
125
+ - article_timestamp_desc
126
+ - article_id_asc
src/range_news/_core/indicator.py ADDED
@@ -0,0 +1,1417 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic, traceable article-evidence construction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import html
7
+ import re
8
+ from collections.abc import Iterable, Sequence
9
+ from datetime import datetime
10
+ from typing import Literal
11
+
12
+ from .catalogs import (
13
+ CatalogAlias,
14
+ CatalogBundle,
15
+ active_during,
16
+ alias_spans,
17
+ context_result,
18
+ find_normalized_phrase_spans,
19
+ lexical_spans,
20
+ load_catalogs,
21
+ normalize_financial_phrase,
22
+ )
23
+ from .config import NewsRelevanceConfig, load_config
24
+ from .models import (
25
+ Article,
26
+ ArticleAdmissibilityDecision,
27
+ ArticleAdmissibilityStatus,
28
+ EvidencePath,
29
+ EvidenceStatus,
30
+ ExposureFamily,
31
+ ExposureRelevanceDecision,
32
+ ExposureRelevanceStatus,
33
+ IdentityResolution,
34
+ IdentityStatus,
35
+ Observation,
36
+ ObservationBundle,
37
+ RelevanceDecision,
38
+ parse_article_time,
39
+ )
40
+
41
+ _DETERMINISTIC_METHODS = {
42
+ "exact_identifier",
43
+ "exact_catalog_alias",
44
+ "normalized_catalog_alias",
45
+ "lexical_phrase",
46
+ "regex_pattern",
47
+ "event_rule",
48
+ "source_metadata",
49
+ }
50
+ _COMPLETED_LINKS = {
51
+ "direct_asset",
52
+ "direct_benchmark",
53
+ "resolved_exposure",
54
+ "market_wide",
55
+ }
56
+ _UNRESOLVED_EXPOSURE_TAGS = {
57
+ "direct_security",
58
+ "constituent_or_issuer",
59
+ "sector_or_industry",
60
+ "company_event",
61
+ "semantic_financial_other",
62
+ }
63
+ _MERMAID_MAX_EVIDENCE_CARDS = 4
64
+ _MERMAID_MAX_ARTICLE_PREVIEW_CHARS = 260
65
+ _MERMAID_SNIPPET_CONTEXT_CHARS = 52
66
+
67
+
68
+ class _MermaidExplanation(str):
69
+ """Mermaid source that renders when it is the final value in a notebook cell."""
70
+
71
+ def _repr_markdown_(self) -> str:
72
+ return f"```mermaid\n{self}\n```"
73
+
74
+
75
+ def _stable_id(prefix: str, *values: object) -> str:
76
+ payload = "\0".join(str(value) for value in values).encode("utf-8")
77
+ return f"{prefix}-{hashlib.sha256(payload).hexdigest()[:24]}"
78
+
79
+
80
+ def _method_for(alias: CatalogAlias) -> str:
81
+ return {
82
+ "exact_identifier": "exact_identifier",
83
+ "exact_case_sensitive": "exact_catalog_alias",
84
+ "exact_casefold": "exact_catalog_alias",
85
+ "normalized_phrase": "normalized_catalog_alias",
86
+ }[alias.match_mode]
87
+
88
+
89
+ def _exact_identifier_spans(text: str, value: str) -> list[tuple[int, int]]:
90
+ pattern = re.compile(rf"(?<![A-Za-z0-9_]){re.escape(value)}(?![A-Za-z0-9_])")
91
+ return [(match.start(), match.end()) for match in pattern.finditer(text)]
92
+
93
+
94
+ class RelevanceIndicator:
95
+ """Build catalog-lite decisions without loading a model or using the network."""
96
+
97
+ def __init__(
98
+ self,
99
+ config: NewsRelevanceConfig | None = None,
100
+ catalogs: CatalogBundle | None = None,
101
+ ) -> None:
102
+ self.config = config or load_config()
103
+ self.catalogs = catalogs or load_catalogs(self.config)
104
+
105
+ @classmethod
106
+ def from_yaml(cls, path: str) -> RelevanceIndicator:
107
+ return cls(config=load_config(path))
108
+
109
+ def observe(self, article: Article) -> ObservationBundle:
110
+ """Create deterministic catalog, lexical, and event observations."""
111
+
112
+ text = article.text
113
+ observations: list[Observation] = []
114
+ candidates: list[tuple[CatalogAlias, int, int]] = []
115
+ for alias in self.catalogs.aliases:
116
+ candidates.extend(
117
+ (alias, start, end) for start, end in alias_spans(text, alias)
118
+ )
119
+ longest: dict[tuple[str, int], int] = {}
120
+ for alias, start, end in candidates:
121
+ key = (alias.canonical_id, start)
122
+ longest[key] = max(longest.get(key, 0), end)
123
+ for alias, start, end in candidates:
124
+ if end < longest[(alias.canonical_id, start)]:
125
+ continue
126
+ entity = self.catalogs.entities[alias.canonical_id]
127
+ context_ok, context_reason = True, "context_not_required"
128
+ if alias.requires_context:
129
+ context_ok, context_reason = context_result(
130
+ text,
131
+ start=start,
132
+ end=end,
133
+ rule=self.config.context_rules.get(alias.context_rule_id or ""),
134
+ )
135
+ observations.append(
136
+ Observation(
137
+ observation_id=_stable_id(
138
+ "obs2",
139
+ article.article_id,
140
+ start,
141
+ end,
142
+ entity.entity_concept,
143
+ _method_for(alias),
144
+ alias.alias_id,
145
+ ),
146
+ article_id=article.article_id,
147
+ matched_surface=text[start:end],
148
+ span_start=start,
149
+ span_end=end,
150
+ entity_concept=entity.entity_concept,
151
+ detection_method=_method_for(alias),
152
+ confidence=1.0,
153
+ candidate_canonical_id=alias.canonical_id,
154
+ catalog_entry_id=alias.alias_id,
155
+ catalog_version=self.catalogs.entity_catalog_version,
156
+ tag_type=(
157
+ "direct_security"
158
+ if entity.entity_kind == "fund"
159
+ else (
160
+ "benchmark_or_index"
161
+ if entity.entity_kind == "index"
162
+ else "constituent_or_issuer"
163
+ )
164
+ ),
165
+ context_status=context_reason,
166
+ source_id=alias.source_id,
167
+ )
168
+ )
169
+
170
+ for rule in self.catalogs.lexical_rules:
171
+ for phrase in rule.phrases:
172
+ for start, end in lexical_spans(text, rule, phrase):
173
+ context_ok = True
174
+ if rule.context_any:
175
+ context = text[
176
+ max(0, start - rule.context_window_chars) : min(
177
+ len(text), end + rule.context_window_chars
178
+ )
179
+ ]
180
+ context_ok = any(
181
+ bool(find_normalized_phrase_spans(context, term))
182
+ for term in rule.context_any
183
+ )
184
+ method = rule.detection_method
185
+ stable_values: tuple[object, ...]
186
+ if method == "event_rule":
187
+ stable_values = (article.article_id, start, end, rule.rule_id)
188
+ else:
189
+ stable_values = (
190
+ article.article_id,
191
+ start,
192
+ end,
193
+ rule.entity_concept,
194
+ method,
195
+ self.catalogs.lexical_catalog_version,
196
+ )
197
+ observations.append(
198
+ Observation(
199
+ observation_id=_stable_id(
200
+ "signal2" if method == "event_rule" else "obs2",
201
+ *stable_values,
202
+ ),
203
+ article_id=article.article_id,
204
+ matched_surface=text[start:end],
205
+ span_start=start,
206
+ span_end=end,
207
+ entity_concept=rule.entity_concept,
208
+ detection_method=method,
209
+ confidence=1.0,
210
+ candidate_canonical_id=rule.target_id,
211
+ catalog_version=self.catalogs.lexical_catalog_version,
212
+ rule_id=rule.rule_id,
213
+ tag_type=rule.tag_type,
214
+ event_family=rule.event_family,
215
+ market_wide_authorized=rule.market_wide_authorized,
216
+ context_status=(
217
+ "lexical_context_satisfied"
218
+ if context_ok
219
+ else "lexical_context_not_satisfied"
220
+ ),
221
+ source_id=rule.source_id,
222
+ )
223
+ )
224
+ for rule_id, rule in self.config.market_wide_conjunctions.items():
225
+ actor_spans = [
226
+ span
227
+ for phrase in rule.actor_phrases
228
+ for span in find_normalized_phrase_spans(text, phrase)
229
+ ]
230
+ actor_spans.extend(
231
+ span
232
+ for identifier in rule.actor_identifiers
233
+ for span in _exact_identifier_spans(text, identifier)
234
+ )
235
+ action_spans = [
236
+ span
237
+ for phrase in rule.action_phrases
238
+ for span in find_normalized_phrase_spans(text, phrase)
239
+ ]
240
+ object_spans = [
241
+ span
242
+ for phrase in rule.object_phrases
243
+ for span in find_normalized_phrase_spans(text, phrase)
244
+ ]
245
+ scope_spans = [
246
+ span
247
+ for phrase in rule.scope_phrases
248
+ for span in find_normalized_phrase_spans(text, phrase)
249
+ ]
250
+ if not all((actor_spans, action_spans, object_spans, scope_spans)):
251
+ continue
252
+ contributing = [
253
+ actor_spans[0],
254
+ action_spans[0],
255
+ object_spans[0],
256
+ scope_spans[0],
257
+ ]
258
+ start = min(span[0] for span in contributing)
259
+ end = max(span[1] for span in contributing)
260
+ observations.append(
261
+ Observation(
262
+ observation_id=_stable_id(
263
+ "signal2", article.article_id, start, end, rule_id
264
+ ),
265
+ article_id=article.article_id,
266
+ matched_surface=text[start:end],
267
+ span_start=start,
268
+ span_end=end,
269
+ entity_concept=rule.entity_concept,
270
+ detection_method="event_rule",
271
+ confidence=1.0,
272
+ candidate_canonical_id=rule.target_id,
273
+ catalog_version=self.config.version,
274
+ rule_id=rule_id,
275
+ tag_type=rule.tag_type,
276
+ event_family=rule.event_family,
277
+ market_wide_authorized=True,
278
+ context_status="market_wide_conjunction_satisfied",
279
+ source_id="sec_rules_regulations",
280
+ )
281
+ )
282
+ return ObservationBundle(
283
+ article=article,
284
+ observations=tuple(
285
+ sorted(set(observations), key=lambda row: row.observation_id)
286
+ ),
287
+ )
288
+
289
+ def resolve(
290
+ self,
291
+ observations: ObservationBundle | Sequence[Observation],
292
+ as_of: datetime | str,
293
+ ) -> tuple[IdentityResolution, ...]:
294
+ """Resolve identities at ``as_of`` using effective-dated catalog records."""
295
+
296
+ timestamp = parse_article_time(as_of)
297
+ rows = (
298
+ observations.observations
299
+ if isinstance(observations, ObservationBundle)
300
+ else observations
301
+ )
302
+ output: list[IdentityResolution] = []
303
+ for observation in rows:
304
+ status = IdentityStatus.UNRESOLVED
305
+ canonical_id: str | None = None
306
+ reason = "observation_requires_entity_linker"
307
+ if observation.detection_method in {"frozen_gliner", "semantic"}:
308
+ if observation.candidate_canonical_id is not None:
309
+ raise ValueError(
310
+ "model observations cannot assign canonical identities"
311
+ )
312
+ reason = "semantic_observation_requires_entity_linker"
313
+ elif observation.detection_method == "event_rule":
314
+ reason = "event_signal_without_entity_or_asset_link"
315
+ elif observation.catalog_entry_id is not None:
316
+ alias = next(
317
+ item
318
+ for item in self.catalogs.aliases
319
+ if item.alias_id == observation.catalog_entry_id
320
+ )
321
+ if not active_during(
322
+ timestamp, valid_from=alias.valid_from, valid_to=alias.valid_to
323
+ ):
324
+ status = IdentityStatus.REJECTED
325
+ reason = "inactive_at_article_time"
326
+ elif observation.context_status in {
327
+ "context_clause_not_satisfied",
328
+ "missing_context_rule",
329
+ "explicit_collision_exclusion",
330
+ }:
331
+ status = IdentityStatus.AMBIGUOUS
332
+ reason = observation.context_status
333
+ else:
334
+ status = IdentityStatus.ACCEPTED
335
+ canonical_id = observation.candidate_canonical_id
336
+ reason = "active_catalog_identity"
337
+ elif observation.detection_method == "lexical_phrase":
338
+ if observation.context_status == "lexical_context_not_satisfied":
339
+ status = IdentityStatus.AMBIGUOUS
340
+ reason = "lexical_context_not_satisfied"
341
+ else:
342
+ status = IdentityStatus.ACCEPTED
343
+ canonical_id = observation.candidate_canonical_id
344
+ reason = "accepted_lexical_identity"
345
+ output.append(
346
+ IdentityResolution(
347
+ resolution_id=_stable_id(
348
+ "id2", observation.observation_id, status.value, reason
349
+ ),
350
+ observation_id=observation.observation_id,
351
+ article_id=observation.article_id,
352
+ status=status,
353
+ canonical_id=canonical_id,
354
+ reason_code=reason,
355
+ resolution_method=observation.detection_method,
356
+ catalog_version=observation.catalog_version,
357
+ )
358
+ )
359
+ return tuple(sorted(output, key=lambda row: row.resolution_id))
360
+
361
+ def _primary_observations(
362
+ self,
363
+ observations: Sequence[Observation],
364
+ resolutions: Sequence[IdentityResolution],
365
+ ) -> tuple[Observation, ...]:
366
+ resolution_map = {row.observation_id: row for row in resolutions}
367
+ groups: dict[tuple[object, ...], list[Observation]] = {}
368
+ for observation in observations:
369
+ key = (
370
+ observation.article_id,
371
+ observation.span_start,
372
+ observation.span_end,
373
+ normalize_financial_phrase(observation.matched_surface),
374
+ )
375
+ groups.setdefault(key, []).append(observation)
376
+ priority = {
377
+ method: index for index, method in enumerate(self.config.precedence)
378
+ }
379
+ selected: list[Observation] = []
380
+ for rows in groups.values():
381
+ accepted = [
382
+ row
383
+ for row in rows
384
+ if row.detection_method in _DETERMINISTIC_METHODS
385
+ and resolution_map[row.observation_id].status is IdentityStatus.ACCEPTED
386
+ ]
387
+ deterministic = [
388
+ row for row in rows if row.detection_method in _DETERMINISTIC_METHODS
389
+ ]
390
+ semantic = [
391
+ row
392
+ for row in rows
393
+ if row.detection_method in {"frozen_gliner", "semantic"}
394
+ ]
395
+ selected.extend(deterministic)
396
+ if not accepted and semantic:
397
+ selected.append(
398
+ min(
399
+ semantic,
400
+ key=lambda row: (
401
+ priority.get(row.detection_method, len(priority)),
402
+ -(row.confidence or 0.0),
403
+ row.observation_id,
404
+ ),
405
+ )
406
+ )
407
+ return tuple(sorted(selected, key=lambda row: row.observation_id))
408
+
409
+ def _evidence_paths(
410
+ self,
411
+ article: Article,
412
+ observations: Sequence[Observation],
413
+ resolutions: Sequence[IdentityResolution],
414
+ ) -> tuple[EvidencePath, ...]:
415
+ resolution_map = {row.observation_id: row for row in resolutions}
416
+ evidence: list[EvidencePath] = []
417
+ for observation in self._primary_observations(observations, resolutions):
418
+ resolution = resolution_map[observation.observation_id]
419
+ tag_type = observation.tag_type or "semantic_financial_other"
420
+ identity_ids = (
421
+ ()
422
+ if observation.detection_method == "event_rule"
423
+ else (resolution.resolution_id,)
424
+ )
425
+
426
+ def add(
427
+ *,
428
+ status: EvidenceStatus,
429
+ link_status: str,
430
+ tag_detail: str,
431
+ target_id: str | None,
432
+ family: ExposureFamily | None,
433
+ reason: str,
434
+ relationship_ids: tuple[str, ...] = (),
435
+ market_wide: bool = False,
436
+ tag_type_override: str | None = None,
437
+ _observation: Observation = observation,
438
+ _identity_ids: tuple[str, ...] = identity_ids,
439
+ _tag_type: str = tag_type,
440
+ ) -> None:
441
+ output_tag_type = tag_type_override or (
442
+ "market_wide" if market_wide else _tag_type
443
+ )
444
+ evidence.append(
445
+ EvidencePath(
446
+ evidence_id=_stable_id(
447
+ "ev2", _observation.observation_id, link_status, reason
448
+ ),
449
+ article_id=article.article_id,
450
+ tag_type=output_tag_type,
451
+ tag_detail=tag_detail,
452
+ matched_surface=_observation.matched_surface,
453
+ detection_method=_observation.detection_method,
454
+ status=status,
455
+ link_status=link_status,
456
+ target_id=target_id,
457
+ exposure_family=family,
458
+ reason_code=reason,
459
+ tier=self.config.ranking_tiers[output_tag_type],
460
+ observation_ids=(_observation.observation_id,),
461
+ identity_resolution_ids=_identity_ids,
462
+ relationship_ids=relationship_ids,
463
+ catalog_version=_observation.catalog_version,
464
+ event_family=_observation.event_family,
465
+ explicit_market_wide_rule=market_wide,
466
+ )
467
+ )
468
+
469
+ if resolution.status is IdentityStatus.REJECTED:
470
+ rejected_detail = (
471
+ self.catalogs.entities[
472
+ observation.candidate_canonical_id or ""
473
+ ].canonical_name
474
+ if observation.catalog_entry_id is not None
475
+ else observation.candidate_canonical_id
476
+ or observation.rule_id
477
+ or tag_type
478
+ )
479
+ add(
480
+ status=EvidenceStatus.REJECTED,
481
+ link_status="rejected",
482
+ tag_detail=rejected_detail,
483
+ target_id=None,
484
+ family=None,
485
+ reason=resolution.reason_code,
486
+ )
487
+ continue
488
+ if (
489
+ resolution.status is IdentityStatus.AMBIGUOUS
490
+ and observation.catalog_entry_id is not None
491
+ ):
492
+ entity = self.catalogs.entities[
493
+ observation.candidate_canonical_id or ""
494
+ ]
495
+ add(
496
+ status=EvidenceStatus.UNRESOLVED,
497
+ link_status="unresolved",
498
+ tag_detail=entity.canonical_name,
499
+ target_id=None,
500
+ family=None,
501
+ reason=resolution.reason_code,
502
+ )
503
+ continue
504
+ if observation.detection_method == "event_rule":
505
+ if observation.market_wide_authorized:
506
+ add(
507
+ status=EvidenceStatus.ACCEPTED,
508
+ link_status="market_wide",
509
+ tag_detail=observation.rule_id or tag_type,
510
+ target_id=ExposureFamily.MARKET_WIDE.value,
511
+ family=ExposureFamily.MARKET_WIDE,
512
+ reason="explicit_market_wide_rule",
513
+ market_wide=True,
514
+ )
515
+ continue
516
+ add(
517
+ status=EvidenceStatus.UNRESOLVED,
518
+ link_status="unresolved",
519
+ tag_detail=observation.rule_id or tag_type,
520
+ target_id=None,
521
+ family=None,
522
+ reason="event_signal_without_entity_or_asset_link",
523
+ )
524
+ continue
525
+ if observation.detection_method in {"frozen_gliner", "semantic"}:
526
+ add(
527
+ status=EvidenceStatus.UNRESOLVED,
528
+ link_status="unresolved",
529
+ tag_detail=observation.entity_concept,
530
+ target_id=None,
531
+ family=None,
532
+ reason="semantic_observation_requires_entity_linker",
533
+ )
534
+ continue
535
+ if observation.catalog_entry_id is not None:
536
+ canonical_id = resolution.canonical_id
537
+ if canonical_id is None:
538
+ raise ValueError("accepted catalog identity lost its canonical ID")
539
+ entity = self.catalogs.entities[canonical_id]
540
+ family = self.catalogs.exposure_family_for(canonical_id, self.config)
541
+ if entity.entity_kind == "fund" and family is not None:
542
+ add(
543
+ status=EvidenceStatus.ACCEPTED,
544
+ link_status="direct_asset",
545
+ tag_detail=entity.canonical_name,
546
+ target_id=canonical_id,
547
+ family=family,
548
+ reason="accepted_direct_asset_identity",
549
+ )
550
+ relationships = self.catalogs.active_relationship_path(
551
+ canonical_id, article.timestamp, self.config
552
+ )
553
+ if relationships:
554
+ add(
555
+ status=EvidenceStatus.ACCEPTED,
556
+ link_status="resolved_exposure",
557
+ tag_detail=family.value,
558
+ target_id=family.value,
559
+ family=family,
560
+ reason="active_complete_exposure_path",
561
+ relationship_ids=relationships,
562
+ tag_type_override="benchmark_or_index",
563
+ )
564
+ elif entity.entity_kind == "index" and family is not None:
565
+ add(
566
+ status=EvidenceStatus.ACCEPTED,
567
+ link_status="direct_benchmark",
568
+ tag_detail=entity.canonical_name,
569
+ target_id=canonical_id,
570
+ family=family,
571
+ reason="accepted_direct_benchmark_identity",
572
+ )
573
+ else:
574
+ add(
575
+ status=EvidenceStatus.UNRESOLVED,
576
+ link_status="unresolved",
577
+ tag_detail=entity.canonical_name,
578
+ target_id=canonical_id,
579
+ family=None,
580
+ reason="identity_resolved_without_relevance_path",
581
+ )
582
+ continue
583
+ market_wide = (
584
+ resolution.status is IdentityStatus.ACCEPTED
585
+ and observation.market_wide_authorized
586
+ )
587
+ add(
588
+ status=(
589
+ EvidenceStatus.ACCEPTED
590
+ if market_wide
591
+ else EvidenceStatus.UNRESOLVED
592
+ ),
593
+ link_status="market_wide" if market_wide else "unresolved",
594
+ tag_detail=observation.rule_id or observation.entity_concept,
595
+ target_id=(
596
+ "MARKET_WIDE" if market_wide else observation.candidate_canonical_id
597
+ ),
598
+ family=ExposureFamily.MARKET_WIDE if market_wide else None,
599
+ reason=(
600
+ "explicit_market_wide_rule"
601
+ if market_wide
602
+ else "identity_resolved_without_relevance_path"
603
+ ),
604
+ market_wide=market_wide,
605
+ )
606
+ return tuple(sorted(set(evidence), key=lambda row: row.evidence_id))
607
+
608
+ def assess_article(
609
+ self,
610
+ article: Article,
611
+ observations: Sequence[Observation],
612
+ resolutions: Sequence[IdentityResolution],
613
+ ) -> ArticleAdmissibilityDecision:
614
+ paths = self._evidence_paths(article, observations, resolutions)
615
+ accepted = tuple(
616
+ path for path in paths if path.status is EvidenceStatus.ACCEPTED
617
+ )
618
+ unresolved = tuple(
619
+ path for path in paths if path.status is EvidenceStatus.UNRESOLVED
620
+ )
621
+ rejected = tuple(
622
+ path for path in paths if path.status is EvidenceStatus.REJECTED
623
+ )
624
+ event_paths = tuple(
625
+ path for path in unresolved if path.event_family is not None
626
+ )
627
+ accepted_identities = {
628
+ row.observation_id
629
+ for row in resolutions
630
+ if row.status is IdentityStatus.ACCEPTED
631
+ }
632
+ accepted_nonsemantic = any(
633
+ row.observation_id in accepted_identities
634
+ and row.detection_method in _DETERMINISTIC_METHODS
635
+ for row in observations
636
+ )
637
+ semantic_unresolved = any(
638
+ path.detection_method in {"semantic", "frozen_gliner"}
639
+ for path in unresolved
640
+ )
641
+ if accepted or accepted_nonsemantic or event_paths:
642
+ status = ArticleAdmissibilityStatus.ACCEPTED
643
+ reason = "accepted_financial_evidence"
644
+ elif semantic_unresolved:
645
+ status = ArticleAdmissibilityStatus.PLAUSIBLE_UNRESOLVED
646
+ reason = "semantic_financial_evidence_requires_resolution"
647
+ else:
648
+ status = ArticleAdmissibilityStatus.REJECTED
649
+ reason = "no_accepted_financial_evidence"
650
+ return ArticleAdmissibilityDecision(
651
+ article_id=article.article_id,
652
+ status=status,
653
+ accepted_evidence=accepted,
654
+ unresolved_evidence=unresolved,
655
+ rejected_evidence=rejected,
656
+ reason_codes=(reason,),
657
+ )
658
+
659
+ def assess_exposure(
660
+ self,
661
+ article: Article,
662
+ exposure_family: ExposureFamily | str,
663
+ observations: Sequence[Observation],
664
+ resolutions: Sequence[IdentityResolution],
665
+ ) -> ExposureRelevanceDecision:
666
+ family = ExposureFamily(exposure_family)
667
+ paths = self._evidence_paths(article, observations, resolutions)
668
+ supported = tuple(
669
+ path
670
+ for path in paths
671
+ if path.status is EvidenceStatus.ACCEPTED
672
+ and path.link_status in _COMPLETED_LINKS
673
+ and path.exposure_family is family
674
+ )
675
+ if supported:
676
+ return ExposureRelevanceDecision(
677
+ article_id=article.article_id,
678
+ exposure_family=family,
679
+ status=ExposureRelevanceStatus.SUPPORTED,
680
+ evidence_paths=supported,
681
+ reason_codes=("completed_relevance_path",),
682
+ )
683
+ completed_elsewhere = any(
684
+ path.status is EvidenceStatus.ACCEPTED
685
+ and path.link_status in _COMPLETED_LINKS
686
+ for path in paths
687
+ )
688
+ unresolved = tuple(
689
+ path
690
+ for path in paths
691
+ if path.status is EvidenceStatus.UNRESOLVED
692
+ and path.tag_type in _UNRESOLVED_EXPOSURE_TAGS
693
+ and path.reason_code
694
+ not in {
695
+ "explicit_collision_exclusion",
696
+ "context_clause_not_satisfied",
697
+ "lexical_context_not_satisfied",
698
+ }
699
+ )
700
+ if (
701
+ family is not ExposureFamily.MARKET_WIDE
702
+ and not completed_elsewhere
703
+ and unresolved
704
+ ):
705
+ return ExposureRelevanceDecision(
706
+ article_id=article.article_id,
707
+ exposure_family=family,
708
+ status=ExposureRelevanceStatus.PLAUSIBLE_UNRESOLVED,
709
+ unresolved_paths=unresolved,
710
+ reason_codes=("missing_effective_dated_exposure_path",),
711
+ )
712
+ return ExposureRelevanceDecision(
713
+ article_id=article.article_id,
714
+ exposure_family=family,
715
+ status=ExposureRelevanceStatus.NOT_SUPPORTED,
716
+ reason_codes=("no_completed_relevance_path",),
717
+ )
718
+
719
+ def predict(
720
+ self,
721
+ article: Article,
722
+ exposure_families: Iterable[ExposureFamily | str] | None = None,
723
+ semantic_observations: Sequence[Observation] = (),
724
+ ) -> RelevanceDecision:
725
+ bundle = self.observe(article)
726
+ for observation in semantic_observations:
727
+ if observation.article_id != article.article_id:
728
+ raise ValueError("semantic observation article ID mismatch")
729
+ if observation.span_end > len(article.text):
730
+ raise ValueError("semantic observation span falls outside article text")
731
+ if (
732
+ article.text[observation.span_start : observation.span_end]
733
+ != observation.matched_surface
734
+ ):
735
+ raise ValueError(
736
+ "semantic observation offsets do not match article text"
737
+ )
738
+ if observation.detection_method not in {"semantic", "frozen_gliner"}:
739
+ raise ValueError(
740
+ "injected observations must use a semantic detection method"
741
+ )
742
+ if observation.candidate_canonical_id is not None:
743
+ raise ValueError("semantic observations cannot assign canonical IDs")
744
+ observations = tuple(
745
+ sorted(
746
+ {*bundle.observations, *semantic_observations},
747
+ key=lambda row: row.observation_id,
748
+ )
749
+ )
750
+ resolutions = self.resolve(observations, article.timestamp)
751
+ article_decision = self.assess_article(article, observations, resolutions)
752
+ requested = (
753
+ tuple(ExposureFamily(item) for item in exposure_families)
754
+ if exposure_families is not None
755
+ else tuple(ExposureFamily)
756
+ )
757
+ exposure_decisions = {
758
+ family: self.assess_exposure(article, family, observations, resolutions)
759
+ for family in requested
760
+ }
761
+ return RelevanceDecision(
762
+ article=article,
763
+ article_decision=article_decision,
764
+ exposure_decisions=exposure_decisions,
765
+ observations=observations,
766
+ resolutions=resolutions,
767
+ )
768
+
769
+ def explain(
770
+ self,
771
+ decision: RelevanceDecision,
772
+ *,
773
+ output_format: Literal["text", "mermaid"] = "text",
774
+ detail: Literal["summary", "details", "audit"] = "summary",
775
+ ) -> str:
776
+ """Explain a completed prediction without rerunning detection logic.
777
+
778
+ The default is a short explanation. ``detail="details"`` exposes the full
779
+ recorded evidence. ``detail="audit"`` remains as a backwards-compatible
780
+ alias. Mermaid results render natively in JupyterLab 4.1+ and Notebook 7.1+
781
+ while remaining string-compatible for logging or saving.
782
+ """
783
+
784
+ if detail not in {"summary", "details", "audit"}:
785
+ raise ValueError("detail must be 'summary' or 'details'")
786
+ show_details = detail in {"details", "audit"}
787
+ if output_format == "text":
788
+ return (
789
+ self._audit_text_explanation(decision)
790
+ if show_details
791
+ else self._text_explanation(decision)
792
+ )
793
+ if output_format == "mermaid":
794
+ source = (
795
+ self._audit_mermaid_explanation(decision)
796
+ if show_details
797
+ else self._mermaid_explanation(decision)
798
+ )
799
+ return _MermaidExplanation(source)
800
+ raise ValueError("output_format must be 'text' or 'mermaid'")
801
+
802
+ @staticmethod
803
+ def _group_paths(
804
+ paths: Sequence[EvidencePath],
805
+ ) -> tuple[tuple[EvidencePath, int], ...]:
806
+ """Collapse repeated occurrence-level evidence for readable explanations."""
807
+
808
+ groups: dict[tuple[object, ...], list[EvidencePath]] = {}
809
+ for path in paths:
810
+ key = (
811
+ path.tag_type,
812
+ path.tag_detail,
813
+ path.matched_surface,
814
+ path.detection_method,
815
+ path.status,
816
+ path.link_status,
817
+ path.target_id,
818
+ path.exposure_family,
819
+ path.reason_code,
820
+ path.relationship_ids,
821
+ path.catalog_version,
822
+ path.event_family,
823
+ path.explicit_market_wide_rule,
824
+ )
825
+ groups.setdefault(key, []).append(path)
826
+ return tuple(
827
+ (min(rows, key=lambda item: item.evidence_id), len(rows))
828
+ for _, rows in sorted(
829
+ groups.items(),
830
+ key=lambda item: min(row.evidence_id for row in item[1]),
831
+ )
832
+ )
833
+
834
+ @staticmethod
835
+ def _path_label(path: EvidencePath, occurrence_count: int = 1) -> str:
836
+ lineage = ", ".join(path.relationship_ids) or "no relationship lineage"
837
+ surface = path.matched_surface.replace("\r", " ").replace("\n", " ")
838
+ if len(surface) > 120:
839
+ surface = surface[:117].rstrip() + "..."
840
+ occurrences = (
841
+ f"; occurrences={occurrence_count}" if occurrence_count > 1 else ""
842
+ )
843
+ return (
844
+ f"{surface!r}: {path.tag_type}/{path.tag_detail}; "
845
+ f"status={path.status.value}; method={path.detection_method}; "
846
+ f"link={path.link_status}; reason={path.reason_code}; "
847
+ f"relationships={lineage}{occurrences}"
848
+ )
849
+
850
+ @staticmethod
851
+ def _status_heading(decision: RelevanceDecision) -> str:
852
+ return {
853
+ "relevant": "KEEP",
854
+ "needs_review": "CHECK",
855
+ "not_relevant": "IGNORE",
856
+ }[decision.status]
857
+
858
+ def _text_explanation(self, decision: RelevanceDecision) -> str:
859
+ lines = [
860
+ self._status_heading(decision),
861
+ f"Why: {decision.why}",
862
+ ]
863
+ if decision.relevant_to:
864
+ lines.append(f"Markets: {', '.join(decision.relevant_to)}")
865
+ lines.append(f"Article: {decision.article.article_id}")
866
+ return "\n".join(lines)
867
+
868
+ @staticmethod
869
+ def _mermaid_text(value: object) -> str:
870
+ """Escape user text for a quoted Mermaid Markdown string."""
871
+
872
+ text = " ".join(str(value).replace("\r", " ").replace("\n", " ").split())
873
+ text = text.replace("\\", "/").replace('"', "'").replace("`", "'")
874
+ for character in ("*", "_", "[", "]"):
875
+ text = text.replace(character, f"\\{character}")
876
+ return html.escape(text, quote=False)
877
+
878
+ @classmethod
879
+ def _highlighted_span(
880
+ cls,
881
+ text: str,
882
+ *,
883
+ window_start: int,
884
+ match_start: int,
885
+ match_end: int,
886
+ window_end: int,
887
+ ) -> str:
888
+ open_marker = "\ue000"
889
+ close_marker = "\ue001"
890
+ value = (
891
+ text[window_start:match_start]
892
+ + open_marker
893
+ + text[match_start:match_end]
894
+ + close_marker
895
+ + text[match_end:window_end]
896
+ )
897
+ rendered = cls._mermaid_text(value)
898
+ return rendered.replace(open_marker, "**").replace(close_marker, "**")
899
+
900
+ @staticmethod
901
+ def _visual_observation_groups(
902
+ decision: RelevanceDecision,
903
+ ) -> tuple[tuple[Observation, int], ...]:
904
+ groups: dict[tuple[object, ...], list[Observation]] = {}
905
+ for observation in decision.observations:
906
+ key = (
907
+ observation.matched_surface.casefold(),
908
+ observation.entity_concept,
909
+ observation.detection_method,
910
+ observation.tag_type,
911
+ observation.context_status,
912
+ )
913
+ groups.setdefault(key, []).append(observation)
914
+ return tuple(
915
+ (min(rows, key=lambda row: (row.span_start, row.observation_id)), len(rows))
916
+ for _, rows in sorted(
917
+ groups.items(),
918
+ key=lambda item: min(
919
+ (row.span_start, row.observation_id) for row in item[1]
920
+ ),
921
+ )
922
+ )
923
+
924
+ @classmethod
925
+ def _article_preview_lines(
926
+ cls,
927
+ decision: RelevanceDecision,
928
+ visible_groups: Sequence[tuple[Observation, int]],
929
+ ) -> list[str]:
930
+ article = decision.article
931
+ lines = [
932
+ f"**ARTICLE TEXT** · {cls._mermaid_text(article.article_id)}",
933
+ (
934
+ f"{cls._mermaid_text(article.source)} · "
935
+ f"{cls._mermaid_text(article.timestamp.isoformat())}"
936
+ ),
937
+ ]
938
+ text = article.text
939
+ observations = sorted(
940
+ decision.observations,
941
+ key=lambda row: (row.span_start, -(row.span_end - row.span_start)),
942
+ )
943
+ non_overlapping: list[Observation] = []
944
+ covered_until = -1
945
+ for observation in observations:
946
+ if observation.span_start < covered_until:
947
+ continue
948
+ non_overlapping.append(observation)
949
+ covered_until = observation.span_end
950
+
951
+ if len(text) <= _MERMAID_MAX_ARTICLE_PREVIEW_CHARS:
952
+ cursor = 0
953
+ pieces: list[str] = []
954
+ for observation in non_overlapping:
955
+ pieces.append(text[cursor : observation.span_start])
956
+ pieces.append("\ue000" + observation.matched_surface + "\ue001")
957
+ cursor = observation.span_end
958
+ pieces.append(text[cursor:])
959
+ preview = (
960
+ cls._mermaid_text("".join(pieces))
961
+ .replace("\ue000", "**")
962
+ .replace("\ue001", "**")
963
+ )
964
+ lines.append(preview or cls._mermaid_text(text))
965
+ return lines
966
+
967
+ if not visible_groups:
968
+ prefix = cls._mermaid_text(
969
+ text[:_MERMAID_MAX_ARTICLE_PREVIEW_CHARS].rstrip()
970
+ )
971
+ lines.append(f"{prefix} …")
972
+ return lines
973
+
974
+ lines.append(
975
+ f"{len(text):,} characters · relevant snippets shown below"
976
+ )
977
+ for observation, _ in visible_groups[:3]:
978
+ start = max(0, observation.span_start - _MERMAID_SNIPPET_CONTEXT_CHARS)
979
+ end = min(len(text), observation.span_end + _MERMAID_SNIPPET_CONTEXT_CHARS)
980
+ snippet = cls._highlighted_span(
981
+ text,
982
+ window_start=start,
983
+ match_start=observation.span_start,
984
+ match_end=observation.span_end,
985
+ window_end=end,
986
+ )
987
+ lines.append(
988
+ f"{'…' if start else ''}{snippet}{'…' if end < len(text) else ''}"
989
+ )
990
+ return lines
991
+
992
+ def _evidence_card(
993
+ self,
994
+ observation: Observation,
995
+ occurrence_count: int,
996
+ resolution_by_observation: dict[str, IdentityResolution],
997
+ ) -> tuple[list[str], str]:
998
+ tag_label = {
999
+ "direct_security": "Fund or ticker",
1000
+ "benchmark_or_index": "Index or benchmark",
1001
+ "constituent_or_issuer": "Company or issuer",
1002
+ "macroeconomic": "Macroeconomic signal",
1003
+ "macroeconomic_indicator": "Economic indicator",
1004
+ "financial_regulator": "Financial regulator",
1005
+ "sector_or_industry": "Sector or industry",
1006
+ "company_event": "Company event",
1007
+ "market_wide": "Broad-market signal",
1008
+ }.get(
1009
+ observation.tag_type or "",
1010
+ (observation.tag_type or observation.entity_concept)
1011
+ .replace("_", " ")
1012
+ .title(),
1013
+ )
1014
+ method_label = {
1015
+ "exact_identifier": "exact symbol",
1016
+ "exact_catalog_alias": "exact catalog name",
1017
+ "normalized_catalog_alias": "normalized catalog name",
1018
+ "lexical_phrase": "saved financial phrase",
1019
+ "regex_pattern": "financial pattern",
1020
+ "event_rule": "event rule",
1021
+ "source_metadata": "source metadata",
1022
+ "semantic": "semantic candidate",
1023
+ "frozen_gliner": "semantic entity candidate",
1024
+ }.get(
1025
+ observation.detection_method,
1026
+ observation.detection_method.replace("_", " "),
1027
+ )
1028
+ count = f" × {occurrence_count}" if occurrence_count > 1 else ""
1029
+ lines = [
1030
+ f"**{self._mermaid_text(observation.matched_surface)}**{count}",
1031
+ f"{self._mermaid_text(tag_label)} · {self._mermaid_text(method_label)}",
1032
+ ]
1033
+
1034
+ resolution = resolution_by_observation.get(observation.observation_id)
1035
+ if resolution is None:
1036
+ lines.append("△ Identity was not resolved")
1037
+ style = "review"
1038
+ elif resolution.status is IdentityStatus.ACCEPTED:
1039
+ canonical = resolution.canonical_id or observation.entity_concept
1040
+ entity = self.catalogs.entities.get(canonical)
1041
+ canonical_label = entity.canonical_name if entity is not None else canonical
1042
+ lines.append(f"✓ Identity: {self._mermaid_text(canonical_label)}")
1043
+ style = "verified"
1044
+ elif resolution.status is IdentityStatus.AMBIGUOUS:
1045
+ lines.append("✕ Identity is ambiguous")
1046
+ style = "blocked"
1047
+ elif resolution.status is IdentityStatus.REJECTED:
1048
+ lines.append("✕ Identity was rejected")
1049
+ style = "blocked"
1050
+ else:
1051
+ lines.append("△ Identity still needs resolution")
1052
+ style = "review"
1053
+
1054
+ context_line = {
1055
+ "context_clause_satisfied": "✓ Financial context confirmed",
1056
+ "lexical_context_satisfied": "✓ Financial context confirmed",
1057
+ "context_not_required": "• Context check not required",
1058
+ "not_required": "• Context check not required",
1059
+ "explicit_collision_exclusion": "✕ Blocked by non-financial context",
1060
+ "context_clause_not_satisfied": "✕ Required financial context missing",
1061
+ "lexical_context_not_satisfied": "✕ Required financial context missing",
1062
+ }.get(observation.context_status)
1063
+ if context_line:
1064
+ lines.append(context_line)
1065
+ if observation.context_status in {
1066
+ "explicit_collision_exclusion",
1067
+ "context_clause_not_satisfied",
1068
+ "lexical_context_not_satisfied",
1069
+ }:
1070
+ style = "blocked"
1071
+ return lines, style
1072
+
1073
+ @classmethod
1074
+ def _market_card_lines(
1075
+ cls,
1076
+ family: ExposureFamily,
1077
+ paths: Sequence[EvidencePath],
1078
+ ) -> list[str]:
1079
+ grouped: dict[tuple[str, str, str | None], list[EvidencePath]] = {}
1080
+ for path in paths:
1081
+ key = (path.link_status, path.matched_surface.casefold(), path.target_id)
1082
+ grouped.setdefault(key, []).append(path)
1083
+ resolved_surfaces = {
1084
+ path.matched_surface.casefold()
1085
+ for path in paths
1086
+ if path.link_status == "resolved_exposure"
1087
+ }
1088
+ ordered = sorted(
1089
+ grouped.values(),
1090
+ key=lambda rows: (
1091
+ {
1092
+ "resolved_exposure": 0,
1093
+ "direct_benchmark": 1,
1094
+ "market_wide": 2,
1095
+ "direct_asset": 3,
1096
+ }.get(rows[0].link_status, 4),
1097
+ rows[0].matched_surface.casefold(),
1098
+ ),
1099
+ )
1100
+ lines = [f"**{cls._mermaid_text(family.label)}**"]
1101
+ for rows in ordered:
1102
+ path = rows[0]
1103
+ if (
1104
+ path.link_status == "direct_asset"
1105
+ and path.matched_surface.casefold() in resolved_surfaces
1106
+ ):
1107
+ continue
1108
+ count = f" × {len(rows)}" if len(rows) > 1 else ""
1109
+ surface = cls._mermaid_text(path.matched_surface)
1110
+ if path.link_status == "resolved_exposure":
1111
+ value = f"{surface}{count} → {cls._mermaid_text(family.label)}"
1112
+ elif path.link_status == "direct_benchmark":
1113
+ value = f"{surface}{count} is the benchmark"
1114
+ elif path.link_status == "direct_asset":
1115
+ value = f"{surface}{count} is a tracked fund"
1116
+ elif path.link_status == "market_wide":
1117
+ value = f"{surface}{count} activates an approved broad-market rule"
1118
+ else:
1119
+ continue
1120
+ lines.append(value)
1121
+ if len(lines) == 4:
1122
+ break
1123
+ return lines
1124
+
1125
+ @staticmethod
1126
+ def _mermaid_node(node_id: str, lines: Sequence[str], style: str) -> str:
1127
+ return f' {node_id}["`' + "\n".join(lines) + f'`"]:::{style}'
1128
+
1129
+ def _mermaid_explanation(self, decision: RelevanceDecision) -> str:
1130
+ groups = self._visual_observation_groups(decision)
1131
+ visible_groups = groups[:_MERMAID_MAX_EVIDENCE_CARDS]
1132
+ resolution_by_observation = {
1133
+ row.observation_id: row for row in decision.resolutions
1134
+ }
1135
+ lines = [
1136
+ "flowchart TD",
1137
+ " accTitle: News relevance evidence path",
1138
+ (
1139
+ " accDescr: Highlighted article evidence, verification gates, "
1140
+ "market links, and the final relevance decision"
1141
+ ),
1142
+ self._mermaid_node(
1143
+ "A",
1144
+ self._article_preview_lines(decision, visible_groups),
1145
+ "article",
1146
+ ),
1147
+ ' subgraph FOUND["1 · Evidence found in the article"]',
1148
+ " direction LR",
1149
+ ]
1150
+
1151
+ evidence_nodes: list[str] = []
1152
+ if visible_groups:
1153
+ for index, (observation, count) in enumerate(visible_groups, start=1):
1154
+ node = f"E{index}"
1155
+ card_lines, style = self._evidence_card(
1156
+ observation, count, resolution_by_observation
1157
+ )
1158
+ lines.append(
1159
+ " " + self._mermaid_node(node, card_lines, style).lstrip()
1160
+ )
1161
+ evidence_nodes.append(node)
1162
+ hidden_count = len(groups) - len(visible_groups)
1163
+ if hidden_count:
1164
+ node = "EMORE"
1165
+ lines.append(
1166
+ " "
1167
+ + self._mermaid_node(
1168
+ node,
1169
+ (
1170
+ f"**+{hidden_count} more distinct match(es)**",
1171
+ "Use detail='details' for the full evidence trail",
1172
+ ),
1173
+ "muted",
1174
+ ).lstrip()
1175
+ )
1176
+ evidence_nodes.append(node)
1177
+ else:
1178
+ node = "E0"
1179
+ lines.append(
1180
+ " "
1181
+ + self._mermaid_node(
1182
+ node,
1183
+ (
1184
+ "**No financial evidence found**",
1185
+ "No saved alias, phrase, event, or semantic candidate matched",
1186
+ ),
1187
+ "muted",
1188
+ ).lstrip()
1189
+ )
1190
+ evidence_nodes.append(node)
1191
+ lines.append(" end")
1192
+ for node in evidence_nodes:
1193
+ lines.append(f' A -->|"scan text"| {node}')
1194
+
1195
+ lines.append(' G1{"2 · Article evidence admissible?"}:::gate')
1196
+ for node in evidence_nodes:
1197
+ lines.append(f" {node} --> G1")
1198
+
1199
+ result_style = {
1200
+ "relevant": "accepted",
1201
+ "needs_review": "review",
1202
+ "not_relevant": "rejected",
1203
+ }[decision.status]
1204
+ result_lines = {
1205
+ "relevant": [
1206
+ "**KEEP**",
1207
+ "Financial context accepted",
1208
+ (
1209
+ "Markets: " + ", ".join(decision.relevant_to)
1210
+ if decision.relevant_to
1211
+ else "No verified configured market"
1212
+ ),
1213
+ ],
1214
+ "needs_review": [
1215
+ "**CHECK**",
1216
+ "Potential financial evidence needs review",
1217
+ "No verified configured market",
1218
+ ],
1219
+ "not_relevant": [
1220
+ "**IGNORE**",
1221
+ "No accepted financial evidence",
1222
+ ],
1223
+ }[decision.status]
1224
+ result_lines = [
1225
+ self._mermaid_text(value) if "**" not in value else value
1226
+ for value in result_lines
1227
+ ]
1228
+ lines.append(self._mermaid_node("R", result_lines, result_style))
1229
+
1230
+ if decision.status == "not_relevant":
1231
+ lines.append(' G1 -->|"No · filter stops here"| R')
1232
+ else:
1233
+ admissibility_label = (
1234
+ "Yes · deterministic financial evidence"
1235
+ if decision.status == "relevant"
1236
+ else "Not yet · unresolved semantic evidence"
1237
+ )
1238
+ lines.append(
1239
+ f' G1 -->|"{admissibility_label}"| '
1240
+ 'G2{"3 · Configured market path complete?"}:::gate'
1241
+ )
1242
+ supported = [
1243
+ (family, family_decision)
1244
+ for family in ExposureFamily
1245
+ if (
1246
+ (family_decision := decision.exposure_decisions.get(family))
1247
+ is not None
1248
+ and family_decision.status is ExposureRelevanceStatus.SUPPORTED
1249
+ )
1250
+ ]
1251
+ if supported:
1252
+ lines.extend(
1253
+ (
1254
+ ' subgraph PATHS["Verified market relationships"]',
1255
+ " direction LR",
1256
+ )
1257
+ )
1258
+ market_nodes: list[str] = []
1259
+ for index, (family, family_decision) in enumerate(supported, start=1):
1260
+ node = f"M{index}"
1261
+ lines.append(
1262
+ " "
1263
+ + self._mermaid_node(
1264
+ node,
1265
+ self._market_card_lines(
1266
+ family, family_decision.evidence_paths
1267
+ ),
1268
+ "market",
1269
+ ).lstrip()
1270
+ )
1271
+ market_nodes.append(node)
1272
+ lines.append(" end")
1273
+ for node in market_nodes:
1274
+ lines.append(f' G2 -->|"Yes"| {node}')
1275
+ lines.append(f" {node} --> R")
1276
+ else:
1277
+ unresolved = any(
1278
+ family_decision.status
1279
+ is ExposureRelevanceStatus.PLAUSIBLE_UNRESOLVED
1280
+ for family_decision in decision.exposure_decisions.values()
1281
+ )
1282
+ market_lines = (
1283
+ (
1284
+ "**No completed market relationship**",
1285
+ "Potential links still need entity resolution",
1286
+ )
1287
+ if unresolved
1288
+ else (
1289
+ "**No completed market relationship**",
1290
+ "The article can still be kept as financial context",
1291
+ )
1292
+ )
1293
+ market_style = "review" if unresolved else "muted"
1294
+ lines.append(self._mermaid_node("M0", market_lines, market_style))
1295
+ lines.append(' G2 -->|"No"| M0')
1296
+ lines.append(" M0 --> R")
1297
+
1298
+ lines.extend(
1299
+ (
1300
+ " classDef article fill:#f8fafc,stroke:#475467,color:#101828,stroke-width:2px",
1301
+ " classDef verified fill:#ecfdf3,stroke:#079455,color:#074d31,stroke-width:1.5px",
1302
+ " classDef review fill:#fffaeb,stroke:#dc6803,color:#713b12,stroke-width:1.5px",
1303
+ " classDef blocked fill:#fef3f2,stroke:#d92d20,color:#7a271a,stroke-width:1.5px",
1304
+ " classDef gate fill:#eff8ff,stroke:#1570ef,color:#1849a9,stroke-width:2px",
1305
+ " classDef market fill:#f4f3ff,stroke:#6938ef,color:#42307d,stroke-width:1.5px",
1306
+ " classDef accepted fill:#dcfae6,stroke:#079455,color:#05603a,stroke-width:3px",
1307
+ " classDef rejected fill:#fee4e2,stroke:#d92d20,color:#7a271a,stroke-width:3px",
1308
+ " classDef muted fill:#f2f4f7,stroke:#98a2b3,color:#475467,stroke-dasharray:4 3",
1309
+ " style FOUND fill:#fcfcfd,stroke:#d0d5dd,stroke-dasharray:3 3",
1310
+ " style PATHS fill:#fcfcfd,stroke:#d0d5dd,stroke-dasharray:3 3",
1311
+ " linkStyle default stroke:#98a2b3,stroke-width:1.5px",
1312
+ )
1313
+ )
1314
+ if not any(line.startswith(' subgraph PATHS') for line in lines):
1315
+ lines.remove(
1316
+ " style PATHS fill:#fcfcfd,stroke:#d0d5dd,stroke-dasharray:3 3"
1317
+ )
1318
+ return "\n".join(lines)
1319
+
1320
+ def _audit_text_explanation(self, decision: RelevanceDecision) -> str:
1321
+ lines = [
1322
+ f"Article {decision.article.article_id}",
1323
+ f"Article admissibility: {decision.article_decision.status.value}",
1324
+ ]
1325
+ article_paths = self._group_paths(
1326
+ (
1327
+ *decision.article_decision.accepted_evidence,
1328
+ *decision.article_decision.unresolved_evidence,
1329
+ *decision.article_decision.rejected_evidence,
1330
+ )
1331
+ )
1332
+ if article_paths:
1333
+ lines.append("Article evidence:")
1334
+ lines.extend(
1335
+ f"- {self._path_label(path, count)}" for path, count in article_paths
1336
+ )
1337
+ else:
1338
+ reasons = ", ".join(decision.article_decision.reason_codes) or "none"
1339
+ lines.append(f"Article evidence: none ({reasons})")
1340
+
1341
+ for family in ExposureFamily:
1342
+ family_decision = decision.exposure_decisions.get(family)
1343
+ if family_decision is None:
1344
+ continue
1345
+ lines.append(f"{family.value}: {family_decision.status.value}")
1346
+ paths = self._group_paths(
1347
+ (*family_decision.evidence_paths, *family_decision.unresolved_paths)
1348
+ )
1349
+ if paths:
1350
+ lines.extend(
1351
+ f"- {self._path_label(path, count)}" for path, count in paths
1352
+ )
1353
+ else:
1354
+ reasons = ", ".join(family_decision.reason_codes) or "none"
1355
+ lines.append(f"- no supporting path ({reasons})")
1356
+ return "\n".join(lines)
1357
+
1358
+ def _audit_mermaid_explanation(self, decision: RelevanceDecision) -> str:
1359
+ def label(value: object) -> str:
1360
+ return (
1361
+ str(value)
1362
+ .replace("\\", "\\\\")
1363
+ .replace('"', "'")
1364
+ .replace("\r", " ")
1365
+ .replace("\n", " ")
1366
+ )
1367
+
1368
+ article_id = label(decision.article.article_id)
1369
+ article_status = decision.article_decision.status.value
1370
+ lines = [
1371
+ "flowchart TD",
1372
+ f' A["Article: {article_id}"] --> AD["Article admissibility: {article_status}"]',
1373
+ ]
1374
+ article_paths = self._group_paths(
1375
+ (
1376
+ *decision.article_decision.accepted_evidence,
1377
+ *decision.article_decision.unresolved_evidence,
1378
+ *decision.article_decision.rejected_evidence,
1379
+ )
1380
+ )
1381
+ if article_paths:
1382
+ for index, (path, count) in enumerate(article_paths, start=1):
1383
+ lines.append(
1384
+ f' A --> AE{index}["{label(self._path_label(path, count))}"]'
1385
+ )
1386
+ lines.append(f" AE{index} --> AD")
1387
+ else:
1388
+ reasons = ", ".join(decision.article_decision.reason_codes) or "none"
1389
+ lines.append(f' A --> AN["No article evidence: {label(reasons)}"]')
1390
+ lines.append(" AN --> AD")
1391
+
1392
+ for family_index, family in enumerate(ExposureFamily, start=1):
1393
+ family_decision = decision.exposure_decisions.get(family)
1394
+ if family_decision is None:
1395
+ continue
1396
+ family_node = f"F{family_index}"
1397
+ lines.append(
1398
+ f' A --> {family_node}["{family.value}: '
1399
+ f'{family_decision.status.value}"]'
1400
+ )
1401
+ paths = self._group_paths(
1402
+ (*family_decision.evidence_paths, *family_decision.unresolved_paths)
1403
+ )
1404
+ if paths:
1405
+ for path_index, (path, count) in enumerate(paths, start=1):
1406
+ path_node = f"{family_node}P{path_index}"
1407
+ lines.append(
1408
+ f' {path_node}["{label(self._path_label(path, count))}"] '
1409
+ f"--> {family_node}"
1410
+ )
1411
+ else:
1412
+ reasons = ", ".join(family_decision.reason_codes) or "none"
1413
+ lines.append(
1414
+ f' {family_node}N["No supporting path: {label(reasons)}"] '
1415
+ f"--> {family_node}"
1416
+ )
1417
+ return "\n".join(lines)
src/range_news/_core/models.py ADDED
@@ -0,0 +1,418 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Immutable public data contracts for news relevance decisions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ from collections.abc import Mapping
7
+ from dataclasses import dataclass, field
8
+ from datetime import datetime
9
+ from enum import Enum
10
+ from types import MappingProxyType
11
+ from zoneinfo import ZoneInfo
12
+
13
+ _ARTICLE_TIMEZONE = ZoneInfo("America/New_York")
14
+
15
+
16
+ def parse_article_time(value: datetime | str) -> datetime:
17
+ """Parse an article time, interpreting naive values as America/New_York."""
18
+
19
+ if isinstance(value, datetime):
20
+ timestamp = value
21
+ elif isinstance(value, str) and value.strip():
22
+ timestamp = datetime.fromisoformat(value.strip())
23
+ else:
24
+ raise TypeError("timestamp must be a datetime or non-empty ISO-8601 string")
25
+ if timestamp.tzinfo is None:
26
+ timestamp = timestamp.replace(tzinfo=_ARTICLE_TIMEZONE)
27
+ return timestamp
28
+
29
+
30
+ class ArticleAdmissibilityStatus(str, Enum):
31
+ ACCEPTED = "accepted"
32
+ PLAUSIBLE_UNRESOLVED = "plausible_unresolved"
33
+ REJECTED = "rejected"
34
+
35
+
36
+ class ExposureRelevanceStatus(str, Enum):
37
+ SUPPORTED = "supported"
38
+ PLAUSIBLE_UNRESOLVED = "plausible_unresolved"
39
+ NOT_SUPPORTED = "not_supported"
40
+
41
+
42
+ class ExposureFamily(str, Enum):
43
+ SP500 = "EXPOSURE:SP500"
44
+ NASDAQ100 = "EXPOSURE:NASDAQ100"
45
+ RUSSELL2000 = "EXPOSURE:RUSSELL2000"
46
+ MARKET_WIDE = "MARKET_WIDE"
47
+
48
+ @property
49
+ def label(self) -> str:
50
+ """Return the concise name used in user-facing summaries."""
51
+
52
+ return {
53
+ ExposureFamily.SP500: "S&P 500",
54
+ ExposureFamily.NASDAQ100: "Nasdaq-100",
55
+ ExposureFamily.RUSSELL2000: "Russell 2000",
56
+ ExposureFamily.MARKET_WIDE: "Broad market",
57
+ }[self]
58
+
59
+
60
+ class IdentityStatus(str, Enum):
61
+ ACCEPTED = "accepted"
62
+ AMBIGUOUS = "ambiguous"
63
+ REJECTED = "rejected"
64
+ UNRESOLVED = "unresolved"
65
+
66
+
67
+ class EvidenceStatus(str, Enum):
68
+ ACCEPTED = "accepted"
69
+ UNRESOLVED = "unresolved"
70
+ REJECTED = "rejected"
71
+
72
+
73
+ @dataclass(frozen=True, slots=True)
74
+ class Article:
75
+ article_id: str
76
+ timestamp: datetime | str
77
+ text: str
78
+ source: str = "unknown"
79
+ content_id: str | None = None
80
+ decision_bucket: str | None = None
81
+
82
+ def __post_init__(self) -> None:
83
+ if not self.article_id.strip():
84
+ raise ValueError("article_id must be non-empty")
85
+ if not isinstance(self.text, str) or not self.text.strip():
86
+ raise ValueError("text must be a non-empty string")
87
+ object.__setattr__(self, "timestamp", parse_article_time(self.timestamp))
88
+ object.__setattr__(self, "content_id", self.content_id or self.article_id)
89
+
90
+
91
+ @dataclass(frozen=True, slots=True)
92
+ class Observation:
93
+ observation_id: str
94
+ article_id: str
95
+ matched_surface: str
96
+ span_start: int
97
+ span_end: int
98
+ entity_concept: str
99
+ detection_method: str
100
+ confidence: float | None = None
101
+ candidate_canonical_id: str | None = None
102
+ catalog_entry_id: str | None = None
103
+ catalog_version: str | None = None
104
+ rule_id: str | None = None
105
+ tag_type: str | None = None
106
+ event_family: str | None = None
107
+ market_wide_authorized: bool = False
108
+ context_status: str = "not_required"
109
+ source_id: str | None = None
110
+
111
+ def __post_init__(self) -> None:
112
+ if self.span_start < 0 or self.span_end <= self.span_start:
113
+ raise ValueError("observation offsets must describe a non-empty span")
114
+ if not self.observation_id or not self.article_id or not self.matched_surface:
115
+ raise ValueError("observation identity and surface must be non-empty")
116
+ if self.confidence is not None and not 0 <= self.confidence <= 1:
117
+ raise ValueError("confidence must be in [0, 1]")
118
+
119
+
120
+ @dataclass(frozen=True, slots=True)
121
+ class ObservationBundle:
122
+ article: Article
123
+ observations: tuple[Observation, ...] = ()
124
+
125
+
126
+ @dataclass(frozen=True, slots=True)
127
+ class IdentityResolution:
128
+ resolution_id: str
129
+ observation_id: str
130
+ article_id: str
131
+ status: IdentityStatus
132
+ canonical_id: str | None
133
+ reason_code: str
134
+ resolution_method: str
135
+ catalog_version: str | None = None
136
+
137
+ def __post_init__(self) -> None:
138
+ if self.status is IdentityStatus.ACCEPTED and not self.canonical_id:
139
+ raise ValueError("accepted identities require a canonical_id")
140
+ if self.status is not IdentityStatus.ACCEPTED and self.canonical_id is not None:
141
+ raise ValueError("only accepted identities may assign canonical_id")
142
+
143
+
144
+ @dataclass(frozen=True, slots=True)
145
+ class EvidencePath:
146
+ evidence_id: str
147
+ article_id: str
148
+ tag_type: str
149
+ tag_detail: str
150
+ matched_surface: str
151
+ detection_method: str
152
+ status: EvidenceStatus
153
+ link_status: str
154
+ target_id: str | None
155
+ exposure_family: ExposureFamily | None
156
+ reason_code: str
157
+ tier: int
158
+ observation_ids: tuple[str, ...] = ()
159
+ identity_resolution_ids: tuple[str, ...] = ()
160
+ relationship_ids: tuple[str, ...] = ()
161
+ catalog_version: str | None = None
162
+ event_family: str | None = None
163
+ explicit_market_wide_rule: bool = False
164
+
165
+ def __post_init__(self) -> None:
166
+ if self.tier < 0:
167
+ raise ValueError("evidence tier must be non-negative")
168
+ if self.status is EvidenceStatus.ACCEPTED and self.exposure_family is None:
169
+ raise ValueError("accepted exposure evidence requires an exposure family")
170
+ if self.link_status == "resolved_exposure" and not self.relationship_ids:
171
+ raise ValueError("resolved exposure evidence requires relationship lineage")
172
+ if self.exposure_family is ExposureFamily.MARKET_WIDE and (
173
+ self.link_status != "market_wide" or not self.explicit_market_wide_rule
174
+ ):
175
+ raise ValueError("MARKET_WIDE requires an explicitly authorized rule")
176
+
177
+
178
+ @dataclass(frozen=True, slots=True)
179
+ class ArticleAdmissibilityDecision:
180
+ article_id: str
181
+ status: ArticleAdmissibilityStatus
182
+ accepted_evidence: tuple[EvidencePath, ...] = ()
183
+ unresolved_evidence: tuple[EvidencePath, ...] = ()
184
+ rejected_evidence: tuple[EvidencePath, ...] = ()
185
+ reason_codes: tuple[str, ...] = ()
186
+
187
+
188
+ @dataclass(frozen=True, slots=True)
189
+ class ExposureRelevanceDecision:
190
+ article_id: str
191
+ exposure_family: ExposureFamily
192
+ status: ExposureRelevanceStatus
193
+ evidence_paths: tuple[EvidencePath, ...] = ()
194
+ unresolved_paths: tuple[EvidencePath, ...] = ()
195
+ reason_codes: tuple[str, ...] = ()
196
+
197
+ def __post_init__(self) -> None:
198
+ if self.status is ExposureRelevanceStatus.SUPPORTED and not self.evidence_paths:
199
+ raise ValueError("supported exposure decisions require evidence lineage")
200
+ if (
201
+ self.status is ExposureRelevanceStatus.PLAUSIBLE_UNRESOLVED
202
+ and not self.unresolved_paths
203
+ ):
204
+ raise ValueError(
205
+ "plausible unresolved exposure decisions require unresolved evidence"
206
+ )
207
+ if any(
208
+ path.exposure_family is not self.exposure_family
209
+ for path in self.evidence_paths
210
+ ):
211
+ raise ValueError("evidence paths must support the decision exposure family")
212
+
213
+
214
+ @dataclass(frozen=True, slots=True, repr=False)
215
+ class RelevanceDecision:
216
+ article: Article
217
+ article_decision: ArticleAdmissibilityDecision
218
+ exposure_decisions: Mapping[ExposureFamily, ExposureRelevanceDecision]
219
+ observations: tuple[Observation, ...] = ()
220
+ resolutions: tuple[IdentityResolution, ...] = ()
221
+
222
+ def __post_init__(self) -> None:
223
+ if self.article.article_id != self.article_decision.article_id:
224
+ raise ValueError("article decision provenance mismatch")
225
+ decisions = dict(self.exposure_decisions)
226
+ if any(key is not value.exposure_family for key, value in decisions.items()):
227
+ raise ValueError("exposure decision mapping keys must match their values")
228
+
229
+ observation_ids = {row.observation_id for row in self.observations}
230
+ if len(observation_ids) != len(self.observations):
231
+ raise ValueError("observation IDs must be unique")
232
+ if any(row.article_id != self.article.article_id for row in self.observations):
233
+ raise ValueError("observation provenance mismatch")
234
+
235
+ resolution_ids = {row.resolution_id for row in self.resolutions}
236
+ if len(resolution_ids) != len(self.resolutions):
237
+ raise ValueError("identity resolution IDs must be unique")
238
+ for resolution in self.resolutions:
239
+ if resolution.article_id != self.article.article_id:
240
+ raise ValueError("identity resolution provenance mismatch")
241
+ if resolution.observation_id not in observation_ids:
242
+ raise ValueError(
243
+ "identity resolution references an unknown observation"
244
+ )
245
+
246
+ article_paths = (
247
+ *self.article_decision.accepted_evidence,
248
+ *self.article_decision.unresolved_evidence,
249
+ *self.article_decision.rejected_evidence,
250
+ )
251
+ exposure_paths = tuple(
252
+ path
253
+ for decision in decisions.values()
254
+ for path in (*decision.evidence_paths, *decision.unresolved_paths)
255
+ )
256
+ for path in (*article_paths, *exposure_paths):
257
+ if path.article_id != self.article.article_id:
258
+ raise ValueError("evidence path provenance mismatch")
259
+ if not set(path.observation_ids).issubset(observation_ids):
260
+ raise ValueError("evidence path references an unknown observation")
261
+ if not set(path.identity_resolution_ids).issubset(resolution_ids):
262
+ raise ValueError(
263
+ "evidence path references an unknown identity resolution"
264
+ )
265
+ object.__setattr__(self, "exposure_decisions", MappingProxyType(decisions))
266
+
267
+ @property
268
+ def status(self) -> str:
269
+ """Return the plain-language overall result."""
270
+
271
+ return {
272
+ ArticleAdmissibilityStatus.ACCEPTED: "relevant",
273
+ ArticleAdmissibilityStatus.PLAUSIBLE_UNRESOLVED: "needs_review",
274
+ ArticleAdmissibilityStatus.REJECTED: "not_relevant",
275
+ }[self.article_decision.status]
276
+
277
+ @property
278
+ def accepted(self) -> bool:
279
+ """Whether the article has accepted financial evidence."""
280
+
281
+ return self.article_decision.status is ArticleAdmissibilityStatus.ACCEPTED
282
+
283
+ @property
284
+ def relevant_to(self) -> tuple[str, ...]:
285
+ """Plain-language markets with a completed relevance path."""
286
+
287
+ return tuple(
288
+ family.label
289
+ for family in ExposureFamily
290
+ if (
291
+ (result := self.exposure_decisions.get(family)) is not None
292
+ and result.status is ExposureRelevanceStatus.SUPPORTED
293
+ )
294
+ )
295
+
296
+ @staticmethod
297
+ def _surface(path: EvidencePath) -> str:
298
+ surface = " ".join(path.matched_surface.split())
299
+ return surface if len(surface) <= 60 else surface[:57].rstrip() + "..."
300
+
301
+ @classmethod
302
+ def _why_for_path(cls, path: EvidencePath) -> str:
303
+ surface = cls._surface(path)
304
+ if path.link_status == "resolved_exposure" and path.exposure_family:
305
+ return f"{surface} connects to {path.exposure_family.label}"
306
+ if path.link_status == "direct_asset":
307
+ return f"fund or ticker found: {surface}"
308
+ if path.link_status == "direct_benchmark":
309
+ return f"index or benchmark found: {surface}"
310
+ if path.link_status == "market_wide":
311
+ return f"broad-market news found: {surface}"
312
+ return {
313
+ "macroeconomic_indicator": f"economic indicator found: {surface}",
314
+ "financial_regulator": f"financial regulator found: {surface}",
315
+ "sector_or_industry": f"industry or sector found: {surface}",
316
+ "company_event": f"company event found: {surface}",
317
+ }.get(path.tag_type, f"financial information found: {surface}")
318
+
319
+ @property
320
+ def why(self) -> str:
321
+ """Return a short explanation; detailed lineage remains available separately."""
322
+
323
+ if self.article_decision.status is ArticleAdmissibilityStatus.REJECTED:
324
+ return "No financial match found."
325
+
326
+ paths = (
327
+ self.article_decision.accepted_evidence
328
+ if self.article_decision.status is ArticleAdmissibilityStatus.ACCEPTED
329
+ else self.article_decision.unresolved_evidence
330
+ )
331
+ phrases: list[str] = []
332
+ seen: set[str] = set()
333
+ for path in sorted(
334
+ paths,
335
+ key=lambda item: (
336
+ item.tier,
337
+ item.matched_surface.casefold(),
338
+ item.evidence_id,
339
+ ),
340
+ ):
341
+ key = path.matched_surface.casefold().strip()
342
+ if key in seen:
343
+ continue
344
+ seen.add(key)
345
+ phrases.append(self._why_for_path(path))
346
+
347
+ if not phrases:
348
+ return (
349
+ "Possible financial information was found, but it needs review."
350
+ if self.status == "needs_review"
351
+ else "Financial information found."
352
+ )
353
+ visible = phrases[:3]
354
+ reason = "; ".join(visible)
355
+ if len(phrases) > len(visible):
356
+ reason += f"; and {len(phrases) - len(visible)} more match(es)"
357
+ if self.status == "needs_review":
358
+ reason += ", but it could not be connected to a configured market"
359
+ return reason[0].upper() + reason[1:] + "."
360
+
361
+ def summary(self) -> dict[str, object]:
362
+ """Return the small, JSON-friendly result most callers need."""
363
+
364
+ return {
365
+ "article_id": self.article.article_id,
366
+ "status": self.status,
367
+ "accepted": self.accepted,
368
+ "why": self.why,
369
+ "relevant_to": list(self.relevant_to),
370
+ }
371
+
372
+ def __repr__(self) -> str:
373
+ return (
374
+ "RelevanceDecision("
375
+ f"article_id={self.article.article_id!r}, "
376
+ f"status={self.status!r}, "
377
+ f"relevant_to={self.relevant_to!r}, "
378
+ f"why={self.why!r})"
379
+ )
380
+
381
+ def _repr_markdown_(self) -> str:
382
+ """Render a concise decision card in Jupyter without an IPython dependency."""
383
+
384
+ def markdown(value: object) -> str:
385
+ escaped = html.escape(str(value))
386
+ for character in ("\\", "`", "*", "_", "[", "]"):
387
+ escaped = escaped.replace(character, f"\\{character}")
388
+ return escaped
389
+
390
+ heading = {
391
+ "relevant": "Keep",
392
+ "needs_review": "Check",
393
+ "not_relevant": "Ignore",
394
+ }[self.status]
395
+ lines = [f"### {heading}", "", f"**Why:** {markdown(self.why)}"]
396
+ if self.relevant_to:
397
+ lines.extend(("", f"**Markets:** {', '.join(self.relevant_to)}"))
398
+ lines.extend(
399
+ (
400
+ "",
401
+ (
402
+ f"*{markdown(self.article.source)} | "
403
+ f"{markdown(self.article.timestamp.isoformat())} | "
404
+ f"{markdown(self.article.article_id)}*"
405
+ ),
406
+ )
407
+ )
408
+ return "\n".join(lines)
409
+
410
+
411
+ @dataclass(frozen=True, slots=True)
412
+ class RankedCandidate:
413
+ decision_bucket: str
414
+ exposure_family: ExposureFamily
415
+ rank: int
416
+ article_id: str
417
+ accepted_evidence_ids: tuple[str, ...]
418
+ decision: RelevanceDecision = field(compare=False, repr=False)
src/range_news/_taxonomy_impl.py ADDED
@@ -0,0 +1,629 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Immutable taxonomy composition and deterministic ranking contracts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from abc import ABC, abstractmethod
8
+ from collections.abc import Callable, Iterable, Mapping, Sequence
9
+ from dataclasses import dataclass, field, fields, is_dataclass, replace
10
+ from datetime import datetime, timezone
11
+ from enum import Enum
12
+ from importlib.resources import files as resource_files
13
+ from pathlib import Path
14
+ from types import MappingProxyType
15
+
16
+ from ._core.catalogs import (
17
+ CatalogAlias,
18
+ CatalogBundle,
19
+ CatalogEntity,
20
+ LexicalRule,
21
+ active_during,
22
+ load_catalogs,
23
+ )
24
+ from ._core.config import NewsRelevanceConfig, load_config
25
+ from ._core.models import (
26
+ EvidencePath,
27
+ ExposureFamily,
28
+ ExposureRelevanceStatus,
29
+ RankedCandidate,
30
+ RelevanceDecision,
31
+ )
32
+
33
+ _MATCH_MODES = {
34
+ "exact_identifier",
35
+ "exact_case_sensitive",
36
+ "exact_casefold",
37
+ "normalized_phrase",
38
+ }
39
+ _ENTITY_KINDS = {"fund", "index", "issuer_family"}
40
+ _DETECTION_METHODS = {"lexical_phrase", "event_rule"}
41
+
42
+
43
+ def _key(value: object) -> str:
44
+ if isinstance(value, Enum):
45
+ return str(value.value)
46
+ return str(value)
47
+
48
+
49
+ def _stable_value(value: object) -> object:
50
+ if isinstance(value, Enum):
51
+ return value.value
52
+ if isinstance(value, Path):
53
+ return value.name
54
+ if is_dataclass(value):
55
+ return {
56
+ item.name: _stable_value(getattr(value, item.name))
57
+ for item in fields(value)
58
+ }
59
+ if isinstance(value, Mapping):
60
+ return {
61
+ _key(key): _stable_value(item)
62
+ for key, item in sorted(value.items(), key=lambda pair: _key(pair[0]))
63
+ }
64
+ if isinstance(value, (set, frozenset)):
65
+ return sorted((_stable_value(item) for item in value), key=repr)
66
+ if isinstance(value, (tuple, list)):
67
+ return [_stable_value(item) for item in value]
68
+ return value
69
+
70
+
71
+ def _fingerprint(config: NewsRelevanceConfig, catalogs: CatalogBundle) -> str:
72
+ config_payload = {
73
+ item.name: _stable_value(getattr(config, item.name))
74
+ for item in fields(config)
75
+ if item.name not in {"entity_catalog_path", "lexical_catalog_path"}
76
+ }
77
+ payload = {
78
+ "config": config_payload,
79
+ "catalogs": _stable_value(catalogs),
80
+ }
81
+ encoded = json.dumps(
82
+ payload,
83
+ sort_keys=True,
84
+ separators=(",", ":"),
85
+ ensure_ascii=False,
86
+ ).encode("utf-8")
87
+ return hashlib.sha256(encoded).hexdigest()
88
+
89
+
90
+ def copy_default_taxonomy(
91
+ destination: str | Path,
92
+ *,
93
+ overwrite: bool = False,
94
+ ) -> Path:
95
+ """Copy an editable default taxonomy bundle and return its config path.
96
+
97
+ The resulting directory contains ``taxonomy.yml`` plus the two catalog
98
+ files it references. Call :meth:`Taxonomy.from_yaml` or
99
+ :meth:`RelevanceEngine.from_yaml` after editing the copies.
100
+ """
101
+
102
+ root = Path(destination).expanduser().resolve()
103
+ package = resource_files("range_news._core")
104
+ copies = (
105
+ (package.joinpath("configs", "default.yml"), root / "taxonomy.yml"),
106
+ (
107
+ package.joinpath("catalogs", "entities.yml"),
108
+ root / "catalogs" / "entities.yml",
109
+ ),
110
+ (
111
+ package.joinpath("catalogs", "lexical.yml"),
112
+ root / "catalogs" / "lexical.yml",
113
+ ),
114
+ )
115
+ existing = [target for _source, target in copies if target.exists()]
116
+ if existing and not overwrite:
117
+ names = ", ".join(str(path) for path in existing)
118
+ raise FileExistsError(
119
+ f"taxonomy files already exist: {names}; pass overwrite=True to replace them"
120
+ )
121
+
122
+ for source, target in copies:
123
+ target.parent.mkdir(parents=True, exist_ok=True)
124
+ target.write_bytes(source.read_bytes())
125
+ return root / "taxonomy.yml"
126
+
127
+
128
+ def _validate(config: NewsRelevanceConfig, catalogs: CatalogBundle) -> None:
129
+ if not isinstance(config, NewsRelevanceConfig):
130
+ raise TypeError("config must be a NewsRelevanceConfig")
131
+ if not isinstance(catalogs, CatalogBundle):
132
+ raise TypeError("catalogs must be a CatalogBundle")
133
+
134
+ for canonical_id, entity in catalogs.entities.items():
135
+ if canonical_id != entity.canonical_id:
136
+ raise ValueError(
137
+ f"entity mapping key {canonical_id!r} does not match "
138
+ f"canonical ID {entity.canonical_id!r}"
139
+ )
140
+ for name, value in (
141
+ ("canonical_id", entity.canonical_id),
142
+ ("canonical_name", entity.canonical_name),
143
+ ("entity_concept", entity.entity_concept),
144
+ ):
145
+ if not isinstance(value, str) or not value.strip():
146
+ raise ValueError(f"entity {canonical_id!r} has empty {name}")
147
+ if entity.entity_kind not in _ENTITY_KINDS:
148
+ raise ValueError(
149
+ f"entity {canonical_id!r} has unsupported kind "
150
+ f"{entity.entity_kind!r}"
151
+ )
152
+ active_during(
153
+ entity.valid_from,
154
+ valid_from=entity.valid_from,
155
+ valid_to=entity.valid_to,
156
+ )
157
+
158
+ alias_ids: set[str] = set()
159
+ for alias in catalogs.aliases:
160
+ if not isinstance(alias.alias_id, str) or not alias.alias_id.strip():
161
+ raise ValueError("alias ID must be a non-empty string")
162
+ if not isinstance(alias.surface, str) or not alias.surface.strip():
163
+ raise ValueError(f"alias {alias.alias_id!r} has an empty surface")
164
+ if alias.alias_id in alias_ids:
165
+ raise ValueError(f"duplicate alias ID: {alias.alias_id}")
166
+ alias_ids.add(alias.alias_id)
167
+ if alias.canonical_id not in catalogs.entities:
168
+ raise ValueError(
169
+ f"alias {alias.alias_id!r} references unknown entity "
170
+ f"{alias.canonical_id!r}"
171
+ )
172
+ if alias.match_mode not in _MATCH_MODES:
173
+ raise ValueError(
174
+ f"alias {alias.alias_id!r} has unsupported match mode "
175
+ f"{alias.match_mode!r}"
176
+ )
177
+ if alias.requires_context and alias.context_rule_id not in config.context_rules:
178
+ raise ValueError(
179
+ f"alias {alias.alias_id!r} requires unknown context rule "
180
+ f"{alias.context_rule_id!r}"
181
+ )
182
+ active_during(
183
+ alias.valid_from,
184
+ valid_from=alias.valid_from,
185
+ valid_to=alias.valid_to,
186
+ )
187
+
188
+ rule_ids: set[str] = set()
189
+ for rule in catalogs.lexical_rules:
190
+ if not isinstance(rule.rule_id, str) or not rule.rule_id.strip():
191
+ raise ValueError("lexical rule ID must be a non-empty string")
192
+ if rule.rule_id in rule_ids:
193
+ raise ValueError(f"duplicate lexical rule ID: {rule.rule_id}")
194
+ rule_ids.add(rule.rule_id)
195
+ if not rule.phrases or any(
196
+ not isinstance(phrase, str) or not phrase.strip() for phrase in rule.phrases
197
+ ):
198
+ raise ValueError(
199
+ f"lexical rule {rule.rule_id!r} needs non-empty string phrases"
200
+ )
201
+ if rule.detection_method not in _DETECTION_METHODS:
202
+ raise ValueError(
203
+ f"lexical rule {rule.rule_id!r} has unsupported detection method "
204
+ f"{rule.detection_method!r}"
205
+ )
206
+ if rule.tag_type not in config.ranking_tiers:
207
+ raise ValueError(
208
+ f"lexical tag type {rule.tag_type!r} has no configured ranking tier"
209
+ )
210
+ if (
211
+ isinstance(rule.context_window_chars, bool)
212
+ or not isinstance(rule.context_window_chars, int)
213
+ or rule.context_window_chars < 0
214
+ ):
215
+ raise ValueError(
216
+ f"lexical rule {rule.rule_id!r} has an invalid context window"
217
+ )
218
+
219
+ memberships: dict[str, ExposureFamily] = {}
220
+ for family, members in config.exposure_members.items():
221
+ if not isinstance(family, ExposureFamily):
222
+ raise ValueError(f"unsupported exposure family: {family!r}")
223
+ for canonical_id in members:
224
+ if canonical_id not in catalogs.entities:
225
+ raise ValueError(
226
+ f"exposure family {family.value} references unknown entity "
227
+ f"{canonical_id!r}"
228
+ )
229
+ previous = memberships.setdefault(canonical_id, family)
230
+ if previous is not family:
231
+ raise ValueError(
232
+ f"entity {canonical_id!r} belongs to both {previous.value} "
233
+ f"and {family.value}"
234
+ )
235
+
236
+
237
+ @dataclass(frozen=True, slots=True)
238
+ class Taxonomy:
239
+ """A validated, immutable configuration and catalog bundle."""
240
+
241
+ config: NewsRelevanceConfig
242
+ catalogs: CatalogBundle
243
+ fingerprint: str = field(init=False)
244
+
245
+ def __post_init__(self) -> None:
246
+ _validate(self.config, self.catalogs)
247
+ object.__setattr__(
248
+ self, "fingerprint", _fingerprint(self.config, self.catalogs)
249
+ )
250
+
251
+ @property
252
+ def version(self) -> str:
253
+ """Return the version of the decision configuration."""
254
+
255
+ return self.config.version
256
+
257
+ @property
258
+ def entity_ids(self) -> tuple[str, ...]:
259
+ """Return canonical entity identifiers in stable order."""
260
+
261
+ return tuple(sorted(self.catalogs.entities))
262
+
263
+ @property
264
+ def lexical_rule_ids(self) -> tuple[str, ...]:
265
+ """Return lexical rule identifiers in stable order."""
266
+
267
+ return tuple(sorted(rule.rule_id for rule in self.catalogs.lexical_rules))
268
+
269
+ def describe(self) -> dict[str, object]:
270
+ """Return a compact, serializable summary of this taxonomy."""
271
+
272
+ return {
273
+ "version": self.version,
274
+ "fingerprint": self.fingerprint,
275
+ "entity_catalog_version": self.catalogs.entity_catalog_version,
276
+ "lexical_catalog_version": self.catalogs.lexical_catalog_version,
277
+ "entities": len(self.catalogs.entities),
278
+ "aliases": len(self.catalogs.aliases),
279
+ "relationships": len(self.catalogs.relationships),
280
+ "lexical_rules": len(self.catalogs.lexical_rules),
281
+ "exposure_families": tuple(family.value for family in ExposureFamily),
282
+ }
283
+
284
+ @classmethod
285
+ def from_yaml(cls, path: str | Path) -> "Taxonomy":
286
+ """Load a full maintained news-relevance configuration file."""
287
+
288
+ config = load_config(path)
289
+ return Taxonomy(config=config, catalogs=load_catalogs(config))
290
+
291
+ def extend(
292
+ self,
293
+ configure: Callable[["TaxonomyBuilder"], object],
294
+ ) -> "Taxonomy":
295
+ """Return a new taxonomy after applying one builder callback."""
296
+
297
+ if not callable(configure):
298
+ raise TypeError("configure must be callable")
299
+ builder = TaxonomyBuilder(self)
300
+ configure(builder)
301
+ return builder.build()
302
+
303
+
304
+ class DefaultTaxonomy(Taxonomy):
305
+ """The bundled financial taxonomy, optionally extended by `configure()`."""
306
+
307
+ def __init__(self) -> None:
308
+ config = load_config()
309
+ catalogs = load_catalogs(config)
310
+ builder = TaxonomyBuilder._from_parts(config, catalogs)
311
+ self.configure(builder)
312
+ built = builder.build()
313
+ super().__init__(config=built.config, catalogs=built.catalogs)
314
+
315
+ def configure(self, builder: "TaxonomyBuilder") -> None:
316
+ """Override in a subclass to add rules before the taxonomy is frozen."""
317
+
318
+
319
+ class TaxonomyBuilder:
320
+ """Mutable construction surface whose `build()` result is immutable."""
321
+
322
+ __slots__ = (
323
+ "_aliases",
324
+ "_catalogs",
325
+ "_changed_entities",
326
+ "_changed_lexical",
327
+ "_config",
328
+ "_entities",
329
+ "_exposure_members",
330
+ "_lexical_rules",
331
+ )
332
+
333
+ def __init__(self, base: Taxonomy) -> None:
334
+ if not isinstance(base, Taxonomy):
335
+ raise TypeError("base must be a Taxonomy")
336
+ self._initialize(base.config, base.catalogs)
337
+
338
+ @classmethod
339
+ def _from_parts(
340
+ cls,
341
+ config: NewsRelevanceConfig,
342
+ catalogs: CatalogBundle,
343
+ ) -> "TaxonomyBuilder":
344
+ builder = cls.__new__(cls)
345
+ builder._initialize(config, catalogs)
346
+ return builder
347
+
348
+ def _initialize(
349
+ self,
350
+ config: NewsRelevanceConfig,
351
+ catalogs: CatalogBundle,
352
+ ) -> None:
353
+ self._config = config
354
+ self._catalogs = catalogs
355
+ self._entities = dict(catalogs.entities)
356
+ self._aliases = list(catalogs.aliases)
357
+ self._lexical_rules = list(catalogs.lexical_rules)
358
+ self._exposure_members = {
359
+ family: set(members) for family, members in config.exposure_members.items()
360
+ }
361
+ self._changed_entities = False
362
+ self._changed_lexical = False
363
+
364
+ def add_entity(
365
+ self,
366
+ *,
367
+ canonical_id: str,
368
+ canonical_name: str,
369
+ entity_concept: str,
370
+ entity_kind: str,
371
+ valid_from: str,
372
+ valid_to: str | None = None,
373
+ exposure_family: ExposureFamily | str | None = None,
374
+ ) -> "TaxonomyBuilder":
375
+ if not canonical_id.strip() or canonical_id in self._entities:
376
+ raise ValueError(f"entity ID must be new and non-empty: {canonical_id!r}")
377
+ if entity_kind not in _ENTITY_KINDS:
378
+ raise ValueError(f"unsupported entity kind: {entity_kind!r}")
379
+ entity = CatalogEntity(
380
+ canonical_id=canonical_id,
381
+ canonical_name=canonical_name,
382
+ entity_concept=entity_concept,
383
+ entity_kind=entity_kind,
384
+ valid_from=valid_from,
385
+ valid_to=valid_to,
386
+ )
387
+ self._entities[canonical_id] = entity
388
+ if exposure_family is not None:
389
+ family = ExposureFamily(exposure_family)
390
+ self._exposure_members.setdefault(family, set()).add(canonical_id)
391
+ self._changed_entities = True
392
+ return self
393
+
394
+ def add_alias(
395
+ self,
396
+ *,
397
+ alias_id: str,
398
+ canonical_id: str,
399
+ surface: str,
400
+ match_mode: str = "normalized_phrase",
401
+ requires_context: bool = False,
402
+ context_rule_id: str | None = None,
403
+ valid_from: str | None = None,
404
+ valid_to: str | None = None,
405
+ source_id: str = "user-taxonomy",
406
+ ) -> "TaxonomyBuilder":
407
+ if any(row.alias_id == alias_id for row in self._aliases):
408
+ raise ValueError(f"duplicate alias ID: {alias_id}")
409
+ entity = self._entities.get(canonical_id)
410
+ if entity is None:
411
+ raise ValueError(f"alias references unknown entity: {canonical_id}")
412
+ if match_mode not in _MATCH_MODES:
413
+ raise ValueError(f"unsupported match mode: {match_mode!r}")
414
+ if requires_context and context_rule_id not in self._config.context_rules:
415
+ raise ValueError(f"unknown context rule: {context_rule_id!r}")
416
+ self._aliases.append(
417
+ CatalogAlias(
418
+ alias_id=alias_id,
419
+ canonical_id=canonical_id,
420
+ surface=surface,
421
+ match_mode=match_mode,
422
+ requires_context=requires_context,
423
+ context_rule_id=context_rule_id if requires_context else None,
424
+ valid_from=valid_from or entity.valid_from,
425
+ valid_to=valid_to if valid_to is not None else entity.valid_to,
426
+ source_id=source_id,
427
+ )
428
+ )
429
+ self._changed_entities = True
430
+ return self
431
+
432
+ def add_lexical_rule(
433
+ self,
434
+ *,
435
+ rule_id: str,
436
+ entity_concept: str,
437
+ tag_type: str,
438
+ phrases: Sequence[str],
439
+ target_id: str | None = None,
440
+ detection_method: str = "lexical_phrase",
441
+ market_wide_authorized: bool = False,
442
+ case_sensitive: bool = False,
443
+ context_window_chars: int = 160,
444
+ context_any: Sequence[str] = (),
445
+ event_family: str | None = None,
446
+ source_id: str = "user-taxonomy",
447
+ ) -> "TaxonomyBuilder":
448
+ if any(row.rule_id == rule_id for row in self._lexical_rules):
449
+ raise ValueError(f"duplicate lexical rule ID: {rule_id}")
450
+ if detection_method not in _DETECTION_METHODS:
451
+ raise ValueError(f"unsupported detection method: {detection_method!r}")
452
+ if tag_type not in self._config.ranking_tiers:
453
+ raise ValueError(
454
+ f"lexical tag type {tag_type!r} has no configured ranking tier"
455
+ )
456
+ if isinstance(phrases, (str, bytes)):
457
+ raise TypeError("phrases must be a sequence of strings, not one string")
458
+ phrase_tuple = tuple(phrases)
459
+ if not phrase_tuple or any(
460
+ not isinstance(item, str) or not item.strip() for item in phrase_tuple
461
+ ):
462
+ raise ValueError("phrases must contain non-empty strings")
463
+ if (
464
+ isinstance(context_window_chars, bool)
465
+ or not isinstance(context_window_chars, int)
466
+ or context_window_chars < 0
467
+ ):
468
+ raise ValueError("context_window_chars must be non-negative")
469
+ if isinstance(context_any, (str, bytes)) or any(
470
+ not isinstance(item, str) or not item.strip() for item in context_any
471
+ ):
472
+ raise ValueError("context_any must contain non-empty strings")
473
+ self._lexical_rules.append(
474
+ LexicalRule(
475
+ rule_id=rule_id,
476
+ entity_concept=entity_concept,
477
+ tag_type=tag_type,
478
+ target_id=target_id,
479
+ phrases=phrase_tuple,
480
+ detection_method=detection_method,
481
+ market_wide_authorized=market_wide_authorized,
482
+ case_sensitive=case_sensitive,
483
+ context_window_chars=context_window_chars,
484
+ context_any=tuple(context_any),
485
+ event_family=event_family,
486
+ source_id=source_id,
487
+ )
488
+ )
489
+ self._changed_lexical = True
490
+ return self
491
+
492
+ def build(self) -> Taxonomy:
493
+ exposure_members = MappingProxyType(
494
+ {
495
+ family: frozenset(members)
496
+ for family, members in self._exposure_members.items()
497
+ }
498
+ )
499
+ config = replace(self._config, exposure_members=exposure_members)
500
+ entity_version = self._catalogs.entity_catalog_version
501
+ lexical_version = self._catalogs.lexical_catalog_version
502
+ if self._changed_entities and not entity_version.endswith("+custom"):
503
+ entity_version += "+custom"
504
+ if self._changed_lexical and not lexical_version.endswith("+custom"):
505
+ lexical_version += "+custom"
506
+ catalogs = CatalogBundle(
507
+ entity_catalog_version=entity_version,
508
+ lexical_catalog_version=lexical_version,
509
+ entities=MappingProxyType(dict(self._entities)),
510
+ aliases=tuple(self._aliases),
511
+ relationships=MappingProxyType(dict(self._catalogs.relationships)),
512
+ lexical_rules=tuple(self._lexical_rules),
513
+ )
514
+ return Taxonomy(config=config, catalogs=catalogs)
515
+
516
+
517
+ class Ranker(ABC):
518
+ """Stable ranking template; subclasses only define the domain sort key."""
519
+
520
+ @abstractmethod
521
+ def sort_key(
522
+ self,
523
+ prediction: RelevanceDecision,
524
+ accepted_paths: tuple[EvidencePath, ...],
525
+ *,
526
+ exposure_family: ExposureFamily,
527
+ decision_bucket: str,
528
+ ) -> tuple[object, ...]:
529
+ """Return an ascending key; article ID is appended as the final tie-break."""
530
+
531
+ def rank(
532
+ self,
533
+ predictions: Iterable[RelevanceDecision],
534
+ *,
535
+ config: NewsRelevanceConfig,
536
+ exposure_family: ExposureFamily | str,
537
+ decision_bucket: datetime | str,
538
+ limit: int | None = None,
539
+ ) -> tuple[RankedCandidate, ...]:
540
+ if limit is not None and (
541
+ isinstance(limit, bool) or not isinstance(limit, int) or limit <= 0
542
+ ):
543
+ raise ValueError("limit must be a positive integer or None")
544
+ family = ExposureFamily(exposure_family)
545
+ bucket = str(decision_bucket)
546
+ grouped: dict[str, tuple[RelevanceDecision, list[EvidencePath]]] = {}
547
+ for prediction in predictions:
548
+ exposure = prediction.exposure_decisions.get(family)
549
+ if (
550
+ exposure is None
551
+ or exposure.status is not ExposureRelevanceStatus.SUPPORTED
552
+ ):
553
+ continue
554
+ paths = [
555
+ path
556
+ for path in exposure.evidence_paths
557
+ if path.status.value == "accepted"
558
+ and path.link_status in config.accepted_link_statuses
559
+ ]
560
+ if not paths:
561
+ continue
562
+ existing = grouped.get(prediction.article.article_id)
563
+ if existing is not None and existing[0] is not prediction:
564
+ raise ValueError("one article ID cannot map to multiple predictions")
565
+ if existing is None:
566
+ grouped[prediction.article.article_id] = (prediction, paths)
567
+ else:
568
+ existing[1].extend(paths)
569
+
570
+ sortable = []
571
+ for article_id, (prediction, paths) in grouped.items():
572
+ accepted = tuple(paths)
573
+ custom = self.sort_key(
574
+ prediction,
575
+ accepted,
576
+ exposure_family=family,
577
+ decision_bucket=bucket,
578
+ )
579
+ if not isinstance(custom, tuple):
580
+ raise TypeError("Ranker.sort_key() must return a tuple")
581
+ sortable.append(((custom, article_id), prediction, accepted))
582
+
583
+ ordered = sorted(sortable, key=lambda row: row[0])
584
+ if limit is not None:
585
+ ordered = ordered[:limit]
586
+ return tuple(
587
+ RankedCandidate(
588
+ decision_bucket=bucket,
589
+ exposure_family=family,
590
+ rank=index,
591
+ article_id=prediction.article.article_id,
592
+ accepted_evidence_ids=tuple(
593
+ sorted(path.evidence_id for path in accepted)
594
+ ),
595
+ decision=prediction,
596
+ )
597
+ for index, (_key_row, prediction, accepted) in enumerate(ordered, 1)
598
+ )
599
+
600
+
601
+ class DefaultRanker(Ranker):
602
+ """The maintained tier, evidence-diversity, recency ordering."""
603
+
604
+ def sort_key(
605
+ self,
606
+ prediction: RelevanceDecision,
607
+ accepted_paths: tuple[EvidencePath, ...],
608
+ *,
609
+ exposure_family: ExposureFamily,
610
+ decision_bucket: str,
611
+ ) -> tuple[object, ...]:
612
+ del exposure_family, decision_bucket
613
+ timestamp = prediction.article.timestamp.astimezone(timezone.utc).timestamp()
614
+ return (
615
+ min(path.tier for path in accepted_paths),
616
+ -len({path.tag_detail for path in accepted_paths}),
617
+ -len({path.detection_method for path in accepted_paths}),
618
+ -timestamp,
619
+ )
620
+
621
+
622
+ __all__ = [
623
+ "DefaultRanker",
624
+ "DefaultTaxonomy",
625
+ "Ranker",
626
+ "Taxonomy",
627
+ "TaxonomyBuilder",
628
+ "copy_default_taxonomy",
629
+ ]
src/range_news/api.py ADDED
@@ -0,0 +1,371 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Public API for deterministic financial-news relevance screening."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+ from collections.abc import Iterable, Sequence
8
+ from datetime import datetime
9
+ from functools import lru_cache
10
+ from pathlib import Path
11
+ from typing import Literal
12
+
13
+ from ._core.accelerated.indicator import AcceleratedRelevanceIndicator
14
+ from ._core.accelerated.compact import (
15
+ ArrowCompactFacts,
16
+ ColumnArticleBatch,
17
+ CompactRelevanceIndicator,
18
+ )
19
+ from ._core.models import Article, ExposureFamily, RelevanceDecision
20
+
21
+ from .backends import available_backends, create_backend
22
+ from .customization import (
23
+ DefaultRanker,
24
+ DefaultTaxonomy,
25
+ Ranker,
26
+ Taxonomy,
27
+ )
28
+ from .models import (
29
+ BackendInfo,
30
+ Prediction,
31
+ PythonScreeningBatch,
32
+ RankedPrediction,
33
+ )
34
+
35
+ TimestampLike = datetime | str
36
+
37
+
38
+ def _workers(value: int | None) -> int:
39
+ if value is None:
40
+ return max(1, min(10, os.cpu_count() or 1))
41
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
42
+ raise ValueError("workers must be a positive integer or None")
43
+ return value
44
+
45
+
46
+ def _article_id(text: str, timestamp: TimestampLike) -> str:
47
+ payload = f"{timestamp}\0{text}".encode("utf-8")
48
+ return f"article-{hashlib.sha256(payload).hexdigest()[:20]}"
49
+
50
+
51
+ def _optional_length(
52
+ name: str,
53
+ values: Sequence[object] | None,
54
+ expected: int,
55
+ ) -> None:
56
+ if values is not None and len(values) != expected:
57
+ raise ValueError(f"{name} length {len(values)} does not match {expected}")
58
+
59
+
60
+ class RelevanceEngine:
61
+ """Exact rich prediction plus explicitly separate compact screening paths."""
62
+
63
+ __slots__ = (
64
+ "_backend",
65
+ "_indicator",
66
+ "_ranker",
67
+ "_screening_indicator",
68
+ "_taxonomy",
69
+ "_workers",
70
+ )
71
+
72
+ def __init__(
73
+ self,
74
+ taxonomy: Taxonomy | None = None,
75
+ *,
76
+ ranker: Ranker | None = None,
77
+ backend: Literal["cpu"] = "cpu",
78
+ workers: int | None = None,
79
+ ) -> None:
80
+ resolved_taxonomy = taxonomy or DefaultTaxonomy()
81
+ if not isinstance(resolved_taxonomy, Taxonomy):
82
+ raise TypeError("taxonomy must be a Taxonomy or None")
83
+ resolved_ranker = ranker or DefaultRanker()
84
+ if not isinstance(resolved_ranker, Ranker):
85
+ raise TypeError("ranker must inherit Ranker")
86
+ self._taxonomy = resolved_taxonomy
87
+ self._ranker = resolved_ranker
88
+ self._workers = _workers(workers)
89
+ self._backend = create_backend(backend)
90
+ self._indicator = AcceleratedRelevanceIndicator(
91
+ config=resolved_taxonomy.config,
92
+ catalogs=resolved_taxonomy.catalogs,
93
+ )
94
+ self._screening_indicator = CompactRelevanceIndicator(
95
+ config=resolved_taxonomy.config,
96
+ catalogs=resolved_taxonomy.catalogs,
97
+ )
98
+
99
+ @classmethod
100
+ def from_yaml(
101
+ cls,
102
+ path: str | Path,
103
+ *,
104
+ ranker: Ranker | None = None,
105
+ backend: Literal["cpu"] = "cpu",
106
+ workers: int | None = None,
107
+ ) -> "RelevanceEngine":
108
+ return cls(
109
+ Taxonomy.from_yaml(path),
110
+ ranker=ranker,
111
+ backend=backend,
112
+ workers=workers,
113
+ )
114
+
115
+ @property
116
+ def taxonomy(self) -> Taxonomy:
117
+ return self._taxonomy
118
+
119
+ @property
120
+ def ranker(self) -> Ranker:
121
+ return self._ranker
122
+
123
+ @property
124
+ def backend(self) -> str:
125
+ return self._backend.name
126
+
127
+ @property
128
+ def backend_info(self) -> BackendInfo:
129
+ return self._backend.describe(self._screening_indicator)
130
+
131
+ @property
132
+ def workers(self) -> int:
133
+ return self._workers
134
+
135
+ def predict(
136
+ self,
137
+ text: str,
138
+ *,
139
+ timestamp: TimestampLike,
140
+ article_id: str | None = None,
141
+ source: str = "unknown",
142
+ ) -> Prediction:
143
+ """Build one rich decision with evidence and explanation provenance."""
144
+
145
+ article = Article(
146
+ article_id=(
147
+ _article_id(text, timestamp) if article_id is None else article_id
148
+ ),
149
+ timestamp=timestamp,
150
+ text=text,
151
+ source=source,
152
+ )
153
+ return self._indicator.predict(article)
154
+
155
+ def predict_batch(
156
+ self,
157
+ texts: Sequence[str],
158
+ *,
159
+ timestamps: Sequence[TimestampLike],
160
+ article_ids: Sequence[str] | None = None,
161
+ sources: Sequence[str] | None = None,
162
+ workers: int | None = None,
163
+ ) -> tuple[Prediction, ...]:
164
+ """Build rich decisions; use `screen_batch()` for throughput work."""
165
+
166
+ count = len(texts)
167
+ if len(timestamps) != count:
168
+ raise ValueError("texts and timestamps must have equal lengths")
169
+ _optional_length("article_ids", article_ids, count)
170
+ _optional_length("sources", sources, count)
171
+ articles = tuple(
172
+ Article(
173
+ article_id=(
174
+ article_ids[index] if article_ids is not None else f"row-{index}"
175
+ ),
176
+ timestamp=timestamps[index],
177
+ text=text,
178
+ source=sources[index] if sources is not None else "unknown",
179
+ )
180
+ for index, text in enumerate(texts)
181
+ )
182
+ return self._indicator.predict_batch(
183
+ articles,
184
+ workers=_workers(workers) if workers is not None else self._workers,
185
+ )
186
+
187
+ def screen_batch(
188
+ self,
189
+ texts: Sequence[str],
190
+ *,
191
+ timestamps: Sequence[TimestampLike],
192
+ article_ids: Sequence[str] | None = None,
193
+ sources: Sequence[str] | None = None,
194
+ workers: int | None = None,
195
+ ) -> PythonScreeningBatch:
196
+ """Run compact list screening with implicit zero-observation rows."""
197
+
198
+ count = len(texts)
199
+ if len(timestamps) != count:
200
+ raise ValueError("texts and timestamps must have equal lengths")
201
+ _optional_length("article_ids", article_ids, count)
202
+ _optional_length("sources", sources, count)
203
+ ids = (
204
+ article_ids
205
+ if article_ids is not None
206
+ else tuple(f"row-{index}" for index in range(count))
207
+ )
208
+ batch = ColumnArticleBatch(
209
+ article_ids=ids,
210
+ timestamps=timestamps,
211
+ texts=texts,
212
+ sources=sources,
213
+ )
214
+ return self._backend.screen_batch(
215
+ self._screening_indicator,
216
+ batch,
217
+ workers=_workers(workers) if workers is not None else self._workers,
218
+ )
219
+
220
+ def screen_arrow(
221
+ self,
222
+ data,
223
+ *,
224
+ text_column: str = "text",
225
+ timestamp_column: str = "timestamp",
226
+ workers: int | None = None,
227
+ row_count: int | None = None,
228
+ ) -> ArrowCompactFacts:
229
+ """Run the sparse Arrow path without materializing per-row Python objects."""
230
+
231
+ if row_count is not None and (
232
+ isinstance(row_count, bool)
233
+ or not isinstance(row_count, int)
234
+ or row_count < 0
235
+ ):
236
+ raise ValueError("row_count must be a non-negative integer or None")
237
+ discovered = getattr(data, "num_rows", None)
238
+ if discovered is not None:
239
+ discovered = int(discovered)
240
+ if row_count is None:
241
+ row_count = discovered
242
+ elif row_count != discovered:
243
+ raise ValueError(
244
+ f"row_count {row_count} does not match Arrow rows {discovered}"
245
+ )
246
+
247
+ return self._backend.screen_arrow(
248
+ self._screening_indicator,
249
+ self._taxonomy,
250
+ data,
251
+ text_column=text_column,
252
+ timestamp_column=timestamp_column,
253
+ workers=_workers(workers) if workers is not None else self._workers,
254
+ row_count=row_count,
255
+ )
256
+
257
+ def explain(
258
+ self,
259
+ prediction: RelevanceDecision,
260
+ *,
261
+ format: Literal["text", "mermaid"] = "text",
262
+ detail: Literal["summary", "audit"] = "summary",
263
+ ) -> str:
264
+ """Format already-recorded evidence; this does not rerun prediction."""
265
+
266
+ if not isinstance(prediction, RelevanceDecision):
267
+ raise TypeError("prediction must be a rich Prediction")
268
+ if detail not in {"summary", "audit"}:
269
+ raise ValueError("detail must be 'summary' or 'audit'")
270
+ return self._indicator.explain(
271
+ prediction,
272
+ output_format=format,
273
+ detail=detail,
274
+ )
275
+
276
+ def rank(
277
+ self,
278
+ predictions: Iterable[Prediction],
279
+ *,
280
+ exposure_family: ExposureFamily | str,
281
+ decision_bucket: datetime | str,
282
+ limit: int | None = None,
283
+ ) -> tuple[RankedPrediction, ...]:
284
+ return self._ranker.rank(
285
+ predictions,
286
+ config=self._taxonomy.config,
287
+ exposure_family=exposure_family,
288
+ decision_bucket=decision_bucket,
289
+ limit=limit,
290
+ )
291
+
292
+
293
+ @lru_cache(maxsize=1)
294
+ def _default_engine() -> RelevanceEngine:
295
+ return RelevanceEngine()
296
+
297
+
298
+ def predict(
299
+ text: str,
300
+ *,
301
+ timestamp: TimestampLike,
302
+ article_id: str | None = None,
303
+ source: str = "unknown",
304
+ ) -> Prediction:
305
+ return _default_engine().predict(
306
+ text,
307
+ timestamp=timestamp,
308
+ article_id=article_id,
309
+ source=source,
310
+ )
311
+
312
+
313
+ def predict_batch(
314
+ texts: Sequence[str],
315
+ *,
316
+ timestamps: Sequence[TimestampLike],
317
+ article_ids: Sequence[str] | None = None,
318
+ sources: Sequence[str] | None = None,
319
+ workers: int | None = None,
320
+ ) -> tuple[Prediction, ...]:
321
+ return _default_engine().predict_batch(
322
+ texts,
323
+ timestamps=timestamps,
324
+ article_ids=article_ids,
325
+ sources=sources,
326
+ workers=workers,
327
+ )
328
+
329
+
330
+ def screen_batch(
331
+ texts: Sequence[str],
332
+ *,
333
+ timestamps: Sequence[TimestampLike],
334
+ article_ids: Sequence[str] | None = None,
335
+ sources: Sequence[str] | None = None,
336
+ workers: int | None = None,
337
+ ) -> PythonScreeningBatch:
338
+ return _default_engine().screen_batch(
339
+ texts,
340
+ timestamps=timestamps,
341
+ article_ids=article_ids,
342
+ sources=sources,
343
+ workers=workers,
344
+ )
345
+
346
+
347
+ def screen_arrow(
348
+ data,
349
+ *,
350
+ text_column: str = "text",
351
+ timestamp_column: str = "timestamp",
352
+ workers: int | None = None,
353
+ row_count: int | None = None,
354
+ ) -> ArrowCompactFacts:
355
+ return _default_engine().screen_arrow(
356
+ data,
357
+ text_column=text_column,
358
+ timestamp_column=timestamp_column,
359
+ workers=workers,
360
+ row_count=row_count,
361
+ )
362
+
363
+
364
+ __all__ = [
365
+ "RelevanceEngine",
366
+ "available_backends",
367
+ "predict",
368
+ "predict_batch",
369
+ "screen_arrow",
370
+ "screen_batch",
371
+ ]
src/range_news/backends.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Backend selection for the supported CPU implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.util import find_spec
6
+ from typing import TYPE_CHECKING, Protocol
7
+
8
+ from ._core.accelerated.compact import (
9
+ ArrowCompactFacts,
10
+ ColumnArticleBatch,
11
+ CompactColumnScreenBatch,
12
+ CompactRelevanceIndicator,
13
+ )
14
+ from .models import BackendInfo
15
+
16
+ if TYPE_CHECKING:
17
+ from .customization import Taxonomy
18
+
19
+
20
+ def _native_available() -> bool:
21
+ try:
22
+ return find_spec("range_news._native") is not None
23
+ except (ImportError, ModuleNotFoundError, ValueError):
24
+ return False
25
+
26
+
27
+ class _Backend(Protocol):
28
+ name: str
29
+
30
+ def describe(
31
+ self,
32
+ indicator: CompactRelevanceIndicator,
33
+ ) -> BackendInfo: ...
34
+
35
+ def screen_batch(
36
+ self,
37
+ indicator: CompactRelevanceIndicator,
38
+ batch: ColumnArticleBatch,
39
+ *,
40
+ workers: int,
41
+ ) -> CompactColumnScreenBatch: ...
42
+
43
+ def screen_arrow(
44
+ self,
45
+ indicator: CompactRelevanceIndicator,
46
+ taxonomy: "Taxonomy",
47
+ data,
48
+ *,
49
+ text_column: str,
50
+ timestamp_column: str,
51
+ workers: int,
52
+ row_count: int | None,
53
+ ) -> ArrowCompactFacts: ...
54
+
55
+
56
+ class _CpuBackend:
57
+ name = "cpu"
58
+
59
+ def describe(
60
+ self,
61
+ indicator: CompactRelevanceIndicator,
62
+ ) -> BackendInfo:
63
+ native_list = indicator.list_available
64
+ native_arrow = indicator.arrow_available
65
+ if native_list and native_arrow:
66
+ detail = "Rust list and Arrow screening are available"
67
+ elif native_list:
68
+ detail = "Rust list screening is available; Arrow screening is unavailable"
69
+ else:
70
+ detail = (
71
+ "Python prediction is available; rebuild the native extension "
72
+ "for compact screening"
73
+ )
74
+ return BackendInfo(
75
+ name=self.name,
76
+ available=True,
77
+ accelerated=native_list,
78
+ detail=detail,
79
+ )
80
+
81
+ def screen_batch(
82
+ self,
83
+ indicator: CompactRelevanceIndicator,
84
+ batch: ColumnArticleBatch,
85
+ *,
86
+ workers: int,
87
+ ) -> CompactColumnScreenBatch:
88
+ if not indicator.list_available:
89
+ raise RuntimeError(
90
+ "Compact screening requires the packaged Rust extension; "
91
+ "install a wheel for this platform or rebuild the package"
92
+ )
93
+ return indicator.screen_column_batch(batch, workers=workers)
94
+
95
+ def screen_arrow(
96
+ self,
97
+ indicator: CompactRelevanceIndicator,
98
+ taxonomy: "Taxonomy",
99
+ data,
100
+ *,
101
+ text_column: str,
102
+ timestamp_column: str,
103
+ workers: int,
104
+ row_count: int | None,
105
+ ) -> ArrowCompactFacts:
106
+ del taxonomy
107
+ if not indicator.arrow_available:
108
+ raise RuntimeError(
109
+ "Arrow screening requires the packaged Rust extension and the "
110
+ "'arrow' extra"
111
+ )
112
+ return indicator.screen_arrow(
113
+ data,
114
+ text_column=text_column,
115
+ timestamp_column=timestamp_column,
116
+ workers=workers,
117
+ row_count=row_count,
118
+ )
119
+
120
+
121
+ def create_backend(name: str) -> _Backend:
122
+ normalized = name.strip().lower() if isinstance(name, str) else ""
123
+ if normalized == "cpu":
124
+ return _CpuBackend()
125
+ raise ValueError("backend must be 'cpu'; experimental GPU backends are not shipped")
126
+
127
+
128
+ def available_backends() -> tuple[BackendInfo, ...]:
129
+ """Return supported runtimes without initializing the native matcher."""
130
+
131
+ native = _native_available()
132
+ return (
133
+ BackendInfo(
134
+ name="cpu",
135
+ available=True,
136
+ accelerated=native,
137
+ detail=(
138
+ "packaged Rust extension is discoverable"
139
+ if native
140
+ else "Python prediction is available; native compact screening is unavailable"
141
+ ),
142
+ ),
143
+ )
144
+
145
+
146
+ __all__ = ["available_backends", "create_backend"]
src/range_news/customization.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Public taxonomy composition and deterministic ranking contracts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.resources import files as resource_files
6
+ from pathlib import Path
7
+
8
+ from ._taxonomy_impl import (
9
+ DefaultRanker,
10
+ DefaultTaxonomy,
11
+ Ranker,
12
+ Taxonomy,
13
+ TaxonomyBuilder,
14
+ )
15
+
16
+ # Keep repr(), documentation, and pickling pointed at the public module after
17
+ # the pre-release namespace migration.
18
+ for _public_type in (
19
+ DefaultRanker,
20
+ DefaultTaxonomy,
21
+ Ranker,
22
+ Taxonomy,
23
+ TaxonomyBuilder,
24
+ ):
25
+ _public_type.__module__ = __name__
26
+
27
+
28
+ class GeneralEconomyTaxonomy(DefaultTaxonomy):
29
+ """The bundled, conservative taxonomy for economy and broad-market news."""
30
+
31
+
32
+ def copy_default_taxonomy(
33
+ destination: str | Path,
34
+ *,
35
+ overwrite: bool = False,
36
+ ) -> Path:
37
+ """Copy the bundled general-economy taxonomy to an editable directory."""
38
+
39
+ root = Path(destination).expanduser().resolve()
40
+ package = resource_files("range_news._core")
41
+ copies = (
42
+ (package.joinpath("configs", "default.yml"), root / "taxonomy.yml"),
43
+ (
44
+ package.joinpath("catalogs", "entities.yml"),
45
+ root / "catalogs" / "entities.yml",
46
+ ),
47
+ (
48
+ package.joinpath("catalogs", "lexical.yml"),
49
+ root / "catalogs" / "lexical.yml",
50
+ ),
51
+ )
52
+ existing = [target for _source, target in copies if target.exists()]
53
+ if existing and not overwrite:
54
+ names = ", ".join(str(path) for path in existing)
55
+ raise FileExistsError(
56
+ f"taxonomy files already exist: {names}; pass overwrite=True to replace them"
57
+ )
58
+
59
+ for source, target in copies:
60
+ target.parent.mkdir(parents=True, exist_ok=True)
61
+ target.write_bytes(source.read_bytes())
62
+ return root / "taxonomy.yml"
63
+
64
+
65
+ __all__ = [
66
+ "DefaultRanker",
67
+ "DefaultTaxonomy",
68
+ "GeneralEconomyTaxonomy",
69
+ "Ranker",
70
+ "Taxonomy",
71
+ "TaxonomyBuilder",
72
+ "copy_default_taxonomy",
73
+ ]
src/range_news/models.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Public names for relevance inputs, decisions, and screening results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from ._core.accelerated.compact import (
8
+ ArrowCompactFacts,
9
+ CompactColumnScreenBatch,
10
+ ScreeningResult,
11
+ )
12
+ from ._core.models import (
13
+ Article,
14
+ ArticleAdmissibilityStatus,
15
+ EvidencePath,
16
+ ExposureFamily,
17
+ ExposureRelevanceStatus,
18
+ RankedCandidate,
19
+ RelevanceDecision,
20
+ )
21
+
22
+ Prediction = RelevanceDecision
23
+ RankedPrediction = RankedCandidate
24
+ PythonScreeningBatch = CompactColumnScreenBatch
25
+ ArrowScreeningBatch = ArrowCompactFacts
26
+ ScreeningBatch = PythonScreeningBatch | ArrowScreeningBatch
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class BackendInfo:
31
+ """Availability and acceleration details for one backend."""
32
+
33
+ name: str
34
+ available: bool
35
+ accelerated: bool
36
+ detail: str
37
+
38
+
39
+ __all__ = [
40
+ "ArrowScreeningBatch",
41
+ "Article",
42
+ "ArticleAdmissibilityStatus",
43
+ "BackendInfo",
44
+ "EvidencePath",
45
+ "ExposureFamily",
46
+ "ExposureRelevanceStatus",
47
+ "Prediction",
48
+ "PythonScreeningBatch",
49
+ "RankedPrediction",
50
+ "ScreeningBatch",
51
+ "ScreeningResult",
52
+ ]
src/range_news/py.typed ADDED
File without changes
vendor/range_news/__init__.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Exact, CPU-first, traceable financial-news relevance screening."""
2
+
3
+ from .api import (
4
+ RelevanceEngine,
5
+ available_backends,
6
+ predict,
7
+ predict_batch,
8
+ screen_arrow,
9
+ screen_batch,
10
+ )
11
+ from .customization import (
12
+ DefaultRanker,
13
+ DefaultTaxonomy,
14
+ GeneralEconomyTaxonomy,
15
+ Ranker,
16
+ Taxonomy,
17
+ TaxonomyBuilder,
18
+ copy_default_taxonomy,
19
+ )
20
+ from .models import (
21
+ ArrowScreeningBatch,
22
+ Article,
23
+ ArticleAdmissibilityStatus,
24
+ BackendInfo,
25
+ EvidencePath,
26
+ ExposureFamily,
27
+ ExposureRelevanceStatus,
28
+ Prediction,
29
+ PythonScreeningBatch,
30
+ RankedPrediction,
31
+ ScreeningBatch,
32
+ ScreeningResult,
33
+ )
34
+
35
+ __version__ = "0.1.0a1"
36
+
37
+ __all__ = [
38
+ "ArrowScreeningBatch",
39
+ "Article",
40
+ "ArticleAdmissibilityStatus",
41
+ "BackendInfo",
42
+ "DefaultRanker",
43
+ "DefaultTaxonomy",
44
+ "EvidencePath",
45
+ "ExposureFamily",
46
+ "ExposureRelevanceStatus",
47
+ "GeneralEconomyTaxonomy",
48
+ "Prediction",
49
+ "PythonScreeningBatch",
50
+ "RankedPrediction",
51
+ "Ranker",
52
+ "RelevanceEngine",
53
+ "ScreeningBatch",
54
+ "ScreeningResult",
55
+ "Taxonomy",
56
+ "TaxonomyBuilder",
57
+ "__version__",
58
+ "available_backends",
59
+ "copy_default_taxonomy",
60
+ "predict",
61
+ "predict_batch",
62
+ "screen_arrow",
63
+ "screen_batch",
64
+ ]
vendor/range_news/_core/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Private, deterministic financial-news relevance implementation."""