""" framing_v2: Track A signal-level additions per DESIGN_v2 §4. Implements: S-1: Expanded uncertainty regex (adds 15+ hedge constructions to the existing uncertainty category). S-2: detect_named_author_citation() - new signal for named-author- and-institutional-citation patterns. S-3: detect_growth_vocabulary() - new signal for growth/expansion vocabulary. S-4: detect_efficiency_vocabulary() - new signal for efficiency/ optimization vocabulary. Does NOT modify the live framing.py. Used by frame_library_v3 in the Track A test harness. """ from __future__ import annotations import re import sys from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[2] if str(REPO_ROOT) not in sys.path: sys.path.insert(0, str(REPO_ROOT)) # Import the full live framing module; we override only what's changing. import framing as _framing from clarethium_measure import split_sentences # ============================================================================ # S-1: Expanded uncertainty regex # ============================================================================ # The live framing.py ANALYTICAL_CATEGORIES["uncertainty"] regex is copied # here and extended with hedge constructions identified in the validation # study as systematically missed. Rationale in RULE_AUDIT.md §2.8. _UNCERTAINTY_V2_RE = re.compile( # Original patterns from framing.py (preserved verbatim) r'\b(unclear|uncertain(?:ty)?|debated|contested|depends|varies|' r'unpredictable|unknown|estimated|' r'approximate(?:ly)?(?!\s*[\d$%])|roughly(?!\s*[\d$%])|' r'open\s+question|remains\s+to\s+be\s+seen|' r'not\s+(?:yet|fully)\s+(?:clear|understood|determined)|' r'concerns?\s+(?:about|over|remain|that)|' r'questions?\s+(?:about|whether|remain)|' r'remains?\s+(?:unclear|uncertain|to\s+be\s+seen|limited|insufficient|incomplete)|' r'limited\s+(?:data|evidence|research)|insufficient\s+(?:data|evidence)|' r'long[- ]term\s+(?:safety|outcomes?|effects?|data|implications?)|' r'preliminary|inconclusive|unresolved|tentative|' # S-1 NEW: hedge constructions added per RULE_AUDIT.md §2.8 r'undemonstrated|unproven|' r'theoretically\s+promising|practically\s+unproven|' r'years\s+or\s+decades|years\s+away|decades\s+away|' r'no\s+credible\s+signs?|not\s+yet\s+clear|' r'remains?\s+open|far\s+from\s+settled|' r'difficult\s+to\s+predict|subject\s+to\s+revision|' r'hard\s+to\s+say|too\s+early\s+to|' r'cannot\s+yet|yet\s+to\s+be\s+(?:seen|determined|established|validated)|' r'is\s+unclear\s+whether|debate\s+continues|' r'no\s+consensus|disputed\s+among|' r'still\s+(?:years|decades)|' r'much\s+remains\s+(?:unclear|unknown)|' r'evidence\s+is\s+(?:mixed|limited|thin|sparse)|' r'lack\s+(?:a|an)?\s*(?:clear|full|thorough)\s+understanding)\b', re.IGNORECASE ) def detect_coverage_v2(text: str) -> dict: """detect_coverage with the S-1 expanded uncertainty regex. Strategy: delegate to live framing.detect_coverage for everything, then recompute the uncertainty category with the expanded regex and recompute the covered/missing sets accordingly. This preserves all live-module invariants (diminisher handling, covered-threshold logic, keys) while applying S-1 only to the uncertainty dimension. """ # Live detection first (gives us full structure including diminishers) live = _framing.detect_coverage(text) # Guard against word_count=0 (empty text): divide-by-zero caused # ZeroDivisionError in density computation below. Floor at 1 so # empty / trivially-short inputs return zero density rather than # crashing (documented by test_frame_library_v4_1.xfail). n_words = max(live.get("word_count") or len(text.split()), 1) # Recompute uncertainty with the S-1 expanded regex uncertainty_matches = _UNCERTAINTY_V2_RE.findall(text) n_u = len(uncertainty_matches) u_density = round(n_u * 1000.0 / n_words, 1) # Mirror the live module's "covered" determination for uncertainty. # Live module considers a category "covered" when count >= 2 (or a # density threshold; use the same rule it applies to other categories # by inspecting the existing uncertainty "covered" flag and how many # markers it had). live_u = live.get("categories", {}).get("uncertainty", {}) live_u_count = live_u.get("count", 0) live_u_dismissed = live_u.get("dismissed", 0) # If live considered it covered at count C, we can approximate the # threshold as count >= 2 with diminishers subtracted. effective_u = n_u - live_u_dismissed covered_u = effective_u >= 2 # Update the categories dict with the new uncertainty entry new_cats = dict(live.get("categories", {})) new_cats["uncertainty"] = { "count": n_u, "density_per_1kw": u_density, "covered": covered_u, "dismissed": live_u_dismissed, } # Recompute covered/missing lists covered = [c for c, info in new_cats.items() if info.get("covered")] missing = [c for c in new_cats if c not in covered] out = dict(live) out["categories"] = new_cats out["covered"] = covered out["missing"] = missing out["coverage_count"] = len(covered) return out # ============================================================================ # S-2: Named-author-citation signal # ============================================================================ # Pattern: "Name (Year)" as in academic citation _PATTERN_NAME_YEAR = re.compile( r'\b([A-Z][a-z]+(?:\s+(?:et\s+al\.|and\s+[A-Z][a-z]+))?)\s*\((?:19|20)\d{2}\)', ) # Pattern: "according to Name" or "according to the Name" _PATTERN_ACCORDING_TO = re.compile( r'\baccording\s+to\s+(?:the\s+)?([A-Z][A-Za-z]+(?:\s+[A-Z][A-Za-z]+){0,3})\b', re.IGNORECASE, ) # Pattern: "Name's research/work/study/paper/analysis/argument" _PATTERN_NAME_POSSESSIVE = re.compile( r'\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+){0,2})[\u2019\']s\s+' r'(?:research|work|study|paper|analysis|argument|findings?|' r'theory|results?|observation|book|article)\b', ) # Pattern: "Name found/argued/published/identified/showed/noted/observed" # Requires at least two consecutive capitalized words (author name) near # a research verb. _PATTERN_NAME_VERB = re.compile( r'\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,2})\s+' r'(?:found|argued|published|identified|showed|noted|observed|' r'demonstrated|proposed|reported|discovered|suggested|concluded|' r'introduced|developed|claimed)\b', ) # Pattern: institutional authority citations (known acronyms/names + claim-verb context) _PATTERN_INSTITUTIONAL = re.compile( r'\b(FDA|WHO|CDC|EPA|NIH|NASA|SEC|IMF|UN|OECD|ECB|IPCC|' r'European\s+(?:Commission|Parliament|Council|Union)|' r'World\s+(?:Bank|Health\s+Organization|Meteorological\s+Organization)|' r'United\s+Nations|Federal\s+Reserve|Supreme\s+Court|' r'American\s+(?:Medical\s+Association|Psychological\s+Association))\b' ) # Pattern: "a (YEAR) study/paper/survey" _PATTERN_YEAR_STUDY = re.compile( r'\b(?:a|the)\s+(?:19|20)\d{2}\s+(?:study|paper|survey|report|analysis|meta-analysis|review)\b', re.IGNORECASE, ) def detect_named_author_citation(text: str) -> dict: """Detect named-author and institutional-authority citation patterns. Returns dict with: matches: total count of matched patterns density_per_1kw: matches normalized by word count examples: up to 5 example matches (for debugging/validation) by_pattern: count per pattern type """ n_words = max(len(text.split()), 1) patterns = { "name_year": _PATTERN_NAME_YEAR, "according_to": _PATTERN_ACCORDING_TO, "possessive": _PATTERN_NAME_POSSESSIVE, "name_verb": _PATTERN_NAME_VERB, "institutional": _PATTERN_INSTITUTIONAL, "year_study": _PATTERN_YEAR_STUDY, } by_pattern = {} all_matches = [] for name, pat in patterns.items(): found = pat.findall(text) by_pattern[name] = len(found) all_matches.extend(str(m) for m in found) # Deduplicate to count unique authorities; simple lowercase match set unique = list({m.lower() for m in all_matches}) total_matches = len(all_matches) unique_count = len(unique) return { "matches": total_matches, "unique_count": unique_count, "density_per_1kw": round(total_matches * 1000.0 / n_words, 1), "examples": all_matches[:5], "by_pattern": by_pattern, } # ============================================================================ # S-3: Growth vocabulary signal # ============================================================================ _GROWTH_VOCAB_RE = re.compile( r'\b(prosperity|prosperous|' r'expand(?:ing|ed|s)?|expansion|expansive|' r'grow(?:ing|n|s|th)?|grew|' r'scal(?:ing|ed|es)|scale(?:\s+up)?|' r'outperform(?:ing|ed|s)?|' r'surge(?:d|s|ing)?|' r'boom(?:ing|s|ed)?|' r'boost(?:ed|ing|s)?|' r'dominate(?:s|d|ing)?|domination|' r'accelerate(?:d|ing|s)?|acceleration|' r'breakthrough(?:s)?|' r'record\s+(?:revenue|earnings|growth|profits?|results?)|' r'compound(?:s|ed|ing)?|exponential|' r'unprecedented\s+(?:growth|opportunity|scale)|' r'massive\s+(?:prosperity|growth|opportunity|scale)|' r'tremendous\s+(?:upside|opportunity)|' r'transformative|rapidly\s+growing|' r'skyrocket(?:ing|ed|s)?|flourish(?:ing|ed|s)?|' r'thriv(?:ing|ed|es?)|' r'breakthroughs?)\b', re.IGNORECASE ) def detect_growth_vocabulary(text: str) -> dict: n_words = max(len(text.split()), 1) matches = _GROWTH_VOCAB_RE.findall(text) return { "matches": len(matches), "density_per_1kw": round(len(matches) * 1000.0 / n_words, 1), "examples": list({m.lower() for m in matches})[:10], } # ============================================================================ # S-4: Efficiency vocabulary signal # ============================================================================ _EFFICIENCY_VOCAB_RE = re.compile( r'\b(efficient(?:cy|ly)?|inefficien(?:cy|t|tly)|' r'optim(?:al|ize|izes|ized|izing|ization|ums?)|' r'streamlin(?:e|ed|es|ing)|' r'productive|productivity|' r'cost\s+(?:reduction|saving|cutting|control|effective|efficien\w*)|' r'automate(?:d|s|ing)?|automation|' r'lean|throughput|' r'reduce\s+(?:cost|time|overhead|friction|latency|waste)|' r'reducing\s+(?:cost|time|overhead|friction|latency|waste)|' r'outsource(?:d|s|ing)?|offshore(?:d|s|ing)?|' r'effective(?:ness|ly)?|' r'operational\s+(?:efficiency|excellence|effectiveness)|' r'resource\s+(?:efficien\w*|optim\w*)|' r'scale\s+(?:more\s+)?efficien\w*)\b', re.IGNORECASE ) def detect_efficiency_vocabulary(text: str) -> dict: n_words = max(len(text.split()), 1) matches = _EFFICIENCY_VOCAB_RE.findall(text) return { "matches": len(matches), "density_per_1kw": round(len(matches) * 1000.0 / n_words, 1), "examples": list({m.lower() for m in matches})[:10], }