GitHub Actions
feat: Phase 2 (M14-M25, X05-X07) + Phase 3 experimental (M26-M31) + E2E tests + docs
4cd8837
Raw
History Blame
4.53 kB
"""M30 — Evidence Graph & EBKH Integration (experimental, Phase 3).
Content-addressed claim graph alongside the event log.
Events record what happened; claims record what is believed and by whom.
Gated by config.research.evidence_graph = True.
"""
from __future__ import annotations
import hashlib
import json
import time
import uuid
from dataclasses import dataclass, field
from typing import Any, NewType
ClaimID = NewType("ClaimID", str)
SourceID = NewType("SourceID", str)
@dataclass(frozen=True)
class ClaimSource:
source_id: SourceID
source_type: str # "event" | "external" | "ebkh" | "manual"
url: str | None = None
retrieved_at: float | None = None
reliability_score: float = 1.0
@dataclass(frozen=True)
class Claim:
"""An assertion by a node about some fact, with provenance."""
claim_id: ClaimID
subject: str # what the claim is about (URI or free text)
predicate: str # what is being claimed
object_: str # the claimed value
asserted_by: str # NodeID of the asserting node
sources: tuple[ClaimSource, ...]
community_id: str
asserted_at: float = field(default_factory=time.time)
confidence: float = 1.0
signature: bytes = b""
def content_id(self) -> ClaimID:
"""Stable content-addressed ID based on subject/predicate/object."""
payload = f"{self.subject}\x00{self.predicate}\x00{self.object_}"
return ClaimID("claim:" + hashlib.sha256(payload.encode()).hexdigest()[:16])
@dataclass(frozen=True)
class Attestation:
"""A second node vouches for a claim."""
claim_id: ClaimID
attested_by: str
attested_at: float = field(default_factory=time.time)
signature: bytes = b""
@dataclass(frozen=True)
class Dispute:
"""A node disputes a claim."""
claim_id: ClaimID
disputed_by: str
reason: str
disputed_at: float = field(default_factory=time.time)
counter_claim_id: ClaimID | None = None
class ClaimStore:
"""Append-only content-addressed claim store (in-memory prototype).
Production implementation will use a Merkle-DAG store backed by SQLite.
EBKH adapter (PostGIS + OSINT) plugs in via the `import_ebkh_record` method.
"""
def __init__(self) -> None:
self._claims: dict[ClaimID, Claim] = {}
self._attestations: dict[ClaimID, list[Attestation]] = {}
self._disputes: dict[ClaimID, list[Dispute]] = {}
def add_claim(self, claim: Claim) -> ClaimID:
cid = claim.content_id()
if cid not in self._claims:
self._claims[cid] = claim
return cid
def attest(self, attestation: Attestation) -> None:
self._attestations.setdefault(attestation.claim_id, []).append(attestation)
def dispute(self, dispute: Dispute) -> None:
self._disputes.setdefault(dispute.claim_id, []).append(dispute)
def get_claim(self, claim_id: ClaimID) -> Claim | None:
return self._claims.get(claim_id)
def find_by_subject(self, subject: str) -> list[Claim]:
return [c for c in self._claims.values() if c.subject == subject]
def attestation_count(self, claim_id: ClaimID) -> int:
return len(self._attestations.get(claim_id, []))
def is_disputed(self, claim_id: ClaimID) -> bool:
return bool(self._disputes.get(claim_id))
def import_ebkh_record(self, record: dict[str, Any], asserted_by: str, community_id: str) -> ClaimID:
"""Import a record from Christof's EBKH system as a Claim.
Expects record to have at minimum: subject, predicate, object, source_url.
"""
source = ClaimSource(
source_id=SourceID(record.get("ebkh_id", str(uuid.uuid4()))),
source_type="ebkh",
url=record.get("source_url"),
reliability_score=float(record.get("reliability", 1.0)),
)
claim = Claim(
claim_id=ClaimID(str(uuid.uuid4())),
subject=str(record.get("subject", "")),
predicate=str(record.get("predicate", "asserts")),
object_=str(record.get("object", "")),
asserted_by=asserted_by,
sources=(source,),
community_id=community_id,
)
return self.add_claim(claim)
def summary(self) -> dict:
return {
"claims": len(self._claims),
"attestations": sum(len(v) for v in self._attestations.values()),
"disputes": sum(len(v) for v in self._disputes.values()),
}