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
+32
View File
@@ -103,3 +103,35 @@ async def test_a_negative_window_is_refused(client):
"/api/settings/import", json={"download_revisit_days": -1}
)
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,
)
await db.commit()
assert made == 1
assert made == (1, 0)
assoc = (await db.execute(select(PostAssociation))).scalar_one()
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,
)
await db.commit()
assert made == 0
assert made == (0, 0)
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(
teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW,
) == 0
) == (0, 0)
@pytest.mark.asyncio
@@ -250,7 +250,7 @@ async def test_another_artists_drop_is_never_proposed(db):
assert await PostAssociationService(db).match_post(
teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW,
) == 0
) == (0, 0)
@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(
teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW,
) == 0
) == (0, 0)
@pytest.mark.asyncio
@@ -287,7 +287,7 @@ async def test_a_synthetic_post_cannot_announce_anything(db):
assert await PostAssociationService(db).match_post(
drop_a.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW,
) == 0
) == (0, 0)
@pytest.mark.asyncio
@@ -315,7 +315,7 @@ async def test_a_dismissed_pair_is_never_proposed_again(db):
assert await svc.match_post(
teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW,
) == 0
) == (0, 0)
await db.commit()
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.discord_link_enabled = False
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 ----------------------------------------------------
@@ -422,7 +424,7 @@ async def test_a_shared_working_name_proposes_a_pair_time_cannot_reach(db):
)
await db.commit()
assert made == 1
assert made == (1, 0)
assoc = (await db.execute(select(PostAssociation))).scalar_one()
assert assoc.payload_post_id == drop.id
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()
assert made == 0
assert made == (0, 0)
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()
assert made == 0
assert made == (0, 0)
corpus = await svc._corpus(artist.id)
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()
assert made == 1
assert made == (1, 0)
assoc = (await db.execute(select(PostAssociation))).scalar_one()
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()
assert made == 0
assert made == (0, 0)
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 (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"]