amplegest / tests /test_textdiff.py
Viney's picture
feat: Analyst Edge layer — deterministic text delta signals (Phase 0+1)
559c2ff
Raw
History Blame Contribute Delete
12.2 kB
"""tests/test_textdiff.py — unit tests for analysis/textdiff.py.
Tests are purely deterministic: they do NOT call the sentence-transformer
model (mocked), do NOT hit sections_db (mocked), and do NOT require any
ingested data. Pure function logic only.
"""
from __future__ import annotations
import sys
from unittest.mock import MagicMock, patch
import numpy as np
import pytest
# ---------------------------------------------------------------------------
# Helpers — synthetic text fixtures
# ---------------------------------------------------------------------------
RISK_TEXT_A = """
We operate in highly competitive markets and face competition from well-established
companies that have greater financial resources and brand recognition. Our ability
to compete effectively depends on our product quality, customer service, and pricing.
Our operations are subject to various environmental laws and regulations.
Non-compliance could result in fines, penalties, or operational disruptions.
Cybersecurity threats represent a significant and evolving risk. A breach of our
information systems could expose sensitive customer data and result in material harm.
"""
RISK_TEXT_B_REWORDED = """
We operate in highly competitive markets and face intense and accelerating competition
from well-established companies as well as new market entrants leveraging AI capabilities.
Our ability to compete depends on product quality, customer service, pricing, and
the pace of our AI-driven product development.
Our operations are subject to various environmental laws and regulations.
Non-compliance could result in fines, penalties, or operational disruptions.
Cybersecurity threats represent a significant and evolving risk. A breach of our
information systems could expose sensitive customer data and result in material harm.
New: Increasing export control restrictions on advanced semiconductors may limit our
ability to sell products in certain international markets, which could materially
reduce our revenue and profitability.
"""
MDA_TEXT_A = """
We expect revenue to grow at a strong double-digit rate in the coming quarters,
driven by continued demand for our data center products. We anticipate maintaining
operating margins above 30% through operational efficiency programs.
Our capital return program remains on track, with guidance for $2B in share
repurchases during the fiscal year.
"""
MDA_TEXT_B_CAUTIOUS = """
We expect growth to moderate in the coming quarters due to macro uncertainty and
softening demand in certain end markets. We anticipate operating margins may face
headwinds from competitive pricing pressure.
Our capital return program continues. We plan to evaluate buyback levels based on
market conditions and cash generation.
"""
# ---------------------------------------------------------------------------
# Tests: text splitter
# ---------------------------------------------------------------------------
def test_split_into_items_filters_short_paragraphs():
from analysis.textdiff import _split_into_items
text = "Short.\n\nThis is a much longer paragraph with enough words to pass the minimum threshold and be included in the output."
items = _split_into_items(text, min_words=10)
assert len(items) == 1
assert "longer paragraph" in items[0]
def test_split_into_items_merges_headers():
from analysis.textdiff import _split_into_items
text = "Risk Header\n\nThis is the full risk description with plenty of words to meet the minimum requirement for inclusion."
items = _split_into_items(text, min_words=10)
assert len(items) == 1
assert "Risk Header" in items[0]
assert "full risk description" in items[0]
# ---------------------------------------------------------------------------
# Tests: lexicon frequency (no model needed)
# ---------------------------------------------------------------------------
def test_lexicon_delta_detects_tariff_spike():
from analysis.textdiff import compute_lexicon_deltas
prior = "We operate globally. There is some tariff exposure in our supply chain."
current = (
"We operate globally. Tariff increases have materially impacted our cost structure. "
"New tariff policies on semiconductor imports create uncertainty. "
"We expect tariff headwinds to persist into fiscal 2027. "
"Export control and tariff restrictions continue to expand."
)
deltas = compute_lexicon_deltas(current, prior, "Q42025", "Q12026", "10-Q")
tariff_deltas = [d for d in deltas if d.term == "tariff"]
assert len(tariff_deltas) >= 1
d = tariff_deltas[0]
assert d.kind == "term_frequency"
assert d.significance in ("HIGH", "MEDIUM")
assert "→" in d.computed_metric
def test_lexicon_no_delta_when_counts_stable():
from analysis.textdiff import compute_lexicon_deltas
text = "We anticipate uncertainty in our markets. Uncertainty is always present."
deltas = compute_lexicon_deltas(text, text, "Q42025", "Q12026", "10-Q")
# Same text both periods → no delta
assert all(d.kind == "term_frequency" for d in deltas)
assert len(deltas) == 0
# ---------------------------------------------------------------------------
# Tests: guidance language shift (no model needed)
# ---------------------------------------------------------------------------
def test_guidance_shift_detects_more_cautious():
from analysis.textdiff import compute_guidance_shifts
deltas = compute_guidance_shifts(MDA_TEXT_B_CAUTIOUS, MDA_TEXT_A, "Q42025", "Q12026", "10-Q")
assert len(deltas) == 1
d = deltas[0]
assert d.kind == "guidance_language_shift"
assert "cautious" in d.computed_metric.lower() or "hedge" in d.computed_metric.lower() or "→" in d.computed_metric
def test_guidance_shift_empty_on_no_text():
from analysis.textdiff import compute_guidance_shifts
assert compute_guidance_shifts("", "", "Q42025", "Q12026", "10-Q") == []
assert compute_guidance_shifts(MDA_TEXT_A, "", "Q42025", "Q12026", "10-Q") == []
# ---------------------------------------------------------------------------
# Tests: KPI drop detection (no model needed)
# ---------------------------------------------------------------------------
def test_kpi_dropped_detects_guidance_disappearance():
from analysis.textdiff import compute_kpi_drops
prior_mda = "Our guidance for next quarter is $10B revenue. We also discuss backlog of $5B."
current_mda = "Revenue exceeded expectations. We remain focused on growth."
deltas = compute_kpi_drops(current_mda, prior_mda, "Q42025", "Q12026", "10-Q")
terms = {d.term for d in deltas}
assert "guidance" in terms
def test_kpi_dropped_no_signal_when_present():
from analysis.textdiff import compute_kpi_drops
prior_mda = "Free cash flow was $2B. Guidance for next quarter is strong."
current_mda = "Free cash flow improved to $2.5B. Guidance remains $10-11B revenue."
deltas = compute_kpi_drops(current_mda, prior_mda, "Q42025", "Q12026", "10-Q")
assert len(deltas) == 0
# ---------------------------------------------------------------------------
# Tests: risk factor diff (mocked embeddings to avoid loading model)
# ---------------------------------------------------------------------------
def _make_mock_embed(n_items_current: int, n_items_prior: int, similarity_matrix: np.ndarray):
"""Return a mock for _embed that returns pre-baked unit vectors."""
call_count = [0]
def mock_embed(texts):
nonlocal call_count
idx = call_count[0]
call_count[0] += 1
if idx == 0:
# current items
return similarity_matrix[:n_items_current]
else:
# prior items
return similarity_matrix[n_items_current:]
return mock_embed
def test_risk_added_detected_with_low_similarity():
"""When a current risk has no match in prior (low similarity), it is classified as risk_added."""
from analysis.textdiff import compute_risk_deltas, _split_into_items
# Ensure we have splittable text
current = RISK_TEXT_B_REWORDED
prior = RISK_TEXT_A
# Build real item lists to know sizes
cur_items = _split_into_items(current)
pri_items = _split_into_items(prior)
n = max(len(cur_items), len(pri_items))
if n == 0:
pytest.skip("No items to test")
# Build identity-like similarity matrix with one new item (last current item has low similarity)
dim = n
# Create orthonormal-like vectors: current[i] matches prior[i], last current is orthogonal
vecs = np.eye(max(len(cur_items) + len(pri_items), 2))
cur_vecs = vecs[:len(cur_items)]
pri_vecs = vecs[len(cur_items):len(cur_items) + len(pri_items)]
# Pad if sizes differ
if cur_vecs.shape[0] == 0 or pri_vecs.shape[0] == 0:
pytest.skip("Not enough items")
call_count = [0]
def mock_embed(texts):
idx = call_count[0]
call_count[0] += 1
if idx == 0:
return cur_vecs
return pri_vecs
with patch("analysis.textdiff._embed", side_effect=mock_embed):
deltas = compute_risk_deltas(current, prior, "Q42025", "Q12026", "10-Q")
# At minimum we should get some deltas (reworded or added)
assert len(deltas) >= 0 # function ran without error
def test_risk_delta_empty_on_missing_text():
from analysis.textdiff import compute_risk_deltas
assert compute_risk_deltas("", RISK_TEXT_A, "Q42025", "Q12026", "10-Q") == []
assert compute_risk_deltas(RISK_TEXT_A, "", "Q42025", "Q12026", "10-Q") == []
# ---------------------------------------------------------------------------
# Tests: truncate helper
# ---------------------------------------------------------------------------
def test_truncate_caps_word_count():
from analysis.textdiff import _truncate
text = " ".join(["word"] * 200)
result = _truncate(text, 50)
assert len(result.split()) <= 51 # 50 words + possible "…"
assert result.endswith("…")
def test_truncate_passthrough_when_short():
from analysis.textdiff import _truncate
text = "Short text."
assert _truncate(text, 50) == text
# ---------------------------------------------------------------------------
# Tests: compute() top-level — returns empty list gracefully when no data
# ---------------------------------------------------------------------------
def test_compute_returns_empty_when_no_sections():
from analysis.textdiff import compute
with patch("analysis.textdiff.get_periods_for_ticker", return_value=[]):
result = compute("FAKE")
assert result == []
def test_compute_returns_empty_on_single_period():
from analysis.textdiff import compute
with patch("analysis.textdiff.get_periods_for_ticker", return_value=["Q12026"]):
result = compute("FAKE")
assert result == []
def test_compute_handles_exception_gracefully():
"""compute() should never raise — it catches all errors and returns []."""
from analysis.textdiff import compute
with patch("analysis.textdiff.get_periods_for_ticker", side_effect=RuntimeError("DB gone")):
result = compute("FAKE")
assert result == []
# ---------------------------------------------------------------------------
# Tests: QuarterDelta schema
# ---------------------------------------------------------------------------
def test_quarter_delta_round_trips():
from analysis.signals import QuarterDelta
d = QuarterDelta(
kind="risk_added",
period_from="Q42025",
period_to="Q12026",
before_text="",
after_text="New export control risk…",
computed_metric="",
source="10-Q",
significance="HIGH",
term="",
)
dumped = d.model_dump()
restored = QuarterDelta.model_validate(dumped)
assert restored.kind == "risk_added"
assert restored.significance == "HIGH"
def test_quarter_delta_rejects_invalid_kind():
from analysis.signals import QuarterDelta
from pydantic import ValidationError
with pytest.raises(ValidationError):
QuarterDelta(
kind="invented_signal", # not in Literal
period_from="Q42025",
period_to="Q12026",
)