""" InstitutionalExtractor — derives InstitutionalState from unstructured text. Two-stage pipeline: Stage 1 — Date extraction: regex scanning + dateutil.parser Stage 2 — Event classification: keyword patterns assign t_op / t_inst / actors Usage (Option B — automatic): extractor = InstitutionalExtractor() state, confidence = extractor.extract(query + " " + synthesis) if state and confidence > 0.3: metrics = lens.observe(state) Usage (Option A — structured, zero extraction overhead): state = InstitutionalState( state_id="recall-2024", ladder=TimestampLadder(t_op=days_since_epoch(2024, 1, 10), t_inst=days_since_epoch(2024, 10, 10)), closure_class=ClosureClass.SUPPRESSED, ... ) metrics = lens.observe(state) Confidence reflects how many key fields (t_op, t_inst, closure_class, actors) were successfully populated. Callers should apply a threshold (≥ 0.3 suggested) before trusting the output. """ from __future__ import annotations import hashlib import logging import math import re from dataclasses import dataclass from datetime import datetime from typing import Dict, List, Optional, Tuple from reasoning_forge.time_travel_lens import ( ActorGap, ClosureClass, InstitutionalState, TimestampLadder, ) logger = logging.getLogger(__name__) # ── Event-type keyword patterns ─────────────────────────────────────────────── # Material action: what the organization DID before formal acknowledgement _OP_RE = re.compile( r'\b(' r'patch(ed)?|fix(ed)?|modif(ied|y|ication)?|adjust(ed|ment)?|' r'implement(ed)?|quietly|internally|discover(ed)?|' r'aware|knew|know|found|identified?|detect(ed)?|' r'changed?|altered?|corrected?|remediat(ed)?|' r'suppress(ed)?|conceal(ed)?|hid(den)?|cover(ed)?\s*up|' r'kept?\s+(quiet|secret|hidden)|did\s+not\s+(disclose|report|tell)' r')\b', re.IGNORECASE, ) # Formal registration: public / official acknowledgement _INST_RE = re.compile( r'\b(' r'announced?|disclos(ed|ure)|report(ed|ing)?|fil(ed|ing)?|' r'register(ed)?|publish(ed)?|admitted?|recall(ed)?|' r'notif(ied)?\s+(?:the\s+)?public|press\s+release|' r'official(ly)?|regulatory\s+(filing|report|submission)|' r'formal(ly)?|publicly\s+(disclosed?|announced?|admitted?)|' r'documented?|submi(tted|ssion)|acknowledged?' r')\b', re.IGNORECASE, ) # Closure class signals _SUPPRESSED_RE = re.compile( r'\b(' r'den(ied|y)|conceal(ed)?|hid(den)?|suppress(ed)?|cover(ed)?\s*up|' r'fail(ed)?\s+to\s+(disclose|report|tell)|did\s+not\s+report|' r'never\s+report(ed)?|cover-up|withheld?|kept?\s+secret' r')\b', re.IGNORECASE, ) _DRIFT_RE = re.compile( r'\b(' r'ongoing|under\s+investigation|still\s+review(ing)?|pending|unresolved|' r'under\s+review|not\s+yet\s+(disclosed?|reported?)|' r'investigation\s+continue|active\s+investigation' r')\b', re.IGNORECASE, ) _CLOSED_RE = re.compile( r'\b(' r'recall(ed)?|fully\s+disclos(ed|ure)|admit(ted)?|resolv(ed)?|' r'compensat(ed)?|settl(ed|ement)|final\s+report|' r'closed?\s+(investigation|case)|investigation\s+complet(ed)?' r')\b', re.IGNORECASE, ) # Known institutional actor types and their keyword aliases _ACTOR_TERMS: Dict[str, List[str]] = { "engineers": ["engineer", "technical", "developer", "r&d", "scientist", "researcher"], "management": ["management", "manager", "director", "vp ", "vice president", "supervisor"], "legal": ["legal", "lawyer", "attorney", "counsel", "law department", "general counsel"], "executives": ["executive", "ceo", "cfo", "coo", "cto", "board", "leadership", "c-suite"], "regulators": ["regulator", "agency", "fda", "epa", "sec ", "nhtsa", "government", "federal", "oversight", "inspector", "auditor"], "employees": ["employee", "worker", "staff", "team member", "personnel"], } # ── Date extraction ─────────────────────────────────────────────────────────── # Ordered by specificity — more specific patterns are tried first _DATE_REGEXES = [ re.compile( r'(?:January|February|March|April|May|June|July|August|' r'September|October|November|December)\s+\d{1,2}(?:,?\s+\d{4})?', re.IGNORECASE, ), re.compile( r'(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\.?\s+\d{1,2}(?:,?\s+\d{4})?', re.IGNORECASE, ), re.compile(r'\d{4}-\d{2}-\d{2}'), re.compile(r'\d{1,2}/\d{1,2}/(?:\d{4}|\d{2})'), re.compile( r'\d{1,2}(?:st|nd|rd|th)\s+(?:of\s+)?' r'(?:January|February|March|April|May|June|July|August|' r'September|October|November|December)', re.IGNORECASE, ), ] _EPOCH = datetime(1970, 1, 1) @dataclass class _DateHit: raw: str parsed: Optional[datetime] context: str # ±100 chars of surrounding text op_score: float # keyword density for material-action events inst_score: float # keyword density for formal-registration events class InstitutionalExtractor: """ Derives InstitutionalState from a text string. Thread-safe — a single instance may be reused across forge calls. dateutil is an optional dependency; if absent, only ISO dates are parsed. """ def __init__(self, reference_year: int = 2025): self._reference_year = reference_year self._dateutil_available = self._check_dateutil() @staticmethod def _check_dateutil() -> bool: try: import dateutil.parser # noqa: F401 return True except ImportError: return False # ── Stage 1: date extraction ────────────────────────────────────────────── def _find_dates(self, text: str) -> List[_DateHit]: hits: List[_DateHit] = [] seen: set = set() for rx in _DATE_REGEXES: for m in rx.finditer(text): # Skip overlapping spans if any(s in seen for s in range(m.start(), m.end())): continue seen.update(range(m.start(), m.end())) raw = m.group(0) parsed = self._parse_date(raw) lo = max(0, m.start() - 100) hi = min(len(text), m.end() + 100) ctx = text[lo:hi] hits.append(_DateHit( raw=raw, parsed=parsed, context=ctx, op_score=float(len(_OP_RE.findall(ctx))), inst_score=float(len(_INST_RE.findall(ctx))), )) return hits def _parse_date(self, raw: str) -> Optional[datetime]: if self._dateutil_available: try: from dateutil import parser as dup return dup.parse( raw, default=datetime(self._reference_year, 1, 1), dayfirst=False, ) except Exception: pass # Fallback: ISO only try: return datetime.strptime(raw.strip(), "%Y-%m-%d") except ValueError: return None # ── Stage 2: event classification ──────────────────────────────────────── def _infer_closure_class(self, text: str) -> Tuple[ClosureClass, float]: sup = len(_SUPPRESSED_RE.findall(text)) drift = len(_DRIFT_RE.findall(text)) closed = len(_CLOSED_RE.findall(text)) total = sup + drift + closed if total == 0: return ClosureClass.DRIFT, 0.15 def _conf(n: int) -> float: return round(min(1.0, n / max(total, 1) * 1.5), 2) if sup >= closed and sup >= drift: return ClosureClass.SUPPRESSED, _conf(sup) if closed >= sup and closed >= drift: return ClosureClass.CLOSED, _conf(closed) return ClosureClass.DRIFT, _conf(drift) def _extract_actors( self, text: str, t_op: Optional[float], t_inst: Optional[float], ) -> List[ActorGap]: """ Assigns per-actor timestamps by examining which event-type keywords appear in sentences that mention each actor. """ actors: List[ActorGap] = [] sentences = re.split(r'[.!?\n]', text) for actor_name, aliases in _ACTOR_TERMS.items(): actor_sentences = [ s for s in sentences if any(alias in s.lower() for alias in aliases) ] if not actor_sentences: continue joined = " ".join(actor_sentences) op_hits = len(_OP_RE.findall(joined)) inst_hits = len(_INST_RE.findall(joined)) actors.append(ActorGap( actor_id=actor_name, t_op_i=t_op if op_hits > 0 else None, t_inst_i=t_inst if inst_hits > 0 else None, )) return actors # ── Main ───────────────────────────────────────────────────────────────── def extract(self, text: str) -> Tuple[Optional[InstitutionalState], float]: """ Derive InstitutionalState from text. Returns (state, confidence) where confidence ∈ [0.0, 1.0]. Returns (None, 0.0) when insufficient structure is found. Confidence rubric: Each populated field (t_op, t_inst, closure_class, actors) contributes 0.25 × closure_confidence to the final score. A fully-populated extraction with strong closure signal approaches 1.0. """ if not text or len(text.strip()) < 30: return None, 0.0 hits = self._find_dates(text) if not hits: return None, 0.0 # Pick t_op and t_inst as the highest-scoring candidates for each role, # requiring them to be distinct date hits. op_ranked = sorted(hits, key=lambda h: h.op_score, reverse=True) inst_ranked = sorted(hits, key=lambda h: h.inst_score, reverse=True) op_hit = op_ranked[0] inst_hit = next((h for h in inst_ranked if h is not op_hit), None) t_op = self._to_days(op_hit.parsed) t_inst = self._to_days(inst_hit.parsed) if inst_hit else None if t_op is None and t_inst is None: return None, 0.0 closure_class, closure_conf = self._infer_closure_class(text) actors = self._extract_actors(text, t_op, t_inst) # Confidence: fraction of key fields populated × closure confidence populated = sum([ t_op is not None, t_inst is not None, closure_conf > 0.25, len(actors) > 0, ]) confidence = round((populated / 4.0) * max(closure_conf, 0.2), 3) # Unfolding energy proxy: keyword density per 100 words word_count = max(len(text.split()), 1) keyword_hits = len(_OP_RE.findall(text)) + len(_INST_RE.findall(text)) unfolding_energy = round(keyword_hits / word_count * 100, 2) state_id = hashlib.sha256(text[:200].encode()).hexdigest()[:16] state = InstitutionalState( state_id=state_id, ladder=TimestampLadder( t_op=t_op, t_inst=t_inst, ), closure_class=closure_class, unfolding_energy=unfolding_energy, influence_over_time=[1.0] * max(1, len(hits)), actor_gaps=actors, registration_fidelity_memo=op_hit.context[:250], ) logger.debug( "[InstitutionalExtractor] state=%s t_op=%.1f t_inst=%s " "closure=%s conf=%.3f actors=%d", state_id, t_op or 0, f"{t_inst:.1f}" if t_inst is not None else "None", closure_class.value, confidence, len(actors), ) return state, confidence # ── Helpers ─────────────────────────────────────────────────────────────── @staticmethod def _to_days(dt: Optional[datetime]) -> Optional[float]: """Convert a datetime to days-since-Unix-epoch for TimestampLadder.""" if dt is None: return None return (dt - _EPOCH).total_seconds() / 86400.0