Download hv_wall.py from zeechimp/hv-wall: direct link, hf CLI and curl.
- Browser
- Download file 43.6 kB
-
https://huggingface.co/zeechimp/hv-wall/resolve/main/hv_wall.py
- Command line
-
hf download hf://zeechimp/hv-wall/hv_wall.py
-
curl -L -o hv_wall.py https://huggingface.co/zeechimp/hv-wall/resolve/main/hv_wall.py
43.6 kB
| """ | |
| 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 | |
| # ============================================================================ | |
| 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 | |
| # ============================================================================ | |
| 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), | |
| } | |
| 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 | |
| # ------------------------------------------------------------------ | |
| 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 | |
| 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, | |
| ) | |
| 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" | |
| 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) | |
| 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) | |
| 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() |