"""Unit tests and property tests for the sentence-aware segmenter. Tests cover: - Basic segmentation with correct offsets (21.1) - Document-type-specific strategies (21.2) - Filing section and transcript speaker preservation (21.3) - Boilerplate scoring (21.4) - No truncation for long documents (21.5) - Property tests for offset mapping, reconstruction, and checksums (21.6) """ from __future__ import annotations import hashlib import pytest from hypothesis import given, settings from hypothesis import strategies as st from services.intelligence_pipeline_v3.segmenter import ( ArticleStrategy, FilingStrategy, MacroEventStrategy, Segmenter, TranscriptStrategy, score_boilerplate, ) from services.intelligence_pipeline_v3.segmenter.strategies import get_strategy # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture def segmenter() -> Segmenter: return Segmenter() SAMPLE_ARTICLE = ( "Apple reported record revenue of $123.9 billion for Q1 2024. " "The company beat analyst expectations by a wide margin. " "CEO Tim Cook said growth was driven by iPhone and services. " "Shares rose 3% in after-hours trading.\n\n" "Meanwhile, Microsoft also reported strong results. " "Azure cloud revenue grew 28% year-over-year. " "The company expects continued momentum in AI workloads." ) SAMPLE_FILING = ( "Item 1. Business\n\n" "The company is a global technology leader. " "We operate in three segments. " "Our products serve enterprise customers.\n\n" "Item 2. Properties\n\n" "We own facilities in 15 countries. " "Our headquarters is in San Jose, California. " "We lease approximately 5 million square feet.\n\n" "Item 7. Management's Discussion and Analysis\n\n" "Revenue increased 15% to $50 billion. " "Operating expenses grew 8% driven by R&D investment. " "Net income was $12 billion, up from $10 billion. " "We expect continued growth in our cloud segment." ) SAMPLE_TRANSCRIPT = ( "OPERATOR: Welcome to the Q4 2024 earnings call. " "I would now like to turn the call over to Tim Cook.\n\n" "Tim Cook - CEO: Thank you. " "We are pleased to report another record quarter. " "Revenue reached $123.9 billion. " "Services revenue hit an all-time high.\n\n" "Luca Maestri - CFO: Looking at our financials, " "gross margin expanded to 46.6%. " "Operating cash flow was $40 billion.\n\n" "OPERATOR: We will now take questions from analysts." ) # --------------------------------------------------------------------------- # 21.1 — Preserve source offsets and checksums # --------------------------------------------------------------------------- class TestSourceOffsetsAndChecksums: """Test that chunks preserve exact source offsets and have valid checksums.""" def test_chunk_text_matches_source_offsets(self, segmenter: Segmenter) -> None: """Each chunk.text must exactly equal source[start_char:end_char].""" chunks = segmenter.segment(SAMPLE_ARTICLE, "article", "doc-001") for chunk in chunks: assert chunk.text == SAMPLE_ARTICLE[chunk.start_char:chunk.end_char] def test_chunk_checksum_is_sha256(self, segmenter: Segmenter) -> None: """Checksum must be SHA-256 of chunk text.""" chunks = segmenter.segment(SAMPLE_ARTICLE, "article", "doc-001") for chunk in chunks: expected = hashlib.sha256(chunk.text.encode("utf-8")).hexdigest() assert chunk.checksum == expected def test_chunk_id_is_deterministic(self, segmenter: Segmenter) -> None: """chunk_id is {document_id}:{start_char}.""" chunks = segmenter.segment(SAMPLE_ARTICLE, "article", "doc-001") for chunk in chunks: assert chunk.chunk_id == f"doc-001:{chunk.start_char}" def test_empty_text_returns_no_chunks(self, segmenter: Segmenter) -> None: """Empty string produces no chunks.""" assert segmenter.segment("", "article") == [] def test_single_sentence_produces_one_chunk(self, segmenter: Segmenter) -> None: """A short text produces exactly one chunk.""" text = "Apple stock rose 5% today." chunks = segmenter.segment(text, "article", "short-doc") assert len(chunks) == 1 assert chunks[0].text == text assert chunks[0].start_char == 0 assert chunks[0].end_char == len(text) # --------------------------------------------------------------------------- # 21.2 — Document-type-specific chunk strategies # --------------------------------------------------------------------------- class TestDocumentTypeStrategies: """Test that each document type uses its own strategy.""" def test_article_uses_article_strategy(self) -> None: strategy = get_strategy("article") assert strategy is ArticleStrategy def test_news_uses_article_strategy(self) -> None: strategy = get_strategy("news") assert strategy is ArticleStrategy def test_filing_uses_filing_strategy(self) -> None: strategy = get_strategy("filing") assert strategy is FilingStrategy def test_transcript_uses_transcript_strategy(self) -> None: strategy = get_strategy("transcript") assert strategy is TranscriptStrategy def test_macro_event_uses_macro_strategy(self) -> None: strategy = get_strategy("macro_event") assert strategy is MacroEventStrategy def test_unknown_type_uses_default(self) -> None: strategy = get_strategy("unknown_type_xyz") assert strategy is ArticleStrategy def test_macro_chunks_are_smaller(self, segmenter: Segmenter) -> None: """Macro event strategy produces smaller chunks than filing strategy.""" # Generate a long text long_text = "This is a sentence about macro events. " * 200 macro_chunks = segmenter.segment(long_text, "macro_event", "macro-1") filing_chunks = segmenter.segment(long_text, "filing", "filing-1") if len(macro_chunks) > 1 and len(filing_chunks) > 1: avg_macro = sum(len(c.text) for c in macro_chunks) / len(macro_chunks) avg_filing = sum(len(c.text) for c in filing_chunks) / len(filing_chunks) assert avg_macro < avg_filing # --------------------------------------------------------------------------- # 21.3 — Preserve filing sections and transcript speakers # --------------------------------------------------------------------------- class TestFilingSectionsAndSpeakers: """Test that filing sections and transcript speakers are preserved.""" def test_filing_section_path_assigned(self, segmenter: Segmenter) -> None: """Filing chunks should have section_path based on Item headers.""" chunks = segmenter.segment(SAMPLE_FILING, "filing", "filing-001") # At least one chunk should have a section path sections_found = [c for c in chunks if c.section_path] assert len(sections_found) > 0 def test_filing_section_contains_item_headers(self, segmenter: Segmenter) -> None: """Filing section paths should reference Item headers.""" chunks = segmenter.segment(SAMPLE_FILING, "filing", "filing-001") all_sections = set() for c in chunks: for s in c.section_path: all_sections.add(s) # Should find at least some of the Item headers assert any("Item 1" in s for s in all_sections) or any("Item 2" in s for s in all_sections) def test_transcript_speaker_assigned(self, segmenter: Segmenter) -> None: """Transcript chunks should have speaker labels.""" chunks = segmenter.segment(SAMPLE_TRANSCRIPT, "transcript", "tx-001") speakers_found = [c for c in chunks if c.speaker] assert len(speakers_found) > 0 def test_transcript_speaker_names_correct(self, segmenter: Segmenter) -> None: """Speaker names should match those in the transcript.""" chunks = segmenter.segment(SAMPLE_TRANSCRIPT, "transcript", "tx-001") all_speakers = {c.speaker for c in chunks if c.speaker} # Should find at least one of the speakers assert any("Tim Cook" in s or "OPERATOR" in s or "Luca Maestri" in s for s in all_speakers) def test_article_has_no_speaker(self, segmenter: Segmenter) -> None: """Article chunks should not have speaker metadata.""" chunks = segmenter.segment(SAMPLE_ARTICLE, "article", "art-001") for chunk in chunks: assert chunk.speaker is None # --------------------------------------------------------------------------- # 21.4 — Mark boilerplate and duplicate chunks # --------------------------------------------------------------------------- class TestBoilerplateDetection: """Test boilerplate scoring.""" def test_forward_looking_boilerplate(self) -> None: """Forward-looking statements disclaimer scores high.""" text = ( "This press release contains forward-looking statements. " "Actual results may differ materially from expectations. " "All rights reserved. © 2024 Company Inc." ) score = score_boilerplate(text) assert score >= 0.5 def test_factual_content_scores_low(self) -> None: """Factual financial content scores low.""" text = ( "Revenue increased 23% year-over-year to $45.2 billion. " "Earnings per share were $2.18, beating consensus of $2.05. " "The company raised full-year guidance to $180 billion." ) score = score_boilerplate(text) assert score < 0.3 def test_boilerplate_score_capped_at_one(self) -> None: """Score never exceeds 1.0.""" text = ( "Forward-looking statements disclaimer. Safe harbor. " "Copyright 2024. All rights reserved. Disclaimer applies. " "This press release contains certain information. " "Actual results may differ materially. Not an offer or solicitation." ) score = score_boilerplate(text) assert score <= 1.0 def test_empty_text_scores_zero(self) -> None: """Empty text scores 0.0.""" assert score_boilerplate("") == 0.0 assert score_boilerplate(" \n\t ") == 0.0 def test_segmenter_assigns_boilerplate_scores(self, segmenter: Segmenter) -> None: """Chunks from segmenter have boilerplate_score populated.""" text = ( "Revenue grew 20% this quarter. Strong performance across all segments.\n\n" "This press release contains forward-looking statements. " "Actual results may differ materially from those anticipated." ) chunks = segmenter.segment(text, "article", "bp-001") # All chunks should have a score between 0 and 1 for chunk in chunks: assert 0.0 <= chunk.boilerplate_score <= 1.0 # --------------------------------------------------------------------------- # 21.5 — Remove the 8,000-character truncation from v3 # --------------------------------------------------------------------------- class TestNoTruncation: """Test that long documents are NOT truncated.""" def test_long_document_produces_many_chunks(self, segmenter: Segmenter) -> None: """A 50,000-char document should produce multiple chunks, not be truncated.""" # Create a document well beyond 8,000 chars sentences = [f"Sentence number {i} with some financial data about revenue growth. " for i in range(1000)] long_text = " ".join(sentences) assert len(long_text) > 50000 chunks = segmenter.segment(long_text, "article", "long-doc") # Should have many chunks covering the full document assert len(chunks) > 5 # Last chunk should reach near the end of the document assert chunks[-1].end_char == len(long_text) def test_full_coverage_of_long_document(self, segmenter: Segmenter) -> None: """Every character in a long document should be covered by at least one chunk.""" sentences = [f"Market analysis point {i} shows interesting trends. " for i in range(500)] long_text = " ".join(sentences) chunks = segmenter.segment(long_text, "article", "coverage-doc") # First chunk starts at 0 or very near it assert chunks[0].start_char == 0 # Last chunk ends at document end assert chunks[-1].end_char == len(long_text) def test_beyond_8000_chars_content_preserved(self, segmenter: Segmenter) -> None: """Content after 8000 chars is preserved in chunks (not truncated).""" # Build text where important content is after 8000 chars padding = "Filler content for padding. " * 400 # ~11,200 chars important = "CRITICAL EARNINGS BEAT $5.00 EPS versus $4.50 expected." text = padding + important chunks = segmenter.segment(text, "article", "no-trunc") # The important content should appear in at least one chunk all_text = "".join(c.text[c.overlap_left:] for c in chunks) assert "CRITICAL EARNINGS BEAT" in all_text # --------------------------------------------------------------------------- # 21.6 — Property tests proving chunk/evidence span mapping # --------------------------------------------------------------------------- class TestPropertyBasedSegmenter: """Property-based tests for segmenter invariants. **Validates: Requirements 4.1, 4.2, 4.6** """ @given(text=st.text(min_size=1, max_size=20000, alphabet=st.characters( categories=("L", "N", "P", "Z", "S"), include_characters=".!? \n", ))) @settings(max_examples=100) def test_every_chunk_maps_to_source_text(self, text: str) -> None: """Property: For any text, chunk.text == source[chunk.start_char:chunk.end_char]. **Validates: Requirements 4.1, 4.6** """ segmenter = Segmenter() chunks = segmenter.segment(text, "article", "prop-test") for chunk in chunks: assert chunk.text == text[chunk.start_char:chunk.end_char], ( f"Chunk at [{chunk.start_char}:{chunk.end_char}] does not match source" ) @given(text=st.text(min_size=1, max_size=20000, alphabet=st.characters( categories=("L", "N", "P", "Z", "S"), include_characters=".!? \n", ))) @settings(max_examples=100) def test_chunks_cover_full_document(self, text: str) -> None: """Property: The non-overlapping core of chunks covers the entire source. **Validates: Requirements 4.1, 4.2** """ segmenter = Segmenter() chunks = segmenter.segment(text, "article", "cover-test") if not chunks: # Empty/whitespace-only text may produce no chunks assert not text.strip() return # First chunk starts at 0 assert chunks[0].start_char == 0 # Last chunk ends at document length assert chunks[-1].end_char == len(text) # Chunks must be ordered and cover the full range # The core (non-overlap) portions should cover without gaps # Due to overlap, adjacent chunks' starts may be <= previous chunk's end for i in range(1, len(chunks)): # Each chunk's start (adjusted for overlap) should not leave gaps core_start = chunks[i].start_char + chunks[i].overlap_left prev_end = chunks[i - 1].end_char assert core_start <= prev_end, ( f"Gap between chunk {i-1} end ({prev_end}) and chunk {i} core start ({core_start})" ) @given(text=st.text(min_size=1, max_size=10000, alphabet=st.characters( categories=("L", "N", "P", "Z", "S"), include_characters=".!? \n", ))) @settings(max_examples=100) def test_checksum_matches_sha256_of_text(self, text: str) -> None: """Property: Checksum is always SHA-256 of chunk.text. **Validates: Requirements 4.1** """ segmenter = Segmenter() chunks = segmenter.segment(text, "article", "checksum-test") for chunk in chunks: expected = hashlib.sha256(chunk.text.encode("utf-8")).hexdigest() assert chunk.checksum == expected, ( f"Checksum mismatch for chunk {chunk.chunk_id}" ) @given( text=st.text(min_size=10, max_size=15000, alphabet=st.characters( categories=("L", "N", "P", "Z", "S"), include_characters=".!? \n", )), doc_type=st.sampled_from(["article", "filing", "transcript", "macro_event"]), ) @settings(max_examples=100) def test_all_document_types_preserve_offsets(self, text: str, doc_type: str) -> None: """Property: Offset invariant holds for all document types. **Validates: Requirements 4.2, 4.3** """ segmenter = Segmenter() chunks = segmenter.segment(text, doc_type, "multi-type-test") for chunk in chunks: assert chunk.text == text[chunk.start_char:chunk.end_char] assert chunk.document_type == doc_type