CI and images / lint (push) Successful in 3s
CI and images / extension-version (push) Successful in 3s
CI and images / frontend-build (push) Successful in 19s
CI and images / backend-lint-and-test (push) Successful in 32s
CI and images / integration (push) Successful in 2m17s
CI and images / sign-extension (push) Successful in 4s
CI and images / build-agent (push) Successful in 6s
CI and images / build-web (push) Successful in 1m40s
CI and images / smoke-web (push) Successful in 53s
CI and images / promote (push) Skipped
Grouping read a message's images through primary_post_id alone, which only the first message imported with a file holds. The backfill runs newest-first, so the original message usually owned nothing: 101 of Yellowroom's messages (mostly 2018-2020) could never be grouped. Every image they carried also sat in another message (296 links, measured on the live instance). _message_images unions ownership with provenance for the candidate query, the drop seed, the member image links, and the merge pass. Nothing is re-owned. A later drop carrying the very file an earlier one carries now merges by a new same_image route, which nearest-neighbour could not see because it skips the image itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
315 lines
12 KiB
Python
315 lines
12 KiB
Python
"""#4390: a creator's trickle is one drop, not a card per message.
|
|
|
|
Operator, 2026-09-24, on a feed of "Grouped from 1 Discord message" cards:
|
|
*"the groups are still single image even when they can clearly be seen as
|
|
group"*. 665 of Yellowroom's 714 drops were one message, because both join
|
|
paths demand closeness to a drop's FIRST image and a piece's stages drift away
|
|
from it — each is nearest the one before, not the first.
|
|
|
|
The merge pass joins a later drop to an earlier one within 7 days when they
|
|
share a working name, or when one's image is the other's nearest neighbour in
|
|
the artist's library. Vectors here are built at stated angles (see
|
|
test_discord_grouping._vec) so each test says exactly which image is nearest
|
|
to which, rather than hoping.
|
|
"""
|
|
import math
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
import pytest
|
|
from sqlalchemy import select
|
|
|
|
from backend.app.models import (
|
|
Artist,
|
|
ImageProvenance,
|
|
ImageRecord,
|
|
Post,
|
|
PostAssociation,
|
|
Source,
|
|
)
|
|
from backend.app.services.discord_grouping import (
|
|
DROP_GROUPER,
|
|
_compatible,
|
|
group_source,
|
|
merge_trickles,
|
|
)
|
|
|
|
pytestmark = pytest.mark.integration
|
|
|
|
DIM = 1152
|
|
GAP = timedelta(hours=168)
|
|
T0 = datetime.now(UTC) - timedelta(days=30)
|
|
|
|
|
|
def _vec(angle: float) -> list[float]:
|
|
v = [0.0] * DIM
|
|
v[0] = math.cos(angle)
|
|
v[1] = math.sin(angle)
|
|
return v
|
|
|
|
|
|
# --- the compatibility rule, pure --------------------------------------------
|
|
|
|
|
|
def test_unnamed_stages_fit_with_anything():
|
|
"""The Marin case: canvas screenshots carry no name, and two early stages
|
|
both nearest to one later stage are one trickle, not an ambiguity."""
|
|
assert _compatible([set(), set(), set()])
|
|
assert _compatible([set(), {"svtt"}])
|
|
|
|
|
|
def test_two_differently_named_pieces_never_meet():
|
|
assert not _compatible([{"alpha"}, {"beta"}, set()])
|
|
|
|
|
|
def test_pieces_sharing_a_name_are_one_piece():
|
|
assert _compatible([{"year", "20k"}, {"year"}])
|
|
|
|
|
|
# --- end to end --------------------------------------------------------------
|
|
|
|
|
|
async def _seed(db, name):
|
|
artist = Artist(name=name, slug=name)
|
|
db.add(artist)
|
|
await db.flush()
|
|
source = Source(artist_id=artist.id, platform="discord",
|
|
url=f"https://discord.com/channels/1/{name}", enabled=True)
|
|
db.add(source)
|
|
await db.flush()
|
|
return artist, source
|
|
|
|
|
|
_n = iter(range(1, 100_000))
|
|
|
|
|
|
async def _message(db, artist, source, *, at, angle, name=None, text=None):
|
|
n = next(_n)
|
|
post = Post(source_id=source.id, artist_id=artist.id,
|
|
external_post_id=f"msg-{n}", post_date=at, description=text)
|
|
db.add(post)
|
|
await db.flush()
|
|
# gallery-dl's Discord shape, so the prefix strips and NAME is the working
|
|
# name. Unnamed messages are canvas screenshots, which carry none.
|
|
stem = name or f"Screenshot_2026-09-08_{n:06d}"
|
|
db.add(ImageRecord(
|
|
path=f"/images/{artist.slug}/20260901_{1234567890000 + n}_01_{stem}.png",
|
|
sha256=f"{n:064d}", size_bytes=10, mime="image/png", width=10, height=10,
|
|
origin="downloaded", primary_post_id=post.id, artist_id=artist.id,
|
|
siglip_embedding=_vec(angle),
|
|
))
|
|
await db.flush()
|
|
return post
|
|
|
|
|
|
async def _group_then_merge(db, source):
|
|
"""E2 makes the singletons exactly as it does live; then the merge pass."""
|
|
await group_source(db, source, max_distance=0.10, window_minutes=60)
|
|
merged = await merge_trickles(
|
|
db, source, gap=GAP, min_images=2, cooldown=timedelta(hours=24),
|
|
)
|
|
await db.commit()
|
|
return merged
|
|
|
|
|
|
async def _drops(db, source):
|
|
return (await db.execute(
|
|
select(Post).where(
|
|
Post.source_id == source.id,
|
|
Post.synthesized_by == DROP_GROUPER,
|
|
Post.absorbed_by_post_id.is_(None),
|
|
).order_by(Post.post_date)
|
|
)).scalars().all()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stages_a_day_apart_become_one_drop(db):
|
|
"""The screenshot, measured shape: each stage 0.12 from the next and 0.46
|
|
from the first, so seed distance splits them and nearest-neighbour joins
|
|
them."""
|
|
artist, source = await _seed(db, "trickle-artist")
|
|
a = await _message(db, artist, source, at=T0, angle=0.0, text="Very early Marin.")
|
|
b = await _message(db, artist, source, at=T0 + timedelta(hours=17), angle=0.5,
|
|
text="Might get mirrored.")
|
|
c = await _message(db, artist, source, at=T0 + timedelta(hours=20), angle=1.0,
|
|
text="Got there eventually.")
|
|
await db.commit()
|
|
|
|
assert await _group_then_merge(db, source) == 2
|
|
|
|
(drop,) = await _drops(db, source)
|
|
assert set(drop.synthesis_details["member_post_ids"]) == {a.id, b.id, c.id}
|
|
assert drop.synthesis_details["message_count"] == 3
|
|
# The body is every message's text, in arrival order.
|
|
assert drop.description.index("Very early") < drop.description.index("Got there")
|
|
# And it says why — a grouping FC invented has to be checkable.
|
|
assert {m["route"] for m in drop.synthesis_details["merged"]} == {"nearest"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_shared_working_name_joins_across_days(db):
|
|
"""`svtt_wip3` did not join `svtt_wip4` from the day before on the live
|
|
instance. Orthogonal vectors here, so only the name can do it — and the
|
|
route it records says so."""
|
|
artist, source = await _seed(db, "named-artist")
|
|
await _message(db, artist, source, at=T0, angle=0.0, name="svtt_wip3")
|
|
await _message(db, artist, source, at=T0 + timedelta(days=2), angle=math.pi / 2,
|
|
name="svtt_drench_b")
|
|
await db.commit()
|
|
|
|
await _group_then_merge(db, source)
|
|
|
|
(drop,) = await _drops(db, source)
|
|
assert drop.synthesis_details["merged"][0]["route"] == "name:svtt"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_nothing_joins_across_a_quiet_week(db):
|
|
"""Measured: where the nearest neighbour was another message within 7 days
|
|
the names agreed 53 times of 53; past 7 days they begin to disagree."""
|
|
artist, source = await _seed(db, "quiet-artist")
|
|
await _message(db, artist, source, at=T0, angle=0.0, name="alpha_wip1")
|
|
await _message(db, artist, source, at=T0 + timedelta(days=8), angle=0.05,
|
|
name="alpha_base")
|
|
await db.commit()
|
|
|
|
assert await _group_then_merge(db, source) == 0
|
|
assert len(await _drops(db, source)) == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_two_named_pieces_meeting_through_a_third_stay_apart(db):
|
|
"""C's image is nearest A's; B's image is nearest C's. A is `alpha`, B is
|
|
`beta` — so C reaches two different pieces and nothing moves."""
|
|
artist, source = await _seed(db, "bridge-artist")
|
|
await _message(db, artist, source, at=T0, angle=0.0, name="alpha")
|
|
await _message(db, artist, source, at=T0 + timedelta(hours=3), angle=0.6, name="beta")
|
|
await _message(db, artist, source, at=T0 + timedelta(days=1), angle=0.25)
|
|
await db.commit()
|
|
|
|
assert await _group_then_merge(db, source) == 0
|
|
assert len(await _drops(db, source)) == 3
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_teaser_link_survives_its_drop_being_merged(db):
|
|
"""The association's payload FK cascades. Merging the drop a teaser was
|
|
linked to must carry the link across, or it silently undoes #4402."""
|
|
artist, source = await _seed(db, "linked-artist")
|
|
patreon = Source(artist_id=artist.id, platform="patreon",
|
|
url="https://patreon.com/linked-artist", enabled=True)
|
|
db.add(patreon)
|
|
await db.flush()
|
|
teaser = Post(source_id=patreon.id, artist_id=artist.id, external_post_id="teaser",
|
|
post_date=T0 + timedelta(days=1, hours=2))
|
|
db.add(teaser)
|
|
await _message(db, artist, source, at=T0, angle=0.0, name="svtt_wip3")
|
|
await _message(db, artist, source, at=T0 + timedelta(days=1), angle=math.pi / 2,
|
|
name="svtt_drench_b")
|
|
await db.commit()
|
|
await group_source(db, source, max_distance=0.10, window_minutes=60)
|
|
later = (await _drops(db, source))[-1]
|
|
db.add(PostAssociation(announcement_post_id=teaser.id, payload_post_id=later.id,
|
|
score=1.0, status="linked", linked_by="fc"))
|
|
await db.commit()
|
|
|
|
await merge_trickles(db, source, gap=GAP, min_images=2, cooldown=timedelta(hours=24))
|
|
await db.commit()
|
|
|
|
(drop,) = await _drops(db, source)
|
|
link = (await db.execute(select(PostAssociation))).scalar_one()
|
|
assert (link.payload_post_id, link.status, link.linked_by) == (drop.id, "linked", "fc")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_merging_history_does_not_drag_it_to_the_top_of_the_feed(db):
|
|
"""Growth is stamped at the merged messages' own time. A merge the first
|
|
sweep makes over two-year-old drops must not read as news today."""
|
|
artist, source = await _seed(db, "history-artist")
|
|
old = datetime.now(UTC) - timedelta(days=700)
|
|
await _message(db, artist, source, at=old, angle=0.0, name="svtt_wip1")
|
|
await _message(db, artist, source, at=old + timedelta(days=1), angle=0.5, name="svtt_wip2")
|
|
await _message(db, artist, source, at=old + timedelta(days=2), angle=1.0, name="svtt_base")
|
|
await db.commit()
|
|
|
|
await _group_then_merge(db, source)
|
|
|
|
(drop,) = await _drops(db, source)
|
|
assert drop.last_grew_at <= old + timedelta(days=2, minutes=1)
|
|
assert drop.resurfaced_at is None or drop.resurfaced_at <= old + timedelta(days=2, minutes=1)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_checked_drop_is_not_examined_again(db):
|
|
"""Each drop costs one nearest-neighbour query per image, once. The flag is
|
|
what stops every hourly sweep repeating the whole history."""
|
|
artist, source = await _seed(db, "checked-artist")
|
|
await _message(db, artist, source, at=T0, angle=0.0, name="alpha")
|
|
await _message(db, artist, source, at=T0 + timedelta(days=3), angle=math.pi / 2,
|
|
name="beta")
|
|
await db.commit()
|
|
await _group_then_merge(db, source)
|
|
|
|
for drop in await _drops(db, source):
|
|
assert drop.synthesis_details["trickle_checked"] is True
|
|
assert "nearest_message_ids" in drop.synthesis_details
|
|
|
|
|
|
# --- the same file posted twice -----------------------------------------------
|
|
# 101 of Yellowroom's messages were never grouped: every image they carried also
|
|
# sat in another message, which the newest-first backfill had made its owner.
|
|
|
|
|
|
async def _repost(db, artist, source, *, at, of, text=None):
|
|
"""A message carrying a file another message already owns."""
|
|
n = next(_n)
|
|
post = Post(source_id=source.id, artist_id=artist.id,
|
|
external_post_id=f"msg-{n}", post_date=at, description=text)
|
|
db.add(post)
|
|
await db.flush()
|
|
image_id = (await db.execute(
|
|
select(ImageRecord.id).where(ImageRecord.primary_post_id == of.id)
|
|
)).scalar_one()
|
|
db.add(ImageProvenance(image_record_id=image_id, post_id=post.id, source_id=source.id))
|
|
await db.flush()
|
|
return post
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_message_that_only_reposts_an_image_is_still_grouped(db):
|
|
artist, source = await _seed(db, "repost-artist")
|
|
later = await _message(db, artist, source, at=T0, angle=0.0)
|
|
original = await _repost(db, artist, source, at=T0 - timedelta(days=400), of=later,
|
|
text="first posted here")
|
|
await db.commit()
|
|
|
|
await group_source(db, source, max_distance=0.10, window_minutes=60)
|
|
await db.commit()
|
|
|
|
await db.refresh(original)
|
|
assert original.absorbed_by_post_id is not None
|
|
# Grouping links, it never re-owns: the image keeps its primary post.
|
|
owner = (await db.execute(
|
|
select(ImageRecord.primary_post_id).join(
|
|
ImageProvenance, ImageProvenance.image_record_id == ImageRecord.id,
|
|
).where(ImageProvenance.post_id == original.absorbed_by_post_id)
|
|
)).scalar_one()
|
|
assert owner == later.id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_the_same_file_posted_a_day_apart_is_one_drop(db):
|
|
"""The live pair: 32554 (Aug 25) and 32553 (Aug 26) carry one file.
|
|
Nearest-neighbour cannot see it — it skips the image itself."""
|
|
artist, source = await _seed(db, "twice-artist")
|
|
second = await _message(db, artist, source, at=T0 + timedelta(days=1), angle=0.0)
|
|
first = await _repost(db, artist, source, at=T0, of=second)
|
|
await db.commit()
|
|
|
|
await _group_then_merge(db, source)
|
|
|
|
(drop,) = await _drops(db, source)
|
|
assert set(drop.synthesis_details["member_post_ids"]) | {
|
|
m for r in drop.synthesis_details.get("merged", []) for m in r["message_ids"]
|
|
} >= {first.id, second.id}
|
|
assert drop.synthesis_details["merged"][0]["route"] == "same_image"
|