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
393 lines
15 KiB
Python
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)
|