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
+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)