Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
645d994
Surface fact validity in context packs: annotations, current-version …
Jul 9, 2026
0abff00
Query-anchored excerpt windows: stop head-biased chunks cutting the a…
Jul 9, 2026
f77997a
Add entity grounding: pack-level unsupported-entity statistic for abs…
Jul 9, 2026
11a0cf3
Capture speaker provenance and bias promotion toward user-asserted facts
Jul 9, 2026
675a831
Time-aware retrieval: temporal anchors as a first-class ranking signal
Jul 9, 2026
457a040
Add coverage mode: gated instance diversity for aggregation queries
Jul 9, 2026
b5bfdbf
Add disclosed --verify-grounding gate to the LongMemEval harness
Jul 9, 2026
778658f
Add review-gated roll-up cards to the consolidation engine
Jul 9, 2026
594e00c
Fact-augmented key expansion: derived retrieval keys join memory FTS
Jul 9, 2026
ea1b237
Merge branch 'lme2/supersession-surfacing' into lme2-integration
Jul 9, 2026
c023be8
Merge branch 'lme2/time-aware' into lme2-integration
Jul 9, 2026
91dcb59
Merge branch 'lme2/coverage-mode' into lme2-integration
Jul 9, 2026
686db73
Merge branch 'lme2/entity-grounding' into lme2-integration
Jul 9, 2026
70d4721
Merge branch 'lme2/capture-bias' into lme2-integration
Jul 9, 2026
1b22c3e
Merge branch 'lme2/excerpt-anchoring' into lme2-integration
Jul 9, 2026
d4103cd
Merge branch 'lme2/grounding-gate' into lme2-integration
Jul 9, 2026
3a409ef
Merge branch 'lme2/fact-keys' into lme2-integration
Jul 9, 2026
014c720
Merge branch 'lme2/consolidation-rollups' into lme2-integration
Jul 9, 2026
1519cef
Integrate round-2 waves: cross-branch wiring and merge reconciliation
Jul 9, 2026
b8efc68
Apply five loss-forensics fixes: anchor gate, coverage key, fact-key …
Jul 10, 2026
b98cda9
Changelog for the round-2 retrieval and consolidation features
Jul 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

## Unreleased

- Time-aware retrieval: temporal anchors parsed from the query ("two weeks ago", "in March 2023", "between X and Y") join RRF fusion as one more ranked list against event dates — never a hard filter, dormant on date-free queries, honest `temporal_anchor` trace stage.
- Coverage mode for aggregation-shaped recall ("how many…", "list every…"): query-surface intent gate, capped clause decomposition, and instance-diversity fusion keyed on `(source_id, source_chunk_id)` so distinct instances fill the slots; dormant path byte-identical.
- Consolidation roll-up cards: the merge engine proposes review-gated cards that pre-aggregate same-topic instances with per-instance dates, values, and speaker provenance; accepted cards are first-class recallable memories, members stay individually recallable, nothing auto-promotes, zero added commit-path work.
- Fact-augmented retrieval keys (migration `20260707_0082`): derived category/attribute keys indexed at low weight on both backends so category-phrased queries match instance memories; deterministic tier always on, optional model tier behind the provider seam; identifier-shaped attributes excluded from derivation.
- Entity-grounding honesty note: context packs state "no stored memories mention X" when a salient query entity has zero corpus support — a retrieval statistic, only present when true.
- Supersession validity annotations: pack items carry compact validity metadata (valid-from/to, superseded-by, corrected-at) and the current version of a corrected fact always ranks above its superseded ancestor.
- Speaker-provenance capture: memories record USER vs ASSISTANT origin; user-asserted values win promotion-rank tie-breaks; memory cards label "you said" vs "assistant suggested"; cross-batch duplicate promotions deduped.
- Query-anchored excerpts in the benchmark packer: excerpt windows center on the query's best-matching line (gated on enumeration shape, with upward+downward run extensions) instead of chunk heads.
- Disclosed post-generation grounding gate for the benchmark harness (`--verify-grounding`, off by default, fingerprint-stamped): a separate judge-neutral pass converts answers whose load-bearing claims lack context support into abstentions; fail-open, both texts recorded.
- Benchmark checkpoint rows now record pack provenance (retrieved session ids, memory ids, context digest) so paired flips are attributable offline.
- Published LongMemEval result unchanged at 79.4%: the paired 172-question slice measures this release at parity (+1 net, p=1.0) with per-type movement (temporal +2, multi-session +1, abstention +1, knowledge-update/preference −1 each); the round-2 features ship for their product value with no new benchmark claim.

## v0.9.1 — 2026-07-07

- Retrieval bug fix: the sources stage was content-blind (matched only titles/metadata with a broken stopword list, effectively returning the most-recent sessions); it is now RRF fusion over chunk-level full-text hits, provenance of winning memories, and title/recency — plus an FTS OR-fallback when strict AND finds nothing.
Expand Down
92 changes: 92 additions & 0 deletions apps/api/alembic/versions/20260707_0082_memory_fact_keys.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"""Derived retrieval keys (``fact_keys``) join the memory FTS stream.

Strict lexical search cannot bridge category-phrased questions to
instance-phrased memories: "charity event fundraising total" shares no
token with a memory that only says "Bike-a-Thon raised $5,000".
``alicebot_api.vnext_fact_keys`` derives capped hypernym/attribute/unit
phrasings per memory; this migration gives them a home and folds them
into full-text search:

- ``memories.fact_keys text NULL`` -- NULL means "never derived" (the
backfill target), ``''`` means "derived, nothing to add".
- ``memories.search_tsv`` is a stored GENERATED column (from
``20260704_0072``), so its expression cannot be altered in place: drop
and re-add it with a fourth ``setweight(..., 'D')`` term over
``fact_keys``. ``ts_rank``'s default weights score ``'D'`` lexemes at
0.1 versus title's 1.0, so derived keys make rows FINDABLE without
outranking direct text matches. ADD COLUMN computes the generated
column for existing rows, and the GIN index is rebuilt on top.

The SQLite mirror is the ``fact_keys`` column on the ``memories_fts``
FTS5 table in ``alicebot_api.sqlite_schema`` (bm25 weight 0.1).

Revision ID: 20260707_0082
Revises: 20260707_0081
"""

from __future__ import annotations

from alembic import op


revision = "20260707_0082"
down_revision = "20260707_0081"
branch_labels = None
depends_on = None


_SEARCH_TSV_WITH_FACT_KEYS_SQL = """
ALTER TABLE memories
ADD COLUMN search_tsv tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(title, '')), 'A')
|| setweight(to_tsvector('english', coalesce(canonical_text, '')), 'B')
|| setweight(to_tsvector('english', coalesce(summary, '')), 'C')
|| setweight(to_tsvector('english', coalesce(fact_keys, '')), 'D')
) STORED
"""

# The pre-0082 expression from 20260704_0072, restored on downgrade.
_SEARCH_TSV_WITHOUT_FACT_KEYS_SQL = """
ALTER TABLE memories
ADD COLUMN search_tsv tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(title, '')), 'A')
|| setweight(to_tsvector('english', coalesce(canonical_text, '')), 'B')
|| setweight(to_tsvector('english', coalesce(summary, '')), 'C')
) STORED
"""

_CREATE_SEARCH_TSV_INDEX_SQL = """
CREATE INDEX memories_search_tsv_gin_idx
ON memories USING gin (search_tsv)
"""

_UPGRADE_STATEMENTS = (
"""
ALTER TABLE memories
ADD COLUMN fact_keys text NULL
""",
"DROP INDEX IF EXISTS memories_search_tsv_gin_idx",
"ALTER TABLE memories DROP COLUMN IF EXISTS search_tsv",
_SEARCH_TSV_WITH_FACT_KEYS_SQL,
_CREATE_SEARCH_TSV_INDEX_SQL,
)

_DOWNGRADE_STATEMENTS = (
"DROP INDEX IF EXISTS memories_search_tsv_gin_idx",
"ALTER TABLE memories DROP COLUMN IF EXISTS search_tsv",
_SEARCH_TSV_WITHOUT_FACT_KEYS_SQL,
_CREATE_SEARCH_TSV_INDEX_SQL,
"ALTER TABLE memories DROP COLUMN IF EXISTS fact_keys",
)


def upgrade() -> None:
for statement in _UPGRADE_STATEMENTS:
op.execute(statement)


def downgrade() -> None:
for statement in _DOWNGRADE_STATEMENTS:
op.execute(statement)
6 changes: 6 additions & 0 deletions apps/api/src/alicebot_api/mcp_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -1858,6 +1858,12 @@ def _sqlite_memory_correct(context: MCPRuntimeContext, arguments: Mapping[str, o
# Acceptance is the promotion into trusted memory, so it is also
# the entity-linking moment; proposal-time candidates never link.
_link_accepted_memory_entities(store, updated)
# Same moment for derived retrieval keys: deterministic tier
# only (use_env_provider=False), so review actions never make
# a synchronous model call; failures never fail the review.
from alicebot_api.vnext_fact_keys import attach_memory_fact_keys

attach_memory_fact_keys(store, updated, use_env_provider=False, actor_type="user")
elif resolved_action == "delete":
updated = store.update_memory(
memory_id=memory_id,
Expand Down
183 changes: 183 additions & 0 deletions apps/api/src/alicebot_api/memory_provenance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
"""Speaker-provenance derivation and promotion-rank bias for captured memories.

Conversational transcripts tag turns with a speaker label (``[USER]:`` /
``[ASSISTANT]:``, with or without brackets). Capture derives a
``provenance_role`` from that *content shape alone* — no benchmark metadata,
question types, or labels are ever consulted — and classifies how assertive
the content is:

* ``user_asserted`` — the user states a concrete value about themselves in
the first person ("I paid $50 for the taxi"). These are ground truth the
user vouched for.
* ``assistant_estimate`` — the assistant offers hedged or ranged figures
("approximately $180-270", "typically costs around ¥20,000-30,000").
These are model chatter, not user facts.

``provenance_promotion_rank`` turns those classes into a small ordinal used
to ORDER same-topic candidates at promotion time: user-asserted facts sort
before neutral content, and assistant estimates sort after it. This is a
bias, not suppression — assistant-derived memories still capture, promote,
and recall; they only lose ordering ties against a first-person user
assertion for the same slot.

Content without a recognizable speaker tag derives no role, gets no
adjustment anywhere, and follows the byte-identical pre-existing code path.
"""

from __future__ import annotations

from collections.abc import Callable, Sequence
import re
from typing import TypeVar

PROVENANCE_ROLE_USER = "user"
PROVENANCE_ROLE_ASSISTANT = "assistant"

ASSERTION_CLASS_USER_ASSERTED = "user_asserted"
ASSERTION_CLASS_ASSISTANT_ESTIMATE = "assistant_estimate"

# Promotion-rank ordinals (lower sorts first / wins the slot).
PROMOTION_RANK_USER_ASSERTED = 0
PROMOTION_RANK_USER = 1
PROMOTION_RANK_NEUTRAL = 2
PROMOTION_RANK_ASSISTANT_ESTIMATE = 3

# Speaker tag at the start of a line: "[USER]:", "USER:", "[Assistant]: ",
# case-insensitive. Matching is anchored so mid-sentence mentions of the
# words "user"/"assistant" never derive a role.
_SPEAKER_TAG_PATTERN = re.compile(
r"^\s*(?:\[(?P<bracketed>user|assistant)\]|(?P<bare>user|assistant))\s*:\s*",
re.IGNORECASE,
)

# First-person subject pronouns: the speaker is talking about themselves.
_FIRST_PERSON_PATTERN = re.compile(r"\b(?:i|i'm|i've|i'd|my|me|we|our)\b", re.IGNORECASE)

# Assertion verbs that bind a concrete value to the speaker (broader than
# the generic claim_sentence verb list; includes common past-tense forms).
_ASSERTION_VERB_PATTERN = re.compile(
r"\b(?:is|are|was|were|am|paid|pay|pays|bought|buy|spent|spend|cost|costs|"
r"earned|earn|earns|raised|raise|got|get|gets|have|has|had|own|owns|owned|"
r"weigh|weighs|weighed|work|works|worked|saved|save|charges?|charged|"
r"told|said)\b",
re.IGNORECASE,
)

# A concrete value: currency amounts, percentages, unit-bearing quantities,
# or thousands-separated numbers. Deliberately NOT any bare digit — loose
# digit matching turns ordinary chatter ("my iPhone 13", "chapter 4") into
# asserted-value memories and drowns retrieval in noise.
_CONCRETE_VALUE_PATTERN = re.compile(
r"[$€£¥]\s*\d" # currency-symbol amounts: $50, ¥20,000
r"|\d[\d,]*(?:\.\d+)?\s*%" # percentages: 15%
r"|\d[\d,]*(?:\.\d+)?\s*(?:dollars?|bucks|usd|euros?|eur|gbp|yen|jpy)\b" # currency words
r"|\d[\d,]*(?:\.\d+)?\s*(?:hours?|hrs?|minutes?|mins?|seconds?|days?|weeks?|months?|years?|yrs?"
r"|miles?|km|kilometers?|meters?|feet|ft|inches?|kg|kilograms?|grams?|lbs?|pounds?|ounces?|oz"
r"|gb|mb|tb|mph|kph|calories?|steps?|dollars?)\b" # quantity + unit
r"|\d{1,3}(?:,\d{3})+", # thousands-separated numbers: 20,000
re.IGNORECASE,
)

# Hedged/estimate phrasing that marks assistant figures as approximations.
_HEDGE_PATTERN = re.compile(
r"\b(?:approximately|approx\.?|around|roughly|about|estimated?|estimates?|"
r"typically|usually|likely|generally|on average|up to|between)\b",
re.IGNORECASE,
)

# A numeric range like "$180-270", "¥20,000-30,000", or "20 - 30".
_NUMERIC_RANGE_PATTERN = re.compile(r"\d(?:[\d,.]*)\s*[-–—]\s*[$€£¥]?\s*\d")

_T = TypeVar("_T")


def derive_speaker_role(text: str) -> str | None:
"""Speaker role from a leading transcript tag, or ``None`` when untagged."""
match = _SPEAKER_TAG_PATTERN.match(text)
if match is None:
return None
role = (match.group("bracketed") or match.group("bare") or "").casefold()
if role == PROVENANCE_ROLE_USER:
return PROVENANCE_ROLE_USER
if role == PROVENANCE_ROLE_ASSISTANT:
return PROVENANCE_ROLE_ASSISTANT
return None


def classify_assertion(text: str, role: str | None) -> str | None:
"""Assertion class for speaker-tagged content; ``None`` when neutral.

* USER + first-person pronoun + assertion verb + concrete value →
``user_asserted``.
* ASSISTANT + concrete value + (hedge word or numeric range) →
``assistant_estimate``.

Anything else (no role, no concrete value, plain assistant statements)
stays neutral so unrelated content is never re-ranked.
"""
if role is None:
return None
if not _CONCRETE_VALUE_PATTERN.search(text):
return None
if role == PROVENANCE_ROLE_USER:
if _FIRST_PERSON_PATTERN.search(text) and _ASSERTION_VERB_PATTERN.search(text):
return ASSERTION_CLASS_USER_ASSERTED
return None
if role == PROVENANCE_ROLE_ASSISTANT:
if _HEDGE_PATTERN.search(text) or _NUMERIC_RANGE_PATTERN.search(text):
return ASSERTION_CLASS_ASSISTANT_ESTIMATE
return None
return None


def provenance_promotion_rank(
*,
provenance_role: str | None,
assertion_class: str | None,
) -> int:
"""Ordinal promotion rank; lower wins ordering for the same topic slot.

``user_asserted`` (0) < plain user statement (1) < neutral/unknown and
plain assistant content (2) < ``assistant_estimate`` (3). Content with
no derived role always ranks neutral, so provenance-free stores keep
their existing order byte-for-byte.
"""
if assertion_class == ASSERTION_CLASS_USER_ASSERTED:
return PROMOTION_RANK_USER_ASSERTED
if assertion_class == ASSERTION_CLASS_ASSISTANT_ESTIMATE:
return PROMOTION_RANK_ASSISTANT_ESTIMATE
if provenance_role == PROVENANCE_ROLE_USER:
return PROMOTION_RANK_USER
return PROMOTION_RANK_NEUTRAL


def order_by_provenance(
items: Sequence[_T],
*,
rank_of: Callable[[_T], int],
) -> list[_T]:
"""Stable provenance ordering: rank ascending, original order preserved.

When every item ranks neutral (the ungated case) the result is the
input order unchanged.
"""
decorated = sorted(
((rank_of(item), index) for index, item in enumerate(items)),
)
return [items[index] for _rank, index in decorated]


__all__ = [
"ASSERTION_CLASS_ASSISTANT_ESTIMATE",
"ASSERTION_CLASS_USER_ASSERTED",
"PROMOTION_RANK_ASSISTANT_ESTIMATE",
"PROMOTION_RANK_NEUTRAL",
"PROMOTION_RANK_USER",
"PROMOTION_RANK_USER_ASSERTED",
"PROVENANCE_ROLE_ASSISTANT",
"PROVENANCE_ROLE_USER",
"classify_assertion",
"derive_speaker_role",
"order_by_provenance",
"provenance_promotion_rank",
]
Loading
Loading