Skip to content

Commit c9e458c

Browse files
Reyaansh SinhaReyaansh Sinha
authored andcommitted
Reconstruct optimized verifier stack
1 parent 9f1584c commit c9e458c

28 files changed

Lines changed: 1441 additions & 107 deletions

Halgorithem/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
from .core import Halgorithm
2+
from .main import HalgorithemVerifier, verify, verify_urls
23

3-
__all__ = ["Halgorithm"]
4+
__all__ = ["Halgorithm", "HalgorithemVerifier", "verify", "verify_urls"]

Halgorithem/checks/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Higher-level verification checks for Halgorithem."""

Halgorithem/checks/atomic.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import re
2+
from functools import lru_cache
3+
4+
from ..claim_extraction import split_atomic_claims
5+
from ..models import AtomicCheck, AtomicClaim
6+
7+
8+
@lru_cache(maxsize=8192)
9+
def _tokens(text):
10+
return frozenset(t.lower() for t in re.findall(r"\b[a-zA-Z][a-zA-Z'-]+\b", text or "") if len(t) > 2)
11+
12+
13+
def _overlap(left, right):
14+
if not left:
15+
return 0.0
16+
return len(left & right) / len(left)
17+
18+
19+
def _priority(verdict):
20+
return {"CONTRADICT": 3, "ENTAIL": 2, "NEUTRAL": 1}.get(verdict, 0)
21+
22+
23+
def _score_claim(verdict, confidence):
24+
if verdict == "ENTAIL":
25+
return confidence
26+
if verdict == "CONTRADICT":
27+
return -confidence
28+
return 0.5 * confidence
29+
30+
31+
def prepare_document_claims(document):
32+
doc_claims = []
33+
for sentence in document:
34+
for claim in split_atomic_claims(sentence.resolved_text) or [sentence.resolved_text]:
35+
doc_claims.append((claim, _tokens(claim)))
36+
return doc_claims
37+
38+
39+
def atomic_claim_nli(processed_sentence, document, nli_model=None, doc_claims=None):
40+
ai_text = getattr(processed_sentence, "resolved_text", processed_sentence)
41+
ai_claims = split_atomic_claims(ai_text) or [ai_text]
42+
doc_claims = doc_claims if doc_claims is not None else prepare_document_claims(document)
43+
44+
if not doc_claims:
45+
return AtomicCheck(status="no_document_claims")
46+
47+
matched = []
48+
for claim in ai_claims:
49+
claim_tokens = _tokens(claim)
50+
best_claim, best_tokens = max(doc_claims, key=lambda pair: _overlap(claim_tokens, pair[1]))
51+
matched.append((claim, best_claim, best_tokens))
52+
53+
if nli_model is not None:
54+
nli_results = nli_model.predict_batch(
55+
[best_claim for _, best_claim, _ in matched],
56+
[claim for claim, _, _ in matched],
57+
)
58+
else:
59+
nli_results = [None] * len(matched)
60+
61+
results = []
62+
for (claim, best_claim, best_tokens), nli in zip(matched, nli_results):
63+
claim_tokens = _tokens(claim)
64+
if not claim_tokens or not best_tokens:
65+
results.append(AtomicClaim(claim=claim, verdict="NEUTRAL", confidence=0.0, evidence=""))
66+
continue
67+
if nli is None:
68+
overlap = _overlap(claim_tokens, best_tokens)
69+
verdict = "ENTAIL" if overlap >= 0.70 else "NEUTRAL"
70+
confidence = overlap if verdict == "ENTAIL" else 0.50
71+
elif nli.label == "CONTRADICTION":
72+
verdict = "CONTRADICT"
73+
confidence = nli.score
74+
elif nli.label == "ENTAILMENT":
75+
verdict = "ENTAIL"
76+
confidence = nli.score
77+
else:
78+
verdict = "NEUTRAL"
79+
confidence = nli.score
80+
results.append(AtomicClaim(claim=claim, verdict=verdict, confidence=confidence, evidence=best_claim))
81+
82+
entail = sum(1 for c in results if c.verdict == "ENTAIL")
83+
contradict = sum(1 for c in results if c.verdict == "CONTRADICT")
84+
total = len(results)
85+
if total:
86+
weighted_sum = sum(_score_claim(claim.verdict, claim.confidence) for claim in results)
87+
score = weighted_sum / total
88+
else:
89+
score = None
90+
results.sort(key=lambda c: (_priority(c.verdict), c.confidence), reverse=True)
91+
return AtomicCheck(claims=results, score=score, status="ok")

Halgorithem/checks/nli.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import re
2+
from functools import lru_cache
3+
4+
from ..contradiction import find_contradiction
5+
from ..models import NLICheck
6+
from ..text_processing import extract_numbers, has_negation_mismatch, lemmatize_tokens
7+
8+
9+
@lru_cache(maxsize=8192)
10+
def _tokens(text):
11+
return frozenset(t.lower() for t in re.findall(r"\b[a-zA-Z][a-zA-Z'-]+\b", text or "") if len(t) > 2)
12+
13+
14+
@lru_cache(maxsize=8192)
15+
def _content_lemmas(text):
16+
return frozenset(lemma for lemma in lemmatize_tokens(text) if len(lemma) > 2)
17+
18+
19+
class NLIModel:
20+
model_quality = 0.75
21+
22+
def predict(self, premise, hypothesis):
23+
return self.predict_batch([premise], [hypothesis])[0]
24+
25+
def predict_batch(self, premises, hypotheses):
26+
return [rule_nli(premise, hypothesis) for premise, hypothesis in zip(premises, hypotheses)]
27+
28+
29+
def rule_nli(premise, hypothesis):
30+
chunk = {"text": premise or "", "numbers": extract_numbers(premise)}
31+
issue = find_contradiction(
32+
claim=hypothesis,
33+
chunk=chunk,
34+
extract_numbers=extract_numbers,
35+
has_negation_mismatch=has_negation_mismatch,
36+
score=1.0,
37+
threshold=0.0,
38+
)
39+
if issue:
40+
return NLICheck("CONTRADICTION", 0.84, issue.get("reason", "Contradiction"), model_quality=0.75)
41+
42+
premise_tokens = _tokens(premise)
43+
hypothesis_tokens = _tokens(hypothesis)
44+
premise_numbers = set(extract_numbers(premise))
45+
hypothesis_numbers = set(extract_numbers(hypothesis))
46+
if hypothesis_numbers and not premise_numbers:
47+
return NLICheck("NEUTRAL", 0.35, "Missing number evidence", model_quality=0.75)
48+
if hypothesis_numbers and premise_numbers and not hypothesis_numbers.issubset(premise_numbers):
49+
return NLICheck("NEUTRAL", 0.42, "Number evidence differs", model_quality=0.75)
50+
if hypothesis_tokens:
51+
token_overlap = len(premise_tokens & hypothesis_tokens) / len(hypothesis_tokens)
52+
premise_lemmas = _content_lemmas(premise)
53+
hypothesis_lemmas = _content_lemmas(hypothesis)
54+
lemma_overlap = len(premise_lemmas & hypothesis_lemmas) / len(hypothesis_lemmas) if hypothesis_lemmas else 0.0
55+
overlap = max(token_overlap, lemma_overlap)
56+
if overlap >= 0.70:
57+
return NLICheck("ENTAILMENT", min(0.60 + overlap * 0.30, 0.92), model_quality=0.75)
58+
return NLICheck("NEUTRAL", 0.50, model_quality=0.75)
59+
60+
61+
def sentence_nli(processed_sentence, document=None, nli_model=None, hits=None):
62+
model = nli_model or NLIModel()
63+
claim = getattr(processed_sentence, "resolved_text", processed_sentence)
64+
claim = claim if isinstance(claim, str) else str(claim)
65+
relevant_hits = hits or []
66+
if not relevant_hits and document is not None:
67+
relevant_hits = [{"sentence": s.resolved_text, "score": 1.0} for s in document[:1]]
68+
69+
best_hit_score = max(
70+
(
71+
(hit.get("score", 0.0) if isinstance(hit, dict) else getattr(hit, "score", 0.0))
72+
for hit in relevant_hits
73+
),
74+
default=0.0,
75+
)
76+
min_hit_score = max(0.30, best_hit_score * 0.80)
77+
premises = []
78+
hypotheses = []
79+
hit_scores = []
80+
for hit in relevant_hits[:5]:
81+
premise = hit.get("sentence") if isinstance(hit, dict) else getattr(hit, "sentence", str(hit))
82+
hit_score = hit.get("score", 0.0) if isinstance(hit, dict) else getattr(hit, "score", 0.0)
83+
if hit_score < min_hit_score:
84+
continue
85+
premises.append(premise)
86+
hypotheses.append(claim)
87+
hit_scores.append(hit_score)
88+
if not premises:
89+
return NLICheck("NEUTRAL", 0.50, model_quality=getattr(model, "model_quality", 1.0))
90+
91+
results = model.predict_batch(premises, hypotheses)
92+
entailments = [r for r in results if r.label == "ENTAILMENT"]
93+
strong_entailment = max(entailments, key=lambda r: r.score) if entailments else None
94+
contradictions = [r for r in results if r.label == "CONTRADICTION"]
95+
if strong_entailment and strong_entailment.score >= 0.82:
96+
return strong_entailment
97+
if contradictions:
98+
return max(contradictions, key=lambda r: r.score)
99+
if entailments:
100+
return max(entailments, key=lambda r: r.score)
101+
return max(results, key=lambda r: r.score)

Halgorithem/checks/similarity.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import heapq
2+
3+
from ..models import SimilarityCheck, SimilarityHit
4+
def _token_overlap(left_tokens, right_tokens):
5+
left_tokens = {token for token in left_tokens if len(token) > 2}
6+
if not left_tokens or not right_tokens:
7+
return 0.0
8+
return len(left_tokens & set(right_tokens)) / len(left_tokens)
9+
10+
11+
def _similarities(embedder, left, rights):
12+
if hasattr(embedder, "similarity_many"):
13+
try:
14+
return embedder.similarity_many(left, rights)
15+
except Exception:
16+
pass
17+
return [float(embedder.similarity(left, right)) for right in rights]
18+
19+
20+
def similarity_search(processed_sentence, document, embedder, top_k=5):
21+
query_embedding = processed_sentence.embedding
22+
if query_embedding is None:
23+
query_embedding = embedder.encode(processed_sentence.resolved_text, convert_to_tensor=True)
24+
25+
embeddings = []
26+
for doc_sentence in document:
27+
embedding = doc_sentence.embedding
28+
if embedding is None:
29+
embedding = embedder.encode(doc_sentence.resolved_text, convert_to_tensor=True)
30+
embeddings.append(embedding)
31+
32+
raw_scores = _similarities(embedder, query_embedding, embeddings)
33+
hits = []
34+
for doc_sentence, raw_score in zip(document, raw_scores):
35+
overlap = _token_overlap(processed_sentence.tokens, doc_sentence.tokens)
36+
lemma_overlap = _token_overlap(processed_sentence.lemmas, doc_sentence.lemmas)
37+
number_bonus = 0.05 if processed_sentence.numbers and processed_sentence.numbers.issubset(doc_sentence.numbers) else 0.0
38+
overlap = max(overlap, lemma_overlap)
39+
score = min(raw_score + 0.12 * overlap, 1.0)
40+
score = min(score + number_bonus, 1.0)
41+
hits.append(
42+
SimilarityHit(
43+
sentence=doc_sentence.context_text or doc_sentence.resolved_text,
44+
score=score,
45+
source=doc_sentence.source,
46+
sentence_id=doc_sentence.sentence_id,
47+
source_quality=doc_sentence.source_quality,
48+
)
49+
)
50+
51+
selected = heapq.nlargest(top_k, hits, key=lambda hit: hit.score)
52+
source_quality = max((hit.source_quality for hit in selected), default=0.55)
53+
score = selected[0].score if selected else 0.0
54+
return SimilarityCheck(score=score, hits=selected, source_quality=source_quality)

Halgorithem/checks/units.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
UNIT_ALIASES = {
2+
"g": "gram",
3+
"gram": "gram",
4+
"grams": "gram",
5+
"kg": "kilogram",
6+
"kilogram": "kilogram",
7+
"kilograms": "kilogram",
8+
"lb": "pound",
9+
"lbs": "pound",
10+
"pound": "pound",
11+
"pounds": "pound",
12+
"m": "meter",
13+
"meter": "meter",
14+
"meters": "meter",
15+
"cm": "centimeter",
16+
"centimeter": "centimeter",
17+
"centimeters": "centimeter",
18+
"km": "kilometer",
19+
"kilometer": "kilometer",
20+
"kilometers": "kilometer",
21+
"mile": "mile",
22+
"miles": "mile",
23+
}
24+
25+
NORMALIZATION = {
26+
"gram": ("mass", 0.001),
27+
"kilogram": ("mass", 1.0),
28+
"pound": ("mass", 0.45359237),
29+
"meter": ("length", 1.0),
30+
"centimeter": ("length", 0.01),
31+
"kilometer": ("length", 1000.0),
32+
"mile": ("length", 1609.344),
33+
}
34+
35+
36+
def normalize_unit(unit):
37+
canonical = UNIT_ALIASES.get((unit or "").lower())
38+
return NORMALIZATION.get(canonical) if canonical else None

Halgorithem/claim_extraction.py

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,14 @@
11
import re
22

3-
from .nlp import nlp
3+
from .nlp import parse
44

55

66
CLAIM_SPLIT_RE = re.compile(r"\s*(?:;|\n+|\s+-\s+)\s*")
7-
CONJUNCTION_RE = re.compile(
8-
r"\s+(?:and|but|while|whereas)\s+"
9-
r"(?=(?:[A-Z][a-z]+|\d|it\b|he\b|she\b|they\b|the\b|a\b|an\b))",
10-
re.IGNORECASE,
11-
)
7+
CONJUNCTION_RE = re.compile(r"\s+(?:and|but|while|whereas)\s+", re.IGNORECASE)
128

139

1410
def _has_factual_shape(text):
15-
doc = nlp(text)
11+
doc = parse(text)
1612
has_subject = any(t.dep_ in {"nsubj", "nsubjpass"} for t in doc)
1713
has_verb = any(t.pos_ in {"VERB", "AUX"} for t in doc)
1814
has_anchor = any(doc.ents) or any(t.like_num for t in doc) or any(t.pos_ == "PROPN" for t in doc)
@@ -27,7 +23,7 @@ def _has_factual_shape(text):
2723

2824

2925
def _has_event_verb(text):
30-
doc = nlp(text)
26+
doc = parse(text)
3127
return any(t.pos_ in {"VERB", "AUX"} for t in doc)
3228

3329

Halgorithem/confidence.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
from .nlp import parse
2+
3+
14
INFERENTIAL_TERMS = {
25
"helped",
36
"made",
@@ -9,6 +12,14 @@
912
"significant",
1013
"influential",
1114
}
15+
INFERENTIAL_ROOT_LEMMAS = {
16+
"help",
17+
"ease",
18+
"learn",
19+
"influence",
20+
"matter",
21+
"signify",
22+
}
1223

1324
NEGATION_TERMS = {
1425
"no",
@@ -28,8 +39,9 @@
2839

2940

3041
def is_inferential_claim(claim):
31-
words = set((claim or "").lower().replace(".", "").split())
32-
return bool(words & INFERENTIAL_TERMS)
42+
doc = parse(claim)
43+
root = next((t for t in doc if t.dep_ == "ROOT"), None)
44+
return bool(root and root.lemma_.lower() in INFERENTIAL_ROOT_LEMMAS)
3345

3446

3547
def is_negative_claim(claim):
@@ -42,7 +54,14 @@ def classify_support(score, threshold=0.30, contradiction=None, unsupported_term
4254
supported_threshold = max(threshold + 0.10, 0.40)
4355

4456
hard_contradiction = contradiction and contradiction.get("reason") in {
45-
"Date mismatch", "Number mismatch", "Unit mismatch", "Negation mismatch"
57+
"Date mismatch",
58+
"Number mismatch",
59+
"Unit mismatch",
60+
"Negation mismatch",
61+
"Entity-role mismatch",
62+
"Location mismatch",
63+
"Source qualifier mismatch",
64+
"NLI contradiction",
4665
}
4766
if hard_contradiction:
4867
return "CONTRADICTION"
@@ -71,6 +90,6 @@ def confidence_score(score, evidence_count=0, contradiction=None, unsupported_te
7190
confidence = max(0.0, min(float(score), 1.0))
7291
confidence += min(evidence_count, 3) * 0.04
7392
confidence -= min(len(unsupported_terms), 4) * 0.06
74-
if contradiction:
93+
if contradiction and status in {"CONTRADICTION", "HALLUCINATION"}:
7594
confidence += 0.10
7695
return round(max(0.0, min(confidence, 1.0)), 3)

0 commit comments

Comments
 (0)