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..923595fa 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -336,3 +336,81 @@ 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. + +## 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/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..b9a20d9a --- /dev/null +++ b/docs/belief-layer.md @@ -0,0 +1,220 @@ +# 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. + +## 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 +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. + +**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..499a1582 --- /dev/null +++ b/src/everos/memory/belief/__init__.py @@ -0,0 +1,60 @@ +"""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 .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 +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", + "BeliefKeyer", + "BeliefResolver", + "BeliefRevision", + "BeliefState", + "BeliefVerdict", + "FactObservation", + "ProvenanceTier", + "compose_reliability", + "entropy_bits", + "entry_mass", + "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/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_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) 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)