Spaces:
Running on Zero
Running on Zero
Commit ·
f8768df
0
Parent(s):
Publish-ready RANGE snapshot
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +2 -0
- CHANGELOG.md +56 -0
- CITATION.cff +15 -0
- Cargo.lock +1017 -0
- Cargo.toml +26 -0
- DEPLOYMENT.md +202 -0
- LICENSE +21 -0
- PACKAGE_README.md +350 -0
- README.md +154 -0
- SPACE_BUNDLE_MANIFEST.json +79 -0
- app.py +1172 -0
- docs/attention2alpha-compatibility-review.md +46 -0
- docs/performance.md +83 -0
- docs/readme-research.md +65 -0
- docs/release.md +81 -0
- docs/taxonomy.md +194 -0
- docs/technical-signoff.md +53 -0
- examples.py +55 -0
- examples/app.py +86 -0
- examples/benchmark_hf.py +195 -0
- examples/custom_taxonomy.py +43 -0
- examples/quickstart.py +26 -0
- pyproject.toml +84 -0
- requirements.txt +4 -0
- rust/core.rs +1372 -0
- rust/generated/python_whitespace.bin +3 -0
- rust/generated/unicode_index.bin +3 -0
- rust/generated/unicode_meta.txt +1 -0
- rust/generated/unicode_pool.bin +3 -0
- rust/lib.rs +321 -0
- src/range_news/__init__.py +64 -0
- src/range_news/_core/__init__.py +1 -0
- src/range_news/_core/accelerated/__init__.py +1 -0
- src/range_news/_core/accelerated/compact.py +583 -0
- src/range_news/_core/accelerated/indicator.py +384 -0
- src/range_news/_core/catalogs.py +470 -0
- src/range_news/_core/catalogs/entities.yml +186 -0
- src/range_news/_core/catalogs/lexical.yml +744 -0
- src/range_news/_core/config.py +293 -0
- src/range_news/_core/configs/default.yml +126 -0
- src/range_news/_core/indicator.py +1417 -0
- src/range_news/_core/models.py +418 -0
- src/range_news/_taxonomy_impl.py +629 -0
- src/range_news/api.py +371 -0
- src/range_news/backends.py +146 -0
- src/range_news/customization.py +73 -0
- src/range_news/models.py +52 -0
- src/range_news/py.typed +0 -0
- vendor/range_news/__init__.py +64 -0
- 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 |
+

|
| 11 |
+

|
| 12 |
+

|
| 13 |
+

|
| 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&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."""
|