feat: a creator's trickle becomes one drop instead of a card per message (4390)
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 26s
CI and images / backend-lint-and-test (push) Successful in 34s
CI and images / integration (push) Successful in 2m24s
CI and images / sign-extension (push) Successful in 3s
CI and images / build-agent (push) Successful in 5s
CI and images / build-web (push) Successful in 1m54s
CI and images / smoke-web (push) Successful in 1m0s
CI and images / promote (push) Skipped

The operator, 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.

Both join paths demand cosine <= 0.10 to a drop's FIRST image. The stages of
one piece fail that: each is nearest the one before, not the first.
`svtt_wip4` never joined `svtt_wip3` from the day before.

Measured on artist 8 before writing it:
- phash cannot see it. Stages sit 68-134 bits apart; unrelated same-artist
  pairs have a median of 126 and a p5 of 110 (lesson #4400).
- The embedding's nearest neighbour can. Every stage of three real trickles
  had a sibling as its single nearest image in the artist's library. In a
  control over all 137 recent Discord images, a nearest neighbour that was
  another message within 7 days carried the same working name 53 times out
  of 53. Mismatches start past 7 days.

A new merge pass runs last in the sweep. A later drop folds into an earlier
one within discord_group_close_after_hours (168h, the measured 7 days) when
they share a gated leading working name, or when one's image is the other's
nearest neighbour. A drop reaching several earlier drops pulls them all
together, unless two of them are named as different pieces.

A merge carries teaser links across (the payload FK would otherwise cascade
them away). Growth is stamped at the messages' own time, so merging history
never jumps an old drop to the top of the feed. Each drop records the route
it merged by, and is checked once.

Offline replay over Yellowroom's 127 drops since 2025: they become 70 posts.
The Marin trickle ("Very early Marin" -> 3 screenshots -> MarinaraSauce_base)
becomes one post of 5 by nearest neighbour. svtt, 0-k1, cnni14 and 0adm come
together by name.

FAMILY_MAX_POSTS moves to post_naming, so the grouper and the teaser card
share one definition.

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 13:47:25 -04:00
co-authored by Claude Opus 5.5
parent 7aa074d1a9
commit e08401c44a
7 changed files with 633 additions and 22 deletions
+253
View File
@@ -0,0 +1,253 @@
"""#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,
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