Files
FabledCurator/tests/test_post_unification.py
T
bvandeusenandClaude Opus 5.5 e08401c44a
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
feat: a creator's trickle becomes one drop instead of a card per message (4390)
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
2026-09-24 13:47:25 -04:00

393 lines
15 KiB
Python

"""#4402 / #4401: a teaser's card shows what it points at — by reference.
Operator, 2026-09-24: *"the teaser from the patreon post doesn't show the items
that it's supposed to reference"*, and on how: *"discord 'posts' land as normal
and only hidden from the post view they're posted the same day. the nested
items on the unified post are a duplicate or reference of existing content."*
Two properties carry the whole design, and most of what follows pins them:
* NOTHING MOVES. A referenced Discord post keeps its row, its date and its
place in the feed; only a drop sitting beside its own teaser is left out.
* A family is the creator's LEADING working name, one hop from the seed, inside
a window. The refusals are the measured false positives, not invented ones.
"""
from collections import Counter
from datetime import UTC, datetime, timedelta
import pytest
from sqlalchemy import select
from backend.app.models import (
Artist,
ImageRecord,
ImportSettings,
Post,
PostAssociation,
Source,
)
from backend.app.services.discord_grouping import DROP_GROUPER
from backend.app.services.post_association_service import PostAssociationService
from backend.app.services.post_feed_service import PostFeedService
from backend.app.services.post_naming import FAMILY_MAX_POSTS
from backend.app.services.post_unification import Candidate, family
T0 = datetime(2026, 9, 1, 12, 0, tzinfo=UTC)
WINDOW = timedelta(days=60)
def _c(image_id, name, *, days=0, post_id=None, phash=None):
"""One image, Discord-shaped, `days` from T0."""
return Candidate(
image_id=image_id,
post_id=post_id if post_id is not None else image_id,
path=f"20260901_12345678901234{image_id:04d}_01_{name}.png",
phash=phash,
at=T0 + timedelta(days=days),
)
def _family(seed, pool, *, names=None, hashes=None):
return family(
seed, pool, Counter(names or {}), Counter(hashes or {}),
anchor=T0, window=WINDOW,
)
# --- the family rule, pure ---------------------------------------------------
def test_the_older_wips_of_the_same_piece_are_its_family():
"""The operator's ask exactly: *"yellowroom trickles out variants and I want
them to show in the grouped post even if they're older"*. Measured shape:
`Year_20k_wip1 -> wip3 -> Base -> Cndm` over 44 days."""
seed = [_c(1, "Year_20k_Cndm")]
pool = [_c(2, "Year_20k_wip1", days=-44), _c(3, "Year_20K_wip3", days=-44),
_c(4, "Year_20k_Base", days=-20)]
assert [c.image_id for c in _family(seed, pool)] == [2, 3, 4]
def test_a_family_reads_oldest_first():
"""So the card shows the trickle in the order it happened."""
seed = [_c(1, "svtt_drench_b")]
pool = [_c(2, "svtt_wip5", days=-1), _c(3, "svtt_wip1", days=-3)]
assert [c.image_id for c in _family(seed, pool)] == [3, 2]
def test_a_namesake_outside_the_window_is_not_family():
"""Time does most of the work. Measured on artist 8: every collision found
spreads over 500 days — `ashley` 1258, `anya` 1217, `image0` 1974."""
seed = [_c(1, "Ashley_TAIGA")]
pool = [_c(2, "Ashley_Re4_A", days=-1258)]
assert _family(seed, pool) == []
def test_a_shared_trailing_word_is_not_family():
"""The one plain collision inside 60 days on artist 8: `bottom`, three
posts, 56 days apart. No frequency cap can refuse a word that rare — the
leading-name rule does."""
seed = [_c(1, "Undyne_insert_bottom_only-C")]
pool = [_c(2, "Lichgalclc_Lingerie_Bottom_21", days=-56)]
assert _family(seed, pool) == []
def test_a_leading_name_the_creator_uses_everywhere_is_not_a_family():
"""A character is a habit, not a piece. At the cap the name is gated even
inside the window."""
seed = [_c(1, "Bea_Machamp_Shiny")]
pool = [_c(2, "Bea_Machoke_Shiny", days=-5)]
assert _family(seed, pool, names={"bea": FAMILY_MAX_POSTS}) == []
def test_the_family_cap_admits_the_measured_long_families():
"""`tentacooler` spans 6 posts over 7 days and `0-k1` 6 over 10 — real
families, both lost at the pairing cap of 6. That is why the family cap is
its own number."""
seed = [_c(1, "Tentacooler")]
pool = [_c(2, "Tentacooler_c_ins", days=-7)]
assert [c.image_id for c in _family(seed, pool, names={"tentacooler": 6})] == [2]
def test_a_near_duplicate_joins_the_family_without_a_name():
"""Half of this creator's teasers are screenshots, which carry no name. The
same file re-posted is still the same file."""
seed = [_c(1, "image0", phash=0b1011)]
pool = [_c(2, "image0", days=-10, phash=0b1010)]
assert [c.image_id for c in _family(seed, pool)] == [2]
def test_a_distant_hash_is_not_a_duplicate():
"""Lesson #4400: same-artist images are similar whether or not they are the
same piece, so only a NEAR-duplicate counts — the matcher's own line."""
seed = [_c(1, "image0", phash=0)]
pool = [_c(2, "image0", days=-10, phash=(1 << 100) - 1)]
assert _family(seed, pool) == []
def test_a_family_is_one_hop_from_the_seed():
"""Chaining is what lets a family drift: `alpha` reaches an image that also
carries `beta`, and `beta` must not then reach its own family."""
seed = [_c(1, "alpha_final")]
pool = [_c(2, "alpha_beta", days=-3), _c(3, "beta_wip1", days=-4)]
assert [c.image_id for c in _family(seed, pool)] == [2]
def test_the_seed_never_comes_back_as_its_own_family():
seed = [_c(1, "Year_20k_Cndm")]
assert _family(seed, seed + [_c(2, "Year_20k_Base", days=-20)])[0].image_id == 2
assert len(_family(seed, seed)) == 0
# --- the card, end to end ----------------------------------------------------
async def _channels(db, name):
artist = Artist(name=name, slug=name)
db.add(artist)
await db.flush()
patreon = Source(artist_id=artist.id, platform="patreon",
url=f"https://patreon.com/{name}", enabled=True)
discord = Source(artist_id=artist.id, platform="discord",
url=f"https://discord.com/channels/1/{name}", enabled=True)
db.add_all([patreon, discord])
await db.flush()
return artist, patreon, discord
_seq = iter(range(1, 10_000))
async def _post(db, artist, source, *, at, names, body=None, title=None, synthetic=False):
post = Post(
source_id=source.id, artist_id=artist.id,
external_post_id=f"ext-{next(_seq)}", post_date=at,
post_title=title, description=body,
synthesized_by=DROP_GROUPER if synthetic else None,
)
db.add(post)
await db.flush()
for name in names:
n = next(_seq)
db.add(ImageRecord(
# Discord-shaped whatever the platform: the prefix is stripped, so
# the NAME below is the leading name.
path=f"/images/{artist.slug}/20260901_1234567890{n:06d}_01_{name}.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,
))
await db.flush()
return post
async def _link(db, teaser, drop, *, status="linked", linked_by="fc"):
a = PostAssociation(
announcement_post_id=teaser.id, payload_post_id=drop.id, score=1.0,
signals={"identity": 1.0, "identity_token": "year"},
status=status, linked_by=linked_by if status == "linked" else None,
)
db.add(a)
await db.flush()
return a
async def _feed(db, artist):
page = await PostFeedService(db).scroll(artist_id=artist.id, limit=100)
return {item["id"]: item for item in page["items"]}
@pytest.mark.integration
@pytest.mark.asyncio
async def test_a_teaser_shows_the_drop_it_announced(db):
"""The complaint itself: the card now carries the drop's images and text."""
artist, patreon, discord = await _channels(db, "unifyartist")
teaser = await _post(db, artist, patreon, at=T0, names=["Year_20k_teaser"],
title="Year 20k", body="Full set in the server")
drop = await _post(db, artist, discord, at=T0 - timedelta(hours=1),
names=["Year_20k_Cndm", "Year_20k_Cndm_alt"],
body="@everyone 🍈🍈 the full set", synthetic=True)
assoc = await _link(db, teaser, drop)
await db.commit()
unified = (await _feed(db, artist))[teaser.id]["unified"]
assert [t["role"] for t in unified["thumbnails"]] == ["drop", "drop"]
assert {t["post_id"] for t in unified["thumbnails"]} == {drop.id}
assert unified["links"] == [{
"association_id": assoc.id, "post_id": drop.id,
"linked_by": "fc", "token": "year",
}]
assert unified["texts"][0]["text"] == "@everyone 🍈🍈 the full set"
@pytest.mark.integration
@pytest.mark.asyncio
async def test_a_drop_beside_its_teaser_leaves_the_feed(db):
"""*"only hidden from the post view they're posted the same day"*."""
artist, patreon, discord = await _channels(db, "foldartist")
teaser = await _post(db, artist, patreon, at=T0, names=["Year_20k_teaser"])
drop = await _post(db, artist, discord, at=T0 - timedelta(hours=2),
names=["Year_20k_Cndm"], synthetic=True)
await _link(db, teaser, drop)
await db.commit()
feed = await _feed(db, artist)
assert teaser.id in feed
assert drop.id not in feed
# Left out of the FEED, not out of existence: reachable by id, as every
# post is, because it is still the images' true origin.
assert (await PostFeedService(db).get_post(drop.id))["id"] == drop.id
@pytest.mark.integration
@pytest.mark.asyncio
async def test_a_drop_days_from_its_teaser_keeps_its_place(db):
"""A reference does not take anything out of history."""
artist, patreon, discord = await _channels(db, "keepartist")
teaser = await _post(db, artist, patreon, at=T0, names=["Year_20k_teaser"])
drop = await _post(db, artist, discord, at=T0 - timedelta(days=3),
names=["Year_20k_Cndm"], synthetic=True)
await _link(db, teaser, drop)
await db.commit()
feed = await _feed(db, artist)
assert drop.id in feed
assert feed[teaser.id]["unified"]["thumbnails"][0]["post_id"] == drop.id
@pytest.mark.integration
@pytest.mark.asyncio
async def test_a_proposal_changes_nothing_on_the_card(db):
"""Only a LINKED pair unifies. A pending proposal is a question for the
review queue, and rendering it would assert a link nobody made."""
artist, patreon, discord = await _channels(db, "pendingartist")
teaser = await _post(db, artist, patreon, at=T0, names=["Year_20k_teaser"])
drop = await _post(db, artist, discord, at=T0 - timedelta(hours=1),
names=["Year_20k_Cndm"], synthetic=True)
await _link(db, teaser, drop, status="pending")
await db.commit()
feed = await _feed(db, artist)
assert feed[teaser.id]["unified"] is None
assert drop.id in feed
@pytest.mark.integration
@pytest.mark.asyncio
async def test_the_older_variants_come_along_and_nothing_else(db):
"""The family reaches back 44 days for the wips — and not 90 days for a
namesake, and not at all for a message that only shares a trailing word."""
artist, patreon, discord = await _channels(db, "familyartist")
teaser = await _post(db, artist, patreon, at=T0, names=["Year_20k_teaser"])
drop = await _post(db, artist, discord, at=T0 - timedelta(hours=1),
names=["Year_20k_Cndm"], synthetic=True)
wip = await _post(db, artist, discord, at=T0 - timedelta(days=44),
names=["Year_20k_wip1"], body="wip, feedback welcome")
base = await _post(db, artist, discord, at=T0 - timedelta(days=20),
names=["Year_20K_Base"])
too_old = await _post(db, artist, discord, at=T0 - timedelta(days=90),
names=["Year_20k_old"])
trailing = await _post(db, artist, discord, at=T0 - timedelta(days=5),
names=["Other_piece_year"])
await _link(db, teaser, drop)
await db.commit()
feed = await _feed(db, artist)
unified = feed[teaser.id]["unified"]
variants = [t["post_id"] for t in unified["thumbnails"] if t["role"] == "variant"]
assert variants == [wip.id, base.id]
assert unified["variant_count"] == 2
assert too_old.id not in variants and trailing.id not in variants
assert "wip, feedback welcome" in [t["text"] for t in unified["texts"]]
# Referenced, not moved: every variant is still in the feed on its own.
assert {wip.id, base.id} <= set(feed)
@pytest.mark.integration
@pytest.mark.asyncio
async def test_the_family_window_is_the_operators_setting(db):
artist, patreon, discord = await _channels(db, "windowartist")
teaser = await _post(db, artist, patreon, at=T0, names=["Year_20k_teaser"])
drop = await _post(db, artist, discord, at=T0 - timedelta(hours=1),
names=["Year_20k_Cndm"], synthetic=True)
await _post(db, artist, discord, at=T0 - timedelta(days=20), names=["Year_20k_wip1"])
await _link(db, teaser, drop)
settings = await db.get(ImportSettings, 1)
settings.discord_family_window_days = 10
await db.commit()
unified = (await _feed(db, artist))[teaser.id]["unified"]
assert unified["variant_count"] == 0
@pytest.mark.integration
@pytest.mark.asyncio
async def test_undo_is_a_dismissal_and_everything_returns(db):
"""The operator chose *"nest automatically, with visible undo"*. The undo
is the review queue's own dismiss: the drop comes back to the feed, the
card loses its references, and the dismissed row is what stops the next
sweep linking the pair straight back."""
artist, patreon, discord = await _channels(db, "undoartist")
teaser = await _post(db, artist, patreon, at=T0, names=["Year_20k_teaser"])
drop = await _post(db, artist, discord, at=T0 - timedelta(hours=1),
names=["Year_20k_Cndm"], synthetic=True)
assoc = await _link(db, teaser, drop)
await db.commit()
await PostAssociationService(db).dismiss(assoc.id)
await db.commit()
feed = await _feed(db, artist)
assert feed[teaser.id]["unified"] is None
assert drop.id in feed
row = (await db.execute(select(PostAssociation))).scalar_one()
assert (row.status, row.linked_by) == ("dismissed", None)
@pytest.mark.integration
@pytest.mark.asyncio
async def test_an_operator_accept_is_recorded_as_theirs(db):
"""So the card does not claim FC made a link a person made."""
artist, patreon, discord = await _channels(db, "acceptartist")
teaser = await _post(db, artist, patreon, at=T0, names=["Year_20k_teaser"])
drop = await _post(db, artist, discord, at=T0 - timedelta(hours=1),
names=["Year_20k_Cndm"], synthetic=True)
assoc = await _link(db, teaser, drop, status="pending")
await db.commit()
await PostAssociationService(db).accept(assoc.id)
await db.commit()
unified = (await _feed(db, artist))[teaser.id]["unified"]
assert unified["links"][0]["linked_by"] == "operator"
@pytest.mark.integration
@pytest.mark.asyncio
async def test_a_fold_window_of_zero_hides_nothing(db):
artist, patreon, discord = await _channels(db, "nofoldartist")
teaser = await _post(db, artist, patreon, at=T0, names=["Year_20k_teaser"])
drop = await _post(db, artist, discord, at=T0 - timedelta(hours=1),
names=["Year_20k_Cndm"], synthetic=True)
await _link(db, teaser, drop)
settings = await db.get(ImportSettings, 1)
settings.discord_link_fold_hours = 0
await db.commit()
assert drop.id in await _feed(db, artist)