feat: FC links a conclusive pair itself instead of asking (4392)
CI and images / lint (push) Failing after 2s
CI and images / extension-version (push) Successful in 2s
CI and images / frontend-build (push) Successful in 20s
CI and images / backend-lint-and-test (push) Successful in 31s
CI and images / integration (push) Failing after 2m15s
CI and images / sign-extension (push) Skipped
CI and images / build-web (push) Skipped
CI and images / smoke-web (push) Skipped
CI and images / promote (push) Skipped
CI and images / build-agent (push) Skipped

Operator, 2026-09-24: *"I don't want this to be manual that defeats the
convenience that I'm going for."*

Confirm-only was right while every signal was circumstantial. Time proximity
and a body that mentions Discord can never be more than suggestive, so asking
was the honest response to what FC actually knew. A shared working name is
different in kind: when the creator's own name for a piece appears in exactly
these two posts and nowhere else in their library, there is nothing left for
the operator to adjudicate, and asking is a chore FC invented for them.

AUTO_LINK_FLOOR is 1.0 and sits deliberately above IDENTITY_FLOOR's 0.75. The
gap between them IS the review queue — real evidence, not certain enough for
FC to act on alone. Measured on artist 8, of 15 name-sharing pairs: 11 are
conclusive, 2 more propose, 2 fall short of both.

Three refusals, because an auto-link is FC asserting something the operator
never saw:

  * Exactly one candidate may be conclusive. Two is not a tie to be broken by
    score — one post can tease two pieces dropped separately, and then each
    drop carries a different name from the same teaser, each individually
    conclusive. Two conclusive answers to "which drop is this" means the
    question was wrong, so both queue and FC says nothing.
  * Neither end may already be claimed by an accepted link. That link is the
    operator's decision and reassigning its other end would overrule them
    silently.
  * The whole thing is one setting, defaulting on, reversible in the UI —
    an accepted link is a row they can dismiss.

`match_post` now returns (proposed, linked) so a sweep can report what it did
on its own rather than only what it queued.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
This commit is contained in:
2026-09-24 08:14:04 -04:00
co-authored by Claude Opus 5
parent 81f23991e9
commit 31de54e326
7 changed files with 369 additions and 28 deletions
@@ -0,0 +1,44 @@
"""discord_link_auto — whether FC links a conclusive pair without asking.
Operator, 2026-09-24: *"I don't want this to be manual that defeats the
convenience that I'm going for."*
Confirm-only was right while every signal was circumstantial. Time proximity
and a body that mentions Discord can never be more than suggestive, so asking
was the honest response. A shared working name is different in kind: when the
creator's own name for a piece appears in exactly these two posts and nowhere
else in their library, there is nothing left for the operator to adjudicate,
and asking is just a chore FC invented for them.
Defaults ON, which is a real change of posture and deliberate. It only governs
the conclusive band — weaker evidence still queues — and a link is a row the
operator can dismiss, so the reversal is a click rather than a migration.
Revision ID: 0109
Revises: 0108
Create Date: 2026-09-24
"""
import sqlalchemy as sa
from alembic import op
revision = "0109"
down_revision = "0108"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"import_settings",
sa.Column(
"discord_link_auto",
sa.Boolean(),
nullable=False,
server_default=sa.text("true"),
),
)
def downgrade():
op.drop_column("import_settings", "discord_link_auto")
+5
View File
@@ -44,6 +44,7 @@ _EDITABLE_FIELDS = (
"discord_link_enabled", "discord_link_enabled",
"discord_link_threshold", "discord_link_threshold",
"discord_link_window_hours", "discord_link_window_hours",
"discord_link_auto",
"extdl_mega_enabled", "extdl_mega_enabled",
"extdl_gdrive_enabled", "extdl_gdrive_enabled",
"extdl_mediafire_enabled", "extdl_mediafire_enabled",
@@ -165,6 +166,10 @@ async def update_import_settings():
body["discord_link_enabled"], bool body["discord_link_enabled"], bool
): ):
return jsonify({"error": "discord_link_enabled must be a boolean"}), 400 return jsonify({"error": "discord_link_enabled must be a boolean"}), 400
if "discord_link_auto" in body and not isinstance(
body["discord_link_auto"], bool
):
return jsonify({"error": "discord_link_auto must be a boolean"}), 400
if "discord_link_threshold" in body: if "discord_link_threshold" in body:
v = body["discord_link_threshold"] v = body["discord_link_threshold"]
if not isinstance(v, (int, float)) or isinstance(v, bool) or v < 0 or v > 1: if not isinstance(v, (int, float)) or isinstance(v, bool) or v < 0 or v > 1:
+19
View File
@@ -139,6 +139,25 @@ class ImportSettings(Base):
server_default="24", server_default="24",
) )
# Whether FC links a CONCLUSIVE pair without asking.
#
# Operator, 2026-09-24: *"I don't want this to be manual that defeats the
# convenience that I'm going for."* Confirm-only was the right default
# while the only signals were circumstantial — proximity and a body that
# mentions Discord can never be more than suggestive, and asking was the
# honest response to that. A shared working name is different in kind: when
# the name appears in exactly these two posts and nowhere else in the
# artist's library, there is nothing for the operator to adjudicate.
#
# Only the conclusive band is affected (post_association_service.
# AUTO_LINK_FLOOR). Everything weaker still queues, and an accepted link is
# a row the operator can dismiss, so this is reversible in the UI rather
# than only in the database.
discord_link_auto: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True,
server_default="true",
)
# #830 off-platform file-host downloads — per-host enable lever (default on, # #830 off-platform file-host downloads — per-host enable lever (default on,
# rule #26). Column names are extdl_<host>_enabled so the worker reads them # rule #26). Column names are extdl_<host>_enabled so the worker reads them
# via getattr(settings, f"extdl_{host}_enabled", True). # via getattr(settings, f"extdl_{host}_enabled", True).
@@ -129,6 +129,20 @@ MAX_CANDIDATES = 25
# the manual button's job. # the manual button's job.
MAX_RECENT_DROPS = 200 MAX_RECENT_DROPS = 200
# What a shared name must reach before FC links a pair WITHOUT asking.
#
# 1.0, which under post_naming's post-span counting means the name appears in
# exactly these two posts and nowhere else in the artist's library. That is not
# "strong evidence" — within the library it is conclusive, and the remaining
# ways to be wrong are a mis-parse or the creator reusing a name for a genuinely
# different piece on the same day.
#
# Deliberately above IDENTITY_FLOOR, which is what a name needs to PROPOSE.
# The gap between them is the review queue: real evidence, not certain enough
# for FC to act on by itself. Measured on artist 8, 15 name-sharing pairs: 11
# are conclusive, 2 more propose, 2 fall short of both.
AUTO_LINK_FLOOR = 1.0
@dataclass(frozen=True) @dataclass(frozen=True)
class _Corpus: class _Corpus:
@@ -272,14 +286,38 @@ class PostAssociationService:
.limit(MAX_CANDIDATES) .limit(MAX_CANDIDATES)
)).scalars().all() )).scalars().all()
async def _claimed(self, announcement_id: int, payload_id: int) -> bool:
"""Is either end of this pair already spoken for by an accepted link?
An auto-link is FC asserting something the operator never saw, so it
only happens where there is nothing to contradict. A drop already
linked to a different announcement is exactly such a contradiction, and
resolving it is a judgement about which one is right — which is the
operator's, not FC's.
"""
return (await self.session.execute(
select(PostAssociation.id).where(
PostAssociation.status == "linked",
or_(
PostAssociation.payload_post_id == payload_id,
PostAssociation.announcement_post_id == announcement_id,
),
).limit(1)
)).scalar() is not None
async def match_post( async def match_post(
self, announcement_id: int, *, threshold: float, window_hours: float, self, announcement_id: int, *, threshold: float, window_hours: float,
) -> int: auto_link: bool = False,
"""Score one announcement against nearby groupings. Returns proposals made.""" ) -> tuple[int, int]:
"""Score one announcement against nearby groupings.
Returns `(proposed, linked)` — how many pairs were written, and how
many of those were linked outright rather than queued.
"""
announcement = await self.session.get(Post, announcement_id) announcement = await self.session.get(Post, announcement_id)
if announcement is None or announcement.synthesized_by is not None: if announcement is None or announcement.synthesized_by is not None:
# A synthetic post cannot announce anything — FC wrote it. # A synthetic post cannot announce anything — FC wrote it.
return 0 return 0, 0
window = timedelta(hours=window_hours) window = timedelta(hours=window_hours)
declared = declared_signal(announcement.description) declared = declared_signal(announcement.description)
@@ -289,6 +327,7 @@ class PostAssociationService:
here_text = corpus.text_by_post.get(announcement.id, "") here_text = corpus.text_by_post.get(announcement.id, "")
made = 0 made = 0
scored: list[tuple[Post, float, dict, float]] = []
for group in await self._candidate_groups(announcement, window=window): for group in await self._candidate_groups(announcement, window=window):
if group.id in already: if group.id in already:
continue continue
@@ -335,15 +374,35 @@ class PostAssociationService:
# Carried so the queue can say WHY. A review queue that cannot # Carried so the queue can say WHY. A review queue that cannot
# explain itself is one the operator learns to click through. # explain itself is one the operator learns to click through.
signals["identity_token"] = token signals["identity_token"] = token
scored.append((group, score, signals, identity))
# Who, if anyone, FC links without asking.
#
# EXACTLY ONE candidate may be conclusive. Two drops sharing a name
# with one teaser at full strength is not a tie to be broken by score —
# it means the name identifies something other than what FC thinks it
# does, and the right response is to queue both and say nothing.
auto_id = None
if auto_link:
conclusive = [c for c in scored if c[3] >= AUTO_LINK_FLOOR]
if len(conclusive) == 1 and not await self._claimed(
announcement.id, conclusive[0][0].id
):
auto_id = conclusive[0][0].id
linked = 0
for group, score, signals, identity in scored:
status = "linked" if group.id == auto_id else "pending"
linked += status == "linked"
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,
score=score, score=score,
signals=signals, signals=signals,
status="pending", status=status,
)) ))
made += 1 made += 1
return made return made, linked
async def list_pending(self) -> list[dict]: async def list_pending(self) -> list[dict]:
rows = (await self.session.execute( rows = (await self.session.execute(
@@ -412,7 +471,7 @@ async def rescan(session: AsyncSession, *, now: datetime | None = None) -> dict:
"""Score every recent non-synthetic post against nearby groupings.""" """Score every recent non-synthetic post against nearby groupings."""
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} return {"enabled": False, "scanned": 0, "proposed": 0, "linked": 0}
now = now or datetime.now(UTC) now = now or datetime.now(UTC)
window_hours = float(settings.discord_link_window_hours) window_hours = float(settings.discord_link_window_hours)
@@ -466,17 +525,25 @@ async def rescan(session: AsyncSession, *, now: datetime | None = None) -> dict:
svc = PostAssociationService(session) svc = PostAssociationService(session)
proposed = 0 proposed = 0
linked = 0
# Sorted because `ids` is now a union of two queries: set iteration order # Sorted because `ids` is now a union of two queries: set iteration order
# is arbitrary, and a sweep that visits posts in a different order each # is arbitrary, and a sweep that visits posts in a different order each
# run is one whose failures cannot be reproduced. # run is one whose failures cannot be reproduced.
for pid in sorted(ids): for pid in sorted(ids):
proposed += await svc.match_post( made, auto = await svc.match_post(
pid, pid,
threshold=float(settings.discord_link_threshold), threshold=float(settings.discord_link_threshold),
window_hours=window_hours, window_hours=window_hours,
auto_link=bool(settings.discord_link_auto),
) )
proposed += made
linked += auto
log.info( log.info(
"discord announcement matcher: scanned %d post(s), proposed %d pair(s)", "discord announcement matcher: scanned %d post(s), proposed %d pair(s), "
len(ids), proposed, "linked %d outright",
len(ids), proposed, linked,
) )
return {"enabled": True, "scanned": len(ids), "proposed": proposed} return {
"enabled": True, "scanned": len(ids), "proposed": proposed,
"linked": linked,
}
+9 -5
View File
@@ -1184,12 +1184,16 @@ def group_discord_drops() -> str:
soft_time_limit=900, time_limit=1200, soft_time_limit=900, time_limit=1200,
) )
def match_post_associations() -> str: def match_post_associations() -> str:
"""Milestone 388 E5: propose which Patreon post announced which Discord drop. """Milestone 388 E5: which Patreon post announced which Discord drop.
Proposes only — every pair lands in a review queue and nothing is linked A CONCLUSIVE pair — one the creator's own working name identifies, where
until the operator accepts. Maintenance lane for the same reason as the that name appears in these two posts and nowhere else in their library — is
grouper: no inference, no ML library, and it must not depend on the linked outright when `discord_link_auto` is on, because there is nothing
optional ml-worker being present. there for the operator to adjudicate. Everything weaker lands in the review
queue and stays unlinked until they accept it.
Maintenance lane for the same reason as the grouper: no inference, no ML
library, and it must not depend on the optional ml-worker being present.
""" """
import asyncio import asyncio
+32
View File
@@ -103,3 +103,35 @@ async def test_a_negative_window_is_refused(client):
"/api/settings/import", json={"download_revisit_days": -1} "/api/settings/import", json={"download_revisit_days": -1}
) )
assert resp.status_code == 400 assert resp.status_code == 400
# --- the announcement matcher's own switch ---------------------------------
@pytest.mark.asyncio
async def test_auto_linking_is_on_by_default(client):
"""Operator, 2026-09-24: "I don't want this to be manual that defeats the
convenience that I'm going for." Defaulting ON is the change of posture
that request asks for, so the default is what this pins."""
resp = await client.get("/api/settings/import")
assert resp.status_code == 200
assert (await resp.get_json())["discord_link_auto"] is True
@pytest.mark.asyncio
async def test_auto_linking_can_be_turned_off(client):
"""Confirm-only has to stay reachable — linking without asking is a change
of posture, and not everyone wants it."""
resp = await client.patch(
"/api/settings/import", json={"discord_link_auto": False}
)
assert resp.status_code == 200
assert (await resp.get_json())["discord_link_auto"] is False
@pytest.mark.asyncio
async def test_auto_linking_refuses_a_non_boolean(client):
resp = await client.patch(
"/api/settings/import", json={"discord_link_auto": "yes"}
)
assert resp.status_code == 400
+183 -13
View File
@@ -185,7 +185,7 @@ async def test_a_teaser_and_its_drop_an_hour_apart_are_proposed(db):
teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW, teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW,
) )
await db.commit() await db.commit()
assert made == 1 assert made == (1, 0)
assoc = (await db.execute(select(PostAssociation))).scalar_one() assoc = (await db.execute(select(PostAssociation))).scalar_one()
assert assoc.announcement_post_id == teaser.id assert assoc.announcement_post_id == teaser.id
@@ -214,7 +214,7 @@ async def test_two_unrelated_posts_the_same_day_are_not_proposed(db):
teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW, teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW,
) )
await db.commit() await db.commit()
assert made == 0 assert made == (0, 0)
assert (await db.execute(select(PostAssociation))).scalars().all() == [] assert (await db.execute(select(PostAssociation))).scalars().all() == []
@@ -231,7 +231,7 @@ async def test_a_drop_outside_the_window_is_not_proposed(db):
assert await PostAssociationService(db).match_post( assert await PostAssociationService(db).match_post(
teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW, teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW,
) == 0 ) == (0, 0)
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -250,7 +250,7 @@ async def test_another_artists_drop_is_never_proposed(db):
assert await PostAssociationService(db).match_post( assert await PostAssociationService(db).match_post(
teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW, teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW,
) == 0 ) == (0, 0)
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -272,7 +272,7 @@ async def test_an_artist_with_no_discord_source_produces_nothing_and_no_error(db
assert await PostAssociationService(db).match_post( assert await PostAssociationService(db).match_post(
teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW, teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW,
) == 0 ) == (0, 0)
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -287,7 +287,7 @@ async def test_a_synthetic_post_cannot_announce_anything(db):
assert await PostAssociationService(db).match_post( assert await PostAssociationService(db).match_post(
drop_a.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW, drop_a.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW,
) == 0 ) == (0, 0)
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -315,7 +315,7 @@ async def test_a_dismissed_pair_is_never_proposed_again(db):
assert await svc.match_post( assert await svc.match_post(
teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW, teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW,
) == 0 ) == (0, 0)
await db.commit() await db.commit()
assert len((await db.execute(select(PostAssociation))).scalars().all()) == 1 assert len((await db.execute(select(PostAssociation))).scalars().all()) == 1
@@ -384,7 +384,9 @@ async def test_the_rescan_is_a_no_op_when_the_switch_is_off(db):
settings = await ImportSettings.load(db) settings = await ImportSettings.load(db)
settings.discord_link_enabled = False settings.discord_link_enabled = False
await db.commit() await db.commit()
assert await rescan(db) == {"enabled": False, "scanned": 0, "proposed": 0} assert await rescan(db) == {
"enabled": False, "scanned": 0, "proposed": 0, "linked": 0,
}
# --- the identity route ---------------------------------------------------- # --- the identity route ----------------------------------------------------
@@ -422,7 +424,7 @@ async def test_a_shared_working_name_proposes_a_pair_time_cannot_reach(db):
) )
await db.commit() await db.commit()
assert made == 1 assert made == (1, 0)
assoc = (await db.execute(select(PostAssociation))).scalar_one() assoc = (await db.execute(select(PostAssociation))).scalar_one()
assert assoc.payload_post_id == drop.id assert assoc.payload_post_id == drop.id
assert assoc.signals["identity"] == 1.0 assert assoc.signals["identity"] == 1.0
@@ -484,7 +486,7 @@ async def test_a_name_the_creator_reuses_everywhere_links_nothing(db):
) )
await db.commit() await db.commit()
assert made == 0 assert made == (0, 0)
assert (await db.execute(select(PostAssociation))).scalars().all() == [] assert (await db.execute(select(PostAssociation))).scalars().all() == []
@@ -518,7 +520,7 @@ async def test_a_name_below_the_floor_is_recorded_but_carries_nothing(db):
) )
await db.commit() await db.commit()
assert made == 0 assert made == (0, 0)
corpus = await svc._corpus(artist.id) corpus = await svc._corpus(artist.id)
assert corpus.token_posts["cnni18x"] == 4, "four posts carry the name" assert corpus.token_posts["cnni18x"] == 4, "four posts carry the name"
@@ -549,7 +551,7 @@ async def test_a_marker_the_creator_uses_once_lifts_a_pair_over_the_line(db):
) )
await db.commit() await db.commit()
assert made == 1 assert made == (1, 0)
assoc = (await db.execute(select(PostAssociation))).scalar_one() assoc = (await db.execute(select(PostAssociation))).scalar_one()
assert assoc.signals["marker"] == 1.0 assert assoc.signals["marker"] == 1.0
@@ -582,7 +584,7 @@ async def test_a_marker_the_creator_uses_constantly_lifts_nothing(db):
) )
await db.commit() await db.commit()
assert made == 0 assert made == (0, 0)
def test_the_creators_own_phrasing_counts_as_a_declaration(): def test_the_creators_own_phrasing_counts_as_a_declaration():
@@ -661,3 +663,171 @@ async def test_the_sweep_still_ignores_a_drop_nothing_is_near(db):
assert result["proposed"] == 0 assert result["proposed"] == 0
assert (await db.execute(select(PostAssociation))).scalars().all() == [] assert (await db.execute(select(PostAssociation))).scalars().all() == []
# --- linking without asking ------------------------------------------------
#
# Operator, 2026-09-24: "I don't want this to be manual that defeats the
# convenience that I'm going for." Confirm-only was right while every signal
# was circumstantial; a name the creator uses on these two posts and nowhere
# else is different in kind. These pin where that line sits.
@pytest.mark.asyncio
async def test_a_conclusive_name_links_without_asking(db):
"""A name appearing in exactly these two posts is not strong evidence —
within the artist's library it is conclusive, and there is nothing left for
the operator to adjudicate. 20 hours apart, with nothing else to go on."""
artist, patreon, discord = await _artist_with_channels(db, "autoartist")
now = datetime.now(UTC)
teaser = await _teaser(
db, artist, patreon, at=now - timedelta(hours=20),
body="a little preview", names=["Frieren1Clean"],
)
await _drop(db, artist, discord, at=now, names=["01_Frieren1Clean"])
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.status == "linked"
@pytest.mark.asyncio
async def test_the_same_pair_still_waits_when_the_switch_is_off(db):
"""The setting is the whole difference; nothing else about the pair
changes. Confirm-only has to remain reachable, because "link it yourself"
is a change of posture and not everyone wants it."""
artist, patreon, discord = await _artist_with_channels(db, "manualartist")
now = datetime.now(UTC)
teaser = await _teaser(
db, artist, patreon, at=now - timedelta(hours=20),
body="a little preview", names=["Frieren1Clean"],
)
await _drop(db, artist, discord, at=now, names=["01_Frieren1Clean"])
await db.commit()
made = await PostAssociationService(db).match_post(
teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW,
auto_link=False,
)
await db.commit()
assert made == (1, 0)
assoc = (await db.execute(select(PostAssociation))).scalar_one()
assert assoc.status == "pending"
@pytest.mark.asyncio
async def test_evidence_short_of_conclusive_is_queued_not_linked(db):
"""The gap between IDENTITY_FLOOR and AUTO_LINK_FLOOR, which is the review
queue. This pair proposes on a name spanning three posts — real evidence,
not certain enough for FC to act on by itself. Measured equivalent on
artist 8: `0-k`, the operator's own example, at three posts."""
artist, patreon, discord = await _artist_with_channels(db, "queuedartist")
now = datetime.now(UTC)
teaser = await _teaser(
db, artist, patreon, at=now - timedelta(hours=20),
body="a little preview", ext="t0", names=["0-k"],
)
await _drop(db, artist, discord, at=now, names=["01_0-k"])
await _teaser(
db, artist, patreon, at=now - timedelta(days=30),
body="older", ext="other", names=["0-k_wip1"],
)
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, 0)
assoc = (await db.execute(select(PostAssociation))).scalar_one()
assert assoc.status == "pending"
assert assoc.signals["identity"] == 0.75
@pytest.mark.asyncio
async def test_two_drops_sharing_one_name_link_neither(db):
"""Not a tie to be broken by score.
One post can tease two pieces that were dropped separately, and then each
drop carries a DIFFERENT name from the same teaser — each spanning exactly
two posts, so each is individually conclusive. Two conclusive answers to
"which drop is this" is not a close call; it means the question was wrong.
Both are queued and FC says nothing.
(One name cannot do this on its own: under post-span counting, a token in
the teaser and two drops spans three posts and is not conclusive at all.
The ambiguity has to come from two names, which is why this fixture has
two.)
"""
artist, patreon, discord = await _artist_with_channels(db, "ambigartist")
now = datetime.now(UTC)
teaser = await _teaser(
db, artist, patreon, at=now - timedelta(hours=2),
body="a little preview", names=["Sabirth", "Terra3"],
)
await _drop(db, artist, discord, at=now, ext="fc-drop:1",
names=["01_Sabirth"])
await _drop(db, artist, discord, at=now - timedelta(hours=4),
ext="fc-drop:2", names=["01_Terra3"])
await db.commit()
made = await PostAssociationService(db).match_post(
teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW,
auto_link=True,
)
await db.commit()
proposed, linked = made
assert (proposed, linked) == (2, 0)
rows = (await db.execute(select(PostAssociation))).scalars().all()
assert {r.status for r in rows} == {"pending"}
@pytest.mark.asyncio
async def test_a_drop_another_post_already_claims_is_never_taken(db):
"""An accepted link is the operator's decision. FC reassigning its other
end on the next sweep would silently overrule them."""
artist, patreon, discord = await _artist_with_channels(db, "claimedartist")
now = datetime.now(UTC)
first = await _teaser(
db, artist, patreon, at=now - timedelta(hours=2),
body="first", ext="t0", names=["Terra3"],
)
# The drop carries both names, so the second teaser's claim on it is
# CONCLUSIVE on `Svtt` — two posts, nowhere else. Only the existing link
# stops it, which is the point being pinned.
drop = await _drop(db, artist, discord, at=now,
names=["01_Terra3", "02_Svtt"])
second = await _teaser(
db, artist, patreon, at=now - timedelta(hours=3),
body="second", ext="t1", names=["Svtt"],
)
db.add(PostAssociation(
announcement_post_id=first.id, payload_post_id=drop.id,
score=1.0, signals={"identity": 1.0}, status="linked",
))
await db.commit()
made = await PostAssociationService(db).match_post(
second.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW,
auto_link=True,
)
await db.commit()
assert made[1] == 0, "the drop is already spoken for"
rows = (await db.execute(
select(PostAssociation).where(
PostAssociation.announcement_post_id == second.id
)
)).scalars().all()
assert [r.status for r in rows] == ["pending"]