feat(embeddings): every vector records the model whose space it lives in (#4132)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m1s
CI & Build / Python tests (push) Successful in 1m40s
CI & Build / Build & push image (push) Canceled after 27s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m1s
CI & Build / Python tests (push) Successful in 1m40s
CI & Build / Build & push image (push) Canceled after 27s
The four embedding tables stamped chunker_version but not the model, and vector(384) is a width, not an identity: a same-width model swap would write a second geometry beside the first with no error. - embedding_model on note/rule/milestone/system embeddings (0109; existing rows stamped with the only model any install has ever run). - Every write stamps EMBEDDING_MODEL; every backfill's "current" test is is_current_stamp(), both halves of calibration_stamp(). - migrate_floor refuses while any row its surface searches is off the live model, before sampling: re-embed, then migrate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -228,6 +228,7 @@ async def test_upsert_stores_one_versioned_row_per_chunk():
|
||||
assert [r.chunk_index for r in rows] == list(range(len(chunks)))
|
||||
assert [r.chunk_text for r in rows] == chunks
|
||||
assert {r.chunker_version for r in rows} == {emb.CHUNKER_VERSION}
|
||||
assert {r.embedding_model for r in rows} == {emb.EMBEDDING_MODEL}
|
||||
assert {r.user_id for r in rows} == {42}
|
||||
session.execute.assert_awaited() # the delete that makes replacement atomic
|
||||
|
||||
@@ -314,3 +315,17 @@ async def test_backfill_reembeds_notes_with_a_stale_chunker_version():
|
||||
|
||||
embedded = [call.args[0] for call in upsert.call_args_list]
|
||||
assert embedded == [2], "only the stale note is re-embedded"
|
||||
|
||||
|
||||
def test_a_row_is_current_only_in_the_live_models_space():
|
||||
"""#4132: the column is a width, not an identity, so a same-width model
|
||||
swap writes a second geometry with no error. The backfill's "current"
|
||||
predicate has to name BOTH halves of the stamp, on every embedding table."""
|
||||
from scribe.models.embedding import (
|
||||
MilestoneEmbedding, NoteEmbedding, RuleEmbedding, SystemEmbedding,
|
||||
)
|
||||
from scribe.services import embeddings as emb
|
||||
|
||||
for table in (NoteEmbedding, RuleEmbedding, MilestoneEmbedding, SystemEmbedding):
|
||||
clause = str(emb.is_current_stamp(table))
|
||||
assert "chunker_version" in clause and "embedding_model" in clause, table
|
||||
|
||||
@@ -23,7 +23,7 @@ from scribe.models.embedding import EMBEDDING_DIM, NoteEmbedding
|
||||
from scribe.models.note import Note
|
||||
from scribe.models.project import Project
|
||||
from scribe.services import lessons as lessons_svc
|
||||
from scribe.services.embeddings import CHUNKER_VERSION, semantic_search_notes
|
||||
from scribe.services.embeddings import CHUNKER_VERSION, EMBEDDING_MODEL, semantic_search_notes
|
||||
from tests.helpers import ensure_user
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
|
||||
@@ -71,6 +71,7 @@ async def corpus():
|
||||
note_id=note.id, chunk_index=0, user_id=owner.id,
|
||||
embedding=QUERY_VEC, chunk_text=note.title,
|
||||
chunker_version=CHUNKER_VERSION,
|
||||
embedding_model=EMBEDDING_MODEL,
|
||||
))
|
||||
ids = {k: n.id for k, n in rows.items()}
|
||||
ids["owner"], ids["a"], ids["b"] = owner.id, a.id, b.id
|
||||
|
||||
@@ -17,7 +17,7 @@ from scribe.models.embedding import EMBEDDING_DIM, MilestoneEmbedding
|
||||
from scribe.models.milestone import Milestone
|
||||
from scribe.models.project import Project
|
||||
from scribe.services import dedup as dedup_svc
|
||||
from scribe.services.embeddings import CHUNKER_VERSION, semantic_search_milestones
|
||||
from scribe.services.embeddings import CHUNKER_VERSION, EMBEDDING_MODEL, semantic_search_milestones
|
||||
from tests.helpers import ensure_user
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine", "_no_embedding")]
|
||||
@@ -48,7 +48,8 @@ async def roadmap():
|
||||
await s.flush()
|
||||
for ms, vec in ((m3, NEAR), (done, NEAR), (unrelated, FAR), (foreign, NEAR)):
|
||||
s.add(MilestoneEmbedding(milestone_id=ms.id, chunk_index=0, embedding=vec,
|
||||
chunk_text=ms.title, chunker_version=CHUNKER_VERSION))
|
||||
chunk_text=ms.title, chunker_version=CHUNKER_VERSION,
|
||||
embedding_model=EMBEDDING_MODEL))
|
||||
ids = {"owner": owner.id, "stranger": stranger.id, "mine": mine.id,
|
||||
"m3": m3.id, "done": done.id, "unrelated": unrelated.id, "foreign": foreign.id}
|
||||
await s.commit()
|
||||
|
||||
@@ -33,7 +33,7 @@ def _vec(*nonzero_first):
|
||||
|
||||
def _emb(note_id, user_id, chunk_index, vec):
|
||||
"""A chunk row at the current chunker version (#280, migration 0077)."""
|
||||
from scribe.services.embeddings import CHUNKER_VERSION
|
||||
from scribe.services.embeddings import CHUNKER_VERSION, EMBEDDING_MODEL
|
||||
|
||||
return NoteEmbedding(
|
||||
note_id=note_id,
|
||||
@@ -42,6 +42,7 @@ def _emb(note_id, user_id, chunk_index, vec):
|
||||
embedding=vec,
|
||||
chunk_text=f"chunk {chunk_index} of note {note_id}",
|
||||
chunker_version=CHUNKER_VERSION,
|
||||
embedding_model=EMBEDDING_MODEL,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ from scribe.models.embedding import EMBEDDING_DIM, RuleEmbedding
|
||||
from scribe.models.project import Project
|
||||
from scribe.models.share import ProjectShare
|
||||
from scribe.services import rulebooks as rulebooks_svc
|
||||
from scribe.services.embeddings import CHUNKER_VERSION, semantic_search_rules
|
||||
from scribe.services.embeddings import CHUNKER_VERSION, EMBEDDING_MODEL, semantic_search_rules
|
||||
from tests.helpers import ensure_user
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
|
||||
@@ -66,6 +66,7 @@ async def homes():
|
||||
s.add(RuleEmbedding(
|
||||
rule_id=rule.id, chunk_index=0, embedding=QUERY_VEC,
|
||||
chunk_text=rule.title, chunker_version=CHUNKER_VERSION,
|
||||
embedding_model=EMBEDDING_MODEL,
|
||||
))
|
||||
await s.commit()
|
||||
ids.update(glob=glob.id, on_a=on_a.id, on_b=on_b.id)
|
||||
|
||||
@@ -15,6 +15,9 @@ WHAT THIS PINS
|
||||
mid-backfill would end up with every bar at zero.
|
||||
4. **Every registry surface can be migrated.** A seventh arm that nobody adds
|
||||
a re-scorer for is one whose floor silently cannot survive a model change.
|
||||
5. **A half-migrated corpus is a refusal (#4132).** While any row the surface
|
||||
searches is stamped with another model, a re-score measures a blend of two
|
||||
geometries — so nothing is sampled until the backfill has finished.
|
||||
"""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
@@ -43,11 +46,42 @@ def _session_with(rows):
|
||||
return session
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _corpus_on_the_live_model():
|
||||
"""Every test below starts from a wholly re-embedded corpus; the one that
|
||||
is about a half-migrated corpus patches this again."""
|
||||
with patch.object(rm, "rows_off_the_live_model", AsyncMock(return_value=0)):
|
||||
yield
|
||||
|
||||
|
||||
def test_every_surface_has_a_rescorer():
|
||||
"""Otherwise a surface's floor cannot cross a model change at all."""
|
||||
assert set(rm._RESCORERS) == set(SURFACES)
|
||||
|
||||
|
||||
def test_every_surface_names_the_corpus_it_searches():
|
||||
"""Otherwise the half-migrated check has no table to count for it."""
|
||||
assert set(rm._CORPUS) == set(SURFACES)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_corpus_part_way_through_a_model_change_refuses_before_sampling():
|
||||
rows = _logs([("q1", None, 0.9), ("q2", None, 0.1)])
|
||||
rescore = AsyncMock(return_value=0.4)
|
||||
with patch.object(rm, "rows_off_the_live_model", AsyncMock(return_value=17)) as off, \
|
||||
patch.object(rm, "async_session", MagicMock(return_value=_session_with(rows))), \
|
||||
patch.object(rm, "floor_for", AsyncMock(return_value=0.5)), \
|
||||
patch.object(rm, "set_dial", AsyncMock()) as set_dial, \
|
||||
patch.dict(rm._RESCORERS, {"prompt_rule": rescore}):
|
||||
out = await rm.migrate_floor(1, "prompt_rule", apply=True)
|
||||
|
||||
assert out["migrated"] is False
|
||||
assert out["rows_off_model"] == 17
|
||||
assert off.await_args.args[0] is rm.RuleEmbedding
|
||||
rescore.assert_not_called()
|
||||
set_dial.assert_not_called()
|
||||
|
||||
|
||||
def test_the_floor_that_admits_a_fraction_is_an_observed_score():
|
||||
scores = [0.9, 0.8, 0.7, 0.6, 0.5, 0.4, 0.3, 0.2, 0.1, 0.05]
|
||||
# 30% of ten is three; the third-best score is the bar that admits exactly
|
||||
|
||||
Reference in New Issue
Block a user