Release: dev → main (first public release) #258

Merged
bvandeusen merged 94 commits from dev into main 2026-09-25 10:02:40 -04:00
3 changed files with 289 additions and 27 deletions
Showing only changes of commit 2f9e35390e - Show all commits
+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 be within the window to exist at all); this is the button for a first run
over a library that predates the feature.""" over a library that predates the feature."""
async with get_session() as session: 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() await session.commit()
return jsonify(result) return jsonify(result)
+133 -26
View File
@@ -286,10 +286,28 @@ class PostAssociationService:
paths_by_post: dict[int, list[str]] = {} paths_by_post: dict[int, list[str]] = {}
hashes_by_post: dict[int, list[int]] = {} 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( rows = await self.session.execute(
select( select(
ImageRecord.primary_post_id, ImageRecord.path, ImageRecord.phash func.coalesce(owner.c.absorbed_by_post_id, ImageRecord.primary_post_id),
).where( ImageRecord.path, ImageRecord.phash,
)
.select_from(ImageRecord)
.join(owner, owner.c.id == ImageRecord.primary_post_id)
.where(
ImageRecord.artist_id == artist_id, ImageRecord.artist_id == artist_id,
ImageRecord.primary_post_id.is_not(None), ImageRecord.primary_post_id.is_not(None),
) )
@@ -333,44 +351,81 @@ class PostAssociationService:
self._corpora[artist_id] = corpus self._corpora[artist_id] = corpus
return corpus return corpus
async def _decided(self, announcement_id: int) -> set[int]: async def _decided(self, announcement_id: int) -> dict[int, PostAssociation]:
"""Payload posts already proposed for this announcement, in ANY status. """Pairs already recorded for this announcement, keyed by payload.
Dismissed pairs are included deliberately: re-proposing a pair the Linked and dismissed pairs are never touched again: re-proposing a pair
operator has already rejected on every subsequent scan is the single the operator has already rejected on every subsequent scan is the
behaviour that makes a review queue get ignored. 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( rows = (await self.session.execute(
select(PostAssociation.payload_post_id) select(PostAssociation)
.where(PostAssociation.announcement_post_id == announcement_id) .where(PostAssociation.announcement_post_id == announcement_id)
)).scalars().all() )).scalars().all()
return set(rows) return {a.payload_post_id: a for a in rows}
async def _candidate_groups( async def _candidate_groups(
self, announcement: Post, *, window: timedelta, self, announcement: Post, *, window: timedelta,
) -> list[Post]: ) -> list[tuple[Post, datetime]]:
"""Synthetic Discord groupings by the SAME artist, inside the window. """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 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: docstring on E4). It is also a hard filter rather than a scored one:
two different creators posting minutes apart is a coincidence, not two different creators posting minutes apart is a coincidence, not
evidence, and letting it score at all would mean a busy hour across the evidence, and letting it score at all would mean a busy hour across the
library could out-vote everything else. 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) at = _post_time(announcement)
sort_key = func.coalesce(Post.post_date, Post.downloaded_at) member = Post.__table__.alias("member")
return (await self.session.execute( member_at = func.coalesce(member.c.post_date, member.c.downloaded_at)
select(Post) rows = list((await self.session.execute(
select(member.c.absorbed_by_post_id, member_at)
.where( .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.artist_id == announcement.artist_id,
Post.synthesized_by == DROP_GROUPER, 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, Post.id != announcement.id,
sort_key >= at - window,
sort_key <= at + window,
) )
.order_by(sort_key)
.limit(MAX_CANDIDATES)
)).scalars().all() )).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: async def _claimed(self, announcement_id: int, payload_id: int) -> bool:
"""Is either end of this pair already spoken for by an accepted link? """Is either end of this pair already spoken for by an accepted link?
@@ -415,8 +470,9 @@ class PostAssociationService:
made = 0 made = 0
scored: list[tuple[Post, float, dict, float]] = [] scored: list[tuple[Post, float, dict, float]] = []
for group in await self._candidate_groups(announcement, window=window): for group, group_at in await self._candidate_groups(announcement, window=window):
if group.id in already: prior = already.get(group.id)
if prior is not None and prior.status != "pending":
continue continue
named, token = shared_identity( named, token = shared_identity(
here, here,
@@ -435,7 +491,7 @@ class PostAssociationService:
identity = max(named, copied) identity = max(named, copied)
circumstantial = { circumstantial = {
"proximity": proximity_signal( "proximity": proximity_signal(
_post_time(group) - _post_time(announcement), window, group_at - _post_time(announcement), window,
), ),
"declared": declared, "declared": declared,
"marker": marker_overlap( "marker": marker_overlap(
@@ -494,6 +550,16 @@ class PostAssociationService:
status = "linked" if group.id == auto_id else "pending" status = "linked" if group.id == auto_id else "pending"
if status == "linked": if status == "linked":
linked += 1 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( self.session.add(PostAssociation(
announcement_post_id=announcement.id, announcement_post_id=announcement.id,
payload_post_id=group.id, payload_post_id=group.id,
@@ -573,8 +639,17 @@ class PostAssociationService:
return out return out
async def rescan(session: AsyncSession, *, now: datetime | None = None) -> dict: async def rescan(
"""Score every recent non-synthetic post against nearby groupings.""" 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) settings = await ImportSettings.load(session)
if not settings.discord_link_enabled: if not settings.discord_link_enabled:
return {"enabled": False, "scanned": 0, "proposed": 0, "linked": 0} 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. # a full-library rescan is the manual button's job, not the sweep's.
horizon = now - timedelta(hours=window_hours * 2) horizon = now - timedelta(hours=window_hours * 2)
sort_key = func.coalesce(Post.post_date, Post.downloaded_at) 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( ids = set((await session.execute(
select(Post.id).where( select(Post.id).where(
Post.synthesized_by.is_(None), 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 # 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 # pair with an empty review queue on the live instance. The other two were
# about scoring; this one meant nothing was scored at all. # 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, Post.synthesized_by == DROP_GROUPER,
func.coalesce(Post.last_grew_at, Post.downloaded_at) >= horizon, func.coalesce(Post.last_grew_at, Post.downloaded_at) >= horizon,
) )
.order_by(func.coalesce(Post.last_grew_at, Post.downloaded_at).desc()) .order_by(func.coalesce(Post.last_grew_at, Post.downloaded_at).desc())
.limit(MAX_RECENT_DROPS) .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 # The interval arithmetic is done in Python rather than SQL: a handful of
# literal ranges is portable, and `now - INTERVAL` is not. # literal ranges is portable, and `now - INTERVAL` is not.
ranges = [ ranges = [
@@ -629,6 +730,12 @@ async def rescan(session: AsyncSession, *, now: datetime | None = None) -> dict:
) )
)).scalars().all()) )).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) svc = PostAssociationService(session)
proposed = 0 proposed = 0
linked = 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 justifies it: 20 bits was the widest true pair, 108 the nearest unrelated
one.""" one."""
assert 20 < DUPLICATE_MAX_DISTANCE < 108 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)