diff --git a/results/annotations.csv b/results/annotations.csv new file mode 100644 index 0000000..e050698 --- /dev/null +++ b/results/annotations.csv @@ -0,0 +1,738 @@ +Song,Artist Credit,Pattern,Votes +Needed Me,Rihanna,F9,3 +Needed Me,Rihanna,F10,3 +Needed Me,Rihanna,X1,3 +Dangerous Woman,Ariana Grande,F8,3 +Dangerous Woman,Ariana Grande,F14,3 +Dangerous Woman,Ariana Grande,X15,2 +Dangerous Woman,Ariana Grande,X16,3 +No,Meghan Trainor,F8,3 +No,Meghan Trainor,F12,3 +No,Meghan Trainor,X15,3 +Me Too,Meghan Trainor,F5,3 +Me Too,Meghan Trainor,F6,3 +Me Too,Meghan Trainor,F7,3 +Sorry,Beyonce,F2,3 +Sorry,Beyonce,F4,3 +Sorry,Beyonce,F10,3 +Sorry,Beyonce,X8,3 +Sorry,Beyonce,X10,3 +Side To Side,Ariana Grande Featuring Nicki Minaj,F11,3 +Side To Side,Ariana Grande Featuring Nicki Minaj,F13,3 +Side To Side,Ariana Grande Featuring Nicki Minaj,X1,2 +Side To Side,Ariana Grande Featuring Nicki Minaj,X8,3 +Side To Side,Ariana Grande Featuring Nicki Minaj,X11,3 +"Sit Still, Look Pretty",Daya,F9,3 +"Sit Still, Look Pretty",Daya,X8,3 +Bodak Yellow (Money Moves),Cardi B,F2,3 +Bodak Yellow (Money Moves),Cardi B,F3,3 +Bodak Yellow (Money Moves),Cardi B,F5,3 +Bodak Yellow (Money Moves),Cardi B,F9,2 +Bodak Yellow (Money Moves),Cardi B,X3,3 +Bodak Yellow (Money Moves),Cardi B,X8,3 +Bodak Yellow (Money Moves),Cardi B,X9,3 +Bodak Yellow (Money Moves),Cardi B,X10,3 +Scars To Your Beautiful,Alessia Cara,X13,3 +Look What You Made Me Do,Taylor Swift,F8,3 +Look What You Made Me Do,Taylor Swift,F10,3 +Rockabye,Clean Bandit Featuring Sean Paul & Anne-Marie,F10,3 +Rockabye,Clean Bandit Featuring Sean Paul & Anne-Marie,X11,3 +Rockabye,Clean Bandit Featuring Sean Paul & Anne-Marie,X12,3 +Rockabye,Clean Bandit Featuring Sean Paul & Anne-Marie,X13,3 +Rockabye,Clean Bandit Featuring Sean Paul & Anne-Marie,X14,3 +Sorry Not Sorry,Demi Lovato,F7,3 +Sorry Not Sorry,Demi Lovato,F8,3 +Sorry Not Sorry,Demi Lovato,F9,3 +Sorry Not Sorry,Demi Lovato,X1,3 +Sorry Not Sorry,Demi Lovato,X15,3 +Rake It Up,Yo Gotti Featuring Nicki Minaj,M1,2 +Rake It Up,Yo Gotti Featuring Nicki Minaj,M2,3 +Rake It Up,Yo Gotti Featuring Nicki Minaj,M4,2 +Rake It Up,Yo Gotti Featuring Nicki Minaj,M8,2 +Rake It Up,Yo Gotti Featuring Nicki Minaj,F2,3 +Rake It Up,Yo Gotti Featuring Nicki Minaj,F5,3 +Rake It Up,Yo Gotti Featuring Nicki Minaj,X1,3 +Rake It Up,Yo Gotti Featuring Nicki Minaj,X2,3 +Rake It Up,Yo Gotti Featuring Nicki Minaj,X3,3 +Rake It Up,Yo Gotti Featuring Nicki Minaj,X4,3 +Rake It Up,Yo Gotti Featuring Nicki Minaj,X7,3 +Rake It Up,Yo Gotti Featuring Nicki Minaj,X10,3 +Rake It Up,Yo Gotti Featuring Nicki Minaj,X15,3 +Loyalty.,Kendrick Lamar Featuring Rihanna,M1,3 +Loyalty.,Kendrick Lamar Featuring Rihanna,F2,3 +Loyalty.,Kendrick Lamar Featuring Rihanna,X1,3 +Loyalty.,Kendrick Lamar Featuring Rihanna,X2,3 +Loyalty.,Kendrick Lamar Featuring Rihanna,X4,3 +Loyalty.,Kendrick Lamar Featuring Rihanna,X10,3 +Love Galore,SZA Featuring Travis Scott,M1,3 +Love Galore,SZA Featuring Travis Scott,M2,2 +Love Galore,SZA Featuring Travis Scott,M10,2 +Love Galore,SZA Featuring Travis Scott,F11,3 +Love Galore,SZA Featuring Travis Scott,X2,3 +Love Galore,SZA Featuring Travis Scott,X6,3 +Love Galore,SZA Featuring Travis Scott,X8,3 +Love Galore,SZA Featuring Travis Scott,X11,3 +Swalla,Jason Derulo Featuring Nicki Minaj & Ty Dolla $ign,M1,3 +Swalla,Jason Derulo Featuring Nicki Minaj & Ty Dolla $ign,F14,3 +Swalla,Jason Derulo Featuring Nicki Minaj & Ty Dolla $ign,X2,3 +Swalla,Jason Derulo Featuring Nicki Minaj & Ty Dolla $ign,X4,3 +Swalla,Jason Derulo Featuring Nicki Minaj & Ty Dolla $ign,X5,3 +Swalla,Jason Derulo Featuring Nicki Minaj & Ty Dolla $ign,X8,3 +Swalla,Jason Derulo Featuring Nicki Minaj & Ty Dolla $ign,X16,3 +I Like It,"Cardi B, Bad Bunny & J Balvin",M1,3 +I Like It,"Cardi B, Bad Bunny & J Balvin",M13,3 +I Like It,"Cardi B, Bad Bunny & J Balvin",F1,3 +I Like It,"Cardi B, Bad Bunny & J Balvin",F8,3 +I Like It,"Cardi B, Bad Bunny & J Balvin",X2,3 +I Like It,"Cardi B, Bad Bunny & J Balvin",X4,3 +I Like It,"Cardi B, Bad Bunny & J Balvin",X5,3 +I Like It,"Cardi B, Bad Bunny & J Balvin",X8,3 +I Like It,"Cardi B, Bad Bunny & J Balvin",X15,3 +In My Feelings,Drake,M1,3 +In My Feelings,Drake,M2,3 +In My Feelings,Drake,M4,3 +In My Feelings,Drake,M10,3 +In My Feelings,Drake,M11,3 +In My Feelings,Drake,M13,3 +In My Feelings,Drake,X1,3 +In My Feelings,Drake,X2,3 +In My Feelings,Drake,X3,3 +In My Feelings,Drake,X4,3 +In My Feelings,Drake,X5,3 +In My Feelings,Drake,X7,3 +Girls Like You,Maroon 5 Featuring Cardi B,F3,2 +Girls Like You,Maroon 5 Featuring Cardi B,X8,3 +Girls Like You,Maroon 5 Featuring Cardi B,X9,3 +Girls Like You,Maroon 5 Featuring Cardi B,X12,3 +Girls Like You,Maroon 5 Featuring Cardi B,X14,3 +Girls Like You,Maroon 5 Featuring Cardi B,X15,3 +Nice For What,Drake,M1,3 +Nice For What,Drake,M2,3 +Nice For What,Drake,M3,2 +Nice For What,Drake,M6,3 +Nice For What,Drake,M9,3 +Nice For What,Drake,M10,3 +Nice For What,Drake,M11,3 +Nice For What,Drake,X2,3 +Nice For What,Drake,X7,3 +Nice For What,Drake,X13,3 +No Limit,G-Eazy Featuring A$AP Rocky & Cardi B,M4,3 +No Limit,G-Eazy Featuring A$AP Rocky & Cardi B,M9,2 +No Limit,G-Eazy Featuring A$AP Rocky & Cardi B,M13,3 +No Limit,G-Eazy Featuring A$AP Rocky & Cardi B,X1,3 +No Limit,G-Eazy Featuring A$AP Rocky & Cardi B,X3,3 +No Limit,G-Eazy Featuring A$AP Rocky & Cardi B,X4,3 +No Limit,G-Eazy Featuring A$AP Rocky & Cardi B,X5,3 +No Limit,G-Eazy Featuring A$AP Rocky & Cardi B,X8,3 +No Limit,G-Eazy Featuring A$AP Rocky & Cardi B,X14,3 +FEFE,6ix9ine Featuring Nicki Minaj & Murda Beatz,M1,3 +FEFE,6ix9ine Featuring Nicki Minaj & Murda Beatz,M5,3 +FEFE,6ix9ine Featuring Nicki Minaj & Murda Beatz,X1,3 +FEFE,6ix9ine Featuring Nicki Minaj & Murda Beatz,X2,3 +FEFE,6ix9ine Featuring Nicki Minaj & Murda Beatz,X5,3 +FEFE,6ix9ine Featuring Nicki Minaj & Murda Beatz,X6,3 +FEFE,6ix9ine Featuring Nicki Minaj & Murda Beatz,X16,3 +MotorSport,"Migos, Nicki Minaj & Cardi B",M1,3 +MotorSport,"Migos, Nicki Minaj & Cardi B",M4,2 +MotorSport,"Migos, Nicki Minaj & Cardi B",M13,3 +MotorSport,"Migos, Nicki Minaj & Cardi B",F3,3 +MotorSport,"Migos, Nicki Minaj & Cardi B",X1,3 +MotorSport,"Migos, Nicki Minaj & Cardi B",X2,3 +MotorSport,"Migos, Nicki Minaj & Cardi B",X3,3 +MotorSport,"Migos, Nicki Minaj & Cardi B",X4,3 +MotorSport,"Migos, Nicki Minaj & Cardi B",X8,3 +MotorSport,"Migos, Nicki Minaj & Cardi B",X9,3 +Be Careful,Cardi B,F1,3 +Be Careful,Cardi B,F4,3 +Be Careful,Cardi B,F11,3 +Be Careful,Cardi B,F14,3 +Be Careful,Cardi B,X1,3 +Be Careful,Cardi B,X8,2 +Be Careful,Cardi B,X11,2 +Be Careful,Cardi B,X16,3 +Bartier Cardi,Cardi B Featuring 21 Savage,M1,3 +Bartier Cardi,Cardi B Featuring 21 Savage,M7,2 +Bartier Cardi,Cardi B Featuring 21 Savage,M13,3 +Bartier Cardi,Cardi B Featuring 21 Savage,F3,3 +Bartier Cardi,Cardi B Featuring 21 Savage,F12,2 +Bartier Cardi,Cardi B Featuring 21 Savage,X1,3 +Bartier Cardi,Cardi B Featuring 21 Savage,X2,3 +Bartier Cardi,Cardi B Featuring 21 Savage,X4,3 +Bartier Cardi,Cardi B Featuring 21 Savage,X6,3 +Bartier Cardi,Cardi B Featuring 21 Savage,X8,3 +Bartier Cardi,Cardi B Featuring 21 Savage,X9,3 +Bartier Cardi,Cardi B Featuring 21 Savage,X15,3 +God Is A Woman,Ariana Grande,F8,3 +God Is A Woman,Ariana Grande,F9,3 +God Is A Woman,Ariana Grande,X15,3 +Big Bank,"YG Featuring 2 Chainz, Big Sean & Nicki Minaj",M1,3 +Big Bank,"YG Featuring 2 Chainz, Big Sean & Nicki Minaj",M4,3 +Big Bank,"YG Featuring 2 Chainz, Big Sean & Nicki Minaj",M11,3 +Big Bank,"YG Featuring 2 Chainz, Big Sean & Nicki Minaj",M13,2 +Big Bank,"YG Featuring 2 Chainz, Big Sean & Nicki Minaj",F1,3 +Big Bank,"YG Featuring 2 Chainz, Big Sean & Nicki Minaj",F2,3 +Big Bank,"YG Featuring 2 Chainz, Big Sean & Nicki Minaj",F5,3 +Big Bank,"YG Featuring 2 Chainz, Big Sean & Nicki Minaj",X1,3 +Big Bank,"YG Featuring 2 Chainz, Big Sean & Nicki Minaj",X2,3 +Big Bank,"YG Featuring 2 Chainz, Big Sean & Nicki Minaj",X3,3 +Big Bank,"YG Featuring 2 Chainz, Big Sean & Nicki Minaj",X4,3 +Big Bank,"YG Featuring 2 Chainz, Big Sean & Nicki Minaj",X5,3 +Big Bank,"YG Featuring 2 Chainz, Big Sean & Nicki Minaj",X8,3 +Big Bank,"YG Featuring 2 Chainz, Big Sean & Nicki Minaj",X10,3 +Bad Guy,Billie Eilish,F3,3 +Bad Guy,Billie Eilish,F8,2 +Bad Guy,Billie Eilish,F13,3 +Bad Guy,Billie Eilish,X9,3 +Bad Guy,Billie Eilish,X15,3 +7 Rings,Ariana Grande,F6,3 +7 Rings,Ariana Grande,F7,3 +7 Rings,Ariana Grande,X1,3 +Truth Hurts,Lizzo,F4,3 +Truth Hurts,Lizzo,F8,3 +Truth Hurts,Lizzo,F14,3 +Truth Hurts,Lizzo,X8,2 +Truth Hurts,Lizzo,X15,3 +No Guidance,Chris Brown Featuring Drake,M1,3 +No Guidance,Chris Brown Featuring Drake,M2,2 +No Guidance,Chris Brown Featuring Drake,M8,3 +No Guidance,Chris Brown Featuring Drake,M10,3 +No Guidance,Chris Brown Featuring Drake,M12,3 +No Guidance,Chris Brown Featuring Drake,X2,3 +No Guidance,Chris Brown Featuring Drake,X12,3 +No Guidance,Chris Brown Featuring Drake,X14,3 +Please Me,Cardi B & Bruno Mars,M2,3 +Please Me,Cardi B & Bruno Mars,F3,3 +Please Me,Cardi B & Bruno Mars,F7,3 +Please Me,Cardi B & Bruno Mars,X2,3 +Please Me,Cardi B & Bruno Mars,X7,3 +Please Me,Cardi B & Bruno Mars,X8,3 +Please Me,Cardi B & Bruno Mars,X9,3 +Money,Cardi B,F1,3 +Money,Cardi B,F5,3 +Money,Cardi B,F7,3 +Money,Cardi B,X1,3 +Money,Cardi B,X2,3 +Money,Cardi B,X3,3 +Money,Cardi B,X8,3 +You Need To Calm Down,Taylor Swift,X14,3 +Look Back At It,A Boogie Wit da Hoodie,M1,3 +Look Back At It,A Boogie Wit da Hoodie,M5,3 +Look Back At It,A Boogie Wit da Hoodie,M6,3 +Look Back At It,A Boogie Wit da Hoodie,M10,3 +Look Back At It,A Boogie Wit da Hoodie,X2,3 +Look Back At It,A Boogie Wit da Hoodie,X5,3 +ME!,Taylor Swift Featuring Brendon Urie,F11,3 +ME!,Taylor Swift Featuring Brendon Urie,X1,3 +ME!,Taylor Swift Featuring Brendon Urie,X9,3 +ME!,Taylor Swift Featuring Brendon Urie,X14,3 +Taki Taki,"DJ Snake Featuring Selena Gomez, Ozuna & Cardi B",M1,3 +Taki Taki,"DJ Snake Featuring Selena Gomez, Ozuna & Cardi B",M7,3 +Taki Taki,"DJ Snake Featuring Selena Gomez, Ozuna & Cardi B",F1,3 +Taki Taki,"DJ Snake Featuring Selena Gomez, Ozuna & Cardi B",F12,3 +Taki Taki,"DJ Snake Featuring Selena Gomez, Ozuna & Cardi B",X2,3 +Taki Taki,"DJ Snake Featuring Selena Gomez, Ozuna & Cardi B",X6,3 +Taki Taki,"DJ Snake Featuring Selena Gomez, Ozuna & Cardi B",X8,3 +Taki Taki,"DJ Snake Featuring Selena Gomez, Ozuna & Cardi B",X15,3 +Act Up,City Girls,F1,3 +Act Up,City Girls,F3,3 +Act Up,City Girls,F5,3 +Act Up,City Girls,F7,3 +Act Up,City Girls,X3,3 +Act Up,City Girls,X8,3 +Act Up,City Girls,X9,3 +Act Up,City Girls,X15,3 +My Type,Saweetie,F1,3 +My Type,Saweetie,F3,3 +My Type,Saweetie,F5,3 +My Type,Saweetie,F9,3 +My Type,Saweetie,F14,3 +My Type,Saweetie,X1,3 +My Type,Saweetie,X3,3 +My Type,Saweetie,X8,3 +My Type,Saweetie,X9,3 +My Type,Saweetie,X16,3 +Clout,Offset Featuring Cardi B,M2,2 +Clout,Offset Featuring Cardi B,M4,3 +Clout,Offset Featuring Cardi B,M8,3 +Clout,Offset Featuring Cardi B,F2,3 +Clout,Offset Featuring Cardi B,X1,3 +Clout,Offset Featuring Cardi B,X4,3 +Clout,Offset Featuring Cardi B,X8,3 +Clout,Offset Featuring Cardi B,X10,3 +Clout,Offset Featuring Cardi B,X14,3 +Cash Shit,Megan Thee Stallion Featuring DaBaby,M1,3 +Cash Shit,Megan Thee Stallion Featuring DaBaby,M4,3 +Cash Shit,Megan Thee Stallion Featuring DaBaby,M12,3 +Cash Shit,Megan Thee Stallion Featuring DaBaby,F1,2 +Cash Shit,Megan Thee Stallion Featuring DaBaby,F2,3 +Cash Shit,Megan Thee Stallion Featuring DaBaby,F4,3 +Cash Shit,Megan Thee Stallion Featuring DaBaby,F5,3 +Cash Shit,Megan Thee Stallion Featuring DaBaby,F7,3 +Cash Shit,Megan Thee Stallion Featuring DaBaby,X2,3 +Cash Shit,Megan Thee Stallion Featuring DaBaby,X3,3 +Cash Shit,Megan Thee Stallion Featuring DaBaby,X8,3 +Cash Shit,Megan Thee Stallion Featuring DaBaby,X10,3 +Cash Shit,Megan Thee Stallion Featuring DaBaby,X12,3 +Hot Girl Summer,"Megan Thee Stallion, Nicki Minaj & Ty Dolla $ign",M13,3 +Hot Girl Summer,"Megan Thee Stallion, Nicki Minaj & Ty Dolla $ign",F3,3 +Hot Girl Summer,"Megan Thee Stallion, Nicki Minaj & Ty Dolla $ign",F8,2 +Hot Girl Summer,"Megan Thee Stallion, Nicki Minaj & Ty Dolla $ign",X1,3 +Hot Girl Summer,"Megan Thee Stallion, Nicki Minaj & Ty Dolla $ign",X6,3 +Hot Girl Summer,"Megan Thee Stallion, Nicki Minaj & Ty Dolla $ign",X8,3 +Hot Girl Summer,"Megan Thee Stallion, Nicki Minaj & Ty Dolla $ign",X9,3 +Hot Girl Summer,"Megan Thee Stallion, Nicki Minaj & Ty Dolla $ign",X14,3 +Hot Girl Summer,"Megan Thee Stallion, Nicki Minaj & Ty Dolla $ign",X15,3 +Hot Girl Summer,"Megan Thee Stallion, Nicki Minaj & Ty Dolla $ign",X16,3 +Say So,Doja Cat,F3,3 +Say So,Doja Cat,F7,2 +Say So,Doja Cat,F12,3 +Say So,Doja Cat,X2,3 +Say So,Doja Cat,X8,3 +Say So,Doja Cat,X9,3 +Say So,Doja Cat,X15,3 +Savage,Megan Thee Stallion,F1,3 +Savage,Megan Thee Stallion,F2,3 +Savage,Megan Thee Stallion,F3,3 +Savage,Megan Thee Stallion,F8,3 +Savage,Megan Thee Stallion,X8,3 +Savage,Megan Thee Stallion,X9,3 +Savage,Megan Thee Stallion,X10,3 +Savage,Megan Thee Stallion,X15,3 +Intentions,Justin Bieber Featuring Quavo,M1,3 +Intentions,Justin Bieber Featuring Quavo,M2,3 +Intentions,Justin Bieber Featuring Quavo,M9,3 +Intentions,Justin Bieber Featuring Quavo,M11,3 +WAP,Cardi B Featuring Megan Thee Stallion,F5,3 +WAP,Cardi B Featuring Megan Thee Stallion,F7,3 +WAP,Cardi B Featuring Megan Thee Stallion,F8,3 +WAP,Cardi B Featuring Megan Thee Stallion,F9,3 +WAP,Cardi B Featuring Megan Thee Stallion,X1,3 +WAP,Cardi B Featuring Megan Thee Stallion,X3,3 +WAP,Cardi B Featuring Megan Thee Stallion,X15,3 +Hot Girl Bummer,blackbear,M1,3 +Hot Girl Bummer,blackbear,M5,3 +Hot Girl Bummer,blackbear,M6,3 +Hot Girl Bummer,blackbear,M7,3 +Hot Girl Bummer,blackbear,M13,3 +Hot Girl Bummer,blackbear,X1,3 +Hot Girl Bummer,blackbear,X2,3 +Hot Girl Bummer,blackbear,X16,3 +One Of Them Girls,Lee Brice,M3,3 +One Of Them Girls,Lee Brice,M7,3 +One Of Them Girls,Lee Brice,M8,3 +One Of Them Girls,Lee Brice,X6,3 +One Of Them Girls,Lee Brice,X13,3 +You Should Be Sad,Halsey,F2,3 +You Should Be Sad,Halsey,F11,3 +You Should Be Sad,Halsey,X4,2 +Juicy,Doja Cat & Tyga,M1,3 +Juicy,Doja Cat & Tyga,M2,3 +Juicy,Doja Cat & Tyga,M5,3 +Juicy,Doja Cat & Tyga,M7,2 +Juicy,Doja Cat & Tyga,F7,3 +Juicy,Doja Cat & Tyga,F8,3 +Juicy,Doja Cat & Tyga,F12,3 +Juicy,Doja Cat & Tyga,X2,3 +Juicy,Doja Cat & Tyga,X7,3 +Juicy,Doja Cat & Tyga,X8,3 +Juicy,Doja Cat & Tyga,X15,3 +P*$$y Fairy (OTW),Jhene Aiko,F7,3 +P*$$y Fairy (OTW),Jhene Aiko,F9,3 +P*$$y Fairy (OTW),Jhene Aiko,F12,3 +P*$$y Fairy (OTW),Jhene Aiko,X15,3 +Kiss Me More,Doja Cat Featuring SZA,F2,3 +Kiss Me More,Doja Cat Featuring SZA,F5,3 +Kiss Me More,Doja Cat Featuring SZA,F7,3 +Kiss Me More,Doja Cat Featuring SZA,F8,3 +Kiss Me More,Doja Cat Featuring SZA,X1,3 +Kiss Me More,Doja Cat Featuring SZA,X2,3 +Kiss Me More,Doja Cat Featuring SZA,X3,3 +Kiss Me More,Doja Cat Featuring SZA,X15,3 +34+35,Ariana Grande Feat. Doja Cat & Megan Thee Stallion,F14,3 +34+35,Ariana Grande Feat. Doja Cat & Megan Thee Stallion,X1,3 +34+35,Ariana Grande Feat. Doja Cat & Megan Thee Stallion,X16,3 +Up,Cardi B,F2,3 +Up,Cardi B,F3,3 +Up,Cardi B,F5,3 +Up,Cardi B,F6,3 +Up,Cardi B,F7,3 +Up,Cardi B,X3,3 +Up,Cardi B,X8,3 +Up,Cardi B,X9,2 +Up,Cardi B,X10,3 +Up,Cardi B,X15,3 +Best Friend,Saweetie Featuring Doja Cat,F1,3 +Best Friend,Saweetie Featuring Doja Cat,F6,3 +Best Friend,Saweetie Featuring Doja Cat,F7,3 +Best Friend,Saweetie Featuring Doja Cat,F8,3 +Best Friend,Saweetie Featuring Doja Cat,X1,3 +Best Friend,Saweetie Featuring Doja Cat,X2,3 +Best Friend,Saweetie Featuring Doja Cat,X8,3 +Best Friend,Saweetie Featuring Doja Cat,X15,3 +Beautiful Mistakes,Maroon 5 Featuring Megan Thee Stallion,X2,3 +Beautiful Mistakes,Maroon 5 Featuring Megan Thee Stallion,X6,3 +Need To Know,Doja Cat,F3,3 +Need To Know,Doja Cat,F9,3 +Need To Know,Doja Cat,F13,3 +Need To Know,Doja Cat,X9,3 +Need To Know,Doja Cat,X15,3 +Body,Megan Thee Stallion,F7,3 +Body,Megan Thee Stallion,F8,3 +Body,Megan Thee Stallion,X1,2 +Body,Megan Thee Stallion,X8,3 +Body,Megan Thee Stallion,X15,3 +Thot Shit,Megan Thee Stallion,F2,3 +Thot Shit,Megan Thee Stallion,F3,3 +Thot Shit,Megan Thee Stallion,F4,3 +Thot Shit,Megan Thee Stallion,F5,3 +Thot Shit,Megan Thee Stallion,F6,3 +Thot Shit,Megan Thee Stallion,X3,3 +Thot Shit,Megan Thee Stallion,X8,3 +Thot Shit,Megan Thee Stallion,X9,3 +Thot Shit,Megan Thee Stallion,X10,3 +Thot Shit,Megan Thee Stallion,X15,3 +Kings & Queens,Ava Max,F2,3 +Kings & Queens,Ava Max,F7,3 +Kings & Queens,Ava Max,F8,3 +Kings & Queens,Ava Max,F9,3 +Kings & Queens,Ava Max,X10,3 +Cry Baby,Megan Thee Stallion Featuring DaBaby,M1,3 +Cry Baby,Megan Thee Stallion Featuring DaBaby,M2,3 +Cry Baby,Megan Thee Stallion Featuring DaBaby,M4,3 +Cry Baby,Megan Thee Stallion Featuring DaBaby,M13,3 +Cry Baby,Megan Thee Stallion Featuring DaBaby,F2,3 +Cry Baby,Megan Thee Stallion Featuring DaBaby,F3,3 +Cry Baby,Megan Thee Stallion Featuring DaBaby,F5,2 +Cry Baby,Megan Thee Stallion Featuring DaBaby,X1,3 +Cry Baby,Megan Thee Stallion Featuring DaBaby,X2,3 +Cry Baby,Megan Thee Stallion Featuring DaBaby,X3,3 +Cry Baby,Megan Thee Stallion Featuring DaBaby,X4,3 +Cry Baby,Megan Thee Stallion Featuring DaBaby,X5,3 +Cry Baby,Megan Thee Stallion Featuring DaBaby,X6,3 +Cry Baby,Megan Thee Stallion Featuring DaBaby,X7,3 +Cry Baby,Megan Thee Stallion Featuring DaBaby,X9,3 +Cry Baby,Megan Thee Stallion Featuring DaBaby,X10,3 +Big Energy,Latto,F1,3 +Big Energy,Latto,F2,3 +Big Energy,Latto,F3,3 +Big Energy,Latto,F5,3 +Big Energy,Latto,F7,3 +Big Energy,Latto,F8,3 +Big Energy,Latto,F12,3 +Big Energy,Latto,F13,3 +Big Energy,Latto,F14,3 +Big Energy,Latto,X1,3 +Big Energy,Latto,X3,3 +Big Energy,Latto,X8,3 +Big Energy,Latto,X9,3 +Big Energy,Latto,X10,3 +Big Energy,Latto,X15,3 +abcdefu,GAYLE,F1,2 +abcdefu,GAYLE,F4,3 +Woman,Doja Cat,F5,3 +Woman,Doja Cat,F7,3 +Woman,Doja Cat,F8,3 +Woman,Doja Cat,F11,3 +Woman,Doja Cat,F13,3 +Woman,Doja Cat,X3,3 +Woman,Doja Cat,X15,3 +Vegas,Doja Cat,F1,2 +Vegas,Doja Cat,F2,3 +Super Freaky Girl,Nicki Minaj,F1,3 +Super Freaky Girl,Nicki Minaj,F3,3 +Super Freaky Girl,Nicki Minaj,F7,3 +Super Freaky Girl,Nicki Minaj,F8,3 +Super Freaky Girl,Nicki Minaj,X1,3 +Super Freaky Girl,Nicki Minaj,X7,3 +Super Freaky Girl,Nicki Minaj,X8,3 +Super Freaky Girl,Nicki Minaj,X9,3 +Super Freaky Girl,Nicki Minaj,X15,3 +MAMIII,Becky G X Karol G,F3,3 +MAMIII,Becky G X Karol G,F4,3 +MAMIII,Becky G X Karol G,F8,3 +MAMIII,Becky G X Karol G,X2,3 +MAMIII,Becky G X Karol G,X9,3 +MAMIII,Becky G X Karol G,X10,2 +MAMIII,Becky G X Karol G,X15,3 +Sweetest Pie,Megan Thee Stallion & Dua Lipa,F3,3 +Sweetest Pie,Megan Thee Stallion & Dua Lipa,F7,3 +Sweetest Pie,Megan Thee Stallion & Dua Lipa,F9,3 +Sweetest Pie,Megan Thee Stallion & Dua Lipa,X8,3 +Sweetest Pie,Megan Thee Stallion & Dua Lipa,X9,3 +Sweetest Pie,Megan Thee Stallion & Dua Lipa,X15,3 +Get Into It (Yuh),Doja Cat,F3,3 +Get Into It (Yuh),Doja Cat,F7,3 +Get Into It (Yuh),Doja Cat,F8,3 +Get Into It (Yuh),Doja Cat,X2,3 +Get Into It (Yuh),Doja Cat,X8,3 +Get Into It (Yuh),Doja Cat,X9,3 +Get Into It (Yuh),Doja Cat,X15,3 +Party,Bad Bunny & Rauw Alejandro,M1,3 +Party,Bad Bunny & Rauw Alejandro,M3,3 +Party,Bad Bunny & Rauw Alejandro,M4,3 +Party,Bad Bunny & Rauw Alejandro,M6,3 +Party,Bad Bunny & Rauw Alejandro,M13,3 +Party,Bad Bunny & Rauw Alejandro,X2,3 +Party,Bad Bunny & Rauw Alejandro,X3,3 +Party,Bad Bunny & Rauw Alejandro,X13,2 +Cuff It,Beyonce,F11,3 +Cuff It,Beyonce,F13,3 +Cuff It,Beyonce,X1,3 +Cuff It,Beyonce,X12,3 +Cuff It,Beyonce,X15,2 +Players,Coi Leray,F3,3 +Players,Coi Leray,F5,3 +Players,Coi Leray,F6,3 +Players,Coi Leray,F7,3 +Players,Coi Leray,F8,3 +Players,Coi Leray,F9,3 +Players,Coi Leray,X1,3 +Players,Coi Leray,X8,3 +Players,Coi Leray,X9,3 +Players,Coi Leray,X15,3 +Dance The Night,Dua Lipa,F7,2 +Dance The Night,Dua Lipa,F10,3 +Dance The Night,Dua Lipa,F11,2 +Spin Bout U,Drake & 21 Savage,M1,3 +Spin Bout U,Drake & 21 Savage,M2,3 +Spin Bout U,Drake & 21 Savage,M4,3 +Spin Bout U,Drake & 21 Savage,M5,3 +Spin Bout U,Drake & 21 Savage,M6,3 +Spin Bout U,Drake & 21 Savage,M9,3 +Spin Bout U,Drake & 21 Savage,M10,3 +Spin Bout U,Drake & 21 Savage,M11,3 +Spin Bout U,Drake & 21 Savage,M13,3 +Spin Bout U,Drake & 21 Savage,X2,3 +Spin Bout U,Drake & 21 Savage,X3,3 +Spin Bout U,Drake & 21 Savage,X7,2 +Spin Bout U,Drake & 21 Savage,X9,3 +Spin Bout U,Drake & 21 Savage,X12,2 +Spin Bout U,Drake & 21 Savage,X13,3 +Spin Bout U,Drake & 21 Savage,X16,3 +Barbie World,Nicki Minaj & Ice Spice With Aqua,F1,3 +Barbie World,Nicki Minaj & Ice Spice With Aqua,F5,3 +Barbie World,Nicki Minaj & Ice Spice With Aqua,F7,3 +Barbie World,Nicki Minaj & Ice Spice With Aqua,F8,3 +Barbie World,Nicki Minaj & Ice Spice With Aqua,F13,3 +Barbie World,Nicki Minaj & Ice Spice With Aqua,F14,2 +Barbie World,Nicki Minaj & Ice Spice With Aqua,X1,3 +Barbie World,Nicki Minaj & Ice Spice With Aqua,X2,2 +Barbie World,Nicki Minaj & Ice Spice With Aqua,X3,3 +Barbie World,Nicki Minaj & Ice Spice With Aqua,X8,3 +Barbie World,Nicki Minaj & Ice Spice With Aqua,X15,3 +Paint The Town Red,Doja Cat,F1,3 +Paint The Town Red,Doja Cat,X1,3 +Paint The Town Red,Doja Cat,X8,3 +Made You Look,Meghan Trainor,F6,3 +Made You Look,Meghan Trainor,F7,3 +Made You Look,Meghan Trainor,F8,3 +Made You Look,Meghan Trainor,X7,3 +Made You Look,Meghan Trainor,X15,3 +Wait In The Truck,HARDY Featuring Lainey Wilson,M2,3 +Wait In The Truck,HARDY Featuring Lainey Wilson,M3,3 +Wait In The Truck,HARDY Featuring Lainey Wilson,M12,3 +Wait In The Truck,HARDY Featuring Lainey Wilson,X13,3 +TQG,Karol G x Shakira,F3,3 +TQG,Karol G x Shakira,F4,3 +TQG,Karol G x Shakira,F8,3 +TQG,Karol G x Shakira,F11,3 +TQG,Karol G x Shakira,X7,3 +TQG,Karol G x Shakira,X8,2 +TQG,Karol G x Shakira,X9,3 +TQG,Karol G x Shakira,X15,3 +Princess Diana,Ice Spice & Nicki Minaj,F1,3 +Princess Diana,Ice Spice & Nicki Minaj,F3,3 +Princess Diana,Ice Spice & Nicki Minaj,F6,3 +Princess Diana,Ice Spice & Nicki Minaj,F8,3 +Princess Diana,Ice Spice & Nicki Minaj,X8,3 +Princess Diana,Ice Spice & Nicki Minaj,X9,3 +Princess Diana,Ice Spice & Nicki Minaj,X15,3 +Tomorrow 2,GloRilla & Cardi B,F2,3 +Tomorrow 2,GloRilla & Cardi B,F3,3 +Tomorrow 2,GloRilla & Cardi B,X3,2 +Tomorrow 2,GloRilla & Cardi B,X8,3 +Tomorrow 2,GloRilla & Cardi B,X9,3 +Tomorrow 2,GloRilla & Cardi B,X10,2 +Tomorrow 2,GloRilla & Cardi B,X14,3 +What It Is (Block Boy),Doechii Featuring Kodak Black,F7,3 +What It Is (Block Boy),Doechii Featuring Kodak Black,F11,3 +What It Is (Block Boy),Doechii Featuring Kodak Black,F13,3 +What It Is (Block Boy),Doechii Featuring Kodak Black,X1,3 +What It Is (Block Boy),Doechii Featuring Kodak Black,X11,3 +What It Is (Block Boy),Doechii Featuring Kodak Black,X12,2 +What It Is (Block Boy),Doechii Featuring Kodak Black,X16,3 +Seven,Jung Kook Featuring Latto,F4,3 +Seven,Jung Kook Featuring Latto,F8,3 +Seven,Jung Kook Featuring Latto,X12,3 +Seven,Jung Kook Featuring Latto,X15,3 +Area Codes,Kaliii,F1,2 +Area Codes,Kaliii,F5,3 +Area Codes,Kaliii,F9,3 +Area Codes,Kaliii,X1,3 +Area Codes,Kaliii,X3,3 +Area Codes,Kaliii,X8,3 +Wild As Her,Corey Kent,M3,3 +Wild As Her,Corey Kent,M8,3 +Wild As Her,Corey Kent,M9,3 +Wild As Her,Corey Kent,M12,3 +Wild As Her,Corey Kent,X4,3 +Wild As Her,Corey Kent,X13,3 +"Bzrp Music Sessions, Vol. 53",Bizarrap & Shakira,F3,3 +"Bzrp Music Sessions, Vol. 53",Bizarrap & Shakira,F4,3 +"Bzrp Music Sessions, Vol. 53",Bizarrap & Shakira,F6,3 +"Bzrp Music Sessions, Vol. 53",Bizarrap & Shakira,F7,3 +"Bzrp Music Sessions, Vol. 53",Bizarrap & Shakira,F8,3 +"Bzrp Music Sessions, Vol. 53",Bizarrap & Shakira,F10,3 +"Bzrp Music Sessions, Vol. 53",Bizarrap & Shakira,X5,2 +"Bzrp Music Sessions, Vol. 53",Bizarrap & Shakira,X8,3 +"Bzrp Music Sessions, Vol. 53",Bizarrap & Shakira,X9,3 +Put It On Da Floor Again,Latto Featuring Cardi B,F5,3 +Put It On Da Floor Again,Latto Featuring Cardi B,X3,2 +Put It On Da Floor Again,Latto Featuring Cardi B,X8,3 +Put It On Da Floor Again,Latto Featuring Cardi B,X14,2 +Put It On Da Floor Again,Latto Featuring Cardi B,X16,3 +Bloody Mary,Lady Gaga,F11,3 +Bloody Mary,Lady Gaga,X11,3 +Bloody Mary,Lady Gaga,X12,2 +Espresso,Sabrina Carpenter,F3,3 +Espresso,Sabrina Carpenter,F7,3 +Espresso,Sabrina Carpenter,F8,3 +Espresso,Sabrina Carpenter,F9,3 +Espresso,Sabrina Carpenter,F11,3 +Espresso,Sabrina Carpenter,X9,3 +Espresso,Sabrina Carpenter,X15,3 +Agora Hills,Doja Cat,F3,3 +Agora Hills,Doja Cat,F7,3 +Agora Hills,Doja Cat,F11,3 +Agora Hills,Doja Cat,F13,3 +Agora Hills,Doja Cat,X2,2 +Agora Hills,Doja Cat,X8,2 +Agora Hills,Doja Cat,X9,3 +Agora Hills,Doja Cat,X11,3 +Feather,Sabrina Carpenter,F2,3 +Feather,Sabrina Carpenter,X10,3 +Cowgirls,Morgan Wallen Featuring ERNEST,M3,3 +Cowgirls,Morgan Wallen Featuring ERNEST,M6,3 +Cowgirls,Morgan Wallen Featuring ERNEST,M8,3 +Cowgirls,Morgan Wallen Featuring ERNEST,M12,3 +Cowgirls,Morgan Wallen Featuring ERNEST,X2,3 +Cowgirls,Morgan Wallen Featuring ERNEST,X16,3 +Wanna Be,GloRilla & Megan Thee Stallion,F3,3 +Wanna Be,GloRilla & Megan Thee Stallion,F4,3 +Wanna Be,GloRilla & Megan Thee Stallion,F7,3 +Wanna Be,GloRilla & Megan Thee Stallion,F9,3 +Wanna Be,GloRilla & Megan Thee Stallion,X1,3 +Wanna Be,GloRilla & Megan Thee Stallion,X8,3 +Wanna Be,GloRilla & Megan Thee Stallion,X9,3 +Wanna Be,GloRilla & Megan Thee Stallion,X15,3 +Yeah Glo!,GloRilla,F1,2 +Yeah Glo!,GloRilla,F2,3 +Yeah Glo!,GloRilla,F6,3 +Yeah Glo!,GloRilla,X8,3 +Rich Baby Daddy,Drake Featuring Sexyy Red & SZA,M1,3 +Rich Baby Daddy,Drake Featuring Sexyy Red & SZA,M2,3 +Rich Baby Daddy,Drake Featuring Sexyy Red & SZA,M4,3 +Rich Baby Daddy,Drake Featuring Sexyy Red & SZA,M10,3 +Rich Baby Daddy,Drake Featuring Sexyy Red & SZA,F5,3 +Rich Baby Daddy,Drake Featuring Sexyy Red & SZA,X1,3 +Rich Baby Daddy,Drake Featuring Sexyy Red & SZA,X2,3 +Rich Baby Daddy,Drake Featuring Sexyy Red & SZA,X3,3 +Rich Baby Daddy,Drake Featuring Sexyy Red & SZA,X4,2 +Rich Baby Daddy,Drake Featuring Sexyy Red & SZA,X7,3 +Rich Baby Daddy,Drake Featuring Sexyy Red & SZA,X13,3 +Rich Baby Daddy,Drake Featuring Sexyy Red & SZA,X14,3 +Rich Baby Daddy,Drake Featuring Sexyy Red & SZA,X15,3 +Never Lose Me,Flo Milli,F1,3 +Never Lose Me,Flo Milli,F3,3 +Never Lose Me,Flo Milli,F5,3 +Never Lose Me,Flo Milli,F11,3 +Never Lose Me,Flo Milli,F14,3 +Never Lose Me,Flo Milli,X1,3 +Never Lose Me,Flo Milli,X3,3 +Never Lose Me,Flo Milli,X8,3 +Never Lose Me,Flo Milli,X9,3 +Never Lose Me,Flo Milli,X16,3 +Get It Sexyy,Sexyy Red,F7,3 +Get It Sexyy,Sexyy Red,F8,3 +Get It Sexyy,Sexyy Red,X1,3 +Get It Sexyy,Sexyy Red,X15,3 +Everybody,Nicki Minaj Featuring Lil Uzi Vert,M1,3 +Everybody,Nicki Minaj Featuring Lil Uzi Vert,M11,2 +Everybody,Nicki Minaj Featuring Lil Uzi Vert,X1,3 +Everybody,Nicki Minaj Featuring Lil Uzi Vert,X2,3 +Everybody,Nicki Minaj Featuring Lil Uzi Vert,X3,3 +Everybody,Nicki Minaj Featuring Lil Uzi Vert,X4,3 +Everybody,Nicki Minaj Featuring Lil Uzi Vert,X16,2 +High Road,Koe Wetzel & Jessie Murph,X11,3 +High Road,Koe Wetzel & Jessie Murph,X12,2 +High Road,Koe Wetzel & Jessie Murph,X14,3 +Taste,Sabrina Carpenter,F3,3 +Taste,Sabrina Carpenter,F4,3 +Taste,Sabrina Carpenter,F9,3 +Taste,Sabrina Carpenter,X8,3 +Taste,Sabrina Carpenter,X9,3 +Taste,Sabrina Carpenter,X15,3 +FTCU,Nicki Minaj,F2,3 +FTCU,Nicki Minaj,F5,3 +FTCU,Nicki Minaj,F8,2 +FTCU,Nicki Minaj,X3,3 +FTCU,Nicki Minaj,X8,3 +FTCU,Nicki Minaj,X10,3 +FTCU,Nicki Minaj,X15,3 +On My Mama,Victoria Monet,F7,2 +On My Mama,Victoria Monet,F8,3 +On My Mama,Victoria Monet,F12,3 +On My Mama,Victoria Monet,X2,3 +On My Mama,Victoria Monet,X8,3 +On My Mama,Victoria Monet,X15,3 +That's So True,Gracie Abrams,F3,3 +That's So True,Gracie Abrams,F11,3 +That's So True,Gracie Abrams,X9,3 +That's So True,Gracie Abrams,X11,2 +That's So True,Gracie Abrams,X15,3 +30 For 30,SZA With Kendrick Lamar,F2,3 +30 For 30,SZA With Kendrick Lamar,F3,3 +30 For 30,SZA With Kendrick Lamar,F10,3 +30 For 30,SZA With Kendrick Lamar,F14,3 +30 For 30,SZA With Kendrick Lamar,X8,3 +30 For 30,SZA With Kendrick Lamar,X9,3 +30 For 30,SZA With Kendrick Lamar,X10,3 +30 For 30,SZA With Kendrick Lamar,X16,3 +Manchild,Sabrina Carpenter,F4,3 +Manchild,Sabrina Carpenter,F9,3 +Manchild,Sabrina Carpenter,F11,3 +Manchild,Sabrina Carpenter,X12,3 +Sticky,"Tyler, The Creator Featuring GloRilla, Sexyy Red & Lil Wayne",M13,3 +Sticky,"Tyler, The Creator Featuring GloRilla, Sexyy Red & Lil Wayne",F1,3 +Sticky,"Tyler, The Creator Featuring GloRilla, Sexyy Red & Lil Wayne",F2,2 +Sticky,"Tyler, The Creator Featuring GloRilla, Sexyy Red & Lil Wayne",F3,2 +Sticky,"Tyler, The Creator Featuring GloRilla, Sexyy Red & Lil Wayne",X1,3 +Sticky,"Tyler, The Creator Featuring GloRilla, Sexyy Red & Lil Wayne",X8,3 +Whatchu Kno About Me,GloRilla & Sexyy Red,F1,3 +Whatchu Kno About Me,GloRilla & Sexyy Red,F6,3 +Whatchu Kno About Me,GloRilla & Sexyy Red,F7,3 +Whatchu Kno About Me,GloRilla & Sexyy Red,X1,3 +Whatchu Kno About Me,GloRilla & Sexyy Red,X2,3 +Whatchu Kno About Me,GloRilla & Sexyy Red,X7,3 +Whatchu Kno About Me,GloRilla & Sexyy Red,X8,3 +Whatchu Kno About Me,GloRilla & Sexyy Red,X15,3 +Blue Strips,Jessie Murph,F4,3 +Blue Strips,Jessie Murph,F8,3 +Blue Strips,Jessie Murph,X2,3 +Blue Strips,Jessie Murph,X8,2 +Blue Strips,Jessie Murph,X15,3 +Happen To Me,Russell Dickerson,M3,3 +Happen To Me,Russell Dickerson,M6,3 +Happen To Me,Russell Dickerson,M7,3 +Happen To Me,Russell Dickerson,M8,3 +BMF,SZA,F3,3 +BMF,SZA,F11,3 +BMF,SZA,F13,3 +BMF,SZA,X8,3 +BMF,SZA,X9,3 +BMF,SZA,X11,3 +BMF,SZA,X15,3 +Heart Of A Woman,Summer Walker,F4,3 +Heart Of A Woman,Summer Walker,F11,3 +Heart Of A Woman,Summer Walker,X11,3 +Heart Of A Woman,Summer Walker,X12,3 diff --git a/results/patterns.csv b/results/patterns.csv new file mode 100644 index 0000000..6432c62 --- /dev/null +++ b/results/patterns.csv @@ -0,0 +1,44 @@ +Pattern,Group,Name,Description +M1,male,將女性化約為身體部位與可品評物件,女性在歌詞中不以完整的人出現,而是被化約為特定身體部位、以「it」「that thing」等物件代稱,或直接比擬為資產、商品,由男性敘事者逐項打量、驗貨、打分。這種化約使女性成為供觀看與取用的客體,其「真假」「行情」皆可被檢視估值,主體性在語言層面即被抹除。 +M2,male,以命令句指揮女性的身體與行為,男性聲部大量使用祈使句,直接規定女性該如何擺弄身體、如何展示自己、該對誰說什麼;即使表面內容是「稱讚」或「賦權」,語法上的指令位置仍使女性成為被指揮的執行者,行動的發動權握在說話的男性手中。 +M3,male,男性單一視角代言,女性全程無聲,全曲由男性敘事者獨白,女性的內心、慾望、過去與人生選擇皆由男方轉述、詮釋與定義;她沒有名字、沒有自己的聲部,或即使有女聲也只被分配無實質內容的和聲。男性自認能讀出她的「真實心意」,她的存在被化約為男性經驗中的事件與素材。 +M4,male,金錢供養與親密關係的交易腳本,親密關係被鋪排為「男性出資、女性以身體或忠誠回報」的對價結構:男方以信用卡、保險箱密碼、購物、開店承諾滿足想像中的女性需求,女方的消費被恩賜化;即使女聲展現議價姿態,其權力來源仍繫於男性錢包,複製女性經濟依附的框架。 +M5,male,性的雙重標準與貞潔敘事,男性敘事者一面炫耀自己四處留情、性能力過人,一面審查女方的性史、迷戀其「初次」與「空白」,或嘲弄其性選擇;性征服是男性的本錢,性歷史卻是女性的污點,形成明確的雙重標準與處女情結式的評價機制。 +M6,male,挪用女性賦權語彙並掏空其意涵,原屬女性自主、自我賦權的語句與符號(「girls just wanna have fun」「hot girl」、女性的「解放」與自由)被男性敘事挪用後翻轉:或改寫為所有女性圍繞男性打轉,或接上嘲弄字眼加以譏諷,或把女性「為自己而活」的行為重新詮釋為對男性的性可得性,使賦權語彙淪為服務男性慾望的裝飾。 +M7,male,架空拒絕與同意,女性明確的拒絕被詮釋為欲擒故縱的測試遊戲,設下的界線被描述為男性必須「攻破」的障礙;同時在對方飲酒、情感脆弱、判斷力可能受損的情境下推進追求,把踩剎車的責任推給女方、預設沉默即同意,使「不就是不」的同意原則在敘事中失效。 +M8,male,「紅顏禍水」式的歸咎敘事,行使自主權、主動離開或單純存在的女性,被描繪為蓄意誘惑、摧毀男性的危險力量;男性的心碎、慾望與情感責任被整批轉嫁到女方身上,主動選擇的女性被貼上「會傷害男人」「應被警惕」的標籤,男性則自居受害者。 +M9,male,男性自居評價者、授權者與守門人,即使歌詞表面在讚美或「聲援」女性,評價與認可的權力始終在男性手上:由他審核她「帶了什麼上桌」、裁定她的展現「沒關係」、宣稱她不需要外界認可(而正是由他來確保這一點),甚至她的事業與夢想也取決於男方的一通電話,女性的價值須經男性核可方能成立。 +M10,male,單向忠誠要求與占有控制,忠誠、順從與情緒供給的義務僅單方面加諸女性:以誓詞式逼問要求她永不離開、擅自切斷她的人際關係與通訊、以暴力威脅框限其社交自主,或要求她無條件附和、拒絕被質疑;未見任何對等的承諾結構,關係權力全然不對等。 +M11,male,貶抑其他女性以襯托眼前對象、將女性可替換化,歌詞以「hoes」等厭女詞彙貶低其他女性、抹黑女性情誼、暗示多數女性只想依附,藉此抬高眼前這一位「特別」的女子;而她的特別又建立在男性閱女無數的比較之上,女性整體被放進可品評、汰選、互換的集合,並被挑動彼此敵意。 +M12,male,動物化、幼體化與馴服框架,女性被比擬為馬匹等可騎乘、可駕馭的動物,或以「小東西」「小寶貝」等幼體化、寵物化稱呼矮化;即使以否定句表態「不馴服她」,仍預設馴服女人是常態選項,男性居於決定馴服與否、施予自由與否的權力位置。 +M13,male,以性征服炫耀男性氣概、將性框架為男性間的較量,性行為被以狩獵、命中、考驗等語彙描述為男性的戰績與資本:或炫耀能奪取他人伴侶、或以陽具與性能力自誇、或挑釁式質疑女方的「承受能力」,女性的意願缺席,僅作為男性之間比拚性能力的標的與戰利品。 +F1,female,厭女詞彙的雙向流通——自稱與攻擊共用同一套貶稱,大量報告呈現同一種語言機制:女性敘事者以「bitch」「hoes」「ho」等根源於貶低女性的詞彙,同時用於自我命名(常以「賦權」「挪用」姿態出現)與攻擊其他女性。由於同一詞彙在文本內兼作自誇與罵詞,其貶義功能持續運作,挪用的翻轉意涵被抵銷;即使全數出自女性之口,仍再生產「以侮辱女性的字眼定義女性」的語言結構,並使該詞彙常態化。 +F2,female,陰性即侮辱、陽剛即力量的語言階序,多份報告指出一種對稱的修辭:一方面以女性性器或陰柔特質(「pussy」「sissy」「bitch」)作為「懦弱、低劣」的罵詞,另一方面以男性身體符碼與陽剛資格(陰莖尺寸、「夠不夠男人」、big dick energy)作為力量與人格價值的度量。無論罵的是男是女,這套語言都預設「陰性=可鄙、陽剛=權力」,女性敘事者展現強悍時也須借用男性符碼命名自身力量。 +F3,female,以男性為戰利品的女性敵對競爭,跨越極多首歌的核心情節:女性之間的關係被框定為圍繞男性注意力、慾望與佔有的零和較勁——以「搶走妳的男人」「他有女友仍求見我」作為炫耀與羞辱手段,把男性當作女性互鬥的獎品與計分工具,並常升級為對其他女性的肢體暴力威脅展演;自我賦權建立在其他女性的失敗、恐懼與從屬之上,而非任何連帶。 +F4,female,男性過錯的責任轉嫁至女性,當敘事涉及男性的背叛或失格,怒火與究責常偏移到女性身上:指向「第三者」、對方的新歡、母親姊妹等親屬,甚至內化為敘事者的自我懷疑;真正該負責的男性則在敘事中隱身、免責,形成「女人怪罪女人」「女人替男人的錯自責」的結構。 +F5,female,性—資源交易腳本,性與親密關係被明碼標價為與男性金錢的交換:以性魅力換取名牌、學費、供養,以男方財力作為交往前提與篩選標準。即使以女性主動、掌控的口吻呈現,文本仍複製「男性出錢、女性以性回報」的性別化交易結構,把女性身體價值錨定在男性購買力上,而非挑戰這套交換邏輯。 +F6,female,消費化的賦權——以財富與奢侈品界定自主與尊重,「賦權」「獨立」「療癒」被窄化為消費能力與排場符號:快樂等同購物、尊重繫於金飾財力、姊妹情誼以餽贈名牌維繫。這種論述具階級排他性,將女性自主收編進市場邏輯,對不具此經濟條件的女性形成排除,也未觸及結構性的平等。 +F7,female,身體的部位化與商品化自我物化,女性(含敘事者自身)被拆解為可檢視、評分、品嚐的部位與規格清單:胸、腰、臀的比例評比,以食物、飲品、貴金屬、玩偶等消費品自況,把「可被觀看、可被取用」當作價值來源。即使以第一人稱自信口吻發聲,仍複製將人化約為可估價客體的凝視邏輯。 +F8,female,自主宣言與男性凝視背書的矛盾結構,許多歌曲開頭或表層是自主、獨立、女神式的宣告,但價值的最終認證權仍交還男性:力量由「你」觸發、神性須「讓你相信」、魅力以男人驚豔失神、慾求排隊為證。宣稱自主與引用男性凝視作證並存於同一文本,形成內在矛盾,使賦權敘事繞回男性裁判的迴圈。 +F9,female,權力位置的翻轉而非拆解,「女力」以宰制—服從關係的位置對調來呈現:把男性化約為征服名單上的戰利品、可試駕丟棄的商品、待馴的狗,或以絕對王權式威嚇號令他人。物化與支配的邏輯原封不動,只是換人坐上壓迫者的位置;被複製的是「以征服計分」「階序即力量」的父權權力模式本身。 +F10,female,冷硬無情等於力量的情感規訓,「變強」被等同於情感封閉:不流淚、少付出、令對方依賴、心碎也維持完美儀容。這套敘事採納傳統陽剛支配腳本(壓抑情感、以他人依賴為權力來源)作為女性力量的樣板,並貶抑長期被性別化的依附與脆弱特質,把痛苦不得外露當成美德甚至加以審美化。 +F11,female,照顧、隱忍與依附的浪漫化,女性被定位為修復破碎男人、打理其生活、無條件原諒與等待的一方:明知受傷仍「一次又一次給機會」、以家務與情感勞動作為自我推銷的賣點、把「無法放手」呈現為愛的證明。這種付出被描繪成天性或美德,使不對等的情感勞動與依附式自我價值被自然化。 +F12,female,同意界線的模糊與施壓,性互動的描寫反覆侵蝕明確同意的原則:將酒醉與性事直接綁定並浪漫化、對顯露遲疑者斷定「你其實想要」並叫對方閉嘴、把拒絕與調情姿態並置而稀釋「不」的訊息。無論施壓方性別為何,這套「拒絕或遲疑不算數」的話術邏輯都在文本中被常態化。 +F13,female,支配—臣服腳本的情慾化,親密關係中的上下位階被編碼為情趣與愛的證明:以「daddy」「papa」稱呼伴侶、將被打被銬被管教描寫為愛與魅力、自居被男性擺弄的從屬角色並以身體傷痕為取悅的憑證。父權式的權力階序在此不是被批判的對象,而是被慾望化、浪漫化的腳本。 +F14,female,好女人/壞女人的分類與自我切割,文本以性行為與關係位置對女性進行分類排序——好女孩/壞女孩、正宮/小三、「真」與「假」——敘事者常藉「我不是那種女人」的切割自抬身價,一面宣示性自主、一面以蕩婦羞辱與性活躍女性劃清界線。這套雙重標準將父權對女性性的評價體系內化為女性彼此定位的座標。 +X1,mixed,貶抑稱謂的常態化:以「bitch」「hoe」作為女性的預設稱呼,跨越大量歌曲,無論男聲或女聲,皆以「bitch」「hoe」「trick」「chicks」等貶抑詞彙作為指稱女性的日常用語——用於通稱、問候、稱讚甚至自況,使侮辱性標籤脫離罵人的語境而成為中性化的預設稱謂。其運作方式是把「女性」本身變成貶義詞:即使語境看似親暱或讚美,女性仍須先被安上侮辱性前提才能被談論,整體污名化因反覆流通而被自然化。 +X2,mixed,女性被化約為身體部位與性功能,女性在歌詞中被拆解為臀、胸、性器官等零件,或以「東西」、動物、車輛、食物等非人意象指稱,其存在價值被限縮於性功能與可供觀賞的部位。此一機制通常由男性敘事者的凝視驅動:讚美收束於身體,關係中「唯一真實」的只剩身體部位,人格主體被整個抹除。 +X3,mixed,性與親密關係的金錢交易化,性關係被反覆框定為明碼標價的交易:性同意可用金錢購買、身體可與名牌精品比價、性服務以學費、紅底鞋、帳戶存款作為對價,「跟我混就有錢拿」成為關係的基本語法。此模式雙向運作——男聲以財力宣示對女性性的購買權,女聲段落亦常以「以性換取物質」作為誇耀資本,使親密關係整體被收編進商品邏輯。 +X4,mixed,所有格宣示與男性間較勁的戰利品邏輯,以「my bitch」「your bitch」「your women」等所有格語式將女性標記為男性財產,與名車、鑽石並列為地位象徵;「搶走你的女人」則成為男性之間互相羞辱、炫耀的籌碼,女性的反應只用來證明說話者的魅力並貶低對手。女性在此結構中沒有立場與聲音,僅作為可奪取、可交換的戰利品在男性之間流通。 +X5,mixed,女性作為可計數、可挑選、可丟棄的可替換品,女性以複數、清單、數量的形式出現:按國籍羅列比較、以童謠點選遊戲挑選、以一夜情次數與「拉來的女孩數」計分,連名字都可以亂猜或不記得。其運作在於強調數量而抹除個體——任何一個女性都可被下一個取代,去留取決於是否提供性服務,拒絕即遭驅逐或丟棄。 +X6,mixed,性同意的消解與性資格感,多首歌以不同手法架空女性的「不」:下藥後性交並引以為傲、性行為中擅自取下保險套、宣稱「嘴上說不要其實想要」而比女性更懂她自己、以言語安撫壓制反抗、被封鎖後仍持續糾纏、把性當成女方「欠給」的東西而對拒絕表現質問。共同機制是男性慾望被預設為當然權利,女性的意願在文本中缺席、被改寫或被覆蓋。 +X7,mixed,命令句驅使女性進行性化的身體表演,由男性(或男性視角)發出的祈使句——扭、倒退、彎腰、搖擺——把女性定位為聽令演出、供人觀賞取樂甚至可在男人之間「共享」的表演者。表演常與獲利、拍攝上傳、助興享樂綁定,女性在這些段落中沒有回應的聲音,只有被指揮的身體。 +X8,mixed,女聲的自我賦權以貶抑其他女性為代價,眾多女性聲部展現金錢、性或事業上的強勢姿態,但其「賦權」的修辭建構在踩低同性之上:沿用「bitches」「hoes」「thots」等厭女詞彙、進行蕩婦羞辱、身體羞辱與階級羞辱,把其他女性貶為虛偽、嫉妒、低價值的群體以自我抬升。此模式顯示厭女語言被內化為女性自我證明的工具,賦權表述反而使貶抑語彙持續流通。 +X9,mixed,以男性為軸心的女女敵對競爭,女性之間的關係被框定為圍繞男性的爭奪戰:「看好你的男人」、「搶走妳的男人」成為勝負宣言,男伴被描述為須藏好以防被奪的財產,女性則被提醒外面有大量競爭者、必須證明勝過其他女人才能留住男人。此機制把女性的價值與勝負綁定於男性的青睞,預設女性互為情敵而非同盟。 +X10,mixed,「陰性=低劣」的象徵階序,「像女人」被當作對男性的終極侮辱:以月經、八卦、呻吟、示弱等被標記為女性專屬的特質貶低對手;反之,權力的表達則借用「king」、皮條客、陽具意象等陽性符碼,即使由女性說出,仍以男性符號為力量的度量衡。兩者合構成同一套階序——陰性特質可恥、陽性特質為尊。 +X11,mixed,女性犧牲、忍耐與自我消耗的神聖化,女性的付出被推向極致並加以歌頌:母親的人生化約為兒子的墊腳石、女方替男方承擔法律與財務風險被美化為「公平交換」、明知受傷仍以藥物麻痺自己留在有害關係被包裝為愛。共同機制是以「愛」「奉獻」「真心」之名,將自我抹除與依附有害關係浪漫化,女性作為獨立個體的需求就此消失。 +X12,mixed,男性免責與女性承擔善後,男性的失責被輕描淡寫或完全歸零:缺席父親以一句戲謔帶過、酗酒失格的男方理所當然由女方容忍駕車善後、男性是否出軌甚至被歸因於女方性表現是否充分。運作方式是把育兒、經濟、情緒勞動與道德責任單向轉嫁給女性,男性則保有離開與犯錯的自由。 +X13,mixed,女性失語與男性代言、救世主敘事,女性的處境由男性(或旁觀者)之口界定:受暴女性不被容許陳述與參與自身的「正義」,由男性代行暴力拯救;單親母親只透過他人的評價與指令被呈現;男性自居性與人生的「導師」,宣稱改變、教育、調教女方。共同形式是女性在攸關自身的敘事中缺席,其角色被收編為襯托男性英雄氣概或施恩姿態的素材。 +X14,mixed,女性情緒的病理化與噤聲規訓,女性的憤怒、悲傷與抗議被貼上「瘋狂」「歇斯底里」「無理取鬧」的標籤,或被命令收起眼淚、閉嘴、安分、讓路;女聲段落亦常自我內化此框架,以道歉式口吻承認自己「psycho」「crazy」。機制在於把女性的情緒表達本身問題化,使提出質疑者噤聲,而男方無須檢視自身行為。 +X15,mixed,女性自我價值錨定於男性凝視與慾望,即使在女性主導的段落中,自我讚頌的判準仍是「他喜歡」「他需要」「成為他的幻想」:身體價值由男性的目光、性反應與挑選來確立,力量與選擇權建立在「性感、可被慾望」之上。此形式使表面的自主表述仍以男性慾望為座標,賦權被限定在迎合凝視的範圍內。 +X16,mixed,聖女/蕩婦二分與性的雙重標準,女性依性行為被劃入互斥的階序:可上床者即不配被娶、「大學女生」與性自主被預設為反差、「好女孩」成為品行規訓標籤,而女性的性經歷數量被當作攻擊其價值的武器——即使攻擊者自身同樣性自主。機制是男性(或敘事者)握有分類與評級權,女性的性成為決定其可敬程度的單一判準。 diff --git a/tools/src/pop_fem_audit_tools/__main__.py b/tools/src/pop_fem_audit_tools/__main__.py index 131fdae..7901b7f 100644 --- a/tools/src/pop_fem_audit_tools/__main__.py +++ b/tools/src/pop_fem_audit_tools/__main__.py @@ -22,6 +22,7 @@ from .commands import ( fetch_artists_command, fetch_lyrics_command, run_llm_command, + tally_annotations_command, tally_codings_command, tally_groups_command, ) @@ -36,6 +37,7 @@ SUBCOMMANDS: dict[str, Callable[[list[str] | None], int]] = { "fetch-artists": fetch_artists_command, "fetch-lyrics": fetch_lyrics_command, "run-llm": run_llm_command, + "tally-annotations": tally_annotations_command, "tally-codings": tally_codings_command, "tally-groups": tally_groups_command, } diff --git a/tools/src/pop_fem_audit_tools/commands/__init__.py b/tools/src/pop_fem_audit_tools/commands/__init__.py index 6bf3e9d..02bcb81 100644 --- a/tools/src/pop_fem_audit_tools/commands/__init__.py +++ b/tools/src/pop_fem_audit_tools/commands/__init__.py @@ -95,6 +95,32 @@ def run_llm_command(argv: list[str] | None = None) -> int: return main(argv) +def tally_annotations_command(argv: list[str] | None = None) -> int: + """Settle the step-5 pattern annotations by a majority of the + three annotation runs. + + Writes the pattern table CSV file, holding the header row + ``Pattern,Group,Name,Description`` and one row per pattern + extracted from the three gendered synthesis archives, and the + annotation table CSV file, holding the header row + ``Song,Artist Credit,Pattern,Votes`` and one row per (song, + pattern) pair at least two of the three annotation runs' + cleaned ballots carry, the song named by its title and its + stored artist credit from the SQLite working store. Nothing + is written when a synthesis section yields an empty name or + description, a run record is malformed, a song does not + appear exactly three times in the pooled ballots, or a + settled song is not in the working store; the error message + names what failed. + + :param argv: The command-line arguments, or None for + ``sys.argv``. + :return: The exit status: 0 on success, non-zero on failure. + """ + from .tally_annotations import main + return main(argv) + + def tally_codings_command(argv: list[str] | None = None) -> int: """Settle the coding by a majority of the three coding runs. diff --git a/tools/src/pop_fem_audit_tools/commands/tally_annotations.py b/tools/src/pop_fem_audit_tools/commands/tally_annotations.py new file mode 100644 index 0000000..627dda7 --- /dev/null +++ b/tools/src/pop_fem_audit_tools/commands/tally_annotations.py @@ -0,0 +1,656 @@ +# Tools for A Feminist Audit of Pop Music. +# Copyright 2026 imacat. All rights reserved. +# Authors: +# imacat@mail.imacat.idv.tw (imacat), 2026/8/15 +# AI assistance: Claude Code (Anthropic) +"""The majority tally of the three step-5 annotation runs. + +Settles the per-song pattern annotation step: the pattern +vocabulary comes from the three gendered synthesis archives (the +male-group, female-group, and mixed-group runs of step +5-03-synthesize), and the same annotation definition file is run +three times independently over every song to select which +patterns apply. This command extracts the pattern table from the +synthesis archives, tallies the three annotation runs' votes, and +writes the two final tables the paper cites: the pattern table +(the "patterns" CSV argument) and the per-song annotation table +(the "annotations" CSV argument). + +Each synthesis archive's ``output.jsonl`` holds a single record +whose "text" field is a Markdown document, one section per +pattern, each section starting with a heading line. The pattern +name is the heading text with its leading numbering token (for +example ``模式一:`` or ``一、``) stripped; the pattern +description is the section's non-empty lines that are not example +quotes (a line starting with ``- ``), joined with a single space. +The male synthesis yields the pattern IDs ``M1``, ``M2``, ...; the +female synthesis ``F1``, ``F2``, ...; the mixed synthesis ``X1``, +``X2``, ..., every set numbered in the section order of its own +document. + +Each annotation run's ``output.jsonl`` holds one record per song, +the ID ``song-`` and the "text" field a JSON array of the +pattern IDs the song was annotated with. The records of every +given run directory are pooled. A record whose "text" field is +missing, or does not parse to a JSON array of strings, is +skipped, a warning naming the run directory and song reported on +standard error; this lets a rescue archive of replacement ballots +be passed as an additional run directory when a run archive holds +a dead record. Every song must appear exactly three times in the +pool once such records are skipped. A pattern ID that is not one of +the extracted IDs, or whose gendered prefix does not apply to the +song's stored performer gender (male songs take ``M``/``X``, +female songs take ``F``/``X``, every other song takes all three), +is dropped from its ballot, as is a duplicate within the one +ballot; every drop is reported on standard error. A (song, +pattern) pair is settled when at least two of the three cleaned +ballots carry it, so three votes never tie. + +The song's title and stored artist credit are looked up in the +given SQLite working store by the numeric song ID, so this +command runs after ``build-db``. Nothing is written when a +synthesis section yields an empty name or description, a run +record is malformed, a song does not appear exactly three times +in the pool, or a settled song is not in the working store; the +error message names what failed. +""" +import argparse +import csv +import json +import re +import sqlite3 +import sys +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from ..utils import format_duration + +MAJORITY: int = 2 +"""The number of cleaned ballots that must carry a pattern for a +(song, pattern) pair to be settled.""" +BALLOTS_PER_SONG: int = 3 +"""The number of times a song must appear in the pooled ballots.""" +SONG_ID_PREFIX: str = "song-" +"""The prefix every annotation record ID must carry; the numeric +song ID is the rest of the ID.""" +PATTERNS_HEADER: tuple[str, str, str, str] = ( + "Pattern", "Group", "Name", "Description") +"""The header row of the pattern table CSV file.""" +ANNOTATIONS_HEADER: tuple[str, str, str, str] = ( + "Song", "Artist Credit", "Pattern", "Votes") +"""The header row of the annotation table CSV file.""" +_GROUP_PREFIXES: tuple[tuple[str, str], ...] = ( + ("male", "M"), ("female", "F"), ("mixed", "X")) +"""The synthesis group name and its pattern ID prefix, in the +order the three synthesis archives are given.""" +_APPLICABLE_PREFIXES: dict[str, set[str]] = { + "male": {"M", "X"}, "female": {"F", "X"}} +"""The pattern ID prefixes applicable to a song, keyed by the +song's stored performer gender; a gender missing here (including +None) takes every prefix.""" +_NUMBERING_RE: re.Pattern[str] = re.compile( + r"^(?:模式[一二三四五六七八九十]+|[一二三四五六七八九十]+)[:、]") +"""The leading numbering token of a pattern heading, stripped to +yield the pattern name.""" +_HEADING_RE: re.Pattern[str] = re.compile(r"^#+\s*(.*)$") +"""A Markdown heading line, the heading text captured.""" + + +class TallyError(Exception): + """An error that fails the annotation tally.""" + + +@dataclass(frozen=True) +class Pattern: + """One extracted pattern of a gendered synthesis document.""" + + id: str + """The pattern ID, the group's prefix and its 1-based section + number.""" + group: str + """The synthesis group the pattern came from: "male", + "female", or "mixed".""" + name: str + """The pattern name, its numbering token stripped.""" + description: str + """The pattern description, its example quotes excluded.""" + + +def parse_args(argv: list[str] | None) -> argparse.Namespace: + """Parse the command-line arguments. + + :param argv: The command-line arguments, or None for + ``sys.argv``. + :return: The parsed arguments. + """ + parser: argparse.ArgumentParser = argparse.ArgumentParser( + description="Settle the step-5 pattern annotations by a" + " majority of the three annotation runs.") + parser.add_argument( + "male_synthesis", type=Path, + help="the male-group synthesis run's archive directory") + parser.add_argument( + "female_synthesis", type=Path, + help="the female-group synthesis run's archive directory") + parser.add_argument( + "mixed_synthesis", type=Path, + help="the mixed-group synthesis run's archive directory") + parser.add_argument( + "db_path", type=Path, + help="the SQLite working database") + parser.add_argument( + "patterns_csv", type=Path, + help="the output pattern table CSV file, by convention" + " results/patterns.csv") + parser.add_argument( + "annotations_csv", type=Path, + help="the output annotation table CSV file, by convention" + " results/annotations.csv") + parser.add_argument( + "run_dir", type=Path, nargs="+", + help="an annotation run's archive directory, one or more") + return parser.parse_args(argv) + + +def load_synthesis_text(synthesis_dir: Path) -> str: + """Load the Markdown pattern document of one synthesis archive. + + :param synthesis_dir: The synthesis run's archive directory, + containing ``output.jsonl``. + :return: The "text" field of the archive's single record. + :raises TallyError: When the file cannot be read, does not + hold exactly one JSON record, the record is not a + successful result, or its "text" is not a string. + """ + path: Path = synthesis_dir / "output.jsonl" + text: str + try: + text = path.read_text(encoding="utf-8") + except OSError as error: + raise TallyError(str(error)) from error + lines: list[str] = [x for x in text.split("\n") + if x.strip() != ""] + if len(lines) != 1: + raise TallyError( + f"{path}: expected exactly one record, found" + f" {len(lines)}") + record: Any + try: + record = json.loads(lines[0]) + except json.JSONDecodeError as error: + raise TallyError( + f"{path}: malformed JSON: {error}") from error + if not isinstance(record, dict) or "error" in record \ + or "text" not in record: + raise TallyError(f"{path}: not a successful result") + body: Any = record["text"] + if not isinstance(body, str): + raise TallyError(f"{path}: \"text\" is not a string") + return body + + +def extract_patterns( + synthesis_dir: Path, group: str, prefix: str) \ + -> list[Pattern]: + """Extract the numbered patterns of one synthesis document. + + :param synthesis_dir: The synthesis run's archive directory. + :param group: The synthesis group name: "male", "female", or + "mixed". + :param prefix: The pattern ID prefix of the group. + :return: The patterns, in the document's section order, IDs + ``1``, ``2``, ... + :raises TallyError: When the archive cannot be loaded, holds + no pattern section, or a section yields an empty name or + description. + """ + text: str = load_synthesis_text(synthesis_dir) + sections: list[tuple[str, str]] = _parse_sections(text) + if len(sections) == 0: + raise TallyError( + f"{synthesis_dir}: no pattern sections found") + patterns: list[Pattern] = [] + index: int + name: str + description: str + for index, (name, description) in enumerate(sections, start=1): + if name == "" or description == "": + raise TallyError( + f"{synthesis_dir}: section {index}: empty name" + " or description") + patterns.append(Pattern( + id=f"{prefix}{index}", group=group, name=name, + description=description)) + return patterns + + +def _parse_sections(text: str) -> list[tuple[str, str]]: + """Split a pattern document into its numbered sections. + + :param text: The synthesis document. + :return: The sections' (name, description) pairs, in document + order; the text before the first heading is discarded. + """ + lines: list[str] = text.split("\n") + headings: list[int] = [ + i for i, x in enumerate(lines) if _HEADING_RE.match(x)] + sections: list[tuple[str, str]] = [] + position: int + start: int + for position, start in enumerate(headings): + end: int = headings[position + 1] \ + if position + 1 < len(headings) else len(lines) + heading: re.Match[str] | None = _HEADING_RE.match( + lines[start]) + assert heading is not None + name: str = _NUMBERING_RE.sub( + "", heading.group(1).strip(), count=1).strip() + body: list[str] = [x.strip() for x in lines[start + 1:end]] + description: str = " ".join( + x for x in body if x != "" and not x.startswith("- ")) + sections.append((name, description)) + return sections + + +def load_ballots(run_dirs: list[Path]) -> dict[int, list[list[str]]]: + """Load and pool the annotation ballots of the given runs. + + A record whose "text" field is missing, or does not parse to + a JSON array of strings, is skipped, a warning naming the run + directory and song reported on standard error, as an + observable side effect. + + :param run_dirs: The annotation runs' archive directories. + :return: The raw ballots (the selected pattern IDs, duplicates + not yet collapsed) of every song, keyed by the numeric + song ID, in the pooled record order. + :raises TallyError: When an ``output.jsonl`` cannot be read, a + line is not a well-formed record, or a song does not + appear exactly :data:`BALLOTS_PER_SONG` times in the pool + once malformed-"text" records are skipped. + """ + pooled: dict[int, list[list[str]]] = {} + run_dir: Path + for run_dir in run_dirs: + path: Path = run_dir / "output.jsonl" + text: str + try: + text = path.read_text(encoding="utf-8") + except OSError as error: + raise TallyError(str(error)) from error + line: str + for line in text.split("\n"): + if line.strip() == "": + continue + record: Any + try: + record = json.loads(line) + except json.JSONDecodeError as error: + raise TallyError( + f"{path}: malformed JSON: {error}") from error + if not isinstance(record, dict) or "id" not in record: + raise TallyError( + f"{path}: record without \"id\": {line}") + song_id: int = _parse_song_id(record["id"], run_dir) + if "text" not in record: + print( + f"warning: {run_dir}: song-{song_id}: no" + " \"text\" field, skipped", file=sys.stderr) + continue + ballot: list[str] | None = _parse_ballot( + record["text"], run_dir, song_id) + if ballot is None: + continue + pooled.setdefault(song_id, []).append(ballot) + song_id: int + ballots: list[list[str]] + for song_id, ballots in pooled.items(): + if len(ballots) != BALLOTS_PER_SONG: + raise TallyError( + f"song-{song_id}: appears {len(ballots)} times" + f" in the pool, expected {BALLOTS_PER_SONG}") + return pooled + + +def _parse_song_id(item_id: Any, run_dir: Path) -> int: + """Parse the numeric song ID out of an annotation record ID. + + :param item_id: The record's "id" field. + :param run_dir: The run directory the record came from, for + the error message. + :return: The parsed numeric song ID. + :raises TallyError: When the ID is not in the ``song-`` + form. + """ + if not isinstance(item_id, str) \ + or not item_id.startswith(SONG_ID_PREFIX) \ + or not item_id[len(SONG_ID_PREFIX):].isdigit(): + raise TallyError( + f"{run_dir}: id \"{item_id}\": not in" + f" \"{SONG_ID_PREFIX}\" form") + return int(item_id[len(SONG_ID_PREFIX):]) + + +def _parse_ballot(text: Any, run_dir: Path, song_id: int) \ + -> list[str] | None: + """Parse and validate one song's ballot "text" field. + + A "text" field that is not a string, is not well-formed JSON, + or does not parse to a JSON array of strings is reported on + standard error as a warning naming the run directory and + song, and the record is skipped. + + :param text: The record's "text" field. + :param run_dir: The run directory the record came from, for + the warning message. + :param song_id: The numeric song ID, for the warning message. + :return: The selected pattern IDs, in the given order, + duplicates not collapsed; None when the "text" field is + malformed. + """ + if not isinstance(text, str): + print( + f"warning: {run_dir}: song-{song_id}: \"text\" is not" + " a string, skipped", file=sys.stderr) + return None + selected: Any + try: + selected = json.loads(text) + except json.JSONDecodeError: + print( + f"warning: {run_dir}: song-{song_id}: \"text\" is" + " malformed JSON, skipped", file=sys.stderr) + return None + if not isinstance(selected, list) \ + or not all(isinstance(x, str) for x in selected): + print( + f"warning: {run_dir}: song-{song_id}: \"text\" does" + " not parse to a JSON array of strings, skipped", + file=sys.stderr) + return None + return selected + + +def load_genders(db_path: Path) -> dict[int, str | None]: + """Load the stored performer gender of every song. + + :param db_path: The SQLite working database. + :return: The stored performer gender, keyed by the song ID. + :raises TallyError: When the database cannot be read. + """ + songs: dict[int, tuple[str, str, str | None]] \ + = _load_songs(db_path) + return {x: y[2] for x, y in songs.items()} + + +def _load_songs(db_path: Path) \ + -> dict[int, tuple[str, str, str | None]]: + """Load the title, artist credit, and gender of every song. + + :param db_path: The SQLite working database. + :return: The title, the stored artist credit, and the stored + performer gender of every song, keyed by the song ID. + :raises TallyError: When the database cannot be read. + """ + try: + connection: sqlite3.Connection = sqlite3.connect( + f"file:{db_path.resolve()}?mode=ro", uri=True) + except sqlite3.Error as error: + raise TallyError(str(error)) from error + try: + rows: Any = connection.execute( + "SELECT id, title, artist_credit, performer_gender" + " FROM songs") + return {x[0]: (x[1], x[2], x[3]) for x in rows} + except sqlite3.Error as error: + raise TallyError(str(error)) from error + finally: + connection.close() + + +def clean_ballots( + pooled: dict[int, list[list[str]]], + pattern_ids: set[str], + genders: dict[int, str | None]) \ + -> tuple[dict[int, list[set[str]]], int]: + """Drop the out-of-scope and duplicate items of every ballot. + + Every dropped occurrence is reported on standard error as an + observable side effect. + + :param pooled: The raw ballots of every song, keyed by the + numeric song ID. + :param pattern_ids: The extracted pattern IDs. + :param genders: The stored performer gender of every song + known to the working store, keyed by the song ID; a song + missing here takes every pattern ID prefix. + :return: The cleaned ballots (the applicable, deduplicated + pattern IDs) of every song, keyed by the numeric song ID, + and the total number of dropped occurrences. + """ + cleaned: dict[int, list[set[str]]] = {} + dropped: int = 0 + song_id: int + ballots: list[list[str]] + for song_id, ballots in pooled.items(): + applicable: set[str] = _APPLICABLE_PREFIXES.get( + genders.get(song_id), {"M", "F", "X"}) + cleaned_ballots: list[set[str]] = [] + ballot: list[str] + for ballot in ballots: + kept: set[str] + count: int + kept, count = _clean_one_ballot( + ballot, song_id, pattern_ids, applicable) + cleaned_ballots.append(kept) + dropped += count + cleaned[song_id] = cleaned_ballots + return cleaned, dropped + + +def _clean_one_ballot( + ballot: list[str], song_id: int, pattern_ids: set[str], + applicable: set[str]) -> tuple[set[str], int]: + """Drop the out-of-scope and duplicate items of one ballot. + + :param ballot: The raw selected pattern IDs, in the given + order. + :param song_id: The numeric song ID, for the warning messages. + :param pattern_ids: The extracted pattern IDs. + :param applicable: The pattern ID prefixes applicable to the + song. + :return: The applicable, deduplicated pattern IDs, and the + number of dropped occurrences. + """ + kept: set[str] = set() + dropped: int = 0 + pattern_id: str + for pattern_id in ballot: + if pattern_id in kept: + print( + f"warning: song-{song_id}: dropped duplicate" + f" ballot item \"{pattern_id}\"", file=sys.stderr) + dropped += 1 + continue + if pattern_id not in pattern_ids \ + or pattern_id[:1] not in applicable: + print( + f"warning: song-{song_id}: dropped out-of-scope" + f" ballot item \"{pattern_id}\"", file=sys.stderr) + dropped += 1 + continue + kept.add(pattern_id) + return kept, dropped + + +def tally_votes(cleaned: dict[int, list[set[str]]]) \ + -> dict[int, dict[str, int]]: + """Tally the pattern votes of the cleaned ballots, song by song. + + :param cleaned: The cleaned ballots of every song, keyed by + the numeric song ID. + :return: The settled pattern votes of every song (at least + :data:`MAJORITY` of its cleaned ballots), keyed by the + numeric song ID and then by the pattern ID; a song with no + settled pattern maps to an empty mapping. + """ + tallied: dict[int, dict[str, int]] = {} + song_id: int + ballots: list[set[str]] + for song_id, ballots in cleaned.items(): + counts: dict[str, int] = {} + ballot: set[str] + for ballot in ballots: + pattern_id: str + for pattern_id in ballot: + counts[pattern_id] = counts.get(pattern_id, 0) + 1 + tallied[song_id] = { + x: y for x, y in counts.items() if y >= MAJORITY} + return tallied + + +def write_patterns_csv( + output_csv: Path, patterns: list[Pattern]) -> None: + """Write the pattern table CSV file. + + Writes an RFC 4180 CSV file, UTF-8, with CRLF line endings, + carrying the header row ``Pattern,Group,Name,Description`` and + one row per extracted pattern, in the given order. The parent + directory is created when it does not exist. + + :param output_csv: The output CSV file. + :param patterns: The extracted patterns, in the output order. + :return: None. + :raises OSError: When the file cannot be written. + """ + output_csv.parent.mkdir(parents=True, exist_ok=True) + with open(output_csv, "w", encoding="utf-8", + newline="") as file: + writer: Any = csv.writer(file) + writer.writerow(PATTERNS_HEADER) + writer.writerows( + (x.id, x.group, x.name, x.description) + for x in patterns) + + +def build_annotation_rows( + tallied: dict[int, dict[str, int]], + songs: dict[int, tuple[str, str, str | None]], + patterns: list[Pattern]) \ + -> list[tuple[str, str, str, int]]: + """Build the ordered data rows of the annotation table. + + :param tallied: The settled pattern votes of every song, keyed + by the numeric song ID and then by the pattern ID. + :param songs: The title, the stored artist credit, and the + stored performer gender of every stored song, keyed by the + song ID. + :param patterns: The extracted patterns, in the pattern table + order. + :return: The rows, each the song title, the artist credit, the + pattern ID, and the number of votes, ordered by the + numeric song ID and then by the pattern table order. + :raises TallyError: When a settled song is not in the working + store. + """ + order: dict[str, int] = {x.id: i for i, x in enumerate(patterns)} + rows: list[tuple[str, str, str, int]] = [] + song_id: int + for song_id in sorted(tallied): + votes: dict[str, int] = tallied[song_id] + if len(votes) == 0: + continue + if song_id not in songs: + raise TallyError( + f"song-{song_id}: not in the working store") + title: str + artist_credit: str + title, artist_credit, _ = songs[song_id] + pattern_id: str + for pattern_id in sorted(votes, key=lambda x: order[x]): + rows.append(( + title, artist_credit, pattern_id, + votes[pattern_id])) + return rows + + +def write_annotations_csv( + output_csv: Path, rows: list[tuple[str, str, str, int]]) \ + -> None: + """Write the annotation table CSV file. + + Writes an RFC 4180 CSV file, UTF-8, with CRLF line endings, + carrying the header row ``Song,Artist Credit,Pattern,Votes`` + and one row per settled (song, pattern) pair, in the given + order. The parent directory is created when it does not + exist. + + :param output_csv: The output CSV file. + :param rows: The settled rows, in the output order. + :return: None. + :raises OSError: When the file cannot be written. + """ + output_csv.parent.mkdir(parents=True, exist_ok=True) + with open(output_csv, "w", encoding="utf-8", + newline="") as file: + writer: Any = csv.writer(file) + writer.writerow(ANNOTATIONS_HEADER) + writer.writerows(rows) + + +def main(argv: list[str] | None = None) -> int: + """Settle the pattern annotations by a majority of the runs. + + Writes the pattern table CSV file and the annotation table CSV + file described in the module docstring. Nothing is written + when a synthesis section yields an empty name or description, + a run record is malformed, a song does not appear exactly + :data:`BALLOTS_PER_SONG` times in the pool, or a settled song + is not in the working store; the error message names what + failed. + + :param argv: The command-line arguments, or None for + ``sys.argv``. + :return: The exit status: 0 on success, non-zero on failure. + """ + started: float = time.monotonic() + args: argparse.Namespace = parse_args(argv) + try: + patterns: list[Pattern] = [] + synthesis_dirs: tuple[Path, Path, Path] = ( + args.male_synthesis, args.female_synthesis, + args.mixed_synthesis) + synthesis_dir: Path + group: str + prefix: str + for synthesis_dir, (group, prefix) in zip( + synthesis_dirs, _GROUP_PREFIXES): + patterns.extend( + extract_patterns(synthesis_dir, group, prefix)) + write_patterns_csv(args.patterns_csv, patterns) + pattern_ids: set[str] = {x.id for x in patterns} + pooled: dict[int, list[list[str]]] \ + = load_ballots(args.run_dir) + songs: dict[int, tuple[str, str, str | None]] \ + = _load_songs(args.db_path) + genders: dict[int, str | None] \ + = {x: y[2] for x, y in songs.items()} + cleaned: dict[int, list[set[str]]] + dropped: int + cleaned, dropped = clean_ballots(pooled, pattern_ids, + genders) + tallied: dict[int, dict[str, int]] = tally_votes(cleaned) + rows: list[tuple[str, str, str, int]] \ + = build_annotation_rows(tallied, songs, patterns) + write_annotations_csv(args.annotations_csv, rows) + except (TallyError, OSError) as error: + print(f"error: {error}", file=sys.stderr) + return 1 + elapsed: str = format_duration(time.monotonic() - started) + print( + f"Done. Tallied {len(rows)} settled pairs across" + f" {len(tallied)} songs, {dropped} votes dropped." + f" {elapsed} elapsed.", file=sys.stderr) + return 0 diff --git a/tools/tests/test_tally_annotations.py b/tools/tests/test_tally_annotations.py new file mode 100644 index 0000000..337a07f --- /dev/null +++ b/tools/tests/test_tally_annotations.py @@ -0,0 +1,525 @@ +# Tools for A Feminist Audit of Pop Music. +# Copyright 2026 imacat. All rights reserved. +# Authors: +# imacat@mail.imacat.idv.tw (imacat), 2026/8/15 +# AI assistance: Claude Code (Anthropic) +"""Unit tests for the step-5 annotation tally module.""" +import csv +import io +import json +import tempfile +import unittest +from contextlib import redirect_stderr +from pathlib import Path + +import sqlalchemy as sa +from sqlalchemy.orm import Session + +from pop_fem_audit_tools.commands import tally_annotations +from pop_fem_audit_tools.database import Base +from pop_fem_audit_tools.models import Song + + +class TestTallyAnnotations(unittest.TestCase): + """Test cases for the step-5 pattern annotation tally.""" + + def setUp(self) -> None: + """Create the archive directories, the database, and the + output paths.""" + tmp: tempfile.TemporaryDirectory[str] \ + = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.__dir: Path = Path(tmp.name) + self.__male_synthesis: Path = self.__dir / "male-synthesis" + self.__female_synthesis: Path \ + = self.__dir / "female-synthesis" + self.__mixed_synthesis: Path = self.__dir / "mixed-synthesis" + self.__male_synthesis.mkdir() + self.__female_synthesis.mkdir() + self.__mixed_synthesis.mkdir() + self.__db_path: Path = self.__dir / "working.sqlite3" + self.__patterns_csv: Path \ + = self.__dir / "results" / "patterns.csv" + self.__annotations_csv: Path \ + = self.__dir / "results" / "annotations.csv" + self.__runs: list[Path] = [] + number: int + for number in (1, 2, 3): + run_dir: Path = self.__dir / f"run{number}" + run_dir.mkdir() + self.__runs.append(run_dir) + + def __write_default_synthesis_archives(self) -> None: + """Write the male, female, and mixed synthesis archives. + + The male archive holds two patterns (``M1``, ``M2``), and + the female and mixed archives hold one pattern each + (``F1``, ``X1``). + + :return: None. + """ + self.__write_synthesis(self.__male_synthesis, [ + ("模式一:厭女語彙的常態化", ["描述一。"], ["「引文一」"]), + ("模式二:陰陽權力階序", ["描述二。"], ["「引文二」"])]) + self.__write_synthesis(self.__female_synthesis, [ + ("一、女女敵對", ["描述三。"], ["「引文三」"])]) + self.__write_synthesis(self.__mixed_synthesis, [ + ("模式一:佔有語法", ["描述四。"], ["「引文四」"])]) + + @staticmethod + def __write_synthesis( + synthesis_dir: Path, + sections: list[tuple[str, list[str], list[str]]]) \ + -> None: + """Write one synthesis archive's ``output.jsonl``. + + :param synthesis_dir: The synthesis run's archive + directory. + :param sections: Each pattern's heading text, description + lines, and example quote lines (written with the + ``- `` prefix). + :return: None. + """ + parts: list[str] = [] + heading: str + body: list[str] + quotes: list[str] + for heading, body, quotes in sections: + parts.append(f"## {heading}") + parts.append("") + parts.extend(body) + parts.append("") + parts.extend(f"- {x}" for x in quotes) + parts.append("") + text: str = "\n".join(parts) + record: dict[str, str] = { + "id": "synthesis", "text": text, + "stop_reason": "end_turn"} + (synthesis_dir / "output.jsonl").write_text( + json.dumps(record, ensure_ascii=False) + "\n", + encoding="utf-8") + + def __seed_songs( + self, + songs: list[tuple[int, str, str, str | None]]) -> None: + """Create the working store schema and the fixture songs. + + :param songs: The song ID, title, artist credit, and + stored performer gender of every fixture song. + :return: None. + """ + engine: sa.Engine = sa.create_engine( + f"sqlite:///{self.__db_path}") + Base.metadata.create_all(engine) + session: Session + with Session(engine) as session: + song_id: int + title: str + artist_credit: str + gender: str | None + for song_id, title, artist_credit, gender in songs: + session.add(Song( + id=song_id, title=title, + artist_credit=artist_credit, + performer_gender=gender)) + session.commit() + engine.dispose() + + def __write_run( + self, run_dir: Path, + ballots: dict[int, list[str]]) -> None: + """Write one annotation run's ``output.jsonl``. + + :param run_dir: The run's archive directory. + :param ballots: The selected pattern IDs of every song, + keyed by the numeric song ID. + :return: None. + """ + lines: list[str] = [ + json.dumps({ + "id": f"song-{song_id}", + "text": json.dumps(pattern_ids, ensure_ascii=False), + "stop_reason": "end_turn"}, ensure_ascii=False) + for song_id, pattern_ids in ballots.items()] + (run_dir / "output.jsonl").write_text( + "\n".join(lines) + "\n", encoding="utf-8") + + def __write_same_ballots_to_all_runs( + self, ballots: dict[int, list[str]]) -> None: + """Write the same ballots to all three run directories. + + :param ballots: The selected pattern IDs of every song, + keyed by the numeric song ID. + :return: None. + """ + run_dir: Path + for run_dir in self.__runs: + self.__write_run(run_dir, ballots) + + def __run_tally(self) -> tuple[int, str]: + """Run the tally command against the fixture archives. + + :return: The exit status and the standard error text. + """ + stderr: io.StringIO = io.StringIO() + status: int + with redirect_stderr(stderr): + status = tally_annotations.main([ + str(self.__male_synthesis), + str(self.__female_synthesis), + str(self.__mixed_synthesis), str(self.__db_path), + str(self.__patterns_csv), + str(self.__annotations_csv)] + + [str(x) for x in self.__runs]) + return status, stderr.getvalue() + + @staticmethod + def __read_rows(path: Path) -> list[list[str]]: + """Read a written CSV file back as rows. + + :param path: The CSV file. + :return: The rows, the header row included. + """ + with open(path, encoding="utf-8", newline="") as file: + return list(csv.reader(file)) + + def test_patterns_extracted_in_group_and_section_order( + self) -> None: + """Test that the pattern table lists the male, then + female, then mixed patterns, in section order, the + numbering token stripped and the example quotes + excluded from the description.""" + self.__write_default_synthesis_archives() + self.__seed_songs([(1, "Song A", "Artist A", "male")]) + self.__write_same_ballots_to_all_runs({1: []}) + status: int + status, _ = self.__run_tally() + self.assertEqual(status, 0) + self.assertEqual(self.__read_rows(self.__patterns_csv), [ + ["Pattern", "Group", "Name", "Description"], + ["M1", "male", "厭女語彙的常態化", "描述一。"], + ["M2", "male", "陰陽權力階序", "描述二。"], + ["F1", "female", "女女敵對", "描述三。"], + ["X1", "mixed", "佔有語法", "描述四。"]]) + + def test_empty_pattern_name_fails(self) -> None: + """Test that a heading that is only a numbering token + fails the tally, nothing written.""" + self.__write_synthesis(self.__male_synthesis, [ + ("模式一:", ["描述。"], [])]) + self.__write_synthesis(self.__female_synthesis, [ + ("一、女女敵對", ["描述。"], [])]) + self.__write_synthesis(self.__mixed_synthesis, [ + ("模式一:佔有語法", ["描述。"], [])]) + self.__seed_songs([(1, "Song A", "Artist A", "male")]) + self.__write_same_ballots_to_all_runs({1: []}) + status: int + stderr: str + status, stderr = self.__run_tally() + self.assertNotEqual(status, 0) + self.assertIn("empty name or description", stderr) + self.assertFalse(self.__patterns_csv.exists()) + + def test_empty_pattern_description_fails(self) -> None: + """Test that a section with no description line (only + example quotes) fails the tally, nothing written.""" + self.__write_synthesis(self.__male_synthesis, [ + ("模式一:厭女語彙", [], ["「引文」"])]) + self.__write_synthesis(self.__female_synthesis, [ + ("一、女女敵對", ["描述。"], [])]) + self.__write_synthesis(self.__mixed_synthesis, [ + ("模式一:佔有語法", ["描述。"], [])]) + self.__seed_songs([(1, "Song A", "Artist A", "male")]) + self.__write_same_ballots_to_all_runs({1: []}) + status: int + stderr: str + status, stderr = self.__run_tally() + self.assertNotEqual(status, 0) + self.assertIn("empty name or description", stderr) + self.assertFalse(self.__patterns_csv.exists()) + + def test_song_not_appearing_three_times_fails(self) -> None: + """Test that a song missing from one run's ballots fails + the tally.""" + self.__write_default_synthesis_archives() + self.__seed_songs([(1, "Song A", "Artist A", "male")]) + self.__write_run(self.__runs[0], {1: ["M1"]}) + self.__write_run(self.__runs[1], {1: ["M1"]}) + self.__write_run(self.__runs[2], {}) + status: int + stderr: str + status, stderr = self.__run_tally() + self.assertNotEqual(status, 0) + self.assertIn("song-1", stderr) + self.assertIn("expected", stderr) + self.assertFalse(self.__annotations_csv.exists()) + + def test_ballot_missing_text_field_skipped_then_fails( + self) -> None: + """Test that a ballot record without "text" is skipped + with a warning, leaving the song short of the three + ballots the completeness check requires.""" + self.__write_default_synthesis_archives() + self.__seed_songs([(1, "Song A", "Artist A", "male")]) + self.__write_same_ballots_to_all_runs({1: ["M1"]}) + (self.__runs[0] / "output.jsonl").write_text( + json.dumps({"id": "song-1"}) + "\n", encoding="utf-8") + status: int + stderr: str + status, stderr = self.__run_tally() + self.assertNotEqual(status, 0) + self.assertIn("warning:", stderr) + self.assertIn(str(self.__runs[0]), stderr) + self.assertIn("no \"text\" field", stderr) + self.assertIn("song-1", stderr) + self.assertIn("expected", stderr) + + def test_ballot_text_not_array_of_strings_skipped_then_fails( + self) -> None: + """Test that a "text" that is not a JSON array of strings + is skipped with a warning, leaving the song short of the + three ballots the completeness check requires.""" + self.__write_default_synthesis_archives() + self.__seed_songs([(1, "Song A", "Artist A", "male")]) + self.__write_same_ballots_to_all_runs({1: ["M1"]}) + (self.__runs[0] / "output.jsonl").write_text( + json.dumps({"id": "song-1", + "text": json.dumps({"M1": []})}) + "\n", + encoding="utf-8") + status: int + stderr: str + status, stderr = self.__run_tally() + self.assertNotEqual(status, 0) + self.assertIn("warning:", stderr) + self.assertIn(str(self.__runs[0]), stderr) + self.assertIn("JSON array of strings", stderr) + self.assertIn("song-1", stderr) + self.assertIn("expected", stderr) + + def test_dead_record_rescued_from_extra_run_dir(self) -> None: + """Test that a dead record (malformed "text") in one run + directory is skipped with a warning, and its ballot is + rescued from an extra run directory, tallying + successfully.""" + self.__write_default_synthesis_archives() + self.__seed_songs([(1, "Song A", "Artist A", "male")]) + self.__write_same_ballots_to_all_runs({1: ["M1"]}) + (self.__runs[0] / "output.jsonl").write_text( + json.dumps({"id": "song-1", + "text": json.dumps({"M1": []})}) + "\n", + encoding="utf-8") + rescue_dir: Path = self.__dir / "rescue" + rescue_dir.mkdir() + self.__write_run(rescue_dir, {1: ["M1"]}) + stderr: io.StringIO = io.StringIO() + status: int + with redirect_stderr(stderr): + status = tally_annotations.main([ + str(self.__male_synthesis), + str(self.__female_synthesis), + str(self.__mixed_synthesis), str(self.__db_path), + str(self.__patterns_csv), + str(self.__annotations_csv)] + + [str(x) for x in self.__runs] + + [str(rescue_dir)]) + self.assertEqual(status, 0) + self.assertIn("warning:", stderr.getvalue()) + self.assertIn("JSON array of strings", stderr.getvalue()) + self.assertEqual(self.__read_rows(self.__annotations_csv), [ + ["Song", "Artist Credit", "Pattern", "Votes"], + ["Song A", "Artist A", "M1", "3"]]) + + def test_unknown_pattern_id_dropped_with_warning(self) -> None: + """Test that a selected ID outside the extracted patterns + is dropped, the occurrence reported on standard error.""" + self.__write_default_synthesis_archives() + self.__seed_songs([(1, "Song A", "Artist A", "male")]) + self.__write_same_ballots_to_all_runs( + {1: ["M1", "hallucinated"]}) + status: int + stderr: str + status, stderr = self.__run_tally() + self.assertEqual(status, 0) + self.assertEqual(self.__read_rows(self.__annotations_csv), [ + ["Song", "Artist Credit", "Pattern", "Votes"], + ["Song A", "Artist A", "M1", "3"]]) + self.assertEqual( + stderr.count("dropped out-of-scope ballot item" + " \"hallucinated\""), 3) + + def test_out_of_scope_pattern_dropped_with_warning( + self) -> None: + """Test that a pattern outside the song's gendered scope + is dropped, the occurrence reported on standard error.""" + self.__write_default_synthesis_archives() + self.__seed_songs([(1, "Song A", "Artist A", "male")]) + self.__write_same_ballots_to_all_runs({1: ["M1", "F1"]}) + status: int + stderr: str + status, stderr = self.__run_tally() + self.assertEqual(status, 0) + self.assertEqual(self.__read_rows(self.__annotations_csv), [ + ["Song", "Artist Credit", "Pattern", "Votes"], + ["Song A", "Artist A", "M1", "3"]]) + self.assertEqual( + stderr.count( + "dropped out-of-scope ballot item \"F1\""), 3) + + def test_mixed_pattern_applies_to_every_song(self) -> None: + """Test that a mixed-group pattern ("X" prefix) is in + scope for a male-credited song.""" + self.__write_default_synthesis_archives() + self.__seed_songs([(1, "Song A", "Artist A", "male")]) + self.__write_same_ballots_to_all_runs({1: ["X1"]}) + status: int + stderr: str + status, stderr = self.__run_tally() + self.assertEqual(status, 0) + self.assertEqual(self.__read_rows(self.__annotations_csv), [ + ["Song", "Artist Credit", "Pattern", "Votes"], + ["Song A", "Artist A", "X1", "3"]]) + + def test_unknown_gender_takes_every_pattern_prefix( + self) -> None: + """Test that a song with no stored performer gender + accepts patterns of every group.""" + self.__write_default_synthesis_archives() + self.__seed_songs([(1, "Song A", "Artist A", None)]) + self.__write_same_ballots_to_all_runs({1: ["M1", "F1"]}) + status: int + status, _ = self.__run_tally() + self.assertEqual(status, 0) + self.assertEqual(self.__read_rows(self.__annotations_csv), [ + ["Song", "Artist Credit", "Pattern", "Votes"], + ["Song A", "Artist A", "M1", "3"], + ["Song A", "Artist A", "F1", "3"]]) + + def test_duplicate_ballot_item_dropped_with_warning( + self) -> None: + """Test that a pattern ID listed twice in one ballot is + collapsed, the extra occurrence reported on standard + error.""" + self.__write_default_synthesis_archives() + self.__seed_songs([(1, "Song A", "Artist A", "male")]) + self.__write_run(self.__runs[0], {1: ["M1", "M1"]}) + self.__write_run(self.__runs[1], {1: []}) + self.__write_run(self.__runs[2], {1: []}) + status: int + stderr: str + status, stderr = self.__run_tally() + self.assertEqual(status, 0) + self.assertEqual(self.__read_rows(self.__annotations_csv), + [["Song", "Artist Credit", "Pattern", + "Votes"]]) + self.assertEqual( + stderr.count( + "dropped duplicate ballot item \"M1\""), 1) + + def test_majority_vote_settles_two_of_three(self) -> None: + """Test that a (song, pattern) pair needs at least two of + the three cleaned ballots to settle.""" + self.__write_default_synthesis_archives() + self.__seed_songs([(1, "Song A", "Artist A", "male")]) + self.__write_run(self.__runs[0], {1: ["M1", "M2"]}) + self.__write_run(self.__runs[1], {1: ["M1"]}) + self.__write_run(self.__runs[2], {1: []}) + status: int + status, _ = self.__run_tally() + self.assertEqual(status, 0) + self.assertEqual(self.__read_rows(self.__annotations_csv), [ + ["Song", "Artist Credit", "Pattern", "Votes"], + ["Song A", "Artist A", "M1", "2"]]) + + def test_annotations_sorted_by_song_then_pattern_order( + self) -> None: + """Test the row order: numeric song ID, then pattern in + the male-then-female-then-mixed extraction order.""" + self.__write_default_synthesis_archives() + self.__seed_songs([ + (2, "Song B", "Artist B", "female"), + (10, "Song C", "Artist C", None)]) + self.__write_same_ballots_to_all_runs({ + 10: ["X1", "M1"], 2: ["F1"]}) + status: int + status, _ = self.__run_tally() + self.assertEqual(status, 0) + self.assertEqual(self.__read_rows(self.__annotations_csv), [ + ["Song", "Artist Credit", "Pattern", "Votes"], + ["Song B", "Artist B", "F1", "3"], + ["Song C", "Artist C", "M1", "3"], + ["Song C", "Artist C", "X1", "3"]]) + + def test_output_is_crlf_with_header(self) -> None: + """Test the written bytes of both tables: RFC 4180, CRLF, + header row.""" + self.__write_synthesis(self.__male_synthesis, [ + ("模式一:厭女語彙", ["描述一。"], [])]) + self.__write_synthesis(self.__female_synthesis, [ + ("一、女女敵對", ["描述二。"], [])]) + self.__write_synthesis(self.__mixed_synthesis, [ + ("模式一:佔有語法", ["描述三。"], [])]) + self.__seed_songs([(1, "Song A", "Artist A", "male")]) + self.__write_same_ballots_to_all_runs({1: ["M1"]}) + status: int + status, _ = self.__run_tally() + self.assertEqual(status, 0) + self.assertEqual( + self.__patterns_csv.read_bytes(), + ( + "Pattern,Group,Name,Description\r\n" + "M1,male,厭女語彙,描述一。\r\n" + "F1,female,女女敵對,描述二。\r\n" + "X1,mixed,佔有語法,描述三。\r\n" + ).encode("utf-8")) + self.assertEqual( + self.__annotations_csv.read_bytes(), + b"Song,Artist Credit,Pattern,Votes\r\n" + b"Song A,Artist A,M1,3\r\n") + + def test_song_missing_from_working_store_fails(self) -> None: + """Test that a settled song not stored in the working + store fails the tally, nothing written.""" + self.__write_default_synthesis_archives() + self.__seed_songs([(2, "Song B", "Artist B", "male")]) + self.__write_same_ballots_to_all_runs({1: ["M1"]}) + status: int + stderr: str + status, stderr = self.__run_tally() + self.assertNotEqual(status, 0) + self.assertIn("song-1", stderr) + self.assertIn("not in the working store", stderr) + self.assertFalse(self.__annotations_csv.exists()) + + def test_no_pattern_sections_fails(self) -> None: + """Test that a synthesis document with no heading fails + the tally, nothing written.""" + (self.__male_synthesis / "output.jsonl").write_text( + json.dumps({"id": "synthesis", "text": "no headings"}) + + "\n", encoding="utf-8") + self.__write_synthesis(self.__female_synthesis, [ + ("一、女女敵對", ["描述。"], [])]) + self.__write_synthesis(self.__mixed_synthesis, [ + ("模式一:佔有語法", ["描述。"], [])]) + self.__seed_songs([(1, "Song A", "Artist A", "male")]) + self.__write_same_ballots_to_all_runs({1: []}) + status: int + stderr: str + status, stderr = self.__run_tally() + self.assertNotEqual(status, 0) + self.assertIn("no pattern sections", stderr) + self.assertFalse(self.__patterns_csv.exists()) + + def test_summary_line_reports_counts(self) -> None: + """Test that the closing summary reports the settled + pair, song, and dropped vote counts.""" + self.__write_default_synthesis_archives() + self.__seed_songs([(1, "Song A", "Artist A", "male")]) + self.__write_same_ballots_to_all_runs( + {1: ["M1", "hallucinated"]}) + status: int + stderr: str + status, stderr = self.__run_tally() + self.assertEqual(status, 0) + self.assertIn("Tallied 1 settled pairs across 1 songs", + stderr) + self.assertIn("3 votes dropped", stderr)