fix: the matcher sees a drop's names, finds merged trickles, and catches up on its own (4390, 4392)
CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 2s
CI and images / frontend-build (push) Successful in 21s
CI and images / backend-lint-and-test (push) Successful in 31s
CI and images / integration (push) Successful in 2m17s
CI and images / sign-extension (push) Successful in 3s
CI and images / build-agent (push) Successful in 6s
CI and images / build-web (push) Successful in 1m45s
CI and images / smoke-web (push) Successful in 56s
CI and images / promote (push) Skipped

Three defects found while answering "is the linking automatic":

1. The name and image checks never worked on a live drop. The corpus keyed
   every image by primary_post_id, but a drop's images belong to its member
   messages and the drop claims them only through provenance, so a drop
   looked nameless and hashless. The tests attached images to the drop
   itself, which discord_grouping never does. Images now count under the
   post a reader sees them on: a message's absorbing drop, else the post
   itself.

2. The trickle merge (e08401c) dates a drop by its first stage, days before
   the release a teaser announces, which put merged trickles outside the 24h
   window. Candidates are now found and timed by their closest member
   message. The sweep follows recently grown drops by their messages' times
   the same way.

3. A pair left pending was skipped forever: the matcher skipped every
   recorded pair, not only decided ones. Pending pairs are now re-scored in
   place and linked once conclusive. Linked and dismissed pairs are still
   never touched.

Also, "Scan now" shared the sweep's 48-hour horizon, so it could not reach
the history it is described as being for. It now scores every post by an
artist with Discord drops (rescan(full=True)).

New tests build drops the way the grouper does, with images owned by the
member messages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
This commit is contained in:
2026-09-24 14:01:07 -04:00
co-authored by Claude Opus 5.5
parent e08401c44a
commit 2f9e35390e
3 changed files with 289 additions and 27 deletions
+3 -1
View File
@@ -207,6 +207,8 @@ async def rescan_associations():
be within the window to exist at all); this is the button for a first run
over a library that predates the feature."""
async with get_session() as session:
result = await association_rescan(session)
# full=True: the button reaches the whole history, which the hourly
# sweep's 48-hour horizon never does.
result = await association_rescan(session, full=True)
await session.commit()
return jsonify(result)
+133 -26
View File
@@ -286,10 +286,28 @@ class PostAssociationService:
paths_by_post: dict[int, list[str]] = {}
hashes_by_post: dict[int, list[int]] = {}
# An image is counted under the post a reader SEES it on: a Discord
# message absorbed into a drop contributes to the drop, not to itself.
#
# Keyed on `primary_post_id` alone until 2026-09-24, which on the live
# instance meant a drop never had a name or a hash at all — its images
# are owned by its member messages, and the drop only claims them
# through provenance. The identity route therefore never fired there;
# the tests missed it because they attached images to the drop itself,
# which discord_grouping never does.
#
# Counting by the drop also makes the span honest: five wips of one
# piece posted as five messages and grouped into one drop are ONE post
# as far as "how many posts carry this name" is concerned.
owner = Post.__table__.alias("owner")
rows = await self.session.execute(
select(
ImageRecord.primary_post_id, ImageRecord.path, ImageRecord.phash
).where(
func.coalesce(owner.c.absorbed_by_post_id, ImageRecord.primary_post_id),
ImageRecord.path, ImageRecord.phash,
)
.select_from(ImageRecord)
.join(owner, owner.c.id == ImageRecord.primary_post_id)
.where(
ImageRecord.artist_id == artist_id,
ImageRecord.primary_post_id.is_not(None),
)
@@ -333,44 +351,81 @@ class PostAssociationService:
self._corpora[artist_id] = corpus
return corpus
async def _decided(self, announcement_id: int) -> set[int]:
"""Payload posts already proposed for this announcement, in ANY status.
async def _decided(self, announcement_id: int) -> dict[int, PostAssociation]:
"""Pairs already recorded for this announcement, keyed by payload.
Dismissed pairs are included deliberately: re-proposing a pair the
operator has already rejected on every subsequent scan is the single
behaviour that makes a review queue get ignored.
Linked and dismissed pairs are never touched again: re-proposing a pair
the operator has already rejected on every subsequent scan is the
single behaviour that makes a review queue get ignored.
A PENDING pair is different — nobody has decided it — so the caller
re-scores it. Otherwise a pair queued by an older, weaker matcher sits
in the queue forever even once the evidence is conclusive, which is the
chore the operator asked FC not to hand them.
"""
rows = (await self.session.execute(
select(PostAssociation.payload_post_id)
select(PostAssociation)
.where(PostAssociation.announcement_post_id == announcement_id)
)).scalars().all()
return set(rows)
return {a.payload_post_id: a for a in rows}
async def _candidate_groups(
self, announcement: Post, *, window: timedelta,
) -> list[Post]:
"""Synthetic Discord groupings by the SAME artist, inside the window.
) -> list[tuple[Post, datetime]]:
"""Synthetic Discord groupings by the SAME artist with a message inside
the window — each with the time of its message CLOSEST to the teaser.
Same-artist is the identity signal and it is free (see the module
docstring on E4). It is also a hard filter rather than a scored one:
two different creators posting minutes apart is a coincidence, not
evidence, and letting it score at all would mean a busy hour across the
library could out-vote everything else.
Measured on the MESSAGES, not the drop's own date. A drop is dated by
its first message, and since #4390 merges a creator's trickle into one
drop, that can be days before the release the teaser announces —
"Very early Marin" on Sep 7, `MarinaraSauce_base` on Sep 11. Matching
on the drop's date would put every merged trickle outside the window.
"""
at = _post_time(announcement)
sort_key = func.coalesce(Post.post_date, Post.downloaded_at)
return (await self.session.execute(
select(Post)
member = Post.__table__.alias("member")
member_at = func.coalesce(member.c.post_date, member.c.downloaded_at)
rows = list((await self.session.execute(
select(member.c.absorbed_by_post_id, member_at)
.where(
member.c.artist_id == announcement.artist_id,
member.c.absorbed_by_post_id.is_not(None),
member_at >= at - window,
member_at <= at + window,
)
)).all())
# The drop's own date counts too — the first message's, so it adds
# nothing for a real drop, but it keeps a drop with no member rows
# (hand-built, or one whose messages were removed) matchable.
own_at = func.coalesce(Post.post_date, Post.downloaded_at)
rows += (await self.session.execute(
select(Post.id, own_at).where(
Post.artist_id == announcement.artist_id,
Post.synthesized_by == DROP_GROUPER,
own_at >= at - window,
own_at <= at + window,
)
)).all()
closest: dict[int, datetime] = {}
for group_id, when in rows:
if group_id not in closest or abs(when - at) < abs(closest[group_id] - at):
closest[group_id] = when
if not closest:
return []
groups = (await self.session.execute(
select(Post).where(
Post.id.in_(list(closest)),
Post.synthesized_by == DROP_GROUPER,
Post.id != announcement.id,
sort_key >= at - window,
sort_key <= at + window,
)
.order_by(sort_key)
.limit(MAX_CANDIDATES)
)).scalars().all()
ranked = sorted(groups, key=lambda g: (abs(closest[g.id] - at), g.id))
return [(g, closest[g.id]) for g in ranked[:MAX_CANDIDATES]]
async def _claimed(self, announcement_id: int, payload_id: int) -> bool:
"""Is either end of this pair already spoken for by an accepted link?
@@ -415,8 +470,9 @@ class PostAssociationService:
made = 0
scored: list[tuple[Post, float, dict, float]] = []
for group in await self._candidate_groups(announcement, window=window):
if group.id in already:
for group, group_at in await self._candidate_groups(announcement, window=window):
prior = already.get(group.id)
if prior is not None and prior.status != "pending":
continue
named, token = shared_identity(
here,
@@ -435,7 +491,7 @@ class PostAssociationService:
identity = max(named, copied)
circumstantial = {
"proximity": proximity_signal(
_post_time(group) - _post_time(announcement), window,
group_at - _post_time(announcement), window,
),
"declared": declared,
"marker": marker_overlap(
@@ -494,6 +550,16 @@ class PostAssociationService:
status = "linked" if group.id == auto_id else "pending"
if status == "linked":
linked += 1
prior = already.get(group.id)
if prior is not None:
# Re-scored in place: a pending pair keeps its row (and id),
# and only an upgrade to linked counts as news.
prior.score = score
prior.signals = signals
if status == "linked":
prior.status = "linked"
prior.linked_by = "fc"
continue
self.session.add(PostAssociation(
announcement_post_id=announcement.id,
payload_post_id=group.id,
@@ -573,8 +639,17 @@ class PostAssociationService:
return out
async def rescan(session: AsyncSession, *, now: datetime | None = None) -> dict:
"""Score every recent non-synthetic post against nearby groupings."""
async def rescan(
session: AsyncSession, *, now: datetime | None = None, full: bool = False,
) -> dict:
"""Score recent non-synthetic posts against nearby groupings.
`full=True` scores EVERY post by an artist who has Discord drops at all —
the manual button's job, for history that predates the feature or that a
trickle merge (#4390) has just rearranged. It used to share the sweep's
48-hour horizon, so the button described as "a first run over a library
that predates the feature" could not reach that library.
"""
settings = await ImportSettings.load(session)
if not settings.discord_link_enabled:
return {"enabled": False, "scanned": 0, "proposed": 0, "linked": 0}
@@ -586,6 +661,18 @@ async def rescan(session: AsyncSession, *, now: datetime | None = None) -> dict:
# a full-library rescan is the manual button's job, not the sweep's.
horizon = now - timedelta(hours=window_hours * 2)
sort_key = func.coalesce(Post.post_date, Post.downloaded_at)
if full:
with_drops = select(Post.artist_id).where(
Post.synthesized_by == DROP_GROUPER
).distinct()
ids = set((await session.execute(
select(Post.id).where(
Post.synthesized_by.is_(None),
Post.absorbed_by_post_id.is_(None),
Post.artist_id.in_(with_drops),
)
)).scalars().all())
return await _score(session, settings, ids, window_hours)
ids = set((await session.execute(
select(Post.id).where(
Post.synthesized_by.is_(None),
@@ -605,14 +692,28 @@ async def rescan(session: AsyncSession, *, now: datetime | None = None) -> dict:
# That is #4392's third cause, and it is the one that left a measured 0.800
# pair with an empty review queue on the live instance. The other two were
# about scoring; this one meant nothing was scored at all.
drop_times = (await session.execute(
select(sort_key).where(
#
# The times are the drops' MESSAGES, not the drops' own dates: a drop that
# grew today by a trickle merge (#4390) is dated by its first stage, days
# earlier, while the teaser sits beside the message that just joined.
recent = (
select(Post.id).where(
Post.synthesized_by == DROP_GROUPER,
func.coalesce(Post.last_grew_at, Post.downloaded_at) >= horizon,
)
.order_by(func.coalesce(Post.last_grew_at, Post.downloaded_at).desc())
.limit(MAX_RECENT_DROPS)
)).scalars().all()
)
member = Post.__table__.alias("member")
drop_times = set((await session.execute(
select(func.coalesce(member.c.post_date, member.c.downloaded_at))
.where(member.c.absorbed_by_post_id.in_(recent))
)).scalars().all())
# Plus each drop's own date — its first message's, so a duplicate for a
# real drop, but what a drop with no member rows is placed by.
drop_times |= set((await session.execute(
select(sort_key).where(Post.id.in_(recent))
)).scalars().all())
# The interval arithmetic is done in Python rather than SQL: a handful of
# literal ranges is portable, and `now - INTERVAL` is not.
ranges = [
@@ -629,6 +730,12 @@ async def rescan(session: AsyncSession, *, now: datetime | None = None) -> dict:
)
)).scalars().all())
return await _score(session, settings, ids, window_hours)
async def _score(
session: AsyncSession, settings: ImportSettings, ids: set[int], window_hours: float,
) -> dict:
svc = PostAssociationService(session)
proposed = 0
linked = 0
+153
View File
@@ -950,3 +950,156 @@ def test_the_duplicate_threshold_sits_inside_the_measured_gap():
justifies it: 20 bits was the widest true pair, 108 the nearest unrelated
one."""
assert 20 < DUPLICATE_MAX_DISTANCE < 108
# --- drops shaped as the grouper actually writes them ----------------------
#
# Every test above hand-builds a drop that OWNS its images. discord_grouping
# never does that: a drop's images belong to its member messages and the drop
# claims them through provenance. Against that shape the matcher saw no names
# and no hashes at all, so the identity route could not fire on the live
# instance — and nothing here could have noticed.
async def _real_drop(db, artist, discord, *, stages):
"""Messages, each owning one named image, grouped by the real grouper and
merged by the real trickle pass. `stages` is [(at, name), ...]."""
from backend.app.services.discord_grouping import group_source, merge_trickles
for i, (at, name) in enumerate(stages):
msg = Post(source_id=discord.id, artist_id=artist.id,
external_post_id=f"{artist.slug}-msg-{i}", post_date=at)
db.add(msg)
await db.flush()
vec = [0.0] * 1152
vec[i % 1152] = 1.0
db.add(ImageRecord(
path=f"/images/{artist.slug}/20260901_{1234567890000 + i}_01_{name}.png",
sha256=f"{artist.id:08d}{i:056d}", size_bytes=10, mime="image/png",
width=10, height=10, origin="downloaded", primary_post_id=msg.id,
artist_id=artist.id, siglip_embedding=vec,
))
await db.flush()
await group_source(db, discord, max_distance=0.10, window_minutes=60)
await merge_trickles(db, discord, gap=timedelta(hours=168), min_images=2,
cooldown=timedelta(hours=24))
await db.commit()
return (await db.execute(
select(Post).where(Post.source_id == discord.id,
Post.synthesized_by == DROP_GROUPER,
Post.absorbed_by_post_id.is_(None))
)).scalars().all()
@pytest.mark.asyncio
async def test_a_drop_whose_images_belong_to_its_messages_still_links_on_its_name(db):
"""The live shape. `0-k` on the teaser and on the drop's MESSAGE, nowhere
else — conclusive, so FC links it without asking."""
artist, patreon, discord = await _artist_with_channels(db, "liveshape")
now = datetime.now(UTC) - timedelta(days=5)
(drop,) = await _real_drop(db, artist, discord, stages=[(now, "0-k_base")])
teaser = await _teaser(db, artist, patreon, at=now + timedelta(hours=20),
body="new one", names=["0-k"])
await db.commit()
made = await PostAssociationService(db).match_post(
teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW, auto_link=True,
)
await db.commit()
assert made == (1, 1)
assoc = (await db.execute(select(PostAssociation))).scalar_one()
assert (assoc.payload_post_id, assoc.signals["identity_token"]) == (drop.id, "0-k")
@pytest.mark.asyncio
async def test_a_merged_trickle_is_found_by_its_latest_stage(db):
"""A trickle merged by #4390 is dated by its FIRST stage — four days before
the release the teaser announces. Matching on the drop's own date would put
it outside the 24h window."""
artist, patreon, discord = await _artist_with_channels(db, "trickleshape")
start = datetime.now(UTC) - timedelta(days=10)
(drop,) = await _real_drop(db, artist, discord, stages=[
(start, "svtt_wip1"), (start + timedelta(days=2), "svtt_wip3"),
(start + timedelta(days=4), "svtt_drench_b"),
])
teaser = await _teaser(db, artist, patreon, at=start + timedelta(days=4, hours=1),
body="new one", names=["svtt_teaser"])
await db.commit()
made = await PostAssociationService(db).match_post(
teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW, auto_link=True,
)
await db.commit()
assert made[0] == 1
assoc = (await db.execute(select(PostAssociation))).scalar_one()
assert assoc.payload_post_id == drop.id
# Timed against the closest message, an hour away — not the first, four
# days away.
assert assoc.signals["proximity"] > 0.9
@pytest.mark.asyncio
async def test_a_pair_left_pending_is_linked_once_the_evidence_is_conclusive(db):
"""Nobody decided a pending pair, so the matcher re-scores it. Otherwise a
pair queued by an older, weaker matcher waits for a click forever."""
artist, patreon, discord = await _artist_with_channels(db, "pendingshape")
now = datetime.now(UTC) - timedelta(days=5)
(drop,) = await _real_drop(db, artist, discord, stages=[(now, "0-k_base")])
teaser = await _teaser(db, artist, patreon, at=now + timedelta(hours=2),
body="new one", names=["0-k"])
db.add(PostAssociation(announcement_post_id=teaser.id, payload_post_id=drop.id,
score=0.61, signals={"proximity": 0.9}, status="pending"))
await db.commit()
await PostAssociationService(db).match_post(
teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW, auto_link=True,
)
await db.commit()
assoc = (await db.execute(select(PostAssociation))).scalar_one()
assert (assoc.status, assoc.linked_by) == ("linked", "fc")
@pytest.mark.asyncio
async def test_a_dismissed_pair_is_never_rescored(db):
artist, patreon, discord = await _artist_with_channels(db, "dismissedshape")
now = datetime.now(UTC) - timedelta(days=5)
(drop,) = await _real_drop(db, artist, discord, stages=[(now, "0-k_base")])
teaser = await _teaser(db, artist, patreon, at=now + timedelta(hours=2),
body="new one", names=["0-k"])
db.add(PostAssociation(announcement_post_id=teaser.id, payload_post_id=drop.id,
score=0.61, signals={}, status="dismissed"))
await db.commit()
assert await PostAssociationService(db).match_post(
teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW, auto_link=True,
) == (0, 0)
assoc = (await db.execute(select(PostAssociation))).scalar_one()
assert assoc.status == "dismissed"
@pytest.mark.asyncio
async def test_the_full_rescan_reaches_history_the_sweep_does_not(db):
"""The "Scan now" button. It used to share the sweep's 48-hour horizon, so
it could not reach the library it was described as being for."""
artist, patreon, discord = await _artist_with_channels(db, "historyshape")
long_ago = datetime.now(UTC) - timedelta(days=400)
(drop,) = await _real_drop(db, artist, discord, stages=[(long_ago, "0-k_base")])
# Authored long ago too. A drop FC wrote TODAY is exactly what the sweep
# follows back to its teaser (#4392's third cause), so it would find this
# pair without the button.
drop.downloaded_at = long_ago
await _teaser(db, artist, patreon, at=long_ago + timedelta(hours=2),
body="new one", names=["0-k"])
settings = await ImportSettings.load(db)
settings.discord_link_enabled = True
await db.commit()
assert (await rescan(db))["proposed"] == 0
await db.commit()
result = await rescan(db, full=True)
await db.commit()
assert (result["proposed"], result["linked"]) == (1, 1)