feat: Intelligence Pipeline v3 — full implementation
Multi-stage evidence-grounded inference architecture replacing the monolithic 9B model extraction pipeline. CPU-first specialist services handle routine extraction while the 9B vLLM model is preserved for semantic adjudication of ambiguous cases. Key components: - Capability-aware inference gateway (OpenAI-compatible + Ollama) - Endpoint registry with DB migrations and REST API - Sentence-aware document segmenter (property tests) - Deterministic financial parsing with offset integrity - Symbol resolution with ambiguity detection - Specialist service (GLiNER2, dynamic batching, K8s deployment) - Company-specific sentiment (FinBERT, calibration) - Retrieval-based novelty and duplicate detection - Confidence calibration pipeline - Deterministic routing engine (property tests) - 9B adjudication layer with VRAM gating - Stock-specific impact model (features, labels, baseline, trained) - Pipeline orchestrator (state machine, queues, leases, feature flags) - Bounded parallelism (async workers, semaphore, load shedding) - Observability (tracing, metrics, alerts) - Compatibility adapter (v3→v2 golden mapping tests) - Shadow/canary promotion framework - Active learning and fine-tuning pipeline Test results: 1,161 tests pass, ruff lint clean. All 282 spec tasks completed.
This commit is contained in:
@@ -0,0 +1,583 @@
|
||||
"""Unit tests and property tests for the deterministic financial parser.
|
||||
|
||||
Tests cover:
|
||||
- 23.1: Parse tickers, currencies, money, percentages, basis points, ranges, EPS, revenue, dates, fiscal periods
|
||||
- 23.2: Store literal and normalized representations
|
||||
- 23.3: Link each candidate to exact offsets
|
||||
- 23.4: Property tests for numeric formatting and unit conversions
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
from hypothesis import given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from services.intelligence_pipeline_v3.parsing import (
|
||||
CandidateType,
|
||||
FinancialParser,
|
||||
normalize_value,
|
||||
)
|
||||
from services.intelligence_pipeline_v3.parsing.normalizer import (
|
||||
normalize_basis_points,
|
||||
normalize_money,
|
||||
normalize_percentage,
|
||||
normalize_range,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def parser() -> FinancialParser:
|
||||
return FinancialParser()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 23.1 — Parse tickers, currencies, money, percentages, basis points,
|
||||
# ranges, EPS, revenue, dates, and fiscal periods
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTickerParsing:
|
||||
"""Test ticker symbol detection."""
|
||||
|
||||
def test_dollar_ticker(self, parser: FinancialParser) -> None:
|
||||
"""Detect $AAPL style tickers."""
|
||||
results = parser.parse("Shares of $AAPL rose 3% today.")
|
||||
tickers = [r for r in results if r.candidate_type == CandidateType.TICKER]
|
||||
assert len(tickers) == 1
|
||||
assert tickers[0].literal_value == "$AAPL"
|
||||
|
||||
def test_multiple_tickers(self, parser: FinancialParser) -> None:
|
||||
"""Detect multiple tickers in one text."""
|
||||
results = parser.parse("$AAPL and $MSFT both reported earnings.")
|
||||
tickers = [r for r in results if r.candidate_type == CandidateType.TICKER]
|
||||
assert len(tickers) == 2
|
||||
literals = {t.literal_value for t in tickers}
|
||||
assert "$AAPL" in literals
|
||||
assert "$MSFT" in literals
|
||||
|
||||
def test_ticker_not_confused_with_currency(self, parser: FinancialParser) -> None:
|
||||
"""$AAPL (letters) is a ticker, not currency."""
|
||||
results = parser.parse("$AAPL hit $200.")
|
||||
tickers = [r for r in results if r.candidate_type == CandidateType.TICKER]
|
||||
currencies = [r for r in results if r.candidate_type == CandidateType.CURRENCY]
|
||||
assert any(t.literal_value == "$AAPL" for t in tickers)
|
||||
assert any(c.literal_value == "$200" for c in currencies)
|
||||
|
||||
|
||||
class TestCurrencyParsing:
|
||||
"""Test simple currency detection."""
|
||||
|
||||
def test_usd_amount(self, parser: FinancialParser) -> None:
|
||||
results = parser.parse("The stock traded at $123.45 today.")
|
||||
currencies = [r for r in results if r.candidate_type == CandidateType.CURRENCY]
|
||||
assert len(currencies) >= 1
|
||||
assert any(c.literal_value == "$123.45" for c in currencies)
|
||||
|
||||
def test_euro_amount(self, parser: FinancialParser) -> None:
|
||||
results = parser.parse("Trading at €99 in Frankfurt.")
|
||||
currencies = [r for r in results if r.candidate_type == CandidateType.CURRENCY]
|
||||
assert len(currencies) == 1
|
||||
assert currencies[0].literal_value == "€99"
|
||||
assert currencies[0].unit == "EUR"
|
||||
|
||||
def test_gbp_amount(self, parser: FinancialParser) -> None:
|
||||
results = parser.parse("Shares are £1,234.56 in London.")
|
||||
currencies = [r for r in results if r.candidate_type == CandidateType.CURRENCY]
|
||||
assert len(currencies) == 1
|
||||
assert currencies[0].normalized_value == 1234.56
|
||||
assert currencies[0].unit == "GBP"
|
||||
|
||||
|
||||
class TestMoneyParsing:
|
||||
"""Test money amounts with multipliers."""
|
||||
|
||||
def test_billion_amount(self, parser: FinancialParser) -> None:
|
||||
results = parser.parse("Apple reported $94.9 billion in revenue.")
|
||||
# Should get a revenue match (more specific than money)
|
||||
revenue = [r for r in results if r.candidate_type == CandidateType.REVENUE]
|
||||
assert len(revenue) >= 1
|
||||
|
||||
def test_million_amount(self, parser: FinancialParser) -> None:
|
||||
results = parser.parse("Operating costs were $45 million last quarter.")
|
||||
money = [r for r in results if r.candidate_type == CandidateType.MONEY]
|
||||
assert len(money) >= 1
|
||||
assert any(m.normalized_value == 45_000_000.0 for m in money)
|
||||
|
||||
|
||||
class TestPercentageParsing:
|
||||
"""Test percentage detection."""
|
||||
|
||||
def test_simple_percentage(self, parser: FinancialParser) -> None:
|
||||
results = parser.parse("The stock rose 4% today.")
|
||||
pcts = [r for r in results if r.candidate_type == CandidateType.PERCENTAGE]
|
||||
assert len(pcts) == 1
|
||||
assert pcts[0].normalized_value == 4.0
|
||||
assert pcts[0].unit == "%"
|
||||
|
||||
def test_negative_percentage(self, parser: FinancialParser) -> None:
|
||||
results = parser.parse("Revenue declined -2.5% year-over-year.")
|
||||
pcts = [r for r in results if r.candidate_type == CandidateType.PERCENTAGE]
|
||||
assert len(pcts) == 1
|
||||
assert pcts[0].normalized_value == -2.5
|
||||
|
||||
def test_percent_word(self, parser: FinancialParser) -> None:
|
||||
results = parser.parse("Margins expanded 1.2 percent this quarter.")
|
||||
pcts = [r for r in results if r.candidate_type == CandidateType.PERCENTAGE]
|
||||
assert len(pcts) == 1
|
||||
assert pcts[0].normalized_value == 1.2
|
||||
|
||||
|
||||
class TestBasisPointsParsing:
|
||||
"""Test basis points detection."""
|
||||
|
||||
def test_basis_points_full(self, parser: FinancialParser) -> None:
|
||||
results = parser.parse("The Fed raised rates by 25 basis points.")
|
||||
bps = [r for r in results if r.candidate_type == CandidateType.BASIS_POINTS]
|
||||
assert len(bps) == 1
|
||||
assert bps[0].normalized_value == pytest.approx(0.25)
|
||||
assert bps[0].unit == "bps"
|
||||
|
||||
def test_bps_abbreviation(self, parser: FinancialParser) -> None:
|
||||
results = parser.parse("Spreads widened 50bps today.")
|
||||
bps = [r for r in results if r.candidate_type == CandidateType.BASIS_POINTS]
|
||||
assert len(bps) == 1
|
||||
assert bps[0].normalized_value == pytest.approx(0.50)
|
||||
|
||||
def test_bps_with_space(self, parser: FinancialParser) -> None:
|
||||
results = parser.parse("Credit spreads tightened 100 bps.")
|
||||
bps = [r for r in results if r.candidate_type == CandidateType.BASIS_POINTS]
|
||||
assert len(bps) == 1
|
||||
assert bps[0].normalized_value == pytest.approx(1.0)
|
||||
|
||||
|
||||
class TestRangeParsing:
|
||||
"""Test range detection."""
|
||||
|
||||
def test_dollar_range_dash(self, parser: FinancialParser) -> None:
|
||||
results = parser.parse("Guidance was $10-$12 per share.")
|
||||
ranges = [r for r in results if r.candidate_type == CandidateType.RANGE]
|
||||
assert len(ranges) == 1
|
||||
assert ranges[0].normalized_value == pytest.approx(11.0)
|
||||
|
||||
def test_dollar_range_to(self, parser: FinancialParser) -> None:
|
||||
results = parser.parse("Expected range $1.50 to $2.00.")
|
||||
ranges = [r for r in results if r.candidate_type == CandidateType.RANGE]
|
||||
assert len(ranges) == 1
|
||||
assert ranges[0].normalized_value == pytest.approx(1.75)
|
||||
|
||||
|
||||
class TestEPSParsing:
|
||||
"""Test EPS detection."""
|
||||
|
||||
def test_eps_per_share(self, parser: FinancialParser) -> None:
|
||||
results = parser.parse("The company earned $1.52 per share.")
|
||||
eps = [r for r in results if r.candidate_type == CandidateType.EPS]
|
||||
assert len(eps) == 1
|
||||
assert eps[0].normalized_value == pytest.approx(1.52)
|
||||
assert eps[0].unit == "USD"
|
||||
|
||||
def test_eps_prefix(self, parser: FinancialParser) -> None:
|
||||
results = parser.parse("EPS of $2.18 beat expectations.")
|
||||
eps = [r for r in results if r.candidate_type == CandidateType.EPS]
|
||||
assert len(eps) == 1
|
||||
assert eps[0].normalized_value == pytest.approx(2.18)
|
||||
|
||||
|
||||
class TestRevenueParsing:
|
||||
"""Test revenue figure detection."""
|
||||
|
||||
def test_revenue_amount(self, parser: FinancialParser) -> None:
|
||||
results = parser.parse("Apple reported $94.9 billion in revenue.")
|
||||
rev = [r for r in results if r.candidate_type == CandidateType.REVENUE]
|
||||
assert len(rev) == 1
|
||||
assert rev[0].normalized_value == pytest.approx(94_900_000_000.0)
|
||||
assert rev[0].unit == "USD"
|
||||
|
||||
def test_revenue_prefix(self, parser: FinancialParser) -> None:
|
||||
results = parser.parse("Revenue reached $50 billion this year.")
|
||||
rev = [r for r in results if r.candidate_type == CandidateType.REVENUE]
|
||||
assert len(rev) == 1
|
||||
assert rev[0].normalized_value == pytest.approx(50_000_000_000.0)
|
||||
|
||||
|
||||
class TestDateParsing:
|
||||
"""Test date detection."""
|
||||
|
||||
def test_named_month_date(self, parser: FinancialParser) -> None:
|
||||
results = parser.parse("The report was filed on January 15, 2024.")
|
||||
dates = [r for r in results if r.candidate_type == CandidateType.DATE]
|
||||
assert len(dates) == 1
|
||||
assert "January 15" in dates[0].literal_value
|
||||
|
||||
def test_abbreviated_month(self, parser: FinancialParser) -> None:
|
||||
results = parser.parse("Earnings released on Oct 28, 2024.")
|
||||
dates = [r for r in results if r.candidate_type == CandidateType.DATE]
|
||||
assert len(dates) == 1
|
||||
assert "Oct 28" in dates[0].literal_value
|
||||
|
||||
def test_iso_date(self, parser: FinancialParser) -> None:
|
||||
results = parser.parse("Published on 2024-01-15.")
|
||||
dates = [r for r in results if r.candidate_type == CandidateType.DATE]
|
||||
assert len(dates) == 1
|
||||
assert dates[0].literal_value == "2024-01-15"
|
||||
|
||||
|
||||
class TestFiscalPeriodParsing:
|
||||
"""Test fiscal period detection."""
|
||||
|
||||
def test_quarter_with_year(self, parser: FinancialParser) -> None:
|
||||
results = parser.parse("Results for Q1 2024 were strong.")
|
||||
periods = [r for r in results if r.candidate_type == CandidateType.FISCAL_PERIOD]
|
||||
assert len(periods) == 1
|
||||
assert periods[0].period is not None
|
||||
assert periods[0].period.period_type == "quarter"
|
||||
assert periods[0].period.period_value == "Q1"
|
||||
assert periods[0].period.year == 2024
|
||||
|
||||
def test_fiscal_year(self, parser: FinancialParser) -> None:
|
||||
results = parser.parse("FY2025 outlook is positive.")
|
||||
periods = [r for r in results if r.candidate_type == CandidateType.FISCAL_PERIOD]
|
||||
assert len(periods) == 1
|
||||
assert periods[0].period is not None
|
||||
assert periods[0].period.period_type == "fiscal_year"
|
||||
assert periods[0].period.year == 2025
|
||||
|
||||
def test_half_year(self, parser: FinancialParser) -> None:
|
||||
results = parser.parse("H1 2024 revenue grew 15%.")
|
||||
periods = [r for r in results if r.candidate_type == CandidateType.FISCAL_PERIOD]
|
||||
assert len(periods) == 1
|
||||
assert periods[0].period is not None
|
||||
assert periods[0].period.period_type == "half"
|
||||
assert periods[0].period.period_value == "H1"
|
||||
|
||||
def test_short_year(self, parser: FinancialParser) -> None:
|
||||
results = parser.parse("Q4'24 results beat estimates.")
|
||||
periods = [r for r in results if r.candidate_type == CandidateType.FISCAL_PERIOD]
|
||||
assert len(periods) == 1
|
||||
assert periods[0].period is not None
|
||||
assert periods[0].period.year == 2024
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 23.2 — Store literal and normalized representations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLiteralAndNormalized:
|
||||
"""Test that both literal text and normalized numeric values are stored."""
|
||||
|
||||
def test_money_stores_both(self, parser: FinancialParser) -> None:
|
||||
text = "$94.9 billion in revenue"
|
||||
results = parser.parse(text)
|
||||
rev = [r for r in results if r.candidate_type == CandidateType.REVENUE]
|
||||
assert len(rev) == 1
|
||||
# Literal preserved exactly
|
||||
assert rev[0].literal_value == "$94.9 billion in revenue"
|
||||
# Normalized to numeric
|
||||
assert rev[0].normalized_value == pytest.approx(94_900_000_000.0)
|
||||
|
||||
def test_percentage_stores_both(self, parser: FinancialParser) -> None:
|
||||
text = "Growth was 4% this quarter."
|
||||
results = parser.parse(text)
|
||||
pcts = [r for r in results if r.candidate_type == CandidateType.PERCENTAGE]
|
||||
assert len(pcts) == 1
|
||||
assert pcts[0].literal_value == "4%"
|
||||
assert pcts[0].normalized_value == 4.0
|
||||
|
||||
def test_basis_points_stores_both(self, parser: FinancialParser) -> None:
|
||||
text = "Rates increased 25 basis points."
|
||||
results = parser.parse(text)
|
||||
bps = [r for r in results if r.candidate_type == CandidateType.BASIS_POINTS]
|
||||
assert len(bps) == 1
|
||||
assert "25 basis points" in bps[0].literal_value
|
||||
assert bps[0].normalized_value == pytest.approx(0.25)
|
||||
|
||||
def test_eps_stores_both(self, parser: FinancialParser) -> None:
|
||||
text = "EPS was $1.52 per share."
|
||||
results = parser.parse(text)
|
||||
eps = [r for r in results if r.candidate_type == CandidateType.EPS]
|
||||
assert len(eps) == 1
|
||||
assert "$1.52 per share" in eps[0].literal_value
|
||||
assert eps[0].normalized_value == pytest.approx(1.52)
|
||||
|
||||
def test_ticker_has_no_normalized_value(self, parser: FinancialParser) -> None:
|
||||
"""Tickers don't have numeric values."""
|
||||
results = parser.parse("$AAPL is up today.")
|
||||
tickers = [r for r in results if r.candidate_type == CandidateType.TICKER]
|
||||
assert len(tickers) == 1
|
||||
assert tickers[0].normalized_value is None
|
||||
|
||||
def test_date_has_no_normalized_value(self, parser: FinancialParser) -> None:
|
||||
"""Dates don't have numeric values (they have period annotations)."""
|
||||
results = parser.parse("Q1 2024 results are strong.")
|
||||
periods = [r for r in results if r.candidate_type == CandidateType.FISCAL_PERIOD]
|
||||
assert len(periods) == 1
|
||||
assert periods[0].normalized_value is None
|
||||
assert periods[0].period is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 23.3 — Link each candidate to exact offsets
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExactOffsets:
|
||||
"""Test that each candidate has correct start_char and end_char."""
|
||||
|
||||
def test_offset_matches_source(self, parser: FinancialParser) -> None:
|
||||
"""literal_value must equal text[start_char:end_char]."""
|
||||
text = "Apple earned $1.52 per share in Q1 2024."
|
||||
results = parser.parse(text)
|
||||
for candidate in results:
|
||||
assert text[candidate.start_char:candidate.end_char] == candidate.literal_value, (
|
||||
f"Offset mismatch for {candidate.candidate_type}: "
|
||||
f"expected '{candidate.literal_value}' but got "
|
||||
f"'{text[candidate.start_char:candidate.end_char]}'"
|
||||
)
|
||||
|
||||
def test_offsets_for_multiple_candidates(self, parser: FinancialParser) -> None:
|
||||
"""Multiple candidates all have valid offsets."""
|
||||
text = "$AAPL reported $94.9 billion in revenue, up 15% year-over-year."
|
||||
results = parser.parse(text)
|
||||
assert len(results) >= 3 # ticker, revenue, percentage
|
||||
|
||||
for candidate in results:
|
||||
assert text[candidate.start_char:candidate.end_char] == candidate.literal_value
|
||||
|
||||
def test_offsets_no_overlap(self, parser: FinancialParser) -> None:
|
||||
"""Candidates should not have overlapping offsets."""
|
||||
text = "$AAPL rose 3% after reporting $94.9 billion in revenue and EPS of $2.18."
|
||||
results = parser.parse(text)
|
||||
|
||||
for i in range(len(results)):
|
||||
for j in range(i + 1, len(results)):
|
||||
a = results[i]
|
||||
b = results[j]
|
||||
# No overlap: one must end before the other starts
|
||||
assert a.end_char <= b.start_char or b.end_char <= a.start_char, (
|
||||
f"Overlap between {a.candidate_type}[{a.start_char}:{a.end_char}] "
|
||||
f"and {b.candidate_type}[{b.start_char}:{b.end_char}]"
|
||||
)
|
||||
|
||||
def test_offsets_within_bounds(self, parser: FinancialParser) -> None:
|
||||
"""All offsets must be within the text bounds."""
|
||||
text = "The stock price was $45.67 in Q3 2024."
|
||||
results = parser.parse(text)
|
||||
for candidate in results:
|
||||
assert candidate.start_char >= 0
|
||||
assert candidate.end_char <= len(text)
|
||||
assert candidate.start_char < candidate.end_char
|
||||
|
||||
def test_empty_text_no_candidates(self, parser: FinancialParser) -> None:
|
||||
"""Empty text produces no candidates."""
|
||||
assert parser.parse("") == []
|
||||
assert parser.parse(" \n\t ") == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 23.4 — Property tests for numeric formatting and unit conversions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPropertyBasedFinancialParser:
|
||||
"""Property-based tests for financial parsing.
|
||||
|
||||
**Validates: Requirements 5.1, 5.7, 5.8**
|
||||
"""
|
||||
|
||||
@given(
|
||||
amount=st.floats(min_value=0.01, max_value=999.99, allow_nan=False, allow_infinity=False),
|
||||
)
|
||||
@settings(max_examples=100)
|
||||
def test_currency_strings_parse_to_expected_value(self, amount: float) -> None:
|
||||
"""Property: Generated currency strings parse to their expected value.
|
||||
|
||||
**Validates: Requirements 5.1, 5.7**
|
||||
"""
|
||||
# Round to 2 decimal places for realistic currency
|
||||
amount = round(amount, 2)
|
||||
text = f"The price was ${amount:.2f} per unit."
|
||||
parser = FinancialParser()
|
||||
results = parser.parse(text)
|
||||
|
||||
# Should find at least one currency or EPS match
|
||||
numeric_candidates = [
|
||||
r for r in results
|
||||
if r.candidate_type in (CandidateType.CURRENCY, CandidateType.EPS)
|
||||
and r.normalized_value is not None
|
||||
]
|
||||
assert len(numeric_candidates) >= 1
|
||||
assert any(
|
||||
abs(c.normalized_value - amount) < 0.01
|
||||
for c in numeric_candidates
|
||||
), f"No candidate matched expected value {amount}"
|
||||
|
||||
@given(
|
||||
pct=st.floats(min_value=-99.9, max_value=99.9, allow_nan=False, allow_infinity=False),
|
||||
)
|
||||
@settings(max_examples=100)
|
||||
def test_percentage_strings_parse_to_expected_value(self, pct: float) -> None:
|
||||
"""Property: Generated percentage strings parse to their expected value.
|
||||
|
||||
**Validates: Requirements 5.1, 5.7**
|
||||
"""
|
||||
pct = round(pct, 1)
|
||||
if pct == 0.0:
|
||||
pct = 1.0 # Avoid edge case with sign
|
||||
sign = "+" if pct > 0 else ""
|
||||
text = f"Revenue changed {sign}{pct}% this quarter."
|
||||
parser = FinancialParser()
|
||||
results = parser.parse(text)
|
||||
|
||||
pct_candidates = [
|
||||
r for r in results
|
||||
if r.candidate_type == CandidateType.PERCENTAGE
|
||||
and r.normalized_value is not None
|
||||
]
|
||||
assert len(pct_candidates) >= 1
|
||||
assert any(
|
||||
abs(c.normalized_value - pct) < 0.1
|
||||
for c in pct_candidates
|
||||
), f"No candidate matched expected percentage {pct}"
|
||||
|
||||
@given(
|
||||
bps=st.integers(min_value=1, max_value=500),
|
||||
)
|
||||
@settings(max_examples=100)
|
||||
def test_basis_points_normalize_to_percentage(self, bps: int) -> None:
|
||||
"""Property: N basis points always normalizes to N/100 percentage points.
|
||||
|
||||
**Validates: Requirements 5.7, 5.8**
|
||||
"""
|
||||
text = f"Rates moved {bps} basis points today."
|
||||
parser = FinancialParser()
|
||||
results = parser.parse(text)
|
||||
|
||||
bps_candidates = [
|
||||
r for r in results
|
||||
if r.candidate_type == CandidateType.BASIS_POINTS
|
||||
and r.normalized_value is not None
|
||||
]
|
||||
assert len(bps_candidates) >= 1
|
||||
expected = bps / 100.0
|
||||
assert any(
|
||||
abs(c.normalized_value - expected) < 0.001
|
||||
for c in bps_candidates
|
||||
), f"Expected {expected} but got {[c.normalized_value for c in bps_candidates]}"
|
||||
|
||||
@given(
|
||||
text=st.text(min_size=1, max_size=5000, alphabet=st.characters(
|
||||
categories=("L", "N", "P", "Z", "S"),
|
||||
include_characters="$€£¥%.,+-/0123456789",
|
||||
)),
|
||||
)
|
||||
@settings(max_examples=100)
|
||||
def test_normalized_values_are_always_finite(self, text: str) -> None:
|
||||
"""Property: Normalized values are always finite floats (no inf, no NaN).
|
||||
|
||||
**Validates: Requirements 5.7, 5.8**
|
||||
"""
|
||||
parser = FinancialParser()
|
||||
results = parser.parse(text)
|
||||
|
||||
for candidate in results:
|
||||
if candidate.normalized_value is not None:
|
||||
assert math.isfinite(candidate.normalized_value), (
|
||||
f"Non-finite value {candidate.normalized_value} for "
|
||||
f"{candidate.candidate_type}: '{candidate.literal_value}'"
|
||||
)
|
||||
|
||||
@given(
|
||||
text=st.text(min_size=1, max_size=5000, alphabet=st.characters(
|
||||
categories=("L", "N", "P", "Z", "S"),
|
||||
include_characters="$€£¥%.,+-/0123456789 \n",
|
||||
)),
|
||||
)
|
||||
@settings(max_examples=100)
|
||||
def test_offsets_always_map_to_literal(self, text: str) -> None:
|
||||
"""Property: For any text, candidate offsets always map to the literal value.
|
||||
|
||||
**Validates: Requirements 5.1, 5.8**
|
||||
"""
|
||||
parser = FinancialParser()
|
||||
results = parser.parse(text)
|
||||
|
||||
for candidate in results:
|
||||
extracted = text[candidate.start_char:candidate.end_char]
|
||||
assert extracted == candidate.literal_value, (
|
||||
f"Offset mismatch: [{candidate.start_char}:{candidate.end_char}] = "
|
||||
f"'{extracted}' != literal '{candidate.literal_value}'"
|
||||
)
|
||||
|
||||
@given(
|
||||
text=st.text(min_size=1, max_size=5000, alphabet=st.characters(
|
||||
categories=("L", "N", "P", "Z", "S"),
|
||||
include_characters="$€£¥%.,+-/0123456789 \n",
|
||||
)),
|
||||
)
|
||||
@settings(max_examples=100)
|
||||
def test_no_overlapping_candidates(self, text: str) -> None:
|
||||
"""Property: No two candidates have overlapping offsets.
|
||||
|
||||
**Validates: Requirements 5.1**
|
||||
"""
|
||||
parser = FinancialParser()
|
||||
results = parser.parse(text)
|
||||
|
||||
for i in range(len(results)):
|
||||
for j in range(i + 1, len(results)):
|
||||
a = results[i]
|
||||
b = results[j]
|
||||
assert a.end_char <= b.start_char or b.end_char <= a.start_char, (
|
||||
f"Overlap: {a.candidate_type}[{a.start_char}:{a.end_char}] "
|
||||
f"vs {b.candidate_type}[{b.start_char}:{b.end_char}]"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Normalizer unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNormalizerFunctions:
|
||||
"""Test normalizer helper functions directly."""
|
||||
|
||||
def test_normalize_money_billion(self) -> None:
|
||||
assert normalize_money("$94.9 billion") == pytest.approx(94_900_000_000.0)
|
||||
|
||||
def test_normalize_money_million(self) -> None:
|
||||
assert normalize_money("$45 million") == pytest.approx(45_000_000.0)
|
||||
|
||||
def test_normalize_money_per_share(self) -> None:
|
||||
assert normalize_money("$1.52 per share") == pytest.approx(1.52)
|
||||
|
||||
def test_normalize_money_simple(self) -> None:
|
||||
assert normalize_money("$123.45") == pytest.approx(123.45)
|
||||
|
||||
def test_normalize_money_with_commas(self) -> None:
|
||||
assert normalize_money("$1,234.56") == pytest.approx(1234.56)
|
||||
|
||||
def test_normalize_percentage(self) -> None:
|
||||
assert normalize_percentage("4%") == pytest.approx(4.0)
|
||||
assert normalize_percentage("-2.5%") == pytest.approx(-2.5)
|
||||
assert normalize_percentage("+1.2 percent") == pytest.approx(1.2)
|
||||
|
||||
def test_normalize_basis_points(self) -> None:
|
||||
assert normalize_basis_points("25 basis points") == pytest.approx(0.25)
|
||||
assert normalize_basis_points("50bps") == pytest.approx(0.50)
|
||||
assert normalize_basis_points("100 bps") == pytest.approx(1.0)
|
||||
|
||||
def test_normalize_range(self) -> None:
|
||||
low, high = normalize_range("$10-$12")
|
||||
assert low == pytest.approx(10.0)
|
||||
assert high == pytest.approx(12.0)
|
||||
|
||||
def test_normalize_range_to(self) -> None:
|
||||
low, high = normalize_range("$1.50 to $2.00")
|
||||
assert low == pytest.approx(1.50)
|
||||
assert high == pytest.approx(2.00)
|
||||
|
||||
def test_normalize_value_dispatches(self) -> None:
|
||||
assert normalize_value("money", "$94.9 billion") == pytest.approx(94_900_000_000.0)
|
||||
assert normalize_value("percentage", "4%") == pytest.approx(4.0)
|
||||
assert normalize_value("basis_points", "25 basis points") == pytest.approx(0.25)
|
||||
assert normalize_value("eps", "$1.52 per share") == pytest.approx(1.52)
|
||||
assert normalize_value("ticker", "$AAPL") is None
|
||||
Reference in New Issue
Block a user