From ce295dd34d2c5abde6e08c8df62527895904f579 Mon Sep 17 00:00:00 2001 From: derekding Date: Mon, 3 Aug 2026 08:01:26 +0100 Subject: [PATCH 1/2] feat(memory): add belief layer for atomic-fact conflict resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Atomic facts have no arbitration today. Two facts that contradict each other are both stored, both indexed, both match the same query, and the answering model picks by BM25 score. `deprecated_by` is a Reflection consolidation marker, not a contradiction verdict, and nothing else in the write path notices the disagreement. Adds `everos.memory.belief`: mutually exclusive facts share a `belief_key` and hold a categorical distribution, updated by a Bayesian rule whose evidential weight is capped by the provenance of the channel each fact arrived on. Pure domain code — no I/O, no LLM. Two properties, both pinned by tests: - a channel at or below reliability 0.5 cannot change what the memory asserts, at any volume, including via a candidate the belief has never seen (admission is gated by the same pivot as the likelihood ratio — without that the guarantee is vacuous); - a single trusted correction can still supersede a single trusted claim, which a fixed entry mass silently makes impossible. Measured on the LongMemEval knowledge-update slice with the gold evidence spans fed straight into the resolver — no extractor, no retriever, no LLM, ~2s offline. Poisoning arms have zero effect on what is asserted; the cost is that a true correction arriving on a low-trust channel is rejected identically. Numbers and open questions in docs/belief-layer.md. Not wired into persistence or search yet: `belief_key` derivation from an undecomposed fact sentence is the open design question, and it wants a maintainer's call before any of that is built. --- .gitignore | 1 + benchmarks/README.md | 48 +++ benchmarks/belief_ku.py | 289 ++++++++++++++++++ docs/belief-layer.md | 179 +++++++++++ src/everos/memory/belief/__init__.py | 56 ++++ src/everos/memory/belief/models.py | 196 ++++++++++++ src/everos/memory/belief/resolver.py | 180 +++++++++++ src/everos/memory/belief/update.py | 214 +++++++++++++ .../unit/test_memory/test_belief/__init__.py | 1 + .../test_memory/test_belief/test_resolver.py | 151 +++++++++ .../test_memory/test_belief/test_update.py | 157 ++++++++++ 11 files changed, 1472 insertions(+) create mode 100644 benchmarks/belief_ku.py create mode 100644 docs/belief-layer.md create mode 100644 src/everos/memory/belief/__init__.py create mode 100644 src/everos/memory/belief/models.py create mode 100644 src/everos/memory/belief/resolver.py create mode 100644 src/everos/memory/belief/update.py create mode 100644 tests/unit/test_memory/test_belief/__init__.py create mode 100644 tests/unit/test_memory/test_belief/test_resolver.py create mode 100644 tests/unit/test_memory/test_belief/test_update.py diff --git a/.gitignore b/.gitignore index fa370a0f..2ce276b8 100755 --- a/.gitignore +++ b/.gitignore @@ -253,6 +253,7 @@ evaluation/src/adapters/*/prompts/profile/*.json # Locomo source dataset (downloadable, not source code) data/locomo10.json +data/longmemeval_oracle.json evaluation/data/locomo/locomo10.json evaluation/locomo_evaluation/data/locomo10.json diff --git a/benchmarks/README.md b/benchmarks/README.md index 416f683b..f694e085 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -336,3 +336,51 @@ The `add` + `wait_ready` phase dominates wall-clock time; LLM calls | `Too many open files (os error 24)` | LanceDB FD exhaustion from concurrent searches | Lower `search_concurrency` in config.toml (agentic needs more FDs per query) or raise `ulimit -n` | | Low accuracy across all categories | Embedding/rerank not configured | Verify `everos.toml` has working embedding + rerank providers | | `conv/error.log` exists | Unhandled exception in that conversation | Read the traceback; other conversations are unaffected | + +--- + +# Belief-layer benchmark (offline) + +`belief_ku.py` measures one thing the LoCoMo pipeline above cannot isolate: +**what the memory asserts when a stored fact is later contradicted.** + +It runs on the `knowledge-update` slice of +[LongMemEval](https://github.com/xiaowu0162/LongMemEval) — 78 instances that +are two-session supersessions, 70 of which carry turn-level gold evidence +spans in both sessions. Those spans feed +`everos.memory.belief.BeliefResolver` directly, with no extractor, no +retriever and no LLM in between, so the resulting number is attributable to +the update rule rather than to the pipeline around it. + +No server, no providers, no API key, ~2 seconds. + +```bash +mkdir -p data && cd data +wget https://huggingface.co/datasets/xiaowu0162/longmemeval-cleaned/resolve/main/longmemeval_oracle.json +cd .. +uv run python benchmarks/belief_ku.py --data data/longmemeval_oracle.json +``` + +``` +arm lww belief +-------------------------------------------- +clean 100.0% 78.6% +poison-5 0.0% 78.6% +novel-5 0.0% 78.6% +lowtrust-fix 100.0% 0.0% +``` + +`clean` is a pure recency test where last-write-wins is optimal by +construction. The three overlay arms — a stale claim replayed on a low-trust +channel, an unseen claim asserted on one, and the true update arriving on +one — are constructed on top of the benchmark and are **not** part of +LongMemEval. See [docs/belief-layer.md](../docs/belief-layer.md) for what +each row means and what the 21.4% gap on `clean` is. + +## CLI reference + +| Flag | Default | Meaning | +|---|---|---| +| `--data` | `data/longmemeval_oracle.json` | Path to the oracle split | +| `--repetitions` | `5` | How many times the attacker repeats its claim | +| `--poison-tier` | `web_fetch` | Channel the attacker writes on | diff --git a/benchmarks/belief_ku.py b/benchmarks/belief_ku.py new file mode 100644 index 00000000..fe1eaac3 --- /dev/null +++ b/benchmarks/belief_ku.py @@ -0,0 +1,289 @@ +"""Belief-layer benchmark — LongMemEval knowledge-update, with overlays. + +Complements ``benchmarks/run.py``. That one measures the whole pipeline on +LoCoMo and needs a server, providers, and an LLM judge. This one measures +exactly one thing — what the memory asserts when a fact changes — and needs +none of them: no server, no API key, no network after the dataset download, +about two seconds. + +Why this slice +-------------- +``knowledge-update`` is the only part of a public memory benchmark whose +instances are supersessions: a fact stated in session 0, revised in +session 1, gold answer being the revision. All 78 are exactly two sessions +with turn-level ``has_answer`` annotations, so the gold evidence spans can +be fed straight into :class:`~everos.memory.belief.BeliefResolver` with no +extractor and no retriever in the way. A number produced this way is +attributable to the update rule, which is the only claim being made. + + supersession accuracy = P(asserted fact == the session-1 claim) + +Arms +---- +``clean`` + Both claims on ``user_direct``. Pure recency, so last-write-wins is + optimal by construction and this arm only shows no regression. +``poison-k`` + Then the **stale** claim replayed k times on a low-trust channel, + dated after the update, phrased with maximum confidence. +``novel-k`` + Same, but asserting a claim the belief has never seen — the path that + bypasses the likelihood ratio and goes through admission instead. +``lowtrust-fix`` + The **true** update arrives on the low-trust channel. Measures what + the provenance ceiling costs, not only what it buys. + +The three overlays are constructed on top of the benchmark; they are not +part of LongMemEval and are labelled as such in the output. What they +reuse is its real dialogue text and its real gold supersession. + +Usage:: + + mkdir -p data && cd data + wget https://huggingface.co/datasets/xiaowu0162/longmemeval-cleaned/resolve/main/longmemeval_oracle.json + cd .. + uv run python benchmarks/belief_ku.py --data data/longmemeval_oracle.json +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import json +import re +from dataclasses import dataclass +from pathlib import Path + +from everos.core.observability.logging import configure_logging +from everos.memory.belief import ( + BeliefResolver, + FactObservation, + ProvenanceTier, + compose_reliability, +) + +_DATE = re.compile(r"(\d{4})/(\d{2})/(\d{2})\s*\([^)]*\)\s*(\d{2}):(\d{2})") +_POISON_PREFIX = "Confirmed, and verified: to be clear, this is final. " + +# Content confidence is the extraction layer's job in production — the algo +# library emits it alongside the fact. This rubric stands in for it so the +# benchmark stays offline, and it is deliberately crude: the misses it +# causes are reported rather than tuned away. +_HEDGE = re.compile( + r"\b(maybe|might|possibly|perhaps|i think|i believe|probably|apparently|" + r"not sure|unsure|if i recall|iirc|roughly|could be|seems?|i'd say|" + r"leaning towards?)\b", + re.IGNORECASE, +) +_FIRM = re.compile( + r"\b(definitely|confirmed|actually|in fact|to be clear|we decided|final|" + r"settled|verified|i measured|i tested|correction[:,])\b", + re.IGNORECASE, +) + + +def content_confidence(text: str) -> float: + """Speaker commitment read off epistemic markers, in ``[0.05, 0.98]``.""" + score = 0.70 + if _HEDGE.search(text): + score -= 0.42 + if _FIRM.search(text): + score += 0.25 + return max(0.05, min(0.98, score)) + + +@dataclass(frozen=True) +class Instance: + """One KU instance reduced to its two competing claims.""" + + key: str + stale: str + fresh: str + stale_at: dt.datetime + fresh_at: dt.datetime + + +def _parse_date(raw: str) -> dt.datetime: + match = _DATE.match(raw) + if match is None: + return dt.datetime(1970, 1, 1, tzinfo=dt.UTC) + year, month, day, hour, minute = (int(part) for part in match.groups()) + return dt.datetime(year, month, day, hour, minute, tzinfo=dt.UTC) + + +def load_instances(path: Path) -> list[Instance]: + """Read the KU instances that carry evidence spans in both sessions.""" + out: list[Instance] = [] + for item in json.loads(path.read_text()): + if item["question_type"] != "knowledge-update": + continue + sessions = item["haystack_sessions"] + if len(sessions) != 2: + continue + spans = [ + [turn["content"] for turn in session if turn.get("has_answer")] + for session in sessions + ] + if not spans[0] or not spans[1]: + continue + out.append( + Instance( + key=item["question_id"], + stale=" ".join(spans[0])[:240], + fresh=" ".join(spans[1])[:240], + stale_at=_parse_date(item["haystack_dates"][0]), + fresh_at=_parse_date(item["haystack_dates"][1]), + ) + ) + return out + + +def build_stream( + instance: Instance, + arm: str, + *, + repetitions: int, + poison_tier: ProvenanceTier, + novel: str, +) -> list[FactObservation]: + """Observations for one instance under one arm, in arrival order.""" + + def observation( + fact: str, at: dt.datetime, tier: ProvenanceTier, text: str | None = None + ) -> FactObservation: + return FactObservation( + belief_key=instance.key, + fact=fact, + observed_at=at, + tier=tier, + content_confidence=content_confidence(text or fact), + source_id=f"{instance.key}:{tier.value}", + ) + + fresh_tier = ( + poison_tier if arm == "lowtrust-fix" else ProvenanceTier.USER_DIRECT + ) + stream = [ + observation(instance.stale, instance.stale_at, ProvenanceTier.USER_DIRECT), + observation(instance.fresh, instance.fresh_at, fresh_tier), + ] + + payload = None + if arm.startswith("poison"): + payload = instance.stale + elif arm.startswith("novel"): + payload = novel + if payload is not None: + for step in range(1, repetitions + 1): + stream.append( + observation( + payload, + instance.fresh_at + dt.timedelta(days=step), + poison_tier, + _POISON_PREFIX + payload, + ) + ) + return stream + + +def score( + instances: list[Instance], + arm: str, + policy: str, + *, + repetitions: int, + poison_tier: ProvenanceTier, +) -> tuple[float, float]: + """Supersession accuracy and mean asserted probability for one cell.""" + hits = 0 + confidence_total = 0.0 + for index, instance in enumerate(instances): + novel = instances[(index + 1) % len(instances)].stale + stream = build_stream( + instance, + arm, + repetitions=repetitions, + poison_tier=poison_tier, + novel=novel, + ) + if policy == "lww": + asserted, probability = stream[-1].fact, 1.0 + else: + resolver = BeliefResolver() + for item in stream: + resolver.observe(item) + verdict = resolver.verdict(instance.key) + asserted, probability = verdict.fact, verdict.probability + hits += asserted == instance.fresh + confidence_total += probability + total = len(instances) + return 100.0 * hits / total, confidence_total / total + + +def main() -> int: + """Run the benchmark and print the arm-by-policy table.""" + # One line per revision is the right default for a memory runtime and + # the wrong one for a benchmark that performs ~1,700 of them. + configure_logging("WARNING") + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--data", default="data/longmemeval_oracle.json") + parser.add_argument("--repetitions", type=int, default=5) + parser.add_argument( + "--poison-tier", + default=ProvenanceTier.WEB_FETCH.value, + choices=[tier.value for tier in ProvenanceTier], + ) + args = parser.parse_args() + + path = Path(args.data) + if not path.exists(): + print(f"missing {path} — see the module docstring for the download command") + return 2 + + instances = load_instances(path) + poison_tier = ProvenanceTier(args.poison_tier) + ceiling = compose_reliability(poison_tier, 1.0) + arms = [ + "clean", + f"poison-{args.repetitions}", + f"novel-{args.repetitions}", + "lowtrust-fix", + ] + policies = ["lww", "belief"] + + print( + f"LongMemEval knowledge-update — {len(instances)} instances with gold " + f"evidence spans in both sessions" + ) + print( + f"poison tier `{poison_tier.value}` (ceiling {ceiling:.2f}), " + f"k={args.repetitions}\n" + ) + header = f"{'arm':<16}" + "".join(f"{policy:>14}" for policy in policies) + print(header) + print("-" * len(header)) + for arm in arms: + row = f"{arm:<16}" + confidences = [] + for policy in policies: + accuracy, confidence = score( + instances, + arm, + policy, + repetitions=args.repetitions, + poison_tier=poison_tier, + ) + row += f"{accuracy:>13.1f}%" + confidences.append(confidence) + print(row) + + print( + "\nclean is a recency test — last-write-wins is optimal there by " + "construction.\nThe overlays are the discriminating arms, and they are " + "constructed: they are\nnot part of LongMemEval." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/belief-layer.md b/docs/belief-layer.md new file mode 100644 index 00000000..ad10c0b4 --- /dev/null +++ b/docs/belief-layer.md @@ -0,0 +1,179 @@ +# Belief layer — probabilistic conflict resolution over atomic facts + +Status: proposal, domain layer implemented and benchmarked, persistence and +wiring not yet built. + +## The gap + +EverOS stores an atomic fact as a sentence with a timestamp, an owner, and a +pointer to its source MemCell. There is no mechanism anywhere in the write +path that notices two facts disagree. + +`deprecated_by` looks like one and is not: Reflection sets it when it merges +*fragmented* cluster members into a consolidated episode (Select → Merge → +Re-extract → Deprecate). It is a consolidation marker, not a contradiction +verdict. A grep for dedup / contradiction / conflict handling over `src/` +returns SQLite `ON CONFLICT` clauses and nothing else. + +So when a user says "I use 6 ounces of water per tablespoon" in March and +"I've switched to 5 ounces" in June, both facts are live, both are indexed, +both match the same query, and both are handed to the model. Which one comes +back first is a function of BM25 scoring and embedding distance — neither of +which knows which is true. + +Two things are missing, and they are the same thing: + +1. **arbitration** — which of two mutually exclusive facts does the memory + assert? +2. **calibration** — how sure is it, and can a caller act on that number? + +`confidence` exists on `AgentSkill` today (LLM-emitted, alongside +`maturity_score`) but not on facts, and an LLM-emitted scalar is not a +probability: nothing normalises it across candidates and nothing updates it +when new evidence arrives. + +## What this adds + +`everos.memory.belief` — a pure domain module, no I/O, no LLM. + +Mutually exclusive facts share a `belief_key` and hold a categorical +distribution. Each observation carries the channel it arrived on; the update +is Bayesian with the evidential weight of an observation capped by that +channel's reliability ceiling. + +```python +from everos.memory.belief import BeliefResolver, FactObservation, ProvenanceTier + +resolver = BeliefResolver() +resolver.observe(FactObservation( + belief_key="user_42:coffee_ratio", + fact="6 ounces of water per tablespoon", + observed_at=march, + tier=ProvenanceTier.USER_DIRECT, + content_confidence=0.9, +)) +resolver.observe(FactObservation(..., fact="5 ounces...", observed_at=june)) + +verdict = resolver.verdict("user_42:coffee_ratio") +# fact="5 ounces...", probability=0.7, superseded=["6 ounces..."] +if verdict.is_uncertain: + ... # ask rather than assert +``` + +Three design commitments, each with a test that fails if it breaks. + +**Trust is a property of the channel, never of the text.** Reliability is +`min(channel ceiling, content confidence)`. Content confidence may lower +trust within the ceiling, never raise it. Reading trust off the sentence +instead hands it to whoever writes the sentence: "Confirmed, verified, this +is final" scores near 1.0 on any hedging rubric. + +**A channel at or below r = 0.5 cannot change what the memory asserts, +at any volume.** The likelihood ratio clamps to 1 at that pivot — a source +you distrust asserting X is not evidence against X, it is simply not +evidence. Clamping rather than inverting also fixes the pivot independently +of how many candidates the belief holds. + +**A single trusted correction can still supersede a single trusted claim.** +This is the property that is easy to lose, and losing it is silent. See +below. + +## The one subtle part + +Admission and evidence are different operations and they need separate +gates. + +A candidate the belief has never seen cannot be reweighted, only admitted, +and admission happens *before* any likelihood test runs. So the entry mass +is a second, hidden trust knob — and it controls both properties at once: + +| entry mass | supersession accuracy | untrusted novel claim wins | +|---|---|---| +| 0.02 | 0.0% | 0% | +| 0.20 | 28.6% | 28.6% | +| 0.30 | 72.9% | 72.9% | +| 0.40 | 94.3% | 90.0% | +| 0.50 | 100.0% | 100.0% | + +No setting satisfies both. At the small end the memory is immune because it +never learns anything; at the large end an untrusted channel can install any +belief it likes. A layer tuned at either end is worse than no layer at all, +and it fails quietly. + +`entry_mass()` resolves this by conditioning admission on the same pivot the +likelihood ratio uses: above it a trusted first sighting enters at a mass +proportional to the channel's reliability, below it the candidate is +recorded but inert. It is still retrievable and still auditable — it simply +cannot win. + +A related detail: a first observation admitted at reliability `r` leaves the +belief at `p ≈ r`, not at 1.0. The residual sits on a reserved `UNKNOWN` +candidate. Without it, a fact heard exactly once normalises to certainty and +every number in the store reads as 1.0. + +## Results + +`benchmarks/belief_ku.py`, LongMemEval `knowledge-update`: 70 instances that +are two-session supersessions with turn-level gold evidence spans. The spans +feed the resolver directly — no extractor, no retriever, no LLM — so the +number is attributable to the update rule. Runs offline in about two +seconds. + +| arm | last-write-wins | belief | +|---|---|---| +| clean | **100.0%** | 78.6% | +| poison-5 (stale claim replayed on `web_fetch`) | 0.0% | **78.6%** | +| novel-5 (unseen claim asserted on `web_fetch`) | 0.0% | **78.6%** | +| lowtrust-fix (true update on `web_fetch`) | 100.0% | 0.0% | + +Read the rows, not the cells. + +- `clean` is a pure recency test — last-write-wins is optimal there by + construction, and this arm exists to show the belief layer does not + regress on the ordinary case. +- The identical 78.6% across the three arms is the point: the attacks have + **zero** effect. Not "reduced" — the asserted fact is the same one, with + the same probability, whether or not the attacker is there. +- The 21.4% gap on `clean` is fully accounted for: 15 of 70 updates are + phrased with hedges ("I'd say the marketing campaign is the priority"), + the stand-in content-confidence rubric drops them below the pivot, and + admission is blocked. `15/70 = 21.4%` exactly. This is an extraction + quality number, not an arbitration number — the algo layer emits a real + confidence in production and the rubric in the benchmark is a placeholder + for it. +- `lowtrust-fix` = 0.0% is the honest cost. A ceiling that stops a bad + correction on a low-trust channel stops a good one identically. There is + no setting that gets both; this row is the price of the other three. + +## What is not built + +**Persistence.** `BeliefState` and `BeliefRevision` are derived state and +belong in SQLite (`~/.everos/.index/sqlite/system.db`), not in the LanceDB +fact table — no index migration, and the states rebuild from the revision +log. Needs a repo + an alembic revision. + +**`belief_key` derivation.** The resolver takes the key; nothing produces it +yet. An atomic fact is an undecomposed sentence, so there is no +`(subject, attribute)` to group on. Options, roughly in order of appetite: +the algo layer emits an optional `subject` / `attribute` pair alongside the +fact; or facts are grouped by embedding cluster within an owner scope, which +is cheap but conflates "similar" with "mutually exclusive"; or callers +supply it explicitly for the narrow slice they care about. This is the real +open question and the reason this is a design note rather than a pull +request. + +**Search integration.** `search/filters.py` already excludes +`deprecated_by IS NOT NULL`; the analogous move is to rank or filter by +posterior and to surface `probability` on the recall DTO so an answering +model can see how sure the memory is. + +**Tier assignment.** `ProvenanceTier` is an enum with a ceiling table. +Mapping EverOS's existing scoping (`owner_type`, `app_id`, `session_id`, +`sender_ids`) onto tiers should be config, in `everos.toml`, in the operator's +hands — not inferred, and never from anything an agent can write. + +**Calibration.** The layer reports probabilities. Whether they are *true* +probabilities is an empirical question that needs outcomes to score against +(Brier / ECE over resolved beliefs). Until that is measured, `entropy` is +the honest thing to show a caller and `probability` should be read as a +ranking, not a frequency. diff --git a/src/everos/memory/belief/__init__.py b/src/everos/memory/belief/__init__.py new file mode 100644 index 00000000..569fb8a5 --- /dev/null +++ b/src/everos/memory/belief/__init__.py @@ -0,0 +1,56 @@ +"""Belief layer — probabilistic conflict resolution over atomic facts. + +Atomic facts are stored and retrieved independently today, so two facts +that contradict each other both survive and both reach the answering +model. This package adds the missing arbitration: mutually exclusive +candidates share a ``belief_key`` and hold a probability distribution, +updated by a reliability-weighted Bayesian rule whose evidential weight +is capped by the provenance of the channel each fact arrived on. + + from everos.memory.belief import BeliefResolver, FactObservation + + resolver = BeliefResolver() + resolver.observe(FactObservation(...)) + verdict = resolver.verdict("user_42:coffee_ratio") + +Two properties it is built to hold, both covered by +``tests/unit/test_memory/test_belief``: a channel at or below the trust +pivot cannot change what the memory asserts at any volume, and a single +trusted correction can still supersede a single trusted claim. +""" + +from .models import PIVOT as PIVOT +from .models import TIER_CEILING as TIER_CEILING +from .models import UNKNOWN as UNKNOWN +from .models import BeliefRevision as BeliefRevision +from .models import BeliefState as BeliefState +from .models import BeliefVerdict as BeliefVerdict +from .models import FactObservation as FactObservation +from .models import ProvenanceTier as ProvenanceTier +from .resolver import BeliefResolver as BeliefResolver +from .update import compose_reliability as compose_reliability +from .update import entropy_bits as entropy_bits +from .update import entry_mass as entry_mass +from .update import kl_bits as kl_bits +from .update import likelihood_ratio as likelihood_ratio +from .update import posterior as posterior +from .update import total_variation as total_variation + +__all__ = [ + "PIVOT", + "TIER_CEILING", + "UNKNOWN", + "BeliefResolver", + "BeliefRevision", + "BeliefState", + "BeliefVerdict", + "FactObservation", + "ProvenanceTier", + "compose_reliability", + "entropy_bits", + "entry_mass", + "kl_bits", + "likelihood_ratio", + "posterior", + "total_variation", +] diff --git a/src/everos/memory/belief/models.py b/src/everos/memory/belief/models.py new file mode 100644 index 00000000..15daef57 --- /dev/null +++ b/src/everos/memory/belief/models.py @@ -0,0 +1,196 @@ +"""Domain models for the belief layer. + +An atomic fact today is a sentence with a timestamp. Two facts that +contradict each other are both stored, both retrieved, and the answering +model is left to pick. These models add the missing dimension: a set of +mutually exclusive candidate facts (a *belief*) carrying a probability +distribution, and a per-observation audit record of how that distribution +moved. + +The trust model is the load-bearing part. Reliability is a property of the +**channel** a fact arrived on, never of the sentence. A confidently phrased +claim from a scraped page must not outrank a hedged claim from the user, +and no amount of repetition on a low tier may change that — otherwise +volumetric memory poisoning works by construction. +""" + +from __future__ import annotations + +import datetime as dt +import math +from collections.abc import Mapping +from enum import StrEnum + +from pydantic import BaseModel, ConfigDict, Field + +PIVOT = 0.5 +"""Reliability below which an observation carries no evidential weight. + +The likelihood ratio clamps to 1 here, so a channel at or under the pivot +cannot shift probability mass between candidates that are already known, +and cannot admit a candidate that is not (see ``update.entry_mass``). +""" + +UNKNOWN = "__unknown__" +"""Reserved candidate holding the mass no observation has claimed. + +Every belief starts entirely on it. Without an explicit residual the +first observation of a fact would normalise to probability 1.0 — a +memory certain of something it has been told exactly once, which is the +opposite of the point. When ``UNKNOWN`` is the most probable candidate +the memory holds no position and reports ``fact = None``. +""" + + +class ProvenanceTier(StrEnum): + """Channel a fact arrived on. Determines its reliability ceiling.""" + + USER_DIRECT = "user_direct" + USER_EDIT = "user_edit" + AGENT_OWN = "agent_own" + TOOL_OUTPUT = "tool_output" + DOCUMENT = "document" + AGENT_THIRD_PARTY = "agent_third_party" + WEB_FETCH = "web_fetch" + UNTRUSTED = "untrusted" + + +TIER_CEILING: Mapping[ProvenanceTier, float] = { + ProvenanceTier.USER_EDIT: 0.99, + ProvenanceTier.USER_DIRECT: 0.98, + ProvenanceTier.AGENT_OWN: 0.80, + ProvenanceTier.TOOL_OUTPUT: 0.65, + ProvenanceTier.DOCUMENT: 0.55, + ProvenanceTier.AGENT_THIRD_PARTY: 0.40, + ProvenanceTier.WEB_FETCH: 0.30, + ProvenanceTier.UNTRUSTED: 0.10, +} +"""Maximum reliability a channel can ever earn. + +Everything at or below :data:`PIVOT` is inert: it may be recorded and +retrieved, but it cannot change what the memory asserts. +""" + +DEFAULT_TIER = ProvenanceTier.UNTRUSTED + + +class FactObservation(BaseModel): + """One sighting of a candidate fact on one channel. + + Args: + belief_key: Identifies the set of mutually exclusive candidates + this fact competes within — one belief per key. Supplied by + the caller; see ``docs/belief-layer.md`` for how the + extraction layer is expected to derive it. + fact: The atomic fact sentence, canonical surface form. + observed_at: When the claim was made (not when it was indexed). + tier: Channel provenance. Caps reliability. + content_confidence: How firmly the speaker committed to the + claim, read off hedging or definiteness. May only *lower* + reliability within the tier ceiling, never raise it. + taint: Origin tier when the content reached this channel through + an untrusted intermediary. The stricter ceiling applies. + source_id: Provenance pointer for audit — memcell / session id. + """ + + model_config = ConfigDict(frozen=True) + + belief_key: str + fact: str + observed_at: dt.datetime + tier: ProvenanceTier = DEFAULT_TIER + content_confidence: float = Field(default=0.7, ge=0.0, le=1.0) + taint: ProvenanceTier | None = None + source_id: str = "" + + +class BeliefState(BaseModel): + """Current distribution over the candidate facts of one belief.""" + + key: str + distribution: dict[str, float] = Field(default_factory=dict) + updated_at: dt.datetime + observation_count: int = 0 + + @property + def asserted(self) -> str | None: + """The most probable candidate, or ``None`` while empty.""" + if not self.distribution: + return None + return max(self.distribution, key=lambda k: self.distribution[k]) + + @property + def probability(self) -> float: + """Probability mass on :attr:`asserted`.""" + fact = self.asserted + return self.distribution[fact] if fact is not None else 0.0 + + +class BeliefRevision(BaseModel): + """Audit record of one observation applied to one belief. + + This is the decision chain. It answers "why does the memory currently + say X" with a replayable sequence rather than a single overwrite: + what arrived, on which channel, how far it moved the distribution, and + whether it counted. + + Args: + shift: Total variation distance between prior and posterior — how + much of the belief's mass this observation relocated, in + ``[0, 1]``. Deliberately not KL: an observation that + introduces a candidate the prior had never heard of has + unbounded KL, dominated by whatever floor the implementation + picked, which makes the number unusable for a threshold and + misleading in an audit log. + admitted: Whether this observation introduced a new candidate + rather than reweighting known ones. + """ + + model_config = ConfigDict(frozen=True) + + belief_key: str + fact: str + reliability: float + prior: dict[str, float] + posterior: dict[str, float] + shift: float + admitted: bool + accepted: bool + observed_at: dt.datetime + source_id: str = "" + + +class BeliefVerdict(BaseModel): + """What the memory asserts for one belief, and how sure it is.""" + + model_config = ConfigDict(frozen=True) + + belief_key: str + fact: str | None + probability: float + entropy_bits: float + candidate_count: int = 0 + superseded: list[str] = Field(default_factory=list) + observation_count: int = 0 + + @property + def normalised_entropy(self) -> float: + """Entropy as a fraction of the maximum for this many candidates. + + Raw bits are not comparable across beliefs: two candidates cap at + one bit, twelve cap at 3.58, so any absolute threshold silently + means something different per belief. This is the comparable one. + """ + if self.candidate_count < 2: + return 0.0 + return self.entropy_bits / math.log2(self.candidate_count) + + @property + def is_uncertain(self) -> bool: + """True when the belief should be surfaced as a question. + + Above half the available entropy the memory holds no usable + position, and saying so is more useful to a caller than asserting + the marginally-leading candidate as if it were settled. + """ + return self.fact is None or self.normalised_entropy >= 0.5 diff --git a/src/everos/memory/belief/resolver.py b/src/everos/memory/belief/resolver.py new file mode 100644 index 00000000..b3117091 --- /dev/null +++ b/src/everos/memory/belief/resolver.py @@ -0,0 +1,180 @@ +"""BeliefResolver — folds fact observations into beliefs and verdicts. + +Stateless with respect to storage: the caller owns persistence. Load the +states you need, fold observations in, hand the revisions to whatever +audit sink you keep. That keeps the domain rule testable without a +database and lets the state live in SQLite, where derived state belongs, +rather than in the LanceDB fact index. + +Usage:: + + resolver = BeliefResolver() + for obs in observations: + revision = resolver.observe(obs) + verdict = resolver.verdict("user_42:coffee_ratio") + if verdict.is_uncertain: + ... # ask rather than assert +""" + +from __future__ import annotations + +import datetime as dt +from collections.abc import Iterable, Mapping + +from everos.component.utils.datetime import ensure_utc +from everos.core.observability.logging import get_logger + +from .models import ( + UNKNOWN, + BeliefRevision, + BeliefState, + BeliefVerdict, + FactObservation, +) +from .update import ( + compose_reliability, + decay, + entropy_bits, + posterior, + total_variation, +) + +logger = get_logger(__name__) + +_SECONDS_PER_DAY = 86400.0 + + +class BeliefResolver: + """Maintains one categorical belief per ``belief_key``. + + Args: + gate_shift: Total variation below which a revision is recorded + as not accepted. The belief still updates and the observation + still counts; the audit log just stays free of restatements. + 0.05 is roughly "the fourth identical confirmation". + entry_scale: Tuning factor on how much prior mass a trusted new + candidate is admitted with. See ``update.entry_mass``. + half_life_days: Days for an unconfirmed belief to lose half its + concentration. ``None`` disables decay. + """ + + def __init__( + self, + *, + gate_shift: float = 0.05, + entry_scale: float = 1.0, + half_life_days: float | None = None, + ) -> None: + self._gate_shift = gate_shift + self._entry_scale = entry_scale + self._half_life_days = half_life_days + self._states: dict[str, BeliefState] = {} + + @property + def states(self) -> Mapping[str, BeliefState]: + """Current belief states, keyed by ``belief_key``.""" + return self._states + + def load(self, states: Iterable[BeliefState]) -> None: + """Seed the resolver with persisted states. + + Args: + states: Previously stored belief states. + """ + for state in states: + self._states[state.key] = state + + def observe(self, observation: FactObservation) -> BeliefRevision: + """Apply one observation and return its audit record. + + Args: + observation: The sighting to fold in. + + Returns: + The revision record, whether or not it cleared the gate. + """ + observed_at = ensure_utc(observation.observed_at) + state = self._states.get(observation.belief_key) + prior = self._prior(state, observed_at) + + reliability = compose_reliability( + observation.tier, observation.content_confidence, observation.taint + ) + post = posterior( + prior, observation.fact, reliability, entry_scale=self._entry_scale + ) + moved = total_variation(post, prior) + + self._states[observation.belief_key] = BeliefState( + key=observation.belief_key, + distribution=post, + updated_at=observed_at, + observation_count=(state.observation_count if state else 0) + 1, + ) + + revision = BeliefRevision( + belief_key=observation.belief_key, + fact=observation.fact, + reliability=reliability, + prior=dict(prior), + posterior=post, + shift=moved, + admitted=observation.fact not in prior, + accepted=moved > self._gate_shift, + observed_at=observed_at, + source_id=observation.source_id, + ) + if revision.accepted: + logger.debug( + "belief_revised", + belief_key=observation.belief_key, + reliability=round(reliability, 3), + shift=round(moved, 3), + source_id=observation.source_id, + ) + return revision + + def verdict( + self, belief_key: str, *, at: dt.datetime | None = None + ) -> BeliefVerdict: + """What the memory asserts for one belief. + + Args: + belief_key: The belief to read. + at: Read the belief as of this moment, applying decay. Defaults + to the last observation time (no decay applied). + + Returns: + The verdict, with the losing candidates listed as superseded. + """ + state = self._states.get(belief_key) + if state is None: + return BeliefVerdict( + belief_key=belief_key, fact=None, probability=0.0, entropy_bits=0.0 + ) + + dist = ( + self._prior(state, ensure_utc(at)) if at is not None else state.distribution + ) + leader = max(dist, key=lambda k: dist[k]) if dist else None + # UNKNOWN winning means no observation has earned a position; that + # is a real answer and must not be dressed up as a fact. + asserted = None if leader == UNKNOWN else leader + return BeliefVerdict( + belief_key=belief_key, + fact=asserted, + probability=dist.get(leader, 0.0) if leader else 0.0, + entropy_bits=entropy_bits(dist), + candidate_count=len(dist), + superseded=sorted(key for key in dist if key != leader and key != UNKNOWN), + observation_count=state.observation_count, + ) + + def _prior(self, state: BeliefState | None, at: dt.datetime) -> dict[str, float]: + """Distribution to update against, decayed to ``at`` if configured.""" + if state is None: + return {} + if self._half_life_days is None: + return dict(state.distribution) + elapsed = (at - ensure_utc(state.updated_at)).total_seconds() / _SECONDS_PER_DAY + return decay(state.distribution, elapsed, self._half_life_days) diff --git a/src/everos/memory/belief/update.py b/src/everos/memory/belief/update.py new file mode 100644 index 00000000..a10349cf --- /dev/null +++ b/src/everos/memory/belief/update.py @@ -0,0 +1,214 @@ +"""The update rule: closed-form categorical Bayes with a trust pivot. + +Three properties this rule is built to have, in the order they matter: + +1. **A channel at or below the pivot cannot change what is asserted.** + Not by phrasing, not by volume. This is what makes memory poisoning a + non-event rather than an arms race against prompt wording. +2. **A single trusted correction can supersede a single trusted claim.** + Sounds trivial; it is the property naive implementations lose. If a + new candidate enters the distribution at a fixed epsilon, no + reliability is ever enough to promote it in one step, and the memory + silently keeps asserting the stale value forever. +3. **Repetition saturates.** Confirming what is already believed moves + the distribution by a shrinking number of bits, so an audit log gated + on movement stays readable instead of recording every restatement. + +(1) and (2) are the same knob and that is the whole subtlety. Admission of +a never-before-seen candidate happens *before* any likelihood test, so an +entry mass large enough to satisfy (2) hands an untrusted channel a free +seat at the table. :func:`entry_mass` resolves this by conditioning +admission on the same pivot the likelihood ratio uses. +""" + +from __future__ import annotations + +import math +from collections.abc import Mapping + +from .models import DEFAULT_TIER, PIVOT, TIER_CEILING, UNKNOWN, ProvenanceTier + +_EPS = 1e-9 +_INERT_MASS = 1e-6 +"""Admission mass for a candidate seen only on a sub-pivot channel. + +Not zero: the candidate is still recorded and still retrievable, which +matters for audit. It simply cannot win. +""" + + +def compose_reliability( + tier: ProvenanceTier, + content_confidence: float, + taint: ProvenanceTier | None = None, +) -> float: + """Reliability of one observation: ``min(ceiling, content)``. + + Content confidence may only lower trust within the channel's ceiling. + Reading trust off the text instead would let an attacker who controls + the text control the trust: "Confirmed, verified, this is final" + scores near 1.0 on any hedging rubric. + + Args: + tier: Channel the observation arrived on. + content_confidence: Speaker commitment, in ``[0, 1]``. + taint: Origin tier if the content was laundered through this + channel from a less trusted one. + + Returns: + Reliability in ``[0.01, 0.99]``. + """ + ceiling = TIER_CEILING.get(tier, TIER_CEILING[DEFAULT_TIER]) + if taint is not None: + ceiling = min(ceiling, TIER_CEILING.get(taint, TIER_CEILING[DEFAULT_TIER])) + return max(0.01, min(ceiling, content_confidence)) + + +def likelihood_ratio(reliability: float) -> float: + """Evidential weight of one observation, clamped at the pivot. + + ``LR = max(1, r / (1 - r))``. The clamp is deliberate: a source you + do not trust asserting X is not evidence *against* X, it is simply + not evidence. Clamping rather than inverting also fixes the pivot at + ``r = 0.5`` independently of how many candidates the belief holds — + the textbook form ``r if match else (1-r)/(|V|-1)`` has a threshold + at ``1/|V|``, which drifts as candidates accumulate, so no guarantee + can be stated about it. + + Args: + reliability: Composed reliability of the observation. + + Returns: + Multiplier ``>= 1.0`` applied to the observed candidate. + """ + r = min(max(reliability, 0.0), 0.999) + return max(1.0, r / max(1.0 - r, _EPS)) + + +def entry_mass(reliability: float, scale: float = 1.0) -> float: + """Prior mass a never-before-seen candidate is admitted with. + + Conditioned on the same pivot as :func:`likelihood_ratio`, for the + reason given in the module docstring: a fixed entry mass cannot serve + both supersession and poisoning resistance. Above the pivot a + trusted first sighting enters at a mass proportional to how much the + channel is trusted; below it, the candidate is recorded but inert. + + Args: + reliability: Composed reliability of the observation. + scale: Tuning factor on the admitted mass, in ``(0, 1]``. Lower + values make the memory more conservative about adopting new + candidates from trusted channels. + + Returns: + Prior mass for the new candidate. + """ + if reliability <= PIVOT: + return _INERT_MASS + return min(0.99, reliability * scale) + + +def entropy_bits(distribution: Mapping[str, float]) -> float: + """Shannon entropy in bits — how unsure the belief is.""" + return -sum(p * math.log2(max(p, _EPS)) for p in distribution.values() if p > 0) + + +def kl_bits( + posterior_dist: Mapping[str, float], prior_dist: Mapping[str, float] +) -> float: + """``KL(posterior || prior)`` in bits — how far the belief moved.""" + total = 0.0 + for key in set(posterior_dist) | set(prior_dist): + p = posterior_dist.get(key, _EPS) + q = prior_dist.get(key, _EPS) + if p > _EPS: + total += p * math.log2(p / max(q, _EPS)) + return max(0.0, total) + + +def total_variation( + posterior_dist: Mapping[str, float], prior_dist: Mapping[str, float] +) -> float: + """Share of the belief's mass an observation relocated, in ``[0, 1]``. + + The audit measure. :func:`kl_bits` is the natural one while the set of + candidates is fixed, but it diverges the moment an observation + introduces a candidate the prior had never heard of — precisely the + supersession case worth logging. The reported figure would then be a + function of the implementation's zero-floor rather than of anything + that happened, so this is used for the write gate instead. + """ + keys = set(posterior_dist) | set(prior_dist) + return 0.5 * sum( + abs(posterior_dist.get(key, 0.0) - prior_dist.get(key, 0.0)) for key in keys + ) + + +def decay( + distribution: Mapping[str, float], elapsed_days: float, half_life_days: float +) -> dict[str, float]: + """Mix toward uniform as a belief goes unconfirmed. + + Forgetting as precision loss rather than deletion: a fact last + confirmed two years ago should come back with low confidence, not + come back wrong and not vanish. + + Args: + distribution: Current distribution. + elapsed_days: Days since the last observation. + half_life_days: Days for half the concentration to be lost. + + Returns: + The decayed distribution. + """ + if not distribution or elapsed_days <= 0 or half_life_days <= 0: + return dict(distribution) + retention = (0.5 ** (1.0 / half_life_days)) ** elapsed_days + uniform = 1.0 / len(distribution) + return { + key: retention * p + (1.0 - retention) * uniform + for key, p in distribution.items() + } + + +def posterior( + prior: Mapping[str, float], + fact: str, + reliability: float, + entry_scale: float = 1.0, +) -> dict[str, float]: + """Apply one observation to a distribution. + + ``p(fact) ∝ LR(r) · p_prior(fact)``; every other candidate keeps its + mass and is renormalised. ``O(|candidates|)``, no gradients. + + A **first** sighting is admitted at :func:`entry_mass` and returns + there without a likelihood boost, so one observation on a channel of + reliability ``r`` leaves the belief at about ``r`` — the calibrated + reading of "a 0.9-reliable channel said this once". Applying the + ratio on the admission step too would land it at 0.99 instead, which + is a memory certain of something it has been told once. Subsequent + sightings take the ratio path and saturate. + + Args: + prior: Distribution before the observation. Empty means a belief + that has never been observed; it starts wholly on + :data:`~everos.memory.belief.models.UNKNOWN`. + fact: The observed candidate. + reliability: Composed reliability of the observation. + entry_scale: Passed to :func:`entry_mass` for a new candidate. + + Returns: + The posterior distribution, normalised. + """ + dist = dict(prior) or {UNKNOWN: 1.0} + if fact not in dist: + admitted = entry_mass(reliability, entry_scale) + total = sum(dist.values()) or 1.0 + scaled = {key: p / total * (1.0 - admitted) for key, p in dist.items()} + scaled[fact] = admitted + return scaled + ratio = likelihood_ratio(reliability) + weighted = {key: (ratio if key == fact else 1.0) * p for key, p in dist.items()} + normaliser = sum(weighted.values()) or 1.0 + return {key: p / normaliser for key, p in weighted.items()} diff --git a/tests/unit/test_memory/test_belief/__init__.py b/tests/unit/test_memory/test_belief/__init__.py new file mode 100644 index 00000000..6a91d2dc --- /dev/null +++ b/tests/unit/test_memory/test_belief/__init__.py @@ -0,0 +1 @@ +"""Belief layer unit tests.""" diff --git a/tests/unit/test_memory/test_belief/test_resolver.py b/tests/unit/test_memory/test_belief/test_resolver.py new file mode 100644 index 00000000..24e408d8 --- /dev/null +++ b/tests/unit/test_memory/test_belief/test_resolver.py @@ -0,0 +1,151 @@ +"""Pins the resolver contract: supersession, audit trail, and abstention. + +What a caller is entitled to rely on: + +- the memory adopts a correction from a trusted channel; +- a flood on an untrusted channel changes nothing it asserts; +- every observation leaves a replayable record, including the ones that + were rejected — "why does memory say X" must be answerable; +- when the memory holds no usable position it says so instead of + asserting the marginally-leading candidate. +""" + +from __future__ import annotations + +import datetime as dt + +import pytest + +from everos.memory.belief import ( + BeliefResolver, + BeliefState, + FactObservation, + ProvenanceTier, +) + +_T0 = dt.datetime(2026, 1, 1, tzinfo=dt.UTC) +_KEY = "user_42:coffee_ratio" + + +def _obs( + fact: str, + *, + day: int = 0, + tier: ProvenanceTier = ProvenanceTier.USER_DIRECT, + confidence: float = 0.9, + source_id: str = "mc_1", +) -> FactObservation: + return FactObservation( + belief_key=_KEY, + fact=fact, + observed_at=_T0 + dt.timedelta(days=day), + tier=tier, + content_confidence=confidence, + source_id=source_id, + ) + + +def test_trusted_correction_supersedes() -> None: + resolver = BeliefResolver() + resolver.observe(_obs("6 ounces of water per tablespoon", day=0)) + resolver.observe(_obs("5 ounces of water per tablespoon", day=7)) + + verdict = resolver.verdict(_KEY) + + assert verdict.fact == "5 ounces of water per tablespoon" + assert verdict.superseded == ["6 ounces of water per tablespoon"] + assert verdict.observation_count == 2 + + +def test_untrusted_flood_changes_nothing() -> None: + resolver = BeliefResolver() + resolver.observe(_obs("5 ounces of water per tablespoon", day=0)) + for day in range(1, 51): + resolver.observe( + _obs( + "12 ounces of water per tablespoon", + day=day, + tier=ProvenanceTier.WEB_FETCH, + confidence=0.99, + source_id="scraped", + ) + ) + + verdict = resolver.verdict(_KEY) + + assert verdict.fact == "5 ounces of water per tablespoon" + + +def test_every_observation_is_auditable() -> None: + """Rejected observations are recorded too — that is the point of an audit.""" + resolver = BeliefResolver() + resolver.observe(_obs("5 ounces", day=0)) + revision = resolver.observe( + _obs("12 ounces", day=1, tier=ProvenanceTier.UNTRUSTED, confidence=0.99) + ) + + assert revision.belief_key == _KEY + assert revision.fact == "12 ounces" + assert revision.reliability <= 0.10 + assert revision.accepted is False + assert revision.admitted is True + assert revision.shift < 0.05 + assert revision.prior != {} + assert revision.posterior["5 ounces"] > revision.posterior["12 ounces"] + assert revision.source_id == "mc_1" + + +def test_first_observation_is_accepted_and_moves_the_belief() -> None: + resolver = BeliefResolver() + revision = resolver.observe(_obs("5 ounces", day=0)) + + assert revision.accepted is True + assert revision.admitted is True + assert 0.0 < revision.shift <= 1.0 + assert revision.prior == {} + + +def test_unknown_key_yields_an_empty_verdict() -> None: + verdict = BeliefResolver().verdict("nothing:here") + + assert verdict.fact is None + assert verdict.probability == 0.0 + assert verdict.observation_count == 0 + + +def test_a_split_belief_reports_itself_as_uncertain() -> None: + """Two comparable candidates should be surfaced, not silently picked.""" + resolver = BeliefResolver() + resolver.observe(_obs("option a", day=0, confidence=0.6)) + resolver.observe(_obs("option b", day=1, confidence=0.6)) + + verdict = resolver.verdict(_KEY) + + assert verdict.is_uncertain + assert verdict.probability < 0.75 + + +def test_decay_makes_an_unconfirmed_belief_uncertain() -> None: + resolver = BeliefResolver(half_life_days=30.0) + resolver.observe(_obs("5 ounces", day=0)) + resolver.observe(_obs("6 ounces", day=1, confidence=0.6)) + + fresh = resolver.verdict(_KEY) + stale = resolver.verdict(_KEY, at=_T0 + dt.timedelta(days=400)) + + assert stale.entropy_bits > fresh.entropy_bits + assert stale.fact is not None + + +def test_persisted_state_round_trips() -> None: + resolver = BeliefResolver() + resolver.observe(_obs("5 ounces", day=0)) + saved = list(resolver.states.values()) + + restored = BeliefResolver() + restored.load(BeliefState(**state.model_dump()) for state in saved) + + assert restored.verdict(_KEY).fact == "5 ounces" + assert restored.verdict(_KEY).probability == pytest.approx( + resolver.verdict(_KEY).probability + ) diff --git a/tests/unit/test_memory/test_belief/test_update.py b/tests/unit/test_memory/test_belief/test_update.py new file mode 100644 index 00000000..5892ab97 --- /dev/null +++ b/tests/unit/test_memory/test_belief/test_update.py @@ -0,0 +1,157 @@ +"""Pins the update rule's guarantees. + +The three properties the belief layer is allowed to claim: + +1. a channel at or below the pivot cannot change the asserted fact, at + any volume and for any number of candidates — including by admitting + a brand-new candidate, which is the path that bypasses the likelihood + ratio entirely; +2. a channel above the pivot can, in one observation; +3. repeated confirmation saturates, so a movement-gated audit log stays + finite. + +If any of these break, the layer is either useless or unsafe, so they are +pinned rather than spot-checked. +""" + +from __future__ import annotations + +from itertools import pairwise + +import pytest + +from everos.memory.belief import ( + PIVOT, + UNKNOWN, + ProvenanceTier, + compose_reliability, + entropy_bits, + entry_mass, + likelihood_ratio, + posterior, +) +from everos.memory.belief.update import decay, total_variation + + +def _asserted(distribution: dict[str, float]) -> str: + return max(distribution, key=lambda k: distribution[k]) + + +@pytest.mark.parametrize("candidates", [2, 3, 5, 12]) +@pytest.mark.parametrize("reliability", [0.10, 0.30, 0.40, 0.50]) +def test_sub_pivot_channel_cannot_flip_a_known_candidate( + candidates: int, reliability: float +) -> None: + """1000 sub-pivot observations do not move the mode, for any |V|.""" + dist = {"true": 0.9} + for i in range(candidates - 1): + dist[f"other_{i}"] = 0.1 / (candidates - 1) + + for _ in range(1000): + dist = posterior(dist, "other_0", reliability) + + assert _asserted(dist) == "true" + + +@pytest.mark.parametrize("reliability", [0.10, 0.30, 0.50]) +def test_sub_pivot_channel_cannot_admit_a_new_candidate(reliability: float) -> None: + """The admission path is gated by the same pivot as the likelihood. + + Without this the guarantee above is vacuous: an attacker simply + asserts a value the belief has never seen, which is admitted before + any reliability test runs. + """ + dist = {"true": 0.8, "unknown": 0.2} + for _ in range(1000): + dist = posterior(dist, "injected", reliability) + + assert _asserted(dist) == "true" + assert dist["injected"] < 0.01 + + +def test_trusted_channel_supersedes_in_one_observation() -> None: + """A single trusted correction overturns a single trusted claim. + + The property naive implementations lose: with a fixed epsilon entry + mass no reliability is ever enough, and the memory keeps asserting + the stale value forever. + """ + established = posterior({}, "old_value", 0.9) + corrected = posterior(established, "new_value", 0.9) + + assert _asserted(corrected) == "new_value" + + +@pytest.mark.parametrize("reliability", [0.55, 0.7, 0.9, 0.98]) +def test_one_sighting_leaves_the_belief_at_the_channel_reliability( + reliability: float, +) -> None: + """A fact heard once is believed to the degree the channel is trusted. + + The number is only worth storing if it means something. Normalising a + lone observation to 1.0 — which is what happens when the residual + mass has nowhere to sit — makes every belief in the store read as + certain and the whole layer decorative. + """ + dist = posterior({}, "heard_once", reliability) + + assert dist["heard_once"] == pytest.approx(reliability) + assert dist[UNKNOWN] == pytest.approx(1.0 - reliability) + + +def test_entry_mass_is_pivot_conditional() -> None: + assert entry_mass(PIVOT) < 1e-3 + assert entry_mass(PIVOT - 0.01) < 1e-3 + assert entry_mass(0.9) == pytest.approx(0.9) + assert entry_mass(0.9, scale=0.5) == pytest.approx(0.45) + + +def test_likelihood_ratio_pivots_at_one_half() -> None: + assert likelihood_ratio(0.3) == pytest.approx(1.0) + assert likelihood_ratio(0.5) == pytest.approx(1.0) + assert likelihood_ratio(0.9) == pytest.approx(9.0) + + +def test_content_confidence_cannot_exceed_the_channel_ceiling() -> None: + """Confident phrasing on a scraped page stays at the page's ceiling.""" + assert compose_reliability(ProvenanceTier.WEB_FETCH, 0.99) == pytest.approx(0.30) + assert compose_reliability(ProvenanceTier.USER_DIRECT, 0.25) == pytest.approx(0.25) + + +def test_taint_applies_the_stricter_ceiling() -> None: + """Laundering untrusted content through a trusted tool does not launder trust.""" + laundered = compose_reliability( + ProvenanceTier.TOOL_OUTPUT, 0.9, taint=ProvenanceTier.UNTRUSTED + ) + assert laundered <= 0.10 + + +def test_repeated_confirmation_saturates() -> None: + """Each identical confirmation moves the belief less than the last.""" + dist = {"x": 0.5, "unknown": 0.5} + movements = [] + for _ in range(8): + updated = posterior(dist, "x", 0.9) + movements.append(total_variation(updated, dist)) + dist = updated + + assert all(a > b for a, b in pairwise(movements)) + assert movements[-1] < 0.05 + + +def test_decay_loses_precision_without_losing_the_fact() -> None: + dist = {"a": 0.95, "b": 0.05} + entropies = [entropy_bits(decay(dist, days, 30.0)) for days in (0, 30, 90, 365)] + + assert all(a < b for a, b in pairwise(entropies)) + assert entropies[-1] > 0.98 + assert decay(dist, 365, 30.0)["a"] > 0.0 + + +def test_total_variation_is_bounded_even_when_a_candidate_is_new() -> None: + """The audit measure stays in [0, 1] where KL would diverge.""" + admitted = posterior({}, "first_ever", 0.9) + + assert 0.0 <= total_variation(admitted, {}) <= 1.0 + assert total_variation({"a": 1.0}, {"a": 1.0}) == pytest.approx(0.0) + assert total_variation({"a": 1.0}, {"b": 1.0}) == pytest.approx(1.0) From c46b5daf39cc3a36e0cd8d1e87a7105900ddeb6c Mon Sep 17 00:00:00 2001 From: derekding Date: Mon, 3 Aug 2026 08:24:15 +0100 Subject: [PATCH 2/2] feat(memory): derive belief keys so conflicts group without annotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resolver arbitrates between facts sharing a `belief_key` and nothing produced one — an atomic fact is an undecomposed sentence with no (subject, attribute) to group on. That was the blocker on running the layer over real data. BeliefKeyer groups on the observation that competing facts are about the same thing while differing in the value: the signature drops value-bearing tokens and keeps the topic, so "pre-approved for $350,000 from Wells Fargo" and "$400,000 from Wells Fargo" collapse together. Terms are IDF-weighted against what the scope has already said; without that, "really" and "looking" weigh as much as "pre-approved" and the false-link rate is about ten times worse. The errors are asymmetric and the default threshold is set accordingly. A missed link leaves two contradicting facts unarbitrated, which is today's behaviour, so nothing is lost. A false link declares unrelated facts mutually exclusive and lets one suppress the other. On benchmarks/belief_key.py the default links 81.4% of true pairs at a 0.45% false-link rate — chosen from the precision side of the curve, not the F1 peak. Re-running the supersession benchmark with derived keys, with nothing telling the resolver what competes, gives 78.6% clean and 78.6% under poison-5, scoring an instance correct only when the memory asserts the update AND has stopped asserting the stale claim. Lexical on purpose. EverOS already embeds every fact and matching on those embeddings is the better implementation; this establishes the grouping problem is tractable before anyone spends embedding calls on it, and the similarity is a constructor argument so the swap is not a rewrite. --- benchmarks/README.md | 30 +++ benchmarks/belief_key.py | 242 ++++++++++++++++++ docs/belief-layer.md | 61 ++++- src/everos/memory/belief/__init__.py | 4 + src/everos/memory/belief/keying.py | 193 ++++++++++++++ .../test_memory/test_belief/test_keying.py | 98 +++++++ 6 files changed, 618 insertions(+), 10 deletions(-) create mode 100644 benchmarks/belief_key.py create mode 100644 src/everos/memory/belief/keying.py create mode 100644 tests/unit/test_memory/test_belief/test_keying.py diff --git a/benchmarks/README.md b/benchmarks/README.md index f694e085..923595fa 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -377,10 +377,40 @@ one — are constructed on top of the benchmark and are **not** part of LongMemEval. See [docs/belief-layer.md](../docs/belief-layer.md) for what each row means and what the 21.4% gap on `clean` is. +## Belief-key derivation + +`belief_key.py` measures the step before arbitration: deciding *which* facts +are competing for the same slot at all. Labels come free from KU pair +membership. Same dataset, same offline constraints. + +```bash +uv run python benchmarks/belief_key.py --data data/longmemeval_oracle.json +``` + +``` + threshold linked false links +---------------------------------- + 0.20 85.7% 1.60% + 0.25 81.4% 0.45% <- default + 0.30 68.6% 0.12% + +Supersession with derived keys (nothing tells it what competes): + clean 78.6% + poison-5 78.6% +``` + +The two columns are not symmetric and the threshold is set from the right +one — a missed link leaves a contradiction unarbitrated, which is today's +behaviour, while a false link lets an unrelated fact suppress a true one. + ## CLI reference +`belief_ku.py`: + | Flag | Default | Meaning | |---|---|---| | `--data` | `data/longmemeval_oracle.json` | Path to the oracle split | | `--repetitions` | `5` | How many times the attacker repeats its claim | | `--poison-tier` | `web_fetch` | Channel the attacker writes on | + +`belief_key.py` takes `--data` only. diff --git a/benchmarks/belief_key.py b/benchmarks/belief_key.py new file mode 100644 index 00000000..4fafb689 --- /dev/null +++ b/benchmarks/belief_key.py @@ -0,0 +1,242 @@ +"""Belief-key derivation benchmark — does the grouping find real conflicts? + +The resolver arbitrates between facts that share a ``belief_key``. This +measures whether :class:`~everos.memory.belief.BeliefKeyer` puts the right +facts together, and — the number that actually governs the threshold — +how often it puts the wrong ones together. + +Labels come free from LongMemEval ``knowledge-update``: the two evidence +spans of one instance are *by construction* two readings of the same +attribute, and spans from different instances are not. That gives 70 +positive pairs and every cross-instance pair as a negative, with no +annotation of my own. + +Two things are reported, and they are not symmetric: + +``linked`` + Share of true pairs the keyer groups. A miss leaves two contradicting + facts unarbitrated — which is where EverOS is today, so nothing is + lost that was not already lost. +``false links`` + Share of unrelated pairs it groups. Each one lets an irrelevant fact + suppress a true one. This is the error that costs something, so the + default threshold is set from this column. + +The last section runs the full KU supersession benchmark with *derived* +keys instead of the oracle key, which is the only number that says what +the layer would do on real data. + +Usage:: + + uv run python benchmarks/belief_key.py --data data/longmemeval_oracle.json +""" + +from __future__ import annotations + +import argparse +import json +import random +import re +import sys +from dataclasses import dataclass +from pathlib import Path + +# Ensure the repo root is on sys.path when run as a script (see run.py) +_repo_root = str(Path(__file__).resolve().parent.parent) +if _repo_root not in sys.path: + sys.path.insert(0, _repo_root) + +from benchmarks.belief_ku import Instance, build_stream, load_instances # noqa: E402 +from everos.core.observability.logging import configure_logging # noqa: E402 +from everos.memory.belief import BeliefKeyer, BeliefResolver, ProvenanceTier # noqa: E402 +from everos.memory.belief.keying import _DEFAULT_THRESHOLD # noqa: E402 + +_SENTENCE = re.compile(r"(?<=[.!?])\s+") +_WORD = re.compile(r"[a-z0-9']+") + + +@dataclass(frozen=True) +class Pair: + """Two statements of one attribute, from the same KU instance.""" + + question: str + stale: str + fresh: str + + +def _salient_sentence(turn: str, question: str) -> str: + """The sentence in a turn that the question is asking about. + + Stands in for extraction: the algo layer would emit one atomic fact per + claim, and this picks the claim rather than the whole turn of chat + around it. Selection is by question overlap, which is symmetric between + the two sessions and so does not favour either reading. + """ + wanted = set(_WORD.findall(question.lower())) + best, best_score = turn, -1 + for sentence in _SENTENCE.split(turn): + candidate = sentence.strip() + if len(candidate) < 10: + continue + score = len(wanted & set(_WORD.findall(candidate.lower()))) + if score > best_score: + best, best_score = candidate, score + return best + + +def load_pairs(path: Path) -> list[Pair]: + """KU instances reduced to the two competing claims, one sentence each.""" + pairs: list[Pair] = [] + for item in json.loads(path.read_text()): + if item["question_type"] != "knowledge-update": + continue + sessions = item["haystack_sessions"] + if len(sessions) != 2: + continue + spans = [ + [turn["content"] for turn in session if turn.get("has_answer")] + for session in sessions + ] + if not spans[0] or not spans[1]: + continue + pairs.append( + Pair( + question=item["question"], + stale=_salient_sentence(spans[0][-1], item["question"]), + fresh=_salient_sentence(spans[1][-1], item["question"]), + ) + ) + return pairs + + +def _fitted_keyer(pairs: list[Pair], threshold: float) -> BeliefKeyer: + """A keyer whose term weights have seen the corpus, holding no beliefs. + + IDF needs a corpus. Feeding the facts in per scope means each belief is + minted in isolation while the weighting still knows which terms are + common, which is what a warm store looks like. + """ + keyer = BeliefKeyer(threshold=threshold) + for index, pair in enumerate(pairs): + keyer.key_for(pair.stale, scope=f"warm_{index}") + keyer.key_for(pair.fresh, scope=f"warm_{index}") + return keyer + + +def measure_linking( + pairs: list[Pair], threshold: float, *, samples: int = 4000 +) -> tuple[float, float]: + """Share of true pairs linked, and of unrelated pairs falsely linked.""" + keyer = _fitted_keyer(pairs, threshold) + + linked = 0 + for index, pair in enumerate(pairs): + scope = f"eval_{index}" + if keyer.key_for(pair.stale, scope=scope) == keyer.key_for( + pair.fresh, scope=scope + ): + linked += 1 + + rng = random.Random(0) + false_links = 0 + for draw in range(samples): + left, right = rng.sample(range(len(pairs)), 2) + scope = f"neg_{draw}" + if keyer.key_for(pairs[left].fresh, scope=scope) == keyer.key_for( + pairs[right].fresh, scope=scope + ): + false_links += 1 + + return 100.0 * linked / len(pairs), 100.0 * false_links / samples + + +def measure_end_to_end(path: Path, threshold: float) -> tuple[float, float]: + """Supersession accuracy with derived keys, clean and under attack. + + Same protocol and the same claim sentences as the linking measurement + above — the only difference from ``belief_ku.py`` is that nothing tells + the resolver which facts compete. + + An instance counts as correct when the memory asserts the updated + claim **and no longer asserts the stale one**. Both halves are needed: + a keyer that links nothing scores a free pass on the first half while + leaving the contradiction exactly where it found it. + """ + pairs = load_pairs(path) + instances = [ + _instance(pair, source, index) + for index, (pair, source) in enumerate( + zip(pairs, load_instances(path), strict=True) + ) + ] + results = [] + for arm in ("clean", "poison-5"): + hits = 0 + for index, instance in enumerate(instances): + keyer = _fitted_keyer(pairs, threshold) + resolver = BeliefResolver() + stream = build_stream( + instance, + arm, + repetitions=5, + poison_tier=ProvenanceTier.WEB_FETCH, + novel=instances[(index + 1) % len(instances)].stale, + ) + keys = set() + for observation in stream: + key = keyer.key_for(observation.fact, scope=instance.key) + keys.add(key) + resolver.observe(observation.model_copy(update={"belief_key": key})) + asserted = {resolver.verdict(key).fact for key in keys} + hits += instance.fresh in asserted and instance.stale not in asserted + results.append(100.0 * hits / len(instances)) + return results[0], results[1] + + +def _instance(pair: Pair, source: Instance, index: int) -> Instance: + """A ``belief_ku`` instance carrying the claim sentences, not whole turns.""" + return Instance( + key=f"e2e_{index}", + stale=pair.stale, + fresh=pair.fresh, + stale_at=source.stale_at, + fresh_at=source.fresh_at, + ) + + +def main() -> int: + """Run both measurements and print the threshold curve.""" + configure_logging("WARNING") + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--data", default="data/longmemeval_oracle.json") + args = parser.parse_args() + + path = Path(args.data) + if not path.exists(): + print(f"missing {path} — see the module docstring for the download command") + return 2 + + pairs = load_pairs(path) + print(f"LongMemEval knowledge-update — {len(pairs)} labelled pairs\n") + print(f"{'threshold':>10}{'linked':>10}{'false links':>14}") + print("-" * 34) + for threshold in (0.15, 0.20, 0.25, 0.30, 0.35, 0.40): + linked, false_links = measure_linking(pairs, threshold) + marker = " <- default" if threshold == _DEFAULT_THRESHOLD else "" + print(f"{threshold:>10.2f}{linked:>9.1f}%{false_links:>13.2f}%{marker}") + + print( + "\nA miss leaves two facts unarbitrated, which is today's behaviour.\n" + "A false link lets an unrelated fact suppress a true one. The default\n" + "is set from the right-hand column." + ) + + clean, poisoned = measure_end_to_end(path, _DEFAULT_THRESHOLD) + print("\nSupersession with derived keys (nothing tells it what competes):") + print(f" clean {clean:>6.1f}%") + print(f" poison-5 {poisoned:>6.1f}%") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/belief-layer.md b/docs/belief-layer.md index ad10c0b4..b9a20d9a 100644 --- a/docs/belief-layer.md +++ b/docs/belief-layer.md @@ -145,6 +145,57 @@ Read the rows, not the cells. correction on a low-trust channel stops a good one identically. There is no setting that gets both; this row is the price of the other three. +## Deriving `belief_key` + +The resolver arbitrates between facts sharing a key, and nothing in EverOS +produces one — an atomic fact is an undecomposed sentence with no +`(subject, attribute)` to group on. + +`keying.BeliefKeyer` groups on the observation that competing facts are +*about the same thing while differing in the value*. The signature drops +value-bearing tokens and keeps the topic, so "pre-approved for $350,000 +from Wells Fargo" and "pre-approved for $400,000 from Wells Fargo" collapse +to the same signature. Terms are IDF-weighted against what the scope has +already said; without that, "really" and "looking" count as much as +"pre-approved", and the false-link rate is roughly ten times worse. + +**The errors are not symmetric, and that sets the threshold.** A missed +link leaves two contradicting facts unarbitrated — exactly today's +behaviour, so nothing is lost. A false link declares two unrelated facts +mutually exclusive and lets one suppress the other. Partial arbitration is +worth having; wrong arbitration is not. + +`benchmarks/belief_key.py` takes its labels from KU pair membership: the +two evidence spans of an instance are two readings of one attribute by +construction, and spans from different instances are not. + +| threshold | true pairs linked | unrelated pairs linked | +|---|---|---| +| 0.15 | 90.0% | 6.03% | +| 0.20 | 85.7% | 1.60% | +| **0.25** (default) | **81.4%** | **0.45%** | +| 0.30 | 68.6% | 0.12% | +| 0.40 | 48.6% | 0.05% | + +Running the supersession benchmark again with *derived* keys — nothing +telling the resolver what competes — gives **78.6% clean and 78.6% under +`poison-5`**. An instance counts as correct only when the memory asserts +the update *and* has stopped asserting the stale claim; both halves are +needed, since a keyer that links nothing would otherwise score full marks +while leaving every contradiction exactly where it found it. + +That figure coincides with the oracle-key run without being the same +result. There the entire loss was hedged updates falling below the pivot; +here the claim sentences are shorter and only 5.7% hedge, while 18.6% fail +to link. The dominant error moved from extraction to keying. + +This is a lexical stand-in and should not survive contact with production. +EverOS already embeds every fact, and matching on those embeddings is the +better implementation — `BeliefKeyer` is written so that swap is a +constructor argument. The lexical version exists to establish that the +grouping problem is tractable at all before anyone spends embedding calls +on it. + ## What is not built **Persistence.** `BeliefState` and `BeliefRevision` are derived state and @@ -152,16 +203,6 @@ belong in SQLite (`~/.everos/.index/sqlite/system.db`), not in the LanceDB fact table — no index migration, and the states rebuild from the revision log. Needs a repo + an alembic revision. -**`belief_key` derivation.** The resolver takes the key; nothing produces it -yet. An atomic fact is an undecomposed sentence, so there is no -`(subject, attribute)` to group on. Options, roughly in order of appetite: -the algo layer emits an optional `subject` / `attribute` pair alongside the -fact; or facts are grouped by embedding cluster within an owner scope, which -is cheap but conflates "similar" with "mutually exclusive"; or callers -supply it explicitly for the narrow slice they care about. This is the real -open question and the reason this is a design note rather than a pull -request. - **Search integration.** `search/filters.py` already excludes `deprecated_by IS NOT NULL`; the analogous move is to rank or filter by posterior and to surface `probability` on the recall DTO so an answering diff --git a/src/everos/memory/belief/__init__.py b/src/everos/memory/belief/__init__.py index 569fb8a5..499a1582 100644 --- a/src/everos/memory/belief/__init__.py +++ b/src/everos/memory/belief/__init__.py @@ -19,6 +19,8 @@ trusted correction can still supersede a single trusted claim. """ +from .keying import BeliefKeyer as BeliefKeyer +from .keying import signature as signature from .models import PIVOT as PIVOT from .models import TIER_CEILING as TIER_CEILING from .models import UNKNOWN as UNKNOWN @@ -40,6 +42,7 @@ "PIVOT", "TIER_CEILING", "UNKNOWN", + "BeliefKeyer", "BeliefResolver", "BeliefRevision", "BeliefState", @@ -52,5 +55,6 @@ "kl_bits", "likelihood_ratio", "posterior", + "signature", "total_variation", ] diff --git a/src/everos/memory/belief/keying.py b/src/everos/memory/belief/keying.py new file mode 100644 index 00000000..1b1f3f2a --- /dev/null +++ b/src/everos/memory/belief/keying.py @@ -0,0 +1,193 @@ +"""Deriving ``belief_key`` — which facts are competing for the same slot. + +The resolver arbitrates between facts that share a key. Nothing produces +that key: an atomic fact is an undecomposed sentence, with no +``(subject, attribute)`` to group on. + +The rule here is that two facts compete when they are **about the same +thing while differing in the value**. So the signature drops value-bearing +tokens — numbers, quantities, amounts — and keeps the topic: + + "I got pre-approved for $350,000 from Wells Fargo" + "I got pre-approved for $400,000 from Wells Fargo" + ^ signature identical, values differ + +Terms are IDF-weighted against the facts seen so far in the scope. Without +that, "really", "looking", "forward" count as much as "pre-approved" and +"Wells Fargo", and unrelated chat about anything at all starts scoring as +a contradiction. Weighting is worth roughly a factor of ten in precision on +the benchmark (`benchmarks/belief_key.py`). + +Errors here are not symmetric, and the threshold is set accordingly +----------------------------------------------------------------- +A **missed** link leaves two contradicting facts unarbitrated — exactly +where EverOS is today, so no ground is lost. A **false** link declares two +unrelated facts mutually exclusive, and one then suppresses the other: a +true fact stops being asserted because something irrelevant outranked it. + +Partial arbitration is worth having; wrong arbitration is not. The default +threshold is therefore set where false links are rare rather than where +the F1 is best, and the losing candidate always stays retrievable — a +belief suppresses a fact from being *asserted*, never from being *found*. + +This is a lexical stand-in. EverOS already embeds every fact, and matching +on those embeddings is the better implementation; :class:`BeliefKeyer` is +written against a similarity it takes as a parameter so that swap is a +constructor argument rather than a rewrite. +""" + +from __future__ import annotations + +import hashlib +import math +import re +from collections import Counter + +_TOKEN = re.compile(r"[a-z0-9']+") + +_STOPWORD_TEXT = ( + "a an the i you we my your our me it is are was were be been am do does " + "did have has had of to for in on at with and or but so that this these " + "those there here about just really some any mine can could would should " + "will i'm i've it's don't very much more most also as by from up out if " + "then than what when how why who" +) + +_STOPWORDS = frozenset(_STOPWORD_TEXT.split()) +"""Terms too common to say anything about what a fact is about. + +Kept short on purpose. Aggressive stopword lists start deleting the words +that distinguish one belief from another, and IDF weighting already +demotes whatever is common in a given store. +""" + +_VALUE = re.compile( + r"^(\d[\w:./%,-]*|\$.*|one|two|three|four|five|six|seven|eight|nine|ten|" + r"eleven|twelve|first|second|third|fourth|fifth|sixth|seventh|eighth|" + r"ninth|tenth)$" +) +"""Tokens that carry the *value* rather than the topic. + +A belief's candidates differ precisely here, so these must not enter the +signature — otherwise the two readings of a changed quantity look like +different topics and never compete. +""" + +_DEFAULT_THRESHOLD = 0.25 +"""Weighted-overlap score above which two facts share a belief. + +Chosen from the precision side of the curve, not the F1 peak: on +``benchmarks/belief_key.py`` it links 81.4% of true pairs at a 0.45% +false-link rate. Dropping to 0.20 buys 4 points of linking and costs 3.5x +the false links; raising it to 0.30 cuts false links by a further 4x for +13 points of linking, which is the right trade for a scope holding many +near-duplicate topics. +""" + + +def signature(fact: str) -> frozenset[str]: + """Topic terms of a fact, with value-bearing tokens removed.""" + return frozenset( + token + for token in _TOKEN.findall(fact.lower()) + if len(token) > 1 and token not in _STOPWORDS and not _VALUE.match(token) + ) + + +class BeliefKeyer: + """Assigns facts to beliefs by topic signature within a scope. + + Stateful by design: term frequencies and known signatures accumulate + as facts arrive, so the weighting adapts to what a given owner + actually talks about. Feed it the scope's existing facts on startup + to restore that. + + Args: + threshold: Weighted overlap above which a fact joins an existing + belief. See :data:`_DEFAULT_THRESHOLD` for how it was picked. + """ + + def __init__(self, *, threshold: float = _DEFAULT_THRESHOLD) -> None: + self._threshold = threshold + self._document_frequency: Counter[str] = Counter() + self._documents = 0 + self._signatures: dict[str, frozenset[str]] = {} + self._scopes: dict[str, str] = {} + + def key_for(self, fact: str, *, scope: str = "") -> str: + """Return the belief this fact belongs to, minting one if new. + + Args: + fact: The atomic fact sentence. + scope: Owner / app partition. Facts never compete across + scopes, whatever they say. + + Returns: + A stable ``belief_key``. + """ + terms = signature(fact) + self._document_frequency.update(terms) + self._documents += 1 + + if terms: + match = self._best_match(terms, scope) + if match is not None: + return match + + key = self._mint(terms, scope, fact) + self._signatures[key] = terms + self._scopes[key] = scope + return key + + def _best_match(self, terms: frozenset[str], scope: str) -> str | None: + """Highest-scoring known belief in ``scope``, if it clears the bar.""" + best_key: str | None = None + best_score = self._threshold + for key, known in self._signatures.items(): + if self._scopes.get(key) != scope: + continue + score = self._overlap(terms, known) + if score >= best_score: + best_key, best_score = key, score + return best_key + + def _overlap(self, left: frozenset[str], right: frozenset[str]) -> float: + """IDF-weighted overlap coefficient of two signatures, in ``[0, 1]``. + + Overlap rather than Jaccard: one fact restating a topic at greater + length is the same belief, and Jaccard punishes it for the extra + words. + """ + if not left or not right: + return 0.0 + shared = sum(self._idf(term) for term in left & right) + smaller = min( + sum(self._idf(term) for term in left), + sum(self._idf(term) for term in right), + ) + return shared / smaller if smaller else 0.0 + + def _idf(self, term: str) -> float: + """Inverse document frequency, smoothed, always ``>= 1``.""" + return ( + math.log((self._documents + 1) / (self._document_frequency[term] + 1)) + 1.0 + ) + + def _mint(self, terms: frozenset[str], scope: str, fact: str) -> str: + """A stable key for a belief this scope has not held before. + + Derived from the founding signature, and never revised afterwards: + widening a belief's signature as members join lets one belief drift + into the territory of another and swallow it, and that failure is + both silent and unrecoverable. + + A fact with no topic terms at all ("it is 5") falls back to the + sentence itself, so two of them get two beliefs. There is no + evidence they compete, and with no evidence the rule is not to + link — see the module docstring on which error costs more. + """ + parts = sorted(terms) if terms else [fact] + digest = hashlib.sha256( + "\x00".join([scope, *parts]).encode("utf-8") + ).hexdigest()[:16] + return f"{scope}:{digest}" if scope else digest diff --git a/tests/unit/test_memory/test_belief/test_keying.py b/tests/unit/test_memory/test_belief/test_keying.py new file mode 100644 index 00000000..0b3296df --- /dev/null +++ b/tests/unit/test_memory/test_belief/test_keying.py @@ -0,0 +1,98 @@ +"""Pins belief-key derivation: what competes, what must not, and the bias. + +The contract worth defending is asymmetric. Missing a link costs nothing +that was not already lost — the facts stay side by side, which is where +EverOS is without this module. Inventing a link costs a true fact its +place, silently. So these tests pin the *precision* side hardest. +""" + +from __future__ import annotations + +from everos.memory.belief.keying import BeliefKeyer, signature + + +def test_signature_drops_the_value_and_keeps_the_topic() -> None: + """Candidates of one belief differ exactly where the signature ignores.""" + cheap = signature("I got pre-approved for $350,000 from Wells Fargo") + dear = signature("I got pre-approved for $400,000 from Wells Fargo") + + assert cheap == dear + assert "wells" in cheap + assert "350,000" not in cheap + + +def test_a_changed_quantity_lands_on_the_same_belief() -> None: + keyer = BeliefKeyer() + + first = keyer.key_for("I've tried three different Korean restaurants in my city") + second = keyer.key_for("I've tried four different Korean restaurants in my city") + + assert first == second + + +def test_unrelated_facts_do_not_compete() -> None: + keyer = BeliefKeyer() + + running = keyer.key_for("I set a personal best in the charity 5K run of 27:12") + mortgage = keyer.key_for("I got pre-approved for $350,000 from Wells Fargo") + + assert running != mortgage + + +def test_scopes_never_compete() -> None: + """Two users saying the same sentence hold two separate beliefs.""" + keyer = BeliefKeyer() + + mine = keyer.key_for("my coffee ratio is 6 ounces per tablespoon", scope="user_a") + yours = keyer.key_for("my coffee ratio is 6 ounces per tablespoon", scope="user_b") + + assert mine != yours + assert mine.startswith("user_a:") + + +def test_keys_are_stable_across_keyer_instances() -> None: + """A key minted today must still name the same belief after a restart.""" + fact = "my coffee ratio is 6 ounces per tablespoon" + + assert BeliefKeyer().key_for(fact, scope="u") == BeliefKeyer().key_for( + fact, scope="u" + ) + + +def test_a_belief_signature_does_not_widen_as_members_join() -> None: + """Anti-drift: a belief must not grow into its neighbour and swallow it. + + The two 5K facts join one belief. If joining widened that belief's + signature to the union of its members, the vocabulary it matches + against would keep growing and eventually cover the unrelated fact. + """ + keyer = BeliefKeyer() + key = keyer.key_for("my personal best in the charity 5K run was 27:12") + keyer.key_for("I want to beat my personal best 5K time of 25:50 this year") + + unrelated = keyer.key_for( + "I finished my fifth issue of National Geographic about the Amazon" + ) + + assert unrelated != key + + +def test_an_empty_signature_still_gets_its_own_key() -> None: + """A fact of pure stopwords and numbers competes with nothing.""" + keyer = BeliefKeyer() + + first = keyer.key_for("it is 5") + second = keyer.key_for("it is 6") + + assert first != second + + +def test_threshold_trades_recall_for_precision_in_the_stated_direction() -> None: + near_miss = "the standing desk in my home office is 48 inches wide" + other = "my home office chair is 5 years old" + + permissive = BeliefKeyer(threshold=0.05) + strict = BeliefKeyer(threshold=0.9) + + assert permissive.key_for(near_miss) == permissive.key_for(other) + assert strict.key_for(near_miss) != strict.key_for(other)