fsi-anomaly / research /claimgraph.py
FerrellSyntheticIntelligence's picture
backup all: 100 files (batch)
8b8e59d verified
Raw
History Blame Contribute Delete
13.9 kB
"""Claim Graph β€” deterministic contradiction elevation for the FSI suit.
The model does NOT resolve contradictions. The system SURFACES them:
"Doc A says X. Doc B says NOT X." β†’ elevated CONTRADICTION card for the human.
Big-tech basis (tiny-model-suit): claim graph extraction + provenance tiering.
Deterministic, no model call needed for the elevation step.
"""
import re
import json
from collections import defaultdict
from dataclasses import dataclass, asdict
from typing import List, Dict, Optional
from pathlib import Path
# --- Claim extraction (deterministic) ---
CLAIM_PATTERNS = [
# "X is Y" / "X was Y" / "X will be Y"
(re.compile(r'\b([A-Z][a-zA-Z\s]{2,50}?)\s+(is|was|will be|has been|had been)\s+([^.]{5,100})', re.IGNORECASE), 'assertion'),
# "X said Y" / "X stated Y" / "X claimed Y"
(re.compile(r'\b([A-Z][a-zA-Z\s]{2,50}?)\s+(said|stated|claimed|asserted|reported|wrote)\s+(?:that\s+)?([^.]{5,100})', re.IGNORECASE), 'attribution'),
# Time expressions: "at 9am", "ended at 11am", "ran until noon", "by 5pm" - FULL sentence capture
(re.compile(r'(?:^|[.!?]\s+)([^.]*?(?:at|by|until|ended at|ran until)\s+\d{1,2}:\d{2}|\d{1,2}(?:am|pm)|noon|midnight)[^.]*\.', re.IGNORECASE), 'time'),
# Numbers/dates that are checkable
(re.compile(r'\b(\d{1,3}(?:,\d{3})*(?:\.\d+)?%?)\b'), 'number'),
(re.compile(r'\b(?:19|20)\d{2}\b'), 'year'),
# Quoted claims
(re.compile(r'"([^"]{10,200})"'), 'quote'),
]
# Key nouns that indicate the SUBJECT of a claim (for cross-doc matching)
SUBJECT_NOUNS = {
'meeting', 'budget', 'file', 'incident', 'report', 'document', 'memo', 'record',
'account', 'statement', 'testimony', 'evidence', 'claim', 'assertion', 'allegation',
'event', 'occurrence', 'deletion', 'modification', 'change', 'update', 'revision',
'investigation', 'audit', 'review', 'analysis', 'study', 'survey', 'assessment',
'timeline', 'schedule', 'agenda', 'minutes', 'transcript', 'recording', 'log',
'entry', 'transaction', 'transfer', 'payment', 'deposit', 'withdrawal', 'balance',
'amount', 'total', 'sum', 'figure', 'number', 'value', 'cost', 'price', 'fee',
'date', 'time', 'year', 'month', 'day', 'hour', 'minute', 'deadline', 'period',
'person', 'individual', 'official', 'witness', 'source', 'author', 'speaker',
'agency', 'department', 'organization', 'company', 'institution', 'government',
'program', 'project', 'operation', 'initiative', 'policy', 'rule', 'regulation',
'law', 'statute', 'order', 'directive', 'memo', 'memorandum', 'letter', 'email',
'message', 'communication', 'notification', 'alert', 'warning', 'report', 'filing'
}
@dataclass
class Claim:
text: str
claim_type: str
source_doc: str
source_id: str
entities: List[str]
subjects: List[str]
values: List[str]
times: List[str]
span_start: int
span_end: int
@dataclass
class Contradiction:
claim_a: Claim
claim_b: Claim
contradiction_type: str # 'direct' | 'numeric' | 'temporal' | 'attribution' | 'time'
severity: str # 'high' | 'medium' | 'low'
explanation: str
def extract_entities(text: str) -> List[str]:
"""Extract proper nouns and key entities from text."""
words = re.findall(r'\b[A-Z][a-zA-Z]{2,}\b', text)
stop = {'The', 'This', 'That', 'These', 'Those', 'It', 'He', 'She', 'We', 'They', 'You', 'I',
'At', 'By', 'Until', 'Ended', 'Ran', 'Official', 'Witness', 'Stated', 'Said'}
return list(set(w for w in words if w not in stop))
def extract_subjects(text: str) -> List[str]:
"""Extract subject nouns (meeting, budget, file, etc.) for cross-doc matching."""
text_lower = text.lower()
subjects = []
for noun in SUBJECT_NOUNS:
if noun in text_lower:
subjects.append(noun)
return subjects
def extract_values(text: str) -> List[str]:
"""Extract checkable values: numbers, years, percentages."""
values = []
values.extend(re.findall(r'\b\d{1,3}(?:,\d{3})*(?:\.\d+)?%?\b', text))
values.extend(re.findall(r'\b(?:19|20)\d{2}\b', text))
return values
def extract_times(text: str) -> List[str]:
"""Extract time expressions."""
times = re.findall(r'\d{1,2}:\d{2}|\d{1,2}(?:am|pm)|noon|midnight', text, re.IGNORECASE)
return [t.lower() for t in times]
def extract_claims(text: str, source_doc: str, source_id: str) -> List[Claim]:
"""Extract atomic claims from document text."""
claims = []
seen_texts = set()
for pattern, ctype in CLAIM_PATTERNS:
for match in pattern.finditer(text):
claim_text = match.group(0).strip()
if len(claim_text) < 15:
continue
# Deduplicate by normalized text
norm_text = re.sub(r'\s+', ' ', claim_text.lower())
if norm_text in seen_texts:
continue
seen_texts.add(norm_text)
entities = extract_entities(claim_text)
subjects = extract_subjects(claim_text)
values = extract_values(claim_text)
times = extract_times(claim_text)
claims.append(Claim(
text=claim_text,
claim_type=ctype,
source_doc=source_doc,
source_id=source_id,
entities=entities,
subjects=subjects,
values=values,
times=times,
span_start=match.start(),
span_end=match.end()
))
return claims
def claims_overlap(claim_a: Claim, claim_b: Claim) -> bool:
"""Check if two claims are about the same subject."""
# Check subject overlap (primary for cross-doc matching)
shared_subjects = set(claim_a.subjects) & set(claim_b.subjects)
if shared_subjects:
return True
# Also check entity overlap
shared_entities = set(claim_a.entities) & set(claim_b.entities)
if shared_entities:
return True
# Also check value overlap for numeric claims
if claim_a.values and claim_b.values:
shared_values = set(claim_a.values) & set(claim_b.values)
if shared_values:
return True
return False
def detect_contradiction(claim_a: Claim, claim_b: Claim) -> Optional[Contradiction]:
"""Detect if two claims about the same subject contradict."""
if not claims_overlap(claim_a, claim_b):
return None
text_a = claim_a.text.lower()
text_b = claim_b.text.lower()
# Direct negation patterns
negations = [
(r'\bis\b', r'\bis not\b|\bwas not\b|\bwill not be\b'),
(r'\bwas\b', r'\bwas not\b|\bis not\b'),
(r'\bhas\b', r'\bhas not\b|\bhave not\b'),
(r'\bcan\b', r'\bcannot\b|\bcan\'t\b'),
(r'\bwill\b', r'\bwill not\b|\bwon\'t\b'),
(r'\btrue\b', r'\bfalse\b'),
(r'\byes\b', r'\bno\b'),
]
for pos, neg in negations:
if re.search(pos, text_a) and re.search(neg, text_b):
return Contradiction(
claim_a=claim_a, claim_b=claim_b,
contradiction_type='direct',
severity='high',
explanation=f"Direct negation: '{claim_a.text[:80]}...' vs '{claim_b.text[:80]}...'"
)
if re.search(pos, text_b) and re.search(neg, text_a):
return Contradiction(
claim_a=claim_a, claim_b=claim_b,
contradiction_type='direct',
severity='high',
explanation=f"Direct negation: '{claim_a.text[:80]}...' vs '{claim_b.text[:80]}...'"
)
# Numeric contradiction (same subject, different numbers)
if claim_a.values and claim_b.values:
shared_vals = set(claim_a.values) & set(claim_b.values)
a_only = set(claim_a.values) - set(claim_b.values)
b_only = set(claim_b.values) - set(claim_a.values)
if a_only and b_only and not shared_vals:
return Contradiction(
claim_a=claim_a, claim_b=claim_b,
contradiction_type='numeric',
severity='high',
explanation=f"Conflicting values: {claim_a.values} vs {claim_b.values}"
)
# Time contradiction (same subject, different times)
if claim_a.times and claim_b.times:
shared_times = set(claim_a.times) & set(claim_b.times)
a_only = set(claim_a.times) - set(claim_b.times)
b_only = set(claim_b.times) - set(claim_a.times)
if a_only and b_only and not shared_times:
return Contradiction(
claim_a=claim_a, claim_b=claim_b,
contradiction_type='time',
severity='high',
explanation=f"Conflicting times: {claim_a.times} vs {claim_b.times}"
)
# Temporal contradiction (same subject, different dates)
years_a = [v for v in claim_a.values if re.match(r'^(?:19|20)\d{2}$', v)]
years_b = [v for v in claim_b.values if re.match(r'^(?:19|20)\d{2}$', v)]
if years_a and years_b:
if set(years_a) != set(years_b):
return Contradiction(
claim_a=claim_a, claim_b=claim_b,
contradiction_type='temporal',
severity='medium',
explanation=f"Conflicting dates: {years_a} vs {years_b}"
)
# Attribution contradiction (same quote, different sources)
if claim_a.claim_type == 'attribution' and claim_b.claim_type == 'attribution':
if claim_a.text == claim_b.text and claim_a.source_id != claim_b.source_id:
return Contradiction(
claim_a=claim_a, claim_b=claim_b,
contradiction_type='attribution',
severity='medium',
explanation=f"Same claim attributed to different sources: {claim_a.source_id} vs {claim_b.source_id}"
)
return None
def build_claim_graph(documents: Dict[str, str]) -> Dict:
"""Build claim graph from documents, return all claims and contradictions.
Args:
documents: {source_id: text} mapping
Returns:
{
'claims': [...],
'contradictions': [...],
'by_subject': {subject: [claim_ids]},
'by_entity': {entity: [claim_ids]},
'by_source': {source_id: [claim_ids]}
}
"""
all_claims = []
claim_id = 0
by_subject = defaultdict(list)
by_entity = defaultdict(list)
by_source = defaultdict(list)
for source_id, text in documents.items():
claims = extract_claims(text, source_id, source_id)
for claim in claims:
claim_dict = asdict(claim)
claim_dict['id'] = claim_id
all_claims.append(claim_dict)
for subject in claim.subjects:
by_subject[subject].append(claim_id)
for entity in claim.entities:
by_entity[entity].append(claim_id)
by_source[source_id].append(claim_id)
claim_id += 1
# Find contradictions
contradictions = []
for i, claim_a in enumerate(all_claims):
for j, claim_b in enumerate(all_claims[i+1:], i+1):
if claim_a['source_id'] == claim_b['source_id']:
continue # Same source, skip
contr = detect_contradiction(
Claim(**{k:v for k,v in claim_a.items() if k!='id'}),
Claim(**{k:v for k,v in claim_b.items() if k!='id'})
)
if contr:
c_dict = asdict(contr)
c_dict['claim_a_id'] = i
c_dict['claim_b_id'] = j
contradictions.append(c_dict)
return {
'claims': all_claims,
'contradictions': contradictions,
'by_subject': dict(by_subject),
'by_entity': dict(by_entity),
'by_source': dict(by_source),
'stats': {
'total_claims': len(all_claims),
'total_contradictions': len(contradictions),
'by_type': defaultdict(int, {c['contradiction_type']: 1 for c in contradictions})
}
}
def format_contradiction_card(contradiction: Dict, claims: List[Dict]) -> str:
"""Format a contradiction as a human-readable card for the TUI."""
a = claims[contradiction['claim_a_id']]
b = claims[contradiction['claim_b_id']]
severity_marker = {'high': 'πŸ”΄', 'medium': '🟑', 'low': '🟒'}.get(contradiction['severity'], 'βšͺ')
card = f"""
{severity_marker} CONTRADICTION [{contradiction['contradiction_type'].upper()}] {severity_marker}
────────────────────────────────────────
Source A ({a['source_id']}):
{a['text'][:120]}...
Source B ({b['source_id']}):
{b['text'][:120]}...
Explanation: {contradiction['explanation']}
Shared Subjects: {', '.join(set(a['subjects']) & set(b['subjects']))}
Shared Entities: {', '.join(set(a['entities']) & set(b['entities']))}
Values A: {a['values'] or 'none'}
Values B: {b['values'] or 'none'}
Times A: {a['times'] or 'none'}
Times B: {b['times'] or 'none'}
"""
return card
if __name__ == "__main__":
# Demo with test documents
test_docs = {
"doc_a": "The meeting started at 9am and ended at 11am. Official John Smith stated the budget was $50 million.",
"doc_b": "The meeting started at 9am and ran until noon. Witness Jane Doe said the budget was $75 million.",
"doc_c": "The 1993 incident file was deleted. Agency reports confirm the deletion occurred in 1995.",
}
graph = build_claim_graph(test_docs)
print(f"Claims extracted: {graph['stats']['total_claims']}")
print(f"Contradictions found: {graph['stats']['total_contradictions']}")
print(f"By type: {dict(graph['stats']['by_type'])}")
print()
for contr in graph['contradictions']:
print(format_contradiction_card(contr, graph['claims']))