""" hv-wall ======= Where the reader stops believing a text. Not from difficulty. From a claim that outruns its evidence, a claim that contradicts an earlier one, or a conclusion that no prior sentence supports. Given a text, output a WallReport: the breaking point, the type of wall, a per-sentence trust curve, and reasons. Pure stdlib. No dependencies. Author: zeechimp License: Apache-2.0 """ from __future__ import annotations import argparse import json import math import os import re import sys from collections import Counter from dataclasses import dataclass, asdict, field from typing import Any, Dict, List, Optional, Set, Tuple # ============================================================================ # Lexicons # ============================================================================ CERTAINTY_MARKERS = frozenset(""" definitely definitively certainly obviously clearly undoubtedly unquestionably absolutely surely plainly evidently undeniably unmistakably decidedly categorically conclusively decisively resolutely proves proven prove proved proof impossible must always never guaranteed """.split()) HEDGE_MARKERS = frozenset(""" might may possibly perhaps appears appear seems seem suggests suggest likely unlikely roughly approximately around about somewhat rather tends tend typically generally often sometimes occasionally apparently presumably arguably conceivably potentially reportedly allegedly uncertain unsure unclear """.split()) CONCLUSION_MARKERS = ( "therefore", "thus", "hence", "consequently", "accordingly", "it follows that", "we conclude", "we can conclude", "this shows", "this demonstrates", "this proves", "in conclusion", "as a result", ) CONTRAST_MARKERS = ( "however", "but", "yet", "although", "though", "nevertheless", "nonetheless", "conversely", "on the contrary", "in contrast", "on the other hand", "by contrast", "notwithstanding", "despite this", "even so", ) EVIDENCE_MARKERS = ( "according to", "studies show", "studies suggest", "research shows", "research suggests", "data show", "data suggest", "evidence indicates", "we measured", "we observed", "we found", "for example", "for instance", "specifically", "namely", "for one", "in fact", "as measured", ) DIRECTIONAL_ANTONYMS = { "increase": "decrease", "increases": "decreases", "increased": "decreased", "increasing": "decreasing", "improve": "worsen", "improves": "worsens", "improved": "worsened", "improving": "worsening", "reduce": "raise", "reduces": "raises", "reduced": "raised", "reducing": "raising", "cause": "prevent", "causes": "prevents", "caused": "prevented", "causing": "preventing", "support": "oppose", "supports": "opposes", "supported": "opposed", "supporting": "opposing", "confirm": "deny", "confirms": "denies", "confirmed": "denied", "confirming": "denying", "safe": "unsafe", "effective": "ineffective", "beneficial": "harmful", "positive": "negative", "valid": "invalid", "significant": "insignificant", "true": "false", "possible": "impossible", "before": "after", "accept": "reject", "accepts": "rejects", "accepted": "rejected", "requires": "forbids", "allows": "prohibits", "agree": "disagree", "agrees": "disagrees", "rise": "fall", "rises": "falls", "rose": "fell", "high": "low", "higher": "lower", "highest": "lowest", "more": "less", "most": "least", "certain": "uncertain", "clear": "unclear", } # Direction groups. Two words from opposite groups, applied to the same # topic, is a semantic contradiction even if neither is the other's # literal antonym. "reduces employment" vs "increases employment" is # caught here where the literal table fails. DIRECTION_GROUPS = { "up": frozenset(""" increase increases increased increasing rise rises rose risen grow grows grew grown growth expand expands expanded expansion raise raises raised raising improve improves improved improving gain gains gained gaining positive higher highest more most upward up climb climbs climbed climbing """.split()), "down": frozenset(""" decrease decreases decreased decreasing fall falls fell fallen shrink shrinks shrank shrunk contract contracts contracted lower lowers lowered lowering reduce reduces reduced reducing worsen worsens worsened worsening lose loses lost losing negative lower lowest less least downward down decline declines declined declining drop drops dropped dropping diminish """.split()), "cause": frozenset(""" cause causes caused causing produce produces produced producing create creates created creating induce induces induced trigger triggers triggered triggering generate """.split()), "prevent": frozenset(""" prevent prevents prevented preventing avoid avoids avoided block blocks blocked blocking inhibit inhibits inhibited prohibit prohibits prohibited prohibiting stop stops stopped """.split()), "support": frozenset(""" support supports supported supporting confirm confirms confirmed agree agrees agreed approve approves approved accept accepts accepted affirm affirms affirmed """.split()), "oppose": frozenset(""" oppose opposes opposed opposing deny denies denied contradict contradicts contradicted disagree disagrees disagreed reject rejects rejected refuse refuses refused """.split()), } GROUP_OPPOSITES = frozenset([ ("up", "down"), ("down", "up"), ("cause", "prevent"), ("prevent", "cause"), ("support", "oppose"), ("oppose", "support"), ]) DISCOURSE_MARKERS = frozenset(""" therefore thus hence consequently accordingly however but yet although though nevertheless nonetheless conversely meanwhile similarly moreover furthermore additionally """.split()) COMMON_WORDS = frozenset(""" the be to of and a in that have i it for not on with he as you do at this but his by from they we say her she or an will my one all would there their what so up out if about who get which go me when make can like time no just him know take people into year your good some could them see other than then now look only come its over think also back after use two how our work first well way even new want because any these give day most us is are was were been being has had having does did doing will would shall should can could may might must man woman child water fire earth air sun moon star light dark hand head eye ear mouth nose foot leg arm body face heart mind life death food bread milk meat fish tree flower grass leaf root seed farm field hill mountain river sea lake boat ship road street city town house room door window wall floor roof bed chair table book page word line letter number name place thing part side end start middle top bottom front back left right high low long short big small old new young hot cold wet dry clean dirty light heavy soft hard fast slow easy true false good bad happy sad love hate fear hope help hurt win lose give take send bring buy sell pay cost money price work play run walk jump sit stand sleep wake eat drink cook wash read write speak hear see feel know think learn teach ask answer tell show hide open close push pull carry hold drop throw catch break fix build make do try use move turn stop start keep leave stay wait meet join save spend show thank want wish walk stop continue morning climb row push pull begin finish start end remain rest return arrive depart leave enter exit follow lead sit stand lie rise fall drop still quiet calm slow fast soft loud bright dark warm cool fresh clean never always often sometimes rarely usually speak spoke spoken take took taken give gave given see saw seen know knew known think thought thought come came come go went gone say said said tell told told find found found hold held held bring brought brought buy bought bought teach taught taught catch caught caught build built built send sent sent spend spent lose lost lost lead led led meet met met read read read write wrote written run ran run swim swam swum thing things word words time times year years day days man men woman women child children person people place places work works way ways life lives hand hands eye eyes part parts end ends line lines side sides name names head heads house houses friend friends family families group groups country countries world worlds city cities school schools """.split()) # ============================================================================ # Tokenization # ============================================================================ _WORD_RE = re.compile(r"[A-Za-z][A-Za-z'\-]*") _SENT_SPLIT_RE = re.compile(r"(?<=[.!?])\s+(?=[A-Z\"'(])") _NUMBER_RE = re.compile(r"\b\d+(?:[.,]\d+)*\b") def _sentences(text: str) -> List[str]: return [s.strip() for s in _SENT_SPLIT_RE.split(text) if s.strip()] def _words(text: str) -> List[str]: return _WORD_RE.findall(text) def _content_words(text: str) -> List[str]: return [w.lower() for w in _words(text) if w.lower() not in COMMON_WORDS and w.lower() not in DISCOURSE_MARKERS and len(w) >= 3] def _stem(w: str) -> str: w = w.lower() if len(w) <= 4: return w for suffix in ("ingly", "edly", "ing", "ed", "ly", "es", "s"): if w.endswith(suffix) and len(w) - len(suffix) >= 3: base = w[: -len(suffix)] if len(base) >= 2 and base[-1] == base[-2] and base[-1] not in "aeiou": base = base[:-1] return base return w def _has_conclusion_marker(text: str) -> Tuple[bool, int]: low = text.lower().strip() for m in CONCLUSION_MARKERS: if low.startswith(m): return True, len(m) for m in CONCLUSION_MARKERS: if f" {m} " in f" {low} ": return True, len(m) return False, 0 def _has_contrast_marker(text: str) -> Tuple[bool, str]: low = text.lower().strip() for m in CONTRAST_MARKERS: if low.startswith(m): return True, m if f" {m} " in f" {low} ": return True, m return False, "" def _has_evidence_marker(text: str) -> bool: low = text.lower() return any(m in low for m in EVIDENCE_MARKERS) # ============================================================================ # Config # ============================================================================ @dataclass class HVWallConfig: # Detection wall_threshold: float = 0.35 certainty_scale: float = 6.0 hedge_scale: float = 5.0 contradiction_scale: float = 0.4 specificity_scale: float = 5.0 # Gap formula gap_confidence_jump_weight: float = 0.5 # Contrast bonus contrast_bonus: float = 0.15 # Type thresholds contradiction_min: float = 0.30 overclaim_min: float = 0.30 gap_min: float = 0.25 # Reporting min_sentence_words: int = 3 top_reasons: int = 4 version: str = "0.1.2" # ============================================================================ # Report dataclasses # ============================================================================ @dataclass class SentenceProfile: id: int text: str span: Tuple[int, int] n_words: int confidence: float evidence: float specificity: float hedge: float conclusion: float overclaim: float contradiction: float gap: float contrast: float wall: float wall_type: str trust: float reasons: List[str] = field(default_factory=list) def to_dict(self) -> dict: return { "id": self.id, "text": self.text, "span": list(self.span), "n_words": self.n_words, "confidence": round(self.confidence, 4), "evidence": round(self.evidence, 4), "specificity": round(self.specificity, 4), "hedge": round(self.hedge, 4), "conclusion": round(self.conclusion, 4), "overclaim": round(self.overclaim, 4), "contradiction": round(self.contradiction, 4), "gap": round(self.gap, 4), "contrast": round(self.contrast, 4), "wall": round(self.wall, 4), "wall_type": self.wall_type, "trust": round(self.trust, 4), "reasons": list(self.reasons), } @dataclass class WallReport: text: str n_sentences: int n_words: int wall_id: Optional[int] wall_type: str wall_score: float wall_span: Optional[Tuple[int, int]] wall_text: str confidence_trajectory: List[float] evidence_trajectory: List[float] trust_curve: List[float] profiles: List[SentenceProfile] confidence_style: str evidence_style: str summary: str def to_dict(self) -> dict: return { "text": self.text, "n_sentences": self.n_sentences, "n_words": self.n_words, "wall_id": self.wall_id, "wall_type": self.wall_type, "wall_score": round(self.wall_score, 4), "wall_span": list(self.wall_span) if self.wall_span else None, "wall_text": self.wall_text, "confidence_trajectory": [round(v, 4) for v in self.confidence_trajectory], "evidence_trajectory": [round(v, 4) for v in self.evidence_trajectory], "trust_curve": [round(v, 4) for v in self.trust_curve], "confidence_style": self.confidence_style, "evidence_style": self.evidence_style, "summary": self.summary, "profiles": [p.to_dict() for p in self.profiles], } # ============================================================================ # The model # ============================================================================ class HVWall: """Predicts where a reader stops believing a text.""" def __init__(self, config: Optional[HVWallConfig] = None): self.config = config or HVWallConfig() self._obs = 0 def __repr__(self) -> str: return ( f"HVWall(threshold={self.config.wall_threshold}, " f"certainty_scale={self.config.certainty_scale}, " f"version={self.config.version})" ) # ------------------------------------------------------------------ # Feature extraction # ------------------------------------------------------------------ def _confidence(self, sentence: str) -> Tuple[float, int, int]: words = [w.lower() for w in _words(sentence)] n = max(1, len(words)) cert = sum(1 for w in words if w in CERTAINTY_MARKERS) hedg = sum(1 for w in words if w in HEDGE_MARKERS) c_rate = cert / n h_rate = hedg / n conf = min(1.0, self.config.certainty_scale * c_rate) hedge = min(1.0, self.config.hedge_scale * h_rate) net = max(0.0, conf - 0.5 * hedge) return net, cert, hedg def _evidence(self, sentence: str) -> Tuple[float, float, bool]: words = _words(sentence) content = _content_words(sentence) n_content = max(1, len(content)) n_digits = len(_NUMBER_RE.findall(sentence)) n_proper = sum( 1 for i, w in enumerate(words) if w[0].isupper() and i > 0 and w.lower() not in COMMON_WORDS and w.lower() not in DISCOURSE_MARKERS and len(w) >= 3 ) n_rare = sum(1 for w in content if len(w) >= 8) spec = ( 0.5 * (n_digits / n_content) + 0.3 * (n_proper / n_content) + 0.2 * (n_rare / n_content) ) spec = min(1.0, self.config.specificity_scale * spec) attribution = _has_evidence_marker(sentence) evidence = 0.6 * spec + 0.4 * (1.0 if attribution else 0.0) return evidence, spec, attribution # ------------------------------------------------------------------ # Direction detection # ------------------------------------------------------------------ @staticmethod def _direction_of(word: str) -> List[str]: w = word.lower() sw = _stem(w) out: List[str] = [] for g, words in DIRECTION_GROUPS.items(): if w in words or sw in words: out.append(g) return out def _direction_pairs(self, sentence: str) -> List[Tuple[str, str, str]]: words = [w.lower() for w in _words(sentence)] out: List[Tuple[str, str, str]] = [] for i, w in enumerate(words): groups = self._direction_of(w) if not groups: continue j = self._closest_topic(words, i) if j is None: continue cand = words[j] if cand in COMMON_WORDS or len(cand) < 4: continue topic = _stem(cand) for g in groups: out.append((topic, g, w)) return out @staticmethod def _closest_topic(words: List[str], idx: int) -> Optional[int]: best = None best_key: Tuple[int, int] = (10, 1) for j in range(max(0, idx - 3), min(len(words), idx + 4)): if j == idx: continue cand = words[j] if cand in COMMON_WORDS or len(cand) < 4: continue if HVWall._direction_of(cand): continue if cand in DISCOURSE_MARKERS: continue after = 0 if j > idx else 1 key = (abs(j - idx), after) if key < best_key: best_key = key best = j return best # ------------------------------------------------------------------ # Contradiction detection # ------------------------------------------------------------------ def _contradiction( self, sentence: str, prior_sentences: List[str], ) -> Tuple[float, Optional[int], List[Tuple[str, str]]]: content = _content_words(sentence) if not content: return 0.0, None, [] prior_stems: Dict[str, int] = {} for idx, p in enumerate(prior_sentences): for w in _content_words(p): prior_stems.setdefault(_stem(w), idx) pairs: List[Tuple[str, str]] = [] contradiction_indices: List[int] = [] for w in content: sw = _stem(w) opp = DIRECTIONAL_ANTONYMS.get(sw) or DIRECTIONAL_ANTONYMS.get(w) if opp is None: continue opp_stem = _stem(opp) if opp_stem in prior_stems: pairs.append((sw, opp)) contradiction_indices.append(prior_stems[opp_stem]) curr_pairs = self._direction_pairs(sentence) prior_dirs: Dict[str, List[Tuple[int, str, str]]] = {} for idx, p in enumerate(prior_sentences): for topic, g, src in self._direction_pairs(p): prior_dirs.setdefault(topic, []).append((idx, g, src)) for topic, curr_g, curr_src in curr_pairs: for prior_idx, prior_g, prior_src in prior_dirs.get(topic, []): if (prior_g, curr_g) in GROUP_OPPOSITES: label = f"{topic}:{prior_src}↔{curr_src}" pairs.append((label, f"({prior_g} vs {curr_g})")) contradiction_indices.append(prior_idx) if not pairs: return 0.0, None, [] score = min(1.0, self.config.contradiction_scale * math.sqrt(len(pairs))) contradicting_idx = Counter(contradiction_indices).most_common(1)[0][0] return score, contradicting_idx, pairs # ------------------------------------------------------------------ # Gap # ------------------------------------------------------------------ def _gap( self, sentence: str, confidence: float, prior_evidence: List[float], prior_confidences: List[float], ) -> Tuple[float, bool]: has_marker, _ = _has_conclusion_marker(sentence) if not has_marker or not prior_evidence: return 0.0, has_marker prior_ev_mean = sum(prior_evidence) / len(prior_evidence) evidence_deficit = max(0.0, confidence - prior_ev_mean) prior_conf_mean = ( sum(prior_confidences) / len(prior_confidences) if prior_confidences else 0.0 ) confidence_jump = max(0.0, confidence - prior_conf_mean) gap = evidence_deficit + self.config.gap_confidence_jump_weight * confidence_jump return gap, True # ------------------------------------------------------------------ # Wall classification (priority-based) # ------------------------------------------------------------------ def _wall_type( self, overclaim: float, contradiction: float, gap: float ) -> Tuple[float, str]: """ Wall type by priority, not by raw max. Contradiction dominates: the text disagrees with itself. Overclaim beats gap: a certain claim is a stronger reader-stop than a conclusion marker alone. Gap fires only when neither contradiction nor overclaim is strong. """ cfg = self.config if contradiction >= cfg.contradiction_min: return contradiction, "contradiction" if overclaim >= cfg.overclaim_min: return overclaim, "overclaim" if gap >= cfg.gap_min: return gap, "gap" best = max(overclaim, contradiction, gap) if best <= 0.0: return 0.0, "neutral" if best == contradiction: return best, "contradiction" if best == overclaim: return best, "overclaim" return best, "gap" # ------------------------------------------------------------------ # Reasons # ------------------------------------------------------------------ def _reasons( self, sentence: str, confidence: float, cert_count: int, hedge_count: int, evidence: float, specificity: float, attribution: bool, overclaim: float, contradiction: float, contradiction_idx: Optional[int], contradiction_pairs: List[Tuple[str, str]], gap: float, has_conclusion: bool, has_contrast: bool, contrast_word: str, prior_confidences: List[float], prior_evidence: List[float], ) -> List[str]: reasons: List[str] = [] cfg = self.config # Determine the primary signal, matching _wall_type. primary: Optional[str] = None if contradiction >= cfg.contradiction_min: primary = "contradiction" elif overclaim >= cfg.overclaim_min: primary = "overclaim" elif gap >= cfg.gap_min: primary = "gap" if contradiction > 0.05 and (primary == "contradiction" or primary is None): for a, b in contradiction_pairs[:3]: if contradiction_idx is not None: reasons.append(f"conflict with sentence {contradiction_idx}: {a}") else: reasons.append(f"conflict: {a}") if overclaim > 0.05 and (primary == "overclaim" or primary is None): if cert_count: matched = [ w.lower() for w in _words(sentence) if w.lower() in CERTAINTY_MARKERS ] reasons.append( f"certainty markers: {', '.join(repr(m) for m in matched[:3])}" ) reasons.append( f"specificity {specificity:.2f} (evidence = {evidence:.2f})" ) if not attribution: reasons.append("no attribution marker") if prior_confidences: mean_prior = sum(prior_confidences) / len(prior_confidences) jump = confidence - mean_prior if jump > 0.2: reasons.append( f"confidence jumps {jump:.2f} above prior mean " f"({mean_prior:.2f})" ) if prior_evidence: mean_prior_ev = sum(prior_evidence) / len(prior_evidence) if evidence < mean_prior_ev: reasons.append( f"evidence drops below prior mean " f"({mean_prior_ev:.2f} -> {evidence:.2f})" ) if gap > 0.05 and (primary == "gap" or primary is None): if has_conclusion: reasons.append("conclusion marker with insufficient prior evidence") if prior_evidence: mean_prior_ev = sum(prior_evidence) / len(prior_evidence) reasons.append( f"prior evidence mean {mean_prior_ev:.2f}, " f"conclusion confidence {confidence:.2f}" ) if has_contrast and (overclaim > 0.05 or contradiction > 0.05 or gap > 0.05): reasons.append(f"contrast marker {contrast_word!r}") if not reasons: if hedge_count: reasons.append(f"hedge markers: {hedge_count}") elif confidence < 0.1: reasons.append("no strong assertion") return reasons[: self.config.top_reasons] # ------------------------------------------------------------------ # Full analysis # ------------------------------------------------------------------ def analyze(self, text: str) -> WallReport: if not text or not text.strip(): return self._empty_report(text) sentences = _sentences(text) n = len(sentences) spans: List[Tuple[int, int]] = [] cursor = 0 for s in sentences: i = text.find(s, cursor) if i < 0: i = cursor spans.append((i, i + len(s))) cursor = i + len(s) prior_sentences: List[str] = [] prior_confidences: List[float] = [] prior_evidence: List[float] = [] profiles: List[SentenceProfile] = [] trust = 1.0 for i, s in enumerate(sentences): n_words = len(_words(s)) confidence, cert_count, hedge_count = self._confidence(s) hedge_score = min( 1.0, self.config.hedge_scale * hedge_count / max(1, n_words) ) evidence, specificity, attribution = self._evidence(s) has_conclusion, _ = _has_conclusion_marker(s) has_contrast, contrast_word = _has_contrast_marker(s) conclusion_strength = confidence if has_conclusion else 0.0 overclaim = ( max(0.0, confidence - evidence) if n_words >= self.config.min_sentence_words else 0.0 ) contradiction, contradiction_idx, contradiction_pairs = ( self._contradiction(s, prior_sentences) if prior_sentences else (0.0, None, []) ) gap, _ = self._gap(s, confidence, prior_evidence, prior_confidences) wall, wall_type = self._wall_type(overclaim, contradiction, gap) # Cap wall at 1.0 before any bonus wall = min(1.0, wall) if has_contrast and wall > 0.05: wall = min(1.0, wall + self.config.contrast_bonus) if i == 0: trust = 1.0 else: trust = trust * math.exp(-wall) reasons = self._reasons( sentence=s, confidence=confidence, cert_count=cert_count, hedge_count=hedge_count, evidence=evidence, specificity=specificity, attribution=attribution, overclaim=overclaim, contradiction=contradiction, contradiction_idx=contradiction_idx, contradiction_pairs=contradiction_pairs, gap=gap, has_conclusion=has_conclusion, has_contrast=has_contrast, contrast_word=contrast_word, prior_confidences=prior_confidences, prior_evidence=prior_evidence, ) profiles.append(SentenceProfile( id=i, text=s, span=spans[i], n_words=n_words, confidence=confidence, evidence=evidence, specificity=specificity, hedge=hedge_score, conclusion=conclusion_strength, overclaim=overclaim, contradiction=contradiction, gap=gap, contrast=1.0 if has_contrast else 0.0, wall=wall, wall_type=wall_type if wall > 0.0 else "neutral", trust=trust, reasons=reasons, )) prior_sentences.append(s) prior_confidences.append(confidence) prior_evidence.append(evidence) wall_ids = [p.id for p in profiles if p.wall >= self.config.wall_threshold] if wall_ids: wall_id = max(wall_ids, key=lambda i: profiles[i].wall) elif profiles: wall_id = max(range(n), key=lambda i: profiles[i].wall) if profiles[wall_id].wall < 0.15: wall_id = None else: wall_id = None if wall_id is not None: wp = profiles[wall_id] wall_type = wp.wall_type wall_score = wp.wall wall_span: Optional[Tuple[int, int]] = wp.span wall_text = wp.text else: wall_type = "neutral" wall_score = 0.0 wall_span = None wall_text = "" conf_traj = [p.confidence for p in profiles] evid_traj = [p.evidence for p in profiles] trust_curve = [p.trust for p in profiles] conf_style = self._trajectory_style(conf_traj) evid_style = self._trajectory_style(evid_traj) summary = self._summary(wall_id, wall_type, wall_score, profiles) self._obs += 1 return WallReport( text=text, n_sentences=n, n_words=sum(p.n_words for p in profiles), wall_id=wall_id, wall_type=wall_type, wall_score=wall_score, wall_span=wall_span, wall_text=wall_text, confidence_trajectory=conf_traj, evidence_trajectory=evid_traj, trust_curve=trust_curve, profiles=profiles, confidence_style=conf_style, evidence_style=evid_style, summary=summary, ) @staticmethod def _trajectory_style(values: List[float]) -> str: if len(values) < 2: return "flat" mean = sum(values) / len(values) var = sum((v - mean) ** 2 for v in values) / len(values) std = math.sqrt(var) if mean <= 1e-6: return "flat" spread = std / mean first, last = values[0], values[-1] delta = last - first if spread < 0.15: return "flat" if spread > 0.75: return "volatile" if delta > 0.15: return "rising" if delta < -0.15: return "falling" peak_idx = max(range(len(values)), key=lambda i: values[i]) if 0 < peak_idx < len(values) - 1: return "peaked" return "flat" @staticmethod def _summary( wall_id: Optional[int], wall_type: str, wall_score: float, profiles: List[SentenceProfile], ) -> str: if wall_id is None: return ( "No wall detected. The text maintains consistent " "confidence and evidence throughout." ) excerpt = profiles[wall_id].text if len(excerpt) > 60: excerpt = excerpt[:57] + "..." if wall_type == "overclaim": return ( f"Reader stops at sentence {wall_id}: " f"overclaim — a claim is asserted with " f"more confidence than its evidence supports. " f"\"{excerpt}\"" ) if wall_type == "contradiction": return ( f"Reader stops at sentence {wall_id}: " f"contradiction — the sentence contradicts an earlier claim. " f"\"{excerpt}\"" ) if wall_type == "gap": return ( f"Reader stops at sentence {wall_id}: " f"gap — a conclusion is drawn that prior evidence does not support. " f"\"{excerpt}\"" ) return f"Reader stops at sentence {wall_id}." def _empty_report(self, text: str) -> WallReport: return WallReport( text=text, n_sentences=0, n_words=0, wall_id=None, wall_type="neutral", wall_score=0.0, wall_span=None, wall_text="", confidence_trajectory=[], evidence_trajectory=[], trust_curve=[], profiles=[], confidence_style="flat", evidence_style="flat", summary="Empty text.", ) # ------------------------------------------------------------------ # Rendering # ------------------------------------------------------------------ def render(self, report: WallReport) -> str: lines: List[str] = [] bar = "=" * 72 lines.append(bar) lines.append("hv-wall — where the reader stops believing") lines.append(bar) lines.append("") lines.append( f" text : {report.n_sentences} sentences, " f"{report.n_words} words" ) if report.wall_id is not None: lines.append(f" wall at : sentence {report.wall_id}") lines.append(f" wall type : {report.wall_type}") lines.append(f" wall score : {report.wall_score:.3f}") else: lines.append(" wall : none detected") lines.append(f" conf style : {report.confidence_style}") lines.append(f" evid style : {report.evidence_style}") lines.append("") if not report.profiles: lines.append(" (no content)") return "\n".join(lines) lines.append(" WALL PROFILE") lines.append(" " + "-" * 68) lines.append( f" {'id':>3} {'wall':>5} {'type':<14} " f"{'conf':>5} {'evid':>5} {'trust':>5} excerpt" ) for p in report.profiles: marker = "*" if p.id == report.wall_id else " " excerpt = self._shorten(p.text, 30) lines.append( f" {marker}{p.id:>2} {p.wall:>5.2f} " f"{p.wall_type:<14} {p.confidence:>5.2f} " f"{p.evidence:>5.2f} {p.trust:>5.2f} {excerpt}" ) lines.append("") lines.append(" TRAJECTORIES") lines.append(" " + "-" * 68) lines.append( " conf : " + " -> ".join(f"{v:.2f}" for v in report.confidence_trajectory) ) lines.append( " evid : " + " -> ".join(f"{v:.2f}" for v in report.evidence_trajectory) ) lines.append( " trust : " + " -> ".join(f"{v:.2f}" for v in report.trust_curve) ) below = [i for i, v in enumerate(report.trust_curve) if v < 0.5] if below: lines.append(f" trust falls below 0.5 at sentence {below[0]}") lines.append("") wall_reasons = [ p for p in report.profiles if p.reasons and p.wall >= max(0.15, report.wall_score - 0.1) ] if wall_reasons: lines.append(" WALL REASONS") lines.append(" " + "-" * 68) for p in wall_reasons[:3]: lines.append( f" [{p.id}] wall_type={p.wall_type}, " f"score={p.wall:.2f}" ) for r in p.reasons: lines.append(f" - {r}") lines.append("") lines.append(" SUMMARY") lines.append(" " + "-" * 68) lines.append(f" {report.summary}") lines.append("") return "\n".join(lines) @staticmethod def _shorten(s: str, n: int) -> str: s = s.strip().replace("\n", " ") if len(s) <= n: return s return s[: n - 1].rsplit(" ", 1)[0] + "…" # ------------------------------------------------------------------ # Persistence # ------------------------------------------------------------------ def save_pretrained(self, save_dir: str) -> None: os.makedirs(save_dir, exist_ok=True) payload = { "config": asdict(self.config), "observations": self._obs, } with open(os.path.join(save_dir, "config.json"), "w") as f: json.dump(payload, f, indent=2) @classmethod def from_pretrained(cls, save_dir: str) -> "HVWall": with open(os.path.join(save_dir, "config.json"), "r") as f: payload = json.load(f) cfg = HVWallConfig(**payload.get("config", {})) obj = cls(config=cfg) obj._obs = int(payload.get("observations", 0)) return obj # ============================================================================ # Demo # ============================================================================ SAMPLE_OVERCLAIM = ( "The data suggests some correlation between the policy and the outcome. " "Results appear to indicate a modest effect in some subpopulations. " "The mechanism remains unclear, and further work is needed to establish " "causality. " "Therefore, the policy definitively causes the outcome in all cases, " "and this is unquestionably proven by the evidence." ) SAMPLE_CONTRADICTION = ( "Increasing the minimum wage reduces employment among low-skill workers. " "This effect has been documented consistently across regions. " "However, the empirical literature on this subject is mixed. " "Therefore, increasing the minimum wage actually increases employment " "among low-skill workers, and no disemployment effects have ever been found." ) SAMPLE_GAP = ( "The company reported revenue growth of 12 percent last quarter. " "Margins expanded by 3 percentage points. " "Customer retention improved to 94 percent. " "The new product line was launched in three regions. " "Therefore, the stock is guaranteed to rise 50 percent next year." ) SAMPLE_CLEAN = ( "According to the 2024 industry report, revenue grew 12 percent. " "The company's own filings show margin expansion of 3 points. " "Customer retention data indicate a stable 94 percent. " "These numbers suggest moderate growth rather than a step change. " "Analysts expect continued performance in line with these figures." ) def _demo(output_dir: str = "./hv_wall_output") -> None: os.makedirs(output_dir, exist_ok=True) m = HVWall() samples = [ ("Overclaim", SAMPLE_OVERCLAIM), ("Contradiction", SAMPLE_CONTRADICTION), ("Gap", SAMPLE_GAP), ("Clean", SAMPLE_CLEAN), ] for name, text in samples: print() print("#" * 72) print(f"# {name}") print("#" * 72) print(m.render(m.analyze(text))) print() print("=" * 72) print("Summary across samples") print("=" * 72) print( f" {'sample':<16} {'sents':>6} {'wall':>5} " f"{'type':<14} {'score':>5}" ) print(" " + "-" * 60) for name, text in samples: r = m.analyze(text) wall_id = r.wall_id if r.wall_id is not None else -1 print( f" {name:<16} {r.n_sentences:>6} {wall_id:>5} " f"{r.wall_type:<14} {r.wall_score:>5.2f}" ) print() print("=" * 72) print("Save / load round trip") print("=" * 72) save_path = os.path.join(output_dir, "hv_wall_model") m.save_pretrained(save_path) m2 = HVWall.from_pretrained(save_path) print(f" saved to : {save_path}") print(f" reloaded : {m2!r}") a = m.analyze(SAMPLE_OVERCLAIM).wall_score b = m2.analyze(SAMPLE_OVERCLAIM).wall_score print(f" wall score: {a:.4f}") print(f" reloaded : {b:.4f}") print(f" identical : {abs(a - b) < 1e-9}") print() # ============================================================================ # CLI # ============================================================================ def _cli() -> None: p = argparse.ArgumentParser( description="hv-wall: where the reader stops believing." ) p.add_argument("--text", type=str, default="", help="text to analyze (or '-' to read stdin)") p.add_argument("--json", action="store_true", help="output JSON instead of a rendered report") p.add_argument("--wall", action="store_true", help="print only the wall sentence and score") p.add_argument("--save-to", type=str, default="", help="save the model to this directory") p.add_argument("--outdir", type=str, default="./hv_wall_output") args = p.parse_args() text = sys.stdin.read() if args.text == "-" else args.text m = HVWall() if args.save_to: m.save_pretrained(args.save_to) print(f"saved to {args.save_to}", file=sys.stderr) if not text: _demo(args.outdir) return report = m.analyze(text) if args.wall: if report.wall_id is None: print("none") else: print(f"{report.wall_id}\t{report.wall_type}\t" f"{report.wall_score:.3f}\t{report.wall_text}") return if args.json: print(json.dumps(report.to_dict(), indent=2, ensure_ascii=False)) else: print(m.render(report)) if __name__ == "__main__": _cli()