diff --git a/docs/decision-log.md b/docs/decision-log.md index 5ddef24..cbb4b4b 100644 --- a/docs/decision-log.md +++ b/docs/decision-log.md @@ -576,3 +576,27 @@ 更正而合流),逐字可對回的引述由 43,098 升至 44,122。 更正表為外部參數而非寫死於程式——研究者的介入因此 出現在重現命令上,且逐列可審、可 diff。 + +## 2026-08-08 + +- **署名 Pinkfong 的演出者實體指認為 Hope Segoine**: + Billboard 署名 Pinkfong 為品牌(Wikidata Q55735607,型態 + brand),非演唱者;其唯一上榜曲 Baby Shark(2019#75) + 的實際演唱者為 Hope Segoine(KTVB 2019-03-06 報導、 + Songfacts、經紀簡介,出處詳私人留存的 + QuickStatements 草稿)。藉既有的 + 署名正規化機制(`ArtistImporter.CANONICAL_ARTIST_NAMES`) + 指認:歌曲的署名字串維持榜單所印的 "Pinkfong",解析出 + 的演出者實體為 Hope Segoine。品牌無性別可言,人有; + 本表記錄的其他項目為拼寫正規化,此筆為實體指認,屬 + 研究者裁定。 + +- **歌曲層演出者性別的推導規則**:`build-db` 於套用 + Wikidata 快照後,為每首歌自全體署名者(primary 與 + featured 一律計入)的性別推導 `performer_gender`:已知 + 性別有兩種以上相異值即 `mixed`——未知者不可能翻案; + 已知者全部同值且無未知,取該值;其餘(已知同值但有 + 未知,或全未知)留空。值域不折疊:non-binary、 + genderfluid、trans woman 依快照原值參與比較,與 male/ + female 相異即計入 `mixed`。此欄同步鏡射於 + `data/derived/songs.csv` 的 `Performer Gender` 欄。 diff --git a/tools/src/pop_fem_audit_tools/commands/build_db.py b/tools/src/pop_fem_audit_tools/commands/build_db.py index c725c19..20ca9a0 100644 --- a/tools/src/pop_fem_audit_tools/commands/build_db.py +++ b/tools/src/pop_fem_audit_tools/commands/build_db.py @@ -40,6 +40,11 @@ spelling, except for the names listed in ``ArtistImporter.CANONICAL_ARTIST_NAMES``, which always store the canonical spelling regardless of which variant is seen first. +Once the artists carry their captured attributes, every song +takes a performer gender derived from the genders of the artists +credited on it, primary and featured alike; see +`PerformerGenderDeriver.performer_gender`. + On a successful build, two review CSV files, ``songs.csv`` and ``artists.csv``, are (re)written under the given output directory, mirroring the stored songs and artists without their IDs; see @@ -311,6 +316,7 @@ class ArtistImporter: "cris mj": "Cris MJ", "mariah the scientist": "Mariah the Scientist", "surf mesa": "Surf Mesa", + "pinkfong": "Hope Segoine", } """The canonical artist spellings, keyed by the case-folded identity.""" @@ -609,6 +615,66 @@ class CaptureImporter: setattr(artist, attribute, row[column]) +class PerformerGenderDeriver: + """The performer-gender job: derives the song-level performer + gender from the genders of the credited artists.""" + + MIXED: str = "mixed" + """The performer gender of a song whose credited artists do not + all share one gender.""" + + def __init__(self, session: Session) -> None: + """Initialize the deriver. + + :param session: The database session. + """ + self.__session: Session = session + + def derive_performer_genders(self) -> None: + """Set the performer gender of every stored song. + + Reads the songs back from the database, including any songs + pending in the same session, and sets + ``Song.performer_gender`` from the genders of the artists + credited on the song, primary and featured alike (see + `performer_gender`). When the method returns, the derived + performer genders are queryable in the session. + + :return: None. + """ + song: Song + for song in self.__session.scalars(sa.select(Song)): + song.performer_gender = self.performer_gender( + [x.artist.gender for x in song.song_artists]) + self.__session.flush() + + @classmethod + def performer_gender( + cls, genders: Iterable[str | None]) -> str | None: + """Combine the credited artists' genders into one value. + + A gender that is None or empty counts as unknown. Two or + more distinct known genders give ``MIXED``, an unknown one + notwithstanding, as an unknown cannot undo a disagreement. + A single known gender shared by every credited artist gives + that gender. Anything else -- a single known gender + alongside an unknown one, or no known gender at all -- gives + None. + + :param genders: The genders of the artists credited on one + song, in any order. + :return: The performer gender of the song, or None when it + is undetermined. + """ + values: list[str | None] = list(genders) + known: set[str] = {x for x in values if x} + if len(known) > 1: + return cls.MIXED + if len(known) == 1 and all(x for x in values): + return known.pop() + return None + + class CodingImporter: """The coding-import job: loads the settled coding table onto the stored songs.""" @@ -760,7 +826,8 @@ def reset_store(session: Session) -> None: class CSVExporter: """Writes the review CSV files mirroring the working store.""" - __SONGS_HEADER: tuple[str, ...] = ("Title", "Artists", "Positions") + __SONGS_HEADER: tuple[str, ...] = ( + "Title", "Artists", "Positions", "Performer Gender") """The header row of ``songs.csv``, for human readers.""" __ARTISTS_HEADER: tuple[str, ...] = ( "Name", "Wikidata QID", "Gender", "Type", "Genre", "Country", @@ -852,7 +919,8 @@ class CSVExporter: for song in songs: row: list[str] = [ song.title, song.artist_credit, - self.__song_positions(song)] + self.__song_positions(song), + song.performer_gender or ""] rows.append(row) return rows @@ -912,6 +980,7 @@ def main(argv: list[str] | None = None) -> int: ArtistImporter(session).import_artists() CaptureImporter(session).import_captures( args.lyrics_dir, args.wikidata_csv) + PerformerGenderDeriver(session).derive_performer_genders() CodingImporter(session).import_codings(args.codings) counts = StoreCounts.get_instance(session) CSVExporter(session, args.derived_dir).write() diff --git a/tools/src/pop_fem_audit_tools/models.py b/tools/src/pop_fem_audit_tools/models.py index 39453a4..95b0b57 100644 --- a/tools/src/pop_fem_audit_tools/models.py +++ b/tools/src/pop_fem_audit_tools/models.py @@ -41,6 +41,11 @@ class Song(Base): """The combined artist credit string as printed on the chart.""" lyrics: Mapped[str | None] """The lyrics text, when available.""" + performer_gender: Mapped[str | None] + """The gender of the credited performers taken together: + "mixed" when they disagree, their common gender when every + credited artist's gender is known and they agree, and None + otherwise.""" chart_entries: Mapped[list[ChartEntry]] \ = relationship(back_populates="song") """The chart entries of the song.""" diff --git a/tools/tests/test_build_db.py b/tools/tests/test_build_db.py index d7f2510..6f5f10d 100644 --- a/tools/tests/test_build_db.py +++ b/tools/tests/test_build_db.py @@ -292,6 +292,14 @@ class TestBuildDB(unittest.TestCase): """ self.__chart.write_text(content, encoding="utf-8") + def __write_wikidata(self, content: str) -> None: + """Write the Wikidata artist snapshot CSV fixture. + + :param content: The CSV content. + :return: None. + """ + self.__wikidata.write_text(content, encoding="utf-8") + def __write_codings(self, content: str) -> None: """Write the coding CSV fixture. @@ -538,6 +546,92 @@ class TestBuildDB(unittest.TestCase): assert artist is not None self.assertEqual(artist.name, "Surf Mesa") + def test_canonical_pinkfong_resolves_to_hope_segoine( + self) -> None: + """Test that a "Pinkfong" credit stores the artist named + "Hope Segoine", leaving the printed credit untouched.""" + self.__write_chart( + "year,rank,title,artist\n" + "2016,1,Baby Shark,Pinkfong\n" + "2016,2,filler,Filler Artist\n" + "2017,1,filler2,Filler Artist Two\n" + "2017,2,filler3,Filler Artist Three\n") + status: int + stderr: str + status, stderr = self.__run_build() + self.assertEqual(status, 0) + session: Session = self.__session() + song: Song | None = session.scalar( + sa.select(Song).where(Song.title == "Baby Shark")) + assert song is not None + self.assertEqual(song.artist_credit, "Pinkfong") + self.assertEqual( + [x.artist.name for x in song.song_artists], + ["Hope Segoine"]) + + GENDER_CHART_CSV: str = ( + "year,rank,title,artist\n" + "2016,1,Mixed Song,\"Adele, Drake & Nobody\"\n" + "2016,2,Female Song,Adele & Taylor Swift\n" + "2017,1,Unknown Song,Adele featuring Nobody\n" + "2017,2,No Gender Song,Nobody Else\n") + """The chart CSV fixture exercising the performer gender: a + disagreement with an unknown artist, an all-known agreement, an + agreement with an unknown artist, and no known gender.""" + + GENDER_WIKIDATA_CSV: str = ( + "name,qid,gender,type,genre,country,note\n" + "Adele,Q2831,female,solo,pop,GB,\n" + "Drake,Q33240,male,solo,hip-hop,CA,\n" + "Taylor Swift,Q26876,female,solo,pop,US,\n") + """The artist snapshot fixture for the performer gender, leaving + "Nobody" and "Nobody Else" without a gender.""" + + def __performer_genders(self) -> dict[str, str | None]: + """Read the stored performer genders keyed by the titles. + + :return: The stored performer genders, keyed by the song + titles. + """ + session: Session = self.__session() + return {x.title: x.performer_gender + for x in session.scalars(sa.select(Song))} + + def test_performer_gender_derived(self) -> None: + """Test the performer gender of every song: a disagreement + gives "mixed" even with an unknown artist, an all-known + agreement gives that gender, and an unknown artist otherwise + leaves it unset.""" + self.__write_chart(self.GENDER_CHART_CSV) + self.__write_wikidata(self.GENDER_WIKIDATA_CSV) + status: int + stderr: str + status, stderr = self.__run_build( + "--wikidata-csv", str(self.__wikidata)) + self.assertEqual(status, 0) + self.assertEqual( + self.__performer_genders(), + {"Mixed Song": "mixed", + "Female Song": "female", + "Unknown Song": None, + "No Gender Song": None}) + + def test_performer_gender_in_songs_csv(self) -> None: + """Test that songs.csv mirrors the performer gender, empty + when it is unset.""" + self.__write_chart(self.GENDER_CHART_CSV) + self.__write_wikidata(self.GENDER_WIKIDATA_CSV) + self.assertEqual( + self.__run_build("--wikidata-csv", + str(self.__wikidata))[0], 0) + rows: list[list[str]] = self.__read_csv_rows("songs.csv") + self.assertEqual( + [(row[0], row[3]) for row in rows], + [("Female Song", "female"), + ("Mixed Song", "mixed"), + ("No Gender Song", ""), + ("Unknown Song", "")]) + def test_first_run_on_fresh_store(self) -> None: """Test that a build on a fresh store creates the tables.""" self.assertEqual( @@ -727,7 +821,7 @@ class TestBuildDB(unittest.TestCase): self.assertEqual(self.__run_build()[0], 0) self.assertEqual( self.__read_csv_header("songs.csv"), - ["Title", "Artists", "Positions"]) + ["Title", "Artists", "Positions", "Performer Gender"]) rows: list[list[str]] = self.__read_csv_rows("songs.csv") self.assertEqual( [row[:2] for row in rows],