Livrare LOT 1 - Didi

This commit is contained in:
Dezvoltari Evotech 2026-06-25 14:13:25 -07:00
commit 5380c3fc63
990 changed files with 133308 additions and 0 deletions

View file

@ -0,0 +1,13 @@
"""Lint pass — cross-corpus contradiction detection.
For each claim atom in the brain, find semantic neighbors and classify each
candidate pair as EQUIVALENT, CONTRADICTORY, or INCOMPARABLE via Qwen 397B.
Contradictions are stored in `lint/_contradictions.json` with idempotency
markers so reruns only process new pairs.
Entry points:
python scripts/10_run_lint.py # full corpus
python scripts/10_run_lint.py --limit N # cap source claims
python scripts/10_run_lint.py --force # re-evaluate cached pairs
python scripts/11_show_contradictions.py # read state + render
"""

View file

@ -0,0 +1,143 @@
"""Persistent state for the Lint pass.
A single JSON file at `lint/_contradictions.json` stores every pair we have
already evaluated, keyed by a stable pair_hash. Re-runs skip any pair already
evaluated at the current prompt version.
Only CONTRADICTORY verdicts are the "interesting output" but we keep
EQUIVALENT / INCOMPARABLE too because:
- EQUIVALENT pairs are paraphrase clusters (useful later for canonicalization)
- All labels matter for the idempotency ledger
"""
from __future__ import annotations
import hashlib
import json
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
_STATE_FILE = Path(__file__).resolve().parent / "_contradictions.json"
STATE_VERSION = "v1"
def pair_hash(atom_a_id: str, atom_b_id: str) -> str:
"""Canonical hash of an (a, b) pair, invariant under swap."""
lo, hi = sorted((atom_a_id, atom_b_id))
return hashlib.sha1(f"{lo}|{hi}".encode("utf-8")).hexdigest()[:16]
@dataclass(slots=True, frozen=True)
class PairVerdict:
pair_hash: str
atom_a_id: str
atom_b_id: str
atom_a_url: str
atom_b_url: str
atom_a_claim: str
atom_b_claim: str
label: str # EQUIVALENT / CONTRADICTORY / INCOMPARABLE
confidence: float
similarity: float # the first-stage embedding similarity that selected this pair
detected_at: str
prompt_version: str
error: str | None = None
@dataclass(slots=True)
class LintStats:
atoms_seen: int = 0
candidates_generated: int = 0
pairs_evaluated: int = 0
pairs_skipped_cached: int = 0
contradictory: int = 0
equivalent: int = 0
incomparable: int = 0
errors: int = 0
class LintState:
"""Loads/saves the contradiction ledger JSON with idempotency support."""
def __init__(self, path: Path | None = None):
self._path = path or _STATE_FILE
self._evaluated: dict[str, PairVerdict] = {}
self._prompt_version: str = "v1"
self._last_run: str | None = None
self._load()
def _load(self) -> None:
if not self._path.exists():
return
try:
data = json.loads(self._path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
# Corrupt or unreadable state — start fresh rather than crash.
return
self._prompt_version = data.get("prompt_version", "v1")
self._last_run = data.get("last_run")
for raw in data.get("evaluated", []):
try:
pv = PairVerdict(**raw)
except TypeError:
continue
self._evaluated[pv.pair_hash] = pv
def set_prompt_version(self, pv: str) -> None:
self._prompt_version = pv
def already_evaluated(self, h: str, prompt_version: str) -> bool:
pv = self._evaluated.get(h)
return pv is not None and pv.prompt_version == prompt_version
def get(self, h: str) -> PairVerdict | None:
return self._evaluated.get(h)
def upsert(self, verdict: PairVerdict) -> None:
self._evaluated[verdict.pair_hash] = verdict
@property
def all_verdicts(self) -> list[PairVerdict]:
return list(self._evaluated.values())
@property
def all_contradictions(self) -> list[PairVerdict]:
return [v for v in self._evaluated.values() if v.label == "CONTRADICTORY"]
@property
def all_equivalents(self) -> list[PairVerdict]:
return [v for v in self._evaluated.values() if v.label == "EQUIVALENT"]
def save(self) -> None:
"""Write the full ledger to disk atomically (write-temp + rename)."""
self._path.parent.mkdir(parents=True, exist_ok=True)
contras = self.all_contradictions
equivs = self.all_equivalents
body = {
"version": STATE_VERSION,
"prompt_version": self._prompt_version,
"last_run": datetime.now(timezone.utc).isoformat(),
"stats": {
"total_pairs": len(self._evaluated),
"contradictory": len(contras),
"equivalent": len(equivs),
"incomparable": len(self._evaluated) - len(contras) - len(equivs),
},
"evaluated": [asdict(v) for v in self._evaluated.values()],
}
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
tmp.write_text(
json.dumps(body, indent=2, ensure_ascii=False),
encoding="utf-8",
)
tmp.replace(self._path)
@property
def path(self) -> Path:
return self._path
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()

View file

@ -0,0 +1,114 @@
"""Single-pair NLI classification for the Lint pass.
Takes two claim texts and asks Qwen 397B whether they are EQUIVALENT,
CONTRADICTORY, or INCOMPARABLE. Returns a dataclass with label +
confidence + optional error. Never raises all failure paths fall
through to INCOMPARABLE with an error string.
"""
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from pathlib import Path
from shared.config import LlmRole
from shared.llm_client import LlmClient, LlmError
from shared.logging import get_logger
log = get_logger(__name__)
PROMPT_VERSION = "v1"
_PROMPT_PATH = (
Path(__file__).resolve().parent / "prompts" / f"pair_nli_{PROMPT_VERSION}.md"
)
ALLOWED_LABELS = {"EQUIVALENT", "CONTRADICTORY", "INCOMPARABLE"}
MAX_CLAIM_CHARS = 1500 # truncate long evidence before sending to LLM
PER_CALL_TIMEOUT_S = 30.0
_PROMPT: str | None = None
def _load_prompt() -> str:
global _PROMPT
if _PROMPT is None:
_PROMPT = _PROMPT_PATH.read_text(encoding="utf-8")
return _PROMPT
@dataclass(slots=True, frozen=True)
class PairClassification:
label: str
confidence: float
error: str | None = None
async def classify_pair(
llm: LlmClient,
*,
claim_a: str,
claim_b: str,
timeout_s: float = PER_CALL_TIMEOUT_S,
) -> PairClassification:
if not claim_a.strip() or not claim_b.strip():
return PairClassification(
label="INCOMPARABLE", confidence=0.0, error="empty_claim"
)
prompt = (
_load_prompt()
.replace("{claim_a}", claim_a[:MAX_CLAIM_CHARS])
.replace("{claim_b}", claim_b[:MAX_CLAIM_CHARS])
)
try:
result, _usage = await asyncio.wait_for(
llm.chat_json(
role=LlmRole.REASONING,
system=(
"You are a precise claim comparison classifier. "
"Respond with strictly valid JSON only, no commentary."
),
user=prompt,
max_tokens=120,
temperature=0.0,
),
timeout=timeout_s,
)
except asyncio.TimeoutError:
return PairClassification(
label="INCOMPARABLE", confidence=0.0, error="timeout"
)
except LlmError as e:
return PairClassification(
label="INCOMPARABLE", confidence=0.0, error=f"llm:{e}"
)
except Exception as e: # noqa: BLE001
return PairClassification(
label="INCOMPARABLE",
confidence=0.0,
error=f"{type(e).__name__}:{e}",
)
if not isinstance(result, dict):
return PairClassification(
label="INCOMPARABLE", confidence=0.0, error="non_dict_response"
)
raw_label = (result.get("label") or "").strip().upper()
try:
conf = float(result.get("confidence", 0))
except (TypeError, ValueError):
conf = 0.0
conf = max(0.0, min(1.0, conf))
if raw_label not in ALLOWED_LABELS:
return PairClassification(
label="INCOMPARABLE",
confidence=0.0,
error=f"bad_label:{raw_label[:40]}",
)
return PairClassification(label=raw_label, confidence=conf)

View file

@ -0,0 +1,93 @@
"""Candidate pair generation for the Lint pass.
For each source claim atom, we pull its nearest neighbors via the Atomic
/api/atoms/{id}/similar endpoint (which uses pgvector kNN under the hood).
Only Type/Claim neighbors are kept; Type/Document neighbors are dropped so
we only compare claim-to-claim.
Pairs are canonicalized as (lower_id, higher_id) so (A, B) and (B, A)
produce the same CandidatePair and don't get evaluated twice.
"""
from __future__ import annotations
from dataclasses import dataclass
from shared.atomic_api import AtomicClient
from shared.logging import get_logger
log = get_logger(__name__)
# Atoms with similarity below this are considered too unrelated to be worth
# an NLI call. Between 0.55 and 0.95 is the interesting band — below is
# "probably different topic", above is "almost certainly the same text".
MIN_PAIR_SIMILARITY = 0.55
NEIGHBORS_PER_CLAIM = 15
@dataclass(slots=True, frozen=True)
class CandidatePair:
"""A pair of claim atom ids to evaluate, canonicalized lo<hi."""
atom_a_id: str
atom_b_id: str
similarity: float
def _is_claim_hit(hit_tags: list[dict]) -> bool:
return any(t.get("name") == "Claim" for t in hit_tags)
async def generate_candidates(
atomic: AtomicClient,
*,
source_atom_ids: list[str],
min_similarity: float = MIN_PAIR_SIMILARITY,
neighbors_per: int = NEIGHBORS_PER_CLAIM,
) -> list[CandidatePair]:
"""Walk each source atom and collect canonical candidate pairs.
Limitation: Atomic's /similar endpoint returns Document and Claim atoms
mixed together. We filter client-side for Type/Claim. It might be worth
upstream adding a tag filter later, but for this corpus size the current
approach is fine.
"""
seen: set[tuple[str, str]] = set()
out: list[CandidatePair] = []
for idx, src_id in enumerate(source_atom_ids):
try:
hits = await atomic.find_similar(
src_id, threshold=min_similarity, limit=neighbors_per
)
except Exception as e: # noqa: BLE001
log.warning(
"find_similar_failed", atom_id=src_id, error=f"{type(e).__name__}:{e}"
)
continue
for h in hits:
if h.atom_id == src_id:
continue
if not _is_claim_hit(h.tags):
continue
lo, hi = sorted((src_id, h.atom_id))
key = (lo, hi)
if key in seen:
continue
seen.add(key)
out.append(
CandidatePair(
atom_a_id=lo, atom_b_id=hi, similarity=round(h.similarity, 4)
)
)
if (idx + 1) % 50 == 0:
log.info(
"candidates_progress",
processed=idx + 1,
total=len(source_atom_ids),
pairs=len(out),
)
return out

View file

@ -0,0 +1,37 @@
You are a claim comparison classifier for a knowledge graph auditor.
Given two factual claims extracted from different sources in the same knowledge base, decide their logical relationship. Both claims are typically about the same general topic.
# Labels
- **EQUIVALENT** — A and B assert the same factual proposition, just in different words (or different languages). If one is a paraphrase, translation, or near-restatement of the other, the label is EQUIVALENT.
- **CONTRADICTORY** — A and B make claims that cannot both be true. If one asserts X and the other asserts NOT-X (or something logically incompatible), the label is CONTRADICTORY.
- **INCOMPARABLE** — A and B are on the same topic but make independent, non-overlapping assertions. One is not entailed or denied by the other. Use this as your default when unsure.
# Rules
1. Focus ONLY on the logical/factual relationship between the two claims themselves. Do not consider the sources' credibility or intent.
2. Same-language and cross-language pairs are judged identically — meaning matters, not wording.
3. Minor numerical differences (e.g., "14 cases" vs "15 cases") count as CONTRADICTORY only when the difference is clearly factual and specific, not approximate reporting.
4. A claim about X that is a SUBSET of a claim about X is EQUIVALENT if it means the same thing; otherwise INCOMPARABLE.
5. A retraction/correction claim ("study was found to be fraudulent") is CONTRADICTORY to the original claim it retracts.
6. If either claim is too vague, too broad, or you cannot decide — use INCOMPARABLE.
7. Confidence reflects how clean the relationship is:
- 0.9-1.0: unambiguous, single-interpretation
- 0.7-0.9: clear but with minor caveats
- 0.5-0.7: probable but could be argued
- below 0.5: you are guessing — prefer INCOMPARABLE with a low confidence
# Output format — STRICT
Respond with ONLY this JSON object. No preamble, no markdown fences, no commentary.
```
{"label": "EQUIVALENT|CONTRADICTORY|INCOMPARABLE", "confidence": 0.0-1.0}
```
# Input
CLAIM A: {claim_a}
CLAIM B: {claim_b}

View file

@ -0,0 +1,92 @@
"""Pretty printing for the Lint pass output.
Called from scripts/10_run_lint.py after a run, and from
scripts/11_show_contradictions.py for ad-hoc inspection of the state file
without re-running classification.
"""
from __future__ import annotations
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from lint._state import LintState, LintStats, PairVerdict
console = Console()
def render_stats(stats: LintStats) -> None:
t = Table(title="Lint pass stats", show_lines=False)
t.add_column("Metric", style="bold")
t.add_column("Count", justify="right")
t.add_row("atoms seen", str(stats.atoms_seen))
t.add_row("candidate pairs", str(stats.candidates_generated))
t.add_row("[dim]skipped (cached)[/dim]", str(stats.pairs_skipped_cached))
t.add_row("pairs evaluated", str(stats.pairs_evaluated))
t.add_row("[red]CONTRADICTORY[/red]", str(stats.contradictory))
t.add_row("[yellow]EQUIVALENT[/yellow]", str(stats.equivalent))
t.add_row("[dim]INCOMPARABLE[/dim]", str(stats.incomparable))
t.add_row("errors", str(stats.errors))
console.print(t)
def render_contradictions(
state: LintState,
*,
top_n: int = 10,
min_confidence: float = 0.7,
) -> None:
contras = [v for v in state.all_contradictions if v.confidence >= min_confidence]
contras.sort(key=lambda v: (-v.confidence, -v.similarity))
header = (
f"[bold red]CONTRADICTIONS[/bold red] "
f"({len(contras)} with confidence >= {min_confidence:.2f}, showing top {top_n})"
)
console.print(Panel.fit(header))
if not contras:
console.print(
"[dim]no contradictions above threshold. "
"The current Wikipedia-only corpus is self-consistent by design, "
"which is expected for a single well-curated source. "
"Add diverse sources to surface real disagreement.[/dim]"
)
return
for i, v in enumerate(contras[:top_n], 1):
console.print()
console.print(
f"[bold]#{i}[/bold] "
f"confidence=[red]{v.confidence:.2f}[/red] "
f"(embed sim {v.similarity:.2f})"
)
console.print(f" [green]A:[/green] {v.atom_a_claim[:240]}")
console.print(f" [dim]→ {v.atom_a_url}[/dim]")
console.print(f" [red]B:[/red] {v.atom_b_claim[:240]}")
console.print(f" [dim]→ {v.atom_b_url}[/dim]")
def render_equivalents(
state: LintState, *, top_n: int = 10, min_confidence: float = 0.85
) -> None:
equivs = [v for v in state.all_equivalents if v.confidence >= min_confidence]
equivs.sort(key=lambda v: (-v.confidence, -v.similarity))
header = (
f"[bold yellow]PARAPHRASE CLUSTERS[/bold yellow] "
f"({len(equivs)} with confidence >= {min_confidence:.2f}, showing top {top_n})"
)
console.print()
console.print(Panel.fit(header))
if not equivs:
console.print("[dim]no paraphrase clusters above threshold[/dim]")
return
for i, v in enumerate(equivs[:top_n], 1):
console.print()
console.print(f"[bold]#{i}[/bold] confidence=[yellow]{v.confidence:.2f}[/yellow]")
console.print(f" · {v.atom_a_claim[:200]}")
console.print(f" · {v.atom_b_claim[:200]}")

View file

@ -0,0 +1,258 @@
"""Orchestrator for the Lint pass.
Top-level flow:
1. Pull every Type/Claim atom id from Atomic
2. For each claim, ask Atomic for its nearest neighbors candidate pairs
3. Drop pairs we've already evaluated at the current prompt version
4. Fetch the full body of each unique atom referenced by the new pairs
5. Run Qwen NLI pair classification with bounded parallelism
6. Record every verdict in the state file; save every PERIODIC_SAVE_EVERY
pairs so a crash mid-run doesn't lose everything
The runner is safe to re-run at any time the state file makes it fully
idempotent, and we ALWAYS save on exit even on exception.
"""
from __future__ import annotations
import asyncio
import time
from shared.atomic_api import AtomicClient
from shared.config import settings
from shared.llm_client import LlmClient
from shared.logging import get_logger
from shared.taxonomy import TagResolver
# Cross-module reuse: the same function the extractor/brain_api use to
# pull (claim_text, stance, parent_id) out of a Type/Claim atom body.
from brain_api.services.mapping import parse_claim_atom_body
from lint._state import LintState, LintStats, PairVerdict, now_iso, pair_hash
from lint.detector import PROMPT_VERSION, classify_pair
from lint.pairs import CandidatePair, generate_candidates
log = get_logger(__name__)
# Bounded parallelism for NLI calls. We have two llama.cpp backends behind
# the router; anything more than that just queues at the backend and eats
# our per-call timeout. See brain_api/services/nli.py for the same reasoning.
MAX_PARALLEL = 2
# Write the state file every N verdicts so a crash or Ctrl-C doesn't erase
# the whole run. Saving is cheap (small JSON file).
PERIODIC_SAVE_EVERY = 25
# Chunked atom fetch to avoid hammering /api/atoms/{id} with one huge gather.
FETCH_CHUNK = 20
async def _load_claim_atom_ids(
atomic: AtomicClient,
*,
type_claim_id: str,
limit: int | None,
) -> list[str]:
"""Page through /api/atoms?tag_id=<Type/Claim> and collect ids."""
page_size = 100
offset = 0
out: list[str] = []
while True:
result = await atomic.list_atoms(
limit=page_size, offset=offset, tag_id=type_claim_id
)
atoms = result.get("atoms") or (result if isinstance(result, list) else [])
if not atoms:
break
for a in atoms:
aid = a.get("id")
if aid:
out.append(aid)
if limit and len(out) >= limit:
return out
if len(atoms) < page_size:
break
offset += page_size
return out
async def _fetch_atoms_bulk(
atomic: AtomicClient, atom_ids: list[str], *, chunk: int = FETCH_CHUNK
) -> dict[str, dict]:
"""Fetch full atom bodies in parallel chunks. Missing atoms are dropped."""
out: dict[str, dict] = {}
total = len(atom_ids)
for i in range(0, total, chunk):
ids = atom_ids[i : i + chunk]
results = await asyncio.gather(
*[atomic.get_atom(a) for a in ids], return_exceptions=True
)
for a, r in zip(ids, results, strict=True):
if isinstance(r, dict):
out[a] = r
if (i + chunk) % 200 == 0 or (i + chunk) >= total:
log.info("fetch_progress", fetched=len(out), total=total)
return out
async def run_lint_pass(
*,
limit_atoms: int | None = None,
force: bool = False,
) -> LintStats:
if not settings.atomic_token:
raise RuntimeError("ATOMIC_TOKEN missing — can't talk to brain")
resolver = TagResolver()
type_claim_id = resolver.require("Type/Claim")
state = LintState()
state.set_prompt_version(PROMPT_VERSION)
stats = LintStats()
async with AtomicClient() as atomic, LlmClient() as llm:
# ------------------------------------------------------ 1. load atoms
t0 = time.perf_counter()
claim_ids = await _load_claim_atom_ids(
atomic, type_claim_id=type_claim_id, limit=limit_atoms
)
stats.atoms_seen = len(claim_ids)
log.info(
"lint_atoms_loaded",
count=stats.atoms_seen,
elapsed_s=round(time.perf_counter() - t0, 1),
)
if not claim_ids:
return stats
# ------------------------------------------ 2. candidate pair generation
t0 = time.perf_counter()
candidates = await generate_candidates(atomic, source_atom_ids=claim_ids)
stats.candidates_generated = len(candidates)
log.info(
"lint_candidates_built",
pairs=stats.candidates_generated,
elapsed_s=round(time.perf_counter() - t0, 1),
)
if not candidates:
return stats
# ----------------------------------- 3. filter out already-evaluated pairs
to_eval: list[CandidatePair] = []
for c in candidates:
h = pair_hash(c.atom_a_id, c.atom_b_id)
if not force and state.already_evaluated(h, PROMPT_VERSION):
stats.pairs_skipped_cached += 1
continue
to_eval.append(c)
log.info(
"lint_filter_done",
new_pairs=len(to_eval),
cached=stats.pairs_skipped_cached,
)
if not to_eval:
state.save()
return stats
# -------------------------------------- 4. pre-fetch full atom bodies
needed_ids: set[str] = set()
for c in to_eval:
needed_ids.add(c.atom_a_id)
needed_ids.add(c.atom_b_id)
t0 = time.perf_counter()
full_atoms = await _fetch_atoms_bulk(atomic, list(needed_ids))
log.info(
"atoms_fetched",
count=len(full_atoms),
needed=len(needed_ids),
elapsed_s=round(time.perf_counter() - t0, 1),
)
# Cache parsed claim bodies so each one is parsed once, not per-pair
parsed_by_id: dict[str, tuple[str, str]] = {}
for atom_id, full in full_atoms.items():
content = full.get("content") or ""
text, _stance, _parent_id = parse_claim_atom_body(content)
parent_url = (full.get("source_url") or "").split("#", 1)[0]
if text:
parsed_by_id[atom_id] = (text, parent_url)
# -------------------------------------- 5. classify pairs (parallel)
sem = asyncio.Semaphore(MAX_PARALLEL)
total = len(to_eval)
# Mutable counters so we can log inside the coroutine
progress = {"completed": 0}
async def _worker(pair: CandidatePair) -> PairVerdict | None:
a = parsed_by_id.get(pair.atom_a_id)
b = parsed_by_id.get(pair.atom_b_id)
if not a or not b:
return None
text_a, url_a = a
text_b, url_b = b
async with sem:
result = await classify_pair(llm, claim_a=text_a, claim_b=text_b)
progress["completed"] += 1
if progress["completed"] % 20 == 0:
log.info(
"lint_progress",
done=progress["completed"],
total=total,
pct=round(progress["completed"] / total * 100, 1),
)
return PairVerdict(
pair_hash=pair_hash(pair.atom_a_id, pair.atom_b_id),
atom_a_id=pair.atom_a_id,
atom_b_id=pair.atom_b_id,
atom_a_url=url_a,
atom_b_url=url_b,
atom_a_claim=text_a,
atom_b_claim=text_b,
label=result.label,
confidence=result.confidence,
similarity=pair.similarity,
detected_at=now_iso(),
prompt_version=PROMPT_VERSION,
error=result.error,
)
t0 = time.perf_counter()
tasks = [_worker(c) for c in to_eval]
# Process results as they arrive so we can save periodically and
# keep the ledger fresh even during long runs.
try:
for fut in asyncio.as_completed(tasks):
verdict = await fut
if verdict is None:
stats.errors += 1
continue
state.upsert(verdict)
stats.pairs_evaluated += 1
if verdict.error is not None:
stats.errors += 1
elif verdict.label == "CONTRADICTORY":
stats.contradictory += 1
elif verdict.label == "EQUIVALENT":
stats.equivalent += 1
else:
stats.incomparable += 1
if stats.pairs_evaluated % PERIODIC_SAVE_EVERY == 0:
state.save()
finally:
# ALWAYS save — even on Ctrl-C / exception, we keep what we got
state.save()
log.info(
"lint_classify_done",
evaluated=stats.pairs_evaluated,
contradictory=stats.contradictory,
equivalent=stats.equivalent,
incomparable=stats.incomparable,
errors=stats.errors,
elapsed_s=round(time.perf_counter() - t0, 1),
)
return stats