fix: count the POSTS a working name spans, not the files (4392)
CI and images / lint (push) Failing after 3s
CI and images / extension-version (push) Successful in 3s
CI and images / frontend-build (push) Successful in 23s
CI and images / backend-lint-and-test (push) Successful in 32s
CI and images / integration (push) Successful in 2m19s
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

Counting files punished a piece for the one thing a working name is
guaranteed to do — have several exports. Measured across the operator's four
dual-platform artists: knuxy carries `p217` on four files spanning exactly two
posts, the Patreon post and the Discord drop, and ~200 comic-page tokens have
that shape. Every one scored half strength. Counting posts scores them 1.00
and still catches the habits, which span many posts rather than many files:
tamadaheijun's `comic2` spans 8, conto's `seth2` 5, `maid` 4. Knuxy's
ambiguous band drops from 268 tokens to 55.

Three false positives found the same way — by running the module over the real
library rather than reading it:

  * The screenshot guard matches from the start of the stem, so it never fired
    on the legacy `<post id>_media_<media id>_` era. `Screenshot 2025-07-27
    182450ab` sailed through and contributed `2025-07-27` — the same-day date
    collision this module's docstring exists to refuse.
  * tamadaheijun names screenshots in Japanese. A guard that knows only the
    English word is a guard for one artist.
  * `timeline 3-0002` spans three unrelated posts. Keeping hyphens inside
    tokens for `0-k`'s sake let the number survive whole, so an identity token
    now has to contain a letter — which is the property behind the old
    bare-year and all-digits rules anyway, and leaves `0-k`, `680lc` and `p59`
    untouched.

Also stopwords for `the`, `gif`, `main`, `patreon`, `capture`, `timeline`,
each measured carrying a false match inside the admitted band.

IDENTITY_FLOOR names what a shared name must reach to propose a link with no
corroboration. At 0.75, 13 of artist 8's 15 name-sharing pairs clear it,
including the operator's own example. The two that do not are real pairs this
signal will not carry alone — the stated cost of refusing the four-post band,
where conto's `illustration9` and `maid` also sit.

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:00:36 -04:00
co-authored by Claude Opus 5
parent ecbfd83029
commit c67a8c313e
2 changed files with 170 additions and 37 deletions
+85 -26
View File
@@ -67,7 +67,7 @@ from pathlib import PurePosixPath
# spelling — sailed straight past the guard while the space-separated Patreon # spelling — sailed straight past the guard while the space-separated Patreon
# spelling was caught. Found by running this against the live library rather # spelling was caught. Found by running this against the live library rather
# than by reading it. Assert the next character is not a letter instead. # than by reading it. Assert the next character is not a letter instead.
_SCREENSHOT = re.compile(r"^screen[ _-]?shot(?![a-z])", re.I) _SCREENSHOT = re.compile(r"^(?:screen[ _-]?shot(?![a-z])|\u30b9\u30af\u30ea\u30fc\u30f3\u30b7\u30e7\u30c3\u30c8|\u30b9\u30af\u30b7\u30e7)", re.I)
# The importer's per-post media index: `01_`, `02_`. Not part of any name. # The importer's per-post media index: `01_`, `02_`. Not part of any name.
_MEDIA_INDEX = re.compile(r"^\d{1,3}_") _MEDIA_INDEX = re.compile(r"^\d{1,3}_")
@@ -76,6 +76,15 @@ _MEDIA_INDEX = re.compile(r"^\d{1,3}_")
# `{date:%Y%m%d}_{message_id}_{num:>02}_{filename}`. # `{date:%Y%m%d}_{message_id}_{num:>02}_{filename}`.
_DISCORD_PREFIX = re.compile(r"^\d{8}_\d{6,}_\d{1,3}_") _DISCORD_PREFIX = re.compile(r"^\d{8}_\d{6,}_\d{1,3}_")
# The legacy era (#4002): images sit FLAT at the artist root as
# `<post id>_media_<media id>_<name>`. Stripping it is not cosmetic — the
# SCREENSHOT guard below matches from the start of the stem, so while this
# prefix was left on, a legacy screenshot never looked like one. Measured on
# tamadaheijun: `109078417_media_334848471_Screenshot 2025-07-27 182450ab`
# sailed through and contributed `2025-07-27`, which is precisely the
# same-day date collision this module was built to refuse.
_LEGACY_PREFIX = re.compile(r"^\d+_media_\d+_")
# The importer's content-hash suffix, `__<10 hex>`, sometimes doubled on files # The importer's content-hash suffix, `__<10 hex>`, sometimes doubled on files
# that went through an older import era. # that went through an older import era.
_HASH_SUFFIX = re.compile(r"(?:__[0-9a-f]{10})+$") _HASH_SUFFIX = re.compile(r"(?:__[0-9a-f]{10})+$")
@@ -93,31 +102,70 @@ _DECORATION = re.compile(
_STOPWORDS = frozenset({ _STOPWORDS = frozenset({
"img", "image", "untitled", "new", "test", "page", "final", "copy", "img", "image", "untitled", "new", "test", "page", "final", "copy",
"post", "media", "file", "avatar", "cover", "banner", "icon", "splash", "post", "media", "file", "avatar", "cover", "banner", "icon", "splash",
# Each of these was MEASURED carrying a false match in the 3..6 frequency
# band, where the rarity gate still admits a token: `capture` across six
# unrelated knuxy posts, `the` and `patreon` out of legacy title-derived
# names, `main` out of `Anya Main CST`, `timeline` and `gif` out of
# tamadaheijun's exports.
"the", "gif", "main", "patreon", "capture", "timeline", "screenshot",
# Literally the string "None": issue #3999's Discord naming rendered # Literally the string "None": issue #3999's Discord naming rendered
# `{user[name]}` as it for ~1,600 files, so it is the single most common # `{user[name]}` as it for ~1,600 files, so it is the single most common
# "name" in the library and identifies nothing. # "name" in the library and identifies nothing.
"none", "none",
}) })
# A bare year, or a date fragment. These are what made the screenshot collision # A bare year: still needed for the TEXT signal, where words and numbers are
# look like a match, and they are worthless as identity even in a real name. # tokenised separately.
_YEAR = re.compile(r"^(?:19|20)\d{2}$") _YEAR = re.compile(r"^(?:19|20)\d{2}$")
_ALL_DIGITS = re.compile(r"^\d+$")
# An identity token must contain a LETTER. This replaces separate "all digits"
# and "bare year" rules with the property behind both, and it is the rule that
# holds once hyphens are kept inside tokens for `0-k`'s sake: without it
# `2025-07-27` and `3-0002` survive as single tokens, and both were measured
# linking unrelated posts — the second across three of them, out of
# tamadaheijun's `timeline 3-0002` exports.
#
# `0-k`, `680lc`, `cnni18x` and `p59` all keep a letter and are unaffected.
_HAS_LETTER = re.compile(r"[A-Za-z]")
MIN_TOKEN_LEN = 3 MIN_TOKEN_LEN = 3
# A token shared by more than this many of ONE ARTIST's images is a habit, not # A token appearing in more than this many of ONE ARTIST's POSTS is a habit,
# an identity — a character name, a series tag, a recurring export preset. The # not an identity — a character name, a series tag, a recurring export preset.
# bar is deliberately low: a working name identifies one piece, so it should #
# appear on that piece's handful of exports (base, wips, the teaser crop) and # POSTS, not files, and the difference is not bookkeeping. Counting files
# nowhere else. Raising this trades false positives for reach, which is the # punishes a piece for having many exports, which is the one thing a working
# wrong direction here — a wrong link asserts two different pieces are one. # name is GUARANTEED to do. Measured across the operator's four dual-platform
MAX_TOKEN_FREQUENCY = 6 # artists: knuxy's comic pages carry `p217` on four files spread over exactly
# two posts — the Patreon post and the Discord drop — and file-counting scored
# every one of ~200 such tokens at half strength for it. Counting posts scores
# them 1.00 while still catching the real habits, which span many posts:
# tamadaheijun's `comic2` spans 8, conto's `seth2` 5, `maid` 4.
#
# Six rather than two, although two posts IS the shape of a teaser and its
# drop, because a creator legitimately revisits one working name: a wip post,
# then an alt, then the release. Measured on artist 8, `cnni18x` and `680lc`
# each span four posts and both are genuine. IDENTITY_FLOOR below is what
# decides how much span a link may carry on its own.
MAX_TOKEN_POSTS = 6
# A shared name at or above this strength is enough to propose a link with NO
# corroboration — it is identity evidence, and the whole reason this module
# exists is that identity survives where circumstance does not.
#
# 0.75 is a token spanning three posts or fewer. Measured on artist 8: of the
# 15 same-artist pairs that share a name, 13 clear this bar, including the
# operator's own example (`0-k`, three posts, 1.3h apart). The two that do not
# — `680lc` and `cnni18x`, four posts each, 21h apart — are real pairs this
# signal will not carry alone; they are the measured cost of not admitting the
# four-post band, where conto's `illustration9` and `maid` also sit.
IDENTITY_FLOOR = 0.75
def _strip_prefixes(stem: str) -> str: def _strip_prefixes(stem: str) -> str:
"""Remove the framing each platform's importer adds around the real name.""" """Remove the framing each platform's importer adds around the real name."""
stem = _DISCORD_PREFIX.sub("", stem) stem = _DISCORD_PREFIX.sub("", stem)
stem = _LEGACY_PREFIX.sub("", stem)
stem = _MEDIA_INDEX.sub("", stem) stem = _MEDIA_INDEX.sub("", stem)
return _HASH_SUFFIX.sub("", stem) return _HASH_SUFFIX.sub("", stem)
@@ -142,22 +190,27 @@ def working_name_tokens(path: str) -> set[str]:
tok = _DECORATION.sub("", raw).strip("-") tok = _DECORATION.sub("", raw).strip("-")
if len(tok) < MIN_TOKEN_LEN: if len(tok) < MIN_TOKEN_LEN:
continue continue
if tok in _STOPWORDS or _YEAR.match(tok) or _ALL_DIGITS.match(tok): if tok in _STOPWORDS or not _HAS_LETTER.search(tok):
continue continue
out.add(tok) out.add(tok)
return out return out
def token_frequencies(paths: Iterable[str]) -> Counter[str]: def token_frequencies(posts: Iterable[Iterable[str]]) -> Counter[str]:
"""How often each working-name token appears across one artist's images. """How many of ONE ARTIST's POSTS each working-name token appears in.
Takes posts — each an iterable of that post's image paths — rather than a
flat list of paths, because the unit of the count is the post. See
MAX_TOKEN_POSTS for what that buys; the short version is that a piece with
six exports in one post has used its name once.
Scoped to the ARTIST, not the library: a working name belongs to the person Scoped to the ARTIST, not the library: a working name belongs to the person
who chose it, and the same string can be one creator's piece and another's who chose it, and the same string can be one creator's piece and another's
boilerplate. Built once per artist per sweep, not per candidate pair. boilerplate. Built once per artist per sweep, not per candidate pair.
""" """
counts: Counter[str] = Counter() counts: Counter[str] = Counter()
for p in paths: for paths in posts:
counts.update(working_name_tokens(p)) counts.update({t for path in paths for t in working_name_tokens(path)})
return counts return counts
@@ -187,7 +240,7 @@ def shared_identity(
right: Iterable[str], right: Iterable[str],
frequencies: Counter[str], frequencies: Counter[str],
*, *,
max_frequency: int = MAX_TOKEN_FREQUENCY, max_frequency: int = MAX_TOKEN_POSTS,
) -> tuple[float, str | None]: ) -> tuple[float, str | None]:
"""Strength in [0, 1] that two sets of filenames name the SAME piece. """Strength in [0, 1] that two sets of filenames name the SAME piece.
@@ -197,16 +250,17 @@ def shared_identity(
Strength is a function of the winning token's rarity within the artist's Strength is a function of the winning token's rarity within the artist's
own library, not of how many tokens matched. One decisive token beats three own library, not of how many tokens matched. One decisive token beats three
vague ones, and a token that appears on forty of this artist's images is a vague ones, and a token appearing across forty of this artist's posts is a
habit rather than an identity however exactly it matches. habit rather than an identity however exactly it matches.
`frequencies` must be the POST counts from `token_frequencies`.
""" """
shared = {t for t in set(left) & set(right) if frequencies.get(t, 0) <= max_frequency} shared = {t for t in set(left) & set(right) if frequencies.get(t, 0) <= max_frequency}
if not shared: if not shared:
return 0.0, None return 0.0, None
# The rarest shared token decides — one decisive token beats three vague # The rarest shared token decides — one decisive token beats three vague
# ones. `frequencies` counts IMAGES here, and a real working name lands on # ones.
# a few of them: the base, its wips, the teaser crop.
token = min(shared, key=lambda t: (frequencies.get(t, 0), -len(t), t)) token = min(shared, key=lambda t: (frequencies.get(t, 0), -len(t), t))
strength = round(_rarity(max(frequencies.get(token, 1), 1), max_frequency), 4) strength = round(_rarity(max(frequencies.get(token, 1), 1), max_frequency), 4)
# A token sitting exactly ON the cap decays to zero, and naming it anyway # A token sitting exactly ON the cap decays to zero, and naming it anyway
@@ -248,11 +302,16 @@ _COMMON_TEXT = frozenset({
}) })
# A marker in more than this many of ONE ARTIST's posts is a signature, not a # A marker in more than this many of ONE ARTIST's posts is a signature, not a
# tie-back. Tighter than MAX_TOKEN_FREQUENCY because the units differ and so # tie-back. A marker tying an announcement to its drop lands on two posts —
# does the evidence: that one counts a working name across a piece's handful of # the two.
# EXPORTS, where this counts a public decoration across POSTS. A marker that #
# ties an announcement to its drop lands on two posts the two. # Tighter than MAX_TOKEN_POSTS. Both count posts, so the numbers are directly
MAX_MARKER_FREQUENCY = 4 # comparable and the gap between them is the claim being made: a working name
# is the creator's private label for one piece and may honestly recur as they
# revisit it, while a marker is public decoration and stops being evidence the
# moment it is reused. Measured on artist 8: 💦 spans 13 posts, the word
# "like" 43, and 🌗 — a real tie-back — exactly 2.
MAX_MARKER_POSTS = 4
def text_markers(text: str | None) -> set[str]: def text_markers(text: str | None) -> set[str]:
@@ -291,7 +350,7 @@ def marker_overlap(
right: str | None, right: str | None,
frequencies: Counter[str], frequencies: Counter[str],
*, *,
max_frequency: int = MAX_MARKER_FREQUENCY, max_frequency: int = MAX_MARKER_POSTS,
) -> float: ) -> float:
"""Strength in [0, 1] that two texts share a DELIBERATE marker. """Strength in [0, 1] that two texts share a DELIBERATE marker.
+85 -11
View File
@@ -17,8 +17,9 @@ from collections import Counter
import pytest import pytest
from backend.app.services.post_naming import ( from backend.app.services.post_naming import (
MAX_MARKER_FREQUENCY, IDENTITY_FLOOR,
MAX_TOKEN_FREQUENCY, MAX_MARKER_POSTS,
MAX_TOKEN_POSTS,
marker_frequencies, marker_frequencies,
marker_overlap, marker_overlap,
shared_identity, shared_identity,
@@ -74,6 +75,42 @@ def test_a_work_in_progress_matches_the_piece_it_became():
) )
@pytest.mark.parametrize(
"path, why",
[
(
"109078417_media_334848471_Screenshot 2025-07-27 182450ab.png",
"the guard matches from the START of the stem, so while the legacy "
"prefix was left on, a legacy screenshot never looked like one — "
"and contributed `2025-07-27`, the exact same-day date collision "
"this module refuses",
),
(
"136070668_media_513155924_\u30b9\u30af\u30ea\u30fc\u30f3\u30b7\u30e7\u30c3\u30c8 2025-07-27 9.31.png",
"tamadaheijun's screenshots are named in Japanese; a guard that "
"only knows the English word is a guard for one artist",
),
(
"129421439_media_469882823_timeline 3-0002.jpg",
"keeping hyphens inside tokens for `0-k`'s sake let `3-0002` "
"survive whole, and it was MEASURED spanning three unrelated posts",
),
],
)
def test_measured_false_positives_contribute_nothing(path, why):
"""Each of these was found by running the module against the operator's
real library, not by reading it — which is the only way any of them would
have been found."""
assert working_name_tokens(path) == set(), why
def test_an_identity_token_must_contain_a_letter():
"""The property behind refusing bare years, bare numbers and date
fragments, stated once. `0-k`, `680lc` and `p59` all keep a letter."""
assert working_name_tokens("01_2025-07-27.jpg") == set()
assert working_name_tokens("01_0-k.jpg") == {"0-k"}
def test_the_literal_string_none_is_not_a_name(): def test_the_literal_string_none_is_not_a_name():
"""#3999 rendered `{user[name]}` as "None" for ~1,600 files, which made it """#3999 rendered `{user[name]}` as "None" for ~1,600 files, which made it
the single most common "name" in the library and an identity for nothing.""" the single most common "name" in the library and an identity for nothing."""
@@ -132,7 +169,7 @@ def test_a_token_at_the_cap_names_nothing():
"""It decays to zero, and reporting it anyway would hand the review queue a """It decays to zero, and reporting it anyway would hand the review queue a
reason with no weight behind it — "matched on loislanetb2", with nothing reason with no weight behind it — "matched on loislanetb2", with nothing
there. A token is named only while it is doing work.""" there. A token is named only while it is doing work."""
freqs = Counter({"tok": MAX_TOKEN_FREQUENCY}) freqs = Counter({"tok": MAX_TOKEN_POSTS})
assert shared_identity({"tok"}, {"tok"}, freqs) == (0.0, None) assert shared_identity({"tok"}, {"tok"}, freqs) == (0.0, None)
@@ -152,9 +189,10 @@ def test_the_rarest_shared_token_decides_not_the_count_of_them():
def test_frequencies_are_counted_per_artist_not_per_library(): def test_frequencies_are_counted_per_artist_not_per_library():
"""A working name belongs to the person who chose it; the same string is """A working name belongs to the person who chose it; the same string is
one creator's piece and another's boilerplate.""" one creator's piece and another's boilerplate."""
counts = token_frequencies( counts = token_frequencies([
["01_ConnFront.jpg", "20230222_1078078245695664148_01_ConnFront.jpg"] ["01_ConnFront.jpg"],
) ["20230222_1078078245695664148_01_ConnFront.jpg"],
])
assert counts["connfront"] == 2 assert counts["connfront"] == 2
@@ -219,11 +257,47 @@ def test_marker_frequencies_count_posts_not_occurrences():
def test_the_marker_gate_is_tighter_than_the_filename_gate(): def test_the_marker_gate_is_tighter_than_the_filename_gate():
"""Stated as a property because the two caps count different things and the """Both caps count POSTS, so they are directly comparable and the gap is a
difference is deliberate: the filename cap counts a working name across a claim: a working name is the creator's private label for one piece and may
piece's EXPORTS, the marker cap counts a public decoration across POSTS. A honestly recur as they revisit it, while a marker is public decoration and
marker tying an announcement to its drop lands on two posts — the two.""" stops being evidence the moment it is reused."""
assert MAX_MARKER_FREQUENCY < MAX_TOKEN_FREQUENCY assert MAX_MARKER_POSTS < MAX_TOKEN_POSTS
def test_a_name_must_clear_the_floor_to_link_on_its_own():
"""The floor is the whole two-route design in one number: identity may
propose alone, circumstance never may. Pinned against the threshold it
guards so the two cannot drift apart silently."""
assert 0.0 < IDENTITY_FLOOR <= 1.0
assert IDENTITY_FLOOR > 0.60 # the matcher's default threshold
# --- the count is of POSTS, which is what makes the cap mean anything --------
def test_a_piece_with_many_exports_is_not_penalised_for_having_them():
"""Counting FILES punishes a piece for the one thing a working name is
guaranteed to do. Measured: knuxy carries `p217` on four files across
exactly two posts — the Patreon post and the Discord drop — and roughly
two hundred comic-page tokens have that shape. File-counting scored every
one of them at half strength."""
counts = token_frequencies([
["p217.jpg", "p217-clean.jpg"], # the Patreon post
["20240101_123456789_01_p217.jpg", "..._02_p217-clean.jpg"], # the drop
])
assert counts["p217"] == 2
assert shared_identity({"p217"}, {"p217"}, counts) == (1.0, "p217")
def test_a_name_reused_across_many_posts_is_still_caught():
"""The other half of the same property — the cap has to keep working once
the unit changes. Measured habits: tamadaheijun's `comic2` spans 8 posts,
conto's `seth2` 5."""
counts = token_frequencies([["comic2_%02d.jpg" % i] for i in range(8)])
assert counts["comic2"] == 8
assert shared_identity({"comic2"}, {"comic2"}, counts) == (0.0, None)
def test_marker_overlap_cannot_be_called_without_the_frequencies(): def test_marker_overlap_cannot_be_called_without_the_frequencies():