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
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:
@@ -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
@@ -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():
|
||||||
|
|||||||
Reference in New Issue
Block a user