hv-wall / hv_wall.py
zeechimp's picture
Upload hv_wall.py
284681c verified
Raw History Blame Contribute Delete
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
# ============================================================================
@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()