lixiaowww
Initial commit: forecaster agent with zero-cost GitHub Pages deploy.
2fc729c
Raw
History Blame Contribute Delete
18.8 kB
"""Crowd gate: turn many overlapping human contributions into a sparse set of
high-information, well-argued, decorrelated signals — then aggregate.
Design (harness conventions)
----------------------------
* All nondeterminism lives behind interfaces (`Embedder`, `SoundnessJudge`) so the
core is deterministic and unit-testable offline. Real LLM/embedding backends are
drop-in replacements for the deterministic stubs.
* The scoring core (`js_divergence`, novelty, sparse selection, aggregation) is pure
functions over plain Python — no network, no hidden state.
* Every admit/reject decision emits a structured `Decision` trace for observability.
* Two conjunctive gates: SOUNDNESS (is it valid + evidence-backed?) AND NOVELTY
(is it decorrelated from what we already have?). Entropy selects/explores;
it is never the arbiter — realised Brier (via `skill_fn`) governs long-run weight.
* Sparsity = de-redundancy: greedy novelty gating collapses viewpoint clusters to
representatives, then a top-k cap keeps only the few non-zero "parameters".
"""
from __future__ import annotations
import hashlib
import json
import math
import re
from dataclasses import dataclass, field, asdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Callable, Optional, Protocol
# --------------------------------------------------------------------------- #
# Models
# --------------------------------------------------------------------------- #
from schemas import Contribution
@dataclass
class SoundnessVerdict:
soundness: float # 0..1 reasoning quality
evidence_ok: bool # passes the hard verifiable-evidence gate
reasons: str = ""
@dataclass
class Decision:
"""Observable trace of what the gate did with one contribution."""
contribution_id: str
contributor_id: str
soundness: Optional[float] = None
evidence_ok: Optional[bool] = None
novelty: Optional[float] = None
admitted: bool = False
weight: float = 0.0
reason: str = ""
@dataclass
class GateResult:
target_id: str
prior_probability: float # agent's own forecast before the crowd
aggregate_probability: float # crowd-adjusted forecast
selected: list[str] # contribution ids that became non-zero params
decisions: list[Decision]
def explain(self) -> str:
lines = [f"target={self.target_id} prior={self.prior_probability:.3f} "
f"-> aggregate={self.aggregate_probability:.3f} "
f"({len(self.selected)} of {len(self.decisions)} admitted)"]
for d in self.decisions:
tag = f"ADMIT w={d.weight:.3f}" if d.admitted else f"REJECT[{d.reason}]"
sc = f"snd={d.soundness}" if d.soundness is not None else "snd=-"
nv = f"nov={d.novelty:.3f}" if d.novelty is not None else "nov=-"
lines.append(f" {tag:<22} {sc} {nv} {d.contributor_id}")
return "\n".join(lines)
# --------------------------------------------------------------------------- #
# Interfaces (the harness seams)
# --------------------------------------------------------------------------- #
class Embedder(Protocol):
def embed(self, texts: list[str]) -> list[list[float]]: ...
class SoundnessJudge(Protocol):
def score(self, c: Contribution, target_statement: str) -> SoundnessVerdict: ...
# --------------------------------------------------------------------------- #
# Deterministic default backends (offline, no API key)
# --------------------------------------------------------------------------- #
_TOKEN = re.compile(r"[a-z0-9]+")
class HashingEmbedder:
"""Deterministic bag-of-tokens hashing embedding. No network. Good for tests
and a sane default; swap for a real embeddings API in production."""
def __init__(self, dim: int = 256):
self.dim = dim
def embed(self, texts: list[str]) -> list[list[float]]:
out = []
for t in texts:
v = [0.0] * self.dim
for tok in _TOKEN.findall(t.lower()):
bucket = int.from_bytes(hashlib.md5(tok.encode()).digest()[:4], "big")
v[bucket % self.dim] += 1.0
out.append(_l2(v))
return out
class HeuristicSoundnessJudge:
"""Deterministic, offline soundness proxy. Enforces the hard evidence gate and
rewards structured reasoning over bare assertion. A reasonable default and the
fixture backend for evals; LLMSoundnessJudge is the production backend."""
CONNECTIVES = ("because", "therefore", "however", "since", "thus", "given",
"implies", "whereas", "consequently", "if", "so", "but")
def score(self, c: Contribution, target_statement: str) -> SoundnessVerdict:
evidence_ok = len(c.evidence_urls) > 0
words = c.argument.split()
length_score = min(1.0, len(words) / 60.0)
conn = sum(c.argument.lower().count(k) for k in self.CONNECTIVES)
reasoning_score = min(1.0, conn / 4.0)
# bare assertions (no connectives, very short) score low
soundness = round(0.5 * length_score + 0.5 * reasoning_score, 3)
return SoundnessVerdict(
soundness=soundness, evidence_ok=evidence_ok,
reasons=f"len={len(words)} connectives={conn} evidence={len(c.evidence_urls)}")
class LLMSoundnessJudge:
"""Production backend: an LLM grades the argument against a strict rubric.
Uses call_llm() for provider routing (Groq free tier → Anthropic → error).
Web-search tool is omitted for Groq compatibility; evidence is assessed
by URL-presence heuristic in the prompt instead.
Kept behind the SoundnessJudge interface so the core stays testable without it.
"""
def __init__(self, model: str | None = None):
# None → call_llm() selects the best available model automatically
self.model = model
def score(self, c: Contribution, target_statement: str) -> SoundnessVerdict:
try:
from forecast import call_llm
except ImportError:
from .forecast import call_llm
system = (
"You are a strict argument quality judge. Grade contributions on a "
"0..1 soundness rubric: valid logic, falsifiable claims, engagement "
"with the strongest counterargument, and whether cited evidence URLs "
"plausibly support the position. Be concise and objective."
)
user = (
f"Claim under discussion: {target_statement}\n"
f"Contributor's position: P(true)={c.probability}\n"
f"Argument: {c.argument}\n"
f"Evidence URLs provided: {c.evidence_urls}\n\n"
"Return ONLY JSON (no code fences): "
'{"soundness": 0.0..1.0, "evidence_ok": true|false, "reasons": "brief explanation"}'
)
text = call_llm(system, user, max_tokens=400, model=self.model)
s, e = text.find("{"), text.rfind("}")
data = json.loads(text[s:e + 1])
return SoundnessVerdict(
soundness=float(data.get("soundness", 0.0)),
evidence_ok=bool(data.get("evidence_ok", False)),
reasons=str(data.get("reasons", "")))
# --------------------------------------------------------------------------- #
# Pure scoring core (deterministic, fully unit-tested)
# --------------------------------------------------------------------------- #
_EPS = 1e-9
_LN2 = math.log(2.0)
def _l2(v: list[float]) -> list[float]:
n = math.sqrt(sum(x * x for x in v))
return [x / n for x in v] if n > _EPS else v
def cosine(a: list[float], b: list[float]) -> float:
return sum(x * y for x, y in zip(a, b)) # inputs assumed L2-normalised
def _kl_bernoulli(p: float, q: float) -> float:
p = min(1 - _EPS, max(_EPS, p))
q = min(1 - _EPS, max(_EPS, q))
return p * math.log(p / q) + (1 - p) * math.log((1 - p) / (1 - q))
def js_divergence(p: float, q: float) -> float:
"""Jensen-Shannon divergence between Bernoulli(p) and Bernoulli(q), in [0,1]."""
m = 0.5 * (p + q)
jsd = 0.5 * _kl_bernoulli(p, m) + 0.5 * _kl_bernoulli(q, m)
return max(0.0, min(1.0, jsd / _LN2))
def novelty(prob: float, arg_vec: list[float], ensemble_prob: float,
existing_vecs: list[list[float]], alpha: float = 0.4) -> float:
"""Marginal information of a contribution vs the current ensemble.
Combines forecast divergence (how different the number is) with semantic
novelty (how different the *reasoning* is). Both in [0,1]."""
forecast_term = js_divergence(prob, ensemble_prob)
if existing_vecs:
sim = max(cosine(arg_vec, e) for e in existing_vecs)
semantic_term = max(0.0, 1.0 - max(0.0, sim))
else:
semantic_term = 1.0
return alpha * forecast_term + (1 - alpha) * semantic_term
def aggregate(prior_p: float, picks: list[tuple[float, float]],
prior_weight: float = 1.0, extremize: float = 1.0) -> float:
"""Weighted combination of the prior (agent) forecast and selected (prob, weight)
contributions. `extremize`>1 pushes the result away from 0.5 (crowds are often
under-confident); =1 is off."""
num = prior_p * prior_weight
den = prior_weight
for p, w in picks:
num += p * w
den += w
p = num / den if den > _EPS else prior_p
if extremize != 1.0:
a = p ** extremize
b = (1 - p) ** extremize
p = a / (a + b + _EPS)
return max(0.0, min(1.0, p))
# --------------------------------------------------------------------------- #
# The gate
# --------------------------------------------------------------------------- #
@dataclass
class GateConfig:
tau_soundness: float = 0.5 # min reasoning quality to pass the validity gate
tau_novelty: float = 0.25 # min marginal information to pass the diversity gate
k: int = 5 # sparsity cap: max non-zero contributions
alpha: float = 0.4 # forecast-vs-semantic novelty mix
skill_floor: float = 0.25 # min weight factor for cold-start contributors
extremize: float = 1.0
prior_weight: float = 1.0 # weight of the agent's own forecast
class CrowdGate:
def __init__(self, embedder: Embedder, judge: SoundnessJudge,
skill_fn: Callable[[str], float] | None = None,
cfg: GateConfig | None = None):
self.embedder = embedder
self.judge = judge
self.skill_fn = skill_fn or (lambda _cid: 0.5) # neutral prior skill
self.cfg = cfg or GateConfig()
def process(self, target_statement: str, target_id: str, prior_p: float,
prior_rationale: str, contributions: list[Contribution]) -> GateResult:
cfg = self.cfg
decisions: list[Decision] = []
# 1. SOUNDNESS gate (cheap reject of noise + hard evidence requirement) ----
survivors: list[tuple[Contribution, SoundnessVerdict]] = []
for c in contributions:
v = self.judge.score(c, target_statement)
d = Decision(c.id, c.contributor_id, soundness=v.soundness,
evidence_ok=v.evidence_ok)
if not v.evidence_ok:
d.reason = "no_evidence"
decisions.append(d)
continue
if v.soundness < cfg.tau_soundness:
d.reason = "weak_argument"
decisions.append(d)
continue
survivors.append((c, v))
decisions.append(d)
# process strongest arguments first so near-duplicates of them read as redundant
survivors.sort(key=lambda cv: (-cv[1].soundness, cv[0].id))
dmap = {d.contribution_id: d for d in decisions}
# 2. NOVELTY gate (greedy de-redundancy = implicit viewpoint clustering) ---
texts = [prior_rationale] + [c.argument for c, _ in survivors]
vecs = self.embedder.embed(texts)
prior_vec, surv_vecs = vecs[0], vecs[1:]
existing_vecs = [prior_vec]
ensemble = [prior_p]
admitted: list[tuple[Contribution, SoundnessVerdict, float]] = []
for (c, v), vec in zip(survivors, surv_vecs):
ens_mean = sum(ensemble) / len(ensemble)
nov = novelty(c.probability, vec, ens_mean, existing_vecs, cfg.alpha)
dmap[c.id].novelty = round(nov, 4)
if nov < cfg.tau_novelty:
dmap[c.id].reason = "redundant"
continue
admitted.append((c, v, nov))
existing_vecs.append(vec)
ensemble.append(c.probability)
# 3. SPARSE selection: keep top-k by quality x diversity; rest -> zero -----
def quality(cv):
c, v, nov = cv
skill = max(cfg.skill_floor, self.skill_fn(c.contributor_id))
return v.soundness * skill * (0.5 + 0.5 * nov)
admitted.sort(key=lambda cv: -quality(cv))
selected = admitted[: cfg.k]
for c, _, _ in admitted[cfg.k:]:
dmap[c.id].reason = "pruned_sparsity"
# 4. weight + aggregate ----------------------------------------------------
picks: list[tuple[float, float]] = []
chosen_ids: list[str] = []
for c, v, nov in selected:
w = round(quality((c, v, nov)), 4)
dmap[c.id].admitted = True
dmap[c.id].weight = w
dmap[c.id].reason = "admitted"
picks.append((c.probability, w))
chosen_ids.append(c.id)
agg = aggregate(prior_p, picks, cfg.prior_weight, cfg.extremize)
return GateResult(target_id, prior_p, round(agg, 4), chosen_ids,
list(decisions))
# --------------------------------------------------------------------------- #
# Persistence: store contributions, score them once the target resolves
# --------------------------------------------------------------------------- #
class ContributionStore:
"""Records contributions and, once a target prediction resolves, scores each
contributor's forecast by Brier — that realised score is what `skill_fn` reads,
so reputation is earned, never assumed."""
def __init__(self, path: str | Path | None = None):
from sqlmodel import create_engine
if path is not None:
# Ensure the directory exists
Path(path).parent.mkdir(parents=True, exist_ok=True)
self.engine = create_engine(f"sqlite:///{path}", echo=False)
else:
from schemas import engine
self.engine = engine
from sqlmodel import SQLModel
SQLModel.metadata.create_all(self.engine)
def add(self, c: Contribution) -> None:
from sqlmodel import Session
with Session(self.engine) as session:
session.add(c)
session.commit()
def list_for_target(self, target_id: str) -> list[Contribution]:
from sqlmodel import Session, select
with Session(self.engine) as session:
statement = select(Contribution).where(Contribution.target_id == target_id)
return list(session.exec(statement).all())
def get_by_contributor(self, target_id: str, contributor_id: str) -> Contribution | None:
from sqlmodel import Session, select
with Session(self.engine) as session:
statement = select(Contribution).where(
Contribution.target_id == target_id,
Contribution.contributor_id == contributor_id,
)
return session.exec(statement).first()
def target_ids_with_contributions(self) -> list[str]:
from sqlmodel import Session, select
with Session(self.engine) as session:
rows = session.exec(select(Contribution.target_id).distinct()).all()
return list(rows)
def _all_serialized(self) -> list[dict]:
from sqlmodel import Session, select
with Session(self.engine) as session:
statement = select(Contribution)
contributions = session.exec(statement).all()
out = []
for c in contributions:
out.append({
"id": c.id,
"target_id": c.target_id,
"contributor_id": c.contributor_id,
"probability": c.probability,
"argument": c.argument,
"evidence_urls": c.evidence_urls,
"created_at": c.created_at.isoformat() if isinstance(c.created_at, datetime) else c.created_at,
"outcome": c.outcome,
"brier": c.brier
})
return out
def resolve_target(self, target_id: str, outcome: bool) -> int:
from sqlmodel import Session, select
with Session(self.engine) as session:
statement = select(Contribution).where(
Contribution.target_id == target_id,
Contribution.outcome == None
)
contributions = session.exec(statement).all()
n = 0
for c in contributions:
c.outcome = outcome
c.brier = (c.probability - (1.0 if outcome else 0.0)) ** 2
session.add(c)
n += 1
session.commit()
return n
def contributor_skill(self, contributor_id: str, min_history: int = 3) -> float:
"""Map a contributor's Brier history to a skill factor in [0,1].
Cold-start contributors get a neutral 0.5 (exploration); skill is only
sharpened once they have a track record (exploitation)."""
from sqlmodel import Session, select
with Session(self.engine) as session:
statement = select(Contribution).where(
Contribution.contributor_id == contributor_id,
Contribution.brier != None
)
contributions = session.exec(statement).all()
briers = [c.brier for c in contributions]
if len(briers) < min_history:
return 0.5
mean_b = sum(briers) / len(briers)
return max(0.0, min(1.0, 1.0 - mean_b / 0.25))
def skill_fn(self) -> Callable[[str], float]:
return self.contributor_skill
def log_trace(result: GateResult, path: str | Path = "data/crowd_traces.jsonl") -> None:
p = Path(path)
p.parent.mkdir(parents=True, exist_ok=True)
with p.open("a") as f:
f.write(json.dumps({
"ts": datetime.now(timezone.utc).isoformat(),
"target_id": result.target_id,
"prior": result.prior_probability,
"aggregate": result.aggregate_probability,
"selected": result.selected,
"decisions": [asdict(d) for d in result.decisions],
}) + "\n")