Harden 8-K item-code classifier against dirty input strings

The _classify_event_type mapping (2.02 → earnings_release, 7.01 →
guidance_update, 1.01 → material_contract, 1.03 → other_material_event,
8.01 → other_material_event, 5.02 → management_change) was already in
place but used naive 'in items' string matching. Upstream extractors
sometimes deliver items as 'Item 2.02' or '2.02 - Results of Operations'
(full-description form), which silently slipped through to event_type
'unknown' and were rejected by all 12 v7.356 PEAD engines.

A reparse using the patched classifier touched 9,779 historical 'unknown'
rows; only 16 actually flipped (the rest are genuinely off-vocab 8-Ks
like 9.01-only, 3.01, 5.07). The fix is therefore small in retroactive
impact, but defends against future ingestion drift.

Changes:
 - libs/parser/rule_parser.py: rewrote _classify_event_type with
   _normalize_item_codes (regex \\b(\\d+\\.\\d+)\\b token extractor) and
   tuple-of-pairs _ITEM_TO_EVENT_TYPE mapping. Earnings_release wins
   priority over management_change when 2.02 + 5.02 co-occur, consistent
   with the strategy's vocabulary intent.
 - tests/unit/test_rule_parser.py: 7 new regression tests covering
   AMD/MNST-style 2.02+9.01, dirty 'Item 2.02' / '2.02 - Results...'
   forms, and negative cases (9.01-only, 2.03, 3.01 remain unknown).

Note: a follow-up vocabulary normalizer is still needed for the
Oracle-fallback path in apps/pipeline/event_parser/main.py:140, which
writes raw oracle_event.event_type values like 'earnings_result',
'shareholder_vote', 'regulation_fd' that don't match the strategy
vocabulary. Flagged for separate ticket.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
main
I Luk Kim 3 months ago
parent 27de44c8d8
commit f67c534ce3

@ -129,19 +129,54 @@ def _extract_item_numbers(text: str) -> list[str]:
return list(dict.fromkeys(_ITEM_RE.findall(text))) return list(dict.fromkeys(_ITEM_RE.findall(text)))
# Pattern used to harvest a clean N.NN item code from an arbitrarily formatted
# entry such as "2.02", "Item 2.02", "2.02 - Results of Operations and ...",
# or "Item 2.02 Results of Operations and Financial Condition".
_ITEM_NUMBER_TOKEN_RE = re.compile(r"\b(\d+\.\d+)\b")
def _normalize_item_codes(items: list[str]) -> list[str]:
"""Normalize raw item entries into a list of clean N.NN codes.
Tolerates upstream variation:
- "2.02" -> "2.02"
- "Item 2.02" -> "2.02"
- "2.02 - Results of Operations" -> "2.02"
- "Item 2.02 Results of Operations" -> "2.02"
- "Results of Operations and Financial..." -> "" (dropped)
Order-preserving, deduplicated.
"""
normalized: list[str] = []
for raw in items or []:
if not isinstance(raw, str):
continue
m = _ITEM_NUMBER_TOKEN_RE.search(raw)
if m:
code = m.group(1)
if code not in normalized:
normalized.append(code)
return normalized
# 8-K item code -> ParserEventOutput.event_type vocabulary.
# Earnings (2.02) takes precedence over downstream items like 9.01 because a
# 2.02 release is conventionally co-filed with 9.01 (Financial Statements and
# Exhibits) — the strategy must see those as earnings_release, not "unknown".
_ITEM_TO_EVENT_TYPE: tuple[tuple[str, str], ...] = (
("2.02", "earnings_release"),
("7.01", "guidance_update"),
("5.02", "management_change"),
("1.01", "material_contract"),
("1.03", "other_material_event"),
("8.01", "other_material_event"),
)
def _classify_event_type(items: list[str]) -> str: def _classify_event_type(items: list[str]) -> str:
if "2.02" in items: codes = _normalize_item_codes(items)
return "earnings_release" for code, event_type in _ITEM_TO_EVENT_TYPE:
if "7.01" in items: if code in codes:
return "guidance_update" return event_type
if "1.01" in items:
return "material_contract"
if "8.01" in items:
return "other_material_event"
if "5.02" in items:
return "management_change"
if "1.03" in items:
return "other_material_event"
return "unknown" return "unknown"

@ -165,3 +165,124 @@ def test_positive_backlog_and_customer_signals_outweigh_single_financing_flag():
out = p.parse("DOC::test", "8-K", text, {"filing_date": "2026-03-05"}) out = p.parse("DOC::test", "8-K", text, {"filing_date": "2026-03-05"})
assert out.event_direction == "bullish" assert out.event_direction == "bullish"
# ---------------------------------------------------------------------------
# Item -> event_type classification (regression tests for unknown-class bug
# where AMD/MNST 2.02 + 9.01 earnings filings were tagged "unknown").
# ---------------------------------------------------------------------------
def test_classify_event_type_amd_style_earnings_release_with_9_01():
"""AMD/MNST file 2.02 alongside 9.01; must classify as earnings_release."""
from libs.parser.rule_parser import _classify_event_type
# Order in either direction — earnings still wins.
assert _classify_event_type(["2.02", "9.01"]) == "earnings_release"
assert _classify_event_type(["9.01", "2.02"]) == "earnings_release"
def test_classify_event_type_tolerates_dirty_item_strings():
"""Upstream may store entries like 'Item 2.02' or '2.02 - Results...'."""
from libs.parser.rule_parser import _classify_event_type
assert _classify_event_type(["Item 2.02", "Item 9.01"]) == "earnings_release"
assert (
_classify_event_type(
["Item 2.02 Results of Operations and Financial Condition"]
)
== "earnings_release"
)
assert _classify_event_type(["2.02 - Results of Operations"]) == "earnings_release"
def test_classify_event_type_known_mappings_each_item():
from libs.parser.rule_parser import _classify_event_type
assert _classify_event_type(["7.01"]) == "guidance_update"
assert _classify_event_type(["1.01"]) == "material_contract"
assert _classify_event_type(["8.01"]) == "other_material_event"
assert _classify_event_type(["1.03"]) == "other_material_event"
# 5.02 (departure of officers) -> management_change, even when co-filed
# with 9.01 financial-statements-and-exhibits.
assert _classify_event_type(["5.02", "9.01"]) == "management_change"
def test_classify_event_type_negative_cases_remain_unknown():
"""Items that aren't in the strategy vocabulary must remain 'unknown'.
This guards against accidental over-eager relabeling.
"""
from libs.parser.rule_parser import _classify_event_type
# 9.01 alone (financial statements & exhibits) is not its own event type
assert _classify_event_type(["9.01"]) == "unknown"
# 2.03 (financial obligation), 3.01 (delisting), 5.07 (shareholder vote)
assert _classify_event_type(["2.03"]) == "unknown"
assert _classify_event_type(["3.01"]) == "unknown"
assert _classify_event_type(["5.07"]) == "unknown"
# No items at all
assert _classify_event_type([]) == "unknown"
# Description-only with no extractable code
assert (
_classify_event_type(["Results of Operations and Financial Condition"])
== "unknown"
)
def test_parse_amd_style_earnings_with_only_9_01_in_item_numbers_uses_text_fallback():
"""If the SGML loss left only 9.01 in item_numbers but the body says
'AMD Reports Q3 Financial Results', the body regex must catch it."""
from libs.parser.rule_parser import RuleBasedParser
from libs.parser.text_normalizer import normalize_text
p = RuleBasedParser()
html = """
<html><body>
<div>NEWS RELEASE</div>
<div>AMD Reports Third Quarter 2025 Financial Results</div>
<div>Revenue of $7.7 billion grew year over year.</div>
</body></html>
"""
out = p.parse(
"DOC::test",
"8-K",
normalize_text(html, is_html=True),
{"filing_date": "2025-10-29", "item_numbers": ["9.01"]},
)
assert out.event_type == "earnings_release"
def test_parse_mnst_style_earnings_dirty_item_format_classifies_correctly():
"""MNST-style: 'Item 2.02 Results of Operations and Financial Condition'
plus 'Item 9.01 ...' as raw entries must classify earnings_release."""
from libs.parser.rule_parser import RuleBasedParser
p = RuleBasedParser()
text = (
"Monster Beverage Corporation Reports 2025 Third Quarter Financial Results\n"
"Net sales of $1.97 billion."
)
out = p.parse(
"DOC::test",
"8-K",
text,
{
"filing_date": "2025-11-06",
"item_numbers": [
"Item 2.02 Results of Operations and Financial Condition",
"Item 9.01 Financial Statements and Exhibits",
],
},
)
assert out.event_type == "earnings_release"
def test_normalize_item_codes_drops_non_string_and_dedupes():
from libs.parser.rule_parser import _normalize_item_codes
# Order-preserving + dedup + non-string ignored
assert _normalize_item_codes(["Item 2.02", "2.02", "9.01", None, 7]) == [
"2.02",
"9.01",
]

Loading…
Cancel
Save